diff --git a/.claude/hooks/notify.py b/.claude/hooks/notify.py index 56d1ceb..7cc1120 100644 --- a/.claude/hooks/notify.py +++ b/.claude/hooks/notify.py @@ -5,6 +5,11 @@ .claude/settings.local.json(gitignore 済み)の env 等で各自設定する. 自前サーバー等へ送る場合は NTFY_SERVER で送信先を変えられる(既定: https://ntfy.sh). +Stop はターンが終わるたびに呼ばれるため,サブエージェントに作業を依頼して待っているだけの +ターン終了(依頼した直後にメインの手が空いて一旦ターンが終わる)でも発火する.そのままでは +「処理が完了しました」が実際の完了より前に届くので,hook 入力の background_tasks を見て +依頼中のエージェントが残っているターンでは通知しない(PENDING_TASK_TYPES 参照). + settings.json では async で配線しているため,本フックの成否は Claude Code の動作に影響しない. 送信に失敗しても黙って終了する.外部サービスを経由するため,本文にコードや会話の内容は含めない. """ @@ -22,19 +27,54 @@ GIT_TIMEOUT_SEC = 5 # ntfy の優先度(3 が既定.4 は通知音・バイブが強調される) PRIORITY_NEEDS_INPUT = 4 +# Stop 時点で「まだ作業が続く」と見なす背景タスクの種類(hook 入力 background_tasks[].type). +# subagent・workflow は完了するとセッションが再開して作業が続くため,これらが残っているターン終了は +# 完了ではない.shell・monitor は開発サーバーのように意図して動かし続けるものが該当しうる +# (含めると通知が永久に止まる)ため対象にしない. +PENDING_TASK_TYPES = ("subagent", "workflow") +# エージェント名は設定由来の短い識別子だが,通知本文が壊れないよう長さを制限する +AGENT_NAME_MAX = 40 + + +def has_pending_agent(data: dict) -> bool: + """依頼した作業がまだ動いている(サブエージェント・ワークフローが在席している)かを返す. + + background_tasks を持たない古い Claude Code では常に False となり,従来どおり毎ターン通知する. + """ + tasks = data.get("background_tasks") + if not isinstance(tasks, list): + return False + for task in tasks: + if isinstance(task, dict) and task.get("type") in PENDING_TASK_TYPES: + return True + return False + + +def agent_suffix(data: dict) -> str: + """サブエージェント起因のイベントなら「(エージェント: 名前)」を返す(そうでなければ空文字).""" + name = data.get("agent_type") + if not isinstance(name, str) or not name.strip(): + return "" + return "(エージェント: {})".format(" ".join(name.split())[:AGENT_NAME_MAX]) def build_notification(data: dict) -> dict | None: """イベントから ntfy の通知内容(本文・タグ・優先度)を決める.通知不要なら None を返す.""" event = data.get("hook_event_name") if event == "Stop": + # 依頼したエージェントが動いている間のターン終了は完了ではない. + # 依頼が終わるとセッションが再開するので,本当に終わった最後のターン終了で通知される + if has_pending_agent(data): + return None return {"message": "処理が完了しました", "tags": ["white_check_mark"]} if event == "PreToolUse" and data.get("tool_name") == "AskUserQuestion": - return {"message": "質問への回答を待っています", "tags": ["question"], "priority": PRIORITY_NEEDS_INPUT} + message = "質問への回答を待っています" + agent_suffix(data) + return {"message": message, "tags": ["question"], "priority": PRIORITY_NEEDS_INPUT} if event == "Notification": if data.get("notification_type") == "permission_prompt": - return {"message": "ツール実行の許可を待っています", "tags": ["lock"], "priority": PRIORITY_NEEDS_INPUT} - return {"message": "入力を待っています", "tags": ["speech_balloon"], "priority": PRIORITY_NEEDS_INPUT} + message = "ツール実行の許可を待っています" + agent_suffix(data) + return {"message": message, "tags": ["lock"], "priority": PRIORITY_NEEDS_INPUT} + return {"message": "入力を待っています" + agent_suffix(data), "tags": ["speech_balloon"], "priority": PRIORITY_NEEDS_INPUT} return None diff --git a/.claude/hooks/tests/test_hooks.py b/.claude/hooks/tests/test_hooks.py index ff55c99..d61d686 100644 --- a/.claude/hooks/tests/test_hooks.py +++ b/.claude/hooks/tests/test_hooks.py @@ -561,6 +561,90 @@ # --------------------------------------------------------------------------- +# notify.build_notification(どのイベントで通知するか) +# --------------------------------------------------------------------------- +def _subagent_task(agent_type: str = "coder") -> dict: + return {"id": "t1", "type": "subagent", "status": "running", "description": "...", "agent_type": agent_type} + + +class TestNotifyBuildNotification(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + sys.path.insert(0, HOOKS_DIR) + try: + import notify # noqa: WPS433 + finally: + sys.path.pop(0) + cls.notify = notify + + def build(self, data: dict): + return self.notify.build_notification(data) + + # Stop: 依頼したエージェントが動いている間は通知しない + def test_stop_without_background_tasks_notifies(self): + self.assertIsNotNone(self.build({"hook_event_name": "Stop"})) + + def test_stop_with_empty_background_tasks_notifies(self): + self.assertIsNotNone(self.build({"hook_event_name": "Stop", "background_tasks": []})) + + def test_stop_with_running_subagent_is_silent(self): + self.assertIsNone(self.build({"hook_event_name": "Stop", "background_tasks": [_subagent_task()]})) + + def test_stop_with_running_workflow_is_silent(self): + task = {"id": "w1", "type": "workflow", "status": "running", "description": "...", "name": "wf"} + self.assertIsNone(self.build({"hook_event_name": "Stop", "background_tasks": [task]})) + + def test_stop_with_shell_task_still_notifies(self): + """開発サーバー等の常駐 shell / monitor で通知が止まらないこと.""" + for kind in ("shell", "monitor"): + with self.subTest(kind=kind): + task = {"id": "s1", "type": kind, "status": "running", "description": "..."} + self.assertIsNotNone(self.build({"hook_event_name": "Stop", "background_tasks": [task]})) + + def test_stop_with_mixed_tasks_is_silent(self): + tasks = [{"id": "s1", "type": "shell", "status": "running", "description": "..."}, _subagent_task()] + self.assertIsNone(self.build({"hook_event_name": "Stop", "background_tasks": tasks})) + + def test_stop_with_malformed_background_tasks_notifies(self): + """想定外の形(古いバージョン・壊れた入力)でも通知を落とさない.""" + for tasks in ("not-a-list", {"type": "subagent"}, [None, "x", 1], [{}]): + with self.subTest(tasks=tasks): + self.assertIsNotNone(self.build({"hook_event_name": "Stop", "background_tasks": tasks})) + + # 待ち状態の通知はエージェント名を添える + def test_permission_prompt_from_subagent_names_agent(self): + data = {"hook_event_name": "Notification", "notification_type": "permission_prompt", + "agent_id": "a1", "agent_type": "coder"} + self.assertIn("coder", self.build(data)["message"]) + + def test_permission_prompt_from_main_has_no_agent_label(self): + data = {"hook_event_name": "Notification", "notification_type": "permission_prompt"} + self.assertNotIn("エージェント", self.build(data)["message"]) + + def test_question_from_subagent_names_agent(self): + data = {"hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion", "agent_type": "tester"} + self.assertIn("tester", self.build(data)["message"]) + + def test_agent_name_is_trimmed_and_capped(self): + data = {"hook_event_name": "Notification", "notification_type": "permission_prompt", + "agent_type": " a" + chr(10) + "b " + "x" * 200} + message = self.build(data)["message"] + self.assertNotIn(chr(10), message) + self.assertIn("a b", message) + self.assertLess(len(message), 80) + + def test_blank_agent_type_is_ignored(self): + for value in ("", " ", None, 123): + with self.subTest(value=value): + data = {"hook_event_name": "Notification", "notification_type": "idle_prompt", "agent_type": value} + self.assertNotIn("エージェント", self.build(data)["message"]) + + def test_unknown_event_is_silent(self): + self.assertIsNone(self.build({"hook_event_name": "SubagentStop"})) + self.assertIsNone(self.build({"hook_event_name": "PreToolUse", "tool_name": "Bash"})) + + +# --------------------------------------------------------------------------- # Python 3.7 互換(静的チェック) # --------------------------------------------------------------------------- PY39_ONLY_ATTRS = {"removesuffix", "removeprefix", "is_relative_to", "with_stem", "readlink", "randbytes", "lcm", "cache"} diff --git "a/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" "b/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" index ca42545..27b39e9 100644 --- "a/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" +++ "b/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" @@ -60,7 +60,7 @@ - **GitHub リポジトリのセキュリティ設定**: GitHub の Settings → Code security にある **Dependabot alerts**(既知脆弱性の検出."Vulnerabilities" として表示される)と **Dependabot security updates** はリポジトリごとに既定で OFF のため,リポジトリを作成したら必ず有効化する.モードに関わらず `/setup` が `gh api` で有効化・検証する(コマンド・検証・トラブル対応は `.claude/skills/setup/reference.md`「GitHub リポジトリのセキュリティ設定」).`/setup` 時点でリポジトリが無い場合は,`ENV_03_管理者用環境構築手順.md` に転記した同じコマンドをリポジトリ作成後に実行する. - **依存バージョン更新(Dependabot version updates)**: 上記とは別に,依存パッケージを定期的に最新化する PR を作らせるため,`/setup` が技術スタックに合わせて `.github/dependabot.yml` を必ず生成する(週 1 回・マイナー/パッチをまとめる・`[update]` プレフィックス.エコシステム対応表と雛形は `.claude/skills/setup/reference.md`「依存バージョン更新の設定」).Dependabot が作る PR と alert の処理は `/deps-update` で行う(ゲートを満たす PR を自動マージし,メジャー更新等は分析付きで報告.GUIDE_02「コミットルール」の例外). - **Claude Code の権限設定(`.claude/settings.json`)**: テンプレート同梱の `permissions.allow`(`Bash(gh pr merge:*)`)は,auto mode で ops-runner に `/commit merge`・`/deps-update` のマージを実行させるために必要なので削除しない(変更はセッション再起動後に反映).マージが分類器に止められた場合の切り分けと対処は `.claude/skills/commit/reference.md`「gh コマンドが実行される前にブロックされた場合」を参照. -- **スマホへのプッシュ通知(任意)**: テンプレート同梱の `.claude/settings.json` は,作業完了(`Stop`.毎ターン)・許可待ち(`Notification`)・質問(`PreToolUse` の `AskUserQuestion`)で `.claude/hooks/notify.py` を呼び,[ntfy](https://ntfy.sh) 経由でスマホに通知する(ホスト OS・dev container のどちらでも動く).送信先トピックが未設定なら何もしないため,受け取りたい人だけ以下を行う. +- **スマホへのプッシュ通知(任意)**: テンプレート同梱の `.claude/settings.json` は,作業完了(`Stop`)・許可待ち(`Notification`)・質問(`PreToolUse` の `AskUserQuestion`)で `.claude/hooks/notify.py` を呼び,[ntfy](https://ntfy.sh) 経由でスマホに通知する(ホスト OS・dev container のどちらでも動く).送信先トピックが未設定なら何もしないため,受け取りたい人だけ以下を行う. - スマホに ntfy アプリを入れ,推測されにくいトピック名(例: `claude-` +ランダムな英数字)を購読する.トピック名を知っていれば誰でも購読・送信できるため,パスワードと同様に扱いリポジトリには書かない - iPhone でアプリを開くと届いているのにプッシュ通知が出ない場合は,トピックを削除してアプリを再起動し,購読し直す(ntfy の既知の問題.購読し直すと通知の登録がやり直される) - `{ "env": { "NTFY_TOPIC": "<トピック名>" } }` を以下のいずれかに書く(既存の設定がある場合は `env` にキーを追加する.反映されない場合はセッションを再起動する) @@ -68,6 +68,8 @@ - **dev container を使う場合**: プロジェクトの `.claude/settings.local.json`(gitignore 済み)に書く.ホストの `~/.claude` は通常コンテナから見えないが,ワークスペースはコンテナに共有されるため,プロジェクトごとに 1 回でよい - 両方に書いた場合はプロジェクトの `.claude/settings.local.json` が優先される - 外部サービスを経由するため,通知には「Claude Code (リポジトリ名)」(origin リモートが無ければフォルダ名)と定型文だけを送り,コードや会話の内容は送らない + - 完了通知は**ターン終了のたびには送らない**.サブエージェント(`/implement` の coder 等)やワークフローに依頼して待っているだけのターン終了では黙り,依頼した作業が終わって本当に手が空いたときだけ「処理が完了しました」を送る(フックが `Stop` の入力 `background_tasks` を見て判定する).一方,バックグラウンドのシェル(開発サーバー等)は動かし続けるのが普通で,対象にすると通知が永久に止まるため判定から除いている + - 許可待ち・質問の通知は,サブエージェント起因なら本文に「(エージェント: coder)」のようにエージェント名が付く - **成果物**: - `ENV_02_環境構築手順.md` — メンバーの参加時や環境の再構築時に使う手順 - `ENV_03_管理者用環境構築手順.md` — プロジェクト作成時に一度だけ行う初期設定(リポジトリ作成,GitHub リポジトリのセキュリティ設定,外部サービスの設定等)