@softspark/ai-toolkit 4.16.1 → 4.18.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 (63) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +13 -19
  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/app/plugins/README.md +16 -4
  7. package/app/plugins/rtk-pack/README.md +123 -0
  8. package/app/plugins/rtk-pack/hooks/rewrite.sh +79 -0
  9. package/app/plugins/rtk-pack/plugin.json +60 -0
  10. package/app/plugins/rtk-pack/scripts/init.py +252 -0
  11. package/app/plugins/rtk-pack/scripts/status.py +105 -0
  12. package/benchmarks/ecosystem-doctor-snapshot.json +14 -15
  13. package/bin/ai-toolkit.js +15 -2
  14. package/kb/history/completed/output-filter-retirement-20260726.md +128 -0
  15. package/kb/history/completed/rtk-pack-integration-20260726.md +710 -0
  16. package/kb/procedures/maintenance-sop.md +1 -1
  17. package/kb/procedures/release-preparation-sop.md +8 -3
  18. package/kb/procedures/rtk-upstream-sync-sop.md +279 -0
  19. package/kb/reference/architecture-overview.md +2 -3
  20. package/kb/reference/cli-reference.md +4 -14
  21. package/kb/reference/enterprise-config-guide.md +1 -21
  22. package/kb/reference/hooks-catalog.md +3 -60
  23. package/kb/reference/plugin-pack-conventions.md +17 -2
  24. package/kb/reference/supported-tools-registry.md +0 -4
  25. package/llms-full.txt +1171 -402
  26. package/llms.txt +3 -1
  27. package/manifest.json +147 -36
  28. package/package.json +1 -2
  29. package/scripts/audit_skills.py +21 -0
  30. package/scripts/claude_app.py +2 -21
  31. package/scripts/config_cli.py +4 -0
  32. package/scripts/config_merger.py +0 -17
  33. package/scripts/config_validator.py +11 -138
  34. package/scripts/doctor.py +3 -20
  35. package/scripts/install.py +2 -1
  36. package/scripts/install_steps/ai_tools.py +28 -99
  37. package/scripts/install_steps/hooks.py +26 -24
  38. package/scripts/merge-hooks.py +33 -2
  39. package/scripts/output_filter_retirement.py +395 -0
  40. package/scripts/plugin.py +136 -16
  41. package/scripts/schemas/ai-toolkit-config.schema.json +0 -60
  42. package/scripts/uninstall.py +13 -27
  43. package/scripts/verify_rtk_binary.py +335 -0
  44. package/app/hooks/filter-tool-output.sh +0 -76
  45. package/app/output-filter-policy.json +0 -15
  46. package/benchmarks/output-filter/README.md +0 -11
  47. package/benchmarks/output-filter/scenarios.json +0 -25
  48. package/kb/reference/tool-output-filter.md +0 -288
  49. package/scripts/benchmark_output_filter.py +0 -343
  50. package/scripts/output_filter_cli.py +0 -347
  51. package/scripts/output_filter_hook.py +0 -23
  52. package/scripts/tool_output_filter/__init__.py +0 -33
  53. package/scripts/tool_output_filter/contracts.py +0 -173
  54. package/scripts/tool_output_filter/engine.py +0 -260
  55. package/scripts/tool_output_filter/hook_runtime.py +0 -369
  56. package/scripts/tool_output_filter/input.py +0 -56
  57. package/scripts/tool_output_filter/invariants.py +0 -40
  58. package/scripts/tool_output_filter/policy.py +0 -153
  59. package/scripts/tool_output_filter/profiles/__init__.py +0 -68
  60. package/scripts/tool_output_filter/profiles/repeat_lines.py +0 -71
  61. package/scripts/tool_output_filter/profiles/tap_success.py +0 -154
  62. package/scripts/tool_output_filter/recovery.py +0 -846
  63. package/scripts/tool_output_filter/telemetry.py +0 -13
package/scripts/doctor.py CHANGED
@@ -75,7 +75,6 @@ EXPECTED_HOOKS = [
75
75
  "subagent-start.sh",
76
76
  "subagent-stop.sh",
77
77
  "session-end.sh",
78
- "filter-tool-output.sh",
79
78
  ]
80
79
 
81
80
  PLANNED_ASSETS = [
@@ -442,25 +441,9 @@ def check_generated_artifacts(dr: DiagResult, fix_mode: bool) -> None:
442
441
  else:
443
442
  dr.warn(f"{artifact} missing (run: ai-toolkit generate-all)")
444
443
 
445
- # Check if AGENTS.md is stale (agents added/removed since generation)
446
- agents_md = toolkit_dir / "AGENTS.md"
447
- if agents_md.is_file():
448
- agents_dir = toolkit_dir / "app" / "agents"
449
- actual_agents = sum(1 for f in agents_dir.glob("*.md") if f.is_file()) if agents_dir.is_dir() else 0
450
- content = agents_md.read_text(encoding="utf-8")
451
- mentioned_agents = len(re.findall(r"^### `", content, re.MULTILINE))
452
- if actual_agents != mentioned_agents:
453
- if fix_mode:
454
- result = subprocess.run(
455
- ["python3", str(toolkit_dir / "scripts" / "generate_agents_md.py")],
456
- capture_output=True,
457
- text=True,
458
- )
459
- if result.returncode == 0:
460
- agents_md.write_text(result.stdout, encoding="utf-8")
461
- dr.fixed(f"regenerated stale AGENTS.md ({mentioned_agents} -> {actual_agents} entries)")
462
- else:
463
- dr.warn(f"AGENTS.md may be stale: {mentioned_agents} entries vs {actual_agents} agent files")
444
+ # AGENTS.md no longer embeds the agent catalog (agents are discovered from
445
+ # their native directories), so there is no per-entry staleness to check —
446
+ # presence is covered by the generated-artifacts loop above.
464
447
 
465
448
  print()
466
449
 
@@ -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
 
@@ -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: