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,390 @@
1
+ #!/usr/bin/env python3
2
+ """Scan prose for discourse-level silhouette AI-writing patterns.
3
+
4
+ This is the macro-structure scanner one level above scripts/structure_scan.py.
5
+ structure_scan measures SURFACE structure (sentence cadence, opener words,
6
+ listicle formatting). silhouette_scan measures IDEA ARRANGEMENT: does the
7
+ document follow a template-shaped outline, preview-then-fulfill its own points,
8
+ open body paragraphs with rotating discourse cues, and close with a recap loop?
9
+
10
+ Five validated one-sided tells (all AI-high; humans cluster at zero):
11
+
12
+ scaffold_opener_share body paragraphs opening with a discourse-cue class
13
+ callback_content early vocab absent mid-document, returning at the end
14
+ (the recap loop; strongest single tell)
15
+ role_entropy_bits count of distinct cue-opener classes (a template rotates
16
+ "However / In addition / Ultimately" openers)
17
+ preview_fulfillment intro content words reappearing as body-paragraph heads
18
+ heading_preview heading head-nouns previewed in the intro (outline follow)
19
+
20
+ Composite:
21
+
22
+ silhouette_penalty = sum_i weight_i * relu((m_i - median_i) / scale_i)
23
+
24
+ scored against a committed HUMAN reference distribution
25
+ (evals/fixtures/silhouette/human_reference.json), negative side clipped. The
26
+ document flags when silhouette_penalty >= 1.0.
27
+
28
+ Scale note (deviation from a naive sample-IQR denominator, stated on purpose):
29
+ these five metrics are degenerate at zero across the human corpus -- the sample
30
+ IQR is 0.0 for every one, so the literal (m - median)/IQR with a 0.05 floor
31
+ reduces to m/0.05 and produces human false positives (a human doc with one
32
+ genuine callback at 0.17 lands at penalty > 1.0). The reference therefore scales
33
+ each metric by its human UPPER FENCE -- the validated activation threshold past
34
+ which a human essentially never scores. The scorer uses
35
+ denom = max(sample_iqr, fence) so a future per-author profile with a real,
36
+ non-degenerate IQR widens the scale naturally. This keeps the exact weighted
37
+ relu-of-z shape, reproduces the research's struct01/03/09/11 signature, and holds
38
+ 0 / 8 human false positives on the validation corpus.
39
+
40
+ Voice fingerprint (not implemented here): the same per-metric median/scale
41
+ machinery generalizes to a PER-AUTHOR reference stored in a voice profile, so
42
+ mimic mode can penalize |draft - author-median| instead of generic-human. That
43
+ per-author integration lands with the teach/mimic branch (WP10b); this scanner
44
+ ships the generic-human reference only.
45
+
46
+ stdlib only. JSON to stdout. Exit 1 on flag, 0 clean, 2 on a missing file.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import argparse
52
+ import json
53
+ import math
54
+ import re
55
+ import sys
56
+ from collections import Counter
57
+ from pathlib import Path
58
+
59
+ HERE = Path(__file__).resolve().parent
60
+ sys.path.insert(0, str(HERE))
61
+
62
+ from structure_scan import STOPWORDS as _STRUCTURE_STOPWORDS # noqa: E402
63
+ from _lang import ( # noqa: E402
64
+ is_probably_english,
65
+ paragraphs as _prose_paragraphs,
66
+ words,
67
+ )
68
+
69
+
70
+ REFERENCE_PATH = (
71
+ Path(__file__).resolve().parent.parent
72
+ / "evals" / "fixtures" / "silhouette" / "human_reference.json"
73
+ )
74
+
75
+ # Flag the document when the composite reaches this penalty (research-validated).
76
+ PENALTY_THRESHOLD = 1.0
77
+
78
+ # Minimum prose paragraphs before the silhouette metrics are meaningful.
79
+ MIN_PARAGRAPHS = 3
80
+
81
+
82
+ # silhouette_scan's stopword list is a pure superset of structure_scan's: same
83
+ # core function words plus pronouns/quantifiers that matter for content-bigram
84
+ # and callback-content comparisons but that structure_scan doesn't need.
85
+ SILHOUETTE_STOPWORDS = _STRUCTURE_STOPWORDS | frozenset({
86
+ "our", "your", "their", "my", "me", "us", "them", "his", "her", "what",
87
+ "which", "who", "when", "where", "how", "why", "there", "here", "about",
88
+ "just", "more", "most", "some", "all", "also", "out", "up", "one", "two",
89
+ "get", "got", "like", "much", "many", "very", "every", "only",
90
+ })
91
+
92
+ # Discourse cue classes for paragraph-opener roles. Copied verbatim from the
93
+ # validated research prototype (scratchpad/research-silhouette/silhouette_probe.py).
94
+ ROLE_CUES = {
95
+ "contrast": r"^(on the other hand|on one hand|however|conversely|in contrast|"
96
+ r"yet|but |perhaps most|that said|still,)",
97
+ "addition": r"^(moreover|furthermore|additionally|in addition|also,|"
98
+ r"another|second|third|next,|finally,|besides)",
99
+ "conclusion": r"^(in conclusion|ultimately|overall|in the end|to sum|"
100
+ r"in summary|as we|remember|the future)",
101
+ "enumeration": r"^(first,|firstly|1\.|step \d|there are)",
102
+ "cause": r"^(therefore|thus|consequently|as a result|because of)",
103
+ }
104
+ ROLE_RE = {k: re.compile(v, re.I) for k, v in ROLE_CUES.items()}
105
+
106
+
107
+ def content(text: str) -> list[str]:
108
+ return [w for w in words(text) if len(w) > 3 and w not in SILHOUETTE_STOPWORDS]
109
+
110
+
111
+ def paragraphs(text: str) -> list[str]:
112
+ return _prose_paragraphs(text, strip_bold=True)
113
+
114
+
115
+ # ---------------- METRICS (verbatim from the validated prototype) ----------------
116
+
117
+ def m_scaffold_opener_share(paras: list[str]):
118
+ """Share of body paragraphs opening with a discourse-connective/scaffold cue."""
119
+ body = paras[1:] if len(paras) > 1 else paras
120
+ if not body:
121
+ return 0.0
122
+ hits = 0
123
+ for p in body:
124
+ for rx in ROLE_RE.values():
125
+ if rx.search(p):
126
+ hits += 1
127
+ break
128
+ return round(hits / len(body), 3)
129
+
130
+
131
+ def m_role_entropy(paras: list[str]):
132
+ """Shannon entropy (bits) over opener role classes incl. 'topic' (none).
133
+ A human never opens paragraphs with cue classes -> entropy 0; a template
134
+ rotates several distinct cue classes -> positive entropy."""
135
+ if len(paras) < 3:
136
+ return None
137
+ roles = []
138
+ for p in paras:
139
+ r = "topic"
140
+ for name, rx in ROLE_RE.items():
141
+ if rx.search(p):
142
+ r = name
143
+ break
144
+ roles.append(r)
145
+ counts = Counter(roles)
146
+ n = len(roles)
147
+ ent = -sum((c / n) * math.log2(c / n) for c in counts.values())
148
+ return round(ent, 3)
149
+
150
+
151
+ def m_preview_fulfillment(paras: list[str]):
152
+ """Share of body paragraphs whose opening content word appears in the
153
+ intro paragraph (outline-following / preview-then-fulfill tell)."""
154
+ if len(paras) < 4:
155
+ return None
156
+ intro = set(content(paras[0]))
157
+ if not intro:
158
+ return 0.0
159
+ body = paras[1:-1] if len(paras) > 2 else paras[1:]
160
+ hits = tot = 0
161
+ for p in body:
162
+ cs = content(p)
163
+ if not cs:
164
+ continue
165
+ tot += 1
166
+ if cs[0] in intro:
167
+ hits += 1
168
+ return round(hits / tot, 3) if tot else 0.0
169
+
170
+
171
+ def m_callback_content(paras: list[str]):
172
+ """Content words introduced in the first third, ABSENT from the middle,
173
+ reappearing in the last third: a recap loop, not a sustained topic."""
174
+ n = len(paras)
175
+ if n < 5:
176
+ return None
177
+ third = max(1, n // 3)
178
+ early = set().union(*[set(content(paras[i])) for i in range(third)])
179
+ mid = (set().union(*[set(content(paras[i])) for i in range(third, n - third)])
180
+ if n - 2 * third > 0 else set())
181
+ late = set().union(*[set(content(paras[i])) for i in range(n - third, n)])
182
+ cb = (early & late) - mid
183
+ return round(len(cb) / n, 3)
184
+
185
+
186
+ def m_heading_preview(text: str):
187
+ """Share of ## heading head-nouns whose key word also appears in the intro
188
+ paragraph = outline preview-then-fulfill. Measures outline-following, not
189
+ heading presence, so it is retained under --genre docs."""
190
+ heads = re.findall(r"(?m)^\s{0,3}#{2,3}\s+(.*)$", text)
191
+ if len(heads) < 3:
192
+ return None
193
+ paras = paragraphs(text)
194
+ intro = set(content(paras[0])) if paras else set()
195
+ if not intro:
196
+ return 0.0
197
+ hit = 0
198
+ for h in heads:
199
+ hc = set(content(h))
200
+ if hc & intro:
201
+ hit += 1
202
+ return round(hit / len(heads), 3)
203
+
204
+
205
+ # Metric registry: name -> (paragraph-based? / text-based?, function).
206
+ PARA_METRICS = {
207
+ "scaffold_opener_share": m_scaffold_opener_share,
208
+ "role_entropy_bits": m_role_entropy,
209
+ "preview_fulfillment": m_preview_fulfillment,
210
+ "callback_content": m_callback_content,
211
+ }
212
+ TEXT_METRICS = {
213
+ "heading_preview": m_heading_preview,
214
+ }
215
+ METRIC_ORDER = [
216
+ "scaffold_opener_share",
217
+ "role_entropy_bits",
218
+ "heading_preview",
219
+ "preview_fulfillment",
220
+ "callback_content",
221
+ ]
222
+
223
+ SUGGESTIONS = {
224
+ "scaffold_opener_share":
225
+ "Open body paragraphs on their own specific claim, not a discourse cue.",
226
+ "role_entropy_bits":
227
+ "Stop rotating 'However / In addition / Ultimately' scaffold openers.",
228
+ "heading_preview":
229
+ "Headings restate the intro's outline; let sections carry new ground.",
230
+ "preview_fulfillment":
231
+ "The body just fulfills an outline previewed in the intro; drop the preview.",
232
+ "callback_content":
233
+ "The ending loops back to opening vocabulary; end on a concrete final point.",
234
+ }
235
+
236
+
237
+ def compute_metrics(text: str, paras: list[str]) -> dict:
238
+ row = {}
239
+ for name in METRIC_ORDER:
240
+ if name in PARA_METRICS:
241
+ row[name] = PARA_METRICS[name](paras)
242
+ else:
243
+ row[name] = TEXT_METRICS[name](text)
244
+ return row
245
+
246
+
247
+ def load_reference(path: Path) -> dict:
248
+ data = json.loads(path.read_text())
249
+ return data["metrics"]
250
+
251
+
252
+ def relu(x: float) -> float:
253
+ return x if x > 0 else 0.0
254
+
255
+
256
+ def flag(metric, value, threshold, detail, suggestion) -> dict:
257
+ return {
258
+ "metric": metric,
259
+ "value": value,
260
+ "threshold": threshold,
261
+ "severity": "soft",
262
+ "detail": detail,
263
+ "suggestion": suggestion,
264
+ }
265
+
266
+
267
+ # Metrics a given --genre suppresses outright. See the docstring in scan()
268
+ # for why only callback_content is suppressed under --genre docs.
269
+ GENRE_SUPPRESSIONS = {
270
+ "docs": {"callback_content"},
271
+ }
272
+
273
+
274
+ def scan(text: str, reference: dict, genre: str = "prose") -> dict:
275
+ # Genre is a passthrough echoed for parity with structure_scan.
276
+ # --genre docs suppresses ONLY callback_content: reference docs, specs, and
277
+ # doctrine conventionally reprise opening themes at the end, which is not
278
+ # the essay recap coda the metric exists to catch (SIL rows pin both
279
+ # directions). heading_preview is deliberately retained under docs because
280
+ # it measures outline-following (a tell even in reference docs), and the
281
+ # metric already clears legitimate academic roadmaps (struct17) on its own.
282
+ # --genre social documents that loose social copy rarely has the
283
+ # >=3-paragraph structure these metrics need.
284
+ paras = paragraphs(text)
285
+ base = {
286
+ "genre": genre,
287
+ "prose_paragraphs": len(paras),
288
+ }
289
+ if len(paras) < MIN_PARAGRAPHS:
290
+ base.update({
291
+ "flags": [],
292
+ "flagged": {},
293
+ "metrics": None,
294
+ "penalty": None,
295
+ "note": f"fewer than {MIN_PARAGRAPHS} prose paragraphs; "
296
+ "silhouette metrics not scored",
297
+ })
298
+ return base
299
+
300
+ metrics = compute_metrics(text, paras)
301
+ contributions = {}
302
+ penalty = 0.0
303
+ flags = []
304
+ for name in METRIC_ORDER:
305
+ if name in GENRE_SUPPRESSIONS.get(genre, set()):
306
+ contributions[name] = 0.0
307
+ continue
308
+ ref = reference[name]
309
+ value = metrics[name]
310
+ if not isinstance(value, (int, float)):
311
+ contributions[name] = None
312
+ continue
313
+ median = ref["median"]
314
+ scale = max(ref["iqr"], ref["fence"])
315
+ weight = ref["weight"]
316
+ contribution = round(weight * relu((value - median) / scale), 3)
317
+ contributions[name] = contribution
318
+ penalty += contribution
319
+ # A metric individually clears the human fence when value >= fence,
320
+ # i.e. its contribution reaches its full weight.
321
+ if value >= ref["fence"]:
322
+ flags.append(flag(
323
+ name,
324
+ value,
325
+ f"human fence {ref['fence']} (weight {weight})",
326
+ f"{name} at {value} clears the human upper fence "
327
+ f"{ref['fence']}.",
328
+ SUGGESTIONS[name],
329
+ ))
330
+ penalty = round(penalty, 3)
331
+
332
+ if penalty >= PENALTY_THRESHOLD:
333
+ flags.insert(0, flag(
334
+ "silhouette_penalty",
335
+ penalty,
336
+ f">= {PENALTY_THRESHOLD}",
337
+ "The document's idea arrangement matches a templated AI silhouette "
338
+ "(preview-then-fulfill, rotating scaffold openers, recap loop).",
339
+ "Rearrange around the actual argument instead of a symmetric outline; "
340
+ "cut previews and the closing recap.",
341
+ ))
342
+
343
+ base.update({
344
+ "flags": flags,
345
+ "flagged": {f["metric"]: True for f in flags},
346
+ "metrics": metrics,
347
+ "contributions": contributions,
348
+ "penalty": penalty,
349
+ })
350
+ return base
351
+
352
+
353
+ def parse_args(argv: list[str]) -> argparse.Namespace:
354
+ parser = argparse.ArgumentParser(description=__doc__)
355
+ parser.add_argument("path", nargs="?")
356
+ parser.add_argument("--genre", choices=["prose", "docs", "social"], default="prose")
357
+ return parser.parse_args(argv)
358
+
359
+
360
+ def main(argv: list[str]) -> int:
361
+ args = parse_args(argv)
362
+ if not REFERENCE_PATH.exists():
363
+ print(f"Missing reference: {REFERENCE_PATH}", file=sys.stderr)
364
+ return 2
365
+ reference = load_reference(REFERENCE_PATH)
366
+
367
+ if args.path:
368
+ path = Path(args.path)
369
+ if not path.exists():
370
+ print(f"Missing file: {path}", file=sys.stderr)
371
+ return 2
372
+ text = path.read_text(errors="replace")
373
+ else:
374
+ text = sys.stdin.buffer.read().decode("utf-8", errors="replace")
375
+
376
+ result = scan(text, reference, args.genre)
377
+
378
+ if not result.get("flags") and not is_probably_english(text):
379
+ print(json.dumps({"non_english": True, "flags": [], "penalty": None}, indent=2))
380
+ print("note: input appears non-English; scanner declined (English-only).", file=sys.stderr)
381
+ return 0
382
+
383
+ print(json.dumps(result, indent=2))
384
+ is_flagged = bool(result.get("penalty") is not None
385
+ and result["penalty"] >= PENALTY_THRESHOLD)
386
+ return 1 if is_flagged else 0
387
+
388
+
389
+ if __name__ == "__main__":
390
+ raise SystemExit(main(sys.argv[1:]))