Claude Media
TaskCreated/TaskCompletedフックでタスク進捗を外部通知する

TaskCreated/TaskCompletedフックでタスク進捗を外部通知する

タスクの作成・完了ごとに発火するTaskCreated/TaskCompletedフックで、Slack通知や完了条件の強制を組む方法。モデルによる発火条件の違いも扱います。

TaskCreated/TaskCompletedフックとは

TaskCreatedはタスクリストに新しいタスクが作られる瞬間、TaskCompletedはタスクが完了扱いになる瞬間に発火するフックです。Claude CodeのTaskCreate/TaskUpdateツールに対応し、進捗をSlackなどの外部ツールへ流したり、完了条件を満たすまでタスクを閉じさせなかったりする用途に使います。

どちらもmatcherを持たず、対象イベントが起きるたびに発火します。タスクの出入りが多いセッションでは、通知の頻度もそのまま増えます。

用途は二つに分かれます。一つは進捗の可視化で、長時間走らせるセッションやAgent Teamsの並行作業を、離席中でも通知チャンネルで追えます。もう一つは完了条件の強制で、タスクが「完了」になる直前に割り込み、テスト未通過のまま閉じさせない運用が組めます。実装の作法が違うので、以降で両方を扱います。

設定したのに発火しない — Task toolが無効なセッション

このフックはTaskCreate/TaskUpdateツールが有効なセッションでしか発火しません。そしてこのツールは、モデルによって既定の有効・無効が分かれています。

くらべる

Task toolの既定(v2.1.268以降)

フックは発火する

既定で有効

Claude 3.x、Opus 4〜4.7、Sonnet 4〜4.6、Haiku 4.5。

オプトインするまで発火しない

既定で無効

Opus 4.8、Sonnet 5、Fable 5、Mythos 5以降の新しいモデル。Claude Codeが認識しないモデルIDも同じ扱いで、LLMゲートウェイ経由のカスタムなモデル名がこれに当たります。

挙動の変遷は2段階です。v2.1.233でOpus 4.8以降の新しいモデルがTask toolの対象外になりました。v2.1.268で「有効なモデルを列挙し、それ以外は無効」という現在の形に変わっています。新しいモデルは書き出したチェックリストなしで多段階の作業を追えるうえ、ツール定義とリマインダーがコンテキストを使うため、というのが無効化の理由です。

有効化する経路は4つあります。

  • 環境変数CLAUDE_CODE_ENABLE_TODO_TOOLS=1を付けて起動する。全モデル・全プロバイダーで有効になります
  • claude --allowedTools TaskCreateのように、ツール名を--allowedToolsで指定する
  • --toolsにTask系ツールを含める(組み込みツールをそこで指定したものに絞る指定なので、他に使うツールも並べます)
  • Agent SDKではallowedToolsまたはtoolsオプションに同名のツールを渡す

例外が一つあります。バックグラウンドセッションとクラウドセッション(Claude Code on the web)は、モデルにかかわらずTask toolが提供されます。

TodoWriteとの関係にも注意が必要です。CLAUDE_CODE_ENABLE_TASKS=0を設定すると、4つのTask toolの代わりにTodoWriteが提供されます。このときはTaskCreateもTaskUpdateも呼ばれないので、フックは発火しません。

サブエージェントには、セッションがTask toolを持っていれば同じツールが渡ります。サブエージェント自身が別モデルで動いていても同じです。同一プロセス内で動くAgent Teamsのteammateも、親セッションに従います。

split paneで動くteammateだけは別扱いです。独立したプロセスとして動くため、そのteammate自身のモデルで有効・無効が決まります。親セッションがOpus 4.7で有効でも、split paneのteammateがSonnet 5なら、そちらでは発火しません。サブエージェントの起動・終了そのものを捕まえたいときは、SubagentStart/SubagentStopフックの出番です。

入力フィールドと発火タイミング

両イベントとも、受け取るフィールドは同じ形です。

フィールド内容
task_id内容対象タスクの識別子
task_subject内容タスクのタイトル
task_description内容タスクの詳細説明(無いこともある)
teammate_name内容作成・完了したteammate名(無いこともある)
team_name内容非推奨。将来のリリースで削除予定

TaskCreatedはTaskCreateツールが呼ばれた瞬間に発火します。TaskCompletedは2通りで、いずれかのエージェントがTaskUpdateツールでタスクを完了させたときと、Agent Teamsのteammateが進行中タスクを残したままターンを終えたときの両方で発火します。TaskCompletedはv2.1.33で、TaskCreatedはv2.1.84で追加されたイベントです。

task_descriptionとteammate_nameは省略されることがあるフィールドです。通知テンプレートでtask_descriptionが必ずあると仮定すると、空欄が出ます。スクリプト側でjq -r '.teammate_name // "session"'のようにフォールバックを書いておけば、単独セッションとチーム運用で同じフックを使い回せます。

Slackにタスクの動きを流す

HTTPフックはフックの入力JSONをそのままPOSTします。SlackのIncoming Webhookが受け付ける{"text": "..."}には整形できないため、コマンドフックでjqが本文を組み立て、curlで送る形が確実です。ターミナルのデスクトップ通知で足りるなら、Notificationフックのほうが設定は簡単です。

手順

Slack通知を入れるまで

  1. 1

    スクリプトを置く

    .claude/hooks/notify-task.shに下のスクリプトを保存し、chmod +xで実行権限を付けます。hook_event_nameで作成と完了を見分けるので、1本で両方のイベントに使えます。

  2. 2

    settings.jsonに登録する

    TaskCreatedとTaskCompletedの両方に同じコマンドを書き、async: trueを付けます。Slackへのリクエストで本体の作業が止まらなくなります。

  3. 3

    新しいセッションで確認する

    Task toolが有効なモデルか、CLAUDE_CODE_ENABLE_TODO_TOOLS=1付きで起動し、タスクを作らせます。チャンネルに🆕と✅が届けば完了です。

#!/bin/bash
input=$(cat)
event=$(echo "$input" | jq -r '.hook_event_name')
subject=$(echo "$input" | jq -r '.task_subject')
who=$(echo "$input" | jq -r '.teammate_name // "session"')
 
case "$event" in
  TaskCreated)   mark="🆕" ;;
  TaskCompleted) mark="✅" ;;
  *)             mark="•" ;;
esac
 
curl -s -X POST "$SLACK_WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -d "$(jq -nc --arg text "$mark [$who] $subject" '{text: $text}')"
 
exit 0

登録は次のとおりです。

{
  "hooks": {
    "TaskCreated": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify-task.sh",
            "async": true
          }
        ]
      }
    ],
    "TaskCompleted": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify-task.sh",
            "async": true
          }
        ]
      }
    ]
  }
}

本文の組み立てにjq -nc --argを使っているのは、タイトルに引用符が入ってもJSONが壊れないようにするためです。curlの行を除いたスクリプトにサンプル入力を流すと、v2.1.287の環境で次の出力になりました。

$ echo '{"hook_event_name":"TaskCreated","task_id":"task-001","task_subject":"Add login endpoint"}' | bash notify-task.sh
{"text":"🆕 [session] Add login endpoint"}
$ echo '{"hook_event_name":"TaskCompleted","task_id":"task-001","task_subject":"Add login endpoint","teammate_name":"implementer"}' | bash notify-task.sh
{"text":"✅ [implementer] Add login endpoint"}

teammate_nameが無い入力ではsessionに落ち、ある入力ではその名前が入ります。

通知かゲートか — 同期と非同期の分かれ目

同じフックでも、通知だけが目的なのか、完了そのものを止めたいのかで構成が変わります。

くらべる

通知・記録と強制の構成

async: true

通知・記録

Slack通知や監査ログの書き込みに向きます。exit codeは常に0にしておけば、通知の失敗でタスクの進行が止まりません。

同期実行

強制

テスト通過の必須化や、[TICKET-123]のような命名規則の強制に向きます。失敗時はexit 2で返し、命名規則のチェックはTaskCreated側に置きます。

async hookはdecision controlを持ちません。async: trueを付けた時点で、decisionやcontinueなどの出力フィールドは効かなくなります。通知とゲートを1本のスクリプトで兼ねようとすると、この非対称に当たります。

async: trueのコマンドフックには、timeoutも適用されません。一方で同期のコマンドフックは、既定で600秒のタイムアウトまでClaudeの実行を止めます。通知スクリプトを同期のまま置くと、Slack側の遅延がそのまま作業の待ち時間になります。

非同期のまま「失敗したときだけClaudeを起こす」には、asyncRewakeがあります。asyncRewake: trueのフックがexit 2で終わると、stderr(空ならstdout)がシステムリマインダーとしてClaudeに届き、セッションが待機中でも起こされます。完了のブロックではなく、バックグラウンドの失敗を知らせる仕組みです。

ブロックの効き方はイベントごとに違う

exit 2や{"decision": "block"}で止めたときの挙動は、TaskCreatedとTaskCompletedで異なります。

くらべる

ブロックしたときに起きること

作成を取り消す

TaskCreated

exit 2または{"decision": "block", "reason": "..."}で止めると、タスクは削除され、メッセージがツールのエラーとしてClaudeに返ります。continue: falseは無視され、Claudeは作業を続けます。

完了を止める

TaskCompleted

exit 2は常にタスクの完了を止め、stderrがClaudeへのフィードバックになります。{"continue": false, "stopReason": "..."}が効くのは、teammateがターンを終えたことが発火の原因のときだけです。TaskUpdateが原因のときは無視されます。

テストが通るまで完了させない例は次のとおりです。

#!/bin/bash
input=$(cat)
subject=$(echo "$input" | jq -r '.task_subject')
 
if ! npm test 2>&1; then
  echo "テストが通っていません。完了前に修正してください: $subject" >&2
  exit 2
fi
 
exit 0

テストを回さず、LLMに完了条件を判定させる手もあります。type: "prompt"のフックは{"ok": false, "reason": "..."}を返せます。TaskUpdateで完了させようとしたときは、reasonがClaudeにツールエラーとして返り、ターンが続きます。teammateがターンを終えたことが原因のときは、既定でそのteammateが止まります。

{
  "hooks": {
    "TaskCompleted": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "次のタスクの成果物がタイトルの内容を満たしているか判定してください: $ARGUMENTS"
          }
        ]
      }
    ]
  }
}

プロンプトフックの既定のタイムアウトは30秒です。入力JSONは$ARGUMENTSの位置に差し込まれ、プレースホルダーを書かなければ末尾に追記されます。

Agent Teamsで複数teammateが動くとき

teammateを3人立てて機能実装を分担させるようなセッションでは、teammateが作成・完了したタスクのteammate_nameにその名前が入ります。リーダー役のセッションで誰が何を担当しているかを把握したいときは、この2つをチャンネル通知に繋いでおけば、各teammateのターンを覗きに行かずに済みます。

teammateがタスクを完了扱いにせずターンを終えたときも、TaskCompletedは発火します。トリガーはTaskUpdateではなく「ターン終了」で、continue: falseが効くのはこのケースだけです。TaskUpdateで明示的に完了させたときはcontinue: falseが無視されるので、完了そのものをやり直させたいならexit 2に統一するのが安全です。

通知が届かないときの切り分け

Task toolが無効だと、エージェントはチームの共有タスクリストを使わず、メッセージでチームと連携します。フックが発火しないのと同じ原因で、チームの進捗がタスクとして残らない点も見分けの手がかりになります。

手順

届かないときに見る順番

  1. 1

    Task toolが提供されているか

    使っているモデルが既定の無効側か、ゲートウェイ経由のカスタムなモデル名になっていないかを見ます。無効ならCLAUDE_CODE_ENABLE_TODO_TOOLS=1で起動し直します。

  2. 2

    TodoWriteに切り替わっていないか

    CLAUDE_CODE_ENABLE_TASKS=0が設定されていると、TaskCreate/TaskUpdateが呼ばれないのでフックは発火しません。

  3. 3

    async: trueを外して同期で動かす

    stderrとexit codeがそのまま見えるようになります。--debug付きで起動すると、フックの実行状況も追えます。原因が分かったらasyncを戻します。

  4. 4

    スクリプト単体で動かす

    サンプルJSONを標準入力に流して、SLACK_WEBHOOK_URLが空でないか、jqが入っているかを確かめます。

よくあるつまずき

  • team_nameをチーム識別に使う: 非推奨フィールドで、将来のリリースで削除予定です。チームを識別したいならteammate_nameとセッション側の情報を組み合わせます
  • 別プロセスのteammateも同じフックを継承すると思い込む: split paneで動くteammateは別のClaude Codeプロセスです。自分自身のモデルで決まり、親の構成は継承しません。同一プロセスのteammateだけが継承します
  • async hookにexit 2でゲートを期待する: 非同期実行はdecisionなどのフィールドを見ません。ブロックが必要なスクリプトは同期にします

まとめ

Slackへの通知や監査ログはasync: trueで、完了や作成を止めたいものは同期で、と目的で実行方式を分けるのが基本です。組む前に確認したいのは、使うモデルでTask toolが有効かどうかです。

既存のhooks実例カタログのSubagentStop通知と組み合わせれば、サブエージェント単位とタスク単位の両方の粒度で進捗を追えます。まずasync: trueの通知だけを入れ、必要になった時点でゲートを足す順番が組み替えやすい進め方です。

よくある質問

PreToolUseでTaskCreateを止めるのと何が違いますか

PreToolUseはツール呼び出し全般に効く汎用のフックで、matcherにTaskCreateを指定すれば同じように止められます。違いはmatcherの要不要です。TaskCreatedは最初からこのイベント専用で、matcherを書かずに済みます。命名規則の強制のようにタスク作成だけに閉じたロジックには、こちらが向いています。

この記事を共有:XはてブLinkedIn