@tduyng/pi-timbre 0.0.2 → 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 +82 -109
- package/package.json +3 -2
- package/scripts/anti-slop-setup.sh +126 -0
- package/src/README.md +19 -14
- package/src/code.ts +26 -7
package/README.md
CHANGED
|
@@ -1,152 +1,117 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
Constrains the agent's prose and code before tokens are generated, then measures what still gets through.
|
|
4
|
-
|
|
5
|
-
For full design rationale, see `src/README.md`.
|
|
6
|
-
|
|
7
|
-
## What it adds
|
|
8
|
-
|
|
9
|
-
`before_agent_start`: injects a constraint block into the system prompt each turn, which is the `Never` and `Code` sections of the catalog and nothing else; the five judgment sections are served on demand by `timbre_review_guide`.
|
|
10
|
-
|
|
11
|
-
`tool_call`: records the path a write touched, for the once-per-turn code check below, and notices a prose write once per file per session. It refuses nothing.
|
|
12
|
-
|
|
13
|
-
There was a preflight here that blocked the write, and this paragraph used to say so. It was removed in `0df54db3` because it repeated the report the result already carried, after `7f69c7ab` had already replaced the refusal with a note for the same reason. A blocked write discards the tool call and costs a full regeneration, which is more than the tell it prevented is worth.
|
|
14
|
-
|
|
15
|
-
The write is never refused, because refusing it discards the tool call and costs a full regeneration. One note per file per session: the model edits the spans the note names, and that edit is another write, which is another read.
|
|
16
|
-
|
|
17
|
-
`tool_result` note: appends a tell report to write and edit results for prose and TypeScript files.
|
|
18
|
-
|
|
19
|
-
### What the score was worth on `english`, and why the note is gone
|
|
20
|
-
|
|
21
|
-
It was a label, not a discriminator, on the checkpoint serving, and no reformulation of it fixed that. Seventeen samples, nine written by people and eight of this model's own replies: pi's vendor documentation, this repository's `AGENTS.md`, `GUIDE.md` from its first commit, and assistant replies from real sessions. All seventeen came back level 2. It is not reading length, since 200 characters of `AGENTS.md` scores the same as 3,500, and the argmax is not hiding a usable threshold either: the probability of level 0 separates the two classes at 0.167 AUC and `1 - p(level 2)` at 0.250, where chance is 0.5.
|
|
22
|
-
|
|
23
|
-
Three question shapes were measured on those same seventeen samples. The one as shipped scored 0.264. Rewriting the criteria so each level describes what it means, rather than naming it, scored 0.250. Replacing the three-level score with a single continuous judgement scored 0.514. None separates, so this is the checkpoint's ceiling and not the wording. `PLAN.md` records the same wall from the other side: making an instruction more precise collapsed its spread from 0.380 to 0.199, and the vaguer phrasing carried more signal than the precise one.
|
|
24
|
-
|
|
25
|
-
The one place a session does record who wrote a paragraph is the transcript itself: a user message is human and an assistant message is machine. Over eighty real passages from this repository's sessions, forty of each, the score reaches 0.512 AUC, at means 0.706 against 0.764 with the wrong one on top, and the note would have fired on 30 of the 40 machine passages and 26 of the 40 human ones. Scored on markdown files instead, thirteen same-file pairs where the agent had rewritten an earlier version, it reaches 0.500, though both sides of those pairs are agent-written and the test cannot settle much. The note was three quarters true and three quarters false at the same time, which is the worst of both: it spends 110ms of server time per prose write to tell the model to rewrite prose that a person wrote.
|
|
26
|
-
|
|
27
|
-
So the label is gone, and with it the switch, the shadow records, the turn-end drain and the write-path call. `PLAN.md`'s own rule gives the reason: a question earns a call only when the decision it reads exists in the transcript, and the note was reading a question the transcript does not answer.
|
|
28
|
-
|
|
29
|
-
`message_end`: silently records tell density per assistant reply.
|
|
30
|
-
|
|
31
|
-
`timbre_review_guide` tool: serves the judgment procedure on request.
|
|
32
|
-
|
|
33
|
-
`/timbre` subcommands: `on`, `off`, `fix <paths>`, `reset`, `status`, `stats`.
|
|
34
|
-
|
|
35
|
-
`ctrl+alt+t` shortcut: toggles prevention with the same persistence as the command. The status bar shows `on` or `off`.
|
|
36
|
-
|
|
37
|
-
## Token costs
|
|
1
|
+
<!-- lint-disable -- this file names the tells it documents -->
|
|
38
2
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
- The catalog, 986 tokens, and it is the only standing cost this package has
|
|
42
|
-
- Per turn, cache warm: 99 tokens
|
|
43
|
-
- Per turn, cache cold: 1,085 tokens
|
|
44
|
-
|
|
45
|
-
The constraint block is the only part of the rules the model reads before it writes, so it is built whenever prevention is on, . The read judges what landed and fails open, and a rule the model only meets afterwards is a rule it already broke.
|
|
46
|
-
|
|
47
|
-
Code is checked once per turn, not once per write. An oxlint spawn is about 130ms of fixed cost and a file adds 2-3ms, so a per-write check paid that fixed cost again 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. It runs wherever the session runs, so there is no per-project command to remember.
|
|
48
|
-
|
|
49
|
-
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: sessions show it replying that the reports were stale and described states it had already replaced and committed. Fixing a finding also changes the file's signature, which earned a second follow-up for the tell the fix uncovered, so one file could produce a stream of them. Moved onto the next write or edit result, the note was unambiguous but dropped whenever a turn's last act was the write itself.
|
|
50
|
-
|
|
51
|
-
The note goes into the session as an entry at the turn's end. It used to ride the next write or edit result, which was unambiguous but dropped the note 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. An entry costs no model request, keeps its own provenance instead of borrowing a command's output, and is always delivered. Nothing else stands in for it, because nothing blocks a write. What remains is the deterministic tell report on the write and edit result, which covers the mechanical tells in the text the model wrote, so what is given up is the code check's end-of-task report rather than the prose report.
|
|
52
|
-
|
|
53
|
-
A 30-turn session costs about 3k tokens with a 1-hour cache TTL. The same session costs 33k if every turn goes cold, which is what Pi's default 5-minute TTL produces.
|
|
3
|
+
# @tduyng/pi-timbre
|
|
54
4
|
|
|
55
|
-
|
|
5
|
+
Keeps Markdown and TypeScript inside a project's rules before the writing happens, instead of cleaning it up afterwards.
|
|
56
6
|
|
|
57
|
-
|
|
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.
|
|
58
8
|
|
|
59
|
-
|
|
9
|
+
## Before the first token
|
|
60
10
|
|
|
61
|
-
|
|
62
|
-
| --- | ------------------------- | ------: | ---------: | -------: |
|
|
63
|
-
| 1 | prevention on | 12 | 1 | 9 |
|
|
64
|
-
| 1 | prevention off | 19 | 14 | 22 |
|
|
65
|
-
| 2 | full catalog, prevention on | 12 | 2 | 8 |
|
|
66
|
-
| 2 | `Never` and `Code` only, prevention on | 12 | 3 | 11 |
|
|
67
|
-
| 2 | prevention off | 14 | 15 | 7 |
|
|
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.
|
|
68
12
|
|
|
69
|
-
|
|
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.
|
|
70
14
|
|
|
71
|
-
|
|
15
|
+
Two rules keep the injected block small:
|
|
72
16
|
|
|
73
|
-
|
|
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.
|
|
74
19
|
|
|
75
|
-
|
|
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.
|
|
76
21
|
|
|
77
|
-
|
|
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.
|
|
78
23
|
|
|
79
|
-
|
|
24
|
+
## Hooks
|
|
80
25
|
|
|
81
|
-
|
|
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 |
|
|
82
33
|
|
|
83
|
-
|
|
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.
|
|
84
35
|
|
|
85
|
-
|
|
36
|
+
## Markdown
|
|
86
37
|
|
|
87
|
-
|
|
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 |
|
|
88
45
|
|
|
89
|
-
The
|
|
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.
|
|
90
47
|
|
|
91
|
-
|
|
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.
|
|
92
49
|
|
|
93
|
-
|
|
50
|
+
## TypeScript
|
|
94
51
|
|
|
95
|
-
|
|
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.
|
|
96
53
|
|
|
97
|
-
|
|
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 |
|
|
98
61
|
|
|
99
|
-
|
|
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.
|
|
100
63
|
|
|
101
|
-
|
|
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.
|
|
102
65
|
|
|
103
|
-
|
|
104
|
-
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
105
|
-
| `TIMBRE` | `1` | Master switch |
|
|
106
|
-
| `TIMBRE_PREVENT` | `1` | Layer 0. `0` measures without injecting |
|
|
107
|
-
| `TIMBRE_WATCH_CHAT` | `1` | Measure assistant replies, silently |
|
|
66
|
+
## Setting up anti-slop
|
|
108
67
|
|
|
109
|
-
|
|
68
|
+
Upstream publishes no npm package and asks to be vendored, so timbre ships the vendoring:
|
|
110
69
|
|
|
111
|
-
|
|
70
|
+
```bash
|
|
71
|
+
# from the installed package or any checkout of this repo
|
|
72
|
+
./scripts/anti-slop-setup.sh
|
|
73
|
+
```
|
|
112
74
|
|
|
113
|
-
|
|
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`.
|
|
114
76
|
|
|
115
|
-
|
|
77
|
+
Timbre reads that path by default. With the rules elsewhere:
|
|
116
78
|
|
|
117
|
-
|
|
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
|
+
```
|
|
118
83
|
|
|
119
|
-
|
|
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.
|
|
120
85
|
|
|
121
|
-
|
|
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.
|
|
122
87
|
|
|
123
|
-
|
|
124
|
-
| -------------------------------------------------------------------------------------- | --------- | ---------------- | ------------- |
|
|
125
|
-
| `blog/`, `posts/`, `essays/`, `drafts/` | narrative | no | yes |
|
|
126
|
-
| `CHANGELOG.md`, `HISTORY.md`, `RELEASE_NOTES.md`, `COMMIT_EDITMSG` | changelog | yes | no |
|
|
127
|
-
| `adr/`, `rfc/`, `spec/`, `reference/`, `api/`, `docs/`, `README.md`, `CONTRIBUTING.md` | reference | yes | no |
|
|
128
|
-
| anything else | unknown | no | no |
|
|
88
|
+
## Commands
|
|
129
89
|
|
|
130
|
-
|
|
90
|
+
```
|
|
91
|
+
/timbre status|stats
|
|
92
|
+
/timbre on|off
|
|
93
|
+
/timbre fix <paths>
|
|
94
|
+
/timbre reset
|
|
95
|
+
ctrl+alt+t
|
|
96
|
+
```
|
|
131
97
|
|
|
132
|
-
|
|
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`.
|
|
133
99
|
|
|
134
|
-
|
|
100
|
+
A toggle persists to the project state file, so it survives the session and reaches a subagent, which runs as its own process.
|
|
135
101
|
|
|
136
|
-
|
|
137
|
-
- Staccato run: four or more short sentences in a row. One short sentence is emphasis. A run is engineering.
|
|
102
|
+
## Measurements
|
|
138
103
|
|
|
139
|
-
|
|
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.
|
|
140
105
|
|
|
141
|
-
|
|
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.
|
|
142
107
|
|
|
143
|
-
-
|
|
144
|
-
- Does not use a corpus or model call for detection (deterministic regex). 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, so nothing here is blocked or detected on one.
|
|
145
|
-
- Does not recompute the prompt block per turn (frozen once, adapts across sessions).
|
|
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.
|
|
146
109
|
|
|
147
|
-
|
|
110
|
+
## Limits
|
|
148
111
|
|
|
149
|
-
|
|
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.
|
|
150
115
|
|
|
151
116
|
## Install
|
|
152
117
|
|
|
@@ -155,3 +120,11 @@ pi install npm:@tduyng/pi-timbre
|
|
|
155
120
|
```
|
|
156
121
|
|
|
157
122
|
Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-timbre`
|
|
123
|
+
|
|
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.
|
|
4
|
-
"description": "Constrain
|
|
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 ->
|
|
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
|
|
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
|
|
131
|
-
semicolon
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
100
|
-
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 []
|