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,219 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Pure execution-state interpretation for instructions."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from enum import Enum
7
+
8
+ from ww.actions import actions
9
+ from ww.assignments import active_assignment, input_only
10
+ from ww.contracts import (
11
+ Control,
12
+ InstructionStatus,
13
+ ItemStatus,
14
+ NextRole,
15
+ OperatorReason,
16
+ PlanItemKind,
17
+ )
18
+ from ww.control import child_workflow, loop_control, replays_harmlessly
19
+ from ww.errors import StateError
20
+ from ww.execution_models import ExecutionState
21
+ from ww.handler_repairs import needs_repair
22
+ from ww.plan import WorkflowPlan
23
+ from ww.transitions import loop_limit_reached
24
+ from ww.validation import expect_literal
25
+
26
+ from .models import Instruction
27
+
28
+
29
+ class Audience(Enum):
30
+ """Who reads an instruction and what they are expected to do with it."""
31
+
32
+ SINGLE_SESSION = "single"
33
+ """One session plays both roles and does the work itself."""
34
+ MANAGER_DELEGATING = "manager_delegating"
35
+ """The manager hands the next assignment to a worker."""
36
+ WORKER_RETURNING = "worker_returning"
37
+ """A worker has finished its assignment and returns control."""
38
+ WORKER = "worker"
39
+ """A worker performs the active assignment."""
40
+ MANAGER = "manager"
41
+ """The manager runs the next command itself."""
42
+
43
+
44
+ def audience(instruction: Instruction) -> Audience:
45
+ if instruction.workflow_runtime == "single":
46
+ return Audience.SINGLE_SESSION
47
+ if instruction.caller_role == "manager" and instruction.next_role == "worker":
48
+ return Audience.MANAGER_DELEGATING
49
+ if instruction.caller_role == "worker" and instruction.next_role in {
50
+ "manager",
51
+ "operator",
52
+ }:
53
+ return Audience.WORKER_RETURNING
54
+ return Audience.WORKER if instruction.next_role == "worker" else Audience.MANAGER
55
+
56
+
57
+ def manager_continues_itself(instruction: Instruction) -> bool:
58
+ """Whether the step a manager's completion hands back is the manager's own.
59
+
60
+ Then ``next`` has nothing to decide for the manager: no worker is selected,
61
+ nothing waits for the operator, no assessment outcome is to be chosen, and
62
+ no loop, child, repair, or plan change needs a deliberate command.
63
+ """
64
+ return (
65
+ instruction.workflow_runtime == "auto"
66
+ and instruction.caller_role == "manager"
67
+ and instruction.next_role == "manager"
68
+ and instruction.role == "manager"
69
+ and instruction.control == "handoff_manager"
70
+ and instruction.status == "pending"
71
+ and instruction.item_status == "pending"
72
+ and instruction.operator_reason is None
73
+ and instruction.choosing_outcome_of is None
74
+ and instruction.plan_change is None
75
+ and instruction.fix_required is None
76
+ and instruction.handler_repair is None
77
+ and instruction.error is None
78
+ and not instruction.is_loop_control
79
+ and not instruction.is_child_workflow_control
80
+ and not instruction.manager_only
81
+ )
82
+
83
+
84
+ def _instruction_status(value: str) -> InstructionStatus:
85
+ status: InstructionStatus = expect_literal(
86
+ value, InstructionStatus, "bootstrap request status", error=StateError
87
+ )
88
+ return status
89
+
90
+
91
+ def _item_status(value: str) -> ItemStatus:
92
+ status: ItemStatus = expect_literal(
93
+ value, ItemStatus, "bootstrap request item status", error=StateError
94
+ )
95
+ return status
96
+
97
+
98
+ def _plan_item_kind(value: str) -> PlanItemKind:
99
+ if not actions.contains(value):
100
+ raise StateError(f"bootstrap request has invalid action kind: {value!r}")
101
+ return value
102
+
103
+
104
+ def _has_previous_artifacts(state: ExecutionState, plan: WorkflowPlan) -> bool:
105
+ return any(
106
+ record.artifact is not None
107
+ for index, (item, record) in enumerate(
108
+ zip(plan.items, state.item_executions, strict=True)
109
+ )
110
+ if index < state.cursor and record.status == "completed"
111
+ )
112
+
113
+
114
+ def _next_steps(state: ExecutionState, plan: WorkflowPlan) -> tuple[str, ...]:
115
+ current = plan.items[state.cursor]
116
+ if current.phase != "step":
117
+ return ()
118
+ return tuple(
119
+ dict.fromkeys(
120
+ item.step
121
+ for item in plan.items[state.cursor + 1 :]
122
+ if item.phase == "step"
123
+ and item.parent == current.parent
124
+ and item.step != current.step
125
+ )
126
+ )
127
+
128
+
129
+ def _index_for_id(plan: WorkflowPlan, item_id: str) -> int:
130
+ for index, item in enumerate(plan.items):
131
+ if item.id == item_id:
132
+ return index
133
+ raise StateError(f"plan item {item_id!r} is not in the task snapshot")
134
+
135
+
136
+ def operator_reason(state: ExecutionState, plan: WorkflowPlan) -> OperatorReason | None:
137
+ """Why the task waits for the operator, or ``None`` when it does not."""
138
+ item = plan.items[state.cursor] if state.cursor < len(plan.items) else None
139
+ if state.status == "interrupted":
140
+ # ``next`` replays a harmless handler without asking anyone.
141
+ if item is not None and replays_harmlessly(item):
142
+ return None
143
+ return "handler_interrupted"
144
+ if state.status == "failed":
145
+ if state.failure_kind is not None:
146
+ return state.failure_kind
147
+ if item is not None and child_workflow(item) is not None:
148
+ return "child_failed"
149
+ if item is not None and item.owner == "agent":
150
+ return "work_failed"
151
+ return "handler_failed"
152
+ if (
153
+ state.status != "awaiting_input"
154
+ and item is not None
155
+ and loop_limit_reached(state, item)
156
+ ):
157
+ return "loop_limit"
158
+ return None
159
+
160
+
161
+ def _control(state: ExecutionState, plan: WorkflowPlan) -> tuple[Control, NextRole]:
162
+ if operator_reason(state, plan) is not None:
163
+ return "awaiting_operator", "operator"
164
+ if state.status in {"failed", "interrupted"}:
165
+ return "blocked", "manager"
166
+ if state.status in {"completed", "abandoned"}:
167
+ return "handoff_manager", "manager"
168
+ if needs_repair(state):
169
+ return (
170
+ ("continue_worker", "worker")
171
+ if state.active_item_id
172
+ else ("handoff_manager", "manager")
173
+ )
174
+ if state.status == "awaiting_input":
175
+ assignment = active_assignment(
176
+ plan, state.assignment_item_id, runtime=state.workflow_runtime
177
+ )
178
+ if (
179
+ state.workflow_runtime == "auto"
180
+ and assignment is not None
181
+ and input_only(plan, assignment)
182
+ ):
183
+ return "handoff_manager", "manager"
184
+ return "continue_worker", "worker"
185
+ if state.cursor < len(plan.items):
186
+ item = plan.items[state.cursor]
187
+ record = state.item_executions[state.cursor]
188
+ if (
189
+ item.owner == "ww"
190
+ and item.execution == "automatic"
191
+ and record.status == "in_progress"
192
+ ):
193
+ return "blocked", "manager"
194
+ if state.cursor < len(plan.items) and (
195
+ child_workflow(plan.items[state.cursor]) is not None
196
+ or loop_control(plan.items[state.cursor]) is not None
197
+ ):
198
+ return "blocked", "manager"
199
+ assignment = active_assignment(
200
+ plan, state.assignment_item_id, runtime=state.workflow_runtime
201
+ )
202
+ if assignment is not None and state.cursor < assignment.stop:
203
+ return "continue_worker", "worker"
204
+ if state.active_item_id and state.cursor < len(plan.items):
205
+ return "continue_worker", "worker"
206
+ return "handoff_manager", "manager"
207
+
208
+
209
+ def _result_saved(state: ExecutionState, plan: WorkflowPlan) -> bool:
210
+ assignment = active_assignment(
211
+ plan, state.assignment_item_id, runtime=state.workflow_runtime
212
+ )
213
+ start = assignment.start if assignment is not None else 0
214
+ stop = min(state.cursor + 1, len(plan.items))
215
+ return any(
216
+ plan.items[index].owner == "agent"
217
+ and state.item_executions[index].status == "completed"
218
+ for index in range(start, stop)
219
+ )
@@ -0,0 +1,168 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Instruction wording and continuation command construction."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+
8
+ from ww.actions import InstructionContext, PlannedAction, actions
9
+ from ww.plan import PlanItem
10
+ from ww.variables import PROJECTS
11
+
12
+ from .commands import (
13
+ TASK_PLACEHOLDER,
14
+ add_child_command,
15
+ add_item_command,
16
+ item_command,
17
+ items_command,
18
+ update_item_command,
19
+ )
20
+
21
+ NO_SUBAGENTS = (
22
+ "This step allows no subagents (`subagents: false`): whoever performs it "
23
+ "does all of its work alone and spawns no subagent for anything, not for "
24
+ "research, tests, or review."
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ContainerArtifact:
30
+ """The artifact ``artifact_from`` naming a group or an assessment found.
31
+
32
+ ``step`` and ``artifact`` (an absolute path) name the latest step inside
33
+ the container that saved one in its current round, or the assessment
34
+ itself when its outcome saved none; both are ``None`` when none has.
35
+ """
36
+
37
+ step: str | None = None
38
+ artifact: str | None = None
39
+
40
+
41
+ def _stage(item: PlanItem | None) -> str | None:
42
+ if item is None:
43
+ return None
44
+ if item.phase == "step":
45
+ return "Nested workflow step" if item.parent else "Workflow step"
46
+ if item.phase == "before_start_workflow":
47
+ return "Before Workflow Start hook"
48
+ if item.phase == "before_complete_workflow":
49
+ return "Before Workflow Completion hook"
50
+ return item.phase.replace("_", " ").title() + " hook"
51
+
52
+
53
+ def action_text(
54
+ item: PlanItem,
55
+ task_values: dict[str, str] | None = None,
56
+ task_id: str | None = None,
57
+ container_artifact: ContainerArtifact | None = None,
58
+ later_pass: bool = False,
59
+ ) -> str:
60
+ """Render the work text for an ordinary action, plus item or child guidance.
61
+
62
+ ``later_pass``: the step collects for an ``items`` pass after the
63
+ workflow's first, which reuses the items already collected.
64
+
65
+ ``container_artifact`` is what the step's ``artifact_from`` resolved to
66
+ when it names a group or an assessment; ``None`` for a single step.
67
+ """
68
+ if not isinstance(item.operation, PlannedAction):
69
+ raise ValueError(f"core operation {item.kind!r} has no ordinary action text")
70
+ content = actions.get(item.operation.identifier).instruction(
71
+ item.operation.payload,
72
+ InstructionContext(item.description, item.name, task_values or {}),
73
+ )
74
+ task_reference = task_id or TASK_PLACEHOLDER
75
+ if item.item_operation == "collect" and later_pass:
76
+ result = (
77
+ content.text
78
+ + "\n\nThis step starts a later pass over the items this run already "
79
+ "collected: do not split the source again or recreate items, and keep "
80
+ "what is recorded on them. Inspect them with "
81
+ f"`{items_command(task_reference)}`. Add an item with "
82
+ f"`{add_item_command(task_reference)}` only when this step asks you "
83
+ "to reconcile the items with their source. When this step completes, "
84
+ "the pass runs for the items recorded by then; an item added later "
85
+ "joins the next pass."
86
+ )
87
+ return _with_artifact_dependency(result, item, container_artifact)
88
+ if item.item_operation == "collect":
89
+ result = (
90
+ content.text
91
+ + "\n\nSplit the source into complete, reportable items. Preserve every "
92
+ "source comment; use a stable source ID when one exists, otherwise choose "
93
+ f"a unique ID. Use `{add_item_command()}` for "
94
+ "each item. Combine related work under one canonical item only when that "
95
+ "avoids duplicate analysis or fixes; retain each related source item and "
96
+ "link it with `--refers-to <canonical-id>` so it can still be "
97
+ "reported."
98
+ )
99
+ if item.split_instruction:
100
+ result += f"\n\nHow to split: {item.split_instruction}"
101
+ return _with_artifact_dependency(result, item, container_artifact)
102
+ if item.child_operation == "collect":
103
+ projects = [
104
+ name for name in (task_values or {}).get(PROJECTS, "").split(",") if name
105
+ ]
106
+ result = (
107
+ content.text
108
+ + "\n\nRecord each child task with:\n\n```console\n"
109
+ + add_child_command(
110
+ task_reference, project=bool(projects), identity=item.child_identity
111
+ )
112
+ + "\n```\n\nRun this command once for every independent child task."
113
+ )
114
+ if item.split_instruction:
115
+ result += f"\n\nHow to split: {item.split_instruction}"
116
+ if item.child_identity:
117
+ result += (
118
+ "\n\nDo not pass `--id`: each child receives its ID from the first "
119
+ "step of its own workflow, which runs when the child starts."
120
+ )
121
+ if projects:
122
+ result += (
123
+ "\n\nConfigured projects: "
124
+ + ", ".join(f"`{name}`" for name in projects)
125
+ + ". Pass `--project` with the project a child works in; omit it "
126
+ "for a child that works in the root."
127
+ )
128
+ return _with_artifact_dependency(result, item, container_artifact)
129
+ text = content.text
130
+ if content.include_item_context and item.item_id:
131
+ text += (
132
+ f"\n\nActive item: `{item.item_id}`. Inspect it with "
133
+ f"`{item_command(task_reference, item.item_id)}`."
134
+ " Save progress with:\n\n```console\n"
135
+ + update_item_command(task_reference, item.item_id, item.item_operation)
136
+ + "\n```\n\nThis command confirms the update and returns the worker "
137
+ "completion command."
138
+ )
139
+ return _with_artifact_dependency(text, item, container_artifact)
140
+
141
+
142
+ def _with_artifact_dependency(
143
+ text: str, item: PlanItem, container: ContainerArtifact | None
144
+ ) -> str:
145
+ if item.artifact_dependency is None:
146
+ return text
147
+ if container is not None and container.artifact is None:
148
+ return (
149
+ text + f"\n\nNo artifact is available from `{item.artifact_dependency}`: "
150
+ "no step inside it that saves one completed in this run. Continue "
151
+ "without it."
152
+ )
153
+ if container is not None and container.step == item.artifact_dependency:
154
+ return (
155
+ text + f"\n\nUse the artifact produced by the `{container.step}` step as "
156
+ f"input to this work: `{container.artifact}`."
157
+ )
158
+ if container is not None:
159
+ return (
160
+ text + f"\n\nUse the artifact produced by the `{container.step}` step, "
161
+ f"the latest saved inside `{item.artifact_dependency}`, as input to "
162
+ f"this work: `{container.artifact}`."
163
+ )
164
+ return (
165
+ text
166
+ + f"\n\nUse the artifact produced by the `{item.artifact_dependency}` step "
167
+ "as input to this work."
168
+ )
ww/interactions.py ADDED
@@ -0,0 +1,187 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The per-task record of conversations held with the operator.
3
+
4
+ An interactive step is a conversation the agent holds with the operator in
5
+ its own session; ww cannot hear it. When the conversation ends, the agent
6
+ records both sides at once with ``interact --transcript``, and ww appends
7
+ each entry to one file per task, ``interactions.md``, never rewriting it.
8
+ The ``interrupt`` hook appends what it recovers from the agent's own
9
+ transcript the same way, under a speaker marked ``(recovered)``. Every entry names the
10
+ run and step it belongs to, and the work item when the step is a per-item
11
+ stage, so the file reads as the task's whole history of operator involvement
12
+ and the conversation of one stage can be read back out of it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass, replace
20
+ from pathlib import Path
21
+
22
+ from ww.errors import StateError
23
+ from ww.storage import Storage
24
+
25
+ INTERACTIONS_FILE = "interactions.md"
26
+ _SEPARATOR = " · "
27
+ # A heading has time, run, step, and speaker; a per-item stage adds its item.
28
+ _FIELDS = 4
29
+ # Appended to the speaker of an entry the interrupt hook recovered.
30
+ RECOVERED = " (recovered)"
31
+ # A transcript line that starts an entry: a speaker marker in any case, plain
32
+ # or bold with the colon inside or outside the bold, then the entry's first
33
+ # line, e.g. "**Operator:** Use a queue." or "agent: I propose a queue.".
34
+ _MARKER = re.compile(
35
+ r"^\s*(?:\*\*)?(Agent|Operator)(?:\*\*)?:(?:\*\*)?\s*(.*)$", re.IGNORECASE
36
+ )
37
+ _TRANSCRIPT_FORMAT = (
38
+ "a transcript is lines starting with `Agent:` or `Operator:`, each "
39
+ "followed by that side's words; the lines until the next marker belong "
40
+ "to the same entry"
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class InteractionEntry:
46
+ """One recorded entry: who said what, in which run, step, and item."""
47
+
48
+ at: str
49
+ run_id: str | None
50
+ step: str
51
+ item_id: str | None
52
+ speaker: str
53
+ text: str
54
+
55
+ def to_dict(self) -> dict[str, object]:
56
+ return {
57
+ "at": self.at,
58
+ "run_id": self.run_id,
59
+ "step": self.step,
60
+ "item_id": self.item_id,
61
+ "speaker": self.speaker,
62
+ "text": self.text,
63
+ }
64
+
65
+
66
+ class InteractionLog:
67
+ def __init__(self, storage: Storage) -> None:
68
+ self.storage = storage
69
+
70
+ def path(self, task_id: str) -> Path:
71
+ return self.storage.runtime_path / "tasks" / task_id / INTERACTIONS_FILE
72
+
73
+ def append(
74
+ self,
75
+ task_id: str,
76
+ *,
77
+ run_id: str | None,
78
+ step: str,
79
+ speaker: str,
80
+ text: str,
81
+ at: str,
82
+ item_id: str | None = None,
83
+ ) -> None:
84
+ """Append one entry; the file is created with a title on first use."""
85
+ self.append_entries(
86
+ task_id,
87
+ ((speaker, text),),
88
+ run_id=run_id,
89
+ step=step,
90
+ at=at,
91
+ item_id=item_id,
92
+ )
93
+
94
+ def append_entries(
95
+ self,
96
+ task_id: str,
97
+ spoken: Sequence[tuple[str, str]],
98
+ *,
99
+ run_id: str | None,
100
+ step: str,
101
+ at: str,
102
+ item_id: str | None = None,
103
+ ) -> None:
104
+ """Append ``(speaker, text)`` entries in order, in one write."""
105
+ if not spoken:
106
+ return
107
+ path = self.path(task_id)
108
+ path.parent.mkdir(parents=True, exist_ok=True)
109
+ fields = [at, run_id or "-", step]
110
+ if item_id is not None:
111
+ fields.append(item_id)
112
+ chunks = []
113
+ for speaker, text in spoken:
114
+ chunks.append(f"## {_SEPARATOR.join([*fields, speaker])}\n\n")
115
+ if text.strip():
116
+ chunks.append(text.strip() + "\n\n")
117
+ with path.open("a", encoding="utf-8") as handle:
118
+ if handle.tell() == 0:
119
+ handle.write(f"# {task_id} — interactions with the operator\n\n")
120
+ handle.write("".join(chunks))
121
+
122
+ def read(self, task_id: str) -> str:
123
+ path = self.path(task_id)
124
+ return path.read_text(encoding="utf-8") if path.is_file() else ""
125
+
126
+ def entries(self, task_id: str) -> tuple[InteractionEntry, ...]:
127
+ """Read the record back as entries, in the order they were appended."""
128
+ result: list[InteractionEntry] = []
129
+ heading: InteractionEntry | None = None
130
+ body: list[str] = []
131
+ for line in self.read(task_id).splitlines():
132
+ parsed = _parse_heading(line)
133
+ if parsed is None:
134
+ body.append(line)
135
+ continue
136
+ if heading is not None:
137
+ result.append(replace(heading, text="\n".join(body).strip()))
138
+ heading, body = parsed, []
139
+ if heading is not None:
140
+ result.append(replace(heading, text="\n".join(body).strip()))
141
+ return tuple(result)
142
+
143
+ def remove(self, task_id: str) -> None:
144
+ """Forget a task's record, as part of resetting the task."""
145
+ self.path(task_id).unlink(missing_ok=True)
146
+
147
+
148
+ def parse_transcript(text: str) -> tuple[tuple[str, str], ...]:
149
+ """A plain transcript as ordered ``(speaker, text)`` entries.
150
+
151
+ Speakers are ``operator`` and ``agent``; an entry with no words is
152
+ dropped. Text before the first marker, or no entry at all, is refused.
153
+ """
154
+ entries: list[tuple[str, list[str]]] = []
155
+ for line in text.splitlines():
156
+ match = _MARKER.match(line)
157
+ if match is not None:
158
+ entries.append((match.group(1).lower(), [match.group(2)]))
159
+ elif entries:
160
+ entries[-1][1].append(line)
161
+ elif line.strip():
162
+ raise StateError(
163
+ f"the transcript starts with {line.strip()[:40]!r}; "
164
+ + _TRANSCRIPT_FORMAT
165
+ )
166
+ result = tuple(
167
+ (speaker, "\n".join(lines).strip())
168
+ for speaker, lines in entries
169
+ if "\n".join(lines).strip()
170
+ )
171
+ if not result:
172
+ raise StateError("the transcript has no entries; " + _TRANSCRIPT_FORMAT)
173
+ return result
174
+
175
+
176
+ def _parse_heading(line: str) -> InteractionEntry | None:
177
+ """An entry heading: time, run, step, an optional item, and the speaker."""
178
+ if not line.startswith("## "):
179
+ return None
180
+ fields = line[3:].split(_SEPARATOR)
181
+ if len(fields) not in (_FIELDS, _FIELDS + 1):
182
+ return None
183
+ at, run_id, step = fields[:3]
184
+ item_id = fields[3] if len(fields) > _FIELDS else None
185
+ return InteractionEntry(
186
+ at, None if run_id == "-" else run_id, step, item_id, fields[-1], ""
187
+ )
ww/interpolation.py ADDED
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Interpolation analysis for normalized workflow definitions."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from collections.abc import Mapping
8
+
9
+ from ww.errors import ConfigurationError
10
+
11
+ # One {{name}} template token, spaces inside the braces allowed:
12
+ # "{{ ww.task.id }}" and "{{metadata.owner}}" both match.
13
+ _TOKEN = re.compile(
14
+ r"\{\{\s*([A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*)\s*\}\}"
15
+ )
16
+ # Double-braced text that is not a valid token, e.g. "{{ not a name }}".
17
+ _BRACES = re.compile(r"\{\{.*?\}\}")
18
+
19
+
20
+ def dependencies(value: str) -> tuple[str, ...]:
21
+ """Return ordered interpolation names and reject malformed tokens."""
22
+ names = tuple(match.group(1) for match in _TOKEN.finditer(value))
23
+ normalized = _TOKEN.sub("", value)
24
+ if "{{" in normalized or "}}" in normalized or _BRACES.search(normalized):
25
+ raise ConfigurationError(f"invalid interpolation in {value!r}")
26
+ return names
27
+
28
+
29
+ def interpolate(value: str, variables: Mapping[str, str]) -> str:
30
+ """Bind values that are available, preserving declared unresolved tokens."""
31
+ dependencies(value)
32
+
33
+ def replace(match: re.Match[str]) -> str:
34
+ name = match.group(1)
35
+ return variables.get(name, match.group(0))
36
+
37
+ return _TOKEN.sub(replace, value)