End-to-end autonomous workflow that chains research → plan → implement → validate → ship → merge in a single session. All phases run sequentially in the current Claude Code session, invoking skills directly. Context is managed naturally via Claude's automatic compaction, and the thoughts/ system provides persistent handoff documents between phases. > **Legacy mode for the orchestrator (post-2026-06-15).** `oneshot` remains the canonical > single-shot lifecycle for direct user invocation. The ...
Scanned 9/3/2026
Install to Claude Code
npx -y skills add coalesce-labs/catalyst --skill oneshot --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Oneshot?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/coalesce-labs-oneshot)More formats (shields.io, HTML) on the badges page.
---
name: oneshot
description:
"End-to-end autonomous workflow — research, plan, implement, validate, ship, and merge in one
command. **ALWAYS use when** the user says 'oneshot', 'do everything end to end', 'full workflow',
or wants to go from ticket/idea to merged PR autonomously. All phases run sequentially in the
current session, using agent teams for parallelism when needed."
disable-model-invocation: false
allowed-tools:
Read, Write, Bash, Task, Grep, Glob
version: 3.0.0
---
# Oneshot
End-to-end autonomous workflow that chains research → plan → implement → validate → ship → merge in
a single session. All phases run sequentially in the current Claude Code session, invoking skills
directly. Context is managed naturally via Claude's automatic compaction, and the thoughts/ system
provides persistent handoff documents between phases.
> **Legacy mode for the orchestrator (post-2026-06-15).** `oneshot` remains the canonical
> single-shot lifecycle for direct user invocation. The orchestrator's default worker dispatch is
> also still `oneshot-legacy` — opt in to the per-phase pipeline by setting
> `catalyst.orchestration.dispatchMode` to `"phase-agents"` in `.catalyst/config.json`, which runs
> nine short-lived `claude --bg` skills (`phase-triage` … `phase-monitor-deploy`) instead of one
> long `claude -p` `oneshot` worker. See
> [Phase agents](https://catalyst.coalescelabs.ai/reference/orchestration/phase-agents/) for the
> pipeline, model assignment, and cost economics.
## Prerequisites
```bash
# Resolve the shared catalyst-dev scripts dir (skills live in catalyst-legacy; scripts stay in
# catalyst-dev). Fail fast with an actionable message if catalyst-dev is not installed.
source "${CLAUDE_PLUGIN_ROOT:-plugins/legacy}/scripts/require-catalyst-dev.sh" \
"${CLAUDE_PLUGIN_ROOT:-plugins/legacy}" || exit 1
# 0. Check project setup (thoughts, config, workflow context init)
if [[ -f "${CATALYST_DEV_SCRIPTS}/check-project-setup.sh" ]]; then
"${CATALYST_DEV_SCRIPTS}/check-project-setup.sh" || exit 1
fi
# 1. Validate thoughts system (REQUIRED)
if [[ ! -d "thoughts/shared" ]]; then
echo "❌ ERROR: Thoughts system not configured"
echo "Run: ./scripts/humanlayer/init-project.sh . {project-name}"
exit 1
fi
```
## Input Modes
Supports two input modes:
**Ticket-based:**
```
/catalyst-dev:oneshot PROJ-123
```
Reads ticket from Linear, uses title/description as research query.
**Freeform:**
```
/catalyst-dev:oneshot "How does authentication work and can we add OAuth?"
```
Uses the provided text as the research query directly.
## Flags
| Flag | Description |
| ---------------------- | ------------------------------------------------------------- |
| `--team` | Use agent teams for parallel implementation in Phase 3 |
| `--label <text>` | Custom display label for the session (overrides auto-derived) |
| `--no-merge` | Stop after PR creation — do NOT enter listen loop or merge |
| `--no-ticket` | Skip Linear ticket creation in freeform mode |
| `--skip-validation` | Skip Phase 4 entirely |
| `--skip-quality-gates` | Run `/validate-plan` but skip quality gate loop |
## Orchestrator Mode
When running under an `/orchestrate` coordinator, oneshot writes status updates to a **worker signal
file** so the orchestrator can track progress and run adversarial verification.
**Single-ticket scope contract (READ FIRST — CTL-208).** Your assigned scope is exactly the ticket
ID passed as the first positional argument (`$1`). This is the SOLE source of truth for what work to
do. The orchestrator state directory (`$ORCH_DIR`), wave briefings, sibling worker signal files, and
comms channel participant lists exist for write-through state reporting and one-way context
absorption — they NEVER expand or modify your scope.
DO:
- Use `${TICKET_ID}` (= `$1`) as your single ticket throughout the workflow.
- Read your own signal file at `${ORCH_DIR}/workers/${TICKET_ID}.json` — the SPECIFIC file named for
your ticket, not the directory.
- Read the briefing for your wave by exact filename: `${ORCH_DIR}/wave-${WAVE}-briefing.md`, where
`${WAVE}` comes from your signal file's `wave` field (set by the dispatcher in
`orchestrate-dispatch-next`).
DO NOT:
- Enumerate `${ORCH_DIR}/workers/*.json` to discover sibling tickets.
- Read `${ORCH_DIR}/state.json` to see what other tickets are queued or in flight.
- Treat the wave briefing's "Wave roster" section as a list of tickets you must process — the wave
briefing is shared across every worker in the wave; your assigned ticket is still only `$1`.
- Treat comms channel participants (visible via `catalyst-comms status`) as your scope.
- Ask the user to clarify which of "the tickets you see" they meant — there is exactly one ticket:
`$1`. If `$1` is empty or missing, fail loudly; do not search for tickets to do.
**Detection (checked once at startup):**
```bash
# 1. CATALYST_ORCHESTRATOR_DIR env var (set by orchestrator in dispatch)
ORCH_DIR="${CATALYST_ORCHESTRATOR_DIR:-}"
# 2. CATALYST_ORCHESTRATOR_ID env var (set by orchestrator in dispatch)
ORCH_ID="${CATALYST_ORCHESTRATOR_ID:-}"
# 3. Sibling directory with workers/ subdirectory (convention-based)
if [ -z "$ORCH_DIR" ]; then
PARENT=$(dirname "$(pwd)")
for DIR in "$PARENT"/*/workers; do
if [ -d "$DIR" ]; then
ORCH_DIR=$(dirname "$DIR")
break
fi
done
fi
# Resolve global state script path
STATE_SCRIPT="${CATALYST_DEV_SCRIPTS}/catalyst-state.sh"
```
**Shared comms channel (CTL-111 / CTL-249):** if `CATALYST_COMMS_CHANNEL` is set by the
orchestrator, the worker joins the shared channel, posts real traffic at each lifecycle boundary,
and reads inbound messages (directed to `$TICKET_ID`) after each phase transition. Best-effort —
every call is wrapped so a missing `catalyst-comms` CLI never crashes the worker. The worker posts
at **minimum 4 messages** per run: start + phase transitions + done. Inbound reads are driven by
`comms_check` (see below) — a non-blocking poll that checks for `abort`, `use-event-driven`, and
`reprioritize` signals from the orchestrator.
```bash
# Resolve the catalyst-comms binary. Prefer the plugin-shipped copy so installs
# where `catalyst-comms` is only a shell alias (which doesn't propagate to
# subshells) still work. Fall back to PATH for users who have symlinked it.
COMMS_BIN="${CATALYST_DEV_SCRIPTS}/catalyst-comms"
[ -x "$COMMS_BIN" ] || COMMS_BIN="$(command -v catalyst-comms 2>/dev/null || true)"
if [ -z "$COMMS_BIN" ] || [ ! -x "$COMMS_BIN" ]; then
echo "warn: catalyst-comms not found — worker comms disabled" >&2
COMMS_BIN=""
fi
# Helper — called at every hook point below. Silent no-op when comms is unavailable.
comms_post() {
local type="$1" body="$2"
[ -z "${CATALYST_COMMS_CHANNEL:-}" ] && return 0
[ -n "$COMMS_BIN" ] || return 0
"$COMMS_BIN" send "$CATALYST_COMMS_CHANNEL" "$body" \
--as "$TICKET_ID" --type "$type" >/dev/null 2>&1 || true
}
# Inbound comms — read messages directed to this worker at each phase boundary.
# COMMS_LAST_READ tracks the channel file line offset so we skip historical messages.
# Initialized after join (below) to the current end-of-file.
CATALYST_DIR="${CATALYST_DIR:-$HOME/catalyst}"
COMMS_CHANNEL_FILE="${CATALYST_DIR}/comms/channels/${CATALYST_COMMS_CHANNEL:-_}.jsonl"
COMMS_LAST_READ=0
comms_check() {
[ -z "${CATALYST_COMMS_CHANNEL:-}" ] && return 0
[ -n "$COMMS_BIN" ] || return 0
[ -f "$COMMS_CHANNEL_FILE" ] || return 0
local msgs next_pos
# Snapshot line count BEFORE polling so messages arriving during the read
# window are picked up on the next call rather than silently skipped.
next_pos=$(wc -l < "$COMMS_CHANNEL_FILE" | tr -d ' ')
msgs=$("$COMMS_BIN" poll "$CATALYST_COMMS_CHANNEL" \
--filter-to "$TICKET_ID" --since "$COMMS_LAST_READ" 2>/dev/null || true)
COMMS_LAST_READ="$next_pos"
[ -z "$msgs" ] && return 0
while IFS= read -r msg; do
[ -z "$msg" ] && continue
local msg_type msg_body
msg_type=$(printf '%s' "$msg" | jq -r '.type // "info"' 2>/dev/null || echo "info")
msg_body=$(printf '%s' "$msg" | jq -r '.body // ""' 2>/dev/null || echo "")
echo "[comms] Inbound ($msg_type): $msg_body" >&2
case "$msg_body" in
abort*|ABORT*) echo "[comms] Abort signal — exiting" >&2; exit 1 ;;
esac
done <<< "$msgs"
}
# Once, at startup — right after orchestrator mode detection:
if [ -n "${CATALYST_COMMS_CHANNEL:-}" ] && [ -n "$COMMS_BIN" ]; then
"$COMMS_BIN" join "$CATALYST_COMMS_CHANNEL" \
--as "$TICKET_ID" \
--capabilities "oneshot: ${TICKET_ID}" \
--orch "${CATALYST_ORCHESTRATOR_ID:-}" \
--parent orchestrator \
--ttl 3600 >/dev/null 2>&1 || true
comms_post info "started oneshot for $TICKET_ID"
# Snapshot the channel file line count so comms_check skips pre-worker messages
COMMS_CHANNEL_FILE="${CATALYST_DIR}/comms/channels/${CATALYST_COMMS_CHANNEL}.jsonl"
[ -f "$COMMS_CHANNEL_FILE" ] && COMMS_LAST_READ=$(wc -l < "$COMMS_CHANNEL_FILE" | tr -d ' ')
fi
# CTL-303: broker registration helpers. The worker uses agent.checkin auto-correlation
# instead of explicit filter.register calls. After PR creation, a second agent.checkin
# with claimed_pr set is all the broker needs to auto-derive a pr_lifecycle interest.
# The Phase 5 listen loop waits on filter.wake.${CATALYST_SESSION_ID} as before.
# When the daemon is not running, the loop falls back to direct catalyst-events wait-for.
broker_daemon_running() {
if command -v catalyst-broker >/dev/null 2>&1; then
catalyst-broker probe 2>/dev/null
elif command -v catalyst-filter >/dev/null 2>&1; then
catalyst-filter status 2>/dev/null | grep -q "^running"
else
return 1
fi
}
# CTL-429: retry wrapper — daemon may be starting up or momentarily unavailable.
# Tries up to 3 times with 2s gaps before declaring the daemon absent.
wait_for_broker_ready() {
local max_attempts=3 attempt=0
while [ $attempt -lt $max_attempts ]; do
broker_daemon_running && return 0
attempt=$((attempt + 1))
[ $attempt -lt $max_attempts ] && sleep 2
done
return 1
}
# CTL-303: update claimed_pr in the broker via a second agent.checkin.
# The broker auto-derives pr_lifecycle from the check-in — no explicit filter.register needed.
broker_claim_pr() {
# Args: $1 = PR_NUMBER, $2 = TICKET_ID, $3 = BRANCH_NAME, $4 = REPO (org/name), $5 = BASE_BRANCH (default: main)
wait_for_broker_ready || return 1
[ -n "${CATALYST_SESSION_ID:-}" ] || return 1
local pr="$1" ticket="$2" repo="$4" base="${5:-main}"
local ts; ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
"$STATE_SCRIPT" event "$(jq -nc \
--arg ts "$ts" \
--arg sid "$CATALYST_SESSION_ID" \
--arg orch "${CATALYST_ORCHESTRATOR_ID:-}" \
--argjson pr "$pr" \
--arg ticket "$ticket" \
--arg repo "$repo" \
--arg base "$base" \
'{ts: $ts, event: "agent.checkin",
detail: {
session_id: $sid,
ticket: (if $ticket == "" then null else $ticket end),
orchestrator: (if $orch == "" then null else $orch end),
claimed_pr: $pr,
repo: $repo,
base_branches: [{pr: $pr, base: $base}]
}}')" 2>/dev/null || return 1
return 0
}
# Keep filter_register_worker as an alias for backward compat.
# New code should use broker_claim_pr instead.
filter_daemon_running() { broker_daemon_running; }
filter_register_worker() {
local pr="$1" ticket="$2" branch="$3" repo="$4" base="${5:-main}"
broker_claim_pr "$pr" "$ticket" "$branch" "$repo" "$base"
}
filter_deregister_worker() {
broker_daemon_running || return 0
[ -n "${CATALYST_SESSION_ID:-}" ] || return 0
# Deregister the auto-correlated pr_lifecycle interest (interest_id = sid).
"$STATE_SCRIPT" event "$(jq -nc \
--arg sid "$CATALYST_SESSION_ID" \
'{ts: (now | todate), event: "filter.deregister", orchestrator: null, worker: null, detail: {interest_id: $sid}}')" 2>/dev/null || true
# CTL-357: also deregister the comms_lifecycle interest (interest_id = sid-comms).
"$STATE_SCRIPT" event "$(jq -nc \
--arg id "${CATALYST_SESSION_ID}-comms" \
'{ts: (now | todate), event: "filter.deregister", orchestrator: null, worker: null, detail: {interest_id: $id}}')" 2>/dev/null || true
}
# CTL-357: register a comms_lifecycle worker subscription so the broker wakes
# the worker on messages directed at it (`to=$TICKET_ID` or `to=all`) on the
# shared channel. The interest_id is "${CATALYST_SESSION_ID}-comms" — distinct
# from the broker's auto-correlated pr_lifecycle interest (which uses just
# the session_id) so the two interests coexist. Both share the same
# notify_event = filter.wake.${CATALYST_SESSION_ID}, so the Phase 5 listen
# loop predicate is unchanged. Best-effort; broker absence is fine.
broker_register_comms() {
wait_for_broker_ready || return 0
[ -n "${CATALYST_SESSION_ID:-}" ] || return 0
[ -n "${CATALYST_COMMS_CHANNEL:-}" ] || return 0
[ -n "${TICKET_ID:-}" ] || return 0
"$STATE_SCRIPT" event "$(jq -nc \
--arg sid "$CATALYST_SESSION_ID" \
--arg id "${CATALYST_SESSION_ID}-comms" \
--arg orch "${CATALYST_ORCHESTRATOR_ID:-}" \
--arg notify "filter.wake.${CATALYST_SESSION_ID}" \
--arg channel "$CATALYST_COMMS_CHANNEL" \
--arg ticket "$TICKET_ID" \
'{ts: (now | todate), event: "filter.register",
orchestrator: (if $orch == "" then null else $orch end),
worker: null,
detail: {
interest_id: $id,
interest_type: "comms_lifecycle",
notify_event: $notify,
persistent: true,
session_id: $sid,
channel: $channel,
subscriber_kind: "worker",
subscriber_ticket: $ticket
}}')" 2>/dev/null || true
}
# Register the worker's comms subscription once at startup. The wait-for
# filter (filter.wake.${CATALYST_SESSION_ID}) is unchanged — this just gives
# the broker a second deterministic reason to fire that wake.
broker_register_comms
# Belt-and-suspenders: trap on EXIT/INT/TERM ensures graceful deregister.
# Watchdog cleanup in the daemon handles crash cases via session_id matching.
trap 'filter_deregister_worker' EXIT INT TERM
```
If `ORCH_DIR` is detected, the worker:
1. **Reads its signal file** from `${ORCH_DIR}/workers/${TICKET_ID}.json` (the single named file for
this worker — do NOT list other files in the workers/ directory)
2. **Updates status at each phase transition** — writes `status`, `phase`, and `updatedAt` to both
the local signal file AND the global state at `~/catalyst/state.json`
3. **Derives and writes `label`** to the signal file at startup (see Label Derivation below)
4. **Emits events** to the global event log at each phase transition
5. **Fills `definitionOfDone`** at Phase 4 (validation) and Phase 5 (ship) with actual results
6. **Reads its wave briefing** at `${ORCH_DIR}/wave-${WAVE}-briefing.md` if it exists, where
`${WAVE}` is read from the worker's own signal file's `wave` field (set by dispatcher). Do NOT
glob `wave-*-briefing.md` — only the worker's own wave is in scope. If the signal file has no
`wave` field (older orchestrators), skip briefing read entirely.
**Label Derivation** (at startup, before first phase transition):
The `label` field in the signal file gives the session a human-readable display name. It is derived
automatically unless overridden with `--label`:
```bash
# If --label flag was provided, use it directly
if [ -n "$USER_LABEL" ]; then
LABEL="$USER_LABEL"
else
# Auto-derive: "<skill> <ticket>"
SKILL_NAME="oneshot" # or the current skill name
LABEL="${SKILL_NAME} ${TICKET_ID}"
fi
# Write to signal file (once, at startup)
if [ -f "$SIGNAL_FILE" ]; then
jq --arg label "$LABEL" '.label = $label' "$SIGNAL_FILE" > "${SIGNAL_FILE}.tmp" \
&& mv "${SIGNAL_FILE}.tmp" "$SIGNAL_FILE"
fi
```
**Signal file + global state update helper** (run at each phase boundary):
```bash
SIGNAL_FILE="${ORCH_DIR}/workers/${TICKET_ID}.json"
if [ -f "$SIGNAL_FILE" ]; then
OLD_STATUS=$(jq -r '.status' "$SIGNAL_FILE")
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Update local signal file. Atomically records phaseTimestamps[$status] = $ts
# so the monitor can build a Gantt timeline. Sets completedAt for terminal states.
IS_TERMINAL="false"
case "$NEW_STATUS" in done|failed|stalled) IS_TERMINAL="true" ;; esac
jq --arg status "$NEW_STATUS" \
--arg phase "$PHASE_NUM" \
--arg ts "$TS" \
--argjson terminal "$IS_TERMINAL" \
'.status = $status
| .phase = ($phase | tonumber)
| .updatedAt = $ts
| .phaseTimestamps = ((.phaseTimestamps // {}) | .[$status] = $ts)
| (if $terminal then .completedAt = $ts else . end)' \
"$SIGNAL_FILE" > "${SIGNAL_FILE}.tmp" && mv "${SIGNAL_FILE}.tmp" "$SIGNAL_FILE"
# Update global state (if orchestrator ID is known)
if [ -n "$ORCH_ID" ] && [ -f "$STATE_SCRIPT" ]; then
"$STATE_SCRIPT" worker "$ORCH_ID" "$TICKET_ID" \
".status = \"$NEW_STATUS\" | .phase = $PHASE_NUM"
# Emit phase-advance / terminal event via the central producer (CTL-229).
# Routine info-tier transitions (researching → planning → …) coalesce
# within the configured window; terminal transitions (pr-created, merging,
# done, failed, stalled, deploy-failed) flush any pending queue and emit
# immediately, with PR enrichment when --to is PR-bearing.
EMITTER="${CATALYST_DEV_SCRIPTS}/emit-worker-status-change.sh"
if [ -x "$EMITTER" ]; then
"$EMITTER" emit \
--orch "$ORCH_ID" \
--ticket "$TICKET_ID" \
--from "$OLD_STATUS" \
--to "$NEW_STATUS" \
--signal-file "$SIGNAL_FILE" >/dev/null 2>&1 || true
fi
fi
# CTL-111: announce phase transition to shared comms channel. Runs 5× in the
# normal path (researching → planning → implementing → validating → shipping),
# comfortably above the ≥2-transition floor.
comms_post info "${OLD_STATUS} → ${NEW_STATUS}"
fi
# CTL-249: check for inbound orchestrator messages after each phase transition.
comms_check
```
**When worker creates a PR**, also update global state with PR details. Record `prOpenedAt`
immediately so the dashboard can show how long the PR has been open separately from how long it took
to merge:
```bash
PR_OPENED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [ -n "$ORCH_ID" ] && [ -f "$STATE_SCRIPT" ]; then
"$STATE_SCRIPT" worker "$ORCH_ID" "$TICKET_ID" \
".pr = {number: ${PR_NUMBER}, url: \"${PR_URL}\", ciStatus: \"pending\", prOpenedAt: \"${PR_OPENED_AT}\", autoMergeArmedAt: null, mergedAt: null}"
"$STATE_SCRIPT" event "$(jq -nc \
--arg ts "$PR_OPENED_AT" \
--arg orch "$ORCH_ID" --arg w "$TICKET_ID" \
--argjson pr "$PR_NUMBER" --arg url "$PR_URL" \
'{ts: $ts, orchestrator: $orch, worker: $w, event: "worker-pr-created", detail: {pr: $pr, url: $url}}')"
fi
# CTL-111: announce PR opening on the shared comms channel
comms_post info "pr:#${PR_NUMBER} opened"
```
**When PR is merged** (CTL-252: written by the worker after active listen loop confirms CLEAN):
The worker actively merges its own PR after the listen loop confirms the PR is CLEAN (CI green +
reviews satisfied). The worker writes `pr.mergedAt` + `status: "done"` to the signal file and
transitions the Linear ticket. The **orchestrator's Phase 4** is a safety-net fallback for workers
that stalled or crashed before completing their own merge.
**When worker reaches terminal state** (done or failed):
**Mandatory `attention` on block** (per the Worker comms discipline below; the `catalyst-comms` skill was removed with the daemon, CTL-2240): in addition to the failure path below, the worker MUST also `comms_post attention "<reason>"` when it hits any of the following mid-flight, even if it is not yet writing `status: "failed"`:
- scope conflict with a sibling worker
- missing required access (CLI / credential / API)
- ambiguous spec the worker cannot resolve unilaterally
- same test/CI failure 3+ times after distinct fix attempts
- writing `status: "stalled"` (any phase)
Use a single `attention` per blocker (do not retry). Continue with whatever work is still possible,
or exit if the blocker is total. The orchestrator's poll loop will promote the message to a
state-level NEEDS ATTENTION item.
```bash
if [ -n "$ORCH_ID" ] && [ -f "$STATE_SCRIPT" ]; then
if [ "$NEW_STATUS" = "done" ]; then
"$STATE_SCRIPT" event "$(jq -nc --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg orch "$ORCH_ID" --arg w "$TICKET_ID" \
'{ts: $ts, orchestrator: $orch, worker: $w, event: "worker-done", detail: null}')"
elif [ "$NEW_STATUS" = "failed" ]; then
"$STATE_SCRIPT" event "$(jq -nc --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg orch "$ORCH_ID" --arg w "$TICKET_ID" --arg reason "$ERROR_MSG" \
'{ts: $ts, orchestrator: $orch, worker: $w, event: "worker-failed", detail: {reason: $reason}}')"
"$STATE_SCRIPT" attention "$ORCH_ID" "waiting-for-user" "$TICKET_ID" \
"Worker failed: ${ERROR_MSG}"
# CTL-111: post attention on shared comms channel so sibling workers / orchestrator
# monitoring loop can observe the blocker without reading the state file.
comms_post attention "worker failed: ${ERROR_MSG:-unknown}"
fi
fi
```
**Phase-to-status mapping for signal file:**
| Phase | Signal Status | Writer |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| 1 start | `researching` | worker |
| 2 start | `planning` | worker |
| 3 start | `implementing` | worker |
| 4 start | `validating` | worker |
| 5 start | `shipping` | worker |
| 5 PR opened | `pr-created` + `pr.prOpenedAt` + `pr.ciStatus: "pending"` | worker |
| 5 PR listen loop: inline blocker handled | (worker fixes CI/reviews and loops) | worker |
| 5 PR listen loop: human changes-requested | `status: "stalled"` + attention | worker |
| 5 PR listen loop: unresolvable conflicts | `status: "stalled"` + attention | worker |
| 5 PR merged by worker (skipDeployVerification=true or no deploy config) | `pr.ciStatus: "merged"` + `pr.mergedAt` + `status: "done"` + `completedAt` | worker |
| 5 PR merged by worker (skipDeployVerification=false, CTL-211) | `pr.ciStatus: "merged"` + `pr.mergedAt` + `pr.mergeCommitSha` + `deploy.startedAt` + `deploy.environment` → waits for `deployment_status` | worker |
| 5 deployment_status.success on production env | `status: "done"` + `deploy.completedAt` + `deploy.result: "success"` | worker (orchestrator Phase 4 as fallback) |
| 5 deployment_status.failure on production env | `status: "deploy-failed"` + `deploy.failedAttempts` + attention | worker (orchestrator Phase 4 as fallback) |
| 5 worker stalled/crashed — merge fallback | `pr.ciStatus: "merged"` + `pr.mergedAt` + `status: "done"` | orchestrator Phase 4 (safety net) |
| Any failure | `failed` | worker |
## Session Tracking
Start a catalyst-session at the very beginning of the workflow, before Phase 1. This session spans
the entire oneshot lifecycle and records phase transitions, PR creation, and completion.
```bash
SESSION_SCRIPT="${CATALYST_DEV_SCRIPTS}/catalyst-session.sh"
if [[ -x "$SESSION_SCRIPT" ]]; then
CATALYST_SESSION_ID=$("$SESSION_SCRIPT" start --skill "oneshot" \
--ticket "${TICKET_ID:-}" \
--workflow "${CATALYST_SESSION_ID:-}")
export CATALYST_SESSION_ID
# CTL-455: mirror the catalyst session_id into the worker signal file so
# `orchestrate-roll-usage.sh` can populate session_metrics when the worker's
# stream produces a result event. Orchestrator mode only — standalone runs
# have no signal file.
if [ -n "${ORCH_DIR:-}" ] && [ -f "${ORCH_DIR}/workers/${TICKET_ID}.json" ]; then
_SF="${ORCH_DIR}/workers/${TICKET_ID}.json"
jq --arg sid "$CATALYST_SESSION_ID" '.catalystSessionId = $sid' \
"$_SF" > "${_SF}.tmp" && mv "${_SF}.tmp" "$_SF"
fi
fi
```
**At each phase transition**, call BOTH the signal file update helper (above) AND the session phase
call. The session phase call is additive — it never replaces the signal file write:
```bash
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" phase "$CATALYST_SESSION_ID" "$NEW_STATUS" --phase "$PHASE_NUM"
fi
```
**When a PR is created** (Phase 5), record it in the session:
```bash
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" pr "$CATALYST_SESSION_ID" --number "$PR_NUMBER" --url "$PR_URL"
fi
```
**At terminal states** (done or failed), end the session:
```bash
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" end "$CATALYST_SESSION_ID" --status done --reason "PR merged"
# or: "$SESSION_SCRIPT" end "$CATALYST_SESSION_ID" --status failed --reason "<why>"
fi
```
**Iteration counter** (see CTL-158): bump `--kind plan` whenever the plan is re-entered
(validate-plan kicks back to create-plan) and `--kind fix` whenever an automated fix retry runs in
Phase 4 (quality gates) or Phase 5 (CI auto-fix). The counts are flushed to OTLP as
`claude_code_iteration_count_total{linear_key,kind}` at session end so downstream estimation can
read rework signal per ticket:
```bash
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" iteration "$CATALYST_SESSION_ID" --kind fix # or --kind plan
fi
```
## Workflow Phases
### Phase 1: Research (Current Session — Opus)
This phase runs in the current session to allow user interaction during research.
1. **Parse input**: Determine if ticket ID or freeform query
2. **Register ticket in workflow context (REQUIRED if ticket-based)** — immediately after parsing:
```bash
"${CATALYST_DEV_SCRIPTS}/workflow-context.sh" set-ticket "TICKET-ID"
```
This ensures `.catalyst/.workflow-context.json` exists and `currentTicket` is set before any
other work begins. Downstream skills and hooks depend on this file existing.
3. **If ticket**: Read ticket details via Linearis CLI, move to `stateMap.research` (default: "In
Progress")
4. **If freeform (and NOT `--no-ticket`)**: After research completes, offer to create a Linear
ticket from the findings:
```
Research complete. Would you like to create a Linear ticket from these findings?
[y/N]
```
If yes, create a ticket via the Linearis CLI (run `linearis issues usage` for create syntax)
using the research summary as description, then register the ticket ID:
`workflow-context.sh set-ticket "NEW-TICKET-ID"`
5. **Conduct research** — follow the `/catalyst-dev:research-codebase` process exactly. This is the
single source of truth for how codebase research works (including sub-agent
spawning, synthesis, and document creation). The research document MUST be written to
`thoughts/shared/research/` and tracked in workflow context before proceeding to Phase 2.
### Phase 2: Plan (Current Session)
Runs `/catalyst-dev:create-plan` directly in the current session.
```
/catalyst-dev:create-plan thoughts/shared/research/$RESEARCH_DOC
```
**What happens:**
- Reads research document from thoughts/
- Runs `/create-plan` interactively with the user
- Creates plan at `thoughts/shared/plans/YYYY-MM-DD-{ticket}-{description}.md`
- Syncs thoughts automatically
**User interaction**: The user interacts with the planning session normally. The plan is refined
iteratively until approved.
**Linear**: If ticket exists, move to `stateMap.planning` (default: "In Progress").
### Phase 3: Implement (Current Session)
After the plan is approved, runs implementation directly:
```
/catalyst-dev:implement-plan thoughts/shared/plans/$PLAN_DOC
```
**What happens:**
- Reads plan document from thoughts/
- Runs `/implement-plan` with full capabilities — follows TDD (tests written before implementation
per phase)
- Can spawn agent teams for complex multi-file implementations (see --team mode)
- **Does NOT commit or create PR** — deferred to Phase 5
**Linear**: If ticket exists, move to `stateMap.inProgress` (default: "In Progress").
### Phase 4: Validate + Quality Gates (Current Session)
**Skip this phase entirely with `--skip-validation`.**
Runs validation and quality enforcement directly:
```
/catalyst-dev:validate-plan thoughts/shared/plans/$PLAN_DOC
```
**Step 1: Validate plan implementation**
- Runs `/validate-plan` against the plan document
- Produces a validation report with phase completion status and deviations
**Step 2: Run skill-based quality gates** (skip with `--skip-quality-gates`)
Run these skill/agent gates in order:
```
Gate 1: /validate-type-safety → tsc + reward hacking scan + tests + lint
Gate 2: /security-review → security vulnerability scan (built-in)
Gate 3: code-reviewer agent → style/guideline adherence
Gate 4: pr-test-analyzer agent → test coverage verification
```
For each gate: run it, if it fails and is auto-fixable (gates 1 and 2), attempt to fix and re-run.
Gates 3 and 4 produce advisory findings — address them if significant.
**Step 3: Run config-based quality gates**
Reads additional gates from `.catalyst/config.json` under `catalyst.qualityGates` (see Configuration
section below). Runs each gate in `order` sequence:
```
For each gate (sorted by order):
1. Run gate.command
2. If passes → mark ✅, continue to next gate
3. If fails AND gate.autofix is true:
- Analyze errors
- Attempt automated fix
- Re-run gate.command
- After the fix attempt (pass OR fail), bump the iteration counter:
catalyst-session.sh iteration "$CATALYST_SESSION_ID" --kind fix
4. If fails AND gate.autofix is false OR autofix attempt failed:
- Log failure, continue to next gate
5. After all gates, if any required gate failed:
- Retry from first failed gate (up to maxRetries total cycles)
```
**After max retries exhausted with failures:** Present the user with options:
```
⚠️ Quality gates failed after {maxRetries} attempts:
❌ typecheck: 3 errors remaining
❌ test: 2 failing tests
Options:
[1] Fix manually and re-run gates
[2] Continue to Ship phase anyway (gates marked as skipped)
[3] Create handoff document and stop
```
**Fallback behavior (no `qualityGates` config):** If `catalyst.qualityGates` is not configured,
construct default gates from legacy config keys:
| Legacy Key | Gate | Order |
| ------------------------------ | --------- | ----- |
| `catalyst.pr.typecheckCommand` | typecheck | 1 |
| `catalyst.pr.lintCommand` | lint | 2 |
| `catalyst.pr.testCommand` | test | 3 |
| `catalyst.pr.buildCommand` | build | 4 |
If none of those keys exist either, skip quality gates entirely (validation-only mode).
### Phase 5: Ship (Current Session)
**Step 1: Smart PR Creation/Update**
Check if a PR already exists for the current branch:
```bash
EXISTING_PR=$(gh pr list --head "$(git branch --show-current)" --json number --jq '.[0].number' 2>/dev/null)
```
- **If PR exists** (`$EXISTING_PR` is not empty):
- Commit and push new changes
- Update PR description with `/describe-pr`
- Log: "Updated existing PR #$EXISTING_PR"
- **If no PR exists**:
- Run `/create-pr` (handles commit, push, PR creation, description, Linear linking)
**Step 2: Active PR Listen Loop — Wait for CLEAN then Merge (replaces auto-merge)**
After the PR is created, enter an event-driven listen loop. The preferred wake mechanism (CTL-269) is a single `filter.register` covering CI, comms inbound, reviews, BEHIND, and Linear ticket changes — the worker then waits on `filter.wake.${CATALYST_SESSION_ID}` and the Groq-backed filter daemon decides which raw events match. When the daemon is not running, the loop falls back to the two-phase pattern with per-concern jq filters, implemented inline below (the wait-for-github and catalyst-filter skills that documented these were removed with the daemon, CTL-2240). The worker actively resolves blockers (CI failures, bot review threads, BEHIND) inline and proceeds to Step 3 only when the PR is CLEAN (CI green + reviews satisfied). On unrecoverable blockers (human changes-requested, persistent DIRTY) the worker writes `status: "stalled"` and exits; the orchestrator's Phase 4 is a safety-net fallback.
**Wake narration (MANDATORY, CTL-369).** Every iteration of the listen loop — both on a `wait-for`
return AND on each `mergeable_state` re-check — must produce a single short line of assistant text
before re-entering the wait. This defeats the `Human:\n<task-id>` rendering bleed that occurs when
the agent returns an `end_turn` containing only `thinking` blocks after a Monitor-wrapped wake. The
line shape is:
```text
wake: <event.name> #<PR_NUMBER> [interest=<type>] — <action being taken>
wake: <event.name> — routine, staying in event loop
wake: <event.name> — already addressed, no-op
wake: rest-poll — broker down, polling gh api
```
Surface `.body.payload.interest_id` and `.body.payload.reason` from the wake envelope when present (broker wakes carry both); for hand-rolled two-phase filter wakes, surface the matched `event.name` and `#${PR_NUMBER}` instead. The monitor-events skill that carried the full narration rule and the good-vs-bad transcript fixture was removed with the daemon (CTL-2240); the rule above is the operative statement.
```bash
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')
PR_OPENED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Record PR opening immediately
jq --arg ts "$PR_OPENED_AT" '.pr.prOpenedAt = $ts | .status = "pr-created"' \
"$SIGNAL_FILE" > "$SIGNAL_FILE.tmp" && mv "$SIGNAL_FILE.tmp" "$SIGNAL_FILE"
# Pre-flight: verify event infrastructure (pattern inlined below; the wait-for-github skill was removed with the daemon, CTL-2240)
# CTL-572: probe .webhookTunnel.connected — the field that exists. The old
# probe read a field catalyst-monitor never emits, so it always resolved to
# "unknown" and forced REST polling on every run. .connected is optimistic
# (EventSource.isStarted(), not live delivery), so also fall back when the
# tunnel has gone quiet: no webhook event across the shared smee channel for
# TUNNEL_STALE_AGE_SEC.
INFRA_STATUS=$(catalyst-monitor status --json 2>/dev/null)
TUNNEL_STALE_AGE_SEC="${CATALYST_TUNNEL_STALE_AGE_SEC:-21600}" # 6h default
USE_REST=false
TUNNEL_CONNECTED=$(echo "$INFRA_STATUS" | jq -r '.webhookTunnel.connected // false' 2>/dev/null)
if [ "$TUNNEL_CONNECTED" != "true" ]; then
echo "WARN: webhook tunnel not connected — using REST fallback"
USE_REST=true
else
# Staleness guard. jq handles the ISO-8601 math (portable across GNU/BSD date).
# lastEventAt absent (null) => fresh monitor, NOT treated as stale.
TUNNEL_EVENT_AGE=$(echo "$INFRA_STATUS" | jq -r '
(.webhookTunnel.lastEventAt // "") as $t
| if $t == "" then ""
else (now - (($t | sub("\\.[0-9]+Z$"; "Z")) | fromdateiso8601)) | floor
end' 2>/dev/null)
if [ -n "$TUNNEL_EVENT_AGE" ] && [ "$TUNNEL_EVENT_AGE" -gt "$TUNNEL_STALE_AGE_SEC" ]; then
echo "WARN: webhook tunnel last event ${TUNNEL_EVENT_AGE}s ago (> ${TUNNEL_STALE_AGE_SEC}s) — stale, using REST fallback"
USE_REST=true
fi
fi
# CTL-303: use broker auto-correlation. Emit a second agent.checkin with claimed_pr
# so the broker auto-derives a pr_lifecycle interest — no explicit filter.register needed.
# When the broker is not running, the loop falls back to the two-phase pattern below.
USE_FILTER_DAEMON=false
PR_BASE_BRANCH=$(gh pr view "$PR_NUMBER" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "main")
if broker_claim_pr "$PR_NUMBER" "$TICKET_ID" "$(git branch --show-current)" "$REPO" "$PR_BASE_BRANCH"; then
USE_FILTER_DAEMON=true
echo "[Phase 5] Broker registered pr_lifecycle for session ${CATALYST_SESSION_ID} on PR #${PR_NUMBER}"
else
# Broker absent or unavailable — emit debug telemetry and proceed with two-phase fallback
"$STATE_SCRIPT" event "$(jq -nc \
--arg sid "${CATALYST_SESSION_ID:-}" \
--arg pr "${PR_NUMBER:-}" \
'{ts: (now | todate), event: "broker.fallback.taken",
detail: {reason: "daemon absent", session_id: $sid, pr: ($pr | tonumber? // $pr)}}')" \
2>/dev/null || true
echo "[Phase 5] Broker unavailable — using two-phase wait-for fallback for PR #${PR_NUMBER}"
fi
CI_FIX_ATTEMPTS=0
MAX_CI_FIX_ATTEMPTS=3
PR_DONE=false
while [ "$PR_DONE" = "false" ]; do
# CTL-269 preferred path: single semantic wake covers all concerns.
if [ "$USE_FILTER_DAEMON" = "true" ] && [ "$USE_REST" != "true" ]; then
EVENT=$(catalyst-events wait-for \
--filter ".attributes.\"event.name\" == \"filter.wake.${CATALYST_SESSION_ID}\"" \
--timeout 600 2>/dev/null || true)
if [ -n "$EVENT" ]; then
WAKE_REASON=$(echo "$EVENT" | jq -r '.body.payload.reason // "unknown"' 2>/dev/null || echo "unknown")
echo "[Phase 5] Filter wake: ${WAKE_REASON}"
fi
# Drain inbound comms inside the loop now that filter.wake fires on
# comms.message.posted events too — comms_check is idempotent (advances
# COMMS_LAST_READ atomically) and a no-op when nothing arrived.
comms_check
elif [ "$USE_REST" != "true" ]; then
# Fallback: two-phase event wait (wait-for-github was removed with the daemon, CTL-2240; the pattern is inlined here).
# Filter field reference: [[event-schema]] — note check_suite/workflow_run use
# detail.prNumbers, not scope.pr. PR/review events DO populate scope.pr.
EVENT=$(catalyst-events wait-for \
--filter "(.attributes.\"vcs.pr.number\" == ${PR_NUMBER} or (.body.payload.prNumbers // [] | contains([${PR_NUMBER}]))) and (
.attributes.\"event.name\" == \"github.pr.merged\" or
.attributes.\"event.name\" == \"github.check_suite.completed\" or
(.attributes.\"event.name\" | startswith(\"github.pr_review\")) or
.attributes.\"event.name\" == \"github.push\"
)" \
--timeout 180 2>/dev/null || true)
if [ -z "$EVENT" ]; then
# Phase 1 timed out — run diagnostics (diagnostic block inlined below)
_LOG_FILE=~/catalyst/events/$(date -u +%Y-%m).jsonl
_LOG_LINES=$(wc -l < "$_LOG_FILE" 2>/dev/null | tr -d ' ')
_SINCE_LINE=$(( ${_LOG_LINES:-0} > 500 ? ${_LOG_LINES:-0} - 500 : 0 ))
HEARTBEATS=$(catalyst-events tail --since-line "$_SINCE_LINE" 2>/dev/null \
| jq -c 'select(.attributes."event.name" == "heartbeat")' | wc -l | tr -d ' ')
# CTL-572: probe .webhookTunnel.connected (the field that exists). The
# HEARTBEATS == 0 term remains the stronger liveness signal here.
TUNNEL_NOW=$(catalyst-monitor status --json 2>/dev/null \
| jq -r '.webhookTunnel.connected // false')
if [ "${HEARTBEATS:-0}" -eq 0 ] || [ "$TUNNEL_NOW" != "true" ]; then
echo "Infrastructure issue detected — switching to REST polling"
USE_REST=true
else
# Infrastructure healthy — extend to Phase 2 (7200s)
EVENT=$(catalyst-events wait-for \
--filter "(.attributes.\"vcs.pr.number\" == ${PR_NUMBER} or (.body.payload.prNumbers // [] | contains([${PR_NUMBER}]))) and (
.attributes.\"event.name\" == \"github.pr.merged\" or
.attributes.\"event.name\" == \"github.check_suite.completed\" or
(.attributes.\"event.name\" | startswith(\"github.pr_review\")) or
.attributes.\"event.name\" == \"github.push\"
)" \
--timeout 7200 2>/dev/null || true)
fi
fi
# Drain inbound comms after each wake so messages don't sit until phase boundary.
comms_check
fi
# Authoritative REST check — never gh pr view --json (GraphQL); REST only
PR_JSON=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" 2>/dev/null || echo '{}')
PR_MERGED=$(echo "$PR_JSON" | jq -r '.merged // false')
MERGE_STATE=$(echo "$PR_JSON" | jq -r '.mergeable_state // "unknown"')
# REST .mergeable_state values: "clean", "blocked", "behind", "dirty", "unknown", "unstable"
if [ "$PR_MERGED" = "true" ]; then
PR_DONE=true; break
fi
# Check for human reviewer changes-requested (escalates to stalled)
LAST_CR=$(gh pr view "$PR_NUMBER" --json reviews \
--jq '[.reviews[] | select(.state == "CHANGES_REQUESTED")] | last | .author.login // ""' \
2>/dev/null || echo "")
if [ -n "$LAST_CR" ]; then
ERROR_MSG="Changes requested by human reviewer ${LAST_CR} — operator action required"
NEW_STATUS="stalled"; PHASE_NUM=5
jq --arg status "stalled" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.status = $status | .updatedAt = $ts' \
"$SIGNAL_FILE" > "$SIGNAL_FILE.tmp" && mv "$SIGNAL_FILE.tmp" "$SIGNAL_FILE"
comms_post attention "stalled: ${ERROR_MSG}"
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" end "$CATALYST_SESSION_ID" --status failed --reason "changes requested by ${LAST_CR}"
fi
exit 1
fi
case "$MERGE_STATE" in
clean)
# CI passed and reviews satisfied — proceed to Step 3
PR_DONE=true
;;
blocked)
# Unresolved bot review threads or CI failure
UNRESOLVED=$(gh pr view "$PR_NUMBER" --json reviewThreads \
--jq '[.reviewThreads[] | select(.isResolved == false)] | length' 2>/dev/null || echo 0)
if [ "${UNRESOLVED:-0}" -gt 0 ]; then
# Bot review threads — resolve via /review-comments, then loop
/catalyst-dev:review-comments "$PR_NUMBER"
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" iteration "$CATALYST_SESSION_ID" --kind fix
fi
elif [ "$CI_FIX_ATTEMPTS" -lt "$MAX_CI_FIX_ATTEMPTS" ]; then
# CI failure — attempt automated fix, push commit, then loop
CI_FIX_ATTEMPTS=$((CI_FIX_ATTEMPTS + 1))
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" iteration "$CATALYST_SESSION_ID" --kind fix
fi
# Analyze CI failure from check run logs and push a targeted fix commit
# (per the Phase 4 quality gate retry pattern)
else
ERROR_MSG="CI blocked after ${MAX_CI_FIX_ATTEMPTS} fix attempts — escalating"
NEW_STATUS="stalled"; PHASE_NUM=5
jq --arg status "stalled" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.status = $status | .updatedAt = $ts' \
"$SIGNAL_FILE" > "$SIGNAL_FILE.tmp" && mv "$SIGNAL_FILE.tmp" "$SIGNAL_FILE"
comms_post attention "stalled: ${ERROR_MSG}"
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" end "$CATALYST_SESSION_ID" --status failed --reason "CI blocked after ${MAX_CI_FIX_ATTEMPTS} fix attempts"
fi
exit 1
fi
;;
behind)
# Branch is behind base — rebase and push
BASE_BRANCH_NAME=$(git remote show origin 2>/dev/null \
| grep "HEAD branch" | awk '{print $NF}')
git fetch origin && git rebase "origin/${BASE_BRANCH_NAME:-main}"
git push --force-with-lease
;;
dirty)
ERROR_MSG="Merge conflicts (DIRTY) — cannot auto-resolve"
NEW_STATUS="stalled"; PHASE_NUM=5
jq --arg status "stalled" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.status = $status | .updatedAt = $ts' \
"$SIGNAL_FILE" > "$SIGNAL_FILE.tmp" && mv "$SIGNAL_FILE.tmp" "$SIGNAL_FILE"
comms_post attention "stalled: ${ERROR_MSG}"
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" end "$CATALYST_SESSION_ID" --status failed --reason "merge conflicts (DIRTY)"
fi
exit 1
;;
unknown|unstable)
# Transient state — continue waiting for next event
;;
esac
# REST fallback sleep interval (no event tunnel)
[ "$USE_REST" = "true" ] && sleep 300
done
```
**Step 3: Merge + Record Success**
PR is CLEAN (or already merged). Execute the merge directly (no `--auto`), optionally verify
deployment, write `status: "done"`, and exit.
```bash
# Execute merge now that PR is ready (no --auto; worker owns the merge in CTL-252 contract)
if [ "$PR_MERGED" != "true" ]; then
# CTL-56: capture head ref BEFORE merge so we can delete it checkout-free after confirm.
HEAD_REF=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.ref' 2>/dev/null || true)
# Merge via REST only (CTL-56: dropping the local branch-cleanup flag avoids the
# `git checkout <base>` that fails inside a linked worktree when main is checked
# out in the primary clone). The exit code is now meaningful; REST confirm below
# is the authoritative success gate.
gh pr merge "$PR_NUMBER" --squash
fi
# Confirm via REST (authoritative — never gh pr view --json)
MERGED_OK=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.merged' 2>/dev/null || echo "false")
if [ "$MERGED_OK" != "true" ]; then
ERROR_MSG="gh pr merge succeeded but REST confirms PR not merged — escalating"
comms_post attention "stalled: ${ERROR_MSG}"
jq --arg status "stalled" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.status = $status | .updatedAt = $ts' \
"$SIGNAL_FILE" > "$SIGNAL_FILE.tmp" && mv "$SIGNAL_FILE.tmp" "$SIGNAL_FILE"
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" end "$CATALYST_SESSION_ID" --status failed --reason "merge not confirmed via REST"
fi
exit 1
fi
# CTL-56: delete the remote head ref checkout-free, AFTER merge is REST-confirmed. Idempotent +
# best-effort: a 404/422 means the ref is already gone or protected; never fail the phase on
# branch cleanup — the merge already landed.
if [[ -n "${HEAD_REF:-}" ]]; then
# CTL-56: URL-encode the head ref (preserve '/') so a metacharacter like '#' in a branch name
# (e.g. feature#123) can't truncate the endpoint into deleting the wrong ref.
enc_ref=$(printf '%s' "$HEAD_REF" | jq -sRr @uri | sed 's|%2F|/|g')
gh api --method DELETE "repos/${REPO}/git/refs/heads/${enc_ref}" >/dev/null 2>&1 \
|| echo "CTL-56: remote branch ${HEAD_REF} delete skipped (already gone or protected)" >&2
fi
MERGE_COMMIT_SHA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" \
--jq '.merge_commit_sha // empty' 2>/dev/null || echo "")
MERGED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Optional deployment verification (CTL-211)
SKIP_DEPLOY=$(jq -r --arg repo "${REPO}" \
'.catalyst.deploy[$repo].skipDeployVerification // true' .catalyst/config.json 2>/dev/null \
|| echo "true")
PROD_ENV=$(jq -r --arg repo "${REPO}" \
'.catalyst.deploy[$repo].productionEnvironment // "production"' .catalyst/config.json 2>/dev/null)
DEPLOYMENT_URL=""
if [ "$SKIP_DEPLOY" != "true" ] && [ -n "$MERGE_COMMIT_SHA" ]; then
# Two-phase wait for deployment_status (wait-for-github was removed with the daemon, CTL-2240)
DEPLOY_TIMEOUT=$(jq -r --arg repo "${REPO}" \
'.catalyst.deploy[$repo].timeoutSec // 1800' .catalyst/config.json 2>/dev/null || echo 1800)
DEPLOY_EVENT=$(catalyst-events wait-for \
--filter "(.attributes.\"event.name\" | startswith(\"github.deployment_status\")) and
.attributes.\"deployment.environment\" == \"${PROD_ENV}\" and
.attributes.\"vcs.revision\" == \"${MERGE_COMMIT_SHA}\"" \
--timeout 180 2>/dev/null || true)
# Authoritative deploy lookup (REST fallback pattern inlined below)
DEPLOY_JSON=$(gh api -X GET "/repos/${REPO}/deployments" \
-f sha="$MERGE_COMMIT_SHA" -f environment="$PROD_ENV" --jq '.[0] // empty' 2>/dev/null || echo "")
if [ -n "$DEPLOY_JSON" ]; then
DEPLOY_ID=$(echo "$DEPLOY_JSON" | jq -r '.id // empty')
STATUS_JSON=$(gh api "/repos/${REPO}/deployments/${DEPLOY_ID}/statuses" \
--jq '.[0] // empty' 2>/dev/null || echo "")
DEPLOY_STATE=$(echo "$STATUS_JSON" | jq -r '.state // "pending"')
DEPLOYMENT_URL=$(echo "$STATUS_JSON" | jq -r '.environment_url // empty')
if [ "$DEPLOY_STATE" = "failure" ] || [ "$DEPLOY_STATE" = "error" ]; then
jq --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.status = "deploy-failed" | .updatedAt = $ts' \
"$SIGNAL_FILE" > "$SIGNAL_FILE.tmp" && mv "$SIGNAL_FILE.tmp" "$SIGNAL_FILE"
comms_post attention "deploy-failed: ${PROD_ENV} deploy failed for PR #${PR_NUMBER}"
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" end "$CATALYST_SESSION_ID" --status failed --reason "deployment failed in ${PROD_ENV}"
fi
exit 1
fi
fi
fi
# Record merge in signal file — worker writes status=done (CTL-252 contract)
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
jq --arg ts "$MERGED_AT" --arg now "$TS" --arg sha "${MERGE_COMMIT_SHA:-}" \
--arg deploy_url "${DEPLOYMENT_URL:-}" \
'.pr.mergedAt = $ts | .pr.ciStatus = "merged"
| (if $sha != "" then .pr.mergeCommitSha = $sha else . end)
| .status = "done" | .phase = 5 | .updatedAt = $now
| .completedAt = $now | .phaseTimestamps.done = $now
| (if $deploy_url != "" then .deployment = {url: $deploy_url} else . end)' \
"$SIGNAL_FILE" > "${SIGNAL_FILE}.tmp" && mv "${SIGNAL_FILE}.tmp" "$SIGNAL_FILE"
# Update global state
if [ -n "$ORCH_ID" ] && [ -f "$STATE_SCRIPT" ]; then
"$STATE_SCRIPT" worker "$ORCH_ID" "$TICKET_ID" \
".status = \"done\" | .phase = 5 | .pr.mergedAt = \"${MERGED_AT}\" | .pr.ciStatus = \"merged\""
"$STATE_SCRIPT" event "$(jq -nc \
--arg ts "$TS" --arg orch "$ORCH_ID" --arg w "$TICKET_ID" \
--argjson pr "$PR_NUMBER" --arg mt "$MERGED_AT" \
'{ts:$ts, orchestrator:$orch, worker:$w, event:"worker-pr-merged", detail:{pr:$pr, mergedAt:$mt}}')"
"$STATE_SCRIPT" event "$(jq -nc --arg ts "$TS" --arg orch "$ORCH_ID" --arg w "$TICKET_ID" \
'{ts:$ts, orchestrator:$orch, worker:$w, event:"worker-done", detail:null}')"
fi
# Transition Linear ticket to done (worker owns this in CTL-252 contract)
"${CATALYST_DEV_SCRIPTS}/linear-transition.sh" \
--ticket "$TICKET_ID" --transition done --config .catalyst/config.json 2>/dev/null || true
# End session
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" pr "$CATALYST_SESSION_ID" --number "$PR_NUMBER" --url "$PR_URL"
"$SESSION_SCRIPT" end "$CATALYST_SESSION_ID" --status done --reason "PR merged"
fi
# CTL-111: post done to shared comms channel
if [ -n "${CATALYST_COMMS_CHANNEL:-}" ] && [ -n "$COMMS_BIN" ]; then
"$COMMS_BIN" done "$CATALYST_COMMS_CHANNEL" --as "$TICKET_ID" >/dev/null 2>&1 || true
fi
```
**Step 4: Optional Rollup Fragment Contribution (CTL-108)**
Before exiting, under orchestrator mode only (`ORCH_DIR` set), the worker MAY write a short markdown
fragment describing anything surprising, risky, or worth flagging to human reviewers of the whole
orchestrator's output:
```bash
if [ -n "$ORCH_DIR" ] && [ -d "$ORCH_DIR/workers" ]; then
FRAGMENT_PATH="${ORCH_DIR}/workers/${TICKET_ID}-rollup.md"
# Write a short note — first line is the one-liner used in the rollup "what shipped" list.
cat > "$FRAGMENT_PATH" <<EOF
One-sentence summary of what shipped and any reviewer heads-up.
Additional context, migration notes, follow-up tickets, etc.
EOF
fi
```
- **File name**: MUST match `${TICKET_ID}-rollup.md` exactly — the orch-monitor scans for this
pattern to assemble the orchestrator-level rollup briefing.
- **Content**: keep it short. The first non-blank line becomes the one-liner in the "What shipped"
list in the orch-monitor UI. The rest appears under a `### ${TICKET_ID}` heading in the "Gotchas"
section.
- **Optional**: skip the fragment if there is nothing reviewers need to know beyond the PR title —
not having a fragment is the norm, not the exception.
- **No orchestrator mode**: do nothing (standalone oneshot runs do not write fragments).
The orchestrator's Phase 4 poll loop transitions the Linear ticket to `stateMap.done` when it
confirms `state=MERGED` via the shared helper (CTL-69):
```bash
"${CATALYST_DEV_SCRIPTS}/linear-transition.sh" \
--ticket "$TICKET_ID" --transition done --config .catalyst/config.json
```
In standalone mode (no orchestrator), the user runs `/catalyst-dev:merge-pr` which handles this
transition.
**Step 5: File improvement findings (CTL-176 / CTL-183 routing)**
Drain the findings queue and file one ticket per entry. Orchestrator-dispatched oneshot runs share
the orchestrator's queue (`$CATALYST_FINDINGS_FILE=$ORCH_DIR/findings.jsonl`) and the orchestrator's
Phase 7 files everything — this step is still safe to run and will find an empty queue in that case.
Standalone oneshot runs (no orchestrator) use a session-scoped queue path derived from
`$CATALYST_SESSION_ID`, falling back to `.catalyst/findings/current.jsonl`.
**Recording findings during the run.** The moment you notice friction worth fixing (workflow gaps,
bugs spotted in adjacent code, recurring manual steps), record it on the queue:
```bash
"${CATALYST_DEV_SCRIPTS}/add-finding.sh" \
--title "Short imperative title" \
--body "Reproduction + expected + observed + any links" \
--skill oneshot --severity low
```
Record inline, not as a post-run retrospective — context compaction loses observations that wait.
Don't prompt the user; don't batch. Step 5 below files the whole queue in one pass.
**What counts:** friction the maintainer would want fixed, bugs in adjacent catalyst code spotted
incidentally, gaps in tooling, manual steps that should be automated. **What doesn't:** this
ticket's own follow-up TODOs (PR body), user preferences that should be durable memory, routine
debugging. In orchestrator-dispatched workers, stdin is not a TTY and `CATALYST_AUTONOMOUS=1` is
expected to be set — the helper silently skips filing when consent is not already granted, never
prompts. Standalone oneshot runs prompt interactively once and persist "yes":
```bash
FEEDBACK="${CATALYST_DEV_SCRIPTS}/file-feedback.sh"
CONSENT="${CATALYST_DEV_SCRIPTS}/feedback-consent.sh"
FINDINGS_FILE="${CATALYST_FINDINGS_FILE:-.catalyst/findings/${CATALYST_SESSION_ID:-current}.jsonl}"
if [ -x "$FEEDBACK" ] && [ -f "$FINDINGS_FILE" ] && [ -s "$FINDINGS_FILE" ]; then
COUNT=$(wc -l < "$FINDINGS_FILE" | tr -d ' ')
if [ "$("$CONSENT" check)" != "granted" ] && [ -z "${CATALYST_AUTONOMOUS:-}" ] && [ -t 0 ]; then
read -r -p "File $COUNT improvement tickets at end of run? [Y/n] " yn
case "$yn" in [Nn]*) : ;; *) "$CONSENT" grant >/dev/null ;; esac
fi
if [ "$("$CONSENT" check)" = "granted" ]; then
FILED=0
while IFS= read -r line; do
TITLE=$(jq -r '.title' <<<"$line")
BODY=$(jq -r '.body' <<<"$line")
SKILL=$(jq -r '.skill // "oneshot"' <<<"$line")
RESULT=$("$FEEDBACK" --title "$TITLE" --body "$BODY" --skill "$SKILL" --json 2>/dev/null || true)
STATUS=$(jq -r '.status // "failed"' <<<"$RESULT")
if [ "$STATUS" = "filed" ]; then
ID=$(jq -r '.identifier // .url // ""' <<<"$RESULT")
echo " filed: $ID ($TITLE)"
FILED=$((FILED + 1))
fi
done < "$FINDINGS_FILE"
[ "$FILED" -eq "$COUNT" ] && rm -f "$FINDINGS_FILE"
fi
fi
```
**If `--no-merge` was set**, skip Steps 2–3 (listen loop and merge) entirely and report PR status
instead:
```
PR ready: https://github.com/org/repo/pull/{number}
Merge state: $mergeStateStatus
✅ CI passed
✅ Threads resolved ({N} addressed)
✅ Reviews addressed
❌ Review required — 1 approval needed (if applicable)
Merge later with: /catalyst-dev:merge-pr
```
**Linear**: `/create-pr` moves ticket to `stateMap.inReview` (default: "In Review"). The worker
transitions it to `stateMap.done` after merge in Step 3; the orchestrator's Phase 4 handles this
only as a fallback for stalled workers.
### Phase 6: (deprecated)
Phase 6 used to run `/merge-pr` separately. Workers now exit at `status: "done"` after actively
merging their own PR and verifying deployment (CTL-252). `/merge-pr` is still useful as a standalone
tool for merging a PR opened outside the oneshot flow.
## Team Mode (Optional)
For complex implementations spanning multiple files/layers:
```
/catalyst-dev:oneshot --team PROJ-123
```
In team mode, Phase 3 uses agent teams for parallel implementation:
- Lead agent (Opus) coordinates the implementation
- Teammates (Sonnet) each own distinct file groups
- Each teammate can spawn their own research sub-agents
- Lead reviews teammate work via plan approval gates
**When to use `--team`:**
- Implementation spans 3+ files across different domains (frontend + backend + tests)
- Multiple independent components can be implemented in parallel
- Complex cross-cutting features
**When NOT to use `--team`:**
- Simple sequential changes
- Changes to a single file or closely related files
- Quick bug fixes
## Context Management Strategy
All phases run in a single session. Context is managed through:
1. **Automatic compaction** — Claude Code compresses prior messages as the conversation approaches
context limits. This happens transparently and allows long-running workflows.
2. **Thoughts as persistent handoff** — Each phase writes its output to `thoughts/shared/` (research
documents, plans). Subsequent phases read these files, so the essential information is always
available even after compaction.
3. **Agent teams for parallelism** — When a phase needs to do parallel work (research sub-agents,
team-mode implementation), it spawns Agent subagents. Each subagent gets its own context window
and returns a summary, keeping the main session's context lean.
```
Phase 1: Research — spawns parallel sub-agents, writes to thoughts/shared/research/
Phase 2: Plan — reads research doc, runs /create-plan, writes to thoughts/shared/plans/
Phase 3: Implement — reads plan doc, runs /implement-plan (can use --team for agent teams)
Phase 4: Validate — reads plan doc, runs /validate-plan + quality gates
Phase 5: Ship — runs /create-pr, enters active listen loop (event-driven, resolves CI/review
blockers inline), merges when CLEAN, verifies deployment, writes status=done, exits.
Orchestrator Phase 4 is a safety-net fallback for stalled/crashed workers only.
```
## Configuration
### Quality Gates
Configure quality gates in the consuming project's `.catalyst/config.json`:
```json
{
"catalyst": {
"qualityGates": {
"enabled": true,
"maxRetries": 3,
"gates": [
{
"name": "typecheck",
"command": "npm run type-check",
"required": true,
"autofix": true,
"order": 1
},
{
"name": "lint",
"command": "npm run lint:fix",
"required": true,
"autofix": true,
"order": 2
},
{
"name": "test",
"command": "npm run test",
"required": true,
"autofix": false,
"order": 3
},
{
"name": "build",
"command": "npm run build",
"required": true,
"autofix": false,
"order": 4
}
]
}
}
}
```
**Schema:**
| Field | Type | Description |
| ------------------ | ------- | -------------------------------------------------------------------- |
| `enabled` | boolean | Master toggle for quality gates (default: `true`) |
| `maxRetries` | number | Max retry cycles across all gates (default: `3`) |
| `gates[].name` | string | Display name for the gate |
| `gates[].command` | string | Shell command to run |
| `gates[].required` | boolean | If `true`, failure blocks shipping. If `false`, failure is a warning |
| `gates[].autofix` | boolean | If `true`, attempt automated fixes on failure before retrying |
| `gates[].order` | number | Execution order (lowest first) |
**Backward compatibility:** If `qualityGates` is absent, the command falls back to constructing
gates from `catalyst.pr.typecheckCommand`, `catalyst.pr.lintCommand`, `catalyst.pr.testCommand`, and
`catalyst.pr.buildCommand`. If none of those exist, quality gates are skipped entirely.
### Model Selection
All phases run in the current session using whatever model the session was started with. When
running as an orchestrator worker, the model is set by the orchestrator's `workerModel` config
(default: Opus).
## Linear Integration
State transitions throughout the lifecycle:
| Phase | Transition | Config Key | Default |
| ---------------------------------- | ------------ | --------------------- | ------------- |
| 1 start | → research | `stateMap.research` | "In Progress" |
| 1 end (ticket created in freeform) | → backlog | `stateMap.backlog` | "Backlog" |
| 2 start | → planning | `stateMap.planning` | "In Progress" |
| 3 start | → inProgress | `stateMap.inProgress` | "In Progress" |
| 5 (PR created) | → inReview | `stateMap.inReview` | "In Review" |
| 5 (PR merged by worker) | → done | `stateMap.done` | "Done" |
The worker transitions the ticket to `stateMap.done` after actively merging the PR in Step 3. The
orchestrator's Phase 4 handles this transition only as a fallback for workers that stalled before
completing their own merge (CTL-252).
## Error Handling
**All error paths must end the session.** Before presenting errors or creating handoffs, always run:
```bash
if [[ -n "${CATALYST_SESSION_ID:-}" && -x "$SESSION_SCRIPT" ]]; then
"$SESSION_SCRIPT" end "$CATALYST_SESSION_ID" --status failed --reason "<why it failed>"
fi
```
**If research phase fails:**
- End session with `--status failed`
- Save partial findings to thoughts/
- Present error to user
- Suggest running `/catalyst-dev:research-codebase` manually
**If implementation fails:**
- End session with `--status failed`
- Partial work is preserved (uncommitted)
- Handoff document created automatically
- User can resume with `/catalyst-dev:resume-handoff`
**If quality gates fail after max retries:**
- Present failures with options (fix, continue, handoff)
- If user continues, gates are marked as skipped in PR description
- If user creates handoff, end session with `--status failed`, remaining phases are documented for
next session
**If CI checks fail in Phase 5:**
- Worker detects the CI failure in the active listen loop (Step 2) via REST check on
`mergeable_state`
- Worker attempts automated fix (up to 3 times) — analyzes CI failure, pushes fix commit, continues
loop
- After 3 failed fix attempts, worker writes `status: "stalled"` and posts `attention` to comms
- The orchestrator's Phase 4 then dispatches a fix-up worker via `orchestrate-auto-fixup` (CTL-64)
**Automatic handoff on stop:** When the workflow stops at any phase (user choice, unrecoverable
error, context exhaustion):
- End session with `--status failed`
- Invoke `/create-handoff` with: phases completed, current phase status, unresolved issues,
CI/review status, and remaining phases
- Save handoff to `thoughts/shared/handoffs/`
- User can resume with `/catalyst-dev:resume-handoff`
## Important
- **All phases run in the current session** — no separate processes are spawned
- **thoughts/ is the handoff mechanism** — all documents persist between phases and survive
compaction
- **NEVER add Claude attribution** to any generated artifacts
- **Use wiki-links** for cross-references between thoughts documents (e.g., `[[filename]]`), not
full paths
- **Phase 3 does NOT commit** — all git operations are deferred to Phase 5
- **Worker's success contract is `status: "done"` (CTL-252)** — the worker opens the PR, enters an
event-driven listen loop using `catalyst-events wait-for`, resolves CI/review blockers inline,
merges when CLEAN with `gh pr merge --squash` (worktree-safe, CTL-56; no `--auto`), and writes
`status: "done"` with `pr.mergedAt` and `deployment.url` (if applicable). Workers do NOT use
`ScheduleWakeup` (unreliable in `-p` mode) — they use `catalyst-events wait-for` which is a
blocking subprocess call that works reliably in non-interactive sessions
- **Worker handles BEHIND, CI failures, and bot review threads inline** — in the Phase 5 listen
loop; the orchestrator's Phase 4 is a safety-net fallback for workers that write
`status: "stalled"`
- **Worker writes `pr.mergedAt` + `status: "done"`** — after actively merging the PR in Phase 5
Step 3. The orchestrator's Phase 4 handles this only for workers that stalled before completing
- **Worker exits cleanly after writing `status: "done"`** — this is the expected success path. The
orchestrator distinguishes this from stalls (no PR, no progress for 15+ minutes)
- **Worker comms discipline** — when posting to the shared comms channel, follow the rules in the `catalyst-comms` CLI's posting discipline (`plugins/dev/scripts/catalyst-comms`; the skill documenting it was removed with the daemon, CTL-2240): `info` is the default heartbeat (phase transitions only, ~5–7 per session), `attention` is reserved for orchestrator action (0–2 per session, MANDATORY on the escalation triggers listed there — scope conflict, missing access, ambiguous spec, 3+ repeated CI failures, `status="stalled"`), `done` fires once at terminal success via the `done` subcommand. The existing `comms_post` helper in this skill already routes correctly — these rules govern _when_ you call it.
- **Worker inbound reads (CTL-249)** — `comms_check` is called after each phase transition via the signal-file update block. It polls for messages directed to `$TICKET_ID` (skipping pre-worker history via `$COMMS_LAST_READ`), logs all inbound messages, and exits on `abort`. `catalyst-comms send` already emits `comms.message.posted` events to the global event log (CTL-210), so Option B event emission is complete — extending `catalyst-events wait-for` to include `comms.message` filters is tracked in CTL-247 (the wait-for-github skill that carried it was removed with the daemon, CTL-2240).
**IMPORTANT: Document Storage Rules**
- ALWAYS write to `thoughts/shared/` (research, plans, prs subdirectories)
- NEVER write to `thoughts/searchable/` — this is a read-only search index
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!