SLOPSHOPPER

user-todos

Claude がユーザーに頼んだこと(今やる・あとで)をプロンプト上の帯に常に表示する

newbandguardtoolprocesstimer
★ 2v0.1.0no licenseupdated 2026-10-15gunubin/dotfiles/claude/.claude/mods/user-todos
A shopper browsing a rack in a slop shop
README

Dotfiles

macOS Homebrew Fish Shell Neovim Tmux

Terminal screenshot

My personal development environment managed with GNU Stow, Homebrew, and Make.

Features

  • GNU Stow — Symlink-based dotfile management
  • Brewfile — Declarative package management via Homebrew Bundle
  • Makefile — One-command setup and teardown

Packages

Shell & Prompt

PackageDescription
FishUser-friendly interactive shell
StarshipCross-shell prompt

Terminal

PackageDescription
GhosttyGPU-accelerated terminal emulator
iTerm2macOS terminal emulator
tmuxTerminal multiplexer
workmuxGit worktree + tmux workflow manager

Editor & IDE

PackageDescription
NeovimHyperextensible text editor
IntelliJ IDEAJetBrains IDE settings

CLI Tools

PackageDescription
batA cat clone with syntax highlighting
ezaA modern replacement for ls
yaziTerminal file manager
Claude CodeAI coding assistant

System

PackageDescription
GitVersion control configuration
Karabiner-ElementsmacOS keyboard customizer (config generated via karabiner.ts in karabiner-config/)

Prerequisites

Installation

Quick Start

git clone https://github.com/gunubin/dotfiles.git
cd dotfiles
make install

Individual Commands

CommandDescription
make installCreate symlinks for all packages
make brew-bundleInstall Homebrew dependencies from Brewfile
make updateRe-sync all symlinks (clean + install)
make cleanRemove all symlinks

Git Configuration

User-specific Git settings (name, email) should be stored in ~/.gitconfig.local, which is not tracked by this repository.

[user]
    name = YOUR_NAME
    email = YOUR_EMAIL
Source 2 files
hooks/register.tsx 253 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Todo, TodoWhen } from '../types'
5
6const PLUGIN = 'user-todos'
7const TOOL_ADD = 'mcp__user-todos__add'
8const TOOL_DONE = 'mcp__user-todos__done'
9const TOOL_MOVE = 'mcp__user-todos__move'
10const TOOL_LIST = 'mcp__user-todos__list'
11// 「あとで」は全セッション共通なので $.store に置き、帯の描画用に $.state へ写す
12const LATER_STORE_KEY = 'later'
13// 他のセッションが「あとで」を書き換えても帯に反映されるよう、定期的に読み直す
14const SYNC_MS = 30_000
15
16const now = atom({ plugin: 'user-todos', key: 'now' } as const, [] as Todo[])
17const later = atom({ plugin: 'user-todos', key: 'later' } as const, [] as Todo[])
18// ターミナルの配色(Catppuccin Mocha)に揃えたパステルの黄色
19const NOW_COLOR = '#f9e2af'
20const isLaterOpen = atom({ plugin: 'user-todos', key: 'isLaterOpen' } as const, false)
21
22const WHEN_SCHEMA = {
23  type: 'string',
24  enum: ['now', 'later'],
25  description: 'now: 今の作業を進めるのにユーザーの行動が要る / later: 今すぐではないが、あとでユーザーがやること',
26}
27
28const isTodo = (value: unknown): value is Todo =>
29  typeof value === 'object' &&
30  value !== null &&
31  typeof (value as Todo).id === 'string' &&
32  typeof (value as Todo).text === 'string'
33
34const loadLater = async ($: EngineInterface): Promise<Todo[]> => {
35  const stored = await $.store.get(LATER_STORE_KEY)
36  return Array.isArray(stored) ? stored.filter(isTodo) : []
37}
38
39// 他のセッションの書き込みを消さないよう、書き換えのたびに $.store から読み直してから書く
40const changeLater = async ($: EngineInterface, fn: (list: Todo[]) => Todo[]): Promise<Todo[]> => {
41  const next = fn(await loadLater($))
42  await $.store.set(LATER_STORE_KEY, next)
43  await update($, later, () => next)
44  return next
45}
46
47const syncLater = async ($: EngineInterface) => {
48  const stored = await loadLater($)
49  await update($, later, () => stored)
50}
51
52const repoName = async ($: EngineInterface): Promise<string> => {
53  const cwd = await $.session.cwd()
54  try {
55    const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
56    const root = exitCode === 0 ? stdout.trim() : ''
57    return (root || cwd).split('/').pop() ?? cwd
58  } catch {
59    return cwd.split('/').pop() ?? cwd
60  }
61}
62
63const formatList = (nowList: Todo[], laterList: Todo[]) => {
64  const lines = (list: Todo[]) => (list.length === 0 ? ['  (なし)'] : list.map(todo => `  - ${todo.id}: ${todo.text}`))
65  return ['今やる:', ...lines(nowList), 'あとで:', ...lines(laterList)].join('\n')
66}
67
68const findIn = async ($: EngineInterface, id: string): Promise<TodoWhen | undefined> => {
69  if ((await read($, now)).some(todo => todo.id === id)) return 'now'
70  if ((await loadLater($)).some(todo => todo.id === id)) return 'later'
71  return undefined
72}
73
74const remove = async ($: EngineInterface, id: string) => {
75  await update($, now, list => list.filter(todo => todo.id !== id))
76  await changeLater($, list => list.filter(todo => todo.id !== id))
77}
78
79// 同じミリ秒に複数登録されても id が重ならないよう、連番を足す
80let sequence = 0
81const newId = async ($: EngineInterface) => {
82  sequence += 1
83  return `${(await $.clock.now()).toString(36)}${sequence.toString(36)}`
84}
85
86export const register: Register = on => {
87  on('session.start', async ($, e, next) => {
88    await syncLater($)
89    $.clock.every(SYNC_MS, () => void syncLater($))
90
91    await $.tool.register({
92      name: 'add',
93      description:
94        'ユーザーにやってもらうことを、プロンプト上の帯に登録して常に見えるようにする。' +
95        '確認・動作確認・判断・外部の連絡待ちなど、ユーザーが何かしないと作業が進まないときは、返答で頼むのと同時に必ず when=now で登録する。' +
96        '今すぐではないがあとでユーザーがやること(後回しにした作業、期日のある対応など)は when=later で登録する(全セッション・全リポジトリ共通で残る)。' +
97        'text は「誰が・何をしたら再開できるか」がわかる1文にする。機密情報(APIキー、顧客名、社内URL)は書かない。',
98      inputSchema: {
99        type: 'object',
100        properties: {
101          text: { type: 'string', description: 'ユーザーにやってもらうこと(1文)' },
102          when: WHEN_SCHEMA,
103        },
104        required: ['text', 'when'],
105      },
106      isDeferred: false,
107    })
108    await $.tool.register({
109      name: 'done',
110      description:
111        '帯に登録した項目を完了にして消す。ユーザーがやったと言った、または結果から済んだとわかったときに呼ぶ。' +
112        'ユーザーが返事をしただけで、頼んだことが済んでいなければ消さない。',
113      inputSchema: {
114        type: 'object',
115        properties: { id: { type: 'string', description: 'add / list が返した項目の id' } },
116        required: ['id'],
117      },
118      isDeferred: false,
119    })
120    await $.tool.register({
121      name: 'move',
122      description: '項目を「今やる」と「あとで」の間で移す。ユーザーが後回しにすると言ったときや、あとでの項目に今取りかかるときに呼ぶ。',
123      inputSchema: {
124        type: 'object',
125        properties: {
126          id: { type: 'string', description: 'add / list が返した項目の id' },
127          to: WHEN_SCHEMA,
128        },
129        required: ['id', 'to'],
130      },
131      isDeferred: false,
132    })
133    await $.tool.register({
134      name: 'list',
135      description: '帯に登録されている「今やる」と「あとで」の項目を id 付きで返す。',
136      inputSchema: { type: 'object', properties: {} },
137      isDeferred: false,
138    })
139
140    return next(e)
141  })
142
143  // ツールの引数は e.input ではなく e の直下に載る(tool / tool_use_id / agentId は予約キー)
144  on('tool.call', { tool: TOOL_ADD }, async ($, e) => {
145    const { text, when } = e as unknown as { text: string; when: TodoWhen }
146    const todo: Todo = {
147      id: await newId($),
148      text: text.trim(),
149      repo: await repoName($),
150      createdAt: new Date(await $.clock.now()).toISOString(),
151    }
152    if (when === 'later') {
153      await changeLater($, list => [...list, todo])
154    } else {
155      await update($, now, list => [...list, todo])
156    }
157    return { result: `${when === 'later' ? 'あとで' : '今やる'}に登録しました(id: ${todo.id})。` }
158  })
159
160  on('tool.call', { tool: TOOL_DONE }, async ($, e) => {
161    const { id } = e as unknown as { id: string }
162    if ((await findIn($, id)) === undefined) {
163      return { result: `id ${id} の項目はありません。\n${formatList(await read($, now), await loadLater($))}` }
164    }
165    await remove($, id)
166    return { result: `${id} を完了にしました。` }
167  })
168
169  on('tool.call', { tool: TOOL_MOVE }, async ($, e) => {
170    const { id, to } = e as unknown as { id: string; to: TodoWhen }
171    const from = await findIn($, id)
172    if (from === undefined) {
173      return { result: `id ${id} の項目はありません。\n${formatList(await read($, now), await loadLater($))}` }
174    }
175    if (from === to) {
176      return { result: `${id} はすでに${to === 'later' ? 'あとで' : '今やる'}にあります。` }
177    }
178    const todo = (from === 'now' ? await read($, now) : await loadLater($)).find(one => one.id === id)
179    if (todo === undefined) {
180      return { result: `id ${id} の項目はありません。` }
181    }
182    await remove($, id)
183    if (to === 'later') {
184      await changeLater($, list => [...list, todo])
185    } else {
186      await update($, now, list => [...list, todo])
187    }
188    return { result: `${id} を${to === 'later' ? 'あとで' : '今やる'}に移しました。` }
189  })
190
191  on('tool.call', { tool: TOOL_LIST }, async $ => {
192    await syncLater($)
193    return { result: formatList(await read($, now), await read($, later)) }
194  })
195
196  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
197    const nowList = await read($, now)
198    const laterList = await read($, later)
199    if (e.props.hasSurvey || (nowList.length === 0 && laterList.length === 0)) {
200      return next(e)
201    }
202
203    const { Box, Button, Text } = $.ui.resolve(e)
204    const isOpen = await read($, isLaterOpen)
205    const row = (todo: Todo, actions: 'now' | 'later') => (
206      <Box key={`row-${todo.id}`} flexDirection="row">
207        <Button key={`done-${todo.id}`} label="✓" onPress={() => remove($, todo.id)} />
208        <Text> </Text>
209        {actions === 'later' && (
210          <Button
211            key={`up-${todo.id}`}
212            label="今やる"
213            onPress={async () => {
214              await remove($, todo.id)
215              await update($, now, list => [...list, todo])
216            }}
217          />
218        )}
219        <Text wrap="truncate-end">
220          {actions === 'later' ? ' ' : ''}
221          {todo.text}
222          {actions === 'later' && <Text dimColor> {todo.repo}</Text>}
223        </Text>
224      </Box>
225    )
226
227    return (
228      <Box flexDirection="column">
229        {nowList.length > 0 && (
230          <Text bold color={NOW_COLOR}>
231            ▶ Your turn ({nowList.length})
232          </Text>
233        )}
234        {nowList.map(todo => row(todo, 'now'))}
235        {laterList.length > 0 && (
236          <Box flexDirection="row">
237            <Button
238              key="toggle-later"
239              plain
240              onPress={() => update($, isLaterOpen, open => !open)}
241            >
242              <Text dimColor>
243                {isOpen ? '▼' : '▶'} Later ({laterList.length})
244              </Text>
245            </Button>
246          </Box>
247        )}
248        {isOpen && laterList.map(todo => row(todo, 'later'))}
249      </Box>
250    )
251  })
252}
253
types/index.d.ts 10 lines
1export type TodoWhen = 'now' | 'later'
2
3export type Todo = { id: string; text: string; repo: string; createdAt: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'user-todos': { now: Todo[]; later: Todo[]; isLaterOpen: boolean }
8  }
9}
10