From 4450fe868750ec200c8e8e6e27a0d41844b48db0 Mon Sep 17 00:00:00 2001 From: Raymond Ware <5082532+raymondware@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:36:16 -0700 Subject: [PATCH] feat: detect negative positive restatement --- .github/workflows/plugin-validation.yml | 3 + README.md | 4 +- agents/ai-ism-critic.md | 2 + agents/anti-pattern-selector.md | 4 +- agents/draft-implementer.md | 1 + agents/voice-implementer.md | 1 + anti-patterns/language.md | 29 ++++++- anti-patterns/manifest.json | 14 +++- skills/scribe/SKILL.md | 6 +- tests/test_prose_fingerprints.py | 40 +++++++++ tools/draw_simulator.py | 7 +- tools/prose_fingerprints.py | 106 ++++++++++++++++++++++++ tools/validate_plugin.py | 26 ++++++ 13 files changed, 231 insertions(+), 12 deletions(-) create mode 100644 tests/test_prose_fingerprints.py create mode 100644 tools/prose_fingerprints.py diff --git a/.github/workflows/plugin-validation.yml b/.github/workflows/plugin-validation.yml index 986d6be..9812785 100644 --- a/.github/workflows/plugin-validation.yml +++ b/.github/workflows/plugin-validation.yml @@ -21,3 +21,6 @@ jobs: - name: Validate plugin manifests and draw rotation run: python3 tools/validate_plugin.py + + - name: Run prose fingerprint tests + run: python3 -m unittest discover -s tests diff --git a/README.md b/README.md index a66a9f7..fed1b99 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Built for longform authors, essayists, ghostwriters, and editors. Runs a team of ## The Problem -AI-generated writing converges on the same cadence. "Delve into the tapestry of." "It's important to note that." "Not just X, but Y." Em-dashes everywhere. Three-word lists. Conclusion paragraphs that restate the previous five. Hedging openers. +AI-generated writing converges on the same cadence. "Delve into the tapestry of." "It's important to note that." "Not just X, but Y." "This is not X. It is Y." Em-dashes everywhere. Three-word lists. Conclusion paragraphs that restate the previous five. Hedging openers. Scribe fixes this with persona-first generation and a sameness score that quantifies how close prose is to the AI default. @@ -95,7 +95,7 @@ Scribe ships with anti-patterns across three files: | File | Patterns | |---|---| -| `anti-patterns/language.md` | Delve/tapestry/realm vocabulary, tricolon reflex, hedge stacking, marketing verbs, em-dash overuse, self-descriptors, filler intensifiers | +| `anti-patterns/language.md` | Delve/tapestry/realm vocabulary, tricolon reflex, hedge stacking, marketing verbs, em-dash overuse, self-descriptors, negative/positive restatement, filler intensifiers | | `anti-patterns/rhetoric.md` | Universal aphorisms, "not just X but Y" frames, throat-clearing openers, flashlight-hedging adjectives | | `anti-patterns/structure.md` | Conclusion-restating, predictable section sequence, thesis-delay syndrome | diff --git a/agents/ai-ism-critic.md b/agents/ai-ism-critic.md index 54751b1..cc63a05 100644 --- a/agents/ai-ism-critic.md +++ b/agents/ai-ism-critic.md @@ -45,6 +45,7 @@ Catch every instance of the following: - Em-dashes used as universal connectors (>4 per page in any persona) - Tricolons (three-word adjective lists for emphasis) - "Not just X, but Y" frames +- Negative/positive restatements: "This is not X. It is Y," "The goal is not X. The goal is Y," or stacked negation followed by positive restatement - "It's not about X, it's about Y" binaries - "And yet" twist sentences - "What most people don't realize..." pseudo-reveals @@ -74,6 +75,7 @@ Each fingerprint type has a cost: | Em-dashes over budget | +1 each over | | Tricolons | +2 each, capped at +6 | | "Not just X, but Y" / binary frames | +3 each, capped at +6 | +| Negative/positive restatement | +3 each, capped at +9 | | "And yet" twist (literal phrase) | +3 each | | Universal aphorism | +5 each, capped at +10 | | Concluding hortatory | +5 each | diff --git a/agents/anti-pattern-selector.md b/agents/anti-pattern-selector.md index 645b436..46e5e0b 100644 --- a/agents/anti-pattern-selector.md +++ b/agents/anti-pattern-selector.md @@ -46,8 +46,8 @@ Build `must_include` — patterns whose `personas` or `site_signals` strongly ma |---|---| | `surface` is `chapter` or `essay` | At least 2 from: `chapter-recap-opening`, `chapter-end-summary`, `forward-reference`, `predictable-section-sequence`, `conclusion-restating-conclusion` | | `surface` is `opening` | `in-todays-fast-paced-world`, `delve-tapestry-realm`, `universalizing-we-many-people` | -| `surface` is `conclusion` | `concluding-hortatory`, `aphorism-section-closer`, `conclusion-restating-conclusion` | -| `persona` is `business-book` | At least 2 from: `marketing-verb`, `not-x-but-y-frame`, `three-anecdote-montage`, `chapter-end-summary`, `concluding-hortatory` | +| `surface` is `conclusion` | `concluding-hortatory`, `aphorism-section-closer`, `conclusion-restating-conclusion`, `negative-positive-restatement` | +| `persona` is `business-book` | At least 2 from: `marketing-verb`, `not-x-but-y-frame`, `negative-positive-restatement`, `three-anecdote-montage`, `chapter-end-summary`, `concluding-hortatory` | | `persona` is `memoir` | `universal-aphorism`, `flashlight-hedging-adjectives`, `aphorism-section-closer` | | `persona` is `technical-writing` | `marketing-verb`, `self-descriptor`, `bullet-list-body` | | `persona` is `literary-fiction` | `tricolon-reflex`, `em-dash-universal-connector` | diff --git a/agents/draft-implementer.md b/agents/draft-implementer.md index 6773983..a98f068 100644 --- a/agents/draft-implementer.md +++ b/agents/draft-implementer.md @@ -36,6 +36,7 @@ You are the first writer. You produce the first draft of the requested passage b - Do not produce three-word lists (tricolons) unless the rhythm-spec explicitly allows them in this run. - Do not produce paragraphs of uniform length. Hit the standard-deviation target. - Do not include "key takeaways," "in conclusion," "as we have seen," or any other recap phrase from selected_subset. +- Do not produce negative/positive restatement: avoid "not X, but Y," "This is not X. It is Y," and "The goal is not X. The goal is Y" unless the contrast is specific and necessary. - Do not write a meta-introduction explaining what you are about to do. Begin the passage cold. ## Output diff --git a/agents/voice-implementer.md b/agents/voice-implementer.md index 623c133..6aac1c3 100644 --- a/agents/voice-implementer.md +++ b/agents/voice-implementer.md @@ -27,6 +27,7 @@ Scan for any phrase from the persona's "Forbidden Openers" list — replace. Scan for any vocabulary on the banned list (delve, tapestry, navigate-as-metaphor, etc.) — replace with concrete alternative. Scan for "It's worth noting that," "It's important to," "In a world where," "many people," "we all," etc. — cut. Scan for tricolons — replace with two-item or four-item lists. +Scan for negative/positive restatement ("This is not X. It is Y," "The goal is not X. The goal is Y"): state the positive claim directly unless the contrast is necessary. Scan for "and yet" twists — restructure or cut the literal phrase. Scan for marketing verbs (unlock, leverage, transform, robust, powerful) — replace. diff --git a/anti-patterns/language.md b/anti-patterns/language.md index 52e81ec..3a2aa06 100644 --- a/anti-patterns/language.md +++ b/anti-patterns/language.md @@ -3,7 +3,7 @@ file: anti-patterns/language.md tags: [vocabulary, ai-isms, register, idiom, hedging] affects: all severity_default: high -pattern_count: 12 +pattern_count: 13 --- # Language Anti-Patterns @@ -179,6 +179,33 @@ _Tags: rhythm, rhetoric · Affects: all · Severity: high_ --- +## Negative/Positive Restatement +_Tags: ai-isms, rhetoric, restatement · Affects: all · Severity: high_ + +**What it is**: A broader cousin of the "not just X" reflex. The sentence negates a weak version of the claim, then immediately states the preferred positive version. Common forms include "This is not X. It is Y," "This is not about X. It is about Y," "Not X. Y," and stacked variants that repeat the move across a paragraph. + +**Why it fails**: The negated half is usually a straw frame the reader did not need. AI prose uses the pattern to create fake decisiveness. The result sounds polished but padded: first it says what the thing is not, then it says what it meant to say. + +**Fix**: State the positive claim once. Keep the negative half only when the reader probably believes the false version and the contrast is specific. + +``` +// AI restatement +This is not a productivity trick. It is a new operating model for teams. + +// Direct claim +It changes how teams decide what work counts as finished. +``` + +``` +// AI restatement +The goal is not to replace judgment. The goal is to put judgment in the right place. + +// Direct claim +Put judgment where it belongs: use the model for context-sensitive choices and use policy for boundaries that must not drift. +``` + +--- + ## Flashlight-Hedging Adjectives _Tags: voice, register · Affects: all · Severity: moderate_ diff --git a/anti-patterns/manifest.json b/anti-patterns/manifest.json index d22be86..6536497 100644 --- a/anti-patterns/manifest.json +++ b/anti-patterns/manifest.json @@ -27,7 +27,8 @@ "universalizing", "false-dichotomy", "tricolon", - "dialogue" + "dialogue", + "restatement" ], "patterns": [ { @@ -140,6 +141,17 @@ "personas": ["all"], "site_signals": ["any"] }, + { + "id": "negative-positive-restatement", + "title": "Negative/Positive Restatement", + "categories": ["ai-isms", "rhetoric", "restatement"], + "tags": ["not-x-then-y", "padded-contrast", "ai-default"], + "severity_default": "high", + "source_file": "anti-patterns/language.md", + "anchor": "negativepositive-restatement", + "personas": ["all"], + "site_signals": ["any", "chapter", "essay", "conclusion"] + }, { "id": "flashlight-hedging-adjectives", "title": "Flashlight-Hedging Adjectives", diff --git a/skills/scribe/SKILL.md b/skills/scribe/SKILL.md index f1574b8..045fba8 100644 --- a/skills/scribe/SKILL.md +++ b/skills/scribe/SKILL.md @@ -1,7 +1,7 @@ --- name: scribe version: 1.0.0 -description: Multi-agent writing harness for longform authors. Catches AI-default writing patterns (delve, tapestry, em-dash overuse, hedging openers, three-word lists, conclusion-restating) before they ship. Five writer personas (literary fiction, popular nonfiction, technical writing, memoir, business book), persona-specific rhythm and voice rules, anti-pattern rotation per run with audit log, multi-critic synthesis. Built for authors, essayists, ghostwriters, and editors. +description: Multi-agent writing harness for longform authors. Catches AI-default writing patterns (delve, tapestry, em-dash overuse, hedging openers, three-word lists, negative/positive restatement, conclusion-restating) before they ship. Five writer personas (literary fiction, popular nonfiction, technical writing, memoir, business book), persona-specific rhythm and voice rules, anti-pattern rotation per run with audit log, multi-critic synthesis. Built for authors, essayists, ghostwriters, and editors. --- # Scribe v1.0.0 @@ -134,12 +134,12 @@ The pool is grouped into three base files plus a research-sourced directory: | File | Patterns | |---|---| -| `${CLAUDE_PLUGIN_ROOT}/anti-patterns/language.md` | Delve / tapestry, tricolons, hedge stacking, em-dash universal connector, marketing verb, self-descriptor, universal aphorism, "not just X but Y" frames, filler intensifiers | +| `${CLAUDE_PLUGIN_ROOT}/anti-patterns/language.md` | Delve / tapestry, tricolons, hedge stacking, em-dash universal connector, marketing verb, self-descriptor, universal aphorism, "not just X but Y" frames, negative/positive restatement, filler intensifiers | | `${CLAUDE_PLUGIN_ROOT}/anti-patterns/structure.md` | Uniform paragraph length, chapter-recap opening, chapter-end summary, forward references, three-part skeleton, predictable section sequence, bullet-list body, three-anecdote montage, false-question subhead, conclusion-restating-conclusion | | `${CLAUDE_PLUGIN_ROOT}/anti-patterns/rhetoric.md` | "In today's fast-paced world" opener, universalizing claims, false dichotomies, rhetorical question reflex, aphorism section closers, "what most don't realize" pseudo-reveal, "and yet" twist, concluding hortatory | | `${CLAUDE_PLUGIN_ROOT}/anti-patterns/sourced-from-research/` | Filed by the research-scout agent. Each pattern requires 2+ independent complaints. | -Total at v0.1: 30 anti-patterns. Will grow through ralph-loop iterations and `/scribe:research` runs. +Total at v1.0: 31 anti-patterns. Will grow through ralph-loop iterations and `/scribe:research` runs. ## Sameness Score diff --git a/tests/test_prose_fingerprints.py b/tests/test_prose_fingerprints.py new file mode 100644 index 0000000..5a41003 --- /dev/null +++ b/tests/test_prose_fingerprints.py @@ -0,0 +1,40 @@ +from pathlib import Path +import importlib.util +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "tools" / "prose_fingerprints.py" +SPEC = importlib.util.spec_from_file_location("prose_fingerprints", MODULE_PATH) +assert SPEC and SPEC.loader +prose_fingerprints = importlib.util.module_from_spec(SPEC) +sys.modules["prose_fingerprints"] = prose_fingerprints +SPEC.loader.exec_module(prose_fingerprints) +detect_negative_positive_restatement = prose_fingerprints.detect_negative_positive_restatement + + +class NegativePositiveRestatementTests(unittest.TestCase): + def test_detects_adjacent_sentence_restatement(self): + text = "This is not a productivity trick. It is a new operating model for teams." + findings = detect_negative_positive_restatement(text) + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0].pattern_id, "negative-positive-restatement") + self.assertEqual(findings[0].kind, "not_x_it_is_y") + + def test_detects_not_about_about_frame(self): + text = "The goal is not about replacing judgment. The goal is about putting judgment in the right place." + findings = detect_negative_positive_restatement(text) + self.assertTrue(any(item.kind == "not_about_about" for item in findings)) + + def test_detects_not_just_but_frame(self): + text = "It is not just a checklist, but a complete operating model." + findings = detect_negative_positive_restatement(text) + self.assertTrue(any(item.kind == "not_just_but" for item in findings)) + + def test_allows_specific_contrast_without_immediate_positive_restating(self): + text = "The job did not finish because the database was unavailable. The retry queue preserved the failed work item." + self.assertEqual(detect_negative_positive_restatement(text), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/draw_simulator.py b/tools/draw_simulator.py index 8da1616..da30e22 100644 --- a/tools/draw_simulator.py +++ b/tools/draw_simulator.py @@ -45,6 +45,7 @@ "marketing-verb", "em-dash-connector", "self-descriptor", + "negative-positive-restatement", } CATEGORY_DIVERSITY_MIN = ( @@ -178,13 +179,13 @@ def take(*ids: str) -> list[str]: if inputs.persona == "literary-fiction": forced += take("tricolon-reflex", "em-dash-connector") elif inputs.persona == "popular-nonfiction": - forced += take("hedge-stacking", "filler-openers", "universal-aphorism") + forced += take("hedge-stacking", "negative-positive-restatement", "universal-aphorism") elif inputs.persona == "technical-writing": - forced += take("hedge-stacking", "marketing-verb", "filler-openers") + forced += take("hedge-stacking", "marketing-verb", "negative-positive-restatement") elif inputs.persona == "memoir": forced += take("tricolon-reflex", "self-descriptor", "universal-aphorism") elif inputs.persona == "business-book": - forced += take("marketing-verb", "hedge-stacking", "filler-openers") + forced += take("marketing-verb", "hedge-stacking", "negative-positive-restatement") seen: set[str] = set() deduped: list[str] = [] diff --git a/tools/prose_fingerprints.py b/tools/prose_fingerprints.py new file mode 100644 index 0000000..99f83c3 --- /dev/null +++ b/tools/prose_fingerprints.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Lightweight prose fingerprint detectors for Scribe validation.""" + +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Finding: + pattern_id: str + kind: str + line: int + excerpt: str + + +NEGATIVE_POSITIVE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ( + "not_just_but", + re.compile( + r"\bnot\s+(?:just|only|merely)\b[^.!?;]{0,160}\bbut(?:\s+also)?\b[^.!?]{0,160}", + re.IGNORECASE, + ), + ), + ( + "not_about_about", + re.compile( + r"\b(?:it|this|that|the\s+goal|the\s+point)\s+(?:is|'s)\s+not\s+about\b[^.!?]{0,140}" + r"[.!?;,]\s*(?:it|this|that|the\s+goal|the\s+point)\s+(?:is|'s)\s+about\b[^.!?]{0,140}", + re.IGNORECASE, + ), + ), + ( + "not_x_it_is_y", + re.compile( + r"\b(?:it|this|that|the\s+goal|the\s+point|the\s+work|the\s+system)\s+(?:is|'s)\s+not\b[^.!?]{3,140}" + r"[.!?;]\s*(?:it|this|that|the\s+goal|the\s+point|the\s+work|the\s+system)\s+(?:is|'s)\b[^.!?]{3,140}", + re.IGNORECASE, + ), + ), + ( + "not_fragment_then_claim", + re.compile( + r"(?:^|[.!?]\s+)Not\s+(?:a\s+|an\s+|the\s+)?[^.!?]{3,100}[.!?]\s+[A-Z][^.!?]{3,140}", + ), + ), +) + + +def line_for_offset(text: str, offset: int) -> int: + return text.count("\n", 0, offset) + 1 + + +def compact_excerpt(value: str, limit: int = 220) -> str: + excerpt = re.sub(r"\s+", " ", value).strip() + if len(excerpt) <= limit: + return excerpt + return excerpt[: limit - 1].rstrip() + "..." + + +def detect_negative_positive_restatement(text: str) -> list[Finding]: + """Find negated claim followed by immediate positive restatement.""" + findings: list[Finding] = [] + seen: set[tuple[int, int, str]] = set() + + for kind, pattern in NEGATIVE_POSITIVE_PATTERNS: + for match in pattern.finditer(text): + key = (match.start(), match.end(), kind) + if key in seen: + continue + seen.add(key) + findings.append( + Finding( + pattern_id="negative-positive-restatement", + kind=kind, + line=line_for_offset(text, match.start()), + excerpt=compact_excerpt(match.group(0)), + ) + ) + + findings.sort(key=lambda item: (item.line, item.excerpt)) + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description="Detect Scribe AI-default prose fingerprints.") + parser.add_argument("path", help="Text or Markdown file to inspect") + parser.add_argument("--json", action="store_true", help="Emit machine-readable findings") + args = parser.parse_args() + + text = Path(args.path).read_text(encoding="utf-8") + findings = detect_negative_positive_restatement(text) + if args.json: + print(json.dumps([asdict(item) for item in findings], indent=2)) + else: + for item in findings: + print(f"{item.pattern_id}:{item.line}:{item.kind}: {item.excerpt}") + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/validate_plugin.py b/tools/validate_plugin.py index c797b88..369d692 100644 --- a/tools/validate_plugin.py +++ b/tools/validate_plugin.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import importlib.util import re import subprocess import sys @@ -187,12 +188,37 @@ def validate_draw_rotation() -> None: fail("draw simulator subset size outside expected range") +def validate_negative_positive_detector() -> None: + module_path = ROOT / "tools" / "prose_fingerprints.py" + spec = importlib.util.spec_from_file_location("prose_fingerprints", module_path) + if spec is None or spec.loader is None: + fail("could not load prose_fingerprints detector") + module = importlib.util.module_from_spec(spec) + sys.modules["prose_fingerprints"] = module + spec.loader.exec_module(module) + + positive_cases = [ + "This is not a productivity trick. It is a new operating model for teams.", + "The goal is not about replacing judgment. The goal is about putting judgment in the right place.", + "It is not just a checklist, but a complete operating model.", + ] + for case in positive_cases: + findings = module.detect_negative_positive_restatement(case) + if not findings: + fail(f"negative/positive detector missed: {case}") + + negative_case = "The job did not finish because the database was unavailable. The retry queue preserved the failed work item." + if module.detect_negative_positive_restatement(negative_case): + fail("negative/positive detector flagged a specific non-restatement failure sentence") + + def main() -> int: validate_plugin_manifests() validate_agents_and_commands() validate_antipattern_manifest() validate_personas() validate_draw_rotation() + validate_negative_positive_detector() print("Scribe plugin validation passed") return 0