Re-explain the last answer as ASD-STE100 text, a diagram, an HTML page or an explainer video, and score answers for STE style.

A Claude Code mod that asks the model to re-explain its last answer as ASD-STE100 text, a diagram, an HTML page or an explainer video, and scores each answer for STE style in the status line.
Based on a post by Andrej Karpathy about making LLM output easier to understand (ASD-STE100 text, diagrams, HTML pages, explainer videos). Not affiliated with him.
/plugin marketplace add 0xGondarxyz/claude-code-mods
/plugin install output-ladder@claude-code-mods
Or try it without installing:
git clone https://github.com/0xGondarxyz/claude-code-mods
claude --plugin-dir claude-code-mods/output-ladder
Mods are not sandboxed. They run with the same access as Claude Code. Read the source before you install any mod, including this one.
Each command sends a prompt about your last answer. Text you type after the command is added as Extra instruction: <text>.
| Command | What it asks for |
|---|---|
/ste | The last answer rewritten about 80% of the way to ASD-STE100. /ste full asks for strict ASD-STE100. |
/diagram | An ASCII diagram with boxes and arrows, 100 columns wide, plus at most 3 lines of notes. |
/page | One self-contained HTML page with inline SVG. It is published if an Artifact or publish tool exists, otherwise saved to a file. |
/video | A short explainer video in the style of 3Blue1Brown: a scene plan first, free or local tools, an MP4 at the end. |
When an answer has 120 or more words of prose, four buttons appear above the prompt: STE, Diagram, Page, Video. Pressing one sends the same prompt as the command (without extra instruction) and hides the buttons. The buttons also hide when you send a new prompt.
After each answer the status line shows STE 82%. The score is 100 times a weighted mix of three shares:
Code blocks, inline code, table rows, headings and URLs are left out. List items count as sentences. An answer with fewer than 40 prose words gets no score, and the status line keeps the previous one.
The score measures style only. The official ASD-STE100 dictionary and rules are not bundled, so it cannot tell whether a word is approved. The passive check is a simple pattern and has false positives and misses.
claude plugin test .
MIT
hooks/register.tsx 119 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { BUTTON_LABELS, DESCRIPTIONS, RUNGS, buildAsk, responseText } from './ladder'
5import { proseWordCount, scoreSte, statusText } from './ste'
6
7const BAND_MIN_WORDS = 120
8
9const isShown = atom({ plugin: 'output-ladder', key: 'isShown' } as const, false)
10
11export const register: Register = on => {
12 // Text of the latest turn's answer. A turn can append several messages.
13 let answer = ''
14 let isFresh = true
15
16 on('session.start', async ($, e, next) => {
17 try {
18 for (const rung of RUNGS) {
19 await $.command.register({
20 name: rung,
21 description: DESCRIPTIONS[rung],
22 argumentHint: rung === 'ste' ? '[full] [extra instruction]' : '[extra instruction]',
23 })
24 }
25 } catch {}
26 return next(e)
27 })
28
29 for (const rung of RUNGS) {
30 on('command.run', { command: rung }, async ($, e) => {
31 const ask = buildAsk(rung, e.args)
32 await update($, isShown, () => false)
33 // The engine refuses a submit from inside command.run (it would wait on
34 // this very hook), so the prompt is queued right after the hook returns.
35 $.clock.after(0, () => {
36 void $.prompt.submit({ text: ask.text, asUser: true })
37 })
38 return { text: ask.reply }
39 })
40 }
41
42 on('session.append', ($, e, next) => {
43 try {
44 const text = responseText(e)
45 if (text !== '') {
46 answer = isFresh ? text : `${answer}\n\n${text}`
47 isFresh = false
48 }
49 } catch {}
50 return next(e)
51 })
52
53 on('prompt.submit', async ($, e, next) => {
54 answer = ''
55 isFresh = true
56 await update($, isShown, () => false)
57 return next(e)
58 })
59
60 on('turn.complete', async ($, e, next) => {
61 try {
62 if (e.agentId === undefined && e.reason === 'answer') {
63 // Rows seen by session.append; the turn's final text is the fallback.
64 const text = answer !== '' ? answer : e.answer
65 answer = ''
66 isFresh = true
67 const scored = scoreSte(text)
68 if (scored) $.ui.status(statusText(scored.score))
69 await update($, isShown, () => proseWordCount(text) >= BAND_MIN_WORDS)
70 }
71 } catch {}
72 return next(e)
73 })
74
75 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
76 if (e.props.hasSurvey || !(await read($, isShown))) return next(e)
77
78 const { Box, Button, Text } = $.ui.resolve(e)
79
80 return (
81 <Box>
82 <Text dimColor>Explain it another way: </Text>
83 <Button
84 key="ste"
85 label={BUTTON_LABELS.ste}
86 onPress={async () => {
87 await update($, isShown, () => false)
88 await $.prompt.submit({ text: buildAsk('ste').text, asUser: true })
89 }}
90 />
91 <Button
92 key="diagram"
93 label={BUTTON_LABELS.diagram}
94 onPress={async () => {
95 await update($, isShown, () => false)
96 await $.prompt.submit({ text: buildAsk('diagram').text, asUser: true })
97 }}
98 />
99 <Button
100 key="page"
101 label={BUTTON_LABELS.page}
102 onPress={async () => {
103 await update($, isShown, () => false)
104 await $.prompt.submit({ text: buildAsk('page').text, asUser: true })
105 }}
106 />
107 <Button
108 key="video"
109 label={BUTTON_LABELS.video}
110 onPress={async () => {
111 await update($, isShown, () => false)
112 await $.prompt.submit({ text: buildAsk('video').text, asUser: true })
113 }}
114 />
115 </Box>
116 )
117 })
118}
119hooks/ladder.ts 76 lines1// Prompt texts and argument handling for the four rungs.
2
3export type Rung = 'ste' | 'diagram' | 'page' | 'video'
4
5export const RUNGS: readonly Rung[] = ['ste', 'diagram', 'page', 'video']
6
7export const BUTTON_LABELS: Record<Rung, string> = {
8 ste: 'STE',
9 diagram: 'Diagram',
10 page: 'Page',
11 video: 'Video',
12}
13
14export const DESCRIPTIONS: Record<Rung, string> = {
15 ste: 'Re-explain the last answer in ASD-STE100 style (add "full" for strict)',
16 diagram: 'Re-explain the last answer as an ASCII diagram',
17 page: 'Turn the last answer into a self-contained HTML page',
18 video: 'Make a short explainer video of the last answer',
19}
20
21const PROMPTS: Record<Rung, string> = {
22 ste: 'Rewrite your last answer about 80% of the way to ASD-STE100 Simplified Technical English: sentences of 20 words or fewer, common words with one meaning each, active voice, present tense, one idea per sentence, no idioms. Keep every fact and every number.',
23 diagram:
24 'Explain your last answer as a diagram. Draw an ASCII diagram with boxes and arrows that fits in 100 columns, then add at most 3 short lines of notes.',
25 page: 'Turn your last answer into one self-contained HTML page that explains it visually: clear layout, inline SVG diagrams, small interactions only where they help understanding. If an Artifact or publish tool is available, publish it; otherwise save the file and give its path.',
26 video:
27 'Make a short explainer video of your last answer in the style of 3Blue1Brown. First show a scene plan. Use free or local tools for visuals and narration; do not use paid APIs unless I ask. Render an MP4 and give its path.',
28}
29
30const STE_FULL =
31 'Rewrite your last answer in ASD-STE100 Simplified Technical English, following the specification strictly. Keep every fact and every number.'
32
33const REPLIES: Record<Rung, string> = {
34 ste: 'Asking for an STE version of the last answer.',
35 diagram: 'Asking for a diagram of the last answer.',
36 page: 'Asking for an HTML page of the last answer.',
37 video: 'Asking for an explainer video of the last answer.',
38}
39
40const REPLY_STE_FULL = 'Asking for a full ASD-STE100 version of the last answer.'
41
42export type Ask = { text: string; reply: string }
43
44/** The prompt and the reply line for a rung. `args` is what the user typed after the command. */
45export function buildAsk(rung: Rung, args = ''): Ask {
46 let extra = args.trim()
47 let isFull = false
48 if (rung === 'ste') {
49 const m = /^full(?:\s+|$)/i.exec(extra)
50 if (m) {
51 isFull = true
52 extra = extra.slice(m[0].length).trim()
53 }
54 }
55 const base = isFull ? STE_FULL : PROMPTS[rung]
56 return {
57 text: extra === '' ? base : `${base}\nExtra instruction: ${extra}`,
58 reply: isFull ? REPLY_STE_FULL : REPLIES[rung],
59 }
60}
61
62type AppendLike = {
63 door: string
64 agentId?: string
65 message: { type: string; content: ReadonlyArray<{ type: string; text?: unknown }> }
66}
67
68/** Text blocks of one assistant response row of the main conversation, or '' for any other row. */
69export function responseText(e: AppendLike): string {
70 if (e.door !== 'response' || e.message.type !== 'assistant' || e.agentId !== undefined) return ''
71 return e.message.content
72 .map(block => (block.type === 'text' && typeof block.text === 'string' ? block.text : ''))
73 .filter(t => t !== '')
74 .join('\n\n')
75}
76hooks/ste.ts 78 lines1// Pure STE style scoring. It measures style only: sentence length, passive
2// voice and word length. The official ASD-STE100 dictionary is not used.
3
4export type SteScore = { score: number; sentences: number; words: number }
5
6const MIN_WORDS = 40
7const MAX_SENTENCE_WORDS = 20
8const MAX_WORD_LENGTH = 12
9const PASSIVE = /\b(am|is|are|was|were|be|been|being)\s+(\w+ly\s+)?\w+(ed|en)\b/i
10const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/
11
12/** Prose sentences of a markdown answer: code, tables, headings, URLs and blank lines dropped. */
13export function proseSentences(text: string): string[] {
14 const noFences = text.replace(/```[\s\S]*?(?:```|$)/g, '\n')
15 const blocks: string[] = []
16 let buf: string[] = []
17 const flush = () => {
18 if (buf.length > 0) blocks.push(buf.join(' '))
19 buf = []
20 }
21 for (const raw of noFences.split(/\r?\n/)) {
22 const trimmed = raw.trim()
23 if (trimmed === '' || trimmed.startsWith('|') || trimmed.startsWith('#')) {
24 flush()
25 continue
26 }
27 const line = trimmed.replace(/`[^`]*`/g, ' ').replace(/https?:\/\/\S+/g, ' ')
28 const body = line.replace(LIST_ITEM, '').trim()
29 if (body === '') {
30 flush()
31 continue
32 }
33 if (LIST_ITEM.test(trimmed)) {
34 flush()
35 blocks.push(body)
36 } else {
37 buf.push(body)
38 }
39 }
40 flush()
41 const sentences: string[] = []
42 for (const block of blocks) {
43 for (const part of block.split(/(?<=[.!?])\s+/)) {
44 if (wordsOf(part).length > 0) sentences.push(part.trim())
45 }
46 }
47 return sentences
48}
49
50function wordsOf(sentence: string): string[] {
51 return sentence
52 .split(/\s+/)
53 .map(w => w.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ''))
54 .filter(w => w !== '')
55}
56
57/** Number of prose words in an answer, by the same rules as the score. */
58export function proseWordCount(text: string): number {
59 return proseSentences(text).reduce((n, s) => n + wordsOf(s).length, 0)
60}
61
62export function scoreSte(text: string): SteScore | undefined {
63 const sentences = proseSentences(text)
64 const lengths = sentences.map(s => wordsOf(s))
65 const words = lengths.reduce((n, w) => n + w.length, 0)
66 if (words < MIN_WORDS) return undefined
67 const count = sentences.length
68 const short = lengths.filter(w => w.length <= MAX_SENTENCE_WORDS).length
69 const active = sentences.filter(s => !PASSIVE.test(s)).length
70 const plain = lengths.reduce((n, w) => n + w.filter(x => x.length <= MAX_WORD_LENGTH).length, 0)
71 const score = Math.round(100 * (0.6 * (short / count) + 0.25 * (active / count) + 0.15 * (plain / words)))
72 return { score, sentences: count, words }
73}
74
75export function statusText(score: number): string {
76 return `STE ${score}%`
77}
78types/index.d.ts 11 lines1export type LadderState = {
2 /** True while the four buttons are shown above the prompt. */
3 isShown: boolean
4}
5
6declare module 'claude-code' {
7 interface PluginState {
8 'output-ladder': { isShown: boolean }
9 }
10}
11