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
@@ -0,0 +1,699 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Typed action payloads, phase contexts, and the internal registry."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any, ClassVar, Generic, Literal, Protocol, TypeVar
11
+
12
+ from ww.contracts import AssertionKind, ExecutionKind, PlanItemOwner
13
+ from ww.discovery import AvailableActions
14
+ from ww.errors import ConfigurationError
15
+ from ww.extensions import (
16
+ ExtensionCheckResult,
17
+ ExtensionContext,
18
+ ExtensionHandler,
19
+ ExtensionRegistry,
20
+ )
21
+ from ww.interpolation import dependencies, interpolate
22
+ from ww.validation import (
23
+ expect_literal,
24
+ expect_string,
25
+ is_strict_int,
26
+ )
27
+ from ww.variables import RUNTIME_PREFIXES, unknown_template_message
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Prompt:
32
+ text: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Skill:
37
+ name: str
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class SlashCommand:
42
+ name: str
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Mcp:
47
+ connection: str
48
+ prompt: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class AssertionCondition:
53
+ """One condition on a command's output: ``empty``, or ``equals`` a value.
54
+
55
+ ``value`` is ``None`` exactly when the kind is ``empty``.
56
+ """
57
+
58
+ kind: AssertionKind
59
+ value: str | None = None
60
+
61
+ def __post_init__(self) -> None:
62
+ if self.kind not in {"empty", "equals"}:
63
+ raise ValueError(f"invalid assertion condition: {self.kind!r}")
64
+ if (self.kind == "equals") != isinstance(self.value, str):
65
+ raise ValueError("an equals condition requires a value; empty takes none")
66
+
67
+ def holds(self, output: str) -> bool:
68
+ if self.kind == "empty":
69
+ return output.strip() == ""
70
+ return output == self.value
71
+
72
+ def describe(self) -> str:
73
+ if self.kind == "empty":
74
+ return "empty"
75
+ return f"equal to `{self.value}`"
76
+
77
+ def to_data(self) -> str | dict[str, str]:
78
+ """``"empty"``, or ``{"equals": <value>}``, as ``assert`` lists write it."""
79
+ if self.value is None:
80
+ return self.kind
81
+ return {self.kind: self.value}
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class AssertionDefinition:
86
+ """What a command's output must satisfy: every condition of ``assert``."""
87
+
88
+ conditions: tuple[AssertionCondition, ...]
89
+
90
+ def __post_init__(self) -> None:
91
+ if not self.conditions:
92
+ raise ValueError("an assertion needs at least one condition")
93
+
94
+ def holds(self, output: str) -> bool:
95
+ """Whether the command's output satisfies every condition."""
96
+ return all(condition.holds(output) for condition in self.conditions)
97
+
98
+ def describe(self) -> str:
99
+ """The requirement in words, for instructions and failure messages."""
100
+ return (
101
+ "Command output must be "
102
+ + " and ".join(condition.describe() for condition in self.conditions)
103
+ + "."
104
+ )
105
+
106
+ def to_data(self) -> list[str | dict[str, str]]:
107
+ return [condition.to_data() for condition in self.conditions]
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class CommandDefinition:
112
+ """A command whose data arguments are distinct from optional shell source."""
113
+
114
+ argv: tuple[str, ...] = ()
115
+ shell: str | None = None
116
+ args: tuple[str, ...] = ()
117
+ env: tuple[tuple[str, str], ...] = ()
118
+
119
+ def __post_init__(self) -> None:
120
+ if bool(self.argv) == (self.shell is not None):
121
+ raise ValueError("command requires exactly one of argv or shell")
122
+ if self.shell is None and (self.args or self.env):
123
+ raise ValueError("command args and env require shell source")
124
+
125
+ def to_dict(self) -> dict[str, object]:
126
+ if self.shell is None:
127
+ return {"argv": list(self.argv)}
128
+ result: dict[str, object] = {"shell": self.shell}
129
+ if self.args:
130
+ result["args"] = list(self.args)
131
+ if self.env:
132
+ result["env"] = dict(self.env)
133
+ return result
134
+
135
+ @property
136
+ def templates(self) -> tuple[str, ...]:
137
+ """Return interpolated fields; shell source is intentionally excluded."""
138
+ return (*self.argv, *self.args, *(value for _, value in self.env))
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class Commands:
143
+ commands: tuple[CommandDefinition, ...]
144
+ assertion: AssertionDefinition | None = None
145
+ # The author's statement that running the sequence again is harmless, so
146
+ # an interrupted run may be replayed without an operator decision.
147
+ idempotent: bool = False
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class Extension:
152
+ reference: str
153
+ version: str | None = None
154
+ api_version: int | None = None
155
+ source: str | None = None
156
+ fingerprint: str | None = None
157
+ settings: dict[str, object] | None = None
158
+ # Positional arguments from the reference's ``args``; templates until
159
+ # the item runs, when they are rendered with its values.
160
+ arguments: tuple[str, ...] = ()
161
+
162
+
163
+ ActionPayload = Prompt | Skill | SlashCommand | Mcp | Commands | Extension
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class PlannedAction:
168
+ """An action's stable identity and typed compiled payload."""
169
+
170
+ identifier: str
171
+ payload: object
172
+ serialized_payload: dict[str, object] | None = field(
173
+ default=None, compare=False, repr=False
174
+ )
175
+
176
+ @property
177
+ def kind(self) -> str:
178
+ """An ordinary action is identified by its registry identifier."""
179
+ return self.identifier
180
+
181
+ def to_dict(self) -> dict[str, object]:
182
+ if not actions.contains(self.identifier):
183
+ payload = self.serialized_payload
184
+ if payload is None and isinstance(self.payload, dict):
185
+ payload = self.payload
186
+ if payload is None:
187
+ raise ValueError("unavailable action payload must remain plain data")
188
+ return {"identifier": self.identifier, "payload": payload}
189
+ return {
190
+ "identifier": self.identifier,
191
+ "payload": actions.get(self.identifier).encode(self.payload),
192
+ }
193
+
194
+ @classmethod
195
+ def from_dict(cls, data: object) -> PlannedAction:
196
+ if not isinstance(data, dict) or not {"identifier", "payload"} <= set(data):
197
+ raise ValueError("plan action must have identifier and payload")
198
+ identifier = expect_string(data["identifier"], "action identifier")
199
+ payload = data["payload"]
200
+ if not isinstance(payload, dict):
201
+ raise ValueError("plan action payload must be a mapping")
202
+ if not actions.contains(identifier):
203
+ return cls(identifier, payload)
204
+ return cls(identifier, actions.get(identifier).decode(payload), payload)
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class DefinedAction:
209
+ """A normalized definition before compilation and interpolation."""
210
+
211
+ identifier: str
212
+ payload: object
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class DefinitionOverrideContext:
217
+ """Notation-level facts an action may use when a handler is reused.
218
+
219
+ Shared handler precedence stays in the configuration layer. An action may
220
+ own the meaning of omitted and explicit fields in its own payload.
221
+ """
222
+
223
+ mapping: Mapping[str, object]
224
+ action_fields: Mapping[str, object]
225
+ description_is_explicit: bool
226
+ local_name: str
227
+ local_description: str
228
+
229
+
230
+ DefinitionT = TypeVar("DefinitionT")
231
+ PlannedT = TypeVar("PlannedT")
232
+ AttestationField = Literal["values", "workspace"]
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class ResolutionContext:
237
+ agent: str
238
+ available: AvailableActions
239
+ extensions: ExtensionRegistry | None
240
+ builtins: dict[str, str]
241
+ allowed_variables: frozenset[str]
242
+ # The configured project whose extension settings the item follows: the
243
+ # run's project for an item working in the task workspace or the project
244
+ # directory, none for one working in the root.
245
+ project: str | None = None
246
+
247
+ def interpolate(self, value: str) -> str:
248
+ unknown = {
249
+ name
250
+ for name in dependencies(value)
251
+ if name not in self.allowed_variables
252
+ and not name.startswith(RUNTIME_PREFIXES)
253
+ }
254
+ if unknown:
255
+ raise ConfigurationError(unknown_template_message(unknown))
256
+ return interpolate(value, self.builtins)
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class InstructionContext:
261
+ description: str
262
+ name: str
263
+ task_values: dict[str, str]
264
+
265
+
266
+ @dataclass(frozen=True)
267
+ class InstructionContent:
268
+ text: str
269
+ markdown: tuple[str, ...]
270
+ show_context: bool = True
271
+ after_shared: tuple[str, ...] = ()
272
+ include_item_context: bool = False
273
+
274
+
275
+ @dataclass(frozen=True)
276
+ class ExtensionBinding:
277
+ """An extension reference and the frozen settings a payload binds to it."""
278
+
279
+ reference: str
280
+ settings: dict[str, object] | None
281
+
282
+
283
+ @dataclass(frozen=True)
284
+ class ActionTraits:
285
+ """How core treats one planned payload.
286
+
287
+ Every field has the plain default, so an action only names what it needs:
288
+ ``command_segments`` is ``None`` unless the action runs durable command
289
+ segments, ``attests_output`` says an interrupted command needs operator
290
+ stdout to settle its assertion, ``manual_attestation`` lists what an
291
+ operator may attest after an interruption, and ``idempotent`` lets core
292
+ replay an interrupted action without asking anyone.
293
+ """
294
+
295
+ attests_output: bool = False
296
+ artifact_attribution: str = "auto"
297
+ manual_attestation: frozenset[AttestationField] = frozenset()
298
+ extension_binding: ExtensionBinding | None = None
299
+ command_segments: tuple[CommandDefinition, ...] | None = None
300
+ idempotent: bool = False
301
+
302
+
303
+ class Action(ABC, Generic[DefinitionT, PlannedT]):
304
+ """One kind of ordinary work an agent performs or ww runs on its behalf.
305
+
306
+ A subclass owns its payload types and their whole lifecycle: parsing the
307
+ YAML shape, validating and planning it, rendering the instruction, and
308
+ encoding the planned payload for the saved plan. The two hooks at the end
309
+ have safe defaults; override them only when the action has that behaviour.
310
+ Workflow structure (loops, handoffs, children) is not an action.
311
+ """
312
+
313
+ identifier: ClassVar[str]
314
+ owner: ClassVar[PlanItemOwner] = "agent"
315
+ execution: ClassVar[ExecutionKind] = "agent_instruction"
316
+ planned_type: type[PlannedT]
317
+
318
+ @abstractmethod
319
+ def parse(
320
+ self, source: dict[str, Any], name: str, description: str, path: str
321
+ ) -> DefinitionT:
322
+ """Read the action's own fields from an authored handler mapping."""
323
+
324
+ @abstractmethod
325
+ def validate(self, definition: DefinitionT, path: str) -> None:
326
+ """Reject a definition that parsed but cannot be planned."""
327
+
328
+ @abstractmethod
329
+ def templates(self, definition: DefinitionT) -> tuple[str, ...]:
330
+ """Return the fields core scans for ``{{variable}}`` dependencies."""
331
+
332
+ @abstractmethod
333
+ def plan(self, definition: DefinitionT, context: ResolutionContext) -> PlannedT:
334
+ """Resolve a definition against one agent, project, and variable set."""
335
+
336
+ @abstractmethod
337
+ def instruction(
338
+ self, planned: PlannedT, context: InstructionContext
339
+ ) -> InstructionContent:
340
+ """Render what the agent (or ``ww plan``) shows for this payload."""
341
+
342
+ @abstractmethod
343
+ def encode(self, planned: PlannedT) -> dict[str, object]:
344
+ """Return the plain-data form saved in a plan snapshot."""
345
+
346
+ @abstractmethod
347
+ def decode(self, data: dict[str, Any]) -> PlannedT:
348
+ """Rebuild a planned payload strictly from its saved form."""
349
+
350
+ def override_definition(
351
+ self,
352
+ local: DefinitionT,
353
+ inherited: DefinitionT,
354
+ context: DefinitionOverrideContext,
355
+ ) -> DefinitionT:
356
+ """Merge a step's local payload with the catalog handler it references.
357
+
358
+ Shared handler precedence stays in the configuration layer; an action
359
+ decides what its own omitted fields inherit. By default the local
360
+ payload replaces the inherited one.
361
+ """
362
+ del inherited, context
363
+ return local
364
+
365
+ def traits(self, planned: PlannedT) -> ActionTraits:
366
+ """Describe how core should treat a planned payload."""
367
+ del planned
368
+ return ActionTraits()
369
+
370
+
371
+ @dataclass(frozen=True)
372
+ class ActionResult:
373
+ """The complete, validated-by-core outcome proposed by an action."""
374
+
375
+ ok: bool
376
+ output: str = ""
377
+ error: str = ""
378
+ values: dict[str, str] = field(default_factory=dict)
379
+ working_directory: Path | None = None
380
+
381
+ @classmethod
382
+ def succeeded(
383
+ cls,
384
+ output: str = "",
385
+ *,
386
+ values: Mapping[str, str] | None = None,
387
+ working_directory: Path | None = None,
388
+ ) -> ActionResult:
389
+ return cls(
390
+ True,
391
+ output,
392
+ values=dict(values or {}),
393
+ working_directory=working_directory,
394
+ )
395
+
396
+ @classmethod
397
+ def failed(cls, error: str, *, output: str = "") -> ActionResult:
398
+ return cls(False, output, error)
399
+
400
+
401
+ @dataclass(frozen=True)
402
+ class RecoveryCheckResult:
403
+ """A checker attestation for an interrupted automatic action.
404
+
405
+ ``action`` attests the entire action and therefore permits normal result
406
+ application (output values and working directory). ``command_segment``
407
+ attests just one durable command boundary; it may carry stdout only and
408
+ the coordinator resumes the remaining action afterwards. The explicit
409
+ scope prevents a generic success from silently completing a multi-command
410
+ action.
411
+ """
412
+
413
+ status: Literal["succeeded", "not_succeeded", "unknown"]
414
+ result: ActionResult | None = None
415
+ scope: Literal["action", "command_segment"] = "action"
416
+ segment: int | None = None
417
+ error: str = ""
418
+
419
+ def __post_init__(self) -> None:
420
+ if self.status not in {"succeeded", "not_succeeded", "unknown"}:
421
+ raise ValueError("recovery check result has an invalid status")
422
+ if self.scope not in {"action", "command_segment"}:
423
+ raise ValueError("recovery check result has an invalid scope")
424
+ if not isinstance(self.error, str):
425
+ raise TypeError("recovery check result error must be a string")
426
+ if self.status == "succeeded":
427
+ if not isinstance(self.result, ActionResult) or not self.result.ok:
428
+ raise ValueError(
429
+ "a succeeded recovery check requires a successful result"
430
+ )
431
+ if self.scope == "command_segment":
432
+ if not is_strict_int(self.segment) or self.segment < 0:
433
+ raise ValueError(
434
+ "a command-segment recovery check requires a segment"
435
+ )
436
+ if self.result.values or self.result.working_directory is not None:
437
+ raise ValueError(
438
+ "a command-segment recovery check cannot provide values "
439
+ "or a working directory"
440
+ )
441
+ elif self.segment is not None:
442
+ raise ValueError(
443
+ "an action recovery check cannot identify a command segment"
444
+ )
445
+ elif self.result is not None or self.segment is not None:
446
+ raise ValueError(
447
+ "only a succeeded recovery check may include a result or segment"
448
+ )
449
+
450
+ @classmethod
451
+ def succeeded(cls, result: ActionResult) -> RecoveryCheckResult:
452
+ return cls("succeeded", result=result)
453
+
454
+ @classmethod
455
+ def succeeded_segment(cls, segment: int, output: str = "") -> RecoveryCheckResult:
456
+ return cls(
457
+ "succeeded",
458
+ result=ActionResult.succeeded(output),
459
+ scope="command_segment",
460
+ segment=segment,
461
+ )
462
+
463
+ @classmethod
464
+ def not_succeeded(cls) -> RecoveryCheckResult:
465
+ return cls("not_succeeded")
466
+
467
+ @classmethod
468
+ def unknown(cls, error: str = "") -> RecoveryCheckResult:
469
+ return cls("unknown", error=error)
470
+
471
+
472
+ @dataclass(frozen=True)
473
+ class CommandRequest:
474
+ """One prepared command segment. The service owns its durable execution."""
475
+
476
+ argv: tuple[str, ...]
477
+ environment: dict[str, str]
478
+
479
+
480
+ @dataclass(frozen=True)
481
+ class CommandOutcome:
482
+ """Full command output; previews remain solely in durable state records."""
483
+
484
+ ok: bool
485
+ stdout: str = ""
486
+ stderr: str = ""
487
+ exit_code: int | None = None
488
+ launch_error: str | None = None
489
+
490
+
491
+ class CommandService(Protocol):
492
+ def completed(self, segment: int) -> CommandOutcome | None: ...
493
+
494
+ def execute(self, segment: int, request: CommandRequest) -> CommandOutcome: ...
495
+
496
+
497
+ class ExtensionService(Protocol):
498
+ def validate_identity(self, planned: Extension) -> None: ...
499
+
500
+ def context(self, planned: Extension) -> ExtensionContext: ...
501
+
502
+ def handler(self, reference: str) -> ExtensionHandler: ...
503
+
504
+
505
+ class ExtensionIdentityService(Protocol):
506
+ """Identity checks available before an external operation is recorded."""
507
+
508
+ def validate_identity(self, planned: Extension) -> None: ...
509
+
510
+
511
+ class RecoveryExtensionService(Protocol):
512
+ """Checker-only extension operations available during recovery."""
513
+
514
+ def validate_identity(self, planned: Extension) -> None: ...
515
+
516
+ def check(self, planned: Extension) -> ExtensionCheckResult: ...
517
+
518
+
519
+ class ExtensionHandlerService(Protocol):
520
+ """Handler lookup only, for checks that run before any work is recorded."""
521
+
522
+ def handler(self, reference: str) -> ExtensionHandler: ...
523
+
524
+
525
+ class InputValidationContext(Protocol):
526
+ """What an action may consult while judging values an agent supplied."""
527
+
528
+ @property
529
+ def extensions(self) -> ExtensionHandlerService: ...
530
+
531
+
532
+ class ExecutionContext(Protocol):
533
+ """Read-only invocation information plus deliberately narrow effects."""
534
+
535
+ @property
536
+ def root(self) -> Path: ...
537
+
538
+ @property
539
+ def workspace(self) -> Path | None: ...
540
+
541
+ @property
542
+ def runtime_values(self) -> Mapping[str, str]: ...
543
+
544
+ @property
545
+ def task_id(self) -> str: ...
546
+
547
+ @property
548
+ def run_id(self) -> str | None: ...
549
+
550
+ @property
551
+ def operation_id(self) -> str: ...
552
+
553
+ @property
554
+ def attempt(self) -> int: ...
555
+
556
+ @property
557
+ def commands(self) -> CommandService: ...
558
+
559
+ @property
560
+ def extensions(self) -> ExtensionService: ...
561
+
562
+
563
+ class PreflightContext(Protocol):
564
+ """Read-only validation context with no effect-capable services."""
565
+
566
+ @property
567
+ def root(self) -> Path: ...
568
+ @property
569
+ def workspace(self) -> Path | None: ...
570
+ @property
571
+ def runtime_values(self) -> Mapping[str, str]: ...
572
+ @property
573
+ def task_id(self) -> str: ...
574
+ @property
575
+ def run_id(self) -> str | None: ...
576
+ @property
577
+ def extensions(self) -> ExtensionIdentityService: ...
578
+
579
+
580
+ class RecoveryContext(Protocol):
581
+ """Saved-operation information without command execution capabilities."""
582
+
583
+ @property
584
+ def root(self) -> Path: ...
585
+ @property
586
+ def workspace(self) -> Path | None: ...
587
+ @property
588
+ def runtime_values(self) -> Mapping[str, str]: ...
589
+ @property
590
+ def task_id(self) -> str: ...
591
+ @property
592
+ def run_id(self) -> str | None: ...
593
+ @property
594
+ def operation_id(self) -> str: ...
595
+ @property
596
+ def attempt(self) -> int: ...
597
+ @property
598
+ def extensions(self) -> RecoveryExtensionService: ...
599
+
600
+
601
+ class AutomaticAction(Action[DefinitionT, PlannedT]):
602
+ """An action ww runs itself, recording its external effects durably.
603
+
604
+ Core writes an ``in_progress`` record before calling :meth:`execute`, so an
605
+ interruption leaves an unknown-outcome boundary. :meth:`preflight` runs
606
+ before that record exists and must have no external effect;
607
+ :meth:`check_recovery` may later settle the outcome without replaying it.
608
+ """
609
+
610
+ owner: ClassVar[PlanItemOwner] = "ww"
611
+ execution: ClassVar[ExecutionKind] = "automatic"
612
+
613
+ @abstractmethod
614
+ def execute(self, planned: PlannedT, context: ExecutionContext) -> ActionResult:
615
+ """Perform the work through the narrow services on ``context``."""
616
+
617
+ def preflight(self, planned: PlannedT, context: PreflightContext) -> None:
618
+ """Validate identity and configuration before core records a start."""
619
+ del planned, context
620
+
621
+ def validate_inputs(
622
+ self,
623
+ planned: PlannedT,
624
+ values: Mapping[str, str],
625
+ context: InputValidationContext,
626
+ ) -> str | None:
627
+ """Judge the declared inputs an agent is about to hand this action.
628
+
629
+ Core calls this while the completion that carries the values is still
630
+ refusable, so a value the action would reject at run time is turned
631
+ back with this message before anything is saved. ``values`` holds
632
+ only the action's own declared inputs. ``None`` accepts them.
633
+ """
634
+ del planned, values, context
635
+ return None
636
+
637
+ def check_recovery(
638
+ self, planned: PlannedT, context: RecoveryContext
639
+ ) -> RecoveryCheckResult | None:
640
+ """Settle an interrupted operation's outcome; ``None`` means no checker."""
641
+ del planned, context
642
+ return None
643
+
644
+
645
+ class ActionRegistry:
646
+ """Explicit, replaceable internal registration boundary."""
647
+
648
+ def __init__(self) -> None:
649
+ self._actions: dict[str, Action[Any, Any]] = {}
650
+
651
+ def register(self, action: Action[Any, Any]) -> None:
652
+ self._validate(action)
653
+ if action.identifier in self._actions:
654
+ raise ValueError(f"action {action.identifier!r} is already registered")
655
+ self._actions[action.identifier] = action
656
+
657
+ @staticmethod
658
+ def _validate(action: Action[Any, Any]) -> None:
659
+ """Reject inconsistent registrations at the only extension boundary."""
660
+ if not isinstance(action, Action):
661
+ raise TypeError("actions must subclass ww.actions.Action")
662
+ identifier = getattr(action, "identifier", None)
663
+ if not isinstance(identifier, str) or not identifier:
664
+ raise TypeError("action identifier must be a non-empty string")
665
+ expect_literal(action.owner, PlanItemOwner, f"action {identifier!r} owner")
666
+ expect_literal(
667
+ action.execution, ExecutionKind, f"action {identifier!r} execution"
668
+ )
669
+ if action.owner == "agent" and action.execution != "agent_instruction":
670
+ raise ValueError(
671
+ f"action {identifier!r} has incompatible owner/execution "
672
+ f"combination {action.owner!r}/{action.execution!r}"
673
+ )
674
+ if (action.execution == "automatic") != isinstance(action, AutomaticAction):
675
+ raise TypeError(
676
+ f"action {identifier!r} with execution {action.execution!r} must "
677
+ + ("" if action.execution == "automatic" else "not ")
678
+ + "subclass AutomaticAction"
679
+ )
680
+ if not isinstance(getattr(action, "planned_type", None), type):
681
+ raise TypeError(f"action {identifier!r} planned_type must be a type")
682
+
683
+ def get(self, identifier: str) -> Action[Any, Any]:
684
+ try:
685
+ return self._actions[identifier]
686
+ except KeyError as error:
687
+ raise ConfigurationError(
688
+ f"action implementation {identifier!r} is unavailable"
689
+ ) from error
690
+
691
+ def contains(self, identifier: str) -> bool:
692
+ return identifier in self._actions
693
+
694
+ def unregister(self, identifier: str) -> None:
695
+ """Remove an internal registration, primarily for isolated tests."""
696
+ self._actions.pop(identifier)
697
+
698
+
699
+ actions = ActionRegistry()