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,461 @@
|
|
|
1
|
+
"""Command-line entry point for AgentForge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .core.contracts import ModelTier, Outcome, RunStatus
|
|
11
|
+
|
|
12
|
+
TIER_HELP = (
|
|
13
|
+
"override the Model Tier: `deep`, `standard`, `cheap`, or `role=tier` "
|
|
14
|
+
"to move one Role. Repeatable."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="agentforge",
|
|
21
|
+
description="Coordinate specialized software agents through reusable workflows.",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# `--version` is answered by the parser and exits, exactly as `--help` does;
|
|
25
|
+
# neither ever reaches `main`'s dispatch. The literal lives in `__version__`
|
|
26
|
+
# alone, so a release bumps one line here and one in `pyproject.toml` --
|
|
27
|
+
# tests/test_docs.py fails if those two ever disagree.
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
29
|
+
|
|
30
|
+
subcommands = parser.add_subparsers(dest="command", metavar="<command>")
|
|
31
|
+
|
|
32
|
+
plan = subcommands.add_parser("plan", help="turn a task into a GitHub issue")
|
|
33
|
+
plan.add_argument("task", help="the task, in your own words")
|
|
34
|
+
plan.add_argument("--provider", default=None, help="coding-agent CLI to drive")
|
|
35
|
+
plan.add_argument("--tier", default=None, help="Model Tier for the Orchestrator")
|
|
36
|
+
plan.add_argument("-C", "--directory", default=".", help="repository to plan against")
|
|
37
|
+
|
|
38
|
+
implement = subcommands.add_parser(
|
|
39
|
+
"implement", help="run an issue's roster and open a draft pull request"
|
|
40
|
+
)
|
|
41
|
+
implement.add_argument("issue", type=int, help="issue number")
|
|
42
|
+
implement.add_argument("--provider", default=None, help="coding-agent CLI to drive")
|
|
43
|
+
implement.add_argument("--tier", action="append", default=[], help=TIER_HELP)
|
|
44
|
+
implement.add_argument("-C", "--directory", default=".", help="repository to work in")
|
|
45
|
+
implement.add_argument(
|
|
46
|
+
"--allow-commands",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help=(
|
|
49
|
+
"let Agents run commands, not just edit files (ADR-0007). Off by default, "
|
|
50
|
+
"and granted for this Run only -- never persisted to configuration"
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
implement.add_argument(
|
|
54
|
+
"--no-context-pack",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help=(
|
|
57
|
+
"hand every Role an empty Context Pack, so each reads the repository for "
|
|
58
|
+
"itself. This is the control Run: compare its cost against a packed Run of "
|
|
59
|
+
"the same issue to find out what the pack is worth. Plugin Fragments ride "
|
|
60
|
+
"in the pack, so this drops those too -- use --no-plugins to drop only those"
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
implement.add_argument(
|
|
64
|
+
"--no-plugins",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help=(
|
|
67
|
+
"resolve the Context Pack as usual but activate no Plugins, so no Fragment "
|
|
68
|
+
"reaches a prompt. This is the control for what the Fragments cost, and it "
|
|
69
|
+
"is a separate switch because --no-context-pack removes both at once"
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
run = subcommands.add_parser(
|
|
74
|
+
"run",
|
|
75
|
+
help="run a Plugin's Command: a repeated chore, with no model involved",
|
|
76
|
+
)
|
|
77
|
+
run.add_argument(
|
|
78
|
+
"name",
|
|
79
|
+
nargs="?",
|
|
80
|
+
help="the Command to run. Leave it out to list what this repository has",
|
|
81
|
+
)
|
|
82
|
+
run.add_argument("arguments", nargs="*", help="the Command's positional arguments")
|
|
83
|
+
run.add_argument("-C", "--directory", default=".", help="repository to run in")
|
|
84
|
+
|
|
85
|
+
init = subcommands.add_parser(
|
|
86
|
+
"init",
|
|
87
|
+
help="inspect this repository and write .agentforge/config.yaml",
|
|
88
|
+
)
|
|
89
|
+
init.add_argument("--provider", default=None, help="coding-agent CLI to drive")
|
|
90
|
+
init.add_argument("-C", "--directory", default=".", help="repository to configure")
|
|
91
|
+
init.add_argument(
|
|
92
|
+
"--force",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="replace an existing config. Without it, init reports what differs and writes nothing",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
unslop = subcommands.add_parser("unslop", help="scan prose for machine-writing tells")
|
|
98
|
+
unslop.add_argument("path", help="file to scan")
|
|
99
|
+
unslop.add_argument("--json", action="store_true", help="emit the full report as JSON")
|
|
100
|
+
|
|
101
|
+
return parser
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _run_unslop(args: argparse.Namespace, runner=None) -> int:
|
|
105
|
+
from .core.skills import run_unslop
|
|
106
|
+
|
|
107
|
+
report = run_unslop(args.path, runner=runner)
|
|
108
|
+
|
|
109
|
+
if args.json:
|
|
110
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
111
|
+
else:
|
|
112
|
+
from .core.skills import _describe
|
|
113
|
+
|
|
114
|
+
for result in report.results:
|
|
115
|
+
if result.error:
|
|
116
|
+
print(f" {result.scanner}: could not run — {result.error}")
|
|
117
|
+
continue
|
|
118
|
+
verdict = "clean" if result.clean else f"{result.violations} finding(s)"
|
|
119
|
+
print(f" {result.scanner}: {verdict}")
|
|
120
|
+
for line in _describe(result):
|
|
121
|
+
print(f" {line}")
|
|
122
|
+
summary = "clean" if report.clean else f"{report.violations} finding(s)"
|
|
123
|
+
print(f"{report.path.name}: {summary}")
|
|
124
|
+
|
|
125
|
+
if report.failed:
|
|
126
|
+
return 2
|
|
127
|
+
return 0 if report.clean else 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_tier_overrides(values: list[str]) -> tuple[ModelTier | None, dict[str, ModelTier]]:
|
|
131
|
+
"""`deep` moves every Role; `implementer=deep` moves one."""
|
|
132
|
+
default: ModelTier | None = None
|
|
133
|
+
per_role: dict[str, ModelTier] = {}
|
|
134
|
+
|
|
135
|
+
for value in values or ():
|
|
136
|
+
role, _, tier = value.partition("=")
|
|
137
|
+
if not tier:
|
|
138
|
+
default = _tier(role)
|
|
139
|
+
continue
|
|
140
|
+
per_role[role.strip().lower()] = _tier(tier)
|
|
141
|
+
|
|
142
|
+
return default, per_role
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _tier(value: str) -> ModelTier:
|
|
146
|
+
try:
|
|
147
|
+
return ModelTier(value.strip().lower())
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
known = ", ".join(str(tier) for tier in ModelTier)
|
|
150
|
+
raise SystemExit(f"unknown Model Tier {value.strip()!r}; expected one of: {known}") from exc
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def build_interviewer(stdin=None, prompt=input):
|
|
154
|
+
"""The human, as a callable — or `None` when nothing interactive is attached.
|
|
155
|
+
|
|
156
|
+
The terminal lives here and nowhere else: `core` and `agents` take a
|
|
157
|
+
callable, so the interview is exercised in tests by passing a list of
|
|
158
|
+
answers rather than by faking a console.
|
|
159
|
+
|
|
160
|
+
An empty line ends the interview, and so does end-of-input. A Task that was
|
|
161
|
+
already clear should not cost a conversation, and a human who has said
|
|
162
|
+
everything they have to say should not have to answer three more questions
|
|
163
|
+
to get their Issue.
|
|
164
|
+
"""
|
|
165
|
+
stream = stdin if stdin is not None else sys.stdin
|
|
166
|
+
if not (hasattr(stream, "isatty") and stream.isatty()):
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
asked = False
|
|
170
|
+
|
|
171
|
+
def ask(question: str) -> str | None:
|
|
172
|
+
nonlocal asked
|
|
173
|
+
if not asked:
|
|
174
|
+
print("\nThe Orchestrator has questions before it writes anything down.")
|
|
175
|
+
print("Answer them, or press Enter on an empty line to plan with what it has.")
|
|
176
|
+
asked = True
|
|
177
|
+
print(f"\n {question}")
|
|
178
|
+
try:
|
|
179
|
+
answer = prompt(" > ").strip()
|
|
180
|
+
except EOFError:
|
|
181
|
+
print()
|
|
182
|
+
return None
|
|
183
|
+
return answer or None
|
|
184
|
+
|
|
185
|
+
return ask
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _run_plan(args: argparse.Namespace, runner=None) -> int:
|
|
189
|
+
from .core.runtime import Forge, RunFailed
|
|
190
|
+
from .providers import DEFAULT_PROVIDER
|
|
191
|
+
|
|
192
|
+
forge = Forge(cwd=args.directory, provider=args.provider or DEFAULT_PROVIDER, runner=runner)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
outcome = forge.plan(
|
|
196
|
+
args.task,
|
|
197
|
+
tier=_tier(args.tier) if args.tier else None,
|
|
198
|
+
interviewer=build_interviewer(),
|
|
199
|
+
)
|
|
200
|
+
except RunFailed as exc:
|
|
201
|
+
print(f"agentforge: {exc}", file=sys.stderr)
|
|
202
|
+
return 2
|
|
203
|
+
|
|
204
|
+
result = outcome.result
|
|
205
|
+
if not outcome.filed:
|
|
206
|
+
heading = "needs a decision from you" if result.escalated else "could not plan this task"
|
|
207
|
+
print(f"agentforge: the Orchestrator {heading}.", file=sys.stderr)
|
|
208
|
+
print(f" {result.summary}", file=sys.stderr)
|
|
209
|
+
return 1 if result.escalated else 2
|
|
210
|
+
|
|
211
|
+
issue = outcome.issue
|
|
212
|
+
document = outcome.document
|
|
213
|
+
print(f"\nFiled issue #{issue.number}: {issue.url}")
|
|
214
|
+
print(f" Roster: {', '.join(f'{r.name} ({r.tier})' for r in document.roster)}")
|
|
215
|
+
if outcome.interview:
|
|
216
|
+
print(f" Interview: {len(outcome.interview)} question(s) answered")
|
|
217
|
+
for note in document.notes:
|
|
218
|
+
print(f" Note: {note}")
|
|
219
|
+
|
|
220
|
+
if outcome.touched:
|
|
221
|
+
print("\nThe interview left changes in your working tree:")
|
|
222
|
+
for path in outcome.touched:
|
|
223
|
+
print(f" - {path}")
|
|
224
|
+
print("Review and commit them: a Run refuses to start on a dirty tree.")
|
|
225
|
+
|
|
226
|
+
print(f"\nRun it with: agentforge implement {issue.number}")
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _run_implement(args: argparse.Namespace, runner=None) -> int:
|
|
231
|
+
from .core.issues import IssueError, render_run_cost
|
|
232
|
+
from .core.runtime import Forge, RunFailed
|
|
233
|
+
from .providers import DEFAULT_PROVIDER
|
|
234
|
+
|
|
235
|
+
default_tier, per_role = parse_tier_overrides(args.tier)
|
|
236
|
+
forge = Forge(cwd=args.directory, provider=args.provider or DEFAULT_PROVIDER, runner=runner)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
state = forge.implement(
|
|
240
|
+
args.issue,
|
|
241
|
+
tier_overrides=per_role or None,
|
|
242
|
+
tier=default_tier,
|
|
243
|
+
allow_commands=args.allow_commands,
|
|
244
|
+
resolve_context=not args.no_context_pack,
|
|
245
|
+
use_plugins=not args.no_plugins,
|
|
246
|
+
)
|
|
247
|
+
except (RunFailed, IssueError) as exc:
|
|
248
|
+
print(f"agentforge: {exc}", file=sys.stderr)
|
|
249
|
+
return 2
|
|
250
|
+
|
|
251
|
+
for result in state.results:
|
|
252
|
+
marker = {
|
|
253
|
+
Outcome.COMPLETED: "ok",
|
|
254
|
+
Outcome.ESCALATED: "escalated",
|
|
255
|
+
Outcome.FAILED: "failed",
|
|
256
|
+
}[result.outcome]
|
|
257
|
+
print(f" [{marker}] {result.role} ({result.tier}) — {result.summary}")
|
|
258
|
+
|
|
259
|
+
if state.results:
|
|
260
|
+
print(f"\n Cost: {render_run_cost(state.results)}")
|
|
261
|
+
|
|
262
|
+
if state.status is RunStatus.AWAITING_SIGNOFF:
|
|
263
|
+
print(f"\nDraft pull request: {state.pull_request}")
|
|
264
|
+
print("AgentForge stops at Sign-off. A human merges.")
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
if state.status is RunStatus.HALTED:
|
|
268
|
+
print(
|
|
269
|
+
f"\nRun halted at step {state.current_step}. Issue #{state.issue} is labelled "
|
|
270
|
+
f"`{RunStatus.HALTED.label}`; correct the plan block and re-run.",
|
|
271
|
+
file=sys.stderr,
|
|
272
|
+
)
|
|
273
|
+
return 1
|
|
274
|
+
|
|
275
|
+
if state.status is RunStatus.SUSPENDED:
|
|
276
|
+
blocking = next((entry for entry in reversed(state.gates) if entry.blocked), None)
|
|
277
|
+
gate = f"the `{blocking.kind}` Gate" if blocking else "a Gate"
|
|
278
|
+
print(
|
|
279
|
+
f"\nRun suspended at step {state.current_step}, waiting on {gate}. Issue "
|
|
280
|
+
f"#{state.issue} is labelled `{RunStatus.SUSPENDED.label}`; re-run once it clears.",
|
|
281
|
+
file=sys.stderr,
|
|
282
|
+
)
|
|
283
|
+
if blocking and blocking.summary:
|
|
284
|
+
print(f" {blocking.summary}", file=sys.stderr)
|
|
285
|
+
return 1
|
|
286
|
+
|
|
287
|
+
if not state.results:
|
|
288
|
+
print(f"Issue #{state.issue} has nothing left to run.")
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
print(f"\nRun failed. See issue #{state.issue}.", file=sys.stderr)
|
|
292
|
+
return 2
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _run_command(args: argparse.Namespace, runner=None) -> int:
|
|
296
|
+
"""`agentforge run`: a chore, run directly, with no Issue and no Run.
|
|
297
|
+
|
|
298
|
+
Activation outside a Run has no blast radius to read, so what answers is
|
|
299
|
+
what the repository is — its root markers. A dbt project has dbt chores
|
|
300
|
+
whatever anybody is editing today (ADR-0019).
|
|
301
|
+
|
|
302
|
+
The human typing this is ADR-0007's grant, so a Command that starts a
|
|
303
|
+
process may start it here. That is the difference between this and the same
|
|
304
|
+
Command reached from inside an unattended Run.
|
|
305
|
+
"""
|
|
306
|
+
from pathlib import Path
|
|
307
|
+
|
|
308
|
+
from .core.commands import run_command
|
|
309
|
+
from .core.contracts import Plan
|
|
310
|
+
from .core.process import SubprocessRunner
|
|
311
|
+
from .core.registry import activate, commands_for
|
|
312
|
+
|
|
313
|
+
root = Path(args.directory).resolve()
|
|
314
|
+
table = commands_for(activate(Plan(summary=""), root))
|
|
315
|
+
|
|
316
|
+
if not args.name:
|
|
317
|
+
if not table:
|
|
318
|
+
print(f"No Plugin answers for {root}, so there are no Commands to run.")
|
|
319
|
+
print("A Command is contributed by a Plugin: see `agentforge --help`.")
|
|
320
|
+
return 0
|
|
321
|
+
print("Commands this repository's Plugins contribute:\n")
|
|
322
|
+
for name, command in sorted(table.items()):
|
|
323
|
+
named = " ".join(f"<{argument}>" for argument in command.arguments)
|
|
324
|
+
print(f" {name} {named}".rstrip())
|
|
325
|
+
if command.summary:
|
|
326
|
+
print(f" {command.summary}")
|
|
327
|
+
return 0
|
|
328
|
+
|
|
329
|
+
command = table.get(args.name.strip().lower())
|
|
330
|
+
if command is None:
|
|
331
|
+
available = ", ".join(sorted(table)) or "none in this repository"
|
|
332
|
+
print(f"agentforge: no Command named {args.name!r}; available: {available}", file=sys.stderr)
|
|
333
|
+
return 2
|
|
334
|
+
|
|
335
|
+
outcome = run_command(
|
|
336
|
+
command,
|
|
337
|
+
args.arguments,
|
|
338
|
+
root=root,
|
|
339
|
+
runner=runner if runner is not None else SubprocessRunner(),
|
|
340
|
+
allow_commands=True,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
for path in outcome.written:
|
|
344
|
+
print(f" wrote {path}")
|
|
345
|
+
|
|
346
|
+
if outcome.error:
|
|
347
|
+
print(f"agentforge: {outcome.error}", file=sys.stderr)
|
|
348
|
+
return 2
|
|
349
|
+
|
|
350
|
+
if outcome.result is not None and not outcome.result.ok:
|
|
351
|
+
rendered = " ".join(outcome.result.argv)
|
|
352
|
+
print(f"agentforge: `{rendered}` exited {outcome.result.returncode}", file=sys.stderr)
|
|
353
|
+
detail = (outcome.result.stderr or outcome.result.stdout).strip()
|
|
354
|
+
if detail:
|
|
355
|
+
print(f" {detail[:800]}", file=sys.stderr)
|
|
356
|
+
return 1
|
|
357
|
+
|
|
358
|
+
if outcome.written:
|
|
359
|
+
print("\nReview them as a diff and commit them yourself: a Command commits nothing.")
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _run_init(args: argparse.Namespace, runner=None) -> int:
|
|
364
|
+
"""`agentforge init`: look at the repository, say so, and write the config.
|
|
365
|
+
|
|
366
|
+
The precondition comes first and refuses loudly. A repository with no
|
|
367
|
+
GitHub remote cannot host a Run (ADR-0002), and finding that out at setup is
|
|
368
|
+
better than finding it out when the first Run halts — so nothing is created
|
|
369
|
+
until `open_repository` has answered.
|
|
370
|
+
|
|
371
|
+
What init detects and cannot yet persist it prints. `load_config` reads two
|
|
372
|
+
keys, and writing the rest would be writing keys nothing consults (ADR-0020).
|
|
373
|
+
"""
|
|
374
|
+
from .core.config import load_config
|
|
375
|
+
from .core.contracts import Plan
|
|
376
|
+
from .core.process import SubprocessRunner
|
|
377
|
+
from .core.project import config_path, detect, differences, render_config
|
|
378
|
+
from .core.registry import activate
|
|
379
|
+
from .core.repo import PreconditionFailed, open_repository
|
|
380
|
+
from .providers import DEFAULT_PROVIDER, PROVIDERS
|
|
381
|
+
|
|
382
|
+
provider = (args.provider or DEFAULT_PROVIDER).strip().lower()
|
|
383
|
+
if provider not in PROVIDERS:
|
|
384
|
+
known = ", ".join(sorted(PROVIDERS))
|
|
385
|
+
print(f"agentforge: unknown provider {provider!r}; available: {known}", file=sys.stderr)
|
|
386
|
+
return 2
|
|
387
|
+
|
|
388
|
+
runner = runner if runner is not None else SubprocessRunner()
|
|
389
|
+
try:
|
|
390
|
+
repo = open_repository(runner, args.directory)
|
|
391
|
+
except PreconditionFailed as exc:
|
|
392
|
+
print(f"agentforge: {exc}", file=sys.stderr)
|
|
393
|
+
return 2
|
|
394
|
+
|
|
395
|
+
active = activate(Plan(summary=""), repo.root)
|
|
396
|
+
context = detect(
|
|
397
|
+
repo.root,
|
|
398
|
+
provider,
|
|
399
|
+
tracked=repo.tracked_files(),
|
|
400
|
+
plugins=tuple(plugin.name for plugin in active.plugins),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
print(f"Repository: {repo.root}")
|
|
404
|
+
print(f" Languages: {', '.join(context.languages) or 'none recognised'}")
|
|
405
|
+
print(f" Provider: {context.provider} ({context.capability_tier} capability tier)")
|
|
406
|
+
suite = " ".join(context.test_suite)
|
|
407
|
+
where = context.suite_detected or "not detected, so this is the documented default"
|
|
408
|
+
print(f" Suite: `{suite}` — {where}")
|
|
409
|
+
print(f" Plugins: {', '.join(context.plugins) or 'none by root marker'}")
|
|
410
|
+
print(
|
|
411
|
+
" printed, not written: which Plugins answer is decided per Run\n"
|
|
412
|
+
" from the frozen plan's blast radius, not from this file."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
path = config_path(repo.root)
|
|
416
|
+
if path.is_file() and not args.force:
|
|
417
|
+
found = differences(context, path.read_text(encoding="utf-8"))
|
|
418
|
+
print(f"\n{path} already exists.")
|
|
419
|
+
if not found:
|
|
420
|
+
print("It matches what init would write. Nothing to do.")
|
|
421
|
+
return 0
|
|
422
|
+
for line in found:
|
|
423
|
+
print(f" - {line}")
|
|
424
|
+
print("Nothing was written. Re-run with --force to replace it.")
|
|
425
|
+
return 1
|
|
426
|
+
|
|
427
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
path.write_text(render_config(context), encoding="utf-8")
|
|
429
|
+
load_config(repo.root) # it reads back, or this command has not succeeded
|
|
430
|
+
|
|
431
|
+
print(f"\nWrote {path}")
|
|
432
|
+
print("Review it and commit it: AgentForge reads it and never edits it again.")
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def main(argv: list[str] | None = None, runner=None) -> int:
|
|
437
|
+
"""`runner` is the Command Runner seam: leave it unset and the real one is
|
|
438
|
+
built. Tests pass a fake and the whole CLI runs offline."""
|
|
439
|
+
parser = build_parser()
|
|
440
|
+
args = parser.parse_args(argv)
|
|
441
|
+
|
|
442
|
+
if args.command is None:
|
|
443
|
+
parser.print_help()
|
|
444
|
+
return 2
|
|
445
|
+
|
|
446
|
+
if args.command == "unslop":
|
|
447
|
+
return _run_unslop(args, runner)
|
|
448
|
+
if args.command == "plan":
|
|
449
|
+
return _run_plan(args, runner)
|
|
450
|
+
if args.command == "implement":
|
|
451
|
+
return _run_implement(args, runner)
|
|
452
|
+
if args.command == "run":
|
|
453
|
+
return _run_command(args, runner)
|
|
454
|
+
if args.command == "init":
|
|
455
|
+
return _run_init(args, runner)
|
|
456
|
+
|
|
457
|
+
raise SystemExit(f"agentforge {args.command} is not implemented yet.")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if __name__ == "__main__":
|
|
461
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Context resolution and extraction."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Per-language readers that turn one file into what it defines and what it uses.
|
|
2
|
+
|
|
3
|
+
An extractor answers two questions about a single file and nothing else: what
|
|
4
|
+
does it define, and what does it reach for. It never opens a second file, never
|
|
5
|
+
resolves an import, and never decides what belongs in a Context Pack — that is
|
|
6
|
+
`context.resolver`'s job, and keeping the two apart is what lets a language be
|
|
7
|
+
added as one small module with one fixture.
|
|
8
|
+
|
|
9
|
+
A file type nobody wrote an extractor for is not an error. `extractor_for`
|
|
10
|
+
returns `None`, the resolver carries the path, and nothing is claimed about the
|
|
11
|
+
contents — an unfamiliar language degrades the pack rather than failing the Run.
|
|
12
|
+
|
|
13
|
+
Every extractor takes text rather than a path. Reading is the resolver's job, so
|
|
14
|
+
these are pure functions and their tests need no repository.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable, Mapping
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import python as python_extractor
|
|
23
|
+
from . import sql as sql_extractor
|
|
24
|
+
from . import yaml as yaml_extractor
|
|
25
|
+
from .base import Extraction
|
|
26
|
+
|
|
27
|
+
#: Suffix to extractor. Lowercased before lookup, because a repository written
|
|
28
|
+
#: on Windows has `.SQL` files in it and they are the same language.
|
|
29
|
+
EXTRACTORS: dict[str, Callable[[str], Extraction]] = {
|
|
30
|
+
".py": python_extractor.extract,
|
|
31
|
+
".sql": sql_extractor.extract,
|
|
32
|
+
".yaml": yaml_extractor.extract,
|
|
33
|
+
".yml": yaml_extractor.extract,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def extractor_for(
|
|
38
|
+
path: str | Path, extractors: Mapping[str, Callable[[str], Extraction]] | None = None
|
|
39
|
+
) -> Callable[[str], Extraction] | None:
|
|
40
|
+
"""The extractor for this path's file type, or `None` if nobody wrote one.
|
|
41
|
+
|
|
42
|
+
`extractors` is the table to look in, defaulting to the built-in three. A
|
|
43
|
+
Run with active Plugins passes a wider one, assembled by `core.registry`,
|
|
44
|
+
and the widening is invisible from here: a Plugin's reader is looked up the
|
|
45
|
+
same way and by the same suffix.
|
|
46
|
+
"""
|
|
47
|
+
table = EXTRACTORS if extractors is None else extractors
|
|
48
|
+
return table.get(Path(path).suffix.lower())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract(
|
|
52
|
+
path: str | Path,
|
|
53
|
+
text: str,
|
|
54
|
+
extractors: Mapping[str, Callable[[str], Extraction]] | None = None,
|
|
55
|
+
) -> Extraction:
|
|
56
|
+
"""Read one file with whatever extractor its type has.
|
|
57
|
+
|
|
58
|
+
An unknown type and a file that will not parse produce the same empty
|
|
59
|
+
extraction on purpose: in both cases AgentForge has nothing to say about the
|
|
60
|
+
contents, and inventing a difference between them would be inventing a
|
|
61
|
+
claim.
|
|
62
|
+
|
|
63
|
+
A Plugin's extractor is caught by the same `except` as a built-in one. A
|
|
64
|
+
Plugin that raises costs the pack one file's contents, never the Run — the
|
|
65
|
+
same bargain `core.registry` makes when a Plugin raises during activation.
|
|
66
|
+
"""
|
|
67
|
+
reader = extractor_for(path, extractors)
|
|
68
|
+
if reader is None:
|
|
69
|
+
return Extraction()
|
|
70
|
+
try:
|
|
71
|
+
return reader(text)
|
|
72
|
+
except Exception: # noqa: BLE001 - a malformed file degrades the pack, never a Run
|
|
73
|
+
return Extraction()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = ["EXTRACTORS", "Extraction", "extract", "extractor_for"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""What an extractor hands back.
|
|
2
|
+
|
|
3
|
+
Its own module so that an extractor can import it without importing the
|
|
4
|
+
registry that imports every extractor. `providers/base.py` splits for the same
|
|
5
|
+
reason.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Extraction:
|
|
15
|
+
"""What one file defines, and what it reaches for outside itself.
|
|
16
|
+
|
|
17
|
+
Two tuples rather than one, because a Role reading the pack asks different
|
|
18
|
+
questions of them: `symbols` is where to make the change, and `references`
|
|
19
|
+
is what the change can break. A Python module's functions and a YAML file's
|
|
20
|
+
keys are both symbols; its imports and a query's source tables are both
|
|
21
|
+
references.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
symbols: tuple[str, ...] = ()
|
|
25
|
+
references: tuple[str, ...] = ()
|
|
26
|
+
|
|
27
|
+
def __bool__(self) -> bool:
|
|
28
|
+
return bool(self.symbols or self.references)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ordered(names) -> tuple[str, ...]:
|
|
32
|
+
"""First occurrence wins, order preserved, blanks dropped.
|
|
33
|
+
|
|
34
|
+
Every extractor needs this and every extractor needs it to behave the same
|
|
35
|
+
way: ADR-0010 makes a pack's contents deterministic for a given Plan and
|
|
36
|
+
repository, and a set would make the order of two symbols depend on the
|
|
37
|
+
hash seed.
|
|
38
|
+
"""
|
|
39
|
+
seen: dict[str, None] = {}
|
|
40
|
+
for name in names:
|
|
41
|
+
text = str(name).strip()
|
|
42
|
+
if text:
|
|
43
|
+
seen.setdefault(text, None)
|
|
44
|
+
return tuple(seen)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["Extraction", "ordered"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""What a Python module defines, and what it imports.
|
|
2
|
+
|
|
3
|
+
Parsed with `ast` rather than matched with regular expressions. A regex that
|
|
4
|
+
finds `def` also finds it in a docstring, and a Context Pack that names symbols
|
|
5
|
+
which are not there costs a Role the tokens to discover that.
|
|
6
|
+
|
|
7
|
+
A module that will not parse yields nothing. Half a syntax tree is a worse
|
|
8
|
+
answer than no answer, and the file is still carried in the pack by path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from .base import Extraction, ordered
|
|
17
|
+
|
|
18
|
+
#: Methods of a class are carried as `Class.method`, one level deep. A nested
|
|
19
|
+
#: function is a detail of its parent and is not a place a Plan sends anybody.
|
|
20
|
+
_DEFINITIONS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract(text: str) -> Extraction:
|
|
24
|
+
"""The module's top-level definitions and the modules it imports."""
|
|
25
|
+
tree = ast.parse(text)
|
|
26
|
+
|
|
27
|
+
symbols: list[str] = []
|
|
28
|
+
references: list[str] = []
|
|
29
|
+
|
|
30
|
+
for node in tree.body:
|
|
31
|
+
if isinstance(node, _DEFINITIONS):
|
|
32
|
+
symbols.append(node.name)
|
|
33
|
+
if isinstance(node, ast.ClassDef):
|
|
34
|
+
symbols.extend(
|
|
35
|
+
f"{node.name}.{child.name}"
|
|
36
|
+
for child in node.body
|
|
37
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
for node in ast.walk(tree):
|
|
41
|
+
if isinstance(node, ast.Import):
|
|
42
|
+
references.extend(alias.name for alias in node.names)
|
|
43
|
+
elif isinstance(node, ast.ImportFrom):
|
|
44
|
+
# A relative import keeps its dots: `..core.contracts` says which
|
|
45
|
+
# package the file belongs to, and `core.contracts` would not.
|
|
46
|
+
references.append("." * node.level + (node.module or ""))
|
|
47
|
+
|
|
48
|
+
return Extraction(
|
|
49
|
+
symbols=ordered(symbols),
|
|
50
|
+
references=ordered(name for name in references if not _stdlib(name)),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _stdlib(name: str) -> bool:
|
|
55
|
+
"""Whether an import names the standard library.
|
|
56
|
+
|
|
57
|
+
Dropped, because a reference is what a change can break and nothing in a
|
|
58
|
+
Plan breaks `pathlib`. It is also most of what a typical module imports, so
|
|
59
|
+
carrying it would spend the pack's budget on the one part of it every Role
|
|
60
|
+
already knows.
|
|
61
|
+
"""
|
|
62
|
+
return name.partition(".")[0] in sys.stdlib_module_names
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["extract"]
|