zero-slop 2.7.7 → 2.8.0

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Manav Mishra
3
+ Copyright (c) 2026 Garage Capital Ventures
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -5,20 +5,70 @@
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.7-72528F">
8
+ <img alt="version" src="https://img.shields.io/badge/version-2.8.0-72528F">
9
+ <a href="https://hol.org/guard/plugins"><img alt="Listed in the HOL plugin registry" src="https://img.shields.io/badge/HOL%20registry-listed-2C6E8F"></a>
10
+ <a href="https://github.com/hashgraph-online/awesome-ai-plugins#tools--integrations"><img alt="Listed in awesome-ai-plugins" src="https://img.shields.io/badge/awesome--ai--plugins-listed-2C6E8F"></a>
11
+ <a href="https://zero-slop.ai/try/"><img alt="This README scores 12.0 out of 100 on the slop score, inside the human range" src="https://img.shields.io/badge/slop%20score-12.0%2F100-0f7d55"></a>
9
12
  </p>
10
13
 
11
- Take all the slop out of your AI writing. The #1 agentic anti-slop skill.
14
+ Less slop, more pop in your writing.
12
15
 
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).
16
+ Zero Slop finds stock phrasing, mechanical rhythm, vague claims, and canned
17
+ formatting, then gives your AI assistant or agent harness an editing workflow on guardrails. Its
18
+ MIT-licensed local checks run offline; your existing AI assistant or harness does the editing.
19
+ Score a draft at [zero-slop.ai/try](https://zero-slop.ai/try/); benchmark at
20
+ [zero-slop.ai](https://zero-slop.ai).
14
21
 
15
- ![A scored sentence before and after editing](assets/demo.png)
22
+ ```sh
23
+ npx zero-slop install
24
+ ```
25
+
26
+ ![Scoring a launch post at 100.0, rewriting it to 9.5, with the 40% figure held by the fact gate](assets/zero-slop-demo.gif)
27
+
28
+ ### What it does, on a real draft
29
+
30
+ A launch post, as AI wrote it:
31
+
32
+ > We're thrilled to announce that our team has leveraged cutting-edge AI to deliver a seamless onboarding experience. It's not just a redesign, it's a fundamental reimagining of how users engage with our platform. In today's fast-paced landscape, the ability to iterate quickly is crucial. By harnessing the power of machine learning, we've been able to reduce setup time by 40%. Here's the thing nobody tells you: onboarding is where most products lose their users. We're excited to continue this journey with you.
33
+
34
+ `slopscore.py --explain` on that paragraph, unedited:
35
+
36
+ ```text
37
+ Writing score: 100.0/100 [major rewrite]
38
+ Flagged phrases : 11 across 83 words
39
+ Sentence variety: too even
40
+ Main issues : canned LinkedIn phrase, promotional language, manufactured
41
+ stakes, two-part contrast used as a formula, performed
42
+ writer's voice, buzzword used as promotion
43
+
44
+ Flagged phrases (11), strongest first:
45
+ "In today's fast-paced" manufactured stakes; start where the reader needs to start
46
+ "We're thrilled to" canned LinkedIn phrase; say what happened without the stock opening
47
+ "It's not just a redesign, it's"
48
+ two-part contrast used as a formula; state the claim once, plainly
49
+ "Here's the thing" performed writer's voice; say the thing plainly
50
+ 'cutting-edge' promotional language; name what it does; cut the adjectives
51
+ 'leveraged' buzzword used as promotion; use the plain word
52
+ ```
53
+
54
+ The rewrite, limited to the draft's stated claims:
55
+
56
+ > We used machine learning to reduce onboarding setup time by 40%.
57
+
58
+ ```text
59
+ Writing score: 9.5/100 [clear]
60
+ Flagged phrases : 0 across 10 words
61
+ ```
16
62
 
17
63
  ## Problem
18
64
 
19
- AI writing has a distinct tell: "It's not X. It's Y." "Here's the thing nobody tells you."
65
+ AI-assisted writing often converges on the same constructions: "It's not X. It's Y."
66
+ "Here's the thing nobody tells you." One emdash is fine. Multiple emdashes, definitely slop. The same effect can come from repetition in the wording or the structure. Mechanical rhythm and overworked formatting can do it too.
20
67
 
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.
68
+ Zero Slop is an Agent Skill and ships no model. Claude, GPT, or another compatible
69
+ model edits; local tools check names, numbers, quotations, links, code, tables, and
70
+ paths. Your AI compares meaning because matching words cannot catch every changed
71
+ claim. It runs in any harness that reads `SKILL.md`.
22
72
 
23
73
  ## How to install Zero Slop
24
74
 
@@ -34,7 +84,10 @@ Or install it with `npx`:
34
84
  npx skills add manavmishra/ZeroSlop --global
35
85
  ```
36
86
 
37
- Or from the registry, which also installs the scorer as a command:
87
+ Or from the registry, which also installs the scorer as a command. Zero Slop is
88
+ listed in [awesome-ai-plugins](https://github.com/hashgraph-online/awesome-ai-plugins#tools--integrations)
89
+ and carries a public profile in the [HOL plugin registry](https://hol.org/guard/plugins),
90
+ where its trust score and scanner results are published:
38
91
 
39
92
  ```sh
40
93
  npx zero-slop install # add --harness codex|cursor|opencode|zed
@@ -42,7 +95,8 @@ npx zero-slop score draft.md # score without installing anything
42
95
  ```
43
96
 
44
97
  ChatGPT users can download [`dist/zero-slop-single-file.md`](dist/zero-slop-single-file.md).
45
- Claude.ai users can upload [`dist/zero-slop.zip`](dist/zero-slop.zip). `npx skills update zero-slop --global` updates it later.
98
+ Claude.ai users can upload the [latest release ZIP](https://github.com/manavmishra/ZeroSlop/releases/latest/download/zero-slop.zip).
99
+ `npx skills update zero-slop --global` updates a skills CLI installation later.
46
100
 
47
101
  ## How to use Zero Slop
48
102
 
@@ -50,8 +104,21 @@ Claude.ai users can upload [`dist/zero-slop.zip`](dist/zero-slop.zip). `npx skil
50
104
  /zero-slop (your writing)
51
105
  ```
52
106
 
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.
107
+ You get the edited draft, before-and-after scores, and quoted flagged phrases.
108
+ `/zero-slop inspect (your writing)` reviews without rewriting. For a folder,
109
+ `slopscore.py --batch drafts/ --gate 25` fails above the threshold.
110
+
111
+ ## Show your score
112
+
113
+ The badge above is this README's own, from `npx zero-slop score README.md`.
114
+ Put yours up the same way:
115
+
116
+ ```md
117
+ [![Slop score](https://img.shields.io/badge/slop%20score-12.0%2F100-0f7d55)](https://zero-slop.ai/try/)
118
+ ```
119
+
120
+ Colours are the scorer's bands: `0f7d55` under 25, `b8860b` under 60, `b0502c`
121
+ above.
55
122
 
56
123
  ## The slop that Zero Slop catches
57
124
 
@@ -68,9 +135,9 @@ For a folder, `slopscore.py --batch drafts/ --gate 25` fails the build above the
68
135
  9. Synonym cycling: the agent, the assistant, the tool, all one thing.
69
136
  10. Marketing riders: "robust" and "leverage" score only beside a marketing trigger, so a runbook stays quiet.
70
137
 
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,
72
- paragraphs that shuffle without loss. [`references/eval.md`](references/eval.md) has all
73
- 80 checks.
138
+ A reading pass covers document-wide problems: repeated shapes, crowded statistics,
139
+ and paragraphs that shuffle without loss. [`references/eval.md`](references/eval.md)
140
+ has all 80 checks.
74
141
 
75
142
  Human writing scored 9 to 21 in [`data/corpus/must-not-flag/`](data/corpus/must-not-flag/);
76
143
  unedited AI drafts averaged 77 across [`bench/examples.json`](bench/examples.json).
@@ -84,7 +151,7 @@ Eight roles form one workflow. Each is a job rather than a service, run as its o
84
151
 
85
152
  | Role | Who does it | What happens |
86
153
  |---|---|---|
87
- | 1. Scorer | Local tools | Finds the exact wording, rhythm, readability, and formatting problems that raised the writing score. |
154
+ | 1. Scorer | Local tools | Finds the exact phrases behind the writing score, then checks pacing and readability. It also catches overworked formatting. |
88
155
  | 2. Interpreter | Your AI assistant | Reads the claims, purpose, audience, structure, and voice before changing anything. |
89
156
  | 3. Rewriter | Your AI assistant | Removes stock language and rebuilds order, rhythm, and tone without inventing detail. |
90
157
  | 4. Fact gate | Local tools | Rejects any version that changes names, numbers, quotations, links, code, tables, paths, or structure. |
@@ -101,15 +168,13 @@ tools use only Python's standard library.
101
168
 
102
169
  ## Private learning from your edits
103
170
 
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
- hooks, no reaching into where you publish.
171
+ Learning starts only when you provide the original output and your edited version.
172
+ Zero Slop does not monitor files, browsers, or publishing tools.
106
173
 
107
- Private
108
- data stays under `$ZERO_SLOP_HOME`.
174
+ Private data stays under `$ZERO_SLOP_HOME`.
109
175
 
110
- This is human-in-the-loop online learning. It never retrains Claude, GPT, or another
111
- model, and involves no neural training or RLHF. A profile can exempt existing watchlist
112
- words when selected by name; it does not learn cadence, tone, or a complete writing
176
+ This human-in-the-loop learning never retrains the model. A profile selected by name
177
+ can exempt existing watchlist words; it does not learn cadence, tone, or a complete
113
178
  style.
114
179
 
115
180
  ## What's inside
@@ -120,16 +185,18 @@ with [`scripts/register.py`](scripts/register.py) running the reading pass.
120
185
  [`bench/README.md`](bench/README.md) documents every benchmark with its limits.
121
186
  [zero-slop.ai](https://zero-slop.ai) has the same reference as browsable pages, plus the [benchmark in full](https://zero-slop.ai/benchmark/).
122
187
 
123
- ## Evidence
188
+ ## Testing and limits
124
189
 
125
190
  ### Against other tools, same model, same drafts
126
191
 
127
- We reran Zero Slop, [avoid-ai-writing](https://github.com/conorbronsdon/avoid-ai-writing),
192
+ The saved replay ran Zero Slop, [avoid-ai-writing](https://github.com/conorbronsdon/avoid-ai-writing),
128
193
  [no-ai-slop](https://github.com/petergyang/no-ai-slop) and
129
- [humanizer](https://github.com/blader/humanizer) on the same samples, each with
130
- GPT-5.4, high reasoning, batches of three, and its pinned instructions.
194
+ [humanizer](https://github.com/blader/humanizer) on the same 18 drafts, each with
195
+ GPT-5.4, high reasoning, batches of three, and pinned instructions. The Zero Slop
196
+ outputs came from v2.5.9; later releases rescore those frozen outputs but do not
197
+ pretend to have regenerated them.
131
198
 
132
- | Method | Mean writing score ↓ | Passed all Zero Slop checks | Important details kept | Average length change |
199
+ | Method | Mean writing score ↓ | Passed Zero Slop's local gates | Source check passed | Average length change |
133
200
  |---|---:|---:|---:|---:|
134
201
  | Original drafts | 76.3 | 0/18 | — | — |
135
202
  | Zero Slop | 12.8 | 18/18 | 18/18 | -8.9% |
@@ -151,19 +218,23 @@ rates, and a method-hidden quality ranking.
151
218
  ![Method-hidden quality ranking, lower is better](assets/bench-blind-quality.png)
152
219
 
153
220
  This is a small LLM-reviewed regression study. It measures neither field accuracy nor a
154
- universal ranking. Drafts, mappings, verdicts, hashes and limits:
155
- [`bench/incumbent-blind-replay/`](bench/incumbent-blind-replay/). On the 38-item
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.
221
+ universal ranking. Drafts, mappings, verdicts, hashes, version records, and limits are
222
+ in [`bench/README.md`](bench/README.md). The separate method-hidden two-way replay used
223
+ Zero Slop v2.6.0 and is preserved in
224
+ [`bench/incumbent-blind-replay/`](bench/incumbent-blind-replay/).
225
+
226
+ For the 38-item editorial panel, the current scorer matched the prior 84.2% result.
227
+ All frozen scores stayed unchanged, all 18 human controls remained below the gate,
228
+ and all 18 obvious search cases remained above it. These fixed-sample checks are not
229
+ proof of general accuracy. We recorded 1.86% lower median throughput across 31 interleaved
230
+ local runs; treat that as machine noise, not a speed claim.
161
231
 
162
232
  ### Speed
163
233
 
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.
234
+ On one busy Apple silicon Mac: 1,000 documents in 3.2233 seconds (310.2 per second),
235
+ a 15,201-word document in 0.4757 seconds, the slowest stress case in 3.3487 seconds,
236
+ and an 8,000-word learning pass in 0.2284 seconds. Editing time is excluded; expect
237
+ different results on other machines and loads.
167
238
 
168
239
  ### Current models
169
240
 
@@ -183,9 +254,9 @@ Beemo, raw responses averaged
183
254
 
184
255
  ## Where Zero Slop came from
185
256
 
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,
188
- private learning, portfolio analysis and release tests.
257
+ Zero Slop builds on work by no-ai-slop, humanizer, de-slop, stop-slop, unslop-text,
258
+ and avoid-ai-writing. It adds a writing score, source protection, separate editorial
259
+ passes, private learning, portfolio analysis, and release tests.
189
260
 
190
261
  ![Documented capabilities at pinned repository versions](assets/competitor-capabilities.png)
191
262
 
package/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: zero-slop
3
3
  license: MIT
4
4
  metadata:
5
- version: "2.7.7"
5
+ version: "2.8.0"
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
  ---
@@ -504,6 +504,17 @@ Re-run the local tools. A version clears the fact gate only when ALL hold:
504
504
  wording may change; their content and nesting may not. This deterministic
505
505
  check still cannot see a subtly reframed claim, changed emphasis, or shifted
506
506
  implication, so the judgment pass below remains mandatory
507
+ - if a reviewer confirms that a dropped figure was an unsourced flourish rather
508
+ than a fact, record the decision in a source-bound JSON file and rerun:
509
+
510
+ ```
511
+ python3 <skill-root>/scripts/slopscore.py --fidelity \
512
+ --adjudication <ruling.json> <original> <rewrite>
513
+ ```
514
+
515
+ The file contains schema `1`, the SHA-256 of the exact original text, and
516
+ `allow_dropped_figures`. It can excuse only figures found in that source; it
517
+ cannot weaken checks for names, quotations, links, feelings, or structure
507
518
  - shape (social genres only): the scorer reports `broetry` when most
508
519
  paragraphs are single sentences and fragments run three or more deep. This
509
520
  is its own axis, never folded into the score, because broetry is a slop tell
@@ -741,9 +752,10 @@ facts, meaning, structure, and whether the writing is performing rather than say
741
752
  ```
742
753
 
743
754
  The "two-part contrasts / announcements" row is the performed-register count from
744
- step 2. Add a line for the register gate beside it: `Register gate: 58 checks, 0
745
- failed` or the count that did fail. A report without it is a report that skipped the
746
- checklist. **Print it even when both numbers are zero**, and print it on a draft that
755
+ step 2. Add a line beside it: `Final review: 80 checks, 0 failed` or the count
756
+ that did fail. The 80 checks include AI reading, local measurements, and source
757
+ protection; do not imply that one script performed all of them. **Print it even when
758
+ both numbers are zero**, and print it on a draft that
747
759
  scored clean. It is the only evidence that the pass ran; a report without it is a
748
760
  report that skipped it.
749
761
 
package/bin/zero-slop.mjs CHANGED
@@ -5,10 +5,10 @@
5
5
  // mirrors rather than people. This is the executable half: it installs the same
6
6
  // runtime the tarball already carries, and runs the scorer without a checkout.
7
7
 
8
- import { cp, mkdir, readFile, rm, stat } from "node:fs/promises";
8
+ import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
9
9
  import { spawn } from "node:child_process";
10
10
  import { homedir } from "node:os";
11
- import { dirname, join, resolve } from "node:path";
11
+ import { dirname, join, parse, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
 
14
14
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -41,7 +41,7 @@ Usage
41
41
  Install targets
42
42
  --harness ${Object.keys(HARNESS_DIRS).join(", ")} (default: claude)
43
43
  --dir install into an explicit directory instead
44
- --force overwrite an existing installation
44
+ --force replace a verified Zero Slop installation
45
45
 
46
46
  Examples
47
47
  npx zero-slop install # ~/.claude/skills/zero-slop
@@ -63,10 +63,18 @@ function parseArgs(argv) {
63
63
  break;
64
64
  }
65
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);
66
+ else if (a === "--harness" || a === "--dir") {
67
+ const value = argv[i + 1];
68
+ if (!value || value.startsWith("--")) throw new Error(`${a} needs a value`);
69
+ flags[a.slice(2)] = value;
70
+ i += 1;
71
+ } else if (a.startsWith("--harness=")) {
72
+ flags.harness = a.slice("--harness=".length);
73
+ if (!flags.harness) throw new Error("--harness needs a value");
74
+ } else if (a.startsWith("--dir=")) {
75
+ flags.dir = a.slice("--dir=".length);
76
+ if (!flags.dir) throw new Error("--dir needs a value");
77
+ } else rest.push(a);
70
78
  }
71
79
  return { flags, rest, passthrough };
72
80
  }
@@ -92,21 +100,77 @@ async function exists(p) {
92
100
  }
93
101
  }
94
102
 
103
+ function assertNarrowTarget(dest) {
104
+ const target = resolve(dest);
105
+ const forbidden = new Set([
106
+ parse(target).root,
107
+ resolve(homedir()),
108
+ resolve(process.cwd()),
109
+ ROOT,
110
+ ]);
111
+ if (forbidden.has(target)) {
112
+ throw new Error(`refusing broad install target: ${target}`);
113
+ }
114
+ }
115
+
116
+ async function verifiedExistingInstall(dest) {
117
+ const info = await lstat(dest);
118
+ if (info.isSymbolicLink() || !info.isDirectory()) return false;
119
+ const entries = await readdir(dest);
120
+ if (!entries.length) return true;
121
+ try {
122
+ const skill = await readFile(join(dest, "SKILL.md"), "utf8");
123
+ const header = skill.split("---", 3)[1] ?? "";
124
+ const runtime = await lstat(join(dest, "scripts", "slopscore.py"));
125
+ const patterns = await lstat(join(dest, "data", "patterns.json"));
126
+ return /^name:\s*zero-slop\s*$/m.test(header)
127
+ && runtime.isFile() && patterns.isFile();
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
95
133
  async function install(flags) {
96
134
  const dest = targetDir(flags);
97
- if ((await exists(dest)) && !flags.force) {
135
+ assertNarrowTarget(dest);
136
+ const present = await exists(dest);
137
+ if (present && !flags.force) {
98
138
  console.error(
99
139
  `zero-slop is already installed at ${dest}\n` +
100
140
  `Re-run with --force to overwrite it, or --dir to install elsewhere.`,
101
141
  );
102
142
  return 1;
103
143
  }
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 });
144
+ if (present && !(await verifiedExistingInstall(dest))) {
145
+ throw new Error(
146
+ `refusing to overwrite ${dest}: it is not a Zero Slop installation`,
147
+ );
148
+ }
149
+
150
+ // Build the complete payload beside the destination before replacing an
151
+ // existing install. A copy failure therefore leaves the working version
152
+ // untouched, and the backup is restored if the final rename fails.
153
+ const nonce = `${process.pid}-${Date.now()}`;
154
+ const staged = `${dest}.installing-${nonce}`;
155
+ const backup = `${dest}.backup-${nonce}`;
156
+ await mkdir(dirname(dest), { recursive: true });
157
+ await mkdir(staged);
158
+ try {
159
+ for (const entry of PAYLOAD) {
160
+ await cp(join(ROOT, entry), join(staged, entry), { recursive: true });
161
+ }
162
+ if (present) await rename(dest, backup);
163
+ try {
164
+ await rename(staged, dest);
165
+ } catch (exc) {
166
+ if (present && (await exists(backup)) && !(await exists(dest))) {
167
+ await rename(backup, dest);
168
+ }
169
+ throw exc;
170
+ }
171
+ if (present) await rm(backup, { recursive: true, force: true });
172
+ } finally {
173
+ await rm(staged, { recursive: true, force: true });
110
174
  }
111
175
  console.log(`Installed zero-slop ${await version()} into ${dest}`);
112
176
  console.log("Restart your agent, then run: /zero-slop (your writing)");
@@ -147,7 +211,13 @@ async function main() {
147
211
  console.log(usage(v));
148
212
  return command || argv.length ? 0 : 0;
149
213
  }
150
- if (command === "install") return install(flags);
214
+ if (command === "install") {
215
+ if (rest.length !== 1 || passthrough.length) {
216
+ console.error("install accepts only --harness, --dir, and --force");
217
+ return 2;
218
+ }
219
+ return install(flags);
220
+ }
151
221
  if (command === "where") {
152
222
  console.log(targetDir(flags));
153
223
  return 0;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zero-slop",
3
- "version": "2.7.7",
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.",
3
+ "version": "2.8.0",
4
+ "description": "An Agent Skill that scores AI-sounding prose locally, guides the host AI through an editorial rewrite, and checks changed source details. The local tools run offline with zero dependencies.",
5
5
  "bin": {
6
6
  "zero-slop": "bin/zero-slop.mjs"
7
7
  },
@@ -50,6 +50,7 @@
50
50
  "!scripts/contextual.py",
51
51
  "!scripts/contextual-signals.md",
52
52
  "!references/contextual-signals.md",
53
+ "!scripts/make-readme-gif.mjs",
53
54
  "!scripts/growth-snapshot.mjs",
54
55
  "!data/voices/",
55
56
  "!**/__pycache__/"
@@ -232,8 +232,11 @@ the report even when they are zero.
232
232
  (`figure_evidence`) and waits. Rule on each one: a figure with a source, a unit,
233
233
  a baseline or a citation must be restored; an unsourced multiplier or statistic
234
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:
235
+ was right to cut. Record that ruling in a source-bound JSON file and rerun
236
+ `slopscore.py --fidelity --adjudication ruling.json original rewrite`; the
237
+ file carries schema `1`, the exact original text's SHA-256, and an
238
+ `allow_dropped_figures` list. Never invent a citation to keep a number alive.
239
+ Unruled means unresolved:
237
240
  the default is that the figure stays, so silence here is a failed check and not
238
241
  a pass. Both errors are real. Restoring a flourish is how a rewrite ships fake
239
242
  precision, and dropping a measurement is how it ships a lie.
package/scripts/learn.py CHANGED
@@ -71,8 +71,8 @@ SHARED_LOG = DATA / "learned-log.md"
71
71
  # default and lives outside the repository — a checkout should never carry one
72
72
  # person's writing. Override with ZERO_SLOP_HOME to relocate or to share a
73
73
  # deliberate team-scoped store.
74
- HOME = Path(os.environ.get("ZERO_SLOP_HOME",
75
- Path.home() / ".zero-slop")).expanduser()
74
+ HOME = Path(os.environ.get("ZERO_SLOP_HOME")
75
+ or Path.home() / ".zero-slop").expanduser()
76
76
  OBS = HOME / "reflections.json"
77
77
  LOCAL = HOME / "learned.json"
78
78
  LOCAL_LOG = HOME / "learned-log.md"
@@ -35,6 +35,7 @@ import sys
35
35
  # certified-human samples would let these budgets be derived rather than argued.
36
36
  MIN_WORDS = 300
37
37
  BUDGETS = {
38
+ "antithesis_pair": (0.0, 2),
38
39
  "subtractive_contrast": (6.0, 3),
39
40
  "comma_series": (26.0, 8),
40
41
  "significance_scaffolding": (0.0, 1),
@@ -51,6 +52,90 @@ BUDGETS = {
51
52
  "referent_cluster": (1.5, 1),
52
53
  "adjective_inflation": (1.5, 1),
53
54
  }
55
+ # A short draft cannot support a per-1,000-word rate, so it uses an absolute
56
+ # recurrence floor instead. Two families appear once in the certified-human
57
+ # corpus; requiring two keeps those controls quiet while still closing the old
58
+ # "everything under 300 words passes" bypass.
59
+ SHORT_FLOORS = {
60
+ **{key: floor for key, (_budget, floor) in BUDGETS.items()},
61
+ "dangling_pointer": 2,
62
+ "referent_cluster": 2,
63
+ }
64
+
65
+ # eval.md A1 / SKILL.md step 2.1: antithesis pairs -- two balanced statements,
66
+ # the second landing the twist. Budget is ONE per piece, and "three or more under
67
+ # 500 words means the register failed whatever the score said."
68
+ #
69
+ # This report had no row for that family. RX_SUBTRACTIVE below is a different
70
+ # check -- eval.md A2, the corrective appositive, judged on density -- and it was
71
+ # carrying the whole contrast family's name in the report while A1 went
72
+ # unmeasured. A 479-word manifesto with five antithesis pairs in it therefore
73
+ # printed "Binary contrasts: 1 found, ok" and nothing else about contrast.
74
+ #
75
+ # Two of the four shapes are reachable without a parser, and only those two ship:
76
+ # marked staccato -- "Not perfect. Honest."
77
+ # adjacent isocolon -- one verb frame, both arguments swapped: "Speed is moving
78
+ # fast. Velocity is moving fast in the same direction."
79
+ # Bare subject swap and unmarked reversal stay the reader's judgment, exactly as
80
+ # references/tells.md already says they must.
81
+ ANTITHESIS_STOP = frozenset("""
82
+ a an the this that these those it its is are was were be been being am do does did
83
+ to of in on at by for with from as and or but so if then than not no nor yet
84
+ we you they he she i us our your their his her them me my their there here
85
+ one two three first second next last own same very just only also more most less
86
+ """.split())
87
+
88
+ # The negation can open the pair ("Not perfect. Honest.") or land at the end of
89
+ # it ("The draft was cheap. The signal it sent was not."). Both are in tells.md.
90
+ RX_MARKED_OPEN = re.compile(r"^\W*not\b", re.I)
91
+ RX_MARKED_CLOSE = re.compile(r"\b(?:was|were|is|are|did|does|do|has|have|had|will)\s+not\W*$", re.I)
92
+
93
+
94
+ def _antithesis_content(sent: str) -> list[str]:
95
+ """Content words, with a trailing -s folded away so a verb frame still
96
+ matches when only its agreement changed: 'let' and 'lets' are one frame."""
97
+ out = []
98
+ for word in re.findall(r"[A-Za-z][\w'-]*", sent):
99
+ word = word.lower()
100
+ if word in ANTITHESIS_STOP:
101
+ continue
102
+ if len(word) > 3 and word.endswith("s") and not word.endswith("ss"):
103
+ word = word[:-1]
104
+ out.append(word)
105
+ return out
106
+
107
+
108
+ def antithesis_pairs(prose: str) -> list[str]:
109
+ """Adjacent balanced sentences: the marked form, and the swapped-argument one."""
110
+ out = []
111
+ sents = _sentences(prose)
112
+ for first, second in zip(sents, sents[1:]):
113
+ n_first, n_second = len(first.split()), len(second.split())
114
+ # Both halves have to be short enough to read as one figure. A short
115
+ # line beside a paragraph-length sentence is not a balanced pair.
116
+ if not (2 <= n_first <= 14 and 1 <= n_second <= 14):
117
+ continue
118
+ # Marked: the twist is announced, at either end of the pair.
119
+ if RX_MARKED_OPEN.match(first) and n_second <= 10:
120
+ out.append(f"{first} {second}")
121
+ continue
122
+ if RX_MARKED_CLOSE.search(second) and n_first <= 10:
123
+ out.append(f"{first} {second}")
124
+ continue
125
+ head, tail = _antithesis_content(first), _antithesis_content(second)
126
+ if len(head) < 2 or len(tail) < 2:
127
+ continue
128
+ # Identical content is repetition, not antithesis: the figure needs the
129
+ # arguments to have changed.
130
+ if head == tail:
131
+ continue
132
+ shared = set(head) & set(tail)
133
+ # A shared frame carrying most of the shorter sentence, with the
134
+ # arguments changed: that is the figure, not an accidental echo.
135
+ if len(shared) >= 2 and len(shared) / min(len(head), len(tail)) >= 0.5:
136
+ out.append(f"{first} {second}")
137
+ return out
138
+
54
139
 
55
140
  # "X, not Y." and "A rather than B." The corrective appositive. Each instance is
56
141
  # usually careful writing, which is why no pattern list contains it.
@@ -133,6 +218,28 @@ RX_INFLATION = re.compile(
133
218
  r"(?:improvement|progress|difference|impact|result|results|value|win|shift"
134
219
  r"|change|benefit|breakthrough|game.?changer)\b", re.I)
135
220
 
221
+ # An imperative is the bare base form, so it carries none of the inflection
222
+ # (-s, -ed, -es) or the auxiliaries FINITE_VERB looks for: "Play to win." and
223
+ # "Build durable growth." were both being reported as verbless fragments. On the
224
+ # seven-line manifesto that surfaced this, two of the five hits were imperatives,
225
+ # so 40% of the document's one finding was wrong. A closed list is the same
226
+ # device FINITE_VERB already is, and it only ever fires at a sentence opening.
227
+ IMPERATIVE_OPENER = re.compile(
228
+ r"^\W*(?:and|but|so|then|now|first|next|finally)?\W*"
229
+ r"(?:add|aim|allow|apply|ask|assume|avoid|begin|book|break|bring|build|buy"
230
+ r"|call|change|check|choose|clean|clear|click|close|collect|come|compare"
231
+ r"|consider|copy|count|cover|create|cut|decide|define|delete|deliver|design"
232
+ r"|do|draw|drive|drop|edit|enter|expect|explain|fill|find|finish|fix|focus"
233
+ r"|follow|forget|get|give|go|grab|grow|handle|help|hire|hold|imagine|include"
234
+ r"|install|invest|join|keep|know|launch|lead|learn|leave|let|listen|look"
235
+ r"|love|make|meet|move|name|note|notice|open|pay|pick|plan|play|point|prefer"
236
+ r"|press|pull|push|put|read|remember|remove|repeat|replace|report|reset"
237
+ r"|return|review|run|save|say|scale|see|select|sell|send|set|share|ship|show"
238
+ r"|sign|skip|solve|sort|speak|spend|stand|start|stay|stop|study|take|talk"
239
+ r"|teach|tell|test|think|throw|touch|track|treat|trust|try|turn|update|use"
240
+ r"|wait|walk|want|watch|win|write)\b",
241
+ re.I)
242
+
136
243
  FINITE_VERB = re.compile(
137
244
  r"\b(?:is|are|was|were|be|been|being|has|have|had|do|does|did|can|could|will"
138
245
  r"|would|shall|should|may|might|must|gets?|goes|comes?|makes?|takes?|gives?"
@@ -152,6 +259,8 @@ def verbless_fragments(prose: str) -> list[str]:
152
259
  continue
153
260
  if sent.rstrip().endswith(":") or sent.lstrip().startswith(("-", "*", "#", "|")):
154
261
  continue
262
+ if IMPERATIVE_OPENER.match(sent):
263
+ continue
155
264
  if not FINITE_VERB.search(sent):
156
265
  out.append(sent)
157
266
  return out
@@ -287,7 +396,7 @@ def measure(text: str) -> dict:
287
396
 
288
397
  paras = paragraphs(prose)
289
398
  lengths = [len(p.split()) for p in paras]
290
- uniformity = (
399
+ paragraph_uniformity = (
291
400
  round(statistics.pstdev(lengths) / statistics.mean(lengths), 2)
292
401
  if len(lengths) > 2 and statistics.mean(lengths)
293
402
  else None
@@ -298,9 +407,10 @@ def measure(text: str) -> dict:
298
407
  triads = [" ".join(m.group(0).split()) for m in NEGATION_TRIAD.finditer(prose)]
299
408
  dangling = dangling_pointers(text)
300
409
  fragments = verbless_fragments(prose)
410
+ antithesis = antithesis_pairs(prose)
301
411
  thin = thin_sections(text)
302
412
  clusters = referent_clusters(text)
303
- uniformity, column = table_row_uniformity(text)
413
+ table_uniformity, column = table_row_uniformity(text)
304
414
 
305
415
  return {
306
416
  "words": words,
@@ -311,14 +421,15 @@ def measure(text: str) -> dict:
311
421
  "verbless_fragment": {"count": len(fragments), "per_1k": per_k(len(fragments)), "hits": fragments[:5]},
312
422
  "thin_section": {"count": len(thin), "per_1k": per_k(len(thin)), "hits": thin[:6]},
313
423
  "referent_cluster": {"count": len(clusters), "per_1k": per_k(len(clusters)), "hits": clusters[:3]},
314
- "table_uniformity": {"share": uniformity, "column": column},
424
+ "table_uniformity": {"share": table_uniformity, "column": column},
425
+ "antithesis_pair": {"count": len(antithesis), "per_1k": per_k(len(antithesis)), "hits": antithesis[:6]},
315
426
  "subtractive_contrast": {"count": len(subtractive), "per_1k": per_k(len(subtractive)), "hits": subtractive[:12]},
316
427
  "comma_series": {"count": len(series), "per_1k": per_k(len(series))},
317
428
  "significance_scaffolding": {"count": len(significance), "per_1k": per_k(len(significance)), "hits": significance[:6]},
318
429
  "classifier_scaffolding": {"count": len(classifier), "per_1k": per_k(len(classifier)), "hits": classifier[:6]},
319
430
  "inanimate_agent": {"count": len(inanimate), "per_1k": per_k(len(inanimate)), "hits": inanimate[:8]},
320
431
  "repeated_openings": {"count": len(repeated), "per_1k": per_k(len(repeated)), "hits": repeated[:6]},
321
- "paragraph_uniformity": uniformity,
432
+ "paragraph_uniformity": paragraph_uniformity,
322
433
  }
323
434
 
324
435
 
@@ -329,7 +440,10 @@ def verdicts(m: dict) -> list[tuple[str, float, float, bool]]:
329
440
  for key, (budget, floor) in BUDGETS.items():
330
441
  value = m[key]["per_1k"]
331
442
  count = m[key]["count"]
332
- ok = short or value <= budget or count < floor
443
+ # Rates are unstable on short drafts, but an absolute budget is not.
444
+ # Below MIN_WORDS, use only the recurrence floor; otherwise require
445
+ # both enough instances and a rate over budget before failing.
446
+ ok = count < SHORT_FLOORS[key] if short else value <= budget or count < floor
333
447
  rows.append((key, value, budget, ok))
334
448
  return rows
335
449
 
@@ -342,7 +456,8 @@ LABEL = {
342
456
  "verbless_fragment": "Verbless fragments",
343
457
  "thin_section": "Headings over a sentence or two",
344
458
  "referent_cluster": "One thing under several names",
345
- "subtractive_contrast": "Binary contrasts",
459
+ "antithesis_pair": "Antithesis pairs",
460
+ "subtractive_contrast": "Binary contrasts (X, not Y)",
346
461
  "comma_series": "Comma-series density",
347
462
  "significance_scaffolding": "Announced significance",
348
463
  "classifier_scaffolding": "Graded not delivered (stems)",
@@ -360,8 +475,11 @@ def render(m: dict, name: str) -> str:
360
475
  out.append(f" Under {MIN_WORDS} words. Rates are not reported: one instance in a short")
361
476
  out.append(" document swamps the rate. Counts only.")
362
477
  out.append("")
478
+ states = {key: ok for key, _value, _budget, ok in verdicts(m)}
363
479
  for key in BUDGETS:
364
- out.append(f" {LABEL[key]:<32} {m[key]['count']:>6} found")
480
+ mark = "ok " if states[key] else "OVER"
481
+ out.append(f" {mark} {LABEL[key]:<32} {m[key]['count']:>6} found"
482
+ f" limit {SHORT_FLOORS[key] - 1}")
365
483
  return "\n".join(out)
366
484
  for key, value, budget, ok in verdicts(m):
367
485
  mark = "ok " if ok else "OVER"
@@ -500,8 +618,8 @@ def read_packet(text: str, name: str) -> dict:
500
618
  "file": name,
501
619
  "instruction": (
502
620
  "Work section by section, one pass per section: answer all of section A "
503
- "before opening B, and so on. Sixty questions held at once get a "
504
- "sixty-th of your attention each; ten at a time get read. Answer with "
621
+ "before opening B, and so on. Do not hold the whole checklist in "
622
+ "attention at once; work in small sections and answer each item. Answer with "
505
623
  "pass or fail; where a question asks for a count, give the number. Quote "
506
624
  "exact spans as evidence; never paraphrase. Then fill _coverage: map "
507
625
  "every paragraph id to \"clean\" or to the list of check ids that fire "
@@ -547,9 +665,22 @@ def check_evidence(raw: str, answers: dict, checks: list[dict]) -> list[str]:
547
665
  if not isinstance(got, dict):
548
666
  continue
549
667
  answer = got.get("answer")
550
- quotes = [q for q in (got.get("evidence") or []) if isinstance(q, str)]
668
+ raw_quotes = got.get("evidence")
669
+ if raw_quotes is not None and not isinstance(raw_quotes, list):
670
+ problems.append(f"{check['id']} evidence must be a list of exact quotes")
671
+ raw_quotes = []
672
+ elif isinstance(raw_quotes, list) and any(not isinstance(q, str) for q in raw_quotes):
673
+ problems.append(f"{check['id']} evidence entries must all be strings")
674
+ quotes = [q for q in (raw_quotes or []) if isinstance(q, str)]
551
675
  count = got.get("count")
552
676
 
677
+ if ("___" in check["title"] and "count" in check["title"].lower()
678
+ and (isinstance(count, bool) or not isinstance(count, int) or count < 0)):
679
+ problems.append(f"{check['id']} is missing a required count")
680
+ elif count is not None and (isinstance(count, bool)
681
+ or not isinstance(count, int) or count < 0):
682
+ problems.append(f"{check['id']} count must be a non-negative integer or null")
683
+
553
684
  if answer == "fail" and not quotes:
554
685
  problems.append(f"{check['id']} failed with no quoted evidence")
555
686
  if answer == "fail" and isinstance(count, int) and count == 0:
@@ -572,6 +703,9 @@ def check_evidence(raw: str, answers: dict, checks: list[dict]) -> list[str]:
572
703
 
573
704
  def verdict(text: str, answers: dict) -> tuple[int, str]:
574
705
  """Combine the measured rates with the model's read. Both must clear."""
706
+ if not isinstance(answers, dict):
707
+ return 1, ("Register verdict\n\n FAIL answer packet must be a JSON object "
708
+ "keyed by checklist id. No checks were accepted.")
575
709
  m = measure(text)
576
710
  checks = [c for c in load_checks() if not c["skip"] and not c["auto"]]
577
711
  out = ["Register verdict", ""]
@@ -602,7 +736,10 @@ def verdict(text: str, answers: dict) -> tuple[int, str]:
602
736
  out.append(f" {state:<5} {qid:<5}{check['title'][:48]}{shown}")
603
737
  if got["answer"] == "fail":
604
738
  failed.append(qid)
605
- for quote in (got.get("evidence") or [])[:3]:
739
+ quotes = got.get("evidence")
740
+ if not isinstance(quotes, list):
741
+ quotes = []
742
+ for quote in quotes[:3]:
606
743
  out.append(f" · {str(quote)[:84]}")
607
744
  out.append("")
608
745
 
@@ -614,12 +751,53 @@ def verdict(text: str, answers: dict) -> tuple[int, str]:
614
751
  out.append(" paragraph nobody read; the checklist was answered from memory.")
615
752
  failed.append("coverage (missing)")
616
753
  else:
754
+ known_paragraphs = set(para_ids)
755
+ # JSON object keys arrive as strings, but verdict() is also a public
756
+ # library function. Treat an in-process packet with non-string keys as
757
+ # invalid input instead of letting sorting or joining raise TypeError.
758
+ unknown = sorted(
759
+ (key for key in coverage if not isinstance(key, str)
760
+ or key not in known_paragraphs),
761
+ key=str,
762
+ )
617
763
  unread = [i for i in para_ids if i not in coverage]
764
+ coverage_problems = []
765
+ valid_check_ids = {c["id"] for c in checks}
766
+ if unknown:
767
+ coverage_problems.append(
768
+ "unknown paragraph id(s): " + ", ".join(map(str, unknown[:8])))
769
+ for para_id in para_ids:
770
+ if para_id not in coverage:
771
+ continue
772
+ value = coverage[para_id]
773
+ if value == "clean":
774
+ continue
775
+ if not isinstance(value, list) or not value:
776
+ coverage_problems.append(
777
+ f"{para_id} must be clean or a non-empty list of failed check ids")
778
+ continue
779
+ if any(not isinstance(check_id, str) for check_id in value):
780
+ coverage_problems.append(f"{para_id} check ids must be strings")
781
+ continue
782
+ if len(value) != len(set(value)):
783
+ coverage_problems.append(f"{para_id} repeats a check id")
784
+ for check_id in value:
785
+ if check_id not in valid_check_ids:
786
+ coverage_problems.append(f"{para_id} names unknown check {check_id}")
787
+ else:
788
+ referenced = answers.get(check_id)
789
+ if not isinstance(referenced, dict) or referenced.get("answer") != "fail":
790
+ coverage_problems.append(
791
+ f"{para_id} names {check_id}, but that check did not fail")
618
792
  if unread:
619
793
  out.append(f" FAIL {len(unread)} paragraph(s) never dispositioned: "
620
794
  + ", ".join(unread[:8]))
621
795
  failed.append("coverage (incomplete)")
622
- else:
796
+ if coverage_problems:
797
+ for problem in coverage_problems:
798
+ out.append(f" FAIL {problem}")
799
+ failed.append("coverage (invalid)")
800
+ if not unread and not coverage_problems:
623
801
  flagged = sum(1 for v in coverage.values() if v != "clean")
624
802
  out.append(f" ok all {len(para_ids)} paragraphs dispositioned, "
625
803
  f"{flagged} carrying findings")
@@ -634,7 +812,12 @@ def verdict(text: str, answers: dict) -> tuple[int, str]:
634
812
  out.append(" Re-read the draft and quote the exact span, or change the answer.")
635
813
  else:
636
814
  answered = sum(1 for c in checks if isinstance(answers.get(c["id"]), dict))
637
- quoted = sum(len(answers.get(c["id"], {}).get("evidence") or []) for c in checks)
815
+ quoted = 0
816
+ for check in checks:
817
+ got = answers.get(check["id"])
818
+ quotes = got.get("evidence") if isinstance(got, dict) else None
819
+ if isinstance(quotes, list):
820
+ quoted += sum(isinstance(quote, str) for quote in quotes)
638
821
  if not answered:
639
822
  out.append(" none no answer matched any check id. The answers file is for a")
640
823
  out.append(" different checklist, or the ids are wrong.")
package/scripts/rerank.py CHANGED
@@ -36,7 +36,7 @@ def _tier(s):
36
36
  return 0 if s["preserved"] else 1
37
37
 
38
38
 
39
- def rank(original, candidates, genre=None):
39
+ def rank(original, candidates, genre=None, adjudicated=None):
40
40
  """candidates: {name: text}. Returns them scored and sorted, best first.
41
41
 
42
42
  Sort key: fidelity tier first (a fabrication can never win), then soft quality,
@@ -55,7 +55,7 @@ def rank(original, candidates, genre=None):
55
55
  data = slopscore.load_patterns()
56
56
  scored = []
57
57
  for name, text in candidates.items():
58
- s = slopscore.rewrite_score(original, text, genre, data)
58
+ s = slopscore.rewrite_score(original, text, genre, data, adjudicated)
59
59
  s["name"], s["text"] = name, text
60
60
  scored.append(s)
61
61
  scored.sort(key=lambda s: (_tier(s), -s["soft"], s["after_ai"],
@@ -93,6 +93,8 @@ def main(argv=None):
93
93
  ap.add_argument("--original", required=True, metavar="FILE")
94
94
  ap.add_argument("--candidates", metavar="JSON_FILE")
95
95
  ap.add_argument("--genre")
96
+ ap.add_argument("--adjudication", metavar="JSON_FILE",
97
+ help="source-bound rulings for figures intentionally removed")
96
98
  ap.add_argument("--out", metavar="FILE")
97
99
  ap.add_argument("--emit", action="store_true")
98
100
  ap.add_argument("files", nargs="*")
@@ -122,8 +124,15 @@ def main(argv=None):
122
124
  if len(candidates) < 2:
123
125
  ap.error("give at least two candidate rewrites to choose between")
124
126
 
127
+ adjudicated = None
128
+ if args.adjudication:
129
+ import slopscore
130
+ try:
131
+ adjudicated = slopscore.load_adjudication(args.adjudication, original)
132
+ except ValueError as exc:
133
+ ap.error(str(exc))
125
134
  try:
126
- scored = rank(original, candidates, args.genre)
135
+ scored = rank(original, candidates, args.genre, adjudicated)
127
136
  except ValueError as exc:
128
137
  ap.error(str(exc))
129
138
  print(render(scored))
package/scripts/safeio.py CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env python3
2
2
  """Small, stdlib-only primitives for durable and concurrency-safe state updates.
3
3
 
4
- Zero Slop's scorer is read-only, but the learning and calibration tools perform
5
- read-modify-write updates. Atomic replacement prevents torn JSON; lock directories
6
- prevent two processes from silently overwriting each other's observations.
4
+ Zero Slop never alters a scored draft. Learning and calibration update private state,
5
+ and an interactive score can update one local, one-time note marker. Atomic
6
+ replacement prevents torn files; lock directories prevent concurrent processes from
7
+ silently overwriting each other's state.
7
8
  """
8
9
  from contextlib import contextmanager
9
10
  import hashlib
@@ -24,6 +24,8 @@ The phrase lists live beside this script in ../data/patterns.json and
24
24
  """
25
25
  import bisect
26
26
  import functools
27
+ import hashlib
28
+ import importlib.util
27
29
  import json
28
30
  import math
29
31
  import re
@@ -37,7 +39,19 @@ SHAPE_SOLO_THRESHOLD = 0.62 # calibrated, see calibrate.py --shape
37
39
  # Where personal voice profiles live — outside the repo, since they are the
38
40
  # user's own writing. One file per author, git-ignored by construction.
39
41
  import os
40
- HOME = Path(os.environ.get("ZERO_SLOP_HOME", Path.home() / ".zero-slop")).expanduser()
42
+
43
+ # The scorer is both a CLI and an importable single-file module. Load its
44
+ # adjacent helper by path so importlib callers do not have to modify sys.path,
45
+ # and so an unrelated third-party module named ``safeio`` cannot be selected.
46
+ _SAFEIO_SPEC = importlib.util.spec_from_file_location(
47
+ "_zero_slop_safeio", Path(__file__).resolve().with_name("safeio.py"))
48
+ if _SAFEIO_SPEC is None or _SAFEIO_SPEC.loader is None: # pragma: no cover
49
+ raise ImportError("cannot load Zero Slop's adjacent safeio.py")
50
+ _SAFEIO = importlib.util.module_from_spec(_SAFEIO_SPEC)
51
+ _SAFEIO_SPEC.loader.exec_module(_SAFEIO)
52
+ atomic_write_text = _SAFEIO.atomic_write_text
53
+ file_locks = _SAFEIO.file_locks
54
+ HOME = Path(os.environ.get("ZERO_SLOP_HOME") or Path.home() / ".zero-slop").expanduser()
41
55
  VOICE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
42
56
 
43
57
 
@@ -77,13 +91,10 @@ def _load_notes():
77
91
 
78
92
  def _save_notes(state):
79
93
  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)
94
+ atomic_write_text(NOTES_FILE, json.dumps(state) + "\n", mode=0o600)
95
+ return True
85
96
  except OSError:
86
- pass # a read-only home must never break a score
97
+ return False # a read-only home must never break a score
87
98
 
88
99
 
89
100
  def star_note_is_due(argv=None, isatty=None, env=None):
@@ -112,14 +123,22 @@ def record_human_run(argv=None, isatty=None, env=None):
112
123
  return None
113
124
  if not (sys.stdout.isatty() if isatty is None else isatty):
114
125
  return None
115
- state = _load_notes()
116
- if state.get("star_note_shown"):
126
+ try:
127
+ # A short best-effort lock prevents simultaneous terminal runs from
128
+ # both printing the one-time note. Contention or a read-only state
129
+ # directory must never delay or fail the score itself.
130
+ with file_locks([NOTES_FILE], timeout=0.25):
131
+ state = _load_notes()
132
+ if state.get("star_note_shown"):
133
+ return None
134
+ state["human_runs"] = int(state.get("human_runs", 0)) + 1
135
+ due = state["human_runs"] >= STAR_NOTE_AFTER_RUNS
136
+ if due:
137
+ state["star_note_shown"] = True
138
+ if not _save_notes(state):
139
+ return None
140
+ except (OSError, SystemExit):
117
141
  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
142
  if not due:
124
143
  return None
125
144
  return ("\n If Zero Slop has been useful, a star helps people find it: "
@@ -1201,14 +1220,15 @@ def facts(text, _other=""):
1201
1220
  out = {}
1202
1221
  for kind, rx in FACT_RX:
1203
1222
  found = set()
1204
- for m in re.finditer(rx, urls if kind == "url" else prose):
1223
+ flags = re.I if kind == "figure" else 0
1224
+ for m in re.finditer(rx, urls if kind == "url" else prose, flags):
1205
1225
  v = (m.group(1) if m.lastindex else m.group(0)).strip()
1206
1226
  if kind == "name":
1207
1227
  v = _peel_entity(v, prose, other)
1208
1228
  if not v:
1209
1229
  continue
1210
1230
  if kind == "figure":
1211
- v = v.replace(",", "").lstrip("$").rstrip()
1231
+ v = v.replace(",", "").lstrip("$").rstrip().lower()
1212
1232
  v = re.sub(r"\s*percent$", "%", v)
1213
1233
  v = re.sub(r"\s*(million|bn|billion|m|k)$",
1214
1234
  lambda x: {"million":"m","billion":"bn"}.get(x.group(1), x.group(1)), v)
@@ -1276,7 +1296,9 @@ BLOCKQUOTE_LINE_RX = re.compile(r"^[ \t]*>[^\n]*$", re.M)
1276
1296
  HEADING_RX = re.compile(r"^(#{1,6})[ \t]+(.+?)[ \t]*$", re.M)
1277
1297
  PATH_RX = re.compile(
1278
1298
  r"(?<![\w:])((?:\.\.?/|/)[A-Za-z0-9._~\-]+"
1279
- r"(?:/[A-Za-z0-9._~\-]+)*|[A-Za-z]:\\[A-Za-z0-9._\\~\-]+)"
1299
+ r"(?:/[A-Za-z0-9._~\-]+)*|(?:[A-Za-z0-9._~\-]+/)+"
1300
+ r"[A-Za-z0-9._~\-]+\.[A-Za-z0-9._~\-]+|"
1301
+ r"[A-Za-z]:\\[A-Za-z0-9._\\~\-]+)"
1280
1302
  )
1281
1303
 
1282
1304
 
@@ -1438,6 +1460,49 @@ def figure_contexts(text, figures):
1438
1460
  return out
1439
1461
 
1440
1462
 
1463
+ def load_adjudication(path, original):
1464
+ """Load explicit dropped-figure rulings bound to one exact source text.
1465
+
1466
+ The file is intentionally small and closed-schema. It cannot weaken name,
1467
+ quote, URL, feeling, or structure checks, and it cannot excuse a number that
1468
+ was not present in the source it names.
1469
+ """
1470
+ source = Path(path)
1471
+ try:
1472
+ if source.stat().st_size > 65_536:
1473
+ raise ValueError("adjudication file exceeds 64 KiB")
1474
+ payload = json.loads(source.read_text(encoding="utf-8"))
1475
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
1476
+ raise ValueError(f"cannot read adjudication file: {exc}") from exc
1477
+ if not isinstance(payload, dict):
1478
+ raise ValueError("adjudication file must be a JSON object")
1479
+ expected = {"schema", "original_sha256", "allow_dropped_figures"}
1480
+ if set(payload) != expected or payload.get("schema") != 1:
1481
+ raise ValueError("adjudication file must use schema 1 and only documented keys")
1482
+ digest = payload.get("original_sha256")
1483
+ actual = hashlib.sha256(original.encode("utf-8")).hexdigest()
1484
+ if not isinstance(digest, str) or digest != actual:
1485
+ raise ValueError("adjudication source hash does not match the original text")
1486
+ raw = payload.get("allow_dropped_figures")
1487
+ if not isinstance(raw, list) or len(raw) > 100:
1488
+ raise ValueError("allow_dropped_figures must be a list of at most 100 figures")
1489
+ original_figures = facts(original)["figure"]
1490
+ allowed = []
1491
+ for item in raw:
1492
+ if not isinstance(item, str) or not item.strip() or len(item) > 80:
1493
+ raise ValueError("each allowed figure must be a short non-empty string")
1494
+ parsed = facts(item)["figure"]
1495
+ if len(parsed) != 1:
1496
+ raise ValueError(f"allowed figure is not one unambiguous figure: {item!r}")
1497
+ canonical = next(iter(parsed))
1498
+ if canonical not in original_figures:
1499
+ raise ValueError(f"allowed figure is absent from the original: {item!r}")
1500
+ allowed.append(canonical)
1501
+ if len(allowed) != len(set(allowed)):
1502
+ raise ValueError("allow_dropped_figures contains a duplicate")
1503
+ return set(allowed)
1504
+
1505
+
1441
1506
  def fidelity(before, after, adjudicated=None):
1442
1507
  """Did the rewrite keep every fact, and did it add any?
1443
1508
 
@@ -1620,8 +1685,8 @@ def rewrite_score(before_text, after_text, genre=None, data=None,
1620
1685
  "preserved": fid["preserved"], "invented": fid["invented"]}
1621
1686
 
1622
1687
 
1623
- def render_fidelity(before, after):
1624
- r = fidelity(before, after)
1688
+ def render_fidelity(before, after, adjudicated=None):
1689
+ r = fidelity(before, after, adjudicated)
1625
1690
  out = ["", " FACT AND MEANING CHECK · original vs edited text", ""]
1626
1691
  for kind, kept, dropped, added in r["rows"]:
1627
1692
  out.append(f" {kind:<8} {len(kept)} kept"
@@ -1640,6 +1705,9 @@ def render_fidelity(before, after):
1640
1705
  out.append(" protected document content changed:")
1641
1706
  for finding in r["structure"][:8]:
1642
1707
  out.append(f" {finding['code']:<23} {finding['message']}")
1708
+ if r.get("unsourced"):
1709
+ for figure in sorted(r["unsourced"]):
1710
+ out.append(f" ruled cut {figure!r} (reviewer marked it unsourced)")
1643
1711
  out += ["",
1644
1712
  " Result: " + ("facts preserved; nothing added"
1645
1713
  if r["preserved"] and not r["invented"] else
@@ -1726,7 +1794,7 @@ def main():
1726
1794
  if "--help" in argv or "-h" in argv:
1727
1795
  print(__doc__)
1728
1796
  return 0
1729
- value_flags = {"--gate", "--genre", "--voice"}
1797
+ value_flags = {"--gate", "--genre", "--voice", "--adjudication"}
1730
1798
  bool_flags = {"--json", "--explain", "--formal", "--fidelity", "--dna",
1731
1799
  "--portfolio", "--batch", "--heatmap"}
1732
1800
  unknown = [arg for arg in argv if arg.startswith("--")
@@ -1739,6 +1807,8 @@ def main():
1739
1807
  if flag in argv]
1740
1808
  if len(modes) > 1:
1741
1809
  raise SystemExit("choose only one mode: " + ", ".join(modes))
1810
+ if "--adjudication" in argv and "--fidelity" not in argv:
1811
+ raise SystemExit("--adjudication is valid only with --fidelity")
1742
1812
 
1743
1813
  gv, _ = gate_value()
1744
1814
  # Values that belong to a flag (--gate 25, --genre social, --voice manav)
@@ -1773,9 +1843,16 @@ def main():
1773
1843
  if len(args) != 2:
1774
1844
  sys.exit("--fidelity needs exactly two files: before and after")
1775
1845
  before, after = _read_text_file(args[0]), _read_text_file(args[1])
1776
- for line in render_fidelity(before, after):
1846
+ adjudicated = None
1847
+ ruling_path = _required_option_value(argv, "--adjudication")
1848
+ if ruling_path:
1849
+ try:
1850
+ adjudicated = load_adjudication(ruling_path, before)
1851
+ except ValueError as exc:
1852
+ raise SystemExit(str(exc)) from exc
1853
+ for line in render_fidelity(before, after, adjudicated):
1777
1854
  print(line)
1778
- r = fidelity(before, after)
1855
+ r = fidelity(before, after, adjudicated)
1779
1856
  sys.exit(0 if (r["preserved"] and not r["invented"]) else 1)
1780
1857
 
1781
1858
  if "--dna" in sys.argv: