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,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'
|