issue update could set a due date, estimate, parent, project, or
milestone but never remove one, and project update had the same gap for
lead, start date, and target date. Only --unassign and --clear-cycle
existed. cliffy rejects an empty string as a missing option value, so
--due-date "" is not a workaround, and an agent driving the CLI had to
fall back to a hand-written projectUpdate/issueUpdate mutation through
linear api.
Add one boolean clear flag per field, each placed after its set flag and
modelled on --clear-cycle: it conflicts with its set flag (a
ValidationError before any request, with a null check so --estimate 0
counts as a value), skips the lookup the set flag would run, and puts an
explicit null in the mutation input. --clear-project also rejects
--milestone, because a milestone belongs to the project being removed;
--project with --clear-milestone is allowed so a move can detach a stale
milestone in one update. The project update guard treats each clear flag
as an update and its suggestion lists them.
Linear honours null for every field, including startDate, verified on a
scratch project and issue.
Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
The CLI could only comment on issues. Documents, projects, and initiatives
all take comments in Linear (GitHub issue #230 asked for document comments),
so this adds `document comment list|add`, `project comment list|add`, and
`initiative comment list|add`, mirroring `issue comment` with the same
--body / --body-file conventions and the shared Markdown hint. Every comment
`add`, including the issue one, now takes `--reply-to <commentId>`; -p and
--parent stay as aliases so existing scripts keep working.
The entity-agnostic parts live in src/utils/comments.ts: a typed comment
target union feeding one AddComment mutation, strict body handling (an
explicitly blank --body or body file is an error, not a fall-through to the
prompt), a CommentListFields fragment so the four --json shapes cannot drift,
a page collector, and the threaded renderer. Comment lists now fetch every
page instead of stopping silently at 50, and their JSON nodes, plus the
comments in `issue view --json`, carry quotedText (the passage an inline
comment quotes) alongside parent.id. Replies whose root is missing from the
result are rendered as replies naming their parent instead of being dropped.
API findings, verified live against scratch objects on 2026-09-04:
- A reply must carry its entity id as well as parentId; parentId alone is
rejected, so every add sends both.
- Project comments attach via projectId, but the schema's Project.comments
connection does not return them; only the root `comments` query filtered
by project does. Initiative has no comments connection at all. Both list
commands therefore use the root query and select the entity in the same
operation so an unknown UUID is reported as not found rather than as an
empty list.
- Document comments attach via the document's documentContentId, which is
looked up first; `document(id:)` accepts a UUID or slug directly.
- Linear rejects a reply to a reply and a cross-entity parent with a
user-presentable message, which is surfaced verbatim.
Linear's not-found error carries the user-presentable message "Could not
find referenced <Type>.", which isNotFoundError never matched, so the
existing not-found branches were dead. Matching that wording exposed a
`document view` catch block that re-threw instead of reporting; it now goes
through handleError like everything else.
Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
Every command that takes a team now accepts its key, name, or UUID through
one shared resolver (findTeam / resolveTeam / resolveTeams), replacing the
key-only getTeamIdByKey and the ad-hoc uppercasing spread across commands.
One aliased ResolveTeam operation looks up key, name, and (for UUID-shaped
input) id in a single round trip; precedence is key, then id, then name,
applied client-side so a reference that equals one team's key and another
team's name always means the key. Keys stay the canonical downstream form:
filters that matched on team.key still do, with the server's uppercase key,
and callers that need a UUID take it from the same resolved object. An
unknown team now errors with the list of valid keys instead of an empty
result or a raw "Entity not found" from the API.
Only explicit input goes through the resolver. The configured default team
is already a normalized key, and resolving it would add a round trip to
every default-team invocation of the most-used commands for no gain. In
issue create, the interactive substring picker survives only for that
default; an explicit --team that matches nothing errors like everywhere
else.
issue query --state and issue mine --state take a workflow state name or
ID as well as the six type tokens. Names and IDs are resolved within the
queried scope (the team, the teams, or the whole workspace under
--all-teams, where a name matches every team's same-named state), so a
state from another team errors instead of silently matching nothing, and
the error lists the scope's states. Type-only input still sends the same
{ type: { in } } filter with no extra request; a mix of types and names
becomes an or-filter.
The MCP server already describes these parameters as "key, name, or ID"
and "type, name, or ID"; this brings the CLI to parity so an agent does
not need a preliminary team list to translate a name into a key.
Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
A project's long-form overview body could be set at creation via
project create --content / --content-file, but never changed afterwards:
project update only exposed --description, which is Linear's separate
255-character summary field. Updating the body meant hand-writing a
projectUpdate mutation through linear api and reading the markdown from a
file yourself.
project update now takes the same two flags as create, spelled and worded
identically, and resolves them through create's existing helper so the
mutual-exclusion and file-read behavior cannot drift between the two
commands. Content and description are independent API fields and may be
set together. The no-options guard uses null checks so an empty content
file still counts as an explicit value to forward; Linear currently keeps
the existing body when sent an empty string, so this is not a way to clear
it, and cliffy rejects --content "" outright.
No short aliases: -f already means --description-file on this command and
create has none for content either.
Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
Scripts need to map a team name to its key and id, and team list was the
only way to see both, so they had to scrape its table. It was also the last
list command without JSON. The maintainer asked to sweep the other commands
in the same state; the survey found cycle list, cycle view, milestone list,
milestone view, and project view, so all six get -j, --json here. issue mine
stays human-only on purpose (issue query is its JSON surface).
The reporter proposed a five-field subset for team list. The JSON instead
carries every field the query already selects, per the repository rule to
preserve GraphQL names and nesting rather than invent CLI shapes. Lists emit
{ nodes, pageInfo } after the same filtering and ordering as the table, so
archived teams stay hidden and cycles stay newest-first. Views emit the
object as fetched, including every issue rather than the ten-item preview,
and milestone view --all --json includes every page.
Two of these queries took Linear's default page with no cursor: cycle list
and milestone list silently dropped everything past fifty. Adding JSON would
have made that easier to consume without making it safer, so both now
paginate and fail loudly if Linear advertises a page without a cursor. The
view queries gain pageInfo on their issues connection so callers can see
when a page was partial. A 2.0.0 changelog entry claimed cycle list --json;
that merge only touched SVG files, so this is the first time it ships.
Github-Issue: Fixes#276
Github-Issue-Url: https://github.com/schpet/linear-cli/issues/276
Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
#266 adds `-T/--template` and a `pr_template` config option, which are the right
surface -- the names and the precedence are kept exactly as the contributor
designed them. The mechanism cannot work, though, and I could not find a variant
of it that does.
The command already passes `--body <issue url>`, and #266 appends `--template`
next to it. `gh` refuses that pair outright:
`--template` is not supported when using `--body` or `--body-file`
So every use of the new flag fails, and setting `pr_template` in config breaks
`issue pr` on every invocation rather than only when the flag is passed.
Dropping `--body` to make room for `--template` -- the obvious repair -- is
worse. `gh` only consults a template when it is running interactively; without a
body a non-TTY caller gets
must provide `--title` and `--body` (or `--fill` ...) when not running interactively
and no pull request at all. That would trade a broken flag for a command broken
in CI, scripts, and agents. Handing `gh` a temporary file that already contains
the template fails the same way, because the problem is the missing `--body`,
not the file's contents.
So the template is read here and folded into the body we already send, with the
issue URL appended after it. The URL is what Linear matches on to attach the pull
request to its issue, so it has to survive; putting it last leaves the template's
prose as the first thing a reviewer reads. Every existing flag keeps working,
because the argv shape is unchanged.
Reading the file ourselves means we own its failures, and per CLAUDE.md an
explicitly requested template that cannot be used is an error rather than a
silent fallback to a URL-only body -- otherwise the user gets a pull request
quietly missing the content they asked for. Missing paths, directories,
non-regular files and unreadable files all produce a message naming the path.
NUL bytes are rejected too: `Deno.readTextFile` does not refuse binary input, it
substitutes U+FFFD and keeps the NULs, which `Deno.Command` then rejects with a
bare TypeError that never mentions the file.
One deliberate surface change: #266's description suggests `-T ""` to override a
configured default. That worked only because an empty string happened to be
falsy. It is now an explicit `--no-template` flag, and `-T ""` errors with a
suggestion pointing at it.
The generated skill docs under skills/ are left alone; they are produced from an
installed binary out of band and are already stale on trunk.
Linear attaches every document to exactly one target — project, issue,
initiative, team, cycle, or release — and documentCreate now rejects
targetless documents outright. The CLI only exposed --project/--issue on
create, --project on update, and slug-only --project plus --issue on list.
The reporter asked for the missing create flags; the consistent fix is
wider: a shared attachment-target module (six long-only flags, exactly-one
validation before any network/editor/stdin work, --team + --cycle
collapsing into one team-scoped cycle target like the issue commands)
now backs create, update, and list, so their semantics cannot drift.
Along the way this removes the interactive "workspace document" option
(it always fails server-side now), gives update the missing --issue,
fixes list --project silently matching slug IDs only, resolves list
filters to IDs so bad input errors instead of returning an empty list,
types the ATTACHMENT column (six namespaces make bare names ambiguous),
and shows all six associations in view/list output.
New shared resolvers: resolveInitiativeId (UUID/slug/name) and
resolveReleaseId (UUID/name/version, paginated to exhaustion so ambiguity
detection sees every candidate, erroring on ambiguous matches rather than
picking one silently). teamId/initiativeId/cycleId are [Internal] in
Linear's schema but verified working with a regular API key, as issueId
was before it became public.
Live-QA'd against a real workspace for project/issue/team/cycle/
initiative targets, including re-pointing (the server clears the old
target). Releases require a Business plan and are covered by mocked
tests and the refreshed schema only.
Github-Issue: Fixes#260
Github-Issue-Url: https://github.com/schpet/linear-cli/issues/260
Three related gaps in member listing:
`team members` had no --json output. It now emits the connection shape
({ nodes, pageInfo }) with GraphQL field names preserved, matching label list
and project list.
There was no way to list everyone in the workspace, only per-team. Adds
`linear user list` (alias `u`) querying viewer.organization.users. This is a
sibling command rather than a `team members --organization` flag: workspace
members are definitionally not team members, and a flag that has to error when
combined with the positional team key is the design saying it's two commands.
Members now show admin, owner, and you markers alongside the existing
inactive/guest/not-assignable ones, via a shared renderer.
Along the way this fixes `team members --all`, which was a no-op. getTeamMembers
never passed includeDisabled, so Linear defaulted it to false and disabled users
were never fetched — the client-side `active` filter was narrowing a set that
could not contain them. The regression test pins includeDisabled in the mock
variables, so it fails loudly if the flag stops reaching the API.
Note: the --all fix could not be exercised against real data; the workspace used
for QA has no disabled users.
## Summary
This improves `linear issue create` in three related areas:
- allow `--project` to keep issue creation interactive, the same way
`--parent` already does
- add optional project selection in interactive issue creation
- add configurable default self-assignment behavior for created issues
Before this change, `linear issue create --project "Dashboard"` skipped
interactive mode and failed with "Title is required when not using
interactive mode". With this PR, `--project` is treated as an
interactive-safe input, so users can preselect a project without also
needing `--title`.
Project prompting in interactive mode remains opt-in.
`issue_create_ask_project` defaults to `false`, so existing interactive
behavior is unchanged unless users explicitly enable the new prompt.
## Changes
- allow interactive issue creation when the only flags are `--project`,
`--parent`, or both
- add a team-scoped project picker during interactive issue creation
- gate the direct project prompt behind a new config option:
`issue_create_ask_project`
- keep `issue_create_ask_project = false` as the default, so project
selection stays out of the main interactive flow unless enabled
- when `issue_create_ask_project = false`, expose `Project` through the
existing "Add more fields" flow instead
- continue inheriting the parent issue's project when `--parent` is used
without an explicit `--project`
- allow explicit `--project` together with `--parent` and defer any
invalid combination checks to the Linear API
- add a new config option, `issue_create_assign_self`, with these modes:
- `auto` (default): respect Linear's `autoAssignToSelf` setting
- `always`: default-assign created issues to self
- `never`: never default-assign created issues
- update docs for the new interactive behavior and config options
## Notes
- `issue_create_ask_project` defaults to `false`, so users who do not
set it will keep the previous interactive flow
- project selection only shows projects available to the selected team
- the interactive project picker is not shown when a parent issue is
present unless the project was explicitly provided
- explicit `--assignee`, the interactive assignee flow, and `--start`
still override the default assignment behavior
## Testing
- `deno task codegen`
- `deno task check`
- `deno lint`
- `deno fmt`
- `deno task generate-skill-docs`
- `deno test --allow-all --quiet test/config.test.ts`
- `deno test --allow-all --quiet
test/commands/issue/issue-create.test.ts`
## Summary
This makes Linear document comments visible to agents/automation and
prevents `linear document update` from silently orphaning inline comment
anchors.
- include paginated document comments in `linear document view <id>
--json`
- guard Markdown content updates when active inline document comments
are present
- allow top-level document comments to pass through, since they do not
carry losable inline anchors
- add `--force` to opt back into the existing replacement behavior when
the caller intentionally accepts the risk
## Background / related work
Closes#230, which reports that document comments are currently
unreachable from the CLI and omitted from `document view --json`.
Related:
- #219 added richer `document view` behavior for downloaded inline
images; this PR keeps normal/raw rendered views lean and only expands
`--json` with comment metadata.
- #121 added the raw `linear api` GraphQL escape hatch. That is useful
for inspection, but it does not make `documentUpdate(content)` safe for
inline document comments.
## API limitation
This is intentionally a safety guard plus read fix, not a
comment-preserving writer.
From the schema used by this CLI:
- `Document.comments(...)` exposes document comments and
`Comment.quotedText`, which is enough to detect inline comments.
- `DocumentUpdateInput` only accepts Markdown `content` for document
body writes; it does not accept `contentState`/YJS/ProseMirror data or
comment anchor metadata.
- `CommentCreateInput`/`CommentUpdateInput` expose `quotedText`, but
live testing showed raw GraphQL `commentUpdate(quotedText: ...)` returns
success without recreating an inline anchor. Writing
`<linear-comment>`-style tags through `documentUpdate(content)` stores
literal text, not an anchor.
So the CLI cannot preserve or restore inline anchors through the public
GraphQL write path it uses. The best safe behavior here is to make
comments visible and convert silent data loss into an explicit stop.
`--force` remains the old replacement behavior behind an intentional
flag.
## Behavior
`linear document view <id> --json` now returns a flattened, paginated
`comments.nodes` list with fields useful to agents:
- `id`, `body`, `quotedText`, `documentContentId`
- timestamps / archive / resolution metadata
- `url`, `user`, and `parent.id`
- final `pageInfo`
`linear document update <id> --content...` now scans active document
comments page-by-page. It blocks only when it finds a comment with
`quotedText`, i.e. an inline comment anchor that can be detached by
replacing Markdown content. Top-level document comments with
`quotedText: null` do not block.
## Testing
- `deno task validate`
- `deno test --allow-all --quiet`
Full suite result locally: `338 passed`, `0 failed`, `5 ignored`.
## Why
Linear projects have two text fields: a short description and a longer
project overview. The CLI only supported the short description, so
projects created from the terminal still needed a manual edit in Linear
to add goals, plans, specs, or launch notes.
This PR lets users create a project with its overview already filled in,
either from inline markdown or a markdown file. That makes `linear
project create` more useful for scripted project setup, templates, and
project specs kept in a repo.
It also exposes a few existing Linear create fields so users can set
priority, labels, members, icon, and color when creating the project
instead of doing a follow-up edit.
## What changed
- `linear project create` now accepts `--content` for inline project
overview markdown.
- `--content-file` reads the project overview from a markdown file.
- `--content` and `--content-file` are mutually exclusive.
- Project create can now set priority, labels, members, icon, and color.
- The command passes these values through `ProjectCreateInput` using
Linear field names.
## Checks
- `deno task codegen`
- `deno task check`
- `deno lint`
- `deno task test`
Image attachments uploaded via `issue attach` and `issue comment add
--attach` were sent with makePublic auto-detected to true for raster
images, producing a public.linear.app URL readable by anyone,
unauthenticated, with no way to opt out. This silently published
screenshots of internal data from private workspaces.
Default all uploads to private (uploads.linear.app), matching the Linear
web app. Add a --public flag to both commands to opt into a public URL,
which is only valid for raster images; requesting it for other types is
now an error rather than a silent downgrade. Print a warning whenever an
upload lands on a public URL.
Also document the attachment commands and their privacy behaviour in the
README (previously undocumented) and regenerate the skill reference.
Fixes#233
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce 'issue mine' for personal work queue and 'issue query' for
structured retrieval with optional full-text search via --search.
Keep 'issue list' as a separate backwards-compatible command.
Remove standalone 'issue search' registration in favor of query --search.
query supports multi-team filtering, --all-teams, --json output,
--search-comments, and verifies GraphQL variables in tests.
Adds --milestone flag to `linear issue list` to filter issues by project
milestone name. Requires --project since milestones belong to projects.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Summary
- Adds `--milestone` flag to `issue create` and `issue update` commands
- Resolves milestones by name (case-insensitive) within the specified
project
- For `issue update`, automatically falls back to the issue's existing
project if `--project` is not explicitly provided
- Validates that a project context exists before attempting milestone
lookup, with helpful error messages
- Shows **project** and **milestone** in `issue view` output (displayed
below the title when present)
## Example usage
```sh
# Create an issue with a milestone
linear issue create --title "Implement feature" --team ENG --project "My Project" --milestone "Phase 1"
# Update an issue's milestone (project inferred from issue)
linear issue update ENG-123 --milestone "Phase 2"
# View an issue — now shows project and milestone
linear issue view ENG-123
# => Project: My Project | Milestone: Phase 1
```
## Test plan
- [x] Happy path tests for create and update with milestone
- [x] Snapshot test for `issue view` with project and milestone
displayed
- [x] All 205 tests pass
- [x] `deno check` and `deno lint` pass
- [x] GraphQL codegen regenerated
- [x] Verified end-to-end against Linear API
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add CI workflow step to verify generated skill docs are up-to-date
- Add development section to README with instructions for:
- Updating skill documentation with deno task generate-skill-docs
- Running code formatting with deno fmt
- Update generate-docs.ts to automatically format generated files
- Regenerate skill docs to ensure they match current CLI version
Fixes#135
The auth login command is now stable, so remove the warning from README.
Regenerated skill docs and updated marketplace.json and plugin.json
versions to match deno.json.
Add a warning callout in the setup section noting that `linear auth login` is new and may have issues, with a link to the v1.8.1 README for stable setup instructions.
Add built-in credential storage for managing multiple Linear workspaces:
- ~/.config/linear/credentials.toml stores API keys by workspace slug
- auth login: add credentials (auto-detects workspace from API)
- auth logout: remove credentials
- auth list: show configured workspaces with org/user info
- auth default: set the default workspace
- global -w/--workspace flag to target specific workspace
API key precedence: CLI flag > env var > config > workspace flag > project workspace > default
This PR adds several major features to linear-cli:
- **Initiative management**: Full CRUD support for initiatives including
list, view, create, archive, unarchive, update, and delete commands
- **Initiative-project linking**: Commands to add and remove projects
from initiatives
- **Label management**: List, create, and delete commands for labels
with team filtering
- **Project creation**: New `project create` command with interactive
mode and initiative linking
- **Team deletion**: New `team delete` command with confirmation
- **Bulk operations**: New utility supporting bulk operations across
commands (issue delete now supports multiple IDs)
## New Commands
### Initiatives
- `linear initiative list` - List all initiatives with filtering options
- `linear initiative view <id>` - View initiative details including
linked projects
- `linear initiative create` - Create new initiative (interactive or via
flags)
- `linear initiative archive <id>` - Archive an initiative
- `linear initiative unarchive <id>` - Unarchive an initiative
- `linear initiative update <id>` - Update initiative properties
- `linear initiative delete <id>` - Delete an initiative (with
confirmation)
- `linear initiative add-project` - Link a project to an initiative
- `linear initiative remove-project` - Remove project from initiative
### Labels
- `linear label list` - List labels with optional team filter
- `linear label create` - Create a new label
- `linear label delete <id>` - Delete a label
### Projects
- `linear project create` - Create a new project with team, lead, dates,
status, and optional initiative linking
### Teams
- `linear team delete <id>` - Delete a team (with confirmation)
Add support for loading `.linear.toml` from user's home directory as a
fallback when no project-level config exists:
- Unix: `~/.config/linear/linear.toml` or `$XDG_CONFIG_HOME/linear/linear.toml`
- Windows: `%APPDATA%\linear\linear.toml`
Config precedence (highest to lowest):
1. CLI flags
2. Environment variables
3. Project config (`.linear.toml` in cwd or repo root)
4. User home config
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
## Summary
Adds comprehensive milestone management functionality to linear-cli,
enabling full CRUD operations for Linear project milestones.
## Motivation
The Linear API supports project milestones, but the CLI didn't expose
this functionality. This PR adds milestone management commands to
complement the existing project and issue commands.
## Changes
### New Commands
- `linear milestone list --project <id>` - List milestones for a project
- `linear milestone create` - Create new project milestones
- `linear milestone update <id>` - Update existing milestones
- `linear milestone delete <id>` - Delete milestones with confirmation
- Short alias: `linear m` for all milestone commands
### Features
- Full CRUD operations for project milestones
- Table-formatted output for list command
- Interactive confirmation prompts for deletion (with `--force` option
to skip)
- Support for milestone name, description, and target date
- Follows existing CLI patterns and conventions
### Implementation
- Uses GraphQL Code Generator for type-safe queries/mutations
- Built with Cliffy for command structure and prompts
- Leverages existing graphql client utilities
- Includes spinner for loading states
- Updated README with usage examples
## Testing
All commands have been tested with the Linear API:
- ✅ List milestones for a project (empty and populated)
- ✅ Create milestones with name, description, target date
- ✅ Update milestone properties
- ✅ Delete milestones with confirmation prompt
- ✅ Delete with `--force` flag (skip confirmation)
## Documentation
Updated README.md with:
- Milestone commands section
- Usage examples for all operations
- Command aliases
## Example Usage
```bash
# List milestones
linear milestone list --project abc123
linear m list --project abc123 # alias
# Create a milestone
linear milestone create --project abc123 --name "Q1 Goals" --target-date "2026-03-31"
linear m create --project abc123 # interactive mode
# Update a milestone
linear milestone update xyz789 --name "Q1 Objectives"
linear m update xyz789 --target-date "2026-04-15"
# Delete a milestone
linear milestone delete xyz789
linear m delete xyz789 --force # skip confirmation
```
use case:
- you are addressing feedback on an issue ABC-123
- run `linear issue start 123` to re-start that issue
- fire up claude code
- run `! linear issue view` to drop the issue, and comments into context
- run `! linear issue commits` to dump the diff of previous work into
context
- ask claude to address the feedback (it will have a much better shot of
knowing what you are talking about)
Create Claude Code skill plugin for linear-cli with marketplace and
plugin metadata. Includes SKILL.md documentation that explains how to
use the linear-cli CLI tool for managing Linear issues from the
command line.
- Add marketplace.json with plugin configuration
- Add plugin.json with plugin metadata
- Add skills/linear-cli/SKILL.md with usage documentation
Claude-session-id: 60a47cf8-9f8d-46a9-bbde-6097eafd8584
Document all supported configuration options in the README with a
table showing environment variable names, TOML config keys, example
values, and descriptions. Include information about valid config file
locations and precedence rules.
Claude-session-id: 0effccb0-b575-4bf8-b1c7-5b850894d475
Add documentation for the new comment management commands:
- linear issue comment list: view all comments on an issue
- linear issue comment add: add a new comment or reply
- linear issue comment update: modify an existing comment
Claude-session-id: d5069805-c5b7-479c-bc65-f7d20f7c39d7
Refactor issue start workflow to support both git and jj version control
systems via a new LINEAR_VCS configuration option (defaults to git).
- Create vcs.ts abstraction layer handling git and jj workflows
- Create jj.ts utility module for shared jj operations and formatting
- Add LINEAR_VCS config option to select between "git" and "jj"
- Use exhaustive switch with satisfies never for type safety
- Refactor issue-describe to reuse issue description formatting
- Extract git branch operations into vcs.ts for VCS abstraction
Claude-session-id: 3a93e6b3-7ede-4bc6-b6e3-cb6b2f08682a