@tduyng/pi-timbre 0.0.0-stage → 0.0.2

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.
package/src/README.md ADDED
@@ -0,0 +1,385 @@
1
+ <!-- lint-disable -- this file quotes the tells it documents -->
2
+
3
+ # timbre
4
+
5
+ Keeps the agent's prose and code sounding like this project, by constraining it before the
6
+ tokens are generated rather than cleaning up after.
7
+
8
+ Timbre is the acoustic property that identifies a specific voice regardless of pitch or
9
+ volume. That is the target here: not "generic human prose" but prose that still sounds like
10
+ the person whose repository it lands in.
11
+
12
+ ## Why this is not another slop linter
13
+
14
+ Every comparable tool works after generation.
15
+
16
+ | Tool | Detection | Correction | Cost per reply |
17
+ | ------------------- | ---------------------------- | -------------------------------- | ----------------------- |
18
+ | SLYE | none, 200-char gate | secondary LLM rewrite | 1 API call |
19
+ | claudish-to-english | none, 200-char gate | local ollama rewrite | 1 local call |
20
+ | nopus | statistical, corpus-backed | asks the agent to redo the reply | 1 redo |
21
+ | humanizer | none, on demand | the agent, against 50+ patterns | free |
22
+ | timbre | deterministic, then measured | prevention, before generation | one cached prompt block |
23
+
24
+ Pi's `before_agent_start` can return a system prompt for the turn. That is the one hook that
25
+ makes prevention possible, and it is why this extension is Pi-only. Nothing else in the
26
+ comparison table can reach the other side of generation.
27
+
28
+ For Pi, timbre replaces the `prose` skill outright. It carries the full
29
+ rule set: the constraints in the system prompt, the judgment layer behind a tool. Those two
30
+ skills now live only under Claude Code, which has its own skill loader for them.
31
+
32
+ ## The loop
33
+
34
+ ```
35
+ before_agent_start -> inject the whole constraint set, plus what this project repeats
36
+ timbre_review_guide -> serve the judgment procedure when the agent asks for it
37
+ tool_call -> block writes carrying an undeniable tell before they land
38
+ message_end -> measure the reply, silently, record what still got through
39
+ tool_result -> report tells in prose and source files just written
40
+ /timbre fix -> scan prose already on disk and make the agent rewrite it
41
+ memory -> feeds the next before_agent_start
42
+ ```
43
+
44
+ Detection is not a separate product here. It is the input to prevention and the instrument
45
+ that says whether prevention works.
46
+
47
+ ## Layers
48
+
49
+ | Layer | Detects | Cost |
50
+ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
51
+ | 0. Prevention | Nothing. Constrains the generation instead. | one cached system-prompt block |
52
+ | 0.5 Preflight | Blocks the tells with no legitimate use and no false positive after masking: tool leakage, invisible Unicode, em and en dashes, curly quotes, a clause-joining semicolon. Everything else is note-only, placeholders included, since templates legitimately contain them. After three consecutive blocks it stands down rather than loop with a stubborn retry | free |
53
+ | 1. Forensic | Tool leakage: `utm_source=chatgpt.com`, `citeturn0search0`, zero-width characters, unfilled placeholders | free |
54
+ | 1. Mechanical | Fixed tells: dashes, semicolons, tier-1 vocabulary, stock phrases, Claude's own tics | free |
55
+ | 2. Statistical | Cadence, clause stacks, and paragraph shape: the signals regex cannot see | free |
56
+ | 3. Judgment | Triage, the pattern catalog, false positives, human signals worth keeping | one tool call, only when reviewing |
57
+
58
+ There is no corpus. nopus reaches layer 2 with 1.8 MB of frequency and concreteness data,
59
+ which overlaps heavily with the tier-1 vocabulary already matched exactly. Cadence variance,
60
+ clause stacks, repeated openings, and paragraph shape need no data at all, so they are the
61
+ signals implemented.
62
+
63
+ Tier 2 words (`robust`, `seamless`, `pivotal`, `harness`) are not flagged one by one.
64
+ They are ordinary English alone and a tell only in clusters, so timbre counts them per
65
+ paragraph and flags a paragraph that uses several at once. That catches the cluster without
66
+ the single-use false positives that would discredit the report.
67
+
68
+ ## The Pangram method
69
+
70
+ Detection and rewrite rules draw from the Pangram 4 technical report (Glickenhaus et al.,
71
+ 2026): clause-level provenance, contiguous span aggregation, and the rule that reordering
72
+ your own sentences never changes authorship but rewording toward model phrasing does.
73
+
74
+ ## Register
75
+
76
+ A commit message, an API reference and a blog post do not share a correct voice. Terse and
77
+ list-heavy is right in a changelog and wrong in an essay, so the rhythm signal has to mean
78
+ different things in each. Path classifies it, with no model call:
79
+
80
+ | Path | Register | Terse is correct | Voice allowed |
81
+ | ------------------------------------ | --------- | ---------------- | ------------- |
82
+ | `docs/`, `README.md`, `adr/`, `api/` | reference | yes | no |
83
+ | `CHANGELOG.md`, `COMMIT_EDITMSG` | changelog | yes | no |
84
+ | `blog/`, `posts/`, `essays/` | narrative | no | yes |
85
+ | anything else | unknown | no | no |
86
+
87
+ ## Commands
88
+
89
+ A status indicator in the bar shows the live mode (`timbre: on`, `timbre: off`).
90
+
91
+ | Command | Effect |
92
+ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
93
+ | `/timbre` | Tell density with prevention on versus off, the most frequent tells, and where each setting comes from |
94
+ | `/timbre fix <path>... [instructions]` | Scan prose already on disk and make the agent rewrite it. The way back into a document layers 0 and 1 never saw |
95
+ | `/timbre off` | Stop injecting. Samples still record, which is how a baseline gets built |
96
+ | `/timbre on` | Resume injecting |
97
+ | `/timbre reset` | Discard this project's samples, live overrides included, back to the env defaults |
98
+ | `ctrl+alt+t` | Toggle prevention with the same persistence path as `/timbre on` and `/timbre off` |
99
+
100
+ `/timbre fix` is the repair path for text that already landed. Layers 0 and 1 both act at
101
+ generation time, so a document written in an earlier session, by another tool, or by a model
102
+ that ignored the rules is out of their reach entirely. `fix` scans the named files off disk,
103
+ groups what it finds by tell id with the line numbers, and sends the agent a real user
104
+ message that starts a turn. It sends nothing when a file scans clean, and it skips a file
105
+ carrying a `<!-- lint-disable -- <reason> -->` marker, the same escape hatch the write path honours.
106
+
107
+ A word names a file when it starts with the `@` file completion inserts, carries a glob
108
+ character, ends in a prose suffix, or is path-shaped and exists on disk. Everything else is
109
+ instruction text, kept verbatim with its newlines, and it rides along in the rewrite request.
110
+ That last clause is what keeps a pasted paragraph mentioning `JSON.parse` or `/someEndpoint`
111
+ from being read as two files nobody asked for.
112
+
113
+ Globs expand here, since a slash command is not a shell and nothing expanded them on the way
114
+ in. A pattern without a directory in it searches the whole project, so `/timbre fix *.md drop
115
+ the ai slop` reaches `docs/deep.md`. One with a directory is taken literally. `node_modules`,
116
+ `.git` and the usual build directories are never walked. One request carries at most ten
117
+ files and says how many it left behind.
118
+
119
+ Naming no file at all is allowed. `/timbre fix that readme and shorten it` refers to a file
120
+ the way a person does, and resolving that needs the conversation, which the command handler
121
+ cannot see and the agent already has. So the sentence is forwarded intact, with the review
122
+ procedure attached, and the agent finds the file. Nothing is scanned on that path: no line
123
+ numbers up front, and the agent runs the scan itself once it knows what it is looking at.
124
+
125
+ An instruction also overrides a clean scan. `/timbre fix notes.md make it shorter` still
126
+ sends the rewrite when the file carries no mechanical tell, because "are there tells" and
127
+ "is this any good" are different questions and the second one was asked.
128
+
129
+ The check on the rewrite needs no extra machinery. The agent's edits go back out through
130
+ `write` and `edit`, where the preflight blocks a reintroduced em dash, curly quote or
131
+ semicolon outright and the tool result reports whatever else survived. An on-request scan
132
+ records no sample: a sample answers whether prevention changed what a model generated on
133
+ some turn, and a file read off disk belongs to no turn and neither prevention arm, so
134
+ counting it would corrupt the only measurement `/timbre` exists to produce.
135
+
136
+ `on` and `off` switch live, take effect the next turn, and persist
137
+ to the project state file: a one-off `/timbre off` for this session is also what the next,
138
+ unrelated session in this project starts from, not something scoped back down when the
139
+ session ends. Each switch says so (`project default until changed again`), and each also
140
+ changes the system prompt, so it says the next turn re-primes the cached prefix rather than
141
+ reusing this turn's cache read. `/timbre reset` is the way back to `TIMBRE_PREVENT` and
142
+ `TIMBRE_PREVENT`'s env value. `ctrl+alt+t` toggles prevention with the
143
+ same persistence path as `/timbre on|off`, for flipping mid-session without leaving the editor.
144
+
145
+ Persisting to the file rather than keeping the switch session-scoped is what lets it reach a
146
+ subagent: a subagent runs as its own `pi` process against the same project, sharing no memory
147
+ with the session that spawned it, so the state file is the only channel `/timbre off` has to
148
+ reach it. `TIMBRE_PREVENT` set in the environment reaches subagents too, since they inherit
149
+ it. Only the live toggle needed the file.
150
+
151
+ A persisted toggle outlives the session that set it, so a new session announces any still-active override at startup instead of applying it silently, and `/timbre` names each setting's source (env or the state file) and flags carry-overs. The announcement names only overrides that change behaviour against the env default: a `/timbre off` on a session whose `TIMBRE_PREVENT` is already `0` is not news.
152
+
153
+ ## Does prevention work
154
+
155
+ That is an empirical question, answered by `/timbre` rather than argued about. Every sample
156
+ records whether layer 0 was active, so the readout compares this project's own
157
+ prevention-on and prevention-off history directly. Until both arms have samples the effect
158
+ reads `null`:
159
+
160
+ ```
161
+ prevention off: no samples yet (run /timbre off to build a baseline)
162
+ prevention on: <density> tells/1k chars over <n> samples
163
+
164
+ Effect: null (need samples from both arms)
165
+ ```
166
+
167
+ To get a real number, run `/timbre off` for a stretch, then `/timbre on`. Once both arms have
168
+ samples the readout prints a measured delta:
169
+
170
+ ```
171
+ prevention off: <density> tells/1k chars over <n> samples
172
+ prevention on: <density> tells/1k chars over <m> samples
173
+
174
+ Effect: <Z>% fewer tells with prevention on.
175
+ ```
176
+
177
+ `<Z>` is filled from real samples, never estimated. No project has a two-sided sample set
178
+ yet, so no percentage has been measured or quoted as timbre's actual effect.
179
+
180
+ ## Configuration
181
+
182
+ Environment only.
183
+
184
+ | Variable | Default | Meaning |
185
+ | ------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
186
+ | `TIMBRE` | `1` | Master switch |
187
+ | `TIMBRE_PREVENT` | `1` | Layer 0. `0` measures without injecting |
188
+ | `TIMBRE_WATCH_CHAT` | `1` | Measure assistant replies, silently |
189
+
190
+ Set `PI_CACHE_RETENTION=long` in your environment. Pi's default cache TTL is 5 minutes, so
191
+ any pause longer than that makes the next turn re-write the whole cached prefix instead of
192
+ reading it, at 12x the cost. A 1-hour TTL is what makes the numbers below hold in practice.
193
+
194
+ ## What layer 0 costs
195
+
196
+ The block sits in the system prompt, so it is sent on every turn of every session. It is built whenever prevention is on: a rule the model only meets after the write is a rule it already broke. Gating the block on the read also had a failure mode, since a refused connection and a model with nothing to say look the same from the outside, and the rules ended up nowhere with nothing reporting it. Pi marks the block cacheable, and Anthropic bills cache writes at 1.25x and reads at 0.1x:
197
+
198
+ | | tokens |
199
+ | ---------------------------------------- | --------------------------------------------- |
200
+ | The catalog | 986 |
201
+ | Per turn, cache warm | 99 |
202
+ | Per turn, cache cold | 1,085 |
203
+ | `review.md` | 4,586, and only when the agent calls the tool |
204
+
205
+ A 30-turn session costs about 5k tokens with a 1-hour cache. The same session costs 47k if
206
+ every single turn goes cold, which is what a 5-minute TTL plus normal think time produces.
207
+
208
+ That table is a computed estimate, not a measurement. `/timbre` now reports the average
209
+ `cacheRead`/`cacheWrite` Anthropic itself billed per turn, straight from each reply's own
210
+ `usage`, so the estimate can be checked against a real session rather than trusted on its own:
211
+
212
+ ```
213
+ cache (prevention on): avg 118 read / 3 write tokens/turn over 12 turns
214
+ ```
215
+
216
+ A high write average relative to read means the prefix is not actually staying cached turn to
217
+ turn, the one failure that would silently defeat the whole design, and `/timbre` says so.
218
+
219
+ The catalog states the rules nothing else can enforce and omits the ones the scanner
220
+ catches for free: paying every turn to restate a rule the tool result already names by line
221
+ buys the same enforcement twice. `TIMBRE_PREVENT=0` drops the cost to zero and leaves
222
+ detection and measurement running, which is the cheapest way to build a baseline before
223
+ deciding whether prevention pays for itself.
224
+
225
+ ## Design constraints worth keeping
226
+
227
+ **Every rule is enforced, not every rule is injected.** Coverage is a property of the whole
228
+ system, not of the prompt. The tool result catches 22 prose tells, 18 TypeScript rules and the statistical
229
+ tells for free,
230
+ so those do not need prompt tokens to be enforced. What only prevention can reach is the rest:
231
+ false agency, naming the real thing, fabrication, dropped caveats, register, discourse shape,
232
+ and the code smells no linter sees. `catalog.md` states exactly that set, plus the handful of
233
+ mechanical tells whose base rate is high enough that catching them after the fact would mean
234
+ constant rewrite churn.
235
+
236
+ **The block is static.** An earlier version varied per turn, which was the worst possible
237
+ property for the one string that sits in front of the entire conversation.
238
+
239
+ The block is computed once per project and then frozen for the session. Recomputing it per
240
+ turn would be ruinous: memory changes on every reply and every file write, and the
241
+ system prompt sits in front of the whole conversation, so one shifted word there invalidates
242
+ the entire cached prefix. It still adapts across sessions, which is the timescale a
243
+ writing habit actually moves on.
244
+
245
+ **Constraints are injected, procedures are served.** A constraint ("never an em dash") is
246
+ short and useless after the fact, so it belongs in the prompt before generation. A procedure
247
+ for judging and repairing existing text is long and irrelevant on any turn that is not doing
248
+ that, so it sits behind the `timbre_review_guide` tool. That split is what lets timbre carry
249
+ the whole of `prose` and `anti-slop` without paying for the whole of it every turn.
250
+
251
+ **Every tell is stated once in `patterns.ts`.** `guidance/catalog.md` is the only place a
252
+ constraint's prose lives. `patterns.ts` carries detection regexes and nothing else. An earlier
253
+ version paired each pattern with its own copy of the rule text, which made two sources of
254
+ truth for the same sentence and a standing invitation to drift.
255
+
256
+ **One catalog, chosen by nothing.** A regex over the prompt text used to pick between rule
257
+ sets, scoring 0.375 recall at 0.250 precision over 17 recorded sessions, and the switch cost
258
+ a live subcommand, a persisted setting, a compaction latch and a second cache signature.
259
+ `catalog.md` is now injected on every turn, sent with the rewrite request and read by System
260
+ One.
261
+
262
+ Every section it states is a rule no detector checks: the actor, the real thing, the claim,
263
+ the voice, the register, the code comment. Every section it omits has a detector behind it.
264
+ Framing tells maps to six patterns, Structures to avoid to seven, Shape's cadence to the
265
+ Tier-2 statistics, Conversation to `chat-artifact`, the TypeScript rules to the vendored
266
+ word lists to `ai-vocabulary`. Two exceptions moved rather than
267
+ vanished: the typographic ellipsis, which no pattern matches, sits in Never, and the line
268
+ about justifying a type assertion with a type rather than a comment is absent, since
269
+ `require-safety-comment-for-type-assertion` is enforced and the catalog would have been
270
+ arguing with it.
271
+
272
+ `review.md` stays separate. It is a procedure rather than a rule set, so it is served on
273
+ demand.
274
+
275
+ **Everything fails open, but a degraded state is never silent.** A broken scan, an unwritable
276
+ state file or malformed memory leaves the turn exactly as Pi would have run it. File findings
277
+ are appended to a successful tool result rather than raised as an error: the write did happen,
278
+ and a false error sends the agent rewriting a file that is already correct on disk. Failing
279
+ open is not the same as failing invisibly: a missing `guidance/` directory (a packaging slip)
280
+ or an unreadable catalog both degrade a whole layer to a no-op, and `/timbre`
281
+ names both rather than reporting "prevention on" over an empty block.
282
+
283
+ **A hit is evidence, not a verdict.** Backtick spans, fenced and indented code, URLs and
284
+ link targets are masked, so a doc naming a tell is not committing it. Markdown blockquotes
285
+ and double-quoted phrases are **not** masked and will be flagged: the escape hatch there is
286
+ `<!-- lint-disable -- <reason> -->` in the first 400 bytes, which is how this README avoids flagging itself.
287
+ The reason must run at least 8 characters, and a bare marker is not honoured, so an opt-out
288
+ always says why.
289
+
290
+ **Spans over scattered hits.** Fifteen em dashes and a handful of throat-clearing phrases
291
+ need the agent to know which line ranges are machine-shaped. `segments.ts` merges adjacent
292
+ flagged lines into contiguous spans and computes the flagged character fraction, so
293
+ `/timbre fix` can say "lines 40-58 are machine-shaped, rewrite from your outline" rather
294
+ than deliver a scattered list.
295
+
296
+ **A weak model argues with a note, so the typography tells block instead.** Layers 0 and 1
297
+ both route through the model's cooperation: the prompt asks, the report tells. A
298
+ `nemotron-3.5-lightning-free` session put twenty em dashes in one document with the rule in
299
+ its system prompt, read the report naming every line, and concluded the detector was wrong.
300
+ A block is the one enforcement that does not need the model to agree, so the tells that
301
+ cannot be a false positive after masking take that path. The rest stay notes, because a
302
+ wrongly blocked write costs a full retry round trip.
303
+
304
+ **The block groups by id, not by hit.** Seven em dashes used to print seven identical hint
305
+ lines and no location, which reads as noise and leaves the agent guessing which spans to
306
+ rewrite. The message now names the path, one line per id with every line number it fired on,
307
+ and the first two offending lines quoted. An edit's numbers count the hunk text rather than
308
+ the file, and the message says so, since `edits[].newText` is all the preflight sees.
309
+
310
+ **A write scans the whole file. An edit scans only its hunks.** Both come straight from the
311
+ tool call, never a disk read. `write` always replaces a file wholesale, so the whole new file
312
+ is the agent's own text. `edit` carries `edits[].newText`, so a three-line change to a long
313
+ document is judged on those three lines, not on every tell already sitting in the rest of the
314
+ file. That also keeps a page under active edit from dominating the sample window with
315
+ near-duplicate reads of its own unchanged bulk.
316
+
317
+ ## State
318
+
319
+ One JSON file per project under `$XDG_STATE_HOME/timbre/`, keyed by a hash of the project
320
+ path. Writes are atomic (temp file, then rename) so a crash mid-write cannot leave a
321
+ truncated file that breaks the next session. Nothing leaves the machine. A prose write is
322
+ noticed once per file per session, keyed by session and path, so a rewrite cannot spend a
323
+ second notice on the same file. `/timbre` counts what was noticed and what was held back.
324
+
325
+ ## Known limits
326
+
327
+ **Thesis still unverified by field data.** `index.test.ts` fires the real `before_agent_start`,
328
+ `message_end`, and `tool_result` handlers the way Pi would, so the wiring is verified. The thesis, that preventing before generation reduces tells, is not
329
+ verified: a project that has only ever run with prevention on has no baseline, so the effect
330
+ reads `null`. A measured effect requires running `/timbre off` for a stretch first.
331
+
332
+ **Calibration.** The burstiness threshold (CoV below 0.35 over 6+ sentences) is a starting
333
+ value, not a derived one. nopus published intervention rates from 5,337 measured responses.
334
+ Nothing here has that backing yet, which is precisely what `/timbre` exists to produce.
335
+
336
+ **Measurement caveats that survive.** Hits are counted once per pattern per line, so a hard
337
+ wrapped paragraph scores higher than the same text unwrapped. Chat replies are never
338
+ wrapped and file prose usually is, so the two are not perfectly comparable even within the
339
+ prose domain. Concurrent sessions merge on write rather than lock, so a simultaneous write
340
+ can still drop one sample.
341
+
342
+ **Pattern rot.** Every pattern is calibrated against current models. Wikipedia's own guide
343
+ already notes some signs are outdated for recent releases. Patterns live in `patterns.ts` as
344
+ data for that reason.
345
+
346
+ **Corpus contamination, if voice learning is ever added.** Learning an author's voice from a
347
+ git history that already contains model-written commits teaches the system to reproduce slop
348
+ as "their voice". A clean corpus would mean pre-2023 commits or explicitly human-verified
349
+ text. Getting this wrong inverts the product silently, so it is not implemented on a guess.
350
+
351
+ **English only.** The pattern list is English. A French or Vietnamese repository gets layer 0
352
+ and the forensic tier, and little else of value.
353
+
354
+ ## Files
355
+
356
+ | File | Contents |
357
+ | ------------------------ | ---------------------------------------------------------------------- |
358
+ | `guidance/catalog.md` | The rules nothing else can enforce. Injected every turn |
359
+ | `guidance/review.md` | The judgment layer: triage, catalog, false positives, de-slop workflow |
360
+ | `rules.ts` | Loads and caches the catalog and the review guide |
361
+ | `guide.ts` | Serves `guidance/review.md` through the `timbre_review_guide` tool |
362
+ | `patterns.ts` | Every mechanically detectable tell, as data. Detection only |
363
+ | `scan.ts` | Layer 1 prose. Pure functions, no I/O |
364
+ | `stats.ts` | Layer 2. Burstiness |
365
+ | `register.ts` | Path to register classification |
366
+ | `prevent.ts` | Layer 0. Composes the block and the recurring-tells line |
367
+ | `memory.ts` | Layer 4. Per-project state and the effect calculation |
368
+ | `report.ts` | Formatting for the model and for the terminal |
369
+ | `index.ts` | Event wiring |
370
+
371
+ ## Development
372
+
373
+ From the repo root:
374
+
375
+ ```sh
376
+ just test timbre/
377
+ just check
378
+ ```
379
+
380
+ `just check` runs the typecheck, plain Oxlint, the repo anti-slop pass, and
381
+ the full test suite, all from `extensions/`, which is where `tsconfig.json` and
382
+ `node_modules` actually live. Running `tsc`/`oxlint` directly from any other directory
383
+ finds neither. The anti-slop pass stays scoped to `timbre/` rather than the whole package:
384
+ turning it on repo-wide would also fail against node_modules and pre-existing files this
385
+ extension never touched.
package/src/code.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { promisify } from 'node:util'
5
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import type { ToolResultEvent } from '@earendil-works/pi-coding-agent'
8
+ import type { Hit } from './scan.js'
9
+ import { Validator } from 'ata-validator'
10
+ import { t } from 'ata-validator/t'
11
+
12
+ const HERE = dirname(fileURLToPath(import.meta.url))
13
+ function findOxlintBin(): string {
14
+ let dir = HERE
15
+ while (true) {
16
+ const candidate = join(dir, 'node_modules', '.bin', 'oxlint')
17
+ if (existsSync(candidate)) return candidate
18
+ const parent = dirname(dir)
19
+ if (parent === dir) return join(HERE, '..', '..', 'node_modules', '.bin', 'oxlint')
20
+ dir = parent
21
+ }
22
+ }
23
+ const OXLINT_BIN = findOxlintBin()
24
+ const OXLINT_CONFIG = join(homedir(), '.agents', 'tools', 'oxlint', 'anti-slop.config.ts')
25
+ const VENDORED = `${join(homedir(), '.agents', 'tools', 'oxlint')}/`
26
+
27
+ const TS_FILE = /\.(ts|tsx|js|jsx|mjs|cjs)$/i
28
+ const SKIP_CODE = [
29
+ /\.d\.(ts|mts|cts)$/i,
30
+ /\.min\.(ts|tsx|js|jsx|mjs|cjs|css)$/i,
31
+ /\.snap$/i,
32
+ /\.gen\.(ts|tsx|js|jsx)$/i,
33
+ /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/i
34
+ ]
35
+ const TIMEOUT_MS = 10_000
36
+ const PREFIX = 'anti-slop('
37
+
38
+ const execFileAsync = promisify(execFile)
39
+
40
+ type OxlintDiagnostic = {
41
+ code: string
42
+ filename?: string
43
+ message?: string
44
+ labels?: Array<{ span?: { line?: number } }>
45
+ }
46
+
47
+ const OxlintReportSchema = t.object({ diagnostics: t.optional(t.unknown()) })
48
+ const isOxlintReportSchema = new Validator(OxlintReportSchema).isValidObject
49
+ const OxlintDiagnosticSchema = t.object({
50
+ code: t.string(),
51
+ filename: t.optional(t.string()),
52
+ message: t.optional(t.string()),
53
+ labels: t.optional(
54
+ t.array(
55
+ t.object({
56
+ span: t.optional(t.object({ line: t.optional(t.number()) }))
57
+ })
58
+ )
59
+ )
60
+ })
61
+ const isOxlintDiagnosticSchema = new Validator(OxlintDiagnosticSchema).isValidObject
62
+
63
+ export function codePath(
64
+ event: Pick<ToolResultEvent, 'toolName' | 'input'>,
65
+ cwd: string,
66
+ isError = false
67
+ ): string | null {
68
+ if (isError) return null
69
+ if (event.toolName !== 'write' && event.toolName !== 'edit') return null
70
+ const path = String(event.input['path'] ?? '')
71
+ if (!TS_FILE.test(path)) return null
72
+ const abs = isAbsolute(path) ? path : resolve(cwd, path)
73
+ if (abs.startsWith(VENDORED)) return null
74
+ if (SKIP_CODE.some((re) => re.test(abs))) return null
75
+ return abs
76
+ }
77
+
78
+ export type CodeFinding = Hit & { path: string }
79
+
80
+ /**
81
+ * One oxlint process for the whole turn.
82
+ *
83
+ * The spawn is ~130ms of fixed cost and the marginal file is 2-3ms: forty files
84
+ * measured 230ms against 135ms for one. So the check runs once per turn over
85
+ * every code file the turn touched, off the write's critical path, rather than
86
+ * once per write where the fixed cost was paid N times for nothing.
87
+ */
88
+ // A spawn that cannot find its config returns null, which reads the same as a clean file, so
89
+ // the failure is otherwise invisible. Both paths live outside this package: the rules are a
90
+ // vendored copy under ~/.agents/tools/oxlint, so a checkout on another machine checks nothing.
91
+ export function codeCheckProblem(bin: string = OXLINT_BIN, config: string = OXLINT_CONFIG): string | undefined {
92
+ if (!existsSync(bin)) return `the code check is off: no oxlint binary at ${bin}`
93
+ if (!existsSync(config)) return `the code check is off: no rule config at ${config}`
94
+ return undefined
95
+ }
96
+
97
+ export async function scanCodeBatch(
98
+ paths: string[],
99
+ bin = OXLINT_BIN,
100
+ config = OXLINT_CONFIG
101
+ ): Promise<CodeFinding[] | null> {
102
+ const unique = [...new Set(paths)].filter((path) => existsSync(path))
103
+ if (unique.length === 0) return []
104
+ try {
105
+ const { stdout } = await execFileAsync(bin, ['--config', config, '--format', 'json', ...unique], {
106
+ encoding: 'utf-8',
107
+ timeout: TIMEOUT_MS,
108
+ maxBuffer: 32 * 1024 * 1024
109
+ })
110
+ return parseOxlint(stdout)
111
+ } catch (err) {
112
+ // absent for a spawn failure, so the field is optional.
113
+ return parseOxlint((err as { stdout?: string }).stdout ?? '')
114
+ }
115
+ }
116
+
117
+ export function parseOxlint(stdout: string): CodeFinding[] | null {
118
+ let parsed: unknown
119
+ try {
120
+ parsed = JSON.parse(stdout)
121
+ } catch {
122
+ return null
123
+ }
124
+ if (!isOxlintReportSchema(parsed)) return null
125
+ const report = parsed
126
+ // A report whose diagnostics field is present but not a list is malformed, and
127
+ // reading it as an empty list would report a broken spawn as a clean file.
128
+ if (report.diagnostics !== undefined && !Array.isArray(report.diagnostics)) return null
129
+ const diagnostics = report.diagnostics ?? []
130
+ const seen = new Set<string>()
131
+ const out: CodeFinding[] = []
132
+ for (const d of diagnostics) {
133
+ if (!isAntiSlop(d)) continue
134
+ const line = d.labels?.[0]?.span?.line ?? 0
135
+ const path = d.filename ?? ''
136
+ const key = `${path}:${d.code}:${line}`
137
+ if (seen.has(key)) continue
138
+ seen.add(key)
139
+ out.push({
140
+ path,
141
+ line,
142
+ id: d.code.slice(PREFIX.length, -1),
143
+ tier: 1 as const,
144
+ hint: d.message ?? '',
145
+ text: ''
146
+ })
147
+ }
148
+ return out
149
+ }
150
+
151
+ function isAntiSlop(d: unknown): d is OxlintDiagnostic {
152
+ if (!isOxlintDiagnosticSchema(d)) return false
153
+ return d.code.startsWith(PREFIX)
154
+ }
package/src/config.ts ADDED
@@ -0,0 +1,21 @@
1
+ export type Config = {
2
+ enabled: boolean
3
+ prevent: boolean
4
+ watchChat: boolean
5
+ }
6
+
7
+ const FALSE = /^(0|false|no|off)$/i
8
+
9
+ function flag(name: string, fallback: boolean): boolean {
10
+ const raw = process.env[name]
11
+ if (raw === undefined || raw === '') return fallback
12
+ return !FALSE.test(raw)
13
+ }
14
+
15
+ export function loadConfig(): Config {
16
+ return {
17
+ enabled: flag('TIMBRE', true),
18
+ prevent: flag('TIMBRE_PREVENT', true),
19
+ watchChat: flag('TIMBRE_WATCH_CHAT', true)
20
+ }
21
+ }
@@ -0,0 +1,17 @@
1
+ <!-- lint-disable -- this file names the tells it forbids -->
2
+
3
+ # Writing constraints
4
+
5
+ ## Never
6
+
7
+ Em or en dashes, or a double hyphen as one. A semicolon joining two clauses, split the
8
+ sentence. A typographic ellipsis. Title Case Headings, use sentence case. "utilize" or
9
+ "leverage" for "use". Throat-clearing ("it's worth noting", "here's the thing"). Announcing
10
+ what comes next ("let's dive in"). An opening hook ("Ever wondered", "In today's
11
+ fast-paced world"). A summary or upbeat closing line. A self-answered beat ("The result?").
12
+ "not X, it's Y".
13
+
14
+ Code: comment only a non-obvious invariant, a security reason, or a contract. No debug
15
+ logs, commented-out code, scaffolding, test ceremony where a plain test is clearer, or
16
+ adapter layers the neighbouring code does without. The malformed-input cases that survive
17
+ belong at real trust boundaries. The gate lint enforces these on every check.