claude-dev-env 1.38.1 → 1.40.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 +10 -36
- package/_shared/pr-loop/audit-reply-template.md +147 -0
- package/_shared/pr-loop/fix-protocol.md +25 -4
- package/_shared/pr-loop/gh-payloads.md +37 -50
- package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
- package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +199 -0
- package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
- package/_shared/pr-loop/scripts/post_audit_thread.py +1242 -0
- package/_shared/pr-loop/scripts/preflight.py +129 -2
- package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1116 -0
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
- package/_shared/pr-loop/state-schema.md +1 -1
- package/agents/clean-coder.md +2 -2
- package/agents/pr-description-writer.md +150 -52
- package/bin/install.mjs +6 -7
- package/bin/install.test.mjs +8 -0
- package/commands/doc-gist.md +16 -0
- package/commands/plan.md +0 -2
- package/commands/review-plan.md +1 -1
- package/docs/CODE_RULES.md +122 -2
- package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
- package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
- package/hooks/blocking/code_rules_enforcer.py +1143 -129
- package/hooks/blocking/convergence_gate_blocker.py +130 -0
- package/hooks/blocking/destructive_command_blocker.py +74 -0
- package/hooks/blocking/gh_body_arg_blocker.py +30 -0
- package/hooks/blocking/md_to_html_blocker.py +119 -0
- package/hooks/blocking/pr_description_enforcer.py +57 -22
- package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
- package/hooks/blocking/test_code_rules_enforcer.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
- package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
- package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
- package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
- package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
- package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
- package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
- package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
- package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
- package/hooks/blocking/test_destructive_command_blocker.py +146 -0
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
- package/hooks/blocking/test_md_to_html_blocker.py +317 -0
- package/hooks/blocking/test_pr_description_enforcer.py +69 -8
- package/hooks/config/any_type_config.py +7 -0
- package/hooks/config/banned_identifiers_constants.py +11 -0
- package/hooks/config/blocking_check_limits.py +38 -0
- package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
- package/hooks/config/code_rules_enforcer_constants.py +53 -0
- package/hooks/config/convergence_branch_constants.py +9 -0
- package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
- package/hooks/config/html_companion_constants.py +20 -0
- package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
- package/hooks/config/pr_description_enforcer_constants.py +14 -0
- package/hooks/config/test_banned_identifiers_constants.py +17 -0
- package/hooks/hooks.json +28 -20
- package/hooks/pyproject.toml +69 -0
- package/hooks/validators/mypy_integration.py +47 -1
- package/hooks/validators/run_all_validators.py +3 -3
- package/hooks/validators/test_mypy_integration.py +50 -1
- package/hooks/workflow/doc_gist_auto_publish.py +144 -0
- package/hooks/workflow/md_to_html_companion.py +365 -0
- package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
- package/hooks/workflow/test_md_to_html_companion.py +452 -0
- package/package.json +1 -1
- package/rules/gh-body-file.md +2 -0
- package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
- package/scripts/check.ps1 +106 -0
- package/scripts/config/timing.py +11 -0
- package/scripts/sweep_empty_dirs.py +138 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_sweep_empty_dirs.py +183 -0
- package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
- package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
- package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
- package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
- package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
- package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
- package/skills/bugteam/CONSTRAINTS.md +21 -22
- package/skills/bugteam/EXAMPLES.md +3 -3
- package/skills/bugteam/PROMPTS.md +227 -67
- package/skills/bugteam/SKILL.md +132 -455
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +112 -39
- package/skills/bugteam/reference/audit-contract.md +4 -22
- package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
- package/skills/bugteam/reference/design-rationale.md +2 -2
- package/skills/bugteam/reference/github-pr-reviews.md +50 -57
- package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
- package/skills/bugteam/reference/team-setup.md +111 -9
- package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
- package/skills/bugteam/scripts/README.md +60 -0
- package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
- package/skills/bugteam/scripts/bugteam_preflight.py +328 -0
- package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
- package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
- package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
- package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
- package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
- package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
- package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +309 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
- package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
- package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
- package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
- package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
- package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
- package/skills/bugteam/test_skill_additions.py +1 -11
- package/skills/code/SKILL.md +176 -0
- package/skills/copilot-review/SKILL.md +16 -0
- package/skills/doc-gist/SKILL.md +99 -0
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
- package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
- package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
- package/skills/doc-gist/references/examples/05-design-system.html +629 -0
- package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
- package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
- package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
- package/skills/doc-gist/references/examples/11-status-report.html +528 -0
- package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
- package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
- package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
- package/skills/doc-gist/references/examples/README.md +5 -0
- package/skills/doc-gist/scripts/config/__init__.py +0 -0
- package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
- package/skills/doc-gist/scripts/gist_upload.py +177 -0
- package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
- package/skills/findbugs/SKILL.md +96 -2
- package/skills/monitor-open-prs/SKILL.md +14 -32
- package/skills/monitor-open-prs/test_skill_contract.py +0 -11
- package/skills/pr-consistency-audit/SKILL.md +112 -0
- package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
- package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
- package/skills/pr-converge/SKILL.md +229 -23
- package/skills/pr-converge/config/__init__.py +0 -0
- package/skills/pr-converge/config/constants.py +63 -0
- package/skills/pr-converge/reference/convergence-gates.md +138 -44
- package/skills/pr-converge/reference/examples.md +43 -11
- package/skills/pr-converge/reference/fix-protocol.md +6 -5
- package/skills/pr-converge/reference/ground-rules.md +5 -3
- package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
- package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
- package/skills/pr-converge/reference/per-tick.md +107 -31
- package/skills/pr-converge/reference/state-schema.md +22 -1
- package/skills/pr-converge/reference/stop-conditions.md +9 -7
- package/skills/pr-converge/scripts/README.md +34 -46
- package/skills/pr-converge/scripts/check_bugbot_ci.py +279 -0
- package/skills/pr-converge/scripts/check_convergence.py +497 -0
- package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
- package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
- package/skills/qbug/SKILL.md +157 -27
- package/skills/session-log/SKILL.md +216 -114
- package/skills/session-tidy/SKILL.md +1 -1
- package/skills/skill-builder/SKILL.md +138 -56
- package/skills/skill-builder/references/delegation-map.md +72 -113
- package/skills/skill-builder/references/progressive-disclosure.md +122 -0
- package/skills/skill-builder/references/self-audit-checklist.md +92 -0
- package/skills/skill-builder/references/skill-types.md +228 -0
- package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
- package/skills/skill-builder/templates/gap-analysis.md +15 -8
- package/skills/skill-builder/workflows/improve-skill.md +86 -57
- package/skills/skill-builder/workflows/new-skill.md +80 -168
- package/skills/skill-builder/workflows/polish-skill.md +78 -54
- package/skills/structure-prompt/SKILL.md +50 -0
- package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
- package/skills/structure-prompt/reference/block-classification.md +27 -0
- package/skills/structure-prompt/reference/canonical-case.md +48 -0
- package/skills/structure-prompt/reference/citation-depth.md +70 -0
- package/skills/structure-prompt/reference/cleanup.md +33 -0
- package/skills/structure-prompt/reference/constraints.md +33 -0
- package/skills/structure-prompt/reference/directives.md +37 -0
- package/skills/structure-prompt/reference/examples.md +72 -0
- package/skills/structure-prompt/reference/instantiation.md +51 -0
- package/skills/structure-prompt/reference/output-contract.md +72 -0
- package/skills/structure-prompt/reference/per-category.md +23 -0
- package/skills/structure-prompt/reference/persona.md +38 -0
- package/skills/structure-prompt/reference/research.md +33 -0
- package/skills/structure-prompt/reference/structure.md +28 -0
- package/agents/code-standards-agent.md +0 -93
- package/agents/groq-coder.md +0 -113
- package/agents/plan-executor.md +0 -226
- package/agents/project-docs-analyzer.md +0 -53
- package/agents/project-structure-organizer-agent.md +0 -72
- package/agents/skill-to-agent-converter.md +0 -370
- package/agents/skill-writer-agent.md +0 -470
- package/agents/user-docs-writer.md +0 -67
- package/agents/workflow-visual-documenter.md +0 -82
- package/commands/readability-review.md +0 -20
- package/hooks/mypy.ini +0 -2
- package/hooks/notification/attention_needed_notify.py +0 -71
- package/hooks/notification/claude_notification_handler.py +0 -67
- package/hooks/notification/notification_utils.py +0 -267
- package/hooks/notification/subagent_complete_notify.py +0 -381
- package/hooks/notification/test_attention_needed_notify.py +0 -47
- package/hooks/notification/test_claude_notification_handler.py +0 -54
- package/hooks/notification/test_notification_utils.py +0 -91
- package/hooks/notification/test_subagent_complete_notify.py +0 -79
- package/scripts/config/groq_bugteam_config.py +0 -230
- package/scripts/config/test_groq_bugteam_config.py +0 -83
- package/scripts/config/test_spec_implementer_prompt.py +0 -32
- package/scripts/groq_bugteam.README.md +0 -131
- package/scripts/groq_bugteam.py +0 -647
- package/scripts/groq_bugteam_dotenv.py +0 -40
- package/scripts/groq_bugteam_spec.py +0 -226
- package/scripts/test_groq_bugteam.py +0 -529
- package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
- package/scripts/test_groq_bugteam_dotenv.py +0 -66
- package/scripts/test_groq_bugteam_spec.py +0 -338
- package/skills/bugteam/SKILL_EVALS.md +0 -309
- package/skills/dream/SKILL.md +0 -118
- package/skills/ingest/SKILL.md +0 -40
- package/skills/npm-creator/SKILL.md +0 -187
- package/skills/readability-review/SKILL.md +0 -127
- package/skills/resume-review/SKILL.md +0 -261
- package/skills/rule-audit/SKILL.md +0 -307
- package/skills/rule-creator/SKILL.md +0 -150
- package/skills/searching-obsidian-vault/SKILL.md +0 -131
- package/skills/skill-writer/REFERENCE.md +0 -284
- package/skills/skill-writer/SKILL.md +0 -222
- package/skills/tdd-team/SKILL.md +0 -128
|
@@ -1,381 +0,0 @@
|
|
|
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()
|
|
@@ -1,47 +0,0 @@
|
|
|
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
|
|
@@ -1,54 +0,0 @@
|
|
|
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,91 +0,0 @@
|
|
|
1
|
-
"""Unit tests for notification_utils ntfy guard behavior."""
|
|
2
|
-
|
|
3
|
-
import importlib.util
|
|
4
|
-
import json
|
|
5
|
-
import pathlib
|
|
6
|
-
import subprocess
|
|
7
|
-
import types
|
|
8
|
-
from unittest.mock import patch
|
|
9
|
-
|
|
10
|
-
HOOK_DIRECTORY = pathlib.Path(__file__).parent
|
|
11
|
-
MODULE_PATH = HOOK_DIRECTORY / "notification_utils.py"
|
|
12
|
-
|
|
13
|
-
FIXTURE_DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/111/aaa-fixture"
|
|
14
|
-
FIXTURE_SECRET_ID = "fixture-secret-id-0000"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def load_notification_utils_with_environment(
|
|
18
|
-
environment_overrides: dict[str, str],
|
|
19
|
-
) -> types.ModuleType:
|
|
20
|
-
module_specification = importlib.util.spec_from_file_location(
|
|
21
|
-
"notification_utils_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_should_skip_curl_when_topic_environment_variable_is_unset() -> None:
|
|
33
|
-
environment_with_topic_removed = {"NTFY_TOPIC": ""}
|
|
34
|
-
module_under_test = load_notification_utils_with_environment(
|
|
35
|
-
environment_with_topic_removed
|
|
36
|
-
)
|
|
37
|
-
with patch("subprocess.Popen") as popen_spy:
|
|
38
|
-
module_under_test.notify_ntfy(title="Test", message="payload")
|
|
39
|
-
assert popen_spy.call_count == 0
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_should_invoke_curl_when_topic_environment_variable_is_set() -> None:
|
|
43
|
-
environment_with_topic_set = {"NTFY_TOPIC": "private-topic-for-test"}
|
|
44
|
-
module_under_test = load_notification_utils_with_environment(
|
|
45
|
-
environment_with_topic_set
|
|
46
|
-
)
|
|
47
|
-
with patch("subprocess.Popen") as popen_spy:
|
|
48
|
-
module_under_test.notify_ntfy(title="Test", message="payload")
|
|
49
|
-
assert popen_spy.call_count == 1
|
|
50
|
-
curl_arguments = popen_spy.call_args.args[0]
|
|
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
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
"""Unit tests for subagent-complete-notify 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 / "subagent_complete_notify.py"
|
|
10
|
-
|
|
11
|
-
FIXTURE_ACTIVITY_SECRET_ID = "fixture-activity-id-0003"
|
|
12
|
-
FIXTURE_TASK_DESCRIPTION = "subagent finished research task"
|
|
13
|
-
FIXTURE_PROJECT_NAME = "fixture-project"
|
|
14
|
-
NON_WINDOWS_NON_WSL_PLATFORM = "Darwin"
|
|
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
|
-
"subagent_complete_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_activity_secret_id_to_notify_discord() -> None:
|
|
33
|
-
module_under_test = load_hook_with_environment(
|
|
34
|
-
{"BWS_DISCORD_ACTIVITY_SECRET_ID": FIXTURE_ACTIVITY_SECRET_ID}
|
|
35
|
-
)
|
|
36
|
-
with (
|
|
37
|
-
patch.object(
|
|
38
|
-
module_under_test,
|
|
39
|
-
"get_task_info_from_stdin",
|
|
40
|
-
return_value=FIXTURE_TASK_DESCRIPTION,
|
|
41
|
-
),
|
|
42
|
-
patch.object(
|
|
43
|
-
module_under_test, "get_project_name", return_value=FIXTURE_PROJECT_NAME
|
|
44
|
-
),
|
|
45
|
-
patch.object(module_under_test, "notify_ntfy"),
|
|
46
|
-
patch.object(module_under_test, "notify_discord") as discord_spy,
|
|
47
|
-
patch.object(module_under_test, "is_wsl", return_value=False),
|
|
48
|
-
patch.object(module_under_test, "platform") as platform_stub,
|
|
49
|
-
):
|
|
50
|
-
platform_stub.system.return_value = NON_WINDOWS_NON_WSL_PLATFORM
|
|
51
|
-
module_under_test.main()
|
|
52
|
-
assert discord_spy.call_count == 1
|
|
53
|
-
call_kwargs = discord_spy.call_args.kwargs
|
|
54
|
-
assert call_kwargs["webhook_secret_id"] == FIXTURE_ACTIVITY_SECRET_ID
|
|
55
|
-
assert call_kwargs["title"] == FIXTURE_PROJECT_NAME
|
|
56
|
-
assert call_kwargs["message"] == FIXTURE_TASK_DESCRIPTION
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_notify_ntfy_skips_when_topic_unset() -> None:
|
|
60
|
-
module_under_test = load_hook_with_environment({"CLAUDE_NTFY_TOPIC": ""})
|
|
61
|
-
with patch.object(module_under_test.subprocess, "Popen") as popen_spy:
|
|
62
|
-
module_under_test.notify_ntfy(title="t", message="m")
|
|
63
|
-
assert popen_spy.call_count == 0
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def test_main_skips_notify_discord_when_task_description_is_empty() -> None:
|
|
67
|
-
module_under_test = load_hook_with_environment(
|
|
68
|
-
{"BWS_DISCORD_ACTIVITY_SECRET_ID": FIXTURE_ACTIVITY_SECRET_ID}
|
|
69
|
-
)
|
|
70
|
-
with (
|
|
71
|
-
patch.object(module_under_test, "get_task_info_from_stdin", return_value=""),
|
|
72
|
-
patch.object(
|
|
73
|
-
module_under_test, "get_project_name", return_value=FIXTURE_PROJECT_NAME
|
|
74
|
-
),
|
|
75
|
-
patch.object(module_under_test, "notify_ntfy"),
|
|
76
|
-
patch.object(module_under_test, "notify_discord") as discord_spy,
|
|
77
|
-
):
|
|
78
|
-
module_under_test.main()
|
|
79
|
-
assert discord_spy.call_count == 0
|