esedre 0.1.11 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esedre.mjs CHANGED
@@ -7,9 +7,19 @@ import path9 from "node:path";
7
7
  // src/storage/filesystem.ts
8
8
  import fs3 from "node:fs";
9
9
  import path3 from "node:path";
10
+ import os2 from "node:os";
10
11
 
11
12
  // src/types.ts
12
- var CURRENT_ESEDRE_VERSION = "0.1.11";
13
+ var CURRENT_ESEDRE_VERSION = "1.0.0";
14
+ var INVERSE_RELATIONS = {
15
+ "relates-to": "relates-to",
16
+ "blocks": "blocked-by",
17
+ "blocked-by": "blocks",
18
+ "parent-of": "child-of",
19
+ "child-of": "parent-of",
20
+ "duplicates": "duplicated-by",
21
+ "duplicated-by": "duplicates"
22
+ };
13
23
  var EsedreConflictError = class extends Error {
14
24
  constructor(ticketId, currentHash, lastHash) {
15
25
  super(
@@ -446,9 +456,13 @@ function computeTicketHash(ticket) {
446
456
  title: (ticket.meta.title || "").trim(),
447
457
  category: ticket.meta.category,
448
458
  status: ticket.meta.status,
459
+ priority: ticket.meta.priority || "",
449
460
  complexity: ticket.meta.complexity || "",
450
461
  effort: ticket.meta.estimatedEffort || "",
451
462
  project: ticket.meta.project || "",
463
+ milestone: ticket.meta.milestone || "",
464
+ featureFlag: ticket.meta.featureFlag || "",
465
+ links: ticket.meta.links || [],
452
466
  revision: ticket.meta.revision || 1,
453
467
  completedAt: ticket.meta.completedAt || "",
454
468
  detailRaw: (ticket.detail?.raw || "").trim(),
@@ -495,11 +509,17 @@ async function generateProjectSnapshot(storage, projectCode, outputDir) {
495
509
  title: ticketObj.meta.title,
496
510
  type: ticketObj.meta.type || ticketObj.meta.category || "Feature",
497
511
  category: ticketObj.meta.type || ticketObj.meta.category || "Feature",
512
+ priority: ticketObj.meta.priority,
498
513
  status: ticketObj.meta.status,
499
514
  complexity: ticketObj.meta.complexity,
500
515
  estimatedEffort: ticketObj.meta.estimatedEffort,
501
516
  project: pCode,
502
517
  summary: ticketObj.detail?.summary,
518
+ milestone: ticketObj.meta.milestone,
519
+ featureFlag: ticketObj.meta.featureFlag,
520
+ inheritedFeatureFlag: ticketObj.meta.inheritedFeatureFlag,
521
+ links: ticketObj.meta.links || [],
522
+ isBlocked: ticketObj.isBlocked || false,
503
523
  hasPlan: Boolean(plan && plan.trim().length > 0),
504
524
  planMarkdown: plan && plan.trim().length > 0 ? plan : void 0,
505
525
  createdAt,
@@ -510,12 +530,38 @@ async function generateProjectSnapshot(storage, projectCode, outputDir) {
510
530
  sha1
511
531
  });
512
532
  }
533
+ let milestoneSnapshots;
534
+ try {
535
+ const rawMilestones = await storage.listMilestones(projectCode);
536
+ milestoneSnapshots = rawMilestones.map((m) => {
537
+ const mTickets = entries.filter(
538
+ (t) => t.milestone && (t.milestone.toLowerCase() === String(m.id).toLowerCase() || t.milestone.toLowerCase() === m.title.toLowerCase())
539
+ );
540
+ const completed = mTickets.filter((t) => t.status === "Completed").length;
541
+ const total = mTickets.length;
542
+ const progressPercent = total > 0 ? Math.round(completed / total * 100) : 0;
543
+ return {
544
+ id: m.id,
545
+ project: m.project,
546
+ title: m.title,
547
+ description: m.description,
548
+ status: m.status,
549
+ featureFlag: m.featureFlag,
550
+ targetDate: m.targetDate,
551
+ totalTickets: total,
552
+ completedTickets: completed,
553
+ progressPercent
554
+ };
555
+ });
556
+ } catch {
557
+ }
513
558
  const snapshot = {
514
559
  version: CURRENT_ESEDRE_VERSION,
515
560
  projectCode,
516
561
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
517
562
  totalTickets: entries.length,
518
- tickets: entries
563
+ tickets: entries,
564
+ ...milestoneSnapshots && milestoneSnapshots.length > 0 ? { milestones: milestoneSnapshots } : {}
519
565
  };
520
566
  const esedreDir = path2.join(outputDir, ".esedre");
521
567
  if (!fs2.existsSync(esedreDir)) {
@@ -538,6 +584,289 @@ async function generateProjectSnapshot(storage, projectCode, outputDir) {
538
584
  return snapshot;
539
585
  }
540
586
 
587
+ // src/utils/formatter.ts
588
+ var isColorSupported = !process.env.NO_COLOR && (process.stdout.isTTY || process.env.FORCE_COLOR);
589
+ var colors = {
590
+ reset: isColorSupported ? "\x1B[0m" : "",
591
+ bold: isColorSupported ? "\x1B[1m" : "",
592
+ dim: isColorSupported ? "\x1B[2m" : "",
593
+ cyan: isColorSupported ? "\x1B[36m" : "",
594
+ green: isColorSupported ? "\x1B[32m" : "",
595
+ yellow: isColorSupported ? "\x1B[33m" : "",
596
+ magenta: isColorSupported ? "\x1B[35m" : "",
597
+ red: isColorSupported ? "\x1B[31m" : "",
598
+ blue: isColorSupported ? "\x1B[34m" : ""
599
+ };
600
+ function normalizePriority(val) {
601
+ if (!val) return void 0;
602
+ const lower = String(val).trim().toLowerCase();
603
+ if (lower === "critical") return "Critical";
604
+ if (lower === "high") return "High";
605
+ if (lower === "medium") return "Medium";
606
+ if (lower === "low") return "Low";
607
+ return void 0;
608
+ }
609
+ function normalizeDashesAndMojibake(val) {
610
+ if (typeof val !== "string") return val;
611
+ return val.replace(/–|‗/g, "-").replace(/â€[˜™]/g, "'").replace(/â€[œ ]/g, '"').replace(/\u00e2\u20ac\u201c|\u00e2\u0080\u0093|–/g, "-").replace(/\u00e2\u20ac\u201d|\u00e2\u0080\u0094|—/g, "-").replace(/\u00e2\u20ac\u02dc|\u00e2\u20ac\u2122|‘|’/g, "'").replace(/\u00e2\u20ac\u0153|\u00e2\u20ac\u009d|\u00e2\u0080\u009d|“|â€\u009d|†/g, '"').replace(/\u00e2\u20ac\u00a6|…/g, "...").replace(/[\u2012\u2013\u2014\u2015\u2212]/g, "-");
612
+ }
613
+ function normalizeTicketFields(ticket) {
614
+ if (!ticket) return ticket;
615
+ if (ticket.meta) {
616
+ if (ticket.meta.title) ticket.meta.title = normalizeDashesAndMojibake(ticket.meta.title);
617
+ if (ticket.meta.estimatedEffort) ticket.meta.estimatedEffort = normalizeDashesAndMojibake(ticket.meta.estimatedEffort);
618
+ if (ticket.meta.complexity) ticket.meta.complexity = normalizeDashesAndMojibake(ticket.meta.complexity);
619
+ if (ticket.meta.milestone) ticket.meta.milestone = normalizeDashesAndMojibake(ticket.meta.milestone);
620
+ if (ticket.meta.featureFlag) ticket.meta.featureFlag = normalizeDashesAndMojibake(ticket.meta.featureFlag);
621
+ if (ticket.meta.inheritedFeatureFlag) ticket.meta.inheritedFeatureFlag = normalizeDashesAndMojibake(ticket.meta.inheritedFeatureFlag);
622
+ if (ticket.meta.priority) {
623
+ const norm = normalizePriority(ticket.meta.priority);
624
+ if (norm) {
625
+ ticket.meta.priority = norm;
626
+ } else {
627
+ delete ticket.meta.priority;
628
+ }
629
+ }
630
+ }
631
+ if (ticket.detail) {
632
+ if (ticket.detail.title) ticket.detail.title = normalizeDashesAndMojibake(ticket.detail.title);
633
+ if (ticket.detail.estimatedEffort) ticket.detail.estimatedEffort = normalizeDashesAndMojibake(ticket.detail.estimatedEffort);
634
+ if (ticket.detail.summary) ticket.detail.summary = normalizeDashesAndMojibake(ticket.detail.summary);
635
+ if (ticket.detail.milestone) ticket.detail.milestone = normalizeDashesAndMojibake(ticket.detail.milestone);
636
+ if (ticket.detail.priority) {
637
+ const norm = normalizePriority(ticket.detail.priority);
638
+ if (norm) {
639
+ ticket.detail.priority = norm;
640
+ } else {
641
+ delete ticket.detail.priority;
642
+ }
643
+ }
644
+ if (ticket.detail.breakdown) ticket.detail.breakdown = ticket.detail.breakdown.map((b) => normalizeDashesAndMojibake(b));
645
+ if (ticket.detail.technicalDetails) ticket.detail.technicalDetails = ticket.detail.technicalDetails.map((t) => normalizeDashesAndMojibake(t));
646
+ if (ticket.detail.openQuestions) ticket.detail.openQuestions = ticket.detail.openQuestions.map((q) => normalizeDashesAndMojibake(q));
647
+ if (ticket.detail.raw) ticket.detail.raw = normalizeDashesAndMojibake(ticket.detail.raw);
648
+ }
649
+ if (ticket.planMarkdown) {
650
+ ticket.planMarkdown = normalizeDashesAndMojibake(ticket.planMarkdown);
651
+ }
652
+ if (ticket.comments) {
653
+ for (const c of ticket.comments) {
654
+ if (c.text) c.text = normalizeDashesAndMojibake(c.text);
655
+ if (c.author) c.author = normalizeDashesAndMojibake(c.author);
656
+ }
657
+ }
658
+ return ticket;
659
+ }
660
+ function formatTicketListTable(tickets) {
661
+ if (tickets.length === 0) {
662
+ return `${colors.dim}No tickets found matching criteria.${colors.reset}`;
663
+ }
664
+ const showPriority = tickets.some((t) => Boolean(t.meta.priority));
665
+ const rows = tickets.map((t) => {
666
+ const id = t.projectDescriptor?.code ? `${t.projectDescriptor.code}-${t.meta.id}` : `#${t.meta.id}`;
667
+ const project = t.projectDescriptor?.code || "CORE";
668
+ const type = t.meta.type || t.meta.category || "Feature";
669
+ const priority = t.meta.priority || "-";
670
+ const status = t.meta.status;
671
+ const title = normalizeDashesAndMojibake(t.meta.title) + (t.isBlocked ? ` ${colors.red}[BLOCKED]${colors.reset}` : "");
672
+ return { id, project, type, priority, status, title };
673
+ });
674
+ const idWidth = Math.max(4, ...rows.map((r) => r.id.length));
675
+ const projWidth = Math.max(7, ...rows.map((r) => r.project.length));
676
+ const typeWidth = Math.max(8, ...rows.map((r) => r.type.length));
677
+ const prioWidth = showPriority ? Math.max(8, ...rows.map((r) => r.priority.length)) : 0;
678
+ const statusWidth = Math.max(14, ...rows.map((r) => r.status.length));
679
+ const headerParts = [
680
+ pad("ID", idWidth),
681
+ pad("Project", projWidth),
682
+ pad("Type", typeWidth)
683
+ ];
684
+ if (showPriority) headerParts.push(pad("Priority", prioWidth));
685
+ headerParts.push(pad("Status", statusWidth), "Title");
686
+ const header = `${colors.bold}${headerParts.join(" ")}${colors.reset}`;
687
+ const dividerParts = [
688
+ "-".repeat(idWidth),
689
+ "-".repeat(projWidth),
690
+ "-".repeat(typeWidth)
691
+ ];
692
+ if (showPriority) dividerParts.push("-".repeat(prioWidth));
693
+ dividerParts.push("-".repeat(statusWidth), "-".repeat(40));
694
+ const divider = `${colors.dim}${dividerParts.join(" ")}${colors.reset}`;
695
+ const formattedRows = rows.map((r) => {
696
+ const statusColored = colorStatus(r.status);
697
+ const typeColored = colorType(r.type);
698
+ const projColored = `${colors.magenta}${r.project}${colors.reset}`;
699
+ const rowParts = [
700
+ `${colors.bold}${pad(r.id, idWidth)}${colors.reset}`,
701
+ pad(projColored, projWidth + (isColorSupported ? colors.magenta.length + colors.reset.length : 0)),
702
+ pad(typeColored, typeWidth + (isColorSupported ? 9 : 0))
703
+ ];
704
+ if (showPriority) {
705
+ const prioColored = r.priority === "-" ? `${colors.dim}-${colors.reset}` : colorPriority(r.priority);
706
+ rowParts.push(pad(prioColored, prioWidth + (isColorSupported && r.priority !== "-" ? 9 : isColorSupported ? colors.dim.length + colors.reset.length : 0)));
707
+ }
708
+ rowParts.push(
709
+ pad(statusColored, statusWidth + (isColorSupported ? 9 : 0)),
710
+ r.title
711
+ );
712
+ return rowParts.join(" ");
713
+ });
714
+ return [header, divider, ...formattedRows].join("\n");
715
+ }
716
+ function formatTicketDetail(ticket) {
717
+ const { meta, detail, comments, planMarkdown, projectDescriptor } = ticket;
718
+ const lines = [];
719
+ lines.push(`${colors.bold}${colors.cyan}Ticket #${meta.project ? `${meta.project}-${meta.id}` : meta.id}: ${normalizeDashesAndMojibake(meta.title)}${colors.reset}`);
720
+ lines.push(`${colors.dim}${"=".repeat(60)}${colors.reset}`);
721
+ lines.push(`${colors.bold}Project:${colors.reset} ${projectDescriptor ? `${projectDescriptor.code} - ${projectDescriptor.name}` : "Default"}`);
722
+ lines.push(`${colors.bold}Type:${colors.reset} ${colorType(meta.type || meta.category || "Feature")}`);
723
+ if (meta.priority) {
724
+ lines.push(`${colors.bold}Priority:${colors.reset} ${colorPriority(meta.priority)}`);
725
+ }
726
+ lines.push(`${colors.bold}Status:${colors.reset} ${colorStatus(meta.status)}`);
727
+ lines.push(`${colors.bold}Complexity:${colors.reset} ${normalizeDashesAndMojibake(meta.complexity || "Medium")}`);
728
+ lines.push(`${colors.bold}Effort:${colors.reset} ${normalizeDashesAndMojibake(meta.estimatedEffort || "N/A")}`);
729
+ lines.push(`${colors.bold}Submitted By:${colors.reset} ${meta.submittedBy || "Unknown"}`);
730
+ if (meta.milestone) {
731
+ lines.push(`${colors.bold}Milestone:${colors.reset} ${colors.magenta}${meta.milestone}${colors.reset}`);
732
+ }
733
+ if (meta.featureFlag) {
734
+ const isInherited = meta.inheritedFeatureFlag && meta.inheritedFeatureFlag === meta.featureFlag;
735
+ lines.push(`${colors.bold}Feature Flag:${colors.reset} ${colors.yellow}${meta.featureFlag}${colors.reset}${isInherited ? ` ${colors.dim}(inherited from Milestone)${colors.reset}` : ""}`);
736
+ }
737
+ if (ticket.isBlocked) {
738
+ lines.push(`${colors.bold}Dependency:${colors.reset} ${colors.red}${colors.bold}Blocked by uncompleted ticket${colors.reset}`);
739
+ }
740
+ if (detail?.summary) {
741
+ lines.push("");
742
+ lines.push(`${colors.bold}Summary:${colors.reset}`);
743
+ lines.push(normalizeDashesAndMojibake(detail.summary));
744
+ }
745
+ if (detail?.breakdown && detail.breakdown.length > 0) {
746
+ lines.push("");
747
+ lines.push(`${colors.bold}Feature Breakdown:${colors.reset}`);
748
+ for (const b of detail.breakdown) {
749
+ lines.push(` \u2022 ${normalizeDashesAndMojibake(b)}`);
750
+ }
751
+ }
752
+ if (detail?.technicalDetails && detail.technicalDetails.length > 0) {
753
+ lines.push("");
754
+ lines.push(`${colors.bold}Technical Details:${colors.reset}`);
755
+ for (const t of detail.technicalDetails) {
756
+ lines.push(` \u2022 ${normalizeDashesAndMojibake(t)}`);
757
+ }
758
+ }
759
+ if (detail?.openQuestions && detail.openQuestions.length > 0) {
760
+ lines.push("");
761
+ lines.push(`${colors.bold}Open Decisions & Questions:${colors.reset}`);
762
+ for (const q of detail.openQuestions) {
763
+ lines.push(` \u2022 ${normalizeDashesAndMojibake(q)}`);
764
+ }
765
+ }
766
+ if (ticket.links && ticket.links.length > 0) {
767
+ lines.push("");
768
+ lines.push(`${colors.bold}Linked Tickets (${ticket.links.length}):${colors.reset}`);
769
+ for (const l of ticket.links) {
770
+ const relFormatted = l.relation === "blocks" ? `${colors.red}Blocks${colors.reset}` : l.relation === "blocked-by" ? `${colors.red}Blocked by${colors.reset}` : l.relation === "parent-of" ? `${colors.magenta}Parent of${colors.reset}` : l.relation === "child-of" ? `${colors.magenta}Child of${colors.reset}` : l.relation === "duplicates" ? `${colors.yellow}Duplicates${colors.reset}` : l.relation === "duplicated-by" ? `${colors.yellow}Duplicated by${colors.reset}` : `${colors.cyan}Relates to${colors.reset}`;
771
+ const blockedTag = l.isBlockedByUncompleted ? ` ${colors.red}[BLOCKED]${colors.reset}` : "";
772
+ const titleSnippet = l.targetTitle ? ` : ${normalizeDashesAndMojibake(l.targetTitle)}` : "";
773
+ const statusSnippet = l.targetStatus ? ` [${l.targetStatus}]` : "";
774
+ lines.push(` \u2022 ${relFormatted} ${colors.bold}${l.targetKey}${colors.reset}${statusSnippet}${titleSnippet}${blockedTag}`);
775
+ }
776
+ }
777
+ if (planMarkdown) {
778
+ lines.push("");
779
+ lines.push(`${colors.bold}Implementation Plan:${colors.reset}`);
780
+ lines.push(normalizeDashesAndMojibake(planMarkdown.trim()));
781
+ }
782
+ if (comments && comments.length > 0) {
783
+ lines.push("");
784
+ lines.push(`${colors.bold}Comments (${comments.length}):${colors.reset}`);
785
+ for (const c of comments) {
786
+ lines.push(` ${colors.dim}[${c.timestamp.slice(0, 10)}]${colors.reset} ${colors.bold}${normalizeDashesAndMojibake(c.author)}:${colors.reset} ${normalizeDashesAndMojibake(c.text)}`);
787
+ }
788
+ }
789
+ return lines.join("\n");
790
+ }
791
+ function pad(str, width) {
792
+ return str.padEnd(width, " ");
793
+ }
794
+ function colorStatus(status) {
795
+ switch (status) {
796
+ case "Completed":
797
+ return `${colors.green}${status}${colors.reset}`;
798
+ case "In Development":
799
+ return `${colors.green}${status}${colors.reset}`;
800
+ case "Rejected":
801
+ return `${colors.red}${status}${colors.reset}`;
802
+ default:
803
+ return `${colors.cyan}${status}${colors.reset}`;
804
+ }
805
+ }
806
+ function colorType(type) {
807
+ switch (type) {
808
+ case "Feature":
809
+ return `${colors.blue}${type}${colors.reset}`;
810
+ case "Platform":
811
+ return `${colors.magenta}${type}${colors.reset}`;
812
+ case "Tools":
813
+ return `${colors.yellow}${type}${colors.reset}`;
814
+ case "Bug":
815
+ return `${colors.red}${type}${colors.reset}`;
816
+ case "Idea":
817
+ return `${colors.green}${type}${colors.reset}`;
818
+ default:
819
+ return type;
820
+ }
821
+ }
822
+ function colorPriority(priority) {
823
+ if (!priority) return "";
824
+ switch (priority.toLowerCase()) {
825
+ case "critical":
826
+ return `${colors.red}${colors.bold}Critical${colors.reset}`;
827
+ case "high":
828
+ return `${colors.yellow}${colors.bold}High${colors.reset}`;
829
+ case "medium":
830
+ return `${colors.blue}Medium${colors.reset}`;
831
+ case "low":
832
+ return `${colors.dim}Low${colors.reset}`;
833
+ default:
834
+ return priority;
835
+ }
836
+ }
837
+ function formatMilestoneListTable(milestones, tickets) {
838
+ if (milestones.length === 0) {
839
+ return `${colors.dim}No milestones found.${colors.reset}`;
840
+ }
841
+ const rows = milestones.map((m) => {
842
+ const id = String(m.id);
843
+ const project = m.project;
844
+ const title = m.title;
845
+ const status = m.status;
846
+ const flag = m.featureFlag || "-";
847
+ let progress = "-";
848
+ if (tickets) {
849
+ const mTickets = tickets.filter(
850
+ (t) => t.meta.milestone && (t.meta.milestone.toLowerCase() === String(m.id).toLowerCase() || t.meta.milestone.toLowerCase() === m.title.toLowerCase())
851
+ );
852
+ const completed = mTickets.filter((t) => t.meta.status === "Completed").length;
853
+ progress = `${completed}/${mTickets.length}`;
854
+ }
855
+ return { id, project, status, flag, progress, title };
856
+ });
857
+ const idWidth = Math.max(4, ...rows.map((r) => r.id.length));
858
+ const projWidth = Math.max(7, ...rows.map((r) => r.project.length));
859
+ const statusWidth = Math.max(10, ...rows.map((r) => r.status.length));
860
+ const flagWidth = Math.max(14, ...rows.map((r) => r.flag.length));
861
+ const progWidth = Math.max(8, ...rows.map((r) => r.progress.length));
862
+ const header = `${colors.bold}${pad("ID", idWidth)} ${pad("Project", projWidth)} ${pad("Status", statusWidth)} ${pad("Umbrella Flag", flagWidth)} ${pad("Progress", progWidth)} Title${colors.reset}`;
863
+ const divider = `${colors.dim}${"-".repeat(idWidth)} ${"-".repeat(projWidth)} ${"-".repeat(statusWidth)} ${"-".repeat(flagWidth)} ${"-".repeat(progWidth)} ${"-".repeat(30)}${colors.reset}`;
864
+ const formattedRows = rows.map((r) => {
865
+ return `${colors.bold}${pad(r.id, idWidth)}${colors.reset} ${pad(r.project, projWidth)} ${pad(r.status, statusWidth)} ${pad(r.flag, flagWidth)} ${pad(r.progress, progWidth)} ${r.title}`;
866
+ });
867
+ return [header, divider, ...formattedRows].join("\n");
868
+ }
869
+
541
870
  // src/storage/filesystem.ts
542
871
  function isProjectMatch(code, filter) {
543
872
  if (!code || !filter) return false;
@@ -592,6 +921,20 @@ var FilesystemStorageAdapter = class {
592
921
  }
593
922
  }
594
923
  refreshConfig() {
924
+ if (this.isConfigExplicit) {
925
+ try {
926
+ const discovered = findEsedreConfig(this.workspaceRoot);
927
+ if (discovered.config && Object.keys(discovered.config).length > 0) {
928
+ this.config = {
929
+ ...this.config,
930
+ ...discovered.config
931
+ };
932
+ }
933
+ } catch {
934
+ }
935
+ this.lastConfigCheck = Date.now();
936
+ return;
937
+ }
595
938
  try {
596
939
  const discovered = findEsedreConfig(this.workspaceRoot);
597
940
  this.config = discovered.config || {};
@@ -1104,86 +1447,647 @@ ${formattedList}`
1104
1447
  }
1105
1448
  return projectDesc;
1106
1449
  }
1107
- findTicketLocation(id) {
1450
+ async updateProject(input) {
1451
+ const cleanCode = input.code.trim();
1452
+ const cleanLower = cleanCode.toLowerCase();
1453
+ if (input.name !== void 0) {
1454
+ const nameVal = validateProjectName(input.name);
1455
+ if (!nameVal.valid) {
1456
+ throw new Error(nameVal.error);
1457
+ }
1458
+ }
1108
1459
  const locations = this.resolveProjectLocations();
1109
- if (locations.length === 0) return null;
1110
- const strId = String(id).trim();
1111
- const prefixMatch = strId.match(/^([a-zA-Z0-9]{1,8})[-_:](\d+)$/i);
1112
- if (prefixMatch) {
1113
- const code = prefixMatch[1].toLowerCase();
1114
- const num2 = parseInt(prefixMatch[2], 10);
1115
- const loc = locations.find((l) => isProjectMatch(l.project.code, code));
1116
- if (!loc) return null;
1117
- const ticketDir = path3.join(loc.ticketsDir, String(num2));
1118
- const metaPath = path3.join(ticketDir, "meta.json");
1119
- if (fs3.existsSync(metaPath)) {
1120
- if (loc.isSharedDir) {
1460
+ const loc = locations.find((l) => l.project.code.toLowerCase() === cleanLower);
1461
+ if (!loc) {
1462
+ throw new Error(`Project "${cleanCode}" not found`);
1463
+ }
1464
+ const updatedDesc = {
1465
+ ...loc.project,
1466
+ ...input.name !== void 0 ? { name: input.name.trim() } : {},
1467
+ ...input.description !== void 0 ? { description: input.description.trim() } : {},
1468
+ ...input.colors !== void 0 ? { colors: input.colors } : {},
1469
+ ...input.techStack !== void 0 ? { techStack: input.techStack } : {},
1470
+ ...input.groundingRules !== void 0 ? { groundingRules: input.groundingRules } : {},
1471
+ ...input.guidelinesRef !== void 0 ? { guidelinesRef: input.guidelinesRef } : {}
1472
+ };
1473
+ if (loc.sourceType === "hub" && loc.hubDir) {
1474
+ const projectDir = path3.dirname(loc.ticketsDir);
1475
+ const pJsonPath = path3.join(projectDir, "project.json");
1476
+ let existingPJson = {};
1477
+ if (fs3.existsSync(pJsonPath)) {
1478
+ try {
1479
+ existingPJson = JSON.parse(fs3.readFileSync(pJsonPath, "utf-8"));
1480
+ } catch {
1481
+ }
1482
+ }
1483
+ const mergedPJson = { ...existingPJson, ...updatedDesc };
1484
+ writeSafeFile(pJsonPath, JSON.stringify(mergedPJson, null, 2) + "\n");
1485
+ const hubProjectsFile = path3.join(loc.hubDir, "projects.json");
1486
+ const subHubProjectsFile = path3.join(loc.hubDir, "projects", "projects.json");
1487
+ for (const f of [hubProjectsFile, subHubProjectsFile]) {
1488
+ if (fs3.existsSync(f)) {
1121
1489
  try {
1122
- const metaRaw = fs3.readFileSync(metaPath, "utf-8");
1123
- const meta = JSON.parse(metaRaw);
1124
- meta.type = meta.type || meta.category || "Feature";
1125
- meta.category = meta.type;
1126
- const mProj = (meta.project || "").toLowerCase();
1127
- if (mProj && mProj !== code && mProj !== loc.project.code.toLowerCase()) {
1128
- return null;
1490
+ const arr = JSON.parse(fs3.readFileSync(f, "utf-8"));
1491
+ const idx = arr.findIndex((p) => p.code.toLowerCase() === cleanLower);
1492
+ if (idx >= 0) {
1493
+ arr[idx] = { ...arr[idx], ...updatedDesc };
1494
+ writeSafeFile(f, JSON.stringify(arr, null, 2) + "\n");
1129
1495
  }
1130
1496
  } catch {
1131
1497
  }
1132
1498
  }
1133
- return { loc, ticketDir, id: num2 };
1134
1499
  }
1135
- return null;
1136
- }
1137
- const num = typeof id === "number" ? id : parseInt(strId, 10);
1138
- if (isNaN(num)) return null;
1139
- const matches = [];
1140
- const checkedDirs = /* @__PURE__ */ new Set();
1141
- for (const loc of locations) {
1142
- const ticketDir = path3.join(loc.ticketsDir, String(num));
1143
- const metaPath = path3.join(ticketDir, "meta.json");
1144
- if (!fs3.existsSync(metaPath)) continue;
1145
- if (loc.isSharedDir) {
1146
- if (checkedDirs.has(ticketDir)) continue;
1147
- checkedDirs.add(ticketDir);
1148
- try {
1149
- const metaRaw = fs3.readFileSync(metaPath, "utf-8");
1150
- const meta = JSON.parse(metaRaw);
1151
- const matchedLoc = locations.find(
1152
- (l) => meta.projectId !== void 0 && l.project.id === meta.projectId || meta.project && l.project.code.toLowerCase() === meta.project.toLowerCase()
1153
- ) || loc;
1154
- matches.push({ loc: matchedLoc, ticketDir, id: num });
1155
- } catch {
1156
- matches.push({ loc, ticketDir, id: num });
1500
+ } else {
1501
+ const projectDir = loc.sourceType === "federated" ? path3.dirname(loc.ticketsDir) : path3.join(this.workspaceRoot, ".esedre");
1502
+ const candidates = [
1503
+ path3.join(projectDir, "project.json"),
1504
+ path3.join(this.workspaceRoot, ".esedre", "project.json"),
1505
+ path3.join(this.workspaceRoot, "project.json")
1506
+ ];
1507
+ for (const pJsonPath of candidates) {
1508
+ if (fs3.existsSync(pJsonPath)) {
1509
+ try {
1510
+ const existing = JSON.parse(fs3.readFileSync(pJsonPath, "utf-8"));
1511
+ const merged = { ...existing, ...updatedDesc };
1512
+ writeSafeFile(pJsonPath, JSON.stringify(merged, null, 2) + "\n");
1513
+ } catch {
1514
+ }
1515
+ }
1516
+ }
1517
+ const pJsonListCandidates = [
1518
+ path3.join(projectDir, "projects.json"),
1519
+ path3.join(this.workspaceRoot, ".esedre", "projects.json"),
1520
+ path3.join(this.workspaceRoot, "projects.json")
1521
+ ];
1522
+ for (const projectsJsonPath of pJsonListCandidates) {
1523
+ if (fs3.existsSync(projectsJsonPath)) {
1524
+ try {
1525
+ const arr = JSON.parse(fs3.readFileSync(projectsJsonPath, "utf-8"));
1526
+ const idx = arr.findIndex((p) => p.code.toLowerCase() === cleanLower);
1527
+ if (idx >= 0) {
1528
+ arr[idx] = { ...arr[idx], ...updatedDesc };
1529
+ writeSafeFile(projectsJsonPath, JSON.stringify(arr, null, 2) + "\n");
1530
+ }
1531
+ } catch {
1532
+ }
1157
1533
  }
1158
- } else {
1159
- matches.push({ loc, ticketDir, id: num });
1160
1534
  }
1161
1535
  }
1162
- if (this.config.projectCode) {
1163
- const prefLower = this.config.projectCode.toLowerCase();
1164
- const preferredMatch = matches.find(
1165
- (m) => m.loc.project.code.toLowerCase() === prefLower
1166
- );
1167
- if (preferredMatch) return preferredMatch;
1168
- }
1169
- if (matches.length === 1) {
1170
- return matches[0];
1171
- }
1172
- if (matches.length > 1) {
1173
- const codes = matches.map((m) => m.loc.project.code);
1174
- throw new Error(
1175
- `Ambiguous ticket #${num}: exists in multiple projects (${codes.join(", ")}). Please specify as <ProjectCode>-${num} (e.g. ${codes[0]}-${num}).`
1176
- );
1536
+ this.refreshConfig();
1537
+ return updatedDesc;
1538
+ }
1539
+ async renameProjectCode(input) {
1540
+ const cleanOld = input.oldCode.trim();
1541
+ const cleanNew = input.newCode.trim();
1542
+ const oldLower = cleanOld.toLowerCase();
1543
+ const newLower = cleanNew.toLowerCase();
1544
+ const codeVal = validateProjectCode(cleanNew);
1545
+ if (!codeVal.valid) {
1546
+ throw new Error(codeVal.error);
1547
+ }
1548
+ if (input.newName !== void 0) {
1549
+ const nameVal = validateProjectName(input.newName);
1550
+ if (!nameVal.valid) {
1551
+ throw new Error(nameVal.error);
1552
+ }
1177
1553
  }
1178
- return null;
1179
- }
1180
- async listTickets(filter) {
1181
1554
  const locations = this.resolveProjectLocations();
1182
- const tickets = [];
1183
- const processedSharedTickets = /* @__PURE__ */ new Set();
1184
- for (const loc of locations) {
1185
- if (filter?.project && filter.project !== "all") {
1186
- const pLower = filter.project.toLowerCase();
1555
+ const loc = locations.find((l) => l.project.code.toLowerCase() === oldLower);
1556
+ if (!loc) {
1557
+ throw new Error(`Project "${cleanOld}" not found`);
1558
+ }
1559
+ if (oldLower !== newLower) {
1560
+ const collision = locations.find((l) => l.project.code.toLowerCase() === newLower);
1561
+ if (collision) {
1562
+ throw new Error(`Project code "${cleanNew}" collides with existing project "${collision.project.code}".`);
1563
+ }
1564
+ }
1565
+ let migratedTicketsCount = 0;
1566
+ const updatedHubs = [];
1567
+ const updatedConfigs = [];
1568
+ const updatedDesc = {
1569
+ ...loc.project,
1570
+ code: cleanNew,
1571
+ ...input.newName ? { name: input.newName.trim() } : {}
1572
+ };
1573
+ if (loc.sourceType === "hub" && loc.hubDir) {
1574
+ const hubDir = loc.hubDir;
1575
+ const oldProjectDir = path3.dirname(loc.ticketsDir);
1576
+ const hubProjectsDir = path3.dirname(oldProjectDir);
1577
+ const newProjectDir = path3.join(hubProjectsDir, cleanNew);
1578
+ if (oldProjectDir.toLowerCase() === newProjectDir.toLowerCase()) {
1579
+ if (path3.basename(oldProjectDir) !== cleanNew) {
1580
+ const tempHop = `${oldProjectDir}__rename_hop_${Date.now()}`;
1581
+ fs3.renameSync(oldProjectDir, tempHop);
1582
+ fs3.renameSync(tempHop, newProjectDir);
1583
+ }
1584
+ } else {
1585
+ if (fs3.existsSync(newProjectDir)) {
1586
+ throw new Error(`Target directory "${newProjectDir}" already exists.`);
1587
+ }
1588
+ fs3.renameSync(oldProjectDir, newProjectDir);
1589
+ }
1590
+ const pJsonPath = path3.join(newProjectDir, "project.json");
1591
+ let existingPJson = {};
1592
+ if (fs3.existsSync(pJsonPath)) {
1593
+ try {
1594
+ existingPJson = JSON.parse(fs3.readFileSync(pJsonPath, "utf-8"));
1595
+ } catch {
1596
+ }
1597
+ }
1598
+ const mergedPJson = { ...existingPJson, ...updatedDesc, code: cleanNew };
1599
+ writeSafeFile(pJsonPath, JSON.stringify(mergedPJson, null, 2) + "\n");
1600
+ const hubProjectsFile = path3.join(hubDir, "projects.json");
1601
+ const subHubProjectsFile = path3.join(hubDir, "projects", "projects.json");
1602
+ for (const f of [hubProjectsFile, subHubProjectsFile]) {
1603
+ if (fs3.existsSync(f)) {
1604
+ try {
1605
+ const arr = JSON.parse(fs3.readFileSync(f, "utf-8"));
1606
+ const idx = arr.findIndex((p) => p.code.toLowerCase() === oldLower);
1607
+ if (idx >= 0) {
1608
+ arr[idx] = { ...arr[idx], ...updatedDesc, code: cleanNew };
1609
+ writeSafeFile(f, JSON.stringify(arr, null, 2) + "\n");
1610
+ if (!updatedHubs.includes(f)) updatedHubs.push(f);
1611
+ }
1612
+ } catch {
1613
+ }
1614
+ }
1615
+ }
1616
+ const newTicketsDir = path3.join(newProjectDir, "tickets");
1617
+ if (fs3.existsSync(newTicketsDir)) {
1618
+ try {
1619
+ const entries = fs3.readdirSync(newTicketsDir, { withFileTypes: true });
1620
+ for (const ent of entries) {
1621
+ if (!ent.isDirectory()) continue;
1622
+ const tMetaPath = path3.join(newTicketsDir, ent.name, "meta.json");
1623
+ if (fs3.existsSync(tMetaPath)) {
1624
+ try {
1625
+ const meta = JSON.parse(fs3.readFileSync(tMetaPath, "utf-8"));
1626
+ meta.project = cleanNew;
1627
+ if (Array.isArray(meta.links)) {
1628
+ for (const l of meta.links) {
1629
+ if (l.targetProject && l.targetProject.toLowerCase() === oldLower) {
1630
+ l.targetProject = cleanNew;
1631
+ l.targetKey = `${cleanNew}-${l.targetId}`;
1632
+ }
1633
+ }
1634
+ }
1635
+ writeSafeFile(tMetaPath, JSON.stringify(meta, null, 2) + "\n");
1636
+ migratedTicketsCount++;
1637
+ } catch {
1638
+ }
1639
+ }
1640
+ }
1641
+ } catch {
1642
+ }
1643
+ }
1644
+ for (const otherLoc of locations) {
1645
+ if (otherLoc.project.code.toLowerCase() === oldLower) continue;
1646
+ if (fs3.existsSync(otherLoc.ticketsDir)) {
1647
+ try {
1648
+ const entries = fs3.readdirSync(otherLoc.ticketsDir, { withFileTypes: true });
1649
+ for (const ent of entries) {
1650
+ if (!ent.isDirectory()) continue;
1651
+ const tMetaPath = path3.join(otherLoc.ticketsDir, ent.name, "meta.json");
1652
+ if (fs3.existsSync(tMetaPath)) {
1653
+ try {
1654
+ const meta = JSON.parse(fs3.readFileSync(tMetaPath, "utf-8"));
1655
+ if (Array.isArray(meta.links)) {
1656
+ let changed = false;
1657
+ for (const l of meta.links) {
1658
+ if (l.targetProject && l.targetProject.toLowerCase() === oldLower) {
1659
+ l.targetProject = cleanNew;
1660
+ l.targetKey = `${cleanNew}-${l.targetId}`;
1661
+ changed = true;
1662
+ }
1663
+ }
1664
+ if (changed) {
1665
+ writeSafeFile(tMetaPath, JSON.stringify(meta, null, 2) + "\n");
1666
+ }
1667
+ }
1668
+ } catch {
1669
+ }
1670
+ }
1671
+ }
1672
+ } catch {
1673
+ }
1674
+ }
1675
+ }
1676
+ const newMilestonesPath = path3.join(newProjectDir, "milestones.json");
1677
+ if (fs3.existsSync(newMilestonesPath)) {
1678
+ try {
1679
+ const arr = JSON.parse(fs3.readFileSync(newMilestonesPath, "utf-8"));
1680
+ for (const m of arr) {
1681
+ m.project = cleanNew;
1682
+ }
1683
+ writeSafeFile(newMilestonesPath, JSON.stringify(arr, null, 2) + "\n");
1684
+ } catch {
1685
+ }
1686
+ }
1687
+ } else {
1688
+ const esedreDir = path3.join(this.workspaceRoot, ".esedre");
1689
+ const pJsonPath = path3.join(esedreDir, "project.json");
1690
+ if (fs3.existsSync(pJsonPath)) {
1691
+ try {
1692
+ const existing = JSON.parse(fs3.readFileSync(pJsonPath, "utf-8"));
1693
+ writeSafeFile(pJsonPath, JSON.stringify({ ...existing, ...updatedDesc, code: cleanNew }, null, 2) + "\n");
1694
+ } catch {
1695
+ }
1696
+ }
1697
+ const projectsJsonPath = path3.join(esedreDir, "projects.json");
1698
+ if (fs3.existsSync(projectsJsonPath)) {
1699
+ try {
1700
+ const arr = JSON.parse(fs3.readFileSync(projectsJsonPath, "utf-8"));
1701
+ const idx = arr.findIndex((p) => p.code.toLowerCase() === oldLower);
1702
+ if (idx >= 0) {
1703
+ arr[idx] = { ...arr[idx], ...updatedDesc, code: cleanNew };
1704
+ writeSafeFile(projectsJsonPath, JSON.stringify(arr, null, 2) + "\n");
1705
+ }
1706
+ } catch {
1707
+ }
1708
+ }
1709
+ if (fs3.existsSync(loc.ticketsDir)) {
1710
+ try {
1711
+ const entries = fs3.readdirSync(loc.ticketsDir, { withFileTypes: true });
1712
+ for (const ent of entries) {
1713
+ if (!ent.isDirectory()) continue;
1714
+ const tMetaPath = path3.join(loc.ticketsDir, ent.name, "meta.json");
1715
+ if (fs3.existsSync(tMetaPath)) {
1716
+ try {
1717
+ const meta = JSON.parse(fs3.readFileSync(tMetaPath, "utf-8"));
1718
+ meta.project = cleanNew;
1719
+ if (Array.isArray(meta.links)) {
1720
+ for (const l of meta.links) {
1721
+ if (l.targetProject && l.targetProject.toLowerCase() === oldLower) {
1722
+ l.targetProject = cleanNew;
1723
+ l.targetKey = `${cleanNew}-${l.targetId}`;
1724
+ }
1725
+ }
1726
+ }
1727
+ writeSafeFile(tMetaPath, JSON.stringify(meta, null, 2) + "\n");
1728
+ migratedTicketsCount++;
1729
+ } catch {
1730
+ }
1731
+ }
1732
+ }
1733
+ } catch {
1734
+ }
1735
+ }
1736
+ const milestonesJsonPath = path3.join(esedreDir, "milestones.json");
1737
+ if (fs3.existsSync(milestonesJsonPath)) {
1738
+ try {
1739
+ const arr = JSON.parse(fs3.readFileSync(milestonesJsonPath, "utf-8"));
1740
+ for (const m of arr) {
1741
+ if (m.project.toLowerCase() === oldLower) {
1742
+ m.project = cleanNew;
1743
+ }
1744
+ }
1745
+ writeSafeFile(milestonesJsonPath, JSON.stringify(arr, null, 2) + "\n");
1746
+ } catch {
1747
+ }
1748
+ }
1749
+ }
1750
+ const workspaceConfigFile = path3.join(this.workspaceRoot, ".esedre", "esedre.json");
1751
+ if (fs3.existsSync(workspaceConfigFile)) {
1752
+ try {
1753
+ const wsCfg = JSON.parse(fs3.readFileSync(workspaceConfigFile, "utf-8"));
1754
+ let modified = false;
1755
+ if (wsCfg.projectCode && wsCfg.projectCode.toLowerCase() === oldLower) {
1756
+ wsCfg.projectCode = cleanNew;
1757
+ if (input.newName) wsCfg.projectName = input.newName.trim();
1758
+ modified = true;
1759
+ }
1760
+ if (Array.isArray(wsCfg.allowedProjects)) {
1761
+ const idx = wsCfg.allowedProjects.findIndex((p) => p.toLowerCase() === oldLower);
1762
+ if (idx >= 0) {
1763
+ wsCfg.allowedProjects[idx] = cleanNew;
1764
+ modified = true;
1765
+ }
1766
+ }
1767
+ if (modified) {
1768
+ writeSafeFile(workspaceConfigFile, JSON.stringify(wsCfg, null, 2) + "\n");
1769
+ updatedConfigs.push(workspaceConfigFile);
1770
+ }
1771
+ } catch {
1772
+ }
1773
+ }
1774
+ const globalConfigFile = path3.join(os2.homedir(), ".esedre", "config.json");
1775
+ if (fs3.existsSync(globalConfigFile)) {
1776
+ try {
1777
+ const gCfg = JSON.parse(fs3.readFileSync(globalConfigFile, "utf-8"));
1778
+ let modified = false;
1779
+ if (gCfg.projects && typeof gCfg.projects === "object") {
1780
+ for (const [key, val] of Object.entries(gCfg.projects)) {
1781
+ if (key.toLowerCase() === oldLower) {
1782
+ delete gCfg.projects[key];
1783
+ gCfg.projects[cleanNew] = val;
1784
+ modified = true;
1785
+ break;
1786
+ }
1787
+ }
1788
+ }
1789
+ if (modified) {
1790
+ writeSafeFile(globalConfigFile, JSON.stringify(gCfg, null, 2) + "\n");
1791
+ updatedConfigs.push(globalConfigFile);
1792
+ }
1793
+ } catch {
1794
+ }
1795
+ }
1796
+ if (this.config.projectCode && this.config.projectCode.toLowerCase() === oldLower) {
1797
+ this.config.projectCode = cleanNew;
1798
+ if (input.newName) this.config.projectName = input.newName.trim();
1799
+ }
1800
+ if (Array.isArray(this.config.allowedProjects)) {
1801
+ const idx = this.config.allowedProjects.findIndex((p) => p.toLowerCase() === oldLower);
1802
+ if (idx >= 0) {
1803
+ this.config.allowedProjects[idx] = cleanNew;
1804
+ }
1805
+ }
1806
+ this.refreshConfig();
1807
+ return {
1808
+ success: true,
1809
+ oldCode: cleanOld,
1810
+ newCode: cleanNew,
1811
+ project: updatedDesc,
1812
+ migratedTicketsCount,
1813
+ updatedHubs,
1814
+ updatedConfigs
1815
+ };
1816
+ }
1817
+ getMilestonesPath(loc) {
1818
+ const parent = path3.dirname(loc.ticketsDir);
1819
+ return path3.join(parent, "milestones.json");
1820
+ }
1821
+ readProjectMilestones(loc) {
1822
+ const mPath = this.getMilestonesPath(loc);
1823
+ if (!fs3.existsSync(mPath)) return [];
1824
+ try {
1825
+ const raw = fs3.readFileSync(mPath, "utf-8");
1826
+ const parsed = JSON.parse(raw);
1827
+ return Array.isArray(parsed) ? parsed : [];
1828
+ } catch {
1829
+ return [];
1830
+ }
1831
+ }
1832
+ writeProjectMilestones(loc, milestones) {
1833
+ const mPath = this.getMilestonesPath(loc);
1834
+ writeSafeFile(mPath, JSON.stringify(milestones, null, 2) + "\n");
1835
+ }
1836
+ resolveMilestone(milestoneRef, localLoc) {
1837
+ const cleanRef = normalizeDashesAndMojibake(milestoneRef).trim();
1838
+ if (!cleanRef) return null;
1839
+ const locations = this.resolveProjectLocations();
1840
+ const colonIdx = cleanRef.indexOf(":");
1841
+ if (colonIdx > 0) {
1842
+ const projPrefix = cleanRef.substring(0, colonIdx).trim();
1843
+ const targetRef = cleanRef.substring(colonIdx + 1).trim();
1844
+ const targetLoc = locations.find((l) => isProjectMatch(l.project.code, projPrefix));
1845
+ if (targetLoc) {
1846
+ const pMilestones = this.readProjectMilestones(targetLoc);
1847
+ const match = pMilestones.find(
1848
+ (m) => String(m.id).toLowerCase() === targetRef.toLowerCase() || m.title.toLowerCase() === targetRef.toLowerCase()
1849
+ );
1850
+ if (match) return { ...match, project: targetLoc.project.code };
1851
+ }
1852
+ }
1853
+ if (localLoc) {
1854
+ const localMilestones = this.readProjectMilestones(localLoc);
1855
+ const match = localMilestones.find(
1856
+ (m) => String(m.id).toLowerCase() === cleanRef.toLowerCase() || m.title.toLowerCase() === cleanRef.toLowerCase()
1857
+ );
1858
+ if (match) return { ...match, project: localLoc.project.code };
1859
+ }
1860
+ for (const loc of locations) {
1861
+ if (localLoc && isProjectMatch(loc.project.code, localLoc.project.code)) continue;
1862
+ const list = this.readProjectMilestones(loc);
1863
+ const match = list.find(
1864
+ (m) => String(m.id).toLowerCase() === cleanRef.toLowerCase() || m.title.toLowerCase() === cleanRef.toLowerCase()
1865
+ );
1866
+ if (match) return { ...match, project: loc.project.code };
1867
+ }
1868
+ return null;
1869
+ }
1870
+ async listMilestones(projectCode) {
1871
+ const locations = this.resolveProjectLocations();
1872
+ const milestones = [];
1873
+ for (const loc of locations) {
1874
+ if (projectCode && projectCode !== "all") {
1875
+ if (!isProjectMatch(loc.project.code, projectCode)) continue;
1876
+ }
1877
+ const list = this.readProjectMilestones(loc);
1878
+ for (const m of list) {
1879
+ milestones.push({
1880
+ ...m,
1881
+ project: loc.project.code
1882
+ });
1883
+ }
1884
+ }
1885
+ return milestones.sort((a, b) => {
1886
+ if (a.project !== b.project) return a.project.localeCompare(b.project);
1887
+ return (Number(a.id) || 0) - (Number(b.id) || 0);
1888
+ });
1889
+ }
1890
+ async getMilestone(id, projectCode) {
1891
+ const target = String(id).trim();
1892
+ const colonIdx = target.indexOf(":");
1893
+ let effectiveProject = projectCode;
1894
+ let effectiveId = target;
1895
+ if (colonIdx > 0 && (!projectCode || projectCode === "all")) {
1896
+ effectiveProject = target.substring(0, colonIdx).trim();
1897
+ effectiveId = target.substring(colonIdx + 1).trim();
1898
+ }
1899
+ const all = await this.listMilestones(effectiveProject);
1900
+ const targetLower = effectiveId.toLowerCase();
1901
+ const found = all.find(
1902
+ (m) => String(m.id).toLowerCase() === targetLower || m.title.toLowerCase() === targetLower
1903
+ );
1904
+ return found || null;
1905
+ }
1906
+ async createMilestone(input) {
1907
+ const locations = this.resolveProjectLocations();
1908
+ const projectCode = input.projectCode || this.config.projectCode;
1909
+ let targetLoc;
1910
+ if (projectCode) {
1911
+ targetLoc = locations.find((l) => isProjectMatch(l.project.code, projectCode));
1912
+ } else if (locations.length === 1) {
1913
+ targetLoc = locations[0];
1914
+ }
1915
+ if (!targetLoc) {
1916
+ throw new Error("Project is required to create a milestone.");
1917
+ }
1918
+ const existing = this.readProjectMilestones(targetLoc);
1919
+ const existingIds = existing.map((m) => typeof m.id === "number" ? m.id : parseInt(String(m.id), 10)).filter((n) => !isNaN(n));
1920
+ const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
1921
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1922
+ const title = normalizeDashesAndMojibake(input.title).trim();
1923
+ if (!title) {
1924
+ throw new Error("Milestone title is required.");
1925
+ }
1926
+ const milestone = {
1927
+ id: nextId,
1928
+ project: targetLoc.project.code,
1929
+ title,
1930
+ description: input.description ? normalizeDashesAndMojibake(input.description).trim() : void 0,
1931
+ status: input.status || "Planned",
1932
+ featureFlag: input.featureFlag ? normalizeDashesAndMojibake(input.featureFlag).trim() : void 0,
1933
+ targetDate: input.targetDate ? normalizeDashesAndMojibake(input.targetDate).trim() : void 0,
1934
+ createdAt: now,
1935
+ updatedAt: now
1936
+ };
1937
+ existing.push(milestone);
1938
+ this.writeProjectMilestones(targetLoc, existing);
1939
+ return milestone;
1940
+ }
1941
+ async updateMilestone(id, input, projectCode) {
1942
+ const locations = this.resolveProjectLocations();
1943
+ const targetId = String(id).trim().toLowerCase();
1944
+ let foundLoc;
1945
+ let foundIndex = -1;
1946
+ for (const loc of locations) {
1947
+ if (projectCode && projectCode !== "all" && !isProjectMatch(loc.project.code, projectCode)) continue;
1948
+ const list2 = this.readProjectMilestones(loc);
1949
+ const idx = list2.findIndex(
1950
+ (m2) => String(m2.id).toLowerCase() === targetId || m2.title.toLowerCase() === targetId
1951
+ );
1952
+ if (idx >= 0) {
1953
+ foundLoc = loc;
1954
+ foundIndex = idx;
1955
+ break;
1956
+ }
1957
+ }
1958
+ if (!foundLoc || foundIndex < 0) {
1959
+ throw new Error(`Milestone '${id}' not found.`);
1960
+ }
1961
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1962
+ const list = this.readProjectMilestones(foundLoc);
1963
+ const m = list[foundIndex];
1964
+ if (input.title !== void 0) m.title = normalizeDashesAndMojibake(input.title).trim();
1965
+ if (input.description !== void 0) {
1966
+ m.description = input.description ? normalizeDashesAndMojibake(input.description).trim() : void 0;
1967
+ }
1968
+ if (input.status !== void 0) {
1969
+ m.status = input.status;
1970
+ if (input.status === "Completed" && !m.completedAt) {
1971
+ m.completedAt = now;
1972
+ } else if (input.status !== "Completed") {
1973
+ delete m.completedAt;
1974
+ }
1975
+ }
1976
+ if (input.featureFlag !== void 0) {
1977
+ if (input.featureFlag === null || input.featureFlag === "" || input.featureFlag.toLowerCase() === "none") {
1978
+ delete m.featureFlag;
1979
+ } else {
1980
+ m.featureFlag = normalizeDashesAndMojibake(input.featureFlag).trim();
1981
+ }
1982
+ }
1983
+ if (input.targetDate !== void 0) {
1984
+ if (input.targetDate === null || input.targetDate === "" || input.targetDate.toLowerCase() === "none") {
1985
+ delete m.targetDate;
1986
+ } else {
1987
+ m.targetDate = normalizeDashesAndMojibake(input.targetDate).trim();
1988
+ }
1989
+ }
1990
+ m.updatedAt = now;
1991
+ m.project = foundLoc.project.code;
1992
+ this.writeProjectMilestones(foundLoc, list);
1993
+ return m;
1994
+ }
1995
+ async deleteMilestone(id, projectCode) {
1996
+ const locations = this.resolveProjectLocations();
1997
+ const targetId = String(id).trim().toLowerCase();
1998
+ for (const loc of locations) {
1999
+ if (projectCode && projectCode !== "all" && !isProjectMatch(loc.project.code, projectCode)) continue;
2000
+ const list = this.readProjectMilestones(loc);
2001
+ const filtered = list.filter(
2002
+ (m) => String(m.id).toLowerCase() !== targetId && m.title.toLowerCase() !== targetId
2003
+ );
2004
+ if (filtered.length !== list.length) {
2005
+ this.writeProjectMilestones(loc, filtered);
2006
+ return true;
2007
+ }
2008
+ }
2009
+ return false;
2010
+ }
2011
+ findTicketLocation(id) {
2012
+ const locations = this.resolveProjectLocations();
2013
+ if (locations.length === 0) return null;
2014
+ const strId = String(id).trim();
2015
+ const prefixMatch = strId.match(/^([a-zA-Z0-9]{1,8})[-_:](\d+)$/i);
2016
+ if (prefixMatch) {
2017
+ const code = prefixMatch[1].toLowerCase();
2018
+ const num2 = parseInt(prefixMatch[2], 10);
2019
+ const loc = locations.find((l) => isProjectMatch(l.project.code, code));
2020
+ if (!loc) return null;
2021
+ const ticketDir = path3.join(loc.ticketsDir, String(num2));
2022
+ const metaPath = path3.join(ticketDir, "meta.json");
2023
+ if (fs3.existsSync(metaPath)) {
2024
+ if (loc.isSharedDir) {
2025
+ try {
2026
+ const metaRaw = fs3.readFileSync(metaPath, "utf-8");
2027
+ const meta = JSON.parse(metaRaw);
2028
+ meta.type = meta.type || meta.category || "Feature";
2029
+ meta.category = meta.type;
2030
+ const mProj = (meta.project || "").toLowerCase();
2031
+ if (mProj && mProj !== code && mProj !== loc.project.code.toLowerCase()) {
2032
+ return null;
2033
+ }
2034
+ } catch {
2035
+ }
2036
+ }
2037
+ return { loc, ticketDir, id: num2 };
2038
+ }
2039
+ return null;
2040
+ }
2041
+ const num = typeof id === "number" ? id : parseInt(strId, 10);
2042
+ if (isNaN(num)) return null;
2043
+ const matches = [];
2044
+ const checkedDirs = /* @__PURE__ */ new Set();
2045
+ for (const loc of locations) {
2046
+ const ticketDir = path3.join(loc.ticketsDir, String(num));
2047
+ const metaPath = path3.join(ticketDir, "meta.json");
2048
+ if (!fs3.existsSync(metaPath)) continue;
2049
+ if (loc.isSharedDir) {
2050
+ if (checkedDirs.has(ticketDir)) continue;
2051
+ checkedDirs.add(ticketDir);
2052
+ try {
2053
+ const metaRaw = fs3.readFileSync(metaPath, "utf-8");
2054
+ const meta = JSON.parse(metaRaw);
2055
+ const matchedLoc = locations.find(
2056
+ (l) => meta.projectId !== void 0 && l.project.id === meta.projectId || meta.project && l.project.code.toLowerCase() === meta.project.toLowerCase()
2057
+ ) || loc;
2058
+ matches.push({ loc: matchedLoc, ticketDir, id: num });
2059
+ } catch {
2060
+ matches.push({ loc, ticketDir, id: num });
2061
+ }
2062
+ } else {
2063
+ matches.push({ loc, ticketDir, id: num });
2064
+ }
2065
+ }
2066
+ if (this.config.projectCode) {
2067
+ const prefLower = this.config.projectCode.toLowerCase();
2068
+ const preferredMatch = matches.find(
2069
+ (m) => m.loc.project.code.toLowerCase() === prefLower
2070
+ );
2071
+ if (preferredMatch) return preferredMatch;
2072
+ }
2073
+ if (matches.length === 1) {
2074
+ return matches[0];
2075
+ }
2076
+ if (matches.length > 1) {
2077
+ const codes = matches.map((m) => m.loc.project.code);
2078
+ throw new Error(
2079
+ `Ambiguous ticket #${num}: exists in multiple projects (${codes.join(", ")}). Please specify as <ProjectCode>-${num} (e.g. ${codes[0]}-${num}).`
2080
+ );
2081
+ }
2082
+ return null;
2083
+ }
2084
+ async listTickets(filter) {
2085
+ const locations = this.resolveProjectLocations();
2086
+ const tickets = [];
2087
+ const processedSharedTickets = /* @__PURE__ */ new Set();
2088
+ for (const loc of locations) {
2089
+ if (filter?.project && filter.project !== "all") {
2090
+ const pLower = filter.project.toLowerCase();
1187
2091
  if (!isProjectMatch(loc.project.code, filter.project)) {
1188
2092
  continue;
1189
2093
  }
@@ -1213,6 +2117,9 @@ ${formattedList}`
1213
2117
  meta.id = meta.id ?? num;
1214
2118
  meta.type = meta.type || meta.category || "Feature";
1215
2119
  meta.category = meta.type;
2120
+ if (meta.priority) {
2121
+ meta.priority = normalizePriority(meta.priority);
2122
+ }
1216
2123
  let effectiveLoc = loc;
1217
2124
  if (loc.isSharedDir) {
1218
2125
  const found = locations.find(
@@ -1222,18 +2129,61 @@ ${formattedList}`
1222
2129
  }
1223
2130
  meta.project = effectiveLoc.project.code;
1224
2131
  meta.projectId = effectiveLoc.project.id;
2132
+ let mMatch = null;
2133
+ if (meta.milestone) {
2134
+ meta.milestone = normalizeDashesAndMojibake(meta.milestone);
2135
+ mMatch = this.resolveMilestone(meta.milestone, effectiveLoc);
2136
+ if (mMatch?.featureFlag) {
2137
+ meta.inheritedFeatureFlag = mMatch.featureFlag;
2138
+ if (!meta.featureFlag) {
2139
+ meta.featureFlag = mMatch.featureFlag;
2140
+ }
2141
+ }
2142
+ }
1225
2143
  if (filter?.project && filter.project !== "all") {
1226
2144
  const pLower = filter.project.toLowerCase();
1227
2145
  if (!isProjectMatch(meta.project, filter.project)) {
1228
2146
  continue;
1229
2147
  }
1230
2148
  }
2149
+ if (filter?.milestone) {
2150
+ const mFilter = filter.milestone.trim().toLowerCase();
2151
+ const resolvedFilterMilestone = this.resolveMilestone(filter.milestone, effectiveLoc);
2152
+ const matchesDirect = Boolean(meta.milestone && meta.milestone.toLowerCase() === mFilter);
2153
+ const matchesResolvedTitle = Boolean(
2154
+ resolvedFilterMilestone && meta.milestone && meta.milestone.toLowerCase() === resolvedFilterMilestone.title.toLowerCase()
2155
+ );
2156
+ const matchesResolvedId = Boolean(
2157
+ resolvedFilterMilestone && meta.milestone && String(resolvedFilterMilestone.id).toLowerCase() === meta.milestone.toLowerCase()
2158
+ );
2159
+ const matchesCompound = Boolean(
2160
+ resolvedFilterMilestone && meta.milestone && `${resolvedFilterMilestone.project}:${resolvedFilterMilestone.title}`.toLowerCase() === meta.milestone.toLowerCase()
2161
+ );
2162
+ const matchesResolvedSelf = Boolean(
2163
+ mMatch && (mMatch.title.toLowerCase() === mFilter || String(mMatch.id).toLowerCase() === mFilter || `${mMatch.project}:${mMatch.title}`.toLowerCase() === mFilter || `${mMatch.project}:${mMatch.id}`.toLowerCase() === mFilter)
2164
+ );
2165
+ if (!matchesDirect && !matchesResolvedTitle && !matchesResolvedId && !matchesCompound && !matchesResolvedSelf) {
2166
+ continue;
2167
+ }
2168
+ }
1231
2169
  if (filter?.status && meta.status !== filter.status) {
1232
2170
  continue;
1233
2171
  }
1234
2172
  if (filter?.category && meta.category !== filter.category) {
1235
2173
  continue;
1236
2174
  }
2175
+ if (filter?.priority !== void 0) {
2176
+ const fPrio = String(filter.priority).trim().toLowerCase();
2177
+ if (fPrio === "none" || fPrio === "null") {
2178
+ if (meta.priority) {
2179
+ continue;
2180
+ }
2181
+ } else {
2182
+ if (!meta.priority || meta.priority.toLowerCase() !== fPrio) {
2183
+ continue;
2184
+ }
2185
+ }
2186
+ }
1237
2187
  if (filter?.search) {
1238
2188
  const term = filter.search.toLowerCase();
1239
2189
  const matchesTitle = meta.title.toLowerCase().includes(term);
@@ -1268,8 +2218,24 @@ ${formattedList}`
1268
2218
  comments,
1269
2219
  projectDescriptor: effectiveLoc.project
1270
2220
  };
2221
+ normalizeTicketFields(ticketObj);
2222
+ this.enrichTicketLinks(ticketObj);
1271
2223
  ticketObj.sha1 = computeTicketHash(ticketObj);
1272
2224
  ticketObj.lastHash = ticketObj.sha1;
2225
+ if (filter?.isBlocked !== void 0 && Boolean(ticketObj.isBlocked) !== Boolean(filter.isBlocked)) {
2226
+ continue;
2227
+ }
2228
+ if (filter?.linkedTo) {
2229
+ const target = filter.linkedTo.trim().toLowerCase();
2230
+ const hasLink = ticketObj.meta.links?.some((l) => {
2231
+ const tk = l.targetKey.toLowerCase();
2232
+ const idStr = String(l.targetId);
2233
+ return tk === target || idStr === target;
2234
+ });
2235
+ if (!hasLink) {
2236
+ continue;
2237
+ }
2238
+ }
1273
2239
  tickets.push(ticketObj);
1274
2240
  } catch {
1275
2241
  }
@@ -1293,6 +2259,19 @@ ${formattedList}`
1293
2259
  meta.id = meta.id ?? locInfo.id;
1294
2260
  meta.project = locInfo.loc.project.code;
1295
2261
  meta.projectId = locInfo.loc.project.id;
2262
+ if (meta.priority) {
2263
+ meta.priority = normalizePriority(meta.priority);
2264
+ }
2265
+ if (meta.milestone) {
2266
+ meta.milestone = normalizeDashesAndMojibake(meta.milestone);
2267
+ const mMatch = this.resolveMilestone(meta.milestone, locInfo.loc);
2268
+ if (mMatch?.featureFlag) {
2269
+ meta.inheritedFeatureFlag = mMatch.featureFlag;
2270
+ if (!meta.featureFlag) {
2271
+ meta.featureFlag = mMatch.featureFlag;
2272
+ }
2273
+ }
2274
+ }
1296
2275
  let detail;
1297
2276
  const detailPath = path3.join(locInfo.ticketDir, "detail.md");
1298
2277
  if (fs3.existsSync(detailPath)) {
@@ -1319,6 +2298,8 @@ ${formattedList}`
1319
2298
  comments,
1320
2299
  projectDescriptor: locInfo.loc.project
1321
2300
  };
2301
+ normalizeTicketFields(ticket);
2302
+ this.enrichTicketLinks(ticket);
1322
2303
  ticket.sha1 = computeTicketHash(ticket);
1323
2304
  ticket.lastHash = ticket.sha1;
1324
2305
  return ticket;
@@ -1368,10 +2349,13 @@ ${formattedList}`
1368
2349
  if (itemMatch) openQuestions.push(itemMatch[1].trim());
1369
2350
  }
1370
2351
  }
2352
+ const priorityMatch = raw.match(/\*\*Priority\*\*:\s*([^\n]+)/i);
2353
+ const priority = priorityMatch ? normalizePriority(priorityMatch[1].trim()) : void 0;
1371
2354
  return {
1372
2355
  title,
1373
2356
  type,
1374
2357
  category,
2358
+ priority,
1375
2359
  complexity,
1376
2360
  estimatedEffort,
1377
2361
  summary,
@@ -1411,13 +2395,40 @@ ${formattedList}`
1411
2395
  fs3.mkdirSync(ticketDir, { recursive: true });
1412
2396
  const now = (/* @__PURE__ */ new Date()).toISOString();
1413
2397
  const effectiveType = input.type || input.category || "Feature";
2398
+ const cleanTitle = normalizeDashesAndMojibake(input.title).slice(0, 48).trim();
2399
+ const cleanEffort = normalizeDashesAndMojibake(input.estimatedEffort || input.effort || "2.0 - 4.0 hours");
2400
+ const cleanComplexity = normalizeDashesAndMojibake(input.complexity || "Medium");
2401
+ const cleanPriority = normalizePriority(input.priority);
2402
+ let cleanMilestone = input.milestone ? normalizeDashesAndMojibake(String(input.milestone)).trim() : void 0;
2403
+ let inheritedFlag;
2404
+ let initialFlag;
2405
+ if (cleanMilestone) {
2406
+ const pMilestones = this.readProjectMilestones(targetLoc);
2407
+ const mMatch = pMilestones.find(
2408
+ (m) => String(m.id).toLowerCase() === cleanMilestone?.toLowerCase() || m.title.toLowerCase() === cleanMilestone?.toLowerCase()
2409
+ );
2410
+ if (mMatch?.featureFlag) {
2411
+ inheritedFlag = mMatch.featureFlag;
2412
+ initialFlag = mMatch.featureFlag;
2413
+ }
2414
+ }
2415
+ let cleanFeatureFlag = input.featureFlag ? normalizeDashesAndMojibake(input.featureFlag).trim() : void 0;
2416
+ if (!cleanFeatureFlag && initialFlag) {
2417
+ cleanFeatureFlag = initialFlag;
2418
+ }
2419
+ const cleanSummary = input.summary ? normalizeDashesAndMojibake(input.summary) : void 0;
2420
+ const cleanDetailRaw = input.detailMarkdown || input.detail ? normalizeDashesAndMojibake(input.detailMarkdown || input.detail) : void 0;
1414
2421
  const meta = {
1415
2422
  id: nextId,
1416
- title: input.title.slice(0, 48).trim(),
2423
+ title: cleanTitle,
1417
2424
  type: effectiveType,
1418
2425
  category: effectiveType,
1419
- complexity: input.complexity || "Medium",
1420
- estimatedEffort: input.estimatedEffort || input.effort || "2.0 - 4.0 hours",
2426
+ ...cleanPriority ? { priority: cleanPriority } : {},
2427
+ ...cleanMilestone ? { milestone: cleanMilestone } : {},
2428
+ ...cleanFeatureFlag ? { featureFlag: cleanFeatureFlag } : {},
2429
+ ...inheritedFlag ? { inheritedFeatureFlag: inheritedFlag } : {},
2430
+ complexity: cleanComplexity,
2431
+ estimatedEffort: cleanEffort,
1421
2432
  submittedBy: input.submittedBy || "Developer",
1422
2433
  timestamp: now,
1423
2434
  createdAt: now,
@@ -1428,7 +2439,11 @@ ${formattedList}`
1428
2439
  projectId: targetLoc.project.id,
1429
2440
  project: targetLoc.project.code
1430
2441
  };
1431
- const rawDetail = (input.detailMarkdown || input.detail)?.trim();
2442
+ const rawDetail = cleanDetailRaw?.trim();
2443
+ const priorityLine = meta.priority ? `**Priority**: ${meta.priority}
2444
+ ` : "";
2445
+ const milestoneLine = meta.milestone ? `**Milestone**: ${meta.milestone}
2446
+ ` : "";
1432
2447
  let detailMd;
1433
2448
  if (rawDetail) {
1434
2449
  if (/^#\s+[^\n]+/m.test(rawDetail)) {
@@ -1436,7 +2451,7 @@ ${formattedList}`
1436
2451
  if (!/\*\*(?:Type|Category)\*\*:/i.test(processed)) {
1437
2452
  const metaBlock = `
1438
2453
  **Category**: ${meta.category}
1439
- **Complexity**: ${meta.complexity}
2454
+ ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
1440
2455
  **Estimated Effort**: ${meta.estimatedEffort}
1441
2456
  `;
1442
2457
  processed = processed.replace(/^(# Ticket[^\n]+\n)/m, `$1${metaBlock}`);
@@ -1476,7 +2491,7 @@ ${rawDetail}
1476
2491
  }
1477
2492
  detailMd = `# Ticket #${nextId}: ${meta.title}
1478
2493
  **Category**: ${meta.category}
1479
- **Complexity**: ${meta.complexity}
2494
+ ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
1480
2495
  **Estimated Effort**: ${meta.estimatedEffort}
1481
2496
 
1482
2497
  ${body.trim()}
@@ -1485,7 +2500,7 @@ ${body.trim()}
1485
2500
  } else {
1486
2501
  detailMd = `# Ticket #${nextId}: ${meta.title}
1487
2502
  **Category**: ${meta.category}
1488
- **Complexity**: ${meta.complexity}
2503
+ ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
1489
2504
  **Estimated Effort**: ${meta.estimatedEffort}
1490
2505
 
1491
2506
  ### Summary
@@ -1527,22 +2542,61 @@ ${input.summary || "Summary to be defined."}
1527
2542
  const isNowCompleted = updates.status === "Completed";
1528
2543
  const completedAt = isNowCompleted ? existing.meta.completedAt || now : updates.status && updates.status !== "Completed" ? void 0 : existing.meta.completedAt;
1529
2544
  const revision = (existing.meta.revision || 1) + 1;
2545
+ const sanitizedUpdates = { ...updates };
2546
+ if (sanitizedUpdates.title) sanitizedUpdates.title = normalizeDashesAndMojibake(sanitizedUpdates.title);
2547
+ if (sanitizedUpdates.estimatedEffort) sanitizedUpdates.estimatedEffort = normalizeDashesAndMojibake(sanitizedUpdates.estimatedEffort);
2548
+ if (sanitizedUpdates.complexity) sanitizedUpdates.complexity = normalizeDashesAndMojibake(sanitizedUpdates.complexity);
1530
2549
  const updatedMeta = {
1531
2550
  ...existing.meta,
1532
- ...updates,
2551
+ ...sanitizedUpdates,
1533
2552
  id: locInfo.id,
1534
2553
  project: locInfo.loc.project.code,
1535
2554
  updatedAt: now,
1536
2555
  completedAt,
1537
2556
  revision
1538
2557
  };
2558
+ if ("priority" in updates || updates.priority !== void 0) {
2559
+ const rawPrio = updates.priority;
2560
+ if (rawPrio === null || rawPrio === "none" || rawPrio === "null" || rawPrio === "") {
2561
+ delete updatedMeta.priority;
2562
+ } else {
2563
+ const norm = normalizePriority(rawPrio);
2564
+ if (norm) {
2565
+ updatedMeta.priority = norm;
2566
+ } else {
2567
+ delete updatedMeta.priority;
2568
+ }
2569
+ }
2570
+ }
2571
+ if ("milestone" in updates || updates.milestone !== void 0) {
2572
+ const rawMilestone = updates.milestone;
2573
+ if (rawMilestone === null || rawMilestone === "none" || rawMilestone === "null" || rawMilestone === "") {
2574
+ delete updatedMeta.milestone;
2575
+ if (updatedMeta.inheritedFeatureFlag && updatedMeta.featureFlag === updatedMeta.inheritedFeatureFlag) {
2576
+ delete updatedMeta.featureFlag;
2577
+ }
2578
+ delete updatedMeta.inheritedFeatureFlag;
2579
+ } else {
2580
+ updatedMeta.milestone = normalizeDashesAndMojibake(String(rawMilestone)).trim();
2581
+ const pMilestones = this.readProjectMilestones(locInfo.loc);
2582
+ const mMatch = pMilestones.find(
2583
+ (m) => String(m.id).toLowerCase() === updatedMeta.milestone?.toLowerCase() || m.title.toLowerCase() === updatedMeta.milestone?.toLowerCase()
2584
+ );
2585
+ if (mMatch?.featureFlag) {
2586
+ updatedMeta.inheritedFeatureFlag = mMatch.featureFlag;
2587
+ if (!updatedMeta.featureFlag) {
2588
+ updatedMeta.featureFlag = mMatch.featureFlag;
2589
+ }
2590
+ }
2591
+ }
2592
+ }
1539
2593
  writeSafeFile(metaPath, JSON.stringify(updatedMeta, null, 2) + "\n");
1540
2594
  const newType = updates.type || updates.category;
1541
2595
  if (newType) {
1542
2596
  updatedMeta.type = newType;
1543
2597
  updatedMeta.category = newType;
1544
2598
  }
1545
- if (existing.detail && (updates.title || updates.type || updates.category || updates.complexity || updates.estimatedEffort)) {
2599
+ if (existing.detail && ("priority" in updates || "milestone" in updates || updates.title || updates.type || updates.category || updates.complexity || updates.estimatedEffort)) {
1546
2600
  let content = existing.detail.raw;
1547
2601
  if (updates.title) {
1548
2602
  content = content.replace(/^#\s*(?:Ticket\s*#?\d+\s*:\s*|\d+\s*:\s*)?[^\n]+/im, `# Ticket #${locInfo.id}: ${updatedMeta.title}`);
@@ -1550,77 +2604,321 @@ ${input.summary || "Summary to be defined."}
1550
2604
  if (updates.type || updates.category) {
1551
2605
  content = content.replace(/\*\*(?:Type|Category)\*\*:\s*[^\n]+/i, `**Type**: ${updatedMeta.type}`);
1552
2606
  }
2607
+ if ("priority" in updates) {
2608
+ if (updatedMeta.priority) {
2609
+ if (/\*\*Priority\*\*:\s*[^\n]+/i.test(content)) {
2610
+ content = content.replace(/\*\*Priority\*\*:\s*[^\n]+/i, `**Priority**: ${updatedMeta.priority}`);
2611
+ } else if (/\*\*(?:Type|Category)\*\*:\s*[^\n]+/i.test(content)) {
2612
+ content = content.replace(/(\*\*(?:Type|Category)\*\*:\s*[^\n]+)/i, `$1
2613
+ **Priority**: ${updatedMeta.priority}`);
2614
+ }
2615
+ } else {
2616
+ content = content.replace(/\*\*Priority\*\*:\s*[^\n]+\n?/i, "");
2617
+ }
2618
+ }
2619
+ if ("milestone" in updates) {
2620
+ if (updatedMeta.milestone) {
2621
+ if (/\*\*Milestone\*\*:\s*[^\n]+/i.test(content)) {
2622
+ content = content.replace(/\*\*Milestone\*\*:\s*[^\n]+/i, `**Milestone**: ${updatedMeta.milestone}`);
2623
+ } else if (/\*\*(?:Priority|Type|Category)\*\*:\s*[^\n]+/i.test(content)) {
2624
+ content = content.replace(/(\*\*(?:Priority|Type|Category)\*\*:\s*[^\n]+)/i, `$1
2625
+ **Milestone**: ${updatedMeta.milestone}`);
2626
+ }
2627
+ } else {
2628
+ content = content.replace(/\*\*Milestone\*\*:\s*[^\n]+\n?/i, "");
2629
+ }
2630
+ }
1553
2631
  if (updates.complexity) {
1554
2632
  content = content.replace(/\*\*Complexity\*\*:\s*[^\n]+/i, `**Complexity**: ${updatedMeta.complexity}`);
1555
2633
  }
1556
2634
  if (updates.estimatedEffort) {
1557
2635
  content = content.replace(/\*\*Estimated Effort\*\*:\s*[^\n]+/i, `**Estimated Effort**: ${updatedMeta.estimatedEffort}`);
1558
2636
  }
1559
- writeSafeFile(path3.join(locInfo.ticketDir, "detail.md"), content);
2637
+ writeSafeFile(path3.join(locInfo.ticketDir, "detail.md"), content);
2638
+ }
2639
+ return await this.getTicket(`${locInfo.loc.project.code}-${locInfo.id}`);
2640
+ }
2641
+ async getPlan(id) {
2642
+ const locInfo = this.findTicketLocation(id);
2643
+ if (!locInfo) return null;
2644
+ const planPath = path3.join(locInfo.ticketDir, "implementation_plan.md");
2645
+ if (!fs3.existsSync(planPath)) return null;
2646
+ return normalizeDashesAndMojibake(fs3.readFileSync(planPath, "utf-8"));
2647
+ }
2648
+ async savePlan(id, planMarkdown, lastHash) {
2649
+ const ticket = await this.getTicket(id);
2650
+ if (!ticket) {
2651
+ throw new Error(`Ticket #${id} does not exist`);
2652
+ }
2653
+ if (lastHash !== void 0) {
2654
+ const currentHash = ticket.sha1 || computeTicketHash(ticket);
2655
+ if (!verifyTicketHash(currentHash, lastHash)) {
2656
+ throw new EsedreConflictError(id, currentHash, lastHash);
2657
+ }
2658
+ }
2659
+ const locInfo = this.findTicketLocation(id);
2660
+ if (!locInfo) {
2661
+ throw new Error(`Ticket #${id} could not be located on disk`);
2662
+ }
2663
+ const planPath = path3.join(locInfo.ticketDir, "implementation_plan.md");
2664
+ writeSafeFile(planPath, normalizeDashesAndMojibake(planMarkdown));
2665
+ const metaPath = path3.join(locInfo.ticketDir, "meta.json");
2666
+ if (fs3.existsSync(metaPath)) {
2667
+ try {
2668
+ const meta = JSON.parse(fs3.readFileSync(metaPath, "utf-8"));
2669
+ meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2670
+ meta.revision = (meta.revision || 1) + 1;
2671
+ writeSafeFile(metaPath, JSON.stringify(meta, null, 2) + "\n");
2672
+ } catch {
2673
+ }
2674
+ }
2675
+ }
2676
+ async addComment(id, comment) {
2677
+ const ticket = await this.getTicket(id);
2678
+ if (!ticket) {
2679
+ throw new Error(`Ticket #${id} does not exist`);
2680
+ }
2681
+ const locInfo = this.findTicketLocation(id);
2682
+ if (!locInfo) {
2683
+ throw new Error(`Ticket #${id} could not be located on disk`);
2684
+ }
2685
+ const commentsPath = path3.join(locInfo.ticketDir, "comments.json");
2686
+ let comments = [];
2687
+ if (fs3.existsSync(commentsPath)) {
2688
+ try {
2689
+ comments = JSON.parse(fs3.readFileSync(commentsPath, "utf-8"));
2690
+ } catch {
2691
+ }
2692
+ }
2693
+ const newComment = {
2694
+ id: `${locInfo.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
2695
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2696
+ author: normalizeDashesAndMojibake(comment.author || "User"),
2697
+ text: normalizeDashesAndMojibake(comment.text.trim())
2698
+ };
2699
+ comments.push(newComment);
2700
+ writeSafeFile(commentsPath, JSON.stringify(comments, null, 2) + "\n");
2701
+ return newComment;
2702
+ }
2703
+ resolveTicketIdentifier(id, defaultProject) {
2704
+ const str = String(id).trim();
2705
+ if (!/^([a-zA-Z0-9]{1,8})[-_:](\d+)$/i.test(str) && defaultProject && defaultProject !== "all") {
2706
+ const candidate = this.findTicketLocation(`${defaultProject}-${str}`);
2707
+ if (candidate) {
2708
+ return {
2709
+ ...candidate,
2710
+ key: `${candidate.loc.project.code}-${candidate.id}`,
2711
+ project: candidate.loc.project.code
2712
+ };
2713
+ }
2714
+ }
2715
+ const loc = this.findTicketLocation(id);
2716
+ if (!loc) return null;
2717
+ return {
2718
+ ...loc,
2719
+ key: `${loc.loc.project.code}-${loc.id}`,
2720
+ project: loc.loc.project.code
2721
+ };
2722
+ }
2723
+ wouldCreateCycle(sourceKey, targetKey, relation) {
2724
+ if (relation !== "blocks" && relation !== "parent-of") {
2725
+ return false;
2726
+ }
2727
+ const sLower = sourceKey.toLowerCase();
2728
+ const visited = /* @__PURE__ */ new Set();
2729
+ const queue = [targetKey.toLowerCase()];
2730
+ while (queue.length > 0) {
2731
+ const current = queue.shift();
2732
+ if (current === sLower) {
2733
+ return true;
2734
+ }
2735
+ if (visited.has(current)) continue;
2736
+ visited.add(current);
2737
+ const loc = this.findTicketLocation(current);
2738
+ if (!loc) continue;
2739
+ const metaPath = path3.join(loc.ticketDir, "meta.json");
2740
+ if (!fs3.existsSync(metaPath)) continue;
2741
+ try {
2742
+ const meta = JSON.parse(fs3.readFileSync(metaPath, "utf-8"));
2743
+ if (Array.isArray(meta.links)) {
2744
+ for (const l of meta.links) {
2745
+ if (l.relation === relation) {
2746
+ queue.push(l.targetKey.toLowerCase());
2747
+ }
2748
+ }
2749
+ }
2750
+ } catch {
2751
+ }
2752
+ }
2753
+ return false;
2754
+ }
2755
+ enrichTicketLinks(ticket) {
2756
+ if (!ticket.meta.links || ticket.meta.links.length === 0) {
2757
+ ticket.links = [];
2758
+ ticket.isBlocked = false;
2759
+ return;
2760
+ }
2761
+ const enriched = [];
2762
+ let isBlocked = false;
2763
+ for (const link of ticket.meta.links) {
2764
+ const enrichedLink = { ...link, isResolved: false };
2765
+ const targetLoc = this.findTicketLocation(link.targetKey);
2766
+ if (targetLoc) {
2767
+ const targetMetaPath = path3.join(targetLoc.ticketDir, "meta.json");
2768
+ if (fs3.existsSync(targetMetaPath)) {
2769
+ try {
2770
+ const targetMeta = JSON.parse(fs3.readFileSync(targetMetaPath, "utf-8"));
2771
+ enrichedLink.targetTitle = targetMeta.title;
2772
+ enrichedLink.targetType = targetMeta.type || targetMeta.category || "Feature";
2773
+ enrichedLink.targetStatus = targetMeta.status || "Planned";
2774
+ enrichedLink.isTargetCompleted = targetMeta.status === "Completed";
2775
+ if (targetMeta.priority) {
2776
+ enrichedLink.targetPriority = normalizePriority(targetMeta.priority);
2777
+ }
2778
+ enrichedLink.isResolved = true;
2779
+ if (link.relation === "blocked-by" && targetMeta.status !== "Completed") {
2780
+ enrichedLink.isBlockedByUncompleted = true;
2781
+ isBlocked = true;
2782
+ }
2783
+ } catch {
2784
+ }
2785
+ }
2786
+ }
2787
+ enriched.push(enrichedLink);
1560
2788
  }
1561
- return await this.getTicket(`${locInfo.loc.project.code}-${locInfo.id}`);
1562
- }
1563
- async getPlan(id) {
1564
- const locInfo = this.findTicketLocation(id);
1565
- if (!locInfo) return null;
1566
- const planPath = path3.join(locInfo.ticketDir, "implementation_plan.md");
1567
- if (!fs3.existsSync(planPath)) return null;
1568
- return fs3.readFileSync(planPath, "utf-8");
2789
+ ticket.links = enriched;
2790
+ ticket.isBlocked = isBlocked;
1569
2791
  }
1570
- async savePlan(id, planMarkdown, lastHash) {
1571
- const ticket = await this.getTicket(id);
1572
- if (!ticket) {
1573
- throw new Error(`Ticket #${id} does not exist`);
2792
+ async addTicketLink(sourceId, relation, targetId, options) {
2793
+ if (!INVERSE_RELATIONS[relation]) {
2794
+ throw new Error(
2795
+ `Invalid link relation: "${relation}". Valid relations: ${Object.keys(INVERSE_RELATIONS).join(", ")}`
2796
+ );
1574
2797
  }
1575
- if (lastHash !== void 0) {
1576
- const currentHash = ticket.sha1 || computeTicketHash(ticket);
1577
- if (!verifyTicketHash(currentHash, lastHash)) {
1578
- throw new EsedreConflictError(id, currentHash, lastHash);
1579
- }
2798
+ const sourceInfo = this.resolveTicketIdentifier(sourceId, options?.project);
2799
+ if (!sourceInfo) {
2800
+ throw new Error(`Source ticket #${sourceId} could not be located.`);
1580
2801
  }
1581
- const locInfo = this.findTicketLocation(id);
1582
- if (!locInfo) {
1583
- throw new Error(`Ticket #${id} could not be located on disk`);
2802
+ const targetInfo = this.resolveTicketIdentifier(targetId, options?.project || sourceInfo.project);
2803
+ if (!targetInfo) {
2804
+ throw new Error(`Target ticket #${targetId} could not be located.`);
1584
2805
  }
1585
- const planPath = path3.join(locInfo.ticketDir, "implementation_plan.md");
1586
- writeSafeFile(planPath, planMarkdown);
1587
- const metaPath = path3.join(locInfo.ticketDir, "meta.json");
1588
- if (fs3.existsSync(metaPath)) {
2806
+ if (sourceInfo.key.toLowerCase() === targetInfo.key.toLowerCase()) {
2807
+ throw new Error(`Cannot link ticket ${sourceInfo.key} to itself.`);
2808
+ }
2809
+ if (this.wouldCreateCycle(sourceInfo.key, targetInfo.key, relation)) {
2810
+ throw new Error(
2811
+ `Circular dependency detected: linking ${sourceInfo.key} -> ${targetInfo.key} with "${relation}" forms a cycle.`
2812
+ );
2813
+ }
2814
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2815
+ const sourceMetaPath = path3.join(sourceInfo.ticketDir, "meta.json");
2816
+ const sourceMeta = JSON.parse(fs3.readFileSync(sourceMetaPath, "utf-8"));
2817
+ sourceMeta.links = sourceMeta.links || [];
2818
+ const existingSourceLink = sourceMeta.links.find(
2819
+ (l) => l.targetKey.toLowerCase() === targetInfo.key.toLowerCase()
2820
+ );
2821
+ if (existingSourceLink) {
2822
+ existingSourceLink.relation = relation;
2823
+ existingSourceLink.targetProject = targetInfo.project;
2824
+ existingSourceLink.targetId = targetInfo.id;
2825
+ } else {
2826
+ sourceMeta.links.push({
2827
+ relation,
2828
+ targetKey: targetInfo.key,
2829
+ targetProject: targetInfo.project,
2830
+ targetId: targetInfo.id,
2831
+ createdAt: now,
2832
+ createdBy: options?.author ? normalizeDashesAndMojibake(options.author) : void 0
2833
+ });
2834
+ }
2835
+ sourceMeta.updatedAt = now;
2836
+ sourceMeta.revision = (sourceMeta.revision || 1) + 1;
2837
+ writeSafeFile(sourceMetaPath, JSON.stringify(sourceMeta, null, 2) + "\n");
2838
+ let updatedTarget;
2839
+ const targetMetaPath = path3.join(targetInfo.ticketDir, "meta.json");
2840
+ if (fs3.existsSync(targetMetaPath)) {
1589
2841
  try {
1590
- const meta = JSON.parse(fs3.readFileSync(metaPath, "utf-8"));
1591
- meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1592
- meta.revision = (meta.revision || 1) + 1;
1593
- writeSafeFile(metaPath, JSON.stringify(meta, null, 2) + "\n");
2842
+ const targetMeta = JSON.parse(fs3.readFileSync(targetMetaPath, "utf-8"));
2843
+ targetMeta.links = targetMeta.links || [];
2844
+ const inverseRel = INVERSE_RELATIONS[relation];
2845
+ const existingTargetLink = targetMeta.links.find(
2846
+ (l) => l.targetKey.toLowerCase() === sourceInfo.key.toLowerCase()
2847
+ );
2848
+ if (existingTargetLink) {
2849
+ existingTargetLink.relation = inverseRel;
2850
+ existingTargetLink.targetProject = sourceInfo.project;
2851
+ existingTargetLink.targetId = sourceInfo.id;
2852
+ } else {
2853
+ targetMeta.links.push({
2854
+ relation: inverseRel,
2855
+ targetKey: sourceInfo.key,
2856
+ targetProject: sourceInfo.project,
2857
+ targetId: sourceInfo.id,
2858
+ createdAt: now,
2859
+ createdBy: options?.author ? normalizeDashesAndMojibake(options.author) : void 0
2860
+ });
2861
+ }
2862
+ targetMeta.updatedAt = now;
2863
+ targetMeta.revision = (targetMeta.revision || 1) + 1;
2864
+ writeSafeFile(targetMetaPath, JSON.stringify(targetMeta, null, 2) + "\n");
2865
+ updatedTarget = await this.getTicket(targetInfo.key) || void 0;
1594
2866
  } catch {
1595
2867
  }
1596
2868
  }
2869
+ const updatedSource = await this.getTicket(sourceInfo.key);
2870
+ return { source: updatedSource, target: updatedTarget };
1597
2871
  }
1598
- async addComment(id, comment) {
1599
- const ticket = await this.getTicket(id);
1600
- if (!ticket) {
1601
- throw new Error(`Ticket #${id} does not exist`);
1602
- }
1603
- const locInfo = this.findTicketLocation(id);
1604
- if (!locInfo) {
1605
- throw new Error(`Ticket #${id} could not be located on disk`);
2872
+ async removeTicketLink(sourceId, targetId, options) {
2873
+ const sourceInfo = this.resolveTicketIdentifier(sourceId, options?.project);
2874
+ if (!sourceInfo) {
2875
+ throw new Error(`Source ticket #${sourceId} could not be located.`);
1606
2876
  }
1607
- const commentsPath = path3.join(locInfo.ticketDir, "comments.json");
1608
- let comments = [];
1609
- if (fs3.existsSync(commentsPath)) {
2877
+ const targetInfo = this.resolveTicketIdentifier(targetId, options?.project || sourceInfo.project);
2878
+ const targetKey = targetInfo ? targetInfo.key : String(targetId).trim();
2879
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2880
+ const sourceMetaPath = path3.join(sourceInfo.ticketDir, "meta.json");
2881
+ if (fs3.existsSync(sourceMetaPath)) {
1610
2882
  try {
1611
- comments = JSON.parse(fs3.readFileSync(commentsPath, "utf-8"));
2883
+ const sourceMeta = JSON.parse(fs3.readFileSync(sourceMetaPath, "utf-8"));
2884
+ if (Array.isArray(sourceMeta.links)) {
2885
+ const originalLen = sourceMeta.links.length;
2886
+ sourceMeta.links = sourceMeta.links.filter(
2887
+ (l) => !(l.targetKey.toLowerCase() === targetKey.toLowerCase() && (!options?.relation || l.relation === options.relation))
2888
+ );
2889
+ if (sourceMeta.links.length !== originalLen) {
2890
+ sourceMeta.updatedAt = now;
2891
+ sourceMeta.revision = (sourceMeta.revision || 1) + 1;
2892
+ writeSafeFile(sourceMetaPath, JSON.stringify(sourceMeta, null, 2) + "\n");
2893
+ }
2894
+ }
1612
2895
  } catch {
1613
2896
  }
1614
2897
  }
1615
- const newComment = {
1616
- id: `${locInfo.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
1617
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1618
- author: comment.author || "User",
1619
- text: comment.text.trim()
1620
- };
1621
- comments.push(newComment);
1622
- writeSafeFile(commentsPath, JSON.stringify(comments, null, 2) + "\n");
1623
- return newComment;
2898
+ let updatedTarget;
2899
+ if (targetInfo) {
2900
+ const targetMetaPath = path3.join(targetInfo.ticketDir, "meta.json");
2901
+ if (fs3.existsSync(targetMetaPath)) {
2902
+ try {
2903
+ const targetMeta = JSON.parse(fs3.readFileSync(targetMetaPath, "utf-8"));
2904
+ if (Array.isArray(targetMeta.links)) {
2905
+ const originalLen = targetMeta.links.length;
2906
+ targetMeta.links = targetMeta.links.filter(
2907
+ (l) => !(l.targetKey.toLowerCase() === sourceInfo.key.toLowerCase() && (!options?.relation || l.relation === INVERSE_RELATIONS[options.relation]))
2908
+ );
2909
+ if (targetMeta.links.length !== originalLen) {
2910
+ targetMeta.updatedAt = now;
2911
+ targetMeta.revision = (targetMeta.revision || 1) + 1;
2912
+ writeSafeFile(targetMetaPath, JSON.stringify(targetMeta, null, 2) + "\n");
2913
+ updatedTarget = await this.getTicket(targetInfo.key) || void 0;
2914
+ }
2915
+ }
2916
+ } catch {
2917
+ }
2918
+ }
2919
+ }
2920
+ const updatedSource = await this.getTicket(sourceInfo.key);
2921
+ return { source: updatedSource, target: updatedTarget };
1624
2922
  }
1625
2923
  };
1626
2924
 
@@ -1652,19 +2950,56 @@ var SecurityFilter = class {
1652
2950
  }
1653
2951
  return proj;
1654
2952
  }
2953
+ async updateProject(input) {
2954
+ if (!isProjectAuthorized(input.code, this.config.allowedProjects)) {
2955
+ throw new EsedreAuthorizationError(input.code);
2956
+ }
2957
+ return this.target.updateProject(input);
2958
+ }
2959
+ async renameProjectCode(input) {
2960
+ if (!isProjectAuthorized(input.oldCode, this.config.allowedProjects)) {
2961
+ throw new EsedreAuthorizationError(input.oldCode);
2962
+ }
2963
+ const result = await this.target.renameProjectCode(input);
2964
+ if (this.config.allowedProjects && !this.config.allowedProjects.includes("*")) {
2965
+ const oldUpper = input.oldCode.toUpperCase();
2966
+ const idx = this.config.allowedProjects.findIndex((p) => p.toUpperCase() === oldUpper);
2967
+ if (idx >= 0) {
2968
+ this.config.allowedProjects[idx] = result.newCode;
2969
+ }
2970
+ }
2971
+ return result;
2972
+ }
2973
+ redactLinks(ticket) {
2974
+ if (!ticket.links || ticket.links.length === 0) return ticket;
2975
+ if (!this.config.allowedProjects || this.config.allowedProjects.includes("*")) {
2976
+ return ticket;
2977
+ }
2978
+ for (const link of ticket.links) {
2979
+ if (link.targetProject && !isProjectAuthorized(link.targetProject, this.config.allowedProjects)) {
2980
+ link.targetTitle = "[Restricted Project]";
2981
+ delete link.targetType;
2982
+ delete link.targetStatus;
2983
+ delete link.targetPriority;
2984
+ delete link.isBlockedByUncompleted;
2985
+ link.isResolved = false;
2986
+ }
2987
+ }
2988
+ return ticket;
2989
+ }
1655
2990
  async listTickets(filter) {
1656
2991
  if (filter?.project && !isProjectAuthorized(filter.project, this.config.allowedProjects)) {
1657
2992
  throw new EsedreAuthorizationError(filter.project);
1658
2993
  }
1659
2994
  const tickets = await this.target.listTickets(filter);
1660
2995
  if (!this.config.allowedProjects || this.config.allowedProjects.length === 0) {
1661
- return tickets;
2996
+ return tickets.map((t) => this.redactLinks(t));
1662
2997
  }
1663
2998
  return tickets.filter((t) => {
1664
2999
  const code = t.projectDescriptor?.code || t.meta.project;
1665
3000
  if (!code) return true;
1666
3001
  return isProjectAuthorized(code, this.config.allowedProjects);
1667
- });
3002
+ }).map((t) => this.redactLinks(t));
1668
3003
  }
1669
3004
  async getTicket(id) {
1670
3005
  const ticket = await this.target.getTicket(id);
@@ -1673,7 +3008,7 @@ var SecurityFilter = class {
1673
3008
  if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
1674
3009
  throw new EsedreAuthorizationError(code);
1675
3010
  }
1676
- return ticket;
3011
+ return this.redactLinks(ticket);
1677
3012
  }
1678
3013
  async createTicket(input) {
1679
3014
  const targetCode = input.projectCode || this.config.projectCode;
@@ -1725,132 +3060,88 @@ var SecurityFilter = class {
1725
3060
  }
1726
3061
  return this.target.addComment(id, comment);
1727
3062
  }
1728
- };
1729
-
1730
- // src/utils/formatter.ts
1731
- var isColorSupported = !process.env.NO_COLOR && (process.stdout.isTTY || process.env.FORCE_COLOR);
1732
- var colors = {
1733
- reset: isColorSupported ? "\x1B[0m" : "",
1734
- bold: isColorSupported ? "\x1B[1m" : "",
1735
- dim: isColorSupported ? "\x1B[2m" : "",
1736
- cyan: isColorSupported ? "\x1B[36m" : "",
1737
- green: isColorSupported ? "\x1B[32m" : "",
1738
- yellow: isColorSupported ? "\x1B[33m" : "",
1739
- magenta: isColorSupported ? "\x1B[35m" : "",
1740
- red: isColorSupported ? "\x1B[31m" : "",
1741
- blue: isColorSupported ? "\x1B[34m" : ""
1742
- };
1743
- function formatTicketListTable(tickets) {
1744
- if (tickets.length === 0) {
1745
- return `${colors.dim}No tickets found matching criteria.${colors.reset}`;
1746
- }
1747
- const rows = tickets.map((t) => {
1748
- const id = t.projectDescriptor?.code ? `${t.projectDescriptor.code}-${t.meta.id}` : `#${t.meta.id}`;
1749
- const project = t.projectDescriptor?.code || "CORE";
1750
- const type = t.meta.type || t.meta.category || "Feature";
1751
- const status = t.meta.status;
1752
- const title = t.meta.title;
1753
- return { id, project, type, status, title };
1754
- });
1755
- const idWidth = Math.max(4, ...rows.map((r) => r.id.length));
1756
- const projWidth = Math.max(7, ...rows.map((r) => r.project.length));
1757
- const typeWidth = Math.max(8, ...rows.map((r) => r.type.length));
1758
- const statusWidth = Math.max(14, ...rows.map((r) => r.status.length));
1759
- const header = `${colors.bold}${pad("ID", idWidth)} ${pad("Project", projWidth)} ${pad("Type", typeWidth)} ${pad("Status", statusWidth)} Title${colors.reset}`;
1760
- const divider = `${colors.dim}${"-".repeat(idWidth)} ${"-".repeat(projWidth)} ${"-".repeat(typeWidth)} ${"-".repeat(statusWidth)} ${"-".repeat(40)}${colors.reset}`;
1761
- const formattedRows = rows.map((r) => {
1762
- const statusColored = colorStatus(r.status);
1763
- const typeColored = colorType(r.type);
1764
- const projColored = `${colors.magenta}${r.project}${colors.reset}`;
1765
- return `${colors.bold}${pad(r.id, idWidth)}${colors.reset} ${pad(projColored, projWidth + (isColorSupported ? colors.magenta.length + colors.reset.length : 0))} ${pad(typeColored, typeWidth + (isColorSupported ? 9 : 0))} ${pad(statusColored, statusWidth + (isColorSupported ? 9 : 0))} ${r.title}`;
1766
- });
1767
- return [header, divider, ...formattedRows].join("\n");
1768
- }
1769
- function formatTicketDetail(ticket) {
1770
- const { meta, detail, comments, planMarkdown, projectDescriptor } = ticket;
1771
- const lines = [];
1772
- lines.push(`${colors.bold}${colors.cyan}Ticket #${meta.project ? `${meta.project}-${meta.id}` : meta.id}: ${meta.title}${colors.reset}`);
1773
- lines.push(`${colors.dim}${"=".repeat(60)}${colors.reset}`);
1774
- lines.push(`${colors.bold}Project:${colors.reset} ${projectDescriptor ? `${projectDescriptor.code} - ${projectDescriptor.name}` : "Default"}`);
1775
- lines.push(`${colors.bold}Type:${colors.reset} ${colorType(meta.type || meta.category || "Feature")}`);
1776
- lines.push(`${colors.bold}Status:${colors.reset} ${colorStatus(meta.status)}`);
1777
- lines.push(`${colors.bold}Complexity:${colors.reset} ${meta.complexity || "Medium"}`);
1778
- lines.push(`${colors.bold}Effort:${colors.reset} ${meta.estimatedEffort || "N/A"}`);
1779
- lines.push(`${colors.bold}Submitted By:${colors.reset} ${meta.submittedBy || "Unknown"}`);
1780
- if (meta.featureFlag) {
1781
- lines.push(`${colors.bold}Feature Flag:${colors.reset} ${colors.yellow}${meta.featureFlag}${colors.reset}`);
1782
- }
1783
- if (detail?.summary) {
1784
- lines.push("");
1785
- lines.push(`${colors.bold}Summary:${colors.reset}`);
1786
- lines.push(detail.summary);
1787
- }
1788
- if (detail?.breakdown && detail.breakdown.length > 0) {
1789
- lines.push("");
1790
- lines.push(`${colors.bold}Feature Breakdown:${colors.reset}`);
1791
- for (const b of detail.breakdown) {
1792
- lines.push(` \u2022 ${b}`);
3063
+ async listMilestones(projectCode) {
3064
+ if (projectCode && projectCode !== "all" && !isProjectAuthorized(projectCode, this.config.allowedProjects)) {
3065
+ throw new EsedreAuthorizationError(projectCode);
3066
+ }
3067
+ const all = await this.target.listMilestones(projectCode);
3068
+ if (!this.config.allowedProjects || this.config.allowedProjects.length === 0) {
3069
+ return all;
1793
3070
  }
3071
+ if (this.config.allowedProjects.includes("*")) {
3072
+ return all;
3073
+ }
3074
+ return all.filter((m) => isProjectAuthorized(m.project, this.config.allowedProjects));
1794
3075
  }
1795
- if (detail?.technicalDetails && detail.technicalDetails.length > 0) {
1796
- lines.push("");
1797
- lines.push(`${colors.bold}Technical Details:${colors.reset}`);
1798
- for (const t of detail.technicalDetails) {
1799
- lines.push(` \u2022 ${t}`);
3076
+ async getMilestone(id, projectCode) {
3077
+ if (projectCode && projectCode !== "all" && !isProjectAuthorized(projectCode, this.config.allowedProjects)) {
3078
+ throw new EsedreAuthorizationError(projectCode);
3079
+ }
3080
+ const milestone = await this.target.getMilestone(id, projectCode);
3081
+ if (!milestone) return null;
3082
+ if (milestone.project && !isProjectAuthorized(milestone.project, this.config.allowedProjects)) {
3083
+ throw new EsedreAuthorizationError(milestone.project);
1800
3084
  }
3085
+ return milestone;
1801
3086
  }
1802
- if (detail?.openQuestions && detail.openQuestions.length > 0) {
1803
- lines.push("");
1804
- lines.push(`${colors.bold}Open Decisions & Questions:${colors.reset}`);
1805
- for (const q of detail.openQuestions) {
1806
- lines.push(` \u2022 ${q}`);
3087
+ async createMilestone(input) {
3088
+ const targetCode = input.projectCode || this.config.projectCode;
3089
+ if (targetCode && !isProjectAuthorized(targetCode, this.config.allowedProjects)) {
3090
+ throw new EsedreAuthorizationError(targetCode);
1807
3091
  }
3092
+ return this.target.createMilestone(input);
1808
3093
  }
1809
- if (planMarkdown) {
1810
- lines.push("");
1811
- lines.push(`${colors.bold}Implementation Plan:${colors.reset}`);
1812
- lines.push(planMarkdown.trim());
3094
+ async updateMilestone(id, input, projectCode) {
3095
+ const existing = await this.target.getMilestone(id, projectCode);
3096
+ if (existing) {
3097
+ if (existing.project && !isProjectAuthorized(existing.project, this.config.allowedProjects)) {
3098
+ throw new EsedreAuthorizationError(existing.project);
3099
+ }
3100
+ }
3101
+ return this.target.updateMilestone(id, input, projectCode);
1813
3102
  }
1814
- if (comments && comments.length > 0) {
1815
- lines.push("");
1816
- lines.push(`${colors.bold}Comments (${comments.length}):${colors.reset}`);
1817
- for (const c of comments) {
1818
- lines.push(` ${colors.dim}[${c.timestamp.slice(0, 10)}]${colors.reset} ${colors.bold}${c.author}:${colors.reset} ${c.text}`);
3103
+ async deleteMilestone(id, projectCode) {
3104
+ const existing = await this.target.getMilestone(id, projectCode);
3105
+ if (existing) {
3106
+ if (existing.project && !isProjectAuthorized(existing.project, this.config.allowedProjects)) {
3107
+ throw new EsedreAuthorizationError(existing.project);
3108
+ }
1819
3109
  }
3110
+ return this.target.deleteMilestone(id, projectCode);
1820
3111
  }
1821
- return lines.join("\n");
1822
- }
1823
- function pad(str, width) {
1824
- return str.padEnd(width, " ");
1825
- }
1826
- function colorStatus(status) {
1827
- switch (status) {
1828
- case "Completed":
1829
- return `${colors.green}${status}${colors.reset}`;
1830
- case "In Development":
1831
- return `${colors.green}${status}${colors.reset}`;
1832
- case "Rejected":
1833
- return `${colors.red}${status}${colors.reset}`;
1834
- default:
1835
- return `${colors.cyan}${status}${colors.reset}`;
3112
+ async addTicketLink(sourceId, relation, targetId, options) {
3113
+ const sourceTicket = await this.target.getTicket(sourceId);
3114
+ if (sourceTicket) {
3115
+ const code = sourceTicket.projectDescriptor?.code || sourceTicket.meta.project;
3116
+ if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
3117
+ throw new EsedreAuthorizationError(code);
3118
+ }
3119
+ } else if (options?.project && !isProjectAuthorized(options.project, this.config.allowedProjects)) {
3120
+ throw new EsedreAuthorizationError(options.project);
3121
+ }
3122
+ const result = await this.target.addTicketLink(sourceId, relation, targetId, options);
3123
+ return {
3124
+ source: this.redactLinks(result.source),
3125
+ target: result.target ? this.redactLinks(result.target) : void 0
3126
+ };
1836
3127
  }
1837
- }
1838
- function colorType(type) {
1839
- switch (type) {
1840
- case "Feature":
1841
- return `${colors.blue}${type}${colors.reset}`;
1842
- case "Platform":
1843
- return `${colors.magenta}${type}${colors.reset}`;
1844
- case "Tools":
1845
- return `${colors.yellow}${type}${colors.reset}`;
1846
- case "Bug":
1847
- return `${colors.red}${type}${colors.reset}`;
1848
- case "Idea":
1849
- return `${colors.green}${type}${colors.reset}`;
1850
- default:
1851
- return type;
3128
+ async removeTicketLink(sourceId, targetId, options) {
3129
+ const sourceTicket = await this.target.getTicket(sourceId);
3130
+ if (sourceTicket) {
3131
+ const code = sourceTicket.projectDescriptor?.code || sourceTicket.meta.project;
3132
+ if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
3133
+ throw new EsedreAuthorizationError(code);
3134
+ }
3135
+ } else if (options?.project && !isProjectAuthorized(options.project, this.config.allowedProjects)) {
3136
+ throw new EsedreAuthorizationError(options.project);
3137
+ }
3138
+ const result = await this.target.removeTicketLink(sourceId, targetId, options);
3139
+ return {
3140
+ source: this.redactLinks(result.source),
3141
+ target: result.target ? this.redactLinks(result.target) : void 0
3142
+ };
1852
3143
  }
1853
- }
3144
+ };
1854
3145
 
1855
3146
  // src/mcp/server.ts
1856
3147
  import readline from "node:readline";
@@ -1906,7 +3197,7 @@ var EsedreMcpServer = class {
1906
3197
  },
1907
3198
  serverInfo: {
1908
3199
  name: "esedre",
1909
- version: "0.1.0"
3200
+ version: CURRENT_ESEDRE_VERSION
1910
3201
  }
1911
3202
  }
1912
3203
  };
@@ -2070,7 +3361,11 @@ Status: ${ticket.meta.status}`;
2070
3361
  status: { type: "string", enum: ["Planned", "In Development", "Completed", "Rejected"], description: "Ticket status" },
2071
3362
  type: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"], description: "Ticket type" },
2072
3363
  category: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"], description: "Ticket category (legacy alias for type)" },
2073
- search: { type: "string", description: "Search keywords in title or ID" }
3364
+ priority: { type: "string", enum: ["Critical", "High", "Medium", "Low", "none"], description: "Ticket priority filter" },
3365
+ milestone: { type: "string", description: "Filter tickets by milestone name or ID" },
3366
+ search: { type: "string", description: "Search keywords in title or ID" },
3367
+ isBlocked: { type: "boolean", description: "Filter tickets blocked by uncompleted tickets" },
3368
+ linkedTo: { type: "string", description: "Filter tickets linked to a specific ticket ID or key" }
2074
3369
  }
2075
3370
  }
2076
3371
  },
@@ -2119,10 +3414,12 @@ Status: ${ticket.meta.status}`;
2119
3414
  project: { type: "string", description: "Target project code (e.g. CORE, WEB, DOCS)" },
2120
3415
  type: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"], description: "Ticket type" },
2121
3416
  category: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"], description: "Ticket category" },
3417
+ priority: { type: "string", enum: ["Critical", "High", "Medium", "Low"], description: "Ticket priority" },
2122
3418
  complexity: { type: "string", description: "Complexity (e.g. Low, Medium, High)" },
2123
3419
  effort: { type: "string", description: "Estimated effort (e.g. 2.0 - 4.0 hours)" },
2124
3420
  summary: { type: "string", description: "Initial feature summary" },
2125
3421
  detail: { type: "string", description: "Initial specification, feature breakdown, or technical detail markdown" },
3422
+ milestone: { type: "string", description: "Assign ticket to milestone" },
2126
3423
  author: { type: "string", description: "Submitting author name" }
2127
3424
  },
2128
3425
  required: ["title"]
@@ -2138,11 +3435,13 @@ Status: ${ticket.meta.status}`;
2138
3435
  status: { type: "string", enum: ["Planned", "In Development", "Completed", "Rejected"] },
2139
3436
  type: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"] },
2140
3437
  category: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"] },
3438
+ priority: { type: "string", enum: ["Critical", "High", "Medium", "Low", "none"], description: "Ticket priority (pass none or null to clear)" },
2141
3439
  title: { type: "string", description: "New title (max 48 characters)" },
2142
3440
  complexity: { type: "string", description: "Complexity (e.g. Low, Medium, High)" },
2143
3441
  effort: { type: "string", description: "Estimated effort (e.g. 2.0 - 4.0 hours)" },
2144
3442
  inDevelopment: { type: "boolean", description: "Active development toggle" },
2145
3443
  featureFlag: { type: "string", description: "Feature flag name" },
3444
+ milestone: { type: "string", description: 'Update ticket milestone ("none" to clear)' },
2146
3445
  lastHash: { type: "string", description: "Optimistic concurrency control: last known sha1 hash of the ticket" }
2147
3446
  },
2148
3447
  required: ["ticketId"]
@@ -2158,7 +3457,94 @@ Status: ${ticket.meta.status}`;
2158
3457
  text: { type: "string", description: "Comment message" },
2159
3458
  author: { type: "string", description: "Author name (e.g. Antigravity, Developer)" }
2160
3459
  },
2161
- required: ["ticketId", "text"]
3460
+ required: ["ticketId", "text"]
3461
+ }
3462
+ },
3463
+ {
3464
+ name: "esedre_list_milestones",
3465
+ description: "List roadmap milestones with optional project filter.",
3466
+ inputSchema: {
3467
+ type: "object",
3468
+ properties: {
3469
+ project: { type: "string", description: "Project code (e.g. Esedre, Alce)" }
3470
+ }
3471
+ }
3472
+ },
3473
+ {
3474
+ name: "esedre_get_milestone",
3475
+ description: "Retrieve milestone details by ID or title.",
3476
+ inputSchema: {
3477
+ type: "object",
3478
+ properties: {
3479
+ id: { type: ["integer", "string"], description: "Milestone ID or title" },
3480
+ project: { type: "string", description: "Project code" }
3481
+ },
3482
+ required: ["id"]
3483
+ }
3484
+ },
3485
+ {
3486
+ name: "esedre_create_milestone",
3487
+ description: "Create a new milestone with optional umbrella feature flag.",
3488
+ inputSchema: {
3489
+ type: "object",
3490
+ properties: {
3491
+ title: { type: "string", description: "Milestone title" },
3492
+ project: { type: "string", description: "Target project code" },
3493
+ description: { type: "string", description: "Milestone goal and description" },
3494
+ featureFlag: { type: "string", description: "Optional umbrella feature flag inherited by member tickets" },
3495
+ targetDate: { type: "string", description: "Optional target completion date" },
3496
+ status: { type: "string", enum: ["Planned", "Active", "Completed", "Closed"], description: "Milestone status" }
3497
+ },
3498
+ required: ["title"]
3499
+ }
3500
+ },
3501
+ {
3502
+ name: "esedre_update_milestone",
3503
+ description: "Update milestone state, umbrella feature flag, status, or target date.",
3504
+ inputSchema: {
3505
+ type: "object",
3506
+ properties: {
3507
+ id: { type: ["integer", "string"], description: "Milestone ID or title" },
3508
+ project: { type: "string", description: "Project code" },
3509
+ title: { type: "string", description: "New milestone title" },
3510
+ description: { type: "string", description: "Milestone goal and description" },
3511
+ featureFlag: { type: "string", description: 'Umbrella feature flag ("none" to clear)' },
3512
+ targetDate: { type: "string", description: 'Target date ("none" to clear)' },
3513
+ status: { type: "string", enum: ["Planned", "Active", "Completed", "Closed"], description: "Milestone status" }
3514
+ },
3515
+ required: ["id"]
3516
+ }
3517
+ },
3518
+ {
3519
+ name: "esedre_link_ticket",
3520
+ description: "Establish a bi-directional link between two tickets (within or across projects) with cycle detection.",
3521
+ inputSchema: {
3522
+ type: "object",
3523
+ properties: {
3524
+ sourceTicketId: { type: ["integer", "string"], description: "Source ticket ID or compound key" },
3525
+ relation: {
3526
+ type: "string",
3527
+ enum: ["relates-to", "blocks", "blocked-by", "parent-of", "child-of", "duplicates", "duplicated-by"],
3528
+ description: "Link relation type"
3529
+ },
3530
+ targetTicketId: { type: ["integer", "string"], description: "Target ticket ID or compound key" },
3531
+ author: { type: "string", description: "Author creating the link" },
3532
+ project: { type: "string", description: "Project code context for unqualified source ticket ID" }
3533
+ },
3534
+ required: ["sourceTicketId", "relation", "targetTicketId"]
3535
+ }
3536
+ },
3537
+ {
3538
+ name: "esedre_unlink_ticket",
3539
+ description: "Remove a link between two tickets and its reciprocal link.",
3540
+ inputSchema: {
3541
+ type: "object",
3542
+ properties: {
3543
+ sourceTicketId: { type: ["integer", "string"], description: "Source ticket ID or compound key" },
3544
+ targetTicketId: { type: ["integer", "string"], description: "Target ticket ID or compound key" },
3545
+ project: { type: "string", description: "Project code context for unqualified source ticket ID" }
3546
+ },
3547
+ required: ["sourceTicketId", "targetTicketId"]
2162
3548
  }
2163
3549
  }
2164
3550
  ];
@@ -2173,7 +3559,11 @@ Status: ${ticket.meta.status}`;
2173
3559
  status: args.status,
2174
3560
  type,
2175
3561
  category: type,
2176
- search: args.search
3562
+ priority: args.priority,
3563
+ milestone: args.milestone,
3564
+ search: args.search,
3565
+ isBlocked: args.isBlocked !== void 0 ? Boolean(args.isBlocked) : void 0,
3566
+ linkedTo: args.linkedTo
2177
3567
  });
2178
3568
  return tickets.map((t) => ({
2179
3569
  id: t.meta.id,
@@ -2181,10 +3571,16 @@ Status: ${ticket.meta.status}`;
2181
3571
  type: t.meta.type || t.meta.category,
2182
3572
  category: t.meta.type || t.meta.category,
2183
3573
  status: t.meta.status,
3574
+ priority: t.meta.priority,
2184
3575
  complexity: t.meta.complexity,
2185
3576
  effort: t.meta.estimatedEffort,
3577
+ milestone: t.meta.milestone,
3578
+ featureFlag: t.meta.featureFlag,
3579
+ inheritedFeatureFlag: t.meta.inheritedFeatureFlag,
2186
3580
  project: t.projectDescriptor?.code || t.meta.project || "UNASSIGNED",
2187
- sha1: t.sha1 || t.meta.sha1
3581
+ sha1: t.sha1 || t.meta.sha1,
3582
+ isBlocked: t.isBlocked,
3583
+ links: t.links
2188
3584
  }));
2189
3585
  }
2190
3586
  case "get_ticket": {
@@ -2209,12 +3605,14 @@ Status: ${ticket.meta.status}`;
2209
3605
  title: args.title,
2210
3606
  type,
2211
3607
  category: type,
3608
+ priority: args.priority,
2212
3609
  projectCode: args.project,
2213
3610
  complexity: args.complexity,
2214
3611
  estimatedEffort: args.effort,
2215
3612
  summary: args.summary,
2216
3613
  detail: args.detail || args.detailMarkdown,
2217
3614
  detailMarkdown: args.detailMarkdown || args.detail,
3615
+ milestone: args.milestone,
2218
3616
  submittedBy: args.author || "Agent"
2219
3617
  });
2220
3618
  return created;
@@ -2228,11 +3626,13 @@ Status: ${ticket.meta.status}`;
2228
3626
  updates.category = type;
2229
3627
  }
2230
3628
  if (args.status) updates.status = args.status;
3629
+ if (args.priority !== void 0) updates.priority = args.priority;
2231
3630
  if (args.title) updates.title = args.title;
2232
3631
  if (args.complexity) updates.complexity = args.complexity;
2233
3632
  if (args.effort) updates.estimatedEffort = args.effort;
2234
3633
  if (args.inDevelopment !== void 0) updates.isActivePlanning = Boolean(args.inDevelopment);
2235
3634
  if (args.featureFlag) updates.featureFlag = args.featureFlag;
3635
+ if (args.milestone !== void 0) updates.milestone = args.milestone;
2236
3636
  const updated = await this.storage.updateTicket(id, updates, args.lastHash);
2237
3637
  return updated;
2238
3638
  }
@@ -2244,6 +3644,63 @@ Status: ${ticket.meta.status}`;
2244
3644
  });
2245
3645
  return comment;
2246
3646
  }
3647
+ case "list_milestones": {
3648
+ return this.storage.listMilestones(args.project);
3649
+ }
3650
+ case "get_milestone": {
3651
+ const m = await this.storage.getMilestone(args.id, args.project);
3652
+ if (!m) throw new Error(`Milestone '${args.id}' not found`);
3653
+ const tickets = await this.storage.listTickets({ project: args.project || m.project, milestone: String(m.id) });
3654
+ return {
3655
+ ...m,
3656
+ tickets
3657
+ };
3658
+ }
3659
+ case "create_milestone": {
3660
+ return this.storage.createMilestone({
3661
+ title: args.title,
3662
+ projectCode: args.project,
3663
+ description: args.description,
3664
+ featureFlag: args.featureFlag,
3665
+ targetDate: args.targetDate,
3666
+ status: args.status
3667
+ });
3668
+ }
3669
+ case "update_milestone": {
3670
+ return this.storage.updateMilestone(args.id, {
3671
+ title: args.title,
3672
+ description: args.description,
3673
+ featureFlag: args.featureFlag,
3674
+ targetDate: args.targetDate,
3675
+ status: args.status
3676
+ }, args.project);
3677
+ }
3678
+ case "link_ticket": {
3679
+ const sourceId = this.parseTicketId(args.sourceTicketId);
3680
+ const targetId = this.parseTicketId(args.targetTicketId);
3681
+ const relation = String(args.relation).toLowerCase().replace(/_/g, "-");
3682
+ const result = await this.storage.addTicketLink(sourceId, relation, targetId, {
3683
+ author: args.author || "Agent",
3684
+ project: args.project
3685
+ });
3686
+ return {
3687
+ success: true,
3688
+ source: result.source,
3689
+ target: result.target
3690
+ };
3691
+ }
3692
+ case "unlink_ticket": {
3693
+ const sourceId = this.parseTicketId(args.sourceTicketId);
3694
+ const targetId = this.parseTicketId(args.targetTicketId);
3695
+ const result = await this.storage.removeTicketLink(sourceId, targetId, {
3696
+ project: args.project
3697
+ });
3698
+ return {
3699
+ success: true,
3700
+ source: result.source,
3701
+ target: result.target
3702
+ };
3703
+ }
2247
3704
  default:
2248
3705
  throw new Error(`Unknown tool: ${name}`);
2249
3706
  }
@@ -2443,7 +3900,7 @@ import path6 from "node:path";
2443
3900
  // src/server/daemon.ts
2444
3901
  import fs5 from "node:fs";
2445
3902
  import path5 from "node:path";
2446
- import os2 from "node:os";
3903
+ import os3 from "node:os";
2447
3904
  import http from "node:http";
2448
3905
  import { spawn, spawnSync } from "node:child_process";
2449
3906
  import { fileURLToPath } from "node:url";
@@ -2453,7 +3910,7 @@ function getGlobalEsedreDir() {
2453
3910
  if (process.env.ESEDRE_GLOBAL_DIR) {
2454
3911
  return process.env.ESEDRE_GLOBAL_DIR;
2455
3912
  }
2456
- return path5.join(os2.homedir(), ".esedre");
3913
+ return path5.join(os3.homedir(), ".esedre");
2457
3914
  }
2458
3915
  function ensureGlobalEsedreStore() {
2459
3916
  const globalDir = getGlobalEsedreDir();
@@ -2721,6 +4178,27 @@ async function stopDaemon(options = {}) {
2721
4178
  }
2722
4179
  return false;
2723
4180
  }
4181
+ try {
4182
+ if (process.platform === "win32") {
4183
+ const out = spawnSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).stdout || "";
4184
+ const lines = out.split("\n");
4185
+ for (const line of lines) {
4186
+ if (line.includes(`:${port}`) && line.includes("LISTENING")) {
4187
+ const parts = line.trim().split(/\s+/);
4188
+ const foundPid = parseInt(parts[parts.length - 1], 10);
4189
+ if (!isNaN(foundPid) && foundPid > 0) {
4190
+ pid = foundPid;
4191
+ break;
4192
+ }
4193
+ }
4194
+ }
4195
+ } else {
4196
+ const out = spawnSync("lsof", ["-ti", `tcp:${port}`], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).stdout || "";
4197
+ const foundPid = parseInt(out.trim().split("\n")[0], 10);
4198
+ if (!isNaN(foundPid) && foundPid > 0) pid = foundPid;
4199
+ }
4200
+ } catch {
4201
+ }
2724
4202
  }
2725
4203
  if (pid && isProcessAlive(pid)) {
2726
4204
  try {
@@ -2828,49 +4306,49 @@ function classifyContent(currentContent, latestTemplate, historicHashes) {
2828
4306
  }
2829
4307
  var WRAPPER_CMD = `@echo off
2830
4308
  REM Esedre Autonomous Ticketing & Project Planning Engine Wrapper (Windows CMD)
2831
- setlocal
4309
+ call :run %*
4310
+ exit /b %ERRORLEVEL%
4311
+
4312
+ :run
4313
+ if exist "%~dp0..\\dist\\esedre.mjs" (
4314
+ node "%~dp0..\\dist\\esedre.mjs" %*
4315
+ goto :eof
4316
+ )
4317
+ if exist "%~dp0..\\..\\esedre\\dist\\esedre.mjs" (
4318
+ node "%~dp0..\\..\\esedre\\dist\\esedre.mjs" %*
4319
+ goto :eof
4320
+ )
4321
+ if exist "%~dp0..\\esedre\\dist\\esedre.mjs" (
4322
+ node "%~dp0..\\esedre\\dist\\esedre.mjs" %*
4323
+ goto :eof
4324
+ )
4325
+ if exist "%~dp0..\\node_modules\\esedre\\dist\\esedre.mjs" (
4326
+ node "%~dp0..\\node_modules\\esedre\\dist\\esedre.mjs" %*
4327
+ goto :eof
4328
+ )
4329
+ if exist "%~dp0..\\node_modules\\.bin\\ese.cmd" (
4330
+ call "%~dp0..\\node_modules\\.bin\\ese.cmd" %*
4331
+ goto :eof
4332
+ )
4333
+ if exist "%~dp0..\\node_modules\\.bin\\esedre.cmd" (
4334
+ call "%~dp0..\\node_modules\\.bin\\esedre.cmd" %*
4335
+ goto :eof
4336
+ )
2832
4337
 
2833
4338
  where ese >nul 2>nul
2834
- if %ERRORLEVEL% equ 0 goto use_ese
4339
+ if %ERRORLEVEL% equ 0 (
4340
+ call ese %*
4341
+ goto :eof
4342
+ )
2835
4343
 
2836
4344
  where esedre >nul 2>nul
2837
- if %ERRORLEVEL% equ 0 goto use_esedre
2838
-
2839
- if exist "%~dp0..\\..\\esedre\\dist\\esedre.mjs" goto use_sibling_dist
2840
- if exist "%~dp0..\\esedre\\dist\\esedre.mjs" goto use_local_dist
2841
- if exist "%~dp0..\\node_modules\\.bin\\ese.cmd" goto use_node_modules_ese
2842
- if exist "%~dp0..\\node_modules\\.bin\\esedre.cmd" goto use_node_modules_esedre
4345
+ if %ERRORLEVEL% equ 0 (
4346
+ call esedre %*
4347
+ goto :eof
4348
+ )
2843
4349
 
2844
4350
  call npx --yes esedre %*
2845
- goto done
2846
-
2847
- :use_ese
2848
- call ese %*
2849
- goto done
2850
-
2851
- :use_esedre
2852
- call esedre %*
2853
- goto done
2854
-
2855
- :use_sibling_dist
2856
- node "%~dp0..\\..\\esedre\\dist\\esedre.mjs" %*
2857
- goto done
2858
-
2859
- :use_local_dist
2860
- node "%~dp0..\\esedre\\dist\\esedre.mjs" %*
2861
- goto done
2862
-
2863
- :use_node_modules_ese
2864
- call "%~dp0..\\node_modules\\.bin\\ese.cmd" %*
2865
- goto done
2866
-
2867
- :use_node_modules_esedre
2868
- call "%~dp0..\\node_modules\\.bin\\esedre.cmd" %*
2869
- goto done
2870
-
2871
- :done
2872
- endlocal
2873
- exit /b %ERRORLEVEL%
4351
+ goto :eof
2874
4352
  `;
2875
4353
  var WRAPPER_PS1 = `# Esedre Autonomous Ticketing & Project Planning Engine Wrapper (PowerShell)
2876
4354
  $ErrorActionPreference = "Stop"
@@ -2884,12 +4362,9 @@ try {
2884
4362
 
2885
4363
  $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
2886
4364
 
2887
- if (Get-Command "ese" -ErrorAction SilentlyContinue) {
2888
- & ese @args
2889
- exit $LASTEXITCODE
2890
- }
2891
- if (Get-Command "esedre" -ErrorAction SilentlyContinue) {
2892
- & esedre @args
4365
+ $inRepoEsedre = Join-Path $scriptDir "..\\dist\\esedre.mjs"
4366
+ if (Test-Path $inRepoEsedre) {
4367
+ & node $inRepoEsedre @args
2893
4368
  exit $LASTEXITCODE
2894
4369
  }
2895
4370
  $siblingEsedre = Join-Path $scriptDir "..\\..\\esedre\\dist\\esedre.mjs"
@@ -2902,6 +4377,11 @@ if (Test-Path $distEsedre) {
2902
4377
  & node $distEsedre @args
2903
4378
  exit $LASTEXITCODE
2904
4379
  }
4380
+ $nodeModulesEsedre = Join-Path $scriptDir "..\\node_modules\\esedre\\dist\\esedre.mjs"
4381
+ if (Test-Path $nodeModulesEsedre) {
4382
+ & node $nodeModulesEsedre @args
4383
+ exit $LASTEXITCODE
4384
+ }
2905
4385
  $localBin = Join-Path $scriptDir "..\\node_modules\\.bin\\ese.cmd"
2906
4386
  if (Test-Path $localBin) {
2907
4387
  & $localBin @args
@@ -2912,6 +4392,14 @@ if (Test-Path $localEsedreBin) {
2912
4392
  & $localEsedreBin @args
2913
4393
  exit $LASTEXITCODE
2914
4394
  }
4395
+ if (Get-Command "ese" -ErrorAction SilentlyContinue) {
4396
+ & ese @args
4397
+ exit $LASTEXITCODE
4398
+ }
4399
+ if (Get-Command "esedre" -ErrorAction SilentlyContinue) {
4400
+ & esedre @args
4401
+ exit $LASTEXITCODE
4402
+ }
2915
4403
  & npx --yes esedre @args
2916
4404
  exit $LASTEXITCODE
2917
4405
  `;
@@ -2920,11 +4408,8 @@ var WRAPPER_BASH = `#!/usr/bin/env bash
2920
4408
  set -e
2921
4409
  DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
2922
4410
 
2923
- if command -v ese >/dev/null 2>&1; then
2924
- exec ese "$@"
2925
- fi
2926
- if command -v esedre >/dev/null 2>&1; then
2927
- exec esedre "$@"
4411
+ if [ -f "$DIR/../dist/esedre.mjs" ]; then
4412
+ exec node "$DIR/../dist/esedre.mjs" "$@"
2928
4413
  fi
2929
4414
  if [ -f "$DIR/../../esedre/dist/esedre.mjs" ]; then
2930
4415
  exec node "$DIR/../../esedre/dist/esedre.mjs" "$@"
@@ -2932,12 +4417,21 @@ fi
2932
4417
  if [ -f "$DIR/../esedre/dist/esedre.mjs" ]; then
2933
4418
  exec node "$DIR/../esedre/dist/esedre.mjs" "$@"
2934
4419
  fi
4420
+ if [ -f "$DIR/../node_modules/esedre/dist/esedre.mjs" ]; then
4421
+ exec node "$DIR/../node_modules/esedre/dist/esedre.mjs" "$@"
4422
+ fi
2935
4423
  if [ -f "$DIR/../node_modules/.bin/ese" ]; then
2936
4424
  exec "$DIR/../node_modules/.bin/ese" "$@"
2937
4425
  fi
2938
4426
  if [ -f "$DIR/../node_modules/.bin/esedre" ]; then
2939
4427
  exec "$DIR/../node_modules/.bin/esedre" "$@"
2940
4428
  fi
4429
+ if command -v ese >/dev/null 2>&1; then
4430
+ exec ese "$@"
4431
+ fi
4432
+ if command -v esedre >/dev/null 2>&1; then
4433
+ exec esedre "$@"
4434
+ fi
2941
4435
  exec npx --yes esedre "$@"
2942
4436
  `;
2943
4437
  var ESE_WRAPPER_CMD = `@echo off
@@ -3514,10 +5008,17 @@ function startUiServer(port, webDir) {
3514
5008
 
3515
5009
  // src/server/apiServer.ts
3516
5010
  import http3 from "node:http";
5011
+ var MAX_JSON_BODY_BYTES = 10 * 1024 * 1024;
3517
5012
  function readJsonBody(req) {
3518
5013
  return new Promise((resolve, reject) => {
3519
5014
  let body = "";
5015
+ let bytesRead = 0;
3520
5016
  req.on("data", (chunk) => {
5017
+ bytesRead += chunk.length;
5018
+ if (bytesRead > MAX_JSON_BODY_BYTES) {
5019
+ req.destroy(new Error("Payload Too Large: JSON body exceeds 10MB limit"));
5020
+ return;
5021
+ }
3521
5022
  body += chunk;
3522
5023
  });
3523
5024
  req.on("end", () => {
@@ -3570,7 +5071,7 @@ function createApiHandler(storage, workspaceRoot) {
3570
5071
  res.writeHead(204, {
3571
5072
  "Access-Control-Allow-Origin": "*",
3572
5073
  "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
3573
- "Access-Control-Allow-Headers": "Content-Type, Authorization"
5074
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, x-esedre-allowed-projects"
3574
5075
  });
3575
5076
  res.end();
3576
5077
  return true;
@@ -3602,12 +5103,14 @@ function createApiHandler(storage, workspaceRoot) {
3602
5103
  const status = url.searchParams.get("status") || void 0;
3603
5104
  const type = url.searchParams.get("type") || url.searchParams.get("category") || void 0;
3604
5105
  const category = type;
5106
+ const priority = url.searchParams.get("priority") || void 0;
3605
5107
  const search = url.searchParams.get("search") || void 0;
3606
5108
  const tickets = await reqStorage.listTickets({
3607
5109
  project: project === "all" ? void 0 : project,
3608
5110
  status,
3609
5111
  type,
3610
5112
  category,
5113
+ priority,
3611
5114
  search
3612
5115
  });
3613
5116
  sendJson(res, 200, tickets);
@@ -3643,9 +5146,10 @@ function createApiHandler(storage, workspaceRoot) {
3643
5146
  const numKey = String(t.meta.id);
3644
5147
  const code = t.projectDescriptor?.code || t.meta.project || "Profe";
3645
5148
  const prefixedKey = `${code}-${t.meta.id}`;
3646
- metasMap[prefixedKey] = t.meta;
5149
+ const metaWithLinks = { ...t.meta, links: t.links || t.meta.links || [], isBlocked: t.isBlocked };
5150
+ metasMap[prefixedKey] = metaWithLinks;
3647
5151
  if (!metasMap[numKey] || code.toUpperCase() === "PROF") {
3648
- metasMap[numKey] = t.meta;
5152
+ metasMap[numKey] = metaWithLinks;
3649
5153
  }
3650
5154
  commentsMap[prefixedKey] = t.comments || [];
3651
5155
  if (!commentsMap[numKey] || code.toUpperCase() === "PROF") {
@@ -3747,6 +5251,12 @@ function createApiHandler(storage, workspaceRoot) {
3747
5251
  sendJson(res, 200, snapshot);
3748
5252
  return true;
3749
5253
  }
5254
+ if (pathname === "/api/planning/milestones") {
5255
+ const project = url.searchParams.get("project") || void 0;
5256
+ const milestones = await reqStorage.listMilestones(project);
5257
+ sendJson(res, 200, { milestones });
5258
+ return true;
5259
+ }
3750
5260
  }
3751
5261
  if (req.method === "POST") {
3752
5262
  const body = await readJsonBody(req);
@@ -3783,6 +5293,59 @@ function createApiHandler(storage, workspaceRoot) {
3783
5293
  sendJson(res, 201, project);
3784
5294
  return true;
3785
5295
  }
5296
+ if (pathname === "/api/planning/update-project") {
5297
+ const { code, name, description, colors: colors2, techStack, groundingRules, guidelinesRef } = body;
5298
+ if (!code) {
5299
+ sendJson(res, 400, { error: "Project code is required." });
5300
+ return true;
5301
+ }
5302
+ if (name !== void 0) {
5303
+ const nameVal = validateProjectName(name);
5304
+ if (!nameVal.valid) {
5305
+ sendJson(res, 400, { error: nameVal.error });
5306
+ return true;
5307
+ }
5308
+ }
5309
+ invalidateApiCache();
5310
+ const updated = await reqStorage.updateProject({
5311
+ code,
5312
+ name,
5313
+ description,
5314
+ colors: colors2,
5315
+ techStack,
5316
+ groundingRules,
5317
+ guidelinesRef
5318
+ });
5319
+ sendJson(res, 200, updated);
5320
+ return true;
5321
+ }
5322
+ if (pathname === "/api/planning/rename-project") {
5323
+ const { oldCode, newCode, newName } = body;
5324
+ if (!oldCode || !newCode) {
5325
+ sendJson(res, 400, { error: "Both oldCode and newCode are required to rename a project." });
5326
+ return true;
5327
+ }
5328
+ const codeVal = validateProjectCode(newCode);
5329
+ if (!codeVal.valid) {
5330
+ sendJson(res, 400, { error: codeVal.error });
5331
+ return true;
5332
+ }
5333
+ if (newName !== void 0) {
5334
+ const nameVal = validateProjectName(newName);
5335
+ if (!nameVal.valid) {
5336
+ sendJson(res, 400, { error: nameVal.error });
5337
+ return true;
5338
+ }
5339
+ }
5340
+ invalidateApiCache();
5341
+ const result = await reqStorage.renameProjectCode({
5342
+ oldCode,
5343
+ newCode,
5344
+ newName
5345
+ });
5346
+ sendJson(res, 200, result);
5347
+ return true;
5348
+ }
3786
5349
  if (pathname === "/api/planning/plans") {
3787
5350
  const { ticketId, planMarkdown, lastHash } = body;
3788
5351
  invalidateApiCache();
@@ -3820,6 +5383,48 @@ function createApiHandler(storage, workspaceRoot) {
3820
5383
  sendJson(res, 200, { success: true, detail: detailMarkdown });
3821
5384
  return true;
3822
5385
  }
5386
+ if (pathname === "/api/planning/milestones") {
5387
+ invalidateApiCache();
5388
+ const created = await reqStorage.createMilestone(body);
5389
+ sendJson(res, 201, { milestone: created });
5390
+ return true;
5391
+ }
5392
+ if (pathname === "/api/planning/update-milestone") {
5393
+ invalidateApiCache();
5394
+ const { id, projectCode, ...updates } = body;
5395
+ const updated = await reqStorage.updateMilestone(id, updates, projectCode);
5396
+ sendJson(res, 200, { milestone: updated });
5397
+ return true;
5398
+ }
5399
+ if (pathname === "/api/planning/delete-milestone") {
5400
+ invalidateApiCache();
5401
+ const { id, projectCode } = body;
5402
+ const success = await reqStorage.deleteMilestone(id, projectCode);
5403
+ sendJson(res, 200, { success });
5404
+ return true;
5405
+ }
5406
+ if (pathname === "/api/planning/link") {
5407
+ invalidateApiCache();
5408
+ const { sourceId, relation, targetId, author, project } = body;
5409
+ if (!sourceId || !relation || !targetId) {
5410
+ sendJson(res, 400, { error: "sourceId, relation, and targetId are required." });
5411
+ return true;
5412
+ }
5413
+ const result = await reqStorage.addTicketLink(sourceId, relation, targetId, { author, project });
5414
+ sendJson(res, 200, { success: true, source: result.source, target: result.target });
5415
+ return true;
5416
+ }
5417
+ if (pathname === "/api/planning/unlink") {
5418
+ invalidateApiCache();
5419
+ const { sourceId, targetId, relation, project } = body;
5420
+ if (!sourceId || !targetId) {
5421
+ sendJson(res, 400, { error: "sourceId and targetId are required." });
5422
+ return true;
5423
+ }
5424
+ const result = await reqStorage.removeTicketLink(sourceId, targetId, { relation, project });
5425
+ sendJson(res, 200, { success: true, source: result.source, target: result.target });
5426
+ return true;
5427
+ }
3823
5428
  }
3824
5429
  sendJson(res, 404, { error: `Endpoint not found: ${pathname}` });
3825
5430
  return true;
@@ -4001,18 +5606,33 @@ ${colors.bold}ROADMAP COMMANDS (Pair Programming & LLM Agents):${colors.reset}
4001
5606
  ${colors.bold}plan${colors.reset} <id> [--file <path> | --set "<markdown>"] [--last-hash <sha1>] [--json]
4002
5607
  View or update implementation plan with optimistic concurrency control.
4003
5608
 
4004
- ${colors.bold}create${colors.reset} --title "..." [-p|--project <code>] [-t|--type <type>] [--complexity <c>] [--effort "<e>"] [--summary "<s>"] [--detail "<md>"] [--file <path>] [--json]
5609
+ ${colors.bold}create${colors.reset} --title "..." [-p|--project <code>] [-t|--type <type>] [-P|--priority <prio>] [--complexity <c>] [--effort "<e>"] [--summary "<s>"] [--detail "<md>"] [--file <path>] [--json]
4005
5610
  Mint a new roadmap ticket with sequential numeric ID.
4006
5611
 
4007
- ${colors.bold}update${colors.reset} <id> [-s|--status <status>] [-t|--type <type>] [--title "..."] [--complexity <c>] [--effort "<e>"] [--in-dev] [--flag <name>] [--last-hash <sha1>] [--force] [--json]
4008
- Mutate ticket status, type, title, complexity, effort, active state, or feature flag with OCC protection.
5612
+ ${colors.bold}update${colors.reset} <id> [-s|--status <status>] [-t|--type <type>] [-P|--priority <prio>] [--title "..."] [--complexity <c>] [--effort "<e>"] [--in-dev] [--flag <name>] [--last-hash <sha1>] [--force] [--json]
5613
+ Mutate ticket status, type, priority, title, complexity, effort, active state, or feature flag with OCC protection.
4009
5614
 
4010
5615
  ${colors.bold}comment${colors.reset} <id> ["<text>"] [--text "..."] [--author "..."] [--json]
4011
5616
  Append a research finding, test verification, or note to ticket history.
4012
5617
 
5618
+ ${colors.bold}link${colors.reset} <sourceId> <relation> <targetId> [--author "..."] [--json]
5619
+ Establish a bi-directional link between two tickets (within or across projects).
5620
+ \u2022 Relations: 'relates-to', 'blocks', 'blocked-by', 'parent-of', 'child-of', 'duplicates', 'duplicated-by'
5621
+
5622
+ ${colors.bold}unlink${colors.reset} <sourceId> <targetId> [--json]
5623
+ Remove a link between two tickets and its reciprocal link.
5624
+
4013
5625
  ${colors.bold}snapshot, refresh${colors.reset} [--project <code>] [--json]
4014
5626
  Generate lean read-only projection snapshot (.esedre/snapshot.json) for zero-latency agent context.
4015
5627
 
5628
+ ${colors.bold}milestone${colors.reset} [list | get <id> | create | update <id> | delete <id>] [options] [--json]
5629
+ Manage project milestones with optional umbrella feature flags.
5630
+ \u2022 list: ese milestone list [-p <project>]
5631
+ \u2022 get: ese milestone get <id> [-p <project>]
5632
+ \u2022 create: ese milestone create --title "..." [-p <project>] [--flag <name>] [--status <status>] [--target-date <date>] [--desc "..."]
5633
+ \u2022 update: ese milestone update <id> [--title "..."] [--flag <name>] [--status <status>] [--target-date <date>] [--desc "..."]
5634
+ \u2022 delete: ese milestone delete <id> [-p <project>]
5635
+
4016
5636
  ${colors.bold}SERVICE DAEMON COMMANDS:${colors.reset}
4017
5637
  ${colors.bold}start${colors.reset} [--port <n>] [--foreground | -f] [--quiet] [--json]
4018
5638
  Start the Esedre background server and web UI daemon (default port ${DEFAULT_ESEDRE_PORT}).
@@ -4048,12 +5668,24 @@ ${colors.bold}DEVELOPER ADMINISTRATION (Human Setup & Configuration):${colors.re
4048
5668
  ${colors.bold}projects${colors.reset} [--json]
4049
5669
  List registered workspace projects, project codes (e.g. 'CORE', 'WEB', 'DOCS'), and descriptions.
4050
5670
 
5671
+ ${colors.bold}project${colors.reset} [set <code> [-n|--name <name>] [-d|--desc <text>] | rename <old> <new>] [--json]
5672
+ Inspect, update metadata, or rename workspace projects.
5673
+
5674
+ ${colors.bold}rename-project${colors.reset} <oldCode> <newCode> [-n|--name <name>] [--json]
5675
+ Safely rename a project code on disk, updating hub folders, manifests, ticket references, and workspace configs.
5676
+
4051
5677
  ${colors.bold}OPTIONS:${colors.reset}
4052
5678
  -p, --project <code> Project code (e.g. CORE, ALCE, DOCS).
4053
5679
  -n, --name <name> Project display name (e.g. "Alce Web Reader").
4054
5680
  -t, --type <type> Ticket type ('Feature', 'Platform', 'Tools', 'Idea', 'Bug').
5681
+ -P, --priority <prio> Ticket priority ('Critical', 'High', 'Medium', 'Low', 'none').
5682
+ -m, --milestone <name|id> Ticket milestone association.
4055
5683
  -s, --status <stat> Ticket status ('Planned', 'In Development', 'Completed', 'Rejected').
4056
5684
  -q, --search <query> Case-insensitive substring search query.
5685
+ -r, --relation <rel> Link relation type ('relates-to', 'blocks', 'blocked-by', etc.).
5686
+ --to <targetId> Target ticket ID for link/unlink commands.
5687
+ --blocked Filter tickets that are blocked by uncompleted tickets.
5688
+ --linked-to <key> Filter tickets that are linked to a specific ticket.
4057
5689
  --detail "<md>" Specification markdown for ticket detail during create.
4058
5690
  --file <path> Path to markdown file for detail (create) or implementation plan (plan).
4059
5691
  --json Output raw machine-readable JSON (strongly recommended for autonomous LLM coding agents).
@@ -4092,7 +5724,7 @@ function parseArgs(rawArgs) {
4092
5724
  flags["help"] = true;
4093
5725
  } else if (key === "y") {
4094
5726
  flags["yes"] = true;
4095
- } else if (key === "t" || key === "p" || key === "s" || key === "q" || key === "n") {
5727
+ } else if (key === "t" || key === "p" || key === "s" || key === "q" || key === "n" || key === "P" || key === "m" || key === "r") {
4096
5728
  const next = rawArgs[i + 1];
4097
5729
  if (next !== void 0 && !next.startsWith("-")) {
4098
5730
  flags[key] = next;
@@ -4113,9 +5745,12 @@ function parseArgs(rawArgs) {
4113
5745
  }
4114
5746
  if (flags["t"] && !flags["type"]) flags["type"] = flags["t"];
4115
5747
  if (flags["p"] && !flags["project"]) flags["project"] = flags["p"];
5748
+ if (flags["P"] && !flags["priority"]) flags["priority"] = flags["P"];
5749
+ if (flags["m"] && !flags["milestone"]) flags["milestone"] = flags["m"];
4116
5750
  if (flags["n"] && !flags["name"]) flags["name"] = flags["n"];
4117
5751
  if (flags["s"] && !flags["status"]) flags["status"] = flags["s"];
4118
5752
  if (flags["q"] && !flags["search"]) flags["search"] = flags["q"];
5753
+ if (flags["r"] && !flags["relation"]) flags["relation"] = flags["r"];
4119
5754
  if (flags["f"] && !flags["foreground"]) flags["foreground"] = flags["f"];
4120
5755
  return { command, positionals, flags };
4121
5756
  }
@@ -4129,6 +5764,18 @@ function printDuplicateProjectWarnings(storage, isJson) {
4129
5764
  }
4130
5765
  }
4131
5766
  async function main() {
5767
+ if (process.stdout && typeof process.stdout.setDefaultEncoding === "function") {
5768
+ try {
5769
+ process.stdout.setDefaultEncoding("utf-8");
5770
+ } catch {
5771
+ }
5772
+ }
5773
+ if (process.stderr && typeof process.stderr.setDefaultEncoding === "function") {
5774
+ try {
5775
+ process.stderr.setDefaultEncoding("utf-8");
5776
+ } catch {
5777
+ }
5778
+ }
4132
5779
  const rawArgs = process.argv.slice(2);
4133
5780
  const { command, positionals, flags } = parseArgs(rawArgs);
4134
5781
  try {
@@ -4679,6 +6326,80 @@ ${colors.yellow}\u26A0\uFE0F Warning: Daemon is running v${status.version}, but
4679
6326
  printDaemonLogs({ port, lines, workspaceRoot: discovered.workspaceRoot });
4680
6327
  return;
4681
6328
  }
6329
+ case "rename-project": {
6330
+ const oldCode = positionals[0];
6331
+ const newCode = positionals[1];
6332
+ if (!oldCode || !newCode) {
6333
+ console.error(`${colors.red}Error: Both oldCode and newCode are required (e.g. ese rename-project Profe Lab151)${colors.reset}`);
6334
+ process.exit(1);
6335
+ }
6336
+ const newName = flags["name"] || flags["n"];
6337
+ const result = await storage.renameProjectCode({ oldCode, newCode, newName });
6338
+ if (isJson) {
6339
+ console.log(JSON.stringify(result, null, 2));
6340
+ } else {
6341
+ console.log(`${colors.green}\u2714 Successfully renamed project "${result.oldCode}" to "${result.newCode}"${colors.reset}`);
6342
+ console.log(` \u2022 Display Name: ${result.project.name}`);
6343
+ console.log(` \u2022 Migrated Tickets: ${result.migratedTicketsCount}`);
6344
+ if (result.updatedHubs.length > 0) {
6345
+ console.log(` \u2022 Updated Data Hubs: ${result.updatedHubs.length}`);
6346
+ }
6347
+ if (result.updatedConfigs.length > 0) {
6348
+ console.log(` \u2022 Updated Configs: ${result.updatedConfigs.join(", ")}`);
6349
+ }
6350
+ }
6351
+ return;
6352
+ }
6353
+ case "project": {
6354
+ const sub = positionals[0]?.toLowerCase();
6355
+ if (sub === "rename") {
6356
+ const oldCode = positionals[1];
6357
+ const newCode = positionals[2];
6358
+ if (!oldCode || !newCode) {
6359
+ console.error(`${colors.red}Error: Both oldCode and newCode are required (e.g. ese project rename Profe Lab151)${colors.reset}`);
6360
+ process.exit(1);
6361
+ }
6362
+ const newName = flags["name"] || flags["n"];
6363
+ const result = await storage.renameProjectCode({ oldCode, newCode, newName });
6364
+ if (isJson) {
6365
+ console.log(JSON.stringify(result, null, 2));
6366
+ } else {
6367
+ console.log(`${colors.green}\u2714 Successfully renamed project "${result.oldCode}" to "${result.newCode}"${colors.reset}`);
6368
+ console.log(` \u2022 Display Name: ${result.project.name}`);
6369
+ console.log(` \u2022 Migrated Tickets: ${result.migratedTicketsCount}`);
6370
+ }
6371
+ return;
6372
+ }
6373
+ if (sub === "set") {
6374
+ const code = positionals[1] || flags["project"] || flags["p"];
6375
+ if (!code) {
6376
+ console.error(`${colors.red}Error: Project code is required (e.g. ese project set Lab151 --name "Lab 151")${colors.reset}`);
6377
+ process.exit(1);
6378
+ }
6379
+ const name = flags["name"] || flags["n"];
6380
+ const description = flags["description"] || flags["desc"] || flags["d"];
6381
+ const updated = await storage.updateProject({ code, name, description });
6382
+ if (isJson) {
6383
+ console.log(JSON.stringify(updated, null, 2));
6384
+ } else {
6385
+ console.log(`${colors.green}\u2714 Updated project "${updated.code}"${colors.reset}`);
6386
+ console.log(` \u2022 Name: ${updated.name}`);
6387
+ console.log(` \u2022 Description: ${updated.description}`);
6388
+ }
6389
+ return;
6390
+ }
6391
+ printDuplicateProjectWarnings(storage, isJson);
6392
+ const projects = await storage.getProjects();
6393
+ if (isJson) {
6394
+ console.log(JSON.stringify(projects, null, 2));
6395
+ } else {
6396
+ console.log(`${colors.bold}Registered Projects:${colors.reset}`);
6397
+ for (const p of projects) {
6398
+ console.log(` \u2022 ${colors.bold}${p.code}${colors.reset} (#${p.id}): ${p.name}: ${colors.dim}${p.description}${colors.reset}`);
6399
+ }
6400
+ }
6401
+ return;
6402
+ }
4682
6403
  case "projects": {
4683
6404
  printDuplicateProjectWarnings(storage, isJson);
4684
6405
  const projects = await storage.getProjects();
@@ -4699,19 +6420,43 @@ ${colors.yellow}\u26A0\uFE0F Warning: Daemon is running v${status.version}, but
4699
6420
  const status = flags["status"] || void 0;
4700
6421
  const type = flags["type"] || flags["category"] || void 0;
4701
6422
  const category = type;
6423
+ const rawPriority = flags["priority"];
6424
+ let priority;
6425
+ if (rawPriority) {
6426
+ const lower = rawPriority.trim().toLowerCase();
6427
+ if (lower === "none" || lower === "null") {
6428
+ priority = "none";
6429
+ } else {
6430
+ const norm = normalizePriority(rawPriority);
6431
+ if (!norm) {
6432
+ console.error(`${colors.red}Error: Invalid priority filter '${rawPriority}'. Valid options: Critical, High, Medium, Low, none${colors.reset}`);
6433
+ process.exit(1);
6434
+ }
6435
+ priority = norm;
6436
+ }
6437
+ }
4702
6438
  const search = flags["search"] || void 0;
4703
- const tickets = await storage.listTickets({ project, status, type, category, search });
6439
+ const milestone = flags["milestone"] || flags["m"] || void 0;
6440
+ const isBlocked = flags["blocked"] ? true : void 0;
6441
+ const linkedTo = flags["linked-to"] || flags["linkedTo"] || void 0;
6442
+ const tickets = await storage.listTickets({ project, status, type, category, priority, milestone, search, isBlocked, linkedTo });
4704
6443
  if (isJson) {
4705
6444
  console.log(JSON.stringify(tickets.map((t) => ({
4706
6445
  id: t.meta.id,
4707
- title: t.meta.title,
6446
+ title: normalizeDashesAndMojibake(t.meta.title),
4708
6447
  type: t.meta.type || t.meta.category,
4709
6448
  category: t.meta.type || t.meta.category,
4710
6449
  status: t.meta.status,
4711
- complexity: t.meta.complexity,
4712
- effort: t.meta.estimatedEffort,
6450
+ priority: t.meta.priority,
6451
+ complexity: normalizeDashesAndMojibake(t.meta.complexity),
6452
+ effort: normalizeDashesAndMojibake(t.meta.estimatedEffort),
6453
+ milestone: t.meta.milestone,
6454
+ featureFlag: t.meta.featureFlag,
6455
+ inheritedFeatureFlag: t.meta.inheritedFeatureFlag,
4713
6456
  project: t.projectDescriptor?.code || t.meta.project || "UNASSIGNED",
4714
- sha1: t.sha1 || t.meta.sha1
6457
+ sha1: t.sha1 || t.meta.sha1,
6458
+ isBlocked: t.isBlocked,
6459
+ links: t.links
4715
6460
  })), null, 2));
4716
6461
  } else {
4717
6462
  if (tickets.length === 0) {
@@ -4806,22 +6551,33 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4806
6551
  return;
4807
6552
  }
4808
6553
  case "create": {
4809
- const title = flags["title"];
4810
- if (!title) {
6554
+ const rawTitle = flags["title"];
6555
+ if (!rawTitle) {
4811
6556
  console.error(`${colors.red}Error: --title is required${colors.reset}`);
4812
6557
  process.exit(1);
4813
6558
  }
6559
+ const title = normalizeDashesAndMojibake(rawTitle);
4814
6560
  const type = flags["type"] || flags["category"] || "Feature";
4815
6561
  const category = type;
6562
+ const rawPriority = flags["priority"];
6563
+ let priority;
6564
+ if (rawPriority) {
6565
+ const norm = normalizePriority(rawPriority);
6566
+ if (!norm) {
6567
+ console.error(`${colors.red}Error: Invalid priority '${rawPriority}'. Valid options: Critical, High, Medium, Low${colors.reset}`);
6568
+ process.exit(1);
6569
+ }
6570
+ priority = norm;
6571
+ }
4816
6572
  const projectCode = flags["project"] || discovered.config?.projectCode;
4817
6573
  if (!projectCode) {
4818
6574
  console.error(`${colors.red}Error: Project is required to create a ticket (--project <code> or configure projectCode in esedre.json).${colors.reset}`);
4819
6575
  console.error(`${colors.dim}Tip: Run 'ese init' to initialize this repository.${colors.reset}`);
4820
6576
  process.exit(1);
4821
6577
  }
4822
- const complexity = flags["complexity"] || "Medium";
4823
- const estimatedEffort = flags["effort"] || "2.0 - 4.0 hours";
4824
- const summary = flags["summary"] || void 0;
6578
+ const complexity = normalizeDashesAndMojibake(flags["complexity"] || "Medium");
6579
+ const estimatedEffort = normalizeDashesAndMojibake(flags["effort"] || "2.0 - 4.0 hours");
6580
+ const summary = flags["summary"] ? normalizeDashesAndMojibake(flags["summary"]) : void 0;
4825
6581
  const submittedBy = flags["author"] || "Developer";
4826
6582
  const val = validateProjectCode(projectCode);
4827
6583
  if (!val.valid) {
@@ -4830,6 +6586,7 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4830
6586
  }
4831
6587
  const detailArg = flags["detail"];
4832
6588
  const fileArg = flags["file"];
6589
+ const milestone = flags["milestone"] || flags["m"] || void 0;
4833
6590
  let detailMarkdown = detailArg;
4834
6591
  if (fileArg) {
4835
6592
  const filePath = path9.resolve(fileArg);
@@ -4847,18 +6604,20 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4847
6604
  title,
4848
6605
  type,
4849
6606
  category,
6607
+ priority,
4850
6608
  projectCode,
4851
6609
  complexity,
4852
6610
  estimatedEffort,
4853
6611
  summary,
4854
6612
  submittedBy,
4855
6613
  detail: detailMarkdown,
4856
- detailMarkdown
6614
+ detailMarkdown,
6615
+ milestone
4857
6616
  });
4858
6617
  if (isJson) {
4859
6618
  console.log(JSON.stringify(created, null, 2));
4860
6619
  } else {
4861
- console.log(`${colors.green}\u2714 Created Ticket #${created.meta.id}: ${created.meta.title}${colors.reset} [${created.projectDescriptor?.code || created.meta.project || "UNASSIGNED"}] (SHA-1: ${created.sha1?.slice(0, 8)})`);
6620
+ console.log(`${colors.green}\u2714 Created Ticket #${created.meta.id}: ${created.meta.title}${colors.reset} [${created.projectDescriptor?.code || created.meta.project || "UNASSIGNED"}]${created.meta.priority ? ` [${created.meta.priority}]` : ""} (SHA-1: ${created.sha1?.slice(0, 8)})`);
4862
6621
  }
4863
6622
  return;
4864
6623
  }
@@ -4871,11 +6630,12 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4871
6630
  const projectFlag = flags["project"];
4872
6631
  const lookupKey = projectFlag && /^\d+$/.test(idStr) ? `${projectFlag}-${idStr}` : idStr;
4873
6632
  const status = flags["status"];
4874
- const title = flags["title"];
4875
- const complexity = flags["complexity"];
4876
- const effort = flags["effort"];
6633
+ const title = flags["title"] ? normalizeDashesAndMojibake(flags["title"]) : void 0;
6634
+ const complexity = flags["complexity"] ? normalizeDashesAndMojibake(flags["complexity"]) : void 0;
6635
+ const effort = flags["effort"] ? normalizeDashesAndMojibake(flags["effort"]) : void 0;
4877
6636
  const inDev = flags["in-dev"] !== void 0 ? Boolean(flags["in-dev"]) : void 0;
4878
6637
  const flag = flags["flag"];
6638
+ const rawMilestone = flags["milestone"] !== void 0 ? flags["milestone"] : flags["m"];
4879
6639
  const isForce = Boolean(flags["force"]);
4880
6640
  const lastHash = isForce ? void 0 : flags["last-hash"];
4881
6641
  const updates = {};
@@ -4885,19 +6645,172 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4885
6645
  updates.category = type;
4886
6646
  }
4887
6647
  if (status) updates.status = status;
6648
+ if (flags["priority"] !== void 0) {
6649
+ const raw = String(flags["priority"]).trim();
6650
+ if (raw.toLowerCase() === "none" || raw.toLowerCase() === "clear" || raw.toLowerCase() === "null") {
6651
+ updates.priority = "none";
6652
+ } else {
6653
+ const norm = normalizePriority(raw);
6654
+ if (!norm) {
6655
+ console.error(`${colors.red}Error: Invalid priority '${raw}'. Valid options: Critical, High, Medium, Low, none${colors.reset}`);
6656
+ process.exit(1);
6657
+ }
6658
+ updates.priority = norm;
6659
+ }
6660
+ }
4888
6661
  if (title) updates.title = title;
4889
6662
  if (complexity) updates.complexity = complexity;
4890
6663
  if (effort) updates.estimatedEffort = effort;
4891
6664
  if (inDev !== void 0) updates.isActivePlanning = inDev;
4892
6665
  if (flag) updates.featureFlag = flag;
6666
+ if (rawMilestone !== void 0) updates.milestone = rawMilestone;
4893
6667
  const updated = await storage.updateTicket(lookupKey, updates, lastHash);
4894
6668
  if (isJson) {
4895
6669
  console.log(JSON.stringify(updated, null, 2));
4896
6670
  } else {
4897
- console.log(`${colors.green}\u2714 Updated Ticket #${lookupKey}: ${updated.meta.title} (Status: ${updated.meta.status}, SHA-1: ${updated.sha1?.slice(0, 8)})${colors.reset}`);
6671
+ console.log(`${colors.green}\u2714 Updated Ticket #${lookupKey}: ${updated.meta.title} (Status: ${updated.meta.status}${updated.meta.priority ? `, Priority: ${updated.meta.priority}` : ""}${updated.meta.milestone ? `, Milestone: ${updated.meta.milestone}` : ""}, SHA-1: ${updated.sha1?.slice(0, 8)})${colors.reset}`);
4898
6672
  }
4899
6673
  return;
4900
6674
  }
6675
+ case "milestone":
6676
+ case "milestones": {
6677
+ const subAction = positionals[0] || "list";
6678
+ const project = flags["project"] || flags["p"] || discovered.config?.projectCode;
6679
+ if (subAction === "list") {
6680
+ const milestones = await storage.listMilestones(project);
6681
+ const allTickets = await storage.listTickets({ project });
6682
+ if (isJson) {
6683
+ console.log(JSON.stringify(milestones, null, 2));
6684
+ } else {
6685
+ console.log(formatMilestoneListTable(milestones, allTickets));
6686
+ if (milestones.length > 0) {
6687
+ console.log(`
6688
+ ${colors.dim}Total: ${milestones.length} milestones${colors.reset}`);
6689
+ }
6690
+ }
6691
+ return;
6692
+ }
6693
+ if (subAction === "get") {
6694
+ const idStr = positionals[1];
6695
+ if (!idStr) {
6696
+ console.error(`${colors.red}Error: Milestone ID or title is required (e.g. ese milestone get 1 or ese milestone get "v0.2.0")${colors.reset}`);
6697
+ process.exit(1);
6698
+ }
6699
+ const milestone = await storage.getMilestone(idStr, project);
6700
+ if (!milestone) {
6701
+ console.error(`${colors.red}Error: Milestone "${idStr}" not found.${colors.reset}`);
6702
+ process.exit(1);
6703
+ }
6704
+ const tickets = await storage.listTickets({ project, milestone: String(milestone.id) });
6705
+ if (isJson) {
6706
+ console.log(JSON.stringify({ ...milestone, tickets }, null, 2));
6707
+ } else {
6708
+ console.log(`${colors.bold}${colors.cyan}Milestone #${milestone.id}: ${milestone.title}${colors.reset}`);
6709
+ console.log(`${colors.dim}${"=".repeat(60)}${colors.reset}`);
6710
+ console.log(`${colors.bold}Project:${colors.reset} ${milestone.project}`);
6711
+ console.log(`${colors.bold}Status:${colors.reset} ${milestone.status}`);
6712
+ if (milestone.featureFlag) {
6713
+ console.log(`${colors.bold}Umbrella Flag:${colors.reset} ${colors.yellow}${milestone.featureFlag}${colors.reset} ${colors.dim}(inherited by all tickets in milestone)${colors.reset}`);
6714
+ }
6715
+ if (milestone.targetDate) {
6716
+ console.log(`${colors.bold}Target Date:${colors.reset} ${milestone.targetDate}`);
6717
+ }
6718
+ if (milestone.description) {
6719
+ console.log(`
6720
+ ${colors.bold}Description:${colors.reset}
6721
+ ${milestone.description}`);
6722
+ }
6723
+ const completedCount = tickets.filter((t) => t.meta.status === "Completed").length;
6724
+ const progress = tickets.length > 0 ? Math.round(completedCount / tickets.length * 100) : 0;
6725
+ console.log(`
6726
+ ${colors.bold}Progress:${colors.reset} ${completedCount} / ${tickets.length} completed (${progress}%)`);
6727
+ if (tickets.length > 0) {
6728
+ console.log(`
6729
+ ${colors.bold}Tickets:${colors.reset}`);
6730
+ for (const t of tickets) {
6731
+ console.log(` \u2022 #${t.meta.id} [${t.meta.status}] [${t.meta.type}]: ${t.meta.title}${t.meta.priority ? ` (${t.meta.priority})` : ""}`);
6732
+ }
6733
+ }
6734
+ }
6735
+ return;
6736
+ }
6737
+ if (subAction === "create") {
6738
+ const rawTitle = flags["title"] || positionals.slice(1).join(" ").trim();
6739
+ if (!rawTitle) {
6740
+ console.error(`${colors.red}Error: --title is required to create a milestone (e.g. ese milestone create --title "v0.2.0")${colors.reset}`);
6741
+ process.exit(1);
6742
+ }
6743
+ const title = normalizeDashesAndMojibake(rawTitle);
6744
+ const projectCode = project;
6745
+ if (!projectCode) {
6746
+ console.error(`${colors.red}Error: Project is required to create a milestone (--project <code> or configure projectCode in esedre.json).${colors.reset}`);
6747
+ process.exit(1);
6748
+ }
6749
+ const description = flags["desc"] || flags["description"];
6750
+ const featureFlag = flags["flag"] || flags["feature-flag"];
6751
+ const targetDate = flags["target-date"] || flags["date"];
6752
+ const status = flags["status"] || "Planned";
6753
+ const created = await storage.createMilestone({
6754
+ projectCode,
6755
+ title,
6756
+ description,
6757
+ featureFlag,
6758
+ targetDate,
6759
+ status
6760
+ });
6761
+ if (isJson) {
6762
+ console.log(JSON.stringify(created, null, 2));
6763
+ } else {
6764
+ console.log(`${colors.green}\u2714 Created Milestone #${created.id}: ${created.title} [${created.project}]${created.featureFlag ? ` (Umbrella Flag: ${created.featureFlag})` : ""}${colors.reset}`);
6765
+ }
6766
+ return;
6767
+ }
6768
+ if (subAction === "update") {
6769
+ const idStr = positionals[1];
6770
+ if (!idStr) {
6771
+ console.error(`${colors.red}Error: Milestone ID is required (e.g. ese milestone update 1 --status Active)${colors.reset}`);
6772
+ process.exit(1);
6773
+ }
6774
+ const updates = {};
6775
+ if (flags["title"]) updates.title = flags["title"];
6776
+ if (flags["desc"] || flags["description"]) updates.description = flags["desc"] || flags["description"];
6777
+ if (flags["status"]) updates.status = flags["status"];
6778
+ if (flags["flag"] !== void 0 || flags["feature-flag"] !== void 0) {
6779
+ updates.featureFlag = flags["flag"] !== void 0 ? flags["flag"] : flags["feature-flag"];
6780
+ }
6781
+ if (flags["target-date"] !== void 0 || flags["date"] !== void 0) {
6782
+ updates.targetDate = flags["target-date"] !== void 0 ? flags["target-date"] : flags["date"];
6783
+ }
6784
+ const updated = await storage.updateMilestone(idStr, updates, project);
6785
+ if (isJson) {
6786
+ console.log(JSON.stringify(updated, null, 2));
6787
+ } else {
6788
+ console.log(`${colors.green}\u2714 Updated Milestone #${updated.id}: ${updated.title} (Status: ${updated.status}${updated.featureFlag ? `, Flag: ${updated.featureFlag}` : ""})${colors.reset}`);
6789
+ }
6790
+ return;
6791
+ }
6792
+ if (subAction === "delete") {
6793
+ const idStr = positionals[1];
6794
+ if (!idStr) {
6795
+ console.error(`${colors.red}Error: Milestone ID is required (e.g. ese milestone delete 1)${colors.reset}`);
6796
+ process.exit(1);
6797
+ }
6798
+ const success = await storage.deleteMilestone(idStr, project);
6799
+ if (isJson) {
6800
+ console.log(JSON.stringify({ success, id: idStr }, null, 2));
6801
+ } else {
6802
+ if (success) {
6803
+ console.log(`${colors.green}\u2714 Deleted Milestone #${idStr}${colors.reset}`);
6804
+ } else {
6805
+ console.error(`${colors.red}Error: Milestone "${idStr}" not found.${colors.reset}`);
6806
+ process.exit(1);
6807
+ }
6808
+ }
6809
+ return;
6810
+ }
6811
+ console.error(`${colors.red}Error: Unknown milestone action "${subAction}". Valid actions: list, get, create, update, delete${colors.reset}`);
6812
+ process.exit(1);
6813
+ }
4901
6814
  case "comment": {
4902
6815
  const idStr = positionals[0];
4903
6816
  if (!idStr) {
@@ -4921,6 +6834,58 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4921
6834
  }
4922
6835
  return;
4923
6836
  }
6837
+ case "link": {
6838
+ let sourceId = positionals[0];
6839
+ let relationArg = flags["relation"];
6840
+ let targetId = flags["to"] || positionals[1];
6841
+ if (positionals.length >= 3) {
6842
+ sourceId = positionals[0];
6843
+ relationArg = positionals[1];
6844
+ targetId = positionals[2];
6845
+ } else if (positionals.length === 2 && !flags["relation"]) {
6846
+ const normRel = positionals[1].toLowerCase().replace(/_/g, "-");
6847
+ if (["blocks", "blocked-by", "relates-to", "parent-of", "child-of", "duplicates", "duplicated-by"].includes(normRel)) {
6848
+ relationArg = normRel;
6849
+ targetId = flags["to"] || positionals[2];
6850
+ } else {
6851
+ targetId = positionals[1];
6852
+ relationArg = "relates-to";
6853
+ }
6854
+ }
6855
+ if (!sourceId || !targetId) {
6856
+ console.error(`${colors.red}Error: Both source and target ticket IDs are required.${colors.reset}`);
6857
+ console.error(`${colors.dim}Usage: ese link <sourceId> <relation> <targetId> [options]${colors.reset}`);
6858
+ console.error(`${colors.dim}Relations: blocks, blocked-by, relates-to, parent-of, child-of, duplicates, duplicated-by${colors.reset}`);
6859
+ process.exit(1);
6860
+ }
6861
+ const relation = (relationArg || "relates-to").toLowerCase().replace(/_/g, "-");
6862
+ const project = flags["project"] || discovered.config?.projectCode;
6863
+ const author = flags["author"] || "User";
6864
+ const result = await storage.addTicketLink(sourceId, relation, targetId, { author, project });
6865
+ if (isJson) {
6866
+ console.log(JSON.stringify(result, null, 2));
6867
+ } else {
6868
+ console.log(`${colors.green}\u2714 Linked #${result.source.meta.id} (${result.source.meta.project || "Project"}) -> ${relation} -> #${targetId}${colors.reset}`);
6869
+ }
6870
+ return;
6871
+ }
6872
+ case "unlink": {
6873
+ const sourceId = positionals[0];
6874
+ const targetId = flags["to"] || positionals[1];
6875
+ if (!sourceId || !targetId) {
6876
+ console.error(`${colors.red}Error: Both source and target ticket IDs are required.${colors.reset}`);
6877
+ console.error(`${colors.dim}Usage: ese unlink <sourceId> <targetId> [options]${colors.reset}`);
6878
+ process.exit(1);
6879
+ }
6880
+ const project = flags["project"] || discovered.config?.projectCode;
6881
+ const result = await storage.removeTicketLink(sourceId, targetId, { project });
6882
+ if (isJson) {
6883
+ console.log(JSON.stringify(result, null, 2));
6884
+ } else {
6885
+ console.log(`${colors.green}\u2714 Unlinked #${result.source.meta.id} from #${targetId}${colors.reset}`);
6886
+ }
6887
+ return;
6888
+ }
4924
6889
  default: {
4925
6890
  console.error(`${colors.red}Error: Unknown command "${command}"${colors.reset}`);
4926
6891
  printHelp();