claude-code-workflow 6.3.29 → 6.3.31

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 ==========
@@ -67,6 +67,12 @@ function readIssueHistoryJsonl(issuesDir: string): any[] {
67
67
  }
68
68
  }
69
69
 
70
+ function writeIssueHistoryJsonl(issuesDir: string, issues: any[]) {
71
+ if (!existsSync(issuesDir)) mkdirSync(issuesDir, { recursive: true });
72
+ const historyPath = join(issuesDir, 'issue-history.jsonl');
73
+ writeFileSync(historyPath, issues.map(i => JSON.stringify(i)).join('\n'));
74
+ }
75
+
70
76
  function writeSolutionsJsonl(issuesDir: string, issueId: string, solutions: any[]) {
71
77
  const solutionsDir = join(issuesDir, 'solutions');
72
78
  if (!existsSync(solutionsDir)) mkdirSync(solutionsDir, { recursive: true });
@@ -156,7 +162,30 @@ function writeQueue(issuesDir: string, queue: any) {
156
162
 
157
163
  function getIssueDetail(issuesDir: string, issueId: string) {
158
164
  const issues = readIssuesJsonl(issuesDir);
159
- const issue = issues.find(i => i.id === issueId);
165
+ let issue = issues.find(i => i.id === issueId);
166
+
167
+ // Fallback: Reconstruct issue from solution file if issue not in issues.jsonl
168
+ if (!issue) {
169
+ const solutionPath = join(issuesDir, 'solutions', `${issueId}.jsonl`);
170
+ if (existsSync(solutionPath)) {
171
+ const solutions = readSolutionsJsonl(issuesDir, issueId);
172
+ if (solutions.length > 0) {
173
+ const boundSolution = solutions.find(s => s.is_bound) || solutions[0];
174
+ issue = {
175
+ id: issueId,
176
+ title: boundSolution?.description || issueId,
177
+ status: 'completed',
178
+ priority: 3,
179
+ context: boundSolution?.approach || '',
180
+ bound_solution_id: boundSolution?.id || null,
181
+ created_at: boundSolution?.created_at || new Date().toISOString(),
182
+ updated_at: new Date().toISOString(),
183
+ _reconstructed: true
184
+ };
185
+ }
186
+ }
187
+ }
188
+
160
189
  if (!issue) return null;
161
190
 
162
191
  const solutions = readSolutionsJsonl(issuesDir, issueId);
@@ -254,11 +283,46 @@ function bindSolutionToIssue(issuesDir: string, issueId: string, solutionId: str
254
283
  return { success: true, bound: solutionId };
255
284
  }
256
285
 
286
+ // ========== Path Validation ==========
287
+
288
+ /**
289
+ * Validate that the provided path is safe (no path traversal)
290
+ * Returns the resolved, normalized path or null if invalid
291
+ */
292
+ function validateProjectPath(requestedPath: string, basePath: string): string | null {
293
+ if (!requestedPath) return basePath;
294
+
295
+ // Resolve to absolute path and normalize
296
+ const resolvedPath = resolve(normalize(requestedPath));
297
+ const resolvedBase = resolve(normalize(basePath));
298
+
299
+ // For local development tool, we allow any absolute path
300
+ // but prevent obvious traversal attempts
301
+ if (requestedPath.includes('..') && !resolvedPath.startsWith(resolvedBase)) {
302
+ // Check if it's trying to escape with ..
303
+ const normalizedRequested = normalize(requestedPath);
304
+ if (normalizedRequested.startsWith('..')) {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ return resolvedPath;
310
+ }
311
+
257
312
  // ========== Route Handler ==========
258
313
 
259
314
  export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
260
315
  const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
261
- const projectPath = url.searchParams.get('path') || initialPath;
316
+ const rawProjectPath = url.searchParams.get('path') || initialPath;
317
+
318
+ // Validate project path to prevent path traversal
319
+ const projectPath = validateProjectPath(rawProjectPath, initialPath);
320
+ if (!projectPath) {
321
+ res.writeHead(400, { 'Content-Type': 'application/json' });
322
+ res.end(JSON.stringify({ error: 'Invalid project path' }));
323
+ return true;
324
+ }
325
+
262
326
  const issuesDir = join(projectPath, '.workflow', 'issues');
263
327
 
264
328
  // ===== Queue Routes (top-level /api/queue) =====
@@ -295,7 +359,8 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
295
359
 
296
360
  // GET /api/queue/:id - Get specific queue by ID
297
361
  const queueDetailMatch = pathname.match(/^\/api\/queue\/([^/]+)$/);
298
- if (queueDetailMatch && req.method === 'GET' && queueDetailMatch[1] !== 'history' && queueDetailMatch[1] !== 'reorder') {
362
+ const reservedQueuePaths = ['history', 'reorder', 'switch', 'deactivate', 'merge'];
363
+ if (queueDetailMatch && req.method === 'GET' && !reservedQueuePaths.includes(queueDetailMatch[1])) {
299
364
  const queueId = queueDetailMatch[1];
300
365
  const queuesDir = join(issuesDir, 'queues');
301
366
  const queueFilePath = join(queuesDir, `${queueId}.json`);
@@ -347,6 +412,29 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
347
412
  return true;
348
413
  }
349
414
 
415
+ // POST /api/queue/deactivate - Deactivate current queue (set active to null)
416
+ if (pathname === '/api/queue/deactivate' && req.method === 'POST') {
417
+ handlePostRequest(req, res, async (body: any) => {
418
+ const queuesDir = join(issuesDir, 'queues');
419
+ const indexPath = join(queuesDir, 'index.json');
420
+
421
+ try {
422
+ const index = existsSync(indexPath)
423
+ ? JSON.parse(readFileSync(indexPath, 'utf8'))
424
+ : { active_queue_id: null, queues: [] };
425
+
426
+ const previousActiveId = index.active_queue_id;
427
+ index.active_queue_id = null;
428
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
429
+
430
+ return { success: true, previous_active_id: previousActiveId };
431
+ } catch (err) {
432
+ return { error: 'Failed to deactivate queue' };
433
+ }
434
+ });
435
+ return true;
436
+ }
437
+
350
438
  // POST /api/queue/reorder - Reorder queue items (supports both solutions and tasks)
351
439
  if (pathname === '/api/queue/reorder' && req.method === 'POST') {
352
440
  handlePostRequest(req, res, async (body: any) => {
@@ -399,6 +487,237 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
399
487
  return true;
400
488
  }
401
489
 
490
+ // DELETE /api/queue/:queueId/item/:itemId - Delete item from queue
491
+ const queueItemDeleteMatch = pathname.match(/^\/api\/queue\/([^/]+)\/item\/([^/]+)$/);
492
+ if (queueItemDeleteMatch && req.method === 'DELETE') {
493
+ const queueId = queueItemDeleteMatch[1];
494
+ const itemId = decodeURIComponent(queueItemDeleteMatch[2]);
495
+
496
+ const queuesDir = join(issuesDir, 'queues');
497
+ const queueFilePath = join(queuesDir, `${queueId}.json`);
498
+
499
+ if (!existsSync(queueFilePath)) {
500
+ res.writeHead(404, { 'Content-Type': 'application/json' });
501
+ res.end(JSON.stringify({ error: `Queue ${queueId} not found` }));
502
+ return true;
503
+ }
504
+
505
+ try {
506
+ const queue = JSON.parse(readFileSync(queueFilePath, 'utf8'));
507
+ const items = queue.solutions || queue.tasks || [];
508
+ const filteredItems = items.filter((item: any) => item.item_id !== itemId);
509
+
510
+ if (filteredItems.length === items.length) {
511
+ res.writeHead(404, { 'Content-Type': 'application/json' });
512
+ res.end(JSON.stringify({ error: `Item ${itemId} not found in queue` }));
513
+ return true;
514
+ }
515
+
516
+ // Update queue items
517
+ if (queue.solutions) {
518
+ queue.solutions = filteredItems;
519
+ } else {
520
+ queue.tasks = filteredItems;
521
+ }
522
+
523
+ // Recalculate metadata
524
+ const completedCount = filteredItems.filter((i: any) => i.status === 'completed').length;
525
+ queue._metadata = {
526
+ ...queue._metadata,
527
+ updated_at: new Date().toISOString(),
528
+ ...(queue.solutions
529
+ ? { total_solutions: filteredItems.length, completed_solutions: completedCount }
530
+ : { total_tasks: filteredItems.length, completed_tasks: completedCount })
531
+ };
532
+
533
+ writeFileSync(queueFilePath, JSON.stringify(queue, null, 2));
534
+
535
+ // Update index counts
536
+ const indexPath = join(queuesDir, 'index.json');
537
+ if (existsSync(indexPath)) {
538
+ try {
539
+ const index = JSON.parse(readFileSync(indexPath, 'utf8'));
540
+ const queueEntry = index.queues?.find((q: any) => q.id === queueId);
541
+ if (queueEntry) {
542
+ if (queue.solutions) {
543
+ queueEntry.total_solutions = filteredItems.length;
544
+ queueEntry.completed_solutions = completedCount;
545
+ } else {
546
+ queueEntry.total_tasks = filteredItems.length;
547
+ queueEntry.completed_tasks = completedCount;
548
+ }
549
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
550
+ }
551
+ } catch (err) {
552
+ console.error('Failed to update queue index:', err);
553
+ }
554
+ }
555
+
556
+ res.writeHead(200, { 'Content-Type': 'application/json' });
557
+ res.end(JSON.stringify({ success: true, queueId, deletedItemId: itemId }));
558
+ } catch (err) {
559
+ res.writeHead(500, { 'Content-Type': 'application/json' });
560
+ res.end(JSON.stringify({ error: 'Failed to delete item' }));
561
+ }
562
+ return true;
563
+ }
564
+
565
+ // DELETE /api/queue/:queueId - Delete entire queue
566
+ const queueDeleteMatch = pathname.match(/^\/api\/queue\/([^/]+)$/);
567
+ if (queueDeleteMatch && req.method === 'DELETE') {
568
+ const queueId = queueDeleteMatch[1];
569
+ const queuesDir = join(issuesDir, 'queues');
570
+ const queueFilePath = join(queuesDir, `${queueId}.json`);
571
+ const indexPath = join(queuesDir, 'index.json');
572
+
573
+ if (!existsSync(queueFilePath)) {
574
+ res.writeHead(404, { 'Content-Type': 'application/json' });
575
+ res.end(JSON.stringify({ error: `Queue ${queueId} not found` }));
576
+ return true;
577
+ }
578
+
579
+ try {
580
+ // Delete queue file
581
+ unlinkSync(queueFilePath);
582
+
583
+ // Update index
584
+ if (existsSync(indexPath)) {
585
+ const index = JSON.parse(readFileSync(indexPath, 'utf8'));
586
+
587
+ // Remove from queues array
588
+ index.queues = (index.queues || []).filter((q: any) => q.id !== queueId);
589
+
590
+ // Clear active if this was the active queue
591
+ if (index.active_queue_id === queueId) {
592
+ index.active_queue_id = null;
593
+ }
594
+
595
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
596
+ }
597
+
598
+ res.writeHead(200, { 'Content-Type': 'application/json' });
599
+ res.end(JSON.stringify({ success: true, deletedQueueId: queueId }));
600
+ } catch (err) {
601
+ res.writeHead(500, { 'Content-Type': 'application/json' });
602
+ res.end(JSON.stringify({ error: 'Failed to delete queue' }));
603
+ }
604
+ return true;
605
+ }
606
+
607
+ // POST /api/queue/merge - Merge source queue into target queue
608
+ if (pathname === '/api/queue/merge' && req.method === 'POST') {
609
+ handlePostRequest(req, res, async (body: any) => {
610
+ const { sourceQueueId, targetQueueId } = body;
611
+ if (!sourceQueueId || !targetQueueId) {
612
+ return { error: 'sourceQueueId and targetQueueId required' };
613
+ }
614
+
615
+ if (sourceQueueId === targetQueueId) {
616
+ return { error: 'Cannot merge queue into itself' };
617
+ }
618
+
619
+ const queuesDir = join(issuesDir, 'queues');
620
+ const sourcePath = join(queuesDir, `${sourceQueueId}.json`);
621
+ const targetPath = join(queuesDir, `${targetQueueId}.json`);
622
+
623
+ if (!existsSync(sourcePath)) return { error: `Source queue ${sourceQueueId} not found` };
624
+ if (!existsSync(targetPath)) return { error: `Target queue ${targetQueueId} not found` };
625
+
626
+ try {
627
+ const sourceQueue = JSON.parse(readFileSync(sourcePath, 'utf8'));
628
+ const targetQueue = JSON.parse(readFileSync(targetPath, 'utf8'));
629
+
630
+ const sourceItems = sourceQueue.solutions || sourceQueue.tasks || [];
631
+ const targetItems = targetQueue.solutions || targetQueue.tasks || [];
632
+ const isSolutionBased = !!targetQueue.solutions;
633
+
634
+ // Re-index source items to avoid ID conflicts
635
+ const maxOrder = targetItems.reduce((max: number, i: any) => Math.max(max, i.execution_order || 0), 0);
636
+ const reindexedSourceItems = sourceItems.map((item: any, idx: number) => ({
637
+ ...item,
638
+ item_id: `${item.item_id}-merged`,
639
+ execution_order: maxOrder + idx + 1,
640
+ execution_group: item.execution_group ? `M-${item.execution_group}` : 'M-ungrouped'
641
+ }));
642
+
643
+ // Merge items
644
+ const mergedItems = [...targetItems, ...reindexedSourceItems];
645
+
646
+ if (isSolutionBased) {
647
+ targetQueue.solutions = mergedItems;
648
+ } else {
649
+ targetQueue.tasks = mergedItems;
650
+ }
651
+
652
+ // Merge issue_ids
653
+ const mergedIssueIds = [...new Set([
654
+ ...(targetQueue.issue_ids || []),
655
+ ...(sourceQueue.issue_ids || [])
656
+ ])];
657
+ targetQueue.issue_ids = mergedIssueIds;
658
+
659
+ // Update metadata
660
+ const completedCount = mergedItems.filter((i: any) => i.status === 'completed').length;
661
+ targetQueue._metadata = {
662
+ ...targetQueue._metadata,
663
+ updated_at: new Date().toISOString(),
664
+ ...(isSolutionBased
665
+ ? { total_solutions: mergedItems.length, completed_solutions: completedCount }
666
+ : { total_tasks: mergedItems.length, completed_tasks: completedCount })
667
+ };
668
+
669
+ // Write merged queue
670
+ writeFileSync(targetPath, JSON.stringify(targetQueue, null, 2));
671
+
672
+ // Update source queue status
673
+ sourceQueue.status = 'merged';
674
+ sourceQueue._metadata = {
675
+ ...sourceQueue._metadata,
676
+ merged_into: targetQueueId,
677
+ merged_at: new Date().toISOString()
678
+ };
679
+ writeFileSync(sourcePath, JSON.stringify(sourceQueue, null, 2));
680
+
681
+ // Update index
682
+ const indexPath = join(queuesDir, 'index.json');
683
+ if (existsSync(indexPath)) {
684
+ try {
685
+ const index = JSON.parse(readFileSync(indexPath, 'utf8'));
686
+ const sourceEntry = index.queues?.find((q: any) => q.id === sourceQueueId);
687
+ const targetEntry = index.queues?.find((q: any) => q.id === targetQueueId);
688
+ if (sourceEntry) {
689
+ sourceEntry.status = 'merged';
690
+ }
691
+ if (targetEntry) {
692
+ if (isSolutionBased) {
693
+ targetEntry.total_solutions = mergedItems.length;
694
+ targetEntry.completed_solutions = completedCount;
695
+ } else {
696
+ targetEntry.total_tasks = mergedItems.length;
697
+ targetEntry.completed_tasks = completedCount;
698
+ }
699
+ targetEntry.issue_ids = mergedIssueIds;
700
+ }
701
+ writeFileSync(indexPath, JSON.stringify(index, null, 2));
702
+ } catch {
703
+ // Ignore index update errors
704
+ }
705
+ }
706
+
707
+ return {
708
+ success: true,
709
+ sourceQueueId,
710
+ targetQueueId,
711
+ mergedItemCount: sourceItems.length,
712
+ totalItems: mergedItems.length
713
+ };
714
+ } catch (err) {
715
+ return { error: 'Failed to merge queues' };
716
+ }
717
+ });
718
+ return true;
719
+ }
720
+
402
721
  // Legacy: GET /api/issues/queue (backward compat)
403
722
  if (pathname === '/api/issues/queue' && req.method === 'GET') {
404
723
  const queue = groupQueueByExecutionGroup(readQueue(issuesDir));
@@ -546,6 +865,39 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
546
865
  return true;
547
866
  }
548
867
 
868
+ // POST /api/issues/:id/archive - Archive issue (move to history)
869
+ const archiveMatch = pathname.match(/^\/api\/issues\/([^/]+)\/archive$/);
870
+ if (archiveMatch && req.method === 'POST') {
871
+ const issueId = decodeURIComponent(archiveMatch[1]);
872
+
873
+ const issues = readIssuesJsonl(issuesDir);
874
+ const issueIndex = issues.findIndex(i => i.id === issueId);
875
+
876
+ if (issueIndex === -1) {
877
+ res.writeHead(404, { 'Content-Type': 'application/json' });
878
+ res.end(JSON.stringify({ error: 'Issue not found' }));
879
+ return true;
880
+ }
881
+
882
+ // Get the issue and add archive metadata
883
+ const issue = issues[issueIndex];
884
+ issue.archived_at = new Date().toISOString();
885
+ issue.status = 'completed';
886
+
887
+ // Move to history
888
+ const history = readIssueHistoryJsonl(issuesDir);
889
+ history.push(issue);
890
+ writeIssueHistoryJsonl(issuesDir, history);
891
+
892
+ // Remove from active issues
893
+ issues.splice(issueIndex, 1);
894
+ writeIssuesJsonl(issuesDir, issues);
895
+
896
+ res.writeHead(200, { 'Content-Type': 'application/json' });
897
+ res.end(JSON.stringify({ success: true, issueId, archivedAt: issue.archived_at }));
898
+ return true;
899
+ }
900
+
549
901
  // POST /api/issues/:id/solutions - Add solution
550
902
  const addSolMatch = pathname.match(/^\/api\/issues\/([^/]+)\/solutions$/);
551
903
  if (addSolMatch && req.method === 'POST') {