@tduyng/pi-timbre 0.0.3 → 0.0.4

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/README.md CHANGED
@@ -1,112 +1,117 @@
1
- # @tduyng/pi-timbre
2
-
3
- Stop the slop before it is written. The rules go in the prompt, the mechanical tells are caught on the result, and the code is checked once at the end of the turn.
4
-
5
- ## What it costs
6
-
7
- 208 tokens of injected rules a request, and 70 for the review guide tool. Those rules sit in a cacheable block, so a warm turn pays about a tenth of that: pi marks the block cacheable, and the provider bills a cache read at a fraction of a write.
8
-
9
- The rules cost less than the rewrite they prevent. A reply carrying three em dashes, a semicolon chain, and a summary paragraph costs a follow-up turn to fix, and that turn re-reads the whole prefix.
1
+ <!-- lint-disable -- this file names the tells it documents -->
10
2
 
11
- Set `PI_CACHE_RETENTION=long`. Pi's default cache TTL is 5 minutes, so any pause longer than that forces a full prefix rewrite on the next turn.
12
-
13
- ## What it adds
3
+ # @tduyng/pi-timbre
14
4
 
15
- `before_agent_start` injects the `Never` and `Code` sections of the catalog into the system prompt. The five judgment sections wait in `timbre_review_guide`, which the model calls when it is about to review prose.
5
+ Keeps Markdown and TypeScript inside a project's rules before the writing happens, instead of cleaning it up afterwards.
16
6
 
17
- `tool_result` appends a tell report to `write` and `edit` results for prose and TypeScript files. A write is never refused, because refusing it discards the tool call and costs a full regeneration, which is more than the tell it prevented is worth. One note per file per session.
7
+ Rules go into the system prompt before the model answers. Markdown is scanned when a file is written. TypeScript is checked against `dmmulroy/anti-slop` once per turn. Nothing is rewritten after generation.
18
8
 
19
- `message_end` records tell density per reply, silently.
9
+ ## Before the first token
20
10
 
21
- `/timbre` takes `on`, `off`, `fix <paths>`, `reset`, `status`, and `stats`. `ctrl+alt+t` toggles prevention with the same persistence, and the status bar shows `on` or `off`.
11
+ Cleaning up a sloppy reply costs another request, and that request re-reads the whole conversation. On a 200k-token session the correction is more expensive than the paragraph it fixes.
22
12
 
23
- ## The code check
13
+ Most de-slopping tools work after generation: detect a tell, then ask a model to rewrite. Pi lets an extension return the system prompt for the turn through `before_agent_start`, which runs before the first token exists. That hook is why this is a pi extension rather than a filter.
24
14
 
25
- Code is checked once per turn, not once per write. An oxlint spawn costs about 130ms of fixed overhead and a file adds 2 to 3ms, so a per-write check paid that fixed cost for every file and still made the write wait for it. Batched at the end of the turn it is one spawn for everything the turn touched, off the write's path.
15
+ Two rules keep the injected block small:
26
16
 
27
- Two arrangements came before this one and both failed. Sent as a follow-up, the note cost a whole model request, and by the time the model read one it had usually finished and replied that the reports were stale. Moved onto the next write or edit result, the note was unambiguous but dropped whenever a turn's last act was the write itself: a live check wrote `export function go(): unknown`, the scan found it, and the model never heard about it.
17
+ - Constraints go in the prompt, procedures go behind a tool. "Never use an em dash" is short. A procedure for judging whether a paragraph is any good is long, and irrelevant on a turn that is not judging paragraphs.
18
+ - Only rules that nothing else can enforce go in the prompt. Em dashes, curly quotes, `unknown` returns and 18 TypeScript rules have a deterministic check behind them, so restating them in the prompt buys the same enforcement twice.
28
19
 
29
- An entry at the turn's end costs no model request, keeps its own provenance, and is always delivered. What remains is the deterministic report on the write and edit results, which covers the mechanical tells in the text the model wrote.
20
+ The injected block costs 208 tokens a request and the review guide 70. Pi marks the block cacheable, so a warm turn pays about a tenth. The block is computed once per project and frozen for the session: memory changes on every reply, and one changed word in the system prompt invalidates the whole cached prefix.
30
21
 
31
- ## What prevention is worth
22
+ Set `PI_CACHE_RETENTION=long`. Pi's default cache TTL is 5 minutes, and any pause longer than that forces a full prefix rewrite.
32
23
 
33
- Two runs, same model, scratch projects, replies scored by the deterministic scan.
24
+ ## Hooks
34
25
 
35
- | Run | Arm | Replies | Mechanical | Judgment |
36
- | --- | --- | ---: | ---: | ---: |
37
- | 1 | prevention on | 12 | 1 | 9 |
38
- | 1 | prevention off | 19 | 14 | 22 |
39
- | 2 | full catalog, on | 12 | 2 | 8 |
40
- | 2 | `Never` and `Code` only, on | 12 | 3 | 11 |
41
- | 2 | prevention off | 14 | 15 | 7 |
26
+ | Hook | What happens |
27
+ | --- | --- |
28
+ | `before_agent_start` | Injects the `Never` and `Code` sections of the catalog |
29
+ | `tool_call` | Records the code files the turn touched, and notices a prose file once per session |
30
+ | `tool_result` | Appends the tell report to a `write` or `edit` of a `.md`, `.mdx`, `.txt` or source file |
31
+ | `message_end` | Measures the reply's tell density, silently |
32
+ | `turn_end` | One oxlint spawn over everything the turn wrote, reported as a session entry |
42
33
 
43
- The mechanical result is established and replicated. Prevention takes em dashes and semicolon chains from 14 and 15 down to 1, 2, and 3, and the 117-token `Never` section carries the whole effect, so the other prose sections are not what suppresses an em dash. These are rules a machine can state exactly, and the model follows them when told.
34
+ A write is never refused. Refusing it discards the tool call and the model regenerates the whole file, which costs more than the tell did. The report names the line, and a turn that ends on a write still gets its report because `turn_end` delivers it as an entry.
44
35
 
45
- The judgment result is not established, and this table contradicts an earlier reading of it. Run 1 put judgment tells at 9 against 22 and looked like a 22 percent improvement per 1,000 characters. Run 2 put them at 8 and 11 against 7, which is no effect in either direction. The first reading came from normalising by length across arms that wrote different amounts, so the honest summary is that the catalog's prose sections have no demonstrated effect on the tells this scanner counts.
36
+ ## Markdown
46
37
 
47
- That is not evidence they are useless, because the scanner is blind to what they address. `Name the actor`, `Claims`, `Voice`, and `Register` are about attribution, sourcing, and register, and none of the 22 patterns looks for any of those. So the 660 tokens they cost moved to the on-demand guide, which took the injected block from 986 tokens to 326 and the suite's per-request overhead from about 3,627 to about 2,967.
38
+ | Moment | Catches |
39
+ | --- | --- |
40
+ | Before generation | Throat-clearing, hooks, upbeat closers, self-answered beats, summary paragraphs, title case, "leverage" |
41
+ | On the write | Em and en dashes, clause-joining semicolons, curly quotes, tier-1 vocabulary, stock phrases |
42
+ | On the result | Tool leakage (`utm_source=chatgpt.com`), zero-width characters, unfilled placeholders |
43
+ | Statistics | Monotone cadence, four short sentences in a row, paragraphs that open the same way |
44
+ | On demand | `/timbre fix <paths>` scans prose already on disk and makes the agent rewrite it |
48
45
 
49
- ### A reader scoring the prose does not work either
46
+ The report is advice, so there is a gate too. `bun scripts/prose-gate.mts` in this repository scans every tracked markdown file and fails on a tier-1 tell, and it runs inside `just check`. To use it in another repository, point its import at the installed package and call it from CI.
50
47
 
51
- The obvious instrument for those sections is the model itself. Ten replies per arm, 18,660 characters, scored not on authorship but on "how well does this paragraph hold a reader's attention and say something specific": prevention on averaged 0.493, off 0.537, AUC 0.390 against chance at 0.500.
48
+ Path sets the register without a model call. A changelog can be terse, a blog post cannot, and the same flat cadence is correct in one and wrong in the other. Code spans, fenced blocks, URLs and link targets are masked, so a document that quotes a tell is not committing it. The escape hatch is `<!-- lint-disable -- <reason> -->` in the first 400 bytes, and the reason has to say why.
52
49
 
53
- That is four attempts on this checkpoint and three question shapes on authorship, all at or below chance. The boundary is clear: this checkpoint does coarse judgements about session state and does not do judgements about prose. Nothing written about a paragraph has moved off chance.
50
+ ## TypeScript
54
51
 
55
- ### The 30 patterns that were removed
52
+ Regexes cannot see `Promise<unknown>`, so that half is `dmmulroy/anti-slop`: 18 Oxlint rules, one run at the end of the turn over every source file the turn wrote.
56
53
 
57
- The scanner had 52 patterns, and 30 that had never fired were cut. Never firing is what a working guard looks like from the inside, because prevention was on for every sample behind them, so the cut was re-tested twice.
54
+ | Rule | What it stops |
55
+ | --- | --- |
56
+ | `no-unknown-returns`, `no-unknown-parameters`, `no-unknown-type-aliases` | Hiding a value behind `unknown` |
57
+ | `no-known-value-widening`, `no-widen-then-assert`, `no-chained-type-assertions` | Widening evidence and asserting it back |
58
+ | `no-unsafe-dictionary-type`, `no-object-parameters`, `no-runtime-typeof` | Skipping the parse at the boundary |
59
+ | `no-reflect-get`, `no-reflect-apply`, `no-conditional-empty-object-spread` | Untyped access, an unmodelled omission |
60
+ | `require-safety-comment-for-type-assertion` | An assertion without a `SAFETY:` line |
58
61
 
59
- With all 30 restored, across 24 replies and about 20,000 characters of technical prose, not one fired in either arm. Then the genre was asked for directly: five article-shaped prompts per arm, ten replies, about 14,000 characters, prevention on and off. Zero of the 30 fired again, and the only patterns that fired were `em-dash` and `semicolon`, both still in the set.
62
+ The prompt and the linter do different jobs. The prompt tells the model what a good type looks like while it composes, and the linter supplies the evidence. A rule the linter can prove needs no prompt tokens, so the catalog's `Code` section is four lines.
60
63
 
61
- They target essayistic tells (`rhetorical-hook`, `stock-opener`, `upbeat-closer`, `strawman-objection`, `fractal-summary`, `question-heading`), which is a genre that does not appear here even when requested. The removal stands.
64
+ The check runs once per turn because an oxlint spawn is about 130ms of fixed cost and a file adds 2 to 3ms. Forty files measured 230ms against 135ms for one. Two earlier arrangements failed: a follow-up note cost a whole model request, and a note attached to the next write result was dropped when the turn's last act was the write.
62
65
 
63
- ## Register
66
+ ## Setting up anti-slop
64
67
 
65
- A commit message, an API reference, and a blog post do not share a correct voice. Path classifies the register with no model call.
68
+ Upstream publishes no npm package and asks to be vendored, so timbre ships the vendoring:
66
69
 
67
- | Path pattern | Register | Terse is correct | Voice allowed |
68
- | --- | --- | --- | --- |
69
- | `blog/`, `posts/`, `essays/`, `drafts/` | narrative | no | yes |
70
- | `CHANGELOG.md`, `HISTORY.md`, `RELEASE_NOTES.md`, `COMMIT_EDITMSG` | changelog | yes | no |
71
- | `adr/`, `rfc/`, `spec/`, `reference/`, `api/`, `docs/`, `README.md`, `CONTRIBUTING.md` | reference | yes | no |
72
- | anything else | unknown | no | no |
70
+ ```bash
71
+ # from the installed package or any checkout of this repo
72
+ ./scripts/anti-slop-setup.sh
73
+ ```
73
74
 
74
- The register decides how the rhythm signal reads. A flat cadence in a changelog is correct, and the same cadence in a blog post is a tell.
75
+ The script fetches the pinned upstream revision and writes `~/.agents/tools/oxlint/anti-slop/` and `anti-slop.config.ts` with all 18 rules selected. It records provenance in `anti-slop.vendor.json`, installs `@oxlint/plugins` at the version your Oxlint reports, and then proves the rules run by checking a file that must fail. Re-running changes nothing unless you pass `--force`.
75
76
 
76
- ## Rhythm
77
+ Timbre reads that path by default. With the rules elsewhere:
77
78
 
78
- Two signals the regexes cannot see:
79
+ ```bash
80
+ export TIMBRE_OXLINT_CONFIG=./tools/oxlint/oxlint.config.ts
81
+ export TIMBRE_OXLINT_BIN=./node_modules/.bin/oxlint # optional, found automatically
82
+ ```
79
83
 
80
- - Monotone cadence: uniform sentence lengths across the piece. Fires only when fewer than 30 percent of non-empty lines are structured and the piece has at least one blank line, so technical docs stay quiet.
81
- - Staccato run: four or more short sentences in a row. One short sentence is emphasis, and a run is engineering.
84
+ The same config is the hard gate. `just check` here runs `oxlint --config scripts/oxlint/anti-slop.config.ts extensions/`, and a project's pre-commit hook or CI can run the same command. The report is a tendency. The gate is what makes it never.
82
85
 
83
- Both are suppressed for the registers where an even cadence is correct.
86
+ If upstream's own installer is a better fit, `npx skills add dmmulroy/anti-slop --skill install-anti-slop` vendors into the repository and merges the plugin into the project's Oxlint config. Point `TIMBRE_OXLINT_CONFIG` at that config, since timbre runs `oxlint --config` and reads the `anti-slop(...)` diagnostics back.
84
87
 
85
- ## Memory
88
+ ## Commands
86
89
 
87
- One JSON file per project under `$XDG_STATE_HOME/timbre/`, keyed by a hash of the project path, holding up to 500 samples plus any live overrides from `/timbre`. Writes are atomic, and two sessions in one repository merge rather than lock: a simultaneous write can drop one sample, which is cheap next to a lock file that strands the extension.
90
+ ```
91
+ /timbre status|stats
92
+ /timbre on|off
93
+ /timbre fix <paths>
94
+ /timbre reset
95
+ ctrl+alt+t
96
+ ```
88
97
 
89
- A persisted setting outlives the session that set it. On session start timbre reads the file, loads the overrides, and announces the ones that differ from the environment default. A subagent shares no memory with its parent, only this file, so it is the only channel a `/timbre off` has to reach it.
98
+ `/timbre` reports whether the design is holding: average cache read and write per turn, and a warning when writes dominate reads. It prints a measured effect after the project has samples from both prevention arms, so run `/timbre off` for a stretch first. Until then the effect reads `null`.
90
99
 
91
- The prevention block is computed once per project and frozen for the session, because memory changes on every reply and one changed word in the system prompt invalidates the whole cached prefix. Frozen, timbre adapts across sessions, which is the timescale a writing habit moves on.
100
+ A toggle persists to the project state file, so it survives the session and reaches a subagent, which runs as its own process.
92
101
 
93
- ## What it does not do
102
+ ## Measurements
94
103
 
95
- - It does not rewrite text after generation, since prevention replaces cleanup.
96
- - It does not call a model to detect anything. A local model asked which of two passages reads more machine-generated picked the same one in both orderings, and one asked to label a whole file read human and machine text at chance over 80 passages.
97
- - It does not recompute the prompt block per turn.
104
+ Prevention takes em dashes and semicolon chains from 14 and 15 per 1,000 characters down to 1, 2 and 3. The 117-token `Never` section carries that whole effect.
98
105
 
99
- Everything fails open: a broken scan or an unwritable state leaves the turn untouched, and `/timbre` names a missing `guidance/` directory rather than reporting prevention on over an empty block.
106
+ The judgment sections have no demonstrated effect on the tells the scanner counts, and two runs disagree with the first reading. The scanner is blind to attribution and sourcing, the work those sections do, so it cannot measure them either way. They sit behind the tool for that reason.
100
107
 
101
- The code check degrades the same way. Its rules are read from `~/.agents/tools/oxlint/anti-slop.config.ts` by absolute path and are not in the published tarball, and a spawn whose config is missing returns null, which is indistinguishable from a clean file. `/timbre` now names a missing binary or a missing rule config, which is the only reason you would notice. A published install degrades to the prompt block and says so.
108
+ No model is used to detect anything. A local model asked which of two passages reads more machine-generated picked the same one in both orderings, and one asked to label a file read human and machine text at chance over 80 passages.
102
109
 
103
- ## Configure
110
+ ## Limits
104
111
 
105
- | Variable | Default | Meaning |
106
- | --- | --- | --- |
107
- | `TIMBRE` | `1` | Master switch |
108
- | `TIMBRE_PREVENT` | `1` | Layer 0. `0` measures without injecting |
109
- | `TIMBRE_WATCH_CHAT` | `1` | Measure assistant replies, silently |
112
+ - Nothing is rewritten after generation.
113
+ - Patterns are English. A French or Vietnamese repository gets the prompt block and little else.
114
+ - Everything fails open. `/timbre` names a missing catalog, a missing Oxlint binary or a missing rule config instead of reporting prevention over an empty block.
110
115
 
111
116
  ## Install
112
117
 
@@ -116,4 +121,10 @@ pi install npm:@tduyng/pi-timbre
116
121
 
117
122
  Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-timbre`
118
123
 
119
- The full design rationale lives in `src/README.md`.
124
+ Then vendor the code rules once:
125
+
126
+ ```bash
127
+ /path/to/extensions/pi-timbre/scripts/anti-slop-setup.sh
128
+ ```
129
+
130
+ Design notes live in `src/README.md`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tduyng/pi-timbre",
3
- "version": "0.0.3",
4
- "description": "Constrain prose and code before it is written: inject the rules, catch the mechanical tells, check the code once per turn.",
3
+ "version": "0.0.4",
4
+ "description": "Constrain prose and code before it is written: inject the rules, report the mechanical tells on the write, and check TypeScript against dmmulroy/anti-slop once per turn.",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "src",
27
27
  "!src/**/*.test.ts",
28
+ "scripts",
28
29
  "README.md",
29
30
  "LICENSE"
30
31
  ],
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ # Vendor dmmulroy/anti-slop where pi-timbre looks for it, and prove the rules run.
3
+ #
4
+ # ./anti-slop-setup.sh install the pinned revision into ~/.agents/tools/oxlint
5
+ # ./anti-slop-setup.sh --ref main track upstream head instead of the recorded revision
6
+ # ./anti-slop-setup.sh --dir ./tools/oxlint install beside a project
7
+ # ./anti-slop-setup.sh --force replace a copy that is already there
8
+ #
9
+ # Upstream publishes no npm package and asks to be vendored, so there is nothing to depend on.
10
+ # This writes the copy, the config that selects the rules, the provenance record, and a
11
+ # one-line check that a known violation is reported. Re-running without --force changes nothing.
12
+ set -euo pipefail
13
+
14
+ REPO="https://github.com/dmmulroy/anti-slop"
15
+ PINNED="c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b"
16
+ REF="$PINNED"
17
+ DEST="$HOME/.agents/tools/oxlint"
18
+ FORCE=0
19
+
20
+ while [ $# -gt 0 ]; do
21
+ case "$1" in
22
+ --ref) REF="${2:?--ref needs a revision}"; shift 2 ;;
23
+ --dir) DEST="${2:?--dir needs a path}"; shift 2 ;;
24
+ --force) FORCE=1; shift ;;
25
+ -h|--help) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
26
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
27
+ esac
28
+ done
29
+
30
+ DEST="${DEST/#\~/$HOME}"
31
+ PLUGIN="$DEST/anti-slop"
32
+ CONFIG="$DEST/anti-slop.config.ts"
33
+
34
+ if [ -e "$PLUGIN" ] && [ "$FORCE" -eq 0 ]; then
35
+ echo "anti-slop is already installed at $PLUGIN"
36
+ echo "pi-timbre reads $CONFIG, so there is nothing to do. Re-run with --force to replace it."
37
+ exit 0
38
+ fi
39
+
40
+ need() { command -v "$1" >/dev/null 2>&1 || { echo "missing $1" >&2; exit 1; }; }
41
+ need curl
42
+ need tar
43
+ need python3
44
+
45
+ tmp=$(mktemp -d)
46
+ trap 'rm -rf "$tmp"' EXIT
47
+
48
+ echo "fetching anti-slop@$REF"
49
+ curl -sfSL "$REPO/archive/$REF.tar.gz" | tar xz -C "$tmp"
50
+ src=$(find "$tmp" -maxdepth 2 -type d -name src | head -1)
51
+ [ -n "$src" ] || { echo "no src/ in the archive: upstream layout changed" >&2; exit 1; }
52
+
53
+ mkdir -p "$DEST"
54
+ rm -rf "$PLUGIN"
55
+ cp -R "$src" "$PLUGIN"
56
+ cp "$(dirname "$src")/LICENSE" "$DEST/anti-slop.LICENSE"
57
+
58
+ oxlint_version="1.90.0"
59
+ for candidate in "$(command -v oxlint || true)" "$PWD/node_modules/.bin/oxlint"; do
60
+ if [ -n "$candidate" ] && [ -x "$candidate" ]; then
61
+ found=$("$candidate" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)
62
+ [ -n "$found" ] && oxlint_version="$found" && OXLINT="$candidate"
63
+ break
64
+ fi
65
+ done
66
+
67
+ cat >"$DEST/package.json" <<JSON
68
+ {
69
+ "name": "anti-slop-oxlint",
70
+ "version": "0.0.0",
71
+ "private": true,
72
+ "type": "module",
73
+ "dependencies": {
74
+ "@oxlint/plugins": "$oxlint_version"
75
+ }
76
+ }
77
+ JSON
78
+
79
+ python3 - "$REPO" "$REF" "$PLUGIN" "$DEST/anti-slop.vendor.json" <<'PY'
80
+ import datetime, json, pathlib, sys
81
+ repo, ref, plugin, out = sys.argv[1:5]
82
+ pathlib.Path(out).write_text(json.dumps({
83
+ 'name': 'anti-slop',
84
+ 'repo': repo,
85
+ 'license': 'MIT',
86
+ 'licenseFile': 'anti-slop.LICENSE',
87
+ 'upstreamPath': 'src',
88
+ 'localPath': 'anti-slop',
89
+ 'ref': ref,
90
+ 'syncedAt': datetime.date.today().isoformat(),
91
+ }, indent=2) + '\n')
92
+ rules = sorted(set(__import__('re').findall(r'"([a-z0-9-]+)":', (pathlib.Path(plugin) / 'index.ts').read_text())))
93
+ if not rules:
94
+ sys.exit('no rules found in anti-slop/index.ts: upstream layout changed')
95
+ lines = ['// Written by pi-timbre scripts/anti-slop-setup.sh. Rules come from dmmulroy/anti-slop,',
96
+ '// vendored at ./anti-slop. Select a rule here to enforce it, and edit freely: this file',
97
+ '// is yours, not upstream.',
98
+ 'export default {',
99
+ " jsPlugins: [{ name: 'anti-slop', specifier: './anti-slop/index.ts' }],",
100
+ ' rules: {']
101
+ lines += [f" 'anti-slop/{r}': 'error'," for r in rules]
102
+ lines += [' }', '}', '']
103
+ pathlib.Path(out.replace('.vendor.json', '.config.ts')).write_text('\n'.join(lines))
104
+ print(f"wrote {out.replace('.vendor.json', '.config.ts')} with {len(rules)} rules")
105
+ PY
106
+
107
+ (cd "$DEST" && { bun install --silent >/dev/null 2>&1 || npm install --silent --no-audit --no-fund >/dev/null 2>&1 || echo "install @oxlint/plugins in $DEST yourself: the rules import it" ; })
108
+
109
+ probe="$tmp/probe.ts"
110
+ printf 'type Values = unknown\nexport const values: Values = 1\n' >"$probe"
111
+ if [ -n "${OXLINT:-}" ]; then
112
+ # oxlint exits 1 when it finds a violation, which is the expected result here.
113
+ out=$(cd "$DEST" && "$OXLINT" --config "$CONFIG" "$probe" 2>&1 || true)
114
+ if printf '%s' "$out" | grep -q 'anti-slop(no-unknown-type-aliases)'; then
115
+ echo "verified: $OXLINT reports an anti-slop violation through $CONFIG"
116
+ else
117
+ echo "installed, but the probe was not reported. Check @oxlint/plugins in $DEST" >&2
118
+ exit 1
119
+ fi
120
+ else
121
+ echo "installed. No oxlint on PATH, so the probe was skipped."
122
+ fi
123
+
124
+ echo
125
+ echo "pi-timbre reads $CONFIG by default. From another location, export:"
126
+ echo " TIMBRE_OXLINT_CONFIG=$CONFIG"
package/src/README.md CHANGED
@@ -34,7 +34,7 @@ skills now live only under Claude Code, which has its own skill loader for them.
34
34
  ```
35
35
  before_agent_start -> inject the whole constraint set, plus what this project repeats
36
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
37
+ tool_call -> record the touched code, and notice a prose file once per session
38
38
  message_end -> measure the reply, silently, record what still got through
39
39
  tool_result -> report tells in prose and source files just written
40
40
  /timbre fix -> scan prose already on disk and make the agent rewrite it
@@ -49,7 +49,7 @@ that says whether prevention works.
49
49
  | Layer | Detects | Cost |
50
50
  | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
51
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 |
52
+ | 0.5 Write report | Reports the tells a write would land, grouped by id and line, on the tool result. Nothing is refused: a refused write is discarded and regenerated, which costs more than the tell it prevented | free |
53
53
  | 1. Forensic | Tool leakage: `utm_source=chatgpt.com`, `citeturn0search0`, zero-width characters, unfilled placeholders | free |
54
54
  | 1. Mechanical | Fixed tells: dashes, semicolons, tier-1 vocabulary, stock phrases, Claude's own tics | free |
55
55
  | 2. Statistical | Cadence, clause stacks, and paragraph shape: the signals regex cannot see | free |
@@ -127,8 +127,8 @@ sends the rewrite when the file carries no mechanical tell, because "are there t
127
127
  "is this any good" are different questions and the second one was asked.
128
128
 
129
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
130
+ `write` and `edit`, where the tool result reports a reintroduced em dash, curly quote or
131
+ semicolon with its line. An on-request scan
132
132
  records no sample: a sample answers whether prevention changed what a model generated on
133
133
  some turn, and a file read off disk belongs to no turn and neither prevention arm, so
134
134
  counting it would corrupt the only measurement `/timbre` exists to produce.
@@ -186,6 +186,14 @@ Environment only.
186
186
  | `TIMBRE` | `1` | Master switch |
187
187
  | `TIMBRE_PREVENT` | `1` | Layer 0. `0` measures without injecting |
188
188
  | `TIMBRE_WATCH_CHAT` | `1` | Measure assistant replies, silently |
189
+ | `TIMBRE_OXLINT_CONFIG` | `~/.agents/tools/oxlint/anti-slop.config.ts` | The rules the turn-end code pass runs. `scripts/anti-slop-setup.sh` writes a working one |
190
+ | `TIMBRE_OXLINT_BIN` | the project's `node_modules/.bin/oxlint`, else the package's own | Which Oxlint the code pass spawns |
191
+
192
+ The code pass is `dmmulroy/anti-slop`, vendored rather than depended on because upstream
193
+ publishes no package. `scripts/anti-slop-setup.sh` fetches the pinned revision, writes the
194
+ copy, the selecting config, the provenance record, and verifies the rules by checking a file
195
+ that must fail. The same config serves a project's hard gate, which is the only thing that
196
+ makes "never" true: timbre reports, the gate refuses to merge.
189
197
 
190
198
  Set `PI_CACHE_RETENTION=long` in your environment. Pi's default cache TTL is 5 minutes, so
191
199
  any pause longer than that makes the next turn re-write the whole cached prefix instead of
@@ -293,19 +301,13 @@ flagged lines into contiguous spans and computes the flagged character fraction,
293
301
  `/timbre fix` can say "lines 40-58 are machine-shaped, rewrite from your outline" rather
294
302
  than deliver a scattered list.
295
303
 
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.
304
+ **A blocked write costs more than the tell it prevents.** An earlier version refused a write carrying an em dash, a curly quote or a clause-joining semicolon, on the argument that a weak model argues with a note. A `nemotron-3.5-lightning-free` session put twenty em dashes in one document with the rule in its system prompt, read the report naming every line, and concluded the detector was wrong, which is the case a block fixes. It was still dropped: the refusal discards the tool call, the model regenerates the whole file, and the same text arrives in the same report the block replaced. Reporting on the result names the path and every line, and the model fixes it without a regeneration.
303
305
 
304
- **The block groups by id, not by hit.** Seven em dashes used to print seven identical hint
306
+ **The report groups by id, not by hit.** Seven em dashes used to print seven identical hint
305
307
  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,
308
+ rewrite. The message names the path, one line per id with every line number it fired on,
307
309
  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.
310
+ the file, and the message says so, since `edits[].newText` is all timbre sees.
309
311
 
310
312
  **A write scans the whole file. An edit scans only its hunks.** Both come straight from the
311
313
  tool call, never a disk read. `write` always replaces a file wholesale, so the whole new file
@@ -364,8 +366,11 @@ and the forensic tier, and little else of value.
364
366
  | `stats.ts` | Layer 2. Burstiness |
365
367
  | `register.ts` | Path to register classification |
366
368
  | `prevent.ts` | Layer 0. Composes the block and the recurring-tells line |
369
+ | `code.ts` | The turn-end Oxlint anti-slop pass and its rules config |
367
370
  | `memory.ts` | Layer 4. Per-project state and the effect calculation |
368
371
  | `report.ts` | Formatting for the model and for the terminal |
372
+ | `segments.ts` | Merges adjacent flagged lines into one span to rewrite |
373
+ | `config.ts` | Environment switches |
369
374
  | `index.ts` | Event wiring |
370
375
 
371
376
  ## Development
package/src/code.ts CHANGED
@@ -11,6 +11,8 @@ import { t } from 'ata-validator/t'
11
11
 
12
12
  const HERE = dirname(fileURLToPath(import.meta.url))
13
13
  function findOxlintBin(): string {
14
+ const project = join(process.cwd(), 'node_modules', '.bin', 'oxlint')
15
+ if (existsSync(project)) return project
14
16
  let dir = HERE
15
17
  while (true) {
16
18
  const candidate = join(dir, 'node_modules', '.bin', 'oxlint')
@@ -20,9 +22,26 @@ function findOxlintBin(): string {
20
22
  dir = parent
21
23
  }
22
24
  }
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')}/`
25
+ const DEFAULT_CONFIG = join(homedir(), '.agents', 'tools', 'oxlint', 'anti-slop.config.ts')
26
+
27
+ function envPath(name: string, fallback: string): string {
28
+ const raw = process.env[name]
29
+ if (raw === undefined || raw === '') return fallback
30
+ return isAbsolute(raw) ? raw : resolve(process.cwd(), raw)
31
+ }
32
+
33
+ export function oxlintBin(): string {
34
+ return envPath('TIMBRE_OXLINT_BIN', findOxlintBin())
35
+ }
36
+
37
+ export function oxlintConfig(): string {
38
+ return envPath('TIMBRE_OXLINT_CONFIG', DEFAULT_CONFIG)
39
+ }
40
+
41
+ /** The vendored plugin is synced from upstream, so a file the turn wrote inside it is not the model's. */
42
+ function vendoredDir(config: string = oxlintConfig()): string {
43
+ return `${join(dirname(config), 'anti-slop')}/`
44
+ }
26
45
 
27
46
  const TS_FILE = /\.(ts|tsx|js|jsx|mjs|cjs)$/i
28
47
  const SKIP_CODE = [
@@ -70,7 +89,7 @@ export function codePath(
70
89
  const path = String(event.input['path'] ?? '')
71
90
  if (!TS_FILE.test(path)) return null
72
91
  const abs = isAbsolute(path) ? path : resolve(cwd, path)
73
- if (abs.startsWith(VENDORED)) return null
92
+ if (abs.startsWith(vendoredDir())) return null
74
93
  if (SKIP_CODE.some((re) => re.test(abs))) return null
75
94
  return abs
76
95
  }
@@ -88,7 +107,7 @@ export type CodeFinding = Hit & { path: string }
88
107
  // A spawn that cannot find its config returns null, which reads the same as a clean file, so
89
108
  // the failure is otherwise invisible. Both paths live outside this package: the rules are a
90
109
  // 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 {
110
+ export function codeCheckProblem(bin: string = oxlintBin(), config: string = oxlintConfig()): string | undefined {
92
111
  if (!existsSync(bin)) return `the code check is off: no oxlint binary at ${bin}`
93
112
  if (!existsSync(config)) return `the code check is off: no rule config at ${config}`
94
113
  return undefined
@@ -96,8 +115,8 @@ export function codeCheckProblem(bin: string = OXLINT_BIN, config: string = OXLI
96
115
 
97
116
  export async function scanCodeBatch(
98
117
  paths: string[],
99
- bin = OXLINT_BIN,
100
- config = OXLINT_CONFIG
118
+ bin = oxlintBin(),
119
+ config = oxlintConfig()
101
120
  ): Promise<CodeFinding[] | null> {
102
121
  const unique = [...new Set(paths)].filter((path) => existsSync(path))
103
122
  if (unique.length === 0) return []