@softspark/ai-toolkit 4.2.1 → 4.2.2

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 CHANGED
@@ -7,6 +7,22 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v4.2.2 - Legacy hook duplicate cleanup (2026-05-12)
11
+
12
+ Patch release. Cleans up legacy untagged hook duplicates found during post-release verification of v4.2.1.
13
+
14
+ ### Fixed
15
+
16
+ - **Legacy notification hook cleanup** - `scripts/merge-hooks.py` now removes old direct `osascript` notification hooks from pre-script installs while preserving user-owned notification hooks.
17
+ - **External hook duplicate cleanup** - `scripts/inject_hook_cli.py` now removes legacy untagged entries matching the same event, matcher, and handler payload as the external hook being re-injected.
18
+
19
+ ### Verification
20
+
21
+ - `bats tests/test_merge_hooks_statusline.bats tests/test_inject_hook.bats`: 42/42 passing.
22
+ - Local `~/.claude/settings.json`: 0 behavior duplicates, 0 exact duplicates, 0 untagged hook groups after cleanup.
23
+
24
+ ---
25
+
10
26
  ## v4.2.1 - Claude Code hook enforcement and README cleanup (2026-05-12)
11
27
 
12
28
  Patch release. Tightens Claude Code runtime enforcement, fixes duplicated hook cleanup during install/update, and removes the README hero image for release.
package/README.md CHANGED
@@ -6,16 +6,15 @@
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
  [![Skills](https://img.shields.io/badge/skills-107-brightgreen)](app/skills/)
8
8
  [![Agents](https://img.shields.io/badge/agents-44-blue)](app/agents/)
9
- [![Tests](https://img.shields.io/badge/tests-1049%20passing-success)](tests/)
9
+ [![Tests](https://img.shields.io/badge/tests-1051%20passing-success)](tests/)
10
10
 
11
- ## What's New in v4.2.1
11
+ ## What's New in v4.2.2
12
12
 
13
- Patch release focused on making Claude Code rule enforcement harder to bypass and cleaning the release-facing README.
13
+ Patch release focused on cleaning legacy hook duplicates found during post-release verification.
14
14
 
15
- - **Stop quality gate**: `quality-gate.sh` now runs on `Stop` as well as `TaskCompleted`, blocking final responses when lint/type checks fail.
16
- - **Reliable hook failures**: shell pipelines in the quality gate now preserve the failing tool exit code instead of hiding errors behind `head`.
17
- - **Cleaner installs**: hook merging now strips legacy untagged ai-toolkit duplicates while preserving user-owned hooks.
18
- - **README cleanup**: removed the hero image block for a lighter release README.
15
+ - **Legacy notification cleanup**: `merge-hooks.py` now removes old direct `osascript` notification hooks from pre-script installs.
16
+ - **External hook cleanup**: `inject-hook` now removes legacy untagged duplicates that match the re-injected external hook payload.
17
+ - **Regression coverage**: added tests for both cleanup paths so repeated updates keep hook settings idempotent.
19
18
 
20
19
  See [CHANGELOG.md](CHANGELOG.md) for full history.
21
20
 
@@ -148,7 +147,7 @@ ai-toolkit/
148
147
  │ └── ARCHITECTURE.md # Full system design
149
148
  ├── kb/ # Reference docs, procedures, plans
150
149
  ├── scripts/ # Validation, install, evaluation scripts
151
- ├── tests/ # Bats test suite (1049 tests)
150
+ ├── tests/ # Bats test suite (1051 tests)
152
151
  └── CHANGELOG.md
153
152
  ```
154
153
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-toolkit",
3
3
  "description": "Professional-grade Claude Code toolkit with persona presets, skill security auditor, expanded lifecycle hooks, experimental opt-in plugin packs, benchmark harvesting, and multi-tool support.",
4
- "version": "4.2.1",
4
+ "version": "4.2.2",
5
5
  "author": {
6
6
  "name": "SoftSpark",
7
7
  "url": "https://github.com/softspark"
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.2.1",
2
+ "version": "4.2.2",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "4.2.1",
3
+ "version": "4.2.2",
4
4
  "description": "AI coding toolkit: 107 skills, 44 agents, 12-editor write-through (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo, Aider, Augment, Antigravity, Codex, opencode), machine-enforced safety constitution, SARIF audit, signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
@@ -120,20 +120,56 @@ def _entry_source(entry: dict) -> str | None:
120
120
  return None
121
121
 
122
122
 
123
- def strip_source(hooks: dict, source: str) -> dict:
123
+ def _entry_signature(entry: dict) -> tuple:
124
+ """Return behavior-defining hook fields without source tags."""
125
+ handlers = []
126
+ for hook in entry.get("hooks", []):
127
+ if not isinstance(hook, dict):
128
+ handlers.append(hook)
129
+ continue
130
+ handlers.append(tuple(sorted(
131
+ (key, value)
132
+ for key, value in hook.items()
133
+ if key != "_source"
134
+ )))
135
+ return (entry.get("matcher", ""), tuple(handlers))
136
+
137
+
138
+ def strip_source(hooks: dict, source: str, replacement_hooks: dict | None = None) -> dict:
124
139
  """Remove all entries whose ``_source`` matches *source*.
125
140
 
126
141
  Args:
127
142
  hooks: Existing hooks dict (event-name -> list of entries).
128
143
  source: Source tag to strip.
144
+ replacement_hooks: Optional hook entries being re-injected. When
145
+ provided, untagged legacy entries with the same event/matcher/
146
+ handler payload are stripped as belonging to the same source.
129
147
 
130
148
  Returns:
131
149
  New hooks dict with matching entries removed. Empty event lists
132
150
  are omitted.
133
151
  """
152
+ legacy_signatures: dict[str, set[tuple]] = {}
153
+ if replacement_hooks:
154
+ for event, entries in replacement_hooks.items():
155
+ legacy_signatures[event] = {
156
+ _entry_signature(entry)
157
+ for entry in entries
158
+ if isinstance(entry, dict)
159
+ }
160
+
134
161
  result: dict = {}
135
162
  for event, entries in hooks.items():
136
- filtered = [e for e in entries if _entry_source(e) != source]
163
+ signatures = legacy_signatures.get(event, set())
164
+ filtered = [
165
+ e for e in entries
166
+ if _entry_source(e) != source
167
+ and not (
168
+ isinstance(e, dict)
169
+ and _entry_source(e) is None
170
+ and _entry_signature(e) in signatures
171
+ )
172
+ ]
137
173
  if filtered:
138
174
  result[event] = filtered
139
175
  return result
@@ -174,7 +210,7 @@ def merge_hooks(new_hooks: dict, existing_hooks: dict, source: str) -> dict:
174
210
  Returns:
175
211
  Merged hooks dict.
176
212
  """
177
- merged = strip_source(existing_hooks, source)
213
+ merged = strip_source(existing_hooks, source, new_hooks)
178
214
  for event, entries in new_hooks.items():
179
215
  if event not in merged:
180
216
  merged[event] = []
@@ -27,6 +27,23 @@ import os
27
27
  import sys
28
28
 
29
29
  SOURCE_TAG = "ai-toolkit"
30
+ LEGACY_TOOLKIT_HOOKS = {
31
+ "Notification": [
32
+ {
33
+ "matcher": "",
34
+ "hooks": [
35
+ {
36
+ "type": "command",
37
+ "command": (
38
+ "osascript -e 'display notification "
39
+ '"Claude Code needs your attention" with title "Claude Code"'
40
+ "'"
41
+ ),
42
+ }
43
+ ],
44
+ }
45
+ ],
46
+ }
30
47
 
31
48
 
32
49
  def load_json(path: str) -> dict:
@@ -95,6 +112,12 @@ def strip_toolkit(hooks: dict, toolkit_hooks: dict | None = None) -> dict:
95
112
  for entry in entries
96
113
  if isinstance(entry, dict)
97
114
  }
115
+ for event, entries in LEGACY_TOOLKIT_HOOKS.items():
116
+ legacy_signatures.setdefault(event, set()).update(
117
+ _entry_signature(entry)
118
+ for entry in entries
119
+ if isinstance(entry, dict)
120
+ )
98
121
 
99
122
  result = {}
100
123
  for event, entries in hooks.items():