diff --git a/docs/glossary.md b/docs/glossary.md
index cddb8f0fc..a045e0563 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -786,6 +786,7 @@ ai-plugins の開発で formatter と静的解析を手元と CI で走らせる
| 古いバイトコード | — | Python が、書き換えたソースを、前の内容から作った .pyc で読むこと。.pyc はソースの更新時刻(秒)と大きさだけで新しさを照らすため、同じ秒に同じ大きさで書き換えると見分けられない | — | — | `docs/specifications/cross-refactoring-test-stale-bytecode.md` |
| git を使うテストの土台 | — | cross-refactoring のテストで、git のリポジトリの作業ディレクトリ・pytest の起動口・状態ファイルを用意する補助(crossref_helpers.build_git_flow) | — | — | `docs/specifications/cross-refactoring-test-stale-bytecode.md` |
| CLI の版の一覧 | — | スモークの像へ入れる 4 ランタイムの CLI の版数と取得先と checksum を書いたファイル(tests/runtime-smoke/cli-versions.env)。版を上げるときに書き換える唯一の場所 | — | — | `docs/specifications/runtime-smoke-cli-versions.md` |
+| 前提のコマンドの一覧 | `required_commands` | 根の conftest.py が持つ、テストの置き場所(パス、またはパスに :: でつないだファイルの中のクラス)ごとに要る外部コマンドの表。収集した項目に当たるコマンドが PATH に無ければ、読み飛ばしの指定が無い限り収集を失敗させる | — | — | — |
## NDF の学習の設定(`ndf-training-optout`)
diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json
index bdd8b8390..55ee098d3 100644
--- a/docs/glossary/glossary.json
+++ b/docs/glossary/glossary.json
@@ -5456,6 +5456,13 @@
"context": "ndf-workflow",
"meaning": "関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの",
"pending_source": "issues/issue-2369-design.md"
+ },
+ {
+ "term": "前提のコマンドの一覧",
+ "code": "required_commands",
+ "context": "repo-dev-checks",
+ "meaning": "根の conftest.py が持つ、テストの置き場所(パス、またはパスに :: でつないだファイルの中のクラス)ごとに要る外部コマンドの表。収集した項目に当たるコマンドが PATH に無ければ、読み飛ばしの指定が無い限り収集を失敗させる",
+ "pending_source": "issues/issue-2346-design.md"
}
]
}
diff --git a/issues/issue-2346-design.md b/issues/issue-2346-design.md
new file mode 100644
index 000000000..6f097807e
--- /dev/null
+++ b/issues/issue-2346-design.md
@@ -0,0 +1,272 @@
+# relay の統括役の結合テスト: tmux を使う 4 件が手元と CI で毎回走り、落ちたときに原因を読める(#2346)
+
+## 目的
+
+- **今どうなっているか**: 名前つきのラッパーを tmux で動かす結合テスト 4 件は、tmux の無い実行環境では、テストの側の読み飛ばし(`skipif`)で黙って飛ばされる。そのときの終了コードは、通過と同じ 0 である。待ちが切れて落ちたときの出力は統括役の画面と記録だけで、名前つきのラッパーの区間ごとの起動とシグナルファイルが見えない
+- **誰に効くか**: relay の統括役を直す開発者と、CI の結果で受け入れ条件 3・8 を確かめるレビュアー
+- **変えると何が成り立つか**: tmux が無い実行環境では、テストの実行が tmux を名指しして失敗する。CI では 4 件が毎回走り、結果を名前で読める。待ちが切れたときは、どの区間が起動し、どのシグナルファイルが置かれていたかを失敗の出力から読める
+
+## 適用範囲
+
+- **働く範囲**: このリポジトリのテストの実行と CI だけ。配布物(`relay.py`・`relay_lib/`)の振る舞いは変えない
+- **プロジェクトごとに違うもの**: 無し(このリポジトリの開発の検査だけに効く)
+- **当たるモード**: `standard`
+
+## あるべき姿の根拠
+
+| 根拠 | 種類 | 何を示すか |
+| --- | --- | --- |
+| CI の実行 41945018391(develop、2026-11-05)の分割 0 のジョブが「5375 passed」で skipped が 0 件。`test_relay_hub.py` はファイルのパスの CRC32 を 2 で割った余りが 0 で、分割 0 に入る | 実測 | CI の runner(`ubuntu-latest`)では tmux が PATH にあり、4 件は今も走っている。runner のイメージの定義(`actions/runner-images` の `toolset-2404.json` の apt の一覧)には tmux が無いため、イメージの土台に含まれているだけで、入っている保証は無い |
+| 開発コンテナで `pytest plugins/ndf/scripts/tests/test_relay_hub.py --collect-only -q` を `plugins/ndf/scripts` から打つと、項目の識別子が `plugins/ndf/scripts/tests/test_relay_hub.py::test_spawn_ls_stop_and_hub_handover` と出る(48 件) | 実測 | 項目の識別子は、起点のディレクトリに関わらずリポジトリの根からの相対パスで始まる。前提のコマンドの一覧のキーを、パスの後ろへ `::` で延ばせる |
+| リポジトリの根の `conftest.py` の注記「読み飛ばしを既定にすると、テストの 9 割以上が消えても終了コード 0 で終わる」 | 既存の決定 | 不足を失敗にする仕組みは既にあり、tmux はその一覧に載っていないだけである |
+
+要求と受け入れ条件は #2346 の本文にある(コピーは [issues/issue-2346-requirements.md](issue-2346-requirements.md) )。この文書は受け入れ条件 28〜32 の「どう作るか」だけを扱う。受け入れ条件 1〜27 は ndf v10.27.14 で配布した。その内容は確定仕様 [docs/specifications/ndf-relay-multi-session-supervisor.md](../docs/specifications/ndf-relay-multi-session-supervisor.md) が持つ。
+
+## ドメインモデル
+
+### コンテキスト
+
+| コンテキスト | 何の語が 1 つの意味に決まるか |
+| --- | --- |
+| このリポジトリの開発の検査(`repo-dev-checks`) | テストの実行に要る外部コマンド・読み飛ばしの指定・CI のテストのジョブ |
+
+relay のラッパー(`ndf-relay`)と統括役(`ndf-workflow`)の語は、テストが名前を引くだけで、意味を変えない。
+
+### 集約
+
+| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト |
+| --- | --- | --- | --- | --- |
+| 前提のコマンドの一覧 | 根の `conftest.py` | 一覧(`REQUIRED_COMMANDS`) | キー(テストの置き場所) | 外部コマンドの名前の組 |
+| tmux を使う結合テストの組 | `plugins/ndf/scripts/tests/test_relay_hub.py` | テストのクラス `TestWithTmux` | 4 件のテスト | — |
+| 擬似端末のテストの失敗の文 | `plugins/ndf/scripts/tests/test_relay.py` の `Term` | 失敗の文 | 作業ディレクトリごとの段落 | 区間の起動の引数・ファイルの名前の一覧 |
+| CI のテストのジョブ | `.github/workflows/pytest.yml` | 分割のジョブ(2 本) | 手順 | JUnit XML の成果物 |
+
+### 不変条件
+
+| # | 集約 | 条件 | 破れたときの扱い |
+| --- | --- | --- | --- |
+| I1 | 前提のコマンドの一覧 | tmux を使う 4 件は一覧のキーに当たる。tmux が PATH に無く読み飛ばしの指定も無ければ、収集が失敗し、出力が tmux を名指しする | 実行の全体が 0 以外で終わる(E14) |
+| I2 | 前提のコマンドの一覧 | 読み飛ばしの指定があるとき、skipped になるのはキーに当たる項目だけである。`test_relay_hub.py` の残りの 44 件は走る | 指定ありでも、キーの外の項目は今どおり実行する |
+| I3 | 前提のコマンドの一覧 | キーが項目に当たるのは、識別子がキーと一致するか、キーの直後が `/` か `::` のときだけである(`TestWithTmux` は `TestWithTmuxOld` に当たらない) | 境界の外の項目には前提を課さない |
+| I4 | tmux を使う結合テストの組 | 4 件はテストの側に読み飛ばしの判定を持たない。tmux の有無は I1 の一覧だけが決める | — |
+| I5 | 擬似端末のテストの失敗の文 | 待ちが切れた・終わらないときの失敗の文に、状態の置き場の作業ディレクトリごとに、ディレクトリの名前・ラッパーの名前・そのディレクトリで起動した区間の引数・ディレクトリにあるファイルの名前の一覧が載る | — |
+| I6 | CI のテストのジョブ | テストを始める前に tmux が PATH にあり、版を 1 行出す。テストの後は、成否に関わらず分割ごとの JUnit XML を成果物に残す | tmux を用意できなければ、テストの手順で I1 により落ちる(E15) |
+| I7 | (全体) | `relay.py` と `relay_lib/` に差分が無い | 実装の PR の完了判定で落とす |
+
+### ドメインイベント
+
+| # | イベント | 発生元 | 受け手 |
+| --- | --- | --- | --- |
+| E13 | テストの実行が、tmux を使う結合テストを集めた | pytest の収集 | 根の `conftest.py` の照合 |
+| E14 | tmux が PATH に無く、テストの実行が失敗として tmux を名指しした | 根の `conftest.py` の照合 | テストを打った人・CI のジョブの結果 |
+| E15 | CI のテストのジョブが tmux を使える状態でテストを始めた | `pytest.yml` の tmux の手順 | テストの手順 |
+| E16 | 結合テストの待ちが切れ、ラッパーの記録を失敗の出力に出した | `Term` の待ち | テストの失敗の出力(手元の端末・CI のログと JUnit XML) |
+
+### 用語
+
+| 用語 | 意味 | 用語集への反映 |
+| --- | --- | --- |
+| 前提のコマンドの一覧 | 根の `conftest.py` が持つ、テストの置き場所(パス、またはパスに `::` でつないだファイルの中のクラス)ごとに要る外部コマンドの表。収集した項目に当たるコマンドが PATH に無ければ、読み飛ばしの指定が無い限り収集を失敗させる | 追加(`repo-dev-checks`) |
+
+## 機能一覧
+
+| # | 機能 | 誰が使うか |
+| --- | --- | --- |
+| F1 | tmux が無い実行環境で、tmux を使う結合テストが tmux を名指しして失敗する(指定があれば 4 件だけを飛ばす) | テストを打つ開発者・CI |
+| F2 | CI のテストのジョブが tmux を用意し、4 件の結果を名前で読める成果物を残す | レビュアー・リリースの担当 |
+| F3 | 擬似端末の結合テストの待ちが切れたとき、作業ディレクトリごとの区間の起動とファイルを失敗の出力へ出す | 結合テストの失敗を調べる開発者 |
+
+## 構成要素
+
+| 要素 | 責務 |
+| --- | --- |
+| 根の `conftest.py` の照合(`_bundle_of` を項目で引く形へ変える) | 項目の識別子(根からの相対パス、続けてファイルの中の位置を `::` でつないだもの)を一覧のキーと照らす。境界は I3 |
+| 前提のコマンドの一覧に足すキー | `plugins/ndf/scripts/tests/test_relay_hub.py::TestWithTmux` に `("tmux",)` |
+| `test_relay_hub.py` の `TestWithTmux` | tmux を使う 4 件をまとめるクラス。`needs_tmux` の `skipif` を消す |
+| `test_relay.py` の `Term` の失敗の文 | 状態の置き場を読み、I5 の段落を組む関数を足し、`wait` と `finish` の失敗の文へ載せる |
+| `scripts/tests/test_root_conftest.py` | I1〜I3 を、PATH から tmux を外した子プロセスの pytest で確かめる |
+| `.github/workflows/pytest.yml` の分割のジョブ | tmux が無ければ apt で入れ、版を出す手順。pytest に JUnit XML の出力を足し、成果物として残す手順 |
+
+```mermaid
+graph TD
+ subgraph CI[CI のテストのジョブ]
+ Prep[tmux の用意
と版の表示]
+ Run[pytest の実行]
+ Art[JUnit XML
の成果物]
+ end
+ subgraph Root[根の conftest.py]
+ Table[前提のコマンド
の一覧]
+ Match[項目の照合]
+ end
+ subgraph Tests[plugins/ndf/scripts/tests]
+ Cls[TestWithTmux
の 4 件]
+ Term[Term の
失敗の文]
+ end
+ Prep --> Run
+ Run --> Match
+ Match --> Table
+ Match --> Cls
+ Cls --> Term
+ Run --> Art
+```
+
+図に含めない要素: `scripts/tests/test_root_conftest.py`(照合を子プロセスの pytest で確かめるテストで、実行の流れには現れない)。
+
+### システムの文脈と配置
+
+```mermaid
+graph TD
+ Runner[GitHub Actions の runner
ubuntu-latest]
+ Apt[Ubuntu の apt]
+ Store[GitHub の
成果物の置き場]
+ Runner -->|tmux が無いときだけ| Apt
+ Runner -->|分割ごとに 1 つ| Store
+```
+
+テストは手元の開発コンテナと CI の runner の 2 か所で動く。どちらでも、統括役のテストは使い捨ての tmux サーバだけを使い、利用者の tmux サーバに触れない(今のフィクスチャ `hubenv` のまま)。使い捨ての tmux サーバは、`TMUX_TMPDIR` を一時ディレクトリに向けたものである。
+
+### パッケージ・モジュール構成
+
+```text
+.
+├── conftest.py # 照合を項目の識別子で行う・一覧にキーを足す
+├── .github/workflows/pytest.yml # tmux の用意・JUnit XML の成果物
+├── scripts/tests/test_root_conftest.py # I1〜I3 のテスト
+└── plugins/ndf/scripts/tests/
+ ├── test_relay.py # Term の失敗の文(I5)
+ └── test_relay_hub.py # TestWithTmux(I4)
+```
+
+## 構造
+
+```mermaid
+classDiagram
+ class Term {
+ +env
+ +fake_dir
+ +wait(pred, timeout, what)
+ +finish(timeout)
+ +starts()
+ +relay_dirs()
+ +rows()
+ }
+ class TestWithTmux {
+ +test_spawn_ls_stop_and_hub_handover(hubenv)
+ +test_second_claude_attaches_and_survives_terminal_close(hubenv)
+ +test_limit_refuses_from_terminal_and_spawn(hubenv)
+ +test_killed_named_wrapper_shows_stopped(hubenv)
+ }
+ TestWithTmux ..> Term : hubenv が作る
+```
+
+`Term` に足すのは、状態の置き場から失敗の文の段落を組む処理だけである。`wait` と `finish` の引数と戻り値は変えない。`TestWithTmux` の 4 件はクラスへ移すだけで、本体を変えない。
+
+## 処理の流れ
+
+収集の時点の照合(F1):
+
+```mermaid
+sequenceDiagram
+ participant P as pytest
+ participant C as 根の conftest.py
+ participant T as 前提のコマンドの一覧
+ P->>C: 収集した項目(分割の後)
+ C->>T: 項目の識別子に当たるキー
+ T-->>C: キーと外部コマンドの組
+ alt どれも PATH にある
+ C-->>P: 何もしない
+ else 不足があり指定が無い
+ C-->>P: UsageError(キーとコマンドの名前)
+ else 不足があり指定がある
+ C-->>P: 当たる項目だけに skip の印
+ end
+```
+
+待ちが切れたとき(F3): `Term.wait` が上限に達すると、今の画面と記録に続けて、次の段落を `AssertionError` に載せる。段落は、状態の置き場(`$HOME/.local/state/ndf/relay/`)の下のディレクトリを名前の順に読み、1 つずつ組む。統括役の置き場(`hub/`)も同じ形で載せる。
+
+| 段落に載せるもの | 出所 |
+| --- | --- |
+| ディレクトリの名前とラッパーの名前 | ディレクトリの名前・`name` ファイル(無ければ空) |
+| そのディレクトリで起動した区間の引数 | 偽の claude が書く `starts.jsonl` のうち、`relay_dir` がそのディレクトリの行の `argv` を起動の順に |
+| ディレクトリにあるファイルの名前 | ディレクトリの直下のファイルの名前を名前の順に(`next.json`・`stop`・`relay.pid` などの有無がここで読める) |
+
+ファイルの名前は決め打ちの一覧で調べず、ある分をすべて出す。シグナルファイルの種類が増えても、失敗の文の側を直さずに済む。
+
+CI のテストのジョブ(F2): 分割の 2 本それぞれで、次の順に進む。
+
+1. checkout
+2. Python・Node・uv の用意
+3. tmux の用意(`command -v tmux || { sudo apt-get update && sudo apt-get install -y tmux; }`)と `tmux -V`。apt の索引が古いと `install` が取得で失敗するため、入れる経路では `update` を `install` の直前に打つ
+4. pytest(`--junitxml` を足す)
+5. 成果物の保存(`if: always()`)
+
+## 非機能の実現方式
+
+要求の非機能の条件のうち、この変更が関わるのは 2 項目である。可用性・運用・保守性・移行性・セキュリティの各項目は配布済みの統括役の振る舞いに掛かり、この変更は振る舞いを変えない(I7)。
+
+| 大項目 | 実現方式 |
+| --- | --- |
+| システム環境 | CI は `ubuntu-latest`。tmux はイメージの土台にあるものを使い、無いときだけ apt で入れる。版は `tmux -V` でログに残し、開発コンテナ(3.6)との違いを失敗の調査で読めるようにする |
+| 性能・拡張性 | tmux がある今の runner で足すのは `command -v tmux` と `tmux -V` の 2 つで、1 秒未満。JUnit XML の書き出しと成果物の保存は数秒。apt で入れる経路を通るときは `apt-get update` を含めて 60 秒以内(未確認。下の「未確認のまま残ること」) |
+
+## 決定の記録
+
+### 決定 1: 4 件だけに tmux を課すため、テストのクラスにまとめ、一覧のキーをパスに `::` でつないだ形まで広げる
+
+前提のコマンドの一覧は、今はパスの前方一致だけで項目を引く。ファイル全体をキーにすると、tmux を使わない 44 件も tmux の無い実行環境で失敗し、読み飛ばしの指定では一緒に飛ばされる。4 件をクラス `TestWithTmux` にまとめ、キーを `plugins/ndf/scripts/tests/test_relay_hub.py::TestWithTmux` にする。そうすれば、一覧の 1 行で 4 件だけを指せる。キーに 4 件のテストの名前を並べる形も取れるが、テストの名前を変えたときに一覧の行が当たらなくなる。クラスなら、クラスへ足したテストにも同じ前提が掛かる。
+
+4 件を別のファイルへ移す形は採らない。受け入れ条件 28 は `test_relay_hub.py` を打って tmux を名指しすることを求めており、移すとこのファイルは tmux を要らなくなる。テストに印(marker)を付けて照合で読む形も採らない。前提を書く場所が一覧と印の 2 か所になり、要求の前提 11(同じ役割の判定を別に作らない)に合わない。
+
+根拠: Value 7(MVV 版 5)
+
+### 決定 2: tmux の有無を 1 か所で決めるため、テストの側の `skipif`(`needs_tmux`)を消す
+
+一覧に載せた後も `skipif` を残すと、読み飛ばしの指定が無いときは収集の時点で先に失敗するため働かず、指定があるときは一覧と同じ結果を別の理由の文で出す。判定が 2 か所にあると、片方だけを直したときに食い違いが見えない。消して、I4 のとおり一覧だけが決める形にする。
+
+根拠: Value 7(MVV 版 5)
+
+### 決定 3: runner のイメージが変わってもジョブが止まらないよう、tmux は無いときだけ apt で入れる
+
+今の runner には tmux があり(あるべき姿の根拠の 1 行目)、入れる手順が無くても 4 件は走る。ただし runner のイメージの定義に tmux は載っておらず、土台の変更で消えると、一覧に載せた後はすべての Pull Request のテストのジョブが落ちる。`command -v tmux` で確かめ、無いときだけ `sudo apt-get update` と `sudo apt-get install -y tmux` を続けて打つ。ある間は時間を足さない。毎回 `apt-get install` を打つ形は、ある間も `apt-get update` の時間を毎回払うため採らない。手順を足さずに一覧の失敗に任せる形は、イメージが変わった日から修正が入るまで、無関係な変更のマージが止まるため採らない。
+
+根拠: Value 1 / Value 3(MVV 版 5)
+
+### 決定 4: 4 件の結果を名前で読むため、分割ごとの JUnit XML を成果物に残す
+
+今のジョブは `-q` で走り、ログには件数しか出ない。JUnit XML には項目ごとの名前と結果(通過・失敗・skipped)が残り、`gh run download` で取り出して 4 件を名前で確かめられる。ログに全項目を出す `-rA`・`-v` は、分割の 2 本で合わせて 11,000 件を超える行がログに並び、ほかの失敗を探しにくくするため採らない。4 件だけを別の手順で打ち直す形は、同じテストを 2 回走らせ、本体の実行で走ったかの証拠にならないため採らない。
+
+根拠: Value 4(MVV 版 5)
+
+### 決定 5: どの擬似端末のテストでも原因を読めるよう、失敗の文は `Term` の共通の処理に足す
+
+2026-11-04 の失敗は、統括役の画面と記録だけでは、名前つきのラッパーが区間 2 を起動しようとしたかどうかが読めなかった。区間の起動の引数とファイルの一覧は、統括役のテストに限らず `Term` を使うすべての擬似端末のテストで、待ちが切れたときに役立つ。`test_relay_hub.py` の側に包む処理を作る形は、同じ役割の処理が 2 か所に分かれるため採らない。
+
+根拠: Value 7(MVV 版 5)
+
+## テスト設計
+
+| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか |
+| --- | --- | --- |
+| 受け入れ条件 28・I1 | PATH から tmux を外して `test_relay_hub.py` を子プロセスの pytest で打つと、0 以外で終わり、出力にキーと `tmux` が出る | 一覧からキーを消す、または照合がパスだけを見る形に戻すと、終了コードが 0 になって落ちる |
+| 受け入れ条件 28・I2 | 同じ PATH で、子プロセスの対象を `test_relay_hub.py::TestWithTmux` とキーの外の 1 項目 `test_relay_hub.py::test_bad_name_is_refused[A]` のノード ID に絞り、読み飛ばしの指定を付けて打つと、終了コード 0 で、skipped がちょうど 4 件(4 件の名前)・passed が 1 件(外の 1 項目)。ファイル全体を打たないのは、tmux を使わない 44 件を全体の実行のたびに子プロセスでもう一度走らせないためである | キーをファイル全体にすると、外の 1 項目も skipped になり passed が 0 件で落ちる |
+| I3 | 一覧と同じ照合に、キーの直後が `/`・`::`・それ以外の識別子を渡し、前の 2 つだけが当たる | 前方一致を文字列の前方一致だけにすると、`TestWithTmuxOld` が当たって落ちる |
+| I4 | `test_relay_hub.py` の 4 件に `skipif` の印が無いことを、収集した項目の印で確かめる | `needs_tmux` を戻すと落ちる |
+| 受け入れ条件 29・I6 | 実装の PR の CI で、分割 0 の成果物の JUnit XML に 4 件の名前があり、どれも failure・skipped を持たない。ジョブのログに `tmux -V` の行がある | 実装の PR の検証で確かめる(自動のテストは書かない。ワークフローの文面を照合するテストになるため) |
+| 受け入れ条件 30 | 開発コンテナで `test_spawn_ls_stop_and_hub_handover` を 10 回続けて打ち、10 回とも通る。全体の実行(`-n 4`)でも通る | 実装の PR の検証で確かめ、結果を PR に残す |
+| 受け入れ条件 31・I5 | 作業ディレクトリ 2 つ(`next.json` を持つものと `stop` を持つもの)と、それぞれを指す `starts.jsonl` の行を置いた状態で、`Term` の待ちを切らせると、失敗の文に両方のディレクトリの区間の引数とファイルの名前が出る | 段落を組む処理を失敗の文から外すと落ちる。ファイルの名前を決め打ちの一覧で調べる形にすると、一覧に無い名前のファイルが出ずに落ちる |
+| 受け入れ条件 32・I7 | 実装の PR で `git diff --stat origin/develop -- plugins/ndf/scripts/relay.py plugins/ndf/scripts/relay_lib/` が空 | 完了判定で確かめる |
+
+## 設計の結果
+
+| 課題 | 扱い | 取り込み先 | 触るファイル |
+| --- | --- | --- | --- |
+| #2346 | 実装する | — | `conftest.py`、`scripts/tests/test_root_conftest.py`、`plugins/ndf/scripts/tests/test_relay.py`、`plugins/ndf/scripts/tests/test_relay_hub.py`、`.github/workflows/pytest.yml` |
+
+## 未確認のまま残ること
+
+| 項目 | 内容 |
+| --- | --- |
+| 受け入れ条件 32 の一覧の外のファイル | 受け入れ条件 32 は変更を `plugins/ndf/scripts/tests/`・根の `conftest.py`・`.github/workflows/` に限るが、根の `conftest.py` のテストの置き場は `scripts/tests/test_root_conftest.py` で、この設計はそこにも足す。受け入れ条件 32 の目的(relay の振る舞いを変えない)は I7 で確かめる。この 1 ファイルを一覧へ加えてよいかは承認ゲート 1 で利用者が決める |
+| apt で入れる経路の時間 | 今の runner には tmux があるため、apt の経路は CI で一度も通らない。60 秒以内に収まるかは未確認で、通ったときのジョブの時間で確かめる |
+| 2026-11-04 の失敗の原因 | 要求の前提 10 のとおり追わない。受け入れ条件 30 の実行で再び落ちたら、I5 の失敗の文を材料に別の課題を起こす |
+| `.github/workflows/` の変更と自動の承認 | `.ndf/pace.json` の `boundary_paths` に `.github/workflows/**` があり、この変更はその対象に当たる。承認ゲートの扱いは `development-workflow` の判定に従う |
diff --git a/issues/issue-2346-requirements.md b/issues/issue-2346-requirements.md
new file mode 100644
index 000000000..a7f68a11a
--- /dev/null
+++ b/issues/issue-2346-requirements.md
@@ -0,0 +1,291 @@
+# #2346: relay.py が同じコンテナの複数の claude セッションを統括する(2 本目以降はホストが起動し tmux で端末を結び付ける)
+
+正は課題の本文(#2346)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。
+
+## 現状(2026-11-05、origin/develop e6af6e569)
+
+- 統括役(2 本目以降の `claude` を tmux の window で起動し、`relay.py ls` で一覧し、名前で止める)は ndf v10.27.14 で配布した。仕様は確定仕様 https://github.com/devbasex/ai-plugins/blob/develop/docs/specifications/ndf-relay-multi-session-supervisor.md にある
+- 受け入れ条件 9 の状態の語 `running` / `awaiting_approval` / `stopped` は、実装(`plugins/ndf/scripts/relay_lib/hub_client.py`)と確定仕様に入っている。`relay.py ls --json` の各行は name・state・awaiting_approval・sections・cost_usd・at・account を持つ
+- 受け入れ条件 25 の欠け(環境変数 `NDF_RELAY_HUB_WAIT`・`NDF_RELAY_STOP_WAIT` と `hub/` の受け渡しのファイル、副命令の契約)は #2434(relay.py の統括役の環境変数と hub/ の受け渡しのファイルが契約の宣言に無い)が持つ
+- 2026-11-04 に、受け入れ条件 3・8 を確かめる結合テスト `plugins/ndf/scripts/tests/test_relay_hub.py::test_spawn_ls_stop_and_hub_handover` が開発コンテナ(tmux 3.6)で「待ちが切れた: lroot の区間 2」で落ちたと記録された。**2026-11-05 の実測では再現しない**(下の表)。落ちた原因は未確認である
+- tmux を使う結合テスト 4 件は `needs_tmux`(`pytest.mark.skipif(shutil.which("tmux") is None)`)で、tmux が無い実行環境では黙って飛ばされる。リポジトリの根の `conftest.py` には、テストの置き場所ごとに要る外部コマンドの一覧(`REQUIRED_COMMANDS`)があり、欠けると既定で失敗にする(読み飛ばすのは `NDF_TESTS_ALLOW_MISSING_COMMANDS` を付けたときだけ)。tmux はこの一覧に無い
+- CI のテストのジョブ(`.github/workflows/pytest.yml` の分割の 2 本、`ubuntu-latest`)に tmux を入れる手順は無い(`grep -n tmux .github/workflows/*.yml` が 0 件)。runner に tmux が入っているか、4 件が CI で走ったかは未確認(実行 41945018391 の分割のジョブのログを `gh run view --job <番号> --log` で取ろうとしたが、件数の行を含むログが得られなかった)
+
+| 実行(2026-11-05) | 対象 | 結果 |
+| --- | --- | --- |
+| `pytest plugins/ndf/scripts/tests/test_relay_hub.py -k test_spawn_ls_stop_and_hub_handover` を 5 回続けて | develop の先頭 e6af6e569 | 5 回とも通過(1 回 3.6 秒前後) |
+| 同じテストを、環境変数 `TMUX` を立てた(tmux の中から打った)状態で | 同上 | 通過 |
+| `pytest plugins/ndf/scripts/tests/test_relay_hub.py -n 4` | 同上 | 48 件通過 |
+| `pytest plugins/ndf/scripts/tests -n 4` | 同上 | 5,780 件通過・4 件飛ばし(8 分 39 秒) |
+| 同じテストを 1 回 | タグ `ndf--v10.27.14` | 通過 |
+
+## 具体例
+
+2026-11-01、利用者は 3 本の開発ループを並列で回すと決めた(Factory の `l20261031`・根本解決の `lroot`・開発フローの見直しの `lflow`。引継ぎ文書 `.ndf/handoff/loop-*.md`)。今のラッパー(`relay.py`)では、利用者が端末を 3 つ開き、それぞれで `claude` を打ち、文書の `ndf-next` のコマンドを手で貼る必要がある。
+
+- 3 本のどれが動いていて、どれが止まり、どれが承認待ちかを一目で見る手段が無い
+- `relay.py stop` は 3 本すべてを止める。1 本だけを止められない
+- 端末を閉じるとそのラッパーが終わる
+- conductor(m26 を回しているセッション)が「3 本のループを始める」と決めても、自分では起動できない
+- 3 本が同じアカウントの 5 時間の枠を 3 倍の速さで使うが、枠を見るのは区間ごとのラッパーで、3 本をまとめて見ていない
+
+この課題が終わると、2 本目の `claude` を打てばその端末が tmux の window の中の新しいラッパーへ結び付き、`relay.py ls` で 3 本の状態と承認待ちが 1 つの表で見え、`relay.py stop lroot` で 1 本だけを止められる。
+
+## 依頼(原文)
+
+> 現状はclaude コマンドを実行するとrelay.pyが立ち上がり、それを経由してclaudeが立ち上がります。これを、同じコンテナ内の1本目のclaudeはこのまま、2本目の場合、1本目のrelay.pyが2本目のclaudeを立ち上げ、screen(tmux)には2本目のclaudeに結び付ける、3本目以降も同様、とかいう形にできないでしょうか。
+
+> つまり、relay.pyが複数セッションのclaudeを統括してコントロールできるようにするイメージです
+
+(書き手: 利用者、日付: 2026-11-01。起票者が課題の「由来」の節に原文として写したもの)
+
+> **決定(2026-11-01、利用者)**: 案 B を採る。tmux を親にし、セッションごとに今の `relay.py run` を 1 つの window で動かす。ホストは登録・起動・方針・一覧だけを持つ。設計の工程はこの前提で進め、案 A は比べ直さない。
+
+(書き手: 起票者、日付: 2026-11-01。利用者の決定を起票者が記録した文で、利用者の原文ではない。起票者の推定 S1 として承認ゲート 1 で確かめる)
+
+## 起票者の推定
+
+題・本文のうち、上の利用者の原文に無く起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。
+
+| # | 条件 | 推した出所 | この要求での扱い |
+| --- | --- | --- | --- |
+| S1 | 案 B(tmux を親にし、ラッパーごとに今の `relay.py run` を 1 つの window で動かす。統括役は登録・起動・方針・一覧だけを持つ)を採り、案 A(統括役が全 claude の擬似端末の親になる)は比べ直さない | 課題の本文「設計の候補」(起票者が利用者の決定として記録) | 前提 1 |
+| S2 | conductor の応答(`ndf-spawn` のブロックなど)から名前つきのラッパーを起動できる | 課題の本文「あるべき姿」の「起動」の行 | 受け入れ条件 5 |
+| S3 | 副命令 `relay.py spawn --name <名前> -- <最初の入力>` で端末の無いラッパーを起動でき、後から `relay.py attach <名前>` で結び付けられる | 同上 | 受け入れ条件 4・6 |
+| S4 | 端末を閉じてもラッパーは動き続け、カットポイントの再起動(`ndf-next`)も続く | 課題の本文「あるべき姿」の「端末の結び付け」の行 | 受け入れ条件 7・8 |
+| S5 | `relay.py ls` がラッパーごとの名前・状態・承認待ちの有無・区間の数・使った費用・最後に動いた時刻を出す | 課題の本文「あるべき姿」の「一覧」の行 | 受け入れ条件 9・10(費用は取れないとき空の値。前提 5) |
+| S6 | `relay.py stop <名前>`・`restart <名前>`・`attach <名前>` を持ち、名前を付けない `stop` は今どおり全部を止める | 課題の本文「あるべき姿」の「個別の操作」の行 | 受け入れ条件 11〜14 |
+| S7 | 同時に動かすラッパーの数の上限(既定 3)を宣言で持ち、超える起動を理由つきで断る | 課題の本文「あるべき姿」の「共有の方針」の行 | 受け入れ条件 15・16 |
+| S8 | 同時に流すプランの合計の上限(例 6)を、ラッパーをまたいで持つ | 同上 | 未決 1 |
+| S9 | アカウントの枠と起動の数の上限を、ラッパーをまたいで数える | 同上 | 受け入れ条件 17・18 |
+| S10 | どのラッパーが承認ゲートで待っているかを一覧と通知に出す(端末の結び付いていないラッパーの承認を取りこぼさない) | 課題の本文「あるべき姿」の「承認待ちの知らせ」の行 | 受け入れ条件 10・19 |
+| S11 | 1 本のラッパーが落ちてもほかと統括役は動き続け、統括役が落ちても動いているラッパーは止まらず、次の `claude` が新しい統括役になる | 課題の本文の旧受け入れ条件 8 | 受け入れ条件 20〜22 |
+| S12 | tmux の無い環境では今の 1 本ずつの起動に戻り、そのことを 1 行で知らせる | 課題の本文の旧受け入れ条件 9 | 受け入れ条件 23 |
+| S13 | 新しいコードは共通部品(`lib/step_result.py`・`lib/locks.py`・`lib/procs.py`・`lib/run_state.py`)を通し、#2335(異常系・ログ・回復を 1 つの実行の核へ集める大方針)の契約(#2336 入口・受け渡しのファイル・設定・環境変数の契約の宣言)の対象に入る | 課題の本文の旧受け入れ条件 10 | 受け入れ条件 24・25 |
+| S14 | 起動・一覧・停止の入出力を、Factory の契約(`factory/contracts/` の実行ジョブの指示と進捗の事象)と同じ語と形で持つ。コードは参照しない | 課題の本文「Neural Development Factory との関係」 | 受け入れ条件 26・27 |
+| S15 | `relay.py ls` の出力を 1 行の JSON(統一 JSON レポート)と人の読む表の 2 つにする | 課題の本文の旧受け入れ条件 3 と、2026-10-31 の利用者の方針(統一 JSON レポート) | 受け入れ条件 9 |
+| S16 | 残りの作業は「統括役の結合テスト」である | 引継ぎ文書 `.ndf/handoff/notice-version-lfactory2.md` の 2026-11-05 09:58 JST の行(AI の起動指示。ループ lflow2 の周回 6 の版の予約)と、本文の「現状」の節(起票者、2026-11-04) | 受け入れ条件 28〜32 |
+| S17 | tmux を使う結合テストが、tmux の無い環境で黙って飛ばされないようにする | 本文の「現状」の節の「CI は tmux を入れていないため、この結合テストは `needs_tmux` で飛ばされうる」(起票者、2026-11-04) | 受け入れ条件 28・29 |
+| S18 | 2026-11-04 の失敗の原因を突き止めて直す | 本文の「現状」の節の失敗の記録(起票者、2026-11-04) | 採らない。2026-11-05 に再現しないため(前提 10)。代わりに、再び落ちたときに原因を読める出力を受け入れ条件 31 に置く |
+
+## 目的
+
+- 同じコンテナで複数の開発ループを並列に回すとき、利用者が端末を開いてコマンドを貼る手間をなくし、どのループが動き・止まり・承認待ちかを 1 か所で見て、1 本ずつ止め・やり直し・結び付け直せる状態にする
+- conductor が自分の判断で別の開発ループを起動できるようにする(人の手を止めない。Value 2)
+- 受け入れ条件 3・8(端末の結び付いていない名前つきのラッパーでも `ndf-next` で次の区間が起動する)を確かめる結合テストが、手元と CI のどちらでも毎回走り、飛ばされたまま通過と数えられない状態にする(確かめていないものを済んだと記録しない。Value 1)
+
+## 前提
+
+- 前提 1: 構成は案 B に従う。tmux がラッパーごとの端末の親になり、各 window で今の `relay.py run` を動かす。統括役は登録・起動・方針・一覧だけを持ち、各ラッパーの状態機械(区間の切り替え・`ndf-next` の拾い方・質問シグナルファイル)を変えない(S1。承認ゲート 1 で確かめる)
+- 前提 2: 1 本目の `claude` は今どおり利用者の端末の前景で動き、tmux の中へ移さない。1 本目の端末を閉じれば 1 本目のラッパーは今どおり終わる。統括役の役目は、そのとき受け入れ条件 21 に従って次の `claude` へ移る
+- 前提 3: 端末から打った 2 本目以降の `claude` に名前の指定が無いときは、統括役が重ならない名前を自動で付ける。名前の形は設計で決める
+- 前提 4: 利用者のシェルの設定(`claude` の alias・関数)を書き換える手段は、今の `relay.py install` が置いたものの範囲に留める。新しく利用者のシェルの設定へ書き込む必要が出たら、設計で止めて人へ戻す(共通原則 C6)
+- 前提 5: 区間の数・最後に動いた時刻は各ラッパーの作業ディレクトリの記録から読める。使った費用はラッパーが記録を持っていない区間があり得るため、読めないときは空の値(`null`)で出し、0 と書かない
+- 前提 6: アカウントの 5 時間の枠の使用率はアカウントごとの値で、どのラッパーが読んでも同じ値になる(今の `relay_lib/switch.py` の読み方)。ラッパーをまたいだ枠の数え方は、この値を共有することで満たす
+- 前提 7: 承認待ち通知は、今ある待ち通知(`lib/notify.py`・`lib/wait_notice.py`)の経路を使い、通知先を新しく足さない
+- 前提 8: 対象は 1 つのコンテナ(1 つの OS の利用者)の中だけで、ほかのコンテナや機械のラッパーは統括しない
+- 前提 9: 対象のランタイムは Claude Code だけである(ラッパーが Claude Code だけのため)。Codex は対象外
+- 前提 10: 2026-11-04 に記録された結合テストの失敗は、2026-11-05 に develop の先頭とタグ `ndf--v10.27.14` のどちらでも再現しない(「現状」の節の表)。原因は未確認のまま残し、この要求では原因を追わない。受け入れ条件 30 の実行で再び落ちたら、受け入れ条件 31 の出力を材料に別の課題を起こす
+- 前提 11: tmux の不足を失敗にする仕組みは、根の `conftest.py` の外部コマンドの一覧(`REQUIRED_COMMANDS`)と読み飛ばしの指定(`NDF_TESTS_ALLOW_MISSING_COMMANDS`)をそのまま使い、同じ役割の判定をテストのファイルに別に作らない(Value 7)
+
+## 対象範囲
+
+含む:
+- 2 本目以降の `claude` が統括役に起動を頼み、tmux の window へ結び付く入口
+- 副命令 `relay.py spawn`・`ls`・`attach`・`restart`、名前つきの `stop`
+- conductor の応答から名前つきのラッパーを起動する口
+- 同時に動かすラッパーの数の上限の宣言と判定
+- 統括役が落ちたときの引き継ぎ、tmux の無い環境での今の起動への戻り
+- 一覧と通知での承認待ちの表示
+- tmux を使う結合テストを、tmux の無い環境で飛ばさず失敗にすること(読み飛ばしの指定があるときを除く)
+- CI のテストのジョブで tmux を使えるようにし、4 件を走らせること
+- 結合テストの待ちが切れたときに、原因を読める記録を失敗の出力へ出すこと
+
+含まない:
+- 統括役が claude の擬似端末の親になる形(案 A)
+- 複数のコンテナ・機械をまたいだ統括
+- Factory の実行ジョブの指示でラッパーを起動する入口(手元のマシンで実行ジョブを動かす仕組み #2189 が担う。ここでは語と形を揃えるだけ)
+- 各ラッパーの区間の切り替え・`ndf-next` の拾い方・アカウントの切り替えの規則そのものの変更
+- tmux の導入(入っていなければ今の起動に戻るだけで、入れない)
+- 同時に流すプランの合計の上限(未決 1)
+- 2026-11-04 の結合テストの失敗の原因の調査(前提 10)
+- 契約の宣言の欠け(受け入れ条件 25 の残り。#2434 が持つ)
+- relay のラッパーと統括役のコードの変更(結合テストが通っているため。テストと CI の手順だけを変える)
+
+## ドメインイベント
+
+| # | イベント | 引き金 | 失敗したとき | 順序の前提 |
+| --- | --- | --- | --- | --- |
+| E1 | 1 本目の `claude` でラッパーが起動し、統括役を兼ねた | 統括役の居ないコンテナで利用者が `claude` を打った | 統括役になれなければ、統括役の無い今の 1 本の起動として続ける(受け入れ条件 1) | — |
+| E2 | 2 本目の `claude` が統括役へ起動を頼んだ | 統括役の居るコンテナで利用者が `claude` を打った | 統括役が応答しなければ、頼んだ側が統括役になる(受け入れ条件 21) | E1 |
+| E3 | conductor が名前つきのラッパーの起動を頼んだ | conductor の応答の起動のブロック、または `relay.py spawn` | 名前の重複・上限超えは理由つきで断った(受け入れ条件 6・16) | E1 |
+| E4 | 統括役が同時に動かす数の上限を判定した | E2・E3 | 上限を超えれば起動せず理由を返した(受け入れ条件 16) | E2 か E3 |
+| E5 | 統括役が tmux の window で名前つきのラッパーを起動した | E4 を通った | tmux が無ければ今の起動に戻った(受け入れ条件 23)。window を作れなければ理由を返した | E4 |
+| E6 | 端末が名前つきのラッパーの window へ結び付いた | E2 の頼み、または `relay.py attach` | 名前が無ければ理由つきで終わった(受け入れ条件 13) | E5 |
+| E7 | 端末が閉じられた | 利用者が端末を閉じた | ラッパーは動き続けた(受け入れ条件 7) | E6 |
+| E8 | 名前つきのラッパーがカットポイントで区間を切り替えた | `ndf-next` | 今のラッパーの規則のまま(変えない) | E5 |
+| E9 | 名前つきのラッパーが承認待ちに入った | claude が承認ゲートで問いを出した | 一覧と通知に出した(受け入れ条件 10・19) | E5 |
+| E10 | 利用者が 1 本を止めた・やり直した | `relay.py stop <名前>` / `restart <名前>` | 名前が無ければ理由つきで終わった | E5 |
+| E11 | 名前つきのラッパーが落ちた | ラッパーのプロセスの異常終了 | 一覧で「止まった」と出し、ほかは動き続けた(受け入れ条件 20) | E5 |
+| E12 | 統括役が落ちた | 1 本目のラッパーの終了・異常終了 | 動いているラッパーは止まらず、次に打った `claude` が統括役になった(受け入れ条件 21・22) | E1 |
+| E13 | テストの実行が、tmux を使う結合テストを集めた | `pytest` の収集 | — | — |
+| E14 | tmux が PATH に無く、テストの実行が失敗として tmux を名指しした | E13 で tmux が見つからない | 読み飛ばしの指定があれば、tmux を使う 4 件だけを飛ばした(受け入れ条件 28) | E13 |
+| E15 | CI のテストのジョブが tmux を使える状態でテストを始めた | Pull Request・push での CI の起動 | tmux を用意できなければ、E14 でジョブが落ちた(受け入れ条件 29) | — |
+| E16 | 結合テストの待ちが切れ、ラッパーの記録を失敗の出力に出した | 待ちの上限(区間 2 は 40 秒)に達した | — | E13 |
+
+## 用語
+
+| 用語 | 意味 |
+| --- | --- |
+| 統括役 | 同じコンテナで最初に起動したラッパーが兼ねる役。ほかのラッパーの登録・起動・同時に動かす数の判定・一覧だけを持ち、各ラッパーの状態機械には触れない。課題の本文の「ホスト」に当たる(用語集の「ホスト」は cross-review の収束ループを起動する CLI を指すため使わない) |
+| 名前つきのラッパー | 統括役に名前で登録され、tmux の 1 つの window で動く `relay.py run` の 1 本。カットポイントで区間(セッション)を切り替えても同じ名前で続く。課題の本文の「セッション」に当たる(用語集の「セッション」は claude の 1 回の起動を指すため使わない) |
+| 端末の結び付け | 利用者の端末を、名前つきのラッパーの tmux の window へ attach すること。端末を閉じても window の中のラッパーは動き続ける |
+
+## 受け入れ条件
+
+起動と結び付け:
+
+- [ ] 1. 統括役の居ないコンテナで `claude` を打つと、今と同じく端末の前景でラッパーが起動し、tmux を使わない(1 本目の振る舞いが変わらない。退行しない)
+- [ ] 2. 前提: 統括役が動いている。操作: 別の端末で `claude` を打つ。結果: tmux の新しい window で名前つきのラッパーが起動し、その端末がその window へ結び付く
+- [ ] 3. 2 本目の `claude` が起動した名前つきのラッパーは、端末で直接 `claude` を打ったときと同じ区間の切り替え(`ndf-next` で次の区間を起動する)を行う
+- [ ] 4. `relay.py spawn --name <名前> -- <最初の入力>` を打つと、端末の結び付いていない名前つきのラッパーが起動し、最初の区間の claude が `<最初の入力>` を受け取る。終了コードは 0
+- [ ] 5. conductor の応答に起動のブロック(名前と最初の入力を持つ。形は設計で決める)を置くと、統括役がそのブロックの名前で名前つきのラッパーを起動し、置いた側の区間は止まらない
+- [ ] 6. 既に動いている名前で `spawn` すると起動せず、終了コードが 0 でなく、理由(名前が重なる)を出す
+- [ ] 7. 名前つきのラッパーに結び付いた端末を閉じても、そのラッパーのプロセスは動き続け、`relay.py ls` で「動いている」と出る
+- [ ] 8. 端末の結び付いていない名前つきのラッパーでも、カットポイントの `ndf-next` で次の区間が起動する
+
+一覧と個別の操作:
+
+- [ ] 9. `relay.py ls --json` は 1 行の JSON(統一 JSON レポート。`lib/step_result.py` の形)を出し、`items` に名前つきのラッパーごとに名前・状態(`running` / `awaiting_approval` / `stopped` のどれか。Factory と同じ名前、2026-11-02 の承認ゲート 2 で利用者が決めた)・承認待ちの有無・区間の数・使った費用・最後に動いた時刻を持つ。`--json` の無い `relay.py ls` は同じ中身を人の読む表で出す
+- [ ] 10. 名前つきのラッパーの claude が承認ゲートの問い(`AskUserQuestion`)を出している間、`relay.py ls` のその行の状態は承認待ちになり、答えた後は動いているへ戻る
+- [ ] 11. 名前つきのラッパーが 3 本動いているとき `relay.py stop <名前>` を打つと、その 1 本だけが止まり、残りの 2 本は `relay.py ls` で動いているのまま
+- [ ] 12. 名前を付けない `relay.py stop` は、今と同じく動いているラッパーすべてに停止シグナルファイルを置く(退行しない。前提の確認: `relay_lib/mark.py` の `cmd_stop` が `state_root()` の下の動いているラッパーすべてに `stop` を置く、2026-11-02 に読んで確かめた)
+- [ ] 13. 動いていない名前で `stop` / `restart` / `attach` を打つと、何も変えずに終了コードが 0 でなく、理由(その名前は無い)を出す
+- [ ] 14. `relay.py restart <名前>` は、その 1 本を止めてから同じ名前・同じ作業ディレクトリで起動し直し、ほかのラッパーに触れない。`relay.py attach <名前>` は打った端末をその window へ結び付ける
+
+共有の方針:
+
+- [ ] 15. 同時に動かす名前つきのラッパーの数の上限を宣言(置き場は設計で決める)で持ち、宣言が無いときの既定は 3 である
+- [ ] 16. 上限の数だけ動いているとき、端末の `claude`・`spawn`・conductor の起動のブロックのどれから頼んでも起動せず、理由(上限の数と動いている名前)を出す
+- [ ] 17. 1 日の起動回数の上限(`NDF_RELAY_MAX_STARTS`)は、今と同じくコンテナの中の全ラッパーの起動を合わせて数える(退行しない。前提の確認: `relay_lib/record.py` の `StartLimit.count_today` が `state_root()` の下の全ディレクトリの記録を数える、2026-11-02 に読んで確かめた)
+- [ ] 18. `relay.py ls` の各行に、そのラッパーが今使っているアカウントの名前が出る。同じアカウントを使うラッパーが何本あるかを一覧から数えられる
+
+承認待ちの知らせ:
+
+- [ ] 19. 端末の結び付いていない名前つきのラッパーが承認待ちに入ると、今ある待ち通知が、そのラッパーの名前と `relay.py attach <名前>` の案内を付けて 1 回出る
+
+落ちたとき・環境の差:
+
+- [ ] 20. 名前つきのラッパーのプロセスを 1 本 kill すると、`relay.py ls` でその行が止まったになり、ほかのラッパーと統括役は動き続ける
+- [ ] 21. 統括役のプロセスを kill しても、動いている名前つきのラッパーは止まらない。その後に打った `claude` が新しい統括役になり、`relay.py ls` に既存のラッパーが出る
+- [ ] 22. 2 つの端末でほぼ同時に `claude` を打っても、統括役になるのは 1 つだけである(もう一方は名前つきのラッパーとして起動する)
+- [ ] 23. tmux の実行ファイルが PATH に無い環境で 2 本目の `claude` を打つと、今の 1 本ずつの起動(端末の前景で自分のラッパーを動かす)になり、tmux が無いため統括しないことを 1 行で出す
+
+共通部品と契約:
+
+- [ ] 24. 新しい副命令の結果は `lib/step_result.py` の形で出し、ロック・子プロセス・実行の状態は `lib/locks.py`・`lib/procs.py`・`lib/run_state.py` を通す(同じ役割の関数を relay_lib に新しく作らない。Value 6)
+- [ ] 25. 新しい副命令・受け渡しのファイル・設定・環境変数が #2336 の契約の宣言に載り、契約の検査(`plugins/ndf/scripts/tests/` の契約テスト)が通る
+- [ ] 26. 設計文書に、起動・一覧・停止の入出力の項目と `factory/contracts/schemas/`(実行ジョブの指示・進捗の事象)の項目の対応表があり、対応する項目は同じ名前を使う
+- [ ] 27. `plugins/` から `factory/` を参照しない(`python3 scripts/check-service-boundary.py` が 0 で終わる)
+
+結合テストを毎回走らせる(2026-11-05 に足した。起票者の推定 S16・S17):
+
+- [ ] 28. 前提: tmux が PATH に無い。操作: `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests/test_relay_hub.py -q` を打つ。結果: 終了コードが 0 でなく、出力が不足した外部コマンドとして `tmux` を名指しする。同じ操作に `NDF_TESTS_ALLOW_MISSING_COMMANDS=1` を付けると、tmux を使う 4 件が skipped になり、終了コード 0 で終わる(tmux を使わない 44 件を一緒に飛ばすかは未決 6)
+- [ ] 29. develop 宛の Pull Request の CI のテストのジョブ(`pytest.yml` の分割の 2 本)で、tmux を使う 4 件(`test_spawn_ls_stop_and_hub_handover`・`test_second_claude_attaches_and_survives_terminal_close`・`test_limit_refuses_from_terminal_and_spawn`・`test_killed_named_wrapper_shows_stopped`)が実行されて通り、どれも skipped に数えられない。CI のログか成果物から、4 件の結果を名前で読める
+- [ ] 30. 開発コンテナ(tmux 3.6)で `test_spawn_ls_stop_and_hub_handover` を 10 回続けて実行し、10 回とも通る。`pytest plugins/ndf/scripts/tests -n 4` の全体の実行でも通る
+- [ ] 31. 結合テストが名前つきのラッパーの区間の待ちで切れて落ちるとき、失敗の出力に、そのラッパーの区間ごとの起動の引数と、作業ディレクトリのシグナルファイル(`next.json`・`stop` など)の有無が載る
+- [ ] 32. `relay.py` と `relay_lib/` の振る舞いを変えない(変更は `plugins/ndf/scripts/tests/`・根の `conftest.py`・`.github/workflows/` に限る。`git diff --stat origin/develop` で確かめる)
+
+## 非機能の条件
+
+| 大項目 | 条件 |
+| --- | --- |
+| 可用性 | 名前つきのラッパー 1 本・統括役の異常終了が、ほかのラッパーを止めない(受け入れ条件 20・21) |
+| 運用・保守性 | 統括役の起動・断り・引き継ぎ、名前つきのラッパーの起動・停止は、統括役の記録に時刻・名前・理由つきで 1 行ずつ残る |
+| 移行性 | 今の 1 本だけで使う利用者は、設定を変えずに今と同じ振る舞いになる(受け入れ条件 1・12)。tmux の無い環境も同じ(受け入れ条件 23) |
+| セキュリティ | 統括役への頼みは同じ OS の利用者だけが送れる(受け口のファイル・ソケットの権限を所有者だけにする)。最初の入力・一覧に秘密を出さない |
+| システム環境 | tmux 3.x(2026-11-02 の開発環境は 3.6)。Linux のコンテナ。Claude Code だけ。CI は GitHub Actions の `ubuntu-latest` |
+| 性能・拡張性 | CI で tmux を用意する手順が足す時間は、テストのジョブ 1 本あたり 60 秒以内(今の分割の 2 本は `-n auto` で並列に回る) |
+
+## 影響
+
+| 対象 | 影響 |
+| --- | --- |
+| 公開インタフェース | 変わる(足すだけ)。`relay.py` に副命令 `spawn`・`ls`・`attach`・`restart` と、`stop` の名前の引数を足す。名前を付けない `stop` と今の `run` の振る舞いは変えない(互換あり) |
+| データ | ラッパーの作業ディレクトリの置き場(`~/.local/state/ndf/relay/`)に、統括役の登録と名前の記録を足す。既存のシグナルファイルの形は変えない |
+| 既存の振る舞い | 統括役が居るときの 2 本目以降の `claude` が、自分の端末の前景で動く代わりに tmux の window へ結び付く |
+| テストの実行 | tmux の無い手元の環境で `pytest plugins/ndf/scripts/tests` を打つと、今は tmux を使う 4 件が飛ばされて通るが、失敗に変わる。続けて走らせるには `NDF_TESTS_ALLOW_MISSING_COMMANDS=1` を付ける(node・npm の不足と同じ扱い) |
+
+## 検証手段
+
+| 項目 | 手段 |
+| --- | --- |
+| 起動 | `python3 plugins/ndf/scripts/relay.py ls --json` |
+| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(擬似端末と使い捨ての tmux サーバ `tmux -L <使い捨ての名前>` で受け入れ条件 1〜23 を確かめる) |
+| 静的解析・型検査 | リポジトリの CI と同じ検査(`python3 scripts/check-service-boundary.py`・契約の検査) |
+| tmux の不足(受け入れ条件 28) | tmux を除いた PATH で `pytest plugins/ndf/scripts/tests/test_relay_hub.py` を打つ(pytester か `env PATH=...` の子プロセスで確かめるテスト) |
+| CI(受け入れ条件 29) | 設計 PR の後の実装 PR の CI の結果で、4 件の名前と結果を読む |
+| 手動確認 | 利用者が実際の端末エミュレータで 2 本目の `claude` を打ち、tmux の window の画面が崩れずに描かれ、キー入力が claude に届くことを見る(リリース後テスト) |
+
+## 前提とする取り決め
+
+| 項目 | 参照先 / 決めたこと |
+| --- | --- |
+| プロジェクト構造 | ラッパーのコードは `plugins/ndf/scripts/relay_lib/`、共通部品は `plugins/ndf/scripts/lib/`。`factory/` を参照しない(`AGENTS.md` の「マーケットプレイスの構造」) |
+| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。外部コマンド(tmux)の挙動は書く前に実行して確かめる(`AGENTS.md` のベストプラクティス) |
+| テスト戦略 | 統括役の判定(上限・名前の重なり・引き継ぎ)は単体テスト、tmux と擬似端末を通す振る舞いは使い捨ての tmux サーバでの結合テスト。`.md` の文言を照合するテストは書かない |
+
+## 境界
+
+| 区分 | 内容 |
+| --- | --- |
+| 常に行う | 既存の relay のテストを通す、tmux は使い捨てのサーバ名で試し利用者の tmux サーバに触れない |
+| 確認してから行う | `relay.py install` が置く `claude` の alias・関数の変更(承認ゲートで人が見る。利用者のシェルの設定へ新しく書き込むなら設計で止めて人へ戻す。C6) |
+| 行わない | 各ラッパーの区間の切り替え・アカウントの切り替えの規則の変更、tmux の自動導入、`factory/` の参照 |
+
+## 未決
+
+| 項目 | 誰が決めるか | 期限 |
+| --- | --- | --- |
+| 1. 同時に流すプランの合計の上限(起票者の推定 S8、例 6)をこの課題に含めるか。含めないなら別の課題に起こす | 利用者(承認ゲート 1) | 設計の承認まで |
+| 2. 同時に動かす数の上限の宣言の置き場(`.ndf/` の既存の設定か新しいファイルか) | 設計 | 設計の承認まで |
+| 3. conductor の起動のブロックの形(`ndf-next` と同じくコードブロックの情報文字列で拾うか) | 設計 | 設計の承認まで |
+| 4. 統括役が落ちた後、次の `claude` が来るまでの間に出た承認待ち通知を誰が出すか(各ラッパーが自分で出すか) | 設計 | 設計の承認まで |
+| 5. CI の runner(`ubuntu-latest`)に tmux が入っているか。入っていなければ CI の手順で入れる(`apt-get install tmux` など)か。設計で runner の上で `tmux -V` を実測して決める | 設計 | 設計の承認まで |
+| 6. tmux を外部コマンドの一覧へ載せる単位(`test_relay_hub.py` のファイル全体か、tmux を使う 4 件だけか)。ファイル全体にすると、tmux を使わない 44 件も tmux の無い環境で失敗になる | 設計 | 設計の承認まで |
+| 7. CI のログか成果物で 4 件の結果を名前で読む形(`-rs` の要約・JUnit XML など) | 設計 | 設計の承認まで |
+
+## 背景
+
+### 今の仕組み(2026-11-01、`plugins/ndf/scripts/relay_lib/`)
+
+| 観点 | 今 |
+| --- | --- |
+| 起動 | `claude` の alias が `relay.py run` を前景で起動し、ラッパーが claude を擬似端末の子として 1 つ持つ(`run.Relay`・`terminal.Terminal`、ptyprocess) |
+| 作業ディレクトリ | ラッパーごとに `~/.local/state/ndf/relay/<時刻-pid-乱数>/`(`NDF_RELAY_DIR`)。シグナルファイル `next.json`・`stop`・`relay.lock` などを持つ |
+| 共有 | `count.lock`(起動の数)とアカウント(`relay_lib/switch.py`) |
+| 停止 | `relay.py stop` は動いているラッパーすべてに停止シグナルファイルを置く(`mark.cmd_stop`) |
+| 端末 | ラッパーは起動した端末の前景に常駐する。端末とラッパーは 1 対 1 |
+
+### 設計の候補(2026-11-01 に利用者が案 B に決めた。起票者の記録)
+
+| 案 | 形 | 得 | 失 |
+| --- | --- | --- | --- |
+| A | 統括役が全 claude の擬似端末の親になり、端末の切り替えも自分で行う | プロセスが 1 つで状態を持ちやすい | 統括役が落ちると全ラッパーが落ちる。端末の切り替え(画面の再描画・入力の振り分け)を自作する。統括役の入れ替え(#1587 の `handover`)が全ラッパーに効く |
+| **B(採る)** | tmux を親にし、ラッパーごとに今の `relay.py run` を 1 つの window で動かす。統括役は制御(登録・起動・方針・一覧)だけを持つ | 今のラッパーの状態機械を変えずに使える。1 本が落ちても他は動く。端末の切り替えと結び付け直しは tmux に任せる(既存ライブラリを使う方針) | tmux に依存する(無い環境では今の 1 本ずつの起動に戻る) |
+
+### Neural Development Factory との関係(2026-11-01 に確かめた)
+
+Factory(確定仕様 `docs/specifications/ndf-factory-runs-and-runner.md`)は、並行を**サービスの側**で持つ。スプリントの実行 1 本を実行ジョブ(ECS Fargate のタスク 1 つ)で動かし、conductor は端末の無い `claude -p` で起動する。同時に動かせる数は「実行の枠」(アカウントごと・リポジトリごと。プランの表で上限 3 など)が開始のトランザクションで数える。承認ゲートで止まったら状態を S3 へ保存してジョブを終え、答えで別のコンテナが続ける。人は端末でなく画面(#2172 一覧・詳細、#2173 承認と通知)で見る。
+
+この課題と重なるのは **手元のマシンで Factory の実行ジョブを動かす仕組み(#2189)** である。どちらも「利用者の機械の上で、複数の claude を起動し、見て、止める常駐のプロセス」になる。
+
+| | この課題(ラッパーの統括役) | #2189(手元のマシンで実行ジョブを動かす仕組み) |
+| --- | --- | --- |
+| 起動の出所 | 端末の `claude`・conductor の応答・`relay.py spawn` | Factory の実行ジョブの指示(サービスから受け取る) |
+| claude の形 | 対話の端末(tmux の window) | 端末の無い `claude -p` |
+| 見る場所 | `relay.py ls` と tmux | Factory の画面 |
+
+起動・一覧・停止の口を Factory の契約と同じ語と形で持てば、#2189 は統括役に「Factory から受け取った指示で起動する」入口を足すだけで作れる。`plugins/` は `factory/` を参照しないため、共有するのは契約の形だけである(受け入れ条件 26・27)。
+
+関連: #2315(開発フローの見直しと大整理。同じマイルストーン 35)・#2335(根本解決)・#1587(ラッパーの入れ替え)・#1389(アカウントの切り替え)