Coding Agent のド忘れを機械的にカバーする OSS『markgate』を作った

Coding Agent が思わず命令を忘れてしまっても大丈夫。それを機械的にカバーするツールを OSS として作りました。

目次

目次


Coding Agent のド忘れと二重実行

例えば、実装 skill と、その後に実行するチェック skill (/check)があるとします。

/check は、実装した内容に対してチェックする。例えば、test や typecheck、lint に format や build、ドキュメントの整合性確認、カバレッジ生成、イメージスキャンだったり、プロジェクトによって実装に対して保証したい内容は多々あるかと思います。


しかし、Coding Agent に「実装後は /check を走らせて」と言っても、Coding Agent がそれを忘れてしまい、サイレントにスキップされることがあります。その場合、実行したかったチェックが走らないまま commit されてしまうことになります。


そこで、pre-commit hook を設定して強制的に /check 相当の処理を走らせます。

しかし、Coding Agent はいつも忘れるわけではありません。むしろ、忘れるのは稀で、普段は /check を走らせてくれます。

そうすると、「skill で既に走らせた → hook でまた走る」 の二重実行になってしまいます。


この /check による処理が重いものであれば、せっかくの Coding Agent での迅速なはずの開発サイクルが遅くなってしまいます。軽いものであればあまり気にならないかもしれませんが、頻繁に実行されることで中々の時間を無駄にしてしまうことになります。


さらに、実装 skill 後にチェックをせず hook だけにチェック処理を寄せると、まだ commit をしたくないときにチェック処理を走らせることができません。また、ファイル変更ごとに post-edit hook を走らせるのも、変更のたびにチェック処理が走ることになるため時間がかかってしまいます。


markgate

そこで、そんな状況を機械的にカバーするためのツールとして、CLI ツールの OSS「markgate」を作りました。

github.com


具体的に markgate は、/check skill と hook を両立したまま、Coding Agent が /check を忘れた場合「のみ」 hook を発火させる、ということを実現します。

つまり、普段 Coding Agent がちゃんと /check を走らせてくれる場合は、hook はスキップされるので、二重実行が起きません。


インストール

# Homebrew
brew install go-to-k/tap/markgate

# shell script
curl -fsSL https://raw.githubusercontent.com/go-to-k/markgate/main/install.sh | bash

# go install
go install github.com/go-to-k/markgate/cmd/markgate@latest

# mise (ubi backend)
mise use "ubi:go-to-k/markgate@0.2.0"

なお、markgate は git リポジトリの配下で実行する前提です。


基本の使い方

基本は、設定ファイル無しで、コマンド呼ぶだけです。(凝ったことをしたい人用に設定ファイルも用意しています。)


例えば、上記例の /check skill や pre-commit hook 内で pnpm build を実行しているとしましょう。pre-commit hook は、Claude Code による PreToolUse hook で git commit* に発火させているとします。


1. /check skill

まずは最初に実行する /check skill で、markgate run -- <command> コマンドを使って、pnpm build コマンドをラップします。

// ... skill 定義の中で
- pnpm build
+ markgate run -- pnpm build


2. hook

pre-commit hook 内でも同様に markgate run -- pnpm build に書き換えます。

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "if": "Bash(git commit*)",
-     "hooks": [{ "type": "command", "command": "pnpm build" }]
+     "hooks": [{ "type": "command", "command": "markgate run -- pnpm build" }]
    }]
  }
}


終わり

これだけです。

これだけで、Coding Agent が /check を忘れたとき「だけ」 hook が発火して pnpm build を走らせる、という挙動になります。

Coding Agent がちゃんと /check を走らせてくれた場合、後者の hook はスキップされ、二重実行も起きません。


ゲートとしての用途

ここまでの markgate run は、hook 設定の中に対象のコマンド (pnpm build 等) を書く形でした。一方、markgate setmarkgate verify を組み合わせた ゲートパターン はその逆向きの構造です。チェック処理は skill / build script 側に置いたまま、set で「チェックが通った時点」をマーカーとして記録し、hook 側は verify で「マーカー記録後にコードが変わっていないか」を判定するゲートとして動かす、という形になります。

マーカーは set した時点のコード状態を記録したもので、その後コードが変わると現在のコード状態とマーカーの間にズレが生じ、verify がそのズレを検知して失敗する、という仕組みです。逆にコードが変わっていなければマーカーとズレず verify は通るので、コードが変わったときだけチェックし直し、変わっていなければ再実行しない、という挙動になります。

ゲートパターンも markgate run と同じく設定ファイル無しでそのまま使えますが、markgate init コマンドで生成される .markgate.yml を使えば、複数チェックを 1 つの hook にまとめる集約 verify まで実現できます。


2 つのコマンドの役割は以下の通りです。

  • markgate set: チェックが通った時点をマーカーとして記録する
  • markgate verify: マーカー記録後にコードが変わっていなければ exit 0、変わっていれば exit 1 で止める


なお、マーカーの実態(ハッシュの計算方法、保存場所など)は後述で詳しく扱います。ここではいったん「チェックが通った時点を覚えておく印」というイメージで読み進めて大丈夫です。


最小構成(設定ファイル無し)

チェックの実体(skill / build script / Make ターゲットなど)の末尾で markgate set を呼び、hook 側は markgate verify を呼ぶだけです。

# /check skill 定義の末尾で
pnpm typecheck && pnpm lint:fix && pnpm build && markgate set
// .claude/settings.json (hook 側)
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(git commit*)",
        "hooks": [
          { "type": "command", "command": "markgate verify || { echo 'run /check' >&2; exit 1; }" }
        ]
      }
    ]
  }
}


一連のチェック処理の定義は 1 箇所に集約されたまま、hook はマーカーを見るだけ、という構造になります。マーカーがコードとズレていた場合、hook は exit 1 でブロックし、AI にはエラーとヒント (run /check) が伝わるので、ゲートで止められた場合の対応は、AI に任せて再ループさせることができます。


集約 verify (複数チェック)

ゲートパターンが特に効いてくるのは、チェックが複数並ぶときです。各チェックは自分のマーカーを更新し、hook 側はそれらをまとめた集約マーカーを 1 行で verify する、という構造が組めます。これを実現するのが、composes で子ゲートをまとめる親ゲート(以下、集約ゲート)です。

例えば pre-commit で、軽量チェック (check: typecheck / lint / build / unit test) と、ドキュメント整合性を LLM で確認する重いチェック (docs) を走らせたいとします。1 つのゲートにまとめると ちょっとしたコミットでも毎回 LLM 整合性チェックが走ってしまう ので、.markgate.ymlcheck / docs を独立した 2 つのゲートとして並べて、それぞれに include でスコープを定義し、さらに composes で両者をまとめる pre-commit 親ゲートを置きます。

# .markgate.yml
gates:
  check:
    hash: files # include で指定したファイル群だけをハッシュ対象にする
    include:
      - "src/**"
      - "tests/**"
      - "package.json"
  docs:
    hash: files
    include:
      - "src/**" # src の変更は docs にも影響するので両方に含める
      - "docs/**"
      - "README.md"
  pre-commit:
    composes: [check, docs] # check と docs の AND を取る集約ゲート


pre-commit ゲートが、checkdocs を子ゲートとして取りまとめる集約ゲートです。自前のコマンドは持たず、子ゲートが全部一致していれば一致、1 つでもズレていればズレ、という AND の判定だけを担います。なお、自前のコマンドを持たないため、こうした集約ゲートは markgate run では書けず、ゲートパターンでしか実現できない仕組みです。


skill 側からは、ゲート名を引数で指定して、各ゲートを個別に set します。

# /check skill 定義の末尾で
pnpm typecheck && pnpm lint && pnpm test && markgate set check
# /check-docs skill 定義の末尾で
./scripts/check-docs && markgate set docs


そして、pre-commit hook では、集約ゲートの pre-commit を 1 行で verify します。ズレていた場合は markgate status pre-commit で「どの子ゲートがズレているか」が表示されるので、AI はそれを見て該当する skill を再ループします。

// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(git commit*)",
        "hooks": [
          { "type": "command", "command": "markgate verify pre-commit || { markgate status pre-commit >&2; exit 1; }" }
        ]
      }
    ]
  }
}


ポイントは、編集したスコープに応じて、必要なチェックだけが再実行されることです。各ゲートのマーカーは自分の include に含まれるファイル群だけをハッシュ対象にしているため、片方のスコープしか触っていないコミットでは、もう片方のゲートはハッシュが set 時のマーカーと変わらず一致したまま、という挙動になります。

例えば tests だけ直したコミットでは軽量チェックだけ、CI 設定など include に含まれないファイルだけのコミットなら両方スキップ、というように切り替わります。

編集対象 check docs 再実行が必要なチェック
tests/** のみ ズレ 一致 軽量チェックのみ
docs/** / README.md のみ 一致 ズレ 重いチェックのみ
src/** ズレ ズレ 両方
上記いずれにも該当しない (CI 設定、エディタ設定など) 一致 一致 なし (commit が通る)


ゲートパターンを選ぶ基準

markgate run とゲートパターンの選び分けは、以下のような観点で考えると良いです。

  • 複数チェックを 1 つの hook に集約したい: 上記の集約ゲートのように、各チェックが自分のマーカーを更新して、hook 側は 1 行で集約 verify する構造が組めます。これは markgate run には書けない、ゲートパターンでしかできない使い方です。
  • チェックがコマンドに落ちない: LLM 判定など、shell コマンドで表現できないチェック処理はゲートパターン(次節)
  • hook がブロックするか、代わりに走らせるか: markgate run は、AI がスキルなどの実行を忘れても hook 側で代わりにその処理を走らせてカバーします。反対に verify は、AI が忘れていれば hook でそのままブロックして、AI に「忘れた」というシグナルを返し、AI に再実行を促します。

逆に、チェック処理が 1 つで、hook が「忘れたときの保険」として同じ処理をもう一度走らせる役で十分なら、markgate run -- の方が短く書けます。


コマンド化できない AI チェックの強制

hook はそもそもコマンドしか実行できないので、そのままでは機械的なチェック (lint、test、build など) しか強制できません。

しかし、AI 判断が必要なレビュー (例えば「既存コードと命名の一貫性が取れているか」「PR description が diff と合っているか」など) は、コマンドに落とし込めません。


ところが、ゲートパターンを使えば、hook にこの強制力を持たせられます。

  1. skill (例: /check-docs) の最後で markgate set を呼び、検証成功状態をマーカーに保存する
  2. hook 側で markgate verify を呼び、検証が走っていなければゲートで止める

これで、hook 自身は「AI タスクが走ったかどうか」を確認するだけで済み、hook の強制力を、機械的タスクから AI 判断のタスクまで広げることができる、というわけです。


# /check-docs skill 定義の最後に
markgate set
// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(git commit*)",
        "hooks": [
          { "type": "command", "command": "markgate verify || { echo 'run /check-docs' >&2; exit 1; }" }
        ]
      }
    ]
  }
}


機械的に二重実行を防止する仕組み

つまり、markgate は、Coding Agent がやるべき操作を忘れても忘れなくても 1 回だけの実行で済むよう、機械的に二重実行を防止する仕組みになっています。


マーカーで状態を管理

ここまで何度か出てきた「マーカー」の中身を見ていきます。markgate は、対象のコマンドが成功した時点の「状態」をマーカーとして保存しておいて、次回以降に同じコマンドが走ったときに、マーカーが一致すればスキップ、一致しなければ実行する、という仕組みで動いています。

この「状態」は、実態としてはコード内容のハッシュ値です。ハッシュ対象は「特定のファイル群」と「リポジトリの status」の 2 つから選べます(デフォルトは「リポジトリ」)。どちらにせよ、コードの内容が変わっていない限り、コマンドがスキップされる、ということになります。

# 初回: マーカーがないので pnpm build が走り、成功時にマーカー保存
$ markgate run -- pnpm build
building...
passed in 7.2s

# 2 回目: ファイルに変更なし → 実行をスキップ
$ markgate run -- pnpm build

# ファイル編集後: マーカーが無効化されて再実行
$ echo '// fix typo' >> src/foo.ts
$ markgate run -- pnpm build
building...
passed in 7.1s


また、マーカーファイルはデフォルトでは .git 内(.git/markgate/default.json)に保存されるため、自前でマーカー用のファイルを作成する必要もありませんし、明示的に .gitignore に追加せずとも git の管理下には入りません。(明示的にマーカーファイルのパスを変更し、git の管理下に置くオプションも用意しています。)


キャッシュとしての機能

この仕組みですが、「状態」がキャッシュとして機能している、ということになります。コマンドの実行結果をキャッシュするのではなく、コマンドが成功した「状態」をキャッシュする、という点がポイントです。


一方で、ファイルの内容が変わったらテストやビルドなどは再度実行されて欲しいですよね。

もしファイルの内容が変わった場合、状態が無効化されるため、コマンドが再度実行されます。つまり、キャッシュが切れたというようなイメージです。


これにより、コードの内容が変わったときは確実にコマンドが走る、ということになります。もちろん、コマンドが失敗した場合は状態が保存されないため、次回もコマンドが走ることになります。


他のユースケース例

markgate は、上記の基本的な使い方以外にも、様々な応用パターンがあります。

  • PR 作成の前にドキュメント整合性をチェック (/check-docsgh pr create hook)
  • image の push 前に脆弱性スキャン (/security-scandocker push hook)
  • push 前にカバレッジレポート生成 (/coveragegit push hook)


他の hook manager から使う

ここまでは Claude Code の hook を例にしましたが、markgate は hook manager 非依存 です。husky / lefthook / pre-commit framework など、どの hook manager からでも markgate run -- <command> を 1 行書くだけで使えます。

# .husky/pre-commit
markgate run -- pnpm test
# lefthook.yml
pre-commit:
  commands:
    check:
      run: markgate run -- pnpm test
# .pre-commit-config.yaml (pre-commit framework)
repos:
  - repo: local
    hooks:
      - id: markgate-check
        name: markgate check
        entry: markgate run -- pnpm test
        language: system
        pass_filenames: false


最後に

個人的にも、AI 時代に重宝したくなるツールになったと思います。特に、「AI のミスは機械的にカバーする」 という考え方は、AI を活用する上で重要なポイントの一つだと感じています。

その他の機能 (.markgate.yml での exclude / state_dir 設定、ローカルと CI でのマーカー共有など) に関しては、GitHub リポジトリの README を参照してください。気に入ったらぜひ ⭐ を付けてもらえると嬉しいです。


また、markgate を実際に活用しているプロジェクトとして、フルスクラッチの CDK である cdkd があります。こちらもぜひ参考にしてみてください。

github.com