SLOPSHOPPER

repeat-call-notice

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

newtoast
v0.1.0no licenseupdated 2026-10-10dtakamiya/claude-code-mods/mods/repeat-call-notice
A shopper browsing a rack in a slop shop
README

claude-code-mods

Claude Code の Mods(関数フック型プラグイン)を作って配布するリポジトリ。1リポジトリ = 1マーケットプレイス、mods/<name>/ が1 mod。

収録 mod

mod内容コマンド
context-doctorコンテキストの内訳を分析し、課題点と改善点を出す(読み取り専用)/ctx
session-recapツール別の呼び出し回数とエラー報告件数を一覧にする(読み取り専用・tool 名の集計のみ、input は出力しない)/recap
repeat-call-notice1 ターンで同じツールを同じ入力で N 回以上呼んだら、ターン終了時にトーストで知らせる(読み取り専用・tool 名と回数のみ、input は出力しない。N=3 は仮置き)トースト

公開範囲(private リポジトリ)

このリポジトリは現在 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。

Source 2 files
hooks/register.ts 19 lines
1import 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}
19
hooks/analyze.ts 71 lines
1// 根拠なしの仮置き(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