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,449 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import chalk from 'chalk'
4
+ import { LoopworkMonitor } from '../monitor'
5
+
6
+ /**
7
+ * Loopwork Dashboard
8
+ *
9
+ * Displays comprehensive status, logs, and metrics for all running loops.
10
+ */
11
+
12
+ interface TaskStats {
13
+ completed: number
14
+ failed: number
15
+ pending: number
16
+ }
17
+
18
+ interface NamespaceStats {
19
+ namespace: string
20
+ status: 'running' | 'stopped'
21
+ pid?: number
22
+ uptime?: string
23
+ lastRun?: string
24
+ currentTask?: string
25
+ tasks: TaskStats
26
+ iterations: number
27
+ }
28
+
29
+ class Dashboard {
30
+ private monitor: LoopworkMonitor
31
+ private projectRoot: string
32
+
33
+ constructor(projectRoot?: string) {
34
+ this.projectRoot = projectRoot || process.cwd()
35
+ this.monitor = new LoopworkMonitor(this.projectRoot)
36
+ }
37
+
38
+ /**
39
+ * Display full dashboard
40
+ */
41
+ display(): void {
42
+ console.clear()
43
+ this.printHeader()
44
+ this.printRunningLoops()
45
+ this.printRecentActivity()
46
+ this.printHelp()
47
+ }
48
+
49
+ private printHeader(): void {
50
+ console.log(chalk.bold.cyan('\n╔══════════════════════════════════════════════════════════════╗'))
51
+ console.log(chalk.bold.cyan('║ RALPH LOOP DASHBOARD ║'))
52
+ console.log(chalk.bold.cyan('╚══════════════════════════════════════════════════════════════╝'))
53
+ console.log(chalk.gray(` ${new Date().toLocaleString()}`))
54
+ console.log()
55
+ }
56
+
57
+ private printRunningLoops(): void {
58
+ const { running, namespaces } = this.monitor.getStatus()
59
+
60
+ console.log(chalk.bold.white('┌─ Running Loops ─────────────────────────────────────────────┐'))
61
+
62
+ if (running.length === 0) {
63
+ console.log(chalk.gray('│ No loops currently running │'))
64
+ } else {
65
+ for (const proc of running) {
66
+ const stats = this.getNamespaceStats(proc.namespace)
67
+ const uptime = this.getUptime(proc.startedAt)
68
+
69
+ console.log(chalk.green(`│ ● ${chalk.bold(proc.namespace.padEnd(15))} PID: ${String(proc.pid).padEnd(8)} Uptime: ${uptime.padEnd(10)} │`))
70
+
71
+ if (stats.currentTask) {
72
+ console.log(chalk.gray(`│ └─ Current: ${stats.currentTask.padEnd(43)} │`))
73
+ }
74
+
75
+ const taskLine = `Completed: ${chalk.green(stats.tasks.completed)} | Failed: ${chalk.red(stats.tasks.failed)} | Pending: ${chalk.yellow(stats.tasks.pending)}`
76
+ console.log(chalk.gray(`│ └─ ${taskLine.padEnd(51)} │`))
77
+ }
78
+ }
79
+
80
+ console.log(chalk.white('└─────────────────────────────────────────────────────────────┘'))
81
+ console.log()
82
+
83
+ // Show stopped namespaces
84
+ const stopped = namespaces.filter(n => n.status === 'stopped')
85
+ if (stopped.length > 0) {
86
+ console.log(chalk.bold.white('┌─ Available Namespaces ───────────────────────────────────────┐'))
87
+ for (const ns of stopped) {
88
+ const lastRunStr = ns.lastRun || 'never'
89
+ console.log(chalk.gray(`│ ○ ${ns.name.padEnd(20)} Last run: ${lastRunStr.padEnd(25)} │`))
90
+ }
91
+ console.log(chalk.white('└─────────────────────────────────────────────────────────────┘'))
92
+ console.log()
93
+ }
94
+ }
95
+
96
+ private printRecentActivity(): void {
97
+ console.log(chalk.bold.white('┌─ Recent Activity ───────────────────────────────────────────┐'))
98
+
99
+ const activity = this.getRecentActivity()
100
+
101
+ if (activity.length === 0) {
102
+ console.log(chalk.gray('│ No recent activity │'))
103
+ } else {
104
+ for (const item of activity.slice(0, 10)) {
105
+ const icon = item.type === 'completed' ? chalk.green('✓')
106
+ : item.type === 'failed' ? chalk.red('✗')
107
+ : chalk.blue('→')
108
+
109
+ const line = `${icon} ${chalk.gray(item.time)} ${item.namespace}: ${item.message}`
110
+ console.log(`│ ${line.padEnd(65)}│`)
111
+ }
112
+ }
113
+
114
+ console.log(chalk.white('└─────────────────────────────────────────────────────────────┘'))
115
+ console.log()
116
+ }
117
+
118
+ private printHelp(): void {
119
+ console.log(chalk.gray('Commands:'))
120
+ console.log(chalk.gray(' q - Quit | r - Refresh | s - Start loop | k - Kill loop | l - View logs'))
121
+ console.log()
122
+ }
123
+
124
+ /**
125
+ * Get stats for a specific namespace
126
+ */
127
+ private getNamespaceStats(namespace: string): NamespaceStats {
128
+ const stats: NamespaceStats = {
129
+ namespace,
130
+ status: 'stopped',
131
+ tasks: { completed: 0, failed: 0, pending: 0 },
132
+ iterations: 0,
133
+ }
134
+
135
+ // Check if running
136
+ const running = this.monitor.getRunningProcesses()
137
+ const proc = running.find(p => p.namespace === namespace)
138
+
139
+ if (proc) {
140
+ stats.status = 'running'
141
+ stats.pid = proc.pid
142
+ stats.uptime = this.getUptime(proc.startedAt)
143
+ }
144
+
145
+ // Parse state file for current task
146
+ const stateFile = path.join(
147
+ this.projectRoot,
148
+ namespace === 'default' ? '.loopwork-state' : `.loopwork-state-${namespace}`
149
+ )
150
+
151
+ if (fs.existsSync(stateFile)) {
152
+ try {
153
+ const content = fs.readFileSync(stateFile, 'utf-8')
154
+ const lines = content.split('\n')
155
+ for (const line of lines) {
156
+ const [key, value] = line.split('=')
157
+ if (key === 'LAST_ISSUE' && value) {
158
+ stats.currentTask = `Task #${value}`
159
+ }
160
+ if (key === 'LAST_ITERATION' && value) {
161
+ stats.iterations = parseInt(value, 10)
162
+ }
163
+ }
164
+ } catch {}
165
+ }
166
+
167
+ // Count tasks from logs
168
+ const logsDir = path.join(this.projectRoot, 'loopwork-runs', namespace)
169
+ if (fs.existsSync(logsDir)) {
170
+ const runDirs = fs.readdirSync(logsDir, { withFileTypes: true })
171
+ .filter(d => d.isDirectory() && d.name !== 'monitor-logs')
172
+
173
+ for (const dir of runDirs) {
174
+ const logPath = path.join(logsDir, dir.name, 'logs')
175
+ if (fs.existsSync(logPath)) {
176
+ const files = fs.readdirSync(logPath)
177
+ for (const file of files) {
178
+ if (file.includes('completed')) stats.tasks.completed++
179
+ else if (file.includes('failed')) stats.tasks.failed++
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ return stats
186
+ }
187
+
188
+ /**
189
+ * Get recent activity across all namespaces
190
+ */
191
+ private getRecentActivity(): { time: string; namespace: string; type: string; message: string }[] {
192
+ const activity: { time: string; namespace: string; type: string; message: string }[] = []
193
+
194
+ const runsDir = path.join(this.projectRoot, 'loopwork-runs')
195
+ if (!fs.existsSync(runsDir)) return activity
196
+
197
+ const namespaces = fs.readdirSync(runsDir, { withFileTypes: true })
198
+ .filter(d => d.isDirectory())
199
+
200
+ for (const ns of namespaces) {
201
+ const monitorLogsDir = path.join(runsDir, ns.name, 'monitor-logs')
202
+ if (!fs.existsSync(monitorLogsDir)) continue
203
+
204
+ const logFiles = fs.readdirSync(monitorLogsDir)
205
+ .filter(f => f.endsWith('.log'))
206
+ .sort()
207
+ .reverse()
208
+ .slice(0, 1)
209
+
210
+ for (const logFile of logFiles) {
211
+ const content = fs.readFileSync(path.join(monitorLogsDir, logFile), 'utf-8')
212
+ const lines = content.split('\n').slice(-50)
213
+
214
+ for (const line of lines) {
215
+ if (line.includes('[SUCCESS]') && line.includes('completed')) {
216
+ const time = this.extractTime(line)
217
+ const taskMatch = line.match(/Task (\S+)/)
218
+ activity.push({
219
+ time,
220
+ namespace: ns.name,
221
+ type: 'completed',
222
+ message: taskMatch ? `Completed ${taskMatch[1]}` : 'Task completed',
223
+ })
224
+ } else if (line.includes('[ERROR]') && line.includes('failed')) {
225
+ const time = this.extractTime(line)
226
+ const taskMatch = line.match(/Task (\S+)/)
227
+ activity.push({
228
+ time,
229
+ namespace: ns.name,
230
+ type: 'failed',
231
+ message: taskMatch ? `Failed ${taskMatch[1]}` : 'Task failed',
232
+ })
233
+ } else if (line.includes('Iteration')) {
234
+ const time = this.extractTime(line)
235
+ const iterMatch = line.match(/Iteration (\d+)/)
236
+ if (iterMatch) {
237
+ activity.push({
238
+ time,
239
+ namespace: ns.name,
240
+ type: 'progress',
241
+ message: `Started iteration ${iterMatch[1]}`,
242
+ })
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ // Sort by time descending
250
+ activity.sort((a, b) => b.time.localeCompare(a.time))
251
+
252
+ return activity.slice(0, 10)
253
+ }
254
+
255
+ private extractTime(line: string): string {
256
+ const match = line.match(/\d{1,2}:\d{2}:\d{2}\s*[AP]M/i)
257
+ return match ? match[0] : ''
258
+ }
259
+
260
+ private getUptime(startedAt: string): string {
261
+ const start = new Date(startedAt).getTime()
262
+ const now = Date.now()
263
+ const diff = now - start
264
+
265
+ const hours = Math.floor(diff / (1000 * 60 * 60))
266
+ const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
267
+
268
+ if (hours > 0) {
269
+ return `${hours}h ${mins}m`
270
+ }
271
+ return `${mins}m`
272
+ }
273
+
274
+ /**
275
+ * Interactive mode with auto-refresh
276
+ */
277
+ async interactive(): Promise<void> {
278
+ const readline = await import('readline')
279
+
280
+ // Enable raw mode for single keypress
281
+ if (process.stdin.isTTY) {
282
+ process.stdin.setRawMode(true)
283
+ }
284
+ process.stdin.resume()
285
+
286
+ const refresh = () => {
287
+ this.display()
288
+ }
289
+
290
+ refresh()
291
+
292
+ // Auto-refresh every 5 seconds
293
+ const interval = setInterval(refresh, 5000)
294
+
295
+ process.stdin.on('data', async (data) => {
296
+ const key = data.toString()
297
+
298
+ if (key === 'q' || key === '\u0003') {
299
+ // Quit
300
+ clearInterval(interval)
301
+ if (process.stdin.isTTY) {
302
+ process.stdin.setRawMode(false)
303
+ }
304
+ process.stdin.pause()
305
+ console.log(chalk.gray('\nExiting dashboard...'))
306
+ process.exit(0)
307
+ } else if (key === 'r') {
308
+ refresh()
309
+ } else if (key === 's') {
310
+ // Start a new loop
311
+ clearInterval(interval)
312
+ if (process.stdin.isTTY) {
313
+ process.stdin.setRawMode(false)
314
+ }
315
+
316
+ const rl = readline.createInterface({
317
+ input: process.stdin,
318
+ output: process.stdout,
319
+ })
320
+
321
+ rl.question(chalk.cyan('Enter namespace to start: '), async (namespace) => {
322
+ rl.close()
323
+ if (namespace) {
324
+ const result = await this.monitor.start(namespace.trim())
325
+ if (result.success) {
326
+ console.log(chalk.green(`✓ Started ${namespace} (PID: ${result.pid})`))
327
+ } else {
328
+ console.log(chalk.red(`✗ ${result.error}`))
329
+ }
330
+ }
331
+ setTimeout(() => {
332
+ if (process.stdin.isTTY) {
333
+ process.stdin.setRawMode(true)
334
+ }
335
+ refresh()
336
+ }, 1000)
337
+ })
338
+ } else if (key === 'k') {
339
+ // Kill a loop
340
+ clearInterval(interval)
341
+ if (process.stdin.isTTY) {
342
+ process.stdin.setRawMode(false)
343
+ }
344
+
345
+ const rl = readline.createInterface({
346
+ input: process.stdin,
347
+ output: process.stdout,
348
+ })
349
+
350
+ rl.question(chalk.cyan('Enter namespace to stop (or "all"): '), (namespace) => {
351
+ rl.close()
352
+ if (namespace === 'all') {
353
+ const result = this.monitor.stopAll()
354
+ if (result.stopped.length > 0) {
355
+ console.log(chalk.green(`✓ Stopped: ${result.stopped.join(', ')}`))
356
+ }
357
+ } else if (namespace) {
358
+ const result = this.monitor.stop(namespace.trim())
359
+ if (result.success) {
360
+ console.log(chalk.green(`✓ Stopped ${namespace}`))
361
+ } else {
362
+ console.log(chalk.red(`✗ ${result.error}`))
363
+ }
364
+ }
365
+ setTimeout(() => {
366
+ if (process.stdin.isTTY) {
367
+ process.stdin.setRawMode(true)
368
+ }
369
+ refresh()
370
+ }, 1000)
371
+ })
372
+ } else if (key === 'l') {
373
+ // View logs
374
+ clearInterval(interval)
375
+ if (process.stdin.isTTY) {
376
+ process.stdin.setRawMode(false)
377
+ }
378
+
379
+ const rl = readline.createInterface({
380
+ input: process.stdin,
381
+ output: process.stdout,
382
+ })
383
+
384
+ rl.question(chalk.cyan('Enter namespace for logs: '), (namespace) => {
385
+ rl.close()
386
+ if (namespace) {
387
+ const logs = this.monitor.getLogs(namespace.trim(), 30)
388
+ console.log(chalk.gray('\n─'.repeat(60)))
389
+ console.log(logs.join('\n'))
390
+ console.log(chalk.gray('─'.repeat(60)))
391
+ console.log(chalk.gray('Press any key to return...'))
392
+
393
+ process.stdin.once('data', () => {
394
+ if (process.stdin.isTTY) {
395
+ process.stdin.setRawMode(true)
396
+ }
397
+ refresh()
398
+ })
399
+ } else {
400
+ if (process.stdin.isTTY) {
401
+ process.stdin.setRawMode(true)
402
+ }
403
+ refresh()
404
+ }
405
+ })
406
+ }
407
+ })
408
+ }
409
+
410
+ /**
411
+ * One-time status display
412
+ */
413
+ static display(): void {
414
+ const dashboard = new Dashboard()
415
+ dashboard.display()
416
+ }
417
+ }
418
+
419
+ /**
420
+ * CLI entry point
421
+ */
422
+ async function main() {
423
+ const args = process.argv.slice(2)
424
+ const command = args[0]
425
+
426
+ const dashboard = new Dashboard()
427
+
428
+ switch (command) {
429
+ case 'watch':
430
+ case '-w':
431
+ await dashboard.interactive()
432
+ break
433
+
434
+ case 'status':
435
+ default:
436
+ dashboard.display()
437
+ break
438
+ }
439
+ }
440
+
441
+ export { Dashboard }
442
+
443
+ // Only run main when executed directly, not when imported
444
+ if (import.meta.main) {
445
+ main().catch((err) => {
446
+ console.error(chalk.red(`Error: ${err.message}`))
447
+ process.exit(1)
448
+ })
449
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Dashboard Module
3
+ */
4
+
5
+ export { Dashboard } from './cli'
6
+ export { runKanban } from './kanban'
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Kanban Board TUI
3
+ *
4
+ * Simple kanban view of tasks using ink (React for CLI)
5
+ */
6
+
7
+ import React, { useState, useEffect } from 'react'
8
+ import { render, Box, Text, useInput, useApp } from 'ink'
9
+ import type { Task, TaskStatus } from '../contracts'
10
+ import { createBackend, detectBackend } from '../backends'
11
+
12
+ interface Column {
13
+ status: TaskStatus
14
+ title: string
15
+ color: string
16
+ tasks: Task[]
17
+ }
18
+
19
+ interface KanbanProps {
20
+ projectRoot: string
21
+ refreshInterval?: number
22
+ }
23
+
24
+ function TaskCard({ task, selected }: { task: Task; selected: boolean }) {
25
+ const priorityColor = {
26
+ high: 'red',
27
+ medium: 'yellow',
28
+ low: 'gray',
29
+ }[task.priority]
30
+
31
+ return (
32
+ <Box
33
+ flexDirection="column"
34
+ borderStyle={selected ? 'double' : 'single'}
35
+ borderColor={selected ? 'cyan' : 'gray'}
36
+ paddingX={1}
37
+ marginBottom={1}
38
+ >
39
+ <Text bold color={selected ? 'cyan' : 'white'}>
40
+ {task.id}
41
+ </Text>
42
+ <Text wrap="truncate-end">
43
+ {task.title.length > 28 ? task.title.slice(0, 25) + '...' : task.title}
44
+ </Text>
45
+ <Box>
46
+ <Text color={priorityColor}>[{task.priority}]</Text>
47
+ {task.feature && <Text color="blue"> #{task.feature}</Text>}
48
+ </Box>
49
+ </Box>
50
+ )
51
+ }
52
+
53
+ function KanbanColumn({ column, selectedTask }: { column: Column; selectedTask: string | null }) {
54
+ return (
55
+ <Box flexDirection="column" width="33%" paddingX={1}>
56
+ <Box marginBottom={1} justifyContent="center">
57
+ <Text bold color={column.color}>
58
+ {column.title} ({column.tasks.length})
59
+ </Text>
60
+ </Box>
61
+ <Box flexDirection="column" borderStyle="single" borderColor={column.color} minHeight={10}>
62
+ {column.tasks.length === 0 ? (
63
+ <Box padding={1}>
64
+ <Text color="gray" dimColor>
65
+ No tasks
66
+ </Text>
67
+ </Box>
68
+ ) : (
69
+ column.tasks.slice(0, 5).map((task) => (
70
+ <TaskCard key={task.id} task={task} selected={selectedTask === task.id} />
71
+ ))
72
+ )}
73
+ {column.tasks.length > 5 && (
74
+ <Box paddingX={1}>
75
+ <Text color="gray">+{column.tasks.length - 5} more</Text>
76
+ </Box>
77
+ )}
78
+ </Box>
79
+ </Box>
80
+ )
81
+ }
82
+
83
+ function KanbanBoard({ projectRoot, refreshInterval = 5000 }: KanbanProps) {
84
+ const { exit } = useApp()
85
+ const [columns, setColumns] = useState<Column[]>([
86
+ { status: 'pending', title: 'PENDING', color: 'yellow', tasks: [] },
87
+ { status: 'in-progress', title: 'IN PROGRESS', color: 'blue', tasks: [] },
88
+ { status: 'completed', title: 'COMPLETED', color: 'green', tasks: [] },
89
+ ])
90
+ const [selectedColumn, setSelectedColumn] = useState(0)
91
+ const [selectedRow, setSelectedRow] = useState(0)
92
+ const [error, setError] = useState<string | null>(null)
93
+ const [lastRefresh, setLastRefresh] = useState(new Date())
94
+
95
+ const loadTasks = async () => {
96
+ try {
97
+ const backendConfig = detectBackend(projectRoot)
98
+ const backend = createBackend(backendConfig)
99
+
100
+ // Get all tasks
101
+ const pending = await backend.listPendingTasks()
102
+
103
+ // For completed/in-progress, we need to check all tasks
104
+ // This is a simplified approach - in reality you might want a listAllTasks method
105
+ const allTasks = pending // For now, just show pending tasks in the board
106
+
107
+ setColumns([
108
+ {
109
+ status: 'pending',
110
+ title: 'PENDING',
111
+ color: 'yellow',
112
+ tasks: allTasks.filter((t) => t.status === 'pending'),
113
+ },
114
+ {
115
+ status: 'in-progress',
116
+ title: 'IN PROGRESS',
117
+ color: 'blue',
118
+ tasks: allTasks.filter((t) => t.status === 'in-progress'),
119
+ },
120
+ {
121
+ status: 'completed',
122
+ title: 'COMPLETED',
123
+ color: 'green',
124
+ tasks: allTasks.filter((t) => t.status === 'completed'),
125
+ },
126
+ ])
127
+ setLastRefresh(new Date())
128
+ setError(null)
129
+ } catch (e: any) {
130
+ setError(e.message)
131
+ }
132
+ }
133
+
134
+ useEffect(() => {
135
+ loadTasks()
136
+ const interval = setInterval(loadTasks, refreshInterval)
137
+ return () => clearInterval(interval)
138
+ }, [projectRoot, refreshInterval])
139
+
140
+ useInput((input, key) => {
141
+ if (input === 'q' || key.escape) {
142
+ exit()
143
+ return
144
+ }
145
+
146
+ if (input === 'r') {
147
+ loadTasks()
148
+ return
149
+ }
150
+
151
+ if (key.leftArrow) {
152
+ setSelectedColumn((c) => Math.max(0, c - 1))
153
+ setSelectedRow(0)
154
+ }
155
+ if (key.rightArrow) {
156
+ setSelectedColumn((c) => Math.min(columns.length - 1, c + 1))
157
+ setSelectedRow(0)
158
+ }
159
+ if (key.upArrow) {
160
+ setSelectedRow((r) => Math.max(0, r - 1))
161
+ }
162
+ if (key.downArrow) {
163
+ const maxRow = Math.min(4, columns[selectedColumn].tasks.length - 1)
164
+ setSelectedRow((r) => Math.min(maxRow, r + 1))
165
+ }
166
+ })
167
+
168
+ const selectedTask = columns[selectedColumn]?.tasks[selectedRow]?.id || null
169
+ const totalTasks = columns.reduce((sum, c) => sum + c.tasks.length, 0)
170
+
171
+ return (
172
+ <Box flexDirection="column" padding={1}>
173
+ {/* Header */}
174
+ <Box marginBottom={1} justifyContent="space-between">
175
+ <Text bold color="cyan">
176
+ LOOPWORK KANBAN
177
+ </Text>
178
+ <Text color="gray">
179
+ {totalTasks} tasks | Last refresh: {lastRefresh.toLocaleTimeString()}
180
+ </Text>
181
+ </Box>
182
+
183
+ {/* Error */}
184
+ {error && (
185
+ <Box marginBottom={1}>
186
+ <Text color="red">Error: {error}</Text>
187
+ </Box>
188
+ )}
189
+
190
+ {/* Columns */}
191
+ <Box flexDirection="row">
192
+ {columns.map((column, idx) => (
193
+ <KanbanColumn
194
+ key={column.status}
195
+ column={column}
196
+ selectedTask={idx === selectedColumn ? selectedTask : null}
197
+ />
198
+ ))}
199
+ </Box>
200
+
201
+ {/* Footer */}
202
+ <Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
203
+ <Text color="gray">
204
+ [←/→] Switch column | [↑/↓] Select task | [r] Refresh | [q] Quit
205
+ </Text>
206
+ </Box>
207
+
208
+ {/* Selected task details */}
209
+ {selectedTask && (
210
+ <Box marginTop={1} flexDirection="column" borderStyle="single" paddingX={1}>
211
+ <Text bold>Selected: {selectedTask}</Text>
212
+ <Text>{columns[selectedColumn].tasks[selectedRow]?.title}</Text>
213
+ </Box>
214
+ )}
215
+ </Box>
216
+ )
217
+ }
218
+
219
+ export function runKanban(projectRoot: string = process.cwd()) {
220
+ render(<KanbanBoard projectRoot={projectRoot} />)
221
+ }
222
+
223
+ // CLI entry point
224
+ if (import.meta.main) {
225
+ runKanban(process.cwd())
226
+ }