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/bootstrap.py ADDED
@@ -0,0 +1,405 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Bootstrap external task IDs before a workflow run exists."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Callable
7
+ from typing import Protocol, cast
8
+
9
+ from ww.artifacts import render_step_artifact
10
+ from ww.builtin_workflows import require_lane
11
+ from ww.completion_inputs import validate_requested_values, validate_values
12
+ from ww.errors import ConfigurationError, StateError
13
+ from ww.extensions import ExtensionRegistry
14
+ from ww.instructions import Instruction, action_text, build_bootstrap_instruction
15
+ from ww.plan import PlanCompilationOptions, PlanItem, compile_workflow_plan
16
+ from ww.storage import Storage
17
+ from ww.storage_adapters import ArtifactAddress, TaskStorageAdapter
18
+ from ww.task_ids import validate_child_id
19
+ from ww.workflow_config import ProvidedVariable, WorkflowConfiguration
20
+
21
+
22
+ class StartBootstrapRun(Protocol):
23
+ """Start the normal workflow run created by a resolved bootstrap request."""
24
+
25
+ def __call__(
26
+ self,
27
+ task_id: str,
28
+ workflow_name: str,
29
+ mode_names: tuple[str, ...],
30
+ agent: str,
31
+ *,
32
+ bootstrap_step: str,
33
+ bootstrap_values: tuple[tuple[str, str], ...],
34
+ bootstrap_request_id: str,
35
+ model: str,
36
+ reasoning: str,
37
+ workflow_runtime: str,
38
+ branch_naming_strategy: str | None,
39
+ init_artifact: str,
40
+ project: str | None,
41
+ parent_task_id: str | None = None,
42
+ start_operation_id: str | None = None,
43
+ ) -> Instruction: ...
44
+
45
+
46
+ class BindChild(Protocol):
47
+ """Rename a parent's child record once the child's external ID is known."""
48
+
49
+ def __call__(
50
+ self, parent_task_id: str, temporary_id: str, child_task_id: str
51
+ ) -> None: ...
52
+
53
+
54
+ class ReadWorkflowStatus(Protocol):
55
+ """Read the instruction for an existing workflow run."""
56
+
57
+ def __call__(self, task_id: str, run_id: str | None) -> Instruction: ...
58
+
59
+
60
+ class BootstrapCoordinator:
61
+ """Own bootstrap-request persistence and external-ID binding.
62
+
63
+ The service supplies the two workflow lifecycle operations at the binding
64
+ boundary. This keeps task locks and the transition into a normal workflow
65
+ run explicit without coupling this coordinator to ``WorkflowService``.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ storage: Storage,
71
+ tasks: TaskStorageAdapter,
72
+ extensions: ExtensionRegistry,
73
+ *,
74
+ now: Callable[[], str],
75
+ generated_request_id: Callable[[], str],
76
+ validate_task_id: Callable[[str], None],
77
+ task_exists: Callable[[str], bool],
78
+ ) -> None:
79
+ self.storage = storage
80
+ self.tasks = tasks
81
+ self.extensions = extensions
82
+ self.now = now
83
+ self.generated_request_id = generated_request_id
84
+ self.validate_task_id = validate_task_id
85
+ self.task_exists = task_exists
86
+
87
+ def step(
88
+ self,
89
+ configuration: WorkflowConfiguration,
90
+ workflow_name: str,
91
+ mode_names: tuple[str, ...],
92
+ agent: str,
93
+ unknown_modes_for: Callable[[tuple[str, ...], WorkflowConfiguration], set[str]],
94
+ project: str | None = None,
95
+ ) -> PlanItem | None:
96
+ """Return the first agent step that establishes an external task ID."""
97
+ if workflow_name not in configuration.workflows_by_name:
98
+ raise ConfigurationError(f"workflow not found: {workflow_name}")
99
+ workflow = configuration.workflows_by_name[workflow_name]
100
+ require_lane(workflow)
101
+ unknown_modes = unknown_modes_for(mode_names, configuration)
102
+ if unknown_modes:
103
+ raise StateError("unknown mode(s): " + ", ".join(sorted(unknown_modes)))
104
+ providers = [
105
+ (index, step)
106
+ for index, step in enumerate(workflow.steps)
107
+ if any(value.name == "task_id" for value in step.provide)
108
+ ]
109
+ if not providers:
110
+ return None
111
+ if len(providers) != 1 or providers[0][0] != 0:
112
+ raise ConfigurationError(
113
+ "variables: task_id is only supported on the first workflow step "
114
+ "when start has no task ID"
115
+ )
116
+ step = providers[0][1]
117
+ if (
118
+ step.hooks
119
+ or step.child_steps
120
+ or step.loop_steps
121
+ or step.items is not None
122
+ or step.children is not None
123
+ ):
124
+ raise ConfigurationError(
125
+ "the bootstrap task_id step cannot have hooks, nested steps, "
126
+ "or children"
127
+ )
128
+ plan = compile_workflow_plan(
129
+ configuration,
130
+ self.storage.root,
131
+ workflow_name,
132
+ agent,
133
+ None,
134
+ self.extensions,
135
+ PlanCompilationOptions(project=project, modes=mode_names or None),
136
+ self.extensions.config,
137
+ )
138
+ item = next(
139
+ (
140
+ entry
141
+ for entry in plan.items
142
+ if entry.phase == "step" and entry.step == step.name
143
+ ),
144
+ None,
145
+ )
146
+ if item is None or item.owner != "agent":
147
+ raise ConfigurationError("the bootstrap task_id step must be agent-owned")
148
+ if tuple(value.name for value in item.provide) != ("task_id",):
149
+ raise ConfigurationError(
150
+ "the bootstrap task_id step must hand back exactly the variable task_id"
151
+ )
152
+ return item
153
+
154
+ def start(
155
+ self,
156
+ workflow_name: str,
157
+ mode_names: tuple[str, ...],
158
+ agent: str,
159
+ item: PlanItem,
160
+ model: str,
161
+ reasoning: str,
162
+ workflow_runtime: str,
163
+ branch_naming_strategy: str | None,
164
+ init_artifact: str,
165
+ project: str | None = None,
166
+ *,
167
+ request_id: str | None = None,
168
+ parent_task_id: str | None = None,
169
+ start_operation_id: str | None = None,
170
+ ) -> Instruction:
171
+ """Record an identity request; a child's request reuses its temporary ID."""
172
+ explicit = request_id is not None
173
+ request_id = request_id or self.generated_request_id()
174
+ request: dict[str, object] = {
175
+ "request_id": request_id,
176
+ "workflow": workflow_name,
177
+ "modes": list(mode_names),
178
+ "agent": agent,
179
+ "model": model,
180
+ "reasoning": reasoning,
181
+ "requested_agent": item.requested_agent,
182
+ "requested_model": item.requested_model,
183
+ "requested_reasoning": item.requested_reasoning,
184
+ "requested_profile": item.profile,
185
+ "workflow_runtime": workflow_runtime,
186
+ "branch_naming_strategy": branch_naming_strategy,
187
+ "init_artifact": init_artifact,
188
+ "project": project,
189
+ "parent_task_id": parent_task_id,
190
+ "start_operation_id": start_operation_id,
191
+ "step": item.step,
192
+ "item_id": item.id,
193
+ "item_name": item.name,
194
+ "action_kind": item.kind,
195
+ "action_text": action_text(item),
196
+ "profile_instruction": item.profile_instruction,
197
+ "profile_path": item.profile_path,
198
+ "step_modes": [mode.to_dict() for mode in item.modes],
199
+ "status": "pending",
200
+ "created_at": self.now(),
201
+ }
202
+ with self.storage.lock_project():
203
+ while self.storage.read_bootstrap(request_id) is not None:
204
+ if explicit:
205
+ raise StateError(f"identity request {request_id!r} already exists")
206
+ request_id = self.generated_request_id()
207
+ request["request_id"] = request_id
208
+ self.storage.write_bootstrap(request_id, request)
209
+ return self.instruction(request)
210
+
211
+ def next(
212
+ self,
213
+ request: dict[str, object],
214
+ force: bool,
215
+ *,
216
+ selected_agent: str | None,
217
+ selected_model: str | None,
218
+ selected_reasoning: str | None,
219
+ start_task: StartBootstrapRun,
220
+ status_task: ReadWorkflowStatus,
221
+ bind_child: BindChild | None = None,
222
+ ) -> Instruction:
223
+ status = request.get("status")
224
+ if status == "completed":
225
+ raise StateError("bootstrap request is already completed")
226
+ if status == "binding":
227
+ resolved = request.get("resolved_task_id")
228
+ if isinstance(resolved, str) and resolved:
229
+ return self.complete(
230
+ str(request["request_id"]),
231
+ request,
232
+ (),
233
+ None,
234
+ start_task=start_task,
235
+ status_task=status_task,
236
+ bind_child=bind_child,
237
+ )
238
+ raise StateError(
239
+ "binding bootstrap request is missing its resolved task ID"
240
+ )
241
+ if status == "failed":
242
+ if not force:
243
+ return self.instruction(request)
244
+ request["status"] = "pending"
245
+ request.pop("error", None)
246
+ if request.get("status") == "in_progress":
247
+ raise StateError("a bootstrap step is already in progress; use complete")
248
+ request["status"] = "in_progress"
249
+ request["selected_agent"] = selected_agent
250
+ request["selected_model"] = selected_model
251
+ request["selected_reasoning"] = selected_reasoning
252
+ self.storage.write_bootstrap(str(request["request_id"]), request)
253
+ return self.instruction(request)
254
+
255
+ def complete(
256
+ self,
257
+ request_id: str,
258
+ request: dict[str, object],
259
+ variables: tuple[tuple[str, str], ...],
260
+ artifact: str | None,
261
+ *,
262
+ start_task: StartBootstrapRun,
263
+ status_task: ReadWorkflowStatus,
264
+ bind_child: BindChild | None = None,
265
+ ) -> Instruction:
266
+ """Serialize a bootstrap binding and refresh stale request state."""
267
+ request_path = self.storage.runtime_path / "bootstrap" / f"{request_id}.json"
268
+ with self.storage.locks.lock(
269
+ request_path, purpose=f"bootstrap request {request_id!r}"
270
+ ):
271
+ current = self.storage.read_bootstrap(request_id)
272
+ if current is not None:
273
+ request = current
274
+ return self._complete_locked(
275
+ request_id,
276
+ request,
277
+ variables,
278
+ artifact,
279
+ start_task=start_task,
280
+ status_task=status_task,
281
+ bind_child=bind_child,
282
+ )
283
+
284
+ def _complete_locked(
285
+ self,
286
+ request_id: str,
287
+ request: dict[str, object],
288
+ variables: tuple[tuple[str, str], ...],
289
+ artifact: str | None,
290
+ *,
291
+ start_task: StartBootstrapRun,
292
+ status_task: ReadWorkflowStatus,
293
+ bind_child: BindChild | None = None,
294
+ ) -> Instruction:
295
+ if request.get("status") not in {"in_progress", "binding"}:
296
+ raise StateError("no bootstrap step is in progress; use next")
297
+ supplied = validate_values(variables)
298
+ parent_task_id = request.get("parent_task_id")
299
+ parent = str(parent_task_id) if isinstance(parent_task_id, str) else None
300
+ if request.get("status") == "binding":
301
+ resolved_id = str(request.get("resolved_task_id", ""))
302
+ if supplied:
303
+ validate_requested_values(supplied, (ProvidedVariable("task_id"),))
304
+ else:
305
+ validate_requested_values(supplied, (ProvidedVariable("task_id"),))
306
+ resolved_id = supplied["task_id"]
307
+ if parent is not None:
308
+ # A child's external ID is one segment beneath its parent.
309
+ validate_child_id(resolved_id)
310
+ resolved_id = f"{parent}/{resolved_id}"
311
+ self.validate_task_id(resolved_id)
312
+ if request.get("status") != "binding" and self.task_exists(resolved_id):
313
+ raise StateError(
314
+ f"task {resolved_id!r} already exists and cannot be rebound"
315
+ )
316
+ workflow = str(request["workflow"])
317
+ modes = tuple(str(value) for value in cast(list[object], request["modes"]))
318
+ agent = str(request["agent"])
319
+ step = str(request["step"])
320
+ request["status"] = "binding"
321
+ request["resolved_task_id"] = resolved_id
322
+ request["binding_started_at"] = self.now()
323
+ if artifact is not None:
324
+ request["binding_artifact"] = artifact
325
+ self.storage.write_bootstrap(request_id, request)
326
+ with self.tasks.lock_task(resolved_id):
327
+ if self.task_exists(resolved_id):
328
+ runs, _ = self.tasks.read_task_aggregate(resolved_id)
329
+ if not any(run.bootstrap_request_id == request_id for run in runs):
330
+ raise StateError(
331
+ f"task {resolved_id!r} exists but is not bound to "
332
+ "bootstrap request "
333
+ f"{request_id!r}"
334
+ )
335
+ instruction = status_task(resolved_id, None)
336
+ else:
337
+ instruction = start_task(
338
+ resolved_id,
339
+ workflow,
340
+ modes,
341
+ agent,
342
+ bootstrap_step=step,
343
+ bootstrap_values=(("task_id", resolved_id),),
344
+ bootstrap_request_id=request_id,
345
+ model=str(request.get("model", "auto")),
346
+ reasoning=str(request.get("reasoning", "auto")),
347
+ workflow_runtime=str(request.get("workflow_runtime", "single")),
348
+ branch_naming_strategy=(
349
+ str(request["branch_naming_strategy"])
350
+ if request.get("branch_naming_strategy") is not None
351
+ else None
352
+ ),
353
+ init_artifact=str(request["init_artifact"]),
354
+ project=(
355
+ str(request["project"])
356
+ if request.get("project") is not None
357
+ else None
358
+ ),
359
+ parent_task_id=parent,
360
+ start_operation_id=(
361
+ str(request["start_operation_id"])
362
+ if request.get("start_operation_id") is not None
363
+ else None
364
+ ),
365
+ )
366
+ artifact = artifact or (
367
+ str(request["binding_artifact"])
368
+ if request.get("binding_artifact") is not None
369
+ else None
370
+ )
371
+ if artifact is not None:
372
+ run_id = self.tasks.active_execution_run(resolved_id)
373
+ if run_id is not None:
374
+ self.tasks.write_execution_artifact(
375
+ ArtifactAddress(
376
+ resolved_id,
377
+ workflow,
378
+ step,
379
+ 2,
380
+ str(request["item_name"]),
381
+ "step",
382
+ run_id=run_id,
383
+ step_ordinals=(2,),
384
+ ),
385
+ render_step_artifact(
386
+ task_id=resolved_id,
387
+ workflow=workflow,
388
+ step=step,
389
+ step_number=1,
390
+ step_total=1,
391
+ skill="auto",
392
+ result=artifact,
393
+ ),
394
+ )
395
+ if parent is not None:
396
+ if bind_child is None: # pragma: no cover - service always binds
397
+ raise StateError("child identity requests require a parent binding")
398
+ bind_child(parent, request_id, resolved_id)
399
+ request["status"] = "completed"
400
+ request["completed_at"] = self.now()
401
+ self.storage.write_bootstrap(request_id, request)
402
+ return instruction
403
+
404
+ def instruction(self, request: dict[str, object]) -> Instruction:
405
+ return build_bootstrap_instruction(request, self.storage.root)
@@ -0,0 +1,215 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The workflows ww ships with, composed below every configuration level.
3
+
4
+ Each file in ``ww/assets/workflows/`` is ordinary ``ww.yaml``
5
+ notation holding one or more workflows, and optionally the root ``documents``
6
+ and ``modes`` that belong to them. Together they form a built-in level beneath
7
+ the user, repo and local levels:
8
+
9
+ - a workflow, document or mode that any configuration level defines under the
10
+ same name replaces the built-in one;
11
+ - ``ww.json`` switches a built-in workflow off with
12
+ ``"workflows": {"<name>": {"enabled": false}}``; a file whose workflows are
13
+ all switched off contributes nothing, its documents and modes included.
14
+
15
+ The built-in level is added after composition and parsing, when the
16
+ configuration is validated, so a level's ``extends: false`` never removes it
17
+ and the project's own files are parsed exactly as they are written.
18
+
19
+ ``catchall`` records a change no configured workflow covers. It exists so that
20
+ every change goes through ww, including the small ones an agent would
21
+ otherwise just make, without adding any process to them: the agent works
22
+ exactly as it would on a plain prompt and ww keeps the record.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, replace
28
+ from importlib.resources import files
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING
31
+
32
+ import yaml
33
+
34
+ from ww.errors import ConfigurationError
35
+ from ww.workflow_config import (
36
+ DocumentDefinition,
37
+ ModeDefinition,
38
+ WorkflowConfiguration,
39
+ WorkflowDefinition,
40
+ )
41
+
42
+ if TYPE_CHECKING:
43
+ from importlib.abc import Traversable
44
+
45
+ from ww.project_config import ProjectConfig
46
+
47
+ CATCHALL = "catchall"
48
+ # The root keys a built-in file may declare.
49
+ BUILTIN_KEYS = frozenset({"workflows", "documents", "modes"})
50
+ # Where the built-in files live; tests point it at a directory of their own.
51
+ BUILTIN_DIRECTORY: Traversable | Path = files("ww.assets").joinpath("workflows")
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class BuiltinFile:
56
+ """One built-in YAML file: its workflows and what belongs to them."""
57
+
58
+ name: str
59
+ workflows: tuple[WorkflowDefinition, ...]
60
+ documents: tuple[DocumentDefinition, ...] = ()
61
+ modes: tuple[ModeDefinition, ...] = ()
62
+
63
+
64
+ _parsed: dict[str, tuple[BuiltinFile, ...]] = {}
65
+
66
+
67
+ def builtin_files() -> tuple[BuiltinFile, ...]:
68
+ """Every built-in file, parsed once per directory, in file-name order."""
69
+ directory = BUILTIN_DIRECTORY
70
+ key = str(directory)
71
+ if key not in _parsed:
72
+ _parsed[key] = tuple(
73
+ _parse_file(entry)
74
+ for entry in sorted(directory.iterdir(), key=lambda item: item.name)
75
+ if entry.name.endswith(".yaml") and entry.is_file()
76
+ )
77
+ return _parsed[key]
78
+
79
+
80
+ def builtin_workflows() -> tuple[WorkflowDefinition, ...]:
81
+ return tuple(
82
+ workflow for builtin in builtin_files() for workflow in builtin.workflows
83
+ )
84
+
85
+
86
+ def builtin_workflow_names() -> frozenset[str]:
87
+ return frozenset(workflow.name for workflow in builtin_workflows())
88
+
89
+
90
+ def builtin_workflow(name: str) -> WorkflowDefinition:
91
+ """The built-in workflow ``name``, as ww ships it."""
92
+ for workflow in builtin_workflows():
93
+ if workflow.name == name:
94
+ return workflow
95
+ raise KeyError(name)
96
+
97
+
98
+ def is_builtin(workflow: WorkflowDefinition) -> bool:
99
+ """Whether ``workflow`` is a built-in one, not a configured replacement.
100
+
101
+ A built-in whose recommended workflow is switched off loses the
102
+ recommendation and is still the built-in.
103
+ """
104
+ return any(
105
+ replace(
106
+ workflow,
107
+ recommended_next_workflow=builtin.recommended_next_workflow,
108
+ hooks_from=builtin.hooks_from,
109
+ )
110
+ == builtin
111
+ for builtin in builtin_workflows()
112
+ )
113
+
114
+
115
+ def missing_lane(workflow: WorkflowDefinition) -> str | None:
116
+ """Why ``workflow`` cannot run: it needs ``hooks_from`` and has none.
117
+
118
+ ``None`` when it can. The lane is set in the workflow's ``ww.yaml`` definition.
119
+ """
120
+ if not workflow.needs_hooks_from or workflow.hooks_from is not None:
121
+ return None
122
+ name = workflow.name
123
+ where = (
124
+ "name that lane with hooks_from in its definition in ww.yaml "
125
+ 'first, for example "hooks_from: task"'
126
+ )
127
+ return (
128
+ f"workflow {name!r} runs with a project lane's branch, worktree and "
129
+ f"commit handling; {where}"
130
+ )
131
+
132
+
133
+ def require_lane(workflow: WorkflowDefinition) -> None:
134
+ """Refuse to run ``workflow`` while it needs ``hooks_from`` and has none."""
135
+ reason = missing_lane(workflow)
136
+ if reason is not None:
137
+ raise ConfigurationError(reason)
138
+
139
+
140
+ def with_builtin_workflows(
141
+ configuration: WorkflowConfiguration, project_config: ProjectConfig
142
+ ) -> WorkflowConfiguration:
143
+ """Add each enabled built-in the configuration does not define itself.
144
+
145
+ The built-in workflows follow the configured ones. A built-in file's
146
+ documents and modes come along while any of its workflows is enabled,
147
+ unless the configuration declares one of the same name. A built-in's
148
+ ``recommended_next_workflow`` is dropped while that workflow is off.
149
+ """
150
+ workflows = {workflow.name for workflow in configuration.workflows}
151
+ documents = {document.name for document in configuration.documents}
152
+ modes = {mode.name for mode in configuration.modes}
153
+ added_workflows: list[WorkflowDefinition] = []
154
+ added_documents: list[DocumentDefinition] = []
155
+ added_modes: list[ModeDefinition] = []
156
+ for builtin in builtin_files():
157
+ enabled = [
158
+ workflow
159
+ for workflow in builtin.workflows
160
+ if project_config.workflow_enabled(workflow.name)
161
+ ]
162
+ if not enabled:
163
+ continue
164
+ added_workflows.extend(
165
+ workflow for workflow in enabled if workflow.name not in workflows
166
+ )
167
+ for document in builtin.documents:
168
+ if document.name not in documents:
169
+ documents.add(document.name)
170
+ added_documents.append(document)
171
+ for mode in builtin.modes:
172
+ if mode.name not in modes:
173
+ modes.add(mode.name)
174
+ added_modes.append(mode)
175
+ if not (added_workflows or added_documents or added_modes):
176
+ return configuration
177
+ # A built-in recommending one that is switched off recommends nothing.
178
+ present = workflows | {workflow.name for workflow in added_workflows}
179
+ added_workflows = [
180
+ workflow
181
+ if workflow.recommended_next_workflow in (None, *present)
182
+ else replace(workflow, recommended_next_workflow=None)
183
+ for workflow in added_workflows
184
+ ]
185
+ return replace(
186
+ configuration,
187
+ workflows=(*configuration.workflows, *added_workflows),
188
+ documents=(*configuration.documents, *added_documents),
189
+ modes=(*configuration.modes, *added_modes),
190
+ )
191
+
192
+
193
+ def _parse_file(entry: Traversable | Path) -> BuiltinFile:
194
+ # The notation frontend validates through this module, so it is imported
195
+ # only once a built-in file is first read.
196
+ from ww.config import parse_yaml_text
197
+
198
+ label = f"built-in {entry.name}"
199
+ text = entry.read_text(encoding="utf-8")
200
+ raw = yaml.safe_load(text)
201
+ if not isinstance(raw, dict):
202
+ raise ConfigurationError(f"{label} must contain a mapping")
203
+ unknown = set(raw) - BUILTIN_KEYS
204
+ if unknown:
205
+ raise ConfigurationError(
206
+ f"{label} declares {', '.join(sorted(unknown))}; a built-in file "
207
+ "holds only " + ", ".join(sorted(BUILTIN_KEYS))
208
+ )
209
+ parsed = parse_yaml_text(text, label)
210
+ return BuiltinFile(
211
+ entry.name.removesuffix(".yaml"),
212
+ parsed.workflows,
213
+ parsed.documents,
214
+ parsed.modes,
215
+ )