plugins/dev/skills/linearis/SKILL.md
Linear access rule + Linearis CLI reference. READS → query the local replica by direct SQL (`~/catalyst/catalyst-replica.db`); WRITES and list/search → the `linearis` CLI. Use when working with Linear tickets, cycles, projects, milestones, or when the user mentions ticket IDs like TEAM-123, ENG-456, PROJ-789.
npx skillsauth add coalesce-labs/catalyst linearis-cliInstall 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.
Verified against Linearis v2026.4.9 on 2026-05-31.
⚠️ READ vs WRITE — the rule that governs everything below. Linear READS → query the local replica by direct SQL (
~/catalyst/catalyst-replica.db), or calllinear_read_ticket <ID>. Never shelllinearis issues readfor a routine read — that hits the rate-limited API and 429s the shared quota. WRITES (create/update/state/comment/estimate/label) and list/search/non-issue domains → thelinearisCLI. Full rule + freshness gate: Reading Linear.
CRITICAL: Always use these exact patterns. Do NOT guess or improvise syntax.
⚠️ Read the Gotchas & Traps section before scripting —
issues listsilently hides Done tickets,lineariseats stdin in loops, and there is no--jsonflag. These bite hard.
This is the single source of the Linear read rule. Other skills/agents reference this section — they do not restate it.
Linear READS → query the local Catalyst Cloud replica directly with SQL. Linear WRITES →
always linearis. The replica (~/catalyst/catalyst-replica.db, a SQLite mirror kept
current by the per-host catalyst-cloud-sync change-feed writer) holds every issue field plus
labels, relations, projects, cycles, users, and PR/review state — so one SQL query serves ANY
read, including the label/relation joins the old CLI couldn't. This supersedes the
catalyst-linear wrapper (now deprecated — see below) for agent/skill ad-hoc reads.
Why direct SQL, not bare
linearis. Barelinearisreads always hit the rate-limited Linear API. On the shared-quota fleet that burns budget and 429s everyone. The replica is a sub-ms local copy that already has the answer — reading it is what makes "every client reads the replica" actually true.
isReplicaFresh,
execution-core/replica-read.mjs):
<db>.writer.lock mtime is < 5 min old. The writer heartbeats this
file every few seconds regardless of Linear activity, so it tracks liveness, not
data-change cadence — do NOT gate on the .db/-wal mtime (a quiet feed makes those
look stale even when the mirror is perfectly current).sync_meta has a non-empty cursor row. The writer deletes it at
the start of a re-seed and rewrites it on completion, so its presence means you are not
reading a half-truncated table mid-reseed.linearis for that one read, and file a ticket — a stale/absent
replica signals a writer or mirror gap worth fixing, not a one-off retry.Caveat — the gates prove writer-liveness + seed-completeness, NOT per-row apply success. A rare class of rows (~0.7%) can be present but stale because their change-feed apply silently failed (the
errno:1apply-drift, catalyst-cloud#127 / CTL-1402) — the writer heartbeats and the cursor advances past them, so the freshness gate reads green while that one row holds an old value. Direct SQL cannot make this loud on its own. So: if a specific field contradicts something you just directly observed (e.g. a state you just wrote), treat that one field as an anomaly — re-read it vialinearis, use the live value, and surface it. This is the residual reason writer reliability + apply-failure telemetry (CTL-1402) matter; it is not license to re-verify reads that don't contradict anything.
# Resolve the DB the way the daemon does: $CATALYST_REPLICA_DB, else $CATALYST_DIR, else $HOME.
DB="${CATALYST_REPLICA_DB:-${CATALYST_DIR:-$HOME/catalyst}/catalyst-replica.db}"
replica_fresh() {
local lock="$DB.writer.lock" now age
[[ -f "$lock" ]] || return 1
# GNU `stat -c %Y` first, BSD `stat -f %m` fallback (on Linux `-f` is --file-system, not mtime).
now=$(date +%s); age=$(( now - $(stat -c %Y "$lock" 2>/dev/null || stat -f %m "$lock") ))
(( age < 300 )) || return 1 # writer heartbeat < 5 min
[[ -n "$(sqlite3 "$DB" "SELECT 1 FROM sync_meta WHERE key='cursor' AND value<>'' LIMIT 1;")" ]] # seed complete
}
Scripts should use the shared helper instead of re-implementing this: source
plugins/dev/scripts/lib/linear-read-replica.sh and call linear_read_ticket <ID> (freshness
gate → SQL → loud linearis fallback).
Run sqlite3 "$DB" .schema (or .schema issues) to see the live columns. Verified 2026-07-01:
issues.state is the state NAME directly (Backlog/Implement/PR/Done…) — no join.issues also has: identifier, title, estimate, priority/priority_label,
description, url, branch_name, parent_identifier, project_id, cycle_id, team_id,
assignee_id, the timestamp columns, and a raw column with the full Linear JSON.issue_labels ⋈ labels — JOIN labels l ON l.id = il.label_id WHERE il.issue_id = i.id.relations table (type, issue_identifier, related_identifier). Relations lag ≤ 5 min (reconcile poll, no webhook) — everything
else is real-time.json_extract(raw,'$.path') (e.g. json_extract(raw,'$.state.type')).Representative read — identifier, title, state, estimate, and labels in one query:
sqlite3 -json "$DB" "
SELECT i.identifier, i.title, i.state, i.estimate,
(SELECT group_concat(l.name, ', ') FROM issue_labels il
JOIN labels l ON l.id = il.label_id WHERE il.issue_id = i.id) AS labels
FROM issues i WHERE i.identifier = 'ENG-123' AND i.removed_at IS NULL;"
AND removed_at IS NULLis REQUIRED: a tombstoned (removed) issue must read as a MISS → fall back to live Linear, never as a stale hit.
linearis (no issue-shaped replica form)cycles / projects / milestones / initiatives list & read — use
linearis (Core Operations below). Simple cycles/projects lookups can use those replica
tables, but the linearis commands are the full path.relation.id, cycle.name — CTC-147; state.id, team.key — CTC-148; children is always
[]). These are closeable gaps, not permanent carve-outs — file/track them; don't route
around the replica by habit.lineariscreate / update / state transitions / discuss / estimate / label always go through
linearis. The replica is read-only.
catalyst-linear CLI — DEPRECATEDThe catalyst-linear read|list|search wrapper (CTL-1391) is superseded by direct SQL for
agent/skill reads and retained only as a fail-open compatibility shim. Prefer direct SQL.
(list/search were always linearis passthrough — no replica benefit — and the wrapper's
additive _meta field + duplicate-flag collapsing broke bare-linearis jq pipelines.) The
daemon's own read paths use replica-read.mjs directly and are unaffected by this deprecation.
For full flag details, run linearis usage (all domains) or linearis <domain> usage (one domain).
The usage output is authoritative and always current — prefer it over memorizing flags.
linearis usage # Full overview of every domain and flag
linearis issues usage # Just issue operations
linearis milestones usage # Just milestone operations
linearis cycles usage # Just cycle operations
These are non-obvious behaviors that silently produce wrong results. Verified empirically against v2026.4.9.
issues list HIDES Done tickets by default. A default issues list returns every active state
plus Canceled, but silently omits completed/Done tickets. So a ticket absent from the list
is usually Done, not gone. For ground-truth state of a specific ticket, use issues read <ID>.
To surface completed work, pass --status "Done" explicitly (--completed-after alone does NOT
override the exclusion). The asymmetry is real: Canceled shows, Done doesn't.
linearis consumes stdin — in a for/while loop or heredoc it eats the loop's input and every
iteration after the first misbehaves (often looking like a hang or empty result). Append </dev/null
to every linearis call in scripts and agent loops.
There is no --json flag. JSON is the default output ({ "nodes": [...] }); passing --json
errors with unknown option '--json'. Just pipe the bare command to jq.
--status is a real server-side filter — a misspelled/unknown value returns an empty set
(jq will throw "Cannot iterate over null"), it does NOT fall back to all issues. Validate status
spelling against the team's real workflow states (e.g. CTL uses Research/Plan/Implement/Validate/
PR/Remediate/Triage/Done, not the generic "In Progress").
Filter→scope coupling: --status and --cycle on issues list/search require --team;
--milestone requires --project. Omitting the scope errors. projects list has no --team
filter at all (only --limit/--after) — list all and filter with jq, or pivot via
issues list --team ENG | jq '.nodes[].project'.
--query on issues list is deprecated (still works) — use issues search "<query>" instead.
Milestone/cycle name resolution isn't globally unique. Milestone names can collide across
projects — pass --project (or a UUID) on milestones read/update. cycles list --active and
--window <n> are team-scoped — always pair with --team or you may grab another team's cycle.
project-milestones (old name) fails SILENTLY — it doesn't error, it falls through to the generic
top-level --help dump (looks like success). The domain is milestones.
Shell note (Bash tool runs zsh): status and state are reserved/read-only var names —
status=$(linearis ...) throws "read-only variable". Use st/s/lstate.
auth status is the diagnostic entry point — if calls silently return nothing, run
linearis auth status (read-only) to confirm the token before debugging anything else;
linearis auth login refreshes it.
Reads → direct SQL against the replica (see Reading Linear). The
linearis issues read|list|searchexamples below document the CLI syntax for the cases that still use it — the stale/absent fallback path, non-issue domains, and writes. The flags carry over verbatim.
# Preferred — self-contained inline replica SQL. No source, no $SCRIPT_DIR — works in any
# shell. Resolves the DB path the same way the daemon does (gate on freshness first; see
# Reading Linear):
DB="${CATALYST_REPLICA_DB:-${CATALYST_DIR:-$HOME/catalyst}/catalyst-replica.db}"
sqlite3 -json "$DB" \
"SELECT identifier, title, state, estimate FROM issues WHERE identifier='ENG-123' AND removed_at IS NULL;"
# Inside a skill/script, the shared helper does freshness-gate → replica SQL → loud linearis
# fallback in ONE call. Source it by a RESOLVABLE root — there is NO $SCRIPT_DIR in a bare
# shell; in a catalyst-dev skill use $CLAUDE_PLUGIN_ROOT:
source "${CLAUDE_PLUGIN_ROOT:?source in a catalyst-dev skill}/scripts/lib/linear-read-replica.sh"
json=$(linear_read_ticket ENG-123) || return 1 # linearis-shaped JSON (state.name, estimate, labels.nodes[]…)
title=$(printf '%s' "$json" | jq -r '.title // empty')
linearis issues read ENG-123is the STALE/ABSENT fallback only.linear_read_ticketruns it for you and surfaces the fallback loudly — do not shell it directly for a routine read.
linearis issues search "keyword"
linearis issues search "auth bug" --team ENG --status "Todo"
linearis issues create "Title" --team ENG
linearis issues create "Title" --team ENG --description "Details" --priority 2 --project "Project"
create also accepts --status, --cycle, --estimate, --parent-ticket, --due-date, and the
relation flags (--blocks/--blocked-by/--relates-to/--duplicate-of) — set them at creation time
instead of a wasteful second update. Run linearis issues usage for the full list.
linearis issues update ENG-123 --status "In Progress"
linearis issues update ENG-123 --priority 1
linearis issues update ENG-123 --labels "bug" --label-mode add
linearis issues update ENG-123 --project "Project Name"
linearis issues update ENG-123 --project-milestone "Milestone Name"
update also supports relation flags (--blocks/--blocked-by/--relates-to/--duplicate-of/
--remove-relation) and clearers (--clear-parent-ticket/--clear-cycle/--clear-estimate/
--clear-due-date/--clear-project-milestone/--clear-labels). See linearis issues usage.
Commenting is a thread model under issues (the old flat comments domain is a deprecated
compatibility facade as of v2026.4.x — linearis comments --help itself says "prefer the issues
discussion commands").
# Start a comment / discussion thread (this is the one to use)
linearis issues discuss ENG-123 --body "Starting work on this"
# List root threads on a ticket (use BEFORE re-posting a mirror comment, to avoid dups)
linearis issues discussions ENG-123
# Reply to a thread — <thread> MUST be a root thread ID (from discuss/discussions), NOT ENG-123
linearis issues reply <thread-id> --body "follow-up"
linearis issues replies <thread-id> # list replies in a thread
# Edit / delete (split verbs in the modern path)
linearis issues edit <comment-id> --body "..." # edit a root or reply comment
linearis issues edit-reply <reply-id> --body "..."
linearis issues delete-comment <comment-id>
linearis issues delete-reply <reply-id>
Both issues discuss and issues discussions accept either a UUID or an ABC-123 identifier — no
UUID-resolution dance needed for commenting. comments create still works but is deprecated and loses
nested-reply support — don't teach it as canonical.
Common mistakes:
linearis issues get ENG-123 # ❌ no 'get' — use 'read'
linearis issue view ENG-123 # ❌ no 'view' — use 'read'
linearis issues comment ENG-123 "text" # ❌ no 'comment' subcommand — use 'issues discuss <id> --body'
linearis comments create ENG-123 ... # ⚠️ deprecated facade — prefer 'issues discuss'
linearis issues update ENG-123 --state # ❌ use --status, not --state
linearis project-milestones list # ❌ renamed to 'milestones' in v2026.4
# Discover teams and projects
linearis teams list | jq '.nodes[] | {key, name}'
linearis projects list | jq '.nodes[] | {name, status: .status.name, id}'
# All tickets in a specific project
linearis issues list --project "Auth System" --limit 100
# Tickets in a project, grouped by status (requires --team for --status filter)
linearis issues list --team ENG --project "Auth System" --status "Backlog,Todo" --limit 100
linearis issues list --team ENG --limit 200 | jq '[.nodes[] | select(.project == null)] | length'
linearis issues list --team ENG --limit 200 | jq '.nodes[] | select(.project == null) | {identifier, title, state: .state.name}'
# Urgent/high priority tickets
linearis issues list --team ENG --priority 1 --limit 50
linearis issues list --team ENG --priority 2 --limit 50
# Unestimated tickets in a project
linearis issues list --project "Auth System" --limit 100 | jq '.nodes[] | select(.estimate == null) | {identifier, title}'
# Not updated in 30+ days
linearis issues list --team ENG --updated-before 2026-03-13 --status "In Progress" --limit 50
linearis issues update ENG-123 --project "Auth System"
linearis milestones list --project "Auth System"
linearis milestones read "Beta Launch" --project "Auth System"
linearis milestones read "Beta Launch" --project "Auth System" --limit 100
linearis milestones create "Beta Launch" --project "Auth System" --target-date 2026-06-15
linearis milestones create "GA Release" --project "Auth System" --description "General availability" --target-date 2026-09-01
linearis milestones update "Beta Launch" --project "Auth System" --name "Beta 2.0"
linearis milestones update "Beta Launch" --project "Auth System" --target-date 2026-07-01
linearis issues update ENG-123 --project-milestone "Beta Launch"
# Clear a milestone assignment
linearis issues update ENG-123 --clear-project-milestone
# Tickets in a project with no milestone
linearis issues list --project "Auth System" --limit 100 | jq '.nodes[] | select(.projectMilestone == null) | {identifier, title}'
linearis labels list --team ENG
linearis labels list --team ENG | jq '.nodes[] | {name, color}'
linearis issues list --team ENG --label "bug" --limit 100
linearis issues list --team ENG --label "tech-debt" --limit 100
# Add a label (keeps existing labels)
linearis issues update ENG-123 --labels "needs-triage" --label-mode add
# Replace all labels
linearis issues update ENG-123 --labels "bug,P1" --label-mode overwrite
# Remove all labels
linearis issues update ENG-123 --clear-labels
linearis cycles list --team ENG --active
CYCLE=$(linearis cycles list --team ENG --active | jq -r '.nodes[0].name')
linearis cycles read "$CYCLE" --team ENG --limit 100
CYCLE=$(linearis cycles list --team ENG --active | jq -r '.nodes[0].name')
linearis cycles read "$CYCLE" --team ENG --limit 100 | jq '
.issues
| group_by(.state.name)
| map({status: .[0].state.name, count: length, tickets: [.[].identifier]})
'
# Active cycle plus 2 before and after
linearis cycles list --team ENG --window 2
Status names come from the team's workflow configuration. Use the stateMap in .catalyst/config.json
when available, otherwise read a ticket to discover valid status names.
# Common flow
linearis issues update ENG-123 --status "In Progress"
linearis issues update ENG-123 --status "In Review"
linearis issues update ENG-123 --status "Done"
# With comment
linearis issues update ENG-123 --status "Done"
linearis issues discuss ENG-123 --body "Merged: PR #456"
When .catalyst/config.json contains catalyst.linear.stateIds, prefer passing the UUID directly
to --status instead of the display name. Every linearis resolver short-circuits on UUIDs — zero
resolution API calls. The linear-transition.sh helper does this automatically.
# Resolve and cache UUIDs once (single GraphQL query)
plugins/dev/scripts/resolve-linear-ids.sh
# Then transitions use UUIDs from config — 1 fewer API call per update
plugins/dev/scripts/linear-transition.sh --ticket ENG-123 --transition done
The PR-body guard lib/linear-pr-skip.sh optionally filters its output
through a cached snapshot of workspace team keys at
${XDG_CONFIG_HOME:-$HOME/.config}/catalyst/linear-team-keys.json. The
cache is manual and fail-open — when the file is missing, empty,
malformed, or unreadable, the helper does no filtering (fresh installs
behave like today). Populate / refresh it with:
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/catalyst"
linearis teams list --json |
jq '{keys:[.nodes[].key]|sort, fetched_at:(now|todate)}' \
> "${XDG_CONFIG_HOME:-$HOME/.config}/catalyst/linear-team-keys.json"
Re-run after onboarding a new Linear team. The helper is invoked inside
non-interactive gh pr create / gh pr edit paths — no automatic
refresh is wired in.
--status for issue updates (--state is not a valid flag)issues discuss: Create a comment with linearis issues discuss <id> --body; list
threads with issues discussions <id>. The old comments create still works but is a deprecated
compatibility facade — don't use it. There is no issues comment subcommand.issues list/search, --status (and --cycle) only work with
--team; --milestone requires --project--team ENG). Historically
issues create/search required a UUID and keys silently fell back to the default team
(czottmann/linearis#56) — this could not be reproduced on v2026.4.9 in a single-team workspace, so
verify scope by checking returned identifiers' team prefix when using key-based --team on
create/search in a multi-team workspace.--status "In Progress" not --status In Progress--json flag: every command emits JSON; passing --json errors. Pipe
the bare command to jq. (And append </dev/null in loops — see Gotchas.)linearis <domain> usage: When unsure about flags, check usage instead of guessingv2026.4.9 also exposes these. Read-only subcommands (list/read/status/download) are safe;
create/update/delete/archive/upload mutate — don't run them in audits.
linearis users list [--active] — workspace members (id/name/email); resolve assignee/owner UUIDs.
Note service/OAuth accounts have synthetic emails (*@oauthapp.linear.app).linearis attachments list <issue> [--source-type github] — PR/Slack/link attachments on a ticket.linearis documents list [--project X | --issue ENG-123] + documents read <doc> — project/issue docs
(delete trashes, not hard-delete).linearis initiatives list [--status active] [--with-projects] + initiatives read <init> —
roadmap grouping above projects (defaults to excluding archived; pass --include-archived).linearis files download <url> --output <path> — fetch an asset from Linear storage.linearis auth status / auth login — verify/refresh the API token (see Gotchas #10).development
Migrate a single-harness repo to the dual-harness layout so both Claude Code and Codex load the same instructions and skills — AGENTS.md as the portable canonical doc, a thin CLAUDE.md `@AGENTS.md` bridge, and a `.agents/skills` dir with a `.claude/skills` symlink onto it. Use when asked to migrate to dual-harness, make this repo work in both Claude and Codex, or for agent metadata cleanup.
tools
Goal-driven senior-engineer pipeline-unstick sweep (CTL-1176 rung 3). Given the stuck/failed/needs-human set (or ONE ticket handed by the recovery router), its GOAL is to get the pipeline MOVING again — not to fix one ticket's review findings (that is phase-remediate). It runs AFTER the eyes (diagnostician evidence) and the hands (deterministic unstuck-sweep seams) have already tried, and it CONSUMES their output from a recovery-pass.json brief rather than re-diagnosing or redoing their narrow work. It acts like a senior engineer with full tool access — it resolves merge conflicts, rebases, force-pushes, merges green PRs, and re-dispatches stalled phases AUTONOMOUSLY — and escalates to the operator ONLY for a genuine value judgment / something that degrades other functionality / a real cost-benefit trade-off / a serious architecture change / an ADR conflict. On escalation it AUTHORS the operator inbox row + the push notification (executive-voiced). Dispatched as a `claude --bg` job by phase-agent-dispatch via slash command, AND invocable bare by the operator as a sweep — hence `user-invocable: true`. Ships behind CATALYST_RECOVERY_PASS (off by default — no live behavior change until shadow/enforce).
tools
Diagnose and fix Catalyst setup issues. Validates tools, database, config, OTel, direnv, and thoughts. Automatically fixes what it can — creates directories, initializes the database, sets WAL mode, runs migrations. Use for new installs, upgrades, or when something isn't working.
tools
--- name: phase-triage description: Phase agent that triages a Linear ticket — expands acronyms, classifies (feature/bug/docs/refactor/chore), identifies genuine blockers (a semantic second-pass over the backlog — NOT a prose scrape; CTL-838), estimates scope, writes triage.json, and posts a triage analysis comment to Linear. Triage completion is signaled by that comment plus the local triage.json — there is no `triaged` label. Emits phase.triage.complete.<TICKET> on success and phase.triage.fai