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.
- agentforge_framework/.claude-plugin/plugin.json +4 -0
- agentforge_framework/__init__.py +3 -0
- agentforge_framework/agents/__init__.py +92 -0
- agentforge_framework/agents/architect.py +146 -0
- agentforge_framework/agents/implementer.py +162 -0
- agentforge_framework/agents/orchestrator.py +588 -0
- agentforge_framework/agents/reviewer.py +335 -0
- agentforge_framework/agents/security.py +138 -0
- agentforge_framework/agents/tester.py +125 -0
- agentforge_framework/cli.py +461 -0
- agentforge_framework/context/__init__.py +1 -0
- agentforge_framework/context/extractors/__init__.py +76 -0
- agentforge_framework/context/extractors/base.py +47 -0
- agentforge_framework/context/extractors/python.py +65 -0
- agentforge_framework/context/extractors/sql.py +121 -0
- agentforge_framework/context/extractors/yaml.py +59 -0
- agentforge_framework/context/prompt.py +104 -0
- agentforge_framework/context/resolver.py +185 -0
- agentforge_framework/core/__init__.py +1 -0
- agentforge_framework/core/commands.py +170 -0
- agentforge_framework/core/config.py +90 -0
- agentforge_framework/core/contracts.py +875 -0
- agentforge_framework/core/gates.py +333 -0
- agentforge_framework/core/issues.py +697 -0
- agentforge_framework/core/plan_format.py +272 -0
- agentforge_framework/core/process.py +141 -0
- agentforge_framework/core/project.py +262 -0
- agentforge_framework/core/registry.py +455 -0
- agentforge_framework/core/repo.py +185 -0
- agentforge_framework/core/router.py +1 -0
- agentforge_framework/core/runtime.py +639 -0
- agentforge_framework/core/skills.py +255 -0
- agentforge_framework/core/workflow.py +215 -0
- agentforge_framework/plugins/__init__.py +35 -0
- agentforge_framework/plugins/databricks/__init__.py +86 -0
- agentforge_framework/plugins/pyspark/__init__.py +57 -0
- agentforge_framework/plugins/python/__init__.py +45 -0
- agentforge_framework/plugins/sql/__init__.py +377 -0
- agentforge_framework/providers/__init__.py +48 -0
- agentforge_framework/providers/base.py +248 -0
- agentforge_framework/providers/claude.py +159 -0
- agentforge_framework/providers/codex.py +139 -0
- agentforge_framework/skills/MANIFEST.yaml +157 -0
- agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
- agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
- agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
- agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
- agentforge_framework/skills/grilling/SKILL.md +28 -0
- agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
- agentforge_framework/skills/to-spec/SKILL.md +75 -0
- agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
- agentforge_framework/skills/to-tickets/SKILL.md +105 -0
- agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
- agentforge_framework/skills/unslop/SKILL.md +131 -0
- agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
- agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
- agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
- agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
- agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
- agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
- agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
- agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
- agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
- agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
- agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
- agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
- agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
- agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
- agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
- agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
- agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
- agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
- agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
- agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
- agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
- agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
- agentforge_framework/skills/write-plainly/SKILL.md +94 -0
- agentforge_framework/workflows/bugfix.yaml +8 -0
- agentforge_framework/workflows/feature.yaml +16 -0
- agentforge_framework/workflows/review.yaml +10 -0
- agentforge_framework-0.2.0.dist-info/METADATA +321 -0
- agentforge_framework-0.2.0.dist-info/RECORD +89 -0
- agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
- agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
- agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Offline contribution scaffolder for new unslop AI-isms."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
16
|
+
CONTRIB_ROOT = ROOT / ".unslop" / "contrib"
|
|
17
|
+
TODO_MARKER = "TODO:"
|
|
18
|
+
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
19
|
+
|
|
20
|
+
GATE_COMMANDS = [
|
|
21
|
+
["python3", "evals/check.py", "--full"],
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_text(path: Path) -> str:
|
|
26
|
+
try:
|
|
27
|
+
return path.read_text(encoding="utf-8")
|
|
28
|
+
except OSError as exc:
|
|
29
|
+
print(json.dumps({"error": f"could not read {path}: {exc}"}))
|
|
30
|
+
raise SystemExit(2) from exc
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_json(path: Path, data: object) -> None:
|
|
34
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sha256_text(text: str) -> str:
|
|
38
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def word_count(text: str) -> int:
|
|
42
|
+
return len(re.findall(r"[A-Za-z0-9']+", text))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def shorten(text: str, limit: int = 72) -> str:
|
|
46
|
+
one_line = " ".join(text.split())
|
|
47
|
+
if len(one_line) <= limit:
|
|
48
|
+
return one_line
|
|
49
|
+
return one_line[: limit - 1].rstrip() + "..."
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_scanner(command: list[str], text: str) -> subprocess.CompletedProcess[str]:
|
|
53
|
+
return subprocess.run(command, input=text, capture_output=True, text=True, cwd=ROOT, timeout=30)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def scanner_findings(text: str) -> list[dict[str, object]]:
|
|
57
|
+
findings: list[dict[str, object]] = []
|
|
58
|
+
for name, command in (
|
|
59
|
+
("banned_phrase", ["python3", "scripts/banned_phrase_scan.py"]),
|
|
60
|
+
("structure", ["python3", "scripts/structure_scan.py"]),
|
|
61
|
+
):
|
|
62
|
+
proc = run_scanner(command, text)
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(proc.stdout)
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
data = {}
|
|
67
|
+
violations = data.get("violations", [])
|
|
68
|
+
if name == "structure":
|
|
69
|
+
violations = [
|
|
70
|
+
{
|
|
71
|
+
"phrase": flag.get("metric", ""),
|
|
72
|
+
"category": flag.get("metric", ""),
|
|
73
|
+
"severity": flag.get("severity", ""),
|
|
74
|
+
}
|
|
75
|
+
for flag in data.get("flags", [])
|
|
76
|
+
]
|
|
77
|
+
for violation in violations:
|
|
78
|
+
findings.append(
|
|
79
|
+
{
|
|
80
|
+
"scanner": name,
|
|
81
|
+
"phrase": violation.get("phrase", ""),
|
|
82
|
+
"category": violation.get("category", ""),
|
|
83
|
+
"severity": violation.get("severity", ""),
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
return findings
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def redaction_pairs(values: list[str]) -> list[tuple[str, str]]:
|
|
90
|
+
pairs = []
|
|
91
|
+
for value in values:
|
|
92
|
+
if "=" not in value:
|
|
93
|
+
print(json.dumps({"error": "--redact values must use orig=REPL"}))
|
|
94
|
+
raise SystemExit(2)
|
|
95
|
+
orig, repl = value.split("=", 1)
|
|
96
|
+
pairs.append((orig, repl))
|
|
97
|
+
return pairs
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def apply_redactions(text: str, tell: str, pairs: list[tuple[str, str]]) -> str:
|
|
101
|
+
before_count = text.count(tell)
|
|
102
|
+
redacted = text
|
|
103
|
+
for orig, repl in pairs:
|
|
104
|
+
redacted = redacted.replace(orig, repl)
|
|
105
|
+
if before_count == 0 or redacted.count(tell) != before_count:
|
|
106
|
+
print(json.dumps({"error": "redaction alters the specimen", "tell": tell}))
|
|
107
|
+
raise SystemExit(4)
|
|
108
|
+
return redacted
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def row_fn(slug: str, tell: str, snippet: str) -> dict[str, object]:
|
|
112
|
+
assertion_tell = tell.casefold()
|
|
113
|
+
return {
|
|
114
|
+
"id": f"CONTRIB-FN-{slug}",
|
|
115
|
+
"target": "script",
|
|
116
|
+
"category": "scanner_false_negative",
|
|
117
|
+
"stdin": snippet,
|
|
118
|
+
"assertions": [
|
|
119
|
+
{"type": "json", "path": "total_violations", "gte": 1},
|
|
120
|
+
{"type": "violation_phrase_contains", "value": assertion_tell},
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def row_fp_template(slug: str, category: str, tell: str) -> dict[str, object]:
|
|
126
|
+
return {
|
|
127
|
+
"id": f"CONTRIB-FP-{slug}",
|
|
128
|
+
"category": "scanner_false_positive",
|
|
129
|
+
"protects": category,
|
|
130
|
+
"target": "script",
|
|
131
|
+
"stdin": "TODO: add a literal or domain-specific use that should remain clean.",
|
|
132
|
+
"assertions": [{"type": "json", "path": "total_violations", "equals": 0}],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def render_report(
|
|
137
|
+
manifest: dict[str, object],
|
|
138
|
+
snippet: str,
|
|
139
|
+
gate_results: str = "",
|
|
140
|
+
include_rec: bool = False,
|
|
141
|
+
) -> str:
|
|
142
|
+
redactions = manifest.get("redactions", [])
|
|
143
|
+
redaction_note = "names/numbers redacted; tell verbatim" if redactions else "none"
|
|
144
|
+
rows = [
|
|
145
|
+
f"| CONTRIB-FN-{manifest['pattern_name']} | FN | exact specimen flags `{manifest['tell']}` |",
|
|
146
|
+
f"| CONTRIB-FP-{manifest['pattern_name']} | FP | literal-use protection for `{manifest['tell']}` |",
|
|
147
|
+
]
|
|
148
|
+
if include_rec:
|
|
149
|
+
rows.append("| CONTRIB-REC | REC | existing-word recall still flags |")
|
|
150
|
+
quoted = "\n".join(f"> {line}" if line else ">" for line in snippet.splitlines())
|
|
151
|
+
pattern_added = manifest.get(
|
|
152
|
+
"pattern_added",
|
|
153
|
+
f"TODO: regex or phrase for `{manifest['tell']}`",
|
|
154
|
+
)
|
|
155
|
+
return (
|
|
156
|
+
f"# Add {manifest['category']} pattern: {shorten(str(manifest['tell']))}\n\n"
|
|
157
|
+
"## The specimen\n\n"
|
|
158
|
+
f"{quoted}\n\n"
|
|
159
|
+
f"- Source genre: {manifest.get('source_genre', 'TODO: source genre')}\n"
|
|
160
|
+
f"- Date: {manifest['date']}\n"
|
|
161
|
+
f"- Redaction note: {redaction_note}\n\n"
|
|
162
|
+
"## Why it's an AI-ism\n\n"
|
|
163
|
+
f"{manifest.get('rationale', 'TODO: explain why this phrase is a reusable AI-writing tell in 2-4 sentences.')}\n\n"
|
|
164
|
+
"## Detection\n\n"
|
|
165
|
+
f"- Pattern added: {pattern_added}\n"
|
|
166
|
+
f"- Severity: {manifest.get('severity', 'TODO: hard or soft')}\n"
|
|
167
|
+
f"- Gating rationale: {manifest.get('gating_rationale', 'TODO: explain literal-use boundary')}\n"
|
|
168
|
+
"- Catalog entry location: references/taboo-phrases.md\n\n"
|
|
169
|
+
"## Evals\n\n"
|
|
170
|
+
"| row id | kind | what it pins |\n"
|
|
171
|
+
"|---|---|---|\n"
|
|
172
|
+
+ "\n".join(rows)
|
|
173
|
+
+ "\n\n"
|
|
174
|
+
"The FN stdin is the unmodified specimen after approved redaction.\n\n"
|
|
175
|
+
"## Gate results\n\n"
|
|
176
|
+
f"{gate_results or 'TODO: paste gate tails from verify.'}\n\n"
|
|
177
|
+
"## Checklist\n\n"
|
|
178
|
+
"- [ ] eval-first (row was red before the pattern)\n"
|
|
179
|
+
"- [ ] literal-use FP row included\n"
|
|
180
|
+
"- [ ] REC row if an existing word was gated\n"
|
|
181
|
+
"- [ ] catalog + scanner parity green\n"
|
|
182
|
+
"- [ ] coverage gate green (pattern exercised)\n"
|
|
183
|
+
"- [ ] snippet publication approved by the user\n"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_precheck(args: argparse.Namespace) -> int:
|
|
188
|
+
snippet = read_text(Path(args.snippet_file))
|
|
189
|
+
findings = scanner_findings(snippet)
|
|
190
|
+
if findings:
|
|
191
|
+
print(json.dumps({"status": "already_covered", "findings": findings}, indent=2, sort_keys=True))
|
|
192
|
+
return 3
|
|
193
|
+
print(json.dumps({"status": "clean", "words": word_count(snippet)}, indent=2, sort_keys=True))
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_scaffold(args: argparse.Namespace) -> int:
|
|
198
|
+
if not SLUG_RE.match(args.pattern_name):
|
|
199
|
+
print(
|
|
200
|
+
json.dumps(
|
|
201
|
+
{
|
|
202
|
+
"error": f"invalid pattern name: {args.pattern_name!r}; use lowercase letters, digits, - or _",
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
return 2
|
|
207
|
+
source = Path(args.snippet)
|
|
208
|
+
snippet = read_text(source)
|
|
209
|
+
if args.tell not in snippet:
|
|
210
|
+
print(json.dumps({"error": "tell substring not found", "tell": args.tell}))
|
|
211
|
+
return 2
|
|
212
|
+
try:
|
|
213
|
+
manifest_date = datetime.strptime(args.date, "%Y-%m-%d").date().isoformat()
|
|
214
|
+
except ValueError:
|
|
215
|
+
print(json.dumps({"error": "--date must use YYYY-MM-DD", "date": args.date}))
|
|
216
|
+
return 2
|
|
217
|
+
redactions = redaction_pairs(args.redact or [])
|
|
218
|
+
redacted = apply_redactions(snippet, args.tell, redactions)
|
|
219
|
+
bundle = CONTRIB_ROOT / args.pattern_name
|
|
220
|
+
bundle.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
manifest = {
|
|
222
|
+
"category": args.category,
|
|
223
|
+
"date": manifest_date,
|
|
224
|
+
"pattern_name": args.pattern_name,
|
|
225
|
+
"pre_redaction_sha256": sha256_text(snippet),
|
|
226
|
+
"post_redaction_sha256": sha256_text(redacted),
|
|
227
|
+
"redactions": [{"from": orig, "to": repl} for orig, repl in redactions],
|
|
228
|
+
"tell": args.tell,
|
|
229
|
+
"word_count": word_count(redacted),
|
|
230
|
+
}
|
|
231
|
+
write_json(bundle / "row_fn.json", row_fn(args.pattern_name, args.tell, redacted))
|
|
232
|
+
write_json(bundle / "row_fp_TEMPLATE.json", row_fp_template(args.pattern_name, args.category, args.tell))
|
|
233
|
+
write_json(bundle / "manifest.json", manifest)
|
|
234
|
+
(bundle / "snippet.txt").write_text(redacted, encoding="utf-8")
|
|
235
|
+
(bundle / "report.md").write_text(render_report(manifest, redacted), encoding="utf-8")
|
|
236
|
+
print(json.dumps({"bundle": str(bundle.relative_to(ROOT)), "status": "scaffolded"}, indent=2, sort_keys=True))
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def run_row(row: dict[str, object]) -> subprocess.CompletedProcess[str]:
|
|
241
|
+
return subprocess.run(
|
|
242
|
+
["python3", "scripts/banned_phrase_scan.py"],
|
|
243
|
+
input=str(row.get("stdin", "")),
|
|
244
|
+
capture_output=True,
|
|
245
|
+
text=True,
|
|
246
|
+
cwd=ROOT,
|
|
247
|
+
timeout=30,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def row_assertions_pass(row: dict[str, object], proc: subprocess.CompletedProcess[str]) -> bool:
|
|
252
|
+
try:
|
|
253
|
+
data = json.loads(proc.stdout)
|
|
254
|
+
except json.JSONDecodeError:
|
|
255
|
+
return False
|
|
256
|
+
for assertion in row.get("assertions", []):
|
|
257
|
+
if assertion.get("type") == "json":
|
|
258
|
+
cur = data
|
|
259
|
+
for part in assertion["path"].split("."):
|
|
260
|
+
cur = cur[int(part)] if isinstance(cur, list) else cur[part]
|
|
261
|
+
if "gte" in assertion and not cur >= assertion["gte"]:
|
|
262
|
+
return False
|
|
263
|
+
if "equals" in assertion and cur != assertion["equals"]:
|
|
264
|
+
return False
|
|
265
|
+
elif assertion.get("type") == "violation_phrase_contains":
|
|
266
|
+
phrases = [v.get("phrase", "") for v in data.get("violations", [])]
|
|
267
|
+
needle = str(assertion["value"]).casefold()
|
|
268
|
+
if not any(needle in str(phrase).casefold() for phrase in phrases):
|
|
269
|
+
return False
|
|
270
|
+
else:
|
|
271
|
+
return False
|
|
272
|
+
return True
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def tail(text: str, lines: int = 12) -> str:
|
|
276
|
+
split = text.splitlines()
|
|
277
|
+
return "\n".join(split[-lines:])
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def verify_bundle(bundle: Path, run_gates: bool = True) -> tuple[bool, str]:
|
|
281
|
+
if not bundle.exists():
|
|
282
|
+
return False, "missing bundle"
|
|
283
|
+
row_path = bundle / "row_fn.json"
|
|
284
|
+
report_path = bundle / "report.md"
|
|
285
|
+
manifest_path = bundle / "manifest.json"
|
|
286
|
+
snippet_path = bundle / "snippet.txt"
|
|
287
|
+
for path in (row_path, report_path, manifest_path, snippet_path):
|
|
288
|
+
if not path.exists():
|
|
289
|
+
return False, f"missing {path.name}"
|
|
290
|
+
report = report_path.read_text(encoding="utf-8")
|
|
291
|
+
if TODO_MARKER in report:
|
|
292
|
+
return False, "report contains TODO markers"
|
|
293
|
+
row = json.loads(row_path.read_text(encoding="utf-8"))
|
|
294
|
+
snippet = snippet_path.read_text(encoding="utf-8")
|
|
295
|
+
if row.get("stdin") != snippet:
|
|
296
|
+
return False, "row_fn stdin does not equal snippet.txt byte-for-byte"
|
|
297
|
+
proc = run_row(row)
|
|
298
|
+
assertion_ok = row_assertions_pass(row, proc)
|
|
299
|
+
red_first = not assertion_ok
|
|
300
|
+
gate_results = [f"red-first: {'ok' if red_first else 'already green'}"]
|
|
301
|
+
ok = red_first
|
|
302
|
+
if not red_first:
|
|
303
|
+
ok = True
|
|
304
|
+
gate_results[0] = "red-first: already green; proposed pattern appears active"
|
|
305
|
+
if run_gates:
|
|
306
|
+
for command in GATE_COMMANDS:
|
|
307
|
+
proc = subprocess.run(command, capture_output=True, text=True, cwd=ROOT, timeout=120)
|
|
308
|
+
gate_results.append(
|
|
309
|
+
"### "
|
|
310
|
+
+ " ".join(command)
|
|
311
|
+
+ f"\n\n```text\nexit={proc.returncode}\n{tail(proc.stdout or proc.stderr)}\n```"
|
|
312
|
+
)
|
|
313
|
+
ok = ok and proc.returncode == 0
|
|
314
|
+
final = "\n\n".join(gate_results)
|
|
315
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
316
|
+
report_path.write_text(render_report(manifest, snippet, final, (bundle / "row_rec.json").exists()), encoding="utf-8")
|
|
317
|
+
return ok, final
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
321
|
+
ok, message = verify_bundle(Path(args.bundle), run_gates=not args.no_gates)
|
|
322
|
+
print(message)
|
|
323
|
+
return 0 if ok else 1
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
327
|
+
bundle = Path(args.bundle)
|
|
328
|
+
if not bundle.exists():
|
|
329
|
+
print(json.dumps({"error": "missing bundle", "bundle": str(bundle)}))
|
|
330
|
+
return 2
|
|
331
|
+
manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
|
|
332
|
+
snippet = (bundle / "snippet.txt").read_text(encoding="utf-8")
|
|
333
|
+
report = (bundle / "report.md").read_text(encoding="utf-8")
|
|
334
|
+
gate_marker = "## Gate results\n\n"
|
|
335
|
+
checklist_marker = "\n\n## Checklist"
|
|
336
|
+
gate_results = ""
|
|
337
|
+
if gate_marker in report and checklist_marker in report:
|
|
338
|
+
gate_results = report.split(gate_marker, 1)[1].split(checklist_marker, 1)[0]
|
|
339
|
+
print(render_report(manifest, snippet, gate_results, (bundle / "row_rec.json").exists()).rstrip())
|
|
340
|
+
return 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
344
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
345
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
346
|
+
precheck = sub.add_parser("precheck")
|
|
347
|
+
precheck.add_argument("snippet_file")
|
|
348
|
+
precheck.set_defaults(func=cmd_precheck)
|
|
349
|
+
scaffold = sub.add_parser("scaffold")
|
|
350
|
+
scaffold.add_argument("--snippet", required=True)
|
|
351
|
+
scaffold.add_argument("--tell", required=True)
|
|
352
|
+
scaffold.add_argument("--category", required=True)
|
|
353
|
+
scaffold.add_argument("--pattern-name", required=True)
|
|
354
|
+
scaffold.add_argument("--redact", action="append")
|
|
355
|
+
scaffold.add_argument("--date", default=date.today().isoformat())
|
|
356
|
+
scaffold.set_defaults(func=cmd_scaffold)
|
|
357
|
+
verify = sub.add_parser("verify")
|
|
358
|
+
verify.add_argument("--bundle", required=True)
|
|
359
|
+
verify.add_argument("--no-gates", action="store_true")
|
|
360
|
+
verify.set_defaults(func=cmd_verify)
|
|
361
|
+
report = sub.add_parser("report")
|
|
362
|
+
report.add_argument("--bundle", required=True)
|
|
363
|
+
report.set_defaults(func=cmd_report)
|
|
364
|
+
return parser.parse_args(argv)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def main(argv: list[str]) -> int:
|
|
368
|
+
args = parse_args(argv)
|
|
369
|
+
return args.func(args)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Check change percentage between original and transformed text.
|
|
4
|
+
|
|
5
|
+
Flags if >40% of words changed (may indicate over-editing).
|
|
6
|
+
|
|
7
|
+
Pure reordering is exempt: change_percentage still reflects the raw
|
|
8
|
+
delete+insert diff, but excessive_change stays false when the transformed
|
|
9
|
+
text is a word-level permutation of the original (moved sentences are not
|
|
10
|
+
over-editing). SKILL.md's 40% guidance refers to the excessive_change flag.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python diff_check.py original.txt transformed.txt
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from difflib import SequenceMatcher
|
|
23
|
+
from typing import TypedDict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DiffResult(TypedDict):
|
|
27
|
+
original_word_count: int
|
|
28
|
+
transformed_word_count: int
|
|
29
|
+
similarity_ratio: float
|
|
30
|
+
change_percentage: float
|
|
31
|
+
words_added: int
|
|
32
|
+
words_removed: int
|
|
33
|
+
words_changed: int
|
|
34
|
+
excessive_change: bool
|
|
35
|
+
flags: list[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def split_words(text: str) -> list[str]:
|
|
39
|
+
"""Split text into comparison tokens.
|
|
40
|
+
|
|
41
|
+
Punctuation is tokenized separately (not stripped) so that structural edits
|
|
42
|
+
like repunctuation register as change instead of reading as identical.
|
|
43
|
+
"""
|
|
44
|
+
return re.findall(r'\w+|[^\w\s]', text.lower())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def calculate_diff(original: str, transformed: str) -> DiffResult:
|
|
48
|
+
"""Calculate difference metrics between two texts."""
|
|
49
|
+
original_words = split_words(original)
|
|
50
|
+
transformed_words = split_words(transformed)
|
|
51
|
+
|
|
52
|
+
# Use SequenceMatcher for word-level diff
|
|
53
|
+
matcher = SequenceMatcher(None, original_words, transformed_words)
|
|
54
|
+
similarity = matcher.ratio()
|
|
55
|
+
|
|
56
|
+
# Count operations
|
|
57
|
+
words_added = 0
|
|
58
|
+
words_removed = 0
|
|
59
|
+
words_changed = 0
|
|
60
|
+
|
|
61
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
62
|
+
if tag == 'replace':
|
|
63
|
+
words_changed += max(i2 - i1, j2 - j1)
|
|
64
|
+
elif tag == 'delete':
|
|
65
|
+
words_removed += i2 - i1
|
|
66
|
+
elif tag == 'insert':
|
|
67
|
+
words_added += j2 - j1
|
|
68
|
+
|
|
69
|
+
# Change percentage (relative to original)
|
|
70
|
+
total_changes = words_added + words_removed + words_changed
|
|
71
|
+
change_percentage = (
|
|
72
|
+
(total_changes / len(original_words) * 100)
|
|
73
|
+
if original_words else 0
|
|
74
|
+
)
|
|
75
|
+
shared_tokens = sum((Counter(original_words) & Counter(transformed_words)).values())
|
|
76
|
+
token_overlap = shared_tokens / len(original_words) if original_words else 0
|
|
77
|
+
|
|
78
|
+
# Flags
|
|
79
|
+
flags: list[str] = []
|
|
80
|
+
excessive = False
|
|
81
|
+
|
|
82
|
+
if change_percentage > 40:
|
|
83
|
+
if token_overlap >= 0.9:
|
|
84
|
+
flags.append(f"Mostly reordered ({token_overlap:.0%} tokens shared)")
|
|
85
|
+
else:
|
|
86
|
+
flags.append(f"Excessive change ({change_percentage:.1f}% > 40% threshold)")
|
|
87
|
+
excessive = True
|
|
88
|
+
|
|
89
|
+
if len(transformed_words) < len(original_words) * 0.3:
|
|
90
|
+
flags.append("Transformed text is less than 30% of original length")
|
|
91
|
+
excessive = True
|
|
92
|
+
|
|
93
|
+
if len(transformed_words) > len(original_words) * 1.5:
|
|
94
|
+
flags.append("Transformed text is 50%+ longer than original")
|
|
95
|
+
|
|
96
|
+
# Length change ratio
|
|
97
|
+
length_ratio = len(transformed_words) / len(original_words) if original_words else 0
|
|
98
|
+
if length_ratio < 0.5:
|
|
99
|
+
flags.append(f"Significant condensation ({length_ratio:.0%} of original)")
|
|
100
|
+
elif length_ratio > 1.2:
|
|
101
|
+
flags.append(f"Text expanded ({length_ratio:.0%} of original)")
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"original_word_count": len(original_words),
|
|
105
|
+
"transformed_word_count": len(transformed_words),
|
|
106
|
+
"similarity_ratio": round(similarity, 3),
|
|
107
|
+
"change_percentage": round(change_percentage, 1),
|
|
108
|
+
"words_added": words_added,
|
|
109
|
+
"words_removed": words_removed,
|
|
110
|
+
"words_changed": words_changed,
|
|
111
|
+
"excessive_change": excessive,
|
|
112
|
+
"flags": flags
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> None:
|
|
117
|
+
if len(sys.argv) < 3:
|
|
118
|
+
print("Usage: diff_check.py <original.txt> <transformed.txt>")
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
|
|
121
|
+
# Read inputs
|
|
122
|
+
try:
|
|
123
|
+
with open(sys.argv[1], 'r') as f:
|
|
124
|
+
original = f.read()
|
|
125
|
+
with open(sys.argv[2], 'r') as f:
|
|
126
|
+
transformed = f.read()
|
|
127
|
+
except OSError as e:
|
|
128
|
+
print(json.dumps({"error": f"Could not read input: {e}"}))
|
|
129
|
+
sys.exit(2)
|
|
130
|
+
|
|
131
|
+
result = calculate_diff(original, transformed)
|
|
132
|
+
print(json.dumps(result, indent=2))
|
|
133
|
+
|
|
134
|
+
# Exit with 1 if excessive change
|
|
135
|
+
sys.exit(1 if result["excessive_change"] else 0)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|