ww-agentic-workflows 1.0.0.dev3__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 (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/plan/models.py ADDED
@@ -0,0 +1,665 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Immutable plan data and serialization."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, replace
7
+ from typing import TypeVar, get_args
8
+
9
+ from ww.actions import Commands, PlannedAction, actions
10
+ from ww.contracts import (
11
+ CheckSource,
12
+ ChildOperation,
13
+ ExecutionKind,
14
+ ItemAssignment,
15
+ ItemOperation,
16
+ LoopAssignment,
17
+ PlanItemKind,
18
+ PlanItemOwner,
19
+ PlanItemPhase,
20
+ StepRole,
21
+ )
22
+ from ww.operations import LoopBoundary, PlanOperation, encode_operation
23
+ from ww.validation import is_positive_int
24
+ from ww.workflow_config import (
25
+ ChoiceDefinition,
26
+ DocumentDefinition,
27
+ DocumentUpdate,
28
+ ItemFieldUpdate,
29
+ ProvidedVariable,
30
+ RuleHints,
31
+ SavedMetadata,
32
+ )
33
+ from ww.workspace import WORKDIRS, Workdir
34
+
35
+ PayloadT = TypeVar("PayloadT")
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class PlannedMode:
40
+ """A mode frozen into the plan for one agent step.
41
+
42
+ ``automatic`` marks a mode the step gets from the mode's own filters
43
+ rather than from the run's selected modes. The description is frozen so
44
+ a run keeps delivering the guidance it started with.
45
+ """
46
+
47
+ name: str
48
+ description: tuple[str, ...] = ()
49
+ automatic: bool = False
50
+
51
+ def to_dict(self) -> dict[str, object]:
52
+ return {
53
+ "name": self.name,
54
+ "description": list(self.description),
55
+ "automatic": self.automatic,
56
+ }
57
+
58
+ @classmethod
59
+ def from_dict(cls, value: object, path: str) -> PlannedMode:
60
+ """Read a mode written by :meth:`to_dict`, rejecting any other shape."""
61
+ keys = {"name", "description", "automatic"}
62
+ if not isinstance(value, dict) or set(value) != keys:
63
+ raise ValueError(
64
+ f"{path} must be an object of name, description, automatic"
65
+ )
66
+ name, description, automatic = (
67
+ value["name"],
68
+ value["description"],
69
+ value["automatic"],
70
+ )
71
+ if (
72
+ not isinstance(name, str)
73
+ or not isinstance(description, list)
74
+ or not all(isinstance(line, str) for line in description)
75
+ or not isinstance(automatic, bool)
76
+ ):
77
+ raise ValueError(f"{path} has an invalid name, description or automatic")
78
+ return cls(name, tuple(description), automatic)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class PlannedRule:
83
+ """A rule frozen into the plan for one agent step.
84
+
85
+ The text and its hash are frozen so a run keeps delivering the wording it
86
+ started with; ``has_command`` says whether ww checks it mechanically.
87
+ ``source`` is the rule file, relative to the project root when it lies
88
+ inside it, or ``None`` for a rule written in the step's YAML.
89
+ """
90
+
91
+ id: str
92
+ summary: str
93
+ text: str
94
+ text_hash: str
95
+ paths: tuple[str, ...] = ()
96
+ has_command: bool = False
97
+ max_fixes: int = 1
98
+ hints: RuleHints = RuleHints()
99
+ source: str | None = None
100
+
101
+ def to_dict(self) -> dict[str, object]:
102
+ data: dict[str, object] = {
103
+ "id": self.id,
104
+ "summary": self.summary,
105
+ "text": self.text,
106
+ "text_hash": self.text_hash,
107
+ "paths": list(self.paths),
108
+ "has_command": self.has_command,
109
+ "max_fixes": self.max_fixes,
110
+ }
111
+ if self.hints.to_dict():
112
+ data["hints"] = self.hints.to_dict()
113
+ if self.source is not None:
114
+ data["source"] = self.source
115
+ return data
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class PlannedCheck:
120
+ """A command ww runs when the step completes; a failure sends it back.
121
+
122
+ ``command`` is already planned like any cli handler's: build-time values
123
+ are substituted and runtime ones are left for execution. ``summary`` is
124
+ the rule's first sentence, or the hook's handler name.
125
+
126
+ A ``derived`` check is one the operator approved into the rule-automation
127
+ store; it is never compiled into a plan but resolved when the step
128
+ begins, and ``covers`` names the rules of the step it checks, so a check
129
+ shared by several rules runs once.
130
+ """
131
+
132
+ id: str
133
+ source: CheckSource
134
+ summary: str
135
+ command: Commands
136
+ paths: tuple[str, ...] = ()
137
+ max_fixes: int = 1
138
+ covers: tuple[str, ...] = ()
139
+ on_failure_instruction: str | None = None
140
+
141
+ def __post_init__(self) -> None:
142
+ if self.source not in {"rule", "hook", "derived"}:
143
+ raise ValueError(f"invalid check source: {self.source!r}")
144
+ if not is_positive_int(self.max_fixes):
145
+ raise ValueError("check max_fixes must be a positive integer")
146
+ if bool(self.covers) != (self.source == "derived"):
147
+ raise ValueError("exactly a derived check names the rules it covers")
148
+
149
+ def to_dict(self) -> dict[str, object]:
150
+ data: dict[str, object] = {
151
+ "id": self.id,
152
+ "source": self.source,
153
+ "summary": self.summary,
154
+ "command": actions.get("cli").encode(self.command),
155
+ "paths": list(self.paths),
156
+ "max_fixes": self.max_fixes,
157
+ }
158
+ if self.covers:
159
+ data["covers"] = list(self.covers)
160
+ if self.on_failure_instruction is not None:
161
+ data["on_failure_instruction"] = self.on_failure_instruction
162
+ return data
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class VerificationTarget:
167
+ """What a ww-generated verification item verifies.
168
+
169
+ The item judges, or proposes a check for, the rules without a command of
170
+ the agent item ``item_id``. Each distinct set of worker hints among those
171
+ rules gets its own verification item; ``ordinal`` numbers them in the
172
+ order ww created them and ``hints`` is the set this one runs with.
173
+ """
174
+
175
+ item_id: str
176
+ ordinal: int
177
+ hints: RuleHints = RuleHints()
178
+
179
+ def __post_init__(self) -> None:
180
+ if not is_positive_int(self.ordinal):
181
+ raise ValueError("verification ordinal must be a positive integer")
182
+
183
+ def to_dict(self) -> dict[str, object]:
184
+ data: dict[str, object] = {"item_id": self.item_id, "ordinal": self.ordinal}
185
+ if self.hints.to_dict():
186
+ data["hints"] = self.hints.to_dict()
187
+ return data
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class PlanItem:
192
+ id: str
193
+ position: int
194
+ name: str
195
+ description: str
196
+ operation: PlanOperation
197
+ owner: PlanItemOwner
198
+ execution: ExecutionKind
199
+ requires_agent_input: bool
200
+ workflow: str
201
+ step: str
202
+ parent: str | None
203
+ phase: PlanItemPhase
204
+ source: str
205
+ registered_handler: str | None
206
+ provide: tuple[ProvidedVariable, ...] = ()
207
+ save_metadata: tuple[SavedMetadata, ...] = ()
208
+ # Documents this agent-owned item creates or edits in place.
209
+ update_document: tuple[DocumentUpdate, ...] = ()
210
+ # Custom item fields this agent-owned item must set on its item, or on
211
+ # every item when it is the collection.
212
+ update_item: tuple[ItemFieldUpdate, ...] = ()
213
+ outputs: tuple[str, ...] = ()
214
+ dependencies: tuple[str, ...] = ()
215
+ requested_agent: str | None = None
216
+ requested_model: str | None = None
217
+ requested_reasoning: str | None = None
218
+ # Who performs the item: the manager in its own session, or a worker.
219
+ role: StepRole = "worker"
220
+ # ``False``: its performer spawns no subagents for anything.
221
+ subagents: bool = True
222
+ # A conversation with the operator; performed by the session that can
223
+ # talk to them, so ``role`` is ``manager`` as well.
224
+ interactive: bool = False
225
+ explicit: bool = False
226
+ learnable: bool = False
227
+ choices: tuple[ChoiceDefinition, ...] = ()
228
+ # A per-item stage the operator answers on the operator page.
229
+ ui: bool = False
230
+ model: str | None = None
231
+ reasoning: str | None = None
232
+ profile: str | None = None
233
+ profile_instruction: str | None = None
234
+ # A project-local profile file, relative to the project root; the
235
+ # instruction prints it absolute for the current filesystem.
236
+ profile_path: str | None = None
237
+ # The directory this item works in: the task workspace, the project's
238
+ # own directory, or the project root.
239
+ workdir: Workdir = "task"
240
+ summary: bool = False
241
+ item_operation: ItemOperation | None = None
242
+ item_template: bool = False
243
+ item_id: str | None = None
244
+ # The stable identity of the ``items`` declaration this item belongs to: its
245
+ # logical step path. Carried by the collection item and by every per-item
246
+ # stage and hook (templates and their concrete copies), and by nothing
247
+ # else. It is plan data, independent of display names and work-item IDs,
248
+ # so a later pass can expand exactly its own templates. Not
249
+ # ``child_stage``, which belongs to ``children``.
250
+ item_pass: str | None = None
251
+ # On a collection item: this pass only collects or reconciles items and
252
+ # has no per-item stages (explicit ``items: {steps: []}``).
253
+ item_collect_only: bool = False
254
+ item_assignment: ItemAssignment = "per_step"
255
+ # Set on every body step and hook of the nearest enclosing ``loop``.
256
+ loop_id: str | None = None
257
+ loop_assignment: LoopAssignment | None = None
258
+ split_instruction: str | None = None
259
+ # On a collection item: the items outlive the run and are reconciled.
260
+ shared_items: bool = False
261
+ # On a collection item: the field a new item must carry, and the fields
262
+ # whose values may each appear once across all items.
263
+ item_identity: str | None = None
264
+ item_unique: tuple[str, ...] = ()
265
+ artifact: bool = True
266
+ child_operation: ChildOperation | None = None
267
+ # On a children collection: each child binds its own external ID through
268
+ # the first step of the child workflow, so ``add-child`` takes no ``--id``.
269
+ child_identity: bool = False
270
+ # On every per-child stage (``children.steps``) and its hooks: the path
271
+ # of the ``children`` step. Its templates carry ``item_template`` until
272
+ # the children are collected.
273
+ child_stage: str | None = None
274
+ # On a concrete per-child stage: the one-based position of its child in
275
+ # the run's children, which are append-only and never reordered.
276
+ child_number: int | None = None
277
+ # Ordered logical container paths above ``parent``. Hierarchy is explicit
278
+ # plan data; consumers must not reconstruct it by parsing ``step``.
279
+ ancestors: tuple[str, ...] = ()
280
+ # One-based declaration ordinal for every path in ``(*ancestors, step)``.
281
+ # Artifact storage uses these values to produce stable, ordered paths.
282
+ step_ordinals: tuple[int, ...] = ()
283
+ artifact_dependency: str | None = None
284
+ loop_break: str | None = None
285
+ loop_continue: str | None = None
286
+ assessment_question: str | None = None
287
+ assessment_outcomes: tuple[str, ...] = ()
288
+ # The outcomes that end the workflow; they emit no items of their own.
289
+ assessment_stops: tuple[str, ...] = ()
290
+ assessment_parent: str | None = None
291
+ assessment_outcome: str | None = None
292
+ # Rules delivered on this agent step's page, and the checks ww runs when
293
+ # it completes.
294
+ rules: tuple[PlannedRule, ...] = ()
295
+ checks: tuple[PlannedCheck, ...] = ()
296
+ # The modes delivered on this agent step's page: the run's selected
297
+ # modes, then the automatic modes whose filters admit the step.
298
+ modes: tuple[PlannedMode, ...] = ()
299
+ # Set on a verification item ww inserts before an agent step whose rules
300
+ # without a command need a verifier; never compiled from configuration.
301
+ verifies: VerificationTarget | None = None
302
+ on_failure: str = "operator"
303
+ on_failure_instruction: str | None = None
304
+ max_handler_fixes: int = 3
305
+
306
+ @property
307
+ def kind(self) -> PlanItemKind:
308
+ return self.operation.kind
309
+
310
+ @property
311
+ def breaks_children(self) -> bool:
312
+ """Whether this step's ``break`` ends the per-child stages.
313
+
314
+ A ``break`` ends the nearest enclosing construct: a loop inside the
315
+ per-child stage, else the children, whose remaining ones are skipped.
316
+ """
317
+ if self.loop_break is None or self.child_stage is None:
318
+ return False
319
+ return self.loop_id is None or not self.loop_id.startswith(
320
+ f"{self.child_stage}/"
321
+ )
322
+
323
+ @property
324
+ def hands_over(self) -> bool:
325
+ """Whether completing this item must leave a summary for the next step.
326
+
327
+ Only an ordinary agent step does: hooks, ``init`` (its artifact is the
328
+ requirements), the built-in workflow summary, and verification items
329
+ are exempt.
330
+ """
331
+ return (
332
+ self.phase == "step"
333
+ and self.owner == "agent"
334
+ and not self.summary
335
+ and self.step != "init"
336
+ and self.verifies is None
337
+ )
338
+
339
+ def payload_as(self, payload_type: type[PayloadT]) -> PayloadT:
340
+ if not isinstance(self.operation, PlannedAction):
341
+ raise ValueError(f"plan operation {self.kind!r} has no action payload")
342
+ payload = self.operation.payload
343
+ if not isinstance(payload, payload_type):
344
+ raise ValueError(
345
+ f"plan action {self.kind!r} does not contain "
346
+ f"{payload_type.__name__} data"
347
+ )
348
+ return payload
349
+
350
+ def __post_init__(self) -> None:
351
+ if self.on_failure not in {"operator", "fix"}:
352
+ raise ValueError("invalid handler failure policy")
353
+ if not is_positive_int(self.max_handler_fixes):
354
+ raise ValueError("handler fix limit must be positive")
355
+ if (self.on_failure == "fix" or self.on_failure_instruction is not None) and (
356
+ self.kind != "cli" or self.owner != "ww"
357
+ ):
358
+ raise ValueError("handler repair requires an automatic command")
359
+ if self.phase not in {
360
+ "before_start_workflow",
361
+ "before_start",
362
+ "step",
363
+ "before_complete",
364
+ "after_complete",
365
+ "before_complete_workflow",
366
+ }:
367
+ raise ValueError(f"invalid plan item phase: {self.phase!r}")
368
+ if self.item_operation not in {
369
+ None,
370
+ "collect",
371
+ "process_item",
372
+ "resolve_item",
373
+ "report_item",
374
+ "handle_item",
375
+ }:
376
+ raise ValueError(f"invalid item operation: {self.item_operation!r}")
377
+ if self.item_assignment not in get_args(ItemAssignment):
378
+ raise ValueError(f"invalid item assignment: {self.item_assignment!r}")
379
+ if self.loop_assignment is not None and self.loop_assignment not in get_args(
380
+ LoopAssignment
381
+ ):
382
+ raise ValueError(f"invalid loop assignment: {self.loop_assignment!r}")
383
+ if (self.loop_id is None) != (self.loop_assignment is None):
384
+ raise ValueError("loop ID and loop assignment must be set together")
385
+ if self.workdir not in WORKDIRS:
386
+ raise ValueError(f"invalid workdir: {self.workdir!r}")
387
+ if self.child_operation not in {None, "collect"}:
388
+ raise ValueError(f"invalid child operation: {self.child_operation!r}")
389
+ if self.child_number is not None and (
390
+ self.child_stage is None or not is_positive_int(self.child_number)
391
+ ):
392
+ raise ValueError("a child number belongs to a per-child stage")
393
+ if self.step_ordinals and (
394
+ len(self.step_ordinals) != len(self.ancestors) + 1
395
+ or not all(is_positive_int(value) for value in self.step_ordinals)
396
+ ):
397
+ raise ValueError("step ordinals must match the positive step hierarchy")
398
+ if isinstance(self.operation, PlannedAction):
399
+ contract = actions.get(self.kind) if actions.contains(self.kind) else None
400
+ if contract is not None and (
401
+ self.owner != contract.owner or self.execution != contract.execution
402
+ ):
403
+ raise ValueError(
404
+ f"plan item kind {self.kind!r} conflicts with owner/execution"
405
+ )
406
+ if contract is not None and not isinstance(
407
+ self.operation.payload, contract.planned_type
408
+ ):
409
+ raise ValueError(
410
+ f"plan item kind {self.kind!r} has the wrong action payload type"
411
+ )
412
+ elif (self.owner, self.execution) != (
413
+ self.operation.owner,
414
+ self.operation.execution,
415
+ ):
416
+ raise ValueError(
417
+ f"{self.kind} items must be {self.operation.owner}-owned "
418
+ f"{self.operation.execution} operations"
419
+ )
420
+ # ``validate`` accepts the pre-planning definition. Planned payloads
421
+ # may deliberately have a distinct type, and their strict boundary is
422
+ # the action's decoder (used for persisted snapshots).
423
+ if isinstance(self.operation, LoopBoundary) and (
424
+ self.loop_break is not None or self.loop_continue is not None
425
+ ):
426
+ raise ValueError("loop control items cannot define a worker break gate")
427
+ if self.loop_break is not None and (
428
+ self.owner != "agent" or self.phase != "step"
429
+ ):
430
+ raise ValueError("loop break gates require agent-owned step work")
431
+ if self.loop_continue is not None and (
432
+ self.owner != "agent" or self.phase != "step"
433
+ ):
434
+ raise ValueError("loop continue gates require agent-owned step work")
435
+ expected_agent_input = self.execution == "automatic" and bool(self.provide)
436
+ if self.requires_agent_input != expected_agent_input:
437
+ raise ValueError(
438
+ "requires_agent_input must match an automatic action with provided "
439
+ "values"
440
+ )
441
+ if (self.rules or self.checks) and self.owner != "agent":
442
+ raise ValueError("only agent-owned plan items carry rules and checks")
443
+ if self.modes and self.owner != "agent":
444
+ raise ValueError("only agent-owned plan items carry modes")
445
+ if self.verifies is not None and (
446
+ self.owner != "agent" or self.rules or self.checks or self.provide
447
+ ):
448
+ raise ValueError(
449
+ "a verification item is agent-owned and carries no rules, checks, "
450
+ "or provided values"
451
+ )
452
+ if self.save_metadata and self.owner != "agent" and self.kind != "cli":
453
+ raise ValueError("only agent-owned or CLI plan items can save metadata")
454
+ if self.update_document and self.owner != "agent":
455
+ raise ValueError("only agent-owned plan items can update documents")
456
+ if self.update_item and self.owner != "agent" and self.kind != "cli":
457
+ raise ValueError("only agent-owned or CLI plan items can save item fields")
458
+ metadata_names = [item.name for item in self.save_metadata]
459
+ metadata_keys = [(item.scope, item.key) for item in self.save_metadata]
460
+ if len(metadata_names) != len(set(metadata_names)):
461
+ raise ValueError("plan item has duplicate saved metadata names")
462
+ if len(metadata_keys) != len(set(metadata_keys)):
463
+ raise ValueError("plan item has duplicate saved metadata keys")
464
+ for index, (scope, key) in enumerate(metadata_keys):
465
+ if any(
466
+ scope == other_scope
467
+ and (key.startswith(f"{other}.") or other.startswith(f"{key}."))
468
+ for other_scope, other in metadata_keys[index + 1 :]
469
+ ):
470
+ raise ValueError("plan item has overlapping saved metadata keys")
471
+
472
+ def to_dict(self) -> dict[str, object]:
473
+ """The persisted form."""
474
+ data = self._to_dict()
475
+ # Pass identity is written only where it applies.
476
+ if self.item_pass is not None:
477
+ data["item_pass"] = self.item_pass
478
+ if self.item_collect_only:
479
+ data["item_collect_only"] = True
480
+ if self.on_failure != "operator":
481
+ data["on_failure"] = self.on_failure
482
+ data["max_handler_fixes"] = self.max_handler_fixes
483
+ if self.on_failure_instruction is not None:
484
+ data["on_failure_instruction"] = self.on_failure_instruction
485
+ # Loop membership is written only where it applies, so plans saved
486
+ # before it existed re-serialize byte for byte and keep their digest.
487
+ if self.loop_id is None:
488
+ del data["loop_id"], data["loop_assignment"]
489
+ if self.profile_path is None:
490
+ del data["profile_path"]
491
+ if self.workdir == "task":
492
+ del data["workdir"]
493
+ if not self.update_document:
494
+ del data["update_document"]
495
+ if not self.interactive:
496
+ del data["interactive"]
497
+ if not self.explicit:
498
+ del data["explicit"]
499
+ if not self.learnable:
500
+ del data["learnable"]
501
+ if not self.choices:
502
+ del data["choices"]
503
+ if not self.ui:
504
+ del data["ui"]
505
+ if not self.shared_items:
506
+ del data["shared_items"]
507
+ if not self.update_item:
508
+ del data["update_item"]
509
+ if self.item_identity is None and not self.item_unique:
510
+ del data["item_identity"], data["item_unique"]
511
+ if not self.rules:
512
+ del data["rules"]
513
+ if not self.checks:
514
+ del data["checks"]
515
+ if not self.modes:
516
+ del data["modes"]
517
+ if self.verifies is None:
518
+ del data["verifies"]
519
+ if self.child_stage is None:
520
+ del data["child_stage"]
521
+ if self.child_number is None:
522
+ del data["child_number"]
523
+ return data
524
+
525
+ def _to_dict(self) -> dict[str, object]:
526
+ return {
527
+ "id": self.id,
528
+ "position": self.position,
529
+ "name": self.name,
530
+ "description": self.description,
531
+ "operation": encode_operation(self.operation),
532
+ "owner": self.owner,
533
+ "execution": self.execution,
534
+ "requires_agent_input": self.requires_agent_input,
535
+ "workflow": self.workflow,
536
+ "step": self.step,
537
+ "parent": self.parent,
538
+ "phase": self.phase,
539
+ "source": self.source,
540
+ "registered_handler": self.registered_handler,
541
+ "provide": [item.to_dict() for item in self.provide],
542
+ "save_metadata": [item.to_dict() for item in self.save_metadata],
543
+ "update_document": [item.to_dict() for item in self.update_document],
544
+ "outputs": list(self.outputs),
545
+ "dependencies": list(self.dependencies),
546
+ "requested_agent": self.requested_agent,
547
+ "requested_model": self.requested_model,
548
+ "requested_reasoning": self.requested_reasoning,
549
+ "role": self.role,
550
+ "subagents": self.subagents,
551
+ "interactive": self.interactive,
552
+ "explicit": self.explicit,
553
+ "learnable": self.learnable,
554
+ "choices": [choice.to_dict() for choice in self.choices],
555
+ "ui": self.ui,
556
+ "model": self.model,
557
+ "reasoning": self.reasoning,
558
+ "profile": self.profile,
559
+ "profile_instruction": self.profile_instruction,
560
+ "profile_path": self.profile_path,
561
+ "workdir": self.workdir,
562
+ "summary": self.summary,
563
+ "item_operation": self.item_operation,
564
+ "item_template": self.item_template,
565
+ "item_id": self.item_id,
566
+ "item_assignment": self.item_assignment,
567
+ "loop_id": self.loop_id,
568
+ "loop_assignment": self.loop_assignment,
569
+ "split_instruction": self.split_instruction,
570
+ "shared_items": self.shared_items,
571
+ "update_item": [item.to_dict() for item in self.update_item],
572
+ "item_identity": self.item_identity,
573
+ "item_unique": list(self.item_unique),
574
+ "artifact": self.artifact,
575
+ "child_operation": self.child_operation,
576
+ "child_identity": self.child_identity,
577
+ "child_stage": self.child_stage,
578
+ "child_number": self.child_number,
579
+ "ancestors": list(self.ancestors),
580
+ "step_ordinals": list(self.step_ordinals),
581
+ "artifact_dependency": self.artifact_dependency,
582
+ "loop_break": self.loop_break,
583
+ "loop_continue": self.loop_continue,
584
+ "assessment_question": self.assessment_question,
585
+ "assessment_outcomes": list(self.assessment_outcomes),
586
+ "assessment_parent": self.assessment_parent,
587
+ "assessment_outcome": self.assessment_outcome,
588
+ # Written only when set: a plan without stops has no such key.
589
+ **(
590
+ {"assessment_stops": list(self.assessment_stops)}
591
+ if self.assessment_stops
592
+ else {}
593
+ ),
594
+ "rules": [rule.to_dict() for rule in self.rules],
595
+ "checks": [check.to_dict() for check in self.checks],
596
+ "modes": [mode.to_dict() for mode in self.modes],
597
+ "verifies": self.verifies.to_dict() if self.verifies else None,
598
+ }
599
+
600
+
601
+ def number_step_paths(items: tuple[PlanItem, ...]) -> tuple[PlanItem, ...]:
602
+ """Attach each plan item's declaration ordinal at every hierarchy level."""
603
+ children: dict[str | None, list[str]] = {}
604
+ ordinals: dict[str, int] = {}
605
+ for item in items:
606
+ chain = (*item.ancestors, item.step)
607
+ parent: str | None = None
608
+ for path in chain:
609
+ siblings = children.setdefault(parent, [])
610
+ if path not in siblings:
611
+ siblings.append(path)
612
+ ordinals[path] = len(siblings)
613
+ parent = path
614
+ return tuple(
615
+ replace(
616
+ item,
617
+ step_ordinals=tuple(
618
+ ordinals[path] for path in (*item.ancestors, item.step)
619
+ ),
620
+ )
621
+ for item in items
622
+ )
623
+
624
+
625
+ @dataclass(frozen=True)
626
+ class WorkflowPlan:
627
+ workflow: str
628
+ workflow_description: str
629
+ agent: str
630
+ task_id: str | None
631
+ modes: tuple[str, ...]
632
+ handoff: bool
633
+ items: tuple[PlanItem, ...]
634
+ # The root documents, frozen with the plan so a run resolves their paths
635
+ # without reading the configuration again.
636
+ documents: tuple[DocumentDefinition, ...] = ()
637
+ # Offered to the operator when the run completes; frozen with the plan so
638
+ # a later configuration change does not alter a finished run's page.
639
+ recommended_next_workflow: str | None = None
640
+ # The workflow whose project handling the run takes (``hooks_from``),
641
+ # frozen so extensions key their settings by the lane the run started on.
642
+ hooks_from: str | None = None
643
+
644
+ @property
645
+ def lane(self) -> str:
646
+ """The workflow extensions key their settings by: the lane, else this."""
647
+ return self.hooks_from or self.workflow
648
+
649
+ def to_dict(self) -> dict[str, object]:
650
+ data: dict[str, object] = {
651
+ "workflow": self.workflow,
652
+ "workflow_description": self.workflow_description,
653
+ "agent": self.agent,
654
+ "task_id": self.task_id,
655
+ "modes": list(self.modes),
656
+ "handoff": self.handoff,
657
+ "items": [item.to_dict() for item in self.items],
658
+ }
659
+ if self.documents:
660
+ data["documents"] = [document.to_dict() for document in self.documents]
661
+ if self.recommended_next_workflow is not None:
662
+ data["recommended_next_workflow"] = self.recommended_next_workflow
663
+ if self.hooks_from is not None:
664
+ data["hooks_from"] = self.hooks_from
665
+ return data