esedre 0.1.12 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.12";
13
+ var CURRENT_ESEDRE_VERSION = "1.0.1";
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)) {
@@ -551,9 +597,18 @@ var colors = {
551
597
  red: isColorSupported ? "\x1B[31m" : "",
552
598
  blue: isColorSupported ? "\x1B[34m" : ""
553
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
+ }
554
609
  function normalizeDashesAndMojibake(val) {
555
610
  if (typeof val !== "string") return val;
556
- 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\u0080\u009d|“|”/g, '"').replace(/\u00e2\u20ac\u00a6|…/g, "...").replace(/[\u2012\u2013\u2014\u2015\u2212]/g, "-");
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, "-");
557
612
  }
558
613
  function normalizeTicketFields(ticket) {
559
614
  if (!ticket) return ticket;
@@ -561,11 +616,31 @@ function normalizeTicketFields(ticket) {
561
616
  if (ticket.meta.title) ticket.meta.title = normalizeDashesAndMojibake(ticket.meta.title);
562
617
  if (ticket.meta.estimatedEffort) ticket.meta.estimatedEffort = normalizeDashesAndMojibake(ticket.meta.estimatedEffort);
563
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
+ }
564
630
  }
565
631
  if (ticket.detail) {
566
632
  if (ticket.detail.title) ticket.detail.title = normalizeDashesAndMojibake(ticket.detail.title);
567
633
  if (ticket.detail.estimatedEffort) ticket.detail.estimatedEffort = normalizeDashesAndMojibake(ticket.detail.estimatedEffort);
568
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
+ }
569
644
  if (ticket.detail.breakdown) ticket.detail.breakdown = ticket.detail.breakdown.map((b) => normalizeDashesAndMojibake(b));
570
645
  if (ticket.detail.technicalDetails) ticket.detail.technicalDetails = ticket.detail.technicalDetails.map((t) => normalizeDashesAndMojibake(t));
571
646
  if (ticket.detail.openQuestions) ticket.detail.openQuestions = ticket.detail.openQuestions.map((q) => normalizeDashesAndMojibake(q));
@@ -586,25 +661,55 @@ function formatTicketListTable(tickets) {
586
661
  if (tickets.length === 0) {
587
662
  return `${colors.dim}No tickets found matching criteria.${colors.reset}`;
588
663
  }
664
+ const showPriority = tickets.some((t) => Boolean(t.meta.priority));
589
665
  const rows = tickets.map((t) => {
590
666
  const id = t.projectDescriptor?.code ? `${t.projectDescriptor.code}-${t.meta.id}` : `#${t.meta.id}`;
591
667
  const project = t.projectDescriptor?.code || "CORE";
592
668
  const type = t.meta.type || t.meta.category || "Feature";
669
+ const priority = t.meta.priority || "-";
593
670
  const status = t.meta.status;
594
- const title = normalizeDashesAndMojibake(t.meta.title);
595
- return { id, project, type, status, title };
671
+ const title = normalizeDashesAndMojibake(t.meta.title) + (t.isBlocked ? ` ${colors.red}[BLOCKED]${colors.reset}` : "");
672
+ return { id, project, type, priority, status, title };
596
673
  });
597
674
  const idWidth = Math.max(4, ...rows.map((r) => r.id.length));
598
675
  const projWidth = Math.max(7, ...rows.map((r) => r.project.length));
599
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;
600
678
  const statusWidth = Math.max(14, ...rows.map((r) => r.status.length));
601
- const header = `${colors.bold}${pad("ID", idWidth)} ${pad("Project", projWidth)} ${pad("Type", typeWidth)} ${pad("Status", statusWidth)} Title${colors.reset}`;
602
- const divider = `${colors.dim}${"-".repeat(idWidth)} ${"-".repeat(projWidth)} ${"-".repeat(typeWidth)} ${"-".repeat(statusWidth)} ${"-".repeat(40)}${colors.reset}`;
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}`;
603
695
  const formattedRows = rows.map((r) => {
604
696
  const statusColored = colorStatus(r.status);
605
697
  const typeColored = colorType(r.type);
606
698
  const projColored = `${colors.magenta}${r.project}${colors.reset}`;
607
- 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}`;
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(" ");
608
713
  });
609
714
  return [header, divider, ...formattedRows].join("\n");
610
715
  }
@@ -615,12 +720,22 @@ function formatTicketDetail(ticket) {
615
720
  lines.push(`${colors.dim}${"=".repeat(60)}${colors.reset}`);
616
721
  lines.push(`${colors.bold}Project:${colors.reset} ${projectDescriptor ? `${projectDescriptor.code} - ${projectDescriptor.name}` : "Default"}`);
617
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
+ }
618
726
  lines.push(`${colors.bold}Status:${colors.reset} ${colorStatus(meta.status)}`);
619
727
  lines.push(`${colors.bold}Complexity:${colors.reset} ${normalizeDashesAndMojibake(meta.complexity || "Medium")}`);
620
728
  lines.push(`${colors.bold}Effort:${colors.reset} ${normalizeDashesAndMojibake(meta.estimatedEffort || "N/A")}`);
621
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
+ }
622
733
  if (meta.featureFlag) {
623
- lines.push(`${colors.bold}Feature Flag:${colors.reset} ${colors.yellow}${meta.featureFlag}${colors.reset}`);
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}`);
624
739
  }
625
740
  if (detail?.summary) {
626
741
  lines.push("");
@@ -648,6 +763,17 @@ function formatTicketDetail(ticket) {
648
763
  lines.push(` \u2022 ${normalizeDashesAndMojibake(q)}`);
649
764
  }
650
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
+ }
651
777
  if (planMarkdown) {
652
778
  lines.push("");
653
779
  lines.push(`${colors.bold}Implementation Plan:${colors.reset}`);
@@ -693,6 +819,53 @@ function colorType(type) {
693
819
  return type;
694
820
  }
695
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
+ }
696
869
 
697
870
  // src/storage/filesystem.ts
698
871
  function isProjectMatch(code, filter) {
@@ -748,6 +921,20 @@ var FilesystemStorageAdapter = class {
748
921
  }
749
922
  }
750
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
+ }
751
938
  try {
752
939
  const discovered = findEsedreConfig(this.workspaceRoot);
753
940
  this.config = discovered.config || {};
@@ -1260,6 +1447,567 @@ ${formattedList}`
1260
1447
  }
1261
1448
  return projectDesc;
1262
1449
  }
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
+ }
1459
+ const locations = this.resolveProjectLocations();
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)) {
1489
+ try {
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");
1495
+ }
1496
+ } catch {
1497
+ }
1498
+ }
1499
+ }
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
+ }
1533
+ }
1534
+ }
1535
+ }
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
+ }
1553
+ }
1554
+ const locations = this.resolveProjectLocations();
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
+ }
1263
2011
  findTicketLocation(id) {
1264
2012
  const locations = this.resolveProjectLocations();
1265
2013
  if (locations.length === 0) return null;
@@ -1369,6 +2117,9 @@ ${formattedList}`
1369
2117
  meta.id = meta.id ?? num;
1370
2118
  meta.type = meta.type || meta.category || "Feature";
1371
2119
  meta.category = meta.type;
2120
+ if (meta.priority) {
2121
+ meta.priority = normalizePriority(meta.priority);
2122
+ }
1372
2123
  let effectiveLoc = loc;
1373
2124
  if (loc.isSharedDir) {
1374
2125
  const found = locations.find(
@@ -1378,18 +2129,61 @@ ${formattedList}`
1378
2129
  }
1379
2130
  meta.project = effectiveLoc.project.code;
1380
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
+ }
1381
2143
  if (filter?.project && filter.project !== "all") {
1382
2144
  const pLower = filter.project.toLowerCase();
1383
2145
  if (!isProjectMatch(meta.project, filter.project)) {
1384
2146
  continue;
1385
2147
  }
1386
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
+ }
1387
2169
  if (filter?.status && meta.status !== filter.status) {
1388
2170
  continue;
1389
2171
  }
1390
2172
  if (filter?.category && meta.category !== filter.category) {
1391
2173
  continue;
1392
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
+ }
1393
2187
  if (filter?.search) {
1394
2188
  const term = filter.search.toLowerCase();
1395
2189
  const matchesTitle = meta.title.toLowerCase().includes(term);
@@ -1425,8 +2219,23 @@ ${formattedList}`
1425
2219
  projectDescriptor: effectiveLoc.project
1426
2220
  };
1427
2221
  normalizeTicketFields(ticketObj);
2222
+ this.enrichTicketLinks(ticketObj);
1428
2223
  ticketObj.sha1 = computeTicketHash(ticketObj);
1429
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
+ }
1430
2239
  tickets.push(ticketObj);
1431
2240
  } catch {
1432
2241
  }
@@ -1450,6 +2259,19 @@ ${formattedList}`
1450
2259
  meta.id = meta.id ?? locInfo.id;
1451
2260
  meta.project = locInfo.loc.project.code;
1452
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
+ }
1453
2275
  let detail;
1454
2276
  const detailPath = path3.join(locInfo.ticketDir, "detail.md");
1455
2277
  if (fs3.existsSync(detailPath)) {
@@ -1477,6 +2299,7 @@ ${formattedList}`
1477
2299
  projectDescriptor: locInfo.loc.project
1478
2300
  };
1479
2301
  normalizeTicketFields(ticket);
2302
+ this.enrichTicketLinks(ticket);
1480
2303
  ticket.sha1 = computeTicketHash(ticket);
1481
2304
  ticket.lastHash = ticket.sha1;
1482
2305
  return ticket;
@@ -1526,10 +2349,13 @@ ${formattedList}`
1526
2349
  if (itemMatch) openQuestions.push(itemMatch[1].trim());
1527
2350
  }
1528
2351
  }
2352
+ const priorityMatch = raw.match(/\*\*Priority\*\*:\s*([^\n]+)/i);
2353
+ const priority = priorityMatch ? normalizePriority(priorityMatch[1].trim()) : void 0;
1529
2354
  return {
1530
2355
  title,
1531
2356
  type,
1532
2357
  category,
2358
+ priority,
1533
2359
  complexity,
1534
2360
  estimatedEffort,
1535
2361
  summary,
@@ -1572,6 +2398,24 @@ ${formattedList}`
1572
2398
  const cleanTitle = normalizeDashesAndMojibake(input.title).slice(0, 48).trim();
1573
2399
  const cleanEffort = normalizeDashesAndMojibake(input.estimatedEffort || input.effort || "2.0 - 4.0 hours");
1574
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
+ }
1575
2419
  const cleanSummary = input.summary ? normalizeDashesAndMojibake(input.summary) : void 0;
1576
2420
  const cleanDetailRaw = input.detailMarkdown || input.detail ? normalizeDashesAndMojibake(input.detailMarkdown || input.detail) : void 0;
1577
2421
  const meta = {
@@ -1579,6 +2423,10 @@ ${formattedList}`
1579
2423
  title: cleanTitle,
1580
2424
  type: effectiveType,
1581
2425
  category: effectiveType,
2426
+ ...cleanPriority ? { priority: cleanPriority } : {},
2427
+ ...cleanMilestone ? { milestone: cleanMilestone } : {},
2428
+ ...cleanFeatureFlag ? { featureFlag: cleanFeatureFlag } : {},
2429
+ ...inheritedFlag ? { inheritedFeatureFlag: inheritedFlag } : {},
1582
2430
  complexity: cleanComplexity,
1583
2431
  estimatedEffort: cleanEffort,
1584
2432
  submittedBy: input.submittedBy || "Developer",
@@ -1592,6 +2440,10 @@ ${formattedList}`
1592
2440
  project: targetLoc.project.code
1593
2441
  };
1594
2442
  const rawDetail = cleanDetailRaw?.trim();
2443
+ const priorityLine = meta.priority ? `**Priority**: ${meta.priority}
2444
+ ` : "";
2445
+ const milestoneLine = meta.milestone ? `**Milestone**: ${meta.milestone}
2446
+ ` : "";
1595
2447
  let detailMd;
1596
2448
  if (rawDetail) {
1597
2449
  if (/^#\s+[^\n]+/m.test(rawDetail)) {
@@ -1599,7 +2451,7 @@ ${formattedList}`
1599
2451
  if (!/\*\*(?:Type|Category)\*\*:/i.test(processed)) {
1600
2452
  const metaBlock = `
1601
2453
  **Category**: ${meta.category}
1602
- **Complexity**: ${meta.complexity}
2454
+ ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
1603
2455
  **Estimated Effort**: ${meta.estimatedEffort}
1604
2456
  `;
1605
2457
  processed = processed.replace(/^(# Ticket[^\n]+\n)/m, `$1${metaBlock}`);
@@ -1639,7 +2491,7 @@ ${rawDetail}
1639
2491
  }
1640
2492
  detailMd = `# Ticket #${nextId}: ${meta.title}
1641
2493
  **Category**: ${meta.category}
1642
- **Complexity**: ${meta.complexity}
2494
+ ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
1643
2495
  **Estimated Effort**: ${meta.estimatedEffort}
1644
2496
 
1645
2497
  ${body.trim()}
@@ -1648,7 +2500,7 @@ ${body.trim()}
1648
2500
  } else {
1649
2501
  detailMd = `# Ticket #${nextId}: ${meta.title}
1650
2502
  **Category**: ${meta.category}
1651
- **Complexity**: ${meta.complexity}
2503
+ ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
1652
2504
  **Estimated Effort**: ${meta.estimatedEffort}
1653
2505
 
1654
2506
  ### Summary
@@ -1703,13 +2555,48 @@ ${input.summary || "Summary to be defined."}
1703
2555
  completedAt,
1704
2556
  revision
1705
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
+ }
1706
2593
  writeSafeFile(metaPath, JSON.stringify(updatedMeta, null, 2) + "\n");
1707
2594
  const newType = updates.type || updates.category;
1708
2595
  if (newType) {
1709
2596
  updatedMeta.type = newType;
1710
2597
  updatedMeta.category = newType;
1711
2598
  }
1712
- 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)) {
1713
2600
  let content = existing.detail.raw;
1714
2601
  if (updates.title) {
1715
2602
  content = content.replace(/^#\s*(?:Ticket\s*#?\d+\s*:\s*|\d+\s*:\s*)?[^\n]+/im, `# Ticket #${locInfo.id}: ${updatedMeta.title}`);
@@ -1717,6 +2604,30 @@ ${input.summary || "Summary to be defined."}
1717
2604
  if (updates.type || updates.category) {
1718
2605
  content = content.replace(/\*\*(?:Type|Category)\*\*:\s*[^\n]+/i, `**Type**: ${updatedMeta.type}`);
1719
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
+ }
1720
2631
  if (updates.complexity) {
1721
2632
  content = content.replace(/\*\*Complexity\*\*:\s*[^\n]+/i, `**Complexity**: ${updatedMeta.complexity}`);
1722
2633
  }
@@ -1789,6 +2700,226 @@ ${input.summary || "Summary to be defined."}
1789
2700
  writeSafeFile(commentsPath, JSON.stringify(comments, null, 2) + "\n");
1790
2701
  return newComment;
1791
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);
2788
+ }
2789
+ ticket.links = enriched;
2790
+ ticket.isBlocked = isBlocked;
2791
+ }
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
+ );
2797
+ }
2798
+ const sourceInfo = this.resolveTicketIdentifier(sourceId, options?.project);
2799
+ if (!sourceInfo) {
2800
+ throw new Error(`Source ticket #${sourceId} could not be located.`);
2801
+ }
2802
+ const targetInfo = this.resolveTicketIdentifier(targetId, options?.project || sourceInfo.project);
2803
+ if (!targetInfo) {
2804
+ throw new Error(`Target ticket #${targetId} could not be located.`);
2805
+ }
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)) {
2841
+ try {
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;
2866
+ } catch {
2867
+ }
2868
+ }
2869
+ const updatedSource = await this.getTicket(sourceInfo.key);
2870
+ return { source: updatedSource, target: updatedTarget };
2871
+ }
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.`);
2876
+ }
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)) {
2882
+ try {
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
+ }
2895
+ } catch {
2896
+ }
2897
+ }
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 };
2922
+ }
1792
2923
  };
1793
2924
 
1794
2925
  // src/securityFilter.ts
@@ -1819,19 +2950,56 @@ var SecurityFilter = class {
1819
2950
  }
1820
2951
  return proj;
1821
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
+ }
1822
2990
  async listTickets(filter) {
1823
2991
  if (filter?.project && !isProjectAuthorized(filter.project, this.config.allowedProjects)) {
1824
2992
  throw new EsedreAuthorizationError(filter.project);
1825
2993
  }
1826
2994
  const tickets = await this.target.listTickets(filter);
1827
2995
  if (!this.config.allowedProjects || this.config.allowedProjects.length === 0) {
1828
- return tickets;
2996
+ return tickets.map((t) => this.redactLinks(t));
1829
2997
  }
1830
2998
  return tickets.filter((t) => {
1831
2999
  const code = t.projectDescriptor?.code || t.meta.project;
1832
3000
  if (!code) return true;
1833
3001
  return isProjectAuthorized(code, this.config.allowedProjects);
1834
- });
3002
+ }).map((t) => this.redactLinks(t));
1835
3003
  }
1836
3004
  async getTicket(id) {
1837
3005
  const ticket = await this.target.getTicket(id);
@@ -1840,7 +3008,7 @@ var SecurityFilter = class {
1840
3008
  if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
1841
3009
  throw new EsedreAuthorizationError(code);
1842
3010
  }
1843
- return ticket;
3011
+ return this.redactLinks(ticket);
1844
3012
  }
1845
3013
  async createTicket(input) {
1846
3014
  const targetCode = input.projectCode || this.config.projectCode;
@@ -1892,6 +3060,87 @@ var SecurityFilter = class {
1892
3060
  }
1893
3061
  return this.target.addComment(id, comment);
1894
3062
  }
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;
3070
+ }
3071
+ if (this.config.allowedProjects.includes("*")) {
3072
+ return all;
3073
+ }
3074
+ return all.filter((m) => isProjectAuthorized(m.project, this.config.allowedProjects));
3075
+ }
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);
3084
+ }
3085
+ return milestone;
3086
+ }
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);
3091
+ }
3092
+ return this.target.createMilestone(input);
3093
+ }
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);
3102
+ }
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
+ }
3109
+ }
3110
+ return this.target.deleteMilestone(id, projectCode);
3111
+ }
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
+ };
3127
+ }
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
+ };
3143
+ }
1895
3144
  };
1896
3145
 
1897
3146
  // src/mcp/server.ts
@@ -1948,7 +3197,7 @@ var EsedreMcpServer = class {
1948
3197
  },
1949
3198
  serverInfo: {
1950
3199
  name: "esedre",
1951
- version: "0.1.0"
3200
+ version: CURRENT_ESEDRE_VERSION
1952
3201
  }
1953
3202
  }
1954
3203
  };
@@ -2112,7 +3361,11 @@ Status: ${ticket.meta.status}`;
2112
3361
  status: { type: "string", enum: ["Planned", "In Development", "Completed", "Rejected"], description: "Ticket status" },
2113
3362
  type: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"], description: "Ticket type" },
2114
3363
  category: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"], description: "Ticket category (legacy alias for type)" },
2115
- 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" }
2116
3369
  }
2117
3370
  }
2118
3371
  },
@@ -2161,10 +3414,12 @@ Status: ${ticket.meta.status}`;
2161
3414
  project: { type: "string", description: "Target project code (e.g. CORE, WEB, DOCS)" },
2162
3415
  type: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"], description: "Ticket type" },
2163
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" },
2164
3418
  complexity: { type: "string", description: "Complexity (e.g. Low, Medium, High)" },
2165
3419
  effort: { type: "string", description: "Estimated effort (e.g. 2.0 - 4.0 hours)" },
2166
3420
  summary: { type: "string", description: "Initial feature summary" },
2167
3421
  detail: { type: "string", description: "Initial specification, feature breakdown, or technical detail markdown" },
3422
+ milestone: { type: "string", description: "Assign ticket to milestone" },
2168
3423
  author: { type: "string", description: "Submitting author name" }
2169
3424
  },
2170
3425
  required: ["title"]
@@ -2180,27 +3435,116 @@ Status: ${ticket.meta.status}`;
2180
3435
  status: { type: "string", enum: ["Planned", "In Development", "Completed", "Rejected"] },
2181
3436
  type: { type: "string", enum: ["Feature", "Platform", "Tools", "Idea", "Bug"] },
2182
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)" },
2183
3439
  title: { type: "string", description: "New title (max 48 characters)" },
2184
3440
  complexity: { type: "string", description: "Complexity (e.g. Low, Medium, High)" },
2185
3441
  effort: { type: "string", description: "Estimated effort (e.g. 2.0 - 4.0 hours)" },
2186
3442
  inDevelopment: { type: "boolean", description: "Active development toggle" },
2187
3443
  featureFlag: { type: "string", description: "Feature flag name" },
3444
+ milestone: { type: "string", description: 'Update ticket milestone ("none" to clear)' },
2188
3445
  lastHash: { type: "string", description: "Optimistic concurrency control: last known sha1 hash of the ticket" }
2189
3446
  },
2190
- required: ["ticketId"]
3447
+ required: ["ticketId"]
3448
+ }
3449
+ },
3450
+ {
3451
+ name: "esedre_add_comment",
3452
+ description: "Append a developer or LLM agent comment to a ticket.",
3453
+ inputSchema: {
3454
+ type: "object",
3455
+ properties: {
3456
+ ticketId: { type: ["integer", "string"], description: "Numeric ticket ID or compound key" },
3457
+ text: { type: "string", description: "Comment message" },
3458
+ author: { type: "string", description: "Author name (e.g. Antigravity, Developer)" }
3459
+ },
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"]
2191
3535
  }
2192
3536
  },
2193
3537
  {
2194
- name: "esedre_add_comment",
2195
- description: "Append a developer or LLM agent comment to a ticket.",
3538
+ name: "esedre_unlink_ticket",
3539
+ description: "Remove a link between two tickets and its reciprocal link.",
2196
3540
  inputSchema: {
2197
3541
  type: "object",
2198
3542
  properties: {
2199
- ticketId: { type: ["integer", "string"], description: "Numeric ticket ID or compound key" },
2200
- text: { type: "string", description: "Comment message" },
2201
- author: { type: "string", description: "Author name (e.g. Antigravity, Developer)" }
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" }
2202
3546
  },
2203
- required: ["ticketId", "text"]
3547
+ required: ["sourceTicketId", "targetTicketId"]
2204
3548
  }
2205
3549
  }
2206
3550
  ];
@@ -2215,7 +3559,11 @@ Status: ${ticket.meta.status}`;
2215
3559
  status: args.status,
2216
3560
  type,
2217
3561
  category: type,
2218
- 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
2219
3567
  });
2220
3568
  return tickets.map((t) => ({
2221
3569
  id: t.meta.id,
@@ -2223,10 +3571,16 @@ Status: ${ticket.meta.status}`;
2223
3571
  type: t.meta.type || t.meta.category,
2224
3572
  category: t.meta.type || t.meta.category,
2225
3573
  status: t.meta.status,
3574
+ priority: t.meta.priority,
2226
3575
  complexity: t.meta.complexity,
2227
3576
  effort: t.meta.estimatedEffort,
3577
+ milestone: t.meta.milestone,
3578
+ featureFlag: t.meta.featureFlag,
3579
+ inheritedFeatureFlag: t.meta.inheritedFeatureFlag,
2228
3580
  project: t.projectDescriptor?.code || t.meta.project || "UNASSIGNED",
2229
- sha1: t.sha1 || t.meta.sha1
3581
+ sha1: t.sha1 || t.meta.sha1,
3582
+ isBlocked: t.isBlocked,
3583
+ links: t.links
2230
3584
  }));
2231
3585
  }
2232
3586
  case "get_ticket": {
@@ -2251,12 +3605,14 @@ Status: ${ticket.meta.status}`;
2251
3605
  title: args.title,
2252
3606
  type,
2253
3607
  category: type,
3608
+ priority: args.priority,
2254
3609
  projectCode: args.project,
2255
3610
  complexity: args.complexity,
2256
3611
  estimatedEffort: args.effort,
2257
3612
  summary: args.summary,
2258
3613
  detail: args.detail || args.detailMarkdown,
2259
3614
  detailMarkdown: args.detailMarkdown || args.detail,
3615
+ milestone: args.milestone,
2260
3616
  submittedBy: args.author || "Agent"
2261
3617
  });
2262
3618
  return created;
@@ -2270,11 +3626,13 @@ Status: ${ticket.meta.status}`;
2270
3626
  updates.category = type;
2271
3627
  }
2272
3628
  if (args.status) updates.status = args.status;
3629
+ if (args.priority !== void 0) updates.priority = args.priority;
2273
3630
  if (args.title) updates.title = args.title;
2274
3631
  if (args.complexity) updates.complexity = args.complexity;
2275
3632
  if (args.effort) updates.estimatedEffort = args.effort;
2276
3633
  if (args.inDevelopment !== void 0) updates.isActivePlanning = Boolean(args.inDevelopment);
2277
3634
  if (args.featureFlag) updates.featureFlag = args.featureFlag;
3635
+ if (args.milestone !== void 0) updates.milestone = args.milestone;
2278
3636
  const updated = await this.storage.updateTicket(id, updates, args.lastHash);
2279
3637
  return updated;
2280
3638
  }
@@ -2286,6 +3644,63 @@ Status: ${ticket.meta.status}`;
2286
3644
  });
2287
3645
  return comment;
2288
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
+ }
2289
3704
  default:
2290
3705
  throw new Error(`Unknown tool: ${name}`);
2291
3706
  }
@@ -2485,7 +3900,7 @@ import path6 from "node:path";
2485
3900
  // src/server/daemon.ts
2486
3901
  import fs5 from "node:fs";
2487
3902
  import path5 from "node:path";
2488
- import os2 from "node:os";
3903
+ import os3 from "node:os";
2489
3904
  import http from "node:http";
2490
3905
  import { spawn, spawnSync } from "node:child_process";
2491
3906
  import { fileURLToPath } from "node:url";
@@ -2495,7 +3910,7 @@ function getGlobalEsedreDir() {
2495
3910
  if (process.env.ESEDRE_GLOBAL_DIR) {
2496
3911
  return process.env.ESEDRE_GLOBAL_DIR;
2497
3912
  }
2498
- return path5.join(os2.homedir(), ".esedre");
3913
+ return path5.join(os3.homedir(), ".esedre");
2499
3914
  }
2500
3915
  function ensureGlobalEsedreStore() {
2501
3916
  const globalDir = getGlobalEsedreDir();
@@ -2763,6 +4178,27 @@ async function stopDaemon(options = {}) {
2763
4178
  }
2764
4179
  return false;
2765
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
+ }
2766
4202
  }
2767
4203
  if (pid && isProcessAlive(pid)) {
2768
4204
  try {
@@ -2870,49 +4306,49 @@ function classifyContent(currentContent, latestTemplate, historicHashes) {
2870
4306
  }
2871
4307
  var WRAPPER_CMD = `@echo off
2872
4308
  REM Esedre Autonomous Ticketing & Project Planning Engine Wrapper (Windows CMD)
2873
- 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
+ )
2874
4337
 
2875
4338
  where ese >nul 2>nul
2876
- if %ERRORLEVEL% equ 0 goto use_ese
4339
+ if %ERRORLEVEL% equ 0 (
4340
+ call ese %*
4341
+ goto :eof
4342
+ )
2877
4343
 
2878
4344
  where esedre >nul 2>nul
2879
- if %ERRORLEVEL% equ 0 goto use_esedre
2880
-
2881
- if exist "%~dp0..\\..\\esedre\\dist\\esedre.mjs" goto use_sibling_dist
2882
- if exist "%~dp0..\\esedre\\dist\\esedre.mjs" goto use_local_dist
2883
- if exist "%~dp0..\\node_modules\\.bin\\ese.cmd" goto use_node_modules_ese
2884
- 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
+ )
2885
4349
 
2886
4350
  call npx --yes esedre %*
2887
- goto done
2888
-
2889
- :use_ese
2890
- call ese %*
2891
- goto done
2892
-
2893
- :use_esedre
2894
- call esedre %*
2895
- goto done
2896
-
2897
- :use_sibling_dist
2898
- node "%~dp0..\\..\\esedre\\dist\\esedre.mjs" %*
2899
- goto done
2900
-
2901
- :use_local_dist
2902
- node "%~dp0..\\esedre\\dist\\esedre.mjs" %*
2903
- goto done
2904
-
2905
- :use_node_modules_ese
2906
- call "%~dp0..\\node_modules\\.bin\\ese.cmd" %*
2907
- goto done
2908
-
2909
- :use_node_modules_esedre
2910
- call "%~dp0..\\node_modules\\.bin\\esedre.cmd" %*
2911
- goto done
2912
-
2913
- :done
2914
- endlocal
2915
- exit /b %ERRORLEVEL%
4351
+ goto :eof
2916
4352
  `;
2917
4353
  var WRAPPER_PS1 = `# Esedre Autonomous Ticketing & Project Planning Engine Wrapper (PowerShell)
2918
4354
  $ErrorActionPreference = "Stop"
@@ -2926,12 +4362,9 @@ try {
2926
4362
 
2927
4363
  $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
2928
4364
 
2929
- if (Get-Command "ese" -ErrorAction SilentlyContinue) {
2930
- & ese @args
2931
- exit $LASTEXITCODE
2932
- }
2933
- if (Get-Command "esedre" -ErrorAction SilentlyContinue) {
2934
- & esedre @args
4365
+ $inRepoEsedre = Join-Path $scriptDir "..\\dist\\esedre.mjs"
4366
+ if (Test-Path $inRepoEsedre) {
4367
+ & node $inRepoEsedre @args
2935
4368
  exit $LASTEXITCODE
2936
4369
  }
2937
4370
  $siblingEsedre = Join-Path $scriptDir "..\\..\\esedre\\dist\\esedre.mjs"
@@ -2944,6 +4377,11 @@ if (Test-Path $distEsedre) {
2944
4377
  & node $distEsedre @args
2945
4378
  exit $LASTEXITCODE
2946
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
+ }
2947
4385
  $localBin = Join-Path $scriptDir "..\\node_modules\\.bin\\ese.cmd"
2948
4386
  if (Test-Path $localBin) {
2949
4387
  & $localBin @args
@@ -2954,6 +4392,14 @@ if (Test-Path $localEsedreBin) {
2954
4392
  & $localEsedreBin @args
2955
4393
  exit $LASTEXITCODE
2956
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
+ }
2957
4403
  & npx --yes esedre @args
2958
4404
  exit $LASTEXITCODE
2959
4405
  `;
@@ -2962,11 +4408,8 @@ var WRAPPER_BASH = `#!/usr/bin/env bash
2962
4408
  set -e
2963
4409
  DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
2964
4410
 
2965
- if command -v ese >/dev/null 2>&1; then
2966
- exec ese "$@"
2967
- fi
2968
- if command -v esedre >/dev/null 2>&1; then
2969
- exec esedre "$@"
4411
+ if [ -f "$DIR/../dist/esedre.mjs" ]; then
4412
+ exec node "$DIR/../dist/esedre.mjs" "$@"
2970
4413
  fi
2971
4414
  if [ -f "$DIR/../../esedre/dist/esedre.mjs" ]; then
2972
4415
  exec node "$DIR/../../esedre/dist/esedre.mjs" "$@"
@@ -2974,12 +4417,21 @@ fi
2974
4417
  if [ -f "$DIR/../esedre/dist/esedre.mjs" ]; then
2975
4418
  exec node "$DIR/../esedre/dist/esedre.mjs" "$@"
2976
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
2977
4423
  if [ -f "$DIR/../node_modules/.bin/ese" ]; then
2978
4424
  exec "$DIR/../node_modules/.bin/ese" "$@"
2979
4425
  fi
2980
4426
  if [ -f "$DIR/../node_modules/.bin/esedre" ]; then
2981
4427
  exec "$DIR/../node_modules/.bin/esedre" "$@"
2982
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
2983
4435
  exec npx --yes esedre "$@"
2984
4436
  `;
2985
4437
  var ESE_WRAPPER_CMD = `@echo off
@@ -3131,7 +4583,7 @@ If the host application declares any or all of the 12 core design tokens on \`:r
3131
4583
  - **Never delete tickets** directly via filesystem.
3132
4584
  - **Workspace Scoping**: An LLM coding partner operates strictly within the authorized project scope declared in the local repository configuration. Central repository linking and system-level configuration are managed separately; focus roadmap work, implementation plans, and verification notes on this project.
3133
4585
  - **Secret Developer Hash Route (\`#/planner\`, \`#/plan\`, \`#planner\`, \`#plan\`)**:
3134
- - In web applications embedding Esedre (such as Professor Arwam), \`#/planner\` and \`#/plan\` serve as direct secret developer entry routes.
4586
+ - In web applications embedding Esedre (such as your host web app), \`#/planner\` and \`#/plan\` serve as direct secret developer entry routes.
3135
4587
  - **SEO Invariant**: These developer routes are private and MUST NEVER be exposed in \`sitemap.xml\`, \`robots.txt\`, \`llms.txt\`, or public navigation links.
3136
4588
  - The URL hash is preserved across hard page refreshes (F5) without falling back to \`#/chat\` or other views.
3137
4589
  `;
@@ -3556,10 +5008,17 @@ function startUiServer(port, webDir) {
3556
5008
 
3557
5009
  // src/server/apiServer.ts
3558
5010
  import http3 from "node:http";
5011
+ var MAX_JSON_BODY_BYTES = 10 * 1024 * 1024;
3559
5012
  function readJsonBody(req) {
3560
5013
  return new Promise((resolve, reject) => {
3561
5014
  let body = "";
5015
+ let bytesRead = 0;
3562
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
+ }
3563
5022
  body += chunk;
3564
5023
  });
3565
5024
  req.on("end", () => {
@@ -3612,7 +5071,7 @@ function createApiHandler(storage, workspaceRoot) {
3612
5071
  res.writeHead(204, {
3613
5072
  "Access-Control-Allow-Origin": "*",
3614
5073
  "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
3615
- "Access-Control-Allow-Headers": "Content-Type, Authorization"
5074
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, x-esedre-allowed-projects"
3616
5075
  });
3617
5076
  res.end();
3618
5077
  return true;
@@ -3644,12 +5103,14 @@ function createApiHandler(storage, workspaceRoot) {
3644
5103
  const status = url.searchParams.get("status") || void 0;
3645
5104
  const type = url.searchParams.get("type") || url.searchParams.get("category") || void 0;
3646
5105
  const category = type;
5106
+ const priority = url.searchParams.get("priority") || void 0;
3647
5107
  const search = url.searchParams.get("search") || void 0;
3648
5108
  const tickets = await reqStorage.listTickets({
3649
5109
  project: project === "all" ? void 0 : project,
3650
5110
  status,
3651
5111
  type,
3652
5112
  category,
5113
+ priority,
3653
5114
  search
3654
5115
  });
3655
5116
  sendJson(res, 200, tickets);
@@ -3685,9 +5146,10 @@ function createApiHandler(storage, workspaceRoot) {
3685
5146
  const numKey = String(t.meta.id);
3686
5147
  const code = t.projectDescriptor?.code || t.meta.project || "Profe";
3687
5148
  const prefixedKey = `${code}-${t.meta.id}`;
3688
- metasMap[prefixedKey] = t.meta;
5149
+ const metaWithLinks = { ...t.meta, links: t.links || t.meta.links || [], isBlocked: t.isBlocked };
5150
+ metasMap[prefixedKey] = metaWithLinks;
3689
5151
  if (!metasMap[numKey] || code.toUpperCase() === "PROF") {
3690
- metasMap[numKey] = t.meta;
5152
+ metasMap[numKey] = metaWithLinks;
3691
5153
  }
3692
5154
  commentsMap[prefixedKey] = t.comments || [];
3693
5155
  if (!commentsMap[numKey] || code.toUpperCase() === "PROF") {
@@ -3789,6 +5251,12 @@ function createApiHandler(storage, workspaceRoot) {
3789
5251
  sendJson(res, 200, snapshot);
3790
5252
  return true;
3791
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
+ }
3792
5260
  }
3793
5261
  if (req.method === "POST") {
3794
5262
  const body = await readJsonBody(req);
@@ -3825,6 +5293,59 @@ function createApiHandler(storage, workspaceRoot) {
3825
5293
  sendJson(res, 201, project);
3826
5294
  return true;
3827
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
+ }
3828
5349
  if (pathname === "/api/planning/plans") {
3829
5350
  const { ticketId, planMarkdown, lastHash } = body;
3830
5351
  invalidateApiCache();
@@ -3862,6 +5383,48 @@ function createApiHandler(storage, workspaceRoot) {
3862
5383
  sendJson(res, 200, { success: true, detail: detailMarkdown });
3863
5384
  return true;
3864
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
+ }
3865
5428
  }
3866
5429
  sendJson(res, 404, { error: `Endpoint not found: ${pathname}` });
3867
5430
  return true;
@@ -4043,18 +5606,33 @@ ${colors.bold}ROADMAP COMMANDS (Pair Programming & LLM Agents):${colors.reset}
4043
5606
  ${colors.bold}plan${colors.reset} <id> [--file <path> | --set "<markdown>"] [--last-hash <sha1>] [--json]
4044
5607
  View or update implementation plan with optimistic concurrency control.
4045
5608
 
4046
- ${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]
4047
5610
  Mint a new roadmap ticket with sequential numeric ID.
4048
5611
 
4049
- ${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]
4050
- 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.
4051
5614
 
4052
5615
  ${colors.bold}comment${colors.reset} <id> ["<text>"] [--text "..."] [--author "..."] [--json]
4053
5616
  Append a research finding, test verification, or note to ticket history.
4054
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
+
4055
5625
  ${colors.bold}snapshot, refresh${colors.reset} [--project <code>] [--json]
4056
5626
  Generate lean read-only projection snapshot (.esedre/snapshot.json) for zero-latency agent context.
4057
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
+
4058
5636
  ${colors.bold}SERVICE DAEMON COMMANDS:${colors.reset}
4059
5637
  ${colors.bold}start${colors.reset} [--port <n>] [--foreground | -f] [--quiet] [--json]
4060
5638
  Start the Esedre background server and web UI daemon (default port ${DEFAULT_ESEDRE_PORT}).
@@ -4090,12 +5668,24 @@ ${colors.bold}DEVELOPER ADMINISTRATION (Human Setup & Configuration):${colors.re
4090
5668
  ${colors.bold}projects${colors.reset} [--json]
4091
5669
  List registered workspace projects, project codes (e.g. 'CORE', 'WEB', 'DOCS'), and descriptions.
4092
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
+
4093
5677
  ${colors.bold}OPTIONS:${colors.reset}
4094
5678
  -p, --project <code> Project code (e.g. CORE, ALCE, DOCS).
4095
5679
  -n, --name <name> Project display name (e.g. "Alce Web Reader").
4096
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.
4097
5683
  -s, --status <stat> Ticket status ('Planned', 'In Development', 'Completed', 'Rejected').
4098
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.
4099
5689
  --detail "<md>" Specification markdown for ticket detail during create.
4100
5690
  --file <path> Path to markdown file for detail (create) or implementation plan (plan).
4101
5691
  --json Output raw machine-readable JSON (strongly recommended for autonomous LLM coding agents).
@@ -4134,7 +5724,7 @@ function parseArgs(rawArgs) {
4134
5724
  flags["help"] = true;
4135
5725
  } else if (key === "y") {
4136
5726
  flags["yes"] = true;
4137
- } 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") {
4138
5728
  const next = rawArgs[i + 1];
4139
5729
  if (next !== void 0 && !next.startsWith("-")) {
4140
5730
  flags[key] = next;
@@ -4155,9 +5745,12 @@ function parseArgs(rawArgs) {
4155
5745
  }
4156
5746
  if (flags["t"] && !flags["type"]) flags["type"] = flags["t"];
4157
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"];
4158
5750
  if (flags["n"] && !flags["name"]) flags["name"] = flags["n"];
4159
5751
  if (flags["s"] && !flags["status"]) flags["status"] = flags["s"];
4160
5752
  if (flags["q"] && !flags["search"]) flags["search"] = flags["q"];
5753
+ if (flags["r"] && !flags["relation"]) flags["relation"] = flags["r"];
4161
5754
  if (flags["f"] && !flags["foreground"]) flags["foreground"] = flags["f"];
4162
5755
  return { command, positionals, flags };
4163
5756
  }
@@ -4733,6 +6326,80 @@ ${colors.yellow}\u26A0\uFE0F Warning: Daemon is running v${status.version}, but
4733
6326
  printDaemonLogs({ port, lines, workspaceRoot: discovered.workspaceRoot });
4734
6327
  return;
4735
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
+ }
4736
6403
  case "projects": {
4737
6404
  printDuplicateProjectWarnings(storage, isJson);
4738
6405
  const projects = await storage.getProjects();
@@ -4753,8 +6420,26 @@ ${colors.yellow}\u26A0\uFE0F Warning: Daemon is running v${status.version}, but
4753
6420
  const status = flags["status"] || void 0;
4754
6421
  const type = flags["type"] || flags["category"] || void 0;
4755
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
+ }
4756
6438
  const search = flags["search"] || void 0;
4757
- 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 });
4758
6443
  if (isJson) {
4759
6444
  console.log(JSON.stringify(tickets.map((t) => ({
4760
6445
  id: t.meta.id,
@@ -4762,10 +6447,16 @@ ${colors.yellow}\u26A0\uFE0F Warning: Daemon is running v${status.version}, but
4762
6447
  type: t.meta.type || t.meta.category,
4763
6448
  category: t.meta.type || t.meta.category,
4764
6449
  status: t.meta.status,
6450
+ priority: t.meta.priority,
4765
6451
  complexity: normalizeDashesAndMojibake(t.meta.complexity),
4766
6452
  effort: normalizeDashesAndMojibake(t.meta.estimatedEffort),
6453
+ milestone: t.meta.milestone,
6454
+ featureFlag: t.meta.featureFlag,
6455
+ inheritedFeatureFlag: t.meta.inheritedFeatureFlag,
4767
6456
  project: t.projectDescriptor?.code || t.meta.project || "UNASSIGNED",
4768
- sha1: t.sha1 || t.meta.sha1
6457
+ sha1: t.sha1 || t.meta.sha1,
6458
+ isBlocked: t.isBlocked,
6459
+ links: t.links
4769
6460
  })), null, 2));
4770
6461
  } else {
4771
6462
  if (tickets.length === 0) {
@@ -4868,6 +6559,16 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4868
6559
  const title = normalizeDashesAndMojibake(rawTitle);
4869
6560
  const type = flags["type"] || flags["category"] || "Feature";
4870
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
+ }
4871
6572
  const projectCode = flags["project"] || discovered.config?.projectCode;
4872
6573
  if (!projectCode) {
4873
6574
  console.error(`${colors.red}Error: Project is required to create a ticket (--project <code> or configure projectCode in esedre.json).${colors.reset}`);
@@ -4885,6 +6586,7 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4885
6586
  }
4886
6587
  const detailArg = flags["detail"];
4887
6588
  const fileArg = flags["file"];
6589
+ const milestone = flags["milestone"] || flags["m"] || void 0;
4888
6590
  let detailMarkdown = detailArg;
4889
6591
  if (fileArg) {
4890
6592
  const filePath = path9.resolve(fileArg);
@@ -4902,18 +6604,20 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4902
6604
  title,
4903
6605
  type,
4904
6606
  category,
6607
+ priority,
4905
6608
  projectCode,
4906
6609
  complexity,
4907
6610
  estimatedEffort,
4908
6611
  summary,
4909
6612
  submittedBy,
4910
6613
  detail: detailMarkdown,
4911
- detailMarkdown
6614
+ detailMarkdown,
6615
+ milestone
4912
6616
  });
4913
6617
  if (isJson) {
4914
6618
  console.log(JSON.stringify(created, null, 2));
4915
6619
  } else {
4916
- 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)})`);
4917
6621
  }
4918
6622
  return;
4919
6623
  }
@@ -4931,6 +6635,7 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4931
6635
  const effort = flags["effort"] ? normalizeDashesAndMojibake(flags["effort"]) : void 0;
4932
6636
  const inDev = flags["in-dev"] !== void 0 ? Boolean(flags["in-dev"]) : void 0;
4933
6637
  const flag = flags["flag"];
6638
+ const rawMilestone = flags["milestone"] !== void 0 ? flags["milestone"] : flags["m"];
4934
6639
  const isForce = Boolean(flags["force"]);
4935
6640
  const lastHash = isForce ? void 0 : flags["last-hash"];
4936
6641
  const updates = {};
@@ -4940,19 +6645,172 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4940
6645
  updates.category = type;
4941
6646
  }
4942
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
+ }
4943
6661
  if (title) updates.title = title;
4944
6662
  if (complexity) updates.complexity = complexity;
4945
6663
  if (effort) updates.estimatedEffort = effort;
4946
6664
  if (inDev !== void 0) updates.isActivePlanning = inDev;
4947
6665
  if (flag) updates.featureFlag = flag;
6666
+ if (rawMilestone !== void 0) updates.milestone = rawMilestone;
4948
6667
  const updated = await storage.updateTicket(lookupKey, updates, lastHash);
4949
6668
  if (isJson) {
4950
6669
  console.log(JSON.stringify(updated, null, 2));
4951
6670
  } else {
4952
- 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}`);
4953
6672
  }
4954
6673
  return;
4955
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
+ }
4956
6814
  case "comment": {
4957
6815
  const idStr = positionals[0];
4958
6816
  if (!idStr) {
@@ -4976,6 +6834,58 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
4976
6834
  }
4977
6835
  return;
4978
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
+ }
4979
6889
  default: {
4980
6890
  console.error(`${colors.red}Error: Unknown command "${command}"${colors.reset}`);
4981
6891
  printHelp();