@domandigital/craft 0.8.1 → 0.10.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @domandigital/craft
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cd13c94: `craft copy claims <paths>` lists every sentence holding a price, figure, date or named source, marked sourced or UNSOURCED, for a person to check against the primary source. `findClaims` and `formatClaims` do the same in code. No tell can tell a true figure from a false one; this makes the checking a list instead of a hunt.
8
+ - cd13c94: Catalogue 2026.09.8, from reading all 144 review findings on the live DD site. New tell `prompt-context` (review): the model naming its own inputs, "the attached Perplexity research file", 24 real hits in five live articles. Tuned: `inline-label-list` leaves reference and data lists alone (0 of 11 hits were real), `staccato-triplet` no longer crosses a paragraph break, `ing-tail` passes a list of tasks, `repeated-sentence` ignores footnotes repeating a source title.
9
+
10
+ ## 0.9.0
11
+
12
+ ### Minor Changes
13
+
14
+ - bbf1f54: `chatbot-residue` moves to the house blocking tier. A pasted chat reply (citation tokens, entity markers, "as an AI language model") now fails `craft copy --gate`, `copy-check` and `sanity-copy`. Its estate hits were read first: 15 of 15 real, no false alarms.
15
+
3
16
  ## 0.8.1
4
17
 
5
18
  ### Patch Changes
package/CHARACTER.md CHANGED
@@ -534,7 +534,7 @@ Generated from the package. Run `pnpm --filter @domandigital/craft run docs`
534
534
  after changing an entry; a test fails until you do.
535
535
 
536
536
  <!-- craft:catalogue:start -->
537
- Catalogue version `2026.09.7`, 54 tells.
537
+ Catalogue version `2026.09.8`, 55 tells.
538
538
 
539
539
  | Id | Gen | Surface | Severity | Tell | Why it is a default |
540
540
  | --- | --- | --- | --- | --- | --- |
@@ -578,6 +578,7 @@ Catalogue version `2026.09.7`, 54 tells.
578
578
  | `staccato-triplet` | 2 | copy | warn | Staccato triplet | Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm. |
579
579
  | `where-x-meets-y` | 2 | copy | warn | 'Where X meets Y' | 'Where luxury meets comfort' is a tagline shape that fits every business and so describes none. |
580
580
  | `chatbot-residue` | 1 | copy | warn | Chatbot residue | 'Certainly! Here's a revised version', 'as an AI language model', a citeturn0search0 token, a link tracked utm_source=chatgpt.com: text that exists only because a chat reply was pasted whole. This is evidence, not style. |
581
+ | `prompt-context` | 1 | copy | warn | Prompt context | 'This article uses the attached Perplexity research file as the primary source', 'the attached research is right to say': the model talking about what it was given. A reader of a web page has no attachment. Found 38 times across five live DD articles in 2026-09, none caught by any other tell. |
581
582
  | `placeholder` | 1 | copy | warn | Unfilled placeholder | '[Insert testimonial]', '[Your Name]', lorem ipsum, TODO_PLACEHOLDER: scaffolding a reader must never see, and the clearest sign a page shipped before anyone read it. |
582
583
  | `question-reveal` | 2 | copy | warn | Staged reveal | 'The result? Faster growth.', 'What does this mean for you? It means...': the writer asks the reader's question for them and answers it. An infomercial hook, and a favourite of generated copy. |
583
584
  | `inline-label-list` | 2 | copy | warn | Bold-label bullets | '**Speed:** Faster pages' three times in a row: a short label restated in a few words, the list shape generated copy defaults to. A definition list, with a real explanation after each label, is not this. |
package/COPY.md CHANGED
@@ -45,6 +45,11 @@ A single hit fails the check. These are not judgment calls.
45
45
  a signed-in route (`/admin/`, `/portal/`, `/dashboard/`).
46
46
  - **"No X, no Y" as a list**, anywhere in a sentence. "No obligation, no
47
47
  spam." Defining the business by what it is not, itemised.
48
+ - **Chatbot residue.** "Certainly! Here's a revised version", "as an AI
49
+ language model", "let me know if you'd like me to", ChatGPT citation tokens
50
+ and entity markers, links tracked `utm_source=chatgpt.com`. This is not
51
+ style: it is proof a chat reply was pasted in whole. Remove it, then source
52
+ or cut any claim a citation token was backing. Never re-cite from memory.
48
53
  - The phrase list: `let's dive in`, `let's unpack`, `let's break this down`,
49
54
  `here's the thing`, `it's important to note`, `at its core`,
50
55
  `the key takeaway`, `the reality is`, `the truth is`,
@@ -95,15 +100,15 @@ person reading decides which instance earns its place.
95
100
  | `vague-word` | innovative, scalable, end-to-end, streamline, solutions. Reported only when a file is named outright |
96
101
  | `question-reveal` | "The result? Twice the bookings.", "What does this mean for you? It means...": a staged reveal |
97
102
  | `inline-label-list` | three or more bullets in a row opening "**Label:**", where the label repeats the line |
103
+ | `prompt-context` | "This article uses the attached Perplexity research file as the primary source", "the attached research is right to say", "the attached brief asks": the model naming what it was given. A web page has no attachment. **Zero in anything published** |
98
104
  | `placeholder` | "[Insert client name]", lorem ipsum, `TODO_PLACEHOLDER`. Fine in a draft commit; **zero in anything delivered** |
99
- | `chatbot-residue` | "Certainly! Here's a revised version", "as an AI language model", "let me know if you'd like me to", chat citation tokens, links tracked `utm_source=chatgpt.com`. **Zero in anything delivered**, and the first candidate for the blocking tier |
100
105
 
101
106
  ### Style is not evidence
102
107
 
103
108
  Most tells here are house style: they make copy generic, whoever wrote it. The
104
109
  research is clear that an em dash, a rule of three or a rhetorical question
105
110
  proves nothing about authorship, and em dash rates even run in opposite
106
- directions between models. Only `chatbot-residue` is evidence that a chat reply
111
+ directions between models. Only chatbot residue, in the blocking tier, is evidence that a chat reply
107
112
  was pasted. Report findings as an editorial diagnosis ("three benefit claims
108
113
  lack a mechanism; two stock phrases recur"), never as a score or an
109
114
  accusation.
@@ -208,6 +213,11 @@ Hyphenated compounds count as one word. Not automated.
208
213
  `TODO_PLACEHOLDER: <the exact field needed>` and say so. Never fill it.
209
214
  - A quotation under a named person must be theirs. Copy drafted for someone,
210
215
  in quote marks with their job title beneath it, is a fabricated attribution.
216
+ - **Every price, figure, date and named source is checked against its primary
217
+ source before it ships.** `craft copy claims <paths>` lists them, each marked
218
+ sourced or UNSOURCED. A claim is sourced, corrected or cut, never re-cited
219
+ from memory. The copy check cannot do this part: a false figure in clean
220
+ prose passes every tell.
211
221
 
212
222
  ## CTA rule
213
223
 
package/README.md CHANGED
@@ -104,8 +104,14 @@ run. A test fails if a phrase the standard blocks does not block.
104
104
  ```bash
105
105
  npx craft copy --gate content # the house gate: exit 1 on the blocking tier
106
106
  npx craft copy compare draft.md rewrite.md # facts a rewrite lost or added
107
+ npx craft copy claims content # every price, figure, date and named source, to check
107
108
  ```
108
109
 
110
+ No tell can say whether a figure is true. `craft copy claims` lists each
111
+ sentence a reader would take as a checkable fact, marked `sourced` (a source is
112
+ named) or `UNSOURCED`, for a person to check against the primary source. It
113
+ always exits 0: a checklist, not a verdict.
114
+
109
115
  The same checks run in code:
110
116
 
111
117
  ```ts
@@ -6,9 +6,9 @@ import {
6
6
  snapshotPage,
7
7
  snapshotUrl,
8
8
  snapshotUrls
9
- } from "./chunk-UVBVYM5D.js";
10
- import "./chunk-DWZY5MWD.js";
11
- import "./chunk-OBVWDPQQ.js";
9
+ } from "./chunk-TSKRBIOC.js";
10
+ import "./chunk-QMYERCBB.js";
11
+ import "./chunk-3NGWXZZE.js";
12
12
  export {
13
13
  audit,
14
14
  collectInPage,
package/dist/audit.cjs CHANGED
@@ -564,10 +564,14 @@ var COPY_TELLS = [
564
564
  surface: "copy",
565
565
  why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
566
566
  fix: "Write one sentence that says which of the three is true, and how you know.",
567
- detect: phrases([/(?:\b[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!]\s+){2}[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
567
+ // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
568
+ detect: phrases([/(?:\b[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?:[ \t]+|[ \t]*\n(?![ \t]*\n)[ \t]*)){2}[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
568
569
  fixtures: {
569
- flag: [f("content/home.md", "No fuss. No jargon. Just results.")],
570
- pass: [f("content/home.md", "We fix boilers the same day, and we tell you the price before we start.")]
570
+ flag: [f("content/home.md", "No fuss. No jargon. Just results."), f("content/home.md", "No fuss.\nNo jargon.\nJust results.")],
571
+ pass: [
572
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
573
+ f("content/guide.md", "Your competitor appears in the top three. You don't. Again.\n\nIt's frustrating.")
574
+ ]
571
575
  }
572
576
  },
573
577
  {
@@ -586,6 +590,14 @@ var COPY_TELLS = [
586
590
  }
587
591
  ];
588
592
  var ING_TAIL = /,\s+(?:ensuring|highlighting|underscoring|showcasing|reflecting|demonstrating|emphasi[sz]ing|fostering|cementing|solidifying|reinforcing|symboli[sz]ing|signalling|signaling)\b[^.!?\n]{0,80}/i;
593
+ var PROMPT_CONTEXT = [
594
+ /\b(?:the|this|that)\s+(?:attached|uploaded|provided)\s+(?:(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\s+)?(?:research|research file|research pack|source file|sources)\b/i,
595
+ /\b(?:attached|uploaded)\s+(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\b/i,
596
+ /\bthe\s+(?:attached|uploaded|provided)\s+brief\s+(?:asks|says|wants|requests|suggests|mentions)\b/i
597
+ ];
598
+ function gerundList(block, _match, index) {
599
+ return /(?:^|[(,;:]\s*)\w+ing\b[^,.;()!?]{0,40}$/.test(block.text.slice(Math.max(0, index - 60), index));
600
+ }
589
601
  var VAGUE_ATTRIBUTION = [
590
602
  /\b(?:studies|research|experts|scientists|surveys|statistics|the data)\s+(?:show|shows|suggest|suggests|agree|say|says|confirm|confirms|prove|proves|indicate|indicates)\b/i,
591
603
  /\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
@@ -671,8 +683,10 @@ function inlineLabelHits(ctx) {
671
683
  run = [];
672
684
  };
673
685
  for (const line of lines) {
674
- const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
675
- if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
686
+ const m = /^\s*[-*+]\s+\*\*([^*]{1,40}?):?\*\*:?\s+(\S.*)$/.exec(line);
687
+ const data = m && /[\d£$€%`]|https?:|^\*\*/.test(m[2]);
688
+ const named = m && /\S[A-Z]|[./]/.test(m[1]);
689
+ if (m && !data && !named && m[2].trim().split(/\s+/).length <= 6) run.push(offset);
676
690
  else flush();
677
691
  offset += line.length + 1;
678
692
  }
@@ -710,6 +724,29 @@ var RESEARCH_TELLS = [
710
724
  ]
711
725
  }
712
726
  },
727
+ {
728
+ id: "prompt-context",
729
+ name: "Prompt context",
730
+ generation: 1,
731
+ severity: "warn",
732
+ surface: "copy",
733
+ why: "'This article uses the attached Perplexity research file as the primary source', 'the attached research is right to say': the model talking about what it was given. A reader of a web page has no attachment. Found 38 times across five live DD articles in 2026-09, none caught by any other tell.",
734
+ fix: "Delete the reference to the research or brief. Where it carried a claim, name the real source or cut the claim.",
735
+ detect: phrases(PROMPT_CONTEXT, quoted),
736
+ fixtures: {
737
+ flag: [
738
+ f("content/guide.md", "This article uses the attached Perplexity research file as the primary source."),
739
+ f("content/guide.md", "The attached research is right to say these platforms suit early-stage businesses."),
740
+ f("content/guide.md", "That is exactly how the uploaded research frames the category."),
741
+ f("content/guide.md", "The attached brief asks for the point that most tradespeople miss.")
742
+ ],
743
+ pass: [
744
+ f("content/guide.md", "We research every quote before we send it."),
745
+ f("app/email.tsx", `export const Invoice = () => <p>Your invoice is attached.</p>;`),
746
+ f("content/guide.md", "BrightLocal's research shows that reviews drive 20% of local ranking.")
747
+ ]
748
+ }
749
+ },
713
750
  {
714
751
  id: "placeholder",
715
752
  name: "Unfilled placeholder",
@@ -762,7 +799,11 @@ var RESEARCH_TELLS = [
762
799
  pass: [
763
800
  f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
764
801
  f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
765
- f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n")
802
+ f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n"),
803
+ f("content/guide.md", "- **Per page**: \xA3100-\xA3200 per page\n- **Hourly**: \xA350-\xA375/hour\n- **Day rate**: \xA3350-\xA3500\n"),
804
+ f("content/guide.md", "- **Law Society Find a Solicitor:** For solicitors\n- **AccountingWEB / ICAEW directories:** For accountants\n- **NHS.uk:** For NHS services\n"),
805
+ f("content/guide.md", "1. **Visit PageSpeed Insights** and paste the address\n2. **Enter their URL** and run it\n3. **Note their scores** for later\n"),
806
+ f("content/guide.md", "- **Structural problems:** **REBUILD.**\n- **Messaging issues:** **REFRESH.**\n- **Neither:** **LEAVE IT.**\n")
766
807
  ]
767
808
  }
768
809
  },
@@ -774,7 +815,7 @@ var RESEARCH_TELLS = [
774
815
  surface: "copy",
775
816
  why: "'..., ensuring peace of mind', '..., highlighting our commitment': a participle tacked on the end that claims significance and names no mechanism. One of the most common shapes in the Wikipedia guide to AI writing.",
776
817
  fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
777
- detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
818
+ detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, "")), void 0, gerundList),
778
819
  fixtures: {
779
820
  flag: [
780
821
  f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
@@ -782,7 +823,8 @@ var RESEARCH_TELLS = [
782
823
  ],
783
824
  pass: [
784
825
  f("content/home.md", "We open at 7am, so you can drop the car off before work."),
785
- f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
826
+ f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers."),
827
+ f("content/guide.md", "Many items (claiming your GBP, ensuring NAP consistency, writing title tags) are straightforward.")
786
828
  ]
787
829
  }
788
830
  },
@@ -1032,6 +1074,7 @@ function repeatHits(ctx) {
1032
1074
  for (const s of sentences(doc2)) {
1033
1075
  const words = normal(s.text);
1034
1076
  if (words.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
1077
+ if (/^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
1035
1078
  const key = words.join(" ");
1036
1079
  const earlier = whole.get(key);
1037
1080
  if (earlier) {
@@ -1229,7 +1272,15 @@ var DENSITY_TELLS = [
1229
1272
  f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nMore here.\n\nWe check every valve before the engineer leaves the house.")),
1230
1273
  f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nBy then we check every valve before the engineer leaves the site."))
1231
1274
  ],
1232
- pass: [f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet."))]
1275
+ pass: [
1276
+ f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet.")),
1277
+ f2(
1278
+ "content/guide.md",
1279
+ doc(
1280
+ "The Independent Healthcare Providers Network Private Patient Insight Report 2024 found that private demand rose.[^1]\n\n[^1]: Independent Healthcare Providers Network, Private Patient Insight Report 2024, published online."
1281
+ )
1282
+ )
1283
+ ]
1233
1284
  }
1234
1285
  },
1235
1286
  {
@@ -2294,6 +2345,9 @@ var HOUSE = {
2294
2345
  "plain-english": { tier: "block", label: 'use "properly explained"' },
2295
2346
  buzzword: { tier: "block", label: "forbidden phrase" },
2296
2347
  "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
2348
+ // Evidence of a pasted chat reply, not a matter of style. Moved from review
2349
+ // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
2350
+ "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
2297
2351
  "review-phrase": { tier: "review", label: "review tier" },
2298
2352
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
2299
2353
  "stock-phrase": { tier: "review", label: "stock phrase" },
@@ -2305,10 +2359,10 @@ var HOUSE = {
2305
2359
  "vague-attribution": { tier: "review", label: "unnamed source" },
2306
2360
  "closing-summary": { tier: "review", label: "closing summary" },
2307
2361
  "false-range": { tier: "review", label: "false range" },
2308
- // The first candidate for block: evidence of a pasted chat reply, not a
2309
- // matter of style. Blocks once its estate hits have been read (ROADMAP.md).
2310
- "chatbot-residue": { tier: "review", label: "chatbot residue" },
2311
2362
  "placeholder": { tier: "review", label: "unfilled placeholder" },
2363
+ // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
2364
+ // a proposal can honestly say "the attached research". Review until measured there.
2365
+ "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
2312
2366
  "question-reveal": { tier: "review", label: "staged reveal" },
2313
2367
  "inline-label-list": { tier: "review", label: "bold-label bullets" },
2314
2368
  "phrase-density": { tier: "review", label: "density" },
@@ -2675,7 +2729,7 @@ var RENDERED_PATHS = {
2675
2729
  };
2676
2730
 
2677
2731
  // src/character/check.ts
2678
- var CATALOGUE_VERSION = "2026.09.7";
2732
+ var CATALOGUE_VERSION = "2026.09.8";
2679
2733
  var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2680
2734
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2681
2735
  );
package/dist/audit.js CHANGED
@@ -532,10 +532,14 @@ var COPY_TELLS = [
532
532
  surface: "copy",
533
533
  why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
534
534
  fix: "Write one sentence that says which of the three is true, and how you know.",
535
- detect: phrases([/(?:\b[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!]\s+){2}[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
535
+ // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
536
+ detect: phrases([/(?:\b[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?:[ \t]+|[ \t]*\n(?![ \t]*\n)[ \t]*)){2}[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
536
537
  fixtures: {
537
- flag: [f("content/home.md", "No fuss. No jargon. Just results.")],
538
- pass: [f("content/home.md", "We fix boilers the same day, and we tell you the price before we start.")]
538
+ flag: [f("content/home.md", "No fuss. No jargon. Just results."), f("content/home.md", "No fuss.\nNo jargon.\nJust results.")],
539
+ pass: [
540
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
541
+ f("content/guide.md", "Your competitor appears in the top three. You don't. Again.\n\nIt's frustrating.")
542
+ ]
539
543
  }
540
544
  },
541
545
  {
@@ -554,6 +558,14 @@ var COPY_TELLS = [
554
558
  }
555
559
  ];
556
560
  var ING_TAIL = /,\s+(?:ensuring|highlighting|underscoring|showcasing|reflecting|demonstrating|emphasi[sz]ing|fostering|cementing|solidifying|reinforcing|symboli[sz]ing|signalling|signaling)\b[^.!?\n]{0,80}/i;
561
+ var PROMPT_CONTEXT = [
562
+ /\b(?:the|this|that)\s+(?:attached|uploaded|provided)\s+(?:(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\s+)?(?:research|research file|research pack|source file|sources)\b/i,
563
+ /\b(?:attached|uploaded)\s+(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\b/i,
564
+ /\bthe\s+(?:attached|uploaded|provided)\s+brief\s+(?:asks|says|wants|requests|suggests|mentions)\b/i
565
+ ];
566
+ function gerundList(block, _match, index) {
567
+ return /(?:^|[(,;:]\s*)\w+ing\b[^,.;()!?]{0,40}$/.test(block.text.slice(Math.max(0, index - 60), index));
568
+ }
557
569
  var VAGUE_ATTRIBUTION = [
558
570
  /\b(?:studies|research|experts|scientists|surveys|statistics|the data)\s+(?:show|shows|suggest|suggests|agree|say|says|confirm|confirms|prove|proves|indicate|indicates)\b/i,
559
571
  /\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
@@ -639,8 +651,10 @@ function inlineLabelHits(ctx) {
639
651
  run = [];
640
652
  };
641
653
  for (const line of lines) {
642
- const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
643
- if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
654
+ const m = /^\s*[-*+]\s+\*\*([^*]{1,40}?):?\*\*:?\s+(\S.*)$/.exec(line);
655
+ const data = m && /[\d£$€%`]|https?:|^\*\*/.test(m[2]);
656
+ const named = m && /\S[A-Z]|[./]/.test(m[1]);
657
+ if (m && !data && !named && m[2].trim().split(/\s+/).length <= 6) run.push(offset);
644
658
  else flush();
645
659
  offset += line.length + 1;
646
660
  }
@@ -678,6 +692,29 @@ var RESEARCH_TELLS = [
678
692
  ]
679
693
  }
680
694
  },
695
+ {
696
+ id: "prompt-context",
697
+ name: "Prompt context",
698
+ generation: 1,
699
+ severity: "warn",
700
+ surface: "copy",
701
+ why: "'This article uses the attached Perplexity research file as the primary source', 'the attached research is right to say': the model talking about what it was given. A reader of a web page has no attachment. Found 38 times across five live DD articles in 2026-09, none caught by any other tell.",
702
+ fix: "Delete the reference to the research or brief. Where it carried a claim, name the real source or cut the claim.",
703
+ detect: phrases(PROMPT_CONTEXT, quoted),
704
+ fixtures: {
705
+ flag: [
706
+ f("content/guide.md", "This article uses the attached Perplexity research file as the primary source."),
707
+ f("content/guide.md", "The attached research is right to say these platforms suit early-stage businesses."),
708
+ f("content/guide.md", "That is exactly how the uploaded research frames the category."),
709
+ f("content/guide.md", "The attached brief asks for the point that most tradespeople miss.")
710
+ ],
711
+ pass: [
712
+ f("content/guide.md", "We research every quote before we send it."),
713
+ f("app/email.tsx", `export const Invoice = () => <p>Your invoice is attached.</p>;`),
714
+ f("content/guide.md", "BrightLocal's research shows that reviews drive 20% of local ranking.")
715
+ ]
716
+ }
717
+ },
681
718
  {
682
719
  id: "placeholder",
683
720
  name: "Unfilled placeholder",
@@ -730,7 +767,11 @@ var RESEARCH_TELLS = [
730
767
  pass: [
731
768
  f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
732
769
  f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
733
- f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n")
770
+ f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n"),
771
+ f("content/guide.md", "- **Per page**: \xA3100-\xA3200 per page\n- **Hourly**: \xA350-\xA375/hour\n- **Day rate**: \xA3350-\xA3500\n"),
772
+ f("content/guide.md", "- **Law Society Find a Solicitor:** For solicitors\n- **AccountingWEB / ICAEW directories:** For accountants\n- **NHS.uk:** For NHS services\n"),
773
+ f("content/guide.md", "1. **Visit PageSpeed Insights** and paste the address\n2. **Enter their URL** and run it\n3. **Note their scores** for later\n"),
774
+ f("content/guide.md", "- **Structural problems:** **REBUILD.**\n- **Messaging issues:** **REFRESH.**\n- **Neither:** **LEAVE IT.**\n")
734
775
  ]
735
776
  }
736
777
  },
@@ -742,7 +783,7 @@ var RESEARCH_TELLS = [
742
783
  surface: "copy",
743
784
  why: "'..., ensuring peace of mind', '..., highlighting our commitment': a participle tacked on the end that claims significance and names no mechanism. One of the most common shapes in the Wikipedia guide to AI writing.",
744
785
  fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
745
- detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
786
+ detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, "")), void 0, gerundList),
746
787
  fixtures: {
747
788
  flag: [
748
789
  f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
@@ -750,7 +791,8 @@ var RESEARCH_TELLS = [
750
791
  ],
751
792
  pass: [
752
793
  f("content/home.md", "We open at 7am, so you can drop the car off before work."),
753
- f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
794
+ f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers."),
795
+ f("content/guide.md", "Many items (claiming your GBP, ensuring NAP consistency, writing title tags) are straightforward.")
754
796
  ]
755
797
  }
756
798
  },
@@ -1000,6 +1042,7 @@ function repeatHits(ctx) {
1000
1042
  for (const s of sentences(doc2)) {
1001
1043
  const words = normal(s.text);
1002
1044
  if (words.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
1045
+ if (/^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
1003
1046
  const key = words.join(" ");
1004
1047
  const earlier = whole.get(key);
1005
1048
  if (earlier) {
@@ -1197,7 +1240,15 @@ var DENSITY_TELLS = [
1197
1240
  f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nMore here.\n\nWe check every valve before the engineer leaves the house.")),
1198
1241
  f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nBy then we check every valve before the engineer leaves the site."))
1199
1242
  ],
1200
- pass: [f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet."))]
1243
+ pass: [
1244
+ f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet.")),
1245
+ f2(
1246
+ "content/guide.md",
1247
+ doc(
1248
+ "The Independent Healthcare Providers Network Private Patient Insight Report 2024 found that private demand rose.[^1]\n\n[^1]: Independent Healthcare Providers Network, Private Patient Insight Report 2024, published online."
1249
+ )
1250
+ )
1251
+ ]
1201
1252
  }
1202
1253
  },
1203
1254
  {
@@ -2262,6 +2313,9 @@ var HOUSE = {
2262
2313
  "plain-english": { tier: "block", label: 'use "properly explained"' },
2263
2314
  buzzword: { tier: "block", label: "forbidden phrase" },
2264
2315
  "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
2316
+ // Evidence of a pasted chat reply, not a matter of style. Moved from review
2317
+ // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
2318
+ "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
2265
2319
  "review-phrase": { tier: "review", label: "review tier" },
2266
2320
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
2267
2321
  "stock-phrase": { tier: "review", label: "stock phrase" },
@@ -2273,10 +2327,10 @@ var HOUSE = {
2273
2327
  "vague-attribution": { tier: "review", label: "unnamed source" },
2274
2328
  "closing-summary": { tier: "review", label: "closing summary" },
2275
2329
  "false-range": { tier: "review", label: "false range" },
2276
- // The first candidate for block: evidence of a pasted chat reply, not a
2277
- // matter of style. Blocks once its estate hits have been read (ROADMAP.md).
2278
- "chatbot-residue": { tier: "review", label: "chatbot residue" },
2279
2330
  "placeholder": { tier: "review", label: "unfilled placeholder" },
2331
+ // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
2332
+ // a proposal can honestly say "the attached research". Review until measured there.
2333
+ "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
2280
2334
  "question-reveal": { tier: "review", label: "staged reveal" },
2281
2335
  "inline-label-list": { tier: "review", label: "bold-label bullets" },
2282
2336
  "phrase-density": { tier: "review", label: "density" },
@@ -2643,7 +2697,7 @@ var RENDERED_PATHS = {
2643
2697
  };
2644
2698
 
2645
2699
  // src/character/check.ts
2646
- var CATALOGUE_VERSION = "2026.09.7";
2700
+ var CATALOGUE_VERSION = "2026.09.8";
2647
2701
  var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2648
2702
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2649
2703
  );
@@ -3,18 +3,18 @@ import {
3
3
  hueName,
4
4
  loadNull,
5
5
  shapeName
6
- } from "./chunk-XEH4GK5K.js";
6
+ } from "./chunk-APO4BT2F.js";
7
7
  import {
8
8
  parseFlags
9
- } from "./chunk-X6PHP22P.js";
9
+ } from "./chunk-JKIDWHG3.js";
10
10
  import {
11
11
  fingerprint,
12
12
  fingerprintDistance
13
- } from "./chunk-DWZY5MWD.js";
13
+ } from "./chunk-QMYERCBB.js";
14
14
  import {
15
15
  SNAPSHOT_VERSION,
16
16
  auditSnapshot
17
- } from "./chunk-OBVWDPQQ.js";
17
+ } from "./chunk-3NGWXZZE.js";
18
18
 
19
19
  // src/estate/cli.ts
20
20
  import { existsSync, readFileSync, writeFileSync } from "fs";
@@ -92,7 +92,7 @@ async function snapshotOf(target, cwd) {
92
92
  return snap;
93
93
  }
94
94
  if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL, a snapshot file, nor a site in the register`);
95
- const { snapshotUrl } = await import("./audit-C62GULQR.js");
95
+ const { snapshotUrl } = await import("./audit-EGDNWSQB.js");
96
96
  return snapshotUrl(target);
97
97
  }
98
98
  var line = (distance, sibling, shared) => `${distance.toFixed(2)}${sibling ? " SIBLING" : ""}${shared.length ? ` shares ${shared.join(", ")}` : ""}`;
@@ -866,10 +866,14 @@ var COPY_TELLS = [
866
866
  surface: "copy",
867
867
  why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
868
868
  fix: "Write one sentence that says which of the three is true, and how you know.",
869
- detect: phrases([/(?:\b[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!]\s+){2}[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
869
+ // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
870
+ detect: phrases([/(?:\b[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?:[ \t]+|[ \t]*\n(?![ \t]*\n)[ \t]*)){2}[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
870
871
  fixtures: {
871
- flag: [f("content/home.md", "No fuss. No jargon. Just results.")],
872
- pass: [f("content/home.md", "We fix boilers the same day, and we tell you the price before we start.")]
872
+ flag: [f("content/home.md", "No fuss. No jargon. Just results."), f("content/home.md", "No fuss.\nNo jargon.\nJust results.")],
873
+ pass: [
874
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
875
+ f("content/guide.md", "Your competitor appears in the top three. You don't. Again.\n\nIt's frustrating.")
876
+ ]
873
877
  }
874
878
  },
875
879
  {
@@ -888,6 +892,14 @@ var COPY_TELLS = [
888
892
  }
889
893
  ];
890
894
  var ING_TAIL = /,\s+(?:ensuring|highlighting|underscoring|showcasing|reflecting|demonstrating|emphasi[sz]ing|fostering|cementing|solidifying|reinforcing|symboli[sz]ing|signalling|signaling)\b[^.!?\n]{0,80}/i;
895
+ var PROMPT_CONTEXT = [
896
+ /\b(?:the|this|that)\s+(?:attached|uploaded|provided)\s+(?:(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\s+)?(?:research|research file|research pack|source file|sources)\b/i,
897
+ /\b(?:attached|uploaded)\s+(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\b/i,
898
+ /\bthe\s+(?:attached|uploaded|provided)\s+brief\s+(?:asks|says|wants|requests|suggests|mentions)\b/i
899
+ ];
900
+ function gerundList(block, _match, index) {
901
+ return /(?:^|[(,;:]\s*)\w+ing\b[^,.;()!?]{0,40}$/.test(block.text.slice(Math.max(0, index - 60), index));
902
+ }
891
903
  var VAGUE_ATTRIBUTION = [
892
904
  /\b(?:studies|research|experts|scientists|surveys|statistics|the data)\s+(?:show|shows|suggest|suggests|agree|say|says|confirm|confirms|prove|proves|indicate|indicates)\b/i,
893
905
  /\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
@@ -973,8 +985,10 @@ function inlineLabelHits(ctx) {
973
985
  run = [];
974
986
  };
975
987
  for (const line of lines) {
976
- const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
977
- if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
988
+ const m = /^\s*[-*+]\s+\*\*([^*]{1,40}?):?\*\*:?\s+(\S.*)$/.exec(line);
989
+ const data = m && /[\d£$€%`]|https?:|^\*\*/.test(m[2]);
990
+ const named = m && /\S[A-Z]|[./]/.test(m[1]);
991
+ if (m && !data && !named && m[2].trim().split(/\s+/).length <= 6) run.push(offset);
978
992
  else flush();
979
993
  offset += line.length + 1;
980
994
  }
@@ -1012,6 +1026,29 @@ var RESEARCH_TELLS = [
1012
1026
  ]
1013
1027
  }
1014
1028
  },
1029
+ {
1030
+ id: "prompt-context",
1031
+ name: "Prompt context",
1032
+ generation: 1,
1033
+ severity: "warn",
1034
+ surface: "copy",
1035
+ why: "'This article uses the attached Perplexity research file as the primary source', 'the attached research is right to say': the model talking about what it was given. A reader of a web page has no attachment. Found 38 times across five live DD articles in 2026-09, none caught by any other tell.",
1036
+ fix: "Delete the reference to the research or brief. Where it carried a claim, name the real source or cut the claim.",
1037
+ detect: phrases(PROMPT_CONTEXT, quoted),
1038
+ fixtures: {
1039
+ flag: [
1040
+ f("content/guide.md", "This article uses the attached Perplexity research file as the primary source."),
1041
+ f("content/guide.md", "The attached research is right to say these platforms suit early-stage businesses."),
1042
+ f("content/guide.md", "That is exactly how the uploaded research frames the category."),
1043
+ f("content/guide.md", "The attached brief asks for the point that most tradespeople miss.")
1044
+ ],
1045
+ pass: [
1046
+ f("content/guide.md", "We research every quote before we send it."),
1047
+ f("app/email.tsx", `export const Invoice = () => <p>Your invoice is attached.</p>;`),
1048
+ f("content/guide.md", "BrightLocal's research shows that reviews drive 20% of local ranking.")
1049
+ ]
1050
+ }
1051
+ },
1015
1052
  {
1016
1053
  id: "placeholder",
1017
1054
  name: "Unfilled placeholder",
@@ -1064,7 +1101,11 @@ var RESEARCH_TELLS = [
1064
1101
  pass: [
1065
1102
  f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
1066
1103
  f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
1067
- f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n")
1104
+ f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n"),
1105
+ f("content/guide.md", "- **Per page**: \xA3100-\xA3200 per page\n- **Hourly**: \xA350-\xA375/hour\n- **Day rate**: \xA3350-\xA3500\n"),
1106
+ f("content/guide.md", "- **Law Society Find a Solicitor:** For solicitors\n- **AccountingWEB / ICAEW directories:** For accountants\n- **NHS.uk:** For NHS services\n"),
1107
+ f("content/guide.md", "1. **Visit PageSpeed Insights** and paste the address\n2. **Enter their URL** and run it\n3. **Note their scores** for later\n"),
1108
+ f("content/guide.md", "- **Structural problems:** **REBUILD.**\n- **Messaging issues:** **REFRESH.**\n- **Neither:** **LEAVE IT.**\n")
1068
1109
  ]
1069
1110
  }
1070
1111
  },
@@ -1076,7 +1117,7 @@ var RESEARCH_TELLS = [
1076
1117
  surface: "copy",
1077
1118
  why: "'..., ensuring peace of mind', '..., highlighting our commitment': a participle tacked on the end that claims significance and names no mechanism. One of the most common shapes in the Wikipedia guide to AI writing.",
1078
1119
  fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
1079
- detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
1120
+ detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, "")), void 0, gerundList),
1080
1121
  fixtures: {
1081
1122
  flag: [
1082
1123
  f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
@@ -1084,7 +1125,8 @@ var RESEARCH_TELLS = [
1084
1125
  ],
1085
1126
  pass: [
1086
1127
  f("content/home.md", "We open at 7am, so you can drop the car off before work."),
1087
- f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
1128
+ f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers."),
1129
+ f("content/guide.md", "Many items (claiming your GBP, ensuring NAP consistency, writing title tags) are straightforward.")
1088
1130
  ]
1089
1131
  }
1090
1132
  },
@@ -1334,6 +1376,7 @@ function repeatHits(ctx) {
1334
1376
  for (const s of sentences(doc2)) {
1335
1377
  const words = normal(s.text);
1336
1378
  if (words.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
1379
+ if (/^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
1337
1380
  const key = words.join(" ");
1338
1381
  const earlier = whole.get(key);
1339
1382
  if (earlier) {
@@ -1531,7 +1574,15 @@ var DENSITY_TELLS = [
1531
1574
  f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nMore here.\n\nWe check every valve before the engineer leaves the house.")),
1532
1575
  f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nBy then we check every valve before the engineer leaves the site."))
1533
1576
  ],
1534
- pass: [f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet."))]
1577
+ pass: [
1578
+ f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet.")),
1579
+ f2(
1580
+ "content/guide.md",
1581
+ doc(
1582
+ "The Independent Healthcare Providers Network Private Patient Insight Report 2024 found that private demand rose.[^1]\n\n[^1]: Independent Healthcare Providers Network, Private Patient Insight Report 2024, published online."
1583
+ )
1584
+ )
1585
+ ]
1535
1586
  }
1536
1587
  },
1537
1588
  {
@@ -2629,6 +2680,9 @@ var HOUSE = {
2629
2680
  "plain-english": { tier: "block", label: 'use "properly explained"' },
2630
2681
  buzzword: { tier: "block", label: "forbidden phrase" },
2631
2682
  "negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
2683
+ // Evidence of a pasted chat reply, not a matter of style. Moved from review
2684
+ // on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
2685
+ "chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
2632
2686
  "review-phrase": { tier: "review", label: "review tier" },
2633
2687
  "ai-vocabulary": { tier: "review", label: "AI vocabulary" },
2634
2688
  "stock-phrase": { tier: "review", label: "stock phrase" },
@@ -2640,10 +2694,10 @@ var HOUSE = {
2640
2694
  "vague-attribution": { tier: "review", label: "unnamed source" },
2641
2695
  "closing-summary": { tier: "review", label: "closing summary" },
2642
2696
  "false-range": { tier: "review", label: "false range" },
2643
- // The first candidate for block: evidence of a pasted chat reply, not a
2644
- // matter of style. Blocks once its estate hits have been read (ROADMAP.md).
2645
- "chatbot-residue": { tier: "review", label: "chatbot residue" },
2646
2697
  "placeholder": { tier: "review", label: "unfilled placeholder" },
2698
+ // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
2699
+ // a proposal can honestly say "the attached research". Review until measured there.
2700
+ "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
2647
2701
  "question-reveal": { tier: "review", label: "staged reveal" },
2648
2702
  "inline-label-list": { tier: "review", label: "bold-label bullets" },
2649
2703
  "phrase-density": { tier: "review", label: "density" },
@@ -2992,7 +3046,7 @@ var RENDERED_PATHS = {
2992
3046
  };
2993
3047
 
2994
3048
  // src/character/check.ts
2995
- var CATALOGUE_VERSION = "2026.09.7";
3049
+ var CATALOGUE_VERSION = "2026.09.8";
2996
3050
  var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2997
3051
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2998
3052
  );