@cccarv82/freya 2.3.6 → 2.3.8

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.
Files changed (3) hide show
  1. package/cli/web-ui.js +239 -3
  2. package/cli/web.js +435 -39
  3. package/package.json +1 -1
package/cli/web-ui.js CHANGED
@@ -17,6 +17,8 @@
17
17
  autoApply: true,
18
18
  autoRunReports: false,
19
19
  prettyPublish: true,
20
+ timelineProject: '',
21
+ timelineTag: '',
20
22
  chatSessionId: null,
21
23
  chatLoaded: false
22
24
  };
@@ -705,8 +707,61 @@
705
707
  const filter = String(($('timelineFilter') && $('timelineFilter').value) || '').toLowerCase();
706
708
  const items = Array.isArray(state.timeline) ? state.timeline : [];
707
709
  const kind = state.timelineKind || 'all';
710
+ const projectSelect = $('timelineProject');
711
+ const tagSelect = $('timelineTag');
712
+ const getItemSlug = (it) => {
713
+ if (!it) return '';
714
+ if (it.slug) return String(it.slug);
715
+ if (it.kind === 'task' && it.content) return String(it.content);
716
+ return '';
717
+ };
718
+ const collectTimelineOptions = (list) => {
719
+ const slugs = new Set();
720
+ const tags = new Set();
721
+ for (const it of list) {
722
+ const slug = getItemSlug(it);
723
+ if (slug) slugs.add(slug);
724
+ const itTags = Array.isArray(it.tags) ? it.tags : [];
725
+ for (const tag of itTags) {
726
+ const cleaned = String(tag || '').trim();
727
+ if (cleaned) tags.add(cleaned);
728
+ }
729
+ }
730
+ return {
731
+ slugs: Array.from(slugs).sort((a, b) => a.localeCompare(b)),
732
+ tags: Array.from(tags).sort((a, b) => a.localeCompare(b))
733
+ };
734
+ };
735
+ const syncSelect = (selectEl, options, selected, placeholder) => {
736
+ if (!selectEl) return selected;
737
+ const nextSelected = options.includes(selected) ? selected : '';
738
+ selectEl.innerHTML = '';
739
+ const allOpt = document.createElement('option');
740
+ allOpt.value = '';
741
+ allOpt.textContent = placeholder;
742
+ selectEl.appendChild(allOpt);
743
+ for (const opt of options) {
744
+ const optionEl = document.createElement('option');
745
+ optionEl.value = opt;
746
+ optionEl.textContent = opt;
747
+ selectEl.appendChild(optionEl);
748
+ }
749
+ selectEl.value = nextSelected;
750
+ return nextSelected;
751
+ };
752
+ const options = collectTimelineOptions(items);
753
+ const selectedProject = syncSelect(projectSelect, options.slugs, state.timelineProject || '', 'Todos projetos');
754
+ const selectedTag = syncSelect(tagSelect, options.tags, state.timelineTag || '', 'Todas tags');
755
+ state.timelineProject = selectedProject;
756
+ state.timelineTag = selectedTag;
708
757
  const filtered = items.filter((i) => {
709
- const hay = [i.kind, i.title, i.content, (i.tags || []).join(' ')].join(' ').toLowerCase();
758
+ const slug = getItemSlug(i);
759
+ if (state.timelineProject && slug !== state.timelineProject) return false;
760
+ if (state.timelineTag) {
761
+ const itTags = Array.isArray(i.tags) ? i.tags.map((t) => String(t)) : [];
762
+ if (!itTags.includes(state.timelineTag)) return false;
763
+ }
764
+ const hay = [i.kind, i.title, i.content, i.slug, (i.tags || []).join(' ')].join(' ').toLowerCase();
710
765
  return !filter || hay.includes(filter);
711
766
  });
712
767
  el.innerHTML = '';
@@ -747,6 +802,16 @@
747
802
  renderTimeline();
748
803
  }
749
804
 
805
+ function setTimelineProject(project) {
806
+ state.timelineProject = String(project || '');
807
+ renderTimeline();
808
+ }
809
+
810
+ function setTimelineTag(tag) {
811
+ state.timelineTag = String(tag || '');
812
+ renderTimeline();
813
+ }
814
+
750
815
  async function refreshTimeline() {
751
816
  try {
752
817
  const r = await api('/api/timeline', { dir: dirOrDefault() });
@@ -779,15 +844,54 @@
779
844
  if (current) cards.push(current);
780
845
  el.innerHTML = '';
781
846
  if (!cards.length) { el.innerHTML = renderMarkdown(md); return; }
782
- for (const c of cards) {
847
+ for (let idx = 0; idx < cards.length; idx++) {
848
+ const c = cards[idx];
783
849
  const card = document.createElement('div');
784
850
  card.className = 'reportCard';
785
851
  const dateLine = c.body.find((b)=> b.toLowerCase().includes('data'));
786
852
  const impactLine = c.body.find((b)=> b.toLowerCase().includes('descricao') || b.toLowerCase().includes('impacto'));
853
+ const statusLine = c.body.find((b)=> /^status\s*:/i.test(b));
854
+ const statusRaw = statusLine ? statusLine.split(':').slice(1).join(':').trim().toLowerCase() : '';
855
+ let statusKey = '';
856
+ if (['open', 'aberto', 'aberta'].includes(statusRaw)) statusKey = 'open';
857
+ else if (['mitigating', 'mitigando', 'mitigacao', 'mitigação'].includes(statusRaw)) statusKey = 'mitigating';
858
+ else if (['resolved', 'resolvido', 'resolvida', 'closed', 'fechado', 'fechada'].includes(statusRaw)) statusKey = 'resolved';
859
+
787
860
  card.innerHTML = '<div class="reportTitle">' + escapeHtml(c.title) + '</div>'
788
861
  + (dateLine ? ('<div class="reportMeta">' + escapeHtml(dateLine) + '</div>') : '')
789
862
  + (impactLine ? ('<div class="help" style="margin-top:4px">' + escapeHtml(impactLine) + '</div>') : '')
790
- + c.body.filter((b)=> b!==dateLine && b!==impactLine).map((b) => '<div class="help" style="margin-top:4px">' + escapeHtml(b) + '</div>').join('');
863
+ + c.body.filter((b)=> b!==dateLine && b!==impactLine && b!==statusLine).map((b) => '<div class="help" style="margin-top:4px">' + escapeHtml(b) + '</div>').join('');
864
+
865
+ if (statusKey) {
866
+ const actions = document.createElement('div');
867
+ actions.className = 'reportActions';
868
+ actions.style.display = 'flex';
869
+ actions.style.gap = '8px';
870
+ actions.style.marginTop = '8px';
871
+ actions.style.flexWrap = 'wrap';
872
+
873
+ const label = statusKey === 'open' ? 'aberto' : (statusKey === 'mitigating' ? 'mitigando' : 'resolvido');
874
+ const pillClass = statusKey === 'resolved' ? 'ok' : (statusKey === 'mitigating' ? 'info' : 'warn');
875
+ const pill = document.createElement('span');
876
+ pill.className = 'pill ' + pillClass;
877
+ pill.textContent = label;
878
+ actions.appendChild(pill);
879
+
880
+ if (statusKey !== 'resolved') {
881
+ const btn = document.createElement('button');
882
+ btn.className = 'btn small';
883
+ btn.type = 'button';
884
+ btn.textContent = 'Marcar resolvido';
885
+ btn.onclick = async () => {
886
+ await api('/api/incidents/resolve', { dir: dirOrDefault(), title: c.title, index: idx });
887
+ await refreshIncidents();
888
+ };
889
+ actions.appendChild(btn);
890
+ }
891
+
892
+ card.appendChild(actions);
893
+ }
894
+
791
895
  el.appendChild(card);
792
896
  }
793
897
  }
@@ -814,10 +918,20 @@
814
918
  for (const it of items) {
815
919
  const row = document.createElement('div');
816
920
  row.className = 'rep';
921
+ const priority = String(it.priority || '').toLowerCase();
922
+ const pill = priority ? ('<span class="pill ' + (priority === 'high' ? 'warn' : (priority === 'medium' ? 'info' : '')) + '">' + escapeHtml(priority) + '</span>') : '';
923
+ const action = it.linkRel ? ('<button class="btn small" type="button" data-link="' + escapeHtml(it.linkRel) + '">Abrir status</button>') : '';
817
924
  row.innerHTML = '<div style="display:flex; justify-content:space-between; gap:10px; align-items:center">'
818
925
  + '<div style="min-width:0"><div style="font-weight:800">' + escapeHtml(it.slug || 'unassigned') + '</div>'
819
926
  + '<div class="help" style="margin-top:4px">Total: ' + escapeHtml(String(it.total)) + ' · Pendentes: ' + escapeHtml(String(it.pending)) + ' · Concluidas: ' + escapeHtml(String(it.completed)) + '</div></div>'
927
+ + '<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap">' + pill + action + '</div>'
820
928
  + '</div>';
929
+ const btn = row.querySelector('button[data-link]');
930
+ if (btn) {
931
+ btn.onclick = async () => {
932
+ await api('/api/reports/open', { dir: dirOrDefault(), relPath: btn.getAttribute('data-link') });
933
+ };
934
+ }
821
935
  el.appendChild(row);
822
936
  }
823
937
  if (!items.length) {
@@ -1154,6 +1268,120 @@
1154
1268
  }
1155
1269
  }
1156
1270
 
1271
+ async function refreshQualityScore() {
1272
+ const el = $('qualityScoreCard');
1273
+ if (el) el.innerHTML = '<div class="help">Carregando score...</div>';
1274
+ try {
1275
+ const r = await api('/api/quality/score', { dir: dirOrDefault() });
1276
+ if (!el) return;
1277
+ if (r && r.needsInit) {
1278
+ el.innerHTML = '<div class="help">Workspace não inicializado.</div>';
1279
+ return;
1280
+ }
1281
+ const score = (r && typeof r.score === 'number') ? r.score : null;
1282
+ const breakdown = (r && r.breakdown) ? r.breakdown : {};
1283
+ const threshold = 90;
1284
+ const status = (score !== null && score >= threshold) ? 'ok' : 'warn';
1285
+
1286
+ const line = (label, data, keyLabel) => {
1287
+ if (!data) return '';
1288
+ const pct = (typeof data.pct === 'number') ? `${data.pct}%` : 'n/a';
1289
+ const detail = keyLabel ? `${data[keyLabel] || 0}/${data.total || 0}` : `${data.total || 0}`;
1290
+ return `<div class=\"help\" style=\"margin-top:4px\"><b>${escapeHtml(label)}:</b> ${escapeHtml(pct)} (${escapeHtml(detail)})</div>`;
1291
+ };
1292
+
1293
+ const html = `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
1294
+ + `<div style=\"min-width:0\"><div style=\"font-weight:800\">${score === null ? 'Sem score' : `Score: ${score}%`}</div>`
1295
+ + `${line('Tasks com projectSlug', breakdown.tasks, 'withProjectSlug')}`
1296
+ + `${line('Status com history', breakdown.status, 'withHistory')}`
1297
+ + `${line('Blockers com projectSlug', breakdown.blockers, 'withProjectSlug')}`
1298
+ + `</div>`
1299
+ + `<div class=\"pill ${status}\">${status}</div>`
1300
+ + `</div>`;
1301
+ el.innerHTML = html;
1302
+ } catch {
1303
+ if (el) el.innerHTML = '<div class="help">Falha ao carregar score.</div>';
1304
+ }
1305
+ }
1306
+
1307
+ async function refreshRiskSummary() {
1308
+ const el = $('riskSummary');
1309
+ if (el) el.innerHTML = '<div class="help">Carregando riscos...</div>';
1310
+ try {
1311
+ const r = await api('/api/risk/summary', { dir: dirOrDefault() });
1312
+ if (!el) return;
1313
+ if (r && r.needsInit) {
1314
+ el.innerHTML = '<div class="help">Workspace não inicializado.</div>';
1315
+ return;
1316
+ }
1317
+ const items = Array.isArray(r.items) ? r.items : [];
1318
+ if (!items.length) {
1319
+ el.innerHTML = '<div class="help">Sem riscos relevantes.</div>';
1320
+ return;
1321
+ }
1322
+ const rows = items.map((it) => {
1323
+ const age = (it.oldestBlockerDays != null) ? `${it.oldestBlockerDays}d` : 'n/a';
1324
+ return `<div class=\"rep\">`
1325
+ + `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
1326
+ + `<div style=\"min-width:0\"><div style=\"font-weight:800\">${escapeHtml(it.slug || '')}</div>`
1327
+ + `<div class=\"help\" style=\"margin-top:4px\">Pendentes: ${escapeHtml(String(it.pendingTasks || 0))} · Blockers 7d+: ${escapeHtml(String(it.oldBlockers || 0))} · Mais antigo: ${escapeHtml(age)}</div>`
1328
+ + `</div>`
1329
+ + `<div class=\"pill warn\">risco</div>`
1330
+ + `</div>`
1331
+ + `</div>`;
1332
+ }).join('');
1333
+ el.innerHTML = rows;
1334
+ } catch {
1335
+ if (el) el.innerHTML = '<div class="help">Falha ao carregar riscos.</div>';
1336
+ }
1337
+ }
1338
+
1339
+ async function refreshExecutiveSummary() {
1340
+ const el = $('executiveSummary');
1341
+ if (el) el.textContent = 'Carregando resumo...';
1342
+ try {
1343
+ const r = await api('/api/summary/executive', { dir: dirOrDefault() });
1344
+ if (!el) return;
1345
+ el.textContent = r.summary || 'Sem resumo disponível.';
1346
+ } catch {
1347
+ if (el) el.textContent = 'Falha ao carregar resumo.';
1348
+ }
1349
+ }
1350
+
1351
+ async function refreshAnomalies() {
1352
+ const el = $('anomaliesBox');
1353
+ if (el) el.innerHTML = '<div class="help">Carregando anomalias...</div>';
1354
+ try {
1355
+ const r = await api('/api/anomalies', { dir: dirOrDefault() });
1356
+ if (!el) return;
1357
+ const anomalies = (r && r.anomalies) ? r.anomalies : {};
1358
+ const tasksMissing = anomalies.tasksMissingProject || { count: 0, samples: [] };
1359
+ const statusMissing = anomalies.statusMissingHistory || { count: 0, samples: [] };
1360
+
1361
+ const rows = [];
1362
+ const pushRow = (label, data) => {
1363
+ const status = data.count > 0 ? 'warn' : 'ok';
1364
+ const samples = (data.samples || []).slice(0, 5).map((s) => `<div class=\"help\" style=\"margin-top:4px\">${escapeHtml(s)}</div>`).join('');
1365
+ rows.push(
1366
+ `<div class=\"rep\">`
1367
+ + `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
1368
+ + `<div style=\"min-width:0\"><div style=\"font-weight:800\">${escapeHtml(label)}</div>`
1369
+ + `<div class=\"help\" style=\"margin-top:4px\">${data.count} ocorrência(s)</div>`
1370
+ + `${samples}</div>`
1371
+ + `<div class=\"pill ${status}\">${status}</div>`
1372
+ + `</div>`
1373
+ + `</div>`
1374
+ );
1375
+ };
1376
+
1377
+ pushRow('Tarefas sem projectSlug', tasksMissing);
1378
+ pushRow('Status sem history', statusMissing);
1379
+ el.innerHTML = rows.join('') || '<div class="help">Sem anomalias.</div>';
1380
+ } catch {
1381
+ if (el) el.innerHTML = '<div class="help">Falha ao carregar anomalias.</div>';
1382
+ }
1383
+ }
1384
+
1157
1385
  async function doHealth() {
1158
1386
  try {
1159
1387
  saveLocal();
@@ -1546,6 +1774,10 @@
1546
1774
 
1547
1775
  if (isCompanionPage) {
1548
1776
  await refreshHealthChecklist();
1777
+ await refreshQualityScore();
1778
+ await refreshExecutiveSummary();
1779
+ await refreshAnomalies();
1780
+ await refreshRiskSummary();
1549
1781
  await refreshIncidents();
1550
1782
  await refreshHeatmap();
1551
1783
  return;
@@ -1597,6 +1829,10 @@
1597
1829
  window.setTimelineKind = setTimelineKind;
1598
1830
  window.refreshBlockersInsights = refreshBlockersInsights;
1599
1831
  window.refreshHealthChecklist = refreshHealthChecklist;
1832
+ window.refreshQualityScore = refreshQualityScore;
1833
+ window.refreshExecutiveSummary = refreshExecutiveSummary;
1834
+ window.refreshAnomalies = refreshAnomalies;
1835
+ window.refreshRiskSummary = refreshRiskSummary;
1600
1836
  window.copyOut = copyOut;
1601
1837
  window.copyPath = copyPath;
1602
1838
  window.openSelected = openSelected;
package/cli/web.js CHANGED
@@ -255,6 +255,57 @@ function splitForDiscord(text, limit = 1900) {
255
255
  return parts;
256
256
  }
257
257
 
258
+ function parseIncidentMarkdown(md) {
259
+ const lines = String(md || '').split(/\r?\n/);
260
+ const items = [];
261
+ let current = null;
262
+ for (const line of lines) {
263
+ if (line.startsWith('- **')) {
264
+ if (current) items.push(current);
265
+ current = { title: line.replace('- **', '').replace('**', '').trim(), body: [] };
266
+ continue;
267
+ }
268
+ if (current && line.trim().startsWith('- ')) {
269
+ current.body.push(line.trim().replace(/^- /, ''));
270
+ }
271
+ }
272
+ if (current) items.push(current);
273
+ return items;
274
+ }
275
+
276
+ function serializeIncidentMarkdown(items) {
277
+ return (items || []).map((it) => {
278
+ const body = Array.isArray(it.body) ? it.body : [];
279
+ const lines = ['- **' + (it.title || 'Incidente') + '**'];
280
+ for (const b of body) lines.push('- ' + b);
281
+ return lines.join('\n');
282
+ }).join('\n');
283
+ }
284
+
285
+ function resolveIncidentInMarkdown(md, title, index) {
286
+ const items = parseIncidentMarkdown(md);
287
+ const matches = items.map((it, i) => ({ it, i })).filter((x) => x.it.title === title);
288
+ if (!matches.length) return null;
289
+ const pick = typeof index === 'number' && Number.isInteger(index) ? matches[index] : matches[0];
290
+ if (!pick) return null;
291
+ const entry = pick.it;
292
+ const body = Array.isArray(entry.body) ? entry.body : [];
293
+ const statusIdx = body.findIndex((b) => /^status\s*:/i.test(b));
294
+ if (statusIdx >= 0) body[statusIdx] = 'Status: resolved';
295
+ else body.push('Status: resolved');
296
+ entry.body = body;
297
+ return serializeIncidentMarkdown(items);
298
+ }
299
+
300
+ function normalizePriority(value) {
301
+ const raw = String(value || '').trim().toLowerCase();
302
+ if (!raw) return '';
303
+ if (['high', 'alta', 'alto', 'critical', 'critico', 'crítico'].includes(raw)) return 'high';
304
+ if (['medium', 'media', 'médio', 'medio'].includes(raw)) return 'medium';
305
+ if (['low', 'baixa', 'baixo'].includes(raw)) return 'low';
306
+ return '';
307
+ }
308
+
258
309
  function postJson(url, bodyObj) {
259
310
  return new Promise((resolve, reject) => {
260
311
  const u = new URL(url);
@@ -1360,6 +1411,11 @@ function buildTimelineHtml(safeDefault, appVersion) {
1360
1411
  <input id=\"timelineFilter\" placeholder=\"filtrar (tag, projeto, tipo)\" oninput=\"renderTimeline()\" />
1361
1412
  </section>
1362
1413
 
1414
+ <section class=\"reportsTools\" style=\"display:grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px;\">
1415
+ <select id=\"timelineProject\" onchange=\"setTimelineProject(this.value)\"></select>
1416
+ <select id=\"timelineTag\" onchange=\"setTimelineTag(this.value)\"></select>
1417
+ </section>
1418
+
1363
1419
  <section class=\"reportsGrid\" id=\"timelineGrid\"></section>
1364
1420
  </div>
1365
1421
  </main>
@@ -1443,6 +1499,46 @@ function buildCompanionHtml(safeDefault, appVersion) {
1443
1499
 
1444
1500
  <section class="reportsGrid" id="healthChecklist"></section>
1445
1501
 
1502
+ <section class="panel" style="margin-top:16px">
1503
+ <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1504
+ <b>Qualidade de Log</b>
1505
+ <button class="btn small" type="button" onclick="refreshQualityScore()">Atualizar</button>
1506
+ </div>
1507
+ <div class="panelBody">
1508
+ <div id="qualityScoreCard"></div>
1509
+ </div>
1510
+ </section>
1511
+
1512
+ <section class="panel" style="margin-top:16px">
1513
+ <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1514
+ <b>Resumo Executivo</b>
1515
+ <button class="btn small" type="button" onclick="refreshExecutiveSummary()">Atualizar</button>
1516
+ </div>
1517
+ <div class="panelBody">
1518
+ <div id="executiveSummary" class="help"></div>
1519
+ </div>
1520
+ </section>
1521
+
1522
+ <section class="panel" style="margin-top:16px">
1523
+ <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1524
+ <b>Anomalias</b>
1525
+ <button class="btn small" type="button" onclick="refreshAnomalies()">Atualizar</button>
1526
+ </div>
1527
+ <div class="panelBody">
1528
+ <div id="anomaliesBox"></div>
1529
+ </div>
1530
+ </section>
1531
+
1532
+ <section class="panel" style="margin-top:16px">
1533
+ <div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
1534
+ <b>Resumo de Risco</b>
1535
+ <button class="btn small" type="button" onclick="refreshRiskSummary()">Atualizar</button>
1536
+ </div>
1537
+ <div class="panelBody">
1538
+ <div id="riskSummary"></div>
1539
+ </div>
1540
+ </section>
1541
+
1446
1542
  <section class="panel" style="margin-top:16px">
1447
1543
  <div class="panelHead"><b>Incident Radar</b></div>
1448
1544
  <div class="panelBody">
@@ -1621,6 +1717,62 @@ function readJsonOrNull(p) {
1621
1717
  }
1622
1718
  }
1623
1719
 
1720
+ function truncateText(text, maxLen) {
1721
+ const str = String(text || '').trim();
1722
+ if (str.length <= maxLen) return str;
1723
+ return str.slice(0, Math.max(0, maxLen - 3)).trimEnd() + '...';
1724
+ }
1725
+
1726
+ function getTimelineItems(workspaceDir) {
1727
+ const items = [];
1728
+ const dailyDir = path.join(workspaceDir, 'logs', 'daily');
1729
+ if (exists(dailyDir)) {
1730
+ const files = fs.readdirSync(dailyDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f));
1731
+ for (const f of files) {
1732
+ const date = f.replace('.md', '');
1733
+ const full = path.join(dailyDir, f);
1734
+ const body = fs.readFileSync(full, 'utf8');
1735
+ items.push({ kind: 'daily', date, title: `Daily ${date}`, content: body.slice(0, 500) });
1736
+ }
1737
+ }
1738
+ const base = path.join(workspaceDir, 'data', 'Clients');
1739
+ if (exists(base)) {
1740
+ const stack = [base];
1741
+ while (stack.length) {
1742
+ const dirp = stack.pop();
1743
+ const entries = fs.readdirSync(dirp, { withFileTypes: true });
1744
+ for (const ent of entries) {
1745
+ const full = path.join(dirp, ent.name);
1746
+ if (ent.isDirectory()) stack.push(full);
1747
+ else if (ent.isFile() && ent.name === 'status.json') {
1748
+ const doc = readJsonOrNull(full) || {};
1749
+ const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/');
1750
+ const hist = Array.isArray(doc.history) ? doc.history : [];
1751
+ for (const h of hist) {
1752
+ items.push({
1753
+ kind: 'status',
1754
+ date: h.date || '',
1755
+ title: `${doc.project || slug} (${h.type || 'Status'})`,
1756
+ content: h.content || '',
1757
+ tags: h.tags || [],
1758
+ slug
1759
+ });
1760
+ }
1761
+ }
1762
+ }
1763
+ }
1764
+ }
1765
+ const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
1766
+ const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
1767
+ const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
1768
+ for (const t of tasks) {
1769
+ if (t.createdAt) items.push({ kind: 'task', date: String(t.createdAt).slice(0, 10), title: `Task criada: ${t.description || t.id}`, content: t.projectSlug || '' });
1770
+ if (t.completedAt) items.push({ kind: 'task', date: String(t.completedAt).slice(0, 10), title: `Task concluida: ${t.description || t.id}`, content: t.projectSlug || '' });
1771
+ }
1772
+ items.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
1773
+ return items;
1774
+ }
1775
+
1624
1776
  function writeJson(p, obj) {
1625
1777
  ensureDir(path.dirname(p));
1626
1778
  fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8');
@@ -1935,17 +2087,75 @@ async function cmdWeb({ port, dir, open, dev }) {
1935
2087
  }
1936
2088
 
1937
2089
  if (req.url === '/api/timeline') {
1938
- const items = [];
1939
- const dailyDir = path.join(workspaceDir, 'logs', 'daily');
1940
- if (exists(dailyDir)) {
1941
- const files = fs.readdirSync(dailyDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f));
1942
- for (const f of files) {
1943
- const date = f.replace('.md', '');
1944
- const full = path.join(dailyDir, f);
1945
- const body = fs.readFileSync(full, 'utf8');
1946
- items.push({ kind: 'daily', date, title: `Daily ${date}`, content: body.slice(0, 500) });
2090
+ const items = getTimelineItems(workspaceDir);
2091
+ return safeJson(res, 200, { ok: true, items });
2092
+ }
2093
+
2094
+ if (req.url === '/api/summary/executive') {
2095
+ const items = getTimelineItems(workspaceDir);
2096
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
2097
+ const recent = items.filter((it) => {
2098
+ const ts = Date.parse(it.date || '');
2099
+ return Number.isFinite(ts) && ts >= cutoff;
2100
+ });
2101
+ const counts = { status: 0, taskCreated: 0, taskCompleted: 0, daily: 0 };
2102
+ for (const it of recent) {
2103
+ if (it.kind === 'status') counts.status++;
2104
+ else if (it.kind === 'daily') counts.daily++;
2105
+ else if (it.kind === 'task' && String(it.title || '').toLowerCase().includes('concluida')) counts.taskCompleted++;
2106
+ else if (it.kind === 'task') counts.taskCreated++;
2107
+ }
2108
+
2109
+ const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2110
+ const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
2111
+ const openBlockers = (blockerDoc.blockers || []).filter((b) => b && String(b.status || '').trim() === 'OPEN');
2112
+
2113
+ let summary = '';
2114
+ if (!recent.length) {
2115
+ summary = 'Sem eventos recentes na timeline nos últimos 7 dias.';
2116
+ } else {
2117
+ const parts = [];
2118
+ if (counts.taskCompleted) parts.push(`${counts.taskCompleted} tarefa(s) concluida(s)`);
2119
+ if (counts.taskCreated) parts.push(`${counts.taskCreated} tarefa(s) criada(s)`);
2120
+ if (counts.status) parts.push(`${counts.status} update(s) de status`);
2121
+ if (!parts.length && counts.daily) parts.push(`${counts.daily} daily(s)`);
2122
+ const head = parts.length ? parts.join(', ') : `${recent.length} eventos`;
2123
+ summary = `Resumo: ${head} nos últimos 7 dias.`;
2124
+ }
2125
+
2126
+ const blockersSentence = openBlockers.length
2127
+ ? `Há ${openBlockers.length} blocker(s) aberto(s).`
2128
+ : 'Sem blockers abertos.';
2129
+
2130
+ summary = `${summary} ${blockersSentence}`.trim();
2131
+ summary = truncateText(summary, 260);
2132
+ return safeJson(res, 200, { ok: true, summary, stats: { recent: recent.length, openBlockers: openBlockers.length, ...counts } });
2133
+ }
2134
+
2135
+ if (req.url === '/api/quality/score') {
2136
+ if (!looksLikeFreyaWorkspace(workspaceDir)) {
2137
+ return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
2138
+ }
2139
+
2140
+ const pct = (count, total) => (total > 0 ? Math.round((count / total) * 1000) / 10 : null);
2141
+
2142
+ // Tasks with projectSlug
2143
+ let tasksTotal = 0;
2144
+ let tasksWithProject = 0;
2145
+ const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
2146
+ if (exists(taskFile)) {
2147
+ const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2148
+ const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2149
+ tasksTotal = tasks.length;
2150
+ for (const t of tasks) {
2151
+ const slug = String(t && t.projectSlug ? t.projectSlug : '').trim();
2152
+ if (slug) tasksWithProject++;
1947
2153
  }
1948
2154
  }
2155
+
2156
+ // Status files with history array
2157
+ let statusTotal = 0;
2158
+ let statusWithHistory = 0;
1949
2159
  const base = path.join(workspaceDir, 'data', 'Clients');
1950
2160
  if (exists(base)) {
1951
2161
  const stack = [base];
@@ -1956,32 +2166,159 @@ if (req.url === '/api/timeline') {
1956
2166
  const full = path.join(dirp, ent.name);
1957
2167
  if (ent.isDirectory()) stack.push(full);
1958
2168
  else if (ent.isFile() && ent.name === 'status.json') {
2169
+ statusTotal++;
1959
2170
  const doc = readJsonOrNull(full) || {};
1960
- const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/');
1961
- const hist = Array.isArray(doc.history) ? doc.history : [];
1962
- for (const h of hist) {
1963
- items.push({
1964
- kind: 'status',
1965
- date: h.date || '',
1966
- title: `${doc.project || slug} (${h.type || 'Status'})`,
1967
- content: h.content || '',
1968
- tags: h.tags || [],
1969
- slug
1970
- });
1971
- }
2171
+ if (Array.isArray(doc.history)) statusWithHistory++;
1972
2172
  }
1973
2173
  }
1974
2174
  }
1975
2175
  }
2176
+
2177
+ // Blockers with projectSlug
2178
+ let blockersTotal = 0;
2179
+ let blockersWithProject = 0;
2180
+ const blockersFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2181
+ if (exists(blockersFile)) {
2182
+ const blockersDoc = readJsonOrNull(blockersFile) || { blockers: [] };
2183
+ const blockers = Array.isArray(blockersDoc.blockers) ? blockersDoc.blockers : [];
2184
+ blockersTotal = blockers.length;
2185
+ for (const b of blockers) {
2186
+ const slug = String(b && b.projectSlug ? b.projectSlug : '').trim();
2187
+ if (slug) blockersWithProject++;
2188
+ }
2189
+ }
2190
+
2191
+ const breakdown = {
2192
+ tasks: { total: tasksTotal, withProjectSlug: tasksWithProject, pct: pct(tasksWithProject, tasksTotal) },
2193
+ status: { total: statusTotal, withHistory: statusWithHistory, pct: pct(statusWithHistory, statusTotal) },
2194
+ blockers: { total: blockersTotal, withProjectSlug: blockersWithProject, pct: pct(blockersWithProject, blockersTotal) }
2195
+ };
2196
+
2197
+ const scoreParts = [breakdown.tasks.pct, breakdown.status.pct, breakdown.blockers.pct].filter((v) => typeof v === 'number');
2198
+ const score = scoreParts.length ? Math.round((scoreParts.reduce((a, b) => a + b, 0) / scoreParts.length) * 10) / 10 : null;
2199
+
2200
+ return safeJson(res, 200, { ok: true, score, breakdown });
2201
+ }
2202
+
2203
+ if (req.url === '/api/risk/summary') {
2204
+ if (!looksLikeFreyaWorkspace(workspaceDir)) {
2205
+ return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
2206
+ }
2207
+
2208
+ const pendingThreshold = 5;
2209
+ const daysThreshold = 7;
2210
+ const now = Date.now();
2211
+
2212
+ const pendingByProject = {};
1976
2213
  const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
1977
- const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
1978
- const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
1979
- for (const t of tasks) {
1980
- if (t.createdAt) items.push({ kind: 'task', date: String(t.createdAt).slice(0, 10), title: `Task criada: ${t.description || t.id}`, content: t.projectSlug || '' });
1981
- if (t.completedAt) items.push({ kind: 'task', date: String(t.completedAt).slice(0, 10), title: `Task concluida: ${t.description || t.id}`, content: t.projectSlug || '' });
2214
+ if (exists(taskFile)) {
2215
+ const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2216
+ const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2217
+ for (const t of tasks) {
2218
+ if (!t || t.status === 'COMPLETED') continue;
2219
+ const slug = String(t.projectSlug || '').trim();
2220
+ if (!slug) continue;
2221
+ pendingByProject[slug] = (pendingByProject[slug] || 0) + 1;
2222
+ }
1982
2223
  }
1983
- items.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
1984
- return safeJson(res, 200, { ok: true, items });
2224
+
2225
+ const blockersByProject = {};
2226
+ const oldestByProject = {};
2227
+ const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
2228
+ if (exists(blockerFile)) {
2229
+ const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
2230
+ const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
2231
+ for (const b of blockers) {
2232
+ if (!b) continue;
2233
+ const status = String(b.status || '').toUpperCase();
2234
+ if (status !== 'OPEN' && status !== 'MITIGATING') continue;
2235
+ const slug = String(b.projectSlug || '').trim();
2236
+ if (!slug) continue;
2237
+ const createdAt = b.createdAt ? Date.parse(b.createdAt) : null;
2238
+ if (!createdAt) continue;
2239
+ const ageDays = Math.floor((now - createdAt) / (24 * 60 * 60 * 1000));
2240
+ if (ageDays < daysThreshold) continue;
2241
+ blockersByProject[slug] = (blockersByProject[slug] || 0) + 1;
2242
+ if (oldestByProject[slug] == null || ageDays > oldestByProject[slug]) oldestByProject[slug] = ageDays;
2243
+ }
2244
+ }
2245
+
2246
+ const projects = new Set([...Object.keys(pendingByProject), ...Object.keys(blockersByProject)]);
2247
+ const items = [];
2248
+ for (const slug of projects) {
2249
+ const pending = pendingByProject[slug] || 0;
2250
+ const oldBlockers = blockersByProject[slug] || 0;
2251
+ const oldestDays = oldestByProject[slug] || null;
2252
+ if (pending <= pendingThreshold && oldBlockers === 0) continue;
2253
+ items.push({ slug, pendingTasks: pending, oldBlockers, oldestBlockerDays: oldestDays });
2254
+ }
2255
+
2256
+ items.sort((a, b) => {
2257
+ if (b.oldBlockers !== a.oldBlockers) return b.oldBlockers - a.oldBlockers;
2258
+ if (b.pendingTasks !== a.pendingTasks) return b.pendingTasks - a.pendingTasks;
2259
+ return (b.oldestBlockerDays || 0) - (a.oldestBlockerDays || 0);
2260
+ });
2261
+
2262
+ return safeJson(res, 200, { ok: true, items: items.slice(0, 5), threshold: { pending: pendingThreshold, days: daysThreshold } });
2263
+ }
2264
+
2265
+ if (req.url === '/api/anomalies') {
2266
+ const anomalies = {
2267
+ tasksMissingProject: { count: 0, samples: [] },
2268
+ statusMissingHistory: { count: 0, samples: [] }
2269
+ };
2270
+
2271
+ const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
2272
+ if (exists(taskFile)) {
2273
+ const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
2274
+ const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
2275
+ for (const t of tasks) {
2276
+ const slug = String(t.projectSlug || '').trim();
2277
+ if (!slug) {
2278
+ anomalies.tasksMissingProject.count++;
2279
+ if (anomalies.tasksMissingProject.samples.length < 5) {
2280
+ anomalies.tasksMissingProject.samples.push(`data/tasks/task-log.json::${t.id || t.description || 'task'}`);
2281
+ }
2282
+ }
2283
+ }
2284
+ }
2285
+
2286
+ const base = path.join(workspaceDir, 'data', 'Clients');
2287
+ if (exists(base)) {
2288
+ const stack = [base];
2289
+ while (stack.length) {
2290
+ const dirp = stack.pop();
2291
+ const entries = fs.readdirSync(dirp, { withFileTypes: true });
2292
+ for (const ent of entries) {
2293
+ const full = path.join(dirp, ent.name);
2294
+ if (ent.isDirectory()) stack.push(full);
2295
+ else if (ent.isFile() && ent.name === 'status.json') {
2296
+ const doc = readJsonOrNull(full) || {};
2297
+ if (!Array.isArray(doc.history)) {
2298
+ anomalies.statusMissingHistory.count++;
2299
+ if (anomalies.statusMissingHistory.samples.length < 5) {
2300
+ anomalies.statusMissingHistory.samples.push(path.relative(workspaceDir, full).replace(/\\/g, '/'));
2301
+ }
2302
+ }
2303
+ }
2304
+ }
2305
+ }
2306
+ }
2307
+
2308
+ return safeJson(res, 200, { ok: true, anomalies });
2309
+ }
2310
+
2311
+ if (req.url === '/api/incidents/resolve') {
2312
+ const title = payload.title;
2313
+ const index = Number.isInteger(payload.index) ? payload.index : null;
2314
+ if (!title) return safeJson(res, 400, { error: 'Missing title' });
2315
+ const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
2316
+ if (!exists(p)) return safeJson(res, 404, { error: 'Incident index not found' });
2317
+ const md = fs.readFileSync(p, 'utf8');
2318
+ const updated = resolveIncidentInMarkdown(md, title, index);
2319
+ if (!updated) return safeJson(res, 404, { error: 'Incident not found' });
2320
+ fs.writeFileSync(p, updated, 'utf8');
2321
+ return safeJson(res, 200, { ok: true });
1985
2322
  }
1986
2323
 
1987
2324
  if (req.url === '/api/incidents') {
@@ -1996,13 +2333,20 @@ if (req.url === '/api/timeline') {
1996
2333
  const doc = readJsonOrNull(file) || { tasks: [] };
1997
2334
  const tasks = Array.isArray(doc.tasks) ? doc.tasks : [];
1998
2335
  const map = {};
2336
+ const priorityRank = { high: 3, medium: 2, low: 1, '': 0 };
1999
2337
  for (const t of tasks) {
2000
2338
  const slug = t.projectSlug || 'unassigned';
2001
- if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0 };
2339
+ if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0, priority: '' };
2002
2340
  map[slug].total++;
2003
2341
  if (t.status === 'COMPLETED') map[slug].completed++; else map[slug].pending++;
2342
+ const p = normalizePriority(t.priority || t.severity);
2343
+ if (priorityRank[p] > priorityRank[map[slug].priority || '']) map[slug].priority = p;
2004
2344
  }
2005
- const items = Object.entries(map).map(([slug, v]) => ({ slug, ...v }));
2345
+ const items = Object.entries(map).map(([slug, v]) => {
2346
+ const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
2347
+ const linkRel = exists(statusPath) ? path.relative(workspaceDir, statusPath).replace(/\\/g, '/') : '';
2348
+ return { slug, ...v, linkRel };
2349
+ });
2006
2350
  items.sort((a, b) => b.total - a.total);
2007
2351
  return safeJson(res, 200, { ok: true, items });
2008
2352
  }
@@ -2075,6 +2419,7 @@ if (req.url === '/api/reports/list') {
2075
2419
  const lc = textInput.toLowerCase();
2076
2420
  const projectsDir = path.join(workspaceDir, 'docs', 'projects');
2077
2421
  const links = [];
2422
+ const slugs = [];
2078
2423
 
2079
2424
  if (exists(projectsDir)) {
2080
2425
  const files = fs.readdirSync(projectsDir).filter((f) => f.endsWith('.md'));
@@ -2084,7 +2429,10 @@ if (req.url === '/api/reports/list') {
2084
2429
  const txt = fs.readFileSync(full, 'utf8');
2085
2430
  const m = txt.match(/DataPath:\s*data\/Clients\/(.+?)\//i);
2086
2431
  const slug = m ? m[1].toLowerCase() : name.toLowerCase();
2087
- if (lc.includes(slug)) links.push('[[' + name + ']]');
2432
+ if (lc.includes(slug)) {
2433
+ links.push('[[' + name + ']]');
2434
+ slugs.push(slug);
2435
+ }
2088
2436
  }
2089
2437
  }
2090
2438
 
@@ -2099,17 +2447,21 @@ if (req.url === '/api/reports/list') {
2099
2447
  if (ent.isDirectory()) stack.push(full);
2100
2448
  else if (ent.isFile() && ent.name === 'status.json') {
2101
2449
  const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/').toLowerCase();
2102
- if (lc.includes(slug)) links.push('[[' + slug + ']]');
2450
+ if (lc.includes(slug)) {
2451
+ links.push('[[' + slug + ']]');
2452
+ slugs.push(slug);
2453
+ }
2103
2454
  }
2104
2455
  }
2105
2456
  }
2106
2457
  }
2107
2458
 
2108
- const uniq = Array.from(new Set(links));
2109
- if (!uniq.length) return '';
2110
- return '\n\nLinks: ' + uniq.join(' ');
2459
+ const uniqLinks = Array.from(new Set(links));
2460
+ const uniqSlugs = Array.from(new Set(slugs));
2461
+ const linksText = uniqLinks.length ? ('\n\nLinks: ' + uniqLinks.join(' ')) : '';
2462
+ return { linksText, slugs: uniqSlugs };
2111
2463
  } catch {
2112
- return '';
2464
+ return { linksText: '', slugs: [] };
2113
2465
  }
2114
2466
  }
2115
2467
 
@@ -2125,9 +2477,33 @@ if (req.url === '/api/reports/list') {
2125
2477
  const hh = String(stamp.getHours()).padStart(2, '0');
2126
2478
  const mm = String(stamp.getMinutes()).padStart(2, '0');
2127
2479
 
2128
- const block = `\n\n## [${hh}:${mm}] Raw Input\n${text}\n`;
2480
+ const linkInfo = autoLinkNotes(text);
2481
+ const linksText = linkInfo && linkInfo.linksText ? linkInfo.linksText : '';
2482
+ const slugs = linkInfo && Array.isArray(linkInfo.slugs) ? linkInfo.slugs : [];
2483
+
2484
+ const block = `\n\n## [${hh}:${mm}] Raw Input\n${text}${linksText}\n`;
2129
2485
  fs.appendFileSync(file, block, 'utf8');
2130
2486
 
2487
+ if (slugs.length) {
2488
+ const logRel = path.relative(workspaceDir, file).replace(/\\/g, '/');
2489
+ const stampText = `${d} ${hh}:${mm}`;
2490
+ for (const slug of slugs) {
2491
+ const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
2492
+ if (!exists(statusPath)) continue;
2493
+ const doc = readJsonOrNull(statusPath) || { history: [] };
2494
+ if (!Array.isArray(doc.history)) doc.history = [];
2495
+ const already = doc.history.some((h) => h && (String(h.source || '').includes(logRel) || String(h.content || '').includes(logRel)));
2496
+ if (already) continue;
2497
+ doc.history.push({
2498
+ date: isoNow(),
2499
+ type: 'Log',
2500
+ content: `Log entry ${stampText} (${logRel})`,
2501
+ source: logRel
2502
+ });
2503
+ writeJson(statusPath, doc);
2504
+ }
2505
+ }
2506
+
2131
2507
  return safeJson(res, 200, { ok: true, file: path.relative(workspaceDir, file).replace(/\\/g, '/'), appended: true });
2132
2508
  }
2133
2509
 
@@ -2788,6 +3164,19 @@ if (req.url === '/api/reports/list') {
2788
3164
  return safeJson(res, 200, { ok: true, items, stats: { pendingTasks, openBlockers, reportsToday, reportsTotal: reports.length } });
2789
3165
  }
2790
3166
 
3167
+ if (req.url === '/api/incidents/resolve') {
3168
+ const title = payload.title;
3169
+ const index = Number.isInteger(payload.index) ? payload.index : null;
3170
+ if (!title) return safeJson(res, 400, { error: 'Missing title' });
3171
+ const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
3172
+ if (!exists(p)) return safeJson(res, 404, { error: 'Incident index not found' });
3173
+ const md = fs.readFileSync(p, 'utf8');
3174
+ const updated = resolveIncidentInMarkdown(md, title, index);
3175
+ if (!updated) return safeJson(res, 404, { error: 'Incident not found' });
3176
+ fs.writeFileSync(p, updated, 'utf8');
3177
+ return safeJson(res, 200, { ok: true });
3178
+ }
3179
+
2791
3180
  if (req.url === '/api/incidents') {
2792
3181
  const p = path.join(workspaceDir, 'docs', 'reports', 'fidelizacao-incident-index.md');
2793
3182
  if (!exists(p)) return safeJson(res, 200, { ok: true, markdown: '' });
@@ -2800,13 +3189,20 @@ if (req.url === '/api/reports/list') {
2800
3189
  const doc = readJsonOrNull(file) || { tasks: [] };
2801
3190
  const tasks = Array.isArray(doc.tasks) ? doc.tasks : [];
2802
3191
  const map = {};
3192
+ const priorityRank = { high: 3, medium: 2, low: 1, '': 0 };
2803
3193
  for (const t of tasks) {
2804
3194
  const slug = t.projectSlug || 'unassigned';
2805
- if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0 };
3195
+ if (!map[slug]) map[slug] = { total: 0, pending: 0, completed: 0, priority: '' };
2806
3196
  map[slug].total++;
2807
3197
  if (t.status === 'COMPLETED') map[slug].completed++; else map[slug].pending++;
3198
+ const p = normalizePriority(t.priority || t.severity);
3199
+ if (priorityRank[p] > priorityRank[map[slug].priority || '']) map[slug].priority = p;
2808
3200
  }
2809
- const items = Object.entries(map).map(([slug, v]) => ({ slug, ...v }));
3201
+ const items = Object.entries(map).map(([slug, v]) => {
3202
+ const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
3203
+ const linkRel = exists(statusPath) ? path.relative(workspaceDir, statusPath).replace(/\\/g, '/') : '';
3204
+ return { slug, ...v, linkRel };
3205
+ });
2810
3206
  items.sort((a, b) => b.total - a.total);
2811
3207
  return safeJson(res, 200, { ok: true, items });
2812
3208
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cccarv82/freya",
3
- "version": "2.3.6",
3
+ "version": "2.3.8",
4
4
  "description": "Personal AI Assistant with local-first persistence",
5
5
  "scripts": {
6
6
  "health": "node scripts/validate-data.js && node scripts/validate-structure.js",