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,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"]