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/config/actions.py ADDED
@@ -0,0 +1,591 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Action, command, metadata, and hook parsing for ww.yaml."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import Any, cast
8
+
9
+ from ww.actions import DefinedAction, actions
10
+ from ww.contracts import HookFailure, HookPhase, HookScope, RequestedActionKind
11
+ from ww.errors import ConfigurationError
12
+ from ww.extensions import is_extension_reference
13
+ from ww.operations import WorkflowHandoff
14
+ from ww.variables import is_reserved_name
15
+ from ww.workflow_config import (
16
+ ALL,
17
+ ALL_NAMES,
18
+ DocumentUpdate,
19
+ HandlerDefinition,
20
+ HookDefinition,
21
+ ItemFieldUpdate,
22
+ ProvidedVariable,
23
+ SavedMetadata,
24
+ )
25
+ from ww.workspace import WORKDIRS, Workdir
26
+
27
+ from .values import (
28
+ _description,
29
+ _mapping,
30
+ _name,
31
+ _name_filter,
32
+ _named_entry,
33
+ _nonempty_string,
34
+ _only,
35
+ _optional_agent,
36
+ _optional_bool,
37
+ _optional_string,
38
+ _unique,
39
+ )
40
+
41
+ HOOK_PHASES: tuple[HookPhase, ...] = (
42
+ "before_start_workflow",
43
+ "before_start",
44
+ "before_complete",
45
+ "after_complete",
46
+ "before_complete_workflow",
47
+ )
48
+
49
+
50
+ # ``action.type`` names that spell a core control rather than a registered action.
51
+ CORE_ACTION_TYPES = frozenset({"loop", "workflow_transition", "child_workflow"})
52
+ # The agent action kinds ``kind`` chooses between.
53
+ ACTION_KINDS: tuple[RequestedActionKind, ...] = ("skill", "slash_command", "prompt")
54
+ # The prefixes of ``saves`` entries; the prefix is the kind and scope of the
55
+ # saved value, the rest its storage path.
56
+ _SAVE_PREFIXES = ("metadata.", "project_metadata.", "documents.", "item.field.")
57
+ # A dotted metadata path, e.g. "github.owner"; "github..owner" does not match.
58
+ _METADATA_PATH = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*")
59
+ # A variable name, dots and hyphens allowed, e.g. "ww.task-id".
60
+ _VARIABLE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_.-]*")
61
+
62
+
63
+ def _parse_handler(
64
+ mapping: dict[str, Any],
65
+ path: str,
66
+ *,
67
+ inline: bool = False,
68
+ transition: bool = False,
69
+ allowed_extra: set[str] | None = None,
70
+ ) -> HandlerDefinition:
71
+ """Parse one handler; ``transition`` admits the ``handoff_to`` key on a step."""
72
+ _only(
73
+ mapping,
74
+ _handler_keys()
75
+ | ({"handoff_to"} if inline or transition else set())
76
+ | (allowed_extra or set()),
77
+ path,
78
+ )
79
+ if "handlers" in mapping:
80
+ return _parse_automatic_group(mapping, path, inline=inline)
81
+ if (inline or transition) and "handoff_to" in mapping:
82
+ extra = set(mapping) - {
83
+ "name",
84
+ "handoff_to",
85
+ "start_child",
86
+ "description",
87
+ "agent",
88
+ "model",
89
+ "reasoning",
90
+ }
91
+ if "children" in extra:
92
+ raise ConfigurationError(
93
+ f"{path} cannot combine children with handoff_to; "
94
+ "name the child workflow under children.workflow"
95
+ )
96
+ if extra:
97
+ raise ConfigurationError(
98
+ f"{path} handoff_to cannot contain other handler fields"
99
+ )
100
+ return HandlerDefinition(
101
+ _name(mapping, path) if "name" in mapping else "start-workflow",
102
+ _description(mapping.get("description"), path),
103
+ operation=WorkflowHandoff(_nonempty_string(mapping, "handoff_to", path)),
104
+ agent=_optional_agent(mapping, "agent", path),
105
+ model=_optional_string(mapping, "model", path),
106
+ reasoning=_optional_string(mapping, "reasoning", path),
107
+ )
108
+ if inline and "name" not in mapping:
109
+ if "argv" in mapping:
110
+ name = "inline-argv"
111
+ elif "shell" in mapping:
112
+ name = "inline-shell"
113
+ elif "mcp" in mapping and isinstance(mapping["mcp"], str):
114
+ name = f"mcp-{mapping['mcp']}"
115
+ else:
116
+ raise ConfigurationError(f"{path}.name is required for this handler")
117
+ else:
118
+ name = _name(mapping, path)
119
+ description = _description(mapping.get("description"), f"handler {name!r}")
120
+ if "action" in mapping:
121
+ shorthand_keys = {
122
+ "kind",
123
+ "mcp",
124
+ "argv",
125
+ "shell",
126
+ "args",
127
+ "env",
128
+ "assert",
129
+ "idempotent",
130
+ }
131
+ if shorthand_keys & set(mapping):
132
+ raise ConfigurationError(
133
+ f"{path} cannot combine action with shorthand fields"
134
+ )
135
+ raw_action = _mapping(mapping["action"], f"{path}.action")
136
+ identifier = _nonempty_string(raw_action, "type", f"{path}.action")
137
+ source = {key: value for key, value in raw_action.items() if key != "type"}
138
+ if identifier in CORE_ACTION_TYPES:
139
+ # Core controls are engine behaviour with their own keys, not
140
+ # registry actions selected by type.
141
+ raise ConfigurationError(
142
+ f"{path}.action.type {identifier!r} is a core control; use "
143
+ "`handoff_to`, `children`, or `loop` on the step instead"
144
+ )
145
+ implementation = actions.get(identifier)
146
+ payload = implementation.parse(source, name, description, f"{path}.action")
147
+ implementation.validate(payload, f"{path}.action")
148
+ action = DefinedAction(identifier, payload)
149
+ return HandlerDefinition(
150
+ name=name,
151
+ description=description,
152
+ action=action,
153
+ **_failure_policy(mapping, path),
154
+ **handler_values(mapping, f"handler {name!r}"),
155
+ agent=_optional_agent(mapping, "agent", f"handler {name!r}"),
156
+ model=_optional_string(mapping, "model", f"handler {name!r}"),
157
+ reasoning=_optional_string(mapping, "reasoning", f"handler {name!r}"),
158
+ workdir=_optional_workdir(mapping, f"handler {name!r}"),
159
+ )
160
+ explicit: list[RequestedActionKind] = []
161
+ if "kind" in mapping:
162
+ kind = mapping["kind"]
163
+ if kind not in ACTION_KINDS:
164
+ raise ConfigurationError(
165
+ f"handler {name!r} kind must be one of: " + ", ".join(ACTION_KINDS)
166
+ )
167
+ explicit.append(cast(RequestedActionKind, kind))
168
+ mcp_value = mapping.get("mcp")
169
+ has_command = bool({"argv", "shell"} & set(mapping))
170
+ if {"args", "env", "assert", "idempotent"} & set(mapping) and not has_command:
171
+ actions.get("cli").parse(mapping, name, description, f"handler {name!r}")
172
+ if has_command and explicit:
173
+ raise ConfigurationError(f"handler {name!r} cannot combine a command and kind")
174
+ if mcp_value is not None:
175
+ if not isinstance(mcp_value, str) or not mcp_value.strip():
176
+ raise ConfigurationError(f"handler {name!r} mcp must be non-empty")
177
+ if has_command or explicit:
178
+ raise ConfigurationError(
179
+ f"handler {name!r} cannot combine mcp with another action"
180
+ )
181
+ requested_kind: RequestedActionKind | None = "mcp"
182
+ else:
183
+ requested_kind = explicit[0] if explicit else None
184
+ action_kind = (
185
+ "cli" if has_command else "mcp" if mcp_value is not None else requested_kind
186
+ )
187
+ typed_action = None
188
+ if action_kind is not None:
189
+ implementation = actions.get(action_kind)
190
+ payload = implementation.parse(mapping, name, description, path)
191
+ implementation.validate(payload, path)
192
+ typed_action = DefinedAction(action_kind, payload)
193
+ return HandlerDefinition(
194
+ name=name,
195
+ description=description,
196
+ action=typed_action,
197
+ **_failure_policy(mapping, path),
198
+ **handler_values(mapping, f"handler {name!r}"),
199
+ agent=_optional_agent(mapping, "agent", f"handler {name!r}"),
200
+ model=_optional_string(mapping, "model", f"handler {name!r}"),
201
+ reasoning=_optional_string(mapping, "reasoning", f"handler {name!r}"),
202
+ workdir=_optional_workdir(mapping, f"handler {name!r}"),
203
+ )
204
+
205
+
206
+ def _parse_automatic_group(
207
+ mapping: dict[str, Any], path: str, *, inline: bool
208
+ ) -> HandlerDefinition:
209
+ allowed = {
210
+ "name",
211
+ "description",
212
+ "handlers",
213
+ "workdir",
214
+ "agent",
215
+ "model",
216
+ "reasoning",
217
+ "on_failure",
218
+ "on_failure_instruction",
219
+ "handler",
220
+ "hooks",
221
+ "profile",
222
+ "subagents",
223
+ "artifact",
224
+ }
225
+ extra = set(mapping) - allowed
226
+ if extra:
227
+ raise ConfigurationError(
228
+ f"{path}.handlers cannot combine with: " + ", ".join(sorted(extra))
229
+ )
230
+ entries = mapping["handlers"]
231
+ if not isinstance(entries, list) or not entries:
232
+ raise ConfigurationError(f"{path}.handlers must be a non-empty list")
233
+ members = tuple(
234
+ _parse_hook_handler(entry, f"{path}.handlers[{index}]")
235
+ for index, entry in enumerate(entries)
236
+ )
237
+ return HandlerDefinition(
238
+ _name(mapping, path) if "name" in mapping else "inline-handlers",
239
+ description=_description(mapping.get("description"), path),
240
+ handlers=members,
241
+ workdir=_optional_workdir(mapping, path),
242
+ agent=_optional_agent(mapping, "agent", path),
243
+ model=_optional_string(mapping, "model", path),
244
+ reasoning=_optional_string(mapping, "reasoning", path),
245
+ **_failure_policy(mapping, path),
246
+ )
247
+
248
+
249
+ def _failure_policy(mapping: dict[str, Any], path: str) -> dict[str, Any]:
250
+ return {
251
+ "on_failure": _on_failure(mapping, path, "operator")
252
+ if "on_failure" in mapping
253
+ else None,
254
+ "on_failure_instruction": _optional_string(
255
+ mapping, "on_failure_instruction", path
256
+ ),
257
+ }
258
+
259
+
260
+ def _optional_workdir(mapping: dict[str, Any], path: str) -> Workdir | None:
261
+ if "workdir" not in mapping:
262
+ return None
263
+ value = mapping["workdir"]
264
+ if value not in WORKDIRS:
265
+ raise ConfigurationError(
266
+ f"{path}.workdir must be one of: {', '.join(WORKDIRS)}"
267
+ )
268
+ return cast(Workdir, value)
269
+
270
+
271
+ def _handler_keys() -> set[str]:
272
+ return {
273
+ "name",
274
+ "description",
275
+ "handlers",
276
+ "kind",
277
+ "mcp",
278
+ "argv",
279
+ "shell",
280
+ "args",
281
+ "env",
282
+ "assert",
283
+ "idempotent",
284
+ "on_failure",
285
+ "on_failure_instruction",
286
+ "agent",
287
+ "variables",
288
+ "saves",
289
+ "model",
290
+ "reasoning",
291
+ "action",
292
+ "workdir",
293
+ }
294
+
295
+
296
+ def _parse_hooks(data: Any, scope: HookScope, path: str) -> tuple[HookDefinition, ...]:
297
+ if data is None:
298
+ return ()
299
+ mapping = _mapping(data, path)
300
+ _only(mapping, set(HOOK_PHASES), path)
301
+ result: list[HookDefinition] = []
302
+ for phase in HOOK_PHASES:
303
+ entries = mapping.get(phase, [])
304
+ if not isinstance(entries, list):
305
+ raise ConfigurationError(f"{path}.{phase} must be a list")
306
+ for index, entry in enumerate(entries):
307
+ result.extend(_parse_hook(entry, phase, scope, f"{path}.{phase}[{index}]"))
308
+ return tuple(result)
309
+
310
+
311
+ HOOK_FAILURES: tuple[HookFailure, ...] = ("fix", "operator")
312
+
313
+
314
+ def _parse_hook(
315
+ data: Any, phase: HookPhase, scope: HookScope, path: str
316
+ ) -> tuple[HookDefinition, ...]:
317
+ mapping = _mapping(data, path)
318
+ allowed = _handler_keys() | {"handlers", "handoff_to", "on_failure"}
319
+ if scope in {"global", "workflow"} and phase not in {
320
+ "before_start_workflow",
321
+ "before_complete_workflow",
322
+ }:
323
+ allowed.add("steps")
324
+ if scope == "global":
325
+ allowed.add("workflows")
326
+ mapping = _named_entry(
327
+ mapping,
328
+ path,
329
+ allowed=allowed,
330
+ ignored={"workflows", "steps", "on_failure", "on_failure_instruction"},
331
+ )
332
+ _only(mapping, allowed, path)
333
+ on_failure = _on_failure(mapping, path, "operator")
334
+ _optional_string(mapping, "on_failure_instruction", path)
335
+ if "handlers" in mapping:
336
+ action_keys = set(mapping) & (
337
+ (_handler_keys() - {"handlers", "on_failure", "on_failure_instruction"})
338
+ | {"handoff_to"}
339
+ )
340
+ if action_keys:
341
+ raise ConfigurationError(
342
+ f"{path} cannot combine handlers with handler key(s): "
343
+ + ", ".join(sorted(action_keys))
344
+ )
345
+ raw_handlers = mapping["handlers"]
346
+ if not isinstance(raw_handlers, list) or not raw_handlers:
347
+ raise ConfigurationError(f"{path}.handlers must be a non-empty list")
348
+ references = tuple(
349
+ _parse_hook_member(
350
+ item,
351
+ f"{path}.handlers[{index}]",
352
+ on_failure,
353
+ mapping.get("on_failure_instruction"),
354
+ )
355
+ for index, item in enumerate(raw_handlers)
356
+ )
357
+ else:
358
+ action = {
359
+ key: value
360
+ for key, value in mapping.items()
361
+ if key not in {"workflows", "steps", "on_failure"}
362
+ }
363
+ if not action:
364
+ raise ConfigurationError(f"{path} requires a handler action")
365
+ references = ((_parse_hook_handler(action, path), on_failure),)
366
+
367
+ workflows = _name_filter(
368
+ mapping.get("workflows", ALL_NAMES), f"{path}.workflows", empty=ALL
369
+ )
370
+ steps = _name_filter(mapping.get("steps", ALL_NAMES), f"{path}.steps", empty=ALL)
371
+ return tuple(
372
+ HookDefinition(
373
+ phase=phase,
374
+ handler=reference,
375
+ workflows=workflows,
376
+ steps=steps,
377
+ scope=scope,
378
+ path=path,
379
+ on_failure=failure,
380
+ )
381
+ for reference, failure in references
382
+ )
383
+
384
+
385
+ def _on_failure(
386
+ mapping: dict[str, Any], path: str, default: HookFailure
387
+ ) -> HookFailure:
388
+ value = mapping.get("on_failure", default)
389
+ if value not in HOOK_FAILURES:
390
+ raise ConfigurationError(
391
+ f"{path}.on_failure must be one of: " + ", ".join(HOOK_FAILURES)
392
+ )
393
+ return cast(HookFailure, value)
394
+
395
+
396
+ def _parse_hook_member(
397
+ data: Any,
398
+ path: str,
399
+ group_failure: HookFailure,
400
+ group_instruction: str | None = None,
401
+ ) -> tuple[HandlerDefinition, HookFailure]:
402
+ """One member of a hook's ``handlers`` list and its own ``on_failure``."""
403
+ mapping = _named_entry(
404
+ _mapping(data, path),
405
+ path,
406
+ allowed=_handler_keys() | {"handoff_to", "on_failure"},
407
+ ignored={"on_failure"},
408
+ )
409
+ failure = _on_failure(mapping, path, group_failure)
410
+ handler = {key: value for key, value in mapping.items() if key != "on_failure"}
411
+ if "on_failure_instruction" not in handler and group_instruction is not None:
412
+ handler["on_failure_instruction"] = group_instruction
413
+ return _parse_hook_handler(handler, path), failure
414
+
415
+
416
+ def _parse_hook_handler(data: Any, path: str) -> HandlerDefinition:
417
+ mapping = _named_entry(
418
+ _mapping(data, path),
419
+ path,
420
+ allowed=_handler_keys() | {"handoff_to"},
421
+ )
422
+ if _bare_extension_reference(mapping):
423
+ return extension_reference(mapping, path)
424
+ return _parse_handler(mapping, path, inline=True)
425
+
426
+
427
+ def _bare_extension_reference(mapping: dict[str, Any]) -> bool:
428
+ """Whether ``mapping`` names an extension handler as is.
429
+
430
+ Such an entry carries at most the directory it works in and the
431
+ arguments it runs with; everything else about the handler is the
432
+ extension's to define.
433
+ """
434
+ name = mapping.get("name")
435
+ return (
436
+ isinstance(name, str)
437
+ and is_extension_reference(name)
438
+ and set(mapping) <= {"name", "workdir", "args"}
439
+ )
440
+
441
+
442
+ def extension_reference(mapping: dict[str, Any], path: str) -> HandlerDefinition:
443
+ """A bare reference to an extension handler, with its ``args``."""
444
+ arguments = mapping.get("args", [])
445
+ if not isinstance(arguments, list) or not all(
446
+ isinstance(argument, str) for argument in arguments
447
+ ):
448
+ raise ConfigurationError(f"{path}.args must be a list of strings")
449
+ return HandlerDefinition(
450
+ mapping["name"],
451
+ workdir=_optional_workdir(mapping, path),
452
+ extension_arguments=tuple(arguments),
453
+ )
454
+
455
+
456
+ def handler_values(mapping: dict[str, Any], path: str) -> dict[str, Any]:
457
+ """The ``variables`` and ``saves`` of a handler, as its definition fields."""
458
+ provide, outputs = parse_variables(mapping.get("variables", []), path)
459
+ save_metadata, update_document, update_item = parse_saves(
460
+ mapping.get("saves", []), path
461
+ )
462
+ return {
463
+ "provide": provide,
464
+ "outputs": outputs,
465
+ "save_metadata": save_metadata,
466
+ "update_document": update_document,
467
+ "update_item": update_item,
468
+ }
469
+
470
+
471
+ def parse_variables(
472
+ data: Any, path: str
473
+ ) -> tuple[tuple[ProvidedVariable, ...], tuple[str, ...]]:
474
+ """Parse ``variables``: what the step hands back, read as ``{{name}}``.
475
+
476
+ ``- name: description`` is a value the performer supplies with
477
+ ``--variable``; a bare ``- name`` is one an automatic action returns
478
+ itself. A name may not start with ``ww`` (or ``__``): those are ww's.
479
+ """
480
+ if data is None:
481
+ return (), ()
482
+ if not isinstance(data, list):
483
+ raise ConfigurationError(f"{path}.variables must be a list")
484
+ provided: list[ProvidedVariable] = []
485
+ returned: list[str] = []
486
+ for index, item in enumerate(data):
487
+ item_path = f"{path}.variables[{index}]"
488
+ if isinstance(item, str):
489
+ name = item
490
+ if not _VARIABLE_NAME.fullmatch(name):
491
+ raise ConfigurationError(
492
+ f"{item_path} must be a normalized variable name"
493
+ )
494
+ else:
495
+ mapping = _named_entry(_mapping(item, item_path), item_path)
496
+ _only(mapping, {"name", "description"}, item_path)
497
+ name = _name(mapping, item_path)
498
+ if is_reserved_name(name):
499
+ raise ConfigurationError(
500
+ f"{path}.variables name {name!r} is reserved: names starting "
501
+ "with ww are ww's own values"
502
+ )
503
+ if isinstance(item, str):
504
+ returned.append(name)
505
+ else:
506
+ provided.append(
507
+ ProvidedVariable(
508
+ name, _description(mapping.get("description"), item_path)
509
+ )
510
+ )
511
+ _unique((*(item.name for item in provided), *returned), f"variable in {path}")
512
+ return tuple(provided), tuple(returned)
513
+
514
+
515
+ def parse_saves(
516
+ data: Any, path: str
517
+ ) -> tuple[
518
+ tuple[SavedMetadata, ...], tuple[DocumentUpdate, ...], tuple[ItemFieldUpdate, ...]
519
+ ]:
520
+ """Parse ``saves``: what the step writes to metadata, documents, or its item.
521
+
522
+ Each entry names a prefixed path, ``metadata.<path>``,
523
+ ``project_metadata.<path>``, ``documents.<name>`` or
524
+ ``item.field.<name>``, with the text saying what to put there; a
525
+ metadata entry may add ``append: true``.
526
+ """
527
+ if data is None:
528
+ return (), (), ()
529
+ if not isinstance(data, list):
530
+ raise ConfigurationError(f"{path}.saves must be a list")
531
+ metadata: list[SavedMetadata] = []
532
+ documents: list[DocumentUpdate] = []
533
+ fields: list[ItemFieldUpdate] = []
534
+ for index, item in enumerate(data):
535
+ item_path = f"{path}.saves[{index}]"
536
+ mapping = _named_entry(_mapping(item, item_path), item_path)
537
+ _only(mapping, {"name", "description", "append"}, item_path)
538
+ target = mapping.get("name")
539
+ if not isinstance(target, str) or not target.startswith(_SAVE_PREFIXES):
540
+ hint = (
541
+ "; saves take no ww. prefix"
542
+ if isinstance(target, str) and target.startswith("ww.")
543
+ else ""
544
+ )
545
+ raise ConfigurationError(
546
+ f"{item_path} must name metadata.<path>, project_metadata.<path>, "
547
+ f"documents.<name>, or item.field.<name>{hint}"
548
+ )
549
+ description = _description(mapping.get("description"), item_path)
550
+ append = _optional_bool(mapping, "append", item_path)
551
+ prefix = next(prefix for prefix in _SAVE_PREFIXES if target.startswith(prefix))
552
+ rest = target.removeprefix(prefix)
553
+ if append is not None and prefix not in {"metadata.", "project_metadata."}:
554
+ raise ConfigurationError(f"{item_path}.append applies to metadata only")
555
+ try:
556
+ if prefix in {"metadata.", "project_metadata."}:
557
+ if not _METADATA_PATH.fullmatch(rest):
558
+ raise ConfigurationError(
559
+ f"{item_path} must name a dotted metadata path"
560
+ )
561
+ project = prefix == "project_metadata."
562
+ metadata.append(
563
+ SavedMetadata(
564
+ target if project else rest,
565
+ rest,
566
+ description,
567
+ "project" if project else "task",
568
+ append=append or False,
569
+ )
570
+ )
571
+ elif prefix == "documents.":
572
+ documents.append(DocumentUpdate(rest, description))
573
+ else:
574
+ fields.append(ItemFieldUpdate(rest, description))
575
+ except ValueError as error:
576
+ raise ConfigurationError(f"{item_path}: {error}") from error
577
+ _unique((item.name for item in metadata), f"saved metadata path in {path}")
578
+ _unique((item.name for item in documents), f"document in {path}")
579
+ _unique((item.name for item in fields), f"item field in {path}")
580
+ for scope in ("task", "project"):
581
+ keys = [item.key for item in metadata if item.scope == scope]
582
+ for index, key in enumerate(keys):
583
+ if any(
584
+ key.startswith(f"{other}.") or other.startswith(f"{key}.")
585
+ for other in keys[index + 1 :]
586
+ ):
587
+ raise ConfigurationError(
588
+ f"saved metadata paths in {path} cannot overlap within "
589
+ f"the {scope} scope"
590
+ )
591
+ return tuple(metadata), tuple(documents), tuple(fields)