zero-slop 2.7.6 → 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,7 +5,7 @@
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.6-72528F">
8
+ <img alt="version" src="https://img.shields.io/badge/version-2.7.7-72528F">
9
9
  </p>
10
10
 
11
11
  Take all the slop out of your AI writing. The #1 agentic anti-slop skill.
@@ -153,10 +153,10 @@ rates, and a method-hidden quality ranking.
153
153
  This is a small LLM-reviewed regression study. It measures neither field accuracy nor a
154
154
  universal ranking. Drafts, mappings, verdicts, hashes and limits:
155
155
  [`bench/incumbent-blind-replay/`](bench/incumbent-blind-replay/). On the 38-item
156
- editorial panel ([`bench/README.md`](bench/README.md)), v2.7.6 matched the prior 84.2% result
156
+ editorial panel ([`bench/README.md`](bench/README.md)), v2.7.7 matched the prior 84.2% result
157
157
  with every frozen document score unchanged, all 18 human controls clear and all 18 search
158
158
  cases still caught: the release moves what the gate asks and leaves the meter
159
- untouched. Median throughput was 2.32% lower across 12 runs, which is local timing
159
+ untouched. Median throughput was 3.51% lower across 12 runs, which is local timing
160
160
  noise and no kind of speed claim.
161
161
 
162
162
  ### Speed
package/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: zero-slop
3
3
  license: MIT
4
4
  metadata:
5
- version: "2.7.6"
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
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-slop",
3
- "version": "2.7.6",
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
5
  "bin": {
6
6
  "zero-slop": "bin/zero-slop.mjs"
@@ -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
@@ -1787,6 +1907,11 @@ def main():
1787
1907
  f"layout; your AI assistant still reviews the ideas, voice, and facts.")
1788
1908
  sys.exit(0 if ok else 1)
1789
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
+
1790
1915
 
1791
1916
  if __name__ == "__main__":
1792
1917
  main()