claude-code-workflow 6.3.36 → 6.3.37

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 (134) hide show
  1. package/.claude/commands/workflow/lite-fix.md +108 -9
  2. package/.claude/skills/ccw-loop/README.md +303 -0
  3. package/.claude/skills/ccw-loop/SKILL.md +259 -0
  4. package/.claude/skills/ccw-loop/phases/actions/action-complete.md +320 -0
  5. package/.claude/skills/ccw-loop/phases/actions/action-debug-with-file.md +485 -0
  6. package/.claude/skills/ccw-loop/phases/actions/action-develop-with-file.md +365 -0
  7. package/.claude/skills/ccw-loop/phases/actions/action-init.md +200 -0
  8. package/.claude/skills/ccw-loop/phases/actions/action-menu.md +192 -0
  9. package/.claude/skills/ccw-loop/phases/actions/action-validate-with-file.md +307 -0
  10. package/.claude/skills/ccw-loop/phases/orchestrator.md +486 -0
  11. package/.claude/skills/ccw-loop/phases/state-schema.md +474 -0
  12. package/.claude/skills/ccw-loop/specs/action-catalog.md +300 -0
  13. package/.claude/skills/ccw-loop/specs/loop-requirements.md +192 -0
  14. package/.claude/skills/ccw-loop/templates/progress-template.md +175 -0
  15. package/.claude/skills/ccw-loop/templates/understanding-template.md +303 -0
  16. package/.claude/skills/ccw-loop/templates/validation-template.md +258 -0
  17. package/ccw/dist/cli.d.ts.map +1 -1
  18. package/ccw/dist/cli.js +8 -1
  19. package/ccw/dist/cli.js.map +1 -1
  20. package/ccw/dist/commands/cli.d.ts.map +1 -1
  21. package/ccw/dist/commands/cli.js +14 -1
  22. package/ccw/dist/commands/cli.js.map +1 -1
  23. package/ccw/dist/commands/loop.d.ts +10 -0
  24. package/ccw/dist/commands/loop.d.ts.map +1 -0
  25. package/ccw/dist/commands/loop.js +289 -0
  26. package/ccw/dist/commands/loop.js.map +1 -0
  27. package/ccw/dist/core/dashboard-generator.d.ts.map +1 -1
  28. package/ccw/dist/core/dashboard-generator.js +4 -1
  29. package/ccw/dist/core/dashboard-generator.js.map +1 -1
  30. package/ccw/dist/core/routes/claude-routes.d.ts.map +1 -1
  31. package/ccw/dist/core/routes/claude-routes.js +5 -3
  32. package/ccw/dist/core/routes/claude-routes.js.map +1 -1
  33. package/ccw/dist/core/routes/cli-routes.d.ts +6 -0
  34. package/ccw/dist/core/routes/cli-routes.d.ts.map +1 -1
  35. package/ccw/dist/core/routes/cli-routes.js +42 -13
  36. package/ccw/dist/core/routes/cli-routes.js.map +1 -1
  37. package/ccw/dist/core/routes/cli-settings-routes.d.ts.map +1 -1
  38. package/ccw/dist/core/routes/cli-settings-routes.js +44 -0
  39. package/ccw/dist/core/routes/cli-settings-routes.js.map +1 -1
  40. package/ccw/dist/core/routes/codexlens/semantic-handlers.d.ts.map +1 -1
  41. package/ccw/dist/core/routes/codexlens/semantic-handlers.js +3 -2
  42. package/ccw/dist/core/routes/codexlens/semantic-handlers.js.map +1 -1
  43. package/ccw/dist/core/routes/core-memory-routes.d.ts.map +1 -1
  44. package/ccw/dist/core/routes/core-memory-routes.js +4 -2
  45. package/ccw/dist/core/routes/core-memory-routes.js.map +1 -1
  46. package/ccw/dist/core/routes/files-routes.d.ts.map +1 -1
  47. package/ccw/dist/core/routes/files-routes.js +4 -2
  48. package/ccw/dist/core/routes/files-routes.js.map +1 -1
  49. package/ccw/dist/core/routes/loop-routes.d.ts +24 -0
  50. package/ccw/dist/core/routes/loop-routes.d.ts.map +1 -0
  51. package/ccw/dist/core/routes/loop-routes.js +334 -0
  52. package/ccw/dist/core/routes/loop-routes.js.map +1 -0
  53. package/ccw/dist/core/routes/loop-v2-routes.d.ts +35 -0
  54. package/ccw/dist/core/routes/loop-v2-routes.d.ts.map +1 -0
  55. package/ccw/dist/core/routes/loop-v2-routes.js +1208 -0
  56. package/ccw/dist/core/routes/loop-v2-routes.js.map +1 -0
  57. package/ccw/dist/core/routes/memory-routes.d.ts.map +1 -1
  58. package/ccw/dist/core/routes/memory-routes.js +2 -1
  59. package/ccw/dist/core/routes/memory-routes.js.map +1 -1
  60. package/ccw/dist/core/routes/task-routes.d.ts +12 -0
  61. package/ccw/dist/core/routes/task-routes.d.ts.map +1 -0
  62. package/ccw/dist/core/routes/task-routes.js +321 -0
  63. package/ccw/dist/core/routes/task-routes.js.map +1 -0
  64. package/ccw/dist/core/routes/test-loop-routes.d.ts +11 -0
  65. package/ccw/dist/core/routes/test-loop-routes.d.ts.map +1 -0
  66. package/ccw/dist/core/routes/test-loop-routes.js +298 -0
  67. package/ccw/dist/core/routes/test-loop-routes.js.map +1 -0
  68. package/ccw/dist/core/server.d.ts.map +1 -1
  69. package/ccw/dist/core/server.js +43 -3
  70. package/ccw/dist/core/server.js.map +1 -1
  71. package/ccw/dist/core/websocket.d.ts +59 -0
  72. package/ccw/dist/core/websocket.d.ts.map +1 -1
  73. package/ccw/dist/core/websocket.js +34 -0
  74. package/ccw/dist/core/websocket.js.map +1 -1
  75. package/ccw/dist/tools/claude-cli-tools.d.ts +40 -0
  76. package/ccw/dist/tools/claude-cli-tools.d.ts.map +1 -1
  77. package/ccw/dist/tools/claude-cli-tools.js +119 -0
  78. package/ccw/dist/tools/claude-cli-tools.js.map +1 -1
  79. package/ccw/dist/tools/loop-manager.d.ts +84 -0
  80. package/ccw/dist/tools/loop-manager.d.ts.map +1 -0
  81. package/ccw/dist/tools/loop-manager.js +425 -0
  82. package/ccw/dist/tools/loop-manager.js.map +1 -0
  83. package/ccw/dist/tools/loop-state-manager.d.ts +47 -0
  84. package/ccw/dist/tools/loop-state-manager.d.ts.map +1 -0
  85. package/ccw/dist/tools/loop-state-manager.js +149 -0
  86. package/ccw/dist/tools/loop-state-manager.js.map +1 -0
  87. package/ccw/dist/tools/loop-task-manager.d.ts +138 -0
  88. package/ccw/dist/tools/loop-task-manager.d.ts.map +1 -0
  89. package/ccw/dist/tools/loop-task-manager.js +270 -0
  90. package/ccw/dist/tools/loop-task-manager.js.map +1 -0
  91. package/ccw/dist/types/index.d.ts +1 -0
  92. package/ccw/dist/types/index.d.ts.map +1 -1
  93. package/ccw/dist/types/index.js +1 -0
  94. package/ccw/dist/types/index.js.map +1 -1
  95. package/ccw/dist/types/loop.d.ts +257 -0
  96. package/ccw/dist/types/loop.d.ts.map +1 -0
  97. package/ccw/dist/types/loop.js +17 -0
  98. package/ccw/dist/types/loop.js.map +1 -0
  99. package/ccw/src/cli.ts +9 -1
  100. package/ccw/src/commands/cli.ts +14 -1
  101. package/ccw/src/commands/loop.ts +344 -0
  102. package/ccw/src/core/dashboard-generator.ts +4 -1
  103. package/ccw/src/core/routes/claude-routes.ts +5 -3
  104. package/ccw/src/core/routes/cli-routes.ts +47 -15
  105. package/ccw/src/core/routes/cli-settings-routes.ts +47 -0
  106. package/ccw/src/core/routes/codexlens/semantic-handlers.ts +3 -2
  107. package/ccw/src/core/routes/core-memory-routes.ts +4 -2
  108. package/ccw/src/core/routes/files-routes.ts +4 -2
  109. package/ccw/src/core/routes/loop-routes.ts +386 -0
  110. package/ccw/src/core/routes/loop-v2-routes.ts +1412 -0
  111. package/ccw/src/core/routes/memory-routes.ts +2 -1
  112. package/ccw/src/core/routes/task-routes.ts +361 -0
  113. package/ccw/src/core/routes/test-loop-routes.ts +312 -0
  114. package/ccw/src/core/server.ts +44 -3
  115. package/ccw/src/core/websocket.ts +104 -0
  116. package/ccw/src/templates/dashboard-css/12-cli-legacy.css +56 -0
  117. package/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css +55 -0
  118. package/ccw/src/templates/dashboard-css/36-loop-monitor.css +1896 -0
  119. package/ccw/src/templates/dashboard-css/36-loop-monitor.css.backup +1877 -0
  120. package/ccw/src/templates/dashboard-js/components/cli-status.js +64 -3
  121. package/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js +251 -110
  122. package/ccw/src/templates/dashboard-js/components/navigation.js +10 -0
  123. package/ccw/src/templates/dashboard-js/components/notifications.js +16 -0
  124. package/ccw/src/templates/dashboard-js/i18n.js +475 -1
  125. package/ccw/src/templates/dashboard-js/views/cli-manager.js +3 -2
  126. package/ccw/src/templates/dashboard-js/views/loop-monitor.js +3244 -0
  127. package/ccw/src/templates/dashboard.html +20 -2
  128. package/ccw/src/tools/claude-cli-tools.ts +143 -0
  129. package/ccw/src/tools/loop-manager.ts +519 -0
  130. package/ccw/src/tools/loop-state-manager.ts +173 -0
  131. package/ccw/src/tools/loop-task-manager.ts +380 -0
  132. package/ccw/src/types/index.ts +1 -0
  133. package/ccw/src/types/loop.ts +316 -0
  134. package/package.json +1 -1
@@ -0,0 +1,1412 @@
1
+ /**
2
+ * Loop V2 Routes Module
3
+ * CCW Loop System - Simplified HTTP API endpoints for Dashboard
4
+ * Provides simplified loop CRUD operations independent of task files
5
+ *
6
+ * Loop Endpoints:
7
+ * - GET /api/loops/v2 - List all loops with pagination
8
+ * - POST /api/loops/v2 - Create loop with {title, description, max_iterations}
9
+ * - GET /api/loops/v2/:loopId - Get loop details
10
+ * - PUT /api/loops/v2/:loopId - Update loop metadata (title, description, max_iterations, tags, priority, notes)
11
+ * - PATCH /api/loops/v2/:loopId/status - Quick status update with {status}
12
+ * - DELETE /api/loops/v2/:loopId - Delete loop
13
+ * - POST /api/loops/v2/:loopId/start - Start loop execution
14
+ * - POST /api/loops/v2/:loopId/pause - Pause loop
15
+ * - POST /api/loops/v2/:loopId/resume - Resume loop
16
+ * - POST /api/loops/v2/:loopId/stop - Stop loop
17
+ *
18
+ * Task Management Endpoints:
19
+ * - POST /api/loops/v2/:loopId/tasks - Add task to loop
20
+ * - GET /api/loops/v2/:loopId/tasks - List all tasks for loop
21
+ * - PUT /api/loops/v2/tasks/:taskId - Update task (requires loop_id in body)
22
+ * - DELETE /api/loops/v2/tasks/:taskId - Delete task (requires loop_id query param)
23
+ * - PUT /api/loops/v2/:loopId/tasks/reorder - Reorder tasks with {ordered_task_ids: string[]}
24
+ *
25
+ * Advanced Task Features:
26
+ * - POST /api/loops/v2/:loopId/import - Import tasks from issue with {issue_id}
27
+ * - POST /api/loops/v2/:loopId/generate - Generate tasks via Gemini with {tool?, count?}
28
+ */
29
+
30
+ import { join } from 'path';
31
+ import { randomBytes } from 'crypto';
32
+ import type { RouteContext } from './types.js';
33
+ import { LoopStatus } from '../../types/loop.js';
34
+ import type { LoopState } from '../../types/loop.js';
35
+ import { TaskStorageManager, type TaskCreateRequest, type TaskUpdateRequest, type TaskReorderRequest } from '../../tools/loop-task-manager.js';
36
+ import { executeCliTool } from '../../tools/cli-executor.js';
37
+
38
+ /**
39
+ * V2 Loop Create Request
40
+ */
41
+ interface V2LoopCreateRequest {
42
+ title: string;
43
+ description?: string;
44
+ max_iterations?: number;
45
+ }
46
+
47
+ /**
48
+ * V2 Loop Update Request (extended)
49
+ */
50
+ interface V2LoopUpdateRequest {
51
+ // Basic fields
52
+ title?: string;
53
+ description?: string;
54
+ max_iterations?: number;
55
+
56
+ // Extended metadata fields
57
+ tags?: string[];
58
+ priority?: 'low' | 'medium' | 'high';
59
+ notes?: string;
60
+ }
61
+
62
+ /**
63
+ * V2 Loop Storage Format (simplified, independent of task files)
64
+ */
65
+ interface V2LoopStorage {
66
+ loop_id: string;
67
+ title: string;
68
+ description: string;
69
+ max_iterations: number;
70
+ status: LoopStatus;
71
+ current_iteration: number;
72
+ created_at: string;
73
+ updated_at: string;
74
+ completed_at?: string;
75
+ failure_reason?: string;
76
+
77
+ // Extended metadata fields
78
+ tags?: string[];
79
+ priority?: 'low' | 'medium' | 'high';
80
+ notes?: string;
81
+
82
+ // Tasks stored in separate tasks.jsonl file
83
+ }
84
+
85
+ /**
86
+ * Handle V2 loop routes
87
+ * @returns true if route was handled, false otherwise
88
+ */
89
+ export async function handleLoopV2Routes(ctx: RouteContext): Promise<boolean> {
90
+ const { pathname, req, res, initialPath, handlePostRequest, url, broadcastToClients } = ctx;
91
+
92
+ // Get workflow directory from initialPath
93
+ const workflowDir = initialPath || process.cwd();
94
+ const loopDir = join(workflowDir, '.workflow', '.loop');
95
+
96
+ // Helper to broadcast loop state updates
97
+ const broadcastStateUpdate = (loopId: string, status: LoopStatus): void => {
98
+ try {
99
+ broadcastToClients({
100
+ type: 'LOOP_STATE_UPDATE',
101
+ loop_id: loopId,
102
+ status: status as 'created' | 'running' | 'paused' | 'completed' | 'failed',
103
+ updated_at: new Date().toISOString()
104
+ });
105
+ } catch (error) {
106
+ // Silently ignore broadcast errors
107
+ }
108
+ };
109
+
110
+ // Helper to generate loop ID
111
+ const generateLoopId = (): string => {
112
+ const timestamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0];
113
+ const random = randomBytes(4).toString('hex');
114
+ return `loop-v2-${timestamp}-${random}`;
115
+ };
116
+
117
+ // Helper to read loop storage
118
+ const readLoopStorage = async (loopId: string): Promise<V2LoopStorage | null> => {
119
+ const { readFile } = await import('fs/promises');
120
+ const { existsSync } = await import('fs');
121
+ const filePath = join(loopDir, `${loopId}.json`);
122
+
123
+ if (!existsSync(filePath)) {
124
+ return null;
125
+ }
126
+
127
+ try {
128
+ const content = await readFile(filePath, 'utf-8');
129
+ return JSON.parse(content) as V2LoopStorage;
130
+ } catch {
131
+ return null;
132
+ }
133
+ };
134
+
135
+ // Helper to write loop storage
136
+ const writeLoopStorage = async (loop: V2LoopStorage): Promise<void> => {
137
+ const { writeFile, mkdir } = await import('fs/promises');
138
+ const { existsSync } = await import('fs');
139
+
140
+ if (!existsSync(loopDir)) {
141
+ await mkdir(loopDir, { recursive: true });
142
+ }
143
+
144
+ const filePath = join(loopDir, `${loop.loop_id}.json`);
145
+ await writeFile(filePath, JSON.stringify(loop, null, 2), 'utf-8');
146
+ };
147
+
148
+ // Helper to delete loop storage
149
+ const deleteLoopStorage = async (loopId: string): Promise<void> => {
150
+ const { unlink } = await import('fs/promises');
151
+ const { existsSync } = await import('fs');
152
+ const filePath = join(loopDir, `${loopId}.json`);
153
+
154
+ if (existsSync(filePath)) {
155
+ await unlink(filePath);
156
+ }
157
+
158
+ // Also delete tasks.jsonl if exists
159
+ const tasksPath = join(loopDir, `${loopId}.tasks.jsonl`);
160
+ if (existsSync(tasksPath)) {
161
+ await unlink(tasksPath).catch(() => {});
162
+ }
163
+ };
164
+
165
+ // Helper to list all loops
166
+ const listLoops = async (): Promise<V2LoopStorage[]> => {
167
+ const { readdir } = await import('fs/promises');
168
+ const { existsSync } = await import('fs');
169
+
170
+ if (!existsSync(loopDir)) {
171
+ return [];
172
+ }
173
+
174
+ const files = await readdir(loopDir);
175
+ const loopFiles = files.filter(f => f.startsWith('loop-v2-') && f.endsWith('.json'));
176
+
177
+ const loops: V2LoopStorage[] = [];
178
+ for (const file of loopFiles) {
179
+ const loopId = file.replace('.json', '');
180
+ const loop = await readLoopStorage(loopId);
181
+ if (loop) {
182
+ loops.push(loop);
183
+ }
184
+ }
185
+
186
+ return loops;
187
+ };
188
+
189
+ // ==== EXACT PATH ROUTES ====
190
+
191
+ // POST /api/loops/v2 - Create loop with simplified fields
192
+ if (pathname === '/api/loops/v2' && req.method === 'POST') {
193
+ handlePostRequest(req, res, async (body) => {
194
+ const { title, description, max_iterations } = body as V2LoopCreateRequest;
195
+
196
+ // Validation
197
+ if (!title || typeof title !== 'string' || title.trim().length === 0) {
198
+ return { success: false, error: 'title is required and must be non-empty', status: 400 };
199
+ }
200
+
201
+ if (description !== undefined && typeof description !== 'string') {
202
+ return { success: false, error: 'description must be a string', status: 400 };
203
+ }
204
+
205
+ if (max_iterations !== undefined && (typeof max_iterations !== 'number' || max_iterations < 1)) {
206
+ return { success: false, error: 'max_iterations must be a positive number', status: 400 };
207
+ }
208
+
209
+ try {
210
+ const loopId = generateLoopId();
211
+ const now = new Date().toISOString();
212
+
213
+ const loop: V2LoopStorage = {
214
+ loop_id: loopId,
215
+ title: title.trim(),
216
+ description: description?.trim() || '',
217
+ max_iterations: max_iterations || 10,
218
+ status: LoopStatus.CREATED,
219
+ current_iteration: 0,
220
+ created_at: now,
221
+ updated_at: now
222
+ };
223
+
224
+ await writeLoopStorage(loop);
225
+
226
+ // Broadcast creation
227
+ broadcastStateUpdate(loopId, LoopStatus.CREATED);
228
+
229
+ return { success: true, data: loop };
230
+ } catch (error) {
231
+ return { success: false, error: (error as Error).message, status: 500 };
232
+ }
233
+ });
234
+ return true;
235
+ }
236
+
237
+ // GET /api/loops/v2 - List all loops with pagination
238
+ if (pathname === '/api/loops/v2' && req.method === 'GET') {
239
+ try {
240
+ const loops = await listLoops();
241
+
242
+ // Parse query params for pagination and filtering
243
+ const searchParams = url?.searchParams;
244
+ let filteredLoops = loops;
245
+
246
+ // Filter by status
247
+ const statusFilter = searchParams?.get('status');
248
+ if (statusFilter && statusFilter !== 'all') {
249
+ filteredLoops = filteredLoops.filter(l => l.status === statusFilter);
250
+ }
251
+
252
+ // Sort by updated_at (most recent first)
253
+ filteredLoops.sort((a, b) =>
254
+ new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
255
+ );
256
+
257
+ // Parse pagination params
258
+ const limit = parseInt(searchParams?.get('limit') || '50', 10);
259
+ const offset = parseInt(searchParams?.get('offset') || '0', 10);
260
+
261
+ // Apply pagination
262
+ const paginatedLoops = filteredLoops.slice(offset, offset + limit);
263
+
264
+ res.writeHead(200, { 'Content-Type': 'application/json' });
265
+ res.end(JSON.stringify({
266
+ success: true,
267
+ data: paginatedLoops,
268
+ total: filteredLoops.length,
269
+ limit,
270
+ offset,
271
+ hasMore: offset + limit < filteredLoops.length,
272
+ timestamp: new Date().toISOString()
273
+ }));
274
+ return true;
275
+ } catch (error) {
276
+ res.writeHead(500, { 'Content-Type': 'application/json' });
277
+ res.end(JSON.stringify({ success: false, error: (error as Error).message }));
278
+ return true;
279
+ }
280
+ }
281
+
282
+ // ==== NESTED PATH ROUTES (more specific patterns first) ====
283
+
284
+ // POST /api/loops/v2/:loopId/start - Start loop execution
285
+ if (pathname.match(/\/api\/loops\/v2\/[^/]+\/start$/) && req.method === 'POST') {
286
+ const loopId = pathname.split('/').slice(-2)[0];
287
+ if (!loopId || !isValidId(loopId)) {
288
+ res.writeHead(400, { 'Content-Type': 'application/json' });
289
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
290
+ return true;
291
+ }
292
+
293
+ try {
294
+ const loop = await readLoopStorage(loopId);
295
+ if (!loop) {
296
+ res.writeHead(404, { 'Content-Type': 'application/json' });
297
+ res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
298
+ return true;
299
+ }
300
+
301
+ // Can only start created or paused loops
302
+ if (!['created', 'paused'].includes(loop.status.toLowerCase())) {
303
+ res.writeHead(400, { 'Content-Type': 'application/json' });
304
+ res.end(JSON.stringify({
305
+ success: false,
306
+ error: `Cannot start loop with status: ${loop.status}`
307
+ }));
308
+ return true;
309
+ }
310
+
311
+ // Update loop status
312
+ loop.status = LoopStatus.RUNNING;
313
+ loop.updated_at = new Date().toISOString();
314
+ await writeLoopStorage(loop);
315
+
316
+ // Broadcast state update
317
+ broadcastStateUpdate(loopId, LoopStatus.RUNNING);
318
+
319
+ // Trigger ccw-loop skill execution (non-blocking)
320
+ // The skill will check status before each action and exit gracefully on pause/stop
321
+ executeCliTool({
322
+ tool: 'claude',
323
+ prompt: `/ccw-loop --loop-id ${loopId} --auto`,
324
+ mode: 'write',
325
+ workingDir: workflowDir
326
+ }).catch((error) => {
327
+ // Log error but don't fail the start request
328
+ console.error(`Failed to trigger ccw-loop skill for ${loopId}:`, error);
329
+ // Update loop status to failed
330
+ readLoopStorage(loopId).then(async (failedLoop) => {
331
+ if (failedLoop) {
332
+ failedLoop.status = LoopStatus.FAILED;
333
+ failedLoop.failure_reason = `Skill execution failed: ${error.message}`;
334
+ failedLoop.completed_at = new Date().toISOString();
335
+ await writeLoopStorage(failedLoop);
336
+ broadcastStateUpdate(loopId, LoopStatus.FAILED);
337
+ }
338
+ });
339
+ });
340
+
341
+ res.writeHead(200, { 'Content-Type': 'application/json' });
342
+ res.end(JSON.stringify({ success: true, data: loop, message: 'Loop started' }));
343
+ return true;
344
+ } catch (error) {
345
+ res.writeHead(500, { 'Content-Type': 'application/json' });
346
+ res.end(JSON.stringify({ success: false, error: (error as Error).message }));
347
+ return true;
348
+ }
349
+ }
350
+
351
+ // POST /api/loops/v2/:loopId/pause - Pause loop
352
+ if (pathname.match(/\/api\/loops\/v2\/[^/]+\/pause$/) && req.method === 'POST') {
353
+ const loopId = pathname.split('/').slice(-2)[0];
354
+ if (!loopId || !isValidId(loopId)) {
355
+ res.writeHead(400, { 'Content-Type': 'application/json' });
356
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
357
+ return true;
358
+ }
359
+
360
+ try {
361
+ const loop = await readLoopStorage(loopId);
362
+ if (!loop) {
363
+ res.writeHead(404, { 'Content-Type': 'application/json' });
364
+ res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
365
+ return true;
366
+ }
367
+
368
+ // Can only pause running loops
369
+ if (loop.status !== LoopStatus.RUNNING) {
370
+ res.writeHead(400, { 'Content-Type': 'application/json' });
371
+ res.end(JSON.stringify({
372
+ success: false,
373
+ error: `Cannot pause loop with status: ${loop.status}`
374
+ }));
375
+ return true;
376
+ }
377
+
378
+ loop.status = LoopStatus.PAUSED;
379
+ loop.updated_at = new Date().toISOString();
380
+ await writeLoopStorage(loop);
381
+
382
+ broadcastStateUpdate(loopId, LoopStatus.PAUSED);
383
+
384
+ res.writeHead(200, { 'Content-Type': 'application/json' });
385
+ res.end(JSON.stringify({ success: true, data: loop, message: 'Loop paused' }));
386
+ return true;
387
+ } catch (error) {
388
+ res.writeHead(500, { 'Content-Type': 'application/json' });
389
+ res.end(JSON.stringify({ success: false, error: (error as Error).message }));
390
+ return true;
391
+ }
392
+ }
393
+
394
+ // POST /api/loops/v2/:loopId/resume - Resume loop
395
+ if (pathname.match(/\/api\/loops\/v2\/[^/]+\/resume$/) && req.method === 'POST') {
396
+ const loopId = pathname.split('/').slice(-2)[0];
397
+ if (!loopId || !isValidId(loopId)) {
398
+ res.writeHead(400, { 'Content-Type': 'application/json' });
399
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
400
+ return true;
401
+ }
402
+
403
+ try {
404
+ const loop = await readLoopStorage(loopId);
405
+ if (!loop) {
406
+ res.writeHead(404, { 'Content-Type': 'application/json' });
407
+ res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
408
+ return true;
409
+ }
410
+
411
+ // Can only resume paused loops
412
+ if (loop.status !== LoopStatus.PAUSED) {
413
+ res.writeHead(400, { 'Content-Type': 'application/json' });
414
+ res.end(JSON.stringify({
415
+ success: false,
416
+ error: `Cannot resume loop with status: ${loop.status}`
417
+ }));
418
+ return true;
419
+ }
420
+
421
+ loop.status = LoopStatus.RUNNING;
422
+ loop.updated_at = new Date().toISOString();
423
+ await writeLoopStorage(loop);
424
+
425
+ broadcastStateUpdate(loopId, LoopStatus.RUNNING);
426
+
427
+ res.writeHead(200, { 'Content-Type': 'application/json' });
428
+ res.end(JSON.stringify({ success: true, data: loop, message: 'Loop resumed' }));
429
+ return true;
430
+ } catch (error) {
431
+ res.writeHead(500, { 'Content-Type': 'application/json' });
432
+ res.end(JSON.stringify({ success: false, error: (error as Error).message }));
433
+ return true;
434
+ }
435
+ }
436
+
437
+ // POST /api/loops/v2/:loopId/stop - Stop loop
438
+ if (pathname.match(/\/api\/loops\/v2\/[^/]+\/stop$/) && req.method === 'POST') {
439
+ const loopId = pathname.split('/').slice(-2)[0];
440
+ if (!loopId || !isValidId(loopId)) {
441
+ res.writeHead(400, { 'Content-Type': 'application/json' });
442
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
443
+ return true;
444
+ }
445
+
446
+ try {
447
+ const loop = await readLoopStorage(loopId);
448
+ if (!loop) {
449
+ res.writeHead(404, { 'Content-Type': 'application/json' });
450
+ res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
451
+ return true;
452
+ }
453
+
454
+ // Can only stop running or paused loops
455
+ if (![LoopStatus.RUNNING, LoopStatus.PAUSED, LoopStatus.CREATED].includes(loop.status)) {
456
+ res.writeHead(400, { 'Content-Type': 'application/json' });
457
+ res.end(JSON.stringify({
458
+ success: false,
459
+ error: `Cannot stop loop with status: ${loop.status}`
460
+ }));
461
+ return true;
462
+ }
463
+
464
+ loop.status = LoopStatus.FAILED;
465
+ loop.failure_reason = 'Manually stopped by user';
466
+ loop.completed_at = new Date().toISOString();
467
+ loop.updated_at = loop.completed_at;
468
+ await writeLoopStorage(loop);
469
+
470
+ broadcastStateUpdate(loopId, LoopStatus.FAILED);
471
+
472
+ res.writeHead(200, { 'Content-Type': 'application/json' });
473
+ res.end(JSON.stringify({ success: true, data: loop, message: 'Loop stopped' }));
474
+ return true;
475
+ } catch (error) {
476
+ res.writeHead(500, { 'Content-Type': 'application/json' });
477
+ res.end(JSON.stringify({ success: false, error: (error as Error).message }));
478
+ return true;
479
+ }
480
+ }
481
+
482
+ // ==== SINGLE PARAM ROUTES (must come after nested routes) ====
483
+
484
+ // GET /api/loops/v2/:loopId - Get loop details
485
+ if (pathname.match(/^\/api\/loops\/v2\/[^/]+$/) && req.method === 'GET') {
486
+ const loopId = pathname.split('/').pop();
487
+ if (!loopId || !isValidId(loopId)) {
488
+ res.writeHead(400, { 'Content-Type': 'application/json' });
489
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
490
+ return true;
491
+ }
492
+
493
+ try {
494
+ const loop = await readLoopStorage(loopId);
495
+ if (!loop) {
496
+ res.writeHead(404, { 'Content-Type': 'application/json' });
497
+ res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
498
+ return true;
499
+ }
500
+
501
+ res.writeHead(200, { 'Content-Type': 'application/json' });
502
+ res.end(JSON.stringify({ success: true, data: loop }));
503
+ return true;
504
+ } catch (error) {
505
+ res.writeHead(500, { 'Content-Type': 'application/json' });
506
+ res.end(JSON.stringify({ success: false, error: (error as Error).message }));
507
+ return true;
508
+ }
509
+ }
510
+
511
+ // PUT /api/loops/v2/:loopId - Update loop metadata
512
+ if (pathname.match(/^\/api\/loops\/v2\/[^/]+$/) && req.method === 'PUT') {
513
+ const loopId = pathname.split('/').pop();
514
+ if (!loopId || !isValidId(loopId)) {
515
+ res.writeHead(400, { 'Content-Type': 'application/json' });
516
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
517
+ return true;
518
+ }
519
+
520
+ handlePostRequest(req, res, async (body) => {
521
+ const { title, description, max_iterations, tags, priority, notes } = body as V2LoopUpdateRequest;
522
+
523
+ try {
524
+ const loop = await readLoopStorage(loopId);
525
+ if (!loop) {
526
+ return { success: false, error: 'Loop not found', status: 404 };
527
+ }
528
+
529
+ // Can only update created or paused loops
530
+ if (![LoopStatus.CREATED, LoopStatus.PAUSED, LoopStatus.FAILED, LoopStatus.COMPLETED].includes(loop.status)) {
531
+ return { success: false, error: `Cannot update loop with status: ${loop.status}`, status: 400 };
532
+ }
533
+
534
+ // Validate and apply updates
535
+ if (title !== undefined) {
536
+ if (typeof title !== 'string' || title.trim().length === 0) {
537
+ return { success: false, error: 'title must be a non-empty string', status: 400 };
538
+ }
539
+ loop.title = title.trim();
540
+ }
541
+
542
+ if (description !== undefined) {
543
+ if (typeof description !== 'string') {
544
+ return { success: false, error: 'description must be a string', status: 400 };
545
+ }
546
+ loop.description = description.trim();
547
+ }
548
+
549
+ if (max_iterations !== undefined) {
550
+ if (typeof max_iterations !== 'number' || max_iterations < 1) {
551
+ return { success: false, error: 'max_iterations must be a positive number', status: 400 };
552
+ }
553
+ loop.max_iterations = max_iterations;
554
+ }
555
+
556
+ // Extended metadata fields
557
+ if (tags !== undefined) {
558
+ if (!Array.isArray(tags) || !tags.every(t => typeof t === 'string')) {
559
+ return { success: false, error: 'tags must be an array of strings', status: 400 };
560
+ }
561
+ loop.tags = tags;
562
+ }
563
+
564
+ if (priority !== undefined) {
565
+ if (!['low', 'medium', 'high'].includes(priority)) {
566
+ return { success: false, error: 'priority must be one of: low, medium, high', status: 400 };
567
+ }
568
+ loop.priority = priority;
569
+ }
570
+
571
+ if (notes !== undefined) {
572
+ if (typeof notes !== 'string') {
573
+ return { success: false, error: 'notes must be a string', status: 400 };
574
+ }
575
+ loop.notes = notes.trim();
576
+ }
577
+
578
+ loop.updated_at = new Date().toISOString();
579
+ await writeLoopStorage(loop);
580
+
581
+ broadcastStateUpdate(loopId, loop.status);
582
+
583
+ return { success: true, data: loop };
584
+ } catch (error) {
585
+ return { success: false, error: (error as Error).message, status: 500 };
586
+ }
587
+ });
588
+ return true;
589
+ }
590
+
591
+ // PATCH /api/loops/v2/:loopId/status - Quick status update
592
+ if (pathname.match(/\/api\/loops\/v2\/[^/]+\/status$/) && req.method === 'PATCH') {
593
+ const loopId = pathname.split('/').slice(-2)[0];
594
+ if (!loopId || !isValidId(loopId)) {
595
+ res.writeHead(400, { 'Content-Type': 'application/json' });
596
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
597
+ return true;
598
+ }
599
+
600
+ handlePostRequest(req, res, async (body) => {
601
+ const { status } = body as { status?: string };
602
+
603
+ if (!status || typeof status !== 'string') {
604
+ return { success: false, error: 'status is required', status: 400 };
605
+ }
606
+
607
+ if (!Object.values(LoopStatus).includes(status as LoopStatus)) {
608
+ return { success: false, error: `Invalid status: ${status}`, status: 400 };
609
+ }
610
+
611
+ try {
612
+ const loop = await readLoopStorage(loopId);
613
+ if (!loop) {
614
+ return { success: false, error: 'Loop not found', status: 404 };
615
+ }
616
+
617
+ loop.status = status as LoopStatus;
618
+ loop.updated_at = new Date().toISOString();
619
+
620
+ if (status === LoopStatus.COMPLETED && !loop.completed_at) {
621
+ loop.completed_at = new Date().toISOString();
622
+ }
623
+
624
+ await writeLoopStorage(loop);
625
+
626
+ broadcastStateUpdate(loopId, loop.status);
627
+
628
+ return { success: true, data: loop };
629
+ } catch (error) {
630
+ return { success: false, error: (error as Error).message, status: 500 };
631
+ }
632
+ });
633
+ return true;
634
+ }
635
+
636
+ // DELETE /api/loops/v2/:loopId - Delete loop
637
+ if (pathname.match(/^\/api\/loops\/v2\/[^/]+$/) && req.method === 'DELETE') {
638
+ const loopId = pathname.split('/').pop();
639
+ if (!loopId || !isValidId(loopId)) {
640
+ res.writeHead(400, { 'Content-Type': 'application/json' });
641
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
642
+ return true;
643
+ }
644
+
645
+ try {
646
+ const loop = await readLoopStorage(loopId);
647
+ if (!loop) {
648
+ res.writeHead(404, { 'Content-Type': 'application/json' });
649
+ res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
650
+ return true;
651
+ }
652
+
653
+ // Cannot delete running loops
654
+ if (loop.status === LoopStatus.RUNNING) {
655
+ res.writeHead(400, { 'Content-Type': 'application/json' });
656
+ res.end(JSON.stringify({
657
+ success: false,
658
+ error: 'Cannot delete running loop. Stop it first.'
659
+ }));
660
+ return true;
661
+ }
662
+
663
+ await deleteLoopStorage(loopId);
664
+
665
+ // Broadcast deletion
666
+ try {
667
+ broadcastToClients({
668
+ type: 'LOOP_DELETED',
669
+ loop_id: loopId
670
+ });
671
+ } catch {
672
+ // Ignore broadcast errors
673
+ }
674
+
675
+ res.writeHead(200, { 'Content-Type': 'application/json' });
676
+ res.end(JSON.stringify({ success: true, message: 'Loop deleted' }));
677
+ return true;
678
+ } catch (error) {
679
+ res.writeHead(500, { 'Content-Type': 'application/json' });
680
+ res.end(JSON.stringify({ success: false, error: (error as Error).message }));
681
+ return true;
682
+ }
683
+ }
684
+
685
+ // ==== TASK MANAGEMENT ENDPOINTS ====
686
+
687
+ // Helper to create TaskStorageManager instance
688
+ const createTaskManager = (): TaskStorageManager => {
689
+ return new TaskStorageManager(workflowDir);
690
+ };
691
+
692
+ // POST /api/loops/v2/:loopId/tasks - Add task to loop
693
+ if (pathname.match(/\/api\/loops\/v2\/[^/]+\/tasks$/) && req.method === 'POST') {
694
+ const loopId = pathname.split('/').slice(-2)[0];
695
+ if (!loopId || !isValidId(loopId)) {
696
+ res.writeHead(400, { 'Content-Type': 'application/json' });
697
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
698
+ return true;
699
+ }
700
+
701
+ handlePostRequest(req, res, async (body) => {
702
+ const { description, tool, mode, prompt_template, command, on_error } = body as TaskCreateRequest;
703
+
704
+ // Validation
705
+ if (!description || typeof description !== 'string' || description.trim().length === 0) {
706
+ return { success: false, error: 'description is required', status: 400 };
707
+ }
708
+
709
+ if (!tool || typeof tool !== 'string') {
710
+ return { success: false, error: 'tool is required', status: 400 };
711
+ }
712
+
713
+ const validTools = ['bash', 'gemini', 'codex', 'qwen', 'claude'];
714
+ if (!validTools.includes(tool)) {
715
+ return { success: false, error: `tool must be one of: ${validTools.join(', ')}`, status: 400 };
716
+ }
717
+
718
+ if (!mode || typeof mode !== 'string') {
719
+ return { success: false, error: 'mode is required', status: 400 };
720
+ }
721
+
722
+ const validModes = ['analysis', 'write', 'review'];
723
+ if (!validModes.includes(mode)) {
724
+ return { success: false, error: `mode must be one of: ${validModes.join(', ')}`, status: 400 };
725
+ }
726
+
727
+ if (!prompt_template || typeof prompt_template !== 'string' || prompt_template.trim().length === 0) {
728
+ return { success: false, error: 'prompt_template is required', status: 400 };
729
+ }
730
+
731
+ try {
732
+ const taskManager = createTaskManager();
733
+ const task = await taskManager.addTask(loopId, {
734
+ description: description.trim(),
735
+ tool,
736
+ mode,
737
+ prompt_template: prompt_template.trim(),
738
+ command,
739
+ on_error
740
+ });
741
+
742
+ // Broadcast task added
743
+ try {
744
+ broadcastToClients({
745
+ type: 'TASK_ADDED',
746
+ loop_id: loopId,
747
+ task_id: task.task_id,
748
+ task: task
749
+ });
750
+ } catch {
751
+ // Ignore broadcast errors
752
+ }
753
+
754
+ return { success: true, data: task };
755
+ } catch (error) {
756
+ return { success: false, error: (error as Error).message, status: 500 };
757
+ }
758
+ });
759
+ return true;
760
+ }
761
+
762
+ // GET /api/loops/v2/:loopId/tasks - List all tasks for loop
763
+ if (pathname.match(/\/api\/loops\/v2\/[^/]+\/tasks$/) && req.method === 'GET') {
764
+ const loopId = pathname.split('/').slice(-2)[0];
765
+ if (!loopId || !isValidId(loopId)) {
766
+ res.writeHead(400, { 'Content-Type': 'application/json' });
767
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
768
+ return true;
769
+ }
770
+
771
+ try {
772
+ const taskManager = createTaskManager();
773
+ const tasks = await taskManager.getTasks(loopId);
774
+
775
+ // Sort by order
776
+ tasks.sort((a, b) => a.order - b.order);
777
+
778
+ res.writeHead(200, { 'Content-Type': 'application/json' });
779
+ res.end(JSON.stringify({
780
+ success: true,
781
+ data: tasks,
782
+ total: tasks.length,
783
+ loop_id: loopId,
784
+ timestamp: new Date().toISOString()
785
+ }));
786
+ return true;
787
+ } catch (error) {
788
+ res.writeHead(500, { 'Content-Type': 'application/json' });
789
+ res.end(JSON.stringify({ success: false, error: (error as Error).message }));
790
+ return true;
791
+ }
792
+ }
793
+
794
+ // GET /api/loops/v2/tasks/:taskId - Get single task (taskId lookup)
795
+ if (pathname.match(/^\/api\/loops\/v2\/tasks\/[^/]+$/) && req.method === 'GET') {
796
+ const taskId = pathname.split('/').pop();
797
+ if (!taskId || !isValidId(taskId)) {
798
+ res.writeHead(400, { 'Content-Type': 'application/json' });
799
+ res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
800
+ return true;
801
+ }
802
+
803
+ try {
804
+ const taskManager = createTaskManager();
805
+ // Get all loops and search for the task
806
+ const loops = await listLoops();
807
+ let foundTask = null;
808
+ let foundLoopId = null;
809
+
810
+ for (const loop of loops) {
811
+ const loopId = loop.loop_id;
812
+ try {
813
+ const tasks = await taskManager.getTasks(loopId);
814
+ const task = tasks.find(t => t.task_id === taskId);
815
+ if (task) {
816
+ foundTask = task;
817
+ foundLoopId = loopId;
818
+ break;
819
+ }
820
+ } catch {
821
+ continue;
822
+ }
823
+ }
824
+
825
+ if (!foundTask) {
826
+ res.writeHead(404, { 'Content-Type': 'application/json' });
827
+ res.end(JSON.stringify({ success: false, error: 'Task not found' }));
828
+ return true;
829
+ }
830
+
831
+ res.writeHead(200, { 'Content-Type': 'application/json' });
832
+ res.end(JSON.stringify({
833
+ success: true,
834
+ data: { ...foundTask, loop_id: foundLoopId },
835
+ timestamp: new Date().toISOString()
836
+ }));
837
+ return true;
838
+ } catch (error) {
839
+ res.writeHead(500, { 'Content-Type': 'application/json' });
840
+ res.end(JSON.stringify({ success: false, error: (error as Error).message }));
841
+ return true;
842
+ }
843
+ }
844
+
845
+ // PUT /api/loops/v2/tasks/:taskId - Update task (taskId lookup)
846
+ if (pathname.match(/^\/api\/loops\/v2\/tasks\/[^/]+$/) && req.method === 'PUT') {
847
+ const taskId = pathname.split('/').pop();
848
+ if (!taskId || !isValidId(taskId)) {
849
+ res.writeHead(400, { 'Content-Type': 'application/json' });
850
+ res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
851
+ return true;
852
+ }
853
+
854
+ handlePostRequest(req, res, async (body) => {
855
+ const { loop_id, description, tool, mode, prompt_template, command, on_error } = body as TaskUpdateRequest & { loop_id?: string };
856
+
857
+ if (!loop_id || typeof loop_id !== 'string') {
858
+ return { success: false, error: 'loop_id is required', status: 400 };
859
+ }
860
+
861
+ if (!isValidId(loop_id)) {
862
+ return { success: false, error: 'Invalid loop_id format', status: 400 };
863
+ }
864
+
865
+ try {
866
+ const taskManager = createTaskManager();
867
+ const updatedTask = await taskManager.updateTask(loop_id, taskId, {
868
+ description,
869
+ tool,
870
+ mode,
871
+ prompt_template,
872
+ command,
873
+ on_error
874
+ });
875
+
876
+ if (!updatedTask) {
877
+ return { success: false, error: 'Task not found', status: 404 };
878
+ }
879
+
880
+ // Broadcast task updated
881
+ try {
882
+ broadcastToClients({
883
+ type: 'TASK_UPDATED',
884
+ loop_id: loop_id,
885
+ task_id: taskId,
886
+ task: updatedTask
887
+ });
888
+ } catch {
889
+ // Ignore broadcast errors
890
+ }
891
+
892
+ return { success: true, data: updatedTask };
893
+ } catch (error) {
894
+ return { success: false, error: (error as Error).message, status: 500 };
895
+ }
896
+ });
897
+ return true;
898
+ }
899
+
900
+ // DELETE /api/loops/v2/tasks/:taskId - Delete task (taskId lookup)
901
+ if (pathname.match(/^\/api\/loops\/v2\/tasks\/[^/]+$/) && req.method === 'DELETE') {
902
+ const taskId = pathname.split('/').pop();
903
+ if (!taskId || !isValidId(taskId)) {
904
+ res.writeHead(400, { 'Content-Type': 'application/json' });
905
+ res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
906
+ return true;
907
+ }
908
+
909
+ // Get loop_id from query parameter
910
+ const urlObj = new URL(req.url || '', `http://localhost`);
911
+ const loopId = urlObj.searchParams.get('loop_id');
912
+
913
+ if (!loopId || !isValidId(loopId)) {
914
+ res.writeHead(400, { 'Content-Type': 'application/json' });
915
+ res.end(JSON.stringify({ success: false, error: 'loop_id query parameter is required' }));
916
+ return true;
917
+ }
918
+
919
+ try {
920
+ const taskManager = createTaskManager();
921
+ const deleted = await taskManager.deleteTask(loopId, taskId);
922
+
923
+ if (!deleted) {
924
+ res.writeHead(404, { 'Content-Type': 'application/json' });
925
+ res.end(JSON.stringify({ success: false, error: 'Task not found' }));
926
+ return true;
927
+ }
928
+
929
+ // Broadcast task deleted
930
+ try {
931
+ broadcastToClients({
932
+ type: 'TASK_DELETED',
933
+ loop_id: loopId,
934
+ task_id: taskId
935
+ });
936
+ } catch {
937
+ // Ignore broadcast errors
938
+ }
939
+
940
+ res.writeHead(200, { 'Content-Type': 'application/json' });
941
+ res.end(JSON.stringify({ success: true, message: 'Task deleted' }));
942
+ return true;
943
+ } catch (error) {
944
+ res.writeHead(500, { 'Content-Type': 'application/json' });
945
+ res.end(JSON.stringify({ success: false, error: (error as Error).message }));
946
+ return true;
947
+ }
948
+ }
949
+
950
+ // PUT /api/loops/v2/:loopId/tasks/reorder - Reorder tasks
951
+ if (pathname.match(/\/api\/loops\/v2\/[^/]+\/tasks\/reorder$/) && req.method === 'PUT') {
952
+ const loopId = pathname.split('/').slice(-3)[0];
953
+ if (!loopId || !isValidId(loopId)) {
954
+ res.writeHead(400, { 'Content-Type': 'application/json' });
955
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
956
+ return true;
957
+ }
958
+
959
+ handlePostRequest(req, res, async (body) => {
960
+ const { ordered_task_ids } = body as TaskReorderRequest;
961
+
962
+ if (!ordered_task_ids || !Array.isArray(ordered_task_ids)) {
963
+ return { success: false, error: 'ordered_task_ids must be an array', status: 400 };
964
+ }
965
+
966
+ if (ordered_task_ids.length === 0) {
967
+ return { success: false, error: 'ordered_task_ids cannot be empty', status: 400 };
968
+ }
969
+
970
+ try {
971
+ const taskManager = createTaskManager();
972
+ const reorderedTasks = await taskManager.reorderTasks(loopId, { ordered_task_ids });
973
+
974
+ // Broadcast tasks reordered
975
+ try {
976
+ broadcastToClients({
977
+ type: 'TASK_REORDERED',
978
+ loop_id: loopId,
979
+ ordered_task_ids: ordered_task_ids,
980
+ tasks: reorderedTasks
981
+ });
982
+ } catch {
983
+ // Ignore broadcast errors
984
+ }
985
+
986
+ return { success: true, data: reorderedTasks };
987
+ } catch (error) {
988
+ return { success: false, error: (error as Error).message, status: 500 };
989
+ }
990
+ });
991
+ return true;
992
+ }
993
+
994
+ // ==== ADVANCED TASK FEATURES ====
995
+
996
+ // POST /api/loops/v2/:loopId/import - Import tasks from issue
997
+ if (pathname.match(/\/api\/loops\/v2\/[^/]+\/import$/) && req.method === 'POST') {
998
+ const loopId = pathname.split('/').slice(-2)[0];
999
+ if (!loopId || !isValidId(loopId)) {
1000
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1001
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
1002
+ return true;
1003
+ }
1004
+
1005
+ handlePostRequest(req, res, async (body) => {
1006
+ const { issue_id } = body as { issue_id?: string };
1007
+
1008
+ if (!issue_id || typeof issue_id !== 'string') {
1009
+ return { success: false, error: 'issue_id is required', status: 400 };
1010
+ }
1011
+
1012
+ try {
1013
+ // Fetch issue data from issue-manager
1014
+ const { readFile } = await import('fs/promises');
1015
+ const { existsSync } = await import('fs');
1016
+ const issuesDir = join(workflowDir, '.workflow', 'issues');
1017
+ const issuesPath = join(issuesDir, 'issues.jsonl');
1018
+
1019
+ let issueData: any = null;
1020
+
1021
+ // Try reading from active issues
1022
+ if (existsSync(issuesPath)) {
1023
+ const content = await readFile(issuesPath, 'utf-8');
1024
+ const issues = content.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
1025
+ issueData = issues.find((i: any) => i.id === issue_id);
1026
+ }
1027
+
1028
+ // Try reading from history if not found
1029
+ if (!issueData) {
1030
+ const historyPath = join(issuesDir, 'issue-history.jsonl');
1031
+ if (existsSync(historyPath)) {
1032
+ const content = await readFile(historyPath, 'utf-8');
1033
+ const historyIssues = content.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
1034
+ issueData = historyIssues.find((i: any) => i.id === issue_id);
1035
+ }
1036
+ }
1037
+
1038
+ if (!issueData) {
1039
+ return { success: false, error: `Issue ${issue_id} not found`, status: 404 };
1040
+ }
1041
+
1042
+ // Load solutions to get bound solution tasks
1043
+ const solutionsPath = join(issuesDir, 'solutions', `${issue_id}.jsonl`);
1044
+ let tasksToImport: any[] = [];
1045
+
1046
+ if (existsSync(solutionsPath)) {
1047
+ const solutionsContent = await readFile(solutionsPath, 'utf-8');
1048
+ const solutions = solutionsContent.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
1049
+
1050
+ // Get tasks from bound solution
1051
+ const boundSolution = solutions.find((s: any) => s.id === issueData.bound_solution_id) ||
1052
+ solutions.find((s: any) => s.is_bound) ||
1053
+ solutions[0];
1054
+
1055
+ if (boundSolution?.tasks) {
1056
+ tasksToImport = boundSolution.tasks;
1057
+ }
1058
+ }
1059
+
1060
+ if (tasksToImport.length === 0) {
1061
+ return { success: false, error: 'No tasks found in issue. Bind a solution with tasks first.', status: 400 };
1062
+ }
1063
+
1064
+ // Broadcast import start
1065
+ broadcastToClients({
1066
+ type: 'LOOP_TASK_IMPORT_PROGRESS',
1067
+ loop_id: loopId,
1068
+ payload: {
1069
+ stage: 'starting',
1070
+ total: tasksToImport.length,
1071
+ imported: 0
1072
+ }
1073
+ });
1074
+
1075
+ const taskManager = createTaskManager();
1076
+ const createdTasks: any[] = [];
1077
+
1078
+ // Convert issue tasks to loop tasks
1079
+ for (let i = 0; i < tasksToImport.length; i++) {
1080
+ const issueTask = tasksToImport[i];
1081
+
1082
+ // Map issue task fields to loop task fields
1083
+ const taskRequest: TaskCreateRequest = {
1084
+ description: issueTask.description || issueTask.title || `Task ${i + 1}`,
1085
+ tool: mapIssueToolToLoopTool(issueTask.tool) || 'gemini',
1086
+ mode: mapIssueModeToLoopMode(issueTask.mode) || 'write',
1087
+ prompt_template: issueTask.prompt_template || issueTask.prompt || `Execute: ${issueTask.description || issueTask.title}`,
1088
+ command: issueTask.command,
1089
+ on_error: mapIssueOnError(issueTask.on_error)
1090
+ };
1091
+
1092
+ const task = await taskManager.addTask(loopId, taskRequest);
1093
+ createdTasks.push(task);
1094
+
1095
+ // Broadcast progress
1096
+ broadcastToClients({
1097
+ type: 'LOOP_TASK_IMPORT_PROGRESS',
1098
+ loop_id: loopId,
1099
+ payload: {
1100
+ stage: 'importing',
1101
+ total: tasksToImport.length,
1102
+ imported: i + 1,
1103
+ current_task: task
1104
+ }
1105
+ });
1106
+ }
1107
+
1108
+ // Broadcast completion
1109
+ broadcastToClients({
1110
+ type: 'LOOP_TASK_IMPORT_COMPLETE',
1111
+ loop_id: loopId,
1112
+ payload: {
1113
+ total: tasksToImport.length,
1114
+ imported: createdTasks.length,
1115
+ tasks: createdTasks
1116
+ }
1117
+ });
1118
+
1119
+ return {
1120
+ success: true,
1121
+ data: createdTasks,
1122
+ message: `Imported ${createdTasks.length} tasks from issue ${issue_id}`
1123
+ };
1124
+ } catch (error) {
1125
+ return { success: false, error: (error as Error).message, status: 500 };
1126
+ }
1127
+ });
1128
+ return true;
1129
+ }
1130
+
1131
+ // POST /api/loops/v2/:loopId/generate - Generate tasks via Gemini
1132
+ if (pathname.match(/\/api\/loops\/v2\/[^/]+\/generate$/) && req.method === 'POST') {
1133
+ const loopId = pathname.split('/').slice(-2)[0];
1134
+ if (!loopId || !isValidId(loopId)) {
1135
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1136
+ res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
1137
+ return true;
1138
+ }
1139
+
1140
+ handlePostRequest(req, res, async (body) => {
1141
+ const { tool = 'gemini', count } = body as { tool?: string; count?: number };
1142
+
1143
+ try {
1144
+ // Get loop details for context
1145
+ const loop = await readLoopStorage(loopId);
1146
+ if (!loop) {
1147
+ return { success: false, error: 'Loop not found', status: 404 };
1148
+ }
1149
+
1150
+ // Broadcast generation start
1151
+ broadcastToClients({
1152
+ type: 'LOOP_TASK_GENERATION_PROGRESS',
1153
+ loop_id: loopId,
1154
+ payload: {
1155
+ stage: 'analyzing',
1156
+ message: 'Analyzing loop description...'
1157
+ }
1158
+ });
1159
+
1160
+ // Build generation prompt
1161
+ const generatePrompt = `PURPOSE: Generate ${count || 5} specific tasks for loop execution
1162
+ TASK: Analyze the loop description and generate a list of actionable tasks that can be executed via CLI tools. Each task should have clear description, tool selection, mode, and prompt template.
1163
+ MODE: analysis
1164
+ CONTEXT: Loop title: ${loop.title}
1165
+ Loop description: ${loop.description || 'No description provided'}
1166
+ Max iterations: ${loop.max_iterations}
1167
+ EXPECTED: Return a JSON array of tasks with this exact structure:
1168
+ [
1169
+ {
1170
+ "description": "Clear task description",
1171
+ "tool": "gemini|codex|qwen|bash",
1172
+ "mode": "analysis|write|review",
1173
+ "prompt_template": "PURPOSE: ... TASK: ... MODE: analysis CONTEXT: @**/* EXPECTED: ...",
1174
+ "on_error": "continue|pause|fail_fast"
1175
+ }
1176
+ ]
1177
+ CONSTRAINTS: Generate ${count || 5} tasks | Use gemini for AI tasks | Use bash for CLI commands | Include error handling strategy`;
1178
+
1179
+ // Call CLI with gemini to generate tasks
1180
+ let generatedTasks: any[] = [];
1181
+ let outputBuffer = '';
1182
+
1183
+ const result = await executeCliTool({
1184
+ tool: tool === 'codex' || tool === 'qwen' || tool === 'gemini' ? tool : 'gemini',
1185
+ prompt: generatePrompt,
1186
+ mode: 'analysis',
1187
+ format: 'plain',
1188
+ cd: workflowDir,
1189
+ timeout: 120000, // 2 minutes timeout
1190
+ stream: true
1191
+ }, (unit) => {
1192
+ // Collect output
1193
+ outputBuffer += unit.content;
1194
+
1195
+ // Broadcast partial output for progress
1196
+ broadcastToClients({
1197
+ type: 'LOOP_TASK_GENERATION_PROGRESS',
1198
+ loop_id: loopId,
1199
+ payload: {
1200
+ stage: 'generating',
1201
+ message: 'Generating tasks...',
1202
+ output: unit.content
1203
+ }
1204
+ });
1205
+ });
1206
+
1207
+ if (!result.success) {
1208
+ return { success: false, error: 'Failed to generate tasks via CLI', status: 500 };
1209
+ }
1210
+
1211
+ // Parse generated tasks from CLI output
1212
+ generatedTasks = parseGeneratedTasks(outputBuffer);
1213
+
1214
+ if (generatedTasks.length === 0) {
1215
+ return {
1216
+ success: false,
1217
+ error: 'No valid tasks generated. Check CLI output for details.',
1218
+ status: 500,
1219
+ output: outputBuffer
1220
+ };
1221
+ }
1222
+
1223
+ // Broadcast import start
1224
+ broadcastToClients({
1225
+ type: 'LOOP_TASK_GENERATION_PROGRESS',
1226
+ loop_id: loopId,
1227
+ payload: {
1228
+ stage: 'importing',
1229
+ message: `Importing ${generatedTasks.length} generated tasks...`,
1230
+ total: generatedTasks.length,
1231
+ imported: 0
1232
+ }
1233
+ });
1234
+
1235
+ const taskManager = createTaskManager();
1236
+ const createdTasks: any[] = [];
1237
+
1238
+ // Add generated tasks to loop
1239
+ for (let i = 0; i < generatedTasks.length; i++) {
1240
+ const genTask = generatedTasks[i];
1241
+
1242
+ const taskRequest: TaskCreateRequest = {
1243
+ description: genTask.description || `Generated Task ${i + 1}`,
1244
+ tool: validateTool(genTask.tool) ? genTask.tool : 'gemini',
1245
+ mode: validateMode(genTask.mode) ? genTask.mode : 'write',
1246
+ prompt_template: genTask.prompt_template || `Execute task: ${genTask.description}`,
1247
+ command: genTask.command,
1248
+ on_error: validateOnError(genTask.on_error) ? genTask.on_error : 'continue'
1249
+ };
1250
+
1251
+ const task = await taskManager.addTask(loopId, taskRequest);
1252
+ createdTasks.push(task);
1253
+
1254
+ // Broadcast progress
1255
+ broadcastToClients({
1256
+ type: 'LOOP_TASK_GENERATION_PROGRESS',
1257
+ loop_id: loopId,
1258
+ payload: {
1259
+ stage: 'importing',
1260
+ message: `Importing task ${i + 1}/${generatedTasks.length}...`,
1261
+ total: generatedTasks.length,
1262
+ imported: i + 1,
1263
+ current_task: task
1264
+ }
1265
+ });
1266
+ }
1267
+
1268
+ // Broadcast completion
1269
+ broadcastToClients({
1270
+ type: 'LOOP_TASK_GENERATION_COMPLETE',
1271
+ loop_id: loopId,
1272
+ payload: {
1273
+ total: generatedTasks.length,
1274
+ imported: createdTasks.length,
1275
+ tasks: createdTasks
1276
+ }
1277
+ });
1278
+
1279
+ return {
1280
+ success: true,
1281
+ data: createdTasks,
1282
+ message: `Generated and imported ${createdTasks.length} tasks`
1283
+ };
1284
+ } catch (error) {
1285
+ return { success: false, error: (error as Error).message, status: 500 };
1286
+ }
1287
+ });
1288
+ return true;
1289
+ }
1290
+
1291
+ return false;
1292
+ }
1293
+
1294
+ /**
1295
+ * Sanitize ID parameter to prevent path traversal attacks
1296
+ * @returns true if valid, false if invalid
1297
+ */
1298
+ function isValidId(id: string): boolean {
1299
+ if (!id) return false;
1300
+ // Block path traversal attempts and null bytes
1301
+ if (id.includes('/') || id.includes('\\') || id === '..' || id === '.') return false;
1302
+ if (id.includes('\0')) return false;
1303
+ return true;
1304
+ }
1305
+
1306
+ /**
1307
+ * Map issue tool to loop tool
1308
+ */
1309
+ function mapIssueToolToLoopTool(tool: any): 'bash' | 'gemini' | 'codex' | 'qwen' | 'claude' | null {
1310
+ const validTools = ['bash', 'gemini', 'codex', 'qwen', 'claude'];
1311
+ if (validTools.includes(tool)) return tool as any;
1312
+ // Map aliases
1313
+ if (tool === 'ccw') return 'gemini';
1314
+ if (tool === 'ai') return 'gemini';
1315
+ return null;
1316
+ }
1317
+
1318
+ /**
1319
+ * Map issue mode to loop mode
1320
+ */
1321
+ function mapIssueModeToLoopMode(mode: any): 'analysis' | 'write' | 'review' | null {
1322
+ const validModes = ['analysis', 'write', 'review'];
1323
+ if (validModes.includes(mode)) return mode as any;
1324
+ // Map aliases
1325
+ if (mode === 'read') return 'analysis';
1326
+ if (mode === 'create' || mode === 'modify') return 'write';
1327
+ return null;
1328
+ }
1329
+
1330
+ /**
1331
+ * Map issue on_error value
1332
+ */
1333
+ function mapIssueOnError(onError: any): 'continue' | 'pause' | 'fail_fast' | undefined {
1334
+ const validValues = ['continue', 'pause', 'fail_fast'];
1335
+ if (validValues.includes(onError)) return onError as any;
1336
+ // Map aliases
1337
+ if (onError === 'stop') return 'pause';
1338
+ if (onError === 'abort') return 'fail_fast';
1339
+ return undefined;
1340
+ }
1341
+
1342
+ /**
1343
+ * Validate tool value
1344
+ */
1345
+ function validateTool(tool: any): boolean {
1346
+ const validTools = ['bash', 'gemini', 'codex', 'qwen', 'claude'];
1347
+ return validTools.includes(tool);
1348
+ }
1349
+
1350
+ /**
1351
+ * Validate mode value
1352
+ */
1353
+ function validateMode(mode: any): boolean {
1354
+ const validModes = ['analysis', 'write', 'review'];
1355
+ return validModes.includes(mode);
1356
+ }
1357
+
1358
+ /**
1359
+ * Validate on_error value
1360
+ */
1361
+ function validateOnError(onError: any): boolean {
1362
+ const validValues = ['continue', 'pause', 'fail_fast'];
1363
+ return validValues.includes(onError);
1364
+ }
1365
+
1366
+ /**
1367
+ * Parse generated tasks from CLI output
1368
+ * Extracts JSON array from output, handles various response formats
1369
+ */
1370
+ function parseGeneratedTasks(output: string): any[] {
1371
+ let tasks: any[] = [];
1372
+
1373
+ // Try to find JSON array in output
1374
+ const jsonMatch = output.match(/\[[\s\S]*\]/);
1375
+ if (jsonMatch) {
1376
+ try {
1377
+ tasks = JSON.parse(jsonMatch[0]);
1378
+ } catch {
1379
+ // Invalid JSON, try alternative parsing
1380
+ }
1381
+ }
1382
+
1383
+ // If no valid JSON array found, try parsing line by line
1384
+ if (tasks.length === 0) {
1385
+ const lines = output.split('\n');
1386
+ for (const line of lines) {
1387
+ const trimmed = line.trim();
1388
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
1389
+ try {
1390
+ tasks.push(JSON.parse(trimmed));
1391
+ } catch {
1392
+ // Skip invalid lines
1393
+ }
1394
+ }
1395
+ }
1396
+ }
1397
+
1398
+ // Filter and validate task objects
1399
+ return tasks.filter(t =>
1400
+ t &&
1401
+ typeof t === 'object' &&
1402
+ (t.description || t.title || t.task) &&
1403
+ (t.tool || t.mode || t.prompt_template)
1404
+ ).map(t => ({
1405
+ description: t.description || t.title || t.task || 'Untitled task',
1406
+ tool: t.tool || 'gemini',
1407
+ mode: t.mode || 'write',
1408
+ prompt_template: t.prompt_template || t.prompt || `Execute: ${t.description || t.title || t.task}`,
1409
+ command: t.command,
1410
+ on_error: t.on_error || 'continue'
1411
+ }));
1412
+ }