Lints the prose Claude writes (Markdown, text, code comments) with Vale and hands the alerts back to Claude

A Claude Code plugin that runs Vale over the prose Claude writes: Markdown, text, and comments in code.
/prose-lint <file or folder>… lints whole files. Claude reads the output too, so "fix these" works after it./prose-lint stats [project] shows Claude's writing in this session and the last 30 days: alerts per 1,000 words by week, the rules it trips most with how often it kept their alerts, and docs against code comments. The plugin keeps these counts on your machine, in its own store, with no text but a few example matches per rule./prose-lint coach turns those stats into changes. It shows this session's line and the five rules Claude writes the most alerts of, each with how often Claude kept them, a few examples and the config that ran it. Claude then proposes up to three changes, such as turning a noisy rule off in its config or adding a line to CLAUDE.md, and applies only the ones you pick. It needs 1,000 words of writing in the last 30 days first.The same file, written in the terminal and in the Code tab of the Claude Desktop app:
<!-- vale off -->
<!-- vale on -->

When the turn ends, the row strikes out what got fixed. Here Claude fixed the dash in a second edit, which raised no alerts of its own:

It needs Claude Code v2.1.287 or later, which runs mods, and Vale 3.18 or later on your PATH (brew install vale). Then, in your shell:
claude plugin marketplace add andrezzoid/newsroom
claude plugin install prose-lint@newsroom
The plugin uses the first config it finds:
.vale.ini in the edited file's folder or a folder above it: the project's own rules.~/.config/vale/.vale.ini.vale/.vale.ini: the Newsroom style in vale/styles/Newsroom, which this repo keeps, so the plugin downloads nothing. Each rule targets one sign in Wikipedia's Signs of AI writing or one line of its author's writing rules, and each alert's message names it before the fix.<!-- The table quotes what each rule flags, so Vale skips it. --> <!-- vale off -->
| Rule | Level | Flags |
|---|---|---|
EmDashes | error | An em dash, or an en dash with a space on each side. "1–3" passes. |
NeverTag | error | A ", never X" tag: "keeps the hash, never the account number". |
NegativeParallelisms | warning | "Not just X, but Y", "it's not X, it's Y", "is X, not Y", "no X, no Y". |
AIVocabulary | warning | Wikipedia's AI vocabulary for each model era ("delve", "pivotal", "crucial"), plus "genuine", "meaningful", "actionable" and a few more. |
Copulatives | warning | "Serves as a", "stands as a", "boasts a" in place of "is" or "has". |
Adverbs | warning | Filler adverbs: "really", "just", "very" and 33 more. |
BoldLabels | warning | A label and a colon in place of a sentence: "The tell: …". |
Passive | warning | Passive voice, in documents but not in code comments. |
<!-- vale on -->
vale/styles/Newsroom/NOTICE names the package each rule comes from. Words in vale/styles/config/vocabularies/Newsroom/accept.txt, such as "leverage", don't raise AIVocabulary, Adverbs, Copulatives or Passive. The bundled rules need Vale 3.18 or later: older versions read the *.test.yml files beside the rules as rules and fail.
The transcript row and Claude's note name the config that ran. A project's styles are the project's to download: run vale sync in the repo if Vale reports them missing.
To keep the bundled rules and add your own, copy vale/.vale.ini and its styles folder into your repo and edit from there.
The plugin sees only the Write and Edit tools, so a file Claude writes through Bash (cat >, sed) goes unchecked. A pre-commit hook with the same .vale.ini covers every staged file. Save this as .git/hooks/pre-commit and make it executable:
#!/bin/sh
# Lints the staged files with the repo's .vale.ini; error-level alerts block the commit.
git diff --cached --name-only --diff-filter=ACM -z | xargs -0 sh -c '[ $# -eq 0 ] || exec vale "$@"' valehooks/register.tsx 1007 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { Counts, DayStats, Kind, OpenAlert, ProseAlert, ProseReport } from '../types'
5
6// After each Write or Edit, runs Vale over the file and keeps the alerts on
7// the lines Claude added. Claude reads them as context on the tool result, and
8// the call's transcript row shows them under the diff. A project's own
9// .vale.ini decides the rules when there is one; otherwise vale/.vale.ini
10// does. Each edit also feeds the day's stats: words written, alerts written,
11// and, when the turn ends, which alerts Claude fixed or kept.
12// types/index.d.ts declares what the rows and the stats keep.
13
14type Hunk = { oldStart: number; newStart: number; lines: readonly string[] }
15
16// One alert as `vale --output JSON` reports it. Line is 1-based; Span is the
17// 1-based, inclusive character range of the match within that line.
18type ValeAlert = {
19 Check: string
20 Line: number
21 Span: [number, number]
22 Message: string
23 Severity: ProseAlert['severity']
24 Match: string
25}
26
27// What lint() found: the alerts per file, and the config that produced them
28// as people read it ("bundled rules", or a .vale.ini path with ~ for home).
29// configArgs are the arguments that picked the config: none when Vale found
30// the project's or the user's own, else --config with the bundled one.
31type Linted = { configLabel: string; configArgs: string[]; byFile: Record<string, ValeAlert[]> }
32
33// Caps the alerts /prose-lint prints, so a folder of old prose stays readable.
34const MAX_REPORTED = 200
35
36// The label of the plugin's own rules, which also tells parse() that a failure
37// isn't a team's unsynced styles.
38const BUNDLED = 'bundled rules'
39
40// What /prose-lint takes, for its hint and its usage line.
41const USAGE = '<file or folder>… | stats [project] | coach'
42
43// The report of each flagged Edit or Write, for its transcript row.
44const reports = atom({ plugin: 'prose-lint', key: 'reports' } as const, { configLabel: '', alerts: [] })
45
46// The alerts Claude wrote in the current turn, given a verdict when it ends.
47const open = atom({ plugin: 'prose-lint', key: 'open' } as const, [] as OpenAlert[])
48
49// The window /prose-lint stats reads, in days, today included.
50const STATS_DAYS = 30
51
52// How long the store keeps a day's stats: a year of trends, at about a
53// kilobyte per day and project, far below the store's 4 MiB limit.
54const KEEP_DAYS = 365
55
56// The rules /prose-lint stats lists, by written alerts.
57const STATS_RULES = 8
58
59// The rules /prose-lint coach lists, by written alerts.
60const COACH_RULES = 5
61
62// The words the coach needs before it proposes anything: the rates are per
63// 1,000 words, and below that one alert moves a rule's rate a lot.
64const COACH_WORDS = 1000
65
66export const register: Register = on => {
67 // A reload fires session.start again, so the command survives one; and the
68 // engine awaits this hook, so /prose-lint exists before the first prompt.
69 on('session.start', async ($, e, next) => {
70 await fold($).catch(() => {})
71 await $.command.register({
72 name: 'prose-lint',
73 description: 'Lint files or folders with Vale, `stats [project]` for your writing stats, or `coach` for changes to make from them',
74 argumentHint: USAGE,
75 })
76 return next(e)
77 })
78
79 // Once the main loop's turn has answered, so every fix Claude made in it
80 // counts. A subagent's turn ends mid-turn, and an interrupted or failed
81 // turn gave Claude no chance to fix: their alerts stay open for the next
82 // answered turn.
83 on('turn.complete', async ($, e, next) => {
84 const ended = await next(e)
85 if (!e.agentId && e.reason === 'answer') await lintOpen($).catch(() => {})
86 return ended
87 })
88
89 // Lints whole files, where the edit hooks only check the lines an edit
90 // added. Each path gets the config an edit in its folder would, so paths
91 // from two repos each get their own. Claude reads the output row too, so
92 // "fix these" can follow.
93 on('command.run', { command: 'prose-lint' }, async ($, e) => {
94 const paths = e.args.split(/\s+/).filter(Boolean)
95 if (paths.length === 0) return { text: `Usage: /prose-lint ${USAGE}` }
96 // `stats` alone, or with a project name (no slash), is the stats view;
97 // ./stats lints a folder by that name, and `stats docs/` lints both.
98 if (paths[0] === 'stats' && paths.length <= 2 && !paths[1]?.includes('/')) {
99 return { text: await statsReport($, paths[1]) }
100 }
101 // `coach` alone is the coach; ./coach lints a folder by that name.
102 if (paths[0] === 'coach' && paths.length === 1) return coachReport($)
103
104 const cwd = await $.session.cwd()
105 const results: (Linted | undefined)[] = []
106 for (const path of paths) {
107 const absolute = path.startsWith('/') ? path : `${cwd}/${path}`
108 const isDir = (await $.fs.stat(absolute).catch(() => undefined))?.kind === 'dir'
109 results.push(await lint($, [absolute], isDir ? absolute : dirname(absolute)))
110 }
111
112 const linted = results.filter(result => result !== undefined)
113 if (linted.length === 0) return { text: 'prose-lint: Vale did not run; the toast says why.' }
114 const failed = linted.length < results.length ? '\n\n(Vale failed on some paths; the toast says why.)' : ''
115 return { text: report(linted, cwd) + failed }
116 })
117
118 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
119 const ran = await next(e)
120 return ran.deny !== undefined || ran.isError || ran.result.staged ? ran : withAlerts($, ran, e.tool_use_id, ran.result)
121 })
122
123 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
124 const ran = await next(e)
125 return ran.deny !== undefined || ran.isError || ran.result.staged ? ran : withAlerts($, ran, e.tool_use_id, ran.result)
126 })
127
128 // Each surface draws an Edit's or Write's diff in a different place: the
129 // terminal as a ToolResult block under the call's row, the desktop app
130 // inside the ToolUse row, with no ToolResult. The docs say neither; both
131 // come from trying it. Each hook adds the alerts under the diff where its
132 // surface draws it.
133 on('ui.render', { component: 'ToolResult' }, ($, e, next) =>
134 e.surface === 'terminal' ? annotate($, e, () => next(e)) : next(e),
135 )
136 on('ui.render', { component: 'ToolUse' }, ($, e, next) =>
137 e.surface === 'terminal' ? next(e) : annotate($, e, () => next(e)),
138 )
139}
140
141// Claude Code's own drawing (`engine`), then each flagged line of the call
142// with its matches underlined and the messages boxed under it. The engine's
143// drawing alone when the call has no alerts.
144const annotate = async (
145 $: EngineInterface,
146 e: RenderInput<'ToolUse'> | RenderInput<'ToolResult'>,
147 engine: () => Promise<RenderElement>,
148): Promise<RenderElement> => {
149 const isRunning = 'isRunning' in e.props && e.props.isRunning
150 if ((e.props.tool !== 'Edit' && e.props.tool !== 'Write') || isRunning || e.props.isErrored) return engine()
151
152 const { configLabel, alerts: list } = await read($, memberOf(reports, e))
153 if (list.length === 0) return engine()
154
155 const { Box, Text } = $.ui.resolve(e)
156 const lines = [...new Set(list.map(a => a.line))].sort((a, b) => a - b)
157 const gutter = String(lines.at(-1)).length
158 // Where a line's text starts, past the "12 │ " gutter; its box aligns to it.
159 const textColumn = gutter + 3
160 const fixed = list.filter(a => a.status === 'fixed').length
161
162 // Indented to line up with the text of Claude Code's own result block. A
163 // fixed alert stays in view, struck through, so the row tells the whole
164 // story of the edit: what the lint found, and what got fixed.
165 return (
166 <Box flexDirection="column">
167 {await engine()}
168 <Box flexDirection="column" marginTop={1} marginLeft={5}>
169 <Text>
170 <Text bold>prose-lint</Text>
171 <Text dimColor>
172 {' '}
173 · {plural(list.length, 'alert')}
174 {fixed > 0 ? ` · ${fixed === list.length ? 'all' : fixed} fixed` : ''} · {configLabel}
175 </Text>
176 </Text>
177 {lines.map(line => {
178 const onLine = list.filter(a => a.line === line).sort((a, b) => a.start - b.start)
179 return (
180 <Box flexDirection="column" marginTop={1}>
181 <Text>
182 <Text dimColor>{String(line).padStart(gutter)} │ </Text>
183 {runs(onLine[0]?.text ?? '', onLine).map(run =>
184 !run.severity ? (
185 run.text
186 ) : run.isFixed ? (
187 <Text strikethrough dimColor>
188 {run.text}
189 </Text>
190 ) : (
191 <Text underline color={COLOR[run.severity]}>
192 {run.text}
193 </Text>
194 ),
195 )}
196 </Text>
197 <Box
198 flexDirection="column"
199 borderStyle="round"
200 borderColor="gray"
201 borderDimColor
202 marginLeft={textColumn}
203 paddingX={1}
204 >
205 {onLine.map(a =>
206 a.status === 'fixed' ? (
207 <Text dimColor>
208 <Text color="green">✓ </Text>
209 <Text strikethrough>{a.message}</Text> {a.check}
210 </Text>
211 ) : (
212 <Text>
213 <Text color={COLOR[a.severity]}>● </Text>
214 {a.message} <Text dimColor>{a.check}</Text>
215 </Text>
216 ),
217 )}
218 </Box>
219 </Box>
220 )
221 })}
222 </Box>
223 </Box>
224 )
225}
226
227const RANK = { suggestion: 1, warning: 2, error: 3 } as const
228const COLOR = { suggestion: 'blue', warning: 'yellow', error: 'red' } as const
229
230type Run = { text: string; severity?: ProseAlert['severity']; isFixed?: boolean }
231
232// Splits a line into runs of plain and flagged characters. Where alerts
233// overlap, an unfixed one wins over a fixed one, then the most severe one
234// colours the run; a run only fixed alerts cover draws as fixed.
235const runs = (text: string, onLine: ProseAlert[]): Run[] => {
236 const out: Run[] = []
237 Array.from(text).forEach((char, i) => {
238 const covering = onLine
239 .filter(a => i + 1 >= a.start && i + 1 <= a.end)
240 .sort((a, b) => Number(a.status === 'fixed') - Number(b.status === 'fixed') || RANK[b.severity] - RANK[a.severity])
241 const severity = covering[0]?.severity
242 const isFixed = covering[0]?.status === 'fixed'
243 const last = out.at(-1)
244 if (last && last.severity === severity && last.isFixed === isFixed) last.text += char
245 else out.push({ text: char, severity, isFixed })
246 })
247 return out
248}
249
250// Lints the written file and hands Claude the alerts on the lines the write
251// added, so it fixes its own prose and leaves the prose that was already there
252// alone. With no original to diff against (a new file, or an old one too large
253// for Claude Code to keep), every line counts. An edit that adds no line adds
254// no alerts or words, so it lints nothing, but it still counts a fix edit when
255// it deleted an open alert's words; an empty diff (nothing changed, or the diff
256// timed out) is one such edit. Also keeps the alerts under the call's id for
257// its transcript row. Returns `ran` unchanged when no alert lands.
258const withAlerts = async <R extends { context?: readonly string[] }>(
259 $: EngineInterface,
260 ran: R,
261 id: string,
262 written: {
263 filePath: string
264 originalFile: string | null
265 structuredPatch: readonly Hunk[]
266 // A Write's; an Edit names none.
267 type?: 'create' | 'update'
268 },
269): Promise<R> => {
270 const { filePath: path, originalFile, structuredPatch } = written
271 const lines = originalFile === null ? undefined : addedLines(structuredPatch)
272 const removed = structuredPatch.flatMap(hunk => hunk.lines.filter(l => l.startsWith('-')).map(l => l.slice(1)))
273 if (lines?.size === 0) {
274 // Stats must never fail Claude's edit, so this drops a failure there.
275 await countFixEdit($, path, removed).catch(() => {})
276 return ran
277 }
278
279 const linted = await lint($, [path], dirname(path))
280 if (!linted) return ran
281 const found = await newAlerts(
282 $,
283 (Object.values(linted.byFile)[0] ?? []).filter(a => lines === undefined || lines.has(a.Line)),
284 written,
285 linted.configArgs,
286 )
287
288 const text = (await $.fs.read(path).catch(() => '')).split(/\r?\n/)
289 // An old file too large to diff would count all its words and the alerts it
290 // already had as Claude's; only a new one can count whole.
291 if (lines !== undefined || written.type === 'create') {
292 const added = lines === undefined ? text : [...lines].map(line => text[line - 1] ?? '')
293 // Stats must never cost Claude its alerts, so this drops a failure there.
294 await recordEdit($, id, path, linted.configLabel, { added, removed }, found).catch(() => {})
295 }
296 if (found.length === 0) return ran
297
298 const kept: ProseReport = {
299 configLabel: linted.configLabel,
300 alerts: found.map(a => ({
301 line: a.Line,
302 start: a.Span[0],
303 end: a.Span[1],
304 match: a.Match,
305 check: a.Check,
306 message: a.Message,
307 severity: a.Severity,
308 text: text[a.Line - 1] ?? '',
309 })),
310 }
311 await update($, memberOf(reports, { requestId: id }), () => kept)
312
313 $.ui.toast(`prose-lint: ${plural(found.length, 'alert')} in ${basename(path)}`)
314 return { ...ran, context: [...(ran.context ?? []), describe(found, linted.configLabel)] }
315}
316
317// Every alert Vale reports for the paths (files or folders), keyed by file;
318// Vale omits files without alerts. Vale itself picks the config, from `dir`:
319// a .vale.ini there or in a folder above, else the user's global one, the
320// same search a team's own `vale` runs make. Only when it finds neither do
321// the bundled rules run. A project config wins because it states the team's
322// goals for the prose in their repo, and their git hook or CI can run the same
323// file over what this mod never sees, such as files Claude writes through Bash.
324// Never throws: a failure (Vale missing, a project config with unsynced
325// styles) shows a toast and returns undefined, so a lint problem never fails
326// Claude's edit.
327const lint = async ($: EngineInterface, paths: string[], dir: string): Promise<Linted | undefined> => {
328 const root = pluginRoot($)
329 const bundled = bundledConfig($)
330
331 // Vale would find the bundled config above the plugin's own files and label
332 // it as a project's.
333 const isOwnFile = dir === root || dir.startsWith(`${root}/`)
334 if (!isOwnFile) {
335 const run = await vale($, ['--output', 'JSON', ...paths], dir)
336 if (!run) return undefined
337 if (!isNoConfig(run)) return parse($, run, await labelFor($, dir), [])
338 }
339
340 const run = await vale($, ['--config', bundled, '--output', 'JSON', ...paths], dir)
341 return run && parse($, run, BUNDLED, ['--config', bundled])
342}
343
344// The docs say `root` is the folder holding plugin.json, which could mean
345// .claude-plugin/ itself; the plugin's files sit beside .claude-plugin/ either way.
346const pluginRoot = ($: EngineInterface): string => $.plugin.root.replace(/\/\.claude-plugin\/?$/, '')
347
348const bundledConfig = ($: EngineInterface): string => `${pluginRoot($)}/vale/.vale.ini`
349
350// The alerts in `text`, linted as the file at `path` from `dir` with the
351// config arguments an earlier lint() settled on, so no config search or label
352// runs again. Vale matches a config's sections against the path string as it
353// is given, so `path` and `dir` must be the ones that lint got for the same
354// sections to apply. Undefined, with no toast, when Vale fails: the caller
355// treats that as "no alerts known".
356const lintText = async (
357 $: EngineInterface,
358 text: string,
359 path: string,
360 dir: string,
361 configArgs: string[],
362): Promise<ValeAlert[] | undefined> => {
363 const run = await $.process
364 .run(['vale', ...configArgs, `--path=${path}`, '--output', 'JSON'], { cwd: dir, timeoutMs: 60_000, stdin: text })
365 .catch(() => undefined)
366 if (!run || run.exitCode > 1) return undefined
367 try {
368 return Object.values(JSON.parse(run.stdout) as Record<string, ValeAlert[]>)[0] ?? []
369 } catch {
370 return undefined
371 }
372}
373
374// Runs vale from `dir`; undefined, after a toast, when it can't start. Vale
375// lints one file in well under a second; the timeout only guards against a
376// hang holding Claude's edit, and leaves room for a folder.
377const vale = ($: EngineInterface, args: string[], dir: string): Promise<ProcessRunResult | undefined> =>
378 $.process.run(['vale', ...args], { cwd: dir, timeoutMs: 60_000 }).catch(err => {
379 $.ui.toast(`prose-lint: vale did not run (${String(err).slice(0, 120)}). Is it installed? (brew install vale)`)
380 return undefined
381 })
382
383// True when Vale found no .vale.ini at all, the one failure that means "use
384// the bundled rules" rather than "this config is broken".
385const isNoConfig = (run: ProcessRunResult): boolean =>
386 run.exitCode === 2 && run.stderr.includes('"no config file found"')
387
388// Vale's report, or undefined after a toast when Vale failed. Vale exits 1
389// when it finds an error-level alert and 2 when it fails; with --output JSON
390// it reports the failure as JSON on stderr, with the message under Text.
391const parse = (
392 $: EngineInterface,
393 run: ProcessRunResult,
394 configLabel: string,
395 configArgs: string[],
396): Linted | undefined => {
397 if (run.exitCode > 1) {
398 let reason = (run.stderr || run.stdout).trim()
399 try {
400 reason = (JSON.parse(run.stderr) as { Text?: string }).Text ?? reason
401 } catch {}
402 const hint = configLabel === BUNDLED ? '' : ` (${configLabel}: does it need \`vale sync\`?)`
403 $.ui.toast(`prose-lint: vale failed: ${reason.slice(0, 160)}${hint}`)
404 return undefined
405 }
406
407 try {
408 return { configLabel, configArgs, byFile: JSON.parse(run.stdout) as Record<string, ValeAlert[]> }
409 } catch {
410 $.ui.toast(`prose-lint: could not read vale's report${run.isStdoutTruncated ? ' (too long)' : ''}`)
411 return undefined
412 }
413}
414
415// The config Vale used for `dir`, as people read it. For display only: it
416// looks for the file names Vale looks for, nearest folder first, but Vale's
417// own search decided the run.
418const labelFor = async ($: EngineInterface, dir: string): Promise<string> => {
419 for (let folder = dir; ; folder = dirname(folder)) {
420 for (const name of ['.vale.ini', '_vale.ini']) {
421 const path = `${folder}/${name}`.replace(/^\/\//, '/')
422 if (await $.fs.stat(path).then(() => true, () => false)) return shorten($, path)
423 }
424 if (dirname(folder) === folder) return 'your global .vale.ini'
425 }
426}
427
428// A config path as the row shows it, with the home folder as ~.
429const shorten = async ($: EngineInterface, path: string): Promise<string> => {
430 const home = await $.env.get('HOME')
431 return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
432}
433
434// The new alerts of an edit, without its existing ones: a diff shows a
435// reworded line as removed and added whole, so the alerts it already had land
436// on an added line too. Vale lints the whole pre-edit file, so code blocks and
437// comment delimiters read as they did, and each alert on a
438// removed line cancels one of the same alert on an added line of the same
439// hunk. A line that goes from one filler word to two still reports the new
440// one, and a word removed in one section never hides the same word added in
441// another.
442// When that lint fails, nothing cancels: better a repeated alert than a hidden
443// one.
444const newAlerts = async (
445 $: EngineInterface,
446 found: ValeAlert[],
447 written: { filePath: string; originalFile: string | null; structuredPatch: readonly Hunk[] },
448 configArgs: string[],
449): Promise<ValeAlert[]> => {
450 if (found.length === 0 || written.originalFile === null) return found
451 const before = await lintText($, written.originalFile, written.filePath, dirname(written.filePath), configArgs)
452 if (!before) return found
453
454 // Which hunk each removed line (old numbering) and added line (new) is in.
455 const removedIn = new Map<number, number>()
456 const addedIn = new Map<number, number>()
457 written.structuredPatch.forEach((hunk, index) => {
458 let oldLine = hunk.oldStart
459 let newLine = hunk.newStart
460 for (const line of hunk.lines) {
461 if (line.startsWith('-')) removedIn.set(oldLine++, index)
462 else if (line.startsWith('+')) addedIn.set(newLine++, index)
463 else if (line.startsWith(' ')) {
464 oldLine++
465 newLine++
466 }
467 }
468 })
469
470 const existing = new Map<string, number>()
471 for (const alert of before) {
472 const hunk = removedIn.get(alert.Line)
473 if (hunk === undefined) continue
474 const key = `${hunk}\n${alertKey(alert.Check, alert.Match)}`
475 existing.set(key, (existing.get(key) ?? 0) + 1)
476 }
477 return found.filter(alert => {
478 const key = `${addedIn.get(alert.Line)}\n${alertKey(alert.Check, alert.Match)}`
479 const left = existing.get(key) ?? 0
480 if (left === 0) return true
481 existing.set(key, left - 1)
482 return false
483 })
484}
485
486// What makes two alerts, in two versions of a file, the same alert: the rule
487// and the words it matched, ignoring case and spacing, since a reworded
488// sentence can capitalize its first word ("it's" becomes "It's") or wrap
489// differently. Every comparison between versions keys on this.
490const alertKey = (check: string, match: string): string => `${check}\n${sameWords(match)}`
491
492const sameWords = (text: string): string => text.toLowerCase().replace(/\s+/g, ' ').trim()
493
494// The 1-based line numbers, in the new file, of the lines a patch added.
495const addedLines = (patch: readonly Hunk[]): Set<number> => {
496 const added = new Set<number>()
497 for (const hunk of patch) {
498 let line = hunk.newStart
499 for (const text of hunk.lines) {
500 if (text.startsWith('+')) added.add(line++)
501 else if (text.startsWith(' ')) line++
502 }
503 }
504 return added
505}
506
507// Adds one Write or Edit to today's stats: the prose words it added, every
508// alert on them, and a fix edit when it removed words an open alert matched
509// in that file. Opens every alert for the turn's end to give a verdict.
510// Files whose kind it can't tell don't count.
511const recordEdit = async (
512 $: EngineInterface,
513 id: string,
514 path: string,
515 configLabel: string,
516 change: { added: string[]; removed: string[] },
517 found: ValeAlert[],
518): Promise<void> => {
519 const kind = kindOf(path)
520 if (!kind) return
521
522 const isFixEdit = await removesOpen($, path, change.removed)
523 await updateStats($, stats => {
524 if (!stats.configs.includes(configLabel)) stats.configs.push(configLabel)
525 stats.words[kind] = (stats.words[kind] ?? 0) + countWords(change.added, path)
526 if (isFixEdit) stats.fixEdits++
527 for (const a of found) {
528 countsOf(stats, a.Check, kind).written++
529 const examples = (stats.examples[a.Check] ??= [])
530 if (examples.length < 3 && !examples.includes(a.Match)) examples.push(a.Match)
531 }
532 })
533 const opened: OpenAlert[] = found.map((a, index) => ({ id, index, file: path, check: a.Check, match: a.Match, kind }))
534 if (opened.length > 0) await update($, open, list => [...list, ...opened])
535}
536
537// Adds a fix edit to today's stats for an edit that added no line, when it
538// removed an open alert's words. No config linted it, so `configs` stays as is.
539const countFixEdit = async ($: EngineInterface, path: string, removed: string[]): Promise<void> => {
540 if (await removesOpen($, path, removed)) {
541 await updateStats($, stats => {
542 stats.fixEdits++
543 })
544 }
545}
546
547// True when the removed lines of `path` hold the words of an alert still open
548// in that file. Removing no words fixes nothing, even an alert that matched
549// only spaces.
550const removesOpen = async ($: EngineInterface, path: string, removed: string[]): Promise<boolean> => {
551 const removedWords = sameWords(removed.join(' '))
552 if (removedWords === '') return false
553 return (await read($, open)).some(alert => alert.file === path && removedWords.includes(sameWords(alert.match)))
554}
555
556// Lints each file with open alerts again and counts how often each alert
557// (rule and words) still shows up there: open alerts of that rule and words
558// take those as kept, in the order Claude wrote them, and the rest count as
559// fixed. Matching by rule and words, not line, survives the line shifts edits
560// cause; the cost is that a match the file already had before counts a new
561// one as kept too.
562//
563// An alert counts in the stats once, at its first verdict. A kept one stays
564// open, so a later fix still strikes it out on its row; its file's
565// modification time skips the lint while the file hasn't changed. Alerts in a
566// file that no longer exists, or that Vale failed on, wait deferred for the
567// next turn.
568const lintOpen = async ($: EngineInterface): Promise<void> => {
569 // Taken and cleared in one step, so an edit that lands meanwhile opens its
570 // alerts for the next turn rather than losing them to this one.
571 let opened: OpenAlert[] = []
572 await update($, open, list => ((opened = list), []))
573 // Alerts stored by an older version of the module carry no row to update.
574 opened = opened.filter(alert => alert.id !== undefined)
575 if (opened.length === 0) return
576
577 const verdicts = new Map<OpenAlert, { status: 'fixed' | 'kept'; lintedMs: number }>()
578 const deferred: OpenAlert[] = []
579 await Promise.all(
580 [...new Set(opened.map(alert => alert.file))].map(async file => {
581 const ofFile = opened.filter(alert => alert.file === file)
582 const mtimeMs = await $.fs.stat(file).then(stat => stat.mtimeMs, () => undefined)
583 if (mtimeMs !== undefined && ofFile.every(alert => alert.status === 'kept' && alert.lintedMs === mtimeMs)) {
584 deferred.push(...ofFile)
585 return
586 }
587 const linted = mtimeMs === undefined ? undefined : await lint($, [file], dirname(file))
588 if (!linted || mtimeMs === undefined) {
589 deferred.push(...ofFile)
590 return
591 }
592 const left = new Map<string, number>()
593 for (const a of Object.values(linted.byFile)[0] ?? []) {
594 left.set(alertKey(a.Check, a.Match), (left.get(alertKey(a.Check, a.Match)) ?? 0) + 1)
595 }
596 for (const alert of ofFile) {
597 const key = alertKey(alert.check, alert.match)
598 const count = left.get(key) ?? 0
599 left.set(key, count - 1)
600 verdicts.set(alert, { status: count > 0 ? 'kept' : 'fixed', lintedMs: mtimeMs })
601 }
602 }),
603 )
604
605 const firstVerdicts = [...verdicts].filter(([alert]) => alert.status === undefined)
606 if (firstVerdicts.length > 0) {
607 await updateStats($, stats => {
608 for (const [alert, { status }] of firstVerdicts) countsOf(stats, alert.check, alert.kind)[status]++
609 })
610 }
611
612 const stillKept = [...verdicts]
613 .filter(([, verdict]) => verdict.status === 'kept')
614 .map(([alert, verdict]): OpenAlert => ({ ...alert, status: 'kept', lintedMs: verdict.lintedMs }))
615 if (stillKept.length > 0 || deferred.length > 0) await update($, open, list => [...deferred, ...stillKept, ...list])
616
617 // Each row whose alerts got a new verdict: a first one, or kept turned fixed.
618 const changed = [...verdicts].filter(([alert, verdict]) => alert.status !== verdict.status)
619 for (const id of new Set(changed.map(([alert]) => alert.id))) {
620 const byIndex = new Map(changed.filter(([alert]) => alert.id === id).map(([alert, verdict]) => [alert.index, verdict.status]))
621 await update($, memberOf(reports, { requestId: id }), report => ({
622 ...report,
623 alerts: report.alerts.map((alert, index) => {
624 const status = byIndex.get(index)
625 return status ? { ...alert, status } : alert
626 }),
627 }))
628 }
629}
630
631// Today's stats for this project, under a key of this session's own, so two
632// open sessions never overwrite each other. Updates run one at a time, since
633// parallel edits in one session would otherwise lose each other's counts.
634let statsQueue: Promise<void> = Promise.resolve()
635
636const updateStats = ($: EngineInterface, change: (stats: DayStats) => void): Promise<void> => {
637 const run = statsQueue.then(async () => {
638 const key = `stats/${day(await $.clock.now())}/${basename(await $.session.root())}/${await $.session.id()}`
639 const stats = ((await $.store.get(key)) as DayStats | undefined) ?? emptyStats()
640 change(stats)
641 await $.store.set(key, stats)
642 })
643 // One failed update must not stop the ones queued after it.
644 statsQueue = run.catch(() => {})
645 return run
646}
647
648// Folds each session's record from an earlier day into that day's record for
649// its project, so the store grows by the day, not by the session, and drops
650// days older than KEEP_DAYS, so it stops growing. Two sessions starting at
651// the same moment could fold one record twice; rare enough to accept.
652const fold = async ($: EngineInterface): Promise<void> => {
653 const now = await $.clock.now()
654 const today = day(now)
655 const oldest = day(now - (KEEP_DAYS - 1) * DAY_MS)
656 for (const key of await $.store.keys()) {
657 const [prefix, date, project, session] = key.split('/')
658 if (prefix !== 'stats' || !date) continue
659 if (date < oldest) {
660 await $.store.delete(key)
661 continue
662 }
663 if (!session || date >= today) continue
664
665 const target = `stats/${date}/${project}`
666 const into = ((await $.store.get(target)) as DayStats | undefined) ?? emptyStats()
667 await $.store.set(target, merge(into, (await $.store.get(key)) as DayStats))
668 await $.store.delete(key)
669 }
670}
671
672// /prose-lint stats: the last STATS_DAYS days, for every project or one: the
673// rate across all rules by week, the rules Claude trips most, and docs
674// against comments, led by this session's line when the session wrote within
675// the view. A rule's kept rate is a hint about the rule more than the
676// writing: a high one points at noise worth tuning in the config.
677const statsReport = async ($: EngineInterface, project?: string): Promise<string> => {
678 const scope = project ?? 'all projects'
679 const { total, current, weeks } = await gather($, project)
680
681 const words = wordsOf(total)
682 if (words === 0) return `prose-lint: no stats for ${scope} in the last ${STATS_DAYS} days yet.`
683
684 const sessionLine = wordsOf(current) > 0 ? [sessionSummary(current), ''] : []
685
686 const rules = topRules(total, STATS_RULES)
687
688 return [
689 ...sessionLine,
690 `**prose-lint stats** · last ${STATS_DAYS} days · ${scope}`,
691 '',
692 `${thousands(words)} words · ${per1000(writtenOf(total), words)} alerts and ` +
693 `${per1000(total.fixEdits, words)} fix edits per 1,000 words · rules: ${total.configs.join(', ')}`,
694 '',
695 '| Week of | Words | Alerts per 1,000 |',
696 '| - | - | - |',
697 ...[...weeks]
698 .sort(([a], [b]) => a.localeCompare(b))
699 .map(([week, stats]) => `| ${week} | ${thousands(wordsOf(stats))} | ${per1000(writtenOf(stats), wordsOf(stats))} |`),
700 '',
701 '| Rule | Written | Kept | Examples |',
702 '| - | - | - | - |',
703 ...rules.map(
704 rule => `| ${ruleCells(rule, total).join(' | ')} |`,
705 ),
706 '',
707 '| Where | Words | Alerts per 1,000 |',
708 '| - | - | - |',
709 ...(['docs', 'comments'] as const).map(kind => {
710 const written = sumCounts(Object.values(total.rules).map(byKind => byKind[kind])).written
711 return `| ${kind} | ${thousands(total.words[kind] ?? 0)} | ${per1000(written, total.words[kind] ?? 0)} |`
712 }),
713 ].join('\n')
714}
715
716// /prose-lint coach: this session's line and the rules Claude writes most, for
717// the user; for Claude alone, how to turn them into proposals.
718const coachReport = async ($: EngineInterface): Promise<{ text: string; context?: string[] }> => {
719 const { total, current, configs } = await gather($)
720 const words = wordsOf(total)
721 if (words < COACH_WORDS) {
722 return {
723 text:
724 `prose-lint: not enough writing yet to coach from: ${thousands(words)} words in the last ${STATS_DAYS} days, ` +
725 `and the coach needs ${thousands(COACH_WORDS)}.`,
726 }
727 }
728 // A command's context waits for the user's next prompt, and a command.run
729 // hook can't submit one (it would wait on the turn the hook holds). A local
730 // command ends no turn either, so a timer submits it once the hook returns,
731 // and Claude answers the table without the user asking.
732 $.clock.after(0, () => void $.prompt.submit({ text: 'Propose changes from the /prose-lint coach table.' }).catch(() => {}))
733 return {
734 context: [coaching(bundledConfig($))],
735 text: [
736 sessionSummary(current),
737 '',
738 `**prose-lint coach** · last ${STATS_DAYS} days · all projects · ${thousands(words)} words`,
739 '',
740 '| Rule | Written | Kept | Examples | Config |',
741 '| - | - | - | - | - |',
742 ...topRules(total, COACH_RULES).map(
743 rule => `| ${[...ruleCells(rule, total), [...(configs.get(rule.check) ?? [])].join(', ')].join(' | ')} |`,
744 ),
745 ].join('\n'),
746 }
747}
748
749// What Claude reads after /prose-lint coach. The evidence decides the kind of
750// change: a rule Claude keeps is noise in the config, a rule Claude writes and
751// fixes is a habit for its instructions. `bundled` is the plugin's own config,
752// which an update overwrites.
753const coaching = (bundled: string): string =>
754 [
755 `The user ran /prose-lint coach. Its table lists the rules you wrote the most alerts of in the last ${STATS_DAYS} days, ` +
756 'across projects. Propose up to three changes the table supports, each with its evidence: the rule, its written ' +
757 'count, its kept rate and the examples. Each change is one of these:',
758 '- A high kept rate means the rule is noise: turn it off or down in the config that ran it.',
759 "- Many written alerts with a low kept rate mean a habit: add a line to your instructions (CLAUDE.md) that stops it.",
760 "- Examples that are the project's own terms: add them to the vocabulary of the config that ran the rule.",
761 'Find CLAUDE.md and the config files yourself. ' +
762 `"${BUNDLED}" is the plugin's own config, ${bundled}, which a plugin update overwrites, so a change to it ` +
763 "starts by copying it and its styles folder to Vale's global config folder: `vale ls-dirs` names it. " +
764 'Tell the user what the copy costs: it then runs in every project without its own config, and later ' +
765 "updates to the plugin's rules stop reaching them. " +
766 "A project's .vale.ini belongs to its team: say so when you propose a change to one.",
767 'Number the proposals and ask the user which to apply. Apply only the changes the user picks, and change no file ' +
768 'before they pick.',
769 ].join('\n')
770
771// A rule's row in the stats and coach tables: the rule, its written alerts,
772// its kept rate (the share of its alerts with a verdict that Claude kept) and
773// its example matches.
774const ruleCells = (rule: { check: string } & Counts, stats: DayStats): string[] => {
775 const judged = rule.kept + rule.fixed
776 return [
777 rule.check,
778 String(rule.written),
779 judged > 0 ? `${Math.round((100 * rule.kept) / judged)}%` : '–',
780 (stats.examples[rule.check] ?? []).map(match => `"${match}"`).join(', '),
781 ]
782}
783
784// The `count` rules Claude wrote the most alerts of, with their counts summed
785// across kinds.
786const topRules = (stats: DayStats, count: number): ({ check: string } & Counts)[] =>
787 Object.entries(stats.rules)
788 .map(([check, byKind]) => ({ check, ...sumCounts(Object.values(byKind)) }))
789 .sort((a, b) => b.written - a.written)
790 .slice(0, count)
791
792// The stats of the last STATS_DAYS days, for every project or one: summed,
793// by week, and this session's own records, which a project's view counts
794// only when the session wrote in that project. A record keeps its configs but
795// not which rule each ran, so `configs` gives a rule every config of each
796// record it appears in: exact while a record ran one config.
797const gather = async ($: EngineInterface, project?: string) => {
798 const since = day((await $.clock.now()) - (STATS_DAYS - 1) * DAY_MS)
799 const sessionId = await $.session.id()
800 const total = emptyStats()
801 const current = emptyStats()
802 const weeks = new Map<string, DayStats>()
803 const configs = new Map<string, Set<string>>()
804 for (const key of await $.store.keys()) {
805 const [prefix, date, from, session] = key.split('/')
806 if (prefix !== 'stats' || !date || !from || (project && from !== project)) continue
807 if (date < since && session !== sessionId) continue
808 const stats = (await $.store.get(key)) as DayStats
809 if (session === sessionId) merge(current, stats)
810 if (date < since) continue
811 merge(total, stats)
812 const week = weekOf(date)
813 if (!weeks.has(week)) weeks.set(week, emptyStats())
814 merge(weeks.get(week) as DayStats, stats)
815 for (const check of Object.keys(stats.rules)) {
816 const ran = configs.get(check) ?? new Set()
817 for (const config of stats.configs) ran.add(config)
818 configs.set(check, ran)
819 }
820 }
821 return { total, current, weeks, configs }
822}
823
824// This session's line: its words, its rates per 1,000 words, and its alerts'
825// verdicts.
826const sessionSummary = (current: DayStats): string => {
827 const words = wordsOf(current)
828 if (words === 0) return '**This session** · no prose written yet'
829 const session = sumCounts(Object.values(current.rules).flatMap(byKind => Object.values(byKind)))
830 return (
831 `**This session** · ${thousands(words)} words · ${per1000(session.written, words)} alerts and ` +
832 `${per1000(current.fixEdits, words)} fix edits per 1,000 words · ` +
833 `${session.written} written, ${session.fixed} fixed, ${session.kept} kept`
834 )
835}
836
837const DAY_MS = 86_400_000
838
839// The Monday that starts the week of a YYYY-MM-DD day.
840const weekOf = (date: string): string => {
841 const [year, month, dayOfMonth] = date.split('-').map(Number)
842 const at = new Date(year ?? 0, (month ?? 1) - 1, dayOfMonth ?? 1)
843 at.setDate(at.getDate() - ((at.getDay() + 6) % 7))
844 return day(at.getTime())
845}
846
847const sumCounts = (all: (Counts | undefined)[]): Counts =>
848 all.reduce<Counts>(
849 (sum, c) => ({ written: sum.written + (c?.written ?? 0), fixed: sum.fixed + (c?.fixed ?? 0), kept: sum.kept + (c?.kept ?? 0) }),
850 { written: 0, fixed: 0, kept: 0 },
851 )
852
853const wordsOf = (stats: DayStats): number => Object.values(stats.words).reduce((sum, n) => sum + n, 0)
854const writtenOf = (stats: DayStats): number =>
855 sumCounts(Object.values(stats.rules).flatMap(byKind => Object.values(byKind))).written
856const per1000 = (count: number, words: number): string => (words > 0 ? ((1000 * count) / words).toFixed(1) : '–')
857// 1234567 as "1,234,567" whatever the machine's locale, unlike toLocaleString.
858const thousands = (n: number): string => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
859// 1 as "1 alert", 3 as "3 alerts".
860const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
861
862const emptyStats = (): DayStats => ({ configs: [], words: {}, rules: {}, fixEdits: 0, examples: {} })
863
864const countsOf = (stats: DayStats, check: string, kind: Kind): Counts =>
865 ((stats.rules[check] ??= {})[kind] ??= { written: 0, fixed: 0, kept: 0 })
866
867// Adds `from` into `into` and returns `into`, which it changes; `from` stays
868// untouched. Examples stay capped at three per rule.
869const merge = (into: DayStats, from: DayStats): DayStats => {
870 for (const config of from.configs) if (!into.configs.includes(config)) into.configs.push(config)
871 for (const [kind, words] of Object.entries(from.words) as [Kind, number][]) {
872 into.words[kind] = (into.words[kind] ?? 0) + words
873 }
874 for (const [check, byKind] of Object.entries(from.rules)) {
875 for (const [kind, counts] of Object.entries(byKind) as [Kind, Counts][]) {
876 const total = countsOf(into, check, kind)
877 total.written += counts.written
878 total.fixed += counts.fixed
879 total.kept += counts.kept
880 }
881 }
882 into.fixEdits += from.fixEdits
883 for (const [check, examples] of Object.entries(from.examples)) {
884 into.examples[check] = [...new Set([...(into.examples[check] ?? []), ...examples])].slice(0, 3)
885 }
886 return into
887}
888
889// A timestamp's local calendar day, as YYYY-MM-DD, which sorts by date.
890const day = (ms: number): string => {
891 const date = new Date(ms)
892 const pad = (n: number) => String(n).padStart(2, '0')
893 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
894}
895
896// Documents count all their prose; the code types the bundled .vale.ini lints
897// count their comments. Keep these lists in step with its section glob.
898const DOCS = new Set(['md', 'mdx', 'markdown', 'txt', 'org', 'html'])
899const CODE = new Set(
900 'c h cc cpp hpp cs css go java kt js jsx mjs cjs ts tsx lua php py rb rs swift scala sh bash zsh'.split(' '),
901)
902
903// The file name's extension, lowercased; '' for a name without one, such as
904// CODEOWNERS, or a dotted folder above it.
905const extOf = (path: string): string => {
906 const name = basename(path)
907 return name.includes('.') ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : ''
908}
909
910const kindOf = (path: string): Kind | undefined =>
911 DOCS.has(extOf(path)) ? 'docs' : CODE.has(extOf(path)) ? 'comments' : undefined
912
913// Where a line comment starts, by the code type's own markers, so a Rust
914// attribute (#[derive]) or a shell flag (--output) never reads as one.
915const COMMENT_START: Record<string, RegExp> = {
916 py: /^#(?!!)/,
917 rb: /^#(?!!)/,
918 sh: /^#(?!!)/,
919 bash: /^#(?!!)/,
920 zsh: /^#(?!!)/,
921 lua: /^--/,
922 php: /^(?:\/\/+|#(?!\[)|\/?\*+)/,
923}
924const C_STYLE = /^(?:\/\/+|\/?\*+)/
925
926const wordCount = (text: string): number => text.match(/\p{L}[\p{L}'’-]*/gu)?.length ?? 0
927
928// Words in the added lines of `path`: a document's prose, without code
929// blocks, inline code, HTML tags or link targets; or the comment lines of
930// code, found by their leading marker. Close enough for a rate, not exact:
931// the fence state starts at each edit's first added line, and trailing
932// comments after code and Python docstrings go uncounted.
933const countWords = (lines: string[], path: string): number => {
934 const commentStart = COMMENT_START[extOf(path)] ?? C_STYLE
935 let inFence = false
936 let words = 0
937 for (const line of lines) {
938 const trimmed = line.trim()
939 if (kindOf(path) === 'docs') {
940 if (/^(```|~~~)/.test(trimmed)) inFence = !inFence
941 else if (!inFence) {
942 words += wordCount(trimmed.replace(/`[^`]*`|<[^>]*>/g, ' ').replace(/\]\([^)]*\)/g, ']'))
943 }
944 } else {
945 const marker = trimmed.match(commentStart)
946 if (marker) words += wordCount(trimmed.slice(marker[0].length))
947 }
948 }
949 return words
950}
951
952const byPosition = (a: ValeAlert, b: ValeAlert): number => a.Line - b.Line || a.Span[0] - b.Span[0]
953
954// The output of /prose-lint: a count and the configs that ran, then each file
955// (relative to `cwd` when inside it) with its alerts in file order, cut at
956// MAX_REPORTED alerts.
957const report = (linted: Linted[], cwd: string): string => {
958 const configs = [...new Set(linted.map(one => one.configLabel))].join(', ')
959 const files = linted
960 .flatMap(one => Object.entries(one.byFile))
961 .filter(([, found]) => found.length > 0)
962 const total = files.reduce((sum, [, found]) => sum + found.length, 0)
963 if (total === 0) return `prose-lint: no alerts found (${configs}).`
964
965 const lines = [
966 `prose-lint · ${plural(total, 'alert')} in ${plural(files.length, 'file')} · ${configs}`,
967 ]
968 let shown = 0
969 for (const [file, found] of files) {
970 if (shown === MAX_REPORTED) break
971 lines.push('', `\`${file.startsWith(`${cwd}/`) ? file.slice(cwd.length + 1) : file}\``)
972 for (const a of [...found].sort(byPosition).slice(0, MAX_REPORTED - shown)) {
973 lines.push(`- ${a.Line}:${a.Span[0]} ${a.Check}: ${a.Message}`)
974 shown++
975 }
976 }
977 if (total > shown) lines.push('', `(${total - shown} more not shown)`)
978 return lines.join('\n')
979}
980
981// The note Claude reads after a flagged edit, terse because it costs context
982// each time. It lists every alert, in file order within each section: later
983// edits report only the lines they add, so an alert left out would never
984// reach Claude. Errors encode hard rules, so they have no way out; Claude
985// keeps any other alert only by telling the user why, so a quiet reply means
986// it fixed them all.
987const describe = (found: ValeAlert[], configLabel: string): string => {
988 const inOrder = [...found].sort(byPosition)
989 const row = (a: ValeAlert) => `- ${a.Line}:${a.Span[0]} ${a.Message}`
990 const errors = inOrder.filter(a => a.Severity === 'error').map(row)
991 const others = inOrder.filter(a => a.Severity !== 'error').map(row)
992
993 return [
994 `Vale (${configLabel}). Don't mention fixes.`,
995 ...(errors.length > 0 ? ['Must fix:', ...errors] : []),
996 ...(others.length > 0 ? ['Fix, or tell the user why not:', ...others] : []),
997 ].join('\n')
998}
999
1000const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
1001
1002// The folder holding `path`: "/" for a file at the root, "." for a bare name.
1003const dirname = (path: string): string => {
1004 const slash = path.lastIndexOf('/')
1005 return slash < 0 ? '.' : path.slice(0, slash) || '/'
1006}
1007types/index.d.ts 91 lines1// What one flagged Edit or Write keeps for its transcript row.
2export type ProseReport = {
3 // Which Vale config produced the alerts, as the row names it: "bundled
4 // rules", or the path of a project's or the user's own .vale.ini.
5 configLabel: string
6 alerts: ProseAlert[]
7}
8
9// One Vale alert on a line Claude added, as the call's transcript row draws it.
10export type ProseAlert = {
11 // 1-based, in the file as the edit left it.
12 line: number
13 // The match within `text`: 1-based, inclusive, in characters.
14 start: number
15 end: number
16 // The words Vale matched, whole even when they run onto the next line.
17 match: string
18 // Vale's rule as Style.Rule, such as Newsroom.EmDashes.
19 check: string
20 // Vale's message, which quotes the match.
21 message: string
22 // The rule's Vale level; picks the colour of the underline and the dot.
23 severity: 'error' | 'warning' | 'suggestion'
24 // The whole line at the time of the edit; a later edit doesn't change it.
25 text: string
26 // Set when the turn that wrote it ends: gone from the file (by Claude's
27 // hand or the user's) or still there. A kept one turns fixed when a later
28 // turn's end finds it gone. Unset until the first verdict.
29 status?: 'fixed' | 'kept'
30}
31
32// Where the prose sits: a document's text, or the comments in code.
33export type Kind = 'docs' | 'comments'
34
35// What became of the alerts on prose Claude wrote. A written alert counts as
36// fixed or kept at the end of the next turn Claude answers, except when the
37// file no longer exists or Vale failed on it then: those count in neither.
38export type Counts = { written: number; fixed: number; kept: number }
39
40// One day's writing in one project: a session's own record until a later
41// session folds it into the day's, in $.store under stats/<day>/<project>
42// (/<session id> while unfolded).
43export type DayStats = {
44 // The configs that ran, as their labels: where this list changes, a jump in
45 // the all-rules rate comes from the rules, not the writing.
46 configs: string[]
47 // Prose words Claude added: lines of documents outside code blocks, and
48 // comment lines in code (counted by their comment marker, so approximate).
49 words: Partial<Record<Kind, number>>
50 // By Vale rule, such as Newsroom.EmDashes, then by kind.
51 rules: Record<string, Partial<Record<Kind, Counts>>>
52 // Writes and Edits to a file that still had open alerts: the round trips
53 // the alerts cost.
54 fixEdits: number
55 // Up to three matched words or phrases per rule, for the stats view.
56 examples: Record<string, string[]>
57}
58
59// An alert Claude wrote this session that isn't fixed yet: waiting for its
60// turn to end, or kept then and watched for a later fix.
61export type OpenAlert = {
62 // The tool_use_id of the Write or Edit that wrote it, whose transcript row
63 // shows it as fixed or kept once it has a verdict.
64 id: string
65 // Its position among that row's alerts.
66 index: number
67 // 'kept' once a turn's end found it still there; the stats count it then
68 // and never again, while the row can still turn it fixed.
69 status?: 'kept'
70 // The file's modification time at that lint, in Unix milliseconds: an
71 // unchanged file can't have fixed it, so the next turn's end skips the lint.
72 lintedMs?: number
73 // The written file's absolute path, as the Write or Edit named it.
74 file: string
75 // Vale's rule as Style.Rule.
76 check: string
77 // The words the rule matched; the turn's end looks for them again.
78 match: string
79 kind: Kind
80}
81
82declare module 'claude-code' {
83 interface PluginState {
84 'prose-lint': {
85 // Keyed by the Edit or Write call's tool_use_id.
86 reports: StateFamily<ProseReport>
87 open: OpenAlert[]
88 }
89 }
90}
91