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,365 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { getConfig } from '../core/config'
|
|
4
|
+
import { StateManager } from '../core/state'
|
|
5
|
+
import { createBackend, type TaskBackend, type Task } from '../backends'
|
|
6
|
+
import { CliExecutor } from '../core/cli'
|
|
7
|
+
import { logger } from '../core/utils'
|
|
8
|
+
import { plugins } from '../plugins'
|
|
9
|
+
import { createCostTrackingPlugin } from '../plugins/cost-tracking'
|
|
10
|
+
import { createTelegramHookPlugin } from '../plugins/telegram/notifications'
|
|
11
|
+
import type { TaskContext } from '../contracts/plugin'
|
|
12
|
+
|
|
13
|
+
function generateSuccessCriteria(task: Task): string[] {
|
|
14
|
+
const criteria: string[] = []
|
|
15
|
+
const desc = task.description.toLowerCase()
|
|
16
|
+
const title = task.title.toLowerCase()
|
|
17
|
+
|
|
18
|
+
if (desc.includes('test') || title.includes('test')) {
|
|
19
|
+
criteria.push('All related tests pass (`bun test` or `yarn test`)')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (desc.includes('api') || desc.includes('endpoint') || desc.includes('graphql')) {
|
|
23
|
+
criteria.push('API endpoint is functional and returns expected responses')
|
|
24
|
+
criteria.push('GraphQL schema validates (no SDL errors)')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (desc.includes('component') || desc.includes('page') || desc.includes('ui') || desc.includes('button')) {
|
|
28
|
+
criteria.push('Component renders without errors')
|
|
29
|
+
criteria.push('UI matches the requirements described in the PRD')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (desc.includes('database') || desc.includes('migration') || desc.includes('prisma') || desc.includes('model')) {
|
|
33
|
+
criteria.push('Database migrations apply cleanly')
|
|
34
|
+
criteria.push('Prisma schema is valid (`yarn rw prisma validate`)')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (desc.includes('fix') || desc.includes('bug') || title.includes('fix')) {
|
|
38
|
+
criteria.push('The bug is fixed and no longer reproducible')
|
|
39
|
+
criteria.push('No regression in related functionality')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (desc.includes('refactor') || title.includes('refactor')) {
|
|
43
|
+
criteria.push('Code behavior is unchanged after refactoring')
|
|
44
|
+
criteria.push('Existing tests still pass')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (criteria.length === 0) {
|
|
48
|
+
criteria.push('Implementation matches the PRD requirements')
|
|
49
|
+
criteria.push('No type errors (`yarn rw type-check`)')
|
|
50
|
+
criteria.push('Code follows project conventions')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return criteria
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function generateFailureCriteria(task: Task): string[] {
|
|
57
|
+
const criteria: string[] = []
|
|
58
|
+
const desc = task.description.toLowerCase()
|
|
59
|
+
|
|
60
|
+
criteria.push('Type errors exist after changes')
|
|
61
|
+
criteria.push('Tests fail that were passing before')
|
|
62
|
+
|
|
63
|
+
if (desc.includes('auth') || desc.includes('password') || desc.includes('login') || desc.includes('security')) {
|
|
64
|
+
criteria.push('Security vulnerabilities introduced (injection, XSS, etc.)')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (desc.includes('api') || desc.includes('interface') || desc.includes('contract')) {
|
|
68
|
+
criteria.push('Breaking changes to existing API contracts')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return criteria
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildPrompt(task: Task, retryContext: string = ''): string {
|
|
75
|
+
const url = task.metadata?.url || task.metadata?.prdFile || ''
|
|
76
|
+
const urlLine = url ? `\nSource: ${url}` : ''
|
|
77
|
+
|
|
78
|
+
const successCriteria = generateSuccessCriteria(task)
|
|
79
|
+
const failureCriteria = generateFailureCriteria(task)
|
|
80
|
+
|
|
81
|
+
return `# Task: ${task.id}
|
|
82
|
+
|
|
83
|
+
## Title
|
|
84
|
+
${task.title}
|
|
85
|
+
|
|
86
|
+
## PRD (Product Requirements)
|
|
87
|
+
${task.description}
|
|
88
|
+
|
|
89
|
+
## Success Criteria
|
|
90
|
+
The task is considered COMPLETE when:
|
|
91
|
+
${successCriteria.map(c => `- [ ] ${c}`).join('\n')}
|
|
92
|
+
|
|
93
|
+
## Failure Criteria
|
|
94
|
+
The task should be marked FAILED if:
|
|
95
|
+
${failureCriteria.map(c => `- ${c}`).join('\n')}
|
|
96
|
+
|
|
97
|
+
## Instructions
|
|
98
|
+
1. Read the PRD carefully and understand the requirements
|
|
99
|
+
2. Implement the task as described
|
|
100
|
+
3. Verify against the success criteria above
|
|
101
|
+
4. Run relevant tests to verify your changes
|
|
102
|
+
5. If tests fail, fix the issues before marking complete
|
|
103
|
+
|
|
104
|
+
${retryContext ? `## Previous Attempt Context\n${retryContext}` : ''}
|
|
105
|
+
|
|
106
|
+
## Important
|
|
107
|
+
- Follow the project's coding style (no semicolons, single quotes, 2-space indent)
|
|
108
|
+
- Run \`yarn rw type-check\` before tests
|
|
109
|
+
- Self-verify against success criteria before marking complete
|
|
110
|
+
${urlLine}
|
|
111
|
+
`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function run(options: any = {}) {
|
|
115
|
+
const config = await getConfig(options)
|
|
116
|
+
const stateManager = new StateManager(config)
|
|
117
|
+
const backend: TaskBackend = createBackend(config.backend)
|
|
118
|
+
const cliExecutor = new CliExecutor(config)
|
|
119
|
+
|
|
120
|
+
if (config.resume) {
|
|
121
|
+
const state = stateManager.loadState()
|
|
122
|
+
if (!state) {
|
|
123
|
+
logger.error('Cannot resume: no saved state')
|
|
124
|
+
process.exit(1)
|
|
125
|
+
}
|
|
126
|
+
config.startTask = String(state.lastIssue)
|
|
127
|
+
config.outputDir = state.lastOutputDir
|
|
128
|
+
logger.info(`Resuming from task ${state.lastIssue}, iteration ${state.lastIteration}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!stateManager.acquireLock()) {
|
|
132
|
+
process.exit(1)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let currentTaskId: string | null = null
|
|
136
|
+
let currentIteration = 0
|
|
137
|
+
|
|
138
|
+
const cleanup = () => {
|
|
139
|
+
logger.warn('\nReceived interrupt signal. Saving state...')
|
|
140
|
+
cliExecutor.killCurrent()
|
|
141
|
+
if (currentTaskId) {
|
|
142
|
+
const stateRef = parseInt(currentTaskId.replace(/\D/g, ''), 10) || 0
|
|
143
|
+
stateManager.saveState(stateRef, currentIteration)
|
|
144
|
+
logger.info('State saved. Resume with: --resume')
|
|
145
|
+
}
|
|
146
|
+
stateManager.releaseLock()
|
|
147
|
+
process.exit(130)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
process.on('SIGINT', cleanup)
|
|
151
|
+
process.on('SIGTERM', cleanup)
|
|
152
|
+
|
|
153
|
+
fs.mkdirSync(config.outputDir, { recursive: true })
|
|
154
|
+
fs.mkdirSync(path.join(config.outputDir, 'logs'), { recursive: true })
|
|
155
|
+
|
|
156
|
+
const namespace = stateManager.getNamespace()
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await plugins.register(createCostTrackingPlugin(config.projectRoot, namespace))
|
|
160
|
+
logger.debug('Cost tracking plugin registered')
|
|
161
|
+
} catch (e: any) {
|
|
162
|
+
logger.debug(`Cost tracking plugin not registered: ${e.message}`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const telegramToken = process.env.TELEGRAM_BOT_TOKEN || (config as any).telegram?.botToken
|
|
166
|
+
const telegramChatId = process.env.TELEGRAM_CHAT_ID || (config as any).telegram?.chatId
|
|
167
|
+
if (telegramToken && telegramChatId) {
|
|
168
|
+
try {
|
|
169
|
+
await plugins.register(createTelegramHookPlugin({
|
|
170
|
+
botToken: telegramToken,
|
|
171
|
+
chatId: telegramChatId,
|
|
172
|
+
}))
|
|
173
|
+
logger.info('Telegram notifications enabled')
|
|
174
|
+
} catch (e: any) {
|
|
175
|
+
logger.debug(`Telegram plugin not registered: ${e.message}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await plugins.runHook('onLoopStart', namespace)
|
|
180
|
+
|
|
181
|
+
logger.info('Loopwork Starting')
|
|
182
|
+
logger.info('─────────────────────────────────────')
|
|
183
|
+
logger.info(`Backend: ${backend.name}`)
|
|
184
|
+
if (config.backend.type === 'github') {
|
|
185
|
+
logger.info(`Repo: ${config.backend.repo || '(current)'}`)
|
|
186
|
+
} else {
|
|
187
|
+
logger.info(`Tasks File: ${config.backend.tasksFile}`)
|
|
188
|
+
}
|
|
189
|
+
logger.info(`Feature: ${config.feature || 'all'}`)
|
|
190
|
+
logger.info(`Max Iterations: ${config.maxIterations}`)
|
|
191
|
+
logger.info(`Timeout: ${config.timeout}s per task`)
|
|
192
|
+
logger.info(`CLI: ${config.cli}`)
|
|
193
|
+
logger.info(`Session ID: ${config.sessionId}`)
|
|
194
|
+
logger.info(`Output Dir: ${config.outputDir}`)
|
|
195
|
+
logger.info(`Dry Run: ${config.dryRun}`)
|
|
196
|
+
logger.info('─────────────────────────────────────')
|
|
197
|
+
|
|
198
|
+
const pendingCount = await backend.countPending({ feature: config.feature })
|
|
199
|
+
logger.info(`Pending tasks: ${pendingCount}`)
|
|
200
|
+
|
|
201
|
+
if (pendingCount === 0) {
|
|
202
|
+
logger.success('No pending tasks found!')
|
|
203
|
+
stateManager.releaseLock()
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let iteration = 0
|
|
208
|
+
let tasksCompleted = 0
|
|
209
|
+
let tasksFailed = 0
|
|
210
|
+
let consecutiveFailures = 0
|
|
211
|
+
let retryContext = ''
|
|
212
|
+
const maxRetries = config.maxRetries ?? 3
|
|
213
|
+
const retryCount: Map<string, number> = new Map()
|
|
214
|
+
|
|
215
|
+
while (iteration < config.maxIterations) {
|
|
216
|
+
iteration++
|
|
217
|
+
cliExecutor.resetFallback()
|
|
218
|
+
|
|
219
|
+
if (consecutiveFailures >= (config.circuitBreakerThreshold ?? 5)) {
|
|
220
|
+
logger.error(`Circuit breaker: ${consecutiveFailures} consecutive failures`)
|
|
221
|
+
break
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let task: Task | null = null
|
|
225
|
+
|
|
226
|
+
if (config.startTask && iteration === 1) {
|
|
227
|
+
task = await backend.getTask(config.startTask)
|
|
228
|
+
} else {
|
|
229
|
+
task = await backend.findNextTask({ feature: config.feature })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!task) {
|
|
233
|
+
logger.success('No more pending tasks!')
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log('')
|
|
238
|
+
logger.info('═══════════════════════════════════════════════════════════════')
|
|
239
|
+
logger.info(`Iteration ${iteration} / ${config.maxIterations}`)
|
|
240
|
+
logger.info(`Task: ${task.id}`)
|
|
241
|
+
logger.info(`Title: ${task.title}`)
|
|
242
|
+
logger.info(`Priority: ${task.priority}`)
|
|
243
|
+
logger.info(`Feature: ${task.feature || 'none'}`)
|
|
244
|
+
if (task.metadata?.url) {
|
|
245
|
+
logger.info(`URL: ${task.metadata.url}`)
|
|
246
|
+
}
|
|
247
|
+
logger.info('═══════════════════════════════════════════════════════════════')
|
|
248
|
+
console.log('')
|
|
249
|
+
|
|
250
|
+
currentTaskId = task.id
|
|
251
|
+
currentIteration = iteration
|
|
252
|
+
const stateRef = parseInt(task.id.replace(/\D/g, ''), 10) || iteration
|
|
253
|
+
stateManager.saveState(stateRef, iteration)
|
|
254
|
+
|
|
255
|
+
if (config.dryRun) {
|
|
256
|
+
logger.warn(`[DRY RUN] Would execute: ${task.id}`)
|
|
257
|
+
logger.debug(`PRD preview:\n${task.description?.substring(0, 300)}...`)
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await backend.markInProgress(task.id)
|
|
262
|
+
|
|
263
|
+
const taskContext: TaskContext = {
|
|
264
|
+
task,
|
|
265
|
+
iteration,
|
|
266
|
+
startTime: new Date(),
|
|
267
|
+
namespace,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await plugins.runHook('onTaskStart', taskContext)
|
|
271
|
+
|
|
272
|
+
const prompt = buildPrompt(task, retryContext)
|
|
273
|
+
retryContext = ''
|
|
274
|
+
|
|
275
|
+
const promptFile = path.join(config.outputDir, 'logs', `iteration-${iteration}-prompt.md`)
|
|
276
|
+
fs.writeFileSync(promptFile, prompt)
|
|
277
|
+
|
|
278
|
+
const outputFile = path.join(config.outputDir, 'logs', `iteration-${iteration}-output.txt`)
|
|
279
|
+
|
|
280
|
+
const exitCode = await cliExecutor.execute(prompt, outputFile, config.timeout)
|
|
281
|
+
|
|
282
|
+
if (exitCode === 0) {
|
|
283
|
+
const comment = `Completed by Loopwork\n\nBackend: ${backend.name}\nSession: ${config.sessionId}\nIteration: ${iteration}`
|
|
284
|
+
await backend.markCompleted(task.id, comment)
|
|
285
|
+
|
|
286
|
+
let output = ''
|
|
287
|
+
try {
|
|
288
|
+
if (fs.existsSync(outputFile)) {
|
|
289
|
+
output = fs.readFileSync(outputFile, 'utf-8')
|
|
290
|
+
}
|
|
291
|
+
} catch {}
|
|
292
|
+
|
|
293
|
+
const duration = (Date.now() - taskContext.startTime.getTime()) / 1000
|
|
294
|
+
await plugins.runHook('onTaskComplete', taskContext, { output, duration })
|
|
295
|
+
|
|
296
|
+
tasksCompleted++
|
|
297
|
+
consecutiveFailures = 0
|
|
298
|
+
retryCount.delete(task.id)
|
|
299
|
+
logger.success(`Task ${task.id} completed!`)
|
|
300
|
+
} else {
|
|
301
|
+
const currentRetries = retryCount.get(task.id) || 0
|
|
302
|
+
|
|
303
|
+
if (currentRetries < maxRetries - 1) {
|
|
304
|
+
retryCount.set(task.id, currentRetries + 1)
|
|
305
|
+
logger.warn(`Task ${task.id} failed, retrying (${currentRetries + 2}/${maxRetries})...`)
|
|
306
|
+
|
|
307
|
+
await backend.resetToPending(task.id)
|
|
308
|
+
|
|
309
|
+
let logExcerpt = ''
|
|
310
|
+
try {
|
|
311
|
+
if (fs.existsSync(outputFile)) {
|
|
312
|
+
const content = fs.readFileSync(outputFile, 'utf-8')
|
|
313
|
+
logExcerpt = content.slice(-1000)
|
|
314
|
+
}
|
|
315
|
+
} catch {}
|
|
316
|
+
|
|
317
|
+
retryContext = `## Previous Attempt Failed\nAttempt ${currentRetries + 1} failed. Log excerpt:\n\`\`\`\n${logExcerpt}\n\`\`\``
|
|
318
|
+
|
|
319
|
+
await new Promise(r => setTimeout(r, config.retryDelay ?? 3000))
|
|
320
|
+
continue
|
|
321
|
+
} else {
|
|
322
|
+
const errorMsg = `Max retries (${maxRetries}) reached\n\nSession: ${config.sessionId}\nIteration: ${iteration}`
|
|
323
|
+
await backend.markFailed(task.id, errorMsg)
|
|
324
|
+
|
|
325
|
+
await plugins.runHook('onTaskFailed', taskContext, errorMsg)
|
|
326
|
+
|
|
327
|
+
tasksFailed++
|
|
328
|
+
consecutiveFailures++
|
|
329
|
+
retryCount.delete(task.id)
|
|
330
|
+
logger.error(`Task ${task.id} failed after ${maxRetries} attempts`)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
await new Promise(r => setTimeout(r, config.taskDelay ?? 2000))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
console.log('')
|
|
338
|
+
logger.info('═══════════════════════════════════════════════════════════════')
|
|
339
|
+
logger.info('Loopwork Complete')
|
|
340
|
+
logger.info('═══════════════════════════════════════════════════════════════')
|
|
341
|
+
logger.info(`Backend: ${backend.name}`)
|
|
342
|
+
logger.info(`Iterations: ${iteration}`)
|
|
343
|
+
logger.info(`Tasks Completed: ${tasksCompleted}`)
|
|
344
|
+
logger.info(`Tasks Failed: ${tasksFailed}`)
|
|
345
|
+
logger.info(`Session ID: ${config.sessionId}`)
|
|
346
|
+
logger.info(`Output Dir: ${config.outputDir}`)
|
|
347
|
+
console.log('')
|
|
348
|
+
|
|
349
|
+
const finalPending = await backend.countPending({ feature: config.feature })
|
|
350
|
+
logger.info(`Final Status: ${finalPending} pending`)
|
|
351
|
+
|
|
352
|
+
if (finalPending === 0) {
|
|
353
|
+
logger.success('All tasks completed!')
|
|
354
|
+
stateManager.clearState()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const loopDuration = Date.now() - (stateManager.loadState()?.startedAt || Date.now())
|
|
358
|
+
await plugins.runHook('onLoopEnd', namespace, {
|
|
359
|
+
completed: tasksCompleted,
|
|
360
|
+
failed: tasksFailed,
|
|
361
|
+
duration: loopDuration / 1000,
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
stateManager.releaseLock()
|
|
365
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend Interface Contract
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Task, Priority } from './task'
|
|
6
|
+
import type { LoopworkPlugin } from './plugin'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Options for finding tasks
|
|
10
|
+
*/
|
|
11
|
+
export interface FindTaskOptions {
|
|
12
|
+
feature?: string
|
|
13
|
+
priority?: Priority
|
|
14
|
+
startFrom?: string
|
|
15
|
+
parentId?: string
|
|
16
|
+
includeBlocked?: boolean
|
|
17
|
+
topLevelOnly?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Result of a task update operation
|
|
22
|
+
*/
|
|
23
|
+
export interface UpdateResult {
|
|
24
|
+
success: boolean
|
|
25
|
+
error?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Backend health check result
|
|
30
|
+
*/
|
|
31
|
+
export interface PingResult {
|
|
32
|
+
ok: boolean
|
|
33
|
+
latencyMs: number
|
|
34
|
+
error?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Task Backend Interface
|
|
39
|
+
*
|
|
40
|
+
* All task sources (GitHub Issues, JSON files, etc.) must implement this interface.
|
|
41
|
+
*/
|
|
42
|
+
export interface TaskBackend {
|
|
43
|
+
/** Backend name for logging */
|
|
44
|
+
readonly name: string
|
|
45
|
+
|
|
46
|
+
/** Find the next pending task */
|
|
47
|
+
findNextTask(options?: FindTaskOptions): Promise<Task | null>
|
|
48
|
+
|
|
49
|
+
/** Get a specific task by ID */
|
|
50
|
+
getTask(taskId: string): Promise<Task | null>
|
|
51
|
+
|
|
52
|
+
/** List all pending tasks */
|
|
53
|
+
listPendingTasks(options?: FindTaskOptions): Promise<Task[]>
|
|
54
|
+
|
|
55
|
+
/** Count pending tasks */
|
|
56
|
+
countPending(options?: FindTaskOptions): Promise<number>
|
|
57
|
+
|
|
58
|
+
/** Mark task as in-progress */
|
|
59
|
+
markInProgress(taskId: string): Promise<UpdateResult>
|
|
60
|
+
|
|
61
|
+
/** Mark task as completed */
|
|
62
|
+
markCompleted(taskId: string, comment?: string): Promise<UpdateResult>
|
|
63
|
+
|
|
64
|
+
/** Mark task as failed */
|
|
65
|
+
markFailed(taskId: string, error: string): Promise<UpdateResult>
|
|
66
|
+
|
|
67
|
+
/** Reset task to pending */
|
|
68
|
+
resetToPending(taskId: string): Promise<UpdateResult>
|
|
69
|
+
|
|
70
|
+
/** Add a comment to a task (optional) */
|
|
71
|
+
addComment?(taskId: string, comment: string): Promise<UpdateResult>
|
|
72
|
+
|
|
73
|
+
/** Health check */
|
|
74
|
+
ping(): Promise<PingResult>
|
|
75
|
+
|
|
76
|
+
// Sub-task and dependency methods
|
|
77
|
+
|
|
78
|
+
/** Get sub-tasks of a parent */
|
|
79
|
+
getSubTasks(taskId: string): Promise<Task[]>
|
|
80
|
+
|
|
81
|
+
/** Get tasks this task depends on */
|
|
82
|
+
getDependencies(taskId: string): Promise<Task[]>
|
|
83
|
+
|
|
84
|
+
/** Get tasks that depend on this task */
|
|
85
|
+
getDependents(taskId: string): Promise<Task[]>
|
|
86
|
+
|
|
87
|
+
/** Check if all dependencies are completed */
|
|
88
|
+
areDependenciesMet(taskId: string): Promise<boolean>
|
|
89
|
+
|
|
90
|
+
/** Create a new task (optional) */
|
|
91
|
+
createTask?(task: Omit<Task, 'id' | 'status'>): Promise<Task>
|
|
92
|
+
|
|
93
|
+
/** Create a sub-task (optional) */
|
|
94
|
+
createSubTask?(parentId: string, task: Omit<Task, 'id' | 'parentId' | 'status'>): Promise<Task>
|
|
95
|
+
|
|
96
|
+
/** Add a dependency (optional) */
|
|
97
|
+
addDependency?(taskId: string, dependsOnId: string): Promise<UpdateResult>
|
|
98
|
+
|
|
99
|
+
/** Remove a dependency (optional) */
|
|
100
|
+
removeDependency?(taskId: string, dependsOnId: string): Promise<UpdateResult>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Backend Plugin - combines TaskBackend operations with Plugin lifecycle hooks
|
|
105
|
+
*/
|
|
106
|
+
export interface BackendPlugin extends LoopworkPlugin, TaskBackend {
|
|
107
|
+
/** Backend type identifier */
|
|
108
|
+
readonly backendType: 'json' | 'github' | string
|
|
109
|
+
|
|
110
|
+
/** Set task priority (optional) */
|
|
111
|
+
setPriority?(taskId: string, priority: Priority): Promise<UpdateResult>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Backend configuration
|
|
116
|
+
*/
|
|
117
|
+
export interface BackendConfig {
|
|
118
|
+
type: 'github' | 'json'
|
|
119
|
+
repo?: string
|
|
120
|
+
tasksFile?: string
|
|
121
|
+
tasksDir?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Factory function type for creating backends
|
|
126
|
+
*/
|
|
127
|
+
export type BackendFactory = (config: BackendConfig) => TaskBackend
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { LoopworkPlugin } from './plugin'
|
|
6
|
+
import type { BackendConfig } from './backend'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Telegram plugin configuration
|
|
10
|
+
*/
|
|
11
|
+
export interface TelegramConfig {
|
|
12
|
+
botToken?: string
|
|
13
|
+
chatId?: string
|
|
14
|
+
notifications?: boolean
|
|
15
|
+
silent?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Discord plugin configuration
|
|
20
|
+
*/
|
|
21
|
+
export interface DiscordConfig {
|
|
22
|
+
webhookUrl?: string
|
|
23
|
+
username?: string
|
|
24
|
+
avatarUrl?: string
|
|
25
|
+
notifyOnStart?: boolean
|
|
26
|
+
notifyOnComplete?: boolean
|
|
27
|
+
notifyOnFail?: boolean
|
|
28
|
+
notifyOnLoopEnd?: boolean
|
|
29
|
+
mentionOnFail?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Asana plugin configuration
|
|
34
|
+
*/
|
|
35
|
+
export interface AsanaConfig {
|
|
36
|
+
accessToken?: string
|
|
37
|
+
projectId?: string
|
|
38
|
+
workspaceId?: string
|
|
39
|
+
autoCreate?: boolean
|
|
40
|
+
syncStatus?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Everhour plugin configuration
|
|
45
|
+
*/
|
|
46
|
+
export interface EverhourConfig {
|
|
47
|
+
apiKey?: string
|
|
48
|
+
autoStartTimer?: boolean
|
|
49
|
+
autoStopTimer?: boolean
|
|
50
|
+
projectId?: string
|
|
51
|
+
dailyLimit?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Todoist plugin configuration
|
|
56
|
+
*/
|
|
57
|
+
export interface TodoistConfig {
|
|
58
|
+
apiToken?: string
|
|
59
|
+
projectId?: string
|
|
60
|
+
syncStatus?: boolean
|
|
61
|
+
addComments?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Cost tracking configuration
|
|
66
|
+
*/
|
|
67
|
+
export interface CostTrackingConfig {
|
|
68
|
+
enabled?: boolean
|
|
69
|
+
defaultModel?: string
|
|
70
|
+
dailyBudget?: number
|
|
71
|
+
alertThreshold?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Main Loopwork configuration
|
|
76
|
+
*/
|
|
77
|
+
export interface LoopworkConfig {
|
|
78
|
+
// Backend
|
|
79
|
+
backend: BackendConfig
|
|
80
|
+
|
|
81
|
+
// CLI settings
|
|
82
|
+
cli?: 'claude' | 'opencode' | 'gemini'
|
|
83
|
+
model?: string
|
|
84
|
+
|
|
85
|
+
// Execution settings
|
|
86
|
+
maxIterations?: number
|
|
87
|
+
timeout?: number
|
|
88
|
+
namespace?: string
|
|
89
|
+
autoConfirm?: boolean
|
|
90
|
+
dryRun?: boolean
|
|
91
|
+
debug?: boolean
|
|
92
|
+
|
|
93
|
+
// Task filtering
|
|
94
|
+
feature?: string
|
|
95
|
+
|
|
96
|
+
// Retry/resilience
|
|
97
|
+
maxRetries?: number
|
|
98
|
+
circuitBreakerThreshold?: number
|
|
99
|
+
taskDelay?: number
|
|
100
|
+
retryDelay?: number
|
|
101
|
+
|
|
102
|
+
// Plugin configs
|
|
103
|
+
telegram?: TelegramConfig
|
|
104
|
+
discord?: DiscordConfig
|
|
105
|
+
asana?: AsanaConfig
|
|
106
|
+
everhour?: EverhourConfig
|
|
107
|
+
todoist?: TodoistConfig
|
|
108
|
+
costTracking?: CostTrackingConfig
|
|
109
|
+
|
|
110
|
+
// Registered plugins
|
|
111
|
+
plugins?: LoopworkPlugin[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Default configuration values
|
|
116
|
+
*/
|
|
117
|
+
export const DEFAULT_CONFIG: Partial<LoopworkConfig> = {
|
|
118
|
+
cli: 'opencode',
|
|
119
|
+
maxIterations: 50,
|
|
120
|
+
timeout: 600,
|
|
121
|
+
namespace: 'default',
|
|
122
|
+
autoConfirm: false,
|
|
123
|
+
dryRun: false,
|
|
124
|
+
debug: false,
|
|
125
|
+
maxRetries: 3,
|
|
126
|
+
circuitBreakerThreshold: 5,
|
|
127
|
+
taskDelay: 2000,
|
|
128
|
+
retryDelay: 3000,
|
|
129
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopwork Contracts
|
|
3
|
+
*
|
|
4
|
+
* Pure types and interfaces - no implementations
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Task types
|
|
8
|
+
export type { Task, TaskStatus, Priority, TaskResult, GitHubLabel, GitHubIssue } from './task'
|
|
9
|
+
export { LABELS, STATUS_LABELS, PRIORITY_LABELS } from './task'
|
|
10
|
+
|
|
11
|
+
// Plugin types
|
|
12
|
+
export type {
|
|
13
|
+
LoopworkPlugin,
|
|
14
|
+
PluginTask,
|
|
15
|
+
TaskMetadata,
|
|
16
|
+
PluginContext,
|
|
17
|
+
PluginTaskResult,
|
|
18
|
+
LoopStats,
|
|
19
|
+
ConfigWrapper,
|
|
20
|
+
} from './plugin'
|
|
21
|
+
|
|
22
|
+
// Backend types
|
|
23
|
+
export type {
|
|
24
|
+
TaskBackend,
|
|
25
|
+
BackendPlugin,
|
|
26
|
+
BackendConfig,
|
|
27
|
+
BackendFactory,
|
|
28
|
+
FindTaskOptions,
|
|
29
|
+
UpdateResult,
|
|
30
|
+
PingResult,
|
|
31
|
+
} from './backend'
|
|
32
|
+
|
|
33
|
+
// Config types
|
|
34
|
+
export type {
|
|
35
|
+
LoopworkConfig,
|
|
36
|
+
TelegramConfig,
|
|
37
|
+
DiscordConfig,
|
|
38
|
+
AsanaConfig,
|
|
39
|
+
EverhourConfig,
|
|
40
|
+
TodoistConfig,
|
|
41
|
+
CostTrackingConfig,
|
|
42
|
+
} from './config'
|
|
43
|
+
export { DEFAULT_CONFIG } from './config'
|