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,335 @@
1
+ /**
2
+ * Everhour Plugin for Loopwork
3
+ *
4
+ * Tracks time spent on tasks using Everhour API.
5
+ * Tasks should have metadata.everhourId or metadata.asanaGid set.
6
+ * If using Asana integration, asanaGid is auto-prefixed with 'as:' for Everhour.
7
+ *
8
+ * Setup:
9
+ * 1. Get API key from Everhour Settings > Integrations > API
10
+ * 2. Set EVERHOUR_API_KEY env var
11
+ * 3. Link Everhour to your Asana project for automatic task syncing
12
+ * 4. Add everhourId or asanaGid to task metadata
13
+ */
14
+
15
+ import type { LoopworkPlugin, PluginTask } from '../contracts'
16
+
17
+ export interface EverhourConfig {
18
+ apiKey?: string
19
+ /** Auto-start timer when task begins */
20
+ autoStartTimer?: boolean
21
+ /** Auto-stop timer when task completes */
22
+ autoStopTimer?: boolean
23
+ /** Default project ID for new time entries */
24
+ projectId?: string
25
+ }
26
+
27
+ interface EverhourTimeEntry {
28
+ id: number
29
+ time: number // seconds
30
+ date: string
31
+ task?: { id: string; name: string }
32
+ user?: { id: number; name: string }
33
+ }
34
+
35
+ interface EverhourTask {
36
+ id: string
37
+ name: string
38
+ time: { total: number; users?: Record<string, number> }
39
+ }
40
+
41
+ interface EverhourTimer {
42
+ status: 'active' | 'stopped'
43
+ duration: number
44
+ task?: { id: string }
45
+ startedAt?: string
46
+ }
47
+
48
+ export class EverhourClient {
49
+ private baseUrl = 'https://api.everhour.com'
50
+ private apiKey: string
51
+
52
+ constructor(apiKey: string) {
53
+ this.apiKey = apiKey
54
+ }
55
+
56
+ private async request<T>(
57
+ method: string,
58
+ endpoint: string,
59
+ body?: Record<string, unknown>
60
+ ): Promise<T> {
61
+ const url = `${this.baseUrl}${endpoint}`
62
+ const response = await fetch(url, {
63
+ method,
64
+ headers: {
65
+ 'X-Api-Key': this.apiKey,
66
+ 'Content-Type': 'application/json',
67
+ },
68
+ body: body ? JSON.stringify(body) : undefined,
69
+ })
70
+
71
+ if (!response.ok) {
72
+ const error = await response.text()
73
+ throw new Error(`Everhour API error: ${response.status} - ${error}`)
74
+ }
75
+
76
+ // Some endpoints return empty response
77
+ const text = await response.text()
78
+ if (!text) return {} as T
79
+
80
+ return JSON.parse(text) as T
81
+ }
82
+
83
+ /**
84
+ * Get current timer status
85
+ */
86
+ async getCurrentTimer(): Promise<EverhourTimer | null> {
87
+ try {
88
+ return await this.request('GET', '/timers/current')
89
+ } catch {
90
+ return null
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Start timer for a task
96
+ * @param taskId - Everhour task ID (for Asana tasks, use 'as:' prefix + GID)
97
+ */
98
+ async startTimer(taskId: string): Promise<EverhourTimer> {
99
+ return this.request('POST', '/timers', { task: taskId })
100
+ }
101
+
102
+ /**
103
+ * Stop the current timer
104
+ */
105
+ async stopTimer(): Promise<EverhourTimer> {
106
+ return this.request('DELETE', '/timers/current')
107
+ }
108
+
109
+ /**
110
+ * Add time entry for a task
111
+ * @param taskId - Everhour task ID
112
+ * @param seconds - Duration in seconds
113
+ * @param date - Date in YYYY-MM-DD format (defaults to today)
114
+ */
115
+ async addTime(taskId: string, seconds: number, date?: string): Promise<EverhourTimeEntry> {
116
+ const today = date || new Date().toISOString().split('T')[0]
117
+ return this.request('POST', `/tasks/${taskId}/time`, {
118
+ time: seconds,
119
+ date: today,
120
+ })
121
+ }
122
+
123
+ /**
124
+ * Get time entries for a task
125
+ */
126
+ async getTaskTime(taskId: string): Promise<EverhourTask> {
127
+ return this.request('GET', `/tasks/${taskId}`)
128
+ }
129
+
130
+ /**
131
+ * Get today's time entries
132
+ */
133
+ async getTodayEntries(): Promise<EverhourTimeEntry[]> {
134
+ const today = new Date().toISOString().split('T')[0]
135
+ return this.request('GET', `/team/time?from=${today}&to=${today}`)
136
+ }
137
+
138
+ /**
139
+ * Get total time logged today (in seconds)
140
+ */
141
+ async getTodayTotal(): Promise<number> {
142
+ const entries = await this.getTodayEntries()
143
+ return entries.reduce((sum, e) => sum + e.time, 0)
144
+ }
145
+
146
+ /**
147
+ * Check if we're under the daily limit
148
+ * @param maxHours - Maximum hours per day (default: 8)
149
+ */
150
+ async checkDailyLimit(maxHours = 8): Promise<{ withinLimit: boolean; hoursLogged: number; remaining: number }> {
151
+ const totalSeconds = await this.getTodayTotal()
152
+ const hoursLogged = totalSeconds / 3600
153
+ const remaining = Math.max(0, maxHours - hoursLogged)
154
+ return {
155
+ withinLimit: hoursLogged < maxHours,
156
+ hoursLogged: Math.round(hoursLogged * 100) / 100,
157
+ remaining: Math.round(remaining * 100) / 100,
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Get user's current status
163
+ */
164
+ async getMe(): Promise<{ id: number; name: string; email: string }> {
165
+ return this.request('GET', '/users/me')
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Create Everhour plugin wrapper
171
+ */
172
+ export function withEverhour(config: EverhourConfig = {}) {
173
+ const apiKey = config.apiKey || process.env.EVERHOUR_API_KEY
174
+
175
+ return (baseConfig: any) => ({
176
+ ...baseConfig,
177
+ everhour: {
178
+ apiKey,
179
+ autoStartTimer: config.autoStartTimer ?? true,
180
+ autoStopTimer: config.autoStopTimer ?? true,
181
+ projectId: config.projectId,
182
+ },
183
+ })
184
+ }
185
+
186
+ /**
187
+ * Get Everhour task ID from task metadata
188
+ * Prefers everhourId, falls back to asanaGid with 'as:' prefix
189
+ */
190
+ function getEverhourTaskId(task: PluginTask): string | undefined {
191
+ const everhourId = task.metadata?.everhourId as string | undefined
192
+ if (everhourId) return everhourId
193
+
194
+ const asanaGid = task.metadata?.asanaGid as string | undefined
195
+ if (asanaGid) return asanaToEverhour(asanaGid)
196
+
197
+ return undefined
198
+ }
199
+
200
+ // Track active timers per task
201
+ const activeTimers: Map<string, { startTime: number; everhourTaskId?: string }> = new Map()
202
+
203
+ /**
204
+ * Create Everhour hook plugin
205
+ *
206
+ * Tasks should have metadata.everhourId or metadata.asanaGid set.
207
+ */
208
+ export function createEverhourPlugin(config: EverhourConfig = {}): LoopworkPlugin {
209
+ const apiKey = config.apiKey || process.env.EVERHOUR_API_KEY || ''
210
+ const autoStart = config.autoStartTimer ?? true
211
+ const autoStop = config.autoStopTimer ?? true
212
+
213
+ if (!apiKey) {
214
+ return {
215
+ name: 'everhour',
216
+ onConfigLoad: (cfg) => {
217
+ console.warn('Everhour plugin: Missing EVERHOUR_API_KEY')
218
+ return cfg
219
+ },
220
+ }
221
+ }
222
+
223
+ const client = new EverhourClient(apiKey)
224
+
225
+ return {
226
+ name: 'everhour',
227
+
228
+ async onLoopStart() {
229
+ // Check daily limit at start
230
+ try {
231
+ const limit = await client.checkDailyLimit(8)
232
+ if (!limit.withinLimit) {
233
+ console.warn(`⚠️ Everhour: Already logged ${limit.hoursLogged}h today (limit: 8h)`)
234
+ } else {
235
+ console.log(`⏱️ Everhour: ${limit.hoursLogged}h logged, ${limit.remaining}h remaining`)
236
+ }
237
+ } catch (e: any) {
238
+ console.warn(`Everhour: Failed to check daily limit: ${e.message}`)
239
+ }
240
+ },
241
+
242
+ async onTaskStart(task) {
243
+ // Record start time
244
+ const startTime = Date.now()
245
+ const everhourTaskId = getEverhourTaskId(task)
246
+ activeTimers.set(task.id, { startTime, everhourTaskId })
247
+
248
+ // Start Everhour timer if task has ID and autoStart enabled
249
+ if (everhourTaskId && autoStart) {
250
+ try {
251
+ await client.startTimer(everhourTaskId)
252
+ console.log(`⏱️ Timer started for ${task.id}`)
253
+ } catch (e: any) {
254
+ console.warn(`Everhour: Failed to start timer: ${e.message}`)
255
+ }
256
+ }
257
+ },
258
+
259
+ async onTaskComplete(task, result) {
260
+ const timerInfo = activeTimers.get(task.id)
261
+ activeTimers.delete(task.id)
262
+
263
+ if (!timerInfo) return
264
+
265
+ const { everhourTaskId } = timerInfo
266
+ const durationSeconds = Math.round(result.duration)
267
+
268
+ // Stop timer if running
269
+ if (everhourTaskId && autoStop) {
270
+ try {
271
+ await client.stopTimer()
272
+ console.log(`⏱️ Timer stopped for ${task.id} (${durationSeconds}s)`)
273
+ } catch (e: any) {
274
+ // Timer might not be running, that's ok
275
+ console.warn(`Everhour: ${e.message}`)
276
+ }
277
+ }
278
+
279
+ // Log time if we have an ID (backup in case timer wasn't running)
280
+ if (everhourTaskId && !autoStart) {
281
+ try {
282
+ await client.addTime(everhourTaskId, durationSeconds)
283
+ console.log(`⏱️ Logged ${durationSeconds}s to ${task.id}`)
284
+ } catch (e: any) {
285
+ console.warn(`Everhour: Failed to log time: ${e.message}`)
286
+ }
287
+ }
288
+ },
289
+
290
+ async onTaskFailed(task) {
291
+ const timerInfo = activeTimers.get(task.id)
292
+ activeTimers.delete(task.id)
293
+
294
+ // Stop timer if running (don't log time for failed tasks by default)
295
+ if (timerInfo?.everhourTaskId && autoStop) {
296
+ try {
297
+ await client.stopTimer()
298
+ console.log(`⏱️ Timer stopped for failed task ${task.id}`)
299
+ } catch {
300
+ // Timer might not be running
301
+ }
302
+ }
303
+ },
304
+
305
+ async onLoopEnd(stats) {
306
+ // Report final time summary
307
+ try {
308
+ const limit = await client.checkDailyLimit(8)
309
+ console.log(`📊 Everhour: Session complete. Total today: ${limit.hoursLogged}h`)
310
+ } catch {
311
+ // Ignore errors in summary
312
+ }
313
+ },
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Helper to convert Asana GID to Everhour task ID
319
+ */
320
+ export function asanaToEverhour(asanaGid: string): string {
321
+ return `as:${asanaGid}`
322
+ }
323
+
324
+ /**
325
+ * Format seconds to human readable duration
326
+ */
327
+ export function formatDuration(seconds: number): string {
328
+ const hours = Math.floor(seconds / 3600)
329
+ const minutes = Math.floor((seconds % 3600) / 60)
330
+
331
+ if (hours > 0) {
332
+ return `${hours}h ${minutes}m`
333
+ }
334
+ return `${minutes}m`
335
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Plugin System
3
+ *
4
+ * Config wrappers and plugin utilities
5
+ */
6
+
7
+ import type {
8
+ LoopworkConfig,
9
+ LoopworkPlugin,
10
+ ConfigWrapper,
11
+ TelegramConfig,
12
+ DiscordConfig,
13
+ AsanaConfig,
14
+ EverhourConfig,
15
+ TodoistConfig,
16
+ CostTrackingConfig,
17
+ } from '../contracts'
18
+ import { DEFAULT_CONFIG } from '../contracts'
19
+
20
+ // ============================================================================
21
+ // Config Helpers
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Define a type-safe config
26
+ */
27
+ export function defineConfig(config: LoopworkConfig): LoopworkConfig {
28
+ return {
29
+ ...DEFAULT_CONFIG,
30
+ ...config,
31
+ plugins: config.plugins || [],
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Define async/dynamic config
37
+ */
38
+ export function defineConfigAsync(
39
+ fn: () => Promise<LoopworkConfig> | LoopworkConfig
40
+ ): () => Promise<LoopworkConfig> {
41
+ return async () => {
42
+ const config = await fn()
43
+ return defineConfig(config)
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Compose multiple wrappers
49
+ *
50
+ * @example
51
+ * export default compose(
52
+ * withTelegram(),
53
+ * withCostTracking(),
54
+ * )(defineConfig({ ... }))
55
+ */
56
+ export function compose(...wrappers: ConfigWrapper[]): ConfigWrapper {
57
+ return (config) => wrappers.reduce((cfg, wrapper) => wrapper(cfg), config)
58
+ }
59
+
60
+ // ============================================================================
61
+ // Plugin Wrappers
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Add a custom plugin
66
+ */
67
+ export function withPlugin(plugin: LoopworkPlugin): ConfigWrapper {
68
+ return (config) => ({
69
+ ...config,
70
+ plugins: [...(config.plugins || []), plugin],
71
+ })
72
+ }
73
+
74
+ /**
75
+ * Add Telegram notifications
76
+ */
77
+ export function withTelegram(options: TelegramConfig = {}): ConfigWrapper {
78
+ return (config) => ({
79
+ ...config,
80
+ telegram: {
81
+ notifications: true,
82
+ silent: false,
83
+ ...options,
84
+ botToken: options.botToken || process.env.TELEGRAM_BOT_TOKEN,
85
+ chatId: options.chatId || process.env.TELEGRAM_CHAT_ID,
86
+ },
87
+ })
88
+ }
89
+
90
+ /**
91
+ * Add Discord notifications
92
+ */
93
+ export function withDiscord(options: DiscordConfig = {}): ConfigWrapper {
94
+ return (config) => ({
95
+ ...config,
96
+ discord: {
97
+ webhookUrl: options.webhookUrl || process.env.DISCORD_WEBHOOK_URL,
98
+ username: options.username || 'Loopwork',
99
+ avatarUrl: options.avatarUrl,
100
+ notifyOnStart: options.notifyOnStart ?? false,
101
+ notifyOnComplete: options.notifyOnComplete ?? true,
102
+ notifyOnFail: options.notifyOnFail ?? true,
103
+ notifyOnLoopEnd: options.notifyOnLoopEnd ?? true,
104
+ mentionOnFail: options.mentionOnFail,
105
+ },
106
+ })
107
+ }
108
+
109
+ /**
110
+ * Add Asana integration
111
+ */
112
+ export function withAsana(options: AsanaConfig = {}): ConfigWrapper {
113
+ return (config) => ({
114
+ ...config,
115
+ asana: {
116
+ accessToken: options.accessToken || process.env.ASANA_ACCESS_TOKEN,
117
+ projectId: options.projectId || process.env.ASANA_PROJECT_ID,
118
+ workspaceId: options.workspaceId,
119
+ autoCreate: options.autoCreate ?? false,
120
+ syncStatus: options.syncStatus ?? true,
121
+ },
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Add Everhour time tracking
127
+ */
128
+ export function withEverhour(options: EverhourConfig = {}): ConfigWrapper {
129
+ return (config) => ({
130
+ ...config,
131
+ everhour: {
132
+ apiKey: options.apiKey || process.env.EVERHOUR_API_KEY,
133
+ autoStartTimer: options.autoStartTimer ?? true,
134
+ autoStopTimer: options.autoStopTimer ?? true,
135
+ projectId: options.projectId,
136
+ dailyLimit: options.dailyLimit ?? 8,
137
+ },
138
+ })
139
+ }
140
+
141
+ /**
142
+ * Add Todoist integration
143
+ */
144
+ export function withTodoist(options: TodoistConfig = {}): ConfigWrapper {
145
+ return (config) => ({
146
+ ...config,
147
+ todoist: {
148
+ apiToken: options.apiToken || process.env.TODOIST_API_TOKEN,
149
+ projectId: options.projectId || process.env.TODOIST_PROJECT_ID,
150
+ syncStatus: options.syncStatus ?? true,
151
+ addComments: options.addComments ?? true,
152
+ },
153
+ })
154
+ }
155
+
156
+ /**
157
+ * Add cost tracking
158
+ */
159
+ export function withCostTracking(options: CostTrackingConfig = {}): ConfigWrapper {
160
+ return (config) => ({
161
+ ...config,
162
+ costTracking: {
163
+ enabled: true,
164
+ defaultModel: 'claude-3.5-sonnet',
165
+ ...options,
166
+ },
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Use GitHub Issues as backend
172
+ */
173
+ export function withGitHub(options: { repo?: string } = {}): ConfigWrapper {
174
+ return (config) => ({
175
+ ...config,
176
+ backend: {
177
+ type: 'github',
178
+ repo: options.repo,
179
+ },
180
+ })
181
+ }
182
+
183
+ /**
184
+ * Use JSON files as backend
185
+ */
186
+ export function withJSON(options: { tasksFile?: string; tasksDir?: string } = {}): ConfigWrapper {
187
+ return (config) => ({
188
+ ...config,
189
+ backend: {
190
+ type: 'json',
191
+ tasksFile: options.tasksFile || '.specs/tasks/tasks.json',
192
+ tasksDir: options.tasksDir,
193
+ },
194
+ })
195
+ }
196
+
197
+ // ============================================================================
198
+ // Plugin Registry
199
+ // ============================================================================
200
+
201
+ class PluginRegistry {
202
+ private plugins: LoopworkPlugin[] = []
203
+
204
+ register(plugin: LoopworkPlugin): void {
205
+ const existing = this.plugins.findIndex((p) => p.name === plugin.name)
206
+ if (existing >= 0) {
207
+ this.plugins[existing] = plugin
208
+ } else {
209
+ this.plugins.push(plugin)
210
+ }
211
+ }
212
+
213
+ unregister(name: string): void {
214
+ this.plugins = this.plugins.filter((p) => p.name !== name)
215
+ }
216
+
217
+ getAll(): LoopworkPlugin[] {
218
+ return [...this.plugins]
219
+ }
220
+
221
+ get(name: string): LoopworkPlugin | undefined {
222
+ return this.plugins.find((p) => p.name === name)
223
+ }
224
+
225
+ clear(): void {
226
+ this.plugins = []
227
+ }
228
+
229
+ /**
230
+ * Run a lifecycle hook on all registered plugins
231
+ */
232
+ async runHook(hookName: keyof LoopworkPlugin, ...args: any[]): Promise<void> {
233
+ for (const plugin of this.plugins) {
234
+ const hook = plugin[hookName]
235
+ if (typeof hook === 'function') {
236
+ try {
237
+ await (hook as Function).apply(plugin, args)
238
+ } catch (error) {
239
+ console.error(`Plugin ${plugin.name} error in ${hookName}:`, error)
240
+ }
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ export const plugins = new PluginRegistry()
247
+
248
+ // ============================================================================
249
+ // Re-exports
250
+ // ============================================================================
251
+
252
+ export type { LoopworkPlugin, ConfigWrapper } from '../contracts'
253
+ export { DEFAULT_CONFIG as defaults } from '../contracts'