@softspark/ai-toolkit 1.7.0 → 1.9.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/CHANGELOG.md +43 -0
- package/README.md +57 -5
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +6 -0
- package/bin/ai-toolkit.js +37 -0
- package/kb/{planning/enterprise-config-inheritance-plan.md → history/completed/enterprise-config-inheritance-plan-20260412.md} +39 -37
- package/kb/reference/enterprise-config-guide.md +329 -0
- package/llms-full.txt +2518 -2181
- package/llms.txt +2 -1
- package/manifest.json +9 -1
- package/package.json +1 -1
- package/scripts/config_cli.py +537 -0
- package/scripts/config_lock.py +154 -0
- package/scripts/config_merger.py +455 -0
- package/scripts/config_resolver.py +507 -0
- package/scripts/config_scaffold.py +266 -0
- package/scripts/config_validator.py +389 -0
- package/scripts/install.py +163 -1
- package/scripts/install_steps/ai_tools.py +101 -1
- package/scripts/install_steps/install_state.py +24 -0
- package/scripts/install_steps/project_registry.py +142 -0
- package/scripts/projects_cli.py +110 -0
- package/scripts/schemas/ai-toolkit-config.schema.json +163 -0
- package/scripts/update_projects.py +141 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scaffolding for ai-toolkit config inheritance.
|
|
3
|
+
|
|
4
|
+
- create_base_package(): Scaffold an npm base config package
|
|
5
|
+
- create_project_config(): Generate .ai-toolkit.json for a project
|
|
6
|
+
|
|
7
|
+
Stdlib-only — no external dependencies.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Base config scaffolder
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def create_base_package(
|
|
22
|
+
name: str,
|
|
23
|
+
output_dir: Path | None = None,
|
|
24
|
+
) -> Path:
|
|
25
|
+
"""Scaffold a base config npm package.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
name: Package name (e.g., '@mycompany/ai-toolkit-config').
|
|
29
|
+
output_dir: Where to create the package directory.
|
|
30
|
+
Defaults to CWD / sanitized-name.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Path to the created package directory.
|
|
34
|
+
"""
|
|
35
|
+
# Sanitize directory name
|
|
36
|
+
dir_name = name.replace("@", "").replace("/", "-")
|
|
37
|
+
pkg_dir = (output_dir or Path.cwd()) / dir_name
|
|
38
|
+
pkg_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
# package.json
|
|
41
|
+
package_json = {
|
|
42
|
+
"name": name,
|
|
43
|
+
"version": "1.0.0",
|
|
44
|
+
"description": f"Shared ai-toolkit configuration for {_org_from_name(name)}",
|
|
45
|
+
"main": "ai-toolkit.config.json",
|
|
46
|
+
"files": ["ai-toolkit.config.json", "rules/", "agents/"],
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@softspark/ai-toolkit": ">=1.5.0"
|
|
49
|
+
},
|
|
50
|
+
"keywords": ["ai-toolkit", "config", "shared"],
|
|
51
|
+
}
|
|
52
|
+
_write_json(pkg_dir / "package.json", package_json)
|
|
53
|
+
|
|
54
|
+
# ai-toolkit.config.json (base config with sane defaults)
|
|
55
|
+
base_config: dict[str, Any] = {
|
|
56
|
+
"$schema": "https://softspark.github.io/ai-toolkit/schemas/ai-toolkit-config.json",
|
|
57
|
+
"name": name,
|
|
58
|
+
"version": "1.0.0",
|
|
59
|
+
"description": f"Base AI toolkit configuration for {_org_from_name(name)}",
|
|
60
|
+
"profile": "standard",
|
|
61
|
+
"agents": {
|
|
62
|
+
"enabled": [
|
|
63
|
+
"backend-specialist",
|
|
64
|
+
"code-reviewer",
|
|
65
|
+
"debugger",
|
|
66
|
+
"security-auditor",
|
|
67
|
+
"test-engineer",
|
|
68
|
+
],
|
|
69
|
+
"disabled": [],
|
|
70
|
+
},
|
|
71
|
+
"rules": {
|
|
72
|
+
"inject": [],
|
|
73
|
+
},
|
|
74
|
+
"constitution": {
|
|
75
|
+
"amendments": [],
|
|
76
|
+
},
|
|
77
|
+
"enforce": {
|
|
78
|
+
"minHookProfile": "standard",
|
|
79
|
+
"requiredPlugins": [],
|
|
80
|
+
"forbidOverride": [],
|
|
81
|
+
"requiredAgents": [],
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
_write_json(pkg_dir / "ai-toolkit.config.json", base_config)
|
|
85
|
+
|
|
86
|
+
# rules/ directory with placeholder
|
|
87
|
+
rules_dir = pkg_dir / "rules"
|
|
88
|
+
rules_dir.mkdir(exist_ok=True)
|
|
89
|
+
(rules_dir / ".gitkeep").touch()
|
|
90
|
+
|
|
91
|
+
# agents/ directory with placeholder
|
|
92
|
+
agents_dir = pkg_dir / "agents"
|
|
93
|
+
agents_dir.mkdir(exist_ok=True)
|
|
94
|
+
(agents_dir / ".gitkeep").touch()
|
|
95
|
+
|
|
96
|
+
# README.md
|
|
97
|
+
readme = _generate_base_readme(name)
|
|
98
|
+
(pkg_dir / "README.md").write_text(readme, encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
return pkg_dir
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _org_from_name(name: str) -> str:
|
|
104
|
+
"""Extract org name from package name."""
|
|
105
|
+
if name.startswith("@"):
|
|
106
|
+
return name.split("/")[0].lstrip("@")
|
|
107
|
+
return name.split("-")[0] if "-" in name else name
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _generate_base_readme(name: str) -> str:
|
|
111
|
+
"""Generate README for base config package."""
|
|
112
|
+
org = _org_from_name(name)
|
|
113
|
+
return f"""# {name}
|
|
114
|
+
|
|
115
|
+
Shared ai-toolkit configuration for {org}.
|
|
116
|
+
|
|
117
|
+
## Usage
|
|
118
|
+
|
|
119
|
+
### In your project
|
|
120
|
+
|
|
121
|
+
Create `.ai-toolkit.json` in your project root:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{{
|
|
125
|
+
"extends": "{name}",
|
|
126
|
+
"profile": "standard"
|
|
127
|
+
}}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Then run:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
ai-toolkit install --local
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Available commands
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
ai-toolkit config validate # Validate config + extends
|
|
140
|
+
ai-toolkit config diff # Show differences from base
|
|
141
|
+
ai-toolkit config check # CI enforcement check
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Customization
|
|
145
|
+
|
|
146
|
+
### Adding rules
|
|
147
|
+
|
|
148
|
+
Add `.md` files to `rules/` and reference them in `ai-toolkit.config.json`:
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{{
|
|
152
|
+
"rules": {{
|
|
153
|
+
"inject": ["./rules/your-rule.md"]
|
|
154
|
+
}}
|
|
155
|
+
}}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Adding agents
|
|
159
|
+
|
|
160
|
+
Add agent `.md` files to `agents/` and reference them:
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
{{
|
|
164
|
+
"agents": {{
|
|
165
|
+
"custom": ["./agents/your-agent.md"]
|
|
166
|
+
}}
|
|
167
|
+
}}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Enforcement
|
|
171
|
+
|
|
172
|
+
Use the `enforce` block to set non-overridable constraints:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{{
|
|
176
|
+
"enforce": {{
|
|
177
|
+
"minHookProfile": "standard",
|
|
178
|
+
"requiredAgents": ["security-auditor"],
|
|
179
|
+
"forbidOverride": ["constitution"],
|
|
180
|
+
"requiredPlugins": ["security-pack"]
|
|
181
|
+
}}
|
|
182
|
+
}}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Publishing
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
npm publish
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Projects using this config will pick up changes on `ai-toolkit update --local`.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# Project config generator
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def create_project_config(
|
|
200
|
+
project_dir: Path,
|
|
201
|
+
extends: str = "",
|
|
202
|
+
profile: str = "standard",
|
|
203
|
+
) -> Path:
|
|
204
|
+
"""Generate .ai-toolkit.json for a project.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
project_dir: Project root directory.
|
|
208
|
+
extends: Base config source (npm, git, local).
|
|
209
|
+
profile: Installation profile.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Path to the created config file.
|
|
213
|
+
"""
|
|
214
|
+
config: dict[str, Any] = {}
|
|
215
|
+
|
|
216
|
+
if extends:
|
|
217
|
+
config["extends"] = extends
|
|
218
|
+
|
|
219
|
+
config["profile"] = profile
|
|
220
|
+
|
|
221
|
+
config_path = project_dir / ".ai-toolkit.json"
|
|
222
|
+
_write_json(config_path, config)
|
|
223
|
+
return config_path
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Helpers
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
231
|
+
"""Write JSON with consistent formatting."""
|
|
232
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
233
|
+
json.dump(data, f, indent=2)
|
|
234
|
+
f.write("\n")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# CLI entry point
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def main() -> None:
|
|
242
|
+
"""CLI: scaffold base config package."""
|
|
243
|
+
if len(sys.argv) < 2:
|
|
244
|
+
print("Usage: config_scaffold.py create-base <package-name> [output-dir]", file=sys.stderr)
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
|
|
247
|
+
cmd = sys.argv[1]
|
|
248
|
+
|
|
249
|
+
if cmd == "create-base":
|
|
250
|
+
if len(sys.argv) < 3:
|
|
251
|
+
print("Usage: config_scaffold.py create-base <package-name> [output-dir]", file=sys.stderr)
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
name = sys.argv[2]
|
|
254
|
+
output_dir = Path(sys.argv[3]) if len(sys.argv) > 3 else None
|
|
255
|
+
pkg_dir = create_base_package(name, output_dir)
|
|
256
|
+
print(json.dumps({
|
|
257
|
+
"created": str(pkg_dir),
|
|
258
|
+
"files": [str(p.relative_to(pkg_dir)) for p in sorted(pkg_dir.rglob("*")) if p.is_file()],
|
|
259
|
+
}, indent=2))
|
|
260
|
+
else:
|
|
261
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
main()
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Config validator for ai-toolkit extends system.
|
|
3
|
+
|
|
4
|
+
Validates .ai-toolkit.json against schema, checks enforce constraints,
|
|
5
|
+
verifies constitution integrity, and validates referenced files exist.
|
|
6
|
+
|
|
7
|
+
Stdlib-only — no external dependencies.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Constants
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
VALID_PROFILES = {"minimal", "standard", "strict", "full", "offline-slm"}
|
|
22
|
+
VALID_HOOK_PROFILES = {"minimal", "standard", "strict"}
|
|
23
|
+
HOOK_PROFILE_ORDER = {"minimal": 0, "standard": 1, "strict": 2}
|
|
24
|
+
IMMUTABLE_ARTICLES = frozenset({1, 2, 3, 4, 5})
|
|
25
|
+
MIN_JUSTIFICATION_LEN = 20
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Exceptions
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
class ConfigValidationError(Exception):
|
|
33
|
+
"""Raised when validation fails."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, errors: list[str]) -> None:
|
|
36
|
+
self.errors = errors
|
|
37
|
+
super().__init__("\n".join(errors))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Public API
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def validate_project_config(
|
|
45
|
+
config: dict[str, Any],
|
|
46
|
+
project_root: Path | None = None,
|
|
47
|
+
) -> list[str]:
|
|
48
|
+
"""Validate a project-level .ai-toolkit.json.
|
|
49
|
+
|
|
50
|
+
Returns list of error strings (empty = valid).
|
|
51
|
+
"""
|
|
52
|
+
errors: list[str] = []
|
|
53
|
+
|
|
54
|
+
# Schema validation (structural)
|
|
55
|
+
_validate_schema(config, errors)
|
|
56
|
+
|
|
57
|
+
# File existence checks
|
|
58
|
+
if project_root:
|
|
59
|
+
_validate_file_references(config, project_root, errors)
|
|
60
|
+
|
|
61
|
+
return errors
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_base_config(
|
|
65
|
+
config: dict[str, Any],
|
|
66
|
+
config_root: Path | None = None,
|
|
67
|
+
) -> list[str]:
|
|
68
|
+
"""Validate a base ai-toolkit.config.json.
|
|
69
|
+
|
|
70
|
+
Returns list of error strings (empty = valid).
|
|
71
|
+
"""
|
|
72
|
+
errors: list[str] = []
|
|
73
|
+
|
|
74
|
+
# Base configs should have name and version
|
|
75
|
+
if not config.get("name"):
|
|
76
|
+
errors.append("Base config missing required field 'name'.")
|
|
77
|
+
if not config.get("version"):
|
|
78
|
+
errors.append("Base config missing required field 'version'.")
|
|
79
|
+
|
|
80
|
+
# Schema validation
|
|
81
|
+
_validate_schema(config, errors)
|
|
82
|
+
|
|
83
|
+
# Enforce block validation
|
|
84
|
+
_validate_enforce_block(config.get("enforce", {}), errors)
|
|
85
|
+
|
|
86
|
+
# File existence checks
|
|
87
|
+
if config_root:
|
|
88
|
+
_validate_file_references(config, config_root, errors)
|
|
89
|
+
|
|
90
|
+
return errors
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_merged_config(
|
|
94
|
+
merged: dict[str, Any],
|
|
95
|
+
base: dict[str, Any],
|
|
96
|
+
) -> list[str]:
|
|
97
|
+
"""Validate a merged config against base enforce constraints.
|
|
98
|
+
|
|
99
|
+
Returns list of error strings (empty = valid).
|
|
100
|
+
"""
|
|
101
|
+
errors: list[str] = []
|
|
102
|
+
enforce = base.get("enforce", {})
|
|
103
|
+
|
|
104
|
+
if not enforce:
|
|
105
|
+
return errors
|
|
106
|
+
|
|
107
|
+
_validate_enforce_constraints(merged, enforce, errors)
|
|
108
|
+
|
|
109
|
+
return errors
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Schema validation
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _validate_schema(config: dict[str, Any], errors: list[str]) -> None:
|
|
117
|
+
"""Validate structural correctness of config."""
|
|
118
|
+
|
|
119
|
+
# extends: must be string if present
|
|
120
|
+
extends = config.get("extends")
|
|
121
|
+
if extends is not None and not isinstance(extends, str):
|
|
122
|
+
errors.append(f"'extends' must be a string, got {type(extends).__name__}.")
|
|
123
|
+
if isinstance(extends, str) and not extends.strip():
|
|
124
|
+
errors.append("'extends' cannot be empty string.")
|
|
125
|
+
|
|
126
|
+
# profile: must be valid enum
|
|
127
|
+
profile = config.get("profile")
|
|
128
|
+
if profile is not None and profile not in VALID_PROFILES:
|
|
129
|
+
errors.append(
|
|
130
|
+
f"Invalid profile '{profile}'. Valid: {', '.join(sorted(VALID_PROFILES))}."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# agents: structural check
|
|
134
|
+
agents = config.get("agents")
|
|
135
|
+
if agents is not None:
|
|
136
|
+
_validate_agents_block(agents, errors)
|
|
137
|
+
|
|
138
|
+
# rules: structural check
|
|
139
|
+
rules = config.get("rules")
|
|
140
|
+
if rules is not None:
|
|
141
|
+
_validate_rules_block(rules, errors)
|
|
142
|
+
|
|
143
|
+
# constitution: structural check
|
|
144
|
+
constitution = config.get("constitution")
|
|
145
|
+
if constitution is not None:
|
|
146
|
+
_validate_constitution_block(constitution, errors)
|
|
147
|
+
|
|
148
|
+
# enforce: structural check
|
|
149
|
+
enforce = config.get("enforce")
|
|
150
|
+
if enforce is not None:
|
|
151
|
+
_validate_enforce_block(enforce, errors)
|
|
152
|
+
|
|
153
|
+
# overrides: structural check
|
|
154
|
+
overrides = config.get("overrides")
|
|
155
|
+
if overrides is not None:
|
|
156
|
+
_validate_overrides_block(overrides, errors)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _validate_agents_block(agents: Any, errors: list[str]) -> None:
|
|
160
|
+
"""Validate agents section structure."""
|
|
161
|
+
if not isinstance(agents, dict):
|
|
162
|
+
errors.append("'agents' must be an object.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
valid_keys = {"enabled", "disabled", "custom"}
|
|
166
|
+
unknown = set(agents.keys()) - valid_keys
|
|
167
|
+
if unknown:
|
|
168
|
+
errors.append(f"Unknown keys in 'agents': {', '.join(sorted(unknown))}.")
|
|
169
|
+
|
|
170
|
+
for key in ("enabled", "disabled", "custom"):
|
|
171
|
+
val = agents.get(key)
|
|
172
|
+
if val is not None:
|
|
173
|
+
if not isinstance(val, list):
|
|
174
|
+
errors.append(f"'agents.{key}' must be an array.")
|
|
175
|
+
elif not all(isinstance(item, str) for item in val):
|
|
176
|
+
errors.append(f"'agents.{key}' items must be strings.")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _validate_rules_block(rules: Any, errors: list[str]) -> None:
|
|
180
|
+
"""Validate rules section structure."""
|
|
181
|
+
if not isinstance(rules, dict):
|
|
182
|
+
errors.append("'rules' must be an object.")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
valid_keys = {"inject", "remove"}
|
|
186
|
+
unknown = set(rules.keys()) - valid_keys
|
|
187
|
+
if unknown:
|
|
188
|
+
errors.append(f"Unknown keys in 'rules': {', '.join(sorted(unknown))}.")
|
|
189
|
+
|
|
190
|
+
for key in ("inject", "remove"):
|
|
191
|
+
val = rules.get(key)
|
|
192
|
+
if val is not None:
|
|
193
|
+
if not isinstance(val, list):
|
|
194
|
+
errors.append(f"'rules.{key}' must be an array.")
|
|
195
|
+
elif not all(isinstance(item, str) for item in val):
|
|
196
|
+
errors.append(f"'rules.{key}' items must be strings.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _validate_constitution_block(constitution: Any, errors: list[str]) -> None:
|
|
200
|
+
"""Validate constitution section structure."""
|
|
201
|
+
if not isinstance(constitution, dict):
|
|
202
|
+
errors.append("'constitution' must be an object.")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
amendments = constitution.get("amendments")
|
|
206
|
+
if amendments is None:
|
|
207
|
+
return
|
|
208
|
+
if not isinstance(amendments, list):
|
|
209
|
+
errors.append("'constitution.amendments' must be an array.")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
seen_articles: set[int] = set()
|
|
213
|
+
for i, amendment in enumerate(amendments):
|
|
214
|
+
if not isinstance(amendment, dict):
|
|
215
|
+
errors.append(f"'constitution.amendments[{i}]' must be an object.")
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
article = amendment.get("article")
|
|
219
|
+
if not isinstance(article, int) or article < 1:
|
|
220
|
+
errors.append(f"'constitution.amendments[{i}].article' must be a positive integer.")
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
if article in seen_articles:
|
|
224
|
+
errors.append(f"Duplicate constitution article number: {article}.")
|
|
225
|
+
seen_articles.add(article)
|
|
226
|
+
|
|
227
|
+
if not amendment.get("title"):
|
|
228
|
+
errors.append(f"'constitution.amendments[{i}].title' is required.")
|
|
229
|
+
if not amendment.get("text"):
|
|
230
|
+
errors.append(f"'constitution.amendments[{i}].text' is required.")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _validate_enforce_block(enforce: Any, errors: list[str]) -> None:
|
|
234
|
+
"""Validate enforce section structure."""
|
|
235
|
+
if not isinstance(enforce, dict):
|
|
236
|
+
errors.append("'enforce' must be an object.")
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
valid_keys = {"minHookProfile", "requiredPlugins", "forbidOverride", "requiredAgents"}
|
|
240
|
+
unknown = set(enforce.keys()) - valid_keys
|
|
241
|
+
if unknown:
|
|
242
|
+
errors.append(f"Unknown keys in 'enforce': {', '.join(sorted(unknown))}.")
|
|
243
|
+
|
|
244
|
+
min_profile = enforce.get("minHookProfile")
|
|
245
|
+
if min_profile is not None and min_profile not in VALID_HOOK_PROFILES:
|
|
246
|
+
errors.append(
|
|
247
|
+
f"Invalid minHookProfile '{min_profile}'. "
|
|
248
|
+
f"Valid: {', '.join(sorted(VALID_HOOK_PROFILES))}."
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
for key in ("requiredPlugins", "forbidOverride", "requiredAgents"):
|
|
252
|
+
val = enforce.get(key)
|
|
253
|
+
if val is not None:
|
|
254
|
+
if not isinstance(val, list):
|
|
255
|
+
errors.append(f"'enforce.{key}' must be an array.")
|
|
256
|
+
elif not all(isinstance(item, str) for item in val):
|
|
257
|
+
errors.append(f"'enforce.{key}' items must be strings.")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _validate_overrides_block(overrides: Any, errors: list[str]) -> None:
|
|
261
|
+
"""Validate overrides section structure."""
|
|
262
|
+
if not isinstance(overrides, dict):
|
|
263
|
+
errors.append("'overrides' must be an object.")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
for key, override in overrides.items():
|
|
267
|
+
if not isinstance(override, dict):
|
|
268
|
+
errors.append(f"'overrides.{key}' must be an object.")
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
if not override.get("override"):
|
|
272
|
+
errors.append(f"'overrides.{key}' missing 'override: true'.")
|
|
273
|
+
|
|
274
|
+
justification = override.get("justification", "")
|
|
275
|
+
if not justification or len(justification) < MIN_JUSTIFICATION_LEN:
|
|
276
|
+
errors.append(
|
|
277
|
+
f"'overrides.{key}.justification' too short "
|
|
278
|
+
f"({len(justification)} chars, min {MIN_JUSTIFICATION_LEN})."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
# Enforce constraint validation (post-merge)
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
def _validate_enforce_constraints(
|
|
287
|
+
merged: dict[str, Any],
|
|
288
|
+
enforce: dict[str, Any],
|
|
289
|
+
errors: list[str],
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Check merged config against enforce constraints."""
|
|
292
|
+
|
|
293
|
+
# minHookProfile
|
|
294
|
+
min_profile = enforce.get("minHookProfile")
|
|
295
|
+
if min_profile:
|
|
296
|
+
profile_to_hook = {
|
|
297
|
+
"minimal": "minimal",
|
|
298
|
+
"standard": "standard",
|
|
299
|
+
"strict": "strict",
|
|
300
|
+
"full": "strict",
|
|
301
|
+
"offline-slm": "minimal",
|
|
302
|
+
}
|
|
303
|
+
merged_profile = merged.get("profile", "standard")
|
|
304
|
+
merged_hook = profile_to_hook.get(merged_profile, "standard")
|
|
305
|
+
if HOOK_PROFILE_ORDER.get(merged_hook, 1) < HOOK_PROFILE_ORDER.get(min_profile, 0):
|
|
306
|
+
errors.append(
|
|
307
|
+
f"Profile '{merged_profile}' (hook: {merged_hook}) "
|
|
308
|
+
f"below minimum '{min_profile}'."
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# requiredAgents
|
|
312
|
+
required = set(enforce.get("requiredAgents", []))
|
|
313
|
+
if required:
|
|
314
|
+
enabled = set(merged.get("agents", {}).get("enabled", []))
|
|
315
|
+
missing = required - enabled
|
|
316
|
+
if missing:
|
|
317
|
+
errors.append(f"Required agents missing: {', '.join(sorted(missing))}.")
|
|
318
|
+
|
|
319
|
+
# requiredPlugins — informational in v1 (plugins field deferred)
|
|
320
|
+
required_plugins = enforce.get("requiredPlugins", [])
|
|
321
|
+
if required_plugins:
|
|
322
|
+
# v1: just warn, no plugins field to check against
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# File reference validation
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def _validate_file_references(
|
|
331
|
+
config: dict[str, Any],
|
|
332
|
+
root: Path,
|
|
333
|
+
errors: list[str],
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Check that referenced files actually exist."""
|
|
336
|
+
|
|
337
|
+
# Custom agents
|
|
338
|
+
for agent_path in config.get("agents", {}).get("custom", []):
|
|
339
|
+
full = root / agent_path
|
|
340
|
+
if not full.is_file():
|
|
341
|
+
errors.append(f"Custom agent file not found: {agent_path} (resolved: {full})")
|
|
342
|
+
|
|
343
|
+
# Rule files
|
|
344
|
+
for rule_path in config.get("rules", {}).get("inject", []):
|
|
345
|
+
full = root / rule_path
|
|
346
|
+
if not full.is_file():
|
|
347
|
+
errors.append(f"Rule file not found: {rule_path} (resolved: {full})")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# CLI entry point
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def main() -> None:
|
|
355
|
+
"""CLI: validate .ai-toolkit.json and print results."""
|
|
356
|
+
if len(sys.argv) < 2:
|
|
357
|
+
print("Usage: config_validator.py <config.json> [--strict]", file=sys.stderr)
|
|
358
|
+
sys.exit(1)
|
|
359
|
+
|
|
360
|
+
config_path = Path(sys.argv[1])
|
|
361
|
+
strict = "--strict" in sys.argv
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
with open(config_path) as f:
|
|
365
|
+
config = json.load(f)
|
|
366
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
367
|
+
print(json.dumps({"valid": False, "errors": [str(e)]}))
|
|
368
|
+
sys.exit(1)
|
|
369
|
+
|
|
370
|
+
project_root = config_path.parent
|
|
371
|
+
is_base = "name" in config and "version" in config
|
|
372
|
+
|
|
373
|
+
if is_base:
|
|
374
|
+
errors = validate_base_config(config, project_root)
|
|
375
|
+
else:
|
|
376
|
+
errors = validate_project_config(config, project_root)
|
|
377
|
+
|
|
378
|
+
if errors:
|
|
379
|
+
for e in errors:
|
|
380
|
+
print(f" ✗ {e}", file=sys.stderr)
|
|
381
|
+
if strict:
|
|
382
|
+
sys.exit(1)
|
|
383
|
+
print(json.dumps({"valid": False, "errors": errors}))
|
|
384
|
+
else:
|
|
385
|
+
print(json.dumps({"valid": True, "errors": []}))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if __name__ == "__main__":
|
|
389
|
+
main()
|