1 ターンで同じツールを同じ入力で繰り返し呼んだことを、ターン終了時にトーストで知らせる(読み取り専用)

Claude Code の Mods(関数フック型プラグイン)を作って配布するリポジトリ。1リポジトリ = 1マーケットプレイス、mods/<name>/ が1 mod。
| mod | 内容 | コマンド |
|---|---|---|
context-doctor | コンテキストの内訳を分析し、課題点と改善点を出す(読み取り専用) | /ctx |
session-recap | ツール別の呼び出し回数とエラー報告件数を一覧にする(読み取り専用・tool 名の集計のみ、input は出力しない) | /recap |
repeat-call-notice | 1 ターンで同じツールを同じ入力で N 回以上呼んだら、ターン終了時にトーストで知らせる(読み取り専用・tool 名と回数のみ、input は出力しない。N=3 は仮置き) | トースト |
このリポジトリは現在 private です。リポジトリへのアクセス権(GitHub 上で閲覧できる権限)がある人だけが取得できます。公開するかどうかは未判断で、判断の経緯は docs/design/open-questions.md の Q-001 を参照してください。
以下の手順は、リポジトリへのアクセス権があり、GitHub 認証済みの利用者向けです。
/plugin install context-doctor --marketplace dtakamiya/claude-code-mods
y でマーケットプレイスを追加 → スコープを選択。terminal セッションのプロンプトで実行する。
Claude Code 2.1.296(validate / test / tsc 通過。UI の目視確認は未実施)。Mods API は early access のため、バージョンが変わったら再検証する。
Mods はサンプルボックスなしで利用者の権限のまま動く。このリポジトリの mod は既定で読み取り専用。$.process.run・$.fs 書込・外部ネットワークを使う mod は、使う理由を各 mod の README に書く。
claude plugin validate mods/<name>
claude plugin test mods/<name>
claude --plugin-dir mods/<name> # 目視確認
詳細は CLAUDE.md。
hooks/register.ts 19 lines1import type { Register } from 'claude-code'
2
3import { findRepeats, formatNotice } from './analyze'
4
5export const register: Register = on => {
6 on('turn.complete', async ($, e, next) => {
7 // サブエージェントのターンでは主会話を誤集計するので何もしない。
8 if (e.agentId !== undefined) return next(e)
9 try {
10 const repeats = findRepeats(await $.session.messages())
11 if (repeats.length > 0) $.ui.toast(formatNotice(repeats))
12 } catch {
13 // 固定文言のみ。例外の中身(入力由来の文字列を含みうる)は出さない。
14 $.ui.log('repeat-call-notice: 判定に失敗したため通知を省略した', { to: 'debug' })
15 }
16 return next(e)
17 })
18}
19hooks/analyze.ts 71 lines1// 根拠なしの仮置き(docs/design/repeat-call-notice.md §4)。
2export const REPEAT_THRESHOLD = 3
3export const MAX_SHOWN_GROUPS = 3
4
5export type Repeat = { tool: string; count: number }
6
7// SessionMessage のうち読む部分だけ。result / text / isError は型の上でも読まない。
8type MessageLike = {
9 role: 'user' | 'assistant'
10 toolUses: readonly { tool: string; input: unknown }[]
11 toolResults?: readonly unknown[]
12}
13
14// キー順に依存しない JSON 文字列。比較専用で、戻り値・通知には出さない。
15const canonicalize = (value: unknown): unknown => {
16 if (Array.isArray(value)) return value.map(canonicalize)
17 if (value !== null && typeof value === 'object') {
18 const record = value as Record<string, unknown>
19 return Object.fromEntries(
20 Object.keys(record)
21 .sort()
22 .map(key => [key, canonicalize(record[key])]),
23 )
24 }
25 return value
26}
27
28// 正規化できない入力は null(他と一致しない 1 件として扱う)。例外の中身は読まない。
29const canonicalKey = (tool: string, input: unknown): string | null => {
30 try {
31 return JSON.stringify([tool, canonicalize(input)])
32 } catch {
33 return null
34 }
35}
36
37// 末尾から遡り、toolResults を持たない最後の user メッセージの次からをこのターンとする。
38const currentTurn = <T extends MessageLike>(messages: readonly T[]): readonly T[] => {
39 for (let i = messages.length - 1; i >= 0; i--) {
40 const m = messages[i]
41 if (m.role === 'user' && !(m.toolResults && m.toolResults.length > 0)) {
42 return messages.slice(i + 1)
43 }
44 }
45 return messages
46}
47
48export const findRepeats = (
49 messages: readonly MessageLike[],
50 threshold: number = REPEAT_THRESHOLD,
51): Repeat[] => {
52 const groups = new Map<string, Repeat>()
53 for (const message of currentTurn(messages)) {
54 for (const use of message.toolUses ?? []) {
55 const key = canonicalKey(use.tool, use.input)
56 if (key === null) continue
57 const found = groups.get(key)
58 groups.set(key, { tool: use.tool, count: (found?.count ?? 0) + 1 })
59 }
60 }
61 // Map は挿入順(初出順)。sort は安定なので同数は初出順のまま。
62 return [...groups.values()].filter(g => g.count >= threshold).sort((a, b) => b.count - a.count)
63}
64
65export const formatNotice = (repeats: readonly Repeat[]): string => {
66 const shown = repeats.slice(0, MAX_SHOWN_GROUPS).map(r => `${r.tool} ×${r.count}`)
67 const rest = repeats.length - MAX_SHOWN_GROUPS
68 if (rest > 0) shown.push(`ほか ${rest} 組`)
69 return `同じ呼び出しの反復: ${shown.join('、')}`
70}
71