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,272 @@
|
|
|
1
|
+
"""The Issue body: prose for the human, one JSON block for the Agent.
|
|
2
|
+
|
|
3
|
+
ADR-0002 puts the handoff contract in a GitHub issue and ADR-0003 freezes it
|
|
4
|
+
once written. Those two together mean the body has to satisfy two readers at
|
|
5
|
+
once. A human judges the plan before any code is written, so the body is
|
|
6
|
+
Markdown. `agentforge implement` recovers the Plan a week later on a different
|
|
7
|
+
machine, so the body also carries a delimited JSON block and the parser reads
|
|
8
|
+
that and only that. No heuristics, no scraping of the prose.
|
|
9
|
+
|
|
10
|
+
The prose is generated from the same `PlanDocument` as the block, so the two
|
|
11
|
+
cannot disagree at filing time. If a human edits one half afterwards, the block
|
|
12
|
+
is the authority — it is what every Role parses.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
from .contracts import (
|
|
20
|
+
PLAN_FORMAT_VERSION,
|
|
21
|
+
ContextPack,
|
|
22
|
+
Plan,
|
|
23
|
+
PlanDocument,
|
|
24
|
+
Roster,
|
|
25
|
+
Task,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
PLAN_OPEN = "<!-- agentforge:plan -->"
|
|
29
|
+
PLAN_CLOSE = "<!-- /agentforge:plan -->"
|
|
30
|
+
|
|
31
|
+
RESULT_OPEN = "<!-- agentforge:result -->"
|
|
32
|
+
RESULT_CLOSE = "<!-- /agentforge:result -->"
|
|
33
|
+
|
|
34
|
+
#: A Gate's verdict travels in a marker of its own rather than in a result
|
|
35
|
+
#: block, because a Gate is not an Agent and `parse_run_log` must keep returning
|
|
36
|
+
#: Agent Results only. See ADR-0008.
|
|
37
|
+
GATE_OPEN = "<!-- agentforge:gate -->"
|
|
38
|
+
GATE_CLOSE = "<!-- /agentforge:gate -->"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PlanFormatError(ValueError):
|
|
42
|
+
"""An Issue body does not carry a plan AgentForge can execute."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_issue_title(task: Task) -> str:
|
|
46
|
+
"""A title a human recognizes in a list of thirty issues."""
|
|
47
|
+
statement = " ".join(task.statement.split())
|
|
48
|
+
if len(statement) <= 72:
|
|
49
|
+
return statement
|
|
50
|
+
return statement[:69].rstrip(" ,.;:-") + "..."
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_issue_body(task: Task, document: PlanDocument) -> str:
|
|
54
|
+
"""The full Issue body: readable plan, then the frozen block."""
|
|
55
|
+
parts = [
|
|
56
|
+
"## Task",
|
|
57
|
+
"",
|
|
58
|
+
f"> {task.statement.strip()}",
|
|
59
|
+
"",
|
|
60
|
+
"## Plan",
|
|
61
|
+
"",
|
|
62
|
+
document.plan.summary.strip(),
|
|
63
|
+
"",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
if document.plan.steps:
|
|
67
|
+
parts += ["### Steps", ""]
|
|
68
|
+
for index, step in enumerate(document.plan.steps, start=1):
|
|
69
|
+
parts.append(f"{index}. **{step.id}** — {step.intent}")
|
|
70
|
+
if step.files:
|
|
71
|
+
parts.append(f" - Files: {', '.join(f'`{f}`' for f in step.files)}")
|
|
72
|
+
if step.acceptance:
|
|
73
|
+
parts.append(f" - Done when: {step.acceptance}")
|
|
74
|
+
parts.append("")
|
|
75
|
+
|
|
76
|
+
if document.plan.constraints:
|
|
77
|
+
parts += ["### Constraints", ""]
|
|
78
|
+
parts += [f"- {constraint}" for constraint in document.plan.constraints]
|
|
79
|
+
parts.append("")
|
|
80
|
+
|
|
81
|
+
parts += [
|
|
82
|
+
"## Roster",
|
|
83
|
+
"",
|
|
84
|
+
(
|
|
85
|
+
f"Running the `{document.workflow}` Workflow, in this order. "
|
|
86
|
+
"A Gate between two Steps holds the Run until it clears."
|
|
87
|
+
),
|
|
88
|
+
"",
|
|
89
|
+
"| Order | Role | Model Tier |",
|
|
90
|
+
"| --- | --- | --- |",
|
|
91
|
+
]
|
|
92
|
+
for index, role in enumerate(document.roster, start=1):
|
|
93
|
+
parts.append(f"| {index} | {role.name} | `{role.tier}` |")
|
|
94
|
+
parts.append("")
|
|
95
|
+
|
|
96
|
+
if document.context:
|
|
97
|
+
parts += ["## Context Pack", ""]
|
|
98
|
+
for label, values in (
|
|
99
|
+
("Files", document.context.files),
|
|
100
|
+
("Symbols", document.context.symbols),
|
|
101
|
+
("References", document.context.references),
|
|
102
|
+
("Conventions", document.context.conventions),
|
|
103
|
+
):
|
|
104
|
+
if values:
|
|
105
|
+
parts.append(f"- {label}: " + ", ".join(values))
|
|
106
|
+
parts.append("")
|
|
107
|
+
|
|
108
|
+
if document.notes:
|
|
109
|
+
parts += ["## Notes", ""]
|
|
110
|
+
parts += [f"- {note}" for note in document.notes]
|
|
111
|
+
parts.append("")
|
|
112
|
+
|
|
113
|
+
parts += [
|
|
114
|
+
"---",
|
|
115
|
+
"",
|
|
116
|
+
PLAN_OPEN,
|
|
117
|
+
"```json",
|
|
118
|
+
json.dumps(document.to_dict(), indent=2, sort_keys=True),
|
|
119
|
+
"```",
|
|
120
|
+
PLAN_CLOSE,
|
|
121
|
+
"",
|
|
122
|
+
(
|
|
123
|
+
"*Filed by AgentForge. The block above is the frozen execution contract "
|
|
124
|
+
"(ADR-0003). Every Role parses it; edit it rather than the prose.*"
|
|
125
|
+
),
|
|
126
|
+
]
|
|
127
|
+
return "\n".join(parts) + "\n"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def extract_plan_payload(text: str) -> dict:
|
|
131
|
+
"""The raw plan block, before Roles are resolved.
|
|
132
|
+
|
|
133
|
+
The Orchestrator needs this: the model it just ran may have named a Role
|
|
134
|
+
that does not exist yet, and dropping those is Roster selection's job rather
|
|
135
|
+
than the parser's.
|
|
136
|
+
"""
|
|
137
|
+
payload = _extract_block(text, PLAN_OPEN, PLAN_CLOSE)
|
|
138
|
+
if payload is None:
|
|
139
|
+
raise PlanFormatError(
|
|
140
|
+
"no AgentForge plan block found; "
|
|
141
|
+
"it was not written by `agentforge plan`, or the block was deleted"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
data = json.loads(payload)
|
|
146
|
+
except json.JSONDecodeError as exc:
|
|
147
|
+
raise PlanFormatError(f"plan block is not valid JSON: {exc}") from exc
|
|
148
|
+
|
|
149
|
+
if not isinstance(data, dict) or "plan" not in data:
|
|
150
|
+
raise PlanFormatError("plan block carries no `plan`")
|
|
151
|
+
return data
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def parse_issue_body(body: str, resolve=None) -> PlanDocument:
|
|
155
|
+
"""Recover the frozen `PlanDocument` from an Issue body."""
|
|
156
|
+
data = extract_plan_payload(body)
|
|
157
|
+
|
|
158
|
+
version = int(data.get("version", PLAN_FORMAT_VERSION))
|
|
159
|
+
if version > PLAN_FORMAT_VERSION:
|
|
160
|
+
raise PlanFormatError(
|
|
161
|
+
f"issue carries plan format v{version}; this AgentForge understands "
|
|
162
|
+
f"v{PLAN_FORMAT_VERSION}. Upgrade AgentForge."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if resolve is None:
|
|
166
|
+
from ..agents import resolve_role as resolve
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
document = PlanDocument.from_dict(data, resolve)
|
|
170
|
+
except KeyError as exc:
|
|
171
|
+
raise PlanFormatError(f"plan block is missing {exc}") from exc
|
|
172
|
+
|
|
173
|
+
if not document.roster:
|
|
174
|
+
raise PlanFormatError("plan block carries an empty Roster; there is nothing to run")
|
|
175
|
+
return document
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def render_result_block(payload: dict) -> str:
|
|
179
|
+
"""The block a Role is asked to end its output with. Shared by every Provider."""
|
|
180
|
+
return "\n".join(
|
|
181
|
+
[RESULT_OPEN, "```json", json.dumps(payload, indent=2), "```", RESULT_CLOSE]
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def extract_result_block(text: str) -> dict | None:
|
|
186
|
+
"""Pull an Agent's structured verdict out of its free text, if it wrote one."""
|
|
187
|
+
payload = _extract_block(text, RESULT_OPEN, RESULT_CLOSE)
|
|
188
|
+
if payload is None:
|
|
189
|
+
return None
|
|
190
|
+
try:
|
|
191
|
+
data = json.loads(payload)
|
|
192
|
+
except json.JSONDecodeError:
|
|
193
|
+
return None
|
|
194
|
+
return data if isinstance(data, dict) else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def render_gate_block(payload: dict) -> str:
|
|
198
|
+
"""The block a Gate ends its Run Log entry with. Written by AgentForge only.
|
|
199
|
+
|
|
200
|
+
A Role is asked for a result block; nobody is asked for one of these. The
|
|
201
|
+
Gate that evaluated writes it, so the next Run can read back which Gate
|
|
202
|
+
spoke and what it said.
|
|
203
|
+
"""
|
|
204
|
+
return "\n".join(
|
|
205
|
+
[GATE_OPEN, "```json", json.dumps(payload, indent=2), "```", GATE_CLOSE]
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def extract_gate_block(text: str) -> dict | None:
|
|
210
|
+
"""Pull a Gate's verdict out of a Run Log comment, if it carries one."""
|
|
211
|
+
payload = _extract_block(text, GATE_OPEN, GATE_CLOSE)
|
|
212
|
+
if payload is None:
|
|
213
|
+
return None
|
|
214
|
+
try:
|
|
215
|
+
data = json.loads(payload)
|
|
216
|
+
except json.JSONDecodeError:
|
|
217
|
+
return None
|
|
218
|
+
return data if isinstance(data, dict) else None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _extract_block(text: str, open_marker: str, close_marker: str) -> str | None:
|
|
222
|
+
"""The JSON inside a delimited, fenced block. Last one wins.
|
|
223
|
+
|
|
224
|
+
Last rather than first because an Agent that quotes the instructions it was
|
|
225
|
+
given writes the example before it writes its answer.
|
|
226
|
+
|
|
227
|
+
The closing fence is the last one inside the markers rather than the first,
|
|
228
|
+
because JSON escapes no backticks: a Gate quoting a failing suite and an
|
|
229
|
+
Agent quoting the code it changed both put fences inside the payload, and
|
|
230
|
+
stopping at the first one truncates the JSON into something unparseable.
|
|
231
|
+
The close marker is what bounds the search, so a block missing one falls
|
|
232
|
+
back to the first fence rather than reaching into whatever follows it.
|
|
233
|
+
"""
|
|
234
|
+
start = text.rfind(open_marker)
|
|
235
|
+
if start == -1:
|
|
236
|
+
return None
|
|
237
|
+
end = text.find(close_marker, start)
|
|
238
|
+
bounded = end != -1
|
|
239
|
+
inner = text[start + len(open_marker) : end if bounded else len(text)]
|
|
240
|
+
|
|
241
|
+
fence = inner.find("```")
|
|
242
|
+
if fence == -1:
|
|
243
|
+
return inner.strip() or None
|
|
244
|
+
inner = inner[fence + 3 :]
|
|
245
|
+
inner = inner.removeprefix("json")
|
|
246
|
+
closing = inner.rfind("```") if bounded else inner.find("```")
|
|
247
|
+
if closing != -1:
|
|
248
|
+
inner = inner[:closing]
|
|
249
|
+
return inner.strip() or None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = [
|
|
253
|
+
"GATE_CLOSE",
|
|
254
|
+
"GATE_OPEN",
|
|
255
|
+
"PLAN_CLOSE",
|
|
256
|
+
"PLAN_OPEN",
|
|
257
|
+
"RESULT_CLOSE",
|
|
258
|
+
"RESULT_OPEN",
|
|
259
|
+
"ContextPack",
|
|
260
|
+
"Plan",
|
|
261
|
+
"PlanDocument",
|
|
262
|
+
"PlanFormatError",
|
|
263
|
+
"Roster",
|
|
264
|
+
"extract_gate_block",
|
|
265
|
+
"extract_plan_payload",
|
|
266
|
+
"extract_result_block",
|
|
267
|
+
"parse_issue_body",
|
|
268
|
+
"render_gate_block",
|
|
269
|
+
"render_issue_body",
|
|
270
|
+
"render_issue_title",
|
|
271
|
+
"render_result_block",
|
|
272
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The one place AgentForge touches an external process.
|
|
2
|
+
|
|
3
|
+
`gh`, the coding-agent CLIs, `git`, and the vendored unslop scanners all route
|
|
4
|
+
through this port. Nothing else in the codebase imports `subprocess`.
|
|
5
|
+
|
|
6
|
+
The payoff is in the test suite: a fake runner scripted by argument-vector
|
|
7
|
+
prefix stands in for every external tool at once, so the whole framework above
|
|
8
|
+
this line runs as real code with no network, no GitHub account, and no
|
|
9
|
+
coding-agent CLI installed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Protocol
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MissingBinary(RuntimeError):
|
|
23
|
+
"""A required external tool is not on PATH.
|
|
24
|
+
|
|
25
|
+
Raised in preference to a bare `FileNotFoundError` so the message names the
|
|
26
|
+
binary a user has to install rather than the syscall that failed.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, binary: str, hint: str = "") -> None:
|
|
30
|
+
message = f"{binary!r} is not installed or not on PATH"
|
|
31
|
+
if hint:
|
|
32
|
+
message = f"{message}. {hint}"
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.binary = binary
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class CommandResult:
|
|
39
|
+
"""Exit status, standard output, and standard error. Nothing else."""
|
|
40
|
+
|
|
41
|
+
argv: tuple[str, ...]
|
|
42
|
+
returncode: int
|
|
43
|
+
stdout: str = ""
|
|
44
|
+
stderr: str = ""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def ok(self) -> bool:
|
|
48
|
+
return self.returncode == 0
|
|
49
|
+
|
|
50
|
+
def check(self) -> CommandResult:
|
|
51
|
+
"""Return self, or raise with enough context to debug the failure."""
|
|
52
|
+
if self.ok:
|
|
53
|
+
return self
|
|
54
|
+
rendered = " ".join(self.argv)
|
|
55
|
+
detail = (self.stderr or self.stdout or "").strip()
|
|
56
|
+
raise CommandFailed(f"`{rendered}` exited {self.returncode}: {detail[:800]}", result=self)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CommandFailed(RuntimeError):
|
|
60
|
+
"""An external process ran and returned a non-zero status."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str, result: CommandResult) -> None:
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.result = result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CommandRunner(Protocol):
|
|
68
|
+
"""The port. An argument vector and a working directory go in; a result
|
|
69
|
+
comes out."""
|
|
70
|
+
|
|
71
|
+
def run(
|
|
72
|
+
self,
|
|
73
|
+
argv: Sequence[str],
|
|
74
|
+
*,
|
|
75
|
+
cwd: Path | str | None = None,
|
|
76
|
+
stdin: str | None = None,
|
|
77
|
+
timeout: float | None = None,
|
|
78
|
+
) -> CommandResult: ...
|
|
79
|
+
|
|
80
|
+
def has_binary(self, binary: str) -> bool:
|
|
81
|
+
"""Whether the tool is available, checked before a Run spends anything."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SubprocessRunner:
|
|
86
|
+
"""The real implementation. The only `subprocess` call site in AgentForge."""
|
|
87
|
+
|
|
88
|
+
def run(
|
|
89
|
+
self,
|
|
90
|
+
argv: Sequence[str],
|
|
91
|
+
*,
|
|
92
|
+
cwd: Path | str | None = None,
|
|
93
|
+
stdin: str | None = None,
|
|
94
|
+
timeout: float | None = None,
|
|
95
|
+
) -> CommandResult:
|
|
96
|
+
argv = tuple(str(part) for part in argv)
|
|
97
|
+
try:
|
|
98
|
+
completed = subprocess.run(
|
|
99
|
+
argv,
|
|
100
|
+
cwd=str(cwd) if cwd else None,
|
|
101
|
+
input=stdin,
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
encoding="utf-8",
|
|
105
|
+
errors="replace",
|
|
106
|
+
timeout=timeout,
|
|
107
|
+
# A non-zero exit is a result here, not an exception. `gh` uses
|
|
108
|
+
# one to mean "no such issue" and the unslop scanners use one to
|
|
109
|
+
# mean "found something"; callers decide what it means.
|
|
110
|
+
check=False,
|
|
111
|
+
)
|
|
112
|
+
except FileNotFoundError as exc:
|
|
113
|
+
raise MissingBinary(argv[0]) from exc
|
|
114
|
+
except subprocess.TimeoutExpired as exc:
|
|
115
|
+
return CommandResult(
|
|
116
|
+
argv=argv,
|
|
117
|
+
returncode=124,
|
|
118
|
+
stdout=_text(exc.stdout),
|
|
119
|
+
stderr=f"timed out after {timeout}s",
|
|
120
|
+
)
|
|
121
|
+
return CommandResult(
|
|
122
|
+
argv=argv,
|
|
123
|
+
returncode=completed.returncode,
|
|
124
|
+
stdout=completed.stdout or "",
|
|
125
|
+
stderr=completed.stderr or "",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def has_binary(self, binary: str) -> bool:
|
|
129
|
+
return shutil.which(binary) is not None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _text(value: bytes | str | None) -> str:
|
|
133
|
+
if isinstance(value, bytes):
|
|
134
|
+
return value.decode("utf-8", errors="replace")
|
|
135
|
+
return value or ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def require(runner: CommandRunner, binary: str, hint: str = "") -> None:
|
|
139
|
+
"""Fail before a model is invoked rather than halfway through a Run."""
|
|
140
|
+
if not runner.has_binary(binary):
|
|
141
|
+
raise MissingBinary(binary, hint)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""What `agentforge init` learns about a repository, and what it writes down.
|
|
2
|
+
|
|
3
|
+
Two halves, kept apart because they are held to different standards. Detection
|
|
4
|
+
reads the repository and answers three questions — which Plugins answer for it,
|
|
5
|
+
what its suite appears to be, which Provider it will drive — and is allowed to
|
|
6
|
+
be wrong, because everything it produces is printed for a human to correct.
|
|
7
|
+
Rendering turns the answers into `.agentforge/config.yaml`, and is allowed to
|
|
8
|
+
write only what `core.config` reads back.
|
|
9
|
+
|
|
10
|
+
That second rule is the whole shape of this module. `docs/PLAN.md` promised a
|
|
11
|
+
config file owning tier mapping, Provider selection, plugin activation, and Gate
|
|
12
|
+
policy; `load_config` reads two keys. Writing the other three would be writing
|
|
13
|
+
keys nothing consults, which is worse than not writing them: a human who edits a
|
|
14
|
+
key that has no effect has been lied to by the file. So what init detects and
|
|
15
|
+
cannot yet persist it prints, and the gap stays visible. See ADR-0020.
|
|
16
|
+
|
|
17
|
+
Nothing here decides whether the repository may be written to. `open_repository`
|
|
18
|
+
answers that, and the CLI asks it first, so a repository that cannot host a Run
|
|
19
|
+
never gets a config file suggesting it can.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import yaml as pyyaml
|
|
28
|
+
|
|
29
|
+
from .config import DEFAULT_CAPABILITIES, DEFAULT_TEST_SUITE, CapabilityTier
|
|
30
|
+
|
|
31
|
+
CONFIG_DIR = ".agentforge"
|
|
32
|
+
CONFIG_FILE = "config.yaml"
|
|
33
|
+
|
|
34
|
+
#: How many tracked files the language census reads. A repository's languages
|
|
35
|
+
#: are visible in the first couple of thousand files, and the census is a line
|
|
36
|
+
#: of output rather than a decision anything turns on.
|
|
37
|
+
MAX_CENSUS = 2000
|
|
38
|
+
|
|
39
|
+
#: Suffix to the name a human calls it. Deliberately short: this names what
|
|
40
|
+
#: AgentForge might have something to say about, and a census listing `.gitignore`
|
|
41
|
+
#: as a language would be noise dressed as information.
|
|
42
|
+
LANGUAGES = {
|
|
43
|
+
".py": "Python",
|
|
44
|
+
".sql": "SQL",
|
|
45
|
+
".yml": "YAML",
|
|
46
|
+
".yaml": "YAML",
|
|
47
|
+
".ipynb": "Notebook",
|
|
48
|
+
".scala": "Scala",
|
|
49
|
+
".java": "Java",
|
|
50
|
+
".ts": "TypeScript",
|
|
51
|
+
".tsx": "TypeScript",
|
|
52
|
+
".js": "JavaScript",
|
|
53
|
+
".jsx": "JavaScript",
|
|
54
|
+
".go": "Go",
|
|
55
|
+
".rs": "Rust",
|
|
56
|
+
".sh": "Shell",
|
|
57
|
+
".md": "Markdown",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ProjectContext:
|
|
63
|
+
"""What AgentForge learned about one repository at `agentforge init`.
|
|
64
|
+
|
|
65
|
+
`suite_detected` and `plugins` are the two fields that exist because a human
|
|
66
|
+
reads this before a machine does. The first says whether the suite in the
|
|
67
|
+
file was found or assumed, which is the difference between a line to leave
|
|
68
|
+
alone and a line to correct. The second is printed and never written:
|
|
69
|
+
activation is decided per Run from the frozen Plan's blast radius, and a
|
|
70
|
+
`plugins:` key would be a key nothing consults.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
root: Path
|
|
74
|
+
provider: str
|
|
75
|
+
capability_tier: CapabilityTier
|
|
76
|
+
test_suite: tuple[str, ...] = DEFAULT_TEST_SUITE
|
|
77
|
+
suite_detected: str = ""
|
|
78
|
+
languages: tuple[str, ...] = ()
|
|
79
|
+
plugins: tuple[str, ...] = ()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def detect(
|
|
83
|
+
root: Path | str,
|
|
84
|
+
provider: str,
|
|
85
|
+
tracked: tuple[str, ...] = (),
|
|
86
|
+
plugins: tuple[str, ...] = (),
|
|
87
|
+
) -> ProjectContext:
|
|
88
|
+
"""Everything init has to say about this repository.
|
|
89
|
+
|
|
90
|
+
`tracked` is the repository's files as git reports them, and `plugins` the
|
|
91
|
+
names `core.registry` activated for it. Both are passed in rather than read
|
|
92
|
+
here: this module opens no process and imports no registry, so detection is
|
|
93
|
+
a pure function of what it was handed and a test needs no repository.
|
|
94
|
+
"""
|
|
95
|
+
suite, because = _suite(Path(root), tracked)
|
|
96
|
+
return ProjectContext(
|
|
97
|
+
root=Path(root),
|
|
98
|
+
provider=provider,
|
|
99
|
+
capability_tier=DEFAULT_CAPABILITIES.get(provider, CapabilityTier.FRAGMENT),
|
|
100
|
+
test_suite=suite,
|
|
101
|
+
suite_detected=because,
|
|
102
|
+
languages=_languages(tracked),
|
|
103
|
+
plugins=tuple(plugins),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def config_path(root: Path | str) -> Path:
|
|
108
|
+
return Path(root) / CONFIG_DIR / CONFIG_FILE
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_config(context: ProjectContext) -> str:
|
|
112
|
+
"""The file, with a comment on every line a human might want to change.
|
|
113
|
+
|
|
114
|
+
Written by hand rather than dumped, because the comments are the point. A
|
|
115
|
+
reader who cannot tell a detected value from a default has to re-derive both
|
|
116
|
+
before touching either, and the first thing anybody does to a generated
|
|
117
|
+
config is edit it.
|
|
118
|
+
"""
|
|
119
|
+
suite = ", ".join(_quoted(part) for part in context.test_suite)
|
|
120
|
+
because = (
|
|
121
|
+
f"detected: {context.suite_detected}"
|
|
122
|
+
if context.suite_detected
|
|
123
|
+
else "not detected — this is the documented default, so correct it if it is wrong"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return f"""\
|
|
127
|
+
# AgentForge project configuration, written by `agentforge init`.
|
|
128
|
+
#
|
|
129
|
+
# This file holds what AgentForge reads and nothing else. Which Plugins answer
|
|
130
|
+
# for this repository is decided per Run from the frozen Plan's blast radius
|
|
131
|
+
# (ADR-0016), so there is no `plugins:` key here to edit.
|
|
132
|
+
|
|
133
|
+
providers:
|
|
134
|
+
# What this Provider's CLI can be relied on to support, declared rather than
|
|
135
|
+
# probed (ADR-0005). `native` delivers a Role's skills as the CLI's own
|
|
136
|
+
# commands; `fragment` inlines them into the prompt instead.
|
|
137
|
+
{context.provider}:
|
|
138
|
+
capability_tier: {context.capability_tier}
|
|
139
|
+
|
|
140
|
+
gates:
|
|
141
|
+
tests:
|
|
142
|
+
# The argument vector the `tests` Gate runs, in this repository.
|
|
143
|
+
# {because}
|
|
144
|
+
suite: [{suite}]
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def differences(context: ProjectContext, existing: str) -> tuple[str, ...]:
|
|
149
|
+
"""How the config on disk differs from the one init would write.
|
|
150
|
+
|
|
151
|
+
Compared as the values `load_config` would read rather than as text, so a
|
|
152
|
+
file somebody reformatted, commented, or reordered is not reported as a
|
|
153
|
+
difference. The point of the comparison is to tell a human whether their
|
|
154
|
+
edits are still there, and a diff that fired on whitespace would not.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
data = pyyaml.safe_load(existing) or {}
|
|
158
|
+
except pyyaml.YAMLError as exc:
|
|
159
|
+
return (f"the file on disk is not valid YAML: {exc}",)
|
|
160
|
+
|
|
161
|
+
if not isinstance(data, dict):
|
|
162
|
+
return ("the file on disk is not a mapping",)
|
|
163
|
+
|
|
164
|
+
found: list[str] = []
|
|
165
|
+
|
|
166
|
+
providers = data.get("providers") or {}
|
|
167
|
+
tier = (providers.get(context.provider) or {}).get("capability_tier")
|
|
168
|
+
if tier is None:
|
|
169
|
+
found.append(f"it names no capability tier for {context.provider!r}")
|
|
170
|
+
elif str(tier) != str(context.capability_tier):
|
|
171
|
+
found.append(
|
|
172
|
+
f"{context.provider} capability tier: {tier} on disk, "
|
|
173
|
+
f"{context.capability_tier} from detection"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
suite = ((data.get("gates") or {}).get("tests") or {}).get("suite")
|
|
177
|
+
if suite is not None:
|
|
178
|
+
rendered = " ".join(suite) if isinstance(suite, list) else str(suite)
|
|
179
|
+
if rendered.split() != list(context.test_suite):
|
|
180
|
+
found.append(
|
|
181
|
+
f"test suite: `{rendered}` on disk, "
|
|
182
|
+
f"`{' '.join(context.test_suite)}` from detection"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return tuple(found)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _suite(root: Path, tracked: tuple[str, ...]) -> tuple[tuple[str, ...], str]:
|
|
189
|
+
"""The suite this repository appears to run, and the evidence for it.
|
|
190
|
+
|
|
191
|
+
Ordered by how specific the evidence is rather than by popularity: a
|
|
192
|
+
repository declaring a pytest section is telling us directly, and one with a
|
|
193
|
+
`tests/` directory is telling us by convention. A repository that shows
|
|
194
|
+
nothing gets the documented default and is told it was a default — guessing
|
|
195
|
+
silently is how a Gate ends up running the wrong command for a month.
|
|
196
|
+
"""
|
|
197
|
+
files = set(tracked)
|
|
198
|
+
|
|
199
|
+
text = _read(root / "pyproject.toml")
|
|
200
|
+
if "[tool.pytest" in text:
|
|
201
|
+
return ("pytest",), "a `[tool.pytest]` section in pyproject.toml"
|
|
202
|
+
|
|
203
|
+
if "pytest.ini" in files or "conftest.py" in files:
|
|
204
|
+
return ("pytest",), "pytest configuration at the repository root"
|
|
205
|
+
|
|
206
|
+
if any(path.startswith("tests/") for path in files):
|
|
207
|
+
return ("pytest",), "a `tests/` directory"
|
|
208
|
+
|
|
209
|
+
package = _read(root / "package.json")
|
|
210
|
+
if '"test"' in package:
|
|
211
|
+
return ("npm", "test"), "a `test` script in package.json"
|
|
212
|
+
|
|
213
|
+
if "go.mod" in files:
|
|
214
|
+
return ("go", "test", "./..."), "a go.mod at the repository root"
|
|
215
|
+
|
|
216
|
+
if "Cargo.toml" in files:
|
|
217
|
+
return ("cargo", "test"), "a Cargo.toml at the repository root"
|
|
218
|
+
|
|
219
|
+
return DEFAULT_TEST_SUITE, ""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _languages(tracked: tuple[str, ...]) -> tuple[str, ...]:
|
|
223
|
+
"""The languages this repository is written in, commonest first.
|
|
224
|
+
|
|
225
|
+
A census rather than a claim: it counts the suffixes git already knows
|
|
226
|
+
about, so a `.venv` nobody committed does not make this a repository full of
|
|
227
|
+
somebody else's Python.
|
|
228
|
+
"""
|
|
229
|
+
counts: dict[str, int] = {}
|
|
230
|
+
for path in tracked[:MAX_CENSUS]:
|
|
231
|
+
language = LANGUAGES.get(Path(path).suffix.lower())
|
|
232
|
+
if language:
|
|
233
|
+
counts[language] = counts.get(language, 0) + 1
|
|
234
|
+
|
|
235
|
+
# Sorted by count and then by name, so two languages with the same number of
|
|
236
|
+
# files do not swap places between two runs against one repository.
|
|
237
|
+
return tuple(name for name, _ in sorted(counts.items(), key=lambda item: (-item[1], item[0])))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _read(path: Path) -> str:
|
|
241
|
+
try:
|
|
242
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
243
|
+
except OSError:
|
|
244
|
+
return ""
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _quoted(part: str) -> str:
|
|
248
|
+
"""A suite argument as YAML. Quoted, because `-q` unquoted is not a string."""
|
|
249
|
+
return '"' + part.replace('"', '\\"') + '"'
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = [
|
|
253
|
+
"CONFIG_DIR",
|
|
254
|
+
"CONFIG_FILE",
|
|
255
|
+
"LANGUAGES",
|
|
256
|
+
"MAX_CENSUS",
|
|
257
|
+
"ProjectContext",
|
|
258
|
+
"config_path",
|
|
259
|
+
"detect",
|
|
260
|
+
"differences",
|
|
261
|
+
"render_config",
|
|
262
|
+
]
|