@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
package/src/build-info.json
CHANGED
|
@@ -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,
|
|
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: "
|
|
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
|
|
@@ -103,7 +126,36 @@ describe("usethis_todo tool", () => {
|
|
|
103
126
|
expect(output).toContain("Available Now")
|
|
104
127
|
})
|
|
105
128
|
|
|
106
|
-
it("
|
|
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: "
|
|
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
|
|
125
|
-
expect(out).toContain("
|
|
126
|
-
expect(out).toContain("
|
|
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("
|
|
200
|
+
it("read_by_id returns not found", async () => {
|
|
134
201
|
const ctx = { sessionID: "sess-miss", directory: tempDir } as any
|
|
135
|
-
const out = await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
ready: "pending", // native has no "ready"
|
|
107
|
+
todo: "pending",
|
|
107
108
|
in_progress: "in_progress",
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 !== "
|
|
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
|
-
|
|
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" ? "
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
|
320
|
-
description: "
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
501
|
+
description: "Update task(s). Send 1 or many for update",
|
|
344
502
|
args: {
|
|
345
503
|
todos: tool.schema.array(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
521
|
+
const normalizedIncoming: any = normalizeTodo(incoming)
|
|
522
|
+
const existing = byId.get(normalizedIncoming.id)
|
|
363
523
|
if (existing) {
|
|
364
|
-
Object.assign(existing,
|
|
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(
|
|
530
|
+
byId.set(normalizedIncoming.id, normalizeTodo({ ...normalizedIncoming, createdAt: now, updatedAt: now }))
|
|
368
531
|
}
|
|
369
532
|
}
|
|
370
533
|
|