Says which rule blocked an action in auto mode and the allow rule that would cover 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.ts 17 lines1import type { Register } from 'claude-code'
2import { explanation, isClassifier } from './explain'
3
4const count = { plugin: 'denial-explainer', key: 'count' } as const
5
6export const register: Register = on => {
7 on('classic.PermissionDenied', async ($, e, next) => {
8 const result = await next(e)
9 if (!isClassifier(e.reason)) return result
10 const held = await $.state.get(count)
11 const n = (held.value ?? 0) + 1
12 await $.state.set(count, n)
13 $.ui.toast(explanation({ tool: e.tool_name, input: e.tool_input, reason: e.reason }, n))
14 return result
15 })
16}
17hooks/explain.ts 77 lines1const DANGEROUS = [
2 /(^|[\s;&|(])rm(\s|$)/,
3 /(^|[\s;&|(])sudo(\s|$)/,
4 /\bgit\s+push\b.*(\s-f\b|--force)/,
5 /\|\s*(sudo\s+)?(ba|z|da)?sh\b/,
6]
7const PLAIN_WORD = /^[\w./@:=+-]+$/
8const MAX_ARG = 60
9
10export type Denial = { tool: string; input: unknown; reason: string }
11
12export function isClassifier(reason: string): boolean {
13 return /classifier/i.test(reason) || /\[[^\]]+\]/.test(reason)
14}
15
16export function ruleName(reason: string): string {
17 return reason.match(/\[([^\]]+)\]/)?.[1] ?? 'auto mode'
18}
19
20function field(input: unknown, key: string): string | undefined {
21 const value = (input as Record<string, unknown> | null)?.[key]
22 return typeof value === 'string' ? value : undefined
23}
24
25export function shortArg({ tool, input }: Denial): string {
26 const raw =
27 tool === 'Bash' ? field(input, 'command') : tool === 'WebFetch' ? field(input, 'url') : field(input, 'file_path')
28 if (!raw) return ''
29 const one = raw.replace(/\s+/g, ' ').trim()
30 return one.length > MAX_ARG ? `${one.slice(0, MAX_ARG - 1)}…` : one
31}
32
33function bashRule(command: string | undefined): string | undefined {
34 if (!command || DANGEROUS.some(d => d.test(command))) return undefined
35 const words = command.trim().split(/\s+/).slice(0, 2)
36 if (!words.every(w => PLAIN_WORD.test(w))) return undefined
37 return `Bash(${words.join(' ')}:*)`
38}
39
40function domain(url: string | undefined): string | undefined {
41 if (!url) return undefined
42 try {
43 return new URL(url).hostname || undefined
44 } catch {
45 return undefined
46 }
47}
48
49export function allowRule({ tool, input }: Denial): string | undefined {
50 if (tool === 'Bash') return bashRule(field(input, 'command'))
51 if (tool.startsWith('mcp__')) return tool
52 if (tool === 'WebFetch') {
53 const host = domain(field(input, 'url'))
54 return host && `WebFetch(domain:${host})`
55 }
56 if (tool === 'Edit' || tool === 'Write') {
57 const path = field(input, 'file_path')
58 return path && !path.includes('"') ? `${tool}(${path})` : undefined
59 }
60 return undefined
61}
62
63export function ordinal(n: number): string {
64 const tens = n % 100
65 if (tens >= 11 && tens <= 13) return `${n}th`
66 return `${n}${({ 1: 'st', 2: 'nd', 3: 'rd' } as Record<number, string>)[n % 10] ?? 'th'}`
67}
68
69export function explanation(denial: Denial, count: number): string {
70 const arg = shortArg(denial)
71 const what = arg ? `${denial.tool} ${arg}` : denial.tool
72 const nth = count >= 2 ? ` (${ordinal(count)} this session)` : ''
73 const rule = allowRule(denial)
74 const allow = rule ? `/permissions add "${rule}"` : 'no allow rule suggested'
75 return `Blocked by ${ruleName(denial.reason)}: ${what}${nth}\nAllow it: ${allow}`
76}
77types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'denial-explainer': { count: number }
4 }
5}
6