SLOPSHOPPER

work-log

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…

newguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-log
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /worklog ⎿ work-log: no log yet (/plugins/work-log/worklog/2026-10-05.md) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

work-log

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

手が届く範囲 (Claude Code 2.1.278 で検証済み)

❯ ./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 状態を検証する。

Source 1 files
hooks/register.ts 91 lines
1import 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