linnl 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
linnl-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CrossNox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
linnl-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,305 @@
1
+ Metadata-Version: 2.4
2
+ Name: linnl
3
+ Version: 0.1.0
4
+ Summary: Lint prose against a configurable set of style rules, judged by a LLM.
5
+ Keywords: lint,prose,writing,style,claude,codex
6
+ Author: CrossNox
7
+ Author-email: CrossNox <ijmermet@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Text Processing :: Linguistic
17
+ Requires-Dist: claude-agent-sdk>=0.2.148
18
+ Requires-Dist: docutils>=0.23
19
+ Requires-Dist: jinja2>=3
20
+ Requires-Dist: markdown-it-py>=4
21
+ Requires-Dist: openai-codex>=0.147.0
22
+ Requires-Dist: pydantic>=2
23
+ Requires-Dist: python-json-logger>=3
24
+ Requires-Dist: rich>=13
25
+ Requires-Dist: typer>=0.15
26
+ Requires-Python: >=3.12
27
+ Project-URL: Repository, https://github.com/CrossNox/linnl
28
+ Project-URL: Issues, https://github.com/CrossNox/linnl/issues
29
+ Description-Content-Type: text/markdown
30
+
31
+ # linnl
32
+ `linnl` is a text linter aimed to catch LLM tells and reduce the complexity of the text produced by them, so that the cognitive load on the reader is reduced and ideas are clearer.
33
+
34
+ Some built-in rules are checked in code, others (most) are judged by an LLM. You can also install `linnl` plugins to provide your own set of rules.
35
+
36
+ ## Install
37
+
38
+ ```sh
39
+ uv tool install linnl
40
+ ```
41
+
42
+ ### Claude as a judge
43
+ You will either need a working `claude` login or an `ANTHROPIC_API_KEY` set in your environment.
44
+
45
+ ### Codex as a judge
46
+ Codex reuses the local Codex session. You can also set `OPENAI_API_KEY`.
47
+
48
+ ## Use
49
+
50
+ ```sh
51
+ linnl lint notes.md
52
+ linnl lint docs/
53
+ echo "text" | linnl lint
54
+ ```
55
+
56
+ Example output:
57
+
58
+ ```
59
+ notes.md
60
+ =========
61
+ line 3:
62
+ > No config. No cron. No surprises.
63
+ SCH003 Three successive fragments open with the same word for emphasis, which is anaphora.
64
+
65
+ line 5:
66
+ > Set it up once; forget about it.
67
+ CHR004 Semicolon.
68
+
69
+ Found 2 violations across 1 file.
70
+ ```
71
+
72
+ ## Configure
73
+ ### CLI
74
+
75
+ Check
76
+ ```sh
77
+ linnl lint --help
78
+ ```
79
+
80
+ To see available quick configuration options.
81
+
82
+ ### File configuration
83
+ The priority is:
84
+ - The closest `pyproject.toml` with a tool.linnl section
85
+ - A `linnl.toml` file next to the closest pyproject.toml
86
+ - `~/.config/linnl/config.toml`
87
+ - The config file shipped with the tool
88
+
89
+ You can easily create a `linnl.toml` with:
90
+ ```sh
91
+ linnl config > linnl.toml
92
+ ```
93
+
94
+ #### Adding your own rules
95
+
96
+ Add groups under `[rules.<PREFIX>]` in your config file. `description` is the only reserved key. Every other key is a rule:
97
+
98
+ ```toml
99
+ extend-select = ["SEC"]
100
+
101
+ [rules.SEC]
102
+ description = "Wording that must not leak infrastructure details"
103
+ 001 = "Names an internal host, IP or path"
104
+ abc = "Shows a credential or token in an example"
105
+ ```
106
+
107
+ That defines `SEC001` and `SECabc`, judged by the model. `linnl` shows the group description to the model above its rules, so make it say what the group is for.
108
+
109
+ The reserved `RGX` group defines regular expression rules. Every value under
110
+ `[rules.RGX]` is compiled as a Python regular expression and is enabled by
111
+ default:
112
+
113
+ ```toml
114
+ [rules.RGX]
115
+ description = "Patterns to avoid"
116
+ 001 = "X is not (.*), it's (.*)"
117
+ ```
118
+
119
+ Each non-overlapping match is reported separately, with the full match as the
120
+ quoted text. Invalid regular expressions fail configuration.
121
+
122
+ If the rule requires configurable parameters:
123
+
124
+ ```toml
125
+ extend-select = ["SEC"]
126
+
127
+ [rules.SEC]
128
+ description = "Wording that must not leak infrastructure details"
129
+ 001 = "Names an internal host, IP or path"
130
+ abc = "Shows a credential or token in an example"
131
+
132
+ [rules.SEC.xyz]
133
+ description = "Do now show more {n_files} files in the current directory"
134
+ n-files = 3
135
+ ```
136
+
137
+ A description can name the rule's options in braces. Write an option name with underscores, so `max-sentences` becomes `{max_sentences}` and `"More than {max_sentences} sentences."` renders with the configured value. Options work on Python and model rules. linnl renders their values before sending model rules to the judge.
138
+
139
+ #### Installing rules plugins
140
+
141
+ Install them alongside `linnl`:
142
+
143
+ ```sh
144
+ uv tool install linnl --with linnl-acme-rules
145
+ ```
146
+
147
+ Then enable their `linnl.plugins` entry-point names.
148
+
149
+ ```toml
150
+ plugins = ["acme-rules"]
151
+ extend-select = ["ACM"]
152
+ ```
153
+
154
+ The package contributes default sections under `[rules]`. Your configuration can select, ignore, and override those rules in the same way as built-in rules.
155
+
156
+ `linnl plugins` lists the rule packages enabled by the active configuration.
157
+
158
+ To author a package, declare an entry point that names a `linnl.plugins.Plugin` subclass:
159
+
160
+ ```toml
161
+ [project.entry-points."linnl.plugins"]
162
+ acme-rules = "linnl_acme_rules:AcmePlugin"
163
+ ```
164
+
165
+ ```python
166
+ from typing import ClassVar
167
+
168
+ from linnl.document import Document
169
+ from linnl.plugins import Plugin
170
+ from linnl.rules import CodeRule
171
+ from linnl.rules.pattern_rules import TextPatternRule
172
+ from linnl.violations import Violation, Violations
173
+
174
+
175
+ class Acme001(TextPatternRule, identifier="ACM001"):
176
+ """Flag obviously.
177
+
178
+ Example: "This is obviously correct."
179
+ """
180
+
181
+ pattern = r"\bobviously\b"
182
+
183
+
184
+ class Acme002(CodeRule, identifier="ACM002"):
185
+ """Flag lines longer than 80 characters.
186
+
187
+ Example: "This line has more than eighty characters and should be shortened before it is committed."
188
+ """
189
+
190
+ def __call__(self, document: Document) -> Violations:
191
+ violations = []
192
+
193
+ for line_number, line in enumerate(document.lines, start=1):
194
+ if len(line) > 80:
195
+ violations.append(
196
+ Violation(
197
+ rule=self,
198
+ path=document.path,
199
+ line=line_number,
200
+ offset=81,
201
+ quote=line,
202
+ )
203
+ )
204
+
205
+ return Violations(violations)
206
+
207
+
208
+ class AcmePlugin(Plugin):
209
+ """Define Acme's writing rules."""
210
+
211
+ name = "acme-rules"
212
+ rules: ClassVar = {
213
+ "ACM": {
214
+ "description": "Acme writing rules",
215
+ "001": "Avoid obviously.",
216
+ "002": "Keep lines at 80 characters or fewer.",
217
+ }
218
+ }
219
+ ```
220
+
221
+ The class's `name` must match the entry point name. `Acme001` registers itself when the package imports it. Each code rule needs a definition in `rules`.
222
+
223
+ ### Check configuration
224
+ `linnl rules` prints every rule with its resolved on/off state and whether Python or the model checks it.
225
+
226
+ ## Built-in rules
227
+
228
+ | Code | Default | Rule |
229
+ | --- | --- | --- |
230
+ | SCH001 | on | Tricolon. Three parallel words, phrases or clauses arranged as a series for rhythm or emphasis. Example: 'It is fast, small and simple.' A list of three things that happen to be three is fine, the arrangement for effect is not. |
231
+ | SCH002 | on | Isocolon. Two or more clauses of matching length and grammatical structure, set side by side for balance. Example: 'Simple to learn, hard to master.' |
232
+ | SCH003 | on | Anaphora. The same word or phrase opening successive clauses or sentences. Example: 'No config. No setup. No surprises.' |
233
+ | SCH004 | on | Antithesis. Contrasting ideas placed in parallel structure. Example: 'Not because it is easy, but because it is hard.' |
234
+ | SCH005 | on | Chiasmus. Words or structure repeated in reverse order across two clauses. Example: 'Ask not what your country can do for you, ask what you can do for your country.' |
235
+ | SCH006 | on | Asyndeton. Conjunctions dropped from a series of clauses or phrases to quicken the pace. Example: 'I came, I saw, I conquered.' |
236
+ | SCH007 | on | Alliterative pairing. Two or more nearby words chosen for a shared initial sound. Example: 'fast and fluid', 'bold and brave'. |
237
+ | SCH008 | on | Epigrammatic closer. A short, pithy, quotable sentence used to close a paragraph or the whole text. Example: ending a paragraph with 'Simple tools, simple problems.' |
238
+ | SCH000 | on | Other scheme. Any scheme not covered above: epistrophe, polysyndeton, climax, symploce, and the like. |
239
+ | SLO001 | on | Slogan. A sentence written to be quoted rather than to inform. Example: 'Ship less, sleep more.' |
240
+ | ZIN001 | on | Simplicity. The sentence is more complex than the idea it carries, through long-winded construction or jargon where a plain word exists. |
241
+ | ZIN002 | on | Brevity. Words that do no work: padding, redundant pairs, throat-clearing openers and restating what was already said. |
242
+ | ZIN003 | on | Clarity. The reader cannot tell what is meant: ambiguous pronouns, vague references, undefined terms, sentences that need a second reading. |
243
+ | ZIN004 | on | Humanity. The writing does not sound like one person talking to another: stiff, bureaucratic, impersonal, over-hedged, or passive voice hiding who did what. |
244
+ | CHR001 | on | Em dash (U+2014). |
245
+ | CHR002 | on | En dash (U+2013). |
246
+ | CHR003 | on | Middle dot (U+00B7). |
247
+ | CHR004 | on | Semicolon. |
248
+ | CHR000 | off | Any non-ASCII character not covered by another CHR rule. |
249
+ | LEN001 | off | The text has more than 3 sentences. |
250
+ | LEN002 | on | Not concise. The text includes material the reader did not ask for: justification, background, alternatives or caveats. |
251
+ | CLH001 | on | No X, no Y chains. |
252
+ | CLH002 | on | That's the whole point, game, or thing. |
253
+ | CLH003 | on | Did not X, did not Y chains. |
254
+ | CLH004 | on | Don't VERB it, VERB it. |
255
+ | CLH005 | on | Sit with that. |
256
+ | CLH006 | on | You already know. |
257
+ | CLH007 | on | Is the entire point, game, or business model. |
258
+ | CLH008 | on | The entire point, game, or business model is. |
259
+ | CLH009 | on | Is real and or not. |
260
+ | CLH010 | on | The punchline is. |
261
+ | CLH011 | on | Worth naming. |
262
+ | CLH012 | on | That's not nothing. |
263
+ | CLH013 | on | Is the whole point, trick, pitch, or idea. |
264
+ | CLH014 | on | Echoing sentence runs. |
265
+ | CLH015 | on | Performative honesty. |
266
+ | CLH016 | on | That's the part. |
267
+ | CLH017 | on | The only X I trust. |
268
+ | CLH018 | on | Don't take my word for it. |
269
+ | CLH019 | on | Turns out. |
270
+ | CLH020 | on | Fits in your head. |
271
+ | CLH021 | on | Stacked rhetorical questions. |
272
+ | CLH022 | on | Repeated sentence openers. |
273
+ | CLH023 | on | Colon into a triple. |
274
+ | CLH024 | on | Here's the twist. |
275
+ | CLH025 | on | X is dead. |
276
+ | CLH026 | on | That's why X mattered. |
277
+ | CLH027 | on | Stranded auxiliary contrast. |
278
+ | WIK001 | on | AI vocabulary words. |
279
+ | WIK002 | on | Not just X, but Y. |
280
+ | WIK003 | on | It's important to note. |
281
+ | WIK004 | on | Stands as a testament. |
282
+ | WIK005 | on | Plays a crucial role. |
283
+ | WIK006 | on | Ever-evolving landscape. |
284
+ | WIK007 | on | Experts argue. |
285
+ | WIK008 | on | Despite these challenges. |
286
+ | WIK009 | on | Participle sentence tails. |
287
+ | WIK010 | on | Promotional boilerplate. |
288
+ | WIK011 | on | Chatbot leftovers. |
289
+
290
+ ## Develop
291
+
292
+ ```sh
293
+ uv sync --group dev
294
+ uv run pytest
295
+ uv run ty check linnl
296
+ uv run ruff check
297
+ uv run ruff format
298
+ uv tool install --editable .
299
+ ```
300
+
301
+ ## Acknowledgments
302
+
303
+ The CLH and WIK rules are adapted from [Simon Willison's LLM cliche highlighter](https://github.com/simonw/tools/blob/main/llm-cliche-highlighter.html).
304
+
305
+ For a broader collection of deterministic prose checks, see [Proselint](https://github.com/amperser/proselint).
linnl-0.1.0/README.md ADDED
@@ -0,0 +1,275 @@
1
+ # linnl
2
+ `linnl` is a text linter aimed to catch LLM tells and reduce the complexity of the text produced by them, so that the cognitive load on the reader is reduced and ideas are clearer.
3
+
4
+ Some built-in rules are checked in code, others (most) are judged by an LLM. You can also install `linnl` plugins to provide your own set of rules.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ uv tool install linnl
10
+ ```
11
+
12
+ ### Claude as a judge
13
+ You will either need a working `claude` login or an `ANTHROPIC_API_KEY` set in your environment.
14
+
15
+ ### Codex as a judge
16
+ Codex reuses the local Codex session. You can also set `OPENAI_API_KEY`.
17
+
18
+ ## Use
19
+
20
+ ```sh
21
+ linnl lint notes.md
22
+ linnl lint docs/
23
+ echo "text" | linnl lint
24
+ ```
25
+
26
+ Example output:
27
+
28
+ ```
29
+ notes.md
30
+ =========
31
+ line 3:
32
+ > No config. No cron. No surprises.
33
+ SCH003 Three successive fragments open with the same word for emphasis, which is anaphora.
34
+
35
+ line 5:
36
+ > Set it up once; forget about it.
37
+ CHR004 Semicolon.
38
+
39
+ Found 2 violations across 1 file.
40
+ ```
41
+
42
+ ## Configure
43
+ ### CLI
44
+
45
+ Check
46
+ ```sh
47
+ linnl lint --help
48
+ ```
49
+
50
+ To see available quick configuration options.
51
+
52
+ ### File configuration
53
+ The priority is:
54
+ - The closest `pyproject.toml` with a tool.linnl section
55
+ - A `linnl.toml` file next to the closest pyproject.toml
56
+ - `~/.config/linnl/config.toml`
57
+ - The config file shipped with the tool
58
+
59
+ You can easily create a `linnl.toml` with:
60
+ ```sh
61
+ linnl config > linnl.toml
62
+ ```
63
+
64
+ #### Adding your own rules
65
+
66
+ Add groups under `[rules.<PREFIX>]` in your config file. `description` is the only reserved key. Every other key is a rule:
67
+
68
+ ```toml
69
+ extend-select = ["SEC"]
70
+
71
+ [rules.SEC]
72
+ description = "Wording that must not leak infrastructure details"
73
+ 001 = "Names an internal host, IP or path"
74
+ abc = "Shows a credential or token in an example"
75
+ ```
76
+
77
+ That defines `SEC001` and `SECabc`, judged by the model. `linnl` shows the group description to the model above its rules, so make it say what the group is for.
78
+
79
+ The reserved `RGX` group defines regular expression rules. Every value under
80
+ `[rules.RGX]` is compiled as a Python regular expression and is enabled by
81
+ default:
82
+
83
+ ```toml
84
+ [rules.RGX]
85
+ description = "Patterns to avoid"
86
+ 001 = "X is not (.*), it's (.*)"
87
+ ```
88
+
89
+ Each non-overlapping match is reported separately, with the full match as the
90
+ quoted text. Invalid regular expressions fail configuration.
91
+
92
+ If the rule requires configurable parameters:
93
+
94
+ ```toml
95
+ extend-select = ["SEC"]
96
+
97
+ [rules.SEC]
98
+ description = "Wording that must not leak infrastructure details"
99
+ 001 = "Names an internal host, IP or path"
100
+ abc = "Shows a credential or token in an example"
101
+
102
+ [rules.SEC.xyz]
103
+ description = "Do now show more {n_files} files in the current directory"
104
+ n-files = 3
105
+ ```
106
+
107
+ A description can name the rule's options in braces. Write an option name with underscores, so `max-sentences` becomes `{max_sentences}` and `"More than {max_sentences} sentences."` renders with the configured value. Options work on Python and model rules. linnl renders their values before sending model rules to the judge.
108
+
109
+ #### Installing rules plugins
110
+
111
+ Install them alongside `linnl`:
112
+
113
+ ```sh
114
+ uv tool install linnl --with linnl-acme-rules
115
+ ```
116
+
117
+ Then enable their `linnl.plugins` entry-point names.
118
+
119
+ ```toml
120
+ plugins = ["acme-rules"]
121
+ extend-select = ["ACM"]
122
+ ```
123
+
124
+ The package contributes default sections under `[rules]`. Your configuration can select, ignore, and override those rules in the same way as built-in rules.
125
+
126
+ `linnl plugins` lists the rule packages enabled by the active configuration.
127
+
128
+ To author a package, declare an entry point that names a `linnl.plugins.Plugin` subclass:
129
+
130
+ ```toml
131
+ [project.entry-points."linnl.plugins"]
132
+ acme-rules = "linnl_acme_rules:AcmePlugin"
133
+ ```
134
+
135
+ ```python
136
+ from typing import ClassVar
137
+
138
+ from linnl.document import Document
139
+ from linnl.plugins import Plugin
140
+ from linnl.rules import CodeRule
141
+ from linnl.rules.pattern_rules import TextPatternRule
142
+ from linnl.violations import Violation, Violations
143
+
144
+
145
+ class Acme001(TextPatternRule, identifier="ACM001"):
146
+ """Flag obviously.
147
+
148
+ Example: "This is obviously correct."
149
+ """
150
+
151
+ pattern = r"\bobviously\b"
152
+
153
+
154
+ class Acme002(CodeRule, identifier="ACM002"):
155
+ """Flag lines longer than 80 characters.
156
+
157
+ Example: "This line has more than eighty characters and should be shortened before it is committed."
158
+ """
159
+
160
+ def __call__(self, document: Document) -> Violations:
161
+ violations = []
162
+
163
+ for line_number, line in enumerate(document.lines, start=1):
164
+ if len(line) > 80:
165
+ violations.append(
166
+ Violation(
167
+ rule=self,
168
+ path=document.path,
169
+ line=line_number,
170
+ offset=81,
171
+ quote=line,
172
+ )
173
+ )
174
+
175
+ return Violations(violations)
176
+
177
+
178
+ class AcmePlugin(Plugin):
179
+ """Define Acme's writing rules."""
180
+
181
+ name = "acme-rules"
182
+ rules: ClassVar = {
183
+ "ACM": {
184
+ "description": "Acme writing rules",
185
+ "001": "Avoid obviously.",
186
+ "002": "Keep lines at 80 characters or fewer.",
187
+ }
188
+ }
189
+ ```
190
+
191
+ The class's `name` must match the entry point name. `Acme001` registers itself when the package imports it. Each code rule needs a definition in `rules`.
192
+
193
+ ### Check configuration
194
+ `linnl rules` prints every rule with its resolved on/off state and whether Python or the model checks it.
195
+
196
+ ## Built-in rules
197
+
198
+ | Code | Default | Rule |
199
+ | --- | --- | --- |
200
+ | SCH001 | on | Tricolon. Three parallel words, phrases or clauses arranged as a series for rhythm or emphasis. Example: 'It is fast, small and simple.' A list of three things that happen to be three is fine, the arrangement for effect is not. |
201
+ | SCH002 | on | Isocolon. Two or more clauses of matching length and grammatical structure, set side by side for balance. Example: 'Simple to learn, hard to master.' |
202
+ | SCH003 | on | Anaphora. The same word or phrase opening successive clauses or sentences. Example: 'No config. No setup. No surprises.' |
203
+ | SCH004 | on | Antithesis. Contrasting ideas placed in parallel structure. Example: 'Not because it is easy, but because it is hard.' |
204
+ | SCH005 | on | Chiasmus. Words or structure repeated in reverse order across two clauses. Example: 'Ask not what your country can do for you, ask what you can do for your country.' |
205
+ | SCH006 | on | Asyndeton. Conjunctions dropped from a series of clauses or phrases to quicken the pace. Example: 'I came, I saw, I conquered.' |
206
+ | SCH007 | on | Alliterative pairing. Two or more nearby words chosen for a shared initial sound. Example: 'fast and fluid', 'bold and brave'. |
207
+ | SCH008 | on | Epigrammatic closer. A short, pithy, quotable sentence used to close a paragraph or the whole text. Example: ending a paragraph with 'Simple tools, simple problems.' |
208
+ | SCH000 | on | Other scheme. Any scheme not covered above: epistrophe, polysyndeton, climax, symploce, and the like. |
209
+ | SLO001 | on | Slogan. A sentence written to be quoted rather than to inform. Example: 'Ship less, sleep more.' |
210
+ | ZIN001 | on | Simplicity. The sentence is more complex than the idea it carries, through long-winded construction or jargon where a plain word exists. |
211
+ | ZIN002 | on | Brevity. Words that do no work: padding, redundant pairs, throat-clearing openers and restating what was already said. |
212
+ | ZIN003 | on | Clarity. The reader cannot tell what is meant: ambiguous pronouns, vague references, undefined terms, sentences that need a second reading. |
213
+ | ZIN004 | on | Humanity. The writing does not sound like one person talking to another: stiff, bureaucratic, impersonal, over-hedged, or passive voice hiding who did what. |
214
+ | CHR001 | on | Em dash (U+2014). |
215
+ | CHR002 | on | En dash (U+2013). |
216
+ | CHR003 | on | Middle dot (U+00B7). |
217
+ | CHR004 | on | Semicolon. |
218
+ | CHR000 | off | Any non-ASCII character not covered by another CHR rule. |
219
+ | LEN001 | off | The text has more than 3 sentences. |
220
+ | LEN002 | on | Not concise. The text includes material the reader did not ask for: justification, background, alternatives or caveats. |
221
+ | CLH001 | on | No X, no Y chains. |
222
+ | CLH002 | on | That's the whole point, game, or thing. |
223
+ | CLH003 | on | Did not X, did not Y chains. |
224
+ | CLH004 | on | Don't VERB it, VERB it. |
225
+ | CLH005 | on | Sit with that. |
226
+ | CLH006 | on | You already know. |
227
+ | CLH007 | on | Is the entire point, game, or business model. |
228
+ | CLH008 | on | The entire point, game, or business model is. |
229
+ | CLH009 | on | Is real and or not. |
230
+ | CLH010 | on | The punchline is. |
231
+ | CLH011 | on | Worth naming. |
232
+ | CLH012 | on | That's not nothing. |
233
+ | CLH013 | on | Is the whole point, trick, pitch, or idea. |
234
+ | CLH014 | on | Echoing sentence runs. |
235
+ | CLH015 | on | Performative honesty. |
236
+ | CLH016 | on | That's the part. |
237
+ | CLH017 | on | The only X I trust. |
238
+ | CLH018 | on | Don't take my word for it. |
239
+ | CLH019 | on | Turns out. |
240
+ | CLH020 | on | Fits in your head. |
241
+ | CLH021 | on | Stacked rhetorical questions. |
242
+ | CLH022 | on | Repeated sentence openers. |
243
+ | CLH023 | on | Colon into a triple. |
244
+ | CLH024 | on | Here's the twist. |
245
+ | CLH025 | on | X is dead. |
246
+ | CLH026 | on | That's why X mattered. |
247
+ | CLH027 | on | Stranded auxiliary contrast. |
248
+ | WIK001 | on | AI vocabulary words. |
249
+ | WIK002 | on | Not just X, but Y. |
250
+ | WIK003 | on | It's important to note. |
251
+ | WIK004 | on | Stands as a testament. |
252
+ | WIK005 | on | Plays a crucial role. |
253
+ | WIK006 | on | Ever-evolving landscape. |
254
+ | WIK007 | on | Experts argue. |
255
+ | WIK008 | on | Despite these challenges. |
256
+ | WIK009 | on | Participle sentence tails. |
257
+ | WIK010 | on | Promotional boilerplate. |
258
+ | WIK011 | on | Chatbot leftovers. |
259
+
260
+ ## Develop
261
+
262
+ ```sh
263
+ uv sync --group dev
264
+ uv run pytest
265
+ uv run ty check linnl
266
+ uv run ruff check
267
+ uv run ruff format
268
+ uv tool install --editable .
269
+ ```
270
+
271
+ ## Acknowledgments
272
+
273
+ The CLH and WIK rules are adapted from [Simon Willison's LLM cliche highlighter](https://github.com/simonw/tools/blob/main/llm-cliche-highlighter.html).
274
+
275
+ For a broader collection of deterministic prose checks, see [Proselint](https://github.com/amperser/proselint).
@@ -0,0 +1 @@
1
+ """Lint prose against style rules, with an LLM judging."""
@@ -0,0 +1,60 @@
1
+ """Install linnl commands for supported coding agents."""
2
+
3
+ import logging
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class Agent(StrEnum):
11
+ """Select the model backend for model-judged rules."""
12
+
13
+ CLAUDE = "claude"
14
+ CODEX = "codex"
15
+
16
+
17
+ DEFAULT_AGENT_MODELS: dict[Agent, str] = {
18
+ Agent.CLAUDE: "claude-opus-5",
19
+ Agent.CODEX: "gpt-5.6-luna",
20
+ }
21
+
22
+
23
+ LINNL_COMMAND = """---
24
+ description: Check your most recent response with linnl.
25
+ ---
26
+
27
+ Check your most recent assistant response with linnl.
28
+
29
+ Extract only the user-facing text from your immediately preceding response.
30
+ Do not include tool calls, this command, or your current reasoning. Pipe that
31
+ text to `linnl lint` through standard input and report the result.
32
+
33
+ If linnl reports violations, explain them and provide a corrected version. If it
34
+ reports no violations, say that the response is clean.
35
+ """
36
+
37
+ AGENT_COMMAND_DIRECTORIES: dict[Agent, tuple[str, str]] = {
38
+ Agent.CLAUDE: (".claude", "commands"),
39
+ Agent.CODEX: (".codex", "prompts"),
40
+ }
41
+
42
+
43
+ def install_hook(agent: Agent, *, local: bool) -> Path:
44
+ """Install the linnl command for an agent and return its path."""
45
+ agent_directory, command_directory = AGENT_COMMAND_DIRECTORIES[agent]
46
+ base_directory = Path.cwd() if local else Path.home()
47
+ command_path = base_directory / agent_directory / command_directory / "linnl.md"
48
+ command_path.parent.mkdir(parents=True, exist_ok=True)
49
+
50
+ if command_path.exists():
51
+ existing_command = command_path.read_text(encoding="utf-8")
52
+ if existing_command != LINNL_COMMAND:
53
+ logger.info("linnl command out of date, will update at %s", command_path)
54
+ else:
55
+ logger.info("linnl command already installed at %s", command_path)
56
+ return command_path
57
+
58
+ command_path.write_text(LINNL_COMMAND, encoding="utf-8")
59
+ logger.info("Installed linnl command at %s", command_path)
60
+ return command_path