@comfanion/workflow 4.38.3-dev.2 → 4.38.4-dev.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.
@@ -1,538 +0,0 @@
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
- })