@soleone/pi-tasks 0.4.2 → 0.5.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/README.md
CHANGED
|
@@ -32,13 +32,14 @@ Task management extension for the [pi coding agent](https://github.com/badlogic/
|
|
|
32
32
|
|
|
33
33
|
## Task backends
|
|
34
34
|
|
|
35
|
-
By default, the extension auto-detects the first applicable backend. If none are applicable, it falls back to `todo-md`.
|
|
35
|
+
By default, the extension auto-detects the first applicable backend. If none are applicable, it falls back to `todo-md`. Projects with a `.tq` directory use the `tq` backend for that session; otherwise `sq` remains the recommended default.
|
|
36
36
|
|
|
37
37
|
For most setups, `sq` is recommended as the default backend. It is lightweight, works well in brand new directories, and can create its local data on demand. Install it from the [`sq` installation guide](https://github.com/DerekStride/sq?tab=readme-ov-file#installation).
|
|
38
38
|
|
|
39
39
|
### Supported backends:
|
|
40
40
|
|
|
41
41
|
- [sq](https://github.com/DerekStride/sq) - Uses the `sq` cli to manage tasks in a `.sift` directory via a `issues.jsonl` file. No initialization necessary.
|
|
42
|
+
- `tq` - Uses the `tq` cli to manage tasks in a `.tq/tasks.jsonl` file. Automatically preferred when a `.tq` directory is detected.
|
|
42
43
|
- [beads](https://github.com/steveyegge/beads) - Uses the `bd` cli to manage tasks into a `.beads` directory containing multiple files.
|
|
43
44
|
- `todo-md` - Creates or reads a `TODO.md` file with different sections to emulate priority.
|
|
44
45
|
|
|
@@ -47,5 +48,6 @@ For most setups, `sq` is recommended as the default backend. It is lightweight,
|
|
|
47
48
|
- `PI_TASKS_TODO_PATH` - override the TODO file path
|
|
48
49
|
- `PI_TASKS_BACKEND` - to explicitly choose a backend implementation. Currently supported values:
|
|
49
50
|
- `sq`
|
|
51
|
+
- `tq`
|
|
50
52
|
- `beads`
|
|
51
53
|
- `todo-md`
|
package/package.json
CHANGED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
|
2
|
+
import type { Task, TaskStatus } from "../../../models/task.ts"
|
|
3
|
+
import type {
|
|
4
|
+
CreateTaskInput,
|
|
5
|
+
TaskAdapter,
|
|
6
|
+
TaskAdapterInitializer,
|
|
7
|
+
TaskSessionContextMessage,
|
|
8
|
+
TaskStatusMap,
|
|
9
|
+
TaskUpdate,
|
|
10
|
+
} from "../../api.ts"
|
|
11
|
+
|
|
12
|
+
const MAX_LIST_RESULTS = 100
|
|
13
|
+
const PI_TASKS_METADATA_KEY = "pi_tasks"
|
|
14
|
+
|
|
15
|
+
const STATUS_MAP = {
|
|
16
|
+
open: "pending",
|
|
17
|
+
inProgress: "in_progress",
|
|
18
|
+
closed: "closed",
|
|
19
|
+
} satisfies TaskStatusMap
|
|
20
|
+
|
|
21
|
+
const TASK_TYPES = ["task", "feature", "bug", "chore", "epic"]
|
|
22
|
+
const PRIORITIES = ["p0", "p1", "p2", "p3", "p4"]
|
|
23
|
+
const PRIORITY_HOTKEYS: Record<string, string> = {
|
|
24
|
+
"0": "p0",
|
|
25
|
+
"1": "p1",
|
|
26
|
+
"2": "p2",
|
|
27
|
+
"3": "p3",
|
|
28
|
+
"4": "p4",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SqCompatibleAdapterOptions {
|
|
32
|
+
id: string
|
|
33
|
+
command: string
|
|
34
|
+
sessionContextMessage: TaskSessionContextMessage
|
|
35
|
+
isApplicable(): boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SqCompatibleItem {
|
|
39
|
+
id: string
|
|
40
|
+
title?: string
|
|
41
|
+
description?: string
|
|
42
|
+
status: string
|
|
43
|
+
priority?: number | string
|
|
44
|
+
metadata?: Record<string, unknown>
|
|
45
|
+
blocked_by?: string[]
|
|
46
|
+
created_at?: string
|
|
47
|
+
updated_at?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface TaskMetadata {
|
|
51
|
+
taskType?: string
|
|
52
|
+
dueAt?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizePriority(value: unknown): string | undefined {
|
|
56
|
+
if (typeof value === "number") {
|
|
57
|
+
const label = `p${value}`
|
|
58
|
+
return PRIORITIES.includes(label) ? label : undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof value !== "string") return undefined
|
|
62
|
+
|
|
63
|
+
const numericPriority = Number.parseInt(value, 10)
|
|
64
|
+
if (String(numericPriority) === value.trim()) {
|
|
65
|
+
return normalizePriority(numericPriority)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const normalized = value.toLowerCase()
|
|
69
|
+
return PRIORITIES.includes(normalized) ? normalized : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toBackendPriority(priority: string, backendId: string): string {
|
|
73
|
+
const normalized = normalizePriority(priority)
|
|
74
|
+
if (!normalized) throw new Error(`Unsupported priority for ${backendId} backend: ${priority}`)
|
|
75
|
+
return normalized.slice(1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeText(value: unknown): string | undefined {
|
|
79
|
+
if (typeof value !== "string") return undefined
|
|
80
|
+
const trimmed = value.trim()
|
|
81
|
+
return trimmed.length > 0 ? trimmed : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
85
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
|
|
86
|
+
return value as Record<string, unknown>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractTaskMetadata(metadata: Record<string, unknown> | undefined): TaskMetadata {
|
|
90
|
+
if (!metadata) return {}
|
|
91
|
+
|
|
92
|
+
const piTasks = asRecord(metadata[PI_TASKS_METADATA_KEY])
|
|
93
|
+
if (!piTasks) return {}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
taskType: normalizeText(piTasks.taskType),
|
|
97
|
+
dueAt: normalizeText(piTasks.dueAt),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildPiTasksMetadata(input: { taskType?: string, dueAt?: string }): Record<string, unknown> | undefined {
|
|
102
|
+
const piTasks: Record<string, unknown> = {}
|
|
103
|
+
|
|
104
|
+
if (input.taskType !== undefined) {
|
|
105
|
+
piTasks.taskType = input.taskType || TASK_TYPES[0]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (input.dueAt !== undefined) {
|
|
109
|
+
piTasks.dueAt = input.dueAt
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return Object.keys(piTasks).length > 0
|
|
113
|
+
? { [PI_TASKS_METADATA_KEY]: piTasks }
|
|
114
|
+
: undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toBackendStatus(status: TaskStatus, backendId: string): string {
|
|
118
|
+
const mapped = STATUS_MAP[status]
|
|
119
|
+
if (!mapped) throw new Error(`Unsupported status for ${backendId} backend: ${status}`)
|
|
120
|
+
return mapped
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function optionWithValue(option: string, value: string): string {
|
|
124
|
+
return `${option}=${value}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fromBackendStatus(status: string, blockedBy: string[] | undefined): TaskStatus {
|
|
128
|
+
if (status === STATUS_MAP.inProgress) return "inProgress"
|
|
129
|
+
if (status === STATUS_MAP.closed) return "closed"
|
|
130
|
+
if ((blockedBy?.length ?? 0) > 0) return "blocked"
|
|
131
|
+
return "open"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function toTask(item: SqCompatibleItem): Task {
|
|
135
|
+
const metadata = extractTaskMetadata(item.metadata)
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
ref: item.id,
|
|
139
|
+
id: item.id,
|
|
140
|
+
title: item.title?.trim() || item.id,
|
|
141
|
+
description: item.description ?? "",
|
|
142
|
+
status: fromBackendStatus(item.status, item.blocked_by),
|
|
143
|
+
priority: normalizePriority(item.priority),
|
|
144
|
+
taskType: metadata.taskType,
|
|
145
|
+
createdAt: item.created_at,
|
|
146
|
+
updatedAt: item.updated_at,
|
|
147
|
+
dueAt: metadata.dueAt,
|
|
148
|
+
dependencyCount: item.blocked_by?.length,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function taskStatusSortRank(status: TaskStatus): number {
|
|
153
|
+
if (status === "inProgress") return 0
|
|
154
|
+
if (status === "open") return 1
|
|
155
|
+
if (status === "blocked") return 2
|
|
156
|
+
return 3
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function taskPrioritySortRank(priority: string | undefined): number {
|
|
160
|
+
if (!priority) return PRIORITIES.length + 1
|
|
161
|
+
const index = PRIORITIES.indexOf(priority)
|
|
162
|
+
return index >= 0 ? index : PRIORITIES.length
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sortActiveTasks(tasks: Task[]): Task[] {
|
|
166
|
+
return [...tasks].sort((left, right) => {
|
|
167
|
+
const statusOrder = taskStatusSortRank(left.status) - taskStatusSortRank(right.status)
|
|
168
|
+
if (statusOrder !== 0) return statusOrder
|
|
169
|
+
|
|
170
|
+
const priorityOrder = taskPrioritySortRank(left.priority) - taskPrioritySortRank(right.priority)
|
|
171
|
+
if (priorityOrder !== 0) return priorityOrder
|
|
172
|
+
|
|
173
|
+
return left.ref.localeCompare(right.ref)
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseJsonArray<T>(stdout: string, context: string, command: string): T[] {
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(stdout)
|
|
180
|
+
if (!Array.isArray(parsed)) throw new Error("expected JSON array")
|
|
181
|
+
return parsed as T[]
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
184
|
+
throw new Error(`Failed to parse ${command} output (${context}): ${message}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseJsonObject<T>(stdout: string, context: string, command: string): T {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(stdout)
|
|
191
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
192
|
+
throw new Error("expected JSON object")
|
|
193
|
+
}
|
|
194
|
+
return parsed as T
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
197
|
+
throw new Error(`Failed to parse ${command} output (${context}): ${message}`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function initialize(pi: ExtensionAPI, options: SqCompatibleAdapterOptions): TaskAdapter {
|
|
202
|
+
async function execCommand(args: string[], timeout = 30_000): Promise<string> {
|
|
203
|
+
const result = await pi.exec(options.command, args, { timeout })
|
|
204
|
+
if (result.code !== 0) {
|
|
205
|
+
const details = (result.stderr || result.stdout || "").trim()
|
|
206
|
+
throw new Error(details.length > 0 ? details : `${options.command} ${args.join(" ")} failed (code ${result.code})`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return result.stdout
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function showRaw(ref: string): Promise<SqCompatibleItem> {
|
|
213
|
+
const out = await execCommand(["show", ref, "--json"])
|
|
214
|
+
return parseJsonObject<SqCompatibleItem>(out, `show ${ref}`, options.command)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
id: options.id,
|
|
219
|
+
statusMap: STATUS_MAP,
|
|
220
|
+
taskTypes: TASK_TYPES,
|
|
221
|
+
priorities: PRIORITIES,
|
|
222
|
+
priorityHotkeys: PRIORITY_HOTKEYS,
|
|
223
|
+
sessionContextMessage: options.sessionContextMessage,
|
|
224
|
+
|
|
225
|
+
async list(): Promise<Task[]> {
|
|
226
|
+
const [pendingOut, inProgressOut] = await Promise.all([
|
|
227
|
+
execCommand(["list", "--status", STATUS_MAP.open, "--json"]),
|
|
228
|
+
execCommand(["list", "--status", STATUS_MAP.inProgress, "--json"]),
|
|
229
|
+
])
|
|
230
|
+
|
|
231
|
+
const pendingItems = parseJsonArray<SqCompatibleItem>(pendingOut, "list pending", options.command)
|
|
232
|
+
const inProgressItems = parseJsonArray<SqCompatibleItem>(inProgressOut, "list in_progress", options.command)
|
|
233
|
+
|
|
234
|
+
const dedupedById = new Map<string, Task>()
|
|
235
|
+
for (const item of [...inProgressItems, ...pendingItems]) {
|
|
236
|
+
dedupedById.set(item.id, toTask(item))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return sortActiveTasks([...dedupedById.values()]).slice(0, MAX_LIST_RESULTS)
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
async show(ref: string): Promise<Task> {
|
|
243
|
+
return toTask(await showRaw(ref))
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async update(ref: string, update: TaskUpdate): Promise<void> {
|
|
247
|
+
const args = ["edit", ref]
|
|
248
|
+
|
|
249
|
+
if (update.title !== undefined) {
|
|
250
|
+
args.push(optionWithValue("--set-title", update.title.trim()))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (update.description !== undefined) {
|
|
254
|
+
args.push(optionWithValue("--set-description", update.description))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (update.status !== undefined) {
|
|
258
|
+
args.push(optionWithValue("--set-status", toBackendStatus(update.status, options.id)))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (update.priority !== undefined) {
|
|
262
|
+
args.push(optionWithValue("--set-priority", toBackendPriority(update.priority, options.id)))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const metadataPatch = buildPiTasksMetadata({
|
|
266
|
+
taskType: update.taskType,
|
|
267
|
+
dueAt: update.dueAt,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
if (metadataPatch) {
|
|
271
|
+
args.push(optionWithValue("--merge-metadata", JSON.stringify(metadataPatch)))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (args.length === 2) return
|
|
275
|
+
await execCommand(args)
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async create(input: CreateTaskInput): Promise<Task> {
|
|
279
|
+
const title = input.title.trim()
|
|
280
|
+
const description = input.description ?? ""
|
|
281
|
+
const selectedPriority = input.priority ?? PRIORITIES[Math.floor(PRIORITIES.length / 2)]
|
|
282
|
+
const metadata = buildPiTasksMetadata({
|
|
283
|
+
taskType: input.taskType || TASK_TYPES[0],
|
|
284
|
+
dueAt: input.dueAt,
|
|
285
|
+
})
|
|
286
|
+
const sourceText = description.trim().length > 0 ? description : title
|
|
287
|
+
|
|
288
|
+
const args = [
|
|
289
|
+
"add",
|
|
290
|
+
optionWithValue("--title", title),
|
|
291
|
+
optionWithValue("--description", description),
|
|
292
|
+
optionWithValue("--priority", toBackendPriority(selectedPriority, options.id)),
|
|
293
|
+
optionWithValue("--text", sourceText),
|
|
294
|
+
"--json",
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
if (metadata) {
|
|
298
|
+
args.push(optionWithValue("--metadata", JSON.stringify(metadata)))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const out = await execCommand(args)
|
|
302
|
+
const created = parseJsonObject<SqCompatibleItem>(out, "create", options.command)
|
|
303
|
+
|
|
304
|
+
if (input.status && input.status !== "open") {
|
|
305
|
+
await execCommand(["edit", created.id, optionWithValue("--set-status", toBackendStatus(input.status, options.id))])
|
|
306
|
+
return toTask(await showRaw(created.id))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return toTask(created)
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function createSqCompatibleAdapterInitializer(options: SqCompatibleAdapterOptions): TaskAdapterInitializer {
|
|
315
|
+
return {
|
|
316
|
+
id: options.id,
|
|
317
|
+
isApplicable: options.isApplicable,
|
|
318
|
+
initialize: (pi) => initialize(pi, options),
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -1,33 +1,6 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
|
2
1
|
import { spawnSync } from "node:child_process"
|
|
3
|
-
import type {
|
|
4
|
-
import
|
|
5
|
-
CreateTaskInput,
|
|
6
|
-
TaskAdapter,
|
|
7
|
-
TaskAdapterInitializer,
|
|
8
|
-
TaskSessionContextMessage,
|
|
9
|
-
TaskStatusMap,
|
|
10
|
-
TaskUpdate,
|
|
11
|
-
} from "../api.ts"
|
|
12
|
-
|
|
13
|
-
const MAX_LIST_RESULTS = 100
|
|
14
|
-
const PI_TASKS_METADATA_KEY = "pi_tasks"
|
|
15
|
-
|
|
16
|
-
const STATUS_MAP = {
|
|
17
|
-
open: "pending",
|
|
18
|
-
inProgress: "in_progress",
|
|
19
|
-
closed: "closed",
|
|
20
|
-
} satisfies TaskStatusMap
|
|
21
|
-
|
|
22
|
-
const TASK_TYPES = ["task", "feature", "bug", "chore", "epic"]
|
|
23
|
-
const PRIORITIES = ["p0", "p1", "p2", "p3", "p4"]
|
|
24
|
-
const PRIORITY_HOTKEYS: Record<string, string> = {
|
|
25
|
-
"0": "p0",
|
|
26
|
-
"1": "p1",
|
|
27
|
-
"2": "p2",
|
|
28
|
-
"3": "p3",
|
|
29
|
-
"4": "p4",
|
|
30
|
-
}
|
|
2
|
+
import type { TaskSessionContextMessage } from "../api.ts"
|
|
3
|
+
import { createSqCompatibleAdapterInitializer } from "./shared/sq-compatible.ts"
|
|
31
4
|
|
|
32
5
|
const SESSION_CONTEXT_MESSAGE: TaskSessionContextMessage = {
|
|
33
6
|
customType: "pi-tasks-backend-context-sq-v1",
|
|
@@ -41,285 +14,14 @@ const SESSION_CONTEXT_MESSAGE: TaskSessionContextMessage = {
|
|
|
41
14
|
].join(" "),
|
|
42
15
|
}
|
|
43
16
|
|
|
44
|
-
interface SqItem {
|
|
45
|
-
id: string
|
|
46
|
-
title?: string
|
|
47
|
-
description?: string
|
|
48
|
-
status: string
|
|
49
|
-
priority?: number | string
|
|
50
|
-
metadata?: Record<string, unknown>
|
|
51
|
-
blocked_by?: string[]
|
|
52
|
-
created_at?: string
|
|
53
|
-
updated_at?: string
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface TaskMetadata {
|
|
57
|
-
taskType?: string
|
|
58
|
-
dueAt?: string
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function normalizePriority(value: unknown): string | undefined {
|
|
62
|
-
if (typeof value === "number") {
|
|
63
|
-
const label = `p${value}`
|
|
64
|
-
return PRIORITIES.includes(label) ? label : undefined
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (typeof value !== "string") return undefined
|
|
68
|
-
|
|
69
|
-
const numericPriority = Number.parseInt(value, 10)
|
|
70
|
-
if (String(numericPriority) === value.trim()) {
|
|
71
|
-
return normalizePriority(numericPriority)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const normalized = value.toLowerCase()
|
|
75
|
-
return PRIORITIES.includes(normalized) ? normalized : undefined
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function toBackendPriority(priority: string): string {
|
|
79
|
-
const normalized = normalizePriority(priority)
|
|
80
|
-
if (!normalized) throw new Error(`Unsupported priority for sq backend: ${priority}`)
|
|
81
|
-
return normalized.slice(1)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function normalizeText(value: unknown): string | undefined {
|
|
85
|
-
if (typeof value !== "string") return undefined
|
|
86
|
-
const trimmed = value.trim()
|
|
87
|
-
return trimmed.length > 0 ? trimmed : undefined
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
91
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
|
|
92
|
-
return value as Record<string, unknown>
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function extractTaskMetadata(metadata: Record<string, unknown> | undefined): TaskMetadata {
|
|
96
|
-
if (!metadata) return {}
|
|
97
|
-
|
|
98
|
-
const piTasks = asRecord(metadata[PI_TASKS_METADATA_KEY])
|
|
99
|
-
if (!piTasks) return {}
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
taskType: normalizeText(piTasks.taskType),
|
|
103
|
-
dueAt: normalizeText(piTasks.dueAt),
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function buildPiTasksMetadata(input: { taskType?: string, dueAt?: string }): Record<string, unknown> | undefined {
|
|
108
|
-
const piTasks: Record<string, unknown> = {}
|
|
109
|
-
|
|
110
|
-
if (input.taskType !== undefined) {
|
|
111
|
-
piTasks.taskType = input.taskType || TASK_TYPES[0]
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (input.dueAt !== undefined) {
|
|
115
|
-
piTasks.dueAt = input.dueAt
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return Object.keys(piTasks).length > 0
|
|
119
|
-
? { [PI_TASKS_METADATA_KEY]: piTasks }
|
|
120
|
-
: undefined
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function toBackendStatus(status: TaskStatus): string {
|
|
124
|
-
const mapped = STATUS_MAP[status]
|
|
125
|
-
if (!mapped) throw new Error(`Unsupported status for sq backend: ${status}`)
|
|
126
|
-
return mapped
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function fromBackendStatus(status: string, blockedBy: string[] | undefined): TaskStatus {
|
|
130
|
-
if (status === STATUS_MAP.inProgress) return "inProgress"
|
|
131
|
-
if (status === STATUS_MAP.closed) return "closed"
|
|
132
|
-
if ((blockedBy?.length ?? 0) > 0) return "blocked"
|
|
133
|
-
return "open"
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function toTask(item: SqItem): Task {
|
|
137
|
-
const metadata = extractTaskMetadata(item.metadata)
|
|
138
|
-
|
|
139
|
-
return {
|
|
140
|
-
ref: item.id,
|
|
141
|
-
id: item.id,
|
|
142
|
-
title: item.title?.trim() || item.id,
|
|
143
|
-
description: item.description ?? "",
|
|
144
|
-
status: fromBackendStatus(item.status, item.blocked_by),
|
|
145
|
-
priority: normalizePriority(item.priority),
|
|
146
|
-
taskType: metadata.taskType,
|
|
147
|
-
createdAt: item.created_at,
|
|
148
|
-
updatedAt: item.updated_at,
|
|
149
|
-
dueAt: metadata.dueAt,
|
|
150
|
-
dependencyCount: item.blocked_by?.length,
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function taskStatusSortRank(status: TaskStatus): number {
|
|
155
|
-
if (status === "inProgress") return 0
|
|
156
|
-
if (status === "open") return 1
|
|
157
|
-
if (status === "blocked") return 2
|
|
158
|
-
return 3
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function taskPrioritySortRank(priority: string | undefined): number {
|
|
162
|
-
if (!priority) return PRIORITIES.length + 1
|
|
163
|
-
const index = PRIORITIES.indexOf(priority)
|
|
164
|
-
return index >= 0 ? index : PRIORITIES.length
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function sortActiveTasks(tasks: Task[]): Task[] {
|
|
168
|
-
return [...tasks].sort((left, right) => {
|
|
169
|
-
const statusOrder = taskStatusSortRank(left.status) - taskStatusSortRank(right.status)
|
|
170
|
-
if (statusOrder !== 0) return statusOrder
|
|
171
|
-
|
|
172
|
-
const priorityOrder = taskPrioritySortRank(left.priority) - taskPrioritySortRank(right.priority)
|
|
173
|
-
if (priorityOrder !== 0) return priorityOrder
|
|
174
|
-
|
|
175
|
-
return left.ref.localeCompare(right.ref)
|
|
176
|
-
})
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function parseJsonArray<T>(stdout: string, context: string): T[] {
|
|
180
|
-
try {
|
|
181
|
-
const parsed = JSON.parse(stdout)
|
|
182
|
-
if (!Array.isArray(parsed)) throw new Error("expected JSON array")
|
|
183
|
-
return parsed as T[]
|
|
184
|
-
} catch (error) {
|
|
185
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
186
|
-
throw new Error(`Failed to parse sq output (${context}): ${message}`)
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function parseJsonObject<T>(stdout: string, context: string): T {
|
|
191
|
-
try {
|
|
192
|
-
const parsed = JSON.parse(stdout)
|
|
193
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
194
|
-
throw new Error("expected JSON object")
|
|
195
|
-
}
|
|
196
|
-
return parsed as T
|
|
197
|
-
} catch (error) {
|
|
198
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
199
|
-
throw new Error(`Failed to parse sq output (${context}): ${message}`)
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
17
|
function isApplicable(): boolean {
|
|
204
18
|
const result = spawnSync("sq", ["--help"], { stdio: "ignore" })
|
|
205
19
|
return !result.error
|
|
206
20
|
}
|
|
207
21
|
|
|
208
|
-
|
|
209
|
-
async function execSq(args: string[], timeout = 30_000): Promise<string> {
|
|
210
|
-
const result = await pi.exec("sq", args, { timeout })
|
|
211
|
-
if (result.code !== 0) {
|
|
212
|
-
const details = (result.stderr || result.stdout || "").trim()
|
|
213
|
-
throw new Error(details.length > 0 ? details : `sq ${args.join(" ")} failed (code ${result.code})`)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return result.stdout
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async function showRaw(ref: string): Promise<SqItem> {
|
|
220
|
-
const out = await execSq(["show", ref, "--json"])
|
|
221
|
-
return parseJsonObject<SqItem>(out, `show ${ref}`)
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
id: "sq",
|
|
226
|
-
statusMap: STATUS_MAP,
|
|
227
|
-
taskTypes: TASK_TYPES,
|
|
228
|
-
priorities: PRIORITIES,
|
|
229
|
-
priorityHotkeys: PRIORITY_HOTKEYS,
|
|
230
|
-
sessionContextMessage: SESSION_CONTEXT_MESSAGE,
|
|
231
|
-
|
|
232
|
-
async list(): Promise<Task[]> {
|
|
233
|
-
const [pendingOut, inProgressOut] = await Promise.all([
|
|
234
|
-
execSq(["list", "--status", STATUS_MAP.open, "--json"]),
|
|
235
|
-
execSq(["list", "--status", STATUS_MAP.inProgress, "--json"]),
|
|
236
|
-
])
|
|
237
|
-
|
|
238
|
-
const pendingItems = parseJsonArray<SqItem>(pendingOut, "list pending")
|
|
239
|
-
const inProgressItems = parseJsonArray<SqItem>(inProgressOut, "list in_progress")
|
|
240
|
-
|
|
241
|
-
const dedupedById = new Map<string, Task>()
|
|
242
|
-
for (const item of [...inProgressItems, ...pendingItems]) {
|
|
243
|
-
dedupedById.set(item.id, toTask(item))
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return sortActiveTasks([...dedupedById.values()]).slice(0, MAX_LIST_RESULTS)
|
|
247
|
-
},
|
|
248
|
-
|
|
249
|
-
async show(ref: string): Promise<Task> {
|
|
250
|
-
return toTask(await showRaw(ref))
|
|
251
|
-
},
|
|
252
|
-
|
|
253
|
-
async update(ref: string, update: TaskUpdate): Promise<void> {
|
|
254
|
-
const args = ["edit", ref]
|
|
255
|
-
|
|
256
|
-
if (update.title !== undefined) {
|
|
257
|
-
args.push("--set-title", update.title.trim())
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (update.description !== undefined) {
|
|
261
|
-
args.push("--set-description", update.description)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (update.status !== undefined) {
|
|
265
|
-
args.push("--set-status", toBackendStatus(update.status))
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (update.priority !== undefined) {
|
|
269
|
-
args.push("--set-priority", toBackendPriority(update.priority))
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const metadataPatch = buildPiTasksMetadata({
|
|
273
|
-
taskType: update.taskType,
|
|
274
|
-
dueAt: update.dueAt,
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
if (metadataPatch) {
|
|
278
|
-
args.push("--merge-metadata", JSON.stringify(metadataPatch))
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (args.length === 2) return
|
|
282
|
-
await execSq(args)
|
|
283
|
-
},
|
|
284
|
-
|
|
285
|
-
async create(input: CreateTaskInput): Promise<Task> {
|
|
286
|
-
const title = input.title.trim()
|
|
287
|
-
const description = input.description ?? ""
|
|
288
|
-
const selectedPriority = input.priority ?? PRIORITIES[Math.floor(PRIORITIES.length / 2)]
|
|
289
|
-
const metadata = buildPiTasksMetadata({
|
|
290
|
-
taskType: input.taskType || TASK_TYPES[0],
|
|
291
|
-
dueAt: input.dueAt,
|
|
292
|
-
})
|
|
293
|
-
const sourceText = description.trim().length > 0 ? description : title
|
|
294
|
-
|
|
295
|
-
const args = [
|
|
296
|
-
"add",
|
|
297
|
-
"--title", title,
|
|
298
|
-
"--description", description,
|
|
299
|
-
"--priority", toBackendPriority(selectedPriority),
|
|
300
|
-
"--text", sourceText,
|
|
301
|
-
"--json",
|
|
302
|
-
]
|
|
303
|
-
|
|
304
|
-
if (metadata) {
|
|
305
|
-
args.push("--metadata", JSON.stringify(metadata))
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const out = await execSq(args)
|
|
309
|
-
const created = parseJsonObject<SqItem>(out, "create")
|
|
310
|
-
|
|
311
|
-
if (input.status && input.status !== "open") {
|
|
312
|
-
await execSq(["edit", created.id, "--set-status", toBackendStatus(input.status)])
|
|
313
|
-
return toTask(await showRaw(created.id))
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
return toTask(created)
|
|
317
|
-
},
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export default {
|
|
22
|
+
export default createSqCompatibleAdapterInitializer({
|
|
322
23
|
id: "sq",
|
|
24
|
+
command: "sq",
|
|
25
|
+
sessionContextMessage: SESSION_CONTEXT_MESSAGE,
|
|
323
26
|
isApplicable,
|
|
324
|
-
|
|
325
|
-
} satisfies TaskAdapterInitializer
|
|
27
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { dirname, resolve } from "node:path"
|
|
4
|
+
import type { TaskSessionContextMessage } from "../api.ts"
|
|
5
|
+
import { createSqCompatibleAdapterInitializer } from "./shared/sq-compatible.ts"
|
|
6
|
+
|
|
7
|
+
const SESSION_CONTEXT_MESSAGE: TaskSessionContextMessage = {
|
|
8
|
+
customType: "pi-tasks-backend-context-tq-v1",
|
|
9
|
+
content: [
|
|
10
|
+
"The pi-tasks extension is using the `tq` backend for this project because a `.tq` directory was detected.",
|
|
11
|
+
"If you need direct `tq` CLI guidance, run `tq prime`.",
|
|
12
|
+
"When manipulating pi-tasks metadata through `tq`, store it under",
|
|
13
|
+
"`pi_tasks`, for example",
|
|
14
|
+
"`--metadata '{\"pi_tasks\":{\"taskType\":\"TYPE\",\"dueAt\":\"TIMESTAMP\"}}'`,",
|
|
15
|
+
"or merge the same shape with `--merge-metadata`.",
|
|
16
|
+
].join(" "),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasTqDirectory(startDirectory = process.cwd()): boolean {
|
|
20
|
+
let currentDirectory = resolve(startDirectory)
|
|
21
|
+
|
|
22
|
+
while (true) {
|
|
23
|
+
if (existsSync(resolve(currentDirectory, ".tq"))) return true
|
|
24
|
+
|
|
25
|
+
const parentDirectory = dirname(currentDirectory)
|
|
26
|
+
if (parentDirectory === currentDirectory) return false
|
|
27
|
+
currentDirectory = parentDirectory
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isApplicable(): boolean {
|
|
32
|
+
if (!hasTqDirectory()) return false
|
|
33
|
+
|
|
34
|
+
const result = spawnSync("tq", ["--help"], { stdio: "ignore" })
|
|
35
|
+
if (result.error) {
|
|
36
|
+
throw new Error("Detected a .tq directory, but the `tq` CLI is not available on PATH")
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default createSqCompatibleAdapterInitializer({
|
|
42
|
+
id: "tq",
|
|
43
|
+
command: "tq",
|
|
44
|
+
sessionContextMessage: SESSION_CONTEXT_MESSAGE,
|
|
45
|
+
isApplicable,
|
|
46
|
+
})
|
package/src/backend/resolver.ts
CHANGED
|
@@ -62,20 +62,27 @@ async function loadAdapterInitializers(): Promise<TaskAdapterInitializer[]> {
|
|
|
62
62
|
|
|
63
63
|
const ADAPTER_INITIALIZERS = await loadAdapterInitializers()
|
|
64
64
|
|
|
65
|
+
function findAdapter(id: string): TaskAdapterInitializer | undefined {
|
|
66
|
+
return ADAPTER_INITIALIZERS.find(adapter => adapter.id === id)
|
|
67
|
+
}
|
|
68
|
+
|
|
65
69
|
function lookup(): TaskAdapterInitializer {
|
|
66
70
|
const configuredAdapterId = process.env.PI_TASKS_BACKEND?.trim()
|
|
67
71
|
if (configuredAdapterId) {
|
|
68
|
-
const configured =
|
|
72
|
+
const configured = findAdapter(configuredAdapterId)
|
|
69
73
|
if (!configured) {
|
|
70
74
|
throw new Error(`Unsupported tasks backend: ${configuredAdapterId}`)
|
|
71
75
|
}
|
|
72
76
|
return configured
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
const tqAdapter = findAdapter("tq")
|
|
80
|
+
if (tqAdapter?.isApplicable()) return tqAdapter
|
|
81
|
+
|
|
75
82
|
const detected = ADAPTER_INITIALIZERS.find(adapter => adapter.isApplicable())
|
|
76
83
|
if (detected) return detected
|
|
77
84
|
|
|
78
|
-
const fallback =
|
|
85
|
+
const fallback = findAdapter("todo-md")
|
|
79
86
|
if (fallback) return fallback
|
|
80
87
|
|
|
81
88
|
return ADAPTER_INITIALIZERS[0]
|