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.
Files changed (62) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +528 -0
  3. package/bin/loopwork +0 -0
  4. package/examples/README.md +70 -0
  5. package/examples/basic-json-backend/.specs/tasks/TASK-001.md +22 -0
  6. package/examples/basic-json-backend/.specs/tasks/TASK-002.md +23 -0
  7. package/examples/basic-json-backend/.specs/tasks/TASK-003.md +37 -0
  8. package/examples/basic-json-backend/.specs/tasks/tasks.json +19 -0
  9. package/examples/basic-json-backend/README.md +32 -0
  10. package/examples/basic-json-backend/TESTING.md +184 -0
  11. package/examples/basic-json-backend/hello.test.ts +9 -0
  12. package/examples/basic-json-backend/hello.ts +3 -0
  13. package/examples/basic-json-backend/loopwork.config.js +35 -0
  14. package/examples/basic-json-backend/math.test.ts +29 -0
  15. package/examples/basic-json-backend/math.ts +3 -0
  16. package/examples/basic-json-backend/package.json +15 -0
  17. package/examples/basic-json-backend/quick-start.sh +80 -0
  18. package/loopwork.config.ts +164 -0
  19. package/package.json +26 -0
  20. package/src/backends/github.ts +426 -0
  21. package/src/backends/index.ts +86 -0
  22. package/src/backends/json.ts +598 -0
  23. package/src/backends/plugin.ts +317 -0
  24. package/src/backends/types.ts +19 -0
  25. package/src/commands/init.ts +100 -0
  26. package/src/commands/run.ts +365 -0
  27. package/src/contracts/backend.ts +127 -0
  28. package/src/contracts/config.ts +129 -0
  29. package/src/contracts/index.ts +43 -0
  30. package/src/contracts/plugin.ts +82 -0
  31. package/src/contracts/task.ts +78 -0
  32. package/src/core/cli.ts +275 -0
  33. package/src/core/config.ts +165 -0
  34. package/src/core/state.ts +154 -0
  35. package/src/core/utils.ts +125 -0
  36. package/src/dashboard/cli.ts +449 -0
  37. package/src/dashboard/index.ts +6 -0
  38. package/src/dashboard/kanban.tsx +226 -0
  39. package/src/dashboard/tui.tsx +372 -0
  40. package/src/index.ts +19 -0
  41. package/src/mcp/server.ts +451 -0
  42. package/src/monitor/index.ts +420 -0
  43. package/src/plugins/asana.ts +192 -0
  44. package/src/plugins/cost-tracking.ts +402 -0
  45. package/src/plugins/discord.ts +269 -0
  46. package/src/plugins/everhour.ts +335 -0
  47. package/src/plugins/index.ts +253 -0
  48. package/src/plugins/telegram/bot.ts +517 -0
  49. package/src/plugins/telegram/index.ts +6 -0
  50. package/src/plugins/telegram/notifications.ts +198 -0
  51. package/src/plugins/todoist.ts +261 -0
  52. package/test/backends.test.ts +929 -0
  53. package/test/cli.test.ts +145 -0
  54. package/test/config.test.ts +90 -0
  55. package/test/e2e.test.ts +458 -0
  56. package/test/github-tasks.test.ts +191 -0
  57. package/test/loopwork-config-types.test.ts +288 -0
  58. package/test/monitor.test.ts +123 -0
  59. package/test/plugins.test.ts +1175 -0
  60. package/test/state.test.ts +295 -0
  61. package/test/utils.test.ts +60 -0
  62. 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
+ }