SLOPSHOPPER

lint-prose

No description.

newguardprocess
A shopper browsing a rack in a slop shop
README

lint-prose

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:

AgentAdapterLoaded by
Claude Codehooks/register.ts, a mod: tool.call on Write and Edit adds context after nextCLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl
pipi.ts: tool_result on write and edit returns { content, isError: true }extensions in dot_pi/agent/modify_settings.json
omppi.ts as in pi, and omp.ts: tool_result on an omp edit of more than one fileextensions in dot_omp/private_agent/private_config.yml
Codexcommand.ts: exit code 2 with the alerts on stderr, exit code 1 with a message for youPostToolUse on apply_patch, through dot_codex/hooks/apply-patch.ts

What it checks

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.

ExtensionVale reads
.md .markdownParagraphs, list items, blockquotes
.orgParagraphs, list items, #+begin_quote
.html .htmThe text inside elements, not the markup
.txtBlocks of text between blank lines

The hook skips these calls and files:

  • A denied or failed call in Claude Code, pi, and omp, because the call wrote nothing.
  • A file under /tmp, /var/tmp, or $TMPDIR.
  • A file with an extension that is not in the table above, and a path that is not a file.
  • Prose that a Bash command writes. See Why not Bash.

Requirements

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.

How it works

After each Write or Edit, the hook does these steps:

  1. It stops when the repository of the file sets 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.
  2. It runs check.sh on the whole file. check.sh runs Vale with the shared vale.ini in ~/.config/vale/.
  3. It keeps only the alerts on the paragraphs that changed.
  4. It gives each new alert to the model. It gives an alert that this session already reported to you only.

The shared Vale config

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

Only the changed paragraphs

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:

  • The file is outside git.
  • The file is untracked.
  • The repository has no commit yet.

Once per session

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.

What the model sees

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:

  • Claude Code: the mod adds the alerts to the context of the result, after the line Prose lint errors in the paragraphs this call changed. Fix them:.
  • pi and omp: the extension appends the alerts to the result content, after the line Prose lint errors:, and marks the result as an error.
  • Codex: 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:

  • 0: no alert, or the hook skipped the file. It prints nothing.
  • 1: a message for you on stderr, or a payload that is not valid JSON. It does not block.
  • 2: the alerts on stderr. They reach the model.

What you see

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.
AgentWhere 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 ompctx.ui.notify with the level warning, on the screen of the session
Codexstderr with exit code 1, which does not block

Settings

The hook reads these variables at run time, so export them in your shell, or set them in env in settings.json:

VariableEffect
CLAUDE_LINT_DISABLETurns 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_DISABLETurns 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.

Failure behavior

The hook fails open in every agent: a broken lint run never changes the result of the tool call.

FailureWhat happens
vale is not on PATH, or vale --version failsThe hook checks nothing. You see nothing.
~/.config/vale/scripts/check.sh is missingYou get a message that names the file. The model gets nothing.
Vale runs but fails, for example on a broken ruleYou get lint-prose: Vale failed and the output of Vale. The model gets nothing.
The mod throwsClaude Code skips the mod hook, and the result stands.
pi.ts throwspi.ts catches its own errors, and the result stands.
command.ts gets a bad payloadcommand.ts exits 1. Only exit code 2 reaches the model, so the result stands.

Testing

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.

Source 2 files
hooks/register.ts 47 lines
1/**
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};
47
core.ts 188 lines
1/**
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