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,333 @@
1
+ """Gates: what holds a Run between two Steps, as predicates in a registry.
2
+
3
+ A Gate is a predicate over Run State and the Run Log returning cleared, blocked,
4
+ or errored. Blocked suspends the Run — nothing is wrong, and the Gate can still
5
+ clear. Errored halts it: a Gate that cannot evaluate has nothing to clear, so
6
+ suspending one would invite a resume that suspends again forever.
7
+
8
+ The registry is the point. `GATES` maps a kind onto its predicate, the Workflow
9
+ parser validates against its keys, and the runtime looks a kind up rather than
10
+ knowing any. Adding a kind is an entry here and nothing in `runtime.py`, exactly
11
+ as a seventh Role is an entry in `RUNNERS`.
12
+
13
+ `GATES` is the floor rather than the whole table. An active Plugin contributes
14
+ Gate kinds of its own — a dbt parse, a dialect check — and `core.registry`
15
+ assembles the table one Run is validated and evaluated against, which is handed
16
+ to `parse_workflow` and to `evaluate_gate` rather than swapped in globally. The
17
+ shipped three cannot be redefined by a Plugin: see ADR-0018.
18
+
19
+ Every Gate is handed the same context, the Command Runner and the working tree
20
+ included, so that one whose verdict comes from executing something has what it
21
+ needs without the runtime knowing which one that is. Most predicates ignore both.
22
+
23
+ A Gate that draws its verdict from a Role's output names that Role in
24
+ `invalidates`, which un-retires the Step that produced it (ADR-0008). A Gate
25
+ that reads something else — a human, a suite it re-executes — names nobody, and
26
+ every Step behind it stays behind it.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Callable, Mapping
32
+ from dataclasses import dataclass, replace
33
+ from pathlib import Path
34
+
35
+ from ..agents.security import SECURITY
36
+ from .config import load_config
37
+ from .contracts import GateEntry, GateVerdict, RunState
38
+ from .process import CommandResult, CommandRunner, MissingBinary
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class GateContext:
43
+ """What a Gate is asked about: the Run, and the Step it stands behind.
44
+
45
+ `role` is the Role of that Step, not the Role the Gate judges — a human Gate
46
+ stands behind a Step whose work it has no opinion of. A predicate that does
47
+ judge a Role's output names it in the verdict it returns.
48
+
49
+ `runner` and `root` are what a Gate acts through when its verdict comes from
50
+ running something rather than from reading the Run Log. They are on every
51
+ context rather than on the ones that need them, so that registering a Gate
52
+ stays the whole cost of adding one.
53
+ """
54
+
55
+ state: RunState
56
+ kind: str
57
+ role: str
58
+ step: int
59
+ runner: CommandRunner
60
+ root: Path
61
+
62
+ @property
63
+ def verdicts(self) -> tuple[GateEntry, ...]:
64
+ """What this Gate has already said, at this Step, in Run Log order.
65
+
66
+ Identity is the kind and the position: one Workflow may declare the same
67
+ kind of Gate twice, and the second one has not spoken because the first
68
+ one did.
69
+ """
70
+ return tuple(
71
+ entry
72
+ for entry in self.state.gates
73
+ if entry.kind == self.kind and entry.step == self.step
74
+ )
75
+
76
+
77
+ GateCheck = Callable[[GateContext], GateEntry]
78
+
79
+
80
+ def human(context: GateContext) -> GateEntry:
81
+ """A human Gate: the Run stops, and a human decides when it goes on.
82
+
83
+ It clears once the Run Log shows it has already blocked here. The human's
84
+ acknowledgement is running `agentforge implement` again — they were told the
85
+ Run stopped, they looked at the branch, and they came back. Nothing else
86
+ would be less ceremony: resuming is a command they have to type either way.
87
+
88
+ Read off the Run Log rather than held in memory, so the Run that resumes may
89
+ be on a different machine from the Run that suspended (ADR-0002).
90
+ """
91
+ if any(entry.blocked for entry in context.verdicts):
92
+ return GateEntry(
93
+ kind="",
94
+ verdict=GateVerdict.CLEARED,
95
+ summary="a human was asked to look at this Step and has re-run the Run",
96
+ )
97
+ return GateEntry(
98
+ kind="",
99
+ verdict=GateVerdict.BLOCKED,
100
+ summary=(
101
+ f"a human Gate follows the {context.role} Step. Review the work on the "
102
+ "Run's branch, then re-run `agentforge implement` to carry on."
103
+ ),
104
+ )
105
+
106
+
107
+ #: The exit status a test runner spends on "tests ran and some failed", which is
108
+ #: the one non-zero status that is a report on the code rather than on the run.
109
+ #: pytest spends 2 through 5 on interruption, internal error, bad usage, and
110
+ #: nothing collected; none of those say anything about the repository.
111
+ SUITE_FAILED = 1
112
+
113
+ #: How much of a failing suite reaches the Run Log. The end of it: a test runner
114
+ #: puts its summary last, and a comment nobody scrolls to the bottom of is a
115
+ #: comment nobody reads.
116
+ TAIL_LINES = 40
117
+ TAIL_CHARS = 2000
118
+
119
+
120
+ def tests(context: GateContext) -> GateEntry:
121
+ """The test-suite Gate: run the suite, and read the exit status.
122
+
123
+ It re-executes rather than reading what the Tester said about the suite. An
124
+ Agent Result is a Role's account of its own work, and a Gate that took one at
125
+ its word would be checking the report rather than the repository. So this
126
+ Gate names nobody in `invalidates`: it judged no Step's output, every Step
127
+ behind it stays behind it, and the Run that resumes runs the suite again
128
+ rather than reading this verdict back (ADR-0008). Naming the Tester here
129
+ would un-retire the Tester Step and deadlock the Run.
130
+
131
+ Cleared, blocked, and errored are three different things that happen when you
132
+ run a suite. It passed. It ran and reported failures — nothing is wrong with
133
+ the plan, and the next commit may well clear it, which is Suspended exactly.
134
+ Or it never reached a verdict, and a Gate with nothing to clear halts the Run
135
+ rather than inviting a resume that suspends again.
136
+
137
+ ADR-0007's default-deny governs what a Role may run, not what AgentForge
138
+ runs. A Gate is not an Agent: the suite is the one the project declared, its
139
+ exit status is read rather than interpreted, and no model chose either.
140
+ """
141
+ suite = load_config(context.root).test_suite
142
+ rendered = " ".join(suite)
143
+
144
+ if not context.runner.has_binary(suite[0]):
145
+ return _cannot_run(rendered, f"{suite[0]!r} is not installed or not on PATH")
146
+
147
+ try:
148
+ result = context.runner.run(suite, cwd=context.root)
149
+ except MissingBinary as exc:
150
+ # The tool resolved on PATH and then would not start: a Windows `npm.cmd`
151
+ # is the everyday way to arrive here. Ending the Run is the Gate's job
152
+ # either way — raising would crash it instead, and nothing a human could
153
+ # act on would reach the Issue.
154
+ return _cannot_run(rendered, str(exc))
155
+
156
+ if result.ok:
157
+ return GateEntry(
158
+ kind="",
159
+ verdict=GateVerdict.CLEARED,
160
+ summary=f"`{rendered}` passed.",
161
+ )
162
+
163
+ if result.returncode == SUITE_FAILED:
164
+ return GateEntry(
165
+ kind="",
166
+ verdict=GateVerdict.BLOCKED,
167
+ summary=(
168
+ f"`{rendered}` failed. The Run stops here rather than carrying a red "
169
+ f"suite to Sign-off.\n\n{command_tail(result)}"
170
+ ),
171
+ )
172
+
173
+ return GateEntry(
174
+ kind="",
175
+ verdict=GateVerdict.ERRORED,
176
+ summary=(
177
+ f"`{rendered}` exited {result.returncode}, which is not a report on the "
178
+ "code: the suite did not run to a verdict, so there is nothing here for a "
179
+ f"later Run to clear.\n\n{command_tail(result)}"
180
+ ),
181
+ )
182
+
183
+
184
+ def _cannot_run(rendered: str, reason: str) -> GateEntry:
185
+ """The suite never started, which is not a report on the code.
186
+
187
+ Errored rather than blocked: waiting clears nothing, and the thing to fix is
188
+ the machine or the declaration rather than the repository.
189
+ """
190
+ return GateEntry(
191
+ kind="",
192
+ verdict=GateVerdict.ERRORED,
193
+ summary=(
194
+ f"the test-suite Gate cannot run `{rendered}`: {reason}. Name the suite this "
195
+ "repository runs under `gates.tests.suite` in `.agentforge/config.yaml`."
196
+ ),
197
+ )
198
+
199
+
200
+ def command_tail(result: CommandResult) -> str:
201
+ """The end of what a command printed, fenced for the Issue.
202
+
203
+ Public because a Plugin's Gate reports what it ran the same way the shipped
204
+ ones do. A validator that shells out to a parser wants the last forty lines
205
+ of it in the Run Log, and writing that a second time would be two answers to
206
+ one question about how much of a failure a human reads.
207
+
208
+ Four backticks rather than three: a failing test in a repository like this
209
+ one prints fenced blocks of its own, and a fence closed early takes the rest
210
+ of the Run Log entry with it.
211
+ """
212
+ text = "\n".join(part for part in (result.stdout.strip(), result.stderr.strip()) if part)
213
+ text = "\n".join(text.splitlines()[-TAIL_LINES:])[-TAIL_CHARS:].strip()
214
+ if not text:
215
+ return "It printed nothing."
216
+ return f"````text\n{text}\n````"
217
+
218
+
219
+ def security(context: GateContext) -> GateEntry:
220
+ """The clean-pass Gate: the Security Agent's Findings, read off the Run Log.
221
+
222
+ The mirror image of the test-suite Gate above. This verdict is drawn from a
223
+ Role's own output, so it names that Role in `invalidates` (ADR-0008): a human
224
+ who fixes a finding needs the audit run again, and a Run that resumed past
225
+ this entry would re-read a finding about code that no longer exists — which
226
+ is the deadlock the ADR was written about, from the other side.
227
+
228
+ An audit that reported nothing clears it. That is why the Security Role is
229
+ told to escalate rather than report an empty list when it could not look:
230
+ "audited and clean" and "did not audit" are the same shape here, and only
231
+ the Role knows which one happened.
232
+
233
+ Security not having run at all errors rather than blocks. A Gate waits for
234
+ something that can still arrive, and a Step that is not in front of this Run
235
+ never will.
236
+ """
237
+ audits = [
238
+ result
239
+ for result in context.state.results
240
+ if result.role == SECURITY.name and result.ok
241
+ ]
242
+ if not audits:
243
+ return GateEntry(
244
+ kind="",
245
+ verdict=GateVerdict.ERRORED,
246
+ summary=(
247
+ f"a {SECURITY.name} Gate stands behind the {context.role} Step, and the "
248
+ f"{SECURITY.name} Role has not completed in this Run. There is no audit "
249
+ "to read, so there is nothing here to clear: put a `security` Step in "
250
+ "front of this Gate."
251
+ ),
252
+ )
253
+
254
+ findings = audits[-1].findings
255
+ if not findings:
256
+ return GateEntry(
257
+ kind="",
258
+ verdict=GateVerdict.CLEARED,
259
+ summary=f"the {SECURITY.name} Agent audited the change and reported no findings",
260
+ )
261
+
262
+ listed = "\n".join(
263
+ f"- `{finding.location or 'no location reported'}` — "
264
+ f"{finding.risk.strip() or 'no risk described'}"
265
+ for finding in findings
266
+ )
267
+ return GateEntry(
268
+ kind="",
269
+ verdict=GateVerdict.BLOCKED,
270
+ invalidates=SECURITY.name,
271
+ # No closing instruction here: the comment this verdict travels in
272
+ # already tells the reader that the Security Step will run again, as
273
+ # every verdict naming a Role in `invalidates` does.
274
+ summary=(
275
+ f"the {SECURITY.name} Agent reported {len(findings)} "
276
+ f"finding{'' if len(findings) == 1 else 's'}, so the Run holds here rather "
277
+ f"than carrying them to Sign-off.\n\n{listed}"
278
+ ),
279
+ )
280
+
281
+
282
+ #: Gate kind to predicate. The Workflow parser validates names against these
283
+ #: keys, so a definition naming `vibes` is refused at load time.
284
+ GATES: dict[str, GateCheck] = {
285
+ "human": human,
286
+ "tests": tests,
287
+ "security": security,
288
+ }
289
+
290
+
291
+ def evaluate_gate(
292
+ kind: str, context: GateContext, gates: Mapping[str, GateCheck] | None = None
293
+ ) -> GateEntry:
294
+ """Ask one Gate, and stamp its answer with which Gate was asked.
295
+
296
+ `gates` is the table to look in, and `None` means the shipped three. A Run
297
+ with active Plugins passes the wider one `core.registry` assembles, the same
298
+ way it passes a wider extractor table to the resolver: this module knows
299
+ that a table can be widened and nothing about what widens it.
300
+
301
+ Total on purpose: an unregistered kind errors rather than raising, so the
302
+ runtime has one way of ending at a Gate rather than two. A Workflow cannot
303
+ reach here with an unknown kind — the parser refuses those, validated
304
+ against the same table — but a Workflow built in code can, and so can a Run
305
+ whose Plugin was skipped after the Workflow that names its Gate was written.
306
+ """
307
+ table = GATES if gates is None else gates
308
+ check = table.get(kind)
309
+ if check is None:
310
+ entry = GateEntry(
311
+ kind=kind,
312
+ verdict=GateVerdict.ERRORED,
313
+ summary=(
314
+ f"no Gate of kind {kind!r} is registered in this Run; "
315
+ f"kinds are: {', '.join(sorted(table))}"
316
+ ),
317
+ )
318
+ else:
319
+ entry = check(context)
320
+
321
+ return replace(entry, kind=kind, step=context.step)
322
+
323
+
324
+ __all__ = [
325
+ "GATES",
326
+ "GateCheck",
327
+ "GateContext",
328
+ "command_tail",
329
+ "evaluate_gate",
330
+ "human",
331
+ "security",
332
+ "tests",
333
+ ]