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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Interface Contract
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { LoopworkConfig } from './config'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Task metadata for external integrations
|
|
9
|
+
*/
|
|
10
|
+
export interface TaskMetadata {
|
|
11
|
+
asanaGid?: string
|
|
12
|
+
everhourId?: string
|
|
13
|
+
todoistId?: string
|
|
14
|
+
[key: string]: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Task object passed to plugin hooks
|
|
19
|
+
*/
|
|
20
|
+
export interface PluginTask {
|
|
21
|
+
id: string
|
|
22
|
+
title: string
|
|
23
|
+
metadata?: TaskMetadata
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Context passed to plugin hooks
|
|
28
|
+
*/
|
|
29
|
+
export interface PluginContext {
|
|
30
|
+
task: PluginTask
|
|
31
|
+
config: LoopworkConfig
|
|
32
|
+
namespace: string
|
|
33
|
+
iteration: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Result passed to onTaskComplete
|
|
38
|
+
*/
|
|
39
|
+
export interface PluginTaskResult {
|
|
40
|
+
duration: number
|
|
41
|
+
success: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Loop statistics passed to onLoopEnd
|
|
46
|
+
*/
|
|
47
|
+
export interface LoopStats {
|
|
48
|
+
completed: number
|
|
49
|
+
failed: number
|
|
50
|
+
duration: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Plugin interface - implement to extend Loopwork
|
|
55
|
+
*/
|
|
56
|
+
export interface LoopworkPlugin {
|
|
57
|
+
/** Unique plugin name */
|
|
58
|
+
name: string
|
|
59
|
+
|
|
60
|
+
/** Called when config is loaded */
|
|
61
|
+
onConfigLoad?: (config: LoopworkConfig) => LoopworkConfig | Promise<LoopworkConfig>
|
|
62
|
+
|
|
63
|
+
/** Called when loop starts */
|
|
64
|
+
onLoopStart?: (namespace: string) => void | Promise<void>
|
|
65
|
+
|
|
66
|
+
/** Called when loop ends */
|
|
67
|
+
onLoopEnd?: (stats: LoopStats) => void | Promise<void>
|
|
68
|
+
|
|
69
|
+
/** Called when task starts */
|
|
70
|
+
onTaskStart?: (task: PluginTask) => void | Promise<void>
|
|
71
|
+
|
|
72
|
+
/** Called when task completes */
|
|
73
|
+
onTaskComplete?: (task: PluginTask, result: PluginTaskResult) => void | Promise<void>
|
|
74
|
+
|
|
75
|
+
/** Called when task fails */
|
|
76
|
+
onTaskFailed?: (task: PluginTask, error: string) => void | Promise<void>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Config wrapper function type
|
|
81
|
+
*/
|
|
82
|
+
export type ConfigWrapper = (config: LoopworkConfig) => LoopworkConfig
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Types and Constants
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type TaskStatus = 'pending' | 'in-progress' | 'completed' | 'failed'
|
|
6
|
+
export type Priority = 'high' | 'medium' | 'low'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Unified task representation across all backends
|
|
10
|
+
*/
|
|
11
|
+
export interface Task {
|
|
12
|
+
id: string
|
|
13
|
+
title: string
|
|
14
|
+
description: string
|
|
15
|
+
status: TaskStatus
|
|
16
|
+
priority: Priority
|
|
17
|
+
feature?: string
|
|
18
|
+
parentId?: string
|
|
19
|
+
dependsOn?: string[]
|
|
20
|
+
metadata?: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Task result after execution
|
|
25
|
+
*/
|
|
26
|
+
export interface TaskResult {
|
|
27
|
+
success: boolean
|
|
28
|
+
output: string
|
|
29
|
+
duration: number
|
|
30
|
+
error?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* GitHub-specific types (for GitHub backend)
|
|
35
|
+
*/
|
|
36
|
+
export interface GitHubLabel {
|
|
37
|
+
name: string
|
|
38
|
+
color?: string
|
|
39
|
+
description?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface GitHubIssue {
|
|
43
|
+
number: number
|
|
44
|
+
title: string
|
|
45
|
+
body: string
|
|
46
|
+
state: 'open' | 'closed'
|
|
47
|
+
labels: GitHubLabel[]
|
|
48
|
+
url: string
|
|
49
|
+
createdAt: string
|
|
50
|
+
updatedAt: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Label constants for GitHub backend
|
|
55
|
+
*/
|
|
56
|
+
export const LABELS = {
|
|
57
|
+
LOOPWORK_TASK: 'loopwork-task',
|
|
58
|
+
STATUS_PENDING: 'loopwork:pending',
|
|
59
|
+
STATUS_IN_PROGRESS: 'loopwork:in-progress',
|
|
60
|
+
STATUS_FAILED: 'loopwork:failed',
|
|
61
|
+
PRIORITY_HIGH: 'priority:high',
|
|
62
|
+
PRIORITY_MEDIUM: 'priority:medium',
|
|
63
|
+
PRIORITY_LOW: 'priority:low',
|
|
64
|
+
SUB_TASK: 'loopwork:sub-task',
|
|
65
|
+
BLOCKED: 'loopwork:blocked',
|
|
66
|
+
} as const
|
|
67
|
+
|
|
68
|
+
export const STATUS_LABELS = [
|
|
69
|
+
LABELS.STATUS_PENDING,
|
|
70
|
+
LABELS.STATUS_IN_PROGRESS,
|
|
71
|
+
LABELS.STATUS_FAILED,
|
|
72
|
+
] as const
|
|
73
|
+
|
|
74
|
+
export const PRIORITY_LABELS = [
|
|
75
|
+
LABELS.PRIORITY_HIGH,
|
|
76
|
+
LABELS.PRIORITY_MEDIUM,
|
|
77
|
+
LABELS.PRIORITY_LOW,
|
|
78
|
+
] as const
|
package/src/core/cli.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { spawn, spawnSync, ChildProcess } from 'child_process'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { logger } from './utils'
|
|
5
|
+
import type { Config } from './config'
|
|
6
|
+
|
|
7
|
+
export interface CliConfig {
|
|
8
|
+
name: string
|
|
9
|
+
cli: 'opencode' | 'claude'
|
|
10
|
+
model: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Default model pools
|
|
14
|
+
export const EXEC_MODELS: CliConfig[] = [
|
|
15
|
+
{ name: 'sonnet-claude', cli: 'claude', model: 'sonnet' },
|
|
16
|
+
{ name: 'sonnet-opencode', cli: 'opencode', model: 'google/antigravity-claude-sonnet-4-5' },
|
|
17
|
+
{ name: 'gemini-3-flash', cli: 'opencode', model: 'google/antigravity-gemini-3-flash' },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export const FALLBACK_MODELS: CliConfig[] = [
|
|
21
|
+
{ name: 'opus-claude', cli: 'claude', model: 'opus' },
|
|
22
|
+
{ name: 'gemini-3-pro', cli: 'opencode', model: 'google/antigravity-gemini-3-pro' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export class CliExecutor {
|
|
26
|
+
private cliPaths: Map<string, string> = new Map()
|
|
27
|
+
private currentSubprocess: ChildProcess | null = null
|
|
28
|
+
private execIndex = 0
|
|
29
|
+
private fallbackIndex = 0
|
|
30
|
+
private useFallback = false
|
|
31
|
+
|
|
32
|
+
constructor(private config: Config) {
|
|
33
|
+
this.detectClis()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private detectClis(): void {
|
|
37
|
+
const home = process.env.HOME || ''
|
|
38
|
+
const candidates: Record<string, string[]> = {
|
|
39
|
+
opencode: [`${home}/.opencode/bin/opencode`, '/usr/local/bin/opencode'],
|
|
40
|
+
claude: [
|
|
41
|
+
`${home}/.nvm/versions/node/v20.18.3/bin/claude`,
|
|
42
|
+
`${home}/.nvm/versions/node/v22.13.0/bin/claude`,
|
|
43
|
+
'/usr/local/bin/claude',
|
|
44
|
+
`${home}/.npm/bin/claude`,
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const [cli, paths] of Object.entries(candidates)) {
|
|
49
|
+
// Try PATH first
|
|
50
|
+
const whichResult = spawnSync('which', [cli], { encoding: 'utf-8' })
|
|
51
|
+
if (whichResult.status === 0 && whichResult.stdout?.trim()) {
|
|
52
|
+
this.cliPaths.set(cli, whichResult.stdout.trim())
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Try known paths
|
|
57
|
+
for (const p of paths) {
|
|
58
|
+
if (fs.existsSync(p)) {
|
|
59
|
+
this.cliPaths.set(cli, p)
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (this.cliPaths.size === 0) {
|
|
66
|
+
throw new Error("No AI CLI found. Install 'opencode' or 'claude'.")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
logger.info(`Available CLIs: ${Array.from(this.cliPaths.keys()).join(', ')}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getNextCliConfig(): CliConfig {
|
|
73
|
+
if (this.useFallback) {
|
|
74
|
+
const config = FALLBACK_MODELS[this.fallbackIndex % FALLBACK_MODELS.length]
|
|
75
|
+
this.fallbackIndex++
|
|
76
|
+
return config
|
|
77
|
+
}
|
|
78
|
+
const config = EXEC_MODELS[this.execIndex % EXEC_MODELS.length]
|
|
79
|
+
this.execIndex++
|
|
80
|
+
return config
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
switchToFallback(): void {
|
|
84
|
+
if (!this.useFallback) {
|
|
85
|
+
this.useFallback = true
|
|
86
|
+
logger.warn('Switching to fallback models')
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
resetFallback(): void {
|
|
91
|
+
this.useFallback = false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
killCurrent(): void {
|
|
95
|
+
if (this.currentSubprocess) {
|
|
96
|
+
this.currentSubprocess.kill('SIGTERM')
|
|
97
|
+
this.currentSubprocess = null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async execute(
|
|
102
|
+
prompt: string,
|
|
103
|
+
outputFile: string,
|
|
104
|
+
timeoutSecs: number
|
|
105
|
+
): Promise<number> {
|
|
106
|
+
const promptFile = path.join(path.dirname(outputFile), 'current-prompt.md')
|
|
107
|
+
fs.writeFileSync(promptFile, prompt)
|
|
108
|
+
|
|
109
|
+
const maxAttempts = EXEC_MODELS.length + FALLBACK_MODELS.length
|
|
110
|
+
|
|
111
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
112
|
+
const cliConfig = this.getNextCliConfig()
|
|
113
|
+
const cliPath = this.cliPaths.get(cliConfig.cli)
|
|
114
|
+
|
|
115
|
+
if (!cliPath) {
|
|
116
|
+
logger.debug(`CLI ${cliConfig.cli} not available, skipping`)
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const env = { ...process.env }
|
|
121
|
+
let args: string[]
|
|
122
|
+
|
|
123
|
+
if (cliConfig.cli === 'opencode') {
|
|
124
|
+
env['OPENCODE_PERMISSION'] = '{"*":"allow"}'
|
|
125
|
+
args = ['run', '--model', cliConfig.model, prompt]
|
|
126
|
+
} else {
|
|
127
|
+
args = ['-p', '--dangerously-skip-permissions', '--model', cliConfig.model]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Show command being executed
|
|
131
|
+
const cmdDisplay = cliConfig.cli === 'opencode'
|
|
132
|
+
? `opencode run --model ${cliConfig.model} "<prompt>"`
|
|
133
|
+
: `claude -p --dangerously-skip-permissions --model ${cliConfig.model}`
|
|
134
|
+
|
|
135
|
+
logger.info(`[${cliConfig.name}] Executing: ${cmdDisplay}`)
|
|
136
|
+
logger.info(`[${cliConfig.name}] Timeout: ${timeoutSecs}s`)
|
|
137
|
+
logger.info(`[${cliConfig.name}] Log file: ${outputFile}`)
|
|
138
|
+
logger.info('─────────────────────────────────────')
|
|
139
|
+
logger.info('📝 Streaming CLI output below...')
|
|
140
|
+
logger.info('─────────────────────────────────────')
|
|
141
|
+
|
|
142
|
+
const result = await this.spawnWithTimeout(
|
|
143
|
+
cliPath,
|
|
144
|
+
args,
|
|
145
|
+
{ env, input: cliConfig.cli === 'claude' ? prompt : undefined },
|
|
146
|
+
outputFile,
|
|
147
|
+
timeoutSecs
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if (result.timedOut) {
|
|
151
|
+
logger.error(`Timed out with ${cliConfig.name}`)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check for rate limits
|
|
156
|
+
const output = fs.existsSync(outputFile)
|
|
157
|
+
? fs.readFileSync(outputFile, 'utf-8').slice(-2000)
|
|
158
|
+
: ''
|
|
159
|
+
|
|
160
|
+
if (/rate.*limit|too.*many.*request|429|RESOURCE_EXHAUSTED/i.test(output)) {
|
|
161
|
+
logger.warn(`Rate limited on ${cliConfig.name}, waiting 30s...`)
|
|
162
|
+
await new Promise(r => setTimeout(r, 30000))
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (/quota.*exceed|billing.*limit/i.test(output)) {
|
|
167
|
+
logger.warn(`Quota exhausted for ${cliConfig.name}`)
|
|
168
|
+
this.switchToFallback()
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (result.exitCode === 0) {
|
|
173
|
+
return 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Non-zero exit, try next
|
|
177
|
+
if (attempt >= EXEC_MODELS.length - 1 && !this.useFallback) {
|
|
178
|
+
this.switchToFallback()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
logger.error('All CLI configurations failed')
|
|
183
|
+
return 1
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private spawnWithTimeout(
|
|
187
|
+
command: string,
|
|
188
|
+
args: string[],
|
|
189
|
+
options: { env?: NodeJS.ProcessEnv; input?: string },
|
|
190
|
+
outputFile: string,
|
|
191
|
+
timeoutSecs: number
|
|
192
|
+
): Promise<{ exitCode: number; timedOut: boolean }> {
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const writeStream = fs.createWriteStream(outputFile)
|
|
195
|
+
let timedOut = false
|
|
196
|
+
const startTime = Date.now()
|
|
197
|
+
|
|
198
|
+
const child = spawn(command, args, {
|
|
199
|
+
env: options.env,
|
|
200
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
this.currentSubprocess = child
|
|
204
|
+
|
|
205
|
+
// Progress logging every second
|
|
206
|
+
const progressInterval = setInterval(() => {
|
|
207
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000)
|
|
208
|
+
const remaining = Math.max(0, timeoutSecs - elapsed)
|
|
209
|
+
|
|
210
|
+
logger.update(`⏱️ Running for ${elapsed}s (timeout in ${remaining}s)`)
|
|
211
|
+
}, 1000)
|
|
212
|
+
|
|
213
|
+
child.stdout?.on('data', (data) => {
|
|
214
|
+
writeStream.write(data)
|
|
215
|
+
process.stdout.write(data)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
child.stderr?.on('data', (data) => {
|
|
219
|
+
writeStream.write(data)
|
|
220
|
+
process.stderr.write(data)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
if (child.stdin) {
|
|
224
|
+
if (options.input) {
|
|
225
|
+
child.stdin.write(options.input)
|
|
226
|
+
}
|
|
227
|
+
child.stdin.end()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const timer = setTimeout(() => {
|
|
231
|
+
timedOut = true
|
|
232
|
+
child.kill('SIGTERM')
|
|
233
|
+
setTimeout(() => child.kill('SIGKILL'), 5000)
|
|
234
|
+
}, timeoutSecs * 1000)
|
|
235
|
+
|
|
236
|
+
child.on('close', (code) => {
|
|
237
|
+
clearInterval(progressInterval)
|
|
238
|
+
clearTimeout(timer)
|
|
239
|
+
writeStream.end()
|
|
240
|
+
this.currentSubprocess = null
|
|
241
|
+
const totalTime = Math.floor((Date.now() - startTime) / 1000)
|
|
242
|
+
const minutes = Math.floor(totalTime / 60)
|
|
243
|
+
const seconds = totalTime % 60
|
|
244
|
+
const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`
|
|
245
|
+
|
|
246
|
+
// Get final file size
|
|
247
|
+
let finalSize = 'N/A'
|
|
248
|
+
try {
|
|
249
|
+
if (fs.existsSync(outputFile)) {
|
|
250
|
+
const stats = fs.statSync(outputFile)
|
|
251
|
+
const sizeKB = (stats.size / 1024).toFixed(1)
|
|
252
|
+
finalSize = `${sizeKB} KB`
|
|
253
|
+
}
|
|
254
|
+
} catch {}
|
|
255
|
+
|
|
256
|
+
logger.info('─────────────────────────────────────')
|
|
257
|
+
logger.info(`✓ CLI execution completed in ${timeStr}`)
|
|
258
|
+
logger.info(`Exit code: ${code ?? 1}`)
|
|
259
|
+
logger.info(`Output size: ${finalSize}`)
|
|
260
|
+
logger.info(`Log file: ${outputFile}`)
|
|
261
|
+
logger.info('─────────────────────────────────────')
|
|
262
|
+
resolve({ exitCode: code ?? 1, timedOut })
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
child.on('error', (err) => {
|
|
266
|
+
clearInterval(progressInterval)
|
|
267
|
+
clearTimeout(timer)
|
|
268
|
+
writeStream.end()
|
|
269
|
+
this.currentSubprocess = null
|
|
270
|
+
logger.error(`Spawn error: ${err.message}`)
|
|
271
|
+
resolve({ exitCode: 1, timedOut: false })
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
import type { LoopworkConfig } from '../contracts'
|
|
5
|
+
import { DEFAULT_CONFIG } from '../contracts'
|
|
6
|
+
import type { BackendConfig } from './backends/types'
|
|
7
|
+
import type { LoopworkConfig as LoopworkFileConfig } from '../contracts'
|
|
8
|
+
|
|
9
|
+
export interface Config extends LoopworkConfig {
|
|
10
|
+
projectRoot: string
|
|
11
|
+
outputDir: string
|
|
12
|
+
sessionId: string
|
|
13
|
+
debug: boolean
|
|
14
|
+
resume: boolean
|
|
15
|
+
startTask?: string
|
|
16
|
+
backend: BackendConfig
|
|
17
|
+
namespace: string // For running multiple loops concurrently
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load configuration from loopwork.config.ts or loopwork.config.js
|
|
22
|
+
*/
|
|
23
|
+
async function loadConfigFile(projectRoot: string): Promise<Partial<LoopworkFileConfig> | null> {
|
|
24
|
+
const configPaths = [
|
|
25
|
+
path.join(projectRoot, 'loopwork.config.ts'),
|
|
26
|
+
path.join(projectRoot, 'loopwork.config.js'),
|
|
27
|
+
path.join(projectRoot, 'loopwork.config.mjs'),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
for (const configPath of configPaths) {
|
|
31
|
+
if (fs.existsSync(configPath)) {
|
|
32
|
+
try {
|
|
33
|
+
const module = await import(configPath)
|
|
34
|
+
return module.default || module
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.warn(`Warning: Failed to load config from ${configPath}`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function getConfig(cliOptions?: Partial<Config> & { config?: string, yes?: boolean, task?: string }): Promise<Config> {
|
|
45
|
+
// If cliOptions is an empty object, treat it as undefined so we parse CLI args
|
|
46
|
+
const hasCliOptions = cliOptions && Object.keys(cliOptions).length > 0
|
|
47
|
+
|
|
48
|
+
const rawOptions = (hasCliOptions ? cliOptions : null) || (() => {
|
|
49
|
+
const program = new Command()
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.option('--backend <type>', 'Task backend: github or json (auto-detects if not specified)')
|
|
53
|
+
.option('--repo <owner/repo>', 'GitHub repository (defaults to current repo)')
|
|
54
|
+
.option('--tasks-file <path>', 'Path to tasks.json file (for json backend)')
|
|
55
|
+
.option('--feature <name>', 'Filter by feature label (feat:<name>)')
|
|
56
|
+
.option('--task <id>', 'Start from specific task ID')
|
|
57
|
+
.option('--max-iterations <number>', 'Maximum iterations before stopping')
|
|
58
|
+
.option('--timeout <seconds>', 'Timeout per task in seconds')
|
|
59
|
+
.option('--cli <name>', 'CLI to use (opencode, claude, gemini)')
|
|
60
|
+
.option('--model <id>', 'Specific model ID')
|
|
61
|
+
.option('--resume', 'Resume from last saved state')
|
|
62
|
+
.option('--dry-run', 'Show what would be done without executing')
|
|
63
|
+
.option('-y, --yes', 'Non-interactive mode, auto-continue on errors')
|
|
64
|
+
.option('--debug', 'Enable debug logging')
|
|
65
|
+
.option('--namespace <name>', 'Namespace for running multiple loops')
|
|
66
|
+
.option('--config <path>', 'Path to config file (loopwork.config.ts)')
|
|
67
|
+
.parse(process.argv)
|
|
68
|
+
|
|
69
|
+
return program.opts()
|
|
70
|
+
})()
|
|
71
|
+
|
|
72
|
+
const options = {
|
|
73
|
+
...rawOptions,
|
|
74
|
+
yes: rawOptions.yes ?? rawOptions.autoConfirm,
|
|
75
|
+
task: rawOptions.task ?? rawOptions.startTask,
|
|
76
|
+
backend: typeof rawOptions.backend === 'string' ? rawOptions.backend : rawOptions.backend?.type,
|
|
77
|
+
tasksFile: rawOptions.tasksFile || (rawOptions.backend as any)?.tasksFile,
|
|
78
|
+
repo: rawOptions.repo || (rawOptions.backend as any)?.repo,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Find project root
|
|
82
|
+
let currentDir = process.cwd()
|
|
83
|
+
let projectRoot = currentDir
|
|
84
|
+
while (currentDir !== '/' && currentDir.length > 1) {
|
|
85
|
+
if (
|
|
86
|
+
fs.existsSync(path.join(currentDir, '.git')) ||
|
|
87
|
+
fs.existsSync(path.join(currentDir, 'package.json'))
|
|
88
|
+
) {
|
|
89
|
+
projectRoot = currentDir
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
currentDir = path.dirname(currentDir)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Load config file (lowest priority)
|
|
96
|
+
const fileConfig = await loadConfigFile(options.config ? path.resolve(options.config) : projectRoot)
|
|
97
|
+
|
|
98
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
99
|
+
|
|
100
|
+
// Priority: CLI args > env vars > config file > defaults
|
|
101
|
+
const namespace = options.namespace ||
|
|
102
|
+
process.env.LOOPWORK_NAMESPACE ||
|
|
103
|
+
fileConfig?.namespace ||
|
|
104
|
+
'default'
|
|
105
|
+
|
|
106
|
+
// Determine backend configuration
|
|
107
|
+
const backendType = options.backend ||
|
|
108
|
+
process.env.LOOPWORK_BACKEND ||
|
|
109
|
+
fileConfig?.backend?.type ||
|
|
110
|
+
detectBackendType(projectRoot, options.tasksFile || fileConfig?.backend?.tasksFile)
|
|
111
|
+
|
|
112
|
+
const backend: BackendConfig = backendType === 'json'
|
|
113
|
+
? {
|
|
114
|
+
type: 'json',
|
|
115
|
+
tasksFile: options.tasksFile ||
|
|
116
|
+
fileConfig?.backend?.tasksFile ||
|
|
117
|
+
path.join(projectRoot, '.specs/tasks/tasks.json'),
|
|
118
|
+
tasksDir: path.dirname(
|
|
119
|
+
options.tasksFile ||
|
|
120
|
+
fileConfig?.backend?.tasksFile ||
|
|
121
|
+
path.join(projectRoot, '.specs/tasks/tasks.json')
|
|
122
|
+
),
|
|
123
|
+
}
|
|
124
|
+
: {
|
|
125
|
+
type: 'github',
|
|
126
|
+
repo: options.repo || fileConfig?.backend?.repo,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
...DEFAULT_CONFIG,
|
|
131
|
+
repo: options.repo || fileConfig?.backend?.repo,
|
|
132
|
+
feature: options.feature || fileConfig?.feature,
|
|
133
|
+
startTask: options.task,
|
|
134
|
+
maxIterations: parseInt(options.maxIterations, 10) || fileConfig?.maxIterations || 50,
|
|
135
|
+
timeout: parseInt(options.timeout, 10) || fileConfig?.timeout || 600,
|
|
136
|
+
cli: options.cli || fileConfig?.cli || 'opencode',
|
|
137
|
+
model: options.model || fileConfig?.model,
|
|
138
|
+
autoConfirm: options.yes ||
|
|
139
|
+
process.env.LOOPWORK_NON_INTERACTIVE === 'true' ||
|
|
140
|
+
fileConfig?.autoConfirm ||
|
|
141
|
+
false,
|
|
142
|
+
dryRun: options.dryRun || fileConfig?.dryRun || false,
|
|
143
|
+
resume: options.resume || false,
|
|
144
|
+
debug: options.debug ||
|
|
145
|
+
process.env.LOOPWORK_DEBUG === 'true' ||
|
|
146
|
+
fileConfig?.debug ||
|
|
147
|
+
false,
|
|
148
|
+
projectRoot,
|
|
149
|
+
outputDir: path.join(projectRoot, 'loopwork-runs', namespace, timestamp),
|
|
150
|
+
sessionId: `loopwork-${namespace}-${timestamp}-${process.pid}`,
|
|
151
|
+
backend,
|
|
152
|
+
namespace,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Auto-detect backend type based on files present
|
|
158
|
+
*/
|
|
159
|
+
function detectBackendType(projectRoot: string, tasksFile?: string): 'github' | 'json' {
|
|
160
|
+
const jsonPath = tasksFile || path.join(projectRoot, '.specs/tasks/tasks.json')
|
|
161
|
+
if (fs.existsSync(jsonPath)) {
|
|
162
|
+
return 'json'
|
|
163
|
+
}
|
|
164
|
+
return 'github'
|
|
165
|
+
}
|