エージェント型コーディングツールへのメトリクスのフィード
Claude Code や opencode のようなエージェント型コーディングツールは、保守性フィードバックの急成長中の消費者であり、エディタの中の人間とは異なる形でそのフィードバックを求めます。人間はキー入力をするので、エディタのループは言語サーバーに頼ります。didChange のたびにパースし、複雑度をマージンに描画し、1 文字ごとに更新する、という具合です。エージェントはキー入力をしません。ツール呼び出しで編集全体を書き込み、ターンを明け渡します。その消費者にとって正しいフィードバックは、バッチで、編集後に、構造化されて届くもの — まさに bca check がすでに出力しているものです。
そのため、このレシピは新しいバイナリの表面を一切追加しません。bca check は、エージェントが必要とするすべてをすでに提供しています:
- 機械的にパース可能な違反リスト(stdout への違反ごとの行、または構造化ドキュメントとしての
--report-format sarif | code-climate | clang-warning | msvc-warning | checkstyle)。 - 段階化された終了コード — 違反があれば
2、クリーンなら0、ツールエラーなら1— により、フックは何もパースせずに「この編集はコードを複雑にしすぎたか?」で分岐できます。 - ベースラインによるフィルタリング、ソース内の抑制マーカー、
[check.exclude]グロブにより、エージェントが見るシグナルは人間が見るのと同じラチェット済みのシグナルになります — ただし、プロジェクトの除外設定がウォーカーの deny セットではなく[check]の下にあることが前提です。
欠けていたのは配線です。このページがその配線です。ツールごとにコピー&ペーストできるフィードバックループと、ループが裏目に出ないようにするためのエージェント向けガイダンスを提供します。
キーストローク時ではなく編集後に
このレシピは、提案中の bca lsp サーバー (#384) と意図的に対をなすものです。両者は異なる消費者に仕え、互いに依存しません:
bca lsp (#384) | 本レシピ | |
|---|---|---|
| 消費者 | エディタ内の人間 | ツールループ内のエージェント |
| トリガー | キーストローク(didChange) | ツール呼び出しの完了(編集の反映) |
| 再パース | インクリメンタル | 編集ごとにファイル全体を 1 回 |
| 出力先 | マージンの診断表示 | モデルへフィードバックされるテキスト |
| ステータス | 提案段階 | bca check で今日から動作 |
人が入力しているときは LSP を、エージェントが編集しているときはこちらを選んでください。エージェントを LSP のインクリメンタルな didChange 経路につなぐのは、エージェントが決して使わない機構の代価を払うことになります。
以下のツール別セクションがすべて呼び出すコマンドは、手で実行するのと同じものです:
# 終了コード 2 ⇒ このファイルに違反が少なくとも 1 件あります。しきい値は
# リポジトリルートの bca.toml(自動的に発見されます)から来ます。その場限りの
# 上書きは 1 つ以上の --threshold フラグで行います。
bca check path/to/edited_file.rs --threshold cognitive=10
エージェントループには、ブロッキング CI ゲートよりも厳しい制限が適しています。偽陽性のコストはマージのブロックではなく無駄なリファクタリング 1 回で済み、シグナルはコードを書いている最中に届きます。テーブルの残りはエージェントフィードバックプロファイルを、同梱のデフォルトの由来はしきい値の選択を参照してください。
リポジトリルートに bca.toml があれば(ローカルしきい値ゲート を参照)、--threshold フラグは不要です。素の bca check <file> がコミット済みの限度値・ベースライン・除外設定を読み込むため、エージェントのループは CI とまったく同じ条件でゲートします。
フックの除外は [check] の下に置く
CI の呼び出しから引き継がれ「ない」ものが 1 つあり、これを省くと偽陽性を生みます。フックは実行ごとにファイルを 1 つ名指しし、明示的に名指しされたパスはすべてのウォーカー除外を上書きします。名指ししたパスは直接の要求であるという rg の慣習です。そのため、プロジェクトが -X、--exclude-from、.bcaignore、あるいはマニフェストの exclude リストでスコープ外にしているファイルであっても、フックが名前で渡した瞬間に解析され、対処すべき問題として位置づける exit 2 のもとで違反として報告されます。
ウォーカーの除外は何が解析されるかを形作ります。check の除外は何がゲートされるかを形作ります。ファイル単位のフック呼び出しが尊重するのは後者だけです。
# bca.toml — 明示的なパスでも生き残るため、フックは CI と同じものを見る。
[check]
exclude = ["./utils/**", "./benches/**"]
明示的に名指しされたパスがウォーカー除外を上書きするたびに、bca は該当する glob を挙げて stderr に警告します。そのため配線ミスは沈黙せず可視化されます。
bca: warning: utils/gate.py matches an exclude pattern (./utils/**) but was named explicitly; analyzing anyway
その行は to-do として扱ってください。名指しされたエントリは [check] exclude への移動を求めています。本リポジトリも、まさにこの理由で自身の開発ツーリング用 glob をそこへ移しました。
フックの実行場所には制約が 1 つあります。#1164 が未解決のあいだ、パスが明示的に名指しされた場合、[check] exclude の glob はマニフェストルートではなく「作業ディレクトリ」を基準に解決されます。以下のフックはどちらもエージェントの作業ディレクトリ(プロジェクトルート)を継承するため影響を受けませんが、先にサブディレクトリへ cd するフックでは、その適用除外が一致しなくなります。
Claude Code
仕組み: .claude/settings.json 内の PostToolUse フック。matcher はファイル編集ツールにスコープします。
フィードバックチャネル: これはあらゆるエージェント型ツールの中で最も強力な適合です。PostToolUse フックは編集が反映された瞬間に発火し、テキストを直接モデルへ注入できます — メッセージを stderr に出して終了コード 2 で終了する(Claude は stderr を何が起きたかのコンテキストとして読みます)か、hookSpecificOutput.additionalContext を含む JSON を出力するかのいずれかです。フィードバックは編集境界そのものに届き、言うべきことが生じるまでトークンコストはゼロです。
.claude/settings.json にフックを配線します:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/bca-check.sh"
}
]
}
]
}
}
ラッパースクリプトは、フックの stdin に届く JSON から編集されたファイルのパスを読み取り、そのファイルだけに bca check を実行し、ゲートに引っかかったときに限り、違反リストとそれに続くエージェント向けガイダンスブロックを stderr に出力して 2 で終了します:
#!/usr/bin/env bash
# .claude/hooks/bca-check.sh — 編集後に、編集された 1 ファイルをゲートします。
set -euo pipefail
# PostToolUse はツール呼び出しを JSON として stdin に渡します。編集された
# ファイルのパスは、Edit/Write/MultiEdit では .tool_input.file_path です。
file_path="$(jq -r '.tool_input.file_path // empty')"
[ -n "$file_path" ] || exit 0 # チェック対象なし。何も出力しません。
[ -f "$file_path" ] || exit 0 # ファイルが消えています(削除など)。
# しきい値・ベースライン・除外設定はリポジトリルートの bca.toml から来ます。
# --no-summary / --no-remediation はフィードバックを違反行そのものに
# 絞ります。下のガイダンスがエージェントに何をすべきかを伝えます。
# 違反行は stdout に、診断があれば stderr に出るため、`2>&1` で実行が生んだ
# どちらの出力も捕捉できます。
status=0
report="$(bca check "$file_path" --no-summary --no-remediation 2>&1)" || status=$?
# bca check の終了コードは、クリーンなら 0、違反ありなら 2、ツールエラーなら 1。
# 設定や IO のエラーが「複雑度」と誤ラベルされないよう、2 に限定して分岐します。
case "$status" in
0) exit 0 ;; # クリーンな場合 ⇒ 何も言わない。
2) ;; # 違反がある場合 ⇒ 以下で報告する。
*) printf 'bca check could not run (exit %s):\n%s\n' "$status" "$report" >&2
exit 0 ;;
esac
# 終了コード 2 により、Claude は stderr を編集に関する文脈として読みます。
# `set -u` の下では未設定の変数がフックを中断させ、ファイルが無い場合は
# 案内文の代わりに素の `cat` エラーが出るため、CLAUDE_PROJECT_DIR に既定値を
# 与え、案内ファイルの存在を確認します。
guidance="${CLAUDE_PROJECT_DIR:-$PWD}/.claude/hooks/bca-guidance.txt"
cat >&2 <<EOF
bca flagged complexity in the file you just edited:
$report
$([ -f "$guidance" ] && cat "$guidance")
EOF
exit 2
エージェント向けガイダンスブロック を .claude/hooks/bca-guidance.txt に保存し、フックのフィードバックと CLAUDE.md が同一の文言を参照するようにします。(依存関係は jq のみで、ほとんどのエージェントイメージに同梱されており、なくても 1 行でインストールできます。)
exit 2 シグナルを使わずに助言的なコンテキストを注入したい場合は、stderr に書き込む代わりに stdout へ JSON を出力します:
jq -n --arg ctx "$offenders" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: $ctx
}
}'
exit 0
違反リストをメモとして Claude の視界に入れたい場合は additionalContext を、先へ進む前に対処すべき問題として提示したい場合は exit 2 を使います。どちらも編集はそのまま残ります — PostToolUse はツールの実行 後 に動作するため、いずれも編集を取り消すことはできません。
opencode
仕組み: プラグイン(hooks オブジェクトを返す非同期関数をエクスポートする JavaScript または TypeScript モジュール)で、ツール実行後(ファイル変更ツールの write と edit を含む)に発火する tool.execute.after フックを使用します。
フィードバックチャネル: after フックはthrow することで問題をエージェントに提示します。opencode の公開プラグインページには throw によるシグナルパターンは記載されていますが、after フック用の助言的な戻り値は記載されていないため、本レシピでは throw を既定とします — throw された Error のメッセージは、ツールの失敗としてエージェントに返されます。
引数の形に注意 — before フックとは異なります。 公開されている @opencode-ai/plugin の型定義では、tool.execute.after のシグネチャは (input, output) で、ツール名は input.tool、ツールの引数は input.args にあります(ファイルパスは input.args.filePath)。ここが罠です。ドキュメント唯一の実例は tool.execute.before のもので、そこでは引数が output.args(実行前で可変)にあります。それを after フックにコピーすると output.args は undefined になり、下記のガードが常に作動して、プラグインは一切実行されないまま沈黙します — インストール済みに見える no-op です。after フックでは input.args.filePath を読み取ってください。
このファイルを .opencode/plugins/ に配置します(プロジェクトレベルで自動読み込みされるため、opencode.json へのエントリは不要です。そのキーは npm 公開プラグイン用です)。以下のプラグインはプレーンな JavaScript です。TypeScript プラグインにする場合は import type { Plugin } from "@opencode-ai/plugin" してエクスポートに型注釈を付け、追加の依存関係は .opencode/package.json に宣言します(opencode が Bun でインストールします):
// .opencode/plugins/bca-check.js
const GUIDANCE = `
Responding to bca metric feedback: make the code genuinely simpler,
not the number smaller. Do not extract a meaningless helper or split a
cohesive function to dodge the count — a spurious helper often raises
file-level nom/nargs and helps nothing. If the complexity is essential
and the function is clearest left whole, add a suppression marker with
a one-line reason instead of contorting the code. Keep the fix in the
function that was flagged rather than widening it into a module
rewrite.
`.trim()
export const BcaCheck = async ({ $ }) => {
return {
// 注意: after フックの引数は `input.args` にあり、`output.args` ではない
// (output はツールの結果 title/output/metadata を保持する)。
"tool.execute.after": async (input, _output) => {
// ファイル書き込みツールにのみ反応する。(パッチ形式の編集ツールは
// 単一の filePath を持たないため、意図的に対象外とする。)
if (input.tool !== "write" && input.tool !== "edit") return
const filePath = input.args?.filePath
if (!filePath) return
// `bca check` はクリーンなら 0、違反があれば 2、ツールエラーなら 1 で終了する。
// Bun の $ はデフォルトで非ゼロ終了時に throw するため、正確なコードで
// 分岐できるようにキャプチャする。
const res = await $`bca check ${filePath} --no-summary --no-remediation`
.quiet()
.nothrow()
// 0 はクリーン、1 はツールエラーで、複雑度の問題ではない。`=== 2` ではなく
// `< 2` を使うことで、段階的終了コード(`--exit-codes=tiered` /
// `exit_codes = "tiered"` による 3-5)も報告される。
if (res.exitCode < 2) return
// throw して違反をエージェントに提示する。行は stdout にあり、
// stderr は唯一の出力が診断メッセージである実行のための
// フォールバック。
const offenders = res.stdout.toString().trim() || res.stderr.toString().trim()
throw new Error(`bca flagged complexity in ${filePath}:\n\n${offenders}\n\n${GUIDANCE}`)
},
}
}
GUIDANCE 文字列は、下記のそのまま使用するブロックと同期を保ってください(または共有ファイルから読み込みます)。チャネルが throw されたエラーであるため、opencode はこれを編集後ステップの失敗として報告します — これは意図どおりの「続行する前に対処せよ」という位置付けです。編集自体は反映されます。tool.execute.after は write / edit ツールがファイルを書き込み終えた後に実行されるため、throw は変更を取り消すことなく次のステップを方向付けます。
プラグイン追加後は opencode を再起動してください。 プラグインは起動時に一度だけ読み込まれ、ホットリロードされません。配置したばかりの .opencode/plugins/bca-check.js は実行中のセッションでは何もしません。opencode を終了して再起動し、しきい値を超えているとわかっているファイルを編集して、edit/write ツールが bca の失敗を報告することを確認してください。インストール済みなのに動かないプラグインは最もよくある症状で、古いセッションが最もよくある原因です。
堅牢化されたリファレンスとして、このリポジトリは独自のコピーを .opencode/plugins/bca-check.js に同梱しています。最小例では省かれている 3 つのガードが追加されており、いずれも実プロジェクトに移植する価値があります:
- リポジトリスコープガード。 パスを解決してプロジェクトルート外のものはスキップし、エージェントがディスク上の別の場所で編集したファイルに対してフックが
bcaを実行しないようにします。 - ローカルビルドの解決。
$BCA、次にチェックアウト内のtarget/release/bca、最後にPATH上のbcaの順で優先します。bcaを自前でビルドするプロジェクトは、グローバルにインストールされている何かではなく、自身のアナライザーでゲートできます。 - 共有ガイダンス。 このプラグインと Claude Code フックの両方が参照する 1 つのファイルからガイダンステキストを読み込み、両者が乖離しないようにします。
エージェント向けガイダンス(このまま使用する)
フィードバックチャネルはレシピの半分にすぎません。素の「cognitive 26 > 25」は、確実にメトリクスゲーミングの動きを誘発します — エージェントは関数ごとの数値を削るために意味的に空のヘルパーを抽出し、関数単位の複雑度を 下げ ながらファイル単位の nom/nargs を 上げ、コードを悪化させます。緩和策は、違反が何を意味し、どう対処すべきかをエージェントに伝えることです。このブロックをエージェントルールファイル(CLAUDE.md、opencode の AGENTS.md)とフックのフィードバックテキストの両方に貼り付け、恒常的なポリシーとしても違反の瞬間にも指示が存在するようにしてください:
**Responding to `bca` metric feedback.** A threshold violation
(cognitive, cyclomatic, ABC, …) means *this function is hard for a
human to follow*. The number is a proxy for that, not the goal. Your
job is to make the code genuinely simpler — not to make the number go
down.
- **Do not game the metric.** Do not extract a helper that exists only
to move complexity off one function, split a cohesive function at an
arbitrary line, collapse readable branches into a dense expression,
or inline/obfuscate logic to dodge the count. These lower the
per-function score while making the code worse — and a spurious
helper often *raises* file-level `nom`/`nargs`, so you have not even
helped the file.
- **Refactor only when it truly clarifies.** A good split has a name
that means something and a boundary a reader would have drawn anyway.
If you cannot name the extracted piece without inventing a
`foo_part2`, the split is gaming — stop.
- **When the complexity is essential, suppress with a reason.** Some
functions are irreducibly complex *and clearest left whole* — a
dispatch `match`, a hand-rolled parser table, an exhaustive state
machine. For these, do not contort the code: add a suppression marker
with the rationale on the same line —
`// bca: suppress(cognitive) — exhaustive opcode dispatch` — and move
on. A clear function with an honest marker is better than a
"compliant" tangle.
- **Keep the fix where the violation is.** The flag is scoped to the
function you just edited. Fix it there, mention anything larger you
noticed, and do not widen the change into a module rewrite to bring
the number down.
誠実な抑制(正確な構文)
上記のガイダンスは、抑制が正当な手段であることをエージェントに伝えます。それが機能するには、エージェントがマーカーを正しく綴る必要があります — マーカー構文は静かな no-op の頻出原因です。正確に教えてください(完全なリファレンス: 抑制マーカー):
- 関数ごと — 関数本体内のコメントにマーカーを置き、対象のメトリクスを列挙します:
// bca: suppress(cyclomatic, abc) — hand-rolled parser table。リストのない素の// bca: suppressは、その関数の「すべての」メトリクスを抑制します。 - ファイル全体 —
// bca: suppress-file(halstead, nargs, nexits)をファイル内の任意の場所に置きます。リストのない// bca: suppress-file形式は、ファイル全体ですべてのメトリクスを抑制します。 - **根拠はマーカーの行、メトリクスリストの後に書いてください。**リストの後はすべて自由テキストで区切りも不要なため、理由はフラグの立った関数を次に読む人の目に入る場所に置かれます。(「裸の」動詞は末尾テキストを一切取りません — そこには根拠とマーカーについての散文を区別するものが何もないため、メトリクスを名指しすることこそが理由を書く資格を買うのです。)毎回書いてください。レビュアーは — 人間でもエージェントでも — 誠実な適用除外と誤魔化しを見分ける必要があり、
bca exemptionsはまさにその監査のためにツリー内のすべてのマーカーを一覧します。 - 正規のメトリクス名を使ってください。 受け付けられる識別子は
abc、cognitive、cyclomatic、halstead、loc、mi、nargs、nexits、nom、npa、npm、wmcです。exitではなくnexitsです(レガシーのexitエイリアスは廃止されました)。未知の識別子は警告が出た上でスキップされ、併記された認識済みの名前は引き続き抑制されます。そのためsuppress(cognitive, exit)はcognitiveを抑制しつつexitについて警告します。tokensは意図的に抑制「不可」です。ハードなリソース上限として扱ってください。
編集ごとではなくタスク境界でゲートする
上記の編集ごとのフックは早期警告のための利便機能であり、ゲートではありません。ゲートは、人間がタスク完了を宣言する前に実行するのと同じチェック、すなわち 2 段構えの make self-scan / pre-commit パターン(ハード層は CI をミラーし、ソフト層は上限の 95% のヘッドルーム帯)です。エージェントには「完了と言う前」のステップとしてこれを指し示してください。
タスク境界より細かい粒度は、エージェントにとって価値が低いか、むしろ逆効果です。複雑度のしきい値は正しさのゲートではなくプロキシなので(下記の注意点を参照)、リファクタリング途中の あらゆる 微小編集の後に再実行しても、数編集後には自然に解消される一時的な違反が生まれるだけです — エージェントはそのノイズの「修正」に 1 ターンを浪費します。エージェントに一貫した変更を仕上げさせ、それから一度だけゲートしてください。
ルールファイルに書かないほうがよい指示が 1 つあります。フックの上に重ねて「修正を検証し、メトリクスを再チェックする」という常設の手順を置くことです。現在のモデルは、指示されなくても自分の編集を読み直し、再チェックします。明示的な指示はその挙動と重なり、入力が変わっていないゲートの再実行にループがターンを費やすことになります。編集が着地すれば、フックは自ら発火します。それを合図としてください。
注意点
本レシピが依存する 3 つの注意点です。無視すると、このループは益より害をもたらします。
- グッドハートの法則 / メトリクスゲーミング。 「この数値を小さくせよ」は「このコードを簡潔にせよ」と同じ指示ではなく、前者を告げられた LLM は最も安上がりな方法でそれを満たします — 通常は複雑さを取り除くのではなく、関数境界の向こうへ動かすだけです。エージェント向けガイダンスブロック と 誠実な抑制のセクション がその緩和策であり、これらは任意の飾りではなく構造上不可欠です。フックと一緒に出荷しなければ、ゲーミングの動きを覚悟してください。
- しきい値はプロキシであり、正しさのゲートではありません。 コンパイル失敗や赤いテストには曖昧さがなく、それらに対するタイトなエージェントループは収束します。複雑度のしきい値はもっと柔らかいものです。しきい値超過は「これがまだ読みやすいか人間が確認すべき」という意味であり、判断の問題であって欠陥ではありません。それに応じて期待値を設定してください — 複雑度フィードバックは、何としてもゼロに追い込むべき合否判定ではなく、エージェントが重み付けして考慮する助言として配線します。
- 複雑度フィードバックはスコープクリープを招きます。 「この関数は追いにくい」という指摘は、エージェントには再構成への誘いとして読まれます。そしてその再構成は、フックが名指しした関数で確実に止まるとは限りません — 2 行の修正が、誰もレビューを頼んでいないモジュール書き換えになります。ルールファイルで範囲を区切ってください。違反は、それを引き起こした編集に限定されます。指摘された場所で直し、それより大きな気づきがあれば言及するにとどめ、数字を追って変更を広げないでください。