esedre 1.0.1 → 1.1.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 fs10 from "node:fs";
5
+ import path10 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.1.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) {
@@ -2660,45 +2872,189 @@ ${input.summary || "Summary to be defined."}
2660
2872
  if (!locInfo) {
2661
2873
  throw new Error(`Ticket #${id} could not be located on disk`);
2662
2874
  }
2663
- const planPath = path3.join(locInfo.ticketDir, "implementation_plan.md");
2664
- writeSafeFile(planPath, normalizeDashesAndMojibake(planMarkdown));
2665
- const metaPath = path3.join(locInfo.ticketDir, "meta.json");
2666
- if (fs3.existsSync(metaPath)) {
2875
+ const planPath = path3.join(locInfo.ticketDir, "implementation_plan.md");
2876
+ writeSafeFile(planPath, normalizeDashesAndMojibake(planMarkdown));
2877
+ const metaPath = path3.join(locInfo.ticketDir, "meta.json");
2878
+ if (fs3.existsSync(metaPath)) {
2879
+ try {
2880
+ const meta = JSON.parse(fs3.readFileSync(metaPath, "utf-8"));
2881
+ meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2882
+ meta.revision = (meta.revision || 1) + 1;
2883
+ writeSafeFile(metaPath, JSON.stringify(meta, null, 2) + "\n");
2884
+ } catch {
2885
+ }
2886
+ }
2887
+ this.invalidateTicketCache(locInfo.ticketDir);
2888
+ }
2889
+ async addComment(id, comment) {
2890
+ const ticket = await this.getTicket(id);
2891
+ if (!ticket) {
2892
+ throw new Error(`Ticket #${id} does not exist`);
2893
+ }
2894
+ const locInfo = this.findTicketLocation(id);
2895
+ if (!locInfo) {
2896
+ throw new Error(`Ticket #${id} could not be located on disk`);
2897
+ }
2898
+ const commentsPath = path3.join(locInfo.ticketDir, "comments.json");
2899
+ let comments = [];
2900
+ if (fs3.existsSync(commentsPath)) {
2901
+ try {
2902
+ comments = JSON.parse(fs3.readFileSync(commentsPath, "utf-8"));
2903
+ } catch {
2904
+ }
2905
+ }
2906
+ const newComment = {
2907
+ id: `${locInfo.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
2908
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2909
+ author: normalizeDashesAndMojibake(comment.author || "User"),
2910
+ text: normalizeDashesAndMojibake(comment.text.trim())
2911
+ };
2912
+ comments.push(newComment);
2913
+ writeSafeFile(commentsPath, JSON.stringify(comments, null, 2) + "\n");
2914
+ this.invalidateTicketCache(locInfo.ticketDir);
2915
+ return newComment;
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)) {
2667
2996
  try {
2668
- const meta = JSON.parse(fs3.readFileSync(metaPath, "utf-8"));
2669
- meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2670
- meta.revision = (meta.revision || 1) + 1;
2671
- writeSafeFile(metaPath, JSON.stringify(meta, null, 2) + "\n");
2997
+ currentInlines = JSON.parse(fs3.readFileSync(inlinesPath, "utf-8"));
2672
2998
  } catch {
2673
2999
  }
2674
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;
2675
3012
  }
2676
- async addComment(id, comment) {
2677
- const ticket = await this.getTicket(id);
2678
- if (!ticket) {
2679
- throw new Error(`Ticket #${id} does not exist`);
2680
- }
3013
+ async saveAttachment(id, filename, buffer) {
2681
3014
  const locInfo = this.findTicketLocation(id);
2682
3015
  if (!locInfo) {
2683
3016
  throw new Error(`Ticket #${id} could not be located on disk`);
2684
3017
  }
2685
- const commentsPath = path3.join(locInfo.ticketDir, "comments.json");
2686
- let comments = [];
2687
- if (fs3.existsSync(commentsPath)) {
2688
- try {
2689
- comments = JSON.parse(fs3.readFileSync(commentsPath, "utf-8"));
2690
- } catch {
2691
- }
3018
+ const attachmentsDir = path3.join(locInfo.ticketDir, "attachments");
3019
+ if (!fs3.existsSync(attachmentsDir)) {
3020
+ fs3.mkdirSync(attachmentsDir, { recursive: true });
2692
3021
  }
2693
- const newComment = {
2694
- id: `${locInfo.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
2695
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2696
- author: normalizeDashesAndMojibake(comment.author || "User"),
2697
- text: normalizeDashesAndMojibake(comment.text.trim())
2698
- };
2699
- comments.push(newComment);
2700
- writeSafeFile(commentsPath, JSON.stringify(comments, null, 2) + "\n");
2701
- return newComment;
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;
2702
3058
  }
2703
3059
  resolveTicketIdentifier(id, defaultProject) {
2704
3060
  const str = String(id).trim();
@@ -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,6 +3501,81 @@ 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
@@ -3442,6 +3877,8 @@ Status: ${ticket.meta.status}`;
3442
3877
  inDevelopment: { type: "boolean", description: "Active development toggle" },
3443
3878
  featureFlag: { type: "string", description: "Feature flag name" },
3444
3879
  milestone: { type: "string", description: 'Update ticket milestone ("none" to clear)' },
3880
+ detail: { type: "string", description: "Updated specification markdown for ticket detail" },
3881
+ detailMarkdown: { type: "string", description: "Updated specification markdown for ticket detail" },
3445
3882
  lastHash: { type: "string", description: "Optimistic concurrency control: last known sha1 hash of the ticket" }
3446
3883
  },
3447
3884
  required: ["ticketId"]
@@ -3633,6 +4070,8 @@ Status: ${ticket.meta.status}`;
3633
4070
  if (args.inDevelopment !== void 0) updates.isActivePlanning = Boolean(args.inDevelopment);
3634
4071
  if (args.featureFlag) updates.featureFlag = args.featureFlag;
3635
4072
  if (args.milestone !== void 0) updates.milestone = args.milestone;
4073
+ if (args.detail !== void 0) updates.detail = args.detail;
4074
+ if (args.detailMarkdown !== void 0) updates.detailMarkdown = args.detailMarkdown;
3636
4075
  const updated = await this.storage.updateTicket(id, updates, args.lastHash);
3637
4076
  return updated;
3638
4077
  }
@@ -4079,6 +4518,22 @@ async function startDaemon(options = {}) {
4079
4518
  resolveStoreDir(workspaceRoot);
4080
4519
  fs5.mkdirSync(path5.dirname(logFile), { recursive: true });
4081
4520
  fs5.mkdirSync(path5.dirname(stateFile), { recursive: true });
4521
+ const MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024;
4522
+ try {
4523
+ if (fs5.existsSync(logFile)) {
4524
+ const stats = fs5.statSync(logFile);
4525
+ if (stats.size > MAX_LOG_SIZE_BYTES) {
4526
+ const oldLogFile = `${logFile}.old`;
4527
+ try {
4528
+ if (fs5.existsSync(oldLogFile)) fs5.unlinkSync(oldLogFile);
4529
+ fs5.renameSync(logFile, oldLogFile);
4530
+ } catch {
4531
+ fs5.truncateSync(logFile, 0);
4532
+ }
4533
+ }
4534
+ }
4535
+ } catch {
4536
+ }
4082
4537
  const logFd = fs5.openSync(logFile, "a");
4083
4538
  const isEsedreArgv1 = process.argv[1] && /esedre/i.test(path5.basename(process.argv[1]));
4084
4539
  const cliCandidates = [
@@ -4276,13 +4731,30 @@ function printDaemonLogs(options = {}) {
4276
4731
  console.log(`\x1B[33mNo logs found at: ${logFile}\x1B[0m`);
4277
4732
  return;
4278
4733
  }
4279
- const content = fs5.readFileSync(logFile, "utf-8");
4280
4734
  const count = options.lines || 40;
4735
+ let content = "";
4736
+ try {
4737
+ const stats = fs5.statSync(logFile);
4738
+ const maxReadBytes = Math.min(stats.size, Math.max(256 * 1024, count * 2048));
4739
+ if (stats.size <= maxReadBytes) {
4740
+ content = fs5.readFileSync(logFile, "utf-8");
4741
+ } else {
4742
+ const fd = fs5.openSync(logFile, "r");
4743
+ const buffer = Buffer.alloc(maxReadBytes);
4744
+ fs5.readSync(fd, buffer, 0, maxReadBytes, stats.size - maxReadBytes);
4745
+ fs5.closeSync(fd);
4746
+ content = buffer.toString("utf-8");
4747
+ }
4748
+ } catch {
4749
+ content = fs5.readFileSync(logFile, "utf-8");
4750
+ }
4281
4751
  const allLines = content.split(/\r?\n/);
4282
- const tail = allLines.slice(-count).join("\n");
4752
+ const sanitizedLines = allLines.slice(-count).map((line) => {
4753
+ 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, "");
4754
+ });
4283
4755
  console.log(`\x1B[36m--- Esedre Daemon Logs (${logFile}) ---\x1B[0m
4284
4756
  `);
4285
- console.log(tail);
4757
+ console.log(sanitizedLines.join("\n"));
4286
4758
  }
4287
4759
 
4288
4760
  // src/upgrade.ts
@@ -4933,11 +5405,120 @@ function initHub(targetDir) {
4933
5405
  hubDir: resolved.replace(/\\/g, "/")
4934
5406
  };
4935
5407
  }
5408
+ function isWrapperOutdated(esedreCmdContent) {
5409
+ if (!esedreCmdContent || typeof esedreCmdContent !== "string") {
5410
+ return true;
5411
+ }
5412
+ if (/goto\s+:?done\b/i.test(esedreCmdContent)) {
5413
+ return true;
5414
+ }
5415
+ if (/^:done\b/im.test(esedreCmdContent)) {
5416
+ return true;
5417
+ }
5418
+ if (!esedreCmdContent.includes("call :run %*") || !/^:run\b/im.test(esedreCmdContent)) {
5419
+ return true;
5420
+ }
5421
+ return false;
5422
+ }
5423
+ function findOutdatedWrappers(currentWorkspaceRoot, globalConfig) {
5424
+ const outdated = [];
5425
+ const checkedDirs = /* @__PURE__ */ new Set();
5426
+ const checkDir = (dir, name) => {
5427
+ const resolved = path6.resolve(expandHome(dir));
5428
+ if (checkedDirs.has(resolved.toLowerCase())) return;
5429
+ checkedDirs.add(resolved.toLowerCase());
5430
+ if (!fs6.existsSync(resolved)) return;
5431
+ const cmdPath = path6.join(resolved, ".esedre", "esedre.cmd");
5432
+ if (fs6.existsSync(cmdPath)) {
5433
+ try {
5434
+ const content = fs6.readFileSync(cmdPath, "utf-8");
5435
+ if (isWrapperOutdated(content)) {
5436
+ outdated.push({ name, dir: resolved, wrapperPath: cmdPath });
5437
+ }
5438
+ } catch {
5439
+ }
5440
+ }
5441
+ };
5442
+ if (currentWorkspaceRoot) {
5443
+ checkDir(currentWorkspaceRoot, path6.basename(currentWorkspaceRoot));
5444
+ }
5445
+ if (globalConfig?.projects) {
5446
+ for (const [code, p] of Object.entries(globalConfig.projects)) {
5447
+ checkDir(p, code);
5448
+ }
5449
+ }
5450
+ return outdated;
5451
+ }
5452
+ async function upgradeAllWorkspaces(options = {}) {
5453
+ const globalCfg = options.globalConfig !== void 0 ? options.globalConfig : readGlobalConfig();
5454
+ const workspaceMap = /* @__PURE__ */ new Map();
5455
+ if (options.currentWorkspaceRoot && fs6.existsSync(options.currentWorkspaceRoot)) {
5456
+ const hasEsedre = fs6.existsSync(path6.join(options.currentWorkspaceRoot, ".esedre")) || fs6.existsSync(path6.join(options.currentWorkspaceRoot, "esedre.json"));
5457
+ if (hasEsedre) {
5458
+ const disc = findEsedreConfig(options.currentWorkspaceRoot, { fallbackToGlobal: true });
5459
+ workspaceMap.set(
5460
+ path6.resolve(options.currentWorkspaceRoot),
5461
+ disc.config?.projectCode || path6.basename(options.currentWorkspaceRoot)
5462
+ );
5463
+ }
5464
+ }
5465
+ if (globalCfg?.projects) {
5466
+ for (const [code, p] of Object.entries(globalCfg.projects)) {
5467
+ const resolved = path6.resolve(expandHome(p));
5468
+ if (fs6.existsSync(resolved)) {
5469
+ if (!workspaceMap.has(resolved)) {
5470
+ workspaceMap.set(resolved, code);
5471
+ }
5472
+ }
5473
+ }
5474
+ }
5475
+ const results = [];
5476
+ for (const [targetDir, name] of workspaceMap.entries()) {
5477
+ try {
5478
+ const targetDiscovered = findEsedreConfig(targetDir, { fallbackToGlobal: true });
5479
+ const projectCode = targetDiscovered.config?.projectCode;
5480
+ const res = configureWorkspace(targetDir, {
5481
+ projectCode,
5482
+ allowedProjects: targetDiscovered.config?.allowedProjects,
5483
+ port: targetDiscovered.config?.port,
5484
+ force: options.force
5485
+ });
5486
+ let totalTickets;
5487
+ if (projectCode) {
5488
+ try {
5489
+ const targetStorage = new SecurityFilter(
5490
+ new FilesystemStorageAdapter(targetDir, targetDiscovered.config),
5491
+ targetDiscovered.config
5492
+ );
5493
+ const snapshot = await generateProjectSnapshot(targetStorage, projectCode, targetDir);
5494
+ totalTickets = snapshot.totalTickets;
5495
+ } catch {
5496
+ }
5497
+ }
5498
+ results.push({
5499
+ dir: targetDir,
5500
+ name,
5501
+ projectCode,
5502
+ upgrade: res,
5503
+ totalTickets
5504
+ });
5505
+ } catch (err) {
5506
+ results.push({
5507
+ dir: targetDir,
5508
+ name,
5509
+ upgrade: {},
5510
+ error: err.message || String(err)
5511
+ });
5512
+ }
5513
+ }
5514
+ const totalUpgraded = results.filter((r) => !r.error && r.upgrade.wrappersPlanted).length;
5515
+ return { workspaces: results, totalUpgraded };
5516
+ }
4936
5517
 
4937
5518
  // src/server/gateway.ts
4938
- import fs8 from "node:fs";
5519
+ import fs9 from "node:fs";
4939
5520
  import http4 from "node:http";
4940
- import path8 from "node:path";
5521
+ import path9 from "node:path";
4941
5522
  import { fileURLToPath as fileURLToPath2 } from "node:url";
4942
5523
 
4943
5524
  // src/server/uiServer.ts
@@ -4975,6 +5556,13 @@ function startUiServer(port, webDir) {
4975
5556
  }
4976
5557
  const safePath = path7.normalize(reqPath).replace(/^(\.\.[/\\])+/, "");
4977
5558
  let filePath = path7.join(webDir, safePath);
5559
+ const resolvedFilePath = path7.resolve(filePath);
5560
+ const resolvedWebDir = path7.resolve(webDir);
5561
+ if (!resolvedFilePath.startsWith(resolvedWebDir)) {
5562
+ res.writeHead(403, { "Content-Type": "text/plain" });
5563
+ res.end("Access Denied: Path escapes web root.");
5564
+ return;
5565
+ }
4978
5566
  if (!fs7.existsSync(filePath) || fs7.statSync(filePath).isDirectory()) {
4979
5567
  filePath = path7.join(webDir, "index.html");
4980
5568
  }
@@ -5008,6 +5596,8 @@ function startUiServer(port, webDir) {
5008
5596
 
5009
5597
  // src/server/apiServer.ts
5010
5598
  import http3 from "node:http";
5599
+ import fs8 from "node:fs";
5600
+ import path8 from "node:path";
5011
5601
  var MAX_JSON_BODY_BYTES = 10 * 1024 * 1024;
5012
5602
  function readJsonBody(req) {
5013
5603
  return new Promise((resolve, reject) => {
@@ -5043,6 +5633,7 @@ function sendJson(res, status, data) {
5043
5633
  }
5044
5634
  var cachedAllData = /* @__PURE__ */ new Map();
5045
5635
  var CACHE_TTL_MS = 5e3;
5636
+ var MAX_CACHE_ENTRIES = 30;
5046
5637
  function invalidateApiCache() {
5047
5638
  cachedAllData.clear();
5048
5639
  }
@@ -5142,6 +5733,8 @@ function createApiHandler(storage, workspaceRoot) {
5142
5733
  const detailsMap = {};
5143
5734
  const plansMap = {};
5144
5735
  const commentsMap = {};
5736
+ const answersMap = {};
5737
+ const inlineCommentsMap = {};
5145
5738
  for (const t of tickets) {
5146
5739
  const numKey = String(t.meta.id);
5147
5740
  const code = t.projectDescriptor?.code || t.meta.project || "Profe";
@@ -5167,6 +5760,18 @@ function createApiHandler(storage, workspaceRoot) {
5167
5760
  plansMap[numKey] = t.planMarkdown;
5168
5761
  }
5169
5762
  }
5763
+ if (t.answers && Object.keys(t.answers).length > 0) {
5764
+ answersMap[prefixedKey] = t.answers;
5765
+ if (!answersMap[numKey] || code.toUpperCase() === "PROF") {
5766
+ answersMap[numKey] = t.answers;
5767
+ }
5768
+ }
5769
+ if (t.inlineComments && t.inlineComments.length > 0) {
5770
+ inlineCommentsMap[prefixedKey] = t.inlineComments;
5771
+ if (!inlineCommentsMap[numKey] || code.toUpperCase() === "PROF") {
5772
+ inlineCommentsMap[numKey] = t.inlineComments;
5773
+ }
5774
+ }
5170
5775
  }
5171
5776
  const projects = await reqStorage.getProjects();
5172
5777
  const responsePayload = {
@@ -5176,11 +5781,15 @@ function createApiHandler(storage, workspaceRoot) {
5176
5781
  details: detailsMap,
5177
5782
  plans: plansMap,
5178
5783
  comments: commentsMap,
5179
- answers: {},
5180
- inlineComments: {},
5784
+ answers: answersMap,
5785
+ inlineComments: inlineCommentsMap,
5181
5786
  planHistory: {},
5182
5787
  ticketHistory: {}
5183
5788
  };
5789
+ if (cachedAllData.size >= MAX_CACHE_ENTRIES) {
5790
+ const oldestKey = cachedAllData.keys().next().value;
5791
+ if (oldestKey) cachedAllData.delete(oldestKey);
5792
+ }
5184
5793
  cachedAllData.set(cacheKey, { data: responsePayload, timestamp: now });
5185
5794
  sendJson(res, 200, responsePayload);
5186
5795
  return true;
@@ -5230,11 +5839,88 @@ function createApiHandler(storage, workspaceRoot) {
5230
5839
  return true;
5231
5840
  }
5232
5841
  if (pathname === "/api/planning/inline-comments") {
5233
- sendJson(res, 200, {});
5842
+ const ticketParam = url.searchParams.get("ticketId") || url.searchParams.get("id");
5843
+ if (ticketParam) {
5844
+ const inlines = reqStorage.getInlineComments ? await reqStorage.getInlineComments(ticketParam) : [];
5845
+ sendJson(res, 200, inlines);
5846
+ return true;
5847
+ }
5848
+ const tickets = await reqStorage.listTickets({});
5849
+ const inlinesMap = {};
5850
+ for (const t of tickets) {
5851
+ if (t.inlineComments && t.inlineComments.length > 0) {
5852
+ inlinesMap[String(t.meta.id)] = t.inlineComments;
5853
+ const code = t.projectDescriptor?.code || t.meta.project;
5854
+ if (code) {
5855
+ inlinesMap[`${code}-${t.meta.id}`] = t.inlineComments;
5856
+ }
5857
+ }
5858
+ }
5859
+ sendJson(res, 200, inlinesMap);
5234
5860
  return true;
5235
5861
  }
5236
5862
  if (pathname === "/api/planning/answers") {
5237
- sendJson(res, 200, {});
5863
+ const ticketParam = url.searchParams.get("ticketId") || url.searchParams.get("id");
5864
+ if (ticketParam) {
5865
+ const answers = await reqStorage.getAnswers(ticketParam);
5866
+ sendJson(res, 200, answers);
5867
+ return true;
5868
+ }
5869
+ const tickets = await reqStorage.listTickets({});
5870
+ const answersMap = {};
5871
+ for (const t of tickets) {
5872
+ if (t.answers && Object.keys(t.answers).length > 0) {
5873
+ answersMap[String(t.meta.id)] = t.answers;
5874
+ const code = t.projectDescriptor?.code || t.meta.project;
5875
+ if (code) {
5876
+ answersMap[`${code}-${t.meta.id}`] = t.answers;
5877
+ }
5878
+ }
5879
+ }
5880
+ sendJson(res, 200, answersMap);
5881
+ return true;
5882
+ }
5883
+ if (pathname === "/api/planning/attachment") {
5884
+ const ticketId = url.searchParams.get("ticketId") || url.searchParams.get("id");
5885
+ const file = url.searchParams.get("file");
5886
+ if (!ticketId || !file) {
5887
+ sendJson(res, 400, { error: "ticketId and file parameters are required." });
5888
+ return true;
5889
+ }
5890
+ const ticket = await reqStorage.getTicket(ticketId);
5891
+ if (!ticket) {
5892
+ sendJson(res, 404, { error: "Ticket not found." });
5893
+ return true;
5894
+ }
5895
+ const filePath = reqStorage.getAttachmentPath ? reqStorage.getAttachmentPath(ticketId, file) : null;
5896
+ if (!filePath || !fs8.existsSync(filePath)) {
5897
+ sendJson(res, 404, { error: "Attachment not found." });
5898
+ return true;
5899
+ }
5900
+ const stat = fs8.statSync(filePath);
5901
+ if (!stat.isFile()) {
5902
+ sendJson(res, 404, { error: "Attachment not found." });
5903
+ return true;
5904
+ }
5905
+ const ext = path8.extname(filePath).toLowerCase();
5906
+ const mimeTypes = {
5907
+ ".png": "image/png",
5908
+ ".jpg": "image/jpeg",
5909
+ ".jpeg": "image/jpeg",
5910
+ ".gif": "image/gif",
5911
+ ".webp": "image/webp",
5912
+ ".svg": "image/svg+xml",
5913
+ ".mp4": "video/mp4",
5914
+ ".webm": "video/webm",
5915
+ ".mov": "video/quicktime"
5916
+ };
5917
+ const contentType = mimeTypes[ext] || "application/octet-stream";
5918
+ res.writeHead(200, {
5919
+ "Content-Type": contentType,
5920
+ "Content-Length": stat.size,
5921
+ "Cache-Control": "public, max-age=86400"
5922
+ });
5923
+ fs8.createReadStream(filePath).pipe(res);
5238
5924
  return true;
5239
5925
  }
5240
5926
  if (pathname === "/api/planning/plan-history") {
@@ -5348,6 +6034,10 @@ function createApiHandler(storage, workspaceRoot) {
5348
6034
  }
5349
6035
  if (pathname === "/api/planning/plans") {
5350
6036
  const { ticketId, planMarkdown, lastHash } = body;
6037
+ if (!ticketId) {
6038
+ sendJson(res, 400, { error: "ticketId is required." });
6039
+ return true;
6040
+ }
5351
6041
  invalidateApiCache();
5352
6042
  await reqStorage.savePlan(ticketId, planMarkdown, lastHash);
5353
6043
  sendJson(res, 200, { success: true });
@@ -5355,6 +6045,10 @@ function createApiHandler(storage, workspaceRoot) {
5355
6045
  }
5356
6046
  if (pathname === "/api/planning/comments") {
5357
6047
  const { ticketId, text, author } = body;
6048
+ if (!ticketId) {
6049
+ sendJson(res, 400, { error: "ticketId is required." });
6050
+ return true;
6051
+ }
5358
6052
  invalidateApiCache();
5359
6053
  await reqStorage.addComment(ticketId, { author: author || "Developer", text });
5360
6054
  const ticket = await reqStorage.getTicket(ticketId);
@@ -5363,12 +6057,22 @@ function createApiHandler(storage, workspaceRoot) {
5363
6057
  }
5364
6058
  if (pathname === "/api/planning/update-meta") {
5365
6059
  const { ticketId, updates, lastHash } = body;
6060
+ if (!ticketId) {
6061
+ sendJson(res, 400, { error: "ticketId is required." });
6062
+ return true;
6063
+ }
6064
+ invalidateApiCache();
5366
6065
  const updated = await reqStorage.updateTicket(ticketId, updates, lastHash);
5367
6066
  sendJson(res, 200, { meta: updated.meta });
5368
6067
  return true;
5369
6068
  }
5370
6069
  if (pathname === "/api/planning/toggle-flag") {
5371
6070
  const { ticketId, flagged } = body;
6071
+ if (!ticketId) {
6072
+ sendJson(res, 400, { error: "ticketId is required." });
6073
+ return true;
6074
+ }
6075
+ invalidateApiCache();
5372
6076
  const updated = await reqStorage.updateTicket(ticketId, {
5373
6077
  featureFlag: flagged ? "chat_enhanced" : ""
5374
6078
  });
@@ -5377,10 +6081,56 @@ function createApiHandler(storage, workspaceRoot) {
5377
6081
  }
5378
6082
  if (pathname === "/api/planning/details") {
5379
6083
  const { ticketId, detailMarkdown, metaUpdates } = body;
5380
- if (metaUpdates) {
5381
- await reqStorage.updateTicket(ticketId, metaUpdates);
6084
+ if (!ticketId) {
6085
+ sendJson(res, 400, { error: "ticketId is required." });
6086
+ return true;
6087
+ }
6088
+ invalidateApiCache();
6089
+ const result = await reqStorage.saveDetail(ticketId, detailMarkdown || "", metaUpdates);
6090
+ sendJson(res, 200, result);
6091
+ return true;
6092
+ }
6093
+ if (pathname === "/api/planning/answers") {
6094
+ const { ticketId, questionIndex, answer } = body;
6095
+ if (ticketId === void 0 || questionIndex === void 0 || answer === void 0) {
6096
+ sendJson(res, 400, { error: "ticketId, questionIndex, and answer are required." });
6097
+ return true;
6098
+ }
6099
+ invalidateApiCache();
6100
+ const updated = await reqStorage.saveAnswer(ticketId, Number(questionIndex), String(answer));
6101
+ sendJson(res, 200, updated);
6102
+ return true;
6103
+ }
6104
+ if (pathname === "/api/planning/save-inline-comment" || pathname === "/api/planning/inline-comments") {
6105
+ const { ticketId, selectedText, comment, author } = body;
6106
+ if (!ticketId || !selectedText || !comment) {
6107
+ sendJson(res, 400, { error: "ticketId, selectedText, and comment are required." });
6108
+ return true;
5382
6109
  }
5383
- sendJson(res, 200, { success: true, detail: detailMarkdown });
6110
+ invalidateApiCache();
6111
+ const inlineComments = reqStorage.saveInlineComment ? await reqStorage.saveInlineComment(ticketId, selectedText, comment, author) : [];
6112
+ sendJson(res, 200, { inlineComments });
6113
+ return true;
6114
+ }
6115
+ if (pathname === "/api/planning/upload-attachment") {
6116
+ const { ticketId, filename, base64Data } = body;
6117
+ if (!ticketId || !filename || !base64Data) {
6118
+ sendJson(res, 400, { error: "ticketId, filename, and base64Data are required." });
6119
+ return true;
6120
+ }
6121
+ const base64Content = base64Data.replace(/^data:[^;]+;base64,/, "");
6122
+ const buffer = Buffer.from(base64Content, "base64");
6123
+ if (!reqStorage.saveAttachment) {
6124
+ sendJson(res, 500, { error: "Attachments are not supported by the storage engine." });
6125
+ return true;
6126
+ }
6127
+ invalidateApiCache();
6128
+ const saved = await reqStorage.saveAttachment(ticketId, filename, buffer);
6129
+ sendJson(res, 200, {
6130
+ success: true,
6131
+ filename: saved.filename,
6132
+ url: `/api/planning/attachment?ticketId=${encodeURIComponent(ticketId)}&file=${encodeURIComponent(saved.filename)}`
6133
+ });
5384
6134
  return true;
5385
6135
  }
5386
6136
  if (pathname === "/api/planning/milestones") {
@@ -5461,35 +6211,35 @@ function startApiServer(port, storage, workspaceRoot) {
5461
6211
 
5462
6212
  // src/server/gateway.ts
5463
6213
  var __filename2 = fileURLToPath2(import.meta.url);
5464
- var __dirname2 = path8.dirname(__filename2);
6214
+ var __dirname2 = path9.dirname(__filename2);
5465
6215
  function resolveWebDir(explicitWebDir, workspaceRoot) {
5466
- if (explicitWebDir && fs8.existsSync(path8.join(explicitWebDir, "index.html"))) {
6216
+ if (explicitWebDir && fs9.existsSync(path9.join(explicitWebDir, "index.html"))) {
5467
6217
  return explicitWebDir;
5468
6218
  }
5469
6219
  const candidates = [
5470
6220
  // 1. Packaged layout right next to executable (e.g. tools/web)
5471
- path8.resolve(__dirname2, "web"),
6221
+ path9.resolve(__dirname2, "web"),
5472
6222
  // 2. From workspace root: <workspaceRoot>/dist/web
5473
- workspaceRoot ? path8.resolve(workspaceRoot, "dist", "web") : "",
6223
+ workspaceRoot ? path9.resolve(workspaceRoot, "dist", "web") : "",
5474
6224
  // 3. In-repo legacy subfolder: <workspaceRoot>/esedre/dist/web
5475
- workspaceRoot ? path8.resolve(workspaceRoot, "esedre", "dist", "web") : "",
6225
+ workspaceRoot ? path9.resolve(workspaceRoot, "esedre", "dist", "web") : "",
5476
6226
  // 4. Sibling standalone repository from workspace root: <workspaceRoot>/../esedre/dist/web
5477
- workspaceRoot ? path8.resolve(workspaceRoot, "..", "esedre", "dist", "web") : "",
6227
+ workspaceRoot ? path9.resolve(workspaceRoot, "..", "esedre", "dist", "web") : "",
5478
6228
  // 5. Packaged layout inside dist/web
5479
- path8.resolve(__dirname2, "dist", "web"),
6229
+ path9.resolve(__dirname2, "dist", "web"),
5480
6230
  // 6. Sibling standalone repository from __dirname: <__dirname>/../../esedre/dist/web
5481
- path8.resolve(__dirname2, "..", "..", "esedre", "dist", "web"),
6231
+ path9.resolve(__dirname2, "..", "..", "esedre", "dist", "web"),
5482
6232
  // 7. Source tree layout: esedre/src/server/ -> esedre/dist/web
5483
- path8.resolve(__dirname2, "..", "..", "dist", "web"),
6233
+ path9.resolve(__dirname2, "..", "..", "dist", "web"),
5484
6234
  // 8. Legacy layout from __dirname
5485
- path8.resolve(__dirname2, "..", "esedre", "dist", "web")
6235
+ path9.resolve(__dirname2, "..", "esedre", "dist", "web")
5486
6236
  ].filter(Boolean);
5487
6237
  for (const candidate of candidates) {
5488
- if (fs8.existsSync(path8.join(candidate, "index.html"))) {
6238
+ if (fs9.existsSync(path9.join(candidate, "index.html"))) {
5489
6239
  return candidate;
5490
6240
  }
5491
6241
  }
5492
- return candidates[0] || path8.resolve(__dirname2, "web");
6242
+ return candidates[0] || path9.resolve(__dirname2, "web");
5493
6243
  }
5494
6244
  function proxyRequest(req, res, targetPort, rewritePath) {
5495
6245
  const targetPath = rewritePath ? rewritePath(req.url || "/") : req.url || "/";
@@ -5609,7 +6359,7 @@ ${colors.bold}ROADMAP COMMANDS (Pair Programming & LLM Agents):${colors.reset}
5609
6359
  ${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
6360
  Mint a new roadmap ticket with sequential numeric ID.
5611
6361
 
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]
6362
+ ${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
6363
  Mutate ticket status, type, priority, title, complexity, effort, active state, or feature flag with OCC protection.
5614
6364
 
5615
6365
  ${colors.bold}comment${colors.reset} <id> ["<text>"] [--text "..."] [--author "..."] [--json]
@@ -5661,8 +6411,9 @@ ${colors.bold}DEVELOPER ADMINISTRATION (Human Setup & Configuration):${colors.re
5661
6411
  \u2022 remove <target> Unlink a project or data hub
5662
6412
  \u2022 set <key> <val> Update a central configuration setting
5663
6413
 
5664
- ${colors.bold}upgrade${colors.reset} [<path>] [--force|-f] [--json]
6414
+ ${colors.bold}upgrade${colors.reset} [<path>] [--all|-a] [--force|-f] [--json]
5665
6415
  Upgrade workspace configuration schema, wrappers, and agent skills. Refreshes snapshot.
6416
+ Pass --all (-a) to upgrade all registered projects in central ~/.esedre/config.json.
5666
6417
  Pass --force to overwrite customized skills with the latest official template.
5667
6418
 
5668
6419
  ${colors.bold}projects${colors.reset} [--json]
@@ -5675,6 +6426,7 @@ ${colors.bold}DEVELOPER ADMINISTRATION (Human Setup & Configuration):${colors.re
5675
6426
  Safely rename a project code on disk, updating hub folders, manifests, ticket references, and workspace configs.
5676
6427
 
5677
6428
  ${colors.bold}OPTIONS:${colors.reset}
6429
+ -a, --all Apply operation across all registered projects in central configuration.
5678
6430
  -p, --project <code> Project code (e.g. CORE, ALCE, DOCS).
5679
6431
  -n, --name <name> Project display name (e.g. "Alce Web Reader").
5680
6432
  -t, --type <type> Ticket type ('Feature', 'Platform', 'Tools', 'Idea', 'Bug').
@@ -5686,8 +6438,8 @@ ${colors.bold}OPTIONS:${colors.reset}
5686
6438
  --to <targetId> Target ticket ID for link/unlink commands.
5687
6439
  --blocked Filter tickets that are blocked by uncompleted tickets.
5688
6440
  --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).
6441
+ --detail "<md>" Specification markdown for ticket detail during create or update.
6442
+ --file <path> Path to markdown file for detail (create, update) or implementation plan (plan).
5691
6443
  --json Output raw machine-readable JSON (strongly recommended for autonomous LLM coding agents).
5692
6444
  --last-hash <hash> Optimistic concurrency control: last known sha1 hash of the ticket from 'get'.
5693
6445
  --force Bypass optimistic concurrency last-hash conflict checks on writes.
@@ -5752,6 +6504,7 @@ function parseArgs(rawArgs) {
5752
6504
  if (flags["q"] && !flags["search"]) flags["search"] = flags["q"];
5753
6505
  if (flags["r"] && !flags["relation"]) flags["relation"] = flags["r"];
5754
6506
  if (flags["f"] && !flags["foreground"]) flags["foreground"] = flags["f"];
6507
+ if (flags["a"] && !flags["all"]) flags["all"] = flags["a"];
5755
6508
  return { command, positionals, flags };
5756
6509
  }
5757
6510
  function printDuplicateProjectWarnings(storage, isJson) {
@@ -5759,7 +6512,7 @@ function printDuplicateProjectWarnings(storage, isJson) {
5759
6512
  const warnings = storage.getDuplicateProjectWarnings ? storage.getDuplicateProjectWarnings() : [];
5760
6513
  for (const warn of warnings) {
5761
6514
  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}`
6515
+ `${colors.yellow}\u26A0\uFE0F Warning: Duplicate project code '${warn.code}' detected across multiple data hubs (${path10.basename(warn.firstHub)} and ${path10.basename(warn.duplicateHub)}). Duplicate project codes across hubs are not supported; only '${path10.basename(warn.firstHub)}' is active.${colors.reset}`
5763
6516
  );
5764
6517
  }
5765
6518
  }
@@ -5802,7 +6555,7 @@ async function main() {
5802
6555
  switch (command) {
5803
6556
  case "init": {
5804
6557
  const rawTarget = positionals[0];
5805
- const targetDir = rawTarget ? path9.resolve(rawTarget) : discovered.workspaceRoot;
6558
+ const targetDir = rawTarget ? path10.resolve(rawTarget) : discovered.workspaceRoot;
5806
6559
  const rawHubFlag = flags["hub"];
5807
6560
  const hubArg = typeof rawHubFlag === "string" ? rawHubFlag : void 0;
5808
6561
  const isHubRequested = Boolean(rawHubFlag);
@@ -5881,7 +6634,7 @@ ${colors.bold}Next Steps:${colors.reset}`);
5881
6634
  console.error(`${colors.red}Error: ${nameVal.error}${colors.reset}`);
5882
6635
  process.exit(1);
5883
6636
  }
5884
- const isTargetHub = fs9.existsSync(path9.join(targetDir, "projects.json")) || fs9.existsSync(path9.join(targetDir, "projects")) && !fs9.existsSync(path9.join(targetDir, ".esedre", "esedre.json"));
6637
+ const isTargetHub = fs10.existsSync(path10.join(targetDir, "projects.json")) || fs10.existsSync(path10.join(targetDir, "projects")) && !fs10.existsSync(path10.join(targetDir, ".esedre", "esedre.json"));
5885
6638
  const isWorkspaceless = isHubRequested || isTargetHub || Boolean(discovered.config?.projectCode) && !rawTarget && discovered.config?.projectCode?.toLowerCase() !== projectCode.toLowerCase();
5886
6639
  let projectNewlyRegistered = false;
5887
6640
  let registeredProj;
@@ -5952,7 +6705,7 @@ ${colors.bold}Next Steps:${colors.reset}`);
5952
6705
  const viteConfigPath = findViteConfig(targetDir);
5953
6706
  if (viteConfigPath && setupProxy === void 0 && !isJson) {
5954
6707
  try {
5955
- const rawContent = fs9.readFileSync(viteConfigPath, "utf-8");
6708
+ const rawContent = fs10.readFileSync(viteConfigPath, "utf-8");
5956
6709
  const alreadyConfigured = rawContent.includes("'/esedre'") || rawContent.includes('"/esedre"');
5957
6710
  if (!alreadyConfigured) {
5958
6711
  if (isYes) {
@@ -5962,7 +6715,7 @@ ${colors.bold}Next Steps:${colors.reset}`);
5962
6715
  const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
5963
6716
  try {
5964
6717
  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] `
6718
+ `${colors.cyan}?${colors.reset} Detected ${colors.bold}${path10.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
6719
  );
5967
6720
  const trimmed = answer.trim().toLowerCase();
5968
6721
  setupProxy = trimmed === "" || trimmed === "y" || trimmed === "yes";
@@ -5978,10 +6731,13 @@ ${colors.bold}Next Steps:${colors.reset}`);
5978
6731
  let hubRelPath = void 0;
5979
6732
  if (rawHub) {
5980
6733
  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;
6734
+ const resolvedHub = path10.resolve(expandHome(singleHub));
6735
+ if (fs10.existsSync(resolvedHub)) {
6736
+ const rel = path10.relative(targetDir, resolvedHub).replace(/\\/g, "/");
6737
+ const isSubdir = !rel.startsWith("..") && !path10.isAbsolute(rel);
6738
+ if (isSubdir || Boolean(flags["data-dir"])) {
6739
+ hubRelPath = rel.startsWith(".") ? rel : "./" + rel;
6740
+ }
5985
6741
  }
5986
6742
  }
5987
6743
  const res = initWorkspace(targetDir, {
@@ -6152,7 +6908,7 @@ ${colors.bold}Next Steps:${colors.reset}`);
6152
6908
  }
6153
6909
  console.log(`
6154
6910
  ${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}`);
6911
+ console.log(` \u2022 Config File: ${configPath} ${fs10.existsSync(configPath) ? colors.green + "(active)" + colors.reset : colors.dim + "(default)" + colors.reset}`);
6156
6912
  console.log(` \u2022 Default Port: ${globalCfg.port || DEFAULT_ESEDRE_PORT}`);
6157
6913
  console.log(`
6158
6914
  ${colors.bold}Data Hubs (dataDir):${colors.reset}`);
@@ -6184,9 +6940,46 @@ ${colors.bold}Commands:${colors.reset}`);
6184
6940
  return;
6185
6941
  }
6186
6942
  case "upgrade": {
6187
- const targetDir = positionals[0] ? path9.resolve(positionals[0]) : discovered.workspaceRoot;
6188
- const targetDiscovered = targetDir === discovered.workspaceRoot ? discovered : findEsedreConfig(targetDir, { fallbackToGlobal: true });
6943
+ const isAll = Boolean(flags["all"] || flags["a"]);
6189
6944
  const isForce = Boolean(flags["force"] || flags["f"]);
6945
+ if (isAll) {
6946
+ const globalCfg = readGlobalConfig();
6947
+ const allRes = await upgradeAllWorkspaces({
6948
+ currentWorkspaceRoot: discovered.workspaceRoot,
6949
+ globalConfig: globalCfg,
6950
+ force: isForce
6951
+ });
6952
+ if (isJson) {
6953
+ console.log(JSON.stringify(allRes, null, 2));
6954
+ return;
6955
+ }
6956
+ if (allRes.workspaces.length === 0) {
6957
+ console.log(`${colors.yellow}No registered workspaces found to upgrade.${colors.reset}`);
6958
+ return;
6959
+ }
6960
+ console.log(`
6961
+ ${colors.bold}${colors.green}\u2714 Esedre multi-workspace upgrade completed across ${allRes.workspaces.length} workspace(s):${colors.reset}`);
6962
+ for (const w of allRes.workspaces) {
6963
+ if (w.error) {
6964
+ console.log(` \u2022 ${colors.red}\u2717 ${w.name} (${w.dir}): ${w.error}${colors.reset}`);
6965
+ } else {
6966
+ const snapStr = w.totalTickets !== void 0 ? ` (${w.totalTickets} tickets projected)` : "";
6967
+ console.log(` \u2022 ${colors.bold}${w.name}${colors.reset} (${w.dir}):`);
6968
+ console.log(` - Wrappers updated in .esedre/`);
6969
+ if (w.totalTickets !== void 0) {
6970
+ console.log(` - Refreshed .esedre/snapshot.json${snapStr}`);
6971
+ }
6972
+ if (w.upgrade.skillConfigured) {
6973
+ console.log(` - Updated .agents/skills/esedre/SKILL.md`);
6974
+ } else if (w.upgrade.skillStatus === "CUSTOMIZED") {
6975
+ console.log(` - Preserved customized .agents/skills/esedre/SKILL.md`);
6976
+ }
6977
+ }
6978
+ }
6979
+ return;
6980
+ }
6981
+ const targetDir = positionals[0] ? path10.resolve(positionals[0]) : discovered.workspaceRoot;
6982
+ const targetDiscovered = targetDir === discovered.workspaceRoot ? discovered : findEsedreConfig(targetDir, { fallbackToGlobal: true });
6190
6983
  const res = configureWorkspace(targetDir, {
6191
6984
  projectCode: targetDiscovered.config?.projectCode,
6192
6985
  allowedProjects: targetDiscovered.config?.allowedProjects,
@@ -6293,8 +7086,10 @@ ${colors.dim}Shutting down Esedre cluster...${colors.reset}`);
6293
7086
  const flagPort = flags["port"] ? parseInt(String(flags["port"]), 10) : void 0;
6294
7087
  const port = resolvePorts(discovered.config, { port: flagPort }).gateway;
6295
7088
  const status = await getDaemonStatus({ port, workspaceRoot: discovered.workspaceRoot });
7089
+ const globalCfg = readGlobalConfig() || discovered.config;
7090
+ const outdatedWrappers = findOutdatedWrappers(discovered.workspaceRoot, globalCfg);
6296
7091
  if (isJson) {
6297
- console.log(JSON.stringify(status, null, 2));
7092
+ console.log(JSON.stringify({ ...status, outdatedWrappers }, null, 2));
6298
7093
  } else {
6299
7094
  if (status.running) {
6300
7095
  console.log(`${colors.bold}${colors.green}\u25CF Esedre daemon is RUNNING${colors.reset}`);
@@ -6312,10 +7107,14 @@ ${colors.yellow}\u26A0\uFE0F Warning: Daemon is running v${status.version}, but
6312
7107
  }
6313
7108
  } else {
6314
7109
  console.log(`${colors.yellow}\u25CB Esedre daemon is STOPPED (port ${status.port})${colors.reset}`);
6315
- if (status.logFile && fs9.existsSync(status.logFile)) {
7110
+ if (status.logFile && fs10.existsSync(status.logFile)) {
6316
7111
  console.log(` \u2022 Recent Logs: ${status.logFile}`);
6317
7112
  }
6318
7113
  }
7114
+ if (outdatedWrappers.length > 0) {
7115
+ console.log(`
7116
+ ${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}`);
7117
+ }
6319
7118
  }
6320
7119
  return;
6321
7120
  }
@@ -6513,11 +7312,11 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
6513
7312
  if (setPlan || filePath) {
6514
7313
  let content = setPlan;
6515
7314
  if (filePath) {
6516
- if (!fs9.existsSync(filePath)) {
7315
+ if (!fs10.existsSync(filePath)) {
6517
7316
  console.error(`${colors.red}Error: Plan file "${filePath}" not found.${colors.reset}`);
6518
7317
  process.exit(1);
6519
7318
  }
6520
- content = fs9.readFileSync(filePath, "utf-8");
7319
+ content = fs10.readFileSync(filePath, "utf-8");
6521
7320
  }
6522
7321
  await storage.savePlan(id, content, lastHash);
6523
7322
  if (isJson) {
@@ -6589,8 +7388,8 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
6589
7388
  const milestone = flags["milestone"] || flags["m"] || void 0;
6590
7389
  let detailMarkdown = detailArg;
6591
7390
  if (fileArg) {
6592
- const filePath = path9.resolve(fileArg);
6593
- if (!fs9.existsSync(filePath)) {
7391
+ const filePath = path10.resolve(fileArg);
7392
+ if (!fs10.existsSync(filePath)) {
6594
7393
  if (isJson) {
6595
7394
  console.error(JSON.stringify({ error: `Detail file "${fileArg}" not found` }));
6596
7395
  } else {
@@ -6598,7 +7397,7 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
6598
7397
  }
6599
7398
  process.exit(1);
6600
7399
  }
6601
- detailMarkdown = fs9.readFileSync(filePath, "utf-8");
7400
+ detailMarkdown = fs10.readFileSync(filePath, "utf-8");
6602
7401
  }
6603
7402
  const created = await storage.createTicket({
6604
7403
  title,
@@ -6664,6 +7463,22 @@ ${colors.dim}Total: ${tickets.length} tickets${colors.reset}`);
6664
7463
  if (inDev !== void 0) updates.isActivePlanning = inDev;
6665
7464
  if (flag) updates.featureFlag = flag;
6666
7465
  if (rawMilestone !== void 0) updates.milestone = rawMilestone;
7466
+ const detailArg = flags["detail"];
7467
+ const fileArg = flags["file"];
7468
+ let detailMarkdown = detailArg ? normalizeDashesAndMojibake(detailArg) : void 0;
7469
+ if (fileArg) {
7470
+ const filePath = path10.resolve(fileArg);
7471
+ if (!fs10.existsSync(filePath)) {
7472
+ if (isJson) {
7473
+ console.error(JSON.stringify({ error: `Detail file "${fileArg}" not found` }));
7474
+ } else {
7475
+ console.error(`${colors.red}Error: Detail file "${fileArg}" not found.${colors.reset}`);
7476
+ }
7477
+ process.exit(1);
7478
+ }
7479
+ detailMarkdown = normalizeDashesAndMojibake(fs10.readFileSync(filePath, "utf-8"));
7480
+ }
7481
+ if (detailMarkdown !== void 0) updates.detailMarkdown = detailMarkdown;
6667
7482
  const updated = await storage.updateTicket(lookupKey, updates, lastHash);
6668
7483
  if (isJson) {
6669
7484
  console.log(JSON.stringify(updated, null, 2));