@softspark/ai-toolkit 4.16.0 → 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 (50) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +11 -16
  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/generate_copilot.py +35 -4
  24. package/scripts/install.py +7 -2
  25. package/scripts/install_steps/ai_tools.py +28 -99
  26. package/scripts/install_steps/hooks.py +26 -24
  27. package/scripts/merge-hooks.py +33 -2
  28. package/scripts/output_filter_retirement.py +395 -0
  29. package/scripts/schemas/ai-toolkit-config.schema.json +0 -60
  30. package/scripts/uninstall.py +13 -27
  31. package/app/hooks/filter-tool-output.sh +0 -76
  32. package/app/output-filter-policy.json +0 -15
  33. package/benchmarks/output-filter/README.md +0 -11
  34. package/benchmarks/output-filter/scenarios.json +0 -25
  35. package/kb/reference/tool-output-filter.md +0 -288
  36. package/scripts/benchmark_output_filter.py +0 -343
  37. package/scripts/output_filter_cli.py +0 -347
  38. package/scripts/output_filter_hook.py +0 -23
  39. package/scripts/tool_output_filter/__init__.py +0 -33
  40. package/scripts/tool_output_filter/contracts.py +0 -173
  41. package/scripts/tool_output_filter/engine.py +0 -260
  42. package/scripts/tool_output_filter/hook_runtime.py +0 -369
  43. package/scripts/tool_output_filter/input.py +0 -56
  44. package/scripts/tool_output_filter/invariants.py +0 -40
  45. package/scripts/tool_output_filter/policy.py +0 -153
  46. package/scripts/tool_output_filter/profiles/__init__.py +0 -68
  47. package/scripts/tool_output_filter/profiles/repeat_lines.py +0 -71
  48. package/scripts/tool_output_filter/profiles/tap_success.py +0 -154
  49. package/scripts/tool_output_filter/recovery.py +0 -846
  50. package/scripts/tool_output_filter/telemetry.py +0 -13
@@ -974,6 +974,23 @@ def _is_managed_skill_dir(path: Path) -> bool:
974
974
  return path.is_dir() and not path.is_symlink() and _is_managed(skill_file)
975
975
 
976
976
 
977
+ def _is_skill_remnant(path: Path) -> bool:
978
+ """Asset-only leftover of a legacy managed skill.
979
+
980
+ Pre-manifest toolkit versions tracked only SKILL.md as managed, so their
981
+ cleanup removed SKILL.md and left reference/ and scripts/ assets behind.
982
+ Without SKILL.md the directory is not a functional Copilot skill, so it is
983
+ safe to rebuild in place; a directory that still has any SKILL.md (managed
984
+ or not) is never classified as a remnant.
985
+ """
986
+ return (
987
+ path.is_dir()
988
+ and not path.is_symlink()
989
+ and not (path / "SKILL.md").exists()
990
+ and not (path / "SKILL.md").is_symlink()
991
+ )
992
+
993
+
977
994
  def _managed_skill_paths(path: Path) -> set[Path]:
978
995
  manifest = path / SKILL_MANIFEST
979
996
  if not manifest.is_file() or manifest.is_symlink():
@@ -999,7 +1016,8 @@ def _has_user_skill_extras(path: Path) -> bool:
999
1016
 
1000
1017
 
1001
1018
  def _copy_user_skill_extras(existing: Path, staging: Path,
1002
- generated_paths: set[Path]) -> None:
1019
+ generated_paths: set[Path],
1020
+ *, skip_stale_assets: bool = False) -> None:
1003
1021
  """Preserve files a user added inside a previously managed skill."""
1004
1022
  old_managed = _managed_skill_paths(existing)
1005
1023
  for source in sorted(existing.rglob("*")):
@@ -1011,6 +1029,8 @@ def _copy_user_skill_extras(existing: Path, staging: Path,
1011
1029
  if not source.is_file():
1012
1030
  continue
1013
1031
  if relative in generated_paths:
1032
+ if skip_stale_assets:
1033
+ continue
1014
1034
  raise RuntimeError(
1015
1035
  f"Copilot skill update would overwrite a user asset: {source}"
1016
1036
  )
@@ -1046,7 +1066,10 @@ def _stage_skill(skills_root: Path, source_dir: Path,
1046
1066
  encoding="utf-8",
1047
1067
  )
1048
1068
  if existing is not None:
1049
- _copy_user_skill_extras(existing, staging, generated_paths)
1069
+ _copy_user_skill_extras(
1070
+ existing, staging, generated_paths,
1071
+ skip_stale_assets=_is_skill_remnant(existing),
1072
+ )
1050
1073
  return staging, name
1051
1074
  except Exception:
1052
1075
  shutil.rmtree(staging, ignore_errors=True)
@@ -1058,7 +1081,10 @@ def _replace_skill_dir(staging: Path, destination: Path) -> None:
1058
1081
  backup: Path | None = None
1059
1082
  try:
1060
1083
  if destination.exists():
1061
- if destination.is_symlink() or not _is_managed_skill_dir(destination):
1084
+ if destination.is_symlink() or not (
1085
+ _is_managed_skill_dir(destination)
1086
+ or _is_skill_remnant(destination)
1087
+ ):
1062
1088
  raise RuntimeError(
1063
1089
  f"Refusing user-owned Copilot skill collision: {destination}"
1064
1090
  )
@@ -1125,7 +1151,12 @@ def _sync_copilot_skills(customization_root: Path, *, label: str) -> None:
1125
1151
  expected_dirs.add(destination_name)
1126
1152
  existing = destination if destination.exists() else None
1127
1153
  if existing is not None and not _is_managed_skill_dir(existing):
1128
- raise RuntimeError(f"Refusing user-owned Copilot skill collision: {destination}")
1154
+ if not _is_skill_remnant(existing):
1155
+ raise RuntimeError(f"Refusing user-owned Copilot skill collision: {destination}")
1156
+ print(
1157
+ f"Note: rebuilding asset-only Copilot skill remnant '{destination}'",
1158
+ file=sys.stderr,
1159
+ )
1129
1160
  staging, rendered_name = _stage_skill(skill_root, source_dir, existing)
1130
1161
  if rendered_name != logical_name:
1131
1162
  shutil.rmtree(staging, ignore_errors=True)
@@ -51,7 +51,7 @@ from emission import agent_count as count_agents, skill_count as count_skills
51
51
 
52
52
  # Step modules
53
53
  from install_steps.symlinks import install_agents, install_skills, clean_legacy_commands
54
- from install_steps.hooks import install_hooks
54
+ from install_steps.hooks import cleanup_retired_output_filter, install_hooks
55
55
  from install_steps.markers import install_marker_files, inject_rules, refresh_url_hooks, refresh_url_mcp
56
56
  from install_steps.ai_tools import install_ai_tools, install_local_project, run_script
57
57
  from install_steps.install_state import (
@@ -435,6 +435,7 @@ def install_claude_code(target_dir: Path, hooks_scripts_dir: Path,
435
435
  install_agents(claude_dir, only, skip, dry_run)
436
436
  install_skills(claude_dir, only, skip, dry_run)
437
437
  clean_legacy_commands(claude_dir, dry_run)
438
+ cleanup_retired_output_filter(hooks_scripts_dir, dry_run)
438
439
  install_hooks(claude_dir, hooks_scripts_dir, only, skip, dry_run)
439
440
  install_marker_files(claude_dir, only, skip, dry_run)
440
441
 
@@ -841,4 +842,8 @@ def _infer_modules_from_legacy(profile: str, only: str) -> list[str]:
841
842
 
842
843
 
843
844
  if __name__ == "__main__":
844
- main()
845
+ try:
846
+ main()
847
+ except RuntimeError as error:
848
+ print(f"ERROR: {error}", file=sys.stderr)
849
+ sys.exit(1)
@@ -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: