@comfanion/usethis_todo 0.1.16-dev.1 → 0.1.16-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.
Files changed (3) hide show
  1. package/index.ts +13 -36
  2. package/package.json +1 -1
  3. package/tools.ts +121 -116
package/index.ts CHANGED
@@ -2,7 +2,7 @@ import type { Plugin } from "@opencode-ai/plugin"
2
2
  import path from "path"
3
3
  import fs from "fs/promises"
4
4
 
5
- import { write, read, read_five, read_by_id, update, setNativeStorageBase } from "./tools"
5
+ import { write, read, read_five, read_by_id, setNativeStorageBase } from "./tools"
6
6
 
7
7
  interface TodoPruneState {
8
8
  lastToolCallId: string | null
@@ -37,13 +37,10 @@ const UsethisTodoPlugin: Plugin = async ({ directory, client }) => {
37
37
  if (!state?.lastToolCallId) return
38
38
 
39
39
  const prunedToolNames = new Set([
40
- "usethis_todo_write",
41
- "usethis_todo_update",
42
- "usethis_todo_read",
43
- "usethis_todo_read_five",
44
- "usethis_todo_read_by_id",
45
40
  "todowrite",
46
41
  "todoread",
42
+ "usethis_todo_read_five",
43
+ "usethis_todo_read_by_id",
47
44
  ])
48
45
 
49
46
  // Collect all TODO-related tool parts
@@ -69,18 +66,19 @@ const UsethisTodoPlugin: Plugin = async ({ directory, client }) => {
69
66
  },
70
67
 
71
68
  tool: {
72
- // Enhanced tools only NO native overrides
73
- // Native todowrite/todoread stay untouched → Bus.publish fires → sidebar works
74
- usethis_todo_write: write,
75
- usethis_todo_read: read,
69
+ // Native overrides tool named "todowrite"/"todoread" completing
70
+ // triggers Bus.publish("todo.updated") → sidebar refresh
71
+ todowrite: write,
72
+ todoread: read,
73
+ // Extra tools (no native equivalent)
76
74
  usethis_todo_read_five: read_five,
77
75
  usethis_todo_read_by_id: read_by_id,
78
- usethis_todo_update: update,
79
76
  },
80
77
 
81
- // Set nicer titles in TUI + track prune state (enhanced tools only)
78
+ // Set nicer titles in TUI + track prune state
82
79
  "tool.execute.after": async (input, output) => {
83
- if (!input.tool.startsWith("usethis_todo_")) return
80
+ const ourTools = new Set(["todowrite", "todoread", "usethis_todo_read_five", "usethis_todo_read_by_id"])
81
+ if (!ourTools.has(input.tool)) return
84
82
 
85
83
  // Update prune state with latest call ID
86
84
  const sessionID = input.sessionID
@@ -93,13 +91,10 @@ const UsethisTodoPlugin: Plugin = async ({ directory, client }) => {
93
91
  const out = output.output || ""
94
92
 
95
93
  // Set a nicer title in TUI
96
- if (input.tool === "usethis_todo_write") {
94
+ if (input.tool === "todowrite") {
97
95
  const match = out.match(/\[(\d+)\/(\d+) done/)
98
96
  output.title = match ? `TODO: ${match[2]} tasks` : "TODO updated"
99
- } else if (input.tool === "usethis_todo_update") {
100
- const match = out.match(/^✅ (.+)$/m)
101
- output.title = match ? match[1] : "Task updated"
102
- } else if (input.tool === "usethis_todo_read") {
97
+ } else if (input.tool === "todoread") {
103
98
  const match = out.match(/\[(\d+)\/(\d+) done, (\d+) in progress\]/)
104
99
  output.title = match ? `TODO [${match[1]}/${match[2]} done]` : "TODO list"
105
100
  } else if (input.tool === "usethis_todo_read_five") {
@@ -107,24 +102,6 @@ const UsethisTodoPlugin: Plugin = async ({ directory, client }) => {
107
102
  } else if (input.tool === "usethis_todo_read_by_id") {
108
103
  output.title = "Task details"
109
104
  }
110
-
111
- // Publish TODO snapshot into chat for write/update ops
112
- const isWriteOp = input.tool === "usethis_todo_write"
113
- || input.tool === "usethis_todo_update"
114
- if (!isWriteOp) return
115
-
116
- const text = ["## TODO", "", out].join("\n")
117
- try {
118
- await client?.session?.prompt?.({
119
- path: { id: input.sessionID },
120
- body: {
121
- noReply: true,
122
- parts: [{ type: "text", text }],
123
- },
124
- })
125
- } catch {
126
- // non-fatal
127
- }
128
105
  },
129
106
  }
130
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comfanion/usethis_todo",
3
- "version": "0.1.16-dev.1",
3
+ "version": "0.1.16-dev.2",
4
4
  "description": "OpenCode plugin: enhanced TODO tools (dual storage + dependency graph)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/tools.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  /**
2
- * TODO Tool with Dependencies & Priority — v3 (unified storage)
2
+ * TODO Tool with Dependencies & Priority — v4 (unified storage)
3
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() - read next 5 tasks with their blockers
8
- * usethis_todo_read_by_id() - read task by id with its blockers
9
- * usethis_todo_update(id, field, value) - update any task field
4
+ * 4 tools:
5
+ * todowrite({ todos: [...], merge? }) - create/update TODO list (upsert by default)
6
+ * todoread() - read TODO with graph analysis
7
+ * todoread_five() - read next 5 tasks with their blockers
8
+ * todoread_by_id({ id }) - read task by id with its blockers
10
9
  *
11
10
  * Storage:
12
11
  * Unified: Native OpenCode storage (TUI compatible)
@@ -16,6 +15,7 @@
16
15
  * - Stores FULL enhanced schema (blockedBy, priority, etc) in the native file
17
16
  * - Maps statuses to native values (pending/completed) for Sidebar compatibility
18
17
  * - Preserves original statuses in `usethisStatus` field
18
+ * - merge (default: true) = upsert; merge: false = replace entire list
19
19
  */
20
20
 
21
21
  import { tool } from "@opencode-ai/plugin"
@@ -71,14 +71,14 @@ interface Todo {
71
71
  content: string // Short task summary
72
72
  description?: string // Full task description (optional)
73
73
  releases?: string[] // Release identifiers (optional)
74
- status: string // todo | in_progress | ready | done
74
+ status: string // pending | in_progress | ready | completed | cancelled
75
75
  priority: string // CRIT | HIGH | MED | LOW
76
76
  blockedBy?: string[] // IDs of blocking tasks
77
77
  createdAt?: number
78
78
  updatedAt?: number
79
79
 
80
80
  // Shadow fields for native compatibility
81
- usethisStatus?: string // The REAL status (todo, ready, etc)
81
+ usethisStatus?: string // The REAL status (pending, ready, etc)
82
82
  usethisPriority?: string // The REAL priority (CRIT, HIGH, etc)
83
83
 
84
84
  // Mind extensions (backward compatible - optional)
@@ -132,22 +132,28 @@ async function logAction(directory: string, action: string, details: string): Pr
132
132
  }
133
133
 
134
134
  async function getNativeDataDirs(): Promise<string[]> {
135
- const dirs = new Set<string>()
136
-
137
- // 1) xdg-basedir (what OpenCode itself uses)
138
- // Removed dynamic import to avoid "chunk not found" errors in some environments
139
- // relying on standard env vars and paths instead
135
+ const dirs: string[] = []
136
+ const seen = new Set<string>()
140
137
 
141
- // 2) explicit XDG override
138
+ // 1) explicit XDG override (highest priority)
142
139
  if (process.env.XDG_DATA_HOME) {
143
- dirs.add(process.env.XDG_DATA_HOME)
140
+ dirs.push(process.env.XDG_DATA_HOME)
141
+ seen.add(process.env.XDG_DATA_HOME)
144
142
  }
145
143
 
146
- // 3) common fallbacks
147
- dirs.add(path.join(os.homedir(), ".local", "share"))
148
- dirs.add(path.join(os.homedir(), "Library", "Application Support"))
144
+ // 2) common fallbacks
145
+ const fallbacks = [
146
+ path.join(os.homedir(), ".local", "share"),
147
+ path.join(os.homedir(), "Library", "Application Support"),
148
+ ]
149
+ for (const dir of fallbacks) {
150
+ if (!seen.has(dir)) {
151
+ dirs.push(dir)
152
+ seen.add(dir)
153
+ }
154
+ }
149
155
 
150
- return [...dirs]
156
+ return dirs
151
157
  }
152
158
 
153
159
  async function getStoragePaths(sid: string): Promise<string[]> {
@@ -172,30 +178,7 @@ async function getStoragePaths(sid: string): Promise<string[]> {
172
178
  // Mapping Logic (Enhanced <-> Native)
173
179
  // ============================================================================
174
180
 
175
- function toNativeStatus(status: string): string {
176
- switch (status) {
177
- case "todo": return "pending"
178
- case "in_progress": return "in_progress"
179
- case "ready": return "in_progress" // Sidebar has no 'ready'
180
- case "done": return "completed"
181
- case "cancelled": return "cancelled"
182
- default: return "pending"
183
- }
184
- }
185
181
 
186
- function fromNativeStatus(nativeStatus: string, originalStatus?: string): string {
187
- // If we have the original status stored, prefer it
188
- if (originalStatus) return originalStatus
189
-
190
- // Otherwise map back (lossy for ready/todo distinction)
191
- switch (nativeStatus) {
192
- case "pending": return "todo"
193
- case "in_progress": return "in_progress"
194
- case "completed": return "done"
195
- case "cancelled": return "cancelled"
196
- default: return "todo"
197
- }
198
- }
199
182
 
200
183
  function toNativePriority(priority: string): string {
201
184
  switch (priority) {
@@ -214,13 +197,13 @@ function toNativePriority(priority: string): string {
214
197
  async function readTodos(sid: string, directory?: string): Promise<Todo[]> {
215
198
  try {
216
199
  const storagePaths = await getStoragePaths(sid)
217
- // Try to read from any available path, starting with authoritative
218
200
  for (const storagePath of storagePaths) {
219
201
  try {
220
202
  const raw = JSON.parse(await fs.readFile(storagePath, "utf-8"))
221
203
  if (Array.isArray(raw)) {
222
204
  return raw.map((t: any) => {
223
- const realStatus = fromNativeStatus(t.status, t.usethisStatus)
205
+ // Restore "ready" status if it was saved
206
+ const realStatus = t.usethisStatus || t.status
224
207
  const realPriority = t.usethisPriority || t.priority
225
208
  return normalizeTodo({ ...t, status: realStatus, priority: realPriority })
226
209
  })
@@ -243,15 +226,17 @@ async function writeTodos(todos: Todo[], sid: string, directory?: string): Promi
243
226
  const enhancedPath = getEnhancedPath(sid, directory)
244
227
 
245
228
  const storageTodos = todos.map(t => {
246
- const nativeStatus = toNativeStatus(t.status)
229
+ // Status is already native — map "ready" to "in_progress" for native storage
230
+ const nativeStatus = t.status === "ready" ? "in_progress" : t.status
247
231
  const nativePriority = toNativePriority(t.priority)
248
232
 
249
233
  return {
250
234
  ...t,
251
235
  status: nativeStatus,
252
236
  priority: nativePriority,
253
- usethisStatus: t.status,
254
- usethisPriority: t.priority
237
+ usethisPriority: t.priority,
238
+ // Keep "ready" status in a field so we can restore it on read
239
+ ...(t.status === "ready" ? { usethisStatus: "ready" } : {}),
255
240
  }
256
241
  })
257
242
 
@@ -289,11 +274,11 @@ function analyzeGraph(todos: Todo[]): TodoGraph {
289
274
  const availableTodos: Todo[] = []
290
275
 
291
276
  for (const todo of todos) {
292
- if (normalizeStatus(todo.status) !== "todo") continue
277
+ if (normalizeStatus(todo.status) !== "pending") continue
293
278
  const activeBlockers = (todo.blockedBy || []).filter(id => {
294
279
  const b = todos.find(t => t.id === id)
295
280
  const bs = normalizeStatus(b?.status)
296
- return b && bs !== "done" && bs !== "cancelled"
281
+ return b && bs !== "completed" && bs !== "cancelled"
297
282
  })
298
283
  if (activeBlockers.length === 0) {
299
284
  availableTodos.push(todo)
@@ -333,24 +318,24 @@ function analyzeGraph(todos: Todo[]): TodoGraph {
333
318
  // ============================================================================
334
319
 
335
320
  const PE = (p?: string) => p === "CRIT" ? "🔴" : p === "HIGH" ? "🟠" : p === "LOW" ? "🟢" : "🟡"
336
- const SI = (s: string) => s === "done" ? "✓" : s === "in_progress" ? "⚙" : s === "ready" ? "⏳" : s === "cancelled" ? "✗" : s === "todo" ? "○" : "·"
321
+ const SI = (s: string) => s === "completed" ? "✓" : s === "in_progress" ? "⚙" : s === "ready" ? "⏳" : s === "cancelled" ? "✗" : s === "pending" ? "○" : "·"
337
322
 
338
323
  function normalizeStatus(input: unknown): string {
339
324
  const s = String(input || "").trim()
340
325
 
341
- // New canonical set
342
- if (s === "todo" || s === "in_progress" || s === "ready" || s === "done") return s
326
+ // Canonical set (aligned with native OpenCode)
327
+ if (s === "pending" || s === "in_progress" || s === "completed" || s === "cancelled") return s
343
328
 
344
- // Back-compat (older versions)
345
- if (s === "pending") return "todo"
346
- if (s === "waiting_review" || s === "finished") return "ready"
347
- if (s === "completed") return "done"
329
+ // Our extension
330
+ if (s === "ready") return "ready"
348
331
 
349
- // Keep cancelled if it appears (native supports it)
350
- if (s === "cancelled") return "cancelled"
332
+ // Back-compat (older versions used different names)
333
+ if (s === "todo") return "pending"
334
+ if (s === "done" || s === "finished") return "completed"
335
+ if (s === "waiting_review") return "ready"
351
336
 
352
337
  // Default
353
- return "todo"
338
+ return "pending"
354
339
  }
355
340
 
356
341
  function normalizeReleases(input: unknown): string[] | undefined {
@@ -365,8 +350,8 @@ function normalizeTodo(input: any): Todo {
365
350
  const status = normalizeStatus(input?.status)
366
351
  const releases = normalizeReleases(input?.releases)
367
352
 
368
- // Auto transition: ready -> done when releases exist
369
- const promotedStatus = status === "ready" && releases?.length ? "done" : status
353
+ // Auto transition: ready -> completed when releases exist
354
+ const promotedStatus = status === "ready" && releases?.length ? "completed" : status
370
355
 
371
356
  // Initialize Mind extensions if provided (backward compatible)
372
357
  const normalized: Todo = {
@@ -401,13 +386,13 @@ function sortTodosForList(todos: Todo[]): Todo[] {
401
386
 
402
387
  function isBlocked(todo: Todo, byId: Map<string, Todo>): boolean {
403
388
  const s = normalizeStatus(todo.status)
404
- if (s !== "todo") return false
389
+ if (s !== "pending") return false
405
390
  if (!todo.blockedBy?.length) return false
406
391
  for (const id of todo.blockedBy) {
407
392
  const b = byId.get(id)
408
393
  const bs = normalizeStatus(b?.status)
409
394
  if (!b) return true
410
- if (bs !== "done" && bs !== "cancelled") return true
395
+ if (bs !== "completed" && bs !== "cancelled") return true
411
396
  }
412
397
  return false
413
398
  }
@@ -508,7 +493,7 @@ function resolveBlockers(todos: Todo[], rootIds: string[]): { blockers: Todo[];
508
493
  function formatGraph(graph: TodoGraph): string {
509
494
  const { todos } = graph
510
495
  const total = todos.length
511
- const done = todos.filter(t => normalizeStatus(t.status) === "done").length
496
+ const done = todos.filter(t => normalizeStatus(t.status) === "completed").length
512
497
  const wip = todos.filter(t => normalizeStatus(t.status) === "in_progress").length
513
498
 
514
499
  const availableTodos = graph.available
@@ -538,37 +523,76 @@ function formatGraph(graph: TodoGraph): string {
538
523
  // ============================================================================
539
524
 
540
525
  export const write = tool({
541
- description: "Create or update TODO list. Use this for TODO. For better performance use ID for task relation",
526
+ description: `Create or update TODO list. Use this for TODO. For better performance use ID for task relation.
527
+
528
+ By default (merge: true) this upserts — only specified tasks are created/updated, all others are preserved.
529
+ Set merge: false to replace the entire list.`,
542
530
  args: {
543
531
  todos: tool.schema.array(
544
532
  tool.schema.object({
545
533
  id: tool.schema.string().describe("Task ID in concat format: E01-S01-T01(for graph dependency)"),
546
534
  content: tool.schema.string().describe("Short task summary or title"),
547
535
  description: tool.schema.string().optional().describe("Full task description. Helps to remember what should be done"),
548
- releases: tool.schema.array(tool.schema.string()).optional().describe("Task IDs that auto-promote this task from ready → done when set (e.g. subtask IDs that were completed)"),
549
- status: tool.schema.string().describe("todo | in_progress | ready | done"),
536
+ releases: tool.schema.array(tool.schema.string()).optional().describe("Task IDs that auto-promote this task from ready → completed when set"),
537
+ status: tool.schema.string().describe("pending | in_progress | ready | completed | cancelled"),
550
538
  priority: tool.schema.string().describe("CRIT | HIGH | MED | LOW"),
551
539
  blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks. this help you understand scope better"),
552
540
  }),
553
541
  ).describe("Array of todos"),
542
+ merge: tool.schema.boolean().optional().default(true).describe("When true (default): merge/upsert — only updates specified tasks, preserves others. When false: replaces entire list."),
554
543
  },
555
544
  async execute(args, context) {
556
545
  const now = Date.now()
557
- const todos = args.todos.map((t: any) => normalizeTodo({ ...t, createdAt: t.createdAt || now, updatedAt: now }))
558
- await writeTodos(todos, context.sessionID, context.directory)
559
- await logAction(context.directory, "write", `Created/Updated ${todos.length} tasks in session ${context.sessionID}`)
560
- return formatGraph(analyzeGraph(todos))
546
+
547
+ if (args.merge !== false) {
548
+ // MERGE mode (default): read existing, merge incoming
549
+ const existing = await readTodos(context.sessionID, context.directory)
550
+ const byId = new Map(existing.map(t => [t.id, t]))
551
+
552
+ for (const incoming of args.todos) {
553
+ const normalizedIncoming: any = normalizeTodo(incoming)
554
+ const existingTodo = byId.get(normalizedIncoming.id)
555
+ if (existingTodo) {
556
+ Object.assign(existingTodo, normalizedIncoming)
557
+ existingTodo.updatedAt = now
558
+ existingTodo.status = normalizeTodo(existingTodo).status
559
+ existingTodo.releases = normalizeTodo(existingTodo).releases
560
+ } else {
561
+ byId.set(normalizedIncoming.id, normalizeTodo({ ...normalizedIncoming, createdAt: now, updatedAt: now }))
562
+ }
563
+ }
564
+
565
+ const merged = [...byId.values()]
566
+ await writeTodos(merged, context.sessionID, context.directory)
567
+ await logAction(context.directory, "write-merge", `Merged ${args.todos.length} task(s) in session ${context.sessionID}`)
568
+ return formatGraph(analyzeGraph(merged))
569
+ } else {
570
+ // REPLACE mode: replace entire list
571
+ const todos = args.todos.map((t: any) => normalizeTodo({ ...t, createdAt: t.createdAt || now, updatedAt: now }))
572
+ await writeTodos(todos, context.sessionID, context.directory)
573
+ await logAction(context.directory, "write", `Created/Updated ${todos.length} tasks in session ${context.sessionID}`)
574
+ return formatGraph(analyzeGraph(todos))
575
+ }
561
576
  },
562
577
  })
563
578
 
564
579
  export const read_five = tool({
565
- description: "Read current TODO list. Shows Next 5 tasks.",
580
+ description: `Read the next 5 actionable tasks unblocked, sorted by priority.
581
+
582
+ Returns:
583
+ - Next 5: the top-priority unblocked tasks you can work on right now
584
+ - Blocked By (resolved): full dependency chain for those tasks
585
+ - Count of remaining tasks beyond the top 5
586
+
587
+ Use this as your primary "what should I do next?" tool. More focused than usethis_todo_read() — shows only actionable items without the full list.
588
+
589
+ Call this after completing a task to see what's next.`,
566
590
  args: {},
567
591
  async execute(_args, context) {
568
592
  const todos = await readTodos(context.sessionID, context.directory)
569
- const ready = sortTodosForList(todos.filter(t => normalizeStatus(t.status) === "todo"))
593
+ const ready = sortTodosForList(todos.filter(t => normalizeStatus(t.status) === "pending"))
570
594
  await logAction(context.directory, "read_five", `Read next 5 tasks (total ready: ${ready.length})`)
571
- if (ready.length === 0) return "No tasks in todo."
595
+ if (ready.length === 0) return "No tasks in pending."
572
596
 
573
597
  const items = ready.slice(0, 5)
574
598
 
@@ -598,7 +622,20 @@ export const read_five = tool({
598
622
  })
599
623
 
600
624
  export const read = tool({
601
- description: "Read current TODO list. Shows all tasks.",
625
+ description: `Read the full TODO list with dependency graph analysis.
626
+
627
+ Returns:
628
+ - All Tasks: complete list grouped by epic/story hierarchy with status icons
629
+ - Available Now: unblocked tasks ready to work on, sorted by priority
630
+ - Blocked: tasks waiting on dependencies, showing what blocks them
631
+ - Progress summary: [done/total done, N in progress]
632
+
633
+ Use this to:
634
+ - Review overall progress and plan next steps
635
+ - Understand which tasks are blocked and why
636
+ - Get the full picture before making decisions
637
+
638
+ For a quick view of just the next actionable tasks, use usethis_todo_read_five() instead.`,
602
639
  args: {},
603
640
  async execute(_args, context) {
604
641
  const todos = await readTodos(context.sessionID, context.directory)
@@ -609,7 +646,14 @@ export const read = tool({
609
646
  })
610
647
 
611
648
  export const read_by_id = tool({
612
- description: "Read task by id.",
649
+ description: `Read a single task by ID with its full dependency chain.
650
+
651
+ Returns:
652
+ - Task details: status, priority, description, content
653
+ - Blocked By (resolved): complete chain of blockers (recursive — shows blockers of blockers)
654
+ - Missing dependencies: IDs referenced in blockedBy that don't exist
655
+
656
+ Use this to inspect a specific task's details and understand what's blocking it before starting work.`,
613
657
  args: {
614
658
  id: tool.schema.string().describe("Task ID"),
615
659
  },
@@ -641,43 +685,4 @@ export const read_by_id = tool({
641
685
  },
642
686
  })
643
687
 
644
- export const update = tool({
645
- description: "Update task(s). Send 1 or many for update",
646
- args: {
647
- todos: tool.schema.array(
648
- tool.schema.object({
649
- id: tool.schema.string().describe("Task ID in concat format: E01-S01-T01"),
650
- content: tool.schema.string().describe("Short task summary"),
651
- description: tool.schema.string().optional().describe("Full task description"),
652
- releases: tool.schema.array(tool.schema.string()).optional().describe("Task IDs that auto-promote this task from ready → done when set (e.g. subtask IDs that were completed)"),
653
- status: tool.schema.string().describe("todo | in_progress | ready | done"),
654
- priority: tool.schema.string().describe("CRIT | HIGH | MED | LOW"),
655
- blockedBy: tool.schema.array(tool.schema.string()).optional().describe("IDs of blocking tasks(from todo -> blocked)"),
656
- }),
657
- ).describe("Array of todos to update"),
658
- },
659
- async execute(args, context) {
660
- const todos = await readTodos(context.sessionID, context.directory)
661
- const now = Date.now()
662
- const byId = new Map(todos.map(t => [t.id, t]))
663
-
664
- for (const incoming of args.todos) {
665
- const normalizedIncoming: any = normalizeTodo(incoming)
666
- const existing = byId.get(normalizedIncoming.id)
667
- if (existing) {
668
- Object.assign(existing, normalizedIncoming)
669
- existing.updatedAt = now
670
- // Ensure auto transition is applied after merge
671
- existing.status = normalizeTodo(existing).status
672
- existing.releases = normalizeTodo(existing).releases
673
- } else {
674
- byId.set(normalizedIncoming.id, normalizeTodo({ ...normalizedIncoming, createdAt: now, updatedAt: now }))
675
- }
676
- }
677
688
 
678
- const merged = [...byId.values()]
679
- await writeTodos(merged, context.sessionID, context.directory)
680
- await logAction(context.directory, "update", `Updated ${args.todos.length} task(s) in session ${context.sessionID}`)
681
- return `✅ Updated ${args.todos.length} task(s)\n\n${formatGraph(analyzeGraph(merged))}`
682
- },
683
- })