claude-dev-env 1.25.1 → 1.26.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 (105) 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} +150 -5
  6. package/hooks/blocking/{destructive-command-blocker.py → destructive_command_blocker.py} +12 -4
  7. package/hooks/blocking/{tdd-enforcer.py → tdd_enforcer.py} +12 -0
  8. package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +2 -2
  9. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +2 -2
  10. package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +1 -1
  11. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +2 -2
  12. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +181 -0
  13. package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +4 -4
  14. package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +1 -1
  15. package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
  16. package/hooks/blocking/test_code_rules_enforcer_magic_string_masking.py +104 -0
  17. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +2 -2
  18. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +2 -2
  19. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +1 -1
  20. package/hooks/blocking/test_destructive_command_blocker.py +63 -4
  21. package/hooks/blocking/test_gh_body_arg_blocker.py +1 -1
  22. package/hooks/blocking/test_pr_description_enforcer.py +8 -8
  23. package/hooks/blocking/test_tdd_enforcer.py +53 -1
  24. package/hooks/github-action/pre-push-review.yml +27 -0
  25. package/hooks/hooks.json +28 -28
  26. package/hooks/lifecycle/{config-change-guard.py → config_change_guard.py} +27 -12
  27. package/hooks/lifecycle/test_config_change_guard.py +3 -3
  28. package/hooks/notification/{attention-needed-notify.py → attention_needed_notify.py} +7 -0
  29. package/hooks/notification/{claude-notification-handler.py → claude_notification_handler.py} +8 -0
  30. package/hooks/notification/notification_utils.py +60 -2
  31. package/hooks/notification/subagent_complete_notify.py +381 -0
  32. package/hooks/notification/test_attention_needed_notify.py +47 -0
  33. package/hooks/notification/test_claude_notification_handler.py +54 -0
  34. package/hooks/notification/test_notification_utils.py +91 -0
  35. package/hooks/notification/test_subagent_complete_notify.py +72 -0
  36. package/hooks/validators/README.md +5 -1
  37. package/hooks/validators/abbreviation_checks.py +1 -1
  38. package/hooks/validators/code_quality_checks.py +1 -1
  39. package/hooks/validators/config.py +5 -0
  40. package/hooks/validators/conftest.py +10 -0
  41. package/hooks/validators/exempt_paths.py +1 -1
  42. package/hooks/validators/git_checks.py +80 -0
  43. package/hooks/validators/magic_value_checks.py +2 -2
  44. package/hooks/validators/pr_reference_checks.py +1 -1
  45. package/hooks/validators/python_antipattern_checks.py +1 -1
  46. package/hooks/validators/run_all_validators.py +53 -105
  47. package/hooks/validators/security_checks.py +1 -1
  48. package/hooks/validators/test_abbreviation_checks.py +2 -2
  49. package/hooks/validators/test_code_quality_checks.py +2 -2
  50. package/hooks/validators/test_file_structure_checks.py +1 -1
  51. package/hooks/validators/test_git_checks.py +79 -13
  52. package/hooks/validators/test_health_check.py +1 -1
  53. package/hooks/validators/test_magic_value_checks.py +2 -2
  54. package/hooks/validators/test_mypy_integration.py +1 -1
  55. package/hooks/validators/test_output_formatter.py +3 -1
  56. package/hooks/validators/test_pr_reference_checks.py +2 -2
  57. package/hooks/validators/test_python_antipattern_checks.py +2 -2
  58. package/hooks/validators/test_python_style_checks.py +2 -4
  59. package/hooks/validators/test_react_checks.py +1 -1
  60. package/hooks/validators/test_ruff_integration.py +1 -1
  61. package/hooks/validators/test_run_all_validators.py +75 -43
  62. package/hooks/validators/test_run_all_validators_integration.py +14 -37
  63. package/hooks/validators/test_security_checks.py +2 -2
  64. package/hooks/validators/test_test_safety_checks.py +1 -1
  65. package/hooks/validators/test_todo_checks.py +2 -2
  66. package/hooks/validators/test_type_safety_checks.py +2 -2
  67. package/hooks/validators/test_useless_test_checks.py +2 -2
  68. package/hooks/validators/test_validator_base.py +1 -1
  69. package/hooks/validators/test_verify_paths.py +2 -4
  70. package/hooks/validators/todo_checks.py +1 -1
  71. package/hooks/validators/type_safety_checks.py +1 -1
  72. package/hooks/validators/useless_test_checks.py +1 -1
  73. package/package.json +1 -1
  74. package/rules/file-global-constants.md +71 -0
  75. package/rules/gh-body-file.md +1 -1
  76. package/rules/prompt-workflow-context-controls.md +48 -0
  77. package/scripts/sync_to_cursor/rules.py +2 -2
  78. package/scripts/tests/test_sync_to_cursor.py +2 -2
  79. package/skills/bugteam/CONSTRAINTS.md +37 -0
  80. package/skills/bugteam/EXAMPLES.md +64 -0
  81. package/skills/bugteam/PROMPTS.md +175 -0
  82. package/skills/bugteam/SKILL.md +204 -295
  83. package/skills/bugteam/SKILL_EVALS.md +346 -0
  84. package/skills/bugteam/scripts/README.md +37 -0
  85. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +334 -0
  86. package/skills/bugteam/scripts/bugteam_preflight.py +135 -0
  87. package/skills/rule-audit/SKILL.md +4 -4
  88. /package/hooks/advisory/{migration-safety-advisor.py → migration_safety_advisor.py} +0 -0
  89. /package/hooks/advisory/{refactor-guard.py → refactor_guard.py} +0 -0
  90. /package/hooks/blocking/{block-main-commit.py → block_main_commit.py} +0 -0
  91. /package/hooks/blocking/{content-search-to-zoekt-redirector.py → content_search_to_zoekt_redirector.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/{test-preflight-check.py → test_preflight_check.py} +0 -0
  97. /package/hooks/blocking/{write-existing-file-blocker.py → write_existing_file_blocker.py} +0 -0
  98. /package/hooks/git-hooks/{post-commit.py → post_commit.py} +0 -0
  99. /package/hooks/lifecycle/{session-end-cleanup.py → session_end_cleanup.py} +0 -0
  100. /package/hooks/{rewrite-plugin-paths.py → rewrite_plugin_paths.py} +0 -0
  101. /package/hooks/session/{plugin-data-dir-cleanup.py → plugin_data_dir_cleanup.py} +0 -0
  102. /package/hooks/validation/{hook-format-validator.py → hook_format_validator.py} +0 -0
  103. /package/hooks/workflow/{auto-formatter.py → auto_formatter.py} +0 -0
  104. /package/hooks/workflow/{investigation-tracker-reset.py → investigation_tracker_reset.py} +0 -0
  105. /package/scripts/{sync-to-cursor.py → sync_to_cursor.py} +0 -0
@@ -1,11 +1,17 @@
1
1
  #!/usr/bin/env python3
2
+ # pragma: no-tdd-gate
2
3
  from datetime import datetime
3
4
  import json
4
5
  import os
5
6
  import sys
6
7
 
7
8
  AUDIT_LOG = os.path.expanduser("~/.claude/cache/config-change-audit.log")
8
- KNOWN_HOOK_COUNT_FILE = os.path.expanduser("~/.claude/cache/known-hook-count.txt")
9
+ # pragma: no-tdd-gate
10
+ DEFAULT_KNOWN_HOOK_COUNT_FILE = os.path.expanduser("~/.claude/cache/known-hook-count.txt")
11
+
12
+
13
+ def get_known_hook_count_file() -> str:
14
+ return os.environ.get("KNOWN_HOOK_COUNT_FILE", DEFAULT_KNOWN_HOOK_COUNT_FILE)
9
15
 
10
16
 
11
17
  def count_hooks_in_settings(file_path: str) -> int:
@@ -32,36 +38,45 @@ def write_audit_entry(source: str, file_path: str) -> None:
32
38
  pass
33
39
 
34
40
 
41
+ # pragma: no-tdd-gate
35
42
  def guard_hook_injection(file_path: str) -> None:
36
43
  current_count = count_hooks_in_settings(file_path)
44
+ known_hook_count_file = get_known_hook_count_file()
37
45
 
38
- if not os.path.exists(KNOWN_HOOK_COUNT_FILE):
46
+ if not os.path.exists(known_hook_count_file):
39
47
  try:
40
- with open(KNOWN_HOOK_COUNT_FILE, "w") as count_file:
48
+ with open(known_hook_count_file, "w") as count_file:
41
49
  count_file.write(str(current_count))
42
50
  except OSError:
43
51
  pass
44
52
  return
45
53
 
46
54
  try:
47
- with open(KNOWN_HOOK_COUNT_FILE) as count_file:
55
+ with open(known_hook_count_file) as count_file:
48
56
  stored_count = int(count_file.read().strip())
49
57
  except (OSError, ValueError):
50
58
  stored_count = current_count
51
59
 
60
+ # pragma: no-tdd-gate
61
+ if current_count > stored_count:
62
+ block_reason = (
63
+ f"Hook count increased from {stored_count} to {current_count}. "
64
+ f"Review the added hook entries before proceeding. "
65
+ f"Delete known-hook-count.txt to reset."
66
+ )
67
+ block_payload = {
68
+ "decision": "block",
69
+ "reason": block_reason,
70
+ }
71
+ print(json.dumps(block_payload))
72
+ return
73
+
52
74
  try:
53
- with open(KNOWN_HOOK_COUNT_FILE, "w") as count_file:
75
+ with open(known_hook_count_file, "w") as count_file:
54
76
  count_file.write(str(current_count))
55
77
  except OSError:
56
78
  pass
57
79
 
58
- if current_count > stored_count:
59
- block_decision = {
60
- "decision": "block",
61
- "reason": f"Hook count changed {stored_count} -> {current_count}. Delete known-hook-count.txt to reset.",
62
- }
63
- print(json.dumps(block_decision))
64
-
65
80
 
66
81
  def main() -> None:
67
82
  try:
@@ -5,7 +5,7 @@ import sys
5
5
  from pathlib import Path
6
6
 
7
7
 
8
- HOOK_PATH = Path(__file__).parent / "config-change-guard.py"
8
+ HOOK_PATH = Path(__file__).parent / "config_change_guard.py"
9
9
 
10
10
 
11
11
  def _run_hook(
@@ -52,8 +52,8 @@ def test_hook_count_increase_emits_user_visible_output(tmp_path: Path) -> None:
52
52
  block_payload = json.loads(hook_run.stdout)
53
53
  assert block_payload["decision"] == "block"
54
54
  assert "2" in block_payload["reason"] and "5" in block_payload["reason"]
55
- assert block_payload["hookSpecificOutput"]["hookEventName"] == "ConfigChange"
56
- assert "hook" in block_payload["hookSpecificOutput"]["additionalContext"].lower()
55
+ assert "hook" in block_payload["reason"].lower()
56
+ assert "hookSpecificOutput" not in block_payload
57
57
 
58
58
 
59
59
  def test_hook_count_stable_produces_no_output(tmp_path: Path) -> None:
@@ -12,6 +12,7 @@ import sys
12
12
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
13
  from notification_utils import (
14
14
  notify_ntfy,
15
+ notify_discord,
15
16
  is_wsl,
16
17
  notify_windows,
17
18
  notify_wsl,
@@ -23,6 +24,7 @@ from notification_utils import (
23
24
  )
24
25
 
25
26
  DEFAULT_MESSAGE = "Input needed"
27
+ ATTENTION_WEBHOOK_SECRET_ID = os.environ.get("BWS_DISCORD_ATTENTION_SECRET_ID", "")
26
28
 
27
29
 
28
30
  def get_question_from_stdin() -> str:
@@ -46,6 +48,11 @@ def main() -> None:
46
48
  question_text = get_question_from_stdin()
47
49
 
48
50
  notify_ntfy(title=project_name, message=question_text)
51
+ notify_discord(
52
+ title=project_name,
53
+ message=question_text,
54
+ webhook_secret_id=ATTENTION_WEBHOOK_SECRET_ID,
55
+ )
49
56
 
50
57
  if system == "Windows":
51
58
  sound_windows()
@@ -8,6 +8,7 @@ import sys
8
8
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
9
  from notification_utils import (
10
10
  notify_ntfy,
11
+ notify_discord,
11
12
  is_wsl,
12
13
  notify_windows,
13
14
  notify_wsl,
@@ -16,6 +17,8 @@ from notification_utils import (
16
17
  get_project_name,
17
18
  )
18
19
 
20
+ ATTENTION_WEBHOOK_SECRET_ID = os.environ.get("BWS_DISCORD_ATTENTION_SECRET_ID", "")
21
+
19
22
 
20
23
  def send_desktop_and_push_notification(
21
24
  project_name: str,
@@ -23,6 +26,11 @@ def send_desktop_and_push_notification(
23
26
  ntfy_priority: str,
24
27
  ) -> None:
25
28
  notify_ntfy(title=project_name, message=notification_message, priority=ntfy_priority)
29
+ notify_discord(
30
+ title=project_name,
31
+ message=notification_message,
32
+ webhook_secret_id=ATTENTION_WEBHOOK_SECRET_ID,
33
+ )
26
34
  system = platform.system()
27
35
  if system == "Windows":
28
36
  sound_windows()
@@ -1,11 +1,19 @@
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
- NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "claude-notifications")
8
- NTFY_BASE_URL = f"https://ntfy.sh/{NTFY_TOPIC}"
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"
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")
11
19
  LINUX_NOTIFICATION_TIMEOUT_MS = "3000"
@@ -138,6 +146,8 @@ def notify_windows(title: str, message: str) -> None:
138
146
 
139
147
 
140
148
  def notify_ntfy(title: str, message: str, priority: str = "high") -> None:
149
+ if not NTFY_TOPIC:
150
+ return
141
151
  try:
142
152
  subprocess.Popen(
143
153
  [
@@ -155,6 +165,54 @@ def notify_ntfy(title: str, message: str, priority: str = "high") -> None:
155
165
  pass
156
166
 
157
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
+
158
216
  def notify_linux() -> None:
159
217
  try:
160
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
+
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+ from notification_utils import notify_discord
15
+
16
+ NTFY_TOPIC = "claude-02633f9d93ea8794"
17
+ DEFAULT_MESSAGE = "Task completed"
18
+ ACTIVITY_WEBHOOK_SECRET_ID = os.environ.get("BWS_DISCORD_ACTIVITY_SECRET_ID", "")
19
+
20
+
21
+ CACHE_DIR = os.path.join(os.path.expanduser("~"), ".claude", "cache")
22
+ LOG_FILE = os.path.join(CACHE_DIR, "subagent-notify-debug.log")
23
+
24
+
25
+ def log_debug(message: str) -> None:
26
+ """Append debug message to log file."""
27
+ try:
28
+ from datetime import datetime
29
+
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
+ import time
64
+
65
+ tool_use_id = None
66
+ for attempt in range(3):
67
+ with open(transcript_path, "r") as f:
68
+ for line in f:
69
+ if agent_id in line and "agent_progress" in line:
70
+ entry = json.loads(line)
71
+ tool_use_id = entry.get("parentToolUseID", "")
72
+ log_debug(f"found agent_progress, tool_use_id={tool_use_id}")
73
+ break
74
+ if tool_use_id:
75
+ break
76
+ log_debug(f"attempt {attempt + 1}: no agent_progress yet, waiting...")
77
+ time.sleep(0.1)
78
+
79
+ if not tool_use_id:
80
+ log_debug(f"no tool_use_id found for agent {agent_id} after retries")
81
+ return f"Agent {agent_id} completed" if agent_id else DEFAULT_MESSAGE
82
+
83
+ # Find the Task tool input with description and subagent_type
84
+ with open(transcript_path, "r") as f:
85
+ for line in f:
86
+ if tool_use_id in line and '"name":"Task"' in line:
87
+ entry = json.loads(line)
88
+ message = entry.get("message", {})
89
+ content = message.get("content", [])
90
+ for item in content:
91
+ if item.get("id") == tool_use_id:
92
+ task_input = item.get("input", {})
93
+ agent_type = task_input.get("subagent_type", "")
94
+ description = task_input.get("description", "")
95
+ log_debug(
96
+ f"found Task input: type={agent_type}, desc={description}"
97
+ )
98
+ if agent_type and description:
99
+ return f"{agent_type}: {description}"
100
+ elif description:
101
+ return description
102
+ elif agent_type:
103
+ return f"{agent_type} completed"
104
+ break
105
+
106
+ log_debug(f"no Task tool found with id {tool_use_id}")
107
+ return f"Agent {agent_id} completed" if agent_id else DEFAULT_MESSAGE
108
+
109
+ except Exception as e:
110
+ log_debug(f"exception: {type(e).__name__}: {e}")
111
+ return DEFAULT_MESSAGE
112
+
113
+
114
+ def get_project_name() -> str:
115
+ """Get project name from working directory."""
116
+ return os.path.basename(os.getcwd())
117
+
118
+
119
+ def notify_ntfy(title: str, message: str, priority: str = "default") -> None:
120
+ """Send push notification via ntfy.sh with title and message."""
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