@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
@@ -1,288 +0,0 @@
1
- ---
2
- title: "Native Tool Output Filter"
3
- category: reference
4
- service: ai-toolkit
5
- tags: [output-filter, hooks, recovery, telemetry, claude-code]
6
- version: "1.0.0"
7
- created: "2026-07-23"
8
- last_updated: "2026-07-23"
9
- description: "Contract, configuration, safety boundaries, recovery, CLI, and runtime support for the native ai-toolkit output filter."
10
- ---
11
-
12
- # Native Tool Output Filter
13
-
14
- ## Overview
15
-
16
- ai-toolkit includes an original, dependency-free filter for selected
17
- post-execution tool results. It is disabled by default and does not depend on,
18
- vendor, execute, or copy another output-filter package.
19
-
20
- The active adapter targets Claude Code because its `PostToolUse` contract can
21
- replace a native tool response through
22
- `hookSpecificOutput.updatedToolOutput`. The replacement object retains the
23
- native response shape and changes only `stdout`. See the
24
- [Claude Code hooks reference](https://code.claude.com/docs/en/hooks).
25
-
26
- The filter never changes the command, arguments, environment, working
27
- directory, permission decision, exit status, or signal. [PATH:
28
- scripts/tool_output_filter/hook_runtime.py] [PATH:
29
- scripts/tool_output_filter/engine.py]
30
-
31
- ## Modes
32
-
33
- | Mode | Model-visible result | Recovery | Telemetry |
34
- |------|----------------------|----------|-----------|
35
- | `off` | Original | None | None |
36
- | `observe` | Original | No raw response | Content-free decision metadata |
37
- | `safe` | Replacement only after every gate passes | Exact native response saved first | Content-free decision metadata |
38
-
39
- `off` is a shell fast path, so the Python runtime is not started. Any runtime
40
- error, malformed payload, unsafe command, failed invariant, unavailable secure
41
- storage, or insufficient saving leaves the original response unchanged.
42
-
43
- Three consecutive profile, invariant, or recovery safety failures open a
44
- persistent session-scoped circuit breaker. One bounded Claude system message
45
- reports the bypass, then later results stay unchanged for that session.
46
-
47
- ## Configuration
48
-
49
- Configure the project in `.softspark-toolkit.json`:
50
-
51
- ```json
52
- {
53
- "toolOutputFilter": {
54
- "mode": "observe",
55
- "profiles": ["repeat-lines", "tap-success"],
56
- "maxInputBytes": 8388608,
57
- "minSavingsBytes": 1024,
58
- "minSavingsRatio": 0.15,
59
- "recovery": {
60
- "mode": "ephemeral",
61
- "ttlMinutes": 60,
62
- "maxSessionBytes": 33554432
63
- }
64
- }
65
- }
66
- ```
67
-
68
- Run `ai-toolkit install --local` or `ai-toolkit update --local` to materialize
69
- the effective policy as:
70
-
71
- ```text
72
- <project>/.claude/ai-toolkit-output-filter.json
73
- <project>/.claude/.ai-toolkit-output-filter.owner
74
- ```
75
-
76
- The managed files use mode `0600`. The hook accepts a project policy only when
77
- the project root is registered in `~/.softspark/ai-toolkit/projects.json`
78
- **and** the regular owner marker matches ai-toolkit. Registration is the
79
- security boundary: the owner marker is a public constant, so requiring the
80
- registry stops a cloned or untrusted checkout from self-enabling filtering by
81
- committing its own marker. An unregistered project, a missing or foreign
82
- marker, or a symlinked project root or `.claude` directory falls back to the
83
- installed global policy at
84
- `~/.softspark/ai-toolkit/hooks/output-filter-policy.json`, which defaults to
85
- `off`. [PATH: app/hooks/filter-tool-output.sh] [PATH:
86
- scripts/install_steps/ai_tools.py]
87
-
88
- `jq` is a required system dependency for the lifecycle hooks and is verified
89
- by `python3 scripts/check_deps.py` alongside `python3`, `git`, and `node`.
90
-
91
- Before executing anything, the hook validates the resolved Python runtime path:
92
- it must be a readable regular file and must not be a symlink. A missing,
93
- non-regular, unreadable, or symlinked runtime makes the hook exit silently and
94
- leave the tool response unchanged, so a tampered or half-installed runtime
95
- cannot be invoked. The same regular-file rule applies to every policy file the
96
- hook reads. [PATH: app/hooks/filter-tool-output.sh]
97
-
98
- Set `AI_TOOLKIT_OUTPUT_FILTER_DISABLE=1` for an immediate bypass without
99
- reinstalling. `AI_TOOLKIT_OUTPUT_FILTER_POLICY` may point the hook to an
100
- explicit regular policy file for controlled operational testing. The hook is
101
- also skipped by the `minimal` hook profile and may be listed in
102
- `AI_TOOLKIT_DISABLED_HOOKS`. `AI_TOOLKIT_OUTPUT_FILTER_HOOK_RUNTIME` is reserved
103
- for controlled runtime testing; the manual and cleanup CLI remains
104
- `output_filter_cli.py`.
105
-
106
- ## Eligibility
107
-
108
- The Claude adapter considers only a completed `PostToolUse` event with:
109
-
110
- - tool name `Bash`;
111
- - a non-empty native session ID of at most 160 ASCII letters, digits,
112
- underscores, or hyphens;
113
- - string `stdout`;
114
- - empty `stderr`;
115
- - `interrupted: false`;
116
- - `isImage: false`;
117
- - a command that matches a strict allowlist for test, lint, typecheck, or
118
- validation tools;
119
- - input at or below 8 MiB;
120
- - valid text without binary or terminal-control content.
121
-
122
- The following always pass through unchanged:
123
-
124
- - failed, interrupted, image, binary, invalid-text, TTY, or streaming results;
125
- - pipes, redirects, shell chaining, substitutions, and multiline commands;
126
- - deployment, release, migration, publish, destroy, audit, and security-scanner
127
- commands;
128
- - arbitrary Python scripts and unknown command shapes;
129
- - output with non-empty stderr;
130
- - unknown profiles or native payload shapes;
131
- - candidates that save less than both the configured byte and ratio threshold.
132
-
133
- The command classifier is eligibility logic only. It never parses and
134
- re-executes a command. [PATH: scripts/tool_output_filter/hook_runtime.py]
135
-
136
- ## Profiles
137
-
138
- ### `repeat-lines`
139
-
140
- Collapses only adjacent identical, non-diagnostic lines. It retains the first
141
- line and adds a versioned marker with the exact number of omitted copies.
142
- Warnings, failures, permissions, security diagnostics, blank lines, comments,
143
- existing filter markers, and control-bearing output are not collapsed.
144
-
145
- ### `tap-success`
146
-
147
- Accepts only a strict, complete, successful TAP stream with a single plan and
148
- contiguous `ok` result numbers. It retains the TAP version, plan, directives,
149
- comments, totals, duration, and other summary lines. Diagnostics, `not ok`,
150
- non-zero failure summaries, malformed plans, gaps, duplicates, and unknown
151
- content reject the whole profile.
152
-
153
- Both profiles are deterministic and idempotent. A safe replacement must remain
154
- smaller after the recovery marker is added. [PATH:
155
- scripts/tool_output_filter/profiles/] [PATH:
156
- tests/test_tool_output_filter_properties.py]
157
-
158
- ## Exact Recovery and Privacy
159
-
160
- Before `safe` mode emits a replacement, it stores and reloads the complete
161
- native tool-response object. Equality must succeed before the hook prints
162
- `updatedToolOutput`.
163
-
164
- ```text
165
- ~/.softspark/ai-toolkit/sessions/<repo-key>/
166
- └── output-filter/
167
- └── <hashed-session>/
168
- ├── <opaque-handle>.json
169
- ├── .circuit-state.json
170
- └── .telemetry.jsonl
171
- ```
172
-
173
- Recovery directories use `0700`; response, state, and telemetry files use
174
- `0600`. Creation and cleanup use pinned directory descriptors, no-follow
175
- operations, atomic publication, opaque random handles, a per-session quota,
176
- and TTL cleanup. If this secure contract is unavailable, `safe` mode returns
177
- the original response.
178
-
179
- The replacement ends with a marker similar to:
180
-
181
- ```text
182
- [ai-toolkit-output-filter repeat-lines/v1; original_lines=500; emitted_lines=3; recovery=<opaque-handle>]
183
- ```
184
-
185
- The recovery file can contain everything returned by the tool, including
186
- secrets. Treat the session directory as sensitive. Telemetry never stores raw
187
- output, commands, paths, environment values, session IDs, or recovery handles.
188
- It contains only profile/version, input/output byte and line counts, latency,
189
- outcome, and a bounded fallback reason.
190
-
191
- Session end, explicit cleanup, and global uninstall remove only validated
192
- ai-toolkit-owned filter artifacts. Foreign files and directories are
193
- preserved. [PATH: scripts/tool_output_filter/recovery.py] [PATH:
194
- app/hooks/session-end.sh] [PATH: scripts/uninstall.py]
195
-
196
- ## CLI
197
-
198
- Inspect candidate savings without changing output:
199
-
200
- ```bash
201
- some-test-command | ai-toolkit output-filter inspect --profile repeat-lines
202
- some-tap-command | ai-toolkit output-filter inspect --profile tap-success
203
- ```
204
-
205
- The JSON report contains counts, eligibility, outcome, and fallback reason. It
206
- does not echo stdin.
207
-
208
- Inspect the effective trusted project or global policy:
209
-
210
- ```bash
211
- ai-toolkit output-filter status
212
- ai-toolkit output-filter status --policy /path/to/materialized-policy.json
213
- ```
214
-
215
- Recover the exact native response object using the handle printed in a safe
216
- replacement:
217
-
218
- ```bash
219
- ai-toolkit output-filter recover <opaque-handle>
220
- ```
221
-
222
- The default lookup derives the current repository session directory. Advanced
223
- or test workflows can add `--base-directory PATH` or `--session-id ID`.
224
-
225
- Clean the ending session, expired exact responses, or all filter artifacts for
226
- the current repository:
227
-
228
- ```bash
229
- ai-toolkit output-filter clean --session-id <native-session-id>
230
- ai-toolkit output-filter clean --session-id <native-session-id> --expired
231
- ai-toolkit output-filter clean
232
- ```
233
-
234
- `--expired` requires `--session-id`. Cleanup prints only the removed artifact
235
- count and scope.
236
-
237
- ## Runtime Capability Matrix
238
-
239
- | Runtime | Active result replacement | Capability |
240
- |---------|---------------------------|------------|
241
- | Claude Code | Yes, opt-in | Native `PostToolUse.updatedToolOutput` adapter |
242
- | Claude Chat / Cowork | No | Plugin export explicitly excludes the Claude Code-only hook |
243
- | Cursor | No | Manual `output-filter inspect` only |
244
- | Windsurf / Devin | No | Manual `output-filter inspect` only |
245
- | GitHub Copilot | No | Manual `output-filter inspect` only |
246
- | Gemini CLI | No | Manual `output-filter inspect` only |
247
- | Cline | No | Manual `output-filter inspect` only |
248
- | Roo Code | No | Manual `output-filter inspect` only |
249
- | Aider | No | Manual `output-filter inspect` only |
250
- | Augment | No | Manual `output-filter inspect` only |
251
- | Google Antigravity | No | Manual `output-filter inspect` only |
252
- | Codex CLI | No | Manual `output-filter inspect` only |
253
- | OpenCode | No | Manual `output-filter inspect` only |
254
-
255
- An editor hook, extra context message, or command wrapper is not treated as
256
- result replacement. A new adapter requires a verified native replacement
257
- contract and dedicated native payload tests.
258
-
259
- ## Benchmark Semantics
260
-
261
- Run the deterministic offline corpus:
262
-
263
- ```bash
264
- PYTHONDONTWRITEBYTECODE=1 python3 scripts/benchmark_output_filter.py
265
- ```
266
-
267
- The benchmark measures profile p95 latency, production Bash-wrapper latency
268
- with a fresh Python process per sample in one native session, traced peak
269
- allocation, and eligible-output byte reduction. It uses 100 samples by default
270
- to avoid a one-sample p95 swing. The current gates are:
271
-
272
- - at least 30% candidate byte reduction;
273
- - at most 20 ms p95 for profile inputs up to 100 KiB;
274
- - at most 150 ms p95 for the 8 MiB profile case;
275
- - at most 75 ms p95 for a cold end-to-end hook process;
276
- - peak traced allocation no greater than three input sizes plus 16 MiB.
277
-
278
- Byte reduction is not billed-token savings and is not a whole-session cost
279
- claim. Measure actual model token receipts separately before changing the
280
- default mode. [PATH: scripts/benchmark_output_filter.py] [PATH:
281
- benchmarks/output-filter/]
282
-
283
- ## Related
284
-
285
- - [Hooks Catalog](hooks-catalog.md)
286
- - [Supported Tools Registry](supported-tools-registry.md)
287
- - [Architecture Overview](architecture-overview.md)
288
- - [Output Token Discipline Plan](../history/completed/output-token-discipline-plan-20260504.md)
@@ -1,343 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Deterministic offline benchmarks for native output profiles."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- import math
9
- import os
10
- import subprocess
11
- import sys
12
- import tempfile
13
- import time
14
- import tracemalloc
15
- from pathlib import Path
16
-
17
- from tool_output_filter.profiles import apply_profile
18
-
19
- REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
20
- DEFAULT_CORPUS = (
21
- REPOSITORY_ROOT / "benchmarks" / "output-filter" / "scenarios.json"
22
- )
23
- MEMORY_ALLOWANCE_BYTES = 16 * 1024 * 1024
24
- COLD_HOOK_INPUT_BYTES = 4 * 1024
25
- COLD_HOOK_MAX_P95_MS = 75.0
26
- DEFAULT_ITERATIONS = 100
27
- HOOK_RUNTIME = REPOSITORY_ROOT / "scripts" / "output_filter_hook.py"
28
- HOOK_SCRIPT = REPOSITORY_ROOT / "app" / "hooks" / "filter-tool-output.sh"
29
-
30
-
31
- def _repeat_output(target_bytes: int, line_width: int) -> str:
32
- prefix = "synthetic progress "
33
- line = prefix + ("x" * max(1, line_width - len(prefix) - 1)) + "\n"
34
- count = math.ceil(target_bytes / len(line.encode("utf-8")))
35
- return line * count
36
-
37
-
38
- def _tap_output(test_count: int) -> str:
39
- result_lines = [
40
- f"ok {number} - synthetic benchmark case {number}\n"
41
- for number in range(1, test_count + 1)
42
- ]
43
- return "".join(
44
- [
45
- "TAP version 13\n",
46
- f"1..{test_count}\n",
47
- *result_lines,
48
- f"# tests {test_count}\n",
49
- f"# pass {test_count}\n",
50
- "# fail 0\n",
51
- "# duration_ms 1\n",
52
- ]
53
- )
54
-
55
-
56
- def _integer_field(scenario: dict[str, object], key: str) -> int:
57
- value = scenario.get(key)
58
- if isinstance(value, bool) or not isinstance(value, int):
59
- raise ValueError(f"benchmark field {key} must be an integer")
60
- return value
61
-
62
-
63
- def _number_field(scenario: dict[str, object], key: str) -> float:
64
- value = scenario.get(key)
65
- if isinstance(value, bool) or not isinstance(value, (int, float)):
66
- raise ValueError(f"benchmark field {key} must be numeric")
67
- return float(value)
68
-
69
-
70
- def _string_field(scenario: dict[str, object], key: str) -> str:
71
- value = scenario.get(key)
72
- if not isinstance(value, str):
73
- raise ValueError(f"benchmark field {key} must be text")
74
- return value
75
-
76
-
77
- def _scenario_input(scenario: dict[str, object]) -> str:
78
- kind = _string_field(scenario, "kind")
79
- if kind == "repeat":
80
- return _repeat_output(
81
- _integer_field(scenario, "targetBytes"),
82
- _integer_field(scenario, "lineWidth"),
83
- )
84
- if kind == "tap":
85
- return _tap_output(_integer_field(scenario, "testCount"))
86
- raise ValueError(f"unknown benchmark kind: {kind}")
87
-
88
-
89
- def _p95(values: list[float]) -> float:
90
- ordered = sorted(values)
91
- index = max(0, math.ceil(len(ordered) * 0.95) - 1)
92
- return ordered[index]
93
-
94
-
95
- def _measure_scenario(
96
- scenario: dict[str, object],
97
- iterations: int,
98
- ) -> dict[str, object]:
99
- raw_output = _scenario_input(scenario)
100
- profile_id = _string_field(scenario, "profile")
101
- scenario_name = _string_field(scenario, "name")
102
- durations: list[float] = []
103
- candidate = apply_profile(profile_id, raw_output)
104
- if candidate is None or not candidate.accepted:
105
- raise RuntimeError(f"profile rejected benchmark {scenario_name}")
106
-
107
- for _ in range(iterations):
108
- started = time.perf_counter_ns()
109
- measured = apply_profile(profile_id, raw_output)
110
- durations.append((time.perf_counter_ns() - started) / 1_000_000)
111
- if measured != candidate:
112
- raise RuntimeError(
113
- f"non-deterministic benchmark {scenario_name}"
114
- )
115
-
116
- tracemalloc.start()
117
- apply_profile(profile_id, raw_output)
118
- _, peak_bytes = tracemalloc.get_traced_memory()
119
- tracemalloc.stop()
120
-
121
- input_bytes = len(raw_output.encode("utf-8"))
122
- candidate_bytes = len(candidate.output.encode("utf-8"))
123
- savings_ratio = (input_bytes - candidate_bytes) / input_bytes
124
- p95_ms = _p95(durations)
125
- memory_limit = input_bytes * 3 + MEMORY_ALLOWANCE_BYTES
126
- passed = (
127
- candidate_bytes < input_bytes
128
- and savings_ratio >= 0.3
129
- and p95_ms <= _number_field(scenario, "maxP95Ms")
130
- and peak_bytes <= memory_limit
131
- )
132
- return {
133
- "name": scenario_name,
134
- "profile": profile_id,
135
- "inputBytes": input_bytes,
136
- "candidateBytes": candidate_bytes,
137
- "savingsRatio": round(savings_ratio, 4),
138
- "p95Ms": round(p95_ms, 3),
139
- "peakBytes": peak_bytes,
140
- "memoryLimitBytes": memory_limit,
141
- "passed": passed,
142
- }
143
-
144
-
145
- def _hook_policy(mode: str) -> dict[str, object]:
146
- return {
147
- "mode": mode,
148
- "profiles": ["repeat-lines"],
149
- "maxInputBytes": 8 * 1024 * 1024,
150
- "minSavingsBytes": 1024,
151
- "minSavingsRatio": 0.15,
152
- "recovery": {
153
- "mode": "ephemeral",
154
- "ttlMinutes": 60,
155
- "maxSessionBytes": 32 * 1024 * 1024,
156
- },
157
- }
158
-
159
-
160
- def _hook_payload(raw_output: str, session_id: str) -> str:
161
- payload = {
162
- "hook_event_name": "PostToolUse",
163
- "tool_name": "Bash",
164
- "session_id": session_id,
165
- "cwd": str(REPOSITORY_ROOT),
166
- "tool_input": {"command": "npm test"},
167
- "tool_response": {
168
- "stdout": raw_output,
169
- "stderr": "",
170
- "interrupted": False,
171
- "isImage": False,
172
- },
173
- }
174
- return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
175
-
176
-
177
- def _hook_session_root(temporary_home: Path) -> Path:
178
- repo_key = "-" + str(REPOSITORY_ROOT).replace("/", "-").lstrip("-")
179
- return (
180
- temporary_home
181
- / ".softspark"
182
- / "ai-toolkit"
183
- / "sessions"
184
- / repo_key
185
- )
186
-
187
-
188
- def _run_cold_hook(
189
- payload: str,
190
- environment: dict[str, str],
191
- ) -> tuple[float, int]:
192
- started = time.perf_counter_ns()
193
- result = subprocess.run(
194
- ["bash", str(HOOK_SCRIPT)],
195
- input=payload,
196
- text=True,
197
- capture_output=True,
198
- cwd=REPOSITORY_ROOT,
199
- env=environment,
200
- check=False,
201
- )
202
- duration_ms = (time.perf_counter_ns() - started) / 1_000_000
203
- if result.returncode != 0 or result.stderr:
204
- raise RuntimeError("cold hook invocation failed")
205
- if result.stdout:
206
- raise RuntimeError("observe hook unexpectedly changed output")
207
- return duration_ms, len(result.stdout.encode("utf-8"))
208
-
209
-
210
- def _measure_cold_hook(
211
- iterations: int,
212
- mode: str = "observe",
213
- ) -> dict[str, object]:
214
- raw_output = _repeat_output(COLD_HOOK_INPUT_BYTES, 96)
215
- input_bytes = len(raw_output.encode("utf-8"))
216
- durations: list[float] = []
217
- emitted_sizes: list[int] = []
218
- with tempfile.TemporaryDirectory() as temporary_directory:
219
- temporary_path = Path(temporary_directory)
220
- policy_path = temporary_path / "policy.json"
221
- policy_path.write_text(
222
- json.dumps(_hook_policy(mode), separators=(",", ":")),
223
- encoding="utf-8",
224
- )
225
- temporary_home = temporary_path / "home"
226
- session_root = _hook_session_root(temporary_home)
227
- session_root.mkdir(parents=True, mode=0o700)
228
- session_root.chmod(0o700)
229
- environment = {
230
- **os.environ,
231
- "HOME": str(temporary_home),
232
- "PYTHONPYCACHEPREFIX": str(temporary_path / "pycache"),
233
- "AI_TOOLKIT_DISABLED_HOOKS": "",
234
- "AI_TOOLKIT_OUTPUT_FILTER_HOOK_RUNTIME": str(HOOK_RUNTIME),
235
- "AI_TOOLKIT_OUTPUT_FILTER_DISABLE": "0",
236
- "AI_TOOLKIT_OUTPUT_FILTER_POLICY": str(policy_path),
237
- "TOOLKIT_HOOK_PROFILE": "standard",
238
- }
239
- environment.pop("PYTHONDONTWRITEBYTECODE", None)
240
- session_id = "benchmark-session"
241
- for iteration in range(iterations):
242
- duration_ms, emitted_bytes = _run_cold_hook(
243
- _hook_payload(raw_output, session_id),
244
- environment,
245
- )
246
- durations.append(duration_ms)
247
- emitted_sizes.append(emitted_bytes)
248
- if mode == "observe":
249
- telemetry_files = list(session_root.rglob(".telemetry.jsonl"))
250
- if len(telemetry_files) != 1:
251
- raise RuntimeError(
252
- "cold hook did not complete observe telemetry"
253
- )
254
- telemetry_lines = telemetry_files[0].read_text(
255
- encoding="ascii",
256
- ).splitlines()
257
- if len(telemetry_lines) != iterations:
258
- raise RuntimeError(
259
- "cold hook telemetry event count is incomplete"
260
- )
261
- p95_ms = _p95(durations)
262
- emitted_bytes = max(emitted_sizes)
263
- return {
264
- "name": "claude-hook-cold-4k",
265
- "mode": mode,
266
- "adapter": "bash-wrapper",
267
- "sessionReused": True,
268
- "iterations": iterations,
269
- "inputBytes": input_bytes,
270
- "emittedBytes": emitted_bytes,
271
- "p95Ms": round(p95_ms, 3),
272
- "maxP95Ms": COLD_HOOK_MAX_P95_MS,
273
- "passed": (
274
- emitted_bytes < input_bytes
275
- and p95_ms <= COLD_HOOK_MAX_P95_MS
276
- ),
277
- }
278
-
279
-
280
- def _load_corpus(path: Path) -> list[dict[str, object]]:
281
- with path.open(encoding="utf-8") as corpus_file:
282
- data = json.load(corpus_file)
283
- if not isinstance(data, list):
284
- raise ValueError("benchmark corpus must be an array")
285
- return data
286
-
287
-
288
- def _build_parser() -> argparse.ArgumentParser:
289
- parser = argparse.ArgumentParser(description=__doc__)
290
- parser.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS)
291
- parser.add_argument("--iterations", type=int, default=DEFAULT_ITERATIONS)
292
- parser.add_argument("--max-input-bytes", type=int)
293
- parser.add_argument("--report-cold-hook-only", action="store_true")
294
- parser.add_argument("--json", action="store_true")
295
- return parser
296
-
297
-
298
- def main(argv: list[str] | None = None) -> int:
299
- args = _build_parser().parse_args(argv)
300
- if args.iterations <= 0:
301
- return 2
302
- scenarios = []
303
- for definition in _load_corpus(args.corpus):
304
- raw_size = len(_scenario_input(definition).encode("utf-8"))
305
- if args.max_input_bytes is not None and raw_size > args.max_input_bytes:
306
- continue
307
- scenarios.append(_measure_scenario(definition, args.iterations))
308
- cold_hook = _measure_cold_hook(args.iterations)
309
- profile_gates_passed = bool(scenarios) and all(
310
- scenario["passed"] for scenario in scenarios
311
- )
312
- cold_hook_enforced = not args.report_cold_hook_only
313
- report = {
314
- "passed": (
315
- profile_gates_passed
316
- and (
317
- cold_hook["passed"]
318
- or not cold_hook_enforced
319
- )
320
- ),
321
- "scenarios": scenarios,
322
- "coldHook": cold_hook,
323
- "coldHookEnforced": cold_hook_enforced,
324
- }
325
- if args.json:
326
- print(json.dumps(report, separators=(",", ":")))
327
- else:
328
- for scenario in scenarios:
329
- print(
330
- f"{scenario['name']}: p95={scenario['p95Ms']}ms "
331
- f"savings={scenario['savingsRatio']} "
332
- f"peak={scenario['peakBytes']} passed={scenario['passed']}"
333
- )
334
- print(
335
- f"{cold_hook['name']}: p95={cold_hook['p95Ms']}ms "
336
- f"emitted={cold_hook['emittedBytes']} "
337
- f"passed={cold_hook['passed']}"
338
- )
339
- return 0 if report["passed"] else 1
340
-
341
-
342
- if __name__ == "__main__":
343
- sys.exit(main())