ユーザーランドハックからSDKネイティブへ:CMS移行ブループリント
CMSマルチセッションを外部CLIオーケストレーターからSDKネイティブIterationEngineへ移行する完全アーキテクチャ — シェルスクリプトをquery()呼び出しに置き換え、CMSを成功させたすべての利点を維持する。
Knowledge Comicシリーズ — 最大の技術的深度、最小の言葉。
なぜ移行するのか
CMSは動く。50回以上のイテレーション、ゼロのcontext腐敗、チェックポイント復旧。なぜ触るのか?
CMS現状:
シェルスクリプト → Claude CLIプロセス起動 → stdout解析 → チェックポイント書き込み
├── リリースごとに変わり得るCLIフラグに依存
├── 非構造化テキスト出力から<report>タグを解析
├── SDKの型システムと統合不可
└── 実行中のClaudeプロセスなしではユニットテスト不可
Claude Agent SDKはquery()を提供する — 新しい会話を作る単一の関数呼び出し。同じ分離。しかし今はPython関数で、サブプロセスではない。テスト可能。型付き。バージョン固定。
目標:トランスポートを置換し、アーキテクチャを維持する。
コアインサイト
CMSのパワーは一つのアーキテクチャ決定から来る:
OrchestratorはLLMではない。
シェルスクリプトはループする。LLMはイテレートする。シェルスクリプトはcontext windowを持たないので、決して埋まらない。
SDK移行はこの特性を保持しなければならない:
間違ったアプローチ:
Orchestrator = 長時間実行のquery()セッション
→ サブクエリの結果を蓄積
→ 同じcontext腐敗問題
→ Agent Teams Leadを再構築しただけ
正しいアプローチ:
Orchestrator = Pythonクラス(IterationEngine)
→ イテレーションごとにquery()を呼び出す(セッションresumeなし)
→ 各query()は新しいcontextを取得
→ IterationEngineのトークン消費はゼロ
→ CMSのコア優位性を維持
キーとなる1行:
result = await query(prompt=iteration_prompt, session_id=None)
# ^^^^^^^^^^^^
# resumeなし。新しいcontext。毎回。
アーキテクチャ:ビフォーアフター
CMS External(現状)
═══════════════════
┌─────────────────┐
│ cms-iterate.sh │ ← シェルスクリプト(LLMではない)
│ │
│ for i in 1..N: │
│ claude --print │─────→ [Claude CLIプロセス]
│ --prompt ... │ │
│ │ │ stdout
│ parse output │←──────────┘
│ write ckpt │───→ checkpoint.json
│ done │
└─────────────────┘
SDK Native(目標)
════════════════
┌──────────────────────┐
│ IterationEngine │ ← Pythonクラス(LLMではない)
│ │
│ for i in 1..N: │
│ await query( │─────→ [SDK query()呼び出し]
│ prompt=..., │ │
│ session_id=None │ │ ResultMessage
│ ) │←──────────┘
│ parse report │
│ checkpoint.save() │───→ checkpoint.json
│ done │
└──────────────────────┘
同じループ。同じチェックポイント。同じ分離。異なるトランスポート。
何が変わるか
| レイヤー | CMS External | SDK Native |
|---|---|---|
| Orchestrator | シェルスクリプト | IterationEngine Pythonクラス |
| 起動 | claude --print --prompt | await query(prompt, session_id=None) |
| 出力解析 | stdoutの正規表現 <report>...</report> | IterationReport.parse(result.result) |
| プロンプトテンプレート | .cms-iterate/prompts/iterator-1.md | prompt_loader.get("iterator-system", **vars) |
| 設定 | シェル内ハードコード | IterationConfig dataclass + defaults.yaml |
| Queryオプション | CLIフラグ | orchestrator.create_fresh_query() |
| エラー処理 | プロセスクラッシュ = 次のイテレーション | try/except 型付きエラー |
| テスト | 統合テストのみ(実行中のCLI必要) | ユニットテスト可能(query() mock) |
何が変わらないか
| コンポーネント | 理由 |
|---|---|
.cms-iterate/ ディレクトリ構造 | チェックポイント、レポート、プロンプト、バックアップ — すべて同じパス |
checkpoint.json フォーマット(v1.1.0) | 後方互換 — 古いチェックポイントは新エンジンにロード可能 |
| 決定ロジック | 同じ4つの停止条件、同じ継続ルール |
| 外部CMS Skills | 当面維持、後で非推奨 — エンジンは並行で動作 |
| 既存orchestratorメソッド | chat(), run(), run_skill() 変更なし |
| Agent定義 | すべての .claude/agents/ 変更なし |
4つの設計決定
決定1:Fresh Queryのパブリック API
エンジンはcontextを蓄積せずにquery()を呼ぶ必要がある。これにはorchestrator上のクリーンなパブリックメソッドが必要 — プライベート内部への直接アクセスではない。
間違い:
engineがorchestrator._build_options(session_id=None)を呼ぶ
└── プライベートメソッド。orchestratorの内部リファクタで壊れる。
正しい:
engineがorchestrator.create_fresh_query(prompt, max_turns=30)を呼ぶ
└── パブリック契約。エンジンはオプションの構築方法を知らない。
重要な理由:OrchestratorはSDKクライアント、APIキー、モデル選択、ツール設定を所有する。エンジンはこれらを複製すべきではない。「このプロンプトで新しい会話をください」と言って結果を受け取るだけ。
1つのメソッド。クリーンな境界。エンジンはSDKを直接importしない。
決定2:初日から並列実行
CMSは既に独立タスクを並列で実行できる:
CMS並列(現状):
cms-iterate.shが3つのCLIプロセスを同時起動
└── Process 1: Task A(依存なし)
└── Process 2: Task B(依存なし)
└── Process 3: Task C(依存なし)
全完了を待つ → Task D(A, Bに依存)を続行
SDK移行はこれを維持する必要がある。v2機能としてではなく、v1パラメータとして。
SDK並列(目標):
engine.start(request, parallel=True)
└── asyncio.gather(
query(task_A_prompt, session_id=None),
query(task_B_prompt, session_id=None),
query(task_C_prompt, session_id=None),
)
全完了を待つ → Task Dを続行
設定インターフェース:
iteration:
parallel: false # v1デフォルト:直列
max_parallel_queries: 3 # parallel=trueの時
v1がparallel=falseで出荷されても、パラメータは存在する。v2でAPIシグネチャ変更不要。
決定3:進化フックポイント
CMSのSelf-Evolving LoopはAgent Teamsに対する最大の差別化要因:
イテレーション失敗 → 学習を抽出 → skillsを進化 → より良いツールでリトライ
エンジンにはこのフックポイントが必要 — v1が完全なループを実装しなくても。
_run_loop 疑似コード:
for iteration in range(max):
report = await _run_single_iteration(checkpoint)
if report.status == "completed":
_apply_report(checkpoint, report)
checkpoint.recovery.failure_count = 0 ← 成功でリセット
elif report.status == "failed":
checkpoint.recovery.failure_count += 1
if config.enable_evolving: ← フックポイント
await _evolve(checkpoint, report) ← 抽出 + 進化
if failure_count >= threshold:
break
checkpoint.save()
v1の_evolve()実装:pass。5文字。しかしフックはループ内にあり、configフラグは存在し、IterationReportは学習に必要な失敗コンテキストを既にキャプチャしている。
v2でプラグイン:experience-extractor agent → skill-evolver agent → 進化したskillsでリトライ。ループ構造のゼロ変更。
決定4:防御的エラー処理
CMSプロセスは自然に分離されている — CLIプロセスのクラッシュはシェルスクリプトを巻き込まない。SDKのquery()呼び出しは同じPythonプロセス内で実行される。ハンドルされないexceptionがエンジンを殺す。
3つのエラー層:
Tier 1 — 予想される失敗(レートリミット、タイムアウト):
→ IterationReport(status="failed", errors=[...])を返す
→ エンジンは閾値に基づいて次のイテレーションに進むか停止
→ チェックポイントは操作前後で保存
Tier 2 — Queryの異常(出力に<report>タグなし):
→ IterationReport(status="partial", raw_output=text)を返す
→ エンジンは警告をログし、次のイテレーションを試行
→ reports/で生の出力を検査可能
Tier 3 — インフラ障害(SDKクラッシュ、ネットワーク断):
→ チェックポイントを即時保存
→ exceptionを再throw(エンジン停止)
→ resumeは最後に保存したチェックポイントから続行
重要な不変条件:チェックポイントは失敗する可能性のある操作の前に必ず保存される。エンジンは1イテレーション以上の作業を失うことはない。
_run_single_iteration:
checkpoint.save() ← query前に保存(クラッシュ保護)
result = await query(...) ← 失敗する可能性あり
report = parse(result) ← 失敗する可能性あり
checkpoint.save() ← 成功後に保存
return report
データモデル
Checkpoint(v1.1.0互換)
Checkpoint
├── version: "1.1.0"
├── iteration_type: "auto-cycle" | "auto-explore" | "custom"
├── request: str
├── current_iteration: int
├── max_iterations: int
├── status: "running" | "completed" | "failed" | "stopped"
│
├── original_context
│ ├── goal: str
│ └── acceptance_criteria_file: str
│
├── context_summary
│ ├── current: str
│ ├── key_decisions: list[str]
│ ├── blockers: list[str]
│ └── next_action: str
│
├── completed_items: list[dict]
├── pending_items: list[dict]
├── history: list[dict] ← イテレーションごとに1エントリ
│
├── progress
│ ├── percent: int
│ └── estimated_remaining: int
│
└── recovery
├── last_successful_iteration: int
└── failure_count: int
シリアライゼーション契約:Checkpoint.to_dict()の出力はCMSシェルスクリプトが書くものとバイト同一。古いチェックポイントはCheckpoint.from_file()でロード。新しいチェックポイントは古いCMSで読める。
IterationReport
IterationReport
├── task_id: str
├── iteration: int
├── status: "completed" | "partial" | "failed" | "blocked"
│
├── iteration_result
│ ├── action_taken: str
│ ├── files_changed: list[str]
│ ├── tests_passed: bool
│ └── errors: list[str]
│
├── checkpoint_update
│ ├── completed_items: list[dict]
│ ├── pending_items: list[dict]
│ ├── progress_percent: int
│ └── context_summary: str
│
└── continue_decision
├── should_continue: bool
└── reason: str
解析元:query()出力の<report>JSON</report>タグ — CMSが既に使っているのと同じフォーマット。
エンジンパブリックAPI
IterationEngine
├── start(request, type, max_iterations, ...) → Checkpoint
│ 新しいチェックポイントを作成し、ループを実行
│
├── resume() → Checkpoint
│ ディスクからチェックポイントをロードし、前回のイテレーションから続行
│
├── stop() → None
│ フラグを設定し、現在のイテレーション完了後にループを終了
│
└── status() → Checkpoint
ディスクからチェックポイントを読み取る(副作用なし)
3つのMCPツールとして公開:iteration_start、iteration_resume、iteration_status
orchestratorメソッドとしても公開:orchestrator.run_iterations(request, ...)
2つの入口、同じエンジン。MCPツールはClaude駆動ワークフロー用。Orchestratorメソッドはプログラマティック用途。
決定ロジック(CMSから変更なし)
続行条件(すべてtrue):
├── pending_itemsが空でない
├── current_iteration < max_iterations
├── recovery.failure_count < failure_threshold(デフォルト:3)
└── stop()が呼ばれていない
停止条件(いずれかtrue):
├── pending_itemsが空 → status: "completed"
├── current_iteration >= max_iterations → status: "stopped"
├── recovery.failure_count >= threshold → status: "failed"
└── stop()が呼ばれた → status: "stopped"
同じ4つの条件。同じ動作。Claudeを実行せずにテスト可能になった。
ファイルマップ
新規ファイル:
src/core/iteration_engine.py ~400行 ← エンジン + データモデル
config/prompts/iterator-system.md ~250行 ← CMSプロンプトから移植
tests/core/test_iteration_engine.py ~300行 ← ユニット + 統合テスト
変更ファイル:
src/core/orchestrator.py +30行 ← run_iterations() + create_fresh_query()
src/tools/self_dev_tools.py +90行 ← 3つのMCPツール
config/defaults.yaml +6行 ← iterationセクション
変更なし:
.cms-iterate/ ← 同ディレクトリ、同フォーマット
.claude/agents/ ← すべてのagent定義
.claude/skills/cms-* ← フォールバックとして維持
src/core/orchestrator.py(残り) ← chat(), run(), run_skill()
新規コード合計:~1,070行 変更コード合計:~126行 リスク面:既存2ファイルに小規模追加
実装順序
Phase 1 — 基盤(統合なし、純粋にユニットテスト可能)
1. iteration_engine.py — Checkpoint + IterationReportデータモデルのみ
2. test_iteration_engine.py — データモデルテスト + 実チェックポイントとの後方互換
3. 実行:pytest tests/core/test_iteration_engine.py -v
Phase 2 — エンジンロジック(queryをmock、まだ実統合なし)
4. iteration_engine.py — 決定ロジック付きIterationEngineクラス
5. iterator-system.md — プロンプトテンプレート移植
6. test_iteration_engine.py — 決定ロジック + mockイテレーションテスト
7. 実行:pytest tests/core/test_iteration_engine.py -v
Phase 3 — 統合(orchestratorに接続)
8. orchestrator.py — create_fresh_query() + run_iterations()
9. self_dev_tools.py — 3つのMCPツール
10. defaults.yaml — iteration設定
11. 実行:pytest tests/core/ -v(フルスイート、リグレッションなし)
Phase 4 — スモークテスト
12. 実際の.cms-iterate/checkpoint.jsonをロード → 全フィールド検証
13. Checkpoint往復:from_file() → to_dict() → バイト比較
14. IterationReportが実CMSレポートテキストを解析
各フェーズは独立してリリース可能。 Phase 1だけで型付きチェックポイント処理を提供。Phase 2でテスト可能な決定ロジックを追加。Phase 3ですべてを接続。Phase 4で後方互換性を検証。
移行タイムライン
Week 1: Phase 1-2(データモデル + エンジンロジック)
└── CMS externalが全本番ワークロードを実行
└── エンジンは存在するが未接続
Week 2: Phase 3-4(統合 + スモークテスト)
└── 両パスが利用可能:orchestrator.run_iterations() と /cms skill
└── 同じタスクを両方で実行し、チェックポイントを比較
Week 3: シャドーモード
└── エンジンがCMSと並行して実タスクを実行
└── チェックポイント出力を比較
└── 差異がある場合:調査、エンジンを修正、CMSをソースオブトゥルースとして維持
Week 4: 切り替え
└── エンジンをデフォルトに
└── CMS skillsを非推奨マーク(削除はしない)
└── 1ヶ月問題なしでv2でCMS skillsを削除
これが実現すること
エンジンがSDKネイティブになると、CMSにはできなかった3つのことが可能になる:
1. 任意のPythonコードからプログラマティックイテレーション
# Before:Claude CLI / Skill呼び出しのみ
# After:
engine = IterationEngine(orchestrator, config)
checkpoint = await engine.start("authモジュールをJWT使用にリファクタ")
print(f"{checkpoint.current_iteration}イテレーションで完了")
イテレーションループがライブラリ呼び出しになる。テスト、CIパイプライン、外部ツールすべてがトリガー可能。
2. 型付きエラー処理
# Before:stdoutテキストに正規表現でエラーパターンマッチ
# After:
if report.status == "failed":
for error in report.iteration_result.errors:
logger.error(f"Iteration {report.iteration}: {error}")
正規表現不要。「CLIが予期せぬ出力をした?」の問題なし。全行程構造化データ。
3. Agent Teamsとの合成
# 終着点:イテレーションを実行するAgent Teamsチームメイト
Task(
subagent_type="general-purpose",
team_name="dev-team",
prompt="iteration_startツールでauthモジュールに10イテレーション実行"
)
Agent TeamsチームメイトがMCPツール経由でIterationEngineを呼び出せる。Agent Teamsの中のCMS。 両アーキテクチャの最良の組み合わせ。
原則
クリーンなインターフェースでユーザーランドソリューションを構築する。プラットフォームが追いついた時、移行はトランスポート置換であり、書き直しではない。
CMS → IterationEngineは1,070行の新コードと126行の変更。チェックポイントフォーマットは変わらない。ディレクトリ構造は変わらない。決定ロジックは変わらない。
CMSが最初から正しい抽象で構築されていたから。
関連記事
- Agent Teamsが存在する前に、私たちはそれを作っていた — CMSとAgent Teamsが同じアーキテクチャに収束した理由
- Agent Teams:Meshトポロジーと5つの死に方 — 通信トポロジーと障害モード
- マルチエージェントアーキテクチャ:並列実行パターン — Sub-agentの基礎