@comfanion/usethis_todo 0.1.0
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/LICENSE +21 -0
- package/README.md +21 -0
- package/index.ts +68 -0
- package/package.json +27 -0
- package/tools.ts +538 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Comfanion
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @comfanion/usethis_todo
|
|
2
|
+
|
|
3
|
+
OpenCode plugin that provides enhanced TODO tools (dual storage + dependency graph).
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
- `usethis_todo_write`
|
|
8
|
+
- `usethis_todo_read`
|
|
9
|
+
- `usethis_todo_read_five`
|
|
10
|
+
- `usethis_todo_read_by_id`
|
|
11
|
+
- `usethis_todo_update`
|
|
12
|
+
|
|
13
|
+
## Install (OpenCode)
|
|
14
|
+
|
|
15
|
+
Add to `opencode.json`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"plugin": ["@comfanion/usethis_todo"]
|
|
20
|
+
}
|
|
21
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
import { write, read, read_five, read_by_id, update } from "./tools"
|
|
4
|
+
|
|
5
|
+
// Re-export tools for programmatic use/tests
|
|
6
|
+
export { write, read, read_five, read_by_id, update } from "./tools"
|
|
7
|
+
|
|
8
|
+
export const UsethisTodoPlugin: Plugin = async ({ client }) => {
|
|
9
|
+
return {
|
|
10
|
+
tool: {
|
|
11
|
+
usethis_todo_write: write,
|
|
12
|
+
usethis_todo_read: read,
|
|
13
|
+
usethis_todo_read_five: read_five,
|
|
14
|
+
usethis_todo_read_by_id: read_by_id,
|
|
15
|
+
usethis_todo_update: update,
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
// UI niceties + publish snapshot into the chat
|
|
19
|
+
"tool.execute.after": async (input, output) => {
|
|
20
|
+
if (!input.tool.startsWith("usethis_todo_")) return
|
|
21
|
+
|
|
22
|
+
const out = output.output || ""
|
|
23
|
+
|
|
24
|
+
// Set a nicer title in TUI
|
|
25
|
+
if (input.tool === "usethis_todo_write") {
|
|
26
|
+
const match = out.match(/\[(\d+)\/(\d+) done/)
|
|
27
|
+
output.title = match ? `📋 TODO: ${match[2]} tasks` : "📋 TODO updated"
|
|
28
|
+
} else if (input.tool === "usethis_todo_update") {
|
|
29
|
+
const match = out.match(/^✅ (.+)$/m)
|
|
30
|
+
output.title = match ? `📝 ${match[1]}` : "📝 Task updated"
|
|
31
|
+
} else if (input.tool === "usethis_todo_read") {
|
|
32
|
+
const match = out.match(/\[(\d+)\/(\d+) done, (\d+) in progress\]/)
|
|
33
|
+
output.title = match ? `📋 TODO [${match[1]}/${match[2]} done]` : "📋 TODO list"
|
|
34
|
+
} else if (input.tool === "usethis_todo_read_five") {
|
|
35
|
+
output.title = "📋 Next 5 tasks"
|
|
36
|
+
} else if (input.tool === "usethis_todo_read_by_id") {
|
|
37
|
+
output.title = "📋 Task details"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Publish snapshot into chat (helps when sidebar doesn't refresh)
|
|
41
|
+
const publishTools = new Set([
|
|
42
|
+
"usethis_todo_write",
|
|
43
|
+
"usethis_todo_update",
|
|
44
|
+
"usethis_todo_read",
|
|
45
|
+
"usethis_todo_read_five",
|
|
46
|
+
"usethis_todo_read_by_id",
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
if (!publishTools.has(input.tool)) return
|
|
50
|
+
|
|
51
|
+
const text = ["## TODO", "", out].join("\n")
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await client?.session?.prompt?.({
|
|
55
|
+
path: { id: input.sessionID },
|
|
56
|
+
body: {
|
|
57
|
+
noReply: true,
|
|
58
|
+
parts: [{ type: "text", text }],
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
} catch {
|
|
62
|
+
// non-fatal
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default UsethisTodoPlugin
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@comfanion/usethis_todo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin: enhanced TODO tools (dual storage + dependency graph)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"exports": "./index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "bun test"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.ts",
|
|
13
|
+
"tools.ts",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@opencode-ai/plugin": "1.1.39"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@opencode-ai/plugin": ">=1.1.0"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT"
|
|
27
|
+
}
|
package/tools.ts
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TODO Tool with Dependencies & Priority — v3 (dual storage)
|
|
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() - get next 5 available tasks
|
|
8
|
+
* usethis_todo_read_by_id() - get next 5 available tasks
|
|
9
|
+
* usethis_todo_update(id, field, value) - update any task field
|
|
10
|
+
*
|
|
11
|
+
* Storage:
|
|
12
|
+
* Enhanced: .opencode/session-todos/{sid}.json (title, blockedBy, graph)
|
|
13
|
+
* Native: ~/.local/share/opencode/storage/todo/{sid}.json (TUI display)
|
|
14
|
+
*
|
|
15
|
+
* Features:
|
|
16
|
+
* - Hierarchical IDs: E01-S01-T01
|
|
17
|
+
* - Dependencies: blockedBy field
|
|
18
|
+
* - Priority: CRIT | HIGH | MED | LOW (auto-sorted)
|
|
19
|
+
* - Graph: shows available, blocked, parallel tasks
|
|
20
|
+
* - Dual write: native OpenCode storage for TUI integration
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { tool } from "@opencode-ai/plugin"
|
|
24
|
+
import path from "path"
|
|
25
|
+
import os from "os"
|
|
26
|
+
import fs from "fs/promises"
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Types
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
interface Todo {
|
|
33
|
+
id: string // E01-S01-T01
|
|
34
|
+
content: string // Short task summary
|
|
35
|
+
description?: string // Full task description (optional)
|
|
36
|
+
releases?: string[] // Release identifiers (optional)
|
|
37
|
+
status: string // todo | in_progress | ready | done
|
|
38
|
+
priority: string // CRIT | HIGH | MED | LOW
|
|
39
|
+
blockedBy?: string[] // IDs of blocking tasks
|
|
40
|
+
createdAt?: number
|
|
41
|
+
updatedAt?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface NativeTodo {
|
|
45
|
+
id: string
|
|
46
|
+
content: string // "title: content" combined
|
|
47
|
+
status: string // pending | in_progress | completed | cancelled
|
|
48
|
+
priority: string // high | medium | low
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TodoGraph {
|
|
52
|
+
todos: Todo[]
|
|
53
|
+
available: string[]
|
|
54
|
+
parallel: string[][]
|
|
55
|
+
blocked: Record<string, string[]>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// Storage — dual write
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
// Resolve project directory (context.directory may be undefined via MCP)
|
|
63
|
+
function dir(directory?: string): string {
|
|
64
|
+
return directory || process.env.OPENCODE_PROJECT_DIR || process.cwd()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Enhanced storage path (project-local)
|
|
68
|
+
function getEnhancedPath(sid: string, directory?: string): string {
|
|
69
|
+
return path.join(dir(directory), ".opencode", "session-todos", `${sid || "current"}.json`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function getNativeDataDirs(): Promise<string[]> {
|
|
73
|
+
const dirs = new Set<string>()
|
|
74
|
+
|
|
75
|
+
// 1) xdg-basedir (what OpenCode itself uses)
|
|
76
|
+
try {
|
|
77
|
+
const mod: any = await import("xdg-basedir")
|
|
78
|
+
if (mod?.xdgData && typeof mod.xdgData === "string") {
|
|
79
|
+
dirs.add(mod.xdgData)
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// ignore
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 2) explicit XDG override
|
|
86
|
+
if (process.env.XDG_DATA_HOME) {
|
|
87
|
+
dirs.add(process.env.XDG_DATA_HOME)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 3) common fallbacks
|
|
91
|
+
dirs.add(path.join(os.homedir(), ".local", "share"))
|
|
92
|
+
dirs.add(path.join(os.homedir(), "Library", "Application Support"))
|
|
93
|
+
|
|
94
|
+
return [...dirs]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function getNativePaths(sid: string): Promise<string[]> {
|
|
98
|
+
const baseDirs = await getNativeDataDirs()
|
|
99
|
+
const file = `${sid || "current"}.json`
|
|
100
|
+
return baseDirs.map((base) => path.join(base, "opencode", "storage", "todo", file))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Map our format → native format
|
|
104
|
+
function toNative(todo: Todo): NativeTodo {
|
|
105
|
+
// Status mapping: our → native
|
|
106
|
+
const statusMap: Record<string, string> = {
|
|
107
|
+
todo: "pending",
|
|
108
|
+
in_progress: "in_progress",
|
|
109
|
+
ready: "in_progress", // native has no "ready"
|
|
110
|
+
finished: "in_progress", // back-compat
|
|
111
|
+
done: "completed", // native uses "completed" not "done"
|
|
112
|
+
cancelled: "cancelled",
|
|
113
|
+
}
|
|
114
|
+
// Priority mapping: CRIT/HIGH/MED/LOW → high/medium/low
|
|
115
|
+
const prioMap: Record<string, string> = {
|
|
116
|
+
CRIT: "high",
|
|
117
|
+
HIGH: "high",
|
|
118
|
+
MED: "medium",
|
|
119
|
+
LOW: "low",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const deps = todo.blockedBy?.length ? ` [← ${todo.blockedBy.join(", ")}]` : ""
|
|
123
|
+
const desc = todo.description?.trim() ? ` — ${todo.description.trim()}` : ""
|
|
124
|
+
const rel = todo.releases?.length ? ` [rel: ${todo.releases.join(", ")}]` : ""
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
id: todo.id,
|
|
128
|
+
content: `${todo.content}${desc}${rel}${deps}`,
|
|
129
|
+
status: statusMap[todo.status] || "pending",
|
|
130
|
+
priority: prioMap[todo.priority] || "medium",
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readTodos(sid: string, directory?: string): Promise<Todo[]> {
|
|
135
|
+
try {
|
|
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))
|
|
139
|
+
} catch {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function writeTodos(todos: Todo[], sid: string, directory?: string): Promise<void> {
|
|
145
|
+
// 1. Enhanced storage (our full format)
|
|
146
|
+
const enhancedPath = getEnhancedPath(sid, directory)
|
|
147
|
+
await fs.mkdir(path.dirname(enhancedPath), { recursive: true })
|
|
148
|
+
await fs.writeFile(enhancedPath, JSON.stringify(todos, null, 2), "utf-8")
|
|
149
|
+
|
|
150
|
+
// 2. Native storage (for TUI display)
|
|
151
|
+
const nativeTodos = todos.map(toNative)
|
|
152
|
+
try {
|
|
153
|
+
const nativePaths = await getNativePaths(sid)
|
|
154
|
+
await Promise.allSettled(
|
|
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
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
} catch {
|
|
161
|
+
// Native write failure is non-fatal
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// Graph analysis
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
function analyzeGraph(todos: Todo[]): TodoGraph {
|
|
170
|
+
const blocked: Record<string, string[]> = {}
|
|
171
|
+
const availableTodos: Todo[] = []
|
|
172
|
+
|
|
173
|
+
for (const todo of todos) {
|
|
174
|
+
if (normalizeStatus(todo.status) !== "todo") continue
|
|
175
|
+
const activeBlockers = (todo.blockedBy || []).filter(id => {
|
|
176
|
+
const b = todos.find(t => t.id === id)
|
|
177
|
+
const bs = normalizeStatus(b?.status)
|
|
178
|
+
return b && bs !== "done" && bs !== "cancelled"
|
|
179
|
+
})
|
|
180
|
+
if (activeBlockers.length === 0) {
|
|
181
|
+
availableTodos.push(todo)
|
|
182
|
+
} else {
|
|
183
|
+
blocked[todo.id] = activeBlockers
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const P: Record<string, number> = { CRIT: 0, HIGH: 1, MED: 2, LOW: 3 }
|
|
188
|
+
availableTodos.sort((a, b) => (P[a.priority] ?? 2) - (P[b.priority] ?? 2))
|
|
189
|
+
const available = availableTodos.map(t => t.id)
|
|
190
|
+
|
|
191
|
+
// Parallel groups
|
|
192
|
+
const parallel: string[][] = []
|
|
193
|
+
const seen = new Set<string>()
|
|
194
|
+
for (const id of available) {
|
|
195
|
+
if (seen.has(id)) continue
|
|
196
|
+
const group = [id]
|
|
197
|
+
seen.add(id)
|
|
198
|
+
for (const other of available) {
|
|
199
|
+
if (seen.has(other)) continue
|
|
200
|
+
const a = todos.find(t => t.id === id)
|
|
201
|
+
const b = todos.find(t => t.id === other)
|
|
202
|
+
if (!b?.blockedBy?.includes(id) && !a?.blockedBy?.includes(other)) {
|
|
203
|
+
group.push(other)
|
|
204
|
+
seen.add(other)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (group.length > 0) parallel.push(group)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { todos, available, parallel, blocked }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ============================================================================
|
|
214
|
+
// Formatting
|
|
215
|
+
// ============================================================================
|
|
216
|
+
|
|
217
|
+
const PE = (p?: string) => p === "CRIT" ? "🔴" : p === "HIGH" ? "🟠" : p === "LOW" ? "🟢" : "🟡"
|
|
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[] = []
|
|
298
|
+
|
|
299
|
+
for (const t of todos) {
|
|
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
|
+
}
|
|
311
|
+
}
|
|
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
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
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)}`)
|
|
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)
|
|
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
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
blockers.push(t)
|
|
363
|
+
if (t.blockedBy?.length) stack.push(...t.blockedBy)
|
|
364
|
+
}
|
|
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)")
|
|
396
|
+
return lines.join("\n")
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ============================================================================
|
|
400
|
+
// Tools
|
|
401
|
+
// ============================================================================
|
|
402
|
+
|
|
403
|
+
export const write = tool({
|
|
404
|
+
description: "Create or update TODO list. TODOv2 (Prefer this instead of TODO)",
|
|
405
|
+
args: {
|
|
406
|
+
todos: tool.schema.array(
|
|
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
|
+
}),
|
|
416
|
+
).describe("Array of todos"),
|
|
417
|
+
},
|
|
418
|
+
async execute(args, context) {
|
|
419
|
+
const now = Date.now()
|
|
420
|
+
const todos = args.todos.map((t: any) => normalizeTodo({ ...t, createdAt: t.createdAt || now, updatedAt: now }))
|
|
421
|
+
await writeTodos(todos, context.sessionID, context.directory)
|
|
422
|
+
return formatGraph(analyzeGraph(todos))
|
|
423
|
+
},
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
export const read_five = tool({
|
|
427
|
+
description: "Read current TODO list. Shows Next 5 tasks.",
|
|
428
|
+
args: {},
|
|
429
|
+
async execute(_args, context) {
|
|
430
|
+
const todos = await readTodos(context.sessionID, context.directory)
|
|
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(", ")}`)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return lines.join("\n")
|
|
458
|
+
},
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
export const read = tool({
|
|
462
|
+
description: "Read current TODO list. Shows all tasks.",
|
|
463
|
+
args: {},
|
|
464
|
+
async execute(_args, context) {
|
|
465
|
+
const todos = await readTodos(context.sessionID, context.directory)
|
|
466
|
+
if (todos.length === 0) return "No todos. Use usethis_todo_write to create."
|
|
467
|
+
return formatGraph(analyzeGraph(todos))
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
export const read_by_id = tool({
|
|
472
|
+
description: "Read task by id.",
|
|
473
|
+
args: {
|
|
474
|
+
id: tool.schema.string().describe("Task ID"),
|
|
475
|
+
},
|
|
476
|
+
async execute(args, context) {
|
|
477
|
+
const todos = await readTodos(context.sessionID, context.directory)
|
|
478
|
+
const todo = todos.find(t => t.id === args.id)
|
|
479
|
+
if (!todo) return `❌ Task ${args.id} not found`
|
|
480
|
+
|
|
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")
|
|
497
|
+
},
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
export const update = tool({
|
|
501
|
+
description: "Update task(s). Send 1 or many for update",
|
|
502
|
+
args: {
|
|
503
|
+
todos: tool.schema.array(
|
|
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
|
+
}),
|
|
513
|
+
).describe("Array of todos to update"),
|
|
514
|
+
},
|
|
515
|
+
async execute(args, context) {
|
|
516
|
+
const todos = await readTodos(context.sessionID, context.directory)
|
|
517
|
+
const now = Date.now()
|
|
518
|
+
const byId = new Map(todos.map(t => [t.id, t]))
|
|
519
|
+
|
|
520
|
+
for (const incoming of args.todos) {
|
|
521
|
+
const normalizedIncoming: any = normalizeTodo(incoming)
|
|
522
|
+
const existing = byId.get(normalizedIncoming.id)
|
|
523
|
+
if (existing) {
|
|
524
|
+
Object.assign(existing, normalizedIncoming)
|
|
525
|
+
existing.updatedAt = now
|
|
526
|
+
// Ensure auto transition is applied after merge
|
|
527
|
+
existing.status = normalizeTodo(existing).status
|
|
528
|
+
existing.releases = normalizeTodo(existing).releases
|
|
529
|
+
} else {
|
|
530
|
+
byId.set(normalizedIncoming.id, normalizeTodo({ ...normalizedIncoming, createdAt: now, updatedAt: now }))
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const merged = [...byId.values()]
|
|
535
|
+
await writeTodos(merged, context.sessionID, context.directory)
|
|
536
|
+
return `✅ Updated ${args.todos.length} task(s)\n\n${formatGraph(analyzeGraph(merged))}`
|
|
537
|
+
},
|
|
538
|
+
})
|