Skip to content

/sdlc:task-close-out

Generated from solutions/ontological/skills/task-close-out/SKILL.md.

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/, and delete the task/ branch locally and on origin. Detects the merged PR automatically from the branch name (override with —pr ). Emits a deterministic stdout marker so /sdlc:orchestrate can dispatch on the result.

  • Bash
  • Read
  • Edit

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 is task/<basename> — see ${CLAUDE_PLUGIN_ROOT}conventions/branch-naming.md).
  • Append --dry-run to 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 exact git commit / git push / git worktree remove / git branch -d / git push origin --delete commands — and the skill exits with a DRY-RUN ok pr=#<N> marker instead of DONE. 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.

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 now closed/done on 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-run was 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 a closed/* 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 the planning/* or open/* 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 --pr was 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 as MERGED, but its mergeCommit is not an ancestor of the current origin/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-work first 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, gh is 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.

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> → exit AMBIGUOUS: <candidates> (the op’s candidate list, verbatim).
  • NO TASK FOUND → exit ERROR reason="task file not found: <argument>".

Read the resolved file’s frontmatter. Apply these checks in order:

  1. status starts with closed/ → emit ALREADY-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.
  2. status is in-progress or in-progress/blocked → continue.
  3. Otherwise (any planning/* or open/* value) → emit NOT-IN-PROGRESS reason="status is <current>; task hasn't started" and exit. Close-out is not applicable.

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 1

If 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 --quiet
git -C <main-repo> merge-base --is-ancestor <mergeCommit> origin/main

git 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.

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 and origin are 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 closing

Branch on exit code:

  • Exit 0 — the lease is held at phase=closing with this host as owner. The CLI emits one stdout line in the shape REACQUIRED task=<basename> phase=closing lease_id=<uuid>. Stderr may carry an informational STOLEN ref=<ref> from=<previous-owner> line if the previous lease was expired; that is normal and the run proceeds. Continue to Step 4.
  • Exit 4LEASE-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 marker LEASE-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 5REF-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 with LEASE-MISSING ref=refs/sdlc/tasks/<basename> on stderr and STOP. The operator either runs /sdlc:task-work to 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.

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> into prs: — each --pr-url in 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-mortem stub when the body carries none, so the section the post-mortem flow fills always exists ([[T-5LP4]]);
  • flips statusclosed/done, sets completion_note + last_reviewed (UTC), and clears relevance_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-lifecycle close-done kind and pushes HEAD:main, retrying by re-applying onto the fresh tip if main advanced 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]]).

If .sdlc/worktrees/<basename> exists:

git -C <main-repo> worktree remove --force .sdlc/worktrees/<basename>
git -C <main-repo> worktree prune

If 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.

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.

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 -1 shows the docs(tasks): mark <basename> closed/done commit at the tip.
  • git -C <main-repo> worktree list does 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=deleted

If 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.).

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 4LEASE-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.

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 to origin/main ([[T-PA51-task-state-commits-regen-site-page]], [[B-P502-commit-task-state-and-planning-docs-via-ephemeral-worktrees]]). Everything else is git/gh state management.
  • A reverted-but-still-reachable merge passes the Step 3 reachability check (the revert leaves mergeCommit reachable) and is out of scope for STALE-PR detection.
  • Committing model-generated messages. See ${CLAUDE_PLUGIN_ROOT}conventions/commit-messages.md.