@softspark/ai-toolkit 4.15.0 → 4.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/AGENTS.md +117 -0
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +19 -13
  4. package/app/.claude-plugin/plugin.json +1 -1
  5. package/app/ARCHITECTURE.md +4 -3
  6. package/app/hooks/_hook-io.sh +18 -3
  7. package/app/hooks/ai-toolkit-statusline.sh +30 -5
  8. package/app/hooks/filter-tool-output.sh +76 -0
  9. package/app/hooks/governance-capture.sh +1 -1
  10. package/app/hooks/guard-path.sh +2 -2
  11. package/app/hooks/post-tool-use.sh +5 -3
  12. package/app/hooks/pre-compact-save.sh +4 -3
  13. package/app/hooks/quality-gate.sh +12 -1
  14. package/app/hooks/revert-guard.sh +5 -2
  15. package/app/hooks/save-session.sh +4 -2
  16. package/app/hooks/session-end.sh +36 -4
  17. package/app/hooks/session-start.sh +11 -5
  18. package/app/hooks.json +10 -0
  19. package/app/output-filter-policy.json +15 -0
  20. package/app/skills/brand-voice/scripts/measure.py +7 -5
  21. package/benchmarks/ecosystem-doctor-snapshot.json +22 -22
  22. package/benchmarks/output-filter/README.md +11 -0
  23. package/benchmarks/output-filter/scenarios.json +25 -0
  24. package/bin/ai-toolkit.js +2 -0
  25. package/kb/history/completed/native-tool-output-filter-plan.md +517 -0
  26. package/kb/procedures/release-preparation-sop.md +6 -5
  27. package/kb/reference/architecture-overview.md +6 -5
  28. package/kb/reference/cli-reference.md +19 -2
  29. package/kb/reference/codex-cli-compatibility.md +1 -0
  30. package/kb/reference/copilot-compatibility.md +173 -0
  31. package/kb/reference/enterprise-config-guide.md +28 -2
  32. package/kb/reference/global-install-model.md +6 -2
  33. package/kb/reference/hooks-catalog.md +105 -16
  34. package/kb/reference/opencode-compatibility.md +1 -0
  35. package/kb/reference/supported-tools-registry.md +10 -5
  36. package/kb/reference/tool-output-filter.md +288 -0
  37. package/kb/reference/windows-support.md +4 -3
  38. package/llms-full.txt +1182 -40
  39. package/llms.txt +3 -0
  40. package/manifest.json +9 -6
  41. package/package.json +3 -2
  42. package/scripts/benchmark_output_filter.py +343 -0
  43. package/scripts/check_deps.py +16 -0
  44. package/scripts/claude_app.py +30 -2
  45. package/scripts/config_cli.py +4 -4
  46. package/scripts/config_lock.py +120 -14
  47. package/scripts/config_merger.py +103 -20
  48. package/scripts/config_resolver.py +22 -2
  49. package/scripts/config_validator.py +268 -16
  50. package/scripts/copilot_legacy_hashes.json +338 -0
  51. package/scripts/doctor.py +1 -0
  52. package/scripts/generate_codex_hooks.py +2 -0
  53. package/scripts/generate_copilot.py +464 -71
  54. package/scripts/generate_copilot_hooks.py +124 -7
  55. package/scripts/generate_gemini_hooks.py +33 -10
  56. package/scripts/generate_opencode_plugin.py +28 -12
  57. package/scripts/install_steps/ai_tools.py +115 -3
  58. package/scripts/install_steps/hooks.py +25 -1
  59. package/scripts/output_filter_cli.py +347 -0
  60. package/scripts/output_filter_hook.py +23 -0
  61. package/scripts/plugin_schema.py +27 -1
  62. package/scripts/schemas/ai-toolkit-config.schema.json +83 -5
  63. package/scripts/session_state.py +156 -42
  64. package/scripts/tool_output_filter/__init__.py +33 -0
  65. package/scripts/tool_output_filter/contracts.py +173 -0
  66. package/scripts/tool_output_filter/engine.py +260 -0
  67. package/scripts/tool_output_filter/hook_runtime.py +369 -0
  68. package/scripts/tool_output_filter/input.py +56 -0
  69. package/scripts/tool_output_filter/invariants.py +40 -0
  70. package/scripts/tool_output_filter/policy.py +153 -0
  71. package/scripts/tool_output_filter/profiles/__init__.py +68 -0
  72. package/scripts/tool_output_filter/profiles/repeat_lines.py +71 -0
  73. package/scripts/tool_output_filter/profiles/tap_success.py +154 -0
  74. package/scripts/tool_output_filter/recovery.py +846 -0
  75. package/scripts/tool_output_filter/telemetry.py +13 -0
  76. package/scripts/uninstall.py +96 -3
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.15.0",
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": "28 hook entries across 14 lifecycle events plus statusLine",
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": ["extends", "profile", "agents", "rules", "constitution", "enforce"],
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.15.0",
4
- "description": "AI coding toolkit: 108 skills, 44 agents, 12 developer-tool integrations plus Claude Chat/Cowork plugin export, machine-enforced safety constitution, SARIF audit, signed npm provenance.",
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())
@@ -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 = [
@@ -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(hooks)}, indent=2, ensure_ascii=False) + "\n"
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(part in SKIP_NAMES or part.endswith(".pyc") for part in relative.parts):
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():
@@ -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(f" ✓ schema valid")
101
- print(f" ✓ no forbidden overrides")
100
+ print(" ✓ schema valid")
101
+ print(" ✓ no forbidden overrides")
102
102
  if extends:
103
- print(f" ✓ extends resolved")
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-VI (immutable)")
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()):