@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.
- package/CHANGELOG.md +41 -0
- package/README.md +11 -16
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/hooks/session-end.sh +1 -13
- package/app/hooks.json +0 -10
- package/benchmarks/ecosystem-doctor-snapshot.json +14 -15
- package/bin/ai-toolkit.js +0 -2
- package/kb/history/completed/output-filter-retirement-20260726.md +128 -0
- package/kb/reference/architecture-overview.md +2 -3
- package/kb/reference/cli-reference.md +3 -13
- package/kb/reference/enterprise-config-guide.md +1 -21
- package/kb/reference/hooks-catalog.md +3 -60
- package/kb/reference/supported-tools-registry.md +0 -4
- package/llms-full.txt +143 -395
- package/llms.txt +1 -1
- package/manifest.json +147 -36
- package/package.json +1 -2
- package/scripts/claude_app.py +2 -21
- package/scripts/config_cli.py +4 -0
- package/scripts/config_merger.py +0 -17
- package/scripts/config_validator.py +11 -138
- package/scripts/doctor.py +3 -20
- package/scripts/generate_copilot.py +35 -4
- package/scripts/install.py +7 -2
- package/scripts/install_steps/ai_tools.py +28 -99
- package/scripts/install_steps/hooks.py +26 -24
- package/scripts/merge-hooks.py +33 -2
- package/scripts/output_filter_retirement.py +395 -0
- package/scripts/schemas/ai-toolkit-config.schema.json +0 -60
- package/scripts/uninstall.py +13 -27
- package/app/hooks/filter-tool-output.sh +0 -76
- package/app/output-filter-policy.json +0 -15
- package/benchmarks/output-filter/README.md +0 -11
- package/benchmarks/output-filter/scenarios.json +0 -25
- package/kb/reference/tool-output-filter.md +0 -288
- package/scripts/benchmark_output_filter.py +0 -343
- package/scripts/output_filter_cli.py +0 -347
- package/scripts/output_filter_hook.py +0 -23
- package/scripts/tool_output_filter/__init__.py +0 -33
- package/scripts/tool_output_filter/contracts.py +0 -173
- package/scripts/tool_output_filter/engine.py +0 -260
- package/scripts/tool_output_filter/hook_runtime.py +0 -369
- package/scripts/tool_output_filter/input.py +0 -56
- package/scripts/tool_output_filter/invariants.py +0 -40
- package/scripts/tool_output_filter/policy.py +0 -153
- package/scripts/tool_output_filter/profiles/__init__.py +0 -68
- package/scripts/tool_output_filter/profiles/repeat_lines.py +0 -71
- package/scripts/tool_output_filter/profiles/tap_success.py +0 -154
- package/scripts/tool_output_filter/recovery.py +0 -846
- 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]
|
|
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(
|
|
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
|
|
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
|
-
|
|
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)
|
package/scripts/install.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
761
|
-
print(
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/merge-hooks.py
CHANGED
|
@@ -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
|
|
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
|
|
168
|
+
and (
|
|
169
|
+
_entry_signature(e) in signatures
|
|
170
|
+
or _is_retired_toolkit_entry(e)
|
|
171
|
+
)
|
|
141
172
|
)
|
|
142
173
|
]
|
|
143
174
|
if filtered:
|