目次
- はじめに — 感覚で管理する allowedTools の限界
- approval-tracker の設計思想
- SQLite スキーマ設計
- approval-tracker.py の実装解説
- approval-analysis.py の使い方
- Phase 3/4 への展望 — 自動化と CI 統合
- 運用してみて分かったこと
- まとめ
はじめに — 感覚で管理する allowedTools の限界
Claude Code でセッション作業をしていると、ツール実行のたびに「このツールを実行してよいですか?」という確認プロンプトが表示されます。プロジェクト序盤は丁寧に確認するのが正しい姿勢ですが、同じツールを何十回も承認し続けると「これは毎回許可しているから allowedTools に追加すべきでは?」という気持ちが生まれます。
しかし問題があります。どのツールを何回承認してきたか、正確な記録が存在しないのです。感覚として「Read はほぼ毎回許可している」とは分かります。一方で「Bash はどれくらいの割合で許可しているか」「Write の承認率は下がっていないか」といった定量的な問いには答えられません。
allowedTools の判断を感覚に頼るリスクはふたつあります。ひとつは、本来追加すべきツールを追加しないまま無駄な確認プロンプトを受け続けること。もうひとつは、本来注視すべきツールを気づかないまま無条件許可リストに追加してしまうことです。後者は特にセキュリティ上の問題につながります。
この問題を解決するために構築したのが approval-tracker です。Claude Code(Anthropic が提供する AI コーディングエージェント CLI)の PreToolUse / PostToolUse フックを使って、すべてのツール呼び出しを SQLite データベースに記録します。蓄積したデータを分析スクリプトで集計し、統計に基づいて allowedTools の最適化候補を提案します。感覚ではなくデータで判断できる体制を整えることが、このシステムの目的です。
approval-tracker の設計思想
PreToolUse と PostToolUse の役割分担
Claude Code のフック機構には複数のイベントがありますが、approval-tracker は PreToolUse と PostToolUse の2イベントを使います。
PreToolUse はツールが実行される直前に発火します。このタイミングでは「ユーザーが承認する前」の状態を記録します。承認されたかどうかはまだ分からないため、approved カラムは NULL のまま INSERT します。
PostToolUse はツール実行が完了した後に発火します。このタイミングでは実行が成功したか(エラーが発生したか)を判定し、直前の PreToolUse レコードを UPDATE して approved フラグと duration_ms(実行時間)を書き込みます。
この pre-post ペアで1回のツール呼び出しを表現する設計により、承認率・実行時間・失敗率という3つの観点から分析が可能になります。
DB をリポジトリの外側(~/.claude/)に置く理由
SQLite ファイルのパスは ~/.claude/logs/approval-tracker.db に固定しています。リポジトリ内に置かない理由は3つあります。
第1に、複数リポジトリをまたいだデータを一元管理できることです。複数のプロジェクトで Claude Code を使う場合でも、同一のDBに書き込まれるため、プロジェクト間の比較が可能です。repo カラムにカレントディレクトリを記録しているため、リポジトリ別の絞り込みもできます。
第2に、リポジトリに機密に近いログを混入させないためです。どのツールをどれだけ使ったかというログは、プロジェクトのコードコミット履歴には含めるべきでないデータです。
第3に、.gitignore の管理を不要にするためです。~/.claude/ 配下は標準的にバージョン管理外であり、うっかりコミットするリスクがありません。
フック失敗でセッションを壊さない設計
フックスクリプトの最も重要な設計原則は「フックが失敗しても Claude Code のセッションを中断させない」ことです。approval-tracker はロギングのためのサブシステムであり、本来の作業フローの主役ではありません。
この原則を実現するために3つの対策を取っています。
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "pre"
try:
event = json.load(sys.stdin)
if mode == "pre":
record_pre(event)
else:
record_post(event)
except Exception:
pass # フック失敗でセッションを壊さない
sys.exit(0) # 常に exit code 0 を返す
1つ目は try/except Exception: pass で全例外を握りつぶすことです。通常のコードでは例外を握りつぶす設計は避けるべきですが、ロギング用フックスクリプトでは意図的な選択です。2つ目は sys.exit(0) で常に成功扱いの exit code を返すことです。Claude Code は exit code 2 をブロックシグナルとして解釈するため、ロギングフックが誤ってブロックを発動しないようにします。3つ目は export_stats_to_repo 関数内でも個別に例外処理することです。JSON エクスポートに失敗してもコアのDB書き込みが成功していれば記録は残ります。
hooks.json への登録
{
"PreToolUse": [
{
"id": "approval-tracker-pre",
"description": "ツール呼び出しをSQLiteに記録(PreToolUse)",
"command": "python3 base/.claude/scripts/approval-tracker.py pre"
}
],
"PostToolUse": [
{
"id": "approval-tracker-post",
"description": "ツール実行結果をSQLiteに記録(PostToolUse)",
"command": "python3 base/.claude/scripts/approval-tracker.py post"
}
]
}
matcher を省略しているため、すべてのツールに対してフックが発火します。特定ツールのみ計測したい場合は "matcher": "Bash|Write|Edit" のように正規表現で絞り込めます。ただし全ツールを計測することで「どのツールを Claude が最もよく使うか」という傾向も見えてくるため、最初は全量収集する設定を推奨します。
SQLite スキーマ設計
tool_invocations テーブル
CREATE TABLE IF NOT EXISTS tool_invocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
tool_name TEXT NOT NULL,
invoked_at TEXT NOT NULL,
approved INTEGER, -- 1=approved, 0=denied, NULL=pre-only
duration_ms INTEGER,
repo TEXT
);
各カラムの設計意図を説明します。
| カラム名 | 型 | 説明 |
|---|---|---|
id | INTEGER PK | 自動採番の主キー。pre-post 紐付けに使用 |
session_id | TEXT | Claude Code のセッション識別子。セッション別集計に使用 |
tool_name | TEXT NOT NULL | ツール名(例: Read, Write, Bash) |
invoked_at | TEXT | UTC ISO 8601 形式のタイムスタンプ。duration 計算の基点 |
approved | INTEGER | 1=承認, 0=拒否/エラー, NULL=PostToolUse 未受信 |
duration_ms | INTEGER | pre から post までのミリ秒。ツール実行時間の指標 |
repo | TEXT | 呼び出し時のカレントディレクトリ。リポジトリ別集計に使用 |
approved カラムの3値設計
approved カラムが 1/0/NULL の3値を取る設計には意図があります。NULL は「PreToolUse は記録されたが PostToolUse がまだ届いていない状態」を意味します。Claude Code の実行が途中でキャンセルされたり、セッションが強制終了された場合に NULL のままレコードが残ります。
これは欠損データではなく「未完了のツール呼び出し」という意味のあるデータです。NULL が多いツールは「途中でキャンセルされることが多い操作」という示唆を与えます。分析クエリでは WHERE approved IS NOT NULL で完了済みレコードのみに絞り込む設計としています。
インデックス設計(推奨)
大量のレコードが蓄積した際のクエリ性能を確保するために、以下のインデックスを追加することを推奨します。
CREATE INDEX IF NOT EXISTS idx_tool_name ON tool_invocations(tool_name);
CREATE INDEX IF NOT EXISTS idx_session_id ON tool_invocations(session_id);
CREATE INDEX IF NOT EXISTS idx_repo ON tool_invocations(repo);
CREATE INDEX IF NOT EXISTS idx_invoked_at ON tool_invocations(invoked_at);
SQLite は軽量DBですが、数万件のレコードになるとフルスキャンで遅延が発生します。特に tool_name と approved を複合する集計クエリが多いため、複合インデックスも有効です。
CREATE INDEX IF NOT EXISTS idx_tool_approved
ON tool_invocations(tool_name, approved);
approval-tracker.py の実装解説
スクリプト全体
#!/usr/bin/env python3
"""Claude Code ツール承認ログを SQLite に記録するフックスクリプト"""
import json, os, sqlite3, sys
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path.home() / ".claude" / "logs" / "approval-tracker.db"
def get_conn() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS tool_invocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
tool_name TEXT NOT NULL,
invoked_at TEXT NOT NULL,
approved INTEGER,
duration_ms INTEGER,
repo TEXT
)
""")
conn.commit()
return conn
def record_pre(event: dict) -> None:
conn = get_conn()
conn.execute(
"INSERT INTO tool_invocations (session_id, tool_name, invoked_at, repo) VALUES (?,?,?,?)",
(
event.get("session_id"),
event.get("tool_name", "unknown"),
datetime.now(timezone.utc).isoformat(),
os.getcwd(),
)
)
conn.commit()
conn.close()
def record_post(event: dict) -> None:
conn = get_conn()
row = conn.execute(
"SELECT id, invoked_at FROM tool_invocations WHERE approved IS NULL "
"AND tool_name = ? ORDER BY id DESC LIMIT 1",
(event.get("tool_name", "unknown"),)
).fetchone()
if row:
invoked_at = datetime.fromisoformat(row[1])
duration_ms = int((datetime.now(timezone.utc) - invoked_at).total_seconds() * 1000)
approved = 1 if not event.get("error") else 0
conn.execute(
"UPDATE tool_invocations SET approved=?, duration_ms=? WHERE id=?",
(approved, duration_ms, row[0])
)
conn.commit()
export_stats_to_repo(conn)
conn.close()
def export_stats_to_repo(conn: sqlite3.Connection) -> None:
"""リモートエージェントが読めるよう JSON にもエクスポート"""
try:
rows = conn.execute("""
SELECT tool_name,
COUNT(*) AS total,
SUM(CASE WHEN approved=1 THEN 1 ELSE 0 END) AS approved,
AVG(duration_ms) AS avg_ms
FROM tool_invocations
WHERE approved IS NOT NULL
GROUP BY tool_name
ORDER BY total DESC
""").fetchall()
stats = {
"exported_at": datetime.now(timezone.utc).isoformat(),
"tools": [
{"tool": r[0], "total": r[1], "approved": r[2], "avg_ms": r[3]}
for r in rows
]
}
stats_path = Path("base/.claude/logs/approval-stats.json")
stats_path.parent.mkdir(parents=True, exist_ok=True)
stats_path.write_text(json.dumps(stats, indent=2, ensure_ascii=False))
except Exception:
pass
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "pre"
try:
event = json.load(sys.stdin)
if mode == "pre":
record_pre(event)
else:
record_post(event)
except Exception:
pass
sys.exit(0)
record_pre の処理フロー
record_pre は Claude Code がツールを実行しようとしている瞬間に呼ばれます。この時点では実行が承認されるかどうかはまだ決まっていません。したがって approved カラムは含めずに INSERT し、NULL のまま残します。
invoked_at には UTC タイムゾーン付きの ISO 8601 形式で現在時刻を記録します。datetime.now(timezone.utc).isoformat() は 2026-04-08T09:23:45.123456+00:00 のような文字列を生成します。後で record_post がこの文字列をパースして経過時間を計算します。
repo には os.getcwd() を使ってカレントディレクトリを記録します。Claude Code は通常プロジェクトのルートディレクトリから起動されるため、これがリポジトリの識別子になります。
record_post の pre-post 紐付けロジック
record_post が最も工夫されている部分は、対応する record_pre のレコードをどう特定するかです。
row = conn.execute(
"SELECT id, invoked_at FROM tool_invocations WHERE approved IS NULL "
"AND tool_name = ? ORDER BY id DESC LIMIT 1",
(event.get("tool_name", "unknown"),)
).fetchone()
このクエリは「approved が NULL(未完了)で、同じ tool_name を持つ最新のレコード」を1件取得します。Claude Code のフックはシングルスレッドで順番に呼ばれるため、この「最新の未完了レコード」が直前の PreToolUse に対応すると考えられます。
理論的には並行実行によってレコードが混在するリスクがゼロではありませんが、Claude Code のツール実行は基本的にシリアルであるため、このシンプルな紐付けロジックで実用上問題ありません。もし厳密な対応付けが必要な場合は、PreToolUse イベントから invocation_id などを取得して明示的に紐付ける設計が必要です。
duration_ms の計算
invoked_at = datetime.fromisoformat(row[1])
duration_ms = int((datetime.now(timezone.utc) - invoked_at).total_seconds() * 1000)
datetime.fromisoformat() は Python 3.7 以降で UTC オフセット付き文字列をパースできます。datetime.now(timezone.utc) との差分を取ることで、ナイーブな datetime 同士の減算による TypeError を避けています。
duration_ms はツールの「実行時間」の近似値ですが、実際には「ユーザーが承認ダイアログを見てから実行が完了するまでの時間」も含まれます。ユーザーが承認プロンプトに時間をかけた場合は duration_ms が長くなります。この数値は厳密な性能指標ではなく「傾向を見る」ための参考値として使います。
export_stats_to_repo の設計
export_stats_to_repo は PostToolUse のたびに呼ばれ、集計済みの統計を JSON ファイルにエクスポートします。このエクスポートには重要な理由があります。SQLite ファイルは ~/.claude/ 配下にあるため、リモートエージェント(GitHub Actions 上の Codex など)がアクセスできません。JSON ファイルをリポジトリ内に置くことで、CI パイプラインやリモートエージェントが最新の統計を読み取れるようになります。
{
"exported_at": "2026-04-08T09:30:00.000000+00:00",
"tools": [
{"tool": "Read", "total": 342, "approved": 341, "avg_ms": 1523},
{"tool": "Bash", "total": 189, "approved": 177, "avg_ms": 4821},
{"tool": "Write", "total": 97, "approved": 94, "avg_ms": 892},
{"tool": "Edit", "total": 76, "approved": 75, "avg_ms": 743},
{"tool": "Glob", "total": 58, "approved": 58, "avg_ms": 312}
]
}
stats_path は Path("base/.claude/logs/approval-stats.json") という相対パスです。Claude Code がリポジトリルートから起動されている前提の設計です。このパスはプロジェクトのディレクトリ構造に合わせて変更してください。たとえば .claude/logs/approval-stats.json や reports/approval-stats.json など、プロジェクトの慣習に沿ったパスを設定します。
approval-analysis.py の使い方
スクリプト全体
#!/usr/bin/env python3
"""承認統計レポートと allowedTools 提案を生成する"""
import sqlite3, json, argparse
from pathlib import Path
DB_PATH = Path.home() / ".claude" / "logs" / "approval-tracker.db"
def report():
conn = sqlite3.connect(DB_PATH)
rows = conn.execute("""
SELECT tool_name,
COUNT(*) AS total,
SUM(CASE WHEN approved=1 THEN 1 ELSE 0 END) AS approved,
ROUND(100.0 * SUM(approved) / COUNT(*), 1) AS approval_rate,
ROUND(AVG(duration_ms), 0) AS avg_ms
FROM tool_invocations
WHERE approved IS NOT NULL
GROUP BY tool_name
ORDER BY total DESC
""").fetchall()
print(f"{'Tool':<30} {'Total':>6} {'Approved':>9} {'Rate':>7} {'AvgMs':>8}")
print("-" * 65)
for r in rows:
print(f"{r[0]:<30} {r[1]:>6} {r[2]:>9} {r[3]:>6}% {r[4]:>8}")
def suggest(threshold: float = 0.9):
"""承認率が threshold 以上のツールを allowedTools 候補として提案"""
conn = sqlite3.connect(DB_PATH)
rows = conn.execute("""
SELECT tool_name,
COUNT(*) AS total,
ROUND(100.0 * SUM(approved) / COUNT(*), 1) AS approval_rate
FROM tool_invocations
WHERE approved IS NOT NULL
GROUP BY tool_name
HAVING approval_rate >= ? AND total >= 5
ORDER BY total DESC
""", (threshold * 100,)).fetchall()
print("# allowedTools 追加候補(承認率90%以上 かつ 5回以上実行)")
print(json.dumps([r[0] for r in rows], indent=2, ensure_ascii=False))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("report")
sub.add_parser("suggest")
args = parser.parse_args()
if args.cmd == "report": report()
elif args.cmd == "suggest": suggest()
report コマンドの実行と出力例
python3 approval-analysis.py report
以下のような出力が得られます。
Tool Total Approved Rate AvgMs
-----------------------------------------------------------------
Read 342 341 99.7% 1523
Glob 58 58 100.0% 312
Grep 54 54 100.0% 428
Write 97 94 96.9% 892
Edit 76 75 98.7% 743
Bash 189 177 93.7% 4821
TodoRead 23 23 100.0% 201
TodoWrite 18 18 100.0% 187
WebSearch 12 10 83.3% 8234
mcp__github__create_pull_request 4 4 100.0% 2341
この出力から複数の示唆が読み取れます。Read・Glob・Grep は承認率が100%に近く、これらは allowedTools に追加する有力候補です。一方 WebSearch の承認率は83.3%で、何らかの操作が2回拒否されています。拒否の多いツールは使用パターンを詳しく調べる価値があります。
AvgMs 列を見ると、Bash の平均実行時間が4821msと突出しています。Bash は Shell コマンドを実行するため実行時間が長くなりますが、承認ダイアログでの確認時間が含まれるため参考値として扱うべきです。
suggest コマンドの出力と allowedTools への組み込み
python3 approval-analysis.py suggest
# allowedTools 追加候補(承認率90%以上 かつ 5回以上実行)
[
"Read",
"Write",
"Edit",
"Bash",
"Glob",
"Grep",
"TodoRead",
"TodoWrite"
]
この出力を .claude/settings.json の allowedTools に反映します。
{
"allowedTools": [
"Read",
"Write",
"Edit",
"Bash",
"Glob",
"Grep",
"TodoRead",
"TodoWrite"
]
}
suggest コマンドの閾値設計
suggest の判定条件は2つです。「承認率が threshold(デフォルト90%)以上」かつ「実行回数が5回以上」です。
承認率の閾値を90%に設定した理由は、許容できる拒否率を10%としたからです。10回に1回は条件によって拒否するツールはまだ注視が必要と判断しています。100%のツールのみを対象にすると候補が少なすぎ、75%以下まで下げると安全性が低下します。
実行回数の下限を5回に設定した理由は、サンプル数が少ない場合の偽陽性を避けるためです。1回しか実行していないツールが1回承認されただけで「承認率100%」と判定されるのは望ましくありません。5回という数は最低限の実績として設定しています。プロジェクトのリスク許容度に応じて threshold と下限回数は調整してください。
Phase 3/4 への展望 — 自動化と CI 統合
allowedTools の自動更新 CI
現在の approval-tracker は「データを集めてレポートを出す」Phase 1/2 の実装です。次のステップは「統計データに基づいて allowedTools を自動更新する」Phase 3 の実装です。
GitHub Actions ワークフローで以下のフローを実現できます。
name: Update allowedTools
on:
schedule:
- cron: '0 3 * * 0' # 毎週日曜3時
jobs:
update-allowed-tools:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Read approval stats
run: |
# approval-stats.json から承認率90%以上のツールを抽出
python3 .claude/scripts/ci-update-allowed-tools.py \
--stats base/.claude/logs/approval-stats.json \
--settings .claude/settings.json \
--threshold 0.9 \
--min-count 10
- name: Create PR if changed
uses: peter-evans/create-pull-request@v6
with:
title: "chore: auto-update allowedTools based on approval stats"
body: "承認統計に基づく allowedTools の自動更新。approval-analysis レポートを確認してマージしてください。"
このワークフローは approval-stats.json(リポジトリ内の JSON エクスポート)を読み込み、settings.json を更新して PR を作成します。人間がレビューしてからマージする設計とすることで、完全自動化のリスクを避けながら手作業を削減できます。
フック削減と最適化
allowedTools に多くのツールを追加するにつれて、フックの発火回数は減少します。ただし approval-tracker のフックは全ツールに対して発火するため、DB 書き込みのオーバーヘッドは一定です。
Phase 4 では、一定期間後に承認率が安定したツールについてはフックを停止する最適化を検討しています。例えば「直近100回すべて承認されているツールはトラッキング対象から外す」といったルールです。これにより、実際に注視が必要なツール(承認率が低い・変動しているツール)に絞ってデータを収集できます。
また現在の export_stats_to_repo は PostToolUse のたびに全件集計と JSON 書き込みを行うため、レコード数が増えると処理時間が伸びます。将来的には集計クエリをキャッシュするか、定期バッチ実行に移行することを計画しています。
リポジトリ別・セッション別分析
現在の分析スクリプトは全リポジトリ・全セッションの集計のみ提供しています。Phase 4 では repo カラムと session_id カラムを活用した絞り込み分析を実装します。
def report_by_repo(repo_filter: str):
conn = sqlite3.connect(DB_PATH)
rows = conn.execute("""
SELECT tool_name,
COUNT(*) AS total,
ROUND(100.0 * SUM(approved) / COUNT(*), 1) AS approval_rate
FROM tool_invocations
WHERE approved IS NOT NULL
AND repo LIKE ?
GROUP BY tool_name
ORDER BY total DESC
""", (f"%{repo_filter}%",)).fetchall()
# ...
「このリポジトリでは Bash の承認率が他より低い」という気づきが得られれば、リポジトリ固有のセキュリティリスクの発見につながります。
運用してみて分かったこと
高承認率のツールは予想通り
実際にデータを収集してみると、高承認率のツールの顔ぶれは概ね予想通りでした。Read・Glob・Grep はほぼ100%の承認率であり、読み取り専用ツールは安全という直感と一致しています。Write と Edit も95%以上の承認率で、Claude Code が提案するファイル変更はほぼ受け入れているということが数値で確認できました。
承認率が低いツールから得られた示唆
WebSearch の承認率が83%程度であることが分かりました。拒否されたケースを振り返ると、「検索クエリが広すぎて意図と違う結果になりそうだった」というケースが多かったです。Claude Code が WebSearch を呼ぶ際の検索クエリ設計に問題があることが示唆されました。これをきっかけに、UserPromptSubmit フックで WebSearch の使用前にクエリをログに残す仕組みを追加しました。
Bash の承認率は93〜95%の範囲で安定しています。拒否されたケースの多くは「意図しないディレクトリへの rm コマンド」や「git push の誤発動」でした。これらはすでに PreToolUse のガードレールフックでブロックしていますが、ガードレールをすり抜けてユーザー判断に委ねられたケースが少数ある、という実態が確認できました。
データが意思決定の根拠になる
最も大きな収穫は「感覚ではなくデータで話せるようになった」ことです。チームで allowedTools を議論する際に「Write は96.9%の承認率で、直近97回のうち3回だけ手動で止めている。その3回を見直して再現性があれば allowedTools に追加して問題ない」という具体的な議論ができるようになりました。
セッション別の分析も有用でした。特定のセッションで Bash の承認率が急落している場合、そのセッションで危険なタスクを実行していた可能性があります。セッション後のレビューに使える指標として活用できます。
まとめ
approval-tracker は「Claude Code の行動を可観測にする」という目的のもと設計したシステムです。主要なポイントを整理します。
| 観点 | 設計の選択 |
|---|---|
| フック設計 | PreToolUse で INSERT、PostToolUse で UPDATE |
| DB 場所 | ~/.claude/logs/ にローカル保存(リポジトリ外) |
| 障害対策 | 全例外を握りつぶし、exit code 0 を常に返す |
| CI 連携 | JSON エクスポートでリモートエージェントとデータ共有 |
| 分析 | report で現状把握、suggest で allowedTools 候補提示 |
この仕組みによって、allowedTools の管理が感覚から統計ベースに変わりました。「承認率90%以上・5回以上実行」という基準を満たしたツールのみを allowedTools に追加することで、過剰な許可とセキュリティリスクを同時に抑制できます。
次のステップは GitHub Actions による自動 PR 生成(Phase 3)です。週次で統計を確認し、候補ツールを PR として提案するフローを整備することで、allowedTools の管理コストをさらに下げていきます。
Claude Code の可観測性を高める仕組みは、AI エージェントを安全かつ効率的に活用するための基盤です。ぜひ自分のプロジェクトでも試してみてください。
関連記事
- Claude Code カスタムフックで AI エージェントの暴走を防ぐ — PreToolUse / PostToolUse フックの基本構造と設計パターンの全体像
- Claude Code コミット規律フックの実装 — 破壊的操作を検出してブロックする pre-destructive-git フックの詳細実装
- Claude Code Hooks・カスタムコマンドで開発品質を自動化する — フック・コマンド・スキルを組み合わせた開発ワークフロー強化の実践