plugins/dev/skills/phase-triage/SKILL.md
--- name: phase-triage description: Phase agent that triages a Linear ticket — expands acronyms, classifies (feature/bug/docs/refactor/chore), identifies genuine blockers (a semantic second-pass over the backlog — NOT a prose scrape; CTL-838), estimates scope, writes triage.json, and posts a triage analysis comment to Linear. Triage completion is signaled by that comment plus the local triage.json — there is no `triaged` label. Emits phase.triage.complete.<TICKET> on success and phase.triage.fai
npx skillsauth add coalesce-labs/catalyst plugins/dev/skills/phase-triageInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Greenfield phase agent shipped in CTL-451 (Initiative 1 Phase 5). 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:
triage.json placeholder 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.Both modes produce the same triage.json shape and emit the same canonical phase event.
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 "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 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.
Environment:
TICKET — Linear identifier (e.g. CTL-451). Required; falls back to CATALYST_TICKET (the
env the dispatcher actually sets — CTL-1441).WORKER_DIR — output directory for triage.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 to CATALYST_ORCHESTRATOR_DIR. CATALYST_ORCHESTRATOR_ID,
CATALYST_SESSION_ID — used for trace/span id derivation in the emitted event; optional.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 bash — so the second positional
# would become the literal "--orch-dir" flag and break every match (CTL-602).
# Keep this block free of bare positional tokens (a "$" immediately followed
# by a digit); braced and command-substitution forms are unaffected.
_PT_LOWER="$(printf '%s' " $COMBINED " | tr '[:upper:]' '[:lower:]')"
case "$_PT_LOWER" in
*' bug '*|*'fix'*|*'bugfix'*|*'broken'*|*'regression'*) CLASSIFICATION=bug ;;
*' doc '*|*'docs'*|*'documentation'*|*'readme'*) CLASSIFICATION=docs ;;
*'refactor'*|*'rename'*|*'cleanup'*|*'extract '*) CLASSIFICATION=refactor ;;
*'chore'*|*'bump'*|*'dependency update'*|*'deps:'*) CLASSIFICATION=chore ;;
*) CLASSIFICATION=feature ;;
esac
# 2b. Estimated scope — word count thresholds.
WORD_COUNT="$(printf '%s' "$DESCRIPTION" | wc -w | tr -d ' ')"
if [ "$WORD_COUNT" -lt 150 ]; then ESTIMATED_SCOPE=small
elif [ "$WORD_COUNT" -lt 400 ]; then ESTIMATED_SCOPE=medium
elif [ "$WORD_COUNT" -lt 1000 ]; then ESTIMATED_SCOPE=large
else ESTIMATED_SCOPE=epic
fi
# 2c. Acronym expansion — built-in dictionary; only emit acronyms actually present
# in the title+description. Inlined (no helper function) so the body carries no
# bare positional parameter, which slash-command arg substitution would clobber
# at dispatch (CTL-602). The jq variables below ($t and friends) are not bare
# "$"-then-digit tokens, so they are unaffected.
ACRONYMS_EXPANDED="$(jq -nc --arg t "$COMBINED" '
[
{a:"PR", e:"Pull Request"},
{a:"CI", e:"Continuous Integration"},
{a:"CLI", e:"Command-Line Interface"},
{a:"API", e:"Application Programming Interface"},
{a:"MVP", e:"Minimum Viable Product"},
{a:"TDD", e:"Test-Driven Development"},
{a:"E2E", e:"End-to-End"},
{a:"SHA", e:"Secure Hash Algorithm"},
{a:"UUID", e:"Universally Unique Identifier"},
{a:"JSON", e:"JavaScript Object Notation"},
{a:"OTEL", e:"OpenTelemetry"},
{a:"OTLP", e:"OpenTelemetry Line Protocol"},
{a:"PromQL", e:"Prometheus Query Language"},
{a:"bg", e:"background"},
{a:"HUD", e:"Heads-Up Display"},
{a:"ADR", e:"Architecture Decision Record"}
]
| ($t | ascii_downcase) as $tl
| map(.a as $acr
| (.a | ascii_downcase) as $acl
| select($tl | test("\\b" + $acl + "\\b")))
| map({acronym: .a, expansion: .e})
')"
# 2d. Dependencies — DELIBERATELY EMPTY in the deterministic path (CTL-838).
# Catalyst does NOT infer dependencies from prose. The old behavior scraped
# every TEAM-NNN token out of the title+description and the scheduler (CTL-755
# STEP E) persisted each as a durable `blocked_by` edge — turning prior-art
# mentions, incident examples, "see also" references and cross-team ids into
# FALSE blockers that deadlocked tickets against work they do not depend on.
# Real prerequisites are first-class, captured two ways, NEVER by scraping:
# 1. The ticket AUTHOR sets formal Linear `blocked_by` LINKS at creation time
# (see the gherkin-ticket / linear / create-tickets skills). Those are
# already durable Linear relations the scheduler honors directly.
# 2. An Opus-mode triage pass acts as a SECOND PAIR OF EYES — it examines the
# backlog and records only GENUINE missed blockers (see "2c" below).
# The bash fallback therefore emits an empty list (safe: no false blocks). It
# must never reintroduce a regex over mentioned ids.
DEPENDENCIES="[]"
# 2e. Summary — first paragraph of prose, trimmed.
# Skip leading markdown headers (^#+\s+) and bullet markers (^[-*+]\s+) so a
# description that opens with "## Problem" yields the first sentence of prose,
# not the literal header. Falls back to an empty summary if the description is
# entirely headers/bullets/blank lines.
# Implemented as a pure-bash loop rather than awk: awk's whole-line field is a
# bare positional token, which Claude Code's slash-command arg substitution
# would rewrite to the ticket id at dispatch, turning every line into broken
# awk arithmetic (CTL-602). This loop uses only bash+zsh-safe constructs
# ([[ =~ ]], <<<, head -c) and carries no bare "$"-then-digit token.
SUMMARY=""
_PT_SKIPPING=1
while IFS= read -r line; do
if [ "$_PT_SKIPPING" -eq 1 ]; then
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
[[ "$line" =~ ^#+[[:space:]]+ ]] && continue
[[ "$line" =~ ^[-*+][[:space:]]+ ]] && continue
_PT_SKIPPING=0
fi
[[ "$line" =~ ^[[:space:]]*$ ]] && break
if [ -z "$SUMMARY" ]; then SUMMARY="$line"; else SUMMARY="$SUMMARY $line"; fi
done <<< "$DESCRIPTION"
SUMMARY="$(printf '%s' "$SUMMARY" | head -c 400)"
# 3. Compose triage.json.
TRIAGE_FILE="$WORKER_DIR/triage.json"
jq -nc \
--arg ticket "$TICKET" \
--arg classification "$CLASSIFICATION" \
--arg scope "$ESTIMATED_SCOPE" \
--argjson acronyms "$ACRONYMS_EXPANDED" \
--argjson dependencies "$DEPENDENCIES" \
--arg summary "$SUMMARY" \
--arg generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{
ticket: $ticket,
classification: $classification,
estimated_scope: $scope,
acronyms_expanded: $acronyms,
dependencies: $dependencies,
summary: $summary,
generated_at: $generated_at
}' > "$TRIAGE_FILE"
# 4. Post triage comment to Linear (best-effort: failure does not escalate the phase, CTL-614).
# Render the dep + acronym lists with jq using single-quoted jq programs and
# Unicode escapes for backticks (`) so the surrounding bash heredoc does
# not need to escape them.
DEPS_RENDERED="$(printf '%s' "$DEPENDENCIES" | jq -r '
if length == 0 then "_none detected_"
else map("`" + . + "`") | join(", ")
end
')"
ACR_RENDERED="$(printf '%s' "$ACRONYMS_EXPANDED" | jq -r '
if length == 0 then "_none detected_"
else map("`" + .acronym + "`=" + .expansion) | join(", ")
end
')"
COMMENT_BODY="$(cat <<EOF
**Phase Triage**
- **Classification**: ${CLASSIFICATION}
- **Estimated scope**: ${ESTIMATED_SCOPE} (description word count: ${WORD_COUNT})
- **Dependencies**: ${DEPS_RENDERED}
- **Acronyms expanded**: ${ACR_RENDERED}
_Triaged automatically by the phase-triage agent (CTL-451)._
EOF
)"
# Append the shared run-metadata footer (CTL-632 follow-on): model, sub-agent
# count, active working duration, session ids, cwd. Fail-soft — a missing helper
# or unresolved orch dir simply omits the footer.
__PT_FOOTER="${__PT_REPO_ROOT}/plugins/dev/scripts/lib/phase-mirror-footer.sh"
if [[ -n "${ORCH_DIR:-}" && -x "${__PT_FOOTER}" ]]; then
MIRROR_FOOTER="$("${__PT_FOOTER}" --orch-dir "${ORCH_DIR}" --ticket "${TICKET}" --phase "triage" 2>/dev/null || true)"
[[ -n "${MIRROR_FOOTER}" ]] && COMMENT_BODY="${COMMENT_BODY}
${MIRROR_FOOTER}"
fi
# CTL-614 / CTL-550: the Linear comment post is best-effort. triage.json is
# already on disk; the canonical pipeline contract is
# `phase.triage.complete.<TICKET>` (see CTL-452). A comment-post failure must
# NOT escalate the ticket to `needs-human`.
# CTL-864: cross-host fence — bow out if a takeover superseded us. No-op single-host.
"${__PT_REPO_ROOT}/plugins/dev/scripts/lib/cluster-fence-guard.sh" --phase "${CATALYST_PHASE:-triage}" --ticket "$TICKET" || exit 10
__PT_COMMENT_POST="${CATALYST_COMMENT_POST_HELPER:-${__PT_REPO_ROOT}/plugins/dev/scripts/lib/linear-comment-post.sh}"
if [[ ! -x "$__PT_COMMENT_POST" ]]; then __PT_COMMENT_POST="$(command -v linear-comment-post.sh 2>/dev/null || true)"; fi
if [[ -n "$__PT_COMMENT_POST" && -x "$__PT_COMMENT_POST" ]] && "$__PT_COMMENT_POST" "${TICKET}" "${COMMENT_BODY}" >/dev/null; then
true
else
echo "phase-triage: linear-comment-post failed (continuing)" >&2
fi
# 5. There is no `triaged` label. Triage completion is signaled by the
# analysis comment posted above plus the local triage.json the coordinator
# reads — the phase agent and the daemon both leave Linear labels alone.
# (Historical note for anyone grepping CTL-558: the old coordinator label
# sweep that tagged `triaged` was removed.)
# 6. Emit the canonical phase event + flip the signal to done (CTL-1410 Phase A:
# via the wrapper, so the terminal status is written in-band under executor=sdk).
"$__PT_WRAPPER" --phase triage --ticket "$TICKET" --status complete \
--payload-json "$(cat "$TRIAGE_FILE")"
# Self-halt after complete to prevent zombie workers (CTL-778 step 2).
# Read our own bg_job_id from the signal file and ask Claude to stop us.
# Best-effort: a failed stop is covered by the daemon reaper backstop.
if [[ -n "${ORCH_DIR:-}" && -f "${ORCH_DIR}/workers/${TICKET}/phase-triage.json" ]]; then
_SELF_BG=$(jq -r '.bg_job_id // empty' \
"${ORCH_DIR}/workers/${TICKET}/phase-triage.json" 2>/dev/null || true)
[[ -n "$_SELF_BG" ]] && claude stop "${_SELF_BG:0:8}" >/dev/null 2>&1 || true
fi
exit 0
The bash body above is fully self-sufficient — the e2e test exercises only that. When an Opus agent runs this skill, it should:
Run the bash body to produce the baseline triage.json and Linear comment.
Read the ticket back, refine the classification + scope estimate with model-quality judgement,
and re-write triage.json if anything changed.
2b. Anchor a numeric estimate against the reference class (CTL-751, CTL-954).
The goal is ONE numeric estimate written in the team's configured scale (fibonacci / tShirt /
exponential / linear — whatever issueEstimation.type the team has set in Linear).
Step 1 (preferred): reference-class lookup. Run:
bun "${REPO_ROOT}/plugins/pm/scripts/estimate/reference-class-lookup.ts" \
--corpus "${REPO_ROOT}/plugins/pm/scripts/estimate/reference-class-corpus.json" \
--title "<ticket title>" --json
where REPO_ROOT is the repo root (the worktree's checkout path, e.g. the directory containing
plugins/). Parse reference_class.points from the JSON output.
Step 2 (fallback when Step 1 errors or returns nothing): scope → method mapping. Fetch the team's estimation method with a single GraphQL call:
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: ${LINEAR_API_TOKEN:-$LINEAR_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"query":"query($k:String!){teams(filter:{key:{eq:$k}}){nodes{issueEstimation{type allowZero extended}}}}", "variables":{"k":"<TEAM_KEY>"}}' \
| jq -r '.data.teams.nodes[0].issueEstimation.type'
Map the bash-body's estimated_scope to the method's scale using this table (select the column
matching the team's type):
| scope | fibonacci | tShirt | exponential | linear | | ------ | --------- | ------ | ----------- | ------ | | xs | 1 | 0 (XS) | 1 | 1 | | small | 1 | 1 (S) | 1 | 1 | | medium | 3 | 2 (M) | 2 | 2 | | large | 5 | 3 (L) | 4 | 3 | | epic | 8 | 5 (XL) | 8 | 5 |
Write the result to triage.json as both "estimate": <points> AND
"estimateMethod": "<type>" (e.g. "estimateMethod": "tShirt"). The scheduler reads
estimateMethod to validate the value against the correct scale without a second network call.
If both Step 1 and Step 2 fail, leave triage.json without an estimate field — the coordinator
skips the Linear estimate write for this ticket (fail-open; forward progress is unaffected). The
bash body intentionally does not write estimate (CTL-558 guard).
2c. Identify genuine blockers — semantic second-pass, READ-ONLY (CTL-838). Catalyst does not
parse or infer dependencies from the description text. The bash body writes an empty dependencies
list by design (CTL-838 killed the old 2d regex scrape). Real prerequisites come from two places,
and your job here is the second one:
Formal author links (primary). The agent that authored the ticket records its real
prerequisites as Linear blocked_by LINKS at creation time (the gherkin-ticket / linear /
create-tickets skills now instruct this). Those are already durable Linear relations and the
admission gate honors them directly from the live graph — triage does not need to re-derive
or re-emit them.
Triage as a SECOND PAIR OF EYES (your job). You are NOT a parser. Do not add a dependency
because an id appears in the prose. Instead: read the ticket's intent, then examine the
relevant backlog — query the replica for the ticket's team / area, and read any candidate
ticket, via direct SQL (see the linearis skill's "Reading Linear" section) — and judge whether
any in-flight or planned work is a true prerequisite the author may have missed: work that
must reach a terminal state
before this ticket can sensibly start (a shared interface not yet built, a migration that must
land first, an explicit "must follow" sequencing). Record only blockers you can justify, in
the rich shape with a reason:
"dependencies": [
{ "id": "CTL-123", "exists": true, "blockerState": "Implement", "reason": "defines the API this ticket consumes" }
]
If you find no genuine missed blocker, leave dependencies as the empty list the bash body wrote. A
mention is not a dependency; a shared topic is not a dependency; "see also / prior art / regression
of / example" is not a dependency. When in doubt, leave it out — a false blocker deadlocks real
work, a missed one is caught on the next pass.
Hard constraint — the skill makes ZERO Linear writes for dependencies. Do NOT call
linearis issues update ... --blocked-by (or any linearis issues update) from this skill. The
admission gate's durable blocked_by persistence lives in the execution-core scheduler (CTL-755
STEP E, scheduler.mjs — it reads triage.json.dependencies, re-validates each id via
fetchTicketState, and drops unresolvable / terminal / cycle-closing / parent-epic (CTL-878) /
cross-team (CTL-838) ids before writing the durable edge with applyBlockedByRelation). Keeping
the write scheduler-side preserves the CTL-497/CTL-558 contract — the phase-triage e2e negative
guard fails the build if this skill ever emits a linearis issues update call.
The replica SQL reads above (ticket detail + backlog scan) are read-only and are fine. STEP E
tolerates BOTH the flat-string and the rich {id} shapes.
linearis issues discuss comment
marking the refinement.The refinement step is deliberately optional — the orchestrator hand-off in CTL-452 only needs the
phase.triage.complete.<TICKET> event, and the bash body already emits that.
If triage genuinely cannot proceed (e.g., the ticket description is too
ambiguous to classify, or a required external resource is unavailable), emit
failed with a structured explanation block. Use the CLI shim:
EXPL_JSON="$(node "${PLUGIN_ROOT}/scripts/execution-core/escalation-explain.mjs" \
--ticket "$TICKET" --phase triage \
--what-failed "{{ specific symptom }}" \
--why-gave-up "{{ reason autonomous triage cannot complete }}" \
--human-question "{{ one specific answerable question }}" \
2>/dev/null || echo '{}')"
The human_question MUST be specific and answerable — never:
Write the specific question instead:
development
Migrate a single-harness repo to the dual-harness layout so both Claude Code and Codex load the same instructions and skills — AGENTS.md as the portable canonical doc, a thin CLAUDE.md `@AGENTS.md` bridge, and a `.agents/skills` dir with a `.claude/skills` symlink onto it. Use when asked to migrate to dual-harness, make this repo work in both Claude and Codex, or for agent metadata cleanup.
tools
Goal-driven senior-engineer pipeline-unstick sweep (CTL-1176 rung 3). Given the stuck/failed/needs-human set (or ONE ticket handed by the recovery router), its GOAL is to get the pipeline MOVING again — not to fix one ticket's review findings (that is phase-remediate). It runs AFTER the eyes (diagnostician evidence) and the hands (deterministic unstuck-sweep seams) have already tried, and it CONSUMES their output from a recovery-pass.json brief rather than re-diagnosing or redoing their narrow work. It acts like a senior engineer with full tool access — it resolves merge conflicts, rebases, force-pushes, merges green PRs, and re-dispatches stalled phases AUTONOMOUSLY — and escalates to the operator ONLY for a genuine value judgment / something that degrades other functionality / a real cost-benefit trade-off / a serious architecture change / an ADR conflict. On escalation it AUTHORS the operator inbox row + the push notification (executive-voiced). Dispatched as a `claude --bg` job by phase-agent-dispatch via slash command, AND invocable bare by the operator as a sweep — hence `user-invocable: true`. Ships behind CATALYST_RECOVERY_PASS (off by default — no live behavior change until shadow/enforce).
tools
Diagnose and fix Catalyst setup issues. Validates tools, database, config, OTel, direnv, and thoughts. Automatically fixes what it can — creates directories, initializes the database, sets WAL mode, runs migrations. Use for new installs, upgrades, or when something isn't working.
development
Reference template every phase-agent skill copies (CTL-448). The leading underscore on the directory name prevents the skill loader from picking this up — it is NOT a runnable skill, only a structural template the nine real phase agents (phase-triage, phase-research, phase-plan, phase-implement, phase-verify, phase-review, phase-pr, phase-monitor-merge, phase-monitor-deploy) clone and specialize. The real phase skills MUST set `user-invocable: true` so `phase-agent-dispatch`'s `claude --bg "/catalyst-dev:phase-X ..."` slash command resolves (CTL-490).