SLOPSHOPPER

denial-explainer

Says which rule blocked an action in auto mode and the allow rule that would cover it

newtoast
v0.1.0no licenseupdated 2026-10-10cpeaustriajc/cj-stack/mods/denial-explainer
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 3 files
hooks/register.ts 17 lines
1import 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}
17
hooks/explain.ts 77 lines
1const 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}
77
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'denial-explainer': { count: number }
4  }
5}
6