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,426 @@
1
+ import { $ } from 'bun'
2
+ import type { TaskBackend, Task, FindTaskOptions, UpdateResult, BackendConfig } from './types'
3
+ import { LABELS } from '../contracts/task'
4
+
5
+ /**
6
+ * Patterns for parsing dependencies and parent references from issue body
7
+ */
8
+ const PARENT_PATTERN = /(?:^|\n)\s*(?:Parent|parent):\s*(?:#?(\d+)|([A-Z]+-\d+-\d+[a-z]?))/i
9
+ const DEPENDS_PATTERN = /(?:^|\n)\s*(?:Depends on|depends on|Dependencies|dependencies):\s*(.+?)(?:\n|$)/i
10
+
11
+ interface GitHubIssue {
12
+ number: number
13
+ title: string
14
+ body: string
15
+ state: 'open' | 'closed'
16
+ labels: { name: string }[]
17
+ url: string
18
+ }
19
+
20
+ /**
21
+ * GitHub Issues Adapter
22
+ *
23
+ * Adapts GitHub Issues API (via gh CLI) to the TaskBackend interface.
24
+ */
25
+ export class GitHubTaskAdapter implements TaskBackend {
26
+ readonly name = 'github'
27
+ private repo?: string
28
+ private maxRetries = 3
29
+ private baseDelayMs = 1000
30
+
31
+ constructor(config: BackendConfig) {
32
+ this.repo = config.repo
33
+ }
34
+
35
+ private repoFlag(): string {
36
+ return this.repo ? `--repo ${this.repo}` : ''
37
+ }
38
+
39
+ private async withRetry<T>(fn: () => Promise<T>, retries = this.maxRetries): Promise<T> {
40
+ let lastError: Error | null = null
41
+
42
+ for (let attempt = 0; attempt <= retries; attempt++) {
43
+ try {
44
+ return await fn()
45
+ } catch (e: any) {
46
+ lastError = e
47
+ const isRetryable = this.isRetryableError(e)
48
+ if (!isRetryable || attempt === retries) throw e
49
+ const delay = this.baseDelayMs * Math.pow(2, attempt)
50
+ await new Promise(r => setTimeout(r, delay))
51
+ }
52
+ }
53
+
54
+ throw lastError || new Error('Retry failed')
55
+ }
56
+
57
+ private isRetryableError(error: any): boolean {
58
+ const message = String(error?.message || error || '').toLowerCase()
59
+ if (message.includes('network') || message.includes('timeout')) return true
60
+ if (message.includes('econnreset') || message.includes('econnrefused')) return true
61
+ if (message.includes('socket hang up')) return true
62
+ if (message.includes('rate limit') || message.includes('429')) return true
63
+ if (message.includes('502') || message.includes('503') || message.includes('504')) return true
64
+ return false
65
+ }
66
+
67
+ private extractIssueNumber(taskId: string): number | null {
68
+ if (taskId.startsWith('GH-')) return parseInt(taskId.slice(3), 10)
69
+ const num = parseInt(taskId, 10)
70
+ if (!isNaN(num)) return num
71
+ const hashMatch = taskId.match(/#(\d+)/)
72
+ if (hashMatch) return parseInt(hashMatch[1], 10)
73
+ return null
74
+ }
75
+
76
+ async findNextTask(options?: FindTaskOptions): Promise<Task | null> {
77
+ const tasks = await this.listPendingTasks(options)
78
+ return tasks[0] || null
79
+ }
80
+
81
+ async getTask(taskId: string): Promise<Task | null> {
82
+ const issueNumber = this.extractIssueNumber(taskId)
83
+ if (!issueNumber) return null
84
+
85
+ try {
86
+ return await this.withRetry(async () => {
87
+ const result = await $`gh issue view ${issueNumber} ${this.repoFlag()} --json number,title,body,labels,url,state`.quiet()
88
+ const issue: GitHubIssue = JSON.parse(result.stdout.toString())
89
+ return this.adaptIssue(issue)
90
+ })
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ async listPendingTasks(options?: FindTaskOptions): Promise<Task[]> {
97
+ const labels = [LABELS.LOOPWORK_TASK, LABELS.STATUS_PENDING]
98
+ if (options?.feature) labels.push(`feat:${options.feature}`)
99
+ if (options?.priority) labels.push(`priority:${options.priority}`)
100
+
101
+ try {
102
+ return await this.withRetry(async () => {
103
+ const labelArg = labels.join(',')
104
+ const result = await $`gh issue list ${this.repoFlag()} --label "${labelArg}" --state open --json number,title,body,labels,url --limit 100`.quiet()
105
+ const issues: GitHubIssue[] = JSON.parse(result.stdout.toString())
106
+ return issues.map(issue => this.adaptIssue(issue))
107
+ })
108
+ } catch {
109
+ return []
110
+ }
111
+ }
112
+
113
+ async countPending(options?: FindTaskOptions): Promise<number> {
114
+ const tasks = await this.listPendingTasks(options)
115
+ return tasks.length
116
+ }
117
+
118
+ async markInProgress(taskId: string): Promise<UpdateResult> {
119
+ const issueNumber = this.extractIssueNumber(taskId)
120
+ if (!issueNumber) return { success: false, error: 'Invalid task ID' }
121
+
122
+ try {
123
+ await this.withRetry(async () => {
124
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.STATUS_PENDING}"`.quiet().nothrow()
125
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.STATUS_FAILED}"`.quiet().nothrow()
126
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --add-label "${LABELS.STATUS_IN_PROGRESS}"`.quiet()
127
+ })
128
+ return { success: true }
129
+ } catch (e: any) {
130
+ return { success: false, error: e.message }
131
+ }
132
+ }
133
+
134
+ async markCompleted(taskId: string, comment?: string): Promise<UpdateResult> {
135
+ const issueNumber = this.extractIssueNumber(taskId)
136
+ if (!issueNumber) return { success: false, error: 'Invalid task ID' }
137
+
138
+ try {
139
+ await this.withRetry(async () => {
140
+ const msg = comment || 'Completed by Loopwork'
141
+ await $`gh issue close ${issueNumber} ${this.repoFlag()} --comment "${msg}"`.quiet()
142
+ })
143
+ return { success: true }
144
+ } catch (e: any) {
145
+ return { success: false, error: e.message }
146
+ }
147
+ }
148
+
149
+ async markFailed(taskId: string, error: string): Promise<UpdateResult> {
150
+ const issueNumber = this.extractIssueNumber(taskId)
151
+ if (!issueNumber) return { success: false, error: 'Invalid task ID' }
152
+
153
+ try {
154
+ await this.withRetry(async () => {
155
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.STATUS_IN_PROGRESS}"`.quiet().nothrow()
156
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --add-label "${LABELS.STATUS_FAILED}"`.quiet()
157
+ const commentText = `**Loopwork Failed**\n\n\`\`\`\n${error}\n\`\`\``
158
+ await $`gh issue comment ${issueNumber} ${this.repoFlag()} --body "${commentText}"`.quiet()
159
+ })
160
+ return { success: true }
161
+ } catch (e: any) {
162
+ return { success: false, error: e.message }
163
+ }
164
+ }
165
+
166
+ async resetToPending(taskId: string): Promise<UpdateResult> {
167
+ const issueNumber = this.extractIssueNumber(taskId)
168
+ if (!issueNumber) return { success: false, error: 'Invalid task ID' }
169
+
170
+ try {
171
+ await this.withRetry(async () => {
172
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.STATUS_FAILED}"`.quiet().nothrow()
173
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.STATUS_IN_PROGRESS}"`.quiet().nothrow()
174
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --add-label "${LABELS.STATUS_PENDING}"`.quiet()
175
+ })
176
+ return { success: true }
177
+ } catch (e: any) {
178
+ return { success: false, error: e.message }
179
+ }
180
+ }
181
+
182
+ async addComment(taskId: string, comment: string): Promise<UpdateResult> {
183
+ const issueNumber = this.extractIssueNumber(taskId)
184
+ if (!issueNumber) return { success: false, error: 'Invalid task ID' }
185
+
186
+ try {
187
+ await this.withRetry(async () => {
188
+ await $`gh issue comment ${issueNumber} ${this.repoFlag()} --body "${comment}"`.quiet()
189
+ })
190
+ return { success: true }
191
+ } catch (e: any) {
192
+ return { success: false, error: e.message }
193
+ }
194
+ }
195
+
196
+ async ping(): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
197
+ const start = Date.now()
198
+ try {
199
+ const result = await $`gh auth status`.quiet()
200
+ return { ok: result.exitCode === 0, latencyMs: Date.now() - start }
201
+ } catch (e: any) {
202
+ return { ok: false, latencyMs: Date.now() - start, error: e.message }
203
+ }
204
+ }
205
+
206
+ async getSubTasks(taskId: string): Promise<Task[]> {
207
+ const allTasks = await this.listAllTasks()
208
+ const parentIssueNum = this.extractIssueNumber(taskId)
209
+ if (!parentIssueNum) return []
210
+
211
+ return allTasks.filter(task => {
212
+ if (!task.parentId) return false
213
+ const parentNum = this.extractIssueNumber(task.parentId)
214
+ return parentNum === parentIssueNum || task.parentId === taskId
215
+ })
216
+ }
217
+
218
+ async getDependencies(taskId: string): Promise<Task[]> {
219
+ const task = await this.getTask(taskId)
220
+ if (!task || !task.dependsOn || task.dependsOn.length === 0) return []
221
+
222
+ const deps: Task[] = []
223
+ for (const depId of task.dependsOn) {
224
+ const depTask = await this.getTask(depId)
225
+ if (depTask) deps.push(depTask)
226
+ }
227
+ return deps
228
+ }
229
+
230
+ async getDependents(taskId: string): Promise<Task[]> {
231
+ const allTasks = await this.listAllTasks()
232
+ return allTasks.filter(task => task.dependsOn?.includes(taskId))
233
+ }
234
+
235
+ async areDependenciesMet(taskId: string): Promise<boolean> {
236
+ const deps = await this.getDependencies(taskId)
237
+ return deps.every(dep => dep.status === 'completed')
238
+ }
239
+
240
+ async createSubTask(parentId: string, task: Omit<Task, 'id' | 'parentId' | 'status'>): Promise<Task> {
241
+ const parentNum = this.extractIssueNumber(parentId)
242
+ if (!parentNum) throw new Error('Invalid parent task ID')
243
+
244
+ const body = `Parent: #${parentNum}\n\n${task.description}`
245
+ const labels = [
246
+ LABELS.LOOPWORK_TASK,
247
+ LABELS.STATUS_PENDING,
248
+ LABELS.SUB_TASK,
249
+ `priority:${task.priority}`,
250
+ ]
251
+ if (task.feature) labels.push(`feat:${task.feature}`)
252
+
253
+ const result = await this.withRetry(async () => {
254
+ const r = await $`gh issue create ${this.repoFlag()} --title "${task.title}" --body "${body}" --label "${labels.join(',')}" --json number,title,body,labels,url`.quiet()
255
+ return JSON.parse(r.stdout.toString())
256
+ })
257
+
258
+ return this.adaptIssue(result)
259
+ }
260
+
261
+ async addDependency(taskId: string, dependsOnId: string): Promise<UpdateResult> {
262
+ const task = await this.getTask(taskId)
263
+ if (!task) return { success: false, error: 'Task not found' }
264
+
265
+ const issueNumber = this.extractIssueNumber(taskId)
266
+ if (!issueNumber) return { success: false, error: 'Invalid task ID' }
267
+
268
+ const currentDeps = task.dependsOn || []
269
+ if (currentDeps.includes(dependsOnId)) return { success: true }
270
+
271
+ const newDeps = [...currentDeps, dependsOnId]
272
+ const depsLine = `Depends on: ${newDeps.join(', ')}`
273
+
274
+ let newBody = task.description
275
+ if (DEPENDS_PATTERN.test(newBody)) {
276
+ newBody = newBody.replace(DEPENDS_PATTERN, `\nDepends on: ${newDeps.join(', ')}\n`)
277
+ } else {
278
+ newBody = `${depsLine}\n\n${newBody}`
279
+ }
280
+
281
+ try {
282
+ await this.withRetry(async () => {
283
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --body "${newBody}"`.quiet()
284
+ })
285
+ return { success: true }
286
+ } catch (e: any) {
287
+ return { success: false, error: e.message }
288
+ }
289
+ }
290
+
291
+ async removeDependency(taskId: string, dependsOnId: string): Promise<UpdateResult> {
292
+ const task = await this.getTask(taskId)
293
+ if (!task) return { success: false, error: 'Task not found' }
294
+
295
+ const issueNumber = this.extractIssueNumber(taskId)
296
+ if (!issueNumber) return { success: false, error: 'Invalid task ID' }
297
+
298
+ const currentDeps = task.dependsOn || []
299
+ const newDeps = currentDeps.filter(d => d !== dependsOnId)
300
+
301
+ let newBody = task.description
302
+ if (newDeps.length > 0) {
303
+ newBody = newBody.replace(DEPENDS_PATTERN, `\nDepends on: ${newDeps.join(', ')}\n`)
304
+ } else {
305
+ newBody = newBody.replace(DEPENDS_PATTERN, '\n')
306
+ }
307
+
308
+ try {
309
+ await this.withRetry(async () => {
310
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --body "${newBody}"`.quiet()
311
+ })
312
+ return { success: true }
313
+ } catch (e: any) {
314
+ return { success: false, error: e.message }
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Create a new task (GitHub issue)
320
+ */
321
+ async createTask(task: Omit<Task, 'id' | 'status'>): Promise<Task> {
322
+ const labels = [
323
+ LABELS.LOOPWORK_TASK,
324
+ LABELS.STATUS_PENDING,
325
+ `priority:${task.priority}`,
326
+ ]
327
+ if (task.feature) labels.push(`feat:${task.feature}`)
328
+
329
+ // Add dependencies to body if present
330
+ let body = task.description
331
+ if (task.dependsOn && task.dependsOn.length > 0) {
332
+ body = `Depends on: ${task.dependsOn.join(', ')}\n\n${body}`
333
+ }
334
+
335
+ const result = await this.withRetry(async () => {
336
+ const r = await $`gh issue create ${this.repoFlag()} --title "${task.title}" --body "${body}" --label "${labels.join(',')}" --json number,title,body,labels,url`.quiet()
337
+ return JSON.parse(r.stdout.toString())
338
+ })
339
+
340
+ return this.adaptIssue(result)
341
+ }
342
+
343
+ /**
344
+ * Set task priority by updating labels
345
+ */
346
+ async setPriority(taskId: string, priority: Task['priority']): Promise<UpdateResult> {
347
+ const issueNumber = this.extractIssueNumber(taskId)
348
+ if (!issueNumber) return { success: false, error: 'Invalid task ID' }
349
+
350
+ try {
351
+ await this.withRetry(async () => {
352
+ // Remove existing priority labels
353
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.PRIORITY_HIGH}"`.quiet().nothrow()
354
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.PRIORITY_MEDIUM}"`.quiet().nothrow()
355
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.PRIORITY_LOW}"`.quiet().nothrow()
356
+
357
+ // Add new priority label
358
+ const priorityLabel = `priority:${priority}`
359
+ await $`gh issue edit ${issueNumber} ${this.repoFlag()} --add-label "${priorityLabel}"`.quiet()
360
+ })
361
+ return { success: true }
362
+ } catch (e: any) {
363
+ return { success: false, error: e.message }
364
+ }
365
+ }
366
+
367
+ private async listAllTasks(): Promise<Task[]> {
368
+ try {
369
+ return await this.withRetry(async () => {
370
+ const result = await $`gh issue list ${this.repoFlag()} --label "${LABELS.LOOPWORK_TASK}" --state open --json number,title,body,labels,url --limit 200`.quiet()
371
+ const issues: GitHubIssue[] = JSON.parse(result.stdout.toString())
372
+ return issues.map(issue => this.adaptIssue(issue))
373
+ })
374
+ } catch {
375
+ return []
376
+ }
377
+ }
378
+
379
+ private adaptIssue(issue: GitHubIssue): Task {
380
+ const labels = issue.labels.map(l => l.name)
381
+ const body = issue.body || ''
382
+
383
+ const taskIdMatch = issue.title.match(/TASK-\d+-\d+[a-z]?/i)
384
+ const id = taskIdMatch ? taskIdMatch[0].toUpperCase() : `GH-${issue.number}`
385
+
386
+ let status: Task['status'] = 'pending'
387
+ if (labels.includes(LABELS.STATUS_IN_PROGRESS)) status = 'in-progress'
388
+ else if (labels.includes(LABELS.STATUS_FAILED)) status = 'failed'
389
+ else if (issue.state === 'closed') status = 'completed'
390
+
391
+ let priority: Task['priority'] = 'medium'
392
+ if (labels.includes(LABELS.PRIORITY_HIGH)) priority = 'high'
393
+ else if (labels.includes(LABELS.PRIORITY_LOW)) priority = 'low'
394
+
395
+ const featureLabel = labels.find(l => l.startsWith('feat:'))
396
+ const feature = featureLabel?.replace('feat:', '')
397
+
398
+ let parentId: string | undefined
399
+ const parentMatch = body.match(PARENT_PATTERN)
400
+ if (parentMatch) {
401
+ parentId = parentMatch[1] ? `GH-${parentMatch[1]}` : parentMatch[2]
402
+ }
403
+
404
+ let dependsOn: string[] | undefined
405
+ const depsMatch = body.match(DEPENDS_PATTERN)
406
+ if (depsMatch) {
407
+ const depsStr = depsMatch[1]
408
+ dependsOn = depsStr.split(/[,\s]+/).filter(Boolean).map(d => {
409
+ const num = d.replace('#', '')
410
+ return /^\d+$/.test(num) ? `GH-${num}` : d
411
+ })
412
+ }
413
+
414
+ return {
415
+ id,
416
+ title: issue.title,
417
+ description: body,
418
+ status,
419
+ priority,
420
+ feature,
421
+ parentId,
422
+ dependsOn,
423
+ metadata: { issueNumber: issue.number, url: issue.url, labels },
424
+ }
425
+ }
426
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Task Backend Module
3
+ *
4
+ * Provides adapter pattern for multiple task sources.
5
+ * Use createBackend() factory to instantiate the appropriate adapter.
6
+ */
7
+
8
+ export * from './types'
9
+ export { GitHubTaskAdapter } from './github'
10
+ export { JsonTaskAdapter } from './json'
11
+
12
+ import type { TaskBackend, BackendConfig } from './types'
13
+ import { GitHubTaskAdapter } from './github'
14
+ import { JsonTaskAdapter } from './json'
15
+
16
+ /**
17
+ * Create a task backend based on configuration
18
+ *
19
+ * @example
20
+ * // GitHub Issues backend
21
+ * const backend = createBackend({ type: 'github', repo: 'owner/repo' })
22
+ *
23
+ * @example
24
+ * // JSON file backend
25
+ * const backend = createBackend({
26
+ * type: 'json',
27
+ * tasksFile: '.specs/tasks/tasks.json'
28
+ * })
29
+ */
30
+ export function createBackend(config: BackendConfig): TaskBackend {
31
+ switch (config.type) {
32
+ case 'github':
33
+ return new GitHubTaskAdapter(config)
34
+
35
+ case 'json':
36
+ return new JsonTaskAdapter(config)
37
+
38
+ default:
39
+ throw new Error(`Unknown backend type: ${(config as any).type}`)
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Auto-detect backend from environment/files
45
+ *
46
+ * Detection order:
47
+ * 1. If LOOPWORK_BACKEND env var is set, use that
48
+ * 2. If .specs/tasks/tasks.json exists, use json
49
+ * 3. Default to github
50
+ */
51
+ export function detectBackend(projectRoot: string): BackendConfig {
52
+ const envBackend = process.env.LOOPWORK_BACKEND
53
+
54
+ if (envBackend === 'json') {
55
+ return {
56
+ type: 'json',
57
+ tasksFile: `${projectRoot}/.specs/tasks/tasks.json`,
58
+ tasksDir: `${projectRoot}/.specs/tasks`,
59
+ }
60
+ }
61
+
62
+ if (envBackend === 'github') {
63
+ return {
64
+ type: 'github',
65
+ repo: process.env.LOOPWORK_REPO,
66
+ }
67
+ }
68
+
69
+ // Auto-detect
70
+ const fs = require('fs')
71
+ const jsonTasksFile = `${projectRoot}/.specs/tasks/tasks.json`
72
+
73
+ if (fs.existsSync(jsonTasksFile)) {
74
+ return {
75
+ type: 'json',
76
+ tasksFile: jsonTasksFile,
77
+ tasksDir: `${projectRoot}/.specs/tasks`,
78
+ }
79
+ }
80
+
81
+ // Default to GitHub
82
+ return {
83
+ type: 'github',
84
+ repo: process.env.LOOPWORK_REPO,
85
+ }
86
+ }