One command to go from "I should work on PE-N" to a clean git worktree with your gitignored files symlinked in and Claude Code already running in plan mode.
$ lcc start
PE-42 H In Progress Migrate notification scheduler off Redis
PE-51 Todo Add Liquid Glass to settings sheet
> PE-47 M Todo Backfill missing receipt categories
feature/pe-47-backfill-receipts — https://linear.app/…/PE-47/…
3/165 · ↑↓ move · enter select · esc cancel
✓ Worktree created: /Users/me/Documents/Projects/Pantry/.lcc/worktrees/PE-47-backfill-receipts
✓ Linked .claude/settings.local.json
✓ Linked .env
✓ Linked .env.local
[Claude Code opens in the worktree, in plan mode]
A single ~1.5 MB binary with no runtime. It shells out to git, claude, gh, open, du, defaults and plutil — everything else is the Zig standard library plus macOS's own Security framework.
Requires Zig 0.16 and macOS. Install Claude Code and make its claude executable available on PATH.
git clone https://github.com/pfriedrix/lcc ~/Documents/Projects/lcc
cd ~/Documents/Projects/lcc
zig build -Doptimize=ReleaseFast
ln -sf "$PWD/zig-out/bin/lcc" /opt/homebrew/bin/lcczig build test # unit tests
zig build run -- list # run without installingEditing anything under src/ changes nothing on your PATH until you re-run zig build -Doptimize=ReleaseFast. A plain zig build installs a debug binary to the same path — slower, and noisier on unexpected errors.
.github/workflows/ci.yml runs both of those on every pull request into master and is a required status check, so a red suite blocks the merge button. It pins the Zig version and the runner image on purpose — the gate should only ever go red because of a change in this repo, never because a toolchain or runner image rolled forward underneath it. Both pins are one-line bumps.
lcc auth
# Browser opens → click Authorize → done.lcc uses OAuth 2.0 with PKCE — like gh auth login. No API keys pasted into the terminal, no shell history secrets. The token is stored in the macOS Keychain and refreshed automatically when it expires. The callback listener gives up after five minutes if you never finish in the browser.
For headless machines, lcc auth --token <pat> stores a Linear personal API token instead.
lcc # print the command list
lcc start # pick an issue, bootstrap worktree, open Claude Code in plan mode
lcc start PE-256 # that issue, no picker
lcc start PE-256 --plan plan.md # a plan already exists — skip plan mode
lcc start PE-256 --json # resolve it and print the result instead of launching an agent
lcc start PE-256 --no-watch # run in this terminal; the session dies with it
lcc start --all # ignore the activeStates filter
lcc issue show PE-256 # state, project, labels, description — reads nothing else
lcc issue show PE-256 --json # the same, for a caller that parses it
lcc issue state PE-256 "In Progress" # move it, by the name on the board
lcc issue comment PE-256 -m "done" # add a comment
lcc issue comment PE-256 -f plan.md # …or read the body off disk
lcc issue project PE-256 --assign v2.6.0 # put it in a release project
lcc config # every setting, in a list you move through and toggle
lcc setup # the same thing, under the name muscle memory reaches for
lcc config watchByDefault false # or name one directly, with no prompt
lcc list # dashboard of every worktree (--local to skip the network columns)
lcc stats # what each worktree has spent in Claude Code
lcc open # the worktrees, and what is running in them
lcc open --json # the sessions as a one-shot, for a caller that parses it
lcc open --stop-all # end every session running in the background
lcc open xcode # pick a worktree and open it in Xcode instead
lcc remove # select worktrees, remove them + their branches + Xcode build data
lcc remove --merged # bulk: every worktree and branch whose work already landed
lcc clean # reclaim build data and transcripts left by worktrees that are gone
lcc auth # log in
lcc auth --status # who am I, when does the token expire
lcc auth --logout # clear the token from the Keychainls, o and rm are accepted as aliases for list, open and remove.
Token statistics and session cleanup read Claude Code transcripts under ~/.claude/projects.
lcc start runs the session in the background rather than taking over the
terminal it was called from. Closing the window, losing an ssh link or rebooting
the terminal emulator no longer kills the agent — the work carries on and
lcc open shows it.
lcc open is the dashboard: every worktree of the repo you are in, and
whatever is running in each. Enter opens one — attaching if a session is
already there, starting one if not.
ISSUE STATUS BRANCH AGE
❯ PE-256 ● waiting feature/pe-256-app-hangs-on-launch 4s
PE-270 ◐ active feature/pe-270-crash-in-mapview 12s
PE-301 ◈ plan feature/pe-301-widget-refresh 31s
PE-9 · no session feature/pe-9-unrelated —
↑↓ move · enter opens · n new issue · x kill · q quit
Shortcuts are key positions, not characters, so they work on any keyboard
layout — on Ukrainian ЙЦУКЕН the key labelled n prints т and still means
new. The same goes for the y/n confirmation lcc remove puts in front of a
deletion, which a Cyrillic layout previously had no way to answer at all.
n picks another issue and starts it without leaving the dashboard — the same
picker and the same bootstrap as lcc start, just without having to quit first.
Changing your mind puts you back in the list rather than quitting lcc.
A session running in another repository still appears, because an agent working
somewhere you are not looking is the one you most need to see. lcc open xcode
is unchanged and still opens a worktree in Xcode.
What the list never shows is a directory that is not there. A worktree you
removed drops off the moment its directory does — including one git worktree prune has not caught up with yet, which git still lists — and the sessions that
ran in it go with it rather than staying as rows that open nothing. Nothing
cleans up after them otherwise: the background process never drops a session from
its own list and writes that list once more on the way out, so its file goes on
naming every worktree it ever touched until a new one replaces the file.
That rule holds even when the agent is still running: a session whose directory
you deleted has no row, because the row would open nothing and the work has
nowhere left to happen. lcc remove is the usual way to get there — it does not
stop a session before taking its worktree away — and lcc open --stop-all is
what ends what is left. Gone means gone, though, and not "could not tell": a
worktree behind a directory this process cannot read, or on a volume that is not
mounted right now, keeps its row and its session rather than being reported as
deleted.
● waiting is the one that wants you: the agent is blocked on a permission
prompt or a question. ◐ active is a turn in flight, ○ idle is finished.
Those come from Claude Code's own hooks rather than from reading its screen, so
a new Claude Code release cannot quietly make them wrong.
A session also reads ● waiting when it has been active for a quarter of an
hour with nothing reported at all. That is not a turn Claude Code said anything
about — it is one lcc has lost track of, because the turn wedged or because it
is blocked on something whose notification never arrived. Either way it wants a
person, and ○ idle would be a claim only a finished turn earns: a session that
had stopped and needed opening used to be painted the same dim circle as the
ones that had nothing left to do.
◈ plan is a turn in flight too, in Claude Code's plan mode — it is researching
and writing a plan, and has not been approved to touch any files yet. Since
planMode defaults to on, every session starts there and leaves when you
approve the plan, so the marker going away is the useful half of it. A session
that is blocked on that approval reads ● waiting, not ◈ plan: the prompt
in front of you outranks the mode behind it.
Enter attaches, and from there the terminal is entirely Claude Code's — lcc
writes nothing of its own over it. ^\ returns to the dashboard without
touching the session. ^C still reaches the agent, which is the point of not
using it to detach. q quits lcc entirely and the sessions keep running.
That one keybinding is the thing worth remembering, because nothing on screen repeats it. There was a status bar on the bottom row that did — the child was told the terminal was a row shorter and a scroll region kept it out of the last line — and against Claude Code it lost. It repaints many times a second using cursor moves relative to wherever the cursor already is, so every paint has to borrow the cursor and hand it back exactly; it also resets the scroll region and uses the terminal's single saved-cursor slot itself. Three different repaint schedules each still disturbed its rendering. Doing it properly means emulating a terminal, which is more than one line of text is worth, so the row went back to the agent.
lcc open --stop-all ends every background session at once, and --force
kills them rather than letting them finish. Otherwise they retire on their own
30 minutes after the last one is done, so nothing accumulates from a month of
finished work.
Two things worth knowing. Sessions run in one shared background process, and
they do not survive it: if it dies the ptys are revoked and every agent gets a
hangup, the same property tmux has. And lcc remove does not yet check whether a
worktree has a live session in it, so check lcc open before removing one.
What does survive is the status. Every hook writes what it reported to a small
file of its own, so when the background process is replaced the dashboard still
shows what each worktree was last doing rather than a column of no session —
which is what you used to get, because a fresh process rewrites the registry from
the one session it just started and erases the rest. A recovered row reads
exactly like a live one; the AGE column is what tells you the ● waiting is four
hours old. Enter on it starts the work again with --resume instead of
attaching, since there is no longer a session to attach to. An agent interrupted
mid-turn reads ● waiting rather than ◐ active: it has no process left, and a
turn in flight is a claim only a running one can make. A session you quit
normally clears its file and goes back to no session, having nothing left to
say. Sessions that died before this shipped left no file behind and stay
no session until you start them again.
A row the dead process left in the registry does not outrank that file either. It
can only read unknown — the one thing that could have said otherwise is gone —
and what the hooks reported is better than that, so the recovered status wins. It
is still not offered for attach: the session behind that row died with its
process, whatever the registry remembers of it.
Sessions also hold the build of lcc they started under. After zig build the
sessions still running can be many commits behind the lcc you are typing —
older behaviour reached through the same command, and every symptom of it looks
like a bug in the new code. The dashboard says so when it happens
(outdated_build in --json), and lcc open --stop-all clears it, at the cost
of ending the work in flight.
Turn the whole thing off with lcc config watchByDefault false, or per
invocation with lcc start --no-watch. Either way lcc start goes back to
running Claude Code in the terminal it was called from. If a session cannot be
put in the background, lcc start says so and falls back to that on its own
rather than failing.
lcc list is the "what am I in the middle of" view — one row per worktree:
$ lcc list
BRANCH STATUS SYNC AGE TOKENS PR LINEAR PATH
feature/pe-256-app-hangs 3 dirty ↑2 ↓0 2h 53M #412 open In Progress ~/…/pe-256-app-hangs
feature/pe-247-exc-bad clean ↑0 ↓14 6d 8.0M #398 merged In Build ~/…/pe-247-exc-bad
feature/pe-224-history clean gone 11d — — In Review ~/…/pe-224-history
| Column | Where it comes from |
|---|---|
STATUS |
git status --porcelain in the worktree — entry count, or missing when the directory is gone |
SYNC |
%(upstream:track) — ↑ahead ↓behind, unpushed with no upstream, gone once the remote branch is deleted |
AGE |
how long ago the branch tip was committed |
TOKENS |
context tokens the worktree's Claude Code sessions have read — see token usage |
PR |
gh pr list for the repo, matched on head branch; open beats merged beats closed |
LINEAR |
the state of the issue the branch names, e.g. PE-256 out of feature/pe-256-… |
Everything local is two git calls plus one git status per worktree — about 0.2s on a large iOS repo with five worktrees. PR and LINEAR are one batched request each and are the whole rest of the cost: roughly 1.9s in total, more on the first call of the day while Linear warms up. --local drops both columns, as does a missing gh or an expired Linear token — those print a hint and leave the rest of the table intact. --no-tokens drops the transcript read behind TOKENS.
Type to filter. Every whitespace-separated word has to appear somewhere in the row — identifier, title, branch, state, assignee or team — and matching is case-insensitive across Latin and Cyrillic alike.
Results are ordered by where the match landed: the start of the identifier ranks above the start of a word, which ranks above something buried mid-word. Typing log therefore surfaces logout() and logAllValues() ahead of issues that merely sit in Backlog. Rows scoring the same keep their original order — your activeStates order first, most recently updated first within each state.
↑/↓ move, enter selects, esc or Ctrl-C cancels with exit code 130.
lcc start PE-256 skips the picker. The issue is fetched by identifier rather than filtered out of your assigned list, so activeStates and the assignee do not apply — naming an issue is a more specific answer than either. --base <ref> cuts a new branch from <ref> instead of asking.
lcc start opens the session in Claude Code's plan mode — claude --permission-mode plan. Taking a worktree is the moment a task is about to be misunderstood most cheaply, so that is where the reading, the questions and the explicit approval belong, and it is the default rather than something to remember.
$ lcc start PE-256
Launching Claude Code in plan mode in ~/…/pe-256-app-hangs
Linear: https://linear.app/x/issue/PE-256/…
The mode governs how the session opens, not what it plans about: what to plan comes from startTaskCommand, which is where a slash command of your own can say what a finished plan has to contain.
--plan <file> says the planning already happened — in the main checkout, in Notion, in a previous session — so the worktree opens straight into the work rather than deriving the answer a second time. Supplying one is what turns plan mode off; either the task is planned now or a plan is brought to it, and there is no third state.
lcc start PE-256 --plan ~/.claude/plans/peaceful-napping-sutton.mdThe plan reaches the agent through {plan} in startTaskCommand, and what travels is the absolute path, not the contents:
{ "startTaskCommand": "/start-task {identifier} --plan {plan}" }{plan} is the only channel, so --plan without it in the template is refused rather than launched. Otherwise the flag's one remaining effect would be to switch plan mode off, leaving the session neither planning here nor holding a plan from anywhere — and startTaskCommand is empty by default, so that would be the out-of-the-box behavior.
A plan is a document, and inlining one would put it in an argv element and in the start_task_command field of --json, which a caller parses — for bytes the agent can read off disk itself. It is resolved to an absolute path, because the agent runs with the worktree as its cwd, and checked before the Keychain is even touched: a path that is not there fails with plan_not_found, one that is a directory says so, and one that exists but cannot be read comes back as plan_unreadable rather than claiming to be missing. Without --plan, {plan} expands to nothing.
There is no auto-detection: the newest file in ~/.claude/plans is not picked up on its own.
An identifier names work without naming a checkout, and nothing in Linear closes that gap: the team is one team, the project is a release, and branchName is derived from the title. Answering it with the current directory alone is right until you are standing somewhere else — and then lcc cuts a branch and builds a worktree that look entirely correct in a repo that has nothing to do with the issue, which is noticed only once the first file is nowhere to be found.
So the question is answered in order of how much each answer can be trusted:
--repo <path> |
said outright, and nothing else is consulted |
| what lcc remembers | the answer this issue got last time, which is why lcc start PE-256 works from any directory at all — including one that is not a repository |
| where the work already is | a branch for PE-256 in a repository lcc knows is the answer: that is where the commits are |
| the question | a picker over the repositories lcc knows, plus the ones sitting beside the current checkout, so the first run has something to offer instead of demanding a path |
Never a guess from the issue text. The words in a title are exactly the words that turn up in unrelated repositories — this repo's own README mentions logAllValues, which is enough to attract an issue that belongs to an iOS app.
Answers are kept in ~/.config/lcc/repos.json (override with LCC_REPOS) and written once a worktree exists, so the picker appears once per issue and never again. lcc start --json never falls back to the current directory: with nothing remembered and no branch anywhere, it fails with repo_unconfirmed rather than creating a worktree somewhere plausible.
Work the issue already has is reused rather than duplicated, at two levels. A worktree is found by exact branch name first, then by the PE-N in it; failing that, a branch carrying the same PE-N under an older name is checked out instead of a second one being cut beside it — Linear changes what it suggests when an issue is renamed, while the commits stay where they were. Both reuses are reported (matched_by, renamed, created: reused_local), and the most recently committed branch wins when a rename happened more than once.
Running lcc start PE-256 twice therefore opens the same worktree twice, whatever the template or the title has done since.
Machine mode, for a caller that is already inside an agent session — a /start-task slash command that owns the issue tracker can use it instead of probing git for the branch and the worktree itself. It requires an identifier, asks nothing (a new branch comes off --base or the default branch), and does not launch an agent, so it is safe to call from inside a session lcc itself started.
$ lcc start PE-256 --json
{
"issue": { "id": "…", "identifier": "PE-256", "title": "…", "url": "…",
"state": "In Review", "state_type": "started", "team": "PE", "assignee": "…" },
"branch": { "name": "feature/pe-256-…", "suggested": "feature/pe-256-…", "renamed": false,
"upstream": "origin/feature/…", "pushed": true,
"ahead": 0, "behind": 0, "current": true },
"worktree": { "path": "/Users/me/…/App.worktrees/pe-256-…", "status": "existing",
"matched_by": "branch", "created": null, "base": null,
"is_main_checkout": false, "is_cwd": true },
"repo": { "root": "/Users/me/…/App", "default_branch": "main" },
"links": { "linked": [".env"], "skipped": [".claude/settings.local.json"] },
"start_task_command": "/start-task PE-256",
"mcp": { "config": "/Users/me/.config/lcc/mcp/-Users-me-Projects-App.json",
"servers": ["linear-server", "xcode"] }
}Every key is always present; absent values are null, never dropped.
| Field | Meaning |
|---|---|
branch.name |
the branch to use — the one with the commits |
branch.suggested / renamed |
what Linear's branchName implies today, and whether it has drifted from name |
branch.pushed / upstream |
false until the branch has an upstream; while it is, Linear's GitHub integration cannot see the branch and will not auto-transition the issue |
worktree.status |
created or existing |
worktree.matched_by |
how an existing worktree was recognised — branch (exact name) or issue (the PE-N matched, i.e. the issue was renamed). null for one this run created |
worktree.is_cwd |
whether this very process is standing in it, which is what tells a caller there is nothing left to open |
worktree.created / base |
the strategy (new, reused_local, tracking_remote) and what a new branch was cut from; null when the worktree already existed |
mcp |
the repo's local-scope servers and the generated --mcp-config. It is null when there is nothing to carry |
Failures come back in the same shape, on stdout, with exit code 1:
{ "error": { "code": "issue_not_found", "message": "No issue PE-999 in Linear." } }Codes: usage, not_authenticated, keychain_unreadable, auth_failed, bad_identifier, issue_not_found, linear_failed, worktree_path_exists, git_failed, bad_repo, plan_not_found, plan_unreadable, repo_unconfirmed — the last one is the picker above in a mode with nobody to ask: pass --repo <path>, or run it once interactively and the answer is remembered. Progress lines and the human-readable error go to stderr, so stdout holds nothing but the payload — including git's own output, which is captured rather than inherited in this mode.
One Linear issue, named by its identifier. Unlike every other command here it needs no repository — it answers the same from anywhere, which is what lets a caller ask about an issue before deciding where its code lives.
$ lcc issue show PE-250
PE-250 Fix CLVisit capture: dropped visits, lost headless writes, no departure
State In Build
Project v2.6.0
Assignee Danylo Krysevych
Labels for/team, source/self, type/bug
Branch feature/pe-250-fix-clvisit-capture-dropped-visits-lost-headless-writes-no
https://linear.app/pfx-lab/issue/PE-250/fix-clvisit-capture-…show is the read-only probe that lcc start --json deliberately is not: start cuts a branch and a worktree on the way to its answer, and a caller that only wanted to look has to undo them.
$ lcc issue show PE-250 --json
{
"issue": { "id": "…", "identifier": "PE-250", "title": "…", "url": "…",
"state": "In Build", "state_type": "started", "state_id": "…",
"team": "PE", "team_id": "…", "assignee": "…", "priority": 3,
"branch_name": "feature/pe-250-…", "updated_at": "2026-07-27T13:40:16.783Z" },
"project": { "id": "…", "name": "v2.6.0" },
"labels": ["for/team", "source/self", "type/bug"],
"description": "## Symptom\n\n…"
}The issue block matches lcc start --json's field for field where the two overlap, so a caller parses one shape rather than two, plus the two ids a write needs: state_id says whether a state change would be a no-op, and team_id is the UUID Linear demands wherever the human key PE is refused.
Labels come back grouped — type/bug, not the bare bug Linear's API answers with. The group is the half that tells a type/bug from an area/bug, and a caller dispatching on type/ has nothing to match without it.
project is null rather than absent when the issue is in no release project, which for an issue past Todo is the thing worth noticing: it is invisible on the release board. Every key is always present; absent values are null, never dropped.
A state is named the way it is written on the board, case-insensitively, and resolved against that team's own workflow states — fetched in the same request as the issue, so there is no window between reading the ids and writing one.
$ lcc issue state PE-283 "in progress"
✓ PE-283: Todo → In ProgressMatching is on the name alone. Nothing here searches on Linear's statusType, and that is the design rather than an omission: matching on the type is what let Canceled resolve to Duplicate, because a type has as many holders as the board has columns in that group. --type filters a set that already matched by name — it can narrow, and it can never reach a state the name did not find.
When two states really do share a name, lcc refuses in both modes rather than guessing:
✗ 2 states in PE are called 'Done': completed, canceled.
Narrow it: lcc issue state PE-283 "Done" --type completed
Two modes disagreeing about which state an issue landed in is worse than either refusing, and (name, type) is the pair that is unique — so a caller can retry deterministically without a human. An unknown name gets the whole board back, in board order, which is what makes the refusal actionable.
A state the issue is already in is reported, not rewritten:
$ lcc issue state PE-283 "Backlog" --json
{ "issue": {…}, "changed": false,
"from": { "id": "37e2dbec…", "name": "Backlog", "type": "backlog" },
"to": { "id": "37e2dbec…", "name": "Backlog", "type": "backlog" } }Linear's GitHub integration moves an issue on a branch push, so arriving to find it already there is the normal case. Writing anyway would bump updatedAt and put a state change in the activity feed that nobody made. to is read out of the mutation's own response rather than echoed from the request, so the report says what Linear ended up with.
It does not validate the transition against a Backlog → Todo → … order: that is workspace policy, lcc is a generic Linear client, and a tool that refuses a legitimate In Review → In Progress because a table said so is a tool people work around. from.type and to.type are reported so a caller can judge a reversal itself.
Codes it adds: state_unknown, state_ambiguous.
-m <text> gives the body outright, -f <file> reads it off disk — one or the other, never both. A markdown plan is what -f is for, so the body travels as a GraphQL string variable and survives quotes, backslashes, newlines and emoji intact.
$ lcc issue comment PE-250 -f plan.md --json
{
"issue": { "id": "…", "identifier": "PE-250", "url": "…" },
"comment": { "id": "…", "url": "…#comment-e971d540", "created_at": "2026-08-03T22:35:23.620Z" },
"body_bytes": 236
}It is not idempotent, and cannot be: calling it twice posts two comments. lcc has no way to tell a retry from a second thought, and deduping on the body would silently swallow a deliberate repeat.
An empty body is refused rather than posted, and a -f that points at a directory says so instead of reporting the file as missing.
A release is a Linear Project named vX.Y.Z, and --assign puts an issue in one. The version is said outright — there is no inference here and nothing is resolved: a command that writes to Linear names what it is writing. 2.6.0 and v2.6.0 are the same release, and the name is normalised to the way the board spells it.
$ lcc issue project PE-283 --assign v2.6.0
✓ PE-283 → v2.6.0Two refusals, both of which name the flag that means it:
✗ PE-283 is already in v2.6.0. Moving it to v2.5.2 is a release cut — say so:
lcc issue project PE-283 --assign v2.5.2 --force
✗ No project called v9.9.9 on PE. Create it:
lcc issue project PE-283 --assign v9.9.9 --create
--create is the consent, moved out of a prompt so a non-interactive caller can give it — a project created by mistake in Linear cannot be undone the way a worktree can. --force is the same idea for a reassignment: moving between releases is a deliberate cut, not something a command that was only meant to file an issue should do on its own.
An issue already in the right project is reported, not rewritten. changed and created are separate keys, because filing an issue into an existing release and bringing a new release board into existence are different events.
A project that already shipped is still a legitimate --assign target: dropping completed releases governs what a resolver may propose, not what a human may name.
The created project gets a name and a one-line description. The :rocket: icon and the project lead are left to the Linear UI — on a mutation that only fires when a project is missing, they widen the request in exchange for decoration.
Codes it adds: bad_version, project_not_found, project_conflict.
The other half: which release an issue targets, worked out rather than named. Read-only, and it always exits 0 — an unresolved case is an answer, not a failure.
Seven rules, in order. The first that can fire, does:
| # | When | Answer |
|---|---|---|
| 1 | a version passed outright | that one, with any project it displaces reported as a conflict |
| 2 | the issue is already in a project | that one — never silently reassigned |
| 3 | the current branch is release/X.Y.Z |
vX.Y.Z |
| 4 | the branch was cut from one | the pull request's base, else the candidate base HEAD is fewest commits ahead of |
| 5 | on trunk | the lowest open release project the board still accumulates for |
| 6 | nothing open left | the next minor above what shipped — proposed, never taken |
| 7 | nobody can tell | the question, with the candidates |
$ lcc issue project PE-283 --resolve --json
{ "status": "resolved", "rule": 4, "rule_name": "nearest_base", "version": "v2.7.0",
"evidence": { "kind": "distance", "text": "release/2.7.0" },
"project": null, "confirm_before_create": false,
"command": "lcc issue project PE-283 --assign v2.7.0 --create",
"git": { "branch": "feature/pe-283-x", "default_branch": "main", "pr_base": null,
"release_branches": [ { "branch": "release/2.7.0", "version": "v2.7.0", "ahead": 1 } ] },
"notes": [] }Rules 6 and 7 cannot be settled without a human, and that is exactly where moving this into a binary pays: the refusal carries the computation. Every semver sort, every "drop the versions already stabilising on a branch" filter, every "bump the minor, not the patch" has already run, so the caller's question is a formatting job:
{ "status": "needs_confirmation", "rule": 6, "version": "v2.6.0",
"baseline": { "version": "v2.5.2", "source": "tag", "evidence": "git tag" },
"confirm_before_create": true,
"question": "Latest released: v2.5.2. No open release project. Create v2.6.0 and assign PE-283? [y / other version / skip]",
"command": "lcc issue project PE-283 --assign v2.6.0 --create" }confirm_before_create is the one bit that matters for a writer: false for the rules that read a version off a fact that exists, true for the single rule that infers one.
Some details that are easy to get wrong, and are decided here rather than left to the caller:
- Trunk wins every tie in rule 4's contest. Falling through to the project board puts the decision where there is more evidence than a commit count, and it never files work into a release that is already stabilising. Two release branches tying is
ambiguous_base— the expensive thing to guess at. - A count that could not be taken loses. An unresolvable ref comes back
null, not0, so a missing branch cannot win a contest for the smallest distance. - Release branches are read off
originonly. A stale localrelease/2.4.1left behind by a shipped release must not be mistaken for one still being stabilised. - Versions are compared as numbers.
v2.10.0is abovev2.9.0, which a lexical sort gets backwards — and rule 6 would then propose a release that already exists. - An open project at or below what already shipped is dropped, with a note. A
v2.4.1forgotten in the backlog long after it went out has no branch left to veto it and would otherwise win rule 5 outright. v2.6.0-rc1is notv2.6.0. A release candidate is invisible to the resolver rather than standing in for the release.git: nullmeans git was never consulted — rule 2 answers without it. An empty object would claim it was asked and came back bare, which is a different fact.
--fetch refreshes the view of origin first. It is off by default because this runs on a hot path, and a stale view degrades to a conservative answer rather than a wrong one.
gh is optional throughout: without it rule 4 falls back to the commit-distance contest, which needs no network, no auth and no remote — the absence costs one note.
Failures take the same shape as lcc start --json — JSON on stdout, the human line on stderr, exit 1. Codes shared by every subcommand: usage, not_authenticated, keychain_unreadable, auth_failed, bad_identifier, issue_not_found, linear_failed. comment adds body_not_found, body_unreadable, body_empty, body_too_large.
Bare lcc open is the dashboard above; lcc open claude names that target explicitly and lcc open xcode picks the other one, case-insensitively.
--json prints the sessions as a one-shot instead of drawing the dashboard, and --stop-all (with --force to kill rather than ask) ends every background session. Both are non-interactive, so both work from a script or a tool call, where the dashboard cannot. Neither means anything to lcc open xcode, which rejects them rather than ignoring them.
Uses the same worktree picker, then launches Xcode instead of Claude. It looks for the shallowest .xcworkspace, .xcodeproj, or Package.swift in the worktree (workspace > project > package when several sit at the same depth) and opens it with open -a Xcode.
lcc remove opens one checkbox list of every non-main worktree. Select any number with Space and press Enter; lcc shows one combined confirmation, then removes the lot in one run. Nothing starts checked, so pressing Enter on an untouched list is a no-op. -y skips the combined confirmation but still lets you choose the worktrees. A failure or an unsaved Xcode document keeps that worktree without stopping the rest of the selection.
Every worktree is inspected before the list is drawn, not after you have picked, because which ones you want gone is the question the list is there to answer:
$ lcc remove
? Select worktrees to remove (space toggles, enter confirms):
BRANCH MERGE STATUS AGE FREES SPENT PATH
❯ ◯ feature/pe-101-shipped merged #412 clean 3d 1.1 GB 53M ~/…/worktrees/pe-101-shipped lcc
◯ feature/pe-256-app-hangs 3 unmerged 2 dirty 2h 839 MB 18M ~/…/worktrees/pe-256-app-hangs lcc — open in Xcode
◯ 9f2c11ab (detached) — clean — — — ~/…/spike prunable
0/3 selected · space toggles · enter confirms · esc cancel
- MERGE — the verdict below:
merged,merged #412,remote gone, or the number of commits that only exist here.--keep-branchleaves the branch alone, so the column is—. - STATUS — uncommitted changes in the worktree, in
lcc list's words:clean,2 dirty, ormissingwhen the directory is no longer there to ask. - AGE — how long ago the branch was last committed to.
- FREES — disk the removal hands back: Xcode build data, plus the session transcripts when
--sessionssays they go too. - SPENT — what Claude Code has spent in that worktree, same number
lcc statsreports.
The path is followed by the same flags lcc list prints (locked, prunable, lcc) and by — open in Xcode / — unsaved in Xcode. That last one is the reason the inspection happens up front: a worktree with unsaved editor work is held back after you tick it, and it is better to know before.
The confirmation that follows spells the same facts out per worktree, one line each, and adds changes 2 uncommitted (lost if the removal is forced) for anything the STATUS column called dirty — git refuses to remove such a worktree, and the answer to that refusal is a prompt offering --force.
lcc remove deletes each selected branch along with its worktree — but only when the commits survive somewhere else. Three things count as safe:
- The branch is an ancestor of the default branch (an ordinary merge).
- The branch was pushed and its upstream is now
[gone]— what a squash-merged PR looks like locally, where ancestry can never prove the commits survived. - GitHub says the branch's pull request is merged.
That third one is there because the first two are read out of local refs, and local refs cannot describe the situation you are actually in most often: you squash-merged the PR a minute ago and the remote branch is still there. Ancestry fails — the SHAs changed and always will have. [gone] has nothing to say either, since nothing deleted the remote branch. Both signals are trying to infer a state GitHub will simply tell you, so lcc asks it, and only about the branches nothing local could vouch for.
lcc fetches and prunes first, for the same reason: origin/master does not grow and an upstream cannot go [gone] until something goes and looks. A removal decided from whatever the last git pull left behind is a decision made on stale refs. --local skips both the fetch and the question — every branch is then judged exactly as it was before, which costs accuracy rather than safety: a stale ref only makes a branch look less safe than it is, and lcc keeps what it cannot vouch for. A failed fetch or an unreachable gh says so and carries on the same way.
Anything else is kept, and lcc reports how many unmerged commits it found and prints the git branch -D you would need. --keep-branch skips the check entirely.
The delete itself goes through git branch -d for a plain merge, so git gets to confirm it independently — but git's own check sees only HEAD and the branch's upstream, and neither of those can see origin/<default>. A branch merged there while local main is behind, with its remote branch deleted on merge (which lcc's own fetch --prune then reflects), is in none of them: -d refuses a merge lcc has already proved. That refusal is answered with -D and a line saying so, rather than handed back as a failure. A delete that fails for any other reason now quotes what git said.
lcc remove --merged runs that check across the whole repo and offers everything that passes in one checkbox list — including branches that outlived their worktree, which nothing else ever cleans up:
$ lcc remove --merged
? Select what to remove (space toggles, enter confirms):
BRANCH MERGE STATUS AGE FREES SPENT PATH
❯ ◉ feature/pe-101-shipped merged clean 3d 2.4 GB 53M ~/…/worktrees/pe-101-shipped lcc
◉ feature/pe-102-squashed merged #412 clean 5d 1.1 GB 18M ~/…/worktrees/pe-102-squashed lcc
◉ feature/pe-103-squashed remote gone — 2w — — branch only — no worktree left
3/3 selected · space toggles · enter confirms · esc cancel
The columns are the ones above. MERGE says which of the three vouched for the row, so a branch deleted on GitHub's word names the pull request that gave it, and a branch that outlived its worktree has nothing to report under STATUS.
A branch checked out anywhere — including the main worktree — is never offered, and a worktree with uncommitted changes is reported and skipped rather than forced. A merged pull request whose branch has since been reopened is not a merge: an open PR shadows the old one, and the branch is left alone. Ticking a row with unsaved Xcode work holds it back rather than removing it. -y takes everything without asking, and skips the list along with it; --force, --keep-branch, --keep-derived-data and --keep-xcode mean the same as they do for a single removal.
A worktree that gets built in Xcode leaves a DerivedData folder behind, and removing the worktree does not remove it. lcc remove matches folders to the worktree by the WorkspacePath recorded in each info.plist, shows their size in the confirmation, and deletes them along with the worktree. --keep-derived-data skips that.
Folders without an info.plist — Xcode's own shared caches — are never touched, and deletion refuses any path that is not a direct child of the DerivedData root.
Set LCC_DERIVED_DATA to override the location; otherwise lcc honours Xcode's own IDECustomDerivedDataLocation when it is absolute.
A worktree open in Xcode does not stop git worktree remove, so the directory goes and the window stays — sitting on a path that no longer exists. lcc remove asks Xcode to close it first, and says so before it does:
Remove worktree feature/pe-101-shipped?
worktree /Users/me/Projects/App/.lcc/worktrees/pe-101-shipped
xcode App.xcworkspace (open — will be closed)
build data App-fmqzbihqxvpk… (2.4 GB)
branch feature/pe-101-shipped (merged — will be deleted)
Which Xcode gets asked matters more than it sounds. A beta installed beside the release build carries the same bundle id, so application id "com.apple.dt.Xcode" picks one of the two and the choice is not yours to make — on a machine running both, the wrong pick is the one holding the worktree. lcc finds the running Xcodes through ps, addresses each by its bundle path, and asks them one at a time.
Matching goes by Xcode's own answer rather than a path lcc assembled: Xcode says /tmp/… where git says /private/tmp/…, so both sides are resolved before the containment test, while the string handed to close is the one Xcode gave. A Swift package opened by its folder is reported as that folder, which is the worktree root rather than something inside it — that counts too.
Unsaved changes stop the removal. Xcode's scripting interface has no save — its documents answer close and nothing else — so lcc cannot put editor work on disk, and deleting the worktree would take it along. It lists what is unsaved and removes nothing; save it in Xcode, or pass --force and lose it knowingly. That is also why the close is saving no: a CLI must not be able to raise a save dialog nobody is looking at.
Everything here is best-effort in one direction only. Nothing running, automation not permitted (System Settings → Privacy & Security → Automation), an Xcode too busy to answer inside the timeout — none of those stops a removal, and the last two say so rather than passing for "nothing was open". --keep-xcode skips the lot: Xcode is never even asked.
~/.claude/projects grows a directory per working directory Claude Code was launched in. Worktrees make a lot of them — a few hundred MB of transcripts for worktrees deleted months ago is normal.
Claude Code names each directory after a flattened cwd, and that flattening is lossy: / and . both become -, so the original path cannot be recovered from the name. lcc reads the cwd field out of a transcript instead, which records the real path. A directory whose transcripts never name one is left out entirely — lcc cannot tell whether its worktree still exists, so it never offers to delete it.
Transcripts are treated as more valuable than build data, because they are: a DerivedData folder comes back on the next build and a transcript is what claude --resume replays. So lcc remove lists the matching folders in its confirmation and keeps them; --sessions is what actually deletes them.
Set LCC_CLAUDE_PROJECTS to override the location.
Claude transcripts record the API usage block on assistant messages.
It shows up wherever you touch a worktree, not only when you ask:
$ lcc open
› Launching Claude Code in ~/…/pe-47-backfill-receipts feature/pe-47-backfill-receipts
Spent here: 3.6M context · 18k output · 2 sessions · 36m ago · ~$2.54
lcc list carries it as a column, lcc start prints it when the worktree already exists, and both removal paths show it — the single confirmation as a line, --merged as a column next to the reclaimable bytes. A transcript's size on disk says nothing about the work it holds, and --sessions deletes it for good.
lcc stats is the detailed view, with --models for the breakdown that explains the money:
$ lcc stats --models
WORKTREE SESS MSGS CONTEXT OUTPUT ~USD ACTIVE LAST ORIGIN
feature/pe-47-backfill-receipts 2 6 3.6M 18k 2.54 12m 36m
opus-5 5 3.6M 17k 2.54
haiku-4-5 1 50 900 0.00
main 17 1198 257M 1.0M 180.14 9h35m now main
feature/pe-51-liquid-glass — — — — — — — lcc
TOTAL 19 1204 261M 1.0M 182.68 9h47m
ACTIVE is how long the work actually took, which is not how long the task has been open. Messages less than 15 minutes apart are one stretch of work and the gap between them counts — that gap is thinking, tool calls, and reading the answer. A longer gap is a break and contributes nothing. The two answers are not close: measured on this repo's own branch, 13.8 days elapsed and 9h35m of it was worked.
It undercounts, deliberately. The first message of a stretch is credited with nothing, because whatever went into asking for it happened before the transcript recorded anything, and a session of one message reads as 0m. A number meant to be weighed against a working day is more useful as a floor than as a flattering estimate. The threshold is idle_gap_seconds in src/usage.zig, and --json reports it alongside the counts so the numbers stay interpretable.
The gaps are measured across the worktree's whole message stream rather than per transcript, because a subagent runs alongside the conversation that spawned it. Summing each transcript's own stretches would bill the same wall clock once per agent, so a pipeline running five of them in parallel could report several times the time it actually took. Merged and sorted first, the stretches cannot add up to more than the span they happened in.
ORIGIN says where a worktree came from — main for the checkout itself, lcc for one lcc created under the configured prefix, blank for one made by hand somewhere else. The column is only drawn when there is something to put in it.
--json prints the same numbers with the cache split intact, for anything that wants to keep its own history.
CONTEXT is the number that matters. It counts fresh input plus cache writes and cache reads, and on a long session the cache reads dominate everything else by two orders of magnitude — the 3.6M above is 3.6M of re-read conversation against 103 tokens of genuinely new input. Output is shown separately because it is priced five times higher per token.
Subagents are counted. They do not write into the conversation that spawned them — each gets its own transcript under <session-id>/subagents/ — so the top level of a project directory holds only part of what a task cost. On a worktree driven through a pipeline that is usually the smaller part: 51M context in the parent against 95M across its subagents is a normal split. The whole directory tree is walked for .jsonl, and everything else Claude Code keeps down there (tool-results/, the per-subagent .json sidecars) carries no usage and is skipped. SESS still counts conversations, not subagents — a subagent is part of the sitting, not another one.
Two things keep the counts honest. Messages are counted once by message.id, because a resumed session copies history forward and compaction rewrites it, so the same API response appears in several lines and often several transcripts. And each line is parsed as JSON rather than scanned for "output_tokens" — a message whose own text quotes a usage block would otherwise inflate its own session, which is exactly what a transcript of a session about token counts does.
~USD is Anthropic list price, applied per model, with cache writes at 1.25× input (2× for the hour-long TTL) and cache reads at 0.1×. It is not what a Claude subscription bills — it is what the same tokens would have cost through the API. The table lives in one place, prices in src/usage.zig; a model that is not in it still has its tokens counted, and the total it is missing from is marked with a trailing +.
The attribution is per worktree, which means per task only as long as the task has its own worktree. Work done in the main checkout lands in one bucket no matter which branch was checked out at the time, because Claude Code keys the directory on the cwd and not on git state.
Counting means parsing every line of every transcript, and the pile only grows — Claude Code appends and never prunes. A repo worked in daily reaches hundreds of MB, and re-reading all of it to redraw a table costs about 0.6s.
Almost none of it changed since the last run, so almost none of it is read again. Each transcript is reduced to the assistant messages that carried usage — two orders of magnitude smaller — and that is kept in ~/.cache/lcc/usage.json, reused whenever the file's size and mtime both still match. Append-only means either one moving is enough to notice. In practice one session is live and everything else is frozen:
cold (cache deleted) 0.64s
warm 0.05s 1.7MB of cache for 1.3B tokens across 45 project directories
Nothing derived is stored. Cost is recomputed from the token counts on every read, so correcting prices takes effect at once instead of being frozen into a file nobody would think to delete. Deduplication is not stored either: a cache entry is deduplicated against its own transcript only, which keeps it a pure function of that file, and the cross-file pass stays with the scanner — the only thing that knows which transcripts a given question spans. A cached run and a cold run produce identical output, which is what the tests assert.
The cache lives under ~/.cache, not ~/.config/lcc like the rest of lcc's state, because it is regenerable and large enough to matter — config directories end up in dotfile repos. LCC_USAGE_CACHE overrides the location; deleting the file costs one slow run. lcc list --no-tokens skips the whole thing.
The backlog of both: every DerivedData folder and Claude project directory whose worktree no longer exists on disk, biggest first, in one checkbox list.
$ lcc clean
› Measuring 31 orphaned folders…
14 GB in 31 folders whose worktree no longer exists.
Session transcripts are what `claude --resume` replays — check before deleting.
? Select what to delete (space toggles, enter confirms):
❯ ◉ 2.4 GB build data LocationTracker-fmqzbi… ~/…/pe-224-history-empty-states
◉ 12 MB sessions -Users-…-pe-224-history ~/…/pe-224-history-empty-states
--build-data and --sessions narrow it to one category; -y takes everything without asking.
~/.config/lcc/config.json. lcc config opens it as a list — arrows to move,
Enter to toggle a switch, cycle a choice, or edit a value, q to leave. Every
change is written as it is made.
❯ Sessions outlive the terminal on
Start in plan mode on
Resume last session on open on
PR and Linear columns cached
Worktree path {repoRoot}/.lcc/worktrees/{branchLeaf}
Naming one setting skips the list entirely and never touches raw mode, which is the form a script, a slash command or a tool call can use:
lcc config # every setting, its value and what it does
lcc config watchByDefault # just the value
lcc config watchByDefault false # set it
lcc config listNetwork local # a choice, not a boolean
lcc config activeStates "Todo, In Progress, In Review" # lists are comma-separatedEvery boolean setting has a flag on both sides, so a stored default is never a
trap you cannot escape for one run: --watch / --no-watch, --tokens /
--no-tokens, --resume / --no-resume, --keep-branch /
--no-keep-branch, and so on. lcc list spells its three-way one as
--refresh, --cached and --local.
--yes and --force are deliberately not settings. A stored value that
pre-approves a destructive operation removes the one confirmation standing
between a mistyped command and a deleted worktree, and it does so invisibly,
months after anyone typed it.
| Key | Default | Meaning |
|---|---|---|
worktreeTemplate |
{repoRoot}/.lcc/worktrees/{branchLeaf} |
Where worktrees go. Placeholders: {repoRoot}, {repoParent}, {repoName}, {branch}, {branchLeaf} |
activeStates |
["Todo", "In Progress"] |
Which Linear states to offer, in this order |
startTaskCommand |
"" |
Passed to Claude Code as its initial prompt. Placeholders: {identifier}, {branch}, {url}, {plan} (the --plan path, empty without it — and required in the template before --plan is accepted) |
linkPatterns |
[".env", ".env.*", "CLAUDE.md", "CLAUDE.local.md", ".claude/settings.local.json"] |
Which files to symlink into each worktree |
linkExclude |
[".env.example", ".env.sample", ".env.template"] |
Which of those to skip |
watchByDefault |
true |
Run new sessions in the background so they outlive the terminal |
planMode |
true |
Open new sessions in plan mode. --plan <file> turns it off regardless |
resumeSessions |
true |
lcc open resumes the worktree's last session |
showTokens |
true |
The TOKENS column in lcc list. Off skips reading transcripts, which is that column's whole cost |
listNetwork |
cached |
The PR and Linear columns in lcc list: refresh, cached or local |
allIssues |
false |
Offer every assigned issue in the picker, not just activeStates |
keepBranch, keepDerivedData, keepXcode |
false |
What lcc remove leaves behind |
mcpCarry |
absent — all of them | Which local-scope MCP servers to carry into Claude; setup accepts a comma-separated list, all, or none |
clientId |
built-in | Linear OAuth application. Override with LCC_CLIENT_ID or lcc auth setup --client-id <id> |
{repoRoot} and {repoParent} always resolve against the main worktree, so running lcc from inside a worktree puts the next one beside its siblings instead of nesting it one level deeper.
A worktree nested inside the repo is added to .git/info/exclude, so it never shows up as untracked.
A pattern is a path relative to the repo root whose every segment may glob, so it reaches things that do not sit at the top level:
| Pattern | Matches |
|---|---|
.env |
a root-level .env, and nothing in a subdirectory |
.env.* |
.env.local, .env.production |
.claude/settings.local.json |
exactly that file |
*/credentials.plist |
Config/credentials.plist, but not a/b/credentials.plist |
* and ? never cross a /, so a pattern only ever opens the directories it names — nothing walks the repository. Missing parent directories are created in the worktree, an existing entry of any kind is left alone (the worktree's own file wins), and a pattern that is absolute or contains .. is skipped rather than clamped.
.claude/settings.local.json is in the defaults because it holds the permission allowlist: without it, every new worktree re-asks for approvals already granted in the main checkout. Linking it means one allowlist shared by every worktree.
CLAUDE.md and CLAUDE.local.md are there for the repos that keep theirs out of git — a worktree of one hands Claude Code no project instructions at all, which is the same session in a repo it knows nothing about. Claude Code walks up the parent directories looking for both, so a worktree nested inside the repo (the default template) finds them without any help; the link is what covers a template that puts worktrees beside the repo. A repo that commits its CLAUDE.md is unaffected either way: the file is already in the worktree, and linking never replaces one that is there — lcc reports it as skipped and moves on.
envPatterns and envExclude are the pre-nested-path names for these two keys. An existing config keeps working; linkPatterns/linkExclude win when both are present, and lcc setup rewrites the old key to the new one.
The Linear token lives in the login Keychain (generic password, service lcc, account linear-token), and the Keychain decides who may read it from the program's code signature — not its path. Zig's linker only ad-hoc signs, and an ad-hoc signature carries no identity beyond the hash of the binary itself, so every rebuild arrives as a changed program: macOS then asks for the login password and the process blocks, with no output and no child process, until the dialog is answered. One "Always Allow" only ever covers the exact build it was granted for.
So the build signs the installed binary itself, with nothing to configure:
$ zig build -Doptimize=ReleaseFast
info: signing lcc with "Apple Development: …"
Which certificate it is does not matter — only that it stays the same between builds — so the build takes whatever the machine already has: a certificate named lcc-dev if you made one on purpose, otherwise an Apple Development certificate, which a machine that builds apps already has. The chosen name is printed rather than picked silently. Finding nothing is not an error either; the build then leaves the ad-hoc signature alone and the prompts come back.
The designated requirement becomes identifier lcc and … certificate leaf[subject.CN] = "<identity>", which does not mention the binary's contents at all — two different builds produce the same requirement, so the Keychain keeps recognising lcc and one "Always Allow" holds.
| Override | |
|---|---|
-Dsign="<identity>" |
sign with that certificate |
LCC_CODESIGN_IDENTITY="<identity>" |
the same, from the environment |
-Dsign=none |
opt out, keep the ad-hoc signature |
A self-signed certificate works and never expires on someone else's schedule (Keychain Access → Certificate Assistant → Create a Certificate, type Code Signing); name it lcc-dev and it is preferred automatically. An Apple Development certificate is equally fine, with the caveat that it expires — the requirement changes with the certificate, so the first run after a renewal asks once more.
That one prompt is answerable with Deny, or with Escape, or it can be missed entirely behind a full-screen terminal — and a refused read is not a missing login. It is reported as one:
✗ The Linear token is in the Keychain, but reading it failed: authorization failed
(keychain prompt denied?) (OSStatus -25293). Answer `Always Allow` if macOS asks
again; `lcc auth` re-stores it if it stays refused.
lcc auth --status says the same thing rather than "Not authenticated", and --json calls it keychain_unreadable rather than not_authenticated. Re-running lcc auth does fix it, by storing the item afresh under the current signature — but it is a full browser round trip for a dialog that only had to be answered, which is why the two are told apart.
Symlinks cannot solve the same problem for MCP servers, because they are not in the repository. claude mcp add without -s user stores a server under projects["<absolute cwd>"].mcpServers in ~/.claude.json — the key is the directory. A worktree is a different directory, so it starts with none of them: the checkout where linear-server was added is the only place it exists.
lcc start and lcc open read the repo's local-scope servers and pass them to Claude Code as --mcp-config <file>, generated per repo under ~/.config/lcc/mcp/.
Two things this cannot do. A server that was never authenticated stays unauthenticated — /mcp in a session is the only thing that fixes that, and the worktree is not why it is dark. And a session that is already running cannot be handed servers retroactively, which is why lcc start --json reports what a launch would have carried instead of carrying it.
mcpCarry narrows the set to the names it lists, matched case-insensitively, keeping the file's order. lcc setup accepts a comma-separated list, all to remove the key and carry every server, or none to write an empty list and carry none. Carrying everything is the default because it is the answer that never surprises anyone, but it is not free: a server the work never calls still spends every agent in the session its name and its instructions, on every turn — and a pipeline that fans out to twenty subagents pays that twenty times over. Measured across eight pipeline runs in this repo's worktrees, linear-server and xcode accounted for every local-scope call that was made; clickup, notion and sentry were carried into all eight and never touched once. A name the repo does not have is ignored, and a list that matches nothing launches without MCP rather than with an empty config.
build.zig build, run and test steps
src/main.zig argv parsing and dispatch
src/commands/ one file per command
src/linear.zig GraphQL over std.http.Client
src/oauth.zig PKCE, callback listener, token refresh
src/keychain.zig Security.framework SecItem*
src/github.zig pull-request state via the `gh` CLI
src/prompt.zig raw-mode search, confirm, checkbox, input
src/git.zig worktrees, branches, branch disposition, drift
src/mcp.zig local-scope MCP servers, carried into a worktree
src/repos.zig which repository an issue belongs to, remembered
src/fold.zig case folding for ASCII, Latin-1, Cyrillic
src/link.zig pattern matching and symlinking into a worktree
src/disk.zig path containment and batched `du`
src/derived_data.zig DerivedData discovery and reclamation
src/claude_projects.zig ~/.claude/projects discovery and reclamation
src/usage.zig token usage out of the transcripts, per worktree and model
src/usage_cache.zig transcripts distilled, so a second run does not read them
src/keychain.zig deliberately @cImports five narrow CoreFoundation/Security headers rather than the umbrella ones: on the macOS 26.5 SDK, CoreFoundation/CoreFoundation.h drags in mach headers whose bitfield structs translate-c turns opaque (tripping their own _Static_asserts), and Security/Security.h drags in xpc.h, which puts nullability attributes on the non-pointer uuid_t.
macOS only. The Keychain layer talks to Security.framework directly, so Linux (libsecret) and Windows (Credential Manager) would each need their own backend.
Pinned to Zig 0.16. That release moved std.fs.File to std.Io.File, threads an Io parameter through every I/O call, and changed the main signature — expect edits on the next Zig release.
Case folding covers ASCII, Latin-1 Supplement and Cyrillic. Latin Extended-A is left out on purpose: its case pairs are irregular and some foldings are multi-codepoint, so covering it half-correctly would be worse than not covering it.
Everything up to 0.1.0 was TypeScript on Node, built on @linear/sdk, @inquirer/prompts, @napi-rs/keyring, commander, execa, open and picocolors. It was replaced wholesale by this implementation after a command-by-command parity check, then deleted. git log still has it.
MIT