reflectt-node 0.1.6 → 0.1.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 (85) hide show
  1. package/README.md +13 -0
  2. package/defaults/gitignore.template +23 -0
  3. package/dist/boardHealthWorker.d.ts +4 -0
  4. package/dist/boardHealthWorker.d.ts.map +1 -1
  5. package/dist/boardHealthWorker.js +36 -1
  6. package/dist/boardHealthWorker.js.map +1 -1
  7. package/dist/buildInfo.d.ts.map +1 -1
  8. package/dist/buildInfo.js +47 -10
  9. package/dist/buildInfo.js.map +1 -1
  10. package/dist/chat.d.ts +4 -0
  11. package/dist/chat.d.ts.map +1 -1
  12. package/dist/chat.js +6 -2
  13. package/dist/chat.js.map +1 -1
  14. package/dist/cli.js +37 -12
  15. package/dist/cli.js.map +1 -1
  16. package/dist/cloud.d.ts.map +1 -1
  17. package/dist/cloud.js +131 -64
  18. package/dist/cloud.js.map +1 -1
  19. package/dist/continuity-loop.d.ts.map +1 -1
  20. package/dist/continuity-loop.js +297 -29
  21. package/dist/continuity-loop.js.map +1 -1
  22. package/dist/dashboard.js +1 -1
  23. package/dist/dashboard.js.map +1 -1
  24. package/dist/deploy-monitor.d.ts +18 -0
  25. package/dist/deploy-monitor.d.ts.map +1 -0
  26. package/dist/deploy-monitor.js +165 -0
  27. package/dist/deploy-monitor.js.map +1 -0
  28. package/dist/events.d.ts.map +1 -1
  29. package/dist/events.js +15 -2
  30. package/dist/events.js.map +1 -1
  31. package/dist/executionSweeper.d.ts +1 -0
  32. package/dist/executionSweeper.d.ts.map +1 -1
  33. package/dist/executionSweeper.js +43 -7
  34. package/dist/executionSweeper.js.map +1 -1
  35. package/dist/files.d.ts.map +1 -1
  36. package/dist/files.js +17 -3
  37. package/dist/files.js.map +1 -1
  38. package/dist/fingerprint.d.ts +30 -0
  39. package/dist/fingerprint.d.ts.map +1 -0
  40. package/dist/fingerprint.js +122 -0
  41. package/dist/fingerprint.js.map +1 -0
  42. package/dist/github-webhook-attribution.d.ts +38 -0
  43. package/dist/github-webhook-attribution.d.ts.map +1 -0
  44. package/dist/github-webhook-attribution.js +123 -0
  45. package/dist/github-webhook-attribution.js.map +1 -0
  46. package/dist/inbox.d.ts.map +1 -1
  47. package/dist/inbox.js +4 -0
  48. package/dist/inbox.js.map +1 -1
  49. package/dist/index.js +37 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/pulse.d.ts +7 -0
  52. package/dist/pulse.d.ts.map +1 -1
  53. package/dist/pulse.js +15 -0
  54. package/dist/pulse.js.map +1 -1
  55. package/dist/review-state.d.ts +9 -0
  56. package/dist/review-state.d.ts.map +1 -0
  57. package/dist/review-state.js +17 -0
  58. package/dist/review-state.js.map +1 -0
  59. package/dist/schedule.d.ts +60 -0
  60. package/dist/schedule.d.ts.map +1 -0
  61. package/dist/schedule.js +176 -0
  62. package/dist/schedule.js.map +1 -0
  63. package/dist/server.d.ts.map +1 -1
  64. package/dist/server.js +501 -14
  65. package/dist/server.js.map +1 -1
  66. package/dist/suppression-ledger.d.ts.map +1 -1
  67. package/dist/suppression-ledger.js +12 -3
  68. package/dist/suppression-ledger.js.map +1 -1
  69. package/dist/system-loop-state.d.ts +1 -1
  70. package/dist/system-loop-state.d.ts.map +1 -1
  71. package/dist/system-loop-state.js +1 -0
  72. package/dist/system-loop-state.js.map +1 -1
  73. package/dist/tasks.d.ts +9 -1
  74. package/dist/tasks.d.ts.map +1 -1
  75. package/dist/tasks.js +193 -41
  76. package/dist/tasks.js.map +1 -1
  77. package/dist/types.d.ts +1 -1
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/usage-tracking.d.ts +26 -0
  80. package/dist/usage-tracking.d.ts.map +1 -1
  81. package/dist/usage-tracking.js +91 -4
  82. package/dist/usage-tracking.js.map +1 -1
  83. package/package.json +1 -1
  84. package/public/dashboard.js +136 -56
  85. package/public/docs.md +18 -0
@@ -33,6 +33,8 @@ function activatePage(page) {
33
33
  document.querySelectorAll('.sidebar-link[data-page]').forEach(link => {
34
34
  link.classList.toggle('active', link.dataset.page === page);
35
35
  });
36
+ // Load doctor page data when shown
37
+ if (page === 'doctor' && typeof loadDoctorPage === 'function') loadDoctorPage();
36
38
  }
37
39
 
38
40
  function toggleSidebar() {
@@ -724,9 +726,10 @@ async function loadPresence() {
724
726
  // ---- Tasks ----
725
727
  async function loadTasks(forceFull = false) {
726
728
  try {
727
- const useDelta = !forceFull && lastTaskSync > 0;
729
+ // Force full load if we have no tasks yet (e.g. first load failed)
730
+ const useDelta = !forceFull && lastTaskSync > 0 && allTasks.length > 0;
728
731
  const qs = new URLSearchParams();
729
- qs.set('limit', '80');
732
+ qs.set('limit', '200');
730
733
  if (useDelta) qs.set('updatedSince', String(lastTaskSync));
731
734
 
732
735
  const r = await fetch(BASE + '/tasks?' + qs.toString());
@@ -1382,16 +1385,8 @@ async function loadSharedArtifacts() {
1382
1385
  const pinnedRows = pinned.map(p => {
1383
1386
  const found = files.find(f => (String(f.name || '')).toLowerCase() === p.name.toLowerCase());
1384
1387
  if (!found) {
1385
- return `<div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
1386
- <div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
1387
- <div style="font-size:13px;color:var(--text-bright);font-weight:600">${esc(p.label)}</div>
1388
- <span class="assignee-tag" style="color:var(--yellow)">missing</span>
1389
- </div>
1390
- <div style="font-size:11px;color:var(--text-muted);margin-top:4px">
1391
- To make this available here, create a symlink in the shared workspace:<br/>
1392
- <code>ln -s ../RYANS-THOUGHTS.md ~/.openclaw/workspace-shared/process/RYANS-THOUGHTS.md</code>
1393
- </div>
1394
- </div>`;
1388
+ // Operator notes are optional. If not configured, do not show an operator-only instruction to end users.
1389
+ return '';
1395
1390
  }
1396
1391
  const href = '/shared/view?path=' + encodeURIComponent(found.path);
1397
1392
  return `<div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
@@ -1420,9 +1415,13 @@ async function loadSharedArtifacts() {
1420
1415
  </div>`;
1421
1416
  }).join('');
1422
1417
 
1418
+ const pinnedBox = pinnedRows
1419
+ ? `<div style="border:1px solid var(--border-subtle);border-radius:8px;overflow:hidden">${pinnedRows}</div>
1420
+ <div style="height:10px"></div>`
1421
+ : '';
1422
+
1423
1423
  body.innerHTML = `
1424
- <div style="border:1px solid var(--border-subtle);border-radius:8px;overflow:hidden">${pinnedRows}</div>
1425
- <div style="height:10px"></div>
1424
+ ${pinnedBox}
1426
1425
  <div style="font-size:11px;color:var(--text-muted);margin:0 0 8px">Shared workspace directory: <code>process/</code></div>
1427
1426
  <div style="border:1px solid var(--border-subtle);border-radius:8px;overflow:hidden">${listRows}</div>
1428
1427
  `;
@@ -1658,12 +1657,7 @@ async function loadDoctorPage() {
1658
1657
  }
1659
1658
  }
1660
1659
 
1661
- // Hook into page activation: load doctor data when the page is shown
1662
- const _origActivatePage = activatePage;
1663
- function activatePage(page) {
1664
- _origActivatePage(page);
1665
- if (page === 'doctor') loadDoctorPage();
1666
- }
1660
+ // Doctor page activation handled in activatePage() above
1667
1661
 
1668
1662
  async function loadReleaseStatus(force = false) {
1669
1663
  const badge = document.getElementById('release-badge');
@@ -1809,6 +1803,18 @@ function normalizeEpochMs(v) {
1809
1803
  return v;
1810
1804
  }
1811
1805
 
1806
+ function hasReviewerDecision(task) {
1807
+ const meta = task && task.metadata && typeof task.metadata === 'object' ? task.metadata : null;
1808
+ const decision = meta && meta.reviewer_decision && typeof meta.reviewer_decision === 'object'
1809
+ ? meta.reviewer_decision
1810
+ : null;
1811
+ return !!decision;
1812
+ }
1813
+
1814
+ function shouldEscalateReviewerSla(task) {
1815
+ return task && task.slaState === 'breach' && !hasReviewerDecision(task);
1816
+ }
1817
+
1812
1818
  function renderReviewQueue() {
1813
1819
  const panel = document.getElementById('review-queue-panel');
1814
1820
  const body = document.getElementById('review-queue-body');
@@ -1820,11 +1826,23 @@ function renderReviewQueue() {
1820
1826
  const validating = allTasks
1821
1827
  .filter(t => t.status === 'validating')
1822
1828
  .map(t => {
1823
- const rawEntered = t.metadata?.entered_validating_at || t.updatedAt || t.createdAt;
1829
+ const meta = t.metadata || {};
1830
+ const reviewState = typeof meta.review_state === 'string' ? meta.review_state : '';
1831
+ const reviewerDecision = meta.reviewer_decision;
1832
+
1833
+ // If reviewer has acted (needs_author or reviewer_decision recorded), the ball is with the assignee.
1834
+ // We still track an SLA timer, but it should page the assignee (author), not the reviewer.
1835
+ const waitOn = (reviewState === 'needs_author' || reviewerDecision != null) ? 'author' : 'reviewer';
1836
+
1837
+ const rawEntered = waitOn === 'author'
1838
+ ? (reviewerDecision && reviewerDecision.decidedAt) || meta.review_last_activity_at || meta.entered_validating_at || t.updatedAt || t.createdAt
1839
+ : meta.entered_validating_at || t.updatedAt || t.createdAt;
1840
+
1824
1841
  const enteredAt = normalizeEpochMs(rawEntered) || now;
1825
1842
  const timeInReview = Math.min(Math.max(0, now - enteredAt), MAX_REVIEW_MS);
1826
1843
  const slaState = getReviewSlaState(timeInReview);
1827
- return { ...t, timeInReview, slaState, enteredAt };
1844
+
1845
+ return { ...t, timeInReview, slaState, enteredAt, waitOn, reviewState, hasReviewerDecision: reviewerDecision != null };
1828
1846
  })
1829
1847
  .sort((a, b) => {
1830
1848
  // Breaches first, then by time descending
@@ -1834,6 +1852,9 @@ function renderReviewQueue() {
1834
1852
  return b.timeInReview - a.timeInReview;
1835
1853
  });
1836
1854
 
1855
+ const reviewerQueue = validating.filter(t => t.waitOn === 'reviewer');
1856
+ const authorQueue = validating.filter(t => t.waitOn === 'author');
1857
+
1837
1858
  if (validating.length === 0) {
1838
1859
  panel.style.display = '';
1839
1860
  count.textContent = '';
@@ -1849,16 +1870,29 @@ function renderReviewQueue() {
1849
1870
  }
1850
1871
 
1851
1872
  panel.style.display = '';
1852
- count.textContent = validating.length + ' awaiting review';
1853
- // Update sidebar badge
1873
+ const reviewerBreachCount = reviewerQueue.filter(t => t.slaState === 'breach').length;
1874
+ const authorBreachCount = authorQueue.filter(t => t.slaState === 'breach').length;
1875
+
1876
+ // Primary count is "awaiting reviewer" — author-wait tasks are tracked separately.
1877
+ count.textContent = reviewerQueue.length + ' awaiting review';
1878
+
1879
+ // Update sidebar badge (reviewer queue only)
1854
1880
  const navReviewBadge = document.getElementById('nav-review-count');
1855
- if (navReviewBadge) navReviewBadge.textContent = validating.length;
1881
+ if (navReviewBadge) navReviewBadge.textContent = String(reviewerQueue.length);
1882
+
1883
+ const headerExtra = (reviewerBreachCount > 0 || authorBreachCount > 0)
1884
+ ? ' <span style="color:var(--red);font-size:11px;font-weight:600">'
1885
+ + (reviewerBreachCount > 0 ? (reviewerBreachCount + ' reviewer breach' + (reviewerBreachCount > 1 ? 'es' : '')) : '')
1886
+ + (reviewerBreachCount > 0 && authorBreachCount > 0 ? ' · ' : '')
1887
+ + (authorBreachCount > 0 ? (authorBreachCount + ' author breach' + (authorBreachCount > 1 ? 'es' : '')) : '')
1888
+ + '</span>'
1889
+ : '';
1856
1890
 
1857
- const breachCount = validating.filter(t => t.slaState === 'breach').length;
1858
- const headerExtra = breachCount > 0
1859
- ? ' <span style="color:var(--red);font-size:11px;font-weight:600">' + breachCount + ' breach' + (breachCount > 1 ? 'es' : '') + '</span>'
1891
+ const authorExtra = authorQueue.length > 0
1892
+ ? ' <span style="color:var(--text-muted);font-size:11px;font-weight:500">· ' + authorQueue.length + ' awaiting author</span>'
1860
1893
  : '';
1861
- count.innerHTML = validating.length + ' awaiting review' + headerExtra;
1894
+
1895
+ count.innerHTML = reviewerQueue.length + ' awaiting review' + authorExtra + headerExtra;
1862
1896
 
1863
1897
  body.innerHTML = validating.map(t => {
1864
1898
  const reviewer = t.reviewer || '<span style="color:var(--yellow)">unassigned</span>';
@@ -1887,33 +1921,40 @@ function renderReviewQueue() {
1887
1921
 
1888
1922
  bindTaskLinkHandlers(body);
1889
1923
 
1890
- // SLA breach escalation: post to watchdog if any breach found
1891
- if (breachCount > 0) {
1892
- escalateReviewBreaches(validating.filter(t => t.slaState === 'breach'));
1924
+ // SLA breach escalation: split reviewer-wait vs author-wait so we page the right person.
1925
+ // shouldEscalateReviewerSla() suppresses escalation when reviewer_decision already exists.
1926
+ if (reviewerBreachCount > 0) {
1927
+ escalateReviewerBreaches(reviewerQueue.filter(shouldEscalateReviewerSla));
1928
+ }
1929
+ if (authorBreachCount > 0) {
1930
+ escalateAuthorBreaches(authorQueue.filter(t => t.slaState === 'breach'));
1893
1931
  }
1894
1932
  }
1895
1933
 
1896
- let lastReviewEscalationAt = 0;
1934
+ let lastReviewerEscalationAt = 0;
1935
+ let lastAuthorEscalationAt = 0;
1897
1936
  const REVIEW_ESCALATION_COOLDOWN = 20 * 60 * 1000; // 20m
1898
1937
 
1899
- async function escalateReviewBreaches(breachedTasks) {
1938
+ async function escalateReviewerBreaches(breachedTasks) {
1900
1939
  const now = Date.now();
1901
- if (now - lastReviewEscalationAt < REVIEW_ESCALATION_COOLDOWN) return;
1902
- lastReviewEscalationAt = now;
1940
+ if (now - lastReviewerEscalationAt < REVIEW_ESCALATION_COOLDOWN) return;
1941
+ lastReviewerEscalationAt = now;
1903
1942
 
1904
1943
  const lines = breachedTasks.slice(0, 5).map(t => {
1905
1944
  const reviewer = t.reviewer || 'unassigned';
1906
- return '- ' + t.id + ' (' + (t.title || '').slice(0, 50) + ') — reviewer: @' + reviewer + ', waiting ' + formatDuration(t.timeInReview);
1945
+ const prUrl = t.metadata?.review_handoff?.pr_url || t.metadata?.review_handoff?.prUrl || '';
1946
+ const prPart = prUrl ? ' — ' + prUrl : '';
1947
+ return '- ' + t.id + ' (' + (t.title || '').slice(0, 50) + ') — reviewer: @' + reviewer + ', waiting ' + formatDuration(t.timeInReview) + prPart;
1907
1948
  });
1908
1949
 
1909
- const content = '@owner Review SLA breach detected:\n' + lines.join('\n');
1950
+ const content = 'Review overdue (reviewer SLA):\n' + lines.join('\n');
1910
1951
 
1911
1952
  try {
1912
1953
  await fetch(BASE + '/chat/messages', {
1913
1954
  method: 'POST',
1914
1955
  headers: { 'Content-Type': 'application/json' },
1915
1956
  body: JSON.stringify({
1916
- from: 'system',
1957
+ from: 'dashboard',
1917
1958
  content,
1918
1959
  channel: 'general',
1919
1960
  timestamp: now
@@ -1924,6 +1965,36 @@ async function escalateReviewBreaches(breachedTasks) {
1924
1965
  }
1925
1966
  }
1926
1967
 
1968
+ async function escalateAuthorBreaches(breachedTasks) {
1969
+ const now = Date.now();
1970
+ if (now - lastAuthorEscalationAt < REVIEW_ESCALATION_COOLDOWN) return;
1971
+ lastAuthorEscalationAt = now;
1972
+
1973
+ const lines = breachedTasks.slice(0, 5).map(t => {
1974
+ const assignee = t.assignee || 'unassigned';
1975
+ const prUrl = t.metadata?.review_handoff?.pr_url || t.metadata?.review_handoff?.prUrl || '';
1976
+ const prPart = prUrl ? ' — ' + prUrl : '';
1977
+ return '- ' + t.id + ' (' + (t.title || '').slice(0, 50) + ') — assignee: @' + assignee + ', waiting ' + formatDuration(t.timeInReview) + prPart;
1978
+ });
1979
+
1980
+ const content = 'Author action needed (post-review):\n' + lines.join('\n');
1981
+
1982
+ try {
1983
+ await fetch(BASE + '/chat/messages', {
1984
+ method: 'POST',
1985
+ headers: { 'Content-Type': 'application/json' },
1986
+ body: JSON.stringify({
1987
+ from: 'dashboard',
1988
+ content,
1989
+ channel: 'general',
1990
+ timestamp: now
1991
+ })
1992
+ });
1993
+ } catch (err) {
1994
+ console.error('Failed to escalate author breach:', err);
1995
+ }
1996
+ }
1997
+
1927
1998
  // ---- Feedback ----
1928
1999
  let feedbackData = null;
1929
2000
 
@@ -2670,7 +2741,7 @@ function openTaskModal(taskId) {
2670
2741
  loadPrReviewPanel(currentTask);
2671
2742
  }
2672
2743
 
2673
- function formatDuration(sec) {
2744
+ function formatDurationSec(sec) {
2674
2745
  if (sec == null) return '';
2675
2746
  if (sec < 60) return sec + 's';
2676
2747
  return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
@@ -2763,7 +2834,7 @@ function renderPrReviewPanel(data) {
2763
2834
  html += '<div class="ci-check-row">';
2764
2835
  html += '<span class="check-icon">' + icon + '</span>';
2765
2836
  html += '<span class="check-name">' + esc(c.name) + '</span>';
2766
- if (c.durationSec != null) html += '<span class="check-duration">' + formatDuration(c.durationSec) + '</span>';
2837
+ if (c.durationSec != null) html += '<span class="check-duration">' + formatDurationSec(c.durationSec) + '</span>';
2767
2838
  if (c.detailsUrl) html += '<a href="' + esc(c.detailsUrl) + '" target="_blank">logs</a>';
2768
2839
  html += '</div>';
2769
2840
  });
@@ -2979,7 +3050,7 @@ async function checkGettingStarted() {
2979
3050
  // Step 1 done: check if system health loops are ticking (not just uptime > 0)
2980
3051
  const hasHeartbeat = !!(health.system?.loops?.lastTickAt || (health.tasks?.total > 0));
2981
3052
  const hasTasks = (health.tasks?.total || 0) > 0;
2982
- const hasMessages = (health.chat?.total || 0) > 0;
3053
+ const hasMessages = (health.chat?.totalMessages || health.chat?.total || 0) > 0;
2983
3054
 
2984
3055
  // Step 1: server running — always done if dashboard loads
2985
3056
  const step1 = document.getElementById('gs-preflight');
@@ -3005,23 +3076,39 @@ async function checkGettingStarted() {
3005
3076
  step3.querySelector('.gs-icon').textContent = '✓';
3006
3077
  }
3007
3078
 
3008
- // Auto-hide if all steps done
3079
+ // Auto-hide if all steps done, OR if the system clearly isn't a fresh install
3009
3080
  const allDone = panel.querySelectorAll('.gs-step.done').length === 3;
3010
- if (allDone) {
3081
+ const clearlyNotFresh = (health.tasks?.total || 0) > 5 || (health.chat?.totalMessages || health.chat?.total || 0) > 100;
3082
+ if (allDone || clearlyNotFresh) {
3011
3083
  panel.classList.add('hidden');
3012
3084
  }
3085
+
3086
+ // Pre-populate task badge from health before full task fetch completes
3087
+ const taskTotal = health.tasks?.total || 0;
3088
+ const navTaskBadge = document.getElementById('nav-task-count');
3089
+ if (navTaskBadge && taskTotal > 0 && allTasks.length === 0) {
3090
+ navTaskBadge.textContent = taskTotal;
3091
+ }
3013
3092
  } catch {}
3014
3093
  }
3015
3094
 
3016
3095
  function dismissGettingStarted() {
3017
3096
  const panel = document.getElementById('getting-started');
3018
- if (panel) panel.classList.add('hidden');
3097
+ if (panel) {
3098
+ panel.classList.add('hidden');
3099
+ panel.style.display = 'none'; // belt + suspenders
3100
+ }
3019
3101
  try { localStorage.setItem('reflectt-gs-dismissed', '1'); } catch {}
3020
3102
  }
3103
+ // Expose globally for onclick handlers (safety net)
3104
+ window.dismissGettingStarted = dismissGettingStarted;
3105
+ window.dismissFirstBootBanner = dismissFirstBootBanner;
3021
3106
 
3022
3107
  updateClock();
3023
3108
  setInterval(updateClock, 30000);
3024
3109
  checkGettingStarted();
3110
+ // Retry getting-started check after 5s in case health wasn't ready at boot
3111
+ setTimeout(checkGettingStarted, 5000);
3025
3112
  refresh();
3026
3113
  connectEventStream();
3027
3114
  startAdaptiveRefresh();
@@ -3401,20 +3488,13 @@ function fileIcon(mimeType, name) {
3401
3488
  }
3402
3489
 
3403
3490
  function formatFileSize(bytes) {
3404
- if (!bytes) return '0 B';
3491
+ if (typeof bytes !== 'number' || bytes <= 0) return '0 B';
3405
3492
  if (bytes < 1024) return bytes + ' B';
3406
3493
  if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
3407
3494
  return (bytes / 1048576).toFixed(1) + ' MB';
3408
3495
  }
3409
3496
 
3410
- function timeAgo(ts) {
3411
- const d = typeof ts === 'string' ? new Date(ts) : new Date(ts);
3412
- const s = Math.floor((Date.now() - d.getTime()) / 1000);
3413
- if (s < 60) return 'just now';
3414
- if (s < 3600) return Math.floor(s / 60) + 'm ago';
3415
- if (s < 86400) return Math.floor(s / 3600) + 'h ago';
3416
- return Math.floor(s / 86400) + 'd ago';
3417
- }
3497
+ // timeAgo defined above (line ~3286)
3418
3498
 
3419
3499
  async function loadFiles() {
3420
3500
  try {
@@ -3457,7 +3537,7 @@ function renderFiles(files) {
3457
3537
  return '<div class="file-card" tabindex="0" role="button" aria-label="' + (f.originalName || f.filename) + '">' +
3458
3538
  '<div class="thumb" style="display:flex;align-items:center;justify-content:center;height:80px;background:var(--surface-raised);border-radius:var(--radius-sm);overflow:hidden">' + thumb + '</div>' +
3459
3539
  '<div class="card-name">' + (f.originalName || f.filename) + '</div>' +
3460
- '<div class="card-meta">' + formatFileSize(f.size) + ' · ' + timeAgo(f.uploadedAt || f.createdAt) + '</div>' +
3540
+ '<div class="card-meta">' + formatFileSize(f.sizeBytes) + ' · ' + timeAgo(f.uploadedAt || f.createdAt) + '</div>' +
3461
3541
  '<div class="card-actions">' +
3462
3542
  '<a href="/files/' + f.id + '" download="' + (f.originalName || f.filename) + '" class="action-btn" aria-label="Download" onclick="event.stopPropagation()">⬇</a>' +
3463
3543
  '<button class="action-btn delete" aria-label="Delete" onclick="event.stopPropagation();deleteFile(\'' + f.id + '\')">🗑</button>' +
@@ -3470,7 +3550,7 @@ function renderFiles(files) {
3470
3550
  return '<div class="file-list-item" tabindex="0" role="button">' +
3471
3551
  '<span class="list-icon">' + icon + '</span>' +
3472
3552
  '<span class="list-name">' + (f.originalName || f.filename) + '</span>' +
3473
- '<span class="list-meta">' + formatFileSize(f.size) + '</span>' +
3553
+ '<span class="list-meta">' + formatFileSize(f.sizeBytes) + '</span>' +
3474
3554
  '<span class="list-meta">' + timeAgo(f.uploadedAt || f.createdAt) + '</span>' +
3475
3555
  '<div class="list-actions">' +
3476
3556
  '<a href="/files/' + f.id + '" download="' + (f.originalName || f.filename) + '" class="action-btn" aria-label="Download" onclick="event.stopPropagation()">⬇</a>' +
package/public/docs.md CHANGED
@@ -216,7 +216,10 @@ If your deployment needs quiet-hours behavior today, enforce it in scheduler/gat
216
216
  | POST | `/tasks/batch-create` | Batch create up to 20 tasks. Body: `{ "tasks": [...], "createdBy": "agent", "deduplicate": true, "dryRun": false }`. Each task follows the same schema as `POST /tasks`. Returns per-task results (created/duplicate/error) with summary counts. Deduplication checks exact title match + fuzzy word overlap (Jaccard >0.6) against active tasks. |
217
217
  | GET | `/tasks/heartbeat-status` | All doing tasks with stale comment activity (>30m). Returns `{ threshold, doingTaskCount, staleCount, staleTasks[] }`. Use for monitoring status heartbeat discipline compliance. |
218
218
  | GET | `/tasks/board-health` | Board-level health metrics for backlog replenishment. Returns per-agent breakdown (doing, validating, todo, active counts), `needsWork`/`lowWatermark` flags, and `replenishNeeded` trigger (fires when 2+ agents idle or <3 backlog tasks). Query: `include_test=1` to include test-harness tasks. |
219
+ | GET | `/agents` | Agent list — alias for /agents/roles. Returns all agents with roles, WIP status, affinity tags. |
219
220
  | GET | `/agents/roles` | Agent role registry with live WIP status. Returns all agents with `name`, `displayName`, `role`, `affinityTags`, `protectedDomains`, `wipCap`, `wipCount`, `overCap`. |
221
+ | POST | `/agents` | Add agent to team. Body: `{ name, role, description?, affinityTags?, wipCap? }`. Hot-reloads TEAM-ROLES.yaml. |
222
+ | DELETE | `/agents/:name` | Remove agent from team. Hot-reloads TEAM-ROLES.yaml. |
220
223
  | POST | `/config/identity` | Set an agent's display name. Body: `{ "agent": "agent-1", "displayName": "Juniper" }`. Persists to TEAM-ROLES.yaml, hot-reloads. Dashboard and mentions use display name. |
221
224
  | PUT | `/config/team-roles` | Write TEAM-ROLES.yaml. Body: `{ "yaml": "agents:\n - name: link\n role: engineer..." }`. Hot-reloads on save. Used by bootstrap agent to configure team from user intent. |
222
225
  | GET | `/resolve/mention/:mention` | Resolve a mention string (name, displayName, or alias) to canonical agent ID. Returns `{ agent, displayName, role }`. |
@@ -863,6 +866,7 @@ Set via `reflectionNudge` in policy config:
863
866
  | POST | `/usage/caps` | Create spend cap. Body: `{ scope: "global"\|"agent"\|"team", scope_id?, period: "daily"\|"weekly"\|"monthly", limit_usd, action: "warn"\|"throttle"\|"block" }`. |
864
867
  | DELETE | `/usage/caps/:id` | Delete a spend cap. |
865
868
  | GET | `/usage/routing-suggestions` | Smart routing savings suggestions (which low-stakes categories could use cheaper models). Query: `since`. |
869
+ | GET | `/costs` | Cost dashboard — aggregated spend for COO/PM monitoring. Query: `days` (1–90, default 7). Returns: `daily_by_model` (spend per model per day), `daily_totals` (per-day rolled-up for threshold alerting), `avg_cost_by_lane` (avg cost per closed task by `qa_bundle.lane`, 30-day floor), `avg_cost_by_agent` (avg cost per closed task per agent + `top_model`, 30-day floor), `top_tasks_by_cost` (top 20 most expensive tasks in window), `summary` (total tokens + cost), `lane_agent_window_days` (actual window used for lane/agent averages). |
866
870
 
867
871
  ### Model Pricing (built-in estimates, per 1M tokens)
868
872
  | Model | Input | Output |
@@ -1034,6 +1038,20 @@ Export → import preserves: summary, description, organizer, attendees, locatio
1034
1038
  | GET | `/calendar/events/:id/export.ics` | Export single event as .ics file. |
1035
1039
  | POST | `/calendar/import` | Import events from .ics content. Body: `{ ics: string, organizer?: string }` or raw .ics string. Returns created/updated events. UID-based dedup on re-import. |
1036
1040
 
1041
+ ## Schedule Feed
1042
+
1043
+ Shared time-awareness for the team. Canonical records for deploy windows, focus blocks, and scheduled task work — so agents can coordinate timing without chat.
1044
+
1045
+ **MVP scope (v1):** One-off windows only. No iCal/RRULE, no reminders, no recurring rules. For per-agent availability and recurring blocks use `/calendar/blocks`. For notifications use the Calendar Reminder Engine.
1046
+
1047
+ | Method | Path | Description |
1048
+ |--------|------|-------------|
1049
+ | GET | `/schedule/feed` | Upcoming entries in chronological order. Query: `kinds` (comma-separated: `deploy_window,focus_block,scheduled_task`), `owner`, `after` (epoch ms, default: now), `before` (epoch ms), `limit` (default: 50, max: 200). |
1050
+ | POST | `/schedule/entries` | Create a schedule entry. Body: `{ kind, title, start, end, owner, task_id?, status?, meta? }`. `kind` must be `deploy_window`, `focus_block`, or `scheduled_task`. `start`/`end` are epoch ms. Default status: `open` / `active` / `pending`. |
1051
+ | GET | `/schedule/entries/:id` | Get a single entry by ID. |
1052
+ | PATCH | `/schedule/entries/:id` | Update an entry. Body: `{ title?, start?, end?, status?, meta? }`. |
1053
+ | DELETE | `/schedule/entries/:id` | Delete an entry. Returns 204. |
1054
+
1037
1055
  ## Remote Node Management
1038
1056
 
1039
1057
  Auth-gated endpoints for managing a reflectt-node instance remotely. Provide `REFLECTT_MANAGE_TOKEN` env var; authenticate via `x-manage-token` header or `Authorization: Bearer <token>`. Loopback (localhost) access is always allowed.