Counts observed tool-call attempts without storing arguments or calling a model

用不到 40 行 hooks module 示範 observe + 自訂 command + 記憶體 state。這是依官方 Create a mod 與測試 API 撰寫的原創教學範例,不是官方 sample 的完整移植,不宣稱有生產力收益。
/research-tally:列出自 module 載入或上次 reset 以來,到達本 hook 的 tool.call 嘗試,按工具名稱排序。/research-tally reset:清除計數。其他參數顯示 usage、不清除。next(e) 一次,回傳下游結果;不批准、不拒絕、不重試工具。不是成功執行次數。 下游拒絕的呼叫仍計數;在上游被攔截、沒有傳到此 hook 的呼叫不計。只要事件送達就計入,沒有主 agent/subagent 拆分;不能當完整稽核、billing 或成功率資料。
Module reload/新 session 重置記憶體;這裡沒有使用 host $.state 或 $.store 保留資料。它也不以 /clear 作為計數邊界;要明確重新計數請用 reset。工具名稱本身也可能透露整合名稱,分享輸出前仍需檢查。
需要支援 Mods 的 Claude Code;官方最低版本是 2.1.287,本範例在 2.1.289、macOS arm64 驗證。沒有 npm dependencies 或 install script;執行以下命令不會安裝此 plugin:
# 從研究 repo 根目錄執行
claude plugin validate --strict examples/research-tally
claude plugin test examples/research-tally
2026-10-05 本機實際結果:
hooks: session.start, tool.call, command.run{command=research-tally}
calls: $.command.register
Validation passed
6 pass
0 fail
Ran 6 tests across 1 file.
測試先在空的 register 實作上得到 0 pass/6 fail,再加入行為得到上述結果。測試涵蓋:command 註冊與 session 轉交、零次與重複查詢、Read 2 次/Bash 1 次的不對稱計數、下游 denial 保留、reset、不明參數與無關 command 不被攔截。
測試工具呼叫都有 stub,不會真的讀檔或執行 Bash。 驗證的是 Mod 處理事件的邏輯;不是實際模型工作、完整權限引擎或跨平臺驗證。本次沒有在互動 session/Desktop 載入它,也沒有進行 headless 真實 session smoke test;未測過的部分不標示通過。
以下是讀者可自行執行的步驟,不是本次已執行紀錄。Mod 沒有 sandbox;只在你信任的環境載入。
claude --plugin-dir ./examples/research-tally
在該 Claude Code session 先輸入 /research-tally,應顯示 0;要求它做少量唯讀工作後再查詢,數量依真正到達 hook 的工具呼叫而定;最後試 /research-tally reset。熱重載也會讓記憶體計數歸零。
沒有額外 installation;離開 session、下次不傳 --plugin-dir,就不會從這條路徑載入它。未提供 marketplace,避免把研究 clone 變成自動安裝來源。
本範例刻意不改 permission、不讀完整 tool results。如果要延伸成 masking、policy 或持久 log,先重新設計資料與權限邊界,補失敗/重入/多 Mod 順序測試;不能把計數測試通過當成安全功能已驗證。
hooks/register.js 35 lines1// Only tool names and counts are retained, never arguments or results.
2const counts = new Map()
3
4export function register(on) {
5 on('session.start', async ($, e, next) => {
6 await $.command.register({
7 name: 'research-tally',
8 description: 'Show observed tool-call attempts; use reset to clear the counter',
9 })
10 return next(e)
11 })
12
13 on('tool.call', async ($, e, next) => {
14 counts.set(e.tool, (counts.get(e.tool) ?? 0) + 1)
15 return next(e)
16 })
17
18 on('command.run', { command: 'research-tally' }, async ($, e) => {
19 const action = e.args.trim()
20 if (action === 'reset') {
21 counts.clear()
22 return { text: 'Counter reset. Observed tool-call attempts: 0' }
23 }
24 if (action !== '') {
25 return { text: 'Usage: /research-tally [reset]' }
26 }
27
28 const total = [...counts.values()].reduce((sum, count) => sum + count, 0)
29 const lines = [...counts.entries()]
30 .sort(([left], [right]) => left.localeCompare(right))
31 .map(([tool, count]) => tool + ': ' + count)
32 return { text: ['Observed tool-call attempts: ' + total, ...lines].join('\n') }
33 })
34}
35