@softspark/ai-toolkit 4.16.1 → 4.17.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 (49) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +9 -15
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/hooks/session-end.sh +1 -13
  5. package/app/hooks.json +0 -10
  6. package/benchmarks/ecosystem-doctor-snapshot.json +14 -15
  7. package/bin/ai-toolkit.js +0 -2
  8. package/kb/history/completed/output-filter-retirement-20260726.md +128 -0
  9. package/kb/reference/architecture-overview.md +2 -3
  10. package/kb/reference/cli-reference.md +3 -13
  11. package/kb/reference/enterprise-config-guide.md +1 -21
  12. package/kb/reference/hooks-catalog.md +3 -60
  13. package/kb/reference/supported-tools-registry.md +0 -4
  14. package/llms-full.txt +143 -395
  15. package/llms.txt +1 -1
  16. package/manifest.json +147 -36
  17. package/package.json +1 -2
  18. package/scripts/claude_app.py +2 -21
  19. package/scripts/config_cli.py +4 -0
  20. package/scripts/config_merger.py +0 -17
  21. package/scripts/config_validator.py +11 -138
  22. package/scripts/doctor.py +3 -20
  23. package/scripts/install.py +2 -1
  24. package/scripts/install_steps/ai_tools.py +28 -99
  25. package/scripts/install_steps/hooks.py +26 -24
  26. package/scripts/merge-hooks.py +33 -2
  27. package/scripts/output_filter_retirement.py +395 -0
  28. package/scripts/schemas/ai-toolkit-config.schema.json +0 -60
  29. package/scripts/uninstall.py +13 -27
  30. package/app/hooks/filter-tool-output.sh +0 -76
  31. package/app/output-filter-policy.json +0 -15
  32. package/benchmarks/output-filter/README.md +0 -11
  33. package/benchmarks/output-filter/scenarios.json +0 -25
  34. package/kb/reference/tool-output-filter.md +0 -288
  35. package/scripts/benchmark_output_filter.py +0 -343
  36. package/scripts/output_filter_cli.py +0 -347
  37. package/scripts/output_filter_hook.py +0 -23
  38. package/scripts/tool_output_filter/__init__.py +0 -33
  39. package/scripts/tool_output_filter/contracts.py +0 -173
  40. package/scripts/tool_output_filter/engine.py +0 -260
  41. package/scripts/tool_output_filter/hook_runtime.py +0 -369
  42. package/scripts/tool_output_filter/input.py +0 -56
  43. package/scripts/tool_output_filter/invariants.py +0 -40
  44. package/scripts/tool_output_filter/policy.py +0 -153
  45. package/scripts/tool_output_filter/profiles/__init__.py +0 -68
  46. package/scripts/tool_output_filter/profiles/repeat_lines.py +0 -71
  47. package/scripts/tool_output_filter/profiles/tap_success.py +0 -154
  48. package/scripts/tool_output_filter/recovery.py +0 -846
  49. package/scripts/tool_output_filter/telemetry.py +0 -13
@@ -1,7 +1,6 @@
1
1
  """Install global and project-local AI tool configs."""
2
2
  from __future__ import annotations
3
3
 
4
- import json
5
4
  import os
6
5
  import shutil
7
6
  import subprocess
@@ -15,7 +14,10 @@ from codex_skill_adapter import (
15
14
  unmanaged_codex_skill_names,
16
15
  )
17
16
  from mcp_editors import sync_project_mcp_to_editors
18
- from secure_fs import SecureDestination, run_secure_transaction
17
+ from output_filter_retirement import (
18
+ PROJECT_POLICY_NAME,
19
+ managed_project_policy,
20
+ )
19
21
  from injection import (
20
22
  collapse_blank_runs as _collapse_blank_runs,
21
23
  strip_all_sections as _strip_all_sections,
@@ -23,11 +25,6 @@ from injection import (
23
25
  )
24
26
 
25
27
 
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
-
31
28
  def install_ai_tools(target_dir: Path, rules_dir: Path,
32
29
  dry_run: bool,
33
30
  editors: list[str] | None = None,
@@ -399,6 +396,24 @@ def _cleanup_retired_windsurf_surfaces(cwd: Path) -> None:
399
396
  print(" Migrated: removed undocumented .devin/skills toolkit pointer")
400
397
 
401
398
 
399
+ def _cleanup_retired_output_filter_policy(cwd: Path) -> None:
400
+ """Remove the per-project policy the v4.16.x tool-output filter wrote.
401
+
402
+ Only the owner-marked pair is reclaimed; a policy file the user wrote by
403
+ hand has no marker and is left untouched.
404
+ """
405
+ managed = managed_project_policy(cwd / ".claude")
406
+ if not managed:
407
+ return
408
+ for path in managed:
409
+ try:
410
+ path.unlink()
411
+ except OSError as error:
412
+ print(f" Warning: kept .claude/{path.name} ({error})")
413
+ return
414
+ print(f" Migrated: removed retired .claude/{PROJECT_POLICY_NAME}")
415
+
416
+
402
417
  def _install_cline_global(target_dir: Path, rules_dir: Path) -> None:
403
418
  """Install Cline global rules in the documented ~/.cline directory."""
404
419
  from generate_cline_rules import generate as gen_cline_rules
@@ -757,10 +772,11 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
757
772
  print(f" Would inject language rules: {', '.join(language_modules)}")
758
773
  if merged_config:
759
774
  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)")
775
+ if managed_project_policy(cwd / ".claude"):
776
+ print(
777
+ " Would migrate: remove retired .claude/"
778
+ f"{PROJECT_POLICY_NAME}"
779
+ )
764
780
  return
765
781
 
766
782
  (cwd / ".claude").mkdir(parents=True, exist_ok=True)
@@ -770,12 +786,7 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
770
786
 
771
787
  _create_local_claude_md(cwd, reset)
772
788
  _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)
789
+ _cleanup_retired_output_filter_policy(cwd)
779
790
 
780
791
  legacy_local_hooks = cwd / ".claude" / "hooks.json"
781
792
  if legacy_local_hooks.is_file():
@@ -893,88 +904,6 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
893
904
  print(" Saved: .softspark-toolkit-extends.json (resolution metadata)")
894
905
 
895
906
 
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
-
978
907
  def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
979
908
  """Install Claude language-rule entrypoints for a project.
980
909
 
@@ -7,6 +7,32 @@ import subprocess
7
7
  from pathlib import Path
8
8
 
9
9
  from _common import app_dir, should_install, toolkit_dir
10
+ from output_filter_retirement import (
11
+ cleanup_global_artifacts,
12
+ find_global_artifacts,
13
+ )
14
+
15
+
16
+ def cleanup_retired_output_filter(hooks_scripts_dir: Path, dry_run: bool) -> None:
17
+ """Reclaim the v4.16.x tool-output filter files left on disk by an upgrade.
18
+
19
+ Runs outside ``should_install`` because the feature is gone: an install
20
+ that skips the hooks component still must not leave an orphaned hook
21
+ script, a global policy, or private recovery data behind. Silent when the
22
+ machine never ran v4.16.x.
23
+ """
24
+ toolkit_data_dir = hooks_scripts_dir.parent
25
+
26
+ if dry_run:
27
+ for label in find_global_artifacts(toolkit_data_dir):
28
+ print(f" Would migrate: remove retired output-filter {label}")
29
+ return
30
+
31
+ removed, warnings = cleanup_global_artifacts(toolkit_data_dir)
32
+ for label in removed:
33
+ print(f" Migrated: removed retired output-filter {label}")
34
+ for warning in warnings:
35
+ print(f" Warning: output-filter retirement {warning}")
10
36
 
11
37
 
12
38
  def install_hooks(claude_dir: Path, hooks_scripts_dir: Path,
@@ -61,13 +87,6 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
61
87
  for runtime_file in sorted(hooks_src.glob("*.json")):
62
88
  shutil.copy2(runtime_file, hooks_scripts_dir / runtime_file.name)
63
89
  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
71
90
  print(f" Copied: {copied} hook scripts to ~/.softspark/ai-toolkit/hooks/")
72
91
  legacy_hooks = claude_dir / "hooks"
73
92
  if legacy_hooks.is_symlink():
@@ -78,8 +97,6 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
78
97
  # Python helpers that hooks invoke at runtime. Kept narrow on purpose — only
79
98
  # scripts that a deployed hook actually executes belong here.
80
99
  HOOK_RUNTIME_SCRIPTS: tuple[str, ...] = (
81
- "output_filter_cli.py",
82
- "output_filter_hook.py",
83
100
  "session_state.py",
84
101
  "session_token_stats.py",
85
102
  "test_cohesion.py",
@@ -107,21 +124,6 @@ def _copy_hook_runtime_scripts(scripts_dst: Path) -> None:
107
124
  shutil.copy2(src, dst)
108
125
  dst.chmod(dst.stat().st_mode | 0o111)
109
126
  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
125
127
  if copied:
126
128
  print(f" Copied: {copied} hook runtime assets to ~/.softspark/ai-toolkit/scripts/")
127
129
 
@@ -54,6 +54,15 @@ LEGACY_TOOLKIT_HOOKS = {
54
54
  ],
55
55
  }
56
56
 
57
+ # Hook scripts older releases installed and current releases no longer ship.
58
+ # Claude Code rewrites settings.json without the "_source" tag, so a retired
59
+ # entry cannot be reclaimed by tag, and it cannot be reclaimed by signature
60
+ # either because the matching entry is gone from the toolkit hooks.json. The
61
+ # script path under the toolkit-owned hooks directory is the durable marker.
62
+ RETIRED_HOOK_SCRIPTS = (
63
+ "ai-toolkit/hooks/filter-tool-output.sh",
64
+ )
65
+
57
66
 
58
67
  def load_json(path: str) -> dict:
59
68
  """Load and parse a JSON file.
@@ -91,6 +100,25 @@ def _is_toolkit_entry(entry: dict) -> bool:
91
100
  return False
92
101
 
93
102
 
103
+ def _is_retired_toolkit_entry(entry: dict) -> bool:
104
+ """Check if every handler in an entry runs a retired toolkit hook script.
105
+
106
+ Requiring *all* handlers to match keeps a user entry that merely shares an
107
+ event with a retired hook, and keeps entries that chain their own command
108
+ after the toolkit one.
109
+ """
110
+ handlers = entry.get("hooks", [])
111
+ if not handlers:
112
+ return False
113
+ for hook in handlers:
114
+ if not isinstance(hook, dict):
115
+ return False
116
+ command = hook.get("command", "")
117
+ if not any(script in command for script in RETIRED_HOOK_SCRIPTS):
118
+ return False
119
+ return True
120
+
121
+
94
122
  def _entry_signature(entry: dict) -> tuple:
95
123
  """Return the behavior-defining parts of a hook entry.
96
124
 
@@ -112,7 +140,7 @@ def _entry_signature(entry: dict) -> tuple:
112
140
 
113
141
 
114
142
  def strip_toolkit(hooks: dict, toolkit_hooks: dict | None = None) -> dict:
115
- """Remove entries tagged with ai-toolkit or matching legacy toolkit hooks."""
143
+ """Remove ai-toolkit entries: tagged, legacy-signature, or retired script."""
116
144
  legacy_signatures: dict[str, set[tuple]] = {}
117
145
  if toolkit_hooks:
118
146
  for event, entries in toolkit_hooks.items():
@@ -137,7 +165,10 @@ def strip_toolkit(hooks: dict, toolkit_hooks: dict | None = None) -> dict:
137
165
  if not _is_toolkit_entry(e)
138
166
  and not (
139
167
  isinstance(e, dict)
140
- and _entry_signature(e) in signatures
168
+ and (
169
+ _entry_signature(e) in signatures
170
+ or _is_retired_toolkit_entry(e)
171
+ )
141
172
  )
142
173
  ]
143
174
  if filtered: