@comfanion/workflow 4.38.3-dev.0 → 4.38.3-dev.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comfanion/workflow",
3
- "version": "4.38.3-dev.0",
3
+ "version": "4.38.3-dev.2",
4
4
  "description": "Initialize OpenCode Workflow system for AI-assisted development with semantic code search",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "4.38.3-dev.0",
3
- "buildDate": "2026-01-28T10:28:17.932Z",
2
+ "version": "4.38.3-dev.2",
3
+ "buildDate": "2026-01-28T11:30:46.666Z",
4
4
  "files": [
5
5
  ".gitignore",
6
6
  "config.yaml",
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
2
  import { readFile } from "fs/promises"
3
3
  import { join } from "path"
4
4
  import { createTempDir, cleanupTempDir } from "./helpers/mock-ctx"
5
- import { write, update, read, get_by_id } from "../../tools/usethis_todo"
5
+ import { write, update, read, read_by_id, read_five } from "../../tools/usethis_todo"
6
6
 
7
7
  describe("usethis_todo tool", () => {
8
8
  let tempDir: string
@@ -28,8 +28,8 @@ describe("usethis_todo tool", () => {
28
28
  const output = await write.execute(
29
29
  {
30
30
  todos: [
31
- { id: "A1", content: "First task", description: "Longer details", status: "ready", priority: "HIGH" },
32
- { id: "A2", content: "Second task", status: "pending", priority: "LOW" },
31
+ { id: "A1", content: "First task", description: "Longer details", status: "todo", priority: "HIGH" },
32
+ { id: "A2", content: "Second task", status: "todo", priority: "LOW" },
33
33
  ],
34
34
  },
35
35
  ctx
@@ -62,7 +62,7 @@ describe("usethis_todo tool", () => {
62
62
  await write.execute(
63
63
  {
64
64
  todos: [
65
- { id: "A1", content: "First task", description: "v1", status: "pending", priority: "MED" },
65
+ { id: "A1", content: "First task", description: "v1", status: "todo", priority: "MED" },
66
66
  ],
67
67
  },
68
68
  ctx
@@ -72,7 +72,7 @@ describe("usethis_todo tool", () => {
72
72
  {
73
73
  todos: [
74
74
  { id: "A1", content: "First task", description: "v2", status: "done", priority: "MED" },
75
- { id: "A2", content: "Second task", status: "ready", priority: "LOW" },
75
+ { id: "A2", content: "Second task", status: "todo", priority: "LOW" },
76
76
  ],
77
77
  },
78
78
  ctx
@@ -87,12 +87,35 @@ describe("usethis_todo tool", () => {
87
87
  expect(enhanced.find((t: any) => t.id === "A1")?.description).toBe("v2")
88
88
  })
89
89
 
90
+ it("auto-promotes ready -> done when releases exist", async () => {
91
+ const ctx = { sessionID: "sess-rel", directory: tempDir } as any
92
+
93
+ await write.execute(
94
+ {
95
+ todos: [
96
+ {
97
+ id: "A1",
98
+ content: "Ship it",
99
+ status: "ready",
100
+ priority: "HIGH",
101
+ releases: ["4.38.4"],
102
+ },
103
+ ],
104
+ },
105
+ ctx
106
+ )
107
+
108
+ const enhancedPath = join(tempDir, ".opencode", "session-todos", "sess-rel.json")
109
+ const enhanced = JSON.parse(await readFile(enhancedPath, "utf-8"))
110
+ expect(enhanced.find((t: any) => t.id === "A1")?.status).toBe("done")
111
+ })
112
+
90
113
  it("read returns graph with content", async () => {
91
114
  const ctx = { sessionID: "sess-read", directory: tempDir } as any
92
115
  await write.execute(
93
116
  {
94
117
  todos: [
95
- { id: "A1", content: "Task content", status: "ready", priority: "HIGH" },
118
+ { id: "A1", content: "Task content", status: "todo", priority: "HIGH" },
96
119
  ],
97
120
  },
98
121
  ctx
@@ -103,7 +126,36 @@ describe("usethis_todo tool", () => {
103
126
  expect(output).toContain("Available Now")
104
127
  })
105
128
 
106
- it("get_by_id returns single task", async () => {
129
+ it("read_five returns up to 5 available tasks with description", async () => {
130
+ const ctx = { sessionID: "sess-five", directory: tempDir } as any
131
+ await write.execute(
132
+ {
133
+ todos: [
134
+ { id: "A1", content: "T1", description: "D1", status: "todo", priority: "HIGH", blockedBy: ["B1"] },
135
+ { id: "A2", content: "T2", status: "todo", priority: "MED" },
136
+ { id: "A3", content: "T3", status: "todo", priority: "LOW" },
137
+ { id: "A4", content: "T4", status: "todo", priority: "LOW" },
138
+ { id: "A5", content: "T5", status: "todo", priority: "LOW" },
139
+ { id: "A6", content: "T6", status: "todo", priority: "LOW" },
140
+ { id: "B1", content: "B", status: "in_progress", priority: "LOW", blockedBy: ["C1"] },
141
+ { id: "C1", content: "C", status: "in_progress", priority: "MED" },
142
+ ],
143
+ },
144
+ ctx
145
+ )
146
+
147
+ const out = await read_five.execute({}, ctx)
148
+ expect(out).toContain("Next 5")
149
+ expect(out).toContain("A1")
150
+ expect(out).toContain("T1")
151
+ expect(out).toContain("D1")
152
+ expect(out).toContain("+1 more")
153
+ expect(out).toContain("Blocked By (resolved)")
154
+ expect(out).toContain("B1")
155
+ expect(out).toContain("C1")
156
+ })
157
+
158
+ it("read_by_id returns task with resolved blockers", async () => {
107
159
  const ctx = { sessionID: "sess-get", directory: tempDir } as any
108
160
  await write.execute(
109
161
  {
@@ -112,27 +164,42 @@ describe("usethis_todo tool", () => {
112
164
  id: "A1",
113
165
  content: "Task content",
114
166
  description: "More details",
115
- status: "ready",
167
+ status: "todo",
116
168
  priority: "HIGH",
117
169
  blockedBy: ["B1"],
118
170
  },
171
+ {
172
+ id: "B1",
173
+ content: "Blocker task",
174
+ status: "done",
175
+ priority: "LOW",
176
+ blockedBy: ["C1"],
177
+ },
178
+ {
179
+ id: "C1",
180
+ content: "Root blocker",
181
+ status: "todo",
182
+ priority: "MED",
183
+ },
119
184
  ],
120
185
  },
121
186
  ctx
122
187
  )
123
188
 
124
- const out = await get_by_id.execute({ id: "A1" }, ctx)
125
- expect(out).toContain("id: A1")
126
- expect(out).toContain("content:")
189
+ const out = await read_by_id.execute({ id: "A1" }, ctx)
190
+ expect(out).toContain("Task:")
191
+ expect(out).toContain("A1")
127
192
  expect(out).toContain("Task content")
128
- expect(out).toContain("blockedBy: B1")
129
- expect(out).toContain("description:")
130
193
  expect(out).toContain("More details")
194
+
195
+ expect(out).toContain("Blocked By (resolved):")
196
+ expect(out).toContain("B1")
197
+ expect(out).toContain("C1")
131
198
  })
132
199
 
133
- it("get_by_id returns not found", async () => {
200
+ it("read_by_id returns not found", async () => {
134
201
  const ctx = { sessionID: "sess-miss", directory: tempDir } as any
135
- const out = await get_by_id.execute({ id: "NOPE" }, ctx)
202
+ const out = await read_by_id.execute({ id: "NOPE" }, ctx)
136
203
  expect(out).toContain("not found")
137
204
  })
138
205
  })
@@ -9,10 +9,18 @@ import type { Plugin } from "@opencode-ai/plugin"
9
9
  export const UsethisTodoPublish: Plugin = async ({ client }) => {
10
10
  return {
11
11
  "tool.execute.after": async (input, output) => {
12
- if (input.tool !== "usethis_todo_write" && input.tool !== "usethis_todo_update") return
12
+ const publishTools = new Set([
13
+ "usethis_todo_write",
14
+ "usethis_todo_update",
15
+ "usethis_todo_read",
16
+ "usethis_todo_read_five",
17
+ "usethis_todo_read_by_id",
18
+ ])
19
+
20
+ if (!publishTools.has(input.tool)) return
13
21
 
14
22
  const text = [
15
- `## TODO`,
23
+ `## TODO`,
16
24
  // `session: ${input.sessionID}`,
17
25
  "",
18
26
  output.output
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * TODO Tool with Dependencies & Priority — v3 (dual storage)
3
- *
3
+ *
4
4
  * 4 commands:
5
5
  * usethis_todo_write({ todos: [...] }) - create/update TODO list
6
6
  * usethis_todo_read() - read TODO with graph analysis
7
- * usethis_todo_read_next_five() - get next 5 available tasks
7
+ * usethis_todo_read_five() - get next 5 available tasks
8
+ * usethis_todo_read_by_id() - get next 5 available tasks
8
9
  * usethis_todo_update(id, field, value) - update any task field
9
- *
10
+ *
10
11
  * Storage:
11
12
  * Enhanced: .opencode/session-todos/{sid}.json (title, blockedBy, graph)
12
13
  * Native: ~/.local/share/opencode/storage/todo/{sid}.json (TUI display)
13
- *
14
+ *
14
15
  * Features:
15
16
  * - Hierarchical IDs: E01-S01-T01
16
17
  * - Dependencies: blockedBy field
@@ -32,7 +33,8 @@ interface Todo {
32
33
  id: string // E01-S01-T01
33
34
  content: string // Short task summary
34
35
  description?: string // Full task description (optional)
35
- status: string // pending | ready | in_progress | waiting_review | done | cancelled
36
+ releases?: string[] // Release identifiers (optional)
37
+ status: string // todo | in_progress | ready | done
36
38
  priority: string // CRIT | HIGH | MED | LOW
37
39
  blockedBy?: string[] // IDs of blocking tasks
38
40
  createdAt?: number
@@ -102,10 +104,10 @@ async function getNativePaths(sid: string): Promise<string[]> {
102
104
  function toNative(todo: Todo): NativeTodo {
103
105
  // Status mapping: our → native
104
106
  const statusMap: Record<string, string> = {
105
- pending: "pending",
106
- ready: "pending", // native has no "ready"
107
+ todo: "pending",
107
108
  in_progress: "in_progress",
108
- waiting_review: "in_progress", // native has no "waiting_review"
109
+ ready: "in_progress", // native has no "ready"
110
+ finished: "in_progress", // back-compat
109
111
  done: "completed", // native uses "completed" not "done"
110
112
  cancelled: "cancelled",
111
113
  }
@@ -116,13 +118,14 @@ function toNative(todo: Todo): NativeTodo {
116
118
  MED: "medium",
117
119
  LOW: "low",
118
120
  }
119
-
121
+
120
122
  const deps = todo.blockedBy?.length ? ` [← ${todo.blockedBy.join(", ")}]` : ""
121
123
  const desc = todo.description?.trim() ? ` — ${todo.description.trim()}` : ""
124
+ const rel = todo.releases?.length ? ` [rel: ${todo.releases.join(", ")}]` : ""
122
125
 
123
126
  return {
124
127
  id: todo.id,
125
- content: `${todo.content}${desc}${deps}`,
128
+ content: `${todo.content}${desc}${rel}${deps}`,
126
129
  status: statusMap[todo.status] || "pending",
127
130
  priority: prioMap[todo.priority] || "medium",
128
131
  }
@@ -130,7 +133,9 @@ function toNative(todo: Todo): NativeTodo {
130
133
 
131
134
  async function readTodos(sid: string, directory?: string): Promise<Todo[]> {
132
135
  try {
133
- return JSON.parse(await fs.readFile(getEnhancedPath(sid, directory), "utf-8"))
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))
134
139
  } catch {
135
140
  return []
136
141
  }
@@ -141,16 +146,16 @@ async function writeTodos(todos: Todo[], sid: string, directory?: string): Promi
141
146
  const enhancedPath = getEnhancedPath(sid, directory)
142
147
  await fs.mkdir(path.dirname(enhancedPath), { recursive: true })
143
148
  await fs.writeFile(enhancedPath, JSON.stringify(todos, null, 2), "utf-8")
144
-
149
+
145
150
  // 2. Native storage (for TUI display)
146
151
  const nativeTodos = todos.map(toNative)
147
152
  try {
148
153
  const nativePaths = await getNativePaths(sid)
149
154
  await Promise.allSettled(
150
- nativePaths.map(async (nativePath) => {
151
- await fs.mkdir(path.dirname(nativePath), { recursive: true })
152
- await fs.writeFile(nativePath, JSON.stringify(nativeTodos, null, 2), "utf-8")
153
- }),
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
+ }),
154
159
  )
155
160
  } catch {
156
161
  // Native write failure is non-fatal
@@ -166,10 +171,11 @@ function analyzeGraph(todos: Todo[]): TodoGraph {
166
171
  const availableTodos: Todo[] = []
167
172
 
168
173
  for (const todo of todos) {
169
- if (todo.status !== "ready") continue
174
+ if (normalizeStatus(todo.status) !== "todo") continue
170
175
  const activeBlockers = (todo.blockedBy || []).filter(id => {
171
176
  const b = todos.find(t => t.id === id)
172
- return b && b.status !== "done"
177
+ const bs = normalizeStatus(b?.status)
178
+ return b && bs !== "done" && bs !== "cancelled"
173
179
  })
174
180
  if (activeBlockers.length === 0) {
175
181
  availableTodos.push(todo)
@@ -177,11 +183,11 @@ function analyzeGraph(todos: Todo[]): TodoGraph {
177
183
  blocked[todo.id] = activeBlockers
178
184
  }
179
185
  }
180
-
186
+
181
187
  const P: Record<string, number> = { CRIT: 0, HIGH: 1, MED: 2, LOW: 3 }
182
188
  availableTodos.sort((a, b) => (P[a.priority] ?? 2) - (P[b.priority] ?? 2))
183
189
  const available = availableTodos.map(t => t.id)
184
-
190
+
185
191
  // Parallel groups
186
192
  const parallel: string[][] = []
187
193
  const seen = new Set<string>()
@@ -200,7 +206,7 @@ function analyzeGraph(todos: Todo[]): TodoGraph {
200
206
  }
201
207
  if (group.length > 0) parallel.push(group)
202
208
  }
203
-
209
+
204
210
  return { todos, available, parallel, blocked }
205
211
  }
206
212
 
@@ -209,52 +215,184 @@ function analyzeGraph(todos: Todo[]): TodoGraph {
209
215
  // ============================================================================
210
216
 
211
217
  const PE = (p?: string) => p === "CRIT" ? "🔴" : p === "HIGH" ? "🟠" : p === "LOW" ? "🟢" : "🟡"
212
- const SI = (s: string) => s === "done" ? "✓" : s === "in_progress" ? "⚙" : s === "ready" ? "" : s === "cancelled" ? "✗" : s === "waiting_review" ? "" : "·"
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[] = []
213
298
 
214
- function formatGraph(graph: TodoGraph): string {
215
- const { todos } = graph
216
- const total = todos.length
217
- const done = todos.filter(t => t.status === "done").length
218
- const wip = todos.filter(t => t.status === "in_progress").length
219
-
220
- const lines: string[] = [`═══ TODO Graph [${done}/${total} done, ${wip} in progress] ═══`, ""]
221
-
222
- lines.push("All Tasks:")
223
299
  for (const t of todos) {
224
- const deps = t.blockedBy?.length ? ` ← ${t.blockedBy.join(", ")}` : ""
225
- const desc = t.description?.trim() ? ` — ${t.description.trim()}` : ""
226
- lines.push(` ${SI(t.status)} ${PE(t.priority)} ${t.id}: ${t.content}${desc}${deps}`)
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
+ }
227
311
  }
228
- lines.push("")
229
-
230
- if (graph.available.length > 0) {
231
- lines.push("Available Now:")
232
- for (const id of graph.available) {
233
- const t = todos.find(x => x.id === id)
234
- const desc = t?.description?.trim() ? ` — ${t.description.trim()}` : ""
235
- lines.push(` → ${PE(t?.priority)} ${id}: ${t?.content}${desc}`)
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
+ }
236
327
  }
237
- } else {
238
- lines.push("Available Now: none")
239
328
  }
240
- lines.push("")
241
-
242
- const multi = graph.parallel.filter(g => g.length > 1)
243
- if (multi.length > 0) {
244
- lines.push("Parallel Groups:")
245
- multi.forEach((g, i) => lines.push(` Group ${i + 1}: ${g.join(", ")}`))
246
- lines.push("")
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)}`)
247
333
  }
248
-
249
- if (Object.keys(graph.blocked).length > 0) {
250
- lines.push("Blocked:")
251
- for (const [id, blockers] of Object.entries(graph.blocked)) {
252
- const t = todos.find(x => x.id === id)
253
- const desc = t?.description?.trim() ? ` — ${t.description.trim()}` : ""
254
- lines.push(` ${id}: ${t?.content}${desc} waiting: ${blockers.join(", ")}`)
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
255
360
  }
361
+
362
+ blockers.push(t)
363
+ if (t.blockedBy?.length) stack.push(...t.blockedBy)
256
364
  }
257
-
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)")
258
396
  return lines.join("\n")
259
397
  }
260
398
 
@@ -266,42 +404,56 @@ export const write = tool({
266
404
  description: "Create or update TODO list. TODOv2 (Prefer this instead of TODO)",
267
405
  args: {
268
406
  todos: tool.schema.array(
269
- tool.schema.object({
270
- id: tool.schema.string().describe("Task ID in concat format: E01-S01-T01"),
271
- content: tool.schema.string().describe("Short task summary"),
272
- description: tool.schema.string().optional().describe("Full task description"),
273
- status: tool.schema.string().describe("pending | ready | in_progress | waiting_review | done | cancelled"),
274
- priority: tool.schema.string().describe("CRIT | HIGH | MED | LOW"),
275
- blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks"),
276
- })
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
+ })
277
416
  ).describe("Array of todos"),
278
417
  },
279
418
  async execute(args, context) {
280
419
  const now = Date.now()
281
- const todos = args.todos.map((t: any) => ({ ...t, createdAt: t.createdAt || now, updatedAt: now }))
420
+ const todos = args.todos.map((t: any) => normalizeTodo({ ...t, createdAt: t.createdAt || now, updatedAt: now }))
282
421
  await writeTodos(todos, context.sessionID, context.directory)
283
422
  return formatGraph(analyzeGraph(todos))
284
423
  },
285
424
  })
286
425
 
287
- export const read_next_five = tool({
426
+ export const read_five = tool({
288
427
  description: "Read current TODO list. Shows Next 5 tasks.",
289
428
  args: {},
290
429
  async execute(_args, context) {
291
430
  const todos = await readTodos(context.sessionID, context.directory)
292
- const graph = analyzeGraph(todos)
293
- if (graph.available.length === 0) return "No tasks available. All blocked or not ready."
294
- const next5 = graph.available.slice(0, 5)
295
- const lines: string[] = ["Next 5 available tasks:", ""]
296
- for (const id of next5) {
297
- const t = graph.todos.find(x => x.id === id)
298
- if (t) {
299
- const desc = t.description?.trim() ? `\n ${t.description.trim()}` : ""
300
- lines.push(`${PE(t.priority)} ${id}: ${t.content}${desc}`)
301
- lines.push("")
302
- }
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(", ")}`)
303
455
  }
304
- if (graph.available.length > 5) lines.push(`... +${graph.available.length - 5} more`)
456
+
305
457
  return lines.join("\n")
306
458
  },
307
459
  })
@@ -316,8 +468,8 @@ export const read = tool({
316
468
  },
317
469
  })
318
470
 
319
- export const get_by_id = tool({
320
- description: "Get one task by id.",
471
+ export const read_by_id = tool({
472
+ description: "Read task by id.",
321
473
  args: {
322
474
  id: tool.schema.string().describe("Task ID"),
323
475
  },
@@ -326,31 +478,38 @@ export const get_by_id = tool({
326
478
  const todo = todos.find(t => t.id === args.id)
327
479
  if (!todo) return `❌ Task ${args.id} not found`
328
480
 
329
- const deps = todo.blockedBy?.length ? `\nblockedBy: ${todo.blockedBy.join(", ")}` : ""
330
- const desc = todo.description?.trim() ? `\n\ndescription:\n${todo.description.trim()}` : ""
331
- return [
332
- `id: ${todo.id}`,
333
- `priority: ${todo.priority}`,
334
- `status: ${todo.status}`,
335
- deps,
336
- `\ncontent:\n${todo.content}`,
337
- desc,
338
- ].filter(Boolean).join("\n")
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")
339
497
  },
340
498
  })
341
499
 
342
500
  export const update = tool({
343
- description: "Update tasks. Same interface as write, but merges by id.",
501
+ description: "Update task(s). Send 1 or many for update",
344
502
  args: {
345
503
  todos: tool.schema.array(
346
- tool.schema.object({
347
- id: tool.schema.string().describe("Task ID in concat format: E01-S01-T01"),
348
- content: tool.schema.string().describe("Short task summary"),
349
- description: tool.schema.string().optional().describe("Full task description"),
350
- status: tool.schema.string().describe("pending | ready | in_progress | waiting_review | done | cancelled"),
351
- priority: tool.schema.string().describe("CRIT | HIGH | MED | LOW"),
352
- blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks"),
353
- })
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
+ })
354
513
  ).describe("Array of todos to update"),
355
514
  },
356
515
  async execute(args, context) {
@@ -359,12 +518,16 @@ export const update = tool({
359
518
  const byId = new Map(todos.map(t => [t.id, t]))
360
519
 
361
520
  for (const incoming of args.todos) {
362
- const existing = byId.get(incoming.id)
521
+ const normalizedIncoming: any = normalizeTodo(incoming)
522
+ const existing = byId.get(normalizedIncoming.id)
363
523
  if (existing) {
364
- Object.assign(existing, incoming)
524
+ Object.assign(existing, normalizedIncoming)
365
525
  existing.updatedAt = now
526
+ // Ensure auto transition is applied after merge
527
+ existing.status = normalizeTodo(existing).status
528
+ existing.releases = normalizeTodo(existing).releases
366
529
  } else {
367
- byId.set(incoming.id, { ...incoming, createdAt: now, updatedAt: now })
530
+ byId.set(normalizedIncoming.id, normalizeTodo({ ...normalizedIncoming, createdAt: now, updatedAt: now }))
368
531
  }
369
532
  }
370
533