@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
@@ -0,0 +1,395 @@
1
+ """Retirement cleanup for the v4.16.x native tool-output filter.
2
+
3
+ v4.16.0 and v4.16.1 installed a PostToolUse filter that wrote files outside the
4
+ npm package: a hook script, a global policy, per-project policy files, and
5
+ private recovery trees that can hold captured command output. v4.17.0 removed
6
+ the feature, so those files are orphaned on every machine that ran the old
7
+ releases. ``install``/``update`` and ``uninstall`` both reclaim them here.
8
+
9
+ The implementation is deliberately self-contained. The package that wrote these
10
+ files (``scripts/tool_output_filter/``) no longer exists in the repository, so
11
+ the ownership rules it enforced are re-stated as literal constants instead of
12
+ being imported. Nothing is removed without matching one of those rules, and no
13
+ path is ever opened through a symlink.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import stat
20
+ from pathlib import Path
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Ownership constants copied from the removed v4.16.x runtime
24
+ # ---------------------------------------------------------------------------
25
+
26
+ HOOK_SCRIPT_NAME = "filter-tool-output.sh"
27
+ HOOK_SCRIPT_MARKER = b"# Claude PostToolUse adapter for the native tool-output filter."
28
+
29
+ GLOBAL_POLICY_NAME = "output-filter-policy.json"
30
+
31
+ PROJECT_POLICY_NAME = "ai-toolkit-output-filter.json"
32
+ PROJECT_OWNER_NAME = ".ai-toolkit-output-filter.owner"
33
+ PROJECT_OWNER_MARKER = b"ai-toolkit-output-filter-policy-v1\n"
34
+
35
+ RUNTIME_SCRIPTS: tuple[tuple[str, bytes], ...] = (
36
+ (
37
+ "output_filter_hook.py",
38
+ b'"""Lean process entry point for the Claude output-filter hook."""',
39
+ ),
40
+ (
41
+ "output_filter_cli.py",
42
+ b'"""Manual and hook entry points for native tool-output filtering."""',
43
+ ),
44
+ )
45
+
46
+ RUNTIME_PACKAGE_NAME = "tool_output_filter"
47
+ RUNTIME_PACKAGE_MARKER = b'"""Dependency-free post-execution tool-output filtering."""'
48
+ _PACKAGE_MODULES: tuple[str, ...] = (
49
+ "__init__.py",
50
+ "contracts.py",
51
+ "engine.py",
52
+ "hook_runtime.py",
53
+ "input.py",
54
+ "invariants.py",
55
+ "policy.py",
56
+ "recovery.py",
57
+ "telemetry.py",
58
+ )
59
+ _PACKAGE_SUBPACKAGES: dict[str, tuple[str, ...]] = {
60
+ "profiles": ("__init__.py", "repeat_lines.py", "tap_success.py"),
61
+ }
62
+
63
+ _RECOVERY_DIRECTORY_NAME = "output-filter"
64
+ _RECOVERY_TOKEN_LENGTH = 32
65
+ _RECOVERY_CIRCUIT_STATE_NAME = ".circuit-state.json"
66
+ _RECOVERY_TELEMETRY_NAME = ".telemetry.jsonl"
67
+ _RECOVERY_PENDING_PREFIX = ".pending-"
68
+ _RECOVERY_DIRECTORY_FLAGS = (
69
+ os.O_RDONLY
70
+ | getattr(os, "O_CLOEXEC", 0)
71
+ | getattr(os, "O_DIRECTORY", 0)
72
+ | getattr(os, "O_NOFOLLOW", 0)
73
+ )
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Recovery trees (sessions/<repo-key>/output-filter/<session>/)
78
+ # ---------------------------------------------------------------------------
79
+
80
+ def _is_recovery_token(name: str) -> bool:
81
+ return (
82
+ len(name) == _RECOVERY_TOKEN_LENGTH
83
+ and all(character in "0123456789abcdef" for character in name)
84
+ )
85
+
86
+
87
+ def _is_recovery_artifact_name(name: str) -> bool:
88
+ """Match only the artifact names the removed filter could have written."""
89
+ if name in {_RECOVERY_CIRCUIT_STATE_NAME, _RECOVERY_TELEMETRY_NAME}:
90
+ return True
91
+ if name.endswith(".json") and _is_recovery_token(name[:-len(".json")]):
92
+ return True
93
+ return name.startswith(_RECOVERY_PENDING_PREFIX) and _is_recovery_token(
94
+ name[len(_RECOVERY_PENDING_PREFIX):]
95
+ )
96
+
97
+
98
+ def _assert_private_recovery_directory(directory_fd: int, label: str) -> None:
99
+ metadata = os.fstat(directory_fd)
100
+ if not stat.S_ISDIR(metadata.st_mode):
101
+ raise RuntimeError(f"{label} is not a directory")
102
+ if stat.S_IMODE(metadata.st_mode) & 0o077:
103
+ raise RuntimeError(f"{label} is not private")
104
+ if metadata.st_uid != os.getuid():
105
+ raise RuntimeError(f"{label} is not owned by this user")
106
+
107
+
108
+ def _open_recovery_directory(parent_fd: int, name: str, label: str) -> int:
109
+ metadata = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
110
+ if not stat.S_ISDIR(metadata.st_mode):
111
+ raise RuntimeError(f"{label} is not a directory")
112
+ return os.open(name, _RECOVERY_DIRECTORY_FLAGS, dir_fd=parent_fd)
113
+
114
+
115
+ def _scan_recovery_session(session_fd: int) -> tuple[str, ...]:
116
+ """Return validated artifact names, refusing anything not owner-private."""
117
+ _assert_private_recovery_directory(session_fd, "owned recovery session")
118
+ artifacts = tuple(
119
+ name for name in os.listdir(session_fd) if _is_recovery_artifact_name(name)
120
+ )
121
+ for artifact_name in artifacts:
122
+ metadata = os.stat(artifact_name, dir_fd=session_fd, follow_symlinks=False)
123
+ if not stat.S_ISREG(metadata.st_mode):
124
+ raise RuntimeError("owned recovery artifact is not regular")
125
+ if stat.S_IMODE(metadata.st_mode) != 0o600:
126
+ raise RuntimeError("owned recovery artifact is not private")
127
+ return artifacts
128
+
129
+
130
+ def _walk_recovery_tree(sessions_root: Path, *, delete: bool) -> int:
131
+ """Validate every repo tree, optionally unlinking the owned artifacts.
132
+
133
+ Counting validates the whole tree before ``delete`` ever runs, so an unsafe
134
+ symlink anywhere aborts the cleanup before the first unlink.
135
+ """
136
+ try:
137
+ root_fd = os.open(sessions_root, _RECOVERY_DIRECTORY_FLAGS)
138
+ except FileNotFoundError:
139
+ return 0
140
+ except OSError as error:
141
+ raise RuntimeError(f"recovery base is unavailable: {error}") from error
142
+ total = 0
143
+ try:
144
+ for repo_name in sorted(os.listdir(root_fd)):
145
+ try:
146
+ metadata = os.stat(repo_name, dir_fd=root_fd, follow_symlinks=False)
147
+ except FileNotFoundError:
148
+ continue
149
+ if not stat.S_ISDIR(metadata.st_mode):
150
+ continue
151
+ repo_fd = os.open(repo_name, _RECOVERY_DIRECTORY_FLAGS, dir_fd=root_fd)
152
+ try:
153
+ total += _walk_recovery_repo(repo_fd, delete=delete)
154
+ finally:
155
+ os.close(repo_fd)
156
+ finally:
157
+ os.close(root_fd)
158
+ return total
159
+
160
+
161
+ def _walk_recovery_repo(repo_fd: int, *, delete: bool) -> int:
162
+ try:
163
+ output_fd = _open_recovery_directory(
164
+ repo_fd,
165
+ _RECOVERY_DIRECTORY_NAME,
166
+ "owned output-filter directory",
167
+ )
168
+ except FileNotFoundError:
169
+ return 0
170
+ total = 0
171
+ try:
172
+ _assert_private_recovery_directory(
173
+ output_fd,
174
+ "owned output-filter directory",
175
+ )
176
+ for session_name in sorted(os.listdir(output_fd)):
177
+ if not _is_recovery_token(session_name):
178
+ continue
179
+ session_fd = _open_recovery_directory(
180
+ output_fd,
181
+ session_name,
182
+ "owned recovery session",
183
+ )
184
+ try:
185
+ artifacts = _scan_recovery_session(session_fd)
186
+ total += len(artifacts)
187
+ if not delete:
188
+ continue
189
+ for artifact_name in artifacts:
190
+ os.unlink(artifact_name, dir_fd=session_fd)
191
+ if artifacts:
192
+ os.fsync(session_fd)
193
+ finally:
194
+ os.close(session_fd)
195
+ if delete:
196
+ # Foreign content keeps the directory: rmdir fails, we move on.
197
+ try:
198
+ os.rmdir(session_name, dir_fd=output_fd)
199
+ except OSError:
200
+ pass
201
+ finally:
202
+ os.close(output_fd)
203
+ if delete:
204
+ try:
205
+ os.rmdir(_RECOVERY_DIRECTORY_NAME, dir_fd=repo_fd)
206
+ except OSError:
207
+ pass
208
+ return total
209
+
210
+
211
+ def count_owned_recovery_artifacts(sessions_root: Path) -> int:
212
+ """Count validated leftover filter artifacts without mutating the tree."""
213
+ return _walk_recovery_tree(sessions_root, delete=False)
214
+
215
+
216
+ def clean_owned_recovery_tree(sessions_root: Path) -> int:
217
+ """Delete validated leftover filter artifacts below all repo sessions."""
218
+ return _walk_recovery_tree(sessions_root, delete=True)
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Per-project policy pair (<project>/.claude/)
223
+ # ---------------------------------------------------------------------------
224
+
225
+ def managed_project_policy(claude_dir: Path) -> list[Path]:
226
+ """Return the managed per-project policy pair, or [] when unmanaged.
227
+
228
+ The owner marker is the ownership test: without it the policy file was
229
+ hand-written by the user and stays put.
230
+ """
231
+ if claude_dir.is_symlink():
232
+ return []
233
+ policy = claude_dir / PROJECT_POLICY_NAME
234
+ owner = claude_dir / PROJECT_OWNER_NAME
235
+ if owner.is_symlink() or not owner.is_file():
236
+ return []
237
+ try:
238
+ if owner.read_bytes() != PROJECT_OWNER_MARKER:
239
+ return []
240
+ except OSError:
241
+ return []
242
+ managed = [owner]
243
+ if not policy.is_symlink() and policy.is_file():
244
+ managed.insert(0, policy)
245
+ return managed
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Global artifacts (~/.softspark/ai-toolkit/)
250
+ # ---------------------------------------------------------------------------
251
+
252
+ def _owned_regular_file(path: Path, marker: bytes | None) -> bool:
253
+ """True only for a real file that still carries its shipped marker."""
254
+ if path.is_symlink() or not path.is_file():
255
+ return False
256
+ if marker is None:
257
+ return True
258
+ try:
259
+ return marker in path.read_bytes()
260
+ except OSError:
261
+ return False
262
+
263
+
264
+ def _owned_global_policy(path: Path) -> bool:
265
+ """True for the seeded global policy, including a user-edited mode."""
266
+ if path.is_symlink() or not path.is_file():
267
+ return False
268
+ try:
269
+ document = json.loads(path.read_text(encoding="utf-8"))
270
+ except (OSError, ValueError):
271
+ return False
272
+ return isinstance(document, dict) and "mode" in document
273
+
274
+
275
+ def _owned_package_root(package_root: Path) -> bool:
276
+ if package_root.is_symlink() or not package_root.is_dir():
277
+ return False
278
+ return _owned_regular_file(package_root / "__init__.py", RUNTIME_PACKAGE_MARKER)
279
+
280
+
281
+ def _remove_package_members(directory: Path, modules: tuple[str, ...]) -> None:
282
+ """Unlink known modules plus their bytecode, leaving anything else alone."""
283
+ stems = {name[: -len(".py")] for name in modules}
284
+ for name in modules:
285
+ member = directory / name
286
+ if not member.is_symlink() and member.is_file():
287
+ member.unlink()
288
+ cache = directory / "__pycache__"
289
+ if not cache.is_symlink() and cache.is_dir():
290
+ for compiled in sorted(cache.iterdir()):
291
+ if compiled.is_symlink() or not compiled.is_file():
292
+ continue
293
+ if not compiled.name.endswith(".pyc") or ".cpython-" not in compiled.name:
294
+ continue
295
+ if compiled.name.split(".", 1)[0] in stems:
296
+ compiled.unlink()
297
+ _rmdir_if_empty(cache)
298
+
299
+
300
+ def _rmdir_if_empty(directory: Path) -> None:
301
+ try:
302
+ directory.rmdir()
303
+ except OSError:
304
+ # Foreign content still lives here: leave the whole directory in place.
305
+ pass
306
+
307
+
308
+ def _global_artifact_paths(toolkit_data_dir: Path) -> list[tuple[Path, str]]:
309
+ """Return every verified global leftover as (path, human label)."""
310
+ hooks_dir = toolkit_data_dir / "hooks"
311
+ scripts_dir = toolkit_data_dir / "scripts"
312
+ found: list[tuple[Path, str]] = []
313
+
314
+ if not hooks_dir.is_symlink() and hooks_dir.is_dir():
315
+ hook_script = hooks_dir / HOOK_SCRIPT_NAME
316
+ if _owned_regular_file(hook_script, HOOK_SCRIPT_MARKER):
317
+ found.append((hook_script, f"hooks/{HOOK_SCRIPT_NAME}"))
318
+ global_policy = hooks_dir / GLOBAL_POLICY_NAME
319
+ if _owned_global_policy(global_policy):
320
+ found.append((global_policy, f"hooks/{GLOBAL_POLICY_NAME}"))
321
+
322
+ if not scripts_dir.is_symlink() and scripts_dir.is_dir():
323
+ for name, marker in RUNTIME_SCRIPTS:
324
+ runtime_script = scripts_dir / name
325
+ if _owned_regular_file(runtime_script, marker):
326
+ found.append((runtime_script, f"scripts/{name}"))
327
+ package_root = scripts_dir / RUNTIME_PACKAGE_NAME
328
+ if _owned_package_root(package_root):
329
+ found.append((package_root, f"scripts/{RUNTIME_PACKAGE_NAME}/"))
330
+
331
+ return found
332
+
333
+
334
+ def _recovery_label(artifacts: int) -> str:
335
+ noun = "file" if artifacts == 1 else "files"
336
+ return f"sessions/*/{_RECOVERY_DIRECTORY_NAME}/ ({artifacts} {noun})"
337
+
338
+
339
+ def find_global_artifacts(toolkit_data_dir: Path) -> list[str]:
340
+ """List labels for leftovers a cleanup run would remove. Never mutates."""
341
+ labels = [label for _, label in _global_artifact_paths(toolkit_data_dir)]
342
+ try:
343
+ artifacts = count_owned_recovery_artifacts(toolkit_data_dir / "sessions")
344
+ except (OSError, RuntimeError):
345
+ artifacts = 0
346
+ if artifacts:
347
+ labels.append(_recovery_label(artifacts))
348
+ return labels
349
+
350
+
351
+ def cleanup_global_artifacts(toolkit_data_dir: Path) -> tuple[list[str], list[str]]:
352
+ """Remove verified leftovers under the toolkit data directory.
353
+
354
+ Returns ``(removed_labels, warnings)``. Both are empty on a clean machine,
355
+ which keeps the caller silent when there is nothing to retire.
356
+ """
357
+ removed: list[str] = []
358
+ warnings: list[str] = []
359
+
360
+ for path, label in _global_artifact_paths(toolkit_data_dir):
361
+ try:
362
+ if path.is_dir():
363
+ _remove_package_members(path, _PACKAGE_MODULES)
364
+ for sub_name, sub_modules in _PACKAGE_SUBPACKAGES.items():
365
+ sub_directory = path / sub_name
366
+ if sub_directory.is_symlink() or not sub_directory.is_dir():
367
+ continue
368
+ _remove_package_members(sub_directory, sub_modules)
369
+ _rmdir_if_empty(sub_directory)
370
+ _rmdir_if_empty(path)
371
+ if path.exists():
372
+ warnings.append(
373
+ f"removed {label} modules but kept the directory "
374
+ "(unrecognized files inside)"
375
+ )
376
+ continue
377
+ else:
378
+ path.unlink()
379
+ except OSError as error:
380
+ warnings.append(f"kept {label} ({error})")
381
+ continue
382
+ removed.append(label)
383
+
384
+ sessions_root = toolkit_data_dir / "sessions"
385
+ try:
386
+ artifacts = clean_owned_recovery_tree(sessions_root)
387
+ except (OSError, RuntimeError) as error:
388
+ warnings.append(
389
+ f"kept sessions/*/{_RECOVERY_DIRECTORY_NAME}/ recovery data ({error})"
390
+ )
391
+ else:
392
+ if artifacts:
393
+ removed.append(_recovery_label(artifacts))
394
+
395
+ return removed, warnings
@@ -39,66 +39,6 @@
39
39
  "default": "standard",
40
40
  "description": "Installation profile controlling which modules are installed."
41
41
  },
42
- "toolOutputFilter": {
43
- "type": "object",
44
- "additionalProperties": false,
45
- "description": "Native post-execution Bash output filtering. Disabled by default.",
46
- "properties": {
47
- "mode": {
48
- "type": "string",
49
- "enum": ["off", "observe", "safe"],
50
- "default": "off"
51
- },
52
- "profiles": {
53
- "type": "array",
54
- "items": {
55
- "type": "string",
56
- "enum": ["repeat-lines", "tap-success"]
57
- },
58
- "uniqueItems": true,
59
- "default": ["repeat-lines", "tap-success"]
60
- },
61
- "maxInputBytes": {
62
- "type": "integer",
63
- "minimum": 1,
64
- "maximum": 8388608,
65
- "default": 8388608
66
- },
67
- "minSavingsBytes": {
68
- "type": "integer",
69
- "minimum": 0,
70
- "maximum": 8388608,
71
- "default": 1024
72
- },
73
- "minSavingsRatio": {
74
- "type": "number",
75
- "minimum": 0,
76
- "maximum": 1,
77
- "default": 0.15
78
- },
79
- "recovery": {
80
- "type": "object",
81
- "additionalProperties": false,
82
- "properties": {
83
- "mode": {
84
- "type": "string",
85
- "enum": ["ephemeral"],
86
- "default": "ephemeral"
87
- },
88
- "ttlMinutes": {
89
- "type": "integer",
90
- "minimum": 1,
91
- "default": 60
92
- },
93
- "maxSessionBytes": {
94
- "type": "integer",
95
- "minimum": 1,
96
- "default": 33554432
97
- }
98
- }
99
- }
100
- }
101
- },
102
42
  "agents": {
103
43
  "type": "object",
104
44
  "additionalProperties": false,
@@ -5,8 +5,8 @@ The default scope is the current user's global install. ``--local`` targets a
5
5
  project, while an explicit legacy positional target scans both project and
6
6
  home-style locations for backward compatibility. Only files, symlinks, JSON
7
7
  handlers, and marker blocks with verifiable ai-toolkit ownership are removed.
8
- Global scope also removes validated output-filter recovery files while keeping
9
- foreign content in the same session trees.
8
+ Global scope also removes validated recovery files left behind by the v4.16.x
9
+ tool-output filter while keeping foreign content in the same session trees.
10
10
 
11
11
  Usage:
12
12
  python3 scripts/uninstall.py [--yes] [--local|--global] [--target DIR]
@@ -33,8 +33,16 @@ from typing import Any
33
33
  sys.path.insert(0, str(Path(__file__).resolve().parent))
34
34
  from _common import app_dir, toolkit_dir
35
35
  from injection import strip_all_sections, strip_section, trim_trailing_blanks
36
- from tool_output_filter.recovery import clean_owned_recovery_tree
37
- from tool_output_filter.recovery import count_owned_recovery_artifacts
36
+ # Retirement cleanup for the v4.16.x tool-output filter. The runtime package
37
+ # that wrote those files is gone; output_filter_retirement re-states its
38
+ # ownership rules as self-contained constants for both install and uninstall.
39
+ from output_filter_retirement import (
40
+ PROJECT_OWNER_NAME as _OUTPUT_FILTER_OWNER_NAME,
41
+ PROJECT_POLICY_NAME as _OUTPUT_FILTER_POLICY_NAME,
42
+ clean_owned_recovery_tree,
43
+ count_owned_recovery_artifacts,
44
+ managed_project_policy as _managed_output_filter_policy,
45
+ )
38
46
 
39
47
 
40
48
  CODEX_AGENT_MARKER = "# ai-toolkit-managed: codex-agent"
@@ -559,28 +567,6 @@ def _discover_claude_hooks(claude_dir: Path) -> list[tuple[str, str]]:
559
567
  return []
560
568
 
561
569
 
562
- _OUTPUT_FILTER_POLICY_NAME = "ai-toolkit-output-filter.json"
563
- _OUTPUT_FILTER_OWNER_NAME = ".ai-toolkit-output-filter.owner"
564
- _OUTPUT_FILTER_OWNER_MARKER = b"ai-toolkit-output-filter-policy-v1\n"
565
-
566
-
567
- def _managed_output_filter_policy(claude_dir: Path) -> list[Path]:
568
- """Return the managed per-project policy pair, or [] when unmanaged."""
569
- policy = claude_dir / _OUTPUT_FILTER_POLICY_NAME
570
- owner = claude_dir / _OUTPUT_FILTER_OWNER_NAME
571
- if owner.is_symlink() or not owner.is_file():
572
- return []
573
- try:
574
- if owner.read_bytes() != _OUTPUT_FILTER_OWNER_MARKER:
575
- return []
576
- except OSError:
577
- return []
578
- managed = [owner]
579
- if not policy.is_symlink() and policy.is_file():
580
- managed.insert(0, policy)
581
- return managed
582
-
583
-
584
570
  def _discover_output_filter_policy(claude_dir: Path) -> list[tuple[str, str]]:
585
571
  if not _managed_output_filter_policy(claude_dir):
586
572
  return []
@@ -1318,7 +1304,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
1318
1304
  parser = argparse.ArgumentParser(
1319
1305
  description=(
1320
1306
  "Remove only ai-toolkit-managed Claude, Codex, Copilot, and "
1321
- "output-filter recovery data while preserving user-owned content."
1307
+ "leftover v4.16.x recovery data while preserving user-owned content."
1322
1308
  ),
1323
1309
  epilog=(
1324
1310
  "Global Codex and Copilot locations honor CODEX_HOME and "
@@ -1,76 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Claude PostToolUse adapter for the native tool-output filter.
3
-
4
- HOOK_SOURCE="${BASH_SOURCE[0]}"
5
- HOOK_DIR="${HOOK_SOURCE%/*}"
6
- [[ "$HOOK_DIR" == "$HOOK_SOURCE" ]] && HOOK_DIR="."
7
- # shellcheck source=_profile-check.sh
8
- source "$HOOK_DIR/_profile-check.sh"
9
-
10
- OWNER_MARKER="ai-toolkit-output-filter-policy-v1"
11
- GLOBAL_POLICY="$HOME/.softspark/ai-toolkit/hooks/output-filter-policy.json"
12
- PROJECTS_REGISTRY="$HOME/.softspark/ai-toolkit/projects.json"
13
-
14
- is_regular_file() {
15
- [[ -f "$1" && -r "$1" && ! -L "$1" ]]
16
- }
17
-
18
- # A project policy is trusted only for projects the user registered via
19
- # `ai-toolkit install --local`. The owner marker alone is a public constant,
20
- # so a cloned repo must never be able to self-enable filtering with it.
21
- is_registered_project() {
22
- is_regular_file "$PROJECTS_REGISTRY" &&
23
- grep -qF "\"$1\"" "$PROJECTS_REGISTRY" 2>/dev/null
24
- }
25
-
26
- if [[ "${AI_TOOLKIT_OUTPUT_FILTER_DISABLE:-}" == "1" ]]; then
27
- exit 0
28
- fi
29
-
30
- if [[ -n "${AI_TOOLKIT_OUTPUT_FILTER_POLICY:-}" ]]; then
31
- POLICY_PATH="$AI_TOOLKIT_OUTPUT_FILTER_POLICY"
32
- if ! is_regular_file "$POLICY_PATH"; then
33
- exit 0
34
- fi
35
- else
36
- PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
37
- PROJECT_POLICY="$PROJECT_ROOT/.claude/ai-toolkit-output-filter.json"
38
- PROJECT_OWNER="$PROJECT_ROOT/.claude/.ai-toolkit-output-filter.owner"
39
- if [[ -L "$PROJECT_ROOT" || -L "$PROJECT_ROOT/.claude" ]]; then
40
- exit 0
41
- fi
42
- if is_registered_project "$PROJECT_ROOT" &&
43
- is_regular_file "$PROJECT_OWNER" &&
44
- [[ "$(<"$PROJECT_OWNER")" == "$OWNER_MARKER" ]]; then
45
- if ! is_regular_file "$PROJECT_POLICY"; then
46
- exit 0
47
- fi
48
- POLICY_PATH="$PROJECT_POLICY"
49
- else
50
- POLICY_PATH="$GLOBAL_POLICY"
51
- fi
52
- fi
53
-
54
- if ! is_regular_file "$POLICY_PATH"; then
55
- exit 0
56
- fi
57
- POLICY_CONTENT="$(<"$POLICY_PATH")" || exit 0
58
- if [[ ${#POLICY_CONTENT} -gt 65536 ]]; then
59
- exit 0
60
- fi
61
- MODE_OFF_PATTERN='"mode"[[:space:]]*:[[:space:]]*"off"'
62
- MODE_ACTIVE_PATTERN='"mode"[[:space:]]*:[[:space:]]*"(observe|safe)"'
63
- if [[ "$POLICY_CONTENT" =~ $MODE_OFF_PATTERN ]]; then
64
- exit 0
65
- fi
66
- if [[ ! "$POLICY_CONTENT" =~ $MODE_ACTIVE_PATTERN ]]; then
67
- exit 0
68
- fi
69
-
70
- RUNTIME_PATH="${AI_TOOLKIT_OUTPUT_FILTER_HOOK_RUNTIME:-${AI_TOOLKIT_OUTPUT_FILTER_CLI:-$HOME/.softspark/ai-toolkit/scripts/output_filter_hook.py}}"
71
- if ! is_regular_file "$RUNTIME_PATH"; then
72
- exit 0
73
- fi
74
- python3 -S "$RUNTIME_PATH" hook --policy "$POLICY_PATH" 2>/dev/null || true
75
-
76
- exit 0
@@ -1,15 +0,0 @@
1
- {
2
- "mode": "off",
3
- "profiles": [
4
- "repeat-lines",
5
- "tap-success"
6
- ],
7
- "maxInputBytes": 8388608,
8
- "minSavingsBytes": 1024,
9
- "minSavingsRatio": 0.15,
10
- "recovery": {
11
- "mode": "ephemeral",
12
- "ttlMinutes": 60,
13
- "maxSessionBytes": 33554432
14
- }
15
- }
@@ -1,11 +0,0 @@
1
- # Native output-filter benchmark corpus
2
-
3
- The corpus is deterministic, synthetic, offline, and authored for ai-toolkit.
4
- It measures pure profile transformation separately from hook process startup.
5
-
6
- The gates are 20 ms p95 for inputs up to 100 KiB, 150 ms p95 for the 8 MiB
7
- hard-cap case, at least 30% reduction, and peak traced allocation no greater
8
- than three input sizes plus 16 MiB. The cold-process gate invokes the production
9
- Bash wrapper with a fresh Python process for every sample in one native session;
10
- its p95 limit is 75 ms. The default 100 samples keep the p95 gate stable enough
11
- for release validation.
@@ -1,25 +0,0 @@
1
- [
2
- {
3
- "name": "repeat-lines-100k",
4
- "profile": "repeat-lines",
5
- "kind": "repeat",
6
- "targetBytes": 102400,
7
- "lineWidth": 96,
8
- "maxP95Ms": 20
9
- },
10
- {
11
- "name": "tap-success-2k",
12
- "profile": "tap-success",
13
- "kind": "tap",
14
- "testCount": 2000,
15
- "maxP95Ms": 20
16
- },
17
- {
18
- "name": "repeat-lines-8m",
19
- "profile": "repeat-lines",
20
- "kind": "repeat",
21
- "targetBytes": 8388608,
22
- "lineWidth": 1024,
23
- "maxP95Ms": 150
24
- }
25
- ]