From bdfded2c24b983d72332911f04070442403c9b8d Mon Sep 17 00:00:00 2001 From: Raymond Ware Date: Fri, 23 Oct 2026 09:47:09 -0700 Subject: [PATCH 1/3] fix(detector): catch contracted and comma-spliced negative parallelism The restatement regexes required whitespace before 's, so "It's not about speed, it's about impact" and similar contractions never matched. Also accept noun subjects, never/isn't forms, comma splices, and the "We don't need X. We need Y" shape. --- tests/test_prose_fingerprints.py | 22 ++++++++++++++++++++++ tools/prose_fingerprints.py | 29 +++++++++++++++++++++++++---- tools/validate_plugin.py | 3 +++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/tests/test_prose_fingerprints.py b/tests/test_prose_fingerprints.py index 5a41003..3a13cfb 100644 --- a/tests/test_prose_fingerprints.py +++ b/tests/test_prose_fingerprints.py @@ -31,6 +31,28 @@ def test_detects_not_just_but_frame(self): findings = detect_negative_positive_restatement(text) self.assertTrue(any(item.kind == "not_just_but" for item in findings)) + def test_detects_contracted_and_comma_spliced_frames(self): + cases = [ + "It's not about speed, it's about impact.", + "It\u2019s not about speed, it\u2019s about impact.", + "Leadership isn't about authority; it's about influence.", + "Success is not about hard work, it's about working smart.", + "It isn't a tool, it's a mindset.", + "This was never about money. It was about respect.", + "We don't need more speed. We need impact.", + ] + for text in cases: + with self.subTest(text=text): + self.assertTrue(detect_negative_positive_restatement(text)) + + def test_allows_plain_negation_followed_by_instruction(self): + text = "This is not the default. Set the flag in config.toml to enable it." + self.assertEqual(detect_negative_positive_restatement(text), []) + + def test_allows_negation_with_consequence(self): + text = "The cache is not warm on first boot, so the first request is slower." + self.assertEqual(detect_negative_positive_restatement(text), []) + 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), []) diff --git a/tools/prose_fingerprints.py b/tools/prose_fingerprints.py index 99f83c3..2de239a 100644 --- a/tools/prose_fingerprints.py +++ b/tools/prose_fingerprints.py @@ -18,6 +18,17 @@ class Finding: excerpt: str +# Building blocks shared by the restatement patterns. Contractions are matched with +# both straight and curly apostrophes because drafts arrive in either form. +_APOS = r"['’]" +_NEG_SUBJECT = r"\b(?:it|this|that|the\s+\w+|\w+(?:\s+\w+){0,2})" +_POS_SUBJECT = r"(?:it|this|that|they|the\s+\w+(?:\s+\w+)?)" +_NEG_BE = ( + rf"(?:\s+(?:is|was|are|were)\s+(?:not|never)|{_APOS}s\s+(?:not|never)" + rf"|\s+(?:is|was|are|were)n{_APOS}t)" +) +_POS_BE = rf"(?:\s+(?:is|was|are|were)|{_APOS}s|{_APOS}re)" + NEGATIVE_POSITIVE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( ( "not_just_but", @@ -29,16 +40,26 @@ class Finding: ( "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}", + rf"{_NEG_SUBJECT}{_NEG_BE}\s+about\b[^.!?]{{0,140}}?" + rf"[.!?;,]\s*{_POS_SUBJECT}{_POS_BE}\s+(?:all\s+|really\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}", + rf"\b(?:it|this|that|the\s+goal|the\s+point|the\s+work|the\s+system){_NEG_BE}\b[^.!?]{{3,140}}?" + rf"[.!?;,]\s*(?:it|this|that|the\s+goal|the\s+point|the\s+work|the\s+system){_POS_BE}\b[^.!?]{{3,140}}", + re.IGNORECASE, + ), + ), + ( + # "We don't need X. We need Y." The backreferences require the same subject and + # verb on both sides, which keeps ordinary negative sentences out. + "not_do_x_do_y", + re.compile( + rf"\b(we|you|they|i)\s+(?:don{_APOS}t|do\s+not|didn{_APOS}t|did\s+not)\s+(\w+)\b[^.!?]{{0,120}}?" + r"[.!?;,]\s*\1\s+\2\b[^.!?]{0,140}", re.IGNORECASE, ), ), diff --git a/tools/validate_plugin.py b/tools/validate_plugin.py index 369d692..a953849 100644 --- a/tools/validate_plugin.py +++ b/tools/validate_plugin.py @@ -201,6 +201,9 @@ def validate_negative_positive_detector() -> None: "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.", + "It's not about speed, it's about impact.", + "Leadership isn't about authority; it's about influence.", + "We don't need more speed. We need impact.", ] for case in positive_cases: findings = module.detect_negative_positive_restatement(case) From a1cad79c697950cac4a7be6f2d0e8c838d2fa82c Mon Sep 17 00:00:00 2001 From: Raymond Ware Date: Fri, 23 Oct 2026 09:48:31 -0700 Subject: [PATCH 2/3] feat(selector): keep negative-parallelism patterns in every draw Rotation excluded last run's patterns, so the writer was told to avoid "it's not X, it's Y" only on alternate runs. Add an always_include manifest flag, mark the three patterns in that family, exempt them from rotation in the selector and simulator, and fail validation if a draw drops one. --- agents/anti-pattern-selector.md | 6 ++++-- anti-patterns/language.md | 2 +- anti-patterns/manifest.json | 11 +++++++---- .../scribe/references/orchestration/quality-gates.md | 1 + tools/draw_simulator.py | 9 +++++++-- tools/validate_plugin.py | 8 ++++++++ 6 files changed, 28 insertions(+), 9 deletions(-) diff --git a/agents/anti-pattern-selector.md b/agents/anti-pattern-selector.md index 46e5e0b..7e3feef 100644 --- a/agents/anti-pattern-selector.md +++ b/agents/anti-pattern-selector.md @@ -13,7 +13,7 @@ You are the scribe anti-pattern-selector. You run before every scribe command th Without rotation, every scribe run flags the same canonical patterns (delve, em-dash overuse, three-word lists, "and yet" twists, "key takeaways" boxes). Outputs become identical across distinct manuscripts. The fix is to draw a different subset per run, weighted by brief-specific signals, with an audit log that proves variation happened. Hard rules: -1. The subset must differ from the previous run's subset. +1. The subset must differ from the previous run's subset. Patterns with `"always_include": true` appear in every subset; the rest of the subset supplies the variation. 2. The draw must be logged to `${CLAUDE_PLUGIN_ROOT}/state/draws.jsonl` before any other agent runs. 3. The selection must incorporate at least one brief-specific input. 4. The drawn subset must appear at the top of every scribe output. @@ -53,6 +53,8 @@ Build `must_include` — patterns whose `personas` or `site_signals` strongly ma | `persona` is `literary-fiction` | `tricolon-reflex`, `em-dash-universal-connector` | | `persona` is `popular-nonfiction` | `and-yet-twist-reflex`, `three-anecdote-montage`, `delve-tapestry-realm` | +For every command, add every manifest pattern with `"always_include": true` to `must_include`. These are the negative-parallelism family (`not-x-but-y-frame`, `not-just-x-but-y-reflex`, `negative-positive-restatement`), the tells that show up most often in AI prose. + For every command, include at least 2 patterns of severity `critical` (these are the canaries — `delve-tapestry-realm`, `uniform-paragraph-length`, `chapter-end-summary`, `in-todays-fast-paced-world`, `conclusion-restating-conclusion`). ### Step 3. Build the Subset @@ -60,7 +62,7 @@ For every command, include at least 2 patterns of severity `critical` (these are Target subset size: **12–14 patterns** (out of ~30 total). The pool is small for v0.1; this will scale as the manifest grows. 1. Start with the must_include set from Step 2. -2. Exclude every pattern in `previous_subset`. +2. Exclude every pattern in `previous_subset`, except `always_include` patterns, which are never rotated out. 3. Fill remaining slots by sampling from the manifest with these weights: - severity `critical`: weight 4 - severity `high`: weight 3 diff --git a/anti-patterns/language.md b/anti-patterns/language.md index 3a2aa06..e00ace3 100644 --- a/anti-patterns/language.md +++ b/anti-patterns/language.md @@ -147,7 +147,7 @@ _Tags: rhetoric, voice, bromide · Affects: popular-nonfiction, business-book, m --- ## "It's Not About X, It's About Y" Frame -_Tags: rhetoric, contrast-construction · Affects: business-book, popular-nonfiction · Severity: moderate_ +_Tags: rhetoric, contrast-construction · Affects: all · Severity: moderate_ **What it is**: Constructions that pretend to subvert one frame in favor of another. "Success is not about hard work; it's about working smart." "Leadership isn't about authority; it's about influence." AI-default contrast structure. diff --git a/anti-patterns/manifest.json b/anti-patterns/manifest.json index 6536497..75460d2 100644 --- a/anti-patterns/manifest.json +++ b/anti-patterns/manifest.json @@ -116,8 +116,9 @@ "severity_default": "moderate", "source_file": "anti-patterns/language.md", "anchor": "its-not-about-x-its-about-y-frame", - "personas": ["business-book", "popular-nonfiction"], - "site_signals": ["any"] + "personas": ["all"], + "site_signals": ["any"], + "always_include": true }, { "id": "filler-intensifiers", @@ -139,7 +140,8 @@ "source_file": "anti-patterns/language.md", "anchor": "not-just-x-but-also-y-reflex", "personas": ["all"], - "site_signals": ["any"] + "site_signals": ["any"], + "always_include": true }, { "id": "negative-positive-restatement", @@ -150,7 +152,8 @@ "source_file": "anti-patterns/language.md", "anchor": "negativepositive-restatement", "personas": ["all"], - "site_signals": ["any", "chapter", "essay", "conclusion"] + "site_signals": ["any", "chapter", "essay", "conclusion"], + "always_include": true }, { "id": "flashlight-hedging-adjectives", diff --git a/skills/scribe/references/orchestration/quality-gates.md b/skills/scribe/references/orchestration/quality-gates.md index b17142e..a9d46ee 100644 --- a/skills/scribe/references/orchestration/quality-gates.md +++ b/skills/scribe/references/orchestration/quality-gates.md @@ -10,6 +10,7 @@ The conductor enforces these gates between phases. Failing a gate either aborts - The subset contains at least 2 patterns of severity `critical` (`delve-tapestry-realm`, `uniform-paragraph-length`, `chapter-end-summary`, `in-todays-fast-paced-world`, `conclusion-restating-conclusion`) - The subset spans at least 2 of the 3 base source files (`language.md`, `structure.md`, `rhetoric.md`) - The subset does NOT match the previous run's subset +- The subset contains every manifest pattern with `"always_include": true` **Fail action**: ABORT. The audit-log discipline is non-negotiable. diff --git a/tools/draw_simulator.py b/tools/draw_simulator.py index da30e22..48802d2 100644 --- a/tools/draw_simulator.py +++ b/tools/draw_simulator.py @@ -70,6 +70,7 @@ class Pattern: anchor: str personas: list[str] site_signals: list[str] + always_include: bool = False @dataclass @@ -110,6 +111,7 @@ def load_manifest(path: Path = MANIFEST_PATH) -> list[Pattern]: anchor=p["anchor"], personas=p["personas"], site_signals=p["site_signals"], + always_include=p.get("always_include", False), ) for p in data["patterns"] ] @@ -212,7 +214,10 @@ def select_subset( seed_int = int(hashlib.sha256(run_id.encode()).hexdigest(), 16) target_size = 8 + (seed_int % 7) - forced_full = must_include_set(patterns, inputs) + # Always-on patterns are the highest-frequency AI tells. Rotating them out would + # let them back into every other draft, so they skip the rotation rule entirely. + always_on = [p.id for p in patterns if p.always_include] + forced_full = [pid for pid in must_include_set(patterns, inputs) if pid not in always_on] forced_kept: list[str] = [] rotation_excluded: list[str] = [] @@ -222,7 +227,7 @@ def select_subset( continue forced_kept.append(pid) - forced_kept = forced_kept[: max(1, target_size // 2)] + forced_kept = always_on + forced_kept[: max(1, target_size // 2)] rng = random.Random(seed_int) weights: dict[str, float] = {} diff --git a/tools/validate_plugin.py b/tools/validate_plugin.py index a953849..1a70398 100644 --- a/tools/validate_plugin.py +++ b/tools/validate_plugin.py @@ -124,6 +124,8 @@ def validate_antipattern_manifest() -> None: fail(f"pattern id is not kebab-case: {pid}") if pid in seen: fail(f"duplicate pattern id {pid}") + if not isinstance(pattern.get("always_include", False), bool): + fail(f"pattern {pid} always_include must be a boolean") seen.add(pid) source = ROOT / pattern["source_file"] if not source.exists(): @@ -186,6 +188,12 @@ def validate_draw_rotation() -> None: fail("draw simulator reused the same subset twice") if not 8 <= lines[0]["subset_size"] <= 14 or not 8 <= lines[1]["subset_size"] <= 14: fail("draw simulator subset size outside expected range") + manifest = read_json(ROOT / "anti-patterns" / "manifest.json") + always_on = {p["id"] for p in manifest["patterns"] if p.get("always_include")} + for line in lines: + missing = always_on - set(line["subset"]) + if missing: + fail(f"draw {line['run_id']} dropped always_include patterns: {sorted(missing)}") def validate_negative_positive_detector() -> None: From 67988f558f6ec99c3713cc22ce273d84f44ddc53 Mon Sep 17 00:00:00 2001 From: Raymond Ware Date: Fri, 23 Oct 2026 09:48:59 -0700 Subject: [PATCH 3/3] feat(pipeline): run the fingerprint detector on every draft The detector only ran in CI, never against generated prose. The conductor now runs it after voice-implementer and loops findings back once, the DRAFT gate requires a clean run, and both writers get an explicit rule against straw-negation setups. --- agents/conductor.md | 1 + agents/draft-implementer.md | 1 + agents/voice-implementer.md | 2 +- skills/scribe/references/orchestration/quality-gates.md | 3 ++- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/agents/conductor.md b/agents/conductor.md index 2986736..f4f28e5 100644 --- a/agents/conductor.md +++ b/agents/conductor.md @@ -65,6 +65,7 @@ If specs from PLAN have gaps (e.g., the brief named no specific scene, no specif 1. Spawn `draft-implementer` with persona file, all specialist specs, structure skeleton, and assigned subset. It produces the prose. 2. Spawn `voice-implementer` with the draft + persona file + selected_subset. It revises for voice consistency, removes any lapsed-into-AI-default phrases, varies sentence-length distribution. +3. Write the voice-implementer draft to a temp file and run `python3 ${CLAUDE_PLUGIN_ROOT}/tools/prose_fingerprints.py `. If it prints any findings, re-run `voice-implementer` once with those lines as explicit findings, then run the detector again. Findings that survive the second pass go to REVIEW as critical-severity items. ### REVIEW Phase diff --git a/agents/draft-implementer.md b/agents/draft-implementer.md index a98f068..7c89f31 100644 --- a/agents/draft-implementer.md +++ b/agents/draft-implementer.md @@ -34,6 +34,7 @@ You are the first writer. You produce the first draft of the requested passage b - Do not start the passage with any forbidden opener listed in the persona file. - Do not exceed the em-dash budget from the rhythm-spec. - Do not produce three-word lists (tricolons) unless the rhythm-spec explicitly allows them in this run. +- Do not set up a claim by negating a straw version of it ("It's not about speed, it's about impact," "not just X, but Y"). State the positive claim directly. - 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. diff --git a/agents/voice-implementer.md b/agents/voice-implementer.md index 6aac1c3..00a2a8c 100644 --- a/agents/voice-implementer.md +++ b/agents/voice-implementer.md @@ -27,7 +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 negative/positive restatement ("This is not X. It is Y," "It's not about X, it's about Y," "It isn't X, it's Y," "We don't need X. We need Y," "not just X, but Y"): state the positive claim directly. Keep the negated half only when the reader probably believes it and the contrast is specific. Scan for "and yet" twists — restructure or cut the literal phrase. Scan for marketing verbs (unlock, leverage, transform, robust, powerful) — replace. diff --git a/skills/scribe/references/orchestration/quality-gates.md b/skills/scribe/references/orchestration/quality-gates.md index a9d46ee..49bea5e 100644 --- a/skills/scribe/references/orchestration/quality-gates.md +++ b/skills/scribe/references/orchestration/quality-gates.md @@ -38,8 +38,9 @@ The conductor enforces these gates between phases. Failing a gate either aborts - voice-implementer returned a draft within ±10% of structure-specialist's target word count - Draft contains no banned vocabulary (the persona's banned list — sanity check; the specialists should have caught this) - Draft does not start with any forbidden opener from the persona file +- `python3 ${CLAUDE_PLUGIN_ROOT}/tools/prose_fingerprints.py ` prints no findings (negative parallelism: "It's not X, it's Y", "not just X, but Y", "We don't need X. We need Y") -**Fail action**: Re-run voice-implementer with explicit findings. If second attempt fails the same way, escalate. +**Fail action**: Re-run voice-implementer with explicit findings. For detector hits, pass the printed lines as the findings. If second attempt fails the same way, escalate. ## REVIEW → PRESENT gate