agentforge-framework 0.2.0__py3-none-any.whl

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 (89) hide show
  1. agentforge_framework/.claude-plugin/plugin.json +4 -0
  2. agentforge_framework/__init__.py +3 -0
  3. agentforge_framework/agents/__init__.py +92 -0
  4. agentforge_framework/agents/architect.py +146 -0
  5. agentforge_framework/agents/implementer.py +162 -0
  6. agentforge_framework/agents/orchestrator.py +588 -0
  7. agentforge_framework/agents/reviewer.py +335 -0
  8. agentforge_framework/agents/security.py +138 -0
  9. agentforge_framework/agents/tester.py +125 -0
  10. agentforge_framework/cli.py +461 -0
  11. agentforge_framework/context/__init__.py +1 -0
  12. agentforge_framework/context/extractors/__init__.py +76 -0
  13. agentforge_framework/context/extractors/base.py +47 -0
  14. agentforge_framework/context/extractors/python.py +65 -0
  15. agentforge_framework/context/extractors/sql.py +121 -0
  16. agentforge_framework/context/extractors/yaml.py +59 -0
  17. agentforge_framework/context/prompt.py +104 -0
  18. agentforge_framework/context/resolver.py +185 -0
  19. agentforge_framework/core/__init__.py +1 -0
  20. agentforge_framework/core/commands.py +170 -0
  21. agentforge_framework/core/config.py +90 -0
  22. agentforge_framework/core/contracts.py +875 -0
  23. agentforge_framework/core/gates.py +333 -0
  24. agentforge_framework/core/issues.py +697 -0
  25. agentforge_framework/core/plan_format.py +272 -0
  26. agentforge_framework/core/process.py +141 -0
  27. agentforge_framework/core/project.py +262 -0
  28. agentforge_framework/core/registry.py +455 -0
  29. agentforge_framework/core/repo.py +185 -0
  30. agentforge_framework/core/router.py +1 -0
  31. agentforge_framework/core/runtime.py +639 -0
  32. agentforge_framework/core/skills.py +255 -0
  33. agentforge_framework/core/workflow.py +215 -0
  34. agentforge_framework/plugins/__init__.py +35 -0
  35. agentforge_framework/plugins/databricks/__init__.py +86 -0
  36. agentforge_framework/plugins/pyspark/__init__.py +57 -0
  37. agentforge_framework/plugins/python/__init__.py +45 -0
  38. agentforge_framework/plugins/sql/__init__.py +377 -0
  39. agentforge_framework/providers/__init__.py +48 -0
  40. agentforge_framework/providers/base.py +248 -0
  41. agentforge_framework/providers/claude.py +159 -0
  42. agentforge_framework/providers/codex.py +139 -0
  43. agentforge_framework/skills/MANIFEST.yaml +157 -0
  44. agentforge_framework/skills/NOTICE +49 -0
  45. agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
  46. agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  47. agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
  48. agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
  49. agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
  50. agentforge_framework/skills/grilling/SKILL.md +28 -0
  51. agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
  52. agentforge_framework/skills/to-spec/SKILL.md +75 -0
  53. agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
  54. agentforge_framework/skills/to-tickets/SKILL.md +105 -0
  55. agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
  56. agentforge_framework/skills/unslop/SKILL.md +131 -0
  57. agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
  58. agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
  59. agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
  60. agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
  61. agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
  62. agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
  63. agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
  64. agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
  65. agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
  66. agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
  67. agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
  68. agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
  69. agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
  70. agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
  71. agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
  72. agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
  73. agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
  74. agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
  75. agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
  76. agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
  77. agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
  78. agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
  79. agentforge_framework/skills/write-plainly/SKILL.md +94 -0
  80. agentforge_framework/workflows/bugfix.yaml +8 -0
  81. agentforge_framework/workflows/feature.yaml +16 -0
  82. agentforge_framework/workflows/review.yaml +10 -0
  83. agentforge_framework-0.2.0.dist-info/METADATA +321 -0
  84. agentforge_framework-0.2.0.dist-info/RECORD +89 -0
  85. agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
  86. agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
  87. agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
  88. agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
  89. agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env python3
2
+ """Scan prose for macro-structure AI-writing patterns."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import math
9
+ import re
10
+ import sys
11
+ from collections import Counter
12
+ from pathlib import Path
13
+
14
+ HERE = Path(__file__).resolve().parent
15
+ sys.path.insert(0, str(HERE))
16
+
17
+ from _lang import ( # noqa: E402
18
+ ENGLISH_FUNCTION_WORDS,
19
+ english_function_share,
20
+ is_probably_english,
21
+ paragraphs as _prose_paragraphs,
22
+ words,
23
+ )
24
+ from readability_metrics import split_sentences # noqa: E402
25
+
26
+
27
+ STOPWORDS = {
28
+ "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of",
29
+ "with", "by", "from", "is", "are", "was", "were", "be", "been", "being",
30
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
31
+ "should", "may", "might", "must", "can", "it", "its", "this", "that",
32
+ "these", "those", "we", "you", "they", "i", "he", "she", "as", "than",
33
+ "if", "then", "so", "not", "no", "yes", "into", "over", "under",
34
+ }
35
+
36
+
37
+ CONNECTIVE_OPENERS = re.compile(
38
+ r"^(however|moreover|furthermore|additionally|in addition|overall|"
39
+ r"consequently|nevertheless)\b",
40
+ re.I,
41
+ )
42
+ # Paragraph-initial "Every <noun> <verb-phrase>" template opener ("Every
43
+ # transformation is scored ...", "Every rewrite passes ..."). As a RHYTHM tell
44
+ # this is about REPETITION, so the metric below fires only at 2+ such paragraph
45
+ # openers; a lone "Every child deserves a good school." stays clean. The verb is
46
+ # a copula/auxiliary or a present/past inflection (-s / -ed) so the second word
47
+ # reads as a predicate, not another noun.
48
+ EVERY_OPENER_RE = re.compile(
49
+ r"^every\s+[a-z][\w'-]*\s+(?:is|are|was|were|be|been|being|has|have|had|"
50
+ r"do|does|did|can|will|shall|must|should|would|may|might|"
51
+ r"[a-z]+(?:s|ed))\b",
52
+ re.I,
53
+ )
54
+ SIGNPOST_RE = re.compile(
55
+ r"\b(first,|next,|having (?:covered|established)|in (?:this|the following|"
56
+ r"the next) section|as (?:mentioned|noted) (?:above|earlier)|let us turn|"
57
+ r"let's turn)\b",
58
+ re.I,
59
+ )
60
+ CLOSER_RE = re.compile(
61
+ r",\s+(ensuring|highlighting|reflecting|allowing|enabling|underscoring|"
62
+ r"showcasing|emphasizing|fostering|driving|paving|reinforcing|solidifying|"
63
+ r"demonstrating|contributing to)\b[^.!?]*[.!?\"]?$",
64
+ re.I,
65
+ )
66
+ CODA_START_RE = re.compile(
67
+ r"^(ultimately,|in the end,|in conclusion|as we've seen|only time will tell|"
68
+ r"remember,|the future\b)",
69
+ re.I,
70
+ )
71
+ BOLD_COLON_RE = re.compile(r"^\s*[-*+]?\s*\*\*[^*]{1,40}\*\*\s*:", re.M)
72
+
73
+
74
+ def prose_paragraphs(text: str) -> list[str]:
75
+ return _prose_paragraphs(text, blank_blockquotes=True)
76
+
77
+
78
+ def cv(values: list[int]) -> float:
79
+ if not values:
80
+ return 0.0
81
+ mean = sum(values) / len(values)
82
+ if mean == 0:
83
+ return 0.0
84
+ return math.sqrt(sum((v - mean) ** 2 for v in values) / len(values)) / mean
85
+
86
+
87
+ def content_bigrams(text: str) -> set[tuple[str, str]]:
88
+ toks = [w for w in words(text) if len(w) > 3 and w not in STOPWORDS]
89
+ return set(zip(toks, toks[1:]))
90
+
91
+
92
+ def triad_count(text: str) -> int:
93
+ return len(re.findall(r"\b[A-Za-z][A-Za-z-]+,\s+[A-Za-z][A-Za-z-]+,\s+and\s+[A-Za-z][A-Za-z-]+\b", text))
94
+
95
+
96
+ def flag(metric: str, value: float | int, threshold: str, detail: str, suggestion: str) -> dict:
97
+ return {
98
+ "metric": metric,
99
+ "value": value,
100
+ "threshold": threshold,
101
+ "severity": "soft",
102
+ "detail": detail,
103
+ "suggestion": suggestion,
104
+ }
105
+
106
+
107
+ # Metrics a given --genre suppresses outright (the genre's normal shape trips
108
+ # the metric without being AI-slop). Table form so each suppression is a single
109
+ # lookup instead of an inline `genre != "..."` conditional per metric.
110
+ GENRE_SUPPRESSIONS = {
111
+ "docs": {"bold_colon_listicle"},
112
+ "social": {"one_line_staccato"},
113
+ }
114
+
115
+
116
+ def scan(text: str, genre: str = "prose") -> dict:
117
+ paragraphs = prose_paragraphs(text)
118
+ prose_text = "\n\n".join(paragraphs)
119
+ sentences = split_sentences(prose_text)
120
+ sentence_lengths = [len(words(s)) for s in sentences]
121
+ prose_words = words(prose_text)
122
+ para_lengths = [len(words(p)) for p in paragraphs]
123
+ metrics = {
124
+ "sentence_burstiness": round(cv(sentence_lengths), 3),
125
+ "summary_sandwich": 0.0,
126
+ "paragraph_cv": round(cv(para_lengths), 3),
127
+ "sentence_mean_len": round(sum(sentence_lengths) / len(sentence_lengths), 1) if sentence_lengths else 0,
128
+ "triad_density": round((triad_count(prose_text) / len(prose_words) * 1000), 3) if prose_words else 0,
129
+ "em_dash_per_1k": round((text.count("—") / len(prose_words) * 1000), 3) if prose_words else 0,
130
+ "bold_colon_listicle_count": len(BOLD_COLON_RE.findall(text)),
131
+ "one_line_staccato_share": 0.0,
132
+ "connective_paragraph_openers": 0,
133
+ "every_template_openers": 0,
134
+ "signpost_density": 0.0,
135
+ "opener_unique_ratio": 0.0,
136
+ "top_opener_share": 0.0,
137
+ "max_consecutive_opener": 0,
138
+ "participial_closer_share": 0.0,
139
+ "conclusion_coda": False,
140
+ }
141
+ flags = []
142
+
143
+ if len(paragraphs) >= 2:
144
+ first = content_bigrams(paragraphs[0])
145
+ last = content_bigrams(paragraphs[-1])
146
+ union = first | last
147
+ metrics["summary_sandwich"] = round(len(first & last) / len(union), 3) if union else 0.0
148
+
149
+ if len(sentences) >= 8 and metrics["sentence_burstiness"] < 0.55:
150
+ flags.append(flag(
151
+ "sentence_burstiness",
152
+ metrics["sentence_burstiness"],
153
+ "< 0.55 over at least 8 prose sentences",
154
+ "Sentence lengths are unusually uniform for running prose.",
155
+ "Vary sentence length and cadence; if this is formal reference prose, review before treating it as blocking.",
156
+ ))
157
+
158
+ if len(paragraphs) >= 3:
159
+ coda = bool(CODA_START_RE.search(paragraphs[-1]))
160
+ if not coda:
161
+ coda = len(content_bigrams(paragraphs[0]) & content_bigrams(paragraphs[-1])) >= 2
162
+ metrics["conclusion_coda"] = coda
163
+ if coda:
164
+ flags.append(flag(
165
+ "conclusion_coda",
166
+ 1,
167
+ "last paragraph starts with a stock coda or repeats 2+ first-paragraph content bigrams",
168
+ "The ending reads like a recap/moral coda instead of a concrete final point.",
169
+ "Cut the wrap-up or end on a specific fact; if this is an abstract or executive summary, judge the genre before changing it.",
170
+ ))
171
+
172
+ if (metrics["bold_colon_listicle_count"] >= 3
173
+ and "bold_colon_listicle" not in GENRE_SUPPRESSIONS.get(genre, set())):
174
+ flags.append(flag(
175
+ "bold_colon_listicle",
176
+ metrics["bold_colon_listicle_count"],
177
+ ">= 3 bold-label colon lines",
178
+ "The raw Markdown has repeated bold-label listicle formatting.",
179
+ "Convert to prose or plain bullets; if this is a reference doc, rerun with --genre docs.",
180
+ ))
181
+
182
+ if paragraphs:
183
+ one_line = 0
184
+ for p in paragraphs:
185
+ ps = split_sentences(p)
186
+ if len(ps) == 1 and len(words(ps[0])) < 12:
187
+ one_line += 1
188
+ metrics["one_line_staccato_share"] = round(one_line / len(paragraphs), 3)
189
+ if (len(paragraphs) >= 6 and metrics["one_line_staccato_share"] > 0.6
190
+ and "one_line_staccato" not in GENRE_SUPPRESSIONS.get(genre, set())):
191
+ flags.append(flag(
192
+ "one_line_staccato",
193
+ metrics["one_line_staccato_share"],
194
+ "> 0.60 over at least 6 paragraphs",
195
+ "Most paragraphs are short single-sentence beats.",
196
+ "Merge related beats and vary paragraph length; if this is social copy, rerun with --genre social.",
197
+ ))
198
+
199
+ connective = sum(1 for p in paragraphs if CONNECTIVE_OPENERS.search(p))
200
+ metrics["connective_paragraph_openers"] = connective
201
+ if connective >= 3 or (len(paragraphs) >= 8 and connective / len(paragraphs) > 0.4):
202
+ flags.append(flag(
203
+ "connective_paragraph_openers",
204
+ connective,
205
+ ">= 3 paragraphs or > 40% of 8+ paragraphs",
206
+ "Paragraphs repeatedly open with formal transition words.",
207
+ "Replace scaffold openers with specific topic sentences; academic prose may justify some connectors.",
208
+ ))
209
+
210
+ every_openers = sum(1 for p in paragraphs if EVERY_OPENER_RE.search(p))
211
+ metrics["every_template_openers"] = every_openers
212
+ if every_openers >= 2:
213
+ flags.append(flag(
214
+ "every_template_openers",
215
+ every_openers,
216
+ ">= 2 paragraphs opening 'Every <noun> <verb>'",
217
+ "Paragraphs repeatedly open on the 'Every ___ is/does ...' template.",
218
+ "Vary the paragraph openings; a repeated Every-template is a machine rhythm tell even when each sentence is fine on its own.",
219
+ ))
220
+
221
+ if prose_words:
222
+ signposts = len(SIGNPOST_RE.findall(prose_text))
223
+ metrics["signpost_density"] = round(signposts / len(prose_words) * 100, 3)
224
+ if len(prose_words) >= 150 and metrics["signpost_density"] > 0.6:
225
+ flags.append(flag(
226
+ "signpost_density",
227
+ metrics["signpost_density"],
228
+ "> 0.6 per 100 prose words, minimum 150 words",
229
+ "The text over-explains its own structure.",
230
+ "Remove roadmap language unless the genre is a textbook, legal brief, or long guide.",
231
+ ))
232
+
233
+ if len(sentences) >= 5:
234
+ openers = []
235
+ for s in sentences:
236
+ ws = words(s)
237
+ if not ws:
238
+ continue
239
+ openers.append(ws[0])
240
+ enumeration = {"the", "a", "an", "section", "chapter", "figure", "table",
241
+ "step", "part", "appendix"}
242
+ counted = [o for o in openers if o not in enumeration]
243
+ top_count = 0
244
+ if counted:
245
+ counts = Counter(counted)
246
+ top_count = max(counts.values())
247
+ metrics["opener_unique_ratio"] = round(len(counts) / len(counted), 3)
248
+ metrics["top_opener_share"] = round(top_count / len(counted), 3)
249
+ run = max_run = 0
250
+ prev = None
251
+ for opener in counted:
252
+ run = run + 1 if opener == prev else 1
253
+ prev = opener
254
+ max_run = max(max_run, run)
255
+ metrics["max_consecutive_opener"] = max_run
256
+ top_repeat = metrics["top_opener_share"] > 0.25 and top_count >= 4
257
+ if metrics["opener_unique_ratio"] < 0.55 or top_repeat or max_run >= 4:
258
+ flags.append(flag(
259
+ "opener_repetition",
260
+ metrics["opener_unique_ratio"],
261
+ "unique ratio < 0.55, one opener > 25% with 4+ uses, or 4 consecutive identical openers",
262
+ "Sentence openings repeat in a template-like rhythm.",
263
+ "Rewrite repeated starts; step-by-step docs may need repeated imperative openers.",
264
+ ))
265
+
266
+ if sentences:
267
+ closer_count = sum(1 for s in sentences if CLOSER_RE.search(s))
268
+ metrics["participial_closer_share"] = round(closer_count / len(sentences), 3)
269
+ if len(sentences) >= 8 and metrics["participial_closer_share"] >= 0.15:
270
+ flags.append(flag(
271
+ "participial_closer_share",
272
+ metrics["participial_closer_share"],
273
+ ">= 0.15 over at least 8 prose sentences",
274
+ "Many sentences end with editorial -ing consequence tails.",
275
+ "Make the consequence concrete or cut the tail; analytical prose may allow an occasional closer.",
276
+ ))
277
+
278
+ return {
279
+ "flags": flags,
280
+ "flagged": {f["metric"]: True for f in flags},
281
+ "metrics": metrics,
282
+ "genre": genre,
283
+ "prose_sentences": len(sentences),
284
+ "prose_paragraphs": len(paragraphs),
285
+ }
286
+
287
+
288
+ def parse_args(argv: list[str]) -> argparse.Namespace:
289
+ parser = argparse.ArgumentParser(description=__doc__)
290
+ parser.add_argument("path", nargs="?")
291
+ parser.add_argument("--genre", choices=["prose", "docs", "social"], default="prose")
292
+ return parser.parse_args(argv)
293
+
294
+
295
+ def main(argv: list[str]) -> int:
296
+ args = parse_args(argv)
297
+ if args.path:
298
+ path = Path(args.path)
299
+ if not path.exists():
300
+ print(f"Missing file: {path}", file=sys.stderr)
301
+ return 2
302
+ text = path.read_text(errors="replace")
303
+ else:
304
+ text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
305
+
306
+ # English-only graceful decline, matching banned_phrase_scan.py.
307
+ result = scan(text, args.genre)
308
+
309
+ # Function-word absence alone is not evidence of a foreign language
310
+ # (imperative stacks and buzzword lists are English slop with few function
311
+ # words). Decline only when the heuristic fails AND nothing flagged.
312
+ if not result.get("flags") and not is_probably_english(text):
313
+ print(json.dumps({"non_english": True, "violations": [], "flags": []}, indent=2))
314
+ print("note: input appears non-English; scanner declined (English-only).", file=sys.stderr)
315
+ return 0
316
+
317
+ print(json.dumps(result, indent=2))
318
+ return 1 if result["flags"] else 0
319
+
320
+
321
+ if __name__ == "__main__":
322
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+ """Co-writer suggestion mode: emit LSP-style structured edit suggestions.
3
+
4
+ Detection is cheap and deterministic (banned_phrase_scan + structure_scan).
5
+ Replacement generation is DELEGATED to a stronger model: by default every
6
+ suggestion is emitted with ``"suggested_replacement": null``. A separate
7
+ ``--apply-replacements FILE`` mode merges externally-produced replacements back
8
+ in and light-validates them. The blocking contract gates live in
9
+ check_suggestions.py; this script never rewrites the document itself.
10
+
11
+ Output shape:
12
+ {
13
+ "document": "<original text>",
14
+ "suggestions": [
15
+ {
16
+ "span": {"start": N, "end": N, "text": "..."},
17
+ "severity": "hard" | "soft",
18
+ "category": "...",
19
+ "rationale": "...",
20
+ "suggested_replacement": null,
21
+ "phrased_as_question": bool
22
+ },
23
+ ...
24
+ ],
25
+ "counts": {...}
26
+ }
27
+
28
+ Soft findings are phrased as questions (register-dependent judgment calls);
29
+ hard findings are stated as direct replacements. Suggestions are emitted in a
30
+ deterministic order (span start, then end, then category) and never overlap.
31
+
32
+ Usage:
33
+ python3 scripts/suggest.py document.md
34
+ python3 scripts/suggest.py < document.md
35
+ python3 scripts/suggest.py document.md --apply-replacements replacements.json
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import json
42
+ import re
43
+ import sys
44
+ from pathlib import Path
45
+
46
+ from banned_phrase_scan import (
47
+ is_probably_english,
48
+ scan_for_violations,
49
+ )
50
+ from structure_scan import scan as structure_scan
51
+
52
+
53
+ def _line_starts(text: str) -> list[int]:
54
+ """Character offset at which each line begins (index 0 == line 1)."""
55
+ starts = [0]
56
+ for i, ch in enumerate(text):
57
+ if ch == "\n":
58
+ starts.append(i + 1)
59
+ return starts
60
+
61
+
62
+ def _offset(line_starts: list[int], line_number: int, column: int) -> int:
63
+ return line_starts[line_number - 1] + (column - 1)
64
+
65
+
66
+ def _rationale(category: str, span_text: str, suggestion: str | None, is_soft: bool) -> str:
67
+ """Build a reviewer-facing rationale.
68
+
69
+ Soft findings are worded as a question because they are judgment calls whose
70
+ right answer depends on register; hard findings are stated as a direct fix.
71
+ """
72
+ if is_soft:
73
+ hint = f" Consider: {suggestion}." if suggestion else ""
74
+ return f"Soft tell ({category}): could “{span_text}” be cut or reworded here?{hint}"
75
+ if suggestion:
76
+ return f"AI-writing tell ({category}): replace “{span_text}” — {suggestion}"
77
+ return f"AI-writing tell ({category}): replace “{span_text}”."
78
+
79
+
80
+ def build_suggestions(text: str) -> list[dict]:
81
+ """Detect AI-isms and turn each into a span-anchored suggestion.
82
+
83
+ Spans come from banned_phrase_scan (phrases + structural regexes), which are
84
+ the findings with real character offsets that a replacement can be applied
85
+ to. macro-structure flags from structure_scan are document-level and are
86
+ surfaced in ``counts`` instead, since they have no single applyable span.
87
+ """
88
+ violations = scan_for_violations(text)
89
+ line_starts = _line_starts(text)
90
+ candidates: list[dict] = []
91
+ for v in violations:
92
+ start = _offset(line_starts, v["line_number"], v["column"])
93
+ end = start + len(v["phrase"])
94
+ span_text = text[start:end]
95
+ is_soft = v["severity"] == "soft"
96
+ candidates.append({
97
+ "span": {"start": start, "end": end, "text": span_text},
98
+ "severity": v["severity"],
99
+ "category": v["category"],
100
+ "rationale": _rationale(v["category"], span_text, v.get("suggestion"), is_soft),
101
+ "suggested_replacement": None,
102
+ "phrased_as_question": is_soft,
103
+ })
104
+
105
+ candidates.sort(key=lambda s: (s["span"]["start"], s["span"]["end"], s["category"]))
106
+
107
+ # Enforce non-overlap deterministically: keep the earliest-starting span and
108
+ # drop any later suggestion that overlaps an already-kept one, so the emitted
109
+ # set always satisfies the span-overlap contract gate.
110
+ kept: list[dict] = []
111
+ last_end = -1
112
+ for s in candidates:
113
+ if s["span"]["start"] >= last_end:
114
+ kept.append(s)
115
+ last_end = s["span"]["end"]
116
+ return kept
117
+
118
+
119
+ def counts_block(suggestions: list[dict], struct: dict) -> dict:
120
+ by_category: dict[str, int] = {}
121
+ hard = soft = 0
122
+ for s in suggestions:
123
+ by_category[s["category"]] = by_category.get(s["category"], 0) + 1
124
+ if s["severity"] == "soft":
125
+ soft += 1
126
+ else:
127
+ hard += 1
128
+ return {
129
+ "total": len(suggestions),
130
+ "hard": hard,
131
+ "soft": soft,
132
+ "by_category": by_category,
133
+ "structure_flags": [f["metric"] for f in struct.get("flags", [])],
134
+ }
135
+
136
+
137
+ def apply_replacements(suggestions: list[dict], repl_path: str) -> list[str]:
138
+ """Merge externally-produced replacements into suggestions and light-validate.
139
+
140
+ The replacement file is ``{"replacements": [{"start", "end", "replacement"}]}``.
141
+ Each replacement is keyed to a suggestion by exact (start, end) span. This is
142
+ only a light merge/validation pass; the blocking contract lives in
143
+ check_suggestions.py.
144
+ """
145
+ data = json.loads(Path(repl_path).read_text())
146
+ index = {(r["start"], r["end"]): r["replacement"] for r in data.get("replacements", [])}
147
+ warnings: list[str] = []
148
+ matched: set[tuple[int, int]] = set()
149
+ for s in suggestions:
150
+ key = (s["span"]["start"], s["span"]["end"])
151
+ if key not in index:
152
+ continue
153
+ rep = index[key]
154
+ s["suggested_replacement"] = rep
155
+ matched.add(key)
156
+ if rep == s["span"]["text"]:
157
+ warnings.append(f"replacement for span {list(key)} is identical to span text")
158
+ elif scan_for_violations(rep) or structure_scan(rep).get("flags"):
159
+ warnings.append(f"replacement for span {list(key)} does not pass the scanners in isolation")
160
+ for key in index:
161
+ if key not in matched:
162
+ warnings.append(f"replacement targets unknown span {list(key)}")
163
+ return warnings
164
+
165
+
166
+ def parse_args(argv: list[str]) -> argparse.Namespace:
167
+ parser = argparse.ArgumentParser(description=__doc__)
168
+ parser.add_argument("path", nargs="?", help="Document file. Reads stdin when omitted.")
169
+ parser.add_argument(
170
+ "--apply-replacements",
171
+ metavar="FILE",
172
+ help="Merge externally-produced replacements (JSON) into the suggestions.",
173
+ )
174
+ return parser.parse_args(argv)
175
+
176
+
177
+ def main(argv: list[str]) -> int:
178
+ args = parse_args(argv)
179
+ if args.path:
180
+ path = Path(args.path)
181
+ if not path.exists():
182
+ print(f"Missing file: {path}", file=sys.stderr)
183
+ return 2
184
+ text = path.read_text(errors="replace")
185
+ else:
186
+ text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
187
+
188
+ # English-only graceful decline, matching the two scanners.
189
+ if not is_probably_english(text):
190
+ print(json.dumps({"non_english": True, "document": text, "suggestions": [],
191
+ "counts": {"total": 0, "hard": 0, "soft": 0,
192
+ "by_category": {}, "structure_flags": []}},
193
+ indent=2))
194
+ print("note: input appears non-English; co-writer declined (English-only).", file=sys.stderr)
195
+ return 0
196
+
197
+ suggestions = build_suggestions(text)
198
+ struct = structure_scan(text)
199
+ out = {
200
+ "document": text,
201
+ "suggestions": suggestions,
202
+ "counts": counts_block(suggestions, struct),
203
+ }
204
+ if args.apply_replacements:
205
+ out["apply_warnings"] = apply_replacements(suggestions, args.apply_replacements)
206
+ print(json.dumps(out, indent=2))
207
+ return 0
208
+
209
+
210
+ if __name__ == "__main__":
211
+ raise SystemExit(main(sys.argv[1:]))