loopwork 0.3.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/CHANGELOG.md +52 -0
- package/README.md +528 -0
- package/bin/loopwork +0 -0
- package/examples/README.md +70 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +22 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +23 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +37 -0
- package/examples/basic-json-backend/.specs/tasks/tasks.json +19 -0
- package/examples/basic-json-backend/README.md +32 -0
- package/examples/basic-json-backend/TESTING.md +184 -0
- package/examples/basic-json-backend/hello.test.ts +9 -0
- package/examples/basic-json-backend/hello.ts +3 -0
- package/examples/basic-json-backend/loopwork.config.js +35 -0
- package/examples/basic-json-backend/math.test.ts +29 -0
- package/examples/basic-json-backend/math.ts +3 -0
- package/examples/basic-json-backend/package.json +15 -0
- package/examples/basic-json-backend/quick-start.sh +80 -0
- package/loopwork.config.ts +164 -0
- package/package.json +26 -0
- package/src/backends/github.ts +426 -0
- package/src/backends/index.ts +86 -0
- package/src/backends/json.ts +598 -0
- package/src/backends/plugin.ts +317 -0
- package/src/backends/types.ts +19 -0
- package/src/commands/init.ts +100 -0
- package/src/commands/run.ts +365 -0
- package/src/contracts/backend.ts +127 -0
- package/src/contracts/config.ts +129 -0
- package/src/contracts/index.ts +43 -0
- package/src/contracts/plugin.ts +82 -0
- package/src/contracts/task.ts +78 -0
- package/src/core/cli.ts +275 -0
- package/src/core/config.ts +165 -0
- package/src/core/state.ts +154 -0
- package/src/core/utils.ts +125 -0
- package/src/dashboard/cli.ts +449 -0
- package/src/dashboard/index.ts +6 -0
- package/src/dashboard/kanban.tsx +226 -0
- package/src/dashboard/tui.tsx +372 -0
- package/src/index.ts +19 -0
- package/src/mcp/server.ts +451 -0
- package/src/monitor/index.ts +420 -0
- package/src/plugins/asana.ts +192 -0
- package/src/plugins/cost-tracking.ts +402 -0
- package/src/plugins/discord.ts +269 -0
- package/src/plugins/everhour.ts +335 -0
- package/src/plugins/index.ts +253 -0
- package/src/plugins/telegram/bot.ts +517 -0
- package/src/plugins/telegram/index.ts +6 -0
- package/src/plugins/telegram/notifications.ts +198 -0
- package/src/plugins/todoist.ts +261 -0
- package/test/backends.test.ts +929 -0
- package/test/cli.test.ts +145 -0
- package/test/config.test.ts +90 -0
- package/test/e2e.test.ts +458 -0
- package/test/github-tasks.test.ts +191 -0
- package/test/loopwork-config-types.test.ts +288 -0
- package/test/monitor.test.ts +123 -0
- package/test/plugins.test.ts +1175 -0
- package/test/state.test.ts +295 -0
- package/test/utils.test.ts +60 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import type {
|
|
4
|
+
TaskBackend,
|
|
5
|
+
Task,
|
|
6
|
+
TaskStatus,
|
|
7
|
+
Priority,
|
|
8
|
+
FindTaskOptions,
|
|
9
|
+
UpdateResult,
|
|
10
|
+
BackendConfig,
|
|
11
|
+
} from './types'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* JSON task file schema
|
|
15
|
+
*/
|
|
16
|
+
interface JsonTaskEntry {
|
|
17
|
+
id: string
|
|
18
|
+
status: TaskStatus
|
|
19
|
+
priority?: Priority
|
|
20
|
+
feature?: string
|
|
21
|
+
parentId?: string // Parent task ID (for sub-tasks)
|
|
22
|
+
dependsOn?: string[] // Task IDs this task depends on
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface JsonTasksFile {
|
|
26
|
+
tasks: JsonTaskEntry[]
|
|
27
|
+
features?: Record<string, { name: string; priority?: Priority }>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* JSON File Adapter
|
|
32
|
+
*
|
|
33
|
+
* Adapts JSON task files + markdown PRDs to the TaskBackend interface.
|
|
34
|
+
*
|
|
35
|
+
* File structure:
|
|
36
|
+
* .specs/tasks/
|
|
37
|
+
* ├── tasks.json # Task registry
|
|
38
|
+
* ├── TASK-001-01.md # PRD files
|
|
39
|
+
* └── TASK-001-02.md
|
|
40
|
+
*
|
|
41
|
+
* Uses file locking to prevent concurrent access issues.
|
|
42
|
+
*/
|
|
43
|
+
export class JsonTaskAdapter implements TaskBackend {
|
|
44
|
+
readonly name = 'json'
|
|
45
|
+
private tasksFile: string
|
|
46
|
+
private tasksDir: string
|
|
47
|
+
private lockFile: string
|
|
48
|
+
private lockTimeout = 5000 // 5 seconds
|
|
49
|
+
private lockRetryDelay = 100 // 100ms between retries
|
|
50
|
+
|
|
51
|
+
constructor(config: BackendConfig) {
|
|
52
|
+
this.tasksFile = config.tasksFile || '.specs/tasks/tasks.json'
|
|
53
|
+
this.tasksDir = config.tasksDir || path.dirname(this.tasksFile)
|
|
54
|
+
this.lockFile = `${this.tasksFile}.lock`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Acquire file lock for write operations
|
|
59
|
+
*/
|
|
60
|
+
private async acquireLock(timeout = this.lockTimeout): Promise<boolean> {
|
|
61
|
+
const startTime = Date.now()
|
|
62
|
+
|
|
63
|
+
while (Date.now() - startTime < timeout) {
|
|
64
|
+
try {
|
|
65
|
+
// Try to create lock file exclusively
|
|
66
|
+
fs.writeFileSync(this.lockFile, String(process.pid), { flag: 'wx' })
|
|
67
|
+
return true
|
|
68
|
+
} catch (e: any) {
|
|
69
|
+
if (e.code === 'EEXIST') {
|
|
70
|
+
// Lock exists, check if stale
|
|
71
|
+
try {
|
|
72
|
+
const lockContent = fs.readFileSync(this.lockFile, 'utf-8')
|
|
73
|
+
const lockPid = parseInt(lockContent, 10)
|
|
74
|
+
const lockStat = fs.statSync(this.lockFile)
|
|
75
|
+
const lockAge = Date.now() - lockStat.mtimeMs
|
|
76
|
+
|
|
77
|
+
// If lock is older than 30 seconds, consider it stale
|
|
78
|
+
if (lockAge > 30000) {
|
|
79
|
+
fs.unlinkSync(this.lockFile)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check if process is still alive (only works for same-machine processes)
|
|
84
|
+
try {
|
|
85
|
+
process.kill(lockPid, 0)
|
|
86
|
+
} catch {
|
|
87
|
+
// Process doesn't exist, remove stale lock
|
|
88
|
+
fs.unlinkSync(this.lockFile)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Error reading lock, try to remove it
|
|
93
|
+
try { fs.unlinkSync(this.lockFile) } catch {}
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Wait before retry
|
|
98
|
+
await new Promise(r => setTimeout(r, this.lockRetryDelay))
|
|
99
|
+
} else {
|
|
100
|
+
// Other error (e.g., directory doesn't exist)
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Release file lock
|
|
111
|
+
*/
|
|
112
|
+
private releaseLock(): void {
|
|
113
|
+
try {
|
|
114
|
+
// Only remove if we own the lock
|
|
115
|
+
const content = fs.readFileSync(this.lockFile, 'utf-8')
|
|
116
|
+
if (parseInt(content, 10) === process.pid) {
|
|
117
|
+
fs.unlinkSync(this.lockFile)
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Ignore errors during cleanup
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Execute a function with file lock
|
|
126
|
+
*/
|
|
127
|
+
private async withLock<T>(fn: () => T): Promise<T> {
|
|
128
|
+
const acquired = await this.acquireLock()
|
|
129
|
+
if (!acquired) {
|
|
130
|
+
throw new Error('Failed to acquire file lock')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
return fn()
|
|
135
|
+
} finally {
|
|
136
|
+
this.releaseLock()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async findNextTask(options?: FindTaskOptions): Promise<Task | null> {
|
|
141
|
+
const tasks = await this.listPendingTasks(options)
|
|
142
|
+
|
|
143
|
+
if (options?.startFrom) {
|
|
144
|
+
const startIdx = tasks.findIndex(t => t.id === options.startFrom)
|
|
145
|
+
if (startIdx >= 0) {
|
|
146
|
+
return tasks[startIdx]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return tasks[0] || null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async getTask(taskId: string): Promise<Task | null> {
|
|
154
|
+
const data = this.loadTasksFile()
|
|
155
|
+
if (!data) return null
|
|
156
|
+
|
|
157
|
+
const entry = data.tasks.find(t => t.id === taskId)
|
|
158
|
+
if (!entry) return null
|
|
159
|
+
|
|
160
|
+
return this.loadFullTask(entry, data)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async listPendingTasks(options?: FindTaskOptions): Promise<Task[]> {
|
|
164
|
+
const data = this.loadTasksFile()
|
|
165
|
+
if (!data) return []
|
|
166
|
+
|
|
167
|
+
let entries = data.tasks.filter(t => t.status === 'pending')
|
|
168
|
+
|
|
169
|
+
if (options?.feature) {
|
|
170
|
+
entries = entries.filter(t => t.feature === options.feature)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (options?.priority) {
|
|
174
|
+
entries = entries.filter(t => (t.priority || 'medium') === options.priority)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Filter by parent
|
|
178
|
+
if (options?.parentId) {
|
|
179
|
+
entries = entries.filter(t => t.parentId === options.parentId)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Filter to top-level only (no parent)
|
|
183
|
+
if (options?.topLevelOnly) {
|
|
184
|
+
entries = entries.filter(t => !t.parentId)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Sort by priority: high > medium > low
|
|
188
|
+
const priorityOrder = { high: 0, medium: 1, low: 2 }
|
|
189
|
+
entries.sort((a, b) => {
|
|
190
|
+
const pa = priorityOrder[a.priority || 'medium']
|
|
191
|
+
const pb = priorityOrder[b.priority || 'medium']
|
|
192
|
+
return pa - pb
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const tasks: Task[] = []
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
const task = await this.loadFullTask(entry, data)
|
|
198
|
+
if (task) {
|
|
199
|
+
// Check if task dependencies are met
|
|
200
|
+
if (!options?.includeBlocked && task.dependsOn && task.dependsOn.length > 0) {
|
|
201
|
+
const depsMet = await this.areDependenciesMetInternal(task.dependsOn, data)
|
|
202
|
+
if (!depsMet) continue // Skip blocked tasks
|
|
203
|
+
}
|
|
204
|
+
tasks.push(task)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return tasks
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Internal method to check if dependencies are met
|
|
213
|
+
*/
|
|
214
|
+
private areDependenciesMetInternal(dependsOn: string[], data: JsonTasksFile): boolean {
|
|
215
|
+
for (const depId of dependsOn) {
|
|
216
|
+
const depEntry = data.tasks.find(t => t.id === depId)
|
|
217
|
+
if (!depEntry || depEntry.status !== 'completed') {
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async countPending(options?: FindTaskOptions): Promise<number> {
|
|
225
|
+
const tasks = await this.listPendingTasks(options)
|
|
226
|
+
return tasks.length
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async markInProgress(taskId: string): Promise<UpdateResult> {
|
|
230
|
+
return this.updateTaskStatus(taskId, 'in-progress')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async markCompleted(taskId: string, _comment?: string): Promise<UpdateResult> {
|
|
234
|
+
return this.updateTaskStatus(taskId, 'completed')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async markFailed(taskId: string, error: string): Promise<UpdateResult> {
|
|
238
|
+
const result = await this.updateTaskStatus(taskId, 'failed')
|
|
239
|
+
if (result.success) {
|
|
240
|
+
// Append error to a log file
|
|
241
|
+
const logFile = path.join(this.tasksDir, `${taskId}.log`)
|
|
242
|
+
const timestamp = new Date().toISOString()
|
|
243
|
+
const entry = `\n[${timestamp}] FAILED: ${error}\n`
|
|
244
|
+
try {
|
|
245
|
+
fs.appendFileSync(logFile, entry)
|
|
246
|
+
} catch {
|
|
247
|
+
// Ignore log write errors
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return result
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async resetToPending(taskId: string): Promise<UpdateResult> {
|
|
254
|
+
return this.updateTaskStatus(taskId, 'pending')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async setPriority(taskId: string, priority: Task['priority']): Promise<UpdateResult> {
|
|
258
|
+
return this.withLock(async () => {
|
|
259
|
+
const data = this.loadTasksFile()
|
|
260
|
+
if (!data) return { success: false, error: 'Tasks file not found' }
|
|
261
|
+
|
|
262
|
+
const taskIndex = data.tasks.findIndex((t) => t.id === taskId)
|
|
263
|
+
if (taskIndex === -1) return { success: false, error: 'Task not found' }
|
|
264
|
+
|
|
265
|
+
data.tasks[taskIndex].priority = priority
|
|
266
|
+
if (!this.saveTasksFile(data)) {
|
|
267
|
+
return { success: false, error: 'Failed to save tasks file' }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { success: true }
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async addComment(taskId: string, comment: string): Promise<UpdateResult> {
|
|
275
|
+
const logFile = path.join(this.tasksDir, `${taskId}.log`)
|
|
276
|
+
const timestamp = new Date().toISOString()
|
|
277
|
+
const entry = `\n[${timestamp}] ${comment}\n`
|
|
278
|
+
try {
|
|
279
|
+
fs.appendFileSync(logFile, entry)
|
|
280
|
+
return { success: true }
|
|
281
|
+
} catch (e: any) {
|
|
282
|
+
return { success: false, error: e.message }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async ping(): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
|
|
287
|
+
const start = Date.now()
|
|
288
|
+
try {
|
|
289
|
+
// Check if tasks file exists and is readable
|
|
290
|
+
if (!fs.existsSync(this.tasksFile)) {
|
|
291
|
+
return { ok: false, latencyMs: Date.now() - start, error: 'Tasks file not found' }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Try to read and parse the file
|
|
295
|
+
const content = fs.readFileSync(this.tasksFile, 'utf-8')
|
|
296
|
+
JSON.parse(content)
|
|
297
|
+
|
|
298
|
+
return { ok: true, latencyMs: Date.now() - start }
|
|
299
|
+
} catch (e: any) {
|
|
300
|
+
return { ok: false, latencyMs: Date.now() - start, error: e.message }
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private loadTasksFile(): JsonTasksFile | null {
|
|
305
|
+
try {
|
|
306
|
+
if (!fs.existsSync(this.tasksFile)) {
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
const content = fs.readFileSync(this.tasksFile, 'utf-8')
|
|
310
|
+
return JSON.parse(content)
|
|
311
|
+
} catch {
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private saveTasksFile(data: JsonTasksFile): boolean {
|
|
317
|
+
try {
|
|
318
|
+
const content = JSON.stringify(data, null, 2)
|
|
319
|
+
fs.writeFileSync(this.tasksFile, content)
|
|
320
|
+
return true
|
|
321
|
+
} catch {
|
|
322
|
+
return false
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private async loadFullTask(entry: JsonTaskEntry, data: JsonTasksFile): Promise<Task | null> {
|
|
327
|
+
// Load PRD from markdown file
|
|
328
|
+
const prdFile = path.join(this.tasksDir, `${entry.id}.md`)
|
|
329
|
+
let description = ''
|
|
330
|
+
let title = entry.id
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
if (fs.existsSync(prdFile)) {
|
|
334
|
+
const content = fs.readFileSync(prdFile, 'utf-8')
|
|
335
|
+
description = content
|
|
336
|
+
|
|
337
|
+
// Extract title from first heading
|
|
338
|
+
const titleMatch = content.match(/^#\s+(.+)$/m)
|
|
339
|
+
if (titleMatch) {
|
|
340
|
+
title = titleMatch[1]
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
// Use empty description if file not found
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Get feature info
|
|
348
|
+
const featureInfo = entry.feature && data.features?.[entry.feature]
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
id: entry.id,
|
|
352
|
+
title,
|
|
353
|
+
description,
|
|
354
|
+
status: entry.status,
|
|
355
|
+
priority: entry.priority || 'medium',
|
|
356
|
+
feature: entry.feature,
|
|
357
|
+
parentId: entry.parentId,
|
|
358
|
+
dependsOn: entry.dependsOn,
|
|
359
|
+
metadata: {
|
|
360
|
+
prdFile,
|
|
361
|
+
featureName: featureInfo?.name,
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Sub-task and dependency methods
|
|
367
|
+
|
|
368
|
+
async getSubTasks(taskId: string): Promise<Task[]> {
|
|
369
|
+
const data = this.loadTasksFile()
|
|
370
|
+
if (!data) return []
|
|
371
|
+
|
|
372
|
+
const subEntries = data.tasks.filter(t => t.parentId === taskId)
|
|
373
|
+
const tasks: Task[] = []
|
|
374
|
+
|
|
375
|
+
for (const entry of subEntries) {
|
|
376
|
+
const task = await this.loadFullTask(entry, data)
|
|
377
|
+
if (task) tasks.push(task)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return tasks
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async getDependencies(taskId: string): Promise<Task[]> {
|
|
384
|
+
const data = this.loadTasksFile()
|
|
385
|
+
if (!data) return []
|
|
386
|
+
|
|
387
|
+
const entry = data.tasks.find(t => t.id === taskId)
|
|
388
|
+
if (!entry || !entry.dependsOn || entry.dependsOn.length === 0) return []
|
|
389
|
+
|
|
390
|
+
const deps: Task[] = []
|
|
391
|
+
for (const depId of entry.dependsOn) {
|
|
392
|
+
const depEntry = data.tasks.find(t => t.id === depId)
|
|
393
|
+
if (depEntry) {
|
|
394
|
+
const task = await this.loadFullTask(depEntry, data)
|
|
395
|
+
if (task) deps.push(task)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return deps
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async getDependents(taskId: string): Promise<Task[]> {
|
|
403
|
+
const data = this.loadTasksFile()
|
|
404
|
+
if (!data) return []
|
|
405
|
+
|
|
406
|
+
const dependents = data.tasks.filter(t => t.dependsOn?.includes(taskId))
|
|
407
|
+
const tasks: Task[] = []
|
|
408
|
+
|
|
409
|
+
for (const entry of dependents) {
|
|
410
|
+
const task = await this.loadFullTask(entry, data)
|
|
411
|
+
if (task) tasks.push(task)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return tasks
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async areDependenciesMet(taskId: string): Promise<boolean> {
|
|
418
|
+
const data = this.loadTasksFile()
|
|
419
|
+
if (!data) return true
|
|
420
|
+
|
|
421
|
+
const entry = data.tasks.find(t => t.id === taskId)
|
|
422
|
+
if (!entry || !entry.dependsOn || entry.dependsOn.length === 0) return true
|
|
423
|
+
|
|
424
|
+
return this.areDependenciesMetInternal(entry.dependsOn, data)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async createTask(task: Omit<Task, 'id' | 'status'>): Promise<Task> {
|
|
428
|
+
return await this.withLock(() => {
|
|
429
|
+
const data = this.loadTasksFile()
|
|
430
|
+
if (!data) throw new Error('Tasks file not found')
|
|
431
|
+
|
|
432
|
+
// Generate new task ID based on existing tasks
|
|
433
|
+
const existingIds = data.tasks.map(t => t.id)
|
|
434
|
+
const prefix = task.feature ? task.feature.toUpperCase() : 'TASK'
|
|
435
|
+
|
|
436
|
+
// Find the next available number
|
|
437
|
+
let num = 1
|
|
438
|
+
while (existingIds.includes(`${prefix}-${String(num).padStart(3, '0')}`)) {
|
|
439
|
+
num++
|
|
440
|
+
}
|
|
441
|
+
const newId = `${prefix}-${String(num).padStart(3, '0')}`
|
|
442
|
+
|
|
443
|
+
const newEntry: JsonTaskEntry = {
|
|
444
|
+
id: newId,
|
|
445
|
+
status: 'pending',
|
|
446
|
+
priority: task.priority || 'medium',
|
|
447
|
+
feature: task.feature,
|
|
448
|
+
parentId: task.parentId,
|
|
449
|
+
dependsOn: task.dependsOn,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
data.tasks.push(newEntry)
|
|
453
|
+
this.saveTasksFile(data)
|
|
454
|
+
|
|
455
|
+
// Create PRD file
|
|
456
|
+
const prdFile = path.join(this.tasksDir, `${newId}.md`)
|
|
457
|
+
const prdContent = `# ${task.title}\n\n${task.description || ''}`
|
|
458
|
+
fs.writeFileSync(prdFile, prdContent)
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
id: newId,
|
|
462
|
+
title: task.title,
|
|
463
|
+
description: task.description || '',
|
|
464
|
+
status: 'pending',
|
|
465
|
+
priority: task.priority || 'medium',
|
|
466
|
+
feature: task.feature,
|
|
467
|
+
parentId: task.parentId,
|
|
468
|
+
dependsOn: task.dependsOn,
|
|
469
|
+
metadata: { prdFile },
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async createSubTask(
|
|
475
|
+
parentId: string,
|
|
476
|
+
task: Omit<Task, 'id' | 'parentId' | 'status'>
|
|
477
|
+
): Promise<Task> {
|
|
478
|
+
return await this.withLock(() => {
|
|
479
|
+
const data = this.loadTasksFile()
|
|
480
|
+
if (!data) throw new Error('Tasks file not found')
|
|
481
|
+
|
|
482
|
+
// Check parent exists
|
|
483
|
+
const parent = data.tasks.find(t => t.id === parentId)
|
|
484
|
+
if (!parent) throw new Error(`Parent task ${parentId} not found`)
|
|
485
|
+
|
|
486
|
+
// Generate sub-task ID (parent-01a, parent-01b, etc.)
|
|
487
|
+
const existingSubtasks = data.tasks.filter(t => t.parentId === parentId)
|
|
488
|
+
const suffix = String.fromCharCode(97 + existingSubtasks.length) // a, b, c...
|
|
489
|
+
const newId = `${parentId}${suffix}`
|
|
490
|
+
|
|
491
|
+
const newEntry: JsonTaskEntry = {
|
|
492
|
+
id: newId,
|
|
493
|
+
status: 'pending',
|
|
494
|
+
priority: task.priority,
|
|
495
|
+
feature: task.feature,
|
|
496
|
+
parentId,
|
|
497
|
+
dependsOn: task.dependsOn,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
data.tasks.push(newEntry)
|
|
501
|
+
this.saveTasksFile(data)
|
|
502
|
+
|
|
503
|
+
// Create PRD file
|
|
504
|
+
const prdFile = path.join(this.tasksDir, `${newId}.md`)
|
|
505
|
+
const prdContent = `# ${task.title}\n\n${task.description}`
|
|
506
|
+
fs.writeFileSync(prdFile, prdContent)
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
id: newId,
|
|
510
|
+
title: task.title,
|
|
511
|
+
description: task.description,
|
|
512
|
+
status: 'pending',
|
|
513
|
+
priority: task.priority,
|
|
514
|
+
feature: task.feature,
|
|
515
|
+
parentId,
|
|
516
|
+
dependsOn: task.dependsOn,
|
|
517
|
+
metadata: { prdFile },
|
|
518
|
+
}
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async addDependency(taskId: string, dependsOnId: string): Promise<UpdateResult> {
|
|
523
|
+
try {
|
|
524
|
+
return await this.withLock(() => {
|
|
525
|
+
const data = this.loadTasksFile()
|
|
526
|
+
if (!data) return { success: false, error: 'Tasks file not found' }
|
|
527
|
+
|
|
528
|
+
const entry = data.tasks.find(t => t.id === taskId)
|
|
529
|
+
if (!entry) return { success: false, error: `Task ${taskId} not found` }
|
|
530
|
+
|
|
531
|
+
// Check dependency exists
|
|
532
|
+
const depEntry = data.tasks.find(t => t.id === dependsOnId)
|
|
533
|
+
if (!depEntry) return { success: false, error: `Dependency ${dependsOnId} not found` }
|
|
534
|
+
|
|
535
|
+
// Add dependency
|
|
536
|
+
if (!entry.dependsOn) entry.dependsOn = []
|
|
537
|
+
if (!entry.dependsOn.includes(dependsOnId)) {
|
|
538
|
+
entry.dependsOn.push(dependsOnId)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
this.saveTasksFile(data)
|
|
542
|
+
return { success: true }
|
|
543
|
+
})
|
|
544
|
+
} catch (e: any) {
|
|
545
|
+
return { success: false, error: e.message }
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async removeDependency(taskId: string, dependsOnId: string): Promise<UpdateResult> {
|
|
550
|
+
try {
|
|
551
|
+
return await this.withLock(() => {
|
|
552
|
+
const data = this.loadTasksFile()
|
|
553
|
+
if (!data) return { success: false, error: 'Tasks file not found' }
|
|
554
|
+
|
|
555
|
+
const entry = data.tasks.find(t => t.id === taskId)
|
|
556
|
+
if (!entry) return { success: false, error: `Task ${taskId} not found` }
|
|
557
|
+
|
|
558
|
+
if (entry.dependsOn) {
|
|
559
|
+
entry.dependsOn = entry.dependsOn.filter(d => d !== dependsOnId)
|
|
560
|
+
if (entry.dependsOn.length === 0) {
|
|
561
|
+
delete entry.dependsOn
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
this.saveTasksFile(data)
|
|
566
|
+
return { success: true }
|
|
567
|
+
})
|
|
568
|
+
} catch (e: any) {
|
|
569
|
+
return { success: false, error: e.message }
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private async updateTaskStatus(taskId: string, status: TaskStatus): Promise<UpdateResult> {
|
|
574
|
+
try {
|
|
575
|
+
return await this.withLock(() => {
|
|
576
|
+
const data = this.loadTasksFile()
|
|
577
|
+
if (!data) {
|
|
578
|
+
return { success: false, error: 'Tasks file not found' }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const entry = data.tasks.find(t => t.id === taskId)
|
|
582
|
+
if (!entry) {
|
|
583
|
+
return { success: false, error: `Task ${taskId} not found` }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
entry.status = status
|
|
587
|
+
|
|
588
|
+
if (this.saveTasksFile(data)) {
|
|
589
|
+
return { success: true }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return { success: false, error: 'Failed to save tasks file' }
|
|
593
|
+
})
|
|
594
|
+
} catch (e: any) {
|
|
595
|
+
return { success: false, error: e.message }
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|