claude-code-workflow 6.3.29 → 6.3.30

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.
@@ -23,7 +23,7 @@
23
23
  * - POST /api/queue/reorder - Reorder queue items
24
24
  */
25
25
  import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
26
- import { join } from 'path';
26
+ import { join, resolve, normalize } from 'path';
27
27
  import type { RouteContext } from './types.js';
28
28
 
29
29
  // ========== JSONL Helper Functions ==========
@@ -156,7 +156,30 @@ function writeQueue(issuesDir: string, queue: any) {
156
156
 
157
157
  function getIssueDetail(issuesDir: string, issueId: string) {
158
158
  const issues = readIssuesJsonl(issuesDir);
159
- const issue = issues.find(i => i.id === issueId);
159
+ let issue = issues.find(i => i.id === issueId);
160
+
161
+ // Fallback: Reconstruct issue from solution file if issue not in issues.jsonl
162
+ if (!issue) {
163
+ const solutionPath = join(issuesDir, 'solutions', `${issueId}.jsonl`);
164
+ if (existsSync(solutionPath)) {
165
+ const solutions = readSolutionsJsonl(issuesDir, issueId);
166
+ if (solutions.length > 0) {
167
+ const boundSolution = solutions.find(s => s.is_bound) || solutions[0];
168
+ issue = {
169
+ id: issueId,
170
+ title: boundSolution?.description || issueId,
171
+ status: 'completed',
172
+ priority: 3,
173
+ context: boundSolution?.approach || '',
174
+ bound_solution_id: boundSolution?.id || null,
175
+ created_at: boundSolution?.created_at || new Date().toISOString(),
176
+ updated_at: new Date().toISOString(),
177
+ _reconstructed: true
178
+ };
179
+ }
180
+ }
181
+ }
182
+
160
183
  if (!issue) return null;
161
184
 
162
185
  const solutions = readSolutionsJsonl(issuesDir, issueId);
@@ -254,11 +277,46 @@ function bindSolutionToIssue(issuesDir: string, issueId: string, solutionId: str
254
277
  return { success: true, bound: solutionId };
255
278
  }
256
279
 
280
+ // ========== Path Validation ==========
281
+
282
+ /**
283
+ * Validate that the provided path is safe (no path traversal)
284
+ * Returns the resolved, normalized path or null if invalid
285
+ */
286
+ function validateProjectPath(requestedPath: string, basePath: string): string | null {
287
+ if (!requestedPath) return basePath;
288
+
289
+ // Resolve to absolute path and normalize
290
+ const resolvedPath = resolve(normalize(requestedPath));
291
+ const resolvedBase = resolve(normalize(basePath));
292
+
293
+ // For local development tool, we allow any absolute path
294
+ // but prevent obvious traversal attempts
295
+ if (requestedPath.includes('..') && !resolvedPath.startsWith(resolvedBase)) {
296
+ // Check if it's trying to escape with ..
297
+ const normalizedRequested = normalize(requestedPath);
298
+ if (normalizedRequested.startsWith('..')) {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ return resolvedPath;
304
+ }
305
+
257
306
  // ========== Route Handler ==========
258
307
 
259
308
  export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
260
309
  const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
261
- const projectPath = url.searchParams.get('path') || initialPath;
310
+ const rawProjectPath = url.searchParams.get('path') || initialPath;
311
+
312
+ // Validate project path to prevent path traversal
313
+ const projectPath = validateProjectPath(rawProjectPath, initialPath);
314
+ if (!projectPath) {
315
+ res.writeHead(400, { 'Content-Type': 'application/json' });
316
+ res.end(JSON.stringify({ error: 'Invalid project path' }));
317
+ return true;
318
+ }
319
+
262
320
  const issuesDir = join(projectPath, '.workflow', 'issues');
263
321
 
264
322
  // ===== Queue Routes (top-level /api/queue) =====
@@ -295,7 +353,8 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
295
353
 
296
354
  // GET /api/queue/:id - Get specific queue by ID
297
355
  const queueDetailMatch = pathname.match(/^\/api\/queue\/([^/]+)$/);
298
- if (queueDetailMatch && req.method === 'GET' && queueDetailMatch[1] !== 'history' && queueDetailMatch[1] !== 'reorder') {
356
+ const reservedQueuePaths = ['history', 'reorder', 'switch', 'deactivate', 'merge'];
357
+ if (queueDetailMatch && req.method === 'GET' && !reservedQueuePaths.includes(queueDetailMatch[1])) {
299
358
  const queueId = queueDetailMatch[1];
300
359
  const queuesDir = join(issuesDir, 'queues');
301
360
  const queueFilePath = join(queuesDir, `${queueId}.json`);
@@ -347,6 +406,29 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
347
406
  return true;
348
407
  }
349
408
 
409
+ // POST /api/queue/deactivate - Deactivate current queue (set active to null)
410
+ if (pathname === '/api/queue/deactivate' && req.method === 'POST') {
411
+ handlePostRequest(req, res, async (body: any) => {
412
+ const queuesDir = join(issuesDir, 'queues');
413
+ const indexPath = join(queuesDir, 'index.json');
414
+
415
+ try {
416
+ const index = existsSync(indexPath)
417
+ ? JSON.parse(readFileSync(indexPath, 'utf8'))
418
+ : { active_queue_id: null, queues: [] };
419
+
420
+ const previousActiveId = index.active_queue_id;
421
+ index.active_queue_id = null;
422
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
423
+
424
+ return { success: true, previous_active_id: previousActiveId };
425
+ } catch (err) {
426
+ return { error: 'Failed to deactivate queue' };
427
+ }
428
+ });
429
+ return true;
430
+ }
431
+
350
432
  // POST /api/queue/reorder - Reorder queue items (supports both solutions and tasks)
351
433
  if (pathname === '/api/queue/reorder' && req.method === 'POST') {
352
434
  handlePostRequest(req, res, async (body: any) => {
@@ -399,6 +481,195 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
399
481
  return true;
400
482
  }
401
483
 
484
+ // DELETE /api/queue/:queueId/item/:itemId - Delete item from queue
485
+ const queueItemDeleteMatch = pathname.match(/^\/api\/queue\/([^/]+)\/item\/([^/]+)$/);
486
+ if (queueItemDeleteMatch && req.method === 'DELETE') {
487
+ const queueId = queueItemDeleteMatch[1];
488
+ const itemId = decodeURIComponent(queueItemDeleteMatch[2]);
489
+
490
+ const queuesDir = join(issuesDir, 'queues');
491
+ const queueFilePath = join(queuesDir, `${queueId}.json`);
492
+
493
+ if (!existsSync(queueFilePath)) {
494
+ res.writeHead(404, { 'Content-Type': 'application/json' });
495
+ res.end(JSON.stringify({ error: `Queue ${queueId} not found` }));
496
+ return true;
497
+ }
498
+
499
+ try {
500
+ const queue = JSON.parse(readFileSync(queueFilePath, 'utf8'));
501
+ const items = queue.solutions || queue.tasks || [];
502
+ const filteredItems = items.filter((item: any) => item.item_id !== itemId);
503
+
504
+ if (filteredItems.length === items.length) {
505
+ res.writeHead(404, { 'Content-Type': 'application/json' });
506
+ res.end(JSON.stringify({ error: `Item ${itemId} not found in queue` }));
507
+ return true;
508
+ }
509
+
510
+ // Update queue items
511
+ if (queue.solutions) {
512
+ queue.solutions = filteredItems;
513
+ } else {
514
+ queue.tasks = filteredItems;
515
+ }
516
+
517
+ // Recalculate metadata
518
+ const completedCount = filteredItems.filter((i: any) => i.status === 'completed').length;
519
+ queue._metadata = {
520
+ ...queue._metadata,
521
+ updated_at: new Date().toISOString(),
522
+ ...(queue.solutions
523
+ ? { total_solutions: filteredItems.length, completed_solutions: completedCount }
524
+ : { total_tasks: filteredItems.length, completed_tasks: completedCount })
525
+ };
526
+
527
+ writeFileSync(queueFilePath, JSON.stringify(queue, null, 2));
528
+
529
+ // Update index counts
530
+ const indexPath = join(queuesDir, 'index.json');
531
+ if (existsSync(indexPath)) {
532
+ try {
533
+ const index = JSON.parse(readFileSync(indexPath, 'utf8'));
534
+ const queueEntry = index.queues?.find((q: any) => q.id === queueId);
535
+ if (queueEntry) {
536
+ if (queue.solutions) {
537
+ queueEntry.total_solutions = filteredItems.length;
538
+ queueEntry.completed_solutions = completedCount;
539
+ } else {
540
+ queueEntry.total_tasks = filteredItems.length;
541
+ queueEntry.completed_tasks = completedCount;
542
+ }
543
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
544
+ }
545
+ } catch (err) {
546
+ console.error('Failed to update queue index:', err);
547
+ }
548
+ }
549
+
550
+ res.writeHead(200, { 'Content-Type': 'application/json' });
551
+ res.end(JSON.stringify({ success: true, queueId, deletedItemId: itemId }));
552
+ } catch (err) {
553
+ res.writeHead(500, { 'Content-Type': 'application/json' });
554
+ res.end(JSON.stringify({ error: 'Failed to delete item' }));
555
+ }
556
+ return true;
557
+ }
558
+
559
+ // POST /api/queue/merge - Merge source queue into target queue
560
+ if (pathname === '/api/queue/merge' && req.method === 'POST') {
561
+ handlePostRequest(req, res, async (body: any) => {
562
+ const { sourceQueueId, targetQueueId } = body;
563
+ if (!sourceQueueId || !targetQueueId) {
564
+ return { error: 'sourceQueueId and targetQueueId required' };
565
+ }
566
+
567
+ if (sourceQueueId === targetQueueId) {
568
+ return { error: 'Cannot merge queue into itself' };
569
+ }
570
+
571
+ const queuesDir = join(issuesDir, 'queues');
572
+ const sourcePath = join(queuesDir, `${sourceQueueId}.json`);
573
+ const targetPath = join(queuesDir, `${targetQueueId}.json`);
574
+
575
+ if (!existsSync(sourcePath)) return { error: `Source queue ${sourceQueueId} not found` };
576
+ if (!existsSync(targetPath)) return { error: `Target queue ${targetQueueId} not found` };
577
+
578
+ try {
579
+ const sourceQueue = JSON.parse(readFileSync(sourcePath, 'utf8'));
580
+ const targetQueue = JSON.parse(readFileSync(targetPath, 'utf8'));
581
+
582
+ const sourceItems = sourceQueue.solutions || sourceQueue.tasks || [];
583
+ const targetItems = targetQueue.solutions || targetQueue.tasks || [];
584
+ const isSolutionBased = !!targetQueue.solutions;
585
+
586
+ // Re-index source items to avoid ID conflicts
587
+ const maxOrder = targetItems.reduce((max: number, i: any) => Math.max(max, i.execution_order || 0), 0);
588
+ const reindexedSourceItems = sourceItems.map((item: any, idx: number) => ({
589
+ ...item,
590
+ item_id: `${item.item_id}-merged`,
591
+ execution_order: maxOrder + idx + 1,
592
+ execution_group: item.execution_group ? `M-${item.execution_group}` : 'M-ungrouped'
593
+ }));
594
+
595
+ // Merge items
596
+ const mergedItems = [...targetItems, ...reindexedSourceItems];
597
+
598
+ if (isSolutionBased) {
599
+ targetQueue.solutions = mergedItems;
600
+ } else {
601
+ targetQueue.tasks = mergedItems;
602
+ }
603
+
604
+ // Merge issue_ids
605
+ const mergedIssueIds = [...new Set([
606
+ ...(targetQueue.issue_ids || []),
607
+ ...(sourceQueue.issue_ids || [])
608
+ ])];
609
+ targetQueue.issue_ids = mergedIssueIds;
610
+
611
+ // Update metadata
612
+ const completedCount = mergedItems.filter((i: any) => i.status === 'completed').length;
613
+ targetQueue._metadata = {
614
+ ...targetQueue._metadata,
615
+ updated_at: new Date().toISOString(),
616
+ ...(isSolutionBased
617
+ ? { total_solutions: mergedItems.length, completed_solutions: completedCount }
618
+ : { total_tasks: mergedItems.length, completed_tasks: completedCount })
619
+ };
620
+
621
+ // Write merged queue
622
+ writeFileSync(targetPath, JSON.stringify(targetQueue, null, 2));
623
+
624
+ // Update source queue status
625
+ sourceQueue.status = 'merged';
626
+ sourceQueue._metadata = {
627
+ ...sourceQueue._metadata,
628
+ merged_into: targetQueueId,
629
+ merged_at: new Date().toISOString()
630
+ };
631
+ writeFileSync(sourcePath, JSON.stringify(sourceQueue, null, 2));
632
+
633
+ // Update index
634
+ const indexPath = join(queuesDir, 'index.json');
635
+ if (existsSync(indexPath)) {
636
+ try {
637
+ const index = JSON.parse(readFileSync(indexPath, 'utf8'));
638
+ const sourceEntry = index.queues?.find((q: any) => q.id === sourceQueueId);
639
+ const targetEntry = index.queues?.find((q: any) => q.id === targetQueueId);
640
+ if (sourceEntry) {
641
+ sourceEntry.status = 'merged';
642
+ }
643
+ if (targetEntry) {
644
+ if (isSolutionBased) {
645
+ targetEntry.total_solutions = mergedItems.length;
646
+ targetEntry.completed_solutions = completedCount;
647
+ } else {
648
+ targetEntry.total_tasks = mergedItems.length;
649
+ targetEntry.completed_tasks = completedCount;
650
+ }
651
+ targetEntry.issue_ids = mergedIssueIds;
652
+ }
653
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
654
+ } catch {
655
+ // Ignore index update errors
656
+ }
657
+ }
658
+
659
+ return {
660
+ success: true,
661
+ sourceQueueId,
662
+ targetQueueId,
663
+ mergedItemCount: sourceItems.length,
664
+ totalItems: mergedItems.length
665
+ };
666
+ } catch (err) {
667
+ return { error: 'Failed to merge queues' };
668
+ }
669
+ });
670
+ return true;
671
+ }
672
+
402
673
  // Legacy: GET /api/issues/queue (backward compat)
403
674
  if (pathname === '/api/issues/queue' && req.method === 'GET') {
404
675
  const queue = groupQueueByExecutionGroup(readQueue(issuesDir));