Imported from dork-labs/marketplace (
plugins/flow/skills/linear-adapter/SKILL.md). Install upstream withnpx skills add dork-labs/marketplace --skill linear-adapter. Copyright stays with the author.
Linear Adapter — the v1 PMClient
Is this the adapter to use? A project can override this shipped adapter with its own, at
.agents/flow/adapters/linear/SKILL.md. Before acting on anything below, runnode --experimental-strip-types "<flow-root>/scripts/config-files.ts": if theadapter.pathit prints is not this file, stop reading this one and read that file instead. If the check cannot run or its output cannot be read, stop.
Flow root. This skill lives at
<flow-root>/skills/linear-adapter/SKILL.md. If you reached it via a symlink (.claude/skills/flow__*or.agents/skills/flow__*), resolve the real path first (realpath <path>): the flow root is two directories above the skill directory. Every<flow-root>/...reference below is relative to that root.
What this is. The
/flowengine's work model + tracker adapter. It is the v1 realization of thePMClientcontract (spec §3): it normalizes Linear into one genericWorkItemshape and fulfils every capability verb, so every generic stage skill and the dispatch policy can work without ever touching a Linear-specific field or a tracker API string.This is a prose contract, not code. In v1 there is no DorkOS server and nothing imports a TypeScript adapter at runtime — the engine is server-free and skill-based. The agent reads this skill and follows it. The typed
interface PMClientdocumented in../../SPEC.mdis what the P5 server build promotes this prose contract into. Building a TS adapter class now would be dead code (this repo forbids dead code), so we do not.
The one rule
All /flow tracker I/O lives here. No other flow skill or /flow:* command
may contain a mcp__linear__* / mcp__plugin_linear_linear__* string, a
composio invocation, or a LINEAR_* slug. Generic stage skills call this
skill ("via the linear-adapter, claim DOR-123") instead of touching the tracker.
This gives the agnosticism win ("all Linear in one place") and a single audit
surface for every tracker write (spec §Security). An executable grep guard
(packages/flow/src/__tests__/tracker-confinement.test.ts) enforces this for the
flow bundle.
The P5 server build swaps this skill for a typed PMClient; a second adapter
(Jira / GitHub Issues) proves the agnosticism. Because the generic layer only
ever speaks WorkItem + the verbs, that swap is additive, not a rewrite.
Accessing Linear — the connection is config-driven
The adapter hardcodes no team, workspace, or account. It reads WHERE and HOW it reaches Linear from config, fresh on every run — so pointing flow at a different team, workspace, or account is a config edit, never a change to this skill:
| Config value | What it is | Where it lives |
|---|---|---|
connection.team.key |
team key / issue prefix (the token before the dash in an id) | config.local.json (null in committed) |
connection.team.id |
the team's Linear id, when a call needs to scope by it | config.local.json (null in committed) |
connection.workspace.slug |
the workspace / org slug | config.local.json (null in committed) |
connection.transport |
which access path is primary — cli or mcp |
config.json (committed policy) |
secrets.trackerAccount |
the account handle the CLI acts as | config.local.json (secret, out-of-band) |
Both settings files live in the project, never in the plugin: .agents/flow/config.json (committed policy) and .agents/flow/config.local.json (this machine's credentials and coordinates, ignored by git; in a git worktree it may be the main checkout's copy). Run node --experimental-strip-types "<flow-root>/scripts/config-files.ts" and read the committed and local paths it prints; never guess them. Values in the local file override the committed one.
/flow:init sets all of these. A real team key/id and workspace slug live in the
gitignored config.local.json, never in the shared committed config — a concrete
id baked into the shipped template would re-hardcode the agnosticism this seam
exists to preserve. Below, <teamKey>, <teamId>, <workspaceSlug>, and
<trackerAccount> stand in for those configured values.
Two transports, selected by connection.transport
Both paths reach the same team and workspace; connection.transport decides
which is primary. The adapter is the only place either path appears.
cli (the default) — the Composio CLI, account-pinned. Works even when the
MCP server is unauthenticated (see the composio-cli skill). Linear slugs are
LINEAR_*. Always pass --account "<trackerAccount>", read fresh from
.agents/flow/config.local.json → secrets.trackerAccount (set by /flow:init); never
hardcode an account name here. This is the safe default precisely because the
acting identity is pinned by config: any other connected account — a maintainer's
personal login, or unrelated artblocks work — must never receive flow's
writes, and the --account "<trackerAccount>" flag is the only thing keeping them
out (there is no team filter on the list slug — see below). An adopter points flow
at a dedicated bot account by setting trackerAccount, not by editing this skill.
composio execute LINEAR_LIST_LINEAR_TEAMS --account "<trackerAccount>" -d '{}'
composio execute LINEAR_LIST_LINEAR_PROJECTS --account "<trackerAccount>" -d '{}'
# Discover other slugs by intent:
composio search "list linear issues" "create a linear issue" --toolkits linear
mcp — the in-session Linear MCP server. Tool names are
mcp__plugin_linear_linear__* (e.g. list_issues, save_issue, save_comment,
get_authenticated_user); the prose shorthand for the family is mcp__linear__*.
It is faster and richer than the CLI, but it carries a real footgun the cli
path does not:
The MCP server acts as whoever authenticated (OAuth'd) it — which is NOT necessarily
secrets.trackerAccount. Unlike the CLI, the MCP transport takes no--accountflag; its acting identity is fixed at OAuth time. If the MCP is authenticated as a different identity thantrackerAccount, every flow write silently lands as that OAuth identity — the exact silent-wrong-account failure the account-pinnedclipath prevents.So before you use
mcpfor any write: confirm the MCP is authenticated as the same identity assecrets.trackerAccount— readget_authenticated_userand check it matches the configured account. If the server is unauthenticated, runmcp__linear__authenticateand complete OAuth as that account. If it is authenticated as a different account, do not write through MCP — fall back to theclitransport (which is account-pinned) rather than acting as the wrong identity.
Query hygiene (applies to every read):
- MCP only: always pass
includeArchived: falseonlist_issues— Linear defaults totrue, pulling archived noise from deleted projects. The ComposioLINEAR_LIST_LINEAR_ISSUEShas noinclude_archivedparam (see the schema gotcha below); passing it errors on a paginated call. - Do not pass
includeMembers: trueonlist_projects— it triggers GraphQL query-complexity errors. Fetch member/lead detail separately.
Composio CLI — verified schemas, response shapes & gotchas
The Composio fallback diverges from the MCP transport in ways that bite silently.
These are empirically verified against composio v0.2.31 and the live DorkOS
workspace; trust them over a slug's --get-schema guess when they conflict.
-
Prefer one GraphQL read over the field-poor list slug.
LINEAR_RUN_QUERY_OR_MUTATIONis the richest read path and resolves the two worst traps below (missing category, flattened labels) in a single call — requeststate{ name type }for the category andlabels{ nodes{ name parent{ name } } }to recover the namespace (reconstructagent/readyasparent.name + "/" + name;ready→parent:agent,research→parent:type). It also returnsestimate(size) andpriority, and accepts a label filter (issues(filter:{ labels:{ name:{ eq:"ready" } } })). The result nests under.data.data.team(note the doubledata). Reach for the per-verb slugs below for writes and simple lookups; reach for GraphQL when you need the full dispatch-ready shape. -
LINEAR_RUN_QUERY_OR_MUTATION's input key isquery_or_mutation— notquery. The payload is-d '{"query_or_mutation": "<the query>", "variables": { … }}'. Aquerykey fails Composio's own schema validation withUnknown keybefore the call ever reaches Linear, so the error names your payload, not your GraphQL (verified againstcomposiov0.2.31, 2026-09-09). -
Team scope is the adapter's job on every read — nothing in the API supplies it.
getBacklogSnapshotandgetEligibleWorkare team-scoped reads: the team is the configuredconnection.team.id/connection.team.key, never "whatever the account can see". Via GraphQL, scope through the team node —team(id: "<teamId>") { issues(…) }— and never a top-levelissues(…). Via the field-poor list slug there is no team filter to pass at all (see below), so post-filter the results by identifier prefix (<teamKey>+-) before any policy or write pass consumes them. A workspace holds many teams — the reference workspace has five, one of them a user-feedback intake team whose issues are live conversations with real people — so an unscoped read hands the groom's write pass items it must never relabel, close, or reassign (verified hazard, 2026-09-10). -
Slugs are doubly-prefixed; there is no
LINEAR_GET_ISSUE. The verbs areLINEAR_LIST_LINEAR_ISSUES,LINEAR_GET_LINEAR_ISSUE,LINEAR_LIST_LINEAR_PROJECTS,LINEAR_GET_LINEAR_PROJECT,LINEAR_LIST_LINEAR_TEAMS,LINEAR_LIST_LINEAR_LABELS,LINEAR_LIST_LINEAR_STATES. The un-doubledLINEAR_GET_ISSUEdoes not exist (ToolRouterV2_ToolNotFound). When a slug 404s, rediscover it withcomposio search "<intent>" --toolkits linear. -
getCurrentUserisLINEAR_GET_CURRENT_USER, notLINEAR_GET_AUTHENTICATED_USER. The second is the MCP tool name with a Composio prefix, and Composio answers it withToolRouterV2_ToolNotFound. Pass-d '{}'. The user usually comes back under.data.user, but the wrapper can nest it elsewhere, so check the keys before readingidandname, and throw if no user object is found. If the slug call fails, the same read isquery { viewer { id name } }throughLINEAR_RUN_QUERY_OR_MUTATION(verified againstcomposiov0.2.31, 2026-09-15). -
LINEAR_GET_LINEAR_ISSUEtakes the human identifier —-d '{"issue_id":"DOR-149"}', no UUID needed. It returns the fields the LIST call omits:state.type(the category),estimate(thesize),priority,labels.nodes,project,parent. Caveat: itsrelationsfield comes backnullvia Composio — the typedblocks/blockedBygraph that feeds dispatch eligibility is not reliably populated. Anullhere means unknown, never "no blockers": "neutral" is only how the dispatch policy ranks an absent graph (see Graceful degradation), and reading it as a finding is how a blocked item gets claimed. Cross-check the graph the reliable way before any decision rests on it. -
The relation graph has exactly one reliable read: GraphQL, through the team node. For any decision that depends on the graph — dispatch blockers, a duplicate adjudication, a cross-team sweep — request
relations { nodes { type relatedIssue { identifier state { type } } } }(plusinverseRelationsfor the incoming edges) insideteam(id: "<teamId>") { issues(…) }viaLINEAR_RUN_QUERY_OR_MUTATION, and treat every other path's answer as unknown (verified 2026-09-09). The cross-team case is the one that bites: a related issue'sidentifiercarries its own team prefix, so an edge can point out of the configured team. Read those edges — "blocked by another team's item" is exactly what the graph is for — but never write to the far end of one: the sweep's scope stays the<teamKey>-prefixed items, and the related issue is evidence, not work. -
LINEAR_LIST_LINEAR_ISSUEShas a tiny filter schema — no team filter. Allowed top-level keys are onlyafter, first, project_id, assignee_id, original_cursor, include_transitions, cursor_was_corrupted. There is noteam_id(passing it is silently dropped on the first call and hard-errors on a paginated one) and noinclude_archived. Scope to a project withproject_id; there is no team scoping at all, and the unfiltered list is WORKSPACE-wide, not team-scoped. One account reaches every team in the workspace it connects to (connection.workspace.slug), so--account "<trackerAccount>"prevents cross-account leakage only — it is what keeps any other connected account (e.g.artblocks) out, and it is not a team filter. Post-filter by identifier prefix, per the team-scope rule above (verified against a five-team workspace, 2026-09-10 — an account that connects to one workspace is not thereby scoped to one team). -
Response shapes: list →
.data.issues[]+.data.page_info{ hasNextPage, endCursor }(not.data.items); get →.data.issue; projects →.data.projects[]; teams →.data.teams[]. When a call needs the team id, use the configuredconnection.team.id(resolve it fromconnection.team.keyviaLINEAR_LIST_LINEAR_TEAMSif it is not set); never inline a literal id here. -
Large reads spill to a file. A big result returns
{ successful: true, storedInFile: true, outputFilePath, tokenCount }with no inline data — readoutputFilePathwithjq(don't slurp it into context). Paginate by passing{ first, after: <endCursor> }and only those keys (adding any filter key to anaftercall trips schema validation). -
Labels arrive FLATTENED to leaf names. A grouped Linear label surfaces on the issue as its bare leaf:
ready(notagent/ready),claimed/completed/needs-input(notagent/*),verify/ideate(notstage/*),task/research/idea/meta(nottype/*). The group prefix is a separate parent label that is not present onlabels.nodes. This is a real normalization trap: the dispatch policy (node --experimental-strip-types "<flow-root>/scripts/dispatch.ts") matches the literalagent/ready, so a raw Composioreadywill silently fail eligibility. The adapter MUST re-namespace leaf → group before handinglabels[]to the policy. Recover the group↔leaf map fromLINEAR_LIST_LINEAR_LABELS(team-scoped; distinguishes container vs leaf labels). -
The category (
state.type) is absent from the LIST call. A listed issue'sstateis{ name }only — notype. Since the generic layer matches on category, resolve it viaLINEAR_GET_LINEAR_ISSUEper item, or once viaLINEAR_LIST_LINEAR_STATES(a team-scopedname → typemap). Atriage-type state is real here (see the category table's † note). -
Project
stateisnullvia Composio.LINEAR_LIST_LINEAR_PROJECTSreturns{ id, name, state: null }— the project workflow-state category is not populated, so the dead-project dispatch tier degrades to a no-op (the documented graceful-degradation behavior; here it is always neutral). -
Comment-writes on ARCHIVED issues fail with a misleading error.
commentCreate(viaLINEAR_CREATE_LINEAR_COMMENTor raw GraphQL) on an archived issue returnsEntity not found: Issue— whileissue(id:)reads and evenissueUpdateon the same UUID still succeed, so the error looks like a bad id or a missing OAuth scope. It is neither (verified 2026-07-13, DOR-306). When a comment-write returns "Entity not found" on a UUID that reads fine, check the issue'sarchivedAtvia GraphQL before suspecting scopes or transposed UUIDs. Archived issues stay out of normal flow via theincludeArchived: falsehygiene rule; this bites only when an item is addressed directly by identifier. -
LINEAR_CREATE_LINEAR_COMMENTwants camelCaseissueId— unlikeLINEAR_GET_LINEAR_ISSUE's snake_caseissue_id. Allowed keys:body, issueId. The casing convention varies per slug; trust the validation error's allowed-keys list. -
LINEAR_LIST_COMMENTScannot filter by issue. Its only keys areafter, first, before, includeArchived. To read one issue's comments back (the round-trip check), useLINEAR_GET_LINEAR_ISSUE— itscomments.nodescarries them — or a GraphQLissue(id:){ comments { nodes { id body } } }. -
Text search is GraphQL-only; no
LINEAR_SEARCH*slug is worth reaching for. The working pattern is oneLINEAR_RUN_QUERY_OR_MUTATIONread:searchIssues(term: $q, first: N, includeArchived: false) { nodes { identifier title state { type } } }(verified 2026-09-09). It searches the whole workspace and returns cross-team results, so scope or post-filter it by identifier prefix exactly as for the list slug before a policy or write pass touches the hits — search is the easiest place to pull another team's issues in by accident.
Building the groom snapshot (getBacklogSnapshot() via Composio)
Verified against the live workspace during the first groom (2026-08-03); team-scoping corrected 2026-09-10.
- Every pull goes through the team node. All three issue pulls below — core
fields, the relation graph, and the closed titles — are
team(id: "<teamId>") { issues(…) }, never a top-levelissues(…). This is not hygiene: the groom's write pass ingests whatever the snapshot contains, so a snapshot carrying a sibling team's items lets it relabel, cancel, or reassign issues that are user-facing communication surfaces — a user-feedback intake team's items are somebody's open conversation, not backlog (verified hazard, 2026-09-10). Check the pulled identifiers all carry the<teamKey>prefix before handing the snapshot on. - The GraphQL complexity cap is 10000, and
relationsare expensive. A plain-fieldteam(id:) { issues }query paginates fine atfirst: 150-250; addingrelations+inverseRelations+childrencosts roughly 230 points per issue, so the relation pull needsfirst: 40or less. Build the snapshot as two paginated team-scoped pulls merged by identifier — core fields at 150, the relation graph at 40 — rather than one query that trips the cap. - Pull closed items separately as titles only, through the same team node
(
team(id:) { issues(filter: { state: { type: { in: ["completed","canceled"] } } }, first: 250) }) — the duplicate/shipped matching passes need names, not full bodies. - Re-namespace labels (leaf →
family/leaf) and resolve state categories exactly as forgetEligibleWork; projectstateDOES come back on a direct GraphQLprojectsquery (unlikeLINEAR_LIST_LINEAR_PROJECTS), so prefer GraphQL here — the groom's dead-project checks (GRM-9/GRM-11) need it. A project is a workspace-level object and can span teams, so narrow the project set to the ones the snapshot's own items reference rather than handing the groom every project the account can see.
Bulk-write traps (the groom write pass)
Each of these cost a failed batch on 2026-08-03; none produces a helpful error:
- A literal
$wordanywhere in an inlined mutation string breaks the call. Composio scans the whole query text for$identifierand demands a matching GraphQL variable, so a description containing a shell snippet or template literal ($sessionId,${client}) fails with "Query contains variable syntax for: X". Pass all prose through real GraphQL variables —mutation($id: String!, $desc: String!) { issueUpdate(id: $id, input: { description: $desc }) { success } }— never string-interpolated into the query body. - A description write REPLACES the whole field — including any signature
already in it. Read the current description, strip any
agent:provenance/flow:provenanceline, then write the new body with exactly one signature as its last line. Appending without stripping leaves two signatures in one field and no rule for which a reader should believe. Note that the signature's JSON can legitimately contain a$, so it goes through a real GraphQL variable like any other description prose (see the trap above). - A label write REPLACES the entire label set.
issueUpdate'slabelIdsis not additive. Compute the union against a fresh read taken immediately before the write — a union computed from an earlier snapshot silently deletes labels a concurrent session added in between (observed live: another session's labels appeared mid-groom). - Aliased mutations partially apply. A failed alias does not roll back its
siblings. Batch 5-10 aliases per call, check each alias's
successin the response, and re-read a sample after every batch. - Project
stateaccepts onlybacklog | planned | started | completed | canceled.pausedis rejected with "No project status found for type paused" even though Linear the product has the concept. Usebacklogfor "real work, not shipped, not active". projectCreaterejects a description longer than 255 characters — "description must be shorter than or equal to 255 characters" (verified 2026-09-10). A project's description is a one-line summary field, not a body, so a programme charter pasted into it fails the whole mutation. Write a single sentence there and put the long prose — the## Goal/## Scope/## Anchor & provenancesections of<flow-root>/templates/records/project.md— on the project's umbrella issue (thetype/metaanchor), which has no such cap.- Never close a project out by hand. Moving a project to
completed/canceledgoes through thecompleteProjectverb (writes table above), which owns the open-issues guardrail and the live-data check that goes with it. Bulk passes are exactly where that check gets skipped, and a wrongly-closed project hides its open issues from dispatch permanently.
The WorkItem normalization shape
Every read verb returns work normalized into this shape, so the generic layer never sees a Linear field name. The adapter's job is the mapping in the third column.
WorkItem {
id, // tracker-native id (Linear node id)
identifier, // human key, e.g. "DOR-123" — the worktree/branch key
title,
description,
type, // idea|research|hypothesis|task|monitor|signal|meta
stateCategory, // backlog|unstarted|started|completed|canceled
// MATCHED ON CATEGORY, NEVER ON DISPLAY NAME (see below)
stateName, // display only ("In Progress", "Triage", …) — never matched on
priority, // 0–4 (0 none · 1 urgent · 2 high · 3 medium · 4 low)
size, // number (points, native) | string (t-shirt) — promotion + ranking
project, // { id, name, stateCategory, lead }
parent, // parent WorkItem id (sub-issue) or null
relations { // the dependency graph — read from typed Linear relations
blocks[], // items THIS blocks
blockedBy[], // items that block THIS (feeds dispatch eligibility)
children[], // sub-issues
relatedTo[],
duplicateOf?,
},
labels[], // ALL labels, including stage/* and agent/*
assignee, // → classifyOwnership(): mine|reviewer|other|unassigned
agentDisposition // ready|claimed|completed|needs-input (derived from agent/* labels)
}
stateCategory is matched on CATEGORY, never on display name
Linear workflow states each belong to one of five categories:
backlog · unstarted · started · completed · canceled. The display name is
team-customizable ("Triage", "In Progress", "Shipped", …) and varies per team —
matching on it is brittle and breaks the moment a team renames a state. The
generic layer therefore branches only on stateCategory; stateName is
carried for display only. The adapter resolves a state to its category via
list_issue_statuses (each status carries its type/category) and maps:
Linear state type |
stateCategory |
|---|---|
triage |
backlog † |
backlog |
backlog |
unstarted |
unstarted |
started |
started |
completed |
completed |
canceled |
canceled |
duplicate |
unprojectable ‡ |
† Linear's Triage feature adds a sixth state type, triage, beyond the
five StateCategorySchema values. It is the un-triaged holding state (an item
that has not yet been classified or routed). Normalize it to backlog —
non-terminal, so it lists and recovers like any open item — but note it is kept
out of dispatch by the absent agent/ready label, not by its category: an
un-triaged item carries no agent/* label, so filterEligible drops it
regardless. Readiness (agent/ready) is produced by the shaping stages, not by
this adapter and not by a state category: TRIAGE applies it on accept (both
routes: simple readied for EXECUTE, complex readied for IDEATE; see triaging-work)
and DECOMPOSE applies it to the execute-ready tasks it emits (see
decomposing-work). The adapter's job is the category mapping above (moving an
accepted item into a true backlog/unstarted state); producing the agent/ready
signal that lets filterEligible pick the work up is the stage skills' job, so an
item lacking it is held out of dispatch by the absent label, never by its
category. Never fabricate a distinct triage category — the typed enum has only
five values.
‡ Linear's Duplicate workflow state has type duplicate — a seventh state
type that maps onto NOTHING in the generic model. An item parked there is
unprojectable: neither open nor closed, invisible to the dispatch policy,
the loop, and every backlog view (the first DorkOS groom, 2026-08-03, found six
items stranded this way). The adapter must never normalize duplicate to a
real category; it must flag these items so a groom routes them out.
Snapshot-time obligations (the two Linear-only groom checks). The generic
audit-backlog.ts oracle is tracker-neutral, so two Linear-specific conditions
are this adapter's job to surface whenever it builds a getBacklogSnapshot():
- Items in a
triage-type state are un-triaged intake. A groom must route every one of them out (to a real backlog/todo state, with atype/*label) — nothing stays in Triage after a groom. - Items in the
duplicate-type state must leave it: verify the duplicate target still makes sense, create a realduplicaterelation vialink, then move the item to a canceled-category state. If verification shows the item is actually live unfinished work, move it to a real open state instead — never leave anything in the unmappable state either way.
type, agentDisposition, priority, size mappings
-
type← thetype/*label group (idea, research, hypothesis, task, monitor, signal, meta). Mutually exclusive — exactly one per issue. -
agentDisposition← theagent/*label group:agent/ready→ready,agent/claimed→claimed,agent/completed→completed,agent/needs-input→needs-input. Theagent/*labels are the durable state machine (spec §3, the Huginn durability lesson) — not the ephemeralplan/checklist field, which does not survive a restart. -
priority← Linear's native priority field (0none,1urgent,2high,3medium,4low). Native field, never apriority/*label. -
size← Linear's native estimate field, passed through as the number Linear gives you (Linear's estimate is numeric on every scale it offers — Fibonacci, exponential, linear). Native field, never a label. Do not stringify it and do not convert it to a t-shirt letter. Drives the dispatch size tier and the sub-issue promotion rule.sizeis a union by design:number | string. A tracker with a numeric estimate emits the number; a tracker with no numeric field emits a t-shirt string (xs·sm·md·lg·xl·xxl). The dispatch policy maps both onto one shared ordinal scale, so an adapter never has to invent a conversion. Emit your tracker's native shape unconverted.Never compare a
sizeto a threshold directly.decomposition.subIssueThresholdis a t-shirt word ("xl"by default), so on a numeric estimatesize ≥ thresholdwould be comparing two different vocabularies —8 ≥ "xl"is not a question either side can answer. Compare ordinals, which is the whole point of the shared scale:sizeOrdinal(item.size) >= sizeOrdinal(decomposition.subIssueThreshold)sizeOrdinalis exported from@dorkos/flow(dispatch-policy.ts) and resolves both vocabularies onto one scale —sizeOrdinal(8)andsizeOrdinal("xl")are both4. An absent or non-conformant estimate returnsundefined(neutral), and a neutral size never promotes. Use it rather than inventing a numeric→t-shirt conversion in your adapter.
Presenting a work item to a human
Every WorkItem this adapter returns carries title alongside identifier, so
the title is always in hand with no extra fetch. Never surface a bare tracker
key to a human reader. Whenever a stage skill, a /flow command, or the loop
reports a work item to a person (terminal output, a status line, a report, an
AskUserQuestion option, a PR or commit body), render the identifier followed by
the title:
DOR-157 - Connect Claude Code account
- Order + separator. Identifier first, then a space-hyphen-space, then the
title. The hyphen keeps the line readable when the title itself contains a
colon (
DOR-149 - Harness portability: dry-run loop). If a title is long or unwieldy, a 3-6 word summary may stand in for it, but the bareDOR-157alone is never acceptable. - Link the identifier, not the title. Where the surface supports a link, the
identifier is the anchor and the title stays plain text, so variable title
punctuation can never break link parsing. The structure is identical on every
surface; only the link syntax adapts:
- Markdown and Obsidian:
[DOR-157](<issue-url>) - Title(a standard link, never an Obsidian[[wikilink]], since the target is an external tracker URL). - HTML:
<a href="<issue-url>">DOR-157</a> - Title. - Slack mrkdwn:
<<issue-url>|DOR-157> - Title. - Auto-linking surfaces (Linear, GitHub, Slack with the Linear app): the bare
DOR-157 - Titlealready links the key, so do not double-link.
- Markdown and Obsidian:
- Tracker comments are exempt. Comments this adapter posts live inside the tracker, whose own UI already shows the title, so pairing there is redundant. This convention governs agent-to-human surfaces outside the tracker.
The 18 capability verbs
Each verb is mapped to its concrete Linear MCP call (primary) and Composio
fallback. The generic layer only ever names these verbs; the adapter owns the
call. Nine reads + nine writes: the contract's 16 required verbs
(../../adapters/SPEC.md section 3), the groom-only
getBacklogSnapshot read this adapter adds on top, and the contract's optional
completeProject, which this adapter supports. (An earlier revision titled
this section "13" while the table already held more — the table is authoritative.)
Reads
| Verb | What it returns | Linear MCP (primary) | Composio fallback (--account <trackerAccount>) |
|---|---|---|---|
getCurrentUser() |
the authenticated account (resolves identity.agent: "auto", drives classifyOwnership) |
mcp__plugin_linear_linear__get_authenticated_user |
LINEAR_GET_CURRENT_USER |
getProjects() |
projects normalized to { id, name, stateCategory, lead } |
mcp__plugin_linear_linear__list_projects (no includeMembers) |
LINEAR_LIST_LINEAR_PROJECTS |
resolveProject(nameOrId) |
one WorkItemProject for a fuzzy name / spec slug / umbrella identifier (case-insensitive). Returns ALL matches when more than one, so the caller disambiguates. The project-addressing primitive for /flow <project>. |
list_projects then match on name; resolve an umbrella id via get_issue → its project |
LINEAR_LIST_LINEAR_PROJECTS then match (+ LINEAR_GET_LINEAR_ISSUE for an umbrella id) |
getProject(id) |
one project with its children[] (project issues), its umbrella issue (the type/meta anchor), and a progress rollup (done/total, current stage). |
list_projects (the one) + list_issues (project filter, includeArchived: false) |
LINEAR_GET_LINEAR_PROJECT + LINEAR_LIST_LINEAR_ISSUES (project filter) |
getProjectWork(projectId) |
getEligibleWork scoped to one project: the candidate WorkItem[] for project-scoped dispatch (same normalization + graceful-degradation rules as getEligibleWork). |
list_issues (project filter, includeArchived: false) |
LINEAR_LIST_LINEAR_ISSUES (project filter) |
getEligibleWork() |
WorkItem[] of candidate work for the dispatch policy (issues for the configured team connection.team.key, includeArchived: false) |
mcp__plugin_linear_linear__list_issues |
LINEAR_LIST_LINEAR_ISSUES |
getInbox(agent) |
the agent's inbox (see shape below) — assigned-to-me + @mentions + new comments since the last tick | list_issues (assignee filter) + mcp__plugin_linear_linear__list_comments |
LINEAR_LIST_LINEAR_ISSUES + LINEAR_LIST_COMMENTS |
getRelations(item) |
the typed relation graph (blocks/blockedBy/children/relatedTo/duplicateOf) for a single item |
mcp__plugin_linear_linear__get_issue (returns relations) |
LINEAR_GET_LINEAR_ISSUE |
getBacklogSnapshot() |
the GROOM input (grooming-backlog): EVERY non-archived item of the configured team regardless of state — open items fully normalized (relations, re-namespaced labels, project stateCategory), plus closed items at least as { identifier, title, stateCategory } for duplicate/shipped matching. Unlike getEligibleWork, nothing is filtered toward dispatch; the snapshot feeds scripts/audit-backlog.ts as { items, opts: { agentIdentity } }. See "Building the groom snapshot" below. |
list_issues paginated with a team filter and no state filter + list_projects + list_issue_statuses (category map) + label-group recovery |
LINEAR_RUN_QUERY_OR_MUTATION, paginated (see the snapshot notes) |
Writes (all confined here; the single audit surface)
| Verb | Durable effect | Linear MCP (primary) | Composio fallback (--account <trackerAccount>) |
|---|---|---|---|
claim(item) |
Writes the agent/claimed label AND moves state into a started-category state — both, in that order, so the claim survives a restart. The label is the durable claim signal (state machine = labels, not the plan field). |
mcp__plugin_linear_linear__save_issue (set labels + stateId) |
LINEAR_UPDATE_ISSUE |
transition(item, stage) |
Sets the stage's stage/* label and, when the stage carries one, its stateCategory (resolved to a concrete state of that category for the team). Drives the stage→projection round-trip. |
save_issue (labels + stateId) |
LINEAR_UPDATE_ISSUE |
comment(item, body) |
Posts a comment. The agent's own comments carry identity.marker (— 🤖 /flow) so the comment-response rules can recognize them in shared-account mode, and the agent:provenance signature line ("Provenance: signing outward writes" below) so a later reader can route a follow-up back to this session. |
mcp__plugin_linear_linear__save_comment |
LINEAR_CREATE_LINEAR_COMMENT |
assignToHuman(item) |
Sets the issue assignee to the reviewer / authenticated human (triggers a Linear notification). Used at the review gate and in handoff. | save_issue (assigneeId) |
LINEAR_UPDATE_ISSUE |
attachEvidence(item, evidence) |
Attaches proof-of-completion (browser recording, test summary, PR link) to the issue via its external URLs / attachment links per evidence.attachTo. |
save_issue (links/attachments) |
LINEAR_UPDATE_ISSUE |
needsInput(item, question) |
The elicitation primitive — four atomic effects: (1) post the question as a comment (multiple-choice when possible, carrying the marker and the agent:provenance line — this is the one write whose whole purpose is to be replied to, so the reply has to be routable); (2) apply the agent/needs-input label; (3) assignToHuman; (4) stop (the loop parks here). Resumes only on a non-agent reply (see getInbox). |
save_comment + save_issue (label + assignee) |
LINEAR_CREATE_LINEAR_COMMENT + LINEAR_UPDATE_ISSUE |
link(a, b, type) |
Creates a typed relation (blocks, related, duplicate, …) between two items. Typed relations live in the graph, never in description prose. |
save_issue (relation) |
LINEAR_UPDATE_ISSUE |
createSubIssue(parent, spec) |
Creates a child issue under parent (sub-issue promotion: fires only when sizeOrdinal(size) >= sizeOrdinal(decomposition.subIssueThreshold), threshold default "xl"). The new issue's canonical home is the per-task issue field in 03-tasks.json. The description it authors carries the agent:provenance signature as its last line; a later rewrite of that description replaces the signature rather than appending a second one. |
mcp__plugin_linear_linear__save_issue (parentId set) |
LINEAR_CREATE_LINEAR_ISSUE |
completeProject(project, outcome) (optional verb — supported here) |
Moves a whole project (not an issue) into a terminal state: outcome: 'completed' when its work shipped, 'canceled' when it was abandoned. Linear's project state accepts only backlog | planned | started | completed | canceled. Never move a project to completed/canceled while it holds open issues — dispatch drops the issues of a terminal project, so an open issue left inside one vanishes from the ready queue and the starvation count permanently, and only a human reading the tracker ever finds it. So verify from live data at call time: list the project's issues (project filter, includeArchived: false), resolve each category, and if any is backlog/unstarted/started, refuse loudly and name them — never trust an issue list the caller passed in. Idempotent: already in the requested terminal state is a no-op; the other terminal state is a real change and re-runs the check. Degradation: via Composio the project's own state reads back null on LINEAR_LIST_LINEAR_PROJECTS, so read it from a GraphQL projects query, where it is populated; if neither the state nor the issue list can be read, refuse rather than guess that an unseen project is empty. |
save_project (project state) |
LINEAR_RUN_QUERY_OR_MUTATION (projectUpdate, id + state as real GraphQL variables; check success) |
completeProject is the contract's one optional verb (contract 1.1.0, SPEC
section 3, Optional verbs). This adapter declaring it supported is what lets
a caller name it — and a caller must still carry its own fallback, since another
tracker's adapter may not support it. One honest caveat on that declaration: the
Composio binding is the verified path, while the MCP tool name has not been
exercised against a live server. Before the first write on the mcp transport,
list the server's tools and find the project-write sibling of save_issue —
save_project on this server family, update_project on an older one. If neither
is there, the server cannot write projects: treat the verb as unsupported on that
transport and use the cli path instead.
Slugs shown in the Composio column follow the
LINEAR_*convention; confirm the exact slug withcomposio search "<intent>" --toolkits linearif a call errors — Composio occasionally revises slug names.LIST_LINEAR_TEAMSandLIST_LINEAR_PROJECTSare confirmed in use today.
Provenance: signing outward writes
Every body this adapter writes outward — every comment, the needsInput
question, any description it authors — carries a hidden, machine-readable
signature line as its last line, so a later reader can route a follow-up back
to the session that wrote it:
<!-- agent:provenance {"v":1,"harness":"claude-code","sessionId":"…","account":"work","host":"…","surface":"dorkos"} -->
The canonical spec is ../../docs/provenance.md —
read it there and do not redefine it here. It owns the marker name, the eight
wire fields, the per-write cadence, the omit-never-fabricate and valid-JSON
discipline, the never-an-email rule, the public-repository rules, the emission
list, and the reader rules. It is tracker-neutral on purpose: every adapter signs
the same way, so a reader can parse a signature without knowing which tracker
produced it. Readers also accept the legacy flow:provenance name; this adapter
never emits it.
What belongs here is the part that is specific to this tracker:
- Round-trip verified. This tracker preserves HTML comments byte-for-byte in both issue descriptions and comment bodies — confirmed by API round-trip on both surfaces. So the signature survives a write→read cycle intact and the reader rules can rely on it.
- A human editing in the rich-text editor can strip the line. The tracker's editor is rich-text, not raw markdown, and a person editing around a hidden comment can drop it without noticing. That is expected, and it is why the reader rule treats a missing signature as "route as unsigned" rather than as evidence about the session.
- Description writes go through the
$wordand replace-whole-field traps documented u
*Truncated - read the full file at https://github.com/dork-labs/marketplace/blob/acb056543f0008db68db32df8c4e03108812c81c/plugins/flow/skills/linear-adapter/SKILL.md.