Linear workflow for Claude Code: issue-next, issue-start, issue-review, issue-ship and issue-plan-cycle skills backed by a deterministic CLI, a linear-manager…

Work Linear issues from Claude Code, from picking the next issue in the cycle to the merged PR. Linear stays up to date along the way, and the current issue is always shown above the prompt.
The plugin has four parts:
issue-next → issue-start → (work) → issue-review → issue-ship, plus issue-plan-cycle to fill the cycle.bin/linear-workflow.mjs) runs the Linear and GitHub steps that need no judgment: fetching and ranking issues, status changes, cycle changes, PR checks. It's faster than an LLM and gives the same answer every time. Claude keeps the parts that need judgment: reading the issue, writing the code, the commit message, PR summary and comments.linear-manager, creates and edits issues, and stands in for the CLI when it can't reach Linear.lib/).Claude Code namespaces plugin skills and agents, so the full names are /linear-workflow:issue-start and linear-workflow:linear-manager. Type /issue and pick from the menu.
/plugin marketplace add Sarimarcus/claude-code-plugins
/plugin install linear-workflow@sarimarcus
export LINEAR_API_KEY=lin_api_… in your shell profile, or a LINEAR_API_KEY= line in your repo's git-ignored .env. The CLI and the mod both use it.linear-manager agent (the Linear connector on claude.ai, or claude mcp add --transport http linear https://mcp.linear.app/mcp)./linear-workflow:issue-next./linear-workflow:issue-nextPicks what to work on next. Read-only. Runs on Haiku: it only presents the CLI's ranked list.
/linear-workflow:issue-next
Claude will:
Y), another (ABC-140), or none/linear-workflow:issue-start <ABC-123>Starts work on an issue.
/linear-workflow:issue-start ENG-123
Claude will:
/linear-workflow:issue-review [ABC-123]Puts the work up for review. Never merges or deploys.
/linear-workflow:issue-review
Claude will:
.claude/linear.json, or the obvious ones such as npm test) (ENG-123) in the messageCloses ENG-123/linear-workflow:issue-ship [ABC-123]Merges the reviewed PR and closes the issue.
/linear-workflow:issue-ship
Claude will:
gh pr merge, or git merge --no-ff locally so your pre-push hooks run)/linear-workflow:issue-plan-cycleFills the active cycle from the backlog. Runs on Haiku, like issue-next. The other skills use your session's model, because they write code, judge acceptance criteria or merge.
Claude will:
all, none, or a list of ids), then set the cycle on those issues and change nothing elseWithout an id, issue-review and issue-ship use the session's issue, then the branch name, then your issues in Linear. If the id was guessed, they show it first, and issue-ship asks you to confirm it before merging.
linear-manager agentUse it for what the CLI doesn't do: filing issues, splitting a plan into sub-issues, labels, relations, milestones, searches ("use linear-manager to file a bug for…"). The skills also fall back to it when the CLI can't reach Linear. It follows the same rules as the CLI:
state: "In Review", labels: ["Bug"], assignee: "me")The skills call it as node "${CLAUDE_PLUGIN_ROOT}/bin/linear-workflow.mjs" <command>. You can run it yourself from any repo. It prints compact JSON on stdout and one summary line on stderr, stating what it examined. Exit codes: 0 ok, 1 error or bad input, 2 refused (needs a human).
Built for a small context. Each skill step makes one call, and each call returns only the fields that step reads. Every tool call is a model turn that re-reads the conversation, so fewer, smaller calls are the main saving. The start view doesn't contain the acceptance criteria at all, so keeping them out of the implementer's view doesn't depend on Claude following an instruction.
| One call per skill step | Returns |
|---|---|
start <ABC-123> | The issue to work from (description without acceptance criteria, blockers, branch name, every comment verbatim) and the repo state (branch, base, branching setting, uncommitted files) |
review [ABC-123] | Which issue (argument, branch name, or your only started issue), its acceptance criteria, and the repo state: base branch, the issue the branch names, this branch's open PR, checks, uncommitted files |
ship [ABC-123] [--pr N] | Which issue, merge settings, and the PR verdict (as pr-check) |
| Single steps | What it does | |
|---|---|---|
config | Repo root, current branch and the issue its name points to (branchIssue), resolved base branch (baseBranch, else origin's default) and whether you're on it, this branch's open PR, team keys, whether a key was found, .claude/linear.json | |
| `resolve [ABC-123\ | 123]` | Which issue to act on: the argument, then the branch name, then your only started issue. Exit 2 with candidates when it can't tell |
| `issue <ABC-123> [--view start\ | review]` | The full issue (description, parsed acceptanceCriteria, blockers, sub-issues, links, branch name, every comment), or just one skill's view of it |
queue [--limit N] | The active cycle's Todo and In Progress issues (not In Review, not blocked), epics replaced by their next sub-issue, ranked, each with its rank and display-ready urgency (overdue 3d, ends in 5d…) | |
plan-cycle [--limit N] | Unscheduled, unblocked backlog issues for the active cycle, ranked | |
| `transition <ABC-123> <state> [--comment TEXT \ | --comment-file F]` | Idempotent status change, comment, parent roll-up (into the parent's own team states) |
set-cycle <n> <ABC-123>… | Put issues in a cycle and change nothing else | |
pr-check <ABC-123> [--pr N] | Whether the issue's PR can merge: ready, wait (checks running) or stop, with reasons. Only PRs whose branch or title names the issue, or whose body closes it, count; --pr picks one of several | |
pr-merged <ABC-123> --pr N | Whether that PR really landed: exit 0 only when GitHub says merged and names the merge commit |
Global flags: --team KEY overrides the team key; --pretty indents the JSON; --out FILE writes the full JSON to a file and prints only its path. Uncommitted-file lists are capped at the first 20, with the total count.
Ranking is priority (Urgent first, no priority last), then milestone target date, then age. A parent rolls up only when no sibling is unfinished, and anything not done or canceled counts as unfinished (In Review included when the target is Done).
── Linear ─────────────────────────────────────────────────────────
◆ ENG-2919 In Progress · High
Core affiliate link builders
root branch · scope: web · parent ENG-2900 Details Hide
sessions ENG-2748 In Review (eng-2748) · ⚠ ENG-2912 In Progress (main)
/linear): description, sub-issues, links, latest comments, and Open in Linear./linear ENG-123, typing /linear-workflow:issue-start ENG-123, or Claude starting it from your words), or the issue in a branch name (alex/eng-2919-link-builders → ENG-2919) that this session switched to itself. A branch issue that another live session in the same checkout has claimed is left to that session, and yours shows No Linear issue found and the sessions row. A lone session opened on a feature branch still picks its issue up.sessions row): every Claude Code session on your machine that runs the plugin, with its issue and checkout (main or the worktree name).held) until that issue is Done or Canceled, or your own session switches branches.linear-manager agent, a Linear MCP write), and every pollMinutes otherwise. A toast says when its state changed./linear | What it does |
|---|---|
/linear | Open the details pane |
/linear ENG-123 | Pin an issue to this session. The pin lasts until this session itself switches to another issue's branch |
/linear clear | Drop the pin and follow the branch again |
/linear refresh | Re-fetch from Linear now |
/linear hide / show | Hide or show the band |
linear-manager agent.gh, logged in, for issue-review and issue-ship.git switch).Nothing is required beyond the API key. Everything else is inferred, and each layer below overrides the one before it:
.claude/linear.json, per repo, committed): states, branching, checks, merge method, issue template.CLAUDE.md, CONTRIBUTING.md, the PR template): the skills follow them for commit messages, PR bodies and anything else they describe.| Option | Default | Meaning |
|---|---|---|
linearApiKey | — | Linear API key, stored as a secret (secret options are not shown in /config). If unset: LINEAR_API_KEY in the environment, then in <repo>/.env |
teamKeys | (auto) | Team keys to find in branch names, for repos whose .claude/linear.json sets no teamKey. Else the keys of the teams you belong to. The mod passes this to the CLI too |
pinCommand | issue-start | Skill whose first argument pins an issue to the session. Empty disables it |
pollMinutes | 5 | How often the mod refreshes the issue |
showOtherSessions | true | Share this session's issue with your other sessions and list theirs |
registryFile | — | Sub-project registry for scope and drift (monorepos). Empty: off |
.claude/linear.json)Optional, all fields optional. A missing value is inferred, or the skill asks once. See linear.example.json.
{
"team": "Engineering",
"teamKey": "ENG",
"project": "Website",
"assignee": "me",
"baseBranch": "main",
"checks": ["npm run build", "npm test"],
"mergeMethod": "squash",
"deleteBranch": true,
"branching": "create",
"states": { "inProgress": "Doing", "inReview": "Ready for QA", "done": "Shipped" },
"issueTemplate": ".github/linear-issue.md"
}
| Field | Default | Meaning |
|---|---|---|
team, teamKey, project, assignee | inferred | Where the skills look and file issues. teamKey also lets you type bare numbers (123) |
baseBranch | origin's default branch | Base for new branches and PRs |
checks | inferred (npm test, make test…) | Commands issue-review runs before committing |
mergeMethod | merge | merge, squash, rebase, or local (git merge --no-ff + push, so your own pre-push hooks run) |
deleteBranch | false | Delete the branch after merging |
branching | create | issue-start creates the issue's branch (create), asks (ask), or stays on the current one (none) |
states | Linear's usual names | Your team's names for In Progress, In Review and Done, used by every transition and by the queue's review filter |
issueTemplate | 4-section template | A markdown file the agent uses for new issue descriptions |
Set the registryFile plugin setting to a JSON file at the repo root:
{ "projects": [ { "key": "web", "path": "apps/web" }, { "key": "api", "path": "services/api" } ] }
An issue labelled web (or whose parent is) is scoped to apps/web, so editing services/api/... raises a drift toast once per issue and sub-project. An issue with no matching label is scoped to everything.
CLAUDE.md take precedence over the skills' defaults.skills/<name>/SKILL.md into your project's .claude/skills/<name>/ and edit it. Yours runs as /<name>, and the plugin's stays available as /linear-workflow:<name>. Keep calling the CLI for the deterministic steps so the checks stay the same. If you rename issue-start, set the pinCommand plugin setting to your skill's name so the band follows it.node <plugin>/bin/linear-workflow.mjs queue./linear hide hides the band; showOtherSessions: false stops the session list; leaving registryFile empty keeps scope and drift checks off.issue-plan-cycle can't be started by Claude on its own. You have to type it. issue-review and issue-ship can also be invoked by Claude. When Claude does that without you asking to ship, issue-ship confirms the issue and PR with you before merging.issue-review or issue-ship yourself authorizes the push or merge for that issue only.git push --force, git reset --hard, git clean, git checkout -- ., git restore ., a bare git stash or git commit --amend: each can destroy work that isn't yours. Every eval checks this. The only push to the base branch is the merge itself, when issue-ship uses mergeMethod: local.issue-review refuses on a branch that names another issue, so work never lands under the wrong issue.issue-ship checks GitHub before writing to Linear, so a merge that failed is never reported as done.git worktrees. In a shared checkout the band warns you, but the shared tree is still shared.issue-start gives Claude those sections and leaves out the acceptance criteria.issue-start treats them as scope.checks in .claude/linear.json so issue-review runs exactly what your CI runs.api.linear.app with your key. They never write it to disk. When you set the key as the secret plugin option, the mod exports it as LINEAR_API_KEY to the session's shell so the CLI can use it.pr-check and the review and ship skills use the gh CLI. The agent uses your Linear MCP connection. What any of them return goes into the conversation like any tool output.~/.claude/linear-workflow/sessions/<session-id>.json (checkout path, issue id, title, state) every minute. These files stay on your machine and are deleted when the session ends, or after a day if it crashed. To turn this off, set showOtherSessions: false.No band. It only shows when the branch names an issue or one is pinned. Check teamKeys, or pin one with /linear ENG-123. If the band says Linear API key not found, set the key (see Configuration).
The details pane doesn't open by itself. A pane the session opens on its own needs a wide terminal (about 144 columns). /linear opens it at any width.
A skill says LINEAR_API_KEY not set. Set the key (Quick start, step 1). Run node "<plugin>/bin/linear-workflow.mjs" config to see what the CLI finds.
The agent says Linear tools are missing. Connect the Linear MCP server (Quick start, step 2), then restart the session.
gh errors in issue-review or issue-ship. Run gh auth status. If the repository doesn't allow your mergeMethod, issue-ship asks which one to use.
The band shows held. Another session moved the shared branch, and yours kept its issue (until it is Done or Canceled). /linear clear follows the branch again.
gh. GitLab and Bitbucket aren't supported./linear clear after those.claude --plugin-dir . # from this folder: load from source, hot-reloads on save
claude plugin validate .
claude plugin test . # unit tests (lib/ and the mod)
Dev tooling lives at the repo root, outside the plugin, so installing the plugin pulls in none of it:
npm install # at the repo root: TypeScript and Node types
npm run typecheck # mod + lib, then CLI + lib
npm test # same as claude plugin test
npm run eval # skill evals, then writes evals/RESULTS.md
Evals (evals/) check the skills' guardrails: issue-ship stops on a missing, draft or failing PR asks when several PRs or running checks are involved, and doesn't merge on its own when Claude reaches it without being asked to ship; issue-review refuses on the base branch or another issue's branch, flags unmet acceptance criteria and never stages unrelated files; no skill ever runs a destructive git command; issue-start never stashes or discards a dirty tree, quotes actionable comments verbatim and hides acceptance criteria; issue-next keeps the CLI's order and urgency. Each case's scaffold.sh builds a git repo and canned CLI answers in .lw/; with EVAL_LINEAR_WORKFLOW_FIXTURES set, the CLI answers from there (and logs each call to calls.log) instead of calling Linear or GitHub. Granting Bash needs Claude Code's sandbox: on Linux, apt install bubblewrap socat. Each case runs 3 times, so a full run costs real tokens (about $3). The latest results are in evals/RESULTS.md: commit it with each release.
The engine generates .claude-plugin/types/ when the mod loads, and the type-check needs it. Layout: lib/ is shared code with no Node or browser APIs; hooks/ is the mod; bin/ is the CLI.
Olivier Depiesse (@Sarimarcus)
See CHANGELOG.md for the current version and its history. The version lives only in plugin.json (and the marketplace entry): don't write it here.
MIT
hooks/register.tsx 609 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import { createClient, LinearError } from '../lib/linear.ts'
5import {
6 checkoutLabel,
7 conflicts,
8 decideSource,
9 driftSite,
10 findRoot,
11 idFromText,
12 isCheckout,
13 touchesLinear,
14 liveOthers,
15 pickSource,
16 resolveApiKey,
17 scopeSites,
18 stateColor,
19 toPaneIssue,
20} from '../lib/logic.ts'
21import { issueDetail, myTeamKeys, normalizeId, resolveTeamKeys } from '../lib/workflow.ts'
22import type { Issue, IssueSource, SessionEntry } from '../types'
23
24const PANE = 'linear-workflow'
25const HEARTBEAT_MS = 60 * 1000
26const GC_MS = 24 * 60 * 60 * 1000
27
28const issueAtom = atom({ plugin: 'linear-workflow', key: 'issue' } as const, null)
29const sourceAtom = atom({ plugin: 'linear-workflow', key: 'source' } as const, null)
30const errorAtom = atom({ plugin: 'linear-workflow', key: 'error' } as const, null)
31const hiddenAtom = atom({ plugin: 'linear-workflow', key: 'isBandHidden' } as const, false)
32const pinnedAtom = atom({ plugin: 'linear-workflow', key: 'pinned' } as const, null)
33const pinnedByAtom = atom({ plugin: 'linear-workflow', key: 'pinnedBy' } as const, null)
34const warnedAtom = atom({ plugin: 'linear-workflow', key: 'warned' } as const, [])
35const othersAtom = atom({ plugin: 'linear-workflow', key: 'others' } as const, [])
36const conflictsWarnedAtom = atom({ plugin: 'linear-workflow', key: 'conflictsWarned' } as const, [])
37const claimedBranchAtom = atom({ plugin: 'linear-workflow', key: 'claimedBranch' } as const, null)
38
39type Repo = { root: string; sites: { key: string; path: string }[] }
40
41let repo: Repo | null = null
42let apiKey: string | undefined
43let teamKeys: string[] = []
44let config = {
45 linearApiKey: '',
46 teamKeys: '',
47 registryFile: '',
48 pinCommand: 'issue-start',
49 pollMinutes: 5,
50 showOtherSessions: true,
51}
52let lastBranchId: string | undefined
53let ownCheckout = false
54let linearTouched = false
55let registryDir: string | null = null
56let inflight: Promise<void> = Promise.resolve()
57
58async function git($: EngineInterface, cwd: string, args: string[]): Promise<string> {
59 try {
60 const ran = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 5000 })
61 return ran.exitCode === 0 ? ran.stdout.trim() : ''
62 } catch {
63 return ''
64 }
65}
66
67async function loadRepo($: EngineInterface): Promise<Repo | null> {
68 const top = await findRoot(args => git($, '.', args))
69 if (!top) return null
70
71 let sites: Repo['sites'] = []
72 try {
73 if (!config.registryFile) throw new Error('no registry')
74 const registry = JSON.parse(await $.fs.read(`${top}/${config.registryFile}`)) as {
75 projects?: { key: string; path: string; active?: boolean }[]
76 sites?: { key: string; path: string; active?: boolean }[]
77 }
78 sites = (registry.projects ?? registry.sites ?? [])
79 .filter(s => s.active !== false)
80 .map(s => ({ key: s.key, path: `${top}/${s.path}` }))
81 } catch {
82 sites = []
83 }
84 return { root: top, sites }
85}
86
87async function loadKey($: EngineInterface): Promise<string | undefined> {
88 if (config.linearApiKey) {
89 // Hand the secret option to the workflow CLI, which runs in Bash.
90 await $.env.set('LINEAR_API_KEY', config.linearApiKey)
91 }
92 const envFileText = repo ? await $.fs.read(`${repo.root}/.env`).catch(() => undefined) : undefined
93 return resolveApiKey({ option: config.linearApiKey, env: await $.env.get('LINEAR_API_KEY'), envFileText })
94}
95
96async function resolveSource($: EngineInterface): Promise<IssueSource | null> {
97 if (!repo) return null
98 const rootBranch = await git($, repo.root, ['rev-parse', '--abbrev-ref', 'HEAD'])
99 const branches = await Promise.all(repo.sites.map(site => git($, site.path, ['rev-parse', '--abbrev-ref', 'HEAD'])))
100 const siteBranches = Object.fromEntries(repo.sites.map((site, n) => [site.key, branches[n] ?? '']))
101 const fromBranch = pickSource(rootBranch, siteBranches, teamKeys)
102 const pinned = await read($, pinnedAtom)
103 const pinnedBy = await read($, pinnedByAtom)
104 const previous = await read($, sourceAtom)
105 const claimedBranch = await read($, claimedBranchAtom)
106 const branchMoved = lastBranchId !== undefined && (fromBranch?.id ?? '') !== lastBranchId
107 const own = ownCheckout
108 ownCheckout = false
109 lastBranchId = fromBranch?.id ?? ''
110 const others = await read($, othersAtom)
111 const root = repo.root
112 const d = decideSource({
113 fromBranch,
114 pinned,
115 pinnedBy,
116 previous,
117 branchMoved,
118 own,
119 claimedBranch,
120 claimedElsewhere: id => others.some(o => o.checkout === root && o.issue === id && o.claimed !== false),
121 })
122 const by = d.pinnedBy as 'command' | 'manual' | 'held' | null
123 if (d.pinned !== pinned || d.pinnedBy !== pinnedBy) await setPin($, d.pinned, by)
124 if (d.claimedBranch !== claimedBranch) await update($, claimedBranchAtom, () => d.claimedBranch)
125 if (d.use === 'pin' && d.pinned) return pinnedSource(d.pinned, by)
126 return d.use === 'branch' ? fromBranch : null
127}
128
129function pinnedSource(id: string, by: 'command' | 'manual' | 'held' | null): IssueSource {
130 const detail =
131 by === 'held' ? 'held: another session moved the branch' : by === 'command' ? `pinned by /${config.pinCommand}` : 'pinned with /linear'
132 return { id, from: 'pinned', detail }
133}
134
135async function fetchIssue($: EngineInterface, id: string): Promise<Issue | string> {
136 if (!apiKey) return 'Linear API key not found (plugin option, LINEAR_API_KEY env, or <repo>/.env)'
137 try {
138 return toPaneIssue(await issueDetail(linear($), id, { latestComments: 3 }))
139 } catch (err) {
140 if (err instanceof LinearError) return err.message.startsWith(id) ? err.message : `${id}: ${err.message}`
141 return `${id}: ${err instanceof Error ? err.message : 'fetch failed'}`
142 }
143}
144
145async function setPin($: EngineInterface, id: string | null, by: 'command' | 'manual' | 'held' | null): Promise<void> {
146 await update($, pinnedAtom, () => id)
147 await update($, pinnedByAtom, () => by)
148}
149
150/** Pin the issue a pin command (`/issue-start ABC-123`) names, however it was invoked: typed or by the Skill tool. */
151async function pinFromSkill($: EngineInterface, name: string, args: string): Promise<void> {
152 if (!config.pinCommand || name.split(':').pop() !== config.pinCommand) return
153 const id = normalizeId(args, teamKeys.length === 1 ? teamKeys[0] : undefined) ?? idFromText(args)
154 if (!id) return
155 await setPin($, id, 'command')
156 await refresh($, true)
157}
158
159function refresh($: EngineInterface, force: boolean): Promise<void> {
160 inflight = inflight.then(() => refreshNow($, force)).then(() => syncSessions($)).catch(() => undefined)
161 return inflight
162}
163
164function heartbeat($: EngineInterface): Promise<void> {
165 inflight = inflight.then(() => syncSessions($)).catch(() => undefined)
166 return inflight
167}
168
169async function syncSessions($: EngineInterface): Promise<void> {
170 if (!repo || !registryDir || !config.showOtherSessions) return
171 const selfId = await $.session.id()
172 const now = await $.clock.now()
173 const source = await read($, sourceAtom)
174 const issue = await read($, issueAtom)
175 const fresh = issue && issue.identifier === source?.id ? issue : null
176 const self: SessionEntry = {
177 sessionId: selfId,
178 checkout: repo.root,
179 label: checkoutLabel(repo.root),
180 issue: source?.id ?? null,
181 title: fresh?.title ?? null,
182 state: fresh?.state ?? null,
183 stateType: fresh?.stateType ?? null,
184 claimed: Boolean(await read($, pinnedAtom)) || (source !== null && (await read($, claimedBranchAtom)) === source.id),
185 updatedAt: now,
186 }
187 await $.fs.write(`${registryDir}/${selfId}.json`, JSON.stringify(self))
188
189 const entries: SessionEntry[] = []
190 for (const f of await $.fs.list(registryDir).catch(() => [])) {
191 if (!f.name.endsWith('.json')) continue
192 const path = `${registryDir}/${f.name}`
193 if (now - f.mtimeMs > GC_MS) {
194 await $.process.run(['rm', '-f', path], { timeoutMs: 2000 }).catch(() => undefined)
195 continue
196 }
197 try {
198 entries.push(JSON.parse(await $.fs.read(path)) as SessionEntry)
199 } catch {
200 // a file mid-write; the next heartbeat reads it
201 }
202 }
203
204 const others = liveOthers(entries, selfId, now)
205 const shape = (list: SessionEntry[]) => JSON.stringify(list.map(o => ({ ...o, updatedAt: 0 })))
206 if (shape(others) !== shape(await read($, othersAtom))) await update($, othersAtom, () => others)
207
208 const warned = await read($, conflictsWarnedAtom)
209 const unwarned = conflicts(self, others).filter(c => !warned.includes(c.key))
210 if (unwarned.length) {
211 await update($, conflictsWarnedAtom, list => [...list, ...unwarned.map(c => c.key)])
212 for (const c of unwarned) $.ui.toast(`⚠ ${c.text}`)
213 }
214}
215
216async function refreshNow($: EngineInterface, force: boolean): Promise<void> {
217 if (teamKeys.length === 0 && apiKey) teamKeys = await loadTeamKeys($)
218 const source = await resolveSource($)
219 const previous = await read($, sourceAtom)
220 if (JSON.stringify(previous) !== JSON.stringify(source)) await update($, sourceAtom, () => source)
221
222 if (!source) {
223 await update($, issueAtom, () => null)
224 await update($, errorAtom, () => null)
225 return
226 }
227
228 const current = await read($, issueAtom)
229 if (!force && current?.identifier === source.id) return
230
231 const got = await fetchIssue($, source.id)
232 if ((await read($, sourceAtom))?.id !== source.id) return
233 if (typeof got === 'string') {
234 await update($, errorAtom, () => got)
235 return
236 }
237 if (current?.identifier === got.identifier && current.state !== got.state) {
238 $.ui.toast(`${got.identifier} moved to ${got.state}`)
239 }
240 // A held issue that is finished has nothing left to hold on to: let go and follow the branch rules again.
241 if (source.from === 'pinned' && (await read($, pinnedByAtom)) === 'held' && (got.stateType === 'completed' || got.stateType === 'canceled')) {
242 await setPin($, null, null)
243 await update($, claimedBranchAtom, () => null)
244 await update($, issueAtom, () => null)
245 return refreshNow($, force)
246 }
247 await update($, errorAtom, () => null)
248 await update($, issueAtom, () => got)
249}
250
251function linear($: EngineInterface) {
252 return createClient(async (url, init) => {
253 const res = await $.http.fetch(url, init)
254 return { status: res.status, text: res.text }
255 }, apiKey ?? '')
256}
257
258async function loadTeamKeys($: EngineInterface): Promise<string[]> {
259 if (config.teamKeys) {
260 // The CLI resolves team keys the same way; hand it the option too.
261 await $.env.set('LINEAR_WORKFLOW_TEAM_KEYS', config.teamKeys)
262 }
263 return resolveTeamKeys({
264 option: config.teamKeys,
265 configText: repo ? await $.fs.read(`${repo.root}/.claude/linear.json`).catch(() => undefined) : undefined,
266 fetchMine: apiKey ? () => myTeamKeys(linear($)) : undefined,
267 })
268}
269
270type Ui = Pick<ElementTable, 'Box' | 'Text'>
271
272/** `── Title n ─────` across the width; the title never shrinks, the rule does. */
273function divider({ Box, Text }: Ui, title: string, width: number, opts: { count?: number; dim?: boolean; marginTop?: number } = {}) {
274 return (
275 <Box marginTop={opts.marginTop ?? 0}>
276 <Box flexShrink={0}>
277 <Text dimColor>── </Text>
278 <Text bold dimColor={opts.dim}>{title}</Text>
279 {opts.count !== undefined && <Text dimColor> {opts.count}</Text>}
280 <Text> </Text>
281 </Box>
282 <Box flexShrink={1} overflow="hidden">
283 <Text dimColor wrap="truncate">{'─'.repeat(Math.max(0, width))}</Text>
284 </Box>
285 </Box>
286 )
287}
288
289/** `◆ ABC-123 In Progress · High` */
290function header({ Box, Text }: Ui, issue: Issue) {
291 return (
292 <Box>
293 <Text bold color={stateColor(issue.stateType)}>◆ </Text>
294 <Text bold>{issue.identifier} </Text>
295 <Text color={stateColor(issue.stateType)}>{issue.state}</Text>
296 <Text dimColor> · {issue.priority}</Text>
297 </Box>
298 )
299}
300
301export const register: Register = (on, options) => {
302 config = {
303 linearApiKey: String(options.linearApiKey ?? ''),
304 teamKeys: String(options.teamKeys ?? ''),
305 registryFile: String(options.registryFile ?? ''),
306 pinCommand: String(options.pinCommand ?? 'issue-start').replace(/^\//, ''),
307 pollMinutes: Math.max(1, Number(options.pollMinutes ?? 5) || 5),
308 showOtherSessions: options.showOtherSessions !== false,
309 }
310
311 on('session.start', async ($, e, next) => {
312 const started = await next(e)
313 await $.command.register({
314 name: 'linear',
315 description: 'Linear issue pane — /linear [ABC-123 | refresh | clear | hide | show]',
316 })
317 $.clock.every(config.pollMinutes * 60 * 1000, () => refresh($, true))
318 $.clock.every(HEARTBEAT_MS, () => heartbeat($))
319 repo = await loadRepo($)
320 apiKey = await loadKey($)
321 teamKeys = await loadTeamKeys($)
322 const home = await $.env.get('HOME')
323 registryDir = home ? `${home}/.claude/linear-workflow/sessions` : null
324 // Learn which issues other sessions claim before choosing this session's own.
325 await heartbeat($)
326 await refresh($, true)
327 if (await read($, sourceAtom)) void $.ui.open({ id: PANE, title: 'Linear' })
328 return started
329 })
330
331 on('command.run', { command: 'linear' }, async ($, e) => {
332 const arg = e.args.trim()
333 switch (arg.toLowerCase()) {
334 case 'refresh':
335 await refresh($, true)
336 return { text: 'Linear issue refreshed.' }
337 case 'clear':
338 await setPin($, null, null)
339 await refresh($, true)
340 return { text: 'Pin cleared; following the branch again.' }
341 case 'hide':
342 case 'show':
343 await update($, hiddenAtom, () => arg.toLowerCase() === 'hide')
344 return { text: `Band ${arg.toLowerCase() === 'hide' ? 'hidden' : 'shown'}.` }
345 }
346
347 if (arg) {
348 const id = idFromText(arg)
349 if (!id) return { text: `Not an issue id: ${arg}` }
350 await setPin($, id, 'manual')
351 await refresh($, true)
352 }
353 await $.ui.open({ id: PANE, title: 'Linear' })
354 return { text: arg ? `Pinned ${idFromText(arg)}.` : 'Linear pane opened.' }
355 })
356
357 on('command.run', async ($, e, next) => {
358 await pinFromSkill($, String(e.command), e.args)
359 return next(e)
360 })
361
362 on('session.end', async ($, e, next) => {
363 if (registryDir) {
364 await $.process.run(['rm', '-f', `${registryDir}/${e.sessionId}.json`], { timeoutMs: 2000 }).catch(() => undefined)
365 }
366 return next(e)
367 })
368
369 on('turn.complete', async ($, e, next) => {
370 const done = await next(e)
371 // Re-fetch when this turn wrote to Linear, so the band shows the new state now, not at the next poll.
372 const force = linearTouched
373 linearTouched = false
374 await refresh($, force)
375 return done
376 })
377
378 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
379 const result = await next(e)
380 const succeeded = !('deny' in result && result.deny) && !result.isError
381 if (succeeded && isCheckout(String((e as unknown as { command?: string }).command ?? ''))) ownCheckout = true
382 return result
383 })
384
385 on('tool.call', async ($, e, next) => {
386 const tool = String(e.tool)
387 if (touchesLinear(tool, e as unknown as { command?: unknown; subagent_type?: unknown })) linearTouched = true
388 if (tool === 'Skill') {
389 const input = e as unknown as { skill?: unknown; args?: unknown }
390 await pinFromSkill($, String(input.skill ?? ''), String(input.args ?? ''))
391 }
392 if (repo && (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit')) {
393 const input = e as unknown as { file_path?: string; notebook_path?: string }
394 const path = input.file_path ?? input.notebook_path ?? ''
395 const fetched = await read($, issueAtom)
396 const issue = fetched?.identifier === (await read($, sourceAtom))?.id ? fetched : null
397 const siteKeys = repo.sites.map(s => s.key)
398 const site = issue && driftSite(path, repo.sites, scopeSites(issue, siteKeys))
399 if (issue && site) {
400 const tag = `${issue.identifier}:${site}`
401 if (!(await read($, warnedAtom)).includes(tag)) {
402 await update($, warnedAtom, list => [...list, tag])
403 $.ui.toast(`Drift: editing ${site}, outside ${issue.identifier}'s scope (${scopeSites(issue, siteKeys).join(', ')})`)
404 }
405 }
406 }
407 return next(e)
408 })
409
410 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
411 const source = await read($, sourceAtom)
412 const others = await read($, othersAtom)
413 if (e.props.hasSurvey || !repo || (await read($, hiddenAtom))) return next(e)
414
415 const { Box, Text, Button } = $.ui.resolve(e)
416 const issue = await read($, issueAtom)
417 const error = await read($, errorAtom)
418 const width = e.props.bodyColumns
419 const clashing = new Set(
420 repo ? conflicts({ checkout: repo.root, issue: source?.id ?? null }, others).map(c => c.sessionId) : [],
421 )
422
423 const ui = { Box, Text }
424 const rule = divider(ui, 'Linear', width, { dim: true })
425 const sessionsRow = others.length > 0 && (
426 <Box>
427 <Box flexShrink={0}>
428 <Text dimColor> sessions </Text>
429 </Box>
430 {others.map((o, i) => {
431 const clash = clashing.has(o.sessionId)
432 const text = o.issue ? `${o.issue}${o.state ? ` ${o.state}` : ''} (${o.label})` : `${o.label}: no issue`
433 return (
434 <Text key={o.sessionId} color={clash ? 'red' : undefined} dimColor={!clash} wrap="truncate">
435 {i > 0 ? ' · ' : ''}
436 {clash ? '⚠ ' : ''}
437 {text}
438 </Text>
439 )
440 })}
441 </Box>
442 )
443 const buttons = (
444 <Box>
445 <Button key="details" label="Details" plain onPress={() => $.ui.open({ id: PANE, title: 'Linear' })} />
446 <Text> </Text>
447 <Button key="hide" label="Hide" plain onPress={() => update($, hiddenAtom, () => true)} />
448 </Box>
449 )
450
451 if (!source) {
452 return (
453 <Box flexDirection="column" width={width} marginTop={1}>
454 {rule}
455 <Text dimColor> No Linear issue found</Text>
456 {sessionsRow}
457 </Box>
458 )
459 }
460
461 if (!issue || issue.identifier !== source.id) {
462 return (
463 <Box flexDirection="column" width={width} marginTop={1}>
464 {rule}
465 <Text dimColor wrap="truncate">
466 ◆ {source.id} {error ?? 'loading…'}
467 </Text>
468 {sessionsRow}
469 </Box>
470 )
471 }
472
473 const siteKeys = repo?.sites.map(s => s.key) ?? []
474 const scope = scopeSites(issue, siteKeys)
475 const meta = [
476 source.from === 'pinned' ? (source.detail.startsWith('held') ? 'held' : 'pinned') : source.from === 'root' ? 'root branch' : source.detail,
477 siteKeys.length === 0 ? '' : scope.length ? `scope: ${scope.join(', ')}` : 'scope: all',
478 issue.parent ? `parent ${issue.parent.identifier}` : '',
479 error ? `stale: ${error}` : '',
480 ].filter(Boolean)
481
482 return (
483 <Box flexDirection="column" width={width} marginTop={1}>
484 {rule}
485 {header(ui, issue)}
486 <Text wrap="truncate"> {issue.title}</Text>
487 <Box justifyContent="space-between">
488 <Box flexShrink={1}>
489 <Text dimColor wrap="truncate"> {meta.join(' · ')}</Text>
490 </Box>
491 <Box flexShrink={0}>{buttons}</Box>
492 </Box>
493 {sessionsRow}
494 </Box>
495 )
496 })
497
498 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
499 const { Box, Text, Button, Link, Markdown } = $.ui.resolve(e)
500 const source = await read($, sourceAtom)
501 const issue = await read($, issueAtom)
502 const error = await read($, errorAtom)
503 const others = await read($, othersAtom)
504 const width = e.props.bodyColumns
505 const clashes = repo ? conflicts({ checkout: repo.root, issue: source?.id ?? null }, others) : []
506
507 const ui = { Box, Text }
508 const section = (title: string, count?: number) => divider(ui, title, width, { count, marginTop: 1 })
509 const field = (label: string, value: string | null | undefined) =>
510 value ? (
511 <Box key={label}>
512 <Box flexShrink={0} width={11}>
513 <Text dimColor>{label}</Text>
514 </Box>
515 <Box flexShrink={1}>
516 <Text wrap="truncate">{value}</Text>
517 </Box>
518 </Box>
519 ) : null
520 const othersSection = others.length > 0 && (
521 <Box flexDirection="column">
522 {section('Other sessions', others.length)}
523 {others.map(o => (
524 <Box key={o.sessionId}>
525 <Text color={o.stateType ? stateColor(o.stateType) : undefined}>{(o.state ?? '—').padEnd(13)}</Text>
526 <Text wrap="truncate">{o.issue ?? 'no issue'} {o.title ?? ''}</Text>
527 <Text dimColor> {o.label}</Text>
528 </Box>
529 ))}
530 {clashes.map(c => <Text key={c.key} color="red">⚠ {c.text}</Text>)}
531 </Box>
532 )
533
534 if (!issue || issue.identifier !== source?.id) {
535 return (
536 <Box flexDirection="column" width={width}>
537 <Text dimColor>{source ? `${source.id}: ${error ?? 'loading…'}` : 'No Linear issue on this branch. /linear ABC-123 pins one.'}</Text>
538 {othersSection}
539 </Box>
540 )
541 }
542
543 const siteKeys = repo?.sites.map(s => s.key) ?? []
544 const scope = scopeSites(issue, siteKeys)
545 const sourceText = source
546 ? source.from === 'pinned' ? source.detail : source.from === 'root' ? `branch ${source.detail}` : source.detail
547 : null
548
549 return (
550 <Box flexDirection="column" width={width}>
551 {header(ui, issue)}
552 <Text bold>{issue.title}</Text>
553 <Box marginTop={1}>
554 <Button key="copy" label="Copy link" onPress={async () => {
555 const copied = await $.ui.copy({ text: issue.url })
556 $.ui.toast(copied.isCopied ? `Copied ${issue.url}` : issue.url)
557 }} />
558 <Text> </Text>
559 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($, true)} />
560 </Box>
561 {error && <Text color="red">Refresh failed: {error}</Text>}
562
563 <Box flexDirection="column" marginTop={1}>
564 {field('Parent', issue.parent ? `${issue.parent.identifier} · ${issue.parent.title}` : null)}
565 {field('Assignee', issue.assignee)}
566 {field('Cycle', issue.cycle !== null ? String(issue.cycle) : null)}
567 {field('Milestone', issue.milestone)}
568 {field('Labels', issue.labels.join(', ') || null)}
569 {field('Scope', scope.length ? scope.join(', ') : 'all')}
570 {field('Source', sourceText)}
571 </Box>
572
573 {issue.children.length > 0 && (
574 <Box flexDirection="column">
575 {section('Sub-issues', issue.children.length)}
576 {issue.children.map(c => (
577 <Box key={c.identifier}>
578 <Text color={stateColor(c.stateType)}>{c.state.padEnd(13)}</Text>
579 <Text wrap="truncate">{c.identifier} {c.title}</Text>
580 </Box>
581 ))}
582 </Box>
583 )}
584
585 {section('Description')}
586 <Markdown text={issue.description || '_No description._'} />
587
588 {issue.comments.length > 0 && (
589 <Box flexDirection="column">
590 {section('Latest comments', issue.comments.length)}
591 {issue.comments.map((c, i) => (
592 <Box key={`c${i}`} flexDirection="column" marginTop={i > 0 ? 1 : 0}>
593 <Text dimColor>{c.author} · {c.createdAt}</Text>
594 <Markdown text={c.body} />
595 </Box>
596 ))}
597 </Box>
598 )}
599
600 {section('Links')}
601 <Link href={issue.url} label="Open in Linear" />
602 {issue.links.map(l => <Link key={l.url} href={l.url} label={l.title} />)}
603
604 {othersSection}
605 </Box>
606 )
607 })
608}
609lib/linear.ts 106 lines1// Linear GraphQL client shared by the mod (through $.http.fetch) and the CLI (through fetch).
2
3export type Fetcher = (
4 url: string,
5 init: { method: string; headers: Record<string, string>; body: string },
6) => Promise<{ status: number; text: string }>
7
8export type StateType = 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled'
9
10export type IssueSummary = {
11 id: string
12 identifier: string
13 title: string
14 url: string
15 priority: number
16 priorityLabel: string
17 state: { name: string; type: StateType }
18 createdAt: string
19 branchName: string
20 milestone: { name: string; targetDate: string | null } | null
21 labels: string[]
22 parent: { identifier: string; title: string; cycleId: string | null; labels: string[] } | null
23 cycleId: string | null
24 blockedBy: { identifier: string; state: string }[]
25 childCount: number
26}
27
28export class LinearError extends Error {}
29
30const ENDPOINT = 'https://api.linear.app/graphql'
31
32export function createClient(fetcher: Fetcher, apiKey: string) {
33 async function query<T>(text: string, variables: Record<string, unknown> = {}): Promise<T> {
34 const res = await fetcher(ENDPOINT, {
35 method: 'POST',
36 headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
37 body: JSON.stringify({ query: text, variables }),
38 })
39 let body: { data?: T; errors?: { message: string }[] }
40 try {
41 body = JSON.parse(res.text) as typeof body
42 } catch {
43 throw new LinearError(`Linear returned HTTP ${res.status} with a non-JSON body`)
44 }
45 if (body.errors?.length) throw new LinearError(body.errors.map(e => e.message).join('; '))
46 if (!body.data) throw new LinearError(`Linear returned HTTP ${res.status} with no data`)
47 return body.data
48 }
49 return { query }
50}
51
52export type Client = ReturnType<typeof createClient>
53
54export const SUMMARY_FIELDS = `id identifier title url priority priorityLabel createdAt branchName
55state{name type} projectMilestone{name targetDate} labels{nodes{name}} cycle{id}
56parent{identifier title cycle{id} labels{nodes{name}}} children{nodes{id}}
57inverseRelations{nodes{type issue{identifier state{name type}}}}`
58
59type RawSummary = {
60 id: string
61 identifier: string
62 title: string
63 url: string
64 priority: number
65 priorityLabel: string
66 createdAt: string
67 branchName: string
68 state: { name: string; type: StateType }
69 projectMilestone: { name: string; targetDate: string | null } | null
70 labels: { nodes: { name: string }[] }
71 cycle: { id: string } | null
72 parent: { identifier: string; title: string; cycle: { id: string } | null; labels: { nodes: { name: string }[] } } | null
73 children: { nodes: { id: string }[] }
74 inverseRelations: { nodes: { type: string; issue: { identifier: string; state: { name: string; type: StateType } } }[] }
75}
76
77export function isOpenType(type: StateType): boolean {
78 return type !== 'completed' && type !== 'canceled'
79}
80
81export function toSummary(raw: RawSummary): IssueSummary {
82 return {
83 id: raw.id,
84 identifier: raw.identifier,
85 title: raw.title,
86 url: raw.url,
87 priority: raw.priority,
88 priorityLabel: raw.priorityLabel,
89 state: raw.state,
90 createdAt: raw.createdAt,
91 branchName: raw.branchName,
92 milestone: raw.projectMilestone,
93 labels: raw.labels.nodes.map(l => l.name),
94 parent: raw.parent
95 ? { identifier: raw.parent.identifier, title: raw.parent.title, cycleId: raw.parent.cycle?.id ?? null, labels: raw.parent.labels.nodes.map(l => l.name) }
96 : null,
97 cycleId: raw.cycle?.id ?? null,
98 blockedBy: raw.inverseRelations.nodes
99 .filter(r => r.type === 'blocks' && isOpenType(r.issue.state.type))
100 .map(r => ({ identifier: r.issue.identifier, state: r.issue.state.name })),
101 childCount: raw.children.nodes.length,
102 }
103}
104
105export type { RawSummary }
106lib/logic.ts 200 lines1import type { Issue, IssueSource, SessionEntry } from '../types'
2import type { IssueDetail } from './workflow.ts'
3
4const ANY_ID = /\b([A-Z][A-Z0-9]{1,9})-(\d+)\b/
5
6export function idFromBranch(branch: string, teamKeys: string[]): string | undefined {
7 if (teamKeys.length === 0) return undefined
8 const re = new RegExp(`(?:^|/)(${teamKeys.join('|')})-(\\d+)(?:-|$)`, 'i')
9 const m = re.exec(branch.trim())
10 return m?.[1] && m[2] ? `${m[1].toUpperCase()}-${m[2]}` : undefined
11}
12
13export function idFromText(text: string): string | undefined {
14 const m = ANY_ID.exec(text.toUpperCase())
15 return m?.[1] && m[2] ? `${m[1]}-${m[2]}` : undefined
16}
17
18export function readEnvValue(text: string, key: string): string | undefined {
19 for (const line of text.split('\n')) {
20 const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line)
21 if (m?.[1] === key) return (m[2] ?? '').trim().replace(/^(['"])(.*)\1$/, '$2')
22 }
23 return undefined
24}
25
26/** The issue named by the root branch, else by the most sub-project branches. */
27export function pickSource(rootBranch: string, siteBranches: Record<string, string>, teamKeys: string[]): IssueSource | null {
28 const rootId = idFromBranch(rootBranch, teamKeys)
29 if (rootId) return { id: rootId, from: 'root', detail: rootBranch }
30
31 const bySite = new Map<string, string[]>()
32 for (const [site, branch] of Object.entries(siteBranches)) {
33 const id = idFromBranch(branch, teamKeys)
34 if (id) bySite.set(id, [...(bySite.get(id) ?? []), site])
35 }
36 if (bySite.size === 0) return null
37
38 const top = [...bySite.entries()].sort((a, b) => b[1].length - a[1].length)[0]
39 if (!top) return null
40 const [id, sites] = top
41 const others = bySite.size > 1 ? `, mixed: ${[...bySite.keys()].filter(k => k !== id).join(', ')}` : ''
42 return { id, from: 'sites', detail: `${sites.length} project(s) on it${others}` }
43}
44
45/** Site keys the issue is scoped to through its labels (or its parent's); empty means every site. */
46export function scopeSites(issue: Issue, siteKeys: string[]): string[] {
47 const own = issue.labels.filter(l => siteKeys.includes(l))
48 if (own.length > 0) return own
49 return (issue.parent?.labels ?? []).filter(l => siteKeys.includes(l))
50}
51
52export function driftSite(
53 filePath: string,
54 sites: { key: string; path: string }[],
55 scope: string[],
56): string | undefined {
57 if (scope.length === 0) return undefined
58 const site = sites.find(s => filePath.startsWith(`${s.path.replace(/\/$/, '')}/`))
59 return site && !scope.includes(site.key) ? site.key : undefined
60}
61
62/** The pane's view of an issue, from the shared fetch: latest comments, bodies capped. */
63export function toPaneIssue(d: IssueDetail): Issue {
64 return {
65 identifier: d.identifier,
66 title: d.title,
67 url: d.url,
68 state: d.state.name,
69 stateType: d.state.type,
70 priority: d.priorityLabel,
71 labels: d.labels,
72 parent: d.parent ? { identifier: d.parent.identifier, title: d.parent.title, labels: d.parent.labels } : null,
73 assignee: d.assignee,
74 cycle: d.cycle,
75 milestone: d.milestone?.name ?? null,
76 description: d.description.slice(0, 6000),
77 children: d.children.map(c => ({ identifier: c.identifier, title: c.title, state: c.state.name, stateType: c.state.type })),
78 comments: d.comments.slice(-3).map(c => ({ author: c.author, createdAt: c.createdAt.slice(0, 10), body: c.body.slice(0, 1200) })),
79 links: d.links,
80 }
81}
82
83/** The repo the session works in: the superproject when inside a submodule. */
84export async function findRoot(git: (args: string[]) => Promise<string>): Promise<string> {
85 return (await git(['rev-parse', '--show-superproject-working-tree'])) || (await git(['rev-parse', '--show-toplevel']))
86}
87
88/** API key: explicit option, then the environment, then `<root>/.env`. */
89export function resolveApiKey(input: { option?: string; env?: string; envFileText?: string }): string | undefined {
90 return input.option || input.env || (input.envFileText ? readEnvValue(input.envFileText, 'LINEAR_API_KEY') : undefined) || undefined
91}
92
93export function stateColor(type: string): string {
94 switch (type) {
95 case 'started': return 'yellow'
96 case 'completed': return 'green'
97 case 'canceled': return 'gray'
98 case 'triage': return 'magenta'
99 default: return 'blue'
100 }
101}
102
103export const STALE_MS = 3 * 60 * 1000
104
105export function isCheckout(command: string): boolean {
106 return command.split(/&&|\|\||;|\n/).some(part => {
107 if (!/\bgit\b(?:\s+-C\s+\S+|\s+-[-\w]+(?:=\S+)?)*\s+(?:checkout|switch)\b/.test(part)) return false
108 return !/\s--(?:\s|$)/.test(part)
109 })
110}
111
112export function checkoutLabel(checkout: string): string {
113 const m = /\/worktrees\/([^/]+)\/?$/.exec(checkout)
114 return m?.[1] ?? 'main'
115}
116
117export function liveOthers(entries: SessionEntry[], selfId: string, now: number): SessionEntry[] {
118 return entries
119 .filter(s => s.sessionId !== selfId && now - s.updatedAt < STALE_MS)
120 .sort((a, b) => a.label.localeCompare(b.label) || (a.issue ?? '').localeCompare(b.issue ?? ''))
121}
122
123export type Conflict = { key: string; sessionId: string; text: string }
124
125export function conflicts(self: { checkout: string; issue: string | null }, others: SessionEntry[]): Conflict[] {
126 const found: Conflict[] = []
127 for (const o of others) {
128 if (self.issue && o.issue === self.issue) {
129 found.push({ key: `same:${o.sessionId}:${o.issue}`, sessionId: o.sessionId, text: `another session (${o.label}) is also on ${o.issue}` })
130 } else if (o.checkout === self.checkout && o.issue && self.issue && o.issue !== self.issue) {
131 found.push({
132 key: `checkout:${o.sessionId}:${o.issue}:${self.issue}`,
133 sessionId: o.sessionId,
134 text: `${o.issue} is being worked in this same checkout (${o.label}) — shared branch and tree`,
135 })
136 }
137 }
138 return found
139}
140
141export type SourceDecision = {
142 /** Which issue the session shows: its pin, the branch's, or none. */
143 use: 'pin' | 'branch' | 'none'
144 pinned: string | null
145 pinnedBy: string | null
146 /** The branch issue this session took on by switching to it itself. */
147 claimedBranch: string | null
148}
149
150/**
151 * Decide which issue a session shows. A session only shows an issue it took on: a pin, a branch it
152 * switched to itself, or a branch issue no other live session in the same checkout has claimed.
153 */
154export function decideSource(input: {
155 fromBranch: IssueSource | null
156 pinned: string | null
157 pinnedBy: string | null
158 previous: IssueSource | null
159 branchMoved: boolean
160 own: boolean
161 claimedBranch: string | null
162 claimedElsewhere: (id: string) => boolean
163}): SourceDecision {
164 const { fromBranch, previous, branchMoved, own, claimedElsewhere } = input
165 let { pinned, pinnedBy, claimedBranch } = input
166 // Only a switch this session made claims (or, onto a branch without an issue, releases) a branch issue.
167 if (own && branchMoved) claimedBranch = fromBranch?.id ?? null
168
169 if (branchMoved && !own) {
170 // Another session moved the shared branch: keep the issue this session had claimed.
171 if (!pinned && previous && claimedBranch === previous.id) {
172 pinned = previous.id
173 pinnedBy = 'held'
174 }
175 } else if (pinned && pinnedBy === 'held' && own && fromBranch?.id === pinned) {
176 pinned = null
177 pinnedBy = null
178 } else if (pinned && branchMoved) {
179 const release = pinnedBy === 'manual' ? fromBranch !== null && fromBranch.id !== pinned : fromBranch?.id !== pinned
180 if (release) {
181 pinned = null
182 pinnedBy = null
183 }
184 }
185
186 const decided = (use: SourceDecision['use']): SourceDecision => ({ use, pinned, pinnedBy, claimedBranch })
187 if (pinned) return decided('pin')
188 if (!fromBranch) return decided('none')
189 if (claimedBranch === fromBranch.id) return decided('branch')
190 if (claimedElsewhere(fromBranch.id)) return decided('none')
191 return decided('branch')
192}
193
194/** Whether a tool call may have changed Linear, so the issue is worth fetching again. */
195export function touchesLinear(tool: string, input: { command?: unknown; subagent_type?: unknown }): boolean {
196 if (tool === 'Bash') return /linear-workflow\.ts"?\s+(transition|set-cycle)\b/.test(String(input.command ?? ''))
197 if (tool === 'Agent') return /linear-manager/.test(String(input.subagent_type ?? ''))
198 return /^mcp__.*linear.*__(save|create|update)_(issue|comment)$/i.test(tool)
199}
200lib/workflow.ts 495 lines1import type { Client, IssueSummary, RawSummary, StateType } from './linear.ts'
2import { isOpenType, LinearError, SUMMARY_FIELDS, toSummary } from './linear.ts'
3
4export type ProjectConfig = {
5 team?: string
6 teamKey?: string
7 project?: string
8 assignee?: string
9 baseBranch?: string
10 checks?: string[]
11 mergeMethod?: 'merge' | 'squash' | 'rebase' | 'local'
12 deleteBranch?: boolean
13 /** Your team's names for the three states the skills move issues to. */
14 states?: StateNames
15 /** What issue-start does about branches: create one (default), ask, or stay on the current one. */
16 branching?: 'create' | 'ask' | 'none'
17 /** Path (from the repo root) of a markdown template the agent uses for new issue descriptions. */
18 issueTemplate?: string
19}
20
21export type StateNames = { inProgress?: string; inReview?: string; done?: string }
22
23export function parseProjectConfig(text: string | undefined): ProjectConfig {
24 if (!text) return {}
25 const raw = JSON.parse(text) as Record<string, unknown>
26 const out: ProjectConfig = {}
27 for (const key of ['team', 'teamKey', 'project', 'assignee', 'baseBranch'] as const) {
28 if (typeof raw[key] === 'string') out[key] = raw[key] as string
29 }
30 if (Array.isArray(raw.checks)) out.checks = raw.checks.filter((c): c is string => typeof c === 'string')
31 if (['merge', 'squash', 'rebase', 'local'].includes(String(raw.mergeMethod))) {
32 out.mergeMethod = raw.mergeMethod as ProjectConfig['mergeMethod']
33 }
34 if (typeof raw.deleteBranch === 'boolean') out.deleteBranch = raw.deleteBranch
35 if (['create', 'ask', 'none'].includes(String(raw.branching))) out.branching = raw.branching as ProjectConfig['branching']
36 if (typeof raw.issueTemplate === 'string') out.issueTemplate = raw.issueTemplate
37 if (raw.states && typeof raw.states === 'object') {
38 const st = raw.states as Record<string, unknown>
39 const names: StateNames = {}
40 for (const key of ['inProgress', 'inReview', 'done'] as const) if (typeof st[key] === 'string') names[key] = st[key] as string
41 out.states = names
42 }
43 return out
44}
45
46/** `ENG-12`, `eng-12`, `#12` or `12` (with a single known team key) → `ENG-12`. */
47export function normalizeId(input: string, teamKey?: string): string | undefined {
48 const text = input.trim()
49 const full = /^([A-Za-z][A-Za-z0-9]{0,9})-(\d+)$/.exec(text)
50 if (full?.[1] && full[2]) return `${full[1].toUpperCase()}-${full[2]}`
51 const bare = /^#?(\d+)$/.exec(text)
52 if (bare?.[1] && teamKey) return `${teamKey.toUpperCase()}-${bare[1]}`
53 return undefined
54}
55
56/** Team keys to look for in branch names: the repo's linear.json, then the user's plugin option, then your teams. */
57export async function resolveTeamKeys(input: {
58 option?: string
59 configText?: string
60 fetchMine?: () => Promise<string[]>
61}): Promise<string[]> {
62 let fromConfig: string[] = []
63 try {
64 fromConfig = splitKeys(parseProjectConfig(input.configText).teamKey)
65 } catch {
66 // unreadable linear.json: fall through
67 }
68 if (fromConfig.length) return fromConfig
69 const fromOption = splitKeys(input.option)
70 if (fromOption.length) return fromOption
71 try {
72 return input.fetchMine ? splitKeys((await input.fetchMine()).join(',')) : []
73 } catch {
74 return []
75 }
76}
77
78function splitKeys(value: string | undefined): string[] {
79 return String(value ?? '')
80 .split(/[\s,]+/)
81 .map(k => k.trim().toUpperCase())
82 .filter(k => /^[A-Z][A-Z0-9]{0,9}$/.test(k))
83}
84
85/** Whole days from `today` (YYYY-MM-DD) to `date`; negative when overdue. */
86export function daysUntil(date: string | null | undefined, today: string): number | null {
87 if (!date) return null
88 const ms = Date.parse(`${date.slice(0, 10)}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)
89 return Number.isNaN(ms) ? null : Math.round(ms / 86_400_000)
90}
91
92// ---------- ranking (pure) ----------
93
94function priorityRank(p: number): number {
95 return p === 0 ? 5 : p
96}
97
98export function compareIssues(a: IssueSummary, b: IssueSummary): number {
99 const byPriority = priorityRank(a.priority) - priorityRank(b.priority)
100 if (byPriority) return byPriority
101 const da = a.milestone?.targetDate ?? '9999-99-99'
102 const db = b.milestone?.targetDate ?? '9999-99-99'
103 if (da !== db) return da < db ? -1 : 1
104 return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0
105}
106
107export function rank(issues: IssueSummary[]): IssueSummary[] {
108 return [...issues].sort(compareIssues)
109}
110
111const ACTIONABLE: StateType[] = ['unstarted', 'started']
112
113const REVIEW_NAMES = ['in review', 'review', 'to review', 'qa', 'in qa', 'code review']
114
115let customStates: StateNames = {}
116
117/** Apply the project's own state names (`states` in .claude/linear.json) for this process. */
118export function useStateNames(names: StateNames | undefined): void {
119 customStates = names ?? {}
120}
121
122/** Linear files review states under `started`, like In Progress; they are not work to pick up. */
123export function isInReview(state: { name: string }): boolean {
124 const name = state.name.trim().toLowerCase()
125 return REVIEW_NAMES.includes(name) || name === customStates.inReview?.trim().toLowerCase()
126}
127
128export function isActionable(issue: IssueSummary): boolean {
129 return ACTIONABLE.includes(issue.state.type) && !isInReview(issue.state) && issue.blockedBy.length === 0
130}
131
132// ---------- reads ----------
133
134export type Cycle = { id: string; number: number; startsAt: string; endsAt: string; teamKey: string }
135
136export async function activeCycle(client: Client, teamKey?: string): Promise<Cycle | null> {
137 const data = await client.query<{ viewer: { teamMemberships: { nodes: { team: { key: string; activeCycle: { id: string; number: number; startsAt: string; endsAt: string } | null } }[] } } }>(
138 `query{viewer{teamMemberships{nodes{team{key activeCycle{id number startsAt endsAt}}}}}}`,
139 )
140 const teams = data.viewer.teamMemberships.nodes.map(n => n.team)
141 const team = teamKey ? teams.find(t => t.key.toUpperCase() === teamKey.toUpperCase()) : teams.find(t => t.activeCycle)
142 if (teamKey && !team) throw new LinearError(`You are not a member of a team with key ${teamKey}`)
143 return team?.activeCycle ? { ...team.activeCycle, teamKey: team.key } : null
144}
145
146export type QueueEntry = IssueSummary & { epic: { identifier: string; title: string } | null; dueInDays: number | null }
147export type QueueResult = {
148 cycle: Cycle | null
149 examined: number
150 candidates: QueueEntry[]
151 droppedBlocked: string[]
152 droppedEpics: { identifier: string; reason: string }[]
153}
154
155export async function queue(client: Client, opts: { teamKey?: string; limit?: number; today: string }): Promise<QueueResult> {
156 const cycle = await activeCycle(client, opts.teamKey)
157 if (!cycle) return { cycle: null, examined: 0, candidates: [], droppedBlocked: [], droppedEpics: [] }
158 const data = await client.query<{ issues: { nodes: RawSummary[] } }>(
159 `query($cycle:ID!){issues(first:250,filter:{cycle:{id:{eq:$cycle}},assignee:{isMe:{eq:true}},state:{type:{in:["unstarted","started"]}}}){nodes{${SUMMARY_FIELDS}}}}`,
160 { cycle: cycle.id },
161 )
162 const issues = data.issues.nodes.map(toSummary).filter(i => !isInReview(i.state))
163 const droppedBlocked = issues.filter(i => i.blockedBy.length > 0).map(i => i.identifier)
164 const droppedEpics: QueueResult['droppedEpics'] = []
165 const candidates: QueueEntry[] = []
166 const due = (i: IssueSummary) => daysUntil(i.milestone?.targetDate, opts.today)
167
168 const unblocked = issues.filter(i => i.blockedBy.length === 0)
169 const isEpic = (i: IssueSummary) => i.state.type === 'started' && i.childCount > 0
170 const nexts = await Promise.all(unblocked.map(i => (isEpic(i) ? nextChild(client, i.id) : Promise.resolve(null))))
171 unblocked.forEach((issue, n) => {
172 if (!isEpic(issue)) {
173 candidates.push({ ...issue, epic: null, dueInDays: due(issue) })
174 return
175 }
176 const next = nexts[n]
177 if (next) candidates.push({ ...next, epic: { identifier: issue.identifier, title: issue.title }, dueInDays: due(next) })
178 else droppedEpics.push({ identifier: issue.identifier, reason: 'no unblocked Todo or In Progress sub-issue' })
179 })
180 const seen = new Set<string>()
181 const unique = candidates.filter(c => (seen.has(c.identifier) ? false : (seen.add(c.identifier), true)))
182 return {
183 cycle,
184 examined: issues.length,
185 candidates: rank(unique).slice(0, opts.limit ?? 10) as QueueEntry[],
186 droppedBlocked,
187 droppedEpics,
188 }
189}
190
191async function nextChild(client: Client, parentId: string): Promise<IssueSummary | null> {
192 const data = await client.query<{ issue: { children: { nodes: RawSummary[] } } }>(
193 `query($id:String!){issue(id:$id){children(first:250,filter:{state:{type:{in:["unstarted","started"]}}}){nodes{${SUMMARY_FIELDS}}}}}`,
194 { id: parentId },
195 )
196 return rank(data.issue.children.nodes.map(toSummary).filter(isActionable))[0] ?? null
197}
198
199export type PlanCycleResult = {
200 cycle: (Cycle & { issueCount: number }) | null
201 examined: number
202 candidates: IssueSummary[]
203 droppedBlocked: number
204 droppedChildren: number
205}
206
207export async function planCycle(client: Client, opts: { teamKey?: string; project?: string; limit?: number }): Promise<PlanCycleResult> {
208 const cycle = await activeCycle(client, opts.teamKey)
209 if (!cycle) return { cycle: null, examined: 0, candidates: [], droppedBlocked: 0, droppedChildren: 0 }
210 const projectFilter = opts.project ? `,project:{name:{eqIgnoreCase:$project}}` : ''
211 const data = await client.query<{ issues: { nodes: RawSummary[] }; cycle: { issues: { nodes: { id: string }[] } } }>(
212 `query($cycle:String!,$team:String!${opts.project ? ',$project:String!' : ''}){
213 cycle(id:$cycle){issues(first:250){nodes{id}}}
214 issues(first:250,filter:{cycle:{null:true},assignee:{isMe:{eq:true}},team:{key:{eq:$team}},state:{type:{in:["backlog","unstarted"]}}${projectFilter}}){nodes{${SUMMARY_FIELDS}}}}`,
215 { cycle: cycle.id, team: cycle.teamKey, ...(opts.project ? { project: opts.project } : {}) },
216 )
217 const issues = data.issues.nodes.map(toSummary)
218 const blocked = issues.filter(i => i.blockedBy.length > 0)
219 const children = issues.filter(i => i.blockedBy.length === 0 && i.parent?.cycleId === cycle.id)
220 const rest = issues.filter(i => i.blockedBy.length === 0 && i.parent?.cycleId !== cycle.id)
221 return {
222 cycle: { ...cycle, issueCount: data.cycle.issues.nodes.length },
223 examined: issues.length,
224 candidates: rank(rest).slice(0, opts.limit ?? 15),
225 droppedBlocked: blocked.length,
226 droppedChildren: children.length,
227 }
228}
229
230export type Criterion = { text: string; checked: boolean }
231
232const HEADING = /^(#{1,6})\s+(.*?)\s*:?\s*$/
233export const ACCEPTANCE_HEADING = /^acceptance criteria$/i
234
235/**
236 * Where a `## Title` section sits in `lines`: its heading line, and where it ends (the next heading of the
237 * same or a higher level, so its sub-headings stay inside). One rule for every reader of a section.
238 */
239export function findSection(lines: string[], title: RegExp): { heading: number; end: number } | null {
240 let heading = -1
241 let level = 0
242 for (let i = 0; i < lines.length; i++) {
243 const h = HEADING.exec((lines[i] as string).trim())
244 if (!h?.[1]) continue
245 if (heading === -1) {
246 if (title.test(h[2] ?? '')) {
247 heading = i
248 level = h[1].length
249 }
250 } else if (h[1].length <= level) return { heading, end: i }
251 }
252 return heading === -1 ? null : { heading, end: lines.length }
253}
254
255/**
256 * The criteria in the "Acceptance Criteria" section, sub-headings included, with their checkbox state.
257 * A section written as prose becomes one criterion, so a non-empty section never reads as "none".
258 */
259export function parseAcceptanceCriteria(description: string): Criterion[] {
260 const lines = description.split('\n')
261 const section = findSection(lines, ACCEPTANCE_HEADING)
262 if (!section) return []
263 const body = lines.slice(section.heading + 1, section.end).filter(l => !HEADING.test(l.trim()))
264 const out: Criterion[] = []
265 for (const line of body) {
266 const m = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(.+?)\s*$/.exec(line)
267 if (m?.[2]) out.push({ text: m[2], checked: (m[1] ?? ' ').toLowerCase() === 'x' })
268 }
269 if (out.length) return out
270 const prose = body.map(l => l.trim()).filter(Boolean).join(' ')
271 return prose ? [{ text: prose, checked: false }] : []
272}
273
274export type IssueDetail = IssueSummary & {
275 acceptanceCriteria: Criterion[]
276 description: string
277 teamKey: string
278 assignee: string | null
279 cycle: number | null
280 children: { identifier: string; title: string; state: { name: string; type: StateType } }[]
281 links: { title: string; url: string }[]
282 comments: { id: string; author: string; createdAt: string; body: string }[]
283}
284
285/** The full issue. `latestComments` fetches only that many of the newest comments (the pane's need). */
286export async function issueDetail(client: Client, id: string, opts: { latestComments?: number } = {}): Promise<IssueDetail> {
287 const comments = opts.latestComments ? `comments(last:${Math.max(1, Math.floor(opts.latestComments))})` : 'comments(first:250)'
288 const data = await client.query<{ issue: (RawSummary & {
289 description: string | null
290 team: { key: string }
291 assignee: { name: string } | null
292 cycle: { id: string; number: number } | null
293 subIssues: { nodes: { identifier: string; title: string; state: { name: string; type: StateType } }[] }
294 attachments: { nodes: { title: string; url: string }[] }
295 comments: { nodes: { id: string; body: string; createdAt: string; user: { name: string } | null }[] }
296 }) | null }>(
297 `query($id:String!){issue(id:$id){${SUMMARY_FIELDS} description team{key} assignee{name} cycle{id number}
298 subIssues: children(first:250){nodes{identifier title state{name type}}}
299 attachments{nodes{title url}}
300 ${comments}{nodes{id body createdAt user{name}}}}}`,
301 { id },
302 )
303 if (!data.issue) throw new LinearError(`${id}: not found`)
304 const raw = data.issue
305 return {
306 ...toSummary(raw),
307 description: raw.description ?? '',
308 acceptanceCriteria: parseAcceptanceCriteria(raw.description ?? ''),
309 teamKey: raw.team.key,
310 assignee: raw.assignee?.name ?? null,
311 cycle: raw.cycle?.number ?? null,
312 children: raw.subIssues.nodes,
313 links: raw.attachments.nodes,
314 comments: raw.comments.nodes
315 .map(c => ({ id: c.id, author: c.user?.name ?? 'integration', createdAt: c.createdAt, body: c.body }))
316 .sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)),
317 }
318}
319
320export async function myTeamKeys(client: Client): Promise<string[]> {
321 const data = await client.query<{ viewer: { teamMemberships: { nodes: { team: { key: string } }[] } } }>(
322 `query{viewer{teamMemberships{nodes{team{key}}}}}`,
323 )
324 return data.viewer.teamMemberships.nodes.map(n => n.team.key.toUpperCase())
325}
326
327export async function myOpenIssues(client: Client): Promise<IssueSummary[]> {
328 const data = await client.query<{ issues: { nodes: RawSummary[] } }>(
329 `query{issues(first:50,filter:{assignee:{isMe:{eq:true}},state:{type:{eq:"started"}}}){nodes{${SUMMARY_FIELDS}}}}`,
330 )
331 return data.issues.nodes.map(toSummary)
332}
333
334// ---------- writes ----------
335
336export type TeamState = { id: string; name: string; type: StateType; position: number }
337
338const ROLE_ALIASES: Record<string, string[]> = {
339 'in progress': ['in progress', 'started', 'doing'],
340 'in review': REVIEW_NAMES,
341 done: ['done', 'completed', 'shipped', 'closed'],
342}
343
344const ROLE_KEYS: Record<string, keyof StateNames> = { 'in progress': 'inProgress', 'in review': 'inReview', done: 'done' }
345
346/** Resolve a target like "In Review" against a team's workflow states, the project's own names first. */
347export function resolveState(states: TeamState[], target: string): TeamState | undefined {
348 const wanted = target.trim().toLowerCase()
349 const role = Object.entries(ROLE_ALIASES).find(([, names]) => names.includes(wanted))
350 const customName = role ? customStates[ROLE_KEYS[role[0]] as keyof StateNames]?.trim().toLowerCase() : undefined
351 const custom = customName ? states.find(s => s.name.toLowerCase() === customName) : undefined
352 if (custom) return custom
353 const exact = states.find(s => s.name.toLowerCase() === wanted)
354 if (exact) return exact
355 if (role) {
356 const byAlias = states.find(s => role[1].includes(s.name.toLowerCase()))
357 if (byAlias) return byAlias
358 const fallbackType: StateType = role[0] === 'done' ? 'completed' : 'started'
359 const ofType = states.filter(s => s.type === fallbackType).sort((a, b) => a.position - b.position)
360 return role[0] === 'in review' ? ofType[ofType.length - 1] : ofType[0]
361 }
362 return undefined
363}
364
365export type Role = 'done' | 'review'
366
367/** The roll-up role of a target state, or null when a move there never rolls a parent up. */
368export function rollUpRole(state: { name: string; type: StateType }): Role | null {
369 if (state.type === 'completed') return 'done'
370 return isInReview(state) ? 'review' : null
371}
372
373/** A sibling still owes work unless it is completed or canceled, or, for a review roll-up, in review. */
374export function siblingsUnfinished(siblings: { identifier: string; state: { name: string; type: StateType } }[], role: Role): string[] {
375 return siblings
376 .filter(s => isOpenType(s.state.type) && !(role === 'review' && isInReview(s.state)))
377 .map(s => s.identifier)
378}
379
380export type TransitionResult = {
381 issue: string
382 from: string
383 to: string
384 changed: boolean
385 commented: boolean
386 parent: { issue: string; rolledUp: boolean; unfinished: string[]; changed: boolean; note?: string } | null
387}
388
389type RawForTransition = {
390 id: string
391 identifier: string
392 state: { id: string; name: string; type: StateType }
393 team: { states: { nodes: TeamState[] } }
394 parent: {
395 id: string
396 identifier: string
397 state: { id: string; name: string }
398 team: { states: { nodes: TeamState[] } }
399 children: { nodes: { identifier: string; state: { name: string; type: StateType } }[] }
400 } | null
401}
402
403export async function transition(client: Client, id: string, target: string, comment?: string): Promise<TransitionResult> {
404 const data = await client.query<{ issue: RawForTransition | null }>(
405 `query($id:String!){issue(id:$id){id identifier state{id name type} team{states{nodes{id name type position}}}
406 parent{id identifier state{id name} team{states{nodes{id name type position}}}
407 children(first:250){nodes{identifier state{name type}}}}}}`,
408 { id },
409 )
410 const issue = data.issue
411 if (!issue) throw new LinearError(`${id}: not found`)
412 const state = resolveState(issue.team.states.nodes, target)
413 if (!state) throw new LinearError(`No workflow state matches "${target}" (have: ${issue.team.states.nodes.map(s => s.name).join(', ')})`)
414
415 const changed = issue.state.id !== state.id
416 if (changed) await setState(client, issue.id, state.id)
417 if (comment) await addComment(client, issue.id, comment)
418
419 let parent: TransitionResult['parent'] = null
420 const role = rollUpRole(state)
421 if (role && issue.parent) {
422 const siblings = issue.parent.children.nodes.map(s => (s.identifier === issue.identifier ? { ...s, state: { name: state.name, type: state.type } } : s))
423 const unfinished = siblingsUnfinished(siblings, role)
424 // The parent may live in another team: move it to that team's state for the same role.
425 const resolved = resolveState(issue.parent.team.states.nodes, role === 'done' ? 'Done' : 'In Review')
426 // resolveState falls back to an In Progress-like state; a review roll-up needs a real review state.
427 const parentState = role === 'review' && resolved && !isInReview(resolved) ? undefined : resolved
428 const parentChanged = unfinished.length === 0 && parentState !== undefined && issue.parent.state.id !== parentState.id
429 if (parentChanged && parentState) {
430 await setState(client, issue.parent.id, parentState.id)
431 await addComment(client, issue.parent.id, 'All sub-issues complete.')
432 }
433 parent = {
434 issue: issue.parent.identifier,
435 rolledUp: unfinished.length === 0 && parentState !== undefined,
436 unfinished,
437 changed: parentChanged,
438 ...(parentState ? {} : { note: `parent's team has no state for "${role}"` }),
439 }
440 }
441 return { issue: issue.identifier, from: issue.state.name, to: state.name, changed, commented: Boolean(comment), parent }
442}
443
444async function setState(client: Client, issueId: string, stateId: string): Promise<void> {
445 const r = await client.query<{ issueUpdate: { success: boolean } }>(
446 `mutation($id:String!,$state:String!){issueUpdate(id:$id,input:{stateId:$state}){success}}`,
447 { id: issueId, state: stateId },
448 )
449 if (!r.issueUpdate.success) throw new LinearError(`issueUpdate failed for ${issueId}`)
450}
451
452async function addComment(client: Client, issueId: string, body: string): Promise<void> {
453 const r = await client.query<{ commentCreate: { success: boolean } }>(
454 `mutation($id:String!,$body:String!){commentCreate(input:{issueId:$id,body:$body}){success}}`,
455 { id: issueId, body },
456 )
457 if (!r.commentCreate.success) throw new LinearError(`commentCreate failed for ${issueId}`)
458}
459
460export type SetCycleResult = { cycle: number; set: string[]; already: string[]; failed: { issue: string; error: string }[] }
461
462export async function setCycle(client: Client, teamKey: string | undefined, number: number, ids: string[]): Promise<SetCycleResult> {
463 const team = teamKey ?? (await activeCycle(client))?.teamKey
464 if (!team) throw new LinearError('No team: pass --team or set teamKey in .claude/linear.json')
465 const found = await client.query<{ cycles: { nodes: { id: string; number: number }[] } }>(
466 `query($team:String!,$n:Float!){cycles(filter:{team:{key:{eq:$team}},number:{eq:$n}}){nodes{id number}}}`,
467 { team, n: number },
468 )
469 const target = found.cycles.nodes[0]
470 if (!target) throw new LinearError(`Team ${team} has no cycle #${number}`)
471 const out: SetCycleResult = { cycle: number, set: [], already: [], failed: [] }
472 const results = await Promise.allSettled(
473 ids.map(async id => {
474 const cur = await client.query<{ issue: { id: string; cycle: { id: string } | null } | null }>(
475 `query($id:String!){issue(id:$id){id cycle{id}}}`,
476 { id },
477 )
478 if (!cur.issue) throw new LinearError('not found')
479 if (cur.issue.cycle?.id === target.id) return 'already' as const
480 const r = await client.query<{ issueUpdate: { success: boolean } }>(
481 `mutation($id:String!,$c:String!){issueUpdate(id:$id,input:{cycleId:$c}){success}}`,
482 { id: cur.issue.id, c: target.id },
483 )
484 if (!r.issueUpdate.success) throw new LinearError('issueUpdate failed')
485 return 'set' as const
486 }),
487 )
488 results.forEach((r, n) => {
489 const id = ids[n] as string
490 if (r.status === 'fulfilled') out[r.value].push(id)
491 else out.failed.push({ issue: id, error: r.reason instanceof Error ? r.reason.message : String(r.reason) })
492 })
493 return out
494}
495types/index.d.ts 54 lines1export type IssueChild = { identifier: string; title: string; state: string; stateType: string }
2
3export type IssueComment = { author: string; createdAt: string; body: string }
4
5export type Issue = {
6 identifier: string
7 title: string
8 url: string
9 state: string
10 stateType: string
11 priority: string
12 labels: string[]
13 parent: { identifier: string; title: string; labels: string[] } | null
14 assignee: string | null
15 cycle: number | null
16 milestone: string | null
17 description: string
18 children: IssueChild[]
19 comments: IssueComment[]
20 links: { title: string; url: string }[]
21}
22
23export type IssueSource = { id: string; from: 'root' | 'sites' | 'pinned'; detail: string }
24
25export type SessionEntry = {
26 sessionId: string
27 checkout: string
28 label: string
29 issue: string | null
30 title: string | null
31 state: string | null
32 stateType: string | null
33 /** Pinned, or on a branch this session switched to itself. */
34 claimed: boolean
35 updatedAt: number
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 'linear-workflow': {
41 issue: Issue | null
42 source: IssueSource | null
43 error: string | null
44 isBandHidden: boolean
45 pinned: string | null
46 pinnedBy: 'command' | 'manual' | 'held' | null
47 warned: string[]
48 others: SessionEntry[]
49 conflictsWarned: string[]
50 claimedBranch: string | null
51 }
52 }
53}
54