agentlinkops 0.5.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.
Files changed (115) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +24 -0
  3. package/CHANGELOG.md +52 -0
  4. package/LICENSE +190 -0
  5. package/NOTICE +17 -0
  6. package/README.md +202 -0
  7. package/bin/agentlinkops.mjs +4 -0
  8. package/bin/linktrail.mjs +6 -0
  9. package/cli/adapters/csv.js +68 -0
  10. package/cli/adapters/sqlite-crm.js +79 -0
  11. package/cli/adapters/suppliers.js +180 -0
  12. package/cli/agent-setup.js +203 -0
  13. package/cli/agentlinkops.mjs +4 -0
  14. package/cli/args.js +18 -0
  15. package/cli/catalog.js +7483 -0
  16. package/cli/check.js +119 -0
  17. package/cli/citation.js +109 -0
  18. package/cli/client.js +60 -0
  19. package/cli/commands.js +183 -0
  20. package/cli/config.js +104 -0
  21. package/cli/connection.js +90 -0
  22. package/cli/context-mcp.js +193 -0
  23. package/cli/context.js +340 -0
  24. package/cli/delivery.js +67 -0
  25. package/cli/doctor.js +283 -0
  26. package/cli/env.js +66 -0
  27. package/cli/fleet.js +82 -0
  28. package/cli/gsc-browser.js +239 -0
  29. package/cli/import.js +108 -0
  30. package/cli/ledger.js +232 -0
  31. package/cli/linktrail.mjs +6 -0
  32. package/cli/local-result.js +161 -0
  33. package/cli/main.js +523 -0
  34. package/cli/mirror.js +126 -0
  35. package/cli/mix.js +123 -0
  36. package/cli/platforms.js +161 -0
  37. package/cli/receipt-performance.js +64 -0
  38. package/cli/receipts.js +122 -0
  39. package/cli/report.js +158 -0
  40. package/cli/setup-plan.js +211 -0
  41. package/cli/skill.js +39 -0
  42. package/cli/state.js +106 -0
  43. package/cli/status.js +51 -0
  44. package/cli/sync.js +179 -0
  45. package/package.json +59 -0
  46. package/references/cli.md +198 -0
  47. package/references/disavow.md +73 -0
  48. package/references/reports.md +53 -0
  49. package/scripts/agentlinkops.py +651 -0
  50. package/scripts/disavow.py +207 -0
  51. package/scripts/linktrail.py +10 -0
  52. package/scripts/migrations/001_crm.sql +45 -0
  53. package/scripts/migrations/002_cloud_events.sql +13 -0
  54. package/scripts/migrations/003_disavow.sql +20 -0
  55. package/scripts/migrations/004_placement_costs.sql +2 -0
  56. package/scripts/report.py +113 -0
  57. package/shared/agent-clients.js +49 -0
  58. package/shared/observation-event.js +43 -0
  59. package/shared/response-contract.js +59 -0
  60. package/shared/search-connection-contract.js +18 -0
  61. package/skills/agentlinkops-assets/SKILL.md +13 -0
  62. package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
  63. package/skills/agentlinkops-campaigns/SKILL.md +17 -0
  64. package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
  65. package/skills/agentlinkops-connect/SKILL.md +33 -0
  66. package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
  67. package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
  68. package/skills/agentlinkops-crm/SKILL.md +27 -0
  69. package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
  70. package/skills/content-defingerprinting/SKILL.md +29 -0
  71. package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
  82. package/skills/internal-linking-optimizer/SKILL.md +56 -0
  83. package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
  84. package/skills/pre-publish-review/SKILL.md +179 -0
  85. package/skills/source-cited-content-builder/SKILL.md +56 -0
  86. package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
  87. package/skills/source-cited-humanizer/SKILL.md +20 -0
  88. package/src/citations/adapter.js +141 -0
  89. package/src/citations/contract.js +171 -0
  90. package/src/citations/match.js +120 -0
  91. package/src/citations/runner.js +151 -0
  92. package/src/citations/stats.js +74 -0
  93. package/src/competitors/contract.js +157 -0
  94. package/src/competitors/domain-mix.js +199 -0
  95. package/src/context/ga4.js +652 -0
  96. package/src/context/gsc.js +925 -0
  97. package/src/context/site-profile.js +445 -0
  98. package/src/context/util.js +71 -0
  99. package/src/context-tool-registry.js +27 -0
  100. package/src/corpus/acquire.js +135 -0
  101. package/src/corpus/seeds.js +182 -0
  102. package/src/discovery/contract.js +237 -0
  103. package/src/discovery/import.js +156 -0
  104. package/src/tool-views.js +117 -0
  105. package/src/verifier/fetch.js +303 -0
  106. package/src/verifier/index.js +311 -0
  107. package/src/verifier/observation-kind.js +91 -0
  108. package/src/verifier/parser.js +241 -0
  109. package/src/verifier/readiness.js +90 -0
  110. package/src/verifier/render-evidence.js +57 -0
  111. package/src/verifier/robots.js +85 -0
  112. package/src/verifier/transition.js +129 -0
  113. package/src/verifier/url.js +56 -0
  114. package/templates/SITE.template.md +59 -0
  115. package/templates/campaigns.v1.json +65 -0
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env python3
2
+ """Zero-LLM statistical & stylometric AI-text detector.
3
+
4
+ Evaluates text for high-frequency AI cadence markers, sentence-length
5
+ burstiness variance, lexical diversity (MATTR), and structural uniformity.
6
+ Stdlib-only, no PyTorch or external model dependencies.
7
+
8
+ Exit codes:
9
+ 0 clean (score < threshold)
10
+ 1 suspicious / AI stylometric signals detected (score >= threshold)
11
+ 2 bad input (missing file, binary data, bad args)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import math
18
+ import re
19
+ import sys
20
+ from collections import Counter
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ SCRIPTS_DIR = Path(__file__).resolve().parent
26
+ sys.path.insert(0, str(SCRIPTS_DIR))
27
+
28
+ from common import ( # noqa: E402
29
+ classify_finding_confidence,
30
+ emit_json,
31
+ read_text_input,
32
+ )
33
+
34
+ DEFAULT_THRESHOLD = 0.65
35
+ MIN_SAMPLE_WORDS = 30
36
+ FULL_WEIGHT_WORDS = 100
37
+
38
+ # High-frequency formulaic transition markers, hedging verbs, and structural
39
+ # boilerplate commonly overrepresented in AI-generated text across frontier LLMs.
40
+ AI_PHRASE_PATTERNS: tuple[tuple[str, str, float], ...] = (
41
+ # (regex_pattern, human_label, weight)
42
+ (r"\bdelve(?:s|d)?\s+into\b", "delve into", 1.2),
43
+ (r"\ba\s+testament\s+to\b", "a testament to", 1.1),
44
+ (r"\brich\s+tapestry(?:\s+of)?\b", "rich tapestry", 1.3),
45
+ (r"\bplays?\s+a\s+(?:pivotal|crucial|vital|key)\s+role\b", "plays a pivotal/crucial role", 1.0),
46
+ (
47
+ r"\bin\s+(?:today'?s|the)\s+(?:(?:fast-paced|ever-evolving|digital|rapidly\s+changing)\s+)*(?:world|landscape|era|environment)\b",
48
+ "in today's fast-paced world/landscape",
49
+ 1.4,
50
+ ),
51
+ (
52
+ r"\bit\s+is\s+(?:important|essential|crucial|worth\s+noting)\s+to\s+(?:note|remember|consider|highlight)\b",
53
+ "it is important/crucial to note",
54
+ 0.9,
55
+ ),
56
+ (
57
+ r"\bnot\s+only\b[\w\s,]+\bbut\s+(?:also\s+)?(?:serves\s+to|acts\s+as|highlights)\b",
58
+ "not only ... but also serves to",
59
+ 0.8,
60
+ ),
61
+ (
62
+ r"\bserve(?:s|d)?\s+as\s+a\s+(?:beacon|reminder|catalyst|cornerstone)\b",
63
+ "serves as a beacon/catalyst/cornerstone",
64
+ 1.1,
65
+ ),
66
+ (
67
+ r"\bunderscore(?:s|d)?\s+the\s+(?:importance|need|significance)\b",
68
+ "underscores the importance/need",
69
+ 0.9,
70
+ ),
71
+ (
72
+ r"\bfoster(?:s|ing|ed)?\s+a\s+(?:sense|culture|deeper\s+understanding)\b",
73
+ "fosters a sense/culture",
74
+ 0.9,
75
+ ),
76
+ (
77
+ r"\bseamlessly\s+(?:integrates?|integrated|blends?|combine[sd]?)\b",
78
+ "seamlessly integrates/blends",
79
+ 1.0,
80
+ ),
81
+ (
82
+ r"\bnavigat(?:e|ing|es|ed)\s+the\s+(?:complexities|intricacies|nuances)\b",
83
+ "navigating the complexities/nuances",
84
+ 1.0,
85
+ ),
86
+ (r"\bmultifaceted\s+(?:nature|approach|landscape)\b", "multifaceted nature/approach", 1.0),
87
+ (r"\bharness(?:ing|ed|es)?\s+the\s+power\s+of\b", "harnessing the power of", 1.0),
88
+ (r"\ba\s+myriad\s+of\b", "a myriad of", 0.8),
89
+ (r"\bparadigm\s+shift\b", "paradigm shift", 0.9),
90
+ (r"\bholistic\s+(?:approach|view|perspective)\b", "holistic approach/perspective", 0.9),
91
+ (r"\bin\s+conclusion\b[,\s]", "in conclusion", 0.8),
92
+ (r"\bto\s+summarize\b[,\s]", "to summarize", 0.8),
93
+ (r"\bultimately\b[,\s]", "ultimately,", 0.6),
94
+ (r"\bfurthermore\b[,\s]", "furthermore,", 0.6),
95
+ (r"\bmoreover\b[,\s]", "moreover,", 0.6),
96
+ (r"\bas\s+an\s+ai\b", "as an AI", 1.5),
97
+ (r"\bi\s+hope\s+this\s+helps\b", "I hope this helps", 1.2),
98
+ )
99
+
100
+ RE_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9\"'(\[])")
101
+ RE_WORDS = re.compile(r"\b[\w'-]+\b", re.UNICODE)
102
+
103
+
104
+ @dataclass
105
+ class MarkerMatch:
106
+ phrase: str
107
+ count: int
108
+ weight: float
109
+ samples: list[str] = field(default_factory=list)
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ return {
113
+ "phrase": self.phrase,
114
+ "count": self.count,
115
+ "weight": self.weight,
116
+ "samples": self.samples,
117
+ }
118
+
119
+
120
+ @dataclass
121
+ class StylometryReport:
122
+ path: str
123
+ word_count: int
124
+ sentence_count: int
125
+ burstiness_cv: float | None
126
+ lexical_diversity: float
127
+ ai_ngram_density: float
128
+ matched_markers: list[dict[str, Any]]
129
+ score: float
130
+ confidence_level: str # CLEAN | LOW | MEDIUM | HIGH
131
+ status: str # ok | insufficient_length
132
+ findings: list[str] = field(default_factory=list)
133
+ notes: list[str] = field(default_factory=list)
134
+
135
+ def to_dict(self) -> dict[str, Any]:
136
+ return {
137
+ "path": self.path,
138
+ "word_count": self.word_count,
139
+ "sentence_count": self.sentence_count,
140
+ "burstiness_cv": round(self.burstiness_cv, 4)
141
+ if self.burstiness_cv is not None
142
+ else None,
143
+ "lexical_diversity": round(self.lexical_diversity, 4),
144
+ "ai_ngram_density": round(self.ai_ngram_density, 4),
145
+ "matched_markers": self.matched_markers,
146
+ "score": round(self.score, 4),
147
+ "confidence_level": self.confidence_level,
148
+ "status": self.status,
149
+ "findings": self.findings,
150
+ "findings_confidence": [classify_finding_confidence(f) for f in self.findings],
151
+ "notes": self.notes,
152
+ }
153
+
154
+
155
+ def extract_sentences(text: str) -> list[str]:
156
+ """Split text into sentences while ignoring blank lines and code block markers."""
157
+ clean_lines = []
158
+ in_code_block = False
159
+ for line in text.splitlines():
160
+ trimmed = line.strip()
161
+ if trimmed.startswith("```"):
162
+ in_code_block = not in_code_block
163
+ continue
164
+ if in_code_block or not trimmed:
165
+ continue
166
+ clean_lines.append(trimmed)
167
+
168
+ raw_text = "\n".join(clean_lines)
169
+ if not raw_text.strip():
170
+ return []
171
+
172
+ chunks = re.split(r"(?<=[.!?])\s+|\n+", raw_text)
173
+ sentences = [s.strip() for s in chunks if s.strip()]
174
+ return sentences
175
+
176
+
177
+ def extract_words(text: str) -> list[str]:
178
+ """Extract normalized alphanumeric word tokens."""
179
+ return [w.lower() for w in RE_WORDS.findall(text)]
180
+
181
+
182
+ def compute_burstiness(sentences: list[str]) -> tuple[float, float, float | None]:
183
+ """Compute mean sentence word length, standard deviation, and coefficient of variation (CV).
184
+
185
+ CV is ``None`` when it cannot be measured (no sentences, or fewer than two
186
+ with words): a one-or-zero-sample CV is undefined, and reporting it as 0.0
187
+ made the caller's tiering read "perfectly uniform" — the strongest
188
+ LLM-likeness signal — for text whose body yielded no sentences at all (#132).
189
+ """
190
+ if not sentences:
191
+ return 0.0, 0.0, None
192
+
193
+ lengths = [len(extract_words(s)) for s in sentences]
194
+ lengths = [L for L in lengths if L > 0]
195
+ if len(lengths) < 2:
196
+ mean_len = float(lengths[0]) if lengths else 0.0
197
+ return mean_len, 0.0, None
198
+
199
+ mean_len = sum(lengths) / len(lengths)
200
+ variance = sum((x - mean_len) ** 2 for x in lengths) / (len(lengths) - 1)
201
+ std_dev = math.sqrt(variance)
202
+ cv = (std_dev / mean_len) if mean_len > 0 else 0.0
203
+ return mean_len, std_dev, cv
204
+
205
+
206
+ def compute_mattr(words: list[str], window_size: int = 50) -> float:
207
+ """Compute Moving-Average Type-Token Ratio (MATTR) across sliding windows."""
208
+ n = len(words)
209
+ if n == 0:
210
+ return 0.0
211
+ if n <= window_size:
212
+ return len(set(words)) / n
213
+
214
+ total_ttr = 0.0
215
+ num_windows = n - window_size + 1
216
+ # Sliding window counts
217
+ current_window = Counter(words[:window_size])
218
+ total_ttr += len(current_window) / window_size
219
+
220
+ for i in range(1, num_windows):
221
+ leaving_word = words[i - 1]
222
+ entering_word = words[i + window_size - 1]
223
+
224
+ current_window[leaving_word] -= 1
225
+ if current_window[leaving_word] == 0:
226
+ del current_window[leaving_word]
227
+
228
+ current_window[entering_word] += 1
229
+ total_ttr += len(current_window) / window_size
230
+
231
+ return total_ttr / num_windows
232
+
233
+
234
+ def scan_ai_phrases(text: str) -> list[MarkerMatch]:
235
+ """Find and tally high-frequency AI cadence phrases."""
236
+ matches: list[MarkerMatch] = []
237
+ for pattern, label, weight in AI_PHRASE_PATTERNS:
238
+ found_spans = []
239
+ for m in re.finditer(pattern, text, re.IGNORECASE):
240
+ found_spans.append(m.group(0))
241
+ if found_spans:
242
+ matches.append(
243
+ MarkerMatch(
244
+ phrase=label,
245
+ count=len(found_spans),
246
+ weight=weight,
247
+ samples=found_spans[:3],
248
+ )
249
+ )
250
+ return matches
251
+
252
+
253
+ def score_text_stylometry(text: str, path: str = "<text>") -> StylometryReport:
254
+ """Run full multi-dimensional stylometric analysis and return a structured report."""
255
+ words = extract_words(text)
256
+ word_count = len(words)
257
+ sentences = extract_sentences(text)
258
+ sentence_count = len(sentences)
259
+
260
+ findings: list[str] = []
261
+ notes: list[str] = []
262
+
263
+ # 1. Length Guard
264
+ if word_count < MIN_SAMPLE_WORDS:
265
+ marker_matches = scan_ai_phrases(text)
266
+ for m in marker_matches:
267
+ findings.append(f"AI phrase marker '{m.phrase}' found ({m.count}x)")
268
+ notes.append(
269
+ f"Sample contains {word_count} words; statistical stylometry is uncalibrated below {MIN_SAMPLE_WORDS} words"
270
+ )
271
+ return StylometryReport(
272
+ path=path,
273
+ word_count=word_count,
274
+ sentence_count=sentence_count,
275
+ burstiness_cv=None,
276
+ lexical_diversity=compute_mattr(words),
277
+ ai_ngram_density=0.0,
278
+ matched_markers=[m.to_dict() for m in marker_matches],
279
+ score=0.0,
280
+ confidence_level="CLEAN",
281
+ status="insufficient_length",
282
+ findings=findings,
283
+ notes=notes,
284
+ )
285
+
286
+ # 2. Metric Calculations
287
+ _, _, cv = compute_burstiness(sentences)
288
+ mattr = compute_mattr(words)
289
+ marker_matches = scan_ai_phrases(text)
290
+
291
+ # N-gram density: weighted marker instances per 100 words
292
+ total_marker_weight = sum(m.count * m.weight for m in marker_matches)
293
+ ngram_density = (total_marker_weight / (word_count / 100.0)) if word_count > 0 else 0.0
294
+
295
+ # 3. Component Sub-scores (0.0 to 1.0)
296
+ # Burstiness subscore: low CV (<0.35) is strongly characteristic of LLMs; high CV (>0.60) is human
297
+ burstiness_score: float | None
298
+ if cv is None:
299
+ # Fewer than two parseable sentences: burstiness is unmeasurable, not
300
+ # maximally LLM-like. The composite renormalizes over the remaining
301
+ # components instead of crediting the strongest signal (#132).
302
+ burstiness_score = None
303
+ elif cv < 0.25:
304
+ burstiness_score = 0.95
305
+ elif cv < 0.35:
306
+ burstiness_score = 0.80
307
+ elif cv < 0.45:
308
+ burstiness_score = 0.50
309
+ elif cv < 0.55:
310
+ burstiness_score = 0.25
311
+ else:
312
+ burstiness_score = 0.05
313
+
314
+ # N-gram subscore: >1.5 weighted matches per 100 words is very high
315
+ if ngram_density >= 2.0:
316
+ ngram_score = 1.0
317
+ elif ngram_density >= 1.0:
318
+ ngram_score = 0.75
319
+ elif ngram_density >= 0.5:
320
+ ngram_score = 0.45
321
+ elif ngram_density > 0:
322
+ ngram_score = 0.20
323
+ else:
324
+ ngram_score = 0.0
325
+
326
+ # Lexical uniformity subscore: LLMs cluster tightly around MATTR 0.65-0.78 for 50-word windows
327
+ diversity_score = 0.4 if 0.68 <= mattr <= 0.76 else 0.1
328
+
329
+ # 4. Composite Scoring & Small-Sample Dampening
330
+ if burstiness_score is None:
331
+ notes.append(
332
+ "Sentence burstiness unavailable (fewer than 2 parsed sentences — e.g. body wrapped in a code fence); composite renormalized over AI-phrase density and lexical diversity"
333
+ )
334
+ raw_composite = ((ngram_score * 0.45) + (diversity_score * 0.10)) / 0.55
335
+ else:
336
+ raw_composite = (burstiness_score * 0.45) + (ngram_score * 0.45) + (diversity_score * 0.10)
337
+
338
+ # Dampening factor: scales smoothly from 0.4 at MIN_SAMPLE_WORDS up to 1.0 at FULL_WEIGHT_WORDS
339
+ if word_count < FULL_WEIGHT_WORDS:
340
+ dampener = 0.4 + 0.6 * (
341
+ (word_count - MIN_SAMPLE_WORDS) / (FULL_WEIGHT_WORDS - MIN_SAMPLE_WORDS)
342
+ )
343
+ notes.append(
344
+ f"Sample word count ({word_count}) is in calibration range ({MIN_SAMPLE_WORDS}-{FULL_WEIGHT_WORDS}); score dampened by factor {dampener:.2f}"
345
+ )
346
+ else:
347
+ dampener = 1.0
348
+
349
+ final_score = min(1.0, max(0.0, raw_composite * dampener))
350
+
351
+ # 5. Classify Findings & Confidence Tiers
352
+ if marker_matches:
353
+ for m in marker_matches:
354
+ findings.append(f"AI cadence phrase '{m.phrase}' ({m.count}x)")
355
+
356
+ if cv is not None and cv < 0.35 and sentence_count >= 3:
357
+ findings.append(f"Unnaturally uniform sentence cadence (CV={cv:.2f} < 0.35)")
358
+
359
+ if ngram_density >= 1.0:
360
+ findings.append(f"Elevated AI formulaic transition density ({ngram_density:.2f}/100w)")
361
+
362
+ if final_score >= 0.75:
363
+ confidence = "HIGH"
364
+ elif final_score >= 0.50:
365
+ confidence = "MEDIUM"
366
+ elif final_score >= 0.25:
367
+ confidence = "LOW"
368
+ else:
369
+ confidence = "CLEAN"
370
+
371
+ return StylometryReport(
372
+ path=path,
373
+ word_count=word_count,
374
+ sentence_count=sentence_count,
375
+ burstiness_cv=cv,
376
+ lexical_diversity=mattr,
377
+ ai_ngram_density=ngram_density,
378
+ matched_markers=[m.to_dict() for m in marker_matches],
379
+ score=final_score,
380
+ confidence_level=confidence,
381
+ status="ok",
382
+ findings=findings,
383
+ notes=notes,
384
+ )
385
+
386
+
387
+ def print_human_stylometry_report(report: StylometryReport, explain: bool = False) -> None:
388
+ """Print clean human-readable output to stdout."""
389
+ print(f"=== Stylometric AI-Text Report: {report.path} ===")
390
+ print(f"Status: {report.status}")
391
+ print(f"Confidence Level: {report.confidence_level}")
392
+ print(f"AI Probability: {report.score * 100:.1f}% (score: {report.score:.3f})")
393
+ print(f"Word Count: {report.word_count}")
394
+ print(f"Sentence Count: {report.sentence_count}")
395
+ cv_display = (
396
+ f"{report.burstiness_cv:.3f}"
397
+ if report.burstiness_cv is not None
398
+ else "n/a (fewer than 2 sentences)"
399
+ )
400
+ print(f"Sentence CV: {cv_display}")
401
+ print(f"Lexical Diversity: {report.lexical_diversity:.3f} (MATTR)")
402
+ print(f"AI Marker Density: {report.ai_ngram_density:.3f} / 100 words")
403
+
404
+ if report.findings:
405
+ print("\nFindings:")
406
+ for f in report.findings:
407
+ print(f" - {f}")
408
+
409
+ if explain and report.matched_markers:
410
+ print("\nMatched Phrases Detail:")
411
+ for m in report.matched_markers:
412
+ print(f" * {m['phrase']} (occurrences: {m['count']}, weight: {m['weight']})")
413
+ if m.get("samples"):
414
+ print(f' sample: "{m["samples"][0]}"')
415
+
416
+ if report.notes:
417
+ print("\nNotes:")
418
+ for n in report.notes:
419
+ print(f" * {n}")
420
+
421
+
422
+ def main() -> int:
423
+ p = argparse.ArgumentParser(
424
+ description=__doc__,
425
+ formatter_class=argparse.RawDescriptionHelpFormatter,
426
+ )
427
+ p.add_argument("path", nargs="?", default="-", help="Text file to score ('-' for stdin)")
428
+ p.add_argument(
429
+ "--threshold",
430
+ type=float,
431
+ default=DEFAULT_THRESHOLD,
432
+ help=f"Score threshold to trigger exit code 1 (default: {DEFAULT_THRESHOLD})",
433
+ )
434
+ p.add_argument("--json", action="store_true", help="Emit JSON output")
435
+ p.add_argument(
436
+ "--explain", action="store_true", help="Include detailed matched phrase breakdown"
437
+ )
438
+
439
+ args = p.parse_args()
440
+
441
+ # Read input
442
+ text = read_text_input(args.path)
443
+ if text is None:
444
+ return 2
445
+
446
+ input_label = "<stdin>" if args.path == "-" else args.path
447
+ report = score_text_stylometry(text, path=input_label)
448
+
449
+ if args.json:
450
+ emit_json(report.to_dict())
451
+ else:
452
+ print_human_stylometry_report(report, explain=args.explain)
453
+
454
+ if report.score >= args.threshold:
455
+ return 1
456
+ return 0
457
+
458
+
459
+ if __name__ == "__main__":
460
+ sys.exit(main())