Skip to content

v0.23.0

Choose a tag to compare

@Kewton Kewton released this 14 Aug 06:17
· 48 commits to main since this release
9650bdd

[0.23.0] - 2026-08-14

Highlight: Epic #1720 Phase 4 を完了し、機械判断の一次ソースを TUI スクレイピングから CLI 自身が申告する構造化イベントへ移す作業を全 6 ツール(claude / codex / copilot / gemini / antigravity / opencode)に広げた。#1759 で AgentEventSource 抽象を切り出して「1 ファイル + レジストリ 1 行でツールが 1 つ増える」形にし、#1760〜#1763 / #1779 で 5 ツール分を実装している。**スクレイパは 2 層目のフォールバックとして残るため、構造化イベントが無い環境の挙動は変わらない。**あわせて、この作業中に CI を 5 時間 31 分 57 秒沈黙させた /proc への recursive mkdir 無限ループ(Linux 限定・同期スピンのため try/catch にも testTimeout にも到達せず OOM も残らない)を、製品コード側の 5 経路でも塞いだ(#1774)。

Added

  • feat(hooks): antigravity に承認裁定経路を張り、受け口の fail-closed な早期 return を塞いだ(Phase 4-4 残作業) (#1779)

    • agy の PreToolUse を登録し、専用の裁定コマンド(stdout が裁定)を /api/hooks/permission-request に向けた。codex の PermissionRequest / copilot の PreToolUse と同じ形で、中継スクリプト scripts/hooks/cmate-agent-event.sh は 1 バイトも変更していない(curl … >/dev/null で応答ボディを捨てるため裁定に使えない)。他 5 ツールの配送挙動は無変更
    • 受け口の潜在欠陥を塞いだ。 permission-request/route.ts は getAgentEventSource() を呼ぶ前にハードコードした {} を返す経路を 2 つ持っていた(worktree 未解決 / catch-all)。他ツールでは {}=「意見なし」だが agy では {}=拒否なので、PreToolUse を張った瞬間に「worktree が消えた」「想定外の例外」で agy のツール呼び出しが黙って拒否される。両経路を source.encodeVerdict({kind:'abstain'}) 経由にし、他ツールの出力が {} のままであることをテストで固定した
    • badRequest() の 4xx は 4xx のままにした(実測に基づく判断)。 裁定コマンドは curl -f を使うので 4xx はボディごと捨てられ、コマンド自身の fallback に落ちる。400 が裁定として agy に届くことはないので、検証エラーを正直に返す現状のままでよい
    • 失敗経路の既定出力は {"decision":"ask"} にした。Issue 本文の指定({"decision":"allow"})から意図的に変えている。 agy の decision は allow/deny/ask/force_ask の 4 値で、ask が「通常の承認フローへ」=abstain そのもの。設定ファイルは ~/.gemini/config/hooks.json のマシン 1 本なので、allow フォールバックは「CommandMate を止めるとマシン上の全 agy セッションが全ツール呼び出しを無承認で実行する」を意味する(CommandMate が起動していないセッションを含む=失敗経路 5 がまさにそれ)。ask なら「CommandMate が入っていない機械と同じ挙動」になり、Issue の狙い(agy を壊さない)を穴を開けずに満たす。Issue 本文の手動受入条件「Auto-Yes 無効時は従来どおりダイアログが出ること」も allow では満たせない
    • サーバ不達 / タイムアウト / 非 2xx / JSON でない or decision の無いボディ / CM_PERMISSION_HOOK_URL 未設定 の 5 経路すべてで {"decision":"ask"} を stdout に出す。curl -m 4 を handler の timeout: 5 の内側に置き、agy に殺されて部分出力になる経路自体を作らない
    • parsePermissionRequest を実装した(toolCall.name / toolCall.args)。()=>null のままだと unknown-payload で常に abstain=**「動いている Auto-Yes に承認するものが無い」と見分けがつかない**
    • noDecision を {kind:'blocks'} → {kind:'proceeds'} に変えた。encodeVerdict と対で読むこと — 安全なのは abstain を ask と綴っているからで、{} に戻すと宣言だけが嘘になる。blocks のままだと Auto-Yes 無効時の全ツール呼び出しで permission-request-abstain-blocks-agent が warn され、起きていないことを報告し続ける
    • capabilities.supportedEvents に pre_tool_use は足していない(Issue 本文の実装範囲 4 からの逸脱)。copilot と同じ理由 — 登録先は permission receiver なので pre_tool_use の NormalizedAgentEvent は 1 件も生成されず、載せると「待っても来ない語」を約束することになる(copilot/source.ts が同じ判断を明記している)
    • 実測で覆した Issue 本文 / #1762 の前提 2 点(いずれも agy 1.1.12・隔離 HOME・専用 tmux socket cmate-i1779・対話 TUI)
      • stdout が空(exit 0・無出力)は {} とは別物で fail-open。 通常の承認ダイアログが出る。「中継のまま PreToolUse を張るとマシン上の全ツール呼び出しが止まる」は成立しない(止まるのは {} を返したときで、{} は ⚠ Tool call denied by pre-tool hook: を出す=#1757 P10 を再現)。中継を使わない判断自体は変わらない(裁定を返せないため)
      • {"decision":"allow"} は対話 TUI の承認ダイアログを抑止しない。 permissionOverrides:["command(*)"] を添えても、リダイレクトの無い ls -a でも同じ。--print(headless)では効く(#1757 §5.4.4)。したがって対話セッションの Auto-Yes は従来どおり TUI 応答経路(#988)が担う — 本 Issue が追加したのは「abstain が安全になったこと」と「裁定を表現できるようになったこと」であって、ダイアログの消滅ではない。裁定は agy の文書どおりの綴りで正直に出している
    • 実機検証: 生成した hooks.json をそのまま隔離 HOME に置き、実 agy 1.1.12 で ①CommandMate 停止状態(ポート未 listen)→ 通常ダイアログ → 1. Yes でコマンドが実際に実行された(最重要条件)/②受け口スタブが {"decision":"ask"} → 通常ダイアログ、リクエストは ?tool=antigravity&worktreeId=…&instanceId=… 付きで到達し payload は toolCall.name/toolCall.args /③スタブが 500 → 通常ダイアログ。ユーザーの実 ~/.gemini/ は 3507 ファイルの sha256 マニフェストが前後一致(検証は複製 HOME で実施)。kill-server は使っていない
    • 空振り緑の反証: ①失敗経路の既定出力を「無出力」に変える ②受け口の早期 return を {} のハードコードに戻す ③encodeVerdict の abstain を {} に変える、の 3 変異を注入して赤になることを実測した(順に 9・2・11 テストが赤。すべて戻して 155/155 緑に復帰)
    • AgentEventSource I/F(types.ts)は 1 行も変更していない。 NoDecisionBehavior に「裁定しない=拒否される」を表す値が無い件は、本 Issue では表現する必要がなくなった(abstain を ask と綴るので proceeds が実態と一致する)。ただし表現できない状態が消えたわけではない — encodeVerdict が {} を返す実装に戻れば noDecision は再び嘘になり、型はそれを防げない。申し送りとして残す
    • 既存テストの期待値を変えた 4 箇所とその理由(いずれも本 Issue が挙動を変えた点そのもの): noDecision を blocks→proceeds / isAbstainSafe を false→true(gemini との対比を「逆」→「理由の違う一致」に書き換え) / abstain の encode を {decision:'allow'}→{decision:'ask'} / buildAntigravityHookConfig の PreToolUse を「未登録であること」→「中継宛でないこと」(主張の芯=中継は裁定を返せない、は不変)。cli-tools/antigravity.test.ts の起動コマンド正規表現は CM_PERMISSION_HOOK_URL を含むよう更新(expect 行ではない)
  • feat(hooks): copilot に構造化イベントと承認裁定を横展開した(Phase 4-3) (#1761)

    • src/lib/hooks/sources/copilot/ を追加し、registry.ts に 1 行登録した。copilot セッションの sessionStatus / wait の完了判定 / Auto-Yes が、TUI スクレイピングではなく copilot 自身の hooks から決まるようになる(スクレイパは #1723 の 2 層目として残る)
    • Issue 本文冒頭の「hooks が実在するか未確認。実在しなければ取り下げ」は事実と逆だった。 #1757 のスパイクが実在を確定させており、payload は 4 ツール中もっとも Claude Code に近い。取り下げていない
    • 設定の書き先は ~/.copilot/settings.json。 copilot help config は config.json に書けると言うが、copilot は起動時に hooks を settings.json へ移送し config.json を機械管理形式で書き戻すため、config.json に書くと次回起動で消える(#1757 P2)
    • ユーザー設定は置換せずマージする。 マーカー cmate-copilot-agent-hooks を含む自分のエントリだけを差し替え、他のキーと他のハンドラ(grouped 形 / flat 形の両方)はそのまま保持する。パースできない設定ファイルには触らず、素の gh copilot で起動する(イベントを失うのは回復できるが、手編集された設定の上書きは回復できない)
    • 相関キーは起動コマンドの環境変数に載せた。 copilot に --settings 相当は無く設定はマシン全体で 1 本なので、Claude のように URL へ焼き込むと 2 番目のインスタンスが 1 番目の名前でイベントを送ることになる。CM_AGENT_WORKTREE_ID / CM_AGENT_INSTANCE_ID を起動コマンドのプレフィックスとして与え、hook が発火時に読む
    • グローバル設定なので、CommandMate が起動していない copilot では無効になるようにした。 全コマンドが CM_AGENT_WORKTREE_ID 未設定で無音終了する。これが無いとオペレータが自分の端末で起動した copilot の Stop が cwd 解決で当たり、誰のエージェントも終わっていないのに commandmate wait が返る
    • 裁定は hookSpecificOutput.permissionDecision(Claude の decision.behavior ではない)。copilot は deny +理由文字列も解釈するため Claude 実装より広いが、permission-decision-service は deny を出さないので能力の記述にとどまる。{} は fail-safe(通常の承認フローに落ちる)
    • decisionTimeoutSeconds は 10(Claude は 600)。Claude の感覚で組むと裁定が届かない。コマンド側は curl -m 4 で 10 秒の内側から打ち切る(エージェントの timeout に判定を任せると「遅れて届いた裁定が黙って捨てられる」ケースと区別できない)
    • supportedEvents は 5 語。notification は #1757 で発火実績ゼロ、pre_tool_use は承認ゲートそのもので permission receiver 宛=イベントストアに入らないため、どちらも約束しない(mapper は綴りを知っているので手設定の hook からは読める)
    • 本 Issue で新たに実測した 3 点(docs/design/copilot-agent-hooks-injection.md): hook コマンドはシェル経由で実行される/copilot プロセスの環境変数が hook に継承される/PreToolUse の stdout が裁定として解釈され、permissionDecision:"allow" は --allow-all-tools 無しでもプロンプトを出さずに実行する(#1757 は deny と {} のみ実測で allow は未計測だった)
    • 実機検証(copilot 1.0.79、受け口はスタブ。本番 port 3000 には送っていない): 相関 env ありで 6 イベント全件が worktreeId / instanceId 正で到達し裁定 allow が効いた/相関 env なしで受信 0 件/CM_AGENT_INSTANCE_ID 未設定は primary に落ちる/受け口停止でもセッションは正常完了(fail-open)/~/.copilot/ は前後で sha256 一致。裁定の往復は ≈110ms(予算 ≈10s に対して約 90 倍の余裕)、受け口停止時は 133ms で {}
    • 空振り緑の反証: ①レジストリから copilot を外す ②イベント名の写像を 1 つ壊す(Stop→session_start)③設定の書き先を config.json に変える ④beginAgentSession() の呼び出しを消す、の 4 変異を注入して赤になることを実測した(順に 8・4・4・4 テストが赤。すべて戻して 82/82 緑に復帰)
    • AgentEventSource I/F は 1 行も変更していない。if (tool === 'copilot') 型の抜け道も追加していない
  • feat(hooks): opencode に server API / SSE 経由の構造化イベントと承認裁定を横展開した(Phase 4-5) (#1763)

    • opencode セッションの機械判断(sessionStatus / wait の完了判定 / プロンプト待ち / Auto-Yes)を、TUI スクレイピングから opencode server の構造化イベントに移した。scraper は 2 層目のフォールバックとして残り、構造化イベントが無い環境の挙動は 1 バイトも変わらない
    • Issue 本文の実装範囲 1・2 は実測で覆っている。 本文は「opencode serve を別プロセスで立て tmux 内で opencode attach <url> する」「serve プロセスのライフサイクル管理(起動・ポート割当・停止・孤児回収)」を求めていたが、#1758 §5.1.2 が 素の opencode TUI 自身が同じ HTTP サーバを内蔵していることを実測している(opencode --port <N> で起動した TUI に対し /global/health / /event / GET /permission / POST /session/:id/permissions/:permissionID がすべて serve と同一に応答)。したがって起動経路の変更は 'opencode' → 'opencode --port <N> --hostname 127.0.0.1' だけであり、killSession(/exit 送出)・初期化待ちループ・reconcileExistingSession は無変更、serve プロセスのライフサイクル管理と孤児回収は実装していない(サーバの寿命 = TUI プロセスの寿命なので、そもそも孤児になりえない)。Issue の「既知の罠」1 番目(/exit が TUI だけ閉じて serve が残る)も起きない
    • 新設 src/lib/hooks/sources/opencode/: 述語つき写像(mappers.ts)/完了の冪等化(turn-gate.ts)/SSE・REST クライアント(client.ts)/ポート割当と永続化(ports.ts)/globalThis 経由の購読レジストリ(subscription.ts)/payload パーサ(payloads.ts)/状態反映(ingest.ts)/起動側の 3 点接続(runtime.ts)。AgentEventSource I/F は 1 行も変えていない — #1759 の definePullEventSource がそのまま使え、if (tool === 'opencode') の類は 1 箇所も足していない
    • noDecision: { kind: 'blocks' }(実測)。 #1758 §5.5.3 は承認要求を 10 分 19 秒放置しても pending のままであることを計測しており、タイムアウトは存在しない。「応答しなければ TUI 承認に落ちる」というフォールバックは無く、TUI ダイアログと REST は先に答えた方が勝つレースである。したがって「判断できないときは黙る」は opencode でだけ安全側に倒れない ので、裁定を見送ったときは permission-request-abstain-blocks-agent を必ず warn する(黙って落ちるとセッションが静かに止まり、考え込んでいる agent と区別がつかない)
    • session.idle を wait の完了判定に使えるようにしたが、数えてはいない。 承認待ちの 10 分間に当該セッションの idle は 1 件も出ない(§5.3.1)ので Stop と同義だが、error / abort 経路では 1 ターンで 2 回発火し(19ms 差)、payload は sessionID のみでターン識別子が無い(§5.3.2 / §5.3.3)。session.status(busy) を観測してから武装 → 最初の session.idle だけ完了 → 以降は破棄する状態機械を置いた。session.status(idle) は session.idle と同一ミリ秒の同じ signal なので写像しない
    • 実機検証で新たに判明: message.updated(role: user)も 1 ターンに複数回、しかも session.idle の後に再送される(1.18.3 実測、2026-08-13)。2 通は byte 単位で同一(time は created のみ)で payload では区別できない。user_prompt_submit には専用イベントが無くこのフレームが唯一の供給源なので、素直に 1:1 で写すと完了したターンの最新イベントが user_prompt_submit になり status-mapping が running と読む → commandmate wait が 30 分の staleness bound まで返らない。同じ状態機械で message id 単位に初回だけ通すようにした(再接続では武装状態は捨てるがannounce 済み id は保持する — 新しいプロンプトは必ず新しい id なので保持しても取りこぼさず、再接続がターン終端と末尾再送のあいだに入る窓を塞げる)。#1758 のスパイクでも Issue 本文でも指摘されていなかった欠陥で、実機ドライブでのみ発見した
    • ポートは CommandMate 側で明示割当する(4200-4299)。 --port 0 は「OS に空きを訊く」ではなく「まず 4096、埋まっていたら ephemeral」であり(§5.9.1)、実ポートを知る手段は stdout 1 行か lsof しかない(ポートファイルは書かれない、§5.9.2)。割当は ~/.commandmate/opencode-ports.json(CM_OPENCODE_PORT_FILE で差替可)に記録し、CommandMate 再起動後の購読復帰は推測ではなく記録 + /global/health + worktree パス照合で行う(ハッシュ再導出だけだと、衝突した 2 インスタンスが互いのサーバに繋がって別 worktree のイベントを取り違える)
    • 購読状態はすべて globalThis 経由(#1736 の前例: dev モードでバンドルが分かれ、書いた側と読む側が別マップになって無言で壊れた)。未知の type では throw せず数えるだけにした — server.heartbeat は 10 秒ごとに届くのにサーバ自身の OpenAPI Event union に載っていない(§5.2.1 / D5)
    • 縮退は全経路 fail-open。 ポート枯渇・サーバ不達(--port を知らない旧版 opencode)・SSE 断・CommandMate 再起動のいずれでも scraper に落ちて動き続ける。CM_AGENT_HOOKS_INJECT=0 で起動が #1763 前の素の opencode に戻る
    • 承認応答は POST /permission/:requestID/reply({reply, message?})に固定した。per-session 版の POST /session/:id/permissions/:permissionID はボディのキーが response で拒否理由を渡せないが、こちらは渡した文字列がそのまま tool part の state.error に出る(§5.5.2)。question.asked は選択肢が構造化されて届くので recordAskUserQuestion() に流している(Claude では scraper に残さざるを得なかった AskUserQuestion 相当)
    • 実機検証(opencode 1.18.3 / 隔離 HOME / 専用 tmux socket cmate-p45-oc): 素の TUI に --port を付けて起動 → /global/health 応答・lsof で TUI プロセス自身が listen することを確認/bash の外部ディレクトリ書き込みで permission.asked → listPending が callID 相関でツール名 bash を解決 → POST /permission/:id/reply {"reply":"once"} → ダイアログが消えてコマンドが実際に実行された(marker ファイルが生成された)/1 ターンにつき stop はちょうど 1 件、かつ最後のイベントが stop(上記の末尾再送修正後)/CommandMate 再起動相当(in-memory 状態ゼロの別プロセス)から opencode-port-recovered で購読が復帰/TUI を kill すると liveness が lost(fetch failed)・probeActivity が null になり scraper 判定に落ちる/2 インスタンス(ポート 4296 / 4295)でイベントの取り違えゼロ(別 sessionID・各 1 ターン)/--port 無しで起動した TUI は listener を持たないため health check で弾かれ従来どおり scraper のみで動く。ユーザーの ~/.config/opencode/opencode.jsonc と ~/.local/share/opencode/ は sha256 一致・ディレクトリ構成 diff 空・mtime も検証開始より前のまま(auth.json は mode 600 で複製したのみ)。ユーザーの mcbd-* tmux セッションは全て健在(kill-server 不使用)
    • 空振り緑の反証: ①レジストリから opencode 登録を外す ②session.idle → stop の写像を壊す ③**noDecision を proceeds に変える** ④idle の冪等化を外す ⑤beginAgentSession() の呼び出しを消す ⑥user_prompt_submit の再送抑止を外す、の 6 変異を注入して赤になることを実測した(順に 7・9・2・2・1・1 テストが赤。全変異を戻して 202 テスト全緑に復帰することも確認)
    • fix(test): develop 時点のテスト 2 箇所が opencode を「未登録ツールの例」に使っていたので直した。 pull-source-contract.test.ts の finally と hooks-permission-request-source-1759.test.ts の afterEach が unregisterAgentEventSource('opencode') を呼んでおり、本 Issue が登録した本物のソースを削除するのに assert は通る(=緑のままレジストリが汚れる)。CI は fileParallelism: false で全ファイルが 1 プロセスを共有するため、削除は以降の全ファイルに波及する(ローカルの並列実行では不可視)。前者は unregister ではなく元のソースを復元するようにし、後者の stub は恒久的にスコープ外の vibe-local へ移した。期待値(expect 行)の意味は変えていない
  • feat(hooks): codex に構造化イベントと承認裁定を横展開した(Phase 4-2) (#1760)

    • #1759 の AgentEventSource に codex 実装(src/lib/hooks/sources/codex/)を足し、レジストリに 1 行登録した。I/F の変更も codex 固有の抜け道(if (tool === 'codex') 等)も無い。 wait / capture --prompts / Auto-Yes への反映は消費層がツール非依存なので自動的に付いてくる
    • 設定は $CODEX_HOME/hooks.json 1 本、内容は静的。相関キーは起動コマンド行の環境変数で渡す。 codex には --settings 相当が無く 1 ファイルが全セッション共有になるため、worktreeId / instanceId をファイルに焼き込めない。本 Issue で hook の command が POSIX シェルで実行され、hook プロセスが codex を起動したシェルの環境を継承することを実測したので、CM_AGENT_WORKTREE_ID / CM_AGENT_INSTANCE_ID / 受け口 URL / CODEX_HOME を起動行に置く方式にした。同一 worktree の codex と codex-2 が実機で正しく振り分けられることを確認済み(cwd は両者同一なので他に手段が無い)
    • CODEX_HOME を起動行で pin する。 tmux セッションは tmux サーバの環境を継ぐため、CODEX_HOME を設定して起動したサーバが書いた hooks.json を、起動された codex が読まない(~/.codex を見る)という無言の食い違いが起きる。実測して修正した(症状はエラー無しの「イベント 0 件」)
    • 登録は 5 イベント。 SessionStart / UserPromptSubmit / Stop / SessionEnd + PermissionRequest。Notification は codex に存在しない(レビュー画面が列挙する 11 イベントに無い)。Pre/PostToolUse は消費側が無く 1 ツール呼び出しにつき 2 POST になるだけなので登録せず、capabilities.supportedEvents からも外した(待つ側が永久に待たないように)。SessionEnd の timeout は 3 秒(codex がクランプし TUI に警告を常時表示するため)
    • PermissionRequest だけインライン curl。 応答ボディがそのまま裁定になるため、ボディを捨てる中継スクリプトは使えない。{} → 通常の承認ダイアログ(fail-safe)、decision.behavior=allow → ダイアログ無しで実行、受け口停止中も fail-open(セッションは止まらない)を実機で確認した
    • trust は CommandMate が与えない。 codex の hooks は人間が trust するまで完全に無言で skipされ、trust はユーザーの ~/.codex/config.toml に書かれる。既定で --dangerously-bypass-hook-trust を使わないのは、このフラグが作業対象リポジトリ内の .codex/hooks.json まで無審査で実行させるため(悪意あるリポジトリを開いた瞬間に任意コマンドが走る)。既定は「設定は書く・trust は人間が codex 自身のレビュー画面で 1 回与える」。自動化向けに CM_CODEX_HOOK_TRUST=bypass を opt-in で用意した。作業前後で ~/.codex/config.toml は sha256 一致(notify を含め非汚染)
    • 起動時の「Hooks need review」ダイアログを cli-tools/codex.ts が処理する(3=trust せず継続)。 getCodexActiveDialog はこの画面を null に分類し isCodexPromptReady も false を返すため、放置すると 30 回ポーリングし切ってダイアログのまま sendMessage に渡る(実測)。hooks.json を置くだけで codex の起動が壊れるということなので、注入とセットで入れた
    • ユーザーの既存 hooks 設定は置換せずマージする。 $CODEX_HOME/hooks.json はユーザー自身の codex hooks が置ける唯一の場所でもある。# commandmate:agent-hooks マーカーで自分のハンドラだけを置換し、内容が一致するときはファイルを開かず(trust を無駄にしない)、JSON として読めないファイルは上書きせず注入を諦める
    • 世代フェンス(#1723 / S8)を CodexTool.startSession の生成パスに追加した。無いと前プロセスの user_prompt_submit を新セッションのものと読んで起動直後に running を publish する(単体テストは緑のまま壊れる型)
    • 空振り緑の反証: ①レジストリから codex 登録を外す ②Stop の写像を壊す ③noDecision を実測値と違う値にする ④beginAgentSession() を消す、の 4 変異を注入して赤になることを実測し、戻して緑に復帰することも確認した
    • CM_AGENT_HOOKS_INJECT=0 で注入をスキップし、起動コマンドが #1760 以前と 1 バイト同一になることをテストで固定した
    • test: tests/setup.ts に CODEX_HOME の既定を temp dir に pin した。pin が無いと codex セッションを起動するテストが開発者の実 ~/.codex/hooks.json を書き換える(実際に起きたので追加した)
    • test: #1759 が「未登録ツールの例」として使っていた 'codex' を 'vibe-local' に差し替えた(agent-event-source.test.ts / agent-session-lifecycle-1759.test.ts)。特に register→finally unregister するケースは、本物の codex source を消しながら assert は通る=緑のまま registry が壊れる(CI は fileParallelism: false で全ファイルが 1 プロセスを共有するため以降の全ファイルに波及する)
    • 設計と実測は docs/design/codex-agent-event-source.md
  • docs/test: codex / copilot / gemini / antigravity の hooks 実機挙動を検証し、実 payload を fixture として収集 (#1757)

    • Epic #1720 Phase 4 の全下流 Issue(#1759 / #1760 / #1761 / #1762)が前提にする外部 CLI 4 種の hooks 挙動を実機で確定した。src/ の変更はゼロ(スパイク)。成果物は docs/design/agent-hooks-phase4-live-verification.md と tests/fixtures/hooks/{codex,copilot,gemini,antigravity}/*.json(24 payload)
    • Issue 本文の「copilot に hooks は無いかもしれない(無ければ #1761 は取り下げ)」という前提は誤りだった。 copilot --help に hook の語が無いのは事実だが、copilot help config が hooks / disableAllHooks を、copilot plugin --help が「Plugins extend Copilot CLI with additional skills, agents, hooks, …」を明記しており、実際に 6 イベントが発火した。payload は 4 ツール中もっとも Claude Code に近い。4 ツールすべてに hooks が実在したため、横展開 Issue の取り下げ提案は 1 件も無い
    • 最重要の安全性所見: antigravity (agy) だけ no-decision が fail-CLOSED。 Claude / codex / copilot は hook が空応答を返すと通常の承認フローに戻る(fail-safe)が、agy は decision を欠いた {} を返すと run_command / list_dir / search_web すべてを拒否する(hooks を外した対照実験で裏取り済み)。一方 timeout(ハンドラ無応答)は fail-open なので、「無応答」と「空応答」で挙動が真逆になる。Auto-Yes v2 が「判断できないときは黙る」実装だと agy ではエージェントが全停止する
    • type:"http" は 4 ツールすべてで使えない(Claude だけの機能)。codex は hooks.json に http を 1 つ書いただけで unknown variant 'http', expected one of 'command', 'prompt', 'agent' となり hooks.json 全体が捨てられ全イベントが死ぬ。警告は stderr 1 行のみで TUI には出ない
    • codex の未 trust hooks は codex exec で完全に無言で skip される(stderr / stdout / ログいずれにも出力なし)。trust はユーザーの ~/.codex/config.toml に [hooks.state."<path>:<event>:0:0"] trusted_hash として書き込まれるため、ユーザー設定を汚さずに hooks を有効化する経路は --dangerously-bypass-hook-trust か CODEX_HOME 差し替えしかない
    • ライフサイクルの意味論が Claude と揃っていない: codex の SessionStart は最初のターン送信時に出る(TUI 起動 08:05:15 → SessionStart 08:06:23)。copilot は UserPromptSubmit が SessionStart より先。codex は強制終了時に SessionEnd を出さない。session_start を起動完了 signal にしてはいけない
    • timeout はいずれも fail-open だが既定値が 2 桁違う: codex 600s / copilot ≈10s(実測)/ agy 30s。裁定を返す受け口は copilot に合わせて 10 秒以内に応答する必要がある
    • AGENT_EVENT_TYPES の 7 語が 4 ツールすべてで揃うわけではない。共通なのは session_start / pre_tool_use / post_tool_use / stop の 4 語だけ。notification は codex / agy に存在せず、session_end / user_prompt_submit は agy に存在しない。gemini は語彙自体が別(BeforeTool / BeforeAgent / PreCompress ほか)でツール名もリマップされる(Bash → run_shell_command)
    • agy の payload にはイベント名も cwd も無い(hook_event_name 相当のフィールドが存在せず、workspacePaths は空配列)。イベント種別と worktree ID は中継コマンドの引数に焼き込む以外に手段が無い。キーも 1 ツールだけ camelCase(protojson)
    • scripts/hooks/cmate-agent-event.sh は 4 ツールのどれにも使えなかったためスパイクはダンプサーバ直で回避した。--event の allowlist が 5 語しかなく(pre_tool_use / post_tool_use で die)、map_event_name は PreToolUse / PostToolUse も gemini/agy の語も知らない。修正は #1759 の担当なので本 Issue では触っていない
    • ユーザーのグローバル CLI 設定は 1 バイトも変更していない: ~/.codex/config.toml(notify の Computer Use 行を含む)/ ~/.gemini/settings.json / ~/.copilot/{config,settings}.json / ~/.gemini/antigravity-cli/settings.json はいずれも検証前後で diff 空・sha256 一致。ツールごとに CODEX_HOME / COPILOT_HOME / GEMINI_CLI_HOME / HOME 差し替えで隔離し、tmux は専用 socket -L cmate-p4spike のみを使用(kill-server 不使用、ユーザーの mcbd-* セッション 11 本は全て健在)
    • 報告: 設定は無変更だが、3 ツールが検証中に自身の auto-update で版を上げた(gemini 0.42.0→0.55.1、copilot 1.0.77→1.0.79、agy 1.1.7→1.1.12。agy は AGY_CLI_DISABLE_AUTO_UPDATE=1 を付けていても更新された)。外部 CLI の版は実質固定できないため、下流はパーサを「未知フィールドは無視」で組むこと
    • 未計測(理由つき): gemini の項目 6(timeout)/ 7(承認裁定)と BeforeTool / AfterTool / AfterAgent / AfterModel / Notification の payload。この環境の Google アカウントが IneligibleTierError(This client is no longer supported for Gemini Code Assist for individuals)でモデル呼び出しに到達できず、ツール実行を伴うターンを 1 度も成立させられなかったため。「確認できなかった」であって「実在しない」ではない(hooks 機構自体は実在し SessionStart ほか 5 イベントを実採取済み)。#1762 が gemini 分を進めるには有効な GEMINI_API_KEY が要る
  • docs/test: opencode の server API / SSE による構造化イベントと承認裁定を実機検証した(Phase 4-0b スパイク、コード変更なし) (#1758)

    • docs/design/opencode-server-live-verification.md と tests/fixtures/hooks/opencode/*.json(19 件)を追加。opencode 1.18.3 を隔離 HOME・専用 tmux socket(cmate-p4spike-oc)で動かし、GET /event の SSE 471 フレームと GET /doc(OpenAPI 3.1.0 / Event union 89 種)を一次証拠として検証項目 1〜10 を実測した
    • 項目 1 は Go。ただし Issue が前提にしていた opencode serve + opencode attach 構成は採らない — 素の opencode TUI 自身が同じ HTTP サーバを内蔵していることを実測した。opencode --port 4791 で起動した TUI に対して /global/health / /event / GET /permission / POST /session/:id/permissions/:permissionID のすべてが serve と同一に応答し、REST 承認でダイアログが消えてコマンドが実行された。これにより #1763 の起動経路変更は src/lib/cli-tools/opencode.ts:136 の 'opencode' → 'opencode --port <N>' だけで足り、killSession(/exit 送出)・初期化待ちループ・reconcileExistingSession は変更不要。Issue の「既知の罠」1 番目は問題にならない。同時に「serve が居なければ scraper に落ちる」という縮退設計も意味が変わる(サーバは TUI と同一プロセスなので中間状態が存在しない。縮退が必要なのは --port 無しで起動された既存セッションに対してのみ)
    • 項目 3(session.idle の意味)は wait の完了判定に使ってよい。 承認待ち 10 分 19 秒・質問待ち 40 秒のあいだ、当該セッションに session.idle は 1 件も出ず session.status も busy のままだった(GET /session/status も busy)。つまり Claude の Stop と同義の「ターンが終わった」であり「セッションが暇」ではない。ただし error / abort 経路では 1 ターンで 2 回発火し(MessageAbortedError → idle → 19ms 後に idle を実測)、payload は sessionID のみでターン識別子が無いため、wait は「busy を観測してから武装 → 最初の idle で完了 → 以降を無視」+ session.error の併読が必須
    • 項目 5(no-decision)は hooks と挙動が逆。 「応答しなければ通常の TUI 承認へ落ちる」というフォールバック段階は存在しない — TUI ダイアログは permission.asked と同時に無条件で描かれ、REST と TUI は最初から並行しており先に答えた方が勝つレースである。そしてタイムアウトが無い(10 分 19 秒放置して自動裁定・タイムアウトイベントとも 0 件)。したがって Auto-Yes は fail-open しない: Claude は「黙る=安全(fail-open)」だったが opencode は「黙る=エージェントが無限停止」で、逆に誤って allow を返すと人間が読む前にダイアログが消える。#1763 は裁定を見送ったことを利用者に見せる必要がある
    • 項目 6 / 9: 1 サーバに複数 session を載せられる(2 session 同時 busy を実測)が、GET /session は「このサーバの session」ではなく同一 HOME / project の全 session を返す(別ポートの serve が作った session まで見えた= opencode.db 共有)ため instance 一覧として使ってはいけない。推奨は 1 インスタンス = 1 TUI プロセス = 1 ポート(当該ポートの session.created が一意に自分のものになる)。POST /session → -s <sessionID> による事前バインドも実測で成立。--port 0 は「OS に空きを訊く」ではなく「まず 4096、埋まっていたら ephemeral」(1 本目 4096 / 2 本目 58153 を実測)で、実ポートを知る手段は stdout 1 行か lsof だけ(ポートファイルは書かれない)→ CommandMate 側で明示割当するのが唯一の安全策
    • 項目 10: plugin は採用しない。 ローカル JS plugin は動作し init / event / tool.execute.before / tool.execute.after が発火したが、event の語彙は SSE と完全に同一で情報が増えず、承認を裁定する plugin フックが存在しない("permission.ask" という文字列自体が 1.18.3 のバイナリに無く、hook 面は event / tool.execute.* / command.execute.before のみ)。Phase 4-5 の中核が plugin では実装できない
    • Issue 本文・--help と実測の食い違いを 6 件記録(うち D1「serve/attach 不要」と D2「no-decision フォールバック不在」は設計の形を変える)。加えて server.heartbeat(10 秒周期・死活監視の signal)がサーバ自身の OpenAPI Event union に載っていない、/api/event は 1 ターンの先頭 3 件で無言に沈黙する(独立に 2 回再現)、GET /api/session/:id/event?after=<seq> は 1 バイトも返さない(durable replay による再接続は使えない)ため、購読は legacy /event 一本に決めた
    • question.asked が質問文・ヘッダ・選択肢を構造化して配り、POST /question/:id/reply で答えられることを実測した。Claude では scraper に残さざるを得なかった AskUserQuestion(#1708 / #1726)が opencode では完全に構造化イベントで扱える
    • 最重要成果物として §9 に Phase 4-1(#1759)への要求事項を、実測から導いた 8 つの制約(到着方向・裁定経路・fail 方向・7 語への非 1:1 写像・相関キー・接続健全性・再同期・未知 type)と具体的な型シグネチャ案(AgentEventTransport / NoDecisionBehavior / Verdict / decide() / listPending() / liveness())として記載した。AgentEventSource が push 型(hooks)だけでなく pull 型(SSE 購読 + REST 応答)も表現できる形でなければならないという制約の根拠がここにある
    • 非汚染の証拠: ~/.config/opencode/opencode.jsonc の before/after diff は空(sha256 4e901f9e… 不変)、~/.local/share/opencode/ の構成 diff も空、opencode.db(58MB)と auth.json の mtime は検証開始前のまま。tmux -L cmate-p4spike-oc 専用 socket のみ使用し kill-server は未使用(ユーザーの mcbd-* 10 本は健在)、本番サーバ(port 3000)へは 1 リクエストも送っていない、立てた serve は PID 指定で個別 kill(広域 pkill 未使用)。src/ の変更は 0 件
  • slash-commands: カタログを claude docs へリコンサイルし、未反映の 3 コマンドを追加 (#1767)

    • /agents /import /list-agents(いずれも claude のみ)。code.claude.com/docs/en/commands.md の実行(2026-08-13)で 3 件とも実在する行であることを確認済み。カタログ総数 159 → 162、claude 可視数 97 → 100(codex 53 / opencode 10 / antigravity 13 は変化なし)
    • /agents と /import は claude 専用の説明キーを持つ(Issue #1704 の tool-scoped key)。/agents は opencode の「利用可能なエージェントを一覧・管理」、/import は codex の「Claude Code から設定…を取り込み」という別の意味の説明を共有していたため、そのままでは claude のパレットに他ツール向けの誤った説明が出る。slashCommands.descriptions.{agents,import} を tool 別オブジェクトへ分割し、既存ツールの文面は 1 文字も変えずにそれぞれのキーへ移した
    • /schedule(#1488 のキュレーション判断)・/ultraplan(#1503 の幻コマンド)・/pr-comments /vim(上流で削除済み)・/cost /stats(/usage のエイリアス)は従来どおり追加していない

Changed

  • feat(hooks): gemini / antigravity に構造化イベントを横展開した(Phase 4-4) (#1762)

    • docs/design/agent-event-source-interface.md §4 の手順 1〜8 を 2 ツール分実行した。新設 src/lib/hooks/sources/gemini/(tool-id / event-vocabulary / settings-generator / shared-config-tree / source)と src/lib/hooks/sources/antigravity/(tool-id / hooks-config / source)、registry.ts に 2 行、index.ts に re-export、cli-tools/{gemini,antigravity}.ts の startSession に世代フェンスと注入。AgentEventSource I/F は変更していない/if (tool === '…') 型の抜け道も入れていない/hook-event-vocabulary.ts の共有表に gemini・agy の綴りを足していない(判断根拠は docs/design/agent-hooks-gemini-antigravity-integration.md)
    • 実機検証で Issue 本文・#1757 の記載と食い違った点(実測を正とした)
      • gemini の hook timeout はミリ秒。 #1757 §8.2 R13 は「単位はすべて秒」としていたが誤り。他ツールと同じ timeout: 5 を書いた実 v0.55.1 セッションで Hook execution error: Hook timed out after 5ms となり、hook は登録され開示バナーにも出て起動もしているのに curl が socket を開く前に殺され、全イベントが無言で失われた。バンドルの DEFAULT_HOOK_TIMEOUT = 6e4(=60 秒)とメッセージ Hook timed out after ${timeout}ms で裏取り。GEMINI_HOOK_TIMEOUT_MS = HOOK_TIMEOUT_SECONDS * 1000 に修正し、ms/秒それぞれをテストで固定した(agy は doc どおり秒。実測でも timeout: 5 で 3 イベント配送成功)
      • gemini の項目 6/7(#1757 §5.3.6 で未計測)を出荷バイナリの実装読みで確定した。 DefaultHookOutput.isBlockingDecision() は decision === 'block'|'deny'、isAskDecision() は === 'ask'、shouldStopExecution() は continue === false =空応答にはどのフィールドも無いので no-decision は fail-OPEN。計測(「そのとき止まらなかった」)より強い根拠(「止められる分岐が無い」)
      • gemini の Notification の subtype は ToolPermission の 1 種のみ(NotificationType の唯一のメンバ)。意味は Claude の permission_prompt と同一
      • agy の Stop は --print(headless)モードでは発火しないケースを確認した(ツール承認が auto-deny で終わった実行では CommandMate の hook もユーザー自身の hook も動かなかった)。ツールを使わない普通のターンでは発火する
    • gemini の Policy Engine と hooks の優先順位(Issue の必須確認事項)— Auto-Yes の意味は一意である。 scheduler の実装は evaluateBeforeToolHook() → checkPolicy() → if (hookDecision === 'ask') decision = ASK_USER の順。hook にできるのは (a) deny/block で即座にツールを落とす (b) continue:false で停止させる (c) ask でダイアログへ引き上げる の 3 つだけで、checkPolicy の裁定を「承認」に変える分岐は存在しない('allow' を hook 出力から読む箇所も無い)。したがって gemini の承認は Policy Engine(--approval-mode / --policy / -y)専任であり、CommandMate は encodeVerdict() で常に {} を返し、Policy Engine のフラグも一切渡さない(hooks を注入しても gemini セッションの権限は広がらない)。gemini の Auto-Yes は従来どおり TUI 経路のみで、hooks 側に第 2 の承認経路が生まれないため二重定義にならない
    • agy のグローバル設定 1 本を複数 worktree でどう扱うか(Issue の必須確認事項)— 相関を設定ファイルから追い出した。 agy が読むのは ~/.gemini/config/hooks.json ただ 1 本(doc の <workspace>/.agents/hooks.json は読まれない)、payload に cwd も workspacePaths も無く、hook の cwd は ~/.gemini/config。よって設定ファイルには worktree も instance も書かず、起動コマンドの CM_HOOK_URL='…?tool=antigravity&worktreeId=…&instanceId=…' に相関を載せた(中継スクリプトは CM_HOOK_URL を自分で読むので hook コマンド内のシェル展開に依存しない)。1 本の設定で N worktree × M instance が同時に成立し、CommandMate 外で起動された agy は cwd が worktree に解決できず 202 で捨てられる(誤紐付けは起きない)。HOME 差し替えは gemini の OAuth 資格情報ごと巻き添えにするため採らなかった
    • agy の PreToolUse は張っていない。 decision 必須の fail-CLOSED({} で全ツール拒否=#1757 P10)で、中継スクリプトは stdout に何も書かないため、張った瞬間にマシン全体のツール呼び出しが止まる。中継に裁定を返す機能を足すのは scripts/hooks/** の変更=本 Issue のスコープ外なので、agy の承認裁定(Auto-Yes v2 経路)は本 Issue では実装していない(encodeVerdict は正しい wire 形を実装済みで、中継が対応した時点で PreToolUse を登録すれば有効になる)。PostToolUse / Stop は doc と実測の両方で空応答が安全
    • 登録イベント: gemini = SessionStart / BeforeAgent / AfterAgent / Notification / SessionEnd(BeforeTool/AfterTool は写像だけ持ち登録しない — 同期実行でツール呼び出しごとに 2 往復ブロックする一方、running は BeforeAgent が既に立てている)。agy = SessionStart / PostToolUse(matcher *) / Stop(agy には user_prompt_submit が無いため PostToolUse が唯一の「実行中」signal)
    • 共有 config ツリーの非破壊: ~/.gemini/ には gemini の settings.json と OAuth 資格情報、agy の config/hooks.json と antigravity* が同居する。書き込みは常にマージ(gemini は hooks 内の自分のエントリだけ差し替え、agy は名前つき hook commandmate の 1 キーだけを占有)。新規作成時のみ file 0600 / dir 0700 で、既存ファイルの permission は変えない。両方向の非破壊を sha256 でテスト固定した(最悪ケースとして「worktree の .gemini が共有ツリーそのもの」でも検証)
    • 消費層の subtype 語彙が Claude のままだった点への対処: status-mapping.ts / agent-event-state.ts は detail === 'permission_prompt' というリテラルを直接比較する。#1759 が抽象化したのはイベント名だけなので、gemini の ToolPermission をそのまま publish するとイベントは正しく届いて記録もされるのに永久に waiting にならない。ソース側(extractDetail)で翻訳した
    • AgentEventSource I/F で表現できなかったもの(#1759 への報告。抜け道は作っていない): (G1) AgentInstanceRef に worktree のパスが無く、configScope:'per-worktree' の gemini は prepareLaunch だけでは設定を書けない → injectGeminiHookSettings(worktreePath, target) を別 export し cli-tools/gemini.ts から呼ぶ(settingsPath は null)。(G2) NoDecisionBehavior に「裁定しない=拒否される」が無い(proceeds/blocks/blocksUntil の 3 値)→ agy は blocks を選択(describeAbstain().safe === false になる唯一の値で、拒否は待機より悪いので危険側に倒れる)。summary の文面は機構としては不正確。(G3) subtype 語彙が共通化されていない(上記)
    • 実機検証(本番サーバ port 3000 には 1 件も飛ばしていない。ローカルのダンプサーバ 3762 とツール専用 tmux socket cmate-p44-gem のみ使用)
      • gemini: session_start / user_prompt_submit / session_end の 3 種が実 v0.55.1 から受け口まで到達(クエリ tool=gemini&worktreeId=wt-live&instanceId=gemini-2、body に cwd / sessionId / detail)。AfterAgent と Notification はこのマシンのアカウントが IneligibleTierError(UNSUPPORTED_CLIENT)でモデル呼び出しに到達できず未計測(#1757 §5.3.6 と同じ制約。「確認できなかった」であって「実在しない」ではない — 綴りは CLI 自身の HookEventName enum と hooks migrate の変換表で確定済み)。ユーザーの security.auth キーを含む既存 settings.json がマージ後も残ることを実ファイルで確認
      • agy: session_start / post_tool_use(×6) / stop が実 v1.1.12 から到達(クエリ instanceId=antigravity-2、cwd は予告どおり ~/.gemini/config)。同じ hooks.json に置いたユーザー自身の名前つき hook (user-probe) も同時に発火=併存を実機で確認。Stop に空応答を返してもエージェントは正常に停止した(fail-safe を実測)
      • fail-open: 受け口を落とした状態で両ツールを実行し、gemini は hook 失敗ログ 0 件・agy は通常どおり応答(どちらも停止しない)
      • ユーザー設定の before/after: ~/.gemini/settings.json / ~/.gemini/config/*.json / ~/.gemini/antigravity-cli/settings.json の sha256 が全一致・diff 空。検証用に作成した ~/.gemini/config/hooks.json は検証前が「ファイル無し」だったので削除して原状復帰済み
    • 空振り緑の反証: 10 変異すべてが赤になることを実測し、戻して緑に復帰することを確認した(括弧内は赤になったテスト数)— registry から gemini を外す(7) / antigravity を外す(3) / gemini の AfterAgent→stop を壊す(4) / agy の設定に焼く --event stop を壊す(1) / agy の noDecision を proceeds にする(2) / gemini の設定書き込みをマージ→置換にする(5) / agy の同(2) / gemini の beginAgentSession を消す(4) / agy の同(2) / gemini の timeout を ms→秒に戻す(1)
    • 品質ゲート実測(cmd > log; echo $?): npm run lint 0 / npx tsc --noEmit 0 / CI=true npm run test:unit 0(827 files・15175 tests)/ CI=true npm run test:integration 0(80 files・1147 passed・2 skipped)。並列モードの npm run test:unit では実行ごとに違う無関係ファイルが 5s timeout で落ちた(1 回目 gate-runner-timestamps、2 回目 hooks-claude-done-delegation ほか 2 件。単独実行ではいずれも緑、CI モードでは全緑)=負荷起因で本変更とは無関係
    • 既存テストの期待値は削除していない。tests/unit/cli-tools/antigravity.test.ts のみ、startSession が実 ~/.gemini/config/hooks.json を書かないよう HOME を一時ディレクトリに退避し、sendKeys の 4 件を CM_HOOK_URL プレフィックスつきの形に更新した(#989 の --model クォート検証は維持)
  • refactor(hooks): AgentEventSource 抽象を抽出し、Claude 固有の継ぎ目をツール別実装に分離した(Phase 4-1) (#1759)

    • Epic #1720 の Phase 4-2〜4-5 が「1 ファイル足せば 1 ツール増える」形になるための土台。Claude の挙動は 1 バイトも変えていない(buildAgentHookSettings の出力・PermissionRequest の応答ボディ・注入される --settings の内容はすべて develop と同一であることを実測で確認済み)
    • 新設 src/lib/hooks/sources/: I/F(types.ts)/述語つきマッパ(event-mapper.ts)/definePushHookSource・definePullEventSource(define-source.ts)/裁定スロット(pending-decisions.ts)/describeAbstain(abstain.ts)/globalThis 経由のレジストリ(registry.ts)/未対応ツール用の互換ソース(legacy-relay.ts)/Claude 実装(claude/)。ツール追加手順は docs/design/agent-event-source-interface.md
    • scripts/hooks/cmate-agent-event.sh の語彙不足を修正した(Phase 4 全体のハードブロッカー)。 type:"http" は Claude 専用で他 4 ツールは使えない(#1757)ため、この中継が唯一の配送路である。にもかかわらず --event は 5 語しか受けず、#1726 で AGENT_EVENT_TYPES に入った pre_tool_use / post_tool_use を渡すと exit 2 していた。加えて map_event_name は PreToolUse / PostToolUse も gemini の BeforeTool / AfterTool / BeforeAgent / AfterAgent も知らなかった。7 語すべて + 5 ツールの native 名に対応させ、session id の抽出に conversationId(antigravity)/ turn_id(codex)を追加し、--detail を新設した(bash 3.2 互換を維持)
    • NoDecisionBehavior を型として持たせた(実測 C3)。「裁定しない=安全」は成り立たない: Claude / codex / copilot は無応答で通常フローへ進むが、antigravity は空応答を「拒否」と解釈してツールを全停止し、opencode はタイムアウト無しで無限待ちする(10 分 19 秒の実測)。/api/hooks/permission-request は abstain かつ noDecision.kind !== 'proceeds' のとき permission-request-abstain-blocks-agent を warn するようにした。どちらの失敗もセッションが無言で止まる(考え込んでいる agent と区別がつかない)ため、記録が唯一の手がかりになる
    • イベント写像を「名前の表」から「述語つきマッパ」に変えた(C4)。 Record<string, AgentEventType> では opencode を写せない — message.part.updated は part.state.status によって pre_tool_use と post_tool_use に割れ、user_prompt_submit には専用イベントが無く(message.updated の info.role)、notification は 3 つの別イベントの束である
    • 未知イベントで throw しないことを型と実装の両方で固定した(C8)。 opencode の server.heartbeat はサーバ自身の /doc に型定義が無いのに 10 秒ごとに届く。未知は null を返して数える(getUnknownEventTally())
    • 世代フェンス(S8)を共通ヘルパ化した。 beginAgentEventGeneration の呼び出しは全コードベースで claude-session.ts の 1 箇所だけだった=他ツールにはフェンスが無い。src/lib/session/agent-session-lifecycle.ts の beginAgentSession() に集約し、Claude の既存呼び出しをそれ経由に置き換えた。Phase 4-2 以降は各 startSession からこの 1 行を呼ぶ
    • S1/S2(イベント名の綴りと subtype 抽出)を agent-event-types.ts から hooks/sources/hook-event-vocabulary.ts へ移した。agent-event-types.ts に残るのは 7 語だけになり、ツール非依存の共有モジュールに 1 ツールの綴りが同居する状態を解消した(次のツールが同じ表に追記される導線を断つのが目的)
    • I/F が pull 型(SSE 購読 + REST 応答)を表現できることを、#1758 §9.4 のチェックリスト 11 項目に照らして検証した(tests/unit/hooks/sources/pull-source-contract.test.ts が opencode の実 SSE fixture でソースを実際に組み立てて確認する)。実際の opencode 対応は #1763 のままスコープ外
    • 空振り緑の反証: レジストリから claude を外す/Stop→stop の写像を 1 つ壊す/noDecision を全ソース proceeds に固定する/describeAbstain を常に safe にする、の 4 変異を注入して赤になることを実測した(順に 11・26・2・6 テストが赤)
    • fix(test): /proc 配下を env var に代入する fixture が Linux CI を 5h31m ハングさせたため差し替えた(PR #1773 の postmortem)。CM_AGENT_HOOKS_DIR = '/proc/…' が製品コードの mkdirSync(dir,{recursive:true}) に届き、procfs は存在し得ない子への mkdir に EPERM ではなく ENOENT を返すため Node の recursive 実装が「親が無い=作って再試行」と解釈して C++ 内で同期無限ループになる(コンテナ実測: 25 秒経っても返らず CPU 100.4%・メモリ 12.66MiB で平坦)。同期ループなのでイベントループが止まり vitest の testTimeout すら発火しない(OOM 痕跡も残らず、ログが無音になるだけ)。macOS では /proc が存在せず即 throw → 緑になるので、原理的にローカル再現しない。fixture を「親が通常ファイルであるパス」に変更した(通常ファイルは子を持てないので全 OS で即 ENOTDIR。macOS/node 24 0ms・Linux/node 18 1ms)。テストの意図(fail-open の検証)と expect 行は不変
    • 再発防止に tests/unit/guards/no-procfs-env-fixtures.test.ts を追加した。tests/ 全体を走査し env var への /proc /sys /dev 配下パスの代入(process.env.X = … / process.env['X'] = … / vi.stubEnv(…))を禁じる。#1760〜#1763 の 4 ワーカーが当該ファイルを雛形にするため、コメントではなく赤いテストで止める。文字列としての /proc 参照は対象外(system-directories.test.ts / db-migration-path.test.ts / git-workflow.test.ts は実 fs に触れない)。/dev/null 等の終端デバイスノードは完全一致で許可(GIT_CONFIG_GLOBAL=/dev/null は既存の正当な idiom)
    • 既存テストの期待値(expect 行)は 1 つも削除・変更していない。tests/unit/scripts/cmate-agent-event.test.ts の 2 ケースだけ入力値を差し替えた(PreToolUse は #1726 以降「対応語の無いイベント」ではなくなったため、その役を PreCompact に交代させた。期待値は同一)

Fixed

  • fix(security): 設定パスが /proc /sys /dev 配下でもサーバが停止しないようにした (#1774)
    • /proc への recursive mkdir は失敗せず「返らない」。 procfs は存在し得ない子への mkdir に EPERM ではなく ENOENT を返し、Node の recursive mkdir はそれを「親が無い」と解釈して親を作って再試行する。親(/proc)の作成は EEXIST で成功扱いになるので 1 階層でもループに入る。同期版はイベントループごと停止(try/catch は書いてあっても呼び出しが返らないので到達しない)、非同期版は promise が永久に settle せず libuv スレッドプールを 1 本恒久占有する(既定 4 本)。エラーログも OOM も残らない — PR #1773 の Unit Tests は fixture が CM_AGENT_HOOKS_DIR に /proc/… を入れたことでこの状態に入り、5 時間 31 分 57 秒、出力ゼロで走った(同期スピンなので vitest の testTimeout も発火できない)
    • Linux / コンテナ限定。 macOS は /proc が無いため即 throw して fail-open になり、ローカルでは構造的に再現しない
    • 共有ヘルパ 1 つを各 resolver に通す形にした(src/config/safe-directory.ts の resolveSafeDirectory(candidate, fallback, source))。Issue 本文は CM_AGENT_HOOKS_DIR / CM_LOG_DIR の 2 経路と書いているが、develop 3e8a6192 時点の実測は 5 経路(Phase 4 の #1760 / #1761 / #1763 が CODEX_HOME / COPILOT_HOME / CM_OPENCODE_PORT_FILE を同型で追加していた)。「2 箇所に足す」ではなく resolver 側に集約したので、6 個目のツールは既存の resolver を呼ぶだけで守られる。gemini/shared-config-tree.writeJsonObjectFile()(~/.gemini ツリーの共通 writer)は引数でパスを受け取り代替既定値が無いので、フォールバックではなく throw で拒否する(呼び出し元 2 つは既に throw を「hooks 無しで起動」として扱う=fail-open の形は不変)
    • isSystemDirectory() をそのまま適用するのは誤りで、実測で退けた(Issue 本文の「提案する対処 1.」からの意図的な逸脱)。 同関数は /tmp と /var も弾くが、そこはハングしない普通の書き込み可能ディレクトリであり、os.tmpdir() は Linux で /tmp・macOS で /var/folders/… と両方その配下にある。適用すると tests/setup.ts:15 の CODEX_HOME 隔離と tests/helpers/agent-hooks-dir.ts が既定値に落ちてテストがユーザーの実 ~/.codex と ~/.commandmate/hooks を書き換え、コンテナ運用の CM_LOG_DIR=/var/log/commandmate も黙って化ける。ハングを防げないうえに正常な構成を壊すので、VIRTUAL_FILESYSTEM_ROOTS(/proc /sys /dev)を名前付き部分集合として切り出し、判定機構(lexical/physical 両解決+境界一致)は既存のものを共有した。VIRTUAL_FILESYSTEM_ROOTS ⊂ SYSTEM_DIRECTORIES はテストで固定してある
    • throw しない。 ログディレクトリで throw するとロギング自体が死に、hooks は既に fail-open が設計方針。既定値へフォールバックして logger.warn を出す。警告は (source, candidate) ごとに 1 回だけ(getLogDir() は全ログ書き込みの経路上にあるため、無条件だと 1 つの設定ミスがログ洪水になる)
    • テストは fs にも env にも触れない。 tests/unit/guards/no-procfs-env-fixtures.test.ts が env 代入を機械的に赤にするので、①述語と resolver は素の文字列引数で全分岐を固定 ②5 設定の配線は述語を mock した無害な sentinel パスで end-to-end に確認 ③引数を取る入口(HookSettingsOptions.directory / CodexHookOptions.codexHome / writeJsonObjectFile)だけ実 /proc 文字列で fail-open まで通す、の 3 段に分けた
    • 空振り緑の反証: 10 変異を 1 つずつ注入して全部赤になることを実測(経路ごとのガード除去 8 種=順に 2・3・2・2・3・2・2・3 テスト赤/isVirtualFilesystemPath を常に false=32 赤/フォールバックを候補値そのままに変更=23 赤)。すべて戻して 136/136 緑に復帰
    • 実機検証: ①実 Linux コンテナ(node:24-bookworm)でガード無しの mkdirSync('/proc/x/y',{recursive:true}) が 15 秒間 CPU 100.4% / メモリ平坦のまま返らないことを再現し、②同じコンテナで実コードをバンドルして走らせると 1ms で既定値へフォールバックし /proc/x は作られないことを確認(/tmp /var/log ~/.codex 相対パス /procfs /devices は素通し)。③macOS 側は隔離環境(ポート 3774・隔離 DB、本番 3000 は無停止)で CM_LOG_DIR=/proc/definitely-not-writable/cmate-1774 を与えてサーバが起動し GET / が 200、/api/worktrees/<id>/logs が 200 で <cwd>/data/logs にフォールバックし、6 回叩いても警告は 1 回であることを実測。~/.commandmate/ ~/.codex/ ~/.copilot/ ~/.gemini/ は前後で sha256 マニフェスト一致(差分は develop 由来の既存テスト agent-session-lifecycle-1759.test.ts が CM_AGENT_HOOKS_DIR を隔離せず書く 1 ファイルのみで、内容は CM_PORT から決まる=本変更とは無関係。同一環境の 2 回連続実行で同一ハッシュを実測して確認)
    • grep -rn "mkdirSync(\|mkdir(" src/ | grep recursive の全 25 件を 1 件ずつ判定し、安全だったものも根拠つきで docs/design/virtual-filesystem-directory-guard.md §4 に記録した(DB 系 3 件は isSystemDirectory 済み、skills 系 6 件は検証済み root 配下、file-operations 3 件は checkPathSafety で worktree 内に限定、CLI 4 件と tmux/read-mode は homedir() 定数、ほか)。残存リスクとして「これらの安全は HOME が正気であることに依存する」を明記