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/project_config.py ADDED
@@ -0,0 +1,752 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The project configuration file, ``ww.json``.
3
+
4
+ ww's settings file, separate from ``ww.yaml``. The YAML
5
+ describes what a workflow *does*, while this describes how the tools around
6
+ it behave. It
7
+ contains ww-wide settings, built-in execution hints, and extension settings.
8
+
9
+ ```json
10
+ {
11
+ "enabled": true,
12
+ "runtime": "single",
13
+ "update_check": true,
14
+ "executable": "ww-agentic-workflows-dev",
15
+ "limits": {"rounds": 3, "fixes": 3},
16
+ "agent_hooks": {"check_unfinished": true, "recent_days": 3},
17
+ "pages": {"worker_requirements": "pointer"},
18
+ "workflows": {"catchall": {"enabled": false}},
19
+ "projects": [
20
+ {"name": "backend", "path": "./backend", "description": "Python API service."}
21
+ ],
22
+ "extensions": {
23
+ "ww/git": {"base_branches": {"default": "main"}, "separate_branch": true}
24
+ }
25
+ }
26
+ ```
27
+
28
+ ``enabled`` is ``true`` (agents use ww for project work), ``false`` (they
29
+ never do), or ``"on_request"`` (ww is available, but agents use it only when
30
+ the user explicitly asks for it).
31
+
32
+ ``limits`` holds positive integers ``rounds`` and ``fixes`` and the non-negative
33
+ ``auto_retries``. ``rounds`` is the round limit of a
34
+ step ``loop`` that sets no ``max_rounds`` of its own. ``fixes`` is how many
35
+ times a step's completion may be rejected for a failed check before ww stops
36
+ for the operator, unless a rule sets its own ``max_fixes``. ``auto_retries``
37
+ (default 0) is how many times ww retries a failed automatic step itself,
38
+ recording each failure on the step, before the step's own failure handling (a
39
+ repair assignment, or the operator) applies.
40
+
41
+ ``agent_hooks`` tunes what the ``session-start`` hook reports.
42
+ ``check_unfinished`` (default ``true``) is whether it scans for unfinished
43
+ tasks at all; ``recent_days`` (default 3) is how many days back a task's last
44
+ update or an interruption counts as recent, for that hook, ``discover``,
45
+ ``lookup``, and ``ww interrupted``.
46
+
47
+ ``pages`` tunes what pages print. ``worker_requirements`` is ``full`` (the
48
+ default: the first page of every delegated worker assignment prints the task
49
+ requirements in full) or ``pointer`` (it carries the pointer to ``ww
50
+ requirements`` instead); the manager's pages are unaffected. ``init`` writes the
51
+ key only once it is set.
52
+
53
+ ``workflows`` switches off the workflows ww provides to every project, such
54
+ as ``catchall``; each is on unless its entry says ``"enabled": false``.
55
+
56
+ ``projects`` are the directories, usually repositories, a task may work in.
57
+ They are optional and machine-specific, which is why they live here rather than
58
+ in ``ww.yaml``: the same workflows can run in checkouts laid out
59
+ differently on each machine.
60
+
61
+ An extension's settings are handed to it untouched. ww validates the shape of
62
+ the file — that ``extensions`` is a mapping of mappings — and nothing about what
63
+ is inside a section, because it cannot know a third party's schema. Each
64
+ extension validates its own settings and reports its own errors.
65
+
66
+ ``task_format`` is the generated task ID format: a template over the
67
+ ``{{timestamp}}``, ``{{digit}}``, and ``{{uuid}}`` placeholders, or ``explicit`` to
68
+ require an ID for every task. It is a setting of the checkout and of the
69
+ tracker a repository uses, not of what a workflow does, so it lives here.
70
+
71
+ A configured project may carry its own ``ww.json`` and
72
+ ``ww.local.json``. Of those files ww reads only the keys in
73
+ ``PROJECT_FILE_KEYS``, ``extensions`` applied over the root's and
74
+ ``task_format`` replacing it, for work done in that project; every other key
75
+ describes the project as a ww root of its own, and the workspace root owns
76
+ those.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ import json
82
+ import re
83
+ from copy import deepcopy
84
+ from dataclasses import dataclass, field
85
+ from pathlib import Path
86
+ from typing import Any, Literal
87
+
88
+ from ww.builtin_workflows import builtin_workflow_names
89
+ from ww.config_files import (
90
+ SETTINGS_FILE,
91
+ ConfigurationLevel,
92
+ configuration_file_exists,
93
+ display_path,
94
+ project_settings_levels,
95
+ read_configuration_file,
96
+ settings_levels,
97
+ )
98
+ from ww.errors import ConfigurationError
99
+ from ww.runtimes import DEFAULT_RUNTIME, RUNTIME_INSTRUCTIONS
100
+ from ww.validation import expect_normalized_name, is_positive_int, is_strict_int
101
+
102
+ FILE_NAME = SETTINGS_FILE
103
+ BUILTIN_NAMES = frozenset({"init", "workflow_summary"})
104
+ # ``init`` only restates requirements; the workflow summary is what people
105
+ # read, so it follows the run's ordinary worker selection.
106
+ BUILTIN_DEFAULTS: dict[str, dict[str, str]] = {
107
+ "init": {"model": "cheapest", "reasoning": "low"},
108
+ "workflow_summary": {"model": "auto", "reasoning": "auto"},
109
+ }
110
+ DEFAULT_ROUNDS = 3
111
+ DEFAULT_FIXES = 3
112
+ DEFAULT_RECENT_DAYS = 3
113
+ # A ``task_format`` that forbids generated IDs: every task is started with an
114
+ # explicit ID, or binds one in its workflow's first step.
115
+ EXPLICIT_TASK_FORMAT = "explicit"
116
+ TASK_FORMAT_PLACEHOLDERS = frozenset({"{{digit}}", "{{timestamp}}", "{{uuid}}"})
117
+ # A placeholder in double braces, e.g. "{{digit}}" in "TASK-{{digit}}".
118
+ _TASK_FORMAT_TOKEN = re.compile(r"\{\{[^{}]*\}\}")
119
+ # The keys ww takes from a configured project's own settings files. Anything
120
+ # else in such a file describes the project as a ww root of its own.
121
+ PROJECT_FILE_KEYS = ("extensions", "task_format")
122
+ # ``enabled``: ww is used by default (``true``), never (``false``), or only
123
+ # when the user explicitly asks for it (``"on_request"``).
124
+ ON_REQUEST: Literal["on_request"] = "on_request"
125
+ Enabled = bool | Literal["on_request"]
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class ProjectDefinition:
130
+ """One directory, usually a repository, a task may work in.
131
+
132
+ Without projects every task works in the project root, which is also
133
+ where ww keeps its configuration and state.
134
+ """
135
+
136
+ name: str
137
+ path: str
138
+ description: str = ""
139
+
140
+ def directory(self, root: Path) -> Path:
141
+ """The project's directory, resolved against ``root`` when relative."""
142
+ return (root / self.path).resolve()
143
+
144
+ def to_dict(self) -> dict[str, str]:
145
+ return {"name": self.name, "path": self.path, "description": self.description}
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class ExtensionSections:
150
+ """The ``extensions`` section of one settings source, keyed by extension.
151
+
152
+ ``source`` names the file or files in messages, so a section that applies
153
+ to nothing is reported where it was written: at the root or in a project.
154
+ """
155
+
156
+ sections: dict[str, dict[str, Any]] = field(default_factory=dict)
157
+ source: str = SETTINGS_FILE
158
+
159
+ def settings_for(self, identifier: str) -> dict[str, Any]:
160
+ """Return one extension's settings, addressed by id or by bare name.
161
+
162
+ ``"ww/git"`` is the canonical key, because two vendors may each ship an
163
+ extension called ``git``. A bare ``"git"`` also resolves, as long as it
164
+ is unambiguous for the extension being asked about.
165
+ """
166
+ if identifier in self.sections:
167
+ return deepcopy(self.sections[identifier])
168
+ _, _, name = identifier.partition("/")
169
+ bare = [key for key in self.sections if "/" not in key and key == name]
170
+ if bare:
171
+ return deepcopy(self.sections[bare[0]])
172
+ return {}
173
+
174
+ def lists(self, identifier: str) -> bool:
175
+ """Whether a section names the extension, even an empty one."""
176
+ _, _, name = identifier.partition("/")
177
+ return identifier in self.sections or name in self.sections
178
+
179
+ def validate_against(self, identifiers: tuple[str, ...]) -> None:
180
+ """Reject sections that name no installed extension, or name two.
181
+
182
+ A settings block that silently applies to nothing is worse than an
183
+ error: the file looks configured and the behaviour never changes.
184
+ """
185
+ for key in sorted(self.sections):
186
+ if "/" in key:
187
+ if key not in identifiers:
188
+ raise ConfigurationError(
189
+ f"{self.source} configures unknown extension {key!r}; "
190
+ + _available(identifiers)
191
+ )
192
+ continue
193
+ matches = [
194
+ identifier
195
+ for identifier in identifiers
196
+ if identifier.partition("/")[2] == key
197
+ ]
198
+ if not matches:
199
+ raise ConfigurationError(
200
+ f"{self.source} configures unknown extension {key!r}; "
201
+ + _available(identifiers)
202
+ )
203
+ if len(matches) > 1:
204
+ raise ConfigurationError(
205
+ f"{self.source} key {key!r} is ambiguous; use the full "
206
+ "identifier: " + ", ".join(sorted(matches))
207
+ )
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class ProjectSettings:
212
+ """What a configured project's own settings files contribute.
213
+
214
+ Exactly the keys in ``PROJECT_FILE_KEYS``: the project's extension
215
+ sections, and its task ID format when it sets one (``None`` means the
216
+ root's applies).
217
+ """
218
+
219
+ project: str
220
+ sections: ExtensionSections
221
+ task_format: str | None = None
222
+ # The files read, repo level then local; empty when the project has none.
223
+ sources: tuple[Path, ...] = ()
224
+ # Each file's own section, in the same order, so a section that applies
225
+ # to nothing is reported in the file that holds it.
226
+ levels: tuple[ExtensionSections, ...] = ()
227
+
228
+ def validate_against(self, identifiers: tuple[str, ...]) -> None:
229
+ """Reject sections that name no installed extension, file by file."""
230
+ for level in self.levels:
231
+ level.validate_against(identifiers)
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class Limits:
236
+ """The ``limits`` setting: how far ww goes before the operator decides."""
237
+
238
+ # A step loop's rounds when it sets no ``max_rounds`` of its own.
239
+ rounds: int = DEFAULT_ROUNDS
240
+ # Rejected completions a check allows when its rule sets no ``max_fixes``.
241
+ fixes: int = DEFAULT_FIXES
242
+ # How often ww itself retries a failed automatic step before the step's
243
+ # failure handling (a repair assignment, or the operator) applies.
244
+ auto_retries: int = 0
245
+
246
+ def to_dict(self) -> dict[str, int]:
247
+ # The retries appear once set, so a default file stays as ``init`` wrote it.
248
+ return {
249
+ "rounds": self.rounds,
250
+ "fixes": self.fixes,
251
+ **({"auto_retries": self.auto_retries} if self.auto_retries else {}),
252
+ }
253
+
254
+
255
+ @dataclass(frozen=True)
256
+ class AgentHooks:
257
+ """The ``agent_hooks`` setting: what the session-start hook reports."""
258
+
259
+ # Whether session-start scans for unfinished tasks at all.
260
+ check_unfinished: bool = True
261
+ # How many days back a task's last update, or an interruption, is recent.
262
+ recent_days: int = DEFAULT_RECENT_DAYS
263
+
264
+ def to_dict(self) -> dict[str, object]:
265
+ return {
266
+ "check_unfinished": self.check_unfinished,
267
+ "recent_days": self.recent_days,
268
+ }
269
+
270
+
271
+ WORKER_REQUIREMENTS = ("full", "pointer")
272
+
273
+
274
+ @dataclass(frozen=True)
275
+ class Pages:
276
+ """The ``pages`` setting: what the pages ww prints carry."""
277
+
278
+ # ``full`` prints the task requirements on a delegated worker assignment's
279
+ # first page; ``pointer`` names the command that prints them instead.
280
+ worker_requirements: Literal["full", "pointer"] = "full"
281
+
282
+ def to_dict(self) -> dict[str, str]:
283
+ # Written once set, so a default file stays as ``init`` wrote it.
284
+ return (
285
+ {"worker_requirements": self.worker_requirements}
286
+ if self.worker_requirements != "full"
287
+ else {}
288
+ )
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class ProjectConfig:
293
+ """Settings that apply to a project rather than to one workflow."""
294
+
295
+ extensions: dict[str, dict[str, Any]] = field(default_factory=dict)
296
+ builtins: dict[str, dict[str, str]] = field(default_factory=dict)
297
+ limits: Limits = Limits()
298
+ agent_hooks: AgentHooks = AgentHooks()
299
+ pages: Pages = Pages()
300
+ # ``false`` tells agents not to use ww in this project; ``start`` refuses.
301
+ # ``"on_request"`` keeps ww available, but agents use it only when the
302
+ # user explicitly asks for it.
303
+ enabled: Enabled = True
304
+ # ``rules.check_guidance``: the operator's own words for the commands
305
+ # ``ww-scriptize-rules`` builds, which it reads from ``ww rules --json``.
306
+ rule_check_guidance: str | None = None
307
+ projects: tuple[ProjectDefinition, ...] = ()
308
+ # The runtime ``start`` uses when ``--runtime`` is omitted.
309
+ runtime: str = DEFAULT_RUNTIME
310
+ # ``false`` silences the notice that the ww checkout is behind its remote.
311
+ update_check: bool = True
312
+ # Analyse operator feedback into candidates, never automatic rules.
313
+ feedback_learning: bool = True
314
+ # Built-in workflows switched off for this project.
315
+ disabled_workflows: frozenset[str] = frozenset()
316
+ # The ww binary this project runs: a command on PATH or a path. ``None``
317
+ # means the project launcher, ``./ww``, which falls back to the standard
318
+ # name.
319
+ executable: str | None = None
320
+ # The generated task ID format; ``None`` keeps ww's ``TASK-{{timestamp}}``.
321
+ task_format: str | None = None
322
+
323
+ @property
324
+ def projects_by_name(self) -> dict[str, ProjectDefinition]:
325
+ return {project.name: project for project in self.projects}
326
+
327
+ @property
328
+ def disabled(self) -> bool:
329
+ """Whether agents must not use ww here at all."""
330
+ return self.enabled is False
331
+
332
+ @property
333
+ def on_request(self) -> bool:
334
+ """Whether agents use ww only when the user explicitly asks for it."""
335
+ return self.enabled == ON_REQUEST
336
+
337
+ def workflow_enabled(self, name: str) -> bool:
338
+ """Whether a built-in workflow is offered in this project."""
339
+ return name not in self.disabled_workflows
340
+
341
+ def builtin_settings(self, name: str) -> dict[str, str]:
342
+ """Return a built-in's complete model/reasoning request."""
343
+ return {**BUILTIN_DEFAULTS[name], **self.builtins.get(name, {})}
344
+
345
+ @property
346
+ def sections(self) -> ExtensionSections:
347
+ return ExtensionSections(self.extensions, FILE_NAME)
348
+
349
+ def settings_for(self, identifier: str) -> dict[str, Any]:
350
+ """Return one extension's settings, addressed by id or by bare name."""
351
+ return self.sections.settings_for(identifier)
352
+
353
+ def validate_against(self, identifiers: tuple[str, ...]) -> None:
354
+ """Reject sections that name no installed extension, or name two."""
355
+ self.sections.validate_against(identifiers)
356
+
357
+ def unknown_project(self, project: str) -> str:
358
+ """The message for a ``--project`` value no entry of ``projects`` has."""
359
+ configured = ", ".join(entry.name for entry in self.projects)
360
+ return f"unknown project {project!r}; " + (
361
+ f"configured projects: {configured}"
362
+ if configured
363
+ else f"no projects are configured in {FILE_NAME}"
364
+ )
365
+
366
+ def project_settings(self, root: Path, project: str) -> ProjectSettings:
367
+ """Load what the configured ``project``'s own settings files contribute."""
368
+ definition = self.projects_by_name.get(project)
369
+ if definition is None:
370
+ raise ConfigurationError(self.unknown_project(project))
371
+ return load_project_settings(root, definition)
372
+
373
+
374
+ def load_project_config(path: Path) -> ProjectConfig:
375
+ """Load the settings levels around the repo file ``path``, deep-merged.
376
+
377
+ The user, repo, and local files apply in that order, each optional;
378
+ without any of them the defaults apply.
379
+ """
380
+ raw, sources = compose_settings(path)
381
+ if not sources:
382
+ return ProjectConfig()
383
+ return _parse_settings(raw, " + ".join(str(source) for source in sources))
384
+
385
+
386
+ def compose_settings(path: Path) -> tuple[dict[str, Any], tuple[Path, ...]]:
387
+ """Deep-merge the settings levels around ``path`` and name the files read.
388
+
389
+ Nested objects merge key by key; any other value, lists included, replaces
390
+ the one above it.
391
+ """
392
+ return _compose_levels(settings_levels(path))
393
+
394
+
395
+ def load_project_settings(root: Path, project: ProjectDefinition) -> ProjectSettings:
396
+ """Read the keys in ``PROJECT_FILE_KEYS`` from a project's own settings files.
397
+
398
+ The project's repo and local files apply in that order, each optional:
399
+ extension sections merge key by key and a later ``task_format`` replaces
400
+ an earlier one. Every other key describes the project as a ww root of its
401
+ own and is left alone. Errors name the project so a mistake is found in
402
+ the right directory.
403
+ """
404
+ sources: list[Path] = []
405
+ levels: list[ExtensionSections] = []
406
+ merged: dict[str, dict[str, Any]] = {}
407
+ task_format: str | None = None
408
+ for level in project_settings_levels(project.directory(root)):
409
+ raw = _read_level(level)
410
+ if raw is None:
411
+ continue
412
+ label = f"{display_path(level.path, root)} (project {project.name!r})"
413
+ sections = ExtensionSections(_parse_extensions(raw, label), label)
414
+ _deep_merge(merged, sections.sections)
415
+ if "task_format" in raw:
416
+ task_format = _parse_task_format(raw["task_format"], label)
417
+ sources.append(level.path)
418
+ levels.append(sections)
419
+ source = " + ".join(display_path(path, root) for path in sources)
420
+ return ProjectSettings(
421
+ project.name,
422
+ ExtensionSections(merged, f"{source} (project {project.name!r})"),
423
+ task_format,
424
+ tuple(sources),
425
+ tuple(levels),
426
+ )
427
+
428
+
429
+ def overlay_settings(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
430
+ """Apply one extension's project settings over its root settings.
431
+
432
+ The same rule as between configuration levels: nested objects merge key
433
+ by key, any other value replaces the one above it, so a project states
434
+ only what differs.
435
+ """
436
+ merged = deepcopy(base)
437
+ _deep_merge(merged, overlay)
438
+ return merged
439
+
440
+
441
+ def _compose_levels(
442
+ levels: tuple[ConfigurationLevel, ...],
443
+ ) -> tuple[dict[str, Any], tuple[Path, ...]]:
444
+ merged: dict[str, Any] = {}
445
+ sources: list[Path] = []
446
+ for level in levels:
447
+ raw = _read_level(level)
448
+ if raw is None:
449
+ continue
450
+ _deep_merge(merged, raw)
451
+ sources.append(level.path)
452
+ return merged, tuple(sources)
453
+
454
+
455
+ def _read_level(level: ConfigurationLevel) -> dict[str, Any] | None:
456
+ """The JSON object one settings file holds, or ``None`` when it is absent."""
457
+ if not configuration_file_exists(level.path):
458
+ return None
459
+ try:
460
+ raw = json.loads(read_configuration_file(level.path))
461
+ except (OSError, json.JSONDecodeError) as error:
462
+ raise ConfigurationError(f"invalid {level.path}: {error}") from error
463
+ if not isinstance(raw, dict):
464
+ raise ConfigurationError(f"{level.path} must contain a JSON object")
465
+ return raw
466
+
467
+
468
+ def _deep_merge(target: dict[str, Any], overlay: dict[str, Any]) -> None:
469
+ for key, value in overlay.items():
470
+ current = target.get(key)
471
+ if isinstance(current, dict) and isinstance(value, dict):
472
+ _deep_merge(current, value)
473
+ else:
474
+ target[key] = deepcopy(value)
475
+
476
+
477
+ def _parse_extensions(raw: dict[str, Any], path: str) -> dict[str, dict[str, Any]]:
478
+ extensions = raw.get("extensions", {})
479
+ if not isinstance(extensions, dict):
480
+ raise ConfigurationError(f"{path}.extensions must be an object")
481
+ for key, value in extensions.items():
482
+ if not isinstance(value, dict):
483
+ raise ConfigurationError(
484
+ f"{path}.extensions[{key!r}] must be an object of settings"
485
+ )
486
+ return dict(extensions)
487
+
488
+
489
+ def _parse_settings(raw: dict[str, Any], path: str) -> ProjectConfig:
490
+ unknown = set(raw) - {
491
+ "enabled",
492
+ "runtime",
493
+ "extensions",
494
+ "builtins",
495
+ "limits",
496
+ "agent_hooks",
497
+ "pages",
498
+ "projects",
499
+ "update_check",
500
+ "feedback_learning",
501
+ "workflows",
502
+ "executable",
503
+ "task_format",
504
+ "rules",
505
+ }
506
+ if unknown:
507
+ raise ConfigurationError(
508
+ f"{path} has unknown key(s): {', '.join(sorted(unknown))}"
509
+ )
510
+ extensions = _parse_extensions(raw, path)
511
+ enabled = _parse_enabled(raw.get("enabled", True), path)
512
+ update_check = raw.get("update_check", True)
513
+ if not isinstance(update_check, bool):
514
+ raise ConfigurationError(f"{path}.update_check must be true or false")
515
+ feedback_learning = raw.get("feedback_learning", True)
516
+ if not isinstance(feedback_learning, bool):
517
+ raise ConfigurationError(f"{path}.feedback_learning must be true or false")
518
+ runtime = raw.get("runtime", DEFAULT_RUNTIME)
519
+ if runtime not in RUNTIME_INSTRUCTIONS:
520
+ raise ConfigurationError(
521
+ f"{path}.runtime must be one of: " + ", ".join(RUNTIME_INSTRUCTIONS)
522
+ )
523
+ disabled_workflows = _parse_workflows(raw.get("workflows"), path)
524
+ builtins = raw.get("builtins", {})
525
+ if not isinstance(builtins, dict):
526
+ raise ConfigurationError(f"{path}.builtins must be an object")
527
+ unknown_builtins = set(builtins) - BUILTIN_NAMES
528
+ if unknown_builtins:
529
+ raise ConfigurationError(
530
+ f"{path}.builtins has unknown name(s): "
531
+ + ", ".join(sorted(unknown_builtins))
532
+ )
533
+ normalized: dict[str, dict[str, str]] = {}
534
+ for name, value in builtins.items():
535
+ if not isinstance(value, dict):
536
+ raise ConfigurationError(f"{path}.builtins.{name} must be an object")
537
+ unknown_fields = set(value) - {"model", "reasoning"}
538
+ if unknown_fields:
539
+ raise ConfigurationError(
540
+ f"{path}.builtins.{name} has unknown key(s): "
541
+ + ", ".join(sorted(unknown_fields))
542
+ )
543
+ for hint_name, hint in value.items():
544
+ if not isinstance(hint, str) or not hint.strip():
545
+ raise ConfigurationError(
546
+ f"{path}.builtins.{name}.{hint_name} must be a non-empty string"
547
+ )
548
+ normalized[name] = dict(value)
549
+ return ProjectConfig(
550
+ extensions=extensions,
551
+ builtins=normalized,
552
+ limits=_parse_limits(raw.get("limits"), path),
553
+ agent_hooks=_parse_agent_hooks(raw.get("agent_hooks"), path),
554
+ pages=_parse_pages(raw.get("pages"), path),
555
+ enabled=enabled,
556
+ projects=_parse_projects(raw.get("projects"), path),
557
+ runtime=runtime,
558
+ update_check=update_check,
559
+ feedback_learning=feedback_learning,
560
+ disabled_workflows=disabled_workflows,
561
+ executable=_parse_executable(raw.get("executable"), path),
562
+ task_format=_parse_task_format(raw.get("task_format"), path),
563
+ **_parse_rules(raw.get("rules"), path),
564
+ )
565
+
566
+
567
+ def _parse_limits(data: Any, path: str) -> Limits:
568
+ """``limits``: optional positive ``rounds`` and ``fixes``, and ``auto_retries``."""
569
+ if data is None:
570
+ return Limits()
571
+ if not isinstance(data, dict):
572
+ raise ConfigurationError(f"{path}.limits must be an object")
573
+ unknown = set(data) - {"rounds", "fixes", "auto_retries"}
574
+ if unknown:
575
+ raise ConfigurationError(
576
+ f"{path}.limits has unknown key(s): {', '.join(sorted(unknown))}"
577
+ )
578
+ for key, value in data.items():
579
+ if key == "auto_retries":
580
+ if not is_strict_int(value) or value < 0:
581
+ raise ConfigurationError(
582
+ f"{path}.limits.auto_retries must be a non-negative integer"
583
+ )
584
+ elif not is_positive_int(value):
585
+ raise ConfigurationError(f"{path}.limits.{key} must be a positive integer")
586
+ return Limits(**data)
587
+
588
+
589
+ def _parse_pages(data: Any, path: str) -> Pages:
590
+ """``pages``: an optional ``worker_requirements``, ``full`` or ``pointer``."""
591
+ if data is None:
592
+ return Pages()
593
+ if not isinstance(data, dict):
594
+ raise ConfigurationError(f"{path}.pages must be an object")
595
+ unknown = set(data) - {"worker_requirements"}
596
+ if unknown:
597
+ raise ConfigurationError(
598
+ f"{path}.pages has unknown key(s): {', '.join(sorted(unknown))}"
599
+ )
600
+ value = data.get("worker_requirements", "full")
601
+ if value not in WORKER_REQUIREMENTS:
602
+ raise ConfigurationError(
603
+ f"{path}.pages.worker_requirements must be one of: "
604
+ + ", ".join(WORKER_REQUIREMENTS)
605
+ )
606
+ return Pages(**data)
607
+
608
+
609
+ def _parse_agent_hooks(data: Any, path: str) -> AgentHooks:
610
+ """``agent_hooks``: an optional ``check_unfinished`` and ``recent_days``."""
611
+ if data is None:
612
+ return AgentHooks()
613
+ if not isinstance(data, dict):
614
+ raise ConfigurationError(f"{path}.agent_hooks must be an object")
615
+ unknown = set(data) - {"check_unfinished", "recent_days"}
616
+ if unknown:
617
+ raise ConfigurationError(
618
+ f"{path}.agent_hooks has unknown key(s): {', '.join(sorted(unknown))}"
619
+ )
620
+ if not isinstance(data.get("check_unfinished", True), bool):
621
+ raise ConfigurationError(
622
+ f"{path}.agent_hooks.check_unfinished must be true or false"
623
+ )
624
+ if not is_positive_int(data.get("recent_days", DEFAULT_RECENT_DAYS)):
625
+ raise ConfigurationError(
626
+ f"{path}.agent_hooks.recent_days must be a positive integer"
627
+ )
628
+ return AgentHooks(**data)
629
+
630
+
631
+ def _parse_rules(data: Any, path: str) -> dict[str, Any]:
632
+ """``rules``: an optional ``check_guidance`` text."""
633
+ if data is None:
634
+ return {}
635
+ if not isinstance(data, dict):
636
+ raise ConfigurationError(f"{path}.rules must be an object")
637
+ unknown = set(data) - {"check_guidance"}
638
+ if unknown:
639
+ raise ConfigurationError(
640
+ f"{path}.rules has unknown key(s): {', '.join(sorted(unknown))}"
641
+ )
642
+ guidance = data.get("check_guidance")
643
+ if guidance is not None and not isinstance(guidance, str):
644
+ raise ConfigurationError(f"{path}.rules.check_guidance must be a string")
645
+ return {
646
+ # Blank text means unset.
647
+ "rule_check_guidance": (guidance or "").strip() or None,
648
+ }
649
+
650
+
651
+ def _parse_enabled(data: Any, path: str) -> Enabled:
652
+ if isinstance(data, bool):
653
+ return data
654
+ if data == ON_REQUEST:
655
+ return ON_REQUEST
656
+ raise ConfigurationError(f'{path}.enabled must be true, false, or "{ON_REQUEST}"')
657
+
658
+
659
+ def _parse_task_format(data: Any, path: str) -> str | None:
660
+ if data is None:
661
+ return None
662
+ if not isinstance(data, str) or not data:
663
+ raise ConfigurationError(f"{path}.task_format must be a non-empty string")
664
+ if data == EXPLICIT_TASK_FORMAT:
665
+ return data
666
+ tokens = _TASK_FORMAT_TOKEN.findall(data)
667
+ rest = _TASK_FORMAT_TOKEN.sub("", data)
668
+ if "{" in rest or "}" in rest:
669
+ raise ConfigurationError(f"{path}.task_format has invalid placeholders")
670
+ unknown = set(tokens) - TASK_FORMAT_PLACEHOLDERS
671
+ if unknown:
672
+ raise ConfigurationError(
673
+ f"{path}.task_format has unknown placeholder(s): "
674
+ + ", ".join(sorted(unknown))
675
+ )
676
+ return data
677
+
678
+
679
+ def _parse_executable(data: Any, path: str) -> str | None:
680
+ if data is None:
681
+ return None
682
+ if not isinstance(data, str) or not data.strip():
683
+ raise ConfigurationError(
684
+ f"{path}.executable must be a command name or a path to the ww binary"
685
+ )
686
+ return data.strip()
687
+
688
+
689
+ def _parse_workflows(data: Any, path: str) -> frozenset[str]:
690
+ """The built-in workflows switched off by ``enabled: false``."""
691
+ if data is None:
692
+ return frozenset()
693
+ if not isinstance(data, dict):
694
+ raise ConfigurationError(f"{path}.workflows must be an object")
695
+ known = builtin_workflow_names()
696
+ unknown = set(data) - known
697
+ if unknown:
698
+ raise ConfigurationError(
699
+ f"{path}.workflows has unknown name(s): {', '.join(sorted(unknown))}; "
700
+ "built-in workflows: " + ", ".join(sorted(known))
701
+ )
702
+ disabled: set[str] = set()
703
+ for name, value in data.items():
704
+ context = f"{path}.workflows.{name}"
705
+ if not isinstance(value, dict):
706
+ raise ConfigurationError(f"{context} must be an object")
707
+ unknown_keys = set(value) - {"enabled"}
708
+ if unknown_keys:
709
+ raise ConfigurationError(
710
+ f"{context} has unknown key(s): {', '.join(sorted(unknown_keys))}"
711
+ )
712
+ enabled = value.get("enabled", True)
713
+ if not isinstance(enabled, bool):
714
+ raise ConfigurationError(f"{context}.enabled must be true or false")
715
+ if not enabled:
716
+ disabled.add(name)
717
+ return frozenset(disabled)
718
+
719
+
720
+ def _parse_projects(data: Any, path: str) -> tuple[ProjectDefinition, ...]:
721
+ if data is None:
722
+ return ()
723
+ if not isinstance(data, list):
724
+ raise ConfigurationError(f"{path}.projects must be a list")
725
+ result: list[ProjectDefinition] = []
726
+ for index, item in enumerate(data):
727
+ context = f"{path}.projects[{index}]"
728
+ if not isinstance(item, dict):
729
+ raise ConfigurationError(f"{context} must be an object")
730
+ unknown_keys = set(item) - {"name", "path", "description"}
731
+ if unknown_keys:
732
+ raise ConfigurationError(
733
+ f"{context} has unknown key(s): {', '.join(sorted(unknown_keys))}"
734
+ )
735
+ name = expect_normalized_name(
736
+ item.get("name"), f"{context}.name", error=ConfigurationError
737
+ )
738
+ if any(project.name == name for project in result):
739
+ raise ConfigurationError(f"{path}.projects has duplicate name {name!r}")
740
+ location = item.get("path")
741
+ if not isinstance(location, str) or not location.strip():
742
+ raise ConfigurationError(f"{context}.path must be a non-empty string")
743
+ description = item.get("description", "")
744
+ if not isinstance(description, str):
745
+ raise ConfigurationError(f"{context}.description must be a string")
746
+ result.append(ProjectDefinition(name, location.strip(), description))
747
+ return tuple(result)
748
+
749
+
750
+ def _available(identifiers: tuple[str, ...]) -> str:
751
+ names = ", ".join(sorted(identifiers))
752
+ return f"installed: {names}" if names else "no extensions are installed"