@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.
Files changed (76) hide show
  1. package/AGENTS.md +117 -0
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +19 -13
  4. package/app/.claude-plugin/plugin.json +1 -1
  5. package/app/ARCHITECTURE.md +4 -3
  6. package/app/hooks/_hook-io.sh +18 -3
  7. package/app/hooks/ai-toolkit-statusline.sh +30 -5
  8. package/app/hooks/filter-tool-output.sh +76 -0
  9. package/app/hooks/governance-capture.sh +1 -1
  10. package/app/hooks/guard-path.sh +2 -2
  11. package/app/hooks/post-tool-use.sh +5 -3
  12. package/app/hooks/pre-compact-save.sh +4 -3
  13. package/app/hooks/quality-gate.sh +12 -1
  14. package/app/hooks/revert-guard.sh +5 -2
  15. package/app/hooks/save-session.sh +4 -2
  16. package/app/hooks/session-end.sh +36 -4
  17. package/app/hooks/session-start.sh +11 -5
  18. package/app/hooks.json +10 -0
  19. package/app/output-filter-policy.json +15 -0
  20. package/app/skills/brand-voice/scripts/measure.py +7 -5
  21. package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
  22. package/benchmarks/output-filter/README.md +11 -0
  23. package/benchmarks/output-filter/scenarios.json +25 -0
  24. package/bin/ai-toolkit.js +2 -0
  25. package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
  26. package/kb/procedures/release-preparation-sop.md +6 -5
  27. package/kb/reference/architecture-overview.md +6 -5
  28. package/kb/reference/cli-reference.md +19 -2
  29. package/kb/reference/codex-cli-compatibility.md +1 -0
  30. package/kb/reference/copilot-compatibility.md +173 -0
  31. package/kb/reference/enterprise-config-guide.md +28 -2
  32. package/kb/reference/global-install-model.md +6 -2
  33. package/kb/reference/hooks-catalog.md +105 -16
  34. package/kb/reference/opencode-compatibility.md +1 -0
  35. package/kb/reference/supported-tools-registry.md +10 -5
  36. package/kb/reference/tool-output-filter.md +288 -0
  37. package/kb/reference/windows-support.md +4 -3
  38. package/llms-full.txt +1182 -40
  39. package/llms.txt +3 -0
  40. package/manifest.json +9 -6
  41. package/package.json +3 -2
  42. package/scripts/benchmark_output_filter.py +343 -0
  43. package/scripts/check_deps.py +16 -0
  44. package/scripts/claude_app.py +30 -2
  45. package/scripts/config_cli.py +4 -4
  46. package/scripts/config_lock.py +120 -14
  47. package/scripts/config_merger.py +103 -20
  48. package/scripts/config_resolver.py +22 -2
  49. package/scripts/config_validator.py +268 -16
  50. package/scripts/copilot_legacy_hashes.json +338 -0
  51. package/scripts/doctor.py +1 -0
  52. package/scripts/generate_codex_hooks.py +2 -0
  53. package/scripts/generate_copilot.py +464 -71
  54. package/scripts/generate_copilot_hooks.py +124 -7
  55. package/scripts/generate_gemini_hooks.py +33 -10
  56. package/scripts/generate_opencode_plugin.py +28 -12
  57. package/scripts/install_steps/ai_tools.py +115 -3
  58. package/scripts/install_steps/hooks.py +25 -1
  59. package/scripts/output_filter_cli.py +347 -0
  60. package/scripts/output_filter_hook.py +23 -0
  61. package/scripts/plugin_schema.py +27 -1
  62. package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
  63. package/scripts/session_state.py +156 -42
  64. package/scripts/tool_output_filter/__init__.py +33 -0
  65. package/scripts/tool_output_filter/contracts.py +173 -0
  66. package/scripts/tool_output_filter/engine.py +260 -0
  67. package/scripts/tool_output_filter/hook_runtime.py +369 -0
  68. package/scripts/tool_output_filter/input.py +56 -0
  69. package/scripts/tool_output_filter/invariants.py +40 -0
  70. package/scripts/tool_output_filter/policy.py +153 -0
  71. package/scripts/tool_output_filter/profiles/__init__.py +68 -0
  72. package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
  73. package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
  74. package/scripts/tool_output_filter/recovery.py +846 -0
  75. package/scripts/tool_output_filter/telemetry.py +13 -0
  76. package/scripts/uninstall.py +96 -3
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env python3
2
+ """Manual and hook entry points for native tool-output filtering."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import math
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from tool_output_filter.contracts import FilterMode, FilterRequest
14
+ from tool_output_filter.contracts import (
15
+ DEFAULT_MAX_INPUT_BYTES,
16
+ DEFAULT_MIN_SAVINGS_BYTES,
17
+ DEFAULT_MIN_SAVINGS_RATIO,
18
+ )
19
+ from tool_output_filter.engine import filter_output
20
+ from tool_output_filter.hook_runtime import run_hook
21
+ from tool_output_filter.input import read_bounded_text
22
+ from tool_output_filter.policy import OutputFilterPolicy, load_policy
23
+ from tool_output_filter.recovery import (
24
+ EphemeralRecoveryStore,
25
+ clean_owned_repo_recovery,
26
+ clean_session,
27
+ recover_by_handle,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ import argparse
32
+
33
+ _OWNER_MARKER = "ai-toolkit-output-filter-policy-v1"
34
+ _INSPECT_PROFILES = frozenset({"repeat-lines", "tap-success"})
35
+
36
+
37
+ def _infer_repo_root(start: Path) -> Path | None:
38
+ # realpath keeps the repo key aligned with the bash hooks, which resolve
39
+ # symlinks via `git rev-parse --show-toplevel`.
40
+ candidate_path = Path(
41
+ os.path.realpath(os.path.expanduser(str(start)))
42
+ )
43
+ if not candidate_path.is_dir():
44
+ return None
45
+ for candidate in (candidate_path, *candidate_path.parents):
46
+ git_marker = candidate / ".git"
47
+ if git_marker.is_dir() or git_marker.is_file():
48
+ return candidate
49
+ return candidate_path
50
+
51
+
52
+ def _session_base_for_repo(repo_root: Path) -> Path:
53
+ repo_key = "-" + str(repo_root).replace("/", "-").lstrip("-")
54
+ return (
55
+ Path.home()
56
+ / ".softspark"
57
+ / "ai-toolkit"
58
+ / "sessions"
59
+ / repo_key
60
+ )
61
+
62
+
63
+ def _manual_session_base() -> Path | None:
64
+ project_directory = os.environ.get("CLAUDE_PROJECT_DIR")
65
+ repo_root = _infer_repo_root(
66
+ Path(project_directory) if project_directory else Path.cwd()
67
+ )
68
+ return _session_base_for_repo(repo_root) if repo_root is not None else None
69
+
70
+
71
+ def _is_regular_file(path: Path) -> bool:
72
+ return path.is_file() and not path.is_symlink()
73
+
74
+
75
+ def _is_registered_project(project_root: Path) -> bool:
76
+ """Trust only projects registered via `ai-toolkit install --local`.
77
+
78
+ The owner marker is a public constant, so a cloned repo must never be
79
+ able to self-enable filtering with it. Mirrors filter-tool-output.sh.
80
+ """
81
+ registry = (
82
+ Path.home() / ".softspark" / "ai-toolkit" / "projects.json"
83
+ )
84
+ if not _is_regular_file(registry):
85
+ return False
86
+ try:
87
+ data = json.loads(registry.read_text(encoding="utf-8"))
88
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
89
+ return False
90
+ projects = data.get("projects") if isinstance(data, dict) else None
91
+ if not isinstance(projects, list):
92
+ return False
93
+ root_text = str(project_root)
94
+ return any(
95
+ isinstance(entry, dict) and entry.get("path") == root_text
96
+ for entry in projects
97
+ )
98
+
99
+
100
+ def _trusted_project_policy(project_root: Path) -> Path | None:
101
+ managed_directory = project_root / ".claude"
102
+ if project_root.is_symlink() or managed_directory.is_symlink():
103
+ return None
104
+ if not _is_registered_project(project_root):
105
+ return None
106
+ policy_path = managed_directory / "ai-toolkit-output-filter.json"
107
+ owner_path = managed_directory / ".ai-toolkit-output-filter.owner"
108
+ if not _is_regular_file(policy_path) or not _is_regular_file(owner_path):
109
+ return None
110
+ try:
111
+ owner_text = owner_path.read_text(encoding="utf-8")
112
+ except OSError:
113
+ return None
114
+ # The bash hook's $(<file) strips trailing newlines; accept the same.
115
+ if owner_text.rstrip("\n") != _OWNER_MARKER:
116
+ return None
117
+ return policy_path
118
+
119
+
120
+ def _resolve_policy_path(explicit_path: Path | None) -> Path | None:
121
+ if explicit_path is not None:
122
+ return explicit_path
123
+ # Same resolution as filter-tool-output.sh: the project directory only,
124
+ # never its parents, so `status` reports what the hook will actually do.
125
+ project_directory = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
126
+ project_policy = _trusted_project_policy(Path(project_directory))
127
+ if project_policy is not None:
128
+ return project_policy
129
+ global_policy = (
130
+ Path.home()
131
+ / ".softspark"
132
+ / "ai-toolkit"
133
+ / "hooks"
134
+ / "output-filter-policy.json"
135
+ )
136
+ return global_policy if _is_regular_file(global_policy) else None
137
+
138
+
139
+ def _default_policy() -> OutputFilterPolicy:
140
+ return OutputFilterPolicy(
141
+ mode=FilterMode.OFF,
142
+ profiles=("repeat-lines", "tap-success"),
143
+ )
144
+
145
+
146
+ def _run_status(policy_path: Path | None) -> int:
147
+ resolved_path = _resolve_policy_path(policy_path)
148
+ if resolved_path is None:
149
+ policy = _default_policy()
150
+ else:
151
+ try:
152
+ policy = load_policy(resolved_path)
153
+ except (OSError, TypeError, ValueError, KeyError):
154
+ return 2
155
+ output = {
156
+ "mode": policy.mode.value,
157
+ "profiles": list(policy.profiles),
158
+ "maxInputBytes": policy.max_input_bytes,
159
+ "minSavingsBytes": policy.min_savings_bytes,
160
+ "minSavingsRatio": policy.min_savings_ratio,
161
+ "recovery": {
162
+ "mode": "ephemeral",
163
+ "ttlMinutes": policy.ttl_minutes,
164
+ "maxSessionBytes": policy.max_session_bytes,
165
+ },
166
+ }
167
+ print(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
168
+ return 0
169
+
170
+
171
+ def _run_inspect(args: argparse.Namespace) -> int:
172
+ if not _valid_inspect_arguments(args):
173
+ return 2
174
+ try:
175
+ raw_output = read_bounded_text(
176
+ sys.stdin.buffer,
177
+ max_bytes=args.max_input_bytes,
178
+ )
179
+ except (TypeError, UnicodeDecodeError, ValueError):
180
+ return 2
181
+ if raw_output is None:
182
+ report = {
183
+ "profile": args.profile,
184
+ "eligible": False,
185
+ "outcome": "passthrough",
186
+ "fallbackReason": "input-too-large",
187
+ "inputBytesAtLeast": args.max_input_bytes + 1,
188
+ "maxInputBytes": args.max_input_bytes,
189
+ }
190
+ print(json.dumps(report, separators=(",", ":")))
191
+ return 0
192
+ request = FilterRequest(
193
+ output=raw_output,
194
+ mode=FilterMode.OBSERVE,
195
+ profile_id=args.profile,
196
+ max_input_bytes=args.max_input_bytes,
197
+ min_savings_bytes=args.min_savings_bytes,
198
+ min_savings_ratio=args.min_savings_ratio,
199
+ )
200
+ result = filter_output(request)
201
+ telemetry = result.telemetry
202
+ input_bytes = len(raw_output.encode("utf-8"))
203
+ report = {
204
+ "profile": args.profile,
205
+ "eligible": result.outcome == "observed",
206
+ "outcome": result.outcome,
207
+ "fallbackReason": result.fallback_reason,
208
+ "inputBytes": input_bytes,
209
+ "candidateBytes": (
210
+ telemetry.output_bytes if telemetry is not None else input_bytes
211
+ ),
212
+ "inputLines": len(raw_output.splitlines()),
213
+ "candidateLines": (
214
+ telemetry.output_lines
215
+ if telemetry is not None
216
+ else len(raw_output.splitlines())
217
+ ),
218
+ }
219
+ print(json.dumps(report, ensure_ascii=False, separators=(",", ":")))
220
+ return 0
221
+
222
+
223
+ def _valid_inspect_arguments(args: argparse.Namespace) -> bool:
224
+ """Reject unsafe manual limits before consuming stdin."""
225
+ return (
226
+ args.profile in _INSPECT_PROFILES
227
+ and 1 <= args.max_input_bytes <= DEFAULT_MAX_INPUT_BYTES
228
+ and 0 <= args.min_savings_bytes <= DEFAULT_MAX_INPUT_BYTES
229
+ and math.isfinite(args.min_savings_ratio)
230
+ and 0.0 <= args.min_savings_ratio <= 1.0
231
+ )
232
+
233
+
234
+ def _run_recover(args: argparse.Namespace) -> int:
235
+ base_directory = args.base_directory or _manual_session_base()
236
+ if base_directory is None:
237
+ return 2
238
+ try:
239
+ if args.session_id:
240
+ with EphemeralRecoveryStore(
241
+ base_directory,
242
+ session_identifier=args.session_id,
243
+ ) as recovery:
244
+ response = recovery.load(args.handle)
245
+ else:
246
+ response = recover_by_handle(base_directory, args.handle)
247
+ except (OSError, RuntimeError, TypeError, ValueError):
248
+ return 2
249
+ if response is None:
250
+ return 1
251
+ print(json.dumps(response, ensure_ascii=False, separators=(",", ":")))
252
+ return 0
253
+
254
+
255
+ def _run_clean(args: argparse.Namespace) -> int:
256
+ base_directory = args.base_directory or _manual_session_base()
257
+ if base_directory is None:
258
+ return 2
259
+ try:
260
+ if args.session_id:
261
+ if args.expired:
262
+ with EphemeralRecoveryStore(
263
+ base_directory,
264
+ session_identifier=args.session_id,
265
+ ) as recovery:
266
+ removed = recovery.clean_expired()
267
+ scope = "expired"
268
+ else:
269
+ removed = clean_session(
270
+ base_directory,
271
+ args.session_id,
272
+ )
273
+ scope = "all"
274
+ elif args.expired:
275
+ return 2
276
+ else:
277
+ removed = clean_owned_repo_recovery(base_directory)
278
+ scope = "all"
279
+ except (OSError, RuntimeError, TypeError, ValueError):
280
+ return 2
281
+ print(json.dumps({"removed": removed, "scope": scope}, separators=(",", ":")))
282
+ return 0
283
+
284
+
285
+ def _build_parser() -> argparse.ArgumentParser:
286
+ import argparse
287
+
288
+ parser = argparse.ArgumentParser(
289
+ description="Inspect and safely filter supported tool output"
290
+ )
291
+ subparsers = parser.add_subparsers(dest="command", required=True)
292
+ hook = subparsers.add_parser("hook")
293
+ hook.add_argument("--policy", type=Path, required=True)
294
+ status = subparsers.add_parser("status")
295
+ status.add_argument("--policy", type=Path)
296
+ inspect = subparsers.add_parser("inspect")
297
+ inspect.add_argument("--profile", required=True)
298
+ inspect.add_argument(
299
+ "--max-input-bytes",
300
+ type=int,
301
+ default=DEFAULT_MAX_INPUT_BYTES,
302
+ )
303
+ inspect.add_argument(
304
+ "--min-savings-bytes",
305
+ type=int,
306
+ default=DEFAULT_MIN_SAVINGS_BYTES,
307
+ )
308
+ inspect.add_argument(
309
+ "--min-savings-ratio",
310
+ type=float,
311
+ default=DEFAULT_MIN_SAVINGS_RATIO,
312
+ )
313
+ recover = subparsers.add_parser("recover")
314
+ recover.add_argument("handle")
315
+ recover.add_argument("--base-directory", type=Path)
316
+ recover.add_argument("--session-id")
317
+ clean = subparsers.add_parser("clean")
318
+ clean.add_argument("--base-directory", type=Path)
319
+ clean.add_argument("--session-id")
320
+ clean.add_argument("--expired", action="store_true")
321
+ return parser
322
+
323
+
324
+ def main(argv: list[str] | None = None) -> int:
325
+ arguments = sys.argv[1:] if argv is None else argv
326
+ if (
327
+ len(arguments) == 3
328
+ and arguments[0] == "hook"
329
+ and arguments[1] == "--policy"
330
+ ):
331
+ return run_hook(Path(arguments[2]))
332
+ args = _build_parser().parse_args(arguments)
333
+ if args.command == "hook":
334
+ return run_hook(args.policy)
335
+ if args.command == "status":
336
+ return _run_status(args.policy)
337
+ if args.command == "inspect":
338
+ return _run_inspect(args)
339
+ if args.command == "recover":
340
+ return _run_recover(args)
341
+ if args.command == "clean":
342
+ return _run_clean(args)
343
+ return 2
344
+
345
+
346
+ if __name__ == "__main__":
347
+ sys.exit(main())
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env python3
2
+ """Lean process entry point for the Claude output-filter hook."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+
8
+ from tool_output_filter.hook_runtime import run_hook
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ arguments = sys.argv[1:] if argv is None else argv
13
+ if (
14
+ len(arguments) != 3
15
+ or arguments[0] != "hook"
16
+ or arguments[1] != "--policy"
17
+ ):
18
+ return 2
19
+ return run_hook(arguments[2])
20
+
21
+
22
+ if __name__ == "__main__":
23
+ sys.exit(main())
@@ -11,7 +11,14 @@ from pathlib import Path
11
11
 
12
12
 
13
13
  # Required top-level fields
14
- REQUIRED_FIELDS = ("name", "description", "version", "domain", "type", "status")
14
+ REQUIRED_FIELDS = (
15
+ "name",
16
+ "description",
17
+ "version",
18
+ "domain",
19
+ "type",
20
+ "status",
21
+ )
15
22
 
16
23
  # Valid status values
17
24
  VALID_STATUSES = frozenset({"stable", "experimental", "deprecated"})
@@ -39,6 +46,24 @@ VALID_HOOK_EVENTS = frozenset({
39
46
  })
40
47
 
41
48
 
49
+ def _validate_requires(data: dict) -> list[str]:
50
+ if "requires" not in data:
51
+ return ["Missing required field: requires"]
52
+
53
+ requires = data["requires"]
54
+ if not isinstance(requires, dict) or not requires:
55
+ return ["'requires' must be a non-empty dictionary"]
56
+
57
+ errors: list[str] = []
58
+ for dependency, constraint in requires.items():
59
+ if not isinstance(dependency, str) or not dependency.strip():
60
+ errors.append("requires keys must be non-empty strings")
61
+ continue
62
+ if not isinstance(constraint, str) or not constraint.strip():
63
+ errors.append(f"requires.{dependency} must be a non-empty string")
64
+ return errors
65
+
66
+
42
67
  def validate_manifest(data: dict, pack_dir: Path | None = None) -> list[str]:
43
68
  """Validate a plugin manifest dict.
44
69
 
@@ -50,6 +75,7 @@ def validate_manifest(data: dict, pack_dir: Path | None = None) -> list[str]:
50
75
  for field in REQUIRED_FIELDS:
51
76
  if field not in data or not data[field]:
52
77
  errors.append(f"Missing required field: {field}")
78
+ errors.extend(_validate_requires(data))
53
79
 
54
80
  # Validate status
55
81
  status = data.get("status", "")
@@ -39,6 +39,66 @@
39
39
  "default": "standard",
40
40
  "description": "Installation profile controlling which modules are installed."
41
41
  },
42
+ "toolOutputFilter": {
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "description": "Native post-execution Bash output filtering. Disabled by default.",
46
+ "properties": {
47
+ "mode": {
48
+ "type": "string",
49
+ "enum": ["off", "observe", "safe"],
50
+ "default": "off"
51
+ },
52
+ "profiles": {
53
+ "type": "array",
54
+ "items": {
55
+ "type": "string",
56
+ "enum": ["repeat-lines", "tap-success"]
57
+ },
58
+ "uniqueItems": true,
59
+ "default": ["repeat-lines", "tap-success"]
60
+ },
61
+ "maxInputBytes": {
62
+ "type": "integer",
63
+ "minimum": 1,
64
+ "maximum": 8388608,
65
+ "default": 8388608
66
+ },
67
+ "minSavingsBytes": {
68
+ "type": "integer",
69
+ "minimum": 0,
70
+ "maximum": 8388608,
71
+ "default": 1024
72
+ },
73
+ "minSavingsRatio": {
74
+ "type": "number",
75
+ "minimum": 0,
76
+ "maximum": 1,
77
+ "default": 0.15
78
+ },
79
+ "recovery": {
80
+ "type": "object",
81
+ "additionalProperties": false,
82
+ "properties": {
83
+ "mode": {
84
+ "type": "string",
85
+ "enum": ["ephemeral"],
86
+ "default": "ephemeral"
87
+ },
88
+ "ttlMinutes": {
89
+ "type": "integer",
90
+ "minimum": 1,
91
+ "default": 60
92
+ },
93
+ "maxSessionBytes": {
94
+ "type": "integer",
95
+ "minimum": 1,
96
+ "default": 33554432
97
+ }
98
+ }
99
+ }
100
+ }
101
+ },
42
102
  "agents": {
43
103
  "type": "object",
44
104
  "additionalProperties": false,
@@ -61,6 +121,23 @@
61
121
  }
62
122
  }
63
123
  },
124
+ "plugins": {
125
+ "type": "object",
126
+ "additionalProperties": false,
127
+ "description": "Resolved plugin enable/disable intent. Runtime installation remains explicit.",
128
+ "properties": {
129
+ "enabled": {
130
+ "type": "array",
131
+ "items": { "type": "string", "minLength": 1 },
132
+ "uniqueItems": true
133
+ },
134
+ "disabled": {
135
+ "type": "array",
136
+ "items": { "type": "string", "minLength": 1 },
137
+ "uniqueItems": true
138
+ }
139
+ }
140
+ },
64
141
  "rules": {
65
142
  "type": "object",
66
143
  "additionalProperties": false,
@@ -81,7 +158,7 @@
81
158
  "constitution": {
82
159
  "type": "object",
83
160
  "additionalProperties": false,
84
- "description": "Constitution amendments. Articles I-VI are immutable. Base articles are immutable. Projects can only ADD new articles (7+).",
161
+ "description": "Constitution amendments. Articles I-VII are immutable. Base articles are immutable. Projects can only ADD new articles (8+).",
85
162
  "properties": {
86
163
  "amendments": {
87
164
  "type": "array",
@@ -92,8 +169,8 @@
92
169
  "properties": {
93
170
  "article": {
94
171
  "type": "integer",
95
- "minimum": 1,
96
- "description": "Article number. 1-5 are reserved (immutable). Base articles are also immutable."
172
+ "minimum": 8,
173
+ "description": "Article number. 1-7 are reserved (immutable). Base articles are also immutable."
97
174
  },
98
175
  "title": {
99
176
  "type": "string",
@@ -120,8 +197,9 @@
120
197
  },
121
198
  "requiredPlugins": {
122
199
  "type": "array",
123
- "items": { "type": "string" },
124
- "description": "Plugins that must be installed in all inheriting projects."
200
+ "items": { "type": "string", "minLength": 1 },
201
+ "uniqueItems": true,
202
+ "description": "Plugins that must appear in resolved enabled intent. Runtime installation remains explicit."
125
203
  },
126
204
  "forbidOverride": {
127
205
  "type": "array",