@softspark/ai-toolkit 4.15.0 → 4.16.0
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.
- package/AGENTS.md +117 -0
- package/CHANGELOG.md +43 -0
- package/README.md +19 -13
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +4 -3
- package/app/hooks/_hook-io.sh +18 -3
- package/app/hooks/ai-toolkit-statusline.sh +30 -5
- package/app/hooks/filter-tool-output.sh +76 -0
- package/app/hooks/governance-capture.sh +1 -1
- package/app/hooks/guard-path.sh +2 -2
- package/app/hooks/post-tool-use.sh +5 -3
- package/app/hooks/pre-compact-save.sh +4 -3
- package/app/hooks/quality-gate.sh +12 -1
- package/app/hooks/revert-guard.sh +5 -2
- package/app/hooks/save-session.sh +4 -2
- package/app/hooks/session-end.sh +36 -4
- package/app/hooks/session-start.sh +11 -5
- package/app/hooks.json +10 -0
- package/app/output-filter-policy.json +15 -0
- package/app/skills/brand-voice/scripts/measure.py +7 -5
- package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
- package/benchmarks/output-filter/README.md +11 -0
- package/benchmarks/output-filter/scenarios.json +25 -0
- package/bin/ai-toolkit.js +2 -0
- package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
- package/kb/procedures/release-preparation-sop.md +6 -5
- package/kb/reference/architecture-overview.md +6 -5
- package/kb/reference/cli-reference.md +19 -2
- package/kb/reference/codex-cli-compatibility.md +1 -0
- package/kb/reference/copilot-compatibility.md +173 -0
- package/kb/reference/enterprise-config-guide.md +28 -2
- package/kb/reference/global-install-model.md +6 -2
- package/kb/reference/hooks-catalog.md +105 -16
- package/kb/reference/opencode-compatibility.md +1 -0
- package/kb/reference/supported-tools-registry.md +10 -5
- package/kb/reference/tool-output-filter.md +288 -0
- package/kb/reference/windows-support.md +4 -3
- package/llms-full.txt +1182 -40
- package/llms.txt +3 -0
- package/manifest.json +9 -6
- package/package.json +3 -2
- package/scripts/benchmark_output_filter.py +343 -0
- package/scripts/check_deps.py +16 -0
- package/scripts/claude_app.py +30 -2
- package/scripts/config_cli.py +4 -4
- package/scripts/config_lock.py +120 -14
- package/scripts/config_merger.py +103 -20
- package/scripts/config_resolver.py +22 -2
- package/scripts/config_validator.py +268 -16
- package/scripts/copilot_legacy_hashes.json +338 -0
- package/scripts/doctor.py +1 -0
- package/scripts/generate_codex_hooks.py +2 -0
- package/scripts/generate_copilot.py +464 -71
- package/scripts/generate_copilot_hooks.py +124 -7
- package/scripts/generate_gemini_hooks.py +33 -10
- package/scripts/generate_opencode_plugin.py +28 -12
- package/scripts/install_steps/ai_tools.py +115 -3
- package/scripts/install_steps/hooks.py +25 -1
- package/scripts/output_filter_cli.py +347 -0
- package/scripts/output_filter_hook.py +23 -0
- package/scripts/plugin_schema.py +27 -1
- package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
- package/scripts/session_state.py +156 -42
- package/scripts/tool_output_filter/__init__.py +33 -0
- package/scripts/tool_output_filter/contracts.py +173 -0
- package/scripts/tool_output_filter/engine.py +260 -0
- package/scripts/tool_output_filter/hook_runtime.py +369 -0
- package/scripts/tool_output_filter/input.py +56 -0
- package/scripts/tool_output_filter/invariants.py +40 -0
- package/scripts/tool_output_filter/policy.py +153 -0
- package/scripts/tool_output_filter/profiles/__init__.py +68 -0
- package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
- package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
- package/scripts/tool_output_filter/recovery.py +846 -0
- package/scripts/tool_output_filter/telemetry.py +13 -0
- package/scripts/uninstall.py +96 -3
package/scripts/config_lock.py
CHANGED
|
@@ -8,6 +8,7 @@ Stdlib-only — no external dependencies.
|
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import hashlib
|
|
11
12
|
import json
|
|
12
13
|
import sys
|
|
13
14
|
from datetime import datetime, timezone
|
|
@@ -41,9 +42,10 @@ def load_lock_file(project_dir: Path) -> dict[str, Any] | None:
|
|
|
41
42
|
return None
|
|
42
43
|
try:
|
|
43
44
|
with open(lock_path, encoding="utf-8") as f:
|
|
44
|
-
|
|
45
|
-
except (json.JSONDecodeError, OSError):
|
|
45
|
+
lock = json.load(f)
|
|
46
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
46
47
|
return None
|
|
48
|
+
return lock if isinstance(lock, dict) else None
|
|
47
49
|
|
|
48
50
|
|
|
49
51
|
def save_lock_file(
|
|
@@ -66,7 +68,7 @@ def save_lock_file(
|
|
|
66
68
|
"lockfileVersion": LOCK_VERSION,
|
|
67
69
|
"resolved": {},
|
|
68
70
|
"generated_at": _now_iso(),
|
|
69
|
-
"ai_toolkit_version": ai_toolkit_version,
|
|
71
|
+
"ai_toolkit_version": ai_toolkit_version or _current_toolkit_version(),
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
for config in resolved_configs:
|
|
@@ -105,16 +107,11 @@ def check_lock_staleness(project_dir: Path) -> str:
|
|
|
105
107
|
if lock is None:
|
|
106
108
|
return "missing"
|
|
107
109
|
|
|
108
|
-
|
|
109
|
-
if
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
resolved = lock.get("resolved", {})
|
|
114
|
-
if not resolved:
|
|
115
|
-
return "stale: no resolved entries"
|
|
116
|
-
|
|
117
|
-
return "ok"
|
|
110
|
+
header_error = _check_lock_header(lock)
|
|
111
|
+
if header_error:
|
|
112
|
+
return header_error
|
|
113
|
+
resolved = lock["resolved"]
|
|
114
|
+
return _check_resolved_chain(config["extends"], resolved)
|
|
118
115
|
|
|
119
116
|
|
|
120
117
|
def get_locked_version(project_dir: Path, config_name: str) -> str | None:
|
|
@@ -126,8 +123,13 @@ def get_locked_version(project_dir: Path, config_name: str) -> str | None:
|
|
|
126
123
|
if lock is None:
|
|
127
124
|
return None
|
|
128
125
|
resolved = lock.get("resolved", {})
|
|
126
|
+
if not isinstance(resolved, dict):
|
|
127
|
+
return None
|
|
129
128
|
entry = resolved.get(config_name, {})
|
|
130
|
-
|
|
129
|
+
if not isinstance(entry, dict):
|
|
130
|
+
return None
|
|
131
|
+
version = entry.get("version")
|
|
132
|
+
return version if isinstance(version, str) and version else None
|
|
131
133
|
|
|
132
134
|
|
|
133
135
|
# ---------------------------------------------------------------------------
|
|
@@ -139,6 +141,110 @@ def _now_iso() -> str:
|
|
|
139
141
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
140
142
|
|
|
141
143
|
|
|
144
|
+
def _check_lock_header(lock: dict[str, Any]) -> str:
|
|
145
|
+
"""Validate lock format and toolkit version."""
|
|
146
|
+
if lock.get("lockfileVersion") != LOCK_VERSION:
|
|
147
|
+
return f"stale: lock version {lock.get('lockfileVersion')} != {LOCK_VERSION}"
|
|
148
|
+
locked_version = lock.get("ai_toolkit_version")
|
|
149
|
+
current_version = _current_toolkit_version()
|
|
150
|
+
if current_version and (
|
|
151
|
+
not isinstance(locked_version, str)
|
|
152
|
+
or not locked_version
|
|
153
|
+
):
|
|
154
|
+
return "stale: ai-toolkit version metadata is missing or invalid"
|
|
155
|
+
if current_version and locked_version != current_version:
|
|
156
|
+
return (
|
|
157
|
+
f"stale: ai-toolkit version '{locked_version}' "
|
|
158
|
+
f"!= current '{current_version}'"
|
|
159
|
+
)
|
|
160
|
+
resolved = lock.get("resolved")
|
|
161
|
+
if not isinstance(resolved, dict) or not resolved:
|
|
162
|
+
return "stale: no resolved entries"
|
|
163
|
+
if not all(isinstance(entry, dict) for entry in resolved.values()):
|
|
164
|
+
return "stale: invalid resolved entries"
|
|
165
|
+
return ""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _check_resolved_chain(
|
|
169
|
+
current_source: str,
|
|
170
|
+
resolved: dict[str, dict[str, Any]],
|
|
171
|
+
) -> str:
|
|
172
|
+
"""Compare every locked config against its current cached snapshot."""
|
|
173
|
+
if not isinstance(current_source, str) or not current_source:
|
|
174
|
+
return "stale: invalid project extends source"
|
|
175
|
+
for name, entry in reversed(list(resolved.items())):
|
|
176
|
+
locked_source = entry.get("source", "")
|
|
177
|
+
if current_source != locked_source:
|
|
178
|
+
return (
|
|
179
|
+
f"stale: extends source '{current_source}' "
|
|
180
|
+
f"!= locked '{locked_source}'"
|
|
181
|
+
)
|
|
182
|
+
error, current_config = _check_resolved_entry(name, entry)
|
|
183
|
+
if error:
|
|
184
|
+
return error
|
|
185
|
+
assert current_config is not None
|
|
186
|
+
current_source = current_config.get("extends", "")
|
|
187
|
+
if not isinstance(current_source, str):
|
|
188
|
+
return f"stale: invalid extends source in resolved config {name}"
|
|
189
|
+
if current_source:
|
|
190
|
+
return f"stale: unresolved extends source '{current_source}'"
|
|
191
|
+
return "ok"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _check_resolved_entry(
|
|
195
|
+
name: str,
|
|
196
|
+
entry: dict[str, Any],
|
|
197
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
198
|
+
"""Compare version and integrity for one locked config."""
|
|
199
|
+
cached = entry.get("cached")
|
|
200
|
+
locked_source = entry.get("source")
|
|
201
|
+
locked_version = entry.get("version")
|
|
202
|
+
locked_integrity = entry.get("integrity")
|
|
203
|
+
if (
|
|
204
|
+
not isinstance(cached, str)
|
|
205
|
+
or not cached
|
|
206
|
+
or not isinstance(locked_source, str)
|
|
207
|
+
or not locked_source
|
|
208
|
+
or not isinstance(locked_version, str)
|
|
209
|
+
or not isinstance(locked_integrity, str)
|
|
210
|
+
or not locked_integrity.startswith("sha256:")
|
|
211
|
+
):
|
|
212
|
+
return f"stale: invalid resolved config metadata for {name}", None
|
|
213
|
+
config_path = Path(cached).expanduser() / "ai-toolkit.config.json"
|
|
214
|
+
try:
|
|
215
|
+
raw_config = config_path.read_bytes()
|
|
216
|
+
current_config = json.loads(raw_config.decode("utf-8"))
|
|
217
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
218
|
+
return f"stale: cannot read resolved config for {name}", None
|
|
219
|
+
if not isinstance(current_config, dict):
|
|
220
|
+
return f"stale: invalid resolved config content for {name}", None
|
|
221
|
+
current_version = current_config.get("version", "")
|
|
222
|
+
if current_version != locked_version:
|
|
223
|
+
return (
|
|
224
|
+
f"stale: {name} version '{current_version}' "
|
|
225
|
+
f"!= locked '{locked_version}'"
|
|
226
|
+
), None
|
|
227
|
+
current_integrity = f"sha256:{hashlib.sha256(raw_config).hexdigest()}"
|
|
228
|
+
if current_integrity != locked_integrity:
|
|
229
|
+
return (
|
|
230
|
+
f"stale: {name} integrity '{current_integrity}' "
|
|
231
|
+
f"!= locked '{locked_integrity}'"
|
|
232
|
+
), None
|
|
233
|
+
return "", current_config
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _current_toolkit_version() -> str:
|
|
237
|
+
"""Read the version of the toolkit owning this lock implementation."""
|
|
238
|
+
package_path = Path(__file__).resolve().parent.parent / "package.json"
|
|
239
|
+
try:
|
|
240
|
+
with open(package_path, encoding="utf-8") as handle:
|
|
241
|
+
package = json.load(handle)
|
|
242
|
+
except (json.JSONDecodeError, OSError):
|
|
243
|
+
return ""
|
|
244
|
+
version = package.get("version", "")
|
|
245
|
+
return version if isinstance(version, str) else ""
|
|
246
|
+
|
|
247
|
+
|
|
142
248
|
# ---------------------------------------------------------------------------
|
|
143
249
|
# CLI entry point (for testing)
|
|
144
250
|
# ---------------------------------------------------------------------------
|
package/scripts/config_merger.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""Config merger for ai-toolkit extends system.
|
|
3
3
|
|
|
4
4
|
Implements layered deep merge with:
|
|
5
|
-
- Constitution immutability (Articles I-
|
|
5
|
+
- Constitution immutability (Articles I-VII absolute, base articles immutable)
|
|
6
6
|
- Agent merge with requiredAgents enforcement
|
|
7
7
|
- Override validation (override:true + justification required)
|
|
8
8
|
- enforce block constraints (minHookProfile, requiredPlugins, forbidOverride, requiredAgents)
|
|
@@ -22,17 +22,10 @@ from typing import Any
|
|
|
22
22
|
# Constants
|
|
23
23
|
# ---------------------------------------------------------------------------
|
|
24
24
|
|
|
25
|
-
IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5, 6})
|
|
25
|
+
IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5, 6, 7})
|
|
26
26
|
|
|
27
27
|
HOOK_PROFILE_ORDER = {"minimal": 0, "standard": 1, "strict": 2}
|
|
28
28
|
|
|
29
|
-
# v1 schema fields that participate in merge
|
|
30
|
-
V1_FIELDS = frozenset({
|
|
31
|
-
"$schema", "extends", "name", "version", "description",
|
|
32
|
-
"profile", "agents", "rules", "constitution", "enforce", "overrides",
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
|
|
36
29
|
# ---------------------------------------------------------------------------
|
|
37
30
|
# Exceptions
|
|
38
31
|
# ---------------------------------------------------------------------------
|
|
@@ -84,6 +77,13 @@ def merge_config_chain(
|
|
|
84
77
|
# Merge project over accumulated base
|
|
85
78
|
result.merged = _merge_project_over_base(accumulated_base, project_config, result)
|
|
86
79
|
|
|
80
|
+
if "toolOutputFilter" in accumulated_base or "toolOutputFilter" in project_config:
|
|
81
|
+
configured = result.merged.get("toolOutputFilter", {})
|
|
82
|
+
result.merged["toolOutputFilter"] = _deep_merge(
|
|
83
|
+
_load_default_output_filter_policy(),
|
|
84
|
+
configured,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
87
|
# Validate enforce constraints
|
|
88
88
|
_validate_enforce(accumulated_base, result.merged)
|
|
89
89
|
|
|
@@ -95,6 +95,16 @@ def merge_two(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
|
|
95
95
|
return _deep_merge(base, overlay)
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
def _load_default_output_filter_policy() -> dict[str, Any]:
|
|
99
|
+
"""Load the canonical disabled output-filter policy."""
|
|
100
|
+
policy_path = Path(__file__).resolve().parent.parent / "app" / "output-filter-policy.json"
|
|
101
|
+
with open(policy_path, encoding="utf-8") as handle:
|
|
102
|
+
policy = json.load(handle)
|
|
103
|
+
if not isinstance(policy, dict):
|
|
104
|
+
raise ConfigMergeError(f"Invalid output-filter policy: {policy_path}")
|
|
105
|
+
return policy
|
|
106
|
+
|
|
107
|
+
|
|
98
108
|
# ---------------------------------------------------------------------------
|
|
99
109
|
# Internal: project-over-base merge (with enforcement)
|
|
100
110
|
# ---------------------------------------------------------------------------
|
|
@@ -108,6 +118,8 @@ def _merge_project_over_base(
|
|
|
108
118
|
merged: dict[str, Any] = {}
|
|
109
119
|
|
|
110
120
|
all_keys = set(base.keys()) | set(project.keys())
|
|
121
|
+
if base.get("enforce", {}).get("requiredPlugins"):
|
|
122
|
+
all_keys.add("plugins")
|
|
111
123
|
|
|
112
124
|
for key in all_keys:
|
|
113
125
|
# Skip meta fields that don't participate in merge output
|
|
@@ -119,23 +131,21 @@ def _merge_project_over_base(
|
|
|
119
131
|
base_val = base.get(key)
|
|
120
132
|
proj_val = project.get(key)
|
|
121
133
|
|
|
122
|
-
if
|
|
134
|
+
if key == "plugins":
|
|
135
|
+
merged[key] = _merge_plugins(base_val or {}, proj_val or {}, base)
|
|
136
|
+
elif proj_val is None:
|
|
123
137
|
merged[key] = base_val
|
|
124
138
|
elif key == "overrides":
|
|
125
139
|
# Always validate overrides against base enforce, even if base has no overrides
|
|
126
140
|
merged[key] = _validate_overrides(base, proj_val, result)
|
|
127
|
-
elif key == "constitution"
|
|
128
|
-
merged[key] = _merge_constitution(base_val, proj_val, base)
|
|
141
|
+
elif key == "constitution":
|
|
142
|
+
merged[key] = _merge_constitution(base_val or {}, proj_val, base)
|
|
129
143
|
elif base_val is None:
|
|
130
144
|
merged[key] = proj_val
|
|
131
|
-
elif key == "constitution":
|
|
132
|
-
merged[key] = _merge_constitution(base_val, proj_val, base)
|
|
133
145
|
elif key == "agents":
|
|
134
146
|
merged[key] = _merge_agents(base_val, proj_val, base)
|
|
135
147
|
elif key == "rules":
|
|
136
148
|
merged[key] = _merge_rules(base_val, proj_val)
|
|
137
|
-
elif key == "overrides":
|
|
138
|
-
merged[key] = _validate_overrides(base, proj_val, result)
|
|
139
149
|
elif key == "enforce":
|
|
140
150
|
# enforce blocks merge: base wins (projects cannot weaken enforcement)
|
|
141
151
|
merged[key] = _merge_enforce(base_val, proj_val)
|
|
@@ -163,7 +173,7 @@ def _merge_constitution(
|
|
|
163
173
|
"""Merge constitution — additions only, no modifications.
|
|
164
174
|
|
|
165
175
|
Rules:
|
|
166
|
-
1. Articles I-
|
|
176
|
+
1. Articles I-VII (1-7) are ABSOLUTELY immutable — toolkit core.
|
|
167
177
|
2. Articles defined by base configs are immutable — projects cannot modify.
|
|
168
178
|
3. Projects can ADD new articles with article numbers not in base.
|
|
169
179
|
"""
|
|
@@ -177,8 +187,8 @@ def _merge_constitution(
|
|
|
177
187
|
if article_num in IMMUTABLE_ARTICLES:
|
|
178
188
|
raise ConfigMergeError(
|
|
179
189
|
f"Cannot modify Constitution Article {article_num} — immutable.\n"
|
|
180
|
-
f"Articles I-
|
|
181
|
-
f"You can ADD new articles (article
|
|
190
|
+
f"Articles I-VII are defined by ai-toolkit and cannot be overridden.\n"
|
|
191
|
+
f"You can ADD new articles (article 8+)."
|
|
182
192
|
)
|
|
183
193
|
if article_num in base_amendments:
|
|
184
194
|
raise ConfigMergeError(
|
|
@@ -226,6 +236,71 @@ def _merge_agents(
|
|
|
226
236
|
}
|
|
227
237
|
|
|
228
238
|
|
|
239
|
+
def _merge_plugins(
|
|
240
|
+
base: dict[str, Any],
|
|
241
|
+
project: dict[str, Any],
|
|
242
|
+
full_base: dict[str, Any],
|
|
243
|
+
) -> dict[str, list[str]]:
|
|
244
|
+
"""Merge plugin intent while preserving inherited requirements."""
|
|
245
|
+
base_enabled = _plugin_names(base, "enabled", "base plugins")
|
|
246
|
+
base_disabled = _plugin_names(base, "disabled", "base plugins")
|
|
247
|
+
project_enabled = _plugin_names(project, "enabled", "project plugins")
|
|
248
|
+
project_disabled = _plugin_names(project, "disabled", "project plugins")
|
|
249
|
+
required = _plugin_names(
|
|
250
|
+
full_base.get("enforce", {}),
|
|
251
|
+
"requiredPlugins",
|
|
252
|
+
"base enforce",
|
|
253
|
+
)
|
|
254
|
+
for label, enabled, disabled in (
|
|
255
|
+
("base", base_enabled, base_disabled),
|
|
256
|
+
("project", project_enabled, project_disabled),
|
|
257
|
+
):
|
|
258
|
+
overlap = enabled & disabled
|
|
259
|
+
if overlap:
|
|
260
|
+
raise ConfigMergeError(
|
|
261
|
+
f"{label.capitalize()} plugins cannot be both enabled and "
|
|
262
|
+
f"disabled: {', '.join(sorted(overlap))}."
|
|
263
|
+
)
|
|
264
|
+
blocked = required & project_disabled
|
|
265
|
+
if blocked:
|
|
266
|
+
plugin = sorted(blocked)[0]
|
|
267
|
+
raise ConfigMergeError(
|
|
268
|
+
f"Cannot disable plugin '{plugin}' — required by base config "
|
|
269
|
+
f"'{full_base.get('name', 'unknown')}'."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
enabled = set(base_enabled)
|
|
273
|
+
enabled.update(project_enabled)
|
|
274
|
+
enabled.difference_update(project_disabled)
|
|
275
|
+
enabled.update(required)
|
|
276
|
+
disabled = (base_disabled | project_disabled) - enabled
|
|
277
|
+
return {
|
|
278
|
+
"enabled": sorted(enabled),
|
|
279
|
+
"disabled": sorted(disabled),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _plugin_names(
|
|
284
|
+
block: Any,
|
|
285
|
+
key: str,
|
|
286
|
+
label: str,
|
|
287
|
+
) -> set[str]:
|
|
288
|
+
"""Return validated plugin names for a merge boundary."""
|
|
289
|
+
if not isinstance(block, dict):
|
|
290
|
+
raise ConfigMergeError(f"{label} must be an object.")
|
|
291
|
+
value = block.get(key, [])
|
|
292
|
+
if not isinstance(value, list) or not all(
|
|
293
|
+
isinstance(item, str) and item.strip()
|
|
294
|
+
for item in value
|
|
295
|
+
):
|
|
296
|
+
raise ConfigMergeError(
|
|
297
|
+
f"{label}.{key} must be an array of non-empty strings."
|
|
298
|
+
)
|
|
299
|
+
if len(value) != len(set(value)):
|
|
300
|
+
raise ConfigMergeError(f"{label}.{key} must not contain duplicates.")
|
|
301
|
+
return set(value)
|
|
302
|
+
|
|
303
|
+
|
|
229
304
|
# ---------------------------------------------------------------------------
|
|
230
305
|
# Merge: rules
|
|
231
306
|
# ---------------------------------------------------------------------------
|
|
@@ -363,7 +438,15 @@ def _validate_enforce(
|
|
|
363
438
|
f"is below minimum '{min_profile}' required by base config."
|
|
364
439
|
)
|
|
365
440
|
|
|
366
|
-
|
|
441
|
+
required_plugins = set(enforce.get("requiredPlugins", []))
|
|
442
|
+
if required_plugins:
|
|
443
|
+
enabled_plugins = set(merged.get("plugins", {}).get("enabled", []))
|
|
444
|
+
missing_plugins = required_plugins - enabled_plugins
|
|
445
|
+
if missing_plugins:
|
|
446
|
+
errors.append(
|
|
447
|
+
"Required plugins missing from enabled intent: "
|
|
448
|
+
f"{', '.join(sorted(missing_plugins))}."
|
|
449
|
+
)
|
|
367
450
|
|
|
368
451
|
# requiredAgents
|
|
369
452
|
required_agents = set(enforce.get("requiredAgents", []))
|
|
@@ -10,7 +10,6 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import hashlib
|
|
12
12
|
import json
|
|
13
|
-
import os
|
|
14
13
|
import shutil
|
|
15
14
|
import subprocess
|
|
16
15
|
import sys
|
|
@@ -167,6 +166,7 @@ def _resolve_chain(
|
|
|
167
166
|
|
|
168
167
|
# Resolve this source
|
|
169
168
|
base_config = _resolve_source(extends_value, project_root, result, refresh=refresh)
|
|
169
|
+
_validate_resolved_base(base_config)
|
|
170
170
|
|
|
171
171
|
# Recurse if this base also extends something
|
|
172
172
|
if base_config.extends:
|
|
@@ -182,6 +182,19 @@ def _resolve_chain(
|
|
|
182
182
|
result.configs.append(base_config)
|
|
183
183
|
|
|
184
184
|
|
|
185
|
+
def _validate_resolved_base(base_config: BaseConfig) -> None:
|
|
186
|
+
"""Reject an invalid base before consumers merge or inspect it."""
|
|
187
|
+
from config_validator import validate_base_config
|
|
188
|
+
|
|
189
|
+
errors = validate_base_config(base_config.data, base_config.root)
|
|
190
|
+
if not errors:
|
|
191
|
+
return
|
|
192
|
+
details = "\n".join(f" - {error}" for error in errors)
|
|
193
|
+
raise ConfigResolverError(
|
|
194
|
+
f"Invalid base config '{base_config.source}':\n{details}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
185
198
|
def _resolve_source(
|
|
186
199
|
source: str,
|
|
187
200
|
project_root: Path,
|
|
@@ -470,11 +483,18 @@ def _load_json(path: Path) -> dict[str, Any]:
|
|
|
470
483
|
"""Load and parse a JSON file."""
|
|
471
484
|
try:
|
|
472
485
|
with open(path, encoding="utf-8") as f:
|
|
473
|
-
|
|
486
|
+
config = json.load(f)
|
|
474
487
|
except json.JSONDecodeError as e:
|
|
475
488
|
raise ConfigResolverError(f"Invalid JSON in {path}: {e}") from e
|
|
489
|
+
except UnicodeDecodeError as e:
|
|
490
|
+
raise ConfigResolverError(
|
|
491
|
+
f"Cannot decode {path} as UTF-8: {e}"
|
|
492
|
+
) from e
|
|
476
493
|
except OSError as e:
|
|
477
494
|
raise ConfigResolverError(f"Cannot read {path}: {e}") from e
|
|
495
|
+
if not isinstance(config, dict):
|
|
496
|
+
raise ConfigResolverError(f"{path} must contain a JSON object.")
|
|
497
|
+
return config
|
|
478
498
|
|
|
479
499
|
|
|
480
500
|
def _file_hash(path: Path) -> str:
|