Imported from kryota-dev/dotfiles (
home/dot_agents/skills/wave-orchestrator/SKILL.md). Install upstream withnpx skills add kryota-dev/dotfiles --skill wave-orchestrator. Copyright stays with the author.
wave-orchestrator
pr-workflow は単一タスクを深く回す。issue-fleet は複数 issue を浅く広く回す(サブエージェント、
draft PR 止まり)。本 skill はその中間にない第三の象限 —— 深く × 広く —— を担う。
実行単位が違うことが本質。issue-fleet はサブエージェントに委任するので親のコンテキストを共有し、
親が尽きれば全部止まる。本 skill は独立した CLI セッションを立てるので、各セッションが自分の
コンテキストを持ち、自分の gate を通り、自分でレビュー対応まで走る。その代わり、セッションは
対話を求めて止まる。その停止をどう捌くかがこの skill の中核になる。
この SKILL.md の責務(norm registry)
この SKILL.md が持つのは規範だけ —— 守ること、してはいけないこと、判定表、そしてそのまま使う
コマンド形である。その規範がなぜその形なのか(実測・事故の経緯・fh や Claude Code の内部挙動)は
references/ が持つ。
縮退で規範を削ったわけではない(記述は移しただけで、どれも省略可能になっていない)。
| ファイル | 内容 |
|---|---|
SKILL.md(本ファイル) |
規範 + 判定表 + そのまま使うコマンド形 |
references/routes-and-safety.md |
2 経路の並存(#537 / #539)と安全原則 5 件の事故記録 |
references/launch.md |
Phase 0 前提と Phase 2 起動の根拠(capability / 承認境界 / profile / 依存 / arm) |
references/approval-channel.md |
承認チャネル監視の根拠(envelope / self-check / 回答の配送) |
references/liveness.md |
生存判定の 7 つの穴と、結末確認(fh runs / succeeded)の根拠 |
references/verification.md |
代理応答の判定と成果物の検証の根拠 |
references/resume-and-teardown.md |
中断と再開・後始末の根拠 |
平常運転では読まなくてよい。 次の 4 つの場合に、対応する reference を Read すること:
- 規範の妥当性を疑ったとき。「これは冗長では」と思ったら、緩める前に根拠を読む
- 規範を書き換える・削るとき。 根拠を読まずに緩めた変更は退行である
- 規範が想定していない状況に出たとき
- 監視・生存判定・self-check のコマンド形を組み直すとき。
references/liveness.mdとreferences/approval-channel.mdを先に Read する
新しい実測を書き足すときは references/ 側へ書く。 規範が変わらないなら本ファイルは 1 行も
増やさない。このファイルは 7 日で 4.8 倍に膨らんだ実績があり、そのとき増えたのは規範ではなく根拠
だった(#610)。
経路は 2 つある(既定は承認チャネル)
| 承認チャネル経路(唯一の正) | tmux 経路(legacy・撤去予定 / drain-only) | |
|---|---|---|
| 起動 | fh session launch(claude -p の非対話実行) |
ペインに対話セッションを立てる |
| 停止の検知 | しない。子が承認チャネルを同期的に呼ぶ | hook イベントの追記を監視する |
| 回答 | fh approvals で読み fh approve で返す |
画面を読んで数字キーを送る |
| 主な事故 | —— | プレースホルダの誤読、Esc 固着、確定キーの投機送信 |
新規の wave は承認チャネル経路で起動する。 画面を読まず、キーを送らず、「今まさに未回答か」を 推定しない —— 呼ばれた時点が未回答であり、返り値が回答である。
並存期間の規範(どちらが正か)。 記述が矛盾したら、この 3 つが勝つ。
- 新規の wave は必ず承認チャネル経路で起動する。
fh sessionが exit 2 を返す・fh doctorがunavailableを返す等で起動できないとき、legacy 経路へフォールバックしない。 そこで止まり、fh onboardの儀式を通すか user へ上げる。 - legacy 経路は drain-only。 使ってよいのは、既に tmux 経路で走っている wave を捌き切るときだけ。 新しい子をペインに立てる用途に使わない。
- legacy 節は履歴として残っている参照であって、現行手順ではない。 本 skill の該当節には 「legacy」と明示してある。
tmux 自体は残す。 ペインで fh session を走らせれば「人が覗いて介入できる窓」は残り、詰まったら
控えてある session id で claude --resume <session-id> を対話セッションとして開き直せる。撤去するのは
機械が画面を読んでキーを送る導線(scripts/wave-events.sh / scripts/send-to-pane.sh)だけで、その
決定・撤去条件・撤去の順序は #539 が持つ。legacy 経路のガードには直さないと決めた穴が 2 件あり、
上の規範 1・2 がその唯一の緩和策である。
→ 根拠: references/routes-and-safety.md
引数
| 引数 | 既定 | 意味 |
|---|---|---|
<issue 番号列> |
- | 対象 issue。空白区切り。# は付けても付けなくてよい |
--from-radar |
off | repo-radar の直近の出力を入力に取る(issue 番号列の代わり) |
--max-parallel=N |
4 | 同時に走らせる子セッション数の上限。wave のサイズとは別物で、wave がこれを超えるなら分割して実行する。quota が閾値帯に入っているときは下方調整する |
--profile=<launcher> |
親から継承 | 子セッションの launcher。親と異なる profile を指定した場合は user の明示承認を求める |
--dry-run |
off | Phase 1 で止め、計画と起動コマンドだけを返す |
安全原則(絶対遵守)
- マージは代理しない。 常に user へ上げる。分類器が上書きできない無条件エスカレート集合の筆頭で、
経路が変わっても動かない。承認チャネル経路では deny ルールが子の側で escalation を起こし、要求は
fh approvalsに積まれる。そこで Leader がfh approve --allowを打ってはならない。 これはコードではなく方針で担保している(Leader を機械的に止める層は無い)。 - 状態を分類していないペインへ送信しない。(tmux 経路の原則。承認チャネル経路では「送信先」と いう概念が無いので不要化する。)一斉配信であっても 1 ペインずつ分類する。
- 事実主張に依存する問いは、一次ソースで裏を取るまで自動応答しない。 セッションの自己診断は外れる。
- 検知器が自己診断に失敗したら黙って進まない。 検知の静かな故障は「誰も停止していない」と 見分けがつかない。
- 共有リポジトリの git 操作を子にやらせない。 orchestrator が直列に 1 回だけ行う。
→ 根拠: references/routes-and-safety.md「安全原則それぞれの事故記録」
Phase 0: 前提チェック
承認チャネルの前提(起動前に必ず確認する)
子は承認チャネルが配線されていなければ AskUserQuestion そのものを失う ——「止まらなくなった」では
なく「問えなくなった」で、外形が同じまま blocking gate が消える。よって次を起動前に確かめる。
-
fh doctor --jsonで、この wave が使う capability がavailableであること。 対象はsession.childに限らない —— Phase 1 の tier 割りがsession.child.standardを要求するなら、それも 確かめる。unavailableなら別 account へ切り替えたり model 名を推測したりせず、そこで止まる。 -
子を走らせる各ワークツリーで、使う capability が残らず承認済みであること。 承認の単位は リポジトリではなくワークツリーである。未承認なら
fh sessionは子を起こさず exit 2 で終わる。 これは正しい fail-closed であって障害ではない。 承認は 2 段階の儀式で行う:fh onboard --manifest <manifest.json> # レビュー。request id が出る fh onboard --manifest <manifest.json> --approve --request <id> -
rollout が provider 実行を許していること。
shadowの間は route を記録するだけで子を起こさない。 逆に言えば、rolloutをshadowに戻すことがそのまま緊急停止になる。
→ 根拠: references/launch.md「承認チャネルが配線されていないと何が起きるか」
tmux の前提(窓としての利用)
tmux の有無を確認する。無ければ Phase 1 だけを実行して停止し、生成したコマンド列を返す。
これは縮退ではなく正規の使い方で、user が手で起動する運用が成立する。
親の profile を CLAUDE_CONFIG_DIR の basename から導出し、子セッションの launcher を決める
(~/.local/launchers/ の同名 symlink)。親と異なる profile で起動するのは user が明示承認した
ときだけ(quota の空きを理由に自動で流さない。業務を別区分のアカウントで走らせることに直結する)。
子には orchestrator 用の model variant を使わない(floor 判定が実質無効化される)。
tmux セッションの用意
子セッションは専用の tmux セッションに置く。
- セッション名:
wave-<owner>__<repo>。区切りが__なのは/#.:が tmux の target 構文と 衝突するため。owner/repo を含めることで別リポジトリの wave と混線しない。 - target は文字列で組み立てず ID を使う。
-tには window ID(@45)/ pane ID(%97)を渡す。 文字列 target は zsh が history modifier として解釈して壊すので、やむを得ず組む場合はブレースで 囲む。Claude Code の Bash ツールは zsh で動くため、bash 前提の手順をそのまま書くと確実に踏む (Phase 3「監視スクリプトは zsh で動く」も先に読むこと)。 - 存在したら再利用し、絶対に kill しない。 kill は稼働中の子を殺す。同名ウィンドウが既にあるなら、 それは重複起動の兆候として扱う。
- 導線の提示: tmux の外からは
tmux attach -t <name>、中からはtmux switch-client -t <name>。 - ウィンドウ番号の詰め直しとペインタイトルの表示は利用者の tmux 設定に依存する。skill 側では設定しない。
→ 根拠: references/launch.md「tmux の target を ID で渡す理由」
Phase 1: 棚卸しと wave 計画(tmux 不要)
repo-radar の出力があればそれを入力に取る。無ければ自分で集める。
- 収集 — assign 済み open issue、自分の open PR、既存ワークツリー、稼働中セッション。
- 内容の把握 — issue 本文を読む。本文の前提は実装の進行で腐るので、issue が指摘した症状が 今も再現するかをコードで確かめてから計画に載せる。「PR #NNN で外した」「定数 X が原因」といった 断定は特に裏取りする価値がある。
- 依存グラフ — 決定待ち(needs-decision)が実装をブロックしていないか、親子・先行関係。
- コンフリクト分析 — 同じファイルを触る issue は同じ波に入れない。ここが並列度を決める。 判断材料は issue 本文ではなくコード(対象コンポーネント・共有ヘルパー・e2e)。
- 他者の open PR との衝突確認 — 同じ領域を他メンバーが触っていないか。タイトルだけで判断せず 変更ファイル一覧を見る。タイトルが似ていても実体は別物ということが普通に起きる。
- wave 割り — 並列可能な組と、直列にせざるを得ない鎖を出す。最長の鎖が全体の律速なので、 その先頭を最初の波に入れる。
- tier と model/effort — tier ごとの model/effort は
/model-fitness-checkの contract に従う。 contract テーブルを本 skill に再掲しない(SSOT が drift する)。floor を下回る model で起動する 必要があるなら、user の明示承認を得たうえでプロンプトに事前承認ブロックを前置する。承認なしに このブロックを付けない —— これは blocking gate を静かに無効化する仕組みだから。 tier を決めたら、それに対応する capability 名を各セッションに割り当てる(Phase 2 で--capabilityに渡す)。既定のsession.childを全 tier に使うと、trivial/small の子まで large 用の model / effort で走り、quota を無駄に食う。→ Phase 2「capability を tier で選ぶ」 - プロンプトの用意 — 1 セッション 1 ファイル。プロンプトはファイルに書き出して渡す
(
fh session --prompt-file)。argv に本文を載せるとpsから同一ホストの他プロセスに読まれる。--sizeを pin する(tier 判定が session 内で揺れると floor 判定も揺れる)。本文には次を含める:- 何をするか(issue 番号と一文要約)、
--size/--operation <context>既に下した決定、腐っている前提の訂正、並行セッションとの衝突可能性<scope>対象外の明示。後続 issue へ送るものを名指しする<constraints>壊してはいけないもの(IPC 契約、a11y ラベル、既存のトレードオフ)<completion>どこで止まるか(マージしないことを明記)
- 何をするか(issue 番号と一文要約)、
出力は計画表 + そのまま貼れるコマンド列。--dry-run ならここで終わる。
Phase 2: 起動
起動前に同名セッションの不在を確認する。 重複投入は最悪の事故(gate を経ない無断マージ)の 入口になっている。
3 つの数を混同しない。
| 概念 | 何で決まるか |
|---|---|
| wave のサイズ | Phase 1 の依存関係とコンフリクト分析。「同時に走らせてよい issue の集合」であって、上限ではない |
--max-parallel |
実際に同時起動する数の上限。quota とマシン資源の制約。wave がこれを超えるなら、その wave を分割して順に流す |
| 1 ウィンドウのペイン数 | 表示の都合だけ(4 前後が視認性の限界)。スケジューリングとは無関係 |
専用セッション(Phase 0)の中にウィンドウを作る。ウィンドウ名は wave 単位(wave1 / wave2 …)とし、
1 つの wave が 1 ウィンドウに収まらないときは wave1a / wave1b のように分ける。各ペインでランチャを起動する。
識別子は 1 つに統一する
子セッション名・ペインタイトル・ワークツリーは同じ識別子で揃える。 揃っていないと、重複検査は セッション名で、送信先の特定はペインタイトルで、成果物の確認はワークツリー名で、と別々の文字列を 突き合わせることになり、取り違える。
識別子は ブランチ名の命名規則から導く(pr-workflow が同じ規則でブランチとワークツリーを作るため、
対応が自動的に取れる)。/ は ps の照合と --resume の指定で扱いにくいので - に置き換える。
ブランチ feat/1714-unify-change-proposal-vocabulary
識別子 feat-1714-unify-change-proposal-vocabulary
この 1 つを次のすべてに使う:
| 用途 | 使い方 |
|---|---|
fh session の台帳 |
--label <識別子> |
| ペインタイトル | tmux select-pane -t <pane> -T "<識別子>" |
| ワークツリー | fh session --worktree <その識別子のワークツリー> |
feat-1714 のような番号だけの短縮形にしない。 監視中はペインが何本も並ぶので、そのペインが
何をしているのかがタイトルから読めないと取り違える。PR 番号をタイトルに足すこともしない —
識別子が 1 つでなくなる。PR は gh pr list --head <ブランチ名> で引ける。
起動する
各ペインで fh session launch を走らせる。セッション ID は起動時に採番して渡す(省略しても起動直後に
stderr へ出るので必ず控える)。同じ値が承認 server の --session にも渡るため、fh approvals の各要求から
どの子の問いかを引ける。
CLAUDE_CONFIG_DIR を明示する。 明示しないと fh は account を unknown にしか解決できず、
外形上は正常な起動と見分けがつかないまま劣化する。値は親の profile(CLAUDE_CONFIG_DIR の basename)
に合わせる。
CLAUDE_CONFIG_DIR="$HOME/<親の profile ディレクトリ>" fh session launch \
--label "<識別子>" \
--session-id "$(uuidgen | tr 'A-Z' 'a-z')" \
--worktree "<ワークツリーの絶対パス>" \
--prompt-file "<プロンプトの絶対パス>" \
--gate "<そのリポジトリの承認済みチェック>"
--capabilityは既定session.child。tier に応じて明示的に選ぶ(下記)。floor を下回る capability を選ばない。--sandboxは既定workspace-write(子は自分のワークツリーへ書く)。--worktreeは承認境界そのものでもある。別リポジトリを指せば「そのリポジトリの承認」が問われる。- 完走すると JSON が出る。
resumeKey/status/initHealth/denials/adapterRunIdを控える (adapterRunIdは「成果物の検証」でfh runs --runに渡す)。
--gate でその wave の完了条件を宣言する。宣言せずに子を起こさない。 宣言の無い run は連結された
検証結果が 0 件になり、「gate を通していない」と「gate を課していない」が記録上で区別できなくなる
——「成果物の検証」の一次判定はこの件数を読むので、宣言を省くとその判定ごと成立しなくなる。
- 載せられるのは承認済みのタスクランナーコマンドだけである。 未承認の宣言は子を起こす前に
exit 2 で止まるので、数時間走ったあとで「その gate は承認されていない」と分かる形にはならない。
gh pr view ...のような検査はこの形に載らない。gate で代替せず別の信号として見る(「成果物の検証」)。 - 種別を前置できる(
<kind>:<command>)。語彙はtest/typecheck/lint/browser/performance/securityで、省略時はtest。繰り返して複数宣言でき(上限 8 本)、直列に走る。 fh session resumeでも必ず渡す。継承されない。--capabilityと違い resume-key から元の宣言を 引く経路が無いので、渡し忘れた resume は完了条件を課さないままsucceededになる。- 宣言した本数を控える。 「成果物の検証」は件数の一致を見るので、本数が分からないと判定できない。
- 「承認済みのタスクランナーが無い」を、onboard を試す前に結論しない。 未承認なのか本当に無いのかは
儀式(下記)を通すまで区別できず、省略した Leader が「無かった」と自己申告するだけで宣言必須を
すり抜けられる。それでも 1 本も宣言できないリポジトリなら、
--gate無しで起こしてよいが、その子は 「成果物の検証」の判定不能経路(→ user へ上げる)に必ず入ることを起動前に確認する。gate を 省いたぶんを、後から成果物側で埋め合わせられると考えない。
子の stderr をファイルへ残さない。 子の生の stderr はそのまま継承されるので、会話内容を含みうる前提で
扱う。ペインで覗くのはよいが > child.log 2>&1 のように永続化しない。
起動フラグを自分で組み立てない。 事前遮断と承認チャネルの配線は fh が sealed invocation として
組み立てる。手で書くと、その保証の外に出る。
この対応を state file に持たない(識別子・ワークツリー・PR)。gh とワークツリーが真実を持つ。
例外は session id 1 点で、これだけは環境が真実を失う(「中断と再開」)。
ワークツリーを作るたびに、そのワークツリーで onboard の儀式を通す。 承認はリポジトリ単位ではなく ワークツリー単位で、main で 1 度通しても linked worktree からの起動は exit 2 で止まる。
cd <ワークツリー> && fh onboard --manifest <manifest.json> # review。request id が出る
cd <ワークツリー> && fh onboard --manifest <manifest.json> --approve --request <id>
.harness/policy.jsonをコピーしても通らない。設計上そうなっている。- 承認しても
fh gapsは掃けない。「gap が残っている=未承認」と読まない。承認の有無はfh sessionが通るかで見る。 - 新しいワークツリーで
--from-gapsを使わない(既存の承認済み capability が落ちる)。--manifestに完全な集合を渡す。 --gateに載せるコマンドもcommandsに入れる。 manifest が持つのは{commands, domains, capabilities}で、capability だけを承認した manifest では宣言した gate が 未承認になり、子を起こす前に exit 2 で止まる。「完全な集合」には完了条件のコマンドも含む。
ワークツリーの依存は起動前に orchestrator 側で入れておく。 子は sandbox 下で走るため、依存の インストールを完走できないことがある。orchestrator が sandbox の外で先に済ませる。
起動前に重複起動を検査する。 同じ識別子のワークツリーで既に子が走っていないか、同じ label の adapter run が走っていないかを見る。重複投入は最悪の事故(gate を経ない無断マージ)の入口である。
→ 根拠: references/launch.md(accountScope が解決されない劣化、sealed
invocation、policy コピーが通らない機序、--from-gaps が候補を落とす機序、pnpm install の実測)、
references/verification.md(宣言を必須にする理由と、宣言した完了条件が
記録へ連結される機序)
capability を tier で選ぶ
fh は tier を知らない。 capability を名前で受け取り、その model / effort をそのまま子の argv へ
渡すだけなので、tier → capability の対応付けは呼び出し側=本 skill の責務である。既定任せにすると
全 tier が large 用の設定で走る。
| Phase 1 で決めた tier | --capability |
|---|---|
| trivial / small / standard | session.child.standard |
| large | session.child(既定) |
- trivial/small を standard へ切り上げる。 子が
pr-workflowを全長走らせ、レビューの統合・裁定を 必ず通るため。切り上げは tier 判定の放棄ではない。 - 具体的な model / effort をここに書かない。 値の SSOT は
~/.config/frontier-harness/config.jsonと/model-fitness-checkの contract の 2 つで、本 skill はその対応名だけを持つ。 - 新しい capability 名は対象リポジトリの manifest 承認が要る。 未承認なら exit 2 で終わるので そこで気付ける —— 静かに既定へ落ちることはない。
--capabilityは floor を下回る選択には使わない。 quota 節約のために 1 段下げてよいという意味では ない。tier を軽く見積もって下の capability を選ぶのは、blocking gate の迂回にあたる。
--profile はこの経路では運べない。 子は継承した CLAUDE_CONFIG_DIR を保持するので、必ず親と
同じ account で走る。親と異なる profile を指定されたら黙って親の account で起動しない —— 選択肢は
(a) その account の親セッションから wave を起動し直す、(b) capability 側に accountScope を宣言して
固定する、の 2 つ。どちらも取れないなら user に上げる。推測で進めない。
→ 根拠: references/launch.md「capability を tier で選ぶのが本 skill の責務で
ある理由」「--profile がこの経路で運べない機序」
起動は、監視を arm するまでが 1 手順である
「子を起こした」の完了条件は、fh approvals を引く仕掛けが動いていることを positive control で
確認済みであること(Phase 3「検知器が壊れていないことを、監視を始める前に示す」)。arm していない
状態で次の作業へ進まない。
「報告します」と書く前に、報告経路が存在することを確かめる。 検知器の故障と張り忘れは原因が違うが 症状が同じ(「進捗が来ない」)なので、宣言時点の自己点検が唯一の共通の防壁になる。
同じことは fh session resume にも要る(「中断と再開」節)。resume も「子が走り出す」操作である。
→ 根拠: references/launch.md「起動と監視の arm を分けてはいけない理由」
Phase 3: 監視と代理応答
承認チャネル経由(既定)
「止まったか」を推定しない(監視をやめるという意味ではない)
承認が要る操作と AskUserQuestion は、子から同期的な MCP ツール呼び出しとして承認チャネルへ届く。
Leader が「止まったか」を推定する必要はなく、呼ばれた時点が未回答であり、返り値が回答である。
不要になるのは「状態の推定」だけである。 要求が積まれたことはこちらへ通知されないので、 見に行く義務は残る(次節)。この節を「監視不要」と読んで放置した実例がある。
読む —— 到着は通知されない。自分で見に行く
fh approvals --json # pending だけ
fh approvals --all --json # 決着済みも含める
-
要求はキューに積まれるだけで、Leader に通知は飛ばない。定期的に引く仕掛けを、wave を起こしたら 必ず併せて用意する。
-
放置は「通る」ではなく「止まる」。 上限(既定 8 時間)に達した要求は自動で deny になる。
-
監視には失敗側も入れる。 新規の承認要求 ID だけを見ると、子が死んで要求が来なくなった状態が 「静かで順調」に見える。生存判定(「沈黙ストール」節)を同じ仕掛けに含め、沈黙が成功に見えない ようにする。
-
出力は要求の配列ではなく envelope である。 top-level を配列として舐める jq は型エラーで落ちる。
fh approvals --json | jq -r '.approvals[] | "\(.id) \(.sessionId) \(.toolName)"' -
各要求は
sessionId/cwd/toolName/risk/ruleId/reason/input/timeoutAtを持つ。AskUserQuestionの要求ならinput.questionsに問いと選択肢がそのまま入っている(画面から読まない)。 -
skippedは読み飛ばしてよいものではない。 壊れて読めなかった要求の隔離先で、そこに入った要求はapprovalsに現れない。.approvals[]だけを見る監視は壊れた要求を静かに取りこぼす。非空なら警告する。
→ 根拠: references/approval-channel.md「通知が飛ばないことの実害」
検知器が壊れていないことを、監視を始める前に示す
「引く仕掛けを用意した」だけでは足りない。Leader 側のポーリングは依然として検知器であり、壊れる。
-
arm 直後に positive control を通す。測るのは「envelope を舐められるか」であって「記録が何件あるか」 ではない。 この形をそのまま使う:
probe=$(fh approvals --all --json 2>&1) \ || { echo "SELFCHECK_FAIL: fh approvals が非 0 終了: $probe" >&2; exit 3; } shape=$(printf '%s' "$probe" \ | jq -r 'if (.approvals|type)=="array" and (.skipped|type)=="array" then "OK" else "BAD" end' 2>&1) \ || { echo "SELFCHECK_FAIL: jq が envelope を舐められない: $shape" >&2; exit 3; } [ "$shape" = "OK" ] || { echo "SELFCHECK_FAIL: envelope の形が違う: $probe" >&2; exit 3; } -
件数で測らない。 件数ベースの検査は Phase 4 の
fh approvals --purgeと正面から矛盾する。使える のは、wave の途中で検知器を組み直すときのように既知の件数を置ける場合に限られる。 -
失敗の種類を別の出口に分ける。 上の形が
||を 3 段に割っているのは、「fhが落ちた」「jq が 舐められない」「形が違う」を別々のメッセージにするためである。単一の判定にまとめない。 -
検知器のエラーを握り潰さない。
2>/dev/nullと|| trueの併用は、パース失敗を「イベント無し」に 写像する —— 安全原則 4 の具体的な破り方である。エラーと 0 件を別の値で表すこと。 -
検知器を組み直したら self-check をやり直す。 組み直しは検知器を疑う機会であって、コピー元を信じる 機会ではない。
→ 根拠: references/approval-channel.md「self-check が要る理由(42 分の事故)」
判定する
下記「代理応答の判定」に従う。この方針は経路が変わっても不変である。 無条件エスカレート集合
(マージ / リリース / デプロイ / 履歴書き換えと強制 push / 外部から取り消せない操作)は、承認チャネルでも
fh approve --allow で代理せず、AskUserQuestion で user へ上げてから答える。
答える
fh approve --request <id> --allow --answers '{"<問い>":"<選択肢のラベル>"}'
fh approve --request <id> --deny --message "<なぜ通さないか>"
--answersは問いのテキスト → 選択したラベルの対応。複数選択の問いは配列で渡す。- 1 要求 1 回。既に回答があれば
fh approveは落ちる(後着が先着を黙って上書きしない)。 denyは子側でpermission_denials[]に残るので、拒否した事実が機械可読な記録になる。- allow で自由文は送れない。 子へ届くのは選択したラベルだけで、注記・訂正・文脈は配送されない
(
--messageを--allowに付けるとfh approveは落ちる)。したがって:- 選択肢のラベルに意味を載せる。
はい/いいえしか無い問いには、判断の理由を伝える術が無い - 訂正を届けたいなら
--deny --messageを使う。 意味的には差し戻しなので deny で正しい。冒頭に 「これは拒否ではなく差し戻しである」と書いておくと、子が要求そのものを取り下げずに済む
- 選択肢のラベルに意味を載せる。
→ 根拠: references/approval-channel.md「allow が自由文を運べないことの実測」
待たせられる時間
承認チャネルは同期呼び出しなので、user へ上げている間子はブロックする。既定で 8 時間まで保持でき、 その間 60 秒ごとに progress 通知が飛んで stdio の idle timeout をリセットする。この既定値を skill 側で 再定義しない。 上限に達した要求は自動で deny になるので、放置は「通る」ではなく「止まる」。
沈黙ストールは別経路で見張る
イベントの追記を監視しない代わりに、プロセスの生死と fh session の stderr に出る heartbeat
(イベントの型名だけ。会話内容は出ない)を見る。長時間 heartbeat が出ず fh approvals に pending も
無いなら、それは判定不能の信号であって「稼働中」の証拠ではない。ペインを覗くか、
claude --resume <session-id> で開き直して確かめる。
生存判定の書き方には穴が 7 つある。いずれも fail-open(止まっているのに「稼働中」と読む、あるいは
動いていないものを数える)方向へ倒れるので、以下の形をそのまま使う。この形を変えるなら、先に
references/liveness.md を Read すること。
# 1 ワークツリー分の生存判定。先にスナップショットを取り、自分の PID を除いてから絞る。
snap=$(ps -Ao pid=,command=)
printf '%s\n' "$snap" \
| awk -v self=$$ '$1 != self' \
| grep -F "frontier-harness/cli.mjs session" \
| grep -F "<ワークツリーの絶対パス>"
grep -v grep を使わない。2 段とも必要である。
pgrep -fを生存判定の土台にしない(現セッションの祖先プロセスを返さない)。psを使う。- パターンを固定文字列にする(
grep -F)。 絶対パスを正規表現へ素で埋めない。 - ワークツリーの絶対パスで必ず絞る。ただしパスだけで絞ってもいけない。
cli.mjs sessionとの AND で fh のプロセスだけに残す(grepの 2 段重ねを 1 段に簡略化しない)。 - tmux の
pane_current_commandを判定に使わない(fhが node を exec してもzshと報告する)。 - 監視自身がマッチする書き方をしない(
psは呼び出し側の argv も見える)。 grep -v grepで自分を落とさない。 自分の除外は語ではなく PID で行う。- 判定スクリプトを、それを書いたのと同じ Bash 呼び出しで実行しない。 作成と実行を別の呼び出しに 分けるか、下記の PID 直接照会を使う。
claude -p の子は argv で引かない。 argv がプロンプト全文である以上、そこに現れる任意の語が偽陽性と
偽陰性の両方を生む。ワークツリーで特定したいなら cwd を見る。
lsof -a -p <pid> -d cwd -Fn | sed -n 's/^n//p'
確実なのは PID を直接照会する形である。 起動時に控えた PID に ps -p を当てると返るのはその 1
プロセスの行だけなので、自己混入が構造的に起きない。
# 起動時に控えた PID を照会する。PID の再利用に備えて中身も確かめる。
alive=0
for pid in $CHILD_PIDS; do
ps -p "$pid" -o command= 2>/dev/null | grep -qF "cli.mjs session" && alive=$((alive + 1))
done
この PID は監視の持ち物であって、「中断と再開」節が禁じている state file ではない。監視が落ちたら PID も捨て、パターン照合の形で組み直す。
件数の検算を必ず入れる。「N 本検査した」と出して期待値と突き合わせる。上の穴はいずれも件数のずれと して最初に現れる。
終わった子の結末は fh runs で引く(fh status は route 決定しか持たない)。ただし resumeKey /
denials は記録されないので、session id は従来どおり起動時に控える。
succeeded を「作業が完了した」と読まない(#573)。outcome が表すのはターンがエラーなく完了
したかだけで、子が指示された gate を 1 度も通していなくても succeeded / exitCode: 0 になる。
結末の確認を fh runs の status で終わらせず、同じ run に連結された検証結果の件数と、成果物の側の
両方で確かめる(「成果物の検証」節)。pr-workflow を非対話の子に走らせること自体の噛み合わなさは #585。
→ 根拠: references/liveness.md(7 つの穴それぞれの実測、argv マッチが両方向に
外れる理由、レビュー 0 件で succeeded になった wave 3 本の実測)
legacy: 検知(tmux 経路)
以下は tmux 経路の記述である。 新規の wave は上の承認チャネル経路で起動する。この節は、既に tmux 経路で走っている wave を捌くため(drain-only)の参照として残る。#539 で撤去が決まっており、 撤去条件を満たした時点でこの節ごと消える。
監視はイベント追記の到着で駆動する。 <記録先>/<session_id>.jsonl を追記監視し、
PreToolUse × AskUserQuestion の 1 行 = 停止 1 回として数える。状態の「解除」を必要と
しないので、回答後に出た 2 回目の質問も独立に検知できる。--state の遷移で駆動しない
(判定ロジックを通ると、その時点の集約結果しか見えない)。
tail -n 0 -F "$D/$U.jsonl" | while IFS= read -r line; do
ev=$(printf '%s' "$line" | jq -r '"\(.hook_event_name)\(if .tool_name then "/"+.tool_name else "" end)\(if .notification_type then "/"+.notification_type else "" end)"')
case "$ev" in
PreToolUse/AskUserQuestion) echo "【停止】選択肢で応答待ち" ;;
Notification/agent_needs_input) echo "【停止】$ev" ;;
Stop | StopFailure) echo "【完了】ターン終了" ;;
esac
done
沈黙ストールを別経路で見張る
イベントが出ない停止が実在する。 auto mode の一時停止(classifier の連続ブロック)は通知を 伴わない可能性があり、Esc キャンセルは実測でイベントを 1 件も出さない。通知の分類だけに頼ると これらを取りこぼすので、**「イベントファイルの無更新 + プロセス生存」**を独立した信号として併用する。
閾値は 25 分を下限とする。 8 分で運用したところ偽陽性だった —— multi-review の Codex /
サブエージェント leg は 10 分超の無音が正常であり、それを停止と誤報すると、稼働中のペインへ
代理応答を送る事故の入口になる。25 分は実測に基づく下限で、これより短くしない。
閾値に達したらそれ自体を「停止」と断定せず、ペインを目視して現況を確かめる(UNKNOWN と
同じ扱い)。沈黙は「判定不能」の信号であって「停止」の証拠ではない。
監視スクリプトは zsh で動く(bash 前提で書かない)
Claude Code の Bash ツールは user の profile から初期化された zsh で動く。Phase 3 の検知は シェルスクリプトを書く場面そのものなので、bash 前提の書き方をすると実際に踏む。
zsh は unquoted なパラメータ展開を field split しない(SH_WORD_SPLIT は既定 off。
man zshoptions: "Causes field splitting to be performed on unquoted parameter expansions.")。
実測(zsh 5.9 / bash 3.2):
v="a b c"
set -- $v; echo $# # zsh: 1 bash: 3 ← パラメータ展開は分割されない
set -- ${=v}; echo $# # zsh: 3 ← 明示分割なら分割される
set -- $(printf "a b c"); echo $# # zsh: 3 ← コマンド置換は zsh でも分割される
「zsh は分割しない」と丸めて覚えないこと。 分割されないのはパラメータ展開だけで、 コマンド置換は分割される。この非対称を取り違えると別の壊れ方をする。
実際に踏んだ壊れ方 —— 6 セッションの生存確認が全滅した:
IDS=""
while read -r id; do IDS="$IDS $id"; done < "$STATE" # 6 識別子をスペース区切りで蓄積
set -- $IDS # zsh では 1 引数のまま
for id in "$@"; do pgrep -f "claude .*-n $id" || echo EXIT; done
pgrep -f "claude .*-n <6 個が連結された文字列>" が 0 件を返し、稼働中の 6 セッション全部を
「プロセス消失」と誤報した。このときは誤報の向きが「消えた」側だったので気づけたが、
word-split の失敗で条件が成立せず「生きている」と判定する形に倒れれば沈黙する故障になる。
回避の指針:
- word-split に依存しない。 1 行 1 件で書き、
while IFS= read -rで 1 件ずつ処理する - 集合が要るなら配列を使う(
arr=(...)/"${arr[@]}")。スペース区切りの文字列に詰め込まない - フラグや蓄積を変数で持ち回らない。 state file に持ち、必要なつど読み直す
(
while ... | ...のサブシェルで変数が消える bash 側の落とし穴も同時に避けられる) - 書いたら本番に流す前にドライランし、件数が期待どおりかを確かめる(上の例なら 「6 件を検査した」と出るか)。件数の検算があれば、この失敗はループの初回で捕まった
--state は送信前のガードと分類に使う(監視の駆動には使わない)。
ASK_QUESTION 選択肢が未回答で開いている
ASK_PERMISSION 権限確認 / agent_needs_input で停止している
RUNNING プロンプトを受けて動いている
IDLE 応答を終えて次の指示を待っている(Stop / StopFailure / idle_prompt)
UNKNOWN 判定できていない(未検知 / auto mode の permission_prompt / 壊れた行あり)
ASK_QUESTION と ASK_PERMISSION は扱いが違う。前者は選択肢に答える(--select)か閉じる
(--dismiss)。後者は選択肢の数字キーを送ってはいけない(--key-for は拒否する)。
判定は 集合の引き算で行う。
未回答 = { PreToolUse × AskUserQuestion の tool_use_id }
− { (PostToolUse ∪ PostToolUseFailure) × AskUserQuestion の tool_use_id }
PostToolUse は成功時のみ発火し、通常の tool failure では PostToolUseFailure が発火するため、
決着イベントは 2 種を対で見る(Esc キャンセルはこの対の例外で、どちらも発火しない ——
後述の「Esc キャンセルはイベントを出さない」節を参照)。この述語は集合演算なので
イベントの到着順に一切依存しない。
かつては「ターン内を論理順序(UserPromptSubmit < Notification < Stop)で判定する」形だったが、
これは Stop が 1 ターンに最後の 1 回だけ来る という誤った前提に乗っていた。サブエージェントへ
委任した親は一度 Stop を出し、同じ prompt_id のまま再開して AskUserQuestion を出す。
any(Stop) が先に評価されるため、選択肢が開いて実際に止まっている子を IDLE と報告していた。
UNKNOWN を「稼働中」と読まない。 これは「判定できていない」であって「動いている」ではない。
以前は画面テキストを照合していたが、TUI の文言に合わなくなる(確認画面を検知できない)、過去の
スクロールバックに誤マッチして永久に稼働中と誤判定する、という 2 通りの壊れ方をした。後者は
沈黙する故障で、イベントが出ないことと正常が見分けられず、子セッションが gate を開いたまま
放置された。判定根拠を payload に移したのはこの再発を構造的に断つため。
auto mode の permission_prompt は停止とも稼働とも読めない —— UNKNOWN を返す。
かつては「auto なら自動承認されるので止まらない」と断定して RUNNING を返していたが、これは
fail-open だった。公式仕様(https://code.claude.com/docs/en/permission-modes)が、auto mode でも
承認を求めて止まる経路を明記している:
- Actions no mode auto-approves —— 明示的な ask ルール / 組織が ask に設定した connector ツール /
requiresUserInteractionの MCP ツールとAskUserQuestion/ critical path を対象にしたrm・rmdir。bypassPermissionsを含むどの mode でも自動承認されない - Writes to protected paths are never auto-approved except in
bypassPermissionsmode - Repeated blocks —— classifier が同一アクションを 3 連続、または累計 20 回ブロックすると auto mode が一時停止し、Claude Code はプロンプトに戻る(閾値は設定不可)
一方で「auto mode の permission_prompt が自動承認され、子が止まらない」ことも実測されている
(3 セッション中 2 件)。両方とも本当なので、通知と permission_mode だけでは判別できない。
したがって判定は次のとおり:
ターン内の permission_mode |
permission_prompt があるときの --state |
|---|---|
一様に auto / bypassPermissions |
UNKNOWN(判別不能。停止しているかもしれない) |
| 混在 / 非 auto / 情報なし | ASK_PERMISSION(見落とさない側へ倒す) |
ただし permission_prompt のあるターンに idle_prompt(「次の指示を待っている」)があれば
UNKNOWN を解除して IDLE に戻す。 承認待ちのモーダルが開いている状態と「次の指示待ち」は
両立しないので、これは idle である積極的な証拠になる。Stop では解除しない —— Stop は
ターンの終わりを意味しない(#447: サブエージェントへ委任した親は途中で Stop を出して再開する)。
RUNNING を返さないので停止を見落とさない。ASK_PERMISSION とも言わないので、稼働中の子へ
代理応答の確定キーを送る事故も起きない(送信側は UNKNOWN で全経路 fail-closed)。
解除されるのは一部だけ ——「塞がる」前提で運用する
実イベント(23 セッション / 208 行 / 52 ターン、2026-08-28 時点のスナップショット)を
prompt_id 単位で集計した結果:
| ターン数 | |
|---|---|
| 全ターン | 52 |
permission_prompt を含む |
15(29%) |
うち idle_prompt も含む(= IDLE へ解除される) |
6 |
うち Stop はあるが idle_prompt が無い(解除されない) |
3 |
うち Stop も idle_prompt も無い(実行中。解除されない) |
6 |
つまり **permission_prompt が出たターンのうち解除されるのは 6/15(40%)**で、残り 6 割は
UNKNOWN のままになる。--text は RUNNING / IDLE のときしか送らないので、その 6 割の
ターンでは自由記述を送れない。
これは設計上の意図どおりで、緩めない(解除の根拠は「積極的な証拠」でなければならない)。 代わりに塞がることを前提に運用する:
UNKNOWNを見たら、まずペインを目視する(--stateの値だけで結論を出さない)- 承認待ちのモーダルが出ているなら、それは本物の停止。内容を読んで代理応答の判定へ回す
- モーダルが無く入力欄が空なら、子はアイドルである。ただし
send-to-pane.shの guard は 緩めない ——--textは通らないままにする。指示を届けたいなら user に上げ、ペインへ 直接入力してもらう。人の入力は新しいprompt_idを作るので、次のターンから--stateは 正常に戻る --select/--submit/--dismissはUNKNOWNでも従来どおり働く(これらはASK_QUESTIONを根拠にするため)。選択肢で止まっている子への応答は塞がらない
--text を多用する運用では、この変更後にペイン確認の回数が実際に増える。 監視の劣化では
なく、劣化を隠さないための可視化である —— 従来はこの 6 割を RUNNING と報告していた。
auto mode の一時停止はイベントを出さない可能性がある。 通知の分類だけでは捕まらないので、 後述の沈黙ストール検知と併用する。
ASK_PERMISSION は非 auto mode でターン終了後も残りうる。 permission_prompt には
AskUserQuestion の tool_use_id に相当する決着信号が無いため、Stop との前後を到着順に頼らず
判別できない。Stop を優先すると本物の権限待ちを IDLE と誤報告して送ってはいけないペインへ
送る事故になるので、逆(偽の ASK_PERMISSION)へ倒してある。偽陽性のときは送信側が fail-closed に
落ちて何も送らないだけで、次のターンが始まれば解消する。wave の子は auto mode で起動するので
実運用での影響は小さい。
壊れた JSONL 行を落とした結果を「稼働中」と読まない。 読取側は壊れた行を読み飛ばす
(1 行の破損で監視全体を止めないため)が、落ちた行が未回答の質問や停止通知だった可能性は
排除できない。よって壊れた行があるセッションの RUNNING / IDLE は UNKNOWN へ降格する。
停止側の判定(ASK_QUESTION / ASK_PERMISSION)は既に見落とさない向きなので降格しない。
空行は破損として数えない(追記の境界で普通に生じる)。
--self-check を通してから監視を始める。hook が配線されていない、記録先に書けない、
決着イベント(PostToolUse / PostToolUseFailure)が配線されていない、
スクリプトを直接実行できない、壊れた行が記録に残っているのいずれでも検知は壊れるので、
黙って進まず報告して止まる。画面テキストへ暗黙にフォールバックしない。
壊れた行が報告されたら --purge でそのセッションの記録を消す(--state が UNKNOWN を
返し続ける理由は self-check でしか分からない)。
直接実行の検査が要る理由: chezmoi が実行ビットを落とすと exit 126 になり、呼び出し側が
|| echo UNKNOWN で吸収すると全ポーリングが UNKNOWN に落ちる。bash 経由なら self-check は
通ってしまうため、この故障モードは明示的に試さないと捕まらない。
self-check は各項目を個別に報告する。実イベントは該当状態のセッションが無ければ観測できないので、 未観測と出たものは検証されていないと読むこと。
停止したセッションの内容は --pending-question で読む(未回答の質問だけを返す)。
未回答の tool_use_id と prompt_id の対は --pending-ids で引ける(--dismiss の
書き戻しが使う。通常の監視で直接使うことはない)。
--question はターン内最後の質問を返すため回答済みでも返るので、現況の判別には使わない。
代理応答の判定(両経路に共通)
この節は経路に依存しない。 承認チャネル経路では fh approvals で読んだ内容に、tmux 経路では
--pending-question で読んだ内容に、同じ判定を当てる。
問いを読んだら、まず内容を読む。そのうえで:
無条件にエスカレートする(分類器は上書きできない):
- マージ / リリース / デプロイ
- 履歴の書き換えと強制 push
- 外部から取り消せない、または取り消しが目立つ操作(issue のクローズ、外部への通知)
一次ソース検証を通してから応答する(問いが事実主張に乗っている場合):
- 「これは既存の flaky です」/「base 側の既存の問題です」/「◯◯が存在しません」/ 「レビュー指摘に対応済みです」—— いずれも実運用で覆っている。不在の主張は特に疑う。
- これは Leader 自身の不在主張にも同じだけ当てはまる。子を反証するときこそ、自分の検索手段が
沈黙していないかを先に疑う(グローバル
CLAUDE.md「grep が無言でスキップするもの」)。
検証で覆ったら、選択肢のどれも採らずに訂正を指示する。提示された選択肢が両方とも間違っている
ことがある。承認チャネルでこれを行う経路は fh approve --deny --message ひとつしかない
(allow は自由文を運べない)。冒頭で「拒否ではなく差し戻しである」と断ってから、何がどう違うのかを書く。
検証のうえ代理応答してよい:
- review 準備への移行のような可逆な gate(差し戻せることを確認したうえで)
- コミット粒度、PR 本文の体裁、レビュー指摘への対応方針
- 設計案の選択(選択の理由が一次ソースで裏づくことを確認してから)
迷ったらエスカレートする。 分類に自信が持てない時点で、それは自動応答してよい問いではない。
→ 根拠: references/verification.md「一次ソース検証が要る事実主張」
(各主張の当て方、生 NUL を含むソースを grep が飛ばして Leader が誤って差し戻した実測)
legacy: 送信(tmux 経路)
以下は tmux 経路の記述である。 承認チャネル経路では fh approve が回答であり、この節が扱う
問題(画面参照・キー送出・プレースホルダの誤読・Esc 固着・送信結果の 3 値)はどれも発生しない。
残してあるのは、既に tmux 経路で走っている wave を捌く(drain-only)ための参照としてである。
#539 で撤去が決まっており、撤去条件を満たした時点でこの節ごと消える。この節のガードには直さないと
決めた穴が 2 件ある(question_ui_open() の default-allow、multiSelect の未対応)。新規の wave を
この経路で起動しないことが、その唯一の緩和策である。
scripts/send-to-pane.sh <pane> --session <uuid> {--select|--submit|--dismiss|--text} を使う。
キー入力そのものは tmux だが、何を送るかは hook イベントから決める。
画面参照の境界(重要)
capture-pane を使う用途を 2 つに分け、片方だけを許容する。
| 用途 | 例 | 可否 |
|---|---|---|
| 自己検証可能な局所的事実の確認 | 入力欄に本文が残っているか / 選択肢 UI が開いているか / どの問いが確定済みか / 確認画面が出ているか | 許容 |
| セッション状態の判定 | 停止か稼働か / 何を問うているか | 禁止(hook payload のみ) |
許容できる理由は失敗の方向が逆だから。旧実装は「画面を見て状態を決め、合致しなければ稼働中と 誤断定する」形で fail-open だった。上記の画面参照は「送ってよいかを決め、確認できなければ送らない」 形で fail-closed であり、TUI が変わったときに起きるのは機能停止であって無言の誤送信ではない。
選択肢ラベル → 数字キーの解決は payload から行う。画面から読むのは確定マークの個数だけ。
画面には「誰も入力していない文字列」が出る
TUI は入力欄が空のとき「次に送りそうな指示」をプレースホルダとして ANSI dim 属性(ESC[2m)で
薄く描く。tmux capture-pane -p は色と属性を落とすため、これが実入力とまったく同じ平文で返る。
$ tmux capture-pane -p -t %105 | tail -1
❯ CI green になったらその内容で投稿して ← 実入力に見える
$ tmux capture-pane -p -e -t %105 | tail -1 | cat -v
^[[39m❯ ^[[2mCI green になったらその内容で投稿して^[[0m ← ESC[2m = dim = プレースホルダ
これを「本文が入力欄に残っている」と読んで Enter を送ると、確定されるのは自分が送った本文では
なく TUI が生成した提案文になる。安全原則が名指しする事故(誰も答えていない指示が成果物に
残る)と同じ性質で、経路が違うだけ。プレースホルダの内容は直前の問いへの的確な回答に見えるので、
文面からは見分けられない(実測 3 件とも見分けられなかった)。
実際に踏んだ。 grill-me を走らせていた子がターンを終えて待機したとき、入力欄に
❯ S0 は独立 issue で。残り4件は推奨どおりで進めて と表示されていた。その子は直前に
「user の判断が要る残件」を 5 件挙げて止まっており、この文字列は 5 件すべてに的確に答える
内容だった。誰も入力していない。 確定できた根拠は capture-pane -p -e の生バイトで、
本文の直前に ESC[2m、末尾に ESC[0m が付いていたこと。
したがって:
- 画面テキストを読むときは必ず
-eを付け、dim 区間を除外してから判定する。-e無しのcapture-paneで入力欄を判定しない - 判定は
send-to-pane.shに閉じ込めてある(--textが内部で行う)。手順を自分で組み立てない - 判定できないなら
UNKNOWNとして送らない。他の信号で補おうとしない(次節)
UserPromptSubmit を「user が入力した」証拠にしない
一度は「入力欄の文字列が user 由来かを UserPromptSubmit の件数で裏取りできる」と考えたが、
これは誤りだった。実測で棄却している。
記録済みの UserPromptSubmit 68 件のうち 28 件は harness がセッションへ注入した
<task-notification> ブロック(バックグラウンドタスクの完了通知)だった。唯一の
UserPromptSubmit が task-notification だったセッションが 7 件ある。 つまり:
UserPromptSubmitは「プロンプトが投入された」ことしか意味しない。投入したのが人か harness かを区別しない- 「件数が 1 だから user 入力なし」は成り立つ場合もあるが、それは偶然そのセッションに背景通知が 来ていなかっただけで、規則としては成立しない
したがって UserPromptSubmit を「user が何かを送った」証拠として扱う実装を書かないこと。
入力欄の判定は dim 属性(ESC[2m)と「選択肢 UI が開いていないこと」の確認に閉じる。
それで判定できないなら送らない。
なお send-to-pane.sh --text は「本文を送った後に UserPromptSubmit が増えたか」で
DELIVERED を判定している。これは投入の有無を見ているので用途としては正しいが、
貼り付け直後に背景通知が届くと、自分の本文が届いていなくても増分が観測されうる
(既知の限界。失敗の向きは「配信済みと報告して再送しない」=送らない側なので致命的では
ないが、成功報告としては不正確)。#477 の射影で prompt 本文をディスクに残さない以上、
イベント側でこの 2 つを見分ける手段は無い。
プレースホルダは不可逆操作を提案してくる
観測されたプレースホルダの多くは「そのセッションが直前に尋ねた問いへの回答」だった。しかし
PR のレビューを終えた子の入力欄に ❯ #478 をマージして が dim で描かれていた(user は
一度も入力していない。そのペインへ届いたのは orchestrator の --text 2 回だけ)。
これは安全原則 1「マージは代理しない」に対する実効的な迂回路である。
従来想定した事故: 重複起動した古いセッションが、自分の判断でマージする
この経路の事故: プレースホルダを実入力と誤読した orchestrator が Enter を送り、マージされる
後者は orchestrator が状態を分類し内容も読んだうえで起きる。「user が入力したが Enter を 押していない」と解釈でき、内容がマージ指示なので「user がマージを決めたのだな」と読めてしまう。 内容が妥当に見えるかどうかで送信可否を決めてはならない理由がここにある。
したがって send-to-pane.sh は、実入力と判定できた場合でも、その本文が不可逆操作(マージ /
強制 push / クローズ / リリース / デプロイ / 公開 / 権限付与 等)を指示していれば Enter を
自動で送らず PENDING_CONFIRM で止まる。dim 判定は必要条件であって十分条件ではない ——
判定が正しくても、その入力が user の意図である保証にはならない(入力途中で放置された /
別の話題への入力の可能性がある)。
既知の限界(依存してはならない点):
- この内容ガードは固定キーワードのブロックリストであり、網羅性は保証されない。載っていない 語彙で不可逆操作を指示されればマッチしない(default-allow)。このガードがあるからと言って dim 判定を緩めてはならない —— dim 判定が主で、これは二重化にすぎない
- ガードが効くのは確定キーの再送経路だけ(初回の送信は常に通る)。したがって誤検知しても
PENDING_CONFIRMで止まって user へ上がるだけなので、網羅性を優先して広めに採っている - 「誰が書いた本文か」はイベントからは判定できない(
UserPromptSubmitは harness の背景通知 でも発火する。前節)。PENDING_CONFIRMを見たら、画面を確認したうえで user に判断を仰ぐ
fail-closed は「そう書いたつもり」では成立しない
上の節は「許容できるのは fail-closed な用途だけ」と書いているが、その fail-closed をどう実装し 損ねるかが問題になる。実運用でその場で書いた判定ワンライナーは 4 通り壊れ、4 通りとも 「実入力(=送ってよい)」側へ落ちた:
| # | 書き方 | 壊れ方 | 誤った結論 |
|---|---|---|---|
| 1 | grep -q $'\033\[2m' |
zsh のクォート解釈で \[ がリテラル化し、grep がパースエラーで非 0 |
if が else に落ち「dim が無い = 実入力」 |
| 2 | capture-pane -p -e | grep "S0 " | head -1 |
入力欄ではなく画面に残った報告本文の行にマッチ | その行に dim が無いので「実入力」 |
| 3 | sed によるエスケープ除去 |
マルチバイトで illegal byte sequence |
本文が空と判定され「空」 |
| 4 | 画面下部から ❯ の行を探す |
選択肢 UI の「選択中の項目」カーソルも ❯ |
選択肢のラベルを「実入力」 |
守るべき性質は 5 つで、send-to-pane.sh の input_box_line / input_box_has_body が満たしている:
- 対象行を入力欄の行に限定する。 画面全体を
grepしない(同じ文字列が報告本文にもある)→ 誤り 2 - 判定に失敗したら「判定不能」を返す。 grep / awk のエラー、行が取れない、属性が読めない —— どれも 「実入力あり」として返さない → 誤り 1
- バイト単位で処理する(
LC_ALL=C)。 ロケール依存の失敗経路を作らない → 誤り 3 - 選択肢 UI / 確認画面が開いている間、および
--stateがRUNNING/IDLEでない間は、 入力欄判定そのものを行わない(「適用不可」を返す)→ 誤り 4。貼り付けから確認までの間に子が 新しい質問を開くことがあるので、読む直前にもう一度状態を見る - 「空」「判定不能」「適用不可」を混同しない。 画面が取れないのに「入力欄は空。配信された 可能性が高い」と報告するのは、根拠のない断定であって fail-closed ではない
戻り値は 0=実入力 / 1=空 / 2=判定不能 / 3=適用不可 の 4 値で、確定キーを送るのは 0 かつ
本文が不可逆操作を指示していないときだけ。
実装言語も効いた。 shell のワンライナーは誤り 1〜3 のすべてで壊れた。スクリプトに落とすときは
エスケープ除去・UTF-8 の扱い・例外の一括「判定不能」化を明示的に書けることを優先する
(本 skill は awk を LC_ALL=C のバイトモードで使い、失敗経路を全部 2 / 3 へ写像している。
同じ性質を満たせない言語・書き方を選ばないこと)。
送信前に必ず確かめる 3 つ
- 対象ペインでそのセッションが生きているか — pane の子プロセスの argv に
--session-id <uuid>または--resume <uuid>があるかを見る。ペイン取り違え・古い ID・セッション終了後の shell へ 本文がコマンドとして流れ込むのを防ぐ - その選択肢が今まさに未回答で提示されているか —
wave-events.shは現在ターンかつASK_QUESTIONかつ未回答の質問に限って番号を返す - 選択肢 UI が画面に実在するか — Esc で閉じた後もイベント側の条件は成立しうる。実在を確かめ ないと、閉じた画面へ裸の数字が入力欄に打ち込まれ、後続の確定キーでそれがメッセージとして送られる
選択肢への応答は 1 問ずつ
- 数字キーのみを送り、
Enterを投機的に送らない。 claude 2.1.221 以降は数字キーが確定して 次の問いへ進むため、続けてEnterを送ると次の問いの既定値を確定させる。実測で発生し、 誰も答えていない問いに✔が付いた —— 安全原則 2 が名指しする事故そのもの - 複数問を 1 回の呼び出しで捌かない。 1 問ずつ送り、そのつど確定を検証してから次へ
- 問い数や
previewの有無から安全性を推論しない。 単一問でも確定しない例(preview / notes 付き)が 実測されている。確定するかは送って検証するしかない - 確定しなかったときだけ
Enterを 1 回送る。--selectがこの手当てを内包している - 全問確定後の確認画面(
Ready to submit)は--submitで確定する - 存在しないラベル・複数の問いにある同名ラベルを指定すると送信せずに落ちる
自由記述は選択肢を閉じてから
--textはASK_QUESTIONのとき本文を送らずに中止する。暗黙にEscapeを送って状態を 変えない ——Escapeが効かなかったときに本文がキー入力として解釈され、誰も選んでいない回答が 確定する(最も危険なタイミングで壊れる)- 閉じたいときは
--dismissを明示的に使う。Escape送出後に閉じたことを確認してから 成功を報告し、閉じなければ非 0 で終了して何も送らない --dismissは「人が Esc で閉じた後」の唯一の回復手段でもある(下記)
Esc キャンセルはイベントを出さない —— 固着と回復
実機検証で確認した事実: 選択肢を Esc で閉じても hook イベントが 1 件も発火しない
(PostToolUse も PostToolUseFailure も、Stop すら出ない。30 秒待って増分ゼロ)。
PostToolUse 自体は正常回答で発火することを同じセッションで確認済みなので、これは
配線の問題ではない。
つまり「人が Esc で閉じた」遷移だけはイベントから知る手段が無く、--state は
ASK_QUESTION に固着する。この状態では次のすべてが拒否され、Leader は手詰まりになる:
| 操作 | 挙動 |
|---|---|
--select |
画面に選択肢 UI が無いので拒否(数字キーが入力欄へ入るのを防ぐ) |
--text |
state=ASK_QUESTION なので拒否(#445) |
--purge 後の --text |
state=UNKNOWN なので拒否 |
回復は --dismiss で行う。選択肢 UI が画面に無いことを確認できた場合、
--dismiss は決着イベントを記録側のログへ書き戻して固着を解除し、
ALREADY_CLOSED を返す(--state は RUNNING へ戻り、--text が通る)。
書き戻す行には "synthetic": "dismiss" の印が付き、本物の hook イベントと区別できる。
--dismiss が Escape を送って閉じた場合も同じ書き戻しを行う(Escape も
イベントを出さないため)。書き戻せなかった場合は UNVERIFIED を返し、固着したままで
あることを明示する(黙って成功にしない)。
監視中に ASK_QUESTION が続くのに画面に選択肢 UI が無いときは、この固着を疑って
--dismiss を打つこと。
- 本文は bracketed paste で送る(
send-keys -lは使わない)。長文で確定キーを取りこぼす問題と、 本文がキーとして解釈される問題の両方が送出方式に起因していた
送信結果は 3 値。「失敗」と断定しない
UserPromptSubmit はキュー経由で届いたメッセージでは発火しない(ターン開始時のプロンプトでしか
出ない)。これ単独では成否を判定できないので、二値の成否をやめた。
| 結果 | 意味 | 呼び出し側の対応 |
|---|---|---|
DELIVERED |
ターン開始を確認 | 次へ進む |
QUEUED_UNCONFIRMED |
本文は手元を離れたがターン開始は未確認 | 再送しない(二重キューになる) |
PENDING_CONFIRM |
本文が入力欄に残っている | 本文を再送しない(連結して二重入力になる)。Enter のみ |
UNVERIFIED |
送ったが確定を検証できない | 画面を確認する |
| ガード失敗(exit 1) | 何も送っていない | 原因を直して再実行してよい |
exit code ではなく標準出力の先頭トークンで分岐する。 「何かを送ったか」だけを知りたいなら
exit 1 かどうかを見る。PENDING_CONFIRM で入力欄をクリアしない(届いた指示を捨てることになる)。
- 送信文に確定キーや制御文字として解釈される文字を含めない
成果物の検証
セッションが「完了しました」と報告しても、報告を成果物の検証に代えない。マージ可否を user へ 上げるときは、自分で確認した事実を添える: CI の集計、未解決レビュースレッド数、base からの遅れ、 受け入れ条件を満たすテストの実在、規約違反語(AI クレジット等)の混入有無。
この 5 項目を満たしても取れない欠陥の類がある。リストを埋めた時点で検証が終わったと読まない。
問い方を 1 つ足す。「直ったか」ではなく「そこに着けるか」。 受け入れ条件を、実装した層ではなく 利用者の到達経路で言い直す。
具体例: 子が指示した gate を通ったかは、まず harness の記録から引く。 Phase 2 で --gate を
宣言してあるので、その結末は run へ連結されている。adapterRunId は起動時 JSON で控えた値を使う。
fh runs --run "<adapterRunId>" --json \
| jq '{status: .run.status, verification: .run.verification, verifications}'
.run.verification が集計(total / passed / …)、verifications が 1 件ずつの記録である。
後者の command まで見る —— 何を完了条件にしたかは、そこにしか残らない。
| 見るもの | 何の証拠か | 使い方 |
|---|---|---|
連結された検証結果の件数(一覧なら各 run の verification の total / passed / failed / errored / skipped) |
**宣言した完了条件が実際に走って通ったか。**コミット時刻にも投稿形式にも依存しない | 一次判定。 total が Phase 2 で宣言した本数と一致し、その全部が passed のときだけ gate 通過と読む |
gh pr view <n> --json reviews が 0 |
レビューが投稿されていない直接の証拠 | 併用。 gate に載るのは承認済みのタスクランナーコマンドだけで、レビューが回ったかは見ていない。0 件は未通過の証拠になるが、1 件以上は通過の証明にならない(投稿形式に依存する。同じ波でも 1〜10 とばらついた) |
全コミットの committedDate |
判定に使わない(下記) |
total: 0 を「gate が無かった」と読まない。 Phase 2 で宣言は必須なので、0 は**「1 本も通して
いない」**である。status: "succeeded" が意味するのは「ターンがエラーなく終わった」だけで、指示した
gate を通ったことではない。
緑の gate を「変更が検証された」と読まない。 主張できるのは**「harness が承認済みコマンドを走らせて
0 が返った」**までである。判定は終了コードだけを見るので、gate コマンドの定義そのもの(Makefile /
package.json)を書き換えた子は全 passed を出せる。緑を見たら、その定義が差分で変わっていないかを
併せて見る。
rebase 済みの PR では信号 2 を根拠に使わない。 committedDate は committer date で、git rebase は
replay した全コミットにこれを打ち直す。よって base 追従で rebase した PR は全コミットが「PR 作成後」に
見え、信号 2 は常に偽になる —— 判定は**「対応した」側(fail-open)へ倒れる**。rebase したという理由
だけで検査が無言で通る。補助信号としても残さない: AND に組めば rebase 済み PR で他の信号まで殺し、
OR に組めば単独で通過を作れるので、限定した使い方が構成できない。
gate を宣言できない、または記録を引けないときは、通過を推定せず判定不能として user へ上げる。
承認済みのタスクランナーが無いリポジトリでは --gate を 1 本も宣言できず、そのとき残るのは信号 1
だけなので gate 通過は言えない。
この問いに Leader が毎回答えられるとは限らない(実機・サインイン・本番データが要る)。 答えられないなら、答えられないと言って user へ渡す —— 利用者の到達経路が結果を左右しうる変更では、 マージ前に user の実機確認を選択肢として出す。答えられないことを隠して規約にすると、守られない 規約が 1 つ増えるだけである。
→ 根拠: references/verification.md「5 項目を満たしても取れない欠陥」
「子が gate を通ったかをどう引くか」、references/liveness.md「記録側で
判別できるようになった」
中断と再開
quota が閾値帯に達したら止めて次のウィンドウで再開する、というのは quota 節が示唆する正常な運用で あって例外ではない。再開には環境が真実を持たなくなるという落とし穴がある。
- 起動時に session id を控える(
fh session launchが起動直後に stderr へ出し、完走時の JSON にもsessionId/resumeKeyとして載せる)。この 1 点だけは state file に持つ。 - 再開は
fh session resume --resume-key <id>で行う。 起動時検査・承認チャネルの配線・sandbox policy は launch と同じ形で適用される(弱まらない)。 - 再開もペインの中から起こす。「起動する」節の指示は resume にもそのまま全部効く —— ペインで
走らせること、
CLAUDE_CONFIG_DIRを明示すること、子の stderr をファイルへ残さないこと、--capabilityを明示すること。Leader の shell から直接(あるいは&で背景に)叩かない。 ペインの外から resume すると、「正常に終了した子」と「窓を失った稼働中の子」が画面上で区別できなく なる(ペインは空白にならず、前のセッションの終了時 JSON を表示し続ける)。 - resume も、監視を arm するまでが 1 手順である。 resume は launch と同じく「子が走り出す」操作 なので、監視の必要性は変わらない。前の監視が子の終了で正常終端していることに注意する。
--capabilityは resume でも必ず渡す。継承されない(resume-key から元の capability を引く経路が 無い)。現行 registry では既定が最も強いのでフォールバックは上振れするが、その前提に寄りかからない —— 上振れは quota を浪費するし、既定より弱い capability が既定になれば向きが反転する。- 中断そのものは承認チャネルでも起きる。 承認要求は既定 8 時間で自動 deny になるので、長く放置した
wave は「通った」ではなく「拒否されて止まった」状態になっている。再開前に
fh approvals --all --jsonで決着を確認する。 - 重複起動を検査する。 同じワークツリーで別の子が走っていないか、同じ label の adapter run が running のままになっていないかを見る。
- 再開したペインのタイトルを再設定する(停止でタイトルが消えるペインがある)。
- 人が直接介入したいときは
claude --resume <session-id>で対話セッションとして開き直す。
→ 根拠: references/resume-and-teardown.md
Phase 4: 後始末
user がマージを承認したら、各セッションに「マージ → 自分のワークツリーとブランチの削除」までを 指示する。共有リポジトリの既定ブランチ更新は含めない(同時実行でロック競合する)。全セッションの 完了後に orchestrator が 1 回だけ更新する。
セッションの終了は user の承認を得てから。終了前にプロセスの身元を確認する(PID は再利用される)。
fh のプロセスを撃たない。監督者だけが死ぬ。 fh を撃つと、その下の claude -p は ppid=1 へ
reparent されて走り続け、user の窓・harness の実行記録・監督の 3 つを同時に失う。
撃つのは worker(claude -p)かプロセスツリー全体である。 そうすれば fh が子の終了を観測して
結末を記録し、最終 JSON をペインに残して自分も終わる。記録も窓も保たれる。
- 「身元を確認する」は撃つ対象が正しいことを保証しない。確認すべきは「これは監督者か worker か」で ある。
- 停止の完了は
psで判定しない(監督者しか見ていないので、孤児が残っていても消えたように見える)。fh runsに結末が記録されたことを完了の判定にする。 fhには stop / kill / cancel に相当するサブコマンドが無い。正規の停止手段が無いので、シグナルを 撃つ側が上記を守るしかない。
会話内容を残さない。 承認要求の payload には質問文と選択肢が入っている。決着済みだけを消す:
fh approvals --purge --json
pending は消えない。まだ答えていない問いがあるなら、それは掃除ではなく対応の対象である。
実行記録は残る(capability / provider / model / effort / 状態 / 時刻 / exit code / resume key /
拒否されたツール名 だけで、会話内容は入らない)。保持期間は fh clean の retention に従う。
tmux 経路で走らせた wave なら、hook が記録したイベントも消す:
wave-events.sh --session <uuid> --purge
→ 根拠: references/resume-and-teardown.md「fh のプロセスを撃つと
監督者だけが死ぬ」
state file
環境から復元できないものだけを持つ。置き場は scratchpad で十分。
持つもの: wave 計画と根拠、委任境界の合意、quota 方針、各セッションへ出した訂正の履歴、 user が保留にしている判断、各子の session id(識別子との対応)。session id は Leader が採番した 値であり、子が止まると環境からは引けなくなるので、ここだけは記録する。
持たないもの: セッションの生死、PR の状態、CI、ワークツリー。これらは tmux と gh が真実を
持っているので毎回取り直す。キャッシュした状態は実態とずれたときに嘘をつく。
orchestrator 自身が長時間走ってコンテキストを消費することを前提に置く。落ちても後続が state file と 環境から再構成できる形にしておく。
quota
起動前と各 wave 境界で、親と同じ profile のプールを読む。読み先と閾値帯は
/model-fitness-check が定義済みなのでそれに従う。閾値を超えるときは黙って進まず、
並列度を下げる・重い tier を後回しにする・そのまま進む、の選択肢を出して user に決めさせる。
効くレバーと効かないレバーを取り違えないこと。 消費の大半は各セッション本体(差分読解・実装・ 統合)であって、レビュー roster ではない。削る前に、実際に何が起動しているかを確認する。
他 skill との連携
| skill | 関係 |
|---|---|
repo-radar |
収集の起点。その出力を Phase 1 の入力に取れる |
frontier-harness(fh) |
子の起動(fh session)と承認チャネル(fh approvals / fh approve)。capability registry・承認境界・rollout・Evidence Bus の SSOT。起動フラグを本 skill 側で組み立てない |
pr-workflow |
各子セッションが実行するもの。本 skill は起動と gate の捌きに徹し、中身に踏み込まない |
model-fitness-check |
model/effort contract と quota 閾値の SSOT。再掲しない |
wtp |
ワークツリーの作成と削除。子セッションが pr-workflow 経由で使う |
issue-fleet |
浅く広く(trivial/small、draft PR 止まり)。tier が軽いならこちら |
review-fleet |
レビュー依頼のバッチ処理。守備範囲が違う |
落とし穴
- issue 本文の前提は腐る。 計画に載せる前にコードで確かめる。
- セッションは自分の変更範囲を過小に見積もる。 共有の seed ヘルパーやフィクスチャ経由で繋がって いるケースを疑う。
- squash merge のリポジトリでは、PR 内のコミット順序は既定ブランチの bisect に影響しない(1 PR が 1 コミットに潰れるため)。取り違えると不要な履歴書き換えを要求してしまう。
- PR 本文に「ユーザー承認済み」等の記述が出たら、実際に承認があったかを確認する。 代理応答の事故は こういう形で成果物に残る。
- 承認チャネルが配線されていない子は、止まらずに「やったはずのことをやっていない」(#526 §1.2.5)。
だから起動を
fh sessionに寄せ、フラグを手で組み立てない。fhが拒否したら、それは回避する対象 ではない。 fh sessionが exit 2 を返したら、それは失敗ではなく承認待ちである。fh gapsで何が未承認かを 見て、fh onboardの儀式を通す。exit code を 0 と混同すると「起動したつもり」になる。- 承認要求を放置すると自動 deny になる(既定 8 時間)。 長時間の中断後は
fh approvals --allで 決着を見る。 - 画面から読んだ「入力欄の本文」は、誰も入力していない提案文かもしれない。(legacy: tmux 経路) → Phase 3「画面参照の境界」
- bash 前提のシェル片は zsh で静かに壊れる。(legacy: tmux 経路)失敗が例外ではなく「0 件ヒット」と して出る。→ Phase 3「監視スクリプトは zsh で動く」
git reset等が権限層に拒否されることがある。 回避策を探す前に、その操作が本当に必要かを問い直す。 部分コミットならパス指定コミットで済むことが多い。どうしても必要なら user に上げる。