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,372 @@
1
+ /**
2
+ * Loopwork Dashboard
3
+ *
4
+ * Live TUI dashboard showing loop progress using ink (React for CLI).
5
+ *
6
+ * Usage:
7
+ * import { createDashboardPlugin, renderDashboard } from './dashboard'
8
+ *
9
+ * // As a plugin
10
+ * withPlugin(createDashboardPlugin())
11
+ *
12
+ * // Standalone
13
+ * renderDashboard()
14
+ */
15
+
16
+ import React, { useState, useEffect } from 'react'
17
+ import { render, Box, Text, useApp, useInput } from 'ink'
18
+ import type { LoopworkPlugin, PluginTask } from './loopwork-config-types'
19
+
20
+ // ============================================================================
21
+ // Types
22
+ // ============================================================================
23
+
24
+ interface TaskEvent {
25
+ id: string
26
+ title: string
27
+ status: 'started' | 'completed' | 'failed'
28
+ duration?: number
29
+ error?: string
30
+ timestamp: Date
31
+ }
32
+
33
+ interface DashboardState {
34
+ namespace: string
35
+ currentTask: PluginTask | null
36
+ taskStartTime: number | null
37
+ completed: number
38
+ failed: number
39
+ total: number
40
+ loopStartTime: number | null
41
+ recentEvents: TaskEvent[]
42
+ isRunning: boolean
43
+ }
44
+
45
+ // Global state for plugin communication
46
+ let dashboardState: DashboardState = {
47
+ namespace: 'default',
48
+ currentTask: null,
49
+ taskStartTime: null,
50
+ completed: 0,
51
+ failed: 0,
52
+ total: 0,
53
+ loopStartTime: null,
54
+ recentEvents: [],
55
+ isRunning: false,
56
+ }
57
+
58
+ let stateListeners: Array<(state: DashboardState) => void> = []
59
+
60
+ function updateState(updates: Partial<DashboardState>) {
61
+ dashboardState = { ...dashboardState, ...updates }
62
+ stateListeners.forEach((fn) => fn(dashboardState))
63
+ }
64
+
65
+ function subscribe(fn: (state: DashboardState) => void) {
66
+ stateListeners.push(fn)
67
+ return () => {
68
+ stateListeners = stateListeners.filter((f) => f !== fn)
69
+ }
70
+ }
71
+
72
+ // ============================================================================
73
+ // Components
74
+ // ============================================================================
75
+
76
+ function Header({ namespace }: { namespace: string }) {
77
+ return (
78
+ <Box borderStyle="round" borderColor="cyan" paddingX={1}>
79
+ <Text bold color="cyan">
80
+ {'🤖 Loopwork Dashboard'}
81
+ </Text>
82
+ <Text color="gray"> | </Text>
83
+ <Text color="yellow">namespace: {namespace}</Text>
84
+ </Box>
85
+ )
86
+ }
87
+
88
+ function ProgressBar({ percent, width = 30 }: { percent: number; width?: number }) {
89
+ const filled = Math.round((percent / 100) * width)
90
+ const empty = width - filled
91
+ const bar = '█'.repeat(filled) + '░'.repeat(empty)
92
+ const color = percent === 100 ? 'green' : percent > 50 ? 'yellow' : 'cyan'
93
+
94
+ return (
95
+ <Text color={color}>
96
+ [{bar}] {percent.toFixed(0)}%
97
+ </Text>
98
+ )
99
+ }
100
+
101
+ function CurrentTask({ task, startTime }: { task: PluginTask | null; startTime: number | null }) {
102
+ const [elapsed, setElapsed] = useState(0)
103
+
104
+ useEffect(() => {
105
+ if (!startTime) {
106
+ setElapsed(0)
107
+ return
108
+ }
109
+
110
+ const interval = setInterval(() => {
111
+ setElapsed(Math.floor((Date.now() - startTime) / 1000))
112
+ }, 1000)
113
+
114
+ return () => clearInterval(interval)
115
+ }, [startTime])
116
+
117
+ if (!task) {
118
+ return (
119
+ <Box marginY={1}>
120
+ <Text color="gray">⏳ Waiting for next task...</Text>
121
+ </Box>
122
+ )
123
+ }
124
+
125
+ return (
126
+ <Box flexDirection="column" marginY={1}>
127
+ <Box>
128
+ <Text color="cyan">{'▶ '}</Text>
129
+ <Text bold>{task.id}</Text>
130
+ <Text color="gray">: {task.title.slice(0, 50)}</Text>
131
+ </Box>
132
+ <Box>
133
+ <Text color="yellow"> ⏱ {formatTime(elapsed)}</Text>
134
+ </Box>
135
+ </Box>
136
+ )
137
+ }
138
+
139
+ function Stats({ completed, failed, total }: { completed: number; failed: number; total: number }) {
140
+ const percent = total > 0 ? ((completed + failed) / total) * 100 : 0
141
+
142
+ return (
143
+ <Box flexDirection="column" marginY={1}>
144
+ <Box>
145
+ <Text color="green">✓ {completed}</Text>
146
+ <Text color="gray"> | </Text>
147
+ <Text color="red">✗ {failed}</Text>
148
+ <Text color="gray"> | </Text>
149
+ <Text>Total: {total}</Text>
150
+ </Box>
151
+ <Box marginTop={1}>
152
+ <ProgressBar percent={percent} />
153
+ </Box>
154
+ </Box>
155
+ )
156
+ }
157
+
158
+ function RecentEvents({ events }: { events: TaskEvent[] }) {
159
+ return (
160
+ <Box flexDirection="column" marginY={1}>
161
+ <Text bold color="white">Recent:</Text>
162
+ {events.length === 0 ? (
163
+ <Text color="gray"> No events yet</Text>
164
+ ) : (
165
+ events.slice(-5).map((event, i) => (
166
+ <Box key={i}>
167
+ <Text color="gray">{formatTimestamp(event.timestamp)} </Text>
168
+ <Text color={event.status === 'completed' ? 'green' : event.status === 'failed' ? 'red' : 'cyan'}>
169
+ {event.status === 'completed' ? '✓' : event.status === 'failed' ? '✗' : '▶'}
170
+ </Text>
171
+ <Text> {event.id}</Text>
172
+ {event.duration && <Text color="gray"> ({event.duration}s)</Text>}
173
+ </Box>
174
+ ))
175
+ )}
176
+ </Box>
177
+ )
178
+ }
179
+
180
+ function ElapsedTime({ startTime }: { startTime: number | null }) {
181
+ const [elapsed, setElapsed] = useState(0)
182
+
183
+ useEffect(() => {
184
+ if (!startTime) return
185
+
186
+ const interval = setInterval(() => {
187
+ setElapsed(Math.floor((Date.now() - startTime) / 1000))
188
+ }, 1000)
189
+
190
+ return () => clearInterval(interval)
191
+ }, [startTime])
192
+
193
+ if (!startTime) return null
194
+
195
+ return (
196
+ <Box>
197
+ <Text color="gray">Loop time: </Text>
198
+ <Text color="white">{formatTime(elapsed)}</Text>
199
+ </Box>
200
+ )
201
+ }
202
+
203
+ function Footer() {
204
+ return (
205
+ <Box marginTop={1}>
206
+ <Text color="gray">Press </Text>
207
+ <Text color="yellow">q</Text>
208
+ <Text color="gray"> to quit</Text>
209
+ </Box>
210
+ )
211
+ }
212
+
213
+ function Dashboard() {
214
+ const { exit } = useApp()
215
+ const [state, setState] = useState<DashboardState>(dashboardState)
216
+
217
+ useEffect(() => {
218
+ return subscribe(setState)
219
+ }, [])
220
+
221
+ useInput((input) => {
222
+ if (input === 'q') {
223
+ exit()
224
+ }
225
+ })
226
+
227
+ return (
228
+ <Box flexDirection="column" padding={1}>
229
+ <Header namespace={state.namespace} />
230
+
231
+ <Box marginTop={1}>
232
+ <ElapsedTime startTime={state.loopStartTime} />
233
+ </Box>
234
+
235
+ <CurrentTask task={state.currentTask} startTime={state.taskStartTime} />
236
+
237
+ <Stats completed={state.completed} failed={state.failed} total={state.total} />
238
+
239
+ <RecentEvents events={state.recentEvents} />
240
+
241
+ <Footer />
242
+ </Box>
243
+ )
244
+ }
245
+
246
+ // ============================================================================
247
+ // Helpers
248
+ // ============================================================================
249
+
250
+ function formatTime(seconds: number): string {
251
+ const h = Math.floor(seconds / 3600)
252
+ const m = Math.floor((seconds % 3600) / 60)
253
+ const s = seconds % 60
254
+
255
+ if (h > 0) return `${h}h ${m}m ${s}s`
256
+ if (m > 0) return `${m}m ${s}s`
257
+ return `${s}s`
258
+ }
259
+
260
+ function formatTimestamp(date: Date): string {
261
+ return date.toLocaleTimeString('en-US', { hour12: false })
262
+ }
263
+
264
+ // ============================================================================
265
+ // Public API
266
+ // ============================================================================
267
+
268
+ /**
269
+ * Render the dashboard (standalone mode)
270
+ */
271
+ export function renderDashboard() {
272
+ const { unmount } = render(<Dashboard />)
273
+ return { unmount }
274
+ }
275
+
276
+ /**
277
+ * Create dashboard plugin
278
+ */
279
+ export function createDashboardPlugin(options: { totalTasks?: number } = {}): LoopworkPlugin {
280
+ return {
281
+ name: 'dashboard',
282
+
283
+ onLoopStart(namespace) {
284
+ updateState({
285
+ namespace,
286
+ loopStartTime: Date.now(),
287
+ isRunning: true,
288
+ completed: 0,
289
+ failed: 0,
290
+ total: options.totalTasks || 0,
291
+ recentEvents: [],
292
+ })
293
+ },
294
+
295
+ onTaskStart(task) {
296
+ updateState({
297
+ currentTask: task,
298
+ taskStartTime: Date.now(),
299
+ })
300
+
301
+ const events = [...dashboardState.recentEvents]
302
+ events.push({
303
+ id: task.id,
304
+ title: task.title,
305
+ status: 'started',
306
+ timestamp: new Date(),
307
+ })
308
+ updateState({ recentEvents: events.slice(-10) })
309
+ },
310
+
311
+ onTaskComplete(task, result) {
312
+ const events = [...dashboardState.recentEvents]
313
+ // Update the started event to completed
314
+ const idx = events.findIndex((e) => e.id === task.id && e.status === 'started')
315
+ if (idx >= 0) {
316
+ events[idx] = {
317
+ ...events[idx],
318
+ status: 'completed',
319
+ duration: Math.round(result.duration),
320
+ }
321
+ }
322
+
323
+ updateState({
324
+ currentTask: null,
325
+ taskStartTime: null,
326
+ completed: dashboardState.completed + 1,
327
+ recentEvents: events.slice(-10),
328
+ })
329
+ },
330
+
331
+ onTaskFailed(task, error) {
332
+ const events = [...dashboardState.recentEvents]
333
+ const idx = events.findIndex((e) => e.id === task.id && e.status === 'started')
334
+ if (idx >= 0) {
335
+ events[idx] = {
336
+ ...events[idx],
337
+ status: 'failed',
338
+ error,
339
+ }
340
+ }
341
+
342
+ updateState({
343
+ currentTask: null,
344
+ taskStartTime: null,
345
+ failed: dashboardState.failed + 1,
346
+ recentEvents: events.slice(-10),
347
+ })
348
+ },
349
+
350
+ onLoopEnd() {
351
+ updateState({
352
+ isRunning: false,
353
+ currentTask: null,
354
+ taskStartTime: null,
355
+ })
356
+ },
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Set total task count (call before loop starts)
362
+ */
363
+ export function setTotalTasks(count: number) {
364
+ updateState({ total: count })
365
+ }
366
+
367
+ /**
368
+ * Get current dashboard state
369
+ */
370
+ export function getDashboardState(): DashboardState {
371
+ return { ...dashboardState }
372
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { logger } from './core/utils'
2
+
3
+ async function main() {
4
+ const args = process.argv.slice(2)
5
+ const command = args[0]
6
+
7
+ if (command === 'init') {
8
+ const { init } = await import('./commands/init')
9
+ await init()
10
+ } else {
11
+ const { run } = await import('./commands/run')
12
+ await run()
13
+ }
14
+ }
15
+
16
+ main().catch((err) => {
17
+ logger.error(`Unhandled error: ${err.message}`)
18
+ process.exit(1)
19
+ })