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,455 @@
|
|
|
1
|
+
"""Which Plugins are active for a Run, and what they contribute.
|
|
2
|
+
|
|
3
|
+
One question, asked once per Run: given the blast radius the frozen Plan names
|
|
4
|
+
and the repository it names it in, which Plugins answer for this work? Everything
|
|
5
|
+
downstream — Fragments now, Extractors and Gate kinds and Commands in the tickets
|
|
6
|
+
that follow — hangs off that answer, which is why activation lives here rather
|
|
7
|
+
than inside any one of them.
|
|
8
|
+
|
|
9
|
+
Three properties this module owes its callers, the same three the Context Pack
|
|
10
|
+
resolver owes:
|
|
11
|
+
|
|
12
|
+
- **Deterministic.** The same Plan against the same repository yields the same
|
|
13
|
+
active set, in the same order. Nothing here iterates a set, and the registry
|
|
14
|
+
is a tuple rather than a dict so that registration order is the answer's order.
|
|
15
|
+
- **Bounded.** Fragments make prompts longer, and the Context Pack milestone
|
|
16
|
+
exists to make them shorter. A Plugin cannot spend more than `MAX_FRAGMENT_CHARS`
|
|
17
|
+
on one Role, and no Role receives more than `MAX_FRAGMENTS_PER_ROLE` of them.
|
|
18
|
+
- **Survivable.** A Plugin that raises while contributing is skipped and named,
|
|
19
|
+
and the Run carries on. Domain knowledge is a nice-to-have; a Run that died
|
|
20
|
+
because a convention list was malformed would be worse than one without it.
|
|
21
|
+
|
|
22
|
+
Activation reads the Plan rather than the resolved Context Pack. The two agree
|
|
23
|
+
in the ordinary case, but a control Run resolves no pack at all (ADR-0010), and
|
|
24
|
+
a Plugin set that changed depending on whether the control was running would
|
|
25
|
+
make the control meaningless.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from ..context.extractors import EXTRACTORS, extract
|
|
35
|
+
from ..context.extractors.base import Extraction
|
|
36
|
+
from ..context.resolver import MAX_FILES, file_text, inside
|
|
37
|
+
from ..plugins import BUILT_IN
|
|
38
|
+
from .contracts import Command, GateEntry, GateVerdict, Plan, Plugin, Validator
|
|
39
|
+
from .gates import GATES, GateCheck
|
|
40
|
+
|
|
41
|
+
#: What one Plugin may contribute to one Role. A Fragment is a few hundred
|
|
42
|
+
#: tokens of convention — Unity Catalog three-part naming, DataFrame API over
|
|
43
|
+
#: RDD — and anything past this is a document that belongs in the repository the
|
|
44
|
+
#: Role is reading anyway. Set from a guess; #61 re-sets it from a measurement.
|
|
45
|
+
MAX_FRAGMENT_CHARS = 1200
|
|
46
|
+
|
|
47
|
+
#: How many Fragments one Role's prompt may carry. Four active Plugins each
|
|
48
|
+
#: spending the cap above is already more standing instruction than the Role's
|
|
49
|
+
#: own prompt, and past that the Plugins are the Role.
|
|
50
|
+
MAX_FRAGMENTS_PER_ROLE = 4
|
|
51
|
+
|
|
52
|
+
#: The file types an `imports` declaration is asked about. An import is a Python
|
|
53
|
+
#: idea, and a `.sql` file's references are tables rather than modules — reading
|
|
54
|
+
#: those for a module name would activate a Plugin because a warehouse happened
|
|
55
|
+
#: to hold a table with its name. See ADR-0017.
|
|
56
|
+
IMPORT_SUFFIXES = (".py",)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Activation:
|
|
61
|
+
"""Which Plugins answered for a Run, and which could not be asked.
|
|
62
|
+
|
|
63
|
+
`skipped` is not an error path the runtime branches on. It is what the Run
|
|
64
|
+
Log prints so that a prompt which did not grow has a reason a human can
|
|
65
|
+
read, the same way the pack itself does.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
plugins: tuple[Plugin, ...] = ()
|
|
69
|
+
skipped: tuple[str, ...] = ()
|
|
70
|
+
|
|
71
|
+
def __bool__(self) -> bool:
|
|
72
|
+
return bool(self.plugins)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
#: The answer for a Run that activated nothing — `--no-plugins`, or a repository
|
|
76
|
+
#: no Plugin claims. A singleton so that callers can default to it without each
|
|
77
|
+
#: building an empty one.
|
|
78
|
+
NO_PLUGINS = Activation()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def activate(
|
|
82
|
+
plan: Plan, root: Path | str, plugins: Sequence[Plugin] = BUILT_IN
|
|
83
|
+
) -> Activation:
|
|
84
|
+
"""The Plugins that answer for this Plan in this repository.
|
|
85
|
+
|
|
86
|
+
A Plugin answers if the Plan's blast radius carries one of its suffixes, if
|
|
87
|
+
the repository root carries one of its markers, or if a Python file in that
|
|
88
|
+
blast radius imports one of the modules it names. Any one is sufficient: a
|
|
89
|
+
Plan touching one `.sql` file in a Python repository is held to both sets of
|
|
90
|
+
conventions, because both are true of the code being written.
|
|
91
|
+
|
|
92
|
+
Import detection is what a suffix cannot do. `.py` says a file is Python and
|
|
93
|
+
says nothing about whether it is a Spark job, so the `pyspark` Plugin
|
|
94
|
+
declares the module rather than the suffix and stays silent in the Django
|
|
95
|
+
app next door. See ADR-0017.
|
|
96
|
+
"""
|
|
97
|
+
root = Path(root)
|
|
98
|
+
suffixes = _suffixes(plan)
|
|
99
|
+
|
|
100
|
+
# Read once for the whole registry, and not at all where no Plugin asks: a
|
|
101
|
+
# repository with neither `pyspark` nor any third-party Plugin declaring an
|
|
102
|
+
# import opens no file to find that out. Memoised in a closure rather than
|
|
103
|
+
# computed up front so that the read happens inside the `try` below, where
|
|
104
|
+
# a Plugin declaring a broken `imports` costs itself and not the Run.
|
|
105
|
+
read: list[frozenset[str]] = []
|
|
106
|
+
|
|
107
|
+
def imported() -> frozenset[str]:
|
|
108
|
+
if not read:
|
|
109
|
+
read.append(_imported(plan, root))
|
|
110
|
+
return read[0]
|
|
111
|
+
|
|
112
|
+
active: list[Plugin] = []
|
|
113
|
+
skipped: list[str] = []
|
|
114
|
+
for plugin in plugins:
|
|
115
|
+
try:
|
|
116
|
+
if _answers(plugin, suffixes, imported, root):
|
|
117
|
+
active.append(plugin)
|
|
118
|
+
except Exception as exc: # noqa: BLE001 — a Plugin must not end a Run
|
|
119
|
+
skipped.append(f"{plugin.name} ({type(exc).__name__}: {exc})")
|
|
120
|
+
|
|
121
|
+
return Activation(plugins=tuple(active), skipped=tuple(skipped))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def fragments_for(activation: Activation, role: str) -> tuple[str, ...]:
|
|
125
|
+
"""What the active Plugins have to say to one Role, in registration order.
|
|
126
|
+
|
|
127
|
+
Empty `roles` on a Fragment means every Role. A Fragment past the size cap
|
|
128
|
+
is truncated rather than dropped: the first sentences of a convention list
|
|
129
|
+
are the convention, and a Role silently held to nothing is worse than one
|
|
130
|
+
held to most of it.
|
|
131
|
+
"""
|
|
132
|
+
name = role.strip().lower()
|
|
133
|
+
collected: list[str] = []
|
|
134
|
+
|
|
135
|
+
for plugin in activation.plugins:
|
|
136
|
+
for fragment in plugin.fragments:
|
|
137
|
+
if fragment.roles and name not in {r.strip().lower() for r in fragment.roles}:
|
|
138
|
+
continue
|
|
139
|
+
text = fragment.text.strip()
|
|
140
|
+
if not text:
|
|
141
|
+
continue
|
|
142
|
+
collected.append(f"**{plugin.name}**\n{text[:MAX_FRAGMENT_CHARS].rstrip()}")
|
|
143
|
+
break # one Fragment per Plugin per Role, so the cap is a cap
|
|
144
|
+
|
|
145
|
+
return tuple(collected[:MAX_FRAGMENTS_PER_ROLE])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def extractors_for(
|
|
149
|
+
activation: Activation,
|
|
150
|
+
base: Mapping[str, Callable[[str], Extraction]] = EXTRACTORS,
|
|
151
|
+
) -> dict[str, Callable[[str], Extraction]]:
|
|
152
|
+
"""The extractor table for a Run: the built-in three, widened by Plugins.
|
|
153
|
+
|
|
154
|
+
The base is the floor rather than the default. A suffix no Plugin claims is
|
|
155
|
+
read the way it has always been read, which is what makes a Run with no
|
|
156
|
+
active Plugin resolve exactly the pack it resolved before Plugins existed.
|
|
157
|
+
|
|
158
|
+
**Two Plugins claiming one suffix: the first in registration order wins.**
|
|
159
|
+
Registration order is the order of `plugins.BUILT_IN`, so the answer is a
|
|
160
|
+
property of the shipped tuple rather than of dictionary insertion, and it
|
|
161
|
+
is the same rule `extractors.base.ordered` applies to names — first
|
|
162
|
+
occurrence wins. The loser is not an error: a Plugin whose reader is
|
|
163
|
+
shadowed for one suffix still contributes everything else it declares.
|
|
164
|
+
|
|
165
|
+
A Plugin's claim beats a built-in one for the same suffix. That is the
|
|
166
|
+
whole point of contributing a reader: `sql` knows what a `ref()` is and the
|
|
167
|
+
built-in SQL extractor does not, and a Run where dbt is active should get
|
|
168
|
+
the answer from the one that knows.
|
|
169
|
+
"""
|
|
170
|
+
table = dict(base)
|
|
171
|
+
claimed: dict[str, str] = {}
|
|
172
|
+
|
|
173
|
+
for plugin in activation.plugins:
|
|
174
|
+
for extractor in plugin.extractors:
|
|
175
|
+
for suffix in extractor.suffixes:
|
|
176
|
+
key = suffix.lower()
|
|
177
|
+
if key in claimed:
|
|
178
|
+
continue # first registration wins, and says so above
|
|
179
|
+
claimed[key] = plugin.name
|
|
180
|
+
table[key] = extractor.read
|
|
181
|
+
|
|
182
|
+
return table
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def gates_for(
|
|
186
|
+
activation: Activation, base: Mapping[str, GateCheck] = GATES
|
|
187
|
+
) -> dict[str, GateCheck]:
|
|
188
|
+
"""The Gate table for a Run: the shipped three, widened by Plugins.
|
|
189
|
+
|
|
190
|
+
Assembled the way the extractor table is, and handed to the two places that
|
|
191
|
+
need it — `parse_workflow` validates a definition against it and
|
|
192
|
+
`evaluate_gate` looks a kind up in it — rather than swapped into `GATES`
|
|
193
|
+
globally. A Run's active Plugins are a property of that Run, and a process
|
|
194
|
+
running two of them must not have the first one's Gate kinds available to
|
|
195
|
+
the second.
|
|
196
|
+
|
|
197
|
+
**A Plugin cannot redefine a shipped kind.** `human`, `tests`, and `security`
|
|
198
|
+
mean what the shipped Workflows say they mean, and a Plugin that could
|
|
199
|
+
replace `human` could make a human Gate stop stopping. This is the one place
|
|
200
|
+
a Plugin's claim loses to a built-in, which is the opposite of the rule for
|
|
201
|
+
Extractors and is deliberate: a suffix is a question about a file, and a
|
|
202
|
+
Plugin that claims one knows more about that file than the generic reader
|
|
203
|
+
does, while a Gate kind is a promise a Workflow names. See ADR-0018.
|
|
204
|
+
|
|
205
|
+
Between two Plugins claiming one kind, the first in registration order wins,
|
|
206
|
+
which is the rule `extractors_for` applies and for the same reason.
|
|
207
|
+
"""
|
|
208
|
+
table = dict(base)
|
|
209
|
+
claimed: dict[str, str] = {}
|
|
210
|
+
|
|
211
|
+
for plugin in activation.plugins:
|
|
212
|
+
for validator in plugin.validators:
|
|
213
|
+
kind = validator.kind.strip().lower()
|
|
214
|
+
if not kind or kind in base or kind in claimed:
|
|
215
|
+
continue
|
|
216
|
+
claimed[kind] = plugin.name
|
|
217
|
+
table[kind] = _guarded(plugin, validator)
|
|
218
|
+
|
|
219
|
+
return table
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _guarded(plugin: Plugin, validator: Validator) -> GateCheck:
|
|
223
|
+
"""One Plugin's check, holding it to the bargain the rest of the seam makes.
|
|
224
|
+
|
|
225
|
+
A validator that cannot evaluate is supposed to return an errored verdict,
|
|
226
|
+
and `plugins/sql`'s dbt Gate is the worked example of doing it. This is what
|
|
227
|
+
happens when one does not: the Run ends at the Gate with a message on its
|
|
228
|
+
Issue rather than at a traceback in the terminal of whoever started it, and
|
|
229
|
+
the message names the Plugin so the reader knows whose Gate broke.
|
|
230
|
+
|
|
231
|
+
Errored rather than blocked, for the reason every other Gate errors: a check
|
|
232
|
+
that raised decided nothing, so waiting would clear nothing. Only a Plugin's
|
|
233
|
+
validators are wrapped — a shipped Gate raising is a defect in AgentForge,
|
|
234
|
+
and dressing it as a verdict would hide it. See ADR-0018.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def check(context) -> GateEntry:
|
|
238
|
+
try:
|
|
239
|
+
return validator.check(context)
|
|
240
|
+
except Exception as exc: # noqa: BLE001 — a Plugin must not end a Run
|
|
241
|
+
return GateEntry(
|
|
242
|
+
kind="",
|
|
243
|
+
verdict=GateVerdict.ERRORED,
|
|
244
|
+
summary=(
|
|
245
|
+
f"the {validator.kind!r} Gate, contributed by the {plugin.name!r} "
|
|
246
|
+
f"Plugin, raised {type(exc).__name__}: {exc}. A Gate that could not "
|
|
247
|
+
"evaluate has nothing here for a later Run to clear."
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return check
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def commands_for(activation: Activation) -> dict[str, Command]:
|
|
255
|
+
"""The Commands this repository's active Plugins contribute, by name.
|
|
256
|
+
|
|
257
|
+
The fourth table, assembled the way the other three are, and the only one
|
|
258
|
+
with no shipped floor: AgentForge itself has no chores, and a Command that
|
|
259
|
+
is not a Plugin's is nobody's. A repository no Plugin answers for therefore
|
|
260
|
+
gets an empty table, and `agentforge run` says so rather than offering a
|
|
261
|
+
list of things that would fail.
|
|
262
|
+
|
|
263
|
+
Two Plugins claiming one name resolve by registration order, first wins,
|
|
264
|
+
which is the rule the other tables apply.
|
|
265
|
+
"""
|
|
266
|
+
table: dict[str, Command] = {}
|
|
267
|
+
|
|
268
|
+
for plugin in activation.plugins:
|
|
269
|
+
for command in plugin.commands:
|
|
270
|
+
name = command.name.strip().lower()
|
|
271
|
+
if name and name not in table:
|
|
272
|
+
table[name] = command
|
|
273
|
+
|
|
274
|
+
return table
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def contributions(activation: Activation) -> tuple[tuple[str, str], ...]:
|
|
278
|
+
"""Each active Plugin and what it contributed, for the Run Log.
|
|
279
|
+
|
|
280
|
+
Named per Plugin rather than totalled, because the reader's question is
|
|
281
|
+
which Plugin grew this prompt rather than by how much.
|
|
282
|
+
"""
|
|
283
|
+
listed: list[tuple[str, str]] = []
|
|
284
|
+
for plugin in activation.plugins:
|
|
285
|
+
parts = []
|
|
286
|
+
if plugin.fragments:
|
|
287
|
+
roles = _named_roles(plugin)
|
|
288
|
+
parts.append(
|
|
289
|
+
f"{len(plugin.fragments)} Fragment(s) for {roles}" if roles
|
|
290
|
+
else f"{len(plugin.fragments)} Fragment(s)"
|
|
291
|
+
)
|
|
292
|
+
if plugin.extractors:
|
|
293
|
+
# The suffixes rather than the count: a reader is only interesting
|
|
294
|
+
# to the person reading the Run Log if they can tell which of their
|
|
295
|
+
# files it changed the reading of.
|
|
296
|
+
parts.append(f"Extractor(s) for {_named_suffixes(plugin)}")
|
|
297
|
+
if plugin.validators:
|
|
298
|
+
# The kinds rather than the count, because the kind is what a
|
|
299
|
+
# Workflow writes: a reader who sees `dbt` here can go and find the
|
|
300
|
+
# Step whose Gate it is, or add one.
|
|
301
|
+
parts.append(f"Gate kind(s) {_named_kinds(plugin)}")
|
|
302
|
+
if plugin.commands:
|
|
303
|
+
# Named for the same reason, and because a Command is the one
|
|
304
|
+
# contribution a human can go and type.
|
|
305
|
+
parts.append(f"Command(s) {_named_commands(plugin)}")
|
|
306
|
+
listed.append((plugin.name, ", ".join(parts) if parts else "nothing"))
|
|
307
|
+
return tuple(listed)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _named_suffixes(plugin: Plugin) -> str:
|
|
311
|
+
"""The file types this Plugin contributes a reader for, in declared order."""
|
|
312
|
+
names: list[str] = []
|
|
313
|
+
for extractor in plugin.extractors:
|
|
314
|
+
for suffix in extractor.suffixes:
|
|
315
|
+
if suffix not in names:
|
|
316
|
+
names.append(suffix)
|
|
317
|
+
return ", ".join(names)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _named_kinds(plugin: Plugin) -> str:
|
|
321
|
+
"""The Gate kinds this Plugin contributes, in declared order."""
|
|
322
|
+
names: list[str] = []
|
|
323
|
+
for validator in plugin.validators:
|
|
324
|
+
if validator.kind not in names:
|
|
325
|
+
names.append(validator.kind)
|
|
326
|
+
return ", ".join(names)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _named_commands(plugin: Plugin) -> str:
|
|
330
|
+
"""The Commands this Plugin contributes, in declared order."""
|
|
331
|
+
names: list[str] = []
|
|
332
|
+
for command in plugin.commands:
|
|
333
|
+
if command.name not in names:
|
|
334
|
+
names.append(command.name)
|
|
335
|
+
return ", ".join(names)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _named_roles(plugin: Plugin) -> str:
|
|
339
|
+
"""The Roles this Plugin speaks to, or empty where it speaks to all of them."""
|
|
340
|
+
names: list[str] = []
|
|
341
|
+
for fragment in plugin.fragments:
|
|
342
|
+
if not fragment.roles:
|
|
343
|
+
return "every Role"
|
|
344
|
+
for role in fragment.roles:
|
|
345
|
+
if role not in names:
|
|
346
|
+
names.append(role)
|
|
347
|
+
return ", ".join(names)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _suffixes(plan: Plan) -> set[str]:
|
|
351
|
+
"""Every file suffix the frozen Plan names, lowercased.
|
|
352
|
+
|
|
353
|
+
The Plan rather than the repository: a Plugin activates for the work being
|
|
354
|
+
done, not for every technology that happens to be checked in. A repository
|
|
355
|
+
with one stray notebook does not become a notebook repository.
|
|
356
|
+
"""
|
|
357
|
+
return {
|
|
358
|
+
Path(path).suffix.lower()
|
|
359
|
+
for step in plan.steps
|
|
360
|
+
for path in step.files
|
|
361
|
+
if path and Path(path).suffix
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _imported(plan: Plan, root: Path) -> frozenset[str]:
|
|
366
|
+
"""Every top-level module the Plan's Python files import.
|
|
367
|
+
|
|
368
|
+
Detection by suffix cannot tell a Spark job from a Django view: both are
|
|
369
|
+
`.py`, and only one of them wants to be told about the DataFrame API. So a
|
|
370
|
+
Plugin may declare the imports it answers for, and this reads the blast
|
|
371
|
+
radius to find them (ADR-0017).
|
|
372
|
+
|
|
373
|
+
Held to the same three promises as everything else here. It reads the files
|
|
374
|
+
the Plan names and never searches, so the answer is a function of the frozen
|
|
375
|
+
Plan and the repository. It reads at most `MAX_FILES` of them, through the
|
|
376
|
+
resolver's own size bound, so a Plan naming forty files costs forty reads
|
|
377
|
+
the pack was about to do anyway. And a file that is missing, unreadable, or
|
|
378
|
+
will not parse contributes nothing rather than raising — a Plugin whose
|
|
379
|
+
detection could be broken by a syntax error would be worse than no Plugin.
|
|
380
|
+
|
|
381
|
+
Imports are read with the built-in Python extractor rather than with a
|
|
382
|
+
regular expression: it is already the thing in this codebase that knows what
|
|
383
|
+
an import is, and `import pyspark` inside a docstring is not one. With the
|
|
384
|
+
built-in table rather than the widened one, because the widened table is
|
|
385
|
+
assembled from the Plugins this function is being asked to choose.
|
|
386
|
+
"""
|
|
387
|
+
names: set[str] = set()
|
|
388
|
+
|
|
389
|
+
for raw in _planned_files(plan)[:MAX_FILES]:
|
|
390
|
+
path = inside(raw, root)
|
|
391
|
+
if path is None or Path(path).suffix.lower() not in IMPORT_SUFFIXES:
|
|
392
|
+
continue
|
|
393
|
+
text = file_text(root / path)
|
|
394
|
+
if not text:
|
|
395
|
+
continue
|
|
396
|
+
# `extract` swallows a file that will not parse, which is the answer
|
|
397
|
+
# detection wants: a Plugin that could be switched off by a syntax error
|
|
398
|
+
# in one file would be worse than a Plugin nobody wrote.
|
|
399
|
+
extraction = extract(path, text)
|
|
400
|
+
# A relative import keeps its dots and names no distributed package, and
|
|
401
|
+
# `pyspark.sql.functions` answers for `pyspark` the same way the bare
|
|
402
|
+
# import does.
|
|
403
|
+
names.update(
|
|
404
|
+
reference.partition(".")[0]
|
|
405
|
+
for reference in extraction.references
|
|
406
|
+
if not reference.startswith(".")
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
return frozenset(name for name in names if name)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _planned_files(plan: Plan) -> list[str]:
|
|
413
|
+
"""Every path the frozen Plan names, in Plan order, without duplicates."""
|
|
414
|
+
seen: dict[str, None] = {}
|
|
415
|
+
for step in plan.steps:
|
|
416
|
+
for path in step.files:
|
|
417
|
+
if path:
|
|
418
|
+
seen.setdefault(str(path), None)
|
|
419
|
+
return list(seen)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _answers(
|
|
423
|
+
plugin: Plugin,
|
|
424
|
+
suffixes: set[str],
|
|
425
|
+
imported: Callable[[], frozenset[str]],
|
|
426
|
+
root: Path,
|
|
427
|
+
) -> bool:
|
|
428
|
+
"""Whether one Plugin answers for this blast radius or this repository.
|
|
429
|
+
|
|
430
|
+
Any of the three detections is sufficient, and they are asked in the order
|
|
431
|
+
they cost in: a suffix is a string comparison, a root marker is a `stat`,
|
|
432
|
+
and an import is the blast radius read — which `imported` defers until a
|
|
433
|
+
Plugin actually declares one.
|
|
434
|
+
"""
|
|
435
|
+
if any(suffix.lower() in suffixes for suffix in plugin.suffixes):
|
|
436
|
+
return True
|
|
437
|
+
if any((root / marker).exists() for marker in plugin.root_markers):
|
|
438
|
+
return True
|
|
439
|
+
declared = getattr(plugin, "imports", ())
|
|
440
|
+
return bool(declared) and any(name in imported() for name in declared)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
__all__ = [
|
|
444
|
+
"IMPORT_SUFFIXES",
|
|
445
|
+
"MAX_FRAGMENTS_PER_ROLE",
|
|
446
|
+
"MAX_FRAGMENT_CHARS",
|
|
447
|
+
"NO_PLUGINS",
|
|
448
|
+
"Activation",
|
|
449
|
+
"activate",
|
|
450
|
+
"commands_for",
|
|
451
|
+
"contributions",
|
|
452
|
+
"extractors_for",
|
|
453
|
+
"fragments_for",
|
|
454
|
+
"gates_for",
|
|
455
|
+
]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Git, through the Command Runner.
|
|
2
|
+
|
|
3
|
+
ADR-0002 makes a git repository with a GitHub remote a hard precondition, and
|
|
4
|
+
user story 11 asks that a repository missing either be refused clearly and
|
|
5
|
+
immediately rather than halfway through a paid Run. Both checks live here, and
|
|
6
|
+
so does the branch-and-commit work that turns an Agent's edits into something
|
|
7
|
+
`gh pr create` can point at.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .process import CommandRunner, MissingBinary, require
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PreconditionFailed(RuntimeError):
|
|
20
|
+
"""The working directory cannot host a Run, and nothing has been spent."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def branch_for_issue(issue: int) -> str:
|
|
24
|
+
"""Branch names derive from the Issue number.
|
|
25
|
+
|
|
26
|
+
The link between a Run, its Issue, and its pull request is then recoverable
|
|
27
|
+
from any one of the three.
|
|
28
|
+
"""
|
|
29
|
+
return f"agentforge/issue-{issue}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Repository:
|
|
34
|
+
"""A git working tree AgentForge is allowed to act on."""
|
|
35
|
+
|
|
36
|
+
runner: CommandRunner
|
|
37
|
+
root: Path
|
|
38
|
+
|
|
39
|
+
def _git(self, *args: str, check: bool = True):
|
|
40
|
+
result = self.runner.run(("git", *args), cwd=self.root)
|
|
41
|
+
if check:
|
|
42
|
+
result.check()
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def current_branch(self) -> str:
|
|
47
|
+
return self._git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def remote_url(self) -> str:
|
|
51
|
+
return self._git("remote", "get-url", "origin", check=False).stdout.strip()
|
|
52
|
+
|
|
53
|
+
def is_dirty(self) -> bool:
|
|
54
|
+
return bool(self._git("status", "--porcelain").stdout.strip())
|
|
55
|
+
|
|
56
|
+
def working_tree(self) -> tuple[tuple[str, str], ...]:
|
|
57
|
+
"""Every entry `git status --porcelain` reports, as (status code, path).
|
|
58
|
+
|
|
59
|
+
The code is kept rather than discarded because `??` is the one
|
|
60
|
+
distinction `commit_declared` turns on: git already knows about
|
|
61
|
+
everything else, and a file git has never seen is the only kind a Role's
|
|
62
|
+
commands can invent.
|
|
63
|
+
|
|
64
|
+
`--untracked-files=all` because the default collapses a wholly new
|
|
65
|
+
directory to `src/newpkg/` and names none of its files. `commit_declared`
|
|
66
|
+
matches paths exactly, so a collapsed entry would drop a new package the
|
|
67
|
+
Plan asked for along with the `__pycache__` beside it.
|
|
68
|
+
"""
|
|
69
|
+
lines = self._git("status", "--porcelain", "--untracked-files=all").stdout.splitlines()
|
|
70
|
+
return tuple(
|
|
71
|
+
(line[:2], line[3:].strip().strip('"')) for line in lines if line.strip()
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def changed_files(self) -> tuple[str, ...]:
|
|
75
|
+
"""Paths touched in the working tree, staged or not."""
|
|
76
|
+
return tuple(path for _, path in self.working_tree())
|
|
77
|
+
|
|
78
|
+
def tracked_files(self) -> tuple[str, ...]:
|
|
79
|
+
"""Every file git knows about, as repository-relative posix paths.
|
|
80
|
+
|
|
81
|
+
Tracked rather than walked: a `.venv`, a `node_modules`, and a build
|
|
82
|
+
directory are all on disk and none of them are this repository's code,
|
|
83
|
+
and git already holds the only list that says which is which. `init`
|
|
84
|
+
reads this to census the languages a repository is written in.
|
|
85
|
+
"""
|
|
86
|
+
listed = self._git("ls-files", check=False)
|
|
87
|
+
if not listed.ok:
|
|
88
|
+
return ()
|
|
89
|
+
return tuple(_normalize(line) for line in listed.stdout.splitlines() if line.strip())
|
|
90
|
+
|
|
91
|
+
def create_branch(self, name: str) -> None:
|
|
92
|
+
"""Switch to `name`, creating it. An Agent never edits on the base branch."""
|
|
93
|
+
existing = self._git("rev-parse", "--verify", "--quiet", name, check=False)
|
|
94
|
+
if existing.ok:
|
|
95
|
+
self._git("checkout", name)
|
|
96
|
+
else:
|
|
97
|
+
self._git("checkout", "-b", name)
|
|
98
|
+
|
|
99
|
+
def commit_declared(self, message: str, declared: Iterable[str]) -> tuple[str, ...]:
|
|
100
|
+
"""Commit the Run's work and nothing its commands left lying around.
|
|
101
|
+
|
|
102
|
+
Returns the paths committed, empty when there was nothing to commit.
|
|
103
|
+
|
|
104
|
+
`declared` is every path the Run said it would touch — the frozen Plan's
|
|
105
|
+
files, and what each Agent reported changing. It gates untracked files
|
|
106
|
+
only. See ADR-0015: a file git already tracks is committed however it
|
|
107
|
+
changed, because refusing an edit to a file the Plan forgot to name
|
|
108
|
+
would drop an Agent's work silently; a file git has never seen is
|
|
109
|
+
committed only when the Run named it, because `--allow-commands` means
|
|
110
|
+
a suite can invent one and `__pycache__` is not the Run's work.
|
|
111
|
+
|
|
112
|
+
Paths match exactly. A declared directory does not admit what is under
|
|
113
|
+
it, which is the whole point: a Plan naming `src/` would otherwise
|
|
114
|
+
re-admit `src/__pycache__/loader.pyc`.
|
|
115
|
+
"""
|
|
116
|
+
allowed = {_normalize(path) for path in declared}
|
|
117
|
+
staging = [
|
|
118
|
+
path for code, path in self.working_tree() if code != "??" or path in allowed
|
|
119
|
+
]
|
|
120
|
+
if not staging:
|
|
121
|
+
return ()
|
|
122
|
+
self._git("add", "--", *staging)
|
|
123
|
+
self._git("commit", "-m", message)
|
|
124
|
+
return tuple(staging)
|
|
125
|
+
|
|
126
|
+
def carries_work_against(self, base: str) -> bool:
|
|
127
|
+
"""Whether this branch holds commits `base` does not.
|
|
128
|
+
|
|
129
|
+
Which is what "something to open a pull request for" means. Asked of git
|
|
130
|
+
rather than of a Role's account of what it changed, because that account
|
|
131
|
+
is exactly what a Run cannot take on trust — and because the work may
|
|
132
|
+
have been committed by an earlier invocation of this Run, or by the human
|
|
133
|
+
whose diff a `review` Workflow was pointed at.
|
|
134
|
+
|
|
135
|
+
A git that cannot answer — an unfetched base, a shallow clone — answers
|
|
136
|
+
yes. Refusing to open a pull request because a ref was missing is the
|
|
137
|
+
worse of the two mistakes.
|
|
138
|
+
"""
|
|
139
|
+
counted = self._git("rev-list", "--count", f"{base}..HEAD", check=False)
|
|
140
|
+
if not counted.ok:
|
|
141
|
+
return True
|
|
142
|
+
return counted.stdout.strip() not in ("", "0")
|
|
143
|
+
|
|
144
|
+
def push(self, branch: str) -> None:
|
|
145
|
+
self._git("push", "--set-upstream", "origin", branch)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _normalize(path: str) -> str:
|
|
149
|
+
r"""A declared path in the spelling `git status` uses.
|
|
150
|
+
|
|
151
|
+
A Plan is written by a model and an Agent Result by another one, so the same
|
|
152
|
+
file arrives as `src/loader.py`, `./src/loader.py`, or — on Windows —
|
|
153
|
+
`src\loader.py`. Git answers in one of those three and a comparison that
|
|
154
|
+
took the other two literally would quietly commit nothing.
|
|
155
|
+
"""
|
|
156
|
+
return path.strip().replace("\\", "/").removeprefix("./").strip("/")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def open_repository(runner: CommandRunner, cwd: Path | str) -> Repository:
|
|
160
|
+
"""Resolve the working directory to a repository, or refuse with a reason."""
|
|
161
|
+
try:
|
|
162
|
+
require(runner, "git", "AgentForge drives git rather than reimplementing it.")
|
|
163
|
+
except MissingBinary as exc:
|
|
164
|
+
raise PreconditionFailed(str(exc)) from exc
|
|
165
|
+
|
|
166
|
+
top = runner.run(("git", "rev-parse", "--show-toplevel"), cwd=cwd)
|
|
167
|
+
if not top.ok:
|
|
168
|
+
raise PreconditionFailed(
|
|
169
|
+
f"{Path(cwd).resolve()} is not inside a git repository. "
|
|
170
|
+
"ADR-0002 makes a repository with a GitHub remote a precondition for every Run."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
repo = Repository(runner=runner, root=Path(top.stdout.strip() or str(cwd)))
|
|
174
|
+
remote = repo.remote_url
|
|
175
|
+
if not remote:
|
|
176
|
+
raise PreconditionFailed(
|
|
177
|
+
f"{repo.root} has no `origin` remote. AgentForge hands off through GitHub "
|
|
178
|
+
"issues (ADR-0002), so a remote is required before a Run can start."
|
|
179
|
+
)
|
|
180
|
+
if "github" not in remote.lower():
|
|
181
|
+
raise PreconditionFailed(
|
|
182
|
+
f"`origin` points at {remote}, which is not GitHub. ADR-0002 supports GitHub only; "
|
|
183
|
+
"no other tracker is implemented."
|
|
184
|
+
)
|
|
185
|
+
return repo
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Task routing definitions."""
|