@soleone/pi-tasks 0.2.1
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/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/package.json +46 -0
- package/src/backend/adapters/beads.ts +305 -0
- package/src/backend/adapters/todo-md.ts +446 -0
- package/src/backend/api.ts +40 -0
- package/src/backend/resolver.ts +86 -0
- package/src/controllers/list.ts +208 -0
- package/src/controllers/show.ts +81 -0
- package/src/extension.ts +358 -0
- package/src/index.ts +7 -0
- package/src/lib/task-serialization.ts +45 -0
- package/src/models/list-item.ts +44 -0
- package/src/models/task.ts +117 -0
- package/src/ui/components/blur-editor.ts +78 -0
- package/src/ui/components/keyboard-help.ts +25 -0
- package/src/ui/components/min-height.ts +21 -0
- package/src/ui/pages/list.ts +540 -0
- package/src/ui/pages/show.ts +425 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
5
|
+
import { dirname, resolve } from "node:path"
|
|
6
|
+
import type { Task, TaskStatus } from "../../models/task.ts"
|
|
7
|
+
import type { CreateTaskInput, TaskAdapter, TaskAdapterInitializer, TaskStatusMap, TaskUpdate } from "../api.ts"
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TODO_FILES = ["TODO.md", "todo.md"] as const
|
|
10
|
+
const TODO_FILE_ENV = "PI_TASKS_TODO_PATH"
|
|
11
|
+
const SECTION_ORDER = ["now", "next", "later", "archive"] as const
|
|
12
|
+
const OPEN_PRIORITIES = ["now", "next", "later"] as const
|
|
13
|
+
const DEFAULT_TODO_TITLE = "TODO"
|
|
14
|
+
|
|
15
|
+
const STATUS_MAP = {
|
|
16
|
+
open: "open",
|
|
17
|
+
closed: "done",
|
|
18
|
+
} satisfies TaskStatusMap
|
|
19
|
+
|
|
20
|
+
type TodoSection = typeof SECTION_ORDER[number]
|
|
21
|
+
type TodoPriority = typeof OPEN_PRIORITIES[number]
|
|
22
|
+
|
|
23
|
+
interface TodoTaskRecord {
|
|
24
|
+
ref: string
|
|
25
|
+
title: string
|
|
26
|
+
description: string
|
|
27
|
+
status: TaskStatus
|
|
28
|
+
priority: TodoPriority | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface TodoDocument {
|
|
32
|
+
title: string
|
|
33
|
+
format: "structured" | "flat"
|
|
34
|
+
tasks: TodoTaskRecord[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function configuredTodoPath(): string {
|
|
38
|
+
const fromEnv = process.env[TODO_FILE_ENV]?.trim()
|
|
39
|
+
if (fromEnv && fromEnv.length > 0) return fromEnv
|
|
40
|
+
|
|
41
|
+
const existingDefault = DEFAULT_TODO_FILES.find(file => existsSync(resolve(process.cwd(), file)))
|
|
42
|
+
return existingDefault ?? DEFAULT_TODO_FILES[0]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveTodoPath(): string {
|
|
46
|
+
return resolve(process.cwd(), configuredTodoPath())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sectionToPriority(section: TodoSection): TodoPriority | undefined {
|
|
50
|
+
if (section === "archive") return undefined
|
|
51
|
+
return section
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function headingToSection(line: string): TodoSection | null {
|
|
55
|
+
const match = line.match(/^##\s+(now|next|later|archive)\s*$/i)
|
|
56
|
+
if (!match) return null
|
|
57
|
+
return match[1]!.toLowerCase() as TodoSection
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseTaskLine(line: string): { checked: boolean; title: string; inlineDescription: string } | null {
|
|
61
|
+
const match = line.match(/^\s*-\s*\[( |x|X)\]\s*(?:\*\*(.+?)\*\*|(.+?))(?:\s*[—-]\s*(.+))?\s*$/)
|
|
62
|
+
if (!match) return null
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
checked: match[1]!.toLowerCase() === "x",
|
|
66
|
+
title: (match[2] ?? match[3] ?? "").trim(),
|
|
67
|
+
inlineDescription: (match[4] ?? "").trim(),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseDescriptionBullet(line: string): string | null {
|
|
72
|
+
const match = line.match(/^\s{2,}-\s+(.+)$/)
|
|
73
|
+
if (!match) return null
|
|
74
|
+
return `- ${match[1]!.trim()}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function computeTaskRef(title: string, description: string, occurrence: number): string {
|
|
78
|
+
const digest = createHash("sha1")
|
|
79
|
+
.update(title.trim())
|
|
80
|
+
.update("\n")
|
|
81
|
+
.update(description.trim())
|
|
82
|
+
.digest("hex")
|
|
83
|
+
.slice(0, 10)
|
|
84
|
+
|
|
85
|
+
return `todo-${digest}-${occurrence}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createNewTaskRef(existingRefs: Set<string>): string {
|
|
89
|
+
let candidate = `todo-${randomUUID().slice(0, 8)}`
|
|
90
|
+
while (existingRefs.has(candidate)) {
|
|
91
|
+
candidate = `todo-${randomUUID().slice(0, 8)}`
|
|
92
|
+
}
|
|
93
|
+
return candidate
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assignTaskRefs(tasks: Omit<TodoTaskRecord, "ref">[]): TodoTaskRecord[] {
|
|
97
|
+
const seen = new Map<string, number>()
|
|
98
|
+
|
|
99
|
+
return tasks.map((task) => {
|
|
100
|
+
const key = `${task.title.trim()}\n${task.description.trim()}`
|
|
101
|
+
const nextOccurrence = (seen.get(key) ?? 0) + 1
|
|
102
|
+
seen.set(key, nextOccurrence)
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
...task,
|
|
106
|
+
ref: computeTaskRef(task.title, task.description, nextOccurrence),
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function extractTitle(lines: string[]): string {
|
|
112
|
+
const heading = lines.find(line => /^#\s+/.test(line.trim()))
|
|
113
|
+
if (!heading) return DEFAULT_TODO_TITLE
|
|
114
|
+
return heading.replace(/^#\s+/, "").trim() || DEFAULT_TODO_TITLE
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseChecklistTasks(
|
|
118
|
+
lines: string[],
|
|
119
|
+
resolvePriority: (section: TodoSection | null) => TodoPriority | undefined,
|
|
120
|
+
): Omit<TodoTaskRecord, "ref">[] {
|
|
121
|
+
const parsedTasks: Omit<TodoTaskRecord, "ref">[] = []
|
|
122
|
+
|
|
123
|
+
let section: TodoSection | null = null
|
|
124
|
+
let activeTask: {
|
|
125
|
+
checked: boolean
|
|
126
|
+
title: string
|
|
127
|
+
inlineDescription: string
|
|
128
|
+
bullets: string[]
|
|
129
|
+
section: TodoSection | null
|
|
130
|
+
} | null = null
|
|
131
|
+
|
|
132
|
+
const flushActiveTask = () => {
|
|
133
|
+
if (!activeTask) return
|
|
134
|
+
|
|
135
|
+
const description = activeTask.bullets.length > 0
|
|
136
|
+
? activeTask.bullets.join("\n")
|
|
137
|
+
: activeTask.inlineDescription
|
|
138
|
+
|
|
139
|
+
parsedTasks.push({
|
|
140
|
+
title: activeTask.title,
|
|
141
|
+
description,
|
|
142
|
+
status: activeTask.checked ? "closed" : "open",
|
|
143
|
+
priority: activeTask.checked ? undefined : resolvePriority(activeTask.section),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
activeTask = null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
const nextSection = headingToSection(line)
|
|
151
|
+
if (nextSection) {
|
|
152
|
+
flushActiveTask()
|
|
153
|
+
section = nextSection
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const taskLine = parseTaskLine(line)
|
|
158
|
+
if (taskLine) {
|
|
159
|
+
flushActiveTask()
|
|
160
|
+
activeTask = {
|
|
161
|
+
...taskLine,
|
|
162
|
+
bullets: [],
|
|
163
|
+
section,
|
|
164
|
+
}
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!activeTask) continue
|
|
169
|
+
|
|
170
|
+
const bullet = parseDescriptionBullet(line)
|
|
171
|
+
if (bullet) {
|
|
172
|
+
activeTask.bullets.push(bullet)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (line.trim().length === 0) continue
|
|
177
|
+
|
|
178
|
+
flushActiveTask()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
flushActiveTask()
|
|
182
|
+
|
|
183
|
+
return parsedTasks
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseTodoDocument(content: string): TodoDocument {
|
|
187
|
+
const lines = content.split(/\r?\n/)
|
|
188
|
+
const title = extractTitle(lines)
|
|
189
|
+
const hasStructuredSections = lines.some(line => headingToSection(line) !== null)
|
|
190
|
+
|
|
191
|
+
if (hasStructuredSections) {
|
|
192
|
+
const tasks = parseChecklistTasks(lines, section => sectionToPriority(section ?? "now") ?? "now")
|
|
193
|
+
return {
|
|
194
|
+
title,
|
|
195
|
+
format: "structured",
|
|
196
|
+
tasks: assignTaskRefs(tasks),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const tasks = parseChecklistTasks(lines, () => "now")
|
|
201
|
+
return {
|
|
202
|
+
title,
|
|
203
|
+
format: "flat",
|
|
204
|
+
tasks: assignTaskRefs(tasks),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function asBulletLines(description: string): string[] {
|
|
209
|
+
const normalized = description
|
|
210
|
+
.split(/\r?\n/)
|
|
211
|
+
.map(line => line.trim())
|
|
212
|
+
.filter(line => line.length > 0)
|
|
213
|
+
|
|
214
|
+
if (normalized.length === 0) return []
|
|
215
|
+
|
|
216
|
+
return normalized.map(line => line.startsWith("- ") ? line : `- ${line}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function taskToMarkdownLine(task: TodoTaskRecord): string[] {
|
|
220
|
+
const checked = task.status === "closed" ? "x" : " "
|
|
221
|
+
const title = task.title.trim()
|
|
222
|
+
const description = task.description.trim()
|
|
223
|
+
|
|
224
|
+
if (description.length === 0) {
|
|
225
|
+
return [`- [${checked}] **${title}**`]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const descriptionLines = description.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
|
|
229
|
+
if (descriptionLines.length === 1 && !descriptionLines[0]!.startsWith("- ")) {
|
|
230
|
+
return [`- [${checked}] **${title}** — ${descriptionLines[0]}`]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const lines = [`- [${checked}] **${title}**`]
|
|
234
|
+
for (const bullet of asBulletLines(description)) {
|
|
235
|
+
lines.push(` ${bullet}`)
|
|
236
|
+
}
|
|
237
|
+
return lines
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function renderStructuredDocument(document: TodoDocument): string {
|
|
241
|
+
const sectionTasks: Record<TodoSection, TodoTaskRecord[]> = {
|
|
242
|
+
now: [],
|
|
243
|
+
next: [],
|
|
244
|
+
later: [],
|
|
245
|
+
archive: [],
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const task of document.tasks) {
|
|
249
|
+
if (task.status === "closed") {
|
|
250
|
+
sectionTasks.archive.push(task)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const priority = task.priority ?? "now"
|
|
255
|
+
sectionTasks[priority].push(task)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const lines: string[] = [`# ${document.title}`, ""]
|
|
259
|
+
|
|
260
|
+
const sectionTitleById: Record<TodoSection, string> = {
|
|
261
|
+
now: "Now",
|
|
262
|
+
next: "Next",
|
|
263
|
+
later: "Later",
|
|
264
|
+
archive: "Archive",
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const section of SECTION_ORDER) {
|
|
268
|
+
lines.push(`## ${sectionTitleById[section]}`)
|
|
269
|
+
lines.push("")
|
|
270
|
+
|
|
271
|
+
for (const task of sectionTasks[section]) {
|
|
272
|
+
lines.push(...taskToMarkdownLine(task))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
lines.push("")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return `${lines.join("\n").trimEnd()}\n`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderFlatDocument(document: TodoDocument): string {
|
|
282
|
+
const lines: string[] = [`# ${document.title}`, ""]
|
|
283
|
+
|
|
284
|
+
const openTasks = document.tasks.filter(task => task.status === "open")
|
|
285
|
+
for (const task of openTasks) {
|
|
286
|
+
lines.push(...taskToMarkdownLine(task))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const archivedTasks = document.tasks.filter(task => task.status === "closed")
|
|
290
|
+
if (archivedTasks.length > 0) {
|
|
291
|
+
lines.push("", "## Archive", "")
|
|
292
|
+
for (const task of archivedTasks) {
|
|
293
|
+
lines.push(...taskToMarkdownLine(task))
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
lines.push("")
|
|
298
|
+
return `${lines.join("\n").trimEnd()}\n`
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function renderTodoDocument(document: TodoDocument): string {
|
|
302
|
+
const shouldUseStructured = document.format === "structured" || document.tasks.some(task => (
|
|
303
|
+
task.status === "open" && task.priority !== undefined && task.priority !== "now"
|
|
304
|
+
))
|
|
305
|
+
|
|
306
|
+
return shouldUseStructured
|
|
307
|
+
? renderStructuredDocument(document)
|
|
308
|
+
: renderFlatDocument(document)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizePriority(priority: string | undefined): TodoPriority | undefined {
|
|
312
|
+
if (!priority) return undefined
|
|
313
|
+
const normalized = priority.toLowerCase()
|
|
314
|
+
if (normalized === "now" || normalized === "next" || normalized === "later") return normalized
|
|
315
|
+
return undefined
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function toTask(task: TodoTaskRecord): Task {
|
|
319
|
+
return {
|
|
320
|
+
ref: task.ref,
|
|
321
|
+
title: task.title,
|
|
322
|
+
description: task.description,
|
|
323
|
+
status: task.status,
|
|
324
|
+
priority: task.priority,
|
|
325
|
+
taskType: "task",
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function normalizeStatus(status: TaskStatus | undefined): TaskStatus {
|
|
330
|
+
if (status === "closed") return "closed"
|
|
331
|
+
return "open"
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function applyTaskUpdate(task: TodoTaskRecord, update: TaskUpdate): TodoTaskRecord {
|
|
335
|
+
const nextTitle = update.title !== undefined ? update.title.trim() : task.title
|
|
336
|
+
const nextDescription = update.description !== undefined ? update.description : task.description
|
|
337
|
+
const nextStatus = update.status !== undefined ? normalizeStatus(update.status) : task.status
|
|
338
|
+
const nextPriority = update.priority !== undefined
|
|
339
|
+
? normalizePriority(update.priority) ?? task.priority
|
|
340
|
+
: task.priority
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
...task,
|
|
344
|
+
title: nextTitle,
|
|
345
|
+
description: nextDescription,
|
|
346
|
+
status: nextStatus,
|
|
347
|
+
priority: nextStatus === "closed" ? undefined : (nextPriority ?? "now"),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function readDocument(filePath: string): Promise<TodoDocument> {
|
|
352
|
+
try {
|
|
353
|
+
const content = await readFile(filePath, "utf8")
|
|
354
|
+
return parseTodoDocument(content)
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
357
|
+
return { title: DEFAULT_TODO_TITLE, format: "structured", tasks: [] }
|
|
358
|
+
}
|
|
359
|
+
throw error
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function writeDocument(filePath: string, document: TodoDocument): Promise<void> {
|
|
364
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
365
|
+
await writeFile(filePath, renderTodoDocument(document), "utf8")
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function isApplicable(): boolean {
|
|
369
|
+
return existsSync(resolveTodoPath())
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function initialize(_pi: ExtensionAPI): TaskAdapter {
|
|
373
|
+
const filePath = resolveTodoPath()
|
|
374
|
+
let documentCache: TodoDocument | null = null
|
|
375
|
+
|
|
376
|
+
async function getDocument(): Promise<TodoDocument> {
|
|
377
|
+
if (documentCache) return documentCache
|
|
378
|
+
documentCache = await readDocument(filePath)
|
|
379
|
+
return documentCache
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function persistDocument(document: TodoDocument): Promise<void> {
|
|
383
|
+
documentCache = document
|
|
384
|
+
await writeDocument(filePath, document)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
id: "todo-md",
|
|
389
|
+
statusMap: STATUS_MAP,
|
|
390
|
+
taskTypes: ["task"],
|
|
391
|
+
priorities: [...OPEN_PRIORITIES],
|
|
392
|
+
invalidateCache: () => {
|
|
393
|
+
documentCache = null
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
async list(): Promise<Task[]> {
|
|
397
|
+
const document = await getDocument()
|
|
398
|
+
return document.tasks
|
|
399
|
+
.filter(task => task.status === "open")
|
|
400
|
+
.map(toTask)
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
async show(ref: string): Promise<Task> {
|
|
404
|
+
const document = await getDocument()
|
|
405
|
+
const task = document.tasks.find(item => item.ref === ref)
|
|
406
|
+
if (!task) throw new Error(`Task not found: ${ref}`)
|
|
407
|
+
return toTask(task)
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
async update(ref: string, update: TaskUpdate): Promise<void> {
|
|
411
|
+
const document = await getDocument()
|
|
412
|
+
const index = document.tasks.findIndex(task => task.ref === ref)
|
|
413
|
+
if (index === -1) throw new Error(`Task not found: ${ref}`)
|
|
414
|
+
|
|
415
|
+
const updatedTasks = [...document.tasks]
|
|
416
|
+
updatedTasks[index] = applyTaskUpdate(updatedTasks[index]!, update)
|
|
417
|
+
|
|
418
|
+
await persistDocument({ ...document, tasks: updatedTasks })
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
async create(input: CreateTaskInput): Promise<Task> {
|
|
422
|
+
const document = await getDocument()
|
|
423
|
+
|
|
424
|
+
const status = normalizeStatus(input.status)
|
|
425
|
+
const existingRefs = new Set(document.tasks.map(task => task.ref))
|
|
426
|
+
const createdTask: TodoTaskRecord = {
|
|
427
|
+
ref: createNewTaskRef(existingRefs),
|
|
428
|
+
title: input.title.trim(),
|
|
429
|
+
description: input.description ?? "",
|
|
430
|
+
status,
|
|
431
|
+
priority: status === "closed"
|
|
432
|
+
? undefined
|
|
433
|
+
: (normalizePriority(input.priority) ?? "now"),
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
await persistDocument({ ...document, tasks: [...document.tasks, createdTask] })
|
|
437
|
+
return toTask(createdTask)
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export default {
|
|
443
|
+
id: "todo-md",
|
|
444
|
+
isApplicable,
|
|
445
|
+
initialize,
|
|
446
|
+
} satisfies TaskAdapterInitializer
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
|
2
|
+
import type { Task, TaskStatus } from "../models/task.ts"
|
|
3
|
+
|
|
4
|
+
export type TaskStatusMap = {
|
|
5
|
+
open: string
|
|
6
|
+
closed: string
|
|
7
|
+
inProgress?: string
|
|
8
|
+
} & Partial<Record<Exclude<TaskStatus, "open" | "inProgress" | "closed">, string>>
|
|
9
|
+
|
|
10
|
+
export interface TaskUpdate {
|
|
11
|
+
title?: string
|
|
12
|
+
description?: string
|
|
13
|
+
status?: TaskStatus
|
|
14
|
+
priority?: string
|
|
15
|
+
taskType?: string
|
|
16
|
+
dueAt?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CreateTaskInput extends TaskUpdate {
|
|
20
|
+
title: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TaskAdapter {
|
|
24
|
+
readonly id: string
|
|
25
|
+
readonly statusMap: TaskStatusMap
|
|
26
|
+
readonly taskTypes: string[]
|
|
27
|
+
readonly priorities: string[]
|
|
28
|
+
readonly priorityHotkeys?: Record<string, string>
|
|
29
|
+
invalidateCache?(): void
|
|
30
|
+
list(): Promise<Task[]>
|
|
31
|
+
show(ref: string): Promise<Task>
|
|
32
|
+
update(ref: string, update: TaskUpdate): Promise<void>
|
|
33
|
+
create(input: CreateTaskInput): Promise<Task>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TaskAdapterInitializer {
|
|
37
|
+
readonly id: string
|
|
38
|
+
isApplicable(): boolean
|
|
39
|
+
initialize(pi: ExtensionAPI): TaskAdapter
|
|
40
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
|
2
|
+
import { readdir } from "node:fs/promises"
|
|
3
|
+
import { dirname, extname, join } from "node:path"
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
5
|
+
import type { TaskAdapter, TaskAdapterInitializer } from "./api.ts"
|
|
6
|
+
|
|
7
|
+
const ADAPTERS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "adapters")
|
|
8
|
+
const SUPPORTED_ADAPTER_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".cjs"])
|
|
9
|
+
|
|
10
|
+
function isTaskAdapterInitializer(value: unknown): value is TaskAdapterInitializer {
|
|
11
|
+
if (!value || typeof value !== "object") return false
|
|
12
|
+
|
|
13
|
+
const candidate = value as {
|
|
14
|
+
id?: unknown
|
|
15
|
+
isApplicable?: unknown
|
|
16
|
+
initialize?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
typeof candidate.id === "string" &&
|
|
21
|
+
typeof candidate.isApplicable === "function" &&
|
|
22
|
+
typeof candidate.initialize === "function"
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function loadAdapterInitializers(): Promise<TaskAdapterInitializer[]> {
|
|
27
|
+
const entries = await readdir(ADAPTERS_DIRECTORY, { withFileTypes: true })
|
|
28
|
+
const adapterFiles = entries
|
|
29
|
+
.filter(entry => entry.isFile())
|
|
30
|
+
.map(entry => entry.name)
|
|
31
|
+
.filter(name => SUPPORTED_ADAPTER_EXTENSIONS.has(extname(name)))
|
|
32
|
+
.sort((a, b) => a.localeCompare(b))
|
|
33
|
+
|
|
34
|
+
const adapters: TaskAdapterInitializer[] = []
|
|
35
|
+
|
|
36
|
+
for (const fileName of adapterFiles) {
|
|
37
|
+
const modulePath = pathToFileURL(join(ADAPTERS_DIRECTORY, fileName)).href
|
|
38
|
+
const module = await import(modulePath)
|
|
39
|
+
const initializer = module.default
|
|
40
|
+
|
|
41
|
+
if (!isTaskAdapterInitializer(initializer)) {
|
|
42
|
+
throw new Error(`Invalid adapter export in ${fileName}: expected default TaskAdapterInitializer`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
adapters.push(initializer)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (adapters.length === 0) {
|
|
49
|
+
throw new Error(`No task adapters found in ${ADAPTERS_DIRECTORY}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ids = new Set<string>()
|
|
53
|
+
for (const adapter of adapters) {
|
|
54
|
+
if (ids.has(adapter.id)) {
|
|
55
|
+
throw new Error(`Duplicate task adapter id detected: ${adapter.id}`)
|
|
56
|
+
}
|
|
57
|
+
ids.add(adapter.id)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return adapters
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ADAPTER_INITIALIZERS = await loadAdapterInitializers()
|
|
64
|
+
|
|
65
|
+
function lookup(): TaskAdapterInitializer {
|
|
66
|
+
const configuredAdapterId = process.env.PI_TASKS_BACKEND?.trim()
|
|
67
|
+
if (configuredAdapterId) {
|
|
68
|
+
const configured = ADAPTER_INITIALIZERS.find(adapter => adapter.id === configuredAdapterId)
|
|
69
|
+
if (!configured) {
|
|
70
|
+
throw new Error(`Unsupported tasks backend: ${configuredAdapterId}`)
|
|
71
|
+
}
|
|
72
|
+
return configured
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const detected = ADAPTER_INITIALIZERS.find(adapter => adapter.isApplicable())
|
|
76
|
+
if (detected) return detected
|
|
77
|
+
|
|
78
|
+
const fallback = ADAPTER_INITIALIZERS.find(adapter => adapter.id === "todo-md")
|
|
79
|
+
if (fallback) return fallback
|
|
80
|
+
|
|
81
|
+
return ADAPTER_INITIALIZERS[0]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default function initializeAdapter(pi: ExtensionAPI): TaskAdapter {
|
|
85
|
+
return lookup().initialize(pi)
|
|
86
|
+
}
|