agentforge-framework 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. agentforge_framework/.claude-plugin/plugin.json +4 -0
  2. agentforge_framework/__init__.py +3 -0
  3. agentforge_framework/agents/__init__.py +92 -0
  4. agentforge_framework/agents/architect.py +146 -0
  5. agentforge_framework/agents/implementer.py +162 -0
  6. agentforge_framework/agents/orchestrator.py +588 -0
  7. agentforge_framework/agents/reviewer.py +335 -0
  8. agentforge_framework/agents/security.py +138 -0
  9. agentforge_framework/agents/tester.py +125 -0
  10. agentforge_framework/cli.py +461 -0
  11. agentforge_framework/context/__init__.py +1 -0
  12. agentforge_framework/context/extractors/__init__.py +76 -0
  13. agentforge_framework/context/extractors/base.py +47 -0
  14. agentforge_framework/context/extractors/python.py +65 -0
  15. agentforge_framework/context/extractors/sql.py +121 -0
  16. agentforge_framework/context/extractors/yaml.py +59 -0
  17. agentforge_framework/context/prompt.py +104 -0
  18. agentforge_framework/context/resolver.py +185 -0
  19. agentforge_framework/core/__init__.py +1 -0
  20. agentforge_framework/core/commands.py +170 -0
  21. agentforge_framework/core/config.py +90 -0
  22. agentforge_framework/core/contracts.py +875 -0
  23. agentforge_framework/core/gates.py +333 -0
  24. agentforge_framework/core/issues.py +697 -0
  25. agentforge_framework/core/plan_format.py +272 -0
  26. agentforge_framework/core/process.py +141 -0
  27. agentforge_framework/core/project.py +262 -0
  28. agentforge_framework/core/registry.py +455 -0
  29. agentforge_framework/core/repo.py +185 -0
  30. agentforge_framework/core/router.py +1 -0
  31. agentforge_framework/core/runtime.py +639 -0
  32. agentforge_framework/core/skills.py +255 -0
  33. agentforge_framework/core/workflow.py +215 -0
  34. agentforge_framework/plugins/__init__.py +35 -0
  35. agentforge_framework/plugins/databricks/__init__.py +86 -0
  36. agentforge_framework/plugins/pyspark/__init__.py +57 -0
  37. agentforge_framework/plugins/python/__init__.py +45 -0
  38. agentforge_framework/plugins/sql/__init__.py +377 -0
  39. agentforge_framework/providers/__init__.py +48 -0
  40. agentforge_framework/providers/base.py +248 -0
  41. agentforge_framework/providers/claude.py +159 -0
  42. agentforge_framework/providers/codex.py +139 -0
  43. agentforge_framework/skills/MANIFEST.yaml +157 -0
  44. agentforge_framework/skills/NOTICE +49 -0
  45. agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
  46. agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  47. agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
  48. agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
  49. agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
  50. agentforge_framework/skills/grilling/SKILL.md +28 -0
  51. agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
  52. agentforge_framework/skills/to-spec/SKILL.md +75 -0
  53. agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
  54. agentforge_framework/skills/to-tickets/SKILL.md +105 -0
  55. agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
  56. agentforge_framework/skills/unslop/SKILL.md +131 -0
  57. agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
  58. agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
  59. agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
  60. agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
  61. agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
  62. agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
  63. agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
  64. agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
  65. agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
  66. agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
  67. agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
  68. agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
  69. agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
  70. agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
  71. agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
  72. agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
  73. agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
  74. agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
  75. agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
  76. agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
  77. agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
  78. agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
  79. agentforge_framework/skills/write-plainly/SKILL.md +94 -0
  80. agentforge_framework/workflows/bugfix.yaml +8 -0
  81. agentforge_framework/workflows/feature.yaml +16 -0
  82. agentforge_framework/workflows/review.yaml +10 -0
  83. agentforge_framework-0.2.0.dist-info/METADATA +321 -0
  84. agentforge_framework-0.2.0.dist-info/RECORD +89 -0
  85. agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
  86. agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
  87. agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
  88. agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
  89. agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,875 @@
1
+ """The shared vocabulary of AgentForge, as data.
2
+
3
+ Every other module imports from here and nothing here imports from them. The
4
+ dataclasses carry no behavior beyond serialization, because their serialized
5
+ shape is a compatibility surface: ADR-0003 makes the Plan an interface that
6
+ every Role parses out of an Issue body someone may have filed a week ago.
7
+
8
+ Terms are defined in `CONTEXT.md`. This file is where they acquire a shape.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Iterable, Sequence
14
+ from dataclasses import dataclass, field, replace
15
+ from enum import StrEnum
16
+ from typing import TYPE_CHECKING, TypeVar
17
+
18
+ if TYPE_CHECKING:
19
+ # Only for the annotations on `Extractor.read` and `Validator.check`.
20
+ # Imported under the guard so that the rule this module's own docstring
21
+ # states stays true at runtime: everything imports from here and nothing
22
+ # here imports from them.
23
+ from ..context.extractors.base import Extraction
24
+ from .gates import GateContext
25
+
26
+ T = TypeVar("T")
27
+
28
+ #: Bumped when a field is removed or its meaning changes. Added fields with
29
+ #: defaults do not require a bump, because an older Issue still parses.
30
+ PLAN_FORMAT_VERSION = 1
31
+
32
+ #: The Workflow an Issue runs when its plan block names none. Every Issue filed
33
+ #: before Workflows existed reads as `feature`, which is what those Runs did.
34
+ DEFAULT_WORKFLOW = "feature"
35
+
36
+
37
+ class ModelTier(StrEnum):
38
+ """The class of model a Role runs on, named by intent. See ADR-0004.
39
+
40
+ Nothing outside a Provider adapter maps these onto a model identifier.
41
+ """
42
+
43
+ DEEP = "deep"
44
+ STANDARD = "standard"
45
+ CHEAP = "cheap"
46
+
47
+
48
+ class Outcome(StrEnum):
49
+ """How an Agent finished.
50
+
51
+ ``ESCALATED`` is a result, not an exception: ADR-0003 requires a Role that
52
+ finds the plan wrong to stop rather than improvise, and the runtime needs to
53
+ tell that apart from a Role that simply crashed.
54
+ """
55
+
56
+ COMPLETED = "completed"
57
+ ESCALATED = "escalated"
58
+ FAILED = "failed"
59
+
60
+
61
+ class GateVerdict(StrEnum):
62
+ """What a Gate says when it is asked.
63
+
64
+ Deliberately not an ``Outcome``. An Outcome is a Role's verdict on its own
65
+ work; a Gate is not an Agent and judges somebody else's. ``ERRORED`` is the
66
+ Gate that could not decide — which is not the same as deciding no, because a
67
+ Gate with nothing to clear cannot be cleared by waiting.
68
+ """
69
+
70
+ CLEARED = "cleared"
71
+ BLOCKED = "blocked"
72
+ ERRORED = "errored"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class GateEntry:
77
+ """One Gate's verdict, as the Run Log carries it. See ADR-0008.
78
+
79
+ `step` is the 1-based position of the Step this Gate follows. Unlike a
80
+ result's position — which `current_step` derives — a Gate's is the only thing
81
+ that says which Gate spoke, so it is recorded rather than re-derived.
82
+
83
+ `invalidates` names the Role whose output this verdict was drawn from, and is
84
+ empty when the Gate judged nobody's: a Security Gate reads the Security
85
+ Agent's findings, while a human Gate reads a human. A blocked verdict naming
86
+ a Role un-retires that Role's Step, which is what stops the next Run from
87
+ re-reading a verdict the human has already acted on.
88
+ """
89
+
90
+ kind: str
91
+ verdict: GateVerdict
92
+ step: int = 0
93
+ invalidates: str = ""
94
+ summary: str = ""
95
+
96
+ @property
97
+ def blocked(self) -> bool:
98
+ return self.verdict is GateVerdict.BLOCKED
99
+
100
+ @property
101
+ def errored(self) -> bool:
102
+ return self.verdict is GateVerdict.ERRORED
103
+
104
+ def to_dict(self) -> dict:
105
+ return {
106
+ "kind": self.kind,
107
+ "verdict": str(self.verdict),
108
+ "step": self.step,
109
+ "invalidates": self.invalidates,
110
+ "summary": self.summary,
111
+ }
112
+
113
+ @classmethod
114
+ def from_dict(cls, data: dict) -> GateEntry:
115
+ return cls(
116
+ kind=str(data["kind"]),
117
+ verdict=GateVerdict(data["verdict"]),
118
+ step=int(data.get("step") or 0),
119
+ invalidates=str(data.get("invalidates") or ""),
120
+ summary=str(data.get("summary") or ""),
121
+ )
122
+
123
+
124
+ class RunStatus(StrEnum):
125
+ """Where a Run stands. Carried on the Issue as a label, per ADR-0002.
126
+
127
+ Three of these are ways for a Run to stop, and CONTEXT.md keeps them apart
128
+ because the reader's next move differs in each. ``SUSPENDED`` is a Gate the
129
+ Run can still clear, and nobody need do anything. ``HALTED`` is what an
130
+ Escalation or an errored Gate produces: the completed steps stand and a
131
+ human decides. ``FAILED`` is a Run AgentForge could not finish at all.
132
+
133
+ ``HALTED`` was ``ESCALATED`` until the glossary settled that an Escalation
134
+ is the verdict a Role reports and Halted the state it produces. The old
135
+ label is still read; see ``LEGACY_LABELS``.
136
+ """
137
+
138
+ PLANNED = "planned"
139
+ RUNNING = "running"
140
+ SUSPENDED = "suspended"
141
+ HALTED = "halted"
142
+ AWAITING_SIGNOFF = "awaiting-signoff"
143
+ FAILED = "failed"
144
+
145
+ @property
146
+ def label(self) -> str:
147
+ return f"agentforge:{self.value}"
148
+
149
+
150
+ #: Labels an earlier AgentForge applied, and what they mean now. Read, never
151
+ #: written: issues labelled `agentforge:escalated` were open when the rename
152
+ #: landed, and a Run that cannot read its own state back is the one thing
153
+ #: ADR-0002 does not survive.
154
+ LEGACY_LABELS: dict[str, RunStatus] = {"agentforge:escalated": RunStatus.HALTED}
155
+
156
+ #: Every status label AgentForge may find on an Issue, so a caller can reconcile
157
+ #: them without knowing the naming scheme. Retired labels are in here so that a
158
+ #: Run clears them rather than leaving an Issue wearing two answers.
159
+ RUN_LABELS = tuple(status.label for status in RunStatus) + tuple(LEGACY_LABELS)
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class Task:
164
+ """A unit of software work stated by a human, in a human's words.
165
+
166
+ A Task reaches the Orchestrator and stops there. ADR-0003 keeps the original
167
+ phrasing away from downstream Roles, which execute the Plan instead.
168
+ """
169
+
170
+ statement: str
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class PlanStep:
175
+ """One unit of the Plan, written to be executed without re-interpretation."""
176
+
177
+ id: str
178
+ intent: str
179
+ files: tuple[str, ...] = ()
180
+ acceptance: str = ""
181
+
182
+ def to_dict(self) -> dict:
183
+ return {
184
+ "id": self.id,
185
+ "intent": self.intent,
186
+ "files": list(self.files),
187
+ "acceptance": self.acceptance,
188
+ }
189
+
190
+ @classmethod
191
+ def from_dict(cls, data: dict) -> PlanStep:
192
+ return cls(
193
+ id=str(data["id"]),
194
+ intent=str(data["intent"]),
195
+ files=tuple(data.get("files") or ()),
196
+ acceptance=str(data.get("acceptance") or ""),
197
+ )
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class Plan:
202
+ """What the Orchestrator decided, frozen once written. See ADR-0003.
203
+
204
+ The fields are deliberately dull. A Plan that needs interpreting is a Plan
205
+ that gets interpreted differently by each Role that reads it.
206
+ """
207
+
208
+ summary: str
209
+ steps: tuple[PlanStep, ...] = ()
210
+ constraints: tuple[str, ...] = ()
211
+
212
+ def to_dict(self) -> dict:
213
+ return {
214
+ "summary": self.summary,
215
+ "steps": [step.to_dict() for step in self.steps],
216
+ "constraints": list(self.constraints),
217
+ }
218
+
219
+ @classmethod
220
+ def from_dict(cls, data: dict) -> Plan:
221
+ return cls(
222
+ summary=str(data["summary"]),
223
+ steps=tuple(PlanStep.from_dict(step) for step in data.get("steps") or ()),
224
+ constraints=tuple(data.get("constraints") or ()),
225
+ )
226
+
227
+
228
+ @dataclass(frozen=True)
229
+ class Role:
230
+ """A named specialization with a fixed job, model tier, prompt, and skills.
231
+
232
+ A Role is a definition, not a running thing. `instructions` is the standing
233
+ job description; the task-specific prompt is assembled per invocation.
234
+ """
235
+
236
+ name: str
237
+ tier: ModelTier
238
+ instructions: str = ""
239
+ skills: tuple[str, ...] = ()
240
+
241
+ def at_tier(self, tier: ModelTier) -> Role:
242
+ """The same Role with its tier overridden, per user request or config."""
243
+ return replace(self, tier=tier)
244
+
245
+
246
+ @dataclass(frozen=True)
247
+ class Roster:
248
+ """The ordered list of Roles an Issue requires.
249
+
250
+ Serialization carries names and tiers only. Instructions are code, not
251
+ contract, so they do not travel in an Issue body where they would be noise
252
+ to the human reading it and stale to the Agent parsing it.
253
+ """
254
+
255
+ roles: tuple[Role, ...] = ()
256
+
257
+ def __iter__(self):
258
+ return iter(self.roles)
259
+
260
+ def __len__(self) -> int:
261
+ return len(self.roles)
262
+
263
+ def names(self) -> tuple[str, ...]:
264
+ return tuple(role.name for role in self.roles)
265
+
266
+ def tiers(self) -> dict[str, ModelTier]:
267
+ """The tier each named Role runs at, as the Roster table promises it.
268
+
269
+ Keyed by name rather than by position, matching how `align_to_workflow`
270
+ collapses a requested Roster onto a Workflow. A Workflow naming one Role
271
+ twice therefore runs both Steps at the one tier the table shows, which
272
+ is what the table says and the only thing a reader could conclude from
273
+ it. See ADR-0014.
274
+ """
275
+ return {role.name: role.tier for role in self.roles}
276
+
277
+ def to_dict(self) -> list[dict]:
278
+ return [{"role": role.name, "tier": str(role.tier)} for role in self.roles]
279
+
280
+ @classmethod
281
+ def from_dict(cls, data: list[dict], resolve) -> Roster:
282
+ """Rebuild a Roster, resolving each name through `resolve(name) -> Role`."""
283
+ roles = []
284
+ for entry in data or ():
285
+ role = resolve(str(entry["role"]))
286
+ tier = entry.get("tier")
287
+ roles.append(role.at_tier(ModelTier(tier)) if tier else role)
288
+ return cls(tuple(roles))
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class Fragment:
293
+ """Conventions a Plugin contributes to the prompts of the Roles it names.
294
+
295
+ `roles` empty means every Role. A Fragment is text and nothing else: it is
296
+ inlined into the Context Pack handed to a Step, so it reaches an Agent the
297
+ same way whatever Provider is driving. See ADR-0016.
298
+ """
299
+
300
+ text: str
301
+ roles: tuple[str, ...] = ()
302
+
303
+
304
+ @dataclass(frozen=True)
305
+ class Extractor:
306
+ """A per-language reader a Plugin contributes, and the suffixes it claims.
307
+
308
+ `read` has the signature every built-in extractor has — text in, `Extraction`
309
+ out — because a Plugin's reader is not a second kind of thing. It is a pure
310
+ function of one file's contents: it never opens a second file and never sees
311
+ a path, which is what keeps it testable against a recorded fixture and what
312
+ stops it from making the pack depend on the machine resolving it.
313
+
314
+ Claiming a suffix a built-in already reads is the point rather than a
315
+ conflict. A `.sql` file in a dbt project has dependencies a generic SQL read
316
+ cannot see, and the Plugin that knows about dbt is the one that should
317
+ answer for it. See ADR-0010 and ADR-0016.
318
+ """
319
+
320
+ suffixes: tuple[str, ...]
321
+ read: Callable[[str], Extraction]
322
+
323
+
324
+ @dataclass(frozen=True)
325
+ class Validator:
326
+ """A Gate kind a Plugin contributes, and the predicate that evaluates it.
327
+
328
+ `check` has the signature every shipped Gate has — a `GateContext` in, a
329
+ `GateEntry` out — because a Plugin's Gate is not a second kind of thing. It
330
+ is handed the Command Runner and the working tree like any other, so a
331
+ validator that shells out to a parser has what it needs, and it returns
332
+ cleared, blocked, or errored with the same meanings: blocked suspends a Run
333
+ that can still clear, errored halts one that cannot.
334
+
335
+ A validator that cannot evaluate returns an errored `GateEntry` rather than
336
+ raising. A Plugin degrades a Run and never ends it, which is the bargain
337
+ `core.registry` makes at activation and `context.extractors` makes when a
338
+ reader raises.
339
+
340
+ `kind` is the name a Workflow's YAML writes. It cannot be one of the shipped
341
+ kinds: `human`, `tests`, and `security` mean what the shipped Workflows say
342
+ they mean, and a Plugin that could redefine `human` could make a human Gate
343
+ stop stopping. See ADR-0018.
344
+ """
345
+
346
+ kind: str
347
+ check: Callable[[GateContext], GateEntry]
348
+
349
+
350
+ @dataclass(frozen=True)
351
+ class FileTemplate:
352
+ """One file a Command writes: where it goes, and what is in it.
353
+
354
+ Both are `string.Template` sources, so `$name` and `${name}` are the
355
+ placeholders and a literal dollar is `$$`. Not `str.format`, because a dbt
356
+ model is Jinja and a template full of `{{ ref(...) }}` would have to double
357
+ every brace it already has — a trap that fires the first time somebody adds
358
+ a macro to a template that looked fine.
359
+ """
360
+
361
+ path: str
362
+ text: str
363
+
364
+
365
+ @dataclass(frozen=True)
366
+ class Command:
367
+ """A repeated chore, expressed so that running it needs no inference.
368
+
369
+ Two shapes, and a Command may be both. `templates` are files it writes;
370
+ `argv` is the argument vector it runs through the Command Runner. Scaffolding
371
+ a dbt model is a Command; deciding whether the model is correct is not, and
372
+ nothing here consults a model, reads the repository, or makes a choice.
373
+
374
+ `arguments` names its positional parameters in order, which is what
375
+ `agentforge run <command> orders` binds against and what a wrong number of
376
+ arguments is reported against. Every placeholder in `templates` and `argv`
377
+ is one of these names, substituted the same way in both.
378
+
379
+ Data, like every other contribution: a Command carries no callable, so what
380
+ it will do is readable without running it. See ADR-0019.
381
+ """
382
+
383
+ name: str
384
+ summary: str = ""
385
+ arguments: tuple[str, ...] = ()
386
+ templates: tuple[FileTemplate, ...] = ()
387
+ argv: tuple[str, ...] = ()
388
+
389
+
390
+ @dataclass(frozen=True)
391
+ class Plugin:
392
+ """A bundle of domain knowledge for one technology, as data.
393
+
394
+ No behaviour: a Plugin declares what it answers for and what it contributes,
395
+ and `core.registry` does the deciding. Every contribution field is optional,
396
+ so a Plugin carrying only Fragments is legal and is what the `python` Plugin
397
+ is, while one carrying no Fragment at all is equally legal and is what `sql`
398
+ is — it reads files and contributes a Gate kind, and says nothing to a Role.
399
+
400
+ `suffixes`, `root_markers`, and `imports` are the three ways a Plugin is
401
+ detected. A suffix answers for the blast radius the frozen Plan names; a
402
+ root marker answers for the repository itself — a `pyproject.toml` says
403
+ Python whatever one Plan happens to touch; an import answers for what a file
404
+ in that blast radius actually uses, because `.py` says nothing about whether
405
+ a module is a Spark job. All three are declarations rather than predicates,
406
+ so a Plugin stays data and `agentforge init` can write down what detection
407
+ already computed. See ADR-0017.
408
+
409
+ Detection and contribution are separate on purpose. `suffixes` says when
410
+ this Plugin is active; an `Extractor`'s own suffixes say what it reads once
411
+ it is. The `sql` Plugin activates on `.sql` and a `dbt_project.yml`, and
412
+ then reads the schema YAML beside the models — which it would be wrong to
413
+ activate for on its own.
414
+ """
415
+
416
+ name: str
417
+ suffixes: tuple[str, ...] = ()
418
+ root_markers: tuple[str, ...] = ()
419
+ #: Top-level module names whose import activates this Plugin — `pyspark`
420
+ #: matches both `import pyspark` and `from pyspark.sql import functions`.
421
+ #: Read out of the Python files the blast radius names, which is the only
422
+ #: place an import means anything.
423
+ imports: tuple[str, ...] = ()
424
+ fragments: tuple[Fragment, ...] = ()
425
+ extractors: tuple[Extractor, ...] = ()
426
+ validators: tuple[Validator, ...] = ()
427
+ commands: tuple[Command, ...] = ()
428
+
429
+
430
+ @dataclass(frozen=True)
431
+ class ContextPack:
432
+ """The bounded set of files, symbols, and conventions handed to an Agent.
433
+
434
+ Two things fill one in. The Orchestrator declares what it believes the work
435
+ touches, and that travels in the Issue body; `context.resolver` resolves
436
+ that declaration against the frozen Plan and the repository at the start of
437
+ a Run, which is the pack an Agent is actually handed. See ADR-0010.
438
+
439
+ A pack is a head start and never a boundary. A Role that needs a file the
440
+ pack does not name reads it, so a resolver mistake costs tokens rather than
441
+ correctness.
442
+ """
443
+
444
+ files: tuple[str, ...] = ()
445
+ symbols: tuple[str, ...] = ()
446
+ conventions: tuple[str, ...] = ()
447
+ #: What those files reach for outside themselves — a module's imports, a
448
+ #: query's source tables. Written by the resolver rather than declared by
449
+ #: the Orchestrator: it is read out of the files, and a Role reads it to
450
+ #: find out what its change can break.
451
+ references: tuple[str, ...] = ()
452
+ #: What the active Plugins contribute to this Step's Role, folded in by the
453
+ #: runtime just before the Agent is invoked. Kept apart from `conventions`
454
+ #: because the two have different authors and a reader should be able to
455
+ #: tell them apart: `conventions` is the Orchestrator's judgement about this
456
+ #: Task, and this is what the repository's technology is held to regardless
457
+ #: of Task. Per Role, so it is absent from the Run-level pack the Run Log
458
+ #: records, and absent from `to_dict` because it never travels in an Issue
459
+ #: body. See ADR-0016.
460
+ fragments: tuple[str, ...] = ()
461
+
462
+ def __bool__(self) -> bool:
463
+ """Whether the pack carries anything at all.
464
+
465
+ The runtime asks this to tell a resolved pack from the empty one a Run
466
+ started with, and every call site spelling out the four fields is the
467
+ same question asked four ways — one of which gets forgotten the next
468
+ time a field is added.
469
+ """
470
+ return bool(
471
+ self.files
472
+ or self.symbols
473
+ or self.conventions
474
+ or self.references
475
+ or self.fragments
476
+ )
477
+
478
+ def to_dict(self) -> dict:
479
+ """The pack as it travels in an Issue body.
480
+
481
+ `fragments` is deliberately absent. It is resolved per Step from the
482
+ Plugins active for the repository the Run is in, so writing it into the
483
+ Issue would freeze one machine's answer into the stable surface
484
+ (ADR-0011) and hand the next Run conventions it may not be held to.
485
+ """
486
+ return {
487
+ "files": list(self.files),
488
+ "symbols": list(self.symbols),
489
+ "conventions": list(self.conventions),
490
+ "references": list(self.references),
491
+ }
492
+
493
+ @classmethod
494
+ def from_dict(cls, data: dict | None) -> ContextPack:
495
+ data = data or {}
496
+ return cls(
497
+ files=tuple(data.get("files") or ()),
498
+ symbols=tuple(data.get("symbols") or ()),
499
+ conventions=tuple(data.get("conventions") or ()),
500
+ references=tuple(data.get("references") or ()),
501
+ )
502
+
503
+
504
+ @dataclass(frozen=True)
505
+ class Usage:
506
+ """What one Provider invocation consumed, in whatever unit its CLI reports.
507
+
508
+ Every figure is optional and none of them defaults to zero, because the
509
+ Providers disagree about what they will tell you: `claude` reports dollars
510
+ and a token split, `codex` prints one token count and no price, and a CLI
511
+ may report nothing at all. A zero would make all three look like a free
512
+ invocation, so absent stays absent and a total can say how much of itself
513
+ is missing. See ADR-0009.
514
+
515
+ `provider` names the CLI the figures came from, so a Run Log line can say
516
+ why a dollar figure is missing rather than leaving a blank where one would
517
+ have been.
518
+ """
519
+
520
+ provider: str = ""
521
+ input_tokens: int | None = None
522
+ output_tokens: int | None = None
523
+ #: One figure for the whole invocation, for a CLI that reports no split.
524
+ total_tokens: int | None = None
525
+ cost_usd: float | None = None
526
+
527
+ @property
528
+ def tokens(self) -> int | None:
529
+ """Every token this invocation used, however the CLI broke them down."""
530
+ if self.total_tokens is not None:
531
+ return self.total_tokens
532
+ if self.input_tokens is None and self.output_tokens is None:
533
+ return None
534
+ return (self.input_tokens or 0) + (self.output_tokens or 0)
535
+
536
+ @property
537
+ def reported(self) -> bool:
538
+ """Whether the Provider said anything at all about what this cost."""
539
+ return self.cost_usd is not None or self.tokens is not None
540
+
541
+ @classmethod
542
+ def combine(cls, usages: Iterable[Usage | None]) -> Usage:
543
+ """Add up what a Run spent, keeping absent figures absent.
544
+
545
+ The split is dropped: a Run whose Steps report a mix of split and
546
+ unsplit counts has no honest input/output total, and one token figure
547
+ that is true beats two that are assembled.
548
+ """
549
+ cost: float | None = None
550
+ tokens: int | None = None
551
+ providers = set()
552
+
553
+ for usage in usages:
554
+ if usage is None:
555
+ continue
556
+ if usage.provider:
557
+ providers.add(usage.provider)
558
+ if usage.cost_usd is not None:
559
+ cost = (cost or 0.0) + usage.cost_usd
560
+ if usage.tokens is not None:
561
+ tokens = (tokens or 0) + usage.tokens
562
+
563
+ return cls(
564
+ provider=providers.pop() if len(providers) == 1 else "",
565
+ total_tokens=tokens,
566
+ cost_usd=cost,
567
+ )
568
+
569
+ def to_dict(self) -> dict:
570
+ """Only what was reported. An absent key is the absent figure."""
571
+ payload: dict = {}
572
+ if self.provider:
573
+ payload["provider"] = self.provider
574
+ for name in ("input_tokens", "output_tokens", "total_tokens"):
575
+ value = getattr(self, name)
576
+ if value is not None:
577
+ payload[name] = int(value)
578
+ if self.cost_usd is not None:
579
+ payload["cost_usd"] = float(self.cost_usd)
580
+ return payload
581
+
582
+ @classmethod
583
+ def from_dict(cls, data: dict | None) -> Usage | None:
584
+ """A usage record, or `None` where a Run Log entry carries none."""
585
+ if not data:
586
+ return None
587
+ return cls(
588
+ provider=str(data.get("provider") or ""),
589
+ input_tokens=_number(data.get("input_tokens"), int),
590
+ output_tokens=_number(data.get("output_tokens"), int),
591
+ total_tokens=_number(data.get("total_tokens"), int),
592
+ cost_usd=_number(data.get("cost_usd"), float),
593
+ )
594
+
595
+
596
+ def _number(value: object, cast):
597
+ """A figure a Run Log carried, or `None` if it carried nothing usable.
598
+
599
+ A human edits Issue bodies, and a cost line that crashed a resumed Run
600
+ would make the measurement more expensive than the thing it measures.
601
+ """
602
+ if value is None or isinstance(value, bool):
603
+ return None
604
+ try:
605
+ return cast(value)
606
+ except (TypeError, ValueError):
607
+ return None
608
+
609
+
610
+ @dataclass(frozen=True)
611
+ class Finding:
612
+ """One thing an Agent found and did not fix.
613
+
614
+ Three fields rather than a sentence, because "potential injection risk" as
615
+ the whole message is what this shape exists to prevent: a human needs to
616
+ know where to look, what could go wrong there, and why that matters in this
617
+ repository rather than in general.
618
+
619
+ A finding is not an Escalation. The plan was executable and was executed;
620
+ this is something noticed on the way, and what a Gate does about it is the
621
+ Gate's business.
622
+ """
623
+
624
+ location: str
625
+ risk: str
626
+ rationale: str = ""
627
+
628
+ def to_dict(self) -> dict:
629
+ return {"location": self.location, "risk": self.risk, "rationale": self.rationale}
630
+
631
+ @classmethod
632
+ def from_dict(cls, data: dict) -> Finding:
633
+ return cls(
634
+ location=str(data.get("location") or ""),
635
+ risk=str(data.get("risk") or ""),
636
+ rationale=str(data.get("rationale") or ""),
637
+ )
638
+
639
+ @classmethod
640
+ def coerce(cls, value: object) -> Finding:
641
+ """A finding as a Role reported it, however it reported it.
642
+
643
+ A model asked for three fields sometimes answers with a sentence.
644
+ Dropping those would clear a Gate that should have blocked, so a bare
645
+ string becomes a finding with no location rather than no finding at all.
646
+ """
647
+ if isinstance(value, dict):
648
+ return cls.from_dict(value)
649
+ return cls(location="", risk=str(value).strip())
650
+
651
+
652
+ @dataclass(frozen=True)
653
+ class AgentResult:
654
+ """What one Agent invocation produced.
655
+
656
+ `summary` is the line a human reads in the Run Log. For an escalation it is
657
+ the reason the Plan could not be executed.
658
+
659
+ `findings` is what the Agent noticed and left for somebody else. Empty means
660
+ it looked and found nothing, which is why a Role that could not look at all
661
+ escalates instead: a Gate reading this cannot tell the two apart otherwise.
662
+
663
+ `usage` is what this invocation consumed, and it hangs here rather than on
664
+ the Run because that is the granularity a tiering decision is made at: a
665
+ Run's total says the Run was expensive, and only a per-Role figure says
666
+ which Role to move.
667
+ """
668
+
669
+ role: str
670
+ tier: ModelTier
671
+ outcome: Outcome
672
+ summary: str
673
+ detail: str = ""
674
+ files_changed: tuple[str, ...] = ()
675
+ findings: tuple[Finding, ...] = ()
676
+ usage: Usage | None = None
677
+
678
+ #: The adapter's full text output. Transport only — it carries the
679
+ #: Orchestrator's plan block out of a Provider invocation and gives a
680
+ #: failure something to show. Deliberately absent from `to_dict`, because
681
+ #: the Run Log is read by humans and re-parsed by later Runs, and a full
682
+ #: transcript in every comment would ruin both.
683
+ raw: str = field(default="", compare=False, repr=False)
684
+
685
+ @property
686
+ def escalated(self) -> bool:
687
+ return self.outcome is Outcome.ESCALATED
688
+
689
+ @property
690
+ def ok(self) -> bool:
691
+ return self.outcome is Outcome.COMPLETED
692
+
693
+ def to_dict(self) -> dict:
694
+ payload = {
695
+ "role": self.role,
696
+ "tier": str(self.tier),
697
+ "outcome": str(self.outcome),
698
+ "summary": self.summary,
699
+ "detail": self.detail,
700
+ "files_changed": list(self.files_changed),
701
+ }
702
+ # Written only when there are any: every Implementer result in the Run
703
+ # Log would otherwise carry an empty list saying it found nothing, which
704
+ # is not something the Implementer was asked.
705
+ if self.findings:
706
+ payload["findings"] = [finding.to_dict() for finding in self.findings]
707
+ # Same rule, for the same reason: a Provider that reported nothing
708
+ # writes no key, so a later reader can tell silence from a free Run.
709
+ if self.usage is not None and self.usage.to_dict():
710
+ payload["usage"] = self.usage.to_dict()
711
+ return payload
712
+
713
+ @classmethod
714
+ def from_dict(cls, data: dict) -> AgentResult:
715
+ return cls(
716
+ role=str(data["role"]),
717
+ tier=ModelTier(data["tier"]),
718
+ outcome=Outcome(data["outcome"]),
719
+ summary=str(data.get("summary") or ""),
720
+ detail=str(data.get("detail") or ""),
721
+ files_changed=tuple(data.get("files_changed") or ()),
722
+ findings=tuple(Finding.coerce(item) for item in data.get("findings") or ()),
723
+ usage=Usage.from_dict(data.get("usage")),
724
+ )
725
+
726
+
727
+ def retirement(
728
+ items: Sequence[T], done: Sequence[str], name_of: Callable[[T], str]
729
+ ) -> tuple[bool, ...]:
730
+ """Which items a completed result has already retired, in order.
731
+
732
+ One completed result retires one entry, so a sequence naming the same Role
733
+ twice resumes into the second occurrence rather than skipping both.
734
+
735
+ This is the rule; `outstanding` is the view of it the Roster and the Workflow
736
+ ask for. The runtime asks for the flags instead, because it walks every Step
737
+ — a Step behind the Run still has a Gate the Run has to pass through.
738
+ """
739
+ unclaimed = list(done)
740
+ flags = []
741
+ for item in items:
742
+ name = name_of(item)
743
+ retired = name in unclaimed
744
+ if retired:
745
+ unclaimed.remove(name)
746
+ flags.append(retired)
747
+ return tuple(flags)
748
+
749
+
750
+ def outstanding(
751
+ items: Sequence[T], done: Sequence[str], name_of: Callable[[T], str]
752
+ ) -> tuple[T, ...]:
753
+ """Items not yet retired by a completed result, in order.
754
+
755
+ Shared by the Roster and the Workflow because both ask the same question of
756
+ the same Run Log.
757
+ """
758
+ flags = retirement(items, done, name_of)
759
+ return tuple(item for item, retired in zip(items, flags, strict=True) if not retired)
760
+
761
+
762
+ def _drop_last(names: list[str], name: str) -> None:
763
+ """Remove the most recent occurrence, which is the one a Gate just judged."""
764
+ for index in range(len(names) - 1, -1, -1):
765
+ if names[index] == name:
766
+ del names[index]
767
+ return
768
+
769
+
770
+ @dataclass(frozen=True)
771
+ class RunState:
772
+ """One execution of one Roster against one Issue.
773
+
774
+ Every field is recoverable from the Issue alone — body for the Plan and the
775
+ Roster, comments for the results, labels for the status. That is ADR-0002's
776
+ claim, and `core.issues.run_state` is where it gets cashed.
777
+ """
778
+
779
+ issue: int
780
+ plan: Plan
781
+ roster: Roster
782
+ context: ContextPack = ContextPack()
783
+ results: tuple[AgentResult, ...] = ()
784
+ gates: tuple[GateEntry, ...] = ()
785
+ status: RunStatus = RunStatus.PLANNED
786
+ branch: str = ""
787
+ pull_request: str = ""
788
+ workflow: str = DEFAULT_WORKFLOW
789
+
790
+ @property
791
+ def done_roles(self) -> tuple[str, ...]:
792
+ """Roles that finished the job, in Run Log order.
793
+
794
+ An escalation is not done. A human corrects the plan block and runs
795
+ `agentforge implement` again, and the Role that escalated is the one
796
+ that has to run — so only completed results retire a Roster entry.
797
+
798
+ A Gate that blocked on a Role's output un-retires it again: the verdict
799
+ was drawn from work a human has since changed, and a Run that resumed
800
+ past it would re-read the same stale finding forever. The Gate entries
801
+ are counted rather than interleaved with the results, because a Gate's
802
+ verdict always trails the Step it judged — the last matching entry is
803
+ the one it was drawn from, and no cursor is needed to say so.
804
+ """
805
+ done = [result.role for result in self.results if result.ok]
806
+ for entry in self.gates:
807
+ if entry.blocked and entry.invalidates in done:
808
+ _drop_last(done, entry.invalidates)
809
+ return tuple(done)
810
+
811
+ @property
812
+ def remaining(self) -> tuple[Role, ...]:
813
+ """Roles that have not yet completed, in Roster order."""
814
+ return outstanding(tuple(self.roster), self.done_roles, lambda role: role.name)
815
+
816
+ @property
817
+ def current_step(self) -> int:
818
+ """The 1-based position of the Step the Run is on. Derived, never stored.
819
+
820
+ A cursor kept alongside the Run Log would be a second answer to a
821
+ question the Run Log already answers, and the two would disagree the
822
+ first time a human edited the Issue — so the count of retired Steps is
823
+ the only answer there is. A Role that escalated or failed did not retire
824
+ its Step, which is why a halted Run is still standing on the Step that
825
+ halted it, and why re-running resumes there.
826
+ """
827
+ return len(self.done_roles) + 1
828
+
829
+ @property
830
+ def escalation(self) -> AgentResult | None:
831
+ """The Escalation that stopped this Run, if one did.
832
+
833
+ The last entry rather than the first: the Run Log keeps every attempt,
834
+ and a Role that escalated, had its plan block corrected, and then
835
+ completed did not stop anything.
836
+ """
837
+ last = self.results[-1] if self.results else None
838
+ return last if last is not None and last.escalated else None
839
+
840
+
841
+ @dataclass(frozen=True)
842
+ class PlanDocument:
843
+ """The machine-readable half of an Issue body: everything an Agent needs.
844
+
845
+ Kept separate from `RunState` because this is what gets written once and
846
+ frozen, while a Run's results accumulate around it.
847
+ """
848
+
849
+ plan: Plan
850
+ roster: Roster
851
+ context: ContextPack = ContextPack()
852
+ version: int = PLAN_FORMAT_VERSION
853
+ notes: tuple[str, ...] = field(default=())
854
+ workflow: str = DEFAULT_WORKFLOW
855
+
856
+ def to_dict(self) -> dict:
857
+ return {
858
+ "version": self.version,
859
+ "plan": self.plan.to_dict(),
860
+ "roster": self.roster.to_dict(),
861
+ "context": self.context.to_dict(),
862
+ "notes": list(self.notes),
863
+ "workflow": self.workflow,
864
+ }
865
+
866
+ @classmethod
867
+ def from_dict(cls, data: dict, resolve) -> PlanDocument:
868
+ return cls(
869
+ plan=Plan.from_dict(data["plan"]),
870
+ roster=Roster.from_dict(data.get("roster") or [], resolve),
871
+ context=ContextPack.from_dict(data.get("context")),
872
+ version=int(data.get("version", PLAN_FORMAT_VERSION)),
873
+ notes=tuple(data.get("notes") or ()),
874
+ workflow=str(data.get("workflow") or DEFAULT_WORKFLOW),
875
+ )