@softspark/ai-toolkit 4.15.0 → 4.16.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 (76) hide show
  1. package/AGENTS.md +117 -0
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +19 -13
  4. package/app/.claude-plugin/plugin.json +1 -1
  5. package/app/ARCHITECTURE.md +4 -3
  6. package/app/hooks/_hook-io.sh +18 -3
  7. package/app/hooks/ai-toolkit-statusline.sh +30 -5
  8. package/app/hooks/filter-tool-output.sh +76 -0
  9. package/app/hooks/governance-capture.sh +1 -1
  10. package/app/hooks/guard-path.sh +2 -2
  11. package/app/hooks/post-tool-use.sh +5 -3
  12. package/app/hooks/pre-compact-save.sh +4 -3
  13. package/app/hooks/quality-gate.sh +12 -1
  14. package/app/hooks/revert-guard.sh +5 -2
  15. package/app/hooks/save-session.sh +4 -2
  16. package/app/hooks/session-end.sh +36 -4
  17. package/app/hooks/session-start.sh +11 -5
  18. package/app/hooks.json +10 -0
  19. package/app/output-filter-policy.json +15 -0
  20. package/app/skills/brand-voice/scripts/measure.py +7 -5
  21. package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
  22. package/benchmarks/output-filter/README.md +11 -0
  23. package/benchmarks/output-filter/scenarios.json +25 -0
  24. package/bin/ai-toolkit.js +2 -0
  25. package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
  26. package/kb/procedures/release-preparation-sop.md +6 -5
  27. package/kb/reference/architecture-overview.md +6 -5
  28. package/kb/reference/cli-reference.md +19 -2
  29. package/kb/reference/codex-cli-compatibility.md +1 -0
  30. package/kb/reference/copilot-compatibility.md +173 -0
  31. package/kb/reference/enterprise-config-guide.md +28 -2
  32. package/kb/reference/global-install-model.md +6 -2
  33. package/kb/reference/hooks-catalog.md +105 -16
  34. package/kb/reference/opencode-compatibility.md +1 -0
  35. package/kb/reference/supported-tools-registry.md +10 -5
  36. package/kb/reference/tool-output-filter.md +288 -0
  37. package/kb/reference/windows-support.md +4 -3
  38. package/llms-full.txt +1182 -40
  39. package/llms.txt +3 -0
  40. package/manifest.json +9 -6
  41. package/package.json +3 -2
  42. package/scripts/benchmark_output_filter.py +343 -0
  43. package/scripts/check_deps.py +16 -0
  44. package/scripts/claude_app.py +30 -2
  45. package/scripts/config_cli.py +4 -4
  46. package/scripts/config_lock.py +120 -14
  47. package/scripts/config_merger.py +103 -20
  48. package/scripts/config_resolver.py +22 -2
  49. package/scripts/config_validator.py +268 -16
  50. package/scripts/copilot_legacy_hashes.json +338 -0
  51. package/scripts/doctor.py +1 -0
  52. package/scripts/generate_codex_hooks.py +2 -0
  53. package/scripts/generate_copilot.py +464 -71
  54. package/scripts/generate_copilot_hooks.py +124 -7
  55. package/scripts/generate_gemini_hooks.py +33 -10
  56. package/scripts/generate_opencode_plugin.py +28 -12
  57. package/scripts/install_steps/ai_tools.py +115 -3
  58. package/scripts/install_steps/hooks.py +25 -1
  59. package/scripts/output_filter_cli.py +347 -0
  60. package/scripts/output_filter_hook.py +23 -0
  61. package/scripts/plugin_schema.py +27 -1
  62. package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
  63. package/scripts/session_state.py +156 -42
  64. package/scripts/tool_output_filter/__init__.py +33 -0
  65. package/scripts/tool_output_filter/contracts.py +173 -0
  66. package/scripts/tool_output_filter/engine.py +260 -0
  67. package/scripts/tool_output_filter/hook_runtime.py +369 -0
  68. package/scripts/tool_output_filter/input.py +56 -0
  69. package/scripts/tool_output_filter/invariants.py +40 -0
  70. package/scripts/tool_output_filter/policy.py +153 -0
  71. package/scripts/tool_output_filter/profiles/__init__.py +68 -0
  72. package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
  73. package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
  74. package/scripts/tool_output_filter/recovery.py +846 -0
  75. package/scripts/tool_output_filter/telemetry.py +13 -0
  76. package/scripts/uninstall.py +96 -3
@@ -18,6 +18,9 @@ import tempfile
18
18
  from pathlib import Path
19
19
  from typing import Any
20
20
 
21
+ import secure_fs
22
+ from secure_fs import SecureDestination, run_secure_transaction
23
+
21
24
 
22
25
  OWNER_KEY = "AI_TOOLKIT_HOOK_OWNER"
23
26
  OWNER_VALUE = "ai-toolkit"
@@ -433,13 +436,11 @@ def _validate_entry(event: str, entry: Any) -> None:
433
436
  raise ValueError(f"Copilot {event} {key} must be positive")
434
437
 
435
438
 
436
- def _is_managed_config(path: Path) -> bool:
437
- if path.is_symlink() or not path.is_file():
438
- return False
439
+ def _is_managed_config_content(content: bytes) -> bool:
439
440
  try:
440
- data = json.loads(path.read_text(encoding="utf-8"))
441
+ data = json.loads(content.decode("utf-8"))
441
442
  _validate_document(data)
442
- except (OSError, json.JSONDecodeError, ValueError):
443
+ except (UnicodeError, json.JSONDecodeError, ValueError):
443
444
  return False
444
445
  entries = [entry for values in data["hooks"].values() for entry in values]
445
446
  return bool(entries) and all(
@@ -448,12 +449,28 @@ def _is_managed_config(path: Path) -> bool:
448
449
  )
449
450
 
450
451
 
452
+ def _is_managed_config(path: Path) -> bool:
453
+ if path.is_symlink() or not path.is_file():
454
+ return False
455
+ try:
456
+ return _is_managed_config_content(path.read_bytes())
457
+ except OSError:
458
+ return False
459
+
460
+
461
+ def _is_managed_script_content(content: bytes) -> bool:
462
+ try:
463
+ return SCRIPT_MARKER in content[:256].decode("utf-8")
464
+ except UnicodeError:
465
+ return False
466
+
467
+
451
468
  def _is_managed_script(path: Path) -> bool:
452
469
  if path.is_symlink() or not path.is_file():
453
470
  return False
454
471
  try:
455
- return SCRIPT_MARKER in path.read_text(encoding="utf-8")[:256]
456
- except (OSError, UnicodeError):
472
+ return _is_managed_script_content(path.read_bytes())
473
+ except OSError:
457
474
  return False
458
475
 
459
476
 
@@ -553,6 +570,106 @@ def copilot_home(home: Path | None = None) -> Path:
553
570
  return base / ".copilot"
554
571
 
555
572
 
573
+ def _cleanup_targets(
574
+ target_dir: Path,
575
+ config_root: Path | None,
576
+ ) -> tuple[Path, Path, Path, list[SecureDestination]]:
577
+ """Resolve and validate every hook-cleanup destination without mutation."""
578
+ target_dir = Path(target_dir).expanduser().absolute()
579
+ project_install = config_root is None
580
+ customization_root = (
581
+ target_dir / ".github"
582
+ if project_install
583
+ else Path(config_root).expanduser().absolute()
584
+ )
585
+ hooks_dir = customization_root / "hooks"
586
+ assets_dir = hooks_dir / "ai-toolkit"
587
+ config_path = hooks_dir / CONFIG_NAME
588
+ script_path = assets_dir / SCRIPT_NAME
589
+ _assert_safe_paths([
590
+ (customization_root, "customization root"),
591
+ (hooks_dir, "hooks directory"),
592
+ (assets_dir, "hook assets directory"),
593
+ (config_path, "hook config"),
594
+ (script_path, "hook runtime"),
595
+ ])
596
+ existing_paths: list[tuple[Path, str]] = []
597
+ if config_path.exists():
598
+ existing_paths.append((config_path, "hook config"))
599
+ if script_path.exists():
600
+ existing_paths.append((script_path, "hook runtime"))
601
+ trusted_root = target_dir if project_install else customization_root
602
+ destinations = [
603
+ SecureDestination(path, trusted_root, f"Copilot {label}")
604
+ for path, label in existing_paths
605
+ ]
606
+ return hooks_dir, config_path, script_path, destinations
607
+
608
+
609
+ def _require_secure_cleanup() -> None:
610
+ if secure_fs.SECURE_DIR_FD:
611
+ return
612
+ raise RuntimeError(
613
+ "Copilot hook cleanup requires POSIX dir_fd and O_NOFOLLOW; "
614
+ "No files were changed"
615
+ )
616
+
617
+
618
+ def preflight_cleanup(
619
+ target_dir: Path,
620
+ *,
621
+ config_root: Path | None = None,
622
+ ) -> None:
623
+ """Fail before installer mutations when hook cleanup cannot run safely."""
624
+ _, _, _, destinations = _cleanup_targets(target_dir, config_root)
625
+ if not destinations:
626
+ return
627
+ _require_secure_cleanup()
628
+ run_secure_transaction(destinations, lambda _transaction: None)
629
+
630
+
631
+ def cleanup(target_dir: Path, *, config_root: Path | None = None) -> None:
632
+ """Remove only the managed Copilot hook bundle for a profile downgrade."""
633
+ hooks_dir, config_path, script_path, destinations = _cleanup_targets(
634
+ target_dir,
635
+ config_root,
636
+ )
637
+ if not destinations:
638
+ return
639
+ _require_secure_cleanup()
640
+
641
+ def remove_managed(transaction) -> bool:
642
+ contents = {
643
+ destination.path: transaction.initial_content(destination)
644
+ for destination in destinations
645
+ }
646
+ config_content = contents.get(config_path)
647
+ script_content = contents.get(script_path)
648
+ config_owned = (
649
+ config_content is None or _is_managed_config_content(config_content)
650
+ )
651
+ script_owned = (
652
+ script_content is None or _is_managed_script_content(script_content)
653
+ )
654
+ if not config_owned or not script_owned:
655
+ print(
656
+ f"Warning: preserving user-owned Copilot hook bundle at '{hooks_dir}'",
657
+ file=sys.stderr,
658
+ )
659
+ return False
660
+ for destination in destinations:
661
+ transaction.unlink(destination)
662
+ return True
663
+
664
+ if run_secure_transaction(destinations, remove_managed):
665
+ label = (
666
+ ".github/hooks"
667
+ if config_root is None
668
+ else str(hooks_dir)
669
+ )
670
+ print(f" Removed managed: {label}/{CONFIG_NAME}")
671
+
672
+
556
673
  def generate(target_dir: Path, *, config_root: Path | None = None) -> None:
557
674
  """Generate repository hooks, or user hooks when ``config_root`` is set."""
558
675
  target_dir = Path(target_dir).expanduser().absolute()
@@ -26,7 +26,9 @@ Writes `<target-dir>/.gemini/settings.json` (merge-safe, idempotent).
26
26
  from __future__ import annotations
27
27
 
28
28
  import json
29
+ import os
29
30
  import sys
31
+ import tempfile
30
32
  from pathlib import Path
31
33
 
32
34
  HOOKS_PREFIX = 'AI_TOOLKIT_HOOK_FORMAT=json "$HOME/.softspark/ai-toolkit/hooks/'
@@ -121,28 +123,49 @@ def merge_hooks(existing_hooks: dict, toolkit_hooks: dict) -> dict:
121
123
  return merged
122
124
 
123
125
 
126
+ def _write_settings_atomic(settings_path: Path, settings: dict) -> None:
127
+ temp_path: Path | None = None
128
+ try:
129
+ with tempfile.NamedTemporaryFile(
130
+ mode="w",
131
+ dir=settings_path.parent,
132
+ prefix=f".{settings_path.name}.",
133
+ encoding="utf-8",
134
+ delete=False,
135
+ ) as temp_file:
136
+ json.dump(settings, temp_file, indent=4, ensure_ascii=False, sort_keys=True)
137
+ temp_file.write("\n")
138
+ temp_file.flush()
139
+ os.fsync(temp_file.fileno())
140
+ temp_path = Path(temp_file.name)
141
+ os.replace(temp_path, settings_path)
142
+ temp_path = None
143
+ finally:
144
+ if temp_path is not None:
145
+ temp_path.unlink(missing_ok=True)
146
+
147
+
124
148
  def generate(target_dir: Path) -> Path:
125
149
  """Write `<target_dir>/.gemini/settings.json` and return its path."""
126
150
  gemini_dir = target_dir / ".gemini"
151
+ if gemini_dir.is_symlink():
152
+ raise RuntimeError(f"Refusing symlinked Gemini directory: {gemini_dir}")
127
153
  gemini_dir.mkdir(parents=True, exist_ok=True)
128
154
  settings_path = gemini_dir / "settings.json"
155
+ if settings_path.is_symlink():
156
+ raise RuntimeError(f"Refusing symlinked Gemini settings: {settings_path}")
129
157
 
130
158
  settings: dict = {}
131
159
  if settings_path.is_file():
132
- try:
133
- with open(settings_path, encoding="utf-8") as f:
134
- settings = json.load(f)
135
- if not isinstance(settings, dict):
136
- settings = {}
137
- except (json.JSONDecodeError, OSError):
138
- settings = {}
160
+ with open(settings_path, encoding="utf-8") as f:
161
+ settings = json.load(f)
162
+ if not isinstance(settings, dict):
163
+ raise ValueError(f"{settings_path} must contain a JSON object")
139
164
 
140
165
  existing_hooks = settings.get("hooks") if isinstance(settings.get("hooks"), dict) else {}
141
166
  settings["hooks"] = merge_hooks(existing_hooks or {}, build_toolkit_hooks())
142
167
 
143
- with open(settings_path, "w", encoding="utf-8") as f:
144
- json.dump(settings, f, indent=4, ensure_ascii=False, sort_keys=True)
145
- f.write("\n")
168
+ _write_settings_atomic(settings_path, settings)
146
169
  return settings_path
147
170
 
148
171
 
@@ -32,7 +32,6 @@ Usage:
32
32
  from __future__ import annotations
33
33
 
34
34
  import argparse
35
- import sys
36
35
  from pathlib import Path
37
36
 
38
37
  PLUGIN_BODY = r"""// ai-toolkit opencode plugin — bridges shared Bash hooks to opencode events.
@@ -49,8 +48,9 @@ PLUGIN_BODY = r"""// ai-toolkit opencode plugin — bridges shared Bash hooks to
49
48
  const HOOKS_DIR = `${process.env.HOME}/.softspark/ai-toolkit/hooks`;
50
49
 
51
50
  /** Invoke a Bash hook with a JSON payload on stdin. */
52
- async function runHook($, script, payload) {
51
+ async function runHook($, script, payload, blockOnExit2 = false) {
53
52
  const scriptPath = `${HOOKS_DIR}/${script}`;
53
+ let result;
54
54
  try {
55
55
  const input = JSON.stringify(payload ?? {});
56
56
  const proc = $`bash ${scriptPath}`.env({
@@ -59,23 +59,34 @@ async function runHook($, script, payload) {
59
59
  });
60
60
  proc.stdin.write(input);
61
61
  proc.stdin.end();
62
- const result = await proc.quiet().nothrow();
63
- if (result.exitCode !== 0 && result.exitCode !== 2) {
64
- // Exit 2 is the toolkit's "block" signal — pass through to opencode as a guard.
65
- process.stderr.write(
66
- `[ai-toolkit] ${script} exited ${result.exitCode}\n${result.stderr.toString()}`
67
- );
68
- }
69
- return result.exitCode;
62
+ result = await proc.quiet().nothrow();
70
63
  } catch (err) {
71
64
  process.stderr.write(`[ai-toolkit] failed to run ${script}: ${err.message}\n`);
72
65
  return 0;
73
66
  }
67
+ if (result.exitCode === 2 && blockOnExit2) {
68
+ const detail = result.stderr.toString().trim();
69
+ throw new Error(
70
+ `[ai-toolkit] ${script} blocked tool execution${detail ? `: ${detail}` : ""}`
71
+ );
72
+ }
73
+ if (result.exitCode !== 0 && result.exitCode !== 2) {
74
+ process.stderr.write(
75
+ `[ai-toolkit] ${script} exited ${result.exitCode}\n${result.stderr.toString()}`
76
+ );
77
+ }
78
+ return result.exitCode;
74
79
  }
75
80
 
76
81
  export const AiToolkitHooks = async ({ $, project, directory, worktree }) => ({
77
82
  event: async ({ event }) => {
78
- const payload = { event: event.type, project, directory, worktree };
83
+ const payload = {
84
+ event: event.type,
85
+ session_id: event?.properties?.sessionID,
86
+ project,
87
+ directory,
88
+ worktree,
89
+ };
79
90
  switch (event.type) {
80
91
  case "session.created":
81
92
  await runHook($, "session-start.sh", payload);
@@ -106,12 +117,15 @@ export const AiToolkitHooks = async ({ $, project, directory, worktree }) => ({
106
117
  "tool.execute.before": async (input, output) => {
107
118
  const payload = {
108
119
  event: "tool.execute.before",
120
+ session_id: input?.sessionID,
121
+ tool_name: input?.tool,
122
+ tool_input: output?.args,
109
123
  tool: input?.tool,
110
124
  args: output?.args,
111
125
  project,
112
126
  };
113
127
  if (input?.tool === "bash") {
114
- await runHook($, "guard-destructive.sh", payload);
128
+ await runHook($, "guard-destructive.sh", payload, true);
115
129
  await runHook($, "commit-quality.sh", payload);
116
130
  }
117
131
  },
@@ -119,6 +133,8 @@ export const AiToolkitHooks = async ({ $, project, directory, worktree }) => ({
119
133
  "tool.execute.after": async (input, output) => {
120
134
  const payload = {
121
135
  event: "tool.execute.after",
136
+ session_id: input?.sessionID,
137
+ tool_name: input?.tool,
122
138
  tool: input?.tool,
123
139
  result: output,
124
140
  project,
@@ -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
 
@@ -1267,6 +1366,16 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
1267
1366
  emit_pointer = not _claude_skills_discoverable(cwd)
1268
1367
 
1269
1368
  if "copilot" in eds:
1369
+ from generate_copilot import (
1370
+ generate as gen_copilot_dir,
1371
+ preflight_cleanup as preflight_copilot_cleanup,
1372
+ )
1373
+ if not add_copilot_dir:
1374
+ from generate_copilot_hooks import (
1375
+ preflight_cleanup as preflight_copilot_hook_cleanup,
1376
+ )
1377
+ preflight_copilot_cleanup(cwd)
1378
+ preflight_copilot_hook_cleanup(cwd)
1270
1379
  inject_with_rules(
1271
1380
  "generate-copilot.sh",
1272
1381
  cwd / ".github" / "copilot-instructions.md",
@@ -1275,16 +1384,19 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
1275
1384
  _install_copilot_agents_md(cwd, rules_dir)
1276
1385
  # Agents and skills are the minimal Copilot surface. Standard and above
1277
1386
  # add path instructions, prompts, and native lifecycle hooks.
1278
- from generate_copilot import generate as gen_copilot_dir
1279
1387
  gen_copilot_dir(
1280
1388
  cwd,
1281
1389
  language_modules=language_modules,
1282
1390
  rules_dir=rules_dir,
1283
1391
  emit_prompts=add_copilot_dir,
1284
1392
  emit_instructions=add_copilot_dir,
1393
+ cleanup_disabled=not add_copilot_dir,
1285
1394
  )
1286
1395
  if add_copilot_dir:
1287
1396
  _try_generator("generate_copilot_hooks", cwd)
1397
+ else:
1398
+ from generate_copilot_hooks import cleanup as cleanup_copilot_hooks
1399
+ cleanup_copilot_hooks(cwd)
1288
1400
 
1289
1401
  if "cursor" in eds:
1290
1402
  inject_with_rules(
@@ -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: