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/replanning.py ADDED
@@ -0,0 +1,367 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Mid-run replanning: a running task takes a changed workflow definition.
3
+
4
+ A task freezes its compiled plan when it starts. When the configuration
5
+ changes afterwards, the manager's ``next`` compiles the task's workflow again
6
+ and compares the new template with the one the run started from, item by
7
+ item in plan order. The first item that differs is the *change point*; ww
8
+ stops for the operator (``operator_reason: plan_changed``), who either takes
9
+ the new definition from that item on (``next --replan``) or carries on with
10
+ the saved plan (``next --keep-plan``).
11
+
12
+ Replanning splices the plan: every item before the change point keeps its
13
+ record, the change point and everything after it are the newly compiled
14
+ items with fresh records. When the change point is an item that already ran,
15
+ the cursor moves back to it and the finished items from there on run again;
16
+ their earlier records move to the run's execution history, so artifacts and
17
+ streams stay readable. What the plan expanded at runtime is respected: a
18
+ verification item belongs to the step it verifies, and a change that reaches
19
+ per-item or per-child stages already expanded, or rewinds past a children
20
+ step whose child tasks exist, is refused with the reason.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import difflib
26
+ import json
27
+ from dataclasses import dataclass, replace
28
+ from typing import Literal
29
+
30
+ from ww.control import child_workflow, loop_control
31
+ from ww.execution_models import ExecutionState, PlanSnapshot
32
+ from ww.execution_models.construction import (
33
+ build_step_projection,
34
+ new_item_execution,
35
+ operation_scope_for,
36
+ )
37
+ from ww.plan import PlanItem, WorkflowPlan
38
+ from ww.plan.models import number_step_paths
39
+ from ww.transitions import Clock, project_steps
40
+
41
+ ChangeKind = Literal["added", "removed", "changed"]
42
+ # Item fields that move with an item's place in the plan rather than its
43
+ # definition, so they never count as a change on their own.
44
+ _PLACEMENT_FIELDS = ("id", "position", "step_ordinals")
45
+ # A field's value is shown up to this many characters on each side.
46
+ _VALUE_LIMIT = 160
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class FieldChange:
51
+ """One field of an item, before and after, as compact JSON."""
52
+
53
+ name: str
54
+ before: str
55
+ after: str
56
+
57
+ def to_dict(self) -> dict[str, object]:
58
+ return {"name": self.name, "before": self.before, "after": self.after}
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ChangedItem:
63
+ """A step or hook the new definition adds, removes, or defines anew."""
64
+
65
+ kind: ChangeKind
66
+ label: str
67
+ fields: tuple[FieldChange, ...] = ()
68
+
69
+ def to_dict(self) -> dict[str, object]:
70
+ return {
71
+ "kind": self.kind,
72
+ "label": self.label,
73
+ "fields": [field.to_dict() for field in self.fields],
74
+ }
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class PlanChange:
79
+ """How the current configuration's plan differs from the saved one.
80
+
81
+ ``first`` is the change point in the template, ``splice`` the same place
82
+ in the run's concrete plan. ``reruns`` names the finished items a replan
83
+ would run again, which the operator confirms; ``refusal`` says why the
84
+ change cannot be applied to this run, when it cannot.
85
+ """
86
+
87
+ configuration_digest: str
88
+ template: WorkflowPlan
89
+ first: int
90
+ splice: int
91
+ changes: tuple[ChangedItem, ...]
92
+ reruns: tuple[str, ...] = ()
93
+ refusal: str | None = None
94
+
95
+ def to_dict(self) -> dict[str, object]:
96
+ return {
97
+ "changes": [change.to_dict() for change in self.changes],
98
+ "reruns": list(self.reruns),
99
+ "refusal": self.refusal,
100
+ }
101
+
102
+
103
+ def plan_change(
104
+ state: ExecutionState,
105
+ snapshot: PlanSnapshot,
106
+ template: WorkflowPlan,
107
+ configuration_digest: str,
108
+ ) -> PlanChange | None:
109
+ """The change ``template`` makes to the run, or ``None`` when it makes none.
110
+
111
+ ``template`` is the run's workflow compiled from the current
112
+ configuration with the options the run started with.
113
+ """
114
+ old = snapshot.template_plan or snapshot.plan
115
+ first = _first_difference(old.items, template.items)
116
+ if first is None:
117
+ if _plan_fields(old) == _plan_fields(template):
118
+ return None
119
+ # Only the workflow's own fields changed: they apply as they are.
120
+ first = len(old.items)
121
+ changes = _changes(old.items[first:], template.items[first:])
122
+ if not changes and first == len(old.items):
123
+ changes = (ChangedItem("changed", f"workflow `{template.workflow}`"),)
124
+ splice, refusal = _splice(state, snapshot, old, first)
125
+ reruns = (
126
+ ()
127
+ if refusal is not None
128
+ else tuple(
129
+ dict.fromkeys(
130
+ _label(item)
131
+ for item, record in zip(
132
+ snapshot.plan.items[splice : state.cursor],
133
+ state.item_executions[splice : state.cursor],
134
+ strict=True,
135
+ )
136
+ if record.status == "completed" and item.verifies is None
137
+ )
138
+ )
139
+ )
140
+ return PlanChange(
141
+ configuration_digest, template, first, splice, changes, reruns, refusal
142
+ )
143
+
144
+
145
+ def replan(
146
+ state: ExecutionState, snapshot: PlanSnapshot, change: PlanChange, now: Clock
147
+ ) -> tuple[ExecutionState, PlanSnapshot]:
148
+ """Take the new definition from the change point on."""
149
+ if change.refusal is not None:
150
+ raise ValueError(change.refusal)
151
+ old = snapshot.plan
152
+ splice = change.splice
153
+ kept = old.items[:splice]
154
+ items = number_step_paths(
155
+ tuple(
156
+ replace(item, position=index)
157
+ for index, item in enumerate(
158
+ (*kept, *change.template.items[change.first :]), 1
159
+ )
160
+ )
161
+ )
162
+ plan = replace(change.template, items=items)
163
+ revision = snapshot.plan_revision + 1
164
+ scope = f"{operation_scope_for(state)}:replan-{revision}"
165
+ records = (
166
+ *(
167
+ replace(record, position=index)
168
+ for index, record in enumerate(state.item_executions[:splice], 1)
169
+ ),
170
+ *(new_item_execution(state.task_id, scope, item) for item in items[splice:]),
171
+ )
172
+ displaced = tuple(
173
+ record
174
+ for record in state.item_executions[splice:]
175
+ if record.status != "pending"
176
+ )
177
+ revised = replace(
178
+ snapshot,
179
+ plan=plan,
180
+ template_plan=change.template,
181
+ plan_revision=revision,
182
+ configuration_digest=change.configuration_digest,
183
+ compiled_at=now(),
184
+ )
185
+ kept_ids = {item.id for item in kept}
186
+ entries = {
187
+ loop.loop_id: index
188
+ for index, item in enumerate(old.items)
189
+ if (loop := loop_control(item)) is not None and loop.boundary == "enter"
190
+ }
191
+ reaches_cursor = splice <= state.cursor
192
+ state = replace(
193
+ state,
194
+ item_executions=records,
195
+ execution_history=(*state.execution_history, *displaced),
196
+ steps=build_step_projection(plan, state.steps),
197
+ snapshot_digest=change.configuration_digest,
198
+ plan_revision=revision,
199
+ plan_digest=revised.plan_digest,
200
+ # A loop entered at or after the change point starts counting anew.
201
+ loop_iterations=tuple(
202
+ (loop_id, count)
203
+ for loop_id, count in state.loop_iterations
204
+ if entries.get(loop_id, -1) < splice
205
+ ),
206
+ loop_exit_item_id=_kept(state.loop_exit_item_id, kept_ids),
207
+ loop_continue_item_id=_kept(state.loop_continue_item_id, kept_ids),
208
+ updated_at=now(),
209
+ )
210
+ if reaches_cursor:
211
+ # The current item is redefined, or the run rewinds to the change
212
+ # point: whatever stopped or occupied it is gone with its record.
213
+ state = replace(
214
+ state,
215
+ cursor=splice,
216
+ status="pending",
217
+ active_item_id=None,
218
+ pending_input_request=None,
219
+ last_error=None,
220
+ failure_kind=None,
221
+ assignment_item_id=None,
222
+ assignment_token=None,
223
+ assignment_model=None,
224
+ assignment_reasoning=None,
225
+ assignment_selected_agent=None,
226
+ assignment_selected_model=None,
227
+ assignment_selected_reasoning=None,
228
+ )
229
+ return project_steps(state, plan, now), revised
230
+
231
+
232
+ def keep_plan(
233
+ state: ExecutionState, snapshot: PlanSnapshot, configuration_digest: str
234
+ ) -> tuple[ExecutionState, PlanSnapshot]:
235
+ """Carry on with the saved plan; this configuration is not offered again."""
236
+ return (
237
+ replace(state, snapshot_digest=configuration_digest),
238
+ replace(snapshot, configuration_digest=configuration_digest),
239
+ )
240
+
241
+
242
+ def _first_difference(
243
+ old: tuple[PlanItem, ...], new: tuple[PlanItem, ...]
244
+ ) -> int | None:
245
+ for index, (before, after) in enumerate(zip(old, new, strict=False)):
246
+ if _definition(before) != _definition(after):
247
+ return index
248
+ if len(old) != len(new):
249
+ return min(len(old), len(new))
250
+ return None
251
+
252
+
253
+ def _definition(item: PlanItem) -> dict[str, object]:
254
+ data = item.to_dict()
255
+ for name in _PLACEMENT_FIELDS:
256
+ data.pop(name, None)
257
+ return data
258
+
259
+
260
+ def _plan_fields(plan: WorkflowPlan) -> dict[str, object]:
261
+ data = plan.to_dict()
262
+ data.pop("items", None)
263
+ return data
264
+
265
+
266
+ def _identity(item: PlanItem) -> tuple[object, ...]:
267
+ """What makes two items the same step or hook across definitions."""
268
+ return (item.workflow, item.step, item.parent, item.phase, item.name)
269
+
270
+
271
+ def _changes(
272
+ old: tuple[PlanItem, ...], new: tuple[PlanItem, ...]
273
+ ) -> tuple[ChangedItem, ...]:
274
+ matcher = difflib.SequenceMatcher(
275
+ a=[_identity(item) for item in old],
276
+ b=[_identity(item) for item in new],
277
+ autojunk=False,
278
+ )
279
+ changes: list[ChangedItem] = []
280
+ for tag, a0, a1, b0, b1 in matcher.get_opcodes():
281
+ before, after = old[a0:a1], new[b0:b1]
282
+ if tag == "equal":
283
+ changes.extend(
284
+ ChangedItem("changed", _label(b), _fields(a, b))
285
+ for a, b in zip(before, after, strict=True)
286
+ if _definition(a) != _definition(b)
287
+ )
288
+ continue
289
+ changes.extend(ChangedItem("removed", _label(item)) for item in before)
290
+ changes.extend(ChangedItem("added", _label(item)) for item in after)
291
+ return tuple(changes)
292
+
293
+
294
+ def _fields(before: PlanItem, after: PlanItem) -> tuple[FieldChange, ...]:
295
+ old, new = _definition(before), _definition(after)
296
+ # A prompt's operation repeats its description, which is shown already.
297
+ prompts = _is_prompt(old) and _is_prompt(new)
298
+ return tuple(
299
+ FieldChange(name, _shown(old.get(name)), _shown(new.get(name)))
300
+ for name in dict.fromkeys((*old, *new))
301
+ if old.get(name) != new.get(name) and not (prompts and name == "operation")
302
+ )
303
+
304
+
305
+ def _is_prompt(definition: dict[str, object]) -> bool:
306
+ operation = definition.get("operation")
307
+ return isinstance(operation, dict) and operation.get("identifier") == "prompt"
308
+
309
+
310
+ def _shown(value: object) -> str:
311
+ text = (
312
+ "(none)"
313
+ if value is None
314
+ else json.dumps(value, sort_keys=True, separators=(",", ":"))
315
+ )
316
+ return text if len(text) <= _VALUE_LIMIT else text[: _VALUE_LIMIT - 1] + "…"
317
+
318
+
319
+ def _label(item: PlanItem) -> str:
320
+ if item.phase == "step":
321
+ return f"step `{item.name}`"
322
+ return f"`{item.name}` ({item.phase.replace('_', ' ')} of `{item.step}`)"
323
+
324
+
325
+ def _splice(
326
+ state: ExecutionState,
327
+ snapshot: PlanSnapshot,
328
+ template: WorkflowPlan,
329
+ first: int,
330
+ ) -> tuple[int, str | None]:
331
+ """Where the change point falls in the concrete plan, or why it cannot."""
332
+ order = {item.id: index for index, item in enumerate(template.items)}
333
+ concrete = snapshot.plan.items
334
+ present = {item.id for item in concrete}
335
+ expanded = [
336
+ item
337
+ for item in template.items[first:]
338
+ if item.id not in present and item.item_template
339
+ ]
340
+ if expanded:
341
+ return len(concrete), (
342
+ f"the change reaches the stages of {_label(expanded[0])}, which "
343
+ "this run has already expanded for its items or children; finish "
344
+ "the run with `--keep-plan`, or reset the task and start it again"
345
+ )
346
+ splice = len(concrete)
347
+ for index, item in enumerate(concrete):
348
+ origin = order.get(item.verifies.item_id if item.verifies else item.id)
349
+ if origin is not None and origin >= first:
350
+ splice = index
351
+ break
352
+ for item, record in zip(
353
+ concrete[splice : state.cursor + 1],
354
+ state.item_executions[splice : state.cursor + 1],
355
+ strict=False,
356
+ ):
357
+ if child_workflow(item) is not None and record.status != "pending":
358
+ return splice, (
359
+ f"replanning would rerun {_label(item)}, whose child tasks "
360
+ "already exist; finish the run with `--keep-plan`, or reset "
361
+ "the task and start it again"
362
+ )
363
+ return splice, None
364
+
365
+
366
+ def _kept(item_id: str | None, kept: set[str]) -> str | None:
367
+ return item_id if item_id in kept else None
ww/results.py ADDED
@@ -0,0 +1,77 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Small service-result contracts shared with output adapters."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+
8
+ from ww.executable import DEFAULT_EXECUTABLE
9
+ from ww.items import WorkItem
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ResetResult:
14
+ task_id: str
15
+ removed: bool
16
+
17
+
18
+ # Note added by init when ww.yaml defines no workflow.
19
+ NO_WORKFLOWS_ACTION = "Define at least one workflow in ww.yaml."
20
+ # What every init ends with: the skill that sets ww up for the people using it.
21
+ INITIALIZATION_NEXT_STEP = "Run the ww-setup skill to set ww up for this project."
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class InitializationResult:
26
+ root: str
27
+ created: tuple[str, ...] = ()
28
+ preserved: tuple[str, ...] = ()
29
+ actions: tuple[str, ...] = ()
30
+ # The operator has not been shown the agent-permission notice yet.
31
+ permission_notice: bool = True
32
+ # The ww binary the project is configured to run.
33
+ executable: str = DEFAULT_EXECUTABLE
34
+ # The command prefixes an agent must allow to run ww without asking.
35
+ commands: tuple[str, ...] = ()
36
+ # For each set-up agent whose permission format ww knows: the agent, its
37
+ # permissions file, and the JSON to merge into that file.
38
+ permissions: tuple[tuple[str, str, str], ...] = ()
39
+ # The set-up agents whose permission format ww does not know.
40
+ other_agents: tuple[str, ...] = ()
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class CleanupResult:
45
+ removed_locks: int
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ItemUpdateResult:
50
+ item: WorkItem
51
+ continuation_command: str | None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class TaskStatus:
56
+ """Compact, current-state view for a workflow task."""
57
+
58
+ task_id: str
59
+ workflow: str
60
+ step: str | None
61
+ step_state: str
62
+ runtime: str
63
+ agent: str | None
64
+ model: str
65
+ reasoning: str
66
+
67
+ def to_dict(self) -> dict[str, str | None]:
68
+ return {
69
+ "task_id": self.task_id,
70
+ "workflow": self.workflow,
71
+ "step": self.step,
72
+ "step_state": self.step_state,
73
+ "runtime": self.runtime,
74
+ "agent": self.agent,
75
+ "model": self.model,
76
+ "reasoning": self.reasoning,
77
+ }
ww/rule_checks.py ADDED
@@ -0,0 +1,230 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Run a step's checks against the files it changed.
3
+
4
+ A check is a planned command: a rule's own ``check``, a ``before_complete``
5
+ hook with ``on_failure: fix``, or a derived check the operator approved into
6
+ the rule-automation store, resolved when the step began. ww runs every check
7
+ of the completing step in plan order, derived checks last, each seeing the
8
+ step's change set in ``WW_STEP_CHANGED_FILES`` (newline-separated, relative
9
+ to the step's directory) narrowed to the check's globs. A check whose globs
10
+ select no changed file is not applicable and does not run. A check fails on
11
+ a non-zero exit or a failed assertion.
12
+
13
+ Checks read the working tree and report; they change no workflow state, so
14
+ running them again after an interruption is harmless and they keep no
15
+ command ledger. Their full output is stored as command-output artifacts.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import shlex
22
+ import subprocess
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass, replace
25
+ from pathlib import Path
26
+
27
+ from ww.action_execution import STATE_OUTPUT_PREVIEW_LIMIT, bounded
28
+ from ww.actions.command import CommandAction
29
+ from ww.changes import all_files, changed_files, select_files, take_mark
30
+ from ww.errors import StateError
31
+ from ww.execution_models import CheckReport, CheckResult, ExecutionState
32
+ from ww.plan import PlanItem, PlannedCheck
33
+ from ww.storage_adapters import CommandOutputAddress
34
+
35
+ CHANGED_FILES_VARIABLE = "WW_STEP_CHANGED_FILES"
36
+ # Lines of a check's output kept in state and shown on the fix page.
37
+ OUTPUT_TAIL_LINES = 40
38
+
39
+ WriteOutput = Callable[[CommandOutputAddress, str], str]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class CheckScope:
44
+ """Where the checks of one completing item run, and with which values."""
45
+
46
+ directory: Path
47
+ values: dict[str, str]
48
+
49
+
50
+ class RuleChecker:
51
+ """Run the checks of one agent item and report every outcome.
52
+
53
+ Without ``write_output`` the full streams are not kept: ``ww check``
54
+ previews a completion and leaves nothing behind.
55
+ """
56
+
57
+ def __init__(
58
+ self, write_output: WriteOutput | None, now: Callable[[], str]
59
+ ) -> None:
60
+ self.write_output = write_output
61
+ self.now = now
62
+
63
+ def run(
64
+ self,
65
+ state: ExecutionState,
66
+ item: PlanItem,
67
+ scope: CheckScope,
68
+ *,
69
+ reuse: CheckReport | None = None,
70
+ ) -> CheckReport:
71
+ """Run the item's planned checks, then the derived ones it resolved.
72
+
73
+ ``reuse`` is an earlier passing report of the same completion: while
74
+ the working tree is still at the tree that report was measured to,
75
+ the checks it passed are not run again. A check the operator waived
76
+ for this step does not run and is not in the report.
77
+ """
78
+ record = state.item_executions[state.cursor]
79
+ waived = dict(record.checks_waived)
80
+ attempt = max((report.attempt for report in item_reports(state)), default=0) + 1
81
+ mark_b = take_mark(scope.directory) if record.change_mark else None
82
+ files, unmarked = change_set(scope.directory, record.change_mark, mark_b)
83
+ kept = (
84
+ {result.id: result for result in reuse.results if result.status != "failed"}
85
+ if reuse is not None and mark_b is not None and reuse.mark == mark_b
86
+ else {}
87
+ )
88
+ results = tuple(
89
+ kept.get(check.id)
90
+ or self._run_check(state, item, check, index, attempt, files, scope)
91
+ for index, check in enumerate((*item.checks, *record.resolved_checks), 1)
92
+ if check.id not in waived
93
+ )
94
+ return CheckReport(
95
+ attempt=attempt,
96
+ checked_at=self.now(),
97
+ results=results,
98
+ mark=mark_b,
99
+ all_files=unmarked,
100
+ )
101
+
102
+ def _run_check(
103
+ self,
104
+ state: ExecutionState,
105
+ item: PlanItem,
106
+ check: PlannedCheck,
107
+ index: int,
108
+ attempt: int,
109
+ files: tuple[str, ...],
110
+ scope: CheckScope,
111
+ ) -> CheckResult:
112
+ selected = select_files(files, check.paths)
113
+ if check.paths and not selected:
114
+ return CheckResult(check.id, check.source, "not_applicable")
115
+ action = CommandAction()
116
+ outputs: list[str] = []
117
+ stdout_ref = stderr_ref = None
118
+ exit_code: int | None = 0
119
+ shown: list[str] = []
120
+ for segment, command in enumerate(check.command.commands, 1):
121
+ try:
122
+ argv, environment = action.render_command(command, scope.values)
123
+ except StateError as error:
124
+ return CheckResult(check.id, check.source, "failed", output=str(error))
125
+ shown.append(
126
+ command.shell if command.shell is not None else shlex.join(argv)
127
+ )
128
+ try:
129
+ completed = subprocess.run(
130
+ argv,
131
+ cwd=scope.directory,
132
+ capture_output=True,
133
+ text=True,
134
+ check=False,
135
+ shell=False,
136
+ env={
137
+ **os.environ,
138
+ **environment,
139
+ CHANGED_FILES_VARIABLE: "\n".join(selected),
140
+ },
141
+ )
142
+ except OSError as error:
143
+ return CheckResult(
144
+ check.id,
145
+ check.source,
146
+ "failed",
147
+ command="\n".join(shown),
148
+ output=f"could not launch the command: {error}",
149
+ )
150
+ address = CommandOutputAddress(
151
+ state.task_id,
152
+ state.run_id or state.workflow,
153
+ item.id,
154
+ f"{state.item_executions[state.cursor].operation_id}:check:{check.id}",
155
+ attempt,
156
+ segment,
157
+ "stdout",
158
+ )
159
+ if completed.stdout and self.write_output is not None:
160
+ stdout_ref = self.write_output(address, completed.stdout)
161
+ if completed.stderr and self.write_output is not None:
162
+ stderr_ref = self.write_output(
163
+ replace(address, stream="stderr"), completed.stderr
164
+ )
165
+ outputs.append(completed.stdout)
166
+ exit_code = completed.returncode
167
+ if completed.returncode != 0:
168
+ return CheckResult(
169
+ check.id,
170
+ check.source,
171
+ "failed",
172
+ command="\n".join(shown),
173
+ output=_tail(completed.stdout + completed.stderr),
174
+ exit_code=completed.returncode,
175
+ stdout_ref=stdout_ref,
176
+ stderr_ref=stderr_ref,
177
+ )
178
+ output = "\n".join(part for part in outputs if part).strip()
179
+ assertion = check.command.assertion
180
+ passed = assertion is None or assertion.holds(output)
181
+ return CheckResult(
182
+ check.id,
183
+ check.source,
184
+ "passed" if passed else "failed",
185
+ command="\n".join(shown),
186
+ output="" if passed else _tail(output),
187
+ exit_code=exit_code,
188
+ stdout_ref=stdout_ref,
189
+ stderr_ref=stderr_ref,
190
+ )
191
+
192
+
193
+ def change_set(
194
+ directory: Path, mark_a: str | None, mark_b: str | None
195
+ ) -> tuple[tuple[str, ...], bool]:
196
+ """The files changed between two marks, or every file when unmarked.
197
+
198
+ The flag is true without a change set: no git, or state written before
199
+ the step took its mark.
200
+ """
201
+ if mark_a and mark_b:
202
+ return changed_files(directory, mark_a, mark_b), False
203
+ return all_files(directory), True
204
+
205
+
206
+ def item_reports(state: ExecutionState) -> tuple[CheckReport, ...]:
207
+ """Every check report of the current item's operation, retries included.
208
+
209
+ An operator retry moves a copy of the record into the history, so a
210
+ report may appear twice; attempts number the reports of one operation,
211
+ and each attempt is one report.
212
+ """
213
+ record = state.item_executions[state.cursor]
214
+ kept = [
215
+ entry
216
+ for entry in state.execution_history
217
+ if entry.operation_id is not None and entry.operation_id == record.operation_id
218
+ ]
219
+ unique: dict[int, CheckReport] = {}
220
+ for entry in (*kept, record):
221
+ for report in entry.check_reports:
222
+ unique.setdefault(report.attempt, report)
223
+ return tuple(unique[attempt] for attempt in sorted(unique))
224
+
225
+
226
+ def _tail(output: str) -> str:
227
+ """The last lines of a check's output, bounded for state and the page."""
228
+ lines = output.strip().splitlines()
229
+ kept = "\n".join(lines[-OUTPUT_TAIL_LINES:])
230
+ return bounded(kept, STATE_OUTPUT_PREVIEW_LIMIT * 4)