reflectt-node 0.1.7 → 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 (80) 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/deploy-monitor.d.ts +18 -0
  23. package/dist/deploy-monitor.d.ts.map +1 -0
  24. package/dist/deploy-monitor.js +165 -0
  25. package/dist/deploy-monitor.js.map +1 -0
  26. package/dist/executionSweeper.d.ts +1 -0
  27. package/dist/executionSweeper.d.ts.map +1 -1
  28. package/dist/executionSweeper.js +43 -7
  29. package/dist/executionSweeper.js.map +1 -1
  30. package/dist/files.d.ts.map +1 -1
  31. package/dist/files.js +17 -3
  32. package/dist/files.js.map +1 -1
  33. package/dist/fingerprint.d.ts +30 -0
  34. package/dist/fingerprint.d.ts.map +1 -0
  35. package/dist/fingerprint.js +122 -0
  36. package/dist/fingerprint.js.map +1 -0
  37. package/dist/github-webhook-attribution.d.ts +38 -0
  38. package/dist/github-webhook-attribution.d.ts.map +1 -0
  39. package/dist/github-webhook-attribution.js +123 -0
  40. package/dist/github-webhook-attribution.js.map +1 -0
  41. package/dist/inbox.d.ts.map +1 -1
  42. package/dist/inbox.js +4 -0
  43. package/dist/inbox.js.map +1 -1
  44. package/dist/index.js +37 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/pulse.d.ts +7 -0
  47. package/dist/pulse.d.ts.map +1 -1
  48. package/dist/pulse.js +15 -0
  49. package/dist/pulse.js.map +1 -1
  50. package/dist/review-state.d.ts +9 -0
  51. package/dist/review-state.d.ts.map +1 -0
  52. package/dist/review-state.js +17 -0
  53. package/dist/review-state.js.map +1 -0
  54. package/dist/schedule.d.ts +60 -0
  55. package/dist/schedule.d.ts.map +1 -0
  56. package/dist/schedule.js +176 -0
  57. package/dist/schedule.js.map +1 -0
  58. package/dist/server.d.ts.map +1 -1
  59. package/dist/server.js +486 -14
  60. package/dist/server.js.map +1 -1
  61. package/dist/suppression-ledger.d.ts.map +1 -1
  62. package/dist/suppression-ledger.js +12 -3
  63. package/dist/suppression-ledger.js.map +1 -1
  64. package/dist/system-loop-state.d.ts +1 -1
  65. package/dist/system-loop-state.d.ts.map +1 -1
  66. package/dist/system-loop-state.js +1 -0
  67. package/dist/system-loop-state.js.map +1 -1
  68. package/dist/tasks.d.ts +9 -1
  69. package/dist/tasks.d.ts.map +1 -1
  70. package/dist/tasks.js +193 -41
  71. package/dist/tasks.js.map +1 -1
  72. package/dist/types.d.ts +1 -1
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/usage-tracking.d.ts +26 -0
  75. package/dist/usage-tracking.d.ts.map +1 -1
  76. package/dist/usage-tracking.js +91 -4
  77. package/dist/usage-tracking.js.map +1 -1
  78. package/package.json +1 -1
  79. package/public/dashboard.js +119 -37
  80. package/public/docs.md +18 -0
@@ -726,9 +726,10 @@ async function loadPresence() {
726
726
  // ---- Tasks ----
727
727
  async function loadTasks(forceFull = false) {
728
728
  try {
729
- 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;
730
731
  const qs = new URLSearchParams();
731
- qs.set('limit', '80');
732
+ qs.set('limit', '200');
732
733
  if (useDelta) qs.set('updatedSince', String(lastTaskSync));
733
734
 
734
735
  const r = await fetch(BASE + '/tasks?' + qs.toString());
@@ -1384,16 +1385,8 @@ async function loadSharedArtifacts() {
1384
1385
  const pinnedRows = pinned.map(p => {
1385
1386
  const found = files.find(f => (String(f.name || '')).toLowerCase() === p.name.toLowerCase());
1386
1387
  if (!found) {
1387
- return `<div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
1388
- <div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
1389
- <div style="font-size:13px;color:var(--text-bright);font-weight:600">${esc(p.label)}</div>
1390
- <span class="assignee-tag" style="color:var(--yellow)">missing</span>
1391
- </div>
1392
- <div style="font-size:11px;color:var(--text-muted);margin-top:4px">
1393
- To make this available here, create a symlink in the shared workspace:<br/>
1394
- <code>ln -s ../RYANS-THOUGHTS.md ~/.openclaw/workspace-shared/process/RYANS-THOUGHTS.md</code>
1395
- </div>
1396
- </div>`;
1388
+ // Operator notes are optional. If not configured, do not show an operator-only instruction to end users.
1389
+ return '';
1397
1390
  }
1398
1391
  const href = '/shared/view?path=' + encodeURIComponent(found.path);
1399
1392
  return `<div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
@@ -1422,9 +1415,13 @@ async function loadSharedArtifacts() {
1422
1415
  </div>`;
1423
1416
  }).join('');
1424
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
+
1425
1423
  body.innerHTML = `
1426
- <div style="border:1px solid var(--border-subtle);border-radius:8px;overflow:hidden">${pinnedRows}</div>
1427
- <div style="height:10px"></div>
1424
+ ${pinnedBox}
1428
1425
  <div style="font-size:11px;color:var(--text-muted);margin:0 0 8px">Shared workspace directory: <code>process/</code></div>
1429
1426
  <div style="border:1px solid var(--border-subtle);border-radius:8px;overflow:hidden">${listRows}</div>
1430
1427
  `;
@@ -1806,6 +1803,18 @@ function normalizeEpochMs(v) {
1806
1803
  return v;
1807
1804
  }
1808
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
+
1809
1818
  function renderReviewQueue() {
1810
1819
  const panel = document.getElementById('review-queue-panel');
1811
1820
  const body = document.getElementById('review-queue-body');
@@ -1817,11 +1826,23 @@ function renderReviewQueue() {
1817
1826
  const validating = allTasks
1818
1827
  .filter(t => t.status === 'validating')
1819
1828
  .map(t => {
1820
- 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
+
1821
1841
  const enteredAt = normalizeEpochMs(rawEntered) || now;
1822
1842
  const timeInReview = Math.min(Math.max(0, now - enteredAt), MAX_REVIEW_MS);
1823
1843
  const slaState = getReviewSlaState(timeInReview);
1824
- return { ...t, timeInReview, slaState, enteredAt };
1844
+
1845
+ return { ...t, timeInReview, slaState, enteredAt, waitOn, reviewState, hasReviewerDecision: reviewerDecision != null };
1825
1846
  })
1826
1847
  .sort((a, b) => {
1827
1848
  // Breaches first, then by time descending
@@ -1831,6 +1852,9 @@ function renderReviewQueue() {
1831
1852
  return b.timeInReview - a.timeInReview;
1832
1853
  });
1833
1854
 
1855
+ const reviewerQueue = validating.filter(t => t.waitOn === 'reviewer');
1856
+ const authorQueue = validating.filter(t => t.waitOn === 'author');
1857
+
1834
1858
  if (validating.length === 0) {
1835
1859
  panel.style.display = '';
1836
1860
  count.textContent = '';
@@ -1846,16 +1870,29 @@ function renderReviewQueue() {
1846
1870
  }
1847
1871
 
1848
1872
  panel.style.display = '';
1849
- count.textContent = validating.length + ' awaiting review';
1850
- // 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)
1851
1880
  const navReviewBadge = document.getElementById('nav-review-count');
1852
- 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
+ : '';
1853
1890
 
1854
- const breachCount = validating.filter(t => t.slaState === 'breach').length;
1855
- const headerExtra = breachCount > 0
1856
- ? ' <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>'
1857
1893
  : '';
1858
- count.innerHTML = validating.length + ' awaiting review' + headerExtra;
1894
+
1895
+ count.innerHTML = reviewerQueue.length + ' awaiting review' + authorExtra + headerExtra;
1859
1896
 
1860
1897
  body.innerHTML = validating.map(t => {
1861
1898
  const reviewer = t.reviewer || '<span style="color:var(--yellow)">unassigned</span>';
@@ -1884,33 +1921,40 @@ function renderReviewQueue() {
1884
1921
 
1885
1922
  bindTaskLinkHandlers(body);
1886
1923
 
1887
- // SLA breach escalation: post to watchdog if any breach found
1888
- if (breachCount > 0) {
1889
- 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'));
1890
1931
  }
1891
1932
  }
1892
1933
 
1893
- let lastReviewEscalationAt = 0;
1934
+ let lastReviewerEscalationAt = 0;
1935
+ let lastAuthorEscalationAt = 0;
1894
1936
  const REVIEW_ESCALATION_COOLDOWN = 20 * 60 * 1000; // 20m
1895
1937
 
1896
- async function escalateReviewBreaches(breachedTasks) {
1938
+ async function escalateReviewerBreaches(breachedTasks) {
1897
1939
  const now = Date.now();
1898
- if (now - lastReviewEscalationAt < REVIEW_ESCALATION_COOLDOWN) return;
1899
- lastReviewEscalationAt = now;
1940
+ if (now - lastReviewerEscalationAt < REVIEW_ESCALATION_COOLDOWN) return;
1941
+ lastReviewerEscalationAt = now;
1900
1942
 
1901
1943
  const lines = breachedTasks.slice(0, 5).map(t => {
1902
1944
  const reviewer = t.reviewer || 'unassigned';
1903
- 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;
1904
1948
  });
1905
1949
 
1906
- const content = '@owner Review SLA breach detected:\n' + lines.join('\n');
1950
+ const content = 'Review overdue (reviewer SLA):\n' + lines.join('\n');
1907
1951
 
1908
1952
  try {
1909
1953
  await fetch(BASE + '/chat/messages', {
1910
1954
  method: 'POST',
1911
1955
  headers: { 'Content-Type': 'application/json' },
1912
1956
  body: JSON.stringify({
1913
- from: 'system',
1957
+ from: 'dashboard',
1914
1958
  content,
1915
1959
  channel: 'general',
1916
1960
  timestamp: now
@@ -1921,6 +1965,36 @@ async function escalateReviewBreaches(breachedTasks) {
1921
1965
  }
1922
1966
  }
1923
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
+
1924
1998
  // ---- Feedback ----
1925
1999
  let feedbackData = null;
1926
2000
 
@@ -3020,13 +3094,21 @@ async function checkGettingStarted() {
3020
3094
 
3021
3095
  function dismissGettingStarted() {
3022
3096
  const panel = document.getElementById('getting-started');
3023
- if (panel) panel.classList.add('hidden');
3097
+ if (panel) {
3098
+ panel.classList.add('hidden');
3099
+ panel.style.display = 'none'; // belt + suspenders
3100
+ }
3024
3101
  try { localStorage.setItem('reflectt-gs-dismissed', '1'); } catch {}
3025
3102
  }
3103
+ // Expose globally for onclick handlers (safety net)
3104
+ window.dismissGettingStarted = dismissGettingStarted;
3105
+ window.dismissFirstBootBanner = dismissFirstBootBanner;
3026
3106
 
3027
3107
  updateClock();
3028
3108
  setInterval(updateClock, 30000);
3029
3109
  checkGettingStarted();
3110
+ // Retry getting-started check after 5s in case health wasn't ready at boot
3111
+ setTimeout(checkGettingStarted, 5000);
3030
3112
  refresh();
3031
3113
  connectEventStream();
3032
3114
  startAdaptiveRefresh();
@@ -3406,7 +3488,7 @@ function fileIcon(mimeType, name) {
3406
3488
  }
3407
3489
 
3408
3490
  function formatFileSize(bytes) {
3409
- if (!bytes) return '0 B';
3491
+ if (typeof bytes !== 'number' || bytes <= 0) return '0 B';
3410
3492
  if (bytes < 1024) return bytes + ' B';
3411
3493
  if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
3412
3494
  return (bytes / 1048576).toFixed(1) + ' MB';
@@ -3455,7 +3537,7 @@ function renderFiles(files) {
3455
3537
  return '<div class="file-card" tabindex="0" role="button" aria-label="' + (f.originalName || f.filename) + '">' +
3456
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>' +
3457
3539
  '<div class="card-name">' + (f.originalName || f.filename) + '</div>' +
3458
- '<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>' +
3459
3541
  '<div class="card-actions">' +
3460
3542
  '<a href="/files/' + f.id + '" download="' + (f.originalName || f.filename) + '" class="action-btn" aria-label="Download" onclick="event.stopPropagation()">⬇</a>' +
3461
3543
  '<button class="action-btn delete" aria-label="Delete" onclick="event.stopPropagation();deleteFile(\'' + f.id + '\')">🗑</button>' +
@@ -3468,7 +3550,7 @@ function renderFiles(files) {
3468
3550
  return '<div class="file-list-item" tabindex="0" role="button">' +
3469
3551
  '<span class="list-icon">' + icon + '</span>' +
3470
3552
  '<span class="list-name">' + (f.originalName || f.filename) + '</span>' +
3471
- '<span class="list-meta">' + formatFileSize(f.size) + '</span>' +
3553
+ '<span class="list-meta">' + formatFileSize(f.sizeBytes) + '</span>' +
3472
3554
  '<span class="list-meta">' + timeAgo(f.uploadedAt || f.createdAt) + '</span>' +
3473
3555
  '<div class="list-actions">' +
3474
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.