@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
package/src/build-info.json
CHANGED
|
@@ -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: "
|
|
32
|
-
{ id: "A2", content: "Second task", status: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
112
|
-
{ id: "A2", content: "T2", status: "
|
|
113
|
-
{ id: "A3", content: "T3", status: "
|
|
114
|
-
{ id: "A4", content: "T4", status: "
|
|
115
|
-
{ id: "A5", content: "T5", status: "
|
|
116
|
-
{ id: "A6", content: "T6", status: "
|
|
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: "
|
|
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: "
|
|
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("
|
|
163
|
-
expect(out).toContain("
|
|
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("
|
|
170
|
-
expect(out).toContain("
|
|
171
|
-
expect(out).toContain("
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
)
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
ready: "pending", // native has no "ready"
|
|
107
|
+
todo: "pending",
|
|
108
108
|
in_progress: "in_progress",
|
|
109
|
-
|
|
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
|
-
|
|
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 !== "
|
|
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
|
-
|
|
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" ? "
|
|
218
|
+
const SI = (s: string) => s === "done" ? "✓" : s === "in_progress" ? "⚙" : s === "ready" ? "⏳" : s === "cancelled" ? "✗" : s === "todo" ? "○" : "·"
|
|
214
219
|
|
|
215
|
-
function
|
|
216
|
-
const
|
|
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
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
lines.push(
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
355
|
-
lines.push(
|
|
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("
|
|
364
|
-
lines.push(
|
|
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(`
|
|
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
|
-
|
|
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
|
|
521
|
+
const normalizedIncoming: any = normalizeTodo(incoming)
|
|
522
|
+
const existing = byId.get(normalizedIncoming.id)
|
|
411
523
|
if (existing) {
|
|
412
|
-
Object.assign(existing,
|
|
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(
|
|
530
|
+
byId.set(normalizedIncoming.id, normalizeTodo({ ...normalizedIncoming, createdAt: now, updatedAt: now }))
|
|
416
531
|
}
|
|
417
532
|
}
|
|
418
533
|
|