@softspark/ai-toolkit 4.15.1 → 4.16.1

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 (73) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +21 -12
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/ARCHITECTURE.md +4 -3
  5. package/app/hooks/_hook-io.sh +18 -3
  6. package/app/hooks/ai-toolkit-statusline.sh +30 -5
  7. package/app/hooks/filter-tool-output.sh +76 -0
  8. package/app/hooks/governance-capture.sh +1 -1
  9. package/app/hooks/guard-path.sh +2 -2
  10. package/app/hooks/post-tool-use.sh +5 -3
  11. package/app/hooks/pre-compact-save.sh +4 -3
  12. package/app/hooks/quality-gate.sh +12 -1
  13. package/app/hooks/revert-guard.sh +5 -2
  14. package/app/hooks/save-session.sh +4 -2
  15. package/app/hooks/session-end.sh +36 -4
  16. package/app/hooks/session-start.sh +11 -5
  17. package/app/hooks.json +10 -0
  18. package/app/output-filter-policy.json +15 -0
  19. package/app/skills/brand-voice/scripts/measure.py +7 -5
  20. package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
  21. package/benchmarks/output-filter/README.md +11 -0
  22. package/benchmarks/output-filter/scenarios.json +25 -0
  23. package/bin/ai-toolkit.js +2 -0
  24. package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
  25. package/kb/procedures/release-preparation-sop.md +6 -5
  26. package/kb/reference/architecture-overview.md +6 -5
  27. package/kb/reference/cli-reference.md +19 -2
  28. package/kb/reference/codex-cli-compatibility.md +1 -0
  29. package/kb/reference/copilot-compatibility.md +173 -0
  30. package/kb/reference/enterprise-config-guide.md +28 -2
  31. package/kb/reference/global-install-model.md +1 -0
  32. package/kb/reference/hooks-catalog.md +105 -16
  33. package/kb/reference/opencode-compatibility.md +1 -0
  34. package/kb/reference/supported-tools-registry.md +10 -5
  35. package/kb/reference/tool-output-filter.md +288 -0
  36. package/llms-full.txt +1173 -35
  37. package/llms.txt +3 -0
  38. package/manifest.json +9 -6
  39. package/package.json +3 -2
  40. package/scripts/benchmark_output_filter.py +343 -0
  41. package/scripts/check_deps.py +16 -0
  42. package/scripts/claude_app.py +30 -2
  43. package/scripts/config_cli.py +4 -4
  44. package/scripts/config_lock.py +120 -14
  45. package/scripts/config_merger.py +103 -20
  46. package/scripts/config_resolver.py +22 -2
  47. package/scripts/config_validator.py +268 -16
  48. package/scripts/doctor.py +1 -0
  49. package/scripts/generate_codex_hooks.py +2 -0
  50. package/scripts/generate_copilot.py +35 -4
  51. package/scripts/generate_gemini_hooks.py +33 -10
  52. package/scripts/generate_opencode_plugin.py +28 -12
  53. package/scripts/install.py +5 -1
  54. package/scripts/install_steps/ai_tools.py +101 -2
  55. package/scripts/install_steps/hooks.py +25 -1
  56. package/scripts/output_filter_cli.py +347 -0
  57. package/scripts/output_filter_hook.py +23 -0
  58. package/scripts/plugin_schema.py +27 -1
  59. package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
  60. package/scripts/session_state.py +156 -42
  61. package/scripts/tool_output_filter/__init__.py +33 -0
  62. package/scripts/tool_output_filter/contracts.py +173 -0
  63. package/scripts/tool_output_filter/engine.py +260 -0
  64. package/scripts/tool_output_filter/hook_runtime.py +369 -0
  65. package/scripts/tool_output_filter/input.py +56 -0
  66. package/scripts/tool_output_filter/invariants.py +40 -0
  67. package/scripts/tool_output_filter/policy.py +153 -0
  68. package/scripts/tool_output_filter/profiles/__init__.py +68 -0
  69. package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
  70. package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
  71. package/scripts/tool_output_filter/recovery.py +846 -0
  72. package/scripts/tool_output_filter/telemetry.py +13 -0
  73. package/scripts/uninstall.py +96 -3
@@ -1,6 +1,7 @@
1
1
  """Install global and project-local AI tool configs."""
2
2
  from __future__ import annotations
3
3
 
4
+ import json
4
5
  import os
5
6
  import shutil
6
7
  import subprocess
@@ -14,6 +15,7 @@ from codex_skill_adapter import (
14
15
  unmanaged_codex_skill_names,
15
16
  )
16
17
  from mcp_editors import sync_project_mcp_to_editors
18
+ from secure_fs import SecureDestination, run_secure_transaction
17
19
  from injection import (
18
20
  collapse_blank_runs as _collapse_blank_runs,
19
21
  strip_all_sections as _strip_all_sections,
@@ -21,6 +23,11 @@ from injection import (
21
23
  )
22
24
 
23
25
 
26
+ OUTPUT_FILTER_POLICY_NAME = "ai-toolkit-output-filter.json"
27
+ OUTPUT_FILTER_OWNER_NAME = ".ai-toolkit-output-filter.owner"
28
+ OUTPUT_FILTER_OWNER_MARKER = b"ai-toolkit-output-filter-policy-v1\n"
29
+
30
+
24
31
  def install_ai_tools(target_dir: Path, rules_dir: Path,
25
32
  dry_run: bool,
26
33
  editors: list[str] | None = None,
@@ -750,6 +757,10 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
750
757
  print(f" Would inject language rules: {', '.join(language_modules)}")
751
758
  if merged_config:
752
759
  print(" Would apply merged config from extends")
760
+ if merged_config and merged_config.get("toolOutputFilter") is not None:
761
+ print(" Would write: .claude/ai-toolkit-output-filter.json")
762
+ else:
763
+ print(" Would remove: managed project output-filter policy (if present)")
753
764
  return
754
765
 
755
766
  (cwd / ".claude").mkdir(parents=True, exist_ok=True)
@@ -759,6 +770,12 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
759
770
 
760
771
  _create_local_claude_md(cwd, reset)
761
772
  _create_local_settings(cwd, reset)
773
+ configured_policy = (
774
+ merged_config.get("toolOutputFilter")
775
+ if merged_config is not None
776
+ else None
777
+ )
778
+ _sync_local_output_filter_policy(cwd, configured_policy)
762
779
 
763
780
  legacy_local_hooks = cwd / ".claude" / "hooks.json"
764
781
  if legacy_local_hooks.is_file():
@@ -832,8 +849,8 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
832
849
 
833
850
  # Inject constitution amendments
834
851
  amendments = merged.get("constitution", {}).get("amendments", [])
835
- # Filter to non-toolkit articles (6+)
836
- custom_amendments = [a for a in amendments if a.get("article", 0) >= 6]
852
+ # Articles I-VII are toolkit-owned; inherited custom amendments start at VIII.
853
+ custom_amendments = [a for a in amendments if a.get("article", 0) >= 8]
837
854
  if custom_amendments:
838
855
  constitution_file = cwd / ".claude" / "constitution.md"
839
856
  if constitution_file.is_file():
@@ -876,6 +893,88 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
876
893
  print(" Saved: .softspark-toolkit-extends.json (resolution metadata)")
877
894
 
878
895
 
896
+ def _sync_local_output_filter_policy(
897
+ cwd: Path,
898
+ configured: dict | None,
899
+ ) -> None:
900
+ """Atomically synchronize the toolkit-owned per-project output policy."""
901
+ policy_path = cwd / ".claude" / OUTPUT_FILTER_POLICY_NAME
902
+ owner_path = cwd / ".claude" / OUTPUT_FILTER_OWNER_NAME
903
+ policy_destination = SecureDestination(
904
+ policy_path, cwd, "project output-filter policy",
905
+ )
906
+ owner_destination = SecureDestination(
907
+ owner_path, cwd, "project output-filter owner marker",
908
+ )
909
+ destinations = [policy_destination, owner_destination]
910
+
911
+ try:
912
+ policy_content = (
913
+ _materialize_output_filter_policy(configured)
914
+ if configured is not None
915
+ else None
916
+ )
917
+ except (
918
+ AttributeError,
919
+ json.JSONDecodeError,
920
+ OSError,
921
+ RuntimeError,
922
+ TypeError,
923
+ ValueError,
924
+ ) as error:
925
+ print(f" Warning: project output-filter policy not changed: {error}")
926
+ return
927
+
928
+ def mutate(transaction) -> None:
929
+ owner = transaction.initial_content(owner_destination)
930
+ existing_policy = transaction.initial_content(policy_destination)
931
+ if owner is None and existing_policy is not None:
932
+ print(" Kept: user-owned .claude/ai-toolkit-output-filter.json")
933
+ return
934
+ if owner not in (None, OUTPUT_FILTER_OWNER_MARKER):
935
+ print(" Kept: untrusted .claude output-filter ownership marker")
936
+ return
937
+
938
+ if policy_content is None:
939
+ if owner == OUTPUT_FILTER_OWNER_MARKER:
940
+ transaction.unlink(policy_destination)
941
+ transaction.unlink(owner_destination)
942
+ print(" Removed: managed project output-filter policy")
943
+ return
944
+
945
+ transaction.atomic_write(policy_destination, policy_content, 0o600)
946
+ if owner is None:
947
+ transaction.atomic_write(
948
+ owner_destination,
949
+ OUTPUT_FILTER_OWNER_MARKER,
950
+ 0o600,
951
+ )
952
+ print(" Wrote: .claude/ai-toolkit-output-filter.json")
953
+
954
+ try:
955
+ run_secure_transaction(destinations, mutate)
956
+ except RuntimeError as error:
957
+ print(f" Warning: project output-filter policy not changed: {error}")
958
+
959
+
960
+ def _materialize_output_filter_policy(configured: dict) -> bytes:
961
+ """Merge a partial project policy over canonical safe defaults."""
962
+ default_path = app_dir / "output-filter-policy.json"
963
+ with open(default_path, encoding="utf-8") as handle:
964
+ defaults = json.load(handle)
965
+ policy = dict(defaults)
966
+ policy.update(configured)
967
+ recovery = dict(defaults.get("recovery", {}))
968
+ recovery.update(configured.get("recovery", {}))
969
+ policy["recovery"] = recovery
970
+
971
+ from config_validator import validate_project_config
972
+ errors = validate_project_config({"toolOutputFilter": policy})
973
+ if errors:
974
+ raise RuntimeError("invalid output-filter policy: " + "; ".join(errors))
975
+ return (json.dumps(policy, indent=2, sort_keys=True) + "\n").encode()
976
+
977
+
879
978
  def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
880
979
  """Install Claude language-rule entrypoints for a project.
881
980
 
@@ -61,6 +61,13 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
61
61
  for runtime_file in sorted(hooks_src.glob("*.json")):
62
62
  shutil.copy2(runtime_file, hooks_scripts_dir / runtime_file.name)
63
63
  copied += 1
64
+ output_filter_policy = app_dir / "output-filter-policy.json"
65
+ policy_destination = hooks_scripts_dir / output_filter_policy.name
66
+ # The global policy is user configuration: seed it once, never overwrite,
67
+ # so `ai-toolkit update` cannot silently reset an enabled mode to off.
68
+ if output_filter_policy.is_file() and not policy_destination.exists():
69
+ shutil.copy2(output_filter_policy, policy_destination)
70
+ copied += 1
64
71
  print(f" Copied: {copied} hook scripts to ~/.softspark/ai-toolkit/hooks/")
65
72
  legacy_hooks = claude_dir / "hooks"
66
73
  if legacy_hooks.is_symlink():
@@ -71,6 +78,8 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
71
78
  # Python helpers that hooks invoke at runtime. Kept narrow on purpose — only
72
79
  # scripts that a deployed hook actually executes belong here.
73
80
  HOOK_RUNTIME_SCRIPTS: tuple[str, ...] = (
81
+ "output_filter_cli.py",
82
+ "output_filter_hook.py",
74
83
  "session_state.py",
75
84
  "session_token_stats.py",
76
85
  "test_cohesion.py",
@@ -98,8 +107,23 @@ def _copy_hook_runtime_scripts(scripts_dst: Path) -> None:
98
107
  shutil.copy2(src, dst)
99
108
  dst.chmod(dst.stat().st_mode | 0o111)
100
109
  copied += 1
110
+ output_filter_src = scripts_src / "tool_output_filter"
111
+ if output_filter_src.is_dir():
112
+ output_filter_dst = scripts_dst / output_filter_src.name
113
+ # Prune first: dirs_exist_ok alone never removes modules deleted in a
114
+ # newer release, and a stale .py at sys.path[0] would shadow the
115
+ # shipped implementation under `python3 -S`.
116
+ if output_filter_dst.is_dir() and not output_filter_dst.is_symlink():
117
+ shutil.rmtree(output_filter_dst)
118
+ shutil.copytree(
119
+ output_filter_src,
120
+ output_filter_dst,
121
+ dirs_exist_ok=True,
122
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
123
+ )
124
+ copied += 1
101
125
  if copied:
102
- print(f" Copied: {copied} hook runtime scripts to ~/.softspark/ai-toolkit/scripts/")
126
+ print(f" Copied: {copied} hook runtime assets to ~/.softspark/ai-toolkit/scripts/")
103
127
 
104
128
 
105
129
  def _run_merge_hooks(action: str, *args: str) -> None:
@@ -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", "")