@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5

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 (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -0
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { BacklogPanel } from './BacklogPanel';
4
+ import { fixtureBacklog } from '../test/fixtures';
5
+ import { TESTIDS } from '../test/fixtures';
6
+
7
+ describe('BacklogPanel', () => {
8
+ it('renders empty state when backlog is null', () => {
9
+ render(<BacklogPanel backlog={null} />);
10
+
11
+ expect(screen.getByText('No backlog items')).toBeInTheDocument();
12
+ });
13
+
14
+ it('renders empty state when backlog has no tiers', () => {
15
+ render(<BacklogPanel backlog={{ tiers: [] }} />);
16
+
17
+ expect(screen.getByText('No backlog items')).toBeInTheDocument();
18
+ });
19
+
20
+ it('renders backlog title and audit tiers', () => {
21
+ render(<BacklogPanel backlog={fixtureBacklog} />);
22
+
23
+ expect(screen.getByText('Audit Backlog')).toBeInTheDocument();
24
+ expect(screen.getByText('P0')).toBeInTheDocument();
25
+ expect(screen.getByText('P1')).toBeInTheDocument();
26
+ });
27
+
28
+ it('displays tier counts (done, inflight, todo)', () => {
29
+ render(<BacklogPanel backlog={fixtureBacklog} />);
30
+
31
+ // P0 tier has done: 1, inflight: 1, todo: 1
32
+ expect(screen.getByText('1 done, 1 inflight, 1 todo')).toBeInTheDocument();
33
+
34
+ // P1 tier has done: 0, inflight: 0, todo: 1
35
+ expect(screen.getByText('0 done, 0 inflight, 1 todo')).toBeInTheDocument();
36
+ });
37
+
38
+ it('renders progress bar segments with correct widths', () => {
39
+ render(<BacklogPanel backlog={fixtureBacklog} />);
40
+
41
+ const doneSegments = screen.getAllByLabelText(/done/);
42
+ expect(doneSegments.length).toBeGreaterThan(0);
43
+
44
+ // P0 has 1/3 done, so should be 33%
45
+ const p0DoneSegment = doneSegments[0];
46
+ expect(p0DoneSegment).toHaveStyle('width: 33.33333333333333%');
47
+ });
48
+
49
+ it('renders progress bar for all segment types', () => {
50
+ render(<BacklogPanel backlog={fixtureBacklog} />);
51
+
52
+ // P0 tier should have all three segment types
53
+ const allSegments = screen.getAllByLabelText(/(done|in flight|to do)/);
54
+ expect(allSegments.length).toBeGreaterThan(0);
55
+ });
56
+
57
+ it('renders all backlog items with status emoji and tags', () => {
58
+ render(<BacklogPanel backlog={fixtureBacklog} />);
59
+
60
+ // P0 items: "Origin fail-closed on /api/session", etc.
61
+ expect(screen.getByText('Origin fail-closed on /api/session')).toBeInTheDocument();
62
+ expect(screen.getByText('Dashboard rewrite foundation (U1)')).toBeInTheDocument();
63
+ expect(screen.getByText('Cutover / to dist index (U9)')).toBeInTheDocument();
64
+
65
+ // Check that tags are present
66
+ expect(screen.getAllByText('[sec]').length).toBeGreaterThan(0);
67
+ expect(screen.getAllByText('[ui]').length).toBeGreaterThan(0);
68
+ });
69
+
70
+ it('skips rendering tiers with zero items', () => {
71
+ const emptyTier = {
72
+ tier: 'P0' as const,
73
+ items: [],
74
+ done: 0,
75
+ inflight: 0,
76
+ todo: 0,
77
+ total: 0,
78
+ };
79
+
80
+ const backlog = {
81
+ tiers: [emptyTier, fixtureBacklog.tiers[0]],
82
+ };
83
+
84
+ render(<BacklogPanel backlog={backlog} />);
85
+
86
+ // Should render P0 from fixture but not the empty one
87
+ expect(screen.getByText('Dashboard rewrite foundation (U1)')).toBeInTheDocument();
88
+ });
89
+
90
+ it('has correct testid and renders panel container', () => {
91
+ render(<BacklogPanel backlog={fixtureBacklog} />);
92
+
93
+ const panel = screen.getByTestId(TESTIDS.backlogPanel);
94
+ expect(panel).toBeInTheDocument();
95
+ });
96
+
97
+ it('status emojis have proper aria-labels', () => {
98
+ render(<BacklogPanel backlog={fixtureBacklog} />);
99
+
100
+ // Look for aria-labels on status emojis
101
+ const statusSpans = screen.getAllByLabelText(/Done|In progress|To do|Blocked/);
102
+ expect(statusSpans.length).toBeGreaterThan(0);
103
+
104
+ statusSpans.forEach((span) => {
105
+ expect(span).toHaveAttribute('aria-label');
106
+ });
107
+ });
108
+
109
+ it('progress segments have proper titles and aria-labels', () => {
110
+ render(<BacklogPanel backlog={fixtureBacklog} />);
111
+
112
+ const doneSegments = screen.getAllByLabelText(/done/);
113
+ doneSegments.forEach((segment) => {
114
+ expect(segment).toHaveAttribute('title');
115
+ expect(segment).toHaveAttribute('aria-label');
116
+ });
117
+ });
118
+
119
+ it('renders P1 tier items correctly', () => {
120
+ render(<BacklogPanel backlog={fixtureBacklog} />);
121
+
122
+ // P1 items from fixture
123
+ expect(screen.getByText('SSE keepalive tuning')).toBeInTheDocument();
124
+ expect(screen.getByText('Hierarchical orchestration seams')).toBeInTheDocument();
125
+ });
126
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * BacklogPanel — Audit backlog with tier progress bars.
3
+ * Shows done/in-flight/todo counts and a list of items per tier.
4
+ * Handles overflow with fade-out effect, empty state.
5
+ */
6
+
7
+ import { TESTIDS } from '../test/fixtures';
8
+ import type { AuditBacklog } from '../lib/types';
9
+
10
+ interface BacklogPanelProps {
11
+ backlog: AuditBacklog | null;
12
+ }
13
+
14
+ function statusEmoji(status: string): string {
15
+ switch (status) {
16
+ case '✅':
17
+ return '✅';
18
+ case '🔵':
19
+ return '🔵';
20
+ case '⬜':
21
+ return '⬜';
22
+ case '⏸':
23
+ return '⏸';
24
+ default:
25
+ return '❓';
26
+ }
27
+ }
28
+
29
+ function statusLabel(status: string): string {
30
+ switch (status) {
31
+ case '✅':
32
+ return 'Done';
33
+ case '🔵':
34
+ return 'In progress';
35
+ case '⬜':
36
+ return 'To do';
37
+ case '⏸':
38
+ return 'Blocked';
39
+ default:
40
+ return 'Unknown';
41
+ }
42
+ }
43
+
44
+ export function BacklogPanel({ backlog }: BacklogPanelProps) {
45
+ if (!backlog || backlog.tiers.length === 0) {
46
+ return (
47
+ <div className="backlog-panel" data-testid={TESTIDS.backlogPanel}>
48
+ <h3>Audit Backlog</h3>
49
+ <p className="empty-state">No backlog items</p>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ return (
55
+ <div className="backlog-panel" data-testid={TESTIDS.backlogPanel}>
56
+ <h3>Audit Backlog</h3>
57
+
58
+ {backlog.tiers.map((tier) => {
59
+ const total = tier.total;
60
+ if (total === 0) return null;
61
+
62
+ const donePercent = total > 0 ? (tier.done / total) * 100 : 0;
63
+ const inflightPercent = total > 0 ? (tier.inflight / total) * 100 : 0;
64
+ const todoPercent = total > 0 ? (tier.todo / total) * 100 : 0;
65
+
66
+ return (
67
+ <div key={tier.tier} className="backlog-tier">
68
+ <div className="tier-header">
69
+ <h4>{tier.tier}</h4>
70
+ <span className="tier-counts">
71
+ {tier.done} done, {tier.inflight} inflight, {tier.todo} todo
72
+ </span>
73
+ </div>
74
+
75
+ <div className="tier-progress-bar">
76
+ {tier.done > 0 && (
77
+ <div
78
+ className="progress-segment done"
79
+ style={{ width: `${donePercent}%` }}
80
+ title={`Done: ${tier.done}`}
81
+ aria-label={`${tier.done} items done`}
82
+ />
83
+ )}
84
+ {tier.inflight > 0 && (
85
+ <div
86
+ className="progress-segment inflight"
87
+ style={{ width: `${inflightPercent}%` }}
88
+ title={`In flight: ${tier.inflight}`}
89
+ aria-label={`${tier.inflight} items in flight`}
90
+ />
91
+ )}
92
+ {tier.todo > 0 && (
93
+ <div
94
+ className="progress-segment todo"
95
+ style={{ width: `${todoPercent}%` }}
96
+ title={`To do: ${tier.todo}`}
97
+ aria-label={`${tier.todo} items to do`}
98
+ />
99
+ )}
100
+ </div>
101
+
102
+ <div className="tier-items-list">
103
+ {tier.items.map((item, idx) => (
104
+ <div key={idx} className="backlog-item">
105
+ <span
106
+ className="item-status"
107
+ title={statusLabel(item.status)}
108
+ aria-label={statusLabel(item.status)}
109
+ >
110
+ {statusEmoji(item.status)}
111
+ </span>
112
+ <span className="item-tag">{item.tag}</span>
113
+ <span className="item-title">{item.title}</span>
114
+ </div>
115
+ ))}
116
+ </div>
117
+ </div>
118
+ );
119
+ })}
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,110 @@
1
+ /* CostChart — SVG bar chart for daily token usage */
2
+
3
+ .chart-container {
4
+ display: flex;
5
+ flex-direction: column;
6
+ border: 1px solid var(--color-border);
7
+ border-radius: var(--radius-md);
8
+ background: var(--color-surface);
9
+ overflow: hidden;
10
+ }
11
+
12
+ .chart-empty {
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ justify-content: center;
17
+ min-height: 250px;
18
+ padding: var(--space-6);
19
+ text-align: center;
20
+ }
21
+
22
+ .chart-empty-message {
23
+ margin: 0 0 var(--space-2) 0;
24
+ font-size: var(--font-size-md);
25
+ font-weight: var(--font-weight-medium);
26
+ color: var(--color-text);
27
+ }
28
+
29
+ .chart-empty-hint {
30
+ margin: 0;
31
+ font-size: var(--font-size-sm);
32
+ color: var(--color-text-muted);
33
+ }
34
+
35
+ .cost-chart-svg {
36
+ max-width: 100%;
37
+ height: auto;
38
+ overflow: visible;
39
+ display: block;
40
+ }
41
+
42
+ .chart-footer {
43
+ padding: var(--space-2) var(--space-4);
44
+ border-top: 1px solid var(--color-border-subtle);
45
+ background: var(--color-bg);
46
+ text-align: right;
47
+ font-size: var(--font-size-xs);
48
+ }
49
+
50
+ .chart-footer-timestamp {
51
+ color: var(--color-text-muted);
52
+ font-family: var(--font-mono);
53
+ }
54
+
55
+ /* Chart axes and styling */
56
+ .chart-axis {
57
+ stroke: var(--color-border);
58
+ stroke-width: 2;
59
+ }
60
+
61
+ .chart-label-x,
62
+ .chart-label-y {
63
+ font-size: 12px;
64
+ fill: var(--color-text-muted);
65
+ font-family: var(--font-sans);
66
+ }
67
+
68
+ /* Bar segments — stacked bars showing in/out tokens */
69
+ .bar-segment {
70
+ stroke: var(--color-border-subtle);
71
+ stroke-width: 0.5;
72
+ transition: opacity var(--transition-fast);
73
+ cursor: pointer;
74
+ }
75
+
76
+ .bar-segment:hover {
77
+ opacity: 0.8;
78
+ }
79
+
80
+ .bar-input {
81
+ fill: var(--color-status-info);
82
+ }
83
+
84
+ .bar-output {
85
+ fill: var(--color-accent);
86
+ }
87
+
88
+ /* Legend */
89
+ .chart-legend {
90
+ font-size: 12px;
91
+ }
92
+
93
+ .legend-text {
94
+ fill: var(--color-text-muted);
95
+ font-family: var(--font-sans);
96
+ font-size: 12px;
97
+ }
98
+
99
+ /* Responsive */
100
+ @media (max-width: 768px) {
101
+ .cost-chart-svg {
102
+ width: 100%;
103
+ }
104
+
105
+ .chart-label-x,
106
+ .chart-label-y,
107
+ .legend-text {
108
+ font-size: 10px;
109
+ }
110
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * CostChart component tests — pure SVG bar chart for per-day tokens.
3
+ * Tests empty data, single day, many days, axis labels, titles.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { render, screen } from '@testing-library/react';
8
+ import { CostChart } from './CostChart';
9
+ import { fixtureCost, fixtureCostWithPricing, TESTIDS } from '../test/fixtures';
10
+
11
+ describe('CostChart', () => {
12
+ it('renders chart with testid', () => {
13
+ render(<CostChart cost={fixtureCost} />);
14
+ expect(screen.getByTestId(TESTIDS.costChart)).toBeInTheDocument();
15
+ });
16
+
17
+ it('renders as an SVG element (inside container)', () => {
18
+ render(<CostChart cost={fixtureCost} />);
19
+ const container = screen.getByTestId(TESTIDS.costChart);
20
+ const svg = container.querySelector('svg');
21
+ expect(svg).toBeInTheDocument();
22
+ });
23
+
24
+ it('renders bars for each day in daily_totals', () => {
25
+ render(<CostChart cost={fixtureCost} />);
26
+ const container = screen.getByTestId(TESTIDS.costChart);
27
+ const svg = container.querySelector('svg') as SVGElement;
28
+ // fixture has 3 days: 2026-07-11, 2026-07-12, 2026-07-13
29
+ // Each day has 2 bars (input and output), so 6 rects total
30
+ const rects = svg.querySelectorAll('rect[data-day]');
31
+ expect(rects.length).toBe(6);
32
+ });
33
+
34
+ it('sets data-day attribute on each bar for testability', () => {
35
+ render(<CostChart cost={fixtureCost} />);
36
+ const container = screen.getByTestId(TESTIDS.costChart);
37
+ const svg = container.querySelector('svg') as SVGElement;
38
+ expect(svg.querySelector('rect[data-day="2026-07-11"]')).toBeInTheDocument();
39
+ expect(svg.querySelector('rect[data-day="2026-07-12"]')).toBeInTheDocument();
40
+ expect(svg.querySelector('rect[data-day="2026-07-13"]')).toBeInTheDocument();
41
+ });
42
+
43
+ it('includes <title> elements for accessibility on each bar', () => {
44
+ render(<CostChart cost={fixtureCost} />);
45
+ const container = screen.getByTestId(TESTIDS.costChart);
46
+ const svg = container.querySelector('svg') as SVGElement;
47
+ const titles = svg.querySelectorAll('title');
48
+ expect(titles.length).toBeGreaterThan(0);
49
+ });
50
+
51
+ it('scales bars proportionally to token counts', () => {
52
+ render(<CostChart cost={fixtureCost} />);
53
+ const container = screen.getByTestId(TESTIDS.costChart);
54
+ const svg = container.querySelector('svg') as SVGElement;
55
+ const day11 = svg.querySelector('rect[data-day="2026-07-11"]') as SVGElement;
56
+ const day12 = svg.querySelector('rect[data-day="2026-07-12"]') as SVGElement;
57
+ // day11: 1204000 tokens, day12: 986170 tokens
58
+ // day11 should be taller than day12
59
+ const height11 = parseFloat(day11?.getAttribute('height') || '0');
60
+ const height12 = parseFloat(day12?.getAttribute('height') || '0');
61
+ expect(height11).toBeGreaterThan(height12);
62
+ });
63
+
64
+ it('handles single day without breaking layout', () => {
65
+ const single = {
66
+ ...fixtureCost,
67
+ daily_totals: {
68
+ '2026-07-13': fixtureCost.daily_totals['2026-07-13'],
69
+ },
70
+ };
71
+ render(<CostChart cost={single} />);
72
+ const container = screen.getByTestId(TESTIDS.costChart);
73
+ const svg = container.querySelector('svg') as SVGElement;
74
+ const rects = svg.querySelectorAll('rect[data-day]');
75
+ // Single day has 2 bars (input and output)
76
+ expect(rects.length).toBe(2);
77
+ });
78
+
79
+ it('handles empty daily_totals gracefully', () => {
80
+ const empty = {
81
+ ...fixtureCost,
82
+ daily_totals: {},
83
+ };
84
+ render(<CostChart cost={empty} />);
85
+ const chart = screen.getByTestId(TESTIDS.costChart);
86
+ // Should still render (possibly with placeholder or empty state)
87
+ expect(chart).toBeInTheDocument();
88
+ });
89
+
90
+ it('provides axis labels for readability', () => {
91
+ render(<CostChart cost={fixtureCost} />);
92
+ const container = screen.getByTestId(TESTIDS.costChart);
93
+ const svg = container.querySelector('svg') as SVGElement;
94
+ // Should have text elements for dates or axis labels
95
+ const texts = svg.querySelectorAll('text');
96
+ expect(texts.length).toBeGreaterThan(0);
97
+ });
98
+
99
+ it('uses theme color tokens (no hex colors)', () => {
100
+ render(<CostChart cost={fixtureCost} />);
101
+ const container = screen.getByTestId(TESTIDS.costChart);
102
+ const svg = container.querySelector('svg') as SVGElement;
103
+ // Should use CSS vars, not inline hex
104
+ const style = svg.getAttribute('style') || '';
105
+ expect(style).not.toMatch(/#[0-9a-fA-F]{3,6}(?![0-9a-fA-F])/);
106
+ });
107
+
108
+ it('works with pricing fixture', () => {
109
+ render(<CostChart cost={fixtureCostWithPricing} />);
110
+ expect(screen.getByTestId(TESTIDS.costChart)).toBeInTheDocument();
111
+ });
112
+
113
+ it('does not overflow parent container', () => {
114
+ render(<CostChart cost={fixtureCost} />);
115
+ const container = screen.getByTestId(TESTIDS.costChart);
116
+ const svg = container.querySelector('svg');
117
+ const viewBox = svg?.getAttribute('viewBox');
118
+ expect(viewBox).toBeTruthy();
119
+ // SVG should use viewBox for responsive scaling
120
+ });
121
+
122
+ it('clamping: does not break with extreme token counts', () => {
123
+ const extreme = {
124
+ ...fixtureCost,
125
+ daily_totals: {
126
+ '2026-07-13': {
127
+ tokens_in: 999999999,
128
+ tokens_out: 999999999,
129
+ },
130
+ },
131
+ };
132
+ render(<CostChart cost={extreme} />);
133
+ expect(screen.getByTestId(TESTIDS.costChart)).toBeInTheDocument();
134
+ });
135
+
136
+ it('chart has proper SVG structure (g elements for groups)', () => {
137
+ render(<CostChart cost={fixtureCost} />);
138
+ const container = screen.getByTestId(TESTIDS.costChart);
139
+ const svg = container.querySelector('svg') as SVGElement;
140
+ // Should use <g> for grouping logical elements
141
+ const groups = svg.querySelectorAll('g');
142
+ expect(groups.length).toBeGreaterThan(0);
143
+ });
144
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * CostChart component — pure SVG bar chart showing per-day token usage.
3
+ * No external chart libraries; uses SVG primitives (<rect>, <text>, <title>).
4
+ * Responsive via viewBox; theme colors via CSS variables.
5
+ * Handles empty data, single day, and many days gracefully.
6
+ */
7
+
8
+ import type { CostSummary } from '../lib/types';
9
+ import { formatTimestamp } from '../lib/format';
10
+ import { TESTIDS } from '../test/fixtures';
11
+ import './CostChart.css';
12
+
13
+ interface CostChartProps {
14
+ cost: CostSummary;
15
+ }
16
+
17
+ export function CostChart({ cost }: CostChartProps) {
18
+ const { daily_totals, overall_scorecard } = cost;
19
+ const days = Object.keys(daily_totals).sort();
20
+ const lastDayKey = days.length > 0 ? days[days.length - 1] : null;
21
+
22
+ if (days.length === 0 || overall_scorecard.total_runs === 0) {
23
+ return (
24
+ <div className="chart-container" data-testid={TESTIDS.costChart}>
25
+ <div className="chart-empty">
26
+ <p className="chart-empty-message">No ledger data yet</p>
27
+ <p className="chart-empty-hint">Cost data will appear as agents complete runs.</p>
28
+ </div>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ // Calculate max for scaling
34
+ const allTokens = days.flatMap((day) => [
35
+ daily_totals[day].tokens_in,
36
+ daily_totals[day].tokens_out,
37
+ ]);
38
+ const maxTokens = Math.max(...allTokens, 1);
39
+
40
+ // SVG layout
41
+ const SVG_WIDTH = 600;
42
+ const SVG_HEIGHT = 250;
43
+ const CHART_MARGIN = 40;
44
+ const CHART_AREA_WIDTH = SVG_WIDTH - CHART_MARGIN * 2;
45
+ const CHART_AREA_HEIGHT = SVG_HEIGHT - CHART_MARGIN * 2;
46
+ const BAR_WIDTH = CHART_AREA_WIDTH / (days.length * 2.5);
47
+ const BAR_GROUP_SPACING = BAR_WIDTH * 1.5;
48
+
49
+ return (
50
+ <div className="chart-container" data-testid={TESTIDS.costChart}>
51
+ <svg
52
+ viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
53
+ className="cost-chart-svg"
54
+ role="img"
55
+ aria-label="Daily token usage by model"
56
+ >
57
+ {/* Axes */}
58
+ <line
59
+ x1={CHART_MARGIN}
60
+ y1={SVG_HEIGHT - CHART_MARGIN}
61
+ x2={SVG_WIDTH - CHART_MARGIN}
62
+ y2={SVG_HEIGHT - CHART_MARGIN}
63
+ className="chart-axis"
64
+ />
65
+ <line
66
+ x1={CHART_MARGIN}
67
+ y1={CHART_MARGIN}
68
+ x2={CHART_MARGIN}
69
+ y2={SVG_HEIGHT - CHART_MARGIN}
70
+ className="chart-axis"
71
+ />
72
+
73
+ {/* Y-axis label */}
74
+ <text x={15} y={CHART_MARGIN} className="chart-label-y">
75
+ Tokens
76
+ </text>
77
+
78
+ {/* Bars and labels */}
79
+ {days.map((day, i) => {
80
+ const totals = daily_totals[day];
81
+ const totalTokens = totals.tokens_in + totals.tokens_out;
82
+ const barHeight = (totalTokens / maxTokens) * CHART_AREA_HEIGHT;
83
+ const xPos = CHART_MARGIN + i * BAR_GROUP_SPACING;
84
+
85
+ return (
86
+ <g key={day} className="bar-group">
87
+ {/* Stacked bar */}
88
+ <g>
89
+ {/* Input tokens (bottom) */}
90
+ <rect
91
+ x={xPos}
92
+ y={SVG_HEIGHT - CHART_MARGIN - (totals.tokens_in / maxTokens) * CHART_AREA_HEIGHT}
93
+ width={BAR_WIDTH * 0.35}
94
+ height={(totals.tokens_in / maxTokens) * CHART_AREA_HEIGHT}
95
+ className="bar-segment bar-input"
96
+ data-day={day}
97
+ >
98
+ <title>
99
+ {day}: {totals.tokens_in.toLocaleString()} tokens in
100
+ </title>
101
+ </rect>
102
+
103
+ {/* Output tokens (top) */}
104
+ <rect
105
+ x={xPos + BAR_WIDTH * 0.4}
106
+ y={SVG_HEIGHT - CHART_MARGIN - barHeight}
107
+ width={BAR_WIDTH * 0.35}
108
+ height={(totals.tokens_out / maxTokens) * CHART_AREA_HEIGHT}
109
+ className="bar-segment bar-output"
110
+ data-day={day}
111
+ >
112
+ <title>
113
+ {day}: {totals.tokens_out.toLocaleString()} tokens out
114
+ </title>
115
+ </rect>
116
+ </g>
117
+
118
+ {/* Date label */}
119
+ <text
120
+ x={xPos + BAR_WIDTH * 0.35}
121
+ y={SVG_HEIGHT - CHART_MARGIN + 20}
122
+ className="chart-label-x"
123
+ textAnchor="middle"
124
+ >
125
+ {day.split('-')[2]}
126
+ </text>
127
+ </g>
128
+ );
129
+ })}
130
+
131
+ {/* Legend */}
132
+ <g className="chart-legend">
133
+ <rect x={SVG_WIDTH - 150} y={15} width={12} height={12} className="bar-input" />
134
+ <text x={SVG_WIDTH - 135} y={25} className="legend-text">
135
+ In
136
+ </text>
137
+
138
+ <rect x={SVG_WIDTH - 150} y={35} width={12} height={12} className="bar-output" />
139
+ <text x={SVG_WIDTH - 135} y={45} className="legend-text">
140
+ Out
141
+ </text>
142
+ </g>
143
+ </svg>
144
+
145
+ {lastDayKey && (
146
+ <div className="chart-footer">
147
+ <span className="chart-footer-timestamp">updated {formatTimestamp(lastDayKey + 'T23:59:59Z')}</span>
148
+ </div>
149
+ )}
150
+ </div>
151
+ );
152
+ }