# Spec 64: コアを GUI なしで動かす(ホストの切り出しとヘッドレス実行) - 状態: **Done**(2026-10-06。起票から Done まで同日・P0〜P6)(2026-10-06 起票 → 同日、査読 2 系統 26 点を反映して rev2。表は Notes 4。 rev2 の再査読で未決 1 を rev2 の方針どおりに閉じた → **同日夕に P0 完了** — 起動ログの基準 6 行 / ロックの実測 3 OS・12 場面すべて予測どおり / `headless_host_contract` 凍結 15 本。「P0 実測記録」が正 → **同日夜に P1 完了** — `crates/fuseforks-host` へ 4 ファイル + `build_host`(6 コミット・挙動不変・ 起動ログ 6 行が一致)。「P1 実装記録」が正 → **同日深夜に P2 完了** — 村の排他ロック + 二重の網 + 覆いの文言 + MSRV 1.89(2 コミット)。「P2 実装記録」が正 → **同日に P3 完了** — `EnvSecretStore` と 衝突の検査 / `headless_preflight`(9 行 → 識別子 11)/ `run_schedules` / `ask_external_outcome`。変異 6 本とも 狙った 1 本だけ赤(1 本は並びのテストの穴を直してから)。「P3 実装記録」が正 → **同日に P4 完了** — 実行ファイル `fuseforks-cli`(`check` / `ask` / `serve`)・結合 7 本(Tasks の 6 本 + 変異で見つけた穴の 1 本)・変異 4 本。 「P4 実装記録」が正 → **同日に P5 完了** — DETAIL 3 言語 / README 3 言語 / PRIVACY 日英 / CLAUDE.md のギャップ 6 つ。 「P5 台帳記録」が正 → **同日に P6 完了 = Done** — 実機 5 件すべて成立、実機で見つかった穴 3 つ + レビューの 1 つを 直した(MCP の子の標準エラー / 判断役の誤った情報 / Ctrl+C の受け口 / 子の 1 行の上限)。「P6 実機記録」が正) - 起点: 利用者(2026-10-06)—「以前から構想されていた fuseforks-core と GUI の完全分離構想について、 仕様を作ってください」 - 前提の裁定(2026-09-14 利用者):「最初は GUI で回して、そのフローが自動化で失敗しないようになったら、 クラウドに core と workspace をコンテナにして送って実行できるようにしたい。これは最終的な目標」 — **GUI は設計と安定化の場、コンテナは安定したフローを実行する場**。順番は「GUI で失敗しなくなる」が先 - 材料: CLAUDE.md「設計の材料 3 件 > core 単独実行の構想」(ギャップ 6 つ・2026-08-29)/ 「agent-orchestrator の実読」(2026-09-18。デーモンと GUI の寿命・多重起動の防止・秘密の渡し方)/ 「外から MCP の扉へ繋ぐ形」(2026-09-22) - **用語**: 本 Spec の「分離」は **「GUI なしでコアを回せること」** の意味で使う。GUI とコアを別プロセスに 分ける(GUI を常駐プロセスのクライアントにする)意味ではない(「採らなかった形」1) ## rev1 からの変更(査読の反映。詳細は Notes 4) - **組み立ての入力を `HostBootOptions` に束ねた**(D1)。予定のティッカーを回すか・扉を開くか・秘密をどこから 読むかは呼び出し側が決める。rev1 は `build_host(paths, app_version, secrets)` で、D6 と D7 が要る設定を運ぶ口が無かった - **`--start` を `ask` と `serve` の両方で必須にし、`none` を無くした**(D6)。rev1 の既定 `none` は `serve` で誰も起動しない空転を作り、`ask` では委譲が要る村で毎回警告を出していた - **`check` の構文と、検査が何を対象にするかを書いた**(D5)。`check` は検査したいコマンドと同じ引数を取る - **`ask` の会話の既定を「新しい会話」へ**(D7)。今の会話へ続けると、GUI で使っている会話の履歴が cron の依頼で埋まり、評価の走行も前の文脈を引きずる - **終了コードを結末ごとに分けた**(D10)。`ask_external` は配送の結末(`PlanTaskState`)を既に受け取って捨てて いるので、それを返す口を 1 本足すだけで済む(オーケストレーションの経路は増えない) - **`ask` も Ctrl+C を拾い、`serve` と同じ手順で閉じる**(D8) - **`sessions.redb` が「別のプロセスが開いている」で開けないときは、起動を止める**(D3) - **環境変数名の衝突検査は、`bootstrap` の直後・MCP の接続より前**(D4)。固定の鍵(`jev_api_token`)も数える - **`--events jsonl` の間は標準エラーの全行を JSON にする**(D7) - 移動と中身の変更を別コミットに分けた(P1)/ ロックの前に `create_dir_all`(D3)/ `HostPaths` は `data_dir` だけを持つ(D2)/ 版番号のフォールバック(D11) ## Goal 1. **村を GUI なしで動かせる。** Tauri を一切リンクしない実行ファイルが、GUI と同じ村(`world.json` / `sessions.redb` / 条例 / 黒板 / 予定)を開き、同じコアで依頼を処理する 2. **使い方は 3 つ。** `check`(このコマンドで GUI なしに回せるかを、LLM を 1 回も呼ばずに判定する)/ `ask`(依頼を 1 通送り、答えを標準出力へ書いて終わる)/ `serve`(常駐して予定と MCP の扉を回す) 3. **GUI の挙動は、同じ村の 2 重オープンを拒む 1 点を除いて変わらない。** 起動の手順・ログの行・IPC・画面はそのまま 4. **同じ村を 2 つのプロセスが同時に開くことを、構造で拒む。** GUI とヘッドレス、ヘッドレス同士のどちらも 5. **コンテナへ持っていける形にする。** 秘密を OS の資格情報ストア以外から読める / パスを引数で渡せる / GUI でしか解けない待ちを起動前に名指しで拒む / 結末を終了コードで読める。 **ただしコンテナの像・配布・クラウドへの配置は範囲外**(別 Spec) **やらないこと(範囲外)**: Docker イメージとその配布 / クラウドへの配置手順 / GUI を常駐プロセスの クライアントにすること(「採らなかった形」1)/ 扉の bind を 127.0.0.1 以外へ開くこと(D9)/ 飛行中のターンを再起動後に再開すること / 1 プロセスで複数の村を開くこと / Release のアセットに ヘッドレスの実行ファイルを足すこと(D12) ## 前提の実測(2026-10-06) **GUI 層は 3,865 行。そのうち Tauri に依存するのは IPC の受け口だけで、持ち上げる側の依存は 7 か所。** | ファイル | 行 | Tauri への依存 | 行き先 | |---|---|---|---| | `commands.rs` | 1,570 | 240(`#[tauri::command]` と `State<'_, AppState>`) | **GUI に残る**(IPC の受け口・117 本) | | `mcp_server.rs` | 629 | 1(`tauri::async_runtime::spawn`) | ホストへ | | `jev_settings.rs` | 516 | 0 | ホストへ | | `probe_approvals.rs` | 338 | 0(doc コメントの 1 か所だけ) | ホストへ | | `pricing_source.rs` | 328 | 0 | ホストへ | | `state.rs` | 245 | 6(`app.path().app_data_dir()` ×2 / `package_info()` / `emit` / `Manager` / `AppHandle`) | 組み立ての本体をホストへ、イベントの橋だけ GUI に残す | | `lib.rs` | 233 | — | GUI に残る(Tauri の Builder と IPC の登録) | - **`state.rs` の `build_state` が起動の配線のすべて**(ログを開く → 版の行 → 資格情報ストア → `bootstrap` → 同梱ツール 9 本の登録 → MCP の初期接続 → 扉 → 前判定の承認 → Jev → 単価表の取得元)。 ここが Tauri の `AppHandle` を受け取っているので、**この関数をそのまま別の実行ファイルから呼べない** - **コアは既に Tauri を知らない**(`fuseforks-core` の依存に tauri は無い)。分離の工事はコアではなく GUI 層の中で起きる - **今、同じ村を 2 つのプロセスが開くと黙って壊れる。** 多重起動を止めているのは `tauri-plugin-single-instance`(GUI のプロセスが 2 つ並ぶのを止めるだけで、村は見ていない)。 別の実行ファイルが同じ村を開くと: - `sessions.redb` は redb 自身が排他ロックを取る(`DatabaseError::DatabaseAlreadyOpen`)が、 **コアは開けないときに WARN を 1 行出して「会話を保存しない起動」で続ける**(`bootstrap.rs:132`) - `world.json` と `schedules.json` は**両方のプロセスが書く**。予定は両方で発火し、`lastConsumedDueMs` を競う - = `lib.rs` の doc が単一インスタンスの理由に挙げた事故が、そのまま別の経路で起きる - **秘密の鍵の名前**: モデルの API キーはテンプレートの ID(`ModelTemplate.id`)、Jev のトークンは `jev_api_token`。 `SecretStore` の実装は `KeyringSecretStore` と `InMemorySecretStore` の 2 つだけで、コンテナには keyring が無い - **外部からの依頼の入口は既にある**(`Orchestrator::ask_external`。Spec 25)— 窓口へ配送して待ち、 同時 1 本・新しい因果の根・天井は新品・送り手は `Endpoint::External`。**`ask` はこれを呼ぶだけ** - **`ask_external` は配送の結末を受け取って捨てている** — `deliver_and_wait` は `(answer, PlanTaskState)` を返し、 `ask_external` は `let (answer, _state) = …` で状態を落としている(`runtime.rs:603`)。状態は `Answered / HandedOff / Undeliverable / NoAnswer / TimedOut / Interrupted / BudgetExhausted`(`plan.rs:29`) - **版番号**: GUI は `package_info().version`(CI がタグから `tauri.conf.json` を書き換える)。 workspace の `version` はどのビルドでも `0.1.0` のまま(`failures.md` #112 の処方の注記) - **MSRV**: workspace の `rust-version` は 1.85。`std::fs::File::try_lock` は 1.89 で安定化(D3) ## Design ### D1. crate の構成と、組み立ての入口 ```text crates/fuseforks-core … 変更は最小(D3 の SessionStoreLocked / D4 の EnvSecretStore / D5 の検査 / D6 の run_schedules / D10 の ask_external_outcome) crates/fuseforks-host … 新設。Tauri を知らない。村を開いて組み立て、閉じる ├─ paths.rs … HostPaths ├─ lock.rs … 村の排他ロック(D3) ├─ boot.rs … build_host(今の build_state の本体) ├─ mcp_server.rs … ← apps/gui-tauri から移動 ├─ probe_approvals.rs … ← 同 ├─ pricing_source.rs … ← 同 └─ jev_settings.rs … ← 同 apps/gui-tauri/src-tauri … Tauri の Builder・IPC(commands.rs)・イベントの橋だけ apps/cli … 新設。実行ファイル fuseforks-cli。check / ask / serve ``` ```rust pub struct HostBootOptions { /// `version: app=… profile=…` の行に書く版(D11)。 pub app_version: String, /// 秘密をどこから読むか。GUI は常に Keyring。 pub secrets: SecretSource, // Keyring | Env /// 予定のティッカーを回すか(D6)。GUI と serve は true、ask と check は false。 pub run_schedules: bool, /// mcp_server.json の設定どおり扉を開くか(D9)。GUI と serve は true、ask と check は false。 pub open_door: bool, } pub async fn build_host(paths: &HostPaths, opts: HostBootOptions) -> Result; ``` - **起動の配線は `build_host` の 1 実装。** GUI と CLI が別々に組むと、同梱ツールの登録漏れ・前判定の承認の 差し込み漏れ(`state.rs` の注記「ここを忘れると全部 unapproved という安全側で止まる」)が片方だけに起きる - **呼び出しごとに違うのは `HostBootOptions` の 4 欄だけ。** 扉を開くかを `build_host` の外(呼び出し側が `host.open_door()` を呼ぶ形)に出さないのは、**GUI の起動ログの行の順序を変えないため**(今は扉の行が 前判定の承認と Jev の行より先に出る。P1 で 1 行ずつ突き合わせる) - 組み立ての順序(GUI の今と同じ。D3 と D4 の 2 手が足される): ```text create_dir_all(data_dir / workspace) → ロック(D3)→ ログ → 版の行 → 秘密のストア → bootstrap → 環境変数名の衝突検査(D4。Env のときだけ)→ 同梱ツール 9 本 → MCP の初期接続 → 扉(open_door) → 前判定の承認 → Jev → 単価表の取得元 ``` - `AppState` は `Host` を 1 つ持つ形へ縮む。`commands.rs` は `state.orchestrator` を `state.host.orchestrator` へ 読み替えるだけで、**IPC の名前・引数・戻り値は 1 つも変えない** - 実行ファイルの名前は **`fuseforks-cli`**(未決 1 の推奨を 2 系統とも支持)。GUI の bin 名 `fuseforks` と衝突しない ### D2. パス — data_dir を引数で受ける ```rust pub struct HostPaths { data_dir: PathBuf } impl HostPaths { pub fn data_dir(&self) -> &Path; // 端末ごとの棚(mcp_server.json / pricing.json / probe_approvals.json / jev.json) pub fn workspace(&self) -> PathBuf; // 常に data_dir/workspace } ``` - **村の場所は data_dir から導く 1 通りだけ**(欄を 2 つ持つと、将来どちらかを別に指したくなったときに 2 つが食い違う形が生まれる) - **GUI は Tauri の `app_data_dir()` を渡す**(今と同じ場所。Windows は `%APPDATA%\jp.outcasts.fuseforks`) - **CLI は `--data-dir` を必須にする。既定値を持たない**(未決 2 の推奨を 2 系統とも支持)。既定で GUI と同じ場所を 開くと、開発機で意図せず GUI の村をヘッドレスで触る形が一番起きやすくなる。D3 のロックは同時を止めるが、 「GUI を閉じた隙に CLI が予定を消化した」は止めない - 棚と村の 2 層は今と同じ。**「村を配っても扉は開かない・承認は付いてこない・単価の取得先は付いてこない」が そのまま「イメージに秘密や承認を焼かない」になる**(コンテナでは data_dir ごとマウントし、棚のファイルは 運用者が意図して置く) ### D3. 村の排他ロック — 同じ村を開けるのは 1 プロセスだけ - `build_host` の最初の手として `data_dir` と `workspace` を `create_dir_all` し(初回起動では無いので)、 `{workspace}/.fuseforks.lock` を作って OS の排他ロックを取る(`std::fs::File::try_lock`)。 取れなければ**それ以上何も開かずに** ~~`HostError::Locked`~~ `HostError::Lock(LockError::Held)` で返す (型名は P2 の実装で `Lock(LockError)` へ。`Held` = 別プロセス / `Io` = 作れない・ロックの無い OS) - GUI: 起動の覆いに「この村は別のプロセスが開いています(Fuseforks の GUI か、fuseforks-cli)」 - CLI: 終了コード 4(D10)と同じ文を標準エラーへ - **ロックファイルの存在ではなく OS のロックで判定する。** プロセスが落ちれば OS が外すので、強制終了の後に 残ったファイルを人が消す手順が要らない - ロックは `Host` が持ち、`Host` が Drop されるまで外さない - **MSRV を 1.89 へ上げる**(`File::try_lock` の安定化)。開発機は 1.98。CI の toolchain は stable なので影響なし。 **P0 で分かった — redb 4.1.0 自身が `rust-version = "1.89"` で、内部のロックも `File::try_lock`** (`file_backend/optimized.rs:31`。`WouldBlock` を `DatabaseAlreadyOpen` へ写しているだけ)。workspace の 1.85 は redb 4.1 を入れた時点で既に下回っており、上げるのは新しい制約ではなく宣言の追従。帰結として **ロックファイルと `sessions.redb` のロックは同じ機構**で、OS ごとの挙動が割れることはない - 単一インスタンスのプラグインは**残す**。役目が違う — プラグインは「2 つ目の GUI を前面化して閉じる」 (利用者への見せ方)、ロックは「村を 2 重に開かない」(データの安全) - **ロックの中身には何も書かない。** Windows の `LockFileEx` はロック中のファイルを他プロセスから読めなくする ので、PID を書いても相手は読めない。「誰が持っているか」を調べたいときは、Unix は `lsof `、 Windows は Resource Monitor の「関連付けられたハンドル」でファイル名を引く - **`sessions.redb` が「別のプロセスが開いている」で開けないときは、起動を止める(二重の網)。** 今の `bootstrap` はどんな理由で開けなくても WARN を 1 行出して「会話を保存しない起動」で続ける。 ロックが正しく効いていればこの経路には来ないが、**ロックの実装に穴があったときに、2 重オープンを黙って 続ける経路がここに残る**。`SessionStore::open` が redb の `DatabaseAlreadyOpen` を `CoreError::SessionStoreLocked` として区別して返し、`bootstrap` はそれだけをエラーで返す (壊れたファイル・権限など他の理由は今までどおり WARN で続ける — そちらは 2 重オープンではない) ### D4. 秘密 — `EnvSecretStore`(読み取り専用) ```rust /// 環境変数から秘密を読む(Spec 64)。**書けない。** set / delete はエラーを返す。 pub struct EnvSecretStore { /* 起動時に 1 回だけ読んだ写し */ } ``` - 鍵 → 変数名: `FUSEFORKS_SECRET_` + 鍵を大文字にして英数字以外を `_` へ(`claude_sonnet` → `FUSEFORKS_SECRET_CLAUDE_SONNET` / `jev_api_token` → `FUSEFORKS_SECRET_JEV_API_TOKEN`) - **起動時に 1 回だけ、`FUSEFORKS_SECRET_` で始まる変数を全部読む。** 以後は環境を見ない (ターンの途中で値が変わる経路を作らない)。ストアを作るのに `world.json` は要らない - **衝突は `bootstrap` の直後・MCP の接続より前に拒む**(D1 の順序)。数える鍵は **村のテンプレート ID の全部 + コードが持つ固定の鍵(`jev_api_token`)**。2 つ以上の鍵が同じ変数名に写るなら、 どちらの鍵か決められないので組み立てを止める(`HostError::SecretNameCollision`、終了コード 5)。 `bootstrap` は LLM も MCP も呼ばないので、ここで止めれば何も外へ出ていない - **値をどこにも出さない**(`SecretStore` の規律そのまま)。`check` が出すのは「どの変数が要るか / 有るか」だけ - **既定は keyring のまま。** CLI は `--secrets keyring|env` で選ぶ(既定 keyring)。GUI は常に keyring - `secret.rs` の doc「なぜ環境変数ではないのか」は**デスクトップの話として残し、1 段落足す** — 「コンテナには資格情報ストアが無い。デプロイ時に注入する環境変数が正しい置き場。**書き込めない実装にしたのは #1 の教訓の側** — 画面から設定したキーが環境変数や平文のファイルへ流れる経路を作らない」 ### D5. GUI でしか解けない待ちを、起動前に名指しする **コマンドの構文**: ```text fuseforks-cli check --for ask|serve --data-dir --start <集合> [--secrets keyring|env] [--bypass-plan-review] [--run-approval ] [--json] ``` - `check` は**検査したいコマンドと同じ引数を取る**(`--for` で `ask` か `serve` を名指しする)。起動する集合も 秘密の読み先も、実際に走らせるときと同じものを検査しないと、CI で `check` が通ってコンテナで落ちる(逆も) - 出力は人が読む文(既定)か、`--json` で `{ "findings": [ { "level", "code", "message", "fix" } ] }` - `ask` と `serve` は**自分の起動の途中で同じ検査を走らせ**、拒否が 1 件でもあれば LLM を 1 回も呼ばずに止まる (`check` を別に打たなくても安全側に倒れる) **検査の本体**はコアの純関数: ```rust pub enum HeadlessMode { Ask, Serve } pub struct HostView<'a> { pub mode: HeadlessMode, pub start: &'a BTreeSet, // 起動する集合(D6 で解決済み。ask では窓口を含む) pub secret_present: &'a dyn Fn(&str) -> bool,// 選んだストアにその鍵があるか(値は見ない) pub probe_approved: &'a dyn Fn(&ScheduleProbe) -> bool, // 棚の承認で通るか pub jev_token_present: bool, pub jev_pruning_enabled: bool, // jev.json pub bypass_plan_review: bool, pub run_approval: RunApproval, } pub fn headless_preflight(world: &World, schedules: &[ScheduledTask], view: &HostView) -> Vec; ``` **材料はファイルと秘密の有無だけ。LLM も MCP も 1 回も呼ばない** — `check` は CI やコンテナのビルド時に安く回せる。 | 検査 | 対象 | 重さ | 理由 | |---|---|---|---| | 計画の確認(`planReview`)が ON の個体が起動する集合に居る | 両方 | **拒否**(`--bypass-plan-review` で通す) | 波が人の承認を永久に待つ(Spec 43)。通すときは Spec 53 のスイッチを立てるだけで、新しい機構は無い | | 起動する集合のテンプレートに、選んだストアの秘密が無い | 両方 | **拒否** | 1 通目で 401 になり、`echo_on_failure` で偽の応答が返る — ヘッドレスでは誰も画面を見ていない | | 窓口(`reception`)が未設定・削除済み | ask | **拒否** | `ask_external` が即座に断る。起動して MCP を繋いでから断るより前で止める | | 窓口の接続先(委譲・転送・`plan` の相手)が起動する集合の外 | ask | 情報(`check` と `--verbose` のときだけ表示) | 委譲は `NOT_RUNNING` で返り、窓口が自分で答える。`--start reception` は利用者が選んだ形なので、毎回の警告にはしない | | 予定の宛先が起動する集合の外 | serve | 警告 | その予定は発火しても配送されない(GUI と同じ「停止中なのでスキップ」)| | コマンドの承認モードが「承認が必要」で、`run` を持つ個体が居る | 両方 | 警告(`--run-approval` で変えられる) | 待ちにはならない(未承認は拒否文が返ってターンは進む — `tools/run.rs`)。ただし `pending` は誰も承認しない | | 前判定・後判定のコマンドがこの棚で未承認 | serve | 警告 | 予定は発火しても配送しない(`unapproved`)。承認は棚の `probe_approvals.json` を GUI で作ってから一緒に持っていく | | 判断役があるのに Jev の鍵が無い / ツール結果の圧縮が ON で鍵が無い | 両方 | 警告 | 判断役は無効で起動し、圧縮は走らない(Spec 59 / 62 の述語どおり) | | MCP サーバーが `command` で起動する stdio | 両方 | 情報 | その実行ファイルがあるかは検査からは分からない(接続は起動時に試す)。**→ Spec 65 で撤去し、警告 `MCP_COMMAND_NOT_FOUND`(有効な stdio のコマンドが絶対パスなら存在、名前なら PATH に無い)へ置き換えた** | - 拒否と警告の文は**直し方を書く**(`failures.md` #44) ### D6. 起動する個体と、一括起動の不変条件 - **`batch_start_invariant`(アプリを開いた時点では誰も走らない)は GUI では不変。** **→ 2026-10-20 [Spec 69](69_launch-and-exit.md) で GUI に例外が 1 つ入った** — 端末側の明示のオプトイン 「起動したら一括起動」(既定 false・`{app_data_dir}/startup.json`・村には入らない)。ヘッドレスは変わらず `--start` が必須 - **ヘッドレスは `--start` を必須にする。既定値を持たない。** 値は次の 3 つ: | 値 | 起動する集合 | |---|---| | `batch` | 一括起動の対象(GUI の全体 ▶ と同じ述語 — `batch_start && (無所属 \|\| group.batch_start)`。Spec 51 凍結 7) | | `reception` | 窓口だけ(`ask` 専用) | | `,,…` | 名指しした個体 | - 不変条件の理由は「開いただけでトークンを払う作りにしない」。引数で `--start` を書くのは明示の意図で、開いただけではない - **`none` は持たない。** `serve` で誰も起動しないと、扉も予定も何も処理できない空転になる - **`ask` は、どの値でも窓口を集合に足す。** `ask` は窓口へ送る以外の動作を持たないので、窓口が止まっていると必ず失敗する - **`ask` と `check` では予定のティッカーを回さない**(`HostBootOptions.run_schedules = false`)。`ask` の間に期限の来た 予定が発火すると、頼んでいない仕事が同じプロセスで走る。コアは `OrchestratorConfig` に `run_schedules: bool` (既定 true = GUI はバイト等価)を 1 欄持ち、`bootstrap` はこれが偽ならティッカーを起こさない。 **消化の記録(`lastConsumedDueMs`)にも触れない**ので、次に GUI か `serve` で開いたときは今までどおり 「再開時に 1 回だけ」が働く - `serve` は予定を回す。宛先が起動する集合の外にある予定は、GUI と同じく「停止中なのでスキップ」で消化される(D5 の警告) ### D7. `ask` — 1 通送って、答えを出して、閉じる ```text fuseforks-cli ask --data-dir --start <集合> [--secrets keyring|env] [--continue-session] [--client <名前>] [--bypass-plan-review] [--run-approval ] [--events jsonl] [--verbose] <依頼文 | - で標準入力> ``` 1. 組み立て(`open_door = false` / `run_schedules = false`)→ D5 の検査 → 起動する集合を起動 2. **新しい会話を作る**(`--continue-session` のときは作らず、今の会話へ続ける) 3. `ask_external_outcome(client, message)` を 1 回呼ぶ(D10)。**送り手は `Endpoint::External`**(既定の名乗りは `fuseforks-cli`、`--client` で変えられる。村に外部クライアントの呼び名が設定されていればそれが勝つ — Spec 25 D8 の `world.json` の `externalName`) 4. 答えを**標準出力へそのまま**書く。改行は 1 つだけ足す 5. 起動した個体を全部止め、ターンが閉じて `turn:` 行と `Record::Turn` が書かれるのを待ってから、結末に応じた終了コードで終わる - **送り手を `User` にしない理由は Spec 25 D6 と同じ** — 端末から打つ人と、cron や CI から呼ぶスクリプトを プロセスの側から区別できない。人の依頼として封筒に書くと、そうでない場合に封筒が嘘になる - **既定を「新しい会話」にした理由**(rev2。査読 2-5): 今の会話へ続けると (a) GUI で使っている会話に cron の依頼が 積まれ、(b) 窓口の履歴(滑る窓 8 往復)に GUI の会話が乗ったまま評価が走る。**扉(MCP サーバー)は今の会話へ 続けるまま** — 扉は GUI が開いている間の外部の割り込みで、GUI の利用者がその場で見ている - **代償**: GUI は起動時に最も新しく更新された会話を開くので、`ask` の後に GUI を開くと `ask` の会話が開く。 会話の一覧から戻れる。機構は足さない - **待ちの上限は村の `ask_timeout`**(Spec 44。既定 600 秒) - **Ctrl+C(SIGINT / SIGTERM)を拾う。** `serve` と同じ閉じ方(D8)を通る。プロセスを即座に落とすと、 プロバイダ側では払っているのに `turn:` 行も `Record::Turn` も残らない(`failures.md` #103 の形) - **標準出力は答えだけ。** パイプで次のコマンドへ渡せるように - **`--events jsonl` の間は、標準エラーの全行が JSON**: `CoreEvent` は IPC と同じワイヤ形で 1 行 1 イベント、 CLI 自身の行(検査の結果・エラー・閉じ方の注記)は `{"type":"cli","level":…,"code":…,"message":…}`。 プレーンテキストの行を混ぜない(パーサが壊れる)。`--events` が無いときは人が読む文 ### D8. `serve` — 常駐して、予定と扉を回す ```text fuseforks-cli serve --data-dir --start <集合> [--secrets keyring|env] [--bypass-plan-review] [--run-approval ] [--events jsonl] ``` - 組み立て(`open_door = true` / `run_schedules = true`)→ D5 の検査 → 起動する集合を起動 → **SIGINT / SIGTERM(Windows は Ctrl+C)まで待つ** - **閉じ方(`ask` と共通)**: 新しい配送を止め → 飛行中のターンに打ち切りを送り(Spec 10 の `interrupt_all`)→ 個体を止め → ロックを外す。**猶予は 30 秒**で、超えたら打ち切りを待たずに終わる。そのときは払いの記録が 欠けうることを標準エラーに 1 行書く(`failures.md` #103 の「払ったのに記録に出ない」を黙って作らない)。 2 回目の Ctrl+C は猶予を待たずに終わる - コンテナの外から状態を見る口(`/healthz` 等)は**作らない**。プロセスが生きていること自体が状態で、 中身は `fuseforks.log` が持つ。要るようになったら別 Spec(agent-orchestrator の `running.json` + `/healthz` が参照実装) ### D9. 扉の bind は 127.0.0.1 のまま — コンテナでは同じネットワーク名前空間のプロキシで受ける **ギャップ 3(bind 固定)は、コードを変えずに越える。** - k8s の Pod や `docker run --network container:` では、同じ Pod / コンテナの中のプロキシから `127.0.0.1` へ届く。外向きの TLS・認証・回数の上限はプロキシが持つ(2026-09-22 の利用者の見立て 「外から繋ぐならサーバー認証で仲介する形」と同じ) - プロキシは `Host` を `127.0.0.1:` へ書き換える(rmcp の既定 `allowed_hosts` が loopback 3 種なので、 書き換えないと拒まれる)。**これはコンテナの Spec の手順で、本 Spec では DETAIL に 1 段落書くだけ** - 凍結(Spec 25 `mcp_server_contract` 凍結 4)は動かさない ### D10. 終了コード — 結末を読める形で返す | コード | 意味 | |---|---| | 0 | `ask`: 答えが返った(`Answered` / `HandedOff`)/ `check`: 拒否が 0 件 / `serve`: シグナルで正常に閉じた | | 1 | コアがエラーを返した(`CoreError`。`code` と文面を 1 行) | | 2 | 引数の誤り | | 3 | D5 の検査で拒否された(`check` は拒否が 1 件以上) | | 4 | 村のロックが取れない(D3)/ `sessions.redb` を別のプロセスが開いている | | 5 | 組み立てに失敗した(`world.json` が壊れている・**無い**(`--data-dir` に村が無い)・秘密の変数名が衝突した 等) | (**`world.json` が無い**を足したのは Spec 65 P3 の後 — それまで `check` は「起動する集合: (なし)・問題ありません」で 0 を返し、`serve` は空の村を作って走っていた。`PreflightError::VillageMissing`) | 6 | `ask`: 答えが返らなかった(`NoAnswer` / `Undeliverable`) | | 7 | `ask`: 待ちの上限を超えた(`TimedOut`) | | 8 | `ask`: 打ち切られた(`Interrupted` — Ctrl+C を含む) | | 9 | `ask`: 予算の天井で止まった(`BudgetExhausted`) | - **`ask_external` は配送の結末を既に受け取って捨てている**(前提の実測)。コアに `ask_external_outcome(client, message) -> CoreResult<(String, PlanTaskState)>` を足し、`ask_external` はそれを呼んで 状態を捨てる 1 行になる。**扉(MCP サーバー)の挙動は 1 つも変わらない**(Spec 25 の 「失敗も文字列で返る — 会話の事実であって扉の故障ではない」はそのまま) - 6〜9 でも**答えの本文(定型文)は標準出力へ書く**。終了コードは機械が読む結末、本文は人が読む結末 - rev1 で予算切れを 0 にしていた理由(「プロセスの失敗ではない」)は正しいが、**コンテナと CI が主な使い道で、 日本語の定型文を解析しないと結末が読めない形は Goal 5 と矛盾する**(査読 2-6)。査読 1 の「分けるには `ask_external` のシグネチャを変える必要がある」は、状態が既に手元にあることで当たらない ### D11. 版番号 - `build_host` は `HostBootOptions.app_version` を受け、今と同じ `version: app=… profile=…` の行を書く - GUI は今までどおり `package_info().version` - CLI は**ビルド時に `build.rs` で取る**: `git describe --tags --abbrev=0`(タグ)と `git rev-parse --short HEAD` (コミット)。**どちらかが取れないとき(`.git` の無いソースの zip・shallow clone・git が無い)は panic せずに落とす** — タグが無ければ `0.0.0`、コミットが無ければ付けない。結果は `0.4.0+g1b33d9d` / `0.0.0+g1b33d9d` / `0.0.0` - `0.0.0` はステータスバーと同じ規則(打っていないリリースを名乗らない)。`+g` は `failures.md` #112 の 「手元のビルド同士は区別できない」をヘッドレスの側だけ埋める(GUI は `build.rs` の射程の外のまま) - **GUI と CLI で手元ビルドの版の書き方が違う**(GUI のログは `0.1.0`、CLI は `0.0.0+g…`)。同じ村のログに 両方が並んだとき、`profile=` と `app=` の形でどちらの実行かが読める。P1 の起動ログの突き合わせは GUI 同士の比較なので、この差は当たらない ### D12. 配布はしない(この Spec では) **→ 行き先(2026-10-07)**: [Spec 65](65_container-deploy.md) でも `fuseforks-cli` は単体で配らない(未決 5)。像も公開せず、 `deploy/Dockerfile` を置いて運用者がソースからビルドする(未決 3)。下の「静的リンク」は採らなかった(Spec 65 D7 — 配るのは実行ファイルではなく像なので、glibc と `libdbus-1-3` を像に入れれば足りる) - `fuseforks-cli` は**ソースからのビルドだけ**(`cargo build -p fuseforks-cli --release`)。Release の アセット・winget・tap には足さない - 理由: 配るなら Linux 向けの静的リンク・コンテナの像・署名の扱いが要り、それはコンテナの Spec で一緒に決める。 先に実行ファイルだけ配ると、像を作るときに配布物の形を 2 回決めることになる ### D13. 外へ送るもの(PRIVACY) - **新しい送信先は無い。** ヘッドレスは GUI と同じコア・同じ設定で動くので、送るものは GUI と同じ - PRIVACY 日英に 1 節足す: 「GUI を使わずに実行した場合も、送るものは同じ。秘密を環境変数から読む形を選んだ場合、 値はプロセスの環境に置かれる(OS の資格情報ストアの保護は効かない)。置き場を選ぶのは実行する人」 ## 採らなかった形 1. **GUI を常駐プロセスのクライアントにする**(agent-orchestrator の形 — Electron が Go のデーモンへソケットで繋ぐ)。 「分離」をこの意味で取ると、IPC 117 本をすべてソケット越しの API にし、イベントの橋をネットワークに載せ、 GUI と常駐の寿命の結び方(`owner` + 握り続けるソケット)を設計することになる。**得るのは「GUI を閉じても 村が回り続ける」で、それは `serve` が GUI なしで既に与える**。GUI で設計して安定させ、無人では `serve` で回す、 という利用者の順番(2026-09-14)には、GUI がコアを同じプロセスに持つ今の形で足りる(査読 2 系統とも支持) 2. **CLI に既定の data_dir を持たせる** — D2 3. **ロックファイルの存在で判定する** — D3。強制終了の後に残る 4. **扉を `0.0.0.0` で開く設定を足す** — D9。トークンの漏れがそのまま外への穴になる。プロキシで受ければ要らない 5. **`ask` で窓口以外の個体へ直接送る `--to`** — 外部の入口が 2 本になり、Spec 25 の「村の中はブラックボックス」と 「同時 1 本」の門を 2 か所で守ることになる。窓口を変えたいなら `world.json` の `reception` を変える 6. **検査を警告だけにする** — 計画の確認の待ちはヘッドレスでは**永久に解けない**ので、警告にすると 「動かないが理由は 1 行だけログにある」を作る 7. **扉を開く処理を `build_host` の外に出す**(査読 1-1 の案の片方)— GUI の起動ログの行の順序が変わる(D1) 8. **`--start` に既定値を持たせる**(`ask` は `none`、`serve` は `batch` に分ける案を含む)— 同じ引数がコマンドで 違う意味になる。`serve` を既定 `batch` にすると、`ask` と `serve` を打ち間違えたときに一括起動の全員が 動き出す。必須にすれば、打った引数がそのまま起動する集合になる ## Tasks ### P0 — 測ってから凍結する - [x] GUI の起動ログ(`fuseforks.log` の起動から `jev:` 行まで)を今のビルドで採取し、P1 の比較の基準にする (2026-10-06。「P0 実測記録」) - [x] `File::try_lock` の挙動を 3 OS で確かめる(同じプロセス内で 2 回取ったとき / 別プロセス / プロセスを殺した後)。CI のランナーで (同日。probe は `922fdaf`・3 OS とも予測どおり) - [x] redb 4.1 の `Database::create` が、別プロセスが開いているときに `DatabaseAlreadyOpen` を返すことを確かめる (同じプロセス内で 2 回開いたときと区別して)(同日。**区別できない** — 同じ値が返る。「P0 実測記録」) - [x] `data_contract.yaml` に `headless_host_contract` を凍結(D2 / D3 / D4 / D5 の拒否 3 つ / D6 / D7 / D10) と、`batch_start_invariant` への注記(GUI では不変・ヘッドレスは `--start` を必須にして明示のときだけ) (同日。凍結 15 本 + `session_store` / `codes` に `SESSION_STORE_LOCKED`) ### P1 — ホストの切り出し(挙動を 1 つも変えない) - [x] `crates/fuseforks-host` を新設し、4 ファイルを**中身を変えずに**移す(1 コミット 1 ファイル。`use` と可視性だけ) (2026-10-06。`84603df` / `be9b362` / `439654d` / `e7b861d`。「P1 実装記録」) - [x] **別のコミットで** `mcp_server.rs` の `tauri::async_runtime::spawn` を `tokio::spawn` へ替える。 `tests/mcp_server_wire.rs` が緑のまま(移動の差分と中身の差分を混ぜない — 巨大ファイル分割の 6 箇条の 3) (同日。`e713e48` — 移動の**前**に剥がした) - [x] `build_state` の本体を `build_host(paths, HostBootOptions)` へ移し、GUI は呼ぶだけにする(同日。`038c7c6`) - [x] `commands.rs` の読み替え(`state.host.…`)。IPC の名前・形は不変(`tests/ipc_contract.rs` が緑)(同日。154 箇所) - [x] P0 で採った起動ログと 1 行ずつ一致することを確かめる(同日 09:20。6 行とも種類・順序・書式が同じ。「P1 実装記録」) ### P2 — 村の排他ロック(GUI にも効く唯一の挙動の変化) - [x] `lock.rs` + `build_host` の最初の手(`create_dir_all` の後)。MSRV 1.89 (2026-10-06。MSRV は `f7ebb37`・ロックは `e7eecd6`。「P2 実装記録」) - [x] `CoreError::SessionStoreLocked` と、`bootstrap` がそれだけを止める分岐(同日。`e7eecd6`) - [x] GUI の起動の覆いの文言(ja / en)(同日。`BootStatus.errorCode` = `VILLAGE_LOCKED` の印で画面が選ぶ) - [x] 結合テスト: 同じ村を 2 回 `build_host` すると 2 回目が `Locked` / 1 回目を Drop した後は取れる / 別プロセス(テストから子プロセスを起こす)でも取れない / ロックを迂回して `sessions.redb` だけを 開いておくと `bootstrap` が `SessionStoreLocked` で止まる(同日。host 3 本 + core 2 本 + 単体 4 本) ### P3 — コア - [x] `EnvSecretStore`(変数名の写像・set/delete はエラー・値をエラー文に載せない)と衝突の検査(固定の鍵を含む) (2026-10-06。「P3 実装記録」) - [x] `headless_preflight`(純関数。D5 の表の 9 行を 1 つずつ単体で、`Ask` と `Serve` の両方で)(同日。行 → 識別子は 11) - [x] `OrchestratorConfig::run_schedules`(偽ならティッカーを起こさない。既定 true でバイト等価)(同日) - [x] `ask_external_outcome`。`ask_external` はそれを呼ぶ 1 行へ(扉の結合テストが緑のまま)(同日) ### P4 — 実行ファイル - [x] `apps/cli`(`fuseforks-cli`): `check` / `ask` / `serve`、引数の解析、終了コード、Ctrl+C、`build.rs` の版番号 - [x] 結合テスト: 秘密の無いテンプレートの村で `check --for ask` が 3 / echo のバックエンドで `ask` が答えを標準出力へ 書いて 0 / ロック中に開くと 4 / `--events jsonl` のとき標準エラーの全行が JSON として読める / `ask` の天井を小さくした村で 9 / `--start` を省くと 2(+ 変異 M4 で見つけた穴: 拒否された `ask` は LLM を 1 回も呼ばずに 3) ### P5 — 台帳 - [x] DETAIL 3 言語に「GUI なしで動かす」の節(D9 のプロキシの 1 段落を含む)/ README 3 言語に 1 行 / PRIVACY 日英(D13)/ CLAUDE.md の「core 単独実行の構想」のギャップ 6 つに、どれを閉じたかを書き戻す / `secret.rs` の doc(**P3 で入っていた** — D4 の段落はそのまま) ### P6 — 実機 - [x] 開発機の村を `--data-dir` でコピーし、`check` → `ask` → `serve` を通す - [x] GUI を開いたまま同じ村へ `ask` → 4 で止まる / 逆に `serve` 中に GUI を開く → 覆いに文言 - [x] `--secrets env` で、keyring に何も無い状態から `ask` が通る - [x] 計画の確認 ON の進行役が居る村で `serve --start batch` → 3 で止まり、`--bypass-plan-review` で通る - [x] `ask` の途中で Ctrl+C → 8 で終わり、`turn:` 行が残る(**複数周のターンのとき**。打ち切りの払いの行は `turn interrupted:`。1 周で答え終わるターンは答えが返って 0 — P6 実機記録) ## P0 実測記録(2026-10-06) ### 起動ログの基準(P1 で 1 行ずつ突き合わせる) 配布版 0.4.0 の直近 5 回の起動(2026-10-01〜10-05・`%APPDATA%\jp.outcasts.fuseforks\workspace\fuseforks.log`)で、 起動の帯は**常に 6 行・同じ並び**: ```text [fuseforks] 起動しました [fuseforks] version: app=0.4.0 profile=release [fuseforks] session: を開きました(発話 N 件 / 履歴 N 体 / 要約 N 体) [fuseforks] attachment gc: removed=N remaining=N bytes=N kinds=image:N,audio:0,video:0,pdf:0 [fuseforks] mcp server: 127.0.0.1:39641/mcp で待ち受けます [fuseforks] jev: enabled=true active=true blocked=false ``` - **1 行目の `起動しました` は `open_log`(`diag.rs:113`)が出す。** P0 の採取は `version:` から切っていたので この 1 行を数え落としていた — P1 の突き合わせで、開発ビルドの帯に `version:` の前の行があることで気づいた (基準を「5 行」と書いたのは誤りで、ここで訂正した。並びの主張は変わらない) - 動くのは数字だけ(`session:` の id と件数・`attachment gc:` の件数)。10-01 の 1 回だけ `session:` の前に 「復元した履歴のうち 1 体分は、該当エージェントが居ないため捨てました」が 1 行入った(`session:` の WARN は この位置に出る) - `mcp server:` は扉が ON の村だから出る(OFF の村では行ごと無い)。MCP の初期接続・前判定の承認・単価表の取得元は この帯に行を出さない(失敗したときだけ) - **P1 の検収は「起動しました → version → (session の WARN) → session → attachment gc → mcp server → jev」の 並びが同じであること**。D1 の「扉を開く処理を `build_host` の外へ出さない」の根拠がこの並び — 外へ出すと `mcp server:` が `jev:` の後ろへ動く ### ロックの実測(`tools/lock_probe`・commit `922fdaf`・3 OS の CI・rustc 1.99.0) **予測を先に書いて撃った。3 OS とも 12 場面すべて一致(unexpected = 0)。** 開発機の Windows(rustc 1.98.1)も同じ。 | 場面 | Linux | macOS | Windows | |---|---|---|---| | `try_lock` 同じハンドルで 2 回目 | **Ok** | **Ok** | **WouldBlock** | | 同じプロセスの別ハンドル | WouldBlock | WouldBlock | WouldBlock | | 別プロセスが持つ | WouldBlock | WouldBlock | WouldBlock | | 持ち主が exit した後 | Ok・0 ms | Ok・0 ms | Ok・0 ms | | 持ち主を kill(SIGKILL / TerminateProcess)した後 | Ok・0 ms(ファイルは残る) | 同左 | Ok・0〜1 ms(同左) | | ロック中のファイルを他プロセスが open / read | Ok / **Ok(7 bytes)** | Ok / Ok(7 bytes) | Ok / **Err os=33**(`ERROR_LOCK_VIOLATION`・kind `Uncategorized`) | | redb `create`、同じプロセスで 2 回目 | DatabaseAlreadyOpen | 同左 | 同左 | | redb `create`、別プロセスが開いている | DatabaseAlreadyOpen | 同左 | 同左 | | redb、持ち主の exit / kill の後 | Ok・1 / 0 ms | Ok・3 / 3 ms | Ok・16 / 26 ms | - **redb は同じプロセスと別プロセスを区別しない** — どちらも `DatabaseAlreadyOpen`。理由は redb 4.1.0 自身が `File::try_lock` の `WouldBlock` を写しているだけだから(`file_backend/optimized.rs:31`)。**ロックファイルと `sessions.redb` のロックは同じ機構**で、D3 の「二重の網」は同じ網を 2 つのファイルに張る形。鳴るのは 「ロックを迂回して `sessions.redb` だけを開いた」ときに限る - **同じハンドルの 2 回目だけが OS で割れる**(Unix の `flock` は再取得を通し、Windows の `LockFileEx` は同じ範囲の 再ロックを `ERROR_LOCK_VIOLATION` で拒む)。std の doc も「未規定・OS 依存」。1 つの `File` で 2 回呼ぶ実装を 書くと Unix では黙って通る — `Host` が `File` を 1 つ持って 1 回だけ呼ぶ形で避ける - **Windows ではロック中のファイルを他プロセスが読めない**(open は通り read が落ちる)。D3 の「中身を書かない」の 実測の裏付け — 書いても Unix でしか読めず、Windows では read が `Uncategorized` で落ちる - 殺した後は 3 OS とも 1 ms 以内に取れ、ファイルは残る = 「存在ではなく OS のロックで判定する」の根拠 - redb は殺した後も 30 ms 以内に `Ok`。修復が走ったかは観測していない(redb はログを持たない) - **redb 4.1.0 の `rust-version` は 1.89**。workspace の宣言 1.85 は redb 4.1 を入れた時点で既に下回っていた (cargo の MSRV-aware resolver は 1.85 互換の redb 4.1 が無いので fallback している)。D3 の「MSRV を 1.89 へ」は 宣言の追従 - probe と手動 workflow(`.github/workflows/probe-lock.yml`)は数字を写したので同日に消した(Spec 34 / 48 と同じ扱い。 `git show 922fdaf` で戻せる)。P2 の結合テストは同じ場面を実装の側で留める ### 凍結 `data_contract.yaml` の `headless_host_contract`(15 本)+ `batch_start_invariant` の注記 + `session_store` と `codes` に `SESSION_STORE_LOCKED`。 ## P1 実装記録(2026-10-06) **コミット 6 本。各コミットで clippy 0・`cargo test --workspace` 全緑(59 バイナリ)・vitest 758。** | commit | 内容 | |---|---| | `84603df` | `crates/fuseforks-host` 新設 + `pricing_source.rs`(`reqwest` の依存も GUI からホストへ) | | `be9b362` | `probe_approvals.rs` | | `439654d` | `jev_settings.rs`(`jevSettingsWiring.test.ts` の走査先を追従) | | `e713e48` | `mcp_server.rs` の `tauri::async_runtime::spawn` → `tokio::spawn`(移動の**前**・別コミット) | | `e7b861d` | `mcp_server.rs` + `tests/mcp_server_wire.rs`(`rmcp` / `axum` / `uuid` / `tokio-util` / dev の `tower` も) | | `038c7c6` | `paths.rs`(`HostPaths`)/ `boot.rs`(`build_host` / `Host` / `HostBootOptions` / `HostError`)。GUI の `state.rs` は Tauri にしか無い 2 つを渡すだけ・`AppState { host }`・`commands.rs` の読み替え 154 箇所 | - **再公開で読み替えを起こさない。** GUI の `lib.rs` に `pub use fuseforks_host::X;` を置くと `crate::X::…` が そのまま解決するので、**4 ファイルの移動で `commands.rs` は 1 行も変わらなかった**。読み替えが要ったのは `build_host` の着地(`AppState` が `Host` 1 つになった)のときだけ - **「中身を変えずに」が破れた箇所は走査テストのパス 3 つ。** `pricing_source.rs` と `jev_settings.rs` の 「起動経路が取得 / 接続の確認を呼ばない」の走査は `CARGO_MANIFEST_DIR` 相対で `src/state.rs` を読んでいたので、 crate を移った瞬間に読む先が消えた(移動のコミットでは GUI の `state.rs` を `../../apps/…` で指し、 `build_host` の着地で `src/boot.rs` へ)。`jevSettingsWiring.test.ts` も同じ。**走査テストは「どの crate に 居るか」を暗黙の入力に持つ** — `failures.md` #101(テストがホストの OS ロケールを暗黙に受けていた)と同じ形 - **`HostBootOptions` は 2 欄で着地**(`app_version` / `open_door`)。D1 の 4 欄のうち `secrets` と `run_schedules` は 機構が着地する P3 で足す(今日足すと読まれない欄になる) - **`HostError` は `transparent`。** 起動の覆いが読むのは `to_string()` で、包み直すと「どこで落ちたか」が 1 段ぼやける。 覆いの文面は 1 文字も変わらない - **読み替えの機械置換は改行で割れたメソッド連鎖を拾わない。** `state\n .orchestrator` の形が 19 箇所あり、 1 行の `sed` では 135 箇所しか当たらなかった(clippy の E0609 で分かった)。`perl -0` で改行をまたいで当て直して 154 箇所。差分の全行が `.host.` の有無だけで対になることを数えてからコミットした - `missing_docs = "warn"` をホストにも敷いたが、移した 4 ファイルは 1 件も警告を出さなかった - **起動ログの突き合わせ(検収)— 一致。** `038c7c6` の開発ビルド(`bun run tauri dev`・2026-10-06 09:20)の帯: ```text [fuseforks] 起動しました [fuseforks] version: app=0.1.0 profile=debug [fuseforks] session: 1791191865252-4ff5da88 を開きました(発話 15 件 / 履歴 1 体 / 要約 0 体) [fuseforks] attachment gc: removed=0 remaining=2 bytes=29894 kinds=image:29894,audio:0,video:0,pdf:0 [fuseforks] mcp server: 127.0.0.1:39641/mcp で待ち受けます [fuseforks] jev: enabled=true active=true blocked=false ``` P0 の基準(配布版 0.4.0・同じ村)と**行の種類・順序・書式が 6 行とも同じ**。違うのは数字だけ — `app=0.1.0 profile=debug`(手元のビルドの版の規則どおり)/ 発話 15 件・履歴 1 体(村がその後使われた)/ gc の `removed=0`(前回 1 件消した後)。`version:` の前に `起動しました` があることで、P0 の基準が 1 行目を 数え落としていたことが分かった(「P0 実測記録」に訂正を書いた) - **1 回目の起動は無駄撃ちだった。** 利用者の 0.4.0 GUI が動いていて、単一インスタンスのプラグインが 2 つ目を 止めた(識別子が同じなので配布版と開発ビルドの間でも効く — exit 0・ログに 1 行も出ない)。動いていないと 読んだのは自分の検査の誤り — Git Bash の `tasklist /FI` は `/FI` をパスに変換して落ち、`2>/dev/null` で 握っていたので「出力なし = 動いていない」と読んだ(`MSYS_NO_PATHCONV` の 3 例目。プロセスの有無は PowerShell の `Get-Process` で見る)。利用者に GUI を閉じてもらってから取り直した - 副産物: 開発ビルドの vite のコンソールに `[intlify] Not found 'grounding.engine.perplexity' key in 'ja'` が出た(本 Spec とは無関係。別件。**→ 2026-10-06 に直した — `failures.md` #141**) ## P2 実装記録(2026-10-06) **コミット 2 本。** `f7ebb37` = MSRV 1.89(宣言の追従)+ それで解禁された clippy の指摘 9 件 + README 3 言語 / `e7eecd6` = ロック本体。clippy 0・Rust 61 バイナリ 1,221 本緑・vitest 759・vue-tsc 0。 - **ロックはログより前。** `create_dir_all` の直後に `VillageLock::acquire` し、取れなければ `open_log` も 呼ばずに返す。相手がログへ書いている最中に、こちらが同じファイルを開いて `起動しました` を混ぜ込まない。 帰結として**ロックで止まった起動は `fuseforks.log` に 1 行も残らない**(stderr と覆いにだけ出る) - **unlink しない。** Drop で消すと、同じパスを開いて待っていた相手が消えた inode のロックを取り、その隙に 新しいプロセスが新しいファイルを作って別のロックを取る(lock file の unlink 競合)。解放は `File` の close だけに任せる。D3 の「存在ではなく OS のロックで判定」はここでも効く - **`HostError::Lock(LockError)`** で `Held`(別プロセス)と `Io`(作れない・ロックの無い OS)を分ける — 直し方が違う。どちらも村を開かない(fail closed) - **覆いの文言は印で選ぶ。** ホストは村の言語を知らない(言語は `world.json` に住み、ロックはそれを読む前に 取る)ので、`BootStatus` に `errorCode`(`VILLAGE_LOCKED` のときだけ)を**加算**し、画面が `orchestrator.villageLocked`(ja / en)を選ぶ。印の無い失敗は今までどおり原文。旧いフロントは欄を無視する - **`SessionStoreLocked` は `DatabaseAlreadyOpen` だけを写す。** `bootstrap` が止めるのはこれだけで、壊れた ファイル・権限は WARN で「会話を保存しない起動」を続ける(対照のテストで留めた) - **別プロセスのテストは自分を持ち主役として起こす** — `current_exe()` を `--exact lock_holder_process` で起動し、 環境変数があるときだけその関数がロックを取って `holder.ready` を書き、標準入力が閉じるまで持つ(無ければ 何もしない空のテスト)。P0 の probe と同じ形で、テストバイナリの外に実行ファイルを置かない - **「Drop の後は取れる」は `build_host` ではなく `VillageLock::acquire` で確かめた。** `Host` を落としても オーケストレーターの背景タスク(予定・統計のティッカー)が `Shared` を握ったままなら `sessions.redb` は 開いたままで、もう 1 度 `build_host` すると凍結 4 の網に当たる。ロックの寿命を確かめる検査にその経路を混ぜない - **旧挙動に寄りかかっていたテストが 1 本。** `schedule_probe_wiring::a_probe_is_persisted_and_reloads` が前の オーケストレーターを握ったまま `bootstrap` し直しており、WARN で続く旧挙動で通っていた。再起動は前のプロセスが 閉じていることなので、`session_persistence.rs` の `shutdown` と同じく drop + `yield_now` してから開き直す形へ (1,221 本中この 1 本だけ) - **MSRV を上げると clippy の指摘が 9 件増えた** — `collapsible_if`(let 連鎖。1.88 で安定)×8 と `is_multiple_of`(1.87)×1。宣言が古かった間は MSRV で黙っていたもの。`cargo clippy --fix` は本体の字下げを 1 段深いまま残すので 9 箇所とも手で整えた。ロックのコミットより前に置く(`try_lock` は 1.85 宣言のままだと `incompatible_msrv` に当たる) - **道具の罠 2 つ**: `cargo test --workspace` が `LNK1104` / `0xc000009a` で 2 回落ち、`-j 6` で通った (`failures.md` #132 の家族。vitest と並走させたときに出た)/ hunk を選んで `git apply --cached` する Python が**text モードの stdout で `\n` を `\r\n` に変えて** patch を壊した(`sys.stdout.buffer` で書く。 `-X utf8` だけでは足りない) - **実機(P6 の「serve 中に GUI を開く」の前倒し)**: 持ち主役(`village_lock` の `lock_holder_process` を 環境変数つきで起こす)に開発機の村のロックを握らせ、開発ビルドの GUI を起動した(2026-10-06 10:14)。 stderr に `初期化に失敗しました: この村は別のプロセスが開いています(Fuseforks の GUI か、fuseforks-cli): …\workspace\.fuseforks.lock` が 1 行出て、**`fuseforks.log` には 1 行も増えなかった**(ロックがログより前の 設計どおり)。覆いの文言(`orchestrator.villageLocked`)は vitest の `useOrchestrator.boot.test.ts` で `errorCode` の有無による切り替えを留めた(画面の目視は P6)。1 回目は前回の `tauri dev` が残した vite が ポート 1420 を握っていて `beforeDevCommand` が落ちた — `Stop-Process -Name fuseforks` は GUI だけを殺し、 vite は残る ## P3 実装記録(2026-10-06) **コミット 1 本。** コア 3 つ(`secret.rs` の `EnvSecretStore` / 新設 `headless.rs` / `run_schedules` と `ask_external_outcome`)+ ホストの `HostBootOptions` 4 欄。clippy 0・Rust 63 バイナリ 1,247 本緑(PR #5 を取り込んだ後)・ vitest 760・vue-tsc 0・変異 6 本とも狙った 1 本だけ赤。 **GUI の起動はバイト等価**(`HostBootOptions::gui` = keyring・予定を回す・扉を開く。`run_schedules=false` の ときだけ出る `schedule: ティッカーは起こしません` の行は GUI では出ない)。 - **`EnvSecretStore`** — `from_env`(UTF-8 でない変数は読まない)/ `from_vars`(テストと、環境を差し替えたい 呼び出し側のため)/ `variable_names`(`check` が「どの変数が有るか」を出すため。値は返さない)。`set` / `delete` は `CoreError::SecretStore` を返し、**文面には変数名だけを載せる**(「`FUSEFORKS_SECRET_X` を設定して起動し直して ください」)。写像は `env_secret_name`、衝突は純関数 `secret_name_collisions` で、**どちらもコアに置いた** — `headless_preflight` が直し方の文に変数名を書くのに同じ写像を使うため - **衝突の検査は `opts.secrets == Env` のときだけ、`bootstrap` の直後・同梱ツールの登録と MCP の接続より前。** 数える鍵は村のテンプレート ID の全部 + `jev_settings::TOKEN_KEY`。`HostError::SecretNameCollision` の文は `変数名 ← 鍵 / 鍵` の形で値を載せない(値はそもそも読んでいない)。**固定の鍵を数えないと、テンプレート ID が `jev-api-token` の村で Jev のトークンとテンプレートの鍵が同じ変数を指す** — 結合テストを 1 本足して留めた - **`secret.rs` の doc の 1 段落(P5 の項目)はここで書いた** — 「なぜ環境変数ではないのか」はデスクトップの話として 残し、コンテナでは逆になること・読み取り専用にしたのは #1 の教訓の側であることを足した。型の隣の doc は型と 一緒に動くのが正しい単位(Spec 27 P0 で `event.rs` の doc を P1 へ送ったのと同じ判断) - **`headless_preflight` は D5 の表の 9 行を、閉じた識別子 11 個で返す。** 行と識別子が 1 対 1 でないのは 2 行: - 2 行目(秘密が無い)を `SECRET_MISSING` / `MODEL_TEMPLATE_MISSING` へ — **テンプレートが村に無い個体は秘密の 有無が問えない**。引けなかった個体を黙って飛ばすと「拒否 0 件」で通り、起動後に別のエラーで落ちる - 3 行目(窓口)を `RECEPTION_UNSET` / `RECEPTION_MISSING` へ — 直し方が違う(初めて設定する / 設定し直す。 Spec 25 P1 が `EXTERNAL_RECEPTION_UNSET` と `AGENT_NOT_FOUND` を分けたのと同じ区別) - 逆に 8 行目(判断役 / 圧縮)は理由が 2 つでも識別子は `JEV_TOKEN_MISSING` 1 つ(直し方が同じ) - **秘密の判定は `CredentialSource` で分けた** — `NotRequired` は問わない / `Unset` は選んだストアに関係なく拒否 (どのストアにも鍵が無い)/ `Keyring` だけ `secret_present` に訊く。文の直し方には env の変数名を書く - **`HostView` は D5 の素描より 1 欄多い** — `stdio_mcp_servers`(9 行目の材料)。`mcp.json` は `World` の外に あるので、共通と個体別を呼び出し側(P4 の CLI)が集めて渡す。純関数のままファイルを読まない - **結果は重い順(拒否 → 警告 → 情報)で、同じ重さの中は表の順(stable sort)。** 文は村の言語(未確定なら ja) - **並びのテストが最初は何も確かめていなかった(変異 M6 で発覚)。** 入力の指摘が表の順のまま重い順にもなって いたので、並べ替えを消しても緑だった。**表の順と重さが逆転するのは 4 行目(情報)が 5〜8 行目(警告)より上に あるところだけ**で、そこを踏む入力(窓口の接続先が集合の外 + 承認モード)に直して赤を確認した。 **一般化: 並べ替えのテストは、並べ替える前の順と後の順が違う入力で書く** — 同じなら並べ替えの有無を区別しない - **`run_schedules`** — `Orchestrator.schedule_task` を `Option` にし、偽なら起こさない(Drop は `if let Some`)。`lastConsumedDueMs` に触れないので、次に GUI か `serve` で開いたとき「再開時に 1 回だけ」が働く。 `runs_schedules()` を足したが、**テストはフラグではなく振る舞いで見る** — 期限の来た予定(作成時刻を 10 分前へ ずらした 1 分間隔)を置いて実ティッカーを 20 ms で回し、真なら消化・偽なら 500 ms 待っても `None`、を対で 確かめた(正の対照が無いと、ティッカーが何もしない実装でも偽の側は緑)。手動の `run_schedule_tick` は偽でも 呼べる(テストの足場) - **`ask_external_outcome`** — `deliver_and_wait` の結末をそのまま返し、`ask_external` はそれを呼んで状態を捨てる 1 行。`external_ask.rs`(扉側)は無改変で緑 - **予測を 1 つ外した** — `BudgetExhausted` の結末を「天井 5 なら初回の見積もりの床 1,000 に届かず、LLM を呼ばずに 止まる」と読んで echo で書いたら `Answered` が返った。**初回の見積もりは `min(床, 天井)` に丸められる** (`reserve_estimate_milli`)ので 1 周目は通り、echo は 1 周で答える。`external_ask.rs` と同じく毎周ツールを 呼ぶバックエンドで 2 周目を天井に当てる形へ直した(1 周目の実効 = 未キャッシュ 1 + 出力 1 × 4 = 5 でちょうど尽きる) - **keyring の組み立てを結合テストにしなかった** — `build_host` は Jev の設定を当てるときに資格情報ストアから `jev_api_token` を読みに行く(`jev_settings::apply` → `stored_token`)ので、テストから開発機や CI の資格情報 ストアに触れることになる。既存のテスト名 `…_only_for_env` は keyring で止めないことまで確かめているように 読めたので `colliding_secret_names_stop_an_env_boot` へ改名した - **変異 6 本**(予測を先に書き、戻した後に SHA-1 で一致を確認): 計画の確認の門を外す → 1 / `run_schedules` を 無視する → 1(偽の側)/ 接頭辞の絞り込みを外す → 1 / 固定の鍵を数えない → 1 / 結末を常に `Answered` → 1 / 並べ替えを消す → **0(上の穴)→ 直して 1**。回したのは各変異の crate の lib と、その変異を留める結合テストの バイナリだけ(全バイナリではない — #133 の形を避けるためバイナリを名指しした) - **道具の罠**: D: の空きが 0.1 GB になり、リンクが `LNK1104` / `LNK1318` / `LNK1180` / `0xc000009a` / os error 112 で落ちた。**正体はディスク満杯で、`target/debug` が 84.4 GiB** あった(`cargo clean` で解消)。 `failures.md` #132 の「リンクが落ちて 1 本も走っていない」と同じ症状の、別の原因 ## P4 実装記録(2026-10-06) **着地**: `apps/cli`(`fuseforks-cli`)= `args.rs` / `exit.rs` / `output.rs` / `run.rs` / `main.rs` + `build.rs`、 workspace の members へ追加。コアに `World::batch_start_ids` / `ConfigStore::read_village_id` / `diag::set_stderr_json`、ホストに `preflight.rs`(`boot.rs` から `secret_store` / `check_secret_names` を切り出し)。 CLI の単体 13 本 + 結合 7 本(`apps/cli/tests/cli.rs`)。workspace 全体で 65 ターゲット・1,269 本が緑・clippy 0。 **決めたこと**(次に触る人が要る順): - **`check` は `build_host` を通さない。** `build_host` は組み立ての途中で `reload_mcp()` を呼び、共通の `mcp.json` のサーバーへ繋ぐ(stdio なら子プロセスを起こす)ので、D5 の「`check` は LLM も MCP も呼ばない」が 成立しない。ホストに **`preflight.rs`** を新設した — ファイルと秘密の有無だけを読み、ロックを取らず、 `bootstrap` も MCP の接続もせず、何も書かない(村の識別子が無くても作らない)。**`ask` と `serve` も組み立ての 前に同じ `preflight` を呼ぶ**ので、拒否があれば LLM にも MCP にも触れずに止まる(D5 の「LLM を呼ばずに」より 1 段強い)。材料は `world.json` / `schedules.json`(宛先の無い予定は `bootstrap` と同じく数えない)/ 共通と起動する個体の `mcp.json` / `village_id` / 棚の `probe_approvals.json` と `jev.json` - **秘密の置き場と衝突の検査は 1 実装** — `boot.rs` から `secret_store(source)` と `check_secret_names(source, ids)` を切り出し、`build_host` と `preflight` が同じ関数を呼ぶ(検査が見た置き場と 組み立てが読む置き場を食い違わせない) - **`--start` の解決は `preflight` の中**(`resolve_start`)。`batch` = 新設の **`World::batch_start_ids`** (画面の `lib/agentGroups.ts` の `batchEligible` と同じ規則 — 個体のトグル × グループのスイッチ・引けない グループ id は無所属・非表示は見ない・並びは `order`)/ `reception` を `ask` 以外で使うと引数の誤り(2)/ 村に居ない id も 2 / **`ask` はどの値でも窓口を足す**(未設定・削除済みなら足さず、検査の拒否に任せる) - **村の識別子を書かずに読む口 `ConfigStore::read_village_id`** をコアに足した(`village_id()` は無ければ作る) - **`--events jsonl` の間は `note!` の標準エラーの行も JSON** — `diag::set_stderr_json(true)` で `{"type":"log","message":…}` の 1 行になる(ファイルの書式は変えない)。`note!` は**ログを開く前も後も必ず 標準エラーへ素の行を出していた**ので、これが無いと凍結 13(標準エラーの全行が JSON)が最初の 1 行で破れる。 本番の経路で標準エラーへ直接書いていたのは `boot.rs` の 1 行(ログを開けなかったとき)だけで、`note!` へ寄せた。 **凍結 13 の追補** — 行の種類は CoreEvent / `{"type":"cli"}` / `{"type":"log"}` の 3 つ - **版番号(`build.rs`)は HEAD だけでなく今のブランチの ref も見張る** — HEAD の中身は `ref: refs/heads/main` の ままでコミットでは動かないので、HEAD だけを `rerun-if-changed` にすると `+g` が古いまま残る。置き場は worktree で変わるので `git rev-parse --git-path` で解決し、無いパスは書かない - 引数は依存を足さずに手で解析(clap はツリーに無い)。`--flag value` と `--flag=value`・`--` の後は依頼文・ 旗は命令ごとに閉じる(知らない旗は 2)・`--run-approval` は `required | auto-approve | no-approval` - 終了コードの写像は `exit.rs` の純関数 3 本(結末 → コード / 組み立ての失敗 → 4 か 5 / 検査の材料の失敗 → 2 か 4 か 5)。`Running` は確定していないので 6(0 にすると確定していないものを成功と読む) - **標準出力は答え(`ask`)か検査の結果(`check`)だけ。** 検査の指摘・CLI の行・診断は全部標準エラー。 `check` の標準出力は人が読む形(指摘ごとの 2 行 + 件数)か `--json` の `{"findings":[…],"start":[…]}` の 1 行 (`start` は解決した起動する集合 — 「batch で誰が起動するか」を起動せずに読む唯一の口)。CoreEvent は `--events jsonl` のときだけ流す(人が読む形で流すと毎秒の統計で答えと指摘が埋もれる) - **指摘を jsonl で出すときは `type: "cli"` の行に `fix` を足す** — 行の種類を 3 つから増やさない(凍結 13) - **引数の誤りでも `--events jsonl` が書いてあれば JSON で出す** — 解析に失敗した後でも凍結 13 を守るため、 `main` が生の引数を先に走査する(`--` の後は依頼文なので数えない)。`set_stderr_json` も解析の直後・ ランタイムを作る前に立てる(組み立ての最初の `note!` から JSON) - **閉じ方は `close()` の 1 実装**(`ask` / `serve` / 起動の途中で落ちたとき)。扉 → `interrupt_all` → 起動した 個体だけ `stop_agent` → 呼び出し側が `Host` を落とす。猶予 30 秒は `select!` で、2 回目のシグナルも同じ `select!` の腕。`ask` の 1 回目の Ctrl+C は `interrupt_all` だけを打って答えを待ち続ける(打ち切られた結末 = 8 を コアから受け取る)、2 回目は待たずに 8 - **`main` はランタイムを `shutdown_timeout(2 秒)` で落とす** — MCP の子プロセスや残った待ちで終了が延びない - **結合テストの村は本物の保存の経路で作る** — テストの中の別ランタイムで `Orchestrator::bootstrap` → 登録 → ランタイムごと落とす(`sessions.redb` を手放させる)。**村を作る側のストアにも鍵を置く** — `upsert_template` は 秘密の裏付けの無い keyring 主張を「未登録」へ引き戻す(`failures.md` #16 の網)ので、置かないと CLI の `--secrets env` に鍵があっても `SECRET_MISSING` になる(最初の実行で 4 本が赤 = テスト側の誤り)。 スタブは std の `TcpListener` を別スレッドで持つ(子プロセスの実行中ずっと応答する)。子プロセスは 120 秒で殺す (`failures.md` #86 — 待ちが壊れても永久に返らない形を作らない) **変異**(予測を先に書いた。復元は scratchpad の写しから戻し SHA-1 で確認): | # | 変異 | 予測 | 実測 | |---|---|---|---| | M1 | `ask` の答えの `println!` を消す | 3(答え / jsonl の標準出力 / 予算の定型文) | 3 | | M2 | `main` の `set_stderr_json(true)` を消す | 1(jsonl) | 1 | | M3 | ロックの網を 5 へ写す | 2(exit の単体 + ロックの結合) | 2 | | M4 | `ask` の「拒否なら止まる」を消す | **0(穴の確認)** | 0 → 7 本目を足して 1 | - **M3 の 1 回目は変異の書き方が誤りだった** — 腕を消すと match が網羅でなくなってコンパイルが落ち、結果行が 1 行も出なかった(「赤が 0」と区別が付かない)。腕を残して写し先だけ変えて入れ直した。**変異の結果は `test result` の行が出たことまで見る**(`failures.md` #132 の同族) - **M4 は予測どおりの穴** — Tasks の 6 本は `ask` が拒否で止まる経路を 1 本も通らない(拒否は `check` でしか 見ていない)。7 本目 `ask_stops_on_a_rejection_without_calling_the_llm` = 秘密の無い村で `ask` → 3 / **スタブが受けた要求が 0 件**(D5 の「LLM を 1 回も呼ばない」を数で読む)/ ロックファイルも作られていない (組み立ての前で止まった)。足した後の M4 で新しい 1 本だけが赤 - workspace の全テストの 1 回目は `session_persistence` のリンクが `link.exe` 1181 で落ち、**集計が空**だった (1 本も走っていない)。単体で組み直すと通り、全体を回し直して 1,269 本。ディスクの空きは 34 GiB あり、 P3 のディスク満杯とは別の一過性の失敗(掴み合いと見ているが確かめていない)— **→ P6 で、同じディスク満杯だったと ほぼ確定**(P6 実機記録の「ディスク」) ## P5 台帳記録(2026-10-06) - **DETAIL 3 言語**: 運用の末尾(「外の LLM から依頼を受ける」の後)に「GUI なしで動かす」の節 — 3 つの命令 / `--data-dir` と `--start` が必須の理由 / 起動前検査の表(拒否 3・警告・情報)/ 秘密の変数名の写像 / `ask` の 新しい会話と代償 / 終了コードの表 / `--events jsonl` の 3 種 / 閉じ方 / 村のロックが GUI にも効くこと / 版番号 / D9 のプロキシの 1 段落。中国語版は抄訳なので箇条書き 10 行 - **P1 の移動にディレクトリ木が追従していなかった**(回収)— 3 言語とも `mcp_server.rs` / `pricing_source.rs` / `jev_settings.rs` / `probe_approvals.rs` を `apps/gui-tauri/src-tauri/src/` の下に書いたままで、 `crates/fuseforks-host` も `apps/cli` も木に無かった。`state.rs` の説明も「オーケストレーター組み立て」のままだった。 **P1 で「台帳は P5」と決めた帰結**で、P1〜P4 の間は台帳が実装と 4 ファイルぶん食い違っていた。「クレート分離の保証」の 依存の図も `apps/gui-tauri → crates/fuseforks-core` の 1 本のままだった(`host` を挟んだ 3 段 + `apps/cli` へ) - **ワークスペースの木に `.fuseforks.lock`**(DETAIL 日英 + PRIVACY 日英の表)、**API キーの置き場**に 「GUI なしのときだけ環境変数からも読める(読み取り専用)」の段落(DETAIL 3 言語) - **README 3 言語**: 「何ができるか」の表に 1 行(172 / 172 / 168 行)。あわせて技術スタックの「このクレートだけで headless に動く」を、実際に GUI なしで動く形(`fuseforks-cli` = コア + ホスト層)に合わせて同じ行のまま直した。 「思想」の節の同じ趣旨の 1 行(コアは GUI に依存しない)は、コア単体の性質としては今も正しいので触っていない - **PRIVACY 日英**: 3 節(認証情報)に「GUI を使わずに実行する場合」の小節 — 送るものは GUI と同じ / 環境変数を 選ぶとキーはプロセスの環境に置かれ資格情報ストアの保護は効かない / 選ぶのは実行する人 / 読むだけで書かない。 最終更新日を 2026-10-06 へ - **CLAUDE.md**: 「core 単独実行の構想」のギャップ 6 つの各項に「→」で書き戻し(閉じた 4 = 1・2・5・6 / コードを変えずに越える 1 = 3 / 既に解けていた 1 = 4)。README の行数の記録に 1 行 - **触っていない外向きの面**: ランディングページと Qiita の記事は固有の機能を列挙していないので嘘にならない。 `fuseforks-cli` は配布していないので winget / tap / Release のノートにも入らない ## P6 実機記録(2026-10-06) **写し**: 開発機の村(`%APPDATA%\jp.outcasts.fuseforks`)を scratchpad の `p6-data` へ写した。GUI が `sessions.redb` を 握っていたので**それとログ・添付・書き出しを除いた**(`ask` は既定で新しい会話を作るので要らない)。予定 2 件はどちらも 無効で、`serve` で発火しない。 | # | 項目 | 結果 | |---|---|---| | 1 | 写しで `check` → `ask` → `serve` | **成立。** `check --for ask` / `--for serve` とも 3(計画の確認 ON のザリ・ルナを名指し)。`ask --bypass-plan-review` は 0 — 標準出力は答えの 1 行だけ・`turn: … stop=- … model=gpt-6.1-sol`・`reply: … to=external:fuseforks-cli`・`agent stopped: … joined=true`。33 秒のうち 22 秒は Docker の MCP の起動。`serve --start batch --bypass-plan-review` は 5 体を起動 → 実コンソールの Ctrl+C で「閉じます」→ 5 体とも `joined=true` → **0**。閉じるのに 13 秒(1 体 3 秒・順番に止める。30 秒の猶予に対して 10 体なら届く — 頻度を見てから並列化を考える) | | 2 | GUI を開いたまま `ask` → 4 / `serve` 中に GUI → 覆い | **成立。** 前半 — 開発ビルドの GUI(pid 38424)が開いている本物の村へ `ask` → 4、GUI の `fuseforks.log` は前後とも 27,520 行(ロックが最初の手で止め、1 行も書いていない)。後半 — 本物の村で `serve --start batch` を立ててから開発ビルドの GUI を起こすと、起動の覆いに「オーケストレーターの起動に失敗しました / [BOOT_FAILED] この村は別のプロセスが開いています(Fuseforks の GUI か、fuseforks-cli)。そちらを閉じてから、アプリを起動し直してください」+ ロックファイルのパス(利用者のスクリーンショット)。角括弧の識別子が `BOOT_FAILED` なのは設計どおり(起動の失敗は全部この識別子で再試行ボタンを出さない。`VILLAGE_LOCKED` は本文の文言を選ぶ印)。その後 `serve` を実コンソールの Ctrl+C で閉じて 0。**1 回目は 2 時間待って GUI が開かれず、二重オープン(v0.4.0 にはロックが無い)を避けて閉じた** — `serve` を先に立てる順番は、利用者が「準備できた」と言ってから立てる形でやり直した | | 3 | `--secrets env` で keyring 空から `ask` | **成立。** 要る変数は `check --secrets env` で読めた — `FUSEFORKS_SECRET_GPT_6_SOL`(ルナ)。利用者が自分の端末で `Read-Host` で置いて `ask --secrets env` → 答えの 1 行・`turn: … stop=-`・`reply: … to=external:fuseforks-cli`。Jev のトークンは置いていないので警告 `JEV_TOKEN_MISSING` が 2 件(判断役は無効・圧縮は走らない)と `jev: … active=false` — 名指しどおり。資格情報ストアは一切読まない(`EnvSecretStore`)ので「keyring が空」と同じ条件。終了コードの行は貼られた出力に無かった(答えが返った結末は 0 に写る — 単体で留めてある) | | 4 | 計画の確認 ON で `serve` → 3、`--bypass-plan-review` で通る | **成立。** `serve --start batch` → 3(LLM も MCP も呼ばない)。`--bypass-plan-review` を付けると 1 の `serve` のとおり 5 体が起動して閉じる | | 5 | `ask` の途中で Ctrl+C → 8 と `turn:` 行 | **成立(3 通り)。** ① ツールを使う依頼で最初の `tool:` 行の後に送る → 2 周目の境目で `turn interrupted: … rounds=2 … prompt=101093 total=101627`(払いの行が残る)→ 定型文「打ち切られました」→ **8** ② 起動 3 秒後に送る → 「起動の途中で中断しました(何も送っていません)」→ **8**(`turn start:` は出ない・2.2 秒で閉じる = P6 で直した穴が実コンソールで塞がっている)③ **1 周で答え終わるターンの飛行中に送ると 0** — 打ち切りは周回の境目で効く(Spec 10)ので、飛行中の 1 周がそのまま答えになって返る(払った答えなので返すのが正しく、D10 の「答えが返った」= 0)。項目 5 の「→ 8」は複数周のターンの話だった | **Ctrl+C の送り方**: 利用者の端末(PowerShell 7 + oh-my-posh の 2 行プロンプト)は、端末のタブへコマンドを流す道具が プロンプトと認識できず使えなかった。代わりに CLI を**隠した専用のコンソール**で起こし、補助プロセスがそのコンソールに 付いて(`AttachConsole`)自分は Ctrl+C を無視したうえで `GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0)` を出した — 端末で Ctrl+C を押したときに OS が送るのと同じイベント。**1 回目は届かなかった** — こちらの実行環境が `CREATE_NEW_PROCESS_GROUP` で起こされており、その「Ctrl+C 無効」の印が子孫の CLI へ継承されていた。包み役が `SetConsoleCtrlHandler(NULL, FALSE)` で 印を外してから CLI を起こすと届いた。**利用者の普通の端末では印は付かない**ので実装の穴ではないが、Windows のサービスや `CREATE_NEW_PROCESS_GROUP` で起こす監督プロセスの下では CLI も Ctrl+C を受けない(CTRL_BREAK は別のイベントで、今は受けて いない)。スクリプトは scratchpad(`driver.py` / `sendctrlc.py` / `enablectrlc.py`)で、リポジトリには入れていない **Unix の SIGINT テストの期待が 2 つ誤っていた**(実機の観測で分かった・`3e3c008`)— (a) 1 周で答えるスタブでは 8 に ならない(上の ③)→ 毎周ツールを呼ぶ遅いスタブへ (b) 打ち切りの払いの行は `turn interrupted:` で `turn: agent=` ではない。 どちらも CI(Unix)で必ず赤になる形で、Windows では `#[cfg(unix)]` で 1 度もコンパイルされていなかった。**実機の観測が、 走っていないテストの誤りを先に見つけた** **実機で見つかった穴**(どれも直して変異で赤を確認。コミットは `4c7a3a8` / `b96d300`。**数を見出しに書かない** — `b96d300` で 3 つ目を足したとき見出しの「2 つ」が残った = #67 の形。レビューの指摘で直した): - **`--events jsonl` の約束(凍結 13)が MCP を繋ぐ村では必ず破れていた。** rmcp の `TokioChildProcess` は子の標準エラーを 既定で **inherit** するので、Docker の MCP ゲートウェイと memoria の出力が素の行のまま標準エラーへ流れた(素の `ask` の 実機出力で見えた)。結合テストの村には MCP が無かったので緑のまま通っていた。処方 = JSON の間だけ子の標準エラーを piped にし、1 行ずつ `{"type":"log","source":"mcp:<名前>","message":…}` に包んで**標準エラーへだけ**写す (`fuseforks.log` には書かない — 子が何を出すかは決められない。#71 の向き)。GUI と JSON でない CLI は今までどおり継ぐ。 子プロセスの標準エラーを継いでいたのは MCP の stdio だけ(`run` と前判定は `process.rs` で piped 済み)。 結合 8 本目 `events_jsonl_wraps_the_stderr_of_mcp_children`(子は実行ファイル自身を誤った引数で起こす = 標準エラーへ 使い方を出して 2 で終わる。OS を問わずある実行ファイル)。変異(分岐を常に継ぐ側へ)で新しい 1 本だけ赤 - **情報 `RECEPTION_TARGETS_OUTSIDE` に判断役が混ざっていた**(実機のルナは判断役 `judge` に繋がっている)。判断役は 起動する個体ではなく呼び出し元のツール呼び出しの中で動くので、「委譲は NOT_RUNNING で返る」はその id については誤り。 処方 = 窓口の接続先から判断役を除く(判断役の行き先 = `judge.toml` の `to` は、ファイルを読まない純関数の材料に入って いないので数えない)。単体に 2 断言を足し、変異(除外を消す)で 1 本だけ赤 - **Ctrl+C の受け口を、窓口へ送る直前まで作っていなかった**(実機の `ask` の所要を見て気づいた — 33 秒のうち 22 秒が MCP の起動)。`tokio::signal::ctrl_c()` は async fn で poll されるまで何も登録しないので、組み立て中・起動中の Ctrl+C は既定の処理でプロセスごと落ち、8 にも閉じ方にもならなかった。処方 = 受け口を**コマンドの最初に登録**する (Windows は `signal::windows::ctrl_c()`、Unix は SIGINT と SIGTERM の `signal()` — どちらも作った時点で登録される)。 組み立て中のシグナルは何も送らずに 8(`serve` は 0)、起動中は集合の全員に止めを送ってから 8。Unix だけの結合 `sigint_during_ask_interrupts_and_exits_eight`(3 秒待つスタブの飛行中に `kill -INT` → 8 と `turn:` 行)。 **Windows では子へ Ctrl+C を自動で送れない**ので、Windows の確かめは項目 5 の実機。Unix の分岐は手元に Linux の ターゲットが無くコンパイルしていない — 最初に通るのは次のタグの CI - **子の標準エラーの 1 行に上限が無かった**(レビューの指摘・2026-10-06)。`read_until` は改行が来るまでバッファを 伸ばすので、改行を出さない子(プログレス表示・バイナリの垂れ流し)が 1 つあると親のメモリが際限なく増える。 無人で回す `serve` では効く形。処方 = `take(64 KiB)` で 1 回の読みを切り、上限で 1 行として写す(`CHILD_LINE_MAX`)。 行の切り出しを `forward_lines(読み手, 出口)` に分けて単体で留めた。変異(`take` を消す)で新しい 1 本だけ赤。 **このとき #130 を踏んだ** — 差分を書いたスクリプトの `\\n` が実改行としてソースへ入り、今回はコンパイルが止めた (バイト列のリテラルは実の CR / LF でも同じバイトになるのでコンパイルが通る — 読み直して直した) - 版番号: P4 のコミット前にビルドしたバイナリは `+gf5b9446` を返し、再ビルドで `+g5473874` に追従した(`build.rs` が ブランチの ref を見張っている、の実機での確認) **ディスク**: 全体テストの途中で D: の空きが 42 MB(100%)になり、`link.exe` 1104 / 1140 と指紋ファイルの書き込み失敗が 並んだ。同じ日の P3 と同じ形。前半の `link.exe` 1181 / 1(「一過性」と書いたもの)もおそらく同じ原因で、**確かめずに 「掴み合い」と書いたのは誤りだった**。GUI の開発ビルドが `target\debug useforks.exe` を握っている間は GUI のパッケージも 組み直せない。 ## 未決 **ゼロ。** ~~1. `--start reception` のとき、窓口の接続先が止まっていることを `ask` で表示するか~~ → **rev2 の方針で確定** (2026-10-06 の再査読)。「情報」として `check` と `--verbose` のときだけ出す。`--start reception` は 「窓口 1 体で済ませる」という利用者の明示で、毎回の警告は秘密の欠落や未承認のような本当の警告を埋もれさせる。 - **再査読の前提を 1 つ訂正して記録する** — 「委譲が `NOT_RUNNING` になった事実はログとイベントに正確に残る」は 半分だけ正しい。残るのは `tool: … name=ask_agent_N ok=… body_chars=…` の行(`--events jsonl` では `toolInvoked`)で、 **止まっていたという理由は残らない**。理由は `ask` の結果の本文に書かれ、本文はログに 1 字も書かない規律 (`failures.md` #71)なので `fuseforks.log` からは読めない。読めるのは GUI の会話ペインのツール行を開いたとき (Spec 57)だけで、ヘッドレスには画面が無い。**ゆえに事前の `check` が、この情報を読める唯一の場所になる** — 結論(`check` で出す)は同じで、理由がより強くなった ## Notes ### 1. CLAUDE.md のギャップ 6 つとの対応 | ギャップ | 本 Spec | |---|---| | 1. ホストの実行ファイル | **閉じる**(D1・P1・P4) | | 2. `batch_start_invariant` との衝突 | **閉じる**(D6 — GUI は不変、ヘッドレスは `--start` 必須) | | 3. 扉の bind 127.0.0.1 固定 | **コードを変えずに越える**(D9 — 同じ名前空間のプロキシ)。手順はコンテナの Spec | | 4. `probe_approvals.json` の焼き込み | **既に解けていた** — 承認鍵は `villageId` で村に結び付いており、端末には結び付いていない。棚のファイルを一緒に持っていけば効く。D5 が未承認を名指しする | | 5. 村の排他所有 | **閉じる**(D3)。設計の状態と実行の状態を bake の段で分ける話(`world.json` と `sessions.redb`)はコンテナの Spec | | 6. GUI 前提機能の検査 | **閉じる**(D5) | ### 2. 次の Spec(コンテナ)へ渡す材料 **→ 行き先(2026-10-07。[Spec 65](65_container-deploy.md))**: 像 = Debian slim + glibc + tini(静的リンクにしない・ `keyring` の feature も落とさない — D7)/ 起動 = 下の形のまま(`deploy/compose.yaml`。ただし `/data` は bind で、 `bake` の写しを置く — P3)/ 外向き = 同じ名前空間のプロキシ(`deploy/compose.door.yaml` の Caddy)/ MCP の stdio = 像に同梱せず、リモート MCP へ寄せる(起動前検査が `MCP_COMMAND_NOT_FOUND` で名指しする) - 像: Linux の静的リンク(`rustls` 前提なので C の TLS は要らない。`keyring` は Linux で Secret Service を引くので、 `--secrets env` しか使わない像では feature を落とせるか確かめる) - 起動: `serve --data-dir /data --secrets env --start batch`。`/data` はボリューム - 外向き: 同じ Pod のプロキシ(D9) - MCP の stdio サーバーは像に同梱するか、リモート MCP(Spec 47)へ寄せる - 参照実装: agent-orchestrator の「鍵を 1 ターンごとにコマンドの環境変数にだけ入れる」形(`run` の子プロセスに 秘密を継承させない規律は、この村は `env_clear` で既に持っている) ### 3. 主流の形との対照(2026-09-14 の調べ) 見た 7 実装の多くは「1 回きりの実行を単位にし、常駐はそれを受ける口として被せる」2 層だった(LangGraph / CrewAI / OpenHands / Claude Code `-p` / Codex `exec` / orca-cli / stablyai/orca)。本 Spec の `ask` と `serve` は この形に合わせてある。常駐させるのは待つもの(予定・外部からの依頼)があるときだけ。 ### 4. rev1 の査読の反映(2026-10-06。2 系統 26 点) | # | 指摘 | 判定 | 反映 | |---|---|---|---| | 1-① | 採らなかった形 1 の線引き | 支持(変更なし) | 用語の定義を冒頭へ(2-用語) | | 1-② | D5 の重さ | 支持(`run` が待ちにならないことを `tools/run.rs` で確認済み) | — | | 1-③ | `EnvSecretStore` と #1 | 支持 | — | | 1-矛盾 1 | `build_host` に予定と扉の設定を運ぶ口が無い | **採用** | D1 `HostBootOptions`。扉を外に出す案は不採用(採らなかった形 7) | | 1-矛盾 2 | `serve --start none` は空転 | **採用** | D6 — `--start` 必須・`none` を削除。コマンドごとに既定を分ける案は不採用(採らなかった形 8) | | 1-矛盾 3 | `check` の構文が無い | **採用** | D5 — 検査したいコマンドと同じ引数 + `--for` + `--json` | | 1-矛盾 4 | 検査がモードを知らない / `HostView` が未定義 | **採用** | D5 — `HeadlessMode` と `HostView` を定義 | | 1-矛盾 5 | D7 の「D8」は誤参照 | **採用・前提を訂正** | Spec 25 D8 の意。呼び名の置き場は `mcp_server.json` ではなく `world.json` の `externalName` | | 1-矛盾 6 | `ask` の Ctrl+C | **採用** | D7・D8 — 同じ閉じ方。終了コード 8 | | 1-未決 5 | 分けるには `ask_external` のシグネチャを変える必要がある | **反証** | 結末は `deliver_and_wait` から既に返っており、`ask_external` が捨てているだけ(`runtime.rs:603`)。新しい口を 1 本足すだけ(D10) | | 1-細 1 | 固定の鍵との衝突 | **採用** | D4 | | 1-細 2 | `.git` が無いときの版番号 | **採用** | D11 | | 2-1 | ロックと `bootstrap` の WARN 続行が両立しない | **採用(範囲を絞って)** | D3 — `DatabaseAlreadyOpen` だけを止める。他の理由の WARN 続行は 2 重オープンではないので残す | | 2-2 | 「中身を変えずに移す」と `tokio::spawn` が矛盾 | **採用** | P1 — 別コミット | | 2-3 | 衝突検査に `world.json` が要るがストアは先に作る | **採用(形を変えて)** | D4 — ストアは環境だけで作れる。衝突検査を `bootstrap` の直後へ置く(`SecretSource` は `HostBootOptions` に入れた) | | 2-4 | `ask` が委譲する村で毎回警告 | **採用** | D5 — 情報へ下げた。未決 1 | | 2-5 | 今の会話へ続けると GUI の履歴を汚す | **採用** | D7 — 既定を新しい会話へ。扉は続けるまま。代償(GUI が `ask` の会話を開く)を明記 | | 2-6 | 終了コード 0 で予算切れを握りつぶす | **採用** | D10 — 6〜9 | | 2-7 | `check` が `--secrets` を見ない | **採用** | D5 — `check` は同じ引数を取る | | 2-8 | 標準エラーの多重化 | **採用** | D7 — `--events jsonl` の間は全行 JSON | | 2-9 | 手元ビルドで GUI `0.1.0` と CLI `0.0.0` が食い違い、P1 の検証が不一致になる | **一部採用・前提を訂正** | P1 の比較は GUI 同士なので当たらない。CLI に `+g` を足した(D11) | | 2-細 1 | ロックの前に `create_dir_all` | **採用** | D3 | | 2-細 2 | ティッカーと起動 0 体 | 採用(`none` 削除で消えた) | D6 — 宛先が止まっている予定の扱いを明記 | | 2-細 3 | Unix の `lsof` | **採用** | D3 | | 2-細 4 | `HostPaths` の冗長 | **採用** | D2 | | 2-細 5 | 用語の定義 | **採用** | 冒頭(README ではなく Spec に置く。README は本 Spec の着地まで触らない) |