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,248 @@
1
+ """The Provider port: one Role, one invocation, one result.
2
+
3
+ ADR-0001 makes every Agent a subprocess call to a coding-agent CLI. `Provider`
4
+ is the whole of that contract — no streaming, no session, no conversation state.
5
+ A Role, a prompt, a Context Pack, a Model Tier, and a working directory go in;
6
+ an `AgentResult` comes out.
7
+
8
+ Two things are deliberately split here:
9
+
10
+ - The **envelope** — how a CLI reports its own success, and where in its output
11
+ the model's text is — belongs to the adapter. A version bump breaks one
12
+ adapter rather than the framework.
13
+ - The **result block** — the delimited JSON a Role is instructed to end with —
14
+ belongs to AgentForge. It travels in the prompt, so every adapter gets it for
15
+ free and none of them has to invent an escalation convention.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from abc import ABC, abstractmethod
21
+ from collections.abc import Sequence
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import ClassVar
25
+
26
+ from ..core.config import CapabilityTier, Config
27
+ from ..core.contracts import (
28
+ AgentResult,
29
+ ContextPack,
30
+ Finding,
31
+ ModelTier,
32
+ Outcome,
33
+ Role,
34
+ Usage,
35
+ )
36
+ from ..core.plan_format import extract_result_block
37
+ from ..core.process import CommandResult, CommandRunner, MissingBinary, require
38
+ from ..core.skills import expand, read_skill
39
+
40
+
41
+ class ProviderError(RuntimeError):
42
+ """A Provider could not be used at all, as distinct from an Agent failing."""
43
+
44
+
45
+ class Provider(ABC):
46
+ """The port. One method, and it is the only thing a Role knows about."""
47
+
48
+ name: ClassVar[str] = "provider"
49
+ binary: ClassVar[str] = ""
50
+
51
+ #: Whether an Agent may run commands, not merely edit files. Default-deny
52
+ #: per ADR-0007: opened for one Run by an explicit flag, and expressed in
53
+ #: the argument vector rather than in prompt text, because a permission
54
+ #: written as an instruction is one the model can talk itself out of.
55
+ allow_commands: bool = False
56
+
57
+ @abstractmethod
58
+ def invoke(
59
+ self,
60
+ *,
61
+ role: Role,
62
+ prompt: str,
63
+ context: ContextPack,
64
+ tier: ModelTier,
65
+ cwd: Path,
66
+ ) -> AgentResult: ...
67
+
68
+ def preflight(self) -> None:
69
+ """Confirm the CLI exists, naming it if not. Called before a Run starts."""
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ProviderOutput:
74
+ """What an adapter recovered from its CLI's envelope.
75
+
76
+ `usage` is what the CLI said the invocation consumed, and it is recovered
77
+ here rather than centrally because the envelope is the adapter's business:
78
+ ADR-0009 keeps a third Provider from adding a third place that knows how the
79
+ second one reports itself. `None` means the CLI reported nothing, which is
80
+ not the same as a free invocation.
81
+ """
82
+
83
+ text: str
84
+ error: str | None = None
85
+ usage: Usage | None = None
86
+
87
+
88
+ class CliProvider(Provider):
89
+ """Shared plumbing for adapters that shell out to a coding-agent CLI.
90
+
91
+ Subclasses supply three small things: the tier-to-model mapping, the
92
+ argument vector, and how to get the model's text out of the CLI's envelope.
93
+ Everything else — turning that text into an `AgentResult` — is the shared
94
+ result-block contract.
95
+
96
+ Extending this is a convenience, not a requirement. An adapter that
97
+ implements `invoke` directly is equally valid, and writing one that way is
98
+ the honest test of whether the port is portable or merely Claude-shaped.
99
+ """
100
+
101
+ #: Tier to model identifier. Nothing outside an adapter knows these strings.
102
+ models: ClassVar[dict[ModelTier, str]] = {}
103
+
104
+ def __init__(
105
+ self,
106
+ runner: CommandRunner,
107
+ timeout: float | None = 1800.0,
108
+ allow_commands: bool = False,
109
+ config: Config | None = None,
110
+ ) -> None:
111
+ self.runner = runner
112
+ self.timeout = timeout
113
+ self.allow_commands = allow_commands
114
+ self.capability_tier = (config or Config()).capability_for(self.name)
115
+
116
+ def preflight(self) -> None:
117
+ try:
118
+ require(
119
+ self.runner,
120
+ self.binary,
121
+ f"AgentForge drives the {self.name} CLI (ADR-0001); install it or "
122
+ f"select another provider with --provider.",
123
+ )
124
+ except MissingBinary as exc:
125
+ raise ProviderError(str(exc)) from exc
126
+
127
+ def model_for(self, tier: ModelTier) -> str:
128
+ try:
129
+ return self.models[tier]
130
+ except KeyError as exc:
131
+ raise ProviderError(
132
+ f"the {self.name} adapter has no model for tier {tier!r}"
133
+ ) from exc
134
+
135
+ @abstractmethod
136
+ def build_argv(
137
+ self, prompt: str, model: str, native_skills: tuple[str, ...] = ()
138
+ ) -> Sequence[str]: ...
139
+
140
+ @abstractmethod
141
+ def parse_output(self, result: CommandResult) -> ProviderOutput: ...
142
+
143
+ def invoke(
144
+ self,
145
+ *,
146
+ role: Role,
147
+ prompt: str,
148
+ context: ContextPack,
149
+ tier: ModelTier,
150
+ cwd: Path,
151
+ ) -> AgentResult:
152
+ prompt, native_skills = self._deliver_skills(role, prompt)
153
+ argv = self.build_argv(prompt, self.model_for(tier), native_skills)
154
+ completed = self.runner.run(argv, cwd=cwd, timeout=self.timeout)
155
+ output = self.parse_output(completed)
156
+ return to_agent_result(role=role, tier=tier, output=output)
157
+
158
+ def _deliver_skills(self, role: Role, prompt: str) -> tuple[str, tuple[str, ...]]:
159
+ """Validate and deliver the Role's skills before the CLI is invoked.
160
+
161
+ A native Provider is named the skills the Role declared and no more: a
162
+ composite fans out to its parts through the CLI's own Skill mechanism,
163
+ which is what the mechanism is for.
164
+
165
+ A Fragment Provider has no such mechanism, so the composite is expanded
166
+ here and every body travels. Without that, a skill whose text says "run
167
+ these two" reaches a Provider that cannot run anything and the Role is
168
+ left with an instruction pointing at nothing.
169
+ """
170
+ if not role.skills:
171
+ return prompt, ()
172
+
173
+ if self.capability_tier is CapabilityTier.NATIVE:
174
+ for name in role.skills:
175
+ read_skill(name) # refuse a name nothing answers for, before the CLI runs
176
+ commands = ", ".join(f"/agentforge:{name}" for name in role.skills)
177
+ instruction = (
178
+ f"Use the declared native AgentForge skills before doing this work: {commands}."
179
+ )
180
+ return f"{instruction}\n\n{prompt}", role.skills
181
+
182
+ fragments = [
183
+ f"## Skill: {name}\n\n{read_skill(name).rstrip()}" for name in expand(role.skills)
184
+ ]
185
+ return f"{prompt}\n\n" + "\n\n".join(fragments) + "\n", ()
186
+
187
+
188
+ def to_agent_result(*, role: Role, tier: ModelTier, output: ProviderOutput) -> AgentResult:
189
+ """Turn a Role's text output into a result the runtime can act on.
190
+
191
+ A Role that reports nothing parseable is a failure, not a quiet success. The
192
+ alternative — treating unstructured text as a completed step — is how a Run
193
+ opens a pull request containing no changes and says it worked.
194
+
195
+ Every way out of here carries the usage the adapter recovered, failures
196
+ included. A Run that spent four dollars discovering its CLI was misconfigured
197
+ spent four dollars, and a total that quietly omitted them would be the one
198
+ figure a reader most wants.
199
+ """
200
+ if output.error:
201
+ return AgentResult(
202
+ role=role.name,
203
+ tier=tier,
204
+ outcome=Outcome.FAILED,
205
+ summary=output.error,
206
+ detail=output.text,
207
+ raw=output.text,
208
+ usage=output.usage,
209
+ )
210
+
211
+ payload = extract_result_block(output.text)
212
+ if payload is None:
213
+ return AgentResult(
214
+ role=role.name,
215
+ tier=tier,
216
+ outcome=Outcome.FAILED,
217
+ summary=(
218
+ f"the {role.name} Agent finished without reporting a result block, "
219
+ "so AgentForge cannot tell what it did"
220
+ ),
221
+ detail=output.text,
222
+ raw=output.text,
223
+ usage=output.usage,
224
+ )
225
+
226
+ try:
227
+ outcome = Outcome(str(payload.get("outcome", "")).strip().lower())
228
+ except ValueError:
229
+ outcome = Outcome.FAILED
230
+
231
+ summary = str(payload.get("summary") or "").strip()
232
+ if outcome is Outcome.FAILED and not summary:
233
+ summary = f"the {role.name} Agent reported an unrecognized outcome"
234
+
235
+ return AgentResult(
236
+ role=role.name,
237
+ tier=tier,
238
+ outcome=outcome,
239
+ summary=summary,
240
+ detail=str(payload.get("detail") or ""),
241
+ files_changed=tuple(payload.get("files_changed") or ()),
242
+ findings=tuple(Finding.coerce(item) for item in payload.get("findings") or ()),
243
+ raw=output.text,
244
+ usage=output.usage,
245
+ )
246
+
247
+
248
+ __all__ = ["CliProvider", "Provider", "ProviderError", "ProviderOutput", "to_agent_result"]
@@ -0,0 +1,159 @@
1
+ """The Claude Code adapter.
2
+
3
+ `claude -p` runs headlessly: it takes a prompt, edits files in the working
4
+ directory, and exits. `--output-format json` wraps the run in an envelope
5
+ carrying an `is_error` flag and the model's final text under `result`, which is
6
+ the only reason this adapter can tell a CLI failure from an Agent failure.
7
+
8
+ Model identifiers appear here and nowhere else in AgentForge (ADR-0004). The
9
+ aliases are used rather than pinned versions so that a vendor release does not
10
+ require an AgentForge release; a team that wants a specific version overrides
11
+ the mapping in configuration once `agentforge init` exists (M5).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections.abc import Sequence
18
+ from typing import ClassVar
19
+
20
+ from ..core.contracts import ModelTier, Usage
21
+ from ..core.process import CommandResult
22
+ from ..core.skills import SKILLS_ROOT
23
+ from .base import CliProvider, ProviderOutput
24
+
25
+
26
+ class ClaudeProvider(CliProvider):
27
+ name: ClassVar[str] = "claude"
28
+ binary: ClassVar[str] = "claude"
29
+
30
+ models: ClassVar[dict[ModelTier, str]] = {
31
+ ModelTier.DEEP: "opus",
32
+ ModelTier.STANDARD: "sonnet",
33
+ ModelTier.CHEAP: "haiku",
34
+ }
35
+
36
+ #: ADR-0007's two postures, mapped onto this CLI's permission modes.
37
+ #: `acceptEdits` lets an Agent write files and nothing else; commands still
38
+ #: need a confirmation that headless mode cannot give, which is the whole
39
+ #: bug in #18. `bypassPermissions` is the open gate. An Agent that has to
40
+ #: ask before every edit cannot run unattended at all, so there is no
41
+ #: third, stricter posture — the blast radius is bounded by the branch the
42
+ #: runtime creates before any Agent is invoked.
43
+ DENIED: ClassVar[str] = "acceptEdits"
44
+ PERMITTED: ClassVar[str] = "bypassPermissions"
45
+
46
+ @property
47
+ def permission_mode(self) -> str:
48
+ return self.PERMITTED if self.allow_commands else self.DENIED
49
+
50
+ def build_argv(
51
+ self, prompt: str, model: str, native_skills: tuple[str, ...] = ()
52
+ ) -> Sequence[str]:
53
+ argv = (
54
+ self.binary,
55
+ "-p",
56
+ prompt,
57
+ "--model",
58
+ model,
59
+ "--output-format",
60
+ "json",
61
+ "--permission-mode",
62
+ self.permission_mode,
63
+ )
64
+ if native_skills:
65
+ argv += ("--plugin-dir", str(SKILLS_ROOT.parent))
66
+ return argv
67
+
68
+ def parse_output(self, result: CommandResult) -> ProviderOutput:
69
+ """Unwrap the JSON envelope.
70
+
71
+ Failure modes, in the order they actually happen: the CLI is missing or
72
+ crashed before printing anything; it printed something that is not JSON;
73
+ it printed a well-formed envelope reporting its own error.
74
+ """
75
+ stdout = result.stdout.strip()
76
+ if not stdout:
77
+ detail = result.stderr.strip() or f"exit status {result.returncode}"
78
+ return ProviderOutput(text="", error=f"the claude CLI produced no output: {detail}")
79
+
80
+ try:
81
+ envelope = json.loads(stdout)
82
+ except json.JSONDecodeError:
83
+ # Older CLIs and `--output-format text` print bare text. The result
84
+ # block still travels in it, so this degrades rather than fails.
85
+ if result.ok:
86
+ return ProviderOutput(text=stdout)
87
+ return ProviderOutput(
88
+ text=stdout,
89
+ error=f"the claude CLI exited {result.returncode} without a JSON envelope",
90
+ )
91
+
92
+ record = _final_record(envelope)
93
+ text = str(record.get("result") or record.get("text") or "")
94
+ usage = _usage(record)
95
+
96
+ if record.get("is_error") or not result.ok:
97
+ reason = text.strip() or result.stderr.strip() or f"exit status {result.returncode}"
98
+ return ProviderOutput(
99
+ text=text,
100
+ error=f"the claude CLI reported an error: {reason}",
101
+ usage=usage,
102
+ )
103
+
104
+ return ProviderOutput(text=text, usage=usage)
105
+
106
+
107
+ #: What the envelope calls the tokens that went in. Cached ones are counted
108
+ #: with the rest: they are cheaper, not free, and the price the CLI charged for
109
+ #: them is already inside `total_cost_usd` — a token figure that omitted them
110
+ #: would disagree with the dollar figure beside it.
111
+ _INPUT_FIELDS = ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")
112
+
113
+
114
+ def _usage(record: dict) -> Usage | None:
115
+ """What this envelope says the invocation consumed.
116
+
117
+ This CLI is the generous one: dollars and a token split, both in the record
118
+ AgentForge was already parsing for the result text. `None` when the envelope
119
+ carries neither, so that a Run Log line can say the CLI reported nothing
120
+ rather than printing a zero nobody measured.
121
+ """
122
+ counts = record.get("usage")
123
+ counts = counts if isinstance(counts, dict) else {}
124
+
125
+ inputs = [_number(counts.get(field), int) for field in _INPUT_FIELDS]
126
+ counted = [value for value in inputs if value is not None]
127
+
128
+ usage = Usage(
129
+ provider=ClaudeProvider.name,
130
+ input_tokens=sum(counted) if counted else None,
131
+ output_tokens=_number(counts.get("output_tokens"), int),
132
+ cost_usd=_number(record.get("total_cost_usd"), float),
133
+ )
134
+ return usage if usage.reported else None
135
+
136
+
137
+ def _number(value: object, cast):
138
+ """One figure out of the envelope, or `None` where it had none to give."""
139
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
140
+ return None
141
+ return cast(value)
142
+
143
+
144
+
145
+ def _final_record(envelope: object) -> dict:
146
+ """The terminal record, whether the CLI emitted one object or a stream."""
147
+ if isinstance(envelope, dict):
148
+ return envelope
149
+ if isinstance(envelope, list):
150
+ for item in reversed(envelope):
151
+ if isinstance(item, dict) and item.get("type") == "result":
152
+ return item
153
+ for item in reversed(envelope):
154
+ if isinstance(item, dict):
155
+ return item
156
+ return {}
157
+
158
+
159
+ __all__ = ["ClaudeProvider"]
@@ -0,0 +1,139 @@
1
+ """A second adapter, to keep ADR-0001's portability claim honest.
2
+
3
+ This exists to prove the Provider port is not merely Claude-shaped. `codex exec`
4
+ differs from `claude -p` in the two places that matter: it prints a transcript
5
+ rather than a JSON envelope, so success has to be read from the exit status, and
6
+ its model identifiers are its own.
7
+
8
+ The posture flags and model slugs below are read off a real install — `codex
9
+ --help` and `~/.codex/models_cache.json` — rather than guessed. The previous
10
+ `gpt-5-codex*` identifiers existed on no account we could find, which is
11
+ ADR-0004's whole point: tier names outlive model names, and a pinned
12
+ identifier goes stale inside a release.
13
+
14
+ This CLI carries reasoning effort separately from model choice, and the
15
+ per-model defaults disagree: `gpt-5.6-sol` starts at `low` while the rest
16
+ start at `medium`. Left alone, `deep` would buy the frontier model and ask it
17
+ to think as little as possible. The adapter pins one value across all three
18
+ tiers instead, so the Model Tier chooses the model and nothing else shifts
19
+ underneath it.
20
+
21
+ Per the note on Issue #1: the useful version of this file is one written by
22
+ somebody who has not read `claude.py`. This one was not, so treat its shape as a
23
+ weaker signal than a genuinely independent adapter would be.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from collections.abc import Sequence
30
+ from typing import ClassVar
31
+
32
+ from ..core.contracts import ModelTier, Usage
33
+ from ..core.process import CommandResult
34
+ from .base import CliProvider, ProviderOutput
35
+
36
+
37
+ class CodexProvider(CliProvider):
38
+ name: ClassVar[str] = "codex"
39
+ binary: ClassVar[str] = "codex"
40
+
41
+ #: Slugs read out of a real install's `~/.codex/models_cache.json` rather
42
+ #: than guessed. The tiers step back a generation at a time: the current
43
+ #: frontier model for `deep`, then the two preceding releases. The
44
+ #: same-generation alternatives (`gpt-5.6-terra`, `gpt-5.6-luna`) are the
45
+ #: obvious other reading of ADR-0004's three tiers; this mapping is the
46
+ #: maintainer's choice, and overriding it is a configuration line.
47
+ models: ClassVar[dict[ModelTier, str]] = {
48
+ ModelTier.DEEP: "gpt-5.6-sol",
49
+ ModelTier.STANDARD: "gpt-5.5",
50
+ ModelTier.CHEAP: "gpt-5.4",
51
+ }
52
+
53
+ #: ADR-0007's two postures, on the two axes `codex --help` documents.
54
+ #:
55
+ #: The sandbox never changes: an Agent writes in the workspace and nowhere
56
+ #: else, in both postures. What the gate moves is the approval policy.
57
+ #: `untrusted` auto-runs only reads (`ls`, `cat`, `sed`) and escalates
58
+ #: anything else, which is this CLI's nearest analogue to the `claude`
59
+ #: adapter's `acceptEdits`. `never` stops asking.
60
+ #:
61
+ #: `danger-full-access` and `--dangerously-bypass-approvals-and-sandbox`
62
+ #: are deliberately unused. ADR-0007 opens a gate; it does not remove the
63
+ #: sandbox, and an unattended Role is the last thing that should be outside
64
+ #: one.
65
+ SANDBOX: ClassVar[str] = "workspace-write"
66
+ DENIED: ClassVar[str] = "untrusted"
67
+ PERMITTED: ClassVar[str] = "never"
68
+
69
+ #: Pinned across every tier, because the per-model defaults disagree —
70
+ #: `gpt-5.6-sol` starts at `low`, the others at `medium`. Setting it here
71
+ #: keeps a Model Tier meaning one thing: it picks the model, and the
72
+ #: reasoning depth stays where the maintainer put it. `low` through `ultra`
73
+ #: are available; raising it is a configuration change under ADR-0004
74
+ #: rather than an edit here.
75
+ REASONING_EFFORT: ClassVar[str] = "medium"
76
+
77
+ def build_argv(
78
+ self, prompt: str, model: str, native_skills: tuple[str, ...] = ()
79
+ ) -> Sequence[str]:
80
+ """Options precede the subcommand: `codex [OPTIONS] <COMMAND> [ARGS]`.
81
+
82
+ This adapter previously passed `--full-auto` after `exec`, which fails
83
+ twice over: that flag does not exist in the current CLI, and options
84
+ placed after the subcommand are rejected regardless.
85
+
86
+ Reasoning effort is pinned rather than derived from the tier, which is
87
+ what lets it be set here at all: a per-tier value would need the Model
88
+ Tier, and the port hands that to `model_for` and not to this method.
89
+ """
90
+ return (
91
+ self.binary,
92
+ "--model",
93
+ model,
94
+ "-c",
95
+ f"model_reasoning_effort={self.REASONING_EFFORT}",
96
+ "--sandbox",
97
+ self.SANDBOX,
98
+ "--ask-for-approval",
99
+ self.PERMITTED if self.allow_commands else self.DENIED,
100
+ "exec",
101
+ prompt,
102
+ )
103
+
104
+ def parse_output(self, result: CommandResult) -> ProviderOutput:
105
+ """No envelope. The transcript is the output and the exit code is the verdict."""
106
+ usage = _usage(result.stdout)
107
+
108
+ if result.ok:
109
+ return ProviderOutput(text=result.stdout, usage=usage)
110
+
111
+ detail = (result.stderr or result.stdout or "").strip()
112
+ return ProviderOutput(
113
+ text=result.stdout,
114
+ error=f"the codex CLI exited {result.returncode}: {detail[:400]}",
115
+ usage=usage,
116
+ )
117
+
118
+
119
+ #: The last line of a transcript, on a run that got that far. One figure and no
120
+ #: split, which is the asymmetry ADR-0009 exists to keep honest: this adapter
121
+ #: reports tokens and never dollars, and the Run Log says so rather than leaving
122
+ #: a blank where a price would have been.
123
+ _TOKENS = re.compile(r"tokens used:\s*([\d,]+)", re.IGNORECASE)
124
+
125
+
126
+ def _usage(transcript: str) -> Usage | None:
127
+ """The token count this CLI prints when it finishes, if it printed one.
128
+
129
+ The last match rather than the first: a transcript that reports per-turn
130
+ counts ends with the one for the whole invocation, and an Agent that quoted
131
+ the phrase in its own output would otherwise be believed over the CLI.
132
+ """
133
+ matches = _TOKENS.findall(transcript or "")
134
+ if not matches:
135
+ return None
136
+ return Usage(provider=CodexProvider.name, total_tokens=int(matches[-1].replace(",", "")))
137
+
138
+
139
+ __all__ = ["CodexProvider"]