Puts a receipt line under each turn (files edited, tests and builds with exit status, commits) and draws wip:compare blocks in replies side by side; older…

Three things: a receipt line under each turn that says what the tools did and how the reply reads (costs the model nothing), a writing check that sends one line with the next prompt only when a reply broke a rule, and drawn fenced blocks in replies, where the model sends only short inputs and the mod draws them. Without the mod each block still reads as plain text.
Commands: /visuals on|off turns the blocks and the model rule on or off; /visuals receipt on|off turns the receipt line on or off; /visuals alone reports both states, and any other argument prints usage and changes nothing. /plainspeak strict|80|off sets the writing check (default off: no score, no note).
After a main-loop turn, one line under the answer, built from the turn's own tool calls (tool.call) and shown at turn.complete. With /plainspeak strict or 80 the writing score follows:
✎ 2 files · ✔ go test · ⎇ a5f39e1 · 2m10s
✎ 2 files · ✔ go test · ⎇ a5f39e1 · 2m10s · tells 0 · long 1 · em— 0
| Part | Source | ||||
|---|---|---|---|---|---|
✎ n files | Edit, Write, MultiEdit, NotebookEdit paths, counted once each | ||||
✔ go test, ✘ go test (exit 1), ? npm test (exit unknown) | Bash commands whose shape is a test (go test, npm test, bun test, pytest, vitest, jest, claude plugin test, wipnote check, …) or a build/check (go build, go vet, tsc, claude plugin validate, …). Leading NAME=value assignments and wrappers (time, sudo, env, nohup, nice, timeout, npx, bunx) are stripped first, so CGO_ENABLED=0 go test is the test run. | ||||
| chained commands | One Bash call has one exit status, and the receipt never claims more than it tells. a && b && c that succeeds: every piece ✔. One that fails: which piece failed is unknown, so the chain is one item, ✘ go build && go vet && go test (exit 1), and it counts as a failed test run when a test is in it. a ; b and `a \ | b report b only; a is ?. a \ | \ | b that succeeds hides which ran (?); one that fails means both failed (✘). An interrupted call is ? throughout. So go test ./... \ | tail -5 reads ? go test (exit unknown)`: drop the pipe if you want the exit known. |
⎇ a5f39e1 | The sha core attaches to a committing Bash call (gitOperation.commit.sha); ⎇ commit when a git commit ran but no sha came back | ||||
2m10s | e.durationMs | ||||
⚠ reply says tests pass; no test command ran this turn | The answer claims passing tests (tests pass, all 39 tests pass, all checks passed, suite is green, …) and no test command ended ok. Also … go test failed and … ended with an unknown exit. A negated sentence (tests did not pass, no checks passed, don't pass) and another sense of the verb (checks pass through the proxy) are not claims. |
Nothing is drawn for a turn with no edits and no test/build/commit commands (a flagged claim still shows). Subagent calls and turns are left out; the receipt is the main loop's. An interrupted turn gets no line, and its collected calls are dropped, so nothing leaks into the next receipt.
Exit codes. The Bash tool's record carries stdout, stderr, interrupted and gitOperation, but no exit code. A non-zero exit makes core answer the call with isError: true, and the model-facing text usually says Exit code N, which the receipt reads when present. So ✔ means the call answered without error, ✘ means it errored or a guard denied it, and ? means interrupted or an unreadable result.
Helpers are pure in hooks/receipt.ts; tests/receipt-fixture.ts holds 16 fake turns the tests must reproduce exactly. The hooks live in hooks/turnhooks.ts.
hooks/lint.ts (pure, ported from luladoc's Zinsser rules) checks a reply for AI tells (banned words, openers, filler, em dashes, same opener three times, a short negating closer, curly quotes or en dashes) and sentences over 25 words (20 in a list item). Code, inline code, URLs and tables are ignored.
/plainspeak off (default) shows no score and sends no note. 80 checks banned words, filler, em dashes and long sentences. strict adds the repeated opener, the negating closer and curly quotes or en dashes.80 or strict, tells n · long n · em— n is appended to the receipt line of every main-loop turn. A tool-free turn shows the score alone only when it breaks a rule, so a clean short answer adds no line.80 or strict, a reply that breaks a rule earns one line (about 20 tokens) such as Last reply: "crucial", 2 em dashes in ¶1, 1 sentence >25 words. It goes with the next prompt the user types as prompt.submit context (text only the model reads), once; a clean reply or an interrupted turn sends nothing and clears any pending note.robust, enhance, landscape of the X) are not in the lint list; the Plain English style still bans them for the model.The note travels as prompt context rather than a system-prompt section on purpose: a section that changes between turns would break the prompt cache on every request.
eval/lintcheck.ts runs the rules offline over saved replies and a session transcript; results and verdicts are in eval/README.md under "Lint false-positive check".
The rule (about 60 tokens, prompt.md) asks for one block only. wipnote claude appends it to the system prompt when it loads the mods, so /visuals off stops the drawing but not the rule. It is not a prompt.compose hook, because a managed policy plugin that sends that event past the user tier skips every mod's hook.
wip:compare — # Option starts an option (a trailing * marks the recommended one), then + pro and - con lines. Drawn side by side.
Two older blocks still parse and draw, so earlier transcripts keep their look, but the rule no longer asks for them: a numbered list and a bold Warning: line are Markdown already (feat-ca6bd746, design section 3).
wip:steps — one step per line; x = done, > = current, no prefix = todo.
wip:callout — first line decision:, warning: or note: then text; further lines continue it.
Open or malformed fences stay plain text. Parsers are in hooks/blocks.ts (pure), drawing in hooks/components.tsx.
claude plugin validate mods/wipnote-visuals
claude plugin test mods/wipnote-visuals
eval/ holds the live-model eval for the rule and the offline lint check (see eval/README.md). The receipt's real-session measurement (20 turns: line shown when and only when tools changed something, classifier misses, mismatch flags that were right) is still to do.
hooks/register.tsx 91 lines1// wipnote-visuals: draws wip:compare (and older wip:steps / wip:callout) fences
2// in replies, puts a receipt line (with the writing score when /plainspeak is
3// on) under each main-loop turn, and sends a one-line writing note with the
4// next prompt when a reply broke a rule (turnhooks.ts).
5// Same flow as wipnote-orchestra's wipnote-ref cards: a one-section rule asks
6// the model for the fences (only while the mod is on), and a ui.render hook on
7// AssistantMessage draws them. The stored message keeps the plain fence.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import { splitBlocks } from './blocks'
12import { renderBlock } from './components'
13import { registerTurn } from './turnhooks'
14
15const enabled = atom({ plugin: 'wipnote-visuals', key: 'enabled' } as const, true)
16// Same state as turnhooks' `receipt`: the scan needs the atom declared in the file that reads it.
17const receipt = atom({ plugin: 'wipnote-visuals', key: 'receipt' } as const, true)
18
19const USAGE = 'Usage: /visuals on|off|receipt on|receipt off (no argument reports the state).'
20
21export type VisualsArgs = { kind: 'set'; which: 'blocks' | 'receipt'; wanted: boolean } | { kind: 'status'; which: 'blocks' | 'receipt' | 'both' } | { kind: 'usage' }
22
23/** `/visuals [on|off|receipt [on|off]]`: anything else is a usage message and changes nothing. */
24export function parseVisualsArgs(args: string): VisualsArgs {
25 const words = String(args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean)
26 if (words.length === 0) return { kind: 'status', which: 'both' }
27 const which = words[0] === 'receipt' ? 'receipt' : 'blocks'
28 const flag = which === 'receipt' ? words[1] : words[0]
29 if (words.length > (which === 'receipt' ? 2 : 1)) return { kind: 'usage' }
30 if (flag === undefined) return which === 'receipt' ? { kind: 'status', which } : { kind: 'usage' }
31 if (flag !== 'on' && flag !== 'off') return { kind: 'usage' }
32 return { kind: 'set', which, wanted: flag === 'on' }
33}
34
35async function registerCommands($: EngineInterface): Promise<void> {
36 await $.command.register({
37 name: 'visuals',
38 description: 'Draw wip:* blocks as visuals (on|off); show the turn receipt under replies (receipt on|off); no argument reports the state',
39 argumentHint: 'on|off|receipt on|receipt off',
40 })
41 await $.command.register({
42 name: 'plainspeak',
43 description: 'Writing check: strict or 80 (fewer rules) add a score to the receipt and a one-line note to the next prompt when a reply has AI tells or long sentences; off (default) does neither',
44 argumentHint: 'strict|80|off',
45 })
46}
47
48const onOff = (v: boolean) => (v ? 'on' : 'off')
49
50/** The state scan needs each atom named literally at its read and update, so both branches are spelled out. */
51async function runVisuals($: EngineInterface, args: string): Promise<string> {
52 const a = parseVisualsArgs(args)
53 if (a.kind === 'usage') return USAGE
54 if (a.kind === 'status') {
55 const [b, r] = await Promise.all([read($, enabled), read($, receipt)])
56 return a.which === 'receipt' ? `Receipt: ${onOff(r)}.` : `Visuals: ${onOff(b)}. Receipt: ${onOff(r)}.`
57 }
58 if (a.which === 'receipt') {
59 await update($, receipt, () => a.wanted)
60 return `Receipt: ${onOff(a.wanted)}.`
61 }
62 await update($, enabled, () => a.wanted)
63 return `Visuals: ${onOff(a.wanted)}.`
64}
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 await registerCommands($)
69 return next(e)
70 })
71
72 on('command.run', { command: 'visuals' }, async ($, e) => ({ text: await runVisuals($, e.args) }))
73
74 registerTurn(on)
75
76 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
77 if (!(await read($, enabled))) return next(e)
78 const segments = splitBlocks(e.props.text)
79 if (!segments.some(s => s.kind === 'block')) return next(e)
80 const ui = $.ui.resolve(e)
81 const { Box, Markdown } = ui
82 return (
83 <Box flexDirection="column">
84 {segments.map((s, i) =>
85 s.kind === 'text' ? <Markdown key={`t${i}`} text={s.text} /> : <Box key={`b${i}`}>{renderBlock(ui, s.block)}</Box>,
86 )}
87 </Box>
88 )
89 })
90}
91hooks/blocks.ts 110 lines1// Pure parsers for the wip:* fenced blocks. No engine imports. Every parser is
2// total: malformed input gives a best-effort parse or null, never a throw.
3// Each format reads fine as plain text without the mod.
4
5export type StepState = 'done' | 'next' | 'todo'
6export type Step = { state: StepState; text: string }
7export type Option = { name: string; recommended: boolean; pros: string[]; cons: string[] }
8export type CalloutKind = 'decision' | 'warning' | 'note'
9export type Callout = { kind: CalloutKind; text: string }
10
11export type Block =
12 | { kind: 'steps'; steps: Step[] }
13 | { kind: 'compare'; options: Option[] }
14 | { kind: 'callout'; callout: Callout }
15
16export type Segment = { kind: 'text'; text: string } | { kind: 'block'; block: Block }
17
18const lines = (body: string): string[] =>
19 String(body ?? '').split(/\r\n|\r|\n/).map(l => l.trim()).filter(l => l !== '')
20
21/** `x text` = done, `> text` = next, otherwise todo. Null when no step has text. */
22export function parseSteps(body: string): Step[] | null {
23 const steps: Step[] = []
24 for (const l of lines(body)) {
25 const m = /^([x>])(?:\s+(.*))?$/.exec(l)
26 const state: StepState = m ? (m[1] === 'x' ? 'done' : 'next') : 'todo'
27 const text = (m ? m[2] : l) ?? ''
28 if (text.trim() !== '') steps.push({ state, text: text.trim() })
29 }
30 return steps.length > 0 ? steps : null
31}
32
33/** `# Name` (trailing `*` = recommended) opens an option; `+ pro` / `- con` lines follow. */
34export function parseCompare(body: string): Option[] | null {
35 const options: Option[] = []
36 let cur: Option | undefined
37 for (const l of lines(body)) {
38 const head = /^#+\s*(.*)$/.exec(l)
39 if (head) {
40 const raw = (head[1] ?? '').trim()
41 const recommended = raw.endsWith('*')
42 const name = (recommended ? raw.slice(0, -1) : raw).trim()
43 cur = { name: name || `Option ${options.length + 1}`, recommended, pros: [], cons: [] }
44 options.push(cur)
45 continue
46 }
47 const item = /^([+-])\s*(.*)$/.exec(l)
48 if (!item || (item[2] ?? '') === '') continue
49 if (!cur) {
50 cur = { name: 'Option 1', recommended: false, pros: [], cons: [] }
51 options.push(cur)
52 }
53 ;(item[1] === '+' ? cur.pros : cur.cons).push(item[2] as string)
54 }
55 return options.length > 0 ? options : null
56}
57
58/** First line `kind: text` (decision, warning, note); later lines continue the text. */
59export function parseCallout(body: string): Callout | null {
60 const ls = lines(body)
61 if (ls.length === 0) return null
62 const m = /^(decision|warning|note)\s*:\s*(.*)$/i.exec(ls[0] as string)
63 const kind = (m ? (m[1] as string).toLowerCase() : 'note') as CalloutKind
64 const first = m ? (m[2] as string) : (ls[0] as string)
65 const text = [first, ...ls.slice(1)].filter(s => s !== '').join('\n')
66 return text === '' ? null : { kind, text }
67}
68
69export function parseBlock(name: string, body: string): Block | null {
70 try {
71 if (name === 'steps') {
72 const steps = parseSteps(body)
73 return steps && { kind: 'steps', steps }
74 }
75 if (name === 'compare') {
76 const options = parseCompare(body)
77 return options && { kind: 'compare', options }
78 }
79 if (name === 'callout') {
80 const callout = parseCallout(body)
81 return callout && { kind: 'callout', callout }
82 }
83 } catch {
84 // total by contract
85 }
86 return null
87}
88
89const FENCE = /^[ \t]*```wip:(steps|compare|callout)[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```[ \t]*$/gm
90
91/**
92 * Splits markdown into text runs and parsed blocks. An open (streaming) fence
93 * or one that does not parse stays text, so it draws as typed.
94 */
95export function splitBlocks(markdown: string): Segment[] {
96 const src = String(markdown ?? '')
97 const out: Segment[] = []
98 let last = 0
99 for (const m of src.matchAll(FENCE)) {
100 const block = parseBlock(m[1] as string, m[2] as string)
101 if (!block) continue
102 if (m.index > last) out.push({ kind: 'text', text: src.slice(last, m.index) })
103 out.push({ kind: 'block', block })
104 last = m.index + m[0].length
105 }
106 if (out.length === 0) return [{ kind: 'text', text: src }]
107 if (last < src.length) out.push({ kind: 'text', text: src.slice(last) })
108 return out
109}
110hooks/components.tsx 58 lines1// Draws parsed wip:* blocks. `ui` is the element table from $.ui.resolve(e),
2// so the same tree works on terminal and desktop surfaces.
3import type { Block, CalloutKind } from './blocks'
4
5type Ui = { Box: any; Text: any } // the resolved element table; props are surface-checked at draw time
6
7const KIND_COLOR: Record<CalloutKind, string> = { decision: 'suggestion', warning: 'warning', note: 'subtle' }
8const KIND_LABEL: Record<CalloutKind, string> = { decision: 'Decision', warning: 'Warning', note: 'Note' }
9const MARK = { done: '✓', next: '→', todo: '·' } as const
10const MARK_COLOR = { done: 'success', next: 'suggestion', todo: 'subtle' } as const
11
12export function Steps({ ui, steps }: { ui: Ui; steps: { state: 'done' | 'next' | 'todo'; text: string }[] }) {
13 const { Box, Text } = ui
14 return (
15 <Box flexDirection="column">
16 {steps.map((s, i) => (
17 <Text key={i}>
18 <Text color={MARK_COLOR[s.state]}>{MARK[s.state]}</Text>
19 <Text dimColor>{` ${i + 1}. `}</Text>
20 <Text bold={s.state === 'next'} dimColor={s.state === 'done'}>{s.text}</Text>
21 </Text>
22 ))}
23 </Box>
24 )
25}
26
27export function Compare({ ui, options }: { ui: Ui; options: { name: string; recommended: boolean; pros: string[]; cons: string[] }[] }) {
28 const { Box, Text } = ui
29 return (
30 <Box flexDirection="row" gap={1}>
31 {options.map((o, i) => (
32 <Box key={i} flexDirection="column" flexGrow={1} paddingX={1}
33 borderStyle="round" borderColor={o.recommended ? 'success' : 'subtle'}>
34 <Text bold color={o.recommended ? 'success' : undefined}>{o.name}{o.recommended ? ' ★ recommended' : ''}</Text>
35 {o.pros.map((p, j) => <Text key={`p${j}`}><Text color="success">+ </Text>{p}</Text>)}
36 {o.cons.map((c, j) => <Text key={`c${j}`}><Text color="error">- </Text>{c}</Text>)}
37 </Box>
38 ))}
39 </Box>
40 )
41}
42
43export function Callout({ ui, kind, text }: { ui: Ui; kind: CalloutKind; text: string }) {
44 const { Box, Text } = ui
45 return (
46 <Box flexDirection="column" borderStyle="round" borderColor={KIND_COLOR[kind]} paddingX={1}>
47 <Text bold color={KIND_COLOR[kind]}>{KIND_LABEL[kind]}</Text>
48 <Text>{text}</Text>
49 </Box>
50 )
51}
52
53export function renderBlock(ui: Ui, block: Block) {
54 if (block.kind === 'steps') return <Steps ui={ui} steps={block.steps} />
55 if (block.kind === 'compare') return <Compare ui={ui} options={block.options} />
56 return <Callout ui={ui} kind={block.callout.kind} text={block.callout.text} />
57}
58hooks/turnhooks.ts 98 lines1// The per-turn hooks: the receipt (tool.call collects, turn.complete shows) and
2// the writing check (lintReply on the finished answer; the score beside the
3// receipt, and a one-line note for the next prompt when the reply broke a rule).
4//
5// The note travels as `prompt.submit` context, not as a system-prompt section:
6// a section that changes between turns would break the prompt cache on every
7// request, while context rides on the user message, which is new anyway. A
8// clean reply sends nothing, and a note is sent once.
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register } from 'claude-code'
11
12import { feedbackLine, lintReply } from './lint'
13import type { Mode } from './lint'
14import { emptyTurn, noteCall, receiptLine } from './receipt'
15import type { Turn } from './receipt'
16
17type On = Parameters<Register>[0]
18
19const receipt = atom({ plugin: 'wipnote-visuals', key: 'receipt' } as const, true)
20const turn = atom({ plugin: 'wipnote-visuals', key: 'turn' } as const, emptyTurn())
21const plainspeak = atom({ plugin: 'wipnote-visuals', key: 'plainspeak' } as const, 'off')
22const lintNote = atom({ plugin: 'wipnote-visuals', key: 'lintNote' } as const, '')
23
24const MODES: readonly Mode[] = ['strict', '80', 'off']
25
26/** `/plainspeak strict|80|off`; no argument reports the mode. */
27export function parseMode(args: string): Mode | undefined {
28 const w = String(args ?? '').trim().toLowerCase()
29 return (MODES as readonly string[]).includes(w) ? (w as Mode) : undefined
30}
31
32/**
33 * The line under the answer: the receipt, then the lint score in the
34 * /plainspeak mode. `off` adds no score. The score alone shows only when the
35 * reply broke a rule, so a clean tool-free turn stays quiet.
36 */
37export function turnLine(t: Turn, answer: string, durationMs: number, mode: Mode): string | null {
38 const line = receiptLine(t, answer, durationMs)
39 if (mode === 'off') return line
40 const report = lintReply(answer, { mode })
41 if (line === null) return report.findings.length > 0 ? report.score : null
42 const [first, ...rest] = line.split('\n')
43 return [`${first} · ${report.score}`, ...rest].join('\n')
44}
45
46/** Ends the turn's bookkeeping: reset the collected calls, set or clear the note. */
47async function completeTurn($: EngineInterface, e: { answer: string; durationMs: number }, text: string): Promise<string> {
48 const t = await read($, turn)
49 await update($, turn, () => emptyTurn())
50 const mode = (await read($, plainspeak)) as Mode
51 await update($, lintNote, () => (mode === 'off' ? '' : feedbackLine(lintReply(e.answer, { mode }))))
52 if (!(await read($, receipt))) return text
53 return turnLine(t, e.answer, e.durationMs, mode) ?? text
54}
55
56/** An aborted turn leaves nothing behind: no stale calls, no stale note. */
57async function abortTurn($: EngineInterface): Promise<void> {
58 await update($, turn, () => emptyTurn())
59 await update($, lintNote, () => '')
60}
61
62export function registerTurn(on: On): void {
63 // The receipt reads the main loop only; a subagent's calls are its own turn.
64 on('tool.call', async ($, e, next) => {
65 const r = await next(e)
66 if (e.agentId === undefined && (await read($, receipt))) {
67 await update($, turn, (t: Turn) => noteCall(t, e as never, r))
68 }
69 return r
70 }).catch(($, e, next) => next(e))
71
72 on('turn.complete', async ($, e, next) => {
73 const r = await next(e)
74 if (e.agentId !== undefined) return r
75 if (e.isAborted) {
76 await abortTurn($)
77 return r
78 }
79 return { ...r, text: await completeTurn($, e, r.text) }
80 }).catch(($, e, next) => next(e))
81
82 // The note rides the next prompt the user types, once.
83 on('prompt.submit', async ($, e, next) => {
84 const note = await read($, lintNote)
85 if (note === '' || e.origin !== undefined) return next(e)
86 await update($, lintNote, () => '')
87 return next({ ...e, context: [...(e.context ?? []), note] })
88 }).catch(($, e, next) => next(e))
89
90 on('command.run', { command: 'plainspeak' }, async ($, e) => {
91 const wanted = parseMode(e.args)
92 if (wanted === undefined) return { text: `Plainspeak: ${await read($, plainspeak)} (strict|80|off).` }
93 await update($, plainspeak, () => wanted)
94 if (wanted === 'off') await update($, lintNote, () => '')
95 return { text: `Plainspeak: ${wanted}.` }
96 })
97}
98hooks/lint.ts 193 lines1// Pure writing lint for chat replies: AI tells and STE sentence limits.
2// Rules are ported from luladoc styles/Zinsser/*.yml (see feat-de13460b).
3// No engine or Node imports: runs inside the mod sandbox and in plain tests.
4
5export type Finding = { rule: string; match: string; index: number; fix?: string; n?: number; para?: number }
6export type Report = { findings: Finding[]; counts: Record<string, number>; longSentences: number; score: string }
7export type Mode = 'strict' | '80' | 'off'
8
9const VOCAB = new RegExp(
10 '\\b(?:delve|delves|delving|pivotal|testament|tapestry|underscore|underscores|underscoring|showcase|showcases|showcasing|vibrant|crucial|fostering|intricate|intricacies|enduring|garner|garners|garnering|resonate|resonates|resonating|invaluable|ignited|igniting|embark|embarks|embarking|realm|beacon|honing|cutting-edge|seamless|holistic|esteemed|renowned|passion|passionate|empower|empowers|empowering|transformative|comprehensive understanding|requisite skills|high-impact|make a difference in people[\'’]s lives|perfect blend|well-equipped|uniquely positioned|game-changer)\\b' +
11 // Context-sensitive entries: only the AI-tell sense, not literal uses.
12 '|(?:the |a |an )?(?:competitive|educational|academic|professional|digital|technological|regulatory|political|economic|healthcare|corporate|research|media|cultural)\\s+landscape\\b' +
13 '|(?:academic|personal|professional|educational|career|life|spiritual|entrepreneurial|leadership|research|learning|growth)\\s+journey\\b' +
14 '|journey (?:to become|towards|toward|of self-discovery|of growth|of learning)' +
15 '|dynamic\\s+(?:individual|leader|team|environment|person|professional|thinker|duo|place to work)\\b' +
16 '|leverag(?:ing|ed)\\b|leverage\\s+(?:my|our|your|this|that|these|those|the)\\b',
17 'gi',
18)
19const OPENER_PHRASE = /^(?:great question|certainly!|absolutely!)/i
20const FILLER = /\b(?:in order to|due to the fact that|it is important to note that|in conclusion)\b/gi
21const FILLER_FIX: Record<string, string> = {
22 'in order to': 'to',
23 'due to the fact that': 'because',
24 'it is important to note that': '(delete)',
25 'in conclusion': '(delete)',
26}
27const PUNCT = /\s–|–\s|[A-Za-z]–[A-Za-z]|[‘’“”]/g
28const NEGATOR = /\b(?:does not|do not|did not|is not|are not|was not|were not|cannot|will not|never did|no longer)\b/i
29// Any negation at all: a sentence with one is not "affirmative" for negclose.
30const NEGATIVE = /\b(?:not|no|never|cannot|none|nothing)\b|n['’]t\b/i
31const LIST_LINE = /^(\s*)(?:\d+[.)]|[-*•])\s+/
32const LABEL = /^\*\*[^*\n]+\*\*:?\s*/
33const TELL_RULES = ['vocab', 'filler', 'opener', 'negclose', 'punct']
34
35// Blank out code, URLs and table rows. Same length, so indices stay valid.
36function blank(s: string): string {
37 return s.replace(/[^\n]/g, ' ')
38}
39function strip(text: string): string {
40 let fenced: string | null = null
41 const lines = text.split('\n').map((line) => {
42 const m = /^\s*(```+|~~~+)/.exec(line)
43 if (fenced) {
44 if (m && m[1][0] === fenced[0] && m[1].length >= fenced.length) fenced = null
45 return blank(line)
46 }
47 if (m) {
48 fenced = m[1]
49 return blank(line)
50 }
51 return /^\s*\|/.test(line) ? blank(line) : line
52 })
53 return lines.join('\n').replace(/`[^`\n]*`/g, blank).replace(/https?:\/\/\S+/g, blank)
54}
55
56type Seg = { text: string; start: number; list: boolean }
57type Sentence = { text: string; index: number; list: boolean }
58
59// Split a paragraph into prose runs and single list lines.
60function segments(par: string, base: number): Seg[] {
61 const segs: Seg[] = []
62 let pos = 0
63 for (const line of par.split('\n')) {
64 const list = LIST_LINE.test(line)
65 const prev = segs[segs.length - 1]
66 if (!list && prev && !prev.list) prev.text += ' ' + line
67 else segs.push({ text: line, start: base + pos, list })
68 pos += line.length + 1
69 }
70 return segs
71}
72
73function sentences(seg: Seg): Sentence[] {
74 const marker = seg.list ? LIST_LINE.exec(seg.text) : null
75 const skip = marker ? marker[0].length : 0
76 let body = seg.text.slice(skip)
77 // A list item that opens with a bold label (`**Label.** text`) is prose under
78 // a heading, not a step: blank the label (same length, so indices hold) so
79 // its period does not split a sentence, and judge the rest as prose.
80 const label = seg.list ? LABEL.exec(body) : null
81 const list = label ? false : seg.list
82 if (label) body = blank(label[0]) + body.slice(label[0].length)
83 // Mask abbreviation dots for splitting only; same length.
84 const masked = body.replace(/\b(e\.g|i\.e|etc|vs|approx)\./gi, (m) => m.replace(/\./g, '_'))
85 const out: Sentence[] = []
86 for (const m of masked.matchAll(/[^.!?]*[.!?]+(?=\s|$)|[^.!?]+$/g)) {
87 const raw = body.slice(m.index, m.index + m[0].length)
88 const lead = raw.length - raw.trimStart().length
89 const text = raw.trim()
90 if (text) out.push({ text, index: seg.start + skip + m.index! + lead, list })
91 }
92 return out
93}
94
95const words = (s: string) => (s.match(/[A-Za-z0-9][A-Za-z0-9'’_.\-/]*/g) || []).length
96
97type Add = (rule: string, match: string, index: number, extra?: Partial<Finding>) => void
98
99// The sentence-level rules of one paragraph: long sentences (every mode), and in
100// strict mode the repeated opener and the negating closer. Returns the long count.
101function lintParagraph(par: string, base: number, strict: boolean, add: Add): number {
102 let long = 0
103 let run: Sentence[] = []
104 for (const seg of segments(par, base)) {
105 const ss = sentences(seg)
106 for (const s of ss) {
107 const n = words(s.text)
108 if (n > (s.list ? 20 : 25)) {
109 long++
110 add('long', s.text.split(/\s+/).slice(0, 5).join(' ') + '…', s.index, { n })
111 }
112 }
113 if (!strict) continue
114 if (seg.list) {
115 run = []
116 continue
117 }
118 for (const s of ss) {
119 const w = /[A-Za-z']+/.exec(s.text.replace(/^[*_#>\s]+/, ''))?.[0]
120 if (w && run.length && /^[A-Za-z']+/.exec(run[0].text.replace(/^[*_#>\s]+/, ''))?.[0] === w) run.push(s)
121 else run = w ? [s] : []
122 if (run.length === 3) add('opener', w!, s.index)
123 }
124 // A short (6 words or fewer) negating sentence that closes the paragraph
125 // right after an affirmative one: the rhetorical "X does. Y does not."
126 // A negated fact after another negated fact is plain prose.
127 const last = ss[ss.length - 1]
128 const prev = ss[ss.length - 2]
129 if (last && prev && words(last.text) <= 6 && NEGATOR.test(last.text) && !NEGATIVE.test(prev.text)) {
130 add('negclose', last.text, last.index)
131 }
132 }
133 return long
134}
135
136export function lintReply(text: string, opts?: { mode?: Mode }): Report {
137 const mode = opts?.mode ?? 'strict'
138 const findings: Finding[] = []
139 let longSentences = 0
140 if (mode !== 'off' && text && text.trim()) {
141 const t = strip(text)
142 const strict = mode === 'strict'
143 const add: Add = (rule, match, index, extra = {}) => findings.push({ rule, match, index, ...extra })
144 for (const m of t.matchAll(VOCAB)) add('vocab', m[0], m.index!)
145 for (const m of t.matchAll(FILLER)) add('filler', m[0], m.index!, { fix: FILLER_FIX[m[0].toLowerCase()] })
146 if (strict) for (const m of t.matchAll(PUNCT)) add('punct', m[0].trim() || m[0], m.index!)
147 let paraNo = 0
148 for (const pm of t.matchAll(/[^\n]+(?:\n(?![ \t]*\n)[^\n]*)*/g)) {
149 const par = pm[0]
150 if (!par.trim()) continue
151 paraNo++
152 const lead = par.length - par.trimStart().length
153 if (strict && OPENER_PHRASE.test(par.trimStart())) add('vocab', par.trimStart().split(/[!,]/)[0], pm.index! + lead)
154 const dashes = (par.match(/—/g) || []).length
155 if (dashes > 1) add('emdash', '—', pm.index! + par.indexOf('—'), { n: dashes, para: paraNo })
156 longSentences += lintParagraph(par, pm.index!, strict, add)
157 }
158 }
159 findings.sort((a, b) => a.index - b.index)
160 const counts: Record<string, number> = {}
161 for (const f of findings) counts[f.rule] = (counts[f.rule] || 0) + 1
162 const tells = TELL_RULES.reduce((a, r) => a + (counts[r] || 0), 0)
163 const em = findings.filter((f) => f.rule === 'emdash').reduce((a, f) => a + (f.n || 0), 0)
164 return { findings, counts, longSentences, score: `tells ${tells} · long ${longSentences} · em— ${em}` }
165}
166
167const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
168
169// One short line for the next prompt; '' when the reply was clean. Items are
170// ranked by severity, not by position: banned words and openers first (the
171// clearest tells), then filler, em dashes, long sentences, and the softer
172// rules last. The first three ranked items make the line.
173export function feedbackLine(report: Report): string {
174 const f = report.findings
175 if (!f.length) return ''
176 const parts: string[] = []
177 const uniq = (rule: string) => [...new Set(f.filter((x) => x.rule === rule).map((x) => x.match.toLowerCase()))]
178 for (const w of uniq('vocab').slice(0, 2)) parts.push(`"${w}"`)
179 if (report.counts.opener) parts.push('3 sentences with the same opener')
180 for (const w of uniq('filler').slice(0, 1)) parts.push(`"${w}"`)
181 for (const d of f.filter((x) => x.rule === 'emdash').slice(0, 1)) parts.push(`${d.n} em dashes in ¶${d.para}`)
182 if (report.longSentences) parts.push(`${plural(report.longSentences, 'sentence', 'sentences')} >25 words`)
183 if (report.counts.negclose) parts.push('a short negating closer')
184 if (report.counts.punct) parts.push('curly quotes or en dash')
185 return `Last reply: ${parts.slice(0, 3).join(', ')}.`
186}
187
188/** The rule a finding list breaks worst, for a one-word summary. */
189export const SEVERITY: readonly string[] = ['vocab', 'opener', 'filler', 'emdash', 'long', 'negclose', 'punct']
190export function topRule(report: Report): string {
191 return SEVERITY.find((r) => (report.counts[r] || 0) > 0) ?? ''
192}
193hooks/receipt.ts 129 lines1// Pure helpers for the turn receipt: the one line under a reply that says what
2// the tools actually did (files edited, test/build/commit commands and how they
3// ended, duration) and flags a reply that claims passing tests when no test
4// command ran. No engine imports. Every helper is total.
5//
6// Exit codes: the Bash tool's record has no exit code (its fields are stdout,
7// stderr, interrupted, gitOperation...). A non-zero exit makes core answer the
8// call with `isError: true`, and the model-facing `text` usually names the
9// code. So: no isError = ok, isError = failed (code parsed from text when
10// present), anything else = unknown. How one exit spreads over a chained
11// command is commands.ts's job.
12import { commandsOf } from './commands'
13import type { Cmd, End, Outcome } from './commands'
14
15export { classifyCommand, commandsOf, labelOf, pieces, stripWrappers } from './commands'
16export type { Cmd, CmdKind, Outcome } from './commands'
17export type Turn = { files: string[]; commands: Cmd[]; commits: string[] }
18
19export const emptyTurn = (): Turn => ({ files: [], commands: [], commits: [] })
20
21const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
22// A passing-test claim: subject, a short gap, then a passing verb.
23const CLAIM = /\b(?:all )?(?:\d+ )?(?:tests?|test suite|checks?|quality gate|suite|go test)\b([^.;\n]{0,40}?)\b(pass(?:es|ed|ing)?|green|succeed(?:s|ed)?)\b|\b(?:passing|green) (?:tests|suite|build)\b/gi
24// Any negation between or just before the subject and the verb undoes the claim.
25const NEGATED = /\b(?:not|no|never|none|fail(?:s|ed|ing)?|without)\b|n['’]t\b/i
26// "pass through", "pass to the proxy": another sense of the verb.
27const OTHER_SENSE = /^\s+(?:through|to|into|along|around|up|down|back|off)\b/i
28
29/** Reads how a tool call ended from the shape core gives `tool.call` results. */
30export function outcomeOf(result: unknown): End {
31 if (result === null || typeof result !== 'object') return { outcome: 'unknown' }
32 const r = result as { deny?: unknown; isError?: unknown; text?: unknown; result?: { interrupted?: unknown } }
33 if (typeof r.deny === 'string') return { outcome: 'failed' }
34 if (r.isError === true) {
35 const m = typeof r.text === 'string' ? /\bexit code[: ]+(\d+)/i.exec(r.text) : null
36 return m ? { outcome: 'failed', exitCode: Number(m[1]) } : { outcome: 'failed' }
37 }
38 if (r.result && typeof r.result === 'object') {
39 return r.result.interrupted === true ? { outcome: 'unknown' } : { outcome: 'ok', exitCode: 0 }
40 }
41 return { outcome: 'unknown' }
42}
43
44type Call = { tool?: unknown; command?: unknown; file_path?: unknown; notebook_path?: unknown }
45
46/** Folds one finished tool call into the turn. Returns a new turn; never throws. */
47export function noteCall(turn: Turn, call: Call, result: unknown): Turn {
48 try {
49 const tool = String(call?.tool ?? '')
50 if (EDIT_TOOLS.has(tool)) {
51 const path = String(call.file_path ?? call.notebook_path ?? '')
52 if (path === '' || turn.files.includes(path)) return turn
53 return { ...turn, files: [...turn.files, path] }
54 }
55 if (tool !== 'Bash') return turn
56 const sha = commitSha(result)
57 const commands = commandsOf(String(call.command ?? ''), outcomeOf(result))
58 if (commands.length === 0 && sha === undefined) return turn
59 return {
60 ...turn,
61 commands: [...turn.commands, ...commands],
62 commits: sha !== undefined && !turn.commits.includes(sha) ? [...turn.commits, sha] : turn.commits,
63 }
64 } catch {
65 return turn
66 }
67}
68
69/** The short sha core attaches to a committing Bash call, when it does. */
70function commitSha(result: unknown): string | undefined {
71 const r = result as { result?: { gitOperation?: { commit?: { sha?: unknown } } } } | null
72 const sha = r?.result?.gitOperation?.commit?.sha
73 return typeof sha === 'string' && sha !== '' ? sha.slice(0, 7) : undefined
74}
75
76/** True when the reply says tests (or checks, the suite, the gate) pass, in that sense, and not negated. */
77export function claimsTestsPass(answer: string): boolean {
78 const text = String(answer ?? '').replace(/```[\s\S]*?```/g, '')
79 for (const m of text.matchAll(CLAIM)) {
80 const before = text.slice(Math.max(0, m.index - 24), m.index)
81 const after = text.slice(m.index + m[0].length, m.index + m[0].length + 12)
82 if (NEGATED.test(before) || NEGATED.test(m[1] ?? '')) continue
83 if (m[2] !== undefined && OTHER_SENSE.test(after)) continue
84 return true
85 }
86 return false
87}
88
89export function formatDuration(ms: number): string {
90 const s = Math.max(0, Math.round(Number(ms) / 1000))
91 if (s < 60) return `${s}s`
92 const m = Math.floor(s / 60)
93 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
94}
95
96const MARK: Record<Outcome, string> = { ok: '✔', failed: '✘', unknown: '?' }
97
98/** Flags the mismatch between what the reply says and what ran, or ''. */
99export function mismatch(turn: Turn, answer: string): string {
100 if (!claimsTestsPass(answer)) return ''
101 const tests = turn.commands.filter(c => c.kind === 'test')
102 if (tests.length === 0) return 'reply says tests pass; no test command ran this turn'
103 if (tests.some(c => c.outcome === 'ok')) return ''
104 const t = tests[tests.length - 1] as Cmd
105 return t.outcome === 'failed' ? `reply says tests pass; ${t.label} failed` : `reply says tests pass; ${t.label} ended with an unknown exit`
106}
107
108/** The receipt line, or null when the turn ran no tool worth a line. */
109export function receiptLine(turn: Turn, answer: string, durationMs: number): string | null {
110 const parts: string[] = []
111 if (turn.files.length > 0) parts.push(`✎ ${turn.files.length} ${turn.files.length === 1 ? 'file' : 'files'}`)
112 const seen = new Set<string>()
113 for (const c of turn.commands) {
114 if (c.kind === 'commit') continue
115 const key = `${c.label}:${c.outcome}`
116 if (seen.has(key)) continue
117 seen.add(key)
118 const code = c.outcome === 'failed' && c.exitCode !== undefined ? ` (exit ${c.exitCode})` : c.outcome === 'unknown' ? ' (exit unknown)' : ''
119 parts.push(`${MARK[c.outcome]} ${c.label}${code}`)
120 }
121 if (turn.commits.length > 0) parts.push(`⎇ ${turn.commits.join(' ')}`)
122 else if (turn.commands.some(c => c.kind === 'commit')) parts.push('⎇ commit')
123 const warn = mismatch(turn, answer)
124 if (parts.length === 0 && warn === '') return null
125 if (parts.length > 0) parts.push(formatDuration(durationMs))
126 const line = parts.join(' · ')
127 return warn === '' ? line : line === '' ? `⚠ ${warn}` : `${line}\n⚠ ${warn}`
128}
129hooks/commands.ts 111 lines1// Shell command reading for the receipt: split one Bash call into its pieces,
2// strip wrappers, classify each piece, and spread the call's single outcome
3// over the pieces without claiming more than the exit status can tell.
4//
5// What one exit status says (bash without pipefail):
6// a && b && c ok: every piece ran and succeeded. failed: some piece failed and
7// the rest never ran, which one is unknown: the chain is one item.
8// a ; b the status is b's; a is unknown either way.
9// a | b the status is b's; a is unknown either way.
10// a || b ok: a or b succeeded, unknown which. failed: both failed.
11// interrupted nothing is known.
12// No engine imports. Every helper is total.
13
14export type CmdKind = 'test' | 'build' | 'commit' | 'other'
15export type Outcome = 'ok' | 'failed' | 'unknown'
16export type End = { outcome: Outcome; exitCode?: number }
17export type Cmd = { label: string; kind: CmdKind; outcome: Outcome; exitCode?: number }
18export type Op = '&&' | '||' | ';' | '|' | null
19export type Piece = { text: string; op: Op }
20
21const TEST = /^(?:go test|gotestsum|scripts\/go-test-streaming\.sh|(?:npm|pnpm|yarn|bun) (?:run )?test|pytest|vitest|jest|mocha|cargo test|claude plugin test|wipnote check)\b/
22const BUILD = /^(?:go build|go vet|wipnote build|(?:npm|pnpm|yarn|bun) run build|cargo build|tsc|claude plugin validate)\b/
23const COMMIT = /^git(?: -C \S+)? commit(?=\s|$)/
24// Leading `NAME=value` assignments and the wrappers that run another command.
25const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/
26const WRAPPER = /^(?:time|sudo(?:\s+-[A-Za-z]+)*|env|nohup|nice(?:\s+-n\s+\d+)?|timeout\s+\d+[smh]?|npx|bunx|pnpm exec|npm exec)\s+/
27const KIND_RANK: Record<CmdKind, number> = { test: 3, build: 2, commit: 1, other: 0 }
28
29/** Splits on `&&`, `||`, `;`, `|` and line ends; each piece carries the operator before it. */
30export function pieces(command: string): Piece[] {
31 const out: Piece[] = []
32 const re = /\s*(&&|\|\||;|\||\r?\n)\s*/g
33 const src = String(command ?? '')
34 let last = 0
35 let op: Op = null
36 const push = (text: string, nextOp: Op) => {
37 const t = text.replace(/^[\s(]+|[\s)]+$/g, '')
38 if (t !== '') out.push({ text: t, op })
39 op = nextOp
40 }
41 for (const m of src.matchAll(re)) {
42 push(src.slice(last, m.index), /\n/.test(m[1] as string) ? ';' : (m[1] as Op))
43 last = m.index + m[0].length
44 }
45 push(src.slice(last), null)
46 return out
47}
48
49/** `CGO_ENABLED=0 time go test` reads as `go test`. */
50export function stripWrappers(segment: string): string {
51 let s = String(segment ?? '').trim()
52 for (;;) {
53 const next = s.replace(ENV_ASSIGN, '').replace(WRAPPER, '')
54 if (next === s) return s
55 s = next
56 }
57}
58
59export function classifyCommand(segment: string): CmdKind {
60 const s = stripWrappers(segment)
61 if (TEST.test(s)) return 'test'
62 if (BUILD.test(s)) return 'build'
63 if (COMMIT.test(s)) return 'commit'
64 return 'other'
65}
66
67/** `go test ./...` reads as `go test`; `pytest -q` keeps one word. */
68export function labelOf(segment: string): string {
69 const words = stripWrappers(segment).split(/\s+/)
70 const first = words[0] ?? ''
71 return /^(?:go|npm|pnpm|yarn|bun|cargo|git|claude|wipnote)$/.test(first) ? words.slice(0, 2).join(' ') : first
72}
73
74type Group = { cmds: Cmd[]; op: Op }
75
76/** Pieces grouped into `&&` chains; a group's `op` is the operator before its first piece. */
77function groups(command: string): Group[] {
78 const out: Group[] = []
79 for (const p of pieces(command)) {
80 const kind = classifyCommand(p.text)
81 const cmd: Cmd = { label: labelOf(p.text), kind, outcome: 'unknown' }
82 if (p.op === '&&' && out.length > 0) (out[out.length - 1] as Group).cmds.push(cmd)
83 else out.push({ cmds: [cmd], op: p.op })
84 }
85 // `other` pieces (cd, echo, tail) still count for position: `go test | tail` leaves go test before the last group.
86 return out.map(g => ({ ...g, cmds: g.cmds.filter(c => c.kind !== 'other') }))
87}
88
89const withEnd = (c: Cmd, end: End): Cmd => (end.exitCode === undefined ? { ...c, outcome: end.outcome } : { ...c, outcome: end.outcome, exitCode: end.exitCode })
90
91/** A failed `&&` chain of several commands is one item named for all of them, ranked by its strongest kind. */
92function merged(g: Group, end: End): Cmd[] {
93 if (g.cmds.length <= 1) return g.cmds.map(c => withEnd(c, end))
94 const kind = g.cmds.reduce<CmdKind>((best, c) => (KIND_RANK[c.kind] > KIND_RANK[best] ? c.kind : best), 'other')
95 return [withEnd({ label: g.cmds.map(c => c.label).join(' && '), kind, outcome: 'unknown' }, end)]
96}
97
98/** The commands of one Bash call with the outcome each can honestly be given. */
99export function commandsOf(command: string, end: End): Cmd[] {
100 const gs = groups(command)
101 const hasOr = gs.some(g => g.op === '||')
102 return gs.flatMap((g, i) => {
103 const isLast = i === gs.length - 1
104 if (end.outcome === 'ok') return isLast && !hasOr ? g.cmds.map(c => withEnd(c, end)) : g.cmds
105 if (end.outcome !== 'failed') return g.cmds
106 // Failed: the last group failed, and so did every group joined to it by `||`.
107 const joinedToLast = gs.slice(i + 1).every(h => h.op === '||')
108 return isLast || joinedToLast ? merged(g, end) : g.cmds
109 })
110}
111types/index.d.ts 21 lines1declare module 'claude-code' {
2 interface PluginState {
3 'wipnote-visuals': {
4 /** `/visuals on|off`: off removes the model rule and draws fences as plain text. */
5 enabled: boolean
6 /** `/visuals receipt on|off`: off stops the receipt and score line under each turn. */
7 receipt: boolean
8 /** The main loop's tool calls so far this turn; reset at turn.complete. */
9 turn: {
10 files: string[]
11 commands: { label: string; kind: 'test' | 'build' | 'commit' | 'other'; outcome: 'ok' | 'failed' | 'unknown'; exitCode?: number }[]
12 commits: string[]
13 }
14 /** `/plainspeak strict|80|off`: which rules feed the note for the next prompt; off sends none. */
15 plainspeak: 'strict' | '80' | 'off'
16 /** The writing note the last reply earned, sent as context with the next prompt; '' when clean. */
17 lintNote: string
18 }
19 }
20}
21