plugins/dev/skills/phase-monitor-deploy/SKILL.md
--- name: phase-monitor-deploy description: Phase agent that watches the post-merge deployment for a ticket. Reads the merge SHA from phase-monitor-merge.json (the signal file phase-monitor-merge writes after `gh pr merge` confirms via REST), subscribes via `catalyst-events wait-for` to deploy events on that SHA, then delegates a live verification check to the /canary skill (gstack). Emits phase.monitor-deploy.complete.<TICKET> on canary success, phase.monitor-deploy.failed.<TICKET> on deploy or
npx skillsauth add coalesce-labs/catalyst plugins/dev/skills/phase-monitor-deployInstall 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.
Greenfield phase agent shipped in CTL-451 (Initiative 1 Phase 5). Runs after phase-pr has merged
the PR. Subscribes to GitHub deployment_status events on the merge commit SHA, then delegates
canary verification to /canary (gstack) once the deploy reaches a terminal state.
Optimized for Haiku — the body is purely procedural shell. The model context is only used to gate
the /canary skill invocation (which itself decides how aggressively to probe the live URL).
Environment:
TICKET — Linear identifier (e.g. CTL-451). Required.WORKER_DIR — directory containing phase-monitor-merge.json (read, primary input) and where
phase-monitor-deploy.json (write) lands. Defaults to ${ORCH_DIR}/workers/${TICKET} if set,
else $(pwd).PHASE_DEPLOY_TIMEOUT_SEC — seconds to wait for a deployment_status event matching the merge
SHA. Default 1800 (30 minutes). Setting to a small value is the documented way to skip deploy
verification in test/dev environments.PHASE_DEPLOY_ENV — GitHub Deployment environment name to match (default production). Set
per-project as needed.PHASE_CANARY_CMD — command line used to invoke the canary skill. Default
claude --model haiku -p /canary --output-format json. Test runners override this with a stub
that emits a fixture canary result.CATALYST_ORCHESTRATOR_ID, CATALYST_SESSION_ID — used for event trace/span id derivation.gh CLI on $PATH, authenticated against the GitHub repo, is required only when
phase-monitor-merge.json exists but .pr.mergeCommitSha is empty (the REST fallback path). In the
common case (where phase-monitor-merge recorded the SHA successfully), gh is not invoked. The
fallback also reads PR number from phase-pr.json.
{
"pr": {
"mergedAt": "2026-05-18T22:00:00Z",
"ciStatus": "merged",
"mergeCommitSha": "abc123..."
}
}
The skill reads .pr.mergeCommitSha only; everything else is informational. The file is written by
[[phase-monitor-merge]] after gh pr merge --squash confirms via REST
(gh api repos/<owner>/<repo>/pulls/<num> returns .merged == true).
/goal "The deploy for the merge SHA actually SUCCEEDED — a terminal
deployment_status success event arrived for the SHA AND the /canary check
passed — and I have written ${WORKER_DIR}/phase-monitor-deploy.json
recording that success. If the deploy FAILED (a deployment_status failure
or a failing canary), I have driven at least one remediation attempt
rather than passively recording failed/skipped. OR no deployment_status
event arrived within PHASE_DEPLOY_TIMEOUT_SEC and I have recorded
status:skipped with a reason (the PR is already merged, so a missing
deploy event is a skip, not a failure)."
CTL-656: monitor-deploy is not a passive watch — its goal is that the deploy actually
succeeded, so the /goal evaluator keeps the agent driving toward a green canary, including a
remediation attempt on a failed deploy, instead of emitting failed/skipped and walking away on
the first terminal signal. The timeout path is the one legitimate early exit. (Production mode only;
the CI bash body below remains self-sufficient and deterministic.)
set -uo pipefail
__PM_SCRIPT_PATH="${BASH_SOURCE[0]:-${0}}"
__PM_SKILL_DIR="$(cd "$(dirname "$__PM_SCRIPT_PATH")" && pwd 2>/dev/null || pwd)"
__PM_REPO_ROOT="${PHASE_AGENT_REPO_ROOT:-$(cd "$__PM_SKILL_DIR/../../../.." 2>/dev/null && pwd || pwd)}"
# CTL-512 → CTL-1410 Phase A: every terminal event goes through the production
# wrapper (phase-agent-emit-complete) so the phase signal file's `status` field is
# written canonically in-band. CTL-512 first moved the `skipped` branch here;
# CTL-1410 completes the migration for complete+failed so monitor-deploy →
# teardown advances under executor=sdk — the event-only phase-emit-complete.sh lib
# helper (which only emitted events and never touched the signal file, leaving the
# terminal status to the bg-only reclaim path that silently no-ops under sdk) is no
# longer sourced. The wrapper also owns the broker emit, session DB close, completedAt.
__PM_WRAPPER="${PHASE_EMIT_WRAPPER:-${__PM_REPO_ROOT}/plugins/dev/scripts/phase-agent-emit-complete}"
if [[ ! -x "$__PM_WRAPPER" ]]; then
echo "phase-monitor-deploy: cannot find phase-agent-emit-complete wrapper at $__PM_WRAPPER" >&2
exit 1
fi
: "${TICKET:?phase-monitor-deploy: TICKET env var required}"
WORKER_DIR="${WORKER_DIR:-${ORCH_DIR:+${ORCH_DIR}/workers/${TICKET}}}"
WORKER_DIR="${WORKER_DIR:-$(pwd)}"
mkdir -p "$WORKER_DIR"
DEPLOY_TIMEOUT="${PHASE_DEPLOY_TIMEOUT_SEC:-1800}"
DEPLOY_ENV="${PHASE_DEPLOY_ENV:-production}"
CANARY_CMD="${PHASE_CANARY_CMD:-claude --model haiku -p /canary --output-format json}"
# 1. Read merge SHA from phase-monitor-merge.json (the prior phase artifact).
MERGE_FILE="$WORKER_DIR/phase-monitor-merge.json"
if [[ ! -f "$MERGE_FILE" ]]; then
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status failed \
--reason "phase-monitor-merge.json missing at $MERGE_FILE"
exit 1
fi
MERGE_SHA="$(jq -r '.pr.mergeCommitSha // empty' "$MERGE_FILE" 2>/dev/null)"
if [[ -z "$MERGE_SHA" ]]; then
# Fall back to gh REST. Mirrors orchestrate-verify.sh:131-156. PR number
# comes from phase-pr.json (phase-monitor-merge.json does not record it).
PR_FILE="$WORKER_DIR/phase-pr.json"
PR_NUMBER=""
if [[ -f "$PR_FILE" ]]; then
PR_NUMBER="$(jq -r '.pr.number // empty' "$PR_FILE" 2>/dev/null)"
fi
if [[ -z "$PR_NUMBER" ]]; then
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status failed \
--reason "phase-monitor-merge.json has empty .pr.mergeCommitSha and no PR number available for gh REST fallback"
exit 1
fi
REPO="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || echo "")"
if [[ -z "$REPO" ]]; then
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status failed \
--reason "phase-monitor-merge.json has empty .pr.mergeCommitSha and gh repo view returned empty"
exit 1
fi
MERGE_SHA="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.merge_commit_sha // empty' 2>/dev/null || echo "")"
if [[ -z "$MERGE_SHA" ]]; then
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status failed \
--reason "phase-monitor-merge.json has empty .pr.mergeCommitSha and gh REST fallback also returned empty for pr#${PR_NUMBER}"
exit 1
fi
fi
# 2. Subscribe to deployment_status events for this SHA. The filter accepts any
# deployment_status event whose vcs.revision matches and whose deployment.environment
# matches PHASE_DEPLOY_ENV. wait-for scans the file from the start (it is not a true
# live tail) so historical events fire immediately, which is the behavior the test
# runner depends on.
DEPLOY_FILTER='(.attributes."event.name" | startswith("github.deployment_status"))
and .attributes."vcs.revision" == "'"$MERGE_SHA"'"
and .attributes."deployment.environment" == "'"$DEPLOY_ENV"'"'
DEPLOY_EVENT="$(catalyst-events wait-for \
--filter "$DEPLOY_FILTER" \
--timeout "$DEPLOY_TIMEOUT" 2>/dev/null || true)"
DEPLOY_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ -z "$DEPLOY_EVENT" ]]; then
# 2a. No deploy event observed — emit `skipped` and exit successfully. The
# orchestrator treats skipped as success because the PR is already merged
# and the deploy pipeline simply did not signal this code path.
jq -nc \
--arg ticket "$TICKET" \
--arg sha "$MERGE_SHA" \
--arg env "$DEPLOY_ENV" \
--arg ts "$DEPLOY_TIME" \
'{
ticket: $ticket,
deploy_sha: $sha,
deploy_env: $env,
deploy_state: "skipped",
deploy_time: $ts,
canary_result: null,
completed_at: $ts,
reason: "no deployment_status event matched within timeout"
}' > "$WORKER_DIR/phase-monitor-deploy.json"
# CTL-512: use the production wrapper so the signal file's `status` field
# is written canonically (status: "skipped", completedAt set). The wrapper
# merges these fields on top of the artifact JSON above without clobbering
# deploy_state / deploy_sha / canary_result. Pre-CTL-512 this branch went
# through the lib helper, which emitted the event but never touched the
# signal file — orchestrate-revive then synthesized a `done` status by
# accident, masking the leak.
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status skipped \
--reason "no deployment_status event for $MERGE_SHA on env $DEPLOY_ENV within ${DEPLOY_TIMEOUT}s"
exit 0
fi
DEPLOY_STATE="$(printf '%s' "$DEPLOY_EVENT" \
| jq -r '.attributes."deployment.state" // .body.payload.state // empty' 2>/dev/null)"
# Preview / live environment URL from the deployment_status payload. Prefer the
# environment URL (the actual deployed/preview site, e.g. a Cloudflare Pages
# preview); fall back to target_url (often the CI run). Surfaced in the mirror
# comment and persisted to the signal as structured data so a HUD/agent can link
# straight to the running deploy.
DEPLOY_URL="$(printf '%s' "$DEPLOY_EVENT" \
| jq -r '.body.payload.environmentUrl // .body.payload.environment_url // .body.payload.targetUrl // .body.payload.target_url // empty' 2>/dev/null || true)"
# 3. Branch on deploy state.
case "$DEPLOY_STATE" in
success)
: # continue to canary
;;
failure|error)
jq -nc \
--arg ticket "$TICKET" \
--arg sha "$MERGE_SHA" \
--arg env "$DEPLOY_ENV" \
--arg state "$DEPLOY_STATE" \
--arg ts "$DEPLOY_TIME" \
'{
ticket: $ticket,
deploy_sha: $sha,
deploy_env: $env,
deploy_state: $state,
deploy_time: $ts,
canary_result: null,
completed_at: $ts
}' > "$WORKER_DIR/phase-monitor-deploy.json"
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status failed \
--reason "deployment_status reported state=$DEPLOY_STATE for $MERGE_SHA on $DEPLOY_ENV" \
--payload-json "$(cat "$WORKER_DIR/phase-monitor-deploy.json")"
exit 1
;;
*)
# pending / in_progress / queued — wait-for already filtered terminal states
# via the test fixture, so this branch is mainly defensive. Treat as failed
# to escalate; future work can re-enter the wait loop instead.
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status failed \
--reason "unexpected non-terminal deployment state: $DEPLOY_STATE"
exit 1
;;
esac
# 4. Run the canary check. The default command shells out to `claude -p /canary`.
# The test runner overrides PHASE_CANARY_CMD to a stub that writes a fixture
# result. Either way, the command is expected to print JSON on stdout that
# parses to an object with at least a `status` field ("success"|"failed").
CANARY_OUT_FILE="$WORKER_DIR/canary-output.json"
CANARY_STDERR_FILE="$WORKER_DIR/canary-stderr.log"
if ! eval "$CANARY_CMD" > "$CANARY_OUT_FILE" 2> "$CANARY_STDERR_FILE"; then
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status failed \
--reason "canary command exited non-zero (see $CANARY_STDERR_FILE)"
exit 1
fi
if ! jq -e . "$CANARY_OUT_FILE" >/dev/null 2>&1; then
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status failed \
--reason "canary stdout did not parse as JSON"
exit 1
fi
CANARY_STATUS="$(jq -r '.status // empty' "$CANARY_OUT_FILE")"
# 5. Compose phase-monitor-deploy.json.
RESULT_FILE="$WORKER_DIR/phase-monitor-deploy.json"
jq -nc \
--arg ticket "$TICKET" \
--arg sha "$MERGE_SHA" \
--arg env "$DEPLOY_ENV" \
--arg ts "$DEPLOY_TIME" \
--arg url "$DEPLOY_URL" \
--slurpfile canary "$CANARY_OUT_FILE" \
'{
ticket: $ticket,
deploy_sha: $sha,
deploy_env: $env,
deploy_state: "success",
deploy_time: $ts,
deployment: ({environment: $env} + (if $url != "" then {url: $url} else {} end)),
canary_result: ($canary | first),
completed_at: $ts
}' > "$RESULT_FILE"
CANARY_STATUS_FOR_MIRROR="$(jq -r '.status // "unknown"' "$CANARY_OUT_FILE" 2>/dev/null || echo "unknown")"
# Mirror the deploy outcome to Linear as a single comment (CTL-632). Shows the
# environment + preview/live URL (clickable straight from the ticket) and the
# canary verdict. Fail-open + idempotent via the per-phase marker. The footer is
# appended via the shared helper. monitor-deploy is the terminal phase, so this
# is the last automated comment on the ticket.
LINEAR_MIRROR_MARKER="${WORKER_DIR}/.linear-mirror-monitor-deploy"
if [[ ! -e "${LINEAR_MIRROR_MARKER}" ]] && command -v linearis >/dev/null 2>&1; then
DEPLOY_URL_LINE="${DEPLOY_URL:-_none reported_}"
MIRROR_BODY="$(cat <<EOF
**Phase Monitor-Deploy** — deployed to \`${DEPLOY_ENV}\`
- **Deploy**: success · canary \`${CANARY_STATUS_FOR_MIRROR}\`
- **Preview / environment URL**: ${DEPLOY_URL_LINE}
- **Merge SHA**: \`${MERGE_SHA}\`
_Posted automatically by phase-monitor-deploy (CTL-632)._
EOF
)"
ORCH_DIR_RESOLVED="${CATALYST_ORCHESTRATOR_DIR:-${ORCH_DIR:-$(cd "${WORKER_DIR}/../.." 2>/dev/null && pwd || echo "")}}"
FOOTER_BIN="${__PM_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 "monitor-deploy" 2>/dev/null || true)"
[[ -n "${MIRROR_FOOTER}" ]] && MIRROR_BODY="${MIRROR_BODY}
${MIRROR_FOOTER}"
fi
# CTL-864: cross-host fence — bow out if a takeover superseded us. No-op single-host.
"${__PM_REPO_ROOT}/plugins/dev/scripts/lib/cluster-fence-guard.sh" --phase "${CATALYST_PHASE:-monitor-deploy}" --ticket "$TICKET" || exit 10
# CTL-1410 Phase A bug fix (pre-existing since CTL-550): this line referenced
# ${PLUGIN_ROOT}, which this body never defines (unlike the model-driven phase
# skills, which resolve it from CLAUDE_PLUGIN_ROOT in an early step). Under the
# body's `set -u` the unbound expansion killed the SUCCESS path right here —
# after the artifact write, before the terminal emit — so no complete event was
# ever emitted. Resolve from __PM_REPO_ROOT (this body's own root idiom),
# mirroring phase-triage's __PT_COMMENT_POST.
COMMENT_POST="${CATALYST_COMMENT_POST_HELPER:-${__PM_REPO_ROOT}/plugins/dev/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; then
: > "${LINEAR_MIRROR_MARKER}"
else
echo "phase-monitor-deploy: linear-comment-post failed (continuing)" >&2
fi
fi
# 6. Emit the canonical phase event based on canary status. CTL-1410 Phase A: via
# $__PM_WRAPPER so the signal file's terminal `status` is flipped in-band under
# executor=sdk (the artifact write above already landed the same file, so the
# wrapper merges status/completedAt on top without clobbering deploy_* fields).
if [[ "$CANARY_STATUS" == "success" ]]; then
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status complete \
--payload-json "$(cat "$RESULT_FILE")"
exit 0
fi
"$__PM_WRAPPER" --phase monitor-deploy --ticket "$TICKET" --status failed \
--reason "canary status=${CANARY_STATUS:-unknown}" \
--payload-json "$(cat "$RESULT_FILE")"
exit 1
Haiku is the default. If the orchestrator routes this to an Opus agent (e.g., for a high-stakes deploy where the canary is borderline), the agent should:
canary-output.json and decide whether the canary result is materially actionable beyond
the binary status field (e.g., performance regressions worth flagging).The bash body alone is enough to drive the state machine. Opus-mode add-ons are pure upside.
development
Migrate a single-harness repo to the dual-harness layout so both Claude Code and Codex load the same instructions and skills — AGENTS.md as the portable canonical doc, a thin CLAUDE.md `@AGENTS.md` bridge, and a `.agents/skills` dir with a `.claude/skills` symlink onto it. Use when asked to migrate to dual-harness, make this repo work in both Claude and Codex, or for agent metadata cleanup.
tools
Goal-driven senior-engineer pipeline-unstick sweep (CTL-1176 rung 3). Given the stuck/failed/needs-human set (or ONE ticket handed by the recovery router), its GOAL is to get the pipeline MOVING again — not to fix one ticket's review findings (that is phase-remediate). It runs AFTER the eyes (diagnostician evidence) and the hands (deterministic unstuck-sweep seams) have already tried, and it CONSUMES their output from a recovery-pass.json brief rather than re-diagnosing or redoing their narrow work. It acts like a senior engineer with full tool access — it resolves merge conflicts, rebases, force-pushes, merges green PRs, and re-dispatches stalled phases AUTONOMOUSLY — and escalates to the operator ONLY for a genuine value judgment / something that degrades other functionality / a real cost-benefit trade-off / a serious architecture change / an ADR conflict. On escalation it AUTHORS the operator inbox row + the push notification (executive-voiced). Dispatched as a `claude --bg` job by phase-agent-dispatch via slash command, AND invocable bare by the operator as a sweep — hence `user-invocable: true`. Ships behind CATALYST_RECOVERY_PASS (off by default — no live behavior change until shadow/enforce).
tools
Diagnose and fix Catalyst setup issues. Validates tools, database, config, OTel, direnv, and thoughts. Automatically fixes what it can — creates directories, initializes the database, sets WAL mode, runs migrations. Use for new installs, upgrades, or when something isn't working.
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