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,420 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { spawn, ChildProcess } from 'child_process'
4
+ import chalk from 'chalk'
5
+
6
+ /**
7
+ * Monitor for running Loopwork instances in the background
8
+ *
9
+ * Features:
10
+ * - Start/stop loops by namespace
11
+ * - Track running processes
12
+ * - View logs and status
13
+ */
14
+
15
+ interface LoopProcess {
16
+ namespace: string
17
+ pid: number
18
+ startedAt: string
19
+ logFile: string
20
+ args: string[]
21
+ }
22
+
23
+ interface MonitorState {
24
+ processes: LoopProcess[]
25
+ }
26
+
27
+ const MONITOR_STATE_FILE = '.loopwork-monitor-state.json'
28
+
29
+ export class LoopworkMonitor {
30
+ private stateFile: string
31
+ private projectRoot: string
32
+
33
+ constructor(projectRoot?: string) {
34
+ this.projectRoot = projectRoot || process.cwd()
35
+ this.stateFile = path.join(this.projectRoot, MONITOR_STATE_FILE)
36
+ }
37
+
38
+ /**
39
+ * Start a loop in the background
40
+ */
41
+ async start(namespace: string, args: string[] = []): Promise<{ success: boolean; pid?: number; error?: string }> {
42
+ // Check if already running
43
+ const running = this.getRunningProcesses()
44
+ const existing = running.find(p => p.namespace === namespace)
45
+ if (existing) {
46
+ return { success: false, error: `Namespace '${namespace}' is already running (PID: ${existing.pid})` }
47
+ }
48
+
49
+ // Create log directory
50
+ const logsDir = path.join(this.projectRoot, 'loopwork-runs', namespace, 'monitor-logs')
51
+ fs.mkdirSync(logsDir, { recursive: true })
52
+
53
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
54
+ const logFile = path.join(logsDir, `${timestamp}.log`)
55
+
56
+ // Build command args
57
+ const fullArgs = ['--namespace', namespace, '-y', ...args]
58
+
59
+ // Spawn background process
60
+ const logStream = fs.openSync(logFile, 'a')
61
+
62
+ const child: ChildProcess = spawn('bun', ['run', 'src/index.ts', ...fullArgs], {
63
+ cwd: path.join(this.projectRoot, 'packages/loopwork'),
64
+ detached: true,
65
+ stdio: ['ignore', logStream, logStream],
66
+ })
67
+
68
+ child.unref()
69
+
70
+ if (!child.pid) {
71
+ return { success: false, error: 'Failed to spawn process' }
72
+ }
73
+
74
+ // Save to state
75
+ const state = this.loadState()
76
+ state.processes.push({
77
+ namespace,
78
+ pid: child.pid,
79
+ startedAt: new Date().toISOString(),
80
+ logFile,
81
+ args: fullArgs,
82
+ })
83
+ this.saveState(state)
84
+
85
+ return { success: true, pid: child.pid }
86
+ }
87
+
88
+ /**
89
+ * Stop a running loop by namespace
90
+ */
91
+ stop(namespace: string): { success: boolean; error?: string } {
92
+ const state = this.loadState()
93
+ const proc = state.processes.find(p => p.namespace === namespace)
94
+
95
+ if (!proc) {
96
+ return { success: false, error: `No running loop found for namespace '${namespace}'` }
97
+ }
98
+
99
+ try {
100
+ process.kill(proc.pid, 'SIGTERM')
101
+
102
+ // Remove from state
103
+ state.processes = state.processes.filter(p => p.namespace !== namespace)
104
+ this.saveState(state)
105
+
106
+ return { success: true }
107
+ } catch (e: any) {
108
+ if (e.code === 'ESRCH') {
109
+ // Process already dead, clean up state
110
+ state.processes = state.processes.filter(p => p.namespace !== namespace)
111
+ this.saveState(state)
112
+ return { success: true }
113
+ }
114
+ return { success: false, error: e.message }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Stop all running loops
120
+ */
121
+ stopAll(): { stopped: string[]; errors: string[] } {
122
+ const stopped: string[] = []
123
+ const errors: string[] = []
124
+
125
+ const running = this.getRunningProcesses()
126
+ for (const proc of running) {
127
+ const result = this.stop(proc.namespace)
128
+ if (result.success) {
129
+ stopped.push(proc.namespace)
130
+ } else {
131
+ errors.push(`${proc.namespace}: ${result.error}`)
132
+ }
133
+ }
134
+
135
+ return { stopped, errors }
136
+ }
137
+
138
+ /**
139
+ * Get list of running processes (with validation)
140
+ */
141
+ getRunningProcesses(): LoopProcess[] {
142
+ const state = this.loadState()
143
+ const running: LoopProcess[] = []
144
+ const toRemove: string[] = []
145
+
146
+ for (const proc of state.processes) {
147
+ if (this.isProcessAlive(proc.pid)) {
148
+ running.push(proc)
149
+ } else {
150
+ toRemove.push(proc.namespace)
151
+ }
152
+ }
153
+
154
+ // Clean up dead processes
155
+ if (toRemove.length > 0) {
156
+ state.processes = state.processes.filter(p => !toRemove.includes(p.namespace))
157
+ this.saveState(state)
158
+ }
159
+
160
+ return running
161
+ }
162
+
163
+ /**
164
+ * Get status of all namespaces
165
+ */
166
+ getStatus(): {
167
+ running: LoopProcess[]
168
+ namespaces: { name: string; status: 'running' | 'stopped'; lastRun?: string }[]
169
+ } {
170
+ const running = this.getRunningProcesses()
171
+
172
+ // Find all namespaces from directories
173
+ const runsDir = path.join(this.projectRoot, 'loopwork-runs')
174
+ const namespaces: { name: string; status: 'running' | 'stopped'; lastRun?: string }[] = []
175
+
176
+ if (fs.existsSync(runsDir)) {
177
+ const dirs = fs.readdirSync(runsDir, { withFileTypes: true })
178
+ .filter(d => d.isDirectory())
179
+ .map(d => d.name)
180
+
181
+ for (const name of dirs) {
182
+ const isRunning = running.some(p => p.namespace === name)
183
+ const nsDir = path.join(runsDir, name)
184
+
185
+ // Find last run timestamp
186
+ let lastRun: string | undefined
187
+ const runDirs = fs.readdirSync(nsDir, { withFileTypes: true })
188
+ .filter(d => d.isDirectory() && d.name !== 'monitor-logs')
189
+ .map(d => d.name)
190
+ .sort()
191
+ .reverse()
192
+
193
+ if (runDirs.length > 0) {
194
+ lastRun = runDirs[0]
195
+ }
196
+
197
+ namespaces.push({
198
+ name,
199
+ status: isRunning ? 'running' : 'stopped',
200
+ lastRun,
201
+ })
202
+ }
203
+ }
204
+
205
+ return { running, namespaces }
206
+ }
207
+
208
+ /**
209
+ * Get recent logs for a namespace
210
+ */
211
+ getLogs(namespace: string, lines = 50): string[] {
212
+ const running = this.getRunningProcesses()
213
+ const proc = running.find(p => p.namespace === namespace)
214
+
215
+ let logFile: string | undefined
216
+
217
+ if (proc) {
218
+ logFile = proc.logFile
219
+ } else {
220
+ // Find most recent log file
221
+ const logsDir = path.join(this.projectRoot, 'loopwork-runs', namespace, 'monitor-logs')
222
+ if (fs.existsSync(logsDir)) {
223
+ const files = fs.readdirSync(logsDir)
224
+ .filter(f => f.endsWith('.log'))
225
+ .sort()
226
+ .reverse()
227
+ if (files.length > 0) {
228
+ logFile = path.join(logsDir, files[0])
229
+ }
230
+ }
231
+ }
232
+
233
+ if (!logFile || !fs.existsSync(logFile)) {
234
+ return [`No logs found for namespace '${namespace}'`]
235
+ }
236
+
237
+ const content = fs.readFileSync(logFile, 'utf-8')
238
+ const allLines = content.split('\n')
239
+ return allLines.slice(-lines)
240
+ }
241
+
242
+ /**
243
+ * Check if a process is still alive
244
+ */
245
+ private isProcessAlive(pid: number): boolean {
246
+ try {
247
+ process.kill(pid, 0)
248
+ return true
249
+ } catch {
250
+ return false
251
+ }
252
+ }
253
+
254
+ private loadState(): MonitorState {
255
+ try {
256
+ if (fs.existsSync(this.stateFile)) {
257
+ const content = fs.readFileSync(this.stateFile, 'utf-8')
258
+ return JSON.parse(content)
259
+ }
260
+ } catch {}
261
+ return { processes: [] }
262
+ }
263
+
264
+ private saveState(state: MonitorState): void {
265
+ fs.writeFileSync(this.stateFile, JSON.stringify(state, null, 2))
266
+ }
267
+ }
268
+
269
+ /**
270
+ * CLI for the monitor
271
+ */
272
+ async function main() {
273
+ const args = process.argv.slice(2)
274
+ const command = args[0]
275
+ const monitor = new LoopworkMonitor()
276
+
277
+ switch (command) {
278
+ case 'start': {
279
+ const namespace = args[1] || 'default'
280
+ const extraArgs = args.slice(2)
281
+ console.log(chalk.blue(`Starting loop in namespace '${namespace}'...`))
282
+ const result = await monitor.start(namespace, extraArgs)
283
+ if (result.success) {
284
+ console.log(chalk.green(`✓ Started (PID: ${result.pid})`))
285
+ console.log(chalk.gray(`View logs: bun run src/monitor.ts logs ${namespace}`))
286
+ } else {
287
+ console.log(chalk.red(`✗ ${result.error}`))
288
+ process.exit(1)
289
+ }
290
+ break
291
+ }
292
+
293
+ case 'stop': {
294
+ const namespace = args[1]
295
+ if (!namespace) {
296
+ console.log(chalk.yellow('Usage: monitor stop <namespace> | monitor stop --all'))
297
+ process.exit(1)
298
+ }
299
+ if (namespace === '--all') {
300
+ const result = monitor.stopAll()
301
+ if (result.stopped.length > 0) {
302
+ console.log(chalk.green(`✓ Stopped: ${result.stopped.join(', ')}`))
303
+ }
304
+ if (result.errors.length > 0) {
305
+ console.log(chalk.red(`✗ Errors:\n ${result.errors.join('\n ')}`))
306
+ }
307
+ if (result.stopped.length === 0 && result.errors.length === 0) {
308
+ console.log(chalk.gray('No running loops'))
309
+ }
310
+ } else {
311
+ const result = monitor.stop(namespace)
312
+ if (result.success) {
313
+ console.log(chalk.green(`✓ Stopped namespace '${namespace}'`))
314
+ } else {
315
+ console.log(chalk.red(`✗ ${result.error}`))
316
+ process.exit(1)
317
+ }
318
+ }
319
+ break
320
+ }
321
+
322
+ case 'status': {
323
+ const { running, namespaces } = monitor.getStatus()
324
+
325
+ console.log(chalk.bold('\nLoopwork Monitor Status'))
326
+ console.log(chalk.gray('─'.repeat(50)))
327
+
328
+ if (running.length === 0) {
329
+ console.log(chalk.gray('No loops currently running\n'))
330
+ } else {
331
+ console.log(chalk.bold(`\nRunning (${running.length}):`))
332
+ for (const proc of running) {
333
+ const uptime = getUptime(proc.startedAt)
334
+ console.log(` ${chalk.green('●')} ${chalk.bold(proc.namespace)}`)
335
+ console.log(` PID: ${proc.pid} | Uptime: ${uptime}`)
336
+ console.log(` Log: ${proc.logFile}`)
337
+ }
338
+ }
339
+
340
+ if (namespaces.length > 0) {
341
+ console.log(chalk.bold('\nAll namespaces:'))
342
+ for (const ns of namespaces) {
343
+ const icon = ns.status === 'running' ? chalk.green('●') : chalk.gray('○')
344
+ const lastRunStr = ns.lastRun ? chalk.gray(`(last: ${ns.lastRun})`) : ''
345
+ console.log(` ${icon} ${ns.name} ${lastRunStr}`)
346
+ }
347
+ }
348
+
349
+ console.log()
350
+ break
351
+ }
352
+
353
+ case 'logs': {
354
+ const namespace = args[1] || 'default'
355
+ const lines = parseInt(args[2], 10) || 50
356
+ const logLines = monitor.getLogs(namespace, lines)
357
+ console.log(logLines.join('\n'))
358
+ break
359
+ }
360
+
361
+ case 'tail': {
362
+ const namespace = args[1] || 'default'
363
+ const running = monitor.getRunningProcesses()
364
+ const proc = running.find(p => p.namespace === namespace)
365
+
366
+ if (!proc) {
367
+ console.log(chalk.red(`Namespace '${namespace}' is not running`))
368
+ process.exit(1)
369
+ }
370
+
371
+ console.log(chalk.gray(`Tailing ${proc.logFile} (Ctrl+C to stop)\n`))
372
+ const tail = spawn('tail', ['-f', proc.logFile], { stdio: 'inherit' })
373
+ tail.on('close', () => process.exit(0))
374
+ break
375
+ }
376
+
377
+ default:
378
+ console.log(chalk.bold('\nLoopwork Monitor'))
379
+ console.log(chalk.gray('─'.repeat(30)))
380
+ console.log(`
381
+ Commands:
382
+ ${chalk.cyan('start <namespace> [args...]')} Start a loop in background
383
+ ${chalk.cyan('stop <namespace>')} Stop a running loop
384
+ ${chalk.cyan('stop --all')} Stop all running loops
385
+ ${chalk.cyan('status')} Show status of all loops
386
+ ${chalk.cyan('logs <namespace> [lines]')} Show recent logs
387
+ ${chalk.cyan('tail <namespace>')} Follow logs in real-time
388
+
389
+ Examples:
390
+ bun run src/monitor.ts start default --feature auth
391
+ bun run src/monitor.ts start feature-a --backend github
392
+ bun run src/monitor.ts status
393
+ bun run src/monitor.ts logs feature-a 100
394
+ bun run src/monitor.ts stop --all
395
+ `)
396
+ break
397
+ }
398
+ }
399
+
400
+ function getUptime(startedAt: string): string {
401
+ const start = new Date(startedAt).getTime()
402
+ const now = Date.now()
403
+ const diff = now - start
404
+
405
+ const hours = Math.floor(diff / (1000 * 60 * 60))
406
+ const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
407
+
408
+ if (hours > 0) {
409
+ return `${hours}h ${mins}m`
410
+ }
411
+ return `${mins}m`
412
+ }
413
+
414
+ // Only run main when executed directly, not when imported
415
+ if (import.meta.main) {
416
+ main().catch((err) => {
417
+ console.error(chalk.red(`Error: ${err.message}`))
418
+ process.exit(1)
419
+ })
420
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Asana Plugin for Loopwork
3
+ *
4
+ * Syncs task status with Asana projects.
5
+ * Tasks should have metadata.asanaGid set to the Asana task GID.
6
+ *
7
+ * Setup:
8
+ * 1. Get Personal Access Token from Asana Developer Console
9
+ * 2. Set ASANA_ACCESS_TOKEN env var
10
+ * 3. Set ASANA_PROJECT_ID env var (from project URL)
11
+ * 4. Add asanaGid to task metadata in your tasks file
12
+ */
13
+
14
+ import type { LoopworkPlugin, PluginTask } from '../contracts'
15
+
16
+ export interface AsanaConfig {
17
+ accessToken?: string
18
+ projectId?: string
19
+ workspaceId?: string
20
+ /** Create Asana tasks for new Loopwork tasks */
21
+ autoCreate?: boolean
22
+ /** Sync status changes to Asana */
23
+ syncStatus?: boolean
24
+ }
25
+
26
+ interface AsanaTask {
27
+ gid: string
28
+ name: string
29
+ completed: boolean
30
+ notes?: string
31
+ }
32
+
33
+ interface AsanaResponse<T> {
34
+ data: T
35
+ }
36
+
37
+ export class AsanaClient {
38
+ private baseUrl = 'https://app.asana.com/api/1.0'
39
+ private accessToken: string
40
+
41
+ constructor(accessToken: string) {
42
+ this.accessToken = accessToken
43
+ }
44
+
45
+ private async request<T>(
46
+ method: string,
47
+ endpoint: string,
48
+ body?: Record<string, unknown>
49
+ ): Promise<T> {
50
+ const url = `${this.baseUrl}${endpoint}`
51
+ const response = await fetch(url, {
52
+ method,
53
+ headers: {
54
+ 'Authorization': `Bearer ${this.accessToken}`,
55
+ 'Content-Type': 'application/json',
56
+ },
57
+ body: body ? JSON.stringify({ data: body }) : undefined,
58
+ })
59
+
60
+ if (!response.ok) {
61
+ const error = await response.text()
62
+ throw new Error(`Asana API error: ${response.status} - ${error}`)
63
+ }
64
+
65
+ const result = await response.json() as AsanaResponse<T>
66
+ return result.data
67
+ }
68
+
69
+ async getTask(taskGid: string): Promise<AsanaTask> {
70
+ return this.request('GET', `/tasks/${taskGid}`)
71
+ }
72
+
73
+ async createTask(projectId: string, name: string, notes?: string): Promise<AsanaTask> {
74
+ return this.request('POST', '/tasks', {
75
+ name,
76
+ notes,
77
+ projects: [projectId],
78
+ })
79
+ }
80
+
81
+ async updateTask(taskGid: string, updates: Partial<{ name: string; notes: string; completed: boolean }>): Promise<AsanaTask> {
82
+ return this.request('PUT', `/tasks/${taskGid}`, updates)
83
+ }
84
+
85
+ async completeTask(taskGid: string): Promise<AsanaTask> {
86
+ return this.updateTask(taskGid, { completed: true })
87
+ }
88
+
89
+ async addComment(taskGid: string, text: string): Promise<void> {
90
+ await this.request('POST', `/tasks/${taskGid}/stories`, { text })
91
+ }
92
+
93
+ async getProjectTasks(projectId: string): Promise<AsanaTask[]> {
94
+ return this.request('GET', `/projects/${projectId}/tasks?opt_fields=gid,name,completed,notes`)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Create Asana plugin wrapper
100
+ */
101
+ export function withAsana(config: AsanaConfig = {}) {
102
+ const accessToken = config.accessToken || process.env.ASANA_ACCESS_TOKEN
103
+ const projectId = config.projectId || process.env.ASANA_PROJECT_ID
104
+
105
+ return (baseConfig: any) => ({
106
+ ...baseConfig,
107
+ asana: {
108
+ accessToken,
109
+ projectId,
110
+ autoCreate: config.autoCreate ?? false,
111
+ syncStatus: config.syncStatus ?? true,
112
+ },
113
+ })
114
+ }
115
+
116
+ /** Helper to get Asana GID from task metadata */
117
+ function getAsanaGid(task: PluginTask): string | undefined {
118
+ return task.metadata?.asanaGid as string | undefined
119
+ }
120
+
121
+ /**
122
+ * Create Asana hook plugin
123
+ *
124
+ * Tasks should have metadata.asanaGid set for Asana integration.
125
+ */
126
+ export function createAsanaPlugin(config: AsanaConfig = {}): LoopworkPlugin {
127
+ const accessToken = config.accessToken || process.env.ASANA_ACCESS_TOKEN || ''
128
+ const projectId = config.projectId || process.env.ASANA_PROJECT_ID || ''
129
+
130
+ if (!accessToken || !projectId) {
131
+ return {
132
+ name: 'asana',
133
+ onConfigLoad: (cfg) => {
134
+ console.warn('Asana plugin: Missing ASANA_ACCESS_TOKEN or ASANA_PROJECT_ID')
135
+ return cfg
136
+ },
137
+ }
138
+ }
139
+
140
+ const client = new AsanaClient(accessToken)
141
+
142
+ return {
143
+ name: 'asana',
144
+
145
+ async onTaskStart(task) {
146
+ const asanaGid = getAsanaGid(task)
147
+ if (!asanaGid) return
148
+
149
+ try {
150
+ await client.addComment(asanaGid, `🔄 Loopwork started working on this task`)
151
+ } catch (e: any) {
152
+ console.warn(`Asana: Failed to add comment: ${e.message}`)
153
+ }
154
+ },
155
+
156
+ async onTaskComplete(task, result) {
157
+ const asanaGid = getAsanaGid(task)
158
+ if (!asanaGid) return
159
+
160
+ try {
161
+ if (config.syncStatus !== false) {
162
+ await client.completeTask(asanaGid)
163
+ }
164
+ await client.addComment(
165
+ asanaGid,
166
+ `✅ Completed by Loopwork in ${Math.round(result.duration)}s`
167
+ )
168
+ } catch (e: any) {
169
+ console.warn(`Asana: Failed to update task: ${e.message}`)
170
+ }
171
+ },
172
+
173
+ async onTaskFailed(task, error) {
174
+ const asanaGid = getAsanaGid(task)
175
+ if (!asanaGid) return
176
+
177
+ try {
178
+ await client.addComment(
179
+ asanaGid,
180
+ `❌ Loopwork failed: ${error.slice(0, 200)}`
181
+ )
182
+ } catch (e: any) {
183
+ console.warn(`Asana: Failed to add comment: ${e.message}`)
184
+ }
185
+ },
186
+
187
+ async onLoopEnd(stats) {
188
+ // Could post a summary to a specific task or project
189
+ console.log(`📊 Asana sync: ${stats.completed} tasks synced`)
190
+ },
191
+ }
192
+ }