No description.

Lints the prose in each Markdown, Org, HTML, or plain-text file that a Write or an Edit changed, through Vale, a prose linter. It gives the alerts to the model, so that the model fixes the text.
One core, core.ts, serves four agents through three adapters:
| Agent | Adapter | Loaded by |
|---|---|---|
| Claude Code | hooks/register.ts, a mod: tool.call on Write and Edit adds context after next | CLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl |
| pi | pi.ts: tool_result on write and edit returns { content, isError: true } | extensions in dot_pi/agent/modify_settings.json |
| omp | pi.ts as in pi, and omp.ts: tool_result on an omp edit of more than one file | extensions in dot_omp/private_agent/private_config.yml |
| Codex | command.ts: exit code 2 with the alerts on stderr, exit code 1 with a message for you | PostToolUse on apply_patch, through dot_codex/hooks/apply-patch.ts |
The hook runs after a Write or an Edit, on the file that the call names. It checks one rule for now: a paragraph, list item, or blockquote has at most 5 sentences, the limit in writing.md. docs/implementation.md tells how to add a rule.
Vale reads only the prose. It skips frontmatter, Org keyword lines, headings, code, <script>, and <pre>. docs/implementation.md lists what each format counts.
| Extension | Vale reads |
|---|---|
.md .markdown | Paragraphs, list items, blockquotes |
.org | Paragraphs, list items, #+begin_quote |
.html .htm | The text inside elements, not the markup |
.txt | Blocks of text between blank lines |
The hook skips these calls and files:
/tmp, /var/tmp, or $TMPDIR.The hook needs vale on PATH. It checks nothing when vale is not on PATH, or when vale --version fails. The second case covers a mise shim with no Vale version set for the current directory.
brew install vale # macOS
mise use -g vale # Linux, or any system with mise
The hook also needs the Vale config in ~/.config/vale/, which just chezmoi deploys. The mod needs Claude Code 2.1.296 or later, the oldest version tested.
pi.ts, command.ts, and apply-patch.ts need Node 23.6 or later, which runs .ts files without a flag. The Codex entry starts with command -v node >/dev/null || exit 0, so a machine without Node gets no error on each call. The SessionStart check then gives a warning, see A missing node gives a warning.
Codex runs a hook only after you trust its entry. After the change from hook.sh to command.ts, trust the hooks again in the Codex TUI, on the host and in the devcontainer. See docs/agents/codex.md.
After each Write or Edit, the hook does these steps:
agent-hook.lint-prose.enabled to false in git config. It runs this git config only for a prose file that exists outside a temp folder.check.sh on the whole file. check.sh runs Vale with the shared vale.ini in ~/.config/vale/.Because check.sh gives Vale the shared config, Vale never reads a repository's own Vale config. check.sh is also the scanner behind the prose-cleanup skill. To check files yourself, run:
bash ~/.config/vale/scripts/check.sh README.md docs/*.md
A paragraph counts as changed when it holds a line that git reports as changed against HEAD. So after an unrelated change, the hook does not report an old long paragraph elsewhere in the file. To find and fix the old alerts that the hook does not report, run /prose-cleanup.
When there is nothing to compare against, the hook reports every alert in the file. This occurs in these cases:
Each alert goes to the model once per session. After that, the same rule on the same paragraph text goes to you only, so a later edit to the same file does not report that paragraph again.
An edit that leaves the paragraph too long reports it again. docs/implementation.md gives the details.
A Write or an Edit has already run when the hook starts, so the hook cannot undo it. In these docs, the hook blocks when it gives alerts to the model. Each alert is one line: path:line:column:rule:message.
Each agent gets the alerts in the way that it allows:
context of the result, after the line Prose lint errors in the paragraphs this call changed. Fix them:.Prose lint errors:, and marks the result as an error.command.ts writes the alerts to stderr and exits 2. A PostToolUse hook reaches the model only with exit code 2.command.ts uses these exit codes:
Some messages are for you and not for the model, because they are not about text that the model can fix (see docs/hooks/decisions.md). The model does not get them:
lint-prose: blocked on before in this session, so not again:, followed by the alerts that this session already reported.lint-prose: Vale failed, so the prose was not checked:, followed by the output of Vale.lint-prose: <path> is missing, so the prose was not checked., when the Vale config is missing. It tells you to run just chezmoi.| Agent | Where the message goes |
|---|---|
| Claude Code | $.ui.notify: a native notification titled lint-prose, through your preferredNotifChannel. It does not show in the transcript. |
| pi and omp | ctx.ui.notify with the level warning, on the screen of the session |
| Codex | stderr with exit code 1, which does not block |
The hook reads these variables at run time, so export them in your shell, or set them in env in settings.json:
| Variable | Effect |
|---|---|
CLAUDE_LINT_DISABLE | Turns the hook off when its list holds prose or all. all also turns off lint-code. Separate the items with spaces or commas. |
AGENT_HOOKS_DISABLE | Turns the hook off when its list holds lint-prose. /toggle-hook sets it. |
The hook also reads git config agent-hook.lint-prose.enabled in the repository of the file. The value false turns the hook off in that repository, across sessions. Outside git, or with a value that is not a boolean, the hook stays on. | LINT_PROSE_VALE_DIR | Replaces ~/.config/vale as the directory of the config and scripts/check.sh. The tests set it to dot_config/vale. |
To turn the hook off for all sessions, run:
export CLAUDE_LINT_DISABLE="prose"
To turn the hook off in a running agent, run /toggle-hook lint-prose. See toggle-hook.
To turn the hook off in one repository, run /toggle-hook --repo lint-prose. In Codex, run git config agent-hook.lint-prose.enabled false in your own terminal. The dcg pack local.agent_hook blocks an agent command that writes this key.
The hook fails open in every agent: a broken lint run never changes the result of the tool call.
| Failure | What happens |
|---|---|
vale is not on PATH, or vale --version fails | The hook checks nothing. You see nothing. |
~/.config/vale/scripts/check.sh is missing | You get a message that names the file. The model gets nothing. |
| Vale runs but fails, for example on a broken rule | You get lint-prose: Vale failed and the output of Vale. The model gets nothing. |
| The mod throws | Claude Code skips the mod hook, and the result stands. |
pi.ts throws | pi.ts catches its own errors, and the result stands. |
command.ts gets a bad payload | command.ts exits 1. Only exit code 2 reaches the model, so the result stands. |
node test/check.ts runs the hook with the real Vale and checks the adapters with stubs. docs/testing.md tells what it checks and what it needs.
hooks/register.ts 47 lines1/**
2 * Claude Code mod: after a Write or Edit call, lints the prose in the file it
3 * changed and adds the alerts to the result as context for the model. A
4 * message for the user only, such as a Vale failure, goes to a notification.
5 * Fails open: a hook that throws is skipped and the result stands.
6 */
7
8import type { Register } from "claude-code";
9import { check, type Io } from "../core.ts";
10
11export const register: Register = (on) => {
12 on("tool.call", { tool: ["Write", "Edit"] }, async ($, e, next) => {
13 const ran = await next(e);
14 // A denied or failed call wrote nothing.
15 if (ran.deny !== undefined || ran.isError) return ran;
16 // The engine only takes $ calls spelled at the call site, so each
17 // function of the core is written out here.
18 const io: Io = {
19 isFile: (path) =>
20 $.fs.stat(path).then(
21 (found) => found.kind === "file",
22 () => false,
23 ),
24 read: (path) => $.fs.read(path).catch(() => undefined),
25 write: (path, text) => $.fs.write(path, text),
26 // Rejects when the program is not installed, or timed out.
27 run: (argv, cwd) => $.process.run(argv, { cwd }).catch(() => undefined),
28 };
29 const { block, notice } = await check(e.file_path, await $.session.id(), io, {
30 disable: await $.env.get("CLAUDE_LINT_DISABLE"),
31 tmpdir: await $.env.get("TMPDIR"),
32 valeDir:
33 (await $.env.get("LINT_PROSE_VALE_DIR")) || `${await $.env.get("HOME")}/.config/vale`,
34 hooksOff: await $.env.get("AGENT_HOOKS_DISABLE"),
35 });
36 if (notice) await $.ui.notify(notice).catch(() => undefined);
37 if (!block) return ran;
38 return {
39 ...ran,
40 context: [
41 ...(ran.context ?? []),
42 `Prose lint errors in the paragraphs this call changed. Fix them:\n${block}`,
43 ],
44 };
45 });
46};
47core.ts 188 lines1/**
2 * The lint-prose logic: lints the prose in the file a Write or Edit changed,
3 * through check.sh and Vale. Only the alerts in paragraphs that git reports as
4 * changed count, and each alert blocks once per session. Not on Bash: a
5 * command names files it only reads, and a read must not block. The adapters
6 * pass in the file and process functions, because a mod reaches them only
7 * through `$`.
8 *
9 * Adapters: hooks/register.ts (Claude Code mod), pi.ts and omp.ts (pi and
10 * omp), command.ts (Codex). Design notes and the rules: docs/implementation.md.
11 */
12
13export interface Io {
14 /** Whether a path is a regular file. */
15 isFile(path: string): Promise<boolean>;
16 /** The text of a file; undefined if it cannot be read. */
17 read(path: string): Promise<string | undefined>;
18 /** Writes the whole text of a file. */
19 write(path: string, text: string): Promise<void>;
20 /** The exit code, stdout, and stderr; undefined when the program cannot start. */
21 run(
22 argv: string[],
23 cwd: string,
24 ): Promise<{ exitCode: number; stdout: string; stderr: string } | undefined>;
25}
26
27export interface Settings {
28 /** CLAUDE_LINT_DISABLE: "prose" or "all" turns the hook off. */
29 disable?: string;
30 /** TMPDIR: files under it are throwaway, and the session record goes in it. */
31 tmpdir?: string;
32 /** The Vale config and scripts/check.sh: ~/.config/vale, or LINT_PROSE_VALE_DIR. */
33 valeDir: string;
34 /** AGENT_HOOKS_DISABLE: the hooks /toggle-hook turned off. */
35 hooksOff?: string;
36}
37
38/** What to say: `block` goes to the model, `notice` to the user only. */
39export interface Result {
40 block?: string;
41 notice?: string;
42}
43
44const PROSE = /\.(md|markdown|org|html?|txt)$/;
45const BLANK = /^\s*$/;
46
47const dirname = (path: string) => path.slice(0, path.lastIndexOf("/")) || "/";
48
49/**
50 * A file under /tmp, /var/tmp, or TMPDIR is scratch prose, not project prose.
51 * macOS resolves /tmp to /private/tmp, so both sides drop that prefix.
52 */
53function throwaway(file: string, tmpdir: string | undefined): boolean {
54 const plain = (path: string) => path.replace(/^\/private/, "");
55 return ["/tmp/", "/var/tmp/", tmpdir].some((dir) => dir && plain(file).startsWith(plain(dir)));
56}
57
58/**
59 * The line numbers of a file that differ from HEAD. Undefined when there is no
60 * base to compare to: outside git, an untracked file, or a first commit. Then
61 * every line is new. docs/implementation.md "Only the changed paragraphs".
62 */
63async function changedLines(file: string, io: Io): Promise<Set<number> | undefined> {
64 const dir = dirname(file);
65 const git = (...args: string[]) => io.run(["git", ...args], dir);
66 if ((await git("ls-files", "--error-unmatch", "--", file))?.exitCode !== 0) return undefined;
67 // Before the first commit there is no HEAD, and the diff fails.
68 const diff = await git("diff", "-U0", "HEAD", "--", file);
69 if (diff?.exitCode !== 0) return undefined;
70 const lines = new Set<number>();
71 // A hunk header is @@ -a,b +start,count @@. A count of 0 is a deletion
72 // after line start, and that line still marks its paragraph as changed.
73 for (const [, start, count = "1"] of diff.stdout.matchAll(/^@@ \S+ \+(\d+)(?:,(\d+))?/gm)) {
74 for (let i = 0; i < Math.max(Number(count), 1); i++) lines.add(Number(start) + i);
75 }
76 return lines;
77}
78
79/**
80 * The paragraph that starts at a 1-based line: its lines up to the next blank
81 * line.
82 */
83function paragraph(lines: string[], start: number): string[] {
84 const end = lines.findIndex((line, i) => i >= start && BLANK.test(line));
85 return lines.slice(start - 1, end < 0 ? undefined : end);
86}
87
88/**
89 * Whether the git config of the repository that holds `dir` sets
90 * agent-hook.lint-prose.enabled to false, as /toggle-hook --repo does.
91 * Outside git, a missing key, or a value that is not a boolean: false.
92 */
93async function repoOff(dir: string, io: Io): Promise<boolean> {
94 const key = "agent-hook.lint-prose.enabled";
95 const ran = await io.run(["git", "config", "--type=bool", "--get", key], dir);
96 return ran?.stdout.trim() === "false";
97}
98
99/** Lints one file. The session id keys the once-per-session record. */
100export async function check(
101 file: string | undefined,
102 session: string | undefined,
103 io: Io,
104 settings: Settings,
105): Promise<Result> {
106 const names = (list = "") => list.split(/[\s,]+/);
107 if (
108 names(settings.hooksOff).includes("lint-prose") ||
109 names(settings.disable).some((name) => name === "all" || name === "prose")
110 )
111 return {};
112 if (!file || !PROSE.test(file) || throwaway(file, settings.tmpdir) || !(await io.isFile(file)))
113 return {};
114 const dir = dirname(file);
115 if (await repoOff(dir, io)) return {};
116 // Run it, not a PATH lookup: a mise shim with no version set for this
117 // directory is on PATH but fails, and its error would block as an alert.
118 if ((await io.run(["vale", "--version"], dir))?.exitCode !== 0) return {};
119 // Vale on PATH but no config: chezmoi did not deploy ~/.config/vale here.
120 const script = `${settings.valeDir}/scripts/check.sh`;
121 if (!(await io.isFile(script)))
122 return {
123 notice: `lint-prose: ${script} is missing, so the prose was not checked. Run \`just chezmoi\` to deploy the Vale config.\n`,
124 };
125
126 // check.sh exits 1 on alerts and 2 when Vale fails, such as on a broken
127 // rule. A failure is not the prose's fault, so it goes to the user and does
128 // not block. docs/implementation.md "Why it blocks".
129 const ran = await io.run(["bash", script, file], dir);
130 if (!ran || ran.exitCode === 0) return {};
131 const output = (ran.stdout + ran.stderr).replace(/\n+$/, "");
132 if (ran.exitCode !== 1)
133 return {
134 notice: `lint-prose: Vale failed, so the prose was not checked:\n${output}\n`,
135 };
136
137 const lines = ((await io.read(file)) ?? "").replace(/\n$/, "").split("\n");
138 const prefix = `${file}:`;
139 let alerts = output.split("\n").filter(Boolean);
140 // An alert names the first line of its paragraph, and the paragraph runs to
141 // the next blank line, so a changed line anywhere in that span keeps it.
142 // shortcut: a tight list is one span, so a change to one item keeps the
143 // alerts of every long item in that list. The upgrade is item boundaries.
144 const changed = await changedLines(file, io);
145 if (changed)
146 alerts = alerts.filter((alert) => {
147 if (!alert.startsWith(prefix)) return false;
148 const start = Number(alert.slice(prefix.length).split(":")[0]);
149 return paragraph(lines, start).some((_, i) => changed.has(start + i));
150 });
151
152 // An alert is a repeat when the record holds its file, rule, and paragraph
153 // text. The text, not the line: an edit above the paragraph moves it, and an
154 // edit that leaves it too long makes it new again. No valid session id, no
155 // record: every alert blocks. docs/implementation.md "Once per session".
156 const record =
157 session && /^[A-Za-z0-9_-]+$/.test(session)
158 ? `${settings.tmpdir || "/tmp"}/lint-prose-${session}`
159 : undefined;
160 const before = record ? ((await io.read(record)) ?? "") : "";
161 const seen = new Set(before.split("\n"));
162 const added: string[] = [];
163 const report: string[] = [];
164 const repeats: string[] = [];
165 for (const alert of alerts) {
166 const [line = "", , rule = ""] = alert
167 .slice(alert.startsWith(prefix) ? prefix.length : 0)
168 .split(":");
169 const key = JSON.stringify([file, rule, paragraph(lines, Number(line)).join("\n")]);
170 if (seen.has(key)) repeats.push(alert);
171 else {
172 report.push(alert);
173 seen.add(key);
174 added.push(key);
175 }
176 }
177 // shortcut: read and write back, because the mod's $.fs has no append. Two
178 // calls at once in one session can drop a key, and that alert blocks once
179 // more. The upgrade is an append in Io.
180 if (record && added.length) await io.write(record, `${before}${added.join("\n")}\n`);
181 if (report.length) return { block: `${report.join("\n")}\n` };
182 if (repeats.length)
183 return {
184 notice: `lint-prose: blocked on before in this session, so not again:\n${repeats.join("\n")}\n`,
185 };
186 return {};
187}
188