Work with Claude like Tony Stark works with JARVIS: short plain-language turns, every code block explained and approved before it is written, a mode band above…

A Claude Code mod for people who want to stay in the room.
ironman is a revolt against AI automation where you run a thousand agents and go away to sip coffee. You come back to a pile of code you never read.
It is for those who work like Tony Stark. AI and human work in close harmony, augmenting each other without losing focus.
ironman makes Claude work that way.
ironman has two parts.
The skill (skills/ironman/SKILL.md) holds the rules Claude follows. It has three modes:
| mode | what it is for |
|---|---|
discuss | talking a problem through, like with a colleague |
teach | learning something step by step, with checks that you followed |
code | building something together, one approved block at a time |
Before each reply, Claude runs scripts/check_draft.py on its draft. The checker flags rare words and acronyms that were not explained, and prose above reading grade 9. Claude fixes what is flagged and sends the checked text. A reminder hook repeats this rule on every message. A Stop hook scores every reply and logs it to ~/.claude/ironman/log.jsonl, and /ironman report sums the log up.
The mod (hooks/) changes what Claude Code shows on screen:
band.tsx draws the band above the prompt: ironman · code, in the mode's color.pane.tsx adds a side pane and three tools Claude calls:pane_diagram draws a mermaid diagram of the overall design, for the overview.pane_plan replaces it with the plan: code blocks as boxes joined by arrows, or a lesson's steps as a list. Each item has a note you can open.pane_focus lights up the item being worked on. Finished items get a check.The pane is sized to fit the diagram.
In a terminal Claude Code session, type:
/plugin install ironman --marketplace cerebrai/ironman-mod
Answer y to add the marketplace, then pick the user scope. Start a new session and turn it on:
/ironman on code
If your Claude Code puts plugin skills under the plugin's name, use /ironman:ironman on code, or just say "ironman on".
uv, which runs the readability checker.npx) and Chrome or Chromium. The mod looks for /usr/bin/google-chrome. If your browser is elsewhere, set PUPPETEER_EXECUTABLE_PATH to its path.CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1.pluginPanes.dockColumns from ~/.claude.json.| command | what it does | ||
|---|---|---|---|
| `/ironman on [discuss\ | teach\ | code]` | turn ironman on for this session (default mode discuss) |
/ironman off | turn it off | ||
/ironman mode <mode> | switch mode | ||
/ironman check | score Claude's last reply | ||
/ironman learn <words…> | add words the checker should treat as known | ||
/ironman forget <words…> | remove them again | ||
/ironman report [all] | sum up the log for this session, or all sessions | ||
/pane | open the side pane yourself |
ironman is per session. Turning it on in one session leaves the others alone.
.claude-plugin/ plugin.json and marketplace.json
hooks/ the mod: register.tsx, band.tsx, pane.tsx, hooks.json
types/ types for the mod's saved state
assets/ mermaid style for the pane diagrams
skills/ironman/ the skill: SKILL.md, references/, scripts/, word list, config
docs/ the logo
Run it from a clone without installing:
claude --plugin-dir ./ironman-mod
Check the plugin and run the checker's tests:
claude plugin validate .
uv run --with textstat --with wordfreq python -m unittest skills/ironman/scripts/test_readability.py
MIT. See LICENSE.
ironman is a fan-made name for a working style. It is not affiliated with or endorsed by Marvel.
hooks/register.tsx 10 lines1import type { Register } from 'claude-code'
2
3import { registerBand } from './band'
4import { registerPane } from './pane'
5
6export const register: Register = (on, options) => {
7 registerBand(on, options)
8 registerPane(on, options)
9}
10hooks/band.tsx 57 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mode } from '../types'
5
6const mode = atom({ plugin: 'ironman', key: 'mode' } as const, null)
7
8const MODE_COLOR: Record<Mode, string> = {
9 discuss: '#4fc3f7',
10 teach: '#81c784',
11 code: '#ffb300',
12}
13
14// /ironman on|off|mode writes {"mode": "..."} here; no file means ironman is off.
15async function readMode($: EngineInterface): Promise<Mode | null> {
16 const home = await $.env.get('HOME')
17 const id = await $.session.id()
18 try {
19 const text = await $.fs.read(`${home}/.claude/ironman/sessions/${id}`)
20 return JSON.parse(text).mode ?? 'discuss'
21 } catch {
22 return null
23 }
24}
25
26async function refreshMode($: EngineInterface): Promise<void> {
27 const value = await readMode($)
28 await update($, mode, () => value)
29}
30
31export const registerBand: Register = on => {
32 on('session.start', { isInteractive: true }, async ($, e, next) => {
33 await refreshMode($)
34 return next(e)
35 })
36
37 on('turn.complete', async ($, e, next) => {
38 await refreshMode($)
39 return next(e)
40 })
41
42 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
43 const current = await read($, mode)
44 if (current === null || e.props.hasSurvey) {
45 return next(e)
46 }
47 const { Box, Text } = $.ui.resolve(e)
48 return (
49 <Box borderStyle="round" borderColor="#c62828" paddingX={1} alignSelf="flex-start">
50 <Text bold color="#ffc107">ironman</Text>
51 <Text dimColor> · </Text>
52 <Text bold color={MODE_COLOR[current]}>{current}</Text>
53 </Box>
54 )
55 })
56}
57hooks/pane.tsx 321 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Diagram, Plan, PlanItem } from '../types'
5
6const PANE = 'ironman'
7const PANE_COLUMNS = 40
8const DIAGRAM_MAX_COLUMNS = 70
9const DIAGRAM_MIN_COLUMNS = 24
10
11// Used when PUPPETEER_EXECUTABLE_PATH is unset; the converter's own browser fails here.
12const DEFAULT_CHROME = '/usr/bin/google-chrome'
13
14// Pictures are made at 3x; this many diagram pixels fill one terminal column.
15const PIXELS_PER_COLUMN = 36
16
17const plan = atom({ plugin: 'ironman', key: 'plan' } as const, null)
18const focus = atom({ plugin: 'ironman', key: 'focus' } as const, null)
19const openNotes = atom({ plugin: 'ironman', key: 'openNotes' } as const, {})
20const diagram = atom({ plugin: 'ironman', key: 'diagram' } as const, null)
21
22type Lit = 'done' | 'now' | 'todo'
23
24const MARK: Record<Lit, string> = { done: '✓', now: '▶', todo: '○' }
25const COLOR: Record<Lit, string | undefined> = { done: '#81c784', now: '#ffc107', todo: undefined }
26const BORDER: Record<Lit, string> = { done: '#81c784', now: '#ffc107', todo: '#5c6773' }
27
28const PLAN_TOOL = {
29 name: 'pane_plan',
30 description:
31 'Show the overall plan in the side pane: the blocks of a piece of code, the ' +
32 'headings of a document, or the steps of a lesson. Call it once, after the ' +
33 'user agrees to the overview. Replaces any earlier plan or diagram. Style ' +
34 '"blocks" draws boxes joined by arrows (use it for code); style "list" draws ' +
35 'an outline (lessons, documents). The default is list.',
36 isDeferred: false,
37 inputSchema: {
38 type: 'object',
39 properties: {
40 title: { type: 'string' },
41 style: { enum: ['list', 'blocks'] },
42 items: {
43 type: 'array',
44 items: {
45 type: 'object',
46 properties: {
47 id: { type: 'string', description: 'short unique name, like b3' },
48 label: { type: 'string' },
49 depth: { type: 'number', description: '0 for a heading, 1 for a step under it' },
50 note: {
51 type: 'string',
52 description: 'one to three plain sentences explaining the item; the user opens it with an arrow',
53 },
54 feeds: {
55 type: 'array',
56 items: { type: 'string' },
57 description: 'blocks style: ids of the blocks this one passes its result to',
58 },
59 },
60 required: ['id', 'label', 'depth'],
61 },
62 },
63 },
64 required: ['title', 'items'],
65 },
66}
67
68const FOCUS_TOOL = {
69 name: 'pane_focus',
70 description:
71 'Light up one item of the plan to show which piece we are on. Call it as ' +
72 'each piece starts. An empty id clears the highlight. If a diagram is showing, ' +
73 'the pane goes back to the plan.',
74 isDeferred: false,
75 inputSchema: {
76 type: 'object',
77 properties: { id: { type: 'string' } },
78 required: ['id'],
79 },
80}
81
82const DIAGRAM_TOOL = {
83 name: 'pane_diagram',
84 description:
85 'Draw a mermaid diagram in the side pane, such as the architecture of the code ' +
86 'we are about to build. Use it for the overview, before the plan. Write a ' +
87 'flowchart that runs top to bottom (flowchart TD), with at most about 8 boxes, ' +
88 'labels of one to three words, and short arrow labels. Use plain rectangles ' +
89 'like A[Label] only: no diamonds {}, no subgraphs, no styling lines. The next pane_plan ' +
90 'replaces the diagram.',
91 isDeferred: false,
92 inputSchema: {
93 type: 'object',
94 properties: {
95 title: { type: 'string' },
96 mermaid: { type: 'string', description: 'mermaid source, for example "flowchart TD\\n A[Read] --> B[Draw]"' },
97 alt: { type: 'string', description: 'one line saying what the diagram shows' },
98 },
99 required: ['title', 'mermaid'],
100 },
101}
102
103const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
104
105// Decodes the first 24 bytes of a PNG; its width and height sit at bytes 16 and 20.
106function pngSize(base64: string): { width: number; height: number } {
107 const bytes: number[] = []
108 for (let i = 0; i < 32; i += 4) {
109 const n = [0, 1, 2, 3].reduce((acc, j) => acc * 64 + B64.indexOf(base64[i + j] ?? 'A'), 0)
110 bytes.push((n >> 16) & 255, (n >> 8) & 255, n & 255)
111 }
112 const at = (o: number) =>
113 ((bytes[o]! << 24) | (bytes[o + 1]! << 16) | (bytes[o + 2]! << 8) | bytes[o + 3]!) >>> 0
114 return { width: at(16), height: at(20) }
115}
116
117// Returns the picture's size, or the converter's error text when it fails.
118async function renderMermaid($: EngineInterface, source: string,
119 base: string): Promise<{ width: number; height: number } | string> {
120 await $.fs.write(`${base}.mmd`, source)
121 const chrome = (await $.env.get('PUPPETEER_EXECUTABLE_PATH')) ?? DEFAULT_CHROME
122 const run = await $.process.run(
123 ['npx', '-y', '-p', '@mermaid-js/mermaid-cli', 'mmdc',
124 '-i', `${base}.mmd`, '-o', `${base}.png`, '-b', 'transparent',
125 '-c', `${$.plugin.root}/assets/mermaid.json`, '-s', '3'],
126 { env: { PUPPETEER_EXECUTABLE_PATH: chrome }, timeoutMs: 120_000 },
127 )
128 if (run.exitCode !== 0) return run.stderr.slice(0, 500)
129 const { base64 } = await $.fs.read(`${base}.png`, { as: 'bytes' })
130 return pngSize(base64)
131}
132
133// The pane is as wide as the picture, plus room for its border.
134function paneColumnsFor(d: { width: number }): number {
135 const fit = Math.ceil(d.width / PIXELS_PER_COLUMN) + 4
136 return Math.min(DIAGRAM_MAX_COLUMNS, Math.max(DIAGRAM_MIN_COLUMNS, fit))
137}
138
139function pictureColumns(d: Diagram, columns: number): number {
140 return Math.min(255, columns, Math.max(10, Math.ceil(d.width / PIXELS_PER_COLUMN)))
141}
142
143// A terminal cell is about twice as tall as it is wide.
144function pictureRows(d: Diagram, columns: number): number {
145 return Math.min(255, Math.max(1, Math.round((columns * (d.height / d.width)) / 2)))
146}
147
148function stateOf(i: number, at: number): Lit {
149 return i < at ? 'done' : i === at ? 'now' : 'todo'
150}
151
152// Bytes, not a file path: a terminal inside a multiplexer (herdr) cannot read the path.
153function drawDiagram(els: ElementTable, d: Diagram, png: string, columns: number): RenderElement {
154 const { Text } = els
155 if (!('Image' in els)) return <Text dimColor>{d.alt}</Text>
156 const { Image } = els
157 const cols = pictureColumns(d, columns)
158 return (
159 <Image key={d.path} source={{ png }}
160 columns={cols} rows={pictureRows(d, cols)} alt={d.alt} />
161 )
162}
163
164// The arrow that opens or closes an item's note; items without a note get none.
165function noteButton($: EngineInterface, els: ElementTable, item: PlanItem,
166 isOpen: boolean): RenderElement | null {
167 if (!item.note) return null
168 const { Button } = els
169 return (
170 <Button key={`${item.id}-note`} label={isOpen ? '▾' : '▸'}
171 onPress={() => update($, openNotes, m => ({ ...m, [item.id]: !m[item.id] }))} />
172 )
173}
174
175function drawList($: EngineInterface, els: ElementTable, items: PlanItem[], at: number,
176 opened: Record<string, boolean>): RenderElement {
177 const { Box, Text } = els
178 return (
179 <Box flexDirection="column">
180 {items.map((item, i) => {
181 const s = stateOf(i, at)
182 const isOpen = opened[item.id] === true
183 return (
184 <Box key={item.id} flexDirection="column" paddingLeft={item.depth * 2}>
185 <Box gap={1}>
186 <Text bold={s === 'now'} color={COLOR[s]} dimColor={at >= 0 && s === 'todo'}>
187 {MARK[s]} {item.label}
188 </Text>
189 {noteButton($, els, item, isOpen)}
190 </Box>
191 {item.note && isOpen ? (
192 <Box paddingLeft={2}>
193 <Text dimColor>{item.note}</Text>
194 </Box>
195 ) : null}
196 </Box>
197 )
198 })}
199 </Box>
200 )
201}
202
203function drawBlocks($: EngineInterface, els: ElementTable, items: PlanItem[], at: number,
204 opened: Record<string, boolean>): RenderElement {
205 const { Box, Text } = els
206 return (
207 <Box flexDirection="column">
208 {items.map((item, i) => {
209 const s = stateOf(i, at)
210 const isOpen = opened[item.id] === true
211 return (
212 <Box key={item.id} flexDirection="column" paddingLeft={item.depth * 2}>
213 <Box borderStyle="round" borderColor={BORDER[s]} paddingX={1} flexDirection="column">
214 <Box gap={1}>
215 <Text bold={s === 'now'} color={COLOR[s]} dimColor={at >= 0 && s === 'todo'}>
216 {MARK[s]} {item.id} {item.label}
217 </Text>
218 {noteButton($, els, item, isOpen)}
219 </Box>
220 {item.note && isOpen ? <Text dimColor>{item.note}</Text> : null}
221 </Box>
222 {item.feeds && item.feeds.length > 0 ? (
223 <Text dimColor> ▼ feeds {item.feeds.join(', ')}</Text>
224 ) : null}
225 </Box>
226 )
227 })}
228 </Box>
229 )
230}
231
232export const registerPane: Register = on => {
233 on('session.start', async ($, e, next) => {
234 await $.tool.register(PLAN_TOOL)
235 await $.tool.register(FOCUS_TOOL)
236 await $.tool.register(DIAGRAM_TOOL)
237 await $.command.register({ name: 'pane', description: 'Open the ironman side pane' })
238 return next(e)
239 })
240
241 on('command.run', { command: 'pane' }, async $ => {
242 const current = await read($, plan)
243 const shown = await read($, diagram)
244 await $.ui.open({
245 id: PANE,
246 title: shown?.title ?? current?.title ?? 'ironman',
247 columns: shown ? paneColumnsFor(shown) : PANE_COLUMNS,
248 })
249 return { text: 'ironman pane opened.' }
250 })
251
252 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
253 const els = $.ui.resolve(e)
254 const shown = await read($, diagram)
255 if (shown !== null) {
256 const { base64 } = await $.fs.read(shown.path, { as: 'bytes' })
257 return drawDiagram(els, shown, base64, Math.max(20, e.props.bodyColumns - 2))
258 }
259 const current = await read($, plan)
260 if (current === null) return <els.Text dimColor>Nothing to show yet.</els.Text>
261 const lit = await read($, focus)
262 const at = current.items.findIndex(item => item.id === lit)
263 const opened = await read($, openNotes)
264 return current.style === 'blocks'
265 ? drawBlocks($, els, current.items, at, opened)
266 : drawList($, els, current.items, at, opened)
267 })
268
269 on('tool.call', { tool: 'mcp__ironman__pane_diagram' }, async ($, e) => {
270 if (typeof e.title !== 'string' || typeof e.mermaid !== 'string') {
271 return { deny: 'pane_diagram needs a title and mermaid text' }
272 }
273 const tmp = (await $.env.get('TMPDIR')) ?? '/tmp'
274 const base = `${tmp}/ironman-pane-${await $.session.id()}/${await $.clock.now()}`
275 const made = await renderMermaid($, e.mermaid, base)
276 if (typeof made === 'string') {
277 return { result: `Mermaid failed. Fix the source and try again:\n${made}` }
278 }
279 const alt = typeof e.alt === 'string' ? e.alt : e.title
280 await update($, diagram, () => ({ title: e.title as string, path: `${base}.png`, ...made, alt }))
281 const opened = await $.ui.open({ id: PANE, title: e.title, columns: paneColumnsFor(made) })
282 const where = opened.isPlaced ? 'Diagram shown in the side pane.' : `Pane not placed: ${opened.reason}.`
283 return { result: `${where} Picture: ${base}.png` }
284 })
285
286 on('tool.call', { tool: 'mcp__ironman__pane_plan' }, async ($, e) => {
287 if (typeof e.title !== 'string' || !Array.isArray(e.items)) {
288 return { deny: 'pane_plan needs a title and a list of items' }
289 }
290 if (e.style !== undefined && e.style !== 'list' && e.style !== 'blocks') {
291 return { deny: 'pane_plan style must be "list" or "blocks"' }
292 }
293 await update($, plan, () => ({
294 title: e.title as string,
295 style: e.style as Plan['style'],
296 items: e.items as PlanItem[],
297 }))
298 await update($, focus, () => null)
299 await update($, openNotes, () => ({}))
300 await update($, diagram, () => null)
301 const opened = await $.ui.open({ id: PANE, title: e.title, columns: PANE_COLUMNS })
302 return { result: opened.isPlaced ? 'Plan shown in the side pane.' : `Pane not placed: ${opened.reason}.` }
303 })
304
305 on('tool.call', { tool: 'mcp__ironman__pane_focus' }, async ($, e) => {
306 if (typeof e.id !== 'string') {
307 return { deny: 'pane_focus needs an id' }
308 }
309 const current = await read($, plan)
310 if (e.id !== '' && !current?.items.some(i => i.id === e.id)) {
311 return { deny: `no plan item has the id ${e.id}` }
312 }
313 await update($, focus, () => (e.id === '' ? null : e.id))
314 if (current !== null && (await read($, diagram)) !== null) {
315 await update($, diagram, () => null)
316 await $.ui.open({ id: PANE, title: current.title, columns: PANE_COLUMNS })
317 }
318 return { result: 'Highlight moved.' }
319 })
320}
321types/index.d.ts 25 lines1export type Mode = 'discuss' | 'teach' | 'code'
2
3export type PlanItem = {
4 id: string
5 label: string
6 depth: number
7 note?: string
8 feeds?: string[]
9}
10export type Plan = { title: string; style?: 'list' | 'blocks'; items: PlanItem[] }
11
12export type Diagram = { title: string; path: string; width: number; height: number; alt: string }
13
14declare module 'claude-code' {
15 interface PluginState {
16 ironman: {
17 mode: Mode | null
18 plan: Plan | null
19 focus: string | null
20 openNotes: Record<string, boolean>
21 diagram: Diagram | null
22 }
23 }
24}
25