diff --git a/.gitignore b/.gitignore index 4c025c6..71b6c8e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ # コントロールプレーンが読む運用ログ。2 時間ごとに増えるので、 # コミットに乗せると Maker の PR が無関係な差分で汚れる loops/events.jsonl +# loop-notify がどこまで通知したかの位置。ホスト側の実行時状態 +loops/.notify-seen loops/.preview.pid loops/.preview.meta loops/runs/*.log diff --git a/.loop/bin/loop-doctor b/.loop/bin/loop-doctor index beb3230..856bd45 100755 --- a/.loop/bin/loop-doctor +++ b/.loop/bin/loop-doctor @@ -313,5 +313,17 @@ else fi fi +# 通知は opt-in なので、未登録は NG ではなく SKIP。 +# ただし「登録したつもりで動いていない」は静かな失敗なので、状態は必ず見せる。 +if [ "$(uname -s)" = "Darwin" ]; then + NOTIFY_STATUS="$("$BIN_DIR/loop-notify" --status 2>/dev/null | head -1)" + case "$NOTIFY_STATUS" in + 有効:*) emit OK "通知エージェント" "${NOTIFY_STATUS#有効: } が読み込まれている" ;; + *) emit SKIP "通知エージェント" "未登録。要るなら .loop/bin/loop-notify --install(macOS のみ)" ;; + esac +else + emit SKIP "通知エージェント" "macOS 専用のため確認しない(uname=$(uname -s))" +fi + [ "$FAILED" -eq 0 ] && exit 0 exit 1 diff --git a/.loop/bin/loop-notify b/.loop/bin/loop-notify new file mode 100755 index 0000000..3a2badb --- /dev/null +++ b/.loop/bin/loop-notify @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# ループの節目を macOS の通知センターに出す(opt-in)。 +# +# **なぜホスト側で動くのか:** ループは Docker コンテナの中で動いており、 +# コンテナからは macOS の通知を出せない(osascript も terminal-notifier も +# ホストにしかなく、通知センターはホストの GUI セッションに属する)。 +# リポジトリはバインドマウントされているので、このファイル自身も +# コンテナが書いたログもホストから同じパスで見える。ホストの LaunchAgent が +# これを呼ぶ。**firing からは絶対に呼ばない。** +# +# **なぜ loops/runs/ ではなく loops/STATE.md を見るのか:** 検証ログ +# (loops/runs/*-verifier-pr-N.md)は agent-run の非追記 tee が Verifier の +# **開始時**に作って書き足していく。つまり「ファイルができた = 検証が終わった」 +# にならない。終わりの信号は record_state が STATE.md の "## Budget" 節に +# 追記する 1 行(.loop/lib/common.sh)で、これは dispatch の最後にだけ書かれる。 +# maker / verifier / fixer / gate / 自動 merge の節目がすべてここを通るので、 +# 見張る場所は 1 つで足りる。 +# +# 使い方: +# loop-notify 新しい記録行を通知する(LaunchAgent が呼ぶ) +# loop-notify --install LaunchAgent を登録する(これが有効化の操作) +# loop-notify --uninstall 解除する +# loop-notify --status 登録状態を表示する +# loop-notify --test サンプル通知を 1 件出して配線を確かめる +# +# 夜中に鳴らさないため、通知は notify.active_from 〜 notify.active_until の +# 間だけ出す。窓の外で起きたことは捨てずに持ち越し、次に窓へ入った最初の +# 実行でまとめて出す(既定 06:00-23:30 なので、深夜 1 時の PR は朝 6 時に鳴る)。 +# +# **有効/無効の唯一の真実は「LaunchAgent が読み込まれているか」。** +# config に enabled を持たせると「true なのに通知が来ない(未登録)」という +# 食い違いが必ず起きるので、スイッチを 2 つ作らない。 +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/common.sh" +BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +STATE_MD="$REPO_ROOT/loops/STATE.md" +SEEN_FILE="$REPO_ROOT/loops/.notify-seen" +# テストは LOOP_NOTIFIER_BIN で通知先を差し替える(実際に通知を出さないため) +NOTIFIER="${LOOP_NOTIFIER_BIN:-$(command -v terminal-notifier 2>/dev/null || true)}" + +# 同じホストで複数のループを動かせるよう、ラベルはリポジトリごとに一意にする +# (docker.project_name と同じ問題。defaults.toml の [docker] のコメントを参照)。 +slug() { + local s + s="$(cfg docker.project_name 2>/dev/null || true)" + [ -n "$s" ] || s="$(basename "$REPO_ROOT")" + printf '%s' "$s" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/-*$//' +} +LABEL="com.dev-loop.notify.$(slug)" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" + +is_macos() { [ "$(uname -s)" = "Darwin" ]; } + +# 通知のクリック先。設定が空ならホストの git remote から組み立てる +repo_url() { + local u + u="$(cfg notify.repo_url 2>/dev/null || true)" + if [ -n "$u" ]; then printf '%s' "$u"; return; fi + u="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || true)" + case "$u" in + git@github.com:*) u="https://github.com/${u#git@github.com:}" ;; + esac + printf '%s' "${u%.git}" +} + +# terminal-notifier があればクリックで URL を開ける。無ければ osascript に落とす +# (osascript の display notification はクリック先を指定できない)。 +notify() { + local title="$1" subtitle="$2" message="$3" url="$4" sound="$5" + [ -n "$message" ] || message=" " + if [ -n "$NOTIFIER" ]; then + # ${url:+-open "$url"} と書くと引用符が literal のまま渡ってしまうため配列で組む + local args + args=( -title "$title" -subtitle "$subtitle" -message "$message" ) + [ -n "$url" ] && args=( "${args[@]}" -open "$url" ) + [ -n "$sound" ] && args=( "${args[@]}" -sound "$sound" ) + "$NOTIFIER" "${args[@]}" >/dev/null 2>&1 + else + /usr/bin/osascript \ + -e "display notification $(osa_str "$message") with title $(osa_str "$title") subtitle $(osa_str "$subtitle")" \ + >/dev/null 2>&1 + fi +} + +# AppleScript の文字列リテラルに埋める。\ と " を潰さないとスクリプトごと壊れる +osa_str() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + printf '"%s"' "$s" +} + +# Verifier の判定は自由文で、書式が揃っていない。実例: +# PR #11: **判定: approve 相当** +# PR #13: ## 検証結果: request-changes +# 先頭付近を優先して見て、決まらなければ全文を見る。**取り違え得るので、 +# 通知本文にはログの 1 行目をそのまま載せて人間が気づけるようにする。** +verdict_of() { + local log_path="$1" scope + [ -f "$log_path" ] || { printf '判定不明'; return; } + for scope in "$(head -12 "$log_path")" "$(cat "$log_path")"; do + if printf '%s' "$scope" | grep -qiE 'request[- ]?changes|修正要求|変更を要求'; then + printf '修正要求'; return + fi + if printf '%s' "$scope" | grep -qiE 'approve|承認'; then + printf 'approve 相当'; return + fi + done + printf '判定不明' +} + +first_line_of() { + [ -f "$1" ] || return 0 + grep -m1 -v '^[[:space:]]*$' "$1" | sed 's/^#* *//' | cut -c1-120 +} + +# --- 通知してよい時間帯 ----------------------------------------------------- +# +# ループは夜中も 2 時間おきに発火する。その通知で起こされたくないので、 +# notify.active_from 〜 notify.active_until の外では**通知位置を進めずに黙る**。 +# 次に窓へ入った最初の実行が、持ち越したぶんをまとめて出す +# (例: 01:00 に PR ができたら 06:00 に鳴る)。 +# +# 壊れた設定値は既定に丸めて stderr に知らせる。gen-crontab と同じ方針で、 +# 設定ミスで機能が静かに死ぬ(= 通知が一生来ない)のを避ける。 + +# "HH:MM" を 0-1439 の分に直す +to_minutes() { + local raw="$1" name="$2" fb="$3" h m + case "$raw" in + [0-9][0-9]:[0-9][0-9]) ;; + *) [ -n "$raw" ] && echo "注意: notify.$name=\"$raw\" は HH:MM として不正なため $fb を使いました" >&2 + raw="$fb" ;; + esac + # 06 や 09 を 8 進数と解釈させない + h=$((10#${raw%:*})); m=$((10#${raw#*:})) + if [ "$h" -gt 23 ] || [ "$m" -gt 59 ]; then + echo "注意: notify.$name=\"$raw\" は時刻として不正なため $fb を使いました" >&2 + h=$((10#${fb%:*})); m=$((10#${fb#*:})) + fi + printf '%s' "$((h * 60 + m))" +} + +window_from() { to_minutes "$(cfg notify.active_from 2>/dev/null || echo '06:00')" active_from '06:00'; } +window_till() { to_minutes "$(cfg notify.active_until 2>/dev/null || echo '23:30')" active_until '23:30'; } + +in_active_window() { + local from till now + from="$(window_from)"; till="$(window_till)" + + # テストは LOOP_NOTIFY_NOW="HH:MM" で現在時刻を差し替える。 + # 実時刻に依存させると「23:35 に走らせたテストだけ落ちる」が起きる + if [ -n "${LOOP_NOTIFY_NOW:-}" ]; then + now="$(to_minutes "$LOOP_NOTIFY_NOW" active_now '00:00')" + else + now=$(( 10#$(date +%H) * 60 + 10#$(date +%M) )) + fi + if [ "$from" -le "$till" ]; then + [ "$now" -ge "$from" ] && [ "$now" -lt "$till" ] + else + # 22:00 → 02:00 のように日をまたぐ指定 + [ "$now" -ge "$from" ] || [ "$now" -lt "$till" ] + fi +} + +hhmm() { printf '%02d:%02d' "$(( $1 / 60 ))" "$(( $1 % 60 ))"; } + +write_plist() { + local interval + interval="$(cfg notify.interval_seconds 2>/dev/null || echo 120)" + case "$interval" in '' | *[!0-9]*) interval=120 ;; esac + [ "$interval" -ge 30 ] 2>/dev/null || interval=30 + + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$PLIST" < + + + + Label$LABEL + ProgramArguments + + /bin/zsh + -lc + exec $BIN_DIR/loop-notify + + + WatchPaths + + $STATE_MD + + StartInterval$interval + StandardOutPath$HOME/Library/Logs/$LABEL.log + StandardErrorPath$HOME/Library/Logs/$LABEL.log + + +PLISTEOF +} + +case "${1:-}" in + --install) + is_macos || { echo "loop-notify は macOS 専用です(uname=$(uname -s))" >&2; exit 1; } + write_plist + # 先に現在位置を記録しておく。登録直後に過去ぶんが一斉に飛ぶのを防ぐ + "$BIN_DIR/loop-notify" --seen-only + launchctl bootout "gui/$(id -u)/$LABEL" >/dev/null 2>&1 || true + if launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null; then :; else + launchctl unload "$PLIST" >/dev/null 2>&1 || true + launchctl load "$PLIST" || { echo "LaunchAgent の読み込みに失敗しました: $PLIST" >&2; exit 1; } + fi + echo "登録しました: $LABEL" + echo " plist: $PLIST" + [ -n "$NOTIFIER" ] || echo " 注意: terminal-notifier が無いため osascript で出します(クリックで PR を開けません)" + echo " 確認: $BIN_DIR/loop-notify --test" + exit 0 + ;; + --uninstall) + launchctl bootout "gui/$(id -u)/$LABEL" >/dev/null 2>&1 \ + || launchctl unload "$PLIST" >/dev/null 2>&1 || true + rm -f "$PLIST" + echo "解除しました: $LABEL" + exit 0 + ;; + --status) + if launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1; then + echo "有効: $LABEL" + else + echo "無効: ${LABEL}($BIN_DIR/loop-notify --install で登録)" + fi + echo "plist: $PLIST" + echo "通知: ${NOTIFIER:-/usr/bin/osascript(terminal-notifier 未導入)}" + if in_active_window; then + echo "時間帯: $(hhmm "$(window_from)")-$(hhmm "$(window_till)")(今は通知する)" + else + echo "時間帯: $(hhmm "$(window_from)")-$(hhmm "$(window_till)")(今は黙る。次に窓へ入ったときまとめて出す)" + fi + exit 0 + ;; + --test) + notify "$(basename "$REPO_ROOT")" "通知の配線を確認しています" \ + "この通知が見えていれば設定は正しく動いています" "$(repo_url)" "Glass" + echo "サンプル通知を出しました(見えない場合は システム設定 > 通知 を確認)" + exit 0 + ;; + --seen-only) SEEN_ONLY=1 ;; + '') SEEN_ONLY=0 ;; + *) echo "使い方: loop-notify [--install|--uninstall|--status|--test]" >&2; exit 2 ;; +esac + +is_macos || exit 0 +[ -f "$STATE_MD" ] || exit 0 + +# "## Budget" 以下の記録行だけを数える。この節は record_state の追記専用。 +BUDGET_LINES="$(awk '/^## Budget/{f=1;next} f && /^- /' "$STATE_MD")" +TOTAL="$(printf '%s\n' "$BUDGET_LINES" | grep -c '^- ' || true)" + +PREV=0 +[ -f "$SEEN_FILE" ] && PREV="$(cat "$SEEN_FILE" 2>/dev/null || echo 0)" +case "$PREV" in '' | *[!0-9]*) PREV=0 ;; esac + +# 初回、--seen-only、または節が短くなった(STATE.md が書き換えられた)ときは +# 通知せず現在位置だけ記録する。過去ぶんが一度に何十件も飛ぶのを防ぐ。 +if [ "$SEEN_ONLY" = 1 ] || [ ! -f "$SEEN_FILE" ] || [ "$TOTAL" -lt "$PREV" ]; then + printf '%s\n' "$TOTAL" > "$SEEN_FILE" + exit 0 +fi +[ "$TOTAL" -le "$PREV" ] && exit 0 + +# 窓の外では**通知位置を進めない**。次に窓へ入った最初の実行が持ち越しを出す。 +# 位置を進めてしまうと夜間ぶんが永久に失われるので、ここで exit する順番が要点 +# (baseline の分岐より後、通知より前)。 +in_active_window || exit 0 + +URL_BASE="$(repo_url)" +TITLE="$(basename "$REPO_ROOT")" + +# 夜間ぶんを持ち越すと、朝いちで何件も一度に鳴り得る。多すぎるときは +# 1 件にまとめる(通知センターを埋めないため)。 +PENDING=$(( TOTAL - PREV )) +MAX_BURST="$(cfg notify.max_burst 2>/dev/null || echo 5)" +case "$MAX_BURST" in '' | *[!0-9]*) MAX_BURST=5 ;; esac +[ "$MAX_BURST" -ge 1 ] 2>/dev/null || MAX_BURST=1 + +if [ "$PENDING" -gt "$MAX_BURST" ]; then + LAST="$(printf '%s\n' "$BUDGET_LINES" | tail -1)" + LAST="${LAST#- }"; LAST="${LAST#*: }" + notify "$TITLE" "ループの記録が $PENDING 件たまっています" \ + "最新: $LAST" "$URL_BASE" "Funk" + printf '%s\n' "$TOTAL" > "$SEEN_FILE" + exit 0 +fi + +printf '%s\n' "$BUDGET_LINES" | tail -n "$((TOTAL - PREV))" | while IFS= read -r line; do + [ -n "$line" ] || continue + + # "- 2026-08-25: verifier pr-14 ok (log: loops/runs/...)" から日付を落とす + body="${line#- }" + body="${body#*: }" + + log_rel="$(printf '%s' "$body" | sed -n 's/.*(log: \([^)]*\)).*/\1/p')" + log_abs="" + [ -n "$log_rel" ] && log_abs="$REPO_ROOT/$log_rel" + + pr="$(printf '%s' "$body" | sed -n 's/.*pr-\([0-9][0-9]*\).*/\1/p')" + issue="$(printf '%s' "$body" | sed -n 's/.*issue-\([0-9][0-9]*\).*/\1/p')" + new_pr="$(printf '%s' "$body" | sed -n 's/.*-> PR #\([0-9][0-9]*\).*/\1/p')" + + url="$URL_BASE" + [ -n "$issue" ] && url="$URL_BASE/issues/$issue" + [ -n "$pr" ] && url="$URL_BASE/pull/$pr" + [ -n "$new_pr" ] && url="$URL_BASE/pull/$new_pr" + + case "$body" in + "verifier pr-"*" ok"*) + v="$(verdict_of "$log_abs")" + case "$v" in + 修正要求) sound="Basso" ;; + approve*) sound="Glass" ;; + *) sound="Funk" ;; + esac + notify "$TITLE" "PR #$pr の検証が終わりました — $v" \ + "$(first_line_of "$log_abs")" "$url" "$sound" + ;; + "verifier pr-"*) + notify "$TITLE" "PR #$pr の検証が完了しませんでした" "$body" "$url" "Basso" + ;; + "maker issue-"*"完了したが PR なし"*) + notify "$TITLE" "⚠️ Issue #$issue は実装できたのに PR が出ていません" \ + "worktree はコンテナの中にしかありません。作り直す前に確認してください" \ + "$url" "Basso" + ;; + "maker issue-"*" ok -> PR #"*) + notify "$TITLE" "Issue #$issue の PR #$new_pr ができました" \ + "これから検証に入ります" "$url" "Glass" + ;; + "maker issue-"*|*"gate 不合格"*|*"needs-human"*|*"コンフリクト"*) + notify "$TITLE" "ループが人間を待っています" "$body" "$url" "Basso" + ;; + "fixer pr-"*) + notify "$TITLE" "PR #$pr の自動修正" "$body" "$url" "Funk" + ;; + "L3 自動 merge"*) + notify "$TITLE" "自動 merge" "$body" "$url" "Glass" + ;; + *) + notify "$TITLE" "ループ" "$body" "$url" "" + ;; + esac +done + +printf '%s\n' "$TOTAL" > "$SEEN_FILE" diff --git a/.loop/defaults.toml b/.loop/defaults.toml index 8c3edb9..b168368 100644 --- a/.loop/defaults.toml +++ b/.loop/defaults.toml @@ -92,6 +92,41 @@ lint = "echo 'project.lint が未設定です' && false" preview = "" preview_port = 3000 +[notify] +# macOS の通知センターにループの節目(Verifier の判定、PR 作成、人間待ち)を出す。 +# +# **有効/無効のスイッチはここに無い。** ループはコンテナの中で動いていて +# 通知はホストから出すしかないので、有効化の操作は「ホストで LaunchAgent を +# 登録すること」そのもの: +# +# .loop/bin/loop-notify --install 登録(= 有効化) +# .loop/bin/loop-notify --uninstall 解除 +# .loop/bin/loop-notify --status 今どちらか +# +# ここに enabled = true を置くと「true なのに通知が来ない(未登録)」という +# 食い違いが必ず起きる。スイッチは 1 つだけにする。 +# +# 通知のクリック先の元になる GitHub リポジトリ URL。 +# 空ならホストの `git remote get-url origin` から組み立てる +repo_url = "" +# 取りこぼし用の定期チェック間隔(秒。最小 30)。STATE.md の変更は plist の +# WatchPaths が即座に拾うが、**コンテナ内の書き込みがホストの FSEvents に +# 必ず伝わる保証がない**ため、保険として定期実行も併用する +interval_seconds = 120 + +# 通知を出してよい時間帯(HH:MM)。ループは夜中も 2 時間おきに発火するので、 +# 既定では夜間を外す。 +# +# **窓の外で起きたことは捨てない。** 通知位置を進めずに持ち越し、次に窓へ +# 入った最初の実行でまとめて出す(既定なら深夜 1 時にできた PR は朝 6 時に鳴る)。 +# active_from > active_until と書けば日をまたぐ窓(例 22:00-02:00)になる。 +active_from = "06:00" +active_until = "23:30" + +# 持ち越しが多いときに 1 件へまとめる閾値。朝いちで通知センターが +# 埋まるのを防ぐ。これを超えた場合は「N 件たまっています」の 1 通になる +max_burst = 5 + [mtg] # VOICEVOX による読み上げ(opt-in) voice = false diff --git a/.loop/tests/loop-doctor.bats b/.loop/tests/loop-doctor.bats index e419f8b..aaf68e6 100644 --- a/.loop/tests/loop-doctor.bats +++ b/.loop/tests/loop-doctor.bats @@ -355,9 +355,10 @@ EOF [ "$status" -eq 1 ] NG_COUNT="$(printf '%s\n' "$output" | grep -c '^NG')" [ "$NG_COUNT" -eq 1 ] - # claude CLI / claude 認証 / gh 認証 / ラベル の 4 件 + # claude CLI / claude 認証 / gh 認証 / ラベル / 通知エージェント の 5 件 + # (通知エージェントは opt-in なので、fixture では未登録 = SKIP になる) SKIP_COUNT="$(printf '%s\n' "$output" | grep -c '^SKIP')" - [ "$SKIP_COUNT" -eq 4 ] + [ "$SKIP_COUNT" -eq 5 ] } @test "config.toml が [project] を全く持たなくてもクラッシュしない" { diff --git a/.loop/tests/loop-notify.bats b/.loop/tests/loop-notify.bats new file mode 100644 index 0000000..42ca892 --- /dev/null +++ b/.loop/tests/loop-notify.bats @@ -0,0 +1,172 @@ +#!/usr/bin/env bats + +load helpers + +# 通知先は stub に差し替える(実際に通知センターへ出さない)。 +# stub は呼ばれるたびに 1 行を $CALLS に追記するので、件数と中身を検査できる。 +setup() { + TMP="$(mktemp -d)" + REPO_ROOT="$TMP/repo" + export REPO_ROOT + mkdir -p "$REPO_ROOT/loops/runs" + LOOP_DIR="$(make_loop_dir "$REPO_ROOT/.loop")" + export LOOP_DIR + + CALLS="$TMP/calls" + export CALLS + cat > "$TMP/notifier-stub" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$CALLS" +STUB + chmod +x "$TMP/notifier-stub" + LOOP_NOTIFIER_BIN="$TMP/notifier-stub" + export LOOP_NOTIFIER_BIN + + # 窓の中を既定にする。窓を見るテストだけが個別に上書きする + LOOP_NOTIFY_NOW="12:00" + export LOOP_NOTIFY_NOW +} + +teardown() { rm -rf "$TMP"; } + +# STATE.md の "## Budget" 節に記録行を作る +state_with() { + { + printf '# STATE\n\n## Budget\n' + for line in "$@"; do printf -- '- 2026-08-25: %s\n' "$line"; done + } > "$REPO_ROOT/loops/STATE.md" +} + +calls() { [ -f "$CALLS" ] && wc -l < "$CALLS" | tr -d ' ' || echo 0; } + +@test "初回は通知せず、現在位置だけ記録する(過去ぶんが一気に飛ばない)" { + state_with "verifier pr-1 ok" "verifier pr-2 ok" "verifier pr-3 ok" + run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$status" -eq 0 ] + [ "$(calls)" -eq 0 ] + [ "$(cat "$REPO_ROOT/loops/.notify-seen")" -eq 3 ] +} + +@test "2 回目以降は増えたぶんだけ通知する" { + state_with "verifier pr-1 ok" + "$LOOP_REAL_DIR/bin/loop-notify" + state_with "verifier pr-1 ok" "verifier pr-2 ok" + run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$status" -eq 0 ] + [ "$(calls)" -eq 1 ] + [[ "$(cat "$CALLS")" == *"PR #2 の検証が終わりました"* ]] +} + +@test "増えていなければ何も出さない" { + state_with "verifier pr-1 ok" + "$LOOP_REAL_DIR/bin/loop-notify" + run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$(calls)" -eq 0 ] +} + +@test "窓の外では通知せず、位置も進めない(持ち越す)" { + state_with "verifier pr-1 ok" + "$LOOP_REAL_DIR/bin/loop-notify" + state_with "verifier pr-1 ok" "maker issue-9 ok -> PR #20" + + LOOP_NOTIFY_NOW="01:00" run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$(calls)" -eq 0 ] + # **位置が進んでいないことが要点。** 進めてしまうと夜間ぶんが永久に消える + [ "$(cat "$REPO_ROOT/loops/.notify-seen")" -eq 1 ] + + # 窓に入った最初の実行で持ち越しが出る + LOOP_NOTIFY_NOW="06:00" run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$(calls)" -eq 1 ] + [[ "$(cat "$CALLS")" == *"PR #20"* ]] + [ "$(cat "$REPO_ROOT/loops/.notify-seen")" -eq 2 ] +} + +@test "窓の境界: active_until ちょうどは窓の外" { + state_with "verifier pr-1 ok" + "$LOOP_REAL_DIR/bin/loop-notify" + state_with "verifier pr-1 ok" "verifier pr-2 ok" + LOOP_NOTIFY_NOW="23:30" run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$(calls)" -eq 0 ] + LOOP_NOTIFY_NOW="23:29" run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$(calls)" -eq 1 ] +} + +@test "active_from > active_until なら日をまたぐ窓になる" { + printf '[notify]\nactive_from = "22:00"\nactive_until = "02:00"\n' > "$LOOP_DIR/config.toml" + state_with "verifier pr-1 ok" + "$LOOP_REAL_DIR/bin/loop-notify" + state_with "verifier pr-1 ok" "verifier pr-2 ok" + + LOOP_NOTIFY_NOW="12:00" run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$(calls)" -eq 0 ] + LOOP_NOTIFY_NOW="23:00" run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$(calls)" -eq 1 ] +} + +@test "壊れた時刻の設定は既定に丸め、通知が止まらない" { + printf '[notify]\nactive_from = "26:99"\nactive_until = "ほげ"\n' > "$LOOP_DIR/config.toml" + state_with "verifier pr-1 ok" + "$LOOP_REAL_DIR/bin/loop-notify" + state_with "verifier pr-1 ok" "verifier pr-2 ok" + LOOP_NOTIFY_NOW="12:00" run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$(calls)" -eq 1 ] +} + +@test "持ち越しが max_burst を超えたら 1 件にまとめる" { + printf '[notify]\nmax_burst = 2\n' > "$LOOP_DIR/config.toml" + state_with "verifier pr-1 ok" + "$LOOP_REAL_DIR/bin/loop-notify" + state_with "verifier pr-1 ok" "verifier pr-2 ok" "verifier pr-3 ok" "verifier pr-4 ok" + run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$(calls)" -eq 1 ] + [[ "$(cat "$CALLS")" == *"3 件たまっています"* ]] + [ "$(cat "$REPO_ROOT/loops/.notify-seen")" -eq 4 ] +} + +@test "検証ログから request-changes を読み取る" { + state_with "verifier pr-1 ok" + "$LOOP_REAL_DIR/bin/loop-notify" + printf '## 検証結果: request-changes\n\n基準 5 が未充足。\n' \ + > "$REPO_ROOT/loops/runs/2026-08-25-verifier-pr-7.md" + state_with "verifier pr-1 ok" \ + "verifier pr-7 ok (log: loops/runs/2026-08-25-verifier-pr-7.md)" + run "$LOOP_REAL_DIR/bin/loop-notify" + [[ "$(cat "$CALLS")" == *"修正要求"* ]] + # 判定を取り違えても人間が気づけるよう、ログの 1 行目を本文に載せる + [[ "$(cat "$CALLS")" == *"検証結果: request-changes"* ]] +} + +@test "書式が違っても approve 相当を読み取る" { + state_with "verifier pr-1 ok" + "$LOOP_REAL_DIR/bin/loop-notify" + printf '検証を完了しました。\n\n**判定: approve 相当**\n' \ + > "$REPO_ROOT/loops/runs/2026-08-25-verifier-pr-8.md" + state_with "verifier pr-1 ok" \ + "verifier pr-8 ok (log: loops/runs/2026-08-25-verifier-pr-8.md)" + run "$LOOP_REAL_DIR/bin/loop-notify" + [[ "$(cat "$CALLS")" == *"approve 相当"* ]] +} + +@test "PR が出ないまま終わった maker は警告として出す(worktree がコンテナ内に残る)" { + state_with "verifier pr-1 ok" + "$LOOP_REAL_DIR/bin/loop-notify" + state_with "verifier pr-1 ok" "maker issue-7 完了したが PR なし (log: x.md)" + run "$LOOP_REAL_DIR/bin/loop-notify" + [[ "$(cat "$CALLS")" == *"Issue #7"* ]] + [[ "$(cat "$CALLS")" == *"PR が出ていません"* ]] +} + +@test "STATE.md が短くなったら通知せず位置を貼り直す(書き換えに追随する)" { + state_with "a" "b" "c" "d" + "$LOOP_REAL_DIR/bin/loop-notify" + state_with "a" + run "$LOOP_REAL_DIR/bin/loop-notify" + [ "$(calls)" -eq 0 ] + [ "$(cat "$REPO_ROOT/loops/.notify-seen")" -eq 1 ] +} + +@test "--status は登録状態と時間帯を表示する" { + run "$LOOP_REAL_DIR/bin/loop-notify" --status + [ "$status" -eq 0 ] + [[ "$output" == *"06:00-23:30"* ]] +} diff --git a/README.md b/README.md index ed97140..20241c1 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,41 @@ diff <(sort .gitignore) <(sort "$D/.gitignore") 状況を見るだけなら `/loop-status`。 +## 通知(macOS、opt-in) + +Verifier が PR を検証し終わったとき、PR ができたとき、ループが人間を +待っているときに、macOS の通知センターに出せます。 + +```bash +.loop/bin/loop-notify --install # 登録(= 有効化) +.loop/bin/loop-notify --test # 配線の確認 +.loop/bin/loop-notify --status # 今どうなっているか +.loop/bin/loop-notify --uninstall # 解除 +``` + +**必ずホストで実行してください。** ループはコンテナの中で動きますが、通知は +ホストの GUI セッションからしか出せません。`--install` はホストの +LaunchAgent を登録し、`loops/STATE.md` の記録行が増えたときに通知します。 + +`terminal-notifier`(`brew install terminal-notifier`)が入っていれば、 +**通知をクリックすると該当の PR / Issue が開きます。** 無ければ `osascript` に +フォールバックしますが、クリック先は指定できません。 + +**夜中は鳴りません。** 既定では 06:00-23:30 の間だけ通知し、窓の外で起きたことは +捨てずに持ち越して、次に窓へ入った最初の実行でまとめて出します(深夜 1 時に +できた PR は朝 6 時に鳴る)。時間帯は `.loop/config.toml` で変えられます: + +```toml +[notify] +active_from = "06:00" +active_until = "23:30" +max_burst = 5 # 持ち越しがこれを超えたら 1 件にまとめる +``` + +**判定は通知の見出しに出ます**(`PR #13 の検証が終わりました — 修正要求`)。 +Verifier の判定文は自由文なので読み取りはヒューリスティックです。取り違えても +気づけるよう、検証ログの 1 行目を通知本文にそのまま載せています。 + ## 最初は L1 から `.loop/config.toml` の `maturity` は**同梱の時点で `"L1"`** です。 diff --git a/docs/superpowers/specs/2026-08-25-notify-design.md b/docs/superpowers/specs/2026-08-25-notify-design.md new file mode 100644 index 0000000..9a3544d --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-notify-design.md @@ -0,0 +1,107 @@ +# ループの節目を macOS に通知する(loop-notify)設計 + +2026-08-25 + +## 解こうとしている問題 + +ループは 2 時間おきに黙って回る。人間がそれを知る手段は `/loop-status` を +叩くか `loops/STATE.md` を開くかで、**どちらも人間から見に行く必要がある**。 + +これが実害を出した。参考実装(akashic-recorder)で、Verifier が PR の本物の +欠陥を見つけて request-changes 相当の判定を出したのに、**11 日間放置された**。 +その間ループは 63 回発火して 1 件も dispatch していない。人間は「たぶん回って +いるだろう」と思っていた。 + +さらに悪いことに、単一アカウント運用では GitHub がセルフレビューを拒むため、 +approve 相当も request-changes も**人間から見える結果は同じ `needs-human` +ラベル**になる。ラベルだけでは合否が区別できず、判定の中身は +`loops/runs/*-verifier-*.md` を開かないと分からない。実際この PR は欠陥のまま +merge された。 + +**押し出し(push)の経路が 1 本も無い**、というのがこのハーネスの穴だった。 + +## 決定 + +macOS の通知センターに、ループの節目を push する。opt-in。 + +## 決定 1: 信号源は loops/STATE.md の "## Budget" 節にする + +候補は 3 つあった。 + +| 候補 | 却下/採用の理由 | +|---|---| +| `loops/runs/*-verifier-pr-N.md` の出現 | **使えない。** `agent-run --log` は非追記の tee なので、このファイルは Verifier の**開始時**に作られて書き足されていく。「ファイルができた = 終わった」にならない | +| `loops/events.jsonl` | 機械可読で筋が良いが、**Verifier の完了を表す kind が無い**。tick 単位(`dispatch` / `finish`)しか記録していない | +| `loops/STATE.md` の `## Budget` 節 | **採用。** `record_state` が dispatch の最後にだけ追記する。maker / verifier / fixer / gate / 自動 merge の節目がすべてここを通るので、見張る場所が 1 つで足りる | + +追記専用の節なので「行数が増えたか」だけで新着を判定できる。位置は +`loops/.notify-seen` に行数で持つ(gitignore 済み)。 + +**将来の改善(未実装):** `events.mjs` の KINDS に `verify` を足し、 +`dispatch-verifier` が判定を構造化して記録すれば、消費側が散文を読まずに済む。 +判定の読み取りが 1 か所に集まる利点も大きい。今回は firing / dispatch-* に +一切手を入れずに済む形を優先した。 + +## 決定 2: ホスト側の LaunchAgent が実行する + +ループは Docker コンテナの中で動くが、**コンテナからは macOS の通知を出せない** +(`osascript` も `terminal-notifier` もホストにしかなく、通知センターはホストの +GUI セッションに属する)。リポジトリはバインドマウントされているので、 +`.loop/bin/loop-notify` 自身もコンテナが書いたログもホストから同じパスで見える。 + +`plist` は `WatchPaths`(STATE.md)と `StartInterval` の両方を持つ。 +**片方では足りない。** WatchPaths だけだと、コンテナ内の書き込みがホストの +FSEvents に必ず伝わる保証がない(Docker Desktop のファイル共有実装に依存する)。 +StartInterval だけだと最大で間隔ぶん遅れる。両方使って、速さと確実さを分ける。 + +## 決定 3: 有効/無効のスイッチを config に置かない + +`[notify] enabled = true` を作らない。有効化の操作は「LaunchAgent を登録する」 +そのもの(`--install`)で、状態は `--status` が答える。 + +スイッチを 2 つ持つと「`enabled = true` なのに通知が来ない(未登録)」という +食い違いが必ず起き、しかもそれは**静かに**起きる。このハーネスで最悪の +壊れ方は「設定は正しく見えるのに機能が黙って死んでいる」なので、真実を 1 つに +する(`gen-crontab` が壊れた設定値を既定に丸めて stderr に知らせるのと同じ方針)。 + +`loop-doctor` は登録状態を必ず 1 行出す。未登録は opt-in なので NG ではなく SKIP。 + +## 決定 4: 夜間は鳴らさず、持ち越す + +ループは夜中も発火する。既定で `06:00-23:30` の間だけ通知する。 + +**窓の外では通知位置(`.notify-seen`)を進めない。** ここが要点で、進めてしまうと +夜間ぶんが永久に失われる。次に窓へ入った最初の実行が持ち越しをまとめて出す。 +`active_from > active_until` と書けば日をまたぐ窓(22:00-02:00 など)になる。 + +持ち越しが `max_burst`(既定 5)を超えたら「N 件たまっています」の 1 通に +まとめる。朝いちで通知センターが埋まるのを防ぐため。 + +## 決定 5: 判定の読み取りはヒューリスティックだと認める + +Verifier の判定は自由文で、書式が揃っていない。実例: + +``` +PR #11: **判定: approve 相当** +PR #13: ## 検証結果: request-changes +``` + +先頭 12 行を優先して見て、決まらなければ全文を見る。`request-changes` を +`approve` より先に判定する(否定的な判定を見落とす方が高くつくため)。 + +**取り違え得ることを前提にする。** 通知本文には検証ログの 1 行目をそのまま +載せ、人間が見出しと本文の食い違いに気づけるようにする。クリックすれば +該当 PR が開く。 + +## テスト + +時刻(`LOOP_NOTIFY_NOW`)と通知先(`LOOP_NOTIFIER_BIN`)を環境変数で +差し替えられるようにした。実時刻に依存させると「23:35 に走らせたテストだけ +落ちる」が起きる。`.loop/tests/loop-notify.bats` に 13 件。 + +## 未解決 + +- **macOS 専用。** Linux ホストでは `--status` が SKIP を返して何もしない。 + `notify-send` への対応は要望が出てから +- 通知は「起きたこと」しか出さない。「10 日間 dispatch が 0 件」のような + **起きていないこと**は検知できない。これは別の仕組み(sentinel)が要る