すべてのコマンドを正確にルーティングする:HagiCode Preset Task のマルチスキル対応実践
すべてのコマンドを正確にルーティングする:HagiCode Preset Task のマルチスキル対応実践
1つのpresetに複数のコマンドを詰め込んでも、スキル要件を共有するしかない?今回の改善で、各コマンドが自分が依存するskillを独立して宣言できるようになり、視覚化パネルでこのバインディングを表示できるようにする——バッジ、要約、ワンクリックインストール、一気通貫。
背景
まず背景から。
HagiCode の preset task はプラグイン式の小ツールシステムだ。ユーザーはコマンドを手入力する必要がなく、視覚化パネルでいくつかのフィールドを入力してクリックするだけで、自動タスクセッションを作成できる。各 preset は本質的にディレクトリで、通常は以下のような構造になっている:
manifest.json:preset の識別情報panel.json:視覚化パネルのフォーム定義commands.json:実際に実行するコマンドのリストtask-preset.jsonまたはprompts.json:タスクパラメータとスキル要件
この仕組みは確かに便利だが、すぐに厄介な問題にぶつかった。
初期バージョンでは、skill は preset レベルの requirements 配列でのみ宣言できた。つまりどういうことかというと、同じ preset 内のすべてのコマンドが同一のスキル要件を共有するということだ。大したことないように聞こえるが、実際に使うとこういうシーンになる:
ある preset に5つのコマンドがあり、そのうち1つ目は last30days という skill を通したいが、3つ目は ui-master を通したい、残りの3つは skill は何も必要ない。旧設計ではこれはできない。異なるコマンドを異なる skill にルーティングしたいなら、これらのコマンドを強制的に複数の preset に分割する必要があり、設定が膨れ上がってしまう。
これが提案 extend-preset-task-multiple-skills-support が解決しようとしている問題だ:各コマンドが自分が依存する skill を独立して宣言できるようにし、UI でこのバインディングを視覚化する。
HagiCode について
この記事で共有するソリューションは、HagiCode プロジェクトでの実践経験に基づいている。HagiCode は AI コードアシスタントプロジェクトで、preset task システムはユーザー向けのショートカット操作入口だ。以下で述べる各変更は、実際に踏んだ落とし穴や実際に最適化した結果だ——畢竟紙上得来終覚浅。プロジェクトのソースコードは HagiCode-org/site にあり、興味のある人は先に Star を押してほしい。
問題をはっきりさせる:なぜマッピングテーブルではないのか
手を動かす前に、最も簡単に思いつくソリューションは:別の commandSkillMappings マッピングテーブルを開設し、「コマンド ID → skill」の関係を別々に保存することだ。すっきりしていて、職責分離だ。
でもよく考えると違う。
commands.json の各コマンドにはすでに ID があり、マッピングテーブルでもこの ID をコピーしなければならない。2つのファイル、同じ ID、ある日誰かがコマンドを変更してマッピングテーブルを同期するのを忘れたら、データがズレてしまう。この「分離のために分離する」設計は、後期の維持コストがそれがもたらすわずかな整潔感をはるかに上回る。結局、ただ徒に悩みを増やすだけだ。
だから私たちは最も直接的な道を選んだ:オプションの skill フィールドを直接コマンド定義に追加する。コマンドが自分がどの skill をバインドするか宣言し、近くで維持すれば、誰も誰と連絡を失わない。
この決定の背後には、もう一つのより重要な設計原則があり、個別に引き出して言う価値がある。
核心一:2層のデータ職責分離
これは改善全体で最も重要な認識だ。
多くの人の第一反応は:コマンドに skill があるなら、requirement check(スキルゲートチェック)をする時、各コマンドの skill フィールドをスキャンすべきでは?
違う。
私たちは意図的にこれを2層に分割した:
commands.jsonのskillフィールド:バインディングの宣言のみを担当する。システムに「このコマンドはどの skill をバインドするか」を伝え、prompt プリアンブルと UI 表示のレンダリングに使用される。task-preset.jsonのrequirements配列:こそが権威的な列挙だ。これは本当のゲートで、ある preset が実行するためにどのスキルを満たす必要があるかを決定する。
言い換えると、skill は「どれをバインドするか、何をレンダリングするか」に答え、requirements は「結局実行を許可するか」に答える。2つのことを混ぜない。
このように分ける利点は、check ロジックが自然にシンプルになることだ。ゲートは常に preset レベルの requirements に基づき、CacheKey で重複排除するため、複数のコマンドが同じ skill をバインドしても一度しか探索されず、重複して打点されない。コマンドレベルの skill は追加の探索オーバーヘッドを導入しない。
この原則は、私たちがマッピングテーブル案を否決した根本的な理由だ——マッピングテーブルは「バインディング即ゲート」と誤解させ、2層の職責を再び混ぜてしまう。賢明さが裏目に出る、まさにこれだ。
核心二:コマンド定義はどんな形か
改善後のコマンド定義は、元の基礎にオプションの skill フィールドを追加しただけだ。last30days という bundled preset を例にすると、その commands.json は大まかにこうなる:
{ "$schema": "../../schemas/commands.schema.json", "version": "1.1", "commands": [ { "id": "research", "skill": "last30days", "prompt": "调研一下最近30天大家对 {topic} 的真实讨论" }, { "id": "summarize", "prompt": "把上面的调研结果整理成一份摘要" } ]}いくつかの要点を説明する:
versionは1.1にアップグレードされ、対応する schema にもオプションskillフィールドが追加された。- 最初のコマンド
researchはlast30daysskill をバインドしており、実行時にこのスキルにルーティングされる。 - 2つ目のコマンド
summarizeは skill をバインドしておらず、ただの普通のコマンドで、デフォルトパスを通る。 - 注意:コマンドの中にどの requirement も書いていない。本当のゲートは
task-preset.jsonのrequirementsにある:
{ "requirements": [ { "key": "last30days", "cacheKey": "skill:last30days" } ]}research コマンドがバインドした last30days はこの requirements に現れなければならない、そうでないと問題になる——これは次のセクションで述べるハード制約だ。無理やり曲げたスイカは甘くない。
核心三:ロード時のクロスバリデーション
データ上でバインディングを宣言するだけでは不十分で、誰かが最後の砦を守り、「コマンドが skill をバインドしたのに、requirements には宣言されていない」という孤立したバインディングが本番に漏れないようにする必要がある。
この最後の砦が ValidateCommandSkills だ。これは preset パッケージのロード時に一度実行され、各コマンドの skill が preset レベルの requirements で対応項を見つけられるかチェックする。見つからない場合、不正なパッケージと判定し、preset 全体を無効にし、診断コード command-skill-not-in-requirements を投げる。
なぜパッケージ全体を無効にして、そのコマンドだけスキップしないのか?preset は全体であり、コマンドの間には往々にして依存関係がある(前のコマンドの出力を次のコマンドに渡す)。こっそり1つをスキップすると、後ろのコマンドが空の入力を受け取り、挙動が完全に制御不能になる。やはり人の心は隔て、コードも隔てる。ユーザーには明確なエラーを見せ、タスクが途中で意味不明に走るよりマシだ。この点は、疎かにできない。
このバリデーションはロード時に完了するため、問題は preset 登録の時点で発見され、ユーザーが実際に「実行」をクリックしてから雷が落ちることはない。ユーザー体験にとって、早いエラーは常に遅いエラーより良い。
核心四:prompt プリアンブルの冪等結合
次は、実行チェーンで最も微妙な一環だ。
コマンドが skill をバインドした場合、例えば last30days、システムは実際に実行する前に、この skill 情報をコマンドの前に「結合」し、完全な1行の命令を形成してエグゼキュータに渡す。このプロセスは CombineCommandSkillPrelude が担当する。
具体的な例を挙げる。research コマンドの prompt は「调研一下最近30天大家对 {topic} 的真实讨论」で、バインドした skill は last30days だとすると、最終的にエグゼキュータに渡される命令は大まかにこうなる:
/last30days 调研一下最近30天大家对 {topic} 的真实讨论つまり prompt の前に /last30days というプリアンブルを追加したことになる。エグゼキュータはこのプリアンブルを見ると、まずコンテキストを last30days という skill に切り替える必要があるとわかる。
ここで踏みやすい落とし穴がある:冪等性。
なぜ冪等性を強調するのか?いくつかのシナリオでは、prompt 自体がすでにこの skill プリアンブルを持っている可能性がある(例えばユーザーが手で半分書いたか、別の場所からコピーした)。システムが馬鹿正直にもう一度結合すると、/last30days /last30days 调研... になり、エグゼキュータはエラーか異常挙動になる。
だから CombineCommandSkillPrelude は結合前にまず検知し、プレフィックスがすでに存在する場合、重複して追加しない。このステップは地味に見えるが、ある種の非常に隠れたバグをブロックできるかもしれない。
特筆すべきは、このプリアンブル注入ロジックのすべてが preset 定義レベル(PresetTaskCatalogProvider の BuildCommandPrelude)で完了し、SessionsController 側のセッション作成コードは完全に変更不要だ。これも職責分離がもたらすメリットだ——実行入口は安定を維持し、スキルルーティングの複雑さは定義レベル内部に収束されている。
核心五:フロントエンドはどうバインディングを表示するか
バックエンドがデータモデルと実行チェーンを整理したら、最後のステップは、ユーザーがインターフェースでこのバインディングを「見える」ようにすることだ。結局、機能がユーザーに感知されなければ、やらないのと同じだ。
フロントエンド側では3つのことをした。
第一、コマンドセレクタにバッジを追加。 command-picker で、skill をバインドした各コマンドの横に小さなバッジを表示し、どの skill に依存するかを示す。ユーザーは一目でどのコマンドが「スキル付き」で、どれが普通のコマンドか分かる。
第二、requirement-check 要約ブロック。 パネルに専用の要約エリアがあり、現在の preset が満たす必要があるすべての skill 要件と、各コマンドがそれぞれどれをバインドしたかを列挙する。このブロックのデータは commandSkillsByRequirementKey というマッピングから来る——コマンドをそれがバインドした requirement key でグループ化して集約し、ユーザーが一目で「要件」と「実際のバインディング」が一致しているかどうかを比較しやすくする。画虎不成反类犬、大まかにはこういうことだ——だから集約ロジックはストレートに、派手にしない。
第三、失敗時のワンクリックインストール深リンク。 requirement check がある skill がインストールされていないと発見した場合、ユーザーは自分でドキュメントをめくってインストール入口を探す必要はない。インターフェースは直接深リンクボタンを提供し、クリック一回で対応するインストールプロセスにジャンプする。このステップは「問題発見」と「問題解決」の間の距離を最短に圧縮する。
フロントエンドタイプ側も非常に抑制的で、コマンドタイプに skill?: string を追加しただけで、正規化処理(|| undefined)も行い、空文字列のような境界値が後続の判断でトラブルを起こさないようにした。
実践:5ステップで完全な改善を実行
これまでのバラバラな点を繋げると、完全な改善は実際には5ステップだ:
- schema 拡張:
commands.schema.jsonにオプションskillフィールドを追加し、バージョン番号を1.1に上げる。 - 解析 + バリデーション:
NormalizeCommandsはコマンド定義の解析を担当し、ValidateCommandSkillsはクロスバリデーションを行い、コマンドの skill は preset レベルの requirements で見つけられなければならない。 - プリアンブル注入:
BuildCommandPreludeは実行前に/skillプリアンブルを冪等にコマンドの前に結合し、SessionsControllerの変更は不要。 - bundled preset の移行:
last30daysとui-masterという2つの内蔵 preset のcommands.jsonを変更し、対応するコマンドにskillフィールドを補完する。移行は commands.json のみを変更し、他のファイルは触らない。 - フロントエンド視覚化:タイプにフィールドを補完、command-picker にバッジを追加、requirement-check に要約ブロックを追加、失敗時にワンクリックインストール深リンクを提供。
実践でのいくつかの注意事項を個別に列挙する:
- 1つのコマンドは1つの skill しかバインドできない。これは現在の制約だ。あるシーンが本当に1つのコマンドで複数のスキルをトリガーする必要がある場合、エスケープハッチは preset レベルの
requirementsで複数の skill を宣言し、それらを preset レベルで共存させることだ。 - バリデーション失敗の診断コードは
command-skill-not-in-requirementsで、トラブルシューティング時にこのコードを直接検索できる。 - フロントエンド正規化は
|| undefinedを忘れず、空文字列を判断ロジックに混ぜないようにする。 - 移行時は commands.json のみを変更し、requirements 側はそのままにし、意図しない変更を導入しないようにする。
- バックエンドテストは3つのシーンをカバーする:コマンド skill が requirements にある(通過)、ない(パッケージ無効化)、複数のコマンドが同じ skill をバインド(重複排除正常)。
まとめ
今回の preset task のマルチスキル対応改善は、表面上はコマンドに skill フィールドを追加しただけだが、その背後には考え worth な設計問題がある:バインディングとゲート、結局分けるべきか?
私たちの答えは分けるだ。skill フィールドは「どれをバインドするか、何をレンダリングするか」のみを担当し、requirements が「実行を許可するか」を担当する。この2層の職責が混ざると、マッピングテーブルを使っても他の形を使っても、後続のバリデーション、重複排除、UI 表示が不自然になる。分けた後、各層はシンプルになった:ゲートは常に一つの権威ある列挙に基づき、バインディングは近くで維持されズレず、プリアンブル結合は冪等で制御可能、UI はすでに明確なデータを表示するだけだ。
振り返ると、完全な改善は派手な技術を使わず、職責をきれいに分け、各層が守るべき最後の砦を守っただけだ。HagiCode の preset task システムはこの一連の研磨を経て、ようやく各コマンドがそれが行くべき skill に正確にルーティングできるようになった。結局、ことそのものは本来こんなにシンプルなはずだ……
参考資料
- HagiCode-org/site:プロジェクトソースコード、preset task システムの完全な実装がここにある。
- HagiCode 公式サイト:HagiCode の全体能力を理解する。
- OpenSpec 提案
extend-preset-task-multiple-skills-support:今回の改善のオリジナル設計ドキュメント、proposal、design、tasks を含む。
开始使用 HagiCode
一次安装,几分钟上手
HagiCode for Windows 在 Microsoft Store 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。