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,639 @@
|
|
|
1
|
+
"""The two commands, as one object.
|
|
2
|
+
|
|
3
|
+
`plan` turns a Task into an Issue. `implement` turns an Issue number into a
|
|
4
|
+
draft pull request. Between them they exercise all four ADRs: agents are CLI
|
|
5
|
+
subprocesses (0001), the Issue carries the handoff and the Run Log (0002), the
|
|
6
|
+
plan freezes when it is filed (0003), and every invocation names a tier rather
|
|
7
|
+
than a model (0004).
|
|
8
|
+
|
|
9
|
+
`implement` walks the Steps of a Workflow, running the ones the Run Log does not
|
|
10
|
+
already account for and passing through the Gate that follows each. It names no
|
|
11
|
+
Role and no Gate kind: Roles are looked up in `RUNNERS` and Gate kinds in
|
|
12
|
+
`GATES`, so a seventh of either is a registration and nothing here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from dataclasses import dataclass, replace
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ..agents import RUNNERS, resolve_role
|
|
22
|
+
from ..agents.orchestrator import Exchange, Interviewer, Orchestrator, Planned
|
|
23
|
+
from ..context.resolver import resolve_pack
|
|
24
|
+
from ..providers import DEFAULT_PROVIDER, get_provider
|
|
25
|
+
from .config import load_config
|
|
26
|
+
from .contracts import (
|
|
27
|
+
AgentResult,
|
|
28
|
+
ContextPack,
|
|
29
|
+
GateEntry,
|
|
30
|
+
GateVerdict,
|
|
31
|
+
ModelTier,
|
|
32
|
+
Outcome,
|
|
33
|
+
PlanDocument,
|
|
34
|
+
Role,
|
|
35
|
+
RunState,
|
|
36
|
+
RunStatus,
|
|
37
|
+
Task,
|
|
38
|
+
retirement,
|
|
39
|
+
)
|
|
40
|
+
from .gates import GateContext, evaluate_gate
|
|
41
|
+
from .issues import (
|
|
42
|
+
GitHub,
|
|
43
|
+
Issue,
|
|
44
|
+
IssueError,
|
|
45
|
+
render_context_comment,
|
|
46
|
+
render_gate_comment,
|
|
47
|
+
render_run_log_comment,
|
|
48
|
+
render_terminal_comment,
|
|
49
|
+
run_state,
|
|
50
|
+
)
|
|
51
|
+
from .plan_format import PlanFormatError, render_issue_body, render_issue_title
|
|
52
|
+
from .process import CommandRunner, SubprocessRunner
|
|
53
|
+
from .registry import (
|
|
54
|
+
NO_PLUGINS,
|
|
55
|
+
Activation,
|
|
56
|
+
activate,
|
|
57
|
+
contributions,
|
|
58
|
+
extractors_for,
|
|
59
|
+
fragments_for,
|
|
60
|
+
gates_for,
|
|
61
|
+
)
|
|
62
|
+
from .repo import PreconditionFailed, Repository, branch_for_issue, open_repository
|
|
63
|
+
from .workflow import Workflow, WorkflowError, load_workflow
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RunFailed(RuntimeError):
|
|
67
|
+
"""A Run could not proceed. The message is written for the person reading it."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class PlanOutcome:
|
|
72
|
+
"""What `agentforge plan` produced."""
|
|
73
|
+
|
|
74
|
+
result: AgentResult
|
|
75
|
+
issue: Issue | None = None
|
|
76
|
+
document: PlanDocument | None = None
|
|
77
|
+
#: What the human was asked and answered, empty when nobody was there.
|
|
78
|
+
interview: tuple[Exchange, ...] = ()
|
|
79
|
+
#: Files the planning pass left changed in the working tree. An interview
|
|
80
|
+
#: records settled terms in the project's glossary, and a human who is not
|
|
81
|
+
#: told that has an unexplained diff and a Run that then refuses to start.
|
|
82
|
+
touched: tuple[str, ...] = ()
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def filed(self) -> bool:
|
|
86
|
+
return self.issue is not None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Forge:
|
|
90
|
+
"""One working directory, one Provider, one `gh`."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
cwd: Path | str = ".",
|
|
95
|
+
provider: str = DEFAULT_PROVIDER,
|
|
96
|
+
runner: CommandRunner | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
self.runner: CommandRunner = runner or SubprocessRunner()
|
|
99
|
+
self.cwd = Path(cwd)
|
|
100
|
+
self.provider_name = provider
|
|
101
|
+
|
|
102
|
+
# --- preconditions -----------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def _prepare(self, allow_commands: bool = False) -> tuple[Repository, GitHub, object]:
|
|
105
|
+
"""Every check that can fail for free, before anything is spent.
|
|
106
|
+
|
|
107
|
+
Absent git, absent remote, absent `gh`, absent coding-agent CLI. A Run
|
|
108
|
+
that is going to fail on a missing binary should fail in the second it
|
|
109
|
+
started, not after a deep-tier planning pass.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
repo = open_repository(self.runner, self.cwd)
|
|
113
|
+
except PreconditionFailed as exc:
|
|
114
|
+
raise RunFailed(str(exc)) from exc
|
|
115
|
+
|
|
116
|
+
github = GitHub(self.runner, repo.root)
|
|
117
|
+
provider = get_provider(
|
|
118
|
+
self.provider_name,
|
|
119
|
+
self.runner,
|
|
120
|
+
allow_commands=allow_commands,
|
|
121
|
+
config=load_config(repo.root),
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
github.preflight()
|
|
125
|
+
provider.preflight()
|
|
126
|
+
except (IssueError, RuntimeError) as exc:
|
|
127
|
+
raise RunFailed(str(exc)) from exc
|
|
128
|
+
|
|
129
|
+
return repo, github, provider
|
|
130
|
+
|
|
131
|
+
# --- agentforge plan ---------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def plan(
|
|
134
|
+
self,
|
|
135
|
+
statement: str,
|
|
136
|
+
tier: ModelTier | None = None,
|
|
137
|
+
interviewer: Interviewer | None = None,
|
|
138
|
+
) -> PlanOutcome:
|
|
139
|
+
"""Turn a Task into an Issue, interviewing first if anybody is there.
|
|
140
|
+
|
|
141
|
+
`interviewer` is the human, as a callable. Passing none is the
|
|
142
|
+
single-shot path: a scheduled Run has nobody to ask, and waiting for an
|
|
143
|
+
answer that will never come is worse than planning from what was typed.
|
|
144
|
+
"""
|
|
145
|
+
repo, github, provider = self._prepare()
|
|
146
|
+
task = Task(statement=statement)
|
|
147
|
+
|
|
148
|
+
# Only when interviewing: the planning pass is told to change nothing,
|
|
149
|
+
# and two extra `git status` calls on every plan buy nothing there.
|
|
150
|
+
before = set(repo.changed_files()) if interviewer else set()
|
|
151
|
+
|
|
152
|
+
planned: Planned = Orchestrator(provider, tier=tier).plan(task, repo.root, interviewer)
|
|
153
|
+
touched = (
|
|
154
|
+
tuple(path for path in repo.changed_files() if path not in before)
|
|
155
|
+
if interviewer
|
|
156
|
+
else ()
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if planned.document is None:
|
|
160
|
+
return PlanOutcome(
|
|
161
|
+
result=planned.result, interview=planned.interview, touched=touched
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
body = render_issue_body(task, planned.document)
|
|
165
|
+
issue = github.create_issue(
|
|
166
|
+
title=render_issue_title(task),
|
|
167
|
+
body=body,
|
|
168
|
+
labels=(RunStatus.PLANNED.label,),
|
|
169
|
+
)
|
|
170
|
+
return PlanOutcome(
|
|
171
|
+
result=planned.result,
|
|
172
|
+
issue=issue,
|
|
173
|
+
document=planned.document,
|
|
174
|
+
interview=planned.interview,
|
|
175
|
+
touched=touched,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# --- agentforge implement ----------------------------------------------
|
|
179
|
+
|
|
180
|
+
def implement(
|
|
181
|
+
self,
|
|
182
|
+
number: int,
|
|
183
|
+
tier_overrides: dict[str, ModelTier] | None = None,
|
|
184
|
+
tier: ModelTier | None = None,
|
|
185
|
+
allow_commands: bool = False,
|
|
186
|
+
resolve_context: bool = True,
|
|
187
|
+
use_plugins: bool = True,
|
|
188
|
+
) -> RunState:
|
|
189
|
+
"""Run the Issue's Workflow. `tier` moves every Role; `tier_overrides` moves one.
|
|
190
|
+
|
|
191
|
+
`allow_commands` is ADR-0007's gate. It is per-Run rather than
|
|
192
|
+
configuration on purpose: a config key would persist a standing grant
|
|
193
|
+
across every future Run in the repository.
|
|
194
|
+
|
|
195
|
+
`resolve_context` off is the control Run. A Context Pack is supposed to
|
|
196
|
+
make a Run cheaper, and the only honest way to know is to run the same
|
|
197
|
+
Issue without one and compare the totals the two Run Logs carry.
|
|
198
|
+
|
|
199
|
+
`use_plugins` off keeps the pack and drops the Plugins' Fragments. The
|
|
200
|
+
two switches are separate because they measure different things and
|
|
201
|
+
ADR-0016 needs both: Fragments ride in the pack, so `resolve_context`
|
|
202
|
+
off already suppresses them, and a Run with neither cannot say which of
|
|
203
|
+
the two moved the total. See ADR-0016 for the three conditions.
|
|
204
|
+
"""
|
|
205
|
+
repo, github, provider = self._prepare(allow_commands=allow_commands)
|
|
206
|
+
|
|
207
|
+
if repo.is_dirty():
|
|
208
|
+
raise RunFailed(
|
|
209
|
+
f"{repo.root} has uncommitted changes. AgentForge commits whatever an Agent "
|
|
210
|
+
"leaves in the working tree, so it will not start a Run on top of your work. "
|
|
211
|
+
"Commit or stash first."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
issue = github.read_issue(number)
|
|
215
|
+
try:
|
|
216
|
+
state = run_state(issue)
|
|
217
|
+
except PlanFormatError as exc:
|
|
218
|
+
raise RunFailed(f"issue #{number} cannot be implemented: {exc}") from exc
|
|
219
|
+
except LookupError as exc:
|
|
220
|
+
raise RunFailed(f"issue #{number} names a Role that cannot run: {exc}") from exc
|
|
221
|
+
|
|
222
|
+
# Before the Workflow is loaded, not after. A later ticket lets a Plugin
|
|
223
|
+
# register a Gate kind, and `parse_workflow` refuses an unknown kind at
|
|
224
|
+
# load time — so a Workflow naming a Plugin's Gate would be rejected
|
|
225
|
+
# before its Plugin existed if these two ran the other way round.
|
|
226
|
+
activation = activate(state.plan, repo.root) if use_plugins else NO_PLUGINS
|
|
227
|
+
# The Gate kinds this Run may name: the shipped three, widened by the
|
|
228
|
+
# Plugins just activated. Assembled once and handed to both the parser
|
|
229
|
+
# and the evaluator, so a definition cannot load against one table and
|
|
230
|
+
# be evaluated against another (ADR-0018).
|
|
231
|
+
gate_kinds = gates_for(activation)
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
workflow = load_workflow(state.workflow, gates=gate_kinds)
|
|
235
|
+
except WorkflowError as exc:
|
|
236
|
+
raise RunFailed(f"issue #{number} cannot be implemented: {exc}") from exc
|
|
237
|
+
|
|
238
|
+
if not workflow.steps:
|
|
239
|
+
raise RunFailed(
|
|
240
|
+
f"the {workflow.name!r} Workflow declares no steps, so there is nothing "
|
|
241
|
+
"to run. Name a Workflow that does, or fill this one in."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# One flag per Step rather than the outstanding ones alone: a Step behind
|
|
245
|
+
# the Run still has a Gate in front of the next one, and a resumed Run
|
|
246
|
+
# has to pass through it.
|
|
247
|
+
retired = retirement(workflow.steps, state.done_roles, lambda step: step.role)
|
|
248
|
+
if not _has_work(workflow, state, retired):
|
|
249
|
+
# Nothing to run is not a Run: no branch, no status change, and no
|
|
250
|
+
# terminal comment, which would otherwise post a second ending every
|
|
251
|
+
# time someone re-read a finished Issue.
|
|
252
|
+
return state
|
|
253
|
+
|
|
254
|
+
# Resolved once, from the frozen Plan, before any Role is invoked
|
|
255
|
+
# (ADR-0010). Doing it per Step would let what a Role sees drift between
|
|
256
|
+
# Steps of one Run, which is the thing the frozen Plan exists to stop.
|
|
257
|
+
pack = (
|
|
258
|
+
# The extractor table comes from the activation resolved above, so a
|
|
259
|
+
# Plugin's reader and a Plugin's Fragment are decided by one answer
|
|
260
|
+
# rather than two. A control Run activated nothing and gets the
|
|
261
|
+
# built-in three, which is what makes it a control for the readers
|
|
262
|
+
# as well as for the prompts.
|
|
263
|
+
resolve_pack(
|
|
264
|
+
state.plan, repo.root, state.context, extractors_for(activation)
|
|
265
|
+
)
|
|
266
|
+
if resolve_context
|
|
267
|
+
else ContextPack()
|
|
268
|
+
)
|
|
269
|
+
state = _with(state, context=pack)
|
|
270
|
+
|
|
271
|
+
branch = branch_for_issue(number)
|
|
272
|
+
repo.create_branch(branch)
|
|
273
|
+
github.set_status(issue, RunStatus.RUNNING)
|
|
274
|
+
|
|
275
|
+
results = list(state.results)
|
|
276
|
+
gates = list(state.gates)
|
|
277
|
+
overrides = tier_overrides or {}
|
|
278
|
+
# ADR-0014: read off the frozen plan block, so a resumed Run resolves
|
|
279
|
+
# tiers the way the invocation that filed the Issue would have.
|
|
280
|
+
chosen = state.roster.tiers()
|
|
281
|
+
invoked = False
|
|
282
|
+
|
|
283
|
+
for position, (step, behind) in enumerate(zip(workflow.steps, retired), start=1):
|
|
284
|
+
if not behind:
|
|
285
|
+
# Before the first Agent of this invocation and never again: the
|
|
286
|
+
# pack is what the Agents below were shown, and a Run that only
|
|
287
|
+
# walked a Gate showed nobody anything.
|
|
288
|
+
if not invoked:
|
|
289
|
+
github.post_comment(
|
|
290
|
+
number,
|
|
291
|
+
render_context_comment(
|
|
292
|
+
state.context, contributions(activation), activation.skipped
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
role = resolve_role(step.role)
|
|
296
|
+
at = overrides.get(
|
|
297
|
+
role.name,
|
|
298
|
+
tier or step.tier or chosen.get(role.name) or role.tier,
|
|
299
|
+
)
|
|
300
|
+
# Derived from the Run Log rather than enumerated, because a
|
|
301
|
+
# resumed Run starts partway through and would otherwise tell a
|
|
302
|
+
# human that a Role escalated at step 1 of a Run whose step 1 is
|
|
303
|
+
# behind it.
|
|
304
|
+
where = _with(state, results=results, gates=gates).current_step
|
|
305
|
+
result = _run_step(
|
|
306
|
+
role.at_tier(at), provider, state, repo.root, activation
|
|
307
|
+
)
|
|
308
|
+
github.post_comment(
|
|
309
|
+
number,
|
|
310
|
+
render_run_log_comment(result, step=where, of=len(workflow.steps)),
|
|
311
|
+
)
|
|
312
|
+
results.append(result)
|
|
313
|
+
invoked = True
|
|
314
|
+
|
|
315
|
+
if result.outcome is not Outcome.COMPLETED:
|
|
316
|
+
status = RunStatus.HALTED if result.escalated else RunStatus.FAILED
|
|
317
|
+
return _end(
|
|
318
|
+
github,
|
|
319
|
+
issue,
|
|
320
|
+
_with(
|
|
321
|
+
state,
|
|
322
|
+
results=results,
|
|
323
|
+
gates=gates,
|
|
324
|
+
status=status,
|
|
325
|
+
branch=branch,
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if step.gate is None:
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
entry = evaluate_gate(
|
|
333
|
+
step.gate,
|
|
334
|
+
GateContext(
|
|
335
|
+
state=_with(state, results=results, gates=gates),
|
|
336
|
+
kind=step.gate,
|
|
337
|
+
role=step.role,
|
|
338
|
+
step=position,
|
|
339
|
+
runner=self.runner,
|
|
340
|
+
root=repo.root,
|
|
341
|
+
),
|
|
342
|
+
gates=gate_kinds,
|
|
343
|
+
)
|
|
344
|
+
if entry.verdict is GateVerdict.CLEARED:
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
# Only a Gate that stopped the Run writes to the Run Log. One that
|
|
348
|
+
# cleared has told the reader nothing and the next Run nothing, and
|
|
349
|
+
# would post an entry on every resume saying the Run carried on.
|
|
350
|
+
github.post_comment(number, render_gate_comment(entry, of=len(workflow.steps)))
|
|
351
|
+
gates.append(entry)
|
|
352
|
+
return _stop_at(
|
|
353
|
+
github,
|
|
354
|
+
issue,
|
|
355
|
+
repo,
|
|
356
|
+
_with(state, results=results, gates=gates, branch=branch),
|
|
357
|
+
entry,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
changed = repo.changed_files()
|
|
361
|
+
committed = repo.commit_declared(
|
|
362
|
+
f"{issue.title}\n\nImplements #{number} via AgentForge.",
|
|
363
|
+
_declared_surface(state, results),
|
|
364
|
+
)
|
|
365
|
+
# Left in the working tree on purpose (ADR-0015), and named rather
|
|
366
|
+
# than dropped: the human at Sign-off is the only one who can say
|
|
367
|
+
# whether an undeclared file was an Agent's work or its suite's.
|
|
368
|
+
left = tuple(path for path in changed if path not in committed)
|
|
369
|
+
base = github.default_branch()
|
|
370
|
+
# An empty working tree is only a failure when the branch has nothing on
|
|
371
|
+
# it either. Plenty of Runs legitimately write nothing here: an audit
|
|
372
|
+
# changes no files, a Step behind a cleared Gate was committed by the
|
|
373
|
+
# invocation that suspended, and a `review` Workflow is pointed at a diff
|
|
374
|
+
# AgentForge did not write. What none of those may do is claim success
|
|
375
|
+
# over a branch identical to the base, which is the empty pull request
|
|
376
|
+
# this check exists to refuse.
|
|
377
|
+
if not committed and invoked and not repo.carries_work_against(base):
|
|
378
|
+
failure = _nothing_to_open(results, workflow, left)
|
|
379
|
+
github.post_comment(number, render_run_log_comment(failure))
|
|
380
|
+
results.append(failure)
|
|
381
|
+
return _end(
|
|
382
|
+
github,
|
|
383
|
+
issue,
|
|
384
|
+
_with(
|
|
385
|
+
state,
|
|
386
|
+
results=results,
|
|
387
|
+
gates=gates,
|
|
388
|
+
status=RunStatus.FAILED,
|
|
389
|
+
branch=branch,
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# A Run that invoked nobody cleared a Gate and found every Step behind
|
|
394
|
+
# it: the work was committed by the Run that suspended, so an empty
|
|
395
|
+
# working tree here is the expected shape rather than a failure.
|
|
396
|
+
repo.push(branch)
|
|
397
|
+
url = github.open_draft_pr(
|
|
398
|
+
title=issue.title,
|
|
399
|
+
body=_pr_body(number, state, results, committed, left),
|
|
400
|
+
head=branch,
|
|
401
|
+
base=base,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return _end(
|
|
405
|
+
github,
|
|
406
|
+
issue,
|
|
407
|
+
_with(
|
|
408
|
+
state,
|
|
409
|
+
results=results,
|
|
410
|
+
gates=gates,
|
|
411
|
+
status=RunStatus.AWAITING_SIGNOFF,
|
|
412
|
+
branch=branch,
|
|
413
|
+
pull_request=url,
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# --- role dispatch ---------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _run_step(
|
|
422
|
+
role: Role,
|
|
423
|
+
provider,
|
|
424
|
+
state: RunState,
|
|
425
|
+
cwd: Path,
|
|
426
|
+
activation: Activation = NO_PLUGINS,
|
|
427
|
+
) -> AgentResult:
|
|
428
|
+
"""Invoke whatever runner is registered for this Role.
|
|
429
|
+
|
|
430
|
+
The lookup is the whole point: the runtime names no Role, so a Workflow
|
|
431
|
+
naming a seventh one needs an entry in `RUNNERS` and nothing here.
|
|
432
|
+
"""
|
|
433
|
+
runner = RUNNERS.get(role.name)
|
|
434
|
+
if runner is None:
|
|
435
|
+
# Unreachable through a validated Workflow — `parse_workflow` refuses
|
|
436
|
+
# unrunnable names at load time — but a Workflow built in code can land here.
|
|
437
|
+
raise RunFailed(
|
|
438
|
+
f"the {role.name!r} Role has no runner in this version; "
|
|
439
|
+
f"available: {', '.join(sorted(RUNNERS))}"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
return runner(provider).run(
|
|
443
|
+
plan=state.plan,
|
|
444
|
+
context=_pack_for(role, state.context, activation),
|
|
445
|
+
cwd=cwd,
|
|
446
|
+
role=role,
|
|
447
|
+
tier=role.tier,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _pack_for(role: Role, pack: ContextPack | None, activation: Activation) -> ContextPack:
|
|
452
|
+
"""The Run's pack, plus whatever the active Plugins say to this one Role.
|
|
453
|
+
|
|
454
|
+
Folded here rather than inside each Role runner: Fragments are per Role and
|
|
455
|
+
the pack is per Run, and this is the one place that knows both. No runner
|
|
456
|
+
signature changes, and the pack recorded in the Run Log stays the Run-level
|
|
457
|
+
one, so a human comparing two Runs is comparing the same object.
|
|
458
|
+
|
|
459
|
+
A Run that resolved no pack gets no Fragments either. ADR-0016 settles that
|
|
460
|
+
`--no-context-pack` is a combined control and `--no-plugins` is the one that
|
|
461
|
+
isolates them.
|
|
462
|
+
"""
|
|
463
|
+
pack = pack or ContextPack()
|
|
464
|
+
if not pack:
|
|
465
|
+
return pack
|
|
466
|
+
|
|
467
|
+
fragments = fragments_for(activation, role.name)
|
|
468
|
+
return replace(pack, fragments=fragments) if fragments else pack
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _declared_surface(state: RunState, results: Sequence[AgentResult]) -> tuple[str, ...]:
|
|
472
|
+
"""Every path this Run said it would touch, from both places it says so.
|
|
473
|
+
|
|
474
|
+
The frozen Plan names files per Step before anything runs, and each Agent
|
|
475
|
+
Result names what its Agent reports changing. Neither is trusted for whether
|
|
476
|
+
work happened — `carries_work_against` asks git that — but together they are
|
|
477
|
+
the only account of *which* files were the Run's, and ADR-0015 needs one:
|
|
478
|
+
`--allow-commands` means a suite writes into the working tree alongside the
|
|
479
|
+
Agents, and no property of a file on disk separates the two.
|
|
480
|
+
|
|
481
|
+
Duplicates are kept out and order is preserved, so a failure message listing
|
|
482
|
+
this reads in Plan order rather than in whatever order a set happened to hold.
|
|
483
|
+
"""
|
|
484
|
+
declared: list[str] = []
|
|
485
|
+
seen: set[str] = set()
|
|
486
|
+
for path in (
|
|
487
|
+
*(path for step in state.plan.steps for path in step.files),
|
|
488
|
+
*(path for result in results for path in result.files_changed),
|
|
489
|
+
):
|
|
490
|
+
if path and path not in seen:
|
|
491
|
+
seen.add(path)
|
|
492
|
+
declared.append(path)
|
|
493
|
+
return tuple(declared)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _nothing_to_open(
|
|
497
|
+
results: list[AgentResult], workflow: Workflow, left: Sequence[str] = ()
|
|
498
|
+
) -> AgentResult:
|
|
499
|
+
"""The Run reported success and committed nothing.
|
|
500
|
+
|
|
501
|
+
Recorded against the last Role to speak, because that is the one whose claim
|
|
502
|
+
the empty commit contradicts. Otherwise the Run opens an empty pull request
|
|
503
|
+
and says it worked.
|
|
504
|
+
|
|
505
|
+
`left` separates the two ways to get here, because the fix differs. An empty
|
|
506
|
+
working tree means the Agents wrote nothing. A working tree holding only
|
|
507
|
+
undeclared files means they wrote somewhere the Plan and their own results
|
|
508
|
+
never named, and ADR-0015 left it uncommitted — which a human can only act on
|
|
509
|
+
if the Run says which files.
|
|
510
|
+
"""
|
|
511
|
+
last = results[-1] if results else None
|
|
512
|
+
if left:
|
|
513
|
+
summary = (
|
|
514
|
+
"the Roster reported success but every file it left is one neither the Plan "
|
|
515
|
+
"nor any Agent Result named, so nothing was committed (ADR-0015): "
|
|
516
|
+
+ ", ".join(left)
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
summary = (
|
|
520
|
+
"the Roster reported success but left no changes in the working tree, "
|
|
521
|
+
"so there is nothing to open a pull request for"
|
|
522
|
+
)
|
|
523
|
+
return AgentResult(
|
|
524
|
+
role=last.role if last else workflow.steps[-1].role,
|
|
525
|
+
tier=last.tier if last else resolve_role(workflow.steps[-1].role).tier,
|
|
526
|
+
outcome=Outcome.FAILED,
|
|
527
|
+
summary=summary,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _has_work(workflow: Workflow, state: RunState, retired: tuple[bool, ...]) -> bool:
|
|
532
|
+
"""Whether this invocation has anything to do at all.
|
|
533
|
+
|
|
534
|
+
A Step still to run, or a Gate the Run has yet to pass through. A Run that
|
|
535
|
+
already reached Sign-off has neither: its Gates were cleared by the Run that
|
|
536
|
+
opened the pull request, and walking them again would open a second one.
|
|
537
|
+
|
|
538
|
+
A suspended Run always has work, even when the definition it suspended
|
|
539
|
+
against no longer declares the Gate that stopped it. Suspended means a Run
|
|
540
|
+
that can still go on, and one that answered "nothing to do" forever would be
|
|
541
|
+
halted under another name.
|
|
542
|
+
"""
|
|
543
|
+
if not all(retired):
|
|
544
|
+
return True
|
|
545
|
+
if state.status is RunStatus.AWAITING_SIGNOFF:
|
|
546
|
+
return False
|
|
547
|
+
return state.status is RunStatus.SUSPENDED or any(step.gate for step in workflow.steps)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _stop_at(
|
|
551
|
+
github: GitHub, issue: Issue, repo: Repository, state: RunState, gate: GateEntry
|
|
552
|
+
) -> RunState:
|
|
553
|
+
"""End a Run at a Gate. Blocked is suspended; errored is halted.
|
|
554
|
+
|
|
555
|
+
Errored halts because a Gate that could not evaluate has nothing to clear,
|
|
556
|
+
and suspending it would invite a resume that suspends again forever.
|
|
557
|
+
|
|
558
|
+
A suspended Run commits and pushes what it has. The human who is being asked
|
|
559
|
+
to clear the Gate has to be able to see the work, and the next invocation
|
|
560
|
+
refuses to start on a dirty working tree — so a Run that suspended without
|
|
561
|
+
committing could never be resumed, which is most of what #9 is for.
|
|
562
|
+
"""
|
|
563
|
+
if not gate.blocked:
|
|
564
|
+
return _end(github, issue, _with(state, status=RunStatus.HALTED))
|
|
565
|
+
|
|
566
|
+
message = (
|
|
567
|
+
f"{issue.title}\n\nPartial work for #{state.issue}; the Run is suspended at a "
|
|
568
|
+
f"{gate.kind} Gate."
|
|
569
|
+
)
|
|
570
|
+
if repo.commit_declared(message, _declared_surface(state, state.results)):
|
|
571
|
+
repo.push(state.branch)
|
|
572
|
+
return _end(github, issue, _with(state, status=RunStatus.SUSPENDED))
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _end(github: GitHub, issue: Issue, state: RunState) -> RunState:
|
|
576
|
+
"""Every way out of a Run, in one place.
|
|
577
|
+
|
|
578
|
+
The comment first, then the label: a reader who sees the label knows the
|
|
579
|
+
reason is already on the Issue, and a Run that dies between the two leaves
|
|
580
|
+
the ending recorded rather than only asserted.
|
|
581
|
+
"""
|
|
582
|
+
github.post_comment(state.issue, render_terminal_comment(state))
|
|
583
|
+
github.set_status(issue, state.status)
|
|
584
|
+
return state
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _pr_body(number: int, state: RunState, results, committed, left=()) -> str:
|
|
588
|
+
lines = [
|
|
589
|
+
f"Closes #{number}.",
|
|
590
|
+
"",
|
|
591
|
+
"## Plan",
|
|
592
|
+
"",
|
|
593
|
+
state.plan.summary.strip(),
|
|
594
|
+
"",
|
|
595
|
+
"## Run Log",
|
|
596
|
+
"",
|
|
597
|
+
]
|
|
598
|
+
for result in results:
|
|
599
|
+
lines.append(f"- **{result.role}** (`{result.tier}`) — {result.summary}")
|
|
600
|
+
if committed:
|
|
601
|
+
lines += ["", "## Files changed", ""]
|
|
602
|
+
lines += [f"- `{path}`" for path in committed]
|
|
603
|
+
if left:
|
|
604
|
+
lines += [
|
|
605
|
+
"",
|
|
606
|
+
"## Left uncommitted",
|
|
607
|
+
"",
|
|
608
|
+
(
|
|
609
|
+
"In the working tree of the machine that ran this, and not in this diff. "
|
|
610
|
+
"Neither the Plan nor any Agent Result named these, so AgentForge did not "
|
|
611
|
+
"commit them (ADR-0015). A build artifact is the usual reason; an Agent "
|
|
612
|
+
"writing outside its Step is the one worth reading."
|
|
613
|
+
),
|
|
614
|
+
"",
|
|
615
|
+
]
|
|
616
|
+
lines += [f"- `{path}`" for path in left]
|
|
617
|
+
lines += [
|
|
618
|
+
"",
|
|
619
|
+
"---",
|
|
620
|
+
"",
|
|
621
|
+
"Opened as a draft by AgentForge. A human merges; no Workflow does.",
|
|
622
|
+
]
|
|
623
|
+
return "\n".join(lines) + "\n"
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _with(state: RunState, **changes) -> RunState:
|
|
627
|
+
"""The same Run, moved on.
|
|
628
|
+
|
|
629
|
+
`dataclasses.replace` rather than a field-by-field copy: the hand-rolled one
|
|
630
|
+
silently dropped every field it did not name, which is a bug that only shows
|
|
631
|
+
up the next time somebody adds a field to `RunState`.
|
|
632
|
+
"""
|
|
633
|
+
for accumulating in ("results", "gates"):
|
|
634
|
+
if accumulating in changes:
|
|
635
|
+
changes[accumulating] = tuple(changes[accumulating])
|
|
636
|
+
return replace(state, **changes)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
__all__ = ["Forge", "PlanOutcome", "RunFailed"]
|