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
@@ -0,0 +1,571 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Compose the workflow configuration levels and their imports into one document.
3
+
4
+ Workflows come from up to three levels, applied top to bottom: the user's
5
+ ``ww.yaml`` in the user configuration directory, the repo's
6
+ ``ww.yaml`` (required), and the checkout's
7
+ ``ww.local.yaml``. A level is its root file plus the files
8
+ that root lists under ``imports``, which come before any other key but
9
+ ``extends``. An import may define anything a root can, except further imports,
10
+ and resolves next to the file that lists it.
11
+
12
+ Files fold in order, each level's imports before its root, so a later file
13
+ overrides an earlier one and a lower level overrides the ones above it:
14
+
15
+ - named catalogs (``modes``, ``documents``, ``handlers``, ``workflows``) and
16
+ ``profiles`` replace an entry of the same name where it first appeared, so
17
+ handlers that reuse an overridden one still find it earlier in the list;
18
+ - ``hooks`` add each phase's entries after those already folded, since hook
19
+ entries carry no name to override;
20
+ - ``rules`` groups replace a group of the same name as a whole;
21
+ - any other key takes the later value.
22
+
23
+ Rule paths, in the root ``rules`` mapping and in a step's ``rules`` list,
24
+ resolve next to the file that declares them. A file outside the repo file's
25
+ directory has its relative rule paths rewritten against that directory, so
26
+ the parser resolves every one against a single base. An absolute rule path is
27
+ accepted and reported as a notice, because it ties the configuration to one
28
+ machine.
29
+
30
+ A level extends the ones above unless one of its files says ``extends: false``;
31
+ then folding starts again at that level.
32
+
33
+ The composed document is ordinary ``ww.yaml`` notation:
34
+ the parser reads it exactly as it would a single file, and nothing is
35
+ written to disk.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import os
41
+ from collections.abc import Mapping
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+ from types import MappingProxyType
45
+ from typing import Any, cast
46
+
47
+ import yaml
48
+
49
+ from ww.config_files import (
50
+ ConfigurationLevel,
51
+ configuration_file_exists,
52
+ display_path,
53
+ read_configuration_file,
54
+ workflow_levels,
55
+ )
56
+ from ww.errors import ConfigurationError
57
+ from ww.workflow_config import WorkflowConfigLevel, WorkflowProvenance
58
+
59
+ IMPORTS_KEY = "imports"
60
+ EXTENDS_KEY = "extends"
61
+
62
+ _NAMED_CATALOGS = {
63
+ "modes": "mode",
64
+ "documents": "document",
65
+ "handlers": "handler",
66
+ "workflows": "workflow",
67
+ }
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Override:
72
+ """One definition that a higher-priority file replaced."""
73
+
74
+ kind: str
75
+ name: str | None
76
+ overridden_in: str
77
+ overridden_by: str
78
+
79
+ @property
80
+ def notice(self) -> str:
81
+ subject = self.kind if self.name is None else f"{self.kind} {self.name!r}"
82
+ return (
83
+ f"{subject} from {self.overridden_in} is overridden by "
84
+ f"{self.overridden_by}."
85
+ )
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ComposedConfiguration:
90
+ """Composed ``ww.yaml`` text, its mapping, and overrides.
91
+
92
+ ``sources`` lists the files folded in, in order; ``ignored`` the files a
93
+ lower level's ``extends: false`` left out.
94
+ """
95
+
96
+ text: str
97
+ raw: dict[str, Any]
98
+ overrides: tuple[Override, ...] = ()
99
+ sources: tuple[str, ...] = ()
100
+ ignored: tuple[str, ...] = ()
101
+ # One notice per absolute rule path, and per manager step that also asks
102
+ # for worker settings, in the files folded in.
103
+ rule_notices: tuple[str, ...] = ()
104
+ workflow_provenance: Mapping[str, WorkflowProvenance] = field(
105
+ default_factory=lambda: MappingProxyType({})
106
+ )
107
+
108
+ @property
109
+ def notices(self) -> tuple[str, ...]:
110
+ return (
111
+ *(
112
+ f"{label} is not applied: a lower level sets extends: false."
113
+ for label in self.ignored
114
+ ),
115
+ *(override.notice for override in self.overrides),
116
+ *self.rule_notices,
117
+ )
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class _Level:
122
+ """One level's files, imports first, and whether it extends the ones above."""
123
+
124
+ files: tuple[tuple[str, dict[str, Any], Path], ...]
125
+ extends: bool
126
+ name: str
127
+
128
+
129
+ def compose_configuration(path: Path) -> ComposedConfiguration:
130
+ """Read the repo file ``path`` with its levels and imports as one document.
131
+
132
+ A repo file standing alone, without ``imports`` or ``extends``, passes
133
+ through untouched, so the parser reports its errors exactly as before.
134
+ """
135
+ base = path.parent
136
+ label = display_path(path, base)
137
+ text = read_configuration_file(path)
138
+ present = tuple(
139
+ level
140
+ for level in workflow_levels(path)
141
+ if level.name == "repo" or configuration_file_exists(level.path)
142
+ )
143
+ if len(present) == 1:
144
+ try:
145
+ root = yaml.safe_load(text)
146
+ except yaml.YAMLError:
147
+ return ComposedConfiguration(text, {}, sources=(label,))
148
+ if not isinstance(root, dict) or not {IMPORTS_KEY, EXTENDS_KEY} & set(root):
149
+ raw = root if isinstance(root, dict) else {}
150
+ return ComposedConfiguration(
151
+ text,
152
+ raw,
153
+ sources=(label,),
154
+ workflow_provenance=_workflow_provenance(raw, label, "project"),
155
+ rule_notices=(
156
+ *_absolute_rule_notices(raw, label),
157
+ *_manager_setting_notices(raw, label),
158
+ ),
159
+ )
160
+ base, levels, start, applied = _fold_order(path, present)
161
+ group_names = {
162
+ name
163
+ for _, raw, _, _ in applied
164
+ if isinstance(raw.get("rules"), dict)
165
+ for name in raw["rules"]
166
+ }
167
+ composer = _Composer()
168
+ notices: list[str] = []
169
+ for file_label, raw, file, level_name in applied:
170
+ notices.extend(_absolute_rule_notices(raw, file_label))
171
+ notices.extend(_manager_setting_notices(raw, file_label))
172
+ composer.apply(
173
+ _rebase_rule_paths(raw, file.parent, base, group_names),
174
+ file_label,
175
+ _public_level(level_name),
176
+ )
177
+ return ComposedConfiguration(
178
+ yaml.safe_dump(composer.raw, sort_keys=False, allow_unicode=True),
179
+ composer.raw,
180
+ tuple(composer.overrides),
181
+ tuple(file_label for file_label, _, _, _ in applied),
182
+ tuple(
183
+ file_label for level in levels[:start] for file_label, _, _ in level.files
184
+ ),
185
+ tuple(notices),
186
+ MappingProxyType(dict(composer.workflow_provenance)),
187
+ )
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class WorkflowSite:
192
+ """One configuration file that defines a workflow, and where it ranks."""
193
+
194
+ file: Path
195
+ label: str
196
+ level: WorkflowConfigLevel
197
+ entry: Any
198
+
199
+
200
+ def workflow_sites(path: Path, name: str) -> tuple[WorkflowSite, ...]:
201
+ """Every applied file defining workflow ``name``, lowest precedence first.
202
+
203
+ The last site is the definition that wins; the ones before it are hidden by
204
+ it. Files a lower level's ``extends: false`` leaves out are not applied and
205
+ not listed.
206
+ """
207
+ present = tuple(
208
+ level
209
+ for level in workflow_levels(path)
210
+ if level.name == "repo" or configuration_file_exists(level.path)
211
+ )
212
+ _, _, _, applied = _fold_order(path, present)
213
+ sites: list[WorkflowSite] = []
214
+ for label, raw, file, level_name in applied:
215
+ workflows = raw.get("workflows")
216
+ if not isinstance(workflows, list):
217
+ continue
218
+ sites.extend(
219
+ WorkflowSite(file, label, _public_level(level_name), entry)
220
+ for entry in workflows
221
+ if entry_name(entry) == name
222
+ )
223
+ return tuple(sites)
224
+
225
+
226
+ def _fold_order(
227
+ path: Path, present: tuple[ConfigurationLevel, ...]
228
+ ) -> tuple[
229
+ Path,
230
+ list[_Level],
231
+ int,
232
+ list[tuple[str, dict[str, Any], Path, str]],
233
+ ]:
234
+ """The files folded for ``path`` in order, after any ``extends: false``."""
235
+ base = path.parent
236
+ seen = {level.path.resolve() for level in present}
237
+ levels = [_read_level(level, base, seen) for level in present]
238
+ start = max(
239
+ (index for index, level in enumerate(levels) if not level.extends),
240
+ default=0,
241
+ )
242
+ applied = [(*file, level.name) for level in levels[start:] for file in level.files]
243
+ return base, levels, start, applied
244
+
245
+
246
+ def _read_level(level: ConfigurationLevel, base: Path, seen: set[Path]) -> _Level:
247
+ root_label = display_path(level.path, base)
248
+ root = _read_file(level.path, root_label)
249
+ keys = [key for key in root if key != EXTENDS_KEY]
250
+ if IMPORTS_KEY in keys and keys[0] != IMPORTS_KEY:
251
+ raise ConfigurationError(
252
+ f"{IMPORTS_KEY} must come before every key but {EXTENDS_KEY} "
253
+ f"in {root_label}"
254
+ )
255
+ imports = root.pop(IMPORTS_KEY, [])
256
+ files = [
257
+ (file_label, _read_import(file, file_label), file)
258
+ for file_label, file in _import_files(imports, level.path, base, seen)
259
+ ]
260
+ files.append((root_label, root, level.path))
261
+ extends = [_extends(raw, file_label) for file_label, raw, _ in files]
262
+ return _Level(tuple(files), False not in extends, level.name)
263
+
264
+
265
+ def _public_level(level: str) -> WorkflowConfigLevel:
266
+ """Translate config_files' internal level names to the public vocabulary."""
267
+ return cast(
268
+ WorkflowConfigLevel,
269
+ {"user": "global", "repo": "project", "local": "local"}[level],
270
+ )
271
+
272
+
273
+ def _workflow_provenance(
274
+ raw: dict[str, Any], source: str, level: WorkflowConfigLevel
275
+ ) -> Mapping[str, WorkflowProvenance]:
276
+ workflows = raw.get("workflows", [])
277
+ if not isinstance(workflows, list):
278
+ return MappingProxyType({})
279
+ return MappingProxyType(
280
+ {
281
+ name: WorkflowProvenance(source, level)
282
+ for entry in workflows
283
+ if (name := entry_name(entry)) is not None
284
+ }
285
+ )
286
+
287
+
288
+ def _extends(raw: dict[str, Any], label: str) -> bool | None:
289
+ """Take a file's ``extends`` out of its definitions and validate it."""
290
+ if EXTENDS_KEY not in raw:
291
+ return None
292
+ value = raw.pop(EXTENDS_KEY)
293
+ if not isinstance(value, bool):
294
+ raise ConfigurationError(f"{label}: {EXTENDS_KEY} must be true or false")
295
+ return value
296
+
297
+
298
+ def _read_file(file: Path, label: str) -> dict[str, Any]:
299
+ try:
300
+ raw = yaml.safe_load(read_configuration_file(file))
301
+ except OSError as error:
302
+ raise ConfigurationError(f"cannot read {label}: {error}") from error
303
+ except yaml.YAMLError as error:
304
+ raise ConfigurationError(f"invalid YAML in {label}: {error}") from error
305
+ if not isinstance(raw, dict):
306
+ raise ConfigurationError(f"{label} must contain a mapping")
307
+ return raw
308
+
309
+
310
+ def _import_files(
311
+ imports: Any, root: Path, base: Path, seen: set[Path]
312
+ ) -> list[tuple[str, Path]]:
313
+ root_label = display_path(root, base)
314
+ if not isinstance(imports, list):
315
+ raise ConfigurationError(
316
+ f"{IMPORTS_KEY} in {root_label} must be a list of file paths"
317
+ )
318
+ result: list[tuple[str, Path]] = []
319
+ for index, entry in enumerate(imports):
320
+ if not isinstance(entry, str) or not entry.strip():
321
+ raise ConfigurationError(
322
+ f"{root_label} {IMPORTS_KEY}[{index}] must be a non-empty file path"
323
+ )
324
+ file = root.parent / entry
325
+ if not configuration_file_exists(file):
326
+ raise ConfigurationError(
327
+ f"imported file not found: {entry} (listed in {root_label})"
328
+ )
329
+ resolved = file.resolve()
330
+ if resolved in seen:
331
+ raise ConfigurationError(
332
+ f"{root_label} {IMPORTS_KEY}[{index}] imports {entry}, which "
333
+ "is already a configuration file or an import"
334
+ )
335
+ seen.add(resolved)
336
+ result.append((display_path(file, base), file))
337
+ return result
338
+
339
+
340
+ def _read_import(file: Path, label: str) -> dict[str, Any]:
341
+ raw = _read_file(file, label)
342
+ if IMPORTS_KEY in raw:
343
+ raise ConfigurationError(
344
+ f"{label} cannot import other files; list every import in the root file"
345
+ )
346
+ return raw
347
+
348
+
349
+ class _Composer:
350
+ """Fold configuration mappings, each later one overriding the earlier."""
351
+
352
+ def __init__(self) -> None:
353
+ self.raw: dict[str, Any] = {}
354
+ self.overrides: list[Override] = []
355
+ # Which file supplied each definition, for override notices.
356
+ self._origins: dict[tuple[str, str | None], str] = {}
357
+ self.workflow_provenance: dict[str, WorkflowProvenance] = {}
358
+
359
+ def apply(
360
+ self, raw: dict[str, Any], label: str, level: WorkflowConfigLevel
361
+ ) -> None:
362
+ for key, value in raw.items():
363
+ current = self.raw.get(key)
364
+ match key, current, value:
365
+ case _, None, _:
366
+ self.raw[key] = value
367
+ self._note_origins(key, value, label)
368
+ if key == "workflows":
369
+ self._set_workflow_provenance(value, label, level)
370
+ case str(), list(), list() if key in _NAMED_CATALOGS:
371
+ self._merge_named(key, current, value, label, level)
372
+ case "profiles", dict(), dict():
373
+ self._merge_profiles(current, value, label)
374
+ case "hooks", dict(), dict():
375
+ _merge_hooks(current, value)
376
+ case "rules", dict(), dict():
377
+ self._merge_rule_groups(current, value, label)
378
+ case _:
379
+ self._record(key, None, key, label)
380
+ self.raw[key] = value
381
+ self._note_origins(key, value, label)
382
+
383
+ def _set_workflow_provenance(
384
+ self, entries: Any, label: str, level: WorkflowConfigLevel
385
+ ) -> None:
386
+ # The raw catalog is not validated yet; the parser rejects bad shapes.
387
+ if not isinstance(entries, list):
388
+ return
389
+ for entry in entries:
390
+ if (name := entry_name(entry)) is not None:
391
+ self.workflow_provenance[name] = WorkflowProvenance(label, level)
392
+
393
+ def _note_origins(self, key: str, value: Any, label: str) -> None:
394
+ self._origins[(key, None)] = label
395
+ if key in _NAMED_CATALOGS and isinstance(value, list):
396
+ for entry in value:
397
+ self._origins[(key, entry_name(entry))] = label
398
+ elif key in {"profiles", "rules"} and isinstance(value, dict):
399
+ for name in value:
400
+ self._origins[(key, name)] = label
401
+
402
+ def _merge_named(
403
+ self,
404
+ key: str,
405
+ merged: list[Any],
406
+ entries: list[Any],
407
+ label: str,
408
+ level: WorkflowConfigLevel,
409
+ ) -> None:
410
+ """Replace same-named entries in place and append the rest.
411
+
412
+ Only names from earlier files are replaced: a name repeated within one
413
+ file stays repeated, so validation still reports the duplicate.
414
+ """
415
+ positions = {
416
+ name: index
417
+ for index, entry in enumerate(merged)
418
+ if (name := entry_name(entry)) is not None
419
+ }
420
+ for entry in entries:
421
+ name = entry_name(entry)
422
+ if name is not None and name in positions:
423
+ self._record(key, name, _NAMED_CATALOGS[key], label)
424
+ merged[positions.pop(name)] = entry
425
+ else:
426
+ merged.append(entry)
427
+ self._origins[(key, name)] = label
428
+ if key == "workflows" and name is not None:
429
+ self.workflow_provenance[name] = WorkflowProvenance(label, level)
430
+
431
+ def _merge_profiles(
432
+ self, merged: dict[str, Any], profiles: dict[str, Any], label: str
433
+ ) -> None:
434
+ for name, description in profiles.items():
435
+ if name in merged:
436
+ self._record("profiles", name, "profile", label)
437
+ merged[name] = description
438
+ self._origins[("profiles", name)] = label
439
+
440
+ def _merge_rule_groups(
441
+ self, merged: dict[str, Any], groups: dict[str, Any], label: str
442
+ ) -> None:
443
+ """A later file's group replaces the earlier group of that name whole."""
444
+ for name, group in groups.items():
445
+ if name in merged:
446
+ self._record("rules", name, "rule group", label)
447
+ merged[name] = group
448
+ self._origins[("rules", name)] = label
449
+
450
+ def _record(self, key: str, name: str | None, kind: str, label: str) -> None:
451
+ self.overrides.append(
452
+ Override(
453
+ kind,
454
+ name,
455
+ self._origins[(key, name)],
456
+ label,
457
+ )
458
+ )
459
+
460
+
461
+ def entry_name(entry: Any) -> str | None:
462
+ """The name a catalog entry declares, explicitly or by shorthand.
463
+
464
+ An entry without a usable name is kept as it is, for the parser to report.
465
+ """
466
+ if not isinstance(entry, dict) or not entry:
467
+ return None
468
+ name = entry["name"] if "name" in entry else next(iter(entry))
469
+ return name if isinstance(name, str) else None
470
+
471
+
472
+ def _merge_hooks(merged: dict[str, Any], hooks: dict[str, Any]) -> None:
473
+ """Run a later file's hooks after the earlier ones, phase by phase."""
474
+ for phase, entries in hooks.items():
475
+ existing = merged.get(phase)
476
+ if isinstance(existing, list) and isinstance(entries, list):
477
+ merged[phase] = [*existing, *entries]
478
+ else:
479
+ merged[phase] = entries
480
+
481
+
482
+ def _rule_path_lists(raw: dict[str, Any]) -> list[list[Any]]:
483
+ """Every list of rule items in one file: root groups and step ``rules``."""
484
+ found: list[list[Any]] = []
485
+ groups = raw.get("rules")
486
+ if isinstance(groups, dict):
487
+ for group in groups.values():
488
+ if isinstance(group, list):
489
+ found.append(group)
490
+ elif isinstance(group, dict) and isinstance(group.get("rules"), list):
491
+ found.append(group["rules"])
492
+
493
+ def walk(value: Any) -> None:
494
+ if isinstance(value, dict):
495
+ for key, nested in value.items():
496
+ if key == "rules" and isinstance(nested, list):
497
+ found.append(nested)
498
+ else:
499
+ walk(nested)
500
+ elif isinstance(value, list):
501
+ for nested in value:
502
+ walk(nested)
503
+
504
+ walk(raw.get("workflows"))
505
+ walk(raw.get("handlers"))
506
+ return found
507
+
508
+
509
+ def _absolute_rule_notices(raw: dict[str, Any], label: str) -> tuple[str, ...]:
510
+ return tuple(
511
+ f"rule path {item} in {label} is absolute; it applies only on this machine."
512
+ for items in _rule_path_lists(raw)
513
+ for item in items
514
+ if isinstance(item, str) and Path(item).is_absolute() and Path(item).exists()
515
+ )
516
+
517
+
518
+ _WORKER_SETTINGS = ("agent", "model", "reasoning", "profile")
519
+
520
+
521
+ def _manager_setting_notices(raw: dict[str, Any], label: str) -> tuple[str, ...]:
522
+ """A step or workflow with ``role: manager`` that also asks for a worker.
523
+
524
+ The manager is whichever session runs the task, so those settings have no
525
+ effect there; they are kept for the steps below that are delegated.
526
+ """
527
+ found: list[str] = []
528
+
529
+ def walk(value: Any) -> None:
530
+ if isinstance(value, dict):
531
+ if value.get("role") == "manager":
532
+ settings = [key for key in _WORKER_SETTINGS if key in value]
533
+ if settings:
534
+ name = entry_name(value) or "a step"
535
+ found.append(
536
+ f"{name} in {label} has role: manager, so "
537
+ + ", ".join(settings)
538
+ + " has no effect on it; only nested steps that set "
539
+ "role: worker use it."
540
+ )
541
+ for nested in value.values():
542
+ walk(nested)
543
+ elif isinstance(value, list):
544
+ for nested in value:
545
+ walk(nested)
546
+
547
+ walk(raw.get("workflows"))
548
+ walk(raw.get("handlers"))
549
+ return tuple(found)
550
+
551
+
552
+ def _rebase_rule_paths(
553
+ raw: dict[str, Any], directory: Path, base: Path, group_names: set[str]
554
+ ) -> dict[str, Any]:
555
+ """Rewrite ``raw``'s relative rule paths from ``directory`` to ``base``.
556
+
557
+ Only strings that exist as paths next to the declaring file and are not
558
+ group names change; a sentence stays the rule text it is.
559
+ """
560
+ if directory.resolve() == base.resolve():
561
+ return raw
562
+ for items in _rule_path_lists(raw):
563
+ for index, item in enumerate(items):
564
+ if (
565
+ isinstance(item, str)
566
+ and item not in group_names
567
+ and not Path(item).is_absolute()
568
+ and (directory / item).exists()
569
+ ):
570
+ items[index] = os.path.relpath(directory / item, base)
571
+ return raw