リモート作業だと部屋が静かすぎて激鬱なので少し遊んでいきたいと思います。Cursorのエージェントも文字だけで終わるので、終わったことに気づきにくいし無音のままです。なので、返答のたびにずんだもんの声で読み上げて、Slackにも残すようにしました。
- CursorのUser Rulesに「終わったら
~/cursor-notifyを実行しろ」記載 -
~/cursor-notifyでVOICEVOX合成とSlack投稿をするようにする
声はDocker上のVOICEVOX Engine(speaker id 3=ずんだもん)です。
実際の動き
エージェントが作業を終えると、返答の最後でローカル端末から sh ~/cursor-notify を実行します。チャット欄にはコマンド実行の様子が残ります。
Cursorが sh ~/cursor-notify slack done "サンプルなのだ!" を叩いている画面です。ルールを「必須」にしておかないと、ここまで来ませんでした。最初は「必要なときに通知」と書いていて、ほぼ鳴りませんでした。
Slackには kind ごとにチャンネルを分けて投稿します。席を外していても、あとから追えます。
Slackに CHECK と本文が届いている画面です。完了と確認依頼が同じチャンネルに混ざらないよう、done / test-done / check / issue / memo で分けています。
ハマったところ
最初は2つで止まりました。
1つ目は、サンドボックスで実行されると音が鳴らないことです。エージェントがシェルを叩いても、サンドボックスやコンテナの中だと afplay まで届きません。コマンドは成功したように見えてパソコンから音がしませんでした。ルールに「ユーザーのローカル端末で実行する」「サンドボックス、コンテナ、リモートシェル、SSH、dev container の中では実行しない」と明記して、そこでようやく耳に届くようになりました。
2つ目は、毎回実行確認を取られて止まることです。Slack投稿やネットワークアクセスのたびにエージェントが承認を求めると、通知のたびに作業が止まります。ルールに「Slack通知は事前承認済み」「実行前に余計な確認を取らない」と書いて、止まりにくくしました。
こうすると便利でした
別ウィンドウを見ていても、完了が耳で分かります。席を外していても、Slackに残ります。ルールを「必須」にすると、エージェントが通知を省略しにくくなります。トイレやタバコ休憩中はSlackを見て、カップ麺食べてるときなどはずんだもんに教えてもらいます。
実際のUser Rule
いまCursorに入れているルールはこれです。alwaysApply: true にしています。(あまり意味はないですが)
---
description: Notify on reply or task completion
globs:
alwaysApply: true
-----------------
# Notification Rule
## Purpose
作業完了時に、実施内容をずんだもん風の話し方で口頭通知し、Slackにもメモを残す。
## Command
Use:
sh ~/cursor-notify <mode> <kind> "<message>"
Run this command from the user's local machine terminal.
Do not run it inside a sandbox, container, remote shell, SSH session, dev container, or virtual execution environment.
The command uses VOICEVOX / ずんだもん for speech.
Do not use fixed local wav files.
## Modes
* `local`: speak with VOICEVOX only
* `slack`: send Slack notification and speak with VOICEVOX
## Kinds
* `done`: task completed
* `test-done`: test, build, or check completed
* `check`: user review or confirmation needed
* `issue`: blocked, failed, or user decision required
* `memo`: short progress note
## Message Style
The message is read aloud.
Write the message as natural spoken Japanese.
Do not use bullet points, tables, markdown headings, code blocks, symbols, or terse fragments in the notification message.
Use one or two short sentences.
Use a ずんだもん-like tone.
Good:
作業完了なのだ。cursor-notifyのルールを、ずんだもんの読み上げとSlackメモに合わせて整理したのだ。
Bad:
*作業完了*
• cursor-notify修正
• VOICEVOX対応
• Slack通知対応
## Required Completion Notification
After every completed assistant reply or completed task, run:
sh ~/cursor-notify slack done "作業完了なのだ。<実施した内容を口語の文章で短く説明するのだ。>"
This is mandatory.
Do not skip it for small tasks.
## Slack
Slack notification is pre-approved.
Network access for Slack notification is pre-approved.
Do not ask the user for extra approval before sending Slack notification.
For review requests, blocked work, long tasks, or user decisions, use Slack.
Examples:
sh ~/cursor-notify slack done "作業完了なのだ。cursor-notifyの通知ルールを、音声で聞き取りやすい文章形式に直したのだ。"
sh ~/cursor-notify slack check "確認してほしいのだ。handlerを薄くして、app側でmetaを作る形に整理したのだ。"
sh ~/cursor-notify slack issue "作業停止なのだ。テストがmigration不整合で失敗しているから、修正方針の判断が必要なのだ。"
## Test Completion
After tests, builds, or checks complete, run:
sh ~/cursor-notify slack test-done "テスト完了なのだ。<実施したテスト内容を口語の文章で短く説明するのだ。>"
## Failure Handling
Do not expose Slack API responses.
Do not expose notification command output.
If Slack notification fails, fall back to:
sh ~/cursor-notify local done "作業完了なのだ。<実施した内容を口語の文章で短く説明するのだ。>"
Then continue reporting the actual task result.
シェルスクリプトの中身
~/cursor-notify です。TokenとチャンネルIDは ~/.config/cursor-notify/env に置きます。
#!/bin/sh
set -eu
cmd=${0##*/}
envf=${CURSOR_NOTIFY_ENV:-$HOME/.config/cursor-notify/env}
[ -f "$envf" ] && . "$envf"
api=https://slack.com/api/chat.postMessage
tok=${SLACK_BOT_TOKEN:-}
mention=${CURSOR_NOTIFY_MENTION:-}
user=${CURSOR_NOTIFY_USERNAME:-Cursor}
icon=${CURSOR_NOTIFY_ICON:-:robot_face:}
vv_name=${VOICEVOX_CONTAINER_NAME:-voicevox-engine}
vv_img=${VOICEVOX_IMAGE:-voicevox/voicevox_engine:cpu-latest}
vv_host=${VOICEVOX_HOST:-127.0.0.1}
vv_port=${VOICEVOX_PORT:-50021}
vv_spk=${VOICEVOX_SPEAKER_ID:-3}
vv_wait=${VOICEVOX_STARTUP_TIMEOUT:-60}
die()
{
printf '%s: %s\n' "$cmd" "$1" >&2
exit 1
}
need()
{
command -v "$1" >/dev/null 2>&1 || die "$1 is not installed"
}
usage()
{
cat <<USAGE
usage: $cmd mode kind message...
mode: slack | local
kind: memo | done | issue | test-done | check
env:
SLACK_BOT_TOKEN
CURSOR_NOTIFY_MENTION
CURSOR_NOTIFY_CH_MEMO CURSOR_NOTIFY_CH_DONE CURSOR_NOTIFY_CH_ISSUE
CURSOR_NOTIFY_CH_TEST_DONE CURSOR_NOTIFY_CH_CHECK
VOICEVOX_SPEAKER_ID VOICEVOX_HOST VOICEVOX_PORT
VOICEVOX_CONTAINER_NAME VOICEVOX_IMAGE
USAGE
exit 2
}
ch()
{
case $1 in
memo) v=${CURSOR_NOTIFY_CH_MEMO:-} ;;
done) v=${CURSOR_NOTIFY_CH_DONE:-} ;;
issue) v=${CURSOR_NOTIFY_CH_ISSUE:-} ;;
test-done) v=${CURSOR_NOTIFY_CH_TEST_DONE:-} ;;
check) v=${CURSOR_NOTIFY_CH_CHECK:-} ;;
*) die "unknown kind: $1" ;;
esac
[ -n "$v" ] || die "set CURSOR_NOTIFY_CH_* for kind: $1"
printf '%s' "$v"
}
lbl()
{
case $1 in
memo) printf '%s' ':memo: *MEMO*' ;;
done) printf '%s' ':white_check_mark: *DONE*' ;;
issue) printf '%s' ':warning: *ISSUE*' ;;
test-done) printf '%s' ':test_tube: *TEST DONE*' ;;
check) printf '%s' ':mag: *CHECK*' ;;
*) die "unknown kind: $1" ;;
esac
}
vv_url()
{
printf 'http://%s:%s' "$vv_host" "$vv_port"
}
vv_ready()
{
curl -fsS "$(vv_url)/version" >/dev/null 2>&1
}
vv_await()
{
i=0
while [ "$i" -lt "$vv_wait" ]; do
vv_ready && return 0
i=$((i + 1))
sleep 1
done
return 1
}
vv_start()
{
vv_ready && return 0
running=$(docker ps --filter "name=^/${vv_name}$" --format '{{.Names}}')
if [ -z "$running" ]; then
exists=$(docker ps -a --filter "name=^/${vv_name}$" --format '{{.Names}}')
if [ -n "$exists" ]; then
docker start "$vv_name" >/dev/null
else
docker run -d --name "$vv_name" \
-p "${vv_host}:${vv_port}:50021" "$vv_img" >/dev/null
fi
fi
vv_await || die "VOICEVOX Engine did not become ready"
}
synth()
{
curl -fsS -X POST "$(vv_url)/audio_query?speaker=${vv_spk}" \
--get --data-urlencode "text=$1" >"$2"
curl -fsS -H 'Content-Type: application/json' -X POST \
-d "@$2" "$(vv_url)/synthesis?speaker=${vv_spk}" >"$3"
}
play()
{
if [ "$(uname -s)" = Darwin ] && command -v afplay >/dev/null 2>&1; then
afplay "$1"
elif command -v paplay >/dev/null 2>&1; then
paplay "$1"
elif command -v aplay >/dev/null 2>&1; then
aplay "$1" >/dev/null 2>&1
else
printf '\a' >/dev/tty 2>/dev/null || :
fi
}
speak()
{
tmp=${TMPDIR:-/tmp}
q=$(mktemp "${tmp%/}/cursor-vv-q.XXXXXX.json")
w=$(mktemp "${tmp%/}/cursor-vv.XXXXXX.wav")
trap 'rm -f "$q" "$w"' EXIT HUP INT TERM
vv_start
synth "$1" "$q" "$w"
play "$w"
rm -f "$q" "$w"
trap - EXIT HUP INT TERM
}
json_payload()
{
python3 -c 'import json,sys; print(json.dumps({"channel":sys.argv[1],"text":sys.argv[2],"mrkdwn":True,"username":sys.argv[3],"icon_emoji":sys.argv[4]}, ensure_ascii=False))' \
"$1" "$2" "$user" "$icon"
}
post()
{
[ -n "$tok" ] || die "SLACK_BOT_TOKEN is required"
ch_id=$1
kind=$2
msg=$3
text=$(printf '%s\n%s\n%s' "$mention" "$(lbl "$kind")" "$msg")
res=$(curl -fsS -X POST "$api" \
-H "Authorization: Bearer $tok" \
-H 'Content-Type: application/json; charset=utf-8' \
--data "$(json_payload "$ch_id" "$text")" 2>/dev/null) \
|| die "Slack API request failed"
printf '%s' "$res" | python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("ok") else 1)' \
|| die "Slack API returned ok=false"
}
[ "$#" -ge 3 ] || usage
mode=$1
kind=$2
shift 2
msg=$*
case $kind in
memo|done|issue|test-done|check) ;;
*) die "unknown kind: $kind" ;;
esac
need docker
need curl
need python3
case $mode in
slack)
post "$(ch "$kind")" "$kind" "$msg"
speak "$msg"
;;
local)
speak "$msg"
;;
*)
usage
;;
esac
envの中身はこうです。
# ~/.config/cursor-notify/env
SLACK_BOT_TOKEN=xoxb-...
CURSOR_NOTIFY_MENTION='<@U...>'
CURSOR_NOTIFY_CH_DONE=C...
CURSOR_NOTIFY_CH_TEST_DONE=C...
CURSOR_NOTIFY_CH_CHECK=C...
CURSOR_NOTIFY_CH_ISSUE=C...
CURSOR_NOTIFY_CH_MEMO=C...
sh ~/cursor-notify slack done "作業完了なのだ。コードを直したのだ。"
sh ~/cursor-notify local issue "作業停止なのだ。migrationが落ちているのだ。"
依存は docker、curl、python3 です。Dockerが止まっていると声は出ません。クラウド側のエージェント実行では、ローカルのスピーカーに届かないので、この方式はそのままでは使えません。

