SLOPSHOPPER

linear-gate

Holds every Linear write until you allow it.

newrowsguardtoast
v0.1.0no licenseupdated 2026-10-10cpeaustriajc/cj-stack/mods/linear-gate
A shopper browsing a rack in a slop shop
README

| session-modes | a mod: /mode audit, no-pr or chat, enforced for the session | optional | | denial-explainer | a mod: names the rule behind an auto-mode denial and the allow rule that would cover it | optional | | loop-brake | a mod: ends a turn after N forced Stop-hook continuations | optional |

cj-stack

CJ's skills and mods for Claude Code.

Install

/plugin marketplace add cpeaustriajc/cj-stack
/plugin install cj-stack@cj-stack
/plugin install linear-gate@cj-stack
/plugin install usage-pace@cj-stack
/plugin install session-modes@cj-stack
/plugin install denial-explainer@cj-stack
/plugin install loop-brake@cj-stack

While editing a mod, add the local clone instead (/plugin marketplace add ~/Projects/cj-stack): a directory marketplace hot-reloads.

Other agents (Codex and anything that reads ~/.agents/skills): scripts/link-skills.sh.

Plugins

PluginContentsInstall
cj-stackthe shipped skills belowby default
linear-gatea mod: holds Linear writes until you allow themby default
work-panea mod: test runs in a paneoptional
usage-pacea mod: weekly usage in the status line, and whether it lasts until your resetoptional
cj-paintpainting-craftwhere you paint in code
cj-jiraarchived Jira-era skillsonly for a Jira client

Skills

All skills are model-invoked: Claude picks them from their description.

SkillBucketWhat it is for
work-planningplanningshaping work in any tracker (Linear, Jira, GitHub, others): specs, work items, phases, projects, updates
project-knowledgeknowledgea decision log and research pages in the team's wiki, Notion, Linear, Obsidian or Confluence
painting-craftcraftdrawing and painting in code to a gallery standard, with a screenshot review loop
jira-ticketarchivedJira tickets and acceptance criteria
draft-ticketarchivedend-to-end Jira ticket drafting
github-issuearchivedGitHub issues holding a Jira ticket's engineering detail
linear-planningarchivedLinear-only predecessor of work-planning (not shipped)
linear-projectarchivedLinear-only predecessor of work-planning (not shipped)
project-wikiarchivedGitHub-wiki-only predecessor of project-knowledge

The archived skills were written for one project and still name its files. Shipped skills stay tool-neutral: tool specifics go in a skill's adapters, and a project's own facts stay in that project.

painting-craft: before and after

The same two prompts, each run once in a fresh session: the original skill on the left, the current painting-craft on the right. Nothing was touched up by hand.

"Paint a 1600x1000 SVG of a knight asleep against an oak tree at dusk, in a Romantic oil-painting look."

Knight at dusk, full picture: before and after

At 2× zoom, where the viewer looks. The knight is a 3D figure lit by the low sun, with separate plates, mail in rows and a visor, instead of a mannequin of tubes:

Knight at dusk, 2x crop: before and after

"Draw a night city street as a 1600x1000 SVG: a car passes behind a row of trees on the near verge. Flat vector style, but it must not look cheap."

Night street, full picture: before and after

At 2× zoom, a crown is clumps hung on a branch skeleton, in three values with broken edges, instead of a pile of round blobs:

Night street, 2x crop of a tree: before and after

What changed: build recipes for the parts that fail up close (references/recipes.md), studies of each hero element against fetched public-domain references before composing (scripts/refs.py, scripts/compare.py), the bundled 3D figure renderer, a paint pass that unifies painted styles (scripts/paintpass.py), and delivery at twice the display size.

Source 2 files
hooks/register.tsx 338 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const asks = atom({ plugin: 'linear-gate', key: 'asks' } as const, {} as Record<string, string>)
5
6const LINEAR_WRITES = new Set([
7  'save_issue', 'save_comment', 'save_document', 'save_project', 'save_milestone',
8  'save_initiative', 'save_initiative_label', 'save_issue_label', 'save_project_label',
9  'save_release', 'save_release_note', 'save_status_update', 'save_diff_comment',
10  'create_attachment', 'create_attachment_from_upload', 'create_issue_label',
11  'create_initiative_label', 'delete_attachment', 'delete_comment', 'delete_diff_comment',
12  'delete_status_update', 'merge_diff', 'update_diff', 'submit_diff_review',
13  'resolve_diff_thread', 'retire_issue_label', 'retire_initiative_label',
14  'retire_project_label', 'restore_issue_label', 'restore_initiative_label',
15  'restore_project_label', 'share_issue', 'unshare_issue', 'mark_notification',
16])
17const LINEAR_SERVER_WRITE = /^(save|delete|create|update|merge|submit|resolve|retire|restore|share|unshare|mark)_/
18const LEAVE = 'Leave it'
19const HEADER = 'Linear'
20const CONFIRM = 'Apply this change?'
21const LOOKUP_MS = 3000
22const BUTTON_MAX = 40
23// The engine refuses a dialog with more rows than this around it and draws its own,
24// which would show only the staged question and hide the body.
25const ROWS_MAX = 12
26const NARROW = 60
27
28function linearWrite(tool: string): string | undefined {
29  const m = /^mcp__(.+?)__(.+)$/.exec(tool)
30  if (!m) return undefined
31  const [, server, name] = m
32  if (LINEAR_WRITES.has(name)) return name
33  if (server === 'linear-server' && LINEAR_SERVER_WRITE.test(name)) return name
34  return undefined
35}
36
37const EVENT_KEYS = new Set(['tool', 'tool_use_id', 'agentId'])
38const TEXT_KEYS = ['body', 'description', 'content']
39const PR_STATUS: Record<string, string> = {
40  markReadyForReview: 'ready for review', convertToDraft: 'back to draft', close: 'closed', reopen: 'reopened',
41}
42const REVIEW: Record<string, string> = { approved: 'Approve', changesRequested: 'Request changes on', commented: 'Leave a review comment on' }
43
44const HEALTH: Record<string, string> = { onTrack: 'on track', atRisk: 'at risk', offTrack: 'off track' }
45const clip = (s: string, max: number) => (s.length > max ? `${s.slice(0, max)}...` : s)
46const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
47const short = (v: string) => (UUID.test(v) ? `${v.slice(0, 8)}…` : v)
48const flat = (t: string) => t.replace(/^\s*(#+|[-*>]|\d+\.)\s+/gm, '').replace(/[*_`]/g, '').replace(/\s+/g, ' ').trim()
49const isNames = (v: unknown): v is string[] => Array.isArray(v) && v.every(x => typeof x === 'string')
50const shown = (v: unknown) =>
51  clip(typeof v === 'string' ? short(flat(v)) : isNames(v) ? v.map(short).join(', ') : JSON.stringify(v), 80)
52const words = (k: string) => k.replace(/Id$/, '').replace(/([A-Z])/g, ' $1').replace(/_/g, ' ').toLowerCase().trim()
53const FIELD: Record<string, string> = { state: 'Status' }
54const heading = (k: string) => FIELD[k] ?? words(k).replace(/^./, c => c.toUpperCase())
55const STATE_VERB: Record<string, string> = { canceled: 'Cancel', cancelled: 'Cancel', done: 'Complete', completed: 'Complete' }
56const people = (v: unknown) =>
57  (Array.isArray(v) ? v : []).map(r => String(r.user ?? r.externalUserId ?? r.githubTeamId)).join(', ')
58
59function whatItDoes(name: string, a: Record<string, unknown>): [string[], string[]] {
60  const s = (k: string) => (typeof a[k] === 'string' ? (a[k] as string) : undefined)
61  const pr = `PR ${s('urlOrId')}`
62  switch (name) {
63    case 'save_issue': {
64      if (s('id')) return [[`${STATE_VERB[s('state')?.toLowerCase() ?? ''] ?? 'Update'} issue ${short(s('id')!)}`], ['id']]
65      const template = s('template') ? `with template ${s('template')}` : 'with no template'
66      return [[`Create issue "${s('title')}" in ${s('team')} ${template}`], ['title', 'team', 'template']]
67    }
68    case 'save_comment': {
69      const parent = ['issueId', 'projectId', 'documentId', 'initiativeId', 'milestoneId', 'statusUpdateId'].find(k => s(k))
70      const noun = parent?.replace(/Id$/, '').replace(/([A-Z])/g, ' $1').toLowerCase()
71      const where = parent ? ` on ${noun} ${short(s(parent)!)}` : ''
72      if (s('id')) return [[`Edit comment ${short(s('id')!)}`], ['id', ...(parent ? [parent] : [])]]
73      if (s('parentId')) return [[`Reply to a comment${where}`], ['parentId', ...(parent ? [parent] : [])]]
74      return parent ? [[`Comment${where}`], [parent]] : [[], []]
75    }
76    case 'save_status_update': {
77      const health = s('health') ? `, health ${HEALTH[s('health')!] ?? s('health')}` : ''
78      const lines = s('id')
79        ? [`Edit the posted ${s('type')} status update ${s('id')}${health}`]
80        : [`Post a ${s('type')} status update on ${s('project') ?? s('initiative')}${health}`]
81      return [lines, ['type', 'id', 'health', 'project', 'initiative']]
82    }
83    case 'save_project': case 'save_milestone': case 'save_initiative': case 'save_document': {
84      const noun = name.split('_')[1]
85      if (s('id')) return [[`${STATE_VERB[s('state')?.toLowerCase() ?? ''] ?? 'Update'} ${noun} ${short(s('id')!)}`], ['id']]
86      const key = s('name') ? 'name' : 'title'
87      return [[`Create ${noun} "${s(key)}"`], [key]]
88    }
89    case 'delete_status_update':
90      return [[`Delete the ${s('type')} status update ${s('id')}`], ['type', 'id']]
91    case 'update_diff': {
92      const lines: string[] = []
93      if (a.addedReviewRequests) lines.push(`Request review from ${people(a.addedReviewRequests)} on ${pr}`)
94      if (a.removedReviewRequests) lines.push(`Withdraw the review request to ${people(a.removedReviewRequests)} on ${pr}`)
95      for (const l of (Array.isArray(a.addedIssueLinks) ? a.addedIssueLinks : []) as { issue: string; type: string }[])
96        lines.push(`Link ${l.issue} (${l.type}) to ${pr}`)
97      if (a.removedIssueLinks) lines.push(`Unlink ${(a.removedIssueLinks as string[]).join(', ')} from ${pr}`)
98      if (s('statusAction')) lines.push(`Mark ${pr} ${PR_STATUS[s('statusAction')!] ?? s('statusAction')}`)
99      if (!lines.length) lines.push(`Edit ${pr}`)
100      return [lines, ['urlOrId', 'addedReviewRequests', 'removedReviewRequests', 'addedIssueLinks', 'removedIssueLinks', 'statusAction']]
101    }
102    case 'merge_diff':
103      return [[`Merge ${pr}${s('mergeMethod') ? ` (${s('mergeMethod')!.toLowerCase()})` : ''}`], ['urlOrId', 'mergeMethod']]
104    case 'submit_diff_review':
105      return [[`${REVIEW[s('decision') ?? ''] ?? 'Review'} ${pr}`], ['urlOrId', 'decision']]
106    case 'save_diff_comment': {
107      const verb = a.draft ? 'Save a draft comment on' : s('commentId') ? 'Edit a comment on' : s('parentId') ? 'Reply on' : 'Comment on'
108      return [[`${verb} ${pr}`], ['urlOrId', 'draft', 'commentId', 'parentId', 'draftId', 'anchor', 'anchorContent']]
109    }
110    case 'create_issue_label': case 'create_initiative_label':
111    case 'save_issue_label': case 'save_initiative_label': case 'save_project_label': {
112      const noun = `${name.split('_')[1]} label`
113      if (name.startsWith('save_') && s('id')) return [[`Edit ${noun} ${s('id')}`], ['id']]
114      const where = s('teamId') ? `in team ${short(s('teamId')!)}` : 'for the whole workspace'
115      return [[`Create ${noun} "${s('name')}" ${where}`], ['name', 'teamId']]
116    }
117    case 'retire_issue_label': case 'retire_initiative_label': case 'retire_project_label':
118    case 'restore_issue_label': case 'restore_initiative_label': case 'restore_project_label': {
119      const [verb, kind] = name.split('_')
120      return [[`${heading(verb)} ${kind} label ${s('id') ?? s('name')}`], ['id', 'name']]
121    }
122    case 'resolve_diff_thread':
123      return [[`${a.resolved === false ? 'Reopen' : 'Resolve'} PR thread ${s('threadId')}`], ['threadId', 'resolved']]
124    default:
125      return [[], []]
126  }
127}
128
129const SHOWN_EDITS = 5
130const row = (t: string) => clip(t.replace(/\n/g, ' ⏎ ').replace(/[ \t]+/g, ' ').trim(), 160)
131const quote = (t: string) => `"${clip(row(t), 40)}"`
132const WRAP = 72
133const WRAP_ROWS = 11
134
135function wrapped(mark: string, text: string): string[] {
136  return block(`  ${mark} `, '    ', text, l => l)
137}
138
139const unmark = (l: string) => l.replace(/^\s*(#+|[-*>]|\d+\.)\s+/, '').replace(/[*_`]/g, '')
140
141function block(first: string, rest: string, text: string, tidy: (line: string) => string): string[] {
142  const rows = text.split('\n').map(l => tidy(l).replace(/\s+/g, ' ').trim()).filter(Boolean).flatMap(line => {
143    const out: string[] = []
144    let cur = ''
145    for (let word of line.split(' ')) {
146      while (word.length > WRAP) {
147        if (cur) out.push(cur), (cur = '')
148        out.push(word.slice(0, WRAP)), (word = word.slice(WRAP))
149      }
150      if (cur && cur.length + 1 + word.length > WRAP) out.push(cur), (cur = '')
151      cur = cur ? `${cur} ${word}` : word
152    }
153    return cur ? [...out, cur] : out
154  })
155  const kept = rows.slice(0, WRAP_ROWS).map((r, i) => (i ? `${rest}${r}` : `${first}${r}`))
156  const hidden = rows.slice(WRAP_ROWS).join(' ').length
157  return hidden ? [...kept, `${rest}… ${hidden} more characters`] : kept
158}
159
160function patchLines(patch: unknown[]): string[] {
161  const lines = patch.slice(0, SHOWN_EDITS).flatMap((raw, i) => {
162    const op = (raw ?? {}) as Record<string, string>
163    const added = (t: string | undefined) => (t ? wrapped('+', t) : [])
164    const body = (() => {
165      switch (op.op) {
166        case 'replace':
167          return [...(op.replace_all ? ['Every match:'] : []), ...wrapped('−', op.old_string), ...added(op.new_string)]
168        case 'insert_before': return [`Before ${quote(op.anchor)}:`, ...added(op.text)]
169        case 'insert_after': return [`After ${quote(op.anchor)}:`, ...added(op.text)]
170        case 'prepend': return ['At the start:', ...added(op.text)]
171        case 'append': return ['At the end:', ...added(op.text)]
172        case 'replace_range':
173          return [`From ${quote(op.from)} up to ${quote(op.to)}:`, ...added(op.new_string)]
174        default: return [`  ${shown(raw)}`]
175      }
176    })()
177    return [...(i ? [''] : []), ...body.map(l => (l.startsWith('  ') ? l : `  ${l}`))]
178  })
179  const rest = patch.length - SHOWN_EDITS
180  return rest > 0 ? [...lines, '', `  …and ${rest} more edit${rest === 1 ? '' : 's'}`] : lines
181}
182
183function actionOf(name: string, args: Record<string, unknown>): string[] {
184  const [lines] = whatItDoes(name, args)
185  return lines.length ? lines : [`${heading(name)} (${name})`]
186}
187
188function isChange(name: string, args: Record<string, unknown>): boolean {
189  return /^(update|merge|submit|resolve)_/.test(name) || (/^save_/.test(name) && typeof args.id === 'string')
190}
191
192function isDrastic(name: string, args: Record<string, unknown>): boolean {
193  return /^(delete|retire)_/.test(name) || STATE_VERB[String(args.state).toLowerCase()] === 'Cancel'
194}
195
196function describeWrite(name: string, args: Record<string, unknown>): string {
197  const [, covered] = whatItDoes(name, args)
198  const change = isChange(name, args)
199  const sep = change ? ' → ' : ': '
200  const label = (k: string) => (change ? heading(k) : heading(k.replace(/^add([A-Z])/, (_, c: string) => c.toLowerCase())))
201  const textKey = TEXT_KEYS.find(k => typeof args[k] === 'string')
202  const patch = Array.isArray(args.patch) ? args.patch : undefined
203  const fields = Object.keys(args)
204    .filter(k => !EVENT_KEYS.has(k) && !covered.includes(k) && k !== textKey && !(patch && k === 'patch'))
205    .map(k => `  ${label(k)}${sep}${shown(args[k])}`)
206  if (textKey) {
207    const text = args[textKey] as string
208    if (name === 'save_comment') fields.push(...(fields.length ? [''] : []), ...block('  ', '  ', text, unmark))
209    else {
210      const head = `  ${label(textKey)}${change ? ' →' : ':'}`
211      const rows = block('    ', '    ', text, unmark)
212      const inline = rows.length === 1 && head.length + rows[0].length - 3 <= 76
213      fields.push(...(inline ? [`${head} ${rows[0].trim()}`] : [...(fields.length ? [''] : []), head, ...rows]))
214    }
215  }
216  if (patch) fields.push(...(fields.length ? [''] : []), ...patchLines(patch))
217  const warning = isDrastic(name, args) ? ['', '⚠ Everyone on the team sees this.'] : []
218  return [...actionOf(name, args), ...(fields.length ? ['', ...fields] : []), ...warning, '', CONFIRM].join('\n')
219}
220
221function allowLabel(name: string, args: Record<string, unknown>): string {
222  if (name === 'save_comment') return args.id ? 'Save edit' : args.parentId ? 'Post reply' : 'Post comment'
223  const line = actionOf(name, args)[0]
224  if (line.length <= BUTTON_MAX) return line
225  const cut = line.includes('"') ? -1 : line.search(/ (in|with|on|for) /)
226  const head = cut > 0 && cut <= BUTTON_MAX ? line.slice(0, cut) : line
227  return head.length <= BUTTON_MAX ? head : `${head.slice(0, BUTTON_MAX - 1)}…`
228}
229
230function failure(out: { text?: string; isError?: boolean }): string | undefined {
231  const text = out.text ?? ''
232  if (out.isError) return clip(text || 'the tool reported an error', 100)
233  if (/"success"\s*:\s*false/.test(text)) return clip(text, 100)
234  return undefined
235}
236
237function bodyOf(description: string): string[] {
238  return description.split('\n').slice(2, -2)
239}
240
241function drawnRows(description: string): number {
242  return bodyOf(description).reduce((n, line) => n + Math.max(1, Math.ceil(line.length / NARROW)), 0)
243}
244
245// The dialog's question must reach the engine unchanged: a free-text answer is keyed by
246// the question the dialog drew, and $.ui.ask drops one keyed by anything else.
247async function stage($: Parameters<typeof read>[0], description: string): Promise<string> {
248  const base = `${description.split('\n')[0]} — ${CONFIRM.toLowerCase()}`
249  let question = base
250  await update($, asks, pending => {
251    question = base
252    for (let n = 2; question in pending; n++) question = `${base} (${n})`
253    return { ...pending, [question]: description }
254  })
255  return question
256}
257
258async function documentTitle($: Parameters<typeof read>[0], server: string, id: string): Promise<string | undefined> {
259  try {
260    const timeout = $.clock.sleep(LOOKUP_MS).then(() => undefined, () => new Promise<never>(() => {}))
261    const out = await Promise.race([$.mcp.call(server, 'get_document', { id }), timeout])
262    if (!out || out.isError) return undefined
263    for (const block of out.content) {
264      if (block.type !== 'text') continue
265      const title = (JSON.parse((block as { text: string }).text) as { title?: unknown }).title
266      if (typeof title === 'string' && title) return title
267    }
268  } catch {
269    return undefined
270  }
271  return undefined
272}
273
274
275function diffColor(line: string, current: string | undefined): string | undefined {
276  if (line.startsWith('  − ')) return 'red'
277  if (line.startsWith('  + ')) return 'green'
278  if (line.startsWith('    ') && line.trim()) return current
279  if (line.startsWith('⚠')) return 'yellow'
280  return undefined
281}
282
283export const register: Register = on => {
284  on('tool.call', async ($, e, next) => {
285    let args = e as unknown as Record<string, unknown>
286
287    const name = linearWrite(e.tool)
288    if (!name) return next(e)
289    if ((await $.session.surfaces()).length === 0) return next(e)
290
291    const server = /^mcp__(.+?)__/.exec(e.tool)![1]
292    const title =
293      name === 'save_document' && typeof args.id === 'string' ? await documentTitle($, server, args.id) : undefined
294    if (title) args = { ...args, id: `"${title}"` }
295    const allow = allowLabel(name, args)
296    const description = describeWrite(name, args)
297    const question = drawnRows(description) <= ROWS_MAX ? await stage($, description) : description
298    let answer: string
299    try {
300      answer = await $.ui.ask(question, { options: [allow, LEAVE], header: HEADER })
301    } catch {
302      return { deny: `${$.plugin.name}: the Linear write was not approved (dialog closed or no one to ask).` }
303    } finally {
304      await update($, asks, ({ [question]: _, ...rest }) => rest)
305    }
306    const action = actionOf(name, args).join('; ')
307    if (answer !== allow) {
308      const reason = answer && answer !== LEAVE ? ` Their note: ${answer}` : ''
309      return { deny: `${$.plugin.name}: the user denied this Linear write (${action}).${reason}` }
310    }
311    const out = await next(e)
312    if (out.deny) return out
313    const failed = failure(out)
314    await $.ui.toast(failed ? `✘ ${action} failed: ${failed}` : `✔ ${action}`)
315    return out
316  })
317
318  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
319    const questions = e.props.questions as { question?: unknown; header?: unknown }[]
320    const q = questions.length === 1 ? questions[0] : undefined
321    const description = q?.header === HEADER && typeof q.question === 'string' ? (await read($, asks))[q.question] : undefined
322    if (!description) return next(e)
323    const { Box, Text } = $.ui.resolve(e)
324    const dialog = await next(e)
325    let color: string | undefined
326    const rows = bodyOf(description).map(line => {
327      color = diffColor(line, color)
328      return <Text color={color}>{line || ' '}</Text>
329    })
330    return (
331      <Box flexDirection="column">
332        {rows}
333        {dialog}
334      </Box>
335    )
336  })
337}
338
types/index.d.ts 8 lines
1export type Asks = Record<string, string>
2
3declare module 'claude-code' {
4  interface PluginState {
5    'linear-gate': { asks: Asks }
6  }
7}
8