Holds every Linear write until you allow it.

| 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's skills and mods for Claude Code.
/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.
| Plugin | Contents | Install |
|---|---|---|
cj-stack | the shipped skills below | by default |
linear-gate | a mod: holds Linear writes until you allow them | by default |
work-pane | a mod: test runs in a pane | optional |
usage-pace | a mod: weekly usage in the status line, and whether it lasts until your reset | optional |
cj-paint | painting-craft | where you paint in code |
cj-jira | archived Jira-era skills | only for a Jira client |
All skills are model-invoked: Claude picks them from their description.
| Skill | Bucket | What it is for |
|---|---|---|
| work-planning | planning | shaping work in any tracker (Linear, Jira, GitHub, others): specs, work items, phases, projects, updates |
| project-knowledge | knowledge | a decision log and research pages in the team's wiki, Notion, Linear, Obsidian or Confluence |
| painting-craft | craft | drawing and painting in code to a gallery standard, with a screenshot review loop |
| jira-ticket | archived | Jira tickets and acceptance criteria |
| draft-ticket | archived | end-to-end Jira ticket drafting |
| github-issue | archived | GitHub issues holding a Jira ticket's engineering detail |
| linear-planning | archived | Linear-only predecessor of work-planning (not shipped) |
| linear-project | archived | Linear-only predecessor of work-planning (not shipped) |
| project-wiki | archived | GitHub-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.
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."

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:

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

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:

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.
hooks/register.tsx 338 lines1import { 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}
338types/index.d.ts 8 lines1export type Asks = Record<string, string>
2
3declare module 'claude-code' {
4 interface PluginState {
5 'linear-gate': { asks: Asks }
6 }
7}
8