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,45 @@
1
+ """The Python Plugin: what a Python change is held to here.
2
+
3
+ Deliberately narrow. This ships one Fragment, aimed at the Roles that write and
4
+ check code, and says only things that change what an Agent produces. A
5
+ convention an Agent would have followed anyway is tokens spent on agreement.
6
+
7
+ No root markers: this Plugin answers for the blast radius alone. A repository
8
+ with a `pyproject.toml` and a Plan that touches only SQL is not doing Python
9
+ work, and holding that Run to Python conventions would be the first way this
10
+ mechanism starts costing more than it returns.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ...core.contracts import Fragment, Plugin
16
+
17
+ #: Aimed at the three Roles that produce or judge code. The Security Role is
18
+ #: absent on purpose: it audits against production standards, and a style
19
+ #: convention in its prompt competes with that rather than supporting it.
20
+ _CONVENTIONS = """\
21
+ Follow these Python conventions unless the file you are editing plainly does otherwise:
22
+
23
+ - Match the module you are editing. Its import grouping, quote style, and naming
24
+ are the convention here, and a file that reads as two styles costs every
25
+ future reader more than either style saves.
26
+ - Type-annotate new public functions and dataclass fields. Leave existing
27
+ unannotated signatures alone unless the Plan names them.
28
+ - Raise a specific exception with a message naming what was wrong and what was
29
+ expected. A bare `raise Exception` or a swallowed `except: pass` is a defect,
30
+ not a shortcut.
31
+ - Prefer a standard-library answer to a new dependency. Adding one is a decision
32
+ the Plan has to have made.
33
+ - Tests assert on behaviour through the public surface, not on private helpers.
34
+ A test that breaks on a rename was testing the rename.\
35
+ """
36
+
37
+ PYTHON = Plugin(
38
+ name="python",
39
+ suffixes=(".py", ".pyi"),
40
+ fragments=(
41
+ Fragment(text=_CONVENTIONS, roles=("implementer", "tester", "reviewer")),
42
+ ),
43
+ )
44
+
45
+ __all__ = ["PYTHON"]
@@ -0,0 +1,377 @@
1
+ """The SQL Plugin: what a `.sql` file means when dbt is what builds it.
2
+
3
+ Deliberately two Extractors, one Gate kind, and no Fragment. The conventions a
4
+ SQL change is held to are a `sql` Fragment's job and nobody has written one
5
+ worth a Role's tokens yet; what this Plugin has that nothing else does is
6
+ knowledge of what a dbt model *depends on* and of what it means for a project to
7
+ still parse — one belongs in the pack and the other in a Gate, and neither
8
+ belongs in a prompt.
9
+
10
+ The built-in SQL extractor reads tables out of a statement. In a dbt project
11
+ that reading is not wrong so much as beside the point: a model says
12
+ `{{ ref('stg_orders') }}` and compiles to a table name this repository does not
13
+ contain, so a generic read finds either nothing or the wrong thing. What breaks
14
+ when the model changes is the models that `ref()` it, and that is what a Role
15
+ needs to be told.
16
+
17
+ The `dbt` Gate is the worked example of a validator. A Workflow in a dbt project
18
+ writes `gate: dbt` after the Step that edits models, and the Run holds there
19
+ until the project parses — the same YAML a Workflow writes for `tests`, and a
20
+ Workflow in a repository this Plugin does not answer for is refused at load
21
+ time, because nothing there would evaluate it.
22
+
23
+ Detection and reading are separate, which is why `suffixes` here is `.sql`
24
+ alone. A repository is a dbt project because of `dbt_project.yml`, and a Plan
25
+ touching a `.sql` file is doing SQL work wherever it is done. Neither of those
26
+ is a reason to activate on every `.yml` in every repository — but once this
27
+ Plugin *is* active, the schema files beside the models are worth reading as
28
+ what they are, so the YAML Extractor claims those suffixes and hands back
29
+ anything that is not dbt-shaped to the reader that already handles it.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+
36
+ import yaml as pyyaml
37
+
38
+ from ...context.extractors import sql as builtin_sql
39
+ from ...context.extractors import yaml as builtin_yaml
40
+ from ...context.extractors.base import Extraction, ordered
41
+ from ...core.contracts import (
42
+ Command,
43
+ Extractor,
44
+ FileTemplate,
45
+ GateEntry,
46
+ GateVerdict,
47
+ Plugin,
48
+ Validator,
49
+ )
50
+ from ...core.gates import GateContext, command_tail
51
+ from ...core.process import MissingBinary
52
+
53
+ #: `ref('model')`, `ref("model")`, and the two-argument `ref('package', 'model')`
54
+ #: that a model in an installed package is reached by. The last argument is the
55
+ #: model either way, which is why the first is captured and discarded.
56
+ _REF = re.compile(
57
+ r"""\bref\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*['"]([^'"]+)['"]\s*)?\)""",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+ #: `source('name', 'table')`. Both halves matter and both are carried: the
62
+ #: source name alone would not say which table, and the table alone would not
63
+ #: say which source declared it.
64
+ _SOURCE = re.compile(
65
+ r"""\bsource\s*\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]\s*\)""",
66
+ re.IGNORECASE,
67
+ )
68
+
69
+ #: How many entries of a list in a schema file are read. The same bound the
70
+ #: built-in YAML extractor applies, and for the same reason: a `models:` list of
71
+ #: two hundred has the shape of a list of three.
72
+ MAX_ITEMS = builtin_yaml.MAX_ITEMS
73
+
74
+
75
+ def extract_model(text: str) -> Extraction:
76
+ """A dbt model's dependencies as references, on top of the generic read.
77
+
78
+ Composed rather than replacing: a model is still SQL, and the columns the
79
+ built-in extractor finds are still where a change lands. What this adds is
80
+ the edges — `ref()` and `source()` targets, carried in front of the compiled
81
+ table names, because they are the names the repository actually contains and
82
+ so the ones a Role can go and read.
83
+
84
+ A file with no `ref()` and no `source()` in it extracts exactly what the
85
+ built-in extractor extracts. That is the ordinary case in a repository that
86
+ has one `.sql` file and no dbt, and it costs that repository nothing.
87
+ """
88
+ dependencies: list[str] = []
89
+
90
+ for package, model in _REF.findall(text):
91
+ # Two-argument `ref` captures ('package', 'model'); one-argument
92
+ # captures ('model', ''). The model is the last non-empty group.
93
+ dependencies.append(f"ref:{model or package}")
94
+
95
+ for source, table in _SOURCE.findall(text):
96
+ dependencies.append(f"source:{source}.{table}")
97
+
98
+ generic = builtin_sql.extract(text)
99
+ return Extraction(
100
+ symbols=generic.symbols,
101
+ references=ordered([*dependencies, *generic.references]),
102
+ )
103
+
104
+
105
+ def extract_schema(text: str) -> Extraction:
106
+ """A dbt schema file read as dbt, or as ordinary YAML when it is not one.
107
+
108
+ A `schema.yml` and a CI config are both YAML and are not both worth the same
109
+ reading. Read generically, a model's name, one of its column's names, and
110
+ the name of a test on that column are three keys at three depths and nothing
111
+ distinguishes them. Read as dbt they are three different kinds of thing:
112
+
113
+ - a model or a source table is a **symbol**, because it is the thing a Plan
114
+ sends somebody to change
115
+ - a column is a **symbol**, qualified by the model it belongs to, so that
116
+ two models with an `id` are two symbols rather than one
117
+ - a **test** is neither. It is carried as a reference, because a test is what
118
+ breaks when the column changes, which is the question `references` answers
119
+
120
+ Anything without a dbt shape falls through to the built-in YAML extractor.
121
+ That fall-through is what makes claiming `.yml` safe: this Plugin activates
122
+ on a `dbt_project.yml` at the root, and a repository that has one still has
123
+ ordinary YAML in it that nobody wants read as dbt.
124
+ """
125
+ document = pyyaml.safe_load(text)
126
+ if not _is_dbt_schema(document):
127
+ return builtin_yaml.extract(text)
128
+
129
+ symbols: list[str] = []
130
+ references: list[str] = []
131
+
132
+ for key in ("models", "seeds", "snapshots"):
133
+ for node in _entries(document.get(key)):
134
+ name = _name(node)
135
+ if not name:
136
+ continue
137
+ symbols.append(name)
138
+ _read_columns(node, name, symbols, references)
139
+
140
+ for source in _entries(document.get("sources")):
141
+ source_name = _name(source)
142
+ if not source_name:
143
+ continue
144
+ for table in _entries(source.get("tables")):
145
+ table_name = _name(table)
146
+ if not table_name:
147
+ continue
148
+ # A source table is a symbol the same way a model is — it is a named
149
+ # thing in this file — and its `source:` reference is what a model
150
+ # reaching for it will have written.
151
+ qualified = f"{source_name}.{table_name}"
152
+ symbols.append(qualified)
153
+ references.append(f"source:{qualified}")
154
+ _read_columns(table, qualified, symbols, references)
155
+
156
+ return Extraction(symbols=ordered(symbols), references=ordered(references))
157
+
158
+
159
+ def _is_dbt_schema(document) -> bool:
160
+ """Whether this document is a dbt schema file rather than any other YAML.
161
+
162
+ A mapping carrying at least one of dbt's node lists. `version: 2` is not the
163
+ test: plenty of YAML declares a version, and a schema file that omits it is
164
+ still a schema file.
165
+ """
166
+ return isinstance(document, dict) and any(
167
+ isinstance(document.get(key), list)
168
+ for key in ("models", "sources", "seeds", "snapshots")
169
+ )
170
+
171
+
172
+ def _read_columns(node, owner: str, symbols: list[str], references: list[str]) -> None:
173
+ """A node's columns as `owner.column`, and its tests as references."""
174
+ for test in _tests(node):
175
+ references.append(f"test:{test} on {owner}")
176
+
177
+ for column in _entries(node.get("columns")):
178
+ name = _name(column)
179
+ if not name:
180
+ continue
181
+ symbols.append(f"{owner}.{name}")
182
+ for test in _tests(column):
183
+ references.append(f"test:{test} on {owner}.{name}")
184
+
185
+
186
+ def _tests(node) -> list[str]:
187
+ """The tests declared on one node, under either spelling.
188
+
189
+ dbt renamed `tests:` to `data_tests:` and reads both, so this reads both. A
190
+ test is a mapping when it takes arguments (`relationships:` with a `to:`)
191
+ and a string when it does not, and the name is what matters either way.
192
+ """
193
+ named: list[str] = []
194
+ for key in ("tests", "data_tests"):
195
+ for test in _entries(node.get(key)) if isinstance(node, dict) else ():
196
+ if isinstance(test, str):
197
+ named.append(test)
198
+ elif isinstance(test, dict) and test:
199
+ named.append(str(next(iter(test))))
200
+ return named
201
+
202
+
203
+ def _entries(value) -> list:
204
+ """The first `MAX_ITEMS` of a list, or nothing at all if it is not one."""
205
+ return value[:MAX_ITEMS] if isinstance(value, list) else []
206
+
207
+
208
+ def _name(node) -> str:
209
+ """A node's `name:`, or empty where it has none."""
210
+ if not isinstance(node, dict):
211
+ return ""
212
+ name = node.get("name")
213
+ return str(name).strip() if name is not None else ""
214
+
215
+
216
+ #: What the `dbt` Gate runs. `parse` rather than `build` or `test`: parsing
217
+ #: resolves every `ref()` and `source()` and compiles every model without
218
+ #: touching a warehouse, so the Gate holds a Run on a project that no longer
219
+ #: hangs together without needing a connection, a profile, or data.
220
+ DBT_PARSE = ("dbt", "parse")
221
+
222
+ #: The status dbt spends on "it ran and the project has a problem", which is a
223
+ #: report on the repository. It spends 2 on a usage error — no project here, a
224
+ #: flag it does not know — and that is a report on the invocation instead.
225
+ DBT_FAILED = 1
226
+
227
+
228
+ def parses(context: GateContext) -> GateEntry:
229
+ """The dbt Gate: parse the project, and read the exit status.
230
+
231
+ The same three answers the test-suite Gate gives, for the same reasons. It
232
+ parsed. It ran and found the project broken — a model referencing one that
233
+ was renamed, a macro that no longer resolves — which the next commit can
234
+ fix, so the Run suspends rather than halting. Or it never reached a verdict,
235
+ and a Gate with nothing to clear halts the Run rather than inviting a resume
236
+ that suspends again.
237
+
238
+ It names nobody in `invalidates`. This verdict comes from re-running dbt
239
+ against the working tree rather than from reading what a Role said about it,
240
+ so no Step's output has been judged and every Step behind this Gate stays
241
+ behind it (ADR-0008).
242
+
243
+ A Plugin's Gate degrades a Run and never ends it: dbt missing from the
244
+ machine, or refusing to start, is an errored verdict rather than an
245
+ exception, which is what the runtime has a way of reporting.
246
+ """
247
+ if not context.runner.has_binary(DBT_PARSE[0]):
248
+ return _cannot_parse(f"{DBT_PARSE[0]!r} is not installed or not on PATH")
249
+
250
+ try:
251
+ result = context.runner.run(DBT_PARSE, cwd=context.root)
252
+ except MissingBinary as exc:
253
+ return _cannot_parse(str(exc))
254
+
255
+ if result.ok:
256
+ return GateEntry(
257
+ kind="",
258
+ verdict=GateVerdict.CLEARED,
259
+ summary="`dbt parse` resolved the project.",
260
+ )
261
+
262
+ if result.returncode == DBT_FAILED:
263
+ return GateEntry(
264
+ kind="",
265
+ verdict=GateVerdict.BLOCKED,
266
+ summary=(
267
+ "`dbt parse` failed, so the project does not resolve. The Run stops "
268
+ f"here rather than carrying that to Sign-off.\n\n{command_tail(result)}"
269
+ ),
270
+ )
271
+
272
+ return GateEntry(
273
+ kind="",
274
+ verdict=GateVerdict.ERRORED,
275
+ summary=(
276
+ f"`dbt parse` exited {result.returncode}, which is not a report on the "
277
+ "project: it did not run to a verdict, so there is nothing here for a "
278
+ f"later Run to clear.\n\n{command_tail(result)}"
279
+ ),
280
+ )
281
+
282
+
283
+ def _cannot_parse(reason: str) -> GateEntry:
284
+ """dbt never started, which says nothing about the project.
285
+
286
+ Errored rather than blocked, for the reason the test-suite Gate errors:
287
+ waiting clears nothing, and what has to change is the machine rather than
288
+ the repository.
289
+ """
290
+ return GateEntry(
291
+ kind="",
292
+ verdict=GateVerdict.ERRORED,
293
+ summary=(
294
+ f"the dbt Gate cannot run `{' '.join(DBT_PARSE)}`: {reason}. Install dbt "
295
+ "where the Run executes, or drop the `dbt` Gate from this Workflow."
296
+ ),
297
+ )
298
+
299
+
300
+ #: The model a scaffold writes. Deliberately a shape rather than a guess: a
301
+ #: staging CTE and a final select is what a reviewer expects to read, and every
302
+ #: decision that needs a person — what it selects from, what it filters, what it
303
+ #: is materialized as — is left where they will see it rather than filled in
304
+ #: with something plausible. `$$name` in a template is a literal dollar; `$name`
305
+ #: is the argument.
306
+ _MODEL_SQL = """with source as (
307
+
308
+ select * from {{ ref('stg_$name') }}
309
+
310
+ ),
311
+
312
+ renamed as (
313
+
314
+ select
315
+ -- Name the columns this model exposes. `select *` here is how a
316
+ -- downstream break becomes a surprise.
317
+ *
318
+
319
+ from source
320
+
321
+ )
322
+
323
+ select * from renamed
324
+ """
325
+
326
+ #: The schema entry beside it. A model with no description and no test is the
327
+ #: thing `dbt parse` is happy with and a reviewer is not, so the scaffold writes
328
+ #: the places both belong and fills in neither.
329
+ _MODEL_YML = """version: 2
330
+
331
+ models:
332
+ - name: $name
333
+ description: ""
334
+ columns:
335
+ - name: id
336
+ description: ""
337
+ data_tests:
338
+ - unique
339
+ - not_null
340
+ """
341
+
342
+ #: The one chore this Plugin knows: two files, in the places dbt looks for them,
343
+ #: with no inference anywhere. Writing them by asking a Role at `standard` tier
344
+ #: is the most expensive way to produce a file whose shape was never in question.
345
+ SCAFFOLD_MODEL = Command(
346
+ name="scaffold-dbt-model",
347
+ summary="Write a dbt model and the schema entry beside it.",
348
+ arguments=("name",),
349
+ templates=(
350
+ FileTemplate(path="models/$name.sql", text=_MODEL_SQL),
351
+ FileTemplate(path="models/$name.yml", text=_MODEL_YML),
352
+ ),
353
+ )
354
+
355
+
356
+ SQL = Plugin(
357
+ name="sql",
358
+ suffixes=(".sql",),
359
+ root_markers=("dbt_project.yml", "dbt_project.yaml"),
360
+ extractors=(
361
+ Extractor(suffixes=(".sql",), read=extract_model),
362
+ Extractor(suffixes=(".yml", ".yaml"), read=extract_schema),
363
+ ),
364
+ validators=(Validator(kind="dbt", check=parses),),
365
+ commands=(SCAFFOLD_MODEL,),
366
+ )
367
+
368
+ __all__ = [
369
+ "DBT_FAILED",
370
+ "DBT_PARSE",
371
+ "MAX_ITEMS",
372
+ "SCAFFOLD_MODEL",
373
+ "SQL",
374
+ "extract_model",
375
+ "extract_schema",
376
+ "parses",
377
+ ]
@@ -0,0 +1,48 @@
1
+ """Provider interfaces and integrations.
2
+
3
+ Selecting a Provider is the only place a user names a coding-agent CLI.
4
+ Everything downstream of `get_provider` speaks in Model Tiers (ADR-0004).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..core.config import Config
10
+ from ..core.process import CommandRunner
11
+ from .base import CliProvider, Provider, ProviderError, ProviderOutput
12
+ from .claude import ClaudeProvider
13
+ from .codex import CodexProvider
14
+
15
+ PROVIDERS: dict[str, type[CliProvider]] = {
16
+ ClaudeProvider.name: ClaudeProvider,
17
+ CodexProvider.name: CodexProvider,
18
+ }
19
+
20
+ DEFAULT_PROVIDER = ClaudeProvider.name
21
+
22
+
23
+ def get_provider(
24
+ name: str,
25
+ runner: CommandRunner,
26
+ allow_commands: bool = False,
27
+ config: Config | None = None,
28
+ ) -> Provider:
29
+ """Build an adapter. `allow_commands` is ADR-0007's gate, closed by default."""
30
+ try:
31
+ provider = PROVIDERS[name]
32
+ except KeyError as exc:
33
+ known = ", ".join(sorted(PROVIDERS))
34
+ raise ProviderError(f"unknown provider {name!r}; available: {known}") from exc
35
+ return provider(runner, allow_commands=allow_commands, config=config)
36
+
37
+
38
+ __all__ = [
39
+ "DEFAULT_PROVIDER",
40
+ "PROVIDERS",
41
+ "ClaudeProvider",
42
+ "CliProvider",
43
+ "CodexProvider",
44
+ "Provider",
45
+ "ProviderError",
46
+ "ProviderOutput",
47
+ "get_provider",
48
+ ]