@comfanion/usethis_todo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -0
  3. package/index.ts +68 -0
  4. package/package.json +27 -0
  5. package/tools.ts +538 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Comfanion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @comfanion/usethis_todo
2
+
3
+ OpenCode plugin that provides enhanced TODO tools (dual storage + dependency graph).
4
+
5
+ ## Tools
6
+
7
+ - `usethis_todo_write`
8
+ - `usethis_todo_read`
9
+ - `usethis_todo_read_five`
10
+ - `usethis_todo_read_by_id`
11
+ - `usethis_todo_update`
12
+
13
+ ## Install (OpenCode)
14
+
15
+ Add to `opencode.json`:
16
+
17
+ ```json
18
+ {
19
+ "plugin": ["@comfanion/usethis_todo"]
20
+ }
21
+ ```
package/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+
3
+ import { write, read, read_five, read_by_id, update } from "./tools"
4
+
5
+ // Re-export tools for programmatic use/tests
6
+ export { write, read, read_five, read_by_id, update } from "./tools"
7
+
8
+ export const UsethisTodoPlugin: Plugin = async ({ client }) => {
9
+ return {
10
+ tool: {
11
+ usethis_todo_write: write,
12
+ usethis_todo_read: read,
13
+ usethis_todo_read_five: read_five,
14
+ usethis_todo_read_by_id: read_by_id,
15
+ usethis_todo_update: update,
16
+ },
17
+
18
+ // UI niceties + publish snapshot into the chat
19
+ "tool.execute.after": async (input, output) => {
20
+ if (!input.tool.startsWith("usethis_todo_")) return
21
+
22
+ const out = output.output || ""
23
+
24
+ // Set a nicer title in TUI
25
+ if (input.tool === "usethis_todo_write") {
26
+ const match = out.match(/\[(\d+)\/(\d+) done/)
27
+ output.title = match ? `📋 TODO: ${match[2]} tasks` : "📋 TODO updated"
28
+ } else if (input.tool === "usethis_todo_update") {
29
+ const match = out.match(/^✅ (.+)$/m)
30
+ output.title = match ? `📝 ${match[1]}` : "📝 Task updated"
31
+ } else if (input.tool === "usethis_todo_read") {
32
+ const match = out.match(/\[(\d+)\/(\d+) done, (\d+) in progress\]/)
33
+ output.title = match ? `📋 TODO [${match[1]}/${match[2]} done]` : "📋 TODO list"
34
+ } else if (input.tool === "usethis_todo_read_five") {
35
+ output.title = "📋 Next 5 tasks"
36
+ } else if (input.tool === "usethis_todo_read_by_id") {
37
+ output.title = "📋 Task details"
38
+ }
39
+
40
+ // Publish snapshot into chat (helps when sidebar doesn't refresh)
41
+ const publishTools = new Set([
42
+ "usethis_todo_write",
43
+ "usethis_todo_update",
44
+ "usethis_todo_read",
45
+ "usethis_todo_read_five",
46
+ "usethis_todo_read_by_id",
47
+ ])
48
+
49
+ if (!publishTools.has(input.tool)) return
50
+
51
+ const text = ["## TODO", "", out].join("\n")
52
+
53
+ try {
54
+ await client?.session?.prompt?.({
55
+ path: { id: input.sessionID },
56
+ body: {
57
+ noReply: true,
58
+ parts: [{ type: "text", text }],
59
+ },
60
+ })
61
+ } catch {
62
+ // non-fatal
63
+ }
64
+ },
65
+ }
66
+ }
67
+
68
+ export default UsethisTodoPlugin
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@comfanion/usethis_todo",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin: enhanced TODO tools (dual storage + dependency graph)",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": "./index.ts",
8
+ "scripts": {
9
+ "test": "bun test"
10
+ },
11
+ "files": [
12
+ "index.ts",
13
+ "tools.ts",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "dependencies": {
18
+ "@opencode-ai/plugin": "1.1.39"
19
+ },
20
+ "peerDependencies": {
21
+ "@opencode-ai/plugin": ">=1.1.0"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "license": "MIT"
27
+ }
package/tools.ts ADDED
@@ -0,0 +1,538 @@
1
+ /**
2
+ * TODO Tool with Dependencies & Priority — v3 (dual storage)
3
+ *
4
+ * 4 commands:
5
+ * usethis_todo_write({ todos: [...] }) - create/update TODO list
6
+ * usethis_todo_read() - read TODO with graph analysis
7
+ * usethis_todo_read_five() - get next 5 available tasks
8
+ * usethis_todo_read_by_id() - get next 5 available tasks
9
+ * usethis_todo_update(id, field, value) - update any task field
10
+ *
11
+ * Storage:
12
+ * Enhanced: .opencode/session-todos/{sid}.json (title, blockedBy, graph)
13
+ * Native: ~/.local/share/opencode/storage/todo/{sid}.json (TUI display)
14
+ *
15
+ * Features:
16
+ * - Hierarchical IDs: E01-S01-T01
17
+ * - Dependencies: blockedBy field
18
+ * - Priority: CRIT | HIGH | MED | LOW (auto-sorted)
19
+ * - Graph: shows available, blocked, parallel tasks
20
+ * - Dual write: native OpenCode storage for TUI integration
21
+ */
22
+
23
+ import { tool } from "@opencode-ai/plugin"
24
+ import path from "path"
25
+ import os from "os"
26
+ import fs from "fs/promises"
27
+
28
+ // ============================================================================
29
+ // Types
30
+ // ============================================================================
31
+
32
+ interface Todo {
33
+ id: string // E01-S01-T01
34
+ content: string // Short task summary
35
+ description?: string // Full task description (optional)
36
+ releases?: string[] // Release identifiers (optional)
37
+ status: string // todo | in_progress | ready | done
38
+ priority: string // CRIT | HIGH | MED | LOW
39
+ blockedBy?: string[] // IDs of blocking tasks
40
+ createdAt?: number
41
+ updatedAt?: number
42
+ }
43
+
44
+ interface NativeTodo {
45
+ id: string
46
+ content: string // "title: content" combined
47
+ status: string // pending | in_progress | completed | cancelled
48
+ priority: string // high | medium | low
49
+ }
50
+
51
+ interface TodoGraph {
52
+ todos: Todo[]
53
+ available: string[]
54
+ parallel: string[][]
55
+ blocked: Record<string, string[]>
56
+ }
57
+
58
+ // ============================================================================
59
+ // Storage — dual write
60
+ // ============================================================================
61
+
62
+ // Resolve project directory (context.directory may be undefined via MCP)
63
+ function dir(directory?: string): string {
64
+ return directory || process.env.OPENCODE_PROJECT_DIR || process.cwd()
65
+ }
66
+
67
+ // Enhanced storage path (project-local)
68
+ function getEnhancedPath(sid: string, directory?: string): string {
69
+ return path.join(dir(directory), ".opencode", "session-todos", `${sid || "current"}.json`)
70
+ }
71
+
72
+ async function getNativeDataDirs(): Promise<string[]> {
73
+ const dirs = new Set<string>()
74
+
75
+ // 1) xdg-basedir (what OpenCode itself uses)
76
+ try {
77
+ const mod: any = await import("xdg-basedir")
78
+ if (mod?.xdgData && typeof mod.xdgData === "string") {
79
+ dirs.add(mod.xdgData)
80
+ }
81
+ } catch {
82
+ // ignore
83
+ }
84
+
85
+ // 2) explicit XDG override
86
+ if (process.env.XDG_DATA_HOME) {
87
+ dirs.add(process.env.XDG_DATA_HOME)
88
+ }
89
+
90
+ // 3) common fallbacks
91
+ dirs.add(path.join(os.homedir(), ".local", "share"))
92
+ dirs.add(path.join(os.homedir(), "Library", "Application Support"))
93
+
94
+ return [...dirs]
95
+ }
96
+
97
+ async function getNativePaths(sid: string): Promise<string[]> {
98
+ const baseDirs = await getNativeDataDirs()
99
+ const file = `${sid || "current"}.json`
100
+ return baseDirs.map((base) => path.join(base, "opencode", "storage", "todo", file))
101
+ }
102
+
103
+ // Map our format → native format
104
+ function toNative(todo: Todo): NativeTodo {
105
+ // Status mapping: our → native
106
+ const statusMap: Record<string, string> = {
107
+ todo: "pending",
108
+ in_progress: "in_progress",
109
+ ready: "in_progress", // native has no "ready"
110
+ finished: "in_progress", // back-compat
111
+ done: "completed", // native uses "completed" not "done"
112
+ cancelled: "cancelled",
113
+ }
114
+ // Priority mapping: CRIT/HIGH/MED/LOW → high/medium/low
115
+ const prioMap: Record<string, string> = {
116
+ CRIT: "high",
117
+ HIGH: "high",
118
+ MED: "medium",
119
+ LOW: "low",
120
+ }
121
+
122
+ const deps = todo.blockedBy?.length ? ` [← ${todo.blockedBy.join(", ")}]` : ""
123
+ const desc = todo.description?.trim() ? ` — ${todo.description.trim()}` : ""
124
+ const rel = todo.releases?.length ? ` [rel: ${todo.releases.join(", ")}]` : ""
125
+
126
+ return {
127
+ id: todo.id,
128
+ content: `${todo.content}${desc}${rel}${deps}`,
129
+ status: statusMap[todo.status] || "pending",
130
+ priority: prioMap[todo.priority] || "medium",
131
+ }
132
+ }
133
+
134
+ async function readTodos(sid: string, directory?: string): Promise<Todo[]> {
135
+ try {
136
+ const raw = JSON.parse(await fs.readFile(getEnhancedPath(sid, directory), "utf-8"))
137
+ if (!Array.isArray(raw)) return []
138
+ return raw.map((t: any) => normalizeTodo(t))
139
+ } catch {
140
+ return []
141
+ }
142
+ }
143
+
144
+ async function writeTodos(todos: Todo[], sid: string, directory?: string): Promise<void> {
145
+ // 1. Enhanced storage (our full format)
146
+ const enhancedPath = getEnhancedPath(sid, directory)
147
+ await fs.mkdir(path.dirname(enhancedPath), { recursive: true })
148
+ await fs.writeFile(enhancedPath, JSON.stringify(todos, null, 2), "utf-8")
149
+
150
+ // 2. Native storage (for TUI display)
151
+ const nativeTodos = todos.map(toNative)
152
+ try {
153
+ const nativePaths = await getNativePaths(sid)
154
+ await Promise.allSettled(
155
+ nativePaths.map(async (nativePath) => {
156
+ await fs.mkdir(path.dirname(nativePath), { recursive: true })
157
+ await fs.writeFile(nativePath, JSON.stringify(nativeTodos, null, 2), "utf-8")
158
+ }),
159
+ )
160
+ } catch {
161
+ // Native write failure is non-fatal
162
+ }
163
+ }
164
+
165
+ // ============================================================================
166
+ // Graph analysis
167
+ // ============================================================================
168
+
169
+ function analyzeGraph(todos: Todo[]): TodoGraph {
170
+ const blocked: Record<string, string[]> = {}
171
+ const availableTodos: Todo[] = []
172
+
173
+ for (const todo of todos) {
174
+ if (normalizeStatus(todo.status) !== "todo") continue
175
+ const activeBlockers = (todo.blockedBy || []).filter(id => {
176
+ const b = todos.find(t => t.id === id)
177
+ const bs = normalizeStatus(b?.status)
178
+ return b && bs !== "done" && bs !== "cancelled"
179
+ })
180
+ if (activeBlockers.length === 0) {
181
+ availableTodos.push(todo)
182
+ } else {
183
+ blocked[todo.id] = activeBlockers
184
+ }
185
+ }
186
+
187
+ const P: Record<string, number> = { CRIT: 0, HIGH: 1, MED: 2, LOW: 3 }
188
+ availableTodos.sort((a, b) => (P[a.priority] ?? 2) - (P[b.priority] ?? 2))
189
+ const available = availableTodos.map(t => t.id)
190
+
191
+ // Parallel groups
192
+ const parallel: string[][] = []
193
+ const seen = new Set<string>()
194
+ for (const id of available) {
195
+ if (seen.has(id)) continue
196
+ const group = [id]
197
+ seen.add(id)
198
+ for (const other of available) {
199
+ if (seen.has(other)) continue
200
+ const a = todos.find(t => t.id === id)
201
+ const b = todos.find(t => t.id === other)
202
+ if (!b?.blockedBy?.includes(id) && !a?.blockedBy?.includes(other)) {
203
+ group.push(other)
204
+ seen.add(other)
205
+ }
206
+ }
207
+ if (group.length > 0) parallel.push(group)
208
+ }
209
+
210
+ return { todos, available, parallel, blocked }
211
+ }
212
+
213
+ // ============================================================================
214
+ // Formatting
215
+ // ============================================================================
216
+
217
+ const PE = (p?: string) => p === "CRIT" ? "🔴" : p === "HIGH" ? "🟠" : p === "LOW" ? "🟢" : "🟡"
218
+ const SI = (s: string) => s === "done" ? "✓" : s === "in_progress" ? "⚙" : s === "ready" ? "⏳" : s === "cancelled" ? "✗" : s === "todo" ? "○" : "·"
219
+
220
+ function normalizeStatus(input: unknown): string {
221
+ const s = String(input || "").trim()
222
+
223
+ // New canonical set
224
+ if (s === "todo" || s === "in_progress" || s === "ready" || s === "done") return s
225
+
226
+ // Back-compat (older versions)
227
+ if (s === "pending") return "todo"
228
+ if (s === "waiting_review" || s === "finished") return "ready"
229
+ if (s === "completed") return "done"
230
+
231
+ // Keep cancelled if it appears (native supports it)
232
+ if (s === "cancelled") return "cancelled"
233
+
234
+ // Default
235
+ return "todo"
236
+ }
237
+
238
+ function normalizeReleases(input: unknown): string[] | undefined {
239
+ if (!Array.isArray(input)) return undefined
240
+ const values = input
241
+ .map((x) => String(x || "").trim())
242
+ .filter(Boolean)
243
+ return values.length ? values : undefined
244
+ }
245
+
246
+ function normalizeTodo(input: any): Todo {
247
+ const status = normalizeStatus(input?.status)
248
+ const releases = normalizeReleases(input?.releases)
249
+
250
+ // Auto transition: ready -> done when releases exist
251
+ const promotedStatus = status === "ready" && releases?.length ? "done" : status
252
+
253
+ return {
254
+ ...input,
255
+ status: promotedStatus,
256
+ releases,
257
+ }
258
+ }
259
+
260
+ function prioRank(p?: string): number {
261
+ return p === "CRIT" ? 0 : p === "HIGH" ? 1 : p === "MED" ? 2 : 3
262
+ }
263
+
264
+ function sortTodosForList(todos: Todo[]): Todo[] {
265
+ return todos
266
+ .slice()
267
+ .sort((a, b) => (prioRank(a.priority) - prioRank(b.priority)) || a.id.localeCompare(b.id))
268
+ }
269
+
270
+ function isBlocked(todo: Todo, byId: Map<string, Todo>): boolean {
271
+ const s = normalizeStatus(todo.status)
272
+ if (s !== "todo") return false
273
+ if (!todo.blockedBy?.length) return false
274
+ for (const id of todo.blockedBy) {
275
+ const b = byId.get(id)
276
+ const bs = normalizeStatus(b?.status)
277
+ if (!b) return true
278
+ if (bs !== "done" && bs !== "cancelled") return true
279
+ }
280
+ return false
281
+ }
282
+
283
+ function todoLine(todo: Todo, byId: Map<string, Todo>): string {
284
+ const desc = todo.description?.trim() ? ` — ${todo.description.trim()}` : ""
285
+ const rel = todo.releases?.length ? ` [rel: ${todo.releases.join(", ")}]` : ""
286
+ const deps = todo.blockedBy?.length ? ` ← ${todo.blockedBy.join(", ")}` : ""
287
+ const ns = normalizeStatus(todo.status)
288
+ const icon = isBlocked(todo, byId) ? "⊗" : SI(ns)
289
+ return `${icon} ${PE(todo.priority)} ${todo.id}: ${todo.content}${desc}${rel}${deps}`
290
+ }
291
+
292
+ function renderNestedTodoList(todos: Todo[], allTodos?: Todo[]): string {
293
+ const byId = new Map((allTodos || todos).map(t => [t.id, t]))
294
+
295
+ // Group by id pattern: E01-S01-T01 → 3 nested levels (E01 → S01 → tasks)
296
+ const groups = new Map<string, Map<string, Todo[]>>()
297
+ const flat: Todo[] = []
298
+
299
+ for (const t of todos) {
300
+ const parts = t.id.split("-")
301
+ if (parts.length >= 3) {
302
+ const epic = parts[0]
303
+ const story = parts[1]
304
+ if (!groups.has(epic)) groups.set(epic, new Map())
305
+ const storyMap = groups.get(epic)!
306
+ if (!storyMap.has(story)) storyMap.set(story, [])
307
+ storyMap.get(story)!.push(t)
308
+ } else {
309
+ flat.push(t)
310
+ }
311
+ }
312
+
313
+ const lines: string[] = []
314
+ const epicKeys = [...groups.keys()].sort()
315
+ for (const epic of epicKeys) {
316
+ lines.push(`- ${epic}`)
317
+ const storyMap = groups.get(epic)!
318
+ const storyKeys = [...storyMap.keys()].sort()
319
+ for (const story of storyKeys) {
320
+ lines.push(` - ${epic}-${story}`)
321
+ const tasks = storyMap.get(story)!
322
+ .slice()
323
+ .sort((a, b) => a.id.localeCompare(b.id))
324
+ for (const t of tasks) {
325
+ lines.push(` - ${todoLine(t, byId)}`)
326
+ }
327
+ }
328
+ }
329
+
330
+ const flatSorted = flat.slice().sort((a, b) => a.id.localeCompare(b.id))
331
+ for (const t of flatSorted) {
332
+ lines.push(`- ${todoLine(t, byId)}`)
333
+ }
334
+
335
+ return lines.length ? lines.join("\n") : "- (empty)"
336
+ }
337
+
338
+ function resolveBlockers(todos: Todo[], rootIds: string[]): { blockers: Todo[]; missing: string[] } {
339
+ const byId = new Map(todos.map(t => [t.id, t]))
340
+ const blockers: Todo[] = []
341
+ const missing: string[] = []
342
+ const seen = new Set<string>()
343
+ const stack: string[] = []
344
+
345
+ for (const id of rootIds) {
346
+ const t = byId.get(id)
347
+ if (!t?.blockedBy?.length) continue
348
+ stack.push(...t.blockedBy)
349
+ }
350
+
351
+ while (stack.length > 0) {
352
+ const id = stack.shift()!
353
+ if (seen.has(id)) continue
354
+ seen.add(id)
355
+
356
+ const t = byId.get(id)
357
+ if (!t) {
358
+ missing.push(id)
359
+ continue
360
+ }
361
+
362
+ blockers.push(t)
363
+ if (t.blockedBy?.length) stack.push(...t.blockedBy)
364
+ }
365
+
366
+ blockers.sort((a, b) => a.id.localeCompare(b.id))
367
+ missing.sort((a, b) => a.localeCompare(b))
368
+ return { blockers, missing }
369
+ }
370
+
371
+ function formatGraph(graph: TodoGraph): string {
372
+ const { todos } = graph
373
+ const total = todos.length
374
+ const done = todos.filter(t => normalizeStatus(t.status) === "done").length
375
+ const wip = todos.filter(t => normalizeStatus(t.status) === "in_progress").length
376
+
377
+ const availableTodos = graph.available
378
+ .map((id) => todos.find((t) => t.id === id))
379
+ .filter(Boolean) as Todo[]
380
+
381
+ const blockedTodos = Object.keys(graph.blocked)
382
+ .map((id) => todos.find((t) => t.id === id))
383
+ .filter(Boolean) as Todo[]
384
+
385
+ const lines: string[] = []
386
+ lines.push(`TODO Graph [${done}/${total} done, ${wip} in progress]`)
387
+ lines.push("")
388
+ lines.push("All Tasks:")
389
+ lines.push(renderNestedTodoList(sortTodosForList(todos), todos))
390
+ lines.push("")
391
+ lines.push("Available Now:")
392
+ lines.push(availableTodos.length ? renderNestedTodoList(availableTodos, todos) : "- (none)")
393
+ lines.push("")
394
+ lines.push("Blocked:")
395
+ lines.push(blockedTodos.length ? renderNestedTodoList(blockedTodos, todos) : "- (none)")
396
+ return lines.join("\n")
397
+ }
398
+
399
+ // ============================================================================
400
+ // Tools
401
+ // ============================================================================
402
+
403
+ export const write = tool({
404
+ description: "Create or update TODO list. TODOv2 (Prefer this instead of TODO)",
405
+ args: {
406
+ todos: tool.schema.array(
407
+ tool.schema.object({
408
+ id: tool.schema.string().describe("Task ID in concat format: E01-S01-T01"),
409
+ content: tool.schema.string().describe("Short task summary"),
410
+ description: tool.schema.string().optional().describe("Full task description"),
411
+ releases: tool.schema.array(tool.schema.string()).optional().describe("Release identifiers"),
412
+ status: tool.schema.string().describe("todo | in_progress | ready | done"),
413
+ priority: tool.schema.string().describe("CRIT | HIGH | MED | LOW"),
414
+ blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks"),
415
+ }),
416
+ ).describe("Array of todos"),
417
+ },
418
+ async execute(args, context) {
419
+ const now = Date.now()
420
+ const todos = args.todos.map((t: any) => normalizeTodo({ ...t, createdAt: t.createdAt || now, updatedAt: now }))
421
+ await writeTodos(todos, context.sessionID, context.directory)
422
+ return formatGraph(analyzeGraph(todos))
423
+ },
424
+ })
425
+
426
+ export const read_five = tool({
427
+ description: "Read current TODO list. Shows Next 5 tasks.",
428
+ args: {},
429
+ async execute(_args, context) {
430
+ const todos = await readTodos(context.sessionID, context.directory)
431
+ const ready = sortTodosForList(todos.filter(t => normalizeStatus(t.status) === "todo"))
432
+ if (ready.length === 0) return "No tasks in todo."
433
+
434
+ const items = ready.slice(0, 5)
435
+
436
+ const rootIds = items.map(t => t.id)
437
+ const rootSet = new Set(rootIds)
438
+ const resolved = resolveBlockers(todos, rootIds)
439
+ const blockers = resolved.blockers.filter(t => !rootSet.has(t.id))
440
+ const missing = resolved.missing
441
+
442
+ const more = ready.length > 5 ? `+${ready.length - 5} more` : ""
443
+
444
+ const lines: string[] = []
445
+ lines.push("Next 5:")
446
+ lines.push(renderNestedTodoList(items, todos))
447
+ if (more) lines.push(more)
448
+
449
+ lines.push("")
450
+ lines.push(`Blocked By (resolved) [${blockers.length}]:`)
451
+ lines.push(blockers.length ? renderNestedTodoList(blockers, todos) : "- (none)")
452
+ if (missing.length) {
453
+ lines.push("")
454
+ lines.push(`Blocked By missing: ${missing.join(", ")}`)
455
+ }
456
+
457
+ return lines.join("\n")
458
+ },
459
+ })
460
+
461
+ export const read = tool({
462
+ description: "Read current TODO list. Shows all tasks.",
463
+ args: {},
464
+ async execute(_args, context) {
465
+ const todos = await readTodos(context.sessionID, context.directory)
466
+ if (todos.length === 0) return "No todos. Use usethis_todo_write to create."
467
+ return formatGraph(analyzeGraph(todos))
468
+ },
469
+ })
470
+
471
+ export const read_by_id = tool({
472
+ description: "Read task by id.",
473
+ args: {
474
+ id: tool.schema.string().describe("Task ID"),
475
+ },
476
+ async execute(args, context) {
477
+ const todos = await readTodos(context.sessionID, context.directory)
478
+ const todo = todos.find(t => t.id === args.id)
479
+ if (!todo) return `❌ Task ${args.id} not found`
480
+
481
+ const { blockers, missing } = resolveBlockers(todos, [todo.id])
482
+
483
+ const lines: string[] = []
484
+ lines.push("Task:")
485
+ lines.push(`- ${todoLine(todo, new Map(todos.map(t => [t.id, t])) )}`)
486
+
487
+ lines.push("")
488
+ lines.push("Blocked By (resolved):")
489
+ lines.push(blockers.length ? renderNestedTodoList(blockers, todos) : "- (none)")
490
+
491
+ if (missing.length) {
492
+ lines.push("")
493
+ lines.push(`Blocked By missing: ${missing.join(", ")}`)
494
+ }
495
+
496
+ return lines.join("\n")
497
+ },
498
+ })
499
+
500
+ export const update = tool({
501
+ description: "Update task(s). Send 1 or many for update",
502
+ args: {
503
+ todos: tool.schema.array(
504
+ tool.schema.object({
505
+ id: tool.schema.string().describe("Task ID in concat format: E01-S01-T01"),
506
+ content: tool.schema.string().describe("Short task summary"),
507
+ description: tool.schema.string().optional().describe("Full task description"),
508
+ releases: tool.schema.array(tool.schema.string()).optional().describe("Release identifiers(from ready -> done)"),
509
+ status: tool.schema.string().describe("todo | in_progress | ready | done"),
510
+ priority: tool.schema.string().describe("CRIT | HIGH | MED | LOW"),
511
+ blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks(from todo -> blocked)"),
512
+ }),
513
+ ).describe("Array of todos to update"),
514
+ },
515
+ async execute(args, context) {
516
+ const todos = await readTodos(context.sessionID, context.directory)
517
+ const now = Date.now()
518
+ const byId = new Map(todos.map(t => [t.id, t]))
519
+
520
+ for (const incoming of args.todos) {
521
+ const normalizedIncoming: any = normalizeTodo(incoming)
522
+ const existing = byId.get(normalizedIncoming.id)
523
+ if (existing) {
524
+ Object.assign(existing, normalizedIncoming)
525
+ existing.updatedAt = now
526
+ // Ensure auto transition is applied after merge
527
+ existing.status = normalizeTodo(existing).status
528
+ existing.releases = normalizeTodo(existing).releases
529
+ } else {
530
+ byId.set(normalizedIncoming.id, normalizeTodo({ ...normalizedIncoming, createdAt: now, updatedAt: now }))
531
+ }
532
+ }
533
+
534
+ const merged = [...byId.values()]
535
+ await writeTodos(merged, context.sessionID, context.directory)
536
+ return `✅ Updated ${args.todos.length} task(s)\n\n${formatGraph(analyzeGraph(merged))}`
537
+ },
538
+ })