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/actions/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Internal registry and public imports for workflow action contracts."""
3
+
4
+ from .command import CommandAction
5
+ from .contracts import (
6
+ Action,
7
+ ActionPayload,
8
+ ActionRegistry,
9
+ ActionResult,
10
+ ActionTraits,
11
+ AssertionCondition,
12
+ AssertionDefinition,
13
+ AttestationField,
14
+ AutomaticAction,
15
+ CommandDefinition,
16
+ CommandOutcome,
17
+ CommandRequest,
18
+ Commands,
19
+ CommandService,
20
+ DefinedAction,
21
+ DefinitionOverrideContext,
22
+ ExecutionContext,
23
+ Extension,
24
+ ExtensionBinding,
25
+ ExtensionIdentityService,
26
+ ExtensionService,
27
+ InstructionContent,
28
+ InstructionContext,
29
+ Mcp,
30
+ PlannedAction,
31
+ PreflightContext,
32
+ Prompt,
33
+ RecoveryCheckResult,
34
+ RecoveryContext,
35
+ RecoveryExtensionService,
36
+ ResolutionContext,
37
+ Skill,
38
+ SlashCommand,
39
+ actions,
40
+ )
41
+ from .extension import ExtensionAction
42
+ from .mcp import McpAction
43
+ from .prompt import PromptAction
44
+ from .skill import SkillAction
45
+ from .slash_command import SlashCommandAction
46
+
47
+ __all__ = [
48
+ "Action",
49
+ "ActionResult",
50
+ "ActionTraits",
51
+ "AttestationField",
52
+ "AutomaticAction",
53
+ "ActionPayload",
54
+ "ActionRegistry",
55
+ "CommandAction",
56
+ "CommandOutcome",
57
+ "CommandRequest",
58
+ "CommandService",
59
+ "AssertionCondition",
60
+ "AssertionDefinition",
61
+ "CommandDefinition",
62
+ "Commands",
63
+ "ExecutionContext",
64
+ "DefinedAction",
65
+ "DefinitionOverrideContext",
66
+ "Extension",
67
+ "ExtensionBinding",
68
+ "ExtensionService",
69
+ "ExtensionIdentityService",
70
+ "ExtensionAction",
71
+ "InstructionContent",
72
+ "InstructionContext",
73
+ "Mcp",
74
+ "PlannedAction",
75
+ "PreflightContext",
76
+ "Prompt",
77
+ "ResolutionContext",
78
+ "RecoveryCheckResult",
79
+ "RecoveryContext",
80
+ "RecoveryExtensionService",
81
+ "Skill",
82
+ "SlashCommand",
83
+ "actions",
84
+ ]
85
+
86
+ for _action in (
87
+ PromptAction(),
88
+ SkillAction(),
89
+ SlashCommandAction(),
90
+ McpAction(),
91
+ CommandAction(),
92
+ ExtensionAction(),
93
+ ):
94
+ actions.register(_action)
ww/actions/command.py ADDED
@@ -0,0 +1,444 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Command action parsing, planning, rendering, and execution effects."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import shlex
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+
11
+ from ww.errors import ConfigurationError, StateError
12
+ from ww.interpolation import dependencies, interpolate
13
+ from ww.validation import (
14
+ expect_bool,
15
+ expect_mapping,
16
+ expect_nonempty_string,
17
+ expect_string,
18
+ reject_unknown_keys,
19
+ )
20
+ from ww.variables import item_context_error
21
+
22
+ from .contracts import (
23
+ ActionResult,
24
+ ActionTraits,
25
+ AssertionCondition,
26
+ AssertionDefinition,
27
+ AutomaticAction,
28
+ CommandDefinition,
29
+ CommandOutcome,
30
+ CommandRequest,
31
+ Commands,
32
+ ExecutionContext,
33
+ InstructionContent,
34
+ InstructionContext,
35
+ ResolutionContext,
36
+ )
37
+
38
+ # An environment variable name, e.g. "WW_TASK_ID"; "2FA" does not match.
39
+ _ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*$")
40
+
41
+
42
+ def _failure_message(
43
+ rendered: tuple[str, ...], index: int, outcome: CommandOutcome
44
+ ) -> str:
45
+ """Say what failed and show what it printed, on whichever stream it used.
46
+
47
+ Test runners, linters, and type checkers overwhelmingly print their
48
+ diagnostics to stdout, so reporting stderr alone would leave the operator,
49
+ who has to choose between retrying and forcing past a failure, with no
50
+ basis for the decision.
51
+ """
52
+ detail = outcome.stderr.strip() or outcome.stdout.strip()
53
+ command = shlex.join(rendered) if rendered else ""
54
+ heading = f"automatic handler failed ({outcome.exit_code})"
55
+ if command:
56
+ heading += f" running: {command}"
57
+ elif index:
58
+ heading += f" at command {index + 1}"
59
+ if not detail:
60
+ return f"{heading}; it printed nothing. See the command output artifact."
61
+ return f"{heading}\n\n{_tail(detail)}"
62
+
63
+
64
+ def _tail(detail: str, limit: int = 40) -> str:
65
+ """Keep the end of the output, where a failure normally explains itself."""
66
+ lines = detail.splitlines()
67
+ if len(lines) <= limit:
68
+ return detail
69
+ dropped = len(lines) - limit
70
+ return "\n".join(
71
+ [f"[{dropped} earlier line(s) omitted; the full output is an artifact]"]
72
+ + lines[-limit:]
73
+ )
74
+
75
+
76
+ class CommandAction(AutomaticAction[Commands, Commands]):
77
+ identifier = "cli"
78
+ planned_type = Commands
79
+
80
+ def execute(self, planned: Commands, context: ExecutionContext) -> ActionResult:
81
+ """Run the command sequence while the command service records each effect."""
82
+ outputs: list[str] = []
83
+ for index, command in enumerate(planned.commands):
84
+ # Reading completed output first is intentional: inputs may have
85
+ # changed after interruption, but a completed external operation is
86
+ # never re-rendered or replayed merely to aggregate its output.
87
+ outcome = context.commands.completed(index)
88
+ rendered_for_report: tuple[str, ...] = ()
89
+ if outcome is None:
90
+ try:
91
+ rendered, environment = self.render_command(
92
+ command, context.runtime_values
93
+ )
94
+ except StateError as error:
95
+ return ActionResult.failed(str(error))
96
+ rendered_for_report = tuple(rendered)
97
+ outcome = context.commands.execute(
98
+ index, CommandRequest(tuple(rendered), environment)
99
+ )
100
+ if not outcome.ok:
101
+ if outcome.launch_error is not None:
102
+ return ActionResult.failed(
103
+ f"automatic handler could not launch command {index + 1}: "
104
+ f"{outcome.launch_error}"
105
+ )
106
+ return ActionResult.failed(
107
+ _failure_message(rendered_for_report, index, outcome)
108
+ )
109
+ outputs.append(outcome.stdout)
110
+ output = "\n".join(part for part in outputs if part).strip()
111
+ if planned.assertion and not planned.assertion.holds(output):
112
+ expected = " and ".join(
113
+ "no output" if condition.value is None else repr(condition.value)
114
+ for condition in planned.assertion.conditions
115
+ )
116
+ return ActionResult.failed(
117
+ f"automatic handler assertion failed: expected {expected}, "
118
+ f"got {output!r}",
119
+ output=output,
120
+ )
121
+ return ActionResult.succeeded(output)
122
+
123
+ def traits(self, planned: Commands) -> ActionTraits:
124
+ return ActionTraits(
125
+ attests_output=planned.assertion is not None,
126
+ command_segments=planned.commands,
127
+ idempotent=planned.idempotent,
128
+ )
129
+
130
+ def parse(
131
+ self, source: dict[str, Any], name: str, description: str, path: str
132
+ ) -> Commands:
133
+ commands, assertion = _parse_command(source, path)
134
+ if not commands:
135
+ raise ConfigurationError(f"{path} requires argv or shell")
136
+ return Commands(commands, assertion, _parse_idempotent(source, path))
137
+
138
+ def validate(self, definition: Commands, path: str) -> None:
139
+ if not definition.commands:
140
+ raise ConfigurationError(f"{path} command requires at least one command")
141
+
142
+ def templates(self, definition: Commands) -> tuple[str, ...]:
143
+ return tuple(
144
+ template
145
+ for command in definition.commands
146
+ for template in command.templates
147
+ )
148
+
149
+ def render_command(
150
+ self, command: CommandDefinition, values: Mapping[str, str]
151
+ ) -> tuple[list[str], dict[str, str]]:
152
+ missing = {
153
+ name
154
+ for template in command.templates
155
+ for name in dependencies(template)
156
+ if name not in values
157
+ }
158
+ context_error = item_context_error(missing, values)
159
+ if context_error is not None:
160
+ raise StateError(context_error)
161
+ if missing:
162
+ raise StateError(
163
+ "automatic handler is missing variable(s): "
164
+ + ", ".join(sorted(missing))
165
+ )
166
+ environment = {
167
+ name: interpolate(template, values) for name, template in command.env
168
+ }
169
+ if command.shell is not None:
170
+ if dependencies(command.shell):
171
+ raise StateError(
172
+ "saved shell source contains interpolation; use shell args or env"
173
+ )
174
+ return [
175
+ "/bin/sh",
176
+ "-c",
177
+ command.shell,
178
+ "ww-command",
179
+ *(interpolate(argument, values) for argument in command.args),
180
+ ], environment
181
+ return [interpolate(argument, values) for argument in command.argv], environment
182
+
183
+ def plan(self, definition: Commands, context: ResolutionContext) -> Commands:
184
+ commands = tuple(
185
+ CommandDefinition(
186
+ argv=tuple(context.interpolate(value) for value in command.argv),
187
+ shell=command.shell,
188
+ args=tuple(context.interpolate(value) for value in command.args),
189
+ env=tuple(
190
+ (key, context.interpolate(value)) for key, value in command.env
191
+ ),
192
+ )
193
+ for command in definition.commands
194
+ )
195
+ return Commands(commands, definition.assertion, definition.idempotent)
196
+
197
+ def instruction(
198
+ self, planned: Commands, context: InstructionContext
199
+ ) -> InstructionContent:
200
+ return InstructionContent(
201
+ context.description or context.name,
202
+ (
203
+ "> **Automated by ww:** shown for information only; do not run "
204
+ "this command yourself.",
205
+ "",
206
+ "**Run**",
207
+ "",
208
+ "```sh",
209
+ *(_display_command(command) for command in planned.commands),
210
+ "```",
211
+ "",
212
+ *(
213
+ (
214
+ "**Recovery**",
215
+ "",
216
+ "Idempotent: after an interruption ww replays it "
217
+ "without asking the operator.",
218
+ "",
219
+ )
220
+ if planned.idempotent
221
+ else ()
222
+ ),
223
+ ),
224
+ after_shared=(
225
+ (
226
+ "**Check**",
227
+ "",
228
+ planned.assertion.describe(),
229
+ "",
230
+ )
231
+ if planned.assertion
232
+ else ()
233
+ ),
234
+ )
235
+
236
+ def encode(self, planned: Commands) -> dict[str, object]:
237
+ data: dict[str, object] = {
238
+ "commands": [command.to_dict() for command in planned.commands],
239
+ "assert": planned.assertion.to_data() if planned.assertion else None,
240
+ }
241
+ # The default is left out so plans saved before the key existed still
242
+ # decode, and a plan that relies on it is refused by a ww without it.
243
+ if planned.idempotent:
244
+ data["idempotent"] = True
245
+ return data
246
+
247
+ def decode(self, data: dict[str, Any]) -> Commands:
248
+ if not {"commands", "assert"} <= set(data):
249
+ raise ValueError(f"action {self.identifier!r} payload has invalid fields")
250
+ idempotent = data.get("idempotent", False)
251
+ if not isinstance(idempotent, bool):
252
+ raise ValueError(f"action {self.identifier!r} idempotent must be a boolean")
253
+ return Commands(
254
+ _commands_from_list(data["commands"], "action"),
255
+ _assertion_from_dict(data["assert"], "action"),
256
+ idempotent,
257
+ )
258
+
259
+
260
+ def _command_from_dict(data: Any) -> CommandDefinition:
261
+ if not isinstance(data, dict):
262
+ raise ValueError("plan command must be a mapping")
263
+ if "argv" in data:
264
+ if "shell" in data:
265
+ raise ValueError("plan command cannot be both argv and shell")
266
+ argv = data["argv"]
267
+ if (
268
+ not isinstance(argv, list)
269
+ or not argv
270
+ or not all(isinstance(item, str) for item in argv)
271
+ ):
272
+ raise ValueError("plan command argv must be a list of strings")
273
+ return CommandDefinition(argv=tuple(argv))
274
+ script = data.get("shell")
275
+ args = data.get("args", [])
276
+ env = data.get("env", {})
277
+ if (
278
+ not isinstance(script, str)
279
+ or not isinstance(args, list)
280
+ or not all(isinstance(item, str) for item in args)
281
+ or not isinstance(env, dict)
282
+ or not all(
283
+ isinstance(key, str) and isinstance(value, str)
284
+ for key, value in env.items()
285
+ )
286
+ ):
287
+ raise ValueError("invalid plan shell command")
288
+ return CommandDefinition(shell=script, args=tuple(args), env=tuple(env.items()))
289
+
290
+
291
+ def _assertion_from_dict(value: Any, item_path: str) -> AssertionDefinition | None:
292
+ """Decode a saved ``assert`` list: ``"empty"`` or ``{"equals": <value>}``."""
293
+ if value is None:
294
+ return None
295
+ if not isinstance(value, list) or not value:
296
+ raise ValueError(f"{item_path}.assert must be a non-empty list or null")
297
+ conditions = []
298
+ for entry in value:
299
+ if entry == "empty":
300
+ conditions.append(AssertionCondition("empty"))
301
+ elif isinstance(entry, dict) and set(entry) == {"equals"}:
302
+ conditions.append(
303
+ AssertionCondition(
304
+ "equals", expect_string(entry["equals"], "assertion value")
305
+ )
306
+ )
307
+ else:
308
+ raise ValueError(f"{item_path}.assert has an invalid condition")
309
+ return AssertionDefinition(tuple(conditions))
310
+
311
+
312
+ def _commands_from_list(value: Any, item_path: str) -> tuple[CommandDefinition, ...]:
313
+ if not isinstance(value, list):
314
+ raise ValueError(f"{item_path}.commands must be a list")
315
+ result = []
316
+ for index, command in enumerate(value):
317
+ try:
318
+ result.append(_command_from_dict(command))
319
+ except ValueError as error:
320
+ raise ValueError(f"{item_path}.commands[{index}]: {error}") from error
321
+ return tuple(result)
322
+
323
+
324
+ def _parse_assertion(raw: object, path: str) -> AssertionDefinition:
325
+ """Parse ``assert``: a list of conditions that must all hold.
326
+
327
+ A condition is ``empty`` or ``{equals: <value>}``.
328
+ """
329
+ context = f"{path}.assert"
330
+ if not isinstance(raw, list) or not raw:
331
+ raise ConfigurationError(
332
+ f"{context} must be a non-empty list of conditions, such as "
333
+ "[empty] or [{equals: clean}]"
334
+ )
335
+ conditions = []
336
+ for index, entry in enumerate(raw):
337
+ entry_path = f"{context}[{index}]"
338
+ if entry == "empty":
339
+ conditions.append(AssertionCondition("empty"))
340
+ continue
341
+ condition = expect_mapping(entry, entry_path, error=ConfigurationError)
342
+ reject_unknown_keys(condition, {"equals"}, entry_path, error=ConfigurationError)
343
+ if "equals" not in condition:
344
+ raise ConfigurationError(f"{entry_path} must be empty or {{equals: ...}}")
345
+ conditions.append(
346
+ AssertionCondition(
347
+ "equals",
348
+ expect_nonempty_string(
349
+ condition["equals"],
350
+ f"{entry_path}.equals",
351
+ error=ConfigurationError,
352
+ ),
353
+ )
354
+ )
355
+ return AssertionDefinition(tuple(conditions))
356
+
357
+
358
+ def _parse_command(
359
+ mapping: dict[str, Any], path: str
360
+ ) -> tuple[tuple[CommandDefinition, ...], AssertionDefinition | None]:
361
+ action_keys = {"argv", "shell"} & set(mapping)
362
+ if not action_keys:
363
+ if {"args", "env", "assert", "idempotent"} & set(mapping):
364
+ raise ConfigurationError(
365
+ f"{path} args, env, assert, and idempotent require argv or shell"
366
+ )
367
+ return (), None
368
+ if len(action_keys) != 1:
369
+ raise ConfigurationError(f"{path} cannot combine argv and shell")
370
+ action = {
371
+ key: mapping[key] for key in action_keys | ({"args", "env"} & set(mapping))
372
+ }
373
+ assertion = (
374
+ _parse_assertion(mapping["assert"], path)
375
+ if mapping.get("assert") is not None
376
+ else None
377
+ )
378
+ return (_parse_command_action(action, path),), assertion
379
+
380
+
381
+ def _parse_idempotent(mapping: Mapping[str, Any], path: str) -> bool:
382
+ """Read the handler-level replay declaration; it defaults to ``False``."""
383
+ if "idempotent" not in mapping:
384
+ return False
385
+ return expect_bool(
386
+ mapping["idempotent"], f"{path}.idempotent", error=ConfigurationError
387
+ )
388
+
389
+
390
+ def _parse_command_action(value: dict[Any, Any], path: str) -> CommandDefinition:
391
+ if "argv" in value:
392
+ reject_unknown_keys(value, {"argv"}, path, error=ConfigurationError)
393
+ argv = value["argv"]
394
+ if (
395
+ not isinstance(argv, list)
396
+ or not argv
397
+ or not all(isinstance(item, str) and item for item in argv)
398
+ ):
399
+ raise ConfigurationError(f"{path} argv must be a non-empty list of strings")
400
+ return CommandDefinition(argv=tuple(argv))
401
+ if "shell" in value:
402
+ reject_unknown_keys(
403
+ value, {"shell", "args", "env"}, path, error=ConfigurationError
404
+ )
405
+ script = value["shell"]
406
+ if not isinstance(script, str) or not script.strip():
407
+ raise ConfigurationError(f"{path} shell must be a non-empty string")
408
+ if dependencies(script):
409
+ raise ConfigurationError(
410
+ f"{path} shell source cannot interpolate values; use args or env"
411
+ )
412
+ args = value.get("args", [])
413
+ if not isinstance(args, list) or not all(
414
+ isinstance(item, str) for item in args
415
+ ):
416
+ raise ConfigurationError(f"{path} shell args must be a list of strings")
417
+ raw_env = value.get("env", {})
418
+ if not isinstance(raw_env, dict) or not all(
419
+ isinstance(name, str)
420
+ and _ENV_NAME.fullmatch(name)
421
+ and isinstance(item, str)
422
+ for name, item in raw_env.items()
423
+ ):
424
+ raise ConfigurationError(
425
+ f"{path} shell env must map variable names to strings"
426
+ )
427
+ return CommandDefinition(
428
+ shell=script,
429
+ args=tuple(args),
430
+ env=tuple(raw_env.items()),
431
+ )
432
+ raise ConfigurationError(f"{path} command action requires argv or shell")
433
+
434
+
435
+ def _display_command(command: CommandDefinition) -> str:
436
+ if command.shell is not None:
437
+ environment = " ".join(
438
+ f"{name}={shlex.quote(value)}" for name, value in command.env
439
+ )
440
+ invocation = shlex.join(
441
+ ["/bin/sh", "-c", command.shell, "ww-command", *command.args]
442
+ )
443
+ return " ".join(part for part in (environment, invocation) if part)
444
+ return shlex.join(command.argv)