claude-dev-env 1.25.2 → 1.26.1

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 (106) hide show
  1. package/CLAUDE.md +6 -0
  2. package/agents/clean-coder.md +1 -1
  3. package/docs/CODE_RULES.md +3 -1
  4. package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +54 -0
  5. package/hooks/blocking/{code-rules-enforcer.py → code_rules_enforcer.py} +154 -5
  6. package/hooks/blocking/test_code_rules_enforcer.py +61 -0
  7. package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +2 -2
  8. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +2 -2
  9. package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +1 -1
  10. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +2 -2
  11. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +183 -0
  12. package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +4 -4
  13. package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +1 -1
  14. package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
  15. package/hooks/blocking/test_code_rules_enforcer_magic_string_masking.py +104 -0
  16. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +2 -2
  17. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +2 -2
  18. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +1 -1
  19. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  20. package/hooks/blocking/test_gh_body_arg_blocker.py +1 -1
  21. package/hooks/blocking/test_pr_description_enforcer.py +8 -8
  22. package/hooks/blocking/test_tdd_enforcer.py +1 -1
  23. package/hooks/github-action/pre-push-review.yml +27 -0
  24. package/hooks/hooks.json +28 -28
  25. package/hooks/lifecycle/{config-change-guard.py → config_change_guard.py} +26 -12
  26. package/hooks/lifecycle/test_config_change_guard.py +3 -3
  27. package/hooks/notification/{attention-needed-notify.py → attention_needed_notify.py} +7 -0
  28. package/hooks/notification/{claude-notification-handler.py → claude_notification_handler.py} +8 -0
  29. package/hooks/notification/notification_utils.py +56 -0
  30. package/hooks/notification/subagent_complete_notify.py +381 -0
  31. package/hooks/notification/test_attention_needed_notify.py +47 -0
  32. package/hooks/notification/test_claude_notification_handler.py +54 -0
  33. package/hooks/notification/test_notification_utils.py +45 -0
  34. package/hooks/notification/test_subagent_complete_notify.py +79 -0
  35. package/hooks/validators/README.md +5 -1
  36. package/hooks/validators/abbreviation_checks.py +1 -1
  37. package/hooks/validators/code_quality_checks.py +1 -1
  38. package/hooks/validators/config.py +5 -0
  39. package/hooks/validators/conftest.py +10 -0
  40. package/hooks/validators/exempt_paths.py +1 -1
  41. package/hooks/validators/git_checks.py +80 -0
  42. package/hooks/validators/magic_value_checks.py +2 -2
  43. package/hooks/validators/pr_reference_checks.py +1 -1
  44. package/hooks/validators/python_antipattern_checks.py +1 -1
  45. package/hooks/validators/run_all_validators.py +53 -105
  46. package/hooks/validators/security_checks.py +1 -1
  47. package/hooks/validators/test_abbreviation_checks.py +2 -2
  48. package/hooks/validators/test_code_quality_checks.py +2 -2
  49. package/hooks/validators/test_file_structure_checks.py +1 -1
  50. package/hooks/validators/test_git_checks.py +79 -13
  51. package/hooks/validators/test_health_check.py +1 -1
  52. package/hooks/validators/test_magic_value_checks.py +2 -2
  53. package/hooks/validators/test_mypy_integration.py +1 -1
  54. package/hooks/validators/test_output_formatter.py +3 -1
  55. package/hooks/validators/test_pr_reference_checks.py +2 -2
  56. package/hooks/validators/test_python_antipattern_checks.py +2 -2
  57. package/hooks/validators/test_python_style_checks.py +2 -4
  58. package/hooks/validators/test_react_checks.py +1 -1
  59. package/hooks/validators/test_ruff_integration.py +1 -1
  60. package/hooks/validators/test_run_all_validators.py +75 -43
  61. package/hooks/validators/test_run_all_validators_integration.py +14 -37
  62. package/hooks/validators/test_security_checks.py +2 -2
  63. package/hooks/validators/test_test_safety_checks.py +1 -1
  64. package/hooks/validators/test_todo_checks.py +2 -2
  65. package/hooks/validators/test_type_safety_checks.py +2 -2
  66. package/hooks/validators/test_useless_test_checks.py +2 -2
  67. package/hooks/validators/test_validator_base.py +1 -1
  68. package/hooks/validators/test_verify_paths.py +2 -4
  69. package/hooks/validators/todo_checks.py +1 -1
  70. package/hooks/validators/type_safety_checks.py +1 -1
  71. package/hooks/validators/useless_test_checks.py +1 -1
  72. package/package.json +1 -1
  73. package/rules/file-global-constants.md +71 -0
  74. package/rules/gh-body-file.md +1 -1
  75. package/rules/prompt-workflow-context-controls.md +48 -0
  76. package/scripts/sync_to_cursor/rules.py +2 -2
  77. package/scripts/tests/test_sync_to_cursor.py +2 -2
  78. package/skills/bugteam/CONSTRAINTS.md +37 -0
  79. package/skills/bugteam/EXAMPLES.md +64 -0
  80. package/skills/bugteam/PROMPTS.md +175 -0
  81. package/skills/bugteam/SKILL.md +204 -295
  82. package/skills/bugteam/SKILL_EVALS.md +346 -0
  83. package/skills/bugteam/scripts/README.md +37 -0
  84. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +334 -0
  85. package/skills/bugteam/scripts/bugteam_preflight.py +135 -0
  86. package/skills/rule-audit/SKILL.md +4 -4
  87. /package/hooks/advisory/{migration-safety-advisor.py → migration_safety_advisor.py} +0 -0
  88. /package/hooks/advisory/{refactor-guard.py → refactor_guard.py} +0 -0
  89. /package/hooks/blocking/{block-main-commit.py → block_main_commit.py} +0 -0
  90. /package/hooks/blocking/{content-search-to-zoekt-redirector.py → content_search_to_zoekt_redirector.py} +0 -0
  91. /package/hooks/blocking/{destructive-command-blocker.py → destructive_command_blocker.py} +0 -0
  92. /package/hooks/blocking/{gh-body-arg-blocker.py → gh_body_arg_blocker.py} +0 -0
  93. /package/hooks/blocking/{hedging-language-blocker.py → hedging_language_blocker.py} +0 -0
  94. /package/hooks/blocking/{pr-description-enforcer.py → pr_description_enforcer.py} +0 -0
  95. /package/hooks/blocking/{sensitive-file-protector.py → sensitive_file_protector.py} +0 -0
  96. /package/hooks/blocking/{tdd-enforcer.py → tdd_enforcer.py} +0 -0
  97. /package/hooks/blocking/{test-preflight-check.py → test_preflight_check.py} +0 -0
  98. /package/hooks/blocking/{write-existing-file-blocker.py → write_existing_file_blocker.py} +0 -0
  99. /package/hooks/git-hooks/{post-commit.py → post_commit.py} +0 -0
  100. /package/hooks/lifecycle/{session-end-cleanup.py → session_end_cleanup.py} +0 -0
  101. /package/hooks/{rewrite-plugin-paths.py → rewrite_plugin_paths.py} +0 -0
  102. /package/hooks/session/{plugin-data-dir-cleanup.py → plugin_data_dir_cleanup.py} +0 -0
  103. /package/hooks/validation/{hook-format-validator.py → hook_format_validator.py} +0 -0
  104. /package/hooks/workflow/{auto-formatter.py → auto_formatter.py} +0 -0
  105. /package/hooks/workflow/{investigation-tracker-reset.py → investigation_tracker_reset.py} +0 -0
  106. /package/scripts/{sync-to-cursor.py → sync_to_cursor.py} +0 -0
@@ -1,10 +1,18 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
+ import json
3
4
  import os
4
5
  import platform
5
6
  import subprocess
7
+ from typing import Optional
6
8
 
7
9
  NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "")
10
+ BWS_FETCH_TIMEOUT_SECONDS = 10
11
+ BWS_EXECUTABLE_NAME = "bws"
12
+ BWS_SECRET_GET_OUTPUT_FORMAT = "json"
13
+ BWS_SECRET_JSON_VALUE_FIELD = "value"
14
+ DISCORD_WEBHOOK_CONTENT_TYPE_HEADER = "Content-Type: application/json"
15
+ DISCORD_WEBHOOK_USERNAME = "Claude Code"
8
16
  NTFY_BASE_URL = f"https://ntfy.sh/{NTFY_TOPIC}" if NTFY_TOPIC else ""
9
17
  WINDOWS_CHIMES_PATH = os.path.join(os.environ.get("SYSTEMROOT", r"C:\Windows"), "Media", "Windows Battery Critical.wav")
10
18
  LINUX_NOTIFICATION_SOUND = os.environ.get("NOTIFICATION_SOUND", "/usr/share/sounds/freedesktop/stereo/message.oga")
@@ -157,6 +165,54 @@ def notify_ntfy(title: str, message: str, priority: str = "high") -> None:
157
165
  pass
158
166
 
159
167
 
168
+ def fetch_bws_secret(secret_id: str) -> Optional[str]:
169
+ if not secret_id:
170
+ return None
171
+ try:
172
+ completed_bws_process = subprocess.run(
173
+ [BWS_EXECUTABLE_NAME, "secret", "get", secret_id, "--output", BWS_SECRET_GET_OUTPUT_FORMAT],
174
+ capture_output=True,
175
+ text=True,
176
+ timeout=BWS_FETCH_TIMEOUT_SECONDS,
177
+ check=True,
178
+ )
179
+ except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.CalledProcessError):
180
+ return None
181
+ try:
182
+ parsed_payload = json.loads(completed_bws_process.stdout)
183
+ except json.JSONDecodeError:
184
+ return None
185
+ secret_value = parsed_payload.get(BWS_SECRET_JSON_VALUE_FIELD)
186
+ if isinstance(secret_value, str):
187
+ return secret_value
188
+ return None
189
+
190
+
191
+ def notify_discord(title: str, message: str, webhook_secret_id: str) -> None:
192
+ if not webhook_secret_id:
193
+ return
194
+ webhook_url = fetch_bws_secret(webhook_secret_id)
195
+ if not webhook_url:
196
+ return
197
+ discord_payload = json.dumps({
198
+ "username": DISCORD_WEBHOOK_USERNAME,
199
+ "content": f"**{title}**\n{message}",
200
+ })
201
+ try:
202
+ subprocess.Popen(
203
+ [
204
+ "curl", "-s",
205
+ "-H", DISCORD_WEBHOOK_CONTENT_TYPE_HEADER,
206
+ "-d", discord_payload,
207
+ webhook_url,
208
+ ],
209
+ stdout=subprocess.DEVNULL,
210
+ stderr=subprocess.DEVNULL,
211
+ )
212
+ except FileNotFoundError:
213
+ pass
214
+
215
+
160
216
  def notify_linux() -> None:
161
217
  try:
162
218
  subprocess.Popen(
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SubagentStop notification hook - cross-platform (Windows/Linux/WSL)
4
+ Plays subtle sound + shows desktop notification when subagent completes.
5
+ """
6
+
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ import platform
11
+ import os
12
+ import time
13
+ from datetime import datetime
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+ from notification_utils import notify_discord
17
+
18
+ NTFY_TOPIC = os.environ.get("CLAUDE_NTFY_TOPIC", "")
19
+ DEFAULT_MESSAGE = "Task completed"
20
+ ACTIVITY_WEBHOOK_SECRET_ID = os.environ.get("BWS_DISCORD_ACTIVITY_SECRET_ID", "")
21
+
22
+
23
+ CACHE_DIR = os.path.join(os.path.expanduser("~"), ".claude", "cache")
24
+ LOG_FILE = os.path.join(CACHE_DIR, "subagent-notify-debug.log")
25
+
26
+
27
+ def log_debug(message: str) -> None:
28
+ """Append debug message to log file."""
29
+ try:
30
+ with open(LOG_FILE, "a") as f:
31
+ f.write(f"{datetime.now().isoformat()} - {message}\n")
32
+ except Exception:
33
+ pass
34
+
35
+
36
+ def get_task_info_from_stdin() -> str:
37
+ """Extract agent type and description from session transcript."""
38
+ try:
39
+ stdin_data = sys.stdin.read()
40
+ hook_input = json.loads(stdin_data)
41
+
42
+ agent_id = hook_input.get("agent_id", "")
43
+ transcript_path = hook_input.get("transcript_path", "")
44
+ agent_transcript_path = hook_input.get("agent_transcript_path", "")
45
+
46
+ log_debug(f"agent_id={agent_id}")
47
+
48
+ # Check if this is a prompt_suggestion agent (internal, skip notification)
49
+ if agent_transcript_path and "prompt_suggestion" in agent_transcript_path:
50
+ log_debug("skipping prompt_suggestion agent")
51
+ return "" # Empty string signals to skip notification
52
+
53
+ # Skip if agent transcript doesn't exist (ephemeral/internal agent)
54
+ if not agent_transcript_path or not os.path.exists(agent_transcript_path):
55
+ log_debug(f"no agent transcript file, skipping")
56
+ return ""
57
+
58
+ if not transcript_path or not os.path.exists(transcript_path):
59
+ log_debug(f"transcript not found or empty path")
60
+ return f"Agent {agent_id} completed" if agent_id else DEFAULT_MESSAGE
61
+
62
+ # Find the Task tool call that spawned this agent (with retry for race condition)
63
+ tool_use_id = None
64
+ for attempt in range(3):
65
+ with open(transcript_path, "r") as f:
66
+ for line in f:
67
+ if agent_id in line and "agent_progress" in line:
68
+ entry = json.loads(line)
69
+ tool_use_id = entry.get("parentToolUseID", "")
70
+ log_debug(f"found agent_progress, tool_use_id={tool_use_id}")
71
+ break
72
+ if tool_use_id:
73
+ break
74
+ log_debug(f"attempt {attempt + 1}: no agent_progress yet, waiting...")
75
+ time.sleep(0.1)
76
+
77
+ if not tool_use_id:
78
+ log_debug(f"no tool_use_id found for agent {agent_id} after retries")
79
+ return f"Agent {agent_id} completed" if agent_id else DEFAULT_MESSAGE
80
+
81
+ # Find the Task tool input with description and subagent_type
82
+ with open(transcript_path, "r") as f:
83
+ for line in f:
84
+ if tool_use_id in line and '"name":"Task"' in line:
85
+ entry = json.loads(line)
86
+ message = entry.get("message", {})
87
+ content = message.get("content", [])
88
+ for item in content:
89
+ if item.get("id") == tool_use_id:
90
+ task_input = item.get("input", {})
91
+ agent_type = task_input.get("subagent_type", "")
92
+ description = task_input.get("description", "")
93
+ log_debug(
94
+ f"found Task input: type={agent_type}, desc={description}"
95
+ )
96
+ if agent_type and description:
97
+ return f"{agent_type}: {description}"
98
+ elif description:
99
+ return description
100
+ elif agent_type:
101
+ return f"{agent_type} completed"
102
+ break
103
+
104
+ log_debug(f"no Task tool found with id {tool_use_id}")
105
+ return f"Agent {agent_id} completed" if agent_id else DEFAULT_MESSAGE
106
+
107
+ except Exception as e:
108
+ log_debug(f"exception: {type(e).__name__}: {e}")
109
+ return DEFAULT_MESSAGE
110
+
111
+
112
+ def get_project_name() -> str:
113
+ """Get project name from working directory."""
114
+ return os.path.basename(os.getcwd())
115
+
116
+
117
+ def notify_ntfy(title: str, message: str, priority: str = "default") -> None:
118
+ """Send push notification via ntfy.sh with title and message."""
119
+ if not NTFY_TOPIC:
120
+ return
121
+ try:
122
+ subprocess.Popen(
123
+ [
124
+ "curl",
125
+ "-s",
126
+ "-H",
127
+ f"Priority: {priority}",
128
+ "-H",
129
+ "Tags: bell",
130
+ "-H",
131
+ f"Title: {title}",
132
+ "-d",
133
+ message,
134
+ f"https://ntfy.sh/{NTFY_TOPIC}",
135
+ ],
136
+ stdout=subprocess.DEVNULL,
137
+ stderr=subprocess.DEVNULL,
138
+ )
139
+ except FileNotFoundError:
140
+ pass
141
+
142
+
143
+ def is_wsl() -> bool:
144
+ """Detect if running in Windows Subsystem for Linux."""
145
+ if platform.system() != "Linux":
146
+ return False
147
+ try:
148
+ with open("/proc/version", "r") as f:
149
+ return "microsoft" in f.read().lower()
150
+ except FileNotFoundError:
151
+ return False
152
+
153
+
154
+ TOAST_SCRIPT_TEMPLATE = r"""
155
+ Add-Type -AssemblyName System.Windows.Forms
156
+ Add-Type -AssemblyName System.Drawing
157
+ Add-Type @"
158
+ using System;
159
+ using System.Runtime.InteropServices;
160
+ public class Win32 {{
161
+ [DllImport("user32.dll")]
162
+ public static extern bool SetProcessDPIAware();
163
+ [DllImport("user32.dll")]
164
+ public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
165
+ [DllImport("user32.dll")]
166
+ public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
167
+ [DllImport("user32.dll")]
168
+ public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
169
+ [DllImport("user32.dll")]
170
+ public static extern bool SetLayeredWindowAttributes(IntPtr hwnd, uint crKey, byte bAlpha, uint dwFlags);
171
+ public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
172
+ public const uint SWP_NOACTIVATE = 0x0010;
173
+ public const uint SWP_SHOWWINDOW = 0x0040;
174
+ public const int GWL_EXSTYLE = -20;
175
+ public const int WS_EX_LAYERED = 0x80000;
176
+ public const int WS_EX_TRANSPARENT = 0x20;
177
+ public const uint LWA_ALPHA = 0x2;
178
+ }}
179
+ "@
180
+
181
+ # Enable DPI awareness for sharp text
182
+ [Win32]::SetProcessDPIAware() | Out-Null
183
+
184
+ $form = New-Object System.Windows.Forms.Form
185
+ $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
186
+ $form.Size = New-Object System.Drawing.Size(520, 110)
187
+ $form.ShowInTaskbar = $false
188
+ $form.BackColor = [System.Drawing.Color]::FromArgb(66, 135, 245)
189
+ $form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
190
+
191
+ # Position at bottom center of primary screen
192
+ $screen = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
193
+ $x = [int]($screen.Left + ($screen.Width - 520) / 2)
194
+ $y = [int]($screen.Bottom - 110 - 50)
195
+ $form.Location = New-Object System.Drawing.Point($x, $y)
196
+
197
+ # Inner panel for dark background (creates border effect)
198
+ $inner = New-Object System.Windows.Forms.Panel
199
+ $inner.Size = New-Object System.Drawing.Size(514, 104)
200
+ $inner.Location = New-Object System.Drawing.Point(3, 3)
201
+ $inner.BackColor = [System.Drawing.Color]::FromArgb(45, 45, 45)
202
+ $form.Controls.Add($inner)
203
+
204
+ # Title label (project name)
205
+ $titleLabel = New-Object System.Windows.Forms.Label
206
+ $titleLabel.Text = "{title}"
207
+ $titleLabel.Font = New-Object System.Drawing.Font("Segoe UI", 12, [System.Drawing.FontStyle]::Bold)
208
+ $titleLabel.ForeColor = [System.Drawing.Color]::FromArgb(120, 180, 255)
209
+ $titleLabel.AutoSize = $false
210
+ $titleLabel.Size = New-Object System.Drawing.Size(514, 30)
211
+ $titleLabel.Location = New-Object System.Drawing.Point(0, 8)
212
+ $titleLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
213
+ $inner.Controls.Add($titleLabel)
214
+
215
+ # Message label
216
+ $messageLabel = New-Object System.Windows.Forms.Label
217
+ $messageLabel.Text = "{message}"
218
+ $messageLabel.Font = New-Object System.Drawing.Font("Segoe UI", 11)
219
+ $messageLabel.ForeColor = [System.Drawing.Color]::White
220
+ $messageLabel.AutoSize = $false
221
+ $messageLabel.Size = New-Object System.Drawing.Size(500, 58)
222
+ $messageLabel.Location = New-Object System.Drawing.Point(7, 40)
223
+ $messageLabel.TextAlign = [System.Drawing.ContentAlignment]::TopCenter
224
+ $inner.Controls.Add($messageLabel)
225
+
226
+ $timer = New-Object System.Windows.Forms.Timer
227
+ $timer.Interval = 6000
228
+ $timer.Add_Tick({{ $form.Close() }})
229
+ $timer.Start()
230
+
231
+ # Make click-through and show without stealing focus
232
+ $exStyle = [Win32]::GetWindowLong($form.Handle, [Win32]::GWL_EXSTYLE)
233
+ [Win32]::SetWindowLong($form.Handle, [Win32]::GWL_EXSTYLE, $exStyle -bor [Win32]::WS_EX_LAYERED -bor [Win32]::WS_EX_TRANSPARENT)
234
+ [Win32]::SetLayeredWindowAttributes($form.Handle, 0, 230, [Win32]::LWA_ALPHA)
235
+ [Win32]::SetWindowPos($form.Handle, [Win32]::HWND_TOPMOST, $x, $y, 520, 110, [Win32]::SWP_NOACTIVATE -bor [Win32]::SWP_SHOWWINDOW)
236
+ $form.Show()
237
+ [System.Windows.Forms.Application]::Run($form)
238
+ """
239
+
240
+
241
+ def build_toast_script(title: str, message: str) -> str:
242
+ """Build PowerShell toast script with dynamic title and message."""
243
+ safe_title = title.replace('"', '`"').replace("'", "`'")
244
+ safe_message = message.replace('"', '`"').replace("'", "`'")
245
+ return TOAST_SCRIPT_TEMPLATE.format(title=safe_title, message=safe_message)
246
+
247
+
248
+ def notify_windows(title: str, message: str) -> None:
249
+ """Windows bottom-center toast notification - non-blocking, no title bar."""
250
+ script = build_toast_script(title, message)
251
+ subprocess.Popen(
252
+ ["powershell", "-ExecutionPolicy", "Bypass", "-Command", script],
253
+ stdout=subprocess.DEVNULL,
254
+ stderr=subprocess.DEVNULL,
255
+ creationflags=subprocess.CREATE_NO_WINDOW
256
+ if hasattr(subprocess, "CREATE_NO_WINDOW")
257
+ else 0,
258
+ )
259
+
260
+
261
+ def notify_wsl(title: str, message: str) -> None:
262
+ """WSL bottom-center toast notification - non-blocking, no title bar."""
263
+ script = build_toast_script(title, message)
264
+ try:
265
+ subprocess.Popen(
266
+ ["powershell.exe", "-ExecutionPolicy", "Bypass", "-Command", script],
267
+ stdout=subprocess.DEVNULL,
268
+ stderr=subprocess.DEVNULL,
269
+ start_new_session=True,
270
+ )
271
+ except FileNotFoundError:
272
+ pass
273
+
274
+
275
+ def notify_linux() -> None:
276
+ """Linux notification using notify-send."""
277
+ subprocess.Popen(
278
+ [
279
+ "notify-send",
280
+ "-t",
281
+ "3000",
282
+ "-i",
283
+ "dialog-information",
284
+ "Claude Code",
285
+ "Subagent task completed",
286
+ ],
287
+ stdout=subprocess.DEVNULL,
288
+ stderr=subprocess.DEVNULL,
289
+ )
290
+
291
+
292
+ def sound_windows() -> None:
293
+ """Windows sound - play notification wav file."""
294
+ subprocess.Popen(
295
+ [
296
+ "powershell",
297
+ "-WindowStyle",
298
+ "Hidden",
299
+ "-Command",
300
+ "(New-Object Media.SoundPlayer 'C:\\Windows\\Media\\Windows Battery Critical.wav').PlaySync()",
301
+ ],
302
+ stdout=subprocess.DEVNULL,
303
+ stderr=subprocess.DEVNULL,
304
+ creationflags=subprocess.CREATE_NO_WINDOW
305
+ if hasattr(subprocess, "CREATE_NO_WINDOW")
306
+ else 0,
307
+ )
308
+
309
+
310
+ def sound_wsl() -> None:
311
+ """WSL sound - plays Windows notification wav via powershell.exe."""
312
+ try:
313
+ subprocess.Popen(
314
+ [
315
+ "powershell.exe",
316
+ "-WindowStyle",
317
+ "Hidden",
318
+ "-Command",
319
+ "(New-Object Media.SoundPlayer 'C:\\Windows\\Media\\Windows Battery Critical.wav').PlaySync()",
320
+ ],
321
+ stdout=subprocess.DEVNULL,
322
+ stderr=subprocess.DEVNULL,
323
+ )
324
+ except FileNotFoundError:
325
+ pass
326
+
327
+
328
+ def sound_linux() -> None:
329
+ """Linux sound - try multiple methods."""
330
+ sound_file = "/usr/share/sounds/freedesktop/stereo/message.oga"
331
+
332
+ if os.path.exists(sound_file):
333
+ for player in ["paplay", "aplay", "play"]:
334
+ try:
335
+ subprocess.Popen(
336
+ [player, sound_file],
337
+ stdout=subprocess.DEVNULL,
338
+ stderr=subprocess.DEVNULL,
339
+ )
340
+ return
341
+ except FileNotFoundError:
342
+ continue
343
+
344
+ # Fallback: terminal bell
345
+ print("\a", end="", flush=True)
346
+
347
+
348
+ def main() -> None:
349
+ system = platform.system()
350
+
351
+ project_name = get_project_name()
352
+ task_description = get_task_info_from_stdin()
353
+
354
+ # Skip notification for internal agents (empty description)
355
+ if not task_description:
356
+ return
357
+
358
+ # Always send to phone with project context
359
+ notify_ntfy(title=project_name, message=task_description)
360
+ notify_discord(
361
+ title=project_name,
362
+ message=task_description,
363
+ webhook_secret_id=ACTIVITY_WEBHOOK_SECRET_ID,
364
+ )
365
+
366
+ if system == "Windows":
367
+ sound_windows()
368
+ notify_windows(project_name, task_description)
369
+ elif is_wsl():
370
+ sound_wsl()
371
+ notify_wsl(project_name, task_description)
372
+ elif system == "Linux":
373
+ sound_linux()
374
+ notify_linux()
375
+ else:
376
+ # macOS or other - just print bell
377
+ print("\a", end="", flush=True)
378
+
379
+
380
+ if __name__ == "__main__":
381
+ main()
@@ -0,0 +1,47 @@
1
+ """Unit tests for attention-needed-notify Discord wiring."""
2
+
3
+ import importlib.util
4
+ import io
5
+ import pathlib
6
+ import types
7
+ from unittest.mock import patch
8
+
9
+ HOOK_DIRECTORY = pathlib.Path(__file__).parent
10
+ MODULE_PATH = HOOK_DIRECTORY / "attention_needed_notify.py"
11
+
12
+ FIXTURE_ATTENTION_SECRET_ID = "fixture-attention-id-0002"
13
+ NON_WINDOWS_NON_WSL_PLATFORM = "Darwin"
14
+ EMPTY_HOOK_INPUT_JSON = "{}"
15
+
16
+
17
+ def load_hook_with_environment(
18
+ environment_overrides: dict[str, str],
19
+ ) -> types.ModuleType:
20
+ module_specification = importlib.util.spec_from_file_location(
21
+ "attention_needed_notify_under_test",
22
+ MODULE_PATH,
23
+ )
24
+ assert module_specification is not None
25
+ assert module_specification.loader is not None
26
+ module_under_test = importlib.util.module_from_spec(module_specification)
27
+ with patch.dict("os.environ", environment_overrides, clear=False):
28
+ module_specification.loader.exec_module(module_under_test)
29
+ return module_under_test
30
+
31
+
32
+ def test_main_forwards_attention_secret_id_to_notify_discord() -> None:
33
+ module_under_test = load_hook_with_environment(
34
+ {"BWS_DISCORD_ATTENTION_SECRET_ID": FIXTURE_ATTENTION_SECRET_ID}
35
+ )
36
+ with (
37
+ patch.object(module_under_test, "notify_ntfy"),
38
+ patch.object(module_under_test, "notify_discord") as discord_spy,
39
+ patch.object(module_under_test, "is_wsl", return_value=False),
40
+ patch.object(module_under_test, "platform") as platform_stub,
41
+ patch("sys.stdin", io.StringIO(EMPTY_HOOK_INPUT_JSON)),
42
+ ):
43
+ platform_stub.system.return_value = NON_WINDOWS_NON_WSL_PLATFORM
44
+ module_under_test.main()
45
+ assert discord_spy.call_count == 1
46
+ call_kwargs = discord_spy.call_args.kwargs
47
+ assert call_kwargs["webhook_secret_id"] == FIXTURE_ATTENTION_SECRET_ID
@@ -0,0 +1,54 @@
1
+ """Unit tests for claude-notification-handler Discord wiring."""
2
+
3
+ import importlib.util
4
+ import pathlib
5
+ import types
6
+ from unittest.mock import patch
7
+
8
+ HOOK_DIRECTORY = pathlib.Path(__file__).parent
9
+ MODULE_PATH = HOOK_DIRECTORY / "claude_notification_handler.py"
10
+
11
+ FIXTURE_ATTENTION_SECRET_ID = "fixture-attention-id-0001"
12
+ FIXTURE_PROJECT_NAME = "fixture-project"
13
+ FIXTURE_MESSAGE = "attention required"
14
+ FIXTURE_PRIORITY = "default"
15
+ NON_WINDOWS_NON_WSL_PLATFORM = "Darwin"
16
+
17
+
18
+ def load_handler_with_environment(
19
+ environment_overrides: dict[str, str],
20
+ ) -> types.ModuleType:
21
+ module_specification = importlib.util.spec_from_file_location(
22
+ "claude_notification_handler_under_test",
23
+ MODULE_PATH,
24
+ )
25
+ assert module_specification is not None
26
+ assert module_specification.loader is not None
27
+ module_under_test = importlib.util.module_from_spec(module_specification)
28
+ with patch.dict("os.environ", environment_overrides, clear=False):
29
+ module_specification.loader.exec_module(module_under_test)
30
+ return module_under_test
31
+
32
+
33
+ def test_send_desktop_and_push_notification_forwards_attention_secret_id_to_notify_discord() -> (
34
+ None
35
+ ):
36
+ module_under_test = load_handler_with_environment(
37
+ {"BWS_DISCORD_ATTENTION_SECRET_ID": FIXTURE_ATTENTION_SECRET_ID}
38
+ )
39
+ with (
40
+ patch.object(module_under_test, "notify_ntfy"),
41
+ patch.object(module_under_test, "notify_discord") as discord_spy,
42
+ patch.object(module_under_test, "platform") as platform_stub,
43
+ ):
44
+ platform_stub.system.return_value = NON_WINDOWS_NON_WSL_PLATFORM
45
+ module_under_test.send_desktop_and_push_notification(
46
+ project_name=FIXTURE_PROJECT_NAME,
47
+ notification_message=FIXTURE_MESSAGE,
48
+ ntfy_priority=FIXTURE_PRIORITY,
49
+ )
50
+ assert discord_spy.call_count == 1
51
+ call_kwargs = discord_spy.call_args.kwargs
52
+ assert call_kwargs["webhook_secret_id"] == FIXTURE_ATTENTION_SECRET_ID
53
+ assert call_kwargs["title"] == FIXTURE_PROJECT_NAME
54
+ assert call_kwargs["message"] == FIXTURE_MESSAGE
@@ -1,13 +1,18 @@
1
1
  """Unit tests for notification_utils ntfy guard behavior."""
2
2
 
3
3
  import importlib.util
4
+ import json
4
5
  import pathlib
6
+ import subprocess
5
7
  import types
6
8
  from unittest.mock import patch
7
9
 
8
10
  HOOK_DIRECTORY = pathlib.Path(__file__).parent
9
11
  MODULE_PATH = HOOK_DIRECTORY / "notification_utils.py"
10
12
 
13
+ FIXTURE_DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/111/aaa-fixture"
14
+ FIXTURE_SECRET_ID = "fixture-secret-id-0000"
15
+
11
16
 
12
17
  def load_notification_utils_with_environment(
13
18
  environment_overrides: dict[str, str],
@@ -44,3 +49,43 @@ def test_should_invoke_curl_when_topic_environment_variable_is_set() -> None:
44
49
  assert popen_spy.call_count == 1
45
50
  curl_arguments = popen_spy.call_args.args[0]
46
51
  assert "https://ntfy.sh/private-topic-for-test" in curl_arguments
52
+
53
+
54
+ def test_fetch_bws_secret_returns_none_when_secret_id_is_empty() -> None:
55
+ module_under_test = load_notification_utils_with_environment({})
56
+ with patch("subprocess.run") as run_spy:
57
+ fetched_value = module_under_test.fetch_bws_secret("")
58
+ assert fetched_value is None
59
+ assert run_spy.call_count == 0
60
+
61
+
62
+ def test_notify_discord_skips_curl_when_secret_id_is_empty() -> None:
63
+ module_under_test = load_notification_utils_with_environment({})
64
+ with patch("subprocess.Popen") as popen_spy:
65
+ module_under_test.notify_discord(
66
+ title="Test",
67
+ message="payload",
68
+ webhook_secret_id="",
69
+ )
70
+ assert popen_spy.call_count == 0
71
+
72
+
73
+ def test_notify_discord_invokes_curl_when_bws_returns_url() -> None:
74
+ module_under_test = load_notification_utils_with_environment({})
75
+ bws_completed = subprocess.CompletedProcess(
76
+ args=[],
77
+ returncode=0,
78
+ stdout=json.dumps({"value": FIXTURE_DISCORD_WEBHOOK_URL}),
79
+ stderr="",
80
+ )
81
+ with patch("subprocess.run", return_value=bws_completed), patch(
82
+ "subprocess.Popen"
83
+ ) as popen_spy:
84
+ module_under_test.notify_discord(
85
+ title="Test",
86
+ message="payload",
87
+ webhook_secret_id=FIXTURE_SECRET_ID,
88
+ )
89
+ assert popen_spy.call_count == 1
90
+ popen_arguments = popen_spy.call_args.args[0]
91
+ assert FIXTURE_DISCORD_WEBHOOK_URL in popen_arguments