# failures.md — AppPromoVideo 罠台帳 事故と罠の記録。**症状 → 真因 → 処方 → 一般化 → 接地の限界** の順で書く。番号は通し。 ## crates/promo_core (2026-09-07 spec 01 rev2) ### 1. 上限を別の単位で決めると、総量が部分の和より小さくなる **症状**: RepoBrief の初案は tree 400 行・README 8,000 字・manifest 5×2,000 字・**総量 32 KB**。最悪ケースの PoC (`worst_case_render_fits_total_budget`) が落ちた。 **真因**: 部分の上限は文字数、総量はバイト。しかも tree の 1 行に上限が無く、部分の和 (36 KB+、多バイトなら 3 倍) が総量を超えていた。総量は「守れない約束」だった。 **処方**: 単位を文字数で統一 (`RENDER_MAX_CHARS = 64_000`)、tree の 1 行にも上限 (100 字)。最悪 ≈ 59.5k 字を 多バイト文字で埋めた PoC で固定。 **一般化**: 上限を複数持つ契約は、**最悪ケースを 1 本のテストで組み立てて総量を検算**する。単位が違う上限は 検算しないと矛盾に気づけない。 ## scripts (2026-09-08 fixture 採取) ### 2. PowerShell 7 の `2> file` はパイプライン内の native コマンドで同じファイルを二重に開く **症状** (ユーザー実行): `'…' | claude -p … 2> stderr.txt | Set-Content raw.jsonl` が `Out-File: The process cannot access the file 'stderr.txt' because it is being used by another process`。 さらに claude.exe が **stdin 待ちのまま残留** (PID 33524、`--max-turns 1` で 2 分以上生存) し、その後の 再実行も同じファイルで衝突し続けた。 **真因**: 2 つ重なっていた。①PowerShell が `2>` を Out-File で実装するため、パイプラインの Out-File と競合する。 ②失敗した起動が claude.exe を残し、それが stderr.txt を掴んでいた。②のせいで「①を直しても同じエラー」に見えた。 **処方**: (a) リダイレクトを PowerShell に任せず `Start-Process -RedirectStandardInput/Output/Error` (本文は prompt.txt から stdin へ)。(b) 「同じファイルで衝突」が続いたら `Get-CimInstance Win32_Process` でコマンドラインから 残留プロセスを特定して止める (`json-schema|apppromo_fixture` で grep)。 **一般化**: 「直したのに同じエラー」は**前回の失敗の残骸が原因を隠している**ことがある。処方を変える前に、 ファイルを掴んでいるプロセスを見る。cli_runner の Phase A で「timeout 後に子孫が残らない」を PoC にする理由が ここでも出た (残留プロセスは次の実行を壊す)。 ### 3. `Start-Process -ArgumentList` は引数を空白で連結するだけで引用符を付けない **症状**: `--json-schema {"type":…}` が `Error: --json-schema is not valid JSON: JSON Parse error: Expected '}'`。 **真因**: Start-Process は配列を空白連結してコマンドラインにするだけで、要素の `"` を保護しない。JSON の `"` が そのまま CRT の引数分割に食われた。 **処方**: Windows CRT の規則で `"` を `\"` に逃がし全体を `"` で囲む (`'"' + ($schema -replace '"','\"') + '"'`)。 **cli_runner (Rust) には無関係** — `tokio::process::Command::arg` は要素ごとに正しく引用する。この罠は採取 スクリプトだけのもの。 **一般化**: JSON を引数に載せる経路は、引用の責任が誰にあるかを確認する。Rust 側で argv を組む本番経路と、 PowerShell で手作りする採取経路は責任の所在が違う。 **接地の限界**: 修正後の実行は本セッション内なので認証で落ちる (`authentication_failed`)。schema が受理された こと (エラーが schema → 認証に変わった) までは確認済み。成功 fixture はユーザー端末での実行待ち。 ## crates/cli_runner (2026-09-08 fixture 採取 2 回目) ### 4. 「採取できた」は成功ではなかった — 401 は 10 回・約 2.5 分再試行してから落ちる **症状**: ユーザーが「fixture 採取できた」と報告。しかし `fixtures/claude_json_schema_ok.jsonl` は存在せず、 temp の raw.jsonl は `system/api_retry` (401 `authentication_failed`, attempt 1..10, 遅延 0.5s→38s) が 並び、最後に assistant(error) + result(is_error)。ユーザーの claude.exe (PID 19856) は報告時点でまだ 再試行中だった。 **真因**: ①採取スクリプトは成功時だけ fixture を残す設計 (前回の修正) なので、失敗時に「3 lines -> …」の 行が出ても最後に消える — 途中の出力を成功と読んだ。②claude CLI は 401 でも 10 回再試行する。 再試行しても直らない種別 (認証) に 2.5 分を使い、その間ユーザーには何も見えない。 **処方**: (a) 失敗ログをそのまま `claude_auth_retry_loop.jsonl` として fixture 化 (api_retry を redact で 落とさないよう修正)。(b) `ParsedLine::ApiRetry` を追加し、`early_abort()` で `authentication_failed` / `oauth_org_not_allowed` の初回再試行で runner がプロセスを止めて `CliError::Auth` を返す (rate_limit 等は CLI に任せる)。(c) 採取スクリプトの最終行を `SUCCESS` / `NOT a success run` の 2 値に固定済み — 報告は その行で判断する。 **一般化**: **失敗を試みる側 (CLI) の再試行方針と、直る見込みのない失敗の種別は別**。呼び出し側は 「何回再試行したか」でなく「再試行で直る種別か」で打ち切る。また、採取・計測の報告は「ファイルが在るか」 で確認する (人の報告は途中経過を読み違える)。 **分離済み (同日 0:13)**: 3 回目の実行 (PID 2860) も 401 ループ。親チェーンは pwsh → **WindowsTerminal.exe → explorer.exe** で、Claude デスクトップは経路に無い。よって「子セッションの環境変数継承」は棄却、 **この機体の claude CLI の OAuth 自体が期限切れ**で確定。デスクトップアプリのセッションが動いていることは CLI の資格情報が生きている証拠にならない (別の資格情報ストア)。処方 = CLI 側で再ログインしてから採取。 **設計への含意 (初案、同日撤回)**: 「`early_abort` で初回 401 を即座に『再ログインしてください』に変換する」 と書いたが、下の成功 fixture で反証された。 **決着 (同日 0:20)**: `claude auth status --text` = `Login: Expired`。ユーザーも対話で承認要求が出ることを確認 (= OAuth 期限切れ)。一方、Windows の **User スコープに `ANTHROPIC_API_KEY` が設定されており**、それを プロセス環境に適用して採取スクリプトを回すと**成功** (`claude_json_schema_ok.jsonl`)。`-p` は OAuth が 切れていても API キーがあれば動く。**`auth status` は OAuth しか見ない** (API キーで動く状態でも `loggedIn: false`) ので、起動前検査に使うなら「loggedIn=false かつ ANTHROPIC_API_KEY 未設定」の組だけを 『認証なし』と判定する。ユーザーの端末で 401 になった理由 (その端末のプロセスに User スコープの変数が 載っていなかった可能性) は未分離。 **反証 (同日 0:25)**: 成功 run の `api_retry` 7 回は**すべて 401 `authentication_failed`** だった (78.5 s のうち約 70 s)。つまり「認証失敗の再試行は直らない」は誤りで、期限切れ OAuth で数回失敗した後に API キーで通る経路が実在する (切り替えの機序は未確認)。書いたばかりの `early_abort` (初回 401 で kill) は **この成功 run を殺していた**。テストが Red になったので撤回し、止めるのは終端の `assistant.error` だけ、 再試行は `retry_notice` で進捗に出してユーザーに待つ/中断を委ねる形にした。**「直らない種別」を自分の 推論で決めない — 直った実データが 1 本あれば推論は負ける。** `rate_limit_event` / `user` (tool_result) と いう未知 type も流れる。`--json-schema` の機序は **`StructuredOutput` という tool_use** (assistant → user の tool_result → result.structured_output)。--allowedTools に書かなくても動いた。 ## crates/image_gen (2026-09-08 Phase C 移植) ### 5. 契約に「公式上限」を書いたが、それは検証していない値だった **症状**: rev2 の `reference_limits.openai.api_max: 16` (「images/edits の image[] は最大 16 (公式)」) を前提に `select_refs` のテストを書いたら落ちた。移植した Kataribe の `max_refs` は **全プロバイダ 3**。 **真因**: 査読 9 への回答を急ぎ、OpenAI の API 仕様の記憶を「公式」と書いて契約に載せた。この workspace で 16 を裏づける実測も文書引用も無い。一方 Kataribe の 3 は live で通っている値。 **処方**: 契約を Kataribe の凍結値 3 に戻し、16 は「未検証なので採らない」と明記。テストは 3 で固定。 **一般化**: **契約に書く数値は、出典 (実測 or 文書の引用) を同じ行に書けないなら書かない。** 「公式」と いう語は出典ではない。§1 (上限の単位) と同族で、上限は最悪ケースの検算か出典のどちらかで裏づける。 ### 6. edition 2024 では `gen` が予約語 **症状**: `pub mod gen;` と `let gen = …` が `expected identifier, found reserved keyword` で落ちた。 **処方**: モジュールは `generator`、変数は `generator` に改名。Kataribe (edition 2021) からの移植や 既存コードの写経で `gen` を使っている箇所は、2024 edition の crate に持ち込む時に全部引っかかる。 ## app (2026-09-08 GUI 実測 — 実行が 401 の再試行で止まる) ### 7. GUI からの実行だけ 401 を繰り返す — 未再現、有力仮説は「端末の ANTHROPIC_API_KEY が別物」 **症状** (ユーザー実機、04:19): GUI から解析を実行すると CLI は起動する (pid 表示) が、stderr に `claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source is set and takes precedence over your claude.ai login` が出た直後から `api_retry authentication_failed (HTTP 401)` を繰り返す。 **切り分け (全部 Neo 側で実測、いずれも 0 回の再試行で成功 = 原因ではない)**: (A) User スコープの `ANTHROPIC_API_KEY` を単独で使用 → 成功。(B) OAuth のみ (`claude auth login` 済み、Claude Max) → 成功。 (C)(D) デスクトップ子セッションの環境変数 (`CLAUDE_CODE_CHILD_SESSION` / `SDK_HAS_HOST_AUTH_REFRESH` / `CLAUDECODE` / `MESSAGING_SOCKET`) を残したまま → 成功。(E1) 既定モデル `claude-opus-5[1m]` → 成功。 (E2) `--add-dir D:\Github\Fuseforks` + allowlist → 成功。(F) `MESSAGING_SOCKET` を死んだパスに差し替え → 成功。 アプリと同じ引数 (`--json-schema --add-dir --allowedTools --verbose stream-json`)・同じ cwd (temp) も含む。 `.env` (dotenvy が拾う範囲: repo / 親 / Kataribe / Fuseforks) と PowerShell プロファイル・Windows Terminal 設定・ Machine スコープに別の鍵は無い。CLI のデバッグログには 401 の本文が残っていない。 **全事実と整合する仮説**: **アプリを起こした端末の `ANTHROPIC_API_KEY` が User スコープの値と別物で無効**。 根拠: 同じ 00:05 にユーザー端末の fixture 採取が 10 × 401 で落ち、Neo が User スコープの鍵で回した採取は成功した。 ユーザー端末の対話 CLI は「API Usage Billing」表示 = 鍵が載っている。アプリは起動元の端末の環境を継承する。 警告文も「鍵が優先される」と言っている。**未確定** — 端末側で鍵の長さと指紋を比べるまで仮説のまま。 **処方 (確定を待たずに入れたもの)**: (a) `cli_runner::env_scrub` — `CLAUDE_CODE_*` / `CLAUDECODE` / `CLAUDE_PID` / `CLAUDE_EFFORT` / `CLAUDE_PREVIEW_*` / `CLAUDE_AGENT_SDK_VERSION` を子に渡さない (子 CLI はホストに結びつかない 独立プロセスであるべき。**原因の証明ではなく衛生**)。fake_cli `env` モードで end-to-end 固定。 (b) `check_cli` が認証の見え方 (鍵の有無・長さ・指紋 / AUTH_TOKEN / base_url / `claude auth status` / 落とした変数) を 返し、設定 → LLM タブに表示。実行開始時にも同じ 1 行を進捗ログへ出す。**鍵の値は画面にも台帳にも出さない**。 **一般化**: 「自分の環境で通る」は「ユーザーの環境で通る」の証明にならない。特に**プロセス環境の継承**が絡む機能は、 何を継承したかを製品自身が見せないと、切り分けがユーザーとの往復になる (#4 の「報告でなくファイルで確認」と同族)。 **接地の限界**: 04:19 の失敗ログは進捗ログの写し (スクリーンショット) のみで、当該プロセスの環境は取れていない。 **決着 (同日、ユーザー実機)**: 設定『OAuth ログインを使う』(= 子に ANTHROPIC_API_KEY を渡さない) を ON にしたら通った。 鍵を外すだけで直ったので、原因は**起動元端末の ANTHROPIC_API_KEY が無効な値**でほぼ確定 (端末側の長さ・指紋の比較は未実施)。 **決着 (同日、ユーザー実機)**: rev3 で GUI から実行 → product カットに Fuseforks の実画面が背景に合成され、その画像 + motion_prompt を MiniMax (Hailuo) の i2v に渡した動画が「綺麗にできた」(フレーム 3 枚: mood 1 = 光る網状ノードの情景、 product 2 = 暗い舞台に実 UI が落ち影つきで浮く)。**合成 = 忠実さの構造保証**が実運用で効いた最初の 1 本。 接地の限界: n=1 (Fuseforks)。日本語見出しは未焼き込み。角度つき (斜め置き) の合成は未実装で、正面配置のみ (この「正面配置のみ」が同日 Phase E で実害として出た → #10 と rev5 で解消)。 ## image_gen / pipeline (2026-09-08 GUI 実測 — 参照画像が「スクショに全く従わない、ありえない画面」) ### 8. 実スクショを「参考」で渡すと、画像モデルは画面を発明する — 忠実さはプロンプトでは保証できない **症状** (ユーザー実機、Gemini): UI スナップショットを参照画像として添付し、palette のアンカーを付けて 生成した製品カットが、実際のアプリ画面と無関係な UI を描いた。ありえない画面が並ぶ。 **真因**: 2 つ重なった。①経路が「参照 = 見た目を寄せる素材」(Kataribe spec 25 のキャラ設定画集の流儀) で、 画素の忠実な再現を頼む経路ではない。②しかも #85 の規律「参照を指す語を書かない」を UI スクショにも 適用していたので、プロンプトは「暖色の机」を描けとだけ言い、画面の中身は完全にモデルの創作になる。 キャラの「見た目を寄せる」と UI の「そのまま出す」は要求が逆で、同じ規律を当てたのが誤り。 **処方 (rev3)**: 製品カットは**モデルに背景だけ描かせ、実スクショの画素を Rust が貼る** (`image_gen::compose::composite_product_cut`: cover で敷いた背景 + 等比縮小・角丸・落ち影のスクショを中央へ)。 ScenePlan に `cut_kind: product|mood` / `snapshot_index` / `motion_prompt` を追加し、product の `image_prompt` は 背景のみ (画面・端末・文字の語を validate で弾く)。背景生成が失敗しても palette の単色で合成する。 参照画像を「寄せる」目的で送るのは mood カットだけ。「LLM は書き、Rust は検める」の画像版 = **モデルは舞台を描き、Rust が画面を貼る**。 **一般化**: 出力の一部が**入力と同一でなければならない**なら、その部分は生成に通さず合成する。 プロンプトの忠実さは確率で、合成の忠実さは構造。#85 (入力を指す語) と本件は同じ「入力の扱い」の問題だが、 向きが逆 — キャラは寄せる (指すな)、UI は写す (生成させるな)。 **接地の限界**: 合成の目視は Neo 側 (Gemini 背景 + Kataribe UI で 1 枚)。実 run (LLM が product カットを 選び、背景だけ描く) の live は未実施。日本語見出しの焼き込みは v1 では持たない (フォント同梱が要る)。 ## crates/pipeline (2026-09-08 Phase E — 別リポジトリに当てたら tree が生成物で埋まった) ### 9. 除外語の一覧は、それを凍結した時の標本の形しか知らない **症状** (`promo brief` を 7 リポジトリに当てて実測): file tree が生成物・ベンダで埋まる。 mxf-tool は 314 行中 227 行 (72%) が `doxy/html/`、CaptionConverterSakura は 400 行中 251 行が `vcpkg/` で **上限 400 に当たって本物のソースが押し出されていた** (`src` はわずか 39 行)。outcast は 280 行中 50 行が `backend/.sqlx/`。Verificator は `venv/Scripts/` — 除外語は `.venv` しか知らず、点なしの `venv` を取りこぼしていた。 **真因**: `EXCLUDED_DIRS` の 8 語を凍結した時の標本が Kataribe と Fuseforks の 2 本だけで、**どちらも 生成物を tree に持たない綺麗な例**だった (ノイズ 0 行)。「上限に当たったら困る」は failures #1 で扱ったが、 **上限に当たる前に中身がノイズに置き換わる**経路は見ていなかった。live が 1 本通ったこと (rev3、Fuseforks) を 「動く」と読んだのが誤りで、n=1 は標本ではない。 **棄却した処方**: 「子が N 件を超えるディレクトリを 1 行に畳む」。実測で分離不能 — Fuseforks `specs/` 52 件 (そのリポジトリで最も情報量の多いディレクトリ) と outcast `.sqlx/` 49 件 (純ノイズ) は **3 件差**。件数はノイズと本体を分けない。閾値を data から決めようとして初めて分かった (実装前に棄却できた)。 **処方 (rev4)**: 二段。①**出所を `git ls-files` にする** — 生成物かどうかを一番よく知っているのは 除外語の一覧ではなくリポジトリ自身。gitignore 済みの doxy と vcpkg はこれだけで消える。git でない対象は 従来の FS walk に落ちる。②git でも消えない**追跡された生成物**は、機械生成された名前で畳む (`[0-9a-f]{16,}` か UUID 断片が子の過半かつ 5 件以上 → `backend/.sqlx/ (49 entries, elided)`)。 7 リポジトリで完全分離した (`.sqlx` 49/49、他の全ディレクトリ 0)。 **実測 (tree 行数、修正前 → 後)**: mxf-tool 314→51 / CaptionConverterSakura 400(切り捨て)→122 / outcast 280→214 / Verificator 114→68 / Kataribe 188→176 / Fuseforks 141→107 / KindleScan 25→17。 CC-Sakura の header から `first 400 entries` が消えた = 切り捨てが解消。 **同時に塞いだ穴**: outcast の tree に `.neo_key.txt` / `.servant_key` / `backend/.env` が並んでいた。 中身は読んでいないが、`cli_runner` は claude に `Read/Glob/Grep` + `--add-dir ` を渡すので、 **brief がその名前を指すこと自体が鍵の在処を教える経路**になる。名前ごと落とすようにした (契約 `secret_names`)。 **一般化**: 除外語の一覧は「知っている汚れ」しか落とせない。落としたい物の**構造** (追跡されていない / 名前が機械生成) を突くと、次に来る未知の汚れにも効く。 failures #1 と同型 — どちらも「上限や一覧を、それを決めた時の標本の外に持ち出した」失敗。 **接地の限界**: git 優先は**未コミットの作業を tree から落とす**。Fuseforks では消えたのは `.claude/worktrees/` `blackboard/` `briefs/` `RundingPage/` の未追跡物だけで実害はなかったが、 「まだコミットしていない実装」を持つリポジトリでは brief が薄くなる。README 本文が `.env` に言及する分は 落としていない (公開文書であり、tree とは別の話)。 ## crates/image_gen (2026-09-08 Phase E — 背景のパースと貼った面のパースが噛み合わない) ### 10. 「合成の忠実さは構造」は、貼る面の向きまでは保証しない **症状** (Phase E、AppPromoVideo 自身を対象に live): product カットを 4 つ作らせたところ、 scene 3 の `image_prompt` が `photographed at a low angle` で Gemini の背景も実際にローアングルなのに、 **貼られた UI は正対のまま**だった。scene 2 (俯瞰寄り) と並べると、背景の視点だけが変わって UI プレートの位置・大きさ・角度が 1 ピクセルも動かない。繋ぐと画面が貼り付いて見える。 **真因**: rev3 で「モデルは舞台を描き、Rust が画面を貼る」に切り替えた時、貼る操作を **等比縮小 + 中央配置**に固定した。画素の同一性は構造で保証したが、**面の向き**は誰も保証していない。 背景のアングルは `image_prompt` (LLM の自由文) が決め、面の向きは Rust の定数が決めていたので、 両者が同じものを指す経路が存在しなかった。 **なぜ Fuseforks の 1 本で見えなかったか**: あの run では product カットが実質 1 種類で、背景も正対だった。 **別アングルの product カットが 2 つ以上並んだ瞬間にしか出ない**不整合で、n=1 では原理的に観測できない。 failures #9 と同じ「標本が 1 つでは標本ではない」型。 **処方 (rev5)**: 面の向きを契約に載せて、貼り方を設定で選べるようにした (ユーザー判断 2026-09-08)。 - `Scene.plate_tilt: Option<{yaw_degrees, pitch_degrees}>` (±35 度) を LLM が書く。背景のアングルを書いた本人が 面の向きも書くので、両者が同じものを指す。 - `PlateMode::Perspective` (既定) = `image_gen::compose` が面を 3D で yaw/pitch 回転 → ピンホール投影 → 4 点から homography を解いて逆写像 + バイリニアで焼く。影も同じ quad で歪める。 - `PlateMode::Frontal` = 面は正対のまま。代わりに `image_prompt` のアングル語 (low angle / top-down / overhead / three-quarter / isometric …) を検査で弾いて背景も正対に保つ。不整合は原理的に出ないが絵は単調。 - **傾き 0 は従来の overlay 経路をそのまま通す** (PoC で画素等価を固定)。既存の 4 本の PoC を壊さないため。 **一般化**: 「構造で保証する」と言った時、**何を保証したかを数え直す**。rev3 が構造化したのは 「画素の同一性」だけで、「面の向き」「大きさ」「切り抜き」は依然として定数か自由文の側にあった。 合成は貼る対象の性質を全部保証するわけではない。 **接地の限界**: 傾けた絵を MiniMax の i2v に通した検証はまだ無い。①傾ける と ②正面固定 のどちらが 動画として良いかは未決で、だから設定で両方選べる形にした (機構だけ先に置く。既定は傾ける)。 傾けると画面内の文字は読みにくくなる — その許容範囲も実測していない。 ## crates/image_gen (2026-09-08 rev6 — i2v が「判別しにくい文字」を作り変える) ### 11. 固定サイズの canvas は、入力が大きい日には黙って情報を捨てる **症状** (ユーザー実測、MiniMax i2v): 合成カットを動画化すると、**UI の小さい文字が別の文字に描き変わる**。 ユーザーの所見「文字は判別しにくいものは作り変えちゃうね」。 **真因**: canvas が `SizeMap::comfy_dims(landscape)` = **1344×768 の固定値**で、スクショ (実測 1282×842) より 縦が低い。`composite_product_cut` は `screen_ratio` (0.78) の枠に等比で収めるので: | screen_ratio | 縮小率 | 11px の UI 文字 | |---|---|---| | 0.78 (既定) | **0.711** | 7.8px | | 0.68 (見出しあり) | 0.620 | 6.8px | | 1.0 (枠いっぱい) | 0.912 | 10.0px | **`screen_ratio` を上限まで上げても等倍に届かない。** 縮小は設定の問題ではなく canvas の寸法が原因で、 **構造的に必ず起きていた**。rev3 は「画素をいじらない」と言っていたが、**等比縮小は画素をいじる操作**である。 そこを「いじらない」と数えていたのが穴。 **処方 (rev6)**: 縮める対象を入れ替える。**背景はモデルが描いた柔らかい絵なので拡大が効き、スクショは 唯一の硬い情報**なので、canvas 側を拡げる (`canvas_for_snapshot`。比率は保ち、長辺 3840px で頭打ち)。 実測の組み合わせで 1344×768 → **1890×1080**、縮小率 **1.000**。上限に当たって縮小が残る時は進捗に警告を出す。 **同時に見つかった 2 件**: mood カットはプロバイダ出力をそのまま保存していたので **JPEG 1376×768 なのに 拡張子は .png**、product カット (PNG 1344×768) と寸法も形式も食い違っていた。動画は全フレームが同寸である 必要がある → `fit_to_canvas` で揃える。揃えられない時は素のまま保存して警告する (生成には金がかかっている)。 **一般化**: #9 (除外語は標本の形しか知らない) / #10 (構造で保証した範囲を数え直せ) と同じ族。 **定数は、それを決めた時の入力しか知らない。** 1344×768 は画像モデルの都合で決めた値で、 入力 (アプリの窓) の都合を一度も見ていなかった。固定値を置く時は「入力がこれを超えたら何が起きるか」を書く。 **接地の限界**: 「等倍なら作り変えられない」はまだ**未検証**。方向はユーザーの実測に基づくが、 1890×1080 版での再テストはこれから。傾けた面の遠い側は等倍より小さくなるので、tilt と可読性の トレードオフは残る (角度と可読性の境目は未実測)。 ## crates/pipeline (2026-09-08 rev7 — 「揮発」ではなく破壊だった) ### 12. 出力先の名前が入力だけで決まると、2 回目が 1 回目を消す **症状** (ユーザー指摘): 「アプリを再起動すると出力が揮発する」。 **真因はもっと悪かった**: `package_dir_name(app_name)` = `_Promo_Package` で **run の識別子を 持たない**。同じアプリに 2 回実行すると `promo.json` / `scenes.md` / `scene_NN_ref_MM.png` が上書きされ、 **過去の出力はディスク上で破壊されていた**。アプリが忘れるのではなく、ファイルが消えていた。 さらに掃除をしないので、新しい run のシーン数が前回より少ないと前回の `scene_07_ref_01.png` 等が残り、 `promo.json` は新しい 6 シーンしか指していないのにフォルダには 7 枚ある、という**混ざった状態**になる。 **なぜ気づかなかったか**: 開発中は毎回リポジトリを変えて試していた (Kataribe → Fuseforks → Verificator → AppPromoVideo)。**同じアプリに 2 回続けて実行したのは、今日 perspective / frontal の対を作った時が初めて**で、 その時は手で別フォルダにコピーしてから回したので露出しなかった。自分で回避策を打っていたことに気づいていない。 **処方 (rev7)**: `/runs//` に隔離する。`run_id` は UTC の `YYYYMMDD-HHMMSS` (ローカル時刻だと夏時間で順序が壊れる。文字列比較がそのまま時系列順になる)。 アプリ側は `app_data/runs.json` に索引を持つが、**索引はキャッシュで正本は各 run の promo.json**。 索引が指すフォルダが無くても missing と出すだけで消さない — 移動しただけかもしれず、こちらの都合で履歴を捨てない。 **一般化**: #9 (除外語は標本の形しか知らない) / #11 (定数は決めた時の入力しか知らない) と同族で、 今回は**出力先の名前**。入力だけから決まる出力パスは、同じ入力で 2 回走った日に前回を消す。 **書き先の名前には「いつ」を入れる。** そして症状の言葉 (「揮発」) をそのまま真因として受け取らない — 今回は「状態保存の機能追加」に見えて、実体はデータ破壊の修正だった。 **接地の限界**: 実 run での通しは未実施 (PoC は `write_package` の 2 回呼び出しと索引の単体まで)。 rev6 以前の既存パッケージを履歴へ取り込む移行はしていない (ファイルは無事だが一覧には出ない)。 ## app (2026-09-08 rev8 — 見出しが豆腐になった) ### 13. 日本語フォントを ASCII の名前で探すと、日本語名のフォントに一生当たらない **症状** (ユーザー実機): 見出しを焼いたら **□□□□** (豆腐) になった。 **切り分けで潰した仮説** (どれも外れ): - フォントに日本語グリフが無い → **外れ**。`NotoSansCJKjp-Black.otf` は `glyph_id('あ')=1454`、outline あり。 `NotoSansJP-VF` / `YuGothB` / `meiryo` も同様。 - `burn_caption` が壊れている → **外れ**。実フォント + 実 `copy_text` で焼いた PNG を目視、正常に出た。 - 出力ファイルが壊れている → **外れ**。run `20260908-153128` の `scene_02_ref_01.png` を切り出して目視、 `リポジトリを渡すだけ。` が正しく焼かれていた。 - 自動選択が Hack Nerd Font を選んだ → **外れ**。`Hack Nerd Font` は `has_japanese=false` で候補にすら入らない (手動選択されたもの)。**豆腐の画像は実行中に Lightbox が前の run の画像を表示していたものだった。** **それでも見つかった実バグ**: `pickCaptionFont` の優先語が **ASCII だけ**だった (`"yu gothic"` / `"meiryo"` / `"gothic"`)。実測すると family 名は **`游ゴシック` / `メイリオ` / `BIZ UDゴシック` / `HGPゴシックE`** と日本語表記で、ASCII の語には当たらない。当たらないと全部同点になり、 `localeCompare` で **ASCII 名が先に来るので勝つ** — 日本語グリフを持つだけの無関係なフォント (`Aharoni Bold`) が `游ゴシック` に勝っていた。手元で `Noto Sans JP` が選ばれていたのは、 **Noto が ASCII 名だったから偶然当たっていただけ**。 **処方**: 優先語に日本語表記と半角カナを入れる (`游ゴシック` / `メイリオ` / `biz udゴシック` / `ゴシック` / `ゴシック`)。避ける語にも `行書` / `ポップ` を追加。 **一般化**: **名前で分類する機構は、名前がどの言語で書かれるかに依存する。** 「日本語対応フォントを探す」 のに探索語が英語だけ、という非対称は気づきにくい。実機の一覧を一度ダンプして目で見るまで分からなかった。 #9 (除外語は標本の形しか知らない) と同族 — 一覧を決め打ちする機構は、必ず実データで当ててから凍結する。 **副産物**: 最初のテストは通ってしまった (`游明朝` に AVOID が効き、タイブレークで偶然勝っただけ)。 **通ったテストが実装を検証していないことがある** — 差が出る組み合わせ (`Aharoni Bold` vs `游ゴシック`) に 書き直して初めて Red になった。 ### 14. 順位を点数に変える式は、リストが伸びると符号が反転する **症状**: `pickCaptionFont` で `Noto Sans CJK JP` が、優先語に何も当たらない `Aharoni Bold` に負けた。 **真因**: スコアが `100 - index * 10` だった。優先リストを 7 語から **12 語に伸ばした**とき、 末尾 (`"sans"` = index 11) に当たると **-10** になり、**何にも当たらない 0 より弱くなる**。 「当たったら加点」のつもりが「当たったら減点」に反転していた。 `"Noto Sans CJK JP"` は `"noto sans jp"` に当たらず (間に `cjk` が挟まる) `"sans"` にだけ当たるので、 ちょうどこの穴に落ちる。 **処方**: `(PREFER.length - hit) * 10`。リストが何語になっても、当たったものは必ず 0 より上に出る。 **一般化**: **順位を点数に変える式は、リストの長さに暗黙に依存する。** 定数 (`100`) を上限として 書いた時点の長さでしか正しくない。#9 / #11 / #13 と同じ「決めた時の入力しか知らない」型で、 今回は**自分が同じセッション内でリストを伸ばして壊した**。長さに依存する式は長さから導く。 **発見の経路**: ユーザーが手で選んだフォント名 (`NotoSansCJKjp-Black.otf`) を見て、 「これは優先語に当たるのか」と数えたら気づいた。テストでは出ていなかった。 ### 15. 「焼く前」を残したつもりで、実は「加工済み」を残していた **症状**: rev9 で見出しを 1 枚ごとに変えられるようにしたが、位置を下→上に変えると**文字がプレートに重なる** (実装直後にユーザーの問いから発覚。live 前)。 **真因**: `base/` に置いたのが「焼く前の**合成画像**」だった。合成の時点で `with_caption_band` が **帯の位置を焼き込んでいる** — プレートを 0.78→0.68 に縮め、見出しと**反対側**へ 7.5% ずらす。 つまり base/ は「見出しが下にある前提で空きを作った絵」であって、位置に対して中立ではない。 しかもその位置は**ジョブ全体の既定**で決まっていたので、シーンごとの上書きは合成に反映されなかった。 **発見の経路**: ユーザーの問い「背景だけ生成させて、文字はアプリで焼き込んでいるのではないのですか?」。 **この問いは私の説明の誤りを突いたもので、バグを指摘したものではなかった。** 答えるために合成の経路を読み直したら、帯の焼き込みに気づいた。 **処方 (rev10)**: `base/` に置くのは**背景**にする。スクショは `snapshots/` にあるので合成からやり直せる。 帯を決める `layout_for` を生成と焼き直しで共有し、**そのカットで効く位置**から毎回計算する。 ディスクは変わらず、`plate_tilt` の変更も無料でできるようになった (UI は未実装)。 **一般化**: **「加工前」と言うとき、どの加工の前かを数える。** rev9 は「焼く前」までは正しかったが、 その手前の「合成」が既に見出しの位置を前提にしていた。パイプラインの途中の状態を保存するときは、 **その状態が後段の選択に依存していないか**を確認する。依存していたら、もう一段さかのぼる。 #10 (構造で保証した範囲を数え直せ) と同型 — あのときは「画素の同一性は保証したが面の向きは保証していない」、 今回は「焼く前は保存したが帯の位置は既に決まっていた」。 ## app (2026-09-09 rev13 — 「スライダーが重すぎる」) ### 16. debug ビルドの画像処理は release の 71 倍遅い — 体感の問題を設計の問題と読み違えかけた **症状** (ユーザー実機): はめ込みのスライダーを動かすと重すぎて操作にならない。 ユーザーの提案は「スライダーで焼き付けず、確定してから焼くべきかも」。 **実測** (1886×1886 の再合成 + PNG エンコード、背景はノイズ画像で実物相当): | canvas | release | debug | 比 | |---|---|---|---| | 1886×1886 | **252 ms** | **17.9 s** | 71 倍 | | 1890×1080 | 222 ms | 16.3 s | 73 倍 | | 640×640 | 52 ms | 3.8 s | 73 倍 | GUI は `target/debug` の exe を使っているので、**17.9 秒**待たされていた。設計 (焼くタイミング) の 問題ではなく、最適化なしでピクセルを回していただけ。 **最初の計測は嘘だった**: 単色画像で測ったら PNG が 81 KB になり、実物 (写真) の 3〜14 MB と桁が違った。 **圧縮の効きが実態とずれる合成データで性能を測らない。** ノイズ画像に替えて測り直した。 **処方**: `[profile.dev.package.] opt-level = 3` を**両方の workspace**に置く (root と `app/src-tauri`。src-tauri は独立 workspace なので片方だけでは効かない)。 自分のコードのデバッグ性は保ちたいので、画像を触る crate と依存だけ最適化する。 **17.9 s → 0.64 s (28 倍)**。加えてユーザーの提案どおり、スライダーとドラッグは**値を変えるだけ**にして 焼き直しは「適用」に集約した (0.64 s でも触るたびに走らせれば操作にならない)。 **一般化**: **体感の苦情を受けたら、まず同じ処理を release で測る。** 「重い」の原因が自分の設計にあると 決めてかかると、直す必要のないものを直して、直すべきものを残す。#11 (canvas が小さかった) と同じで、 **測ると原因が別の層にある**ことがある。今回はユーザーの提案 (焼くタイミング) も正しかったが、 それだけでは 17.9 秒は 16 秒にしかならなかった。 ### 17. `position: absolute; inset: 0` は SVG を伸ばさない **症状** (ユーザー実機): 予定位置の枠が画像とズレて、画像の下あたりに出た。 **切り分け**: まず**枠の座標が正しいか**を実 run で測った。`plate_quad` の値と、 「完成品と base の差分」の外接矩形を突き合わせる: | scene | quad | 実際 | |---|---|---| | 2 (yaw -14 / pitch -10) | 507,404 .. 2410,1473 | 508,405 .. 2409,1471 | | 3 (傾きなし) | 452,412 .. 2372,1444 | 452,412 .. 2371,1465 | | 4 (yaw 14 / pitch 12) | 596,441 .. 2151,1391 | 596,441 .. 2155,1400 | **座標は合っていた** (差は落ち影のぶん)。**最初の測定は嘘だった** — 差分に**焼いた見出しの文字**が 混ざり、上端が 404 でなく 114 に見えていた。文字の帯を外して測り直して初めて一致が見えた。 **真因**: 描画側。`.ghost { position: absolute; inset: 0 }` だけでは SVG は伸びない。 **SVG は置換要素**なので、`width` / `height` が無いと**固有サイズ (既定 300×150)** で描かれる。 ポリゴンの座標 (viewBox 0..100) はその 300×150 の箱に割り当てられるので、画像の箱とは無関係な場所に出る。 **処方**: `width: 100%; height: 100%` を明示する。`inset` に頼らない。 **一般化**: **座標が合っているかと、描かれる場所が合っているかは別の検査。** 今回は Rust 側に PoC があり (`plate_quad` は合成の実測位置と ±2px)、それが通っていたので 「計算は正しい」と分かったが、**CSS に PoC は無い**。ブラウザの箱モデルは置換要素とそれ以外で 振る舞いが違い、`div` で効く指定が `svg` で効かないことがある。 #13 (ASCII の優先語) と同じで、**片方の層だけ検証して全体を検証したつもりになっていた**。 ## app + crates/pipeline (2026-09-09 rev17 — 縦位置スライダーだけ絵が動かない) **症状**: ユーザー報告「見出しの文字の操作のスライダーだけど、スライダーを動かしても プレビューの文字が変化しない」。大きさ・色・位置・フォントは適用すると効くのに、 **縦位置 (`y_ratio`) だけ何も起きない**。値は promo.json の `caption_overrides` に残る。 **切り分け**: `y_ratio` を全ファイルで grep した。 | 場所 | 状態 | |---|---| | `image_gen::caption` (`Caption.y_ratio` / `caption_top`) | ある | | `pipeline::reference::CaptionSpec` / `with_override` | ある | | `pipeline::reference` の**生成**経路 (`cap.y_ratio = spec.y_ratio`) | ある | | app の**焼き直し**経路 (`reburn_caption`) | **無い** | **真因**: `reburn_caption` が `Caption` を**手で組み直していた**。 `size_ratio` / `position` / `color` は写していたが `y_ratio` を写していない。 rev14 で `y_ratio` を足したとき、同じ組み立てが 2 箇所にあり片方しか直らなかった。 promo.json への保存は別経路なので**値は残る** — だから「設定は効いているのに絵が動かない」に見える。 **処方**: 組み立てを `CaptionSpec::to_caption` 1 箇所にして、両方の経路がそこを通るようにした。 ついでに `burn_caption` が持っていた縦位置の式の写しを `caption_top` に寄せた (同じ数式を 2 つ持たない)。 PoC 2 本 — ①全フィールドが `Caption` に届く ②**縦位置を変えると実際に文字が動く** (`assert_ne!` + 上下の判定)。`y_ratio` の行を消すと両方落ちることを確認済み。 **一般化**: **「フィールドを足す」は「撤去したら grep」と同じ検査が要る。** `mandate_removal_requires_ledger_grep` は台帳の追従を見るが、これはコード内の**同型の組み立ての 写し**の話で、同じ非対称が効く — 新しい経路を書くときは目に入るが、既にある経路の読み直しは 要求されない。#18 が #13 (ASCII の優先語) や #17 (SVG の箱) と違うのは、**保存経路が別だったせいで 症状が「効かない」ではなく「効いているのに反映されない」に化けた**こと。 状態が残ることは、それが使われている証拠にはならない。 **処方の一般形**: 同じ構造体を 2 箇所以上で組み立てていたら、**組み立てを関数にして全フィールドを 写す PoC を 1 本置く**。フィールド追加のたびに人が両側を思い出す必要が消える。 ## app (2026-09-09 rev21 — つまみが実効値を指していない) **症状**: ユーザー指摘「傾き画像の既定なのですが、傾いた画像が今は基準に縦横も 0° になっている。 できれば 0° は正面で傾き画像にしたときは傾き数値の° にしたほうがよい」。 **絵は 18° 傾いているのに、スライダーのつまみは 0° を指していた。** **真因**: つまみの位置を「上書きが無ければ 0」で決めていた (`:value="yaw ?? 0"`)。 だが上書きが無いときに実際に効くのは **LLM が書いた `scene.plate_tilt`** で、0 ではない。 `show()` も「既定」としか出さないので、**画面のどこにも実効値が出ていなかった**。 **同じ型の 2 件目 (指摘の外、同時に見つけた)**: 適用して閉じ、開き直すと**全つまみが既定に戻る**。 `promo.json` には `plate_overrides` / `caption_overrides` が保存されているのに、ダイアログは 毎回 null から始めていた。絵には自分の値が焼かれているのに、つまみは違う値を指す。 さらに `reburn_caption` は data URL しか返さないので、frontend の `promo` は適用後も古いままだった。 **処方**: ①`plate_preview` が**合成が使う `tilt_of` の値**をそのまま返す (`tilt: [yaw, pitch]`)。 つまみの基準はこれ。②`tiltValue` / `tiltLabel` (純関数、vitest 4 本) — **0° は「正面」、未指定は「18° (LLM)」、自分の値は「18°」**と、値の出どころまで出す。 ③開いたときに保存済みの上書きをつまみへ戻す。④`reburn_caption` が**書き換えた後の `promo`** も返す (上書きを書くのは backend なので、frontend が手元で真似ると必ず食い違う)。 **一般化**: **つまみは「いま効いている値」を指さなければ、操作の基準を偽る。** 「未指定」を UI 上の 0 に落とすのが誤りで、未指定とは**別の場所に既定がある**という意味でしかない。 #17 (枠の座標) は「計算は正しいが描画がズレる」、#18 (`y_ratio`) は「値は保存されるが効かない」、 これは「値は効いているが表示されない」。**3 つとも、片側だけを見て検証を終えている。** 処方も同型で、**実効値を出すのは合成・焼き込みが実際に使う関数**にする (`tilt_of` / `caption_layout` / `plate_quad`)。UI 側で「たぶんこれが既定」を組み立てた時点で嘘が入る。 ## app (2026-09-10 rev23 — 撮り直した画面が一覧に出ない) **症状**: 同じパスにスクリーンショットを撮り直しても、はめ込みの一覧に新しいほうが現れない。 選べるのは run の写し (古いバイト列) だけで、**古い画面が焼かれ続ける**。エラーは出ない。 **真因**: `snapshots.ts::snapshotChoices` が入力ペインの一覧を**パス一致**で落としていた (`inputPaths.filter((p) => !inRun.has(p))`)。撮り直しはパスが変わらないので、run に既にある パスとして毎回除外される。`read_run_snapshot` は設計どおり run の写しを読む (rev10) ため、 選べる唯一の行が古い画像を指す。 **これは設計判断の誤りではなく、凍結済みの契約への違反だった。** `data_contract.yaml` の `RunSnapshots.add.no_dedup` にこう書いてある — 「同じパスを 2 度足せる。**撮り直しは同じパスで中身が変わる**ので、パスでの重複排除は 新しい画像を拒むことになる」。backend (`add_run_snapshot`) はこれを守っており、同じパスを 2 度受ける。**契約が名指しで禁じた重複排除を、契約を書いた同じ rev20 が UI 側に実装していた。** その結果、守られている入口へ到達する経路が塞がれていた。 **見落としの正体は「気づかなかったこと」ではない。** rev20 は症状を自分で見つけ、spec に `これは未解決` と書いている。にもかかわらず直らなかったのは、**そこに誤った必然性を添えたから** — 「run が自己完結する以上そうなるが、撮り直しの用途とは噛み合わない (その時は別名で足す必要がある)」。 自己完結 (rev10) が強制するのは**焼き直しが写しを読むこと**だけで、**一覧の重複排除の鍵が パスであること**は 1 ミリも強制しない。2 つの独立した決定を因果で結んでしまい、 「仕様上どうしようもない」と読める形で書いたため、**開いている問いが閉じた問いに化けた**。 迂回策 (別名で足す) を併記したことがそれを補強している。 **処方**: 「後から足したぶん」の判定を**パスから中身へ**移した。 ①`stale_run_snapshots` (backend) — 今の元ファイルのバイト列が、**同じパスで写したどの写しとも 一致しない**なら撮り直し。パスは 2 度足せるので照合は index 単位ではなく同じパスの写し全部に対して行う。 ②`snapshotChoices(runPaths, inputPaths, staleRunPaths)` — 撮り直しのパスだけ重複排除から外す。 run の写しは**古いまま残す** (rev10 の自己完結。過去の run が黙って変わってはいけない)。 ③一覧は同じ名前で 2 行並ぶので、下の行を「撮り直し」と名乗らせる。 ④ついでに入力ペインのサムネイル (`snapshotUrls`、パスをキーにしたキャッシュ) も捨てて読み直す — 撮り直し前の絵を出したままだと「変わっていない」と読めてしまう。 **PoC**: backend 5 本 / vitest 2 本。Red は実装前のスタブ (「撮り直しを一切見ない」= 今の挙動) で 観測した。**5 本のうち Red になったのは 1 本だけ**で、残り 4 本 (元が消えた / 写しが無い / 2 度足した後) はスタブでも通る — つまりその 4 本だけでは今回のバグを検出できない。 長さで弾く近道を通らない「同じ長さで中身が違う」1 本を別に足し、バイト照合の行を消すと 落ちることを確認した。 **一般化 (1) パスは識別子ではなく住所である。** 同じ住所に別の中身が来る (撮り直し・再エクスポート・ ビルド成果物) 場面で、パス一致を「同じもの」の判定に使うと、**新しいほうが黙って捨てられる**。 #18 が「値は保存されるが効かない」、#19 が「値は効いているが表示されない」なら、これは **「入口はあるが到達できない」** — rev19 で一度学んだ形 (足りないのは入口ではなく見えている範囲) の再発。 **一般化 (2) 未解決の記録に理由を添えるときは、その理由が本当に必然かを検める。** 「〜である以上そうなる」と書いた瞬間、その項目は**探索の対象から外れる**。 未解決として書き残す価値は「まだ分かっていない」という状態の保存にあり、 **説明を付けるとその価値を自分で消す**。迂回策を併記すると更に強く閉じる (困っていない状態に見える)。 今回は 1 日で回収できたが、閉じた問いは誰も開き直さないので、原理的には無期限に残りうる。 処方: 未解決に理由を書くなら「**〜だからだと思うが未確認**」と、必然の主張ではなく仮説として書く。 **一般化 (3) 契約に「これをするな」と書いてあることは、それを実装しない保証にはならない。** 撤去の grep と同じ検査が要る — 契約に禁止を書いたら、その禁止語 (今回は「重複排除」) で 実装側を grep する。 ## app (2026-09-11 rev30 — 網が「全トークンが未定義」と言った) **症状**: CSS トークンの網 (`cssTokens.test.ts`、フォールバックの無い `var(--x)` に定義があるか) の初版が、 `--text` や `--fs-xs` のように確実に定義されているものまで**全部**未定義と報告した。自己テストは通っていた。 **真因**: vitest は既定で **CSS を空の文字列にする** (`import.meta.glob` の `?raw` でも `''` が返る)。 網の自己テストは「main.css のパスを拾っている」ことしか見ておらず、**中身が空でも通った**。 `test.css.include: [/\.css$/]` で通そうとしたが、変わらなかった — vitest の `shouldProcessCSS` は モジュール ID をそのまま照合し、その ID には**クエリ (`?raw`) が付く**ので `$` で終わる正規表現が一致しない。 **処方**: `css: { include: [/\.css(?:\?|$)/] }`。自己テストに「main.css の**中身**に `--fs-scale:` がある」を足した (中身が空なら網自身が落ちる)。 **一般化 (1) ファイルを拾ったことは、中身を読んだことを含まない。** 網の自己テストは「対象を拾っている」だけでなく 「対象の中身に既知の文字列がある」まで見る。空の中身は、網を**常に通す**方向にも**常に落とす**方向にも効く。 **一般化 (2) 設定が効かない時は、照合される側の形を先に見る。** パス・ID・URL の照合は、クエリやハッシュが付いた形で 行われることがある。仮説 (「include で通るはず」) を 1 度棄却したら、推測を重ねず実装の照合箇所を読む。 ## app (2026-09-11 rev30 — 指定した書体が一度も出ていなかった) **症状**: デザインの修正で `--font-ui` の先頭を `"Inter"` にし、index.html に Google Fonts の `` を足した。 ビルドもテストも通り、エラーは出ない。 **真因**: 2 つ重なっていた。①tauri.conf.json の CSP は `style-src 'self' 'unsafe-inline'` で `font-src` が無い (= `default-src 'self'`) — 外部のスタイルシートもフォントファイルも**黙って拒否される**。②この機体に Inter は入っていない。CSS の書体指定は、見つからなければ **黙って次の候補に落ちる**ので、画面は Noto Sans JP で出ていたはず (実行時は未確認)。どちらもエラーを出さない。 **処方**: 書体を同梱した (`@fontsource-variable/inter` / `noto-sans-jp`、main.ts で import)。**宣言名は `Inter Variable`** で、 トークンに `Inter` と書くと手元に入っていない限り当たらない — 同梱しても名前がずれていれば同じ症状になる。 dist で `data:` への埋め込みが 0 であることも確かめた (小さい資産は `data:` URI にされ、CSP で同じく拒否される)。 網 `fonts.test.ts` は外部 URL / import / **トークンの先頭とパッケージの宣言名の一致**を見る。 **一般化**: **書体指定と CSP は、失敗しても何も言わない。** 「指定した」「ビルドが通った」は「その書体で出ている」を含まない。 書体を変えたら、①それがどこから来るか (同梱 / OS / 外部) ②CSP がその経路を通すか ③宣言名とトークンの名前が一致するか、を確かめる。 ## app (2026-09-12 rev32 — 比較中は履歴の表が 2 行しか見えない) **症状**: 履歴を全画面にした (rev31) 後、比較で 2 つ選ぶと表が上の 2 行ぶんまで縮み、下の方の run を選ぶとその行が見えなくなって外せない (ユーザー報告、スクリーンショットつき)。表の右端に細い縦スクロールバーが出ていた。 **真因**: rev31 で、表を横に流すために包みに `overflow-x: auto` を付けた。**overflow を持つ箱は、flex の中で `min-height: auto` が 0 扱いになる** (中身の高さを最小として守らない)。さらに片方の軸だけ overflow を指定すると、もう片方も `auto` になる。縦の flex の中で下に 65vh の画像が来ると、 表の包みが場所を譲って縮み、表だけの縦スクロールになった。実測 (ダミー 10 行・1920x1032): 見えている 156px / 中身 622px。 **処方**: 表の包みに `flex-shrink: 0`。比較中だけ高さの上限を**意図して**決めて (30vh)、見出し行を固定する。縮むのは画像の側にし (比較の区画が残りを取り、 `object-fit: contain`)、画像の枠に最小 260px を置いて、それより低い窓は画面全体をスクロールさせる。あわせて、比較対象を表の先頭に出した (ユーザー提案)。 **一般化**: **縦の flex の中で `overflow` を付けた子は、黙って縮む候補になる。** 横スクロールのために付けたものでも縦に効く。 どれを縮ませるかは偶然 (中身の量と窓の高さ) に任せず、縮ませない子には `flex-shrink: 0`、縮ませる子には最小の高さを決める。 **見た目の変更は、画面を実際に測るまで「効いた」と言わない** — rev31 は vitest と build だけで報告し、この潰れを出したまま渡した。 ## app (2026-09-12 rev33 — 直したのに、測った数値が 1 つも変わらない) **症状**: チェックボックスの修正の後、ブラウザのペインで測り直すと、直す前と全部同じ数値が出た (28x44 / 76x44 / セルの上端 2 通り)。 vitest と build は通っていた。 **真因**: 測っていたページが古いコードだった。ブラウザのペインは port 1421 で動いていたユーザーの `tauri dev` の vite を開いていたが、 そのサーバーが途中で止まっていた。**開いたページはサーバーが止まっても最後の状態のまま動き続け**、HMR は届かない。 エラーは画面には出ず、コンソールに `ws://localhost:1421` の接続失敗と `ERR_CONNECTION_REFUSED` が約 50 件あっただけ。DOM は旧構造 (`td.pick`)、CSS に新しい規則は無かった。 **処方**: 自分で vite を起動してページを読み込み直し、**先に「今回の変更でしか存在しない印」(新しい要素 `td > div.pick`・新しい CSS 規則) の有無を確かめてから**測った。 **一般化**: **測定器が測っているのが今のコードかを、測る前に確かめる。** 「数値が変わらない」は「修正が効かない」と「修正が届いていない」の 両方を意味するので、前者と読む前に後者を消す。測定のスクリプトの先頭に、今回の変更でしか存在しない印の有無を入れておけば 1 回で切り分けられる。 rev26 の「テストが通ったことは build が通ることを含まない」、rev30 の「ファイルを拾ったことは中身を読んだことを含まない」と同じ型 — **測定の対象が、測りたいものと同じかを検めていない。** ## data_contract.yaml (2026-09-12 — 正本の台帳が YAML として読めなかった) **症状**: rev30 で data_contract.yaml を PyYAML に通したら 87 行目で落ちた。HEAD の版も同じだった。洗い出すと 29 ブロック中 5 つが読めず、 さらに 9 キーが親のブロックから切り離されていて、キーの重複も 1 つあった。いつから壊れていたのか、誰も気づいていなかった。 **真因**: ①**台帳はプログラムから読まれていない** (Rust も frontend もコメントで参照するだけ)。構文が壊れても何も落ちない。 ②Rust の型の擬似記法 (`Ok { text: String }` / `Option<{ yaw_degrees: f32 }>` / `a | b`) を YAML の値にそのまま書いていた。 ③**新しいブロックを、既存の mapping の途中に挿し込んだ** (rev11 の `PlateOverride` を `ImageGenConfig.caption` の途中に)。挿した位置より後ろにあった 親の子キーは、字下げのせいで見た目は続いているように見えるが、YAML 上は新しいブロックの子 (または不正) になる。そこへさらにブロックが挿し込まれ (rev15〜21 の `RunSnapshots` / `CaptionLayout`)、切り離しが固定された。④PyYAML は重複したキーを黙って後勝ちにする。 **処方**: 擬似記法は引用符の中へ。迷子のキーは git で元の位置を確かめてから戻す。重複は 1 つにまとめる。網 `scripts/check_data_contract.py` (解析 + 重複キー) を足し、直す前の版で NG・今の版で OK・重複キーの見本で NG になることを確かめた。 **一般化**: **人しか読まない正本は、壊れても誰も気づかない。** 読む機械がいないなら、構文だけを検める網を置く。 新しいブロックは「関係の近い場所」ではなく**トップレベルの切れ目**に足す — 既存の mapping の途中に挿すと、後ろのキーの親が静かに変わる。 ## app (2026-09-12 rev34 — 入れても色が変わらない、と誤って読んだ) **症状**: スイッチの `:checked` の見た目をブラウザで測ると、入れた状態でも枠は切の色 (rgba(154,146,172,0.3)) のまま、つまみの移動も 0 (`matrix(1, 0, 0, 1, 0, 0)`) だった。規則はページに届いていて、要素は `:checked` にも一致していた (`el.matches(...)` で確認済み)。 **真因**: ブラウザのペインが描画されていない状態 (`document.hidden === true`) では、**CSS の遷移が進まない**。 `getComputedStyle` は遷移の途中の値を返すので、時刻 0 = 変化前の値がそのまま出る。待ち時間を 400ms 取っても、時間が進んでいないので変わらない。 `--trans-fast` を 0s にして測り直すと、`rgb(236, 122, 56)` と `translateX(16px)` が出た。 **処方**: 遷移のある見た目を測る前に、遷移を止める (このプロジェクトは `--trans-fast` / `--trans-normal` の 2 つを 0s にする)。測り終えたら戻す。 **一般化**: **見た目の測定は、時間の止まった場所で行う。** 遷移・アニメーション・遅延読み込みは、測定器 (computed style や位置) に「途中の値」を返させる。 rev30 の「ファイルを拾ったことは中身を読んだことを含まない」、rev33 の「測っているのが今のコードか」に続く同じ型 — **測定の条件が、測りたい状態と同じかを検めていない。** ## app (2026-09-12 rev35 — 「収まった」と言った窓が、ユーザーの窓より 175px 高かった) **症状**: 設定を 1 画面にして「1600x1017 で画面も列もスクロールしない」と報告した。ユーザーのスクリーンショットでは 2 列目にスクロールバーが出ていた。 **真因**: 測った窓 (1600x1017) が**ユーザーの窓 (1288x842) より高かった**。「収まる」は窓の大きさに対してしか言えない主張なのに、測定条件を自分の都合で決めていた。 **処方**: ユーザーの窓の大きさ (スクリーンショットの画素数) で測り直す。あわせて、より小さい窓で**どこがスクロールするか**も測って書く。 **一般化**: **「収まる」「速い」「足りる」は、条件を書かなければ主張になっていない。** 画面の大きさ・データ量・機械の速さは測定条件であって、結果の数字には現れない。 測定条件は**ユーザーの実環境から取る** — スクリーンショットの画素数、実物のファイルサイズ、実機のビルド。rev30 の「単色画像で性能を測った」と同じ型。 ## app (2026-09-12 rev36 — 無い記録を 0 として描いた) **症状**: 履歴から run を開くと、結果ペインに `構成 0 回目で通過` と `0.000 USD` が出た (ユーザーのスクリーンショット)。 **真因**: rev31 の `openRun` が、実行時の統計を持たない「開き直し」でも `analyze` / `plan` を **0 で埋めていた**。結果ペインはその 0 をそのまま描いた。 履歴の表は同じ 0 を「—」(記録なし) と描き分けている (rev15) のに、結果ペインだけが規律の外にいた。しかも正本 (`promo.json` の `run_stats`) は その run の本当の回数と費用を持っていた — **画面に出す値の出所が間違っていた**。 **処方**: `runs.ts::runHeaderStats` を 1 つ置き、**正本 → 実行直後の stage → 記録なし** の順で決める。記録が無ければ chip を出さない。 **一般化**: **「値が無い」を 0 で埋めると、画面は 0 を事実として描く。** 無いことは `null` / `undefined` のまま運び、描く直前に「無い時どうするか」を決める。 rev15 の「0 を 1 回と描くと集計に偽の分母が混ざる」と同じ型で、今回は**同じ規律が別の画面へ転移していなかった**。 規律を作ったら、その規律が守るべき値を**画面ごとに grep する**。 ## cli_runner (2026-09-12 — 落ちた run の手掛かりを捨てていた) **症状**: live の解析が 2 分 04 秒で `CLI の出力の形が想定と違います (result 行が無い (途中で終了)): - **製品分析JSONの作成**: …出力しました。` で落ちた。 原因を調べようとしたが、**材料がこのエラー文の 300 字しか無かった**。 **真因**: `runner::run` は受信した行を `raw_lines` に**メモリだけ**で持ち、畳むのに失敗すると `CliError::Shape.raw` (`head()` = 1 行の先頭 300 字) を載せて捨てていた。 run ディレクトリは export の時に作られるので、**解析の段で落ちた run はディスクに痕跡をまったく残さない**。 加えて、子の終了コードは `status` として取得しておきながら、見ているのは `aider` / `custom` の分岐だけで、**claude では捨てていた** — 自分で終わったのか異常終了したのかすら分からなかった。 **処方**: 生ログ (契約 `CliRawLog`) を `/cli-logs/-.jsonl` に**行が届くたびに**書く (timeout / cancel / kill でも届いたところまで残る)。 成功した run も残す — 封筒の実データは fixture の原料になる。`RunFailed.log_path` に載せ、エラーの文言にもパスを出す。 claude の畳みが失敗した時は終了コードを `Shape.detail` に足す。 **一般化**: **診断できるかどうかは、壊れた後ではなく壊れる前に決まっている。** 失敗の証拠は、失敗した時に作れない。 「300 字あれば足りるだろう」は、何が起きるか分かっている時にしか立てられない仮定で、分かっていないから調べているのだから前提が逆立ちしている。 持っている値を捨てない (終了コード)、通り過ぎる値を落とさない (行) の 2 つは、**使い道が見えていなくても**安い。 ## app (2026-09-12 — 部分一致の網が 1 個見逃した) **症状**: 未使用の i18n キーを数える網を書き、12 個を撤去した。台帳には「13 個」と書いてあった。 **真因**: 参照の判定が `blob.includes(key)` の**部分一致**だった。`runs.delete` は画面から呼ばれていないのに、 `runs.deleteTitle` / `runs.deleteMessage` / `runs.deleteOk` という別のキーが本文にあるため「使われている」と判定されていた。 **長い名前が短い名前を隠す** — 接頭辞が別のキーになっている辞書では必ず起きる。 **処方**: 引用符で囲んだ完全一致 (`"runs.delete"` / `'runs.delete'`) で数える。ソースのキーは必ず文字列リテラルとして書かれているので、これで足りる (`t(cond ? 'a.x' : 'a.y')` のような分岐も拾える)。 **一般化**: **部分一致は「見つける」側には安全で、「無いことを言う」側には危険。** 検索は偽陽性を許すが、**不在の証明**に使うと偽陰性が黙って通る。 今回は台帳の数字 (13) と食い違ったことで気づいた — **自分の測定と既存の記録が 1 個ずれたら、先に測定を疑う。** ## cli_runner (2026-09-12 — 別の CLI に claude の argv を渡していた) **症状**: live の解析が 2 度落ちた。①2 分走ってから `result 行が無い (途中で終了)` で、最後の行が JSON でない日本語の散文 ②起動と同じ秒に `終了コード 2`、stderr に `Error: -p took "--output-format" as its prompt, so the intended prompt was left as an argument and ignored.`。 手元の `claude` (2.1.263) に同じ argv を bash・PowerShell の両方から投げても**再現しなかった**。 **真因**: 起動していたのが `claude` ではなく **`agy`** (Go 製の別系統の CLI) だった。GUI 設定の「実行ファイル」が `agy` で、kind は `claude` のまま。 agy の `-p` は `--print` の別名で**値 (prompt) を取る**ので、`-p --output-format …` の並びでは `--output-format` を本文として飲み込む。 ①は更新前の agy が同じ並びを黙って受けた結果で、出力形式が既定の `text` のままだったため封筒が 1 行も無く、素の散文だけが出ていた (`agy.exe` の更新時刻 18:49:10 はその run の最中。更新が run を終わらせたかどうかは時刻から引いた推論で、証明ではない)。 **なぜ再現できなかったか**: **何を起動したかを記録していなかった。** 生ログ (何が返ったか) は rev38 で残すようにしたが、 argv は残していなかったので、私は「`claude` に渡している」という前提のまま検証を繰り返していた。 決め手になったのは rev38 で足した `.invocation.json` と進捗ログの「起動: …」の 1 行で、**生ログではない**。 **処方**: `CliKind::Agy` を足し、argv も封筒も別に組む (rev39)。加えて、**失敗の証拠は「返ってきたもの」と「渡したもの」の両方**が要る。 **一般化**: **ユーザーの言葉を typo として読み替えない。** 最初の報告は「agy で途中で止まってしまった」で、 **1 語目に実行ファイル名が書いてあった**。私はそれを打ち間違いと見なし、以後 2 時間ぶん検証の前提を間違え続けた。 知らない語は「誤りかもしれない」ではなく「知らない固有名詞かもしれない」と置く。 ここでの確認は 1 行で済み (`where agy`)、実際にそれで終わった。 ## cli_runner (2026-09-12 — 網が空振りで通っていた) **症状**: agy の見張り (許可外ツールの検出) に「読み取りでは止まらない」テストを書き、緑になった。 検出力を確かめるため見張りを常時 true に壊したところ、**そのテストだけ通り続けた**。 **真因**: 判定が `stopped.iter().all(...)` だった。見張りを無効化すると `stopped` が空になり、**空集合に対する `all()` は真**。 「止まるべきものが止まった」ことを一度も確かめていなかった。 **処方**: 件数を先に固定する (`assert!(!stopped.is_empty())`) してから中身を見る。 **一般化**: **`all` / `every` / `none` は、集合が空のとき何も主張しない。** 「余計なものが無い」を測る網には、 必ず「あるべきものがある」を対で置く。同日に i18n の網が部分一致で 1 個見逃した件と同じ形 — どちらも **網が「無い」側だけを見ていた**。不在の証明を書くときは、先に存在の証明を書く。 ## app (2026-09-12 rev41 — 警告を人が見に行く場所に置いた) **症状**: 設定の「CLI の種類」が `claude` のまま実行ファイルだけ `agy` になっている状態で、**同じ失敗が 3 回続いた**。 rev40 で食い違いの検出を入れたが、事故は止まらなかった。 **真因**: 警告の置き場が**設定画面だけ**だった。実行はメイン画面のボタンからするので、設定画面を開かない限り警告に当たらない。 検出は正しく働いていたのに、**出口が人の通り道に無かった**。 **処方**: run の開始時 (認証の行の直後・brief を読む前) に `--version` を引いて判定し、食い違えば進捗ログに 1 行出して止める。 止めても失うものは無い — 食い違ったまま走らせると必ず数秒で失敗する。 **一般化**: **検出したことと、伝わったことは別。** 警告は「人が見に行く場所」ではなく「**必ず通る場所**」に置く。 どこに置くかは、その値が事故を起こす経路のどこを通るかで決まる — 今回なら run の開始時であって、設定の編集時ではない。 「気づいた時に直せるようにした」は、気づかない人には何もしていないのと同じ。 ## pipeline (2026-09-12 rev42 — 費用の無い CLI で 0 を描いた) **症状**: agy で初めて通しを走らせたら、進捗ログに `解析 完了: 0.000 USD / 94.3 s` と出た。agy は費用を返さない。 **真因**: `stages.rs` が `ok.cost_usd.unwrap_or(0.0)` としていた。`RunOk.cost_usd` は最初から `Option` で 「記録なし」を表せたのに、**受け取った側が 0 に潰していた**。claude しか無かった頃は常に `Some` だったので無害で、 CLI が増えた瞬間に嘘になった。`RunStats.cost_usd` が `f64` だったので `promo.json` にも 0 が焼かれる。 **処方**: `StageReport` / `RunStats` / `RunListItem` / frontend の型をすべて `Option` にし、 記録が無ければ chip ごと出さない。合計は `add_cost` で「**片方でも不明なら不明**」— 分かっている分だけ足すと 「一部しか数えていない総額」という別種の嘘になる。 **一般化**: **`unwrap_or(0)` は型が持っていた「無い」を捨てる操作。** `Option` を作った側が正しくても、 受け取る側が潰せば情報は消える。rev36 で「無いものを 0 で埋めない」を直したのは**画面**で、 同じ潰しが**その上流**に残っていた。規律を作ったら、その値が通る経路を端から端まで辿る (画面ごとに grep する、では足りなかった — 今回は画面ではなく畳み込みにいた)。 ## promo_core (2026-09-12 rev43 — 「消えたはず」を消し忘れが現れない条件で測っていた) **症状**: シーン削除のテストが緑だったが、削除の実装 (消えたシーンの画像を消す) を丸ごと外しても**通り続けた**。 **真因**: テストが 4 シーンのうち **2 番目**を消していた。手前を消すと後ろのシーンが順に前へ rename され、 `fs::rename` が上書きしていくので、消し忘れたファイルは**別の rename に潰されて見えなくなる**。 孤児が残るのは**末尾を消した時だけ** — 番号の付け替えが 1 つも起きないので、そのファイルだけが取り残される。 **処方**: 末尾削除のテストを足した。実装を外すとそちらだけが落ちる。 **一般化**: **「消えたはず」を測るテストは、消し忘れが現れる条件で書く。** 今回は「別の操作が偶然そのゴミを 上書きする」経路があり、テストはそれを見ていた。同日の i18n の部分一致・見張りの空振り `all()` と同じ family で、 **どれも「無い」を測る側の失敗**。無いことを測る時は、**無くならなかったら何が残るか**を先に言葉にする。 ## 作業手順 (2026-09-12 rev44 — 壊れていないものを壊したつもりで測った) **症状**: 案内の印 (`data-tour`) を 1 つ外して網の検出力を試したところ「落ちなかった」。 **網が空振りしている**と判断しかけた (同日に 3 件その型があったので、そう見えた)。 **真因**: 外す側のコマンドが効いていなかった。`python -c "…s.replace('
'…)"` を シェルの二重引用符の中に書いたため、意図した文字列にならず**ファイルは無傷のまま**だった。 網は正しく「印は 2 つある」と答えていた。ファイルの状態を数えてからやり直すと、ちゃんと落ちた。 **処方**: 壊して測る時は、**壊れたことを先に確かめる** (置換の前後で件数を出す)。 ヒアドキュメント (`<<'PY'`) を使い、引用符をシェルに解釈させない。 **一般化**: **検出力の検査そのものにも検出力が要る。** 「壊したのに落ちない」は ①網が悪い ②壊せていない、のどちらでも同じ見え方をする。②を先に潰さないと、 **正しい網を疑って外してしまう**。今日は網の空振りを 3 件見つけた直後で、 「またか」という予断が②の確認を飛ばさせた — **同じ形の失敗が続いた後ほど、次の一件を雑に読む。** ## app (2026-09-12 rev45 — 「初回だけ」を作って、見る口を作らなかった) **症状**: 初回起動のナビゲーション (rev44) を入れたのに**出てこない**。`apppromo.tourDone` を消して 再読み込みしても同じ。 **真因**: `shouldShowTour` が「使った痕跡」で弾いていた。`runs.json` に 7 件あるので「既に使っている人」と 判定され、しかも**その場で印を立て直す**ので、何度消しても結果は変わらない。 判定そのものは設計どおり (更新で機構が入った人に既知の手順を教えない) だが、 **一度でも使った環境では二度と見られない**。作った本人も見られない。 **処方**: 設定画面に「使い方を見る」を置き、押したら**メイン画面へ戻してから**出す (対象の要素がメイン画面に居るので、設定画面のままでは照らせない)。 **一般化**: **「初回だけ」は「二度と見られない」と同義。** 出す条件を絞るほど、出し直す口が要る。 条件の設計と入口の設計は**対で**考える。今回は条件だけ移植元から写し、入口を写さなかった (移植元にも無かったかは未確認 — こちらの穴として扱う)。 ## app (2026-09-12 rev47 — 自分で書いた手順を自分で飛ばした) **症状**: `user-select` の規則を足してブラウザで測ったら `auto` のままで、「規則が効いていない」と見えた。 **真因**: **CSS の HMR が届く前に測っていた。** リロードして `document.styleSheets` に `[data-locked]` が 入っていることを確かめてから測り直すと、ちゃんと `none` になっていた。 **処方**: 測る前に、`document.styleSheets` を走査して**今回の変更でしか存在しない規則**の有無を見る。 **一般化**: rev33 で「**測る前に、今のコードがページに届いているかを確かめる**」と書き、 CLAUDE.md にも載せた。それを**自分で飛ばした**。しかも同日の rev44 では「壊したのに落ちない」を 同じ機序 (変更が届いていない) で誤読している。**手順を書くことと、手順を通ることは別。** 測定が期待と違った時の第一の疑いは、常に「測る対象が更新されているか」に置く。 ## 作業手順 (2026-09-13 — 入口で検めなかった値が、10 分後に 3 OS を一斉に落とした) **症状**: タグ `v0.1.1-` (末尾ハイフン) の Build が 3 OS とも `failed to parse config: tauri.conf.json > version must be a semver string` で落ちた。 落ちたのは cargo test を終えた後の `Build Tauri app` で、タグの push から 10 分以上たっていた。 **真因**: `Extract version from tag` は先頭の `v` を剥がして要素数を補うだけで、**結果が semver かを検めていなかった**。 `0.1.1-` はそのまま `tauri.conf.json` に書き込まれ、Tauri が拒んだ時点で初めて分かった。 **処方**: Extract の段で `^[0-9]+\.[0-9]+\.[0-9]+(-…)?(\+…)?$` に照らし、外れたら**タグの綴りが原因だと分かる文言**で即座に落とす。 手元の bash で `v0.1.1-` / `v0.1.1.` を拒み `v0.1.1` / `v0.2` / `v1.0.0-rc.1` を通すことを確認してから入れた。 **一般化**: 入力を変換する段があるなら、**変換の結果をその場で検める**。後段が検めてくれるのは事実だが、 後段に着くまでの費用 (ここでは 3 ランナー × 10 分) は前段が払わせている。落とすなら入口で、原因を名指しして落とす。 ## cli_runner (2026-09-13 rev48 — 見張りが止めた理由が、止めた側のエラーに書かれていない) **症状**: 配布ビルドで agy を選んで実行すると、進捗 `ツール: view_file …` の直後に `CLI が許可されていないツールを使いました (run_command: Get-Content … README_jp.md -TotalCount 100)` で止まる。 コマンドは読み取りなのに「書き換えが起きた可能性」と言われ、なぜシェルに逃げたのかが画面から読めない。 **真因 (2 層)**: ①agy 側 — 同日に入った Gemini プラグインの PreToolUse hook が、`command` の引用符を agy が剥がさない形で 書かれており、**全ツールが `state: ERROR`** で失敗する。ツールが使えない agy はシェルへ逃げる。 ②アプリ側 — `observe_line` は `state: ACTIVE` の tool 行しか進捗に出しておらず、**`ERROR` の行 (理由の 1 行目) を捨てていた**。 見張りは 2 手目で正しく止めたが、1 手目の失敗が見えないので「見張りが誤作動した」ように読める。 **処方**: `AgyLine::Tool.error` に `tool_info.error.message` の 1 行目を運び、`ツール失敗: — <理由>` を進捗に出す (`agy::tool_notice`)。fixture `agy_tool_error_escape.jsonl` で固定。agy 側はユーザーがプラグインを無効化する (アプリ外)。 **一般化**: **止める機構を作ったら、止めた時に「直前に何が起きていたか」を同じ画面に出す。** 見張りのエラーは 2 手目の事実しか 語れず、1 手目の失敗を捨てると、正しく動いた見張りが誤作動に見える。rev41 (警告は必ず通る場所に) の対で、 **理由は止めた場所の隣に**置く。逃げる理由がこちらの argv に無い時 (環境側の故障) は、消せないので名指しする。 ## cli_runner / app (2026-09-14 rev56 — 配布ビルドでだけ、子プロセスのコンソールの窓が開いた) **症状** (ユーザー、v0.1.2 の exe・Windows): 実行すると、タイトル「claude」の**空の黒いウィンドウ**が開く。 解析・構成のたびに開き直す。dev (`npm run tauri dev`) では一度も出ていなかった。 **真因**: `main.rs` の `windows_subsystem = "windows"` は**配布ビルドにだけ**効く (`cfg_attr(not(debug_assertions), …)`)。 コンソールを持たない GUI プロセスがコンソール用のプログラムを起動すると、Windows は `CREATE_NO_WINDOW` が無い限り **新しいコンソールを作って窓を出す**。dev のアプリはターミナルのコンソールを持ち、子はそれを共有するので窓が出ない。 起動箇所はどれもフラグを付けていなかった: `claude` / `agy` / `aider` の本体 (`runner.rs`)、`--version` 2 か所と `auth status` (`lib.rs`)、 `git ls-files` (`collect.rs`)。つまり**配布物でしか現れない不具合を、dev だけで検証していた**。 **処方**: 子プロセスの起動を `cli_runner::no_window` の `std_command` / `tokio_command` に一本化し、Windows で `CREATE_NO_WINDOW` (`0x0800_0000`) を付ける。Fuseforks の MCP 起動 (`fuseforks-core/src/mcp.rs`) と同じ。付け忘れの網として、ソースに `Command::new(` を直に書いた行を数えるテスト (`no_direct_command_new`) を置いた。検出器自身の陽性・陰性と、走査したファイル数の下限も試す (空振りを「違反なし」と読まないため)。Red = 10 か所。 **接地の限界**: **窓が出ないこと自体は、Windows の配布ビルドを起動しないと観測できない** — 起動フラグは `Command` から読み戻せず、 dev では症状が出ない。テストが固定しているのは「全部の起動箇所が口を通る」までで、効き目は次の配布物でユーザーが確かめる。 **一般化**: **dev と配布ビルドで違う設定 (`cfg_attr(not(debug_assertions))` / `data-locked` 等) は、その差が生む症状を dev では見られない。** 差を入れた場所を数え、差の影響を受ける経路 (ここではプロセスの起動) を 1 つの口に集めて、口を通ることをテストで固定する。 効き目は配布物で見る、と台帳に書いておく (rev47 の締めと同じ扱い)。 ## app / image_gen (2026-10-03 rev57 — つまみが 0.95 まで動くのに、0.68 から上は何も変わらなかった) **症状** (ユーザー): はめ込みの大きさを「0.70 以上拡大できない」。スライダーは 0.95 まで動き、値の表示も変わる。 **真因**: 合成の縮尺は `min(枠 / スクショ, 1.0)` で**等倍で頭打ち** (rev6 の設計どおり)。canvas は生成時に既定の枠 (帯ありで 0.68) で スクショが等倍になる大きさまで拡げてあるので、そこから上の枠は効かない (実データ: canvas 2824×1614 / スクショ 1920×1032 → 0.680)。 **設計は正しく、嘘をついていたのはつまみ** — 効かない範囲まで動き、既定位置も 0.78 の決め打ちで、帯ありの run の実効 0.68 と違っていた。 #19 (絵は 18° 傾いているのにつまみは 0° を指す) と同じ型で、#19 で傾きには「実効値は合成が使う関数から取る」を当てたのに、大きさには当てていなかった。 **処方**: 等倍の位置を合成と同じ式 (`compose::native_screen_ratio`) から `plate_preview` で返し、つまみは実効の大きさを指す。 等倍に目盛り、越えた分は「拡大 (ぼやける)」と出し、その時だけ引き伸ばす (`PlateOverride.allow_upscale`、人がつまみを動かした時だけ立つ)。 **一般化**: **上限のある量を指すつまみは、上限そのものも合成側から受け取る。** 範囲 (`min` / `max`) を UI に決め打ちすると、 合成が別の理由 (ここでは画素を引き伸ばさない) で頭打ちにした時に、つまみだけが動き続ける。#19 の処方を当てる時は、 **同じダイアログの他のつまみにも同じ問いを当てる** — 傾き・大きさ・位置は同じ合成の入力で、1 つだけ直すと残りが同じ嘘をつく。 ## app (2026-10-03 rev60 — 画像を 1 回ドロップすると 4 枚並んだ) **症状** (ユーザー): スナップショットの帯に画像をドロップすると、同じ画像が 4 枚並ぶ。 **真因**: 2 つの掛け算。①`addSnapshotPaths` の重複判定が `await` の**前**だけにあり、同じパスで同時に呼ばれるとどれも「まだ無い」と判定して push した (実測: 同時 4 回 → 4 枚、順番 → 1 枚)。②ドロップのリスナーの登録が `onMounted` 内の非同期で、**登録が終わる前に部品が外されると、外す関数が null のまま残った**。 Tauri / wry はドロップ 1 回につき 1 回しか送らないこと、ページの読み直しでは古いリスナーが鳴らないことはソースで確かめた。 ②の引き金は開発時の HMR (依存ファイルを続けて書き換えた) という仮説で、未確定。 **処方**: ①push の直前にもう一度見る。②`disposableListener` — 外す要求を先に受け付け、登録が終わった時点で外されていればすぐ外す。 **一般化**: **「見てから await して書く」は、同時に呼ばれると見た結果が古くなる。** 重複・登録済みの判定は、書く直前 (await の後) にもう一度する。 **非同期に登録して、外す関数を後から受け取る形は、受け取る前に外されると漏れる。** 外す側を先に受け付ける形にする。 片方だけなら症状は出なかった (①だけなら 1 枚、②だけでも重複判定が効く順番なら 1 枚) — 掛け算の不具合は、片側を直すと見えなくなり、もう片側が残る。両方を塞ぐ。