@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
|
@@ -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())
|
package/scripts/plugin_schema.py
CHANGED
|
@@ -11,7 +11,14 @@ from pathlib import Path
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
# Required top-level fields
|
|
14
|
-
REQUIRED_FIELDS = (
|
|
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-
|
|
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":
|
|
96
|
-
"description": "Article number. 1-
|
|
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
|
-
"
|
|
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",
|