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,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."""