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,697 @@
|
|
|
1
|
+
"""The sole boundary to the issue tracker.
|
|
2
|
+
|
|
3
|
+
Every GitHub call in AgentForge is in this file, made by invoking `gh` through
|
|
4
|
+
the Command Runner. AgentForge implements no GitHub authentication and speaks to
|
|
5
|
+
no REST API, because `gh` already solves both (ADR-0002).
|
|
6
|
+
|
|
7
|
+
There is deliberately no Tracker interface. When Azure DevOps becomes real, this
|
|
8
|
+
is the file that gets rewritten, and the estimate in ADR-0002 is that this is a
|
|
9
|
+
day of work rather than a rewrite. Keeping every call site here is what makes
|
|
10
|
+
that estimate true.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .contracts import (
|
|
21
|
+
LEGACY_LABELS,
|
|
22
|
+
RUN_LABELS,
|
|
23
|
+
AgentResult,
|
|
24
|
+
ContextPack,
|
|
25
|
+
Finding,
|
|
26
|
+
GateEntry,
|
|
27
|
+
GateVerdict,
|
|
28
|
+
Outcome,
|
|
29
|
+
RunState,
|
|
30
|
+
RunStatus,
|
|
31
|
+
Usage,
|
|
32
|
+
)
|
|
33
|
+
from .plan_format import (
|
|
34
|
+
extract_gate_block,
|
|
35
|
+
extract_result_block,
|
|
36
|
+
parse_issue_body,
|
|
37
|
+
render_gate_block,
|
|
38
|
+
render_result_block,
|
|
39
|
+
)
|
|
40
|
+
from .process import CommandRunner, MissingBinary, require
|
|
41
|
+
|
|
42
|
+
GH_HINT = "Install the GitHub CLI (https://cli.github.com) and run `gh auth login`."
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class IssueError(RuntimeError):
|
|
46
|
+
"""The tracker could not be read or written."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Comment:
|
|
51
|
+
"""One entry in the Run Log."""
|
|
52
|
+
|
|
53
|
+
author: str
|
|
54
|
+
body: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Issue:
|
|
59
|
+
"""A GitHub issue as AgentForge reads it."""
|
|
60
|
+
|
|
61
|
+
number: int
|
|
62
|
+
title: str
|
|
63
|
+
body: str
|
|
64
|
+
url: str = ""
|
|
65
|
+
labels: tuple[str, ...] = ()
|
|
66
|
+
comments: tuple[Comment, ...] = field(default=())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GitHub:
|
|
70
|
+
"""`gh`, wrapped narrowly enough that swapping the tracker is one file."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, runner: CommandRunner, cwd: Path | str) -> None:
|
|
73
|
+
self.runner = runner
|
|
74
|
+
self.cwd = Path(cwd)
|
|
75
|
+
#: Status labels this instance has applied, so a second transition in
|
|
76
|
+
#: the same Run clears the first without re-reading the Issue or
|
|
77
|
+
#: blind-firing a removal for every label in the scheme.
|
|
78
|
+
self._applied: dict[int, str] = {}
|
|
79
|
+
|
|
80
|
+
def preflight(self) -> None:
|
|
81
|
+
"""Confirm `gh` exists before a Run spends anything."""
|
|
82
|
+
try:
|
|
83
|
+
require(self.runner, "gh", GH_HINT)
|
|
84
|
+
except MissingBinary as exc:
|
|
85
|
+
raise IssueError(str(exc)) from exc
|
|
86
|
+
|
|
87
|
+
def _gh(self, *args: str, check: bool = True):
|
|
88
|
+
result = self.runner.run(("gh", *args), cwd=self.cwd)
|
|
89
|
+
if check and not result.ok:
|
|
90
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
91
|
+
raise IssueError(f"`gh {' '.join(args)}` failed: {detail[:600]}")
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
# --- reading -----------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def read_issue(self, number: int) -> Issue:
|
|
97
|
+
result = self._gh(
|
|
98
|
+
"issue",
|
|
99
|
+
"view",
|
|
100
|
+
str(number),
|
|
101
|
+
"--json",
|
|
102
|
+
"number,title,body,url,labels,comments",
|
|
103
|
+
)
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(result.stdout)
|
|
106
|
+
except json.JSONDecodeError as exc:
|
|
107
|
+
raise IssueError(f"could not parse `gh issue view {number}` output: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
return Issue(
|
|
110
|
+
number=int(data.get("number", number)),
|
|
111
|
+
title=str(data.get("title") or ""),
|
|
112
|
+
body=str(data.get("body") or ""),
|
|
113
|
+
url=str(data.get("url") or ""),
|
|
114
|
+
labels=tuple(label.get("name", "") for label in data.get("labels") or ()),
|
|
115
|
+
comments=tuple(
|
|
116
|
+
Comment(
|
|
117
|
+
author=str((comment.get("author") or {}).get("login") or ""),
|
|
118
|
+
body=str(comment.get("body") or ""),
|
|
119
|
+
)
|
|
120
|
+
for comment in data.get("comments") or ()
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# --- writing -----------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def create_issue(self, title: str, body: str, labels: tuple[str, ...] = ()) -> Issue:
|
|
127
|
+
args = ["issue", "create", "--title", title, "--body", body]
|
|
128
|
+
for label in labels:
|
|
129
|
+
self.ensure_label(label)
|
|
130
|
+
args += ["--label", label]
|
|
131
|
+
|
|
132
|
+
url = self._gh(*args).stdout.strip().splitlines()[-1].strip()
|
|
133
|
+
return Issue(number=_number_from_url(url), title=title, body=body, url=url, labels=labels)
|
|
134
|
+
|
|
135
|
+
def post_comment(self, number: int, body: str) -> None:
|
|
136
|
+
self._gh("issue", "comment", str(number), "--body", body)
|
|
137
|
+
|
|
138
|
+
def ensure_label(self, label: str) -> None:
|
|
139
|
+
"""Labels are created on demand; a fresh repository has none of ours."""
|
|
140
|
+
self._gh("label", "create", label, "--description", "AgentForge run status", check=False)
|
|
141
|
+
|
|
142
|
+
def set_label(self, number: int, label: str) -> None:
|
|
143
|
+
self.ensure_label(label)
|
|
144
|
+
self._gh("issue", "edit", str(number), "--add-label", label)
|
|
145
|
+
|
|
146
|
+
def remove_label(self, number: int, label: str) -> None:
|
|
147
|
+
self._gh("issue", "edit", str(number), "--remove-label", label, check=False)
|
|
148
|
+
|
|
149
|
+
def set_status(self, issue: Issue | int, status: RunStatus) -> None:
|
|
150
|
+
"""One status label at a time, so a Run's state stays unambiguous.
|
|
151
|
+
|
|
152
|
+
The Issue is a snapshot taken when the Run started, so its labels only
|
|
153
|
+
say what was there before. Once this Run has set a status of its own,
|
|
154
|
+
that label is what the Issue is actually wearing and the snapshot is
|
|
155
|
+
stale — which is why the second transition of a Run clears one label
|
|
156
|
+
rather than firing a removal for the first one all over again.
|
|
157
|
+
"""
|
|
158
|
+
number = issue.number if isinstance(issue, Issue) else int(issue)
|
|
159
|
+
if number in self._applied:
|
|
160
|
+
known = {self._applied[number]}
|
|
161
|
+
else:
|
|
162
|
+
known = set(issue.labels) if isinstance(issue, Issue) else set()
|
|
163
|
+
|
|
164
|
+
for stale in sorted(known & set(RUN_LABELS) - {status.label}):
|
|
165
|
+
self.remove_label(number, stale)
|
|
166
|
+
|
|
167
|
+
self.set_label(number, status.label)
|
|
168
|
+
self._applied[number] = status.label
|
|
169
|
+
|
|
170
|
+
def open_draft_pr(
|
|
171
|
+
self, *, title: str, body: str, head: str, base: str = "main"
|
|
172
|
+
) -> str:
|
|
173
|
+
"""Open a draft pull request and stop. No Workflow ever merges."""
|
|
174
|
+
result = self._gh(
|
|
175
|
+
"pr",
|
|
176
|
+
"create",
|
|
177
|
+
"--draft",
|
|
178
|
+
"--title",
|
|
179
|
+
title,
|
|
180
|
+
"--body",
|
|
181
|
+
body,
|
|
182
|
+
"--head",
|
|
183
|
+
head,
|
|
184
|
+
"--base",
|
|
185
|
+
base,
|
|
186
|
+
)
|
|
187
|
+
return result.stdout.strip().splitlines()[-1].strip()
|
|
188
|
+
|
|
189
|
+
def default_branch(self) -> str:
|
|
190
|
+
result = self._gh("repo", "view", "--json", "defaultBranchRef", check=False)
|
|
191
|
+
if not result.ok:
|
|
192
|
+
return "main"
|
|
193
|
+
try:
|
|
194
|
+
data = json.loads(result.stdout)
|
|
195
|
+
except json.JSONDecodeError:
|
|
196
|
+
return "main"
|
|
197
|
+
return str((data.get("defaultBranchRef") or {}).get("name") or "main")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- the Run Log ----------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def render_run_log_comment(
|
|
204
|
+
result: AgentResult, *, step: int | None = None, of: int | None = None
|
|
205
|
+
) -> str:
|
|
206
|
+
"""One Run Log entry: prose a human reads, and a block a Run resumes from.
|
|
207
|
+
|
|
208
|
+
`step` is the 1-based position of the Step this result retired or stopped on,
|
|
209
|
+
and `of` how many the Workflow declares. A Role name alone does not locate an
|
|
210
|
+
Escalation — a Workflow may name the same Role twice, and the human who has
|
|
211
|
+
to correct the plan is looking for one Step in it. Both are optional because
|
|
212
|
+
not every result is a Step: a Run that fails after every Step has run is
|
|
213
|
+
failing the Run, and naming a position there would invent one.
|
|
214
|
+
|
|
215
|
+
The position stays in the prose. The result block is what later Runs parse to
|
|
216
|
+
work out which Steps are behind them, and a position recorded there would be
|
|
217
|
+
a second answer to a question `current_step` already derives.
|
|
218
|
+
"""
|
|
219
|
+
heading = {
|
|
220
|
+
Outcome.COMPLETED: "completed",
|
|
221
|
+
Outcome.ESCALATED: "escalated",
|
|
222
|
+
Outcome.FAILED: "failed",
|
|
223
|
+
}[result.outcome]
|
|
224
|
+
|
|
225
|
+
lines = [
|
|
226
|
+
f"### {result.role} — {heading}{_position(step, of)}",
|
|
227
|
+
"",
|
|
228
|
+
f"**Model Tier:** `{result.tier}`",
|
|
229
|
+
"",
|
|
230
|
+
result.summary.strip() or "_no summary reported_",
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
if result.outcome is Outcome.ESCALATED:
|
|
234
|
+
lines += [
|
|
235
|
+
"",
|
|
236
|
+
(
|
|
237
|
+
"The Plan was not executed. Per ADR-0003 a Role that finds the Plan wrong "
|
|
238
|
+
"stops rather than improvising. Correct the plan block in the issue body "
|
|
239
|
+
"and re-run `agentforge implement`."
|
|
240
|
+
),
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
if result.findings:
|
|
244
|
+
lines += ["", f"**Findings ({len(result.findings)}):**", ""]
|
|
245
|
+
lines += render_findings(result.findings)
|
|
246
|
+
|
|
247
|
+
if result.files_changed:
|
|
248
|
+
lines += ["", "**Files changed:**", ""]
|
|
249
|
+
lines += [f"- `{path}`" for path in result.files_changed]
|
|
250
|
+
|
|
251
|
+
if result.detail.strip():
|
|
252
|
+
lines += ["", "<details><summary>Detail</summary>", "", result.detail.strip(), "", "</details>"]
|
|
253
|
+
|
|
254
|
+
lines += ["", render_cost_line(result.usage)]
|
|
255
|
+
lines += ["", render_result_block(result.to_dict())]
|
|
256
|
+
return "\n".join(lines) + "\n"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def render_cost_line(usage: Usage | None) -> str:
|
|
260
|
+
"""What this invocation consumed, in whatever unit its Provider reports.
|
|
261
|
+
|
|
262
|
+
Three sentences for three Providers, and ADR-0009 is why there is not one.
|
|
263
|
+
A CLI that prices its own work is quoted in dollars; one that counts tokens
|
|
264
|
+
and sets no price is quoted in tokens and says that is all it gave; one that
|
|
265
|
+
says nothing at all has that written down too, because a line that stopped
|
|
266
|
+
at a blank reads as free to everybody who has not read this file.
|
|
267
|
+
"""
|
|
268
|
+
who = f"the `{usage.provider}` CLI" if usage and usage.provider else "this Provider"
|
|
269
|
+
|
|
270
|
+
if usage is None or not usage.reported:
|
|
271
|
+
return f"**Cost:** not reported — {who} does not report what an invocation consumes."
|
|
272
|
+
|
|
273
|
+
if usage.cost_usd is None:
|
|
274
|
+
return f"**Cost:** {usage.tokens:,} tokens — {who} reports tokens and not dollars."
|
|
275
|
+
|
|
276
|
+
return f"**Cost:** ${usage.cost_usd:.4f}{_token_split(usage)}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _token_split(usage: Usage) -> str:
|
|
280
|
+
"""The tokens behind a price, however finely the CLI broke them down."""
|
|
281
|
+
if usage.input_tokens is not None or usage.output_tokens is not None:
|
|
282
|
+
return f" — {usage.input_tokens or 0:,} in, {usage.output_tokens or 0:,} out"
|
|
283
|
+
if usage.tokens is not None:
|
|
284
|
+
return f" — {usage.tokens:,} tokens"
|
|
285
|
+
return ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def render_run_cost(results: Sequence[AgentResult]) -> str:
|
|
289
|
+
"""What the whole Run cost, and how much of that figure is missing.
|
|
290
|
+
|
|
291
|
+
A total assembled from Steps that mostly reported nothing is worse than no
|
|
292
|
+
total, so this says how many Steps stand behind the number. That is what
|
|
293
|
+
keeping an unknown cost absent rather than zero buys: a sum of zeros reads
|
|
294
|
+
as a cheap Run, and "across 3 of 6 Steps" reads as what it is.
|
|
295
|
+
"""
|
|
296
|
+
priced = [result.usage for result in results if result.usage and result.usage.reported]
|
|
297
|
+
if not priced:
|
|
298
|
+
return "not reported — no Step's Provider reported what it consumed."
|
|
299
|
+
|
|
300
|
+
if len(priced) == len(results):
|
|
301
|
+
steps = f"across {len(results)} Step" + ("s" if len(results) != 1 else "")
|
|
302
|
+
else:
|
|
303
|
+
steps = f"across {len(priced)} of {len(results)} Steps"
|
|
304
|
+
|
|
305
|
+
total = Usage.combine(priced)
|
|
306
|
+
if total.cost_usd is None:
|
|
307
|
+
who = f"the `{total.provider}` CLI" if total.provider else "this Provider"
|
|
308
|
+
return f"{total.tokens:,} tokens {steps} — {who} reports tokens and not dollars."
|
|
309
|
+
|
|
310
|
+
return f"${total.cost_usd:.4f}{_token_split(total)} {steps}."
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def render_context_comment(
|
|
314
|
+
context: ContextPack,
|
|
315
|
+
plugins: Sequence[tuple[str, str]] = (),
|
|
316
|
+
skipped: Sequence[str] = (),
|
|
317
|
+
) -> str:
|
|
318
|
+
"""The pack this Run's Agents were handed, recorded before the first one ran.
|
|
319
|
+
|
|
320
|
+
A Run that went wrong is diagnosed against what its Agents could see, and
|
|
321
|
+
without this the reader is left inferring that from the Plan. It carries no
|
|
322
|
+
machine block on purpose: the pack is resolved from the frozen Plan on every
|
|
323
|
+
invocation (ADR-0010), so this is a record of a Run rather than a contract
|
|
324
|
+
the next one reads back.
|
|
325
|
+
|
|
326
|
+
`plugins` and `skipped` are the Plugins that answered for this Run and the
|
|
327
|
+
ones that raised while being asked. Both are named for the same reason the
|
|
328
|
+
pack is: a prompt that grew, or one that did not grow when a reader expected
|
|
329
|
+
it to, should carry its own explanation. See ADR-0016.
|
|
330
|
+
"""
|
|
331
|
+
if not context:
|
|
332
|
+
return (
|
|
333
|
+
"### Context Pack — none\n\n"
|
|
334
|
+
"No pack was resolved for this Run, so every Role reads the repository for "
|
|
335
|
+
"itself. That is what `--no-context-pack` is for: the Run's total below is "
|
|
336
|
+
"the figure a packed Run is measured against. Plugin Fragments ride in the "
|
|
337
|
+
"pack, so this Run carried none of those either.\n"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
lines = [
|
|
341
|
+
f"### Context Pack — {_counted(context)}",
|
|
342
|
+
"",
|
|
343
|
+
(
|
|
344
|
+
"Resolved from the frozen Plan before the first Role was invoked, and handed "
|
|
345
|
+
"to every Agent in this Run. A Role that needed something else read it anyway; "
|
|
346
|
+
"the pack is a head start rather than a boundary."
|
|
347
|
+
),
|
|
348
|
+
"",
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
lines += [f"**Files ({len(context.files)}):**", ""]
|
|
352
|
+
lines += [f"- `{path}`" for path in context.files]
|
|
353
|
+
|
|
354
|
+
for label, values in (("Symbols", context.symbols), ("Reaches for", context.references)):
|
|
355
|
+
if values:
|
|
356
|
+
lines += [
|
|
357
|
+
"",
|
|
358
|
+
f"<details><summary>{label} ({len(values)})</summary>",
|
|
359
|
+
"",
|
|
360
|
+
", ".join(f"`{value}`" for value in values),
|
|
361
|
+
"",
|
|
362
|
+
"</details>",
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
if context.conventions:
|
|
366
|
+
lines += ["", "**Conventions:**", ""]
|
|
367
|
+
lines += [f"- {convention}" for convention in context.conventions]
|
|
368
|
+
|
|
369
|
+
if plugins:
|
|
370
|
+
lines += [
|
|
371
|
+
"",
|
|
372
|
+
f"**Plugins active ({len(plugins)}):**",
|
|
373
|
+
"",
|
|
374
|
+
(
|
|
375
|
+
"Contributed to the prompts of the Roles each names, on top of the pack "
|
|
376
|
+
"above. `--no-plugins` runs the same Issue without them."
|
|
377
|
+
),
|
|
378
|
+
"",
|
|
379
|
+
]
|
|
380
|
+
lines += [f"- `{name}` — {what}" for name, what in plugins]
|
|
381
|
+
|
|
382
|
+
if skipped:
|
|
383
|
+
lines += [
|
|
384
|
+
"",
|
|
385
|
+
"**Plugins skipped:**",
|
|
386
|
+
"",
|
|
387
|
+
"These raised while being asked what they contribute. The Run carried on without them.",
|
|
388
|
+
"",
|
|
389
|
+
]
|
|
390
|
+
lines += [f"- {entry}" for entry in skipped]
|
|
391
|
+
|
|
392
|
+
return "\n".join(lines) + "\n"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _counted(context: ContextPack) -> str:
|
|
396
|
+
"""The pack's size, as the one line a reader compares two Runs by."""
|
|
397
|
+
counts = (
|
|
398
|
+
(len(context.files), "file"),
|
|
399
|
+
(len(context.symbols), "symbol"),
|
|
400
|
+
(len(context.references), "reference"),
|
|
401
|
+
)
|
|
402
|
+
return ", ".join(
|
|
403
|
+
f"{count} {noun}" + ("s" if count != 1 else "") for count, noun in counts if count
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def render_findings(findings: Sequence[Finding]) -> list[str]:
|
|
409
|
+
"""Findings as a human reads them: where, what, and why it matters here.
|
|
410
|
+
|
|
411
|
+
Each field on its own line rather than run together, because the reader is
|
|
412
|
+
scanning for the location first and deciding whether to care second.
|
|
413
|
+
"""
|
|
414
|
+
lines = []
|
|
415
|
+
for finding in findings:
|
|
416
|
+
where = f"`{finding.location}`" if finding.location else "_no location reported_"
|
|
417
|
+
lines.append(f"- **{where}** — {finding.risk.strip() or '_no risk described_'}")
|
|
418
|
+
if finding.rationale.strip():
|
|
419
|
+
lines.append(f" - Why it matters: {finding.rationale.strip()}")
|
|
420
|
+
return lines
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
#: What each verdict means for the Run, in the words the Run Log uses.
|
|
424
|
+
_GATE_ENDINGS: dict[GateVerdict, str] = {
|
|
425
|
+
GateVerdict.BLOCKED: (
|
|
426
|
+
"The Run is suspended here. Nothing is wrong with the plan — this Gate can "
|
|
427
|
+
"still clear."
|
|
428
|
+
),
|
|
429
|
+
GateVerdict.ERRORED: (
|
|
430
|
+
"The Run is halted here. A Gate that cannot evaluate has nothing to clear, "
|
|
431
|
+
"so waiting would not help."
|
|
432
|
+
),
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def render_gate_comment(entry: GateEntry, *, of: int | None = None) -> str:
|
|
437
|
+
"""One Run Log entry for a Gate's verdict. See ADR-0008.
|
|
438
|
+
|
|
439
|
+
It carries a Gate block rather than a result block, because `parse_run_log`
|
|
440
|
+
returns Agent Results and a Gate is not an Agent — one counted as a Step
|
|
441
|
+
would retire the Step it had just refused.
|
|
442
|
+
|
|
443
|
+
Only a Gate that stopped the Run writes one. A Gate that cleared has nothing
|
|
444
|
+
to tell the human and nothing the next Run needs, and posting one on every
|
|
445
|
+
resume would fill the Issue with entries that say a Run carried on.
|
|
446
|
+
"""
|
|
447
|
+
lines = [
|
|
448
|
+
f"### {entry.kind} Gate — {entry.verdict}{_after(entry.step, of)}",
|
|
449
|
+
"",
|
|
450
|
+
entry.summary.strip() or "_no reason reported_",
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
if entry.blocked and entry.invalidates:
|
|
454
|
+
lines += [
|
|
455
|
+
"",
|
|
456
|
+
(
|
|
457
|
+
f"This verdict was drawn from the **{entry.invalidates}** Step's own output, "
|
|
458
|
+
"so that Step is marked for re-run: the next `agentforge implement` runs it "
|
|
459
|
+
"again rather than reading this verdict back."
|
|
460
|
+
),
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
ending = _GATE_ENDINGS.get(entry.verdict)
|
|
464
|
+
if ending:
|
|
465
|
+
lines += ["", ending]
|
|
466
|
+
|
|
467
|
+
lines += ["", render_gate_block(entry.to_dict())]
|
|
468
|
+
return "\n".join(lines) + "\n"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _after(step: int, of: int | None) -> str:
|
|
472
|
+
"""Which Step this Gate stands behind, if the caller knew the total."""
|
|
473
|
+
if not step:
|
|
474
|
+
return ""
|
|
475
|
+
return f" (after step {step} of {of})" if of else f" (after step {step})"
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _position(step: int | None, of: int | None) -> str:
|
|
479
|
+
"""Where in the Workflow this entry sits, if the caller knew."""
|
|
480
|
+
if step is None:
|
|
481
|
+
return ""
|
|
482
|
+
return f" (step {step} of {of})" if of else f" (step {step})"
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
#: How each way of ending a Run reads in the Run Log. A Run that is still moving
|
|
486
|
+
#: has no ending, so `PLANNED` and `RUNNING` are deliberately absent.
|
|
487
|
+
_ENDINGS: dict[RunStatus, str] = {
|
|
488
|
+
RunStatus.AWAITING_SIGNOFF: "Run complete — awaiting Sign-off",
|
|
489
|
+
RunStatus.SUSPENDED: "Run suspended",
|
|
490
|
+
RunStatus.HALTED: "Run halted",
|
|
491
|
+
RunStatus.FAILED: "Run failed",
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def render_terminal_comment(state: RunState) -> str:
|
|
496
|
+
"""The last entry in a Run Log: how the Run ended and how far it got.
|
|
497
|
+
|
|
498
|
+
Every Run that starts posts exactly one of these. That is what makes
|
|
499
|
+
escalation frequency countable off the tracker, which ADR-0003 calls the
|
|
500
|
+
signal of Orchestrator quality — a Run that merely stops posting leaves a
|
|
501
|
+
reader to infer why from the absence of a comment, and nobody counts an
|
|
502
|
+
absence.
|
|
503
|
+
|
|
504
|
+
It carries no result block. The Run Log is replayed to work out which Steps
|
|
505
|
+
are behind a Run, and an ending is not a Step.
|
|
506
|
+
"""
|
|
507
|
+
ending = _ENDINGS.get(state.status)
|
|
508
|
+
if ending is None:
|
|
509
|
+
raise IssueError(
|
|
510
|
+
f"a Run in {state.status} has not ended, so there is nothing to conclude"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
complete = state.status is RunStatus.AWAITING_SIGNOFF
|
|
514
|
+
lines = [
|
|
515
|
+
f"### {ending}" if complete else f"### {ending}{_stopped_at(state)}",
|
|
516
|
+
"",
|
|
517
|
+
f"- **Final state:** `{state.status.label}`",
|
|
518
|
+
f"- **Escalated:** {_escalated_line(state)}",
|
|
519
|
+
f"- **Steps completed:** {', '.join(state.done_roles) or 'none'}",
|
|
520
|
+
f"- **Workflow:** `{state.workflow}`",
|
|
521
|
+
f"- **Cost:** {render_run_cost(state.results)}",
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
waiting = _waiting_on(state)
|
|
525
|
+
if waiting:
|
|
526
|
+
lines.append(f"- **Waiting on:** {waiting}")
|
|
527
|
+
lines.append("")
|
|
528
|
+
|
|
529
|
+
last = state.results[-1] if state.results else None
|
|
530
|
+
if last is not None and not last.ok:
|
|
531
|
+
lines += [f"> {last.summary.strip() or 'no summary reported'}", ""]
|
|
532
|
+
|
|
533
|
+
if complete and state.pull_request:
|
|
534
|
+
lines += [f"Draft pull request: {state.pull_request}", ""]
|
|
535
|
+
|
|
536
|
+
lines += [_what_next(state), ""]
|
|
537
|
+
return "\n".join(lines)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _waiting_on(state: RunState) -> str:
|
|
541
|
+
"""The Gate that stopped this Run, if one did.
|
|
542
|
+
|
|
543
|
+
The label says a Run is suspended; it does not say what would clear it, and
|
|
544
|
+
"waiting" without "on what" is what makes a stalled Run look like a crashed
|
|
545
|
+
one. The last entry rather than the first: a Run that cleared one Gate and
|
|
546
|
+
stopped at the next is waiting on the next.
|
|
547
|
+
"""
|
|
548
|
+
stopped = [entry for entry in state.gates if entry.blocked or entry.errored]
|
|
549
|
+
if not stopped:
|
|
550
|
+
return ""
|
|
551
|
+
last = stopped[-1]
|
|
552
|
+
return f"the `{last.kind}` Gate after step {last.step}"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _escalated_line(state: RunState) -> str:
|
|
556
|
+
"""Whether this Run escalated, and where — the countable half of the comment."""
|
|
557
|
+
escalation = state.escalation
|
|
558
|
+
if escalation is None:
|
|
559
|
+
return "no"
|
|
560
|
+
return f"yes, at step {state.current_step} ({escalation.role})"
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _stopped_at(state: RunState) -> str:
|
|
564
|
+
"""The Step the Run stopped on, as a position and a Role.
|
|
565
|
+
|
|
566
|
+
A Role that escalated or failed did not retire its Step, so the Run is still
|
|
567
|
+
standing on it. A Role that completed retired its Step, and a Run that stops
|
|
568
|
+
after one — at a Gate — stopped on the Step behind it.
|
|
569
|
+
"""
|
|
570
|
+
if not state.results:
|
|
571
|
+
return ""
|
|
572
|
+
last = state.results[-1]
|
|
573
|
+
return f" at step {state.current_step - (1 if last.ok else 0)} — {last.role}"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _what_next(state: RunState) -> str:
|
|
577
|
+
"""The reader's move. It differs for each way of stopping, which is the
|
|
578
|
+
whole reason suspended, halted, and failed are three states and not one."""
|
|
579
|
+
rerun = f"`agentforge implement {state.issue}`"
|
|
580
|
+
if state.status is RunStatus.AWAITING_SIGNOFF:
|
|
581
|
+
return "AgentForge stops here. Sign-off is a human Gate; no Workflow merges."
|
|
582
|
+
if state.status is RunStatus.SUSPENDED:
|
|
583
|
+
return (
|
|
584
|
+
"The Run is waiting on a Gate it can still clear. Nothing is wrong with the "
|
|
585
|
+
f"plan: re-run {rerun} once the Gate passes."
|
|
586
|
+
)
|
|
587
|
+
if state.status is RunStatus.HALTED:
|
|
588
|
+
return (
|
|
589
|
+
"Halted is not failed — the completed Steps above stand. Per ADR-0003 a Role "
|
|
590
|
+
"that finds the plan wrong stops rather than improvising, so correct the plan "
|
|
591
|
+
f"block in the issue body and re-run {rerun}."
|
|
592
|
+
)
|
|
593
|
+
return (
|
|
594
|
+
"AgentForge could not finish this Run. The Run Log entry above carries the detail; "
|
|
595
|
+
f"re-run {rerun} once the cause is gone."
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def parse_run_log(issue: Issue) -> tuple[AgentResult, ...]:
|
|
600
|
+
"""Recover every Agent Result the Run Log carries, in order."""
|
|
601
|
+
results = []
|
|
602
|
+
for comment in issue.comments:
|
|
603
|
+
payload = extract_result_block(comment.body)
|
|
604
|
+
if not payload or "role" not in payload or "outcome" not in payload:
|
|
605
|
+
continue
|
|
606
|
+
try:
|
|
607
|
+
results.append(AgentResult.from_dict(payload))
|
|
608
|
+
except (KeyError, ValueError):
|
|
609
|
+
continue
|
|
610
|
+
return tuple(results)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def parse_gate_log(issue: Issue) -> tuple[GateEntry, ...]:
|
|
614
|
+
"""Recover every Gate verdict the Run Log carries, in order.
|
|
615
|
+
|
|
616
|
+
Kept apart from `parse_run_log` rather than merged into it: the two answer
|
|
617
|
+
different questions, and a single sequence of both would make every caller
|
|
618
|
+
of the Run Log ask what kind of entry it was holding.
|
|
619
|
+
"""
|
|
620
|
+
entries = []
|
|
621
|
+
for comment in issue.comments:
|
|
622
|
+
payload = extract_gate_block(comment.body)
|
|
623
|
+
if not payload or "kind" not in payload or "verdict" not in payload:
|
|
624
|
+
continue
|
|
625
|
+
try:
|
|
626
|
+
entries.append(GateEntry.from_dict(payload))
|
|
627
|
+
except (KeyError, ValueError):
|
|
628
|
+
continue
|
|
629
|
+
return tuple(entries)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def run_state(issue: Issue, resolve=None) -> RunState:
|
|
633
|
+
"""Derive a Run's entire state from the Issue. This is ADR-0002's claim.
|
|
634
|
+
|
|
635
|
+
Body for the Plan and the Roster, comments for the results, labels for the
|
|
636
|
+
status. Nothing local is consulted, which is why `agentforge implement 12`
|
|
637
|
+
works from a clone that has never seen this Run.
|
|
638
|
+
"""
|
|
639
|
+
document = parse_issue_body(issue.body, resolve)
|
|
640
|
+
results = parse_run_log(issue)
|
|
641
|
+
gates = parse_gate_log(issue)
|
|
642
|
+
|
|
643
|
+
status = _status_from_labels(issue.labels)
|
|
644
|
+
if status is None:
|
|
645
|
+
if results and results[-1].escalated:
|
|
646
|
+
status = RunStatus.HALTED
|
|
647
|
+
elif results:
|
|
648
|
+
status = RunStatus.RUNNING
|
|
649
|
+
else:
|
|
650
|
+
status = RunStatus.PLANNED
|
|
651
|
+
|
|
652
|
+
return RunState(
|
|
653
|
+
issue=issue.number,
|
|
654
|
+
plan=document.plan,
|
|
655
|
+
roster=document.roster,
|
|
656
|
+
context=document.context or ContextPack(),
|
|
657
|
+
results=results,
|
|
658
|
+
gates=gates,
|
|
659
|
+
status=status,
|
|
660
|
+
workflow=document.workflow,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _status_from_labels(labels: tuple[str, ...]) -> RunStatus | None:
|
|
665
|
+
for status in RunStatus:
|
|
666
|
+
if status.label in labels:
|
|
667
|
+
return status
|
|
668
|
+
for label, status in LEGACY_LABELS.items():
|
|
669
|
+
if label in labels:
|
|
670
|
+
return status
|
|
671
|
+
return None
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _number_from_url(url: str) -> int:
|
|
675
|
+
tail = url.rstrip("/").rsplit("/", 1)[-1]
|
|
676
|
+
try:
|
|
677
|
+
return int(tail)
|
|
678
|
+
except ValueError as exc:
|
|
679
|
+
raise IssueError(f"could not read an issue number out of {url!r}") from exc
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
__all__ = [
|
|
683
|
+
"Comment",
|
|
684
|
+
"GitHub",
|
|
685
|
+
"Issue",
|
|
686
|
+
"IssueError",
|
|
687
|
+
"parse_gate_log",
|
|
688
|
+
"parse_run_log",
|
|
689
|
+
"render_context_comment",
|
|
690
|
+
"render_cost_line",
|
|
691
|
+
"render_findings",
|
|
692
|
+
"render_gate_comment",
|
|
693
|
+
"render_run_cost",
|
|
694
|
+
"render_run_log_comment",
|
|
695
|
+
"render_terminal_comment",
|
|
696
|
+
"run_state",
|
|
697
|
+
]
|