version: "1.0" project: Outcasts Fuseforks # 正式名 (2026-08-09 改名。旧 Outcasts Concordia)。アプリ内表示・System の送り手名も「Fuseforks」(Spec 30 D4) description: | マルチエージェント・オーケストレーションのドメイン名詞(Data-First Grounding)。 実装より先にここを正とする。型を増減させたら、下記 3 箇所を必ず同時に直すこと。 # 表示語彙とドメイン語彙は別に持つ (2026-07-31 利用者判断)。 vocabulary: domain: エージェント display: サーヴァント rule: | **UI に出る文言だけが「サーヴァント」**。型・フィールド・IPC コマンド・ イベント名・クレート名 (AgentId / AgentSpec / AgentSnapshot / create_agent / agentStatusChanged / fuseforks-core …) と、この台帳・README・failures.md の 地の文は**「エージェント」のまま**。 根拠: 世界観の呼称はいつでも変わりうるが、型名を変えると Rust / TS / 台帳 3 系統の全同期が要る。**変わりやすいものと変えにくいものを同じ語に 束ねない**。ソースコードのコメントも触らない (実装を読む人の語彙は ドメイン側)。 追加・変更するときは `apps/gui-tauri/src` の**利用者に見える文字列だけ**を 直す。見えるかどうかの判定は「template のテキスト / placeholder / title / alt / aria-label / confirm / pushToast と mutate の label」。 # 同一の型が 2 言語に存在する。手で同期させる契約になっている。 single_source_of_truth: rust: crates/fuseforks-core/src/model.rs rust_events: crates/fuseforks-core/src/event.rs typescript: apps/gui-tauri/src/types.ts serde_policy: | 構造体は `rename_all = "camelCase"`、列挙は `rename_all = "snake_case"`。 ゆえに TS 側のフィールドはキャメルケース、状態値はスネークケースの文字列になる。 # --------------------------------------------------------------------------- identifiers: AgentId: kind: newtype(String) wire: 透過的な文字列 constraint: 英数字 / `-` / `_` のみ、1〜64 文字 rationale: | 設定ファイル置き場のディレクトリ名になる。`.` と `/` を型の入口で弾くことで、 IPC 経由のパストラバーサルを構造として封じる。 ModelTemplateId: kind: newtype(String) wire: 透過的な文字列 # --------------------------------------------------------------------------- enums: AgentStatus: values: [idle, starting, running, stopping, failed] note: | 失敗理由はこの列挙に持たせず AgentSnapshot.lastError へ分離する。 ワイヤ表現を素の文字列 `"running"` に保ち、UI の分岐を単純にするため。 failed は idle と同じ「停止している」側で、**start_agent で再稼働できる** (2026-08-06。failures.md #78)。UI のトグルは failed を OFF として描くので、 押すと start_agent が呼ばれる — ここを塞ぐと、画面は OFF に見えているのに アプリを再起動するまで戻せない状態になる。 失敗経路には stop_agent に当たる後始末が無く、tasks の登録・個別 MCP・ started_at が残る。これを畳むのは次の start_agent の冒頭 (reap) で、 「登録がある」と「走っている」を JoinHandle::is_finished で区別する。 ConfigFileKind: values: skill: SKILL.md memory: Memory.md construct: Construct.md mcp: mcp.json # エージェント別 MCP (Spec 02)。書き込みはパース検証つき run: run.json # エージェント別 allow/deny (Spec 15 rev4)。同じくパース検証つき note: | GUI からは列挙値しか受け取らない。任意のファイル名を IPC に載せないことで、 書き込み先を閉じた集合に保つ。 mcp (agents/{id}/mcp.json) と run (agents/{id}/run.json) は書き込み時に JSON パース検証があり、失敗すると保存拒否 (mcp_contract の失敗二分類 (1))。 他の 3 つは自由テキスト。 **ファイル名は設定する対象の名前をそのまま採る。** run.json は run ツールの 設定で、mcp.json は MCP の設定。旧名 shell.json / command.json は 2026-08-05 に run.json へ改名した (shell は「実行はシェルを介さない」という弁解の doc コメントを必要とする名前で、command は 2026-08-04 に一度改名した際の 取り残しが 16 箇所残っていた)。 **run だけは人と機械の両方が書く** (Spec 15 rev4)。エージェントが pending を 足すので、書き込みは全文上書きにせず read → pending だけ差分適用 → write_atomic を 3 回までリトライする。mcp は人しか書かないのでこの問題が無かった。 compose_system_prompt には入らない (プロンプト素材ではなく接続宣言)。 アイコン (agents/{id}/icon.webp) はこの列挙に含めない — テキスト設定と違い バイナリで、read/write の型も検証も別物。専用の 3 コマンド (get/set/clear_agent_icon) で扱う。 mcp_contract: | MCP サーバー宣言 = {workspace}/mcp.json (2026-07-29)。 **Claude Desktop の claude_desktop_config.json と同じ `mcpServers` 形式**を採る (利用者が既に持つ設定をそのまま貼れることに実用上の価値がある)。 クライアント実装は公式 SDK の rmcp 2.2 (client + transport-child-process + transport-streamable-http-client-reqwest。server/macros は扉 = Spec 25 の側)。 LLM のワイヤ形は自前で持つがここは SDK に委ねる — MCP は仕様が広く (handshake / capabilities / 通知 / キャンセル / ページング) 改訂が速く、自前で持っても得るものが無い。 エントリは 2 形 (Spec 47 rev2.1・2026-08-30): - stdio (従来形。`type` 無し、または `type: "stdio"` の明示): command 必須。 **Serialize は type を常に省略** (出力は従来形に一致) - http (`type: "http"`): url 必須 + headers 任意 (Streamable HTTP)。 **Serialize は type: "http" を必ず出力** (省略すると次の読みで stdio と 誤判別される — rev2.1 の致命指摘) - `type: "sse"` (旧 SSE transport) は名指しで拒否。互換を主張するのは stdio と Streamable HTTP の 2 形だけ - 検証は 5 段の固定順 (形の判別 → 相互排他 [両向き] → 必須欄 → 値の形式 → URL スキーム)。1 エントリにつき最初の違反 1 つを エントリ名と欄で名指し。**enabled: false でも検証は掛かる** (スキップされるのは接続だけ) - URL は https、または loopback の http (Url::host() 判定: IP の is_loopback() [::1 含む] / ホスト名 "localhost" 完全一致。 プライベート帯・host.docker.internal は不許可) - **headers の値はエラーにもログにも出さない** (D7。相手の応答本文も 転記しない — 本文に受信ヘッダをエコーするサーバーが実在する。 P0 実測: rmcp の 401 は UnexpectedServerResponse("HTTP 401 …{本文}") の形で本文を運んでくるので、そのまま McpServerStatus.error へ 写すと転送経路になる) - 認証は auth_header に**素のトークン** (reqwest の bearer_auth が Bearer を付ける — rmcp 実装読み)。401/403 は「設定の確認が要る」で 即確定し自動再試行しない - rmcp の feature "reqwest" が TLS の鍵 (reqwest?/rustls)。無いと 既定 Client に HTTPS コネクタが無く「scheme is not http」で落ちる (P0 実測)。rmcp は自前の reqwest 0.13 を持ち込む (ワークスペースの 0.12 = LLM クライアントとは別の実体。版込み依存差分 +1) 不変条件: - MCP ツールは AgentTool 実装 1 つとして ToolRegistry へ入る。 オーケストレーターから見て同梱ツール (remember) と区別が無い - ツール名は `{server}__{tool}` で名前空間を分ける。記号は `_` へ落とし、 64 字超は**末尾**を残す (識別力は tool 側にあり、頭を残すと `filesystem__` だけが並んで区別が付かない) - **1 台の失敗で全体を止めない**。接続できないサーバーは McpServerStatus.error に理由を残して次へ進む (外部コマンドは未インストール・パス違い・権限で 普通に落ちる)。起動時の接続失敗でアプリの起動も止めない - 接続は 30 秒で打ち切る。応答しないまま生きているサーバーがあり、 handshake は待てば返る保証が無い - 再接続時は**古い接続のツールを先に外す**。消さずに登録すると、 繋がっていないサーバーのツールがモデルへ提示され続ける (呼ぶと必ず失敗する幽霊) - RunningService は保持し続ける。drop すると接続が畳まれ子プロセスが落ちる - mcp.json が壊れている場合は**空として扱わずエラー**にする。空にすると 書き間違えた瞬間に全ツールが黙って消え、原因に辿り着けない - env に秘密を書かせない (mcp.json は平文)。**http の headers は env より 1 段重い** — env はローカルの子プロセス止まりだが、 headers の Authorization は外部へ送信され、村 (workspace) を配ると トークンごと配られる (Spec 47 D3。README / PRIVACY に明記)。 keyring 参照化 (CredentialSource の前例) は別 Spec 候補 - **コマンドは PATH から解決する** (rmcp の which-command)。素の名前を Command::new へ渡してはいけない — Windows の PATH には npx.cmd と 拡張子なしの npx (Git Bash 用スクリプト) が両方居り、後者に当たると CreateProcess が実行できず program not found になる。 「Claude Desktop の設定をそのまま貼れる」はここを解決して初めて成り立つ (failures.md #26、実測で確認) エージェント別 MCP = agents/{agent_id}/mcp.json (Spec 02 rev2 承認、 2026-07-30 凍結。旧案 AgentSpec.mcpServers は本案で置き換え): - 形式は共通 mcp.json と同じ (Claude Desktop の mcpServers)。無ければ空集合 - **上書き可能な加算モデル**: エージェントには「共通 + 自分の個別」が 提示される。共通側の機構・UI は不変。{server}__{tool} が同名なら **個別が勝つ** — 共通と同じサーバーを自分専用の接続先 (別 DB 等) で 置き換える正当な手段として位置づける - **ツール収集の最終形** (提示順): 1. 転送・委譲 (トポロジー由来) → 2. 共有 registry (同梱 [enabledTools でフィルタ] + 共通 MCP) → 3. 個別 MCP (orchestrator の per-agent map)。実行解決は逆引きで **個別 map を先に**引き、無ければ共有 registry。個別ツールは registry に入れない (入れると全員から見え、共通の再接続とも衝突) - **接続寿命はエージェントの稼働に一致**: start_agent で接続、 stop_agent で切断。HashMap で保持し、 stop は自分のエントリだけ畳む。同じコマンドでも**プロセスは 共有しない** (接続先が違いうることが個別 MCP の動機そのもの) - **失敗の二分類**: (1) 保存時 JSON パース失敗 = 保存拒否 (UI 保存経路の不変条件: UI 経由の保存後、ディスクは常に正しい JSON か不在。外部編集はこの不変条件の外) / (1') 外部編集起因の パース失敗 = 読み込みエラーとして保持し起動は止めない、個別 ツール 0 本で稼働 / (2) MCP 接続失敗 = サーバー単位で保持し 起動は止めない (共通と同じ規律) - 保存時、稼働中なら個別接続を張り直す。停止中は検証のみ (次回起動で反映) - **接続状態は永続化しない**: プロセス寿命の map + IPC で可視化 (状態ファイルはプロセスが消えても「接続済み」を残して嘘をつく)。 World の同期純データ原則を守り AgentRecord には入れない ordinance_contract: | 村の条例 = {workspace}/Ordinance.md (2026-07-28)。ワークスペース全体 (= 村) の規則で、エージェント個別ではなく場に属するため agents/ の外に置く。 規則の序列: ベンダーの憲法 (モデル側、変更不可) > 村の条例 > 各エージェントの 個別設定 (Construct / SKILL / Memory)。序列はプロンプトの物理順として表現し、 compose_system_prompt が条例を**最上段**に差す (空なら痕跡を残さない)。 条例は全エージェント共通なので、モデル間の憲法差 (振る舞いの既定値の違い) を吸収する正規化層としても機能する。 不変条件: - 連結順は 条例 → 自分について → 作業フォルダ → 接地 → Construct → Skill → **顔ぶれ (Spec 06)** → Memory で固定 (プロンプトキャッシュ)。 キャッシュの安定境界は**安定素材 (条例〜Skill) の末尾 = 顔ぶれの直前** (旧定義「Memory の直前」は Skill と Memory が隣接していた間だけ 同じ点を指せていた。conversation_termination.presence を見よ) - 条例は安定プレフィックスに含まれる。編集すると全エージェントの キャッシュが一度無効になるが、それは「場の規則が変わった」正しい代償 - **モデルが持っていない情報は、禁止ではなく欠落の告知で埋める** (Spec 05)。 実装側が差す節は現在 2 つ: 作業フォルダの実パス (spec.work_dir が Some のとき) — 無いとモデルは説明に使う絶対パスを推測で創作する 接地の制約 (template.google_search が真のとき) — 無いとモデルは出典を求められたとき偽の URL を作る (failures.md #31) どちらも「〜するな」ではなく「〜は手元に無い」と書く。禁止は利用者の 要求と競合したときに折れるが、事実は競合しない。 人格 (SKILL.md) ではなく実装側に置くのは、どちらもワイヤ経路と設定の 性質であって、有効にした全員に等しく効くべきものだから - 反映は次の発話から (プロンプトはメッセージごとに組み直す。再起動不要) icon_contract: | アイコンの中身は WebP 固定 (2026-07-28)。png/jpg → WebP の変換と 256px 角への 中央切り出しは UI 層 (canvas) の責務で、コアは RIFF/WEBP マジック番号と サイズ上限 512 KB だけを検証する (INVALID_ICON)。IPC 経由の任意バイト列が ワークスペースに書かれる経路を入口で塞ぐ。形式を 1 つに固定することで、 表示側は MIME 判定なしで image/webp として扱える。 エージェント削除は設定ディレクトリごと消すため、アイコンの孤児は残らない。 **アイコンの置き場は列挙で持つ** (総数を書くと次の 1 つで腐る — #67 の処方): - エージェント: `agents/{id}/icon.webp` - 利用者: `user/icon.webp` (2026-08-05、Spec 19) - 外部クライアント: `external/icon.webp` (2026-08-07、Spec 25 D8)。 **利用者と共用しない** — 共用すると「外の道具が頼んだこと」と 「自分の依頼」が同じ顔で並ぶ **上の検証 (マジック番号 + 512 KB) は 1 実装を共有する** — 書き込み関数を 種類ごとに分けても、検証だけは同じ述語を通す。2 箇所に書くと、 片方の上限を変えたときにもう片方が古い値のまま通し続ける。 **利用者のアイコンには削除の連鎖が無い** (利用者は削除されない) ので、 孤児の議論は当てはまらない。消えるのは明示的に消したときだけ。 session_store: | 会話の永続化 (Spec 12。**2026-08-02 rev2 承認 → P0 で凍結 → 2026-08-03 P1 着地** (session_store.rs に純機構と単体 17 本) **→ 同日 P2 着地** (書き込み点 2 種 + 起動時の復元 + セッション API + reset_conversation の委譲。結合 8 本) **→ 同日 P3 着地** (IPC 7 本 + SessionDialog.vue + sessionSwitched の投影。 フロント 2 本 + ワイヤ形の凍結 1 本) **→ 同日 P4 着地** (手動要約。summarize_session + 可変文脈への注入。結合 3 本)。 残は P5 実機確認)。 再起動をまたいで会話を保ち、複数のセッションを持ち、再開できるようにする。 **保存するのは「会話」であって「実行状態」ではない。** backend: **redb 4.1.0** (pure Rust・C 依存なし・2 依存)。 path: {workspace}/sessions.redb の**1 ファイル**。 **index ファイルを別に持たない** — JSONL + index.json 案は「真実が 2 つ」に なり、原子性の破れと fork の孤児がその帰結だった (rev1 の構成のバグ)。 redb は機構を**減らす**: index.json / temp+rename の自前実装 / 壊れた行の切り捨て / fork の 2 段階が消える。 **ハイブリッド (redb=索引 + JSONL=本文) は採らない** (永続化を 2 種類 管理する最悪の形)。 tables (2 つだけ): sessions: TableDefinition<&str, &[u8]> # key = session_id, value = SessionMeta(JSON) records: TableDefinition<(&str, u64), &[u8]> # key = (session_id, seq), value = Record(JSON) SessionMeta (JSON): { title, createdAt, updatedAt, parentId?, forkedAtSeq?, recordCount } title は自動生成 (最初のユーザー発話の先頭 30 字)。 parentId / forkedAtSeq は fork で生まれたときだけ入る。 「最新」の判定は **updatedAt** で行う (session_id の辞書順に依存しない)。 Record は **4 種別で閉じる** (kind の閉じた列挙。~~3 種別~~ → **Spec 39 P0 で turn を追加**・2026-08-16): message : AgentMessage **丸ごと** (grounding / coRecipients も含む — 再開後の表示に要る) exchange : { agentId, sent, replied } — 履歴 1 往復。 **sent は実際に送った文字列そのもの** (#45 の規律を保存側へ延長) summary : { agentId, text, coversUpToSeq } — 要約 1 本 turn : { agentId, tsMs, hop, rounds, waves, stop, prompt, cached, completion, reasoning, cacheWrite, cacheWrite1h, model, backend, elapsedMs } — cacheWrite / cacheWrite1h は Spec 40 P3 で追加。**どちらも `#[serde(default)]`** なので v0.1.8〜v0.1.9 のレコードは 0 で 読める (互換の向き: 新しい版が古い記録を読めること)。 **その 0 は「書き込みが無かった」ではなく「記録していなかった」** — 画面はこの版より前の会話で内訳を主張しない。 cacheWrite は prompt の内数、cacheWrite1h は cacheWrite の部分集合。 — ターン 1 本の使用量 (Spec 39 D1)。**`turn:` 行と同じ構造体 (TurnSpend) から 1 実装 settle_turn が書く** — 行とレコードが 食い違う形を構造で作らない。**4 出口 (完走 / 失敗 / 割り込み / 予算) すべてで 1 件**。tsMs は**ターン開始**の壁時計 (`run_turn` の入口 = TurnContext.started。**TurnHandle.requested_at ではない** — あれは割り込みを要求された時刻。P1 で訂正)。 `completion = tokens − prompt` は**書く側の settle_turn が 1 箇所で 引く** (TurnSpend は total を持つ。読む側は引き算をしない)。 reasoning は completion の**内数** (Spec 32 D2)。 **model は Option ではない** — テンプレートは handle_message の 段 1 で 4 出口のどれより先に解決され、引けなければターン自体が 始まらない (observability_rule の「解決前に落ちたターンは turn 行を 持たない」と同じ位置)。 stop は閉じた列挙 7 値 (出口との対応は Spec 39 D1 の表が正): completed | repeat{tool} | tool_limit | failed{code} | interrupted | budget_exhausted | reserve_short budget_exhausted / reserve_short は finish_budget_exhausted の 同じ行から出る (理由を引数で運ぶ — Spec 38 D3 の区別をレコードへ)。 **入れない欄**: 予算の残額 (因果ごと・プロセス寿命) / 依頼文・ 本文 (exchange が持つ。数字の記録に本文を混ぜない) / セッション id (キーが持つ)。 **履歴の入力にならない** — restore_histories / tail_messages / fork_points は turn を読まない。fork は seq 込みで複製するので 分岐先にも分岐点までの turn が残る (その会話の文脈を作った費用 — 正しい記録。区別は stats_contract の scopeMeta.forkedFrom)。 **互換の向きは 2 つ**: (a) 新しい版は旧い sessions.redb を **そのまま読める** (既存 3 種別のシリアライズは不変。旧い村は turn が 0 件になるだけ) / (b) 旧い版で新しい村を開くのは **非サポート** (未知の kind で records() が落ちる。summary を 足したときと同じ位置づけ)。**未知 kind を WARN で飛ばす読み取りは 作らない** — 読めないレコードを黙って落とすと統計が「払っていない」 側へ嘘をつく (#72 の形)。 seq: - u64。**セッション内で 0 から単調増加**。セッションを跨いで共有しない - fork は **at_seq を含めて (inclusive)** コピーする - 採番は書き込みトランザクションの中で行う (同じ seq を 2 度使わない) 不変条件: - **セッションの単位は村** (エージェント別ではない)。1 セッション = 村の会話ログ + 全エージェントの履歴 + 要約。エージェント別に切ると、 同じ波に参加した 2 体が別々の会話に属し、束ねの因果が切れる - **1 トランザクション = 1 つの原子的な変更**。メタデータ更新と レコード追加は同じトランザクションで行う。temp+rename は使わない - **書き込みトランザクションを await を跨いで持たない。** redb は同期 API で 書き込みは 1 本に直列化される。トランザクションを持ったまま LLM 呼び出しや ファイル I/O を待つと村全体が固まる。書くのは 2 点だけ (Shared::record の中 / push_exchange と同じ点) で、どちらも短く閉じる - **滑る窓はロード時に再適用する。** records には全 exchange が残り、 復元時に history_turns の窓で切る。窓を広げたら次の復元から効く (保存時に切ると、設定を広げても過去は戻らない)。 実測: 後ろから 16 件の逆順範囲走査が 0.03 ms (20,000 件のテーブル) - **Shared.log の 5,000 件はメモリ上の上限であって保存の上限ではない。** ファイルには全量が残り、起動時に読むのは末尾 5,000 件 - **復元しないもの**: plan の波 (Spec 08 が in-memory と凍結。 **提案 pending も含む** — Spec 43) / BudgetPool (因果ごと・Spec 11) / MCP 接続 / TurnHandle / 稼働状態 (bootstrap は自動起動しない) / 累積統計 (プロセス寿命) - **半端なターンを許容する。** message は投入時、exchange はターン完了時に 書かれるので、飛行中に落ちると「message はあるが exchange が無い」が残る。 これは正しい記録 (発話は起きた事実) として扱い、**画面には出すが 履歴には入れない**。再開後は「頼んだが答えが返らなかった」ように見える - **セッションの切り替えは飛行中ターンが無いときだけ** (interrupt_all の 後か全員 idle)。飛行中に切り替えると答えが別のセッションへ着地する。 拒否は SESSION_SWITCH_BLOCKED (自動で interrupt_all してから切り替える ことはしない — 畳むかどうかは人の判断)。 **例外は create_session (新規チャット) だけ** (P2 で確定)。着地先が 新しい空のセッションなので既存の記録を汚さず、reset_rule の案 A (飛行中ターンの完了書き込みは許容) がそのまま成立する。 resume / fork / continue_latest / 開いているセッションの delete は **前に保存された会話へ無関係な答えが混ざる**ので拒否する - **起動時は最新セッションを開く** (updatedAt で判定)。読めない・0 件なら **警告 1 行を出して新規セッションを作る** — 起動が止まる経路は作らない - **索引は作らない (v1)。** 実測で 20,000 レコードの全走査が 11 ms。 索引が要るのは 1 桁上から。純 Rust の全文検索 (tantivy + lindera = **451 依存**、しかも zstd-sys で C が戻る) も採らない - **export_session (JSONL 書き出し) は P1 の完了条件。** redb は バイナリなので、人が読める出口が無いと grep 診断ができなくなる (failures.md #47 は concordia.log の grep で解けた)。 読めない保存先を作るなら、出口は機構の一部 要約 (summary) の規律: - **coversUpToSeq < その summary 自身の seq** を不変条件とする (自分自身を覆う要約を作らない) - 復元は「**最新の summary** + coversUpToSeq **より後**の exchange」。 古い summary はレコードとして残るが復元には使わない (痕跡であって現役ではない) - **要約の要約を許す。** 新しい summary は前の summary の本文を入力に 取れる。coversUpToSeq は単調増加するので参照が循環しない - **注入は既存の可変文脈の畳みに相乗りさせる。** history に summary 専用の 席を作らない — 次のターンの context (入退室通知・広場ログと同じ場所) へ 要約を差し、その結果できた sent_user_turn がそのまま exchange として 保存される。これで #45 (送信と保存の一致) と衝突しない - **自動では要約しない (v1)。** 人が押したときだけ。自動要約は LLM 呼び出し = トークンで、token_budget の天井と競合する (Replan 不採用と同じ規律) P4 で着地した要約 (2026-08-03): API: Orchestrator::summarize_session() → 要約できたエージェント数。 **対象は稼働中 (AgentStatus::is_active) のサーヴァントだけ** (2026-08-03 の実機指摘で修正。初版は停止中にも呼んでいた)。要約の目的は 以後のプロンプトを短くすることで、停止中の個体には以後のターンが無い。 それでも呼べば**参加していない個体のぶんまで押した人がトークンを払う**。 履歴は停止しても消えない (P2 で start_agent のクリアをやめた) ので、 起動してから押せばそのとき要約される — 取り逃がしにはならない。 飛ばした相手が居たら System 行に**必ず書く** (黙って対象外にすると、 「要約したのに次のターンが短くならない」個体の理由が画面から消える)。 **使ったトークンは統計へ積み、`summarize:` の 1 行をログへ出す** (2026-08-03 の実機ログで判明した穴を塞いだ)。要約はターンループの外で 走る LLM 呼び出しなので、ここで積まないと**押した人が払った分がどの数字にも 現れない** — カードの累計にも村の集計にも `turn:` 行にも出ない。 行の形: summarize: agent=… model=… covers_up_to=… folded_msgs=… folded_chars=… summary_chars=… prompt=… cached=… total=… **folded_chars と summary_chars の比が効きの本体** (2026-08-03 の実機で、claude-opus-5 が 1 往復を 1,163 字に書き広げた)。 要約は滑る窓と違って落ちないので、膨らんだぶんは**以後の全ターンに乗り続ける**。 summary_chars >= folded_chars なら WARN を 1 行出す。**機構では止めない** — 止めると「要約したのに畳まれない」状態が画面から読めなくなる。 計器だけ置いて、頻度を見てから決める。 要約呼び出しの cached は**構造的に常に 0** (要約用の system + 指示は 通常ターンと別のプレフィックスなので前方一致しない)。1 回きりの 呼び出しなので打ち消す仕掛けは置かない。 IPC summarize_session / 会話ペインの「要約して続ける」ボタン (確認つき)。 **要約を作るのは本人** (そのエージェント自身のモデル・テンプレート) — 別のモデルに書かせると要約の文体と本人の文体が食い違い、次のターンで 「自分が書いた覚え書き」として読めなくなる。ツールは提示しない (提示すると呼びに行く個体が出て、1 回で終わる呼び出しがループになる)。 **境界は書く前に取る** (SessionStore::last_seq) — 書いた後だと 自分自身を覆う要約になり coversUpToSeq < 自身の seq が破れる。 現役の要約は Shared.summaries (BTreeMap) に持ち、 **履歴には置かない**。注入は handle_message の可変文脈 context の先頭へ 「## これまでの経緯(要約)」として差す — 畳んでできた sent_user_turn が そのまま exchange として保存されるので #45 と衝突しない。 復元は restore_histories の summaries から (bootstrap と switch_to の両方)。 **畳むのは保存が済んでから** — 先に畳むと保存に失敗した瞬間に履歴も要約も 無い状態になる。**空の要約・失敗した要約では畳まない** (要約に失敗した代償が履歴の喪失になるのは最悪の交換) し、成功数にも数えない。 個々の失敗で全体は失敗させない (WARN 1 行で次のエージェントへ進む)。 **飛行中ターンがあっても拒まない** — そのターンは sent_user_turn を組み 終えており、完了時の exchange は coversUpToSeq より後の seq を取る。 完了時に System 行を 1 本記録する (要約は会話の外形を変えるので、 いつ畳んだかがログに残らないと後から読めない) 上限: - セッション数: 無制限。**200 を超えたら起動時に WARN 1 行** (削除しない) - 1 セッションのレコード数: 無制限。**50,000 を超えたら WARN** (要約を促す) - ファイルは**自動で縮まない** (copy-on-write)。compact() は持つが v1 では呼ばない — 呼ぶなら明示操作。実測でファイルは実データの約 2 倍 権限: - unix: sessions.redb を 0o600、親ディレクトリを 0o700 - **Windows では設定しない。** {workspace} は %APPDATA% 配下で既に ユーザー私有の ACL 下にあり、POSIX 権限の API が無い。 chmod しようとするほうがバグ - .gitignore へ sessions.redb を**保険として**足す。{workspace} は既定で %APPDATA% 配下なのでリポジトリには入らない — 黒板 (work_dir 配下に生まれる) とは理由が違う - **会話はディスクに平文で残る**ことを README へ明記する。伏せ字保存は 会話の再現性を壊し、検出警告は誤検出が続けば無視される。 代わりに export_session で中身を確認できる経路を保つ API (P2 で配線): create_session / resume_session(id) / continue_latest / fork_session(id, at_seq) / list_sessions / delete_session / export_session(id) → JSONL。 reset_conversation は **create_session への委譲**になる (reset_rule の Spec 12 改訂節を見よ) P1 で着地した純機構 (session_store.rs・2026-08-03): SessionStore::open / create_session(title?) / append(session_id, &Record) → seq / session_meta / list_sessions (updatedAt 降順) / latest_session / records / tail_messages(session_id, limit) / restore_histories(session_id, history_turns) / fork_session(source_id, at_seq) / delete_session / export_session(session_id, &mut dyn Write) / export_session_to_file。 restore_histories は **RestoredHistories { histories, summaries }** を返す — 要約が履歴の席を持たないことを型で保つ (注入は可変文脈の畳みに相乗り)。 1 往復 → ChatMessage 対の変換は **world::exchange_pair の 1 実装**を 押し込む側 (push_exchange) と読み戻す側で共有する。分けて書くと復元した 履歴だけが空メッセージを持ち、次のターンで 400 になる (failures.md #29)。 export の JSONL は 1 行目が {"session":{id, meta}}、2 行目以降が {"seq":N,"kind":...} — **ヘッダ行は kind を持たない** (`"kind":"message"` の grep がヘッダに引っかからないようにするため)。 エラーは SESSION_NOT_FOUND / SESSION_STORE_FAILED (operation に 開始/読み込み/書き込み/確定/開く/初期化/作成/書き出しのどれかを載せる) P2 で着地した配線 (orchestrator・2026-08-03): 書き込み点は **2 つだけ** — Shared::record (message) と Shared::push_exchange (exchange)。後者は履歴へ積む処理と保存を **1 つの関数に閉じてある**: 4 箇所 (成功 / 失敗 / 割り込み / 予算切れ) の 呼び出し側が別々に保存を書くと、片方だけ更新されて再開後の履歴が 実行中の履歴と食い違う (しかもその食い違いは画面に出ない)。 保存の失敗は **WARN 1 行で続行** — 保存できないことは会話を止める理由に ならない。保存先が開けなかった起動は sessions=None で普通に動く (会話は再起動で消える。起動が止まる経路は作らない)。 起動時の復元は open_session_at_boot: 最新セッション (updatedAt) を開き、 会話ログは末尾 log_capacity 件、履歴は exchange から history_turns 往復。 宛先エージェントが消えている履歴は捨てて WARN。 Orchestrator の API: current_session / list_sessions / create_session / resume_session / continue_latest / fork_session / delete_session / export_session。reset_conversation は create_session への委譲 (保存先が無い村だけ旧挙動)。 **開いているセッションを delete したら次のセッションへ切り替える** (無ければ新規作成) — 消したまま開きっぱなしにすると、以後の発話が 行き先の無いセッションへ書かれ続ける P3 で着地した投影 (2026-08-03): IPC 7 本: list_sessions / current_session / resume_session / list_fork_points / fork_session / delete_session / export_session。 **create_session の IPC は作らない** — 新規チャットは reset_conversation が 担うので、同じ操作の入口を 2 つ作らない。 UI は会話ペインのヘッダに「会話一覧」ボタン + SessionDialog.vue (開く / 分岐 / 書き出し / 削除の 4 つ)。分岐は ForkPoint の一覧から 地点を選ぶ 2 段階で、**選んだ依頼の文面は入力欄へ差し戻す** (SessionDialog → ChatPanel の prefill → ChatInput.fill)。 ChatInput は fill(text) だけを defineExpose する — 下書きの所有権は 入力欄に閉じたままにしたいので、v-model を親へ生やさない。 差し戻しは**送信しない**し、宛先 (ForkPoint.to) も一緒に選び直す (差し戻した文面を別のサーヴァントへ送るのが一番痛い間違いになる)。 **投影の要点**: conversationCleared が messages を空にした直後に sessionSwitched が届くので、**そこで list_messages を引き直す** — 開き直しと分岐ではコアが会話ログを戻しており、空のままにすると 画面だけが白紙に見える。 **conversationCleared では通知を出さない** (P3 で変更)。この 1 本は 新規チャット・開き直し・分岐のどれでも同じように飛ぶので、 「新規チャットを開始しました」と言うと開き直しのときに嘘になる。 何が起きたかは操作した側が知っているので、通知は操作側が出す。 **一覧は常時追いかけない** — ダイアログを開いたときと切り替わったときだけ 引く (発話のたびに updatedAt が動くので、毎イベントで引くと会話中ずっと IPC が往復する)。 export の書き出し先は {workspace}/exports/{session_id}.jsonl に固定 (ファイル選択ダイアログのために plugin を足すより、置き場所を 1 つ決めて パスを通知に出すほうが依存も操作も少ない)。 新規チャットの確認ダイアログは**残すが文言を変えた** (D9) — 「この会話を閉じて、新しい会話を始めますか?/今の会話は保存され、 「会話一覧」からいつでも戻れます」。捨てないので警告色は外した 実測 (2026-08-02、40,000 レコード・1 件約 600 バイト): 書き込み 40,000 件 = 271 ms / 後ろから 16 件 = 0.03 ms / 20,000 件の全走査 = 11 ms / fork 10,000 件のコピー = 55 ms / ファイル 64.3 MB。 比較: SQLite FTS5 の既定 unicode61 は**日本語で 0 件ヒット**、 tokenize='trigram' なら全ヒット — 日本語検索に形態素解析は要らない (lindera の 451 依存を外した根拠) path_completion_contract: | 入力欄の `@` パス補完 (Spec 24)。**候補を出すだけの機構**で、 新しい境界も新しいツールも作らない。 ## 凍結 1. 挿すのはパスだけ。中身は展開しない (D1) 選んだ候補は **work_dir 相対パスの文字列**として本文へ入る。 ファイルの中身を発話へ埋め込む経路は**作らない**。 理由は履歴の寿命。**ツール結果はそのターンの messages にしか存在しない**が、 **利用者の発話は sent_user_turn として履歴へ積まれる** (prompt_cache の 「送った文字列をそのまま履歴へ積む」規律)。埋め込むと 1 万トークンの ファイルが**滑る窓 8 往復ぶん再送**される。 **pull なら既存の `file read` の寿命が最初からこれを満たす** ので、 Spec 23 D1 のような「1 ターン限り」の機構を作らずに済む。 **代償**: 中身が要るときは `file read` の 1 周が残る。 消えるのは*探す*周であって*読む*周ではない。 ## 凍結 2. `@` の名前空間はファイル専用ではない (D2) **将来のサーヴァント言及 (`@ザリ @ジェミー` = 利用者からの同報) は 同じ `@` に載せる。** README の「意図的に未実装」に保留として載っている 機能で、`@` を今ファイル専用として埋めるとその道が塞がる。 **候補の種別を持つのはフロントの `Candidate { id, kind }` だけ。** `list_work_dir_files` には `kind` を載せない — サーヴァントの候補は `AgentSnapshot` からフロントのローカル状態だけで組めて **IPC が要らない**ので、 載せると `"file"` 以外が永久に入らない空の欄になる。 **合流はフロントで起きるので、拡張点もフロントに置く。** ## 凍結 3. 打ち切りは必ず画面に出す (D4) `collect_files` は MAX_FILES (20,000) で打ち切る。`truncated: true` を **画面に出す**こと。候補に出ないファイルがあるのに黙っていると 「打っても出ないから無い」と読まれる — この村が grep / 広場ログ / sd で 3 回間違えて直している形 (failures.md #55 / #58)。 ## 凍結 4. 補完は囲いを持たない 挿入されるのは `collect_files` が返したパスだけ (work_dir 境界・ SKIP_DIRS 除外・symlink 不追従を既に通っている)。**利用者が挿入後に 手で `../../` へ書き換えても構わない** — 読む側の `file` / `grep` が `resolve_in_work_dir` (canonicalize + 前方一致) で弾く。 **補完側に検査を足さないこと。** 足すと同じ規律が 2 箇所に住み、 片方だけ直る状態が生まれる (作業フォルダの「参照…」ボタンで 「選ばれたパスは検査せずそのまま欄へ入れる」と決めたのと同じ判断)。 ## 凍結 5. 作業フォルダが無い個体では候補を出さない (D3) 判定は**フロントで行う** — `AgentSnapshot.workDir` が既にあるので、 未設定なら IPC を呼ばずに理由を出せる (AgentSettingsDialog の noWorkDirWarn と同じ形)。**判断に必要な情報を既に持っている層で判断する。** stats_contract: | 統計画面 (Spec 39。**2026-08-16 rev2 承認 → P0 で凍結**。着地は P1〜P3)。 **この村がいくら払ったかを画面で読む。** 扱うのは評価基盤の**費用の層だけ** (otari 側) — 「払った価値があったか」(assertions / pass rate / LLM-judge = promptfoo 側) は合格条件を持たないこの村では空の欄になるので**扱わない** (Spec 15 D7 の規律。材料は Spec 39 Notes 1)。 ## 原本 = Record::Turn (session_store の 4 種別目) 数字の原本は session_store の turn レコード 1 つ。**turn: 行と同じ構造体 (TurnSpend) から 1 実装 settle_turn が両方を書く**。AgentRecord の累計 4 欄 (プロセスをまたぐ生涯累計・会話をまたぐ) は原本にしない — 「いつ」を持たず、 セッション別・時系列が原理的に出ない。fuseforks.log からの埋め戻しも **しない** (8MB 1 世代で回る + テキストの再解析は書式の 2 実装目)。 **帰結: この版より前の会話は遡れない。** 記録の無い会話は 0 の表を出さず 「この会話にはターンの記録がありません (記録はこの版から)」の 1 行だけ (0 は「払っていない」と読まれる — #68 の裏返し)。 ## Scope (閉じた列挙 2 値。**Spec 42 rev2 で all に期間を足した**・2026-08-19) Scope = { kind: "session", sessionId } # 1 会話 (既定 = 今開いている会話) | { kind: "all", period?: { sinceMs, untilMs } } # この村の全会話。period 無し = 全期間 period は **半開区間 [sinceMs, untilMs)** で、TurnRecord.tsMs (ターンの開始時刻) で切る。日付をまたいで走ったターンは**始まった側**に落ちる (払いの単位は ターン 1 本で割らない)。**境界の計算はフロント** (lib/statsPeriod.ts の純関数。 締め日 statsClosingDay + 締め月 {year, month} → ローカル時刻 → epoch ms) で、 **コアは 2 数だけ受け、時計もタイムゾーンも読まない** (aggregate は純関数のまま)。 **session に期間は無い** — 会話そのものが境界で、月を掛けると「この会話に 今月の記録が無い」という下の D6 と紛らわしい空が生まれる。送れる形が無いほうが 強い (Spec 34 の temperature / Spec 37 の tool_choice と同じ)。 **period から締め日を逆算しない** (since = 1 日は月末締めとも 1 日締めとも読める)。 画面のナビは締め月 {year, month} を状態に持ち、境界は都度導く。 **月だけ** — 週・日・任意区間は要る利用がまだ無い。要るときはフロントの純関数へ 選択肢を足すだけでワイヤは動かない (2 数のまま)。 sessions.redb は**村に 1 ファイル** (sessions / records の 2 テーブル) なので、 all は list_sessions() → 各 id で records(id) を読み、turn だけを (session_id, TurnRecord) に集める。**I/O はここまで。集計は純関数**: aggregate(turns: &[(SessionId, TurnRecord)], sessions: &[SessionSummary], scope) -> StatsReport 時計を読まず I/O をしない (budget.rs と同じ形)。IPC は session_stats(scope) の 1 本で「集める → aggregate」の 2 段。フロントは描くだけ。 ## StatsReport (DTO) StatsReport { scope, scopeMeta: { recordedSince: Option, # スコープ (period を掛けた後) で最初の turn の開始時刻。無ければ null oldestMs: Option, # **all のときだけ**: period に関わらず村で最初の turn。 # session では null。turn が 1 件も無い村も null (Spec 42) sessions: [{ sessionId, title, forkedFrom: Option, turns, effective }], # session では 1 件、all では会話ごとの合計表。 # **all で period があるときは turns > 0 の会話だけ** (Spec 42 D4) }, totals: Slice, # スコープ全体 byAgent: [{ agentId, model, ...Slice }], # 鍵は (agentId, model)。agentId は一意ではない # — モデルを切り替えた個体は行が増える byStop: [{ stop, code?, count }], # failed は CODE ごとに分ける series: Option<{ points: [{ tsMs, agentId, effective, prompt, completion, stop }], dropped: u32 }>, # session のみ。all では null } Slice { turns, failed, prompt, cached, completion, reasoning, cacheWrite, cacheWrite1h, effective, cacheRate, outputShare, avgElapsedMs, avgTokensPerTurn } # cacheWrite は生の数で出す。**effective には 1.0× で入っている** (Spec 40 D3) — # 金額が要るならこの生の数へ外部の単価表を当てる。画面は列を増やさず # キャッシュ率のホバーへ内訳を出す (D4)。 規則: - **aggregate の計算順は oldestMs → フィルタ → recordedSince** (Spec 42 rev2)。 oldestMs = min(全 turn の tsMs) を**先に**取り、次に sinceMs ≤ tsMs < untilMs で 落とし、残りの最小を recordedSince にする。逆にすると oldestMs が期間内の最古に なり、画面の ◀ が 1 つ前で止まる。oldestMs は all のときだけ計算する (◀ は all にしか出ない。使う場所が無い値を計算しない — 型ではなく契約で留める。 型を分けると StatsScopeMeta が 2 つに割れる) - **period があるとき scopeMeta.sessions は turns > 0 の会話だけ** (Spec 42 D4)。 「今月」の表に生涯の全会話が — で並ぶのは、行が増える問題をそのまま残す形。 period 無しは現行どおり全件 (— を含む)。**period ありでは表の合計 = totals** — all の sessions は list_sessions() の全件なので「turns に居て sessions に居ない 会話」が存在せず、下の「表に居ない会話も合計には数える」は空振りになる (矛盾ではない。規則は残す) - **effective は budget.rs の重み関数の 1 実装を呼ぶ** ((prompt − cached) ×1 + cached ×0.1 + completion ×4)。 **reasoning は completion の内数なので足さない。** フロントで再計算しない - **通貨に換算しない** (2026-08-15 利用者裁定。凍結)。価格表を持たない — 各社の改定に追従できない / 同じモデルでもキャッシュの帯で単価が変わる (#104: gemini-3.7-flash は 19K 未満で 1.63 倍) / 通貨を出すと「合っている」と 読まれる (実効トークンは「予算がそう数えた」という事実、通貨は推定)。 出すのは利用者が自分の単価で掛けるための数字まで - series は **session スコープのみ**。末尾 N = 500 件 (MESSAGE_LIMIT と同数) で、 落とした件数を dropped に出す (**溢れは数える** — #72)。all では null — 会話をまたいだ 500 件は古い会話が丸ごと消えた列になり、棒を見て 「その会話は払っていない」と読める。all の主役は scopeMeta.sessions の表 - cacheRate = prompt > 0 ? cached / prompt : 0 (AgentCard と同じ分母。 0 除算のガードを定義に含める) / outputShare = (prompt + completion) > 0 ? completion / (prompt + completion) : 0 - 平均は算術平均のみ (中央値・分位は出さない — 出す根拠になる利用がまだ無い) - forkedFrom は **セッションの属性** (SessionMeta.parentId) であって ターンの属性ではない。fork は seq 込みで複製するので、分岐先の統計に 「分岐前に払ったぶん」が載る — 正しい記録 (その会話の文脈を作った費用) - AgentRecord の生涯累計 (カードの数字) と統計の all の個体別合計が **一致するのは、この版より後に作られた村だけ**。既存の村では累計 > 統計で、 欠陥ではない。カードの数字は触らない (素の合計のまま — 別の問い) ## 画面 (Spec 39 D4) - **3 ペインの上に重ねるダイアログではなく、3 ペインを丸ごと差し替える全画面** (2026-08-16 利用者裁定)。App.vue の view: "village" | "stats" 1 つ。 TitleBar と StatusBar は残る (戻る導線・時計・版番号は統計中も要る) - **3 ペインのグリッドは v-show で DOM に残し、StatsView を v-if で足す。** v-if で外すと ChatInput の入力途中・スクロール位置・選択中の個体が捨てられる - view は**保存しない** (bottomTab と同じ。起動は必ず村から) - 更新は core://event の turnRecorded を **統計を開いている間だけ**受けて session_stats を叩き直す。村の表示中はイベントを読み捨てる。 手動更新のボタン・定期 pull は置かない - 描画はテーブル + SVG の棒 1 本 (session の series)。**チャートライブラリは 足さない** — 色は style.css の CSS 変数から引く規律 (v-network-graph を 選んだ理由と同じ) で、SVG なら破れない - promptfoo の見出し (Requests / Total Tokens / Avg Tokens / Avg Latency) を 個体別の表に写す。**Tokens/Sec は出さない** (ストリーミングしないので 意味を持たない)。pass rate 系の 3 チャートは pass の概念が無いので出せない - **全会話は既定で「今の期間」を出す** (Spec 42 rev2)。ヘッダに `◀ 2026 年 8 月分 (7/26〜8/25) ▶` と「全期間」トグル。**▶ は今の期間で止まる** (未来の期間は開けない)、**◀ は oldestMs を含む締め月で止まる** (null なら無効)。 **全期間 ON の間はナビとラベルを出さない** (無効化ではなく非表示 — 灰色の月名は 「今その月を見ている」と読める)。ラベルの範囲は締め月で呼び (「8 月分」)、 右端は untilMs − 1 ms を sinceMs と同じタイムゾーンで日付化する (until は排他なので 9/1 を 8/31 と出す)。期間内に turn が無い月は 「この期間にターンの記録はありません」の 1 行 (D6 の「この会話」と語を分ける)。 この会話 (session) スコープには期間の操作が無い blackboard_contract: | **Spec 55 (2026-09-17 rev2 承認・P0 凍結) が本ブロックの 4 点を覆す** — ファイル名の前半 (表示名 → agent_id) / 状態を動かす手 (file move → blackboard ツールの move) / 直下の `まとめ.md` の特別扱い (廃止) / 書き手 (file ツール → blackboard ツール。file・sd・yq の書き込み系は blackboard/ の下で拒否)。 **正は末尾の「Spec 55」のブロック。** 以下の Spec 54 の形のうち、覆した行には取り消し線を 入れてある。**P3 (2026-09-17) で書き手と状態を動かす手の 2 点、P4 (2026-09-18) で ファイル名の前半と `まとめ.md` の 2 点が着地し、4 点とも覆った** (黒板タブは持ち主を id で引き、`まとめ.md` を特別に扱わない)。 村の黒板 = 共有作業メモ (2026-08-02 条例で運用開始。機構は読み取り投影のみ)。 実体はエージェントの共通 work_dir の `blackboard/` フォルダ (BLACKBOARD_DIR。2026-08-05 に `黒板/` から改名 = 言語に依存しない名前にする 利用者判断。**呼び名としての「黒板」は台帳・設計語に残す**)。 **`.` で始まる名前にはできない** — 走査が隠しフォルダを丸ごと外すので fd / grep から見えなくなり、条例の「着手前に fd + file read」が成立しない。 file read は通るので**書けるし GUI にも映るのに、エージェントだけが自分で 見つけられない**形で割れる (`.concordia` 案を検討して却下)。 **フォルダ名は条例の記述と必ず一致させる** — 食い違うと、エージェントは 条例の名前へ書き GUI はコードの名前を読むので、書けているのに画面に出ない。 書き手は ~~エージェント (file ツール、Spec 09) と人~~ **→ Spec 55 P3: エージェント (blackboard ツール) と人。 file / sd / yq の書き込み系は blackboard/ の下で拒否される**。運用の正は条例 (Ordinance.md): **1 仕事 1 ファイル** ~~`blackboard//<表示名> - <仕事名>.md`~~ **→ Spec 55 P4: `blackboard// - <仕事名>.md`** (Spec 54。 2026-09-17 までは 1 人 1 ファイル `blackboard/<表示名>.md` で、その形は 「状態なし」の孤児として読める)、~~進行役だけが `blackboard/まとめ.md` を束ねる~~ **→ Spec 55 P4: `まとめ.md` は廃止 (凍結 10)**。 GUI 投影は中央下段のタブ「黒板」= IPC list_blackboard → BlackboardNote { dir, state?, name, content, modifiedMs } (blackboard.rs)。 不変条件: - **GUI から「内容を書く」経路は作らない** (書き込み IPC 不在)。条例の 「書いてよいのは自分の付箋だけ」を GUI が迂回する口を開けない - **削除だけは在る** (2026-08-12 の UI 追加。IPC delete_blackboard_note / clear_blackboard)。**上の凍結を覆してはいない** — あれが守っているのは 「誰かの名前で内容が書かれる」ことで、削除はその形を取らない。 むしろ**人にしかできない後始末**で、必要な理由が実在する: **work_dir を移した個体の付箋は本人が消せない** (resolve_in_work_dir が届かない。CLAUDE.md「黒板は work_dir の下にある」)。 2026-08-11 に利用者が手で全消しした経路を、画面へ出しただけ - **削除はごみ箱へ移すだけ。完全削除の経路は持たない** (file ツールの remove と同じ規律)。ごみ箱が使えない環境では **消さずに失敗を返す** — 取り消せない操作へ勝手に格上げしない - **一括には確認を出し、個別には出さない。** 個別は取り消せる (ごみ箱) ので、そこへ確認を積むと**取り消せない操作の確認まで 軽く読まれる**。一括は押し間違いの代償が枚数ぶんなので出す。 確認の文面には**枚数を入れる** (「全部」だけでは何枚か分からない) - **削除できる work_dir は「いまサーヴァントが向いている work_dir」だけ。** GUI から任意のパスを渡せる形にすると、黒板の削除が **どこのファイルでも消せる口**になる (この IPC はツール層の resolve_in_work_dir の外にある)。付箋名 `name` と状態フォルダ名 `state` は **別々に** is_safe_note_name を通す (どちらも 1 成分の平置きの名前だけ。 `/` `\` `..` 先頭 `.` を含めば拒否 = BLACKBOARD_DELETE_FAILED)。`state` の 省略と空文字は直下 (None) と同じ (Spec 54 凍結 4) - **読みと削除は同じ work_dir 列挙から引く** (blackboard_dirs の 1 実装)。 2 箇所に書くと「画面に出ているのに消せない付箋」が生まれる - **push 注入はしない** (モデルは読みたいときに file read、GUI は 表示中の定期再読 10 秒 + 手動更新)。毎ターンの可変注入は増やさない (prompt_cache #45 の規律と、毎ターン可変注入の品質仮説) - `blackboard/` 未作成は空として返す (エラーではない)。読むのは直下のファイルと **直下のフォルダ 1 段の中のファイル**まで。2 段目より深いファイルは無視 (Spec 54 凍結 1。`fd` には出るので、条例が「状態フォルダの中にフォルダを 作らない」と書いて受ける)。読めない 1 枚は黙って飛ばす (1 枚のロック・ 権限で黒板全体を人質にしない) - **コアの返却順は** ~~`まとめ.md` (直下) →~~ **直下の平置き (name 順) → state の 文字列順 → その中で name 順** (Spec 55 P4 で `まとめ.md` の先頭固定を外した) (安定な並びのためで意味は持たせない。 Spec 54 凍結 6)。**画面の列順は別** (下の Spec 54 のブロック)。 1 枚 256 KB 超は切り詰めて末尾に注記 (IPC ペイロードの上限) - 対象は登録エージェントの work_dir (重複除去)。複数の work_dir が 混在しても全部読み、BlackboardNote.dir で区別する - **仕事の状態は state フォルダで宣言する (Spec 54。2026-09-17 rev3)**。 置き場は `blackboard//<表示名> - <仕事名>.md`。`state` は **`doing | on-hold | done` の閉じた 3 値** (英字 = BLACKBOARD_DIR と同じ「言語に依存しない名前」の規律。表示は辞書が訳す)。 **rev2 の 5 値から `needs-you` と `waiting` を落とした** (同日の実機 — 委譲は ターンの中で同期的に待つので「待ちに入った」と宣言する時点が無い / 人の手番は Spec 43 の窓と run.json の pending が機構で決めてバッジに出るので申告が二重。 利用者裁定)。直下の平置きは「状態なし」(unfiled)、3 値の外のフォルダは **フォルダごとに**「その他: <名>」の列 (閉じた列挙から外れた名前を黙って 混ぜず名指しする)。~~**状態の変更は file move であって write ではない**~~ **→ Spec 55 P3: 状態の変更は blackboard ツールの move だけ** (file move は囲いが拒否する) (中身に触れないので append の履歴が保たれる。宛先に同名があれば file の 既存の門が overwrite 無しで拒否する = OS を問わない)。**本 Spec で新設する IPC は無い** — GUI が状態を書く経路は作らない (凍結 2)。将来のドラッグは move の 1 本で、「状態なし」「その他」へは落とせない - **ファイル名の区切りは最初の ` - `** (半角空白 + ハイフン + 半角空白)。 仕事名の中の ` - ` は仕事名の一部。区切りが無ければ全体が表示名 (旧形式 = 仕事名なし)。**BlackboardNote.state は Option (フォルダ名そのもの・None = 直下)、name はファイル名だけでフォルダを含めない** (凍結 3)。仕事名の禁止文字は機械で検査しない (OS が write を拒み、file ツールがその文言を返す = 失敗は沈黙しない。条例に 1 行) - **画面の列は state から決まる (lib/blackboardLanes.ts の純関数)**。列順は `doing → on-hold → done` → 「状態なし」→「その他: …」 で**フロントが持つ** (コアの返却順は state の文字列順で別のもの — コアに 列順を持たせると 3 値の順序がコアと辞書の 2 箇所に住む。凍結 6)。 **3 列は付箋が 0 枚でも見出しを出す**。「状態なし」「その他」は付箋が あるときだけ。~~**直下の `まとめ.md` は列の外・最上部固定・バッジ無し** (classifyNote に掛けない。凍結 9)~~ **→ Spec 55 P4: 特別扱いは無い**。直下の `まとめ.md` は「状態なし」、state の中は普通の付箋で、どちらも持ち主を名乗って いないので孤児バッジ - **持ち主の手番はバッジ (2026-09-16 の 3 列を降ろした)**: classifyNote の 判定はそのまま — active (持ち主が typing 中、または state=dispatched で bundleChars=null の波の進行役か running のタスクの宛先) / yourTurn (持ち主が進行役の波が state=pending) / released (それ以外。内訳 orphanUnknown / orphanMoved / failed / stopped / waiting)。**1 枚に 1 つ**、 優先は yourTurn > active > released で、**優先は「どのバッジを出すか」だけに 効き列の中の並びには使わない** (孤児を先頭へ寄せる以外はコアの name 順。 凍結 5)。バッジの文言は released の内訳で出し、「手が離れている」という 列名は消えた。**列はバッジから派生しない・バッジは付箋から派生しない** — 持ち主 (~~ファイル名の表示名と AgentSnapshot.name の一致~~ **→ Spec 55 P4: ファイル名の前半と AgentSnapshot.id の一致。区切りの無い名前は誰の付箋でもない = コアの split_note_name と同じ判定**) の typing / status / PlanWaveRecord / workDir から画面が毎回引く。ワイヤにも付箋にも持ち主の 状態の欄は足さない (人待ちは Spec 43 の窓と Spec 20 の pending が既に持つ。 3 つ目の真実を作らない)。~~表示名の一致は条例の約束であって機構の保証では ない — 改名すると孤児に見える~~ **→ Spec 55 P4: id は改名で動かないので孤児に ならない**。見出しは「その時点の表示名 - 仕事名」で、id は title に出す - **重複バッジ**: 同じ dir で同じ name (持ち主 + 仕事名の全体) が複数の state に 並んだら**両方に**付く (フロントの派生。コアは数えない。凍結 7)。 `ザリ - A.md` と `ザリ - B.md` は別の仕事なので重複ではない - **列の一括削除は done にだけ付く** (delete_blackboard_note を 1 枚ずつ回し、 途中で失敗したら残りは消さずに止めて読み直す。新しい IPC は無い。凍結 8)。 clear_blackboard は全部 (直下 + 1 段目) - **付箋は見出し行で畳める** (本文だけ隠す。状態は localStorage "fuseforks.blackboardCollapsed.v1"。**鍵は `dir:state/name`**、state 無しは `dir:name` = 既存の保存値を壊さない)。**move で state が変わった付箋は開く** (意図。状態が動いた付箋は人が見るべきもの。鍵を `dir:name` にすると重複の 片方を畳むと両方が畳まれる。凍結 10) Spec 55 (黒板の書き込みをツールにする。2026-09-17 rev2 承認 → P0 で本節を凍結): 起点 — 黒板の規約 (置き場・綴り・状態) をサーヴァントへ伝える経路が条例だけで、 新しい村の Ordinance.md は空・コアのプロンプトは黒板に触れない = 条例に節が無い村では 付箋が 1 枚も書かれない。規約の運び手を条例からツールの説明文と schema へ移す。 - **凍結 1 (ツールと提示)**: 同梱ツール `blackboard` 1 本。**BUNDLED_TOOL_NAMES / DEFAULT_ENABLED_TOOLS / WORK_DIR_TOOL_NAMES のどれにも入れない** (rag と同じ棚。 明示配列の enabledTools を持つ既存の村に生えない穴 = Spec 18 D13 を踏まない)。 提示の門は述語 1 本 = `work_dir がある && uses_blackboard`。**同じ述語を spec_for と call の先頭の 2 箇所が呼ぶ** (提示集合は executable として既に実行フィルタだが、 書き込み系なので call でも 1 回見る。落ちたら outcome=disabled)。理由欄 (Spec 27) は生やす - **凍結 2 (オプトアウト)**: AgentSpec.usesBlackboard: bool (既定 true)。 hearsRoomLog / allowHandoff と同じ肯定形。AgentSnapshot にも写す (投影に無い欄は 保存のたびに既定へ戻る)。役職の雛形には入れない (role_contract の表) - **凍結 3 (主キー)**: 置き場は `blackboard// - <仕事名>.md`。 **前半は agent_id** (Spec 54 の表示名を覆す)。自分の付箋か = 前半が ctx.agent_id か。 **agent_id は生成後に動かない** (発行はフロントの deriveId で新規作成の 1 箇所だけ。 改名は同じ id の name を差し替える) ので改名で孤児は出ない。id の文字集合 `[a-z0-9_-]` に空白は無く、最初の ` - ` で id と仕事名は一意に割れる。表示名は 保存せず、list / read の出力と黒板タブがその時点の名前を id から引く。 ToolContext へ足すのは **agent_names (村の全個体の id → 表示名) と uses_blackboard** (rag_roots と同じく turn.rs の 2 箇所で world から解く)。**旧形式 `<表示名> - …` は読み替えない** (黒板タブで orphanUnknown になる) - **凍結 4 (仕事名の正規化)**: 純関数 1 本を全 op が通す — 前後の空白と末尾の `.` を 落とす / `\ / : * ? " < > |` と制御文字を `_` へ / 空は拒否 / 上限 80 字 (code point)。正規化後の名前を結果に必ず書く。**P2 (2026-09-17) で 2 点足した** — 末尾の `.md` も落とす (盤面は拡張子なしで見せるが、モデルは拡張子つきで返してくる ことがある。落とさないと `調査.md.md` ができ `調査` では引けない) / 上限超えは **切り詰めずに拒否** (黙って切ると別の仕事が同じ名前へ落ちる)。冪等。実装は tools/blackboard.rs の normalize_task_name。ファイル名を (持ち主, 仕事名) へ割るのは blackboard.rs の split_note_name の 1 実装 (書き手のツールと delete_agent の掃除が共有) - **凍結 5 (op は閉じた 6 つ)**: list (本文なしの盤面。直下と「その他」も場所つき) / read { name, owner? } (誰の付箋でも・どの場所でも。範囲の引数は持たない) / write { name, body } (**create-only・置き場は doing 固定**。同じ id + 名前が 3 状態・直下・「その他」のどこにあっても拒否) / append { name, body } (自分の付箋・ **doing と on-hold にあるものだけ**) / move { name, to } (自分の付箋・3 状態の中に あるものだけ) / remove { name } (自分の付箋・場所を問わない・OS のごみ箱)。 他人の付箋への append / move / remove は持ち主を名指しして拒否 **P2 で決めた細部**: (a) op は仕事名しか取らないので、他人の付箋を狙う形は 2 つ — `owner` に他人を書く / 盤面で見た他人の仕事名をそのまま渡す (自分の付箋に無く、 同じ仕事名を他人が持っている)。どちらも not_owner (b) read の `owner` は id でも 表示名でも通る。どちらにも当たらなければ綴りのまま引く (旧形式の付箋を読める) (c) append は前の内容が改行で終わっていなければ改行を 1 つ挟む (メモは 1 件ずつで、 前の行と繋げない。上限の字数にはこの改行も数える) (d) 同じ id + 仕事名が 2 箇所以上 (run や手作業でできる形): append / move は ambiguous で拒否、read は盤面の並びで 最初の 1 枚を返して他の場所を書く、**remove は全部をごみ箱へ** (重複を解く唯一の手) (e) write は create_new で作る (盤面を読んだ後に同じ名前ができていても上書きしない。 Windows の大文字小文字だけが違う名前もここで止まる) (f) list は 12,000 字で有界・ 行の境界でだけ止め、落とした枚数を場所ごとに書く。並びは doing → on-hold → done → 直下 → その他。持ち主を名乗っていないファイル (旧い まとめ.md) はパスつきで出し、 読む経路は file の read と書く (g) 作業フォルダが実在しなければ何も掘らずに断る (h) 結果の文面は ja / en の 2 面 (他の同梱ツールの結果は ja 固定。このツールは 拒否の文面が次の手を運ぶので、英語の村でも読める形にした) - **凍結 6 (遷移)**: doing ⇄ on-hold、doing → done、on-hold → done。**done → * は拒否** (done にある付箋への move は宛先が done でも outcome=frozen。done への append も frozen)。同じ状態への move の no-op は doing / on-hold の中だけ。**やり直しは 「remove してから write」か「別の仕事名で write」の 2 手**で、拒否の文面が名指しする。 **move / remove に現状態の引数は取らない** — 同一個体のターンも周の中のツールも 直列で書き手は持ち主だけなので、サーヴァントどうしの競合は構造上無い。ツールは 名前だけで引き、**結果に実際の遷移 (`on-hold → done`) を必ず書く** - **凍結 7 (write が重複で断るときの案内)**: 通らない手を案内しない。既存の付箋が doing / on-hold なら append・move・別の仕事名、done なら remove してから write・ 別の仕事名、直下・「その他」なら remove してから write・別の仕事名 - **凍結 8 (大きさとフォルダ)**: 付箋 1 枚の上限は 12,000 字 = file read の 1 回の 返却上限 (MAX_OUTPUT_CHARS)。write の本文と append 後の全体に掛ける = **ツールで 書いた付箋は read で必ず全文が返る** (続きを取る引数も分割の案内も持たない)。 超える append は拒否して「別の仕事名で write して続きを書く」を名指しする。 **置き場のフォルダはツールが作る** (write と move。新しい村に blackboard/ は無く、 file mkdir は囲いで塞がる) - **凍結 9 (囲い)**: file の write / append / mkdir / remove (対象が blackboard/ の下)、 move (**元か宛先のどちらか**が下)、copy (**宛先が下のときだけ** — 外への copy は 読み取り)、sd の apply、yq の書き込み op を拒否する。blackboard フォルダ自体への remove / move も同じ。**読み取りは塞がない** (file read / fd / grep / diff / sd の preview / rag)。判定は**解決後のパス**の 1 実装 (work_dir からの最初の要素が `blackboard` か。Windows は大文字小文字を無視)。文字列の前置検査にしない。 拒否の文面は blackboard ツールの write / append / move / remove を名指しする。 **run は塞がない** — 囲いはツール層のもので、run を許した村では保証にならない (run が起こしたプロセスは並行の書き手にもなりうる)。 **P3 の実装 (2026-09-17)**: 判定は tools/fs.rs の is_under_blackboard、拒否と計器は BlackboardFence::refuse の 1 実装。**大文字小文字は OS を問わず無視する** (Windows と macOS の既定では `Blackboard/` が同じフォルダ。区別する OS では黒板タブが読まない 場所まで塞ぐが、失うものが無い)。**yq の set / remove は preview の段で断る** (通して から apply で断ると 1 周を失わせる)。**sd の preview は通し、「apply では書き込めない」の 1 行を添える** (断っていないので計器は出さない)。オプトアウトした個体への文面は ツールを案内せず「黒板を使わない設定」と書く。**アプリは BlackboardTool を登録する** (state.rs) — 囲いだけ入って登録が無いと、黒板へ書く経路が 1 本も無くなる - **凍結 10 (`まとめ.md` の廃止)**: ツールは書かない。黒板タブの最上部固定を外す。 既存のファイルは直下の「状態なし」に孤児として出る (消すのは人)。束ねは返信で返る - **凍結 11 (説明文が規約を運ぶ)**: 説明文に入れるのは 5 点 — 1 仕事 1 付箋 / 着手時に list で盤面を見る / 状態の意味 (on-hold は「止めるよう言われて**自分で** 動かした」。動かせるのは持ち主だけなので「管理人が動かす」と読める書き方にしない) / write は新規だけ・done は動かせない / 仕事の答えは黒板ではなく返信で返す。ja / en。 **実測 (P2)**: 説明文は ja 480 字 / en 1,041 字、schema の説明は ja 259 字 / en 537 字。 5 点のほかに「終わったら最終の報告を返す前に done へ移す」を 1 句入れた (2026-09-17 の 実機で進行役が報告を先に返して付箋が doing に残った。文言なので保証ではない) - **凍結 12 (個体の削除)**: delete_agent は、消す個体の**現在の work_dir** の blackboard/ (直下 + 1 段の全フォルダ = 読み手と同じ走査) から `<その id> - *.md` を OS のごみ箱へ送る。失敗は WARN 1 行で削除は通す。**残余 (保証ではない)**: 以前の work_dir に残した付箋は届かない。**agent_id は削除後に再利用される** (deriveId が空いている最小の番号を取る) ので、同じ id の新しい個体がそのフォルダを 向くと付箋を引き継ぎ、書ける = 「書けるのは自分の付箋だけ」はここで破れる。 運用の処方は「個体を消す前に、以前の作業フォルダの付箋を黒板タブから消す」。 **P3 の実装**: blackboard.rs の trash_notes_of (read_blackboard_dir の結果を split_note_name で割り、前半が id に**完全一致**するものを delete_note へ)。 1 枚でも触ったら `blackboard sweep: agent=… removed=N failed=M` を 1 行 - **凍結 13 (黒板タブ)**: 持ち主の解決は id の一致 (表示名の一致をやめる)。見出しは 表示名 + 仕事名、title に id。orphanUnknown = 前半がどの個体の id にも当たらない (旧形式の付箋 / 凍結 12 の残余)。**掃除が通った削除では孤児は出ない**。orphanMoved・ 重複のバッジ・畳みの鍵・削除の IPC は不変。**GUI から内容を書く経路は作らない** は維持。コアの読み手は状態名を検査しない (3 値を知るのは書き手のツールだけ) - **凍結 14 (計器)**: `blackboard op: agent=… op=… state=… outcome=ok|noop|exists| not_owner|frozen|not_found|too_large|disabled` を 1 行 (frozen は move と append の 両方で出るので op= と組で読む)。囲いの拒否は `blackboard fence: agent=… tool=… op=…`。 本文と仕事名は出さない。**P2 で outcome に 4 値足した** (P0 の 8 値では、引数の誤りと ディスクの失敗が行を出せないか嘘の値になる): invalid (op・仕事名・to・本文の欠け) / misplaced (直下・「その他」にある付箋への append / move) / ambiguous (同じ id + 仕事名が 2 箇所以上) / error (ディスクの失敗)。state= は触った付箋の場所で doing|on-hold|done|root|other|- (3 値の外のフォルダ名は出さない)。move だけ to= が付く。 op= は閉じた 6 つの外なら unknown (モデルが書いた文字列をそのまま出さない) - **撤去**: 止血 (条例の「黒板の節を挿入」+ 黒板タブの空表示の案内 = lib/blackboardOrdinance.ts) は P5 で撤去する (節が無いことの検出ごと要らなくなる)。 **2026-09-18 の P5 で撤去済み** settings_contract: | システム設定ダイアログ (Spec 13。2026-08-03 rev3 承認 → P0 で本節を凍結。 実装は P1〜P5)。主目的は**既にある設定の可視化**。Spec 13 が足した新規の設定は 2 つ (言語・線削除の確認) で、**その後この器へ足されたものは別に数える** — テーマ (2026-08-05・Spec なし)、利用者の呼び名とアイコン (Spec 19)。 **「新規は 2 つだけ」と書いた版は 2026-08-05 のテーマ追加で既に嘘になっており、 Spec 19 の P4 まで気づかれなかった** (増えた名前 theme で grep しても 「2 つだけ」は出てこない = #62 の一般化そのもの)。以後は **器へ足したものを列挙する形にして、総数を書かない**。入口はタイトルバーの「スケジュール」の右 (COG の SVG + 「システム設定」)。画面は 2 ペイン (左メニュー・右ページ、 Kataribe と同型) — **左メニューが「設定できるものの目録」になる**。 置き場は 2 つ (これ以外を作らない): - **村に保存 → world.json**: tokenBudget (Option。単位と正規化は token_budget.ceiling が正)、 askTimeoutSecs (Option。None = 既定 600。30..=3600 の外は保存時に 拒否。Spec 44 — 委譲の待ち時間。詳細は ask_cycle_contract)、 defaultVerifier (Option。None = なし。Spec 53 — 束ねの既定の検証役。 画面の入口は作業状況タブの見出し。詳細は plan_verifier_contract)、 language ("ja" | "en")、 userName (Option。Spec 19。意味論は user_identity_contract が正)。 村を配れば設定も付いて回る。System 行は会話ログに保存されるので、 言語は村の共有物 — localStorage には置けない。 **userName も同じ理由で world.json** — 封筒 `【送り手: {名前}】` が AgentRecord.history と session_store の両方へ文字列そのものとして入る - **端末に保存 → localStorage "fuseforks.settings.v1"**。 **総数を書かず鍵を列挙する** (#67 — 追記そのものが更新になる形): confirmEdgeDelete (boolean。**既定 true** = 絆を切るときに確認を出す。 確認ダイアログは Spec 12 P3 の askConfirm) / confirmClose (boolean。**既定 true** = ウィンドウを閉じる前に確認を出す。 2026-08-08 追加。**ON なら常に聞く** — 「何か走っているときだけ聞く」は 冗長だと利用者が裁定し、加えて**チェックボックスが「確認する」なのに 聞かない場合があると壊れて見える**。小言にしないのは**中身**の側の仕事で、 閉じると止まるもの (飛行中のターン / MCP の扉 / 予定) を そのときの状態から数えて出す。**この村はタスクトレイに常駐しない**ので、 予定が止まる事実は**他のどの画面にも出ておらず、ここが唯一言う場所**) / showPresenceNotices (boolean。**既定 true** = これまでどおり入退室を 会話ペインに出す。2026-08-08 追加。**隠すほうをオプトイン**にしたのは、 既定を変えると既存の村の画面が黙って変わるため。 **表示だけの設定で、モデルへ届く量は 1 バイトも変わらない** — 入退室がプロンプトへ乗る経路は compose_presence_notices で、 生ログの直近 room_log_window 件から System 発を拾う別の機構。 **失敗による停止は隠さない** — カードは「いまの状態」しか示さないので、 過去に落ちた事実の置き場が会話ログしか無い) / autoFitOnResize (boolean。**既定 false**。サーヴァントの絆で **ペインの大きさが変わった後**に fitView を掛け直すか。2026-08-08 追加。 **オプトインなのは、既定を変えると既存の村の見え方が黙って変わるから**。 **「追従し続ける」プロパティは描画ライブラリに無い** — Vue Flow 1.48.2 は 初期化時 1 回の fitViewOnInit と命令的な fitView() だけ、 v-network-graph も fitToContents() と autoPanAndZoomOnLoad だけなので、 ResizeObserver で**コンテナの箱の変化だけ**を見て自前で繋ぐ (2026-08-13 の差し替えでも、呼ぶ関数の名前が変わっただけで機構は不変)。 ノードの移動や辺の増減では発火させない — ドラッグ中に視点が動くと Spec 21 の drop が使う elementFromPoint の座標と画面がずれる。 仕切りのドラッグ中は毎フレーム発火するので 120ms 落ち着かせてから 1 回) / theme ("dark" | "light"。2026-08-05 追加)。表示の都合であって 村の状態ではない (usePaneLayout の「表示の都合は localStorage」と同じ棚) / statsClosingDay (1..=28 | "eom"。**既定 "eom"** = 暦の月。Spec 42・2026-08-19)。 統計の「全会話」を切る月の締め日。締め日 d の期間は **[前の締め日の翌日 00:00, 締め日の翌日 00:00)** のローカル時刻 (25 → 7/26〜8/25 =「8 月分」、"eom" → 1 日〜末日)。**29〜31 は選ばせない** — 2 月に存在しない日を選ばせると「その月はどうなるか」の規則がもう 1 つ要る (却下した代案は 1〜31 + min(d, 末日) の丸め。穴も重なりも出ないが、2 月にだけ 黙って 28 日締めになる規則が設定した本人に見えない)。 **締め日 1 は "eom" と同義ではない** (1 日締めは 7/2〜8/1、月末締めは 8/1〜8/31)。取り違えは説明文ではなく構造で防ぐ — 設定ページの選択肢の直下に その締め日での「今の期間」を**ライブで**出し、統計画面と同じ lib/statsPeriod.ts の 1 実装から導く。**端末に置く理由**は、締め日が請求を読む人の属性で村の状態では なく、会話ログにもプロンプトにも焼き付かないから。村を配った先の人にはその人の 締め日がある。**締め日を変えると過去の月の境界も全部いまの締め日で引き直される** (記録には「どの締め日で払ったか」が無い。締め日ごとに月次を保存する機構は 作らない — 導出値を保存すると 2 実装目)。範囲外・型違いは既定へ落とす / chatZoom (0.9 | 1 | 1.1 | 1.25 | 1.5 | 1.75 | 2。**既定 1** = これまでの 見え方。2026-09-09 追加)。会話ペインの表示倍率。**文字だけでなくペインごと 拡大する** (CSS の zoom) — 会話ペインは大きさを px で直書きした箇所が 33 あり (ChatPanel 16 / ChatInput 15 / GroundingNote 1 / ThinkingNote 1)、 相対単位へ書き換えても px-3 py-2 の余白は固定のまま残るので 150% で窮屈になる。 余白も倍率へ乗せると zoom を手で書いたのと同じ。**zoom が安全なのは このペインに座標計算が 1 つも無いから** (getBoundingClientRect も elementFromPoint も 0 箇所)。**絆の地図に同じ手は使えない** — Spec 21 の drop が elementFromPoint で当たりを取る。**掛けるのは会話の流れと入力欄の 2 つだけ** — ペインの見出し行 (4 ペイン共通の 38px) に掛けると隣との段差になり、 .chat-panel 自体は container query のコンテナ (container-type: inline-size) なので 倍率が入ると @container の幅の評価がずれる。**刻みは離散** (連続量にすると 壊れた値の検査が「範囲内か」だけになり、刻みの意図が消える)。 **表示だけの設定で、モデルへ届く量は 1 バイトも変わらない**。 配線 (:root の --chat-zoom / style.css の .chat-zoom / 掛ける先と掛けない先) は lib/chatZoomWiring.test.ts が走査で留めている。 **保存形は useUiSettings.test.ts が丸ごと固定している** — 鍵が黙って増えると契約と実装が食い違うため - **端末に保存 → localStorage "fuseforks.chatCleared.v1"** (2026-08-08 追加)。会話 ID → その時刻 (tsMs) 以前を会話ペインに出さない。 **消すのは表示だけで中身は 1 件も消さない** — sessions.redb にも shared.log にも残り、**押しても送信量は減らない**。 境界が時刻なのは**発話とツール実行が同じ物差しで切れる**から (AgentMessage.tsMs と ToolRun.tsMs。ID にすると 2 系統の突き合わせが要る)。 **境界は「見えている最後の行の時刻」で、Date.now() は使わない** (壁時計だと押した瞬間に届いた発話がどちら側に落ちるか実行のたびに変わる)。 会話ごとに持つので、新規チャットと分岐は最初から全部見える。 **隠している間は件数と「すべて表示」を画面に出す** — 黙って消すと 押した人は会話ごと失われたと読む (実際は残っている) - **端末に保存 → localStorage "fuseforks.wavesCleared.v1"** (2026-09-15 追加)。作業状況タブ (波ペイン) で表示から隠した波の鍵 "planId:startedAtMs" の配列。**消すのは表示だけで、コアの波のリングには触らない**。 **境界 1 つにしない理由が 2 つ** — planId はプロセス内でだけ単調増加で 再起動すると振り直すので、planId の境界を保存すると再起動後の新しい波が隠れる / startedAtMs の境界だと、押した時点で実行中だった古い波が終わった瞬間に消える。 ゆえに**押した時点で終わっていた波 (確認待ちでなく、実行中のタスクが無い) だけ**を 鍵で覚える。確認待ち (Spec 43) と実行中は押しても隠れない。 クリアは集合を置き換えるので、リングから押し出された波の鍵はそこで落ちる。 隠している間は件数と「すべて表示」を見出しに出す (会話ペインと同じ規律) - **端末に保存 → localStorage "fuseforks.blackboardCollapsed.v1"** (2026-09-16 追加)。黒板タブで本文を畳んだ付箋の鍵 "dir:name" の配列。 **畳むのは表示だけ** (見出し行は残り、付箋の中身にも並びにも触らない)。 部品の外に持つのは、黒板タブが v-if で切り替わりタブを離れると部品ごと 捨てられるため (useWaveClear と同じ形)。一覧に無い鍵は読み直しのたびに 落とす (保存が一覧の大きさを超えて育たない)。壊れた保存値は空として読む - **端末に保存 → localStorage "fuseforks.hiddenGroups.v1"** (Spec 51・ 2026-09-05)。隠しているグループ id の配列。**村には持たせない** — 地図の 表示フィルタは端末の見え方で、村に入れると配った先で「なぜか半分しか 見えない」になる (autoFitOnResize と同じ棚)。知らない id (削除済み・ 別の村) は読み飛ばす。配列を持つので useUiSettings (真偽値だけ) ではなく useWorkDirHistory と同じ形の棚 (useHiddenGroups)。 隠すのは見え方だけで、配送にも一括起動にも効かない (group_contract 凍結 6) - **端末に保存 → localStorage "fuseforks.tour.v1"** (2026-09-13)。初回起動の 案内 (9 歩のコーチマーク) を見たかの印 ("1" だけ)。**村には持たせない** — 村を配った先の人にはその人の初回がある。出すのは lib/tour.ts の shouldShowTour = 印が無く**かつ**モデルテンプレートとサーヴァントが 0 のとき だけ (更新で初めて機構が入った既存の村には出さず、印だけ立てる)。判定は state.ready が立ってから (ready は refreshAll の後 — 前に数えると空の村に見える)。 対象は data-tour="…" を付けた実物の要素で、**空の村にも存在する要素に限る** (起動の歩はカードのトグルではなく一覧ヘッダの一括 ▶)。配線は lib/tourTargets.test.ts が走査で留める。呼び直しは ユーザーインターフェース > 案内 の 1 経路だけ **置き場ではないもの** (保存しない状態。3 つ目の置き場を作ったのではない): - **計画の確認を飛ばすスイッチ plan_review_bypass** (Spec 53・2026-09-15)。 コアの Shared のメモリだけで、**起動時は必ず OFF**。入口はステータスバー (システム設定ではない)。保存しない理由と意味論は plan_edit_window 凍結 11 (b) が正 不変条件: - **左メニューの分類は主題で切る。保存先で切らない** (2026-08-03 の実機 指摘で rev3 から変更)。分類は 全般 (ユーザー / 言語) / コスト管理 (トークン制限) / ユーザーインターフェース (テーマ / 会話の文字 / メッセージ表示/非表示 / 案内)。初版は 「村の設定 / この画面の設定」= 保存先で切っており、**実装の都合が そのまま目録に出ていた**。保存先は分類ではなく各ページ末尾の注記で示す (村に保存される設定は「配ると付いて回る」= 利用者に実害のある事実 だけが伝わればよい)。既定の表示ページは目録の先頭 (2026-08-05 に「全般 → 言語」から**「全般 → ユーザー」へ移った**。 Spec 19 が全般の先頭にユーザーを置いたため。 **「目録の先頭」という規則は不変で、指す先だけが動く**) - **システム設定ダイアログの語彙は Fuseforks のテイストから降りる** (2026-08-03 利用者判断)。UI ラベルは システム用語 = 「トークン制限」「制限あり / 制限なし」、内部の台帳・コード・設計語・ ログは「天井 / ceiling」のまま。CLAUDE.md の用語境界 (外向け=グラウンディング / 内部=接地) と**同型の二例目**。 `CoreError::InvalidTokenBudget` の #[error] は利用者に見えるので 「トークン制限」側で書く。ページ本文の「村」「サーヴァント」は残す (これは世界の説明であって分類名ではない) - **配色 (theme) は「選ぶまで OS に従い、保存もしない」** (2026-08-05 追加。 Spec は切っていない = フロントだけで閉じる変更)。 **language の「初回に確定して再判定しない」とは規律が逆で、理由は 保存先と影響範囲**: language は world.json に住み System 行として **会話ログへ焼き付く**ので、後から解釈が変わると保存済みの内容と 食い違う。theme は端末側の見た目だけなので、選ぶまで OS に追従して 困る人がいない (困るなら選べばそこで固定される)。起点は利用者の観察 — 「一般はライトモードのほうを好む」。開発者の好み (ダーク) を既定に 固定すると、多数派が初回に暗い画面を見る。 **2 値のどちらでもない保存値は既定の定数ではなく OS へ戻す** (定数へ落とすと、手編集で壊れた設定が「利用者はダークを選んだ」状態になる)。 実装は `:root[data-theme="light"]` の**素の CSS** による変数上書き (レイヤ外なので `@layer theme` に必ず勝つ)。`color-scheme` も切り替える — 切り替えないとチェックボックス・ラジオ・select の矢印など **OS が描く部品だけ**が反対のテーマで残る。 **トークンを片方のテーマにだけ足すと、その色を使った場所だけが 反対の色で残る** ので `theme.test.ts` が機械で留める - **「メッセージ表示/非表示」は節を足せば項目が増える形にしてある**が、 機構としての切り替えは**いまも線の削除 1 つだけ**。項目を 1 つ足すたびに 「便利さと安全のどちらかを機械が決められない」の検討が要る (下の 破壊的操作の棚卸しが根拠)。ページの器が広いことは、機構が広いことを 意味しない - **OrchestratorConfig は画面へ出さない** (履歴の往復数・hop 上限・ ツール上限・広場ログの窓 ほか 12 個)。間違えると村が壊れる種類の値で、 正しい値は実測でしか決まらない (#41 の較正)。出すなら別 Spec で 1 つずつ。 **続報 (Spec 44 rev3 / Done 2026-08-25 — 実機検収 5 件観測)**: ask_timeout がその「別 Spec で 1 つずつ」の最初の 1 個 — 欄を World の askTimeoutSecs へ移して 設定化した (12 → 11 個。OrchestratorConfig 自体は画面に出ないまま。 詳細は ask_cycle_contract) - **保存したら次の依頼から効く。再起動を要求する設定は載せない** — メモリ上の World を変えてから world.json へ書き戻す (world.json は 所有者ではなく投影。schedules.json と同じ)。天井は依頼のたびに new_root_budget が World から読むので、次の依頼から新しい値が効く。 ホットリロード用の別機構は作らない - 天井の表示・入力は**実効トークンの整数のみ** (rev3 D1)。素のトークンは 参考値としても出さない — 打ち切り判定に使われるのは実効値で、入力の内訳 (未キャッシュ/キャッシュ済み) が画面に無い以上、素の値は「まだ余裕が あるのに切られた」という誤読だけを生む。文言も凍結: ラベル「トークン制限(実効トークン)」(2026-08-03 に 「トークン天井(実効トークン)」から改称。上の用語境界) / ヘルプ「未キャッシュ×1 + キャッシュ済み×0.1 + 出力×4 を合算した値。 内部では milli 単位で管理し、表示は切り上げた整数です」 - 0 は入力段で弾く (コアの Some(0)→None 正規化との二重化 — 「保存したのに黙って別の値になる」を画面に作らない)。 制限なし (None) の村は**設定画面がその場でその状態を示す**。 起動 WARN を待たせない - 言語の選択肢は 日本語 / English の 2 つだけ。「自動」は**出さない** — 初回起動時に OS の言語を見て ja / en のどちらかへ**確定して保存**する。 検出は sys-locale (コア側。Webview の navigator.language は OS 設定と ズレる)、`ja` 前方一致で Ja・それ以外と取得失敗は En。 **確定済みの値は再起動で再判定しない** (利用者の選択が OS より勝つ)。 world.json の未知の言語コードは「未確定」へ倒して note 1 行 → 次の起動で OS から確定し直す (tokenBudget=0 → None と同じ道。 serde で落とすと world.json ごと開けなくなる)。 UI の辞書は vue-i18n + locales/{ja,en}.json。**鍵集合の全言語一致を テストで固定** (訳の漏れは fallback で画面に出ず、テストでしか見えない)。 言語選択肢のラベル (日本語 / English) は**訳さない** — いま読めない 言語の一覧から自分の言語を探す人が読む - 多言語化は 3 層で、層ごとに判断が違う: (1) UI 文言 = 訳す (辞書は P3。vue-i18n + locales/{ja,en}.json) (2) コアのエラー (#[error] 31 本) = コアは日本語のまま返し、UI が ErrorPayload.code で errors.{code} の辞書を引いて訳す (**コアは言語を知らない**。案 A)。表示は formatError (errorText.ts): 日本語 = message そのまま / 他言語 = 訳語 + **原文併記** (message の可変部 — テンプレート名・件数 — は辞書が持てない。 訳語だけにすると失われ、原文が残れば不具合報告の grep も成立する) / 未知の code = 原文へ落とす (新しいコアと古い辞書の組み合わせで 生キーを画面に出さない) (3) システムプロンプト・ツール説明 = **置換はしない。加算で二言語** (Spec 35 で改訂。2026-08-12)。日本語をバイト等価で据え置き、英語を 足して world.json の language でどちらか一方だけを送る。旧文 「訳さない」が禁じていたのは**置換** (翻訳ではなくプロンプトの 変更で、Spec 08 の「束ねの文言・plan のツール説明は 1 字も 変えない」と衝突する) — 日本語が 1 バイトも変わらない加算は この凍結を破らない。**選ぶ単位は村・新規生成は常に 1 言語**。 ただし**保存済みの System 行と封筒履歴は例外** — 記録時の言語の まま残るので、言語を切り替えた村のプロンプトは混在する (正直な記録として許容。利用者自身の過去の発話と同じ扱い)。 対象外 4 つ: MCP のツール説明 (名付けたのは接続先) / 村の中身 (Ordinance / Construct / SKILL / Memory は利用者の資産) / 台帳 / ツールの結果とエラーの本文 (再入条件は Spec 35 D4) **System 行 (入退室・打ち切り・予算切れ・要約) は記録時の言語で書く** (Spec 35 D6 で「訳さない」から改訂)。System 行は UI 文言ではなく **会話ログの一部**なので表示時には訳さない — 旧文の 「表示時に訳すには kind + params の構造化が要る」は表示時の話で、 language が初回確定なら**記録する瞬間の言語で書けば村の中では一貫する** (構造化もワイヤ変更も不要)。過去の行が当時の言語で残る点は D5 で許容済み (遡って訳し直すと書き出した JSONL と画面が食い違う)。 **compose_presence_notices の見出し (`## 入退室…`) はこれと別** — 保存されず毎ターン生成されるので、組み立て時の現在言語で書く - **UI の恒久要素のアイコンは SVG で描く。絵文字を使わない** — 絵文字は フォント依存で字形と大きさが環境ごとに変わり、currentColor を継承しない のでテーマの配色に追従しない。絵文字を SVG へ替えたら、その絵文字を 本文で参照している文言 (「⚙ から〜」等) を grep で道連れに直す (AgentList.vue の「⚙ から先に 1 件登録」が実例。Spec 13 P1 で回収) user_identity_contract: | 利用者の呼び名とアイコン (Spec 19。2026-08-05 rev1 承認 → P0 で本節を凍結。 実装は P1〜P5)。入口はシステム設定「全般 > ユーザー」= **目録の先頭**。 **呼び名は 1 つではなく 2 つあった。** 起票時の実測: - サーヴァントが読む封筒 = orchestrator の attribute_sender が組む `【送り手: ユーザー】`。**日本語固定で language に追従しない** - 会話ペインの表示名 = ChatPanel.vue の label() が引く辞書キー chat.you (ja「あなた」/ en「You」)。**language に追従する** 本 Spec は 2 つを 1 つの設定へ畳む。 ## 凍結 1. 置き場 呼び名は world.json の userName: Option (ファイル上は camelCase。 language の隣)。アイコンは `{workspace}/user/icon.webp` — `agents/{id}/icon.webp` と**同じ ICON_FILE 定数で親フォルダだけ違う**。 **AgentId に「利用者」を表す特別な値を作らない。** ConfigStore の I/O は read_user_icon / write_user_icon / delete_user_icon の**別関数**にする。 特別な AgentId を作ると、その値が world.agents の検索と Endpoint::Agent の 照合へ漏れる。 **ただし検証は共有する。** 現状の write_icon は WebP マジック番号と 512 KB の検査を関数の中に**べた書き**しており、write_user_icon を素直に 足すと同じ規律が 2 箇所に生える。**検証を述語へ切り出し、両方が通る形にする** (icon_contract の「1 実装を共有する」が正)。分かれてよいのは親フォルダだけ。 **localStorage の fuseforks.settings.v1 には置かない。** 判定材料は settings_contract の境界 (村に保存 / 端末に保存) で、決め手は **会話ログへ焼き付くか**。呼び名は封筒として AgentRecord.history と session_store の両方へ文字列そのものが入る (prompt_cache #45 = 送った文字列をそのまま履歴へ積む規律)。language を localStorage から world.json へ移した理由がそのまま当てはまる。 **保存先で分類しない。** world.json に住むが分類は「全般」= アプリの振る舞い。 条例と役職が「この村がどういう村か」であるのと対 (Spec 14 P3 の差し戻しと failures.md #52 が根拠。**保存先は分類の根拠にならない**)。 ## 凍結 2. 未設定のときの値(Spec 35 で部分改訂。2026-08-12) userName が None のとき: - 封筒は言語で決まる: ja = "ユーザー" / en = "User" (**Spec 35 D5 の部分改訂**。旧文は「日本語固定。language に 追従させない」) - 表示名は chat.you (language に追従する) - アイコンは頭文字の円 (avatarHue(表示名)) **辞書キー chat.you を消さない** — 未設定時のフォールバックとして生き続ける。 **部分改訂の根拠**: 旧凍結の理由は「既存の en の村で封筒が `ユーザー` → `You` に変わり、同じ会話の履歴の途中で送り手名が切り替わる」 だった。**language は初回に確定して再判定しないので、新規の英語村には この理由が当たらない** — 新規村は最初から `User` で一貫する。 途中で言語を切り替えた村は切り替え以後の封筒だけが変わり、それは System 行と同じ「記録時の言語」として許容する (settings_contract 層 3)。 **封筒の語も言語で決まる**: ja = `【送り手: X】` / en = `【Sender: X】` (開き括弧 `【】` は両言語で共通 — 予約文字の集合を動かさないため。 Spec 35 D5)。defuse は **TAG 集合 {送り手, Sender, From} を村の言語に 関係なく常に中和する** (送出は Sender だが、P5 の A/B で From へ 切り替えても偽造の窓が開かないよう先に含める。sender_envelope_contract)。 ## 凍結 3. 設定したときの値 userName が Some(名前) のとき、**封筒と表示名は同じ文字列**になり、 **language に追従しない**。 根拠は attribute_sender の既存 doc — 「プロンプトと画面で同じ送り手が 違う名前になると、利用者とエージェントの会話が噛み合わない」 (Endpoint::System を UI と同じ「Fuseforks」にした判断)。利用者にも同じ規律。 アイコンは会話ペインで agent と同じ経路 (state.icons と同型の objectURL キャッシュ)。設定が無ければ頭文字の円へ落ちる。 ## 凍結 4. 呼び名は保存時に書式検査で拒否する 以下を拒否し、CoreError::InvalidUserName で返す (メモリもファイルも触らない): - 空文字・空白のみ - **`】` を含む** — 封筒 `【送り手: {名前}】` の閉じ括弧。通すと 1 つの発話に 封筒が 2 つあるように読め、送り手を偽装できる - **改行・制御文字を含む** — 封筒は 1 行目。通すと本文と封筒の境界が消える - **32 字を超える** — 封筒は**毎ターン・全宛先**に乗るので、長い名前は 全ターンの固定費になる **注意書きを画面に置くだけにしない。** api_key_env は UI ラベル・注意文・ README・data_contract の 4 箇所に「実値を入れるな」と書いてあったが型は 素の String で、実キーが平文で world.json へ保存された。 **自由入力欄に壊れた値が入りうるなら、必ず入る前提で機械的な検査を置く。** **拒否エラーに入力値を載せない** (同じ事故の処方の写し)。 #[error] の文面は利用者に見えるのでシステム用語で書く (settings_contract の用語境界。InvalidTokenBudget と同じ扱い)。 ## 凍結 5. 顔ぶれには載せない compose_presence は spec.connected_agents から組む「**誰に何を頼めるか**」の 一覧で、利用者は ask / plan の宛先ではない。載せると「利用者へ委譲できる」と 読める行がプロンプトへ入る。 **呼び名がプロンプトへ入るのは封筒ただ 1 つ。** 顔ぶれ・条例・Construct.md の どこにも入らない。 ## 凍結 6. 呼び名は stable_len の外 封筒は**毎ターンの user ロール本文**に入る。system ブロックの安定 プレフィックスに呼び名は 1 文字も現れないので、**呼び名を変えても全員の プロンプトキャッシュは割れない** (prompt_cache の境界は動かない)。 Spec 14 が役職について証明したのと**同じ形の結合テストで留める** — 呼び名を設定しても stable_len が 1 字も動かないこと。 ## 凍結 7. 村を配ると呼び名とアイコンも付いて回る world.json も `{workspace}/user/icon.webp` も workspace の中なので、 村を配れば受け取った人は**前の持ち主の名前で呼ばれる状態から始まる**。 **機構では防がない** — 設定画面で直せる。README へ 1 行書く。 Spec 14 が work_dir を役職の雛形から外した理由は「**端末ごとに違う絶対パスで、 配ると存在しないパスを指す**」ことであって、**名前は端末に依存しない** — 同型ではない。 ## 凍結 8. 改名しても過去は直さない 呼び名を変えても、履歴と会話ログの `【送り手: 旧名】` はそのまま残る。 **残り香を消す機構は作らない** (Spec 14 の役職改名と同じ結論 — system プロンプトは毎ターン組み直されるので現在の呼び名が必ず勝ち、 残るのは履歴の残り香だけ)。 ## 凍結 9. 絆の地図に利用者のノードを出さない (2026-08-05 利用者裁定) 利用者は**全サーヴァントへ話しかけられる**。`send_user_message` は接続を 一切検査せず (`connected_agents` はサーヴァント同士の委譲にしか効かない)、 UI 側の門も「稼働中か」だけ。**利用者の辺は構造上かならず全結線**になる。 **地図の情報量は「繋がっていない組み合わせがある」ことから来る。** 全員と繋がるノードを足すと、辺が N 本増えて情報は 0 しか増えない。 利用者の到達範囲はそもそも線で描かれていない = トポロジーの模型の外に居る (「線は人が引く」の裏返し)。 **凍結 5 (顔ぶれに載せない) と結論は同じだが根拠が違う。** 顔ぶれは *誤読を誘発する* (載せると「利用者へ委譲できる」と読める)。地図は *無情報* (誤読ではなく、足しても何も分からない)。**同じ結論に別々の根拠が あるときは両方書く** — 片方が将来変わっても、もう片方が残る。 attachment_contract: | 添付 — 入力欄からサーヴァントへ画像・音声・動画・PDF を送る (Spec 23 = 画像。2026-08-06 rev3 承認 → P0 で本節を凍結、実装済み。 **Spec 36 = 多モーダル。2026-08-12 rev2 承認 → 末尾「Spec 36 改訂」の節で 凍結 7 を覆し、carries 表ほかを追加凍結**)。凍結 1〜9 の本文で「画像」と あるのは Spec 23 当時の語 — 規律は種別へ一般化されて生きている (据え置き / 覆しの別は各節に明記)。 Goal は「画像を活用する」ではなく**「目立つ欠落を埋める」と「使わない人に 払わせない」の両立**。判断はすべてここから出る。 ## 凍結 1. 加算欄の形 — 添付なしはバイト等価 AgentMessage.attachments: Vec と ChatMessage.attachments: Vec を `#[serde(default, skip_serializing_if = "Vec::is_empty")]` で加算する (grounding と同じ形)。**添付が無い発話のシリアライズ結果は追加前と 1 バイトも変わらない** — P2 の単体テストで 3 adapter とも凍結する。 ここが崩れると画像を一度も使わない村のキャッシュが割れ、画面には何も出ない (stable_len と同じ性質の事故)。 **「使わない人に払わせない」の対象はモデルが読む面とワイヤの出力。** Rust の型 (Attachment / AnthropicRequestBlock::Image / OaiContent) は増える。 2 つを同じ語で書くと「型を増やしたから Goal 違反」とも 「バイト等価も緩めてよい」とも読める (Spec 23 rev3 で切り分けた)。 ## 凍結 2. 置き場 — 本体はファイル、メッセージは参照 実体は `{workspace}/attachments/{uuid}.webp`。AgentMessage が持つのは id・寸法・元ファイル名だけで、base64 をメッセージにも redb にも積まない。 ChatMessage.content: String は変えない (変えると push_exchange / exchange_pair / restore_histories / session_store が同時に動く)。 各ワイヤは**送信の瞬間だけ**ブロック列へ組み立て、画像はテキストより前に置く (Anthropic 公式の推奨)。 ## 凍結 3. 画像は 1 ターン限り (D1。2026-08-06 利用者裁定) 履歴 (AgentRecord.history / Record::Exchange.sent) へは入れない。根拠 3 つ: - Record::Exchange.sent は String で、残すなら redb の既存レコードと非互換 - 滑る窓 8 往復で同じ画像が最大 8 ターン再送される (1568px 16:9 で 1,792 → 8 ターン 14,336 視覚トークン) - 払う量が「添付」という人の操作と 1 対 1 で対応する (Spec 11 が天井を 依頼の因果へ掛けたのと同じ形)。足りなければもう一度添付する **履歴は 2 層あり、添付を持つのは片方だけ**: - 人が画面で何を見るか = Shared.log / AgentMessage (Record::Message) → **持つ** (ファイル参照。再起動しても保持期間内なら表示が戻る) - 次のターンのプロンプト = AgentRecord.history / Record::Exchange → **持たない**。restore_histories は後者だけを読むので、再起動しても 「画面には残るがモデルには渡らない」が保たれる 画面には「この画像は今回のみモデルへ渡ります。2 ターン目以降はモデルからは 見えません (画面には残ります)」を出す。 **ただし「画像があった事実」は畳んだ文脈へ 1 行として残す** (2026-08-06 利用者裁定。実機で穴が出た)。**残すのは印であって画像ではない** ので D1 は不変 — `context` へ入る文字列なので `sent_user_turn` の一部になり、 prompt_cache の「送った文字列をそのまま履歴へ積む」規律でそのまま履歴に載る。 **なぜ要るか**: D1 は画像だけでなく**「画像があったという事実」ごと** 消していた。履歴に積まれるのは畳んだ文字列で、そこに添付の記述が無いため、 次のターンのモデルは**自分が書いた説明文は覚えているのに、画像という物が あったことを知らない**。実機では「画像を貼る → 話す → これを誰々に見せて」 という**最も自然な流れ**で、転送先が理由の書かれていない本文だけを受け取った (下の凍結 5 の門は「そのターンの受信に添付があるか」で張られており、 添付と転送依頼が別ターンになると鳴らない)。 **一般化: 履歴から何かを外す判断をしたら、外したという事実まで一緒に 外していないかを数える。** 物を消すことと、物があったという記録を消すことは 別の判断で、後者は「モデルが答えられない」ではなく **「モデルが答えられないことに気づけない」**として現れる。 ## 凍結 4. 形式とフォールバック (D3) フロントは常に WebP を作る (長辺 1568px へ縮小・縮小したことを画面に出す)。 Provider::OpenAiCompat かつ画像付きで 400 が返った **1 回だけ** JPEG へ 再エンコードして再試行し、それも落ちたら「この接続先は画像を受け付けません」を 画面へ出す (黙って落とさない)。**アニメーション WebP は拒否** (Anthropic は先頭フレームしか読まず、利用者の意図と食い違う。判定は magic では足りず VP8X チャンクのフラグを見る)。 上限: 1 発話 1 枚 / 元ファイル 10MB で拒否 / 変換後 2MB で拒否 (D5)。 検証の共有は述語の単位で行う — validate_icon はパラメータ化**しない** (あの関数は「2 種類のアイコンが 1 つの上限を共有する」を doc で不変条件に している)。共有するのはマジックバイト判定 is_webp だけで、添付は自分の 上限を持つ別の述語 validate_attachment。 ## 凍結 5. 転送では画像を渡さず、落としたことを System 行で知らせる (D6) ask / plan / transfer_to_* の経路で添付は落ちる。落とすとき転送先の本文へ `(画像 1 枚は転送されません)` を入れる。黙って落とすと、転送先がなぜ 画像を見られないのか誰にも診断できない (#44「歯止めの先に道を書く」と Spec 12 P4「飛ばした相手が居たら System 行に必ず書く」の同型)。 **この門は「そのターンの受信に添付があるか」で張られており、射程は狭い。** 添付と転送依頼が別ターンになると鳴らない (実機で踏んだ。 「画像を貼る → 話す → これを誰々に見せて」が最も自然な流れなので、 **現実の使い方はほぼこちらへ落ちる**)。**射程外を埋めるのは凍結 3 の 「画像があった事実を畳んだ文脈へ残す」1 行**で、そちらは履歴に載るので 転送する側が自分で説明できる。**2 つで 1 組**として読むこと — D6 だけを見ると「転送は常に断り書きが出る」と誤読する。 **Spec 36 D6 で分業を固定 (2026-08-12)**: **転送は宛先が運べる種別でも 一律 Drop。carries (下の Spec 36 改訂の節) を読むのは送信入口だけ。** 転送で渡すと 1 回の添付が N ターンの支払いに化け、凍結 3 の「払う量と 人の操作の 1 対 1 対応」が切れる。文言は種別語 (`(音声 1 件は転送されません)` 等) へ一般化し、**モデルへ届く新しい文字列は 2 言語で書く** (Spec 35)。 ## 凍結 6. 広場ログには存在だけを書く (D7) 抜粋にも room_log の pull にも `(画像 1 枚)` の表記だけ。画像ブロックは 広場ログの経路に乗らない。 ## 凍結 7. ~~Gemini ネイティブ経路には実装しない (D8)~~ ## **← Spec 36 D8 で覆した (2026-08-12 rev2 承認)。覆したと明記して覆す** 旧凍結: GeminiPart に inline_data を足さない。ネイティブ経路が要るのは Google 検索の接地だけ (Spec 05 の凍結) で、画像と接地を同時に使う経路は 作らない。 **覆した理由**: 音声・動画を受けるのは Gemini ネイティブだけ (Spec 36 P0 の probe で実測。互換口は音声こそ通るが動画と PDF は通らない)。「画像は互換で 運べるからネイティブに要らない」の前提が**種別の側から**消えた。 画像も同時にネイティブへ通す (probe 済み — WebP inline_data で内容照合まで 合格)。**添付なしのバイト等価テストは維持** — 添付を使わない村の Gemini ワイヤが 1 バイトも変わらないことは Spec 36 でも不変条件。 なお Spec 05 の「ネイティブ経路が要るのは接地だけ」という**ツール側の凍結は 不変** — 覆したのは content 側だけ (道具か中身かで結論が変わる、の続き)。 ## 凍結 8. {workspace}/attachments/ は GC する (D9) 起動時に 30 日より古いファイルを削除し、合計 500MB を超えたら古い順に削る。 **会話ログから参照が消えても実体は消さない** — redb とファイルの 2 つの 真実を同期させない (Spec 12 が index.json を捨てた判断と同じ)。GC で消えた 画像の表示は「画像は保持期間を過ぎて削除されました」の枠にする。 ## 凍結 9. 入口は貼り付けと「参照…」の 2 つ (D4) ドラッグ&ドロップは入れない — Tauri v2 の dragDropEnabled 既定 true が ページ内の drop を横取りする (Spec 21 P0 の実測)。スクリーンショットの 主経路は Ctrl+V なので実害は小さい。 ## P0 実測 (2026-08-06。何が通るかは推測せず叩いて決めた) curl 相当 (in-process HTTP) で各エンドポイントへ WebP と JPEG を送った結果は Spec 23 の P0 実測表が正。**5 系統 × 2 形式 = 10 通りすべて通過**したので、 D3 の既定は WebP。JPEG フォールバックは保険として残す (xAI は公式文書が jpg/png のみと明記しており、文書は将来の実装の予告でもある。 ローカル互換 llama.cpp / vLLM は未実測)。 ## 実機で観測した (2026-08-06。P6 検収 1) OpenAI 互換 (`gpt-5.6-terra`) のサーヴァントが、貼り付けた画像を読んで 内容を説明した。**JPEG フォールバックは発火していない** = WebP が 互換層をそのまま通ったことの実機での裏取り。そのターンの実額は 30,255 tokens。 ## Spec 36 改訂 — 多モーダル (音声・動画・PDF。2026-08-12 rev2 承認 → P0 凍結) 添付の種別を閉じた列挙 AttachmentKind { Image, Audio, Video, Pdf } へ広げる。 形式も閉じる: 音声 = mp3 / wav、動画 = mp4、PDF = pdf。判定は常に magic (ID3・フレーム同期 / RIFF….WAVE / ftyp / %PDF-)。**画像以外は変換しない** (音声・動画の変換器は純 Rust で揃わない。JPEG フォールバックの同型は作らない)。 保存は {workspace}/attachments/{uuid}.{ext} — 画像は変換後 .webp 固定 (既存のまま)、他は magic から決めた ext。**利用者のファイル名の拡張子は 読まない**。**1 発話 1 件は種別によらず据え置き (D5)** — 型は Vec のまま (Option 化は redb の既存レコードと非互換)、保証は送信入口の検査で持ち、 「2 件目は拒否」をテストで凍結して「Vec だから複数対応」の誤読を機械で止める。 ### carries 表 (凍結。Spec 36 の probe 18 発で 20 マス / Spec 37 で 6 行目) **「どのワイヤがどの種別を運べるか」の述語 carries(provider, kind) を 1 実装で持つ。** 読み手は 送信入口の拒否 / フロントの警告表示 (Rust の表を 読むテストで機械同期) / テスト。**転送は読まない** (凍結 5 の分業)。 | Provider | Image | Audio | Video | Pdf | | open_ai_compat | ✓ | ✓ | ✗ | ✓ | | anthropic | ✓ | ✗ | ✗ | ✓ | | gemini | ✓ | ✓ | ✓ | ✓ | | xai_responses | ✓ | ✗ | ✗ | ✓ | | open_ai_responses | ✓ | ✗ | ✗ | ✓ | | meta_responses | ✓ | ✓ | ✓ | ✓ | | perplexity_responses | ✓ | ✗ | ✗ | ✗ | - 全マス**内容照合**で判定した (音声 = TTS の合言葉 / 動画 = 色の遷移 / PDF = 本文の固有文字列 / 画像 = 矩形の色。HTTP 200 では「受理して無視」と 区別できない)。✗ は 400 の受理集合列挙 (OpenAI / Anthropic) または untagged enum の 422 (xAI) による構造判定 - **画像の行が全 ✓** = Spec 34 検収 7 の「ネイティブを選ぶと画像が黙って 落ちる」(Gemini / xAI / OpenAI の 3 例) をこの Spec が解消する (D9) - **xAI の PDF は公式文書に記述が無いのに通った** (文書と実装の乖離 2 例目。 1 例目は Spec 23 の WebP)。文書どおりに締められる日が来たら carries の このマスを実測で取り直す - **perplexity_responses の行は Spec 45 の probe (2026-08-27)**。画像は 内容照合 (1×1 赤 PNG に gemini-3-flash-preview 経由で「赤」)、✗ 3 マスは `invalid type "input_audio"` 等の**型ごと名指し拒否**。判定様式は 第 3 の形 — 名指しはするが受理集合は列挙しない (OpenAI = 全列挙 / xAI = 無言の 422 / Perplexity = named のみ)。**本家 open_ai_responses と Pdf のマスが割れている**ことが、相乗りをやめて 7 本目を切る根拠の 1 つ (Spec 45 D1)。画像 ✓ はワイヤの層 — `perplexity/deepseek-v4-flash-0731` はモデル層で画像を拒否する (400 invalid request。既知の拒否として carries_table.rs の凍結コメントにも残す — Spec 45 D11) - OpenAiCompat の Audio ✓ は**ワイヤの形の話** — 実在するのは input_audio part で、受理はモデル依存 (gpt-5.6-luna は拒否した)。 **述語はワイヤの層だけを守り、モデルの受理は 400 を画面へ出す** (2 層)。 構造的に不可能 (Anthropic へ音声) と実行時拒否を同じ層で扱うと、画面で 区別できなくなる ### 検査の順序 (凍結。Spec 36 rev2 査読 1) 送信入口で carries を判定し、**拒否時は保存も発話の記録もターンも無い** — 出るのは IPC エラー (辞書文言) と計器 `attachment kind: kind=… provider=… outcome=rejected` の 1 行だけ。 通った側も同じ計器で outcome=ok を出す (rejected の行が正の対照を兼ねる — 「行が出ない」だけの検収は計器の空振りと区別できない)。 ### 種別ごとの上限 (D4。種別 1 門・ワイヤ別の門は作らない) 音声 10MB / 動画 12MB / PDF 10MB。基準は**その種別を運ぶ最も狭いワイヤ** = Gemini のインライン 20MB (要求全体)。動画 12MB は base64 で 16.0MB + プロンプトの余白 4.0MB。ワイヤ別にしない理由 — 同じファイルが宛先によって 通ったり落ちたりすると、carries の断り (種別の話) と違い規則が画面から 読めない。代償: PDF は Anthropic 単独なら許される 32MB (公式) を使えない。 **定数は種別を名乗る** (ATTACHMENT_{AUDIO,VIDEO,PDF}_MAX_BYTES)。 既存の画像専用定数は ATTACHMENT_IMAGE_MAX_BYTES / _MAX_EDGE_PX へ改名 (D13。値は不変。弁解コメントを要する汎用名は run.json と同じ規律で改名)。 ### 採らないもの (D10) と GC (D11) **Files API / 事前アップロードは採らない** — 小さくなるのは payload であって トークンではない (視覚・聴覚トークンは毎ターン数えられる)。かつ TTL 付きの 状態 (Gemini Files は 48h) はターン建てのこの村と所有者が合わない (Realtime 見送りと同じ構造)。代償 = 動画はインライン上限の短尺のみ。 GC は既存の器のまま (30 日 / 500MB / 古い順)。**動画が総量を食って他人の 画像を押し出すことは許容する** (30 日が主機構・総量は非常弁)。 attachment gc: の計器へ種別内訳 (kind ごとの bytes) を足し、押し出しの 頻度を観測してから種別クォータを判断する (頻度未知なので観測が先)。 ### 実機で観測した (2026-08-13。P6 の全 6 項目) 4 種別すべてが実機を通った。画像 (Responses)・音声/動画 (Gemini ネイティブ)・ PDF (Anthropic の document / OpenAI 互換 / Responses)。**音声と動画を運ぶのは ネイティブだけ**なので、この 2 件が凍結 7 を覆した判断の裏取りになる。 **Spec 37 で Meta が 2 本目のネイティブとして加わり、表は 24 マスになった** (4 種別 × 6 ワイヤ)。 **運べない組み合わせの拒否は三点が同時に揃った** (ルナ = Responses へ動画)。 画面に carries の表から組み立てた案内、ディスクに mp4 が増えない、 ログに `outcome=rejected` の後 `turn start:` が 1 本も無い。**同じ操作から 正の対照 (計器は動いている) と負の証拠 (ターンは起きていない) が出る**形で、 片方だけなら計器が空振りしている場合と区別できない (#90)。トークンはゼロ。 **添付のコストは `prompt` 単独で読まない。** 9.3MB の PDF を 3 周のターンで 送ると `prompt` は 520,323 に膨らむが、**未キャッシュは 173,564 = ちょうど 1 周ぶん**。D1 が保証するのは**ターンをまたぐ側**で、 **ターンの中の周ごとの再送はプロンプトキャッシュが吸う**。 `prompt` だけを見ると周回数の倍を払ったように読める。 実効トークン (未キャッシュ×1 + キャッシュ×0.1 + 出力×4) は 1 ターン 209,396〜229,166 で、**既定の天井 1,000,000 に対し 21〜23%**。 **D4 の門はサイズで切っていてトークンでは切っていない**ので、上限いっぱいの 10MB なら 1 ターンで天井の 1/4 を使う。設計どおり (払う量が人の操作と 1 対 1 = Goal) だが、数字として残す。 **Gemini の動画は公称の 1/3 以下** — 10.005 秒の 720p (音声つき) と 3.912 秒の mp3 の差が **1,085** しかない (公称 260〜300 tok/秒なら 10 秒で 2,600〜3,000)。`mediaResolution` 依存で、**720p でも公称に届かない**。 画面の注記はこの実測に合わせた (「1 分あたり 1 万トークン規模」)。 **同じファイルを 2 回貼ると 2 回保存される** (重複排除しない)。D11 の帰結 として正しいが、10MB 級を数回貼ると 500MB の非常弁に一気に近づく — 種別クォータを作らない判断を見直す材料はここに溜まる。 ### トークンの実測 (P0。probe の usage から) Gemini: 音声 30 tok/秒 (公称どおり) / 動画 63 tok/秒 (320×240。 **mediaResolution 依存** — 公称 260〜300 は HIGH 時。注記は HIGH 側の桁で 書く) / PDF 520 tok/頁 / WebP 320×240 で 1,064 tok。 Anthropic: PDF 1,633 tok/頁 (公式の 1,500〜3,000 帯内)。 OpenAI Responses: 同じ画像で input 130 tok — **同じ添付でも社ごとに桁が 違う**ので、画面の注記は桁だけを言う (レート表は各社の変更で必ず腐る)。 役職 (Role) — エージェントの雛形と、場に見えるラベル (Spec 14。2026-08-04 rev2 承認 → P0 で本節を凍結。実装は P1〜P5)。 役職は 2 役を兼ねる。**兼ねるが、混ぜない** — 雛形は作るときだけ効き、 ラベルは動いている間ずっと効く。1 つの機構で扱うと「役職を直したら既存の 個体はどうなるか」に答えが 1 つしか作れなくなる。 ## 凍結 1. 置き場 world.json の 6 つ目 (agents / templates / topology_positions / token_budget / language に並ぶ)。ModelTemplate と同じ棚で、**村の共有物**。 村を配ると役職も付いて回る。 Role { id: RoleId, name: String, description: String, color: Option, defaults: RoleDefaults } description は**人が読む説明**でプロンプトには入らない (凍結 6)。 **color は `name` と同じ「参照」側**で `defaults` ではない (凍結 3 の分割)。 表示の属性なので、色を変えると**既にいる全個体のバッジが即座に追従する**。 **プロンプトには入らない** — 顔ぶれに載るのは名前だけで、色は画面のためだけ。 **RoleColor は閉じた列挙** (red / orange / amber / green / teal / blue / violet / pink)。自由な色文字列を採らない理由は 3 つ: - 配色は style.css の @theme 1 箇所で決める規律を保つ。world.json に 生の色が入ると配色がテーマの外へ漏れる - **読みやすさを構造で保証する。** 明度と彩度を実装側で固定し、変わるのは 色相だけ (avatarHue と同じ形)。自由入力は暗い背景に暗い色を選べてしまい、 読めないバッジが作れる - 村を配ったとき、受け取った側のテーマでも成立する 色値は `--color-role-{名前}` の CSS 変数にしかない。**列挙を増やしたら style.css も足す** (手動同期の契約)。 ## 凍結 2. 雛形が持つ欄 — AgentSpec の全欄を 3 分割する ## (**Spec 18 D10 で改訂**: rag_sources を「入れる」から「入れない」へ) **要約せずに全欄を書く。** Spec 14 rev1 は箇条書き 4 本を「4 欄」と数えて 実体の 6 フィールドとズレた。数え落としは要約から生まれる。 検算は 2 本立てる (片方だけだと表と算式がズレる): - AgentSpec の **16 欄 = 対象外 2 + 入れる 3 + 入れない 11** (Spec 55 P1・2026-09-17 に 数え直した。旧記述は「11 欄 = 2 + 3 + 6」で、後から足された allow_handoff / plan_review / role_id / group_id を数えていなかった — model.rs の検算が定数どうしの 自己照合で、実際の欄数を見ていなかったため。**今は左辺を AgentSpec のワイヤの鍵の 数から取る**ので、欄を足して分類を書き忘れると赤くなる) - 下表は 17 行 = その 16 欄 + construct (**ファイルであって AgentSpec の欄ではない**) ゆえに表の「入れる」は 4 行 = **RoleDefaults の 4 欄** | 欄 | 分類 | 理由 | | id | 対象外 | 個体固有。雛形が決めるものではない | | name | 対象外 | 同上 (表示名は作成時に人が付ける) | | construct (ファイル)| 入れる | 役職の本体。作成時に Construct.md へ書く| | model_template_id | 入れる | 役職ごとに向くモデルが違う | | enabled_tools | 入れる | 役職 = 使う道具の集合、が対応する | | max_tool_iterations | 入れる | 調査役はツール往復が多い | | connected_agents | 入れない | 選んだ瞬間に線が引かれる。**線は人が引く** が崩れる | | work_dir | 入れない | 絶対パスで端末ごとに違う。村を配ると壊れる | | rag_sources | 入れない | **Spec 18 で work_dir と同じ性質になった** (意味が「索引済みソース名」→「フォルダの絶対パス」)。村を配ると壊れた参照を配る | | order | 入れない | 左ペインの並び順。役職の性質ではない | | batch_start | 入れない | 一括起動の対象。運用の選択 | | hears_room_log | 入れない | コスト設定 (Spec 03)。村の懐事情で決まる| | allow_handoff | 入れない | 転送の提示。その系統での役割の選択 (2026-08-11)。P1 で表へ回収 | | plan_review | 入れない | 計画の確認 (Spec 43)。同上 | | role_id | 入れない | 流し込みは role_id を触らない (付けるのは呼び出し側)。P1 で表へ回収 | | group_id | 入れない | 所属 (Spec 51)。作成後に人が付ける。P1 で表へ回収 | | uses_blackboard | 入れない | 黒板ツールの提示 (Spec 55)。hears_room_log と同じ個体の運用の選択 | **rag_sources の移動は Spec 18 D10** (2026-08-05)。凍結時に「入れる」だった のは意味が「索引済みソース名」で端末に依存しなかったから。絶対パスへの 意味変更で「入れない」側の分類根拠 (端末ごとに違う絶対パスは雛形に 入れない = work_dir の行) がそのまま当たるようになった。移行は不要 — 既存の defaults.ragSources は空配列しか存在しない (索引に入れる経路が 無かった)。**一般化: 欄の意味を変えるときは、その欄が他のどの型に 写っているかを先に数える** (足すときの「投影にも要るか」の逆向き)。 **参照の存在検査は model_template_id にだけ掛ける** (2026-08-04 P1 の実装で 訂正)。model_template_id = **宣言された登録簿**。world.json に書かれ、 remove_template が参照中の削除を拒む (安定した集合として扱う設計)。 流し込みの時点で World に無ければ**その欄だけ落として WARN**、作成自体は 通す (役職 1 つが壊れていることでサーヴァントを作れなくしない)。 落とした欄は System 行ではなく起動ログと同じ WARN 経路へ (人が直す対象は役職の定義であって会話ではない)。 旧記述「rag_sources は実行時に生える器なので検査しない」は **Spec 18 で 前提ごと失効** — RagIndex は撤去され、rag_sources は雛形の欄ではなくなった。 **境界は「宣言された登録簿か、実行時に育つ器か」。** 同じ「id で外部を指す 欄」でも、参照先の寿命が違えば検査の可否が変わる。 World::from_persisted の「未登録先への接続は黙って落とす」と同型だが、 **黙って落とさない**点だけ違う — あちらは復元、こちらは人が操作している 最中なので、黙ると「入れたはずの設定が入っていない」が見えない。 なお rev1 が rag_sources を除外していた理由 (「村ごとに違うから壊れた参照を 配る」) は別の意味で不成立 — 役職は world.json に住むので、村を配れば 索引も役職も一緒に付いて回る。**入れる判断は変えない。** ## 凍結 3. コピー / 参照の分割 | 対象 | 扱い | 役職の定義を直したとき | | RoleDefaults の 5 欄 | コピー | 既存の個体は変わらない | | 役職の名前 (表示) | 参照 (role_id → name) | 表示は全部追従する | name は **RoleDefaults の外**。台帳・コード・テストで「中身」と書いたら RoleDefaults の 5 欄を指し、name と description を含まない (限定しないと「中身を直しても変わらない」と「改名は追従する」が同時に 真になれない)。 AgentSpec.role_id: Option を持つ。表示名は毎回ここから引く。 中身をコピーにする理由: 参照にすると「後から直すと全員に効く共有規則」の層が **条例と役職の 2 つ**になる。条例が既にその役を担っている。 名前を参照にする理由: 表示名は変わりやすい層で、そこを指すと改名で記述が 腐る (failures.md #51 一般化 2)。AgentId で固定し表示名だけ差し替える 既存の判断と同じ形。 ## 凍結 4. コピーの発火点は「新規作成」ただ 1 つ RoleDefaults が AgentSpec と Construct.md へ書き込まれるのは **サーヴァントを新規作成するときだけ**。他に流し込む経路を作らない。 - 既存のサーヴァントに後から役職を付けられる。ただし role_id だけ。 **設定は 1 欄も上書きしない** (上書きは取り消せない。既に書いた Construct.md を潰すと、履歴に残る人格とこれから読む人格が食い違う) - 役職を外す (role_id = None) も設定を触らない。ラベルが消えるだけ 発火点が 1 つだと「既存の個体は変わらない」が**役職を直したときだけでなく あらゆる操作について**成立する。**発火点が決まらないと契約は凍結できない。** ## 凍結 5. 表示の解決規則 — 3 箇所 1 実装 role_id から表示名を引くのはカードのバッジ・地図のバッジ・顔ぶれの行の 3 箇所。**同じ 1 つの関数で解決し、引けなかったときの扱いも 1 つに揃える。** role_id が World.roles に無ければ**役職の表示を丸ごと省く**。 バッジは描かない。顔ぶれの行は `[...]` ごと落として `agent_2 (ザリ・ロブステル): 稼働中` に戻る。**`[不明]` とは書かない** (存在しない役は判断材料にならず、顔ぶれでは毎ターンぶんのトークンを 払うだけになる)。 3 箇所で規則が分かれると「画面のバッジは消えたのにプロンプトには [不明] が 残る」が生まれる。1 実装へ寄せるのは world::exchange_pair と同じ形 (Spec 12 P1)。役職を削除してもサーヴァントの動作は変わらない — 中身は コピー済みだから。参照方式なら削除が全個体の人格を消す。 ## 凍結 6. プロンプトへ入るのは顔ぶれの行の役職名だけ - **役職の目録 (全 Role + description) はプロンプトに一切入らない。** 目録の読み手は人で、作成時にどの雛形を選ぶかの判断材料。モデルは 作られた後に雛形を選び直さない。「System プロンプトに入れると全部入りに なる」は目録を入れようとしたときにだけ起きる - 顔ぶれの行に役職名を足すだけ: `agent_2 (ザリ・ロブステル) [調査役]: 稼働中` - **自分の役職名はプロンプトに入らない** (2026-08-04 利用者裁定)。 顔ぶれに載るのは connected_agents = **他人**だけで、「# あなたについて」は 表示名しか書かない。理由は**ペルソナが役職名に引きずられる**こと — 人格は Construct.md / SKILL.md の文章が担っており、そこへ 「あなたは助役です」と label を差し込むと、モデルは語の含意 (従属的・補佐的) へ寄る。**役職は人が読む飾りであり雛形の名前**で あって、本人の自己認識の材料ではない。 実機の観測 (2026-08-04): バッジが「助役」のザリに役職を訊くと 「進行役 (オーケストレーター)」と答えた = SKILL.md の役割定義から 答えており、ラベルは読んでいない。**これが意図した状態。** 回帰は `an_agent_never_sees_its_own_role_name_only_the_roles_of_others` (顔ぶれに自分を含める / 「# あなたについて」へ役職を書く のどちらでも落ちる) - 顔ぶれは既に connected_agents のぶんだけに絞られ、既に stable_len の **外** (可変部)。**キャッシュの安定境界は動かない** - **稼働していない個体を顔ぶれから外さない** — 「居るが止まっている」を 知らせるのが Spec 06 の目的で、外すと停止中の相手へ投げる事故が戻る - **description はモデルに見せない。** 読み手が人であること / 顔ぶれは 毎ターン全員ぶんの素の値段で名前 3〜5 トークンに対し説明は 50〜200 / **自分の役職の説明は Construct.md に全文入っており安定部でキャッシュが 効く**。自分は全文・他人は名前だけ、は値段と価値が一致している ## 凍結 7. 役職は実行時の分岐に入らない 配送・権限・ツール提示のどの分岐にも role_id を読む箇所を作らない。 plan の発火条件は entries.len() >= 2 のまま (orchestrator)。 ここが混ざると「進行役 Role なら plan を出す」が自然に見え、**線は人が引く** (AdaptOrch を採らなかった根拠) ごと崩れる。 **判定は「いつ役職を見るか」の 1 点。** 雛形が enabled_tools を持つのは 作成時に初期値を入れるためで、流し込んだ後は普通の AgentSpec の値になる。 **この節を根拠に RoleDefaults から enabled_tools を削ってはいけない** — 凍結しているのは実行時に役職を見ないことであって、雛形が道具の初期値を 持たないことではない。 ## 凍結の外 (仕様には書くが契約では縛らない) - **バッジは由来であって現在の中身を保証しない。** コピー方式の必然で、 作成後に Construct.md も enabled_tools も手で変えられるため、バッジが 「調査役」のまま中身が別の個体が正当な操作で生まれる。**実装しても 落ちないのでテストでは出ない。** 処方は機構でなく言葉 — 仕様と UI (バッジの title) の両方に書き、S2 は「どの雛形から作られたか」、 S3 は「当たりを付けられる」へ約束を縮める。**ドリフト検出は作らない** (差の理由が個体側か役職側か区別できず、区別できない警告は無視される) - **セッションとの食い違いには何も作らない。** session_store は system プロンプトを保存せず、compose_system_prompt は毎ターン現在の spec と ファイルから組み直されるので**現在の役職が必ず勝つ**。残るのは復元した 履歴の中の旧役職の発言だけ。役職表示が変わったら System 行を 1 本書き (付与・変更 (改名含む)・削除の 3 経路。判定は「表示名が変わったか」の 1 点)、compose_presence_notices 経由で次のターンに届く。**ただし 広場ログのオプトアウトと窓で落ちるので保証ではない** — 「自己申告する」 を仕様の約束にしない - 役職ダイアログの入口は**タイトルバーの「条例」の右** (2026-08-04。 初版はシステム設定の左メニューへ入れたが実機で差し戻した)。 **境界は「村の内容物か、アプリの設定か」** — 条例と役職は「この村が どういう村か」を決めるもので、言語やトークン制限のような「アプリが どう振る舞うか」ではない。**保存先で分類しない**: world.json に住むのは 条例も言語も同じで、そこは分類の根拠にならない (settings_contract が rev で潰したのと同じ誤り — 保存先での分類は実装の都合が目録に出る)。 これは入口の話で、置き場そのものは凍結 1 グループ (AgentGroup) — 一覧の区分け・地図の表示切り替え・グループ単位の起動 (Spec 51。2026-09-05 rev2 承認 → P0 で本節を凍結。実装は P1〜P4)。 **隠すのは見え方であって線ではない。** 配送・委譲・転送・予定・統計・提示集合には 1 ミリも触らない。起点は 2026-09-02 の裁定「配線の切り替えは採らず、別系統の ノードとエッジを作る → リストをグループ化して絆に見せる見せないを切り替える」。 ## 凍結 1. 置き場を 2 つに割る — 所属は村、表示は端末 所属 (AgentGroup と AgentSpec.group_id) は world.json。非表示の集合は localStorage "fuseforks.hiddenGroups.v1" (隠しているグループ id の配列。 知らない id は読み飛ばす)。**村に入れると配った先で「なぜか半分しか見えない」** (autoFitOnResize と同じ棚)。 ## 凍結 2. 所属は 1 つ (Option)。跨がない 跨がせると「非表示の意味 (和か積か)」と「全体 ▶ の門 (和か積か)」の規則が もう 1 つずつ要り、どちらも画面に書けない。**共有ワーカーは無所属にする** — 無所属はどのフィルタでも隠れず、接続 (connected_agents) は所属と無関係に跨げる。 Option から Vec へは serde の互換で広げられるので、狭いほうから始める。 ## 凍結 3. 削除は所属を巻き込まない。引けない id は無所属として描く remove_group は groups から消すだけで個体の group_id に触らない (remove_role と 同じ)。引けない id は無所属として描き、**次に所属を書く操作で None へ正規化** (設定ダイアログの保存 / 無所属の箱への drop・箱内の移動)。id は再発行しない (AgentGroup の節) ので、引けない id と None が並んでも取り違えは起きない。 ## 凍結 4. グループ名はプロンプトに入れない 顔ぶれの行にも Construct.md にも載せない (Spec 14 D6 と同じ — 「調査」 「リリース」を読んだ個体はその語に寄る)。名前は人が一覧と地図で見るための分類。 ## 凍結 5. グループは実行時の分岐に入らない 配送・権限・ツール提示・plan の撒き先のどの分岐にも group_id を読む箇所を 作らない (role_contract 凍結 7 と同じ線)。**orchestrator/ の本体に group_id が 0 件**であることを走査テストで留める (pricing が budget を読まない網と同じ形)。 ## 凍結 6. 非表示が効く面は 5 つ。効かない面は名指しで凍結 効く: 一覧 (見出しごと畳む。見出しは残す) / 地図のノード / 地図の辺 (**両端が見えている辺だけ**。隠れる辺の数 = 全辺 − 可視の辺 を要約行へ) / 会話ペインの絞り込み select / Alt+↑↓ の巡回と発話の宛先 (見えている個体だけ。 隠した瞬間に選択中だった個体は見えている先頭へ。**可視 0 体なら選択は null** = 空の村と同じ状態)。 効かない: 会話の行 (名前解決は隠さない) / 配送の全経路 / ツールの提示集合 / 統計・黒板・予定の宛先・ステータスバー・fuseforks.log / **一括起動** (非表示の グループも全体 ▶ で起きる。休ませるのは AgentGroup.batchStart の仕事)。 **無所属はどのフィルタでも隠れない。** ## 凍結 7. 一括起動の門は 2 段。判定は batchAction の 1 本 全体 ▶ の対象 = agent.batchStart && (無所属 || group.batchStart)。 グループの ▶/■ = batchAction(そのグループの個体) — 既存の純関数に部分集合を 渡すだけで、個体の batchStart は効かせる (「含めるか」の軸を 2 つにしない)。 全員 OFF なら無効で title が「対象 0 体」。無所属の見出しにも ▶/■ を置く (目とスイッチは置かない — 隠れず、保存先も無い)。スイッチの置き場は見出しだけ。 ## 凍結 8. drop の確定は IPC 1 本 (commit_agent_drop { order, regroup }) 一覧は区分けごとの VueDraggable (同じ group 名で箱をまたぐ)。end で 1 回、 落ちた箱の区分けを動かしたカードの group_id に、**state から組んだ全区分け** (畳んだ区分けの個体も含む) を連結したものを order にする。並びと所属は **世界ロックの中で一緒に書いて 1 回で永続化** — reorder_agents + update_agent の 2 本に割ると片方だけ通った状態が world.json に残る。地図のノードで終わった drop は 今までどおり絆を張り、並びも所属も確定しない。畳んだ区分けにも落とせる (末尾へ。 畳んだ中のカードは掴めないので出すには表示へ戻す)。 **order の意味は「一覧に並ぶ位置」のまま** — 区分けが入ると最初のドラッグで 連結へ一度だけ正規化される。地図への効きは双方向の辺の描画上の始点だけ (drawDirection は一方向の矢に触らない)。 ## 凍結の外 - v0.2.3 以前のバイナリで村を開くと groups と group_id が消える (failures.md #112。 #[serde(default)] は「欠けても読める」だけ)。利用者が負う条件として README に書く。 **2026-09-14 以降の版は未知の欄を保持して書き戻す** (UnknownFields — 下の ModelTemplate.credential の history (3) を参照)。旧版を直すことはできない - グループ名が空のときの拒否文言 / 見出しの並び (無所属 → groups の配列順) は Spec に書くが契約では縛らない Provider: values: [open_ai_compat, anthropic, gemini, xai_responses, open_ai_responses] detect: | 未指定なら baseUrl から自動判定。`api.anthropic.com` を含めば anthropic、 それ以外は open_ai_compat(判定不能なプロキシは安全側で互換に落とす)。 gemini は**自動判定されない(明示選択のみ)**。generativelanguage.googleapis.com は OpenAI 互換としても動いており、自動判定を変えると設定を触っていない エージェントが黙って別のワイヤへ移るため (Spec 05)。 xai_responses も**自動判定されない(明示選択のみ。Spec 31 D2/P1)** — api.x.ai は OpenAI 互換としても動いており、既存の互換運用の村 (grok-* を open_ai_compat で回している個体)を黙って別のワイヤへ移さない。 gemini と同じ理由の 2 例目。 open_ai_responses も**自動判定されない(明示選択のみ。Spec 34 D7)** — api.openai.com は互換の口が主経路で、この村の gpt-* 個体は全部そこに居る。 3 例目。 non_injective_defaults: | **既定 base URL の表は open_ai_responses で非単射になる (Spec 34 D7)。** open_ai_compat と open_ai_responses が同じ https://api.openai.com/v1 を指す。 gemini / xai_responses は「互換の口も持つ他社ホスト」で**キーが別**だったが、 ここは**値が完全に一致する**。 壊れないのは、lib/providerSkills.ts の baseUrlMismatch / presetBaseUrlFor が **provider を鍵に前方参照するだけ**で、URL から provider を逆引きする経路が 1 本も無いため。**逆引きを足すとここが最初に壊れる。** **同じ文字列を 2 回登録するのは必要**(登録しないと anthropic → open_ai_responses の切り替えで base URL が api.anthropic.com のまま残り、 presetBaseUrlFor の doc が名指しするバグの鏡像になる)。 ALSO_SERVES_COMPAT へは足さない — あれは「互換の口も持つ**他社**ホスト」の 免除で、OpenAI 自身は他社ではない。足すと免除の意味が 2 つになる。 paths: | open_ai_compat: {base_url}/chat/completions + Authorization: Bearer anthropic: {base_url}/messages + x-api-key + anthropic-version gemini: {base_url}/models/{model}:generateContent + x-goog-api-key xai_responses: {base_url}/responses + Authorization: Bearer open_ai_responses: {base_url}/responses + Authorization: Bearer gemini だけモデル名が URL に埋まるため、Provider::path() は &'static str を 返せず String を返す(Spec 05 で全プロバイダの呼び出し側を巻き込んで変更)。 **/responses を返す provider が 2 つある**が、path が同じでも adapter は 別(xai_responses.rs / openai_responses.rs)。**共有の単位は Spec 34 D2 (rev6)**: 共有する — 応答の型 10 個 (Responses*)。11/11 発の serde 実測が根拠 共有する — ResponsesInputItem / ResponsesTool (function_call / function_call_output / flat な function tool は 両社で同じ形を実測している) 分ける — トップレベルの要求構造体 (XaiRequest / OpenAiResponsesRequest) **要求を分けるのは include で必ず割れるから** — xAI は ["no_inline_citations"] を常送し、OpenAI は include を送らない。 共有すると OpenAI へ "include":[] が飛ぶか、skip_serializing_if を足して **xAI 側の golden が動く**。どちらへ倒しても片方の凍結を壊す。 **一般化: 「同じエンドポイントだから同じ型」は送る側では成立しない。** 応答は相手が決めるので同型になりうるが、要求は自分が決めるので、 機能の選び方の差がそのまま欄の差になる。 Effort: values: [low, medium, high, xhigh, max] note: | canonical (ChatRequest.effort) は未指定なら null のまま運ぶ。 XHigh のワイヤ値は `xhigh` (serde の snake_case 変換に任せると `x_high` になり、 TS 側と食い違う)。Effort::as_str と serde 値の一致をテストで固定している。 **Responses ワイヤでの受理集合は実測で確定した (Spec 34 P1)。** gpt-5.6-terra は none / low / medium / high / xhigh / max の **6 値**を受け、 **minimal は 400**。よって **canonical の 5 値はすべて通り、丸めは要らない** (openai_compat が xhigh / max を high へ丸めるのは、受け付けないプロバイダ 向けの縮退。**このワイヤへ写さない**)。 **測り方**: 正しい値を 1 つずつ試すのではなく、**明確に誤った値を 1 つ送る** — 400 の本文がサーバー側から受理集合を全部列挙する ("Supported values are: 'none', 'low', ...")。1 発で表が埋まる。 ワイヤへ出すかどうかと値の決定は adapter の責務で、モデル名で割れる (openai_compat::reasoning_effort)。「未指定なら送らない」は canonical の 規律であって、ワイヤの規律ではない: - 推論制御を持たないモデル: 送らない (解釈できず 400 になる) - grok-4.3 系 / o 系: 未指定でも既定を補う (none / low)。 補わないとプロバイダ既定の深い思考が出力予算を食い潰し空応答になる - gpt-5 系: tools を送る周だけ `none` で固定する。**利用者が段階を 選んでいても上書きする** — /v1/chat/completions は function tools と 思考の併用を 400 で拒否し、キーの省略では回避できない (省略は サーバ既定の思考として扱われる)。tools を送らない周では触らない 値の丸めも adapter が持つ。**丸め先はプロバイダの天井で決まるので 1 つではない** (2026-08-13 に訂正 — 旧記述は「high へ」と 1 つだけ書いていた): - openai_compat: xhigh / max を受け付けない先があるので **high** へ落とす。 **これは本当に段を落としている** - meta_responses: `max` の段が存在しないので **xhigh** へ写す (meta_effort)。**xhigh が Meta の天井なので、丸めというより同義への写像** - openai_responses: canonical の 5 値がすべて通るので **丸めない** (Spec 34 P1 の実測) - xai_responses: **そもそも effort を読まない** (encode に req.effort が 1 度も現れない)。「丸めない」ではなく「送らない」 - anthropic: **同じく読まない。** 送るのは thinking: {adaptive, summarized} だけで、canonical の doc にある output_config.effort は未実装。 **画面で段階を選んでもワイヤは 1 バイトも変わらない** (2026-08-13 実測)。 adaptive は**枠に合わせて思考を縮める**ので、max_output_tokens を 500 にしても本文が 460 字返り、64 まで落として初めて空になった。 **「段階は要求であって保証ではない」の最も強い形** — 要求が届いてすらいない すなわち画面の段階は要求であって保証ではない (failures.md #77)。 CredentialSource: values: unset: 未設定 (既定)。旧版の "none" もここへ落とす (serde alias) not_required: 認証不要であるとユーザーが明示した (ローカル推論サーバなど) keyring: OS の資格情報ストアから取得する。キーはテンプレート ID note: | 秘密そのものを保持するバリアントを持たない。これが 「平文の設定ファイルに秘密が入らない」ことの構造的な根拠。 invariant: | unset のまま送信してはいけない。LlmConfig::from_template が ネットワークへ出る前に LLM_CONFIG で弾く。 根拠: 当初この 2 状態を "none" ひとつにまとめており、キー未登録の テンプレートが「認証不要」と解釈されて認証ヘッダ無しで Anthropic へ送られ、 サーバー側の 401 (x-api-key header is required) になった (2026-07-28)。 ローカルで捕まえられるはずの設定不備を、遠くの失敗にしてはいけない。 同じ理由で clear_credential は not_required ではなく unset へ戻す。 keyring への出入りはコアだけが書ける (単一書き込み者)。 入りは set_credential、出は clear_credential (秘密の削除と一体)。 upsert_template はクライアントの credential を素通しにせず正規化する: 既存が keyring なら維持、秘密の裏付けが無い keyring 主張は unset へ、 unset ⇄ not_required (チェックボックス由来) だけ通す。 根拠: キー登録前に開いた UI 下書きで保存し直すと unset が keyring を 黙って上書きし、「キーは実在するのに未登録」で退避し続けた (2026-07-28、 failures.md #16)。bootstrap は「unset かつ秘密が実在」を keyring へ昇格して 書き戻す (この組み合わせは正規の操作では作れない事故の化石だから)。 Endpoint: tagged_union_key: kind variants: - { kind: user } - { kind: system } - { kind: agent, fields: [id] } - { kind: external, fields: [client] } # Spec 25。外部の MCP クライアント note: | **external を user に畳まない** (mcp_server_contract 凍結 6)。封筒 `【送り手: X】` はモデルが読む入力の一部で、相手が人間かどうかは答え方を 変える情報。client は clientInfo.name の自己申告で、入るのは world::normalize_client_name を通った値だけ。 **表示名は client そのものではない** — world.json の external_name が 設定されていればそちらが勝つ (D8)。解決は orchestrator の external_label 1 実装で、封筒と広場ログが共有する。**Endpoint 側は名乗りのまま据え置く** (記録は監査の材料で、呼び名はその表示規則)。 ワイヤ形は ipc_contract.rs が 4 variant とも凍結している。 # --------------------------------------------------------------------------- entities: TopologyPosition: description: 村の地図上のノード座標。world.json の topologyPositions に保存する UI 表示設定 fields: [x, y] # f64 invariant: | 稼働状態はプロセス寿命で再起動後に実在しないため保存しない。座標だけを保存し、 未配置ノードは UI が円環に自動配置する。エージェント削除時と world.json 読み込み時に 存在しない AgentId の座標を落とす。AgentSpec に混ぜないので、座標更新はエージェント設定を変更しない。 AgentSpec: description: ユーザーが編集する永続的な定義 fields: id: AgentId name: String # 表示名。trim 後の完全一致で一意 (name_invariant) modelTemplateId: ModelTemplateId ragSources: String[] connectedAgents: AgentId[] # 有向グラフの出辺 order: u32 # 左ペインの表示順 workDir: String | null # 同梱 grep / diff の探索範囲。null なら未設定でツールは動かない maxToolIterations: u8 | null # ツール実行回数の個別上限。null なら既定 (12) enabledTools: String[] | null # 提示する同梱ツール名。null なら全提示 (Spec 02) hearsRoomLog: bool # 広場ログを受け取るか。既定 true (Spec 03) usesBlackboard: bool # 黒板ツール (blackboard) を提示するか。既定 true (Spec 55)。欄の無い旧 world.json は true で読む allowHandoff: bool # 転送 (transfer_to_*) を提示するか。既定 true planReview: bool # plan の編集窓 (計画の確認)。既定 false (Spec 43) batchStart: bool # 一括起動 (▶) の対象か。既定 true # 所属するグループ (Spec 51)。**参照**であってコピーではない (role_id と同じ形)。 # null = 無所属。World.groups に無い id は無所属として描き、次に所属を書く # 操作 (設定ダイアログの保存 / 無所属の箱への drop) で null へ正規化する。 # **実行経路で読まない** (group_contract 凍結 5)。**プロンプトへ入れない** (同 4) groupId: AgentGroupId | null batch_start_invariant: | 一括起動 = 左ペインヘッダの ▶ で、batchStart が真のエージェントだけを まとめて起こす (2026-07-31、利用者要望)。起動時に 1 体ずつトグルを 押す手間の解消。 **自動起動ではない。** アプリを開いた時点では誰も走らない — 開いただけで課金が始まる作りにしない。▶ を押したときの対象の選択だけを持つ。 **稼働状態 (AgentStatus) とは別の軸。** 元の UI は 1 つのトグルに 両方を兼ねさせており、「今は止まっているが次の一括起動では起こしたい」を 表現できなかった (だから毎回 1 体ずつ押すしかなかった)。 カードのトグル = 対象の選択 (永続) / スタンバイボタン = 実際の起動・停止。 不変条件: - 既定は true。偽にすると、更新した利用者の ▶ が初回に何もしない ボタンになる (既存の村が全員「対象外」で復元される) - ▶ の役は状態で変わる: 起こせる対象が 1 体でもあれば起動、 対象が全員稼働中なら停止。**混在状態では起動を優先**する — そこで全部止めると、動いていた側の会話を巻き添えに殺す - 遷移中 (starting / stopping) の対象がいる間は、停止へ役を 変えない (mode = none で無効表示)。**2026-08-27 に反転** — 旧規則は「starting は停止側に含める (起動の取り消し)」だったが、 実機では一括起動の直後にもう一度押して起きた直後の個体を止める 二度押しが多発した。starting は MCP 接続のタイムアウト (30 秒) で必ず抜けるので、触れない時間は有界。カードのスタンバイボタンも 同じ理由で遷移中は disabled - 判定は apps/gui-tauri/src/lib/batchStart.ts の純関数が持つ (唯一の意思決定なのでコンポーネントの外でテストする) - 一括の配送は**直列**。並列にすると N 体ぶんの refreshAll が 同時に走って世代ガードの取り直し合戦になり、失敗も N 個の トーストとして同時に出る - **AgentSettingsDialog の seed() にも写す。** 画面に UI は無いが、 写さないと設定保存で既定 (true) へ戻り、対象から外していた個体が 黙って復帰する (ipc_contract の凍結テストでは捕まらない経路) name_invariant: | 表示名は trim 後の完全一致で村内一意 (Spec 06, 2026-07-30)。 表示名は会話・束ね (Spec 04) ・入退室通知・顔ぶれの語彙で、重複すると それら全部が「どちらの話か」を失う。書き込みの入口 (register / update) で DUPLICATE_AGENT_NAME として拒否する。**読み込みは寛容** — 過去に作られた 重複を含む world.json は開ける (検査の目的は新しい重複を作らないことで、 既存データへの罰ではない)。全角/半角の正規化はしない (どこまで畳むかの 線引きが恣意的になり、利用者の意図した区別を潰しうる)。 enabled_tools_invariant: | enabledTools は同梱 8 本 (remember / grep / fd / diff / sd / yq / file / run) の提示制御 (Spec 02 rev2 承認、2026-07-30 凍結。file は Spec 09 で追加)。 MCP 由来・転送・委譲は対象外。**rag も対象外** (Spec 18 D13 — 提示は rag_sources の宣言だけが決める。詳細は rag_tool_contract)。 **blackboard も対象外** (Spec 55 — 提示は work_dir の有無と usesBlackboard が決める。 3 つの定数のどれにも入れない。詳細は blackboard_contract の Spec 55 のブロック)。 - null = 「既定に従う」: **既定集合 DEFAULT_ENABLED_TOOLS を提示**。 新規作成時の保存値は null (UI のチェック全 ON は null の効果の表示で あって、明示配列 7 本を保存するのではない。利用者がチェックを触った 時点で明示配列へ切り替わり、「既定に戻す」で null へ戻せる) - **【破壊的変更・Spec 15 P3 で着地】null の意味を「全同梱ツール」から 「既定集合」へ変える。** それまでは `DEFAULT_ENABLED_TOOLS == BUNDLED_TOOL_NAMES` で挙動は同一。 P3 以降は `BUNDLED_TOOL_NAMES = DEFAULT_ENABLED_TOOLS ∪ {run}` で、 **run だけが既定集合の外**に居る。 理由: run を他の 7 本と同じ扱いにすると、**アプリを更新した瞬間に 全個体がコマンド実行能力を得る**。batch_start_invariant が 「開いただけで課金が始まる作りにしない」と言うのと同じ形で、 **更新しただけで実行能力が増える作りにしない**。 移行は不要 — 既存の world.json は書き換えず、null の解釈が変わるだけで **既存個体は run を得ない**のが移行の目的。 **「新しい同梱ツールが増えれば自動で提示される」は既定集合に入るものに 限る**(この行が旧い約束のまま残ると、run が既定 OFF である理由が消える) - 明示配列 = 「必要な道具だけ」: 列挙した同梱ツールだけを提示。 **新しい同梱ツールは自動で増えない** (それが明示の意味)。[] は 0 本。 **既存の明示配列に file は入らない** — Spec 09 以前に明示へ切り替えた エージェントは、利用者がチェックを入れるまで file を持たない (仕様) - **自動除外が明示より優先**: 作業フォルダ未設定なら、ファイル系 6 本 (grep / fd / diff / sd / yq / file = WORK_DIR_TOOL_NAMES) は enabledTools に関わらず提示しない (使えないツールを見せない。UI は disabled + 注記でチェック状態は保持) - **run は WORK_DIR_TOOL_NAMES に入れない** (Spec 15。あの集合は 「作業フォルダがあるときだけ提示し、かつその中しか触らない」ツールの集合で、 境界を破る run を入れると集合の不変条件が壊れる)。run の提示は **実行可能な登録が 1 件以上あるか**で決まる = command_tool_contract 参照 - **rag はこの機構の対象外** (Spec 18 D13 で 2 段ゲートから宣言のみへ)。 BUNDLED_TOOL_NAMES / DEFAULT_ENABLED_TOOLS / WORK_DIR_TOOL_NAMES の どれにも入らず、提示は **rag_sources の宣言が空または全ルート無効なら 提示しない** の 1 点だけで決まる (rag_tool_contract)。 `BUNDLED_TOOL_NAMES = DEFAULT_ENABLED_TOOLS ∪ {run}` の等式は 8 = 7 + 1 - remember を外しても Memory.md の注入は続く (書きのみ不可)。 注入の制御手段は「ファイルを空にする」として既に存在する allow_handoff_invariant: | allowHandoff は**転送 (transfer_to_*) の提示だけ**を切る (2026-08-11 利用者裁定)。**委譲 (ask_*) と並列委譲 (plan) は残る** — 1 つのフラグで 3 つとも消すと「進行役が誰にも頼めない」に化ける。 分ける理由は**答えの行き先が逆**だから: transfer_to_* 会話ごと渡す。答えは依頼主へ戻らず**利用者へ流れる** ask_* 訊いて**答えを受け取る**。戻り先は reply_to が決めており モデルは触れない (orchestrator の Finish 分岐) **起点は実機** — 進行役が「報告してください」と書きながら転送を選び、 ワーカーの答えが利用者へ返ってオーケストレーションが成立しない事象が 頻発した。**ツールの説明では防げない**: 説明には「答えを受け取るなら ask_* を使え」と書いてあり、モデルはそれを読んだうえで転送を選んでいる (failures.md #84 の一般化「モデルが X を読んで判断する契約は、読んだうえで 従わない個体を 1 体見つけた時点で落ちる」がそのまま当たる)。 **既定は真。** 既存の村の挙動を変えないため、そして転送自体は正当だから (利用者の質問を適任へ回す経路がこれ)。 **委譲 (ask / plan) で呼ばれたターンでは、設定に関わらず提示しない** (2026-08-11 利用者裁定)。判定材料は reply_to の有無ただ 1 つで、 個体の設定とは独立の**構造で決まる規則**。 塞ぐのは**1 つの依頼が 2 本に分裂する**形: 1. ask/plan の戻り口には「答えはこちらへ戻りません」の定型文が返り、 依頼主は答えが無かったものとして報告する 2. 中身は**別の因果**として宛先の受信箱へ積まれ、飛行中のターンが 終わってから届く。依頼主はそれを新しい依頼として読み、答えを **利用者へ**返す 実機 (2026-08-11 04:54-04:59) では 1 の報告と 2 の到着が 2 分 57 秒 ずれ、余分に 2 ターン走った。**転送先は依頼主自身**で、モデルの意図は 正しく道具だけが違った。ミューテーションでは max_hops まで輪が回り、 利用者宛の答えが 4 通になる。 **委譲の連鎖は残る** — ワーカーはさらに別の相手へ ask / plan できる。 消えるのは「会話ごと渡して自分は答えない」1 つだけで、 「自分では無理なので C へ」は ask_C で書ける (答えを受け取って 自分の答えとして返す形になる)。 **HandedOff はこれで死んだわけではない。** Reply.kind = HandedOff / PlanTaskState::HandedOff に到達する経路は**ツール非対応モデルの旧経路** (終了マーカーが無ければ最初の相手へ渡す) として残る。 tests/orchestrator.rs の a_transferring_task_is_recorded_as_handed_off が そこで凍結してあり、撤去しに来ると赤になる。 **提示集合と判定集合を揃える**: 提示しない周では handoffs.decide も 転送要求を拾わない。ずれると「出していないのに効く」か「出したのに 効かない」のどちらかになる (Spec 20 で踏んだ形)。 **手順の文も揃える** — 提示していない道具を名指しすると、存在しない ツールを探させることになる。 **診断はログで読める** (2026-08-11 に足した計器): handoff: agent=… to=… hop=… 転送そのもの reply: agent=… to=… hop=… chars=… refusal=… 答えの宛先と断り形 reply の to=user は「reply_to を持たない配送 = 転送で来た依頼」の証拠。 **この 2 行が無かったせいで、原因の特定に何往復もかかった** (#94)。 refusal=yes|no は**断り形**の観測 (2026-08-24 追加。crate::refusal の 1 実装 — 先頭 80 字の窓 × 閉じた語彙表)。**配送には一切効かない** — 「分類は文言 parse でなく型で運ぶ」の凍結が縛るのは配送で、観測だけの 欄はその外。名前が failure でないのは「できません」が正答の依頼が 実在するため — 数えるのは形で、解釈は読む側 (完遂の軸の材料。 eigent 実読 2026-08-24 由来、出自は camel の validate_task_content)。 hears_room_log_invariant: | hearsRoomLog は広場ログの**受信側だけ**の設定 (Spec 03 rev2 承認、 2026-07-30 凍結)。serde default = true (旧データ・新規作成とも現状互換)。 - false なら compose_room_log をスキップ。履歴 (自エージェントが 送受信したもの) の注入は従来どおりで、変わるのは広場ログの有無だけ - false なら `room_log` ツールも提示しない (Spec 22 で追記。opt-out は 抜粋と読み取りの両方に効く — 抜粋が届かない個体は ID を知る経路が 無く、ツールだけ出しても呼びようがない。room_log_pull の提示条件) - false にしても、そのエージェントの発話は他エージェントの広場ログに 従来どおり載る — プライバシー機能ではなくコスト機能 (毎ターン最大 room_log_window × room_log_excerpt_chars の固定費を絞る) work_dir_invariant: | workDir はユーザーが明示した絶対パス (2026-07-29)。エージェントは プロンプトインジェクションを受けうるため、**読める範囲 = 漏洩しうる範囲** として扱い、同梱ツールの読み取りをこのフォルダの中へ閉じる。 強制は設定値の文字列検査ではなく**ツール実行時**の canonicalize + 前方一致 (tools/fs.rs)。symlink は辿らない — `..` の検査だけでは symlink 経由の 脱出を塞げない。旧 world.json (フィールド不在) は serde(default) で null。 実パスは**システムプロンプトで本人に開示する** (2026-07-29)。ツールは 相対パスしか返さないため、開示しないとモデルは説明に使う絶対パスを 推測で創作する (実機で実在しない `D:\work\Concordia` を作業場所として 語った)。判断材料の欠落は禁止ではなく情報で埋める — 知っていれば 創作する理由が消える (メタ情報の非対称の教訓と同型)。 AgentSnapshot: description: 定義と実行時統計を畳んだ読み取り専用ビュー fields: id: AgentId name: String model: String # テンプレートから解決。欠落時は "" modelTemplateId: ModelTemplateId status: AgentStatus uptimeSecs: u64 # 停止しても保持。再起動で加算 totalTokens: u64 # 入力 + 出力 promptTokens: u64 # うち入力。キャッシュ率の分母 cachedTokens: u64 # うちキャッシュ読み取り # 直近の LLM 呼び出し 1 回ぶんの入力 (Usage.prompt = 未キャッシュ + cache_read + # cache_write + Gemini の toolUsePromptTokenCount)。**累計ではない** — 上の 3 欄と # 違い settle_turn で代入する。**ターンの合計 (TurnRecord.prompt) でもない** — # あれは全周の和で、分子に使うと 6 周で窓の何倍にもなる (Spec 49 D1)。 # null = まだ 1 度も呼び出していない (0 を番兵にしない)。usage が返らなかった # 失敗ターンでは上書きしない (前回値が残る)。累計 4 欄と同じく揮発 — # PersistedWorld に無く、再起動で null に戻る (Spec 49 D6)。 # 用途: 会話ペインの入力欄の下の輪 = lastPromptTokens ÷ ModelTemplate.contextLength lastPromptTokens: u64 | null ragSources: String[] connectedAgents: AgentId[] order: u32 workDir: String | null # 設定ダイアログの編集用に spec から写す maxToolIterations: u8 | null # 同上 enabledTools: String[] | null # 同上 hearsRoomLog: bool # 同上 usesBlackboard: bool # 同上 (**投影にも要る** — Spec 55) allowHandoff: bool # 同上 (転送を提示するか。**投影にも要る**) planReview: bool # 同上 (plan の編集窓。**投影にも要る** — Spec 43) batchStart: bool # 同上 (一括起動 ▶ の対象か。稼働状態は status) groupId: AgentGroupId | null # 同上 (所属。**投影にも要る** — 無いと保存のたびに消える。Spec 51) lastError: ErrorPayload | null AgentGroup: description: | サーヴァント一覧の区分け = タスク名 (Spec 51)。役職 (AgentRole) と同じ棚 (PersistedWorld.groups、world.json。**村を配ると付いて回る**)。 id はコアが発行する UUID v4 で**削除しても再発行しない** (引けない group_id が 個体に残る設計 = group_contract 凍結 3 と組むと、再利用した id が削除前の個体を 黙って吸う)。並びは groups の**配列順 (作成順)** で、並び替えの UI は持たない。 fields: id: AgentGroupId name: String # 見出しに出る。空は拒む。重複は拒まない (タスク名は重なりうる) batchStart: bool # **全体の ▶** がこのグループの個体を対象にするか。既定 true。 # グループ自身の ▶/■ には効かない (効かせると OFF のグループの # ▶ が死んだボタンになる)。個体の batchStart は別の門で、 # 全体 ▶ の対象 = agent.batchStart && (無所属 || group.batchStart) ModelTemplate: description: LLM 接続設定。複数登録して各エージェントから参照する fields: id: ModelTemplateId name: String baseUrl: String # パスは Provider が付ける(Provider.paths 参照) model: String # Spec 49 でこの欄に初めて読み手が付いた (それまで定義と既定 128,000 の 2 行だけ)。 # コンテキスト使用率の輪の分母。**手入力または「取得」ボタン** (Spec 50 — 単価表の # 要素の max_input_tokens を、単価と同じ規則で欄に入れる。表にあれば上書き・無ければ # 触らない・保存はしない)。どちらの経路でも実際の窓と違えば輪の % は嘘になる — # 100% を超えた数字はそのまま出す (設定が窓より小さい診断)。表の値が実際の窓と # 同じである保証は無い (Spec 50 Notes 4) contextLength: u32 temperature: f32 | null # null なら送らない # **requestTimeoutSecs と連動する。** 上限だけ上げると「途中で切れる」が # 「タイムアウトする」に変わるだけで、16,000 トークンの生成は既定の 120 秒に # 収まらない。2 つは必ず一緒に動かす。32K 超はストリーミング前提の数字で、 # 本製品は非ストリーミング (llm_wire) なので設定だけでは届かない # OpenAI 互換ワイヤでの欄名はモデルで割れる — gpt-5 系 / o 系は # max_completion_tokens、他は max_tokens。常に片方だけ送る # (openai_compat::uses_max_completion_tokens。旧欄は 400 # unsupported_parameter で拒否され、新欄は知らない互換サーバがある。 # failures.md #76) # **Responses ワイヤ (xai_responses / open_ai_responses) は # max_output_tokens の 1 名だけ。** 互換の送り分けをここへ写さない — # max_completion_tokens を送ると #76 の逆向きを踏む (Spec 34 D11) maxOutputTokens: u32 credential: CredentialSource # 取得元のみ。秘密を持てるフィールドは存在しない provider: Provider | null # null なら baseUrl から自動判定 useTools: bool effort: Effort | null googleSearch: bool # 既定 false。provider == gemini でのみ効く (Spec 05) # 2 欄とも既定 false。provider == xai_responses でのみ効く (Spec 31)。 # 名前にベンダー接頭辞を持つのは googleSearch と同じ理由 — 素の webSearch は # provider 中立に読め、後続の OpenAI web search と衝突する。別トグルなのは # 別ツール・別課金・別 output 種別と実測済みのため (1 つに畳むと web だけ # 欲しい村が X の攻撃面まで開ける — Spec 31 D3) xaiWebSearch: bool xaiXSearch: bool # 2 欄とも既定 false。provider == open_ai_responses でのみ効く (Spec 34)。 # ベンダー接頭辞の理由は xaiWebSearch と同じ。 # openaiReasoningPro は reasoning.mode を "pro" で送るかどうかの 2 状態で、 # **OFF では欄ごと省く** — 送らないのと "standard" は input_tokens が # 20 で完全一致する (P0a 実測) ので、省略 ≡ standard。 # 3 値の選択肢ではないので既存のトグルの形に乗る (Spec 34 D4) openaiWebSearch: bool openaiReasoningPro: bool # 既定 false。provider == meta_responses でのみ効く (Spec 37)。 # **この行は Spec 45 P0 で回収した追従漏れ** — model.rs には Spec 37 から # 実在するのに、この列挙に無かった (metaWebSearch で grep しても # 0 件だったのが証拠。#51 の「隣の節」の再演) metaWebSearch: bool # --- Perplexity の固有スキル 4 本 (Spec 45 D3)。すべて既定 false、 # provider == perplexity_responses でのみ効く (*_active() の AND 述語)。 # 別トグルなのは別ツール・別課金・別 output 種別 (web/finance/people は # 検索の入力膨張 + finance/people は 1 回 $0.005 の呼び出し課金)。 # openaiWebSearch からの自動写しは作らない — 切り替えただけで課金面が # 開く経路にしない (サイレント喪失は P2 のダイアログ注意文で受ける) perplexityWebSearch: bool perplexityFinanceSearch: bool perplexityPeopleSearch: bool perplexityFetchUrl: bool # --- Gemini の固有スキル (Spec 48 D3)。既定 false、provider == gemini でのみ # 効く (gemini_url_context_active() の AND 述語)。google_search と同じ棚。 # 思考段階 (thinkingLevel) は新しい欄を持たない — 既存の effort を写す (D1) geminiUrlContext: bool requestTimeoutSecs: u32 maxRetries: u32 # --- 単価 (Spec 41)。すべて Option + #[serde(default)]。$ / 100 万トークン --- # **既存の村は 1 つも持たない**ので、無いのは 0 ではなく「未設定」。 inputPerMtok: f64 | null outputPerMtok: f64 | null cacheReadPerMtok: f64 | null cacheWritePerMtok: f64 | null cacheWrite1hPerMtok: f64 | null # Anthropic のみ。cacheWrite の TTL 違い pricingAsOf: String | null # この単価が正しいと信じられる時点 pricing: | **単価は 5 欄 + 日付 1 欄 (Spec 41 D1)。3 欄 (入力/出力/キャッシュ) では足りない** — Spec 40 P2 の実測で「未キャッシュ」の実体はほぼ全部がキャッシュ書き込みで (Anthropic 100.0% / OpenAI 互換 99.8%)、3 欄で計算すると同じログから **1.52 倍ずれる** (実測 $0.2852 対 $0.4332)。 **未設定は「無視」ではなく「1 段上へ落とす」** (出自は otari の calculate_metered_cost)。cacheWrite1h → cacheWrite → input / cacheRead → input。**落ちたトークンは素の入力に残るので、表が不完全でも 合計トークンは失われない。** input と output が両方未設定なら 「単価未登録」として合計から外し、**外したことを画面に出す** (D4 の被覆率)。 **掛ける順序は「単価を先に解決してからトークンを掛ける」** — 引き算と フォールバックの順序で結果が変わるため。引き算には max(0, ..) のクランプが 要る (cacheWrite1h <= cacheWrite は decode で強制しておらず、Spec 40 が 検定として残す判断をしたので不整合な記録が入りうる)。 **手入力は数値のみ。通貨記号を書かせない** — 通貨は表示側が持つ。 **pricingAsOf は 1 欄。「取得した日」と「表の日付」を 2 欄に分けない** — 意味を「この数字が正しいと信じられる時点」に固定すれば、取得でも手入力でも 常に埋まる。2 欄にすると「画面にどちらを出すか」が新しい未決になり、 差が意味を持つのは静的ファイルが現実に対して古いときだけで、 **それはこちらから観測できない**。 **単価は村と一緒に配られる** (ModelTemplate は world.json に住む)。 公開情報でテンプレートの属性なのでこれでよい。**配られないのは取得 URL だけ** — あれは {app_data_dir} の棚 (Spec 25 と同じ線引き。村を配ったとき、受け取った 人の村が、その人の知らない URL へ取りに行ける状態を作らない)。 pricing_fetch_freeze: | **取得の凍結事項 (Spec 41 D2)。pricing に持たせてはならないもの**: - interval / tokio::spawn / background task - 起動時・画面遷移時・フォーカス時の自動 GET - URL の到達確認のための自動 GET (疎通確認もボタン押下時のみ) - リモートと比較して「古いか」を判定すること → 古さは pricingAsOf からの【ローカルの経過日数】だけで表示する **最後の 1 行が要**。D4 が日付を出すのに保存先が無いと、実装者は 「古さはリモートの Last-Modified で分かる」と考えて**起動時に HEAD を飛ばす**。 「古い」を手元の引き算だけで定義すれば、比較のために外へ出る理由が消える。 これを守る限り PRIVACY.md の「起動時に外部へ問い合わせません」は生き残る。 **一方「開発者が運営する送信先はありません」と「利用者が自分で設定した接続先に 対してだけ」の 2 文は嘘になる**ので、取得を足すコミットと同じコミットで PRIVACY 日英へ 1 文足す (P0 では足さない — 存在しない送信を宣言することになる)。 取得は **https のみ・数値のみ・照合の鍵はこちらが持つ model** (向こうが名前を 名乗る余地を作らない)。受け取る数値は **単価 5 つ + max_input_tokens** (Spec 50 で 窓を足した。上の禁止事項はこの追加で 1 字も変わっていない — 変わるのは受け取る 数値の列挙だけ)。単価は 負・非有限・桁外れ (> $1,000/MTok) をその 1 件だけ落とす。 **窓は欄だけを落とす** (Spec 50 D4): 表の値は Option で受け (整数型で受けると 負数 1 件で表全体の parse が落ち、「欄だけ落とす」が書けない — 2026-09-05 実測)、 sane_context が 有限・小数部 0・1..=u32::MAX のときだけ Option にする。 0・負・非整数・u32 超は窓だけ None にして要素は残す (dropped には数えない — 単価としては健全な要素)。要素を落とす規則は単価の側のまま (input と output が 揃わない要素)。窓の件数は `pricing fetch:` の context= に出す (表が欄を運んでいるかを ログから読むため)。 **取得は欄に入れるだけで保存はしない** — 「取得したら黙って上書きされた」を 構造で作らない。取得できなかった欄は触らない (手入力を空で潰さない)。 窓も同じ規則 — 表にあれば contextLength を上書きし、無ければ触らず、通知の 2 文目で 入らなかったことを言う。pricingAsOf は単価の時点で、窓を入れても動かさない。 invariant: | ModelTemplate は秘密を保持しない。保持するのは credential (取得元) だけで、 実値は OS の資格情報ストア (secret::SecretStore) にのみ存在する。 平文の world.json へ秘密が入る経路は、型の段階で存在しない。 秘密がプロセス内を通る区間は LlmConfig::from_template から HTTP ヘッダまでで、 設定ファイル・イベント・エラーメッセージ・IPC 応答のいずれにも現れない。 history: | この欄は 2 度作り直している。 (1) 当初は String 型の apiKeyEnv で、防御は UI のラベルと注意書きだけだった。 運用初日に Anthropic の実キーが貼られ world.json に平文で保存された。 → 書式検査で入口を塞いだ (注意書きは制御ではない)。 (2) 環境変数名を要求する方式そのものが、デスクトップ GUI に不適合だった。 利用者に端末操作と再起動を要求し、Windows では設定済みの変数が起動済み プロセスへ伝播しないため「設定したのに効かない」が常態化した。 綴りを 1 文字誤っただけ (ANTROPIC_API_KEY) で実行時まで気づけなかった。 → 秘密を保持できるフィールドごと廃止し、OS の資格情報ストアへ移した。 (3) 2026-09-14、world.json の構造体が未知の欄を保持して書き戻すようになった (UnknownFields。failures.md #112)。**廃止した apiKeyEnv を「未知の欄」として 持つと、旧い world.json の秘密が保存のたびに書き戻され続ける**ので、 ModelTemplate.retired_api_key_env: () が読んで捨て、書き出さない (AgentRoleDefaults.retired_rag_sources も同じ形)。 **欄を撤去するときは消さずに retired_* へ置き換える** — 消すと次の読みで 未知の欄として拾われる。 教訓: 秘密の置き場は「書けないようにする」だけでは足りず、 「利用者が正しく置ける場所を用意する」ところまでが設計。 AgentMessage: fields: id: String # UUID v4 from: Endpoint to: Endpoint content: String tokens: u32 # 複数宛先へ配る場合、先頭の 1 件にのみ計上 tsMs: u64 hop: u8 # ユーザー入力を起点とした転送回数 coRecipients: AgentId[] # 同報の全宛先。単独宛では省かれる (broadcast_note) grounding: Grounding # 接地の来歴。接地しなければ省かれる (Spec 05 P4) invariant: | 1 回の生成を N 宛先へ配るとき tokens を全件に載せると N 重計上になる。 先頭のみに載せるのが契約。grounding も同じ理由で先頭の 1 件にのみ載せる (1 ターンぶんの事実であり、宛先ごとの属性ではない)。 grounding は**表示層専用**で、プロンプトを組む経路 (compose_room_log / push_exchange) は content しか読まない。 モデルへ戻さない理由は llm_wire.gemini.grounding と Spec 05 Notes 9 を見よ。 Grounding: fields: queries: String[] # モデルが実際に投げた検索語 sources: GroundingSource[] # 参照元。空 = 出典は存在しない engine: GroundingEngine # どの機構が接地したか。閉じた列挙 (Spec 31 D5) invariant: | sources が空であることは情報の欠落ではなく「出典は存在しない」という判定。 表示層はこの状態を無表示へ畳まない。畳むと利用者は本文中の URL を出典だと 信じるが、その URL は経路を持たないモデルが作ったものでありうる (2026-07-29 にドメインのルート URL + 記事の見出しの偽引用を実測)。 1 ターンは複数周に分かれるので、周ごとの記録は Grounding::absorb で 畳んで足し合わせる (検索語は文字列、参照元は URL で重複を潰す)。 GroundingSource: fields: { uri: String, title: String } # title は取れなければ空文字 GroundingEngine: values: [google, xai, open_ai, meta, perplexity] note: | 表示の「エンジン」ラベルはこの値から辞書で引く (grounding.engine の 「Google 検索」固定を Spec 31 P3 で置き換える)。自由文字列にしないのは 役職の色と同じ理由 — 開いた値は表示層に分岐を漏らす。 serde 既定は google: engine 欄の無い既存レコード (sessions.redb に保存済みの AgentMessage) はすべて Spec 05 の Google 検索由来なので、既定で正しく読める。 **open_ai (Spec 34) / meta (Spec 37) はこの列挙への追従が漏れていた** (canonical.rs には当時から実在。Spec 45 P0 で回収 — 新しい variant は 「その型を説明している場所」にも要る、の #51 の再演)。 perplexity は Spec 45 D5 — 出典は annotations ではなく *_results 系 output item から写す (search_results / people_search_results / fetch_url_results / finance_results の 4 種。finance だけ URL 裸の 文字列配列で title を持たない)。 ErrorPayload: fields: code: String # 安定した機械可読値。UI の分岐キー message: String detail: String | null # source 連鎖を平坦化した文字列 agentId: String | null retryable: bool codes: - AGENT_NOT_FOUND - DUPLICATE_AGENT - DUPLICATE_AGENT_NAME # 表示名の重複 (Spec 06。ID と違い構造で守られない) - MODEL_TEMPLATE_NOT_FOUND - SCHEDULE_NOT_FOUND # Spec 07 - INVALID_SCHEDULE # Spec 07。hour > 23 / minute > 59 / everyMinutes == 0 - SCHEDULE_STORE_BLOCKED # Spec 07。schedules.json が読めない間は書き込みを保護 - SESSION_NOT_FOUND # Spec 12。sessions.redb に無いセッションへの操作 - SESSION_STORE_FAILED # Spec 12。sessions.redb の開く/読む/書く/確定のどこかで失敗 - SESSION_SWITCH_BLOCKED # Spec 12。飛行中ターンがある間の resume/fork/delete(開いている方) - INVALID_TOKEN_BUDGET # Spec 13。set_token_budget の Some(0)。0 のマジック値を作らない - INVALID_TOPOLOGY - ALREADY_RUNNING - NOT_RUNNING - MAILBOX_FULL - CONFIG_IO - UNSAFE_IDENTIFIER - INVALID_ICON # 台帳追従漏れの回収 (2026-07-30。実装は Spec 06 以前から存在) - SECRET_STORE_FAILED - CREDENTIAL_MISSING - COMPUTE_FAILED - SERDE_FAILED - LLM_CONFIG - LLM_HTTP - LLM_API - LLM_EMPTY_RESPONSE # 出力上限で本文もツール呼び出しも成立しなかった (finish = length)。 # **再試行しない** (同じ入力なら同じ所で切れる) が、**エージェントも # 降ろさない** (次の依頼が小さければ通る)。この 2 つは別の問いで、 # CoreError::is_retryable と stops_the_agent に分かれている - LLM_OUTPUT_TRUNCATED - LLM_BLOCKED - LLM_NO_STRUCTURED_OUTPUT - LLM_PARSE - IPC_FAILED # TS 側のみ(Tauri ブリッジ自体の失敗) # RagChunk は **Spec 18 Phase 4 で型ごと撤回** (2026-08-05)。 # 旧同梱 RAG (rag.rs = HashEmbedder + in-memory 索引) の断片型で、取り込み # 導線が無く索引は常に空だった。見出し索引 (rag_tool_contract) が置き換え、 # ワイヤの写し (types.ts / commands.rs / ipc.ts) も同時に消えた。 # 凍結されたワイヤ型の撤回なのでここに痕跡を残す — 消すと次に読む人は # 「元から無かった」と読む (failures.md #56 の逆向き)。 TopologyEdge: fields: { source: AgentId, target: AgentId } # ------------------------------------------------------------------------- # Spec 07 の契約。**2026-07-31 に Phase 0 で凍結** (rev2 査読承認)。 # 実装は crates/fuseforks-core/src/schedule.rs (Phase 2 で着地)。発火の判定は # ScheduledTask::decide が返す Tick (Idle / Consume / Fire) 1 つに集まっている。 # 純関数はタイムゾーンを型引数 Tz で受ける — 時刻を引数で渡すだけでは実行機の # 設定にテストが依存したままで、DST のあるタイムゾーンも渡せない。 Weekday: values: [mon, tue, wed, thu, fri, sat, sun] Recurrence: kind: tag つき判別共用体 (serde tag, rename_all = snake_case) variants: | interval: { everyMinutes: u32 } # 1 以上。0 は読み込みで弾く daily: { hour: u8, minute: u8 } # 現地時刻 weekly: { weekday: Weekday, hour: u8, minute: u8 } # 現地時刻 note: | cron 式は採らない。0 17 * * 4 は表現力が高い代わりに読めない人には 一切読めず、UI も自由入力欄にしかならない。「設定が少なくて分かりやすい」 に従い、要望の 2 例 (毎週 X 曜 hh:mm / 定期的に) が言い切れる最小の構造。 後から cron 式を足すことはできるが、外すことはできない。 ScheduledTask: description: 時刻で発火する依頼 (Spec 07)。保存先は {workspace}/schedules.json fields: id: String # UUID v4 to: AgentId # 宛先。自分宛でもワーカー宛でも区別しない message: String # 届ける依頼の本文 recurrence: Recurrence createdAtMs: u64 # interval の起点 (lastConsumedDueMs が null のとき) lastConsumedDueMs: u64 | null # 消化した予定時刻。発火時刻ではない enabled: bool # 既定 true。偽なら発火も消化もしない # --- Spec 28 で加算 (2026-08-08)。3 欄とも default + skip_serializing_if。 # 既定値のままなら直列化しないので、probe 無しの既存予定は # 読みだけでなく**保存もバイト等価**で通る (Spec 28 S5)。 probe: ScheduleProbe | null # 既定 null = 前判定なし (従来の無条件発火) sessionMode: continue | fresh # 既定 continue(閉じた 2 値) summarizeAfter: bool # 既定 false # --- Spec 46 で加算 (2026-08-30)。default + skip_serializing_if で # 後判定なしの既存予定は読みも保存もバイト等価 (Spec 28 S5 と同じ規律)。 acceptance: Acceptance | null # 既定 null = 後判定なし (下の Acceptance が正) # --- Spec 53 で加算 (2026-09-15)。default + skip_serializing_if で既存予定は # 読みも保存もバイト等価。 autoApprovePlans: bool # 既定 false。真ならこの予定の因果 (発火と検収の再依頼) で # 計画の確認 (Spec 43) を開けない。plan_edit_window 凍結 11 が正 firing_rule: | 各 tick で「now 以前にある直近の予定時刻」= due を求めて判定する。 enabled == false → 何もしない (消化もしない) due が無い → 何もしない lastConsumedDueMs >= due → 既に消化済み **壁時計系**で now - due > GRACE → 発火せず消化済みにする それ以外 → 発火し lastConsumedDueMs = due due の算出: 壁時計系 (daily / weekly) = カレンダーから逆算 間隔系 (interval) = anchor + every * floor((now - anchor) / every) ただし now - anchor < every なら None anchor = lastConsumedDueMs ?? createdAtMs invariants: | - **lastConsumedDueMs は「消化した予定時刻」で、発火時刻ではない。** この区別が 規則の全体を支える — 飛ばした予定も消化済みにするので、同じ欄で 「発火した」と「発火せず飛ばした」の両方を表せる。発火時刻を持つと、 飛ばしたことを覚える場所が別に要る。**欄の名前に意味を運ばせる** — rev2 まで lastFiredMs だったが「fired」は飛ばした予定を書く欄として 嘘になるため、凍結前に改名した - **猶予 (GRACE = 5 分) は壁時計系にだけ適用する。** 間隔の予定に 「間違った時刻」は存在しないので猶予を掛ける理由が無い。全種別に掛けると、 2 日止まった 10 分ごとの予定が毎 tick 消化されるだけで一度も発火しない (Spec 07 rev1 のバグ)。かつ everyMinutes > GRACE の予定は due を最遅点に 直しても依然 skip される — 修正は「最遅点」と「猶予の非適用」の 2 つが揃って初めて成立する - **壁時計系は取りこぼしを追いかけない。** 17 時の鐘を 23 時に鳴らすのは 「鳴らなかった」より悪い - **間隔系は再開時に 1 回だけ走る** (溜まった回数ぶんは撒かない)。監視は 「今の状態」を見る仕事なので、遅れて 1 回見るのは正しく 288 回は無意味 - 発火後は lastConsumedDueMs = due (now ではない)。刻みの位相が保たれ、 now - due < every が構成上保証されるので連続発火しない - **現地時刻の変換を unwrap しない。** Local.from_local_datetime() は DST で None / Ambiguous を返す。どちらもその回は発火しない扱い。日本に DST は 無いが、panic する経路を他のタイムゾーンの利用者に残さない (LocalResult::single() が両方を None に畳む。America/New_York の 春 02:30 と秋 01:30 で実際にテストしている = chrono-tz は dev 依存) - 発火の配送は from = System / to = Agent / **hop 0**。予定発火はユーザー 発話と同格の起点なので、そこから先の転送・委譲に満額の燃料を渡す (System から Agent への配送はこれが最初。Spec 06 は User 宛だけ) - **本文の先頭に由来を書く**: 「【定期実行: 毎週 木曜 17:00】」+ 改行 + 本文。 封筒 (【送り手: Fuseforks】) だけではモデルが人の発話と区別できない。 AgentMessage に欄を足さず本文へ書くのは、契約を変えずに済み、 会話ペインにも出るので利用者側の可読性も上がるから - **宛先が Running でなければ撒かない** (消化して飛ばす)。停止中へ投げると NotRunning が毎 tick 積まれるだけ。消化するのでログも 1 回だけになる - **飛ばした理由で行き先を分ける**: 停止中で飛ばしたら会話ログへ 1 行、 猶予超過で飛ばしたら debug ログのみ。停止中は利用者が直せる状態 (起動すれば動く) だが、猶予超過は「閉じていた」の事後報告で直す手が無く、 毎朝 9 時の予定が数日分まとめて会話ログへ流れると本物の通知が埋まる - 保存は **tmp + rename** (save_world と同じ)。電源断で全予定を失わない - **所有者は in-memory (Shared.schedules) で、ファイルは常にその投影。** 書き手が ticker (消化) と UI (追加・削除) の 2 系統あるため、ファイルを 読み戻して書く形にすると片方の変更がもう片方に潰される。保存は 書き込みロックを持ったまま行い、2 つの保存が tmp ファイルを踏み合わない - **schedules.json 全体が JSON として読めない時は、起動は続けるが書き込みを 拒否する** (SCHEDULE_STORE_BLOCKED)。読めなかったファイルへ上書きすると、 利用者が直せば戻ったはずの予定を消すことになる。起動を止めないのは mcp.json と同じ判断 (止めると設定を直す画面へ到達できない) - 読み込みで **1 件単位に検証**して落とす: hour > 23 / minute > 59 / everyMinutes == 0 / 宛先が存在しない。壊れた 1 件で他を人質にしない (mcp.json は逆の判断だが、あちらは「空として扱うと全ツールが黙って 消える」ため全体をエラーにしている。予定は 1 件ずつ独立している) - エージェント削除時、その宛先の予定も消す (remove_agent が他からの参照を 外すのと同じ規律) - **二重発火は完全には防がない。** スケジューラ層の HashSet と AgentTyping(active=false) で軽く抑えるが、イベント取りこぼし時は fail open (集合を空にする) — 塞がったままにすると予定が二度と発火しない 静かな停止になり、稀な二重発火より悪い。 **集合の粒度が AgentId なのは、外す契機に使える信号 (AgentTyping) が エージェント単位でしか来ないから。** TaskId 単位にすると、同じ宛先の 予定が 2 件あるときどちらを外すか決められず集合が塞がる - **多重起動を排他する** (tauri-plugin-single-instance)。同じワークスペースを 掴んだプロセスが 2 つあると、同じ予定が両方で発火し lastConsumedDueMs を 競って書く。**予定より先に入れる** known_limits: | アプリが起動していない間は動かない (デスクトップアプリでありデーモンでは ない)。**この限界は機能の中に書く** — 書かずに「毎週木曜 17 時」と名乗るのは Spec 05 で潰したのと同じ形の嘘。 製品の行き先はタスクトレイ常駐なので限界の大部分は将来消えるが、発火規則 (取りこぼしを追いかけない / 間隔は 1 回だけ) は常駐後も正しいまま。 書き換えが要るのは告知の文言だけ。 参照実装 OpenClaw (2026-07-31 に実物確認) も常駐を OS の service manager (launchd / systemd / Windows Scheduled Task) に任せ、**予定の発火そのものは OS へ出していない**。この形は本 Spec の判断と一致する。 **棄却するのは「予定 1 件ずつを OS へ登録する」案**で、「常駐プロセスを OS に起こさせる」のは別の層の話 (トレイ常駐の手段として生きている)。 境界線 (2026-07-31 確定): **常駐プロセスを起こすのは OS でよい / 予定を 保持し・発火させ・見せるのはアプリ**。「OS に時刻を任せアプリはチャネルの 口だけ担う」案を検討して棄却した理由は 3 つ — (1) 一覧と削除が要望に 入っており、OS 側に置くと UI が**自分の所有しない外部状態の投影**になる (しかも 3 OS 分。投影の同期は failures.md #5 / #19 で踏んだ罠)、 (2) 宛先が AgentId でOS に存在しない概念なので、時刻は OS・宛先と本文は アプリと**真実が 2 箇所に割れる**、(3) 発火規則が製品固有で、OS は自前の ポリシー (Windows の「開始時刻を過ぎたらできるだけ早く実行」) を持つため **委ねると決めた規則が OS の設定で上書きされうる**。 また無人運用では会話ペインを誰も読まないため、**人へ届く出力チャネル**が 構造的に要る (OpenClaw の答えは 25 以上のメッセージチャネル)。 要望の「鐘」はその最小の実例。順序は デスクトップ通知 → 1 チャネル で、 25 チャネル相当の抽象は作らない (別の製品の形)。 # ------------------------------------------------------------------------- # Spec 28 の契約。**2026-08-08 に P0 で凍結** (rev3 承認)。 # 定期発火の前に「LLM を通さない判定」を挟み、変化があったときだけ課金する。 # 実装は crates/fuseforks-core/src/schedule_probe.rs (純機構) + schedule_tick の配線。 ScheduleProbe: description: | 予定の前判定 (Spec 28)。時刻が来たらこのコマンドを実行し、stdout の 合図が expect と一致したときだけチャットを配送する。**LLM を通さない** — 5 分ごとの監視で「変化なし」が 287 回続いても、払うトークンはゼロ。 fields: command: String # 実行ファイル名またはパス。空・空白のみは読み込みで弾く args: [String] # 引数の配列。**順序は意味を持つ** expect: String # stdout 1 行目 (トリム後) との完全一致。空は読み込みで弾く timeoutSecs: u64 # 既定 60・上限 3600 (run.json と同じ値域) cwd: String | null # 絶対パス。既定 null = {workspace}。検査は実行時のみ judgement: | **判定は stdout の 1 行目のみ。exit code は判定に使わない** (計器には載せる)。 行区切りは \n。行末直前の \r は落とす (\r\n を同じに扱う) 1 行目 = 最初の改行まで。先頭が空行なら 1 行目は空文字 トリムは 1 行目のみ。**2 行目以降は原文保持** (JSON のインデントを壊さない) 比較は完全一致。文字数は chars().count() **exit code を判定から外したのは、監視の慣習が異常を非 0 で表すから。** `echo CHANGED; exit 1` が発火しないと Spec 28 S1 の主戦場で永遠に沈黙する。 「壊れたスクリプトの偶然の出力で課金しない」は expect の完全一致が既に 守っている (クラッシュ出力が合図と一字一句同じになる壊れ方は実在しない)。 **allowNonZero のような 2 つ目の軸は作らない** — 判定軸が 2 本あると 「exit と stdout のどちらが効いたか」を利用者が推理する羽目になる。 output_limits: | stdout の取り込みは **65,536 字**で打ち切る。 打ち切りが **1 行目の中** (改行が一度も出ていない) → outcome=error (reason=signal_truncated)。切り詰めた 1 行目で照合すると誤判定するので、 判定そのものを不成立にする 打ち切りが 2 行目以降 → 判定は成立し、付記側だけが切り詰められる **付記の本文は 4,000 字** (run.rs の STDERR_CHARS と同じ桁)。超過は切って 「(全 N 字)」を添える。**N は切る前の元の字数**で、注記そのものは 4,000 字の **外**。取り込み上限で既に切れていたら真の総数は分からないので 「(全 65,536 字以上)」— **知らない総数を確定値として書かない**。 invariants: | - **probe は Tick::Fire の後段・配送の直前。発火規則 (decide) は不変。** 判定列は上から: 宛先停止中 (消化 + System 行) → 宛先飛行中 (スキップ) → **同じ予定の probe が走行中 (スキップ)** → 承認が無い (unapproved・消化) → 実行 (error/timeout・no_match は消化 / match は配送候補へ) - **1 つの due に判定は 1 回。** 不一致・失敗・未承認も lastConsumedDueMs を 書く。書かないと同じ due を毎 tick 再判定し、daily 17:00 の予定が 一致するまで連続ポーリングに化ける。**消化しないのはスキップの 2 段だけ** - **1 予定につき probe は同時 1 本。** timeoutSecs 上限 3600 × 間隔 5 分では probe が due をまたぐ。走行中の Fire は消化せずスキップし、壁時計系は 待つうちに猶予を超えれば Consume へ倒れる (宛先飛行中と同じ倒し方)。 **プロセスが積み上がる経路を残さない** - **probe が走るのは宛先が稼働中のときだけ** (判定列の順序の帰結)。 誰も起動していない村ではコマンドも走らない。ただし**これは配布の防御では ない** — 起動ボタンは村を使うための操作でコマンド実行への同意ではない。 配布の防御は下の approval が担う - **シェル非経由** (command + args 配列)。ここを緩めると Spec 15 が 3 回の査読で守った境界 (パースで守る境界は必ず抜けられる) が横から破れる - **run.json の allow/deny は通さない。** あの囲いは**モデルが書くコマンド**の ためのもので、probe は人が書く欄。**囲いは端末承認 (approval)**。 ここを「親切心で run.json も通す」と二重の囲いになり、GUI で書いた コマンドが自分の村で拒否される - **PATH 解決は実行時。解決したフルパスを毎回ログへ出す** (Spec 15 の 宿題 (a) と同じ処方 — 「構造的に決まる」と言わずに何が走ったかを読める形に) - command / expect の空は**読み込みで弾く** (InvalidRecurrence と同じ棚)。 弾かないと「静かに no_match で消化され続ける予定」= 会話にもログにも 出ない静かな死になる。cwd は**実行時検査** (存在しない・ディレクトリで ないなら outcome=error / reason=cwd_missing) — 読み込みで弾くと村を 配った先でスケジュール全体が壊れる approval: | **probe の実行には端末側の承認が要る** (Spec 28 D10)。 置き場は **{app_data_dir}/probe_approvals.json** (workspace の**外**)。 形は {"hashes": ["<64 桁 hex>", …]} の 1 欄のみ。 鍵 = **SHA-256(canonical_json({args, command, cwd, villageId}))** キーは辞書順・UTF-8・空白なし args の順序は保持。cwd は**書かれた文字列のまま** (null は null。 実行時の解決値を入れない — 環境で動く値を鍵に入れると同じ設定が 機会ごとに別の鍵になる) cwd: null と cwd: "" は別の鍵 (潰すと「未指定」の意味が消える) **保存するのはハッシュだけ。**コマンド行の原文は書かない — 承認ファイルを 「実行可能なコマンドのインベントリ」にしない。承認ダイアログが表示する 原文は schedules.json から読む。 **束縛の実体は村の識別子 {workspace}/village_id** であってパスではない。 **パスを salt にしても効かない** — workspace は {app_data_dir}/workspace 固定 (state.rs) で、村の入れ替えはパスの変更ではなく**同じパスの中身の 差し替え**。同一機では村 A も村 B も常に同じ絶対パスに住む。 **承認を書くのは GUI の保存 IPC だけ** (人のクリック起点)。モデルが schedules.json を file write で書いても承認は付かない (モデルは IPC を 呼べない)。**書き込み関数は tauri::command の層に閉じ、pub(crate) で fuseforks-core 側へ広げない** — 「呼ばない約束」ではなく「呼べない構造」。 保存順序は **承認 → schedules.json**。逆順だと schedules だけ書けて 正当な probe が unapproved で due を失う。承認だけ書けた残骸は 何も参照しない鍵で無害。 **掃除を通す IPC は create_schedule / update_schedule / delete_schedule** (総数ではなく列挙 — 増えたら行を足すことが更新になる。failures.md #67)。 update_schedule (2026-08-30 の編集機能) の順序は create と同じ **承認 → schedules → 掃除** — 編集も「書いた人 = 承認した人」で、 差し替えで参照されなくなった旧コマンドの承認は掃除が落とす。 現在の schedules.json のどの probe にも一致しない承認を落とす。 **set_schedule_enabled は通さない** — 一時停止は「いまは動かさない」で あって取り消しではなく、再開のたびに押し直させると D10 の目的を超える。 **delete_schedule の順序は schedules → 承認** (create と逆)。 create は「承認だけ書けた残骸は無害」なので承認が先だが、削除は **予定だけ消えて承認が残るほうが危険**なので予定を先に消す。 **この掃除は肥大化対策であると同時に、承認を取り消す唯一の経路** (破棄の導線がもう 1 つ = ファイルごと削除)。**取り消しの側が本体** — 予定を消したのに承認が端末に残ると、GUI を通らない経路 (配られた村・ 手書きの schedules.json) で同じコマンド行が入ったとき人が押していないのに 走る。初版は create からしか呼んでおらず、契約と doc の 「保存のたび」「予定を消せば承認も消える」が実装と食い違っていた (failures.md #88)。配線は tests/probe_approval_pruning.rs が **commands.rs を走査して**留める (retain_for の単体テストは 呼ばれていることを保証しない)。 **時計ベースの GC は作らない** (「30 日未参照」は判定に壁時計を持ち込み、 テストが時刻に依存する)。 **読めない probe_approvals.json は空として扱い (全 probe が unapproved = 安全側)、既定を書き戻さない** (failures.md #70 — 読みの安全側と書きの 安全側は別。書き戻すと壊れる前の承認の記録を捨てる)。 権限: **Unix は 0600 を明示。Windows は chmod 相当が無いので偽らない** ({app_data_dir} が利用者プロファイル配下で OS の既定 ACL が利用者に 閉じている、が実態。中身がハッシュだけなのはこの前提の保険でもある)。 **残余 (保証と書かない)**: 自分の村を公開すると village_id も配られる。 id を知る攻撃者は同じ id + 同じコマンド行 + 悪性スクリプトの村を作れる。 潰しているのは「無差別に配った村が承認を素通りする」主要形で、 **公開した村の持ち主を狙う標的攻撃までは防がない**。破棄の導線は probe_approvals.json の削除 (全承認が外れる)。 delivery: | 一致したら **stdout の 2 行目以降を依頼文へ付記**する。付記しないと サーヴァントが同じ情報を取りに行く周回が発生し、トークン節約と逆行する。 【定期実行: {ラベル}】 {message} (空行) 【前判定の出力】 {2 行目以降・原文保持} **sessionMode**: fresh は配送前に create_session。 **create_session は ensure_idle_for_switch を通らない** (検査があるのは 既存会話を開く側) ので、**他個体**が飛行中でも新規チャットは通る (着地先が新しい空の会話なので既存の記録を汚さない。Spec 12 P2 の裁定)。 **宛先自身**の飛行中は判定列の 2 段目で既にスキップ済み — この 2 つの 「飛行中」を書き分けないと矛盾に読める。 **同一 tick に複数発火したときの順序** (※**前判定なしの予定だけ**。 条件は下の「経路が 2 つある」を読むこと): 判定を全件済ませて配送候補を 集める → 候補に fresh が 1 件でもあれば create_session を **1 回** → 全候補を一括配送 → 消化 → 保存。 fresh が複数あってもセッションは 1 つしか作らない (N 個作っても空の セッションが挟まるだけ)。**continue の意味は「切り替えを起こさない」で あって「旧セッションを指名する」ではない** — 同一 tick に fresh と 混在した continue の配送は新しいセッションに積まれる (セッションが村全体の 単位である以上、同時刻の発火が同じセッションに載るのが一貫した挙動で、 途中で画面が 2 回切り替わる形より予測できる)。 **経路が 2 つある** (P2a の実装で確定。起票時は 1 本のつもりだった): schedule_tick は**ティッカーの select! の中で await されている**ので、 前判定をそこで待つと timeoutSecs (上限 3600 秒) のあいだティッカーごと 止まり、同じループが担う AgentTyping の排出まで止まる (in_flight が 掃除されず受信が lag して fail open を誘発する)。**1 件の監視スクリプトが 村中の予定を止める形**なので、前判定つきは切り離したタスクへ出す。 | | 前判定なし | 前判定あり | | 実行 | tick の中 | 切り離したタスク | | 消化 | 配送できたときだけ | 決めた時点で (どの結末でも消化するので待つ理由が無い) | | 配送 | 同一 tick で束ねる (上の順序) | 結末が出た順に独立して配送 | **上の「1 セッション」は前判定なしの側でだけ成立する** — 非同期に散る 結末を「同じ tick」として束ねることはできない。 セッションは村全体の単位なので、fresh の発火は人が見ている画面も 切り替える。**UI の説明文に書く** (知らずに失う形にしない)。 summarize_after: | **対象 = 発火の宛先 (根) + その因果の中で ask / plan (deliver_and_wait) を 通って応答を返し終えた個体。handoff で渡した先は対象外。** **定義を「予算プールを受け取った個体」に置かない** — handoff も同一 Arc を 継承する (tests/orchestrator.rs の a_handoff_inherits_the_same_budget_pool が 凍結) ので実装としては動くが、**仕様が実装の凍結テストに寄りかかる形**に なり、テストの改訂が仕様を黙って変える。挙動 (待って完了した) で定義すれば 予算実装がどう変わっても対象は動かない。 handoff 先を外す根拠は**待っていないので「終わった」を観測する点が無い**こと。 根の完了時点でまだ走っている個体を要約すると、飛行中ターンの畳み込みという 別の問題を持ち込む。 実行時点は**根のターン完了後**。完了検知は配送を deliver_and_wait で包んで 待つ分離タスク (schedule_tick 本体は待たない — 待つと予定ループ全体が 1 件の発火に塞がれる)。 **これは summarize_session の doc「自動では要約しない」を明示的に覆す** (Spec 28 D7)。覆したと分かるように doc にも書く (failures.md #56)。 覆すこと自体は正当 — あの規律の本体は「勝手に仕事を増やさない」で、 予定ごとのオプトインは**人が増やすと決めた仕事**。 実装は summarize_session を対象指定版 (summarize_agents(ids)) へ割り、 既存の全体版はそれを全稼働個体で呼ぶ形に畳む (規律を 2 箇所に書かない)。 instrumentation: | schedule probe: id=… outcome=match|no_match|error|timeout|unapproved exit=0|N|- reason=spawn_failed|not_found|cwd_missing|signal_truncated|- resolved=… stdout_chars=N **probe 実行 (と未承認スキップ) のたびに 1 行** (run decision: / grep include: と同じ形)。**stdout の中身は出さない** — 字数と閉じた列挙だけ (failures.md #71: 計器は秘密の転送経路になる。スクリプトの出力に何が入るかを 機構は決めつけられない)。 **reason が値を持つのは outcome=error のときだけ** — timeout は outcome 側で 完結する。1 つの事実を 2 つの欄で言わない。 **不一致・失敗は会話ログへ流さない** (猶予超過と同じ判断 — 5 分ごとの監視で 毎回 System 行が出ると本物の通知が埋まる)。画面はスケジュールダイアログに 最後の判定 (outcome・reason・時刻) を出す (in-memory・波の記録と同じ寿命)。 **再起動後の診断は fuseforks.log が担う。probe_last.json のような第 2 の 永続ファイルは作らない** — reason 付きの行が既に永続しており複製になる。 機械が書く永続ファイルを増やすことは、人が編集するファイルとの衝突面を 増やすことでもある。 画面へ運ぶ形は **ProbeReport { outcome, reason, atMs }** (P3 で追加)。 保持は ScheduleRuntime.last_probe (予定 ID ごとに直近 1 件・プロセス寿命)、 読み口は Orchestrator::probe_reports、ワイヤは ScheduleView.lastProbe (まだ 1 度も走っていなければ null)。 **reason が値を持つのは outcome == "error" のときだけ** — 他は "-" が 入るので、そのまま画面へ出すと "-" が並ぶ。フロントは純関数 lib/scheduleProbe.ts の probeDisplay で畳み、**辞書の鍵**を返す (訳語をテンプレートで組まない = 表示規則をテストの外へ出さない)。 **「走っていない」と「結末はあるが理由が無い」を型で分ける** — 前者は probeDisplay が null を返す。 observed: | **実機観測 (2026-08-08。Windows / concordia.log)**。設計判断が実測で 裏付けられたものだけを残す: 07:56:47.933 outcome=match exit=0 stdout_chars=8 → 19 ms 後に turn start: chars=25 07:59:17.966 / 08:00:18.069 outcome=no_match → 間に turn start: が 1 本も無い 08:06:47.952 outcome=error exit=- reason=not_found resolved=- stdout_chars=0 08:11:48.148 outcome=match exit=1 stdout_chars=9 → 10 ms 後に turn start: 08:11:52.490 schedule summarize: root=agent_2 agents=1 (村は 9 体稼働中) - **CRLF の除去が主経路だった。** stdout_chars=8 に対し expect='GoMolt' は 6 字 = Python の print が出した GoMolt\r\n。**\r を落とさなければ Windows では永久に一致しない。** 単体テストの 1 本だったものが実機の主経路 - **exit 非 0 でも発火する** (exit=1 で outcome=match)。rev1 の 「exit 非 0 は判定不能 = 不発火」を撤回した判断が、監視の主戦場で効いた - **不一致でも消化している。** 1 分ごとの予定の no_match が **61 秒間隔**で 出た = due が溜まっていない (消化していなければ連射される) - **承認鍵に expect を入れない判断が働いた。** 同じ python dummy.py を使う 予定 2 件 (合図は GoMolt / GoMoooo) に対し probe_approvals.json の鍵は **1 件だけ**で、両方走った - **summarizeAfter の対象が根 1 体だけ。** 9 体稼働中で agents=1 — 参加者集合を封筒で運んだ判断 (予算プールに相乗りさせない) の負の対照 - **reason の閉じた列挙が診断を解いた。** 利用者が echo CHANGED を置いて not_found を踏み、**その 1 語から真因 (Windows の echo は cmd.exe の 組み込みで単体の実行ファイルが無い) に到達した**。spawn_failed や cwd_missing なら全く違う場所を探していた **躓きの形 (シェル非経由の帰結)**: シェルの組み込み (echo / dir) は 指定できず、`;` / `&&` / `|` / `$(...)` も効かない (`;exit 1` は echo への 引数の文字列になるだけ)。Windows で確実なのは python + -c + 1 行のコード。 **UI / README への注記は入れていない** — 躓きが 1 回なので頻度を見てから 決める (#47 で L1 / L2 を作らなかったのと同じ判断)。 **fresh の切り替えはログに 1 行も出ない** (open_session は note! を持たず、 session: の行は起動時だけ)。画面では分かるので実害は無いが、 **ログだけでは手動の新規チャットと区別が付かない**。 **承認の実機観測 (2026-08-08 16:41〜17:01。検収 4 / 4')**。手で置いた schedules.json 1 本が、**コマンド行を 1 文字も変えないまま 3 回状態を変えた**: 16:45:32 / 16:46:32 outcome=unapproved exit=- reason=- resolved=- stdout_chars=0 16:47:37〜16:53:13 outcome=no_match exit=0 resolved=…\Python313\python.exe stdout_chars=13 16:56:10〜16:59:10 outcome=unapproved (village_id だけ差し替え) 17:00:57 / 17:01:27 outcome=no_match (village_id を戻した) - **走っていないことはマーカーで見る。** probe 行の存在だけでは 「実行してから結果を捨てた」実装と区別が付かない (rev1 査読)。 **コマンド本人にファイルを 1 行書かせる**と、無いことが走っていないこと そのものになる。resolved=- も同じ向き (実行ファイルの解決すらしていない) - **4' で probe_approvals.json は 1 バイトも変わらない。** 旧 salt の鍵は 残ったまま、新 salt の鍵が無いだけ。**承認は消えていない・届かなくなった** — 差し替え防御が「記録の消去」ではなく「鍵の付け替え」で成立している - **再起動だけでは失効しない**対照を同じ走行で取ってある (16:51 の再起動後も no_match が継続)。これが無いと 4' を「再起動したから」と読める - **stdout_chars は \r を落とす前の生の長さ** (PROBE28-RAN\r\n = 13) - **承認鍵は外から再計算して一致した** (canonical 形の裏取り) - **前判定は宛先が稼働していないと到達しない。** Tick::Fire は probe より先に mailboxes を見て、停止中は shared.record (会話ペイン) で消化して continue — **note! ではないので fuseforks.log は完全に沈黙し、lastConsumedDueMs だけが 動く**。ログの沈黙には理由が 2 つある (未承認 / 未到達) # Spec 46 P0 で新設 (2026-08-30)。予定の後判定 — 検収の probe と、通るまでの再依頼。 # 出自は Prime Agent (arXiv:2608.23552) の autonomous mode (予算 + 決定論テスト + # 通るまで続行)。村は部品を既に持っており (tokenBudget / スケジュール / probe)、 # ここが足すのは「通るまで続ける」の輪だけ。 Acceptance: description: | 予定の後判定 (Spec 46)。発火した依頼の因果が完了したら検収コマンドを 実行し、stdout が expect と一致しなければ**同じ依頼を失敗の内容つきで 出し直す** (上限つき)。判定は決定論 (probe = 完全一致) なので、 トークンゼロで判定でき、同じ成果物に同じ判定が返る — 「LLM 判定は最後、機械で決まる軸を先に」(評価基盤の裁定) の実行形。 fields: # 判定部は ScheduleProbe を serde(flatten) で再利用。 # command / args / expect / timeoutSecs / cwd — 承認・判定・出力上限の # 規律はすべて ScheduleProbe の節が正で、ここに複製しない。 maxAttempts: u8 # 再依頼を含めた総試行回数。既定 2・範囲 1..=5 invariants: | - **後判定の判定部は ScheduleProbe の再利用** — 検収専用の判定型を 作らない。Acceptance は判定部 + maxAttempts の薄い封筒 (maxAttempts が中に住むのは、acceptance: null のとき意味を持たない欄を 構造で締め出すため — 送れる形が無いほうが強い) - **再依頼を生むのは no_match だけ — かつ試行と予算の両方が残っているとき。** 分岐の表 (rev2 D3): match → 記録して確定 no_match ∧ attempt < maxAttempts ∧ 予算残 > 0 → 再依頼 no_match ∧ (試行上限 ∨ 予算残ゼロ) → 記録して確定 error / timeout / unapproved → 記録して確定。**再依頼しない** 判定不能 3 値が宛先を持たないのは fail-closed — no_match 側へ倒すと壊れた検収コマンドが毎発火で再依頼の輪を回し、 match 側へ倒すと検証が壊れたときだけ検証をすり抜ける (eigent の採点器が 3 回失敗で quality_score=80 を捏造した反面教師) - **再依頼は新しい封筒・同じ財布。** cancel token と participants 集合は 試行ごとに新品 (完了検知と打ち切りが試行単位で独立)、 **Envelope.budget には発火時の Arc をそのまま積む** (new_root_budget を呼ばない)。機構は封筒相乗りの既存 3 前例 (cancel / budget / participants) と同じで、配送のルーティングに フラグは要らない。tokenBudget が「通るまで続ける」の天井になる — 新品にすると毒タスクが 発火 × 試行 × 天井 で漏れる - **範囲はスケジュールのみ。** 配送 5 経路 (人の発話 / ask_* / transfer_to_* / plan の波 / スケジュール) の他 4 つに掛けない (掛けるなら別 Spec) - **再依頼のセッションは continue 固定** (予定の sessionMode は初回だけ)。 前の試行で何をしたかが見えないと同じ失敗を繰り返す — fresh で再依頼すると RepeatGuard に相当する記憶が会話ごと消える - **前判定は発火時に 1 回だけ評価する。再依頼は前判定を通らない** (後判定ループは封筒を直接組むので構造で成立する。通すと、検収の輪の 途中で環境が変わったとき「配送されないまま試行だけ数える」形が生まれる) - **expect は承認の対象外** (承認ハッシュは {args, command, cwd, villageId} で expect を含まない — 前判定も元から同じ性質)。承認の対象は 「何が実行されるか」であって「何を合格とするか」ではない。 expect の変更は新しい実行を 1 つも生まない execution_order: | 発火 → 配送 → 因果の参加者が全員完了 (participants + AgentTyping — summarizeAfter と**同じ検知の 1 実装**) → 後判定 → 分岐。 **完了イベントの受け手は 1 本で、後判定 → (確定したら) 要約の直列。** 並行に発火させると試行ごとに要約が走って課金が試行回数ぶん増える — 要約の発火は後判定ループが確定 (match / 試行上限 / 判定不能 / 予算枯渇) するまで抑止し、確定後に 1 回。要約は確定の理由を問わず走る (summarizeAfter の既存の意味論を変えない)。 **完了検知の番犬は作らない** — 検知は summarizeAfter と 1 実装共有で、 ターンの終了は構造で有界 (周回 36 / 予算 / ask_timeout / probe の timeoutSecs)。2 つ目の検知は片方だけが取りこぼす (Spec 28 P2b の判断)。 redelivery: | 再依頼の本文 = **初回の依頼文** + **最新の失敗注記 1 個だけ** (累積しない): {初回の依頼文} (空行) (前回の検収が不一致でした。検収コマンドの出力: {抜粋}) ベースは常に初回のオリジナル依頼文 — maxAttempts >= 3 で注記が入れ子に なる形を作らない。経緯は continue セッションの履歴の側に全部ある (1 通は自己完結、経緯は履歴、と置き場を分ける)。 **抜粋は stdout の先頭 500 文字** (文字数であってバイトではない — MAX_*_CHARS をバイト長で数える差分は日本語では枠の 1/3 で発火する)。 改行は保持。**stdout がトリム後に空なら stderr の先頭 500 文字**へ落とす (検収コマンドが stderr にだけ理由を書いたとき、モデルへ届く診断を 空にしない。capture 層の process::Ran::Finished は stderr を既に持つ)。 **判定は stdout のみで変えない** (非ゼロ exit は error ではない — 起動失敗だけが error。ScheduleProbe の judgement の規律のまま)。 前判定の「2 行目以降を依頼文へ付記」は**前判定だけの規則** — 後判定の 2 行目以降は判定に使わず、この抜粋の材料になる。 instrumentation: | acceptance: schedule=… attempt=1/2 outcome=match|no_match|error|timeout|unapproved **試行のたびに 1 行。outcome の語彙は schedule probe: 行と同一** (書式も判定も 1 実装を再利用するので、パーサが 2 つの語彙を持たない)。 完遂の軸の 3 本目 — reply: to= / refusal= に続き、これだけが 「成果物の検証」を機械で直接言う列。 画面はスケジュールダイアログに直近の結末 (lastProbe と同じ器・ プロセス寿命・schedules.json へ書かない)。 # Spec 28 P0 で新設 (2026-08-08)。承認の束縛先として立てた識別子だが、 # 用途を承認に限定しない形で 1 つの棚にしてある。 village_identity: | **{workspace}/village_id** — その村の識別子。UUID v4 を ASCII 小文字・ ハイフン付きで 1 行。**村の内容物**なので配布に載る (world.json と同じ棚)。 生成は**起動時の初期化** (state.rs)。**schedule_tick の開始より前** — 発火が id 未確定の窓を持たないため。既存の村は初回起動で生成される。 読み込みは trim() して `^[0-9a-f-]{36}$` を検査し、**不正なら再生成** (書式検査に落ちた id で承認を組むと、その村の承認が永遠に組めない)。 **用途は「同じ村か」の判定だけ。** 秘密ではない (配布に載る) が、 **攻撃者が被害者の値を知り得ない**ことに意味がある — 差し替えた村は 別の id (または無し → 受け手側で新規生成) を運ぶので、承認が構造的に外れる。 **人に見せる名前ではない。** 表示名・村の説明として使わない (使い始めると「読みやすい id」への要望が生まれ、乱数である性質が壊れる)。 # ------------------------------------------------------------------------- # Spec 08 の契約。**2026-07-30 に Phase 0 で凍結** (rev2 査読承認)。 # 波ペイン (plan 実行の可視化、Airflow Grid 相当)。実装は Phase 1〜3 で着地。 # GUI 表示名は 2026-08-02 から「作業状況」(黒板とタブ切り替え。契約は不変)。 # 描画は読み取り専用 — ここから plan を操作する経路は作らない。 PlanTaskState: values: [running, answered, handed_off, undeliverable, no_answer, timed_out, interrupted, budget_exhausted] note: | plan の 1 タスクの解決分類。**文言 parse では取らない** (文言を直した瞬間に 黙って壊れる)。取得の構造は 2 つ: (1) deliver_and_wait の返り値を String → (String, PlanTaskState) へ。 ask 側は分類を捨てるだけ (配送・失敗の文言を ask と共有して境界の ずれを構造で防ぐ、Spec 04 Phase 1 の判断の延長) (2) oneshot チャネルの積み荷を String → Reply { text, kind } へ (下記) セル色の対応 (UI 契約。文言が将来変わっても色はこの表に追従する): running → 進行アニメーション answered → 正常色 handed_off → 注意色 (答えではなく「転送した」という事実が返った) undeliverable / no_answer / timed_out → 失敗色 interrupted → 注意色 (Spec 10。人が止めさせた = 失敗ではない。 turn_interrupt の不変条件 4 とここの色は同じ判断を指す) budget_exhausted → 失敗色 (Spec 11。天井が止めた = 資源の事実。 人の打ち切り interrupted の注意色と混ぜない) interrupted は **2026-08-01 Spec 10 Phase 0 で追加** (rev2 査読承認)。 budget_exhausted は **2026-08-02 Spec 11 P0 で追加** (rev2 LGTM)。 どちらも加算的変更 — 旧レコードのマイグレーション不要、刻み手は Spec 11 P2 の周回境界の検査点 (token_budget.precedence の順)。 加算的変更 — 旧レコードのマイグレーションは不要で、既存の finish_wave が 旧 running を no_answer へ倒す後始末も変わらず生きる (Phase 4 で回帰固定)。 刻み手は 3 箇所に限る: 飛行中の中断 (handle_message の検知点) / 未着手封筒の畳み (agent_loop のターン開始直後) / run_plan の finish_wave (割り込みで波を閉じるときの running の倒し先) Reply: fields: { text: String, kind: PlanTaskState } note: | ask / plan の返信 oneshot チャネルの積み荷 (旧: 素の String)。 kind は handle_message の Finish / Handoff 分岐が刻む — 転送 (handed_off) は文字列としては普通の答えと同じ経路で返るため、型で刻まないと 区別できない。ワイヤには出ない内部型だが Phase 0 で凍結するのは、 分類の刻み手を handle_message の 1 箇所に固定するため。 PlanWaveRecord: description: plan 1 波の実行記録 (Spec 08)。所有者は in-memory、ファイルへ書かない fields: planId: u64 # プロセス内で単調増加。1 始まり・0 は予約 agentId: AgentId # 進行役 wave: u32 # ターン内連番 (stderr の wave= と同じ値のまま) state: pending | dispatched | discarded # 波レベル状態 (Spec 43)。serde default = dispatched — 旧クライアント・編集窓 OFF の経路は従来の意味のまま (加算的変更) startedAtMs: u64 # epoch ms (壁時計) tasks: # 入力順 (束ねと同じ。解決順ではない) to: AgentId # 波内で一意 (同一宛先の重複 = 静的な不正) state: PlanTaskState # 波が pending の間は未配送の置き場 (running のまま。セルの意味は波の state が決め、dispatch がタスクを最終形で置き換える) elapsedMs: u64 | null # 配送→そのタスクの解決。相手のキュー待ちを含む msgChars: u32 message: String? # **pending の間だけ埋まる** (編集 UI が読む提案の真実 — Spec 43 P1 で実装が数えて足した欄。dispatch / discard で落とす — 配送後の本文は束ねとワーカーの履歴に住み、二重に持つとリングの概算が嘘になる。skip_serializing_if で無い時は欄ごと出ない) bundleChars: u64 | null # 波の完了時に埋まる elapsedMs: u64 | null # 波全体 = キュー待ち込みの最遅 1 体分 invariants: | - **タスクの同一性は (planId, to)。** 成立根拠は「同一宛先の重複 = 静的な不正」(parallel_delegation の 3 分類 1) — 配送された波の中で to は必ず一意。task_index を持たないのは**この禁止に依存した設計**で、 同一宛先を許す将来 Spec は task_index の導入とセットになる - **planId はモデル非公開。** 束ねの文言・plan のツール説明は 1 字も 変えない (変えるとプロンプトが変わり、束ねサイズの実測と実機確認の 前提が揺れる)。モデルへ載せる判断は Spec 04 Notes 12 の発火条件 (a) (波の因果を追えなくて困った実例) が出たときに別途 - **配送ゼロの plan (静的差し戻し / hop 上限) は記録しない。** 波 = 配送が起きた単位。stderr の plan wave: も早期 return が eprintln! より 前にあり配送ゼロでは出ない (検証済み) — GUI と stderr の数え方は一致。 **Spec 43 の提案 (state=pending) は例外** — 配送前だが記録される (記録が編集窓の器そのもの)。静的差し戻し・hop 上限の不記録は変わらず、 「plan wave: は配送でだけ出る」も変わらない (提案の段は plan pending:) - 保持はリングバッファ**上限 50 波**。押し出しは**状態を問わず**古い方から。 押し出された波への後続更新は窓の外として無視する (event は普通に飛ぶ ので、投影側の欠けであって配送の欠けではない)。概算 1 波 ≈ 400 byte、 50 波 ≈ 20 KB — 律速はメモリではなく「見て意味のある遡り幅」 - 計時: startedAtMs は壁時計 (epoch ms)、elapsedMs は単調時計 (Instant)。 **計測点は run_plan の JoinSet タスク内** — deliver_and_wait の シグネチャに計時を入れない (ask に不要な荷物を背負わせない) - **完了した波に running を残さない** (Phase 1 実装時に確定)。running の まま finish_wave へ来るのは JoinSet のタスク異常 (パニック) だけで、 deliver_and_wait は必ず分類を返す。finish_wave が残った running を no_answer に倒す — UI に永遠の「実行中」セルを残さないための後始末で あって、正常系の分類ではない - wire は **camelCase** (CoreEvent の既存 variant と同じ rename_all) - 再起動生存 Spec (README 未実装表) が波の記録を生かす側に置くなら、 planId 採番位置の持ち越しもセットで要る (今はプロセス寿命なので不要) projection_rule: | **更新は event (events の plan* 3 種)、再投影は list_plan_waves** (IPC・ 引数なし・古い順で全記録・**実行中の波も含む** — 完了だけ返すと再読み込みの 瞬間に走っていた波が event でしか届かず再投影の穴になる)。 フロントの順序規律: (1) event リスナー登録 → (2) list_plan_waves → (3) planId で upsert。この順でないと list と購読の隙間に飛んだ planTaskResolved が欠落する (initialize の「購読を先に張る」と同じ規律。 **順序が欠落側の本体で、upsert は重複側の対策**)。タスクの upsert 鍵は (planId, to)。フロントの保持もコアと同じ上限 50・古い方から捨てる。 event の順序保証は **per planId のみ** (planWaveStarted → planTaskResolved* → planWaveFinished)。Resolved の相互順序と、 波を跨いだ全体順序 (並行する波の Started は交互に来うる) は保証しない。 SessionSummary: description: 会話一覧の 1 行 (Spec 12)。所有者は sessions.redb、GUI は投影 fields: id: String # {epoch_ms}-{短い乱数}。時刻順に読めるだけで、並びの根拠ではない meta: SessionMeta SessionMeta: fields: title: String # 最初のユーザー発話の先頭 30 字 (文字数。バイトではない) createdAt: u64 # epoch ms updatedAt: u64 # epoch ms。**一覧の並びと「最新」の判定はこれ** parentId: String | 省略 # 分岐で生まれたときだけ出る forkedAtSeq: u64 | 省略 # 同上。**この seq を含めて**複製された recordCount: u64 ForkPoint: description: 分岐できる地点 (Spec 12 P3)。fork の UI が枝を切る位置を選ぶための投影 fields: atSeq: u64 # fork_session(id, atSeq) へそのまま渡す = その発話の直前 (seq - 1) preview: String # 一覧に出す 1 行。先頭 60 字・改行は空白へ潰す text: String # 入力欄へ差し戻す**原文**。改行込み・切り詰めなし to: AgentId | 省略 # その依頼の宛先。宛先がエージェントでなければ出さない tsMs: u64 invariant: | 候補は**ユーザー発話だけ**。全レコードを並べると入退室の System 行や 往復の記録まで候補に出て、「どこで切ったのか」が読めなくなる。 **切るのはその発話の「直前」**で、依頼そのものは複製先に残さない (2026-08-03 実機の観察で確定)。依頼を含めて複製すると、**返事の付かない 依頼が宙に浮いたまま残る** — 返事と exchange はその発話より後に書かれるため。 再実行の機構は無いので、その依頼はどのみち打ち直すことになる。 代わりに text を入力欄へ差し戻し、書き換えて送れる状態にする (S4「そこから**別の頼み方**を試したい」がそのまま操作になる)。 **preview と text を分けてある**のは、潰した文字列を入力欄へ差すと改行が 消えて別の依頼になるため。片方から他方を作ると必ずどちらかが壊れる。 **先頭の発話 (seq 0) は候補にしない** — その手前まで複製すると空の会話で、 それは「新規チャット」と同じ操作になる (同じ結果へ 2 つの入口を作らない)。 wire は camelCase。**ipc_contract.rs の session_wire_fields_are_frozen が 3 型のフィールド集合を固定している** (parentId / forkedAtSeq / to は 出ない形もあるので、両方を固定してある)。 # ------------------------------------------------------------------------- # Spec 10 の契約。**2026-08-01 Phase 0 で凍結** (rev2 査読承認)。 # 割り込み停止 — 飛行中ターンの協調的な打ち切り。実装は Phase 1〜3 で着地。 turn_interrupt: api: interrupt_turn(agent_id) / interrupt_all() dependency: | tokio-util 0.7 (tokio_util::sync::CancellationToken)。tokio 本体には 無い (1.52.3 実ソースと docs.rs latest の 2 経路で確認)。起票時点で 既に間接依存として Cargo.lock に居るため、直接依存への昇格は コンパイル対象を増やさない。child_token の意味論 (親の cancel は全子へ / 子の cancel は親へ遡らない / cancel 済みの親から作った子は cancel 済みで 生まれる) が伝播設計の根拠 — 3 点目により未着手封筒の畳みは ターン開始時の検査だけで成立する (競合の窓が無い) invariants: | 1. **検査点は 2 箇所のみ** — handle_message のツールループ周回境界 (run_plan の join 待ちの select はこの待ち時間内の検査点で、見る トークンは同じ) と、ターン開始直後 (未着手封筒の畳み)。どちらの点でも 「呼び出しと結果の対」(failures.md #29)・「送信と保存の一致」(#45) は 構造的に壊れない。JoinHandle::abort による強制中断は採らない 2. **出口は経路ごとに定める**: 2a. 飛行中に中断されたターン: (a) 会話ログへ System の 1 行 (割り込み要求から検知までの elapsed を含む — LLM 呼び出し中の 切断を別 Spec で入れるかの判断材料。**連鎖で切られた場合** = requested_at が無い場合は「依頼元の打ち切りに連鎖」と書く — 「要求から 0.0 秒」は即検知という嘘の計測値になる) (b) 履歴へ record_interrupted_turn の注記 (record_failed_turn の文言を 使い回さない — 失敗の文言だと次のターンの自分が誤読する。 受信側は **sent_user_turn を送った形のまま**積む — 検知点では 組み立てが済んでいるので attributed へ縮める理由が無く、 前方一致も切れない = 打ち切りでキャッシュはリセットされない) (c) 依頼主が居れば Reply { kind: interrupted }。3 点とも必須。 まとめの LLM 呼び出しはしない。ここまでに使ったトークンは統計へ積む 2b. 未着手で畳まれた封筒: (c) の Reply { interrupted } **だけが必須**。 会話ログへ出さず・履歴へ積まず・turnInterrupted も出さない。 debug ログへ turn folded: の 1 行 (Spec 07 の猶予超過と同じ判断)。 **Reply を送らない drop は実装バグ** (親が no_answer に誤分類する)。 ただし親が先に interrupted で確定した後の send 失敗は正常 — 「drop は実装バグ」の射程はワーカーが送らないことであって、 確定済みの親が受け取らないことではない。race の勝敗は分類を変えない 2c. no-op (飛行中ターンの無い interrupt_turn): 何も出さない。成功 3. **伝播はターンの因果を辿る**。エージェント単位で殺さない。巻き添え 範囲 = その波が配送した封筒 + そこから派生した委譲のみ。ワーカーの ターンは封筒のトークンから child_token で自分の子を作り、それを 自ターンのトークンとする — 親由来と自分宛が 1 トークンに畳み込まれ、 検査は 1 本で足りる (2 本を別々に見る設計は見忘れの席になるので禁止) 4. **打ち切りは失敗ではない**。AgentFailed を出さない・last_error に 書かない・ステータスは Running のまま。ただし stop_agent 経由は この限りではない (Stopping → Idle へ進む) 5. **自動では発火しない**。割り込みの起点は人の操作だけ 6. **割り込みの有効範囲はターン seq に束縛**。TurnHandle { seq, token } を ターン開始時に発行 (seq は単調増加)。エージェントに紐づくフラグを 置かない — ターン A への割り込みが直後のターン B へ漏れる 7. **1 エージェント 1 飛行中ターン**。agent_loop の mpsc 順次処理が根拠。 interrupt_turn(agent_id) の API はこの前提の上に立つ — 並行ターンを 導入する変更は本契約の API を壊すため、セットで改訂が要る interrupt_all: | 全エージェントへ interrupt_turn を適用するだけの薄い皮 (for 文であること 自体が仕様)。**冪等** — 飛行中ターンが 1 つも無くても成功。進行役と ワーカーが親子で二重に切られても、出口の行は各ターンが検知時に 1 回 書くだけなので重複しない (独自の重複排除は持たない) token_budget: spec: "11 (2026-08-02 rev2 LGTM → P0 凍結。実装は P1〜P3 で着地)" unit: | 実効トークン = 未キャッシュ入力 ×1 + キャッシュ済み入力 ×0.1 + 出力 ×4。 内部表現は milli 実効トークン (整数。Atomic に浮動小数が無いため): WEIGHT_UNCACHED_MILLI = 1000 WEIGHT_CACHED_MILLI = 100 # Anthropic cache read 価格比が直接の根拠 WEIGHT_OUTPUT_MILLI = 4000 純関数の戻りは (milli + 999) / 1000 の切り上げ (保守側)。 重みは code constant であって設定ではない — **モデル別・村別の重み設定を 作らない**。変えるときは本契約の改定 (= Spec 改定) であり config ではない。 素のトークン数を使わないのは、キャッシュ済み入力が支配的な健全ターン (実測 87〜99% cached) を過大に数えて誤爆するため。 **キャッシュ書き込みは「未キャッシュ」として ×1 で数える (Spec 40 D3。 2026-08-18 利用者裁定)。実際の課金は ×2.0 なので、天井は請求に対して ゆるい側へ外れる。** - 実測 (claude-sonnet-5・13 ラウンド): 「未キャッシュ」扱い 69,968 の **100.0% がキャッシュ書き込み**で、素の新規入力は 26 トークン (1 ラウンドあたり 2)。`place_message_breakpoint` が毎ターン境界を 打つので、そのターンで増えた分は全部「書き込み」になる — **構造。** - 帰結: **Anthropic の個体では入力の課金が実効モデルの 1.61 倍**。 つまり同じ天井が実質 1.6 倍ゆるい。 - **OpenAI 互換の口も書き込みを持つ** (2026-08-18 実測。gpt-5.6-terra の 9 ラウンドで **未キャッシュの 99.8% が書き込み**)。倍率は 1.25× と 報告されている (一次資料未確認) ので、**入力の課金は実効モデルの 約 1.15 倍**。Anthropic ほどではないが 0 ではない。 - **Gemini は書き込みの欄を持たない** (明示キャッシュを作っていないため)。 xAI / Meta は未測定。 - **直さない理由** (利用者): 厳密にする労力に見合わない。天井の目的は 暴走を止めることであって支払いの精度ではない。**直す場合は既存の村の tokenBudget を人が下げ直す必要がある** (同じ 1M で早く尽きるため) — その移行コストが、得られる精度に見合わない。 - **金額が要るなら実効トークンを使わない。** `Record::Turn` の生の prompt / cached / cache_write / completion に外部の単価表を当てる (Spec 40 の付録)。**実効トークンは歯止めの単位であって通貨ではない。** usage_fallback: | usage 欠落の三規程 (楽観の 0 を作らない): prompt_tokens 欠落 → 送信 UTF-8 バイト数 ÷ 4 (切り上げ) completion_tokens 欠落 → 受信 UTF-8 バイト数 ÷ 4 (切り上げ。0 にしない — ツール結果が支配的なターンで 0 見積もりになる穴) cached 区分なし → 全量を未キャッシュ扱い (保守側) **バイト数 (s.len()) であって chars().count() ではない** — 文字数 ÷ 4 は 日本語 (≈1〜2 字/トークン) で約 4 倍の過小になり保守側に反する。UTF-8 バイトは日本語 3 バイト/字で過大側に倒れ、O(1) で取れる。 ceiling: | world.json の村レベル 1 フィールド tokenBudget (実効トークン建て)。 Rust 側は PersistedWorld の Option フィールド token_budget — rename_all = camelCase が既に効いているので個別の serde rename は不要。 None = 天井なし / Some(n) = 天井あり。**0 のマジック値は使わない** — Some(0) は読み込み時に None へ正規化して debug ログへ 1 行。 **既定 Some(1_000_000) を書くのは新規 world.json の作成時だけ**。既存の 村は None のまま触らない (昨日まで完走していた依頼を今日から止めない)。 None の村は起動時に **WARN** 1 行、文言は次の道込み: 「トークン予算なし(天井なし)— world.json の tokenBudget に 1000000(推奨)を設定できます」。 推奨値の目安 (README P4 で案内): 6 体前後 = 1M / 8 段フロー・12 体規模・ 出力多めのコード生成 = 2〜3M (根拠実測 387K は 6 体編成の値)。 pool: | 予算は因果に 1 つ (BudgetPool)。根 = ユーザー発話 1 通の**宛先 1 体ごと** (同報は宛先ごとに独立) / 予定の発火 1 回。Envelope.budget は cancel と **独立の**フィールド (Option>) — cancel が None の配送でも 予算は運ぶ。転送・波の全配送は**同一の Arc** を指す (新 pool を作った瞬間に 天井が蒸発する。delegation-fanout race — arXiv:2606.04056 の本番 63 インシデント中 11 件がこの型)。 検査は周回境界: try_reserve (残 0 なら打ち切り・atomic) → LLM 呼び出し → consume (fetch_sub・atomic)。**一体の atomic にはできない** (check と減算の 間に飛行時間が挟まる) ため、飛行中のオーバーシュートを許容し超過分も 数える — 次の周回境界で確実に止まる。**超過の上限は「1 呼び出し分」では なく「同時に飛ぶ本数 × 1 呼び出しの実費」** (2026-08-14 に TLC で反証・ specs/tla/BudgetOvershootBound。try_reserve は load のみで予約しないため、 波が N 体へ撒くと残額 1 でも N 体が同時に通る。「1 呼び出し分」は因果が 1 本のときだけ成立)。尽きの観測は AtomicBool の CAS で一回性を保証 (並列ワーカーが同時に観測しても打ち切り記録は 1 系統)。 **残額はモデルへ注入しない** (毎ラウンド可変の注入は prompt_cache の 前方一致を割る)。自動の再依頼はしない。 exhaustion: | 尽きたら新しい LLM 呼び出しと新しい配送を始めない。飛行中の応答・ ツール結果は正常に受け取り記録する。分類は budget_exhausted (PlanTaskState / Reply.kind へ加算追加・色は失敗系 — 人が止めた interrupted の注意色と混ぜない。天井が止めたのは資源の事実)。 稼働は降ろさない — 閉じるのはターンだけ (turn_interrupt と同型)。 System 行は事実 + 次の道 (failures.md #44 の規律): 「次の呼び出しぶんの予算(上限は実効 N トークン)を確保できなかったため 打ち切りました。続きが要るなら改めて依頼してください (予算は依頼ごとに新しく付きます)」。 **「使い切った」とは言わない** (Spec 38 D3) — 予約は次の 1 呼び出しぶんを 先に確保する形なので、**実費を使い切る前に止まる**ことがある (過大予約による早止まり)。起きた事実は「次のぶんを確保できなかった」で、 尽きた場合も早止まりの場合も正しい。残額の内訳は計器が持つ: `budget stop: agent=… ceiling=… remaining=… estimate=… reason=exhausted|reserve_short` を**打ち切った周にだけ** 1 行 (通常運転では 1 行も増えない)。 理由の 2 値は利用者から見て別の事態 — 尽きたなら天井を上げる、 早止まりなら撒く人数を減らすか天井を上げる、と次の手が違う。 precedence: | 歯止めの優先順位 (同じ周回境界で複数が同時成立したときの分類。検査も この順で行い、先に成立した歯止めが Reply.kind を確定する): cancel (interrupted) > budget_exhausted > RepeatGuard > hop / rounds 上限 人の明示停止が最優先、次に資源の事実。race の勝敗が分類を変えない (turn_interrupt と同じ規律) を静的な順序で担保する。 reservation: | (Spec 38 P0 凍結・2026-08-14。実装は P1 以降 = 着手日の YYYYMMDD_reserve ブランチ。D0=(1) を採る — やらないへ倒す場合の 完了定義は Spec 38 D0 が正) **上限式**: 超過 = 飛行中の各呼び出しの「正の見積もり誤差」の総和 (<= 同時数 × 最大正誤差)。旧記述「1 呼び出し分」は因果 1 本のときだけ 成立する数えで、TLC で反証済み (specs/tla/BudgetOvershootBound / failures.md #105)。 **API** (move セマンティクスの凍結が P1 実装の前提): - BudgetPool::try_reserve(estimate_milli: u64) -> Option。 CAS (fetch_update): 残額 >= estimate なら引いて Some、足りなければ None。 **残額 0 のときは estimate 0 でも None** (P1 で精密化 — 「>= で Some」を 字義どおり読むと est=0 が尽きた門を迂回する。exhaustion の「尽きたら 新しい呼び出しを始めない」が勝つ)。 - **estimate > ceiling は即 None** — 天井より大きい予約の部分適用は 残高を負方向へ持っていけるので書かない。 - ReservationGuard::commit(self, actual_milli) は **self を move で消費**: actual > estimate なら差額を debit、actual < estimate なら差額を返金。 - **commit されなかったパスでのみ Drop が走り、予約全額を返す**。 move 消費なので commit 後に Drop は呼ばれない — committed フラグは 持たない (フラグを足すと「commit したのに Drop も返す」二重返金が 再び書ける形に戻る)。 - **払ったと分かる失敗も commit する** (2026-08-16、failures.md #103 の 処方)。LlmError::usage() が Some を返す variant (今は OutputTruncated だけ — プロバイダが 200 を返した後にこちらが Err へ変えた形) は、 run_turn の呼び出し地点で **成功と同じ commit** を通してから Err を 伝播する。Drop の全額返金へ落ちるのは **払いが分からない失敗だけ** (400 / DNS / タイムアウト)。分からないものに見積もりを捏造して引か ない — 払っていない 400 の連発が予算を食う側に倒れる。 **1 予約につき清算は 2 腕** (成功 / 払った失敗) で、 tests/budget_reserve_wiring.rs が `.commit(` を 2 × 2 = 4 で数える。 - 返金は fetch_update + min(ceiling) で**天井に飽和** (並列返金の 一時的な天井超えが後続 debit の超過を隠す形を防御的に塞ぐ)。 **見積もり (D1(b))**: その個体の直前の呼び出しの実効値。初回は床 1_000_000 milli (= 1,000 実効トークン)。 - 置き場は**個体ごとの共有 AtomicU64** — 個体ローカルではターンを またぐ初回ラウンドへ持ち越せない (run_turn はターンごとに新品)。 書くのは commit だけ・読むのは try_reserve の直前だけ・ロックは足さない。 - **初回波は固定床のため上限縮小が効かない。2 回目以降で収束** — これは仕様であって欠陥ではない (床を上げると単発の小さい依頼まで 止まる側に倒れる)。 - 過大予約で落ちた波のセルは budget_exhausted で確定し、返金後も **再依頼しない** (「自動の再依頼はしない」の凍結) — 束ねに欠けが 出るのは仕様。README の予算の節に 1 行で警告する (P4)。 **予約する場所は run_turn の周回境界 2 箇所だけ**。deliver_and_wait 冒頭の配送前検査は LLM を呼ばない門なので **load 観測のまま**とし、 予約しないことをテストで凍結する (3 箇所目を予約に変えると配送拒否の たびに返金が要る形が黙って生える)。 **未確定のまま P1 へ送る 1 点**: JoinSet::abort で Drop が走るかは **仮説** (tokio 文書に明言が無く、査読 2 系統でも結論が割れた)。 P1 の実測テストで決める。プロセス死 (exit / SIGKILL) の予約漏れは 実害の経路が無い — プールはプロセス内メモリで再起動で復元しない (session_store の「予算は復元しない」凍結の帰結)。abort・切断時の under-debit (usage が返らず清算できない実費) は予約の有無と独立で 現行と同じ — 本契約は退行させないことだけを約束する (#103 の**残余**。 usage が返った失敗は 2026-08-16 に清算へ入れた。残るのは usage 自体が 届かない切断・abort・タイムアウトで、これは見積もれない)。 channel: "core://event" tagged_union_key: type variants: agentStatusChanged: { agentId, status } # promptTokens / cachedTokens はキャッシュ率の分母・分子。合計だけだと # 率の更新が refreshAll (起動時と設定変更時) 頼みになり、再起動後の会話で # 率の欄ごと消える (表示は実装していても、データの通り道が偶然にしか # 通らないなら出ないのと同じ。failures.md #33 の経路版)。 # lastPromptTokens (Spec 49) も同じ理由でここに乗せる — 乗せないと輪の更新が # refreshAll 頼みになる。稼働中 (Starting | Running = 起動している個体) だけに # 毎秒。ターンが確定しても個体は Running のままなので次の tick で新しい値が乗る # (停止は人の操作で、そのとき refreshAll が投影を取り直す) agentStatsUpdated: { agentId, uptimeSecs, totalTokens, promptTokens, cachedTokens, lastPromptTokens } messageSent: { message } topologyChanged: {} agentFailed: { agentId, error } backendDegraded: { modelTemplateId, reason } agentTyping: { agentId, active } conversationCleared: {} toolInvoked: { agentId, tool, ok } toolLimitReached: { agentId, maxIterations } # 同一のツール呼び出しの繰り返しを検出して実行せずに打ち切った # (failures.md #41 の処方 1)。repeats = 同じ結果が続いた回数 (打ち切り時は 2) toolRepeatBlocked: { agentId, tool, repeats } hopLimitReached: { agentId, maxHops } # Spec 08 (波ペイン)。順序保証は per planId のみ (Started → Resolved* → # Finished)。再投影との突き合わせ規律は entities の PlanWaveRecord.projection_rule planWaveStarted: { planId, agentId, wave, tasks, startedAtMs } # tasks = [{to, msgChars}] planTaskResolved: { planId, to, state, elapsedMs } planWaveFinished: { planId, bundleChars, elapsedMs } # Spec 10 (割り込み停止)。**飛行中のターンが中断されたときだけ**出す — # 未着手封筒の畳み (turn_interrupt の出口 2b) では出さない (ターンは # 中断されていない。始まらなかっただけ。出すと「1 中断 = 1 イベント」の # 数えが壊れる)。発行者は切られたターン自身 (検知時に 1 回) なので、 # 二重割り込み・interrupt_all・親トークン経由が重なっても 1 本 turnInterrupted: { agentId, turnSeq } # Spec 12 (会話の永続化)。**加算的変更** — conversationCleared の意味は # 変えない (「会話ペインを空にせよ」の表示指示のまま)。意味を変えると # 既存 UI が誤動作するため、開いたセッションの告知は別イベントにする。 # 新規チャット・resume・fork・continue_latest のすべてがこれを出す # (「今どのセッションを見ているか」の唯一の通知路)。 sessionSwitched: { sessionId } # Spec 39 (統計画面)。ターンの使用量が Record::Turn として保存された直後に # 1 通 (4 出口すべて)。**id だけを運び、数字を運ばない** — 数字は # session_stats が aggregate から出す 1 経路に留める (イベントで運ぶと # 2 経路目になる)。受け手はフロントの統計画面だけで、**開いていない間は # 読み捨てる**。コストは agentStatsUpdated (稼働中の個体ごとに毎秒) の # 1/数十以下 — rev1 で「払いすぎ」と見積もって 10 秒 pull にしたのが誤り。 # **加算的変更** (既存 variant の意味は変えない)。 turnRecorded: { agentId, sessionId } observability_rule: | ツール実行は会話ログに現れない (結果はプロンプトの中で消える)。 toolInvoked を出すのは、黙って副作用だけ起きる状態を作らないため。 「動いたかどうか」ではなく「何をしたか」が見えることが要件。 **受け手を持つまで、この主張は実装されていなかった** (2026-07-31 に配線)。 型は types.ts にあったのにフロントのどこも処理しておらず、コアは撒いて いるが受け手が居ない状態だった。発覚は Spec 04 の実機確認で 「plan が使われたか ask_* が 3 回か」を UI から判別できなかったこと。 現在の投影先は 2 つ: 会話ペインの時系列 (発話と混ぜる。因果がそこにしか 現れない) と、エージェントカードの「直近ツール」(今何をしているか)。 toolLimitReached はトーストへ (設定で直せる種類の詰まりなので、どの上限に 当たったかを数字で出す)。 toolRepeatBlocked も別のトーストへ。**toolLimitReached と混ぜない** — 当たったのは上限ではないので上限を上げても直らず、直し方が違う打ち切りを 同じ文言で出すと利用者が設定をいじって同じ所で止まる。 ToolRun は Rust に対応する型が無いフロント固有の投影なので、ミラー契約の types.ts ではなく lib/chatRows.ts に置く。 診断ログ (2026-07-31): `[fuseforks]` 行は stderr と workspace/fuseforks.log の両方へ出る (diag::note)。stderr だけだった間は `tauri dev` の端末にしか残らず、利用者が貼らない限り誰も読めなかった (1 ターン 730,406 トークンの診断がそこで止まった)。 行の種類は version / turn start / turn / turn failed / tool / tool blocked / plan wave / plan bundle / schedule。version は起動の区切りの直後に 1 行 (app=tauri.conf.json の版 / profile=debug|release。2026-08-20、failures.md #112) — **同じ端末に複数世代の配布物が並び、同じ world.json を共有する**ので、 どの世代が村を触ったかがログから読めないと事故を事後に追えない。turn start と turn failed は後から足した — turn 行が成功経路にしかなく、落ちたターンが 1 行も残らないうえ、 ツールを呼ばない周 (LLM 応答待ち) は行が出ないため、 「飛行中」と「3 分前に死んだ」が同じ無音に見えた (2026-07-31)。 **turn 行は失敗したターンにも出る** (2026-08-16、failures.md #103) — `stop=failed:{CODE}` で、欄の並びは成功と同じ (prompt / cached / total / reasoning / backend)。使用量の行はターンの出口 4 つに 1 行ずつ: 完走 = turn (stop=- / repeat:x / tool_limit) / 失敗 = turn (stop=failed:…) / 割り込み = turn interrupted / 予算 = turn budget exhausted。 turn failed は**理由の文面**を運ぶ行として残り、数字は turn 行が持つ (失敗したターンは 2 行になる — 数えるのは turn 行だけでよい)。 出るのは実行ループへ入ったターンだけで、バックエンドの解決 (設定不備) より前に落ちたターンは LLM を 1 度も呼んでいないので turn 行を持たない。 **使用量の 4 行は末尾に `model=` を持つ** (Spec 39 P0・2026-08-16。着地は P1) — turn / turn interrupted / turn budget exhausted のすべてで、**接頭辞と既存の 欄の並びは変えず、増えるのは末尾の `model=` 1 つだけ** (既存の grep を 壊さない)。後ろ 2 行に backend= / elapsed_ms= は**足さない** — 欄を揃えたければ Record::Turn を読む。行とレコードで揃えるのは*出自の構造体* (TurnSpend → settle_turn の 1 実装) であって*出力の欄*ではない。model は #104 が 「turn の prompt はラウンドの合計・モデル名も無い」と書いた欠けで、 帯と機種の判断がログだけでできるようになる。 不変条件: - 載せるのは観測行だけ。**プロンプト本文・ツール結果の本文・資格情報は 載せない** (ログは平文でワークスペースに置かれる。failures.md #1) - 書き込みの失敗は握り潰す。診断の機構がターンを落とさない - 8MB で fuseforks.log.old へ 1 世代だけ回す。世代を増やさないのは、 上限を「2 ファイルぶん」で言い切るため - 時刻はファイル側にだけ付ける (stderr の見え方を変えない) - tool 行の body_chars がこの計器の主目的。ツール結果は履歴に積まれて 以後の全周回で再送されるので、1 本の大きさが周回数ぶん掛かって効く。 turn 行の rounds と prompt だけでは何が太らせたか追えない reset_rule: | conversationCleared = 新規チャット (Spec 03 rev2 承認、2026-07-30 凍結)。 reset_conversation の処理順は固定: 1. Shared.log クリア → 2. 全 AgentRecord.history クリア → 3. conversationCleared 発行 → 4. フロントが messages (Shared.log の投影) を空へ。 不変条件: - 消すのは Shared.log と AgentRecord.history の**2 つだけ**。 稼働状態・累積統計・Memory.md・エージェント別 MCP 接続 (Spec 02 の per-agent map) はすべて維持。AgentRecord ごと クリアしてはいけない (MCP 接続や統計を巻き添えにする) - 飛行中ターンの完了書き込みは**許容** (案 A)。白紙化の直後に 飛行中だった発話 1 件が載るのは仕様 — 発話は起きた事実であり ログに残す (hop 打ち切りの「記録してから打ち切る」と同じ規律)。 世代管理による破棄は採らない **Spec 12 による改訂 (2026-08-02 rev2 承認、P0 で凍結)**: 新規チャットは**捨てるのではなく、今のセッションを閉じて新しい セッションを開く**。上の処理順と不変条件は**そのまま生きる** — メモリ上の Shared.log と AgentRecord.history をクリアする点も、 消すのが 2 つだけである点も変わらない。変わるのは 「クリアした内容がディスクに残っており、一覧から戻れる」ことだけ。 追加の不変条件: - reset_conversation は create_session への委譲になる。UI の 「新規チャット」という名前は残す (利用者の語彙を変えない) - conversationCleared に続けて sessionSwitched を出す。 **conversationCleared を出さない選択は採らない** — 会話ペインを 空にする指示はこれが唯一の経路で、意味を変えると既存 UI が誤動作する - 確認ダイアログは**残すが文言を変える** (「破棄しますか」→ 「この会話を閉じて、新しい会話を始めますか」)。捨てないので警告色は外す - 切り替え (resume / fork / continue_latest) も同じ 2 イベントを この順で出す。新規チャットだけを特別扱いしない - **飛行中ターンの扱いだけは新規チャットが特別**(P2 で確定)。 案 A (完了書き込みは許容) はここでそのまま生きる — 着地先が新しい 空のセッションなので、既存の記録を汚さない。既存の会話を開く resume / fork / continue_latest は SESSION_SWITCH_BLOCKED で拒む - **保存先が開けていない村では改訂前の挙動のまま** — メモリを白紙にして conversationCleared だけを出す (sessionSwitched は 出さない)。保存できないことは新規チャットを使えなくする理由にならない typing_rule: | agentTyping は発話処理 (LLM 呼び出し + ツール実行) の開始と終了で **対になって**流れる (2026-07-29)。処理は数十秒かかりうるため、この間 UI に何も出ないと「届いていないのか、考えているのか」を区別できない。 不変条件: - active: false は成功・失敗を問わず必ず流れる (出しっぱなし禁止)。 開始 → 応答 → 終了の順序をテストで固定 (typing_events_bracket_the_reply) - フロントは true のキーだけ保持し、終了でキーごと消す。加えて agentStatusChanged で稼働外へ遷移したら消す二重の網 - これはストリーミングではない (本文は従来どおり確定時に一括)。 SSE ストリーミングは別 Spec 候補 — LlmBackend::chat が完全応答を 返す設計のため、wire 2 系統 + trait + 逐次描画まで全層に触る boot_contract: | 起動ハンドシェイク = boot_status コマンド (2026-07-29)。 ウィンドウを先に出し、オーケストレーターの初期化 (MCP 接続を含む、 10 秒超あり) はバックグラウンドで走らせる。setup の block_on は廃止 — その間ウィンドウが 1 枚も出ず、起動と故障を区別できなかった。 不変条件: - AppState は初期化が**成功した瞬間**に manage される。それまで State を要求するコマンドを呼んではいけない (抽出で失敗する) - boot_status だけは AppHandle + try_state で答えるため、初期化前でも 常に { ready, error } を返せる。フロントは ready を確認してから 他のコマンドを呼び始める (useOrchestrator.initialize の関門) - 失敗理由は BootError (起動直後に manage 済みの Mutex>) へ入り、フロントは BOOT_FAILED として覆いに表面化する。panic しない — ウィンドウは既に出ており、黙って落ちると起動失敗が無反応に見える - 覆いは 2 段共有: index.html の #app 内 (Vue マウント前) と App.vue (state.ready まで)。同じ .boot-splash / .boot-spinner (style.css) を 使い、切り替わりの継ぎ目を見せない degradation_rule: | 設定不備で代替バックエンドへ退避したときは、必ず backendDegraded を出し、 退避先の応答本文にも理由を書く。さらに退避結果はキャッシュしない (覚えると原因を直しても復帰できない)。 根拠: 環境変数がプロセスへ渡っていないだけの状態で「エコー応答」とだけ名乗る 偽の応答が返り続け、どの設定が欠けているのか画面から追えなくなった (2026-07-28)。 退避は許すが、黙って退避してはいけない。 confirm_rule: | はい/いいえの確認は**アプリ内のモーダル**で出す (2026-08-03 利用者指摘)。 **ブラウザの confirm() / alert() は使わない。** 理由 2 つ: - WebView のネイティブダイアログは**アプリの名前を名乗らない**。Tauri では `localhost` が出る。デスクトップアプリとして配っているものが、操作の 途中でだけ「ブラウザで開いた何か」に見える - 文言と配色を制御できない。ボタンのラベルは OS の言語設定で決まり、 元に戻せる操作とそうでない操作を見分けさせられない 機構: composables/useConfirm.ts (モジュール内に待ち行列) + components/ConfirmHost.vue (App.vue に**1 つだけ**置く)。形は ToastHost と同型。 呼ぶ側は `await askConfirm({ title, message?, confirmLabel?, cancelLabel?, danger? })` で bool を受けるだけ。 不変条件: - **必ず解決する。** 答えても取り消しても Promise が返る。返らないと 呼び出し側の await が永久に止まり、その画面が固まったように見える - **取りこぼさない。** 開いている最中に来た 2 件目は待ち行列へ積み、 順に出す (1 件しか持たない設計は、捨てるか黙って置き換えるかしかない) - **danger は初期フォーカスを取り消し側へ置く。** Enter はフォーカスされた ボタンを押すので、初期位置が実行側だと「Enter を続けて押していたら 消えていた」が起きる。ダイアログ全体で Enter を実行へ束ねない — フォーカスが唯一の「いま Enter で何が起きるか」の表示になる - Escape は **window で拾う** (覆いの上で拾うと、背景クリック後に効かない) - z-index は既存ダイアログ (z-40)・トースト (z-50) より上の z-[60]。 確認は**ダイアログの中から呼ばれる**ので、下に潜ると押せない - title は**問いそのもの**、message は**結果として何が起きるか**を書く # --------------------------------------------------------------------------- topology_rules: rejected: - 自己ループ(自分自身への接続) - 未登録エージェントへの接続 allowed: - 循環(相互接続) rationale: | エージェント同士が往復するのはこのシステムの目的そのもの。 トポロジーで循環を禁じるのではなく、終了の機構で止めるのが正しい層。 # --------------------------------------------------------------------------- conversation_termination: principle: | 会話の終了は 2 層で持つ。片方だけでは足りない (failures.md #11)。 主要フレームワーク (OpenAI Agents SDK / AutoGen / LangGraph) はいずれも 2 層。 **既知の穴 (2026-07-31, failures.md #41)**: 層 2 の歯止めは**全部「回数」建て**で (max_tool_iterations 12 / max_retries 3 / max_hops 8)、コスト建てのものが無い。 **回数の上限がコストの上限になるのは、1 単位あたりのコストが一定のときだけ** — ツールループは毎周回すべての履歴を送り直すので単位コストが単調に増え、 失敗ループ 1 ターンで 30 万トークン規模になりうる。 処方は 3 つ。**1 (同一失敗の検出) は 2026-07-31 に実装済み** (下の tool_execution の repeat_guard)。残るのは 2 (割り込み停止) と 3 (トークン予算)。README の未実装表。 layer_1_semantic: rule: ツール呼び出しの無いテキスト出力が最終出力 (OpenAI Agents SDK と同じ規則) mechanism: | 接続先ごとに `transfer_to_` ツールを 1 本ずつ提示する (名前の慣習も OpenAI Agents SDK に倣う)。tool_choice は Auto。 - ツールを呼んだ → その相手へ転送し、会話が続く - 本文だけ返した → 会話終了。ユーザーへ返る ツール名が 64 文字を超える / 衝突する場合は transfer_to_agent_{index} へ退避。 fan_out: | 1 応答内の transfer_to_* は**全部**拾い、全宛先へ並行に配送する (2026-07-28)。 Claude / Gemini は並列ツール呼び出しを普通に返すため、最初の 1 本で 打ち切ると残りが黙って消える (「みんなに挨拶して」が片方にしか届かない、 という形で表面化した)。同一宛先への重複要求は先勝ちで 1 通に畳む。 不変条件: - 宛先ごとに AgentMessage を 1 通記録する。hop は全通で同じ - tokens は 1 ターンぶんの消費なので先頭の 1 通にだけ載せる (全通に載せると宛先数で二重計上される) - 1 宛先への配送失敗は他の宛先を道連れにしない (枝は独立) - 履歴へは「[→ 宛先] 本文」を結合した 1 エントリとして積む 幅の安全網は max_hops のまま (深さしか縛らない)。fan-out で理論上 幅^深さに増えうるが、総配送数バジェットは実測してから判断する。 delegation: | 委譲 (`ask_`) = 相手に尋ねて**答えを受け取る**経路 (2026-07-28)。 転送 (`transfer_to_`) との違いは**答えの行き先だけ**: transfer → 制御ごと相手へ渡る。相手の答えはユーザーへ返る (電話の転送) ask → 答えが依頼主へ戻り、ツール結果として会話が続く OpenAI Agents SDK が handoff と agent-as-tool を別に持つのと同型。 根拠: 「〇〇に自己紹介を頼んで」で転送すると、依頼主が結果を知れないまま 答えがユーザーへ飛ぶ (実機で観測)。 実装: 受信箱が Envelope { incoming, reply_to: Option } を運ぶ。 reply_to があれば Finish の宛先はユーザーではなく**依頼主**になる。 不変条件: - **必ず有限時間で戻る**。相互に ask し合う配置は待ち合わせになりうるので ask_timeout (Spec 44 から村の設定 askTimeoutSecs・既定 600s。起票時は 180s 固定) で打ち切る。max_hops は深さしか縛らず、 待ちの時間は縛らない - 相手が停止中・受信箱飽和・無応答・上限到達は、いずれも例外ではなく **文字列のツール結果**として返す (ツールの失敗で会話を止めない既存規律) - 質問自体の tokens は 0 で記録する (呼び出し元のターンに計上済み) - hop は ask でも増える (転送と同じ燃料を食う) - **相手が答えず転送した場合は、その事実を reply_to へ送る** (Spec 04 rev3 指摘 4)。reply_to は Finish 分岐でしか使われず、Handoff では 送られないまま drop されていたため、依頼主は「相手から答えが 返りませんでした」を読んでいた。**これは嘘** — 答えは返っており、 宛先が違うだけで会話は第三者へ渡っている。転送は抑制しない (握り潰すと「呼んだのに何も起きない」穴に変わる)。文言だけ直す parallel_delegation: | 並列委譲 (`plan`) = 複数の相手へ**同時に**頼んで全員の答えを束ねる経路 (Spec 04)。ask / transfer との違いは並列性と答えの行き先: ask → 直列 (1 体ずつ待つ)。答えは依頼主へ戻る fan-out → 並列。答えはユーザーへ散る (合流しない) plan → **並列**。答えを**束ねて依頼主へ戻す** 計画はモデルが作り (進行役が 1 波を宣言する)、実行の保証はコードが持つ (並列配送・合流・タイムアウト・集約・燃料)。多段 DAG はスキーマに持たない — 波間の判断はモデルの強みで、全段を先に宣言させると 「2 波目が 1 波目の結果に依存する」場合に破綻する。 提示条件: **接続先が 2 体以上**のエージェントにのみ提示する。 進行役フラグのような設定は足さず、トポロジーから導出する (1 体なら ask_* で足りる)。 **失敗の 3 分類** (処方が分かれる根拠は「その値がいつ確定するか」だけ): 1. 静的な不正 (波の中で不変・事前に確かめられる) → **何も配送せず差し戻す** 対象: 宛先が接続先集合に無い / 同一宛先の重複 / tasks が空 2. 波全体で一様に決まる制約 (波の中で不変・全タスクが同じ値を共有) → **何も配送せず 1 つの結果文字列** 対象: hop の上限到達。N 個の同じ文字列を並べない。 hop を動的に入れないのは、1 回の plan の中で hop が変わらないから (全タスクが同じ incoming.hop を共有する) 3. 動的な失敗 (確かめた瞬間と配送の瞬間で違いうる) → **そのタスクの結果文字列**・道連れなし 対象: 停止中 / 受信箱飽和 / 無応答 / タイムアウト / ワーカーが答えず転送した 3 分類はいずれも **Ok(String) の 1 ツール結果**として返る。差し戻しも エラーチャネルを使わない (「ツールの失敗で会話を止めない」既存規律。 Err を返すと実行ループが「ツールの実行に失敗しました: {err}」で包み、 モデルが読むべき理由が一段深い所へ埋まる)。分類は**文言の違いであって 経路の違いではない**。 不変条件: - tasks[] は { to: enum(接続先 AgentId), message: string } の 2 欄。 **どちらも必須**。message の本文フォールバックは持たない — transfer_to_* は宛先が 1 つなので本文で代替できるが、plan は宛先ごとに 内容が違うのが前提で、同じ本文を全員へ配ると説明が嘘になる - hop の判定式は **incoming.hop + 1 >= max_hops** で ask_agent と同一 (saturating_add(1) を含めて)。片方だけ境界がずれると、同じ配置で ask は通り plan は止まるという説明できない差になる - **`to` は閉じた列挙 (enum)**。接続先の AgentId をそのまま並べ、 表示名は description の対応表に書く。自由文字列にしない — HandoffTools が「ツール名 = ID / 説明 = 表示名」で解いた問題を 作り直すことになり、しかも**表示名の一意性は保証されていない** (World::register_agent が拒否するのは ID の重複だけ) - tasks の件数は **下限 1・上限なし**。空は静的な不正 (通すと空の束ねが 返り hop だけ消える)。上限は同一宛先禁止 × 接続先限定で有界。 **1 件の波は許容する** — 「1 体なら ask_* で足りる」はツールの 提示条件の話で、波の大きさの話ではない - **束ねの見出しは `agent_id (表示名)`**、順序は**入力順**。 表示名だけの見出しにしない (同名が 2 体いると判別不能になる) - ask_timeout は**波全体ではなくタスクごと**に適用し、join で全員を待つ - hop は各配送で +1 (ask と同じ燃料。ネストした plan は深さで縛られる) - ワーカー側から見た形は **ask と完全に同じ**。新しい概念は増えない - 並列なのは**配送**であって実行ではない。各エージェントの受信箱は 1 本でターンは直列に処理されるため、ワーカーが塞がっていればその分待つ。 壁時計は「キュー待ちを含む最遅 1 体分」 - **提示条件はツール構築時のトポロジー、検証は実行時のトポロジーで 取り直す。** set_connections は稼働中に呼べて TopologyChanged を 撒くので、ターンの途中で繋ぎ替えは起こりうる (将来の話ではない) 未解決: 束ねた結果は N 体分の回答全文を進行役の履歴へ積む (ask の N 倍)。 上限も要約も**実測で膨らむのを見てから**決める (確かめる前に席を作らない)。 plan_edit_window: | plan の編集窓 (Spec 43 rev3 / Done 2026-08-25 — 実機検収 7 件観測)。planReview が真の個体の plan は配送せず**提案**を記録してターンを正常に終え、人の承認 (dispatch) が配送を起こす二相。LLM が立てた計画を人が直してから撒く — 「線は人が引く」の計画への延長。出自は eigent の eigent_make_sub_tasks / eigent_start (CLAUDE.md の eigent 実読の節)。 凍結 (Spec 43 の 9 本 + 2026-09-02 の 10 + Spec 53 の 11。4 は Spec 53 で改訂): 1. **既定 OFF。** planReview が偽 (既定) の個体の plan は バイト等価の挙動 — 既存の村は 1 ミリも変わらない 2. **提案は永続化しない** (session_store の「復元しないもの」に 提案 pending も含む。再起動で消えたら頼み直し — 作業の寿命) 3. **dispatch の検証は run_plan と 1 実装** (空 / 宛先がその進行役の 接続先 / 宛先重複。検証は dispatch 時点の接続で掛かる — GUI が何でも配送できる形にしない。Spec 20 rev2 の罠の再演防止) 4. **編集の中間状態に API を与えない。** IPC は dispatch_plan_wave(planId, tasks: [{to, message}], verifier?) と discard_plan_wave(planId) の 2 本だけ。提案の真実は PlanWaveRecord に提示時に記録された tasks、dispatch が受けた 最終形が配送の真実 — その間に真実は無い。 **Spec 53 で改訂**: 3 つ目の verifier (Option) を加算。 **省略可で、省略は None (検証役なし) と解釈する** (TS は verifier?: AgentId | null)。提示時の値は持たず、押したときの値が 真実 (この凍結と同じ規律)。意味論は plan_verifier_contract が正 5. **dispatch は新しい因果の根** (ユーザー発話・予定の発火に次ぐ 3 つ目)。天井は World::token_budget() から new_root_budget で 切る 1 実装 — 式を増やさない。進行役ターンの残り予算は 引き継がない。cancel は新しい root token (interrupt_all で切れる)。 波の配送は next_hop=1 (run_plan と同じ位置) 6. **束ねの送り手は Endpoint::System・宛先は進行役のみ。** 他個体の広場ログへ漏れない (#89 の回帰をテストで留める)。 配送そのものは RepeatGuard の射程外 (ツール呼び出しの機構なので 構造的に当たらない — 当たらないこと自体をテストで凍結する) 7. **LLM は承認後の経路に居ない。** 人が見た計画のデータがそのまま 検証・配送される (提示と実行の同一性の構造的保証) 8. **ターンの外の実行形は 2 つまで** (手動要約 = failures.md #50 と 本 Spec の波の実行者)。3 つ目はこの凍結を名指しで覆す。どちらも TurnSpend で精算し計器を持つ (#50 / #103 の処方を最初から) 9. **dispatch 実行中に進行役が停止したら束ねは破棄**し、 plan bundle discarded: reason=agent_stopped の 1 行 + 会話への System 行で事実を残す (黙って捨てない — #44 の規律)。 **P1 の実装で精緻化** (2026-08-24): 停止は通常この網より手前で効く — stop_agent は interrupt_turn を通り、interrupt_turn は進行役一致の 波の実行者 token を切るので、**波ごと畳まれてワーカーも止まる** (届け先の居ない束ねのためにトークンを払い続けない)。凍結 9 の破棄は その**残余** (波の完了と停止の競合・受信箱飽和 = reason=mailbox_full) を受ける網。両経路とも結合テストで留めた (plan_edit_window.rs) 10. **委譲で呼ばれたターン (reply_to あり) では planReview に関わらず 窓を開けない** (2026-09-02 実機。turn.rs の `plan_review && !awaiting_reply`)。窓は提示でターンを正常に終える二相なので、 戻り口はそこで「提案した」の 1 行に消費され、後から届く束ねは 凍結 5 の新しい根 (hop=0・戻り口なし) へ落ちて**利用者へ流れる** — 依頼主 (plan / ask の呼び手) は束ねを永遠に受け取れない。委譲された ターンでは従来どおりターンの中で撒いて束ね、依頼主へ戻す。 転送を委譲ターンで提示しない #96 の門 (`!awaiting_reply`) と同じ 構造の規則。結合テストで留めた (plan_edit_window.rs の a_delegated_turn_skips_the_window_…) 11. **予定の印かスイッチがあれば planReview に関わらず窓を開けない** (Spec 53 / 2026-09-15)。判定は凍結 10 と同じ 1 箇所 (turn.rs): plan_review && !awaiting_reply && !auto_approve_plans && !plan_review_bypass。 (a) **Envelope.auto_approve_plans: bool** — 真を立てるのはコードの 2 箇所だけ (deliver_scheduled で ScheduledTask.autoApprovePlans が真のとき / その予定の 検収の再依頼 redeliver_for_acceptance — AcceptancePending に写す)。 ask / plan / 転送の配送は受信封筒の値を**写すだけ**で上書きしない。 新しい根 (利用者の発話 / dispatch_plan_wave / 束ねの配送 / MCP の外部依頼) は偽。 封筒はコアの内部型で、モデルや外部クライアントが組む経路は無い (b) **ステータスバーのスイッチ plan_review_bypass** — Shared のメモリだけ (AtomicBool)。**保存しない・起動時は必ず OFF** (付けっぱなしで再起動すると 確認が要る村で黙って確認が消え続けるため。再起動をまたいで無人で回すものは (a) が担う)。1 プロセス 1 村なのでスコープはプロセス全体 = その村 (IPC は村の識別子を持たない)。 利用者裁定「予定で自動で通すならスイッチが OFF でも飛ばす」は (a) OR (b) で成立。 **既定 (予定の欄が偽・スイッチ OFF) なら凍結 1 のとおりバイト等価** 欄名: AgentSpec.planReview: bool (serde default = false)。 **AgentSnapshot にも写す** (投影に無い欄は設定保存で既定へ戻る — Spec 14 P1 の規律)。UI ラベルは「計画の確認」(分類・ラベルは システム用語側 — 用語境界)。 計器: plan pending: (提示) / plan dispatch: (実行の起点) / plan discard: (破棄) / plan bundle discarded: (M2 の破棄)。 既存の plan wave: 〜 plan bundle: は dispatch 後の配送で従来どおり出る。 提案の段では plan wave: を出さない (検収 1 の負の対照)。 **Spec 53 で加算**: plan review skipped: agent=… reason=schedule|bypass (planReview が真の個体で凍結 11 により窓を開けなかったときだけ。凍結 10 の awaiting_reply による飛ばしは出さない)。**両方が真なら reason=schedule** (持続する側の理由 — スイッチを戻しても同じ予定は飛ばし続ける)。 plan_verifier_contract: | 束ねの検証役 (Spec 53 rev2 承認 2026-09-15)。条例の「束ねの検証」節は 「束ねに参加していないサーヴァントへ ask で検証を頼む」と文言で求めていたが、 実機ではフォールバック (自己点検) しか観測されなかった。検証をモデルの選択から 外し、**システムが呼ぶ**機構にする。 凍結: 1. **検証役が None (既定) なら何もしない** — 検証の処理に入らず、束ねには 見出しも理由も一切足さず、計器も出さない。既存の村の束ねと計器は バイト等価 2. **検証役の決まり方は 2 経路で、解決は呼び手が行う** (execute_wave の中では 解決しない — 解決の場所が 2 つあると窓を通った波と通らない波で既定の 読み方がずれる): - ターンの中で撒く plan (窓なし・凍結 11 で窓を飛ばした場合を含む) = World.defaultVerifier を run_plan が撒く時点で読む - 計画の確認で承認した波 = dispatch_plan_wave の verifier 引数 (パネルの初期選択は村の既定。「なし」を選べる) execute_wave は verifier: Option を受ける 3. **検証は束ねの後・返す前** (execute_wave が束ねを組んだ直後)。システムが 検証役へ deliver_and_wait で頼み (**送り手は進行役** — 波のタスクと同じ。 本文に「この依頼はシステムが自動で送っています」と書く。P2 で確定)、答えを束ねの末尾に `## 検証: agent_id(表示名)` の 1 節で付ける。本文 = 各タスクの依頼文 + 束ね + 見る 4 点 (空の答え / 依頼の取り違え / 答え同士の矛盾 / 欠けている論点) + 結論の 3 択 (このまま使える / この相手にだけ聞き直す / 依頼元に確認する)。 区切りは既存の束ねと同じ `## agent_id(表示名)` の見出しで、`---` は使わない (答えは Markdown で普通に現れる)。封筒構文は配送の wrap が既存どおり無害化する 4. **検証役が指定されているのに検証しなかったときは、理由を束ねに 1 行書く** (黙って飛ばさない — failures.md #44)。理由は閉じた 7 つ: 検証役が波の宛先に 含まれる (自己採点しない) / 検証役が進行役自身 / 停止中 / 待ちの輪 (ask_cycle_contract の拒否) / 時間切れ / 予算切れ / **答えを返さなかった (転送した・答えずに終えた。P2 で追加 — 起票時の 6 つのどれにも当たらず、 他の理由へ寄せると計器が嘘をつく)**。判定の順は 参加者 → 進行役 → 輪 → 停止中 (配送の前) → 配送後の結末。**どれも束ねは返す** (検証の失敗で仕事の結果を捨てない)。波が打ち切られた場合は検証しない (束ねを作らない既存の経路のまま) 5. **財布は波を配った財布。** ターンの中 = 進行役の因果の財布、承認後の波 = 承認時に切った根の財布 (new_root_budget)。尽きていれば deliver_and_wait の 事前検査が返し、凍結 4 の「予算切れ」になる。**待ちの連鎖**は波のタスクと 同じ規則 (依頼元の連鎖 + 進行役 — 検証役が進行役へ ask し返すと輪として拒否)。 承認後の波では依頼元の連鎖が空なので進行役 1 つ (**起票時の「空から」は誤り** — 波のタスクが既に進行役を積んでおり、検証だけ規則を割る理由が無い。P2 で訂正)。 待ちの間は進行役のターン (承認後の波では実行者) の打ち切りと競わせる (deliver_and_wait 自身は時間切れしか見ない — 波の待ちと同じ扱い) 6. **検証役に絆 (接続) は要らない** — 人が明示に選んだ宛先で、予定の to と同じ 扱い。**「線は人が引く」の例外**として、選ぶのが人であることで原則の意図を保つ。 選べるのは登録済みのサーヴァント。指定した個体が削除されたら読むときに None 扱い (掃除はしない。画面は「(削除済み)」と出す) 7. **LLM に「検証するか」を選ばせない** (plan_edit_window の窓と同じ規律 — 選べる形にすると failures.md #84 の形で飛ばされる) 8. **ターンの外の実行形を増やさない** (plan_edit_window 凍結 8)。検証は run_plan と run_dispatched_wave の中の配送で、検証役のターンは普通のターンとして TurnSpend で精算される 欄名: World.defaultVerifier: Option (world.json、serde default = None)。 IPC: get_default_verifier / set_default_verifier と dispatch_plan_wave の verifier 引数。 計器: plan verify: agent=… plan_id=… verifier=… outcome=ok|skipped:<理由> chars=… elapsed_ms=… (検証役が None の波では出さない)。 **条例との二重化**: 既定の検証役を入れた村で条例の「束ねの検証」節が残っていると、 進行役がさらに別の個体へ検証を頼む (二重の検証・二重の払い)。条例は利用者の資産なので 契約でも Spec でも書き換えず、改訂案を渡して利用者が決める (Spec 53 Notes 3)。 ask_cycle_contract: | 委譲の輪の検出と待ち時間 (Spec 44 rev2 承認 2026-08-24)。 1 本の時計 (ask_timeout 180 秒固定) に載っていた逆向きの 2 つの仕事 — 「深い委譲をどこまで待つか」(長くしたい) と「輪をいつ解くか」(短く したい) — を分ける。輪は構造 (配送時の即答拒否) へ、時計は保険へ。 起点は実機 (調査の委譲が 180 秒で捨てられ、しかも相手のターンは 止まらないので答えは作られ課金され届かず捨てられる — 二重の損)。 凍結 (Spec 44 の 6 本): 1. **輪の不成立を構造で保証する** (時計に依存しない)。保証するのは 不成立であって拒否の最小性ではない — 連鎖は配送時点のスナップ ショットなので、拒否の直後に待ちが解けていた競合の偽陽性はありうる。 代償は拒否文 1 通で、モデルは次の周で頼み直せる 2. **Envelope.waiting は順序つきの連鎖**・末尾が直近の依頼主。 ask / plan の配送は自分を末尾に追加、転送は**空なら空のまま・ 非空なら末尾を除く** (転送は直近の待ちを解く側 — HandedOff の 返信が配送より前に送られる turn.rs の実測)、新しい因果の根 (利用者発話 / 予定の発火 / Spec 43 の dispatch / 束ねの配送) は空。 **判定は追加後の連鎖に宛先が居るか** — 追加前だと自己委譲 (自己ループの辺 + ask 自分) だけがすり抜けて自分の受信箱を 永遠に待つ 3. **判定は deliver_and_wait の入口 1 実装**。受信箱へ積まずに typed の結果で即答 (停止中の Undeliverable と同じ形 — モデルは 同じ周で読んで進路を変えられる)。**転送の配送では判定しない** — 転送は待ちを作らず (送り手はブロックしない)、輪 = 待ちの循環は deliver_and_wait でしか生まれない。判定を転送に足すと健全な形 (ブロック中の個体への転送 = 受信箱で順番を待つだけ) を壊す。 転送の役目は連鎖の中身を正しく保つことだけ (下流の ask のため) 4. **askTimeoutSecs は World の Option** (world.json — 村と 配られる)。**OrchestratorConfig.ask_timeout は撤去** — 既定は World::ask_timeout() の None → 600 秒の 1 箇所だけに住む (二重定義を作らない)。保存時に 30..=3600 の外を拒否 (下限 30: 未満は調節ではなく委譲の無効化 / 上限 3600: run の timeoutSecs と同値。10 分超は保険としては遅いが、1 時間級の ワーカーを持つ村の明示の選択として許容)。deliver_and_wait が 呼び出しごとに読む = 保存すれば次の委譲から効く・再起動不要 5. **OrchestratorConfig は画面に出ないまま** (形式上 Spec 13 の凍結は 不変 — 欄が World へ引っ越す)。実質は「出さない 12 個」の 1 つの 設定化なので 12 → 11 個 (settings_contract の当該行に続報)。 UI はシステム設定 > コスト管理・保存先は村 (ページ末尾の注記 — tokenBudget と同じ扱い。このアプリは 1 プロセス 1 村なので 「どの村へ書くか」の曖昧さは実在しない) 6. **タイムアウトしても相手のターンは止めない** (既存挙動の凍結 — 止めると「遅いが健全な調査」を殺す側に倒れる。届かない答えの 払いは turn: 行と統計に今までどおり残る) 計器: ask refused: agent=… to=… via=ask|plan reason=circular chain=[…] (拒否のたび 1 行。via に transfer は無い — 転送の拒否は存在しない)。 **外の扉 (Spec 25) との相互作用**: ask_fuseforks の保持時間の上限も この時計なので、既定 600 への引き上げで「既定同士で S1 は切られない」 (クライアント実測保持 196 秒 ≥ 旧既定 180) の前提が崩れる。外から使う 村は .mcp.json の "timeout" (ms) 併記が実質必須になる — mcp_server_contract の当該行に続報済み。D7 (同時 1 本・busy 即拒否) の機構は別のまま不変。 presence: | 顔ぶれの提示と入退室の通知 (Spec 06, 2026-07-30 凍結)。 人間には UI で見えている稼働状態が、エージェントには見えていなかった (点呼のためにトークンを払っていた) 非対称の解消。 **役割分担: 顔ぶれが権威、通知が語り。** 顔ぶれは常に現在の真実、 通知は「いつそうなったか」の時間軸を足す。どちらか片方では足りない。 入退室の通知 (P1): - set_status (状態遷移の単一路) が Running / それ以外の境界をまたいだ ときだけ、from: System / to: User の発話を record する (配送なし。 ターンを発火させない)。文言は状態語彙 3 種: 「{id}({name})が稼働を開始しました / が停止しました / が失敗により停止しました」。オンライン/オフライン等のチャット風 語彙は使わない (同じ状態に 2 つの語彙が並ぶとモデルが対応を推測する) - 起動失敗 (Starting → Failed) は通知しない。まだ場に現れていなかった 者の失敗は入退室ではなく、誤った信念も発生していない。顔ぶれが示す - Failed の理由 (last_error) は流さない。ただし失敗と正常停止の種別は 伝える (進行役の次の一手が変わる) - **通知の合成は hearsRoomLog の gate の外** (プロンプトは通知と 広場ログの 2 節構成)。広場ログの opt-out は固定費の削減で、入退室は 配送先の正しさ — コストの設定が経路の正しさを黙って壊す形にしない - 可視範囲は広場ログの窓 (room_log_window) に従う。窓の外は顔ぶれが 権威を持つ — 情報が消えるのではなく時間軸だけが落ちる - **抽出条件は from == System **かつ** to == User** (2026-08-08 訂正)。 初版は from だけで拾っており、**予定の配送 (from: System / to: Agent) が 全員のプロンプトへ `## 入退室` の項目として入っていた** (failures.md #89 — 実機で、広場ログを切った個体が自分宛でない予定の 依頼文を読んでいた)。**この節は hearsRoomLog の gate の外にある**ので、 宛先付きの発話が混ざると**オプトアウトを構造的に迂回する**。 凍結「宛先外のエージェントはメッセージがあったことすら知らないべき」は is_visible_in_room_log が守るが、**System 発はその述語を通らない**。 通知は「場に向けた告知」であって配送ではない、が正しい射程。 **Spec 28 の退行ではなく Spec 07 (2026-07-30) から在った** — 予定の配送はその時から System → Agent。 固定は tests/presence_notice_scope.rs (正の対照つき 1 本 + 「入退室は全員に届く」の負の対照 1 本) 顔ぶれ (P1.5): - システムプロンプトの可変部分 (安定境界の後・Memory の前) に 1 行。 「## 今の顔ぶれ」+ 接続先を **agent_id(表示名): 状態** で列挙。 状態の語彙は UI の STATUS_LABELS と同一 - **順序はツール提示順 (= connected_agents の保存順) と同一**。 顔ぶれだけ別の整列規則を持つと、同じ相手の並びが transfer_to_* と 食い違い、モデルに二重管理を強いる - 接続先 0 体なら節ごと出さない (空の見出しを置かない) - **ツールの提示は状態で変えない**。停止中の相手の transfer_to_* も 出す (提示を状態で変えるとツール集合が揺れ、キャッシュの プレフィックスごと割れる。提示は静的・状態は動的) stable_len 境界の再定義: 旧定義「Memory の直前」は、Skill と Memory が隣接している間だけ 「Skill の末尾」と同じ点を指せていた (**2 つの記述が同じ点を指すのは 間に何も無い間だけ**、という潜伏)。顔ぶれを挟むと割れ、旧定義のままでは 顔ぶれが安定部分に入って状態変化のたびにキャッシュが割れる。 新定義: **安定素材 (条例〜Skill) の末尾 = 顔ぶれの直前**。 値 (文字数) は現状から変わらないので既存キャッシュは割れない。 実装は compose_system_prompt が境界を確定させた後に顔ぶれを push する room_log: | 各エージェントのプロンプトへ「この場で交わされていた会話」を載せる (広場ログ、2026-07-28)。**可視性と発火を別の軸に分ける**機構: 配送 (= ターンの発火) は宛先だけに保ち、他エージェントの発話は 「聞こえていた」だけの文脈として渡す。反響 (#20) を起こさずに 「全員が場を共有している」を成立させる。 載せる条件 (すべて満たすもののみ): - 送り手がエージェントであること。**ユーザー発の発話は載せない** — ユーザーは聴衆を選んで話しており、広場ログがその選択を迂回する 裏口になってはいけない (「宛先外のエージェントはメッセージが あったことすら知らないべき」= ユーザー指示) - 自分が送り手でも受け手でもないこと (自分の分は履歴に既にある) 有界化: room_log_window (既定 12 件) × room_log_excerpt_chars (既定 200 字)。 全エージェントのプロンプトが同じだけ膨らむため、人数分の乗算で効く。 位置は自分の履歴より前 (場の背景であって自分とのやり取りではない)。 **抜粋であることを本文に書く** (2026-08-04)。切った行には元の長さを 「(全 N 字)」で添え、見出しに「直近 N 件まで・各行は先頭 N 字の抜粋」と、 切った行が 1 件でもあれば次の手を書く (Spec 22 改訂: 「全文が要るなら `room_log` ツールで行頭の ID を指定」。旧文面は「発言した相手へ ask」)。 `…` だけだと「省略された」のか「相手がそこで言い終えた」のかを モデルが区別できず、抜粋を発言の全体として読む (実機で観測 — 利用者が「チャットの全文が渡っていない」と報告した件の真因)。 **打ち切りの規律 (何が起きたか + 次に何をするか + 母数) は同梱ツールだけの ものではなく、プロンプト合成にも掛かる** (failures.md #44 / #55 / #58)。 **「広場ログは読み直す経路を持たない (次の手は本人へ ask しかない)」は Spec 22 で撤回した** (2026-08-06。撤回したと書かないと次に読む人は 生きている規律だと読む — #56)。読み直す経路は room_log_pull の `room_log` ツール。ただし全撤回ではない — **上限超 (ROOM_LOG_READ_MAX_CHARS) とリング溢れの次の手としての ask は残る**。 room_log_pull: | 広場ログの抜粋から全文への読み取り経路 (Spec 22、2026-08-06)。 新ツール `room_log` — orchestrator 合成・読み取り専用・引数は `id` 1 つ。 AgentTool ではない (`Shared.log` を読む以上 ToolContext には載らない — ask_* / transfer_to_* と同じ棚)。 約束は**コストのみ**: 全文到達を ask (相手の 1 ターン丸ごと消費 + 返るのは 再話) からツール 1 呼び出し (LLM 呼び出しゼロ + content そのまま) へ 差し替える。品質 (正解率) は約束しない (arXiv 2607.26637 RQ2 — 整理の確実なリターンは検索コストだけ)。 不変条件: - **可視述語は 1 実装**: `is_visible_in_room_log(agent_id, message)`。 compose_room_log (抜粋に載る条件) と `room_log` (読める条件) の両方が 同じ関数を呼ぶ — 「載る」と「読める」は同じ問いの裏表で、別の述語を 置くと答えがずれる (Spec 20 の prune_settled が decide を使い回した のと同じ理由)。**共有は述語だけ** — 窓切り (room_log_window) と 切り詰め (room_log_excerpt_chars) は compose 側の責務で共有しない (窓まで共有すると「窓の外でも読める」が実装で潰れる) - **ユーザー発話は ID を直接知っていても読めない** (述語が落とす)。 「宛先外はメッセージがあったことすら知らないべき」の凍結は pull 経路でも**述語で**成立する — 注意書きでは守らない - **読み口は Shared.log (in-memory リング 5,000) のみ**。リングから 溢れた発話は not_found。session_store から読む経路は作らない — あちらは可視述語を通す前の生ログで、読み口を 2 つにすると述語の 1 実装共有が破れる。過去セッションへの遡りは別機能 (それは 「続きを読む」ではなく「記録を検索する」で、射程が違う) - **提示条件は hears_room_log == true のみ** (静的)。抜粋が届かない 個体は ID を知る経路が無い。enabledTools の対象外 (ask_* / rag と同じ側) — BUNDLED_TOOL_NAMES にも DEFAULT_ENABLED_TOOLS にも入れない。ログが空かどうかで提示を 揺らさない (提示は静的・状態は動的) - **ID は AgentMessage.id (UUID v4 文字列) への前方一致**。抜粋には **切れた行だけ**に行頭 `[先頭 8 字]` を前置する。合成時点の可視集合内で 前置が衝突する行だけ 12 字 → 16 字 → 全長へ伸長して表示する (git short hash 方式 — 表示された ID は合成時点で必ず一意。 「桁を増やして再試行せよ」は 8 字しか知らない相手に増やしようが ないため採らない)。ツール側は一意に解決できなければ拒否 - **返答は【送り手: A → B】の封筒 + content そのまま**。上限 ROOM_LOG_READ_MAX_CHARS (20,000 字)。**原文性が構造で成立するのは 上限まで** — 超過は打ち切り、打ち切り返答は**母数を必須で含む** (「全 N 字中 M 字までを表示」)。次の手は「発言した相手へ ask」 (そこでは相手ターン消費と再話に戻る — この後退も含めて約束の範囲) - **文面規則**: 可視でない発話は「見つからない」と**同じ文面** (存在を エラーメッセージ経由で教えると凍結が破れる)。曖昧は候補を**列挙せず** 「抜粋に表示されている ID をそのまま使う」と求める (列挙は可視でない 発話の ID が漏れる余地を作る) - **計器**: `room_log read: agent=… id=… outcome=ok|truncated|not_found|ambiguous chars=N` を呼ばれたとき だけ 1 行 (grep include: / run decision: と同じ形)。truncated の率が 上限 20,000 字の妥当性、ambiguous の率が ID 桁数の将来判断の材料。 **本文はログへ書かない** (#71 — モデル出力を運ぶ計器は秘密の転送 経路になる)。fuseforks.log 側では not_found と not_visible を 区別してよい (人向け。**モデル向け文面は同一のまま**) - **原本には一切書かない**。管理エージェント・要約・再編成を置かない — 書く機構が存在しなければ、要約による無言の圧縮 (condense) と 整理の質の侵食は構造的に起きない (arXiv 2607.26637 RQ4)。 原本はハーネスが決定的に書く append-only のリングのまま - **RepeatGuard は既定のまま** (ターン内カウント)。除外や限定を 足さない — ターン内の同一 ID 3 回目が止まるのは正しい歯止め (同じ本文の 2 回目が既に無駄で、積むと以後の全周回で再送される)。 ターン跨ぎの再読は guard がターン関数のローカル変数なので最初から 数えに入らない (Spec 22 rev2 で実測して確認) 抜粋の既定 (room_log_window = 12 / room_log_excerpt_chars = 200) は Spec 22 では動かさない — 固定費の削減は `room_log read:` の計器で pull の使用実態を観測してからの別判断 (弱いモデルが読みに行く判断を どれだけ実行するか未測定のまま絞ると、読み落としを配る)。 sender_envelope: | 受信した発話の本文には「【送り手: 名前】」の封筒を先頭行として付け、 プロンプトと履歴の**両方**へ同じ形で入れる (2026-07-28)。 根拠: ユーザーの言葉もエージェントからの転送も同じ user ロールで届くため、 名前が無いと受信側は区別できない (実際にユーザーの発話を「他のエージェント が話した言葉」と取り違えた)。名前は Endpoint から解決する (User=「ユーザー」/ System=「Fuseforks」/ Agent=表示名、削除済みなら ID)。 System の表示は UI・プロンプトとも「Fuseforks」で統一 (2026-07-31、利用者 指示で「システム」から改名)。場そのものの声としてアプリ名を名乗る。 不変条件: - プロンプトと履歴の形を揃える。履歴に封筒を入れないと、 次のターンで過去の発話だけが出所不明に戻る - RAG の検索クエリには封筒を入れない (検索ノイズになる) - EchoBackend は受信本文をそのまま返すため、エコー応答には封筒が現れる (デバッグ時に封筒の実物が見える、という副産物) user_send_is_single_target: | **ユーザーからの送信は 1 宛先に限る** (UI 制約、2026-07-29)。 同報は全員のターンが並列に走るため、誰も他の答えを見ないまま応答する。 仕切ろうとした個体は「もう答え終わっている」を知りようがなく、同じ相手が 二度答える。収束する形は orchestrator-workers — 進行役 1 体に頼み、 その 1 体が ask_* で順に委譲して答えを受け取りまとめる (各エージェントがちょうど 1 回ずつ話すことをテストで固定済み)。 **コア側の同報機構は残す**: send_user_message_broadcast / co_recipients / 注記 / 表示集約は、エージェント発の fan-out が今も使っている。 剥がすと fan-out が壊れる。制約は UI 層だけに置く。 戻す余地: 「同じ問いを全員へ独立に投げて答えを比べる」用途 (モデル比較) は 正当だが開発者寄り。戻すなら宛先選択の副作用ではなく、それと分かる 名前付き機能として出す。 broadcast_note: | ユーザー同報は AgentMessage.co_recipients (同報の全宛先、受信者自身を含む) を封筒に載せ、受信者のプロンプトへ「N 体へ同時に届いている・転送不要」の 注記を差す (2026-07-28)。根拠: 同報の事実が受信者に見えないと、各エージェント は「自分しか聞いていない」と判断して接続先へ律儀に転送し、反響が起きる (「みんなこんにちは」で実測。局所的には合理的な判断の創発的な暴走)。 転送の禁止ではなく、転送する理由を消す情報を与える。 不変条件: - 注記は宛先本人の封筒にだけ載る。宛先外へは配送自体が無く、 発話の存在ごと見えない - co_recipients が 2 体未満なら単独宛と同義で、注記は付かない (1 対 1 の会話に「同報です」と書くのは嘘で、モデルの判断を歪める) - 注記の位置は履歴の後・今回の受信の直前 (プロンプトキャッシュの 安定プレフィックスを揺らさない) - エージェント発の fan-out も**同内容なら**同報として co_recipients を 自動付与する (2026-07-28)。受け手同士が「相手はこれを知らない」と 誤解して伝言し合う経路はユーザー起点と同型。宛先ごとに内容が違う fan-out には付けない (「全員が同じ内容を受け取っている」が嘘になる) - 表示側は同報・fan-out の複製を 1 吹き出しに畳む (「〇〇 他X名」)。 規則は lib/chatRows.ts の純関数: 同じ送り手・同じ内容・同じ hop・ 宛先未重複の連続だけを畳む (言い直し・送り直しは畳まない) fallback: | use_tools=false のサーバでは終了マーカー `[[END]]` を使う (AutoGen v0.2 の is_termination_msg 同型)。マーカーが無ければ 最初の接続先へ渡す。宛先を選べないため、これは劣化経路。 prompt: | ツールを渡すだけでは「呼ばない = 終了」がモデルに伝わらないため、 手順を system メッセージで明示する (OpenAI Agents SDK の RECOMMENDED_PROMPT_PREFIX と同じ意図)。 layer_2_mechanical: mechanism: hop が max_hops (既定 8) に達したら打ち切り、HopLimitReached を発行 note: | LangGraph の recursion_limit と同じ位置づけ。**終わり方ではなく燃料切れ**。 これが唯一の終了手段になっている状態は設計の欠落であり、実際にそうなっていた。 tool_execution: | 転送と実行を 1 つのツール集合としてモデルへ提示し、返ってきたものを受け手が区別する。 transfer_to_* を呼んだ → 転送してターンを終える (結果は返らない) 実行ツールを呼んだ → 実行し、呼び出しと結果を対で積んでもう一度呼ぶ 何も呼ばなかった → 最終出力 上限は max_tool_iterations (既定 12、AgentSpec.maxToolIterations で エージェント個別に上書き可)。当初の既定 6 は、通常の調査委譲 (grep → 絞り込み → 読む) が 2 セッションで 3 回溶ける実測を受けて 引き上げた (2026-07-30)。低い上限は節約ではなく浪費側に働く — 燃えたトークンの成果が出ないまま、再依頼でもう一度同じだけ燃える。 repeat_guard (同一失敗の検出、2026-07-31 実装、failures.md #41 の処方 1): 規則: **ツール名 + 引数 + 結果本文**が完全一致で 2 回返ったら、 3 回目は実行せず短い通知だけを返し、toolRepeatBlocked を発行する。 数えるのは (ツール名 + 引数) ごとで、**隣接は要求しない**。 判定材料に結果**本文**を使うのは、同梱ツールが失敗を Err ではなく Ok(<エラー文の本文>) で返すため (「ツールの失敗は会話を止めない」規律の 帰結)。Result::is_err で数えると、実機で燃えた経路 (sd の失敗を 12 周 繰り返した failures.md #39) は 1 件も検出できない。失敗が型に載って いない以上、モデルへ返る本文の完全一致が失敗の一致を表す唯一の実体。 文言を parse して失敗かどうかを推定するのはやらない。 不変条件: - 判定はターン内でだけ持つ。ターンを跨ぐ繰り返しは縛らない (同じ依頼が同じ失敗をたどるのは正しい) - 数えは **(ツール名 + 引数) ごとに独立**。間に別の呼び出しが挟まっても 切れない。**当初は隣接する 2 回で判定しており、実機で 1 件も発火 しなかった** — モデルは 1 周に 2〜3 本を並列で呼ぶので、同じ読み直しは 周をまたいで現れ、間に別の呼び出しが挟まって数えが切れる (2026-07-31 実測: 同じ file 呼び出しが round 24/25/28 に出たが、 26 の grep で切れて 3 回目が素通しした)。緩めたのは「隣り合っているか」 だけで、一致の条件は完全一致のまま - 同じ呼び出しが**違う結果**を返したら数え直す。追記が進む・待っていた 状態が変わる場合は、同じ操作でも行き詰まりではない - 成功の繰り返しも同じ扱いで止まる (同じ入力に同じ出力が返る以上、 3 回目に新しい情報は無い)。失敗と成功を区別する材料が無いのと同じ話 - 実行しなかった呼び出しにも**必ず tool_result を積む** (対を崩すと 400。この不変条件の本体は下の空応答の節)。 本文は**短くする** — 同じ 12,000 字をもう一度積むと、以後の全周回で それが再送される。ここが効きの本体 - **止めるのはその 1 本であってループではない。** ループを切るのは その周のツールが全部止まったとき (= 新しいことを何もしていない周) だけ。 並列の 1 本が重複しただけで進行中の作業を殺さない - 打ち切り時に toolLimitReached は出さない (当たったのは上限ではない。 理由を 1 つに保つ) - まとめ呼び出しは上限打ち切りと同じ経路を通る。ただし system 文言は 別 (「同じ操作を勧める提案はしない」を足す)、最終フォールバックの 文言も別 (上限の上げ方を案内しない — 直しても直らない) 空応答の禁止 (2026-07-29, failures.md #29): 上限で打ち切られた周の 応答が「ツール呼び出しだけでテキスト無し」だと最終出力が空になる。 空の発話は (a) 空バブル (b) 履歴の空 assistant (c) 次リクエストの 空テキストブロック → 400 → エージェント停止、と 3 層を伝播する毒。 防御は四層: - orchestrator (第一): 上限で打ち切られてテキストが無いときは、 **ツール無しで最後に 1 回だけ呼び、ここまでの結果を文章化させる**。 中間のツール結果はそのターンの messages にしか存在せず履歴に 積まれないため、まとめずに捨てると「続けて」のたびにゼロから 調査をやり直して同じ上限に当たる (実機で 3 ターン連続 146k tok)。 まとめ 1 回で、燃えたトークンの成果が答えに変わる - orchestrator (最終網): まとめも失敗して空なら正直な文言へ置き換える (「上限に達した。設定で上限を上げるか依頼を分割して」) - 履歴: push_exchange は空の発言を「(発言なし)」へ置換して積む (対を崩すと役割の交互性が壊れるため、落とすのではなく置換) - Anthropic wire: テキストもツール呼び出しも無い発話は送らない (空のテキストブロックも空の content 配列も API に拒否される) 不変条件: - 呼び出しと結果は**必ず対で**履歴へ積む。結果だけ積むとプロバイダが 「対応する呼び出しが無い結果」として拒否する - プロバイダ固有の随伴データは ToolCall.extra (Option) で不透明に運び、 decode した adapter だけが encode で読み戻す。canonical は中身を解釈しない。 実例: Gemini (OpenAI 互換) の思考署名 extra_content.google.thought_signature。 ツール呼び出しごとに返り、履歴再送時に同じ位置へそのまま返すことが必須。 欠くと 400 INVALID_ARGUMENT でツールを呼んだ会話の 2 周目が必ず落ちる (2026-07-28 実機の Gemini で表面化、failures.md #18)。 無いときはキーごと省く (署名を返さないサーバへ null を送らない) - ツールの失敗は会話を止めない。エラーは文字列としてモデルへ返す (失敗でターンごと落とすと、引数を間違えただけで会話が終わる) - **提示していないツール名も捨てず、tool_result で「無い」と返す** (2026-08-02、failures.md #47)。以前は実行対象フィルタが落としており、 呼び出しは tool_result にもログにも残らなかった — モデルから見ると 「呼んだのに何も起きない」ので、消えた呼び出しの代わりに本文だけが 答えとして配信される。execute_tool には「`X` というツールはありません。 提示された名前から選んでください。」の文言が元からあったが、 **捨てられた呼び出しはそこへ到達できなかった**(到達不能な分岐)。 判定は repeat_guard の**後**に置く — 先に置くと同じ結果を返し続けて 上限まで回り、後ろなら 3 回目に周回打ち切りへ落ちる。 toolInvoked は発行しない (「ツールが走った」の意味を保つ) - ツール結果はそのままプロンプトへ入り、モデルの出力を経て外へ出うる。 戻り値に内部情報を載せすぎない ワイヤ形は 2 社で構造が違い、adapter が翻訳する: OpenAI 互換 = assistant.tool_calls (arguments は JSON 文字列へ戻す) + role:"tool" + tool_call_id Anthropic = assistant の tool_use ブロック (input はオブジェクト) + **user** メッセージの tool_result ブロック leaked_tool_call: | **観測中の事象 — 機構は作らず計器だけ置いてある** (2026-08-02)。 モデルがネイティブの tool_use ブロックではなく、本文へツール呼び出しの XML を書いてしまうことがある。ハーネスから見ると「ツールを呼ばなかった」 = 最終出力なので、生の XML がそのまま利用者へ配信される。 実機の初発は claude-opus-5 + MCP 27 本 (MCP_DOCKER) で 1 回 (2026-08-02 21:39、failures.md #47)。同じ依頼を再実行したら MCP_DOCKER__fetch はネイティブで成功したので、**プロンプト起因ではなく 一過性の揺らぎ**と読んでいる。 今あるもの (L0 計器・挙動は変えない): looks_like_leaked_tool_call(text) が真なら、最終出力と判定する直前に `text tool call leaked: agent=… round=… chars=… head=…` を 1 行出す。 検出は `` / `` から始まる)。 削ったのは API 側で、anthropic.rs の decode は JSON のコンテンツ ブロックを走査するだけで XML を解釈しない。 テストは単体 2 本 + **in-situ 1 本** (実際にターンを走らせ、ログ ファイルへ 1 行出るまで確かめる。単体だけだと「単体は緑なのに実機で 発火しない計器」を作りうる — repeat_guard 初版で踏んだ形)。 作らないと決めているもの (頻度が分かるまで): L1 自動修復 (漏れた XML を正規の tool_calls へ昇格させて実行へ流す) と L2 教示リトライ (system で tool_use を促して 1 周だけ回す) は**未実装**。 1 回しか観測できていない事象に機構を足すと、効いているか分からない 機構が増える。計器があるので次に起きれば必ずログに残る — 頻度を見てから Spec を起票する。実装するなら L1 の正規表現は**開始タグが食われた形**を 主に据えること (`^(?P[A-Za-z0-9_-]+)">` + `` 群 + ``)。`) を運ぶ (2026-07-29)。 **Spec 55 P1 (2026-09-17 着地) で agent_names (村の全個体の id → 表示名・id 順) と uses_blackboard を足した** (turn.rs の提示時と実行時の 2 箇所が world から解く) — 見るのは blackboard だけ。黒板は元からファイル名で全員の名前を露出しているので、 名前の表をツール層へ渡すのは新しい露出ではない。 解決するのはオーケストレーター (execute_tool) で、**呼び出しの瞬間**に world から 引く — ツール登録時に固定すると設定変更が効かない。ツール自身は world を知らない (登録簿の型に依存すると MCP ツールと同じ穴に嵌らなくなる)。 **ToolContext.cancel: Option を追加** (2026-08-04、Spec 15 P2)。 **ほとんどのツールは見ない。** Spec 10 の不変条件 1「検査点は周回境界だけ」は そのまま維持し、例外は**外部プロセスを起動するツール** (run) だけ — 周回境界まで待つと最長 timeoutSecs (上限 1 時間) 走り続け、 「要求から 0.0 秒」で止まる Spec 10 の約束がそこだけ破れる。 **葉で 1 箇所だけ見るのは、周回境界の検査を増やすことではない** (ターンループの構造は変わらず、止められない待ちを 1 つ潰すだけ)。 **AgentTool::spec_for(&self, ctx) -> Option を追加** (同上)。 既定は spec() で、**個体で提示内容が変わるツールだけが上書きする**。 None を返したらそのツールを提示しない。 根拠: description() は ToolContext を受け取らないので、 **個体別の提示 (run が「その個体から実行できる登録だけ」を列挙する) は この穴でしか書けない**。WORK_DIR_TOOL_NAMES による自動除外は名前の集合で 書けるが、「登録が 1 件も実行可能でない」のような**中身を見ないと決まらない 除外**は名前の集合では書けない。 bundled_tools_contract: | 同梱 grep / fd / diff = crates/fuseforks-core/src/tools/fs.rs (2026-07-29)。 動機: コーディング用エージェントが最も頻繁に使う道具で、全文読みより 桁違いに安い (トークン節約は最重要課題)。MCP filesystem に依存せず 誰の環境でも動くことに意味がある。 不変条件: - 探索範囲は AgentSpec.workDir の中だけ。未設定なら「設定されていない」と 案内を返すだけで何も読めない (エラーにはしない — モデルが読んで 利用者に設定を頼める) - 範囲の強制は canonicalize 後の前方一致。symlink は辿らない。 「無い」と「外」は区別して返すが、**外側のファイル内容は決して返さない** - 出力は必ず有界: 一致 MAX_MATCHES=100 件 / 1 行 240 字 / 全体 12,000 字 / 1 ファイル 2 MiB / 走査 20,000 ファイル。打ち切りは黙って行わず 件数を結果に書く (silent truncation は「全部見た」と誤読される) - **上限は表示に掛け、数えには掛けない** (2026-08-04。それまでの実装は 表示件数だけを書いて総数を捨てていた)。書くのは走査した全一致の総数と ファイル別の件数 (多い順、MAX_COUNT_FILES=20 まで)。表示件数を総数として 返すと「何件あるか」を読み手が誤り、確かめるための再検索が周を食う — 実害は failures.md #55 (5 体が同じ見出しの行番号を別々に報告した)。 数え続けても最悪コストは変わらない (一致ゼロの grep は今でも全走査する) - **grep は count_only: true で集計モードになる** (2026-08-04)。一致行を 返さず総数とファイル別の件数だけを返す。「何件あるか」は「どの行か」とは 別の問いで、後者の形式で前者に答えると 100 行のノイズが毎回付いてくる - **grep の include / context (Spec 16 で凍結、2026-08-05)**: 凍結 5 点: 1. **include はファイル名 (パスの最後の要素) に対する Rust regex**。 パス全体ではない (fd と同じ規律)。glob ではない = `*.rs` ではなく `\.rs$`。**glob を採らないのは「パターンは Rust regex 構文」を 1 つのツールの中で割らないため** — pattern が regex で include が glob だと書く側が必ず取り違える。size_limit は pattern と同じ 1 MiB。 **予測できる誤り (`*` 始まり / `*.` を含む) はコンパイル失敗するので、 glob 形を名指しした文面を返す** (空振りで返すとモデルはパターンの側を 疑って再検索し周が増える)。**大文字小文字は case_insensitive に従う** = fd の「名前検索は既定で無視」とは逆。**この非対称は意図的**で、 1 回の呼び出しの中で pattern と include が別の既定を持たないため 2. **context は 0〜3、既定 0。範囲外はエラーで、clamp しない**。 丸めると指定した値と効いた値が食い違ったまま出力が返る 3. **一致行は `:`、文脈行は `-`、ブロック区切りは `--`**。書式で区別する。 区別が付かないと**文脈行を一致行として報告する** (#55 の行単位版)。 **`--` は context > 0 のとき、同じファイル内で離れた窓の間だけ**出す。 ファイルが変わるところでは出さない (パスが行頭にあり既に区切れている)。 **窓の畳みも同じファイル内だけ** (跨ぐと行番号の意味が変わる) 4. **MAX_MATCHES は一致行だけを数える。文脈行は MAX_OUTPUT_CHARS だけを 食う**。total_matches は context の値に関係なく同じ。 **文脈行にも MAX_LINE_CHARS (240 字) のクリップを掛ける**。 **打ち切りは窓の境界で行い、ブロックの途中で切らない** (Spec 17 D3 と同じ — 切ってよいのは一覧であってブロックの中身ではない) 6. **include を使った呼び出しは結果に関わらず `grep include:` を 1 行残す** (2026-08-05 追加)。`agent` / `pattern` / `outcome` (ok | glob | invalid)。**include を省いた呼び出しでは 1 行も増えない**。 理由: `tool:` 行は args_chars しか書かないので**引数の中身がどの計器にも 載っておらず**、D1 の賭け (モデルは `*.rs` と書くが名指しの拒否を読んで 直せる) が当たったかを実機のログから読めなかった。 `run decision:` と同じ判断 — **機構が動いているかどうかは、動いた記録が 無ければ確かめられない** (failures.md #58 の同型) 5. **打ち切り文は種別ごとに分ける**。表示の打ち切り (MAX_MATCHES / MAX_OUTPUT_CHARS) は include / path / パターン具体化 / count_only を案内し、context > 0 なら「context を下げると表示できる 一致が増える」も書く。**走査の打ち切り (MAX_FILES) には include を 出さない** — **include は walk を減らさず read を減らすだけ**なので、 案内すると**直らない方法を試させる**ことになる (#44 の変種。次の手が「あるが効かない」形) - **打ち切り文は「何が起きたか」と「次に何をするか」を両方書く。** 落とした量だけを書くと、モデルに残る唯一の選択肢が同じ呼び出しの反復に なり、RepeatGuard に止められてターンごと落ちる (failures.md #44)。 続きを取る経路が無い打ち切りでは「読み直しても同じ範囲が返る」ことまで 明示する — 代替を示すだけでは「もう一度呼べば残りが来るかも」が残る - 隠しディレクトリ (.git 等) と定番のビルド出力 (node_modules / target / dist / build / out / vendor) は走査しない。バイナリ (先頭 4 KiB に NUL) は読まない - 走査は compute::spawn_rayon で Rayon 側へ逃がす (Tokio ワーカーを 塞がない。concurrency の規約どおり) - grep / fd / sd のパターンは Rust regex 構文。コンパイル失敗は読める文字列で返す - fd の一致対象は**名前 (パスの最後の要素) だけ**。相対パス全体に掛けると 一致フォルダの配下が道連れでヒットし一覧がノイズで埋まる。フォルダは 末尾 `/` で示す。大文字小文字の既定は無視 (grep と逆 — 名前検索は 表記揺れが本質的に多く、厳密一致既定は空振り→再試行の往復を増やす) - diff は unified diff (context 3 行)、similar crate。同一内容なら 「同一です」と言う (空文字を返すと成功か失敗か区別できない) - 依存の追加は regex / similar / toml_edit の 3 つ (すべて純 Rust。 toml_edit は書き換え系 yq 用。yaml-edit は PoC で棄却し不採用 — write_tools_contract 参照) write_tools_contract: | 書き換え系 sd / yq = Spec 01 rev2 承認に基づく契約 (2026-07-29 凍結)。 **Spec 55 (2026-09-17 凍結・同日 P3 で着地済み) の囲い**: sd の apply と yq の書き込み op は、対象が `blackboard/` の下なら拒否する (sd の preview と yq の読み取り op は通す)。判定と文面は file と同じ 1 実装 (blackboard_contract の凍結 9)。 初の書き込み能力であり、被害クラスを「漏洩」から「改竄」へ広げるため、 読み取り系より厳しい不変条件を敷く。 不変条件: - **二段階実行** (書き込み op のみ。yq get 等の読み取り op は対象外): 既定は preview — 適用結果の unified diff を返すだけで書かない。 apply: true を明示したときだけ書き込み、そのときも適用した diff を 「適用済み」の明示とともに応答へ含める。黙って書く経路は存在しない - **境界**: 書き込み先も resolve_in_work_dir (canonicalize + 前方一致、 symlink 不追従) を通す。読める範囲を超えて書き込み範囲を広げない。 ツール側の追加制限は許す (yq の additional_restriction: extension — .toml / .json 以外は拒否) - **diff 上限は切り詰めでなく拒否**: 適用結果の diff が 12,000 字を 超える書き込みは preview / apply とも実行しない。変更統計 (件数・ 行数) と「対象を絞る」案内を返す。読み取り系の「切り詰めて明示」と 意図的に異なる例外 — 切り詰めた diff を許すと「何が変わったかが 会話に残る」契約が崩れるため、上限超の書き込みはそもそも起こせない 形にする - **1 呼び出し 1 ファイル (書き込み)**: apply は 1 呼び出しで 1 ファイルしか 書き換えない。インジェクション 1 発での改竄範囲を 1 ファイルに閉じる。 **2026-08-05 改訂 (Spec 17)**: 旧文は「複数ファイル一括置換は提供しない」で preview まで禁じていると読めたが、**この不変条件の理由 (改竄範囲) は 書き込みにしか掛からない**。preview は複数へ広げた (下記 batch preview)。 **書き込みの範囲は 1 文字も緩めていない** - **batch preview (Spec 17 で凍結、2026-08-05)**: sd に paths (String[]) を 足し、**preview だけ**を複数ファイルへ広げる。凍結 8 点: 1. **paths は preview 専用**。apply: true との同時指定は拒否 (paths に書き込みの経路は存在しない — 検査で塞ぐのではなく道を作らない) 2. **paths は明示列挙のみ**。glob も正規表現も受けない。ファイルの発見は grep / fd の仕事で、sd に持たせると同じ仕事が 2 箇所に生える。 明示列挙なら**対象集合が呼び出しの中に literal で残る** = preview を読む人も fuseforks.log も RepeatGuard も対象を正確に数えられる 3. **path と paths の同時指定は拒否。両方無し・空配列も拒否** (「対象 0 件」を成功として返すと一致ゼロと区別が付かない) 4. **枠の扱いは 3 段。diff は 1 本も切り詰めない**: paths の件数 20 超 = **走らせずに拒否** (対象集合が意図より広い) / 1 ファイルの diff 単体が 12,000 字超 = **そのファイルだけ統計を返す** (既存の単一経路と同一文言) / 束ねた合計が 12,000 字に達した = **ファイル境界で一覧を止める** (chunk の途中では絶対に切らない)。 **切っているのはファイルの一覧であって diff の中身ではない** — 上の「diff 上限は切り詰めでなく拒否」を preview の束ねでも保つ。 **読み取り系の「切り詰めて明示」を書き換え系へ持ち込まない** (規律は「どの契約の節に書いたか」で射程が決まる = failures.md #58) 5. **resolve 段 (実在しない・外) の失敗は呼び出し全体を拒否**。 それ以降の段 (フォルダ・サイズ・バイナリ・非 UTF-8) は **そのファイルだけ理由付きで飛ばして走行を続ける**。 resolve を分けないのは実装の都合ではなく規律 — resolve_in_work_dir は canonicalize 成功後に前方一致で外を判定するので **実在しない外側パスは「無い」として返り「外」と区別できない** (戻りが Result<_, String>)。まとめて拒否すれば区別が要らなくなる。 paths に実在しないパスが混じるのは**モデルが取り違えた印**で、 黙って飛ばすとその 1 件は直されないまま残る 6. **重複は正規化パス (resolve の戻り) で畳む。表示も正規化された相対パス** (単一経路と同じ)。順序は **resolve → 重複除去 → 件数検査** (逆だと 重複を並べるだけで上限に当てられる。実体 20 件なら通すのが正しい)。 **並べ替えない** (モデルが書いた順序と preview の順序を食い違わせない)。 **2026-08-05 訂正 (Spec 17 P1)**: 起票時は「表示は初出の表記」と書いたが、 `resolve_in_work_dir` が `./a.md` も `a.md` も同じ `a.md` へ正規化するので **初出かどうかで差が出ず、観測できない規則だった**。 **規則を凍結する前に「その差が観測できるか」を確かめる** 7. **一致ゼロもスキップも必ず名前と理由を列挙する**。全件がそうなった 場合も同じ形で返す — ヘッダだけだと「何も起きなかった」と読まれるが、 **10 件渡して 10 件ともバイナリだったのは「実行された」**。 **束ねの見出しは `===` で始める。`---` は使えない** — unified diff のヘッダ行が `--- {ファイル名}` なので、同じ前置にすると **束ねの見出しと diff のヘッダが区別できない** (P1 の実装中に露見) 8. **preview の複数対応は apply の前例ではない**。 **yq は複数対応しない** — アンケートで yq に出た要望は op: "check" だけで 一括編集の要望は無い。**対称性の欠如は意図的**であり追従漏れではない - **新規ファイル作成なし**: canonicalize (実在パスのみ成功) + is_file() の二重で担保。ディレクトリ指定も拒否 - **同文不書き込み**: 適用しても内容が変わらないなら「変更なし」を 返し書かない (sd の同文置換・yq の同値 set とも。無意味な mtime 更新とフォーマッタ由来の空白差分を作らない) - **検査順序の固定** (エラーメッセージを安定させる): 1 境界解決 → 2 is_file → 3 拡張子 (yq のみ) → 4 サイズ 2 MiB → 5 バイナリ → 6 内容の解釈 (regex コンパイル / パース)。 最初に失敗した段の文言だけを返す - **sd**: replacement は $1 / $name のキャプチャ参照可、リテラル $ は $$ (regex crate の仕様)。インラインフラグ ((?i) 等) はパターン側が case_insensitive 引数より優先 (regex crate の仕様どおり。grep も同挙動) - **yq の set は対称規則 (型破壊の禁止)**: 既存のスカラー値を スカラー値で置き換える操作に限る。コンテナ (テーブル / マップ / 配列) への set・JSON に写像できない型 (TOML/YAML の日時等) への set・存在しない中間キーへの set (静かな新構造の生成) は拒否。 スカラー間の型変化 (数値→文字列等) は許す — diff に見える形で残る - **yq の key** は a.b[0].c 形式のみ (フル yq 式は受けない)。v1 の キーは [A-Za-z0-9_-]+ とインデックス [数字] のみ。クォートキーは 読める文言で拒否 - **yq のフォーマット保持**: TOML = 完全保持 (toml_edit。値の decor を 引き継ぎ、行末コメントも残る)、JSON = キー順保持・整形は正規化される (コメント概念なし。対象行以外の diff は契約どおり) - **YAML は非対応** (2026-07-29 PoC で yaml-edit 0.2.3 を棄却): 先頭コメント消失 / 数値の文字列化 (型破壊) / set_path の構造破壊 / clone 間の変更共有、の 4 点。手動ナビゲーションで回避できない欠陥が 含まれるため、「対応していない形式」と正直に返す縮退を選択 (Spec 01 Phase 4 の PoC 記録)。信頼できる crate が現れたら再訪 - **TOML に null は書けない** (TOML の仕様に null が無い)。null の set は remove を案内する読める文言で拒否 - **UTF-8 でないファイルは編集拒否** (書き換え系のみ)。lossy 変換で 読んで書き戻すと非 UTF-8 部分が化けたまま固定される = 改竄になる - **不在の文言は `file` ツールへ案内する** (Spec 09 で追加)。素の 「見つかりません」だけだと、モデルは綴りの問題と解釈して同じ操作を 繰り返す (実機で 12 ラウンド空転した)。**この 2 つは書き換え専用で 新規作成はできない**と明示し、作るなら file の write へ送る file_tool_contract: | ファイル・フォルダ操作 file = Spec 09 (2026-07-31 凍結)。 **Spec 55 (2026-09-17 凍結・同日 P3 で着地済み) の囲い**: write / append / mkdir / remove は対象が `blackboard/` の下なら拒否、move は元か宛先のどちらかが下なら拒否、copy は**宛先が下の ときだけ**拒否 (外への copy は読み取り)。read は塞がない。判定は解決後のパスの 1 実装で、 拒否の文面は blackboard ツールを名指しする (blackboard_contract の凍結 9)。 動機は実機の空転 — README の英訳を頼んだが道具立てに**新規作成が無く**、 エージェントが sd で延々と試み続けた。禁止ではなく**その行動を選ぶ理由を 消す**情報を与えるのが処方 (判断材料の欠落型。failures.md #20 と同型)。 形: **1 ツール + op の閉じた列挙** (yq の踏襲)。操作ごとにツールを並べると 毎ターンのスキーマ固定費が倍々になり、モデルの選択も散る。 op と引数: read { path } # 中身を返す (bundled と同じ上限) write { path, content, overwrite? } # 既存は overwrite: true が無い限り拒否 append { path, content } # 末尾へ継ぎ足す (rev2 で追加) mkdir { path } # create_dir_all (中間も作る) move { path, to } # 移動・改名。両端とも境界内 copy { path, to } # 複製。両端とも境界内 remove { path } # **OS のごみ箱へ移す** (trash crate) 不変条件: - **境界は 2 種類ある。混ぜない。** 既存の resolve_in_work_dir = 実在するパス専用 (canonicalize + 前方一致)。 **sd / yq はこちらを使い続ける** — その canonicalize が 「新規ファイル作成なし」(write_tools_contract) を構造的に担保しており、 file が別の解決子を持つことでこの担保は**弱まらない**。 新設 resolve_creatable = 実在しない宛先用。3 段で検査する: 1. 宛先の**実在する最も深い祖先**を canonicalize し前方一致で検査 2. 祖先から宛先までの残り成分に `..` と絶対パス成分が無いこと (canonicalize できない区間は文字列検査しか手が無い。`..` を許すと 祖先の検査をすり抜けて外へ出られる) 3. symlink は辿らない (リンクを踏むと囲いが破れる。既存と同じ) - **write の上書きは明示ゲート**: 既存ファイルへの write は overwrite: true が無ければ拒否し、部分編集は sd / yq、丸ごと置換は overwrite への案内を返す。 preview は持たない — 新規作成は壊すものが無く、上書きはこのゲートが 同じ役割を果たす (二段階実行を 2 箇所に持たない) - **削除はごみ箱のみ。完全削除の経路を作らない。** エージェントの操作は 取り消せる側へ倒す。ごみ箱が使えない環境では**失敗として返す** — 黙って完全削除へフォールバックしない (縮退の方向を安全側に固定) - **move / copy は両端を検査する。** 移動元は実在必須 (resolve_in_work_dir)、 宛先は resolve_creatable。片端だけの検査は囲いの穴になる - 読み取りの上限は bundled_tools_contract と共有 (1 ファイル 2 MiB / 出力 12,000 字 / バイナリ拒否)。write の content にも同じ上限を掛ける - **read に offset / range は持たない。** 続きを取る引数が無い以上、 12,000 字を超えるファイルの打ち切り文は「続きを読む方法はありません」と 「代わりに grep を使う」の両方を書く (failures.md #44。実機で 61,891 字の data_contract.yaml を読ませて発生)。offset を足さないのは、grep で既に 書けるうえ「全部読む」を助長して入力トークンを増やす側に効くため (prompt_cache で入力を安くした方向と逆向きに働く) - **append を持つ** (rev2、2026-07-31)。起票時は「read → write で表現できる」 として見送ったが、**実測でその表現が成立しないと分かった** — write は毎回全文を運ぶので、800 行を 100 行ずつ 8 回に割っても最後の write は 800 行を 1 応答で出す必要があり、**出力上限の天井が下がらない**。 累計出力も元の 4.5 倍 (100+200+…+800)。分割は「重くなる」のではなく **成立しない**、が発火の根拠 (failures.md #40 の続き)。 不在なら新規作成として扱い、上書きゲートは掛けない (追記は既存を壊さない)。 上限は**追記後の大きさ**で見る (1 回ぶんだけ見ると小さな追記の反復で 無制限に太らせられる) - 失敗はすべて Ok(String) でモデルへ返す (ツールの失敗で会話を止めない)。 作業フォルダ未設定は work_dir_missing() の共通文言 - 依存の追加は **trash のみ**。本体は std::fs で足りる (create_dir_all / rename / copy / read / write)。検討して不採用: walkdir・ignore (探索は fd / grep が自前で持つ)、tempfile (tmp + rename は save_world の自前 パターンで足り、本ツールの write に原子性の要求が無い)、fs-err (パス埋め込みは自前 format で既にやっている) command_tool_contract: | 同梱 run = エージェント別 allow / deny によるコマンド実行 (Spec 15 rev4、2026-08-04 凍結。**rev3 の登録制から利用者裁定で変更**)。 **本節は実装より先に凍結した契約で、着地は P1'〜P4'。** 動機: 同梱ツールは読む系 (grep / fd / diff / file read) と書く系 (sd / yq / file write) で閉じており、**書いたものを走らせて確かめる経路が 無かった** — エージェントがコードを直しても、テストが通るかを自分で確かめられない。 **rev3 から撤回した凍結が 4 点ある** (撤回したことをここに書く。書かないと 次に読む人は生きている凍結だと読む — failures.md #56 の逆向きの事故): - 「閉じた許容。登録済みのコマンドだけが走る」→ **撤回**。allow はパターンで、 `python *` を許せば任意コード実行が通る - 「除外リストは作らない」→ **撤回**。deny を持つ - 「エージェントは実行ファイル名を書けない」→ **撤回**。command を書く - 「program は登録時に解決した絶対パス / fail closed が構造で成立」→ **撤回**。 実行ごとに PATH で解決する (代わりに解決結果を毎回見せる。下記) **2026-07-31 の利用者判断 (Hook 登録制 = 閉じた許容) も同時に覆っている。** 覆したのは判断を持っている本人で、rev1 が気づかずに反転して拒否されたのとは 経緯が違う。README の「意図的に未実装」の該当行も P5 で書き換える。 不変条件: - **3 状態**: allow に一致 = 承認なしで即実行 / deny に一致 = 拒否し **承認要求を積まない** / **どちらにも無い** = 拒否して pending へ積む。 **deny が allow に優先する** - **deny で承認要求を積まないのは仕様** (利用者裁定)。一度「要らない」と決めた ものが毎回画面へ並ぶと、**承認の一覧がノイズで埋まり、本当に判断すべき 新規の要求が埋もれる** - **シェルを介さない** (rev1 から一貫し、allow/deny でも 1 文字も弱まらない)。 エージェントは command + args 配列を書き、**照合も argv 配列に対して行う**ので `&&` も `|` も `$(...)` も**構造的に存在しない**。stdin は持たない (任意文字列を渡せると sh -c 相当のインジェクションになる) - **パターン構文は 2 つだけ**: 完全一致 (`"ruff"` は args が空のときだけ一致) と 末尾 `*` (`"ruff check *"`)。**`*` は末尾にしか置けない**。 中間のワイルドカードを持たないのは、`*` が何個の引数に対応するかの規則が要り、 **書いた人の意図と照合結果がずれる場所が増える**ため。 **`"ruff"` は引数なしにしか一致しない — 任意引数を許すなら `"ruff *"`。** この差を UI と拒否文面の両方に書く (書かないと「ruff を許可したのに ruff check が通らない」で必ず躓く) - **照合は argv 配列。** パターンを空白で分割してトークン列にし、 `[command, ...args]` と要素ごとに比較する。**引数の中の空白は分割されない** (`args: ["a b"]` は 1 トークン)。シェルの引用規則を持ち込まないための帰結 - **正規化は 3 段** (rev1 査読の MAJOR 6 が指摘し、登録制では消えていた問題): (1) パス区切り (`/` `\` `:`) を含む command は**照合前に拒否** (`./python` と `python` を別物として数えると allow が指す対象が曖昧になる) (2) 比較は**小文字化して**行う (3) Windows では PATHEXT の拡張子を **剥がしてから**比較する (python と python.exe を別物にしない) - **解決したフルパスを結果本文とログに必ず出す** (rev1 査読の CRITICAL 1)。 `allow: ["python"]` がどのバイナリを走らせたかはその時の PATH で変わるので、 **「構造的に決まる」とは言わない。代わりに決まった結果を毎回見せる**。 resolve_program は毎回呼び、**キャッシュしない** (PATH を直したら 次の実行から変わってほしい) - **置き場は agents/{id}/run.json** (ConfigFileKind::Run)。 **ファイル名は設定する対象 (run ツール) の名前をそのまま採る** — mcp.json と同じ規則 (2026-08-05 改名)。旧名 shell.json は「実行はシェルを 介さない」という弁解を doc コメントに必要としており、**弁解が要る名前は 間違っている**という判断で降ろした。機構は command + args 配列のまま。 村で 1 つ共有しない。`{ version, allow[], deny[], pending[], timeoutSecs }`。 **pending も同じファイルの中**に置く — 別ファイルにすると「見る場所」と 「直す場所」が分かれる。**Spec 20 の承認画面は同じ理由で成り立つ** — 1 ファイルに揃っているから、画面は「消して足す」差分 1 つで書ける - **人と機械が同じファイルを書く**。エージェントが pending を足すので、 **全文上書きにしない** — read → pending だけ差分適用 → write_atomic を 3 回までリトライ。人の手編集で allow / deny / timeoutSecs が変わっていても **その変更を壊さない**。競合したら WARN 1 行。 **mcp.json は人しか書かないので無かった形** - **pending は 1 体あたり 20 件、押し出しは firstRequestedAtMs の古い順**。 同一の (command, args) は count++ で畳み、firstRequestedAtMs は最初のまま - **timeoutSecs はファイル 1 つに 1 つ** (既定 60 / 上限 3600)。 パターンごとに持たせると**対応表がもう 1 つ増え**「書いたとおりに効く」を壊す。 将来必要なら timeoutOverrides を足す余地はある (今は作らない) - **不正 JSON は書き込み時に拒否** (mcp.json と同じ扱い)。 **読みの扱いは経路で分ける** (2026-08-05 実測で訂正): - **照合のための読みは既定へ落としてよい** (`RunTool::load`)。allow も deny も 同時に消えるので**全部 pending = fail closed** にしかならない。 旧記述の「deny が黙って消えるので危険側」は**片方だけ消える前提**で書かれて おり、実際は両方消えるので成立しない - **書き戻す経路では既定へ落とさない** (`read_command_policy` は Err)。 **危険なのは読みではなく、落とした既定を書くこと** — 実測で人が書いた `allow: ["git log *"]` と `deny: ["rm *"]` が両方消えた (failures.md #70)。 壊れたファイルは 1 バイトも変えずに残す (人が直せる状態を保つ) - **run.json を持たない個体は「全部 pending」**。**これが fail closed** — allow に一致しない呼び出しは 1 つも走らず、要求として積まれるだけ。 **初回の手間は欠点ではなく仕様**(1 件ずつ人が許容を決める)。 **ただし「手間」は承認ボタンであって JSON の手書きではない** (2026-08-06 利用者裁定)。 **旧記述「allow が空なら提示されず実行もできない」は撤回した** — この節は同じ文の中で矛盾していた。提示しなければ呼び出しは 実行フィルタ (orchestrator の executable) で弾かれ、 **note_pending に到達しないので「全部 pending」が成立しない**。 実際に閉じた輪になっていた: allow 空 → 非提示 → 呼べない → pending 空 → 承認画面に何も出ない (resolve は pending に無いものを NotFound で拒む = D9) → allow は空のまま。 **1 件目だけは人が JSON を手で書くしかなかった** - **末尾 `*` は 0 個以上の引数に一致する** (2026-08-05 実測で固定)。 `allow: ["ruff *"]` は引数なしの `ruff` にも当たる。**開いたパターンが 完全一致のスーパーセットである**ことは承認の 2 択 (下記) の前提で、 1 個以上にすると「`ruff *` を承認したのに `ruff` が通らない」が起きる。 **`*` 単独のパターンは何にも一致させない** — 通せば開いた許容になるうえ、 旧実装は `&parts[1..0]` で panic した (failures.md #69) ## 承認の画面 (Spec 20。2026-08-05 rev2 承認 → P0 で本節を凍結) pending を画面から allow / deny へ動かす経路。**取り除くのは JSON を手で 書き写す手間であって、人が 1 件ずつ許容を決める手間ではない** — 後者は閉じた許容の本体なので残す (Spec 15 D10 の「初回の手間は仕様」は 取り下げていない)。 - **粒度は機械が決めない。承認時に 2 択**: 完全一致 (`git log --oneline -5`) と 末尾 `*` (`git log *` = 第 1 引数までを固定)。**既定は完全一致 (狭いほう)** — 広いほうを既定にすると押し慣れた指が毎回広い許容を作る。 引数なしの pending を開くと `ruff *` - **文字列化はコアの `PendingCommand::pattern(open: bool)` ただ 1 箇所**。 **IPC が受け取るのは `open: bool` まで**で、パターン文字列を外から渡す口を 作らない — 文字列を受けると「粒度は機械が決めない」が **「粒度を GUI が何でも決められる」へ反転**し、`*` 1 文字も送れてしまう - **「第 1 引数までを固定」は当てずっぽう**である (`ls -la /tmp` は旗を固定する)。 **隠さないための処方は、選んだ結果の文字列を画面へそのまま出すこと**と、 細かい粒度を run.json の直接編集へ逃がすこと。編集経路は残す - **却下は deny へ入れる。pending から消すだけにしない** — 消すだけだと 次の呼び出しでまた積まれ、同じものを何度も却下することになる - **pending に該当が無ければ承認も却下もしない** (`ApprovalOutcome::NotFound`)。 承認画面は**実際に要求されたものだけ**を許容へ変える装置で、そこが緩むと 閉じた許容が「GUI から任意に書ける許容」になる。押し出しや積み替えで 消えていた場合は**黙って何もしない、にはしない** — 画面まで運んで 「もう一覧にありません」と告げる (failures.md #44 の同型) - **書き込みは read → 差分適用 → 3 回リトライ**(上の「全文上書きにしない」と 同じ作法)。**`merge_pending_into` は流用できない。方向が逆** — あれは機械の pending を人の編集へ勝たせるが、承認では**人が消した pending が 勝たねばならない**。本番の呼び出し元がゼロなので**撤去する** - **「全部承認」は作らない**。まとめて承認は粒度の決定を飛ばすので、 閉じた許容の本体が消える - **pending に残ってよいのは、いま判定して Unknown のものだけ** (2026-08-05 実機の指摘)。広いパターンで 1 件承認すると、その allow 行が **別の判断待ちを覆う**が、覆われた側は誰も消さないので残り続けていた。 判定は既存の decide をそのまま使う — **「一覧に出るべきか」と 「実行してよいか」は同じ問いの裏表**なので、別の述語を置くと 2 つの答えがずれる。人が run.json を手編集して allow を足した場合も、 次に run が呼ばれた時点で掃除される (一覧が自己修復する) - **判断待ちの引き直しは 3 経路** (2026-08-05 実機の指摘): 起動時 / 承認・却下のあと / **run が呼ばれたとき**。3 つ目が無いと、サーヴァントが 積んでもバッジが動かず、**「開かなくても気づける」ために置いたバッジが 開くまで気づけない**状態になる。run の呼び出しは判断待ちが増える **唯一の**経路なので、そこだけ見れば足りる (refreshAll には混ぜない — あらゆる操作のたびに全個体の run.json を読むのは払いすぎ) - **入口はタイトルバー**(村の内容物であってアプリの設定ではない。 settings_contract の分類には入れない)。**バッジは村の合計** `sum(policy.pending.len())`。0 件でも入口は残す - **承認で allow の 1 件目が入った瞬間、そのサーヴァントは次のターンから 実際にコマンドを実行できるようになる**。**提示はその前から起きている** (2026-08-06 の撤回。旧記述「2 段ゲートの 2 段目が開く」は誤り — 提示が承認を待つと、要求が積めず承認する対象が生まれない)。画面で告げる - **未入力のときは UI がひな型を出す** (2026-08-04 利用者要望)。 mcp.json / run.json とも**外殻だけで中身は空**。ひな型に例を入れないのは、 **消し忘れがそのまま許可になる**ため (allow に "ruff *" が入ったひな型は、 押しただけで ruff を許した村を作る)。ボタンは**空のときしか出さない** — 既に書かれているところへ出すと、押した結果が上書きか追記か読めない。 入れるのはテキストエリアまでで、**保存はしない** (中身を見てから人が押す) - **提示を決めるのは enabledTools に run があるかの 1 段だけ**【既定 OFF】。 **2 段目 (allow が 1 件以上あるか) は 2026-08-06 の利用者裁定で撤回した** — 上の「全部 pending」を参照。**fail closed は提示ではなく decide が守る** (提示しても allow に無い呼び出しは 1 つも走らない)。 **allow が空のときの説明文は「この道具では何も実行できない」と言い切り、 パターンの箇条書きを出さない** — 提示する以上、いま何ができないかを 本文で言わないと、モデルは実行できるつもりで呼ぶ。 回帰 2 段で留めた (`tools::run` の単体 + orchestrator の結合) — **決定を文章だけで残すと、次に読む人が fail closed のつもりで 2 段目を足す** (Spec 18 D13 と同じ形)。 **モデルへは allow のパターンだけを列挙し、 deny は見せない** — 見せると禁止の一覧を毎ターン積むことになり、 **トークンを払って禁止の方法を教える**形になる。 **run は WORK_DIR_TOOL_NAMES に入れない** (あの集合は「その中しか触らない」 ツールの集合で、境界を破る run を入れると集合の不変条件が壊れる) - **run は既定 OFF**。DEFAULT_ENABLED_TOOLS の外に置き、 `BUNDLED_TOOL_NAMES = DEFAULT_ENABLED_TOOLS ∪ {run}`。 **更新しただけで実行能力が増える作りにしない** (batch_start_invariant の「開いただけで課金が始まる作りにしない」と同じ形) - **出力は MAX_OUTPUT_CHARS = 12,000 字を上限とし、run では stderr 4,000 / stdout 8,000 の固定枠に分割して融通しない。** 融通すると stderr の長さで stdout の打ち切り位置が変わり、 **RepeatGuard の完全一致が壊れる**。打ち切り文は #55 の規律で総量を書き、 **続きを取る経路が無いこと**まで書く (再実行しても同じ出力とは限らない) - **プロセスは木ごと kill する** (command-group)。tokio::process::Child::kill は 対象 PID にしか効かず**孫が残る**。このクレートは unsafe_code = "forbid" なので Job Object / setsid を自前で書けない (trash と同じ判断)。 タイムアウトと Spec 10 の打ち切りの両方に掛かる。 **どちらの経路でも kill を必ず呼ぶ** — 2026-08-12 まで呼び出しが 1 つも 無く、`group_spawn` で殺せる形に作ってあるのに殺していなかった。 実機で `ssh` が打ち切り後も 5 分生き残り、`Established` のまま残った。 **`wait_with_output` は self を取るので使えない** (打ち切り時に `child.kill()` を呼ぶ手が残らない)。パイプは別タスクで並行に汲み、 `child.wait()` で待って、走り切らなかったときに kill する。 **経過時間の assertion がプロセス木を落とした証拠になる** — 落とせていないと汲み取りが子の自然終了まで返らない (ミューテーションで 0.2 秒 → 30.4 秒) - **非ゼロ終了は Err にしない。** 終了コード + stdout + stderr を Ok(String) で 返す (failures.md #41 の一般化 3) - **環境変数は env_clear してから名前を指定して親の値をコピー** (PATH / SYSTEMROOT / TEMP / TMP / HOME / USERPROFILE / LANG / PROGRAMDATA)。 **PATH を渡す以上、子プロセスは端末上の任意の実行ファイルへ届く** — 可用性のための選択であって安全のためではない。 **数ではなく列挙で書く** — 総数を併記すると増えたときに 2 箇所で嘘になる (#67) - **PROGRAMDATA は 2026-08-12 に足した。可用性のためで、境界は動かない。** Windows OpenSSH (`C:\Windows\System32\OpenSSH\ssh.exe`) は **これが環境ブロックに無いと起動直後に死ぬ** — `-V` (版を出すだけで 接続も設定読みもしない) ですら**出力ゼロで exit 255**。13 個の候補を 1 つずつ足して通ったのはこれだけ。**値は何でもよい** (存在しないパスでも 空文字でも通るので `%ProgramData%\ssh\ssh_config` を読むからではない。 その端末にそのファイルは無かった)。**規則は確定・機序は未確定。** 綴りは大小どれでも通る (Windows の環境ブロックは参照が大小を区別しない)。 **Git 版の ssh.exe は無くても動く**ので、**どちらが PATH で先に来るかで 症状が出たり出なかったりする** - **run は安全機構を増やさない。** 次の 4 点を契約と UI の両方に書く: (a) **作業フォルダ境界は run に効かない** (子プロセスは絶対パスで外を読める) (b) **file remove のごみ箱限定も意味を失う** (allow に入れたコマンドが rm を 含めば完全削除が走る) (c) **環境変数の遮断は境界ではない** (~/.aws/credentials はファイル経路で読める) (d) **deny は網羅できない** (`rm` を弾いても `python -c` が残る)。 **deny は「うっかり」を減らす道具であって、敵対的な入力を止める壁ではない** — deny をセキュリティ機構として説明しないことが、この機構を持つ条件 tool_reason_contract: | ツール呼び出しの理由表示 = モデルが 1 行で書く意図を画面へ出す (Spec 27 rev3、2026-08-07 凍結)。 **本節は実装より先に凍結した契約で、着地は P1〜P4。** 動機: 会話ペインに出ているのは**ツール名と成否だけ**で、CoreEvent::ToolInvoked は { agent_id, tool, ok } の 3 欄しか運んでいない。この欄は「エージェントが 何をしたかは会話ログに現れない」を埋めるために置かれたのに、 **置いた目的に対して運んでいる情報が足りていない**。 公開に向けた要件 (利用者「他のユーザーは安心できるから必須」)。 実測 (2026-08-07、concordia.log の 969 呼び出し / 355 ターン。 **本節の判断はここから出ている。「安いから入れる」ではなく、どこが高いかまで 分かったうえで入れる**): - ツール呼び出しは **2.73 本 / ターン** - 出力トークンは中央 900 / ターン、実効は中央 14,936 - 出力 ×4 が実効に占める割合は cached=0 で 19.3% / キャッシュ 50% 以上で 28.8% - 理由 30 字 ≈ 25 出力トークンとすると**実効 +1.5〜2.2%**。 **最悪の 2 トークン/字で見積もっても +4〜5%** で、換算の仮定に頑健 - **「30 字」は実測ではなく仮定。** まだ存在しない文字列の長さは測れない。 実測に置き換えるのは P4 の検収 5 (reason_chars の分布) - **費用は 2 種類あり、混ぜて書かない**: (a) スキーマの定義 = 全員・毎ターン だが**誤差** (安定プレフィックスなのでキャッシュに載り実効 ×0.1) / (b) 理由の本文 = **+1.5〜2.2% だがツールを呼んだターンだけ**。 混ぜると既定 ON の是非を判断できなくなる 不変条件: - **理由はモデルの自己申告であって監査証跡ではない。** 理由が正しいことは 誰も保証しない (failures.md #84 — **引用できることは従うことの証拠にならない**)。 書かないと「安心のための表示」が事実の記録だと読まれる - **ok も監査証跡ではない。** ok は**ツールの返り値が Ok かどうか**であって **副作用が成功したかではない** — 同梱ツールは失敗を Err ではなく Ok(<エラー文>) で返すので (#41)、**ok=true のまま失敗している行が常態**。 UI のラベルは「成功 / 失敗」ではなく**「返った / エラーで返った」**。 理由は「意図」として成否と視覚的に分ける - **対象の基準は「引数が既に別の形で画面へ出ているか」。** 出ているなら 理由は重複、出ていないなら理由が唯一の手掛かり。 **総数を書かず名前で列挙する** (#67): 入れる = grep / file / fd / run / sd / yq / remember / rag / room_log。 入れない = ask_* / handoff_* / plan (**引数が発話として会話ペインに出る** — chatRows.ts が from.kind === "agent" の行を描く)。 入れない = McpTool 由来 (**他人のスキーマ**。こちらが宣言していない欄を 生やして転送すると additionalProperties: false のサーバーが拒否する) - **remember は境界例ではなく対象の中心。** AgentMessage を 1 通も生まないので 画面に出ず、しかも**書いた内容が以後の全ターンのプロンプトに残る**。 **カバレッジを呼び出し回数で測らない** — fd 136 回は読むだけで痕跡を 残さないが remember 26 回は Memory.md へ書く。 **静かで重い副作用ほど、回数で測ると軽く見える** - **新しい自前ツールは既定で対象。** 外すのは発話を生むツールを足したときだけ。 **例外リストは 3 種だけ** - **上限 60 字。手順はトリム → 数える → 切り詰め。** 切り詰めは **59 字 + `…` で最終長がちょうど 60 字**。数えるのは chars().count() (**len() で実装すると日本語 20 字で切れ、テストが ASCII だけなら通る** — Spec 16 / Spec 17 の査読で 2 回踏んだ形) - **超過は拒否せず切り詰める。** 表示物であって実行の前提ではない。 拒否すると**理由を長く書いた個体のツール呼び出しが落ちる** = 表示のための機能が実行を壊す。**書き換え系の「上限超は拒否」(sd の diff) とは 別の契約** — あちらは*出力の完全性*、こちらは*表示の見た目* (failures.md #58) - **reason_chars はトリム後・切り詰め前。** 切り詰め後を出すと 「モデルが上限を超えて書くか」が**全部 60 に貼り付いて測れなくなる**。 トリムを先に置くのは、**前後の空白付きの秘密が字数として漏れない**ようにするため - **required に入れない。未記入でも実行は通る。** 埋めない個体の呼び出しが バリデーションで落ちると、**理由を出すための機能がツールを使えなくする**。 **行は消さない** (消すとツールが走ったことまで見えなくなり、 ToolInvoked を置いた元の目的が壊れる) - **注入点は ToolRegistry::specs_for。AgentTool::spec() の既定ではない。** McpTool も AgentTool を実装している (impl AgentTool for は**全 11 個** = 自前 9 + McpTool + テスト用 Dummy) ので、既定に注入すると **外部のスキーマにこちらの欄が生える**。**さらに RunTool は spec_for を 上書きしている** (Spec 15 — 実行可能な登録だけを列挙するため) ので、 spec() の既定では **run にだけ乗らない**。 **specs_for は全部が通る唯一の漏斗。説明文の定数もここ 1 箇所** - **合成側 (ask / handoff / plan / room_log) は AgentTool を実装していない** — orchestrator で ToolSpec を直接組む。**理由欄が生える経路は無い**。 **編集ゼロだがテストで留める** (次に読む人が親切心で足す) - **空欄は 3 種類ある。ReasonState の 4 値で運ぶ** (**P1 の実装で 3 値では足りないと分かり、rev3 の凍結を訂正した**): Written(String) = モデルが書いた / Omitted = 尋ねたが書かなかった / Unsupported = McpTool 由来でそもそも尋ねていない / **Excluded = この村の判断で対象外にしているツール** (合成側)。 **Option にしない** — None が複数の意味を持ち、 **フロントには「このツールは理由を持てるはずか」を知る手段が原理的に無い** (wants_reason はコアにしかなく、MCP 接続は動的)。 表示は Omitted =「理由なし」/ Unsupported =「外部ツール」/ **Excluded = 理由の行を出さない**。 **Unsupported と Excluded を畳めない理由は画面のラベルが嘘になること** — ask_agent_3 に「外部ツール」と出すのは誤り。**契約を書いた時点では 合成側も ToolInvoked を発行することを数えていなかった** (発行するのは registry 経由の呼び出しだけだと暗に仮定していた) - **CoreEvent::ToolInvoked への加算。既存 3 欄は動かさない** - **理由が届かない先は永続共有・永続保存・未来の自分**: 広場ログ (Shared.log は AgentMessage = 発話だけ) / 履歴 (push_exchange は**送った文字列と返した本文の対だけ**) / session_store (SessionRecord::exchange も同じ 2 つ)。 **3 経路とも既に構造で届いていないので、これは実装ではなく凍結** - **同じターンの同じ個体のループ内には意図的に載る。** ツールループの messages に積まれないと**ツール呼び出しが成立しない**。 **これは漏れではなく設計** — 書かないと次に読む人が 「ここも塞ぐべき穴」と読んで**ツールループを壊す** - **広場ログへ出さない。** やると**毎ターンの可変注入が 1 本増え** (#45 / 論文 1 の品質仮説の疑いの筋)、**上の実測ごと崩れる** — 測ったのは「呼んだ個体が 1 回書く」費用であって 「全員の可変文脈へ毎ターン載る」費用ではない - **説明文で読み手を明かさない。** 「利用者が読みます」と書くと **安心させるための文章を誘発しうる** (#84 の系 — 自己申告は事実の記録ではない)。求めるのは**行為の記述**で 「次に何を確かめるために呼ぶのかを 20〜40 字で」。 **これは保証ではなく傾向。保証だと書かない** - **典型の長さを決めるのは上限ではなく説明文。** 60 字は**外れ値だけを 止める天井**。reason_chars の中央値が 60 に貼り付いていたら、 **天井ではなく説明文を直す** - **ログには字数だけ。本文は出さない** (failures.md #71 — 計器がモデルの 出力を記録して、利用者の秘密がログへ平文で残った)。**画面には出す** — 画面は人が見せると決めて撮るもので、自動で残り続けるログとは性質が違う - **既定 ON。** 安心のための表示なので**見せない既定には意味が無い**。 「ツール実行ログを会話で非表示にする」は**この行ごと消す**スイッチで、 **理由だけを消す軸は作らない** (作ると「理由が無いのか設定で消したのか」が 画面から読めなくなる) - **これは「使わない人は払わない」の形にならない。** Spec 23 / 24 / 25 は 使わない村の固定費がゼロだったが、本節は既定 ON で全員に乗る。 **払うのは村の持ち主で、受け取るのはその村を使う人** — 受益者と支払者が違う。**乗せると決めた根拠は動機そのもの** **却下した処方 (覆すときに覆したと分かるように書く。failures.md #56 の逆向き)**: - 「**結果本文が Error: / 失敗 / not found を含むなら ok を偽に倒す正規化層**」 → **採らない**。(1) Spec 08 が「分類は文言 parse でなく型で運ぶ」を凍結して いる (2) **偽陽性が壊滅的** — grep は**一致行を返すのが仕事**で、結果に Error: が入るのは成功。file read でログを読めば「失敗」が入る (実測で grep は呼び出しの 34%、file は 20%) (3) #41 の一般化は **診断であって処方ではない**。根治は同梱ツールが Err を返す側で別 Spec - 「**説明文をシステムプロンプトへ置く**」→ **採らない**。ドリフト懸念 (9 箇所のうち 1 箇所だけ書き換えられる) は**注入点の集約で消える**。 **システムプロンプトは理由欄を持つツールが 1 本も無い個体も毎ターン読む** (MCP だけの村、ツールを 1 本も持たない村) ので、 **持っていない機能の指示を全員が読む**ことになる。 コスト差 360 対 40 トークンは実効 36 対 4 で**どちらも誤差**なので、 **誤差の 9 倍差で構造を決めない** rag_tool_contract: | 同梱 rag = 宣言されたフォルダへの見出し索引 (Spec 18 rev3、2026-08-05 凍結)。 **本節は実装より先に凍結した契約で、着地は Phase 1〜4。** 動機: Markdown の見出し階層 (人が置いた構造) を索引として引く。Manuale (利用者の MCP サーバー) の核の取り込みで、PageIndex (MIT) の考え方に基づく (コードの移植ではない。表示 3 行を doc_index.rs 冒頭に置く)。 同時に、使われていない同梱 RAG (rag.rs = HashEmbedder + 常に空の索引) を 置き換える。**置換が起きるのは能力の層** (索引を張って引く能力が埋め込みから 見出しへ替わる)。**ツールの層は 0 本 → 1 本の増加** — 旧 RAG は AgentTool を 実装しておらず、BUNDLED_TOOL_NAMES にも DEFAULT_ENABLED_TOOLS にも 入っていなかった (ツールとして提示されていなかったからこそ push で 差し込むしかなかった)。 不変条件: - **ツールは 1 本 (`rag`)、op は閉じた列挙 3 つ**: outline { path? } # フォルダ一覧 + 各 Markdown の見出し木 search { pattern, path? } # 一致行 + その行が属する見出しの経路 read { path, section? } # 見出し名で指定した節の本文 + breadcrumb 1 本に畳むのは file_tool_contract と同じ判断 (op ごとにツールを並べると スキーマ固定費が全サーヴァントの毎ターンに乗る) - **rag_sources はフォルダの絶対パスの列。読み取り専用。** 意味は Spec 18 で「索引済みソース名」から変更 (フィールド名と型は不変。 既存の world.json には空配列しか入っていないため移行は不要)。 **work_dir の内外を問わない** — 囲いの単位は領域ではなく**宣言** (grep は work_dir に自動で従う / rag は人が書いた分だけ。重なりは想定内で、 どちらも読み取り専用なので害が無い) - **各ルートの解決は canonicalize + 前方一致・symlink 不追従** (resolve_in_work_dir と同じ規律の**別関数**。あれは work_dir 起点の関数で、 流用すると「新規作成なし」の担保を持つ側の意味が濁る) - **提示は宣言ただ 1 つで決まる** (rag_sources が空でなく、かつ 1 本以上の ルートが有効なら提示。**空または全滅なら提示しない**)。 **enabledTools の対象外** — BUNDLED_TOOL_NAMES にも DEFAULT_ENABLED_TOOLS にも入れず、is_bundled_tool_presented を素通りする (MCP 由来・転送・委譲と 同じ側)。rag は WORK_DIR_TOOL_NAMES にも入れない (境界が別) - **【D13・利用者裁定 2026-08-05】rev3 の D7「DEFAULT_ENABLED_TOOLS に 入れる (2 段ゲートの 1 段目 = enabledTools)」を覆した。** 宣言を書けるのは 人だけなので**宣言そのものがオプトイン**であり、チェックボックスは同じ 意図に対する 2 つ目のスイッチだった。実機で初日に踏んだ — 既存の村は 全個体が enabledTools 明示配列 (Spec 02 の頃の履歴) で、明示配列に 新ツールは自動で増えないため、**フォルダを宣言しても誰にも rag が 出なかった** (ゲート 1 で落ち、ゲート 2 に到達しない)。処方は警告の 追加ではなくスイッチの一本化 — 穴を表示で覆うのではなく、穴ごと消す。 BUNDLED_TOOL_NAMES = DEFAULT_ENABLED_TOOLS ∪ {run} の等式は 8 = 7 + 1 へ戻る - **宣言されたルートは実在検査を掛ける。無効化であって削除ではない。** 検査は起動時と呼び出し時の両方 (起動時だけだと、開いたまま外付けドライブを 外した村が素通りする)。落ちたルートは無効印を付けるだけでファイルからは 消さない (消すと配布した村を別の端末で一度開いて閉じただけで宣言が消える)。 無効印は毎回付け直すのでパスを直せばその場で復活する。 **実在まで見る。読めるかどうかは見ない** (権限の意味は OS で違う。 確かめられないことを確かめたふりの検査で覆わない — run の program 検査と同じ) - **出力の上限は読み取り系と同じ** (MAX_OUTPUT_CHARS = 12,000 / 1 行 240 字 / 走査 MAX_FILES = 20,000)。**件数の上限は別に作らない** (上限が 2 つあると どちらで止まったか読めない)。止める位置は op で違う: outline = **ファイルの境界** (見出し木が途中で終わると無い節を「無い」と読まれる) search = **一致行の境界** (grep と同じ) read = **止めない。超過は節ごと拒否** — 行数・文字数と「search で位置を 絞るか、節を分けてください」を返す。節は構造の単位で、途中で 切れると「ここまでが節の内容」と読まれる (file read が先頭 12,000 字で切り詰めるのと**意図的に逆** — ファイルは構造の 単位ではないので先頭 N 字は先頭 N 字としてしか読まれない) - **read の節解決は 3 段** (完全一致 → 大文字小文字無視 → 部分一致)。 各段で複数当たったら**曖昧として拒否**し候補を返す (黙って 1 つ選ばない) - **毎ターンの「## 参照資料」注入は廃止。pull のみ** (Phase 4 で撤去)。 撤去自体は挙動を変えない (旧索引は常に空で一度も発火していない) — pull は「新機構を pull で設計する判断」であって既存 push を止めた効果ではない - **効く条件: 見出しが構造として機能している文書に限る。** 走り書き・ 自動生成では grep と大差ない。blackboard/ の付箋は対象外 - **rag は安全機構ではない。** 次の 3 点を契約と UI の両方に書く: (a) rag_sources に書いたフォルダはそのエージェントから丸ごと読める = プロンプトインジェクション時の漏洩範囲になる (b) **漏洩先は「そのエージェント」ではない** — 読んだ本文は ask / plan で 他エージェントへ渡るので、実際の範囲は**到達可能な全員 + 各自のモデル提供元** (c) 機密を含むフォルダを指定しない (work_dir 欄と同じ注意書き) 同時改訂 (Spec 18 で予告し、着地は各 Phase): - enabled_tools_invariant: 同梱 8 本 → 9 本 / 既定集合 7 → 8 (Phase 2) - role_contract 凍結 2: rag_sources を雛形から外す。 11 = 対象外 2 + 入れる 3 + 入れない 6 (Phase 3。絶対パスへの意味変更で 「端末ごとに違う絶対パスは雛形に入れない」側の性質になった — work_dir を入れなかった理由がそのまま当たる) - RagChunk: Phase 4 で型ごと撤回 convergence_prerequisite: | 終了条件より先に収束の条件が要る。エージェントは直近 history_turns (既定 8) 往復の 履歴を持ち、自分の発言も assistant として見る。履歴が無いと毎回コールドスタートになり、 同じ入力に同じ出力を返し続けて原理的に収束しない (failures.md #12)。 **履歴の寿命はセッション** (Spec 12 P2 で変更。それ以前はプロセス寿命で、 start_agent が毎回クリアしていた)。sessions.redb の exchange レコードから 起動時に復元し、**start_agent ではクリアしない**。 旧い規律「起動は新しい会話の開始として扱う」は、会話を始め直す手段が他に 無かった時期の代用だった。始め直しは「新規チャット」= 新しいセッションが担う。 start_agent でクリアすると、**再起動して開いた会話の履歴を、エージェントを 起動した瞬間に捨てる** — 起動時に自動起動しない契約と組み合わさって、 Spec 12 の S1 (続きから始められる) が原理的に成立しない。 # --------------------------------------------------------------------------- concurrency: tokio: owns: [エージェントのライフサイクル, LLM 呼び出し, メッセージ配送] rationale: I/O バウンド。ネットワーク待ちでスレッドを占有しない rayon: owns: [ログのトークン集計] # ベクトル類似検索は Spec 18 で rag.rs ごと撤去 rationale: CPU バウンド。Tokio ワーカー上で回すと IPC 応答が詰まる bridge: fuseforks_core::compute::spawn_rayon(oneshot で受け渡し、双方をブロックしない) anti_pattern: | エージェント実行を Rayon に載せてはいけない。Rayon のプールはコア数に固定されるため、 そこでネットワークを待つと同時稼働エージェント数がコア数で頭打ちになる。 # --------------------------------------------------------------------------- llm_wire: origin: Kataribe crates/llm_client(実運用で踏んだ罠を継承) layers: - orchestrator: canonical 型だけを組む。wire を一切見ない - LlmBackend: 差し替え点(実 HTTP / Echo) - client: URL・ヘッダ・タイムアウト・再試行。wire の中身を見ない - adapter: canonical ⇄ wire の純関数。ネットワークを知らない - wire: プロバイダの生 JSON 形。唯一の真実 invariants: - temperature は Option。None ならキーごと省略する(非対応モデルは送ると 400) - OpenAI 系の tool_calls[].function.arguments は JSON *文字列*。decode 境界で 1 回だけ parse - Anthropic の tool_use.input は JSON *オブジェクト*。parse 不要 - 応答側の全フィールドに #[serde(default)] を敷く(互換サーバは形がまちまち) - 未知のコンテンツブロックは Other で受ける(新種で丸ごと壊れないため) - text 空 かつ tool_calls 空 かつ finish==Length のときだけ EmptyResponse(推論の空応答) - Blocked を EmptyResponse から分離する(安全フィルタ拒否を診断可能に保つ) - Parse エラーは raw を保持する(却下理由と一緒に差し戻して再生成させる燃料) - 再試行対象は is_transient のみ(HTTP 障害 / 推論の空応答 / Api のうち RetryClass の 判定が Retry のもの = 429 / 529 / 5xx。insufficient_quota は 429 でも止める)。 分類と待ち時間の規則は retry_contract(Spec 52) - reqwest の source 連鎖は平坦化する(真因が "error sending request" に隠れる) retry_contract: | Spec 52(2026-09-07 rev2 承認)。再試行は `llm/client.rs` の `chat_with_backoff` の 1 箇所で、判断材料は `llm/retry.rs` の純関数(I/O・時計・乱数を読まない。現在時刻は 引数)。構造は busbar `crates/busbar/src/breaker.rs`(Apache-2.0)を写し、コードは 書き直した。 分類 `RetryClass`(閉じた 9 値・網羅 match)と `Verdict`: RateLimit(429) / Overloaded(529) / ServerError(5xx) / Timeout(reqwest is_timeout) / Network(reqwest is_connect・is_request) → Retry Auth(401・403) / ClientError(他の 4xx。**408 を含む** = 2026-09-07 裁定) / ContextLength(400・413 + code) / Billing(プロバイダ code) → Stop - status 以外の材料(code・本文の待ち時間)は adapter の純関数 `error_signal(body)` が 出し、client は `Provider` で振り分けて呼ぶ(decode と同じ。client 自身は JSON を 解釈しない) - Billing と読む code は OpenAI 互換・Responses 4 本の `insufficient_quota` だけ。 Anthropic / Gemini は無し(Gemini の RESOURCE_EXHAUSTED は RateLimit。課金起因の 429 を再試行するリスクを許容する)。表に無い code は status 既定へ落ちる - 分類は `LlmError::Api.class` が持つ(`is_transient` は `CoreError::is_retryable` から 引数無しで呼ばれるため) - **今と変わる挙動は 2 つだけ**: Billing を再試行しない / 明示値を下限として待つ 明示された待ち時間の出所は 2 つ。マージは `attempt()` の失敗枝の 1 箇所: (a) `Retry-After` ヘッダ(全ワイヤ。delay-seconds と HTTP-date。過去の日付は Some(0)) (b) Gemini の本文 `error.details[].@type == google.rpc.RetryInfo` の `retryDelay`("56s") 両方あれば大きいほう。`LlmError::Api.retry_after` に最終値、`hint_src` に出所 (none / header / body / both)。生の 2 値は `hint_src` へ畳んでから捨てる 待ちの式と順序(`plan_wait(attempt, hint, u)`。attempt は 0 始まり・u ∈ [0, 1]): 1. exp = min(200ms × 2^attempt, 5s) — 指数部の天井 5 秒は据え置き 2. hint > 60s(MAX_HONORED_RETRY_AFTER = code constant)なら StopHintTooLong — 再試行せず、本文へ「プロバイダは N 秒後の再試行を求めています」を前置して返す (JPEG フォールバックの文面差し替えと同じ層・同じ形) 3. base = max(exp, hint) — 明示値は下限 4. wait = base × (1 + 0.1 × u) — jitter は max の後・上向きだけ(明示値を下回らない) 天井の判定は jitter 前の hint。jitter 後の wait は最大 66 秒。u の本番の生成は retry.rs の外(SystemTime のナノ秒・attempt・バックエンドのアドレスを FNV-1a で畳み `(h % 1001) / 1000`)。`rand` を core に足さない 打ち切り: `LlmBackend::chat_cancellable(req, cancel)`(**trait の既定メソッド** — 既定は token を読まず `chat` へ委ねる。上書きするのは HttpLlmBackend だけ。署名を変えると 結合テストの 55 バックエンドが落ちるので加算にした = P2 で訂正)の cancel は **sleep だけ**を切る。HTTP 往復は切らない(切るとプロバイダが払わせた usage が届かず、 `turn:` 行にも予算にも出ない = failures.md #103 の形。sleep は捨てても 1 トークンも 払わない)。切れたらいま受けた失敗をそのまま返す。**打ち切りへ落とす判定は turn.rs の 2 箇所** — 周回境界(Ok の経路)と、LLM 呼び出しの Err の腕(払いを清算した直後)。 どちらも finish_interrupted の 1 実装へ落とす。Err の腕に無いと、待ちが切れて返った 429 が `stop=failed:LLM_API` として閉じ、人が止めたターンが失敗を名乗る(Spec 52 P4 の実機 2026-09-08 01:21:13 で踏んだ。token_budget.precedence の cancel 最優先を 1 経路で破っていた)。ターンループの 2 箇所(本体・まとめ)が token を渡す。要約は `chat` のまま 計器(再試行のたびに 1 行 / 分類か天井で止めたとき 1 行): llm retry: model=… attempt=k/M status=… class=… code=… hint=NNs|- src=… wait=…ms llm retry stop: model=… attempt=k/M status=… class=… code=… hint=… src=… [reason=hint_too_long] k は失敗した通算の試行番号(1 始まり)、M は max_retries。status=- は Http / EmptyResponse の再試行。hint=0s src=header は「ヘッダは付いていたが過去の日付」。 **P0 の縮退版**は `llm retry: model= attempt= status= hint= src=header|- wait=` で、 class / code / src=body は P1 `LlmError::Api` の欄(P1 で確定): status / body / class(RetryClass)/ code(プロバイダ code の 先頭 40 字。計器の code= に出すだけ)/ retry_after / hint_src。組む場所は client の `attempt()` 1 箇所と `LlmError::api()`(status 既定・待ち時間なし。テストと文面差し替え用) 状態: **Done(2026-09-08)。** 実機 = 無料枠の Gemini で 3〜4 体の波を 2 回。17 本の `llm retry:` はすべて `src=body`(Gemini は Retry-After ヘッダを付けない。他ワイヤは 未観測)。`hint=0s` が来て 400 ms で再送し外れた個体が 1 体(明示値は約束ではない)。 60 秒待った個体は次で通った。停止は待ちを 2 ms で切ったが分類が failed だった穴を 同日に塞いだ(上の打ち切りの段落) usage: | canonical の Usage(Spec 32 P0 凍結 2026-08-10)。欄は prompt / completion / cache_read / **reasoning**。 **reasoning は completion の内数。** 外数ではない。実測で決めた(Spec 32 の probe 8 発・grok-4.5): 8/8 で `output_tokens - reasoning_tokens` = 3〜5 で、 同じ応答の本文が 4〜8 字だった。**外数ならこの差は本文と無関係に開く。** Usage::total() = prompt + completion ← **変えない** 実効トークンの重み(未キャッシュ ×1 / キャッシュ済み ×0.1 / 出力 ×4)も不変 **reasoning を total へ足すと二重計数になり、トークン天井(Spec 11)の 実効値が跳ねる。** 思考ぶんは既に出力として ×4 で乗っており、分離しても 課金の扱いは 1 ミリも変わらない。Spec 32 が変えるのは**見え方だけ**。 **型は u64 であって Option ではない。**「取れない」と「0 だった」を型で 分けると表示側が 2 状態を扱うことになる。対照の検収(思考を使わない経路でも reasoning=0 の行が出る)は**両方 0 で出る**ことを要求するので、区別は害になる。 供給元は社ごとに欄名が違う。**同じ概念に社ごとの呼び名があるので、 名前で grep しても揃わない**(Spec 32 rev2 は `thoughts_token_count` を 数え落として Gemini を 3 社の外に置いていた。#62 と同型): Gemini usageMetadata.thoughtsTokenCount **completion への足し込みは維持する** — `completion = candidatesTokenCount + thoughtsTokenCount` でのみ totalTokenCount と一致する(既存の実測。下の gemini 節が正) xAI Responses usage.output_tokens_details.reasoning_tokens (output_tokens は既に内数を含む。足し込まない) OpenAI 互換 usage.completion_tokens_details.reasoning_tokens **思考の「本文」は /chat/completions に来ないが「数」は来る** (Spec 32 P1 で数え直した。起票時は「来ないので 0 が正しい」と 書いていたが誤り —「受け取れない」と「数えられない」は独立で、 推論モデルはこの欄を返す)。内訳欄を持たない互換サーバ (llama.cpp / vLLM ほか)では 0 に落ちる。 **ただし実機の応答では未確認**(テストは合成 JSON。Spec 32 P3) Anthropic usage.output_tokens_details.thinking_tokens ~~欄が無い。構造的に取れない~~ **取り消し (Spec 32 P4)** — P1 の判定は **AnthropicUsage (当時 4 欄) を読んで下した**もので、 **実際のワイヤはそれより多くの欄を返していた**。 **`thinking` を 1 つも送っていない要求の応答にも付く** (claude-sonnet-5 は既定で思考する。実測 5/5)。 実測で output_tokens == thinking_tokens == max_tokens の応答 (本文が 1 ブロックも無いターン) を観測 = failures.md #72 の 「出力トークンだけがあって本文が無い」の正体 計器: `turn:` 行へ **reasoning= を常に出す(0 も出す)**。 形は `turn: agent=… hop=… rounds=…/… waves=… stop=… prompt=… cached=… total=… reasoning=… backend=…`(**output という欄は存在しない** — total= の隣へ足した。P1 で着地)。**Spec 32 より前のログには reasoning= が無い** — 古い行を読むときの目印になる。 **条件付きで省かない** — 思考を使わない経路で 0 が出ることが「常に出す実装」 との対照になり、省くと機構が効いているか読めなくなる。 **backend= はどのワイヤを通ったか (Spec 34)。** LlmBackend::name() の値で、 openai-compat / anthropic / gemini / xai-responses / openai-responses。 足したのは**ワイヤを増やしたことを実機で確かめられなかった**ため — プロトコルを切り替えても、検索のような固有機能を使わない限り **ログが 1 行も変わらなかった**(実機で 2 ターン撃って読めなかった)。 Spec 31 はこの穴を踏まずに済んでいたが、それは `xai search:` が偶然 その役をしていたからで、あの行は検索を有効にした呼び出しでしか出ない。 **一般化: あるワイヤに固有の計器は、そのワイヤの「特定の機能を使ったとき」 の証拠にしかならない。「この口を通った」を読みたいなら、機能と無関係な 場所に置く。** **欄は末尾へ足す** — 既存の欄の位置が動かないので、過去の観測を引く grep が壊れない(reasoning= を足したときと同じ形)。 reasoning_summary: | モデルが返す「思考の要約」(Spec 33 P0 凍結 2026-08-10)。 **量は usage.reasoning(Spec 32)、中身はこちら。別の欄・別の口。** canonical の席は ChatResponse.reasoning_summary。**周ごとに畳んで ターンぶんで持ち、AgentMessage へ載せる** — Grounding と同じ経路 (検索した周と関数を呼んだ周は別なので、周ごとに上書きすると先の記録が消える)。 **凍結 1: 履歴には積まない(両社とも常に。D2-a)。** ChatMessage に席を作らない。積むと滑る窓 8 往復で最大 8 回再送され、 実測 3,475 字が固定費になる (Spec 23 D1 と同じ判断・同じ根拠 — 履歴は String で、#45 の規律により「送った文字列そのもの」が入る)。 **証明は型で取る**: 席が無ければ積めないので単体テスト 1 本で足りる。 **runtime のバイト計測は要らない**(Spec 23 P3 が同じ問いを型で閉じた前例)。 **これはワイヤ上の産物の往復とは別の話**(D2-b。**未凍結**)— thinking ブロックや reasoning item をプロバイダが次のターンで要求するか どうかは測定中で、真でも増えるのは ChatMessage の別の席であって、 この凍結 1 は影響を受けない。**社ごとに対象が違う** (xAI = reasoning item / Anthropic = thinking + signature)。 **凍結 2: 「空」は 0 字。長さで判定しない。** 0 字なら枠ごと描かない。**エラーにも警告にもしない** — 実運用の大半 (ツールループの回)がここに落ちるので、警告にすると正常なターンの ほとんどで鳴る(実測: Anthropic のツール併用回は thinking_tokens=34 で 要約 0 字)。1 字以上はそのまま出す。 **閾値で切らない** — xAI の短形 129 字と長形 919〜1,367 字の間に線を引くと、 **probe の産物である数字が表示規則になる**(failures.md #92 と同じ罠)。 しかも「再掲だけか」は内容の判断で、ハーネスには決められない。 **凍結 3: 送信側が変わるのは Anthropic と Gemini。** ~~Anthropic だけ~~ **取り消し (Spec 33 rev3)** — P1 の作業中に **Gemini が 3 社目**だと分かった。**「新しい作業が要る社」で列挙したので、 どちらの形にも見えなかった Gemini が網の外に落ちた** (Spec 32 rev2 の数え落としと同じ機序)。 xAI 何も足さない。reasoning: {"summary":"auto"} は効果ゼロを 4 対 4 で実測(短形/長形の分布が既定 2:2 / auto 2:2 で一致)。 **3 社で唯一、既定で本文が返る** Anthropic thinking: {"type":"adaptive","display":"summarized"} 5 世代の既定は display: "omitted" で、signature は 368〜16,328 字あるのに thinking テキストが 0 字になる。 **budget_tokens は 400**(サーバー自身が adaptive と output_config.effort を名指しする) Gemini generationConfig.thinkingConfig.includeThoughts: true **既定では thought: true の part がそもそも返らない** (実測。part は答えの 1 つだけ)。ゆえに `if part.thought == Some(true) { continue; }` は **現行の設定では一度も発火しない防御コード**。 **要求しなくても思考ぶんは払っている**(実測 3,773 トークン)。 includeThoughts は**接地とは独立に常に送る** — tool_config.includeServerSideToolInvocations の 「組み込みツール × 関数宣言のときだけ送る」規律(Spec 48 D3。 旧: 接地を使うときだけ)とは条件が違い、思考は接地の 有無と無関係に常に起きている **トグルにしない** — 思考ぶんは display に関わらず払っており、summarized は 「既に払ったものを返してもらう」だけ(実測 1 対で thinking_tokens 4,090 対 4,096 = 差 6。**1 対なので「同程度」までしか言わない**)。 トグルを置くと「払っているのに見えない」設定を利用者が作れてしまう。 **5 世代以外への送信は未確認**(P2 で数える。拒否されるならモデル名で ガード = reasoning_effort と同じ「同じワイヤ内の方言の吸収」であって、 Spec 31 D2 が禁じた「ワイヤの選択に名前を使う」には当たらない)。 **凍結 4: 語は「思考の要約」。生の思考とは言わない。** 返るのは summary で、トークン数と字数が 2 桁違う(xAI は reasoning_tokens 1,118〜3,685 に対し最大 1,367 字 / Anthropic は 4,096 に対し 3,475 字)。 **「思考を表示します」と書くと、返っていないものを返っていると言うことになる** (Spec 05 が sources の空を受けて告知を縮小したのと同じ規律)。 **凍結 5: 接地(出典)と同じ枠に並べない。** 出典は**検証できる外部の指し先**、要約は**検証できない内部の申告**。 同じ枠に並べると後者に前者の信用が乗る。 **要約は英語で返る**(問いが日本語でも。**3 社とも実測** = 1 社の癖ではなく この領域の性質)。画面では**原文である旨を 1 行注記する**(Spec 33 D7 案 b。 利用者裁定 2026-08-10。**訳語ではなく事実の記述**なので、「思考の要約」と 言い切る凍結 4 とも「システムプロンプトは訳さない」とも衝突しない)。 **忠実度は社ごとに桁で違う**(P5 実測。英語 4 字/トークン換算): xAI 932〜1,431 字 / 思考 4,319〜10,558 tok → 2〜8% Anthropic 97 字 / 思考 383 tok → 6% Gemini 3,252〜3,718 字 / 思考 963〜1,249 tok → 74〜84% **Gemini は実質、思考そのものを返している。** 凍結 4 の語(要約)は Gemini では控えめすぎるが、**返っていないものを返っていると言う向きでは ない**ので語は変えない。次に触る人が「Gemini も 1 割だろう」と読まないよう 数字をここに置く。 **`reasoning` item の大半は要約が空**(実測 — 2 周で 32 item のターンから 捕獲されたのは 2 件)。**`kinds=reasoning:N` は item 数であって、要約を 持つ item の数ではない**。表示の規模を見積もるときに取り違えない。 計器: 要約を**受け取った回だけ** 1 行(`xai search:` と同じ形)。 `reasoning summary: agent=… items=N chars=M`。 **捕獲した量を数えないと、捕獲したものが存在しなかったことになる** (failures.md #72 の裏返し — あちらは捨てたものを数えなかった)。 mcp_server_contract: | Fuseforks を MCP サーバーにする (Spec 25 rev3 承認 2026-08-07)。外部の LLM (Claude Code / Copilot / CLI) がチャットを 1 本投げ、窓口のサーヴァントが オーケストレーションして束ねた答えを返す。実装は rmcp 2.2 の server + transport-streamable-http-server を GUI プロセス内で開く (村は 1 プロセスにしか居ない — single_instance / redb 単一書き手 / 稼働個体はメモリ。別プロセスの MCP サーバーは構造的に不可)。 凍結 (P0 実測 2026-08-07 で確定): 1. **扉は 1 枚。ツール名 `ask_fuseforks`・引数は `message` だけ。** 宛先 (`to`) も roster も出さない — 村の状態は外からブラックボックス。 **名前は 2026-08-09 に `ask_concordia` から改名した (Spec 30 D3)。** クライアント設定は壊れない (`.mcp.json` に書くのは接続先で、ツール名は tools/list で毎回発見される)。**件数と名前は `the_door_declares_one_tool_under_a_frozen_name` が留める** — 改名しても他のテストは 1 本も落ちなかった。 2. **トークン必須。localhost でも省かない。** 置き場は **`{app_data_dir}/mcp_server.json`** (workspace の**外**・端末側・ Rust 可読)。ON/OFF・ポートも同じファイル。**窓口の agent_id だけは `world.json` の `reception`** (村の内容物 — #52 の境界)。帰結: **村 (workspace) を配っても扉は開かない。** `reception` は**削除済みのエージェントを指していても掃除しない** — 「窓口が見つからない」(AGENT_NOT_FOUND) と「未設定」 (EXTERNAL_RECEPTION_UNSET) は人がとる次の手が違うので、2 つの状態を 1 つに畳まない。設定は書き込みの入口でだけ存在を確かめる。 3. **トークンの生成はサーバーを ON にした時点** (起動時ではない — OFF の村ではポートも開かずトークンも作らない)。毎起動の再生成はしない (クライアント側の設定が毎回無効になる)。再生成は明示ボタン。 4. **bind は 127.0.0.1 固定。** Host / Origin 検査は rmcp の StreamableHttpServerConfig が持つ (allowed_hosts 既定 = localhost/127.0.0.1/::1 = DNS rebinding 対策。実測で確認)。 5. **同時外部依頼は 1 本まで。2 本目は「処理中」で即拒否 (busy)。** 効き先 3 つ: 併走の予算二重消費 / 自己登録の閉路 / 自己呼び出しの デッドロック。**外部入口は新しい因果の根で hop も予算も新品になる — max_hops と token_budget は閉路が扉を通るたびにリセットされ、 どちらも効かない** (rev2 の「既に塞いでいる」は誤りだった)。 6. **`Endpoint::External { client }` を新設** (User に見せかけない — `【送り手: X】` はモデルが読む入力で、相手が人間かどうかは振る舞いを 変える情報)。**ただし「モデルが読んで人間でないと分かる」は 保証ではなく傾向だった** (Spec 26、2026-08-07 実測 — ミュゼは封筒を 読んで外部と判定し、ザリは「本文の自己申告は判断に使わない・する気も ない」と明言して人間の依頼として扱った)。`client` = clientInfo.name の自己申告 = **攻撃者が書ける文字列がプロンプトへ入る経路の 1 つ目** (**「唯一」は誤りだった** — 2 つ目は `message` 本文そのもので、 本文に封筒構文を書けば封筒が 2 つ並ぶ。Spec 26 D1 が塞ぐ)。 `world::normalize_client_name` が `normalize_user_name` と同じ検査を 通し、**落ちたら拒否ではなく既定ラベル `external` (`DEFAULT_EXTERNAL_CLIENT`) へフォールバック** (呼び出し側が対話的に 直せない値なので、拒否すると扉ごと使えなくなる)。 **封筒は `【送り手: {client}(外部クライアント)】`** — 名前だけでは 「そういう呼び名の人間」と読める (呼び名は利用者が自由に付けられる) ので、種別を明示する。乗るのは外部依頼のターンだけで、全員の毎ターンに 乗る固定費ではない。ワイヤ形は `{"kind":"external","client":"..."}` (`ipc_contract.rs` が凍結)。 6'. **外部クライアントの呼び名とアイコンは人が設定できる** (Spec 25 D8、 2026-08-07 利用者要望)。呼び名 = `world.json` の `external_name` (検証は `normalize_user_name` の**同じ述語** — 封筒へ入る値という 一点で制約が決まる)、アイコン = `{workspace}/external/icon.webp` (`validate_icon` の同じ述語。上限が 1 つであること自体が不変条件)。 **未設定なら名乗り (`Endpoint::External.client`) へ落ちる。** 解決は `external_label` の 1 実装で、封筒 (`attribute_sender`) と 広場ログ (`endpoint_label`) が共有する — 2 箇所で組むと 「画面では設定名、プロンプトでは名乗り」の食い違いが生まれる。 **`Endpoint::External` は据え置き** (記録には名乗りが残る = 監査の材料)。 **設定は攻撃面を狭める側に効く** — 設定した村では、 `clientInfo.name` (攻撃者が書ける経路の 1 つ目) がプロンプトへ届かない。 **本文の経路は塞がらない** — 呼び名を設定しても、外部クライアントは `message` 本文に封筒構文を書ける (Spec 26 D1)。 アイコンの置き場を `user/` と分けるのは、共用すると「外の道具が 頼んだこと」と「自分の依頼」が同じ顔で並ぶため。 7. **窓口未設定・停止中でもサーバーは HTTP を受け、ツールが理由を返す** (ポートが開いていなければ理由は返せない)。 8. **待ちは素直に待つ** (start+poll にしない)。上限は ask_timeout (既定 180 秒) に揃える。**外部依頼は予算の根の 3 種類目** (ユーザー発話 / 予定発火 / 外部依頼)。 9. 広場ログ: **依頼 (from=External) は不可視** — is_visible_in_room_log は from が Agent でない発話を既に落とすので**編集ゼロ**(テストで凍結)。 **窓口の返答 (from=Agent, to=External) は User 宛の返答と同じ扱いで可視。** 10. Endpoint のワイヤ形 (serde tag="kind") に variant が 1 つ増える。 redb の既存レコードに新 variant は現れないので移行不要。 **網羅 match はコンパイラが指すが、ワイルドカード・matches!・== の 6 箇所は指さない** — 挙動の表は Spec 25 D6 が正。 P2 実装 (2026-08-07。`apps/gui-tauri/src-tauri/src/mcp_server.rs`): - 依存は実測 **+19 crate** (317 → 336。axum / rmcp-macros / schemars / rand / chacha20 ほか。**全部純 Rust**で C 依存は増えない) - `mcp_server.json` が**読めないときは書き込みを拒む** (schedules.json と 同じ規律)。既定値を書き戻すと合鍵とポートを消す = #70 の再演になる - 合鍵は `Uuid::new_v4().simple()` の 32 桁 16 進。Unix では 0600 へ絞る (**Windows では何もしない** — ACL を自前で組むと効いているつもりで 効いていない形になる) - `Authorization: Bearer` の検査は axum の middleware で**扉の内側へ入る前**。 突き合わせは定数時間 (**効くのは別利用者のプロセスに対してだけ** — 同じ利用者のプロセスは設定ファイルそのものを読める) - `allowed_origins` を loopback の 2 つで**明示的に埋める** (rmcp の既定は 空 = Origin 検査が無効)。Origin ヘッダの無い要求 (CLI) は通る - 状態は `enabled` と `listening` を**別に持つ** — ポートが埋まっていると 「ON なのに開いていない」が起こるので 1 つの真偽値に畳まない。 bind の失敗は保存の失敗ではないので、エラーにせず `lastError` で返す P0 実測 (2026-08-07。probe = rmcp 2.2 server + axum / scratchpad): - **Claude Code は streamable HTTP を受ける** — `--mcp-config` の {"type":"http","url":...} で initialize → tools/list → tools/call が 完走し、ツールの戻り文字列がそのまま返った。stdio シム案は死んだ - **180 秒のツール呼び出しが既定設定のまま完走** (クライアント計 196 秒。 「2 分で自動バックグラウンド化」も経路を壊さない)。サーバー側も curl 直で 180 秒保持を確認 (SSE keep-alive 15 秒間隔)。 ask_timeout 既定 180 秒 ≤ クライアントの実測保持時間なので、既定同士の 組み合わせで S1 は切られない。ask_timeout を上げるなら .mcp.json の "timeout" (ms・サーバー単位) を併記する。 **続報 (Spec 44)**: 既定が 600 秒へ動く (P1 で着地) ので、この 「既定同士で切られない」は崩れる — 外から使う村は "timeout" の併記が 実質必須になる (ask_cycle_contract の相互作用の項が正) - 認証ヘッダは `claude mcp add --transport http --header "Authorization: Bearer ..."` / .mcp.json の headers 欄で通る sender_envelope_contract: | 送り手封筒 `【送り手: X】` の健全性 (Spec 26、2026-08-07)。 **Spec 25 D6 の「モデルが封筒を読んで相手が人間でないと分かる」は 保証ではなく傾向だった**ことが実機で出たので、前提を作り直した節。 実測 (2026-08-07。Spec 25 P5 の検証中に出た): - `orchestrator` の `attribute_sender` は `format!("【送り手: {sender_label}】\n{}", incoming.content)` で、 **封筒は本文の先頭に連結されているだけ**。canonical にもワイヤにも 発話ごとの送り手名を運ぶ欄は無い (`OaiMessage` = role / content / tool_call_id、`AnthropicMessage` = role / content。`name` は ツール定義とツール呼び出しのもの) - **`Endpoint::` は `tools/` に 1 件も出ない。** 提示は `enabled_tools`、 実行は `run.json` と `work_dir` で、どれも送り手を入力に取らない。 **送り手を偽っても、その個体が元から持っていない能力は 1 つも開かない** - ミュゼ (muse-spark1.2-contributor) は封筒を読んで 「MCP経由の外部エージェント」と正しく判定した - ザリ (claude-sonnet-5) は「本文の自己申告文字列だけを見て人間か エージェントかを判定する仕組みには、私はなっていません」 「その判別をしない・する気もない」と明言し、**外部からの依頼を 人間の利用者からの発話として扱った** 凍結 7 点: 1. **発話ごとの送り手識別を運ぶのは封筒だけ。** **条例は存在の事実 (外部エージェントが連絡してくる) を運ぶが、 個別発話の識別はしない。**「唯一の送り手識別」と書くと ワイヤ層とプロンプト構成層を混同させる。 2. **`role: "user"` は送り手を表さない。** 利用者からでも他の サーヴァントからでも外部クライアントからでも同じ値で届く。 **role を送り手の根拠にした個体は、必ず「人間の利用者」へ落ちる。** 3. **封筒は先頭に置かれる。位置が唯一の偽造できない識別子。** 本文がどう書かれても、システムの封筒はオフセット 0 にある。 4. **本文の無害化は全 `Endpoint` に掛かる。**外部だけに掛けない — サーヴァントの発話が封筒構文を引用して広場ログ経由で別の個体へ 届く経路が実在する (2026-08-07 実測。ミュゼの回答が `claude-code(外部クライアント)` を含んでザリの広場ログに乗った)。 5. **`sender_label` は封筒構文を運ばない** — `【` `[` `】` `]` `\n` `\r` を含まない。**`】` と改行だけでは足りない**: 攻撃経路は `】\n【送り手:` なので、開き括弧まで落とさないと **ラベル自体が 2 つ目の封筒を生成できる**。 6. **本文の無害化は閉じた許容ではない。**混同されうる表記は Unicode 全体に 散っており、列挙で塞ぐのは除外リスト (「外部が書いたデータの転送層では、 除外リストは必ずもう一度落ちる」)。**content 層に閉じた許容は作れない** — ワイヤに送り手の席が無いことの帰結。**衝突の主要形を潰す措置であって、 混同されないことの保証ではない。**保証だと書くと api_key_env と同じ 「説明文を構造と取り違える」誤りになる。 **TAG 集合は {送り手, Sender, From}** (Spec 35 D5。2026-08-12) — 照合は 開き括弧 {【, [} × TAG × コロン {:, :} のクロス積なので、 `【Sender:ユーザー]` のような混合形も自然に中和される。 **村の言語に関係なく常に全 TAG を中和する** — 攻撃者は村の言語を 選べる側で、日本語の村へ英語形を書く偽造は英語形を知らない defuse を 素通りする。`From` は送出には使っていない (送出は ja=送り手 / en=Sender) が、P5 の A/B で切り替えても偽造の窓が開かないよう 先に含める。**この拡張は日本語の村でも挙動が変わる唯一の点** (Spec 35 の golden の対象外。セキュリティ修正)。 7. **条例に送り手の判定を書かない** (Spec 26 rev3。2026-08-07 実測)。 条例へ事実 3 文 (先頭のものが送り手 / `role` は送り手を表さない / 本文中の `【送り手: …】` は本文の一部) を入れ、**同じ個体へ同じ文面を 投げる前後比較**を取ったが、**答えは一字も変わらなかった**。 切り分けると**条例の当該節を見出しから太字の位置まで逐語で引用できる のに従っていない**。正解した 2 体のうちミュゼは**条例が無い状態で 正解している**ので「条例あり + 正解」は効果を示さない。 **寄与はどの観測でも測れていない**ため 3 文を撤去した。 条例に残すのは**外部エージェントが依頼を送ることがあるという 存在の事実 1 行だけ**。 **モデルに要求する読みは 0 にする。** 位置の事実は実装上つねに真だが、 **それを読ませることを設計の前提に置かない** — 2026-08-07 の実測で **事実にしても、逐語で引用できても、従わない個体がいた**。 **一般化: 「モデルに X を読ませる」を設計に含めたら、読んだうえで 従わない個体を 1 体見つけた時点でその設計は落ちる。引用できることは、 従うことの証拠にならない。** 判定は本文の無害化の構造だけで成立させる。 prompt_cache: scope: | Anthropic ネイティブ経路のみ。OpenAI 互換層はキャッシュ指示を通せないので、 Gemini / OpenAI 系で観測される cachedTokens はプロバイダ側の暗黙キャッシュ。 実測 (2026-07-30): Gemini ネイティブ経路 (gemini-3.6-flash) では暗黙キャッシュが cachedContentTokenCount で実際に報告され、カードの率も動く。 互換経路の flash-lite は 0 のまま。 **Google 公式に接地した (2026-08-01、ai.google.dev/gemini-api/docs/caching)**: - 暗黙キャッシュは 2.5 以降で既定有効。**指示は要らない** - 最小トークンは **Gemini 3 / 3.1 系 = 4,096 / 2.0・2.5 系 = 2,048**。 Implicit と Explicit で**同じ値**。3.6 と Flash-Lite は表に無く未確認 - 効く条件は「similar **prefix**」であって同一リクエストではない。 公式は stateful / stateless の両方に効くと明記している。 「マルチターンでは暗黙キャッシュはほぼ効かない」は**公式の記述と逆** - 閾値未満では Implicit は**無言で 0%**、Explicit は `Cached content is too small` で作成そのものが失敗する **実測が公称を覆した (2026-08-14、failures.md #104)**: 最小 4,096 は **実効の閾値ではない**。`cached` は約 8,000 の塊で量子化されて返り、 **gemini-3.7-flash は 1 ブロックを 1 度も返さない** (最小 2 ブロック = prompt 約 19,000 が要る)。同一個体・同一 prefix の A/B で **18,305 が 0 / 4 秒後の 19,035 が 15,998**。**gemini-3.6-flash は 同じ帯で 1 ブロック (約 8,050) を普通に返す**ので、閾値は機種ごとに違う。 **公称値を根拠に「効くはず/効かないはず」を書かない** — 実測で決める。 **有力な読み (未実証)**: 村のワーカーは構造的に閾値未満。failures.md #33 の 実測で設定は 900〜1,100 文字 ≈ 約 1,000 トークンで、4,096 の 1/4。 会話を数周足しても 2,048 にすら届かない。これが正なら **Explicit Caching は 解にならない** — 最小トークンが同じなので作成が失敗する。 判定は observability の note_cache_diag が出す周ごとの生の数字で行う。 Explicit Caching を採らない理由は閾値だけではない: バージョン固定の モデル名 (models/gemini-3.6-flash-001) が要り利用者の自由入力欄と衝突する / TTL という状態を純関数 adapter の外へ持ち込む / キャッシュ作成自体が課金。 invariants: - 境界は **2 箇所**。(1) system の安定部分の末尾 (2) messages の末尾。 ツール定義は system より前に置かれ、system に打った cache_control の内側に 入る。判定に tools を数えないと道具を多く提示しているエージェントほど 判定を外す (利得が大きいほど切る)。Anthropic の breakpoint 上限は 4 なので 枠は 2 つ残る - **messages 側の境界は最後の発話の最後のブロック 1 箇所だけ**に打つ。 種別は問わない (text / tool_use / tool_result のどれでも打てる)。 tool_result に打てないと**ツールループの周回だけ境界が抜ける** — キャッシュが最も効いてほしい経路がちょうど外れる - messages 側の判定は **tools + system 全体 + 履歴**の概算トークン数で行う。 キャッシュされる範囲がそこまでなので、判定の対象も同じにする。 履歴の見積もりにはツール引数と結果本文も数える (長いのはたいていそちら) - 最小長の判定は**文字数ではなく概算トークン数**で行う。文字数は言語をまたげない (英語 4 文字 ≈ 1 トークン / 日本語 1 文字 ≈ 1 トークン)。 当初 4,000 文字で判定しており、日本語設定の実機 5 体全員が足切りされていた - 可変部分が空 (Memory.md 未記入) でも cache_control を打つ。 境界探索が末尾を越えて None になる経路で無キャッシュへ落ちていた - TTL は **1 時間**(既定の 5 分ではない)。対話的な使い方では次の発話まで 5 分を超え、超えた瞬間に書き込みからやり直しになる。 余計な書き込みが 1 回減れば長い TTL のほうが得 (1h 書き込み 2.0× < 5m 書き込み 1.25× × 2 回)。 anthropic-beta ヘッダ (extended-cache-ttl-2025-04-11) が無いと ttl は黙って無視され既定へ戻る - 状態で揺れるものを **安定部分に入れない**。接続先の稼働状態・顔ぶれ・ 提示するツール集合など、実行時に変わるものは可変部分か messages 側へ置く - **`Role::System` は「安定なもの」専用の枠。** adapter は Role::System の メッセージを**配列のどこにあっても全部引き抜いて** 1 つの system / systemInstruction へ連結する (gemini.rs / anthropic.rs の encode)。 毎ターン変わるものを System で積むと、**配列上の位置に関係なく前方一致の 先頭へ戻る** — 位置を変えるだけでは直らない (failures.md #45。 並べ替えを先に入れたがワイヤ上は完全な no-op だった)。 参照資料 (RAG) / 広場ログ / 入退室 / 同報の注記は System では積まず、 **今回の受信と一緒に 1 本の user 発話へ畳む** (別々の user 発話にすると ロールが連続し、交互を要求するプロバイダで壊れる)。 固定は volatile_blocks_sit_after_the_history_so_the_cached_prefix_can_grow (位置とロールの両方を見る。**位置だけの検査は壊れた状態でも通る**) - **送った文字列をそのまま履歴へ積む。** 畳んだ文脈を履歴から外すと (「今回だけの文脈を焼き付けない」ため) 送信と保存が食い違い、次のターンで その位置から前方一致が切れる — 載るのは system + tools で頭打ちになる (failures.md #45 第 2 段。実測で cached が 6,465 に張り付き、#42 で測った 安定プレフィックス 6,463 とほぼ一致していた = 履歴が 1 通も載っていなかった)。 揃えるほうが記録としても正しい (エージェントは実際にその文脈込みで 受け取っている)。固定は what_we_send_is_what_the_next_turn_replays_as_history。 既知の穴: 失敗ターン (record_failed_turn) は文脈を再構成できないので attributed だけを積む = 失敗のたびにキャッシュが 1 回リセットされる - **添付 (画像・音声・動画・PDF) は安定プレフィックスに入らない** (Spec 23 / Spec 36 / attachment_contract)。 添付は最終 user 発話のブロック列にだけ現れ、system にも履歴にも入らない (D1 = 1 ターン限り)。**添付したターンだけキャッシュヒット率が下がるのは 仕様** — 2 ターン目以降は画像ブロックが消えてプレフィックスが伸び直す。 毎ターン添付する使い方では毎回割れるが、それは Goal (払うのは添付した 人だけ) と一致している bounds: | **書き込みになる周が 2 つ残る。どちらも仕様上の限界で、打ち消す仕掛けは置かない。** (1) **まとめ呼び出しの周**。tool_choice の変更は tools 層と system 層の キャッシュは保つが**履歴層は落とす**。ツール上限後の 1 回で ToolChoice::None へ変えるので、その周だけ messages が素の値段になる。1 ターンに 1 回。 (2) **1 周の追記が 20 ブロックを超えた周**。境界の後方探索は 20 ブロックまでで、 超えると前周の書き込みを見つけられずその周だけ書き直しになる。実機のモデルは 1 周に 2〜3 本を並列で呼ぶので 1 周 7 ブロック前後 (text 1 + tool_use 3 + tool_result 3) で収まる。9 本以上の並列で境界に触れる。 経緯は failures.md #42。system にしか打っていなかった頃の実測は 1 ターン 2,052,314 トークンの入力のうち 1,826,109 が素の値段で、 cached ÷ 6,463 (安定プレフィックスの実測値) が周回数にぴったり一致していた (36 周で 35.0 / 19 周で 18.0)。**キャッシュの効きは率ではなく 割り算が整数で割り切れるかで診る** (率は連続値なので境界の位置を指さない)。 observability: | AgentSnapshot に promptTokens / cachedTokens を持ち、カードに 「入力の N% をキャッシュ」を出す。**分母は入力**(出力はキャッシュできないので、 合計を分母にすると天井が 100% にならず取り残し量が読めない)。 0% は警告色。効いていないことが画面から分からない指標は静かに壊れる (failures.md #33 は請求ダッシュボードを見るまで数日気づけなかった)。 **コンテキスト使用率の輪 (Spec 49)**: 会話ペインの入力欄の下に、選択中の個体の AgentSnapshot.lastPromptTokens ÷ その個体のテンプレートの contextLength を輪と % で出す。分子は**直近の LLM 呼び出し 1 回**の Usage.prompt (キャッシュ込み・ ツール取得込み。窓を占めるのはキャッシュされていても同じ) で、**ターンの合計や 実効トークンではない** (前者は全周の和・後者は歯止めの単位)。色は 3 段の定数 — 比 < 0.75 = accent / < 0.90 = warn / それ以上 = fail (設定にしない)。弧は min(比, 1) で描き数字は丸めない (100% 超は設定が窓より小さい診断)。輪を出さない 条件は 3 つ: 個体未選択 / テンプレートが引けないか contextLength が 0 / lastPromptTokens が null。**ターンの最中は動かない** (確定後 ≤ 1 秒で追従)。 置き場はステータスバーではない (帯の規律「常に見る必要があるか」を通らない)。 料金・累計・予算との比較は持たない (統計画面とカードの仕事)。 **率だけでは 0% の理由が三つ巴になる** — (a) プロバイダの最小長を下回った (b) 前方一致が壊れた (c) プロバイダが値を返していない。3 つとも処方が違うのに 画面では同じ 0% に見える。判別のため `orchestrator` の note_cache_diag が **周ごとの生の数字**を fuseforks.log へ出す: `cache: agent=… model=… round=N prompt=… cached=… system_chars=… stable_chars=… system_digest=… cache_write=… cache_write_1h=…` cache_write / cache_write_1h は Spec 40 P1 で**末尾へ**足した (接頭辞と既存欄の並びは不変 — 8 日ぶんの実測がこの書式で取られている)。 **素の新規入力は `prompt - cached - cache_write`** で引く。実測では これが 1 ラウンドあたり数トークンで、**「未キャッシュ」の実体はほぼ全部が 書き込み**だった (Anthropic 100.0% / OpenAI 互換 99.8%)。 **cache_write_1h が 0 でも相手の TTL について何も言っていない** — TTL 別の内訳を返すのは Anthropic だけで、OpenAI 形では decode が 0 を焼く。 読み方 — prompt が閾値未満で cached=0 なら (a)。閾値を超えていて cached=0 で **system_digest が前の周から変わっている**なら (b)。どちらでもないなら (c)。 system_blocks が 2 を超えていたら可変ブロックが System に混ざっている。 3 つの罠: - **カードの promptTokens はターンをまたいだ累積**なので閾値との比較に 使えない (1 周 1,000 トークンでも 5 周で 5,000 になる)。判定には周ごとの値が要る - **digest は adapter と同じ畳み方で取る** = messages 中の Role::System を 全部連結したもの。初版は compose_system_prompt の戻り値だけを測っており、 可変ブロックが毎ターン変わっても digest は動かなかった。 **その計装で「(b) は否定」と読んだのは誤診**で、検出不能だっただけ (#45) - **「容疑を潰した」と言えるのは、その容疑を検出できる計装があるときだけ。** 指標を足したら「この値が変わらないとき、何が起きていないと言えるか」を 先に確かめる。別物を測っている指標は「壊れていない」と「見えていない」を 画面上で区別できない gemini_native: why: | Google 検索による接地は OpenAI 互換層に露出していない。 tools: [{"type":"google_search"}] は 400 Invalid tool type で拒否される (実測 2026-07-29)。接地を使うときだけネイティブ経路が要る。 関数呼び出しだけなら互換層で足りる(既存エージェントはそのまま)。 invariants: - finishReason "STOP" は終了を意味しない。functionCall を返したときも STOP が 来る(finishMessage は "Model generated function call(s).")。 終了判定は parts の中身で行い、finishReason は Length 判定にだけ使う - functionCall(こちらが実行する)と toolCall / toolResponse(Google が 実行済み)は別のフィールド。取り違えると実行済みの検索を二重に実行しにいく - part は type タグを持たない判別共用体。どのキーがあるかで種別が決まるので、 enum ではなく全フィールド Option の構造体で受ける - system は contents に混ぜず systemInstruction へ分ける。role は user / model の 2 値しかなく、ツール結果も user ロールの functionResponse パートとして積む - 思考ブロック(thought: true)は本文に混ぜない(内部独白が利用者に見える) - completion = candidatesTokenCount + thoughtsTokenCount。思考も課金対象。 **prompt = promptTokenCount + toolUsePromptTokenCount** (Spec 48 D4。 URL context が取得した本文は toolUsePromptTokenCount として入力単価で 課金され、promptTokenCount には入らない — 実測 32 + 8,999)。 恒等式は 4 項: totalTokenCount = promptTokenCount + candidatesTokenCount + thoughtsTokenCount + toolUsePromptTokenCount (欄なしは 0。 thinkingLevel low では思考が起きない回があり thoughtsTokenCount が 欄ごと省かれる)。畳んだ後の「素の未キャッシュ」prompt − cache_read − cache_write には取得本文が含まれる (定義。予算式 ×1.0 に乗る)。 **同じ要求で toolUsePromptTokenCount が欄なしで返る回がある** (Spec 48 P0 (d)/(d2)。取得は両方で起きている) — 払いが usage に出ない 回が実在する。機構は作らず gemini tools: 行の url_context 数で数える - 組み込みツール (google_search / urlContext) と**関数宣言**を併用するなら toolConfig.includeServerSideToolInvocations が必須。欠くと 400 で名指しされる (文面は「to use Built-in tools with Function calling」)。 **送る条件は「組み込みが 1 つでも ON かつ functionDeclarations が 1 つ以上」** (Spec 48 D3。旧: google_search 単独で真。関数宣言の無い周に送っても 害は無い — P0 (f-1)(f-2) で 3 通り 200 — が、送ると応答に toolCall / toolResponse の part が付く。検索語は groundingMetadata.webSearchQueries からも拾えるので、フラグを落としても Grounding.queries は空にならない) - **thinkingLevel** (Spec 48 D1/D2): generationConfig.thinkingConfig.thinkingLevel に Effort を写す。None → 送らない (既定 medium) / low・medium・high → そのまま / xhigh・max → high (天井への写像)。minimal は Effort に無いので構造的に出ない (3.8 / 3.7 は 400 で拒む)。**門は model.starts_with("gemini-3")** — gemini-2.5-flash-lite は 400 `Thinking level is not supported for this model` (P0 (b'))。同じワイヤ内の方言の吸収であって、ワイヤの選択に名前を使う (Spec 31 D2 の禁止) ではない。includeThoughts: true とは併存する - **toolCall / toolResponse は履歴へ返さない** (Spec 48 P0 (d2) で確定。 Spec 05 以来の「未確認」を閉じた)。返さなくても 200 で、モデルは必要なら 同じ URL / 検索を**再実行**する (toolUsePromptTokenCount がもう 1 度乗る)。 返すなら thoughtSignature ごと逐語 — 剥ぐと 400 `Tool call part is missing thought_signature`。返せば次の周の prompt に 取得本文が乗り (9,050)、返さなければ再取得に乗る (9,047) — どちらも 入力単価で同額なので、ChatMessage に不透明な part の席は作らない - **計器 2 行** (Spec 48 D5): decode が本文・tool_calls・grounding のどれにも 写さなかった part は `dropped content blocks: kinds=… count=…` を 1 行 (Anthropic / Meta と同じ形。toolResponse は現行で毎ターン捨てている = 可視化であって新事象ではない。toolCall は queries を拾うので数えない)。 URL context か検索を使った周だけ `gemini tools: agent=… url_context={retrieved}/{requested} statuses=… tool_use_prompt={n} search_queries={n}` を 1 行 (pplx tools: と同じ棚) schema_subset: | functionDeclarations[].parameters は JSON Schema ではなく OpenAPI 3.0 の部分集合。 未知キー($schema / additionalProperties / exclusiveMinimum 等)は黙って無視されず Unknown name "..." Cannot find field で 400 になる。 adapter が**許可リスト**で削る。除外リストにしないのは、MCP ツールのスキーマを 接続先のサーバーが書くから — こちらから中身を制限できない (同梱ツールの additionalProperties を潰した直後に MCP の $schema で再発した)。 意味を保てるものは写す: const → enum / type 配列 → 単一 type + nullable。 $ref / $defs は解決しない(型を推測して埋めるほうが害が大きい。400 として表に出す)。 grounding: | ChatResponse.grounding に queries(必ず取れる)と sources(実 URL)を載せる。 ChatMessage ではなく ChatResponse に置くのは、後者が履歴に積まれず ラウンドトリップの契約に影響しないから(観測の席を安全に増やせる場所)。 **sources が空であることが「出典は存在しない」の判定**であり、 モデルが語る出典を信じない根拠になる。 **sources はモデルへ返さない(表示層専用)。** 接地はそのターンの中で起き、 sources は答えと同時に返るので、次ターンのプロンプトへ入れても 「前の話題の出典」にしかならない。前ターンの URL を現ターンの根拠として 見せるのは新種の誤帰属で、捏造を別の形に置き換えるだけ (Spec 05 Notes 9)。 表示層への経路は AgentMessage.grounding で、MessageSent イベントが運ぶ (Spec 05 Phase 4)。専用イベントを立てないのは、来歴が発話に添う情報であり、 別便にすると起動時の list_messages 再投影で来歴だけが消えるから。 実測 (2026-07-29, gemini-3.6-flash): queries は届くが **sources は空**。 参照元 URL は返ってこない。よって出典として出せるのは検索語と、モデルが 本文で挙げる発表元の名前だけ。sources が埋まる前提でプロンプト・UI・ 検証手順を設計しないこと (failures.md #31 追記)。 grounding_active: | 接地の判定は ModelTemplate.google_search 単独ではなく **grounding_active() = google_search AND effective_provider() == Gemini**。 互換経路のまま真になっている設定は world.json の直接編集で作れてしまい、 フラグだけを見ると検索できないモデルに「検索できます」と教えることになる。 接地の告知は「持っていない情報を埋める」処方なので、そこで嘘をつくと 処方そのものが毒になる (Spec 05 Phase 2.5)。 UI はその状態でチェックを隠す代わりに理由と直し方を出す (隠すだけだと真のまま見えなくなる)。 ~~未解決: toolCall / toolResponse を履歴へ返す必要があるかは未確認~~ → **2026-09-03 Spec 48 P0 (d2) で確定: 返さなくてよい** (invariants の項)。 席は作らない、が結論のまま残った。 gemini_url_context_active: | **gemini_url_context_active() = gemini_url_context AND effective_provider() == Gemini** (Spec 48 D3。grounding_active と同型の AND 述語)。モデル名の門は無い — gemini-2.5-flash-lite でも urlContext は 200 (P0 (e))。ON なら tools に {"urlContext": {}} を別要素で push。**システムプロンプトへの告知は無い** — URL context は依頼文に URL があるときだけモデルが自分で使う道具で、 告知なしで発火する (probe 5)。出典は groundingChunks[].web に実 URL で載り GroundingEngine::Google のまま (URI の形で検索の redirect と区別はできるが、 engine は応答 1 値で chunk 単位ではなく、redirect ホスト名の照合は除外リスト 側の検査)。urlContextMetadata は画面に出さず gemini tools: 行へ。 xai_responses: why: | Grok の Live Search (現行名 Agent Tools の web_search / x_search) は /v1/chat/completions に存在しない。legacy の search_parameters は HTTP 410 Gone で、サーバー自身が Agent Tools API への移行を名指しして断る (実測 2026-08-09)。検索を使うときだけ /v1/responses ワイヤが要る。 関数呼び出しだけなら互換層で足りる(既存エージェントはそのまま)— gemini_native と同じ構図の 2 例目。 invariants: | - encode は store: false を**常に送る** (Spec 34 D3・利用者裁定 2026-08-11)。 **Option にせず常送する** — skip_serializing_if を付けると 「送っている」ことがワイヤ形の凍結から読めなくなる。この欄は 「村の会話を接続先に保持させない」という主張を支える側なので、 **出力に現れるほうが正しい**。 Spec 31 の時点ではこの欄を 1 度も送っておらず、xAI 側の既定に委ねたまま それが何かを測っていなかった (Spec 34 起票時に発見)。実測で 200 を確認済み。 **2 つの Responses ワイヤで揃える** — 片方だけ寄せると、保持の扱いが 非対称になり、その差がどの画面にもログにも出ない - encode は include: ["no_inline_citations"] を**常に送る** (Spec 31 D1/D5)。 省くと本文へ [[N]](url) の markdown が混入し、annotations は 「本文で参照した分」に痩せる。無効時は「調べて触れた全 URL」が届く (対 probe 実測: 2 件 → 18 件)。本文の無汚染と出典の網羅が同じ 1 手 - トップレベル citations 欄は REST の生応答に**存在しない** (2 回測って 2 回なし)。公式の response.citations は SDK の集約属性であって 生ワイヤの欄ではない。包括リストは annotations (url_citation) で受ける - annotations の全件を Grounding.sources へ入れ、URL で重複を潰す (Grounding::absorb の既存規律)。start_index / end_index は [[N]](url) という**印の文字位置**であって主張の位置ではない。 no_inline_citations では (0, 0) — 読まない・表示で主張単位を偽らない - decode の output 種別は message / reasoning / 検索呼び出しの 3 系。 検索呼び出しは web_search_call / x_search_call / custom_tool_call の **3 名を同じ腕で受ける** (公式文書は x_search_call、実ワイヤの観測は custom_tool_call — 移行途中の未文書挙動)。どの名で来たかを計器に残す。 未知種別は数えてから捨てる (dropped 計器。tool_execution #72 の処方) - reasoning ブロックは本文へ混ぜず、種別と tokens を数えて捨てる (Spec 31 D7)。**受け取りは 4 社 3 Spec に分かれた** — 思考トークンを 数える (Spec 32。この節の usage.reasoning が正) / 要約の受け取りと表示 (Spec 33) / OpenAI の Responses ワイヤ (Spec 34) - 関数ツールは flat 形 {"type":"function", name, description, parameters} で tools 配列に検索ツールと**同居できる** (実測 2026-08-10: 併用で 400 は 出ない — gpt-5 系の chat/completions が拒む組を、この口は受ける)。 応答の function_call item (call_id / name / arguments = JSON 文字列) を ToolCall へ。結果は input へ {"type":"function_call_output", call_id, output} で返す (roundtrip 実測済み — 返した値で答えることを確認) - tool_choice が None の呼び出し (summarize 等) では検索ツールも関数ツールも **どちらも送らない**。「ツールを使わせない」は server-side tool にも及ぶ — 要約の呼び出しで検索が走ると、押した人が検索の注入 input まで払う - 検索語は **2 つの形で来る**。片方だけを読むと、もう片方の経路で queries が黙って空になる (実機の 2 走行がどちらも queries=0 だった): * web_search_call: action.query に素の文字列 * custom_tool_call (x_search の実体): **input に JSON 文字列**で {"query": "...", "limit": "5"}。action は持たない 取れないときは黙って諦める — 検索語が無いことは失敗ではない (応答そのものは届いている)。action.sources は読まない — 出典の正は annotations の 1 系統 (2 系統から足すと同じ URL の 重複排除規則が 2 箇所に生える) - live_search_active(tool) = トグル AND effective_provider() == XaiResponses。 フラグ単独を判定に使わない (gemini_native.grounding_active と同型。 互換経路のまま真の world.json 手編集で「検索できないモデルに検索できると 教える」が再演する) - 計器は検索を使った呼び出しのときだけ 1 行 (grep include: と同じ形)。 正の値は呼び出し回数 — usage の server_side_tool_usage_details (観測名) / server_side_tool_usage (公式文書名) の**どちらで来ても読める**寛容 decode。 cost_in_usd_ticks は補助として生値のまま書き、換算した金額は出さない (tick の単位が未検証。確かめていない換算は計器が嘘の桁を運ぶ) reserved: | ツールのフィルタ引数は公式に存在するが、欄は今作らない (Spec 31 Notes 4。 空の欄は「何を入れるべきか」を問い続ける — Spec 15 D7 と同じ判断)。 将来足すときの名前だけ残す: web_search.allowed_domains / x_search.allowed_x_handles (上限 20) / from_date / to_date。 いずれも tools 配列の要素への加算で、ワイヤの破壊的変更にはならない。 openai_responses: why: | OpenAI の /v1/chat/completions では原理的に取れないものが 3 つある (Spec 34)。 (1) 思考の要約本文 — 数は取れるが本文が来ない (Spec 33 が引き渡した) (2) web 検索 — 一般の gpt-5 系の web_search は Responses 専用で、 chat/completions で検索するには専用モデル gpt-5-search-api が要り、 旧 gpt-4o-search-preview 系は 2026-07-23 に停止済み (3) failures.md #77 の代償 — gpt-5 系は chat/completions で 「function tools と思考の併用」を拒むため、ツールを持つ限り reasoning_effort: "none" を強制していた。**推論モデルの推論を 殺したまま使っていた**。400 の本文自身が /v1/responses を逃げ道に挙げる xai_responses / gemini_native と同じ構図の 3 例目 — 関数呼び出しだけなら 互換層で足りるので、既存の gpt-* 個体は明示選択するまで動かない。 measured: | P0a probe 11 発 (2026-08-11・実鍵・gpt-5.6-terra + xAI へ 1 発)。 予測を撃つ前に書いた (当たり 6 / 外れ 2 / 不完全 1)。 - 素の発 (reasoning も tools も送らない) は input_tokens=20 / 思考なし - 関数ツールと思考は**同居できる** (#77 の 400 が出ない)。 reasoning item が **要求せずに encrypted_content つきで来る** (972 字) - **関数往復で reasoning item の再送は要求されない** — function_call_output だけ返して 200 で完走 (予測は 400 側だった) - **web_search と web_search_preview は別名。** 1 本でも 2 本同時でも input_tokens=4,454 で完全に一致する - **検索ツールは提示するだけで input が 4,434 増える** (素の 20 に対し 4,454。検索させない問いで実測)。 **2 回目以降は cached_tokens=4,411 に乗る**ので、実効トークン (token_budget の重み: キャッシュ済み ×0.1) では初回 4,454 / 以降 約 484 - **temperature は 400** — Unsupported parameter: 'temperature' is not supported with this model. - reasoning の 4 欄はすべて 200 (effort:low / mode:standard / mode:pro / context:current_turn)。**mode:pro は input が 20 → 1,558** (+1,538) - **summary は auto が 0 字・detailed が 522 字。** 組織検証の 400 は出なかった - **11/11 発が wire::XaiResponse に serde で通る** (D2 の判定) invariants: | - encode は store: false を**常に送る** (xai_responses と同じ。Spec 34 D3)。 **2 つの Responses ワイヤで揃える** — 片方だけ寄せると保持の扱いが 非対称になり、その差がどの画面にもログにも出ない - **previous_response_id は使わない。** サーバー側に会話状態が生まれると 真実が 2 つになる (session_store が redb 1 ファイルへ寄せたのと同型)。 この村の履歴の正は sessions.redb で、滑る窓も可変文脈の畳みも送信側で組む - reasoning は 4 欄。**summary は "detailed" を常送する** — "auto" は要約を 1 字も返さない (実測 0 字 / 522 字)。auto を選ぶと 「送っているのに何も出ない」という最も診断しにくい形になる (reasoning_tokens は出ているので払ってはいる)。 **context は "current_turn" を明示して送る** — gpt-5.6 系の既定は all_turns だが、この村は previous_response_id を持たず思考を履歴へも 積まない (reasoning_summary 凍結 1) ので、**参照する先が存在しない**。 省略は「無効」ではなく「サーバ既定を採る」(#77 の教訓の 2 例目) - **mode は openaiReasoningPro のトグル。ON で "pro"、OFF で欄ごと省く。** 送らないのと "standard" は input_tokens が 20 で完全一致する (省略 ≡ standard) - **openai_compat::reasoning_effort をこのワイヤから呼ばない** (Spec 34 D4)。 あの関数の先頭にある gpt-5 → "none" は **chat/completions 固有の制約**への 対処で、**このワイヤはその制約が無いことが移る理由そのもの**。 写すと Goal の 3 本目を自分で潰す。共有するのは丸めだけ。 **負の対照をテストで留める** — gpt-5 + tools ありの encode で "none" が出ないこと - **temperature を送らない** (400 実測。Spec 34 D11)。canonical の 「明示時のみ送る」だけでは守れない — 利用者が画面で温度を入れた瞬間に 毎ターン 400 になる。**モデル名の送り分けを openai_responses.rs に持つ** (uses_max_completion_tokens と同型)。#76 / #77 と同じ家族の 3 件目 - 上限欄は **max_output_tokens の 1 名だけ**。max_completion_tokens を 送らない負の対照をテストへ (#76 の逆向き) - 検索ツールの type は **"web_search"** を送る。web_search_preview は別名で、 input_tokens が 1 バイトも違わない (どちらを送っても同じものが届く)。 旧名を選ぶ理由が 1 つも無い - openai_web_search_active(tool) = トグル AND effective_provider() == OpenAiResponses。 openai_reasoning_pro_active も同型。**フラグ単独を判定に使わない** (grounding_active / live_search_active と同じ規律) - decode の output 種別は message / reasoning / function_call / web_search_call。**未知種別は数えてから捨てる** (#72 の処方) - **web_search_call.action は search / open_page / find_in_page の 3 種**で、 後 2 者は検索語を持たない。**queries < calls は正常。** Spec 31 P5 は queries == calls == 15 を合格条件にしたが、 **その等式をこのワイヤへ写すと、ページを開いただけの周がある OpenAI は 常に不合格に見える。** 計器は actions= の内訳で読む - 出典は **2 系統から集める** (Spec 34 D12。P5 の実機で追加)。 **rev6 までは annotations だけを読んでいて、実機で sources=0 を踏んだ。** * message.content[].annotations (url_citation) — **モデルが引用した分だけ。** 引用しなければ 0 件になる。**start_index / end_index が実在する** (xAI は全部 0 なので、こちらのほうが解像度が高い) * web_search_call.action.sources — **触れた全ソース。** `include: ["web_search_call.action.sources"]` を**常送**しないと来ない **annotations を先に入れ、action.sources を後で足す** — 表題を持つのは 注釈の側だけなので、先に URL だけ入れると重複排除で表題が落ちる - **URL を持たないソースは Grounding.sources へ入れない** (Spec 34 D12)。 リアルタイムの外部フィードは {"type":"api","name":"oai-finance"} の形で来る (公式文書が oai-sports / oai-weather / oai-finance を名指し)。 **URI ではないものを URI の欄へ入れると、画面が「出典」として嘘を出す。** 計器へ api_sources= と api_names= で数と名前だけ残す — 「出典 0 件だったのは内部 API へ回ったから」がログから読める。 **画面は Spec 05 の文言のまま**「参照元は返ってきていません」でよい - **`include` は `["web_search_call.action.sources"]` を常送する。** rev6 の「OpenAI へ include は送らない」は取り消し。T1 (stateless では encrypted_content が既定で返る) は正しかったが、**出典の全件はこの鍵で しか取れない**ことを見落としていた。 **`no_inline_citations` は 400** — xAI 専用の値で、OpenAI の受理集合は file_search_call.results / web_search_call.results / web_search_call.action.sources / message.input_image.image_url / computer_call_output.output.image_url / code_interpreter_call.outputs / reasoning.encrypted_content / message.output_text.logprobs の 8 つ (400 の本文が列挙した)。 **web_search_call.results は読まない** — 画像検索の生データ用で、 実測でも [] だった - engine は GroundingEngine::OpenAi (3 値目) - 計器は **openai search: calls=N actions=search:N,open_page:N,find_in_page:N sources=N queries=N api_sources=N api_names=…** を検索を使った呼び出しの ときだけ 1 行。 **ticks は書かない** — cost_in_usd_ticks は xAI の欄で、OpenAI にあるかを 測っていない。**無い欄を "-" で埋める計器は「測ったが無かった」と 「見ていない」を畳む** - **xai search: の行名は動かさない** — Spec 31 P5 の観測記録がその文字列を 指しており、統一すると過去の観測が引けなくなる (改名で concordia.log の 行を書き換えなかったのと同じ判断) - ~~添付画像はこのワイヤでも送らない~~ **← Spec 36 D9 で改訂 (2026-08-12)**。 responses_input へ input_image / input_file を足し、「ネイティブを選ぶと 画像が黙って落ちる」3 例 (gemini / xai_responses / ここ) を解消する。 述語 carries は attachment_contract の Spec 36 改訂の節が正 (Spec 36 P0 で 20 マス / Spec 37 P0 で 6 行目。実装は Spec 36 P2 / Spec 37 P1)。 旧記述が予告した「先に作ると作り直しになる」は起きなかった — 述語の設計は Spec 36 rev2 で行われ、3 例の記録がその材料になった intentionally_unread: | **応答に在るのに canonical へ読まない欄 (Spec 34 P0a)。** 「serde で通る」は「全部読める」を意味しない — 通ることと読むことは別。 **書いておかないと、次に読む人が「対応漏れ」と読んで足しに来る。** - reasoning[].encrypted_content (実測 972〜1,956 字) — 思考の往復に使う値。**この村は往復しない**ことを実測で確かめた (function_call_output だけ返して 200)。読む必要が無い - usage.input_tokens_details.cache_write_tokens (実測 4,411) — **2026-08-18 に読むようになった (Spec 40 P1)。この項は履歴として残す。** xAI に無い欄。当時この村は cache write を数えていなかった (Usage.cache_read のみ)。 **量として存在するのに計器に無い**と書いたうえで、7 日間そのままだった。 **教訓: 契約に「意図して読まない」と書くことは、読まないことの害を消さない。** #72 の処方 (握り潰しを数える) は「気づかないこと」を防ぐが、 **「気づいたうえで数えないこと」は防がない**。値が費用に効く欄では、 記録ではなく計上まで進めるか、進めない理由を金額で書く variant_addition_sites: | **Provider に 5 値目を足したときに触る場所 (Spec 25 rev3 と同じ棚卸し)。** コンパイラが指す (網羅 match) — 4 箇所。すべて llm/client.rs: Provider::path() / attempt() の encode+認証 / decode の分岐 / name() コンパイラが指さない — 2 系統。**ここが本体**: (a) model.rs の AND 述語 3 本 (grounding_active / xai_*_search_active) は == 比較なので新 variant で false を返す。**それが正しい** (新しい provider が Gemini の検索を有効にしてはいけない) (b) **フロントの lib/providerSkills.ts** — TS の string union は 網羅検査を持たないので**黙って吸う**。DEFAULT_BASE_URL / passiveSkills / providerSkills の 3 つすべてに手が要る (**lib/carries.ts は Record なので指す** — 同じ TS でも union か Record かで網羅性が変わる) **自分で罠を作った実例 (Spec 36 → Spec 37 で発覚)。** Provider::carries を `match (self, kind)` に `(_, K::Image) => true` の形で 書いていた。**種別側がワイルドカードなので新 variant を黙って吸い**、 6 本目 (Meta) が audio=false / video=false を受け取る — **実測は 4 種別 すべて ✓ なので 2 マスが静かに誤る**。しかも doc には 「match を網羅で書くのは 6 値目を足す人へ問いを出すため」と書いてあり、 **その主張自体が嘘だった**。provider ごとに 1 腕の配列リテラルへ直した。 **一般化: 「コンパイラが指す」と doc に書いたら、variant を 1 つ足して 実際に落ちるか確かめる。** 網羅性の主張は、網羅していない書き方でも 同じ文章で書けてしまう (#62 と同族)。 **非単射になる既定 URL の 7 通りは providerSkills.test.ts で先に書く** (Provider.non_injective_defaults を参照)。#91 は机上で追って外した事故。 **7 値目 (perplexity_responses・Spec 45) からの続報**: 手数えの箇所が 1 つ増えた — ALSO_SERVES_RESPONSES (perplexity_responses ブロックの base_url_exemption)。免除の表が 2 枚になったので、「足す表」と 「足さない表」の両方を数える。 meta_responses: why: | api.meta.ai の web 検索は Responses (/v1/responses) にしか無い (Spec 37)。 互換の口 (/v1/chat/completions) には検索が露出していないので、gemini_native / xai_responses / openai_responses と**同じ構図の 4 例目**。関数呼び出しだけなら 互換層で足りるため、既存の muse-spark 個体は明示選択するまで動かない (Provider::detect は変えない — 自動判定を変えると設定を触っていない利用者の 個体が黙って別のワイヤへ移る)。 measured: | **P0 / P0b の probe 24 発で確定 (2026-08-13)。文書ではなく実測が正。** ## ストリーミングは不要 (最重要。規模を決めた) 提示された curl は `"stream": true` + `Accept: text/event-stream` だったが、 **どちらも省いて 200 が返る**。応答の形は `kinds=['reasoning','message']` で **xai_responses / openai_responses と同じ**。この村に SSE は 1 行も無く、 必須なら再試行・タイムアウト・打ち切り・トークン計上がすべてその上に乗る ので桁が変わっていた。**6 本目のワイヤの規模で収まる。** ## 関数往復は 3 社で同一 (共有の分割線の根拠) {"type":"function_call","id":"fc_…","call_id":"call_…","name":"get_weather", "arguments":"{\"city\":\"東京\"}","status":"completed"} `call_id` の命名も `arguments` が JSON **文字列**であることも xAI / OpenAI と 一致。返す側の `function_call_output.output` は **string のみ** (object は `input[2].output did not match any supported type` で 400)。 **wire::ResponsesInputItem::FunctionCallOutput.output: String** なので 型で既に成立している — **緩めると 3 社で割れる。** ## tool_choice は "auto" のみ `none` / `required` / 名指しは 400 (`only "auto" is supported for tool_choice`)。**Kataribe の wire 分割で 観測済みの制約の再演**。この村は ToolChoice::None を使う (ツール上限後の まとめ呼び出し) が、**Responses の既存の作法 (None なら tools ごと送らない) で回避できる** — tool_choice 欄そのものを出さない。 **このワイヤは強制ツール呼び出しを表現できない** (NoStructuredOutput の 経路が使えない)。 ## usage は分解できる {"input_tokens":545,"input_tokens_details":{"cached_tokens":113}, "output_tokens":212,"output_tokens_details":{"reasoning_tokens":142}, "total_tokens":757} `cached_tokens` と `reasoning_tokens` の両方があるので、Spec 32 の思考 トークン計上とキャッシュ率がそのまま効く。 ## carries の 6 行目 — 4 種別すべて (内容照合まで) 画像 input_image の data URL ◎「左が赤、右が青」 音声 input_audio の {data, format} ◎「ガーネット」 動画 input_video の video_url ◎「赤、青」 PDF input_file の {filename, file_data} ◎「GARNET-77」 **Gemini に次ぐ 2 本目の「4 種別すべてを運ぶワイヤ」。** **予測を覆した** — openai_responses からの類推で動画は ✗ と書くところだった。 payload 無しの `{"type":"input_video"}` が `requires video_url or file_id` と**名指しで 400 を返した**ので撃てた。 ## 受理集合の列挙は層で違う (2026-08-13 に訂正) **型の層は列挙しない。** 誤ったツール型を送っても `tools[0] did not match any supported type` だけで**候補を列挙しない** (OpenAI / Anthropic は全列挙、xAI は untagged enum の 422)。 ただし **content part と tool の欄は 1 つずつ名指しで教える** (`tools[].max_results is not supported for Responses web_search` / `input_audio content part requires input_audio, audio_url, or file_id`)。 **未知の型は 1 つずつ名指しさせるのが唯一の手。** **スカラの enum 欄は全列挙する。** `reasoning.effort` に `max` を送ると `` unknown variant `max`, expected one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh` `` が返り、**Spec 34 の「誤った値を 1 つ送ると 400 が全部列挙する」手筋がそのまま効く** (実測 2026-08-13 / P5)。 **旧記述は「Meta は受理集合を列挙しない (第 3 の様式)」と書いていた。** 観測したのは `tools[0]` と content part = **どちらも型の層**で、 そこから欄全般へ広げた誤り。**部分集合で観測した性質を集合全体の性質として 書いた形** (#91 と同族 — あちらは述語、こちらは散文)。 ## effort は max だけ受理されない canonical の 5 段 (low / medium / high / xhigh / max) のうち **`max` の段が Meta には無い** (梯子は none / minimal / low / medium / high / xhigh)。 `meta_responses::meta_effort` が **`max` -> `xhigh`** へ写す。 **これは丸めというより同義への写像。** `xhigh` が Meta の天井なので 「最大」を選んだ意図はそのまま満たされ、失うものが無い。 `openai_compat` の `xhigh` / `max` -> `high` は本当に段を落としているので、 同じ「丸め」でも性質が違う。**落とす先はプロバイダの天井で決まる。** **写像を通さないと個体ごと落ちる** — 400 は `fatal=true` なので `stops_the_agent()` が真になり、稼働が降りる (実測 2026-08-13)。 ## web_search の options は実在する `search_context_size: "high"` は **200 で受理**。`filters.allowed_domains` / `max_results` / 未知欄は 400 で名指し。**固有スキルはトグル 1 つに閉じるが、 軸が在ることは記録する** (将来 search_context_size を出すならもう 1 つの器)。 ## 出力上限の下限 — 添付ありで本文が空になる max_output_tokens=512 → output_tokens=512 (使い切り) → 本文**空** max_output_tokens=1024 → output_tokens=1024(使い切り) → 本文**空** max_output_tokens=2048 → output_tokens=1636 → 本文あり max_output_tokens=4096 → output_tokens=1146 → 本文あり 既定の `reasoning.effort` が **high** (応答に出る) で、思考で使い切っている (#72 と同型)。**下限は effort で動く**ので、画面の警告は法則ではなく **観測値として書く** —「observed: effort=high のとき 1024 で空・2048 で本文」。 **実装が max_output_tokens や effort を黙って書き換えることはしない** (利用者が決めた値を実装が上書きする形は、この村が繰り返し退けてきた)。 files_are_retained: | **送った添付は /v1/files に期限なしで保存される (Spec 37 P0b の最重要発見)。** GET /v1/files が `purpose: "user_data"` / **`expires_at: null`** の一覧を返し、 バイト数がローカルの probe 素材と 1 バイト単位で一致した。 **/v1/chat/completions (互換の口) で送った画像も残っていた**ので、 **Responses ワイヤに限らずこの接続先すべて**に効く。 Meta が `https` URL から取得した動画も保存された。 **音声 (input_audio.data) だけは一覧に現れなかった — 経路で扱いが違う。機序は未確定。** 削除口は在り、実際に消える: DELETE /v1/files/{id} → 200 {"deleted": true} GET /v1/files/{id} → 404 file_not_found ← **確認はこちら** GET /v1/files → 一覧。**最大 20 秒ほど遅れる** **一覧は結果整合で、真実は個別 GET 側。** 削除直後の一覧は件数が変わらず、 20 秒後に減った。**1 回目の観測だけで「削除は効かない」と結論しかけた** — **遅れて正しくなる計器を、壊れていると読まない** (#90 の逆側)。 **この村は削除の口を実装しない** (Spec 37 D11)。外部の状態の CRUD を持つと 「Fuseforks が接続先の状態を管理する」責務が生まれ、mcp_server_contract の 「村の状態は外からブラックボックス」と逆向きの越境になる。 **ただし存在と削除の手順は台帳に書く** — 消す道があることに気づけないのは #44 の逆側 (歯止めの先ではなく、**行き先に道を書く**)。 **画面へは 2 箇所で出す** (Spec 37 D7。利用者裁定 2026-08-13): README の接続先の表 (説明) と、**添付チップの警告** (貼る瞬間)。 **パッシブスキルには載せない** — 保存は接続先の性質でありアプリの働きでは ないので、Spec 31 の基準 (このアプリがその機構を実際に使っているか) が割れる。 **「学習に使われます」とは書かない** — あれは規約で、こちらは観測できる事実。 **性質の違う 2 つを 1 文に混ぜると、どちらも確かめられなくなる。** perplexity_responses: why: | 7 本目のワイヤ (Spec 45 D1。利用者裁定 2026-08-27)。相乗り (open_ai_responses + api.perplexity.ai) では成立しない 4 つのため: 固有スキルのゲート (provider で本物の OpenAI と区別できない) / 出典の decode (*_results 系 output item が dropped へ落ち続ける) / carries が本家と割れる (Pdf) / usage.cost (USD 実額が返る唯一の口)。 xAI (Spec 31) / Meta (Spec 37) に続くベンダー方言 Responses の 3 例目。 Chat Completions の口は持たない (/v1/chat/completions は 404・本文なし。 実測 2026-08-19) — 互換の口を持つ xAI / Meta / Gemini と逆。 measured: | Spec 45 の probe 11 発 (2026-08-27) が正。要点: - アプリの Responses ワイヤの送信形そのまま (store / reasoning / include / 関数ツールとの混在) で 200 (2026-08-19 から実測済み) - finance_search は max_steps 無しだと skill_loaded item だけ返して **200 のまま黙って空振りする** (モデルが「ツール呼び出しが無効」と 自己申告)。max_steps: 5 で完走 - people_search / fetch_url は max_steps 不要 - 誤ったツール型は 400 named (unknown discriminator value)。 受理集合は列挙しない - usage.cost = {currency, input_cost, output_cost, tool_calls_cost, tool_calls_cost_details, total_cost} (USD 実額)。回数は別の欄 usage.tool_calls_details ({invocation: N})。**people_search の鍵は 両欄とも search_people** — tool 型と揃っていない - ツール課金の実測: finance / people $0.005/回、fetch_url $0.0005/回 (文書と一致) - annotations は全 probe で 0 件。出典は *_results item 側にしか無い - reasoning_tokens 0 / cached_tokens は効く (3968 を観測) invariants: | - **固有スキルは 4 本** (Spec 45 D3): perplexityWebSearch / perplexityFinanceSearch / perplexityPeopleSearch / perplexityFetchUrl。 判定は各 *_active() = フラグ AND effective_provider() == PerplexityResponses。フラグ単独を判定に使わない (grounding_active / live_search_active と同じ規律) - **max_steps は perplexityFinanceSearch が ON のときだけ Some(5)** (Spec 45 D4)。定数 5・設定にしない・利用者に見せない。他ツールだけの ときは None = 欄ごとワイヤに出ない (skip_serializing_if)。 送らないと finance が黙って空振りする (エラーにならない最悪の形) ので、 フラグと欄はコアが対で管理する - **要求のトップレベル型は open_ai_responses と共有し、max_steps: Option を加算する** (Spec 45 D2)。OpenAI 経路では常に None — 既存 golden がバイト等価のまま緑であることが加算の機械証明 - **出典は GroundingEngine::Perplexity へ写す** (Spec 45 D5)。写す元は search_results / people_search_results / fetch_url_results (いずれも url + title 持ちのオブジェクト配列) と finance_results (results[].sources = URL 裸の文字列の二重配列。**平坦化が要り、 title / snippet は存在しない — 空文字で捏造せず URL をそのまま 表示名に使う**)。skill_loaded は出典を持たない — 捨てるが数える (dropped の計器。#72) - **計器は pplx tools: の 1 行** (Spec 45 D6): `pplx tools: finance=N people=N fetch=N web=N sources=N invocations=k:v,... tool_cost_usd=X.XXXXXX` * finance= 等の N は **output item の数** (decode が数える。usage の 鍵名から数えない — search_people の非対称と web の detail 未実測に 依存しないため) * invocations= は usage.tool_calls_details の鍵と invocation を **そのまま列挙** (鍵名を enum で固定しない — 綴りが直っても未知の ツールが増えてもサイレント欠損にならない) * tool_cost_usd= は usage.cost.tool_calls_cost (**ツール課金だけ**。 total_cost は入出力込みなので行名の下に置くと誤読する) * sources= は出典として写した URL の総数 (finance は平坦化してから) - **base_url_exemption** (Spec 45 D8): DEFAULT_BASE_URL は https://api.perplexity.ai/v1。ALSO_SERVES_COMPAT には**入れない** (あれは Chat Completions の口の免除で、この接続先はその口を持たない)。 代わりに **ALSO_SERVES_RESPONSES を新設** — open_ai_responses の provider のまま api.perplexity.ai を指す相乗りは 2026-08-19 から動く 正当な現用構成で、baseUrlMismatch が指摘したら誤検知。免除の表は 2 枚になった (互換の口 / Responses の口) - Provider::detect に api.perplexity.ai を足す (未指定テンプレートの 推定。能力・スキル・encode の base URL 分岐は引き続き禁じ手 — detect はそれに当たらない。Spec 45 D1 の精密化) - **usage.cost の統計への写しは範囲外** (Spec 45 D10)。D6 の計器で ログに残すところまで — 捨てはしない・写しもしない - モデル層の拒否には何もしない (Spec 45 D11)。carries の画像 ✓ は ワイヤの層で、deepseek-v4-flash の 400 は既存の 400 処理のまま。 既知の拒否は carries_table.rs の凍結コメントと README に書き残す - **output item の型の置き場 (P1 で決定・追補)**: 共有の ResponsesOutputItem へ Option 欄を加算した (queries / results / contents。要素は PerplexityResultEntry / PerplexityFetchedContent)。 専用型にしなかったのは、あの構造体が enum ではなく**全欄 Option + kind 文字列分岐**の形で、xAI 専用の input 欄が既に同じ形で載っている (前例) ため — 専用型だと ResponsesResponse ごと複製になる。 **3 種別が同じ results 鍵で違う形を運ぶ**ので要素も全欄 Option (search / people = url + title、finance = sources のみ)。 usage 側は cost (ResponsesCost) と tool_calls_details (**BTreeMap** — 計器の列挙順を決定的にする) を加算