@softspark/ai-toolkit 4.16.1 → 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 +32 -0
- package/README.md +9 -15
- 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/install.py +2 -1
- 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
|
@@ -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,
|
package/scripts/uninstall.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
"
|
|
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
|
-
]
|