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_store.py ADDED
@@ -0,0 +1,456 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The rule-automation store: what ww learned about rules without a command.
3
+
4
+ A rule without a command is judged by a verifier agent, unless a converted
5
+ check covers its wording. ``ww-scriptize-rules`` builds such checks outside
6
+ any task, and ``ww rules convert`` records each on the operator's
7
+ confirmation. ww keeps that knowledge in ``ww-rule-automation.json`` at the
8
+ project root, a file meant to be committed so every checkout and task shares
9
+ it. Nothing here is configuration:
10
+ verification never rewrites YAML or rule files, and the store is derived
11
+ knowledge only; the operator's ``ww rules`` writes are the only way rule files
12
+ change (:mod:`ww.rule_writes`).
13
+
14
+ Two maps make up the store. ``rules`` is keyed by the hash of a rule's
15
+ normalised text (:func:`ww.config.rules.rule_text_hash`, the single source of
16
+ truth for that key), so the same wording shares its knowledge wherever it is
17
+ declared and a changed wording starts over. ``checks`` is keyed by a short
18
+ check name; one check may cover several rules, the
19
+ normal case for an ecosystem tool whose one configuration holds many rules.
20
+ Only a ``converted`` check is ever run, and a rule is mechanical only when
21
+ its entry is ``converted`` and names a ``converted`` check. A store written
22
+ before verifiers stopped proposing checks may still hold their interim
23
+ statuses (an approach, a proposed check, a pending revision); they are read
24
+ as they are and never written any more.
25
+
26
+ The file is shared by every task of the project, so each change is a
27
+ read-modify-write under a dedicated file lock, taken inside the task lock of
28
+ the command that changes it and never the other way round.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import re
35
+ from collections.abc import Callable
36
+ from dataclasses import dataclass, field, replace
37
+ from pathlib import Path, PurePosixPath
38
+ from typing import Any, Literal, cast
39
+
40
+ from ww.actions import Commands
41
+ from ww.config.rules import parse_check_command
42
+ from ww.config_files import RULE_AUTOMATION_FILE
43
+ from ww.contracts import CheckAutomationStatus, RuleAutomationStatus
44
+ from ww.errors import ConfigurationError, StateError
45
+ from ww.locking import FileLocks
46
+ from ww.validation import (
47
+ expect_bool,
48
+ expect_literal,
49
+ expect_optional_string,
50
+ expect_string,
51
+ is_strict_int,
52
+ )
53
+
54
+ STORE_FILE = RULE_AUTOMATION_FILE
55
+ STORE_SCHEMA_VERSION = 1
56
+ # Who approved a proposal. ww asks the operator for every approval; ``auto``
57
+ # is what a store written by an earlier ww may record for its own approvals.
58
+ RuleApprover = Literal["operator", "auto"]
59
+ # A check name: lower-case words joined by single hyphens, e.g. "lint-src".
60
+ CHECK_NAME = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
61
+ CHECK_NAME_LIMIT = 40
62
+ _RULE_KEYS = {
63
+ "text",
64
+ "status",
65
+ "interpretation",
66
+ "approach",
67
+ "check",
68
+ "extends",
69
+ "reason",
70
+ "candidates",
71
+ "proposed_in",
72
+ "proposed_run",
73
+ "approved_by",
74
+ "approved_in",
75
+ }
76
+ _APPROVAL_KEYS = {"proposed_run", "approved_by", "approved_in"}
77
+ _SPEC_KEYS = {"argv", "shell", "args", "env", "assert", "config", "covers", "proven"}
78
+ _CHECK_KEYS = (
79
+ _SPEC_KEYS
80
+ | {
81
+ "status",
82
+ "proposed_at",
83
+ "approved_at",
84
+ "proposed_in",
85
+ "pending",
86
+ "reason",
87
+ }
88
+ | _APPROVAL_KEYS
89
+ )
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class RuleEntry:
94
+ """What ww knows about one rule wording.
95
+
96
+ ``check`` names the check that covers the rule, or, for an approach, the
97
+ check the verifier would create or extend (``extends``). ``reason``
98
+ explains a ``not_convertible`` or ``rejected`` rule; ``candidates`` are
99
+ the readings of an ``ambiguous`` one. ``proposed_in`` is the
100
+ verification item that last reported on it, and ``proposed_run`` its run
101
+ (``<task>/<run>``). ``approved_by`` and ``approved_in`` record who
102
+ approved its approach, reading, or check, and in which run; ``None`` for
103
+ an approval recorded before the store kept them.
104
+ """
105
+
106
+ text: str
107
+ status: RuleAutomationStatus
108
+ interpretation: str | None = None
109
+ approach: str | None = None
110
+ check: str | None = None
111
+ extends: bool = False
112
+ reason: str | None = None
113
+ candidates: tuple[str, ...] = ()
114
+ proposed_in: str | None = None
115
+ proposed_run: str | None = None
116
+ approved_by: RuleApprover | None = None
117
+ approved_in: str | None = None
118
+
119
+ def to_dict(self) -> dict[str, object]:
120
+ data: dict[str, object] = {"text": self.text, "status": self.status}
121
+ for key, value in (
122
+ ("interpretation", self.interpretation),
123
+ ("approach", self.approach),
124
+ ("check", self.check),
125
+ ("reason", self.reason),
126
+ ("proposed_in", self.proposed_in),
127
+ ("proposed_run", self.proposed_run),
128
+ ("approved_by", self.approved_by),
129
+ ("approved_in", self.approved_in),
130
+ ):
131
+ if value is not None:
132
+ data[key] = value
133
+ if self.extends:
134
+ data["extends"] = True
135
+ if self.candidates:
136
+ data["candidates"] = list(self.candidates)
137
+ return data
138
+
139
+ @classmethod
140
+ def from_dict(cls, data: Any, path: str) -> RuleEntry:
141
+ mapping = _mapping(data, path, _RULE_KEYS)
142
+ return cls(
143
+ text=expect_string(mapping.get("text"), f"{path}.text"),
144
+ status=expect_literal(
145
+ mapping.get("status"), RuleAutomationStatus, f"{path}.status"
146
+ ),
147
+ interpretation=expect_optional_string(
148
+ mapping.get("interpretation"), f"{path}.interpretation"
149
+ ),
150
+ approach=expect_optional_string(
151
+ mapping.get("approach"), f"{path}.approach"
152
+ ),
153
+ check=expect_optional_string(mapping.get("check"), f"{path}.check"),
154
+ extends=expect_bool(mapping.get("extends", False), f"{path}.extends"),
155
+ reason=expect_optional_string(mapping.get("reason"), f"{path}.reason"),
156
+ candidates=_strings(mapping.get("candidates", []), f"{path}.candidates"),
157
+ proposed_in=expect_optional_string(
158
+ mapping.get("proposed_in"), f"{path}.proposed_in"
159
+ ),
160
+ **_approval(mapping, path),
161
+ )
162
+
163
+
164
+ def _approval(mapping: dict[str, Any], path: str) -> dict[str, Any]:
165
+ """The provenance fields an entry shares: its run, approver, approval run."""
166
+ approver = mapping.get("approved_by")
167
+ return {
168
+ "proposed_run": expect_optional_string(
169
+ mapping.get("proposed_run"), f"{path}.proposed_run"
170
+ ),
171
+ "approved_by": (
172
+ expect_literal(approver, RuleApprover, f"{path}.approved_by")
173
+ if approver is not None
174
+ else None
175
+ ),
176
+ "approved_in": expect_optional_string(
177
+ mapping.get("approved_in"), f"{path}.approved_in"
178
+ ),
179
+ }
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class CheckSpec:
184
+ """One derived check: its command, the files holding its logic, its rules.
185
+
186
+ ``command`` is the cli handler shape (``argv`` or ``shell`` with ``args``
187
+ and ``env``, and ``assert``); ``config`` lists the project files that
188
+ carry the check's logic, so a later rule can join the same tool;
189
+ ``covers`` are the text hashes of the rules it checks; ``proven`` is the
190
+ verifier's report that the check fails on a deliberate violation and
191
+ passes on the real change set.
192
+ """
193
+
194
+ command: Commands
195
+ config: tuple[str, ...] = ()
196
+ covers: tuple[str, ...] = ()
197
+ proven: bool = False
198
+
199
+ def to_dict(self) -> dict[str, object]:
200
+ data: dict[str, object] = dict(self.command.commands[0].to_dict())
201
+ data["assert"] = (
202
+ self.command.assertion.to_data() if self.command.assertion else None
203
+ )
204
+ data["config"] = list(self.config)
205
+ data["covers"] = list(self.covers)
206
+ data["proven"] = self.proven
207
+ return data
208
+
209
+ @classmethod
210
+ def from_dict(cls, data: dict[str, Any], path: str) -> CheckSpec:
211
+ command_keys = cast(
212
+ dict[str, Any],
213
+ {
214
+ key: data[key]
215
+ for key in ("argv", "shell", "args", "env", "assert")
216
+ if key in data and data[key] is not None
217
+ },
218
+ )
219
+ return cls(
220
+ command=parse_command(command_keys, path),
221
+ config=_strings(data.get("config", []), f"{path}.config"),
222
+ covers=_strings(data.get("covers", []), f"{path}.covers"),
223
+ proven=expect_bool(data.get("proven", False), f"{path}.proven"),
224
+ )
225
+
226
+
227
+ def is_check_name(value: str) -> bool:
228
+ """Whether ``value`` is a check name: kebab-case, at most 40 characters.
229
+
230
+ The limit keeps a name from ever looking like a rule's 64-character hash.
231
+ """
232
+ return len(value) <= CHECK_NAME_LIMIT and CHECK_NAME.fullmatch(value) is not None
233
+
234
+
235
+ def is_config_path(path: str) -> bool:
236
+ """Whether ``path`` stays inside the directory it is read from."""
237
+ pure = PurePosixPath(path)
238
+ return bool(path.strip()) and not pure.is_absolute() and ".." not in pure.parts
239
+
240
+
241
+ def parse_command(mapping: dict[str, Any], path: str) -> Commands:
242
+ """A check command in the cli handler shape; one command, no ``idempotent``."""
243
+ try:
244
+ return parse_check_command(mapping, path)
245
+ except ConfigurationError as error:
246
+ raise StateError(str(error)) from error
247
+
248
+
249
+ def describe_command(command: Commands) -> str:
250
+ """A check command as the operator reads it before approving it."""
251
+ first = command.commands[0]
252
+ if first.shell is not None:
253
+ text = first.shell
254
+ if first.args:
255
+ text += " (args: " + " ".join(first.args) + ")"
256
+ if first.env:
257
+ text += " (env: " + ", ".join(f"{k}={v}" for k, v in first.env) + ")"
258
+ else:
259
+ text = " ".join(first.argv)
260
+ return text
261
+
262
+
263
+ @dataclass(frozen=True)
264
+ class CheckEntry:
265
+ """One derived check and its approval state.
266
+
267
+ ``pending`` is a revision of a ``converted`` check that a verifier once
268
+ proposed, as only an old store holds; it never runs, and ``rules
269
+ convert`` drops it. ``reason`` explains a
270
+ ``rejected`` check, such as the operator's ``ww rules revoke``. The
271
+ provenance fields are those of :class:`RuleEntry`.
272
+ """
273
+
274
+ spec: CheckSpec
275
+ status: CheckAutomationStatus
276
+ proposed_at: str | None = None
277
+ approved_at: str | None = None
278
+ proposed_in: str | None = None
279
+ pending: CheckSpec | None = None
280
+ reason: str | None = None
281
+ proposed_run: str | None = None
282
+ approved_by: RuleApprover | None = None
283
+ approved_in: str | None = None
284
+
285
+ def to_dict(self) -> dict[str, object]:
286
+ data = self.spec.to_dict()
287
+ data["status"] = self.status
288
+ for key, value in (
289
+ ("proposed_at", self.proposed_at),
290
+ ("approved_at", self.approved_at),
291
+ ("proposed_in", self.proposed_in),
292
+ ("reason", self.reason),
293
+ ("proposed_run", self.proposed_run),
294
+ ("approved_by", self.approved_by),
295
+ ("approved_in", self.approved_in),
296
+ ):
297
+ if value is not None:
298
+ data[key] = value
299
+ if self.pending is not None:
300
+ data["pending"] = self.pending.to_dict()
301
+ return data
302
+
303
+ @classmethod
304
+ def from_dict(cls, data: Any, path: str) -> CheckEntry:
305
+ mapping = _mapping(data, path, _CHECK_KEYS)
306
+ pending = mapping.get("pending")
307
+ return cls(
308
+ spec=CheckSpec.from_dict(mapping, path),
309
+ status=expect_literal(
310
+ mapping.get("status"), CheckAutomationStatus, f"{path}.status"
311
+ ),
312
+ proposed_at=expect_optional_string(
313
+ mapping.get("proposed_at"), f"{path}.proposed_at"
314
+ ),
315
+ approved_at=expect_optional_string(
316
+ mapping.get("approved_at"), f"{path}.approved_at"
317
+ ),
318
+ proposed_in=expect_optional_string(
319
+ mapping.get("proposed_in"), f"{path}.proposed_in"
320
+ ),
321
+ pending=(
322
+ CheckSpec.from_dict(
323
+ _mapping(pending, f"{path}.pending", _SPEC_KEYS), f"{path}.pending"
324
+ )
325
+ if pending is not None
326
+ else None
327
+ ),
328
+ reason=expect_optional_string(mapping.get("reason"), f"{path}.reason"),
329
+ **_approval(mapping, path),
330
+ )
331
+
332
+
333
+ @dataclass(frozen=True)
334
+ class RuleAutomation:
335
+ """The whole store. Its maps are owned by the instance and never mutated;
336
+ ``with_rule`` and ``with_check`` return a changed copy."""
337
+
338
+ rules: dict[str, RuleEntry] = field(default_factory=dict)
339
+ checks: dict[str, CheckEntry] = field(default_factory=dict)
340
+
341
+ def with_rule(self, text_hash: str, entry: RuleEntry) -> RuleAutomation:
342
+ return replace(self, rules={**self.rules, text_hash: entry})
343
+
344
+ def with_check(self, name: str, entry: CheckEntry) -> RuleAutomation:
345
+ return replace(self, checks={**self.checks, name: entry})
346
+
347
+ def converted_check(self, text_hash: str) -> tuple[str, CheckEntry] | None:
348
+ """The converted check that makes a rule mechanical, if there is one."""
349
+ entry = self.rules.get(text_hash)
350
+ if entry is None or entry.status != "converted" or entry.check is None:
351
+ return None
352
+ check = self.checks.get(entry.check)
353
+ if check is None or check.status != "converted":
354
+ return None
355
+ return entry.check, check
356
+
357
+ def to_dict(self) -> dict[str, object]:
358
+ return {
359
+ "schema_version": STORE_SCHEMA_VERSION,
360
+ "rules": {key: entry.to_dict() for key, entry in self.rules.items()},
361
+ "checks": {key: entry.to_dict() for key, entry in self.checks.items()},
362
+ }
363
+
364
+ @classmethod
365
+ def from_dict(cls, data: Any) -> RuleAutomation:
366
+ if not isinstance(data, dict):
367
+ raise ValueError("the rule automation store must be an object")
368
+ version = data.get("schema_version")
369
+ if not is_strict_int(version) or version != STORE_SCHEMA_VERSION:
370
+ raise ValueError(f"unsupported rule automation schema: {version!r}")
371
+ unknown = set(data) - {"schema_version", "rules", "checks"}
372
+ if unknown:
373
+ raise ValueError(
374
+ "unknown rule automation keys: " + ", ".join(sorted(unknown))
375
+ )
376
+ rules = data.get("rules", {})
377
+ checks = data.get("checks", {})
378
+ if not isinstance(rules, dict) or not isinstance(checks, dict):
379
+ raise ValueError("rule automation rules and checks must be objects")
380
+ for name in checks:
381
+ if not isinstance(name, str) or not is_check_name(name):
382
+ raise ValueError(f"invalid check name in the store: {name!r}")
383
+ return cls(
384
+ rules={
385
+ expect_string(key, "rule hash"): RuleEntry.from_dict(
386
+ value, f"rules.{key}"
387
+ )
388
+ for key, value in rules.items()
389
+ },
390
+ checks={
391
+ key: CheckEntry.from_dict(value, f"checks.{key}")
392
+ for key, value in checks.items()
393
+ },
394
+ )
395
+
396
+
397
+ class RuleStore:
398
+ """Load and change ``ww-rule-automation.json`` at the project root.
399
+
400
+ A missing file is an empty store. An unreadable or malformed file is an
401
+ error, never an empty store: losing approvals silently would re-ask the
402
+ operator and could hide a decision.
403
+ """
404
+
405
+ def __init__(self, root: Path) -> None:
406
+ self.path = Path(root) / STORE_FILE
407
+ self.locks = FileLocks(Path(root))
408
+
409
+ def exists(self) -> bool:
410
+ return self.path.is_file()
411
+
412
+ def load(self) -> RuleAutomation:
413
+ if not self.path.exists():
414
+ return RuleAutomation()
415
+ try:
416
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
417
+ return RuleAutomation.from_dict(raw)
418
+ except (OSError, json.JSONDecodeError, ValueError, StateError) as error:
419
+ raise StateError(
420
+ f"invalid rule automation store {self.path}: {error}"
421
+ ) from error
422
+
423
+ def modify(
424
+ self, change: Callable[[RuleAutomation], RuleAutomation]
425
+ ) -> RuleAutomation:
426
+ """Apply ``change`` to the current store under its lock and save it.
427
+
428
+ The whole read-modify-write holds the store's lock, so two tasks
429
+ recording results at once never lose each other's entries. The file is
430
+ written only when something changed.
431
+ """
432
+ with self.locks.lock(self.path, purpose="rule automation store"):
433
+ current = self.load()
434
+ updated = change(current)
435
+ if updated != current:
436
+ self.locks.atomic_write(
437
+ self.path, json.dumps(updated.to_dict(), indent=2) + "\n"
438
+ )
439
+ return updated
440
+
441
+
442
+ def _mapping(data: Any, path: str, allowed: set[str]) -> dict[str, Any]:
443
+ if not isinstance(data, dict):
444
+ raise ValueError(f"{path} must be an object")
445
+ unknown = set(data) - allowed
446
+ if unknown:
447
+ raise ValueError(f"{path} has unknown keys: " + ", ".join(sorted(unknown)))
448
+ return data
449
+
450
+
451
+ def _strings(value: Any, path: str) -> tuple[str, ...]:
452
+ if not isinstance(value, list) or not all(
453
+ isinstance(item, str) and item for item in value
454
+ ):
455
+ raise ValueError(f"{path} must be a list of non-empty strings")
456
+ return tuple(value)