No description.

Personal Linear workflow conventions for AI-assisted development. Loads when an agent needs to create, transition, read, or update a Linear ticket — supplies the defaults so the agent doesn't have to guess.
TEAM and TEAM-N in examples are placeholders for the team and issue key resolved from the connected workspace. They are never literal configuration; the same plugin is used across workspaces with different team names.
spec-flow uses Linear as a contract host. ndr uses Linear (via reference strings) to point at tickets. Both treat Linear as a tool they consume, not own. The conventions — what team, what labels, what status means what — belong somewhere stable that other plugins can defer to.
That's this plugin.
linear — Single skill carrying the conventions and MCP call patterns. Renders as /linear (skill name matches the plugin name). Triggers on ticket creation, transitions, reads, and queries.Installing Linear exposes its Markdown procedure and the explicit $linear:setup-codex-agents skill; it does not register linear:linear-ops. Choose either user or project scope through that skill. The active installed SKILL.md supplies the plugin root for the helper, so setup never guesses a cache path or version. Use an absolute path to a verified released AgentForge binary and, for isolated testing, explicitly set and validate the intended CODEX_HOME for every lifecycle command. Project setup also requires a validated literal project-root argument.
In a fresh Codex session, run the selected-scope check immediately before each exact agent_type: "linear:linear-ops" dispatch. A failed or stale check blocks that registered-role path. Each native dispatch also receives the exact installed mcp-gotchas.md path or its verbatim content, plus the PM layer-policy.md path or content when its intent declares a parent. The operator writes only that decided intent; a missing connector or required context blocks before a write, and a read-only request stays on the Linear read path.
The explicit Markdown-procedure fallback either embeds the installed agents/linear-ops.md body in a generic bounded child with agent_type omitted, or follows that installed body inline literally. Neither route claims registration. Plugin removal does not remove registered roles; remove each selected scope separately through the receipt-based setup action.
Codex receives no translation of Linear's Claude haiku alias, tool allowlist, or permission settings. The registered role inherits Codex model and effort; record values from runtime metadata when observable, otherwise record them as unknown. The body boundary survives as a procedure, while the package records the missing mechanical tool filter as a Codex loss.
groom, retro, breakdown) propose ticket actions; linear applies any approved transitions and owns the field conventions pm's issue-shape.md defers to.Decision is one of this plugin's type labels. Tickets that capture decision points get the label; the captured decision itself lives as an ndr atom.hooks/register.tsx 150 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ticket } from '../types'
5
6// Active tickets for this session, most recent first. $.state survives hot
7// reloads; team keys seen in issue results persist across sessions in $.store.
8const tickets = atom({ plugin: 'linear', key: 'tickets' } as const, [])
9const TEAM_KEYS = 'teamKeys'
10const ISSUE_TOOLS = ['mcp__linear-server__get_issue', 'mcp__linear-server__save_issue']
11const IDENTIFIER = /^[A-Z][A-Z0-9]{1,9}-\d+$/
12const MENTION = /\b[A-Z]{2,10}-\d+\b/g
13// Nerd Font md-ticket (U+F0516); Linear's own logo is in no terminal font.
14const GLYPH = '\u{F0516}'
15
16type Engine = EngineInterface
17
18const teamKeyOf = (id: string) => id.slice(0, id.lastIndexOf('-'))
19
20// Front-loads `seen`, keeping a known title when the new sighting has none.
21const promote = (list: readonly Ticket[], seen: readonly Ticket[]) => {
22 const known = new Map(list.map(t => [t.id, t]))
23 const fresh = seen.map(t => ({ ...known.get(t.id), ...t, title: t.title ?? known.get(t.id)?.title }))
24 const ids = new Set(fresh.map(t => t.id))
25 return [...fresh, ...list.filter(t => !ids.has(t.id))]
26}
27
28const remember = async ($: Engine, seen: readonly Ticket[]) => {
29 if (seen.length === 0) return
30 await update($, tickets, list => promote(list, seen))
31}
32
33const readTeamKeys = async ($: Engine) => {
34 const stored = await $.store.get(TEAM_KEYS)
35 return new Set(Array.isArray(stored) ? stored.filter(k => typeof k === 'string') : [])
36}
37
38const learnTeamKey = async ($: Engine, id: string) => {
39 const keys = await readTeamKeys($)
40 if (keys.has(teamKeyOf(id))) return
41 await $.store.set(TEAM_KEYS, [...keys, teamKeyOf(id)])
42}
43
44// get_issue and save_issue answer with the issue as JSON text: `id` is the
45// identifier (JUN-468), `uuid` the database id.
46export const parseIssue = (text: string | undefined): Ticket | undefined => {
47 if (!text) return undefined
48 try {
49 const issue: unknown = JSON.parse(text)
50 if (typeof issue !== 'object' || issue === null) return undefined
51 const { id, identifier, title } = issue as Record<string, unknown>
52 const key = [identifier, id].find(v => typeof v === 'string' && IDENTIFIER.test(v))
53 if (typeof key !== 'string') return undefined
54 return typeof title === 'string' ? { id: key, title } : { id: key }
55 } catch {
56 return undefined
57 }
58}
59
60const describe = (list: readonly Ticket[]) =>
61 list.map(t => (t.title ? `- ${t.id}: ${t.title}` : `- ${t.id}`)).join('\n')
62
63export const register: Register = on => {
64 on('session.start', async ($, e, next) => {
65 await $.command.register({
66 name: 'ticket',
67 description: 'Show, clear, or drop the Linear tickets this session tracks',
68 argumentHint: '[clear | drop <ID>]',
69 })
70 return next(e)
71 })
72
73 on('tool.call', async ($, e, next) => {
74 const ran = await next(e)
75 if (!ISSUE_TOOLS.includes(e.tool) || ran.deny !== undefined || ran.isError) return ran
76 const issue = parseIssue(ran.text)
77 if (issue) {
78 await learnTeamKey($, issue.id)
79 await remember($, [issue])
80 }
81 return ran
82 })
83
84 on('prompt.submit', async ($, e, next) => {
85 const keys = await readTeamKeys($)
86 const ids = [...new Set(e.text.match(MENTION) ?? [])].filter(id => keys.has(teamKeyOf(id)))
87 await remember($, ids.map(id => ({ id })))
88 return next(e)
89 })
90
91 on('prompt.compose', async ($, e, next) => {
92 const composed = await next(e)
93 const list = await read($, tickets)
94 if (list.length === 0) return composed
95 const text = [
96 '# Active Linear tickets',
97 'This session is working on these Linear tickets, most recent first. Keep them in mind across compaction, and name them in commits and PRs where the repo convention asks for it.',
98 describe(list),
99 ].join('\n\n')
100 return {
101 sections: [...composed.sections, { id: 'linear:active', text, scope: 'session' as const }],
102 }
103 })
104
105 // The band above the prompt: the newest ticket, its title dimmed and cut to
106 // the row, and how many more are active. Reading the atom while drawing
107 // redraws the band whenever the set changes.
108 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
109 const [first, ...rest] = await read($, tickets)
110 if (e.props.hasSurvey || first === undefined) return next(e)
111 const { Box, Text } = $.ui.resolve(e)
112 // marginTop: a blank row between the conversation and the band.
113 return (
114 <Box marginTop={1}>
115 <Text bold>
116 {GLYPH} {first.id}
117 </Text>
118 {first.title && (
119 <Box flexShrink={1}>
120 <Text dimColor wrap="truncate-end">
121 {' '}
122 {first.title}
123 </Text>
124 </Box>
125 )}
126 {rest.length > 0 && <Text dimColor> +{rest.length}</Text>}
127 </Box>
128 )
129 })
130
131 on('command.run', { command: 'ticket' }, async ($, e) => {
132 const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
133 if (verb === 'clear') {
134 await update($, tickets, () => [])
135 return { text: 'Cleared the active Linear tickets.' }
136 }
137 if (verb === 'drop') {
138 const id = arg.toUpperCase()
139 if (!id) return { text: 'Usage: /ticket drop <ID>' }
140 const before = await read($, tickets)
141 if (!before.some(t => t.id === id)) return { text: `${id} is not an active ticket.` }
142 await update($, tickets, list => list.filter(t => t.id !== id))
143 return { text: `Dropped ${id}.` }
144 }
145 if (verb !== '') return { text: 'Usage: /ticket [clear | drop <ID>]' }
146 const list = await read($, tickets)
147 return { text: list.length === 0 ? 'No active Linear tickets.' : describe(list) }
148 })
149}
150types/index.d.ts 8 lines1export type Ticket = { id: string; title?: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'linear': { tickets: Ticket[] }
6 }
7}
8