SLOPSHOPPER

voice

Plain replies backed by evidence: strips filler and AI tells from the model's text, and flags factual claims that cite no file:line, command output or URL

newbandrowsguardstatusmodel
v0.1.1MITupdated 2026-10-19Egoushka/claude-mods/mods/voice
A shopper browsing a rack in a slop shop
README

voice

Keeps Claude's replies plain and backed by evidence. Two parts, each with its own switch.

Slop filter

Hooks session.append on the model's response rows and rewrites their text blocks before they are stored. The model and the transcript file read the clean text; thinking and tool-use blocks are never touched.

RemovedExamples
Praise openers (only at a sentence, line or list-item start)"Great question!", "You're absolutely right, ...", "That's a great point."
Closers (last line only, never one that mentions code or a link)"I hope this helps!", "Let me know if you have questions."
Filler adverbshonestly, genuinely, truthfully
Throat-clearing"it's worth noting that", "needless to say"
Swapped, because deleting breaks the sentencestraightforward -> simple, delve -> dig

Fenced code (also an unclosed one), inline code, URLs, "quoted" text and > blockquote lines are never touched. Words inside longer words are left alone ("dishonestly"). What the removal leaves behind is repaired: stray commas and spaces, and the next word is capitalised at a sentence start. A reply that would end up empty is kept as it was. Known limit: the capitalised word may be a lowercase identifier written without backticks ("Honestly, npm is fine" becomes "Npm is fine").

Transcript versus screen. The engine's documentation says the screen may paint a reply just before its row is rewritten. The mod therefore also rewrites the AssistantMessage drawing with the same function, so what is drawn matches what is stored (the stored row is the source of truth; the drawing is best effort). It cannot change anything an SDK stream or Remote Control already received.

Status line: voice: 5 edits. In flag mode it reads voice: 5 flagged and nothing is rewritten.

Evidence auditor

On turn.complete of an answer, the mod asks a small model to list factual claims about code or systems that cite no file:line, command output or URL. The call runs after the turn, so it never delays an answer, and its result is dropped if a newer turn started.

Skipped: refused, aborted or errored turns, subagent runs, answers shorter than the minimum length, and answers with fewer than three code or system signals (backticks, file names, words like config, service, commit), so chat replies cost nothing.

The result shows as a row above the prompt: voice: 3 claims in the last reply cite no source with two buttons, plus the first three claims when there is room. The auditor also reads the tool output of that turn (the newest 20 results, each cut to 1,200 characters), so a claim the output supports is not flagged just because the reply doesn't quote it.

  • Ask for sources submits a prompt (as your own words) that lists the claims and asks Claude to cite file:line, command output or a URL for each, or mark it UNKNOWN, and correct any that turn out wrong.
  • Dismiss hides the row. The next turn also clears it.

The row is added to the shared band with await next(e), so other mods' rows stay. It yields to surveys. The terminal and desktop surfaces draw it; other surfaces have no band.

The auditor is a model judging text it cannot check against the code, so treat the list as leads: it finds missing citations, not false statements.

Settings

FieldDefaultMeaning
slopFilteronTurn the filter off entirely.
moderewriterewrite edits the stored text; flag only counts.
extraPhrasesemptyComma-separated phrases to delete (whole words, any spacing), or /regex/flags entries. Example: leverage, /\bseamless(ly)?\b/i. Invalid entries and ones that match the empty string are skipped.
auditonTurn the auditor off.
auditModelhaikuAlias or id for the audit call.
auditMinLength600Characters an answer needs before it is audited.

Cost

  • Slop filter: no model calls, a few regex passes per reply.
  • Auditor: one $.model.complete call per qualifying answer. Input is the answer (cut at 12,000 characters) plus about 250 tokens of instructions; output is capped at 700 tokens, usually under 150. On Haiku that is a fraction of a cent. "Ask for sources" costs one normal turn. With tool output included the input grows by up to about 4k tokens.
  • Neither part adds anything to the main conversation's context, except the "Ask for sources" prompt when you press it.
Source 4 files
hooks/register.tsx 143 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { VoiceAudit, VoiceStats } from '../types'
5import { bandLabel, buildAuditPrompt, buildBackupPrompt, keepEvidence, parseClaims, shouldAudit } from './audit'
6import { filterSlop, parseExtra } from './slop'
7
8const initialStats: VoiceStats = { edits: 0, audited: 0, isAuditing: false }
9const stats = atom({ plugin: 'voice', key: 'stats' } as const, initialStats)
10const audit = atom({ plugin: 'voice', key: 'audit' } as const, { claims: [] } as VoiceAudit)
11
12async function refreshStatus($: EngineInterface, verb: string) {
13  const s = await read($, stats)
14  const parts: string[] = []
15  if (s.edits) parts.push(`${s.edits} ${verb}`)
16  if (s.isAuditing) parts.push('auditing…')
17  $.ui.status(parts.length ? `voice: ${parts.join(' · ')}` : undefined)
18}
19
20async function countEdits($: EngineInterface, edits: number, verb: string) {
21  await update($, stats, s => ({ ...s, edits: s.edits + edits }))
22  await refreshStatus($, verb)
23}
24
25// One small-model call per qualifying answer; a newer turn makes an older result stale.
26async function runAudit($: EngineInterface, answer: string, evidence: readonly string[], model: string, id: number, current: () => number, verb: string) {
27  await update($, stats, s => ({ ...s, isAuditing: true }))
28  await refreshStatus($, verb)
29  const reply = await $.model
30    .complete({ model, prompt: buildAuditPrompt(answer, evidence), maxTokens: 700, effort: 'low', timeoutMs: 30000 })
31    .catch(() => undefined)
32  if (current() !== id) return
33  const claims = reply?.isAnswered ? parseClaims(reply.text) : []
34  await update($, audit, () => ({ claims }))
35  await update($, stats, s => ({ ...s, isAuditing: false, audited: s.audited + 1 }))
36  await refreshStatus($, verb)
37}
38
39async function backThemUp($: EngineInterface, claims: readonly string[]) {
40  await update($, audit, () => ({ claims: [] }))
41  await $.prompt.submit({ text: buildBackupPrompt(claims), asUser: true })
42}
43
44async function dismiss($: EngineInterface) {
45  await update($, audit, () => ({ claims: [] }))
46}
47
48export const register: Register = (on, options) => {
49  const isFilterOn = options.slopFilter !== false
50  const isRewrite = options.mode !== 'flag'
51  const verb = isRewrite ? 'edits' : 'flagged'
52  const extra = parseExtra(typeof options.extraPhrases === 'string' ? options.extraPhrases : '')
53  const isAuditOn = options.audit !== false
54  const model = typeof options.auditModel === 'string' && options.auditModel.trim() ? options.auditModel.trim() : 'haiku'
55  const minLength = typeof options.auditMinLength === 'number' ? options.auditMinLength : 600
56
57  let generation = 0
58  // Tool output seen during the running turn: what a claim may rest on without quoting it.
59  let evidence: string[] = []
60
61  on('session.start', async ($, e, next) => {
62    await update($, stats, () => initialStats)
63    await update($, audit, () => ({ claims: [] }))
64    await refreshStatus($, verb)
65    return next(e)
66  })
67
68  // The stored row is rewritten, so the model and the transcript file read the clean text.
69  on('session.append', { door: 'response' }, async ($, e, next) => {
70    if (!isFilterOn) return next(e)
71    let edits = 0
72    const content = e.message.content.map(block => {
73      if (block.type !== 'text' || typeof block.text !== 'string') return block
74      const r = filterSlop(block.text, extra)
75      edits += r.edits
76      return isRewrite && r.edits > 0 ? { ...block, text: r.text } : block
77    })
78    if (edits === 0) return next(e)
79    await countEdits($, edits, verb)
80    return next(isRewrite ? { ...e, message: { ...e.message, content } } : e)
81  }).catch(($, e, next) => next(e))
82
83  // The screen may paint a reply before its row is stored; draw the same text it will keep.
84  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
85    if (!isFilterOn || !isRewrite) return next(e)
86    const r = filterSlop(e.props.text, extra)
87    return next(r.edits > 0 ? { ...e, props: { ...e.props, text: r.text } } : e)
88  })
89
90  on('tool.call', async ($, e, next) => {
91    const ran = await next(e)
92    if (isAuditOn && ran.deny === undefined) {
93      const text = typeof ran.text === 'string' ? ran.text : JSON.stringify(ran.result ?? '')
94      evidence = keepEvidence(evidence, String(e.tool), text)
95    }
96    return ran
97  })
98
99  on('turn.start', async ($, e, next) => {
100    if (!e.agentId) evidence = []
101    generation += 1
102    await update($, audit, () => ({ claims: [] }))
103    return next(e)
104  })
105
106  on('turn.complete', async ($, e, next) => {
107    if (isAuditOn && e.reason === 'answer' && !e.agentId && shouldAudit(e.answer, minLength)) {
108      generation += 1
109      const id = generation
110      void runAudit($, e.answer, evidence, model, id, () => generation, verb)
111    }
112    return next(e)
113  })
114
115  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
116    const rest = await next(e)
117    if (e.props.hasSurvey) return rest
118    const { claims } = await read($, audit)
119    if (claims.length === 0) return rest
120
121    const { Box, Text, Button } = $.ui.resolve(e)
122    const shown = e.props.maxRows >= 6 ? claims.slice(0, 3) : []
123    return (
124      <Box flexDirection="column">
125        <Box key="voice" flexDirection="column">
126          <Box>
127            <Text color="yellow">{bandLabel(claims.length)} </Text>
128            <Button key="back" label="Ask for sources" onPress={() => backThemUp($, claims)} />
129            <Text> </Text>
130            <Button key="dismiss" label="Dismiss" onPress={() => dismiss($)} />
131          </Box>
132          {shown.map((c, i) => (
133            <Text key={`c${i}`} dimColor wrap="truncate-end">
134              {`  · ${c}`}
135            </Text>
136          ))}
137        </Box>
138        {rest}
139      </Box>
140    )
141  })
142}
143
hooks/audit.ts 83 lines
1// Pure helpers of the evidence auditor.
2
3const SIGNAL =
4  /`[^`\n]+`|```|\b[\w./-]+\.(?:ts|tsx|js|jsx|py|cs|go|rs|java|json|ya?ml|toml|md|sh|sql|rb|php|kt|swift|cpp|c|h)\b|\b\w+\(\)|\b(?:function|class|method|endpoint|config|service|container|database|query|handler|module|package|version|branch|commit|server|deploy\w*|migration|schema|column|api|bug|error|test|build|repo|file|files)\b/gi
5
6/** A long answer that talks about code or systems; chat and short replies are skipped. */
7export function shouldAudit(answer: string, minLength: number): boolean {
8  if (answer.length < Math.max(1, minLength)) return false
9  return (answer.match(SIGNAL) ?? []).length >= 3
10}
11
12export function buildAuditPrompt(answer: string, evidence: readonly string[] = []): string {
13  const seen = evidence.length
14    ? 'Tool output the assistant saw while writing it (a claim this output supports counts as backed, even when the answer does not quote it):\n' +
15      `<tool_output>\n${evidence.join('\n---\n').slice(0, 16000)}\n</tool_output>\n\n`
16    : ''
17  return (
18    'Below is an answer an AI assistant gave about code or a system. List the factual claims it states as certain ' +
19    'about code, files, configuration, behaviour, versions or infrastructure that carry NO evidence: ' +
20    'no file:line reference, no quoted command output, no URL in the text, and no support in the tool output. Skip opinions, plans, questions, advice, ' +
21    'claims marked UNKNOWN or unverified, and claims the text or the tool output backs up. At most 8, each one short sentence in the answer\'s own words.\n' +
22    'Reply with JSON only: {"claims": ["...", "..."]}. If every claim is backed, reply {"claims": []}.\n\n' +
23    seen +
24    `<answer>\n${answer.slice(0, 12000)}\n</answer>`
25  )
26}
27
28// Keeps the newest tool results of a turn, each cut short, as evidence for the auditor.
29export function keepEvidence(list: readonly string[], tool: string, text: string, max = 20): string[] {
30  const one = `${tool}: ${text.replace(/\s+/g, ' ').trim().slice(0, 1200)}`
31  return [...list, one].slice(-max)
32}
33
34const MAX_CLAIMS = 8
35
36/** Reads the model's reply as JSON, tolerating fences, prose around it, arrays and objects. */
37export function parseClaims(raw: string): string[] {
38  const text = raw.replace(/```(?:json)?/gi, '').trim()
39  let value: unknown
40  const attempts = [text]
41  const obj = [text.indexOf('{'), text.lastIndexOf('}')] as const
42  const arr = [text.indexOf('['), text.lastIndexOf(']')] as const
43  if (obj[0] >= 0 && obj[1] > obj[0]) attempts.push(text.slice(obj[0], obj[1] + 1))
44  if (arr[0] >= 0 && arr[1] > arr[0]) attempts.push(text.slice(arr[0], arr[1] + 1))
45  for (const a of attempts) {
46    try {
47      value = JSON.parse(a)
48      break
49    } catch {
50      // try the next slice
51    }
52  }
53  const list: unknown = Array.isArray(value)
54    ? value
55    : value && typeof value === 'object'
56      ? ((value as Record<string, unknown>).claims ?? (value as Record<string, unknown>).unbacked ?? (value as Record<string, unknown>).items)
57      : undefined
58  if (!Array.isArray(list)) return []
59  const seen = new Set<string>()
60  const out: string[] = []
61  for (const item of list) {
62    const field = item && typeof item === 'object' ? ((item as Record<string, unknown>).claim ?? (item as Record<string, unknown>).text ?? (item as Record<string, unknown>).statement) : item
63    if (typeof field !== 'string') continue
64    const claim = field.replace(/\s+/g, ' ').trim().slice(0, 300)
65    if (!claim || seen.has(claim.toLowerCase())) continue
66    seen.add(claim.toLowerCase())
67    out.push(claim)
68    if (out.length === MAX_CLAIMS) break
69  }
70  return out
71}
72
73export function buildBackupPrompt(claims: readonly string[]): string {
74  return (
75    'In your last answer these claims had no evidence:\n' +
76    claims.map((c, i) => `${i + 1}. ${c}`).join('\n') +
77    '\n\nFor each one, check it now and cite file:line, command output or a URL. ' +
78    'If you cannot verify it, mark it UNKNOWN. If it turns out wrong, correct it.'
79  )
80}
81
82export const bandLabel = (n: number) => `voice: ${n} claim${n === 1 ? '' : 's'} in the last reply cite${n === 1 ? 's' : ''} no source`
83
hooks/slop.ts 201 lines
1// Pure text filter: no engine, so tests import it directly.
2
3export type SlopResult = { text: string; edits: number }
4
5type Rule = {
6  re: RegExp
7  /** Replace instead of delete; keeps the first letter's case. */
8  sub?: (match: string) => string
9  /** Only where a sentence starts (an opener, not a mid-sentence use). */
10  startOnly?: boolean
11}
12
13const OPEN = ''
14const CLOSE = ''
15const SLOT = /(\d+)/g
16
17// Where a sentence (or a list item, heading, quote line) begins.
18const SENTENCE_START = /(?:(?:^|\n)[ \t]*(?:(?:[-*+>]|\d+[.)]|#{1,6})[ \t]+)*|[.!?]["'”’)\]\uE001]*[ \t]+)$/
19
20// Not inside a path or file name (src/delve/a.ts, straightforward.ts): those are data, not prose.
21const word = (core: string) => new RegExp(`(?<![\\w/\\\\-]|\\w\\.)(?:${core})(?![\\w-]|\\.\\w|/)`, 'gi')
22const matchCase = (from: string, to: string) => (/^[A-Z]/.test(from) ? to.charAt(0).toUpperCase() + to.slice(1) : to)
23
24const PRAISE =
25  "(?:(?:great|good|excellent|fantastic|fair|interesting|awesome|wonderful|sharp|nice) (?:question|point|catch|idea|observation)" +
26  "|you(?:'|’)re (?:absolutely|totally|completely|entirely|exactly|quite) (?:right|correct)" +
27  "|you are (?:absolutely|totally|completely|entirely|exactly|quite) (?:right|correct)" +
28  "|that(?:'|’)s (?:a|an) (?:really |very )?(?:great|good|excellent|fantastic|fair|interesting|sharp) (?:question|point|catch|idea|observation)" +
29  "|what a (?:great|good|fantastic|excellent) (?:question|idea)" +
30  "|sure thing" +
31  "|(?:absolutely|certainly|of course)(?=!)" +
32  "|(?:great|perfect|excellent|wonderful)(?=[!.,]))"
33
34const DEFAULT_RULES: Rule[] = [
35  { re: new RegExp(`(?<![\\w-])${PRAISE}[!.,;:]*(?:[ \\t]*[—–]+)?`, 'gi'), startOnly: true },
36  { re: word('honestly|genuinely|truthfully'), },
37  {
38    re: word('straightforward(?:ly)?|delve[sd]?|delving'),
39    sub: m => {
40      const k = m.toLowerCase()
41      const to = k === 'straightforward' ? 'simple' : k === 'straightforwardly' ? 'simply' : k === 'delve' ? 'dig' : k === 'delves' ? 'digs' : k === 'delved' ? 'dug' : 'digging'
42      return matchCase(m, to)
43    },
44  },
45  {
46    re: word("it(?:'|’)s (?:worth|important) (?:noting|to note)(?: that)?|it is (?:worth|important) (?:noting|to note)(?: that)?|needless to say|it goes without saying(?: that)?"),
47  },
48]
49
50const CLOSER =
51  /^(?:and |also |so )?(?:i hope (?:this|that|it)(?: all)? helps|hope (?:this|that|it)(?: all)? helps|(?:please )?let me know (?:if|whether|how|what)|(?:please )?feel free to|don(?:'|’)t hesitate to|happy to (?:help|clarify|elaborate|explain|assist)|i(?:'|’)m happy to (?:help|clarify|elaborate|explain|assist)|is there anything else|i(?:'|’)d be (?:happy|glad) to)/i
52
53const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
54
55/** "leverage, /\bseamless(ly)?\b/i" -> regexes. Bad or empty-matching entries are skipped. */
56export function parseExtra(spec: string): RegExp[] {
57  const out: RegExp[] = []
58  for (const raw of spec.match(/\/(?:\\.|[^/\\])+\/[a-z]*|[^,]+/g) ?? []) {
59    const entry = raw.trim()
60    if (!entry) continue
61    const literal = /^\/((?:\\.|[^/\\])+)\/([a-z]*)$/.exec(entry)
62    try {
63      const re = literal
64        ? new RegExp(literal[1] as string, (literal[2] as string).includes('g') ? (literal[2] as string) : `${literal[2]}g`)
65        : word(escape(entry).replace(/\s+/g, '\\s+'))
66      if (!re.test('')) out.push(re)
67    } catch {
68      // an invalid pattern is ignored, not fatal
69    }
70  }
71  return out
72}
73
74/** Hide code, URLs, quotes and blockquotes behind placeholders the rules cannot match inside. */
75export function protect(text: string): { work: string; slots: string[] } {
76  const slots: string[] = []
77  const hold = (s: string) => `${OPEN}${slots.push(s) - 1}${CLOSE}`
78
79  // Fenced blocks, line by line; an unclosed fence (a reply cut off) runs to the end.
80  const lines = text.split('\n')
81  const kept: string[] = []
82  let open: { char: string; size: number; body: string[] } | undefined
83  for (const line of lines) {
84    const fence = /^[ \t]*(`{3,}|~{3,})/.exec(line)
85    if (!open) {
86      if (fence) open = { char: (fence[1] as string)[0] as string, size: (fence[1] as string).length, body: [line] }
87      else kept.push(line)
88      continue
89    }
90    open.body.push(line)
91    const close = /^[ \t]*(`{3,}|~{3,})[ \t]*$/.exec(line)
92    if (close && (close[1] as string)[0] === open.char && (close[1] as string).length >= open.size) {
93      kept.push(hold(open.body.join('\n')))
94      open = undefined
95    }
96  }
97  if (open) kept.push(hold(open.body.join('\n')))
98
99  let work = kept.join('\n')
100  work = work.replace(/(`+)(?:(?!\1)[^\n])+?\1/g, hold)
101  work = work.replace(/(?:https?|ftp):\/\/[^\s<>"'`)\]]*[^\s<>"'`)\].,;:!?]|www\.[^\s<>"'`)\]]*[^\s<>"'`)\].,;:!?]/gi, hold)
102  work = work.replace(/^[ \t]*>.*$/gm, hold)
103  work = work.replace(/“[^”\n]*”|"[^"\n]*"/g, hold)
104  return { work, slots }
105}
106
107export function restore(work: string, slots: string[]): string {
108  let text = work
109  for (let i = 0; i < 6 && SLOT.test(text); i += 1) {
110    SLOT.lastIndex = 0
111    text = text.replace(SLOT, (_, n: string) => slots[Number(n)] ?? '')
112  }
113  return text
114}
115
116function apply(input: string, rule: Rule): { text: string; edits: number } {
117  let text = input
118  let edits = 0
119  let pos = 0
120  const re = rule.re
121  for (;;) {
122    re.lastIndex = pos
123    const m = re.exec(text)
124    if (!m) break
125    if (m[0] === '') {
126      pos = m.index + 1
127      if (pos > text.length) break
128      continue
129    }
130    const end = m.index + m[0].length
131    let before = text.slice(0, m.index)
132    let after = text.slice(end)
133    const isStart = SENTENCE_START.test(before)
134    if (rule.startOnly && !isStart) {
135      pos = end
136      continue
137    }
138    let middle = ''
139    if (rule.sub) {
140      middle = rule.sub(m[0])
141    } else if (isStart) {
142      const isLineStart = before === '' || before.endsWith('\n')
143      after = after.replace(/^[.!?]+[ \t]*/, '').replace(isLineStart ? /^[,;:]?\s*/ : /^[,;:]?[ \t]*/, '')
144      if ((after === '' || after.startsWith('\n')) && /[ \t]$/.test(before)) before = before.replace(/[ \t]+$/, '')
145      after = after.replace(/^[a-z]/, c => c.toUpperCase())
146    } else if (/^[,;:.!?)]/.test(after)) {
147      before = before.replace(/[ \t]+$/, '')
148      if (before.endsWith(',') && /^[.!?;:]/.test(after)) before = before.slice(0, -1)
149      else if (before.endsWith(',') && after.startsWith(',')) after = after.slice(1)
150    } else if (/[ \t]$/.test(before)) {
151      after = after.replace(/^[ \t]+/, '')
152    }
153    text = before + middle + after
154    pos = before.length + middle.length
155    edits += 1
156  }
157  return { text, edits }
158}
159
160// Drops trailing "hope this helps" / "let me know if" sentences from the last line of prose.
161// A sentence that mentions code or a link is information, so it stays.
162function dropClosers(work: string): { text: string; edits: number } {
163  const body = work.replace(/\s+$/, '')
164  const tail = work.slice(body.length)
165  const lineStart = body.lastIndexOf('\n') + 1
166  const line = body.slice(lineStart)
167  if (/^\s*(?:[-*+>|#]|\d+[.)])/.test(line)) return { text: work, edits: 0 }
168  const sentences = line.split(/(?<=[.!?])\s+/)
169  let edits = 0
170  while (sentences.length > 0) {
171    const last = (sentences[sentences.length - 1] as string).trim()
172    if (last.includes(OPEN) || !CLOSER.test(last)) break
173    sentences.pop()
174    edits += 1
175  }
176  if (edits === 0) return { text: work, edits: 0 }
177  const head = body.slice(0, lineStart)
178  const rest = sentences.join(' ')
179  return { text: rest === '' ? head.replace(/\s+$/, '') : head + rest + tail, edits }
180}
181
182/** Removes filler and AI tells from prose; code, URLs and quotes are never touched. */
183export function filterSlop(input: string, extra: readonly RegExp[] = []): SlopResult {
184  if (input.includes(OPEN) || input.includes(CLOSE)) return { text: input, edits: 0 }
185  const { work, slots } = protect(input)
186  let text = work
187  let edits = 0
188  for (const rule of [...DEFAULT_RULES, ...extra.map((re): Rule => ({ re }))]) {
189    const r = apply(text, rule)
190    text = r.text
191    edits += r.edits
192  }
193  const closed = dropClosers(text)
194  text = closed.text
195  edits += closed.edits
196  const result = restore(text, slots)
197  // Never turn an answer into nothing.
198  if (edits === 0 || result.trim() === '') return { text: input, edits: 0 }
199  return { text: result, edits }
200}
201
types/index.d.ts 19 lines
1export type VoiceStats = {
2  /** Edits made (rewrite mode) or edits that would have been made (flag mode). */
3  edits: number
4  /** Answers the auditor has examined. */
5  audited: number
6  isAuditing: boolean
7}
8
9export type VoiceAudit = {
10  /** Claims the auditor found without evidence; empty hides the band. */
11  claims: string[]
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    voice: { stats: VoiceStats; audit: VoiceAudit }
17  }
18}
19