DeepSeek Harness で Service DefinitionService ProviderConsumer という三つの言葉を初めて目にしたとき、多くの人はそれらを抽象的なアーキテクチャ用語のセットと捉え、実際のコーディングとはあまり関係がないと感じるでしょう。しかし、Harness に新しい能力を組み込もうとした瞬間——たとえば「テキストを大文字に変換する」といった一目でわかるほど単純な機能であっても——あなたはこう気づくはずです。このシステムが長期的に進化できるかどうかを本当に決めるのは、能力そのものがどれほど強力かではなく、その能力に明確な縫い目(seam)が切り出されているかどうかである、と。ここでいう「縫い目」とはコードの欠陥ではなく、意図的に残された交換可能な接合部を指します——それはインターフェース、実装、モデル向けツールの三者をそれぞれ独立させ、どれか一つを差し替えても他の二つに影響が及ばないようにします。本記事は、すでに Harness に慣れ親しみ、その拡張メカニズムを深く理解したい上級読者を対象としています。まず能力と縫い目の関係を徹底的に解説し、その後、本当に交換可能な能力 myCap をゼロから書いていきます。Definition / Provider / Consumer という三つのパッケージの境界、登録のタイミング、型宣言のマージ、リクエストと結果の分離、ツールのラッピング、依存性注入を一層ずつ分解し、いつでも差し替え可能で独立して進化できる新しい器官を Harness に自らの手で生やせるようにします。

Definition / Provider / Consumer:なぜ一つの能力を三つのパッケージに分割しなければならないのか

公式ドキュメントでは、これら三つの言葉はしばしば一緒に登場しますが、それぞれが何を担当するのかを最初に明確に説明している例はほとんどありません。まず最も素朴な判断を示します。三つが揃って初めて完全な能力、すなわち一つの seam が構成されるのであり、単一の役割だけでは seam にはならない。 この言葉が拡張体系全体の土台です。Definition はインターフェースと型にすぎず、Provider は実装にすぎず、Consumer はモデルに見せるツールの外殻にすぎません——どの役割を単独で取り出しても、「私は一つの能力を実装した」とは言えません。三つが組み合わさり、それぞれが然るべき位置について初めて、能力は Harness の中に真に存在することになります。

混乱を避けるため、三つの役割の責務境界をもう一度整理しておきます。

  • Service DefinitionCordis サービス、およびリクエスト Request と結果 Result の型を定義する役割です。それは「どんな能力があり、どんな形をしているか」を宣言するだけで、どのように実装されるかにはまったく関心を持ちません。Bash を例にすると、Definition は dsh-shell パッケージであり、ctx.shell として登録され、ShellExecRequest と ShellRunResult という二つの型を定義します。
  • Service Provider:その能力を実際に実装する役割で、通常は一つの実行環境を対象とします。Definition の抽象クラスを継承し、具体的な振る舞いを埋めます。Bash の Provider は dsh-bash-local で、ローカルコンピュータ上でコマンドを実行します。同じ Definition には、dsh-bash-sandbox(サンドボックス内で実行)や dsh-pwsh-local(PowerShell を実行)など、他の Provider も存在します。
  • Consumer:モデル向けのツールで、能力をモデルが呼び出せるツールとして公開します。能力をツールスキーマにラップし、モデルが呼び出せるようにしますが、自分自身は実際の処理を行いません。Bash の Consumer は dsh-tool-bash で、ctx.shell をモデルが呼び出せる bash ツールにラップします。

公式リファレンスドキュメントでは、ctx.shell の三役割の帰属が1つの表で管理されており、これはぜひ覚えておく価値があります。なぜなら、「概念」を「CTX キー + パッケージ名」という具体的なマッピングに落とし込んでいるからです:

ctx キー役割所属パッケージ(Definition)実装(Provider)直接の消費側(Consumer)
ctx.shellseamshellbash-local / bash-sandbox / pwsh-localtool-bash / tool-pwsh / hooks-claude-code / hooks-codex

この表には、見落とされがちな事実が隠されています:消費側は tool-bash だけではありません。 ほかにも tool-pwsh、hooks-claude-code、hooks-codex があり、2つのフックブリッジプラグインも Consumer に数えられます。これらは tool-bash と同様、ctx.shell というインターフェースしか認識せず、背後で動いているのがローカル実行器なのか、サンドボックス実行器なのか、それとも PowerShell なのかにはまったく関心がありません。これこそが seam の価値です:消費側と実装側の間には直接的な依存が一切なく、唯一の中介は Definition です。

示意图
ケイパビリティ seam の三役割関係図:Definition が中央に位置し、Provider と Consumer はそれぞれそれだけに依存し、両者は互いに依存しない。Provider を差し替えるとき、Definition と Consumer は一行も変更する必要がない。

この関係図をもう少し細かく見ると、その構造は非常に美しいものです:Definition が中央にあり、Provider と Consumer はどちらもそれだけに依存しています。Provider は Definition を継承して実装し、Consumer は inject: ['shell'] を通じてそれに依存します。そして Provider と Consumer の間には一切の依存がありません。これはつまり、Provider を差し替えるとき、Definition と Consumer は一行も変更する必要がないということです。ローカル実行からサンドボックス実行に切り替えたい場合、cordis.yml のロード項目を一行置き換えるだけで済み、ツール層のコードには一切触れる必要がありません。

ただし、ここで明確にしておくべき判断基準があります:すべてのケイパビリティを3つのパッケージに分割すべきというわけではありません。 3つの役割は同じパッケージに置くことも、異なるパッケージに分割することもでき、判断基準はただ1つ——これらの役割が独立して進化または差し替えられる必要があるかどうかです。あるケイパビリティが1つの実装しか持たず、実行器を永遠に差し替える必要がないなら、Definition、Provider、Consumer を1つのパッケージに詰め込んでもまったく問題ありません。逆に、将来どこか一環が独立して変化する可能性があるなら、それを切り出すべきです。パッケージを分割するのは見た目のためではなく、変化を最小範囲に隔離するためです。

もう1つ取り上げる価値のある細部があり、それは「パッケージ境界では明示が暗黙に優る」という設計哲学を体現しています。Bash seam では、モデル向けのリクエスト ShellExecRequest と、実行器が実際に使用する完全に解決済みの仕様 ShellExecSpec が意図的に分けられています:前者は workdir、timeoutMs などのオプションフィールドのみを持ち、後者はすべてのフィールドが必須です。ツール層は両者の間で ctx.shell.resolve(request) を呼び出して解決を完了します。なぜツール層にすべて埋められた仕様を直接構築させないのでしょうか?なぜなら、モデル向けの入力は当然ながらフィールドが欠ける可能性があり、デフォルト値を補う必要がある一方で、実行器はフィールドの欠落を許容したくもできもしないからです。「モデル側の緩いリクエスト」と「実行器側の厳格な仕様」をパッケージ境界で分け、明示的に一度の resolve で変換を完了させることは、暗黙の前提を明示的な契約に変えることです。 この経験は、あなたが自前のケイパビリティを構築するときに何度も役立つでしょう。

三つの役割を理解したところで、次は実践に入ります。公式チュートリアルでは myCap という目標能力が提示されています。テキストを入力すると、すべて大文字にして出力するものです。一目でわかるほど小さいながらも、Definition、Provider、Consumer の三つのパッケージを完全にカバーしています。手順は三段階です。まず Service Definition(抽象クラス + 型)を書き、次に Service Provider(実装サブクラス)を書き、最後に Consumer(defineTool)を書き、最後に cordis.yml で Provider と Consumer を組み合わせてロードします。このセクションの重点は、最初の二段階のコードと原理を徹底的に分解することにあり、三段階目は後半で inject メカニズムと合わせて説明します。

Definition は契約だけを書く:MyCapService 抽象クラスと super(ctx, 'myCap') の登録タイミング

Service Definition が宣言するのは能力そのものです。サービスが何と呼ばれ、どう呼び出され、リクエストと結果の型が何であるかです。実装ロジックは一切含まず、抽象メソッド一つとインターフェース二つだけがあります。 抽象クラス MyCapService は Service を継承し、super(ctx, 'myCap') を通じてそれを名前付きサービスとして登録します。まずこのコードを貼り出し、その後で各行の設計の理由を一つずつ説明します。

// ファイルパス:packages/my-cap/my-cap/src/index.ts
import { Service, type Context } from '@deepseek-ai/cordis'

// 宣言マージ:TypeScript で ctx.myCap に型ヒントを与える
declare module '@deepseek-ai/cordis' {
  interface Context {
    myCap: MyCapService
  }
}

// 抽象クラス:Definition パッケージは契約のみを宣言し、実装は書かない
export abstract class MyCapService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myCap') // 名前付きサービス ctx.myCap として登録
  }

  /** Execute the capability. */
  abstract execute(request: MyCapRequest): Promise<MyCapResult>
}

// リクエスト型:呼び出し側は input を必ず提供する
export interface MyCapRequest {
  input: string
}

// 結果型:能力は output を返す
export interface MyCapResult {
  output: string
}

まず名前付きサービスの登録ステップを見てみましょう。super(ctx, 'myCap') の中の文字列 'myCap' がサービス名であり、この能力が ctx 上のどのキーにマウントされるかを決定します。登録後は、Harness 全体の中で Context を取得したあらゆるコードが ctx.myCap を通じてこの能力を参照できます。登録タイミングに注意してください。登録はコンストラクタ内で行われ、つまりサービスインスタンスが生成された瞬間です。これはつまり、Provider がロードされさえすれば、ctx.myCap というキーがコンテキスト上に現れるということであり、他のモジュールがそれを inject できるようになります。中央のレジストリに手動で登録する必要はありません。Cordis の Service 基底クラスがこの処理を代わりにやってくれます。

次に抽象クラスそのものです。Definition パッケージ内の抽象クラスは契約だけを宣言し、実装コードは一切書きません——抽象メソッド execute を 1 つだけ提供し、シグネチャは execute(request: MyCapRequest): Promise<MyCapResult> です。ここには 2 つの設計上の要点があります。第一に、メソッドは抽象であり、各 Provider が必ず独自の実装を提供することを強制し、コンパイラがそれをチェックしてくれます。サブクラスが execute を実装しなければコンパイルは通りません。第二に、シグネチャで使われる型はすべて定義パッケージ自身の MyCapRequest と MyCapResult に由来し、特定の実装が持ち込む型ではありません。契約は形だけを記述し、実装の詳細を漏らさない。これが Definition パッケージが長期的に安定できる前提です。

多くの人が最初に Definition を書くときに犯す間違いがあります。抽象クラスに「共通ロジック」を少しだけ入れたくなってしまうのです。たとえばパラメータ検証やログ出力です。短期的には楽に見えますが、seam の純粋さを壊します。検証ルールを一度 Definition に書いてしまうと、すべての Provider を縛ってしまいます。しかし異なる Provider は同じリクエストに対して異なる事前チェックを持つことがあります(ローカル実行器はコマンドが存在するかに関心があり、Sandbox 実行器は権限ポリシーにより関心があります)。検証は Provider に残し、形は Definition に残してこそ、境界はきれいになります。 もし Provider をまたいで再利用する純粋関数が本当にあるなら、それを Definition 抽象クラスに詰め込むのではなく、独立したユーティリティパッケージに置いてください。

次に、リクエスト型と結果型を Definition パッケージに置くという点を見てみましょう。なぜ MyCapRequest と MyCapResult は抽象クラスと同じパッケージに置くべきで、それぞれ別々に散らばってはいけないのでしょうか。それはそれらが契約の一部だからです。リクエストと結果の形が契約である限り、どの Provider もどの Consumer も同じ型定義を参照します。もし Consumer が自分で入力型を定義し、Provider が自分で戻り値型を定義するなら、三者間には暗黙の並行型が生まれ、契約が進化したときに三か所すべてを同期して変更しなければならず、遅かれ早かれドリフトします。型を Definition パッケージに固定することが、三つの役割が同じ真実を参照することを保証する最も省力な方法です。

最後に、Definition パッケージが「やらないこと」を強調します。それは環境変数を読まず、ファイルシステムに触れず、ネットワークリクエストを発行せず、実行環境に関連する依存を一切導入しません。その依存リストは非常に短く、基本的には Cordis の Service と Context 型だけであるべきです。自分の Definition パッケージが合格かどうかを確認する非常に実用的な基準があります——それを単独でコンパイルし、もし実行環境特有のランタイム依存を引き込むなら、実装ロジックが染み込んでいる証拠です。 Definition パッケージを軽量に保つことが、seam を繰り返し再利用できる前提です。

declare module 宣言マージ:ctx.myCap を TypeScript で型ヒントとして取得できるようにする

上のコードには少し「魔法」に見えるものがあります。それが declare module '@deepseek-ai/cordis' の部分です。多くの人はそのままコピー&ペーストしますが、それが一体何の問題を解決するのかは知りません。この節ではそれを分解して明確に説明します。なぜなら、これを理解しないと、自前の能力を書くときにずっと損をすることになるからです。

問題の出発点はこうです。Cordis の Context インターフェースはもともと、フレームワーク組み込みのサービス、たとえば ctx.shell や ctx.logger などだけを宣言していました。私たちが super(ctx, 'myCap') を通じて実行時に myCap を ctx に取り付けるとき、TypeScript コンパイラは ctx に myCap プロパティが増えることを知りません。 そのため、別の場所で ctx.myCap.execute(...) と書くと、コンパイラは「プロパティ myCap は型 Context に存在しません」と報告します。実行時には明らかにあるのに、型システムには見えない——これが断層の源です。

解決手段は 宣言マージ(declaration merging) です。TypeScript では同じモジュールを複数回宣言でき、宣言は上書きされるのではなくマージされます。私たちは declare module '@deepseek-ai/cordis' によって Cordis の型モジュールを再び開き、そこに interface Context を追加して、myCap: MyCapService というプロパティを加えます。マージ後は、Cordis Context を参照するすべての箇所で新しいプロパティが見えるようになります。したがって:

  • 補完が有効:ctx. と入力すると、IDE は myCap を候補に挙げ、その型が MyCapService であることを表示します。
  • 型チェックが有効:ctx.myCap.execute(request) は、request が MyCapRequest を満たすか、戻り値の型が MyCapResult かどうかを検証します。
  • リネームが安全:サービス名を myCap から別の名前に変更すると、型システムがすべての参照箇所を連動して指摘します。

ここには覚えておくべき三つのエンジニアリング上のポイントがあります。第一に、宣言マージはグローバルに作用します。この .d.ts または .ts ファイルがコンパイルに含まれている限り、その型補完はプロジェクト全体に作用します。ですから declare module を誰も決して import しない隅のファイルに書いてはいけません。通常は Definition パッケージのメインエントリ index.ts に置き、そうすればその能力を参照するあらゆる箇所がついでに型を取得できます。第二に、インターフェース内で宣言するプロパティの型は、Definition パッケージ自身がエクスポートする抽象クラス型(MyCapService)であるべきで、ある具体的な Provider のサブクラス型ではありません。 具体的なサブクラスとして書くと、Provider を切り替えた途端に型が合わなくなり、宣言マージがかえって実装差し替え時の足かせになります。第三に、実行時登録と型宣言は必ずペアで存在しなければなりません。 super(ctx, 'myCap') だけ書いて宣言マージを書かなければコンパイルエラーになります。宣言マージだけ書いて実行時登録を書かなければ、実行時に ctx.myCap は undefined です。どちらも欠かせず、しかも同じ名前を使わなければなりません。

もう一つ見落としやすい落とし穴があります:サービス名の文字列リテラルと宣言マージ内のプロパティ名は、大文字小文字を含めて厳密に一致しなければなりません。 super で 'myCap' と書き、interface Context で mycap と書いても、TypeScript は衝突を報告しません(これは二つの異なるプロパティ名だからです)。しかし実行時に ctx.mycap でアクセスすると undefined になり、ctx.myCap はアクセスできるものの型保護が一切ありません。この種の問題は厄介です。コンパイルエラーを出さず、実行時にだけ壊れるからです。推奨される方法は、まずサービス名を決め、宣言マージと実行時登録の両方を同じ文字列に合わせて書くことです。必要ならサービス名を定数として抽出して再利用します。

宣言マージを前節の抽象クラスと合わせて見ると、Definition パッケージの完全な責務は実は三つだけだとわかります:抽象クラスで呼び出し入口を定義し、インターフェースでリクエスト/結果の形を定義し、宣言マージでサービス名を Context 型にぶら下げる。 三つとも「契約」を中心に展開しており、「どうやるか」に触れるものは一つもありません。これが、私たちが前文で Definition パッケージは軽くあるべきだと繰り返し強調した理由でもあります——担うものが少ないほど、再利用される範囲は広がります。

MyCapRequest.input と MyCapResult.output:リクエスト/結果型をなぜ実装から分離すべきか

この最小能力の二つの型を見てみましょう。MyCapRequest には input: string が一つだけあり、呼び出し側が入力テキストを必ず提供することを表します。MyCapResult には output: string が一つだけあり、能力が処理後のテキストを返すことを表します。これだけの単純さですが、「単純」こそが、私たちがこれを使って分離を実演する理由です——最小の例では、インターフェース型が一体何を記述すべきかが最もはっきりと見えるからです。

まず一つ質問です。この二つのインターフェースが記述しているのは「形状」でしょうか、それとも「振る舞い」でしょうか。答えは明確で、形状だけを記述しているのです。MyCapRequest は「呼び出し側が文字列を渡す」とだけ述べており、その文字列がどこから来るのか、ユーザー入力なのか、長さ制限があるのかには関心がありません。MyCapResult は「文字列を返す」とだけ述べており、大文字小文字の変換が具体的にどう計算されるのか、どの文字集合規則を使うのかには関心がありません。インターフェース型は純粋なデータ契約であり、実行環境に関する情報を一切含みません。

この分離は三つの直接的な利点をもたらします。

  1. Provider は型を変えずに置き換えられる。 ローカル実装の Provider も、リモートサービスを呼び出す Provider も、さらには常に固定文字列を返すテスト用の偽 Provider も書けます。それらの execute シグネチャは完全に一致します。なぜなら、リクエスト型と結果型はどちらも Definition に固定されているからです。Provider を差し替えるとき、Consumer のコードは一行も変更する必要がありません。
  2. Consumer は独立して進化できる。 今日の Consumer はモデル用の defineTool かもしれませんが、明日にはコマンドライン用の Consumer や、ある自動化フロー用の Consumer を追加したくなるかもしれません。それらはすべて MyCapRequest を構築し、MyCapResult を消費し、互いに干渉しません。
  3. テストは最小限の依存で完了できる。 型は形状だけを記述しているため、{ input: 'hello' } のようなオブジェクトを直接構築して execute に渡せます。実行環境を起動する必要はありません。これによりユニットテストは高速かつ安定します。

さらに深く考えると、なぜリクエストと結果を二つの型に分ける必要があり、一つを再利用しないのでしょうか。それは、それらがライフサイクルの両端を記述しているからです。リクエストは「入力側」であり、しばしば任意フィールドを持ち、デフォルト値を補う必要があります。結果は「出力側」であり、しばしば実行後の確定した産物です。前述のとおり、Bash seam において ShellExecRequest と ShellExecSpec が分けられているのは、まさにこの考え方の延長です。モデル/呼び出し側に向けた緩やかな入力と、実行者に向けた厳密な仕様は、性質の異なる二つのものであり、一つの型に無理に押し込むべきではありません。 myCap は今はとても単純ですが、将来 MyCapRequest に任意の locale フィールド(大文字小文字変換の言語環境を制御する)を追加したくなった場合、リクエスト型は任意フィールドを受け入れられ、結果型は依然として output だけであり、両者は独立して進化します。

ここで myCap の完全なフィールド説明表を示します。自前のケイパビリティを作るときの参照にしてください。

フィールド必須かどうか意味分離ポイント
MyCapRequestinput必須呼び出し側が提供する入力テキスト入力元や検証規則に束縛されない
MyCapResultoutput必須(戻り値の一部として)ケイパビリティ処理後の出力テキスト処理アルゴリズムや実行環境に束縛されない

特に注意すべきエンジニアリング上の習慣が一つあります。型を「より汎用的に見せる」ためだけに、前もってフィールドを追加してはいけません。 多くの人は MyCapRequest を設計するとき、metadata、options、context のような大きなポケットフィールドを思わず追加し、「後で役に立つかもしれない」と考えます。その結果、各 Provider はまったく必要としない大量のフィールドを処理しなければならず、Consumer もそれを埋めるために頭を使わなければなりません。インターフェース型は現在の真実の契約を反映すべきです。本当に二つ目の Provider が追加情報を必要とするときになって初めて、明示的にそのフィールドをリクエスト型に追加し、すべての Provider を一緒にアップグレードします。このような「明示的な破壊的変更」は、曖昧な大きなポケットフィールドよりもはるかに健全です。

もう一点補足しておくと、ここでは TypeScript の interface を使っています。これは宣言マージと拡張を自然にサポートしており、公開契約として適しています。より厳密なカプセル化のセマンティクスが必要なら、type に readonly フィールドを組み合わせることもできますが、それはスタイルの選択であり、本記事で扱う核心的な疎結合の思想には影響しません。疎結合の鍵は interface を使うか type を使うかではなく、これら二つの型が形状だけを記述しているか、Definition パッケージに置かれているか、そしてすべての役割から共有参照されているかどうかにあります。

Provider が抽象クラスを継承する:サブクラスが execute の具体的な振る舞いをどう埋めるか

Definition を書き終え、契約が整ったら、次は Provider です。Provider の実装方法は非常に直接的です。Definition 抽象クラスを継承し、唯一の実装入口を提供する——つまり抽象メソッド execute に具体的な振る舞いを埋め込むのです。 myCap 用にローカル Provider を書きましょう。やることは入力文字列を大文字に変換するだけです。

// ファイルパス:packages/my-cap/my-cap-local/src/index.ts
import { Context } from '@deepseek-ai/cordis'
import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/my-cap'

export class MyCapLocal extends MyCapService {
  constructor(ctx: Context) {
    super(ctx) // Definition の名前付きサービス登録ロジックを再利用する
  }

  async execute(request: MyCapRequest): Promise<MyCapResult> {
    // 唯一の実装入口:入力を大文字に変換する
    const output = request.input.toUpperCase()
    return { output }
  }
}

このコードの細部を一つずつ見ていきましょう。第一に、コンストラクタの super(ctx) は空ではありません——最終的に Definition 抽象クラスのコンストラクタ、つまり super(ctx, 'myCap') に到達します。名前付きサービスの登録ロジックは Definition に一度だけ書かれ、すべての Provider が継承によって無償でそれを得ます。 これが登録を抽象クラスのコンストラクタに置く価値です。Provider は文字列 'myCap' を繰り返し書く必要がなくなり、サービス名を複数の Provider 間で手動で同期させ、一文字でも間違えると誤ったキーに登録してしまう問題を避けられます。上のコードで Provider の constructor は実は省略可能です(継承チェーンが自動的に ctx を転送します)。私が明示的に書いたのは呼び出しチェーンをはっきり見せるためだけで、実際のプロジェクトでは省いて構いません。

第二に、execute のシグネチャは抽象メソッドと完全に一致していなければなりません。引数は MyCapRequest で、Promise<MyCapResult> を返します。Promise を返すのは意図的です。なぜなら、実際の能力の実装は通常 I/O(ファイル読み取り、ネットワーク越しの通信、プロセスの呼び出し)を伴うため、インターフェースを非同期に設計しておけば、Provider は同期・非同期の実装を自由に選べ、将来非同期のために契約を変更する必要がありません。myCap の例では toUpperCase 自体は同期ですが、それでも async 関数でラップした Promise を返しているのは、契約に合わせるためです。

第三に、Provider では実行環境に関わるあらゆることを行えます。環境変数の読み取り、ファイルシステムへのアクセス、ネイティブモジュールの読み込みなどです。Definition では禁止されているこれらのことが Provider では許可されるのは、まさにそれが Provider の存在意義だからです。ただし注意すべきは、Provider は「モデルがどうやって自分を呼び出すか」を気にすべきではないという点です。Provider は、すでに構築済みの MyCapRequest を処理し、MyCapResult を返すだけです。「モデル向けの入力の解析」を Consumer に任せることが、Provider の再利用性を保つ鍵となります。同じ Provider は、ツール型の Consumer からも、フック型の Consumer からも、さらにはテストコードから直接呼び出すこともできます。

第四に、Provider を差し替えるコストです。入力を受け取ってリモートサービスに送り、大文字変換を行わせる MyCapRemote を書くと仮定しましょう。コード構造はまったく同じで、execute の中身がネットワークリクエストに置き換わるだけです。これも同様に MyCapService を継承し、同様に ctx.myCap として登録されるため、Consumer は自分の背後にある Provider が差し替わったことにまったく気づけません。 これこそが seam 設計の見返りです。変化は一つのパッケージ内に隔離されます。

よくあるエンジニアリングの落とし穴を一つ指摘しておきます。Provider が execute の中でリクエストオブジェクトをこっそり書き換えないようにしましょう。 たとえば request.input をその場で大文字に書き換えてから返す、といったことです。myCap という単純なシナリオでは問題に見えませんが、複数の Consumer が同じリクエストオブジェクトを共有していたり、Provider がチェーン状に呼び出されたりする場合、その場での書き換えは追跡困難な副作用を引き起こします。正しいやり方は読み取りのみを行い、書き換えず、まったく新しい結果オブジェクトを返すことです。上記のコードにある return { output } は、この原則の表れです。この習慣は、より複雑な seam(たとえば Bash 実行)では特に重要です。というのも、リクエストオブジェクトはしばしば workdir や timeoutMs など実行セマンティクスに影響を与えるフィールドを伴っており、いったん Provider に改ざんされると、調査コストが極めて高くなるからです。

Consumer を defineTool でラップする:能力をモデルが呼び出せるツール schema として公開する

Definition と Provider がそろいましたが、モデルにはまだこの能力が見えていません。なぜなら、Provider が提供するのはプログラム的なインターフェース ctx.myCap.execute(...) であるのに対し、モデルが呼び出せるのはツール(tool)——名前、説明、パラメータ schema を備えた宣言——だからです。前者を後者に翻訳するのが Consumer の役割です。Consumer はモデルに面し、能力をツール schema にラップしてモデルが呼び出せるようにしますが、自分自身は実際の作業を行いません

defineTool でラップすると、おおよそ次のような形になります。これは役割のデモンストレーションであり、重要なのは構造と責務の分担であって、特定のフレームワークの固定 API の詳細ではないことに注意してください。

// ファイルパス:packages/my-cap/my-cap-tool/src/index.ts
import { defineTool } from '@deepseek-ai/dsh-tool'
import { MyCapService, type MyCapRequest } from '@deepseek-ai/my-cap'

export const myCapTool = defineTool({
  name: 'my_cap',
  description: '入力テキストを大文字に変換する',
  parameters: {
    type: 'object',
    properties: {
      input: {
        type: 'string',
        description: '変換するテキスト',
      },
    },
    required: ['input'],
  },
  async execute(args, ctx) {
    // Consumer は引数をリクエストに翻訳し、ctx.myCap を呼び出すことだけを担う
    const request: MyCapRequest = { input: args.input }
    const result = await ctx.myCap.execute(request)
    return result.output
  },
})

このコードでは、いくつかの責務の分担が非常に明確です。name と description はモデルに見せる意味情報であり、モデルはそれに基づいてこのツールをいつ呼び出すべきかを判断します。parameters は JSON Schema に従って記述されたパラメータの形であり、それは MyCapRequest の形(必須の文字列 input が1つ)と一致していますが、この両者は別のレイヤーのものです。前者はモデル向けの関数呼び出しであり、後者はプログラム内部のパラメータ受け渡し向けです。execute で行っているのは翻訳で、モデルから与えられた args を MyCapRequest に変換し、次に ctx.myCap.execute を呼び出し、最後に result.output をモデルに返します。この一連の流れの中で、Consumer は「大文字小文字変換」のロジックを一切実装しておらず、ただの運び屋です。

こうする価値は、同じ能力に対して複数の Consumer を持てる一方で、それらが同じ Provider を共有できることにあります。前述のとおり、Bash seam の消費側には tool-bash のほかにも tool-pwsh、hooks-claude-code、hooks-codex があります。これらの Consumer がモデルやフックに対して露出する形はそれぞれ異なりますが、その下で呼び出しているのはすべて ctx.shell です。同様に、myCapTool とは別にコマンドライン用の Consumer を書くことも、CI でバッチ処理する Consumer を書くこともでき、それらはすべて ctx.myCap を呼び出し、互いに干渉しません。もし Consumer を独立させず、「ツール schema」をそのまま Provider にハードコードしてしまうと、消費形態を1つ追加するたびに Provider を複製しなければならず、Provider と Consumer の進化も一緒くたに縛られてしまいます。

ここで、あなたの Consumer が越境していないかを判断するための、非常に実用的なエンジニアリングチェックリストを挙げます。

  • Consumer に実行環境関連のコード(ファイルの読み書き、プロセスの起動)が現れていませんか?もしあるなら、Provider の仕事を Consumer に持ち込んでしまっています。
  • Consumer に、本来 Provider にあるべきパラメータ検証ロジックが重複実装されていませんか?もしあるなら、検証を Provider に下ろし、Consumer は形式の翻訳だけを行ってください。
  • Consumer が特定の Provider のクラスを直接 import していませんか?もししているなら、それは Definition を迂回しており、seam が壊れています。依存すべきは ctx.myCap というインターフェースだけです。
  • ツールのパラメータ schema はリクエスト型と形が一致していますか?もし両者がずれ始めると、モデルは Provider が処理できないパラメータを埋めてしまいます。

また、description の品質は、モデルがこのツールを呼び出すかどうかに直接影響します。 Bash の例では、ツールの説明によって、これが shell コマンドを実行するツールであることをモデルに分からせる必要があります。myCap の例では、説明によって「テキストを入力すると大文字版が得られる」ことをモデルに理解させる必要があります。ツール名、パラメータ名、説明の三者がそろって、モデルが見るすべての情報を構成しており、どれ一つ欠かせません。もし description が曖昧に書かれていると、モデルは永遠にそれを呼び出さないかもしれませんし、Provider の中でどれほど美しく書いても使われません。

inject: ['shell'] と ctx.shell.run(...):Consumer はインターフェースだけを見て、実行者は見ない

最後に、公式 Bash の例で最も典型的な呼び出しチェーンを分解し、「Consumer はインターフェースだけを見て、実行者は見ない」という言葉をコードに落とし込みましょう。tool-bash という Consumer はどの実行者も import しておらず、行っているのは2つのことです。inject で依存 ctx.shell を宣言し、次に execute で ctx.shell.run(...) を呼び出す。

まず依存の宣言を見てみよう。inject: ['shell'] が表しているのは「私のこの能力は shell という名前のサービスに依存している」ということだ。注目すべきは、注入されるのがサービス名 shell であり、あるパッケージやあるクラスではないという点だ。これは、Consumer が「誰が shell サービスを提供するのか」について何も知らず、まったく関心も持たないことを意味する。実行時の組み立ては cordis.yml が行い、設定ファイルでどの Provider をロードするかによって、tool-bash は自動的にそれを使う。以下は公式が示している設定の断片で、Provider の切り替えがたった1行の変更で済むことが直感的に分かる:

# ファイルパス:cordis.yml

# ローカル実行
- name: '@deepseek-ai/dsh-bash-local'

# 提供元を変えたいときは、上の行を置き換えるだけでよい。
# 下の行に置き換えれば、サンドボックス実行器に切り替わる:
# - name: '@deepseek-ai/dsh-bash-sandbox'

このわずか数行が、seam 設計全体の最も強力な証拠だ。dsh-bash-local を dsh-bash-sandbox に置き換えれば、実行環境はローカルマシンからサンドボックスに切り替わるが、tool-bash のコードや Definition の定義は一文字も変える必要がない。 なぜこうなるのか? tool-bash は最初から最後まで ctx.shell というインターフェースだけに依存しており、呼び出しているのは ctx.shell.run(...) であって、new BashLocal().run(...) ではないからだ。実行時に誰が ctx.shell を登録したかによって、それを使う。

次に呼び出しパスにおける resolve の段階を見てみよう。前述したように、Bash seam では、モデル向けの ShellExecRequest(workdir、timeoutMs は任意)と、実行器が実際に使う ShellExecSpec(フィールド必須)は分離されており、ツール層は両者の間で ctx.shell.resolve(request) を呼び出して解決を完了する。したがって、完全なパスはおおよそ次のとおりだ:モデルが緩いパラメータを提示する → Consumer が ShellExecRequest に組み立てる → ctx.shell.resolve(request) を呼び出して厳密な仕様 ShellExecSpec を得る → ctx.shell.run(...) を呼び出して、現在登録されている Provider に実行を委ねる。このチェーンにおいて、resolve は Definition 側が提供し(契約の一部だから)、run の具体的な振る舞いは Provider が提供し、Consumer は両者の間で運搬する役割だけを担う。

Bash の経験を myCap に写像すると、ロジックは完全に一致する。myCapTool は inject を通じて myCap サービスに依存することを宣言し、execute の中で ctx.myCap.execute(request) を呼び出すべきであり、直接 MyCapLocal を new すべきではない。こうすれば、ローカルの大文字変換をリモートの大文字変換に変えたいとき、cordis.yml の Provider を1行変えるだけで済み、ツール層は手を付けずに済む。myCap は今のところ resolve のような解決ステップを伴わないが、将来 MyCapRequest に任意フィールドが追加され、Provider がデフォルト値を埋めた厳密な仕様を必要とするなら、Bash seam のやり方をそのまま踏襲し、Definition に resolve を追加して、「緩いリクエスト → 厳密な仕様」の変換を明示化すればよい。

最後に、この仕組みを対比表で締めくくり、「インターフェースに依存する」と「実装に依存する」という2つのやり方の違いを並べて示そう:

観点インターフェースのみに依存(inject: ['shell'])具体的な実装に直接依存
Provider の切り替えに Consumer の変更が必要か不要、cordis.yml を1行変えるだけ必須、しばしば import と呼び出しの書き直しが必要
Definition の変更が必要か不要通常これも不要だが、seam はすでに壊れている
複数の Provider を同時に接続できるか可能、どの Provider をロードするかは設定が決める不可、ハードコードで結合されている
Consumer を再利用できるか可能、複数の Consumer が同じインターフェースを共有する困難、強い結合がコピー&ペーストを招く
テストの難易度低い、偽の Provider を注入できる高い、実際の実装を構築しなければならない

ここまでで、三つのピースがそれぞれ揃った。Definition は抽象クラス、リクエスト結果型、宣言マージで契約を定義する。Provider は抽象クラスを継承し、execute の具体的な振る舞いを埋める。Consumer は defineTool でインターフェースをモデルが呼び出せるツール schema に翻訳し、inject を通じてインターフェースだけを認識し、実行者は認識しない。myCap は小さいが、seam の三役の流れを完全に一通り歩んでおり、Bash のような重量級の能力と同じ構造に従っている。この構造を理解すれば、Harness に新しい器官を生やす基本的な手法を身につけたことになる。次に、私たちは視点を「単一の能力」から「能力体系」へと引き上げ、複数の seam が同時に存在するときに、アセンブリ、依存解決、ライフサイクルがどのような新しい問題をもたらすのか、また自前の能力が実際のエンジニアリングでまだどのような境界と落とし穴に対処する必要があるのかを見ていく。

前の段落では、完全な能力 myCap の Definition、Provider、Consumer の三つのパッケージをゼロから書き上げ、また cordis.yml の一行を変えるだけで別の実装に切り替えられることも確認した。この段落では、視点を DeepSeek Harness 自身の中核 seam に戻し、公式リポジトリで最も証拠が揃っている ctx.shell(Bash 実行能力)を解剖対象として、設定の切り替え、パッケージの帰属、リクエストと仕様の境界、フック型 Consumer、物理的なパッケージ分割のトレードオフ、調査チェックリスト、そして 2026 年 9 月時点の最新の導入順序を一気に説明し尽くす。

cordis.yml の一行を変えるだけで実行者を切り替え:ローカル / サンドボックス / PowerShell の三つの Provider

Harness では、「実行者を切り替える」とは決してツールのコードを変更することではなく、アセンブリファイルを変更することである。cordis.yml は Cordis のアセンブリマニフェストであり、現在のプロセスでどのプラグインを読み込み、どの順序でマウントし、どのサービスグラフとして組み立てるかを決定する。Bash という seam の設計目標の一つは、提供側の選択を完全に設定層へと沈めることである。同じ ctx.shell サービス定義、同じ dsh-tool-bash ツールに、異なる Provider パッケージを組み合わせることで、振る舞いは「ローカルマシンで直接実行」から「サンドボックス内で実行」へ、さらに「PowerShell を呼び出す」へと変わる。

素材で示されている最小の切り替え断片は断定的なものである。ローカル実行では @deepseek-ai/dsh-bash-local を読み込み、提供側を切り替えたいときはこの一行を置き換えるだけで、@deepseek-ai/dsh-bash-sandbox に変えればサンドボックス実行者に切り替わる。これをそのまま貼り付け可能な完全な設定へと拡張し、三つの Provider を並べて示し、そのうち二行をコメントアウトして、一行だけを有効にしておく:

# ファイルパス:cordis.yml
# ---------------------------------------------------------------
# Bash seam の Provider 選択:同時に有効化するのは一つだけ
# 残りの二行はコメントとして残し、切り替えが必要なときは「一行を変える」だけでよい
# ---------------------------------------------------------------

plugins:
  # Definition 側:ctx.shell サービス定義を登録する(dsh-shell パッケージ)
  - name: '@deepseek-ai/dsh-shell'

  # ---- Provider:三つから一つを選択 ----

  # 方案 A:ローカル実行(ホストマシンの現在の作業ディレクトリでコマンドを直接 spawn する)
  - name: '@deepseek-ai/dsh-bash-local'
  # 方案 B:サンドボックス実行(隔離環境で同じコマンドを実行する)
  # - name: '@deepseek-ai/dsh-bash-sandbox'
  # 方案 C:PowerShell 実行(Windows / pwsh 環境向けの実行者)
  # - name: '@deepseek-ai/dsh-pwsh-local'

  # ---- Consumer:モデル向けのツール層 ----
  # ツール層は 'shell' を inject するだけで、上でどの Provider が選ばれているかは気にしない
  - name: '@deepseek-ai/dsh-tool-bash'

この設定には、必ず明確に把握しておくべき三つのエンジニアリング上の詳細がある。

第一の詳細は、Provider のロード順序を Definition と揃える必要があることだ。Cordis のサービス組み立てには依存順序がある。Definition が抽象サービスを提供し、Provider がそれを継承して具体的な実装を登録し、Consumer が inject を通じてサービスへの依存を宣言する。もし Provider が Definition より前に配置されると、サービスがまだ登録されておらず、プラグインは利用可能な ctx.shell を取得できなくなる。本番設定における堅実なやり方は、Definition を最初に、Consumer を最後に置き、その間に Provider を挟むことだ。

第二の詳細は、相互排他的なロードだ。三つの Provider は理論上同時にプロセスへ組み込めるが、同じサービスキー ctx.shell に登録すると上書きや競合が発生する。bash-localbash-sandbox はどちらも ctx.shell の実装になろうとし、最終的にどちらが有効になるかはロード順序に依存する。これは典型的な「暗黙の挙動」である。したがって推奨されるやり方は常に、同時に一つの Provider だけを有効化することであり、残りはコメントアウトしたままにして、「どの実行器を選ぶか」を設定ファイル上で一目で読めるようにする。これはコード内で環境変数を使って分岐判定するよりもはるかにクリーンだ。コードの分岐は実行時の暗黙の選択であり、設定ファイルは静的に監査可能な明示的な選択だからである。

第三の詳細は、PowerShell Provider の存在意義だ。これは、seam の分裂軸が「コマンドライン vs 何か他のもの」ではなく、「実行環境」であることを示している。bash-local は POSIX 系環境を、bash-sandbox は隔離環境を、pwsh-local は PowerShell 環境を対象とする。三者は同一の ShellExecRequestShellRunResult の型契約を共有しているため、dsh-tool-bash は背後に誰が立っているかを全く知る必要がない。これこそが、Provider を差し替える際に「Definition と Consumer を一行も変更する必要がない」ことの実証である。切り替え動作は意味的には交換可能な器官を一つ入れ替えることと等価であり、神経系(ツール層)と骨格(型定義)は微動だにしない。

さらに、よりエンジニアリング指向の変種を作ることもできる。Provider の選択を「環境ごとのファイル分割」へと昇格させるのだ。例えば、cordis.local.ymlcordis.sandbox.ymlcordis.ci.yml をそれぞれ一つずつ維持し、起動時に --config で指定する。こうすれば、CI 環境は自然にサンドボックスを実行し、開発者のローカルマシンは自然にローカル実行器を実行し、すべての業務コードとツール定義は完全に一致したまま保たれる。

ctx.shell 三角色帰属表:Definition、Provider、Consumer はそれぞれどのパッケージにあるか

seam を理解する最速の方法は、公式の『能力 Seam と核心サービス』参考ドキュメントの表構造に従って展開することだ。ctx.shell の帰属は、「ctx キー / 役割 / 所属パッケージ(Definition) / 実装(Provider) / 直接の消費側(Consumer)」の五列で明確に説明できる。以下の表は、公式参考ドキュメントの構造に従って述べ直したものである:

ctx キー 役割 所属パッケージ(Definition) 実装(Provider) 直接の消費側(Consumer)
ctx.shell seam(一つの完全な能力) shell(すなわち dsh-shellctx.shell を登録する) bash-local / bash-sandbox / pwsh-local tool-bash / tool-pwsh / hooks-claude-code / hooks-codex

この表の価値は、混同しやすい三つの事柄を一度に明らかにする点にある。

第一に、Definition は単一のパッケージであり、Provider と Consumer はともに複数である。Definition 側では dsh-shell という一つのパッケージだけが契約を維持しており、リクエスト型 ShellExecRequest と結果型 ShellRunResult を定義している。一方、Provider 側には少なくとも三つの実行者があり、Consumer 側には少なくとも四つの消費側がある。seam 全体は「一対多対多」の星形構造であり、その中心は Definition である。

第二に、Consumer はモデル向けのツールだけではない。多くの人はまず「Consumer とはモデルが呼び出せるツールのことだ」と考えるため、tool-bashtool-pwsh を見てもごく自然に感じる。しかし表には hooks-claude-codehooks-codex という二つのフックブリッジプラグインも挙げられている。これらも同じく Consumer だが、向き合っているのはモデルではなく、外部ツールチェーンのフックイベントである。この点は後述で個別に掘り下げる。

第三に、Provider と Consumer は互いに依存しない。表の Provider 列と Consumer 列の間には、いかなる相互参照も存在しない。Provider は Definition の抽象クラスを継承して振る舞いを埋め、Consumer は inject: ['shell'] を通じて同じ Definition に依存する。両者の間には import 関係が一切ない。Provider と Consumer を結ぶ線をすべて削除し、それぞれから Definition への線だけを残しても、図全体は依然として成立する——これが seam の形である。

特に強調しておきたいのは、完全な能力がその seam を構成しており、単一の役割が seam なのではないという点です。単独の dsh-shell は型宣言にすぎず、単独の dsh-bash-local は実装の一部にすぎず、単独の dsh-tool-bash はツールの殻にすぎません。この三つが揃って初めて「交換可能な一つの能力」と言えます。この定義は問題の切り分けで特に役立ちます。ある能力が交換できないと気づいたとき、それは多くの場合 Provider の書き方が悪いのではなく、独立した Definition が欠けているからです。型契約が Provider によって実装パッケージ内でついでに定義されてしまうと、Consumer は避けられず実装パッケージを依存としてインポートし、seam は断たれてしまいます。

ShellExecRequest と ShellExecSpec:resolve(request) はパッケージ境界において暗黙より明示が優る

ctx.shell で最も興味深い設計は、「モデルが提示するリクエスト」と「実行器が必要とするスペック」を二つの型に分けていることです。資料は明確に述べています。モデル向けのリクエストは ShellExecRequest であり、workdir と任意の timeoutMs を含みます。実行器が実際に使用する、すべてのフィールドが完全に解決され必須となったスペックは ShellExecSpec です。両者の間は、ツール層が ctx.shell.resolve(request) を呼び出すことで変換されます。著者の評は「パッケージ境界では暗黙より明示が優る」です。

まず緩い側を見てみましょう。ShellExecRequest はモデルが生成する入力であり、本質的に「不完全」です。モデルはコマンドを与えても作業ディレクトリを言わないかもしれませんし、タイムアウトを言うかもしれないし言わないかもしれませんし、相対パスの workdir を与えるかもしれません(何に対して相対なのか?)。このオブジェクトをそのまま実行器に渡すと、実行器はあちこちで自分で補完しなければならなくなります。デフォルトの作業ディレクトリを自分で読み、デフォルトのタイムアウトを自分で埋め、相対パスを自分で解決するのです。フォールバックのロジックが各 Provider に散らばると、bash-localbash-sandbox がそれぞれ独自のデフォルト値を埋めることになり、両者の挙動に微妙なずれが生じ、しかもそのずれは極めてテストしにくいものになります。

次に厳格な側を見てみましょう。ShellExecSpec は実行器が本当に必要とする入力です。すべてのフィールドが必須で、workdir はすでに解決済みの絶対パスであり、タイムアウトはすでに計算済みの具体的なミリ秒値です。実行器はこれを受け取れば、二次的な推論を一切必要とせず、そのまま spawn できます。

その間にある関門が resolve です。これは四種類の責務を担います。

  • デフォルト値の補完request で省略されたフィールドを実行期の取り決め値で補完する(例えば timeoutMs が提供されない場合に統一のデフォルト値を適用する)のであって、各 Provider にそれぞれ決めさせるのではありません。
  • パス解決の実行:モデルが与えた workdir を確定した絶対パスに解決し、「何に対して相対なのか」という曖昧さを消します。
  • パラメータ検証の実行:パッケージ境界を越える前に明らかに不正なリクエストを遮断し、統一されたエラーを投げます。エラーを Provider の奥深くで爆発させるのではありません。
  • 監査のためのアンカーを残す:すべてのリクエストが resolve を経由しなければならないため、この一箇所で集中的にログを記録し、計測を行い、ポリシーチェックを行うことが、漏れのない唯一の監査点となります。

以下では、表を使って両側の違いを整理します:

次元 ShellExecRequest(モデル向け) ShellExecSpec(実行者向け)
生成元 モデル / Consumer ツール層 ctx.shell.resolve(request) によって生成される
フィールドの完全性 緩やかで、一部のフィールドは省略可能 必須で、すべてのフィールドが解決済み
workdir 任意で、相対パスの可能性がある 必須で、解決済みの確定したパス
timeoutMs 任意 必須で、補完済みの具体的な値
フォールバックの担当 担当しない、resolve に委ねる 担当しない、受け取った時点で最終値
検証が必要か いいえ、「ユーザーの意図」に属する はい、境界を越える前に検証を完了する

なぜこれが「パッケージ境界では明示が暗黙に勝る」と言えるのか? それは、もし resolve を省いて、各 Provider が自分で request.workdir ?? process.cwd() を読むようにすると、「デフォルトの作業ディレクトリは何か」という決定が暗黙のうちに各 Provider の実装に分散してしまうからです。今日は bash-local がプロセスのカレントディレクトリを使い、明日は bash-sandbox がサンドボックスのマウントルートを使い、明後日には pwsh-local を引き継いだ新人が Windows 上でドライブ文字のルートを指定する——同じモデルのリクエストに対して、3 つの実行者が 3 つの挙動を示し、呼び出し側はまったく気づけません。

明示的な resolve を加えることは、パッケージ境界に看板を立てるのと同じです:「この門をくぐれば、パラメータの一つ一つが確定値を持つ」。境界を越えた後のすべてのコードは、もう「空だったらどうするか」を問う必要がありません。これが明示であることの利益です。テストにも極めて好都合です:resolve は純関数的な入力と出力の変換であり、実際の実行環境から切り離して単体テストできます。Provider は ShellExecSpec を受け取った後、「与えられた確定入力が正しい結果を生み出せるか」だけをテストします。2 種類のテストはそれぞれ収束します。

自分自身の seam を書くときは、このパターンをそのまま踏襲できます:パッケージをまたぐ必要のあるインターフェースには、すべて「緩やかなリクエスト + 厳格なスペック + 明示的な resolve」の三点セットを定義する。たとえば、myCap が将来複数のテキストプロセッサをサポートするなら、MyCapRequest を緩やかな側に保ち、MyCapSpec を導入して ctx.myCap.resolve(request) を公開し、すべての Processor が同じデフォルト値と検証ルールを共有するようにできます。

hooks-claude-code と hooks-codex:ツールを書かずに ctx.shell を消費できるブリッジプラグイン

公式の表において、ctx.shell の直接の消費者は tool-bashtool-pwsh だけでなく、hooks-claude-codehooks-codex も含まれている。この二つはフックブリッジプラグインであり、その存在自体が「Consumer とは何か」を再定義するものだ。

最も直感的な理解に従えば、Consumer とは「能力を defineTool に包み込んだモデルツール」である。だとすれば、tool-bashctx.shell をモデルが呼び出せる bash ツールスキーマに包み込む役割を担い、モデルが「ディレクトリを一覧して」と言えば、ツール層は execute の中で ctx.shell.run(...) を呼び出す。これが最も標準的な形態だ。

しかしフックブリッジプラグインは別の道を歩む。それらが向き合うのは外部 Agent ツールチェーンのフックイベントであり、Claude Code の hooks と Codex の hooks はいずれもこの類に属する。外部ツールチェーンのあるイベントが発火したとき、ブリッジプラグインは応答を完了するためにコマンドを実行する必要がある(たとえば状態チェックを一度行う、カスタムスクリプトを一度走らせるなど)。能力をモデルに公開する必要はないため、ツールスキーマも、モデルが読めるツール説明も、モデルの引数組み立ての処理も不要であり、ただctx.shell を直接呼び出すだけでよい。

鍵となるのは、これが tool-bash と同様に、ctx.shell というインターフェースだけを認識し、背後がどの実行器であるかを気にしないという点だ。素材原文の表現は「それらは tool-bash と同様に、ctx.shell というインターフェースだけを認識し、背後がどの実行器であるかを気にしない」である。この一文には強い工学的含意がある:

  • Consumer の形態は開かれている。ツールは一つの Consumer であり、フックブリッジも一つの Consumer であり、将来は定期タスク、HTTP エントリポイント、CLI サブコマンドなどの Consumer もあり得る。それらの共通点はただ一つ、Definition に依存し、Provider に依存しないことだ。
  • 新しい Consumer を追加しても Provider と Definition を変更する必要はない。「ある外部イベントの中で shell コマンドを一つ実行したい」とき、bash-local にコードを追加する必要はなく、新しい Consumer パッケージを書いて inject: ['shell'] するだけでよい。
  • 能力の再利用率が著しく高まる。同一の Bash 実行能力が四種類以上の消費側で共有される。もし新しい消費の仕方ごとに実行ロジックを複製していたら、コードは急速に発散してしまう。seam によって Definition に収束させることで初めて、新しい消費の仕方を追加する限界コストが十分に低くなる。

テストの観点から見ても、フック型の Consumer は検証しやすい。モデルとのやり取りを伴わないため、入力はフックイベントの payload であり、出力はコマンド実行の副作用である。統合テストとして書く際には、イベントを直接構築し、ctx.shell が正しい引数で呼び出されたことをアサートできる——ctx.shell を mock すればよく、実際にプロセスを実行する必要はまったくない。

これは設計上のアンチパターンも示している:tool-bash に私有の実行ロジックをインライン化すると、hooks ブリッジプラグインはそれを再利用できず、もう一份書き直すしかない。実行ロジックが二份あるということは、タイムアウト戦略が二套、エラーハンドリングが二套、作業ディレクトリの取り決めが二套あるということであり、最終的な一貫性の問題は「なぜモデル呼び出しは通るのに、フックのトリガーでは失敗するのか」という形で表面化する。実行能力を ctx.shell に収め、消費の仕方を独立した Consumer に分割するのは、まさにこうした事態を避けるためである。

三つの役割を一つのパッケージに置くか、三つのパッケージに分けるか:唯一の判断基準は独立して進化できるかどうか

素材は明確な答えを与えている:三つの役割は同じパッケージに置いてもよいし、異なるパッケージに分けてもよい;判断基準はただ一つ——これらの役割が独立して進化または置換される必要があるかどうか。この言葉は、実行可能な意思決定方法へと展開する価値がある。

まず「同じパッケージに置く」ことの妥当性を見る。内部向けの小規模なツール型能力で、三つの役割がいずれも一つしかなく、置換する予定もなく、消費の仕方を拡張する予定もないなら、一つのパッケージに置くことは完全に正当である。一つのパッケージは、一度のインストール、一度のビルド、一度のバージョンリリースを意味し、保守コストが最も低い。myCap はデモのシナリオでは、完全に三つを一つにまとめてよい。

次に「三つのパッケージに分ける」ことの收益条件を見る。以下のいずれか一つが成り立つ場合にのみ、分割は割に合う:

  1. Provider が複数必要。Bash のようにローカル、サンドボックス、PowerShell の三つの実行環境が併存する場合、Provider は独立してインストール、独立してリリース、独立してアップグレードできなければならない。一つのパッケージに三つの実行者を詰め込むことも可能だが、利用者はすべての環境依存をインストールすることを強いられる——Windows 上で POSIX 依存をインストールするのは純粋な負担である。
  2. Consumer が複数必要tool-bashtool-pwshhooks-claude-codehooks-codex の四つの消費側にはそれぞれ用途がある。もし一つのパッケージにまとめると、フックしか使わないプロジェクトでもツール層の依存を導入せざるを得なくなる。
  3. Definition の安定性が実装よりはるかに高い。インターフェースは一度公開されたらできるだけ変更を少なくすべきだが、Provider は環境の変化に頻繁に適応する必要がある。両者を同じパッケージに入れると、インターフェース変更と実装変更が同じバージョン番号の中で絡み合い、セマンティックバージョニングでは「インターフェースは変わらず、実装がアップグレードされた」ことを表現できなくなる。
シナリオの特徴 推奨される物理構造 理由
三つの役割がそれぞれ一つしかなく、置換の予定もない 単一パッケージ三ファイル(definition / provider / consumer) 保守コストが最も低く、過剰設計を避けられる
Provider を環境ごとに切り替える必要がある Definition を単独のパッケージにし、Provider をそれぞれ別パッケージにする 利用者が環境に応じて必要な実行者だけをインストールできる
Consumer の形態が多様(ツール + フック + その他) Consumer をそれぞれ別パッケージにする 使われない依存の導入を避けられる
インターフェースは長期的に安定させ、実装は頻繁に反復する必要がある Definition と Provider を少なくとも分ける バージョン番号がインターフェースの安定性と実装変更を独立して表現できる

ここで特に注意すべき判断の落とし穴が一つあります。分割は目的ではなく、置き換え可能であることが目的です。一部のチームはすぐに三つの役割を三つのパッケージに分割しますが、その結果 Definition パッケージには抽象クラス一つと interface 二つしか入っておらず、Provider パッケージと Consumer パッケージは互いに import し合っています。パッケージは分けたのに依存は分けていないため、seam は依然として成立せず、むしろ二つのパッケージ分のビルド設定とリリースパイプラインが増えるだけです。判断基準は常に「独立して進化または置き換えできるか」であり、「公式がいくつのパッケージに分割したか」ではありません。

もう一つの実践上の要点は、物理的なパッケージ分割と依存方向の関係です。合法な依存方向は二つだけです:Provider → DefinitionConsumer → Definition。いかなる Provider → Consumer または Consumer → Provider の参照も契約違反であり、単一パッケージでもマルチパッケージでも同じです。単一パッケージではディレクトリ構造と lint ルールで制約し、マルチパッケージでは package.json の依存宣言によって自然に制約します。Provider パッケージに Consumer パッケージへの依存が現れたら、コードレビューで直ちに止めるべきです。

チェックリスト:Provider と Consumer は互いに依存しないため、Provider を交換する際に一行も変更しなくてよいファイルはどれか

Provider を交換した後、本当に正しくできたかをどう確認するか。以下のチェックリストは、置き換え操作後の検証スクリプトとしてそのまま使えます。核心となる命題はただ一つです。Provider と Consumer は互いに依存しないため、Provider を交換する際に Definition と Consumer はゼロ変更であるべきです

推奨する操作順序は次のとおりです:

  1. 置き換え前にベースラインを凍結するdsh-shell(Definition)と dsh-tool-bash(Consumer)の現在のファイルハッシュまたはコミット番号を記録し、「一行も変更していない」証拠とします。
  2. cordis.yml だけを変更する@deepseek-ai/dsh-bash-local@deepseek-ai/dsh-bash-sandbox(または dsh-pwsh-local)に置き換え、その他の行は触りません。
  3. 依存宣言を確認する。Provider パッケージの依存に Consumer パッケージが現れず、Consumer パッケージの依存に Provider パッケージが現れないことを確認します。もし現れたら、seam がすでに漏れていることを意味するため、先に依存方向を修正してから続行します。
  4. 同じ回帰テストケース群を実行する。同じバッチのモデルリクエスト(workdir の省略、timeoutMs の省略、相対パスの指定などの境界サンプルを含む)が、新しい Provider の下で意味的に一貫した結果とエラーを返すべきです。
  5. resolve の挙動が変わっていないことを確認する。デフォルト値、パス解決ルール、検証エラーメッセージは Definition 側の resolve に由来し、Provider を交換した後もこれらは完全に一致すべきです。差異が現れたら、デフォルト値のロジックが誤って Provider に置かれていることを意味します。
  6. ファイルハッシュを比較する。Definition と Consumer のファイルハッシュがベースラインと一致して初めて、置き換えは合格となります。

以下の表は、クイックリファレンスとして「どのファイルを変更すべきか」「どのファイルは一行たりとも変更すべきでないか」を区別するのに役立ちます:

ファイル / パッケージ 役割 Provider 切り替え時の変更の有無 理由
cordis.yml アセンブリマニフェスト 変更する(通常は一行のみ) 提供元の選択が設定レイヤーに委譲される
dsh-shell(Definition) インターフェースと型 変更ゼロ ShellExecRequest / ShellRunResult は Provider に依存しない
dsh-tool-bash(Consumer) モデルツール 変更ゼロ inject: ['shell'] のみで、具体的な Provider を決して import しない
hooks-claude-code / hooks-codex フック型 Consumer 変更ゼロ 同様に ctx.shell インターフェースのみを認識する
Provider パッケージ自体 実装 旧 Provider を修正する必要はなく、アセンブリ項目を追加・削除するだけ 新しい実行器の追加とは、新しいパッケージ + 新しい設定一行の追加である

トラブルシューティングで最もよく見られる三種類の「偽の合格」も指摘しておくべきです。

第一はデフォルト値を Provider に書き込んでしまうケースです。表面上は Provider の切り替えに成功しますが、新しい Provider の timeoutMs のデフォルト値が古いものと 500 ミリ秒違うだけで、挙動がずれてしまいます。発見方法は、resolve が生成する ShellExecSpec が Provider に依存せず一貫しているかを比較することです。

第二はConsumer 内で実行器名を文字列としてハードコードしてしまうケースです。例えば、ツールレイヤーに「現在が sandbox なら別の分岐を通る」と書き込んでしまうようなものです。これは Provider への暗黙的な依存であり、「設定一行の切り替え」という約束を無効にしてしまいます。正しい方法は、Consumer が ctx.shell.run(...) のみを呼び出し、すべての環境差異を Provider に委ねることです。

第三はProvider が Consumer のヘルパー関数を密かに import してしまうケースです。これは単一パッケージ三ファイルの構造で特に起こりやすく、Provider がツールレイヤーのパラメータクリーニングロジックを再利用しようとしてつい import してしまい、依存の方向が反転してしまいます。修正方法は、このロジックを Definition の resolve に引き上げ、契約の一部とすることです。

2026 年 9 月最新の実践:能力縫合におけるインターフェースファーストと Provider 交換可能化の実装パス

2026年9月時点の実践を踏まえると、新しい能力をHarnessに縫い込む推奨順序はインターフェースを先に、次にProvider、最後にConsumerです。この順序は美的な好みではなく、依存の方向によって決まります。Definitionは両側から共通して依存される唯一のノードであり、まずこれを安定させれば、その後は両側を並行して進められ、互いにブロックしません。

第1段階:Definitionを書き、契約だけを宣言します。参照資料のmyCapの書き方に倣い、抽象クラスMyCapServiceServiceを継承し、super(ctx, 'myCap')で名前付きサービスとして登録します。declare moduleで宣言マージを行い、TypeScriptでctx.myCapに型ヒントが付くようにします。さらにMyCapRequestMyCapResultを定義します。この段階の重要な規律は、パッケージ内に実装ロジックが一切現れないことであり、抽象メソッド1つとinterface 2つだけにします。同時に、resolve(request)のシグネチャとデフォルト値の意味論をできるだけ早く確定することを推奨します。これは「緩いリクエスト → 厳密な仕様」の収束点であり、遅く決めると後続のProviderがそれぞれ独自のフォールバックを実装することになるからです。

第2段階:Providerを書き、具体的な振る舞いを埋めます。ProviderはDefinitionの抽象クラスを継承し、executeを実装します。複数の実行環境が予想される場合は、この時点でProviderを独立したパッケージにし、異なるパッケージ名で環境を区別し、組み立て時にcordis.ymlで選択します。Providerの内部では「厳密な仕様が与えられたとき、どのように実行するか」だけを扱い、パラメータ推論は行いません。

第3段階:Consumerを書き、モデル向けのツールとしてラップします。このステップの雛形はdefineToolです。ツールのschemaを宣言し、execute内でctx.myCap.resolve(request)を呼び出してパラメータの収束を完了し、次にctx.myCap.execute(spec)を呼び出します。依存はinjectで宣言し、Providerパッケージを決してimportしません。

第4段階:組み立てと検証です。cordis.ymlでProviderとConsumerを組み合わせてロードし、エンドツーエンドのユースケースを動作させ、その後、前節のトラブルシューティングチェックリストに従って「Providerを交換してもゼロ変更」の検証を一度行います。このステップが完了して初めて、能力は真に縫い込み完了となります。

この順序を定着させると、非常に有用な副産物が得られます。能力一覧は実装ではなくDefinitionによって列挙できるのです。すべてのseamに独立したDefinitionがあれば、「現在のシステムにはどのような交換可能な能力があるか」にいつでも答えられます。これはアーキテクチャレビューと権限ガバナンスの両方にとって価値があります。

最後に、2026年の実践で繰り返し検証された経験を1つ補足します。Definitionの初版では完全性を追求しないことです。インターフェースが大きすぎるということは、実装側が使わないフィールドを大量に埋めなければならないことを意味し、将来互換性のために変更できなくなることも意味します。まず最小限の使用可能なリクエスト型と結果型を定義し、resolveを拡張点として残し、後でフィールドを追加するときはデフォルト値を優先的に与えるようにすれば、インターフェースのセマンティックバージョンは長期間メジャーバージョン不变のままにできます。逆に、初版ですべての可能な実行オプションをShellExecRequestに詰め込むと、Provider間で「どのフィールドが必須で、どれが省略可能か」について意見が分かれ、seamの清潔さは急速に損なわれます。

まとめとベストプラクティス

記事全体の要点を実行可能なチェックリストに圧縮する:

  • seam の定義をしっかり覚える:完全な能力は Definition、Provider、Consumer の三者によってその seam が構成され、単一の役割だけが seam ではない。
  • 三役割の責務を暗記する:Definition は「どのような能力があり、どのような形か」だけを宣言する。Provider は抽象クラスを継承して実装を埋める。Consumer はモデルに向けて能力をツール schema に包む。
  • 依存方向を守る:合法な辺は Provider → DefinitionConsumer → Definition の二つだけであり、Provider と Consumer は互いに依存しない。
  • 置き換えは設定に落とし込む:実行器を替えるには cordis.yml の一行を変えるだけ。bash-localbash-sandboxpwsh-local の三択で、同時に有効にする Provider は一つだけにすることを推奨する。
  • 表に照らしてパッケージの帰属を確認するctx.shell の Definition は dsh-shell、Provider は bash-local / bash-sandbox / pwsh-local、Consumer は tool-bash / tool-pwsh / hooks-claude-code / hooks-codex である。
  • リクエストと仕様を分離する:モデル向けの ShellExecRequestworkdirtimeoutMs は任意)と実行器が使う ShellExecSpec(フィールド必須)は必ず分け、その間を ctx.shell.resolve(request) で収束させ、「パッケージ境界では暗黙より明示を優先する」を貫く。
  • Consumer の想像力を広げる:フックブリッジプラグインもツールと同様に Consumer であり、ctx.shell インターフェースだけを認識し、背後にある実行器には関心を持たない。したがって、新しい消費方法を追加するときは Provider を変更せず、Consumer パッケージを追加するだけでよい。
  • パッケージ分割は一つの基準だけで判断する:三つの役割が独立して進化または置き換え可能かどうかが、一つのパッケージに置くか三つに分けるかを決める唯一の判断根拠であり、分割のための分割を避ける。
  • 置き換え後は必ず検証する:Provider を替えるとき、Definition とすべての Consumer はゼロ変更であるべきで、ファイルハッシュの比較、依存宣言のチェック、同一グループの回帰テストケースの三重で確認する。
  • インターフェース先行を貫く:実装順序は Definition → Provider → Consumer → 組み立て検証であり、Definition の初版は最小限の利用可能な状態に保ち、resolve を拡張点として残す。