esedre 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esedre.mjs CHANGED
@@ -7,10 +7,10 @@ import path11 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
+ import os3 from "node:os";
11
11
 
12
12
  // src/types.ts
13
- var CURRENT_ESEDRE_VERSION = "1.2.0";
13
+ var CURRENT_ESEDRE_VERSION = "1.3.0";
14
14
  var INVERSE_RELATIONS = {
15
15
  "relates-to": "relates-to",
16
16
  "blocks": "blocked-by",
@@ -20,6 +20,19 @@ var INVERSE_RELATIONS = {
20
20
  "duplicates": "duplicated-by",
21
21
  "duplicated-by": "duplicates"
22
22
  };
23
+ var DEFAULT_LEASE_TTL_MINUTES = 60;
24
+ var EsedreLeaseConflictError = class extends Error {
25
+ ticketKey;
26
+ activeLease;
27
+ constructor(ticketKey, activeLease) {
28
+ super(
29
+ `Lease Conflict on Ticket ${ticketKey}: currently leased by '${activeLease.holder}'${activeLease.hostname ? ` on ${activeLease.hostname}` : ""} until ${activeLease.expiresAt}. Pass --force (or force: true) to override.`
30
+ );
31
+ this.name = "EsedreLeaseConflictError";
32
+ this.ticketKey = ticketKey;
33
+ this.activeLease = activeLease;
34
+ }
35
+ };
23
36
  var EsedreConflictError = class extends Error {
24
37
  constructor(ticketId, currentHash, lastHash) {
25
38
  super(
@@ -35,10 +48,57 @@ var EsedreConflictError = class extends Error {
35
48
  lastHash;
36
49
  };
37
50
 
51
+ // src/utils/identity.ts
52
+ import os from "node:os";
53
+ import { execSync } from "node:child_process";
54
+ function detectGitBranch(cwd) {
55
+ try {
56
+ const out = execSync("git rev-parse --abbrev-ref HEAD", {
57
+ cwd: cwd || process.cwd(),
58
+ stdio: ["ignore", "pipe", "ignore"],
59
+ encoding: "utf-8",
60
+ timeout: 1500
61
+ }).trim();
62
+ return out && out !== "HEAD" ? out : void 0;
63
+ } catch {
64
+ return void 0;
65
+ }
66
+ }
67
+ function resolveWorkerIdentity(input) {
68
+ let hostname = input?.hostname?.trim() || "";
69
+ if (!hostname) {
70
+ try {
71
+ hostname = os.hostname() || "local-host";
72
+ } catch {
73
+ hostname = "local-host";
74
+ }
75
+ }
76
+ let username = "";
77
+ try {
78
+ username = os.userInfo().username || "";
79
+ } catch {
80
+ username = process.env.USER || process.env.USERNAME || "developer";
81
+ }
82
+ const envHolder = process.env.ESEDRE_WORKER_ID || process.env.ESEDRE_AGENT_ID || process.env.AGY_AGENT_ID || "";
83
+ const holder = input?.holder?.trim() || envHolder.trim() || input?.configDefaultAuthor?.trim() || `${username}@${hostname}`;
84
+ const agentType = input?.agentType?.trim() || process.env.ESEDRE_AGENT_TYPE?.trim() || (process.env.AGY_CONVERSATION_ID ? "antigravity" : void 0);
85
+ const sessionId = input?.sessionId?.trim() || process.env.ESEDRE_SESSION_ID?.trim() || process.env.AGY_CONVERSATION_ID?.trim() || void 0;
86
+ const worktree = input?.worktree?.trim() || process.cwd();
87
+ const branch = input?.branch?.trim() || detectGitBranch(worktree);
88
+ return {
89
+ holder,
90
+ ...agentType ? { agentType } : {},
91
+ hostname,
92
+ ...sessionId ? { sessionId } : {},
93
+ ...branch ? { branch } : {},
94
+ ...worktree ? { worktree } : {}
95
+ };
96
+ }
97
+
38
98
  // src/config.ts
39
99
  import fs from "node:fs";
40
100
  import path from "node:path";
41
- import os from "node:os";
101
+ import os2 from "node:os";
42
102
  var DEFAULT_ESEDRE_PORT = 5674;
43
103
  var MAX_PROJECT_CODE_LENGTH = 8;
44
104
  var PROJECT_CODE_REGEX = /^[a-zA-Z0-9]{1,8}$/;
@@ -113,7 +173,7 @@ function getGlobalConfigDir() {
113
173
  return process.env.ESEDRE_GLOBAL_DIR;
114
174
  }
115
175
  if (process.env.VITEST) {
116
- const testGlobalDir = path.join(os.tmpdir(), "esedre-vitest-global");
176
+ const testGlobalDir = path.join(os2.tmpdir(), "esedre-vitest-global");
117
177
  if (!fs.existsSync(testGlobalDir)) {
118
178
  try {
119
179
  fs.mkdirSync(testGlobalDir, { recursive: true });
@@ -122,7 +182,7 @@ function getGlobalConfigDir() {
122
182
  }
123
183
  return testGlobalDir;
124
184
  }
125
- return path.join(os.homedir(), ".esedre");
185
+ return path.join(os2.homedir(), ".esedre");
126
186
  }
127
187
  function getGlobalConfigPath() {
128
188
  return path.join(getGlobalConfigDir(), "config.json");
@@ -130,7 +190,7 @@ function getGlobalConfigPath() {
130
190
  function expandHome(filePath) {
131
191
  if (!filePath) return filePath;
132
192
  if (filePath === "~" || filePath.startsWith("~/") || filePath.startsWith("~\\")) {
133
- return path.join(os.homedir(), filePath.slice(1));
193
+ return path.join(os2.homedir(), filePath.slice(1));
134
194
  }
135
195
  return filePath;
136
196
  }
@@ -492,7 +552,8 @@ function migrateEsedreConfig(raw, targetVersion = CURRENT_ESEDRE_VERSION) {
492
552
  serverUrl: raw?.serverUrl,
493
553
  port: typeof raw?.port === "number" && raw.port !== DEFAULT_ESEDRE_PORT ? raw.port : void 0,
494
554
  ports: typeof raw?.ports === "object" && raw?.ports ? raw.ports : void 0,
495
- projects: typeof raw?.projects === "object" && raw?.projects ? raw.projects : void 0
555
+ projects: typeof raw?.projects === "object" && raw?.projects ? raw.projects : void 0,
556
+ defaultAuthor: typeof raw?.defaultAuthor === "string" && raw.defaultAuthor.trim() ? raw.defaultAuthor.trim() : void 0
496
557
  };
497
558
  return migrated;
498
559
  }
@@ -572,6 +633,7 @@ async function generateProjectSnapshot(storage, projectCode, outputDir) {
572
633
  inheritedFeatureFlag: ticketObj.meta.inheritedFeatureFlag,
573
634
  links: ticketObj.meta.links || [],
574
635
  isBlocked: ticketObj.isBlocked || false,
636
+ ...ticketObj.lease ? { lease: ticketObj.lease } : {},
575
637
  hasPlan: Boolean(plan && plan.trim().length > 0),
576
638
  planMarkdown: plan && plan.trim().length > 0 ? plan : void 0,
577
639
  createdAt,
@@ -607,13 +669,17 @@ async function generateProjectSnapshot(storage, projectCode, outputDir) {
607
669
  });
608
670
  } catch {
609
671
  }
672
+ const activeLeases = entries.map((e) => e.lease).filter((l) => Boolean(l));
673
+ const lastTicketId = entries.reduce((max, e) => Math.max(max, e.id), 0);
610
674
  const snapshot = {
611
675
  version: CURRENT_ESEDRE_VERSION,
612
676
  projectCode,
613
677
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
614
678
  totalTickets: entries.length,
679
+ lastTicketId,
615
680
  tickets: entries,
616
- ...milestoneSnapshots && milestoneSnapshots.length > 0 ? { milestones: milestoneSnapshots } : {}
681
+ ...milestoneSnapshots && milestoneSnapshots.length > 0 ? { milestones: milestoneSnapshots } : {},
682
+ ...activeLeases.length > 0 ? { activeLeases } : {}
617
683
  };
618
684
  const esedreDir = path2.join(outputDir, ".esedre");
619
685
  if (!fs2.existsSync(esedreDir)) {
@@ -801,6 +867,14 @@ function formatTicketDetail(ticket) {
801
867
  if (ticket.isBlocked) {
802
868
  lines.push(`${colors.bold}Dependency:${colors.reset} ${colors.red}${colors.bold}Blocked by uncompleted ticket${colors.reset}`);
803
869
  }
870
+ if (ticket.lease) {
871
+ const l = ticket.lease;
872
+ const hostInfo = l.hostname ? ` on ${l.hostname}` : "";
873
+ const branchInfo = l.branch ? ` [branch: ${l.branch}]` : "";
874
+ lines.push(
875
+ `${colors.bold}Active Lease:${colors.reset} ${colors.cyan}${colors.bold}${l.holder}${colors.reset}${hostInfo}${branchInfo} ${colors.dim}(expires ${l.expiresAt})${colors.reset}`
876
+ );
877
+ }
804
878
  if (detail?.summary) {
805
879
  lines.push("");
806
880
  lines.push(`${colors.bold}Summary:${colors.reset}`);
@@ -933,6 +1007,23 @@ function formatMilestoneListTable(milestones, tickets) {
933
1007
  });
934
1008
  return [header, divider, ...formattedRows].join("\n");
935
1009
  }
1010
+ function formatLeaseListTable(leases) {
1011
+ if (leases.length === 0) {
1012
+ return `${colors.dim}No active ticket leases.${colors.reset}`;
1013
+ }
1014
+ const lines = [];
1015
+ const header = `${pad("TICKET", 12)} ${pad("HOLDER", 22)} ${pad("HOST / AGENT", 22)} ${pad("BRANCH", 24)} ${pad("EXPIRES AT", 24)}`;
1016
+ lines.push(`${colors.bold}${colors.cyan}${header}${colors.reset}`);
1017
+ lines.push(`${colors.dim}${"-".repeat(108)}${colors.reset}`);
1018
+ for (const l of leases) {
1019
+ const hostAgent = [l.hostname, l.agentType].filter(Boolean).join(" / ") || "-";
1020
+ const branch = l.branch || "-";
1021
+ lines.push(
1022
+ `${colors.bold}${pad(l.ticketKey, 12)}${colors.reset} ${colors.green}${pad(l.holder.slice(0, 20), 22)}${colors.reset} ${pad(hostAgent.slice(0, 20), 22)} ${colors.cyan}${pad(branch.slice(0, 22), 24)}${colors.reset} ${colors.dim}${pad(l.expiresAt, 24)}${colors.reset}`
1023
+ );
1024
+ }
1025
+ return lines.join("\n");
1026
+ }
936
1027
 
937
1028
  // src/storage/filesystem.ts
938
1029
  function isProjectMatch(code, filter) {
@@ -1865,7 +1956,7 @@ ${formattedList}`
1865
1956
  } catch {
1866
1957
  }
1867
1958
  }
1868
- const globalConfigFile = path3.join(os2.homedir(), ".esedre", "config.json");
1959
+ const globalConfigFile = path3.join(os3.homedir(), ".esedre", "config.json");
1869
1960
  if (fs3.existsSync(globalConfigFile)) {
1870
1961
  try {
1871
1962
  const gCfg = JSON.parse(fs3.readFileSync(globalConfigFile, "utf-8"));
@@ -2202,7 +2293,8 @@ ${formattedList}`
2202
2293
  "implementation_plan.md",
2203
2294
  "comments.json",
2204
2295
  "answers.json",
2205
- "inline-comments.json"
2296
+ "inline-comments.json",
2297
+ "lease.json"
2206
2298
  ];
2207
2299
  const currentStats = {};
2208
2300
  for (const f of filesToCheck) {
@@ -2230,14 +2322,34 @@ ${formattedList}`
2230
2322
  }
2231
2323
  if (match) {
2232
2324
  let ticket = cached.ticket;
2233
- if (ticket.meta.links && ticket.meta.links.length > 0) {
2234
- ticket = {
2235
- ...ticket,
2236
- meta: { ...ticket.meta, links: ticket.meta.links.map((l) => ({ ...l })) }
2237
- };
2238
- this.enrichTicketLinks(ticket);
2325
+ if (ticket.lease) {
2326
+ const expMs = Date.parse(ticket.lease.expiresAt);
2327
+ if (!isNaN(expMs) && expMs <= Date.now()) {
2328
+ try {
2329
+ fs3.unlinkSync(path3.join(ticketDir, "lease.json"));
2330
+ } catch {
2331
+ }
2332
+ this.ticketMemoryCache.delete(resolvedTicketDir);
2333
+ } else {
2334
+ if (ticket.meta.links && ticket.meta.links.length > 0) {
2335
+ ticket = {
2336
+ ...ticket,
2337
+ meta: { ...ticket.meta, links: ticket.meta.links.map((l) => ({ ...l })) }
2338
+ };
2339
+ this.enrichTicketLinks(ticket);
2340
+ }
2341
+ return ticket;
2342
+ }
2343
+ } else {
2344
+ if (ticket.meta.links && ticket.meta.links.length > 0) {
2345
+ ticket = {
2346
+ ...ticket,
2347
+ meta: { ...ticket.meta, links: ticket.meta.links.map((l) => ({ ...l })) }
2348
+ };
2349
+ this.enrichTicketLinks(ticket);
2350
+ }
2351
+ return ticket;
2239
2352
  }
2240
- return ticket;
2241
2353
  }
2242
2354
  }
2243
2355
  }
@@ -2313,6 +2425,32 @@ ${formattedList}`
2313
2425
  } catch {
2314
2426
  }
2315
2427
  }
2428
+ let lease = null;
2429
+ const leasePath = path3.join(ticketDir, "lease.json");
2430
+ if (currentStats["lease.json"]) {
2431
+ try {
2432
+ const parsedLease = JSON.parse(fs3.readFileSync(leasePath, "utf-8"));
2433
+ const expMs = Date.parse(parsedLease.expiresAt);
2434
+ if (!isNaN(expMs) && expMs <= Date.now()) {
2435
+ try {
2436
+ fs3.unlinkSync(leasePath);
2437
+ delete currentStats["lease.json"];
2438
+ } catch {
2439
+ }
2440
+ } else {
2441
+ parsedLease.project = effectiveLoc.project.code;
2442
+ parsedLease.ticketId = meta.id;
2443
+ parsedLease.ticketKey = `${effectiveLoc.project.code}-${meta.id}`;
2444
+ lease = parsedLease;
2445
+ }
2446
+ } catch {
2447
+ }
2448
+ }
2449
+ if (lease) {
2450
+ meta.lease = lease;
2451
+ } else {
2452
+ delete meta.lease;
2453
+ }
2316
2454
  const ticketObj = {
2317
2455
  meta,
2318
2456
  detail,
@@ -2320,6 +2458,7 @@ ${formattedList}`
2320
2458
  comments,
2321
2459
  answers,
2322
2460
  inlineComments,
2461
+ lease,
2323
2462
  projectDescriptor: effectiveLoc.project
2324
2463
  };
2325
2464
  normalizeTicketFields(ticketObj);
@@ -2450,6 +2589,18 @@ ${formattedList}`
2450
2589
  continue;
2451
2590
  }
2452
2591
  }
2592
+ if (filter?.leased !== void 0) {
2593
+ const hasActiveLease = Boolean(ticketObj.lease);
2594
+ if (hasActiveLease !== Boolean(filter.leased)) {
2595
+ continue;
2596
+ }
2597
+ }
2598
+ if (filter?.leasedBy) {
2599
+ const holderFilter = filter.leasedBy.trim().toLowerCase();
2600
+ if (!ticketObj.lease || ticketObj.lease.holder.toLowerCase() !== holderFilter) {
2601
+ continue;
2602
+ }
2603
+ }
2453
2604
  tickets.push(ticketObj);
2454
2605
  }
2455
2606
  const locResolvedDir = path3.resolve(loc.ticketsDir);
@@ -2483,7 +2634,7 @@ ${formattedList}`
2483
2634
  const category = type;
2484
2635
  const complexityMatch = raw.match(/\*\*Complexity\*\*:\s*([^\n]+)/i);
2485
2636
  const complexity = complexityMatch ? complexityMatch[1].trim() : "Medium";
2486
- const effortMatch = raw.match(/\*\*Estimated Effort\*\*:\s*([^\n]+)/i);
2637
+ const effortMatch = raw.match(/\*\*(?:Estimated\s+)?Effort\*\*:\s*([^\n]+)/i);
2487
2638
  const estimatedEffort = effortMatch ? effortMatch[1].trim() : "2.0 - 4.0 hours";
2488
2639
  let summary;
2489
2640
  const summaryMatch = raw.match(/(?:##|###)\s*(?:Summary|Rationale)\s*\n([\s\S]*?)(?=\n##|\n###|\n#|$)/i);
@@ -2491,30 +2642,86 @@ ${formattedList}`
2491
2642
  summary = summaryMatch[1].trim();
2492
2643
  }
2493
2644
  const breakdown = [];
2494
- const breakdownMatch = raw.match(/(?:##|###)\s*Feature Breakdown\s*\n([\s\S]*?)(?=\n##|\n###|\n#|$)/i);
2645
+ const breakdownMatch = raw.match(/(?:##|###)\s*(?:Feature|Task|Fix|Implementation)\s+Breakdown\s*\n([\s\S]*?)(?=\n##|\n###|\n#|$)/i);
2495
2646
  if (breakdownMatch) {
2496
2647
  const lines = breakdownMatch[1].split("\n");
2648
+ let currentItem = "";
2497
2649
  for (const line of lines) {
2498
- const itemMatch = line.match(/^\s*(?:\d+\.|\*|-)\s+(.*)/);
2499
- if (itemMatch) breakdown.push(itemMatch[1].trim());
2650
+ const numberedMatch = line.match(/^\s*\d+\.\s+(.*)$/);
2651
+ if (numberedMatch) {
2652
+ if (currentItem) breakdown.push(currentItem);
2653
+ currentItem = numberedMatch[1].trim();
2654
+ } else if (/^\s*[-*•]\s+/.test(line)) {
2655
+ const bulletText = line.trim().replace(/^[-*•]\s+/, "").trim();
2656
+ if (/^\s{2,}[-*•]\s+/.test(line) && currentItem) {
2657
+ currentItem += "\n - " + bulletText;
2658
+ } else {
2659
+ if (currentItem) {
2660
+ breakdown.push(currentItem);
2661
+ currentItem = "";
2662
+ }
2663
+ breakdown.push(bulletText);
2664
+ }
2665
+ } else if (currentItem && line.trim()) {
2666
+ currentItem += " " + line.trim();
2667
+ }
2500
2668
  }
2669
+ if (currentItem) breakdown.push(currentItem);
2501
2670
  }
2502
2671
  const technicalDetails = [];
2503
- const techMatch = raw.match(/###\s*(?:Technical Details?|Architecture)\s*\n+([\s\S]*?)(?=\n###|\n---|\n##|$)/i);
2672
+ const openQuestions = [];
2673
+ const combinedMatch = raw.match(/(?:##|###)\s*Technical Details?\s*&\s*Open Questions?\s*\n+([\s\S]*?)(?=\n###|\n---|\n##|\n#|$)/i);
2674
+ if (combinedMatch) {
2675
+ const lines = combinedMatch[1].split("\n");
2676
+ for (const line of lines) {
2677
+ const itemMatch = line.match(/^\s*(?:\d+\.|\*|-|•)\s+(.*)/);
2678
+ if (!itemMatch) continue;
2679
+ const content = itemMatch[1].trim();
2680
+ if (!content) continue;
2681
+ if (/^\*\*Open Questions?\*\*:\s*/i.test(content)) {
2682
+ const q = content.replace(/^\*\*Open Questions?\*\*:\s*/i, "").trim();
2683
+ if (q && !/^(?:None|None recorded at initialization|N\/A)\.?$/i.test(q)) {
2684
+ openQuestions.push(q);
2685
+ }
2686
+ } else {
2687
+ const td = content.replace(/^\*\*Technical Details?\*\*:\s*/i, "").trim();
2688
+ if (td && !/^Architecture specifications to be documented\.?$/i.test(td)) {
2689
+ technicalDetails.push(td);
2690
+ }
2691
+ }
2692
+ }
2693
+ }
2694
+ const techMatch = raw.match(/(?:##|###)\s*(?:Technical Details?(?!\s*&\s*Open Questions?)(?:\s*&\s*Architecture)?|Architecture)\s*\n+([\s\S]*?)(?=\n###|\n---|\n##|\n#|$)/i);
2504
2695
  if (techMatch) {
2505
2696
  const lines = techMatch[1].split("\n");
2506
2697
  for (const line of lines) {
2507
- const itemMatch = line.match(/^\s*(?:\d+\.|\*|-)\s+(.*)/);
2508
- if (itemMatch) technicalDetails.push(itemMatch[1].trim());
2698
+ const itemMatch = line.match(/^\s*(?:\d+\.|\*|-|•)\s+(.*)/);
2699
+ if (!itemMatch) continue;
2700
+ const content = itemMatch[1].trim();
2701
+ if (!content) continue;
2702
+ if (/^\*\*Open Questions?\*\*:\s*/i.test(content)) {
2703
+ const q = content.replace(/^\*\*Open Questions?\*\*:\s*/i, "").trim();
2704
+ if (q && !/^(?:None|None recorded at initialization|N\/A)\.?$/i.test(q)) {
2705
+ openQuestions.push(q);
2706
+ }
2707
+ } else {
2708
+ const td = content.replace(/^\*\*Technical Details?\*\*:\s*/i, "").trim();
2709
+ if (td && !/^Architecture specifications to be documented\.?$/i.test(td)) {
2710
+ technicalDetails.push(td);
2711
+ }
2712
+ }
2509
2713
  }
2510
2714
  }
2511
- const openQuestions = [];
2512
- const questionsMatch = raw.match(/###\s*(?:Open Questions?|Technical Detail & Open Questions?|Decisions)\s*\n+([\s\S]*?)(?=\n###|\n---|\n##|$)/i);
2715
+ const questionsMatch = raw.match(/(?:##|###)\s*(?:Open Questions?(?:\s*&\s*Decisions)?|Decisions)\s*\n+([\s\S]*?)(?=\n###|\n---|\n##|\n#|$)/i);
2513
2716
  if (questionsMatch) {
2514
2717
  const lines = questionsMatch[1].split("\n");
2515
2718
  for (const line of lines) {
2516
- const itemMatch = line.match(/^\s*(?:\d+\.|\*|-)\s+(.*)/);
2517
- if (itemMatch) openQuestions.push(itemMatch[1].trim());
2719
+ const itemMatch = line.match(/^\s*(?:\d+\.|\*|-|•)\s+(.*)/);
2720
+ if (!itemMatch) continue;
2721
+ const content = itemMatch[1].trim().replace(/^\*\*Open Questions?\*\*:\s*/i, "").trim();
2722
+ if (content && !/^(?:None|None recorded at initialization|N\/A)\.?$/i.test(content)) {
2723
+ openQuestions.push(content);
2724
+ }
2518
2725
  }
2519
2726
  }
2520
2727
  const priorityMatch = raw.match(/\*\*Priority\*\*:\s*([^\n]+)/i);
@@ -2533,6 +2740,101 @@ ${formattedList}`
2533
2740
  raw
2534
2741
  };
2535
2742
  }
2743
+ reconcileDetailMetadataHeaders(content, meta, updates, syncAll = false) {
2744
+ const sectionSplitMatch = content.match(/\n(?=(?:##|###)\s+)/);
2745
+ const splitIdx = sectionSplitMatch && sectionSplitMatch.index !== void 0 ? sectionSplitMatch.index : content.length;
2746
+ let preamble = content.slice(0, splitIdx);
2747
+ const bodySection = content.slice(splitIdx);
2748
+ const replaceAndDedupeLine = (text, lineRegex, formatReplacement) => {
2749
+ let seen = false;
2750
+ const next = text.replace(lineRegex, (_full, lead = "", label = "") => {
2751
+ if (!seen) {
2752
+ seen = true;
2753
+ return formatReplacement(lead, label);
2754
+ }
2755
+ return "__ESEDRE_DEDUPE_REMOVE__";
2756
+ });
2757
+ return {
2758
+ text: next.replace(/\n?__ESEDRE_DEDUPE_REMOVE__[ \t]*\r?/g, ""),
2759
+ matched: seen
2760
+ };
2761
+ };
2762
+ if (syncAll || updates?.title) {
2763
+ preamble = preamble.replace(
2764
+ /^#\s*(?:Ticket\s*#?\d+\s*:\s*|\d+\s*:\s*)?[^\n]+/im,
2765
+ `# Ticket #${meta.id}: ${meta.title}`
2766
+ );
2767
+ }
2768
+ if (syncAll || updates?.type || updates?.category) {
2769
+ const effectiveType = meta.type || meta.category;
2770
+ const preferTypeLabel = Boolean(updates?.type || updates?.category);
2771
+ preamble = replaceAndDedupeLine(
2772
+ preamble,
2773
+ /^([ \t]*(?:[-*•]\s+)?)\*\*(Type|Category)\*\*:\s*[^\n\r]*/gim,
2774
+ (lead, label) => `${lead}**${preferTypeLabel ? "Type" : label}**: ${effectiveType}${lead.trim() ? "" : " "}`
2775
+ ).text;
2776
+ }
2777
+ if (syncAll || updates?.status) {
2778
+ preamble = replaceAndDedupeLine(
2779
+ preamble,
2780
+ /^([ \t]*(?:[-*•]\s+)?)\*\*(Status)\*\*:\s*[^\n\r]*/gim,
2781
+ (lead) => `${lead}**Status**: ${meta.status}${lead.trim() ? "" : " "}`
2782
+ ).text;
2783
+ }
2784
+ if (syncAll || updates && "priority" in updates) {
2785
+ if (meta.priority) {
2786
+ const res = replaceAndDedupeLine(
2787
+ preamble,
2788
+ /^([ \t]*(?:[-*•]\s+)?)\*\*(Priority)\*\*:\s*[^\n\r]*/gim,
2789
+ (lead) => `${lead}**Priority**: ${meta.priority}${lead.trim() ? "" : " "}`
2790
+ );
2791
+ preamble = res.text;
2792
+ if (!res.matched && /\*\*(?:Type|Category)\*\*:\s*[^\n]+/i.test(preamble)) {
2793
+ preamble = preamble.replace(
2794
+ /^([ \t]*(?:[-*•]\s+)?\*\*(?:Type|Category)\*\*:\s*[^\n\r]*)/im,
2795
+ `$1
2796
+ **Priority**: ${meta.priority} `
2797
+ );
2798
+ }
2799
+ } else {
2800
+ preamble = preamble.replace(/^[ \t]*(?:[-*•]\s+)?\*\*Priority\*\*:\s*[^\n\r]*\r?\n?/gim, "");
2801
+ }
2802
+ }
2803
+ if (syncAll || updates && "milestone" in updates) {
2804
+ if (meta.milestone) {
2805
+ const res = replaceAndDedupeLine(
2806
+ preamble,
2807
+ /^([ \t]*(?:[-*•]\s+)?)\*\*(Milestone)\*\*:\s*[^\n\r]*/gim,
2808
+ (lead) => `${lead}**Milestone**: ${meta.milestone}${lead.trim() ? "" : " "}`
2809
+ );
2810
+ preamble = res.text;
2811
+ if (!res.matched && /\*\*(?:Priority|Type|Category)\*\*:\s*[^\n]+/i.test(preamble)) {
2812
+ preamble = preamble.replace(
2813
+ /^([ \t]*(?:[-*•]\s+)?\*\*(?:Priority|Type|Category)\*\*:\s*[^\n\r]*)/im,
2814
+ `$1
2815
+ **Milestone**: ${meta.milestone} `
2816
+ );
2817
+ }
2818
+ } else {
2819
+ preamble = preamble.replace(/^[ \t]*(?:[-*•]\s+)?\*\*Milestone\*\*:\s*[^\n\r]*\r?\n?/gim, "");
2820
+ }
2821
+ }
2822
+ if (syncAll || updates?.complexity) {
2823
+ preamble = replaceAndDedupeLine(
2824
+ preamble,
2825
+ /^([ \t]*(?:[-*•]\s+)?)\*\*(Complexity)\*\*:\s*[^\n\r]*/gim,
2826
+ (lead) => `${lead}**Complexity**: ${meta.complexity}${lead.trim() ? "" : " "}`
2827
+ ).text;
2828
+ }
2829
+ if (syncAll || updates?.estimatedEffort) {
2830
+ preamble = replaceAndDedupeLine(
2831
+ preamble,
2832
+ /^([ \t]*(?:[-*•]\s+)?)\*\*((?:Estimated\s+)?Effort)\*\*:\s*[^\n\r]*/gim,
2833
+ (lead, label) => `${lead}**${label || "Estimated Effort"}**: ${meta.estimatedEffort}${lead.trim() ? "" : " "}`
2834
+ ).text;
2835
+ }
2836
+ return preamble + bodySection;
2837
+ }
2536
2838
  formatTicketDetailMarkdown(rawDetail, meta, fallbackSummary) {
2537
2839
  const trimmed = rawDetail.trim();
2538
2840
  const priorityLine = meta.priority ? `**Priority**: ${meta.priority}
@@ -2541,26 +2843,37 @@ ${formattedList}`
2541
2843
  ` : "";
2542
2844
  if (/^#\s+[^\n]+/m.test(trimmed)) {
2543
2845
  let processed = trimmed.replace(/^#\s+[^\n]+/m, `# Ticket #${meta.id}: ${meta.title}`);
2544
- if (!/\*\*(?:Type|Category)\*\*:/i.test(processed)) {
2846
+ const preambleCheck = processed.split(/\n(?=(?:##|###)\s+)/)[0];
2847
+ const hasAnyMetaLine = /\*\*(?:Type|Category|Priority|Milestone|Complexity|(?:Estimated\s+)?Effort|Status)\*\*:/i.test(
2848
+ preambleCheck
2849
+ );
2850
+ if (!hasAnyMetaLine) {
2545
2851
  const metaBlock = `
2546
2852
  **Category**: ${meta.category}
2547
2853
  ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
2548
2854
  **Estimated Effort**: ${meta.estimatedEffort}
2549
2855
  `;
2550
2856
  processed = processed.replace(/^(# Ticket[^\n]+\n)/m, `$1${metaBlock}`);
2857
+ } else if (!/\*\*(?:Type|Category)\*\*:/i.test(preambleCheck)) {
2858
+ processed = processed.replace(/^(# Ticket[^\n]+\n)/m, `$1**Category**: ${meta.category}
2859
+ `);
2551
2860
  }
2861
+ processed = this.reconcileDetailMetadataHeaders(processed, meta, void 0, true);
2552
2862
  if (!/(?:##|###)\s*(?:Summary|Rationale)/i.test(processed)) {
2553
2863
  const summaryBlock = `
2554
2864
  ### Summary
2555
2865
  ${fallbackSummary || "Summary to be defined."}
2556
2866
  `;
2557
- processed = processed.replace(/^((?:# Ticket[^\n]+\n)(?:\*\*[^\n]+\n)*)/m, `$1${summaryBlock}`);
2867
+ processed = processed.replace(
2868
+ /^((?:# Ticket[^\n]+\n)(?:[ \t]*(?:[-*•]\s+)?\*\*[^\n]+\n|\s*\n)*)/m,
2869
+ `$1${summaryBlock}`
2870
+ );
2558
2871
  }
2559
2872
  return processed.endsWith("\n") ? processed : `${processed}
2560
2873
  `;
2561
2874
  }
2562
2875
  const hasSummary = /(?:##|###)\s*(?:Summary|Rationale)/i.test(trimmed);
2563
- const hasBreakdown = /(?:##|###)\s*Feature Breakdown/i.test(trimmed);
2876
+ const hasBreakdown = /(?:##|###)\s*(?:Feature|Task|Fix|Implementation)\s+Breakdown/i.test(trimmed);
2564
2877
  let body = "";
2565
2878
  if (!hasSummary) {
2566
2879
  body += `### Summary
@@ -2767,7 +3080,17 @@ ${input.summary || "Summary to be defined."}
2767
3080
  }
2768
3081
  }
2769
3082
  }
3083
+ delete updatedMeta.lease;
2770
3084
  writeSafeFile(metaPath, JSON.stringify(updatedMeta, null, 2) + "\n");
3085
+ if (updates.status === "Completed" || updates.status === "Rejected") {
3086
+ const leasePath = path3.join(locInfo.ticketDir, "lease.json");
3087
+ if (fs3.existsSync(leasePath)) {
3088
+ try {
3089
+ fs3.unlinkSync(leasePath);
3090
+ } catch {
3091
+ }
3092
+ }
3093
+ }
2771
3094
  const newType = updates.type || updates.category;
2772
3095
  if (newType) {
2773
3096
  updatedMeta.type = newType;
@@ -2777,74 +3100,15 @@ ${input.summary || "Summary to be defined."}
2777
3100
  if (rawDetailArg !== void 0) {
2778
3101
  const cleanDetail = normalizeDashesAndMojibake(rawDetailArg);
2779
3102
  let content = this.formatTicketDetailMarkdown(cleanDetail, updatedMeta, existing.detail?.summary);
2780
- if (updates.type || updates.category) {
2781
- content = content.replace(/\*\*(?:Type|Category)\*\*:\s*[^\n]+/i, `**Type**: ${updatedMeta.type}`);
2782
- }
2783
- if (updatedMeta.priority) {
2784
- if (/\*\*Priority\*\*:\s*[^\n]+/i.test(content)) {
2785
- content = content.replace(/\*\*Priority\*\*:\s*[^\n]+/i, `**Priority**: ${updatedMeta.priority}`);
2786
- } else if (/\*\*(?:Type|Category)\*\*:\s*[^\n]+/i.test(content)) {
2787
- content = content.replace(/(\*\*(?:Type|Category)\*\*:\s*[^\n]+)/i, `$1
2788
- **Priority**: ${updatedMeta.priority}`);
2789
- }
2790
- } else if ("priority" in updates) {
2791
- content = content.replace(/\*\*Priority\*\*:\s*[^\n]+\n?/i, "");
2792
- }
2793
- if (updatedMeta.milestone) {
2794
- if (/\*\*Milestone\*\*:\s*[^\n]+/i.test(content)) {
2795
- content = content.replace(/\*\*Milestone\*\*:\s*[^\n]+/i, `**Milestone**: ${updatedMeta.milestone}`);
2796
- } else if (/\*\*(?:Priority|Type|Category)\*\*:\s*[^\n]+/i.test(content)) {
2797
- content = content.replace(/(\*\*(?:Priority|Type|Category)\*\*:\s*[^\n]+)/i, `$1
2798
- **Milestone**: ${updatedMeta.milestone}`);
2799
- }
2800
- } else if ("milestone" in updates) {
2801
- content = content.replace(/\*\*Milestone\*\*:\s*[^\n]+\n?/i, "");
2802
- }
2803
- if (updates.complexity) {
2804
- content = content.replace(/\*\*Complexity\*\*:\s*[^\n]+/i, `**Complexity**: ${updatedMeta.complexity}`);
2805
- }
2806
- if (updates.estimatedEffort) {
2807
- content = content.replace(/\*\*Estimated Effort\*\*:\s*[^\n]+/i, `**Estimated Effort**: ${updatedMeta.estimatedEffort}`);
2808
- }
3103
+ content = this.reconcileDetailMetadataHeaders(content, updatedMeta, updates, true);
2809
3104
  writeSafeFile(path3.join(locInfo.ticketDir, "detail.md"), content);
2810
- } else if (existing.detail && ("priority" in updates || "milestone" in updates || updates.title || updates.type || updates.category || updates.complexity || updates.estimatedEffort)) {
2811
- let content = existing.detail.raw;
2812
- if (updates.title) {
2813
- content = content.replace(/^#\s*(?:Ticket\s*#?\d+\s*:\s*|\d+\s*:\s*)?[^\n]+/im, `# Ticket #${locInfo.id}: ${updatedMeta.title}`);
2814
- }
2815
- if (updates.type || updates.category) {
2816
- content = content.replace(/\*\*(?:Type|Category)\*\*:\s*[^\n]+/i, `**Type**: ${updatedMeta.type}`);
2817
- }
2818
- if ("priority" in updates) {
2819
- if (updatedMeta.priority) {
2820
- if (/\*\*Priority\*\*:\s*[^\n]+/i.test(content)) {
2821
- content = content.replace(/\*\*Priority\*\*:\s*[^\n]+/i, `**Priority**: ${updatedMeta.priority}`);
2822
- } else if (/\*\*(?:Type|Category)\*\*:\s*[^\n]+/i.test(content)) {
2823
- content = content.replace(/(\*\*(?:Type|Category)\*\*:\s*[^\n]+)/i, `$1
2824
- **Priority**: ${updatedMeta.priority}`);
2825
- }
2826
- } else {
2827
- content = content.replace(/\*\*Priority\*\*:\s*[^\n]+\n?/i, "");
2828
- }
2829
- }
2830
- if ("milestone" in updates) {
2831
- if (updatedMeta.milestone) {
2832
- if (/\*\*Milestone\*\*:\s*[^\n]+/i.test(content)) {
2833
- content = content.replace(/\*\*Milestone\*\*:\s*[^\n]+/i, `**Milestone**: ${updatedMeta.milestone}`);
2834
- } else if (/\*\*(?:Priority|Type|Category)\*\*:\s*[^\n]+/i.test(content)) {
2835
- content = content.replace(/(\*\*(?:Priority|Type|Category)\*\*:\s*[^\n]+)/i, `$1
2836
- **Milestone**: ${updatedMeta.milestone}`);
2837
- }
2838
- } else {
2839
- content = content.replace(/\*\*Milestone\*\*:\s*[^\n]+\n?/i, "");
2840
- }
2841
- }
2842
- if (updates.complexity) {
2843
- content = content.replace(/\*\*Complexity\*\*:\s*[^\n]+/i, `**Complexity**: ${updatedMeta.complexity}`);
2844
- }
2845
- if (updates.estimatedEffort) {
2846
- content = content.replace(/\*\*Estimated Effort\*\*:\s*[^\n]+/i, `**Estimated Effort**: ${updatedMeta.estimatedEffort}`);
2847
- }
3105
+ } else if (existing.detail && ("priority" in updates || "milestone" in updates || updates.status || updates.title || updates.type || updates.category || updates.complexity || updates.estimatedEffort)) {
3106
+ const content = this.reconcileDetailMetadataHeaders(
3107
+ existing.detail.raw,
3108
+ updatedMeta,
3109
+ updates,
3110
+ false
3111
+ );
2848
3112
  writeSafeFile(path3.join(locInfo.ticketDir, "detail.md"), content);
2849
3113
  }
2850
3114
  this.invalidateTicketCache(locInfo.ticketDir);
@@ -3280,6 +3544,163 @@ ${input.summary || "Summary to be defined."}
3280
3544
  const updatedSource = await this.getTicket(sourceInfo.key);
3281
3545
  return { source: updatedSource, target: updatedTarget };
3282
3546
  }
3547
+ readValidLeaseFromDir(ticketDir, projectCode, ticketId) {
3548
+ const leasePath = path3.join(ticketDir, "lease.json");
3549
+ if (!fs3.existsSync(leasePath)) return null;
3550
+ try {
3551
+ const parsed = JSON.parse(fs3.readFileSync(leasePath, "utf-8"));
3552
+ const expMs = Date.parse(parsed.expiresAt);
3553
+ if (!isNaN(expMs) && expMs <= Date.now()) {
3554
+ try {
3555
+ fs3.unlinkSync(leasePath);
3556
+ } catch {
3557
+ }
3558
+ this.invalidateTicketCache(ticketDir);
3559
+ return null;
3560
+ }
3561
+ parsed.project = projectCode;
3562
+ parsed.ticketId = ticketId;
3563
+ parsed.ticketKey = `${projectCode}-${ticketId}`;
3564
+ return parsed;
3565
+ } catch {
3566
+ return null;
3567
+ }
3568
+ }
3569
+ async claimTicket(input) {
3570
+ const resolved = this.resolveTicketIdentifier(input.ticketId, input.project || this.config.projectCode);
3571
+ if (!resolved) {
3572
+ throw new Error(`Ticket #${input.ticketId} could not be located.`);
3573
+ }
3574
+ const identity = resolveWorkerIdentity({
3575
+ holder: input.holder,
3576
+ agentType: input.agentType,
3577
+ hostname: input.hostname,
3578
+ sessionId: input.sessionId,
3579
+ branch: input.branch,
3580
+ worktree: input.worktree || this.workspaceRoot,
3581
+ configDefaultAuthor: this.config.defaultAuthor
3582
+ });
3583
+ const ttlMinutes = typeof input.ttlMinutes === "number" && input.ttlMinutes > 0 ? Math.min(Math.max(1, Math.round(input.ttlMinutes)), 24 * 60) : DEFAULT_LEASE_TTL_MINUTES;
3584
+ const existingLease = this.readValidLeaseFromDir(resolved.ticketDir, resolved.project, resolved.id);
3585
+ let renewed = false;
3586
+ let overridden = false;
3587
+ if (existingLease) {
3588
+ const isSameHolder = existingLease.holder.toLowerCase() === identity.holder.toLowerCase();
3589
+ if (!isSameHolder && !input.force) {
3590
+ throw new EsedreLeaseConflictError(resolved.key, existingLease);
3591
+ }
3592
+ if (isSameHolder) {
3593
+ renewed = true;
3594
+ } else {
3595
+ overridden = true;
3596
+ }
3597
+ }
3598
+ const now = /* @__PURE__ */ new Date();
3599
+ const nowIso = now.toISOString();
3600
+ const expiresIso = new Date(now.getTime() + ttlMinutes * 60 * 1e3).toISOString();
3601
+ const lease = {
3602
+ ticketId: resolved.id,
3603
+ ticketKey: resolved.key,
3604
+ project: resolved.project,
3605
+ holder: normalizeDashesAndMojibake(identity.holder),
3606
+ ...identity.agentType ? { agentType: normalizeDashesAndMojibake(identity.agentType) } : {},
3607
+ ...identity.hostname ? { hostname: normalizeDashesAndMojibake(identity.hostname) } : {},
3608
+ ...identity.sessionId ? { sessionId: normalizeDashesAndMojibake(identity.sessionId) } : {},
3609
+ ...identity.branch ? { branch: normalizeDashesAndMojibake(identity.branch) } : {},
3610
+ ...identity.worktree ? { worktree: identity.worktree } : {},
3611
+ claimedAt: renewed && existingLease ? existingLease.claimedAt : nowIso,
3612
+ renewedAt: nowIso,
3613
+ expiresAt: expiresIso,
3614
+ ttlMinutes
3615
+ };
3616
+ const leasePath = path3.join(resolved.ticketDir, "lease.json");
3617
+ writeSafeFile(leasePath, JSON.stringify(lease, null, 2) + "\n");
3618
+ this.invalidateTicketCache(resolved.ticketDir);
3619
+ let ticket = await this.getTicket(resolved.key);
3620
+ const targetStatus = input.statusOnClaim ?? (ticket.meta.status === "Planned" ? "In Development" : void 0);
3621
+ if (targetStatus && ticket.meta.status !== targetStatus) {
3622
+ ticket = await this.updateTicket(resolved.key, {
3623
+ status: targetStatus,
3624
+ isActivePlanning: targetStatus === "In Development" ? true : ticket.meta.isActivePlanning
3625
+ });
3626
+ }
3627
+ return {
3628
+ lease,
3629
+ ticket,
3630
+ renewed,
3631
+ ...overridden ? { overridden: true } : {}
3632
+ };
3633
+ }
3634
+ async renewLease(input) {
3635
+ const resolved = this.resolveTicketIdentifier(input.ticketId, input.project || this.config.projectCode);
3636
+ if (!resolved) {
3637
+ throw new Error(`Ticket #${input.ticketId} could not be located.`);
3638
+ }
3639
+ const existingLease = this.readValidLeaseFromDir(resolved.ticketDir, resolved.project, resolved.id);
3640
+ if (!existingLease) {
3641
+ throw new Error(`Ticket ${resolved.key} does not have an active lease to renew.`);
3642
+ }
3643
+ if (input.holder && !input.force) {
3644
+ const requestedHolder = input.holder.trim().toLowerCase();
3645
+ if (existingLease.holder.toLowerCase() !== requestedHolder) {
3646
+ throw new EsedreLeaseConflictError(resolved.key, existingLease);
3647
+ }
3648
+ }
3649
+ const ttlMinutes = typeof input.ttlMinutes === "number" && input.ttlMinutes > 0 ? Math.min(Math.max(1, Math.round(input.ttlMinutes)), 24 * 60) : existingLease.ttlMinutes || DEFAULT_LEASE_TTL_MINUTES;
3650
+ const now = /* @__PURE__ */ new Date();
3651
+ const updatedLease = {
3652
+ ...existingLease,
3653
+ ...input.holder ? { holder: normalizeDashesAndMojibake(input.holder.trim()) } : {},
3654
+ renewedAt: now.toISOString(),
3655
+ expiresAt: new Date(now.getTime() + ttlMinutes * 60 * 1e3).toISOString(),
3656
+ ttlMinutes
3657
+ };
3658
+ const leasePath = path3.join(resolved.ticketDir, "lease.json");
3659
+ writeSafeFile(leasePath, JSON.stringify(updatedLease, null, 2) + "\n");
3660
+ this.invalidateTicketCache(resolved.ticketDir);
3661
+ return updatedLease;
3662
+ }
3663
+ async releaseTicket(input) {
3664
+ const resolved = this.resolveTicketIdentifier(input.ticketId, input.project || this.config.projectCode);
3665
+ if (!resolved) {
3666
+ throw new Error(`Ticket #${input.ticketId} could not be located.`);
3667
+ }
3668
+ const existingLease = this.readValidLeaseFromDir(resolved.ticketDir, resolved.project, resolved.id);
3669
+ if (!existingLease) {
3670
+ return { released: false, ticketKey: resolved.key };
3671
+ }
3672
+ if (input.holder && !input.force) {
3673
+ const requestedHolder = input.holder.trim().toLowerCase();
3674
+ if (existingLease.holder.toLowerCase() !== requestedHolder) {
3675
+ throw new EsedreLeaseConflictError(resolved.key, existingLease);
3676
+ }
3677
+ }
3678
+ const leasePath = path3.join(resolved.ticketDir, "lease.json");
3679
+ if (fs3.existsSync(leasePath)) {
3680
+ try {
3681
+ fs3.unlinkSync(leasePath);
3682
+ } catch {
3683
+ }
3684
+ }
3685
+ this.invalidateTicketCache(resolved.ticketDir);
3686
+ return {
3687
+ released: true,
3688
+ ticketKey: resolved.key,
3689
+ previousLease: existingLease
3690
+ };
3691
+ }
3692
+ async getTicketLease(id, projectCode) {
3693
+ const resolved = this.resolveTicketIdentifier(id, projectCode || this.config.projectCode);
3694
+ if (!resolved) return null;
3695
+ return this.readValidLeaseFromDir(resolved.ticketDir, resolved.project, resolved.id);
3696
+ }
3697
+ async listLeases(projectCode) {
3698
+ const tickets = await this.listTickets({
3699
+ project: projectCode === "all" ? void 0 : projectCode,
3700
+ leased: true
3701
+ });
3702
+ return tickets.map((t) => t.lease).filter((l) => Boolean(l));
3703
+ }
3283
3704
  };
3284
3705
 
3285
3706
  // src/securityFilter.ts
@@ -3576,6 +3997,59 @@ var SecurityFilter = class {
3576
3997
  }
3577
3998
  return this.target.getAttachmentPath ? this.target.getAttachmentPath(id, filename) : null;
3578
3999
  }
4000
+ async claimTicket(input) {
4001
+ if (input.project && !isProjectAuthorized(input.project, this.config.allowedProjects)) {
4002
+ throw new EsedreAuthorizationError(input.project);
4003
+ }
4004
+ const ticket = await this.getTicket(
4005
+ input.project && /^\d+$/.test(String(input.ticketId).trim()) ? `${input.project}-${input.ticketId}` : input.ticketId
4006
+ );
4007
+ if (!ticket) {
4008
+ throw new Error(`Ticket #${input.ticketId} not found`);
4009
+ }
4010
+ return this.target.claimTicket(input);
4011
+ }
4012
+ async renewLease(input) {
4013
+ if (input.project && !isProjectAuthorized(input.project, this.config.allowedProjects)) {
4014
+ throw new EsedreAuthorizationError(input.project);
4015
+ }
4016
+ const ticket = await this.getTicket(
4017
+ input.project && /^\d+$/.test(String(input.ticketId).trim()) ? `${input.project}-${input.ticketId}` : input.ticketId
4018
+ );
4019
+ if (!ticket) {
4020
+ throw new Error(`Ticket #${input.ticketId} not found`);
4021
+ }
4022
+ return this.target.renewLease(input);
4023
+ }
4024
+ async releaseTicket(input) {
4025
+ if (input.project && !isProjectAuthorized(input.project, this.config.allowedProjects)) {
4026
+ throw new EsedreAuthorizationError(input.project);
4027
+ }
4028
+ const ticket = await this.getTicket(
4029
+ input.project && /^\d+$/.test(String(input.ticketId).trim()) ? `${input.project}-${input.ticketId}` : input.ticketId
4030
+ );
4031
+ if (!ticket) {
4032
+ throw new Error(`Ticket #${input.ticketId} not found`);
4033
+ }
4034
+ return this.target.releaseTicket(input);
4035
+ }
4036
+ async getTicketLease(id, projectCode) {
4037
+ if (projectCode && !isProjectAuthorized(projectCode, this.config.allowedProjects)) {
4038
+ throw new EsedreAuthorizationError(projectCode);
4039
+ }
4040
+ const ticket = await this.getTicket(
4041
+ projectCode && /^\d+$/.test(String(id).trim()) ? `${projectCode}-${id}` : id
4042
+ );
4043
+ if (!ticket) return null;
4044
+ return this.target.getTicketLease(id, projectCode);
4045
+ }
4046
+ async listLeases(projectCode) {
4047
+ if (projectCode && projectCode !== "all" && !isProjectAuthorized(projectCode, this.config.allowedProjects)) {
4048
+ throw new EsedreAuthorizationError(projectCode);
4049
+ }
4050
+ const allLeases = await this.target.listLeases(projectCode);
4051
+ return allLeases.filter((l) => isProjectAuthorized(l.project, this.config.allowedProjects));
4052
+ }
3579
4053
  };
3580
4054
 
3581
4055
  // src/mcp/server.ts
@@ -3691,6 +4165,20 @@ var EsedreMcpServer = class {
3691
4165
  }
3692
4166
  };
3693
4167
  }
4168
+ if (err instanceof EsedreLeaseConflictError || err.name === "EsedreLeaseConflictError") {
4169
+ return {
4170
+ jsonrpc: "2.0",
4171
+ id,
4172
+ error: {
4173
+ code: -32001,
4174
+ message: err.message,
4175
+ data: {
4176
+ ticketKey: err.ticketKey,
4177
+ activeLease: err.activeLease
4178
+ }
4179
+ }
4180
+ };
4181
+ }
3694
4182
  return {
3695
4183
  jsonrpc: "2.0",
3696
4184
  id,
@@ -3987,6 +4475,71 @@ Status: ${ticket.meta.status}`;
3987
4475
  },
3988
4476
  required: ["sourceTicketId", "targetTicketId"]
3989
4477
  }
4478
+ },
4479
+ {
4480
+ name: "esedre_claim_ticket",
4481
+ description: "Claim an advisory TTL-backed lease on a ticket to coordinate multi-agent and multi-machine work and prevent duplicate effort.",
4482
+ inputSchema: {
4483
+ type: "object",
4484
+ properties: {
4485
+ ticketId: { type: ["number", "string"], description: 'Ticket ID or prefixed key (e.g. 69 or "Esedre-69")' },
4486
+ holder: { type: "string", description: 'Agent or developer identity claiming the ticket (e.g. "agy-subagent-1" or "desktop-worker")' },
4487
+ workerId: { type: "string", description: "Alias for holder: agent or worker identity claiming the ticket" },
4488
+ agentType: { type: "string", description: 'Optional agent runtime identifier (e.g. "antigravity", "claude-code", "codex")' },
4489
+ system: { type: "string", description: "Alias for agentType: system or runtime identifier" },
4490
+ sessionId: { type: "string", description: "Optional conversation or session identifier" },
4491
+ branch: { type: "string", description: "Optional Git branch associated with the lease" },
4492
+ ttlMinutes: { type: "number", description: "Lease time-to-live in minutes (default: 60)" },
4493
+ force: { type: "boolean", description: "Override an existing active lease held by another worker" },
4494
+ project: { type: "string", description: "Optional project code when numeric ticketId is used" }
4495
+ },
4496
+ required: ["ticketId"]
4497
+ }
4498
+ },
4499
+ {
4500
+ name: "esedre_renew_lease",
4501
+ description: "Renew (heartbeat) an active ticket lease to extend its expiration timestamp.",
4502
+ inputSchema: {
4503
+ type: "object",
4504
+ properties: {
4505
+ ticketId: { type: ["number", "string"], description: "Ticket ID or prefixed key" },
4506
+ holder: { type: "string", description: "Holder identity verifying lease ownership" },
4507
+ workerId: { type: "string", description: "Alias for holder: worker identity verifying lease ownership" },
4508
+ system: { type: "string", description: "Optional system or agent runtime identifier" },
4509
+ ttlMinutes: { type: "number", description: "New TTL in minutes from now (default: 60)" },
4510
+ force: { type: "boolean", description: "Force renewal even if held by another identity" },
4511
+ project: { type: "string", description: "Optional project code" }
4512
+ },
4513
+ required: ["ticketId"]
4514
+ }
4515
+ },
4516
+ {
4517
+ name: "esedre_release_ticket",
4518
+ description: "Release an active lease on a ticket when work is paused, handed off, or completed.",
4519
+ inputSchema: {
4520
+ type: "object",
4521
+ properties: {
4522
+ ticketId: { type: ["number", "string"], description: "Ticket ID or prefixed key" },
4523
+ holder: { type: "string", description: "Holder identity verifying lease ownership" },
4524
+ workerId: { type: "string", description: "Alias for holder: worker identity verifying lease ownership" },
4525
+ system: { type: "string", description: "Optional system or agent runtime identifier" },
4526
+ finalStatus: { type: "string", enum: ["Planned", "In Development", "Completed", "Rejected"], description: "Optional status to transition the ticket to upon release" },
4527
+ notes: { type: "string", description: "Optional verification or handoff comment to append upon release" },
4528
+ force: { type: "boolean", description: "Force release even if held by another identity" },
4529
+ project: { type: "string", description: "Optional project code" }
4530
+ },
4531
+ required: ["ticketId"]
4532
+ }
4533
+ },
4534
+ {
4535
+ name: "esedre_list_leases",
4536
+ description: "List all currently active (non-expired) ticket leases across authorized projects.",
4537
+ inputSchema: {
4538
+ type: "object",
4539
+ properties: {
4540
+ project: { type: "string", description: 'Optional project code filter (or "all")' }
4541
+ }
4542
+ }
3990
4543
  }
3991
4544
  ];
3992
4545
  }
@@ -4179,12 +4732,103 @@ Status: ${ticket.meta.status}`;
4179
4732
  target: result.target
4180
4733
  };
4181
4734
  }
4735
+ case "claim_ticket": {
4736
+ const identity = resolveWorkerIdentity({
4737
+ holder: args.holder || args.workerId,
4738
+ agentType: args.agentType || args.system || "mcp-agent",
4739
+ sessionId: args.sessionId,
4740
+ branch: args.branch
4741
+ });
4742
+ const res = await this.storage.claimTicket({
4743
+ ticketId: this.parseTicketId(args.ticketId),
4744
+ holder: identity.holder,
4745
+ agentType: identity.agentType,
4746
+ hostname: identity.hostname,
4747
+ sessionId: identity.sessionId,
4748
+ branch: identity.branch,
4749
+ worktree: identity.worktree,
4750
+ ttlMinutes: args.ttlMinutes,
4751
+ force: Boolean(args.force),
4752
+ project: args.project
4753
+ });
4754
+ return {
4755
+ success: true,
4756
+ renewed: res.renewed,
4757
+ overridden: res.overridden || false,
4758
+ lease: res.lease,
4759
+ ticket: {
4760
+ id: res.ticket.meta.id,
4761
+ project: res.ticket.meta.project,
4762
+ title: res.ticket.meta.title,
4763
+ status: res.ticket.meta.status,
4764
+ sha1: res.ticket.sha1
4765
+ }
4766
+ };
4767
+ }
4768
+ case "renew_lease": {
4769
+ const lease = await this.storage.renewLease({
4770
+ ticketId: this.parseTicketId(args.ticketId),
4771
+ holder: args.holder || args.workerId,
4772
+ ttlMinutes: args.ttlMinutes,
4773
+ force: Boolean(args.force),
4774
+ project: args.project
4775
+ });
4776
+ return {
4777
+ success: true,
4778
+ lease
4779
+ };
4780
+ }
4781
+ case "release_ticket": {
4782
+ const holder = args.holder || args.workerId;
4783
+ const parsedId = this.parseTicketId(args.ticketId);
4784
+ const res = await this.storage.releaseTicket({
4785
+ ticketId: parsedId,
4786
+ holder,
4787
+ force: Boolean(args.force),
4788
+ project: args.project
4789
+ });
4790
+ if (args.finalStatus) {
4791
+ await this.storage.updateTicket(res.ticketKey || parsedId, { status: args.finalStatus });
4792
+ }
4793
+ if (args.notes) {
4794
+ await this.storage.addComment(res.ticketKey || parsedId, {
4795
+ author: holder || res.previousLease?.holder || "mcp-agent",
4796
+ text: args.notes
4797
+ });
4798
+ }
4799
+ return {
4800
+ success: true,
4801
+ ...res
4802
+ };
4803
+ }
4804
+ case "list_leases": {
4805
+ const leases = await this.storage.listLeases(args.project);
4806
+ return {
4807
+ total: leases.length,
4808
+ leases
4809
+ };
4810
+ }
4182
4811
  default:
4183
4812
  throw new Error(`Unknown tool: ${name}`);
4184
4813
  }
4185
4814
  }
4186
4815
  };
4187
4816
 
4817
+ // src/utils/logger.ts
4818
+ var MAX_LOG_FILE_BYTES = 5 * 1024 * 1024;
4819
+ var debugEnabled = Boolean(process.env.ESEDRE_DEBUG);
4820
+ var logFileOverride;
4821
+ function initLogger(options) {
4822
+ if (options?.debug !== void 0) {
4823
+ debugEnabled = options.debug;
4824
+ } else if (process.env.ESEDRE_DEBUG) {
4825
+ debugEnabled = true;
4826
+ }
4827
+ if (options?.logFile) {
4828
+ logFileOverride = options.logFile;
4829
+ }
4830
+ }
4831
+
4188
4832
  // src/utils/manifestDetector.ts
4189
4833
  import fs5 from "node:fs";
4190
4834
  import path5 from "node:path";
@@ -4373,12 +5017,13 @@ function detectProjectManifest(targetDir) {
4373
5017
  // src/upgrade.ts
4374
5018
  import crypto2 from "node:crypto";
4375
5019
  import fs7 from "node:fs";
5020
+ import os5 from "node:os";
4376
5021
  import path7 from "node:path";
4377
5022
 
4378
5023
  // src/server/daemon.ts
4379
5024
  import fs6 from "node:fs";
4380
5025
  import path6 from "node:path";
4381
- import os3 from "node:os";
5026
+ import os4 from "node:os";
4382
5027
  import http from "node:http";
4383
5028
  import { spawn, spawnSync } from "node:child_process";
4384
5029
  import { fileURLToPath } from "node:url";
@@ -4388,7 +5033,7 @@ function getGlobalEsedreDir() {
4388
5033
  if (process.env.ESEDRE_GLOBAL_DIR) {
4389
5034
  return process.env.ESEDRE_GLOBAL_DIR;
4390
5035
  }
4391
- return path6.join(os3.homedir(), ".esedre");
5036
+ return path6.join(os4.homedir(), ".esedre");
4392
5037
  }
4393
5038
  function ensureGlobalEsedreStore() {
4394
5039
  const globalDir = getGlobalEsedreDir();
@@ -4970,7 +5615,11 @@ var HISTORIC_SKILL_HASHES = [
4970
5615
  "8611116c276fdfb598b965c716b251ce7c77c7f7",
4971
5616
  "05f129fb6e06a18df00215af105567abcbf0bc67",
4972
5617
  "5d330ecf8fc96f383946e0351adb60378afa70f0",
4973
- "23716c9db561e52e5115dc364fe154d3a7bc1e23"
5618
+ "23716c9db561e52e5115dc364fe154d3a7bc1e23",
5619
+ "75d50d5f9cf9348cca9d9b2466e04cf7992ef17e",
5620
+ "d0bd99ea93d9c65245b3be51188d4bc153024092",
5621
+ "e5d4fe4f2ae0bd464bad877ea1e5ab63cef747fa",
5622
+ "d25597e8c2c26cdc5873e5dbf02335032db07580"
4974
5623
  ];
4975
5624
  var ESEDRE_SKILL_TEMPLATE = `---
4976
5625
  name: esedre
@@ -4979,7 +5628,7 @@ description: Tooling and workflow reference for interacting with the Esedre deve
4979
5628
 
4980
5629
  # Esedre Planning & Roadmap Workflow
4981
5630
 
4982
- Esedre is the developer ticketing and LLM coding partner coordination platform. The engine codebase resides in the standalone repository \`aellinsar/esedre\` (\`../esedre\`), and operates on decoupled ticket repositories (such as \`aellinsar/esedre-data\` configured in \`.esedre/esedre.json\`). Interact with tickets via in-repo shell wrappers in \`.esedre/\` (\`.esedre/ese\`), or the global \`ese\` CLI.
5631
+ Esedre is the developer ticketing and LLM coding partner coordination platform. The engine codebase resides in the standalone repository \`aellinsar/esedre\` (\`../esedre\`), and operates on decoupled ticket repositories (such as \`aellinsar/esedre-data\` configured in \`~/.esedre/config.json\` or \`.esedre/esedre.json\`). Interact with tickets via in-repo shell wrappers in \`.esedre/\` (\`.esedre/ese\` on POSIX/macOS/Linux/Git Bash, or \`.\\.esedre\\ese.cmd\` on Windows PowerShell/CMD), or the global \`ese\` CLI.
4983
5632
 
4984
5633
  ## 0. Authoritative Ingress Hierarchy & Zero-Latency Context (\`.esedre/snapshot.json\`)
4985
5634
 
@@ -4988,38 +5637,49 @@ All ticket discovery, inspection, and state management strictly follow this posi
4988
5637
  1. **Step 1 (Default for Read-Only Inspection) - Local Snapshot Projection**:
4989
5638
  Always read \`.esedre/snapshot.json\` in the workspace root first. It provides an immediate, zero-latency local projection containing all active and completed tickets, summaries, implementation plans, revision numbers, completion dates (\`completedAt\`), and staleness metrics (\`daysSinceUpdate\`).
4990
5639
  2. **Step 2 (Dynamic Queries & State Mutations) - Esedre MCP Tools**:
4991
- Use first-class MCP tools when active in the session (\`esedre_list_tickets\`, \`esedre_get_ticket\`, \`esedre_get_plan\`, \`esedre_save_plan\`, \`esedre_update_ticket\`, \`esedre_add_comment\`).
5640
+ Use first-class MCP tools when active in the session (\`esedre_list_tickets\`, \`esedre_get_ticket\`, \`esedre_get_plan\`, \`esedre_save_plan\`, \`esedre_update_ticket\`, \`esedre_claim_ticket\`, \`esedre_renew_lease\`, \`esedre_release_ticket\`, \`esedre_list_leases\`, \`esedre_add_comment\`).
4992
5641
  3. **Step 3 (Terminal & Script Fallback) - In-Repo CLI Wrapper**:
4993
- Use \`.esedre/ese\` (\`.esedre/ese get\`, \`.esedre/ese plan\`, \`.esedre/ese update\`, \`.esedre/ese snapshot\`, \`.esedre/ese refresh\`).
5642
+ - **POSIX (macOS, Linux, Git Bash, WSL)**: \`.esedre/ese <command>\` (e.g. \`.esedre/ese get\`, \`.esedre/ese plan\`, \`.esedre/ese update\`, \`.esedre/ese lease\`, \`.esedre/ese snapshot\`).
5643
+ - **Windows (PowerShell / CMD)**: \`.\\.esedre\\ese.cmd <command>\` or \`.\\.esedre\\ese.ps1 <command>\` (do not invoke the extensionless POSIX script \`.esedre/ese\` directly in PowerShell or \`cmd.exe\`, as Windows will fail or prompt to select an application to open the file).
4994
5644
  4. **Storage Boundary**:
4995
5645
  Backing ticket hubs (such as \`esedre-data\`) represent data storage managed by the engine. All agent interactions with roadmap tickets flow through the snapshot projection, MCP tools, or CLI wrappers.
4996
5646
 
4997
5647
  ## 1. Model Context Protocol (MCP) Tools
4998
5648
 
4999
- When Esedre MCP is active in your agent session (\`.agents/mcp_config.json\`), use these tools directly:
5649
+ When Esedre MCP is active in your agent session (\`.agents/mcp.json\` or \`.agents/mcp_config.json\`), use these tools directly:
5000
5650
 
5001
5651
  | Tool | Purpose | Key Arguments |
5002
5652
  |---|---|---|
5003
5653
  | \`esedre_list_tickets\` | List roadmap tickets with filters | \`project\` (e.g. \`Profe\`, \`Esedre\`, \`Alce\`), \`status\`, \`type\`, \`priority\`, \`milestone\`, \`isBlocked\`, \`linkedTo\`, \`search\`, \`includePlan\` |
5004
- | \`esedre_get_ticket\` | Full specification, summary, comments, revision & sha1 | \`ticketId\` (numeric e.g. \`96\` or compound e.g. \`Profe-96\`) |
5654
+ | \`esedre_get_ticket\` | Full specification, summary, comments, links, lease, revision & sha1 | \`ticketId\` (numeric e.g. \`96\` or compound e.g. \`Profe-96\`) |
5005
5655
  | \`esedre_get_plan\` | Active implementation plan markdown | \`ticketId\` (numeric or compound) |
5006
5656
  | \`esedre_save_plan\` | Save implementation plan markdown with OCC | \`ticketId\`, \`planMarkdown\`, \`lastHash\` |
5007
- | \`esedre_create_ticket\` | Mint a new ticket with auto sequential ID | \`title\` (max 48 chars), \`type\`, \`project\`, \`effort\`, \`summary\`, \`detail\` |
5008
- | \`esedre_update_ticket\` | Update ticket attributes with OCC | \`ticketId\`, \`status\`, \`type\`, \`title\`, \`complexity\`, \`effort\`, \`inDevelopment\`, \`featureFlag\`, \`lastHash\` |
5657
+ | \`esedre_create_ticket\` | Mint a new ticket with auto sequential ID | \`title\` (max 48 chars), \`type\`, \`project\`, \`priority\`, \`milestone\`, \`effort\`, \`summary\`, \`detail\` |
5658
+ | \`esedre_update_ticket\` | Update ticket attributes with OCC | \`ticketId\`, \`status\`, \`type\`, \`priority\`, \`milestone\`, \`title\`, \`detail\`, \`detailMarkdown\`, \`complexity\`, \`effort\`, \`inDevelopment\`, \`featureFlag\`, \`lastHash\` |
5659
+ | \`esedre_claim_ticket\` | Claim an ephemeral TTL lease on a ticket (\`In Development\`) | \`ticketId\`, \`holder\`/\`workerId\`, \`agentType\`/\`system\`, \`ttlMinutes\`, \`branch\`, \`force\` |
5660
+ | \`esedre_renew_lease\` | Extend an active ticket lease TTL | \`ticketId\`, \`holder\`/\`workerId\`, \`system\`, \`ttlMinutes\` |
5661
+ | \`esedre_release_ticket\` | Release a ticket lease and optionally transition status | \`ticketId\`, \`holder\`/\`workerId\`, \`system\`, \`finalStatus\`, \`notes\`, \`force\` |
5662
+ | \`esedre_list_leases\` | List all active unexpired ticket leases | \`project\` |
5663
+ | \`esedre_link_ticket\` | Establish bi-directional ticket relationship | \`sourceTicketId\`, \`relation\` (\`relates-to\` \\| \`blocks\` \\| \`parent-of\` \\| \`duplicates\`), \`targetTicketId\`, \`author\` |
5664
+ | \`esedre_unlink_ticket\` | Remove bi-directional ticket relationship | \`sourceTicketId\`, \`targetTicketId\`, \`relation\` |
5665
+ | \`esedre_list_milestones\` | List project milestones and deliverables progress | \`project\` |
5666
+ | \`esedre_get_milestone\` | Get milestone specs, umbrella flag, and member tickets | \`milestoneId\` (numeric or title), \`project\` |
5667
+ | \`esedre_create_milestone\` | Create a project milestone with optional umbrella flag | \`title\`, \`project\`, \`featureFlag\`, \`status\`, \`targetDate\`, \`description\` |
5668
+ | \`esedre_update_milestone\` | Update milestone status, umbrella flag, or title | \`milestoneId\`, \`project\`, \`title\`, \`featureFlag\`, \`status\`, \`targetDate\`, \`description\` |
5009
5669
  | \`esedre_add_comment\` | Append developer or agent comment | \`ticketId\`, \`text\`, \`file\`, \`author\` |
5010
5670
 
5011
5671
  ## 2. Core CLI Commands (Fallback via \`run_command\`)
5012
5672
 
5013
- Both \`esedre\` and \`ese\` work interchangeably:
5673
+ Both \`esedre\` and \`ese\` work interchangeably. On POSIX shells use \`.esedre/ese <command>\`; on Windows PowerShell or CMD use \`.\\.esedre\\ese.cmd <command>\` (or \`.\\.esedre\\ese.ps1 <command>\`):
5014
5674
 
5015
5675
  ### List Tickets
5016
5676
  \`\`\`bash
5017
- .esedre/ese list [-p|--project <code|all>] [-s|--status <status>] [-t|--type <type>] [-q|--search <q>] [--json]
5677
+ .esedre/ese list [-p|--project <code|all>] [-s|--status <status>] [-t|--type <type>] [-P|--priority <priority>] [-m|--milestone <name>] [--blocked] [--linked-to <ticketKey>] [-q|--search <q>] [--json]
5018
5678
  \`\`\`
5019
5679
 
5020
5680
  ### Read Ticket Details
5021
5681
  \`\`\`bash
5022
- .esedre/ese get <ticketId> [--json]
5682
+ .esedre/ese get <ticketId...> [--json]
5023
5683
  \`\`\`
5024
5684
 
5025
5685
  ### Read or Update Implementation Plans
@@ -5031,14 +5691,35 @@ Both \`esedre\` and \`ese\` work interchangeably:
5031
5691
 
5032
5692
  ### Create a Ticket
5033
5693
  \`\`\`bash
5034
- .esedre/ese create --title "..." [-p|--project <code>] [-t|--type Feature] [--complexity Medium] [--effort "2.0 - 4.0 hours"] [--detail "<md>"] [--file <path>] [--json]
5694
+ .esedre/ese create --title "..." [-p|--project <code>] [-t|--type Feature] [-P|--priority High] [-m|--milestone <name>] [--complexity Medium] [--effort "2.0 - 4.0 hours"] [--detail "<md>"] [--file <path>] [--json]
5035
5695
  \`\`\`
5036
5696
 
5037
5697
  ### Update Ticket Status & Attributes
5038
5698
  \`\`\`bash
5039
5699
  .esedre/ese update <ticketId> --status "In Development"
5040
5700
  .esedre/ese update <ticketId> --status "Completed"
5041
- .esedre/ese update <ticketId> --type Feature --complexity Medium --effort "2.0 - 4.0 hours"
5701
+ .esedre/ese update <ticketId> --type Feature -P High --complexity Medium --effort "2.0 - 4.0 hours"
5702
+ .esedre/ese update <ticketId> -P none # Clears priority
5703
+ .esedre/ese update <ticketId> -m "Release 1.0" # Links ticket to milestone (inherits umbrella feature flag)
5704
+ .esedre/ese update <ticketId> -m none # Unlinks ticket from milestone
5705
+ .esedre/ese update <ticketId> --detail "<markdownContent>" # Updates specification markdown
5706
+ .esedre/ese update <ticketId> --file <detailFilePath> # Overwrites specification from file
5707
+ \`\`\`
5708
+
5709
+ ### Ticket Specification (\`detail.md\`) Sections
5710
+ When authoring or updating a ticket's \`detail.md\` (via \`--detail\`, \`--file\`, or \`esedre_update_ticket\`), the snapshot compiler and UI parser recognize the following canonical \`###\` headings:
5711
+ - \`### Summary\`: Concise overview of the problem or feature.
5712
+ - \`### Feature Breakdown\` (or aliases \`### Task Breakdown\`, \`### Fix Breakdown\`, \`### Implementation Breakdown\`): Numbered or bulleted deliverables and fix steps (indented sub-bullets are preserved under their parent item). Use \`### Fix Breakdown\` or \`### Task Breakdown\` naturally for \`Bug\` and \`Tools\` tickets.
5713
+ - \`### Technical Details\` (or \`### Technical Details & Architecture\`): Bold-keyed architectural notes (e.g. \`- **Architecture**: ...\`, \`- **Key Modules**: ...\`, \`- **Root Cause**: ...\`, \`- **Technical Detail**: ...\`).
5714
+ - \`### Open Questions\` (or \`### Open Questions & Decisions\`): Unresolved design questions (\`- **QuestionId**: Question text?\`), which populate \`openQuestions[]\` in \`.esedre/snapshot.json\`.
5715
+
5716
+ ### Ticket Leases (\`ese lease\`)
5717
+ \`\`\`bash
5718
+ .esedre/ese lease claim <ticketId> [--worker-id <id>] [--system <name>] [--ttl <minutes>] [--branch <branch>] [--force] [--json]
5719
+ .esedre/ese lease renew <ticketId> [--worker-id <id>] [--system <name>] [--ttl <minutes>] [--json]
5720
+ .esedre/ese lease release <ticketId> [--worker-id <id>] [--system <name>] [--status <Completed|Planned>] [--notes "..."] [--force] [--json]
5721
+ .esedre/ese lease list [-p|--project <code|all>] [--json]
5722
+ # Top-level aliases: ese claim, ese renew, ese release, ese leases
5042
5723
  \`\`\`
5043
5724
 
5044
5725
  ### Append Comments or Notes
@@ -5048,6 +5729,23 @@ Both \`esedre\` and \`ese\` work interchangeably:
5048
5729
  .esedre/ese comment <ticketId> --text "Verified implementation." --author "Agent"
5049
5730
  \`\`\`
5050
5731
 
5732
+ ### Link or Unlink Tickets
5733
+ \`\`\`bash
5734
+ .esedre/ese link <sourceId> <relation> <targetId> [--author "..."]
5735
+ .esedre/ese unlink <sourceId> <targetId> [--relation <relation>]
5736
+ # Supported relations: relates-to, blocks, parent-of, duplicates
5737
+ # Inverse reciprocal links are automatically synchronized.
5738
+ \`\`\`
5739
+
5740
+ ### Manage Milestones & Umbrella Feature Flags
5741
+ \`\`\`bash
5742
+ .esedre/ese milestone list [-p|--project <code>] [--json]
5743
+ .esedre/ese milestone get <id|title> [-p|--project <code>] [--json]
5744
+ .esedre/ese milestone create --title "..." [-p|--project <code>] [--flag <flag>] [--status <status>] [--target-date <YYYY-MM-DD>] [--description "..."] [--json]
5745
+ .esedre/ese milestone update <id|title> [-p|--project <code>] [--title "..."] [--flag <flag|none>] [--status <status>] [--target-date <YYYY-MM-DD>] [--description "..."] [--json]
5746
+ .esedre/ese milestone delete <id|title> [-p|--project <code>] [--json]
5747
+ \`\`\`
5748
+
5051
5749
  ### Regenerate Snapshot
5052
5750
  \`\`\`bash
5053
5751
  .esedre/ese snapshot
@@ -5090,7 +5788,10 @@ If the host application declares any or all of the 12 core design tokens on \`:r
5090
5788
  ## 4. Safety & Invariants
5091
5789
  - **Always use \`--json\`** for CLI programmatic inspection.
5092
5790
  - **Optimistic Concurrency**: Writes support \`--last-hash <hash>\` to prevent overwriting concurrent updates.
5791
+ - **Ticket Leases (\`ese lease\`)**: Claim a lease (\`ese lease claim <id>\` or \`esedre_claim_ticket\`) when starting active work so concurrent agent sessions or subagents do not collide on the same ticket, and release it (\`ese lease release <id>\`) upon completion.
5093
5792
  - **Universal Type Naming**: Always use \`type\` (\`Feature\`, \`Platform\`, \`Tools\`, \`Idea\`, \`Bug\`). The legacy name \`category\` is deprecated.
5793
+ - **Ticket Priority**: The \`priority\` field is optional (\`Critical\`, \`High\`, \`Medium\`, \`Low\`, or unset/null). Backwards compatible with legacy tickets lacking a priority field. To clear via CLI use \`-P none\` or \`--priority none\` (in code/MCP, pass \`null\` or \`'none'\`).
5794
+ - **Milestones & Umbrella Feature Flags**: Milestones group tickets into deliverables with completion metrics. Milestones can declare an optional umbrella feature flag which member tickets inherit automatically. Unlinking a ticket (\`-m none\`) removes inherited flags.
5094
5795
  - **Compound IDs for Multi-Project Portfolios**: In \`project=all\`, ticket IDs are \`\${projectCode}-\${id}\` (e.g. \`Profe-1\`, \`Esedre-1\`). DOM anchors strictly use \`feature-card-\${projectCode}-\${id}\`.
5095
5796
  - **Never delete tickets** directly via filesystem.
5096
5797
  - **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.
@@ -5099,6 +5800,35 @@ If the host application declares any or all of the 12 core design tokens on \`:r
5099
5800
  - **SEO Invariant**: These developer routes are private and MUST NEVER be exposed in \`sitemap.xml\`, \`robots.txt\`, \`llms.txt\`, or public navigation links.
5100
5801
  - The URL hash is preserved across hard page refreshes (F5) without falling back to \`#/chat\` or other views.
5101
5802
  `;
5803
+ var AGENT_RULE_MAX_BYTES = 24e3;
5804
+ function checkAgentRuleSizes(targetDir) {
5805
+ const candidates = [
5806
+ "AGENTS.md",
5807
+ "GEMINI.md",
5808
+ ".agents/AGENTS.md",
5809
+ ".agents/GEMINI.md",
5810
+ ".gemini/GEMINI.md"
5811
+ ];
5812
+ const warnings = [];
5813
+ for (const rel of candidates) {
5814
+ const fullPath = path7.join(targetDir, rel);
5815
+ if (fs7.existsSync(fullPath)) {
5816
+ try {
5817
+ const stat = fs7.statSync(fullPath);
5818
+ if (stat.isFile() && stat.size > AGENT_RULE_MAX_BYTES) {
5819
+ warnings.push({
5820
+ file: rel,
5821
+ bytes: stat.size,
5822
+ limitBytes: AGENT_RULE_MAX_BYTES,
5823
+ message: `${rel} is ${stat.size.toLocaleString()} bytes (exceeds ${AGENT_RULE_MAX_BYTES.toLocaleString()}-byte agent rule context limit and will be truncated; extract detailed specs into .agents/skills/<name>/SKILL.md).`
5824
+ });
5825
+ }
5826
+ } catch {
5827
+ }
5828
+ }
5829
+ }
5830
+ return warnings;
5831
+ }
5102
5832
  function plantWrappers(targetDir) {
5103
5833
  const esedreDir = path7.join(targetDir, ".esedre");
5104
5834
  fs7.mkdirSync(esedreDir, { recursive: true });
@@ -5121,36 +5851,91 @@ function plantWrappers(targetDir) {
5121
5851
  }
5122
5852
  }
5123
5853
  }
5124
- function setupMcpConfig(targetDir) {
5125
- const agentsDir = path7.join(targetDir, ".agents");
5126
- fs7.mkdirSync(agentsDir, { recursive: true });
5127
- const mcpConfigPath = path7.join(agentsDir, "mcp_config.json");
5128
- let configObj = { mcpServers: {} };
5129
- if (fs7.existsSync(mcpConfigPath)) {
5130
- try {
5131
- configObj = JSON.parse(fs7.readFileSync(mcpConfigPath, "utf-8"));
5132
- if (!configObj.mcpServers) configObj.mcpServers = {};
5133
- } catch {
5134
- }
5135
- }
5136
- if (process.platform === "win32") {
5137
- configObj.mcpServers.esedre = {
5138
- command: "cmd.exe",
5139
- args: ["/c", ".esedre\\esedre.cmd", "mcp"]
5140
- };
5141
- } else {
5142
- configObj.mcpServers.esedre = {
5143
- command: ".esedre/esedre",
5144
- args: ["mcp"]
5145
- };
5854
+ function setupGlobalAntigravityMcpConfig(options = {}) {
5855
+ const geminiDir = options.geminiDir || process.env.ESEDRE_GEMINI_DIR || (process.env.VITEST ? null : path7.join(os5.homedir(), ".gemini"));
5856
+ if (!geminiDir || !fs7.existsSync(geminiDir)) {
5857
+ return false;
5146
5858
  }
5147
5859
  try {
5148
- fs7.writeFileSync(mcpConfigPath, JSON.stringify(configObj, null, 2) + "\n", "utf-8");
5860
+ const configDir = path7.join(geminiDir, "config");
5861
+ fs7.mkdirSync(configDir, { recursive: true });
5862
+ const mcpConfigPath = path7.join(configDir, "mcp_config.json");
5863
+ let configObj = { mcpServers: {} };
5864
+ if (fs7.existsSync(mcpConfigPath)) {
5865
+ try {
5866
+ configObj = JSON.parse(fs7.readFileSync(mcpConfigPath, "utf-8"));
5867
+ if (!configObj || typeof configObj !== "object") configObj = {};
5868
+ if (!configObj.mcpServers || typeof configObj.mcpServers !== "object") {
5869
+ configObj.mcpServers = {};
5870
+ }
5871
+ } catch {
5872
+ configObj = { mcpServers: {} };
5873
+ }
5874
+ }
5875
+ const existing = configObj.mcpServers.esedre;
5876
+ const existingArgs = Array.isArray(existing?.args) ? existing.args.join(" ") : "";
5877
+ const isBareRelativeWrapper = existing?.command === ".esedre/esedre" || existing?.command === ".esedre\\esedre.cmd" || existing?.command === "cmd.exe" && existingArgs.includes(".esedre\\esedre.cmd") && !existingArgs.toLowerCase().includes("if exist");
5878
+ if (!existing || isBareRelativeWrapper) {
5879
+ if (process.platform === "win32") {
5880
+ const globalNpmEse = path7.join(
5881
+ process.env.APPDATA || path7.join(os5.homedir(), "AppData", "Roaming"),
5882
+ "npm",
5883
+ "ese.cmd"
5884
+ );
5885
+ const fallbackCmd = fs7.existsSync(globalNpmEse) ? `call "${globalNpmEse}" mcp` : "call ese mcp";
5886
+ configObj.mcpServers.esedre = {
5887
+ command: "cmd.exe",
5888
+ args: ["/c", `if exist .esedre\\esedre.cmd (call .esedre\\esedre.cmd mcp) else (${fallbackCmd})`]
5889
+ };
5890
+ } else {
5891
+ configObj.mcpServers.esedre = {
5892
+ command: "sh",
5893
+ args: ["-c", "if [ -x .esedre/esedre ]; then exec .esedre/esedre mcp; else exec ese mcp; fi"]
5894
+ };
5895
+ }
5896
+ fs7.writeFileSync(mcpConfigPath, JSON.stringify(configObj, null, 2) + "\n", "utf-8");
5897
+ }
5149
5898
  return true;
5150
5899
  } catch {
5151
5900
  return false;
5152
5901
  }
5153
5902
  }
5903
+ function setupMcpConfig(targetDir, options = {}) {
5904
+ const agentsDir = path7.join(targetDir, ".agents");
5905
+ fs7.mkdirSync(agentsDir, { recursive: true });
5906
+ const writeConfigFile = (fileName) => {
5907
+ const mcpConfigPath = path7.join(agentsDir, fileName);
5908
+ let configObj = { mcpServers: {} };
5909
+ if (fs7.existsSync(mcpConfigPath)) {
5910
+ try {
5911
+ configObj = JSON.parse(fs7.readFileSync(mcpConfigPath, "utf-8"));
5912
+ if (!configObj.mcpServers) configObj.mcpServers = {};
5913
+ } catch {
5914
+ }
5915
+ }
5916
+ if (process.platform === "win32") {
5917
+ configObj.mcpServers.esedre = {
5918
+ command: "cmd.exe",
5919
+ args: ["/c", ".esedre\\esedre.cmd", "mcp"]
5920
+ };
5921
+ } else {
5922
+ configObj.mcpServers.esedre = {
5923
+ command: ".esedre/esedre",
5924
+ args: ["mcp"]
5925
+ };
5926
+ }
5927
+ try {
5928
+ fs7.writeFileSync(mcpConfigPath, JSON.stringify(configObj, null, 2) + "\n", "utf-8");
5929
+ return true;
5930
+ } catch {
5931
+ return false;
5932
+ }
5933
+ };
5934
+ const okLegacy = writeConfigFile("mcp_config.json");
5935
+ const okModern = writeConfigFile("mcp.json");
5936
+ setupGlobalAntigravityMcpConfig(options);
5937
+ return okLegacy && okModern;
5938
+ }
5154
5939
  function setupSkill(targetDir, options = {}) {
5155
5940
  const skillDir = path7.join(targetDir, ".agents", "skills", "esedre");
5156
5941
  fs7.mkdirSync(skillDir, { recursive: true });
@@ -5164,7 +5949,7 @@ function setupSkill(targetDir, options = {}) {
5164
5949
  if (status === "LATEST" && !options.force) {
5165
5950
  return { status, updated: false };
5166
5951
  }
5167
- const hasBannedOrDeprecatedTerms = currentContent.includes("companion agent") || currentContent.includes("companion LLM") || currentContent.includes("tools/esedre.mjs");
5952
+ const hasBannedOrDeprecatedTerms = currentContent.includes("companion agent") || currentContent.includes("companion LLM") || currentContent.includes("tools/esedre.mjs") || currentContent.includes("# Esedre Planning & Roadmap Workflow") && (!currentContent.includes("ese.cmd") || !currentContent.includes("Fix Breakdown") || !currentContent.includes("<ticketId...>"));
5168
5953
  if (options.force || status === "HISTORIC_DEFAULT" || status === "MISSING" || hasBannedOrDeprecatedTerms) {
5169
5954
  fs7.writeFileSync(skillFile, ESEDRE_SKILL_TEMPLATE, "utf-8");
5170
5955
  return { status: "LATEST", updated: true };
@@ -5385,6 +6170,7 @@ function configureWorkspace(targetDir, options = {}) {
5385
6170
  if (!globalStoreRes.ok) {
5386
6171
  console.warn(`\x1B[33m\u26A0\uFE0F Warning: Could not initialize global store at ${globalStoreRes.path}: ${globalStoreRes.error}\x1B[0m`);
5387
6172
  }
6173
+ const ruleSizeWarnings = checkAgentRuleSizes(targetDir);
5388
6174
  return {
5389
6175
  esedreJsonCreatedOrUpdated: true,
5390
6176
  projectRegistered,
@@ -5399,7 +6185,8 @@ function configureWorkspace(targetDir, options = {}) {
5399
6185
  recommendedProxySnippet: proxyRes.recommendedProxySnippet,
5400
6186
  globalStoreStatus: globalStoreRes.ok ? "ready" : "warning",
5401
6187
  globalStorePath: globalStoreRes.path,
5402
- globalStoreError: globalStoreRes.error
6188
+ globalStoreError: globalStoreRes.error,
6189
+ ruleSizeWarnings
5403
6190
  };
5404
6191
  }
5405
6192
  var initWorkspace = configureWorkspace;
@@ -5409,8 +6196,8 @@ var ESEDRE_HUB_AGENTS_TEMPLATE = `# Agent Guidelines: esedre-data
5409
6196
  - **Backing Storage Repository**: \`esedre-data\` is a dedicated, decoupled data hub storing raw tickets, metadata manifests, and implementation plans managed by the Esedre engine.
5410
6197
  - **Authorized Ingress Channels**: Ticket discovery, read-only inspection, status updates, and plan authoring strictly ingress through the Esedre application layer in consuming project workspaces:
5411
6198
  1. **Local Workspace Snapshot**: Check the consuming workspace's \`.esedre/snapshot.json\` first for instant, zero-latency read access to ticket summaries, plans, and statuses.
5412
- 2. **Esedre MCP Tools**: Use \`esedre_get_ticket\`, \`esedre_list_tickets\`, \`esedre_get_plan\`, \`esedre_save_plan\`, and \`esedre_update_ticket\`.
5413
- 3. **Esedre CLI**: Execute \`.esedre/ese get <id>\`, \`.esedre/ese list\`, or \`.esedre/ese plan\` from within the relevant project workspace.
6199
+ 2. **Esedre MCP Tools**: Use \`esedre_get_ticket\`, \`esedre_list_tickets\`, \`esedre_get_plan\`, \`esedre_save_plan\`, \`esedre_create_ticket\`, \`esedre_update_ticket\`, \`esedre_claim_ticket\`, \`esedre_renew_lease\`, \`esedre_release_ticket\`, \`esedre_list_leases\`, and \`esedre_add_comment\`.
6200
+ 3. **Esedre CLI**: Execute \`.esedre/ese get <id>\`, \`.esedre/ese list\`, \`.esedre/ese plan\`, \`.esedre/ese create\`, \`.esedre/ese update\`, \`.esedre/ese lease\`, \`.esedre/ese comment\`, \`.esedre/ese snapshot\`, or \`.esedre/ese refresh\` from within the relevant project workspace.
5414
6201
 
5415
6202
  ## 2. Ingress Redirection Protocol
5416
6203
  When inspecting or managing tickets for any project (such as \`Profe\`, \`Esedre\`, or \`Alce\`), navigate to that project's workspace and utilize its local \`.esedre/snapshot.json\`, Esedre MCP tools, or \`.esedre/ese\` CLI wrapper.
@@ -5779,7 +6566,7 @@ function createApiHandler(storage, workspaceRoot) {
5779
6566
  const numKey = String(t.meta.id);
5780
6567
  const code = t.projectDescriptor?.code || t.meta.project || "Profe";
5781
6568
  const prefixedKey = `${code}-${t.meta.id}`;
5782
- const metaWithLinks = { ...t.meta, links: t.links || t.meta.links || [], isBlocked: t.isBlocked };
6569
+ const metaWithLinks = { ...t.meta, links: t.links || t.meta.links || [], isBlocked: t.isBlocked, lease: t.lease || t.meta.lease || null };
5783
6570
  metasMap[prefixedKey] = metaWithLinks;
5784
6571
  if (!metasMap[numKey] || code.toUpperCase() === "PROF") {
5785
6572
  metasMap[numKey] = metaWithLinks;
@@ -5983,6 +6770,12 @@ function createApiHandler(storage, workspaceRoot) {
5983
6770
  sendJson(res, 200, { milestones });
5984
6771
  return true;
5985
6772
  }
6773
+ if (pathname === "/api/planning/leases") {
6774
+ const project = url.searchParams.get("project") || void 0;
6775
+ const leases = await reqStorage.listLeases(project);
6776
+ sendJson(res, 200, { leases });
6777
+ return true;
6778
+ }
5986
6779
  }
5987
6780
  if (req.method === "POST") {
5988
6781
  const body = await readJsonBody(req);
@@ -6215,6 +7008,61 @@ function createApiHandler(storage, workspaceRoot) {
6215
7008
  sendJson(res, 200, { success: true, source: result.source, target: result.target });
6216
7009
  return true;
6217
7010
  }
7011
+ if (pathname === "/api/planning/leases/claim") {
7012
+ invalidateApiCache();
7013
+ const { ticketId, holder, agentType, hostname, sessionId, branch, worktree, ttlMinutes, force, project } = body;
7014
+ if (!ticketId) {
7015
+ sendJson(res, 400, { error: "ticketId is required." });
7016
+ return true;
7017
+ }
7018
+ const result = await reqStorage.claimTicket({
7019
+ ticketId,
7020
+ holder,
7021
+ agentType,
7022
+ hostname,
7023
+ sessionId,
7024
+ branch,
7025
+ worktree,
7026
+ ttlMinutes,
7027
+ force: Boolean(force),
7028
+ project
7029
+ });
7030
+ sendJson(res, 200, { success: true, ...result });
7031
+ return true;
7032
+ }
7033
+ if (pathname === "/api/planning/leases/renew") {
7034
+ invalidateApiCache();
7035
+ const { ticketId, holder, ttlMinutes, force, project } = body;
7036
+ if (!ticketId) {
7037
+ sendJson(res, 400, { error: "ticketId is required." });
7038
+ return true;
7039
+ }
7040
+ const lease = await reqStorage.renewLease({
7041
+ ticketId,
7042
+ holder,
7043
+ ttlMinutes,
7044
+ force: Boolean(force),
7045
+ project
7046
+ });
7047
+ sendJson(res, 200, { success: true, lease });
7048
+ return true;
7049
+ }
7050
+ if (pathname === "/api/planning/leases/release") {
7051
+ invalidateApiCache();
7052
+ const { ticketId, holder, force, project } = body;
7053
+ if (!ticketId) {
7054
+ sendJson(res, 400, { error: "ticketId is required." });
7055
+ return true;
7056
+ }
7057
+ const result = await reqStorage.releaseTicket({
7058
+ ticketId,
7059
+ holder,
7060
+ force: Boolean(force),
7061
+ project
7062
+ });
7063
+ sendJson(res, 200, { success: true, ...result });
7064
+ return true;
7065
+ }
6218
7066
  }
6219
7067
  sendJson(res, 404, { error: `Endpoint not found: ${pathname}` });
6220
7068
  return true;
@@ -6223,6 +7071,10 @@ function createApiHandler(storage, workspaceRoot) {
6223
7071
  sendJson(res, 403, { error: err.message });
6224
7072
  return true;
6225
7073
  }
7074
+ if (err.name === "EsedreLeaseConflictError") {
7075
+ sendJson(res, 409, { error: err.message, ticketKey: err.ticketKey, activeLease: err.activeLease });
7076
+ return true;
7077
+ }
6226
7078
  console.error("[Esedre API Error]:", err);
6227
7079
  sendJson(res, 500, { error: err.message });
6228
7080
  return true;
@@ -6390,8 +7242,8 @@ ${colors.bold}ROADMAP COMMANDS (Pair Programming & LLM Agents):${colors.reset}
6390
7242
  \u2022 Status values: 'Planned', 'In Development', 'Completed', 'Rejected'
6391
7243
  \u2022 Type values: 'Feature', 'Platform', 'Tools', 'Idea', 'Bug'
6392
7244
 
6393
- ${colors.bold}get${colors.reset} <id> [--json]
6394
- Inspect full ticket specification, feature breakdown, and comments.
7245
+ ${colors.bold}get${colors.reset} <id...> [--json]
7246
+ Inspect full ticket specification, feature breakdown, and comments for one or more tickets.
6395
7247
 
6396
7248
  ${colors.bold}plan${colors.reset} <id> [--file <path> | --set "<markdown>"] [--last-hash <sha1>] [--json]
6397
7249
  View or update implementation plan with optimistic concurrency control.
@@ -6412,6 +7264,9 @@ ${colors.bold}ROADMAP COMMANDS (Pair Programming & LLM Agents):${colors.reset}
6412
7264
  ${colors.bold}unlink${colors.reset} <sourceId> <targetId> [--json]
6413
7265
  Remove a link between two tickets and its reciprocal link.
6414
7266
 
7267
+ ${colors.bold}lease${colors.reset} [claim <id> | release <id> | renew <id> | list] [--by|--worker-id <holder>] [--ttl <mins>] [--branch <b>] [--status <stat>] [--notes "..."] [--force] [--json]
7268
+ Coordinate multi-agent / multi-machine ticket ownership via TTL-backed advisory leases.
7269
+
6415
7270
  ${colors.bold}snapshot, refresh${colors.reset} [--project <code>] [--json]
6416
7271
  Generate lean read-only projection snapshot (.esedre/snapshot.json) for zero-latency agent context.
6417
7272
 
@@ -6571,6 +7426,7 @@ async function main() {
6571
7426
  }
6572
7427
  const rawArgs = process.argv.slice(2);
6573
7428
  const { command, positionals, flags } = parseArgs(rawArgs);
7429
+ initLogger({ debug: Boolean(flags["debug"]) });
6574
7430
  try {
6575
7431
  writeGlobalVersion();
6576
7432
  } catch {
@@ -6830,9 +7686,14 @@ ${colors.bold}Next Steps:${colors.reset}`);
6830
7686
  console.log(` \u2022 In-repo wrappers: .esedre/esedre.cmd, .esedre/ese.cmd, .esedre/esedre, .esedre/ese`);
6831
7687
  console.log(` \u2022 .gitignore: ${res.gitignoreUpdated ? "Added .esedre/snapshot.json" : "Already configured"}`);
6832
7688
  console.log(` \u2022 Snapshot: Generated .esedre/snapshot.json (${snapshotTickets} tickets projected)`);
6833
- console.log(` \u2022 MCP: ${res.mcpConfigured ? "Configured in .agents/mcp_config.json" : "Skipped"}`);
7689
+ console.log(` \u2022 MCP: ${res.mcpConfigured ? "Configured in .agents/mcp_config.json and .agents/mcp.json" : "Skipped"}`);
6834
7690
  console.log(` \u2022 Skill: ${res.skillConfigured ? "Updated .agents/skills/esedre/SKILL.md" : `Retained (${res.skillStatus})`}`);
6835
7691
  console.log(` \u2022 Central Config: Linked into ~/.esedre/config.json`);
7692
+ if (res.ruleSizeWarnings && res.ruleSizeWarnings.length > 0) {
7693
+ for (const rw of res.ruleSizeWarnings) {
7694
+ console.log(` \u2022 ${colors.yellow}\u26A0\uFE0F Rule Size Warning: ${rw.message}${colors.reset}`);
7695
+ }
7696
+ }
6836
7697
  if (res.viteProxyStatus === "CONFIGURED") {
6837
7698
  console.log(` \u2022 ${colors.green}Vite Proxy: Added /esedre proxy to ${res.viteConfigFile} targeting http://127.0.0.1:${effectivePort}${colors.reset}`);
6838
7699
  } else if (res.viteProxyStatus === "ALREADY_CONFIGURED") {
@@ -7014,6 +7875,11 @@ ${colors.bold}${colors.green}\u2714 Esedre multi-workspace upgrade completed acr
7014
7875
  } else if (w.upgrade.skillStatus === "CUSTOMIZED") {
7015
7876
  console.log(` - Preserved customized .agents/skills/esedre/SKILL.md`);
7016
7877
  }
7878
+ if (w.upgrade.ruleSizeWarnings && w.upgrade.ruleSizeWarnings.length > 0) {
7879
+ for (const rw of w.upgrade.ruleSizeWarnings) {
7880
+ console.log(` - ${colors.yellow}\u26A0\uFE0F ${rw.message}${colors.reset}`);
7881
+ }
7882
+ }
7017
7883
  }
7018
7884
  }
7019
7885
  return;
@@ -7045,6 +7911,11 @@ ${colors.bold}${colors.green}\u2714 Esedre multi-workspace upgrade completed acr
7045
7911
  } else if (res.skillStatus === "CUSTOMIZED") {
7046
7912
  console.log(` \u2022 ${colors.yellow}Notice: .agents/skills/esedre/SKILL.md is customized; preserved user modifications (use --force to overwrite).${colors.reset}`);
7047
7913
  }
7914
+ if (res.ruleSizeWarnings && res.ruleSizeWarnings.length > 0) {
7915
+ for (const rw of res.ruleSizeWarnings) {
7916
+ console.log(` \u2022 ${colors.yellow}\u26A0\uFE0F ${rw.message}${colors.reset}`);
7917
+ }
7918
+ }
7048
7919
  }
7049
7920
  return;
7050
7921
  }
@@ -7278,7 +8149,9 @@ ${colors.yellow}Notice: ${outdatedWrappers.length} workspace(s) have legacy CMD
7278
8149
  const milestone = flags["milestone"] || flags["m"] || void 0;
7279
8150
  const isBlocked = flags["blocked"] ? true : void 0;
7280
8151
  const linkedTo = flags["linked-to"] || flags["linkedTo"] || void 0;
7281
- const tickets = await storage.listTickets({ project, status, type, category, priority, milestone, search, isBlocked, linkedTo });
8152
+ const leased = flags["leased"] !== void 0 ? Boolean(flags["leased"]) : void 0;
8153
+ const leasedBy = flags["leased-by"] || flags["leasedBy"] || void 0;
8154
+ const tickets = await storage.listTickets({ project, status, type, category, priority, milestone, search, isBlocked, linkedTo, leased, leasedBy });
7282
8155
  if (isJson) {
7283
8156
  console.log(JSON.stringify(tickets.map((t) => ({
7284
8157
  id: t.meta.id,
@@ -7295,7 +8168,8 @@ ${colors.yellow}Notice: ${outdatedWrappers.length} workspace(s) have legacy CMD
7295
8168
  project: t.projectDescriptor?.code || t.meta.project || "UNASSIGNED",
7296
8169
  sha1: t.sha1 || t.meta.sha1,
7297
8170
  isBlocked: t.isBlocked,
7298
- links: t.links
8171
+ links: t.links,
8172
+ lease: t.lease || null
7299
8173
  })), null, 2));
7300
8174
  } else {
7301
8175
  if (tickets.length === 0) {
@@ -7318,22 +8192,30 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
7318
8192
  return;
7319
8193
  }
7320
8194
  case "get": {
7321
- const idStr = positionals[0];
7322
- if (!idStr) {
8195
+ if (positionals.length === 0) {
7323
8196
  console.error(`${colors.red}Error: Ticket ID is required (e.g. ese get 1 or ese get Profe-1)${colors.reset}`);
7324
8197
  process.exit(1);
7325
8198
  }
7326
8199
  const projectFlag = flags["project"];
7327
- const lookupKey = projectFlag && /^\d+$/.test(idStr) ? `${projectFlag}-${idStr}` : idStr;
7328
- const ticket = await storage.getTicket(lookupKey);
7329
- if (!ticket) {
7330
- console.error(`${colors.red}Error: Ticket #${lookupKey} not found.${colors.reset}`);
7331
- process.exit(1);
8200
+ const tickets = [];
8201
+ for (const idStr of positionals) {
8202
+ const lookupKey = projectFlag && /^\d+$/.test(idStr) ? `${projectFlag}-${idStr}` : idStr;
8203
+ const ticket = await storage.getTicket(lookupKey);
8204
+ if (!ticket) {
8205
+ console.error(`${colors.red}Error: Ticket #${lookupKey} not found.${colors.reset}`);
8206
+ process.exit(1);
8207
+ }
8208
+ tickets.push(ticket);
7332
8209
  }
7333
8210
  if (isJson) {
7334
- console.log(JSON.stringify(ticket, null, 2));
8211
+ console.log(JSON.stringify(tickets.length === 1 ? tickets[0] : tickets, null, 2));
7335
8212
  } else {
7336
- console.log(formatTicketDetail(ticket));
8213
+ console.log(
8214
+ tickets.map((t) => formatTicketDetail(t)).join(`
8215
+ ${colors.dim}${"\u2500".repeat(60)}${colors.reset}
8216
+
8217
+ `)
8218
+ );
7337
8219
  }
7338
8220
  return;
7339
8221
  }
@@ -7761,6 +8643,129 @@ ${colors.bold}Tickets:${colors.reset}`);
7761
8643
  }
7762
8644
  return;
7763
8645
  }
8646
+ case "claim":
8647
+ case "release":
8648
+ case "renew":
8649
+ case "leases":
8650
+ case "lease": {
8651
+ const isTopLevelAlias = command === "claim" || command === "release" || command === "renew" || command === "leases";
8652
+ const subAction = isTopLevelAlias ? command === "leases" ? "list" : command : positionals[0]?.toLowerCase() || "list";
8653
+ const targetArg = isTopLevelAlias ? positionals[0] : positionals[1];
8654
+ const project = flags["project"] || discovered.config?.projectCode;
8655
+ const holder = flags["by"] || flags["holder"] || flags["worker-id"] || flags["workerId"] || flags["author"] || void 0;
8656
+ const agentType = flags["agent"] || flags["agent-type"] || flags["system"] || void 0;
8657
+ const sessionId = flags["session"] || flags["session-id"] || void 0;
8658
+ const branch = flags["branch"] || void 0;
8659
+ const ttlRaw = flags["ttl"] !== void 0 ? Number(flags["ttl"]) : void 0;
8660
+ const isForce = Boolean(flags["force"] || flags["f"]);
8661
+ if (subAction === "list" || subAction === "ls") {
8662
+ const filterProject = flags["all"] || flags["a"] || flags["project"] === "all" ? "all" : project;
8663
+ const leases = await storage.listLeases(filterProject);
8664
+ if (isJson) {
8665
+ console.log(JSON.stringify({ total: leases.length, leases }, null, 2));
8666
+ } else {
8667
+ console.log(formatLeaseListTable(leases));
8668
+ if (leases.length > 0) {
8669
+ console.log(`
8670
+ ${colors.dim}Total: ${leases.length} active lease(s)${colors.reset}`);
8671
+ }
8672
+ }
8673
+ return;
8674
+ }
8675
+ if (subAction === "claim") {
8676
+ if (!targetArg) {
8677
+ console.error(`${colors.red}Error: Ticket ID is required (e.g. ese lease claim 69 --by "agent-1")${colors.reset}`);
8678
+ process.exit(1);
8679
+ }
8680
+ const identity = resolveWorkerIdentity({
8681
+ holder,
8682
+ agentType,
8683
+ sessionId,
8684
+ branch,
8685
+ worktree: discovered.workspaceRoot,
8686
+ configDefaultAuthor: discovered.config?.defaultAuthor
8687
+ });
8688
+ const res = await storage.claimTicket({
8689
+ ticketId: targetArg,
8690
+ holder: identity.holder,
8691
+ agentType: identity.agentType,
8692
+ hostname: identity.hostname,
8693
+ sessionId: identity.sessionId,
8694
+ branch: identity.branch,
8695
+ worktree: identity.worktree,
8696
+ ttlMinutes: ttlRaw,
8697
+ force: isForce,
8698
+ project
8699
+ });
8700
+ if (isJson) {
8701
+ console.log(JSON.stringify(res, null, 2));
8702
+ } else {
8703
+ const actionLabel = res.overridden ? "Overrode & claimed" : res.renewed ? "Renewed lease on" : "Claimed lease on";
8704
+ console.log(
8705
+ `${colors.green}\u2714 ${actionLabel} Ticket ${res.lease.ticketKey}${colors.reset} for ${colors.bold}${res.lease.holder}${colors.reset}${res.lease.hostname ? ` on ${res.lease.hostname}` : ""} (TTL: ${res.lease.ttlMinutes}m, expires ${res.lease.expiresAt})`
8706
+ );
8707
+ }
8708
+ return;
8709
+ }
8710
+ if (subAction === "renew" || subAction === "heartbeat") {
8711
+ if (!targetArg) {
8712
+ console.error(`${colors.red}Error: Ticket ID is required (e.g. ese lease renew 69)${colors.reset}`);
8713
+ process.exit(1);
8714
+ }
8715
+ const lease = await storage.renewLease({
8716
+ ticketId: targetArg,
8717
+ holder,
8718
+ ttlMinutes: ttlRaw,
8719
+ force: isForce,
8720
+ project
8721
+ });
8722
+ if (isJson) {
8723
+ console.log(JSON.stringify({ success: true, lease }, null, 2));
8724
+ } else {
8725
+ console.log(
8726
+ `${colors.green}\u2714 Renewed lease on Ticket ${lease.ticketKey}${colors.reset} for ${colors.bold}${lease.holder}${colors.reset} (expires ${lease.expiresAt})`
8727
+ );
8728
+ }
8729
+ return;
8730
+ }
8731
+ if (subAction === "release" || subAction === "unlock") {
8732
+ if (!targetArg) {
8733
+ console.error(`${colors.red}Error: Ticket ID is required (e.g. ese lease release 69)${colors.reset}`);
8734
+ process.exit(1);
8735
+ }
8736
+ const finalStatus = flags["status"] || void 0;
8737
+ const notes = flags["notes"] || void 0;
8738
+ const res = await storage.releaseTicket({
8739
+ ticketId: targetArg,
8740
+ holder,
8741
+ force: isForce,
8742
+ project
8743
+ });
8744
+ if (finalStatus) {
8745
+ await storage.updateTicket(res.ticketKey || targetArg, { status: finalStatus });
8746
+ }
8747
+ if (notes) {
8748
+ await storage.addComment(res.ticketKey || targetArg, {
8749
+ author: holder || res.previousLease?.holder || discovered.config?.defaultAuthor || "Agent",
8750
+ text: notes
8751
+ });
8752
+ }
8753
+ if (isJson) {
8754
+ console.log(JSON.stringify(res, null, 2));
8755
+ } else {
8756
+ if (res.released) {
8757
+ console.log(
8758
+ `${colors.green}\u2714 Released lease on Ticket ${res.ticketKey}${colors.reset}${res.previousLease ? ` (previously held by ${res.previousLease.holder})` : ""}${finalStatus ? ` -> ${finalStatus}` : ""}`
8759
+ );
8760
+ } else {
8761
+ console.log(`${colors.dim}Ticket ${res.ticketKey} had no active lease.${colors.reset}`);
8762
+ }
8763
+ }
8764
+ return;
8765
+ }
8766
+ console.error(`${colors.red}Error: Unknown lease action "${subAction}". Valid actions: claim, renew, release, list${colors.reset}`);
8767
+ process.exit(1);
8768
+ }
7764
8769
  default: {
7765
8770
  console.error(`${colors.red}Error: Unknown command "${command}"${colors.reset}`);
7766
8771
  printHelp();
@@ -7773,6 +8778,14 @@ ${colors.bold}Tickets:${colors.reset}`);
7773
8778
  console.error(`${colors.dim}Use --force to override if you are certain.${colors.reset}`);
7774
8779
  process.exit(1);
7775
8780
  }
8781
+ if (err instanceof EsedreLeaseConflictError || err.name === "EsedreLeaseConflictError") {
8782
+ if (isJson) {
8783
+ console.error(JSON.stringify({ error: err.message, ticketKey: err.ticketKey, activeLease: err.activeLease }, null, 2));
8784
+ } else {
8785
+ console.error(`${colors.red}${err.message}${colors.reset}`);
8786
+ }
8787
+ process.exit(1);
8788
+ }
7776
8789
  if (err instanceof EsedreAuthorizationError || err.name === "EsedreAuthorizationError") {
7777
8790
  console.error(`${colors.red}Access Denied: ${err.message}${colors.reset}`);
7778
8791
  process.exit(1);