@comfanion/workflow 4.38.3-dev.1 → 4.38.3

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.1",
3
+ "version": "4.38.3",
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.1",
3
- "buildDate": "2026-01-28T11:01:04.180Z",
2
+ "version": "4.38.3",
3
+ "buildDate": "2026-01-28T11:32:07.130Z",
4
4
  "files": [
5
5
  ".gitignore",
6
6
  "config.yaml",
@@ -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
@@ -108,12 +131,14 @@ describe("usethis_todo tool", () => {
108
131
  await write.execute(
109
132
  {
110
133
  todos: [
111
- { id: "A1", content: "T1", description: "D1", status: "ready", priority: "HIGH" },
112
- { id: "A2", content: "T2", status: "ready", priority: "MED" },
113
- { id: "A3", content: "T3", status: "ready", priority: "LOW" },
114
- { id: "A4", content: "T4", status: "ready", priority: "LOW" },
115
- { id: "A5", content: "T5", status: "ready", priority: "LOW" },
116
- { id: "A6", content: "T6", status: "ready", priority: "LOW" },
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" },
117
142
  ],
118
143
  },
119
144
  ctx
@@ -125,6 +150,9 @@ describe("usethis_todo tool", () => {
125
150
  expect(out).toContain("T1")
126
151
  expect(out).toContain("D1")
127
152
  expect(out).toContain("+1 more")
153
+ expect(out).toContain("Blocked By (resolved)")
154
+ expect(out).toContain("B1")
155
+ expect(out).toContain("C1")
128
156
  })
129
157
 
130
158
  it("read_by_id returns task with resolved blockers", async () => {
@@ -136,7 +164,7 @@ describe("usethis_todo tool", () => {
136
164
  id: "A1",
137
165
  content: "Task content",
138
166
  description: "More details",
139
- status: "ready",
167
+ status: "todo",
140
168
  priority: "HIGH",
141
169
  blockedBy: ["B1"],
142
170
  },
@@ -150,7 +178,7 @@ describe("usethis_todo tool", () => {
150
178
  {
151
179
  id: "C1",
152
180
  content: "Root blocker",
153
- status: "pending",
181
+ status: "todo",
154
182
  priority: "MED",
155
183
  },
156
184
  ],
@@ -159,16 +187,14 @@ describe("usethis_todo tool", () => {
159
187
  )
160
188
 
161
189
  const out = await read_by_id.execute({ id: "A1" }, ctx)
162
- expect(out).toContain("id: A1")
163
- expect(out).toContain("content:")
190
+ expect(out).toContain("Task:")
191
+ expect(out).toContain("A1")
164
192
  expect(out).toContain("Task content")
165
- expect(out).toContain("blockedBy: B1")
166
- expect(out).toContain("description:")
167
193
  expect(out).toContain("More details")
168
194
 
169
- expect(out).toContain("blockedBy (resolved):")
170
- expect(out).toContain("- B1")
171
- expect(out).toContain("- C1")
195
+ expect(out).toContain("Blocked By (resolved):")
196
+ expect(out).toContain("B1")
197
+ expect(out).toContain("C1")
172
198
  })
173
199
 
174
200
  it("read_by_id returns not found", async () => {
@@ -9,13 +9,15 @@ 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 (
13
- input.tool !== "usethis_todo_write"
14
- && input.tool !== "usethis_todo_update"
15
- && input.tool !== "usethis_todo_read_five"
16
- && input.tool !== "usethis_todo_read"
17
- && input.tool !== "usethis_todo_read_by_id"
18
- ) 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
19
21
 
20
22
  const text = [
21
23
  `## TODO`,
@@ -33,7 +33,8 @@ interface Todo {
33
33
  id: string // E01-S01-T01
34
34
  content: string // Short task summary
35
35
  description?: string // Full task description (optional)
36
- status: string // pending | ready | in_progress | waiting_review | done | cancelled
36
+ releases?: string[] // Release identifiers (optional)
37
+ status: string // todo | in_progress | ready | done
37
38
  priority: string // CRIT | HIGH | MED | LOW
38
39
  blockedBy?: string[] // IDs of blocking tasks
39
40
  createdAt?: number
@@ -103,10 +104,10 @@ async function getNativePaths(sid: string): Promise<string[]> {
103
104
  function toNative(todo: Todo): NativeTodo {
104
105
  // Status mapping: our → native
105
106
  const statusMap: Record<string, string> = {
106
- pending: "pending",
107
- ready: "pending", // native has no "ready"
107
+ todo: "pending",
108
108
  in_progress: "in_progress",
109
- waiting_review: "in_progress", // native has no "waiting_review"
109
+ ready: "in_progress", // native has no "ready"
110
+ finished: "in_progress", // back-compat
110
111
  done: "completed", // native uses "completed" not "done"
111
112
  cancelled: "cancelled",
112
113
  }
@@ -120,10 +121,11 @@ function toNative(todo: Todo): NativeTodo {
120
121
 
121
122
  const deps = todo.blockedBy?.length ? ` [← ${todo.blockedBy.join(", ")}]` : ""
122
123
  const desc = todo.description?.trim() ? ` — ${todo.description.trim()}` : ""
124
+ const rel = todo.releases?.length ? ` [rel: ${todo.releases.join(", ")}]` : ""
123
125
 
124
126
  return {
125
127
  id: todo.id,
126
- content: `${todo.content}${desc}${deps}`,
128
+ content: `${todo.content}${desc}${rel}${deps}`,
127
129
  status: statusMap[todo.status] || "pending",
128
130
  priority: prioMap[todo.priority] || "medium",
129
131
  }
@@ -131,7 +133,9 @@ function toNative(todo: Todo): NativeTodo {
131
133
 
132
134
  async function readTodos(sid: string, directory?: string): Promise<Todo[]> {
133
135
  try {
134
- 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))
135
139
  } catch {
136
140
  return []
137
141
  }
@@ -165,12 +169,13 @@ async function writeTodos(todos: Todo[], sid: string, directory?: string): Promi
165
169
  function analyzeGraph(todos: Todo[]): TodoGraph {
166
170
  const blocked: Record<string, string[]> = {}
167
171
  const availableTodos: Todo[] = []
168
-
172
+
169
173
  for (const todo of todos) {
170
- if (todo.status !== "ready") continue
174
+ if (normalizeStatus(todo.status) !== "todo") continue
171
175
  const activeBlockers = (todo.blockedBy || []).filter(id => {
172
176
  const b = todos.find(t => t.id === id)
173
- return b && b.status !== "done"
177
+ const bs = normalizeStatus(b?.status)
178
+ return b && bs !== "done" && bs !== "cancelled"
174
179
  })
175
180
  if (activeBlockers.length === 0) {
176
181
  availableTodos.push(todo)
@@ -210,52 +215,184 @@ function analyzeGraph(todos: Todo[]): TodoGraph {
210
215
  // ============================================================================
211
216
 
212
217
  const PE = (p?: string) => p === "CRIT" ? "🔴" : p === "HIGH" ? "🟠" : p === "LOW" ? "🟢" : "🟡"
213
- 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" ? "" : "·"
214
219
 
215
- function formatGraph(graph: TodoGraph): string {
216
- const { todos } = graph
217
- const total = todos.length
218
- const done = todos.filter(t => t.status === "done").length
219
- const wip = todos.filter(t => t.status === "in_progress").length
220
+ function normalizeStatus(input: unknown): string {
221
+ const s = String(input || "").trim()
220
222
 
221
- const lines: string[] = [`═══ TODO Graph [${done}/${total} done, ${wip} in progress] ═══`, ""]
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[] = []
222
298
 
223
- lines.push("All Tasks:")
224
299
  for (const t of todos) {
225
- const deps = t.blockedBy?.length ? ` ← ${t.blockedBy.join(", ")}` : ""
226
- const desc = t.description?.trim() ? ` — ${t.description.trim()}` : ""
227
- 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
+ }
228
311
  }
229
- lines.push("")
230
312
 
231
- if (graph.available.length > 0) {
232
- lines.push("Available Now:")
233
- for (const id of graph.available) {
234
- const t = todos.find(x => x.id === id)
235
- const desc = t?.description?.trim() ? ` — ${t.description.trim()}` : ""
236
- lines.push(` → ${PE(t?.priority)} ${id}: ${t?.content}${desc}`)
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
+ }
237
327
  }
238
- } else {
239
- lines.push("Available Now: none")
240
328
  }
241
- lines.push("")
242
329
 
243
- const multi = graph.parallel.filter(g => g.length > 1)
244
- if (multi.length > 0) {
245
- lines.push("Parallel Groups:")
246
- multi.forEach((g, i) => lines.push(` Group ${i + 1}: ${g.join(", ")}`))
247
- lines.push("")
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)
248
349
  }
249
350
 
250
- if (Object.keys(graph.blocked).length > 0) {
251
- lines.push("Blocked:")
252
- for (const [id, blockers] of Object.entries(graph.blocked)) {
253
- const t = todos.find(x => x.id === id)
254
- const desc = t?.description?.trim() ? ` — ${t.description.trim()}` : ""
255
- lines.push(` ⊗ ${id}: ${t?.content}${desc} waiting: ${blockers.join(", ")}`)
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
256
360
  }
361
+
362
+ blockers.push(t)
363
+ if (t.blockedBy?.length) stack.push(...t.blockedBy)
257
364
  }
258
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)")
259
396
  return lines.join("\n")
260
397
  }
261
398
 
@@ -271,7 +408,8 @@ export const write = tool({
271
408
  id: tool.schema.string().describe("Task ID in concat format: E01-S01-T01"),
272
409
  content: tool.schema.string().describe("Short task summary"),
273
410
  description: tool.schema.string().optional().describe("Full task description"),
274
- status: tool.schema.string().describe("pending | ready | in_progress | waiting_review | done | cancelled"),
411
+ releases: tool.schema.array(tool.schema.string()).optional().describe("Release identifiers"),
412
+ status: tool.schema.string().describe("todo | in_progress | ready | done"),
275
413
  priority: tool.schema.string().describe("CRIT | HIGH | MED | LOW"),
276
414
  blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks"),
277
415
  })
@@ -279,7 +417,7 @@ export const write = tool({
279
417
  },
280
418
  async execute(args, context) {
281
419
  const now = Date.now()
282
- 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 }))
283
421
  await writeTodos(todos, context.sessionID, context.directory)
284
422
  return formatGraph(analyzeGraph(todos))
285
423
  },
@@ -290,19 +428,32 @@ export const read_five = tool({
290
428
  args: {},
291
429
  async execute(_args, context) {
292
430
  const todos = await readTodos(context.sessionID, context.directory)
293
- const graph = analyzeGraph(todos)
294
- if (graph.available.length === 0) return "No tasks available. All blocked or not ready."
295
- const next5 = graph.available.slice(0, 5)
296
- const lines: string[] = ["Next 5 available tasks:", ""]
297
- for (const id of next5) {
298
- const t = graph.todos.find(x => x.id === id)
299
- if (t) {
300
- const desc = t.description?.trim() ? `\n ${t.description.trim()}` : ""
301
- lines.push(`${PE(t.priority)} ${id}: ${t.content}${desc}`)
302
- lines.push("")
303
- }
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(", ")}`)
304
455
  }
305
- if (graph.available.length > 5) lines.push(`... +${graph.available.length - 5} more`)
456
+
306
457
  return lines.join("\n")
307
458
  },
308
459
  })
@@ -327,60 +478,19 @@ export const read_by_id = tool({
327
478
  const todo = todos.find(t => t.id === args.id)
328
479
  if (!todo) return `❌ Task ${args.id} not found`
329
480
 
330
- const byId = new Map(todos.map(t => [t.id, t]))
331
-
332
- // Resolve blockers transitively (task -> blockedBy -> blockedBy ...)
333
- const blockers: Todo[] = []
334
- const missing: string[] = []
335
- const seen = new Set<string>()
336
- const stack = [...(todo.blockedBy || [])]
337
-
338
- while (stack.length > 0) {
339
- const id = stack.shift()!
340
- if (seen.has(id)) continue
341
- seen.add(id)
342
-
343
- const t = byId.get(id)
344
- if (!t) {
345
- missing.push(id)
346
- continue
347
- }
348
-
349
- blockers.push(t)
350
- if (t.blockedBy?.length) stack.push(...t.blockedBy)
351
- }
481
+ const { blockers, missing } = resolveBlockers(todos, [todo.id])
352
482
 
353
483
  const lines: string[] = []
354
- lines.push(`id: ${todo.id}`)
355
- lines.push(`priority: ${todo.priority}`)
356
- lines.push(`status: ${todo.status}`)
357
-
358
- if (todo.blockedBy?.length) {
359
- lines.push(`blockedBy: ${todo.blockedBy.join(", ")}`)
360
- }
484
+ lines.push("Task:")
485
+ lines.push(`- ${todoLine(todo, new Map(todos.map(t => [t.id, t])) )}`)
361
486
 
362
487
  lines.push("")
363
- lines.push("content:")
364
- lines.push(todo.content)
365
-
366
- if (todo.description?.trim()) {
367
- lines.push("")
368
- lines.push("description:")
369
- lines.push(todo.description.trim())
370
- }
371
-
372
- if (blockers.length) {
373
- lines.push("")
374
- lines.push("blockedBy (resolved):")
375
- for (const b of blockers) {
376
- const d = b.description?.trim() ? ` — ${b.description.trim()}` : ""
377
- lines.push(`- ${b.id} [${b.status}] ${b.priority}: ${b.content}${d}`)
378
- }
379
- }
488
+ lines.push("Blocked By (resolved):")
489
+ lines.push(blockers.length ? renderNestedTodoList(blockers, todos) : "- (none)")
380
490
 
381
491
  if (missing.length) {
382
492
  lines.push("")
383
- lines.push(`blockedBy missing: ${missing.join(", ")}`)
493
+ lines.push(`Blocked By missing: ${missing.join(", ")}`)
384
494
  }
385
495
 
386
496
  return lines.join("\n")
@@ -395,9 +505,10 @@ export const update = tool({
395
505
  id: tool.schema.string().describe("Task ID in concat format: E01-S01-T01"),
396
506
  content: tool.schema.string().describe("Short task summary"),
397
507
  description: tool.schema.string().optional().describe("Full task description"),
398
- status: tool.schema.string().describe("pending | ready | in_progress | waiting_review | done | cancelled"),
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"),
399
510
  priority: tool.schema.string().describe("CRIT | HIGH | MED | LOW"),
400
- blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks"),
511
+ blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks(from todo -> blocked)"),
401
512
  })
402
513
  ).describe("Array of todos to update"),
403
514
  },
@@ -407,12 +518,16 @@ export const update = tool({
407
518
  const byId = new Map(todos.map(t => [t.id, t]))
408
519
 
409
520
  for (const incoming of args.todos) {
410
- const existing = byId.get(incoming.id)
521
+ const normalizedIncoming: any = normalizeTodo(incoming)
522
+ const existing = byId.get(normalizedIncoming.id)
411
523
  if (existing) {
412
- Object.assign(existing, incoming)
524
+ Object.assign(existing, normalizedIncoming)
413
525
  existing.updatedAt = now
526
+ // Ensure auto transition is applied after merge
527
+ existing.status = normalizeTodo(existing).status
528
+ existing.releases = normalizeTodo(existing).releases
414
529
  } else {
415
- byId.set(incoming.id, { ...incoming, createdAt: now, updatedAt: now })
530
+ byId.set(normalizedIncoming.id, normalizeTodo({ ...normalizedIncoming, createdAt: now, updatedAt: now }))
416
531
  }
417
532
  }
418
533