oneshot · v3.0.0 · 2026-09-13 · sha256 a6e0b0c2da12b85f

oneshot v3.0.0B

Immutable. This exact content is served forever at /api/v1/blob/a6e0b0c2da12b85f.

---
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)
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. **If ticket**: Read ticket details via Linearis CLI, move to `stateMap.research` (default: "In
   Progress")
3. **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, and use that ticket ID from here on.
4. **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/` 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