---
name: linearis-cli
description:
  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.
---

# Linearis CLI Reference

> 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 call `linear_read_ticket <ID>`.
> **Never** shell `linearis issues read` for 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** → the `linearis` CLI. Full rule + freshness gate: **[Reading Linear](#reading-linear)**.

**CRITICAL: Always use these exact patterns. Do NOT guess or improvise syntax.**

> ⚠️ **Read the [Gotchas & Traps](#gotchas--traps) section before scripting** — `issues list` silently
> hides Done tickets, `linearis` eats stdin in loops, and there is no `--json` flag. These bite hard.

## Reading Linear

> **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`.** Bare `linearis` reads 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.

### The rule

1. **Confirm the replica is FRESH + SEEDED** — both gates (mirrors the daemon's `isReplicaFresh`,
   `execution-core/replica-read.mjs`):
   - **Writer alive:** `<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).
   - **Seed complete:** `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.
2. **Fresh + seeded → query the replica and TRUST it.** Do **not** re-verify against live
   Linear — that defeats the cache and re-burns the quota.
3. **Not fresh / not seeded / your row missing → this is an ALARM, not a silent reroute.** Say
   so loudly, fall back to `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:1` apply-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 via `linearis`, 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.

### Freshness gate (copy-paste, portable macOS/Linux)

```bash
# 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).

If that loud fallback persists, treat it as a replica-tier outage; the configuration order and
health signals are documented in `docs/linear-replica.md`.

### Querying (discover the schema — don't guess columns)

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.
- **Labels:** `issue_labels ⋈ labels` — `JOIN labels l ON l.id = il.label_id WHERE il.issue_id = i.id`.
- **Relations (blocks / blocked-by / …):** the `relations` table (`type, issue_identifier,
  related_identifier`). **Relations lag ≤ 5 min** (reconcile poll, no webhook) — everything
  else is real-time.
- **Any uncolumned field:** `json_extract(raw,'$.path')` (e.g. `json_extract(raw,'$.state.type')`).

Representative read — identifier, title, state, estimate, and labels in one query:

```bash
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 NULL` is REQUIRED: a tombstoned (removed) issue must read as a
> MISS → fall back to live Linear, never as a stale hit.

### Still needs `linearis` (no issue-shaped replica form)

- **Non-issue domains:** `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.
- **Genuinely unmirrored gaps:** cross-team-unsynced parent/child, plus a few unselected fields
  (`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.

### Writes — always `linearis`

`create` / `update` / state transitions / `discuss` / estimate / label **always** go through
`linearis`. The replica is **read-only**.

### `catalyst-linear` CLI — DEPRECATED

The `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.

## Looking Up Syntax

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.

```bash
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
```

## Gotchas & Traps

These are non-obvious behaviors that silently produce wrong results. Verified empirically against
v2026.4.9.

1. **`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.

2. **`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.**

3. **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`.

4. **`--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").

5. **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'`.

6. **`--query` on `issues list` is deprecated** (still works) — use **`issues search "<query>"`** instead.

7. **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.

8. **`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`.

9. **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`.

10. **`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.

## Core Operations

> **Reads → direct SQL against the replica** (see [Reading Linear](#reading-linear)). The
> `linearis issues read|list|search` examples 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.

### Read a ticket

```bash
# 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-123` is the STALE/ABSENT **fallback only**. `linear_read_ticket` runs it
> for you and surfaces the fallback loudly — do **not** shell it directly for a routine read.

### Search tickets

```bash
linearis issues search "keyword"
linearis issues search "auth bug" --team ENG --status "Todo"
```

### Create a ticket

```bash
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.

### Update a ticket

```bash
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`.

### Comment on a ticket

> ⛔ **`linearis issues discuss` posts AS THE HUMAN.** It authenticates with the personal
> `lin_api_…` token, so the comment carries Ryan's identity — and the ask-resolution gate
> (CTL-1567) reads a human-identity comment as *the human deciding* and clears
> the escalation hold. An agent commenting this way can silently look like his decision.
>
> **Every machine reply goes through the app actor instead** — `Catalyst Cloud`, tagged
> with the agent via `createAsUser`:
>
> ```bash
> direnv exec . node "$CLAUDE_PLUGIN_ROOT/scripts/linear-reply.mjs" CTL-123 --as <AGENT> --body "…"
> ```
>
> Use `linearis issues discuss` only when you genuinely intend the comment to be the
> human's. See the `catalyst-dev:ask` skill for the full SOP, and `scripts/ask.mjs` for the
> `create` / `accept` verbs (CTL-1922).

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").

```bash
# An AGENT starting a comment / discussion thread — go through linear-reply.mjs, NOT `discuss`
# (the ⛔ callout above; `discuss` posts under the human's own identity):
direnv exec . node "$CLAUDE_PLUGIN_ROOT/scripts/linear-reply.mjs" ENG-123 --as <AGENT> --body "…" --top

# `linearis issues discuss` — ONLY when the comment is genuinely meant to be the human's own:
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.
# An agent's reply still goes through linear-reply.mjs --parent <thread-id>, not `issues reply`
# (same identity risk as `discuss` — `issues reply` also posts under the personal token).
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:**

```bash
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
```

## Workflow: Backlog Grooming

### Get the lay of the land

```bash
# Discover teams and projects
linearis teams list | jq '.nodes[] | {key, name}'
linearis projects list | jq '.nodes[] | {name, status: .status.name, id}'
```

### Pull tickets by project

```bash
# 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
```

### Find orphaned tickets (no project assigned)

```bash
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}'
```

### Triage by priority

```bash
# 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}'
```

### Find stale tickets

```bash
# Not updated in 30+ days
linearis issues list --team ENG --updated-before 2026-03-13 --status "In Progress" --limit 50
```

### Assign a ticket to a project

```bash
linearis issues update ENG-123 --project "Auth System"
```

## Workflow: Milestone Management

### See milestones for a project

```bash
linearis milestones list --project "Auth System"
```

### Read milestone details (including its issues)

```bash
linearis milestones read "Beta Launch" --project "Auth System"
linearis milestones read "Beta Launch" --project "Auth System" --limit 100
```

### Create a milestone

```bash
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
```

### Rename or reschedule a milestone

```bash
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
```

### Assign tickets to a milestone

```bash
linearis issues update ENG-123 --project-milestone "Beta Launch"

# Clear a milestone assignment
linearis issues update ENG-123 --clear-project-milestone
```

### Audit milestone coverage

```bash
# Tickets in a project with no milestone
linearis issues list --project "Auth System" --limit 100 | jq '.nodes[] | select(.projectMilestone == null) | {identifier, title}'
```

## Workflow: Label Management

### Discover labels

```bash
linearis labels list --team ENG
linearis labels list --team ENG | jq '.nodes[] | {name, color}'
```

### See what a label contains

```bash
linearis issues list --team ENG --label "bug" --limit 100
linearis issues list --team ENG --label "tech-debt" --limit 100
```

### Re-label tickets

```bash
# 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
```

> **`--labels` defaults to OVERWRITE.** Omitting `--label-mode` replaces every
> label on the ticket, silently dropping the ones you did not name. Pass
> `--label-mode add` unless you positively intend a full replacement.

### Trap: a label name can exist on more than one team

A team-scoped label is identified by `(name, team)`, and a workspace can hold
**two different labels with the same name on different teams**. A migration or a
team split leaves exactly that. When it happens, applying the label **by name**
fails, even though the label plainly exists on the issue's team:

```
linearis issues update PROJ-123 --labels orchestrator
  -> "LabelIds for incorrect team — The label 'orchestrator' is not
      associated with the same team as the issue."
```

The failure is **name→id resolution**, not a missing vocabulary: the name
resolved to the other team's twin. `labels list --team <TEAM>` is *correct* and
lists only applicable labels, which is exactly why this is easy to misread as
"discovery and write disagree" and misdiagnose as a stranded vocabulary.

**Step 1 — replica first (free, no API quota).** Confirm which team's issues
actually carry the name, and get the label id, without touching Linear:

```bash
sqlite3 -separator '  ' ~/catalyst/catalyst-replica.db "
  SELECT l.name, l.id, i.team_key, COUNT(*) AS issues
    FROM issue_labels il
    JOIN labels l  ON l.id = il.label_id
    JOIN issues i  ON i.id = il.issue_id
   WHERE l.name = 'orchestrator'
   GROUP BY l.name, l.id, i.team_key;"
```

This answers "which id is in use on MY team's issues", which is usually all you
need — apply that id and move on.

**Step 2 — only if step 1 is inconclusive.** The replica **cannot** prove a
duplicate exists: its `labels` table carries no team column, and it mirrors only
this team's issues, so a twin on another team is structurally invisible to it.
That is a genuine gap, not a shortcut — so this is the documented
single-bounded-check last resort, run **once** during a diagnosis, never in a
loop or a script:

```bash
# LAST RESORT — one call, one label name, during an active diagnosis only.
curl -s -X POST https://api.linear.app/graphql \
  -H "Authorization: $LINEAR_API_KEY" -H "Content-Type: application/json" \
  -d '{"query":"{ issueLabels(filter:{name:{eq:\"orchestrator\"}},first:50){ nodes{ id name team{ key } } } }"}' \
  | jq -r '.data.issueLabels.nodes[] | "\(.name)\tteam=\(.team.key // "WORKSPACE")\t\(.id)"'
```

**Apply by UUID** — the only unambiguous form when a name is duplicated:

```bash
linearis issues update PROJ-123 --labels <label-uuid> --label-mode add
```

Two things that make this trap hard to see:

- **Workspace-scoped labels are immune.** A label with no team (`team=WORKSPACE`)
  has no twin to disambiguate against, so type vocabularies like
  `bug`/`feature`/`chore` keep resolving by name while every component label
  fails. Half your labels working is not evidence that the rest are broken
  differently — it is evidence they are team-scoped.
- **The error names the label, not the team.** It reads as "this label is on the
  wrong team", when the truth is "the name you gave me resolved to a label on
  the wrong team, and the right one exists too."

## Workflow: Cycle Review

### Get the active cycle

```bash
linearis cycles list --team ENG --active
```

### Read cycle with all issues

```bash
CYCLE=$(linearis cycles list --team ENG --active | jq -r '.nodes[0].name')
linearis cycles read "$CYCLE" --team ENG --limit 100
```

### Summarize cycle progress

```bash
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]})
'
```

### Nearby cycles (for planning)

```bash
# Active cycle plus 2 before and after
linearis cycles list --team ENG --window 2
```

## Workflow: Status Transitions

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.

```bash
# 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 — an AGENT posting the "Merged" note goes through linear-reply.mjs, not `discuss`
linearis issues update ENG-123 --status "Done"
direnv exec . node "$CLAUDE_PLUGIN_ROOT/scripts/linear-reply.mjs" ENG-123 --as <AGENT> --body "Merged: PR #456" --top
```

### UUID-based calls (CTL-207)

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.

```bash
# 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
```

### Team-key allowlist cache (CTL-633)

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:

```bash
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.

## Important Rules

1. **--status NOT --state**: Always `--status` for issue updates (`--state` is not a valid flag)
2. **Commenting as an AGENT = `linear-reply.mjs`, NOT `issues discuss`/`issues reply`**: both of those
   `linearis` subcommands authenticate with the single personal token the CLI resolves globally (see
   the ⛔ callout above) — there is no separate app-actor auth path inside `linearis` itself, so they
   ALWAYS post under the human's own identity, which the ask-resolution gate reads as the human
   deciding (CTL-1567/CTL-2086). Use `linear-reply.mjs --as <AGENT>` for every agent-authored comment;
   reserve `issues discuss`/`issues reply` for when the comment is genuinely meant to be the human's
   own. List threads with `issues discussions <id>` (read-only, no identity risk). The old
   `comments create` still works but is a **deprecated compatibility facade** — don't use it. There is
   no `issues comment` subcommand.
3. **milestones NOT project-milestones**: The command was renamed in v2026.4 (old name fails silently)
4. **--status requires --team**: On `issues list`/`search`, `--status` (and `--cycle`) only work with
   `--team`; `--milestone` requires `--project`
5. **--team accepts keys, names, and UUIDs on most commands** (e.g., `--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.
6. **Quotes for spaces**: `--status "In Progress"` not `--status In Progress`
7. **JSON is the default — no `--json` flag**: every command emits JSON; passing `--json` errors. Pipe
   the bare command to jq. (And append `</dev/null` in loops — see Gotchas.)
8. **Use `linearis <domain> usage`**: When unsure about flags, check usage instead of guessing

## Other domains (not detailed above)

v2026.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).
