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,588 @@
1
+ """The Orchestrator: the only Role that reasons.
2
+
3
+ It turns a Task into an Issue. It resolves what it needs about the project,
4
+ chooses a Roster, and writes a Plan detailed enough to execute without further
5
+ interpretation. Everything downstream executes; nothing downstream re-plans
6
+ (ADR-0003), which is why this Role runs at the `deep` tier and why its output
7
+ quality is the ceiling on the system's.
8
+
9
+ Three of its behaviors are load-bearing and all three are tested:
10
+
11
+ - **The interview.** A half-formed Task is pushed on while the human is still
12
+ at the keyboard, because ADR-0003 freezes the plan the moment it is filed and
13
+ this is the last cheap moment to ask anything.
14
+ - **Roster selection** clamps whatever the model asked for down to the Roles
15
+ that exist, and records what it dropped where a human will see it.
16
+ - **Ambiguity escalates.** A Task the Orchestrator cannot plan confidently stops
17
+ here rather than being guessed at.
18
+
19
+ The interview is rounds of one-shot invocations rather than a conversation.
20
+ ADR-0001 gives the Provider port no session and no history, so each round is
21
+ handed the transcript so far and answers with the questions it still has. That
22
+ is also what makes the interview testable without a CLI installed: it is the
23
+ same port, invoked more than once.
24
+
25
+ Nothing interactive attached means no interview. A Run in CI has nobody to ask,
26
+ and a planner that blocked on input that will never arrive would hang rather
27
+ than degrade.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Callable, Sequence
33
+ from dataclasses import dataclass, replace
34
+ from pathlib import Path
35
+ from typing import TYPE_CHECKING
36
+
37
+ from ..core.contracts import (
38
+ DEFAULT_WORKFLOW,
39
+ AgentResult,
40
+ ContextPack,
41
+ ModelTier,
42
+ Outcome,
43
+ Plan,
44
+ PlanDocument,
45
+ Role,
46
+ Roster,
47
+ Task,
48
+ )
49
+
50
+ if TYPE_CHECKING: # `core.workflow` imports this package, so the cycle stays deferred.
51
+ from ..core.workflow import Workflow
52
+ from ..core.plan_format import (
53
+ PLAN_CLOSE,
54
+ PLAN_OPEN,
55
+ RESULT_CLOSE,
56
+ RESULT_OPEN,
57
+ PlanFormatError,
58
+ extract_plan_payload,
59
+ extract_result_block,
60
+ )
61
+
62
+ #: How the Orchestrator reaches the human: one question in, one answer out.
63
+ #: `None` back ends the interview, and the Orchestrator plans with what it has —
64
+ #: a Task that was already clear should not cost a conversation.
65
+ Interviewer = Callable[[str], str | None]
66
+
67
+ #: Rounds of questions before the Orchestrator plans with what it has. The human
68
+ #: can stop sooner and the Orchestrator can declare itself ready sooner; this is
69
+ #: only the backstop against a model that always has one more question, and each
70
+ #: round it does not ask is a `deep` invocation nobody pays for.
71
+ MAX_ROUNDS = 3
72
+
73
+ #: What the interview adds for its own invocations. `grill-with-docs` is the
74
+ #: interview and the writing-down as one job; it has nothing to say to a
75
+ #: planning pass with nobody in the room, so it is declared per invocation
76
+ #: rather than on the Role. It travels the Capability Tier path like every other
77
+ #: skill (ADR-0005), and expands into the two it composes wherever a Provider
78
+ #: cannot invoke a skill by name.
79
+ INTERVIEW_SKILLS = ("grill-with-docs",)
80
+
81
+ INSTRUCTIONS = """\
82
+ You are the Orchestrator in AgentForge. You reason once, and everything after \
83
+ you executes without re-planning.
84
+
85
+ Your output is a frozen execution contract. The Roles that read it will not see \
86
+ the human's original wording, will not re-scope the work, and cannot ask you a \
87
+ question. A step that needs interpreting will be interpreted differently by \
88
+ each Role that reads it.
89
+
90
+ Write a plan that names files, states what changes in each, and says how each \
91
+ step is known to be done.\
92
+ """
93
+
94
+ PROMPT = """\
95
+ {instructions}
96
+
97
+ ## The Task
98
+
99
+ {task}
100
+ {interview}
101
+ ## The repository
102
+
103
+ You are running in {cwd}. Read whatever you need in order to plan accurately -- \
104
+ existing structure, conventions, tests, and any CONTEXT.md, AGENTS.md, or \
105
+ docs/adr/ the project keeps. Do not change any files. This is a planning pass.
106
+
107
+ ## Workflows you may choose
108
+
109
+ {workflows}
110
+
111
+ Name one in the plan block. It decides which Roles run and in what order, so the
112
+ Roster you write is that Workflow's Roles -- choose the Workflow that fits the
113
+ Task rather than assembling a Roster of your own. You may still move a Role to a
114
+ different Model Tier.
115
+
116
+ ## Roles the Workflows draw on
117
+
118
+ {roles}
119
+
120
+ ## What you do not do
121
+
122
+ You have no issue tracker and no triage labels. AgentForge files exactly one \
123
+ Issue from the block you write below, under its own label, through its own \
124
+ GitHub boundary. Do not publish anything, do not open an issue, and do not \
125
+ apply a label -- a second Issue filed from inside a planning pass is one nobody \
126
+ is tracking.
127
+
128
+ ## Required output
129
+
130
+ End your reply with these two blocks, in this order, and nothing after them.
131
+
132
+ Write acceptance criteria a Role can check by reading the repository. Agents \
133
+ run no commands unless a human opens that gate for a Run (ADR-0007), so a \
134
+ criterion phrased as "run the suite and paste the output" is one the executing \
135
+ Role will have to report it could not verify. If a step genuinely cannot be \
136
+ judged without running something, say so in the criterion itself rather than \
137
+ assuming it will be run.
138
+
139
+ First the plan:
140
+
141
+ {plan_open}
142
+ ```json
143
+ {{
144
+ "version": 1,
145
+ "plan": {{
146
+ "summary": "one paragraph a human can judge the work by",
147
+ "steps": [
148
+ {{
149
+ "id": "s1",
150
+ "intent": "what changes and why",
151
+ "files": ["path/one.py"],
152
+ "acceptance": "how this step is known to be done, checkable by reading the repository"
153
+ }}
154
+ ],
155
+ "constraints": ["anything the executing Role must not do"]
156
+ }},
157
+ "workflow": "feature",
158
+ "roster": [{{"role": "implementer", "tier": "standard"}}],
159
+ "context": {{
160
+ "files": ["files a Role must read"],
161
+ "symbols": ["functions or classes the work touches"],
162
+ "conventions": ["project conventions the work must follow"]
163
+ }}
164
+ }}
165
+ ```
166
+ {plan_close}
167
+
168
+ Then your own verdict:
169
+
170
+ {result_open}
171
+ ```json
172
+ {{"outcome": "completed", "summary": "one line describing the plan you wrote"}}
173
+ ```
174
+ {result_close}
175
+
176
+ If the Task is too ambiguous to plan without guessing, write the verdict block \
177
+ with `"outcome": "escalated"` and a summary naming exactly what you need the \
178
+ human to decide. Omit the plan block in that case. The human is still at the \
179
+ keyboard right now; that will not be true when this plan is executed.\
180
+ """
181
+
182
+ INTERVIEW = """\
183
+ You are the Orchestrator in AgentForge, interviewing the human who typed this \
184
+ Task before you write anything down.
185
+
186
+ Ask only about what would change the plan. A question whose answer you could \
187
+ find by reading the repository is a question you should not be asking, and one \
188
+ whose answer would not change a single step is worse.
189
+
190
+ ## The Task
191
+
192
+ {task}
193
+
194
+ ## The repository
195
+
196
+ You are running in {cwd}. Read whatever you need.
197
+
198
+ {glossary}
199
+
200
+ ## What you have asked so far
201
+
202
+ {transcript}
203
+
204
+ ## Required output
205
+
206
+ End your reply with this block and nothing after it:
207
+
208
+ {result_open}
209
+ ```json
210
+ {{
211
+ "outcome": "completed",
212
+ "summary": "one line on what is still unclear, or that nothing is",
213
+ "questions": ["one question per entry, in the order you want them asked"]
214
+ }}
215
+ ```
216
+ {result_close}
217
+
218
+ An empty `questions` list means you have enough to plan. Say that as soon as it \
219
+ is true: this is the last cheap moment to ask, and it is also the human's time.\
220
+ """
221
+
222
+ GLOSSARY_PRESENT = """\
223
+ This project keeps a glossary at `{path}`. Resolve the Task's terms against it \
224
+ rather than against ordinary usage, and when the human settles a term that is \
225
+ not in it, record the decision there in the format the file already uses. \
226
+ Somebody asking the same question next week should find the answer instead of \
227
+ you. Change nothing else in the repository -- this is not the work.\
228
+ """
229
+
230
+ GLOSSARY_ABSENT = """\
231
+ This project keeps no glossary. Resolve the Task's terms against how the \
232
+ repository itself uses them, and do not start a glossary during an interview.\
233
+ """
234
+
235
+ #: The Orchestrator runs at `deep`: it pays for all downstream reasoning once.
236
+ #: `domain-modeling` is standing equipment rather than interview-only: resolving
237
+ #: a Task's terms against the project's own vocabulary is what it does whether
238
+ #: or not anybody is in the room.
239
+ ORCHESTRATOR = Role(
240
+ name="orchestrator",
241
+ tier=ModelTier.DEEP,
242
+ instructions=INSTRUCTIONS,
243
+ #: What a planning pass works with: the project's vocabulary, the synthesis
244
+ #: of a conversation into a spec, and the breakdown of that spec into work.
245
+ #: Both `to-` skills end by publishing to a tracker and labelling what they
246
+ #: filed, which is AgentForge's job — the prompt says so, and ADR-0007's
247
+ #: default-deny means a planning pass cannot reach `gh` to do it anyway.
248
+ skills=("domain-modeling", "to-spec", "to-tickets"),
249
+ )
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class Exchange:
254
+ """One question the Orchestrator asked and what came back."""
255
+
256
+ question: str
257
+ answer: str
258
+
259
+
260
+ def render_transcript(exchanges: Sequence[Exchange]) -> str:
261
+ """The interview so far, as the next round is handed it."""
262
+ if not exchanges:
263
+ return "_Nothing yet. This is the first round._"
264
+ return "\n\n".join(
265
+ f"**You asked:** {e.question.strip()}\n**They answered:** {e.answer.strip()}"
266
+ for e in exchanges
267
+ )
268
+
269
+
270
+ def glossary_section(cwd: Path) -> str:
271
+ """What to tell the interview about the project's vocabulary.
272
+
273
+ The path rather than the text: the Agent is already in the repository and
274
+ can read it, and a glossary inlined into every round is paid for in every
275
+ round.
276
+ """
277
+ path = Path(cwd) / "CONTEXT.md"
278
+ if path.is_file():
279
+ return GLOSSARY_PRESENT.format(path="CONTEXT.md")
280
+ return GLOSSARY_ABSENT
281
+
282
+
283
+ @dataclass(frozen=True)
284
+ class Planned:
285
+ """What a planning pass produced: a document, or a reason there is none."""
286
+
287
+ result: AgentResult
288
+ document: PlanDocument | None = None
289
+ #: The interview behind the plan, empty when there was nobody to interview.
290
+ #: Kept so the caller can say what it cost and what was asked.
291
+ interview: tuple[Exchange, ...] = ()
292
+
293
+ @property
294
+ def escalated(self) -> bool:
295
+ return self.document is None
296
+
297
+
298
+ class Orchestrator:
299
+ """Runs one planning pass and hands back something fileable."""
300
+
301
+ def __init__(self, provider, tier: ModelTier | None = None) -> None:
302
+ self.provider = provider
303
+ self.tier = tier or ORCHESTRATOR.tier
304
+
305
+ def interview(
306
+ self, task: Task, cwd: Path, interviewer: Interviewer
307
+ ) -> tuple[Exchange, ...]:
308
+ """Ask until there is nothing worth asking, the human stops, or the cap.
309
+
310
+ Each round is one invocation handed the whole transcript, because the
311
+ port has no memory (ADR-0001). A round that comes back with no questions
312
+ ends the interview: the Orchestrator saying it has enough is the outcome
313
+ this is for, not a fallback.
314
+
315
+ A round that fails to answer in the required shape also ends it. The
316
+ planning pass is what has to work, and an interview that cannot be
317
+ parsed is a reason to stop asking rather than a reason to stop.
318
+ """
319
+ role = replace(ORCHESTRATOR.at_tier(self.tier), skills=INTERVIEW_SKILLS)
320
+ exchanges: list[Exchange] = []
321
+
322
+ for _ in range(MAX_ROUNDS):
323
+ result = self.provider.invoke(
324
+ role=role,
325
+ prompt=INTERVIEW.format(
326
+ task=task.statement.strip(),
327
+ cwd=cwd,
328
+ glossary=glossary_section(cwd),
329
+ transcript=render_transcript(exchanges),
330
+ result_open=RESULT_OPEN,
331
+ result_close=RESULT_CLOSE,
332
+ ),
333
+ context=ContextPack(),
334
+ tier=self.tier,
335
+ cwd=cwd,
336
+ )
337
+
338
+ questions = _questions(result)
339
+ if not questions:
340
+ break
341
+
342
+ for question in questions:
343
+ answer = interviewer(question)
344
+ if answer is None:
345
+ # The human ended it. What they have already answered still
346
+ # counts; a plan is better for three answers than for none.
347
+ return tuple(exchanges)
348
+ exchanges.append(Exchange(question=question, answer=answer))
349
+
350
+ return tuple(exchanges)
351
+
352
+ def build_prompt(
353
+ self, task: Task, cwd: Path, exchanges: Sequence[Exchange] = ()
354
+ ) -> str:
355
+ from ..core.workflow import available_workflows
356
+ from . import KNOWN_TIERS, ROLES
357
+
358
+ definitions = "\n".join(
359
+ f"- `{workflow.name}`: " + ", ".join(step.role for step in workflow.steps)
360
+ for workflow in available_workflows()
361
+ if workflow.steps
362
+ )
363
+
364
+ available = "\n".join(
365
+ f"- `{name}` (default tier `{ROLES[name].tier}`)"
366
+ for name in sorted(ROLES)
367
+ if name != ORCHESTRATOR.name
368
+ )
369
+ deferred = sorted(set(KNOWN_TIERS) - set(ROLES))
370
+ if deferred:
371
+ available += (
372
+ "\n\nNot yet implemented, so do not put them in the Roster: "
373
+ + ", ".join(f"`{name}`" for name in deferred)
374
+ + "."
375
+ )
376
+
377
+ interview = ""
378
+ if exchanges:
379
+ interview = (
380
+ "\n## What the human told you when you asked\n\n"
381
+ + render_transcript(exchanges)
382
+ + "\n\nThese answers are the Task now. The Roles downstream will not see "
383
+ "them, so anything here that changes a step belongs in the step.\n"
384
+ )
385
+
386
+ return PROMPT.format(
387
+ instructions=ORCHESTRATOR.instructions,
388
+ task=task.statement.strip(),
389
+ interview=interview,
390
+ cwd=cwd,
391
+ workflows=definitions,
392
+ roles=available,
393
+ plan_open=PLAN_OPEN,
394
+ plan_close=PLAN_CLOSE,
395
+ result_open=RESULT_OPEN,
396
+ result_close=RESULT_CLOSE,
397
+ )
398
+
399
+ def plan(
400
+ self, task: Task, cwd: Path, interviewer: Interviewer | None = None
401
+ ) -> Planned:
402
+ """Interview if there is anybody to interview, then plan once.
403
+
404
+ No interviewer is the single-shot path, unchanged: a scheduled Run has
405
+ nobody at the keyboard, and blocking on input that will never arrive is
406
+ the one failure mode worse than planning from an underspecified Task.
407
+ """
408
+ exchanges = self.interview(task, cwd, interviewer) if interviewer else ()
409
+
410
+ role = ORCHESTRATOR.at_tier(self.tier)
411
+ result = self.provider.invoke(
412
+ role=role,
413
+ prompt=self.build_prompt(task, cwd, exchanges),
414
+ context=ContextPack(),
415
+ tier=self.tier,
416
+ cwd=cwd,
417
+ )
418
+
419
+ if result.outcome is not Outcome.COMPLETED:
420
+ return Planned(result=result, interview=exchanges)
421
+
422
+ try:
423
+ document = build_document(result.raw)
424
+ except PlanFormatError as exc:
425
+ return Planned(
426
+ result=AgentResult(
427
+ role=role.name,
428
+ tier=self.tier,
429
+ outcome=Outcome.FAILED,
430
+ summary=f"the Orchestrator reported success but wrote no usable plan: {exc}",
431
+ detail=result.raw,
432
+ raw=result.raw,
433
+ ),
434
+ interview=exchanges,
435
+ )
436
+
437
+ return Planned(result=result, document=document, interview=exchanges)
438
+
439
+
440
+ def _questions(result: AgentResult) -> tuple[str, ...]:
441
+ """The questions one interview round came back with.
442
+
443
+ Read out of the result block rather than out of a marker of its own. An
444
+ interview never reaches an Issue, so this is a prompt convention rather than
445
+ a compatibility surface, and a fourth marker would have to be maintained
446
+ like one.
447
+ """
448
+ if result.outcome is not Outcome.COMPLETED:
449
+ return ()
450
+ payload = extract_result_block(result.raw) or {}
451
+ asked = payload.get("questions")
452
+ if not isinstance(asked, list):
453
+ return ()
454
+ return tuple(str(q).strip() for q in asked if str(q).strip())
455
+
456
+
457
+ def build_document(text: str) -> PlanDocument:
458
+ """Turn an Orchestrator's raw output into the document that gets filed."""
459
+ payload = extract_plan_payload(text)
460
+ plan = Plan.from_dict(payload["plan"])
461
+ workflow, workflow_notes = select_workflow(payload.get("workflow"))
462
+ requested, roster_notes = select_roster(payload.get("roster") or [])
463
+ roster, aligned_notes = align_to_workflow(requested, workflow)
464
+ context = ContextPack.from_dict(payload.get("context"))
465
+ return PlanDocument(
466
+ plan=plan,
467
+ roster=roster,
468
+ context=context,
469
+ workflow=workflow.name,
470
+ notes=workflow_notes + roster_notes + aligned_notes,
471
+ )
472
+
473
+
474
+ def select_workflow(requested) -> tuple[Workflow, tuple[str, ...]]:
475
+ """The Workflow named in the plan block, or the default with a note.
476
+
477
+ Validated here rather than at `implement` time. An Issue naming a Workflow
478
+ nobody has is an Issue that fails a week later on somebody else's machine,
479
+ and the human who could have corrected it is at the keyboard now.
480
+ """
481
+ from ..core.workflow import WorkflowError, available_workflows, load_workflow
482
+
483
+ name = str(requested or "").strip().lower()
484
+ if not name:
485
+ return load_workflow(DEFAULT_WORKFLOW), ()
486
+
487
+ try:
488
+ return load_workflow(name), ()
489
+ except WorkflowError:
490
+ available = ", ".join(w.name for w in available_workflows())
491
+ note = (
492
+ f"The Orchestrator named the `{name}` Workflow, which does not exist "
493
+ f"({available}). The Issue runs `{DEFAULT_WORKFLOW}` instead."
494
+ )
495
+ return load_workflow(DEFAULT_WORKFLOW), (note,)
496
+
497
+
498
+ def align_to_workflow(roster: Roster, workflow: Workflow) -> tuple[Roster, tuple[str, ...]]:
499
+ """The Roles that will actually run, in the order the Workflow runs them.
500
+
501
+ The runtime walks the Workflow, not the Roster, so a Roster that disagreed
502
+ with it would be a promise the Run does not keep — and the Roster table is
503
+ what a human reads to find out who is about to touch their repository.
504
+
505
+ Tiers survive the alignment: choosing the Workflow is the Orchestrator's
506
+ judgement about the shape of the Task, and moving a Role up a tier is its
507
+ judgement about the difficulty of this one. The Roster written here is the
508
+ one the runtime resolves each Step's tier from, so that judgement is what
509
+ runs and the table is what a human can rely on. See ADR-0014.
510
+ """
511
+ from . import resolve_role
512
+
513
+ tiers = roster.tiers()
514
+ roles = []
515
+ for step in workflow.steps:
516
+ role = resolve_role(step.role)
517
+ tier = step.tier or tiers.get(role.name)
518
+ roles.append(role.at_tier(tier) if tier else role)
519
+
520
+ dropped = sorted(set(tiers) - {step.role for step in workflow.steps})
521
+ notes = tuple(
522
+ f"The Orchestrator asked for the `{name}` Role, which the "
523
+ f"`{workflow.name}` Workflow does not run. It is not in the Roster."
524
+ for name in dropped
525
+ )
526
+ return Roster(tuple(roles)), notes
527
+
528
+
529
+ def select_roster(requested) -> tuple[Roster, tuple[str, ...]]:
530
+ """Clamp a requested Roster to the Roles that exist.
531
+
532
+ A model asked to plan a schema migration will reach for a Tester and a
533
+ Security Role, and it is right to. Dropping them silently would leave a
534
+ human reading the Issue believing work is scheduled that never runs, so
535
+ every drop becomes a note in the Issue body.
536
+
537
+ The Implementer is appended when nothing executable survives: the pipe is
538
+ what M1 is proving, and an Issue nobody can implement proves nothing.
539
+ """
540
+ from . import IMPLEMENTER, KNOWN_TIERS, ROLES
541
+
542
+ roles: list[Role] = []
543
+ notes: list[str] = []
544
+ seen: set[str] = set()
545
+
546
+ for entry in requested:
547
+ name = str(entry.get("role", "")).strip().lower() if isinstance(entry, dict) else str(entry)
548
+ if not name or name == ORCHESTRATOR.name:
549
+ continue
550
+
551
+ if name not in ROLES:
552
+ if name in KNOWN_TIERS:
553
+ notes.append(
554
+ f"The Orchestrator asked for the `{name}` Role, which has a declared "
555
+ "tier but is not implemented yet. It was dropped from the Roster."
556
+ )
557
+ else:
558
+ notes.append(f"Unknown Role `{name}` requested by the Orchestrator; dropped.")
559
+ continue
560
+
561
+ if name in seen:
562
+ continue
563
+ seen.add(name)
564
+
565
+ role = ROLES[name]
566
+ tier = entry.get("tier") if isinstance(entry, dict) else None
567
+ roles.append(role.at_tier(ModelTier(tier)) if tier else role)
568
+
569
+ if not roles:
570
+ notes.append(
571
+ "No implemented Role survived Roster selection, so the Implementer was added. "
572
+ "M1 runs a single Role end to end."
573
+ )
574
+ roles.append(IMPLEMENTER)
575
+
576
+ return Roster(tuple(roles)), tuple(notes)
577
+
578
+
579
+ __all__ = [
580
+ "INSTRUCTIONS",
581
+ "ORCHESTRATOR",
582
+ "Orchestrator",
583
+ "Planned",
584
+ "align_to_workflow",
585
+ "build_document",
586
+ "select_roster",
587
+ "select_workflow",
588
+ ]