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,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
@@ -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
+ }