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,273 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aggregate a teach-calibration preferences JSONL into per-dimension confidence,
4
+ surface conflicts against a measured stylometric profile, and pick the next
5
+ dimension to play.
6
+
7
+ Preferences JSONL rows (one per game round):
8
+ {"pair_id": "...", "dimension": "contractions", "choice": "a"|"b"|"neither",
9
+ "ts": "...", "a_label": "contracted", "b_label": "expanded"}
10
+
11
+ `a_label`/`b_label` are optional. When present (calibrate_pairs.py's
12
+ transform_applied names the B pole; A sits at the other pole in the same
13
+ dimension's POLES pair) they let this script report a semantic preferred
14
+ DIRECTION ("expanded", "short", "formal", ...) instead of the bare literal
15
+ "a"/"b" tally, which is what --profile conflict detection needs. Rows without
16
+ labels still count toward n and the confidence interval; they just can't
17
+ resolve to a named pole (direction is reported as "a" or "b").
18
+
19
+ Usage:
20
+ python3 calibrate_score.py --preferences prefs.jsonl
21
+ python3 calibrate_score.py --preferences prefs.jsonl --profile profile.json
22
+ python3 calibrate_score.py --preferences prefs.jsonl --next
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import math
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ HERE = Path(__file__).resolve().parent
33
+ sys.path.insert(0, str(HERE))
34
+
35
+ from calibrate_pairs import DIMENSIONS, POLES # noqa: E402
36
+
37
+ MIN_K = 5
38
+ Z = 1.96
39
+
40
+ # Dimension -> (profile_key, pole_meaning) used only for --profile conflict
41
+ # detection. `low_pole`/`high_pole` say which named pole a LOW vs HIGH measured
42
+ # value corresponds to, so a confident stated preference for the opposite pole
43
+ # of what the measured value implies is flagged.
44
+ _PROFILE_LINKS: dict[str, dict] = {
45
+ "contractions": {
46
+ "profile_key": "contraction_rate",
47
+ "low_pole": "expanded",
48
+ "high_pole": "contracted",
49
+ "low_threshold": 0.05,
50
+ "high_threshold": 0.20,
51
+ },
52
+ "sentence_length": {
53
+ "profile_key": "avg_sentence_length",
54
+ "low_pole": "short",
55
+ "high_pole": "long",
56
+ "low_threshold": 12.0,
57
+ "high_threshold": 20.0,
58
+ },
59
+ "staccato": {
60
+ "profile_key": "avg_sentence_length",
61
+ "low_pole": "staccato",
62
+ "high_pole": "flowing",
63
+ "low_threshold": 12.0,
64
+ "high_threshold": 20.0,
65
+ },
66
+ "em_dash": {
67
+ "profile_key": "em_dash_rate",
68
+ "low_pole": "plain",
69
+ "high_pole": "dashed",
70
+ "low_threshold": 0.02,
71
+ "high_threshold": 0.15,
72
+ },
73
+ "connectives": {
74
+ "profile_key": "formal_connective_rate",
75
+ "low_pole": "plain",
76
+ "high_pole": "formal",
77
+ "low_threshold": 0.10,
78
+ "high_threshold": 0.40,
79
+ },
80
+ }
81
+
82
+ CONFIDENCE_STOP = 0.7
83
+ K_STOP = 9
84
+
85
+
86
+ def wilson_lower_bound(successes: int, n: int, z: float = Z) -> float:
87
+ if n == 0:
88
+ return 0.0
89
+ phat = successes / n
90
+ denom = 1 + z * z / n
91
+ center = phat + z * z / (2 * n)
92
+ margin = z * math.sqrt((phat * (1 - phat) + z * z / (4 * n)) / n)
93
+ return round(max(0.0, (center - margin) / denom), 3)
94
+
95
+
96
+ def load_preferences(path: Path) -> list[dict]:
97
+ rows = []
98
+ for line in path.read_text().splitlines():
99
+ line = line.strip()
100
+ if line:
101
+ rows.append(json.loads(line))
102
+ return rows
103
+
104
+
105
+ def dedup_by_pair_id(rows: list[dict]) -> list[dict]:
106
+ """Keep one row per `pair_id`: the LATEST by `ts` (ISO 8601, so a plain
107
+ string comparison orders correctly). A game round replayed twice (e.g. the
108
+ agent crashed mid-write and re-ran the round) must count once toward `n`
109
+ and resolve to whatever the user most recently chose, not double-count or
110
+ let an earlier write win. Rows without a `pair_id` can't be deduped
111
+ against anything, so each is kept as-is.
112
+ """
113
+ by_pair_id: dict[str, dict] = {}
114
+ passthrough: list[dict] = []
115
+ for row in rows:
116
+ pid = row.get("pair_id")
117
+ if not pid:
118
+ passthrough.append(row)
119
+ continue
120
+ existing = by_pair_id.get(pid)
121
+ if existing is None or row.get("ts", "") >= existing.get("ts", ""):
122
+ by_pair_id[pid] = row
123
+ return list(by_pair_id.values()) + passthrough
124
+
125
+
126
+ def aggregate(rows: list[dict]) -> dict[str, dict]:
127
+ rows = dedup_by_pair_id(rows)
128
+ result = {dim: {"n": 0, "tally": {}, "neither": 0} for dim in DIMENSIONS}
129
+
130
+ for row in rows:
131
+ dim = row.get("dimension")
132
+ if dim not in result:
133
+ continue
134
+ result[dim]["n"] += 1
135
+ choice = row.get("choice")
136
+ if choice == "neither":
137
+ result[dim]["neither"] += 1
138
+ continue
139
+ if choice not in ("a", "b"):
140
+ continue
141
+ label = row.get(f"{choice}_label") or choice
142
+ result[dim]["tally"][label] = result[dim]["tally"].get(label, 0) + 1
143
+
144
+ dimensions = {}
145
+ for dim, data in result.items():
146
+ n = data["n"]
147
+ decisive = sum(data["tally"].values())
148
+ if n < MIN_K or decisive == 0:
149
+ dimensions[dim] = {
150
+ "n": n,
151
+ "status": "insufficient",
152
+ "preferred": None,
153
+ "confidence": 0.0,
154
+ }
155
+ continue
156
+ max_count = max(data["tally"].values())
157
+ top_labels = [label for label, count in data["tally"].items() if count == max_count]
158
+ if len(top_labels) > 1:
159
+ # A genuine tie between the top two (or more) tallies: there is no
160
+ # lean to report, so `preferred` must be null rather than
161
+ # silently picking whichever label happens to sort last.
162
+ dimensions[dim] = {
163
+ "n": n,
164
+ "status": "tied",
165
+ "preferred": None,
166
+ "confidence": 0.0,
167
+ }
168
+ continue
169
+ preferred_label = top_labels[0]
170
+ confidence = wilson_lower_bound(max_count, decisive)
171
+ dimensions[dim] = {
172
+ "n": n,
173
+ "status": "confident",
174
+ "preferred": preferred_label,
175
+ "confidence": confidence,
176
+ }
177
+ return dimensions
178
+
179
+
180
+ def detect_conflicts(dimensions: dict[str, dict], profile: dict) -> list[dict]:
181
+ conflicts = []
182
+ for dim, data in dimensions.items():
183
+ if data["status"] != "confident" or data["confidence"] < CONFIDENCE_STOP:
184
+ continue
185
+ link = _PROFILE_LINKS.get(dim)
186
+ if not link or link["profile_key"] not in profile:
187
+ continue
188
+ measured = profile[link["profile_key"]]
189
+ preferred = data["preferred"]
190
+ conflict_pole = None
191
+ if preferred == link["low_pole"] and measured >= link["high_threshold"]:
192
+ conflict_pole = link["high_pole"]
193
+ elif preferred == link["high_pole"] and measured <= link["low_threshold"]:
194
+ conflict_pole = link["low_pole"]
195
+ if conflict_pole is None:
196
+ continue
197
+ conflicts.append({
198
+ "dimension": dim,
199
+ "preferred": preferred,
200
+ "preferred_confidence": data["confidence"],
201
+ "preferred_provenance": "stated-preference",
202
+ "measured_key": link["profile_key"],
203
+ "measured_value": measured,
204
+ "measured_provenance": "measured-from-samples",
205
+ "message": (
206
+ f"Stated preference for '{preferred}' ({dim}) contradicts "
207
+ f"{link['profile_key']}={measured} measured from samples, "
208
+ f"which points toward '{conflict_pole}'."
209
+ ),
210
+ })
211
+ return conflicts
212
+
213
+
214
+ def next_dimension(dimensions: dict[str, dict]) -> dict:
215
+ def sort_key(dim: str) -> tuple:
216
+ data = dimensions[dim]
217
+ confidence = data["confidence"] if data["status"] == "confident" else 0.0
218
+ return (data["n"], confidence, DIMENSIONS.index(dim))
219
+
220
+ ordered = sorted(DIMENSIONS, key=sort_key)
221
+ chosen = ordered[0]
222
+ data = dimensions[chosen]
223
+ if data["n"] == min(dimensions[d]["n"] for d in DIMENSIONS):
224
+ reason = "fewest_observations"
225
+ else:
226
+ reason = "lowest_confidence"
227
+ return {
228
+ "next_dimension": chosen,
229
+ "reason": reason,
230
+ "n": data["n"],
231
+ "confidence": data["confidence"],
232
+ }
233
+
234
+
235
+ def parse_args(argv: list[str]) -> argparse.Namespace:
236
+ parser = argparse.ArgumentParser(description=__doc__)
237
+ parser.add_argument("--preferences", required=True)
238
+ parser.add_argument("--profile")
239
+ parser.add_argument("--next", action="store_true")
240
+ return parser.parse_args(argv)
241
+
242
+
243
+ def main(argv: list[str]) -> int:
244
+ args = parse_args(argv)
245
+
246
+ try:
247
+ rows = load_preferences(Path(args.preferences))
248
+ except OSError as e:
249
+ print(json.dumps({"error": f"could not read preferences file: {e}"}))
250
+ return 2
251
+
252
+ dimensions = aggregate(rows)
253
+
254
+ if args.next:
255
+ print(json.dumps(next_dimension(dimensions), indent=2, sort_keys=True))
256
+ return 0
257
+
258
+ output = {"dimensions": dimensions}
259
+
260
+ if args.profile:
261
+ try:
262
+ profile = json.loads(Path(args.profile).read_text())
263
+ except OSError as e:
264
+ print(json.dumps({"error": f"could not read profile file: {e}"}))
265
+ return 2
266
+ output["conflicts"] = detect_conflicts(dimensions, profile)
267
+
268
+ print(json.dumps(output, indent=2, sort_keys=True))
269
+ return 0
270
+
271
+
272
+ if __name__ == "__main__":
273
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+ """Validate tiered detector pack integrity."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ ROOT = Path(__file__).resolve().parent.parent
11
+ PACK_DIR = ROOT / "references" / "packs"
12
+ MANIFEST = PACK_DIR / "manifest.json"
13
+
14
+ JUDGE_ONLY_MACROS = {
15
+ "macro_both_sidesism",
16
+ "macro_redemption_arc",
17
+ "macro_preview_recap",
18
+ "macro_over_determination",
19
+ "macro_emotional_flatness",
20
+ }
21
+
22
+
23
+ def scanner_categories() -> set[str]:
24
+ sys.path.insert(0, str(ROOT))
25
+ from scripts.banned_phrase_scan import BANNED_PHRASES, STRUCTURAL_PATTERNS
26
+
27
+ return {v["category"] for v in BANNED_PHRASES.values()} | {
28
+ v["category"] for v in STRUCTURAL_PATTERNS
29
+ }
30
+
31
+
32
+ def main() -> int:
33
+ failures: list[str] = []
34
+ data = json.loads(MANIFEST.read_text())
35
+ packs: dict[str, list[str]] = data["packs"]
36
+ category_to_pack: dict[str, str] = {}
37
+
38
+ for pack, categories in packs.items():
39
+ path = PACK_DIR / f"{pack}.md"
40
+ if not path.exists():
41
+ failures.append(f"missing pack file: {path}")
42
+ continue
43
+ text = path.read_text()
44
+ line_count = len(text.splitlines())
45
+ if line_count > 120:
46
+ failures.append(f"{path} has {line_count} lines; max 120")
47
+ if "## Emit" not in text:
48
+ failures.append(f"{path} missing ## Emit section")
49
+ for category in categories:
50
+ if category in category_to_pack:
51
+ failures.append(
52
+ f"category {category} mapped to both {category_to_pack[category]} and {pack}"
53
+ )
54
+ category_to_pack[category] = pack
55
+
56
+ scanner = scanner_categories()
57
+ missing = scanner - set(category_to_pack)
58
+ extra_scanner = (set(category_to_pack) - scanner) - JUDGE_ONLY_MACROS
59
+ if missing:
60
+ failures.append(f"scanner categories missing from packs: {sorted(missing)}")
61
+ if extra_scanner:
62
+ failures.append(f"unknown non-macro categories in manifest: {sorted(extra_scanner)}")
63
+
64
+ macro_hits = {m: category_to_pack.get(m) for m in JUDGE_ONLY_MACROS}
65
+ missing_macros = [m for m, pack in macro_hits.items() if pack is None]
66
+ if missing_macros:
67
+ failures.append(f"judge-only macro families missing: {sorted(missing_macros)}")
68
+ for macro, pack in macro_hits.items():
69
+ if pack and pack != "pack-structure":
70
+ failures.append(f"{macro} must live in pack-structure, found {pack}")
71
+
72
+ if failures:
73
+ print("\n".join(failures), file=sys.stderr)
74
+ return 1
75
+ print(f"pack integrity ok: {len(packs)} packs, {len(scanner)} scanner categories")
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ raise SystemExit(main())
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env python3
2
+ """Contract gates for co-writer suggestions.
3
+
4
+ Reads a suggestions file — the JSON emitted by suggest.py, i.e. an object with a
5
+ ``document`` string and a ``suggestions`` list — and enforces four blocking
6
+ contracts. Each failure is named so a caller can act on it:
7
+
8
+ span-minimality Every suggested_replacement edits only its own span:
9
+ span.text matches document[start:end], the replacement
10
+ differs from the span text, and it shares no leading or
11
+ trailing whole word with the span (which would mean the
12
+ span grabbed unchanged text and could be shrunk — the
13
+ signature of a whole-sentence rewrite).
14
+ replacement-scanner Every suggested_replacement passes both scanners in
15
+ isolation AND introduces no new violation in context.
16
+ accept-all Applying every replacement yields a document that passes
17
+ both scanners with validate_preservation exit 0 vs the
18
+ original.
19
+ span-overlap Suggestion spans must not overlap.
20
+
21
+ Exit 0 when all gates pass, 1 otherwise. Failures (with the offending gate) are
22
+ reported as JSON on stdout.
23
+
24
+ Usage:
25
+ python3 scripts/check_suggestions.py suggestions.json
26
+ python3 scripts/check_suggestions.py < suggestions.json
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ from banned_phrase_scan import scan_for_violations
37
+ from structure_scan import scan as structure_scan
38
+ from validate_preservation import validate_preservation
39
+
40
+
41
+ def _leading_shared_words(a: str, b: str) -> int:
42
+ """Number of identical leading whole words shared by two strings."""
43
+ n = 0
44
+ for x, y in zip(a.split(), b.split()):
45
+ if x == y:
46
+ n += 1
47
+ else:
48
+ break
49
+ return n
50
+
51
+
52
+ def _trailing_shared_words(a: str, b: str) -> int:
53
+ ra = " ".join(reversed(a.split()))
54
+ rb = " ".join(reversed(b.split()))
55
+ return _leading_shared_words(ra, rb)
56
+
57
+
58
+ def _line_starts(text: str) -> list[int]:
59
+ starts = [0]
60
+ for i, ch in enumerate(text):
61
+ if ch == "\n":
62
+ starts.append(i + 1)
63
+ return starts
64
+
65
+
66
+ def violation_spans(text: str) -> list[tuple[int, int]]:
67
+ """Absolute (start, end) offsets of every banned-phrase/structural violation."""
68
+ starts = _line_starts(text)
69
+ out = []
70
+ for v in scan_for_violations(text):
71
+ s = starts[v["line_number"] - 1] + v["column"] - 1
72
+ out.append((s, s + len(v["phrase"])))
73
+ return out
74
+
75
+
76
+ def _scanners_clean(text: str) -> bool:
77
+ return not scan_for_violations(text) and not structure_scan(text).get("flags")
78
+
79
+
80
+ def apply_all(document: str, suggestions: list[dict]) -> str:
81
+ """Apply every non-null replacement, right-to-left so offsets stay valid."""
82
+ out = document
83
+ for s in sorted(suggestions, key=lambda s: s["span"]["start"], reverse=True):
84
+ rep = s.get("suggested_replacement")
85
+ if rep is None:
86
+ continue
87
+ st, en = s["span"]["start"], s["span"]["end"]
88
+ out = out[:st] + rep + out[en:]
89
+ return out
90
+
91
+
92
+ def check(document: str, suggestions: list[dict]) -> list[dict]:
93
+ failures: list[dict] = []
94
+
95
+ # span-overlap: spans must be disjoint.
96
+ order = sorted(range(len(suggestions)),
97
+ key=lambda i: (suggestions[i]["span"]["start"], suggestions[i]["span"]["end"]))
98
+ last_end = None
99
+ last_i = None
100
+ for i in order:
101
+ st, en = suggestions[i]["span"]["start"], suggestions[i]["span"]["end"]
102
+ if last_end is not None and st < last_end:
103
+ failures.append({
104
+ "gate": "span-overlap",
105
+ "suggestions": [last_i, i],
106
+ "detail": f"span {st}-{en} overlaps the previous span ending at {last_end}",
107
+ })
108
+ last_end, last_i = en, i
109
+
110
+ # span-minimality: span accuracy + tight, local replacements.
111
+ for i, s in enumerate(suggestions):
112
+ st, en = s["span"]["start"], s["span"]["end"]
113
+ span_text = document[st:en]
114
+ if span_text != s["span"]["text"]:
115
+ failures.append({
116
+ "gate": "span-minimality",
117
+ "suggestion": i,
118
+ "detail": "span.text does not match document[start:end]",
119
+ })
120
+ rep = s.get("suggested_replacement")
121
+ if rep is None:
122
+ continue
123
+ if rep == span_text:
124
+ failures.append({
125
+ "gate": "span-minimality",
126
+ "suggestion": i,
127
+ "detail": "replacement is identical to the span text (no change)",
128
+ })
129
+ continue
130
+ if _leading_shared_words(span_text, rep) > 0 or _trailing_shared_words(span_text, rep) > 0:
131
+ failures.append({
132
+ "gate": "span-minimality",
133
+ "suggestion": i,
134
+ "detail": "replacement shares leading/trailing whole words with the span; "
135
+ "shrink the span so the edit is minimal",
136
+ })
137
+
138
+ # replacement-scanner: each replacement is clean alone and in context.
139
+ for i, s in enumerate(suggestions):
140
+ rep = s.get("suggested_replacement")
141
+ if rep is None:
142
+ continue
143
+ if not _scanners_clean(rep):
144
+ failures.append({
145
+ "gate": "replacement-scanner",
146
+ "suggestion": i,
147
+ "detail": "replacement does not pass both scanners in isolation",
148
+ })
149
+ st, en = s["span"]["start"], s["span"]["end"]
150
+ ctx = document[:st] + rep + document[en:]
151
+ new_start, new_end = st, st + len(rep)
152
+ if any(a < new_end and new_start < b for (a, b) in violation_spans(ctx)):
153
+ failures.append({
154
+ "gate": "replacement-scanner",
155
+ "suggestion": i,
156
+ "detail": "replacement introduces a violation in context",
157
+ })
158
+
159
+ # accept-all: applying everything yields a clean, constraint-preserving doc.
160
+ unresolved = [i for i, s in enumerate(suggestions) if s.get("suggested_replacement") is None]
161
+ if unresolved:
162
+ failures.append({
163
+ "gate": "accept-all",
164
+ "detail": f"suggestions {unresolved} have no replacement; cannot accept-all",
165
+ })
166
+ applied = apply_all(document, suggestions)
167
+ if not _scanners_clean(applied):
168
+ failures.append({
169
+ "gate": "accept-all",
170
+ "detail": "the accept-all document still fails a scanner",
171
+ })
172
+ preservation = validate_preservation(document, applied)
173
+ if not preservation["passed"]:
174
+ failures.append({
175
+ "gate": "accept-all",
176
+ "detail": "validate_preservation failed against the original",
177
+ "missing": preservation["missing"],
178
+ })
179
+
180
+ return failures
181
+
182
+
183
+ def parse_args(argv: list[str]) -> argparse.Namespace:
184
+ parser = argparse.ArgumentParser(description="Contract gates for co-writer suggestions.")
185
+ parser.add_argument(
186
+ "path", nargs="?", help="Path to a suggestions JSON file (default: read stdin)"
187
+ )
188
+ return parser.parse_args(argv)
189
+
190
+
191
+ def main(argv: list[str]) -> int:
192
+ args = parse_args(argv)
193
+ if args.path:
194
+ path = Path(args.path)
195
+ if not path.exists():
196
+ print(f"Missing file: {path}", file=sys.stderr)
197
+ return 2
198
+ raw = path.read_text(errors="replace")
199
+ else:
200
+ raw = sys.stdin.buffer.read().decode("utf-8", errors="replace")
201
+
202
+ try:
203
+ data = json.loads(raw)
204
+ except json.JSONDecodeError as e:
205
+ print(json.dumps({"passed": False, "error": f"invalid JSON: {e}"}, indent=2))
206
+ return 1
207
+
208
+ document = data.get("document")
209
+ suggestions = data.get("suggestions", [])
210
+ if not isinstance(document, str):
211
+ print(json.dumps({"passed": False, "error": "missing 'document' string"}, indent=2))
212
+ return 1
213
+
214
+ failures = check(document, suggestions)
215
+ result = {
216
+ "passed": not failures,
217
+ "failure_gates": sorted({f["gate"] for f in failures}),
218
+ "failures": failures,
219
+ }
220
+ print(json.dumps(result, indent=2))
221
+ return 0 if not failures else 1
222
+
223
+
224
+ if __name__ == "__main__":
225
+ raise SystemExit(main(sys.argv[1:]))