SLOPSHOPPER

linear

No description.

newbandguardcommandprompt
v?NOASSERTIONupdated 2026-10-04jdh313/jdh-agents/plugins/linear
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · linear
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ticket ⎿ linear: No active Linear tickets. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

linear

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.

Premise

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.

Scope

  • Owns: Ticket creation defaults (team, labels, priority, milestone), status flow semantics, title shape, description templates, MCP call patterns.
  • Does NOT own: The decision of whether to open a ticket (project CLAUDE.md). The spec-flow contract lifecycle (spec-flow plugin). PR-to-ticket linking (deferred until PRs are introduced).
  • Currently scoped to: A single Linear team.

Skills

  • 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.

Codex optional agent bundle

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.

Composes with

  • spec-flow — When a contract is hosted in Linear, spec-flow writes the contract body to the ticket description. This plugin owns the ticket's other fields.
  • pm — The pm skills (groom, retro, breakdown) propose ticket actions; linear applies any approved transitions and owns the field conventions pm's issue-shape.md defers to.
  • ndr (ships from its own separate marketplace) — 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.
Source 2 files
hooks/register.tsx 150 lines
1import { 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}
150
types/index.d.ts 8 lines
1export type Ticket = { id: string; title?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'linear': { tickets: Ticket[] }
6  }
7}
8