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.
- package/CLAUDE.md +6 -0
- package/agents/clean-coder.md +1 -1
- package/docs/CODE_RULES.md +3 -1
- package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +54 -0
- package/hooks/blocking/{code-rules-enforcer.py → code_rules_enforcer.py} +150 -5
- package/hooks/blocking/{destructive-command-blocker.py → destructive_command_blocker.py} +12 -4
- package/hooks/blocking/{tdd-enforcer.py → tdd_enforcer.py} +12 -0
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +181 -0
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_magic_string_masking.py +104 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +2 -2
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker.py +63 -4
- package/hooks/blocking/test_gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/test_pr_description_enforcer.py +8 -8
- package/hooks/blocking/test_tdd_enforcer.py +53 -1
- package/hooks/github-action/pre-push-review.yml +27 -0
- package/hooks/hooks.json +28 -28
- package/hooks/lifecycle/{config-change-guard.py → config_change_guard.py} +27 -12
- package/hooks/lifecycle/test_config_change_guard.py +3 -3
- package/hooks/notification/{attention-needed-notify.py → attention_needed_notify.py} +7 -0
- package/hooks/notification/{claude-notification-handler.py → claude_notification_handler.py} +8 -0
- package/hooks/notification/notification_utils.py +60 -2
- package/hooks/notification/subagent_complete_notify.py +381 -0
- package/hooks/notification/test_attention_needed_notify.py +47 -0
- package/hooks/notification/test_claude_notification_handler.py +54 -0
- package/hooks/notification/test_notification_utils.py +91 -0
- package/hooks/notification/test_subagent_complete_notify.py +72 -0
- package/hooks/validators/README.md +5 -1
- package/hooks/validators/abbreviation_checks.py +1 -1
- package/hooks/validators/code_quality_checks.py +1 -1
- package/hooks/validators/config.py +5 -0
- package/hooks/validators/conftest.py +10 -0
- package/hooks/validators/exempt_paths.py +1 -1
- package/hooks/validators/git_checks.py +80 -0
- package/hooks/validators/magic_value_checks.py +2 -2
- package/hooks/validators/pr_reference_checks.py +1 -1
- package/hooks/validators/python_antipattern_checks.py +1 -1
- package/hooks/validators/run_all_validators.py +53 -105
- package/hooks/validators/security_checks.py +1 -1
- package/hooks/validators/test_abbreviation_checks.py +2 -2
- package/hooks/validators/test_code_quality_checks.py +2 -2
- package/hooks/validators/test_file_structure_checks.py +1 -1
- package/hooks/validators/test_git_checks.py +79 -13
- package/hooks/validators/test_health_check.py +1 -1
- package/hooks/validators/test_magic_value_checks.py +2 -2
- package/hooks/validators/test_mypy_integration.py +1 -1
- package/hooks/validators/test_output_formatter.py +3 -1
- package/hooks/validators/test_pr_reference_checks.py +2 -2
- package/hooks/validators/test_python_antipattern_checks.py +2 -2
- package/hooks/validators/test_python_style_checks.py +2 -4
- package/hooks/validators/test_react_checks.py +1 -1
- package/hooks/validators/test_ruff_integration.py +1 -1
- package/hooks/validators/test_run_all_validators.py +75 -43
- package/hooks/validators/test_run_all_validators_integration.py +14 -37
- package/hooks/validators/test_security_checks.py +2 -2
- package/hooks/validators/test_test_safety_checks.py +1 -1
- package/hooks/validators/test_todo_checks.py +2 -2
- package/hooks/validators/test_type_safety_checks.py +2 -2
- package/hooks/validators/test_useless_test_checks.py +2 -2
- package/hooks/validators/test_validator_base.py +1 -1
- package/hooks/validators/test_verify_paths.py +2 -4
- package/hooks/validators/todo_checks.py +1 -1
- package/hooks/validators/type_safety_checks.py +1 -1
- package/hooks/validators/useless_test_checks.py +1 -1
- package/package.json +1 -1
- package/rules/file-global-constants.md +71 -0
- package/rules/gh-body-file.md +1 -1
- package/rules/prompt-workflow-context-controls.md +48 -0
- package/scripts/sync_to_cursor/rules.py +2 -2
- package/scripts/tests/test_sync_to_cursor.py +2 -2
- package/skills/bugteam/CONSTRAINTS.md +37 -0
- package/skills/bugteam/EXAMPLES.md +64 -0
- package/skills/bugteam/PROMPTS.md +175 -0
- package/skills/bugteam/SKILL.md +204 -295
- package/skills/bugteam/SKILL_EVALS.md +346 -0
- package/skills/bugteam/scripts/README.md +37 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +334 -0
- package/skills/bugteam/scripts/bugteam_preflight.py +135 -0
- package/skills/rule-audit/SKILL.md +4 -4
- /package/hooks/advisory/{migration-safety-advisor.py → migration_safety_advisor.py} +0 -0
- /package/hooks/advisory/{refactor-guard.py → refactor_guard.py} +0 -0
- /package/hooks/blocking/{block-main-commit.py → block_main_commit.py} +0 -0
- /package/hooks/blocking/{content-search-to-zoekt-redirector.py → content_search_to_zoekt_redirector.py} +0 -0
- /package/hooks/blocking/{gh-body-arg-blocker.py → gh_body_arg_blocker.py} +0 -0
- /package/hooks/blocking/{hedging-language-blocker.py → hedging_language_blocker.py} +0 -0
- /package/hooks/blocking/{pr-description-enforcer.py → pr_description_enforcer.py} +0 -0
- /package/hooks/blocking/{sensitive-file-protector.py → sensitive_file_protector.py} +0 -0
- /package/hooks/blocking/{test-preflight-check.py → test_preflight_check.py} +0 -0
- /package/hooks/blocking/{write-existing-file-blocker.py → write_existing_file_blocker.py} +0 -0
- /package/hooks/git-hooks/{post-commit.py → post_commit.py} +0 -0
- /package/hooks/lifecycle/{session-end-cleanup.py → session_end_cleanup.py} +0 -0
- /package/hooks/{rewrite-plugin-paths.py → rewrite_plugin_paths.py} +0 -0
- /package/hooks/session/{plugin-data-dir-cleanup.py → plugin_data_dir_cleanup.py} +0 -0
- /package/hooks/validation/{hook-format-validator.py → hook_format_validator.py} +0 -0
- /package/hooks/workflow/{auto-formatter.py → auto_formatter.py} +0 -0
- /package/hooks/workflow/{investigation-tracker-reset.py → investigation_tracker_reset.py} +0 -0
- /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
|
-
|
|
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(
|
|
46
|
+
if not os.path.exists(known_hook_count_file):
|
|
39
47
|
try:
|
|
40
|
-
with open(
|
|
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(
|
|
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(
|
|
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 / "
|
|
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
|
|
56
|
-
assert "
|
|
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()
|
package/hooks/notification/{claude-notification-handler.py → claude_notification_handler.py}
RENAMED
|
@@ -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", "
|
|
8
|
-
|
|
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
|