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/rule_conversion.py ADDED
@@ -0,0 +1,331 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Record scriptized checks outside a task: ``rules convert`` and ``decline``.
3
+
4
+ A rule without a command of its own is enforced by the rule-automation
5
+ store. These operations write the store directly, on the operator's
6
+ confirmation, so a check built once for the project (by
7
+ ``ww-scriptize-rules``, or by hand) is recorded without a task's verifier.
8
+ ``scriptize_state`` says, for each declared rule, where it stands, and
9
+ ``scriptize_notice`` tells the operator about the rules no check covers yet.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterator
15
+ from dataclasses import dataclass, replace
16
+ from pathlib import PurePosixPath
17
+ from typing import Literal
18
+
19
+ from ww.builtin_workflows import missing_lane
20
+ from ww.errors import StateError
21
+ from ww.rule_store import (
22
+ CheckEntry,
23
+ CheckSpec,
24
+ RuleAutomation,
25
+ RuleEntry,
26
+ is_check_name,
27
+ is_config_path,
28
+ )
29
+ from ww.workflow_config import RuleDefinition, WorkflowConfiguration, every_step
30
+
31
+ # The built-in workflow that turns rules into checks, outside any task.
32
+ SCRIPTIZE_WORKFLOW = "ww-scriptize-rules"
33
+
34
+ # Where a declared rule stands: checked by its own command, by a converted
35
+ # store check, declined or rejected (judged by a verifier), or not scriptized
36
+ # yet. The interim statuses of an old store, from when verifiers proposed
37
+ # checks inside tasks, count as not scriptized.
38
+ ScriptizeState = Literal[
39
+ "command", "converted", "not_convertible", "rejected", "unscriptized"
40
+ ]
41
+
42
+
43
+ def scriptize_state(automation: RuleAutomation, rule: RuleDefinition) -> ScriptizeState:
44
+ if rule.check is not None:
45
+ return "command"
46
+ if automation.converted_check(rule.text_hash) is not None:
47
+ return "converted"
48
+ entry = automation.rules.get(rule.text_hash)
49
+ if entry is not None and entry.status in {"not_convertible", "rejected"}:
50
+ return entry.status
51
+ return "unscriptized"
52
+
53
+
54
+ def unscriptized_rules(
55
+ configuration: WorkflowConfiguration, automation: RuleAutomation
56
+ ) -> tuple[RuleDefinition, ...]:
57
+ """The declared rules not scriptized yet, each once, in declaration order."""
58
+ found: dict[str, RuleDefinition] = {}
59
+ for rule in _every_rule(configuration):
60
+ if rule.id not in found and scriptize_state(automation, rule) == "unscriptized":
61
+ found[rule.id] = rule
62
+ return tuple(found.values())
63
+
64
+
65
+ def scriptize_notice(
66
+ configuration: WorkflowConfiguration, automation: RuleAutomation
67
+ ) -> str | None:
68
+ """What ``discover`` and ``start`` say about rules without a check yet.
69
+
70
+ ``None`` when there are none, or while ``ww-scriptize-rules`` is switched
71
+ off. It never blocks anything: such rules are judged by verifiers.
72
+ """
73
+ workflow = configuration.workflows_by_name.get(SCRIPTIZE_WORKFLOW)
74
+ if workflow is None:
75
+ return None
76
+ count = len(unscriptized_rules(configuration, automation))
77
+ if not count:
78
+ return None
79
+ notice = (
80
+ f"{count} declared rule{'s have' if count != 1 else ' has'} no check "
81
+ "yet, so a verifier judges "
82
+ f"{'them' if count != 1 else 'it'} in every step. The `ww-scriptize` "
83
+ f"skill starts `{SCRIPTIZE_WORKFLOW}`, which builds "
84
+ f"{'checks for them' if count != 1 else 'a check for it'} with the "
85
+ "operator."
86
+ )
87
+ if missing_lane(workflow) is not None:
88
+ notice += (
89
+ " It first needs the lane it works in: `hooks_from` in its "
90
+ "ww.yaml definition."
91
+ )
92
+ return notice
93
+
94
+
95
+ def declared_rules(
96
+ configuration: WorkflowConfiguration, rule_ids: tuple[str, ...]
97
+ ) -> tuple[RuleDefinition, ...]:
98
+ """The declared rules with these IDs, in the order given.
99
+
100
+ An unknown ID, a repeated one, and a rule with a command of its own,
101
+ which the store never covers, are refused.
102
+ """
103
+ if not rule_ids:
104
+ raise StateError("name at least one rule ID; `rules` lists them")
105
+ if len(set(rule_ids)) != len(rule_ids):
106
+ raise StateError("a rule ID is named twice")
107
+ by_id = {rule.id: rule for rule in _every_rule(configuration)}
108
+ rules = []
109
+ for rule_id in rule_ids:
110
+ rule = by_id.get(rule_id)
111
+ if rule is None:
112
+ raise StateError(
113
+ f"no rule {rule_id!r} is declared; `rules` lists every rule ID"
114
+ )
115
+ if rule.check is not None:
116
+ raise StateError(
117
+ f"rule `{rule_id}` has a command of its own; the store does not "
118
+ "cover it"
119
+ )
120
+ rules.append(rule)
121
+ return tuple(rules)
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class StoreChange:
126
+ """The store after ``convert`` or ``decline``, and what else it changed.
127
+
128
+ ``unscriptized`` are the text hashes of rules returned to unscriptized;
129
+ ``moved`` pairs a rule hash with the other check that covered it before;
130
+ ``dropped_checks`` are checks left covering nothing, removed with their
131
+ pending revisions; ``dropped_revisions`` names the checks whose pending
132
+ revision was dropped while the check stays.
133
+ """
134
+
135
+ automation: RuleAutomation
136
+ unscriptized: tuple[str, ...] = ()
137
+ moved: tuple[tuple[str, str], ...] = ()
138
+ dropped_checks: tuple[str, ...] = ()
139
+ dropped_revisions: tuple[str, ...] = ()
140
+
141
+
142
+ # Rule entries a check's own coverage stands for (``proposed`` only in an old
143
+ # store); any other entry naming the check, such as a rejection, is the
144
+ # operator's record and is kept when the check stops covering the rule.
145
+ _COVERED_STATUSES = frozenset({"converted", "proposed"})
146
+
147
+
148
+ def config_paths(paths: tuple[str, ...]) -> tuple[str, ...]:
149
+ """A check's configuration files, normalised, relative to its directory.
150
+
151
+ An empty path, an absolute one, and one with a ``..`` part, which would
152
+ reach outside the directory the step's checks run in, are refused.
153
+ """
154
+ normalised = []
155
+ for path in paths:
156
+ if not is_config_path(path.strip()):
157
+ raise StateError(
158
+ f"--config {path!r} must be a relative path inside the project, "
159
+ "without `..`"
160
+ )
161
+ normalised.append(PurePosixPath(path.strip()).as_posix())
162
+ return tuple(dict.fromkeys(normalised))
163
+
164
+
165
+ def convert(
166
+ automation: RuleAutomation,
167
+ name: str,
168
+ spec: CheckSpec,
169
+ rules: tuple[RuleDefinition, ...],
170
+ now: str,
171
+ ) -> StoreChange:
172
+ """Record ``name`` as a converted check covering ``rules``.
173
+
174
+ ``spec.covers`` is replaced by the rules' text hashes and ``spec.config``
175
+ is normalised. A new name creates the check; an existing one has its
176
+ command, configuration, proof and coverage replaced, and any pending
177
+ revision dropped. Rules the check covered before and covers no more
178
+ return to unscriptized: their store entries are removed, except a
179
+ rejection or a decline, which stays. A rule another check covered moves
180
+ to this one; a check left covering nothing is removed.
181
+ """
182
+ if not is_check_name(name):
183
+ raise StateError(
184
+ f"check name {name!r} must be kebab-case, at most 40 characters"
185
+ )
186
+ spec = replace(spec, config=config_paths(spec.config))
187
+ hashes = tuple(dict.fromkeys(rule.text_hash for rule in rules))
188
+ previous = automation.checks.get(name)
189
+ before = (
190
+ set(previous.spec.covers)
191
+ | set(previous.pending.covers if previous.pending else ())
192
+ if previous is not None
193
+ else set()
194
+ )
195
+ unscriptized = tuple(
196
+ key
197
+ for key in sorted(before - set(hashes))
198
+ if (entry := automation.rules.get(key)) is not None
199
+ and entry.check == name
200
+ and entry.status in _COVERED_STATUSES
201
+ )
202
+ moved = tuple(
203
+ (key, check_name)
204
+ for key in hashes
205
+ for check_name, check in automation.checks.items()
206
+ if check_name != name and key in _coverage(check)
207
+ )
208
+ automation = replace(
209
+ automation,
210
+ rules={k: v for k, v in automation.rules.items() if k not in unscriptized},
211
+ )
212
+ automation, dropped_checks, dropped_revisions = _uncover(
213
+ automation, set(hashes), keep=name
214
+ )
215
+ if previous is not None and previous.pending is not None:
216
+ dropped_revisions = (name, *dropped_revisions)
217
+ automation = automation.with_check(
218
+ name,
219
+ CheckEntry(
220
+ spec=replace(spec, covers=hashes),
221
+ status="converted",
222
+ proposed_at=previous.proposed_at if previous else now,
223
+ approved_at=now,
224
+ approved_by="operator",
225
+ ),
226
+ )
227
+ for rule in rules:
228
+ entry = automation.rules.get(rule.text_hash)
229
+ automation = automation.with_rule(
230
+ rule.text_hash,
231
+ RuleEntry(
232
+ rule.text,
233
+ "converted",
234
+ interpretation=entry.interpretation if entry else None,
235
+ check=name,
236
+ approved_by="operator",
237
+ ),
238
+ )
239
+ return StoreChange(
240
+ automation,
241
+ unscriptized=unscriptized,
242
+ moved=moved,
243
+ dropped_checks=dropped_checks,
244
+ dropped_revisions=dropped_revisions,
245
+ )
246
+
247
+
248
+ def decline(
249
+ automation: RuleAutomation, rules: tuple[RuleDefinition, ...], reason: str
250
+ ) -> StoreChange:
251
+ """Record ``rules`` as ``not_convertible``: judged, never proposed again."""
252
+ reason = reason.strip()
253
+ if not reason:
254
+ raise StateError("rules decline needs --reason")
255
+ hashes = {rule.text_hash for rule in rules}
256
+ moved = tuple(
257
+ (rule.text_hash, check_name)
258
+ for rule in rules
259
+ for check_name, check in automation.checks.items()
260
+ if rule.text_hash in _coverage(check)
261
+ )
262
+ automation, dropped_checks, dropped_revisions = _uncover(automation, hashes)
263
+ for rule in rules:
264
+ entry = automation.rules.get(rule.text_hash)
265
+ automation = automation.with_rule(
266
+ rule.text_hash,
267
+ RuleEntry(
268
+ rule.text,
269
+ "not_convertible",
270
+ interpretation=entry.interpretation if entry else None,
271
+ reason=reason,
272
+ approved_by="operator",
273
+ ),
274
+ )
275
+ return StoreChange(
276
+ automation,
277
+ moved=moved,
278
+ dropped_checks=dropped_checks,
279
+ dropped_revisions=dropped_revisions,
280
+ )
281
+
282
+
283
+ def _coverage(check: CheckEntry) -> set[str]:
284
+ """The rules a check covers, approved or in its pending revision."""
285
+ return set(check.spec.covers) | set(check.pending.covers if check.pending else ())
286
+
287
+
288
+ def _uncover(
289
+ automation: RuleAutomation, hashes: set[str], keep: str | None = None
290
+ ) -> tuple[RuleAutomation, tuple[str, ...], tuple[str, ...]]:
291
+ """Drop ``hashes`` from every check's coverage but ``keep``'s.
292
+
293
+ A check it changes loses any pending revision (only an old store has
294
+ one); a check, other than a rejected one, left covering nothing is
295
+ removed. Returns the store, the checks removed and the kept checks whose
296
+ revision was dropped.
297
+ """
298
+ dropped_checks: list[str] = []
299
+ dropped_revisions: list[str] = []
300
+ checks = dict(automation.checks)
301
+ for check_name, check in automation.checks.items():
302
+ if check_name == keep or not hashes & _coverage(check):
303
+ continue
304
+ spec = _without(check.spec, hashes)
305
+ if not spec.covers and check.status != "rejected":
306
+ del checks[check_name]
307
+ dropped_checks.append(check_name)
308
+ continue
309
+ if check.pending is not None:
310
+ dropped_revisions.append(check_name)
311
+ checks[check_name] = replace(check, spec=spec, pending=None)
312
+ return (
313
+ replace(automation, checks=checks),
314
+ tuple(dropped_checks),
315
+ tuple(dropped_revisions),
316
+ )
317
+
318
+
319
+ def _without(spec: CheckSpec, hashes: set[str]) -> CheckSpec:
320
+ if not hashes & set(spec.covers):
321
+ return spec
322
+ return replace(spec, covers=tuple(key for key in spec.covers if key not in hashes))
323
+
324
+
325
+ def _every_rule(configuration: WorkflowConfiguration) -> Iterator[RuleDefinition]:
326
+ for group in configuration.rule_groups:
327
+ yield from group.rules
328
+ for step in every_step(configuration):
329
+ for entry in step.rules:
330
+ if isinstance(entry, RuleDefinition):
331
+ yield entry
ww/rule_disputes.py ADDED
@@ -0,0 +1,148 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Every dispute a step's worker raised against a check, across tasks.
3
+
4
+ A worker whose completion a check rejected may dispute the check instead of
5
+ fixing its work (``ww dispute``); the operator then lets the check stand or
6
+ waives it for that step. The task keeps the open dispute on the step's record;
7
+ this log keeps every dispute ever raised in the project, so ``ww lint`` can
8
+ point at the rules and checks that keep being argued with without reading
9
+ every task's state.
10
+
11
+ The log is local history at ``.ww/rule-disputes.json``, beside the task
12
+ states it summarises. It is deliberately not part of the rule-automation
13
+ store: the store holds derived knowledge about rules, a dispute is an event.
14
+ A dispute is appended before the task state records it, so an interruption
15
+ between the two can leave one entry too many, never one too few.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from ww.errors import StateError
26
+ from ww.locking import FileLocks
27
+ from ww.validation import expect_optional_string, expect_positive_int, expect_string
28
+
29
+ DISPUTES_FILE = ".ww/rule-disputes.json"
30
+ DISPUTES_SCHEMA_VERSION = 1
31
+ _ENTRY_KEYS = {
32
+ "check",
33
+ "text_hash",
34
+ "task_id",
35
+ "run_id",
36
+ "step",
37
+ "reason",
38
+ "attempt",
39
+ "disputed_at",
40
+ }
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class DisputeEntry:
45
+ """One dispute: which check, of which step, why, and when.
46
+
47
+ ``text_hash`` is the disputed rule's wording hash when the check is a
48
+ rule's, so a dispute follows the wording like the store does.
49
+ """
50
+
51
+ check: str
52
+ task_id: str
53
+ step: str
54
+ reason: str
55
+ attempt: int
56
+ disputed_at: str
57
+ text_hash: str | None = None
58
+ run_id: str | None = None
59
+
60
+ def to_dict(self) -> dict[str, object]:
61
+ return {
62
+ "check": self.check,
63
+ "text_hash": self.text_hash,
64
+ "task_id": self.task_id,
65
+ "run_id": self.run_id,
66
+ "step": self.step,
67
+ "reason": self.reason,
68
+ "attempt": self.attempt,
69
+ "disputed_at": self.disputed_at,
70
+ }
71
+
72
+ @classmethod
73
+ def from_dict(cls, data: Any, path: str) -> DisputeEntry:
74
+ if not isinstance(data, dict):
75
+ raise ValueError(f"{path} must be an object")
76
+ unknown = set(data) - _ENTRY_KEYS
77
+ if unknown:
78
+ raise ValueError(f"{path} has unknown keys: " + ", ".join(sorted(unknown)))
79
+ return cls(
80
+ check=expect_string(data.get("check"), f"{path}.check"),
81
+ text_hash=expect_optional_string(
82
+ data.get("text_hash"), f"{path}.text_hash"
83
+ ),
84
+ task_id=expect_string(data.get("task_id"), f"{path}.task_id"),
85
+ run_id=expect_optional_string(data.get("run_id"), f"{path}.run_id"),
86
+ step=expect_string(data.get("step"), f"{path}.step"),
87
+ reason=expect_string(data.get("reason"), f"{path}.reason"),
88
+ attempt=expect_positive_int(data.get("attempt"), f"{path}.attempt"),
89
+ disputed_at=expect_string(data.get("disputed_at"), f"{path}.disputed_at"),
90
+ )
91
+
92
+
93
+ class DisputeLog:
94
+ """Read and append ``.ww/rule-disputes.json``.
95
+
96
+ A missing file is an empty log; a malformed one is an error, never an
97
+ empty log. Appends are read-modify-writes under the file's own lock,
98
+ taken inside the task lock of the disputing command.
99
+ """
100
+
101
+ def __init__(self, root: Path) -> None:
102
+ self.path = Path(root) / DISPUTES_FILE
103
+ self.locks = FileLocks(Path(root))
104
+
105
+ def load(self) -> tuple[DisputeEntry, ...]:
106
+ if not self.path.exists():
107
+ return ()
108
+ try:
109
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
110
+ return _entries(raw)
111
+ except (OSError, json.JSONDecodeError, ValueError) as error:
112
+ raise StateError(
113
+ f"invalid rule dispute log {self.path}: {error}"
114
+ ) from error
115
+
116
+ def append(self, entry: DisputeEntry) -> None:
117
+ with self.locks.lock(self.path, purpose="rule dispute log"):
118
+ entries = (*self.load(), entry)
119
+ self.locks.atomic_write(
120
+ self.path,
121
+ json.dumps(
122
+ {
123
+ "schema_version": DISPUTES_SCHEMA_VERSION,
124
+ "disputes": [item.to_dict() for item in entries],
125
+ },
126
+ indent=2,
127
+ )
128
+ + "\n",
129
+ )
130
+
131
+
132
+ def _entries(raw: Any) -> tuple[DisputeEntry, ...]:
133
+ if not isinstance(raw, dict):
134
+ raise ValueError("the dispute log must be an object")
135
+ if raw.get("schema_version") != DISPUTES_SCHEMA_VERSION:
136
+ raise ValueError(
137
+ f"unsupported dispute log schema: {raw.get('schema_version')!r}"
138
+ )
139
+ unknown = set(raw) - {"schema_version", "disputes"}
140
+ if unknown:
141
+ raise ValueError("unknown dispute log keys: " + ", ".join(sorted(unknown)))
142
+ disputes = raw.get("disputes", [])
143
+ if not isinstance(disputes, list):
144
+ raise ValueError("the dispute log's disputes must be a list")
145
+ return tuple(
146
+ DisputeEntry.from_dict(item, f"disputes[{index}]")
147
+ for index, item in enumerate(disputes)
148
+ )