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/extensions/store.py ADDED
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Durable, namespaced state for one extension.
3
+
4
+ Every extension gets ``.ww/ext/<vendor>/<name>/`` and reaches it only through
5
+ this object. Two properties matter:
6
+
7
+ * **Data lives outside the extension's source.** A pip-installed extension
8
+ cannot write next to its own module, and one vendored into ``ext/`` should not
9
+ put runtime state in the working tree. ``.ww/`` is already gitignored.
10
+ * **Writes use a per-file project lock.** Writes to the same store file are
11
+ serialized, and replacement writes are atomic for unlocked readers.
12
+ ``update_text`` keeps the lock across a complete read–modify–write sequence;
13
+ separate reads and writes do not form one transaction.
14
+
15
+ The surface is narrow on purpose: an extension that could name arbitrary paths
16
+ could write anywhere in the project.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+
24
+ from ww.errors import ConfigurationError
25
+ from ww.locking import FileLocks
26
+
27
+
28
+ class ExtensionStore:
29
+ """Read and write files under one extension's private directory."""
30
+
31
+ def __init__(self, root: Path, identifier: str) -> None:
32
+ self.root = Path(root)
33
+ self.identifier = identifier
34
+ self.directory = self.root / ".ww" / "ext" / identifier
35
+ self._locks = FileLocks(root)
36
+
37
+ def path(self, name: str) -> Path:
38
+ """Return the absolute path of ``name`` inside this extension's store."""
39
+ if not name or name.startswith(".") or "/" in name or "\\" in name:
40
+ raise ConfigurationError(
41
+ f"extension store file name must be a plain file name: {name!r}"
42
+ )
43
+ return self.directory / name
44
+
45
+ def append_line(self, name: str, line: str) -> None:
46
+ self._locks.append_line(self.path(name), line)
47
+
48
+ def write_text(self, name: str, content: str) -> None:
49
+ path = self.path(name)
50
+ purpose = f"extension store {self.identifier}/{name}"
51
+ with self._locks.lock(path, purpose=purpose):
52
+ self._locks.atomic_write(path, content)
53
+
54
+ def update_text(self, name: str, update: Callable[[str | None], str]) -> str:
55
+ """Atomically update one file while holding its lock across the read.
56
+
57
+ ``write_text`` protects readers from partial files, but callers that
58
+ derive new content from old content need this operation to avoid lost
59
+ updates. If ``update`` raises or returns a non-string, the file is left
60
+ unchanged.
61
+ """
62
+ path = self.path(name)
63
+ purpose = f"extension store {self.identifier}/{name}"
64
+ with self._locks.lock(path, purpose=purpose):
65
+ current = path.read_text(encoding="utf-8") if path.is_file() else None
66
+ content = update(current)
67
+ if not isinstance(content, str):
68
+ raise TypeError("extension store update must return a string")
69
+ self._locks.atomic_write(path, content)
70
+ return content
71
+
72
+ def read_text(self, name: str) -> str | None:
73
+ path = self.path(name)
74
+ return path.read_text(encoding="utf-8") if path.is_file() else None
75
+
76
+ def read_lines(self, name: str) -> tuple[str, ...]:
77
+ content = self.read_text(name)
78
+ return tuple(content.splitlines()) if content else ()
ww/feedback.py ADDED
@@ -0,0 +1,342 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Agent-generalised feedback candidates; no candidate is an installed rule."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import threading
8
+ import uuid
9
+ from collections.abc import Iterator
10
+ from contextlib import AbstractContextManager, contextmanager
11
+ from copy import deepcopy
12
+ from datetime import datetime, timezone
13
+ from typing import Any
14
+
15
+ from ww.errors import StateError
16
+ from ww.storage import Storage
17
+ from ww.task_ids import validate_task_id
18
+
19
+ STORE_SCHEMA = 1
20
+ # Rule review may prune after five subsequently completed unmatched tasks.
21
+ STALE_TASKS = 5
22
+
23
+
24
+ class FeedbackStore:
25
+ """Project-scoped, locked storage shared by tasks and their worktrees."""
26
+
27
+ def __init__(self, storage: Storage) -> None:
28
+ self.storage = storage
29
+ self.path = storage.runtime_path / "feedback.json"
30
+
31
+ def _lock(self) -> AbstractContextManager[None]:
32
+ return self.storage.locks.lock(self.path, purpose="operator feedback")
33
+
34
+ def _load(self) -> dict[str, Any]:
35
+ if not self.path.exists():
36
+ return {"schema": STORE_SCHEMA, "tasks": [], "completed": [], "points": {}}
37
+ try:
38
+ data = json.loads(self.path.read_text(encoding="utf-8"))
39
+ if not isinstance(data, dict) or data.get("schema") != STORE_SCHEMA:
40
+ raise ValueError("unsupported feedback store schema")
41
+ for key in ("tasks", "completed"):
42
+ if not isinstance(data.get(key), list) or any(
43
+ not isinstance(task, str) for task in data[key]
44
+ ):
45
+ raise ValueError(f"{key} must be a list of task IDs")
46
+ if not isinstance(data.get("points"), dict):
47
+ raise ValueError("points must be an object")
48
+ for point in data["points"].values():
49
+ if not isinstance(point, dict):
50
+ raise ValueError("point must be an object")
51
+ _point_fields(point)
52
+ if not isinstance(point.get("events"), list):
53
+ raise ValueError("events must be a list")
54
+ if type(point.get("last_seen")) is not int:
55
+ raise ValueError("last_seen must be an integer")
56
+ for event in point["events"]:
57
+ if not isinstance(event, dict):
58
+ raise ValueError("event must be an object")
59
+ _text(event, "task")
60
+ _text(event, "quote")
61
+ if "entry" in event:
62
+ if type(event["entry"]) is not int:
63
+ raise ValueError("event entry must be an integer")
64
+ else:
65
+ _text(event, "source")
66
+ _timestamp(event.get("at"))
67
+ point["last_encountered_at"] = _last_encountered(point["events"])
68
+ return dict(data)
69
+ except (OSError, ValueError, StateError) as error:
70
+ raise StateError(f"invalid feedback store {self.path}: {error}") from error
71
+
72
+ def _save(self, data: dict[str, Any]) -> None:
73
+ self.storage.locks.atomic_write(self.path, json.dumps(data, indent=2) + "\n")
74
+
75
+ def get(self, identifier: str) -> dict[str, object]:
76
+ points = self.listing()["points"]
77
+ assert isinstance(points, list)
78
+ for point in points:
79
+ if point["id"] == identifier:
80
+ return dict(point)
81
+ raise StateError(f"unknown feedback point {identifier!r}")
82
+
83
+ def listing(self) -> dict[str, object]:
84
+ data = self._load()
85
+ total = len(data["tasks"])
86
+ completed = set(data["completed"])
87
+ points = []
88
+ for identifier, point in data["points"].items():
89
+ task_ids = {event["task"] for event in point["events"]}
90
+ points.append(
91
+ {
92
+ "id": identifier,
93
+ **point,
94
+ "occurrences": len(point["events"]),
95
+ "task_occurrences": len(task_ids),
96
+ "task_ratio": len(task_ids) / total if total else 0,
97
+ "occurrence_ratio": len(point["events"]) / total if total else 0,
98
+ "completed_task_ratio": (
99
+ len(task_ids & completed) / len(completed) if completed else 0
100
+ ),
101
+ "tasks_since_last_seen": len(data["completed"])
102
+ - point["last_seen"],
103
+ }
104
+ )
105
+ return {
106
+ "total_tasks": total,
107
+ "completed_tasks": len(completed),
108
+ "retire_after_tasks": STALE_TASKS,
109
+ "points": points,
110
+ }
111
+
112
+ def record(
113
+ self,
114
+ task_id: str,
115
+ run_id: str | None,
116
+ analysis: object,
117
+ sources: object,
118
+ ) -> dict[str, object]:
119
+ """Record deductions with quoted artifact evidence and explicit match IDs."""
120
+ validate_task_id(task_id)
121
+ if not isinstance(analysis, list):
122
+ raise StateError("feedback analysis must be a JSON array")
123
+ if not isinstance(sources, list):
124
+ raise StateError("feedback sources must be a list")
125
+ by_id = {source["id"]: source for source in sources}
126
+ prepared = []
127
+ for raw in analysis:
128
+ if not isinstance(raw, dict):
129
+ raise StateError("each feedback point must be an object")
130
+ unknown = set(raw) - {
131
+ "id",
132
+ "summary",
133
+ "reason",
134
+ "enforcement",
135
+ "approach",
136
+ "evidence",
137
+ }
138
+ if unknown:
139
+ raise StateError(
140
+ "unknown feedback fields: " + ", ".join(sorted(unknown))
141
+ )
142
+ fields = _point_fields(raw)
143
+ identifier = raw.get("id")
144
+ if identifier is not None and (
145
+ not isinstance(identifier, str) or not identifier.strip()
146
+ ):
147
+ raise StateError("feedback id must be a non-empty string")
148
+ evidence = raw.get("evidence")
149
+ if not isinstance(evidence, list) or not evidence:
150
+ raise StateError("feedback evidence must be a non-empty list")
151
+ events = []
152
+ for supplied in evidence:
153
+ if not isinstance(supplied, dict) or set(supplied) != {
154
+ "source",
155
+ "quote",
156
+ }:
157
+ raise StateError("feedback evidence needs source and quote")
158
+ source_id = _text(supplied, "source")
159
+ source = by_id.get(source_id)
160
+ if source is None:
161
+ raise StateError(
162
+ "feedback source is not a completed learnable artifact"
163
+ )
164
+ quote = _text(supplied, "quote")
165
+ if quote not in source["content"]:
166
+ raise StateError("feedback quote does not occur in its artifact")
167
+ events.append(
168
+ {
169
+ "task": task_id,
170
+ "run_id": run_id,
171
+ "source": source_id,
172
+ "artifact": source["artifact"],
173
+ "step": source["step"],
174
+ "quote": quote,
175
+ "at": source["encountered_at"],
176
+ }
177
+ )
178
+ prepared.append((identifier, fields, events))
179
+ with self._lock():
180
+ data = self._load()
181
+ if task_id not in data["tasks"]:
182
+ data["tasks"].append(task_id)
183
+ if task_id not in data["completed"]:
184
+ data["completed"].append(task_id)
185
+ for identifier, fields, events in prepared:
186
+ if identifier is not None and identifier not in data["points"]:
187
+ raise StateError(
188
+ f"unknown feedback point {identifier!r}; list first"
189
+ )
190
+ if identifier is None:
191
+ existing = next(
192
+ (
193
+ key
194
+ for key, point in data["points"].items()
195
+ if point["summary"].casefold()
196
+ == fields["summary"].casefold()
197
+ ),
198
+ None,
199
+ )
200
+ if existing is not None:
201
+ seen = {
202
+ _event_key(event)
203
+ for event in data["points"][existing]["events"]
204
+ }
205
+ if any(_event_key(event) not in seen for event in events):
206
+ raise StateError(
207
+ f"existing feedback point {existing!r}; "
208
+ "pass its id to update"
209
+ )
210
+ identifier = existing
211
+ else:
212
+ identifier = "feedback-" + uuid.uuid4().hex[:12]
213
+ point = data["points"].setdefault(identifier, {"events": []})
214
+ seen = {_event_key(event) for event in point["events"]}
215
+ for event in events:
216
+ key = _event_key(event)
217
+ if key not in seen:
218
+ point["events"].append(event)
219
+ seen.add(key)
220
+ point.update(fields)
221
+ point["last_seen"] = max(
222
+ (
223
+ data["completed"].index(event["task"]) + 1
224
+ for event in point["events"]
225
+ if event["task"] in data["completed"]
226
+ ),
227
+ default=0,
228
+ )
229
+ point["last_encountered_at"] = _last_encountered(point["events"])
230
+ self._save(data)
231
+ return self.listing()
232
+
233
+ def complete_task(self, task_id: str) -> None:
234
+ """Count completed task exposure once; never deduce or delete candidates."""
235
+ with self._lock():
236
+ data = self._load()
237
+ if task_id in data["completed"]:
238
+ return
239
+ if task_id not in data["tasks"]:
240
+ data["tasks"].append(task_id)
241
+ data["completed"].append(task_id)
242
+ for point in data["points"].values():
243
+ if any(event["task"] == task_id for event in point["events"]):
244
+ point["last_seen"] = len(data["completed"])
245
+ self._save(data)
246
+
247
+ def prune(
248
+ self,
249
+ *,
250
+ dry_run: bool = False,
251
+ keep: tuple[str, ...] = (),
252
+ ) -> dict[str, object]:
253
+ """Explicit maintenance called by rule review, never by workflow completion."""
254
+ with self._lock():
255
+ data = self._load()
256
+ unknown = set(keep) - set(data["points"])
257
+ if unknown:
258
+ raise StateError(
259
+ "unknown feedback point(s): " + ", ".join(sorted(unknown))
260
+ )
261
+ stale = []
262
+ for identifier, point in data["points"].items():
263
+ pending = {event["task"] for event in point["events"]} - set(
264
+ data["completed"]
265
+ )
266
+ if (
267
+ identifier not in keep
268
+ and not pending
269
+ and len(data["completed"]) - point["last_seen"] >= STALE_TASKS
270
+ ):
271
+ stale.append(identifier)
272
+ if not dry_run and stale:
273
+ for identifier in stale:
274
+ del data["points"][identifier]
275
+ self._save(data)
276
+ return {"dry_run": dry_run, "pruned": stale, "kept": list(keep)}
277
+
278
+
279
+ def _event_key(event: dict[str, Any]) -> tuple[object, ...]:
280
+ # Legacy transcript points remain readable without losing IDs or counts.
281
+ source = event.get("source", f"interaction:{event.get('entry')}")
282
+ return event["task"], event.get("run_id"), source, event["quote"]
283
+
284
+
285
+ def _timestamp(value: object) -> datetime:
286
+ if not isinstance(value, str):
287
+ raise StateError("feedback encounter time must be an ISO timestamp")
288
+ try:
289
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
290
+ if parsed.tzinfo is None:
291
+ raise ValueError("timezone is required")
292
+ return parsed.astimezone(timezone.utc)
293
+ except ValueError as error:
294
+ raise StateError(f"invalid feedback encounter time: {value!r}") from error
295
+
296
+
297
+ def _last_encountered(events: list[dict[str, Any]]) -> str | None:
298
+ return (
299
+ max(events, key=lambda event: _timestamp(event["at"]))["at"] if events else None
300
+ )
301
+
302
+
303
+ def _text(raw: dict[str, Any], key: str) -> str:
304
+ value = raw.get(key)
305
+ if not isinstance(value, str) or not value.strip():
306
+ raise StateError(f"feedback {key} must be a non-empty string")
307
+ return value.strip()
308
+
309
+
310
+ def _point_fields(raw: dict[str, Any]) -> dict[str, str]:
311
+ fields = {
312
+ key: _text(raw, key) for key in ("summary", "reason", "enforcement", "approach")
313
+ }
314
+ if fields["enforcement"] not in {"scripted", "reasoning"}:
315
+ raise StateError("feedback enforcement must be scripted or reasoning")
316
+ return fields
317
+
318
+
319
+ class MemoryFeedbackStore(FeedbackStore):
320
+ """Injectable candidate storage for embedded services without disk writes."""
321
+
322
+ def __init__(self, storage: Storage) -> None:
323
+ super().__init__(storage)
324
+ self.data: dict[str, Any] = {
325
+ "schema": STORE_SCHEMA,
326
+ "tasks": [],
327
+ "completed": [],
328
+ "points": {},
329
+ }
330
+ self.guard = threading.RLock()
331
+
332
+ @contextmanager
333
+ def _lock(self) -> Iterator[None]:
334
+ with self.guard:
335
+ yield
336
+
337
+ def _load(self) -> dict[str, Any]:
338
+ with self.guard:
339
+ return deepcopy(self.data)
340
+
341
+ def _save(self, data: dict[str, Any]) -> None:
342
+ self.data = deepcopy(data)
ww/handler_repairs.py ADDED
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Persistent agent repairs of known failures of automated command items."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import replace
7
+
8
+ from ww.execution_models import ExecutionState
9
+ from ww.plan import PlanItem, WorkflowPlan
10
+ from ww.transitions import Clock, project_steps
11
+
12
+
13
+ def needs_repair(state: ExecutionState) -> bool:
14
+ return (
15
+ state.cursor < len(state.item_executions)
16
+ and state.item_executions[state.cursor].repair_pending
17
+ )
18
+
19
+
20
+ def close_assignment(state: ExecutionState) -> ExecutionState:
21
+ """End the current worker's authority before dispatching another assignment."""
22
+ return replace(
23
+ state,
24
+ assignment_item_id=None,
25
+ assignment_token=None,
26
+ assignment_model=None,
27
+ assignment_reasoning=None,
28
+ assignment_selected_agent=None,
29
+ assignment_selected_model=None,
30
+ assignment_selected_reasoning=None,
31
+ )
32
+
33
+
34
+ def request_repair(
35
+ state: ExecutionState, plan: WorkflowPlan, item: PlanItem, now: Clock
36
+ ) -> ExecutionState:
37
+ """Keep the failed command and its evidence in place while a worker fixes it."""
38
+ records = list(state.item_executions)
39
+ record = records[state.cursor]
40
+ failures = record.repair_failures + 1
41
+ records[state.cursor] = replace(
42
+ record, repair_pending=True, repair_failures=failures
43
+ )
44
+ limited = failures >= item.max_handler_fixes
45
+ active = not limited and state.workflow_runtime == "single"
46
+ return project_steps(
47
+ replace(
48
+ close_assignment(state),
49
+ item_executions=tuple(records),
50
+ status="failed" if limited else "in_progress" if active else "pending",
51
+ active_item_id=item.id if active else None,
52
+ failure_kind="fix_limit" if limited else None,
53
+ updated_at=now(),
54
+ ),
55
+ plan,
56
+ now,
57
+ )
ww/hooks/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Native agent hooks: resume context, a one-time stop reminder, and
3
+ interruption records, translated per agent by small adapters."""
4
+
5
+ from .agents import HOOK_AGENTS, HOOK_EVENTS, HookAgent, HookEvent, hook_agent
6
+ from .install import (
7
+ HookInstallation,
8
+ HookInstallError,
9
+ hook_snippet,
10
+ hooks_file,
11
+ hooks_installed,
12
+ install_hooks,
13
+ manual_instructions,
14
+ registered_elsewhere,
15
+ uninstall_hooks,
16
+ )
17
+ from .records import HookRecords, Interruption
18
+ from .runtime import HookAnswer, answer_hook, is_project_root
19
+
20
+ __all__ = [
21
+ "HOOK_AGENTS",
22
+ "HOOK_EVENTS",
23
+ "HookAgent",
24
+ "HookAnswer",
25
+ "HookEvent",
26
+ "HookInstallError",
27
+ "HookInstallation",
28
+ "HookRecords",
29
+ "Interruption",
30
+ "answer_hook",
31
+ "hook_agent",
32
+ "hook_snippet",
33
+ "hooks_file",
34
+ "hooks_installed",
35
+ "install_hooks",
36
+ "is_project_root",
37
+ "manual_instructions",
38
+ "registered_elsewhere",
39
+ "uninstall_hooks",
40
+ ]