@matt82198/aesop 0.1.0-beta.4 → 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 (174) hide show
  1. package/CHANGELOG.md +46 -5
  2. package/README.md +57 -1
  3. package/aesop.config.example.json +8 -1
  4. package/bin/CLAUDE.md +30 -2
  5. package/bin/cli.js +286 -73
  6. package/daemons/CLAUDE.md +4 -1
  7. package/daemons/run-watchdog.sh +5 -2
  8. package/docs/RELEASING.md +159 -0
  9. package/docs/archive/README.md +3 -0
  10. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  11. package/docs/case-study-portfolio.md +61 -0
  12. package/docs/self-stats-data.json +11 -0
  13. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  14. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  15. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  16. package/hooks/CLAUDE.md +23 -0
  17. package/mcp/CLAUDE.md +213 -0
  18. package/mcp/package.json +26 -0
  19. package/mcp/server.mjs +543 -0
  20. package/monitor/CHARTER.md +76 -110
  21. package/monitor/collect-signals.mjs +85 -0
  22. package/package.json +4 -1
  23. package/scan/fleet-scan.example.mjs +292 -0
  24. package/skills/healthcheck/SKILL.md +44 -0
  25. package/state_store/CLAUDE.md +39 -0
  26. package/state_store/__init__.py +24 -0
  27. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  29. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  30. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  31. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  32. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  33. package/state_store/api.py +35 -0
  34. package/state_store/export.py +19 -0
  35. package/state_store/ingest.py +24 -0
  36. package/state_store/projections.py +52 -0
  37. package/state_store/store.py +102 -0
  38. package/tools/CLAUDE.md +30 -149
  39. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  40. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  41. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  42. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  43. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  44. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  45. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  46. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  52. package/tools/alert_bridge.py +449 -0
  53. package/tools/ci_merge_wait.py +121 -8
  54. package/tools/fleet_prompt_extractor.py +134 -0
  55. package/tools/healthcheck.py +296 -0
  56. package/tools/secret_scan.py +8 -1
  57. package/tools/self_stats.py +509 -0
  58. package/tools/session_usage_summary.py +198 -0
  59. package/tools/svg_to_png.mjs +50 -0
  60. package/tools/transcript_replay.py +236 -0
  61. package/tools/transcript_timeline.py +184 -0
  62. package/tools/verify_dash.py +361 -542
  63. package/tools/verify_submit_encoding.py +12 -4
  64. package/ui/CLAUDE.md +57 -41
  65. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  66. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  67. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  68. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  69. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  70. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  71. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  72. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  73. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  74. package/ui/agents.py +9 -5
  75. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  76. package/ui/api/submit.py +44 -5
  77. package/ui/collectors.py +184 -35
  78. package/ui/config.py +9 -0
  79. package/ui/cost.py +260 -0
  80. package/ui/csrf.py +7 -3
  81. package/ui/handler.py +254 -4
  82. package/ui/render.py +26 -6
  83. package/ui/sse.py +16 -1
  84. package/ui/web/.gitattributes +13 -0
  85. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  86. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  87. package/ui/web/dist/index.html +14 -0
  88. package/ui/web/index.html +13 -0
  89. package/ui/web/package-lock.json +2225 -0
  90. package/ui/web/package.json +26 -0
  91. package/ui/web/src/App.test.tsx +74 -0
  92. package/ui/web/src/App.tsx +142 -0
  93. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  94. package/ui/web/src/components/AgentRow.css +187 -0
  95. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  96. package/ui/web/src/components/AgentRow.tsx +207 -0
  97. package/ui/web/src/components/AgentsPanel.css +108 -0
  98. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  99. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  100. package/ui/web/src/components/AlertsPanel.css +88 -0
  101. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  102. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  103. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  104. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  105. package/ui/web/src/components/CostChart.css +110 -0
  106. package/ui/web/src/components/CostChart.test.tsx +144 -0
  107. package/ui/web/src/components/CostChart.tsx +152 -0
  108. package/ui/web/src/components/CostTable.css +93 -0
  109. package/ui/web/src/components/CostTable.test.tsx +165 -0
  110. package/ui/web/src/components/CostTable.tsx +94 -0
  111. package/ui/web/src/components/EventsFeed.css +68 -0
  112. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  113. package/ui/web/src/components/EventsFeed.tsx +31 -0
  114. package/ui/web/src/components/HealthHeader.css +137 -0
  115. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  116. package/ui/web/src/components/HealthHeader.tsx +281 -0
  117. package/ui/web/src/components/InboxForm.css +135 -0
  118. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  119. package/ui/web/src/components/InboxForm.tsx +116 -0
  120. package/ui/web/src/components/MessagesTail.module.css +144 -0
  121. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  122. package/ui/web/src/components/MessagesTail.tsx +94 -0
  123. package/ui/web/src/components/ReposPanel.css +90 -0
  124. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  125. package/ui/web/src/components/ReposPanel.tsx +67 -0
  126. package/ui/web/src/components/Scorecard.css +106 -0
  127. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  128. package/ui/web/src/components/Scorecard.tsx +85 -0
  129. package/ui/web/src/components/Timeline.module.css +151 -0
  130. package/ui/web/src/components/Timeline.test.tsx +215 -0
  131. package/ui/web/src/components/Timeline.tsx +99 -0
  132. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  133. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  134. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  135. package/ui/web/src/components/TrackerCard.tsx +160 -0
  136. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  137. package/ui/web/src/components/TrackerForm.tsx +144 -0
  138. package/ui/web/src/lib/api.ts +218 -0
  139. package/ui/web/src/lib/format.test.ts +89 -0
  140. package/ui/web/src/lib/format.ts +103 -0
  141. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  142. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  143. package/ui/web/src/lib/types.ts +230 -0
  144. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  145. package/ui/web/src/lib/useHashRoute.ts +23 -0
  146. package/ui/web/src/lib/useSSE.ts +175 -0
  147. package/ui/web/src/main.tsx +10 -0
  148. package/ui/web/src/styles/global.css +179 -0
  149. package/ui/web/src/styles/theme.css +184 -0
  150. package/ui/web/src/styles/work.css +572 -0
  151. package/ui/web/src/test/fixtures.ts +385 -0
  152. package/ui/web/src/test/setup.ts +49 -0
  153. package/ui/web/src/views/Activity.module.css +43 -0
  154. package/ui/web/src/views/Activity.test.tsx +89 -0
  155. package/ui/web/src/views/Activity.tsx +31 -0
  156. package/ui/web/src/views/Cost.css +87 -0
  157. package/ui/web/src/views/Cost.test.tsx +142 -0
  158. package/ui/web/src/views/Cost.tsx +54 -0
  159. package/ui/web/src/views/Overview.css +51 -0
  160. package/ui/web/src/views/Overview.test.tsx +76 -0
  161. package/ui/web/src/views/Overview.tsx +46 -0
  162. package/ui/web/src/views/Work.test.tsx +82 -0
  163. package/ui/web/src/views/Work.tsx +79 -0
  164. package/ui/web/src/vite-env.d.ts +10 -0
  165. package/ui/web/tsconfig.json +22 -0
  166. package/ui/web/vite.config.ts +25 -0
  167. package/ui/web/vitest.config.ts +12 -0
  168. package/ui/templates/dashboard.html +0 -1202
  169. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  170. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  171. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  172. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  173. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  174. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Timeline styling — horizontal status-colored bars per agent.
3
+ */
4
+
5
+ .container {
6
+ border: 1px solid var(--color-border);
7
+ border-radius: var(--radius-md);
8
+ background: var(--color-bg-subtle);
9
+ padding: var(--space-4);
10
+ }
11
+
12
+ .title {
13
+ margin: 0 0 var(--space-4) 0;
14
+ font-size: var(--font-size-md);
15
+ font-weight: var(--font-weight-medium);
16
+ color: var(--color-text);
17
+ }
18
+
19
+ .timelineContent {
20
+ display: flex;
21
+ flex-direction: column;
22
+ gap: var(--space-4);
23
+ }
24
+
25
+ .row {
26
+ display: grid;
27
+ grid-template-columns: 160px 1fr 80px;
28
+ gap: var(--space-3);
29
+ align-items: center;
30
+ }
31
+
32
+ .rowLabel {
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: var(--space-1);
36
+ font-size: var(--font-size-sm);
37
+ overflow: hidden;
38
+ }
39
+
40
+ .agentId {
41
+ font-family: var(--font-mono);
42
+ font-weight: var(--font-weight-bold);
43
+ color: var(--color-text);
44
+ word-break: break-all;
45
+ }
46
+
47
+ .agentStatus {
48
+ font-size: var(--font-size-xs);
49
+ color: var(--color-text-muted);
50
+ text-transform: uppercase;
51
+ }
52
+
53
+ .barContainer {
54
+ height: 24px;
55
+ background: var(--color-bg);
56
+ border: 1px solid var(--color-border-subtle);
57
+ border-radius: var(--radius-sm);
58
+ overflow: hidden;
59
+ position: relative;
60
+ }
61
+
62
+ .bar {
63
+ height: 100%;
64
+ border-radius: var(--radius-sm);
65
+ transition: width var(--transition-normal);
66
+ min-width: 5%;
67
+ max-width: 100%;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: flex-end;
71
+ padding-right: var(--space-2);
72
+ font-size: var(--font-size-xs);
73
+ font-weight: var(--font-weight-medium);
74
+ color: var(--color-text-inverse);
75
+ white-space: nowrap;
76
+ overflow: hidden;
77
+ }
78
+
79
+ /* Status color classes */
80
+ .status-ok {
81
+ background: var(--color-status-ok);
82
+ }
83
+
84
+ .status-info {
85
+ background: var(--color-status-info);
86
+ }
87
+
88
+ .status-warn {
89
+ background: var(--color-status-warn);
90
+ }
91
+
92
+ .status-error {
93
+ background: var(--color-status-error);
94
+ }
95
+
96
+ .status-neutral {
97
+ background: var(--color-status-neutral);
98
+ }
99
+
100
+ .rowDuration {
101
+ text-align: right;
102
+ font-size: var(--font-size-sm);
103
+ font-family: var(--font-mono);
104
+ color: var(--color-text-muted);
105
+ min-width: 60px;
106
+ }
107
+
108
+ .footer {
109
+ margin-top: var(--space-4);
110
+ padding-top: var(--space-4);
111
+ border-top: 1px solid var(--color-border-subtle);
112
+ font-size: var(--font-size-xs);
113
+ color: var(--color-text-muted);
114
+ }
115
+
116
+ .legend {
117
+ display: inline-block;
118
+ line-height: var(--line-height-normal);
119
+ }
120
+
121
+ /* Responsive: stack on small screens */
122
+ @media (max-width: 768px) {
123
+ .row {
124
+ grid-template-columns: 1fr;
125
+ gap: var(--space-2);
126
+ }
127
+
128
+ .rowLabel {
129
+ display: flex;
130
+ flex-direction: row;
131
+ gap: var(--space-2);
132
+ align-items: baseline;
133
+ }
134
+
135
+ .barContainer {
136
+ order: 3;
137
+ grid-column: 1;
138
+ }
139
+
140
+ .rowDuration {
141
+ order: 2;
142
+ text-align: left;
143
+ }
144
+ }
145
+
146
+ /* Respect reduced motion */
147
+ @media (prefers-reduced-motion: reduce) {
148
+ .bar {
149
+ transition: none;
150
+ }
151
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Timeline — Horizontal per-agent bars from startedAt/lastActivity/runtimeSeconds.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { render, screen } from '@testing-library/react';
7
+ import Timeline from './Timeline';
8
+ import { fixtureAgents } from '../test/fixtures';
9
+ import { TESTIDS } from '../test/fixtures';
10
+ import type { Agent } from '../lib/types';
11
+
12
+ describe('Timeline', () => {
13
+ it('renders timeline container with testid', () => {
14
+ render(<Timeline agents={fixtureAgents} />);
15
+ expect(screen.getByTestId(TESTIDS.timeline)).toBeInTheDocument();
16
+ });
17
+
18
+ it('renders one bar per agent', () => {
19
+ render(<Timeline agents={fixtureAgents} />);
20
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
21
+ expect(bars.length).toBe(fixtureAgents.length);
22
+ });
23
+
24
+ it('shows agent id in bar label', () => {
25
+ render(<Timeline agents={fixtureAgents} />);
26
+ fixtureAgents.forEach((agent) => {
27
+ expect(screen.getByText(agent.id)).toBeInTheDocument();
28
+ });
29
+ });
30
+
31
+ it('shows agent status in bar label', () => {
32
+ render(<Timeline agents={fixtureAgents} />);
33
+ const statusElements = screen.getAllByText(/running/i);
34
+ expect(statusElements.length).toBeGreaterThan(0); // At least one "running"
35
+ // Check for idle and SUSPICIOUS in aria-labels instead
36
+ fixtureAgents.forEach((agent) => {
37
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
38
+ const agentBar = bars.find((bar) => bar.getAttribute('aria-label')?.includes(agent.id));
39
+ expect(agentBar?.getAttribute('aria-label')).toContain(agent.status);
40
+ });
41
+ });
42
+
43
+ it('shows runtime duration in bar label', () => {
44
+ render(<Timeline agents={fixtureAgents} />);
45
+ // Should format the runtimeSeconds as a human-readable duration
46
+ // First agent has runtimeSeconds: 1776, which is 29m 36s
47
+ const container = screen.getByTestId(TESTIDS.timeline);
48
+ const text = container.textContent || '';
49
+ expect(text).toContain('29m'); // Should contain formatted duration
50
+ });
51
+
52
+ it('applies status-based color to bars via theme tokens', () => {
53
+ render(<Timeline agents={fixtureAgents} />);
54
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
55
+ expect(bars.length).toBeGreaterThan(0);
56
+
57
+ // Check that bars have role-based styling (via CSS class or style)
58
+ bars.forEach((bar) => {
59
+ const style = window.getComputedStyle(bar);
60
+ // Color should be set via theme tokens (will be computed CSS variable)
61
+ expect(style.backgroundColor || style.color).toBeDefined();
62
+ });
63
+ });
64
+
65
+ it('handles missing startedAt gracefully (null)', () => {
66
+ const agents: Agent[] = [
67
+ {
68
+ ...fixtureAgents[0],
69
+ startedAt: null,
70
+ },
71
+ ];
72
+ render(<Timeline agents={agents} />);
73
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
74
+ expect(bars.length).toBe(1);
75
+ expect(bars[0]).toBeInTheDocument();
76
+ // Should still show agent id and status
77
+ expect(screen.getByText(agents[0].id)).toBeInTheDocument();
78
+ });
79
+
80
+ it('handles missing lastActivity gracefully (null)', () => {
81
+ const agents: Agent[] = [
82
+ {
83
+ ...fixtureAgents[0],
84
+ lastActivity: null,
85
+ },
86
+ ];
87
+ render(<Timeline agents={agents} />);
88
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
89
+ expect(bars.length).toBe(1);
90
+ expect(bars[0]).toBeInTheDocument();
91
+ });
92
+
93
+ it('handles garbage/invalid ISO timestamps (falls back to sensible default)', () => {
94
+ const agents: Agent[] = [
95
+ {
96
+ ...fixtureAgents[0],
97
+ startedAt: 'not-a-date',
98
+ lastActivity: 'garbage',
99
+ },
100
+ ];
101
+ render(<Timeline agents={agents} />);
102
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
103
+ expect(bars.length).toBe(1);
104
+ expect(bars[0]).toBeInTheDocument();
105
+ // Should not throw, should render with fallback values
106
+ expect(screen.getByText(agents[0].id)).toBeInTheDocument();
107
+ });
108
+
109
+ it('clamps timeline when runtimeSeconds is 0 or negative', () => {
110
+ const agents: Agent[] = [
111
+ {
112
+ ...fixtureAgents[0],
113
+ runtimeSeconds: -5,
114
+ },
115
+ ];
116
+ render(<Timeline agents={agents} />);
117
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
118
+ expect(bars.length).toBe(1);
119
+ const bar = bars[0];
120
+ expect(bar).toBeInTheDocument();
121
+ // Should render without NaN or invalid dimensions
122
+ const style = window.getComputedStyle(bar);
123
+ expect(style.width).not.toContain('NaN');
124
+ });
125
+
126
+ it('clamps timeline when runtimeSeconds is extremely large', () => {
127
+ const agents: Agent[] = [
128
+ {
129
+ ...fixtureAgents[0],
130
+ runtimeSeconds: 999999999,
131
+ },
132
+ ];
133
+ render(<Timeline agents={agents} />);
134
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
135
+ expect(bars.length).toBe(1);
136
+ const bar = bars[0];
137
+ expect(bar).toBeInTheDocument();
138
+ // Should not overflow, should be clamped
139
+ const style = window.getComputedStyle(bar);
140
+ expect(style.width).not.toContain('NaN');
141
+ });
142
+
143
+ it('computes bar width proportionally from startedAt to lastActivity', () => {
144
+ const now = new Date().toISOString();
145
+ const oneHourAgo = new Date(Date.now() - 3600000).toISOString();
146
+ const agents: Agent[] = [
147
+ {
148
+ ...fixtureAgents[0],
149
+ startedAt: oneHourAgo,
150
+ lastActivity: now,
151
+ },
152
+ ];
153
+ render(<Timeline agents={agents} />);
154
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
155
+ expect(bars.length).toBe(1);
156
+ const bar = bars[0];
157
+ expect(bar).toBeInTheDocument();
158
+ // Bar should have a width reflecting the time span
159
+ const style = window.getComputedStyle(bar);
160
+ expect(style.width).toBeDefined();
161
+ expect(style.width).not.toBe('0px');
162
+ });
163
+
164
+ it('shows empty state when no agents', () => {
165
+ render(<Timeline agents={[]} />);
166
+ const container = screen.getByTestId(TESTIDS.timeline);
167
+ expect(container.textContent).toContain('no agents');
168
+ });
169
+
170
+ it('each bar is a labeled element for accessibility', () => {
171
+ render(<Timeline agents={fixtureAgents} />);
172
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
173
+ bars.forEach((bar, i) => {
174
+ const agent = fixtureAgents[i];
175
+ // Should have aria-label or be a semantic element with accessible name
176
+ const label = bar.getAttribute('aria-label') || bar.textContent || '';
177
+ expect(label).toContain(agent.id);
178
+ expect(label).toContain(agent.status);
179
+ });
180
+ });
181
+
182
+ it('handles all status colors: running, idle, SUSPICIOUS, HIGH, DRIFT, MED', () => {
183
+ const agentsWithStatuses: Agent[] = [
184
+ { ...fixtureAgents[0], status: 'running' },
185
+ { ...fixtureAgents[0], id: 'idle-agent', status: 'idle' },
186
+ { ...fixtureAgents[0], id: 'sus-agent', status: 'SUSPICIOUS' },
187
+ { ...fixtureAgents[0], id: 'high-agent', status: 'HIGH' },
188
+ { ...fixtureAgents[0], id: 'drift-agent', status: 'DRIFT' },
189
+ { ...fixtureAgents[0], id: 'med-agent', status: 'MED' },
190
+ ];
191
+ render(<Timeline agents={agentsWithStatuses} />);
192
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
193
+ expect(bars.length).toBe(6);
194
+ });
195
+
196
+ it('respects prefers-reduced-motion for animations', () => {
197
+ // Mock matchMedia to return reduced motion preference
198
+ const mockMatchMedia = (query: string) => ({
199
+ matches: query === '(prefers-reduced-motion: reduce)',
200
+ media: query,
201
+ onchange: null,
202
+ addListener: () => {},
203
+ removeListener: () => {},
204
+ addEventListener: () => {},
205
+ removeEventListener: () => {},
206
+ dispatchEvent: () => true,
207
+ });
208
+ Object.defineProperty(window, 'matchMedia', { value: mockMatchMedia, writable: true });
209
+
210
+ render(<Timeline agents={fixtureAgents} />);
211
+ const bars = screen.getAllByTestId(TESTIDS.timelineBar);
212
+ expect(bars.length).toBeGreaterThan(0);
213
+ // Should render without issues even with reduced motion preference
214
+ });
215
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Timeline — Horizontal per-agent bars from startedAt/lastActivity/runtimeSeconds.
3
+ * Bars are status-colored via theme tokens. Includes sensible clamping for edge cases.
4
+ */
5
+
6
+ import { formatAge } from '../lib/format';
7
+ import { TESTIDS } from '../test/fixtures';
8
+ import type { Agent } from '../lib/types';
9
+ import styles from './Timeline.module.css';
10
+
11
+ interface Props {
12
+ agents: Agent[];
13
+ }
14
+
15
+ // Status to CSS class mapping
16
+ function getStatusClass(status: string): string {
17
+ if (status === 'running') return 'status-ok';
18
+ if (status === 'idle') return 'status-info';
19
+ if (status === 'SUSPICIOUS' || status === 'HIGH') return 'status-error';
20
+ if (status === 'DRIFT' || status === 'MED') return 'status-warn';
21
+ return 'status-neutral';
22
+ }
23
+
24
+ // Compute bar width percentage based on time span
25
+ function computeBarWidth(startedAt: string | null, lastActivity: string | null, runtimeSeconds: number): number {
26
+ // If we have both timestamps, use them to compute the span
27
+ if (startedAt && lastActivity) {
28
+ try {
29
+ const start = new Date(startedAt).getTime();
30
+ const end = new Date(lastActivity).getTime();
31
+ const span = (end - start) / 1000; // convert to seconds
32
+
33
+ if (span > 0) {
34
+ // Progress from start to end relative to runtime
35
+ const progress = Math.min(span / Math.max(runtimeSeconds, 1), 1);
36
+ return Math.max(5, Math.min(100, progress * 100)); // Clamp between 5% and 100%
37
+ }
38
+ } catch (err) {
39
+ // Fall through to runtime-based calculation
40
+ }
41
+ }
42
+
43
+ // Fallback: use runtimeSeconds to estimate visibility
44
+ if (runtimeSeconds > 0) {
45
+ // Clamp very large values
46
+ const clamped = Math.min(runtimeSeconds, 86400); // Max 1 day
47
+ return Math.max(5, Math.min(100, (clamped / 3600) * 10)); // Normalize to 5-100%
48
+ }
49
+
50
+ return 10; // Default minimum width
51
+ }
52
+
53
+ export default function Timeline({ agents }: Props) {
54
+ if (agents.length === 0) {
55
+ return (
56
+ <div data-testid={TESTIDS.timeline} className={styles.container}>
57
+ <h3 className={styles.title}>Agent Timeline</h3>
58
+ <div className={styles.emptyState}>(no agents)</div>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ return (
64
+ <div data-testid={TESTIDS.timeline} className={styles.container}>
65
+ <h3 className={styles.title}>Agent Timeline</h3>
66
+ <div className={styles.timelineContent}>
67
+ {agents.map((agent) => {
68
+ const barWidth = computeBarWidth(agent.startedAt, agent.lastActivity, agent.runtimeSeconds ?? 0);
69
+ const statusClass = getStatusClass(agent.status);
70
+ const duration = formatAge(agent.runtimeSeconds ?? 0);
71
+
72
+ return (
73
+ <div key={agent.id} className={styles.row}>
74
+ <div className={styles.rowLabel}>
75
+ <span className={styles.agentId}>{agent.id}</span>
76
+ <span className={styles.agentStatus}>{agent.status}</span>
77
+ </div>
78
+ <div className={styles.barContainer}>
79
+ <div
80
+ data-testid={TESTIDS.timelineBar}
81
+ className={`${styles.bar} ${styles[statusClass]}`}
82
+ style={{ width: `${barWidth}%` }}
83
+ aria-label={`Agent ${agent.id} (${agent.status}, ${duration})`}
84
+ role="presentation"
85
+ />
86
+ </div>
87
+ <div className={styles.rowDuration}>{duration}</div>
88
+ </div>
89
+ );
90
+ })}
91
+ </div>
92
+ <div className={styles.footer}>
93
+ <span className={styles.legend}>
94
+ Green = running · Blue = idle · Orange = warn/drift · Red = error/suspicious
95
+ </span>
96
+ </div>
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { TrackerBoard } from './TrackerBoard';
4
+ import { fixtureTrackerItems } from '../test/fixtures';
5
+ import { TESTIDS } from '../test/fixtures';
6
+
7
+ describe('TrackerBoard', () => {
8
+ it('renders all 4 lanes', () => {
9
+ render(<TrackerBoard items={fixtureTrackerItems} />);
10
+
11
+ // Use role queries to be specific about lane headers
12
+ // Lane headers have format: "LaneName <span>Count</span>"
13
+ // So we need to check if the text starts with the lane name
14
+ const headers = screen.getAllByRole('heading', { level: 2 });
15
+ const laneNames = headers.map((h) => {
16
+ // Get the text before the count span
17
+ const firstNode = h.firstChild;
18
+ if (firstNode && firstNode.nodeType === Node.TEXT_NODE) {
19
+ return firstNode.textContent?.trim() || '';
20
+ }
21
+ return h.textContent || '';
22
+ });
23
+
24
+ expect(laneNames.some((name) => name.includes('Proposed'))).toBe(true);
25
+ expect(laneNames.some((name) => name.includes('Ranked'))).toBe(true);
26
+ expect(laneNames.some((name) => name.includes('In Progress'))).toBe(true);
27
+ });
28
+
29
+ it('buckets items correctly by lane', () => {
30
+ render(<TrackerBoard items={fixtureTrackerItems} />);
31
+
32
+ // fixtureTrackerItems[0] has lane: 'in-progress'
33
+ // fixtureTrackerItems[1] has lane: 'done'
34
+ // fixtureTrackerItems[2] has lane: 'ranked'
35
+ // fixtureTrackerItems[3] has lane: 'proposed'
36
+ // fixtureTrackerItems[4] has lane: 'done' (archived status)
37
+
38
+ expect(screen.getByText('Dashboard rewrite: foundation scaffold')).toBeInTheDocument();
39
+ expect(screen.getByText('Cost collector parses OUTCOMES-LEDGER.md')).toBeInTheDocument();
40
+ expect(screen.getByText('Agent timeline read-only v1')).toBeInTheDocument();
41
+ expect(screen.getByText('Replay slider for agent timeline')).toBeInTheDocument();
42
+ });
43
+
44
+ it('displays lane counts with accessible labels', () => {
45
+ render(<TrackerBoard items={fixtureTrackerItems} />);
46
+
47
+ // Should have accessible labels for counts
48
+ const laneCountSpans = screen.getAllByLabelText(/items in/);
49
+ expect(laneCountSpans.length).toBeGreaterThan(0);
50
+
51
+ laneCountSpans.forEach((span) => {
52
+ expect(span).toHaveAttribute('aria-label');
53
+ });
54
+ });
55
+
56
+ it('routes unknown lanes to proposed', () => {
57
+ const unknownLaneItem = {
58
+ ...fixtureTrackerItems[0],
59
+ lane: 'undefined-lane' as any,
60
+ };
61
+
62
+ render(<TrackerBoard items={[unknownLaneItem]} />);
63
+
64
+ // Should appear under Proposed
65
+ const proposedLane = screen.getByText('Proposed').closest('section');
66
+ expect(proposedLane).toBeInTheDocument();
67
+ expect(proposedLane?.textContent).toContain(unknownLaneItem.title);
68
+ });
69
+
70
+ it('separates archived items from active lanes', () => {
71
+ const archivedItem = fixtureTrackerItems[4]; // status: archived
72
+ expect(archivedItem.status).toBe('archived');
73
+
74
+ render(<TrackerBoard items={[archivedItem]} />);
75
+
76
+ // Should appear in archived section, not in any lane
77
+ expect(screen.getByText('Archived (1)')).toBeInTheDocument();
78
+ expect(screen.getByText(archivedItem.title)).toBeInTheDocument();
79
+ });
80
+
81
+ it('shows archived summary with item count', () => {
82
+ const archived1 = { ...fixtureTrackerItems[1], status: 'archived' as const };
83
+ const archived2 = { ...fixtureTrackerItems[4], status: 'archived' as const };
84
+
85
+ render(<TrackerBoard items={[archived1, archived2]} />);
86
+
87
+ expect(screen.getByText('Archived (2)')).toBeInTheDocument();
88
+ });
89
+
90
+ it('lanes with no items show "No items" placeholder', () => {
91
+ const singleItem = fixtureTrackerItems.slice(0, 1); // only 1 item in in-progress
92
+
93
+ render(<TrackerBoard items={singleItem} />);
94
+
95
+ const emptyMessages = screen.getAllByText('No items');
96
+ expect(emptyMessages.length).toBeGreaterThan(0); // At least 3 empty lanes
97
+ });
98
+
99
+ it('renders tracker cards in each lane', () => {
100
+ render(<TrackerBoard items={fixtureTrackerItems} />);
101
+
102
+ const cards = screen.getAllByTestId(TESTIDS.trackerCard);
103
+ // Should have 4 active cards (5 total minus 1 archived)
104
+ expect(cards.length).toBeGreaterThanOrEqual(4);
105
+ });
106
+
107
+ it('bucket test: unknown lane maps to proposed', () => {
108
+ const testItems = [
109
+ {
110
+ ...fixtureTrackerItems[0],
111
+ lane: 'some-random-lane' as any,
112
+ title: 'Unknown lane item',
113
+ },
114
+ ];
115
+
116
+ render(<TrackerBoard items={testItems} />);
117
+
118
+ const proposedSection = screen.getByText('Proposed').closest('section');
119
+ expect(proposedSection?.textContent).toContain('Unknown lane item');
120
+ });
121
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * TrackerBoard — kanban-style layout with 4 lanes + archived summary.
3
+ * Lane bucketing: proposed/ranked/in-progress/done.
4
+ * Unknown lanes -> proposed. Empty lanes compact.
5
+ * Lane counts with accessible labels and health badges.
6
+ */
7
+
8
+ import { TESTIDS } from '../test/fixtures';
9
+ import type { TrackerItem } from '../lib/types';
10
+ import { TrackerCard } from './TrackerCard';
11
+
12
+ interface TrackerBoardProps {
13
+ items: TrackerItem[];
14
+ onUpdate?: (item: TrackerItem) => void;
15
+ }
16
+
17
+ const LANE_ORDER = ['proposed', 'ranked', 'in-progress', 'done'] as const;
18
+ type Lane = (typeof LANE_ORDER)[number];
19
+
20
+ function getLaneLabel(lane: Lane): string {
21
+ return {
22
+ proposed: 'Proposed',
23
+ ranked: 'Ranked',
24
+ 'in-progress': 'In Progress',
25
+ done: 'Done',
26
+ }[lane];
27
+ }
28
+
29
+ function normalizeLane(lane: string | undefined): Lane {
30
+ const normalized = lane?.toLowerCase();
31
+ if (LANE_ORDER.includes(normalized as Lane)) {
32
+ return normalized as Lane;
33
+ }
34
+ return 'proposed'; // unknown lane -> proposed
35
+ }
36
+
37
+ export function TrackerBoard({ items, onUpdate }: TrackerBoardProps) {
38
+ // Separate active items from archived
39
+ const activeItems = items.filter((item) => item.status !== 'archived');
40
+ const archivedItems = items.filter((item) => item.status === 'archived');
41
+
42
+ // Bucket active items by lane
43
+ const laneMap = new Map<Lane, TrackerItem[]>();
44
+ LANE_ORDER.forEach((lane) => laneMap.set(lane, []));
45
+
46
+ activeItems.forEach((item) => {
47
+ const lane = normalizeLane(item.lane);
48
+ laneMap.get(lane)?.push(item);
49
+ });
50
+
51
+ // Render lanes
52
+ const renderedLanes = LANE_ORDER.map((lane) => {
53
+ const items = laneMap.get(lane) || [];
54
+
55
+ // Calculate health badge info (done/total where derivable)
56
+ let badgeText = `${items.length} items`;
57
+ if (lane === 'done') {
58
+ badgeText = `${items.length}/${items.length} done`;
59
+ } else if (items.length > 0) {
60
+ const doneCount = items.filter((item) => item.status === 'done').length;
61
+ if (doneCount > 0) {
62
+ badgeText = `${doneCount}/${items.length} done`;
63
+ }
64
+ }
65
+
66
+ return (
67
+ <section key={lane} className="tracker-lane" data-testid={TESTIDS.trackerLane}>
68
+ <h2 className="lane-header">
69
+ {getLaneLabel(lane)}
70
+ <span
71
+ className={`lane-badge ${items.length === 0 ? 'lane-badge--empty' : 'lane-badge--active'}`}
72
+ aria-label={`${badgeText} in ${getLaneLabel(lane)}`}
73
+ >
74
+ {badgeText}
75
+ </span>
76
+ </h2>
77
+ <div className="lane-content">
78
+ {items.length === 0 ? (
79
+ <p className="lane-empty">No items</p>
80
+ ) : (
81
+ items.map((item) => <TrackerCard key={item.id} item={item} onUpdate={onUpdate} />)
82
+ )}
83
+ </div>
84
+ </section>
85
+ );
86
+ });
87
+
88
+ return (
89
+ <div className="tracker-board" data-testid={TESTIDS.trackerBoard}>
90
+ <div className="lanes-container">{renderedLanes}</div>
91
+
92
+ {archivedItems.length > 0 && (
93
+ <section className="archived-summary">
94
+ <h3>Archived ({archivedItems.length})</h3>
95
+ <ul className="archived-list">
96
+ {archivedItems.map((item) => (
97
+ <li key={item.id} className="archived-item">
98
+ <span className="archived-title">{item.title}</span>
99
+ <span className="archived-priority">{item.priority}</span>
100
+ </li>
101
+ ))}
102
+ </ul>
103
+ </section>
104
+ )}
105
+ </div>
106
+ );
107
+ }