A daily work log written by itself: turn.start remembers the prompt, tool.call counts the tools it used, and turn.complete appends one Markdown line per turn…

1 ターンにつき 1 行の作業ログを、毎日の Markdown ファイルに自動で書き溜める Mod。
turn.start でターン ID とプロンプト本文を覚える。tool.call (マッチャなし) でツール名ごとの回数を数える。await next(e) を先に 実行してから数えるので、拒否 (deny) とエラー (isError) も結果から拾える。 ツールの引数は一切読まない — 名前と結果だけ。turn.complete で <logDir>/YYYY-MM-DD.md に 1 行追記する:# 2026-09-21
- 01:07:01 (6.2s, answer) Bash x2 — Run the bash command: echo work-log-proof . Then run it a se
- 01:07:36 (16.4s, answer) Glob, PowerShell, 1 errored — C:/Program Files/Git/worklog
時刻 / 所要時間 / 終了理由 / ツール内訳 / プロンプト先頭 60 文字。
session.start で /worklog を $.command.register し、command.run で 今日のファイルの中身を返す (無ければ work-log: no log yet (<path>))。plugin.json の userConfig.logDir で変えられる。空のままなら <プラグインのディレクトリ>/worklog。設定値は register(on, options) の 第 2 引数に届く。届く経路はユーザー設定 / --settings / 管理設定のみで、 プロジェクト設定は読まれない。
{ "pluginConfigs": { "work-log@inline": { "options": { "logDir": "D:/logs" } } } }
claude --plugin-dir C:/Users/lifes/kasegu/mods/work-log
ヘッドレスでも動く。スラッシュコマンドも -p から実行できる (Git Bash では 先頭スラッシュがパスに変換されるので MSYS_NO_PATHCONV=1 が要る):
MSYS_NO_PATHCONV=1 claude -p "/worklog" --plugin-dir <この Mod> --output-format text
❯ ./register.ts hooks: session.start, turn.start, tool.call, turn.complete, command.run{command=worklog}
❯ ./register.ts calls: $.command.register, $.fs.exists, $.fs.read, $.fs.write
✔ Validation passed
tool.call をマッチャなしで取るため、すべてのツール呼び出しが見える。ただし 記録するのはツール名と成否だけで、引数も出力もログには残らない。書き込みは logDir 配下のみ。ネットワークには一切触れない。
claude plugin test .
ターン行の生成 (ツール回数と deny の計上) と、/worklog の 2 状態を検証する。
hooks/register.ts 91 lines1import type { Register } from 'claude-code'
2
3/** What one turn accumulated between `turn.start` and `turn.complete`. */
4type Turn = { text: string; tools: Record<string, number>; denied: number; errored: number }
5
6const pad = (n: number): string => String(n).padStart(2, '0')
7
8/** Local calendar day, the log file's name. */
9const dayOf = (ms: number): string => {
10 const d = new Date(ms)
11 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
12}
13
14/** Local wall-clock time, the first column of a line. */
15const timeOf = (ms: number): string => {
16 const d = new Date(ms)
17 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
18}
19
20/** `Bash x2, Read` — the tools of one turn, busiest first. */
21const toolsOf = (tools: Record<string, number>): string =>
22 Object.entries(tools)
23 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
24 .map(([name, n]) => (n > 1 ? `${name} x${n}` : name))
25 .join(', ') || 'no tools'
26
27export const register: Register = (on, options) => {
28 // The manifest's userConfig arrives here; an empty string means "use my own
29 // directory", which needs $.plugin.root and so waits for a hook to run.
30 const configured = typeof options.logDir === 'string' ? options.logDir.trim() : ''
31 let dir = configured
32
33 const turns = new Map<string, Turn>()
34 // tool.call carries no turnId, so tools land on the turn that started last.
35 let current: string | null = null
36
37 on('session.start', async ($, e, next) => {
38 const r = await next(e)
39 if (dir === '') dir = `${$.plugin.root}/worklog`
40 await $.command.register({ name: 'worklog', description: "Print today's work log (work-log)" })
41 return r
42 }).catch(($, e, next) => (next.called ? undefined : next(e)))
43
44 on('turn.start', async ($, e, next) => {
45 const r = await next(e)
46 current = e.turnId
47 turns.set(e.turnId, { text: e.text, tools: {}, denied: 0, errored: 0 })
48 return r
49 }).catch(($, e, next) => (next.called ? undefined : next(e)))
50
51 // No matcher: this counts every tool, so it sees every call. It reads the
52 // name and the outcome only, never a tool's arguments.
53 // To leave subagents out of the main turn's tally: if (e.agentId !== undefined) return r
54 on('tool.call', async ($, e, next) => {
55 const r = await next(e)
56 const turn = current === null ? undefined : turns.get(current)
57 if (turn !== undefined) {
58 turn.tools[e.tool] = (turn.tools[e.tool] ?? 0) + 1
59 if (r.deny !== undefined) turn.denied += 1
60 else if (r.isError === true) turn.errored += 1
61 }
62 return r
63 }).catch(($, e, next) => (next.called ? undefined : next(e)))
64
65 on('turn.complete', async ($, e, next) => {
66 const r = await next(e)
67 const turn = turns.get(e.turnId)
68 turns.delete(e.turnId)
69 if (turn !== undefined && dir !== '') {
70 const now = Date.now()
71 const flags = [turn.denied > 0 ? `${turn.denied} denied` : '', turn.errored > 0 ? `${turn.errored} errored` : '']
72 const line =
73 `- ${timeOf(now)} (${(e.durationMs / 1000).toFixed(1)}s, ${e.reason}) ` +
74 `${toolsOf(turn.tools)}${flags.filter(Boolean).map(f => `, ${f}`).join('')} ` +
75 `— ${turn.text.replace(/\s+/g, ' ').slice(0, 60)}\n`
76 const path = `${dir}/${dayOf(now)}.md`
77 const prior = (await $.fs.exists(path)) ? await $.fs.read(path) : `# ${dayOf(now)}\n\n`
78 await $.fs.write(path, `${prior}${line}`)
79 }
80 return r
81 }).catch(($, e, next) => (next.called ? undefined : next(e)))
82
83 // The engine prefixes this plugin's name to the text, so the text must not
84 // repeat it (see the third proof in section 5).
85 on('command.run', { command: 'worklog' }, async $ => {
86 const path = `${dir}/${dayOf(Date.now())}.md`
87 if (dir === '' || !(await $.fs.exists(path))) return { text: `no log yet (${path})` }
88 return { text: await $.fs.read(path) }
89 }).catch(($, e, next) => (next.called ? undefined : { text: 'could not read the log' }))
90}
91