ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/cli/prompts.py ADDED
@@ -0,0 +1,217 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Operator confirmations read from the terminal."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sys
8
+ import termios
9
+ import tty
10
+
11
+
12
+ def _ask_choice(prompt: str, choices: tuple[str, ...], default: str) -> str:
13
+ while True:
14
+ value = input(prompt).strip().lower() or default
15
+ if value in choices:
16
+ return value
17
+ print("Choose one of: " + ", ".join(choices) + ".")
18
+
19
+
20
+ def _interactive_terminal() -> bool:
21
+ """Whether the operator can drive a redrawing prompt."""
22
+ return (
23
+ sys.stdin.isatty()
24
+ and sys.stdout.isatty()
25
+ and os.environ.get("TERM") not in {None, "", "dumb"}
26
+ )
27
+
28
+
29
+ def _ask_checklist(
30
+ heading: str, options: tuple[tuple[str, str, bool], ...]
31
+ ) -> tuple[str, ...]:
32
+ """Toggle a list of ``(name, note, checked)`` rows and return the checked.
33
+
34
+ One screen replaces one question per row. A terminal that cannot be driven
35
+ this way -- a pipe, a dumb terminal, a captured test stdin -- never reaches
36
+ here; the caller asks in plain text instead.
37
+ """
38
+ checked = [state for _, _, state in options]
39
+ cursor = 0
40
+ sys.stdout.write(f"{heading}\n")
41
+ sys.stdout.write(" ↑↓ move · space toggles · a all · enter confirms\n\n")
42
+ _draw(options, checked, cursor, first=True)
43
+ settings = termios.tcgetattr(sys.stdin.fileno())
44
+ try:
45
+ tty.setraw(sys.stdin.fileno())
46
+ while True:
47
+ key = sys.stdin.read(1)
48
+ # An arrow key arrives as an escape sequence; read its tail.
49
+ if key == "\x1b" and sys.stdin.read(1) == "[":
50
+ key = _ARROWS.get(sys.stdin.read(1), "")
51
+ cursor, checked, done = _apply_key(key, cursor, checked)
52
+ if done:
53
+ break
54
+ _draw(options, checked, cursor)
55
+ finally:
56
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, settings)
57
+ sys.stdout.write("\n")
58
+ sys.stdout.flush()
59
+ return tuple(
60
+ name for (name, _, _), state in zip(options, checked, strict=True) if state
61
+ )
62
+
63
+
64
+ _ARROWS = {"A": "k", "B": "j"}
65
+
66
+
67
+ def _apply_key(
68
+ key: str, cursor: int, checked: list[bool]
69
+ ) -> tuple[int, list[bool], bool]:
70
+ """Fold one keypress into the checklist's state.
71
+
72
+ Kept apart from the terminal so the behaviour can be tested without one.
73
+ Returns the new cursor, the new marks, and whether the operator is done.
74
+ """
75
+ if key == "\x03":
76
+ raise KeyboardInterrupt
77
+ if key in {"\r", "\n"}:
78
+ return cursor, checked, True
79
+ count = len(checked)
80
+ if key in {"j", "\t"}:
81
+ return (cursor + 1) % count, checked, False
82
+ if key == "k":
83
+ return (cursor - 1) % count, checked, False
84
+ if key == " ":
85
+ toggled = list(checked)
86
+ toggled[cursor] = not toggled[cursor]
87
+ return cursor, toggled, False
88
+ if key == "a":
89
+ return cursor, [not all(checked)] * count, False
90
+ return cursor, checked, False
91
+
92
+
93
+ def _draw(
94
+ options: tuple[tuple[str, str, bool], ...],
95
+ checked: list[bool],
96
+ cursor: int,
97
+ *,
98
+ first: bool = False,
99
+ ) -> None:
100
+ """Repaint the rows in place, so the screen never scrolls away."""
101
+ if not first:
102
+ sys.stdout.write(f"\x1b[{len(options)}A")
103
+ width = max(len(name) for name, _, _ in options)
104
+ for index, (name, note, _) in enumerate(options):
105
+ mark = "x" if checked[index] else " "
106
+ pointer = ">" if index == cursor else " "
107
+ label = f"{name.ljust(width)} {note}" if note else name
108
+ row = f" {pointer} [{mark}] {label}"
109
+ sys.stdout.write(f"\r\x1b[K{row}\n")
110
+ sys.stdout.flush()
111
+
112
+
113
+ def _ask_names(prompt: str, names: tuple[str, ...]) -> tuple[str, ...]:
114
+ """Read zero or more of ``names``, comma-separated; empty means none.
115
+
116
+ One question for a list beats one question per entry when the answer is
117
+ almost always "none of them".
118
+ """
119
+ allowed = {name.lstrip(".").lower(): name for name in names}
120
+ while True:
121
+ value = input(prompt).strip().lower()
122
+ # Someone answering a list of options with "n" plainly means none.
123
+ if not value or value in {"none", "n", "no"}:
124
+ return ()
125
+ chosen = [part.strip().lstrip(".") for part in value.split(",")]
126
+ if all(part in allowed for part in chosen if part):
127
+ return tuple(dict.fromkeys(allowed[part] for part in chosen if part))
128
+ print("Choose from: " + ", ".join(names) + ", or none.")
129
+
130
+
131
+ def _ask_yes_no(prompt: str, default: bool) -> bool:
132
+ while True:
133
+ value = input(prompt).strip().lower()
134
+ if not value:
135
+ return default
136
+ if value in {"y", "yes"}:
137
+ return True
138
+ if value in {"n", "no"}:
139
+ return False
140
+ print("Answer yes or no.")
141
+
142
+
143
+ def _ask_operator(prompt: str, cancelled: str) -> bool:
144
+ """Ask the operator at a terminal; without one, refuse at once.
145
+
146
+ An agent's shell has no terminal, and an answer piped into the prompt is
147
+ not the operator's: the confirmation it needs is ``--yes``, given once
148
+ the operator has agreed. ww never reads a confirmation from a pipe.
149
+ """
150
+ if not sys.stdin.isatty():
151
+ print(
152
+ f"ww error: {cancelled.lower()} needs the operator's confirmation, "
153
+ "and there is no terminal to ask at. If you are an agent, ask the "
154
+ "operator; rerun with --yes only once they have agreed.",
155
+ file=sys.stderr,
156
+ )
157
+ return False
158
+ try:
159
+ confirmed = _ask_yes_no(prompt, default=False)
160
+ except EOFError:
161
+ confirmed = False
162
+ if not confirmed:
163
+ print(
164
+ f"{cancelled} cancelled: explicit confirmation is required.",
165
+ file=sys.stderr,
166
+ )
167
+ return confirmed
168
+
169
+
170
+ def confirm_operator(
171
+ command: str,
172
+ effect: str,
173
+ question: str,
174
+ cancelled: str,
175
+ *,
176
+ assume_yes: bool = False,
177
+ ) -> bool:
178
+ """Require the operator's acknowledgement before an irreversible choice.
179
+
180
+ ``effect`` is ww's own description of what ``command`` will do, obtained
181
+ after the state was checked, so the operator approves a real action.
182
+ ``assume_yes`` is ``--yes``: an agent carrying out the operator's stated
183
+ decision; the effect is still printed, so the decision is on record.
184
+ """
185
+ if assume_yes:
186
+ print(f"`{command}` will {effect}. Confirmed with --yes.", file=sys.stderr)
187
+ return True
188
+ print(f"`{command}` will {effect}.", file=sys.stderr)
189
+ return _ask_operator(f"{question} [y/N] ", cancelled)
190
+
191
+
192
+ def _confirm_force_next(
193
+ effect: str = "skip the current item without running it",
194
+ *,
195
+ assume_yes: bool = False,
196
+ ) -> bool:
197
+ """Require an operator acknowledgement before forcing past work."""
198
+ return confirm_operator(
199
+ "ww next --force",
200
+ effect,
201
+ "Proceed with force?",
202
+ "Force",
203
+ assume_yes=assume_yes,
204
+ )
205
+
206
+
207
+ def confirm_interrupted_retry(*, assume_yes: bool = False) -> bool:
208
+ """Require an operator to acknowledge duplicate-effect risk."""
209
+ if assume_yes:
210
+ print("The retry is confirmed with --yes.", file=sys.stderr)
211
+ return True
212
+ print(
213
+ "This operation was interrupted and may already have taken effect. "
214
+ "Retrying can duplicate its external effect.",
215
+ file=sys.stderr,
216
+ )
217
+ return _ask_operator("Proceed with retry? [y/N] ", "Retry")
ww/cli/updates.py ADDED
@@ -0,0 +1,117 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Announcing a newer ww before running the command the caller asked for."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+
10
+ from ww.errors import WwError
11
+ from ww.executable import DEFAULT_EXECUTABLE, printed_executable
12
+ from ww.package_updates import (
13
+ PackageUpdateNotice,
14
+ last_package_notice,
15
+ mark_package_announced,
16
+ pending_package_notice,
17
+ )
18
+ from ww.project_config import load_project_config
19
+ from ww.storage import Storage
20
+ from ww.updates import (
21
+ UpdateNotice,
22
+ check_enabled,
23
+ installation_checkout,
24
+ last_notice,
25
+ mark_announced,
26
+ pending_notice,
27
+ )
28
+
29
+
30
+ def announce(storage: Storage, *, to_stderr: bool = False) -> None:
31
+ """Write a pending update notice ahead of the command's own output.
32
+
33
+ A notice is a convenience and may never disturb the command: unreadable
34
+ state, a remote that will not answer, or a read-only home leaves the
35
+ invocation exactly as it would otherwise have been. ``to_stderr`` keeps it
36
+ out of stdout that something else is going to parse.
37
+ """
38
+ try:
39
+ if not update_check_wanted(storage):
40
+ return
41
+ checkout = installation_checkout()
42
+ notice = (
43
+ pending_notice(checkout)
44
+ if checkout is not None
45
+ else pending_package_notice()
46
+ )
47
+ if notice is None:
48
+ return
49
+ stream = sys.stderr if to_stderr else sys.stdout
50
+ configured = load_project_config(storage.project_config_path).executable
51
+ executable = configured or (
52
+ "./ww" if (storage.root / "ww").is_file() else DEFAULT_EXECUTABLE
53
+ )
54
+ with printed_executable(executable):
55
+ stream.write(notice.render())
56
+ stream.flush()
57
+ _mark(notice)
58
+ except Exception: # noqa: BLE001 - contain every failure of a convenience
59
+ return
60
+
61
+
62
+ def render_updates(storage: Storage, args: argparse.Namespace) -> str:
63
+ """Render ``ww updates``: what the last check found, or a fresh look."""
64
+ if not update_check_wanted(storage):
65
+ return _rendered(None, args, "The ww update check is turned off.\n")
66
+ checkout = installation_checkout()
67
+ notice: UpdateNotice | PackageUpdateNotice | None
68
+ if checkout is None:
69
+ pending_package_notice(force=bool(args.now))
70
+ notice = last_package_notice()
71
+ else:
72
+ pending_notice(checkout, force=bool(args.now))
73
+ notice = last_notice()
74
+ if notice is not None:
75
+ _mark(notice)
76
+ return _rendered(
77
+ notice,
78
+ args,
79
+ "ww is up to date.\n"
80
+ if checkout is not None
81
+ else "No package update notice is available.\n",
82
+ )
83
+
84
+
85
+ def update_check_wanted(storage: Storage) -> bool:
86
+ """Whether this project and environment want the check to run."""
87
+ try:
88
+ configured = load_project_config(storage.project_config_path).update_check
89
+ except WwError:
90
+ configured = True
91
+ return check_enabled(configured)
92
+
93
+
94
+ def _rendered(
95
+ notice: UpdateNotice | PackageUpdateNotice | None,
96
+ args: argparse.Namespace,
97
+ otherwise: str,
98
+ ) -> str:
99
+ if args.json_output:
100
+ return (
101
+ json.dumps(
102
+ {
103
+ "update_available": notice is not None,
104
+ "notice": notice.to_dict() if notice else None,
105
+ },
106
+ indent=2,
107
+ )
108
+ + "\n"
109
+ )
110
+ return notice.render() if notice else otherwise
111
+
112
+
113
+ def _mark(notice: UpdateNotice | PackageUpdateNotice) -> None:
114
+ if isinstance(notice, PackageUpdateNotice):
115
+ mark_package_announced(notice)
116
+ else:
117
+ mark_announced(notice)
@@ -0,0 +1,156 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Persist agent completion artifacts without changing workflow state."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from ww.actions import PlannedAction, actions
7
+ from ww.artifacts import RuleOutcome, RulesSummary, RuleStatus, render_step_artifact
8
+ from ww.contracts import CheckStatus
9
+ from ww.execution_models import CheckReport, ExecutionState, PlanSnapshot
10
+ from ww.plan import PlanItem
11
+ from ww.rule_checks import item_reports
12
+ from ww.storage_adapters.base import ArtifactAddress, TaskArtifactStorage
13
+
14
+ _REPORTED: dict[CheckStatus, RuleStatus] = {
15
+ "passed": "passed",
16
+ "failed": "failed",
17
+ "not_applicable": "not applicable",
18
+ }
19
+
20
+
21
+ def rule_outcomes(
22
+ state: ExecutionState, item: PlanItem, report: CheckReport | None
23
+ ) -> RulesSummary | None:
24
+ """What the completing step's artifact says about its rules and checks.
25
+
26
+ A rule checked by a derived check reports that check's result; one a
27
+ verifier judged reports the verdict and the verification item; any other
28
+ rule without a check is self-declared: the worker states in its result
29
+ how it followed it. A check reports its result in ``report``, or, when
30
+ the operator waived it, in the last report that ran it. Rejections an
31
+ operator retry moved into the history still count.
32
+ """
33
+ record = state.item_executions[state.cursor]
34
+ if not item.rules and not item.checks:
35
+ return None
36
+ results = {
37
+ result.id: result
38
+ for ran in (*record.check_reports, *((report,) if report else ()))
39
+ for result in ran.results
40
+ }
41
+ checked = {check.id for check in item.checks}
42
+ derived = {
43
+ rule_id: check.id
44
+ for check in record.resolved_checks
45
+ for rule_id in check.covers
46
+ }
47
+ held = record.held_completion
48
+ outcomes = []
49
+ for rule in item.rules:
50
+ if rule.id in checked:
51
+ continue
52
+ verdict = held.verdict(rule.id) if held is not None else None
53
+ if rule.id in derived:
54
+ result = results.get(derived[rule.id])
55
+ status = _REPORTED[result.status] if result else "not applicable"
56
+ outcomes.append(
57
+ RuleOutcome(rule.id, status, detail=f"check `{derived[rule.id]}`")
58
+ )
59
+ elif verdict is not None and verdict.verdict == "pass":
60
+ outcomes.append(
61
+ RuleOutcome(rule.id, "verified pass", detail=f"by `{verdict.by}`")
62
+ )
63
+ else:
64
+ outcomes.append(RuleOutcome(rule.id, "self-declared"))
65
+ for check in item.checks:
66
+ result = results.get(check.id)
67
+ outcomes.append(
68
+ RuleOutcome(
69
+ check.id,
70
+ _REPORTED[result.status] if result is not None else "not applicable",
71
+ hook=check.source == "hook",
72
+ )
73
+ )
74
+ order = {rule.id: index for index, rule in enumerate(item.rules)}
75
+ outcomes.sort(key=lambda outcome: order.get(outcome.id, len(order)))
76
+ return RulesSummary(
77
+ tuple(outcomes),
78
+ fix_attempts=sum(1 for earlier in item_reports(state) if earlier.failed),
79
+ waived=record.checks_waived,
80
+ )
81
+
82
+
83
+ def write_completion_artifacts(
84
+ storage: TaskArtifactStorage,
85
+ task_id: str,
86
+ state: ExecutionState,
87
+ snapshot: PlanSnapshot,
88
+ item: PlanItem,
89
+ loop_entry: PlanItem | None,
90
+ artifact: str | None,
91
+ *,
92
+ rules: RulesSummary | None = None,
93
+ ) -> tuple[str | None, str | None]:
94
+ """Write the completed item and optional enclosing-loop artifacts.
95
+
96
+ ``rules`` is the completed item's own rule report; a loop wrapper's
97
+ artifact carries none.
98
+ """
99
+ if artifact is None:
100
+ return None, None
101
+
102
+ def attribution(plan_item: PlanItem) -> str:
103
+ if not isinstance(plan_item.operation, PlannedAction):
104
+ return "auto"
105
+ action = actions.get(plan_item.kind)
106
+ return action.traits(plan_item.operation.payload).artifact_attribution
107
+
108
+ step_paths = tuple(
109
+ dict.fromkeys(
110
+ plan_item.step
111
+ for plan_item in snapshot.plan.items
112
+ if plan_item.phase == "step"
113
+ )
114
+ )
115
+
116
+ def rendered(plan_item: PlanItem) -> str:
117
+ return render_step_artifact(
118
+ task_id=task_id,
119
+ workflow=state.workflow,
120
+ step=plan_item.step,
121
+ step_number=step_paths.index(plan_item.step) + 1,
122
+ step_total=len(step_paths),
123
+ skill=attribution(plan_item),
124
+ result=artifact,
125
+ rules=rules if plan_item is item else None,
126
+ )
127
+
128
+ def write(plan_item: PlanItem, content: str) -> str:
129
+ return storage.write_execution_artifact(
130
+ ArtifactAddress(
131
+ task_id,
132
+ state.workflow,
133
+ plan_item.step,
134
+ plan_item.position,
135
+ plan_item.name,
136
+ plan_item.phase,
137
+ run_id=state.run_id,
138
+ step_ordinals=plan_item.step_ordinals,
139
+ loop_iterations=tuple(
140
+ (loop_id, iteration)
141
+ for loop_id, iteration in state.loop_iterations
142
+ if loop_id in plan_item.ancestors
143
+ ),
144
+ ),
145
+ content,
146
+ )
147
+
148
+ artifact_reference = None
149
+ if item.artifact:
150
+ artifact_reference = write(
151
+ item, rendered(item) if item.phase == "step" else artifact
152
+ )
153
+ wrapper_artifact_reference = None
154
+ if loop_entry is not None and loop_entry.artifact:
155
+ wrapper_artifact_reference = write(loop_entry, rendered(loop_entry))
156
+ return artifact_reference, wrapper_artifact_reference
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Validation for values supplied when completing workflow work."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from ww.errors import StateError
7
+ from ww.workflow_config import ProvidedVariable
8
+
9
+
10
+ def validate_values(values: tuple[tuple[str, str], ...]) -> dict[str, str]:
11
+ """Return supplied values, rejecting duplicate names."""
12
+ result = dict(values)
13
+ if len(result) != len(values):
14
+ raise StateError("a completion variable can only be supplied once")
15
+ return result
16
+
17
+
18
+ def group_metadata_values(
19
+ values: tuple[tuple[str, str], ...],
20
+ ) -> dict[str, tuple[str, ...]]:
21
+ """Group ``--metadata`` values by name; an append key may repeat."""
22
+ grouped: dict[str, tuple[str, ...]] = {}
23
+ for name, value in values:
24
+ grouped[name] = (*grouped.get(name, ()), value)
25
+ return grouped
26
+
27
+
28
+ def validate_requested_values(
29
+ values: dict[str, str], requested: tuple[ProvidedVariable, ...]
30
+ ) -> None:
31
+ """Require exactly the variables requested by a plan item."""
32
+ expected = {item.name for item in requested}
33
+ unknown, missing = set(values) - expected, expected - set(values)
34
+ if unknown:
35
+ raise StateError(
36
+ "unexpected completion variable(s): " + ", ".join(sorted(unknown))
37
+ )
38
+ if missing:
39
+ raise StateError("missing required variable(s): " + ", ".join(sorted(missing)))