linearis-cli · diff

git:20260823.b1bf8ed to git:20260826.fa0165e

41 added, 605 removed. Audit B to B.

---
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.
+ 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 ticket IDs like TEAM-123.
---
# 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.
+ > Verified against Linearis v2026.4.9 (2026-05-31). ⚠️ **READ vs WRITE.** Linear **READS** → the local replica by direct SQL, or `linear_read_ticket <ID>`. **Never** shell `linearis issues read` for a routine read — it 429s the shared fleet quota. **WRITES** → `linearis`. Read [Gotchas](#gotchas--traps) before scripting.
## 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
+ > **Single source of the Linear read rule** — other skills point here, they don't restate it.
- > **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.
+ 1. **Cloud detection, every session** — reuse the existing helpers, never write new ones:
+ ```bash
+ source "${CLAUDE_PLUGIN_ROOT:?}/scripts/lib/linear-read-replica.sh"
+ replica_fresh; rf=$? # 0 = writer heartbeat <5min AND seeded
+ source "${CLAUDE_PLUGIN_ROOT:?}/scripts/lib/plugin-dirs.sh"
+ marker="$(plugin_dirs_repo_config_path)" # "" if no .catalyst/config.json found
+ ```
+ Either failing → **no cloud mirror**: say so **loudly** (never silent) and fall back to direct `linearis`/API reads — the **non-fleet path** (protects the 2500/hr quota), wrong to recommend on the fleet. Same pattern: `steward`'s `references/cloud-detection.md`.
+ 2. **Cloud mode confirmed → query the replica and TRUST it.** Don't re-verify against live Linear.
+ 3. **Row missing / not fresh → an ALARM, not a silent reroute.** Loud fallback, file a ticket.
- ### Read a ticket
+ **The only reads you should shell directly are through the helper — it is the freshness gate, not a convenience wrapper.** Never run a bare `sqlite3` query against the replica yourself: it skips the `$rf`/`$marker` checks above and can return stale data (or an empty DB) with no fallback.
```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[]…)
+ json=$(linear_read_ticket ENG-123) || return 1 # freshness-gate → SQL → loud fallback, ONE call
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.
+ Raw SQL syntax (only after the helper's gate already ran), schema discovery, apply-drift caveat, deprecated wrapper: [`references/reading-linear-detail.md`](references/reading-linear-detail.md).
- ### Search tickets
+ ## Core Operations
+ Reads → direct SQL via the gated helper above; writes always `linearis` — run `linearis usage` / `linearis <domain> usage` for authoritative, current flag syntax. **`linear_read_ticket` covers a single ticket only** — a scope-wide list/search still goes through `linearis` (no bulk-query replica form yet; see [Reading Linear](references/reading-linear-detail.md#still-needs-linearis)).
```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"
+ linearis issues update ENG-123 --status "In Progress" --labels "bug" --label-mode add
```
- `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-file <path>
- > # --body-file <path> for anything longer than a one-line body; --body REFUSES a path (CTL-2204)
- > ```
- >
- > 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").
-
+ > ⛔ **Agent comments → `linear-reply.mjs`, never `issues discuss`/`reply`** — those post AS THE HUMAN (personal token; ask-resolution gate reads that as the human deciding, CTL-1567).
```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-file <path> --top
# --body-file <path> for anything longer than a one-line body; --body REFUSES a path (CTL-2204)
-
- # `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
- ```
+ `issues discussions <id>` (read-only) is safe. Full CRUD, comment-thread commands, common mistakes, other domains: [`references/core-operations.md`](references/core-operations.md).
## 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
- # --body-file <path> for anything longer than a one-line body; --body REFUSES a path (CTL-2204)
- ```
-
- ### 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
+ > **Single source of the Linear `stateMap` table** — `linear`, `create-plan`, `implement-plan`, `create-pr`, `research-codebase` point here; none restates it.
- 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
+ | Workflow Phase | Default State | Config Key |
+ | --- | --- | --- |
+ | New tickets | Backlog | `stateMap.backlog` |
+ | Acknowledged | Todo | `stateMap.todo` |
+ | Research / Planning started | In Progress | `stateMap.research` / `.planning` |
+ | Implementation | In Progress | `stateMap.inProgress` |
+ | Verify / Review phase | In Progress | `stateMap.verifying` / `.reviewing` |
+ | PR created | In Review | `stateMap.inReview` |
+ | Completed / Canceled | Done / Canceled | `stateMap.done` / `.canceled` |
- ## Other domains (not detailed above)
+ Names come from `.catalyst/config.json`'s `linear.stateMap` (`null` skips a transition). UUID calls + the team-key allowlist cache (`linear-team-keys.json`): [`references/status-transitions.md`](references/status-transitions.md).
- 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.
+ ## Gotchas & Traps
+ 1. `issues list` **hides Done** (shows Canceled) — `--status "Done"`, or `issues read <ID>` for one.
+ 2. `linearis` **consumes stdin** in a loop — append `</dev/null`.
+ 3. **No `--json` flag** — JSON is the default; pipe to `jq`.
+ 4. `--status` is server-side and **fails empty on a typo** — not an error; also deprecated `--query` (use `issues search`).
+ 5. `--status`/`--cycle` require `--team`; `--milestone` requires `--project`; names collide across projects/teams.
+ 6. `project-milestones` fails **silently** to the help dump — the domain is `milestones`.
+ 7. `status`/`state` are zsh read-only vars (`st`/`s`/`lstate`); `auth status` is the diagnostic entry point when calls return nothing.
- - `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).
+ Cookbook, one topic per file: grooming/triage/stale sweeps — [`references/backlog-grooming.md`](references/backlog-grooming.md); milestone create/rename/audit — [`references/milestones.md`](references/milestones.md); labels + the cross-team same-name trap — [`references/labels.md`](references/labels.md); cycle review — [`references/cycles.md`](references/cycles.md).