/sdlc:task-close-out
Generated from solutions/ontological/skills/task-close-out/SKILL.md.
Description
Section titled “Description”Close the loop on a task whose PR has merged: flip the task file to
status: closed/done on main with a completion_note citing the merged
PR, tear down the worktree at .sdlc/worktrees/
Allowed tools
Section titled “Allowed tools”BashReadEdit
Source
Section titled “Source”Usage:
/sdlc:task-close-out <slug-or-filename>— close out the named task. Argument resolution mirrors/sdlc:task-work./sdlc:task-close-out <absolute-path-to-task-file>— close out a task by absolute path. Use this form when invoking from another skill.- Append
--pr <N>to override automatic PR detection (default: derive from the most recent merged PR whose head branch istask/<basename>— see${CLAUDE_PLUGIN_ROOT}conventions/branch-naming.md). - Append
--dry-runto rehearse the close-out without executing any side effects. Steps 1–3 run unchanged (resolve the task, gate on status, detect the merged PR); Steps 4–6 print every side effect they would perform — the unified diff for the frontmatter edit, the exactgit commit/git push/git worktree remove/git branch -d/git push origin --deletecommands — and the skill exits with aDRY-RUN ok pr=#<N>marker instead ofDONE. No file is modified, no commit is created, no branch is touched.
Perform the close-out and report the outcome; acting on the result is the caller’s job.
Output contract — deterministic markers
Section titled “Output contract — deterministic markers”Exactly one line on stdout, of the form:
<MARKER> ...Where MARKER is one of:
TASK-CLOSE-OUT-DONE pr=#<N> worktree=<removed|absent> branch=<deleted|kept>— task is nowclosed/doneon main with a completion_note citing the merged PR; worktree and task branch torn down (or noted as absent / intentionally kept).DRY-RUN ok pr=#<N>—--dry-runwas supplied; Steps 1–3 ran unchanged, Steps 4–6 printed each side effect without executing them, and no state was mutated.ALREADY-CLOSED reason="status is <current>"— the task is already in aclosed/*state. No file edits, no commits, no teardown. If the task’s worktree or feat branch was never torn down (an orphan from a close-out that ran before the worktree was created, or a manual close), this skill does NOT reclaim them — orphan recovery belongs to/sdlc:project-cleanup(e.g./sdlc:project-cleanup --scope worktrees), which scans for the leftover worktree/branch and proposes the teardown for approval.NOT-IN-PROGRESS reason="status is <current>; task hasn't started"— status is in theplanning/*oropen/*family. Close-out is not applicable.NO-MERGED-PR reason="no merged PR found for head task/<basename>"— could not auto-detect a merged PR and--prwas not provided. No file edits, no commits, no teardown.STALE-PR pr=#<N> reason="mergeCommit not reachable from origin/main"— a candidate PR was found (via auto-detection or--pr <N>), GitHub reports it asMERGED, but itsmergeCommitis not an ancestor of the currentorigin/main. The match is treated as stale (branch reuse, retargeted PR, reverted merge). No file edits, no commits, no teardown.LEASE-CONFLICT ref=refs/sdlc/tasks/<basename> owner=<other-host-id>— the lease re-acquire (Step 3a) lost the CAS-REPLACE race against another worker. Another/sdlc:task-close-out(or/sdlc:pr-respond) is already operating on this task. No file edits, no commits, no teardown — the caller decides whether to retry or surface the conflict to the operator.LEASE-MISSING ref=refs/sdlc/tasks/<basename>— the lease ref does not exist on the authority. The task was never claimed under the protocol (pre-cutover task) or has already been archived. No file edits, no commits, no teardown — the operator either runs/sdlc:task-workfirst or accepts the close-out as already done.PARTIAL pr=#<N> failed=<which-step>: <reason>— some side effects ran but a later teardown step failed (e.g. remote branch protected, push race lost). The task file is still closed on main; the caller surfaces the partial state for human follow-up.ERROR reason="<diagnostic>"— could not resolve the task file,ghis unavailable, frontmatter validation failed, etc. No state changes were applied.
/sdlc:orchestrate keys off MARKER only; the trailing key=value
fields are for the human reading the digest log.
References:
sdlc task resolve <arg>— resolves the slug/path argument to an absolute path (Step 1). Its spec doc is${CLAUDE_PLUGIN_ROOT}entities/task/file-resolution.md.${CLAUDE_PLUGIN_ROOT}/conventions/commit-messages.md— canonical pattern for multiline commit bodies.
1. Resolve the task file
Section titled “1. Resolve the task file”Run sdlc task resolve <argument> per
${CLAUDE_PLUGIN_ROOT}entities/task/file-resolution.md — the canonical
call + outcome contract. On success (exit 0) capture the absolute path
and basename and continue to Step 2.
This skill uses the non-interactive ambiguous-policy mode — it never prompts. Map each failure to this skill’s markers:
AMBIGUOUS: <candidates>→ exitAMBIGUOUS: <candidates>(the op’s candidate list, verbatim).NO TASK FOUND→ exitERROR reason="task file not found: <argument>".
2. Verify the task is eligible to close
Section titled “2. Verify the task is eligible to close”Read the resolved file’s frontmatter. Apply these checks in order:
statusstarts withclosed/→ emitALREADY-CLOSED reason="status is <current>"and exit. No file edits, no commits, no teardown. If a human hit this because a closed task’s worktree or feat branch was never cleaned up, the recovery path is/sdlc:project-cleanup(e.g./sdlc:project-cleanup --scope worktrees) — it detects the orphaned worktree/branch and proposes teardown for approval. Reclaiming orphans is deliberately NOT this skill’s job: close-out only tears down the scaffolding it created on a live in-progress task, so an already-closed task is a no-op here by design.statusisin-progressorin-progress/blocked→ continue.- Otherwise (any
planning/*oropen/*value) → emitNOT-IN-PROGRESS reason="status is <current>; task hasn't started"and exit. Close-out is not applicable.
3. Detect the merged PR
Section titled “3. Detect the merged PR”If --pr <N> was supplied, accept the value and skip auto-detection.
Otherwise, query GitHub for the most recent merged PR whose head
branch matches task/<basename>:
gh pr list --search "head:task/<basename> is:merged" --state merged --json number,mergedAt,mergeCommit --limit 1If the query returns an empty array, emit
NO-MERGED-PR reason="no merged PR found for head task/<basename>"
and exit. Crucially: no file edits, no commits, no branch deletions
have happened by this point. A misfiring orchestrator that calls
close-out before a PR is actually merged must leave the repo
unchanged.
Confirm the PR is in fact merged via gh pr view <N> --json state —
if state is anything other than MERGED, emit
ERROR reason="PR #<N> state is <state>, expected MERGED".
Reachability check — guard against stale matches
Section titled “Reachability check — guard against stale matches”Guard against a stale gh pr list --search match: branch-name reuse
across PRs (the --limit 1 “most-recent” semantics picks the latest
matching PR regardless of where its merge landed) or a PR merged into
a different branch yields a false positive. A reverted-but-still-
reachable merge is out of scope (see Notes).
Before any file edit, commit, branch deletion, or worktree teardown,
verify the candidate mergeCommit is actually an ancestor of the
current origin/main:
git -C <main-repo> fetch origin main --quietgit -C <main-repo> merge-base --is-ancestor <mergeCommit> origin/maingit merge-base --is-ancestor exits 0 if reachable, 1 if not
(other exit codes are real errors). If exit code is 1, emit:
STALE-PR pr=#<N> reason="mergeCommit not reachable from origin/main"and exit. This check runs before Step 4, so no file edits, no
commits, no branch deletions have happened when the STALE-PR marker
is emitted — a stale match leaves the repo unchanged.
If exit code is anything other than 0 or 1 (the git invocation
itself failed — missing ref, network failure on fetch), emit
ERROR reason="reachability check failed: <stderr>" and exit.
—dry-run rehearsal mode
Section titled “—dry-run rehearsal mode”When --dry-run was supplied, the side effects below MUST NOT mutate
state. Specifically:
- Steps 3a and 7a (
sdlc lease task reacquire,sdlc lease task archive): do NOT run them; print each command verbatim — exact arguments, no placeholders. - Step 4 (
sdlc task close-commit): run it WITH--dry-run. The op creates its ephemeral worktree, applies the closing mutation there, prints the unified diff of the change, and tears the worktree down WITHOUT committing or pushing — the shared checkout andoriginare untouched. Relay the op’s diff. The op owns the dry-run diff; the skill no longer renders it by hand. - Steps 5–6 (
git worktree remove,git branch -d/git branch -D,git push origin --delete): do NOT run them; print each verbatim.
Make no Edit tool calls against the task file. The reachability
check above still runs in --dry-run mode (it’s read-only). If it
emits STALE-PR, exit with that marker; do not fall through to the
dry-run prints.
After Steps 3a/4–6/7a’s rehearsal prints, emit DRY-RUN ok pr=#<N>
and exit. Do NOT fall through to Step 7’s verification.
The non-rehearsal path (no --dry-run flag) is unchanged from the
prose below: every command runs for real, and the DONE /
PARTIAL / ERROR markers from Step 7 still apply.
3a. Re-acquire the lease into the closing phase
Section titled “3a. Re-acquire the lease into the closing phase”Before any destructive operation runs (worktree teardown, branch
deletion, frontmatter flip to closed/done, completion_note write),
re-acquire the task-lifecycle lease so close-out holds the lease ref
for the duration of the side effects. The re-acquire is a
CAS-REPLACE-with-steal-on-expired (per the ADR’s
protocol.md#steal-an-expired-lease section): a stale lease whose
expires_at has elapsed is taken over with an informational
STOLEN ref=<ref> from=<previous-owner> stderr marker (no operator
decision required), but a fresh lease held by another worker that
loses the CAS race raises LeaseConflict.
Shell out to the lease CLI; branch on its exit codes:
${CLAUDE_PLUGIN_ROOT}cli/sdlc lease task reacquire <basename> --phase closingBranch on exit code:
- Exit 0 — the lease is held at
phase=closingwith this host as owner. The CLI emits one stdout line in the shapeREACQUIRED task=<basename> phase=closing lease_id=<uuid>. Stderr may carry an informationalSTOLEN ref=<ref> from=<previous-owner>line if the previous lease was expired; that is normal and the run proceeds. Continue to Step 4. - Exit 4 —
LEASE-CONFLICT ref=<ref> reason=owner=<other-host-id>on stderr. Another/sdlc:task-close-out(or/sdlc:pr-respond) is already operating on this task. Exit cleanly with the markerLEASE-CONFLICT ref=refs/sdlc/tasks/<basename> owner=<other-host-id>on stderr and STOP. No destructive operation runs — the task file is untouched, the worktree is intact, the branches are intact. The caller decides whether to retry or surface the conflict. - Exit 5 —
REF-NOT-FOUND ref=refs/sdlc/tasks/<basename>on stderr. The lease ref does not exist (the task was never claimed under the protocol, or it was already archived). Exit cleanly withLEASE-MISSING ref=refs/sdlc/tasks/<basename>on stderr and STOP. The operator either runs/sdlc:task-workto claim the lease first or accepts the close-out as already done. - Exit 1 / other — surface the CLI’s stderr to the operator and STOP.
The re-acquire is the entry gate; there is no path that skips it.
4. Mark the task closed on main
Section titled “4. Mark the task closed on main”Land the closing state on main through a single op that performs the
whole mutation + commit inside an EPHEMERAL WORKTREE created off
origin/main. The shared primary checkout is never read or written,
so a parallel session’s uncommitted edits cannot contaminate the close
commit and no --autostash whole-tree sweep occurs
([[B-P502-commit-task-state-and-planning-docs-via-ephemeral-worktrees]]).
Gather EVERY PR URL the task produced, oldest first. This close is the
only moment prs: is written ([[T-IVEJ-prs-once-at-close]]) — in
flight the live PR binding is the lease’s pr_number, not the task
file — so a URL left out here is lost from the record. One call lists
them all, the merged PR from Step 3 included:
gh pr list --head task/<basename> --state all --json url,createdAt \ -q 'sort_by(.createdAt) | .[].url'Pass each URL as its own --pr-url (repeatable, order preserved):
${CLAUDE_PLUGIN_ROOT}cli/sdlc task close-commit <basename> \ --completion-note "Shipped via #<N>." \ --today <today-UTC> \ --pr-url <pr-url> [--pr-url <pr-url> ...] \ --project-root <main-repo>Inside the ephemeral worktree off origin/main, the op:
- verifies/appends
<pr-url>intoprs:— each--pr-urlin the order given, idempotent against URLs already listed, so a re-run adds nothing. It folds a one-line addendum per landed URL into the commit body automatically; - plants the
## Post-mortemstub when the body carries none, so the section the post-mortem flow fills always exists ([[T-5LP4]]); - flips
status→closed/done, setscompletion_note+last_reviewed(UTC), and clearsrelevance_note/readiness_verified_at; - regenerates the derived generated-docs FROM THE CLEAN TREE (so the roster / index never bake in a sibling session’s uncommitted state) and stages exactly the task file + the regenerated artifacts ([[T-PA51-task-state-commits-regen-site-page]]);
- commits via the
task-lifecycleclose-donekind and pushesHEAD:main, retrying by re-applying onto the fresh tip ifmainadvanced under a parallel push (deterministic regen off the new tip, so the retry never conflicts on the union artifacts).
The op prints
CLOSE-COMMIT pushed=<sha> prs=<noop|create|append> prs_count=<N> on
stderr and its structured result on stdout, where <N> is the final
length of prs:. Both are for the digest log only — the
TASK-CLOSE-OUT-DONE marker (Step 7) is identical across all three
actions.
On non-zero exit the close did NOT land: the worktree push is atomic,
so origin/main is unchanged and the shared checkout was never
touched. Surface the op’s stderr, emit
PARTIAL pr=#<N> failed=close-commit: <reason>, and STOP without
teardown so the caller can retry from a known state.
The body ## Post-mortem is filled by /sdlc:task-work Step 8 before
the PR merges. A task that reaches close without one gets the empty
stub from close-commit (above) — that stub is the ONLY body write the
close makes. Any other post-merge body addendum belongs on its own
commit to main, not in the close-commit.
Note: close-commit pushes only the close commit to origin/main; it
does not flush unrelated state commits piled on the local main
checkout (start/verify commits land there until the rest of
[[B-P502-commit-task-state-and-planning-docs-via-ephemeral-worktrees]]
moves them onto the same path). Local main trailing origin/main
after close-out is expected and benign — the task-state-origin lint
tolerates it
([[T-6R73-pre-commit-drift-hooks-gate-unconditionally-forcing-no]]).
5. Tear down the worktree
Section titled “5. Tear down the worktree”If .sdlc/worktrees/<basename> exists:
git -C <main-repo> worktree remove --force .sdlc/worktrees/<basename>git -C <main-repo> worktree pruneIf the worktree directory is absent, skip silently (a task closed
manually with its worktree forgotten is a valid reconciliation case,
not an error) and mark worktree=absent in the final marker;
otherwise mark worktree=removed.
6. Delete the merged branches
Section titled “6. Delete the merged branches”Local branch:
git -C <main-repo> branch -d task/<basename>-d (lowercase) refuses if the branch isn’t merged. If -d
complains the branch isn’t merged but gh pr view <N> --json state
confirms MERGED, the PR was squash-merged (the original branch tip
is not in main’s history). In that case, and only that case, use -D
to force-delete.
Remote branch: GitHub’s “auto-delete head branches on merge” setting
usually handles this. If git -C <main-repo> ls-remote origin 'task/<basename>' still has output:
git -C <main-repo> push origin --delete task/<basename>Follow with git -C <main-repo> fetch --prune to clear the local
remote-tracking ref.
Record branch=deleted on the final marker if both the local and
remote branches were removed (or never existed). Record branch=kept
and surface as PARTIAL if either deletion failed.
7. Verify and emit
Section titled “7. Verify and emit”Skipped under --dry-run: the rehearsal exits at the end of Step 6
with DRY-RUN ok pr=#<N> and never reaches this verification step.
Before exiting, confirm:
git -C <main-repo> log origin/main --oneline -1shows thedocs(tasks): mark <basename> closed/donecommit at the tip.git -C <main-repo> worktree listdoes not include<basename>.git -C <main-repo> branch --list '<prefix>/<basename>'is empty.git -C <main-repo> ls-remote origin '<prefix>/<basename>'is empty.
If every check passes, emit:
TASK-CLOSE-OUT-DONE pr=#<N> worktree=<removed|absent> branch=deletedIf one or more teardown checks failed (but the task file is closed on main), emit:
PARTIAL pr=#<N> failed=<step>: <reason>Exit code is 0 for every defined marker — including ALREADY-CLOSED,
NOT-IN-PROGRESS, NO-MERGED-PR, PARTIAL, LEASE-CONFLICT,
LEASE-MISSING, and ERROR. Non-zero exit is reserved for
catastrophic failures (the sdlc CLI itself unavailable, etc.).
7a. Archive the lease (terminal step)
Section titled “7a. Archive the lease (terminal step)”After every close-out side effect has completed (worktree torn down
in Step 5, branches deleted in Step 6, frontmatter flipped to
closed/done and pushed in Step 4, verification passed in Step 7),
archive the task-lifecycle lease as the protocol’s terminal step.
This transitions the lease ref from active at
refs/sdlc/tasks/<basename> to archived at
refs/sdlc/archive/tasks/<basename>, retaining its lifecycle history
for audit.
Shell out to the lease CLI; branch on its exit codes:
${CLAUDE_PLUGIN_ROOT}cli/sdlc lease task archive <basename>Idempotent — safe to re-run if a prior close-out crashed mid-step. A re-run that finds the archive already written and the active ref already gone resolves to a clean exit-0 no-op, so the whole close-out flow is safe to retry after a partial-failure window without manual lease surgery.
Branch on exit code:
- Exit 0 — the lease was archived (fresh, or an idempotent re-run
that found it already archived). Stdout marker:
ARCHIVED task=<basename> archive_ref=refs/sdlc/archive/tasks/<basename>. - Exit 4 —
LEASE-CONFLICT ref=<ref> reason=owner=<other-host-id>on stderr. Another worker re-acquired the lease between Step 3a and this archive step (a long stall plus an expired lease). Surface to the operator and stop. - Exit 1 / other — surface the CLI’s stderr and stop.
Archive is the last protocol-level step.
Failure modes
Section titled “Failure modes”If gh is unavailable or the user is unauthenticated, Step 3 will
fail; emit ERROR reason="gh: <stderr>" and exit without any state
changes.
If task close-commit (Step 4) rejects the closing frontmatter at the
schema gate, the op writes nothing to origin and exits non-zero;
surface its stderr and emit
PARTIAL pr=#<N> failed=close-commit: <reason>. Nothing landed — the
commit happens in an ephemeral worktree, so a gate failure leaves
origin/main and the shared checkout untouched.
task close-commit commits + pushes from an ephemeral worktree off
origin/main, so a concurrent close-out that advances main only
yields a non-fast-forward push reject — the op recovers by re-applying
onto the fresh tip (no --autostash, no shared-tree rebase, so no
task-file conflict). If it still cannot land after its bounded retries
it exits non-zero; emit
PARTIAL pr=#<N> failed=close-commit: push lost the race and stop.
- This skill hand-edits no files:
task close-commit(Step 4) does the task-file mutation AND the derived generated-docs regeneration inside an ephemeral worktree, then commits + pushes toorigin/main([[T-PA51-task-state-commits-regen-site-page]], [[B-P502-commit-task-state-and-planning-docs-via-ephemeral-worktrees]]). Everything else isgit/ghstate management. - A reverted-but-still-reachable merge passes the Step 3 reachability
check (the revert leaves
mergeCommitreachable) and is out of scope forSTALE-PRdetection. - Committing model-generated messages. See
${CLAUDE_PLUGIN_ROOT}conventions/commit-messages.md.