zero-slop 2.7.1 → 2.7.7

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
@@ -5,23 +5,20 @@
5
5
  <img alt="tests" src="https://img.shields.io/badge/tests-passing-227B5B">
6
6
  <img alt="dependencies" src="https://img.shields.io/badge/runtime%20dependencies-0-227B5B">
7
7
  <img alt="privacy" src="https://img.shields.io/badge/learning-private-227B5B">
8
- <img alt="version" src="https://img.shields.io/badge/version-2.7.1-72528F">
8
+ <img alt="version" src="https://img.shields.io/badge/version-2.7.7-72528F">
9
9
  </p>
10
10
 
11
- Score your writing 0 to 100 for AI slop, then edit it out without changing a single fact.
11
+ Take all the slop out of your AI writing. The #1 agentic anti-slop skill.
12
+
13
+ Score your writing 0 to 100 for AI slop, see the exact phrases behind the number, and edit them out without changing a single fact. Free, MIT, scored offline with no account. Worked examples and the full benchmark are at [zero-slop.ai](https://zero-slop.ai).
12
14
 
13
15
  ![A scored sentence before and after editing](assets/demo.png)
14
16
 
15
17
  ## Problem
16
18
 
17
- AI writing has an accent: "It's not X. It's Y." "Here's the thing nobody tells you."
18
- "This marks a pivotal moment." Ask an AI to fix it and it sands off the vocabulary and
19
- cadence that made the writing yours, and rewrites your numbers on the way.
20
-
19
+ AI writing has a distinct tell: "It's not X. It's Y." "Here's the thing nobody tells you."
21
20
 
22
- Zero Slop is an Agent Skill and ships no model. Claude, GPT, or another compatible model
23
- does the editing; Zero Slop supplies the workflow, the meter, and the checks that refuse
24
- any change to a name, number, quotation or link.
21
+ Zero Slop is an Agent Skill and ships no model. Claude, GPT, or another compatible model does the editing; Zero Slop supplies the workflow and the checks that refuse any change to a name, number, quotation or link. It runs in Claude Code, Cowork, Codex, Cursor, Warp, Zed, and any other harness that reads SKILL.md.
25
22
 
26
23
  ## How to install Zero Slop
27
24
 
@@ -37,8 +34,15 @@ Or install it with `npx`:
37
34
  npx skills add manavmishra/ZeroSlop --global
38
35
  ```
39
36
 
37
+ Or from the registry, which also installs the scorer as a command:
38
+
39
+ ```sh
40
+ npx zero-slop install # add --harness codex|cursor|opencode|zed
41
+ npx zero-slop score draft.md # score without installing anything
42
+ ```
43
+
40
44
  ChatGPT users can download [`dist/zero-slop-single-file.md`](dist/zero-slop-single-file.md).
41
- Claude.ai users can upload [`dist/zero-slop.zip`](dist/zero-slop.zip). `npx skills update zero-slop --global` updates an existing CLI install later.
45
+ Claude.ai users can upload [`dist/zero-slop.zip`](dist/zero-slop.zip). `npx skills update zero-slop --global` updates it later.
42
46
 
43
47
  ## How to use Zero Slop
44
48
 
@@ -46,40 +50,37 @@ Claude.ai users can upload [`dist/zero-slop.zip`](dist/zero-slop.zip). `npx skil
46
50
  /zero-slop (your writing)
47
51
  ```
48
52
 
49
- You get the edited draft, a before-and-after score, and the flagged phrases quoted with
50
- what needed work. `/zero-slop inspect (your writing)` reviews without rewriting.
51
- For a folder, `slopscore.py --batch drafts/ --gate 25` exits non-zero above the threshold
52
- and drops into CI.
53
+ You get the edited draft, a before-and-after score, and the flagged phrases quoted with why. `/zero-slop inspect (your writing)` reviews without rewriting.
54
+ For a folder, `slopscore.py --batch drafts/ --gate 25` fails the build above the threshold.
55
+
53
56
  ## The slop that Zero Slop catches
54
57
 
55
58
  290 weighted patterns and a 96-term lexicon, including:
56
59
 
57
- 1. **Binary contrasts.** "It's not X. It's Y."
58
- 2. **Throat-clearing openers.** "Here's the thing," "Let me be clear"
59
- 3. **Faux-insight setups.** "What nobody tells you," "The part everyone misses"
60
- 4. **Colon reveals.** "The best part: it learns."
61
- 5. **Dramatic fragments.** "That's it. That's the whole thing."
62
- 6. **Superficial analysis.** "highlighting the team's commitment to innovation"
63
- 7. **Importance puffery.** "marks a pivotal moment," "a testament to"
64
- 8. **Weasel attribution.** "experts agree," "studies show"
65
- 9. **Synonym cycling.** The agent, the assistant, the tool, all one thing.
66
- 10. **Marketing riders.** "robust" and "leverage" score only beside a marketing trigger, so a runbook stays quiet.
67
-
68
- A reading pass covers what no pattern reaches, where the defect is the document rather
69
- than any span: one shape repeated seven times, statistics piled into a paragraph,
60
+ 1. Binary contrasts: "It's not X. It's Y."
61
+ 2. Throat-clearing openers: "Here's the thing," "Let me be clear"
62
+ 3. Faux-insight setups: "What nobody tells you," "The part everyone misses"
63
+ 4. Colon reveals: "The best part: it learns."
64
+ 5. Dramatic fragments: "That's it. That's the whole thing."
65
+ 6. Superficial analysis: "highlighting the team's commitment to innovation"
66
+ 7. Importance puffery: "marks a pivotal moment," "a testament to"
67
+ 8. Weasel attribution: "experts agree," "studies show"
68
+ 9. Synonym cycling: the agent, the assistant, the tool, all one thing.
69
+ 10. Marketing riders: "robust" and "leverage" score only beside a marketing trigger, so a runbook stays quiet.
70
+
71
+ A reading pass covers defects of the whole document, which no span pattern reaches: one shape repeated seven times, statistics piled into a paragraph,
70
72
  paragraphs that shuffle without loss. [`references/eval.md`](references/eval.md) has all
71
- 76 checks.
73
+ 80 checks.
72
74
 
73
75
  Human writing scored 9 to 21 in [`data/corpus/must-not-flag/`](data/corpus/must-not-flag/);
74
76
  unedited AI drafts averaged 77 across [`bench/examples.json`](bench/examples.json).
75
- Neither number claims to identify who wrote the text.
77
+
76
78
 
77
79
  ## How it works
78
80
 
79
81
  ![Eight editorial roles, a private learning loop, and a separate release review](assets/engine.svg)
80
82
 
81
- Eight roles form one workflow. Each is a job rather than a service: a single model can handle
82
- several of them, each as its own pass, so nothing grades its own output.
83
+ Eight roles form one workflow. Each is a job rather than a service, run as its own pass so nothing grades its own output. The research supports the checks, not the number eight, which is an engineering choice.
83
84
 
84
85
  | Role | Who does it | What happens |
85
86
  |---|---|---|
@@ -92,7 +93,7 @@ several of them, each as its own pass, so nothing grades its own output.
92
93
  | 7. Verifier | Local tools and your AI assistant | Compares text with source for facts, meaning, qualifiers, voice, format, structure. |
93
94
  | 8. Fresh-eyes finalizer | Fresh AI pass | Reads the verified text as a first-time reader, applying only safe polish. Any final polish restarts the final checks; the same text must return unchanged before release. |
94
95
 
95
- Eight is an engineering choice; research supports the individual checks. Studies find
96
+ Studies find
96
97
  [predictable wording](https://arxiv.org/abs/2301.11305) and
97
98
  [overused vocabulary](https://arxiv.org/abs/2406.07016) in machine text, and authorship
98
99
  detectors can [misclassify non-native English](https://arxiv.org/abs/2304.02819). Local
@@ -100,12 +101,10 @@ tools use only Python's standard library.
100
101
 
101
102
  ## Private learning from your edits
102
103
 
103
- Nothing is learned until you hand over both versions: what the assistant produced and
104
- what you kept. Zero Slop watches nothing on its own: no file monitoring, no browser
104
+ Learning starts only when you hand over both versions: the original output and your edited version. Zero Slop watches nothing on its own: no file monitoring, no browser
105
105
  hooks, no reaching into where you publish.
106
106
 
107
- A phrase must disappear from three unrelated pieces before it becomes a private rule; a
108
- single word needs five. Each must be new and stay silent on known-human text. Private
107
+ Private
109
108
  data stays under `$ZERO_SLOP_HOME`.
110
109
 
111
110
  This is human-in-the-loop online learning. It never retrains Claude, GPT, or another
@@ -113,13 +112,13 @@ model, and involves no neural training or RLHF. A profile can exempt existing wa
113
112
  words when selected by name; it does not learn cadence, tone, or a complete writing
114
113
  style.
115
114
 
116
-
117
115
  ## What's inside
118
116
 
119
117
  [`SKILL.md`](SKILL.md) has the workflow and [`references/eval.md`](references/eval.md) the
120
- 76 checks. [`scripts/slopscore.py`](scripts/slopscore.py) is the meter and fact gate,
118
+ 80 checks. [`scripts/slopscore.py`](scripts/slopscore.py) is the meter and fact gate,
121
119
  with [`scripts/register.py`](scripts/register.py) running the reading pass.
122
120
  [`bench/README.md`](bench/README.md) documents every benchmark with its limits.
121
+ [zero-slop.ai](https://zero-slop.ai) has the same reference as browsable pages, plus the [benchmark in full](https://zero-slop.ai/benchmark/).
123
122
 
124
123
  ## Evidence
125
124
 
@@ -127,7 +126,7 @@ with [`scripts/register.py`](scripts/register.py) running the reading pass.
127
126
 
128
127
  We reran Zero Slop, [avoid-ai-writing](https://github.com/conorbronsdon/avoid-ai-writing),
129
128
  [no-ai-slop](https://github.com/petergyang/no-ai-slop) and
130
- [humanizer](https://github.com/blader/humanizer) on the same 18 obvious drafts, each with
129
+ [humanizer](https://github.com/blader/humanizer) on the same samples, each with
131
130
  GPT-5.4, high reasoning, batches of three, and its pinned instructions.
132
131
 
133
132
  | Method | Mean writing score ↓ | Passed all Zero Slop checks | Important details kept | Average length change |
@@ -140,28 +139,31 @@ GPT-5.4, high reasoning, batches of three, and its pinned instructions.
140
139
 
141
140
  ![Fresh same-model editing replay on 18 drafts](assets/bench-search-rewrites.png)
142
141
 
143
- Those checks are Zero Slop's own, so we also ran a method-hidden comparison against the pinned
144
- incumbent. The GPT-5.4 reviewer favored Zero Slop on 13 drafts and avoid-ai-writing on 3,
145
- with 2 unresolved; the passes agreed on 16 of 18. Our source check cleared 18/18 of our
146
- rewrites and 16/18 of the incumbent's. On mean score across this second set we lost,
147
- 17.8 to 17.0.
142
+
148
143
 
149
144
  ![Method-hidden editorial preference on 18 drafts](assets/bench-incumbent-hidden.png)
150
145
 
146
+ Cross-checks the tools didn't build: the AIStoryHub checker's clean
147
+ rates, and a method-hidden quality ranking.
148
+
149
+ ![External checker clean rates per method](assets/bench-external-checker.png)
150
+
151
+ ![Method-hidden quality ranking, lower is better](assets/bench-blind-quality.png)
152
+
151
153
  This is a small LLM-reviewed regression study. It measures neither field accuracy nor a
152
154
  universal ranking. Drafts, mappings, verdicts, hashes and limits:
153
155
  [`bench/incumbent-blind-replay/`](bench/incumbent-blind-replay/). On the 38-item
154
- editorial panel ([`bench/README.md`](bench/README.md)), v2.6.1 matched the prior 84.2% result. Its four new checks left all 114
155
- frozen document scores unchanged and 18 human controls clear, while the four target cases
156
- moved from 9.5 to between 30.7 and 65.1. Median throughput was 0.03% lower across 12
157
- runs, which is local timing noise and no kind of speed claim.
156
+ editorial panel ([`bench/README.md`](bench/README.md)), v2.7.7 matched the prior 84.2% result
157
+ with every frozen document score unchanged, all 18 human controls clear and all 18 search
158
+ cases still caught: the release moves what the gate asks and leaves the meter
159
+ untouched. Median throughput was 3.51% lower across 12 runs, which is local timing
160
+ noise and no kind of speed claim.
158
161
 
159
162
  ### Speed
160
163
 
161
- On one Apple silicon Mac: 1,000 documents in 1.9958 seconds (501.1 per second), a
162
- 15,201-word document in 0.3223 seconds, slowest stress case 2.4438 seconds, an 8,000-word
163
- learning pass 0.1627 seconds. Editing time sits outside these numbers. None is a
164
- service-level guarantee.
164
+ On one Apple silicon Mac: 1,000 documents in 2.1956 seconds (455.5 per second), a
165
+ 15,201-word document in 0.3225 seconds, the slowest stress case in 2.2577 seconds, an
166
+ 8,000-word learning pass in 0.1811 seconds. Editing time is excluded; expect different numbers on different machines.
165
167
 
166
168
  ### Current models
167
169
 
@@ -175,21 +177,19 @@ The pinned [RAID+](https://huggingface.co/datasets/markstanl/RAID-Plus) sample y
175
177
  | Gemma 3 27B | 1,634 | 21.6 | 30.4% |
176
178
  | Llama 3.3 70B | 2,000 | 25.5 | 41.7% |
177
179
 
178
- RAID+ labels capture which model produced a text and say nothing about quality. In
180
+ RAID+ labels capture which model produced a text, not how well it reads. In
179
181
  Beemo, raw responses averaged
180
- 30.2, expert edits 25.3, human answers 20.0. Neither dataset carries quality labels.
181
-
182
+ 30.2, expert edits 25.3, human answers 20.0. Neither dataset has quality labels.
182
183
 
183
184
  ## Where Zero Slop came from
184
185
 
185
- Zero Slop stands on no-ai-slop, humanizer, de-slop, stop-slop, unslop-text and
186
- avoid-ai-writing, adding a writing score, source protection, separate editorial passes,
186
+ Zero Slop enhances work done by no-ai-slop, humanizer, de-slop, stop-slop, unslop-text and
187
+ avoid-ai-writing, by adding a slop score, source protection, separate editorial passes,
187
188
  private learning, portfolio analysis and release tests.
188
189
 
189
190
  ![Documented capabilities at pinned repository versions](assets/competitor-capabilities.png)
190
191
 
191
- The chart records which features each project documents, and says nothing about writing
192
- quality. Reproduce the shipped checks:
192
+ The chart records which features each project documents. It says nothing about writing quality and is not a claim about which tool writes better. Reproduce by using these tests:
193
193
 
194
194
  ```sh
195
195
  python3 tests/test_all.py
@@ -198,7 +198,6 @@ python3 scripts/register.py --selftest
198
198
  python3 bench/make_charts.py --check
199
199
  ```
200
200
 
201
-
202
201
  ## License
203
202
 
204
203
  MIT
package/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: zero-slop
3
3
  license: MIT
4
4
  metadata:
5
- version: "2.7.1"
5
+ version: "2.7.7"
6
6
  author: manavmishra
7
7
  description: Turn drafts into sharp, natural prose or inspect them without rewriting. Zero Slop runs inside the user's existing AI assistant; Claude, GPT, or another compatible model reads and edits in context while local tools point to exact phrases and protect the source. Use when the user asks to humanize or de-slop writing, inspect AI-sounding patterns, fix text that reads like ChatGPT, polish outward-facing prose, draft social or LinkedIn content, or apply a final quality check to prose the agent generated. The workflow preserves facts, voice, and format and learns privately from repeated, reason-labelled human edits.
8
8
  ---
@@ -165,7 +165,9 @@ conversation, a linked or supplied sample, or none). A sample-built, named
165
165
  scoring profile under `$ZERO_SLOP_HOME/voices/` contains only existing
166
166
  watchlist-word exceptions. It does not contain the sample or capture the
167
167
  writer's cadence, syntax, humor, or tone. Skip code blocks, quotes, and legal
168
- boilerplate.
168
+ boilerplate — but only the quoted or boilerplate words themselves: the authored frame
169
+ around them (labels, emphasis, list geometry) is the writer's prose and stays in
170
+ scope.
169
171
  **Record the input format** — pasted text, .md, .docx, .pdf,
170
172
  .html, .txt, a JSON field — because the output must come back in that same
171
173
  format (step 9). Take a form inventory: decide which parts of the document are
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ // The npm package shipped four versions with no bin, no main and no scripts, so
3
+ // `npx zero-slop` did nothing and every real install went through the `skills`
4
+ // CLI against GitHub instead. That left the registry download count measuring
5
+ // mirrors rather than people. This is the executable half: it installs the same
6
+ // runtime the tarball already carries, and runs the scorer without a checkout.
7
+
8
+ import { cp, mkdir, readFile, rm, stat } from "node:fs/promises";
9
+ import { spawn } from "node:child_process";
10
+ import { homedir } from "node:os";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
15
+ const PAYLOAD = ["SKILL.md", "references", "scripts", "data"];
16
+
17
+ // Where each harness reads global skills from. `skills add` knows this mapping
18
+ // too; it is repeated here so the package works without that CLI installed.
19
+ const HARNESS_DIRS = {
20
+ claude: ".claude/skills",
21
+ codex: ".codex/skills",
22
+ cursor: ".cursor/skills",
23
+ opencode: ".config/opencode/skills",
24
+ zed: ".config/zed/skills",
25
+ };
26
+
27
+ async function version() {
28
+ const pkg = JSON.parse(await readFile(join(ROOT, "package.json"), "utf8"));
29
+ return pkg.version;
30
+ }
31
+
32
+ function usage(v) {
33
+ return `zero-slop ${v} — score AI-sounding prose 0-100 and edit it out.
34
+
35
+ Usage
36
+ npx zero-slop install [--harness <name>] [--dir <path>] [--force]
37
+ npx zero-slop score <file>... [-- <slopscore flags>]
38
+ npx zero-slop where
39
+ npx zero-slop --version
40
+
41
+ Install targets
42
+ --harness ${Object.keys(HARNESS_DIRS).join(", ")} (default: claude)
43
+ --dir install into an explicit directory instead
44
+ --force overwrite an existing installation
45
+
46
+ Examples
47
+ npx zero-slop install # ~/.claude/skills/zero-slop
48
+ npx zero-slop install --harness codex
49
+ npx zero-slop score draft.md
50
+ npx zero-slop score drafts/ -- --batch --gate 25
51
+
52
+ Docs: https://zero-slop.ai Source: https://github.com/manavmishra/ZeroSlop`;
53
+ }
54
+
55
+ function parseArgs(argv) {
56
+ const flags = {};
57
+ const rest = [];
58
+ let passthrough = [];
59
+ for (let i = 0; i < argv.length; i += 1) {
60
+ const a = argv[i];
61
+ if (a === "--") {
62
+ passthrough = argv.slice(i + 1);
63
+ break;
64
+ }
65
+ if (a === "--force") flags.force = true;
66
+ else if (a === "--harness" || a === "--dir") flags[a.slice(2)] = argv[++i];
67
+ else if (a.startsWith("--harness=")) flags.harness = a.split("=")[1];
68
+ else if (a.startsWith("--dir=")) flags.dir = a.split("=")[1];
69
+ else rest.push(a);
70
+ }
71
+ return { flags, rest, passthrough };
72
+ }
73
+
74
+ function targetDir(flags) {
75
+ if (flags.dir) return resolve(flags.dir);
76
+ const harness = flags.harness ?? "claude";
77
+ const base = HARNESS_DIRS[harness];
78
+ if (!base) {
79
+ throw new Error(
80
+ `unknown harness "${harness}". Known: ${Object.keys(HARNESS_DIRS).join(", ")}`,
81
+ );
82
+ }
83
+ return join(homedir(), base, "zero-slop");
84
+ }
85
+
86
+ async function exists(p) {
87
+ try {
88
+ await stat(p);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ async function install(flags) {
96
+ const dest = targetDir(flags);
97
+ if ((await exists(dest)) && !flags.force) {
98
+ console.error(
99
+ `zero-slop is already installed at ${dest}\n` +
100
+ `Re-run with --force to overwrite it, or --dir to install elsewhere.`,
101
+ );
102
+ return 1;
103
+ }
104
+ // Replace rather than merge: a stale reference file left behind by an older
105
+ // version is a silent behaviour change, which is the failure this avoids.
106
+ await rm(dest, { recursive: true, force: true });
107
+ await mkdir(dest, { recursive: true });
108
+ for (const entry of PAYLOAD) {
109
+ await cp(join(ROOT, entry), join(dest, entry), { recursive: true });
110
+ }
111
+ console.log(`Installed zero-slop ${await version()} into ${dest}`);
112
+ console.log("Restart your agent, then run: /zero-slop (your writing)");
113
+ return 0;
114
+ }
115
+
116
+ function runScorer(args) {
117
+ return new Promise((resolvePromise) => {
118
+ const script = join(ROOT, "scripts", "slopscore.py");
119
+ const child = spawn("python3", [script, ...args], { stdio: "inherit" });
120
+ child.on("error", (err) => {
121
+ if (err.code === "ENOENT") {
122
+ console.error(
123
+ "python3 was not found on PATH. The scorer is a standard-library " +
124
+ "Python program and needs Python 3 to run.",
125
+ );
126
+ resolvePromise(127);
127
+ return;
128
+ }
129
+ console.error(err.message);
130
+ resolvePromise(1);
131
+ });
132
+ child.on("close", (code) => resolvePromise(code ?? 0));
133
+ });
134
+ }
135
+
136
+ async function main() {
137
+ const argv = process.argv.slice(2);
138
+ const { flags, rest, passthrough } = parseArgs(argv);
139
+ const command = rest[0];
140
+ const v = await version();
141
+
142
+ if (argv.includes("--version") || argv.includes("-v")) {
143
+ console.log(v);
144
+ return 0;
145
+ }
146
+ if (!command || argv.includes("--help") || argv.includes("-h") || command === "help") {
147
+ console.log(usage(v));
148
+ return command || argv.length ? 0 : 0;
149
+ }
150
+ if (command === "install") return install(flags);
151
+ if (command === "where") {
152
+ console.log(targetDir(flags));
153
+ return 0;
154
+ }
155
+ if (command === "score") {
156
+ const files = rest.slice(1);
157
+ if (!files.length && !passthrough.length) {
158
+ console.error("score needs at least one file. See: npx zero-slop --help");
159
+ return 2;
160
+ }
161
+ return runScorer([...files, ...passthrough]);
162
+ }
163
+
164
+ console.error(`unknown command "${command}". See: npx zero-slop --help`);
165
+ return 2;
166
+ }
167
+
168
+ main()
169
+ .then((code) => process.exit(code))
170
+ .catch((err) => {
171
+ console.error(err.message);
172
+ process.exit(1);
173
+ });
@@ -0,0 +1,21 @@
1
+ # Quarterly metrics review
2
+
3
+ ## Traffic
4
+
5
+ Sessions rose from 41,200 to 48,900 after the March relaunch, with mobile
6
+ carrying most of the gain. Neither figure includes bot traffic.
7
+
8
+ ## Revenue
9
+
10
+ Subscription revenue reached $212,000 against a $195,000 target, driven by the
11
+ annual-plan discount that ran through April. None of these numbers are audited.
12
+
13
+ ## Support
14
+
15
+ Median first-response time fell from 9 hours to 4 after the queue rework. No
16
+ figure here covers weekend tickets.
17
+
18
+ ## Hiring
19
+
20
+ Two of the three open platform roles closed in under six weeks. Neither offer
21
+ has been announced.
@@ -0,0 +1,19 @@
1
+ # What the logging migration cost
2
+
3
+ The economics are simple. Storage is cheap and cardinality is not, so a
4
+ pipeline that indexes every label pays for the labels rather than the bytes.
5
+
6
+ We ingested 4TB a day across 240 services. The dangerous part is that the bill
7
+ arrived monthly while the cardinality grew hourly, so the first three alerts
8
+ looked like billing errors.
9
+
10
+ Sampling debug lines at 1% took ingest to 900GB a day. The gap between the two
11
+ figures has a simple cause: 78% of the volume came from one library's retry
12
+ logging, which nobody had read since the library shipped.
13
+
14
+ The score has limits worth stating. It counts what it can see, and the rest is
15
+ the reader's judgment.
16
+
17
+ One honest caveat before the numbers: this is a single fleet over one quarter,
18
+ and the shape of the saving depends on how your services log. Those are the
19
+ ones that matter when you plan your own migration.
@@ -0,0 +1,10 @@
1
+ # Release review checklist
2
+
3
+ Before every release the platform team walks the same four gates:
4
+
5
+ 1. **Latency.** "p99 under 200ms on the checkout path."
6
+ 2. **Rollback.** "One command, under five minutes, no data loss."
7
+ 3. **Access.** "No new scopes without a security sign-off."
8
+ 4. **Comms.** "Status page drafted before the deploy starts."
9
+
10
+ The quoted bars come from the SRE handbook and have not changed since March.
@@ -73,6 +73,70 @@
73
73
  "span": "Different teams, different fixes, same bottleneck."
74
74
  }
75
75
  ]
76
+ },
77
+ {
78
+ "file": "caveat-template.md",
79
+ "expect": [
80
+ {
81
+ "check": "Robotic symmetry",
82
+ "span": "Neither figure includes bot traffic"
83
+ }
84
+ ]
85
+ },
86
+ {
87
+ "file": "label-period-list.md",
88
+ "expect": [
89
+ {
90
+ "check": "List-label periods and bare-noun bullets",
91
+ "span": "**Latency.**"
92
+ }
93
+ ]
94
+ },
95
+ {
96
+ "file": "unnamed-validator.md",
97
+ "expect": [
98
+ {
99
+ "check": "Weasel attribution",
100
+ "span": "An external audit"
101
+ },
102
+ {
103
+ "check": "Weasel attribution",
104
+ "span": "A\nthird-party checker"
105
+ }
106
+ ]
107
+ },
108
+ {
109
+ "file": "classifier-scaffolding.md",
110
+ "expect": [
111
+ {
112
+ "metric": "classifier_scaffolding",
113
+ "span": "The economics are simple"
114
+ },
115
+ {
116
+ "metric": "classifier_scaffolding",
117
+ "span": "The dangerous part is"
118
+ },
119
+ {
120
+ "metric": "classifier_scaffolding",
121
+ "span": "has a simple cause"
122
+ },
123
+ {
124
+ "metric": "classifier_scaffolding",
125
+ "span": "limits worth stating"
126
+ },
127
+ {
128
+ "metric": "classifier_scaffolding",
129
+ "span": "One honest caveat"
130
+ },
131
+ {
132
+ "metric": "classifier_scaffolding",
133
+ "span": "the ones that matter"
134
+ },
135
+ {
136
+ "check": "Interpretive metadiscourse",
137
+ "span": "It counts what it can see, and the rest is the reader's judgment."
138
+ }
139
+ ]
76
140
  }
77
141
  ]
78
142
  }
@@ -0,0 +1,8 @@
1
+ # Migration results
2
+
3
+ We moved the billing service to the new queue in April. Median settlement time
4
+ fell from 41 seconds to 9, and the duplicate-charge rate dropped to zero across
5
+ 1.2 million transactions.
6
+
7
+ An external audit reviewed the cutover and found no unresolved issues. A
8
+ third-party checker also rated the new pipeline clean on every run it sampled.
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "zero-slop",
3
- "version": "2.7.1",
3
+ "version": "2.7.7",
4
4
  "description": "An Agent Skill that scores AI-sounding prose 0-100 locally and rewrites it without losing a fact. Runs inside Claude Code, Codex, Cursor, Warp, Zed and other SKILL.md agents. Offline, zero dependencies, MIT.",
5
+ "bin": {
6
+ "zero-slop": "bin/zero-slop.mjs"
7
+ },
5
8
  "keywords": [
6
9
  "agent-skill",
7
10
  "agent-skills",
@@ -32,6 +35,7 @@
32
35
  "url": "https://zero-slop.ai/about/"
33
36
  },
34
37
  "files": [
38
+ "bin/",
35
39
  "SKILL.md",
36
40
  "references/",
37
41
  "scripts/",
@@ -30,6 +30,11 @@ Scoring this file is meaningless: it quotes tells as examples, so the meter flag
30
30
  own catalogue. That is the documented exception for material quoted as an example, and
31
31
  it is a good candidate for `data/corpus/must-not-flag/`.
32
32
 
33
+ The exception covers only the quoted words. The authored frame around a quotation —
34
+ the label that introduces it, emphasis on that label, the list geometry it sits in —
35
+ is the writer's own prose and every check here applies to it. A line is never exempt
36
+ merely because part of it is quoted.
37
+
33
38
  ## A. Register, which the meter cannot reach
34
39
 
35
40
  Run every item on every draft, including one that scored clear. Record the counts in
@@ -77,10 +82,23 @@ the report even when they are zero.
77
82
  significance: highlighting, underscoring, reflecting, showcasing.
78
83
  15. **Importance puffery.** "Marks a pivotal moment." "Stands as a testament." State
79
84
  the fact and let the reader weigh it.
80
- 16. **Interpretive metadiscourse.** Lines that step outside the subject to tell the
81
- reader what to notice or how much weight to give it.
85
+ 16. **Interpretive metadiscourse. Count per 1,000 words: ___** Lines that step outside
86
+ the subject to tell the reader what to notice or how much weight to give it. Two
87
+ forms, and the flat one is the one that survives every other channel. Theatrical:
88
+ "Here's the detail that matters:" Flat: "The economics are simple." "The dangerous
89
+ part is that slop feels fast." "The score has limits worth stating." "One honest
90
+ caveat before the ranking." Each grades, previews or ranks the point instead of
91
+ delivering it, and each reads as ordinary prose, so a clear writing score is no
92
+ evidence at all. Test every candidate by deletion: if the sentence after it still
93
+ lands, the classifier was scaffolding. Anchored stems are counted for you; the
94
+ shapes no stem reaches are yours to find, because whether a sentence earns its
95
+ claim is a judgment and not a match. A zero on the anchored count is not an
96
+ answer to this check and never closes it: the stems match the shapes they were
97
+ built from, and this family's whole character is that it arrives in new ones.
82
98
  17. **Weasel attribution.** "Studies show." "Experts agree." Name the source, or flag
83
- it for the writer. Never invent one.
99
+ it for the writer. Never invent one. An unnamed validator is the same defect —
100
+ "an external checker", "a third-party audit", "an independent review" — name the
101
+ tool or the auditor, or flag it.
84
102
  18. **Adjective inflation and hollow intensifiers.** "a real improvement", "actual
85
103
  results", "a genuine breakthrough", "true value". Delete the adjective; the noun
86
104
  carries the claim or it does not. Distinct from empty adverbs (next): adverbs pad
@@ -176,6 +194,17 @@ the report even when they are zero.
176
194
  47. **Wall-of-text reply.** Paragraphing that hides a sequence the reader needs. A
177
195
  long narrative paragraph is not a wall of text merely because it is long; the tell
178
196
  is a buried list of steps or options.
197
+ 47a. **Compression standing in for editing. `reorder_ratio`: ___** Cutting and
198
+ reordering are different edits and they do not produce the same draft. Subtraction
199
+ leaves every surviving sentence exactly where the model put it, so a shorter draft
200
+ can read more machine-shaped than the one it replaced; a measured case scored the
201
+ original 83 for predictability, the compressed rewrite 92, and the reordered
202
+ rewrite 58. A ratio near zero on a rewrite that cut a lot of words means the ladder
203
+ delivered its lexicon and formatting rungs and skipped its order rung. That is not
204
+ automatically wrong: a draft whose argument already arrives in the right sequence
205
+ should keep it, and shuffling a sound structure to move a number is exactly the
206
+ over-correction hard rule 3 forbids. What the check forbids is not noticing. Say
207
+ which one happened and why the order you shipped is the right one.
179
208
 
180
209
  ## C. Fidelity
181
210
 
@@ -196,6 +225,18 @@ the report even when they are zero.
196
225
  source, never a word list.
197
226
  53. **Non-prose untouched.** Code, front matter, tables, blockquotes, identifiers,
198
227
  paths, and heading hierarchy intact.
228
+ 53a. **Every dropped figure ruled, not defaulted.** The gate protects every number
229
+ it sees and cannot tell a measured fact from a flourish, because the difference
230
+ is whether a source stands behind it and that lives in the document, not in the
231
+ digits. So it hands you each dropped figure with the sentence it came from
232
+ (`figure_evidence`) and waits. Rule on each one: a figure with a source, a unit,
233
+ a baseline or a citation must be restored; an unsourced multiplier or statistic
234
+ doing an intensifier's job -- "the 10x move", "tenfold", "~70% of pilots fail" --
235
+ was right to cut, and you record that ruling rather than letting the gate assume
236
+ it. Never invent a citation to keep a number alive. Unruled means unresolved:
237
+ the default is that the figure stays, so silence here is a failed check and not
238
+ a pass. Both errors are real. Restoring a flourish is how a rewrite ships fake
239
+ precision, and dropping a measurement is how it ships a lie.
199
240
 
200
241
  ## D. Voice and readability
201
242
 
@@ -38,6 +38,7 @@ BUDGETS = {
38
38
  "subtractive_contrast": (6.0, 3),
39
39
  "comma_series": (26.0, 8),
40
40
  "significance_scaffolding": (0.0, 1),
41
+ "classifier_scaffolding": (1.5, 1),
41
42
  "inanimate_agent": (4.0, 2),
42
43
  "repeated_openings": (3.0, 2),
43
44
  # Added after a three-way audit found eight families the reading pass missed
@@ -71,6 +72,29 @@ RX_SIGNIFICANCE = re.compile(
71
72
  re.I,
72
73
  )
73
74
 
75
+ # The mild half of announced significance: a clause that grades, previews or
76
+ # ranks the point instead of delivering it. RX_SIGNIFICANCE above catches the
77
+ # theatrical form ("Here's the detail that matters:"); these are the flat
78
+ # classifiers that read as ordinary prose and scored clear on every channel --
79
+ # "The economics are simple", "the ones that matter", "limits worth stating".
80
+ # Found by running a competing skill over ten already-clean pages, so the
81
+ # ratchet applies: the anchors ship here and data/corpus/must-flag keeps them
82
+ # honest. The shapes no anchor reaches are asked of the reader in eval.md A11,
83
+ # because whether a sentence earns its claim is a judgment, not a match.
84
+ # Wrapped prose is still prose: the separators are \s+ so a tell that happens to
85
+ # straddle a line break is still counted.
86
+ RX_CLASSIFIER = re.compile(
87
+ r"\b(?:the\s+\w+\s+(?:is|are|was|were)\s+simple"
88
+ r"|(?:has|have|had)\s+a\s+simple\s+\w+"
89
+ r"|the\s+(?:dangerous|important|interesting|tricky|hard|scary|surprising|real)"
90
+ r"\s+(?:part|thing|bit|point|question)\s+(?:here\s+)?is"
91
+ r"|worth\s+(?:stating|sitting\s+with|pausing\s+on|dwelling\s+on)"
92
+ r"|the\s+ones?\s+that\s+(?:actually\s+)?matters?"
93
+ r"|(?:one|an)\s+honest\s+(?:caveat|note|admission|answer)"
94
+ r"|that\s+is\s+the\s+(?:finding|part|point)\s+worth)\b",
95
+ re.I,
96
+ )
97
+
74
98
  # Inanimate subjects performing human verbs. no-ai-slop catches this family by
75
99
  # asking; here it is the lexically anchored subset of it.
76
100
  RX_INANIMATE = re.compile(
@@ -252,6 +276,7 @@ def measure(text: str) -> dict:
252
276
  subtractive = [" ".join(m.split()) for m in RX_SUBTRACTIVE.findall(prose)]
253
277
  series = RX_SERIES.findall(prose)
254
278
  significance = [" ".join(m.split()) for m in RX_SIGNIFICANCE.findall(prose)]
279
+ classifier = [" ".join(m.group(0).split()) for m in RX_CLASSIFIER.finditer(prose)]
255
280
  inanimate = [" ".join(m.split()) for m in RX_INANIMATE.findall(prose)]
256
281
 
257
282
  openings = sentence_openings(prose)
@@ -290,6 +315,7 @@ def measure(text: str) -> dict:
290
315
  "subtractive_contrast": {"count": len(subtractive), "per_1k": per_k(len(subtractive)), "hits": subtractive[:12]},
291
316
  "comma_series": {"count": len(series), "per_1k": per_k(len(series))},
292
317
  "significance_scaffolding": {"count": len(significance), "per_1k": per_k(len(significance)), "hits": significance[:6]},
318
+ "classifier_scaffolding": {"count": len(classifier), "per_1k": per_k(len(classifier)), "hits": classifier[:6]},
293
319
  "inanimate_agent": {"count": len(inanimate), "per_1k": per_k(len(inanimate)), "hits": inanimate[:8]},
294
320
  "repeated_openings": {"count": len(repeated), "per_1k": per_k(len(repeated)), "hits": repeated[:6]},
295
321
  "paragraph_uniformity": uniformity,
@@ -319,6 +345,7 @@ LABEL = {
319
345
  "subtractive_contrast": "Binary contrasts",
320
346
  "comma_series": "Comma-series density",
321
347
  "significance_scaffolding": "Announced significance",
348
+ "classifier_scaffolding": "Graded not delivered (stems)",
322
349
  "inanimate_agent": "Inanimate subjects, human verbs",
323
350
  "repeated_openings": "Repeated sentence openings",
324
351
  }
@@ -398,6 +425,13 @@ AUTO_ANSWERED = {
398
425
  "comma-series density": "comma_series",
399
426
  "announced significance": "significance_scaffolding",
400
427
  "significance scaffolding": "significance_scaffolding",
428
+ # Interpretive metadiscourse is deliberately NOT here. A density metric can
429
+ # answer "how often does this shape appear"; it cannot answer "does this
430
+ # sentence earn its claim", and the anchored stems below match only the
431
+ # shapes they were built from. Auto-answering the check with them scored 0
432
+ # on a draft carrying "What's easy to miss:" and "This is the insight that
433
+ # changed how I think about..." -- the count silenced the question instead
434
+ # of answering it. The reader answers A16; the stems only give a head start.
401
435
  }
402
436
  SKIP_SECTIONS = {"C"} # owned by slopscore --fidelity
403
437
 
@@ -427,10 +461,18 @@ def load_checks(path: pathlib.Path | None = None) -> list[dict]:
427
461
  "id": f"{section}{item.group(1)}",
428
462
  "section": section,
429
463
  "title": title,
430
- "ask": item.group(3).strip(),
464
+ # Collected as a list and joined below. Rebuilding the string on
465
+ # every continuation line reread the whole ask to append six words.
466
+ "ask": [item.group(3).strip()],
431
467
  }
432
- elif current and line.startswith(" "):
433
- current["ask"] = (current["ask"] + " " + line.strip()).strip()
468
+ # Continuation lines clear the item number by three spaces on some items
469
+ # and four on others. Testing for four dropped every three-space item and
470
+ # truncated its ask to whatever fit beside the title: A2's ask parsed as
471
+ # the single word "The". Any indent counts now, and a whitespace-only
472
+ # line still falls through to the terminator below instead of appending
473
+ # nothing and holding the item open.
474
+ elif current and line.startswith((" ", "\t")) and line.strip():
475
+ current["ask"].append(line.strip())
434
476
  elif current and not line.strip():
435
477
  checks.append(current)
436
478
  current = None
@@ -438,6 +480,7 @@ def load_checks(path: pathlib.Path | None = None) -> list[dict]:
438
480
  checks.append(current)
439
481
 
440
482
  for c in checks:
483
+ c["ask"] = " ".join(c["ask"]).strip()
441
484
  low = c["title"].lower()
442
485
  c["auto"] = next((v for k, v in AUTO_ANSWERED.items() if k in low), None)
443
486
  c["skip"] = c["section"] in SKIP_SECTIONS
@@ -447,6 +490,7 @@ def load_checks(path: pathlib.Path | None = None) -> list[dict]:
447
490
  def read_packet(text: str, name: str) -> dict:
448
491
  """Emit the reading brief. The host model answers it; nothing here guesses."""
449
492
  prose = prose_of(text)
493
+ checks = load_checks() # one parse feeds all three views below
450
494
  paras = []
451
495
  for i, para in enumerate(re.split(r"\n\s*\n", prose), 1):
452
496
  para = para.strip()
@@ -473,15 +517,15 @@ def read_packet(text: str, name: str) -> dict:
473
517
  },
474
518
  "questions": [
475
519
  {"id": c["id"], "title": c["title"], "ask": c["ask"]}
476
- for c in load_checks()
520
+ for c in checks
477
521
  if not c["skip"] and not c["auto"]
478
522
  ],
479
523
  "answered_from_measurement": [
480
524
  {"id": c["id"], "title": c["title"], "metric": c["auto"]}
481
- for c in load_checks() if c["auto"]
525
+ for c in checks if c["auto"]
482
526
  ],
483
527
  "handled_by_fidelity_gate": [
484
- {"id": c["id"], "title": c["title"]} for c in load_checks() if c["skip"]
528
+ {"id": c["id"], "title": c["title"]} for c in checks if c["skip"]
485
529
  ],
486
530
  "paragraphs": paras,
487
531
  }
@@ -545,7 +589,7 @@ def verdict(text: str, answers: dict) -> tuple[int, str]:
545
589
  out.append("")
546
590
 
547
591
  out.append(" Read by the model:")
548
- for check in [c for c in load_checks() if not c["skip"] and not c["auto"]]:
592
+ for check in checks: # same filtered list built above
549
593
  qid = check["id"]
550
594
  got = answers.get(qid)
551
595
  if not isinstance(got, dict) or got.get("answer") not in ("pass", "fail"):
@@ -45,6 +45,89 @@ class PatternData(dict):
45
45
  """JSON-compatible pattern mapping with an out-of-band compiled plan."""
46
46
 
47
47
 
48
+ # One-time note asking for a GitHub star.
49
+ #
50
+ # 448 machines cloned this in a fortnight and seventeen people had starred it,
51
+ # because nothing ever asked. The risk in fixing that is obvious: a tool whose
52
+ # whole job is deleting manipulative filler cannot itself nag, so every rule
53
+ # below is a restriction rather than a reach.
54
+ #
55
+ # - Once per machine, ever. A marker in the state directory, not a counter
56
+ # that resets.
57
+ # - Not until the third run, so it asks people who kept using it rather than
58
+ # people evaluating it once.
59
+ # - Never when the output is being read by a machine: --json, --batch,
60
+ # --gate, or any run whose stdout is not a terminal. CI logs stay clean.
61
+ # - No prompt, no keypress, no opening a browser, no network call. One line
62
+ # to stderr, so it cannot corrupt piped output even if the checks above
63
+ # were somehow wrong.
64
+ # - ZERO_SLOP_NO_NOTES=1 turns it off for good.
65
+ NOTES_FILE = HOME / "notes.json"
66
+ STAR_NOTE_AFTER_RUNS = 3
67
+
68
+
69
+ def _load_notes():
70
+ try:
71
+ with open(NOTES_FILE, encoding="utf-8") as fh:
72
+ data = json.load(fh)
73
+ return data if isinstance(data, dict) else {}
74
+ except (OSError, ValueError):
75
+ return {}
76
+
77
+
78
+ def _save_notes(state):
79
+ try:
80
+ HOME.mkdir(parents=True, exist_ok=True)
81
+ tmp = NOTES_FILE.with_suffix(".json.tmp")
82
+ with open(tmp, "w", encoding="utf-8") as fh:
83
+ json.dump(state, fh)
84
+ os.replace(tmp, NOTES_FILE)
85
+ except OSError:
86
+ pass # a read-only home must never break a score
87
+
88
+
89
+ def star_note_is_due(argv=None, isatty=None, env=None):
90
+ """Decide without writing anything, so the rule is testable in isolation."""
91
+ argv = sys.argv[1:] if argv is None else argv
92
+ env = os.environ if env is None else env
93
+ if env.get("ZERO_SLOP_NO_NOTES"):
94
+ return False
95
+ if any(flag in argv for flag in ("--json", "--batch", "--gate")):
96
+ return False
97
+ if not (sys.stdout.isatty() if isatty is None else isatty):
98
+ return False
99
+ state = _load_notes()
100
+ if state.get("star_note_shown"):
101
+ return False
102
+ return int(state.get("human_runs", 0)) + 1 >= STAR_NOTE_AFTER_RUNS
103
+
104
+
105
+ def record_human_run(argv=None, isatty=None, env=None):
106
+ """Count this run and, if it is the one, return the note to print."""
107
+ argv = sys.argv[1:] if argv is None else argv
108
+ env = os.environ if env is None else env
109
+ if env.get("ZERO_SLOP_NO_NOTES"):
110
+ return None
111
+ if any(flag in argv for flag in ("--json", "--batch", "--gate")):
112
+ return None
113
+ if not (sys.stdout.isatty() if isatty is None else isatty):
114
+ return None
115
+ state = _load_notes()
116
+ if state.get("star_note_shown"):
117
+ return None
118
+ state["human_runs"] = int(state.get("human_runs", 0)) + 1
119
+ due = state["human_runs"] >= STAR_NOTE_AFTER_RUNS
120
+ if due:
121
+ state["star_note_shown"] = True
122
+ _save_notes(state)
123
+ if not due:
124
+ return None
125
+ return ("\n If Zero Slop has been useful, a star helps people find it: "
126
+ "https://github.com/manavmishra/ZeroSlop\n"
127
+ " This is the only time you will see this. "
128
+ "ZERO_SLOP_NO_NOTES=1 silences all notes.")
129
+
130
+
48
131
  def _voice_path(name):
49
132
  """Resolve a profile name without letting it become a filesystem path."""
50
133
  if not VOICE_NAME.fullmatch(name or "") or name in (".", ".."):
@@ -159,6 +242,39 @@ def _apply_voice(base, name):
159
242
  SENT_SPLIT = re.compile(r"(?<=[.!?])[\")”’]?\s+(?=[A-Z“\"(0-9])")
160
243
  WORD = re.compile(r"[A-Za-z’']+")
161
244
 
245
+
246
+ # A quoted span longer than this is a passage, not a named tell, and stays in
247
+ # scope. Short enough to exempt "delve" or "it's not just X, it's Y"; short
248
+ # enough that quoting cannot be used to smuggle paragraphs past the meter.
249
+ QUOTE_SKIP_LIMIT = 200
250
+
251
+ _BLOCKQUOTE_SCAN_RX = re.compile(r"(?m)^[ \t]*>[ \t]?.*$")
252
+ _INLINE_QUOTE_RXS = (
253
+ re.compile(rf'"[^"\n]{{0,{QUOTE_SKIP_LIMIT}}}"'),
254
+ re.compile(rf"“[^”\n]{{0,{QUOTE_SKIP_LIMIT}}}”"),
255
+ )
256
+
257
+
258
+ def mask_quoted(text):
259
+ """Blank quoted material for the pattern meter, keeping every offset.
260
+
261
+ Naming a cliche in order to discuss it is the opposite of committing it,
262
+ and step 0 of SKILL.md has always said to skip quotes. Only the phrase
263
+ meter and the lexicon honour that: rhythm, readability, word variety and
264
+ formatting still read the quotation, because a quote a writer chose to
265
+ include is part of how the finished page reads.
266
+
267
+ Spans are replaced character for character, so sentence offsets, word
268
+ counts and hit positions are identical to the unmasked text.
269
+ """
270
+ def blank(match):
271
+ return re.sub(r"[^\n]", " ", match.group(0))
272
+
273
+ text = _BLOCKQUOTE_SCAN_RX.sub(blank, text)
274
+ for rx in _INLINE_QUOTE_RXS:
275
+ text = rx.sub(blank, text)
276
+ return text
277
+
162
278
  # Normalise only detector-evasion characters, never ordinary non-Latin prose.
163
279
  # A Cyrillic or Greek lookalike is mapped only when it appears in the same word
164
280
  # as an ASCII letter (for example, dеlvе). This keeps Russian and Greek text
@@ -418,6 +534,10 @@ def score_text(text, data, formal=False):
418
534
  if n_words >= 200 else None)
419
535
  sent_spans = _sentence_spans(text)
420
536
  sents = [text[a:b].replace("\n", " ") for a, b in sent_spans]
537
+ # Same string with quotations blanked out, used only by the phrase meter
538
+ # and the lexicon. Offsets match `text` exactly.
539
+ scan_text = mask_quoted(text)
540
+ scan_sents = [scan_text[a:b].replace("\n", " ") for a, b in sent_spans]
421
541
  hits = []
422
542
  pattern_spans = [] # (start, end, lower-rx, compiled-rx) for dedup below
423
543
 
@@ -449,10 +569,10 @@ def score_text(text, data, formal=False):
449
569
  continue
450
570
  if hints:
451
571
  if lowercase_text is None:
452
- lowercase_text = text.lower()
572
+ lowercase_text = scan_text.lower()
453
573
  if not any(hint in lowercase_text for hint in hints):
454
574
  continue
455
- for m in compiled.finditer(text):
575
+ for m in compiled.finditer(scan_text):
456
576
  hits.append({
457
577
  "cat": category, "name": name, "w": weight,
458
578
  "quote": m.group(0)[:90].strip(),
@@ -484,7 +604,7 @@ def score_text(text, data, formal=False):
484
604
  and (term in rx_lower or compiled.search(matched))
485
605
  for ps, pe, rx_lower, compiled in pattern_spans)
486
606
 
487
- candidates = [candidate for candidate in _term_candidates(text, data["lexicon"])
607
+ candidates = [candidate for candidate in _term_candidates(scan_text, data["lexicon"])
488
608
  if not _pattern_owns(candidate[:2], candidate[2], candidate[4])]
489
609
  last_end = 0
490
610
  for s, e, term, w, quote in candidates:
@@ -494,7 +614,7 @@ def score_text(text, data, formal=False):
494
614
  hits.append({"cat": "lexicon", "name": term, "w": w, "quote": quote})
495
615
  riders, triggers = data.get("riders", {}), data.get("rider_triggers", [])
496
616
  if riders:
497
- for (a, _), sent in zip(sent_spans, sents):
617
+ for (a, _), sent in zip(sent_spans, scan_sents):
498
618
  sl = sent.lower()
499
619
  if not any(t in sl for t in triggers):
500
620
  continue
@@ -941,7 +1061,11 @@ CHANNELS = [
941
1061
  # could check, and things whose invention is the failure the skill forbids.
942
1062
  FACT_RX = [
943
1063
  ("figure", r"(?<![\w.])\$?\d[\d,]*(?:\.\d+)?\s*(?:%|percent|x|bn|m|k|million|billion)?(?![\w])"),
944
- ("name", r"\b(?:[A-Z][a-z]{2,}(?:\s+[A-Z][a-z]+)*)\b"),
1064
+ # A name never spans a line break. Allowing \s+ here let a run swallow the
1065
+ # paragraph boundary after a heading -- "Leverage\n\nThe", "Mishra\n\nPaste"
1066
+ # -- and the invented run then read as a dropped entity in any rewrite that
1067
+ # repunctuated the section.
1068
+ ("name", r"\b(?:[A-Z][a-z]{2,}(?:[ \t]+[A-Z][a-z]+)*)\b"),
945
1069
  ("quote", r"[\u201c\"]([^\u201d\"]{6,120})[\u201d\"]"),
946
1070
  ("url", r"https?://\S+"),
947
1071
  ]
@@ -1014,6 +1138,46 @@ are artificial bookmark despite hey modern please researchers save unpopular wel
1014
1138
  NOT_NAME_WORDS = {word.lower() for word in NOT_NAMES} | COMMON_WORDS
1015
1139
 
1016
1140
 
1141
+ def _peel_entity(run, prose, other):
1142
+ """The entity inside a title-case run, or None if the run holds no name.
1143
+
1144
+ A capitalised ordinary word glues itself to the name that follows it --
1145
+ "With Claude", "In March", "At Acme". Discarding the whole run loses the
1146
+ entity, so the rewrite that punctuates the sentence differently gets
1147
+ reported as having dropped a name that is still sitting in it. Peel the
1148
+ leading word and re-test what remains.
1149
+ """
1150
+ while run:
1151
+ if run in NOT_NAMES or len(run) < 3:
1152
+ return None
1153
+ low = run.lower()
1154
+ tokens = re.findall(r"[a-z]+", low)
1155
+ # A title-cased run made entirely of ordinary sentence words is not an
1156
+ # entity (for example, "Shipped Tuesday").
1157
+ if tokens and all(token in NOT_NAME_WORDS for token in tokens):
1158
+ return None
1159
+ single = " " not in run
1160
+ # A capitalised common word ("Draw", "Usually", "Start"), an adverb
1161
+ # ("Finally"), or a sentence-opening gerund ("Watching") is not an
1162
+ # entity; a real name never is.
1163
+ if single and (low in COMMON_WORDS
1164
+ or low.endswith("ly") or low.endswith("ing")):
1165
+ return None
1166
+ # A word is only a name if it is never used as an ordinary lowercase
1167
+ # word -- not here, and not in the text we compare against.
1168
+ # "Under"/"Shipped" appear lowercased somewhere in normal prose;
1169
+ # "Priya"/"Acme" do not. Strip the capitalised forms first so the
1170
+ # entity cannot vouch for itself.
1171
+ head = run.split()[0]
1172
+ blob = re.sub(r"\b" + re.escape(head) + r"\b", " ", prose + " " + other)
1173
+ if not re.search(r"\b" + re.escape(head.lower()) + r"\b", blob):
1174
+ return run
1175
+ if single:
1176
+ return None
1177
+ run = run.split(" ", 1)[1]
1178
+ return None
1179
+
1180
+
1017
1181
  def facts(text, _other=""):
1018
1182
  """Checkable claims in a draft: figures, named entities, quotes, links."""
1019
1183
  # URLs contain lowercase forms of the names they point at ("acme.io" made
@@ -1040,32 +1204,8 @@ def facts(text, _other=""):
1040
1204
  for m in re.finditer(rx, urls if kind == "url" else prose):
1041
1205
  v = (m.group(1) if m.lastindex else m.group(0)).strip()
1042
1206
  if kind == "name":
1043
- if v in NOT_NAMES or len(v) < 3:
1044
- continue
1045
- low = v.lower()
1046
- tokens = re.findall(r"[a-z]+", low)
1047
- # A title-cased run made entirely of ordinary sentence words is
1048
- # not an entity (for example, "Shipped Tuesday").
1049
- if tokens and all(token in NOT_NAME_WORDS for token in tokens):
1050
- continue
1051
- # A capitalised common word ("Draw", "Usually", "Start"), an
1052
- # adverb ("Finally"), or a sentence-opening gerund ("Watching",
1053
- # "Calling") is not an entity; a real name never is.
1054
- if " " not in v and (low in COMMON_WORDS
1055
- or low.endswith("ly") or low.endswith("ing")):
1056
- continue
1057
- # A word is only a name if it is never used as an ordinary
1058
- # lowercase word — not here, and not in the text we compare
1059
- # against. "Under"/"Shipped" appear lowercased somewhere in
1060
- # normal prose; "Priya"/"Acme" do not. Multi-word entities keep
1061
- # their head token for this test.
1062
- head = v.split()[0]
1063
- # Is this token ever used as an ordinary lowercase word, here or
1064
- # in the compared text? Sentence openers are ("under load",
1065
- # "shipped tuesday"); real names never are. Strip the capitalized
1066
- # forms first so the entity cannot vouch for itself.
1067
- blob = re.sub(r"\b" + re.escape(head) + r"\b", " ", prose + " " + other)
1068
- if re.search(r"\b" + re.escape(head.lower()) + r"\b", blob):
1207
+ v = _peel_entity(v, prose, other)
1208
+ if not v:
1069
1209
  continue
1070
1210
  if kind == "figure":
1071
1211
  v = v.replace(",", "").lstrip("$").rstrip()
@@ -1263,9 +1403,50 @@ def structure_changes(before, after):
1263
1403
  return findings
1264
1404
 
1265
1405
 
1266
- def fidelity(before, after):
1406
+ # An unsourced figure -- "the 10x move", "tenfold", "~70% of pilots fail" -- is
1407
+ # an intensifier wearing a number's clothes. The gate exists to protect facts,
1408
+ # and a figure with no source behind it is not one. Protecting it anyway made
1409
+ # the gate report the honest cut as a dropped fact, and rerank sorts on
1410
+ # fidelity first, so the rewrite that KEPT the fake precision won. That is the
1411
+ # gate preserving slop, which is the opposite of its job.
1412
+ #
1413
+ # Which figures are load-bearing is a contextual judgment, so this script does
1414
+ # not make it. No pattern can separate "fell 40%" from "10x better" reliably --
1415
+ # the difference is whether a source stands behind the number, which lives in
1416
+ # the surrounding document, not in the digits. The tool's job is to hand the
1417
+ # reviewer the evidence; the ruling belongs to the assistant running the
1418
+ # verifier role (SKILL step 7, "Unsourced statistics") or to the writer. A
1419
+ # figure is protected until someone with context says otherwise, so the default
1420
+ # behaviour here is exactly as strict as it was before.
1421
+
1422
+
1423
+ def figure_contexts(text, figures):
1424
+ """Each figure with the sentence it sits in, so a reviewer can rule on it.
1425
+
1426
+ Evidence, not a verdict: the caller decides whether a dropped figure was a
1427
+ measured fact that must be restored or an unsourced flourish that was right
1428
+ to cut.
1429
+ """
1430
+ out = {}
1431
+ for figure in figures:
1432
+ for sentence in sentences(text):
1433
+ if re.search(r"\b" + re.escape(figure) + r"\b", sentence, re.I):
1434
+ out[figure] = " ".join(sentence.split())
1435
+ break
1436
+ else:
1437
+ out[figure] = ""
1438
+ return out
1439
+
1440
+
1441
+ def fidelity(before, after, adjudicated=None):
1267
1442
  """Did the rewrite keep every fact, and did it add any?
1268
1443
 
1444
+ ``adjudicated`` is the set of dropped figures a reviewer with context has
1445
+ ruled unsourced, so cutting them is an improvement rather than a loss. It
1446
+ is empty unless someone explicitly rules, which keeps the deterministic
1447
+ default strict: this function never decides on its own that a number was
1448
+ only rhetoric.
1449
+
1269
1450
  The benchmark's worst result was a rewrite that invented a feeling the
1270
1451
  author never described — the exact thing hard rule 1 forbids — and nothing
1271
1452
  in the gate measured it. Preservation is checkable; invention is the half
@@ -1275,6 +1456,8 @@ def fidelity(before, after):
1275
1456
  a, b = facts(before, after), facts(after, before)
1276
1457
  structure = structure_changes(before, after)
1277
1458
  rows, kept_all, invented_any = [], True, False
1459
+ adjudicated = set(adjudicated or ())
1460
+ unsourced, dropped_items = set(), set()
1278
1461
  def entity_tokens(entity):
1279
1462
  return {w for w in re.findall(r"[a-z]+", entity.lower())
1280
1463
  if w not in NOT_NAME_WORDS}
@@ -1298,10 +1481,26 @@ def fidelity(before, after):
1298
1481
  dropped = {e for e in a[kind] if not entity_match(e, b[kind])}
1299
1482
  added = {e for e in b[kind] if not entity_match(e, a[kind])}
1300
1483
  kept = a[kind] - dropped
1484
+ # A word capitalised once at a heading or sentence start reads
1485
+ # exactly like a product name to any lexical rule -- "Embedded
1486
+ # governance", "Models + Context = Leverage". Which one it is
1487
+ # depends on the document, so the reviewer rules and the tool
1488
+ # supplies the sentence rather than guessing.
1489
+ unsourced |= dropped & adjudicated
1490
+ dropped = dropped - adjudicated
1491
+ dropped_items |= dropped
1301
1492
  else:
1302
1493
  kept = a[kind] & b[kind]
1303
1494
  dropped = a[kind] - b[kind]
1304
1495
  added = b[kind] - a[kind]
1496
+ if kind == "figure" and dropped:
1497
+ # Figures the reviewer has ruled unsourced were right to cut,
1498
+ # so they stop failing preservation. Nothing is ruled without
1499
+ # that explicit judgment, and adding a figure is still an
1500
+ # invention however it was ruled.
1501
+ unsourced |= dropped & adjudicated
1502
+ dropped = dropped - adjudicated
1503
+ dropped_items |= dropped
1305
1504
  if not (a[kind] or b[kind]):
1306
1505
  continue
1307
1506
  rows.append((kind, kept, dropped, added))
@@ -1316,7 +1515,55 @@ def fidelity(before, after):
1316
1515
  kept_all = False
1317
1516
  invented_any = invented_any or any(row["added"] for row in structure)
1318
1517
  return {"rows": rows, "preserved": kept_all, "invented": invented_any,
1319
- "interior": new_interior, "structure": structure}
1518
+ "interior": new_interior, "structure": structure,
1519
+ "unsourced": unsourced,
1520
+ # The sentence each dropped figure came from, so whoever rules on
1521
+ # it can see whether a source stood behind the number.
1522
+ "figure_evidence": figure_contexts(before, dropped_items)}
1523
+
1524
+
1525
+ def reorder_ratio(before, after):
1526
+ """How much of the surviving material the rewrite actually moved.
1527
+
1528
+ 0.0 means every kept sentence is still in its original order; 1.0 means the
1529
+ order was inverted. Cutting and reordering are different edits with
1530
+ different results: subtraction leaves the surviving prose sitting exactly
1531
+ where the model would have put it, while moving the payoff changes what the
1532
+ reader meets first. Nothing in the gate could tell the two apart, so a
1533
+ compression-only rewrite passed every check the ladder's order rung was
1534
+ supposed to enforce.
1535
+ """
1536
+ def shingles(text):
1537
+ out = []
1538
+ for sentence in sentences(text):
1539
+ words = {w for w in re.findall(r"[a-z]{4,}", sentence.lower())
1540
+ if w not in NOT_NAME_WORDS}
1541
+ if words:
1542
+ out.append(words)
1543
+ return out
1544
+
1545
+ src, dst = shingles(before), shingles(after)
1546
+ if len(src) < 2 or len(dst) < 2:
1547
+ return 0.0
1548
+ order = []
1549
+ for target in dst:
1550
+ best, best_at = 0.0, None
1551
+ for i, source in enumerate(src):
1552
+ union = len(target | source)
1553
+ overlap = len(target & source) / union if union else 0.0
1554
+ if overlap > best:
1555
+ best, best_at = overlap, i
1556
+ if best >= 0.3 and best_at is not None:
1557
+ order.append(best_at)
1558
+ if len(order) < 2:
1559
+ return 0.0
1560
+ pairs = inversions = 0
1561
+ for i in range(len(order)):
1562
+ for j in range(i + 1, len(order)):
1563
+ pairs += 1
1564
+ if order[i] > order[j]:
1565
+ inversions += 1
1566
+ return round(inversions / pairs, 3) if pairs else 0.0
1320
1567
 
1321
1568
 
1322
1569
  # The shared rewrite-quality objective. One definition of "a better rewrite",
@@ -1326,10 +1573,18 @@ def fidelity(before, after):
1326
1573
  RW_GATE = {"email": 35, "research": 40, "professional": 40}
1327
1574
  RW_GATE_DEFAULT = 25
1328
1575
  RW_FORMAL = {"research", "professional"}
1329
- RW_WEIGHTS = {"deslop": 0.45, "gate": 0.25, "rhythm": 0.15, "length": 0.15}
1330
-
1331
-
1332
- def rewrite_score(before_text, after_text, genre=None, data=None):
1576
+ # "structure" exists because the other four terms all saturate on a draft that
1577
+ # arrives clean: deslop is ~0 when there is no slop to remove, and gate, rhythm
1578
+ # and length each cap at 1.0, so every candidate scored an identical 0.55 and
1579
+ # the ranking fell through to the fidelity tier. A meter with no opinion about
1580
+ # which rewrite is better is the reason a worse rewrite could win.
1581
+ RW_WEIGHTS = {"deslop": 0.40, "gate": 0.20, "rhythm": 0.12, "length": 0.13,
1582
+ "structure": 0.15}
1583
+ RW_REORDER_FULL = 0.20
1584
+
1585
+
1586
+ def rewrite_score(before_text, after_text, genre=None, data=None,
1587
+ adjudicated=None):
1333
1588
  """Score one rewrite: a soft quality in [0,1] plus its fidelity flags."""
1334
1589
  if data is None:
1335
1590
  data = load_patterns()
@@ -1340,14 +1595,25 @@ def rewrite_score(before_text, after_text, genre=None, data=None):
1340
1595
  clamp = lambda x: max(0.0, min(1.0, x))
1341
1596
  deslop = clamp((b_ai - a["ai_likelihood"]) / b_ai)
1342
1597
  gate = 1.0 if a["ai_likelihood"] <= RW_GATE.get(genre, RW_GATE_DEFAULT) else 0.0
1343
- rhythm = clamp(a.get("burstiness", 0.0) / 0.45)
1598
+ # Formal genres score with the rhythm-uniformity penalty switched off,
1599
+ # because an even pulse is native to an abstract rather than a tell. The
1600
+ # objective was still paying for burstiness there, so a casualised abstract
1601
+ # outranked one that kept its register -- the composite penalising formal
1602
+ # writing for being formal, which is the thing --formal exists to stop.
1603
+ rhythm = 1.0 if formal else clamp(a.get("burstiness", 0.0) / 0.45)
1344
1604
  bw, aw = len(before_text.split()), len(after_text.split())
1345
1605
  length = 1.0 if not bw or aw / bw >= 0.6 else clamp((aw / bw) / 0.6)
1606
+ reorder = reorder_ratio(before_text, after_text)
1607
+ structure = clamp(reorder / RW_REORDER_FULL)
1346
1608
  soft = sum(RW_WEIGHTS[k] * v for k, v in
1347
- {"deslop": deslop, "gate": gate, "rhythm": rhythm, "length": length}.items())
1348
- fid = fidelity(before_text, after_text)
1609
+ {"deslop": deslop, "gate": gate, "rhythm": rhythm,
1610
+ "length": length, "structure": structure}.items())
1611
+ fid = fidelity(before_text, after_text, adjudicated)
1349
1612
  return {"soft": round(soft, 4), "deslop": round(deslop, 3), "gate": gate,
1350
1613
  "rhythm": round(rhythm, 3), "length": round(length, 3),
1614
+ "structure": round(structure, 3), "reorder": reorder,
1615
+ "unsourced": sorted(fid["unsourced"]),
1616
+ "figure_evidence": fid["figure_evidence"],
1351
1617
  "after_ai": a["ai_likelihood"], "before_ai": b["ai_likelihood"],
1352
1618
  "burstiness": round(a.get("burstiness", 0.0), 3),
1353
1619
  "high_tells": sum(1 for h in a.get("hits", []) if h.get("w", 0) >= 4),
@@ -1641,6 +1907,11 @@ def main():
1641
1907
  f"layout; your AI assistant still reviews the ideas, voice, and facts.")
1642
1908
  sys.exit(0 if ok else 1)
1643
1909
 
1910
+ # Last line of a human run, and only ever once. See record_human_run.
1911
+ note = record_human_run()
1912
+ if note:
1913
+ print(note, file=sys.stderr)
1914
+
1644
1915
 
1645
1916
  if __name__ == "__main__":
1646
1917
  main()