phase-triage
Greenfield phase agent shipped in CTL-451 (Initiative 1 Phase 5). Reads a Linear ticket, produces a structured triage analysis, and lands the analysis as a Linear comment + label so subsequent phase agents (research, plan, …) can rely on the classification.
The skill is dispatched in two modes:
- Production: An Opus phase agent reads
triage.jsonplaceholder fields produced by the bash body below, then refines them with model-quality analysis (acronym expansion, judgment-of-scope, dep inference) before the comment is posted. - CI / test runner: The bash body alone is self-sufficient — it derives all five fields deterministically from the ticket JSON so e2e tests run without a model call.
Both modes produce the same triage.json shape and emit the same canonical phase event.
Use-case conformance (Opus mode)
When refining the analysis, also assess whether the ticket meets the /catalyst-dev:gherkin-ticket
standard: an outcome-first title (not a mechanism/file/symbol name) and a body that opens with a
plain-English use case followed by tiered Gherkin acceptance criteria. If it does not conform,
add one line to the triage analysis comment flagging it — e.g. "⚠️ Ticket does not lead with a
use-case (per gherkin-ticket); consider rewriting the title/opening for scannability." This
surfaces non-conformant tickets for the operator without auto-rewriting them — triage is a
documentarian, so do not edit the ticket's title or description here; flag only. (The
deterministic bash body and triage.json schema are unchanged.)
/goal
/goal "I have written ${WORKER_DIR}/triage.json populated with all five fields —
classification (feature|bug|docs|refactor|chore), estimated_scope,
acronyms_expanded, dependencies, and a non-empty summary — refined with
real analysis (not just the deterministic placeholders), AND posted the
triage analysis comment to the Linear ticket, AND printed the triage.json
path on stdout."
CTL-656: the /goal evaluator keeps the agent working until the triage is genuinely complete —
every field populated and the Linear comment posted — instead of emitting the first-pass
deterministic placeholder and exiting. A real blocker (unreadable ticket, a Linear 4xx on the
comment) surfaces as needs-input rather than a silently-thin triage.json. (Production/Opus mode
only; the CI bash body is self-sufficient and does not invoke the evaluator.)
Triage completion signal
Triage completion is recorded by two artifacts, not a Linear label: the analysis comment this skill
posts to the ticket, and the local triage.json the coordinator reads (hasTriageArtifact). There
is no triaged workspace label — the daemon never writes one.
Inputs
Environment:
TICKET— Linear identifier (e.g.CTL-451). Required; falls back toCATALYST_TICKET(the env the dispatcher actually sets — CTL-1441).WORKER_DIR— output directory fortriage.json. Defaults to${ORCH_DIR}/workers/${TICKET}, else the canonical~/catalyst/execution-core/workers/${TICKET}— NEVER$(pwd)(a triage.json outside the worker dir is invisible to the monitor and re-triages forever; CTL-1441/CTL-1403).ORCH_DIR— falls back toCATALYST_ORCHESTRATOR_DIR.CATALYST_ORCHESTRATOR_ID,CATALYST_SESSION_ID— used for trace/span id derivation in the emitted event; optional.
Body
set -uo pipefail
# Resolve repo root from the skill location so the helper path works whether
# run from a worktree, the plugin cache, or a checked-out clone.
__PT_SCRIPT_PATH="${BASH_SOURCE[0]:-${0}}"
__PT_SKILL_DIR="$(cd "$(dirname "$__PT_SCRIPT_PATH")" && pwd 2>/dev/null || pwd)"
__PT_REPO_ROOT="${PHASE_AGENT_REPO_ROOT:-$(cd "$__PT_SKILL_DIR/../../../.." 2>/dev/null && pwd || pwd)}"
# CTL-1410 Phase A: terminal events go through the production wrapper
# (phase-agent-emit-complete) instead of the event-only phase-emit-complete.sh
# lib helper, so the phase signal file's `status` field is flipped to done/failed
# canonically in-band. Triage previously delegated that flip to the bg-only
# reclaim path, which silently no-ops under executor=sdk (bg_job_id null →
# classifyWorker "unknown" → reclaim "noop") — the strand this phase closes. The
# wrapper also owns the broker emit, the session-DB close, and completedAt.
__PT_WRAPPER="${PHASE_EMIT_WRAPPER:-${__PT_REPO_ROOT}/plugins/dev/scripts/phase-agent-emit-complete}"
if [[ ! -x "$__PT_WRAPPER" ]]; then
echo "phase-triage: cannot find phase-agent-emit-complete wrapper at $__PT_WRAPPER" >&2
exit 1
fi
# CTL-1397: direct-SQLite Linear reads (replica-first, loud linearis fallback).
__PT_READ_LIB="${PHASE_LINEAR_READ_HELPER:-${__PT_REPO_ROOT}/plugins/dev/scripts/lib/linear-read-replica.sh}"
if [[ ! -r "$__PT_READ_LIB" ]]; then
echo "phase-triage: cannot find linear-read-replica.sh at $__PT_READ_LIB" >&2
exit 1
fi
# shellcheck disable=SC1090
. "$__PT_READ_LIB"
# CTL-1441: the CATALYST_-prefixed env WINS — that is the channel
# phase-agent-dispatch actually populates (DISPATCH_ENV / worker settings), and
# it must beat any stale ambient bare TICKET/ORCH_DIR inherited from a shell or
# a prior invocation (Codex P2 on #2588). The bare names exist only via
# slash-command substitution — exactly the fragile channel that produced the
# CTL-1403 re-triage loop: a substitution miss let WORKER_DIR fall back to
# $(pwd), triage.json landed outside the worker dir, and the monitor (blind to
# it) re-dispatched forever. Bare names remain the operator-sweep fallback.
TICKET="${CATALYST_TICKET:-${TICKET:-}}"
: "${TICKET:?phase-triage: TICKET env var required}"
# CTL-1441: NEVER fall back to $(pwd) for the worker dir — a triage.json outside
# it is invisible to hasTriageArtifact and re-triages forever. Resolve ORCH_DIR
# itself to the canonical location (honoring a custom CATALYST_DIR install) and
# EXPORT it, so the phase-agent-emit-complete wrapper's signal flip — gated on
# CATALYST_ORCHESTRATOR_DIR — lands in the SAME tree as triage.json (Codex R2:
# a worker-dir-only fallback left phase-triage.json never flipping to done,
# blocking triage→research advancement).
ORCH_DIR="${CATALYST_ORCHESTRATOR_DIR:-${ORCH_DIR:-}}"
if [[ -z "$ORCH_DIR" ]]; then
ORCH_DIR="${CATALYST_DIR:-${HOME}/catalyst}/execution-core"
echo "phase-triage: orchestrator dir unset — defaulting to ${ORCH_DIR} (CTL-1441)" >&2
fi
export CATALYST_ORCHESTRATOR_DIR="${CATALYST_ORCHESTRATOR_DIR:-$ORCH_DIR}"
WORKER_DIR="${WORKER_DIR:-${ORCH_DIR}/workers/${TICKET}}"
mkdir -p "$WORKER_DIR"
# 1. Read ticket via direct SQL against the replica (CTL-1397 — never bare
# linearis; the helper falls back loudly to linearis when the replica is
# stale/absent). stderr is left visible so the loud fallback surfaces in the
# phase log. Test stubs point CATALYST_REPLICA_DB at a nonexistent path to
# force the deterministic linearis-stub fallback.
TICKET_JSON_FILE="$(mktemp)"
trap 'rm -f "$TICKET_JSON_FILE"' EXIT
if ! linear_read_ticket "$TICKET" > "$TICKET_JSON_FILE"; then
"$__PT_WRAPPER" --phase triage --ticket "$TICKET" --status failed \
--reason "linear read failed"
exit 1
fi
if ! jq -e . "$TICKET_JSON_FILE" >/dev/null 2>&1; then
"$__PT_WRAPPER" --phase triage --ticket "$TICKET" --status failed \
--reason "linear read returned non-JSON output"
exit 1
fi
TITLE="$(jq -r '.title // ""' "$TICKET_JSON_FILE")"
DESCRIPTION="$(jq -r '.description // ""' "$TICKET_JSON_FILE")"
COMBINED="${TITLE}
${DESCRIPTION}"
# 2. Derive the five triage fields deterministically (the bash fallback path).
# 2a. Classification — first-match over a small regex table. Inlined (no helper
# function) so the body carries no bare positional parameter. At dispatch the
# skill is rendered as a slash command (`/catalyst-dev:phase-triage <TICKET>
# --orch-dir <PATH>`) and Claude Code substitutes bare positional tokens
# everywhere — including inside this fenced ba