@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.
- package/AGENTS.md +117 -0
- package/CHANGELOG.md +43 -0
- package/README.md +19 -13
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +4 -3
- package/app/hooks/_hook-io.sh +18 -3
- package/app/hooks/ai-toolkit-statusline.sh +30 -5
- package/app/hooks/filter-tool-output.sh +76 -0
- package/app/hooks/governance-capture.sh +1 -1
- package/app/hooks/guard-path.sh +2 -2
- package/app/hooks/post-tool-use.sh +5 -3
- package/app/hooks/pre-compact-save.sh +4 -3
- package/app/hooks/quality-gate.sh +12 -1
- package/app/hooks/revert-guard.sh +5 -2
- package/app/hooks/save-session.sh +4 -2
- package/app/hooks/session-end.sh +36 -4
- package/app/hooks/session-start.sh +11 -5
- package/app/hooks.json +10 -0
- package/app/output-filter-policy.json +15 -0
- package/app/skills/brand-voice/scripts/measure.py +7 -5
- package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
- package/benchmarks/output-filter/README.md +11 -0
- package/benchmarks/output-filter/scenarios.json +25 -0
- package/bin/ai-toolkit.js +2 -0
- package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
- package/kb/procedures/release-preparation-sop.md +6 -5
- package/kb/reference/architecture-overview.md +6 -5
- package/kb/reference/cli-reference.md +19 -2
- package/kb/reference/codex-cli-compatibility.md +1 -0
- package/kb/reference/copilot-compatibility.md +173 -0
- package/kb/reference/enterprise-config-guide.md +28 -2
- package/kb/reference/global-install-model.md +6 -2
- package/kb/reference/hooks-catalog.md +105 -16
- package/kb/reference/opencode-compatibility.md +1 -0
- package/kb/reference/supported-tools-registry.md +10 -5
- package/kb/reference/tool-output-filter.md +288 -0
- package/kb/reference/windows-support.md +4 -3
- package/llms-full.txt +1182 -40
- package/llms.txt +3 -0
- package/manifest.json +9 -6
- package/package.json +3 -2
- package/scripts/benchmark_output_filter.py +343 -0
- package/scripts/check_deps.py +16 -0
- package/scripts/claude_app.py +30 -2
- package/scripts/config_cli.py +4 -4
- package/scripts/config_lock.py +120 -14
- package/scripts/config_merger.py +103 -20
- package/scripts/config_resolver.py +22 -2
- package/scripts/config_validator.py +268 -16
- package/scripts/copilot_legacy_hashes.json +338 -0
- package/scripts/doctor.py +1 -0
- package/scripts/generate_codex_hooks.py +2 -0
- package/scripts/generate_copilot.py +464 -71
- package/scripts/generate_copilot_hooks.py +124 -7
- package/scripts/generate_gemini_hooks.py +33 -10
- package/scripts/generate_opencode_plugin.py +28 -12
- package/scripts/install_steps/ai_tools.py +115 -3
- package/scripts/install_steps/hooks.py +25 -1
- package/scripts/output_filter_cli.py +347 -0
- package/scripts/output_filter_hook.py +23 -0
- package/scripts/plugin_schema.py +27 -1
- package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
- package/scripts/session_state.py +156 -42
- package/scripts/tool_output_filter/__init__.py +33 -0
- package/scripts/tool_output_filter/contracts.py +173 -0
- package/scripts/tool_output_filter/engine.py +260 -0
- package/scripts/tool_output_filter/hook_runtime.py +369 -0
- package/scripts/tool_output_filter/input.py +56 -0
- package/scripts/tool_output_filter/invariants.py +40 -0
- package/scripts/tool_output_filter/policy.py +153 -0
- package/scripts/tool_output_filter/profiles/__init__.py +68 -0
- package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
- package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
- package/scripts/tool_output_filter/recovery.py +846 -0
- package/scripts/tool_output_filter/telemetry.py +13 -0
- package/scripts/uninstall.py +96 -3
package/llms.txt
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
- [Plan: Ecosystem Deep Sweep — All 12 Supported Tools](kb/history/completed/ecosystem-deep-sweep-20260423.md)
|
|
18
18
|
- [Plan: Enterprise Config Inheritance — Multi-Repo Governance with `extends`](kb/history/completed/enterprise-config-inheritance-plan-20260412.md)
|
|
19
19
|
- [Spike: F2 MCP Context Trim — Hook Feasibility & Path Decision](kb/history/completed/f2-mcp-trim-spike-20260504.md)
|
|
20
|
+
- [Implementation Plan: Native Tool Output Filter](kb/history/completed/native-tool-output-filter-plan.md)
|
|
20
21
|
- [Plan: Offline-First SLM Profile — Lightweight Mode for Local Models](kb/history/completed/offline-slm-profile-plan-20260411.md)
|
|
21
22
|
- [Plan: Output & Token Discipline](kb/history/completed/output-token-discipline-plan-20260504.md)
|
|
22
23
|
- [How-To Guides](kb/howto/README.md)
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
- [AI Toolkit - Codex CLI Compatibility](kb/reference/codex-cli-compatibility.md)
|
|
39
40
|
- [Ecosystem Comparison](kb/reference/comparison.md)
|
|
40
41
|
- [Plan: Competitive Features — ai-toolkit](kb/reference/competitive-features-implementation.md)
|
|
42
|
+
- [AI Toolkit - GitHub Copilot Compatibility](kb/reference/copilot-compatibility.md)
|
|
41
43
|
- [Distribution Model](kb/reference/distribution-model.md)
|
|
42
44
|
- [Enterprise Config Inheritance Guide](kb/reference/enterprise-config-guide.md)
|
|
43
45
|
- [Extension API Reference](kb/reference/extension-api.md)
|
|
@@ -61,6 +63,7 @@
|
|
|
61
63
|
- [Usage Statistics](kb/reference/stats.md)
|
|
62
64
|
- [Supported Tools Registry](kb/reference/supported-tools-registry.md)
|
|
63
65
|
- [Config Sync](kb/reference/sync.md)
|
|
66
|
+
- [Native Tool Output Filter](kb/reference/tool-output-filter.md)
|
|
64
67
|
- [Unique Features & Differentiators](kb/reference/unique-features.md)
|
|
65
68
|
- [Windows Support](kb/reference/windows-support.md)
|
|
66
69
|
- [Troubleshooting](kb/troubleshooting/README.md)
|
package/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "4.
|
|
2
|
+
"version": "4.16.0",
|
|
3
3
|
"components": {
|
|
4
4
|
"agents": {
|
|
5
5
|
"description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
"tags": ["core", "skills", "commands"]
|
|
17
17
|
},
|
|
18
18
|
"hooks": {
|
|
19
|
-
"description": "
|
|
20
|
-
"path": "app/hooks.json + app/hooks/*.sh",
|
|
21
|
-
"target": "~/.claude/settings.json (merge) + ~/.softspark/ai-toolkit/hooks/ (copy)",
|
|
19
|
+
"description": "29 hook entries across 14 lifecycle events plus statusLine",
|
|
20
|
+
"path": "app/hooks.json + app/hooks/*.sh + scripts/tool_output_filter/ + scripts/output_filter_{hook,cli}.py",
|
|
21
|
+
"target": "~/.claude/settings.json (merge) + ~/.softspark/ai-toolkit/{hooks,scripts}/ (copy)",
|
|
22
22
|
"type": "merge+copy",
|
|
23
|
-
"tags": ["core", "hooks", "quality"]
|
|
23
|
+
"tags": ["core", "hooks", "quality", "output-filter"]
|
|
24
24
|
},
|
|
25
25
|
"plugin-packs": {
|
|
26
26
|
"description": "Experimental domain plugin packs and optional hook/policy modules",
|
|
@@ -166,7 +166,10 @@
|
|
|
166
166
|
"project_config": ".softspark-toolkit.json",
|
|
167
167
|
"base_config": "ai-toolkit.config.json",
|
|
168
168
|
"lock_file": ".softspark-toolkit.lock.json",
|
|
169
|
-
"v1_fields": [
|
|
169
|
+
"v1_fields": [
|
|
170
|
+
"extends", "profile", "agents", "plugins", "rules", "constitution",
|
|
171
|
+
"enforce", "toolOutputFilter"
|
|
172
|
+
],
|
|
170
173
|
"max_extends_depth": 5
|
|
171
174
|
}
|
|
172
175
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "4.
|
|
4
|
-
"description": "AI coding toolkit: 108 skills, 44 agents, 12 developer-tool integrations
|
|
3
|
+
"version": "4.16.0",
|
|
4
|
+
"description": "AI coding toolkit: 108 skills, 44 agents, 12 developer-tool integrations, recoverable native tool-output filtering, Claude Chat/Cowork export, safety constitution, SARIF audit, and signed npm provenance.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
7
7
|
"claude-code",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"llm",
|
|
13
13
|
"toolkit",
|
|
14
14
|
"developer-tools",
|
|
15
|
+
"output-filtering",
|
|
15
16
|
"cursor",
|
|
16
17
|
"windsurf",
|
|
17
18
|
"copilot",
|
|
@@ -0,0 +1,343 @@
|
|
|
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())
|
package/scripts/check_deps.py
CHANGED
|
@@ -76,6 +76,22 @@ REQUIRED = [
|
|
|
76
76
|
},
|
|
77
77
|
"reason": "CLI entry point (bin/ai-toolkit.js)",
|
|
78
78
|
},
|
|
79
|
+
{
|
|
80
|
+
"name": "jq",
|
|
81
|
+
"check": "jq",
|
|
82
|
+
"packages": {
|
|
83
|
+
"brew": "jq",
|
|
84
|
+
"apt": "jq",
|
|
85
|
+
"dnf": "jq",
|
|
86
|
+
"pacman": "jq",
|
|
87
|
+
"apk": "jq",
|
|
88
|
+
"zypper": "jq",
|
|
89
|
+
"winget": "jqlang.jq",
|
|
90
|
+
"choco": "jq",
|
|
91
|
+
"scoop": "jq",
|
|
92
|
+
},
|
|
93
|
+
"reason": "JSON parsing in lifecycle hooks — guard-path.sh blocks file tools without it",
|
|
94
|
+
},
|
|
79
95
|
]
|
|
80
96
|
|
|
81
97
|
OPTIONAL = [
|
package/scripts/claude_app.py
CHANGED
|
@@ -38,6 +38,7 @@ PLUGIN_SCRIPT_FILES = (
|
|
|
38
38
|
"test_cohesion.py",
|
|
39
39
|
"version_check.py",
|
|
40
40
|
)
|
|
41
|
+
CLAUDE_CODE_ONLY_HOOKS = frozenset({"filter-tool-output.sh"})
|
|
41
42
|
RULE_FILES = tuple(sorted((APP_DIR / "rules").glob("*.md"))) + tuple(
|
|
42
43
|
sorted((APP_DIR / "rules" / "common").glob("*.md"))
|
|
43
44
|
)
|
|
@@ -65,6 +66,28 @@ def render_plugin_hooks() -> str:
|
|
|
65
66
|
source = json.loads((APP_DIR / "hooks.json").read_text(encoding="utf-8"))
|
|
66
67
|
hooks = source.get("hooks", {})
|
|
67
68
|
|
|
69
|
+
def supports_claude_app(handler: object) -> bool:
|
|
70
|
+
if not isinstance(handler, dict):
|
|
71
|
+
return True
|
|
72
|
+
command = handler.get("command")
|
|
73
|
+
if not isinstance(command, str):
|
|
74
|
+
return True
|
|
75
|
+
return not any(name in command for name in CLAUDE_CODE_ONLY_HOOKS)
|
|
76
|
+
|
|
77
|
+
app_hooks = {}
|
|
78
|
+
for event, groups in hooks.items():
|
|
79
|
+
app_groups = []
|
|
80
|
+
for group in groups:
|
|
81
|
+
handlers = [
|
|
82
|
+
handler
|
|
83
|
+
for handler in group.get("hooks", [])
|
|
84
|
+
if supports_claude_app(handler)
|
|
85
|
+
]
|
|
86
|
+
if handlers:
|
|
87
|
+
app_groups.append({**group, "hooks": handlers})
|
|
88
|
+
if app_groups:
|
|
89
|
+
app_hooks[event] = app_groups
|
|
90
|
+
|
|
68
91
|
def adapt(value):
|
|
69
92
|
if isinstance(value, dict):
|
|
70
93
|
return {
|
|
@@ -84,7 +107,7 @@ def render_plugin_hooks() -> str:
|
|
|
84
107
|
return adapted
|
|
85
108
|
return value
|
|
86
109
|
|
|
87
|
-
return json.dumps({"hooks": adapt(
|
|
110
|
+
return json.dumps({"hooks": adapt(app_hooks)}, indent=2, ensure_ascii=False) + "\n"
|
|
88
111
|
|
|
89
112
|
|
|
90
113
|
def render_rules_skill(extra_rules: tuple[Path, ...] = ()) -> str:
|
|
@@ -137,7 +160,12 @@ def sync_generated() -> None:
|
|
|
137
160
|
def _copy_tree(source: Path, destination: Path) -> None:
|
|
138
161
|
for path in sorted(source.rglob("*")):
|
|
139
162
|
relative = path.relative_to(source)
|
|
140
|
-
if any(
|
|
163
|
+
if any(
|
|
164
|
+
part in SKIP_NAMES
|
|
165
|
+
or part in CLAUDE_CODE_ONLY_HOOKS
|
|
166
|
+
or part.endswith(".pyc")
|
|
167
|
+
for part in relative.parts
|
|
168
|
+
):
|
|
141
169
|
continue
|
|
142
170
|
target = destination / relative
|
|
143
171
|
if path.is_dir():
|
package/scripts/config_cli.py
CHANGED
|
@@ -97,10 +97,10 @@ def cmd_validate(args: list[str]) -> int:
|
|
|
97
97
|
return 1
|
|
98
98
|
|
|
99
99
|
# Summary — these passed because validation above would have returned 1
|
|
100
|
-
print(
|
|
101
|
-
print(
|
|
100
|
+
print(" ✓ schema valid")
|
|
101
|
+
print(" ✓ no forbidden overrides")
|
|
102
102
|
if extends:
|
|
103
|
-
print(
|
|
103
|
+
print(" ✓ extends resolved")
|
|
104
104
|
|
|
105
105
|
print("\n Config valid ✓")
|
|
106
106
|
return 0
|
|
@@ -217,7 +217,7 @@ def _diff_constitution(base: dict, project: dict) -> None:
|
|
|
217
217
|
if base_articles or proj_articles:
|
|
218
218
|
print()
|
|
219
219
|
print(" Constitution:")
|
|
220
|
-
print(" = Articles I-
|
|
220
|
+
print(" = Articles I-VII (immutable)")
|
|
221
221
|
for num, art in sorted(base_articles.items()):
|
|
222
222
|
print(f" = Article {num}: {art['title']} (inherited from base)")
|
|
223
223
|
for num, art in sorted(proj_articles.items()):
|