Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions .loop/bin/loop-doctor
Original file line number Diff line number Diff line change
Expand Up @@ -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
353 changes: 353 additions & 0 deletions .loop/bin/loop-notify
Original file line number Diff line number Diff line change
@@ -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" <<PLISTEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-lc</string>
<string>exec $BIN_DIR/loop-notify</string>
</array>
<!-- STATE.md の変更を即座に拾う。ただしコンテナ内の書き込みが必ず
ホストの FSEvents に伝わる保証はないので、StartInterval を保険に併用する -->
<key>WatchPaths</key>
<array>
<string>$STATE_MD</string>
</array>
<key>StartInterval</key><integer>$interval</integer>
<key>StandardOutPath</key><string>$HOME/Library/Logs/$LABEL.log</string>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/$LABEL.log</string>
</dict>
</plist>
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"
Loading