plugins/dev/skills/phase-teardown/SKILL.md
--- name: phase-teardown description: Phase agent for the 10th/terminal step of the pipeline (CTL-703). Performs all terminal wrap-up after monitor-deploy — verifies the PR merged, posts a final Linear comment with per-phase timings, transitions Linear to Done (the sole Done writer now), archives the worker dir to ~/catalyst/archives/<TICKET>/, removes the local worktree + branch, then emits phase.teardown.complete.<TICKET>. Reads phase-monitor-deploy.json as its prior-phase artifact. Dispatched
npx skillsauth add coalesce-labs/catalyst plugins/dev/skills/phase-teardownInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Terminal phase of the Catalyst pipeline (CTL-703). Runs after phase-monitor-deploy
has confirmed the deployed canary. Performs all end-of-lifecycle housekeeping:
safety-gates on merge status, posts a per-phase timing summary to Linear, transitions
the ticket to Done, archives the worker dir, and removes the local worktree + branch.
Environment:
TICKET — Linear identifier (e.g. CTL-703). Required.ORCH_DIR — orchestrator working directory; signal files live under
${ORCH_DIR}/workers/${TICKET}/. Defaults to $CATALYST_ORCHESTRATOR_DIR.ORCH_ID — orchestrator instance ID. Defaults to $CATALYST_ORCHESTRATOR_ID.PLUGIN_ROOT — resolved from $CLAUDE_PLUGIN_ROOT or the skill's own dir tree.Signal files read (all under ${ORCH_DIR}/workers/${TICKET}/):
phase-monitor-deploy.json — required prior-artifact; missing → fail.phase-monitor-merge.json — required for safety gate (.pr.mergedAt / .pr.ciStatus).phase-*.json — all present signal files are read for per-phase timing table./goal "The pipeline for ${TICKET} has been fully wrapped-up: the PR is confirmed
merged, a per-phase timing summary has been posted to Linear, Linear is
transitioned to Done, the worker dir is archived to ~/catalyst/archives/${TICKET}/,
the local worktree and branch are removed, and phase.teardown.complete.${TICKET}
has been emitted to the event log."
set -uo pipefail
# ─── Resolver block (zsh-safe) ───────────────────────────────────────────────
__TD_SCRIPT_PATH="${BASH_SOURCE[0]:-${0}}"
__TD_SKILL_DIR="$(cd "$(dirname "$__TD_SCRIPT_PATH")" && pwd 2>/dev/null || pwd)"
__TD_REPO_ROOT="${PHASE_AGENT_REPO_ROOT:-$(cd "$__TD_SKILL_DIR/../../../.." 2>/dev/null && pwd || pwd)}"
__TD_LIB="${PHASE_EMIT_HELPER:-${__TD_REPO_ROOT}/plugins/dev/scripts/lib/phase-emit-complete.sh}"
__TD_WRAPPER="${PHASE_EMIT_WRAPPER:-${__TD_REPO_ROOT}/plugins/dev/scripts/phase-agent-emit-complete}"
if [[ ! -r "$__TD_LIB" ]]; then
echo "phase-teardown: cannot find phase-emit-complete.sh at $__TD_LIB" >&2
exit 1
fi
# shellcheck disable=SC1090
. "$__TD_LIB"
if [[ ! -x "$__TD_WRAPPER" ]]; then
echo "phase-teardown: cannot find phase-agent-emit-complete wrapper at $__TD_WRAPPER" >&2
exit 1
fi
: "${TICKET:?phase-teardown: TICKET env var required}"
# Re-validate the ticket-ID shape before TICKET is interpolated into archive /
# worker paths. phase-agent-dispatch validates this, but a standalone invocation
# does not — without it a crafted TICKET could path-traverse the archive dest.
if ! printf '%s' "$TICKET" | grep -Eq '^[A-Za-z][A-Za-z0-9_]*-[0-9]+$'; then
echo "phase-teardown: invalid TICKET '$TICKET' (expected e.g. CTL-703)" >&2
exit 1
fi
# Trust the command arg / env over leaked CATALYST_* from a sibling dispatch
# (per memory: phase_env_ticket_leak_from_sibling). ORCH_DIR/ORCH_ID come from
# CATALYST_* but we pass --orch-id explicitly on the emit call below.
ORCH_DIR="${ORCH_DIR:-${CATALYST_ORCHESTRATOR_DIR:-}}"
ORCH_ID="${ORCH_ID:-${CATALYST_ORCHESTRATOR_ID:-}}"
# WORKER_DIR: canonical location for all signal files.
WORKER_DIR="${ORCH_DIR:+${ORCH_DIR}/workers/${TICKET}}"
WORKER_DIR="${WORKER_DIR:-$(pwd)}"
mkdir -p "$WORKER_DIR"
# Signal file — must exist (written by phase-agent-dispatch when it dispatches
# this phase). If missing, the wrapper will warn but proceed; the emit will
# still land the event.
SIGNAL_FILE="${WORKER_DIR}/phase-teardown.json"
# Resolve PLUGIN_ROOT (scripts dir) — used for linear-transition.sh, presweep.
PLUGIN_ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-}}"
if [[ -z "$PLUGIN_ROOT" ]]; then
PLUGIN_ROOT="$(cd "$__TD_SKILL_DIR/../.." 2>/dev/null && pwd || echo "")"
fi
# ─── Safety gate: require PR merged ──────────────────────────────────────────
# Read phase-monitor-deploy.json (prior-phase artifact).
DEPLOY_FILE="$WORKER_DIR/phase-monitor-deploy.json"
if [[ ! -f "$DEPLOY_FILE" ]]; then
"$__TD_WRAPPER" --phase teardown --ticket "$TICKET" --status failed \
--reason "prior_artifact_missing:monitor_deploy" \
${ORCH_ID:+--orch-id "$ORCH_ID"} ${ORCH_DIR:+--orch-dir "$ORCH_DIR"} \
|| echo "phase-teardown: CRITICAL — phase-agent-emit-complete failed; no terminal teardown event landed" >&2
exit 1
fi
# Read phase-monitor-merge.json for the merge confirmation.
MERGE_FILE="$WORKER_DIR/phase-monitor-merge.json"
if [[ ! -f "$MERGE_FILE" ]]; then
"$__TD_WRAPPER" --phase teardown --ticket "$TICKET" --status failed \
--reason "prior_artifact_missing:monitor_merge" \
${ORCH_ID:+--orch-id "$ORCH_ID"} ${ORCH_DIR:+--orch-dir "$ORCH_DIR"} \
|| echo "phase-teardown: CRITICAL — phase-agent-emit-complete failed; no terminal teardown event landed" >&2
exit 1
fi
MERGE_CI_STATUS="$(jq -r '.pr.ciStatus // empty' "$MERGE_FILE" 2>/dev/null)"
MERGED_AT="$(jq -r '.pr.mergedAt // empty' "$MERGE_FILE" 2>/dev/null)"
if [[ "$MERGE_CI_STATUS" != "merged" && -z "$MERGED_AT" ]]; then
"$__TD_WRAPPER" --phase teardown --ticket "$TICKET" --status failed \
--reason "pr_not_merged" \
${ORCH_ID:+--orch-id "$ORCH_ID"} ${ORCH_DIR:+--orch-dir "$ORCH_DIR"} \
|| echo "phase-teardown: CRITICAL — phase-agent-emit-complete failed; no terminal teardown event landed" >&2
exit 1
fi
# ─── Per-phase timings ────────────────────────────────────────────────────────
# Loop all phase-*.json signal files; compute completedAt - startedAt for each.
# Build a markdown table for the mirror comment.
TIMING_TABLE="| Phase | Duration |
|-------|----------|"
for signal_f in "$WORKER_DIR"/phase-*.json; do
[[ -f "$signal_f" ]] || continue
phase_name="$(basename "$signal_f" .json | sed 's/^phase-//')"
started="$(jq -r '.startedAt // empty' "$signal_f" 2>/dev/null || true)"
completed="$(jq -r '.completedAt // empty' "$signal_f" 2>/dev/null || true)"
if [[ -n "$started" && -n "$completed" ]]; then
dur_secs="$(jq -n \
--arg s "$started" --arg c "$completed" \
'(($c|fromdateiso8601) - ($s|fromdateiso8601)) | floor' 2>/dev/null || echo "")"
if [[ "$dur_secs" =~ ^[0-9]+$ ]]; then
dur_h=$(( dur_secs / 3600 ))
dur_m=$(( (dur_secs % 3600) / 60 ))
dur_s=$(( dur_secs % 60 ))
if [[ "$dur_h" -gt 0 ]]; then
dur_str="${dur_h}h ${dur_m}m"
elif [[ "$dur_m" -gt 0 ]]; then
dur_str="${dur_m}m ${dur_s}s"
else
dur_str="${dur_s}s"
fi
else
dur_str="_unknown_"
fi
else
dur_str="_unknown_"
fi
TIMING_TABLE="${TIMING_TABLE}
| ${phase_name} | ${dur_str} |"
done
# ─── Linear Done transition ───────────────────────────────────────────────────
# THIS IS THE ONLY Done writer when phase-teardown is in the pipeline.
# Called while still in the ticket worktree so .catalyst/config.json is adjacent.
LINEAR_TRANSITION="${PLUGIN_ROOT}/scripts/linear-transition.sh"
if [[ -x "$LINEAR_TRANSITION" ]]; then
# Capture rc + stderr instead of suppressing them: linear-transition.sh can
# print "transitioned" even when the underlying linearis update fails (memory:
# linear_transition_silent_success), so a silent failure here would leave the
# ticket at PR/inReview while the pipeline reports success. Non-fatal — the
# scheduler's terminalDoneOnce backstop (fires on teardown===done) retries the
# Done write — but the failure must be LOUD so it is diagnosable.
LINEAR_DONE_OUT="$("$LINEAR_TRANSITION" --ticket "$TICKET" --transition done \
--config .catalyst/config.json 2>&1)"
LINEAR_DONE_RC=$?
if [[ $LINEAR_DONE_RC -ne 0 ]]; then
echo "phase-teardown: Linear Done transition FAILED (rc=${LINEAR_DONE_RC}) — terminalDoneOnce backstop will retry: ${LINEAR_DONE_OUT}" >&2
else
echo "phase-teardown: Linear Done transition: ${LINEAR_DONE_OUT}"
fi
else
echo "phase-teardown: linear-transition.sh not found at $LINEAR_TRANSITION; skipping Done transition" >&2
fi
# ─── Archive worker dir ───────────────────────────────────────────────────────
# Archive-first contract (CTL-791): copy signal files to
# ~/catalyst/archives/<TICKET>/ BEFORE any destructive step. ARCHIVE_OK gates
# the worktree-removal block below — if the archive failed we keep the worktree
# (its artifacts are the only remaining copy) and continue to the mirror/emit.
ARCHIVE_DIR="${HOME}/catalyst/archives/${TICKET}"
ARCHIVE_OK="false"
if mkdir -p "$ARCHIVE_DIR" 2>/dev/null; then
if cp -R "${WORKER_DIR}/." "$ARCHIVE_DIR/" 2>/dev/null; then
echo "phase-teardown: worker dir archived to $ARCHIVE_DIR"
ARCHIVE_OK="true"
else
echo "phase-teardown: archive cp failed — worktree removal will be SKIPPED (archive-first contract)" >&2
fi
else
echo "phase-teardown: cannot create archive dir $ARCHIVE_DIR — worktree removal will be SKIPPED" >&2
fi
# ─── Worktree + branch removal ────────────────────────────────────────────────
# Gate on keepWorktreeAfterMerge != true (same pattern as phase-monitor-merge
# CTL-649 teardown block). This skill runs INSIDE the worktree it is about to
# remove; cd to the primary worktree first.
KEEP_WT="$(jq -r '.catalyst.orchestration.keepWorktreeAfterMerge // false' \
.catalyst/config.json 2>/dev/null || echo "false")"
# Archive-first gate: never remove the worktree when the archive step above did
# not complete — the worker dir / worktree artifacts would be lost (CTL-791).
if [[ "${ARCHIVE_OK:-false}" != "true" ]]; then
echo "phase-teardown: archive did not complete; auto-teardown skipped (worktree kept)" >&2
elif [[ "$KEEP_WT" != "true" ]]; then
WORKTREE_PATH="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
PRIMARY_WT="$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')"
BRANCH_NAME="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
if [[ -z "$PRIMARY_WT" || "$PRIMARY_WT" == "$WORKTREE_PATH" ]]; then
echo "phase-teardown: cannot resolve primary worktree distinct from self; auto-teardown skipped" >&2
else
cd "$PRIMARY_WT" || {
echo "phase-teardown: cannot cd to primary worktree; auto-teardown skipped" >&2
cd "$WORKTREE_PATH" # restore, even on failure
}
if [[ "$PWD" == "$PRIMARY_WT" ]]; then
PRESWEEP_BIN="${PLUGIN_ROOT}/scripts/lib/worktree-presweep.sh"
# CTL-649: do NOT swallow presweep stderr — its "N session(s) still alive
# in <path>" diagnostic is the precise leak signal this teardown exists to
# surface. Let it flow straight through to the operator.
# FAIL CLOSED: removal proceeds ONLY when the presweep liveness check
# actually ran and passed. A missing/non-executable presweep helper must
# NOT fall through to an ungated `git worktree remove` — that can yank a
# worktree from under a live claude --bg session (the CTL-649 leak class).
if [[ ! -x "$PRESWEEP_BIN" ]]; then
echo "phase-teardown: worktree-presweep.sh missing/non-executable at $PRESWEEP_BIN; auto-teardown skipped (fail-closed)" >&2
elif ! "$PRESWEEP_BIN" "$WORKTREE_PATH"; then
echo "phase-teardown: presweep failed for $WORKTREE_PATH; auto-teardown skipped" >&2
else
# Capture the real `git worktree remove` stderr so a failed teardown
# reports the actual cause (dirty tree, locked, submodule, etc.) rather
# than guessing. The merge is NEVER rolled back — we only warn + skip.
WT_RM_ERR="$(git worktree remove "$WORKTREE_PATH" 2>&1)"
if [[ $? -eq 0 ]]; then
if [[ -n "$BRANCH_NAME" ]]; then
git branch -D "$BRANCH_NAME" 2>/dev/null \
|| echo "phase-teardown: local branch $BRANCH_NAME already gone" >&2
fi
echo "phase-teardown: auto-teardown complete (worktree + branch removed)"
else
echo "phase-teardown: git worktree remove failed; auto-teardown skipped (merge left intact): ${WT_RM_ERR}" >&2
fi
fi
fi
fi
fi
# ─── End block: Linear mirror comment ────────────────────────────────────────
# Post a final summary to Linear (idempotent via marker). Uses ABSOLUTE signal
# paths — the worktree may be gone by now. Guard against double-posting by
# querying linearis issues discussions first (per memory:
# phase_mirror_marker_lost_on_rewalk), then falling back to the marker file.
LINEAR_MIRROR_MARKER="${WORKER_DIR}/.linear-mirror-teardown"
ARCHIVE_PATH="${HOME}/catalyst/archives/${TICKET}"
if [[ ! -e "${LINEAR_MIRROR_MARKER}" ]] && command -v linearis >/dev/null 2>&1; then
WORKTREE_STATUS="removed"
if [[ "${KEEP_WT:-false}" == "true" ]]; then
WORKTREE_STATUS="kept (keepWorktreeAfterMerge=true)"
fi
MIRROR_BODY="$(cat <<EOF
**Phase Teardown** — pipeline complete for \`${TICKET}\`
### Per-phase timings
${TIMING_TABLE}
### Post-merge housekeeping
- **Linear**: transitioned to Done
- **Worktree**: ${WORKTREE_STATUS}
- **Archive**: \`${ARCHIVE_PATH}\`
_Posted automatically by phase-teardown (CTL-703)._
EOF
)"
ORCH_DIR_RESOLVED="${ORCH_DIR:-}"
FOOTER_BIN="${__TD_REPO_ROOT}/plugins/dev/scripts/lib/phase-mirror-footer.sh"
if [[ -n "${ORCH_DIR_RESOLVED}" && -x "${FOOTER_BIN}" ]]; then
MIRROR_FOOTER="$("${FOOTER_BIN}" --orch-dir "${ORCH_DIR_RESOLVED}" --ticket "${TICKET}" --phase "teardown" 2>/dev/null || true)"
[[ -n "${MIRROR_FOOTER}" ]] && MIRROR_BODY="${MIRROR_BODY}
${MIRROR_FOOTER}"
fi
COMMENT_POST="${CATALYST_COMMENT_POST_HELPER:-${PLUGIN_ROOT}/scripts/lib/linear-comment-post.sh}"
if [[ ! -x "$COMMENT_POST" ]]; then
COMMENT_POST="$(command -v linear-comment-post.sh 2>/dev/null || true)"
fi
if [[ -n "$COMMENT_POST" && -x "$COMMENT_POST" ]] && \
"$COMMENT_POST" "${TICKET}" "${MIRROR_BODY}" >/dev/null 2>&1; then
: > "${LINEAR_MIRROR_MARKER}"
else
echo "phase-teardown: linear-comment-post failed (continuing)" >&2
fi
fi
# ─── Emit canonical phase event ──────────────────────────────────────────────
# Pass --orch-id explicitly to avoid CATALYST_* leak from a sibling dispatch
# (per memory: phase_env_ticket_leak_from_sibling). Loud (non-fatal) diagnostic
# if the emitter itself fails — otherwise the ticket stalls with the failure of
# the surfacing mechanism itself unobservable.
"$__TD_WRAPPER" --phase teardown --ticket "$TICKET" --status complete \
${ORCH_ID:+--orch-id "$ORCH_ID"} ${ORCH_DIR:+--orch-dir "$ORCH_DIR"} \
|| echo "phase-teardown: CRITICAL — phase-agent-emit-complete failed; no terminal teardown event landed" >&2
exit 0
# TEMPLATE fence — invoked by the agent ad-hoc on a fatal error, NOT part of
# the sequential body (the e2e harness excludes this fence by name: after the
# emit block's `exit 0` it would be unreachable dead code in a concatenated run).
# Called when a fatal error is detected before the emit block.
# $1 = reason string
_REASON="${1:-phase-teardown fatal error}"
"$__TD_WRAPPER" --phase teardown --ticket "$TICKET" --status failed \
--reason "$_REASON" \
${ORCH_ID:+--orch-id "$ORCH_ID"} ${ORCH_DIR:+--orch-dir "$ORCH_DIR"} \
|| echo "phase-teardown: CRITICAL — phase-agent-emit-complete failed; no terminal teardown event landed" >&2
exit 1
Failure modes that emit phase.teardown.failed.${TICKET}:
prior_artifact_missing:monitor_deploy — phase-monitor-deploy.json absent.prior_artifact_missing:monitor_merge — phase-monitor-merge.json absent.pr_not_merged — safety gate: merge confirmation missing.testing
Phase-agent that fixes a failing verify verdict so the pipeline self-heals instead of stalling to needs-human (CTL-653). Reads `${ORCH_DIR}/workers/<ticket>/verify.json`, fixes the `findings[]` (every severity:"high" plus the regression_risk drivers) directly via Edit/Write, commits the remediation, and emits `phase.remediate.complete.<ticket>`. The scheduler's router then re-dispatches `verify` to re-check (the verify⇄remediate cycle, cap 3). Dispatched as a `claude --bg` job by `phase-agent-dispatch`, which invokes it via slash command — hence `user-invocable: true`.
tools
--- name: phase-triage description: Phase agent that triages a Linear ticket — expands acronyms, classifies (feature/bug/docs/refactor/chore), identifies genuine blockers (a semantic second-pass over the backlog — NOT a prose scrape; CTL-838), estimates scope, writes triage.json, and posts a triage analysis comment to Linear. Triage completion is signaled by that comment plus the local triage.json — there is no `triaged` label. Emits phase.triage.complete.<TICKET> on success and phase.triage.fai
tools
Phase agent for the research step of the 9-phase orchestrator pipeline (CTL-450). Wraps /catalyst-dev:research-codebase and produces thoughts/shared/research/<date>-<ticket>.md, then emits phase.research.complete.<ticket>. Reads triage.json from the worker dir as its prior-phase artifact. Spawned via plugins/dev/scripts/phase-agent-dispatch, which invokes it via slash command — hence `user-invocable: true`.
development
Phase-agent wrapper that opens the pull request after implementation completes (CTL-449 Initiative 1 Phase 3). Delegates to `/catalyst-dev:create-pr` (which already auto-runs `describe-pr` and transitions Linear to `inReview`), then writes the PR number + URL into the phase signal file so the downstream `phase-monitor-merge` agent can read it without re-querying GitHub. Dispatched as a `claude --bg` job by `phase-agent-dispatch`, which invokes it via slash command — hence `user-invocable: true`.