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.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/config/composition.py
ADDED
|
@@ -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
|