SLOPSHOPPER

wipnote-visuals

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…

newrowsguardcommandprompt
★ 4v0.2.1MITupdated 2026-10-22shakestzd/wipnote/mods/wipnote-visuals
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wipnote-visuals
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ✎ 3 files · ✘ bun test · 42s › /visuals ⎿ wipnote-visuals: Visuals: on. Receipt: on. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

wipnote-visuals (dev mod)

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).

Receipt

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
PartSource
✎ n filesEdit, 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 commandsOne 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.
⎇ a5f39e1The sha core attaches to a committing Bash call (gitOperation.commit.sha); ⎇ commit when a git commit ran but no sha came back
2m10se.durationMs
⚠ reply says tests pass; no test command ran this turnThe 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.

Writing check

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.

  • Modes: /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.
  • Score: in 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.
  • Feedback note: in 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.
  • Word list: ordinary technical words (robust, enhance, landscape of the X) are not in the lint list; the Plain English style still bans them for the model.
  • Ranking: the note names the worst three items by severity (banned words, then openers, filler, em dashes, long sentences, then the softer rules), not the first three hits in the text.

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".

Blocks

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.

SQLite*

  • no server
  • single writer

Postgres

  • concurrent writes
  • extra service

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.

Check it

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.

Source 8 files
hooks/register.tsx 91 lines
1// 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}
91
hooks/blocks.ts 110 lines
1// 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}
110
hooks/components.tsx 58 lines
1// 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}
58
hooks/turnhooks.ts 98 lines
1// 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}
98
hooks/lint.ts 193 lines
1// 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}
193
hooks/receipt.ts 129 lines
1// 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}
129
hooks/commands.ts 111 lines
1// 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}
111
types/index.d.ts 21 lines
1declare 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