esedre 1.0.1 → 1.2.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
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/esedre.ts
4
- import fs9 from "node:fs";
5
- import path9 from "node:path";
4
+ import fs11 from "node:fs";
5
+ import path11 from "node:path";
6
6
 
7
7
  // src/storage/filesystem.ts
8
8
  import fs3 from "node:fs";
@@ -10,7 +10,7 @@ import path3 from "node:path";
10
10
  import os2 from "node:os";
11
11
 
12
12
  // src/types.ts
13
- var CURRENT_ESEDRE_VERSION = "1.0.1";
13
+ var CURRENT_ESEDRE_VERSION = "1.2.0";
14
14
  var INVERSE_RELATIONS = {
15
15
  "relates-to": "relates-to",
16
16
  "blocks": "blocked-by",
@@ -112,6 +112,16 @@ function getGlobalConfigDir() {
112
112
  if (process.env.ESEDRE_GLOBAL_DIR) {
113
113
  return process.env.ESEDRE_GLOBAL_DIR;
114
114
  }
115
+ if (process.env.VITEST) {
116
+ const testGlobalDir = path.join(os.tmpdir(), "esedre-vitest-global");
117
+ if (!fs.existsSync(testGlobalDir)) {
118
+ try {
119
+ fs.mkdirSync(testGlobalDir, { recursive: true });
120
+ } catch {
121
+ }
122
+ }
123
+ return testGlobalDir;
124
+ }
115
125
  return path.join(os.homedir(), ".esedre");
116
126
  }
117
127
  function getGlobalConfigPath() {
@@ -158,6 +168,35 @@ function writeGlobalVersion(version = CURRENT_ESEDRE_VERSION) {
158
168
  }
159
169
  fs.writeFileSync(getGlobalVersionPath(), version.trim() + "\n", "utf-8");
160
170
  }
171
+ function hubContainsProject(hubPath, projectCodes) {
172
+ if (!hubPath || !projectCodes || projectCodes.length === 0) return false;
173
+ const resolved = path.resolve(expandHome(hubPath));
174
+ const projectsDir = path.join(resolved, "projects");
175
+ if (fs.existsSync(projectsDir)) {
176
+ try {
177
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
178
+ const dirNames = entries.filter((e) => e.isDirectory()).map((e) => e.name.toLowerCase());
179
+ if (projectCodes.some((code) => dirNames.includes(code.trim().toLowerCase()))) {
180
+ return true;
181
+ }
182
+ } catch {
183
+ }
184
+ }
185
+ const projectsJson = path.join(resolved, "projects.json");
186
+ if (fs.existsSync(projectsJson)) {
187
+ try {
188
+ const parsed = JSON.parse(fs.readFileSync(projectsJson, "utf-8"));
189
+ if (Array.isArray(parsed)) {
190
+ const pCodes = parsed.map((p) => (p.code || "").trim().toLowerCase());
191
+ if (projectCodes.some((code) => pCodes.includes(code.trim().toLowerCase()))) {
192
+ return true;
193
+ }
194
+ }
195
+ } catch {
196
+ }
197
+ }
198
+ return false;
199
+ }
161
200
  function addLocationToGlobalConfig(targetPath) {
162
201
  const resolved = path.resolve(expandHome(targetPath));
163
202
  if (!fs.existsSync(resolved)) {
@@ -337,8 +376,21 @@ function findEsedreConfig(startDir, options) {
337
376
  }
338
377
  const globalConfig = readGlobalConfig();
339
378
  if (localConfig) {
379
+ let inheritedDataDir = localConfig.dataDir;
380
+ if (inheritedDataDir === void 0 && globalConfig?.dataDir) {
381
+ const targetCodes = [
382
+ ...localConfig.projectCode ? [localConfig.projectCode] : [],
383
+ ...Array.isArray(localConfig.allowedProjects) ? localConfig.allowedProjects : []
384
+ ].filter((c) => c && c !== "*");
385
+ const globalDirs = Array.isArray(globalConfig.dataDir) ? globalConfig.dataDir : [globalConfig.dataDir];
386
+ const matchesGlobalHub = globalDirs.some((d) => hubContainsProject(d, targetCodes));
387
+ if (matchesGlobalHub) {
388
+ inheritedDataDir = globalConfig.dataDir;
389
+ }
390
+ }
340
391
  const merged = {
341
392
  ...localConfig,
393
+ dataDir: inheritedDataDir,
342
394
  port: localConfig.port !== void 0 ? localConfig.port : globalConfig?.port || DEFAULT_ESEDRE_PORT,
343
395
  ports: localConfig.ports || globalConfig?.ports
344
396
  };
@@ -655,6 +707,18 @@ function normalizeTicketFields(ticket) {
655
707
  if (c.author) c.author = normalizeDashesAndMojibake(c.author);
656
708
  }
657
709
  }
710
+ if (ticket.answers) {
711
+ for (const key of Object.keys(ticket.answers)) {
712
+ ticket.answers[key] = normalizeDashesAndMojibake(ticket.answers[key]);
713
+ }
714
+ }
715
+ if (ticket.inlineComments) {
716
+ for (const ic of ticket.inlineComments) {
717
+ if (ic.selectedText) ic.selectedText = normalizeDashesAndMojibake(ic.selectedText);
718
+ if (ic.comment) ic.comment = normalizeDashesAndMojibake(ic.comment);
719
+ if (ic.author) ic.author = normalizeDashesAndMojibake(ic.author);
720
+ }
721
+ }
658
722
  return ticket;
659
723
  }
660
724
  function formatTicketListTable(tickets) {
@@ -759,9 +823,12 @@ function formatTicketDetail(ticket) {
759
823
  if (detail?.openQuestions && detail.openQuestions.length > 0) {
760
824
  lines.push("");
761
825
  lines.push(`${colors.bold}Open Decisions & Questions:${colors.reset}`);
762
- for (const q of detail.openQuestions) {
826
+ detail.openQuestions.forEach((q, idx) => {
763
827
  lines.push(` \u2022 ${normalizeDashesAndMojibake(q)}`);
764
- }
828
+ if (ticket.answers && ticket.answers[String(idx)]) {
829
+ lines.push(` ${colors.green}> Answer:${colors.reset} ${normalizeDashesAndMojibake(ticket.answers[String(idx)])}`);
830
+ }
831
+ });
765
832
  }
766
833
  if (ticket.links && ticket.links.length > 0) {
767
834
  lines.push("");
@@ -883,13 +950,23 @@ function writeSafeFile(filePath, content) {
883
950
  if (!fs3.existsSync(dir)) {
884
951
  fs3.mkdirSync(dir, { recursive: true });
885
952
  }
886
- const normalized = content.replace(/\n/g, "\n");
887
953
  const tempPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
888
954
  try {
889
- fs3.writeFileSync(tempPath, normalized, "utf-8");
890
- fs3.renameSync(tempPath, filePath);
955
+ if (Buffer.isBuffer(content)) {
956
+ fs3.writeFileSync(tempPath, content);
957
+ fs3.renameSync(tempPath, filePath);
958
+ } else {
959
+ const normalized = content.replace(/\r\n/g, "\n");
960
+ fs3.writeFileSync(tempPath, normalized, "utf-8");
961
+ fs3.renameSync(tempPath, filePath);
962
+ }
891
963
  } catch {
892
- fs3.writeFileSync(filePath, normalized, "utf-8");
964
+ if (Buffer.isBuffer(content)) {
965
+ fs3.writeFileSync(filePath, content);
966
+ } else {
967
+ const normalized = content.replace(/\r\n/g, "\n");
968
+ fs3.writeFileSync(filePath, normalized, "utf-8");
969
+ }
893
970
  if (fs3.existsSync(tempPath)) {
894
971
  try {
895
972
  fs3.unlinkSync(tempPath);
@@ -905,6 +982,23 @@ var FilesystemStorageAdapter = class {
905
982
  lastConfigCheck = 0;
906
983
  configTtlMs = 3e3;
907
984
  duplicateProjectWarnings = [];
985
+ milestoneMemoryCache = /* @__PURE__ */ new Map();
986
+ ticketMemoryCache = /* @__PURE__ */ new Map();
987
+ invalidateTicketCache(ticketDir) {
988
+ if (ticketDir) {
989
+ this.ticketMemoryCache.delete(path3.resolve(ticketDir));
990
+ } else {
991
+ this.ticketMemoryCache.clear();
992
+ }
993
+ }
994
+ invalidateMilestoneCache(loc) {
995
+ if (loc) {
996
+ const mPath = path3.resolve(this.getMilestonesPath(loc));
997
+ this.milestoneMemoryCache.delete(mPath);
998
+ } else {
999
+ this.milestoneMemoryCache.clear();
1000
+ }
1001
+ }
908
1002
  getDuplicateProjectWarnings() {
909
1003
  this.resolveProjectLocations();
910
1004
  return [...this.duplicateProjectWarnings];
@@ -1803,6 +1897,8 @@ ${formattedList}`
1803
1897
  this.config.allowedProjects[idx] = cleanNew;
1804
1898
  }
1805
1899
  }
1900
+ this.invalidateTicketCache();
1901
+ this.invalidateMilestoneCache();
1806
1902
  this.refreshConfig();
1807
1903
  return {
1808
1904
  success: true,
@@ -1820,11 +1916,26 @@ ${formattedList}`
1820
1916
  }
1821
1917
  readProjectMilestones(loc) {
1822
1918
  const mPath = this.getMilestonesPath(loc);
1823
- if (!fs3.existsSync(mPath)) return [];
1919
+ const resolvedPath = path3.resolve(mPath);
1920
+ if (!fs3.existsSync(mPath)) {
1921
+ this.milestoneMemoryCache.delete(resolvedPath);
1922
+ return [];
1923
+ }
1824
1924
  try {
1925
+ const stat = fs3.statSync(mPath);
1926
+ const cached = this.milestoneMemoryCache.get(resolvedPath);
1927
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
1928
+ return cached.milestones;
1929
+ }
1825
1930
  const raw = fs3.readFileSync(mPath, "utf-8");
1826
1931
  const parsed = JSON.parse(raw);
1827
- return Array.isArray(parsed) ? parsed : [];
1932
+ const milestones = Array.isArray(parsed) ? parsed : [];
1933
+ this.milestoneMemoryCache.set(resolvedPath, {
1934
+ milestones,
1935
+ mtimeMs: stat.mtimeMs,
1936
+ size: stat.size
1937
+ });
1938
+ return milestones;
1828
1939
  } catch {
1829
1940
  return [];
1830
1941
  }
@@ -1832,6 +1943,8 @@ ${formattedList}`
1832
1943
  writeProjectMilestones(loc, milestones) {
1833
1944
  const mPath = this.getMilestonesPath(loc);
1834
1945
  writeSafeFile(mPath, JSON.stringify(milestones, null, 2) + "\n");
1946
+ this.invalidateMilestoneCache(loc);
1947
+ this.invalidateTicketCache();
1835
1948
  }
1836
1949
  resolveMilestone(milestoneRef, localLoc) {
1837
1950
  const cleanRef = normalizeDashesAndMojibake(milestoneRef).trim();
@@ -2081,13 +2194,162 @@ ${formattedList}`
2081
2194
  }
2082
2195
  return null;
2083
2196
  }
2197
+ readTicketFromDir(ticketDir, num, loc, locations, codeToDescriptor, idToDescriptor) {
2198
+ const resolvedTicketDir = path3.resolve(ticketDir);
2199
+ const filesToCheck = [
2200
+ "meta.json",
2201
+ "detail.md",
2202
+ "implementation_plan.md",
2203
+ "comments.json",
2204
+ "answers.json",
2205
+ "inline-comments.json"
2206
+ ];
2207
+ const currentStats = {};
2208
+ for (const f of filesToCheck) {
2209
+ const p = path3.join(ticketDir, f);
2210
+ try {
2211
+ const st = fs3.statSync(p);
2212
+ currentStats[f] = { mtimeMs: st.mtimeMs, size: st.size };
2213
+ } catch {
2214
+ }
2215
+ }
2216
+ if (!currentStats["meta.json"]) return null;
2217
+ const cached = this.ticketMemoryCache.get(resolvedTicketDir);
2218
+ if (cached) {
2219
+ const cachedKeys = Object.keys(cached.stats);
2220
+ const currentKeys = Object.keys(currentStats);
2221
+ if (cachedKeys.length === currentKeys.length) {
2222
+ let match = true;
2223
+ for (const key of currentKeys) {
2224
+ const c = cached.stats[key];
2225
+ const s = currentStats[key];
2226
+ if (!c || c.mtimeMs !== s.mtimeMs || c.size !== s.size) {
2227
+ match = false;
2228
+ break;
2229
+ }
2230
+ }
2231
+ if (match) {
2232
+ 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);
2239
+ }
2240
+ return ticket;
2241
+ }
2242
+ }
2243
+ }
2244
+ try {
2245
+ const metaPath = path3.join(ticketDir, "meta.json");
2246
+ const metaRaw = fs3.readFileSync(metaPath, "utf-8");
2247
+ const meta = JSON.parse(metaRaw);
2248
+ meta.id = meta.id ?? num;
2249
+ meta.type = meta.type || meta.category || "Feature";
2250
+ meta.category = meta.type;
2251
+ if (meta.priority) {
2252
+ meta.priority = normalizePriority(meta.priority);
2253
+ }
2254
+ let effectiveLoc = loc;
2255
+ if (loc.isSharedDir) {
2256
+ let found;
2257
+ if (idToDescriptor && codeToDescriptor) {
2258
+ const desc = (meta.projectId !== void 0 ? idToDescriptor.get(meta.projectId) : void 0) || (meta.project ? codeToDescriptor.get(meta.project.toLowerCase()) : void 0);
2259
+ if (desc) {
2260
+ found = locations.find((l) => l.project.code.toLowerCase() === desc.code.toLowerCase());
2261
+ }
2262
+ } else {
2263
+ found = locations.find(
2264
+ (l) => meta.projectId !== void 0 && l.project.id === meta.projectId || meta.project && l.project.code.toLowerCase() === meta.project.toLowerCase()
2265
+ );
2266
+ }
2267
+ if (found) effectiveLoc = found;
2268
+ }
2269
+ meta.project = effectiveLoc.project.code;
2270
+ meta.projectId = effectiveLoc.project.id;
2271
+ if (meta.milestone) {
2272
+ meta.milestone = normalizeDashesAndMojibake(meta.milestone);
2273
+ const mMatch = this.resolveMilestone(meta.milestone, effectiveLoc);
2274
+ if (mMatch?.featureFlag) {
2275
+ meta.inheritedFeatureFlag = mMatch.featureFlag;
2276
+ if (!meta.featureFlag) {
2277
+ meta.featureFlag = mMatch.featureFlag;
2278
+ }
2279
+ }
2280
+ }
2281
+ let detail;
2282
+ const detailPath = path3.join(ticketDir, "detail.md");
2283
+ if (currentStats["detail.md"]) {
2284
+ const raw = fs3.readFileSync(detailPath, "utf-8");
2285
+ detail = this.parseDetailMarkdown(raw);
2286
+ }
2287
+ let planMarkdown;
2288
+ const planPath = path3.join(ticketDir, "implementation_plan.md");
2289
+ if (currentStats["implementation_plan.md"]) {
2290
+ planMarkdown = fs3.readFileSync(planPath, "utf-8");
2291
+ }
2292
+ let comments = [];
2293
+ const commentsPath = path3.join(ticketDir, "comments.json");
2294
+ if (currentStats["comments.json"]) {
2295
+ try {
2296
+ comments = JSON.parse(fs3.readFileSync(commentsPath, "utf-8"));
2297
+ } catch {
2298
+ }
2299
+ }
2300
+ let answers;
2301
+ const answersPath = path3.join(ticketDir, "answers.json");
2302
+ if (currentStats["answers.json"]) {
2303
+ try {
2304
+ answers = JSON.parse(fs3.readFileSync(answersPath, "utf-8"));
2305
+ } catch {
2306
+ }
2307
+ }
2308
+ let inlineComments;
2309
+ const inlinesPath = path3.join(ticketDir, "inline-comments.json");
2310
+ if (currentStats["inline-comments.json"]) {
2311
+ try {
2312
+ inlineComments = JSON.parse(fs3.readFileSync(inlinesPath, "utf-8"));
2313
+ } catch {
2314
+ }
2315
+ }
2316
+ const ticketObj = {
2317
+ meta,
2318
+ detail,
2319
+ planMarkdown,
2320
+ comments,
2321
+ answers,
2322
+ inlineComments,
2323
+ projectDescriptor: effectiveLoc.project
2324
+ };
2325
+ normalizeTicketFields(ticketObj);
2326
+ this.enrichTicketLinks(ticketObj);
2327
+ ticketObj.sha1 = computeTicketHash(ticketObj);
2328
+ ticketObj.lastHash = ticketObj.sha1;
2329
+ this.ticketMemoryCache.set(resolvedTicketDir, {
2330
+ ticket: ticketObj,
2331
+ stats: currentStats
2332
+ });
2333
+ return ticketObj;
2334
+ } catch {
2335
+ return null;
2336
+ }
2337
+ }
2084
2338
  async listTickets(filter) {
2085
2339
  const locations = this.resolveProjectLocations();
2086
2340
  const tickets = [];
2087
2341
  const processedSharedTickets = /* @__PURE__ */ new Set();
2342
+ const codeToDescriptor = /* @__PURE__ */ new Map();
2343
+ const idToDescriptor = /* @__PURE__ */ new Map();
2344
+ for (const l of locations) {
2345
+ codeToDescriptor.set(l.project.code.toLowerCase(), l.project);
2346
+ if (l.project.id !== void 0) {
2347
+ idToDescriptor.set(l.project.id, l.project);
2348
+ idToDescriptor.set(String(l.project.id), l.project);
2349
+ }
2350
+ }
2088
2351
  for (const loc of locations) {
2089
2352
  if (filter?.project && filter.project !== "all") {
2090
- const pLower = filter.project.toLowerCase();
2091
2353
  if (!isProjectMatch(loc.project.code, filter.project)) {
2092
2354
  continue;
2093
2355
  }
@@ -2099,145 +2361,104 @@ ${formattedList}`
2099
2361
  } catch {
2100
2362
  continue;
2101
2363
  }
2364
+ const activeDirNames = /* @__PURE__ */ new Set();
2102
2365
  for (const entry of entries) {
2103
2366
  if (!entry.isDirectory()) continue;
2104
2367
  const num = parseInt(entry.name, 10);
2105
2368
  if (isNaN(num)) continue;
2369
+ activeDirNames.add(entry.name);
2106
2370
  const ticketDir = path3.join(loc.ticketsDir, entry.name);
2107
- const metaPath = path3.join(ticketDir, "meta.json");
2108
- if (!fs3.existsSync(metaPath)) continue;
2109
2371
  if (loc.isSharedDir) {
2110
2372
  const key = `${loc.ticketsDir}:${entry.name}`;
2111
2373
  if (processedSharedTickets.has(key)) continue;
2112
2374
  processedSharedTickets.add(key);
2113
2375
  }
2114
- try {
2115
- const metaRaw = fs3.readFileSync(metaPath, "utf-8");
2116
- const meta = JSON.parse(metaRaw);
2117
- meta.id = meta.id ?? num;
2118
- meta.type = meta.type || meta.category || "Feature";
2119
- meta.category = meta.type;
2120
- if (meta.priority) {
2121
- meta.priority = normalizePriority(meta.priority);
2122
- }
2123
- let effectiveLoc = loc;
2124
- if (loc.isSharedDir) {
2125
- const found = locations.find(
2126
- (l) => meta.projectId !== void 0 && l.project.id === meta.projectId || meta.project && l.project.code.toLowerCase() === meta.project.toLowerCase()
2127
- );
2128
- if (found) effectiveLoc = found;
2129
- }
2130
- meta.project = effectiveLoc.project.code;
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
- }
2143
- if (filter?.project && filter.project !== "all") {
2144
- const pLower = filter.project.toLowerCase();
2145
- if (!isProjectMatch(meta.project, filter.project)) {
2146
- continue;
2147
- }
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
- }
2169
- if (filter?.status && meta.status !== filter.status) {
2376
+ const ticketObj = this.readTicketFromDir(
2377
+ ticketDir,
2378
+ num,
2379
+ loc,
2380
+ locations,
2381
+ codeToDescriptor,
2382
+ idToDescriptor
2383
+ );
2384
+ if (!ticketObj) continue;
2385
+ const meta = ticketObj.meta;
2386
+ if (filter?.project && filter.project !== "all") {
2387
+ if (!isProjectMatch(meta.project, filter.project)) {
2170
2388
  continue;
2171
2389
  }
2172
- if (filter?.category && meta.category !== filter.category) {
2390
+ }
2391
+ if (filter?.milestone) {
2392
+ const mFilter = filter.milestone.trim().toLowerCase();
2393
+ const targetLoc = meta.project && locations.find((l) => l.project.code.toLowerCase() === meta.project.toLowerCase()) || loc;
2394
+ const resolvedFilterMilestone = this.resolveMilestone(filter.milestone, targetLoc);
2395
+ const mMatch = meta.milestone ? this.resolveMilestone(meta.milestone, targetLoc) : null;
2396
+ const matchesDirect = Boolean(meta.milestone && meta.milestone.toLowerCase() === mFilter);
2397
+ const matchesResolvedTitle = Boolean(
2398
+ resolvedFilterMilestone && meta.milestone && meta.milestone.toLowerCase() === resolvedFilterMilestone.title.toLowerCase()
2399
+ );
2400
+ const matchesResolvedId = Boolean(
2401
+ resolvedFilterMilestone && meta.milestone && String(resolvedFilterMilestone.id).toLowerCase() === meta.milestone.toLowerCase()
2402
+ );
2403
+ const matchesCompound = Boolean(
2404
+ resolvedFilterMilestone && meta.milestone && `${resolvedFilterMilestone.project}:${resolvedFilterMilestone.title}`.toLowerCase() === meta.milestone.toLowerCase()
2405
+ );
2406
+ const matchesResolvedSelf = Boolean(
2407
+ mMatch && (mMatch.title.toLowerCase() === mFilter || String(mMatch.id).toLowerCase() === mFilter || `${mMatch.project}:${mMatch.title}`.toLowerCase() === mFilter || `${mMatch.project}:${mMatch.id}`.toLowerCase() === mFilter)
2408
+ );
2409
+ if (!matchesDirect && !matchesResolvedTitle && !matchesResolvedId && !matchesCompound && !matchesResolvedSelf) {
2173
2410
  continue;
2174
2411
  }
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
- }
2412
+ }
2413
+ if (filter?.status && meta.status !== filter.status) {
2414
+ continue;
2415
+ }
2416
+ if (filter?.category && meta.category !== filter.category) {
2417
+ continue;
2418
+ }
2419
+ if (filter?.priority !== void 0) {
2420
+ const fPrio = String(filter.priority).trim().toLowerCase();
2421
+ if (fPrio === "none" || fPrio === "null") {
2422
+ if (meta.priority) {
2423
+ continue;
2185
2424
  }
2186
- }
2187
- if (filter?.search) {
2188
- const term = filter.search.toLowerCase();
2189
- const matchesTitle = meta.title.toLowerCase().includes(term);
2190
- const matchesNum = String(meta.id).includes(term) || `${meta.project}-${meta.id}`.toLowerCase().includes(term);
2191
- if (!matchesTitle && !matchesNum) {
2425
+ } else {
2426
+ if (!meta.priority || meta.priority.toLowerCase() !== fPrio) {
2192
2427
  continue;
2193
2428
  }
2194
2429
  }
2195
- let detail;
2196
- const detailPath = path3.join(ticketDir, "detail.md");
2197
- if (fs3.existsSync(detailPath)) {
2198
- const raw = fs3.readFileSync(detailPath, "utf-8");
2199
- detail = this.parseDetailMarkdown(raw);
2200
- }
2201
- let planMarkdown;
2202
- const planPath = path3.join(ticketDir, "implementation_plan.md");
2203
- if (fs3.existsSync(planPath)) {
2204
- planMarkdown = fs3.readFileSync(planPath, "utf-8");
2205
- }
2206
- let comments = [];
2207
- const commentsPath = path3.join(ticketDir, "comments.json");
2208
- if (fs3.existsSync(commentsPath)) {
2209
- try {
2210
- comments = JSON.parse(fs3.readFileSync(commentsPath, "utf-8"));
2211
- } catch {
2212
- }
2430
+ }
2431
+ if (filter?.search) {
2432
+ const term = filter.search.toLowerCase();
2433
+ const matchesTitle = meta.title.toLowerCase().includes(term);
2434
+ const matchesNum = String(meta.id).includes(term) || `${meta.project}-${meta.id}`.toLowerCase().includes(term);
2435
+ if (!matchesTitle && !matchesNum) {
2436
+ continue;
2213
2437
  }
2214
- const ticketObj = {
2215
- meta,
2216
- detail,
2217
- planMarkdown,
2218
- comments,
2219
- projectDescriptor: effectiveLoc.project
2220
- };
2221
- normalizeTicketFields(ticketObj);
2222
- this.enrichTicketLinks(ticketObj);
2223
- ticketObj.sha1 = computeTicketHash(ticketObj);
2224
- ticketObj.lastHash = ticketObj.sha1;
2225
- if (filter?.isBlocked !== void 0 && Boolean(ticketObj.isBlocked) !== Boolean(filter.isBlocked)) {
2438
+ }
2439
+ if (filter?.isBlocked !== void 0 && Boolean(ticketObj.isBlocked) !== Boolean(filter.isBlocked)) {
2440
+ continue;
2441
+ }
2442
+ if (filter?.linkedTo) {
2443
+ const target = filter.linkedTo.trim().toLowerCase();
2444
+ const hasLink = ticketObj.meta.links?.some((l) => {
2445
+ const tk = l.targetKey.toLowerCase();
2446
+ const idStr = String(l.targetId);
2447
+ return tk === target || idStr === target;
2448
+ });
2449
+ if (!hasLink) {
2226
2450
  continue;
2227
2451
  }
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
- }
2452
+ }
2453
+ tickets.push(ticketObj);
2454
+ }
2455
+ const locResolvedDir = path3.resolve(loc.ticketsDir);
2456
+ for (const cachedPath of this.ticketMemoryCache.keys()) {
2457
+ if (cachedPath.startsWith(locResolvedDir + path3.sep)) {
2458
+ const folderName = path3.basename(cachedPath);
2459
+ if (!activeDirNames.has(folderName)) {
2460
+ this.ticketMemoryCache.delete(cachedPath);
2238
2461
  }
2239
- tickets.push(ticketObj);
2240
- } catch {
2241
2462
  }
2242
2463
  }
2243
2464
  }
@@ -2251,61 +2472,8 @@ ${formattedList}`
2251
2472
  async getTicket(id) {
2252
2473
  const locInfo = this.findTicketLocation(id);
2253
2474
  if (!locInfo) return null;
2254
- const metaPath = path3.join(locInfo.ticketDir, "meta.json");
2255
- if (!fs3.existsSync(metaPath)) return null;
2256
- try {
2257
- const metaRaw = fs3.readFileSync(metaPath, "utf-8");
2258
- const meta = JSON.parse(metaRaw);
2259
- meta.id = meta.id ?? locInfo.id;
2260
- meta.project = locInfo.loc.project.code;
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
- }
2275
- let detail;
2276
- const detailPath = path3.join(locInfo.ticketDir, "detail.md");
2277
- if (fs3.existsSync(detailPath)) {
2278
- const raw = fs3.readFileSync(detailPath, "utf-8");
2279
- detail = this.parseDetailMarkdown(raw);
2280
- }
2281
- let planMarkdown;
2282
- const planPath = path3.join(locInfo.ticketDir, "implementation_plan.md");
2283
- if (fs3.existsSync(planPath)) {
2284
- planMarkdown = fs3.readFileSync(planPath, "utf-8");
2285
- }
2286
- let comments = [];
2287
- const commentsPath = path3.join(locInfo.ticketDir, "comments.json");
2288
- if (fs3.existsSync(commentsPath)) {
2289
- try {
2290
- comments = JSON.parse(fs3.readFileSync(commentsPath, "utf-8"));
2291
- } catch {
2292
- }
2293
- }
2294
- const ticket = {
2295
- meta,
2296
- detail,
2297
- planMarkdown,
2298
- comments,
2299
- projectDescriptor: locInfo.loc.project
2300
- };
2301
- normalizeTicketFields(ticket);
2302
- this.enrichTicketLinks(ticket);
2303
- ticket.sha1 = computeTicketHash(ticket);
2304
- ticket.lastHash = ticket.sha1;
2305
- return ticket;
2306
- } catch {
2307
- return null;
2308
- }
2475
+ const locations = this.resolveProjectLocations();
2476
+ return this.readTicketFromDir(locInfo.ticketDir, locInfo.id, locInfo.loc, locations);
2309
2477
  }
2310
2478
  parseDetailMarkdown(raw) {
2311
2479
  const titleMatch = raw.match(/^#\s*(?:Ticket\s*#?\d+\s*:\s*|\d+\s*:\s*)?([^\n]+)/im);
@@ -2365,6 +2533,63 @@ ${formattedList}`
2365
2533
  raw
2366
2534
  };
2367
2535
  }
2536
+ formatTicketDetailMarkdown(rawDetail, meta, fallbackSummary) {
2537
+ const trimmed = rawDetail.trim();
2538
+ const priorityLine = meta.priority ? `**Priority**: ${meta.priority}
2539
+ ` : "";
2540
+ const milestoneLine = meta.milestone ? `**Milestone**: ${meta.milestone}
2541
+ ` : "";
2542
+ if (/^#\s+[^\n]+/m.test(trimmed)) {
2543
+ let processed = trimmed.replace(/^#\s+[^\n]+/m, `# Ticket #${meta.id}: ${meta.title}`);
2544
+ if (!/\*\*(?:Type|Category)\*\*:/i.test(processed)) {
2545
+ const metaBlock = `
2546
+ **Category**: ${meta.category}
2547
+ ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
2548
+ **Estimated Effort**: ${meta.estimatedEffort}
2549
+ `;
2550
+ processed = processed.replace(/^(# Ticket[^\n]+\n)/m, `$1${metaBlock}`);
2551
+ }
2552
+ if (!/(?:##|###)\s*(?:Summary|Rationale)/i.test(processed)) {
2553
+ const summaryBlock = `
2554
+ ### Summary
2555
+ ${fallbackSummary || "Summary to be defined."}
2556
+ `;
2557
+ processed = processed.replace(/^((?:# Ticket[^\n]+\n)(?:\*\*[^\n]+\n)*)/m, `$1${summaryBlock}`);
2558
+ }
2559
+ return processed.endsWith("\n") ? processed : `${processed}
2560
+ `;
2561
+ }
2562
+ const hasSummary = /(?:##|###)\s*(?:Summary|Rationale)/i.test(trimmed);
2563
+ const hasBreakdown = /(?:##|###)\s*Feature Breakdown/i.test(trimmed);
2564
+ let body = "";
2565
+ if (!hasSummary) {
2566
+ body += `### Summary
2567
+ ${fallbackSummary || "Summary to be defined."}
2568
+
2569
+ `;
2570
+ }
2571
+ if (!hasBreakdown && !trimmed.startsWith("#")) {
2572
+ body += `### Feature Breakdown
2573
+ ${trimmed}
2574
+
2575
+ ### Technical Details & Architecture
2576
+ - Architecture specifications to be documented.
2577
+
2578
+ ### Open Questions & Decisions
2579
+ - None recorded at initialization.
2580
+ `;
2581
+ } else {
2582
+ body += `${trimmed}
2583
+ `;
2584
+ }
2585
+ return `# Ticket #${meta.id}: ${meta.title}
2586
+ **Category**: ${meta.category}
2587
+ ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
2588
+ **Estimated Effort**: ${meta.estimatedEffort}
2589
+
2590
+ ${body.trim()}
2591
+ `;
2592
+ }
2368
2593
  async createTicket(input) {
2369
2594
  const locations = this.resolveProjectLocations();
2370
2595
  if (locations.length === 0) {
@@ -2440,64 +2665,14 @@ ${formattedList}`
2440
2665
  project: targetLoc.project.code
2441
2666
  };
2442
2667
  const rawDetail = cleanDetailRaw?.trim();
2443
- const priorityLine = meta.priority ? `**Priority**: ${meta.priority}
2444
- ` : "";
2445
- const milestoneLine = meta.milestone ? `**Milestone**: ${meta.milestone}
2446
- ` : "";
2447
2668
  let detailMd;
2448
2669
  if (rawDetail) {
2449
- if (/^#\s+[^\n]+/m.test(rawDetail)) {
2450
- let processed = rawDetail.replace(/^#\s+[^\n]+/m, `# Ticket #${nextId}: ${meta.title}`);
2451
- if (!/\*\*(?:Type|Category)\*\*:/i.test(processed)) {
2452
- const metaBlock = `
2453
- **Category**: ${meta.category}
2454
- ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
2455
- **Estimated Effort**: ${meta.estimatedEffort}
2456
- `;
2457
- processed = processed.replace(/^(# Ticket[^\n]+\n)/m, `$1${metaBlock}`);
2458
- }
2459
- if (!/(?:##|###)\s*(?:Summary|Rationale)/i.test(processed)) {
2460
- const summaryBlock = `
2461
- ### Summary
2462
- ${input.summary || "Summary to be defined."}
2463
- `;
2464
- processed = processed.replace(/^((?:# Ticket[^\n]+\n)(?:\*\*[^\n]+\n)*)/m, `$1${summaryBlock}`);
2465
- }
2466
- detailMd = processed.endsWith("\n") ? processed : `${processed}
2467
- `;
2468
- } else {
2469
- const hasSummary = /(?:##|###)\s*(?:Summary|Rationale)/i.test(rawDetail);
2470
- const hasBreakdown = /(?:##|###)\s*Feature Breakdown/i.test(rawDetail);
2471
- let body = "";
2472
- if (!hasSummary) {
2473
- body += `### Summary
2474
- ${input.summary || "Summary to be defined."}
2475
-
2476
- `;
2477
- }
2478
- if (!hasBreakdown && !rawDetail.startsWith("#")) {
2479
- body += `### Feature Breakdown
2480
- ${rawDetail}
2481
-
2482
- ### Technical Details & Architecture
2483
- - Architecture specifications to be documented.
2484
-
2485
- ### Open Questions & Decisions
2486
- - None recorded at initialization.
2487
- `;
2488
- } else {
2489
- body += `${rawDetail}
2490
- `;
2491
- }
2492
- detailMd = `# Ticket #${nextId}: ${meta.title}
2493
- **Category**: ${meta.category}
2494
- ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
2495
- **Estimated Effort**: ${meta.estimatedEffort}
2496
-
2497
- ${body.trim()}
2498
- `;
2499
- }
2670
+ detailMd = this.formatTicketDetailMarkdown(rawDetail, meta, input.summary);
2500
2671
  } else {
2672
+ const priorityLine = meta.priority ? `**Priority**: ${meta.priority}
2673
+ ` : "";
2674
+ const milestoneLine = meta.milestone ? `**Milestone**: ${meta.milestone}
2675
+ ` : "";
2501
2676
  detailMd = `# Ticket #${nextId}: ${meta.title}
2502
2677
  **Category**: ${meta.category}
2503
2678
  ${priorityLine}${milestoneLine}**Complexity**: ${meta.complexity}
@@ -2519,6 +2694,7 @@ ${input.summary || "Summary to be defined."}
2519
2694
  }
2520
2695
  writeSafeFile(path3.join(ticketDir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
2521
2696
  writeSafeFile(path3.join(ticketDir, "detail.md"), detailMd);
2697
+ this.invalidateTicketCache(ticketDir);
2522
2698
  const created = await this.getTicket(`${targetLoc.project.code}-${nextId}`);
2523
2699
  return created;
2524
2700
  }
@@ -2542,7 +2718,8 @@ ${input.summary || "Summary to be defined."}
2542
2718
  const isNowCompleted = updates.status === "Completed";
2543
2719
  const completedAt = isNowCompleted ? existing.meta.completedAt || now : updates.status && updates.status !== "Completed" ? void 0 : existing.meta.completedAt;
2544
2720
  const revision = (existing.meta.revision || 1) + 1;
2545
- const sanitizedUpdates = { ...updates };
2721
+ const { priority: _p, detail: _d, detailMarkdown: _dm, ...metaFields } = updates;
2722
+ const sanitizedUpdates = { ...metaFields };
2546
2723
  if (sanitizedUpdates.title) sanitizedUpdates.title = normalizeDashesAndMojibake(sanitizedUpdates.title);
2547
2724
  if (sanitizedUpdates.estimatedEffort) sanitizedUpdates.estimatedEffort = normalizeDashesAndMojibake(sanitizedUpdates.estimatedEffort);
2548
2725
  if (sanitizedUpdates.complexity) sanitizedUpdates.complexity = normalizeDashesAndMojibake(sanitizedUpdates.complexity);
@@ -2596,7 +2773,41 @@ ${input.summary || "Summary to be defined."}
2596
2773
  updatedMeta.type = newType;
2597
2774
  updatedMeta.category = newType;
2598
2775
  }
2599
- if (existing.detail && ("priority" in updates || "milestone" in updates || updates.title || updates.type || updates.category || updates.complexity || updates.estimatedEffort)) {
2776
+ const rawDetailArg = updates.detailMarkdown !== void 0 ? updates.detailMarkdown : updates.detail;
2777
+ if (rawDetailArg !== void 0) {
2778
+ const cleanDetail = normalizeDashesAndMojibake(rawDetailArg);
2779
+ 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
+ }
2809
+ 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)) {
2600
2811
  let content = existing.detail.raw;
2601
2812
  if (updates.title) {
2602
2813
  content = content.replace(/^#\s*(?:Ticket\s*#?\d+\s*:\s*|\d+\s*:\s*)?[^\n]+/im, `# Ticket #${locInfo.id}: ${updatedMeta.title}`);
@@ -2636,6 +2847,7 @@ ${input.summary || "Summary to be defined."}
2636
2847
  }
2637
2848
  writeSafeFile(path3.join(locInfo.ticketDir, "detail.md"), content);
2638
2849
  }
2850
+ this.invalidateTicketCache(locInfo.ticketDir);
2639
2851
  return await this.getTicket(`${locInfo.loc.project.code}-${locInfo.id}`);
2640
2852
  }
2641
2853
  async getPlan(id) {
@@ -2672,6 +2884,7 @@ ${input.summary || "Summary to be defined."}
2672
2884
  } catch {
2673
2885
  }
2674
2886
  }
2887
+ this.invalidateTicketCache(locInfo.ticketDir);
2675
2888
  }
2676
2889
  async addComment(id, comment) {
2677
2890
  const ticket = await this.getTicket(id);
@@ -2698,8 +2911,151 @@ ${input.summary || "Summary to be defined."}
2698
2911
  };
2699
2912
  comments.push(newComment);
2700
2913
  writeSafeFile(commentsPath, JSON.stringify(comments, null, 2) + "\n");
2914
+ this.invalidateTicketCache(locInfo.ticketDir);
2701
2915
  return newComment;
2702
2916
  }
2917
+ async getAnswers(id) {
2918
+ const locInfo = this.findTicketLocation(id);
2919
+ if (!locInfo) return {};
2920
+ const answersPath = path3.join(locInfo.ticketDir, "answers.json");
2921
+ if (!fs3.existsSync(answersPath)) return {};
2922
+ try {
2923
+ return JSON.parse(fs3.readFileSync(answersPath, "utf-8"));
2924
+ } catch {
2925
+ return {};
2926
+ }
2927
+ }
2928
+ async saveAnswer(id, questionIndex, answer) {
2929
+ const locInfo = this.findTicketLocation(id);
2930
+ if (!locInfo) {
2931
+ throw new Error(`Ticket #${id} could not be located on disk`);
2932
+ }
2933
+ const answersPath = path3.join(locInfo.ticketDir, "answers.json");
2934
+ let currentAnswers = {};
2935
+ if (fs3.existsSync(answersPath)) {
2936
+ try {
2937
+ currentAnswers = JSON.parse(fs3.readFileSync(answersPath, "utf-8"));
2938
+ } catch {
2939
+ }
2940
+ }
2941
+ currentAnswers[String(questionIndex)] = normalizeDashesAndMojibake(answer);
2942
+ writeSafeFile(answersPath, JSON.stringify(currentAnswers, null, 2) + "\n");
2943
+ this.invalidateTicketCache(locInfo.ticketDir);
2944
+ return currentAnswers;
2945
+ }
2946
+ async saveDetail(id, detailMarkdown, metaUpdates) {
2947
+ const locInfo = this.findTicketLocation(id);
2948
+ if (!locInfo) {
2949
+ throw new Error(`Ticket #${id} could not be located on disk`);
2950
+ }
2951
+ let updatedMeta;
2952
+ if (metaUpdates && Object.keys(metaUpdates).length > 0) {
2953
+ const updatedTicket = await this.updateTicket(id, metaUpdates);
2954
+ updatedMeta = updatedTicket.meta;
2955
+ } else {
2956
+ const metaPath = path3.join(locInfo.ticketDir, "meta.json");
2957
+ if (fs3.existsSync(metaPath)) {
2958
+ try {
2959
+ const meta = JSON.parse(fs3.readFileSync(metaPath, "utf-8"));
2960
+ meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2961
+ meta.revision = (meta.revision || 1) + 1;
2962
+ writeSafeFile(metaPath, JSON.stringify(meta, null, 2) + "\n");
2963
+ } catch {
2964
+ }
2965
+ }
2966
+ }
2967
+ const detailPath = path3.join(locInfo.ticketDir, "detail.md");
2968
+ writeSafeFile(detailPath, normalizeDashesAndMojibake(detailMarkdown));
2969
+ this.invalidateTicketCache(locInfo.ticketDir);
2970
+ const finalTicket = await this.getTicket(`${locInfo.loc.project.code}-${locInfo.id}`);
2971
+ return {
2972
+ success: true,
2973
+ meta: finalTicket?.meta || updatedMeta,
2974
+ detail: detailMarkdown
2975
+ };
2976
+ }
2977
+ async getInlineComments(id) {
2978
+ const locInfo = this.findTicketLocation(id);
2979
+ if (!locInfo) return [];
2980
+ const inlinesPath = path3.join(locInfo.ticketDir, "inline-comments.json");
2981
+ if (!fs3.existsSync(inlinesPath)) return [];
2982
+ try {
2983
+ return JSON.parse(fs3.readFileSync(inlinesPath, "utf-8"));
2984
+ } catch {
2985
+ return [];
2986
+ }
2987
+ }
2988
+ async saveInlineComment(id, selectedText, comment, author) {
2989
+ const locInfo = this.findTicketLocation(id);
2990
+ if (!locInfo) {
2991
+ throw new Error(`Ticket #${id} could not be located on disk`);
2992
+ }
2993
+ const inlinesPath = path3.join(locInfo.ticketDir, "inline-comments.json");
2994
+ let currentInlines = [];
2995
+ if (fs3.existsSync(inlinesPath)) {
2996
+ try {
2997
+ currentInlines = JSON.parse(fs3.readFileSync(inlinesPath, "utf-8"));
2998
+ } catch {
2999
+ }
3000
+ }
3001
+ const newEntry = {
3002
+ id: `${locInfo.id}-ic-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
3003
+ selectedText: normalizeDashesAndMojibake(selectedText),
3004
+ comment: normalizeDashesAndMojibake(comment.trim()),
3005
+ author: normalizeDashesAndMojibake(author || "Developer"),
3006
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3007
+ };
3008
+ currentInlines.push(newEntry);
3009
+ writeSafeFile(inlinesPath, JSON.stringify(currentInlines, null, 2) + "\n");
3010
+ this.invalidateTicketCache(locInfo.ticketDir);
3011
+ return currentInlines;
3012
+ }
3013
+ async saveAttachment(id, filename, buffer) {
3014
+ const locInfo = this.findTicketLocation(id);
3015
+ if (!locInfo) {
3016
+ throw new Error(`Ticket #${id} could not be located on disk`);
3017
+ }
3018
+ const attachmentsDir = path3.join(locInfo.ticketDir, "attachments");
3019
+ if (!fs3.existsSync(attachmentsDir)) {
3020
+ fs3.mkdirSync(attachmentsDir, { recursive: true });
3021
+ }
3022
+ const MAX_ATTACHMENT_SIZE_BYTES = 10 * 1024 * 1024;
3023
+ if (buffer.length > MAX_ATTACHMENT_SIZE_BYTES) {
3024
+ throw new Error(`Attachment exceeds maximum allowable size of 10MB (received ${(buffer.length / (1024 * 1024)).toFixed(1)}MB)`);
3025
+ }
3026
+ const MAX_TICKET_ATTACHMENTS = 30;
3027
+ const existingFiles = fs3.readdirSync(attachmentsDir);
3028
+ if (existingFiles.length >= MAX_TICKET_ATTACHMENTS) {
3029
+ throw new Error(`Ticket #${id} has reached the maximum allowed limit of ${MAX_TICKET_ATTACHMENTS} attachments`);
3030
+ }
3031
+ let safeName = path3.basename(filename).replace(/[^a-zA-Z0-9._-]/g, "_");
3032
+ if (!safeName || safeName === "." || safeName === ".." || safeName.replace(/_/g, "") === "") {
3033
+ safeName = `attachment_${Date.now()}`;
3034
+ }
3035
+ let targetFile = path3.join(attachmentsDir, safeName);
3036
+ if (fs3.existsSync(targetFile)) {
3037
+ const ext = path3.extname(safeName);
3038
+ const nameWithoutExt = path3.basename(safeName, ext);
3039
+ safeName = `${nameWithoutExt}_${Date.now()}${ext}`;
3040
+ targetFile = path3.join(attachmentsDir, safeName);
3041
+ }
3042
+ writeSafeFile(targetFile, buffer);
3043
+ this.invalidateTicketCache(locInfo.ticketDir);
3044
+ return { filename: safeName, relativePath: `attachments/${safeName}` };
3045
+ }
3046
+ getAttachmentPath(id, filename) {
3047
+ const locInfo = this.findTicketLocation(id);
3048
+ if (!locInfo) return null;
3049
+ const safeName = path3.basename(filename).replace(/[^a-zA-Z0-9._-]/g, "_");
3050
+ if (!safeName || safeName === "." || safeName === "..") return null;
3051
+ const attachmentsDir = path3.resolve(locInfo.ticketDir, "attachments");
3052
+ const target = path3.resolve(attachmentsDir, safeName);
3053
+ if (!target.startsWith(attachmentsDir)) return null;
3054
+ if (fs3.existsSync(target) && fs3.statSync(target).isFile()) {
3055
+ return target;
3056
+ }
3057
+ return null;
3058
+ }
2703
3059
  resolveTicketIdentifier(id, defaultProject) {
2704
3060
  const str = String(id).trim();
2705
3061
  if (!/^([a-zA-Z0-9]{1,8})[-_:](\d+)$/i.test(str) && defaultProject && defaultProject !== "all") {
@@ -2835,6 +3191,7 @@ ${input.summary || "Summary to be defined."}
2835
3191
  sourceMeta.updatedAt = now;
2836
3192
  sourceMeta.revision = (sourceMeta.revision || 1) + 1;
2837
3193
  writeSafeFile(sourceMetaPath, JSON.stringify(sourceMeta, null, 2) + "\n");
3194
+ this.invalidateTicketCache(sourceInfo.ticketDir);
2838
3195
  let updatedTarget;
2839
3196
  const targetMetaPath = path3.join(targetInfo.ticketDir, "meta.json");
2840
3197
  if (fs3.existsSync(targetMetaPath)) {
@@ -2862,6 +3219,7 @@ ${input.summary || "Summary to be defined."}
2862
3219
  targetMeta.updatedAt = now;
2863
3220
  targetMeta.revision = (targetMeta.revision || 1) + 1;
2864
3221
  writeSafeFile(targetMetaPath, JSON.stringify(targetMeta, null, 2) + "\n");
3222
+ this.invalidateTicketCache(targetInfo.ticketDir);
2865
3223
  updatedTarget = await this.getTicket(targetInfo.key) || void 0;
2866
3224
  } catch {
2867
3225
  }
@@ -2890,6 +3248,7 @@ ${input.summary || "Summary to be defined."}
2890
3248
  sourceMeta.updatedAt = now;
2891
3249
  sourceMeta.revision = (sourceMeta.revision || 1) + 1;
2892
3250
  writeSafeFile(sourceMetaPath, JSON.stringify(sourceMeta, null, 2) + "\n");
3251
+ this.invalidateTicketCache(sourceInfo.ticketDir);
2893
3252
  }
2894
3253
  }
2895
3254
  } catch {
@@ -2910,6 +3269,7 @@ ${input.summary || "Summary to be defined."}
2910
3269
  targetMeta.updatedAt = now;
2911
3270
  targetMeta.revision = (targetMeta.revision || 1) + 1;
2912
3271
  writeSafeFile(targetMetaPath, JSON.stringify(targetMeta, null, 2) + "\n");
3272
+ this.invalidateTicketCache(targetInfo.ticketDir);
2913
3273
  updatedTarget = await this.getTicket(targetInfo.key) || void 0;
2914
3274
  }
2915
3275
  }
@@ -3141,9 +3501,86 @@ var SecurityFilter = class {
3141
3501
  target: result.target ? this.redactLinks(result.target) : void 0
3142
3502
  };
3143
3503
  }
3504
+ async getAnswers(id) {
3505
+ const ticket = await this.target.getTicket(id);
3506
+ if (ticket) {
3507
+ const code = ticket.projectDescriptor?.code || ticket.meta.project;
3508
+ if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
3509
+ throw new EsedreAuthorizationError(code);
3510
+ }
3511
+ }
3512
+ return this.target.getAnswers(id);
3513
+ }
3514
+ async saveAnswer(id, questionIndex, answer) {
3515
+ const ticket = await this.target.getTicket(id);
3516
+ if (ticket) {
3517
+ const code = ticket.projectDescriptor?.code || ticket.meta.project;
3518
+ if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
3519
+ throw new EsedreAuthorizationError(code);
3520
+ }
3521
+ }
3522
+ return this.target.saveAnswer(id, questionIndex, answer);
3523
+ }
3524
+ async saveDetail(id, detailMarkdown, metaUpdates) {
3525
+ const ticket = await this.target.getTicket(id);
3526
+ if (ticket) {
3527
+ const code = ticket.projectDescriptor?.code || ticket.meta.project;
3528
+ if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
3529
+ throw new EsedreAuthorizationError(code);
3530
+ }
3531
+ }
3532
+ return this.target.saveDetail(id, detailMarkdown, metaUpdates);
3533
+ }
3534
+ async getInlineComments(id) {
3535
+ const ticket = await this.target.getTicket(id);
3536
+ if (ticket) {
3537
+ const code = ticket.projectDescriptor?.code || ticket.meta.project;
3538
+ if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
3539
+ throw new EsedreAuthorizationError(code);
3540
+ }
3541
+ }
3542
+ return this.target.getInlineComments ? this.target.getInlineComments(id) : [];
3543
+ }
3544
+ async saveInlineComment(id, selectedText, comment, author) {
3545
+ const ticket = await this.target.getTicket(id);
3546
+ if (ticket) {
3547
+ const code = ticket.projectDescriptor?.code || ticket.meta.project;
3548
+ if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
3549
+ throw new EsedreAuthorizationError(code);
3550
+ }
3551
+ }
3552
+ return this.target.saveInlineComment ? this.target.saveInlineComment(id, selectedText, comment, author) : [];
3553
+ }
3554
+ async saveAttachment(id, filename, buffer) {
3555
+ const ticket = await this.target.getTicket(id);
3556
+ if (ticket) {
3557
+ const code = ticket.projectDescriptor?.code || ticket.meta.project;
3558
+ if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
3559
+ throw new EsedreAuthorizationError(code);
3560
+ }
3561
+ }
3562
+ if (!this.target.saveAttachment) {
3563
+ throw new Error("Attachments are not supported by target storage adapter.");
3564
+ }
3565
+ return this.target.saveAttachment(id, filename, buffer);
3566
+ }
3567
+ getAttachmentPath(id, filename) {
3568
+ if (this.target.findTicketLocation) {
3569
+ const locInfo = this.target.findTicketLocation(id);
3570
+ if (locInfo) {
3571
+ const code = locInfo.loc?.project?.code;
3572
+ if (code && !isProjectAuthorized(code, this.config.allowedProjects)) {
3573
+ throw new EsedreAuthorizationError(code);
3574
+ }
3575
+ }
3576
+ }
3577
+ return this.target.getAttachmentPath ? this.target.getAttachmentPath(id, filename) : null;
3578
+ }
3144
3579
  };
3145
3580
 
3146
3581
  // src/mcp/server.ts
3582
+ import fs4 from "node:fs";
3583
+ import path4 from "node:path";
3147
3584
  import readline from "node:readline";
3148
3585
  var EsedreMcpServer = class {
3149
3586
  storage;
@@ -3365,7 +3802,8 @@ Status: ${ticket.meta.status}`;
3365
3802
  milestone: { type: "string", description: "Filter tickets by milestone name or ID" },
3366
3803
  search: { type: "string", description: "Search keywords in title or ID" },
3367
3804
  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" }
3805
+ linkedTo: { type: "string", description: "Filter tickets linked to a specific ticket ID or key" },
3806
+ includePlan: { type: "boolean", description: "When true, includes implementation plan markdown (planMarkdown) for each ticket" }
3369
3807
  }
3370
3808
  }
3371
3809
  },
@@ -3442,6 +3880,8 @@ Status: ${ticket.meta.status}`;
3442
3880
  inDevelopment: { type: "boolean", description: "Active development toggle" },
3443
3881
  featureFlag: { type: "string", description: "Feature flag name" },
3444
3882
  milestone: { type: "string", description: 'Update ticket milestone ("none" to clear)' },
3883
+ detail: { type: "string", description: "Updated specification markdown for ticket detail" },
3884
+ detailMarkdown: { type: "string", description: "Updated specification markdown for ticket detail" },
3445
3885
  lastHash: { type: "string", description: "Optimistic concurrency control: last known sha1 hash of the ticket" }
3446
3886
  },
3447
3887
  required: ["ticketId"]
@@ -3455,9 +3895,10 @@ Status: ${ticket.meta.status}`;
3455
3895
  properties: {
3456
3896
  ticketId: { type: ["integer", "string"], description: "Numeric ticket ID or compound key" },
3457
3897
  text: { type: "string", description: "Comment message" },
3898
+ file: { type: "string", description: "Path to local file containing comment message markdown" },
3458
3899
  author: { type: "string", description: "Author name (e.g. Antigravity, Developer)" }
3459
3900
  },
3460
- required: ["ticketId", "text"]
3901
+ required: ["ticketId"]
3461
3902
  }
3462
3903
  },
3463
3904
  {
@@ -3554,6 +3995,7 @@ Status: ${ticket.meta.status}`;
3554
3995
  switch (canonical) {
3555
3996
  case "list_tickets": {
3556
3997
  const type = args.type || args.category;
3998
+ const includePlan = Boolean(args.includePlan);
3557
3999
  const tickets = await this.storage.listTickets({
3558
4000
  project: args.project,
3559
4001
  status: args.status,
@@ -3565,23 +4007,39 @@ Status: ${ticket.meta.status}`;
3565
4007
  isBlocked: args.isBlocked !== void 0 ? Boolean(args.isBlocked) : void 0,
3566
4008
  linkedTo: args.linkedTo
3567
4009
  });
3568
- return tickets.map((t) => ({
3569
- id: t.meta.id,
3570
- title: t.meta.title,
3571
- type: t.meta.type || t.meta.category,
3572
- category: t.meta.type || t.meta.category,
3573
- status: t.meta.status,
3574
- priority: t.meta.priority,
3575
- complexity: t.meta.complexity,
3576
- effort: t.meta.estimatedEffort,
3577
- milestone: t.meta.milestone,
3578
- featureFlag: t.meta.featureFlag,
3579
- inheritedFeatureFlag: t.meta.inheritedFeatureFlag,
3580
- project: t.projectDescriptor?.code || t.meta.project || "UNASSIGNED",
3581
- sha1: t.sha1 || t.meta.sha1,
3582
- isBlocked: t.isBlocked,
3583
- links: t.links
3584
- }));
4010
+ const results = await Promise.all(
4011
+ tickets.map(async (t) => {
4012
+ let planMarkdown = t.planMarkdown;
4013
+ if (includePlan && planMarkdown === void 0) {
4014
+ const lookupKey = t.projectDescriptor?.code ? `${t.projectDescriptor.code}-${t.meta.id}` : t.meta.id;
4015
+ planMarkdown = await this.storage.getPlan(lookupKey) ?? null;
4016
+ }
4017
+ const hasPlan = Boolean(
4018
+ t.planMarkdown && t.planMarkdown.trim().length > 0 || planMarkdown && planMarkdown.trim().length > 0
4019
+ );
4020
+ return {
4021
+ id: t.meta.id,
4022
+ title: t.meta.title,
4023
+ type: t.meta.type || t.meta.category,
4024
+ category: t.meta.type || t.meta.category,
4025
+ status: t.meta.status,
4026
+ priority: t.meta.priority,
4027
+ complexity: t.meta.complexity,
4028
+ effort: t.meta.estimatedEffort,
4029
+ milestone: t.meta.milestone,
4030
+ featureFlag: t.meta.featureFlag,
4031
+ inheritedFeatureFlag: t.meta.inheritedFeatureFlag,
4032
+ project: t.projectDescriptor?.code || t.meta.project || "UNASSIGNED",
4033
+ summary: t.detail?.summary,
4034
+ hasPlan,
4035
+ ...includePlan ? { planMarkdown: planMarkdown || null } : {},
4036
+ sha1: t.sha1 || t.meta.sha1,
4037
+ isBlocked: t.isBlocked,
4038
+ links: t.links
4039
+ };
4040
+ })
4041
+ );
4042
+ return results;
3585
4043
  }
3586
4044
  case "get_ticket": {
3587
4045
  const id = this.parseTicketId(args.ticketId);
@@ -3633,13 +4091,33 @@ Status: ${ticket.meta.status}`;
3633
4091
  if (args.inDevelopment !== void 0) updates.isActivePlanning = Boolean(args.inDevelopment);
3634
4092
  if (args.featureFlag) updates.featureFlag = args.featureFlag;
3635
4093
  if (args.milestone !== void 0) updates.milestone = args.milestone;
4094
+ if (args.detail !== void 0) updates.detail = args.detail;
4095
+ if (args.detailMarkdown !== void 0) updates.detailMarkdown = args.detailMarkdown;
3636
4096
  const updated = await this.storage.updateTicket(id, updates, args.lastHash);
3637
4097
  return updated;
3638
4098
  }
3639
4099
  case "add_comment": {
3640
4100
  const id = this.parseTicketId(args.ticketId);
4101
+ let text = args.text;
4102
+ if (!text && args.file) {
4103
+ const resolvedPath = path4.resolve(process.cwd(), args.file);
4104
+ if (!fs4.existsSync(resolvedPath)) {
4105
+ throw new Error(`File not found: ${args.file}`);
4106
+ }
4107
+ if (!fs4.statSync(resolvedPath).isFile()) {
4108
+ throw new Error(`Path is not a file: ${args.file}`);
4109
+ }
4110
+ const fileContent = fs4.readFileSync(resolvedPath, "utf-8");
4111
+ if (!fileContent.trim()) {
4112
+ throw new Error(`Comment file is empty: ${args.file}`);
4113
+ }
4114
+ text = fileContent;
4115
+ }
4116
+ if (!text || !text.trim()) {
4117
+ throw new Error("Comment text or file is required");
4118
+ }
3641
4119
  const comment = await this.storage.addComment(id, {
3642
- text: args.text,
4120
+ text,
3643
4121
  author: args.author || "Agent"
3644
4122
  });
3645
4123
  return comment;
@@ -3708,19 +4186,19 @@ Status: ${ticket.meta.status}`;
3708
4186
  };
3709
4187
 
3710
4188
  // src/utils/manifestDetector.ts
3711
- import fs4 from "node:fs";
3712
- import path4 from "node:path";
4189
+ import fs5 from "node:fs";
4190
+ import path5 from "node:path";
3713
4191
  function sanitizeCode(input) {
3714
4192
  const clean = input.replace(/[^a-zA-Z0-9]/g, "");
3715
4193
  return clean.slice(0, MAX_PROJECT_CODE_LENGTH) || "APP";
3716
4194
  }
3717
4195
  function detectProjectManifest(targetDir) {
3718
- const dirBase = path4.basename(targetDir);
4196
+ const dirBase = path5.basename(targetDir);
3719
4197
  for (const settingsFile of ["settings.gradle.kts", "settings.gradle"]) {
3720
- const fullPath = path4.join(targetDir, settingsFile);
3721
- if (fs4.existsSync(fullPath)) {
4198
+ const fullPath = path5.join(targetDir, settingsFile);
4199
+ if (fs5.existsSync(fullPath)) {
3722
4200
  try {
3723
- const content = fs4.readFileSync(fullPath, "utf-8");
4201
+ const content = fs5.readFileSync(fullPath, "utf-8");
3724
4202
  const match = content.match(/rootProject\.name\s*=\s*["']([^"']+)["']/);
3725
4203
  if (match && match[1].trim()) {
3726
4204
  const rawName = match[1].trim();
@@ -3736,15 +4214,15 @@ function detectProjectManifest(targetDir) {
3736
4214
  }
3737
4215
  }
3738
4216
  for (const buildFile of [
3739
- path4.join("app", "build.gradle.kts"),
4217
+ path5.join("app", "build.gradle.kts"),
3740
4218
  "build.gradle.kts",
3741
- path4.join("app", "build.gradle"),
4219
+ path5.join("app", "build.gradle"),
3742
4220
  "build.gradle"
3743
4221
  ]) {
3744
- const fullPath = path4.join(targetDir, buildFile);
3745
- if (fs4.existsSync(fullPath)) {
4222
+ const fullPath = path5.join(targetDir, buildFile);
4223
+ if (fs5.existsSync(fullPath)) {
3746
4224
  try {
3747
- const content = fs4.readFileSync(fullPath, "utf-8");
4225
+ const content = fs5.readFileSync(fullPath, "utf-8");
3748
4226
  const match = content.match(/(?:namespace|applicationId)\s*=\s*["']([^"']+)["']/);
3749
4227
  if (match && match[1].trim()) {
3750
4228
  const ns = match[1].trim();
@@ -3761,10 +4239,10 @@ function detectProjectManifest(targetDir) {
3761
4239
  }
3762
4240
  }
3763
4241
  }
3764
- const pomPath = path4.join(targetDir, "pom.xml");
3765
- if (fs4.existsSync(pomPath)) {
4242
+ const pomPath = path5.join(targetDir, "pom.xml");
4243
+ if (fs5.existsSync(pomPath)) {
3766
4244
  try {
3767
- const content = fs4.readFileSync(pomPath, "utf-8");
4245
+ const content = fs5.readFileSync(pomPath, "utf-8");
3768
4246
  const stripped = content.replace(/<!--[\s\S]*?-->/g, "").replace(/<parent>[\s\S]*?<\/parent>/g, "");
3769
4247
  const nameMatch = stripped.match(/<name>\s*([^<]+)\s*<\/name>/);
3770
4248
  const artifactMatch = stripped.match(/<artifactId>\s*([^<]+)\s*<\/artifactId>/) || content.match(/<artifactId>\s*([^<]+)\s*<\/artifactId>/);
@@ -3783,10 +4261,10 @@ function detectProjectManifest(targetDir) {
3783
4261
  } catch {
3784
4262
  }
3785
4263
  }
3786
- const pkgPath = path4.join(targetDir, "package.json");
3787
- if (fs4.existsSync(pkgPath)) {
4264
+ const pkgPath = path5.join(targetDir, "package.json");
4265
+ if (fs5.existsSync(pkgPath)) {
3788
4266
  try {
3789
- const pkg = JSON.parse(fs4.readFileSync(pkgPath, "utf-8"));
4267
+ const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
3790
4268
  if (pkg.name && typeof pkg.name === "string") {
3791
4269
  const baseName = pkg.name.replace(/^@[\w-]+\//, "");
3792
4270
  return {
@@ -3799,10 +4277,10 @@ function detectProjectManifest(targetDir) {
3799
4277
  } catch {
3800
4278
  }
3801
4279
  }
3802
- const cargoPath = path4.join(targetDir, "Cargo.toml");
3803
- if (fs4.existsSync(cargoPath)) {
4280
+ const cargoPath = path5.join(targetDir, "Cargo.toml");
4281
+ if (fs5.existsSync(cargoPath)) {
3804
4282
  try {
3805
- const content = fs4.readFileSync(cargoPath, "utf-8");
4283
+ const content = fs5.readFileSync(cargoPath, "utf-8");
3806
4284
  const match = content.match(/\[package\][\s\S]*?name\s*=\s*["']([^"']+)["']/);
3807
4285
  if (match && match[1].trim()) {
3808
4286
  const rawName = match[1].trim();
@@ -3816,10 +4294,10 @@ function detectProjectManifest(targetDir) {
3816
4294
  } catch {
3817
4295
  }
3818
4296
  }
3819
- const pyprojectPath = path4.join(targetDir, "pyproject.toml");
3820
- if (fs4.existsSync(pyprojectPath)) {
4297
+ const pyprojectPath = path5.join(targetDir, "pyproject.toml");
4298
+ if (fs5.existsSync(pyprojectPath)) {
3821
4299
  try {
3822
- const content = fs4.readFileSync(pyprojectPath, "utf-8");
4300
+ const content = fs5.readFileSync(pyprojectPath, "utf-8");
3823
4301
  const match = content.match(/name\s*=\s*["']([^"']+)["']/);
3824
4302
  if (match && match[1].trim()) {
3825
4303
  const rawName = match[1].trim();
@@ -3833,10 +4311,10 @@ function detectProjectManifest(targetDir) {
3833
4311
  } catch {
3834
4312
  }
3835
4313
  }
3836
- const setupCfgPath = path4.join(targetDir, "setup.cfg");
3837
- if (fs4.existsSync(setupCfgPath)) {
4314
+ const setupCfgPath = path5.join(targetDir, "setup.cfg");
4315
+ if (fs5.existsSync(setupCfgPath)) {
3838
4316
  try {
3839
- const content = fs4.readFileSync(setupCfgPath, "utf-8");
4317
+ const content = fs5.readFileSync(setupCfgPath, "utf-8");
3840
4318
  const match = content.match(/^name\s*=\s*([^\r\n]+)/m);
3841
4319
  if (match && match[1].trim()) {
3842
4320
  const rawName = match[1].trim();
@@ -3850,10 +4328,10 @@ function detectProjectManifest(targetDir) {
3850
4328
  } catch {
3851
4329
  }
3852
4330
  }
3853
- const setupPyPath = path4.join(targetDir, "setup.py");
3854
- if (fs4.existsSync(setupPyPath)) {
4331
+ const setupPyPath = path5.join(targetDir, "setup.py");
4332
+ if (fs5.existsSync(setupPyPath)) {
3855
4333
  try {
3856
- const content = fs4.readFileSync(setupPyPath, "utf-8");
4334
+ const content = fs5.readFileSync(setupPyPath, "utf-8");
3857
4335
  const match = content.match(/name\s*=\s*["']([^"']+)["']/);
3858
4336
  if (match && match[1].trim()) {
3859
4337
  const rawName = match[1].trim();
@@ -3867,10 +4345,10 @@ function detectProjectManifest(targetDir) {
3867
4345
  } catch {
3868
4346
  }
3869
4347
  }
3870
- const goModPath = path4.join(targetDir, "go.mod");
3871
- if (fs4.existsSync(goModPath)) {
4348
+ const goModPath = path5.join(targetDir, "go.mod");
4349
+ if (fs5.existsSync(goModPath)) {
3872
4350
  try {
3873
- const content = fs4.readFileSync(goModPath, "utf-8");
4351
+ const content = fs5.readFileSync(goModPath, "utf-8");
3874
4352
  const match = content.match(/^module\s+([^\r\n\s]+)/m);
3875
4353
  if (match && match[1].trim()) {
3876
4354
  const modPath = match[1].trim();
@@ -3894,48 +4372,48 @@ function detectProjectManifest(targetDir) {
3894
4372
 
3895
4373
  // src/upgrade.ts
3896
4374
  import crypto2 from "node:crypto";
3897
- import fs6 from "node:fs";
3898
- import path6 from "node:path";
4375
+ import fs7 from "node:fs";
4376
+ import path7 from "node:path";
3899
4377
 
3900
4378
  // src/server/daemon.ts
3901
- import fs5 from "node:fs";
3902
- import path5 from "node:path";
4379
+ import fs6 from "node:fs";
4380
+ import path6 from "node:path";
3903
4381
  import os3 from "node:os";
3904
4382
  import http from "node:http";
3905
4383
  import { spawn, spawnSync } from "node:child_process";
3906
4384
  import { fileURLToPath } from "node:url";
3907
4385
  var __filename = fileURLToPath(import.meta.url);
3908
- var __dirname = path5.dirname(__filename);
4386
+ var __dirname = path6.dirname(__filename);
3909
4387
  function getGlobalEsedreDir() {
3910
4388
  if (process.env.ESEDRE_GLOBAL_DIR) {
3911
4389
  return process.env.ESEDRE_GLOBAL_DIR;
3912
4390
  }
3913
- return path5.join(os3.homedir(), ".esedre");
4391
+ return path6.join(os3.homedir(), ".esedre");
3914
4392
  }
3915
4393
  function ensureGlobalEsedreStore() {
3916
4394
  const globalDir = getGlobalEsedreDir();
3917
- const runDir = path5.join(globalDir, "run");
3918
- const logsDir = path5.join(globalDir, "logs");
4395
+ const runDir = path6.join(globalDir, "run");
4396
+ const logsDir = path6.join(globalDir, "logs");
3919
4397
  try {
3920
- fs5.mkdirSync(runDir, { recursive: true });
3921
- fs5.mkdirSync(logsDir, { recursive: true });
4398
+ fs6.mkdirSync(runDir, { recursive: true });
4399
+ fs6.mkdirSync(logsDir, { recursive: true });
3922
4400
  try {
3923
- const runEntries = fs5.readdirSync(runDir);
4401
+ const runEntries = fs6.readdirSync(runDir);
3924
4402
  for (const entry of runEntries) {
3925
4403
  if (entry.startsWith(".probe-")) {
3926
- fs5.unlinkSync(path5.join(runDir, entry));
4404
+ fs6.unlinkSync(path6.join(runDir, entry));
3927
4405
  }
3928
4406
  }
3929
4407
  } catch {
3930
4408
  }
3931
- const testProbe = path5.join(runDir, `.probe-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`);
4409
+ const testProbe = path6.join(runDir, `.probe-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`);
3932
4410
  try {
3933
- fs5.writeFileSync(testProbe, "ok", "utf-8");
4411
+ fs6.writeFileSync(testProbe, "ok", "utf-8");
3934
4412
  return { ok: true, path: globalDir };
3935
4413
  } finally {
3936
- if (fs5.existsSync(testProbe)) {
4414
+ if (fs6.existsSync(testProbe)) {
3937
4415
  try {
3938
- fs5.unlinkSync(testProbe);
4416
+ fs6.unlinkSync(testProbe);
3939
4417
  } catch {
3940
4418
  }
3941
4419
  }
@@ -3950,10 +4428,10 @@ function resolveStoreDir(workspaceRoot) {
3950
4428
  return globalStore.path;
3951
4429
  }
3952
4430
  if (workspaceRoot) {
3953
- const localStore = path5.join(workspaceRoot, ".esedre");
4431
+ const localStore = path6.join(workspaceRoot, ".esedre");
3954
4432
  try {
3955
- fs5.mkdirSync(path5.join(localStore, "run"), { recursive: true });
3956
- fs5.mkdirSync(path5.join(localStore, "logs"), { recursive: true });
4433
+ fs6.mkdirSync(path6.join(localStore, "run"), { recursive: true });
4434
+ fs6.mkdirSync(path6.join(localStore, "logs"), { recursive: true });
3957
4435
  return localStore;
3958
4436
  } catch {
3959
4437
  }
@@ -3962,32 +4440,32 @@ function resolveStoreDir(workspaceRoot) {
3962
4440
  }
3963
4441
  function getDaemonStateFile(port, workspaceRoot) {
3964
4442
  const store = resolveStoreDir(workspaceRoot);
3965
- return path5.join(store, "run", `daemon-${port}.json`);
4443
+ return path6.join(store, "run", `daemon-${port}.json`);
3966
4444
  }
3967
4445
  function getDaemonLogFile(port, workspaceRoot) {
3968
4446
  const store = resolveStoreDir(workspaceRoot);
3969
- return path5.join(store, "logs", `daemon-${port}.log`);
4447
+ return path6.join(store, "logs", `daemon-${port}.log`);
3970
4448
  }
3971
4449
  function getDaemonState(port, workspaceRoot) {
3972
4450
  const stateFile = getDaemonStateFile(port, workspaceRoot);
3973
- if (!fs5.existsSync(stateFile)) {
4451
+ if (!fs6.existsSync(stateFile)) {
3974
4452
  return null;
3975
4453
  }
3976
4454
  try {
3977
- const raw = fs5.readFileSync(stateFile, "utf-8").trim();
4455
+ const raw = fs6.readFileSync(stateFile, "utf-8").trim();
3978
4456
  if (!raw) {
3979
- fs5.unlinkSync(stateFile);
4457
+ fs6.unlinkSync(stateFile);
3980
4458
  return null;
3981
4459
  }
3982
4460
  const state = JSON.parse(raw);
3983
4461
  if (!state || typeof state.pid !== "number" || typeof state.port !== "number") {
3984
- fs5.unlinkSync(stateFile);
4462
+ fs6.unlinkSync(stateFile);
3985
4463
  return null;
3986
4464
  }
3987
4465
  return state;
3988
4466
  } catch {
3989
4467
  try {
3990
- fs5.unlinkSync(stateFile);
4468
+ fs6.unlinkSync(stateFile);
3991
4469
  } catch {
3992
4470
  }
3993
4471
  return null;
@@ -4058,7 +4536,7 @@ async function startDaemon(options = {}) {
4058
4536
  }
4059
4537
  }
4060
4538
  try {
4061
- fs5.unlinkSync(stateFile);
4539
+ fs6.unlinkSync(stateFile);
4062
4540
  } catch {
4063
4541
  }
4064
4542
  }
@@ -4077,24 +4555,40 @@ async function startDaemon(options = {}) {
4077
4555
  };
4078
4556
  }
4079
4557
  resolveStoreDir(workspaceRoot);
4080
- fs5.mkdirSync(path5.dirname(logFile), { recursive: true });
4081
- fs5.mkdirSync(path5.dirname(stateFile), { recursive: true });
4082
- const logFd = fs5.openSync(logFile, "a");
4083
- const isEsedreArgv1 = process.argv[1] && /esedre/i.test(path5.basename(process.argv[1]));
4558
+ fs6.mkdirSync(path6.dirname(logFile), { recursive: true });
4559
+ fs6.mkdirSync(path6.dirname(stateFile), { recursive: true });
4560
+ const MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024;
4561
+ try {
4562
+ if (fs6.existsSync(logFile)) {
4563
+ const stats = fs6.statSync(logFile);
4564
+ if (stats.size > MAX_LOG_SIZE_BYTES) {
4565
+ const oldLogFile = `${logFile}.old`;
4566
+ try {
4567
+ if (fs6.existsSync(oldLogFile)) fs6.unlinkSync(oldLogFile);
4568
+ fs6.renameSync(logFile, oldLogFile);
4569
+ } catch {
4570
+ fs6.truncateSync(logFile, 0);
4571
+ }
4572
+ }
4573
+ }
4574
+ } catch {
4575
+ }
4576
+ const logFd = fs6.openSync(logFile, "a");
4577
+ const isEsedreArgv1 = process.argv[1] && /esedre/i.test(path6.basename(process.argv[1]));
4084
4578
  const cliCandidates = [
4085
- isEsedreArgv1 ? path5.resolve(process.argv[1]) : void 0,
4086
- path5.resolve(workspaceRoot, "dist", "esedre.mjs"),
4087
- path5.resolve(workspaceRoot, "../esedre/dist", "esedre.mjs"),
4088
- path5.resolve(__dirname, "esedre.mjs"),
4089
- path5.resolve(__dirname, "..", "dist", "esedre.mjs"),
4090
- path5.resolve(__dirname, "..", "..", "dist", "esedre.mjs"),
4091
- path5.resolve(__dirname, "..", "..", "bin", "esedre.js"),
4092
- path5.resolve(__dirname, "..", "..", "bin", "esedre.ts"),
4093
- path5.resolve(__dirname, "..", "esedre.mjs"),
4094
- path5.resolve(__dirname, "../../../esedre/dist", "esedre.mjs")
4579
+ isEsedreArgv1 ? path6.resolve(process.argv[1]) : void 0,
4580
+ path6.resolve(workspaceRoot, "dist", "esedre.mjs"),
4581
+ path6.resolve(workspaceRoot, "../esedre/dist", "esedre.mjs"),
4582
+ path6.resolve(__dirname, "esedre.mjs"),
4583
+ path6.resolve(__dirname, "..", "dist", "esedre.mjs"),
4584
+ path6.resolve(__dirname, "..", "..", "dist", "esedre.mjs"),
4585
+ path6.resolve(__dirname, "..", "..", "bin", "esedre.js"),
4586
+ path6.resolve(__dirname, "..", "..", "bin", "esedre.ts"),
4587
+ path6.resolve(__dirname, "..", "esedre.mjs"),
4588
+ path6.resolve(__dirname, "../../../esedre/dist", "esedre.mjs")
4095
4589
  ].filter(Boolean);
4096
- const cliPath = cliCandidates.find((c) => fs5.existsSync(c) && (c.endsWith(".mjs") || c.endsWith(".js") || c.endsWith(".ts"))) || cliCandidates[0];
4097
- if (!fs5.existsSync(cliPath)) {
4590
+ const cliPath = cliCandidates.find((c) => fs6.existsSync(c) && (c.endsWith(".mjs") || c.endsWith(".js") || c.endsWith(".ts"))) || cliCandidates[0];
4591
+ if (!fs6.existsSync(cliPath)) {
4098
4592
  throw new Error(`Could not locate Esedre executable. Searched candidates: ${cliCandidates.join(", ")}`);
4099
4593
  }
4100
4594
  const child = spawn(process.execPath, [cliPath, "start", "--foreground", "--port", String(port)], {
@@ -4106,7 +4600,7 @@ async function startDaemon(options = {}) {
4106
4600
  });
4107
4601
  child.unref();
4108
4602
  try {
4109
- fs5.closeSync(logFd);
4603
+ fs6.closeSync(logFd);
4110
4604
  } catch {
4111
4605
  }
4112
4606
  const pid = child.pid;
@@ -4147,13 +4641,13 @@ async function startDaemon(options = {}) {
4147
4641
  logFile
4148
4642
  };
4149
4643
  const tmpState = `${stateFile}.tmp.${Date.now()}`;
4150
- fs5.writeFileSync(tmpState, JSON.stringify(state, null, 2) + "\n", "utf-8");
4644
+ fs6.writeFileSync(tmpState, JSON.stringify(state, null, 2) + "\n", "utf-8");
4151
4645
  try {
4152
- fs5.renameSync(tmpState, stateFile);
4646
+ fs6.renameSync(tmpState, stateFile);
4153
4647
  } catch {
4154
- fs5.writeFileSync(stateFile, JSON.stringify(state, null, 2) + "\n", "utf-8");
4648
+ fs6.writeFileSync(stateFile, JSON.stringify(state, null, 2) + "\n", "utf-8");
4155
4649
  try {
4156
- fs5.unlinkSync(tmpState);
4650
+ fs6.unlinkSync(tmpState);
4157
4651
  } catch {
4158
4652
  }
4159
4653
  }
@@ -4216,8 +4710,8 @@ async function stopDaemon(options = {}) {
4216
4710
  }
4217
4711
  }
4218
4712
  try {
4219
- if (fs5.existsSync(stateFile)) {
4220
- fs5.unlinkSync(stateFile);
4713
+ if (fs6.existsSync(stateFile)) {
4714
+ fs6.unlinkSync(stateFile);
4221
4715
  }
4222
4716
  } catch {
4223
4717
  }
@@ -4254,7 +4748,7 @@ async function getDaemonStatus(options = {}) {
4254
4748
  }
4255
4749
  if (state && !isProcessAlive(state.pid)) {
4256
4750
  try {
4257
- fs5.unlinkSync(stateFile);
4751
+ fs6.unlinkSync(stateFile);
4258
4752
  } catch {
4259
4753
  }
4260
4754
  }
@@ -4272,17 +4766,34 @@ function printDaemonLogs(options = {}) {
4272
4766
  const port = resolvePorts(void 0, { port: options.port }).gateway;
4273
4767
  const workspaceRoot = options.workspaceRoot || process.cwd();
4274
4768
  const logFile = getDaemonLogFile(port, workspaceRoot);
4275
- if (!fs5.existsSync(logFile)) {
4769
+ if (!fs6.existsSync(logFile)) {
4276
4770
  console.log(`\x1B[33mNo logs found at: ${logFile}\x1B[0m`);
4277
4771
  return;
4278
4772
  }
4279
- const content = fs5.readFileSync(logFile, "utf-8");
4280
4773
  const count = options.lines || 40;
4774
+ let content = "";
4775
+ try {
4776
+ const stats = fs6.statSync(logFile);
4777
+ const maxReadBytes = Math.min(stats.size, Math.max(256 * 1024, count * 2048));
4778
+ if (stats.size <= maxReadBytes) {
4779
+ content = fs6.readFileSync(logFile, "utf-8");
4780
+ } else {
4781
+ const fd = fs6.openSync(logFile, "r");
4782
+ const buffer = Buffer.alloc(maxReadBytes);
4783
+ fs6.readSync(fd, buffer, 0, maxReadBytes, stats.size - maxReadBytes);
4784
+ fs6.closeSync(fd);
4785
+ content = buffer.toString("utf-8");
4786
+ }
4787
+ } catch {
4788
+ content = fs6.readFileSync(logFile, "utf-8");
4789
+ }
4281
4790
  const allLines = content.split(/\r?\n/);
4282
- const tail = allLines.slice(-count).join("\n");
4791
+ const sanitizedLines = allLines.slice(-count).map((line) => {
4792
+ return line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "").replace(/\r(?!\n)/g, "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
4793
+ });
4283
4794
  console.log(`\x1B[36m--- Esedre Daemon Logs (${logFile}) ---\x1B[0m
4284
4795
  `);
4285
- console.log(tail);
4796
+ console.log(sanitizedLines.join("\n"));
4286
4797
  }
4287
4798
 
4288
4799
  // src/upgrade.ts
@@ -4489,13 +5000,13 @@ When Esedre MCP is active in your agent session (\`.agents/mcp_config.json\`), u
4489
5000
 
4490
5001
  | Tool | Purpose | Key Arguments |
4491
5002
  |---|---|---|
4492
- | \`esedre_list_tickets\` | List roadmap tickets with filters | \`project\` (e.g. \`Profe\`, \`Esedre\`, \`Alce\`), \`status\`, \`type\`, \`search\` |
5003
+ | \`esedre_list_tickets\` | List roadmap tickets with filters | \`project\` (e.g. \`Profe\`, \`Esedre\`, \`Alce\`), \`status\`, \`type\`, \`priority\`, \`milestone\`, \`isBlocked\`, \`linkedTo\`, \`search\`, \`includePlan\` |
4493
5004
  | \`esedre_get_ticket\` | Full specification, summary, comments, revision & sha1 | \`ticketId\` (numeric e.g. \`96\` or compound e.g. \`Profe-96\`) |
4494
5005
  | \`esedre_get_plan\` | Active implementation plan markdown | \`ticketId\` (numeric or compound) |
4495
5006
  | \`esedre_save_plan\` | Save implementation plan markdown with OCC | \`ticketId\`, \`planMarkdown\`, \`lastHash\` |
4496
5007
  | \`esedre_create_ticket\` | Mint a new ticket with auto sequential ID | \`title\` (max 48 chars), \`type\`, \`project\`, \`effort\`, \`summary\`, \`detail\` |
4497
5008
  | \`esedre_update_ticket\` | Update ticket attributes with OCC | \`ticketId\`, \`status\`, \`type\`, \`title\`, \`complexity\`, \`effort\`, \`inDevelopment\`, \`featureFlag\`, \`lastHash\` |
4498
- | \`esedre_add_comment\` | Append developer or agent comment | \`ticketId\`, \`text\`, \`author\` |
5009
+ | \`esedre_add_comment\` | Append developer or agent comment | \`ticketId\`, \`text\`, \`file\`, \`author\` |
4499
5010
 
4500
5011
  ## 2. Core CLI Commands (Fallback via \`run_command\`)
4501
5012
 
@@ -4533,6 +5044,7 @@ Both \`esedre\` and \`ese\` work interchangeably:
4533
5044
  ### Append Comments or Notes
4534
5045
  \`\`\`bash
4535
5046
  .esedre/ese comment <ticketId> "Verified implementation." --author "Agent"
5047
+ .esedre/ese comment <ticketId> --file <commentFilePath> --author "Agent"
4536
5048
  .esedre/ese comment <ticketId> --text "Verified implementation." --author "Agent"
4537
5049
  \`\`\`
4538
5050
 
@@ -4588,8 +5100,8 @@ If the host application declares any or all of the 12 core design tokens on \`:r
4588
5100
  - The URL hash is preserved across hard page refreshes (F5) without falling back to \`#/chat\` or other views.
4589
5101
  `;
4590
5102
  function plantWrappers(targetDir) {
4591
- const esedreDir = path6.join(targetDir, ".esedre");
4592
- fs6.mkdirSync(esedreDir, { recursive: true });
5103
+ const esedreDir = path7.join(targetDir, ".esedre");
5104
+ fs7.mkdirSync(esedreDir, { recursive: true });
4593
5105
  const wrappers = [
4594
5106
  { name: "esedre.cmd", content: WRAPPER_CMD, exec: false },
4595
5107
  { name: "esedre.ps1", content: WRAPPER_PS1, exec: false },
@@ -4599,24 +5111,24 @@ function plantWrappers(targetDir) {
4599
5111
  { name: "ese", content: ESE_WRAPPER_BASH, exec: true }
4600
5112
  ];
4601
5113
  for (const w of wrappers) {
4602
- const fullPath = path6.join(esedreDir, w.name);
4603
- fs6.writeFileSync(fullPath, w.content, "utf-8");
5114
+ const fullPath = path7.join(esedreDir, w.name);
5115
+ fs7.writeFileSync(fullPath, w.content, "utf-8");
4604
5116
  if (w.exec) {
4605
5117
  try {
4606
- fs6.chmodSync(fullPath, 493);
5118
+ fs7.chmodSync(fullPath, 493);
4607
5119
  } catch {
4608
5120
  }
4609
5121
  }
4610
5122
  }
4611
5123
  }
4612
5124
  function setupMcpConfig(targetDir) {
4613
- const agentsDir = path6.join(targetDir, ".agents");
4614
- fs6.mkdirSync(agentsDir, { recursive: true });
4615
- const mcpConfigPath = path6.join(agentsDir, "mcp_config.json");
5125
+ const agentsDir = path7.join(targetDir, ".agents");
5126
+ fs7.mkdirSync(agentsDir, { recursive: true });
5127
+ const mcpConfigPath = path7.join(agentsDir, "mcp_config.json");
4616
5128
  let configObj = { mcpServers: {} };
4617
- if (fs6.existsSync(mcpConfigPath)) {
5129
+ if (fs7.existsSync(mcpConfigPath)) {
4618
5130
  try {
4619
- configObj = JSON.parse(fs6.readFileSync(mcpConfigPath, "utf-8"));
5131
+ configObj = JSON.parse(fs7.readFileSync(mcpConfigPath, "utf-8"));
4620
5132
  if (!configObj.mcpServers) configObj.mcpServers = {};
4621
5133
  } catch {
4622
5134
  }
@@ -4633,28 +5145,28 @@ function setupMcpConfig(targetDir) {
4633
5145
  };
4634
5146
  }
4635
5147
  try {
4636
- fs6.writeFileSync(mcpConfigPath, JSON.stringify(configObj, null, 2) + "\n", "utf-8");
5148
+ fs7.writeFileSync(mcpConfigPath, JSON.stringify(configObj, null, 2) + "\n", "utf-8");
4637
5149
  return true;
4638
5150
  } catch {
4639
5151
  return false;
4640
5152
  }
4641
5153
  }
4642
5154
  function setupSkill(targetDir, options = {}) {
4643
- const skillDir = path6.join(targetDir, ".agents", "skills", "esedre");
4644
- fs6.mkdirSync(skillDir, { recursive: true });
4645
- const skillFile = path6.join(skillDir, "SKILL.md");
4646
- if (!fs6.existsSync(skillFile)) {
4647
- fs6.writeFileSync(skillFile, ESEDRE_SKILL_TEMPLATE, "utf-8");
5155
+ const skillDir = path7.join(targetDir, ".agents", "skills", "esedre");
5156
+ fs7.mkdirSync(skillDir, { recursive: true });
5157
+ const skillFile = path7.join(skillDir, "SKILL.md");
5158
+ if (!fs7.existsSync(skillFile)) {
5159
+ fs7.writeFileSync(skillFile, ESEDRE_SKILL_TEMPLATE, "utf-8");
4648
5160
  return { status: "MISSING", updated: true };
4649
5161
  }
4650
- const currentContent = fs6.readFileSync(skillFile, "utf-8");
5162
+ const currentContent = fs7.readFileSync(skillFile, "utf-8");
4651
5163
  const status = classifyContent(currentContent, ESEDRE_SKILL_TEMPLATE, HISTORIC_SKILL_HASHES);
4652
5164
  if (status === "LATEST" && !options.force) {
4653
5165
  return { status, updated: false };
4654
5166
  }
4655
5167
  const hasBannedOrDeprecatedTerms = currentContent.includes("companion agent") || currentContent.includes("companion LLM") || currentContent.includes("tools/esedre.mjs");
4656
5168
  if (options.force || status === "HISTORIC_DEFAULT" || status === "MISSING" || hasBannedOrDeprecatedTerms) {
4657
- fs6.writeFileSync(skillFile, ESEDRE_SKILL_TEMPLATE, "utf-8");
5169
+ fs7.writeFileSync(skillFile, ESEDRE_SKILL_TEMPLATE, "utf-8");
4658
5170
  return { status: "LATEST", updated: true };
4659
5171
  }
4660
5172
  return { status, updated: false };
@@ -4668,8 +5180,8 @@ var VITE_CONFIG_FILES = [
4668
5180
  ];
4669
5181
  function findViteConfig(targetDir) {
4670
5182
  for (const name of VITE_CONFIG_FILES) {
4671
- const fullPath = path6.join(targetDir, name);
4672
- if (fs6.existsSync(fullPath)) {
5183
+ const fullPath = path7.join(targetDir, name);
5184
+ if (fs7.existsSync(fullPath)) {
4673
5185
  return fullPath;
4674
5186
  }
4675
5187
  }
@@ -4733,10 +5245,10 @@ function configureViteProxy(targetDir, port = DEFAULT_ESEDRE_PORT, setupProxy) {
4733
5245
  recommendedProxySnippet: snippet
4734
5246
  };
4735
5247
  }
4736
- const relativeName = path6.basename(configPath);
5248
+ const relativeName = path7.basename(configPath);
4737
5249
  let content = "";
4738
5250
  try {
4739
- content = fs6.readFileSync(configPath, "utf-8");
5251
+ content = fs7.readFileSync(configPath, "utf-8");
4740
5252
  } catch {
4741
5253
  return {
4742
5254
  status: "MANUAL_REQUIRED",
@@ -4767,7 +5279,7 @@ function configureViteProxy(targetDir, port = DEFAULT_ESEDRE_PORT, setupProxy) {
4767
5279
  };
4768
5280
  }
4769
5281
  try {
4770
- fs6.writeFileSync(configPath, injected, "utf-8");
5282
+ fs7.writeFileSync(configPath, injected, "utf-8");
4771
5283
  return {
4772
5284
  status: "CONFIGURED",
4773
5285
  configFile: relativeName,
@@ -4782,15 +5294,15 @@ function configureViteProxy(targetDir, port = DEFAULT_ESEDRE_PORT, setupProxy) {
4782
5294
  }
4783
5295
  }
4784
5296
  function configureWorkspace(targetDir, options = {}) {
4785
- const esedreDir = path6.join(targetDir, ".esedre");
4786
- if (!fs6.existsSync(esedreDir)) {
4787
- fs6.mkdirSync(esedreDir, { recursive: true });
5297
+ const esedreDir = path7.join(targetDir, ".esedre");
5298
+ if (!fs7.existsSync(esedreDir)) {
5299
+ fs7.mkdirSync(esedreDir, { recursive: true });
4788
5300
  }
4789
- const esedreJsonPath = path6.join(esedreDir, "esedre.json");
5301
+ const esedreJsonPath = path7.join(esedreDir, "esedre.json");
4790
5302
  let existingRaw = null;
4791
- if (fs6.existsSync(esedreJsonPath)) {
5303
+ if (fs7.existsSync(esedreJsonPath)) {
4792
5304
  try {
4793
- existingRaw = JSON.parse(fs6.readFileSync(esedreJsonPath, "utf-8"));
5305
+ existingRaw = JSON.parse(fs7.readFileSync(esedreJsonPath, "utf-8"));
4794
5306
  } catch {
4795
5307
  }
4796
5308
  }
@@ -4826,16 +5338,16 @@ function configureWorkspace(targetDir, options = {}) {
4826
5338
  port: options.port || void 0
4827
5339
  };
4828
5340
  }
4829
- fs6.writeFileSync(esedreJsonPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
5341
+ fs7.writeFileSync(esedreJsonPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
4830
5342
  let projectRegistered = false;
4831
5343
  if (projectCode) {
4832
- const hasExistingTicketsDir = fs6.existsSync(path6.join(targetDir, "tickets")) || fs6.existsSync(path6.join(targetDir, "src", "data", "planning", "tickets"));
4833
- const ticketsDir = path6.join(esedreDir, "tickets");
4834
- if (!hasExistingTicketsDir && !fs6.existsSync(ticketsDir)) {
4835
- fs6.mkdirSync(ticketsDir, { recursive: true });
5344
+ const hasExistingTicketsDir = fs7.existsSync(path7.join(targetDir, "tickets")) || fs7.existsSync(path7.join(targetDir, "src", "data", "planning", "tickets"));
5345
+ const ticketsDir = path7.join(esedreDir, "tickets");
5346
+ if (!hasExistingTicketsDir && !fs7.existsSync(ticketsDir)) {
5347
+ fs7.mkdirSync(ticketsDir, { recursive: true });
4836
5348
  }
4837
- const inRepoPJson = path6.join(esedreDir, "project.json");
4838
- if (!fs6.existsSync(inRepoPJson)) {
5349
+ const inRepoPJson = path7.join(esedreDir, "project.json");
5350
+ if (!fs7.existsSync(inRepoPJson)) {
4839
5351
  const projDesc = {
4840
5352
  id: 1,
4841
5353
  code: projectCode.toUpperCase(),
@@ -4847,14 +5359,14 @@ function configureWorkspace(targetDir, options = {}) {
4847
5359
  border: "border-cyan-500/40"
4848
5360
  }
4849
5361
  };
4850
- fs6.writeFileSync(inRepoPJson, JSON.stringify(projDesc, null, 2) + "\n", "utf-8");
5362
+ fs7.writeFileSync(inRepoPJson, JSON.stringify(projDesc, null, 2) + "\n", "utf-8");
4851
5363
  projectRegistered = true;
4852
5364
  } else if (projectName) {
4853
5365
  try {
4854
- const existing = JSON.parse(fs6.readFileSync(inRepoPJson, "utf-8"));
5366
+ const existing = JSON.parse(fs7.readFileSync(inRepoPJson, "utf-8"));
4855
5367
  if (existing.name !== projectName) {
4856
5368
  existing.name = projectName;
4857
- fs6.writeFileSync(inRepoPJson, JSON.stringify(existing, null, 2) + "\n", "utf-8");
5369
+ fs7.writeFileSync(inRepoPJson, JSON.stringify(existing, null, 2) + "\n", "utf-8");
4858
5370
  }
4859
5371
  } catch {
4860
5372
  }
@@ -4904,26 +5416,26 @@ var ESEDRE_HUB_AGENTS_TEMPLATE = `# Agent Guidelines: esedre-data
4904
5416
  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.
4905
5417
  `;
4906
5418
  function initHub(targetDir) {
4907
- const resolved = path6.resolve(targetDir);
4908
- if (!fs6.existsSync(resolved)) {
4909
- fs6.mkdirSync(resolved, { recursive: true });
5419
+ const resolved = path7.resolve(targetDir);
5420
+ if (!fs7.existsSync(resolved)) {
5421
+ fs7.mkdirSync(resolved, { recursive: true });
4910
5422
  }
4911
- const projectsDir = path6.join(resolved, "projects");
5423
+ const projectsDir = path7.join(resolved, "projects");
4912
5424
  let projectsDirCreated = false;
4913
- if (!fs6.existsSync(projectsDir)) {
4914
- fs6.mkdirSync(projectsDir, { recursive: true });
5425
+ if (!fs7.existsSync(projectsDir)) {
5426
+ fs7.mkdirSync(projectsDir, { recursive: true });
4915
5427
  projectsDirCreated = true;
4916
5428
  }
4917
- const projectsJsonPath = path6.join(resolved, "projects.json");
5429
+ const projectsJsonPath = path7.join(resolved, "projects.json");
4918
5430
  let projectsJsonCreated = false;
4919
- if (!fs6.existsSync(projectsJsonPath)) {
4920
- fs6.writeFileSync(projectsJsonPath, "[]\n", "utf-8");
5431
+ if (!fs7.existsSync(projectsJsonPath)) {
5432
+ fs7.writeFileSync(projectsJsonPath, "[]\n", "utf-8");
4921
5433
  projectsJsonCreated = true;
4922
5434
  }
4923
- const agentsMdPath = path6.join(resolved, "AGENTS.md");
5435
+ const agentsMdPath = path7.join(resolved, "AGENTS.md");
4924
5436
  let agentsMdCreated = false;
4925
- if (!fs6.existsSync(agentsMdPath)) {
4926
- fs6.writeFileSync(agentsMdPath, ESEDRE_HUB_AGENTS_TEMPLATE, "utf-8");
5437
+ if (!fs7.existsSync(agentsMdPath)) {
5438
+ fs7.writeFileSync(agentsMdPath, ESEDRE_HUB_AGENTS_TEMPLATE, "utf-8");
4927
5439
  agentsMdCreated = true;
4928
5440
  }
4929
5441
  return {
@@ -4933,17 +5445,126 @@ function initHub(targetDir) {
4933
5445
  hubDir: resolved.replace(/\\/g, "/")
4934
5446
  };
4935
5447
  }
5448
+ function isWrapperOutdated(esedreCmdContent) {
5449
+ if (!esedreCmdContent || typeof esedreCmdContent !== "string") {
5450
+ return true;
5451
+ }
5452
+ if (/goto\s+:?done\b/i.test(esedreCmdContent)) {
5453
+ return true;
5454
+ }
5455
+ if (/^:done\b/im.test(esedreCmdContent)) {
5456
+ return true;
5457
+ }
5458
+ if (!esedreCmdContent.includes("call :run %*") || !/^:run\b/im.test(esedreCmdContent)) {
5459
+ return true;
5460
+ }
5461
+ return false;
5462
+ }
5463
+ function findOutdatedWrappers(currentWorkspaceRoot, globalConfig) {
5464
+ const outdated = [];
5465
+ const checkedDirs = /* @__PURE__ */ new Set();
5466
+ const checkDir = (dir, name) => {
5467
+ const resolved = path7.resolve(expandHome(dir));
5468
+ if (checkedDirs.has(resolved.toLowerCase())) return;
5469
+ checkedDirs.add(resolved.toLowerCase());
5470
+ if (!fs7.existsSync(resolved)) return;
5471
+ const cmdPath = path7.join(resolved, ".esedre", "esedre.cmd");
5472
+ if (fs7.existsSync(cmdPath)) {
5473
+ try {
5474
+ const content = fs7.readFileSync(cmdPath, "utf-8");
5475
+ if (isWrapperOutdated(content)) {
5476
+ outdated.push({ name, dir: resolved, wrapperPath: cmdPath });
5477
+ }
5478
+ } catch {
5479
+ }
5480
+ }
5481
+ };
5482
+ if (currentWorkspaceRoot) {
5483
+ checkDir(currentWorkspaceRoot, path7.basename(currentWorkspaceRoot));
5484
+ }
5485
+ if (globalConfig?.projects) {
5486
+ for (const [code, p] of Object.entries(globalConfig.projects)) {
5487
+ checkDir(p, code);
5488
+ }
5489
+ }
5490
+ return outdated;
5491
+ }
5492
+ async function upgradeAllWorkspaces(options = {}) {
5493
+ const globalCfg = options.globalConfig !== void 0 ? options.globalConfig : readGlobalConfig();
5494
+ const workspaceMap = /* @__PURE__ */ new Map();
5495
+ if (options.currentWorkspaceRoot && fs7.existsSync(options.currentWorkspaceRoot)) {
5496
+ const hasEsedre = fs7.existsSync(path7.join(options.currentWorkspaceRoot, ".esedre")) || fs7.existsSync(path7.join(options.currentWorkspaceRoot, "esedre.json"));
5497
+ if (hasEsedre) {
5498
+ const disc = findEsedreConfig(options.currentWorkspaceRoot, { fallbackToGlobal: true });
5499
+ workspaceMap.set(
5500
+ path7.resolve(options.currentWorkspaceRoot),
5501
+ disc.config?.projectCode || path7.basename(options.currentWorkspaceRoot)
5502
+ );
5503
+ }
5504
+ }
5505
+ if (globalCfg?.projects) {
5506
+ for (const [code, p] of Object.entries(globalCfg.projects)) {
5507
+ const resolved = path7.resolve(expandHome(p));
5508
+ if (fs7.existsSync(resolved)) {
5509
+ if (!workspaceMap.has(resolved)) {
5510
+ workspaceMap.set(resolved, code);
5511
+ }
5512
+ }
5513
+ }
5514
+ }
5515
+ const results = [];
5516
+ for (const [targetDir, name] of workspaceMap.entries()) {
5517
+ try {
5518
+ const targetDiscovered = findEsedreConfig(targetDir, { fallbackToGlobal: true });
5519
+ const projectCode = targetDiscovered.config?.projectCode;
5520
+ const res = configureWorkspace(targetDir, {
5521
+ projectCode,
5522
+ allowedProjects: targetDiscovered.config?.allowedProjects,
5523
+ port: targetDiscovered.config?.port,
5524
+ force: options.force
5525
+ });
5526
+ let totalTickets;
5527
+ if (projectCode) {
5528
+ try {
5529
+ const targetStorage = new SecurityFilter(
5530
+ new FilesystemStorageAdapter(targetDir, targetDiscovered.config),
5531
+ targetDiscovered.config
5532
+ );
5533
+ const snapshot = await generateProjectSnapshot(targetStorage, projectCode, targetDir);
5534
+ totalTickets = snapshot.totalTickets;
5535
+ } catch {
5536
+ }
5537
+ }
5538
+ results.push({
5539
+ dir: targetDir,
5540
+ name,
5541
+ projectCode,
5542
+ upgrade: res,
5543
+ totalTickets
5544
+ });
5545
+ } catch (err) {
5546
+ results.push({
5547
+ dir: targetDir,
5548
+ name,
5549
+ upgrade: {},
5550
+ error: err.message || String(err)
5551
+ });
5552
+ }
5553
+ }
5554
+ const totalUpgraded = results.filter((r) => !r.error && r.upgrade.wrappersPlanted).length;
5555
+ return { workspaces: results, totalUpgraded };
5556
+ }
4936
5557
 
4937
5558
  // src/server/gateway.ts
4938
- import fs8 from "node:fs";
5559
+ import fs10 from "node:fs";
4939
5560
  import http4 from "node:http";
4940
- import path8 from "node:path";
5561
+ import path10 from "node:path";
4941
5562
  import { fileURLToPath as fileURLToPath2 } from "node:url";
4942
5563
 
4943
5564
  // src/server/uiServer.ts
4944
5565
  import http2 from "node:http";
4945
- import fs7 from "node:fs";
4946
- import path7 from "node:path";
5566
+ import fs8 from "node:fs";
5567
+ import path8 from "node:path";
4947
5568
  var MIME_TYPES = {
4948
5569
  ".html": "text/html; charset=utf-8",
4949
5570
  ".js": "application/javascript; charset=utf-8",
@@ -4973,20 +5594,27 @@ function startUiServer(port, webDir) {
4973
5594
  if (!reqPath || reqPath === "/") {
4974
5595
  reqPath = "/index.html";
4975
5596
  }
4976
- const safePath = path7.normalize(reqPath).replace(/^(\.\.[/\\])+/, "");
4977
- let filePath = path7.join(webDir, safePath);
4978
- if (!fs7.existsSync(filePath) || fs7.statSync(filePath).isDirectory()) {
4979
- filePath = path7.join(webDir, "index.html");
5597
+ const safePath = path8.normalize(reqPath).replace(/^(\.\.[/\\])+/, "");
5598
+ let filePath = path8.join(webDir, safePath);
5599
+ const resolvedFilePath = path8.resolve(filePath);
5600
+ const resolvedWebDir = path8.resolve(webDir);
5601
+ if (!resolvedFilePath.startsWith(resolvedWebDir)) {
5602
+ res.writeHead(403, { "Content-Type": "text/plain" });
5603
+ res.end("Access Denied: Path escapes web root.");
5604
+ return;
5605
+ }
5606
+ if (!fs8.existsSync(filePath) || fs8.statSync(filePath).isDirectory()) {
5607
+ filePath = path8.join(webDir, "index.html");
4980
5608
  }
4981
- if (!fs7.existsSync(filePath)) {
5609
+ if (!fs8.existsSync(filePath)) {
4982
5610
  res.writeHead(404, { "Content-Type": "text/plain" });
4983
5611
  res.end("Esedre UI: index.html not found. Run npm run build first.");
4984
5612
  return;
4985
5613
  }
4986
- const ext = path7.extname(filePath).toLowerCase();
5614
+ const ext = path8.extname(filePath).toLowerCase();
4987
5615
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
4988
5616
  try {
4989
- const content = fs7.readFileSync(filePath);
5617
+ const content = fs8.readFileSync(filePath);
4990
5618
  res.writeHead(200, { "Content-Type": contentType });
4991
5619
  res.end(content);
4992
5620
  } catch (err) {
@@ -5008,6 +5636,8 @@ function startUiServer(port, webDir) {
5008
5636
 
5009
5637
  // src/server/apiServer.ts
5010
5638
  import http3 from "node:http";
5639
+ import fs9 from "node:fs";
5640
+ import path9 from "node:path";
5011
5641
  var MAX_JSON_BODY_BYTES = 10 * 1024 * 1024;
5012
5642
  function readJsonBody(req) {
5013
5643
  return new Promise((resolve, reject) => {
@@ -5043,6 +5673,7 @@ function sendJson(res, status, data) {
5043
5673
  }
5044
5674
  var cachedAllData = /* @__PURE__ */ new Map();
5045
5675
  var CACHE_TTL_MS = 5e3;
5676
+ var MAX_CACHE_ENTRIES = 30;
5046
5677
  function invalidateApiCache() {
5047
5678
  cachedAllData.clear();
5048
5679
  }
@@ -5142,6 +5773,8 @@ function createApiHandler(storage, workspaceRoot) {
5142
5773
  const detailsMap = {};
5143
5774
  const plansMap = {};
5144
5775
  const commentsMap = {};
5776
+ const answersMap = {};
5777
+ const inlineCommentsMap = {};
5145
5778
  for (const t of tickets) {
5146
5779
  const numKey = String(t.meta.id);
5147
5780
  const code = t.projectDescriptor?.code || t.meta.project || "Profe";
@@ -5167,6 +5800,18 @@ function createApiHandler(storage, workspaceRoot) {
5167
5800
  plansMap[numKey] = t.planMarkdown;
5168
5801
  }
5169
5802
  }
5803
+ if (t.answers && Object.keys(t.answers).length > 0) {
5804
+ answersMap[prefixedKey] = t.answers;
5805
+ if (!answersMap[numKey] || code.toUpperCase() === "PROF") {
5806
+ answersMap[numKey] = t.answers;
5807
+ }
5808
+ }
5809
+ if (t.inlineComments && t.inlineComments.length > 0) {
5810
+ inlineCommentsMap[prefixedKey] = t.inlineComments;
5811
+ if (!inlineCommentsMap[numKey] || code.toUpperCase() === "PROF") {
5812
+ inlineCommentsMap[numKey] = t.inlineComments;
5813
+ }
5814
+ }
5170
5815
  }
5171
5816
  const projects = await reqStorage.getProjects();
5172
5817
  const responsePayload = {
@@ -5176,11 +5821,15 @@ function createApiHandler(storage, workspaceRoot) {
5176
5821
  details: detailsMap,
5177
5822
  plans: plansMap,
5178
5823
  comments: commentsMap,
5179
- answers: {},
5180
- inlineComments: {},
5824
+ answers: answersMap,
5825
+ inlineComments: inlineCommentsMap,
5181
5826
  planHistory: {},
5182
5827
  ticketHistory: {}
5183
5828
  };
5829
+ if (cachedAllData.size >= MAX_CACHE_ENTRIES) {
5830
+ const oldestKey = cachedAllData.keys().next().value;
5831
+ if (oldestKey) cachedAllData.delete(oldestKey);
5832
+ }
5184
5833
  cachedAllData.set(cacheKey, { data: responsePayload, timestamp: now });
5185
5834
  sendJson(res, 200, responsePayload);
5186
5835
  return true;
@@ -5230,11 +5879,88 @@ function createApiHandler(storage, workspaceRoot) {
5230
5879
  return true;
5231
5880
  }
5232
5881
  if (pathname === "/api/planning/inline-comments") {
5233
- sendJson(res, 200, {});
5882
+ const ticketParam = url.searchParams.get("ticketId") || url.searchParams.get("id");
5883
+ if (ticketParam) {
5884
+ const inlines = reqStorage.getInlineComments ? await reqStorage.getInlineComments(ticketParam) : [];
5885
+ sendJson(res, 200, inlines);
5886
+ return true;
5887
+ }
5888
+ const tickets = await reqStorage.listTickets({});
5889
+ const inlinesMap = {};
5890
+ for (const t of tickets) {
5891
+ if (t.inlineComments && t.inlineComments.length > 0) {
5892
+ inlinesMap[String(t.meta.id)] = t.inlineComments;
5893
+ const code = t.projectDescriptor?.code || t.meta.project;
5894
+ if (code) {
5895
+ inlinesMap[`${code}-${t.meta.id}`] = t.inlineComments;
5896
+ }
5897
+ }
5898
+ }
5899
+ sendJson(res, 200, inlinesMap);
5234
5900
  return true;
5235
5901
  }
5236
5902
  if (pathname === "/api/planning/answers") {
5237
- sendJson(res, 200, {});
5903
+ const ticketParam = url.searchParams.get("ticketId") || url.searchParams.get("id");
5904
+ if (ticketParam) {
5905
+ const answers = await reqStorage.getAnswers(ticketParam);
5906
+ sendJson(res, 200, answers);
5907
+ return true;
5908
+ }
5909
+ const tickets = await reqStorage.listTickets({});
5910
+ const answersMap = {};
5911
+ for (const t of tickets) {
5912
+ if (t.answers && Object.keys(t.answers).length > 0) {
5913
+ answersMap[String(t.meta.id)] = t.answers;
5914
+ const code = t.projectDescriptor?.code || t.meta.project;
5915
+ if (code) {
5916
+ answersMap[`${code}-${t.meta.id}`] = t.answers;
5917
+ }
5918
+ }
5919
+ }
5920
+ sendJson(res, 200, answersMap);
5921
+ return true;
5922
+ }
5923
+ if (pathname === "/api/planning/attachment") {
5924
+ const ticketId = url.searchParams.get("ticketId") || url.searchParams.get("id");
5925
+ const file = url.searchParams.get("file");
5926
+ if (!ticketId || !file) {
5927
+ sendJson(res, 400, { error: "ticketId and file parameters are required." });
5928
+ return true;
5929
+ }
5930
+ const ticket = await reqStorage.getTicket(ticketId);
5931
+ if (!ticket) {
5932
+ sendJson(res, 404, { error: "Ticket not found." });
5933
+ return true;
5934
+ }
5935
+ const filePath = reqStorage.getAttachmentPath ? reqStorage.getAttachmentPath(ticketId, file) : null;
5936
+ if (!filePath || !fs9.existsSync(filePath)) {
5937
+ sendJson(res, 404, { error: "Attachment not found." });
5938
+ return true;
5939
+ }
5940
+ const stat = fs9.statSync(filePath);
5941
+ if (!stat.isFile()) {
5942
+ sendJson(res, 404, { error: "Attachment not found." });
5943
+ return true;
5944
+ }
5945
+ const ext = path9.extname(filePath).toLowerCase();
5946
+ const mimeTypes = {
5947
+ ".png": "image/png",
5948
+ ".jpg": "image/jpeg",
5949
+ ".jpeg": "image/jpeg",
5950
+ ".gif": "image/gif",
5951
+ ".webp": "image/webp",
5952
+ ".svg": "image/svg+xml",
5953
+ ".mp4": "video/mp4",
5954
+ ".webm": "video/webm",
5955
+ ".mov": "video/quicktime"
5956
+ };
5957
+ const contentType = mimeTypes[ext] || "application/octet-stream";
5958
+ res.writeHead(200, {
5959
+ "Content-Type": contentType,
5960
+ "Content-Length": stat.size,
5961
+ "Cache-Control": "public, max-age=86400"
5962
+ });
5963
+ fs9.createReadStream(filePath).pipe(res);
5238
5964
  return true;
5239
5965
  }
5240
5966
  if (pathname === "/api/planning/plan-history") {
@@ -5348,6 +6074,10 @@ function createApiHandler(storage, workspaceRoot) {
5348
6074
  }
5349
6075
  if (pathname === "/api/planning/plans") {
5350
6076
  const { ticketId, planMarkdown, lastHash } = body;
6077
+ if (!ticketId) {
6078
+ sendJson(res, 400, { error: "ticketId is required." });
6079
+ return true;
6080
+ }
5351
6081
  invalidateApiCache();
5352
6082
  await reqStorage.savePlan(ticketId, planMarkdown, lastHash);
5353
6083
  sendJson(res, 200, { success: true });
@@ -5355,6 +6085,10 @@ function createApiHandler(storage, workspaceRoot) {
5355
6085
  }
5356
6086
  if (pathname === "/api/planning/comments") {
5357
6087
  const { ticketId, text, author } = body;
6088
+ if (!ticketId) {
6089
+ sendJson(res, 400, { error: "ticketId is required." });
6090
+ return true;
6091
+ }
5358
6092
  invalidateApiCache();
5359
6093
  await reqStorage.addComment(ticketId, { author: author || "Developer", text });
5360
6094
  const ticket = await reqStorage.getTicket(ticketId);
@@ -5363,12 +6097,22 @@ function createApiHandler(storage, workspaceRoot) {
5363
6097
  }
5364
6098
  if (pathname === "/api/planning/update-meta") {
5365
6099
  const { ticketId, updates, lastHash } = body;
6100
+ if (!ticketId) {
6101
+ sendJson(res, 400, { error: "ticketId is required." });
6102
+ return true;
6103
+ }
6104
+ invalidateApiCache();
5366
6105
  const updated = await reqStorage.updateTicket(ticketId, updates, lastHash);
5367
6106
  sendJson(res, 200, { meta: updated.meta });
5368
6107
  return true;
5369
6108
  }
5370
6109
  if (pathname === "/api/planning/toggle-flag") {
5371
6110
  const { ticketId, flagged } = body;
6111
+ if (!ticketId) {
6112
+ sendJson(res, 400, { error: "ticketId is required." });
6113
+ return true;
6114
+ }
6115
+ invalidateApiCache();
5372
6116
  const updated = await reqStorage.updateTicket(ticketId, {
5373
6117
  featureFlag: flagged ? "chat_enhanced" : ""
5374
6118
  });
@@ -5377,10 +6121,56 @@ function createApiHandler(storage, workspaceRoot) {
5377
6121
  }
5378
6122
  if (pathname === "/api/planning/details") {
5379
6123
  const { ticketId, detailMarkdown, metaUpdates } = body;
5380
- if (metaUpdates) {
5381
- await reqStorage.updateTicket(ticketId, metaUpdates);
6124
+ if (!ticketId) {
6125
+ sendJson(res, 400, { error: "ticketId is required." });
6126
+ return true;
6127
+ }
6128
+ invalidateApiCache();
6129
+ const result = await reqStorage.saveDetail(ticketId, detailMarkdown || "", metaUpdates);
6130
+ sendJson(res, 200, result);
6131
+ return true;
6132
+ }
6133
+ if (pathname === "/api/planning/answers") {
6134
+ const { ticketId, questionIndex, answer } = body;
6135
+ if (ticketId === void 0 || questionIndex === void 0 || answer === void 0) {
6136
+ sendJson(res, 400, { error: "ticketId, questionIndex, and answer are required." });
6137
+ return true;
6138
+ }
6139
+ invalidateApiCache();
6140
+ const updated = await reqStorage.saveAnswer(ticketId, Number(questionIndex), String(answer));
6141
+ sendJson(res, 200, updated);
6142
+ return true;
6143
+ }
6144
+ if (pathname === "/api/planning/save-inline-comment" || pathname === "/api/planning/inline-comments") {
6145
+ const { ticketId, selectedText, comment, author } = body;
6146
+ if (!ticketId || !selectedText || !comment) {
6147
+ sendJson(res, 400, { error: "ticketId, selectedText, and comment are required." });
6148
+ return true;
6149
+ }
6150
+ invalidateApiCache();
6151
+ const inlineComments = reqStorage.saveInlineComment ? await reqStorage.saveInlineComment(ticketId, selectedText, comment, author) : [];
6152
+ sendJson(res, 200, { inlineComments });
6153
+ return true;
6154
+ }
6155
+ if (pathname === "/api/planning/upload-attachment") {
6156
+ const { ticketId, filename, base64Data } = body;
6157
+ if (!ticketId || !filename || !base64Data) {
6158
+ sendJson(res, 400, { error: "ticketId, filename, and base64Data are required." });
6159
+ return true;
5382
6160
  }
5383
- sendJson(res, 200, { success: true, detail: detailMarkdown });
6161
+ const base64Content = base64Data.replace(/^data:[^;]+;base64,/, "");
6162
+ const buffer = Buffer.from(base64Content, "base64");
6163
+ if (!reqStorage.saveAttachment) {
6164
+ sendJson(res, 500, { error: "Attachments are not supported by the storage engine." });
6165
+ return true;
6166
+ }
6167
+ invalidateApiCache();
6168
+ const saved = await reqStorage.saveAttachment(ticketId, filename, buffer);
6169
+ sendJson(res, 200, {
6170
+ success: true,
6171
+ filename: saved.filename,
6172
+ url: `/api/planning/attachment?ticketId=${encodeURIComponent(ticketId)}&file=${encodeURIComponent(saved.filename)}`
6173
+ });
5384
6174
  return true;
5385
6175
  }
5386
6176
  if (pathname === "/api/planning/milestones") {
@@ -5461,35 +6251,35 @@ function startApiServer(port, storage, workspaceRoot) {
5461
6251
 
5462
6252
  // src/server/gateway.ts
5463
6253
  var __filename2 = fileURLToPath2(import.meta.url);
5464
- var __dirname2 = path8.dirname(__filename2);
6254
+ var __dirname2 = path10.dirname(__filename2);
5465
6255
  function resolveWebDir(explicitWebDir, workspaceRoot) {
5466
- if (explicitWebDir && fs8.existsSync(path8.join(explicitWebDir, "index.html"))) {
6256
+ if (explicitWebDir && fs10.existsSync(path10.join(explicitWebDir, "index.html"))) {
5467
6257
  return explicitWebDir;
5468
6258
  }
5469
6259
  const candidates = [
5470
6260
  // 1. Packaged layout right next to executable (e.g. tools/web)
5471
- path8.resolve(__dirname2, "web"),
6261
+ path10.resolve(__dirname2, "web"),
5472
6262
  // 2. From workspace root: <workspaceRoot>/dist/web
5473
- workspaceRoot ? path8.resolve(workspaceRoot, "dist", "web") : "",
6263
+ workspaceRoot ? path10.resolve(workspaceRoot, "dist", "web") : "",
5474
6264
  // 3. In-repo legacy subfolder: <workspaceRoot>/esedre/dist/web
5475
- workspaceRoot ? path8.resolve(workspaceRoot, "esedre", "dist", "web") : "",
6265
+ workspaceRoot ? path10.resolve(workspaceRoot, "esedre", "dist", "web") : "",
5476
6266
  // 4. Sibling standalone repository from workspace root: <workspaceRoot>/../esedre/dist/web
5477
- workspaceRoot ? path8.resolve(workspaceRoot, "..", "esedre", "dist", "web") : "",
6267
+ workspaceRoot ? path10.resolve(workspaceRoot, "..", "esedre", "dist", "web") : "",
5478
6268
  // 5. Packaged layout inside dist/web
5479
- path8.resolve(__dirname2, "dist", "web"),
6269
+ path10.resolve(__dirname2, "dist", "web"),
5480
6270
  // 6. Sibling standalone repository from __dirname: <__dirname>/../../esedre/dist/web
5481
- path8.resolve(__dirname2, "..", "..", "esedre", "dist", "web"),
6271
+ path10.resolve(__dirname2, "..", "..", "esedre", "dist", "web"),
5482
6272
  // 7. Source tree layout: esedre/src/server/ -> esedre/dist/web
5483
- path8.resolve(__dirname2, "..", "..", "dist", "web"),
6273
+ path10.resolve(__dirname2, "..", "..", "dist", "web"),
5484
6274
  // 8. Legacy layout from __dirname
5485
- path8.resolve(__dirname2, "..", "esedre", "dist", "web")
6275
+ path10.resolve(__dirname2, "..", "esedre", "dist", "web")
5486
6276
  ].filter(Boolean);
5487
6277
  for (const candidate of candidates) {
5488
- if (fs8.existsSync(path8.join(candidate, "index.html"))) {
6278
+ if (fs10.existsSync(path10.join(candidate, "index.html"))) {
5489
6279
  return candidate;
5490
6280
  }
5491
6281
  }
5492
- return candidates[0] || path8.resolve(__dirname2, "web");
6282
+ return candidates[0] || path10.resolve(__dirname2, "web");
5493
6283
  }
5494
6284
  function proxyRequest(req, res, targetPort, rewritePath) {
5495
6285
  const targetPath = rewritePath ? rewritePath(req.url || "/") : req.url || "/";
@@ -5609,10 +6399,10 @@ ${colors.bold}ROADMAP COMMANDS (Pair Programming & LLM Agents):${colors.reset}
5609
6399
  ${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]
5610
6400
  Mint a new roadmap ticket with sequential numeric ID.
5611
6401
 
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]
6402
+ ${colors.bold}update${colors.reset} <id> [-s|--status <status>] [-t|--type <type>] [-P|--priority <prio>] [--title "..."] [--complexity <c>] [--effort "<e>"] [--detail "<md>"] [--file <path>] [--in-dev] [--flag <name>] [--last-hash <sha1>] [--force] [--json]
5613
6403
  Mutate ticket status, type, priority, title, complexity, effort, active state, or feature flag with OCC protection.
5614
6404
 
5615
- ${colors.bold}comment${colors.reset} <id> ["<text>"] [--text "..."] [--author "..."] [--json]
6405
+ ${colors.bold}comment${colors.reset} <id> ["<text>"] [--file <path>] [--text "..."] [--author "..."] [--json]
5616
6406
  Append a research finding, test verification, or note to ticket history.
5617
6407
 
5618
6408
  ${colors.bold}link${colors.reset} <sourceId> <relation> <targetId> [--author "..."] [--json]
@@ -5661,8 +6451,9 @@ ${colors.bold}DEVELOPER ADMINISTRATION (Human Setup & Configuration):${colors.re
5661
6451
  \u2022 remove <target> Unlink a project or data hub
5662
6452
  \u2022 set <key> <val> Update a central configuration setting
5663
6453
 
5664
- ${colors.bold}upgrade${colors.reset} [<path>] [--force|-f] [--json]
6454
+ ${colors.bold}upgrade${colors.reset} [<path>] [--all|-a] [--force|-f] [--json]
5665
6455
  Upgrade workspace configuration schema, wrappers, and agent skills. Refreshes snapshot.
6456
+ Pass --all (-a) to upgrade all registered projects in central ~/.esedre/config.json.
5666
6457
  Pass --force to overwrite customized skills with the latest official template.
5667
6458
 
5668
6459
  ${colors.bold}projects${colors.reset} [--json]
@@ -5675,6 +6466,7 @@ ${colors.bold}DEVELOPER ADMINISTRATION (Human Setup & Configuration):${colors.re
5675
6466
  Safely rename a project code on disk, updating hub folders, manifests, ticket references, and workspace configs.
5676
6467
 
5677
6468
  ${colors.bold}OPTIONS:${colors.reset}
6469
+ -a, --all Apply operation across all registered projects in central configuration.
5678
6470
  -p, --project <code> Project code (e.g. CORE, ALCE, DOCS).
5679
6471
  -n, --name <name> Project display name (e.g. "Alce Web Reader").
5680
6472
  -t, --type <type> Ticket type ('Feature', 'Platform', 'Tools', 'Idea', 'Bug').
@@ -5686,8 +6478,8 @@ ${colors.bold}OPTIONS:${colors.reset}
5686
6478
  --to <targetId> Target ticket ID for link/unlink commands.
5687
6479
  --blocked Filter tickets that are blocked by uncompleted tickets.
5688
6480
  --linked-to <key> Filter tickets that are linked to a specific ticket.
5689
- --detail "<md>" Specification markdown for ticket detail during create.
5690
- --file <path> Path to markdown file for detail (create) or implementation plan (plan).
6481
+ --detail "<md>" Specification markdown for ticket detail during create or update.
6482
+ --file <path> Path to markdown file for detail (create, update), implementation plan (plan), or comment (comment).
5691
6483
  --json Output raw machine-readable JSON (strongly recommended for autonomous LLM coding agents).
5692
6484
  --last-hash <hash> Optimistic concurrency control: last known sha1 hash of the ticket from 'get'.
5693
6485
  --force Bypass optimistic concurrency last-hash conflict checks on writes.
@@ -5752,6 +6544,7 @@ function parseArgs(rawArgs) {
5752
6544
  if (flags["q"] && !flags["search"]) flags["search"] = flags["q"];
5753
6545
  if (flags["r"] && !flags["relation"]) flags["relation"] = flags["r"];
5754
6546
  if (flags["f"] && !flags["foreground"]) flags["foreground"] = flags["f"];
6547
+ if (flags["a"] && !flags["all"]) flags["all"] = flags["a"];
5755
6548
  return { command, positionals, flags };
5756
6549
  }
5757
6550
  function printDuplicateProjectWarnings(storage, isJson) {
@@ -5759,7 +6552,7 @@ function printDuplicateProjectWarnings(storage, isJson) {
5759
6552
  const warnings = storage.getDuplicateProjectWarnings ? storage.getDuplicateProjectWarnings() : [];
5760
6553
  for (const warn of warnings) {
5761
6554
  console.error(
5762
- `${colors.yellow}\u26A0\uFE0F Warning: Duplicate project code '${warn.code}' detected across multiple data hubs (${path9.basename(warn.firstHub)} and ${path9.basename(warn.duplicateHub)}). Duplicate project codes across hubs are not supported; only '${path9.basename(warn.firstHub)}' is active.${colors.reset}`
6555
+ `${colors.yellow}\u26A0\uFE0F Warning: Duplicate project code '${warn.code}' detected across multiple data hubs (${path11.basename(warn.firstHub)} and ${path11.basename(warn.duplicateHub)}). Duplicate project codes across hubs are not supported; only '${path11.basename(warn.firstHub)}' is active.${colors.reset}`
5763
6556
  );
5764
6557
  }
5765
6558
  }
@@ -5802,7 +6595,7 @@ async function main() {
5802
6595
  switch (command) {
5803
6596
  case "init": {
5804
6597
  const rawTarget = positionals[0];
5805
- const targetDir = rawTarget ? path9.resolve(rawTarget) : discovered.workspaceRoot;
6598
+ const targetDir = rawTarget ? path11.resolve(rawTarget) : discovered.workspaceRoot;
5806
6599
  const rawHubFlag = flags["hub"];
5807
6600
  const hubArg = typeof rawHubFlag === "string" ? rawHubFlag : void 0;
5808
6601
  const isHubRequested = Boolean(rawHubFlag);
@@ -5881,7 +6674,7 @@ ${colors.bold}Next Steps:${colors.reset}`);
5881
6674
  console.error(`${colors.red}Error: ${nameVal.error}${colors.reset}`);
5882
6675
  process.exit(1);
5883
6676
  }
5884
- const isTargetHub = fs9.existsSync(path9.join(targetDir, "projects.json")) || fs9.existsSync(path9.join(targetDir, "projects")) && !fs9.existsSync(path9.join(targetDir, ".esedre", "esedre.json"));
6677
+ const isTargetHub = fs11.existsSync(path11.join(targetDir, "projects.json")) || fs11.existsSync(path11.join(targetDir, "projects")) && !fs11.existsSync(path11.join(targetDir, ".esedre", "esedre.json"));
5885
6678
  const isWorkspaceless = isHubRequested || isTargetHub || Boolean(discovered.config?.projectCode) && !rawTarget && discovered.config?.projectCode?.toLowerCase() !== projectCode.toLowerCase();
5886
6679
  let projectNewlyRegistered = false;
5887
6680
  let registeredProj;
@@ -5952,7 +6745,7 @@ ${colors.bold}Next Steps:${colors.reset}`);
5952
6745
  const viteConfigPath = findViteConfig(targetDir);
5953
6746
  if (viteConfigPath && setupProxy === void 0 && !isJson) {
5954
6747
  try {
5955
- const rawContent = fs9.readFileSync(viteConfigPath, "utf-8");
6748
+ const rawContent = fs11.readFileSync(viteConfigPath, "utf-8");
5956
6749
  const alreadyConfigured = rawContent.includes("'/esedre'") || rawContent.includes('"/esedre"');
5957
6750
  if (!alreadyConfigured) {
5958
6751
  if (isYes) {
@@ -5962,7 +6755,7 @@ ${colors.bold}Next Steps:${colors.reset}`);
5962
6755
  const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
5963
6756
  try {
5964
6757
  const answer = await rl.question(
5965
- `${colors.cyan}?${colors.reset} Detected ${colors.bold}${path9.basename(viteConfigPath)}${colors.reset}. Would you like to configure a reverse proxy for ${colors.bold}/esedre${colors.reset} targeting http://127.0.0.1:${port}? [Y/n] `
6758
+ `${colors.cyan}?${colors.reset} Detected ${colors.bold}${path11.basename(viteConfigPath)}${colors.reset}. Would you like to configure a reverse proxy for ${colors.bold}/esedre${colors.reset} targeting http://127.0.0.1:${port}? [Y/n] `
5966
6759
  );
5967
6760
  const trimmed = answer.trim().toLowerCase();
5968
6761
  setupProxy = trimmed === "" || trimmed === "y" || trimmed === "yes";
@@ -5978,10 +6771,13 @@ ${colors.bold}Next Steps:${colors.reset}`);
5978
6771
  let hubRelPath = void 0;
5979
6772
  if (rawHub) {
5980
6773
  const singleHub = Array.isArray(rawHub) ? rawHub[0] : rawHub;
5981
- const resolvedHub = path9.resolve(expandHome(singleHub));
5982
- if (fs9.existsSync(resolvedHub)) {
5983
- const rel = path9.relative(targetDir, resolvedHub).replace(/\\/g, "/");
5984
- hubRelPath = rel.startsWith(".") ? rel : "./" + rel;
6774
+ const resolvedHub = path11.resolve(expandHome(singleHub));
6775
+ if (fs11.existsSync(resolvedHub)) {
6776
+ const rel = path11.relative(targetDir, resolvedHub).replace(/\\/g, "/");
6777
+ const isSubdir = !rel.startsWith("..") && !path11.isAbsolute(rel);
6778
+ if (isSubdir || Boolean(flags["data-dir"])) {
6779
+ hubRelPath = rel.startsWith(".") ? rel : "./" + rel;
6780
+ }
5985
6781
  }
5986
6782
  }
5987
6783
  const res = initWorkspace(targetDir, {
@@ -6152,7 +6948,7 @@ ${colors.bold}Next Steps:${colors.reset}`);
6152
6948
  }
6153
6949
  console.log(`
6154
6950
  ${colors.bold}${colors.cyan}Esedre Central Configuration${colors.reset}`);
6155
- console.log(` \u2022 Config File: ${configPath} ${fs9.existsSync(configPath) ? colors.green + "(active)" + colors.reset : colors.dim + "(default)" + colors.reset}`);
6951
+ console.log(` \u2022 Config File: ${configPath} ${fs11.existsSync(configPath) ? colors.green + "(active)" + colors.reset : colors.dim + "(default)" + colors.reset}`);
6156
6952
  console.log(` \u2022 Default Port: ${globalCfg.port || DEFAULT_ESEDRE_PORT}`);
6157
6953
  console.log(`
6158
6954
  ${colors.bold}Data Hubs (dataDir):${colors.reset}`);
@@ -6184,9 +6980,46 @@ ${colors.bold}Commands:${colors.reset}`);
6184
6980
  return;
6185
6981
  }
6186
6982
  case "upgrade": {
6187
- const targetDir = positionals[0] ? path9.resolve(positionals[0]) : discovered.workspaceRoot;
6188
- const targetDiscovered = targetDir === discovered.workspaceRoot ? discovered : findEsedreConfig(targetDir, { fallbackToGlobal: true });
6983
+ const isAll = Boolean(flags["all"] || flags["a"]);
6189
6984
  const isForce = Boolean(flags["force"] || flags["f"]);
6985
+ if (isAll) {
6986
+ const globalCfg = readGlobalConfig();
6987
+ const allRes = await upgradeAllWorkspaces({
6988
+ currentWorkspaceRoot: discovered.workspaceRoot,
6989
+ globalConfig: globalCfg,
6990
+ force: isForce
6991
+ });
6992
+ if (isJson) {
6993
+ console.log(JSON.stringify(allRes, null, 2));
6994
+ return;
6995
+ }
6996
+ if (allRes.workspaces.length === 0) {
6997
+ console.log(`${colors.yellow}No registered workspaces found to upgrade.${colors.reset}`);
6998
+ return;
6999
+ }
7000
+ console.log(`
7001
+ ${colors.bold}${colors.green}\u2714 Esedre multi-workspace upgrade completed across ${allRes.workspaces.length} workspace(s):${colors.reset}`);
7002
+ for (const w of allRes.workspaces) {
7003
+ if (w.error) {
7004
+ console.log(` \u2022 ${colors.red}\u2717 ${w.name} (${w.dir}): ${w.error}${colors.reset}`);
7005
+ } else {
7006
+ const snapStr = w.totalTickets !== void 0 ? ` (${w.totalTickets} tickets projected)` : "";
7007
+ console.log(` \u2022 ${colors.bold}${w.name}${colors.reset} (${w.dir}):`);
7008
+ console.log(` - Wrappers updated in .esedre/`);
7009
+ if (w.totalTickets !== void 0) {
7010
+ console.log(` - Refreshed .esedre/snapshot.json${snapStr}`);
7011
+ }
7012
+ if (w.upgrade.skillConfigured) {
7013
+ console.log(` - Updated .agents/skills/esedre/SKILL.md`);
7014
+ } else if (w.upgrade.skillStatus === "CUSTOMIZED") {
7015
+ console.log(` - Preserved customized .agents/skills/esedre/SKILL.md`);
7016
+ }
7017
+ }
7018
+ }
7019
+ return;
7020
+ }
7021
+ const targetDir = positionals[0] ? path11.resolve(positionals[0]) : discovered.workspaceRoot;
7022
+ const targetDiscovered = targetDir === discovered.workspaceRoot ? discovered : findEsedreConfig(targetDir, { fallbackToGlobal: true });
6190
7023
  const res = configureWorkspace(targetDir, {
6191
7024
  projectCode: targetDiscovered.config?.projectCode,
6192
7025
  allowedProjects: targetDiscovered.config?.allowedProjects,
@@ -6293,8 +7126,10 @@ ${colors.dim}Shutting down Esedre cluster...${colors.reset}`);
6293
7126
  const flagPort = flags["port"] ? parseInt(String(flags["port"]), 10) : void 0;
6294
7127
  const port = resolvePorts(discovered.config, { port: flagPort }).gateway;
6295
7128
  const status = await getDaemonStatus({ port, workspaceRoot: discovered.workspaceRoot });
7129
+ const globalCfg = readGlobalConfig() || discovered.config;
7130
+ const outdatedWrappers = findOutdatedWrappers(discovered.workspaceRoot, globalCfg);
6296
7131
  if (isJson) {
6297
- console.log(JSON.stringify(status, null, 2));
7132
+ console.log(JSON.stringify({ ...status, outdatedWrappers }, null, 2));
6298
7133
  } else {
6299
7134
  if (status.running) {
6300
7135
  console.log(`${colors.bold}${colors.green}\u25CF Esedre daemon is RUNNING${colors.reset}`);
@@ -6312,10 +7147,14 @@ ${colors.yellow}\u26A0\uFE0F Warning: Daemon is running v${status.version}, but
6312
7147
  }
6313
7148
  } else {
6314
7149
  console.log(`${colors.yellow}\u25CB Esedre daemon is STOPPED (port ${status.port})${colors.reset}`);
6315
- if (status.logFile && fs9.existsSync(status.logFile)) {
7150
+ if (status.logFile && fs11.existsSync(status.logFile)) {
6316
7151
  console.log(` \u2022 Recent Logs: ${status.logFile}`);
6317
7152
  }
6318
7153
  }
7154
+ if (outdatedWrappers.length > 0) {
7155
+ console.log(`
7156
+ ${colors.yellow}Notice: ${outdatedWrappers.length} workspace(s) have legacy CMD wrappers (${outdatedWrappers.map((o) => o.name).join(", ")}). Run 'ese upgrade --all' to update.${colors.reset}`);
7157
+ }
6319
7158
  }
6320
7159
  return;
6321
7160
  }
@@ -6513,11 +7352,11 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
6513
7352
  if (setPlan || filePath) {
6514
7353
  let content = setPlan;
6515
7354
  if (filePath) {
6516
- if (!fs9.existsSync(filePath)) {
7355
+ if (!fs11.existsSync(filePath)) {
6517
7356
  console.error(`${colors.red}Error: Plan file "${filePath}" not found.${colors.reset}`);
6518
7357
  process.exit(1);
6519
7358
  }
6520
- content = fs9.readFileSync(filePath, "utf-8");
7359
+ content = fs11.readFileSync(filePath, "utf-8");
6521
7360
  }
6522
7361
  await storage.savePlan(id, content, lastHash);
6523
7362
  if (isJson) {
@@ -6589,8 +7428,8 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
6589
7428
  const milestone = flags["milestone"] || flags["m"] || void 0;
6590
7429
  let detailMarkdown = detailArg;
6591
7430
  if (fileArg) {
6592
- const filePath = path9.resolve(fileArg);
6593
- if (!fs9.existsSync(filePath)) {
7431
+ const filePath = path11.resolve(fileArg);
7432
+ if (!fs11.existsSync(filePath)) {
6594
7433
  if (isJson) {
6595
7434
  console.error(JSON.stringify({ error: `Detail file "${fileArg}" not found` }));
6596
7435
  } else {
@@ -6598,7 +7437,7 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
6598
7437
  }
6599
7438
  process.exit(1);
6600
7439
  }
6601
- detailMarkdown = fs9.readFileSync(filePath, "utf-8");
7440
+ detailMarkdown = fs11.readFileSync(filePath, "utf-8");
6602
7441
  }
6603
7442
  const created = await storage.createTicket({
6604
7443
  title,
@@ -6664,6 +7503,22 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
6664
7503
  if (inDev !== void 0) updates.isActivePlanning = inDev;
6665
7504
  if (flag) updates.featureFlag = flag;
6666
7505
  if (rawMilestone !== void 0) updates.milestone = rawMilestone;
7506
+ const detailArg = flags["detail"];
7507
+ const fileArg = flags["file"];
7508
+ let detailMarkdown = detailArg ? normalizeDashesAndMojibake(detailArg) : void 0;
7509
+ if (fileArg) {
7510
+ const filePath = path11.resolve(fileArg);
7511
+ if (!fs11.existsSync(filePath)) {
7512
+ if (isJson) {
7513
+ console.error(JSON.stringify({ error: `Detail file "${fileArg}" not found` }));
7514
+ } else {
7515
+ console.error(`${colors.red}Error: Detail file "${fileArg}" not found.${colors.reset}`);
7516
+ }
7517
+ process.exit(1);
7518
+ }
7519
+ detailMarkdown = normalizeDashesAndMojibake(fs11.readFileSync(filePath, "utf-8"));
7520
+ }
7521
+ if (detailMarkdown !== void 0) updates.detailMarkdown = detailMarkdown;
6667
7522
  const updated = await storage.updateTicket(lookupKey, updates, lastHash);
6668
7523
  if (isJson) {
6669
7524
  console.log(JSON.stringify(updated, null, 2));
@@ -6819,10 +7674,30 @@ ${colors.bold}Tickets:${colors.reset}`);
6819
7674
  }
6820
7675
  const projectFlag = flags["project"];
6821
7676
  const lookupKey = projectFlag && /^\d+$/.test(idStr) ? `${projectFlag}-${idStr}` : idStr;
6822
- const positionalText = positionals.slice(1).join(" ").trim();
6823
- const text = flags["text"] || (positionalText || void 0);
7677
+ let text;
7678
+ const filePath = flags["file"];
7679
+ if (filePath) {
7680
+ const resolvedPath = path11.resolve(process.cwd(), filePath);
7681
+ if (!fs11.existsSync(resolvedPath)) {
7682
+ console.error(`${colors.red}Error: File not found: ${filePath}${colors.reset}`);
7683
+ process.exit(1);
7684
+ }
7685
+ if (!fs11.statSync(resolvedPath).isFile()) {
7686
+ console.error(`${colors.red}Error: Path is not a file: ${filePath}${colors.reset}`);
7687
+ process.exit(1);
7688
+ }
7689
+ const fileContent = fs11.readFileSync(resolvedPath, "utf-8");
7690
+ if (!fileContent.trim()) {
7691
+ console.error(`${colors.red}Error: Comment file is empty: ${filePath}${colors.reset}`);
7692
+ process.exit(1);
7693
+ }
7694
+ text = fileContent;
7695
+ } else {
7696
+ const positionalText = positionals.slice(1).join(" ").trim();
7697
+ text = flags["text"] || (positionalText || void 0);
7698
+ }
6824
7699
  if (!text) {
6825
- console.error(`${colors.red}Error: Comment text is required (e.g. ese comment 96 "Verified tests" or --text "...")${colors.reset}`);
7700
+ console.error(`${colors.red}Error: Comment text or --file is required (e.g. ese comment 96 --file note.md or ese comment 96 "Verified tests")${colors.reset}`);
6826
7701
  process.exit(1);
6827
7702
  }
6828
7703
  const author = flags["author"] || "User";