Google Tasksのagents / main / big / repeat / projectを横のパネルに出し、agentsのタスクはボタンで依頼文を入力欄に入れる

Obsidian の保管庫を「AI と共有する自分のナレッジレイク」にして、作業の多くを AI(Claude Code / Codex)に任せられるようにするための一式。 作者が自分の保管庫で実際に運用している仕組みから、個人の中身を抜いて汎用化したもの。
| フォルダ | 何か | 最初に読むもの |
|---|---|---|
knowledge-lake-kit/ | 保管庫の器。 フォルダ構成、AI への指示書(AGENTS.md)、各フォルダの README、テンプレート、運用の考え方 | START_HERE.md(人間)/ SETUP_FOR_AI.md(AI) |
agent-tooling/ | AI に持たせる道具。 スキル(/inbox・/prioritize など)、MCP(Gmail 横断・Google Tasks)、Claude Code の mod、フック、CLI、定期実行 | README.md(人間)/ FOR_AI.md(AI) |
git clone https://github.com/KaiMiyazawa/obsidian-ai-knowledge-lake.git
knowledge-lake-kit/START_HERE.md を上から順に進める。最後は AI がインタビューしながら保管庫を組み立てる。<クローンした場所>/agent-tooling/FOR_AI.md を読んで、この道具を私の保管庫に入れるのを手伝ってどちらも「AI が読んで、利用者に確認しながら進める」前提で書いてある。全部を一度に入れなくてよい。
you@gmail.com、<MAIN_LIST_ID> など)にしてある。MIT。ただし agent-tooling/outside-vault/gtasks-mcp/ は alvinjchoi/gtasks-mcp(MIT)を改造したもので、同フォルダの LICENSE に従う。
hooks/register.tsx 230 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardMessage, BoardProps, Task, TaskLists } from '../types'
5
6const PANE = 'tasks'
7const SERVER = 'gtasks'
8const REFRESH_MS = 10 * 60 * 1000
9
10// AGENTS.md「タスクリストの使い分け」。agentsはAIに投げればよいもの、ほかは本人が動くもの。
11const LISTS = [
12 { key: 'agents', hotkey: 'a', id: '<AGENTS_LIST_ID>' },
13 { key: 'main', hotkey: 'm', id: '<MAIN_LIST_ID>' },
14 { key: 'big', hotkey: 'b', id: '<BIG_LIST_ID>' },
15 { key: 'repeat', hotkey: 'r', id: '<REPEAT_LIST_ID>' },
16 // 一時的な用件専用のリスト。用件が片付いたらリストごと削除し、この行も消す。
17 { key: 'project', hotkey: 'i', id: '<PROJECT_LIST_ID>' },
18] as const
19
20const lists = atom({ plugin: 'tasks-pane', key: 'lists' } as const, {})
21const selected = atom({ plugin: 'tasks-pane', key: 'selected' } as const, 'agents')
22const fetchedAt = atom({ plugin: 'tasks-pane', key: 'fetchedAt' } as const, null)
23const failure = atom({ plugin: 'tasks-pane', key: 'error' } as const, null)
24
25// gtasks の list_with_tree の1行。子タスクは字下げされ、末尾に " parent:<id>" が付く。
26// " [ ] タイトル (due: 2026-10-19T00:00:00.000Z) | id:xxx parent:yyy"
27const LINE = /^(\s*)\[( |x)\] (.*?)(?: \(due: (\d{4}-\d{2}-\d{2})[^)]*\))? \| id:(\S+)(?: parent:\S+)?\s*$/
28
29const parse = (text: string): Task[] =>
30 text.split('\n').flatMap(line => {
31 const m = line.match(LINE)
32 if (!m || m[2] === 'x') return []
33 const [, indent = '', , title = '', due, id = ''] = m
34 return [{ depth: Math.floor(indent.length / 2), title, due: due ?? null, id }]
35 })
36
37// $.mcp.call は gtasks を「未接続」と判定することがある(2026-10-05)。そのときはモデルと同じツール呼び出しで取る。
38const fetchTree = async ($: EngineInterface, taskListId: string, key: string) => {
39 const direct = await $.mcp.call(SERVER, 'list_with_tree', { taskListId }).catch(() => undefined)
40 if (direct !== undefined && !direct.isError) {
41 return direct.content.map(b => (b.type === 'text' ? b.text : '')).join('\n')
42 }
43 const viaTool = await $.tool.call({ tool: `mcp__${SERVER}__list_with_tree`, taskListId } as never)
44 if (viaTool.deny !== undefined || viaTool.isError === true || viaTool.text === undefined) {
45 throw new Error(`${key}の取得に失敗(${viaTool.deny ?? viaTool.text ?? '応答なし'})`)
46 }
47 return viaTool.text
48}
49
50// 一度でも取れたか。起動直後の取り直しと、最初のプロンプトでの取り直しを止めるのに使う。
51let hasFetched = false
52
53// quiet のときは失敗をパネルに出さない(起動直後の再試行中は「読み込み中…」のままにする)。
54const refresh = async ($: EngineInterface, { quiet = false } = {}) => {
55 try {
56 const next: TaskLists = {}
57 for (const list of LISTS) {
58 next[list.key] = parse(await fetchTree($, list.id, list.key))
59 }
60 await update($, lists, () => next)
61 await update($, failure, () => null)
62 await update($, fetchedAt, () => Date.now())
63 hasFetched = true
64 return true
65 } catch (err) {
66 if (!quiet) await update($, failure, () => String(err instanceof Error ? err.message : err))
67 return false
68 }
69}
70
71// 起動直後は gtasks の MCP がまだ繋がっておらず、ツールも未登録で失敗する(2026-10-13 確認)。
72// 接続完了を知らせるイベントは無いので、間隔を倍にしながら約2分取り直す。最後の1回だけ失敗を表示する。
73const RETRY_MS = [2000, 4000, 8000, 16000, 32000, 60000]
74
75const fetchOnStart = async ($: EngineInterface, attempt = 0): Promise<void> => {
76 if (hasFetched) return
77 const isLast = attempt >= RETRY_MS.length
78 if ((await refresh($, { quiet: !isLast })) || isLast) return
79 $.clock.after(RETRY_MS[attempt] ?? 60000, () => void fetchOnStart($, attempt + 1))
80}
81
82// 入力欄に入れるだけで送信はしない。本人が「補足:」の後に文脈を足してから送れる。
83// 送られた側には、前提が足りなければ着手前に質問するよう頼んでおく。
84const startPrompt = ($: EngineInterface, list: string, task: Task) => {
85 const ref = `Google Tasks「${list}」の「${task.title}」(id:${task.id})`
86 const ask =
87 list === 'agents'
88 ? `${ref}に着手して。notes と関係ノートを読み、`
89 : `${ref}を進めたい。これは私が動くタスクなので、notes を読んで、AIが先に下調べ・準備できることを提案して。`
90 return $.prompt.fill({
91 text: `${ask}前提が足りない点や判断が要る点があれば、作業に入る前に質問して。\n\n補足: `,
92 mode: 'replace',
93 })
94}
95
96// 本人の操作なしに開くパネルは、ターミナルが144桁(一度開いたものは110桁)以上ないと置かれない(エンジンの仕様、2026-10-05 確認)。
97// 開き直しても幅が足りなければ同じなので、置かれなかったら最初のプロンプト送信時に開く。本人の Enter が契機なら幅を問わず置かれる。
98let isWaiting = false
99
100const openUnasked = async ($: EngineInterface) => {
101 const opened = await $.ui.open({ id: PANE, title: 'Tasks' })
102 if (opened.isPlaced) return
103 isWaiting = true
104 $.ui.log(`tasks-pane: 起動時は置かれなかった(${opened.reason})。次のプロンプトで開く`, { to: 'debug' })
105}
106
107const todayJst = () => new Date(Date.now() + 9 * 3600 * 1000).toISOString().slice(0, 10)
108
109// 期限切れは赤、3日以内は黄、それ以外はうっすら(dim)。
110const dueStyle = (due: string, today: string) => {
111 if (due < today) return { color: 'red' }
112 const days = (Date.parse(due) - Date.parse(today)) / 86400000
113 return days <= 3 ? { color: 'yellow' } : { dimColor: true }
114}
115
116export const register: Register = on => {
117 on('session.start', async ($, e, next) => {
118 // /tasks は組み込みコマンドなので使えない(2026-10-05 に登録を拒否された)。AGENTS.md の「TODO=Google Tasks」に合わせる。
119 await $.command
120 .register({ name: 'todo', description: 'Google Tasks(agents / main / big / repeat / project)のパネルを開いて更新する' })
121 .catch((err: unknown) => $.ui.log(`tasks-pane: /todo を登録できなかった(${String(err)})`, { to: 'debug' }))
122 void fetchOnStart($)
123 $.clock.every(REFRESH_MS, () => void refresh($))
124 void openUnasked($)
125
126 return next(e)
127 })
128
129 on('prompt.submit', async ($, e, next) => {
130 // 再試行がまだ実っていなくても、本人がプロンプトを送る頃には MCP は繋がっている。
131 if (!hasFetched && e.origin.kind === 'composer') void refresh($)
132 if (isWaiting && e.origin.kind === 'composer') {
133 isWaiting = false
134 const isPlaced = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
135 if (!isPlaced) await $.ui.open({ id: PANE, title: 'Tasks' })
136 }
137
138 return next(e)
139 })
140
141 on('command.run', { command: 'todo' }, async $ => {
142 await $.ui.open({ id: PANE, title: 'Tasks' })
143 await refresh($)
144
145 return { text: 'Tasksパネルを更新した。' }
146 })
147
148 // モデルがタスクを書き換えたら、パネルも追従させる。
149 on('tool.call', async ($, e, next) => {
150 const ran = await next(e)
151 const tool = String(e.tool)
152 if (tool.startsWith(`mcp__${SERVER}__`) && !/__(list|search)/.test(tool)) void refresh($)
153
154 return ran
155 })
156
157 on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
158 const message = e.data as BoardMessage
159 if (message.type === 'select') await update($, selected, () => message.list)
160 else if (message.type === 'start') await startPrompt($, message.list, message.task)
161 else if (message.type === 'refresh') await refresh($)
162
163 return {}
164 })
165
166 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
167 const all = await read($, lists)
168 const current = await read($, selected)
169 const error = await read($, failure)
170 const isLoading = (await read($, fetchedAt)) === null && error === null
171 const today = todayJst()
172
173 // ターミナルは Client で矢印キーを受ける。
174 if (e.surface === 'terminal') {
175 const { Client } = $.ui.resolve(e)
176 const props: BoardProps = {
177 lists: LISTS.map(l => ({ key: l.key, tasks: all[l.key] ?? [] })),
178 current,
179 today,
180 error,
181 isLoading,
182 }
183 // 大きさを渡さないと、描くたびに測り直して落ち着かない。
184 return (
185 <Client
186 key="board"
187 module="./board.tsx"
188 props={props}
189 width={e.props.bodyColumns}
190 height={Math.max(5, e.props.scroll.bodyRows)}
191 />
192 )
193 }
194
195 // ほかの画面はボタンで操作する。
196 const { Box, Button, Text } = $.ui.resolve(e)
197 const tasks = all[current] ?? []
198
199 return (
200 <Box flexDirection="column">
201 <Box flexWrap="wrap" columnGap={1}>
202 {LISTS.map(list => (
203 <Button
204 key={list.key}
205 label={`${list.key} ${all[list.key]?.length ?? '-'}`}
206 hotkey={list.hotkey}
207 variant={list.key === current ? 'primary' : undefined}
208 onPress={() => update($, selected, () => list.key)}
209 />
210 ))}
211 <Button key="refresh" label="更新" hotkey="u" onPress={() => refresh($)} />
212 </Box>
213 {error !== null && <Text color="red">{error}</Text>}
214 {isLoading && <Text dimColor>読み込み中…</Text>}
215 {!isLoading && tasks.length === 0 && <Text dimColor>タスクなし</Text>}
216 {tasks.map(task => (
217 <Box key={task.id} columnGap={1}>
218 <Button key={`go-${task.id}`} label="着手" plain onPress={() => startPrompt($, current, task)} />
219 {task.due !== null && <Text {...dueStyle(task.due, today)}>{task.due.slice(5).replace('-', '/')}</Text>}
220 <Text wrap="truncate-end">
221 {' '.repeat(task.depth)}
222 {task.title}
223 </Text>
224 </Box>
225 ))}
226 </Box>
227 )
228 })
229}
230hooks/board.tsx 79 lines1import type { ClientModule } from 'claude-code'
2
3import type { BoardMessage, BoardProps } from '../types'
4
5type State = { list: number; row: number }
6
7// 期限切れは赤、3日以内は黄、それ以外はうっすら(dim)。
8const dueStyle = (due: string, today: string) => {
9 if (due < today) return { color: 'red' }
10 const days = (Date.parse(due) - Date.parse(today)) / 86400000
11 return days <= 3 ? { color: 'yellow' } : { dimColor: true }
12}
13
14// ターミナルのパネル本体。クリックでフォーカスを渡すと ←→ でリスト、↑↓ でタスク、Enter で着手、u で更新。
15const Board: ClientModule<BoardProps, State> = (props, surface) => {
16 const { Box, Text } = surface.elements
17 const keys = props.lists.map(l => l.key)
18 const initial = Math.max(0, keys.indexOf(props.current))
19 const state = surface.state ?? { list: initial, row: 0 }
20 const list = props.lists[state.list] ?? props.lists[0]
21 const tasks = list?.tasks ?? []
22 const row = Math.min(state.row, Math.max(0, tasks.length - 1))
23
24 surface.onKey(event => {
25 const n = props.lists.length
26 if (event.key === 'left' || event.key === 'right') {
27 const next = (state.list + (event.key === 'left' ? n - 1 : 1)) % n
28 surface.setState({ list: next, row: 0 })
29 surface.post({ type: 'select', list: keys[next] ?? '' } satisfies BoardMessage)
30 } else if (event.key === 'up' || event.key === 'down') {
31 const next = Math.max(0, Math.min(tasks.length - 1, row + (event.key === 'up' ? -1 : 1)))
32 surface.setState({ list: state.list, row: next })
33 } else if (event.key === 'return') {
34 const task = tasks[row]
35 if (list && task) surface.post({ type: 'start', list: list.key, task } satisfies BoardMessage)
36 } else if (event.key === 'u') {
37 surface.post({ type: 'refresh' } satisfies BoardMessage)
38 }
39 })
40
41 // カーソル行が見える範囲だけ描く(ヘッダ2行・フッタ1行を除く)。
42 const room = Math.max(3, (surface.rows || 24) - 3)
43 const start = Math.max(0, Math.min(row - Math.floor(room / 2), tasks.length - room))
44 const visible = tasks.slice(start, start + room)
45
46 return (
47 <Box flexDirection="column" flexGrow={1}>
48 <Box columnGap={1}>
49 {props.lists.map((l, i) => (
50 <Text key={l.key} inverse={i === state.list} bold={i === state.list} dimColor={i !== state.list}>
51 {` ${l.key} ${l.tasks.length} `}
52 </Text>
53 ))}
54 </Box>
55 {props.error !== null && <Text color="red">{props.error}</Text>}
56 {props.isLoading && <Text dimColor>読み込み中…</Text>}
57 {!props.isLoading && tasks.length === 0 && <Text dimColor>タスクなし</Text>}
58 {visible.map((task, i) => {
59 const isCursor = start + i === row
60 return (
61 <Box key={task.id} columnGap={1}>
62 <Text color={isCursor ? 'cyan' : undefined}>{isCursor ? '›' : ' '}</Text>
63 {task.due !== null && (
64 <Text {...dueStyle(task.due, props.today)}>{task.due.slice(5).replace('-', '/')}</Text>
65 )}
66 <Text wrap="truncate-end" bold={isCursor}>
67 {' '.repeat(task.depth)}
68 {task.title}
69 </Text>
70 </Box>
71 )
72 })}
73 <Text dimColor>クリックで操作 ←→リスト ↑↓移動 Enter着手 u更新</Text>
74 </Box>
75 )
76}
77
78export default Board
79types/index.d.ts 35 lines1export type Task = {
2 id: string
3 title: string
4 due: string | null
5 depth: number
6}
7
8export type TaskLists = Record<string, Task[]>
9
10// フック → ターミナルのパネル(board.tsx)へ渡すもの。
11export type BoardProps = {
12 lists: { key: string; tasks: Task[] }[]
13 current: string
14 today: string
15 error: string | null
16 isLoading: boolean
17}
18
19// パネル → フックへ送るもの。
20export type BoardMessage =
21 | { type: 'select'; list: string }
22 | { type: 'start'; list: string; task: Task }
23 | { type: 'refresh' }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'tasks-pane': {
28 lists: TaskLists
29 selected: string
30 fetchedAt: number | null
31 error: string | null
32 }
33 }
34}
35