@backlog-md/core 0.2.2 → 0.3.1

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 (39) hide show
  1. package/README.md +14 -14
  2. package/dist/abstractions/index.d.ts +2 -2
  3. package/dist/abstractions/index.d.ts.map +1 -1
  4. package/dist/core/Core.d.ts +151 -1
  5. package/dist/core/Core.d.ts.map +1 -1
  6. package/dist/core/Core.js +672 -21
  7. package/dist/core/Core.js.map +1 -1
  8. package/dist/core/index.d.ts +1 -1
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js +1 -1
  11. package/dist/core/index.js.map +1 -1
  12. package/dist/index.d.ts +5 -6
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +7 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/markdown/index.d.ts +56 -1
  17. package/dist/markdown/index.d.ts.map +1 -1
  18. package/dist/markdown/index.js +179 -5
  19. package/dist/markdown/index.js.map +1 -1
  20. package/dist/test-adapters/MockGitAdapter.d.ts +1 -1
  21. package/dist/test-adapters/MockGitAdapter.d.ts.map +1 -1
  22. package/dist/test-adapters/index.d.ts +5 -4
  23. package/dist/test-adapters/index.d.ts.map +1 -1
  24. package/dist/test-adapters/index.js +4 -6
  25. package/dist/test-adapters/index.js.map +1 -1
  26. package/dist/types/index.d.ts +52 -0
  27. package/dist/types/index.d.ts.map +1 -1
  28. package/dist/utils/index.d.ts +2 -1
  29. package/dist/utils/index.d.ts.map +1 -1
  30. package/dist/utils/index.js +2 -1
  31. package/dist/utils/index.js.map +1 -1
  32. package/dist/utils/milestones.d.ts +65 -0
  33. package/dist/utils/milestones.d.ts.map +1 -0
  34. package/dist/utils/milestones.js +187 -0
  35. package/dist/utils/milestones.js.map +1 -0
  36. package/dist/utils/sorting.d.ts.map +1 -1
  37. package/dist/utils/sorting.js +2 -3
  38. package/dist/utils/sorting.js.map +1 -1
  39. package/package.json +10 -18
package/dist/core/Core.js CHANGED
@@ -4,9 +4,9 @@
4
4
  * Provides a runtime-agnostic API for managing Backlog.md projects
5
5
  * by accepting adapter implementations for I/O operations.
6
6
  */
7
+ import { extractMilestoneIdFromFilename, extractTaskIndexFromPath, getMilestoneFilename, parseMilestoneMarkdown, parseTaskMarkdown, serializeMilestoneMarkdown, serializeTaskMarkdown, } from "../markdown";
8
+ import { groupTasksByMilestone, groupTasksByStatus, milestoneKey, sortTasks, sortTasksBy, } from "../utils";
7
9
  import { parseBacklogConfig, serializeBacklogConfig } from "./config-parser";
8
- import { parseTaskMarkdown, extractTaskIndexFromPath } from "../markdown";
9
- import { sortTasks, sortTasksBy, groupTasksByStatus } from "../utils";
10
10
  /**
11
11
  * Core class for Backlog.md operations
12
12
  *
@@ -243,10 +243,14 @@ export class Core {
243
243
  entries = entries.sort((a, b) => {
244
244
  const aNum = parseInt(a.id.replace(/\D/g, ""), 10) || 0;
245
245
  const bNum = parseInt(b.id.replace(/\D/g, ""), 10) || 0;
246
- // Completed: descending (most recent first), Active: ascending
247
- return source === "completed" && completedSortByIdDesc
248
- ? bNum - aNum
249
- : aNum - bNum;
246
+ if (source === "completed") {
247
+ // Completed: use completedSortByIdDesc option
248
+ return completedSortByIdDesc ? bNum - aNum : aNum - bNum;
249
+ }
250
+ else {
251
+ // Tasks: use tasksSortDirection option
252
+ return tasksSortDirection === "desc" ? bNum - aNum : aNum - bNum;
253
+ }
250
254
  });
251
255
  const total = entries.length;
252
256
  const pageEntries = entries.slice(offset, offset + limit);
@@ -286,10 +290,14 @@ export class Core {
286
290
  entries = entries.sort((a, b) => {
287
291
  const aNum = parseInt(a.id.replace(/\D/g, ""), 10) || 0;
288
292
  const bNum = parseInt(b.id.replace(/\D/g, ""), 10) || 0;
289
- // Completed: descending (most recent first), Active: ascending
290
- return source === "completed" && completedSortByIdDesc
291
- ? bNum - aNum
292
- : aNum - bNum;
293
+ if (source === "completed") {
294
+ // Completed: use completedSortByIdDesc option
295
+ return completedSortByIdDesc ? bNum - aNum : aNum - bNum;
296
+ }
297
+ else {
298
+ // Tasks: use sortDirection option
299
+ return sortDirection === "desc" ? bNum - aNum : aNum - bNum;
300
+ }
293
301
  });
294
302
  const total = entries.length;
295
303
  const pageEntries = entries.slice(currentOffset, currentOffset + limit);
@@ -310,7 +318,7 @@ export class Core {
310
318
  */
311
319
  getConfig() {
312
320
  this.ensureInitialized();
313
- return this.config;
321
+ return this.safeConfig;
314
322
  }
315
323
  /**
316
324
  * List all tasks, optionally filtered
@@ -325,13 +333,18 @@ export class Core {
325
333
  tasks = tasks.filter((t) => t.status === filter.status);
326
334
  }
327
335
  if (filter?.assignee) {
328
- tasks = tasks.filter((t) => t.assignee.includes(filter.assignee));
336
+ const assignee = filter.assignee;
337
+ tasks = tasks.filter((t) => t.assignee.includes(assignee));
329
338
  }
330
339
  if (filter?.priority) {
331
340
  tasks = tasks.filter((t) => t.priority === filter.priority);
332
341
  }
342
+ if (filter?.milestone) {
343
+ const filterKey = milestoneKey(filter.milestone);
344
+ tasks = tasks.filter((t) => milestoneKey(t.milestone) === filterKey);
345
+ }
333
346
  if (filter?.labels && filter.labels.length > 0) {
334
- tasks = tasks.filter((t) => filter.labels.some((label) => t.labels.includes(label)));
347
+ tasks = tasks.filter((t) => filter.labels?.some((label) => t.labels.includes(label)));
335
348
  }
336
349
  if (filter?.parentTaskId) {
337
350
  tasks = tasks.filter((t) => t.parentTaskId === filter.parentTaskId);
@@ -348,7 +361,232 @@ export class Core {
348
361
  getTasksByStatus() {
349
362
  this.ensureInitialized();
350
363
  const tasks = Array.from(this.tasks.values());
351
- return groupTasksByStatus(tasks, this.config.statuses);
364
+ return groupTasksByStatus(tasks, this.safeConfig.statuses);
365
+ }
366
+ /**
367
+ * Get tasks grouped by milestone
368
+ *
369
+ * Returns a MilestoneSummary with:
370
+ * - milestones: List of milestone IDs in display order
371
+ * - buckets: Array of MilestoneBucket with tasks, progress, status counts
372
+ *
373
+ * The first bucket is always "Tasks without milestone".
374
+ * Each bucket includes progress percentage based on done status.
375
+ *
376
+ * @example
377
+ * ```typescript
378
+ * const summary = core.getTasksByMilestone();
379
+ * for (const bucket of summary.buckets) {
380
+ * console.log(`${bucket.label}: ${bucket.progress}% complete`);
381
+ * console.log(` ${bucket.doneCount}/${bucket.total} tasks done`);
382
+ * }
383
+ * ```
384
+ */
385
+ getTasksByMilestone() {
386
+ this.ensureInitialized();
387
+ const tasks = Array.from(this.tasks.values());
388
+ return groupTasksByMilestone(tasks, this.safeConfig.milestones, this.safeConfig.statuses);
389
+ }
390
+ // =========================================================================
391
+ // Milestone CRUD Operations
392
+ // =========================================================================
393
+ /**
394
+ * Get the milestones directory path
395
+ */
396
+ getMilestonesDir() {
397
+ return this.fs.join(this.projectRoot, "backlog", "milestones");
398
+ }
399
+ /**
400
+ * List all milestones from the milestones directory
401
+ *
402
+ * @returns Array of Milestone objects sorted by ID
403
+ */
404
+ async listMilestones() {
405
+ const milestonesDir = this.getMilestonesDir();
406
+ // Check if directory exists
407
+ if (!(await this.fs.exists(milestonesDir))) {
408
+ return [];
409
+ }
410
+ const entries = await this.fs.readDir(milestonesDir);
411
+ const milestones = [];
412
+ for (const entry of entries) {
413
+ // Skip non-milestone files
414
+ if (!entry.endsWith(".md"))
415
+ continue;
416
+ if (entry.toLowerCase() === "readme.md")
417
+ continue;
418
+ const milestoneId = extractMilestoneIdFromFilename(entry);
419
+ if (!milestoneId)
420
+ continue;
421
+ const filepath = this.fs.join(milestonesDir, entry);
422
+ // Skip directories
423
+ if (await this.fs.isDirectory(filepath))
424
+ continue;
425
+ try {
426
+ const content = await this.fs.readFile(filepath);
427
+ milestones.push(parseMilestoneMarkdown(content));
428
+ }
429
+ catch (error) {
430
+ console.warn(`Failed to parse milestone file ${filepath}:`, error);
431
+ }
432
+ }
433
+ // Sort by ID for consistent ordering
434
+ return milestones.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
435
+ }
436
+ /**
437
+ * Load a single milestone by ID
438
+ *
439
+ * @param id - Milestone ID (e.g., "m-0")
440
+ * @returns Milestone or null if not found
441
+ */
442
+ async loadMilestone(id) {
443
+ const milestonesDir = this.getMilestonesDir();
444
+ if (!(await this.fs.exists(milestonesDir))) {
445
+ return null;
446
+ }
447
+ const entries = await this.fs.readDir(milestonesDir);
448
+ // Find file matching the ID
449
+ const milestoneFile = entries.find((entry) => {
450
+ const fileId = extractMilestoneIdFromFilename(entry);
451
+ return fileId === id;
452
+ });
453
+ if (!milestoneFile) {
454
+ return null;
455
+ }
456
+ const filepath = this.fs.join(milestonesDir, milestoneFile);
457
+ try {
458
+ const content = await this.fs.readFile(filepath);
459
+ return parseMilestoneMarkdown(content);
460
+ }
461
+ catch {
462
+ return null;
463
+ }
464
+ }
465
+ /**
466
+ * Create a new milestone
467
+ *
468
+ * @param input - Milestone creation input
469
+ * @returns Created milestone
470
+ */
471
+ async createMilestone(input) {
472
+ const milestonesDir = this.getMilestonesDir();
473
+ // Ensure milestones directory exists
474
+ await this.fs.createDir(milestonesDir, { recursive: true });
475
+ // Find next available milestone ID
476
+ const entries = await this.fs.readDir(milestonesDir).catch(() => []);
477
+ const existingIds = entries
478
+ .map((f) => {
479
+ const match = f.match(/^m-(\d+)/);
480
+ return match?.[1] ? parseInt(match[1], 10) : -1;
481
+ })
482
+ .filter((id) => id >= 0);
483
+ const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 0;
484
+ const id = `m-${nextId}`;
485
+ const description = input.description || `Milestone: ${input.title}`;
486
+ // Create a temporary milestone to generate content
487
+ const tempMilestone = {
488
+ id,
489
+ title: input.title,
490
+ description,
491
+ rawContent: "",
492
+ tasks: [],
493
+ };
494
+ // Generate content
495
+ const content = serializeMilestoneMarkdown(tempMilestone);
496
+ // Create the final milestone with correct rawContent
497
+ const milestone = {
498
+ id,
499
+ title: input.title,
500
+ description,
501
+ rawContent: content,
502
+ tasks: [],
503
+ };
504
+ // Write file
505
+ const filename = getMilestoneFilename(id, input.title);
506
+ const filepath = this.fs.join(milestonesDir, filename);
507
+ await this.fs.writeFile(filepath, content);
508
+ return milestone;
509
+ }
510
+ /**
511
+ * Update an existing milestone
512
+ *
513
+ * @param id - Milestone ID to update
514
+ * @param input - Fields to update
515
+ * @returns Updated milestone or null if not found
516
+ */
517
+ async updateMilestone(id, input) {
518
+ const existing = await this.loadMilestone(id);
519
+ if (!existing) {
520
+ return null;
521
+ }
522
+ const milestonesDir = this.getMilestonesDir();
523
+ const entries = await this.fs.readDir(milestonesDir);
524
+ // Find the current file
525
+ const currentFile = entries.find((entry) => {
526
+ const fileId = extractMilestoneIdFromFilename(entry);
527
+ return fileId === id;
528
+ });
529
+ if (!currentFile) {
530
+ return null;
531
+ }
532
+ // Build updated values
533
+ const newTitle = input.title ?? existing.title;
534
+ const newDescription = input.description ?? existing.description;
535
+ // Create a temporary milestone to generate content
536
+ const tempMilestone = {
537
+ id: existing.id,
538
+ title: newTitle,
539
+ description: newDescription,
540
+ rawContent: "",
541
+ tasks: existing.tasks,
542
+ };
543
+ // Generate new content
544
+ const content = serializeMilestoneMarkdown(tempMilestone);
545
+ // Create the final updated milestone
546
+ const updated = {
547
+ id: existing.id,
548
+ title: newTitle,
549
+ description: newDescription,
550
+ rawContent: content,
551
+ tasks: existing.tasks,
552
+ };
553
+ // Delete old file
554
+ const oldPath = this.fs.join(milestonesDir, currentFile);
555
+ await this.fs.deleteFile(oldPath);
556
+ // Write new file (with potentially new filename if title changed)
557
+ const newFilename = getMilestoneFilename(id, updated.title);
558
+ const newPath = this.fs.join(milestonesDir, newFilename);
559
+ await this.fs.writeFile(newPath, content);
560
+ return updated;
561
+ }
562
+ /**
563
+ * Delete a milestone
564
+ *
565
+ * @param id - Milestone ID to delete
566
+ * @returns true if deleted, false if not found
567
+ */
568
+ async deleteMilestone(id) {
569
+ const milestonesDir = this.getMilestonesDir();
570
+ if (!(await this.fs.exists(milestonesDir))) {
571
+ return false;
572
+ }
573
+ const entries = await this.fs.readDir(milestonesDir);
574
+ // Find file matching the ID
575
+ const milestoneFile = entries.find((entry) => {
576
+ const fileId = extractMilestoneIdFromFilename(entry);
577
+ return fileId === id;
578
+ });
579
+ if (!milestoneFile) {
580
+ return false;
581
+ }
582
+ const filepath = this.fs.join(milestonesDir, milestoneFile);
583
+ try {
584
+ await this.fs.deleteFile(filepath);
585
+ return true;
586
+ }
587
+ catch {
588
+ return false;
589
+ }
352
590
  }
353
591
  /**
354
592
  * Get a single task by ID
@@ -403,7 +641,7 @@ export class Core {
403
641
  const byStatus = new Map();
404
642
  // Group all tasks by status first (without sorting)
405
643
  const allGrouped = new Map();
406
- for (const status of this.config.statuses) {
644
+ for (const status of this.safeConfig.statuses) {
407
645
  allGrouped.set(status, []);
408
646
  }
409
647
  for (const task of this.tasks.values()) {
@@ -416,7 +654,7 @@ export class Core {
416
654
  }
417
655
  }
418
656
  // Paginate each status column
419
- for (const status of this.config.statuses) {
657
+ for (const status of this.safeConfig.statuses) {
420
658
  let tasks = allGrouped.get(status) ?? [];
421
659
  tasks = sortTasksBy(tasks, sortBy, sortDirection);
422
660
  const total = tasks.length;
@@ -431,7 +669,7 @@ export class Core {
431
669
  }
432
670
  return {
433
671
  byStatus,
434
- statuses: this.config.statuses,
672
+ statuses: this.safeConfig.statuses,
435
673
  };
436
674
  }
437
675
  /**
@@ -471,10 +709,16 @@ export class Core {
471
709
  }
472
710
  // --- Private methods ---
473
711
  ensureInitialized() {
474
- if (!this.initialized) {
475
- throw new Error("Core not initialized. Call initialize() first.");
712
+ if ((!this.initialized && !this.lazyInitialized) || !this.config) {
713
+ throw new Error("Core not initialized. Call initialize() or initializeLazy() first.");
476
714
  }
477
715
  }
716
+ /**
717
+ * Get config with type safety (use after ensureInitialized)
718
+ */
719
+ get safeConfig() {
720
+ return this.config;
721
+ }
478
722
  applyFilters(tasks, filter) {
479
723
  if (!filter)
480
724
  return tasks;
@@ -483,13 +727,18 @@ export class Core {
483
727
  result = result.filter((t) => t.status === filter.status);
484
728
  }
485
729
  if (filter.assignee) {
486
- result = result.filter((t) => t.assignee.includes(filter.assignee));
730
+ const assignee = filter.assignee;
731
+ result = result.filter((t) => t.assignee.includes(assignee));
487
732
  }
488
733
  if (filter.priority) {
489
734
  result = result.filter((t) => t.priority === filter.priority);
490
735
  }
736
+ if (filter.milestone) {
737
+ const filterKey = milestoneKey(filter.milestone);
738
+ result = result.filter((t) => milestoneKey(t.milestone) === filterKey);
739
+ }
491
740
  if (filter.labels && filter.labels.length > 0) {
492
- result = result.filter((t) => filter.labels.some((label) => t.labels.includes(label)));
741
+ result = result.filter((t) => filter.labels?.some((label) => t.labels.includes(label)));
493
742
  }
494
743
  if (filter.parentTaskId) {
495
744
  result = result.filter((t) => t.parentTaskId === filter.parentTaskId);
@@ -521,5 +770,407 @@ export class Core {
521
770
  }
522
771
  }
523
772
  }
773
+ // =========================================================================
774
+ // Milestone-Task Sync Helpers
775
+ // =========================================================================
776
+ /**
777
+ * Add a task ID to a milestone's tasks array
778
+ *
779
+ * @param taskId - Task ID to add
780
+ * @param milestoneId - Milestone ID to update
781
+ */
782
+ async addTaskToMilestone(taskId, milestoneId) {
783
+ const milestone = await this.loadMilestone(milestoneId);
784
+ if (!milestone) {
785
+ console.warn(`Milestone ${milestoneId} not found when adding task ${taskId}`);
786
+ return;
787
+ }
788
+ // Check if task already in milestone
789
+ if (milestone.tasks.includes(taskId)) {
790
+ return;
791
+ }
792
+ // Update milestone with new task
793
+ const updatedMilestone = {
794
+ ...milestone,
795
+ tasks: [...milestone.tasks, taskId],
796
+ };
797
+ await this.writeMilestoneFile(updatedMilestone);
798
+ }
799
+ /**
800
+ * Remove a task ID from a milestone's tasks array
801
+ *
802
+ * @param taskId - Task ID to remove
803
+ * @param milestoneId - Milestone ID to update
804
+ */
805
+ async removeTaskFromMilestone(taskId, milestoneId) {
806
+ const milestone = await this.loadMilestone(milestoneId);
807
+ if (!milestone) {
808
+ return;
809
+ }
810
+ // Check if task is in milestone
811
+ if (!milestone.tasks.includes(taskId)) {
812
+ return;
813
+ }
814
+ // Update milestone without the task
815
+ const updatedMilestone = {
816
+ ...milestone,
817
+ tasks: milestone.tasks.filter((id) => id !== taskId),
818
+ };
819
+ await this.writeMilestoneFile(updatedMilestone);
820
+ }
821
+ /**
822
+ * Write a milestone to disk
823
+ */
824
+ async writeMilestoneFile(milestone) {
825
+ const milestonesDir = this.getMilestonesDir();
826
+ const entries = await this.fs.readDir(milestonesDir).catch(() => []);
827
+ // Find and delete the current file
828
+ const currentFile = entries.find((entry) => {
829
+ const fileId = extractMilestoneIdFromFilename(entry);
830
+ return fileId === milestone.id;
831
+ });
832
+ if (currentFile) {
833
+ const oldPath = this.fs.join(milestonesDir, currentFile);
834
+ await this.fs.deleteFile(oldPath).catch(() => { });
835
+ }
836
+ // Write new file
837
+ const content = serializeMilestoneMarkdown(milestone);
838
+ const filename = getMilestoneFilename(milestone.id, milestone.title);
839
+ const filepath = this.fs.join(milestonesDir, filename);
840
+ await this.fs.writeFile(filepath, content);
841
+ }
842
+ /**
843
+ * Get the tasks directory path
844
+ */
845
+ getTasksDir() {
846
+ return this.fs.join(this.projectRoot, "backlog", "tasks");
847
+ }
848
+ /**
849
+ * Get the completed directory path
850
+ */
851
+ getCompletedDir() {
852
+ return this.fs.join(this.projectRoot, "backlog", "completed");
853
+ }
854
+ // =========================================================================
855
+ // Task CRUD Operations
856
+ // =========================================================================
857
+ /**
858
+ * Create a new task
859
+ *
860
+ * @param input - Task creation input
861
+ * @returns Created task
862
+ */
863
+ async createTask(input) {
864
+ this.ensureInitialized();
865
+ const tasksDir = this.getTasksDir();
866
+ // Ensure tasks directory exists
867
+ await this.fs.createDir(tasksDir, { recursive: true });
868
+ // Generate next task ID
869
+ const existingIds = Array.from(this.tasks.keys())
870
+ .map((id) => parseInt(id.replace(/\D/g, ""), 10))
871
+ .filter((n) => !Number.isNaN(n));
872
+ const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
873
+ const taskId = String(nextId);
874
+ // Build task object
875
+ const now = new Date().toISOString().split("T")[0];
876
+ const task = {
877
+ id: taskId,
878
+ title: input.title,
879
+ status: input.status || this.config?.defaultStatus || "To Do",
880
+ priority: input.priority,
881
+ assignee: input.assignee || [],
882
+ createdDate: now,
883
+ labels: input.labels || [],
884
+ milestone: input.milestone,
885
+ dependencies: input.dependencies || [],
886
+ parentTaskId: input.parentTaskId,
887
+ description: input.description,
888
+ implementationPlan: input.implementationPlan,
889
+ implementationNotes: input.implementationNotes,
890
+ acceptanceCriteriaItems: input.acceptanceCriteria?.map((ac, i) => ({
891
+ index: i + 1,
892
+ text: ac.text,
893
+ checked: ac.checked || false,
894
+ })),
895
+ rawContent: input.rawContent,
896
+ source: "local",
897
+ };
898
+ // Serialize and write file
899
+ const content = serializeTaskMarkdown(task);
900
+ const safeTitle = input.title
901
+ .replace(/[<>:"/\\|?*]/g, "")
902
+ .replace(/\s+/g, " ")
903
+ .slice(0, 50);
904
+ const filename = `${taskId} - ${safeTitle}.md`;
905
+ const filepath = this.fs.join(tasksDir, filename);
906
+ await this.fs.writeFile(filepath, content);
907
+ // Update in-memory cache
908
+ task.filePath = filepath;
909
+ this.tasks.set(taskId, task);
910
+ // Sync milestone if specified
911
+ if (input.milestone) {
912
+ await this.addTaskToMilestone(taskId, input.milestone);
913
+ }
914
+ return task;
915
+ }
916
+ /**
917
+ * Update an existing task
918
+ *
919
+ * @param id - Task ID to update
920
+ * @param input - Fields to update
921
+ * @returns Updated task or null if not found
922
+ */
923
+ async updateTask(id, input) {
924
+ this.ensureInitialized();
925
+ const existing = this.tasks.get(id);
926
+ if (!existing) {
927
+ return null;
928
+ }
929
+ const oldMilestone = existing.milestone;
930
+ const newMilestone = input.milestone === null
931
+ ? undefined
932
+ : input.milestone !== undefined
933
+ ? input.milestone
934
+ : oldMilestone;
935
+ // Build updated task
936
+ const now = new Date().toISOString().split("T")[0];
937
+ const updated = {
938
+ ...existing,
939
+ title: input.title ?? existing.title,
940
+ status: input.status ?? existing.status,
941
+ priority: input.priority ?? existing.priority,
942
+ milestone: newMilestone,
943
+ updatedDate: now,
944
+ description: input.description ?? existing.description,
945
+ implementationPlan: input.clearImplementationPlan
946
+ ? undefined
947
+ : (input.implementationPlan ?? existing.implementationPlan),
948
+ implementationNotes: input.clearImplementationNotes
949
+ ? undefined
950
+ : (input.implementationNotes ?? existing.implementationNotes),
951
+ ordinal: input.ordinal ?? existing.ordinal,
952
+ dependencies: input.dependencies ?? existing.dependencies,
953
+ };
954
+ // Handle label operations
955
+ if (input.labels) {
956
+ updated.labels = input.labels;
957
+ }
958
+ else {
959
+ if (input.addLabels) {
960
+ updated.labels = [...new Set([...updated.labels, ...input.addLabels])];
961
+ }
962
+ if (input.removeLabels) {
963
+ updated.labels = updated.labels.filter((l) => !input.removeLabels?.includes(l));
964
+ }
965
+ }
966
+ // Handle assignee
967
+ if (input.assignee) {
968
+ updated.assignee = input.assignee;
969
+ }
970
+ // Handle dependency operations
971
+ if (input.addDependencies) {
972
+ updated.dependencies = [...new Set([...updated.dependencies, ...input.addDependencies])];
973
+ }
974
+ if (input.removeDependencies) {
975
+ updated.dependencies = updated.dependencies.filter((d) => !input.removeDependencies?.includes(d));
976
+ }
977
+ // Handle acceptance criteria
978
+ if (input.acceptanceCriteria) {
979
+ updated.acceptanceCriteriaItems = input.acceptanceCriteria.map((ac, i) => ({
980
+ index: i + 1,
981
+ text: ac.text,
982
+ checked: ac.checked || false,
983
+ }));
984
+ }
985
+ // Serialize and write file
986
+ const content = serializeTaskMarkdown(updated);
987
+ // Delete old file if exists
988
+ if (existing.filePath) {
989
+ await this.fs.deleteFile(existing.filePath).catch(() => { });
990
+ }
991
+ // Write new file
992
+ const tasksDir = this.getTasksDir();
993
+ const safeTitle = updated.title
994
+ .replace(/[<>:"/\\|?*]/g, "")
995
+ .replace(/\s+/g, " ")
996
+ .slice(0, 50);
997
+ const filename = `${id} - ${safeTitle}.md`;
998
+ const filepath = this.fs.join(tasksDir, filename);
999
+ await this.fs.writeFile(filepath, content);
1000
+ // Update in-memory cache
1001
+ updated.filePath = filepath;
1002
+ this.tasks.set(id, updated);
1003
+ // Handle milestone sync
1004
+ const milestoneChanged = milestoneKey(oldMilestone) !== milestoneKey(newMilestone);
1005
+ if (milestoneChanged) {
1006
+ // Remove from old milestone
1007
+ if (oldMilestone) {
1008
+ await this.removeTaskFromMilestone(id, oldMilestone);
1009
+ }
1010
+ // Add to new milestone
1011
+ if (newMilestone) {
1012
+ await this.addTaskToMilestone(id, newMilestone);
1013
+ }
1014
+ }
1015
+ return updated;
1016
+ }
1017
+ /**
1018
+ * Delete a task
1019
+ *
1020
+ * @param id - Task ID to delete
1021
+ * @returns true if deleted, false if not found
1022
+ */
1023
+ async deleteTask(id) {
1024
+ this.ensureInitialized();
1025
+ const task = this.tasks.get(id);
1026
+ if (!task) {
1027
+ return false;
1028
+ }
1029
+ // Remove from milestone if assigned
1030
+ if (task.milestone) {
1031
+ await this.removeTaskFromMilestone(id, task.milestone);
1032
+ }
1033
+ // Delete file
1034
+ if (task.filePath) {
1035
+ try {
1036
+ await this.fs.deleteFile(task.filePath);
1037
+ }
1038
+ catch {
1039
+ // File may already be deleted
1040
+ }
1041
+ }
1042
+ // Remove from in-memory cache
1043
+ this.tasks.delete(id);
1044
+ return true;
1045
+ }
1046
+ /**
1047
+ * Archive a task (move from tasks/ to completed/)
1048
+ *
1049
+ * @param id - Task ID to archive
1050
+ * @returns Archived task or null if not found or already archived
1051
+ */
1052
+ async archiveTask(id) {
1053
+ this.ensureInitialized();
1054
+ const task = this.tasks.get(id);
1055
+ if (!task) {
1056
+ return null;
1057
+ }
1058
+ // Check if already in completed
1059
+ if (task.source === "completed") {
1060
+ return null;
1061
+ }
1062
+ const completedDir = this.getCompletedDir();
1063
+ // Ensure completed directory exists
1064
+ await this.fs.createDir(completedDir, { recursive: true });
1065
+ // Build new filepath in completed/
1066
+ const safeTitle = task.title
1067
+ .replace(/[<>:"/\\|?*]/g, "")
1068
+ .replace(/\s+/g, " ")
1069
+ .slice(0, 50);
1070
+ const filename = `${id} - ${safeTitle}.md`;
1071
+ const newFilepath = this.fs.join(completedDir, filename);
1072
+ // Delete old file
1073
+ if (task.filePath) {
1074
+ try {
1075
+ await this.fs.deleteFile(task.filePath);
1076
+ }
1077
+ catch {
1078
+ // File may not exist
1079
+ }
1080
+ }
1081
+ // Update task
1082
+ const archived = {
1083
+ ...task,
1084
+ source: "completed",
1085
+ filePath: newFilepath,
1086
+ };
1087
+ // Write to new location
1088
+ const content = serializeTaskMarkdown(archived);
1089
+ await this.fs.writeFile(newFilepath, content);
1090
+ // Update in-memory cache
1091
+ this.tasks.set(id, archived);
1092
+ // Update task index if lazy initialized
1093
+ if (this.lazyInitialized) {
1094
+ const entry = this.taskIndex.get(id);
1095
+ if (entry) {
1096
+ entry.source = "completed";
1097
+ entry.filePath = newFilepath;
1098
+ }
1099
+ }
1100
+ return archived;
1101
+ }
1102
+ /**
1103
+ * Restore a task (move from completed/ to tasks/)
1104
+ *
1105
+ * @param id - Task ID to restore
1106
+ * @returns Restored task or null if not found or not archived
1107
+ */
1108
+ async restoreTask(id) {
1109
+ this.ensureInitialized();
1110
+ const task = this.tasks.get(id);
1111
+ if (!task) {
1112
+ return null;
1113
+ }
1114
+ // Check if in completed
1115
+ if (task.source !== "completed") {
1116
+ return null;
1117
+ }
1118
+ const tasksDir = this.getTasksDir();
1119
+ // Ensure tasks directory exists
1120
+ await this.fs.createDir(tasksDir, { recursive: true });
1121
+ // Build new filepath in tasks/
1122
+ const safeTitle = task.title
1123
+ .replace(/[<>:"/\\|?*]/g, "")
1124
+ .replace(/\s+/g, " ")
1125
+ .slice(0, 50);
1126
+ const filename = `${id} - ${safeTitle}.md`;
1127
+ const newFilepath = this.fs.join(tasksDir, filename);
1128
+ // Delete old file
1129
+ if (task.filePath) {
1130
+ try {
1131
+ await this.fs.deleteFile(task.filePath);
1132
+ }
1133
+ catch {
1134
+ // File may not exist
1135
+ }
1136
+ }
1137
+ // Update task
1138
+ const restored = {
1139
+ ...task,
1140
+ source: "local",
1141
+ filePath: newFilepath,
1142
+ };
1143
+ // Write to new location
1144
+ const content = serializeTaskMarkdown(restored);
1145
+ await this.fs.writeFile(newFilepath, content);
1146
+ // Update in-memory cache
1147
+ this.tasks.set(id, restored);
1148
+ // Update task index if lazy initialized
1149
+ if (this.lazyInitialized) {
1150
+ const entry = this.taskIndex.get(id);
1151
+ if (entry) {
1152
+ entry.source = "tasks";
1153
+ entry.filePath = newFilepath;
1154
+ }
1155
+ }
1156
+ return restored;
1157
+ }
1158
+ /**
1159
+ * Load specific tasks by their IDs (for lazy loading milestone tasks)
1160
+ *
1161
+ * @param ids - Array of task IDs to load
1162
+ * @returns Array of loaded tasks (missing tasks excluded)
1163
+ */
1164
+ async loadTasksByIds(ids) {
1165
+ // If fully initialized, return from cache
1166
+ if (this.initialized) {
1167
+ return ids.map((id) => this.tasks.get(id)).filter((t) => t !== undefined);
1168
+ }
1169
+ // If lazy initialized, load on demand
1170
+ if (this.lazyInitialized) {
1171
+ return this.loadTasks(ids);
1172
+ }
1173
+ throw new Error("Core not initialized. Call initialize() or initializeLazy() first.");
1174
+ }
524
1175
  }
525
1176
  //# sourceMappingURL=Core.js.map