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,1682 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Storage-backed construction of caller-facing instructions."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, replace
8
+ from pathlib import Path
9
+
10
+ from ww.agents import choice_mechanism
11
+ from ww.amendments import Amendment
12
+ from ww.assessments import assessment_outcomes, pending_assessment
13
+ from ww.assignments import (
14
+ Assignment,
15
+ ItemSpan,
16
+ LoopSpan,
17
+ active_assignment,
18
+ assignment_at,
19
+ completion_window,
20
+ input_only,
21
+ item_span,
22
+ loop_span,
23
+ selection_item,
24
+ )
25
+ from ww.config_files import WORKFLOWS_FILE
26
+ from ww.contracts import (
27
+ CallerRole,
28
+ Control,
29
+ InstructionStatus,
30
+ ItemStatus,
31
+ NextRole,
32
+ OperatorReason,
33
+ )
34
+ from ww.control import child_workflow, is_coordinator, loop_control
35
+ from ww.documents import DocumentStore
36
+ from ww.errors import StateError
37
+ from ww.executable import ww_command
38
+ from ww.execution_models import (
39
+ CheckResult,
40
+ ExecutionState,
41
+ PlanItemExecution,
42
+ PlanSnapshot,
43
+ )
44
+ from ww.handler_repairs import needs_repair
45
+ from ww.interactions import InteractionLog
46
+ from ww.item_passes import item_collection
47
+ from ww.operations import LoopBoundary
48
+ from ww.plan import PlanItem, PlannedMode, PlannedRule, WorkflowPlan
49
+ from ww.project_config import load_project_config
50
+ from ww.runtimes import requested_setting, runtime_instruction
51
+ from ww.step_values import StepValues, no_step_values
52
+ from ww.storage_adapters import TaskStorageAdapter
53
+ from ww.transitions import (
54
+ enclosing_loop_entry_index,
55
+ fix_limits,
56
+ loop_iteration_of,
57
+ loop_limit_reached,
58
+ )
59
+ from ww.variables import (
60
+ item_workspace_values,
61
+ )
62
+ from ww.workflow_config import INIT_STEP_NAME, ProvidedVariable
63
+ from ww.workspace import resolve_workspace
64
+
65
+ from .commands import (
66
+ complete_command,
67
+ force_command,
68
+ instruction_command,
69
+ interact_commands,
70
+ next_command,
71
+ recovery_commands,
72
+ requirements_command,
73
+ start_child_command,
74
+ update_child_command,
75
+ )
76
+ from .models import (
77
+ ConversationEntry,
78
+ DisputeView,
79
+ DocumentTask,
80
+ FixFailure,
81
+ FixRequired,
82
+ Instruction,
83
+ RecoveryCommand,
84
+ RuleLine,
85
+ StepHandover,
86
+ VerificationPage,
87
+ VerificationRuleLine,
88
+ )
89
+ from .policy import (
90
+ _control,
91
+ _has_previous_artifacts,
92
+ _index_for_id,
93
+ _instruction_status,
94
+ _item_status,
95
+ _next_steps,
96
+ _plan_item_kind,
97
+ _result_saved,
98
+ operator_reason,
99
+ )
100
+ from .text import NO_SUBAGENTS, ContainerArtifact, _stage, action_text
101
+
102
+ TaskValues = Callable[[ExecutionState, WorkflowPlan], dict[str, str]]
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class _Selection:
107
+ """The worker actually chosen: recorded on the item, else on the assignment."""
108
+
109
+ agent: str | None
110
+ model: str | None
111
+ reasoning: str | None
112
+
113
+ @classmethod
114
+ def effective(
115
+ cls, record: PlanItemExecution | None, state: ExecutionState
116
+ ) -> _Selection:
117
+ return cls(
118
+ (record.selected_agent if record else None)
119
+ or state.assignment_selected_agent,
120
+ (record.selected_model if record else None)
121
+ or state.assignment_selected_model,
122
+ (record.selected_reasoning if record else None)
123
+ or state.assignment_selected_reasoning,
124
+ )
125
+
126
+
127
+ def _with_retry_note(error: str | None, record: PlanItemExecution | None) -> str | None:
128
+ """``error`` followed by the attempts ww itself retried before stopping."""
129
+ if error is None or record is None or not record.retry_errors:
130
+ return error
131
+ attempts = "\n".join(
132
+ f"- attempt {number}: {message.splitlines()[0] if message else 'failed'}"
133
+ for number, message in enumerate(record.retry_errors, start=1)
134
+ )
135
+ return (
136
+ f"{error}\n\nww retried this step {len(record.retry_errors)} time(s) "
137
+ "itself (`limits.auto_retries`) before stopping; the failed earlier "
138
+ f"attempts:\n{attempts}"
139
+ )
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class _RequirementsPage:
144
+ """What one page shows of the task's requirements and amendments."""
145
+
146
+ text: str | None = None
147
+ in_full: bool = True
148
+ command: str | None = None
149
+ amendments: tuple[Amendment, ...] = ()
150
+
151
+
152
+ class InstructionBuilder:
153
+ """Build caller-facing instructions from authoritative run records."""
154
+
155
+ def __init__(
156
+ self,
157
+ tasks: TaskStorageAdapter,
158
+ task_values: TaskValues,
159
+ *,
160
+ root: Path,
161
+ documents: DocumentStore,
162
+ interactions: InteractionLog,
163
+ child_values: StepValues = no_step_values,
164
+ item_values: StepValues = no_step_values,
165
+ worker_requirements: Callable[[], str] = lambda: "full",
166
+ ) -> None:
167
+ self.tasks = tasks
168
+ # ``pages.worker_requirements``, read per page so an edit applies at once.
169
+ self.worker_requirements = worker_requirements
170
+ self.item_values = item_values
171
+ self.child_values = child_values
172
+ self.documents = documents
173
+ self.interactions = interactions
174
+ # Persisted paths are project-relative; instructions print them
175
+ # absolute for the filesystem this process runs in.
176
+ self.root = root
177
+ self.task_values = task_values
178
+
179
+ def build(
180
+ self,
181
+ state: ExecutionState,
182
+ snapshot: PlanSnapshot,
183
+ caller_role: CallerRole | None = None,
184
+ ) -> Instruction:
185
+ plan = snapshot.plan
186
+ item = plan.items[state.cursor] if state.cursor < len(plan.items) else None
187
+ record = (
188
+ state.item_executions[state.cursor]
189
+ if state.cursor < len(state.item_executions)
190
+ else None
191
+ )
192
+ control, next_role = _control(state, plan)
193
+ selection = _Selection.effective(record, state)
194
+ automatic_running = (
195
+ item is not None
196
+ and record is not None
197
+ and item.owner == "ww"
198
+ and record.status == "in_progress"
199
+ and not needs_repair(state)
200
+ )
201
+ assignment = _current_assignment(state, plan, item)
202
+ # A hook shares its assignment with its step, so the worker shape and
203
+ # the delegate heading come from the step that drives selection, not
204
+ # from whichever item the cursor happens to be on.
205
+ driver = (
206
+ item
207
+ if needs_repair(state)
208
+ else selection_item(plan, assignment)
209
+ if assignment is not None
210
+ else None
211
+ )
212
+ shape = driver or item
213
+ span = plan.items[assignment.start : assignment.stop] if assignment else ()
214
+ covered = tuple(
215
+ entry
216
+ for entry in span
217
+ if entry.owner == "agent"
218
+ or entry.requires_agent_input
219
+ or needs_repair(state)
220
+ )
221
+ built = self._build(state, snapshot)
222
+ choosing = pending_assessment(state, plan)
223
+ return replace(
224
+ built,
225
+ assessment_outcomes=(
226
+ choosing.outcomes
227
+ if choosing is not None
228
+ else assessment_outcomes(plan, state.cursor)
229
+ if item is not None and item.assessment_question is not None
230
+ else ()
231
+ ),
232
+ choosing_outcome_of=(
233
+ plan.items[choosing.index].name if choosing is not None else None
234
+ ),
235
+ continuation_command=(
236
+ next_command(state.task_id, outcome="<outcome>")
237
+ if choosing is not None
238
+ else built.continuation_command
239
+ ),
240
+ run_id=state.run_id,
241
+ notices=(*built.notices, *self._configuration_notices(state)),
242
+ workflow_runtime=state.workflow_runtime,
243
+ agent=state.agent,
244
+ model=(record.model if record and record.model else None)
245
+ or (requested_setting(item.model) if item else None)
246
+ or state.model,
247
+ reasoning=(record.reasoning if record and record.reasoning else None)
248
+ or (requested_setting(item.reasoning) if item else None)
249
+ or state.reasoning,
250
+ requested_agent=shape.requested_agent if shape else None,
251
+ requested_model=shape.requested_model if shape else None,
252
+ requested_reasoning=shape.requested_reasoning if shape else None,
253
+ requested_profile=shape.profile if shape else None,
254
+ role=shape.role if shape else "worker",
255
+ subagents=shape.subagents if shape else True,
256
+ assignment_token=worker_token(state),
257
+ profile_instruction=(
258
+ built.profile_instruction
259
+ or (
260
+ profile_instruction(driver, self.root)
261
+ if driver is not None and built.item_status == "in_progress"
262
+ else None
263
+ )
264
+ ),
265
+ selected_agent=selection.agent,
266
+ selected_model=selection.model,
267
+ selected_reasoning=selection.reasoning,
268
+ # Which work comes next depends on the outcome still to be chosen.
269
+ assignment_preview=(
270
+ None
271
+ if choosing is not None
272
+ else _assignment_preview(state, plan, item, next_role)
273
+ ),
274
+ assignment_step=driver.name if driver else None,
275
+ assignment_items=tuple(entry.name for entry in covered),
276
+ assignment_explicit_steps=tuple(
277
+ dict.fromkeys(
278
+ entry.step
279
+ for entry in covered
280
+ if entry.explicit
281
+ and (entry.owner == "agent" or needs_repair(state))
282
+ )
283
+ ),
284
+ assignment_continues=(
285
+ item is not None
286
+ and bool(covered)
287
+ and item.id != covered[0].id
288
+ and item.id in {entry.id for entry in covered}
289
+ ),
290
+ caller_role=caller_role,
291
+ next_role=next_role,
292
+ control=control,
293
+ operator_reason=operator_reason(state, plan),
294
+ result_saved=(
295
+ # A pass gate stops before the next step: there is no result.
296
+ None
297
+ if state.failure_kind == "pass_incomplete"
298
+ # A rejected or held completion keeps only a draft of the result.
299
+ else False
300
+ if state.failure_kind is not None
301
+ else _result_saved(state, plan)
302
+ if state.status in {"failed", "interrupted"} or automatic_running
303
+ else None
304
+ ),
305
+ workflow_runtime_instruction=_guidance(state, shape, selection, next_role),
306
+ is_child_workflow_control=item is not None
307
+ and child_workflow(item) is not None,
308
+ is_loop_control=item is not None and loop_control(item) is not None,
309
+ )
310
+
311
+ def _build(self, state: ExecutionState, snapshot: PlanSnapshot) -> Instruction:
312
+ """Describe the current item; ``build`` layers the shared fields on top."""
313
+ plan = snapshot.plan
314
+ if state.status == "completed":
315
+ handoff = self.tasks.read_handoff(state.task_id)
316
+ return replace(
317
+ _base(state, None),
318
+ handoff=handoff.rstrip() if handoff else None,
319
+ recommended_workflow=plan.recommended_next_workflow,
320
+ parent_task_id=state.parent_task_id,
321
+ feedback_deduction_command=(
322
+ f"{ww_command()} feedback sources {state.task_id} "
323
+ f"--run {state.run_id} --json"
324
+ if load_project_config(self.root / "ww.json").feedback_learning
325
+ and any(
326
+ record.status == "completed"
327
+ and record.artifact
328
+ and any(
329
+ item.id == record.plan_item_id and item.learnable
330
+ for item in plan.items
331
+ )
332
+ for record in (*state.execution_history, *state.item_executions)
333
+ )
334
+ else None
335
+ ),
336
+ )
337
+ if state.status == "awaiting_input":
338
+ return self._awaiting_input(state, plan)
339
+ if state.status in {"failed", "interrupted"}:
340
+ return self._stopped(state, plan)
341
+ if state.cursor >= len(plan.items):
342
+ return replace(
343
+ _base(state, None, status="completed"),
344
+ recommended_workflow=plan.recommended_next_workflow,
345
+ parent_task_id=state.parent_task_id,
346
+ )
347
+ item = plan.items[state.cursor]
348
+ record = state.item_executions[state.cursor]
349
+ if needs_repair(state):
350
+ return self._repair(state, plan, item, record)
351
+ if child_workflow(item) is not None:
352
+ return self._child_control(state, item, record)
353
+ loop = loop_control(item)
354
+ if loop is not None:
355
+ return _loop_control(state, item, loop)
356
+ if record.status == "in_progress":
357
+ if item.owner == "ww" and item.execution == "automatic":
358
+ return _automatic_running(state, item, record)
359
+ return self._active(state, plan, item, record)
360
+ return replace(
361
+ _base(state, item, item_status="pending"),
362
+ continuation_command=next_command(state.task_id),
363
+ )
364
+
365
+ def _repair(
366
+ self,
367
+ state: ExecutionState,
368
+ plan: WorkflowPlan,
369
+ item: PlanItem,
370
+ record: PlanItemExecution,
371
+ ) -> Instruction:
372
+ active = state.active_item_id == item.id
373
+ workspace, values = item_workspace_values(
374
+ self.root,
375
+ item.workdir,
376
+ state.working_directory,
377
+ {**dict(state.workflow_values), **self.task_values(state, plan)},
378
+ )
379
+ commands = (
380
+ tuple(command for command in record.commands if command.status == "failed")
381
+ or record.commands
382
+ )
383
+ references = tuple(
384
+ dict.fromkeys(
385
+ reference
386
+ for command in commands
387
+ for reference in (command.stdout_ref, command.stderr_ref)
388
+ if reference
389
+ )
390
+ )
391
+ text = (
392
+ f"Repair attempt {record.repair_failures} of {item.max_handler_fixes}.\n\n"
393
+ f"Repair the cause of the failed automatic handler `{item.name}`.\n\n"
394
+ "Do not independently execute the handler command. Submit your repair "
395
+ "with ww complete; "
396
+ "ww retries the handler and advances only when it succeeds."
397
+ )
398
+ if item.on_failure_instruction:
399
+ text += "\n\n" + item.on_failure_instruction
400
+ text += "\n\n" + action_text(item, values, state.task_id, ContainerArtifact())
401
+ text += "\n\nFailure:\n\n" + (
402
+ _with_retry_note(
403
+ record.error or state.last_error or "Unknown command failure", record
404
+ )
405
+ or ""
406
+ )
407
+ if references:
408
+ text += "\n\nFull command output:\n" + "\n".join(
409
+ f"- `{self.root / reference}`" for reference in references
410
+ )
411
+ page = self._requirements_page(state, plan, item)
412
+ return replace(
413
+ _base(state, item, item_status="in_progress" if active else "pending"),
414
+ action_text=text,
415
+ task_requirements=page.text,
416
+ requirements_in_full=page.in_full,
417
+ requirements_command=page.command,
418
+ task_amendments=page.amendments,
419
+ working_directory=str(workspace or self.root),
420
+ profile_instruction=profile_instruction(item, self.root),
421
+ handler_repair={
422
+ "item_id": item.id,
423
+ "attempt": record.repair_failures,
424
+ "max_fixes": item.max_handler_fixes,
425
+ "instruction": item.on_failure_instruction,
426
+ "output_refs": list(references),
427
+ "artifacts": list(record.repair_artifacts),
428
+ },
429
+ continuation_command=complete_command(
430
+ state.task_id,
431
+ (),
432
+ True,
433
+ (),
434
+ role="worker",
435
+ assignment=worker_token(state),
436
+ )
437
+ if active
438
+ else next_command(state.task_id),
439
+ explicit=item.explicit,
440
+ )
441
+
442
+ def _awaiting_input(self, state: ExecutionState, plan: WorkflowPlan) -> Instruction:
443
+ request = state.pending_input_request
444
+ if request is None: # pragma: no cover - validated state invariant
445
+ raise ValueError("awaiting-input state requires an input request")
446
+ index = _index_for_id(plan, request.item_id)
447
+ item = plan.items[index]
448
+ requested = {value.name for value in request.values}
449
+ # A retried handler asks for its values again; what it failed with is
450
+ # kept on its record so the page can show it.
451
+ previous_values = tuple(
452
+ (name, value)
453
+ for name, value in state.item_executions[index].supplied_values
454
+ if name in requested
455
+ )
456
+ # The values describe the work done since this handler last ran in
457
+ # this run: an earlier loop round's execution is in the history.
458
+ last_run = max(
459
+ (
460
+ str(record.completed_at)
461
+ for record in state.execution_history
462
+ if record.plan_item_id == item.id and record.completed_at
463
+ ),
464
+ default="",
465
+ )
466
+ input_context = tuple(
467
+ StepHandover(
468
+ found.step, str((self.root / found.artifact).resolve()), found.summary
469
+ )
470
+ for found in self._handovers(state, plan)
471
+ if found.completed_at > last_run
472
+ )
473
+ assignment = active_assignment(
474
+ plan, state.assignment_item_id, runtime=state.workflow_runtime
475
+ )
476
+ manager_input = (
477
+ state.workflow_runtime == "auto"
478
+ and assignment is not None
479
+ and input_only(plan, assignment)
480
+ )
481
+ return replace(
482
+ _base(state, item, item_status="awaiting_input"),
483
+ required_values=request.values,
484
+ previous_values=previous_values,
485
+ automatic_context=(item.name,),
486
+ continuation_command=complete_command(
487
+ state.task_id,
488
+ request.values,
489
+ # Values are the whole result of an input-only assignment.
490
+ artifact=not manager_input,
491
+ role="manager" if manager_input else "worker",
492
+ # A retried handler's values belong to the still open
493
+ # assignment, whose worker command carries its token.
494
+ assignment=None if manager_input else worker_token(state),
495
+ ),
496
+ manager_input=manager_input,
497
+ input_context=input_context,
498
+ )
499
+
500
+ def _previous_step_result(
501
+ self, state: ExecutionState, plan: WorkflowPlan
502
+ ) -> _StepResult | None:
503
+ """The handover of the step completed most recently before this one.
504
+
505
+ The full result stays in the artifact; only its reference travels.
506
+ """
507
+ handovers = self._handovers(state, plan)
508
+ return handovers[-1] if handovers else None
509
+
510
+ def _handovers(
511
+ self, state: ExecutionState, plan: WorkflowPlan
512
+ ) -> tuple[_StepResult, ...]:
513
+ """Every completed ordinary step's handover in this run, oldest first.
514
+
515
+ Only ordinary steps count: hooks, the built-in summary, and ``init``
516
+ (already shown as the requirements) are skipped. Loop history is
517
+ included, so every round of a loop is present in order.
518
+ """
519
+ by_id = {entry.id: entry for entry in plan.items}
520
+ candidates = sorted(
521
+ (
522
+ (record.completed_at, record.position, entry, record)
523
+ for record in (*state.item_executions, *state.execution_history)
524
+ if record.status == "completed"
525
+ and record.artifact is not None
526
+ and record.completed_at is not None
527
+ and (entry := by_id.get(record.plan_item_id)) is not None
528
+ and entry.hands_over
529
+ ),
530
+ key=lambda found: found[:2],
531
+ )
532
+ return tuple(
533
+ _StepResult(
534
+ entry.name,
535
+ str(record.artifact),
536
+ record.summary_for_next,
537
+ str(record.completed_at),
538
+ )
539
+ for _, _, entry, record in candidates
540
+ )
541
+
542
+ def _container_artifact(
543
+ self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
544
+ ) -> ContainerArtifact | None:
545
+ """What ``artifact_from`` supplies when it names a group or assessment.
546
+
547
+ A group emits no item of its own; an assessment named after its
548
+ outcomes has run one of them. Either supplies the artifact of the
549
+ latest step inside it that saved one in its current round: inside a
550
+ loop, only the records of the loop's current iteration count, while
551
+ the rounds of a loop inside the container all do. An assessment
552
+ whose chosen outcome saved nothing supplies its own artifact, when it
553
+ saved one; otherwise none is available. ``None`` means the
554
+ dependency is a single step, including an assessment named from
555
+ inside its own outcomes, and one whose outcomes cannot save an
556
+ artifact.
557
+ """
558
+ path = item.artifact_dependency
559
+ if path is None:
560
+ return None
561
+ inside = {
562
+ entry.id: entry
563
+ for entry in plan.items
564
+ if entry.phase == "step" and entry.artifact and path in entry.ancestors
565
+ }
566
+ own = next(
567
+ (
568
+ entry
569
+ for entry in plan.items
570
+ if entry.phase == "step" and entry.step == path
571
+ ),
572
+ None,
573
+ )
574
+ if not inside or (
575
+ own is not None and (not own.assessment_outcomes or path in item.ancestors)
576
+ ):
577
+ return None
578
+ some = next(iter(inside.values()))
579
+ enclosing = some.ancestors[: some.ancestors.index(path)]
580
+ iterations = dict(state.loop_iterations)
581
+ loops = {
582
+ loop.loop_id: iterations.get(loop.loop_id, 1)
583
+ for entry in plan.items
584
+ if (loop := loop_control(entry)) is not None
585
+ and loop.boundary == "enter"
586
+ and loop.loop_id in enclosing
587
+ }
588
+ # The live records are the current round's; the history holds earlier
589
+ # rounds, of loops inside the container (this round's) or around it.
590
+ current = (
591
+ *state.item_executions,
592
+ *(
593
+ record
594
+ for record in state.execution_history
595
+ if all(
596
+ loop_iteration_of(record, loop_id) == iteration
597
+ for loop_id, iteration in loops.items()
598
+ )
599
+ ),
600
+ )
601
+ latest = max(
602
+ (
603
+ record
604
+ for record in current
605
+ if record.status == "completed"
606
+ and record.artifact is not None
607
+ and record.completed_at is not None
608
+ and record.plan_item_id in inside
609
+ ),
610
+ key=lambda record: (str(record.completed_at), record.position),
611
+ default=None,
612
+ )
613
+ if latest is not None and latest.artifact is not None:
614
+ return ContainerArtifact(
615
+ inside[latest.plan_item_id].step,
616
+ str((self.root / latest.artifact).resolve()),
617
+ )
618
+ if own is not None:
619
+ # The outcome saved nothing: the assessment's own artifact, as
620
+ # ``artifact_from`` naming it supplied before outcomes counted.
621
+ assessed = next(
622
+ (
623
+ record
624
+ for record in state.item_executions
625
+ if record.plan_item_id == own.id
626
+ and record.status == "completed"
627
+ and record.artifact is not None
628
+ ),
629
+ None,
630
+ )
631
+ if assessed is not None and assessed.artifact is not None:
632
+ return ContainerArtifact(
633
+ own.step, str((self.root / assessed.artifact).resolve())
634
+ )
635
+ return ContainerArtifact()
636
+
637
+ def _conversation(
638
+ self, state: ExecutionState, item: PlanItem
639
+ ) -> tuple[ConversationEntry, ...]:
640
+ """The recorded entries of this step's conversation in this run."""
641
+ return tuple(
642
+ ConversationEntry(entry.at, entry.speaker, entry.text)
643
+ for entry in self.interactions.entries(state.task_id)
644
+ if (entry.run_id, entry.step, entry.item_id)
645
+ == (state.run_id, item.name, item.item_id)
646
+ )
647
+
648
+ def _current_child(self, state: ExecutionState, item: PlanItem) -> str:
649
+ """Name a per-child stage's child, and how to refine it before it runs."""
650
+ if item.child_number is None:
651
+ return ""
652
+ children = self.tasks.read_children(state.task_id, state.run_id)
653
+ if item.child_number > len(children):
654
+ return ""
655
+ child = children[item.child_number - 1]
656
+ text = (
657
+ f"\n\nCurrent child: `{child.id}` ({child.status}), "
658
+ f"{item.child_number} of {len(children)}."
659
+ )
660
+ if child.status == "pending":
661
+ text += (
662
+ " Until it starts, change its text, project, or fields with "
663
+ f"`{update_child_command(state.task_id, child.id)}`."
664
+ )
665
+ return text
666
+
667
+ def _document_tasks(
668
+ self, plan: WorkflowPlan, item: PlanItem, state: ExecutionState
669
+ ) -> tuple[DocumentTask, ...]:
670
+ """The documents this item promised to update, with their current files."""
671
+ declared = {document.name: document for document in plan.documents}
672
+ workspace = resolve_workspace(self.root, state.working_directory)
673
+ tasks = []
674
+ for update in item.update_document:
675
+ document = declared.get(update.name)
676
+ if document is None:
677
+ continue
678
+ path = self.documents.path(document, state.task_id, workspace)
679
+ tasks.append(
680
+ DocumentTask(update.name, update.instruction, str(path), path.is_file())
681
+ )
682
+ return tuple(tasks)
683
+
684
+ def _configuration_notices(self, state: ExecutionState) -> tuple[str, ...]:
685
+ """Say which ``ww.yaml`` is in force when the task's worktree has another.
686
+
687
+ Every command, child launches included, reads the configuration of the
688
+ primary checkout; a copy in the worktree that differs from it is not
689
+ in force, which is easy to miss after editing the wrong one.
690
+ """
691
+ workspace = resolve_workspace(self.root, state.working_directory)
692
+ if workspace is None or workspace == self.root.resolve():
693
+ return ()
694
+ local = workspace / WORKFLOWS_FILE
695
+ primary = self.root / WORKFLOWS_FILE
696
+ try:
697
+ if not local.is_file() or local.read_bytes() == primary.read_bytes():
698
+ return ()
699
+ except OSError:
700
+ return ()
701
+ return (
702
+ f"This task's worktree has its own `{WORKFLOWS_FILE}` that differs from "
703
+ f"the primary checkout's; ww reads `{primary}` for every command, "
704
+ "child launches included, so the worktree's copy is not in force.",
705
+ )
706
+
707
+ def _requirements_page(
708
+ self,
709
+ state: ExecutionState,
710
+ plan: WorkflowPlan,
711
+ item: PlanItem,
712
+ *,
713
+ continues_assignment: bool = False,
714
+ ) -> _RequirementsPage:
715
+ """The requirements and amendments an item's page carries.
716
+
717
+ The full requirements print on the first page of each session, then
718
+ later pages point at the command that prints them again. A single
719
+ runtime is one session: its first page that asks an agent for work.
720
+ Under ``auto`` the manager is one session, so the same rule holds for
721
+ its own pages, and every delegated worker assignment is a fresh one:
722
+ its first page prints them (unless ``pages.worker_requirements`` is
723
+ ``pointer``) and the later stages of that assignment point.
724
+ The amendments are short and print on every page.
725
+ """
726
+ if state.workflow_runtime == "auto" and item.role == "worker":
727
+ return _RequirementsPage(
728
+ self.requirements(state, plan),
729
+ not continues_assignment and self.worker_requirements() == "full",
730
+ requirements_command(state.task_id),
731
+ self.tasks.read_amendments(state.task_id),
732
+ )
733
+ first = next(
734
+ (
735
+ entry
736
+ for entry in plan.items
737
+ if entry.owner == "agent" and entry.step != INIT_STEP_NAME
738
+ ),
739
+ None,
740
+ )
741
+ return _RequirementsPage(
742
+ self.requirements(state, plan),
743
+ first is None or item.id == first.id,
744
+ requirements_command(state.task_id),
745
+ self.tasks.read_amendments(state.task_id),
746
+ )
747
+
748
+ def requirements(self, state: ExecutionState, plan: WorkflowPlan) -> str | None:
749
+ """The requirements ``init`` saved, so every worker reads the user's ask."""
750
+ record = next(
751
+ (
752
+ state.item_executions[index]
753
+ for index, item in enumerate(plan.items)
754
+ if item.step == INIT_STEP_NAME
755
+ and item.phase == "step"
756
+ and item.owner == "agent"
757
+ ),
758
+ None,
759
+ )
760
+ if record is None or record.artifact is None:
761
+ return None
762
+ try:
763
+ artifact = self.tasks.read_execution_artifact(record.artifact)
764
+ except StateError:
765
+ return None
766
+ return _result_body(artifact) or None
767
+
768
+ def _stopped(self, state: ExecutionState, plan: WorkflowPlan) -> Instruction:
769
+ """A failed or interrupted run: report the item and how to recover."""
770
+ current = plan.items[state.cursor] if state.cursor < len(plan.items) else None
771
+ record = (
772
+ state.item_executions[state.cursor]
773
+ if current is not None and state.cursor < len(state.item_executions)
774
+ else None
775
+ )
776
+ interrupted = state.status == "interrupted"
777
+ return replace(
778
+ _base(
779
+ state, current, item_status="interrupted" if interrupted else "failed"
780
+ ),
781
+ error=_with_retry_note(
782
+ (
783
+ f"Handler repair reached its fix limit "
784
+ f"({record.repair_failures} of {current.max_handler_fixes})."
785
+ f"\n\n{state.last_error}"
786
+ if record is not None
787
+ and current is not None
788
+ and needs_repair(state)
789
+ and state.failure_kind == "fix_limit"
790
+ else state.last_error
791
+ ),
792
+ record,
793
+ ),
794
+ child_tasks=(
795
+ self.tasks.read_children(state.task_id, state.run_id)
796
+ if current is not None and child_workflow(current) is not None
797
+ else ()
798
+ ),
799
+ operation_id=record.operation_id if record else None,
800
+ recovery_commands=(
801
+ ()
802
+ if current is None
803
+ # A pass gate is passed by recording the items, never skipped.
804
+ else (
805
+ RecoveryCommand("retry", next_command(state.task_id, retry=True)),
806
+ )
807
+ if state.failure_kind == "pass_incomplete"
808
+ else recovery_commands(state.task_id)
809
+ ),
810
+ # At the fix limit the operator decides on what the checks said.
811
+ fix_required=(
812
+ fix_required(current, record)
813
+ if state.failure_kind == "fix_limit"
814
+ and current is not None
815
+ and record is not None
816
+ else None
817
+ ),
818
+ dispute=(
819
+ dispute_view(current, record)
820
+ if state.failure_kind == "check_disputed"
821
+ and current is not None
822
+ and record is not None
823
+ else None
824
+ ),
825
+ )
826
+
827
+ def _verification(
828
+ self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
829
+ ) -> VerificationPage | None:
830
+ """The rules a verification item is asked about and their evidence."""
831
+ target = item.verifies
832
+ if target is None:
833
+ return None
834
+ record = state.item_executions[state.cursor]
835
+ step_index = _index_for_id(plan, target.item_id)
836
+ step = plan.items[step_index]
837
+ step_record = state.item_executions[step_index]
838
+ held = step_record.held_completion
839
+ return VerificationPage(
840
+ step=step.name,
841
+ rules=tuple(
842
+ VerificationRuleLine(
843
+ rule.id,
844
+ rule.text,
845
+ rule.interpretation,
846
+ rule.check,
847
+ rule.missing,
848
+ )
849
+ for rule in record.verification
850
+ ),
851
+ files=held.files if held else (),
852
+ all_files=held.all_files if held else False,
853
+ diff_command=(
854
+ f"git diff {step_record.change_mark} {held.mark}"
855
+ if held is not None and step_record.change_mark and held.mark
856
+ else None
857
+ ),
858
+ draft_artifact=(
859
+ str((self.root / held.draft_ref).resolve())
860
+ if held is not None and held.draft_ref
861
+ else None
862
+ ),
863
+ )
864
+
865
+ def _child_control(
866
+ self, state: ExecutionState, item: PlanItem, record: PlanItemExecution
867
+ ) -> Instruction:
868
+ coordinator = child_workflow(item)
869
+ children = self.tasks.read_children(state.task_id, state.run_id)
870
+ if record.status == "pending":
871
+ return replace(
872
+ _base(state, item, item_status="pending"),
873
+ child_tasks=children,
874
+ continuation_command=next_command(state.task_id),
875
+ is_child_workflow_control=True,
876
+ )
877
+ # A per-child coordinator runs its own child only.
878
+ candidates = (
879
+ children[item.child_number - 1 : item.child_number]
880
+ if item.child_number is not None
881
+ else children
882
+ )
883
+ active = next(
884
+ (
885
+ child
886
+ for child in candidates
887
+ if child.status in {"in_progress", "starting"}
888
+ ),
889
+ None,
890
+ )
891
+ pending = next(
892
+ (child for child in candidates if child.status == "pending"), None
893
+ )
894
+ if active is not None and active.status == "starting":
895
+ text = (
896
+ f"Child `{active.id}` is obtaining its task ID. Continue it "
897
+ f"with:\n\n```console\n"
898
+ f"{instruction_command(active.id, role='manager')}\n```"
899
+ )
900
+ elif active is not None:
901
+ text = f"Child `{active.id}` is in progress at `{active.task_id}`."
902
+ elif pending is not None and coordinator is not None and coordinator.launch:
903
+ text = (
904
+ f"ww starts pending child `{pending.id}` itself, with the "
905
+ "launch settings this stage declares from the child's record. "
906
+ f"Run `{next_command(state.task_id)}` and it "
907
+ "starts the child and shows its page. Until then, the child's "
908
+ "text or project can still change with "
909
+ f"`{update_child_command(state.task_id, pending.id)}`."
910
+ )
911
+ elif pending is not None:
912
+ text = (
913
+ f"Start pending child `{pending.id}` with:\n\n```console\n"
914
+ f"{start_child_command(state.task_id, pending.id)}\n```\n\n"
915
+ "Optionally add `--workflow <name>` to run the child under a "
916
+ "workflow other than the parent's configured child workflow, and "
917
+ "`--runtime`, `--model`, and `--reasoning` to "
918
+ "choose the child's session settings; omitted values inherit "
919
+ "from the parent, except a different model resets omitted "
920
+ "reasoning to `auto`. These settings are fixed once launch begins. "
921
+ "Until a child starts, its text or project can still change "
922
+ f"with `{update_child_command(state.task_id, pending.id)}`."
923
+ )
924
+ else:
925
+ text = "No child task is currently available to start."
926
+ return replace(
927
+ _base(state, item, item_status=record.status),
928
+ action_text=text,
929
+ child_tasks=children,
930
+ is_child_workflow_control=True,
931
+ )
932
+
933
+ def _active(
934
+ self,
935
+ state: ExecutionState,
936
+ plan: WorkflowPlan,
937
+ item: PlanItem,
938
+ record: PlanItemExecution,
939
+ ) -> Instruction:
940
+ """The agent item in progress: its work text and completion commands."""
941
+ assignment = active_assignment(
942
+ plan, state.assignment_item_id, runtime=state.workflow_runtime
943
+ )
944
+ required, context = completion_window(
945
+ plan, state.cursor, assignment.stop if assignment else None
946
+ )
947
+ selection = _Selection.effective(record, state)
948
+ span = _span(plan, assignment)
949
+ span_ids = tuple(stage.id for stage in span.stages) if span else ()
950
+ loop_round = _loop_round(state, plan, item)
951
+ role: CallerRole = "manager" if state.workflow_runtime == "auto" else "worker"
952
+ # Under ``auto`` the manager performs its own step and completes it
953
+ # as the manager; a worker's completion of it is refused.
954
+ completer: CallerRole = (
955
+ "manager"
956
+ if state.workflow_runtime == "auto" and item.role == "manager"
957
+ else "worker"
958
+ )
959
+ previous = (
960
+ self._previous_step_result(state, plan)
961
+ if item.step != INIT_STEP_NAME
962
+ else None
963
+ )
964
+
965
+ verification = self._verification(state, plan, item)
966
+
967
+ def completion(artifact: bool, loop_control: str | None = None) -> str:
968
+ return complete_command(
969
+ state.task_id,
970
+ required,
971
+ artifact,
972
+ item.save_metadata,
973
+ selected_agent=selection.agent,
974
+ selected_model=selection.model,
975
+ selected_reasoning=selection.reasoning,
976
+ loop_control=loop_control,
977
+ role=completer,
978
+ summary=item.hands_over,
979
+ rule_results=(
980
+ tuple(rule.id for rule in verification.rules)
981
+ if verification
982
+ else ()
983
+ ),
984
+ assignment=worker_token(state),
985
+ )
986
+
987
+ loop_break_command = None
988
+ if item.loop_break is not None:
989
+ # A break ending per-child stages has no loop wrapper to save.
990
+ wrapper_artifact = (
991
+ False
992
+ if item.breaks_children
993
+ else plan.items[enclosing_loop_entry_index(plan, state.cursor)].artifact
994
+ )
995
+ loop_break_command = completion(item.artifact or wrapper_artifact, "break")
996
+ later_pass = _later_item_pass(plan, item)
997
+ # A collection step's identity and unique fields are the collection's.
998
+ collection = item_collection(plan) if item.item_operation == "collect" else None
999
+ workspace, values = item_workspace_values(
1000
+ self.root,
1001
+ item.workdir,
1002
+ state.working_directory,
1003
+ {**dict(state.workflow_values), **self.task_values(state, plan)},
1004
+ )
1005
+ requirements = (
1006
+ self._requirements_page(
1007
+ state,
1008
+ plan,
1009
+ item,
1010
+ continues_assignment=item.id in span_ids[1:],
1011
+ )
1012
+ if item.step != INIT_STEP_NAME
1013
+ else _RequirementsPage()
1014
+ )
1015
+ return replace(
1016
+ _base(state, item, item_status=record.status),
1017
+ action_text=action_text(
1018
+ item,
1019
+ {
1020
+ **values,
1021
+ **self.item_values(state, plan, item),
1022
+ **self.child_values(state, plan, item),
1023
+ },
1024
+ state.task_id,
1025
+ self._container_artifact(state, plan, item),
1026
+ later_pass=later_pass,
1027
+ )
1028
+ + self._current_child(state, item),
1029
+ required_values=required,
1030
+ required_metadata=item.save_metadata,
1031
+ automatic_context=context,
1032
+ has_previous_artifacts=_has_previous_artifacts(state, plan),
1033
+ next_steps=_next_steps(state, plan),
1034
+ artifact=record.artifact,
1035
+ continuation_command=completion(item.artifact),
1036
+ loop_break_prompt=item.loop_break,
1037
+ loop_break_command=loop_break_command,
1038
+ breaks_children=item.breaks_children,
1039
+ loop_continue_prompt=item.loop_continue,
1040
+ loop_continue_command=(
1041
+ completion(item.artifact, "continue")
1042
+ if item.loop_continue is not None
1043
+ else None
1044
+ ),
1045
+ loop_name=loop_round[0] if loop_round else None,
1046
+ loop_iteration=loop_round[1] if loop_round else None,
1047
+ max_rounds=loop_round[2] if loop_round else None,
1048
+ task_requirements=requirements.text,
1049
+ requirements_in_full=requirements.in_full,
1050
+ requirements_command=requirements.command,
1051
+ task_amendments=requirements.amendments,
1052
+ previous_step=previous.step if previous else None,
1053
+ previous_step_artifact=(
1054
+ str((self.root / previous.artifact).resolve()) if previous else None
1055
+ ),
1056
+ previous_step_summary=previous.summary if previous else None,
1057
+ summary_required=item.hands_over,
1058
+ documents=self._document_tasks(plan, item, state),
1059
+ interactive=item.interactive,
1060
+ explicit=item.explicit and item.owner == "agent",
1061
+ interaction_entries=record.interaction_entries,
1062
+ interaction_ended=record.interaction_ended,
1063
+ interact_commands=(
1064
+ interact_commands(
1065
+ state.task_id,
1066
+ role,
1067
+ worker_token(state),
1068
+ choices=bool(item.choices),
1069
+ )
1070
+ if item.interactive
1071
+ else None
1072
+ ),
1073
+ choices=item.choices,
1074
+ choice_mechanism=(
1075
+ choice_mechanism(state.agent).instruction if item.choices else None
1076
+ ),
1077
+ chosen=record.chosen,
1078
+ operator_paused=state.operator_paused,
1079
+ conversation=(self._conversation(state, item) if item.interactive else ()),
1080
+ ui=item.ui,
1081
+ shared_items=item.shared_items and not later_pass,
1082
+ stored_items=(
1083
+ self.tasks.read_items(state.task_id, state.run_id)
1084
+ if item.shared_items and not later_pass
1085
+ else ()
1086
+ ),
1087
+ required_item_fields=item.update_item,
1088
+ collects_items=item.item_operation == "collect",
1089
+ item_identity=collection.item_identity if collection else None,
1090
+ item_unique=collection.item_unique if collection else (),
1091
+ run_handovers=(
1092
+ tuple(
1093
+ StepHandover(
1094
+ found.step,
1095
+ str((self.root / found.artifact).resolve()),
1096
+ found.summary,
1097
+ )
1098
+ for found in self._handovers(state, plan)
1099
+ )
1100
+ if item.summary
1101
+ else ()
1102
+ ),
1103
+ profile_instruction=profile_instruction(item, self.root),
1104
+ role=item.role,
1105
+ # A task still in the root needs no ``cd``; an item that chose
1106
+ # its own directory always names it.
1107
+ working_directory=str(workspace) if workspace is not None else None,
1108
+ assignment_scope=(
1109
+ span.to_dict() if span and item.id == span_ids[0] else None
1110
+ ),
1111
+ continues_assignment=item.id in span_ids[1:],
1112
+ rules=rule_lines(item, record),
1113
+ modes=item.modes,
1114
+ fix_required=fix_required(item, record),
1115
+ checks_waived=record.checks_waived,
1116
+ verification=verification,
1117
+ )
1118
+
1119
+
1120
+ def rule_lines(item: PlanItem, record: PlanItemExecution) -> tuple[RuleLine, ...]:
1121
+ """The rules a step's page lists: its rules, then its ``fix`` hooks.
1122
+
1123
+ A rule without a command that a converted derived check covers is listed
1124
+ as checked; a judged one carries the store's interpretation, and the
1125
+ missing configuration file when its converted check does not apply here.
1126
+ """
1127
+ resolutions = {entry.id: entry for entry in record.rule_resolutions}
1128
+
1129
+ def line(rule: PlannedRule) -> RuleLine:
1130
+ resolution = resolutions.get(rule.id)
1131
+ converted = resolution is not None and resolution.status == "converted"
1132
+ missing = resolution.missing if resolution else None
1133
+ return RuleLine(
1134
+ rule.id,
1135
+ rule.summary,
1136
+ rule.paths,
1137
+ rule.has_command or converted,
1138
+ interpretation=resolution.interpretation if resolution else None,
1139
+ check=(
1140
+ resolution.check
1141
+ if resolution is not None and (converted or missing is not None)
1142
+ else None
1143
+ ),
1144
+ missing=missing,
1145
+ )
1146
+
1147
+ return (
1148
+ *(line(rule) for rule in item.rules),
1149
+ *(
1150
+ RuleLine(check.id, check.summary, has_command=True, hook=True)
1151
+ for check in item.checks
1152
+ if check.source == "hook"
1153
+ ),
1154
+ )
1155
+
1156
+
1157
+ def fix_required(item: PlanItem, record: PlanItemExecution) -> FixRequired | None:
1158
+ """What the step's worker must fix after ww rejected its last completion.
1159
+
1160
+ A waiver lifts it for the checks it names: the operator decided the step
1161
+ completes without them. A derived check shows the texts of the rules it
1162
+ covers; a verifier's failing verdict shows its evidence.
1163
+ """
1164
+ if not record.check_reports:
1165
+ return None
1166
+ report = record.check_reports[-1]
1167
+ waived = {key for key, _ in record.checks_waived}
1168
+ failed = tuple(result for result in report.failed if result.id not in waived)
1169
+ if not failed:
1170
+ return None
1171
+ limits = fix_limits(item, record)
1172
+ return FixRequired(
1173
+ attempt=record.fix_attempts,
1174
+ max_fixes=max(limits.values(), default=1),
1175
+ checks=len(report.results),
1176
+ failures=fix_failures(item, record, failed),
1177
+ draft_artifact=record.draft_artifact,
1178
+ )
1179
+
1180
+
1181
+ def fix_failures(
1182
+ item: PlanItem, record: PlanItemExecution, results: tuple[CheckResult, ...]
1183
+ ) -> tuple[FixFailure, ...]:
1184
+ """Failed check results as the fix page shows them, with their rules' texts."""
1185
+ texts = {rule.id: rule.text for rule in item.rules}
1186
+ texts.update(
1187
+ {
1188
+ check.id: check.on_failure_instruction
1189
+ for check in item.checks
1190
+ if check.on_failure_instruction is not None
1191
+ }
1192
+ )
1193
+ covers = {check.id: check.covers for check in record.resolved_checks}
1194
+ return tuple(
1195
+ FixFailure(
1196
+ result.id,
1197
+ result.source == "hook",
1198
+ (
1199
+ "\n".join(
1200
+ f"- `{rule_id}`: {texts.get(rule_id, '')}"
1201
+ for rule_id in covers[result.id]
1202
+ )
1203
+ if result.id in covers
1204
+ else texts.get(result.id)
1205
+ ),
1206
+ result.command,
1207
+ result.output,
1208
+ judged=result.source == "judged",
1209
+ covers=covers.get(result.id, ()),
1210
+ )
1211
+ for result in results
1212
+ )
1213
+
1214
+
1215
+ def dispute_view(item: PlanItem, record: PlanItemExecution) -> DisputeView | None:
1216
+ """The worker's open dispute, with the disputed check as it last failed."""
1217
+ dispute = record.dispute
1218
+ if dispute is None:
1219
+ return None
1220
+ result = next(
1221
+ (
1222
+ result
1223
+ for report in reversed(record.check_reports)
1224
+ for result in report.failed
1225
+ if result.id == dispute.check
1226
+ ),
1227
+ None,
1228
+ )
1229
+ (failure,) = fix_failures(
1230
+ item,
1231
+ record,
1232
+ (
1233
+ result
1234
+ or CheckResult(
1235
+ dispute.check,
1236
+ "rule",
1237
+ "failed",
1238
+ command=dispute.command,
1239
+ output=dispute.output,
1240
+ ),
1241
+ ),
1242
+ )
1243
+ return DisputeView(
1244
+ failure=replace(failure, command=dispute.command, output=dispute.output),
1245
+ reason=dispute.reason,
1246
+ attempt=dispute.attempt,
1247
+ )
1248
+
1249
+
1250
+ def worker_token(state: ExecutionState) -> str | None:
1251
+ """The token a worker command of the open assignment carries, if any.
1252
+
1253
+ Only the ``auto`` runtime delegates, so only there can a worker outlive
1254
+ its assignment; ``single`` runs every step in one session.
1255
+ """
1256
+ return state.assignment_token if state.workflow_runtime == "auto" else None
1257
+
1258
+
1259
+ def _base(
1260
+ state: ExecutionState,
1261
+ item: PlanItem | None,
1262
+ *,
1263
+ status: InstructionStatus | None = None,
1264
+ item_status: ItemStatus | None = None,
1265
+ ) -> Instruction:
1266
+ """The identity fields every instruction carries for the current item."""
1267
+ return Instruction(
1268
+ task_id=state.task_id,
1269
+ workflow=state.workflow,
1270
+ status=status or state.status,
1271
+ item_id=item.id if item else None,
1272
+ item_name=item.name if item else None,
1273
+ stage=_stage(item),
1274
+ step=item.step if item else None,
1275
+ parent=item.parent if item else None,
1276
+ item_status=item_status,
1277
+ action_kind=item.kind if item else None,
1278
+ action_text=None,
1279
+ )
1280
+
1281
+
1282
+ def _loop_control(
1283
+ state: ExecutionState, item: PlanItem, loop: LoopBoundary
1284
+ ) -> Instruction:
1285
+ iteration = dict(state.loop_iterations).get(loop.loop_id, 0)
1286
+ limit_reached = loop_limit_reached(state, item)
1287
+ continuation: str | None = next_command(state.task_id)
1288
+ recovery: tuple[RecoveryCommand, ...] = ()
1289
+ if loop.boundary == "enter":
1290
+ text = (
1291
+ "Dispatch the first loop step. The loop remains active until "
1292
+ "a child step explicitly breaks it."
1293
+ )
1294
+ elif limit_reached:
1295
+ text = (
1296
+ f"Warning: loop `{loop.loop_id}` reached its maximum of "
1297
+ f"{loop.max_times} iterations. ww will not start another "
1298
+ "iteration. Escalate this result to the user for manual resolution."
1299
+ )
1300
+ continuation = None
1301
+ recovery = (force_command(state.task_id),)
1302
+ else:
1303
+ text = (
1304
+ f"Iteration {iteration} is complete. Dispatch the first step "
1305
+ "of the next iteration. The loop remains active until a child "
1306
+ "step explicitly breaks it."
1307
+ )
1308
+ return replace(
1309
+ _base(state, item, item_status="pending"),
1310
+ stage="Loop boundary",
1311
+ action_text=text,
1312
+ continuation_command=continuation,
1313
+ loop_iteration=iteration,
1314
+ max_rounds=loop.max_times,
1315
+ loop_limit_reached=limit_reached,
1316
+ recovery_commands=recovery,
1317
+ is_loop_control=True,
1318
+ )
1319
+
1320
+
1321
+ def _automatic_running(
1322
+ state: ExecutionState, item: PlanItem, record: PlanItemExecution
1323
+ ) -> Instruction:
1324
+ """An automatic item still marked running: only the locked next can settle it."""
1325
+ return replace(
1326
+ _base(state, item, item_status="in_progress"),
1327
+ error=(
1328
+ "automatic handler may still be running; use next "
1329
+ "under the task lock to inspect and resolve it"
1330
+ ),
1331
+ continuation_command=next_command(state.task_id),
1332
+ operation_id=record.operation_id,
1333
+ recovery_commands=recovery_commands(state.task_id),
1334
+ )
1335
+
1336
+
1337
+ @dataclass(frozen=True)
1338
+ class _StepResult:
1339
+ step: str
1340
+ artifact: str
1341
+ summary: str | None
1342
+ completed_at: str = ""
1343
+
1344
+
1345
+ def _result_body(artifact: str) -> str:
1346
+ """The agent's result inside ww's step artifact envelope."""
1347
+ _, marker, body = artifact.partition("\n## Result\n\n")
1348
+ return (body if marker else artifact).strip()
1349
+
1350
+
1351
+ def _bootstrap_profile_instruction(
1352
+ request: dict[str, object], root: Path
1353
+ ) -> str | None:
1354
+ path = request.get("profile_path")
1355
+ if path:
1356
+ return f"Use the profile defined in `{(root / str(path)).resolve()}`."
1357
+ text = request.get("profile_instruction")
1358
+ return str(text) if text else None
1359
+
1360
+
1361
+ def profile_instruction(item: PlanItem, root: Path) -> str | None:
1362
+ """The profile text for an item: a file under ``root`` or configured text."""
1363
+ if item.profile_path is not None:
1364
+ return f"Use the profile defined in `{(root / item.profile_path).resolve()}`."
1365
+ return item.profile_instruction
1366
+
1367
+
1368
+ def _current_assignment(
1369
+ state: ExecutionState, plan: WorkflowPlan, item: PlanItem | None
1370
+ ) -> Assignment | None:
1371
+ """The auto-runtime assignment holding the cursor: active, or about to begin."""
1372
+ if state.workflow_runtime != "auto" or item is None or is_coordinator(item):
1373
+ return None
1374
+ if state.status in {"completed", "failed", "interrupted"}:
1375
+ return None
1376
+ if needs_repair(state):
1377
+ return Assignment(item.id, state.cursor, state.cursor + 1)
1378
+ active = active_assignment(
1379
+ plan, state.assignment_item_id, runtime=state.workflow_runtime
1380
+ )
1381
+ if active is not None and active.start <= state.cursor < active.stop:
1382
+ return active
1383
+ return assignment_at(plan, state.cursor, runtime=state.workflow_runtime)
1384
+
1385
+
1386
+ def _span(
1387
+ plan: WorkflowPlan, assignment: Assignment | None
1388
+ ) -> ItemSpan | LoopSpan | None:
1389
+ """The several stages or loop steps one worker performs in this assignment."""
1390
+ if assignment is None:
1391
+ return None
1392
+ return item_span(plan, assignment) or loop_span(plan, assignment)
1393
+
1394
+
1395
+ def _loop_round(
1396
+ state: ExecutionState, plan: WorkflowPlan, item: PlanItem
1397
+ ) -> tuple[str, int, int] | None:
1398
+ """The enclosing loop's name, current round, and limit for a body step."""
1399
+ if item.loop_id is None:
1400
+ return None
1401
+ entry = next(
1402
+ (
1403
+ candidate
1404
+ for candidate in plan.items
1405
+ if (loop := loop_control(candidate)) is not None
1406
+ and loop.loop_id == item.loop_id
1407
+ and loop.boundary == "enter"
1408
+ ),
1409
+ None,
1410
+ )
1411
+ boundary = loop_control(entry) if entry is not None else None
1412
+ if entry is None or boundary is None: # pragma: no cover - compiler invariant
1413
+ raise ValueError(f"loop {item.loop_id!r} has no entry boundary")
1414
+ iteration = dict(state.loop_iterations).get(item.loop_id, 1)
1415
+ return entry.name, iteration, boundary.max_times
1416
+
1417
+
1418
+ def _assignment_preview(
1419
+ state: ExecutionState,
1420
+ plan: WorkflowPlan,
1421
+ item: PlanItem | None,
1422
+ next_role: NextRole,
1423
+ ) -> dict[str, object] | None:
1424
+ """What a delegating manager should know about the upcoming assignment."""
1425
+ if state.workflow_runtime != "auto" or next_role != "manager":
1426
+ return None
1427
+ if state.status == "awaiting_input":
1428
+ # The manager is already inside this assignment, supplying its values.
1429
+ return None
1430
+ if needs_repair(state) and item is not None:
1431
+ return {
1432
+ "first_item_id": item.id,
1433
+ "start": state.cursor,
1434
+ "stop": state.cursor + 1,
1435
+ "selection_item_id": item.id,
1436
+ "selection_item_name": item.name,
1437
+ "requested_agent": item.requested_agent,
1438
+ "requested_model": item.requested_model,
1439
+ "requested_reasoning": item.requested_reasoning,
1440
+ "requested_profile": item.profile,
1441
+ "explicit": item.explicit,
1442
+ "explicit_steps": (
1443
+ [item.step]
1444
+ if item.explicit and (item.owner == "agent" or needs_repair(state))
1445
+ else []
1446
+ ),
1447
+ "repair": True,
1448
+ }
1449
+ assignment = assignment_at(plan, state.cursor, runtime=state.workflow_runtime)
1450
+ if assignment is not None and input_only(plan, assignment):
1451
+ return {
1452
+ "first_item_id": assignment.first_item_id,
1453
+ "start": assignment.start,
1454
+ "stop": assignment.stop,
1455
+ "message": (
1456
+ "The next assignment only supplies values to an automatic "
1457
+ "handler. The manager provides them itself; no worker is "
1458
+ "selected."
1459
+ ),
1460
+ }
1461
+ if assignment is not None:
1462
+ driver = selection_item(plan, assignment)
1463
+ if driver is None:
1464
+ return None
1465
+ if driver.role == "manager":
1466
+ return {
1467
+ "first_item_id": assignment.first_item_id,
1468
+ "start": assignment.start,
1469
+ "stop": assignment.stop,
1470
+ "message": (
1471
+ f"`{driver.name}` is the manager's (`role: manager`), so no "
1472
+ "worker is "
1473
+ "selected: after the manager command, perform it yourself "
1474
+ "in this session."
1475
+ ),
1476
+ }
1477
+ span = _span(plan, assignment)
1478
+ return {
1479
+ "first_item_id": assignment.first_item_id,
1480
+ "start": assignment.start,
1481
+ "stop": assignment.stop,
1482
+ "selection_item_id": driver.id,
1483
+ "selection_item_name": driver.name,
1484
+ "requested_agent": driver.requested_agent,
1485
+ "requested_model": driver.requested_model,
1486
+ "requested_reasoning": driver.requested_reasoning,
1487
+ "requested_profile": driver.profile,
1488
+ "explicit": driver.explicit,
1489
+ "explicit_steps": [
1490
+ entry.step
1491
+ for entry in plan.items[assignment.start : assignment.stop]
1492
+ if entry.explicit and entry.owner == "agent"
1493
+ ],
1494
+ **(
1495
+ {"item_scope": span.to_dict()}
1496
+ if isinstance(span, ItemSpan)
1497
+ else {"loop_scope": span.to_dict()}
1498
+ if span is not None
1499
+ else {}
1500
+ ),
1501
+ }
1502
+ if item is not None and is_coordinator(item):
1503
+ return {
1504
+ "coordinator_item_id": item.id,
1505
+ "coordinator_item_name": item.name,
1506
+ "message": (
1507
+ "Coordinator work must run before the next worker "
1508
+ "assignment can be determined."
1509
+ ),
1510
+ }
1511
+ return None
1512
+
1513
+
1514
+ def _guidance(
1515
+ state: ExecutionState,
1516
+ item: PlanItem | None,
1517
+ selection: _Selection,
1518
+ next_role: NextRole,
1519
+ ) -> tuple[str, ...]:
1520
+ """Runtime guidance plus notes about worker selection."""
1521
+ guidance = runtime_instruction(state.workflow_runtime, next_role)
1522
+ if item is not None and not item.subagents:
1523
+ guidance = (*guidance, NO_SUBAGENTS)
1524
+ if state.workflow_runtime != "auto" or item is None:
1525
+ return guidance
1526
+ requested = (item.requested_agent, item.requested_model, item.requested_reasoning)
1527
+ selected = (selection.agent, selection.model, selection.reasoning)
1528
+ differences = tuple(
1529
+ (label, request, actual)
1530
+ for label, request, actual in zip(
1531
+ ("agent", "model", "reasoning"), requested, selected, strict=True
1532
+ )
1533
+ if request not in {None, "auto"} and actual is not None and request != actual
1534
+ )
1535
+ if differences:
1536
+ detail = ", ".join(
1537
+ f"{label} requested {request!r}, selected {actual!r}"
1538
+ for label, request, actual in differences
1539
+ )
1540
+ guidance = (
1541
+ *guidance,
1542
+ "Execution selection differs from the request: " + detail + ".",
1543
+ )
1544
+ if item.phase != "step":
1545
+ guidance = (
1546
+ *guidance,
1547
+ "This hook requests different execution settings from "
1548
+ "the current assignment. You may perform it yourself "
1549
+ "or delegate only this hook to a suitable worker. "
1550
+ "You remain responsible for reporting the result to ww.",
1551
+ )
1552
+ if item.interactive:
1553
+ guidance = (
1554
+ *guidance,
1555
+ "This step is interactive: a conversation with the operator that "
1556
+ "only this session can hold, because a delegated worker cannot "
1557
+ "talk to them. Perform it yourself and do not delegate it. Its "
1558
+ "profile, agent, model, and reasoning settings are ignored.",
1559
+ )
1560
+ elif item.role == "manager":
1561
+ guidance = (
1562
+ *guidance,
1563
+ "This step is the manager's (`role: manager`): perform it yourself rather "
1564
+ "than handing it to a worker. Its profile, agent, model, and "
1565
+ "reasoning settings are ignored.",
1566
+ )
1567
+ return guidance
1568
+
1569
+
1570
+ def build_bootstrap_instruction(request: dict[str, object], root: Path) -> Instruction:
1571
+ """Build the presentation view for a short-lived identity request."""
1572
+ request_id = str(request["request_id"])
1573
+ status = str(request["status"])
1574
+ in_progress = status == "in_progress"
1575
+ display_status = "in_progress" if status == "binding" else status
1576
+ if status == "completed":
1577
+ return Instruction(
1578
+ task_id=request_id,
1579
+ workflow=str(request["workflow"]),
1580
+ status="completed",
1581
+ item_id=None,
1582
+ item_name=None,
1583
+ stage=None,
1584
+ step=None,
1585
+ parent=None,
1586
+ item_status=None,
1587
+ action_kind=None,
1588
+ action_text=(
1589
+ "External task identity has been bound to "
1590
+ f"`{request['resolved_task_id']}`."
1591
+ ),
1592
+ next_role="manager",
1593
+ control="handoff_manager",
1594
+ )
1595
+ # A failed bootstrap is agent work that failed: the operator decides.
1596
+ reason: OperatorReason | None = "work_failed" if status == "failed" else None
1597
+ next_role: NextRole = (
1598
+ "worker" if in_progress else "operator" if reason else "manager"
1599
+ )
1600
+ control: Control = (
1601
+ "continue_worker"
1602
+ if in_progress
1603
+ else "awaiting_operator"
1604
+ if reason
1605
+ else "handoff_manager"
1606
+ )
1607
+ return Instruction(
1608
+ task_id=request_id,
1609
+ workflow=str(request["workflow"]),
1610
+ status=_instruction_status(display_status),
1611
+ item_id=str(request["item_id"]),
1612
+ item_name=str(request["item_name"]),
1613
+ stage="Task identity bootstrap",
1614
+ step=str(request["step"]),
1615
+ parent=None,
1616
+ item_status=_item_status(
1617
+ "in_progress" if status in {"binding", "in_progress"} else status
1618
+ ),
1619
+ action_kind=_plan_item_kind(str(request["action_kind"])),
1620
+ action_text=str(request["action_text"]) if in_progress else None,
1621
+ required_values=(ProvidedVariable("task_id", "The external task ID."),)
1622
+ if in_progress
1623
+ else (),
1624
+ continuation_command=(
1625
+ complete_command(request_id, (ProvidedVariable("task_id"),))
1626
+ if in_progress
1627
+ else next_command(request_id)
1628
+ ),
1629
+ error=str(request["error"]) if "error" in request else None,
1630
+ profile_instruction=(
1631
+ _bootstrap_profile_instruction(request, root) if in_progress else None
1632
+ ),
1633
+ workflow_runtime=str(request.get("workflow_runtime", "single")),
1634
+ workflow_runtime_instruction=runtime_instruction(
1635
+ str(request.get("workflow_runtime", "single")), next_role
1636
+ ),
1637
+ model=str(request.get("model", "auto")),
1638
+ reasoning=str(request.get("reasoning", "auto")),
1639
+ requested_agent=str(request.get("requested_agent", request.get("agent", "")))
1640
+ or None,
1641
+ requested_model=str(request.get("requested_model", "auto")),
1642
+ requested_reasoning=str(request.get("requested_reasoning", "auto")),
1643
+ requested_profile=(
1644
+ str(request["requested_profile"])
1645
+ if request.get("requested_profile")
1646
+ else None
1647
+ ),
1648
+ selected_agent=(
1649
+ str(request["selected_agent"]) if request.get("selected_agent") else None
1650
+ ),
1651
+ selected_model=(
1652
+ str(request["selected_model"]) if request.get("selected_model") else None
1653
+ ),
1654
+ selected_reasoning=(
1655
+ str(request["selected_reasoning"])
1656
+ if request.get("selected_reasoning")
1657
+ else None
1658
+ ),
1659
+ next_role=next_role,
1660
+ control=control,
1661
+ operator_reason=reason,
1662
+ modes=_bootstrap_modes(request) if in_progress else (),
1663
+ )
1664
+
1665
+
1666
+ def _bootstrap_modes(request: dict[str, object]) -> tuple[PlannedMode, ...]:
1667
+ """The bootstrap step's modes; a request recorded before them has none."""
1668
+ entries = request.get("step_modes", [])
1669
+ if not isinstance(entries, list):
1670
+ raise ValueError("bootstrap request step_modes must be a list")
1671
+ return tuple(
1672
+ PlannedMode.from_dict(entry, f"bootstrap request step_modes[{index}]")
1673
+ for index, entry in enumerate(entries)
1674
+ )
1675
+
1676
+
1677
+ def _later_item_pass(plan: WorkflowPlan, item: PlanItem) -> bool:
1678
+ """Whether ``item`` collects for an ``items`` pass after the workflow's first."""
1679
+ if item.item_operation != "collect" or item.child_operation is not None:
1680
+ return False
1681
+ first = item_collection(plan)
1682
+ return first is not None and item.item_pass != first.item_pass