前回の記事(【WpAiCli × MCP】AIに「コマンド」ではなく「道具」を渡す。WpAiCliをMCP対応させて感じた“確信”の変化)で、私は「AIに型定義された道具(MCP)を渡すことで、CLIの確率的な推論から“確信”を持った実行へと変わった」と書きました。
ClaudeなどのAIエージェントに「この下書きを作っておいて」「アイキャッチ画像を設定して」と伝えるだけで、AIが自律的にツールを呼び出し、WordPressへの反映からローカルキャッシュの同期までを一瞬で完了してくれる。まさに「AIに指先が生えた」ような快適さでした。
しかし、この新しい執筆体験を日々の実務で酷使し始めた瞬間、これまでは見えなかった“道具の足腰の弱さ”が次々と露呈することになりました。
- 「双方向Syncというブラックボックス」: なんでも自動で同期しようとするあまり、「ローカルで推敲中の原稿が勝手に上書きされないか」「いつプッシュされたのか」が予測できず、人間もAIも不安を抱える。
- 「並行アクセスのクラッシュ」: 自分が手動でCLIコマンドを叩いている裏で、常駐するAI(MCPサーバー)が動き、SQLiteが
database is lockedで落ちる。 - 「設定消失の恐怖」:
dotnet buildやツールのバージョンアップを行うたびに、接続設定が吹き飛びかける。 - 「1,840行のモノリス」: コードが肥大化し、CLIとMCPの間で微妙にロジックが二重管理になっている。
AIが道具を「推測」ではなく「確信」を持って使うようになったからこそ、その道具の側にも、人間の手触り(UX)、AIの自律性(AX: Agent Experience)、そして並行処理やセキュリティといった“実務インフラとしての耐久性”が強く求められるようになったのです。
今回は、WpAiCliを「実験的なツール」から「人間とAIが気兼ねなく並行して酷使できる本物の執筆インフラ」へと脱皮させるために実施した、大規模リファクタリングの記録をお届けします。
1. 最大の思想転換:ブラックボックスな「Sync(双方向)」を捨て、「Gitの思想(Pull / Push)」へ
今回の改修の中で、ユーザー体験(UX)とAIの行動(AX)に最も劇的な変化をもたらしたのが、同期アーキテクチャの根本的な見直しです。
「なんでも自動でやってくれるSync」の危うさ
従来の WpAiCli は、posts sync という「双方向同期」をメインに据えていました。ローカルの差分とサーバーの差分を検出し、いい感じに自動でマージ・反映してくれる――一見すると非常に便利に思えます。
しかし、実際の執筆フローではこれが大きなストレスの源でした。
– 「いまローカルで推敲している途中の原稿が、Syncの拍子に勝手に上書きされないか?」
– 「どのタイミングでサーバーに変更が反映されたのか直感的に分からない」
特に、自律的に動くAIエージェントに SyncPosts というツールを渡すと、「AIが良かれと思って裏で同期を走らせ、人間が手元で直していた文章と衝突する」という恐怖が常に付きまとっていました。
「安全な Pull」と「意図を持った Push」への完全分離
そこで、なんでも一括で処理しようとするブラックボックスな同期を捨て、Git と全く同じメンタルモデル(Pull / Push)へと責務を完全に分離しました。
[1. CHECK] 一覧や詳細を確認する -> wpai posts list / ListPosts
│
[2. PULL] 最新を取り込む -> wpai posts pull / PullPosts
│ ※ 安全第一:未プッシュのローカル編集は絶対に上書き・送信しない
[3. EDIT] ローカルで推敲する -> wp-cache/.../*.md を直接編集
│
[4. PUSH] 意図して反映する -> wpai posts push <ID> / PushPost
pull(安全な一方向取得): サーバーの最新状態を取り込みますが、ローカルで編集中のファイルは絶対に上書きせず、サーバーへも一切プッシュしません。安心して事前フェッチできる安全弁です。push(意図を持った反映): 人間やAIが推敲を終え、「よし、これを反映するぞ」と決めた差分だけをサーバーへ届けます。
内部のサービスクラス名も SyncService から WorkspaceService へと改名。曖昧な SyncReport も TransferReport に刷新しました。
さらに、MCPツールの定義から曖昧な SyncPosts を全廃しただけでなく、CLI の wpai posts sync も非推奨化(実行時は pull と push の使い分けを案内して終了)し、完全に pull / push に一本化しました。
トレードオフ:手軽さを手放し、「確信」を買う
正直に言えば、syncの『ワンクリック感』は失いました。1回のコマンドですべてが済んでいた手軽さが、「pull」と「push」の2回の明示的な操作に分かれたわけですから、手数は1回増えています。
だが、開発ノートとして率直に述べるなら、その「1回増えた操作」こそが、AIが勝手に原稿を触る恐怖を消し去るための必要な代償(トレードオフ)でした。
以前はAIに作業を任せても、推敲中の原稿を上書きされないか横目でチラチラ見張っていました。しかし今では、タスクを投げて席を離れ、戻ったときの手元の状態が『必ずこうなっている』と確信できるようになったのです。
2. AX(Agent Experience)という新視点:道具の手触りを極める
今回の一連の改善を通じて強く実感したのが、「AX(Agent Experience:エージェント体験)」という設計視点の重要性です。人間向けの UX(User Experience)と同じように、自律的に動く AI を「第一級のユーザー」と見立て、AI にとっての使い心地を極限までチューニングしていく必要があります。
AIの迷いを消す「ツール優先度タグ([PRIMARY])」
MCPサーバーとして19個のツールをAIに提示すると、AIは「どのツールが主要で、どれが例外的なのか」を確率的に推論しなければなりません。
例えば以前は、ユーザーが「記事一覧を最新にして」と頼んだだけなのに、AIがどれを呼べばいいか迷った末に滅多に使わないファイル整理ツール(OrganizePosts)を誤って呼び出してしまうことがありました。
そこで、MCPツールの Description の先頭に優先度タグを明記しました。
– [PRIMARY]: 日常の執筆タスクの95%で使用する道具(PullPosts, PushPost, CreatePost など)
– [ADVANCED]: 競合解決やリビジョン復元などの例外処理
– [MAINTENANCE]: 通常は呼ぶ必要のない整理系
このタグを付与した瞬間から、AIは迷わず [PRIMARY] PullPosts を即座に選択するようになり、ツールの呼び間違いや無駄な試行錯誤がゼロになりました。
+9時間のタイムトラベル:二重ローカル変換バグの顛末
地味ながら最も笑えて、最も危険だったのが「日付の二重ローカル変換バグ」でした。
WordPress の REST API は日本標準時(JST)のローカル時刻を返しているのに、それを YAML フロントマターに書き出す際、シリアライザ内部で「念のためローカル変換を適用しよう」と親切心が働き、さらに +9 時間が加算されてしまっていたのです。
結果として、いま書いたばかりの下書き記事の公開日時が、9時間後の「未来」に化けるという怪現象が発生していました。
人間が見れば「あ、9時間ズレてるな」と苦笑いで済みますが、AIにとっては死活問題です。AIはメタデータを論理的に解釈するため、「この記事は未来日付が設定されているから予約投稿(future)として扱わなければならない」と判断し、推論の辻褄を合わせようとして自律動作が狂ってしまうのです。
この二重変換を根本から解消し、WordPress の正確な投稿時刻がそのままローカルに記録されるようにしました。
日本語スラッグと HTML エンティティのデコード
カテゴリやタグのスラッグが %e3%82%a2%e3%83%97%e3%83%aa... とパーセントエンコードされたまま表示されたり、タイトルに –(ダッシュ)や & が混ざっていた問題を一掃しました。
ターミナル出力でも Markdown フロントマターでも、人間とAIが自然に読める「普通の日本語」としてデコードして扱うように改善しています。
ファイル名から「空白文字」を追放
記事タイトルに半角・全角スペースが含まれていると、生成されるファイル名(123-タイトル 名.md)にも空白が混入してしまいます。これは人間にとっても扱いにくいだけでなく、AIがシェル経由でファイルパスを処理する際にクォートが壊れ、構文エラーを引き起こす温床でした。
サニタイズ処理を強化し、全角スペースを含むあらゆる空白文字をハイフン - に統一置換するようにしました。
削除の可視化と 404 フォールバック
記事を削除した際、単に「削除しました」とだけ出るのではなく、Deleted post 123 ("旧タイトル") と削除対象を明確に出力するように改善。また、サーバー側で既に削除されていた場合でもクラッシュせず、ローカルキャッシュを綺麗に自動消去する 404 フォールバックを実装しました。
3. Phase 1:認証情報と設定の永続化 — 安心して使える道具へ
ここからは、内部アーキテクチャの刷新です。最初に着手したのは「設定の保存先」と「認証情報の保護」でした。
設定ファイルの恒久保存(~/.wpaicli/ への移行)
従来の WpAiCli は、接続設定(connections.json)をアプリケーション実行ディレクトリ(AppContext.BaseDirectory)に保存していました。これは dotnet tool の内部パスやビルドフォルダに依存するため、以下の致命的リスクがありました。
– dotnet build や dotnet clean を叩くと接続設定が消える
– dotnet tool update でツールを更新した際に設定が失われる
そこで、ユーザーホームディレクトリ直下の ~/.wpaicli/ を専用ディレクトリとして新設。旧パスからの自動移行ロジックを組み込み、POSIX環境ではディレクトリ権限を 0700(所有者のみ読み書き実行可能)に制限しました。
OSネイティブストア連携とプロセスの覗き見防止
WordPress の REST API 接続に必要なアプリケーションパスワードの管理も、平文 JSON から OS ネイティブストア(macOS Keychain / Linux Secret Service)連携へと切り替えました。
特に macOS では、当初 security add-generic-password -w <password> を呼んでいました。しかし、これだと他のプロセスから ps aux コマンドを叩かれた際、コマンドライン引数としてパスワードが平文で丸見えになってしまうという深刻なセキュリティ上の脆弱性がありました。
これを security -i の対話型セッションに変更し、標準入力経由でパスワードを流し込むことで、プロセス引数からの覗き見を完全防御しました。フォールバックファイルも作成時に 0600 を強制しています。
4. Phase 2:CLIとMCPを対等に扱う — 1,840行モノリスの解体
WpAiCli のエントリーポイントである Program.cs は、全サブコマンドとロジックが同居した結果、1,840行超の巨大モノリスに膨れ上がっていました。
サブコマンドの完全分離
各コマンドハンドラを Commands/ 配下の 13 ファイル(PostsCommand, CategoriesCommand, TagsCommand, MediaCommand, ConnectionsCommand, McpCommand 等)へ分割。Program.cs は 183 行までスリム化され、純粋なルーターとして責務が限定されました。
「CLI」と「MCP」でロジックを二重持ちしない
最大のリファクタリングは、CLI と MCP の共通ビジネスロジックを WorkspaceService に完全集約したことです。
記事の作成・更新・削除・同期といった処理では、以下のような一連の細かな制御が必要です。
– Markdown のフロントマター検証
– タイトルやスラッグのサニタイズ
– ローカルキャッシュ(SQLite + ファイル)の更新
– 公開ステータスに応じたフォルダ自動移動(draft/ ↔ publish/)
– 削除時の 404(サーバー上ですでに消えている場合)の安全なキャッシュ追従
以前はこれらが CLI ハンドラと MCP ツール定義の間で微妙に重複・分散していました。今回の改修で、CLI も MCP もまったく同じ workspaceService.CreatePostAsync() や DeletePostAsync() を呼び出す構造に統一。AI が MCP 経由で記事を作成しても、人間が CLI から作成しても、100% 同一の整合性が保たれるようになりました。
5. Phase 3:並行動作に耐える足腰 — SQLite 最適化と実証テスト
AI(常駐MCP)と人間(手動CLI)が同時にツールを使うようになると、次にぶつかる壁が「SQLite の並行アクセス競合(database is locked)」です。
1. WAL(Write-Ahead Logging)モードの適用
SQLite のデフォルトジャーナルモードでは、書き込み中にすべての読み込みがブロックされます。これを WAL モードに変更し、読み込みと書き込みが互いをブロックしない設計に移行しました。
また、DbContext のインスタンス生成ごとに走っていた Database.EnsureCreated() を ConcurrentDictionary で抑制し、同一 DB パスに対する初期化コストを最小化しました。
2. 実証テストで見つかった「busy_timeout 消失問題」
ここが今回最も深く、面白い発見でした。
書き込み競合が発生した際、即座に例外を吐かずに「少し待ってリトライする」ための SQLite の設定が PRAGMA busy_timeout = 5000;(5秒待機)です。
当初は DB の初期化時に Database.ExecuteSqlRaw("PRAGMA busy_timeout = 5000;") を実行していました。しかし、実証テストを綿密に行う中で重大な事実が判明したのです。
journal_mode = WALはファイルヘッダに永続化されるが、busy_timeoutは「接続スコープ(Per-Connection)」でありファイルには保存されない。
ExecuteSqlRaw で開かれた接続は実行後に即座に閉じられます。同一プロセスの接続プールが再利用されている間は有効に見えますが、別プロセス(まさに CLI と 常駐 MCP サーバーが並行稼働するシナリオ)が新規接続を開いた瞬間、busy_timeout はデフォルトの 0(待機時間ゼロ=即時エラー)に戻っていたのです。
この問題を根本解決するため、EF Core の DbConnectionInterceptor を導入しました。
public class BusyTimeoutInterceptor : DbConnectionInterceptor
{
private const int TimeoutMilliseconds = 5000;
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = $"PRAGMA busy_timeout = {TimeoutMilliseconds};";
cmd.ExecuteNonQuery();
}
public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"PRAGMA busy_timeout = {TimeoutMilliseconds};";
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
}
DbContext の OnConfiguring にこのインターセプターを登録することで、接続プールクリア後であっても、別プロセスであっても、接続が開かれるたびに確実に 5,000ms のタイムアウトが適用されるようになりました。これもまた、並行稼働する AI をエラー落ちさせないための決定的な AX 改善でした。
3. リソースの確実な解放
CacheService に IDisposable および IAsyncDisposable を実装し、内部の DbContext を確実に解放するようにしました。あわせて Program.cs のホスト生成部を using var host = ... とすることで、CLI コマンド終了時にもリソースリークが一切生じないように徹底しました。
6. 品質を担保する70件のテストと「AI向け行動規約(AGENTS.md)」
リファクタリングの総仕上げとして、xUnit による本格的なユニットテストプロジェクト(tests/WpAiCli.Tests)を新設しました。現在、全 70 件のテストがミリ秒単位で高速に実行されています。
- OptionParserTests (22件): フラグ、キー・バリュー、配列、真偽値、重複上書きなど
- SanitizeTitleTests (14件): HTMLエンティティデコード、クロスプラットフォーム禁止文字(
/ : * ? " < > |)の置換、空白ハイフン化、untitledフォールバック - HashTests (6件): 差分検知の要となる SHA-256 の決定性・空文字ハッシュの検証
- FrontMatterTests (17件): YAML相互シリアライズ、日付・ステータスバリデーション
- CacheDbContextTests (7件): WALモード検証、Dispose冪等性、そして「プールクリア後の新規接続でも busy_timeout = 5000 が維持されているか」の実証テスト
- WpAiCliPathsTests (4件): パス解決、
0700/0600パーミッション設定の検証
なぜ AI 向けのツールにこれほど徹底したテストが必要なのでしょうか?
人間なら、コマンドが失敗した時に「あ、オプション間違えたかな」とエラーログを見て自分でリトライできます。しかし、自律的に動く AI エージェントは違います。予期せぬエラーに遭遇すると推論の無限ループに陥ったり、誤ったリカバリ行動を取って状況をさらに悪化させてしまうのです。
これらのテスト群は、人間が見張っていなくても AI が安全・確実にツールを使い倒せるための絶対的なガードレールなのです。
AX の象徴:AIが迷わないための「AGENTS.md」
AX(Agent Experience)の観点で最も重要だったのは、AI に「何をやってはいけないか」を明示的に伝えることでした。
AI コーディングアシスタントや執筆エージェントがプロジェクトに入った際に迷わないよう、リポジトリ直下に AGENTS.md を制定しました。
- 新規作成のゴールデンルール:
- MCP が使えるなら
CreatePostを直接呼ぶ(型安全・エスケープ不要) - CLI を使うなら「タイトルで枠作成 → 生成されたキャッシュを直接編集 → push」の3ステップを守る
- MCP が使えるなら
- 厳禁事項(アンチパターン):
- ワークスペース直下に使い捨ての
draft.mdを作って--content-fileで渡すような迂回行動の禁止 - キャッシュディレクトリ内のファイルを
mvやエクスプローラー等で手動移動・リネームする行為の禁止(SQLiteキャッシュが破壊されるため、必ずツールに任せる)
- ワークスペース直下に使い捨ての
CLI のヘルプメッセージにも Important for AI: としてこのガイダンスを埋め込み、AI が自己判断で変な行動を取らないようガードレールを敷きました。
おわりに:タスクを投げてコーヒーを淹れに行ける安心感
今回のリファクタリングを終えて、私の執筆体験は劇的に変わりました。
以前は、AI に執筆や画像設定のタスクを任せても、「いま自分が手元で直している原稿が、裏で勝手に同期されて上書きされないか」「データベースがロックされてコケていないか」と、画面を横目でチラチラ見張りながら作業していました。便利にはなったものの、心のどこかに常に緊張感があったのです。
しかし今では、AI にタスクを投げたら安心して席を離れ、コーヒーを淹れに行けるようになりました。
戻ってきたとき、手元の Markdown ファイルは絶対に意図通りに保たれており、AI は型安全な MCP ツールで淡々とタスクを完了させている。そして、手元でじっくり推敲を終えたら、自分のタイミングで push を叩く。
AI に「道具」を渡すということは、ツールの側に「人間以上の堅牢性と、絶対に期待を裏切らない予測可能性」が求められるということです。
「なんでも自動でやってくれる魔法のSync」を潔く捨て、確実で安全な「Pull / Push」の分担に戻したこと。
そして、CLI と MCP が同じ強固な足腰の上で並行稼働できるようにしたこと。
今回のリファクタリングを経て、WpAiCli は単なる「実験的なスクリプトの集合体」から、AI と人間が気兼ねなく並行して酷使できる、真の執筆インフラへと完成しました。
これからも日々の執筆をこのツールに委ねながら、実務に即した進化を続けていきたいと思います。

コメント