/mode audit, no-pr and chat: a working mode enforced for the whole session

| 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 121 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, Register } from 'claude-code'
3import { SECTION, parseMode, report, verdict } from './modes'
4import type { Mode } from './modes'
5import { modeSuggestions } from './suggest'
6
7type Api = Parameters<Hook<'session.start'>>[0]
8
9const MOD = 'session-modes'
10const WEEK = 7 * 86_400_000
11
12async function stateDir($: Api): Promise<string | undefined> {
13 const config = await $.env.get('CLAUDE_CONFIG_DIR')
14 if (config) return `${config}/mod-state`
15 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
16 return home ? `${home}/.claude/mod-state` : undefined
17}
18
19async function remove($: Api, paths: string[]): Promise<void> {
20 if (paths.length === 0) return
21 await $.process.run(['rm', '-f', '--', ...paths])
22}
23
24export async function publishState($: Api, mode: string): Promise<void> {
25 try {
26 const dir = await stateDir($)
27 if (!dir) return
28 const path = `${dir}/${await $.session.id()}.${MOD}.json`
29 await $.fs.write(path, JSON.stringify({ v: 1, mode: mode === 'off' ? null : mode }))
30 } catch (cause) {
31 throw new Error(`${MOD}: could not publish state for the status line`, { cause })
32 }
33}
34
35export async function pruneState($: Api): Promise<void> {
36 try {
37 const dir = await stateDir($)
38 if (!dir || !(await $.fs.exists(dir))) return
39 const now = await $.clock.now()
40 const stale = (await $.fs.list(dir)).filter(
41 f => f.kind === 'file' && f.name.endsWith(`.${MOD}.json`) && now - f.mtimeMs > WEEK,
42 )
43 await remove($, stale.map(f => `${dir}/${f.name}`))
44 } catch (cause) {
45 throw new Error(`${MOD}: could not prune old state files`, { cause })
46 }
47}
48
49const mode = atom({ plugin: 'session-modes', key: 'mode' } as const, 'off' as Mode)
50const SECTION_ID = 'session-modes:mode'
51
52const STATUS: Record<Exclude<Mode, 'off'>, string> = {
53 audit: 'audit — read and report only',
54 'no-pr': 'no-pr — no pull requests',
55 chat: 'chat — answer here, no PR or push',
56}
57
58async function drawStatus($: Api, current: Mode): Promise<void> {
59 const surfaces = await $.session.surfaces()
60 const shown = current !== 'off' && surfaces.some(s => s !== 'terminal')
61 await $.ui.status(shown ? STATUS[current as Exclude<Mode, 'off'>] : undefined)
62}
63
64export const register: Register = on => {
65 on('session.start', async ($, e, next) => {
66 const result = await next(e)
67 if (e.isInteractive === false) return result
68 await $.command.register({
69 name: 'mode',
70 description: 'Set the session mode: audit, no-pr, chat or off',
71 argumentHint: 'audit | no-pr | chat | off',
72 })
73 const current = await read($, mode)
74 await drawStatus($, current)
75 if (current !== 'off') await publishState($, current)
76 await pruneState($)
77 return result
78 })
79
80 on('session.attach', async ($, e, next) => {
81 const result = await next(e)
82 await drawStatus($, await read($, mode))
83 return result
84 })
85
86 on('session.detach', async ($, e, next) => {
87 const result = await next(e)
88 await drawStatus($, await read($, mode))
89 return result
90 })
91
92 on('command.run', { command: 'mode' }, async ($, e) => {
93 const wanted = e.args.trim() === '' ? undefined : parseMode(e.args)
94 if (e.args.trim() === '') return { text: report(await read($, mode)) }
95 if (!wanted) return { text: `Unknown mode "${e.args.trim()}". ${report(await read($, mode))}` }
96 await update($, mode, () => wanted)
97 await drawStatus($, wanted)
98 await publishState($, wanted)
99 return { text: wanted === 'off' ? 'Mode off.' : `Mode ${wanted} on.` }
100 })
101
102 on('tool.call', async ($, e, next) => {
103 const command = e.tool === 'Bash' && typeof (e as { command?: unknown }).command === 'string' ? (e as { command: string }).command : ''
104 const deny = verdict(await read($, mode), e.tool, command)
105 return deny ? { deny } : next(e)
106 })
107
108 on('prompt.compose', async ($, e, next) => {
109 const result = await next(e)
110 const current = await read($, mode)
111 if (current === 'off') return result
112 return { sections: [...result.sections, { id: SECTION_ID, text: SECTION[current], scope: 'session' }] }
113 })
114
115 on('prompt.autocomplete', async ($, e, next) => {
116 const rows = modeSuggestions(e.text, e.cursor, await read($, mode))
117 const out = await next(e)
118 return rows.length ? { suggestions: [...out.suggestions, ...rows] } : out
119 })
120}
121hooks/modes.ts 50 lines1export type Mode = 'off' | 'audit' | 'no-pr' | 'chat'
2
3export const MODES = ['audit', 'no-pr', 'chat', 'off'] as const
4
5export function parseMode(args: string): Mode | undefined {
6 const name = args.trim().toLowerCase()
7 return (MODES as readonly string[]).includes(name) ? (name as Mode) : undefined
8}
9
10export const SECTION: Record<Exclude<Mode, 'off'>, string> = {
11 audit: 'Audit mode: read and report only. Do not edit files, commit, push or open pull requests; list findings instead.',
12 'no-pr': 'No-PR mode: do not open a pull request. Commit on the branch and say so in the reply.',
13 chat: 'Chat mode: do not open a pull request or push. Put the result in the reply, not in a pull request.',
14}
15
16const PR_CREATE = /\bgh\s+pr\s+create\b/
17const PUSH = /\bgit\s+push\b/
18const AUDIT_BASH = /\bgit\s+commit\b|\bgit\s+push\b|\bgh\s+pr\s+(create|merge)\b/
19const HARMLESS_REDIRECT = /\d*>&\d+|\d*>>?\s*\/dev\/null/g
20const FILE_REDIRECT = />>?\s*[^\s>&|;]/
21
22export function redirectsToFile(command: string): boolean {
23 return FILE_REDIRECT.test(command.replace(HARMLESS_REDIRECT, ''))
24}
25
26const INSTEAD: Record<Exclude<Mode, 'off'>, string> = {
27 audit: 'report the change instead of making it',
28 'no-pr': 'commit on the branch and leave the pull request to the user',
29 chat: 'put the result in the reply instead',
30}
31
32function isBlocked(mode: Exclude<Mode, 'off'>, tool: string, command: string): boolean {
33 if (mode === 'audit') {
34 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return true
35 return tool === 'Bash' && (AUDIT_BASH.test(command) || redirectsToFile(command))
36 }
37 if (tool.endsWith('create_pull_request')) return true
38 if (tool !== 'Bash') return false
39 return PR_CREATE.test(command) || (mode === 'chat' && PUSH.test(command))
40}
41
42export function verdict(mode: Mode, tool: string, command: string): string | undefined {
43 if (mode === 'off' || !isBlocked(mode, tool, command)) return undefined
44 return `${mode} mode is on: ${INSTEAD[mode]}; /mode off to leave.`
45}
46
47export function report(mode: Mode): string {
48 return `Current mode: ${mode}. Modes: ${MODES.join(', ')}.`
49}
50hooks/suggest.ts 23 lines1import type { PromptAutocompleteSuggestion } from 'claude-code'
2import { MODES } from './modes'
3import type { Mode } from './modes'
4
5const DIM: Record<Mode, string> = {
6 audit: 'read and report only',
7 'no-pr': 'no pull requests',
8 chat: 'answer here, no PR or push',
9 off: 'lift the current mode',
10}
11
12const PARTIAL = /^\/mode ([^\s]*)$/
13
14export function modeSuggestions(text: string, cursor: number, current: Mode): PromptAutocompleteSuggestion[] {
15 const found = PARTIAL.exec(text)
16 if (cursor !== text.length || !found) return []
17 const partial = found[1].toLowerCase()
18 return MODES.filter(m => m.startsWith(partial)).map(m => ({
19 text: m,
20 description: m !== current ? DIM[m] : m === 'off' ? 'no mode is on' : `${DIM[m]} (on)`,
21 }))
22}
23types/index.d.ts 8 lines1export type Mode = 'off' | 'audit' | 'no-pr' | 'chat'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'session-modes': { mode: Mode }
6 }
7}
8