@matt82198/aesop 0.1.0-beta.2 → 0.1.0-beta.4

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 (108) hide show
  1. package/CHANGELOG.md +37 -94
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +89 -264
  4. package/aesop.config.example.json +27 -26
  5. package/bin/CLAUDE.md +39 -0
  6. package/bin/cli.js +368 -24
  7. package/daemons/CLAUDE.md +33 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +90 -10
  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/RELIABILITY.md +61 -0
  23. package/docs/RESTORE.md +397 -0
  24. package/docs/SCRIPTS-POLICY.md +97 -0
  25. package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
  26. package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
  27. package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
  28. package/docs/spikes/tiered-cognition/README.md +29 -0
  29. package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  30. package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  31. package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  32. package/hooks/CLAUDE.md +38 -0
  33. package/hooks/claude/force-model-policy.mjs +159 -0
  34. package/hooks/pre-push-policy.sh +753 -0
  35. package/monitor/.signal-state.json +3 -0
  36. package/monitor/ACTIONS.log +1 -0
  37. package/monitor/BRIEF.md +24 -0
  38. package/monitor/CHARTER.md +29 -8
  39. package/monitor/CLAUDE.md +40 -0
  40. package/monitor/SIGNALS.json +54 -0
  41. package/monitor/collect-signals.mjs +529 -62
  42. package/package.json +12 -3
  43. package/skills/CLAUDE.md +3 -0
  44. package/skills/power/SKILL.md +161 -0
  45. package/tools/CLAUDE.md +191 -0
  46. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  52. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  53. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  54. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  55. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  56. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  57. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  58. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  59. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  60. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  61. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  62. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  63. package/tools/agent-forensics.sh +186 -0
  64. package/tools/buildlog.py +182 -0
  65. package/tools/ci_merge_wait.py +204 -0
  66. package/tools/ensure_state.py +94 -0
  67. package/tools/eod_sweep.py +239 -0
  68. package/tools/fleet_ledger.py +272 -0
  69. package/tools/heartbeat.py +131 -0
  70. package/tools/inbox_drain.py +249 -0
  71. package/tools/launch_tui.py +51 -3
  72. package/tools/lock.mjs +211 -0
  73. package/tools/metrics_gate.py +205 -0
  74. package/tools/orchestrator_status.py +99 -0
  75. package/tools/power_selftest.py +386 -0
  76. package/tools/prepublish_scan.py +84 -0
  77. package/tools/proposals.mjs +248 -0
  78. package/tools/reconstitute.sh +467 -0
  79. package/tools/rotate_logs.py +228 -0
  80. package/tools/scanner_selftest.py +303 -0
  81. package/tools/secret_scan.py +131 -31
  82. package/tools/stall_check.py +172 -0
  83. package/tools/verify_dash.py +694 -0
  84. package/tools/verify_submit_encoding.py +194 -0
  85. package/ui/CLAUDE.md +109 -0
  86. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  87. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  88. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  89. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  90. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  91. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  92. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  93. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  94. package/ui/agents.py +179 -0
  95. package/ui/api/__init__.py +84 -0
  96. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  97. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  98. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  99. package/ui/api/submit.py +58 -0
  100. package/ui/api/tracker.py +116 -0
  101. package/ui/collectors.py +586 -0
  102. package/ui/config.py +114 -0
  103. package/ui/csrf.py +140 -0
  104. package/ui/handler.py +426 -0
  105. package/ui/render.py +29 -0
  106. package/ui/serve.py +68 -796
  107. package/ui/sse.py +168 -0
  108. package/ui/templates/dashboard.html +1202 -0
@@ -0,0 +1,1202 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Aesop Fleet Dashboard</title>
7
+ <script>
8
+ // CSRF token injected by server (same-origin JS can read this)
9
+ window.__AESOP_CSRF_TOKEN__ = __AESOP_CSRF_SENTINEL__;
10
+ </script>
11
+ <style>
12
+ * { margin: 0; padding: 0; box-sizing: border-box; }
13
+ html { color-scheme: dark; }
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Monospace;
16
+ background: #0a0a0a;
17
+ color: #e0e0e0;
18
+ padding: 16px;
19
+ line-height: 1.5;
20
+ }
21
+ .container { max-width: 1600px; margin: 0 auto; }
22
+ h1 { font-size: 20px; margin-bottom: 20px; color: #fff; }
23
+ h2 { font-size: 14px; margin-top: 20px; margin-bottom: 10px; color: #8ac; font-weight: bold; }
24
+
25
+ .header { display: flex; flex-wrap: wrap; gap: 20px; margin-bottom: 20px; padding: 12px; background: #1a1a1a; border-radius: 4px; }
26
+ .header-item { flex: 1 1 140px; min-width: 120px; }
27
+ .header-label { font-size: 11px; color: #999; text-transform: uppercase; margin-bottom: 4px; }
28
+ .header-value { font-size: 14px; color: #fff; font-weight: bold; }
29
+ .status-alive { color: #0a0; }
30
+ .status-stale { color: #f44; }
31
+ .status-unknown { color: #999; }
32
+ .status-not-running { color: #f80; }
33
+
34
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
35
+ @media (max-width: 1200px) { .grid { grid-template-columns: 1fr; } }
36
+ /* Responsive header: items pack 2-per-row on narrow viewports instead of overflowing */
37
+ @media (max-width: 900px) { .header { gap: 12px; } .header-item { flex-basis: calc(50% - 12px); } }
38
+
39
+ .panel { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; }
40
+ .panel-title { font-size: 12px; color: #8ac; font-weight: bold; text-transform: uppercase; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
41
+ .panel-title-emoji { font-size: 14px; }
42
+
43
+ /* Agent row expand/collapse */
44
+ .agent-row { cursor: pointer; padding: 8px; margin-bottom: 4px; background: #0f0f0f; border: 1px solid #2a2a2a; border-radius: 3px; transition: all 0.2s ease; display: flex; align-items: center; gap: 8px; }
45
+ .agent-row:hover { background: #151515; border-color: #444; }
46
+ .agent-row:focus { outline: 2px solid #8ac; outline-offset: 0px; }
47
+ .agent-row.expanded { background: #1a1a1a; border-color: #8ac; }
48
+ .agent-status-icon { font-size: 12px; width: 14px; }
49
+ .agent-row-header { flex: 1; font-size: 12px; display: flex; gap: 12px; align-items: center; }
50
+ .agent-id-badge { color: #8ac; font-weight: bold; }
51
+ .agent-age { color: #666; font-size: 11px; }
52
+ .agent-preview { color: #999; font-size: 11px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
53
+ .agent-expand-toggle { color: #8ac; font-size: 14px; font-weight: bold; transition: transform 0.2s ease, color 0.2s ease; }
54
+ .agent-row:hover .agent-expand-toggle { color: #fff; }
55
+ .agent-row.expanded .agent-expand-toggle { transform: rotate(90deg); }
56
+
57
+ .agent-details { display: none; margin-top: 8px; padding: 12px; background: #0f0f0f; border-left: 3px solid #8ac; border-radius: 2px; max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
58
+ .agent-row.expanded .agent-details { display: block; max-height: 600px; }
59
+ .agent-detail-row { margin-bottom: 8px; font-size: 11px; }
60
+ .agent-detail-label { color: #8ac; font-weight: bold; display: inline-block; width: 100px; }
61
+ .agent-detail-value { color: #ccc; word-break: break-all; }
62
+ .dispatch-prompt { background: #0a0a0a; border: 1px solid #333; border-radius: 2px; padding: 8px; margin-top: 6px; max-height: 300px; overflow-y: auto; font-size: 11px; color: #ccc; font-family: 'Monaco', 'Menlo', monospace; white-space: pre-wrap; word-wrap: break-word; }
63
+
64
+ .item { padding: 8px 0; font-size: 12px; border-bottom: 1px solid #2a2a2a; }
65
+ .item:last-child { border-bottom: none; }
66
+ .item-id { color: #8ac; font-weight: bold; }
67
+ .item-age { color: #999; margin: 0 8px; }
68
+ .item-status { padding: 2px 6px; border-radius: 2px; font-size: 10px; font-weight: bold; }
69
+ .status-running { background: #88f; color: #000; }
70
+ .status-done { background: #0a0; color: #000; }
71
+
72
+ .inbox-box { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; margin-bottom: 20px; }
73
+ .inbox-label { font-size: 11px; color: #999; text-transform: uppercase; margin-bottom: 8px; }
74
+ .inbox-input { width: 100%; padding: 8px; background: #0a0a0a; border: 1px solid #333; color: #e0e0e0; border-radius: 2px; font-size: 12px; font-family: inherit; }
75
+ .inbox-input:focus { outline: none; border-color: #8ac; }
76
+ .inbox-button { background: #8ac; color: #000; border: none; padding: 8px 16px; border-radius: 2px; margin-top: 8px; cursor: pointer; font-weight: bold; font-size: 12px; }
77
+ .inbox-button:hover { background: #9bd; }
78
+ .inbox-button:disabled { background: #555; cursor: not-allowed; }
79
+ .inbox-status { font-size: 11px; color: #0a0; margin-top: 4px; display: none; }
80
+
81
+ /* Alert box styling: distinct alarm treatment when alerts exist */
82
+ .alerts-box { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; }
83
+ .alerts-box.has-alerts { border-color: #f44; border-width: 2px; background: #1a0a0a; }
84
+ .alerts-box.has-high-alerts { border-color: #f44; border-width: 2px; background: #1a0808; }
85
+ .alerts-box.has-med-alerts { border-color: #f80; border-width: 2px; background: #1a1008; }
86
+ .alert-line { font-size: 11px; padding: 4px 0; color: #f44; font-family: monospace; }
87
+ .alert-line.severity-high { color: #f44; font-weight: bold; }
88
+ .alert-line.severity-med { color: #f80; font-weight: bold; }
89
+ .alert-none { color: #666; }
90
+
91
+ /* Alert count in header: scales with severity */
92
+ #alert-count { color: #999; }
93
+ #alert-count.alarm-high { color: #f44; font-weight: bold; }
94
+ #alert-count.alarm-med { color: #f80; font-weight: bold; }
95
+
96
+ .messages-box { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; max-height: 400px; overflow-y: auto; }
97
+ .message { padding: 8px 0; border-bottom: 1px solid #2a2a2a; font-size: 11px; }
98
+ .message:last-child { border-bottom: none; }
99
+ .message-role { color: #8ac; font-weight: bold; }
100
+ .message-time { color: #666; font-size: 10px; margin-left: 8px; }
101
+ .message-text { color: #ccc; margin-top: 4px; }
102
+
103
+ .backlog-tier { margin-bottom: 16px; padding: 12px; background: #0f0f0f; border: 1px solid #2a2a2a; border-radius: 3px; }
104
+ .backlog-tier-header { font-size: 12px; font-weight: bold; color: #8ac; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; }
105
+ .backlog-tier-name { font-size: 13px; }
106
+ .backlog-progress-container { width: 100%; background: #0a0a0a; border: 1px solid #333; border-radius: 2px; height: 20px; overflow: hidden; margin-bottom: 6px; }
107
+ .backlog-progress-bar { height: 100%; display: flex; background: #0a0a0a; }
108
+ .backlog-progress-done { background: #0a0; }
109
+ .backlog-progress-inflight { background: #88f; }
110
+ .backlog-progress-empty { background: #333; flex: 1; }
111
+ .backlog-stats { font-size: 11px; color: #999; }
112
+ .backlog-items { font-size: 11px; margin-top: 8px; max-height: 200px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #555 #0a0a0a; }
113
+ /* Truncation cue: a scrollable (overflowing) tier gets a visible scrollbar + bottom fade so "more below" is obvious */
114
+ .backlog-items::-webkit-scrollbar { width: 8px; }
115
+ .backlog-items::-webkit-scrollbar-thumb { background: #555; border-radius: 4px; }
116
+ .backlog-items::-webkit-scrollbar-track { background: #0a0a0a; }
117
+ .backlog-items.has-overflow { -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), transparent); }
118
+ .backlog-item { padding: 4px 0; color: #ccc; display: flex; gap: 8px; align-items: flex-start; }
119
+ .backlog-item-glyph { min-width: 14px; font-size: 12px; }
120
+ .backlog-item-tag { color: #8ac; font-weight: bold; min-width: 60px; }
121
+ .backlog-item-title { color: #bbbbbb; flex: 1; word-break: break-word; }
122
+ .backlog-item.done { padding: 2px 0; opacity: 0.55; }
123
+ .backlog-item.done .backlog-item-title { opacity: 0.55; font-size: 10px; }
124
+
125
+ .loading { color: #666; font-style: italic; }
126
+ .error { color: #f44; }
127
+ .fade-in { animation: fadeIn 0.3s ease-in; }
128
+ @keyframes fadeIn { from { opacity: 0.5; } to { opacity: 1; } }
129
+
130
+ /* Prefer reduced motion: disable animations */
131
+ @media (prefers-reduced-motion: reduce) {
132
+ .fade-in { animation: none; }
133
+ .audit-marquee { animation: none; }
134
+ }
135
+
136
+ /* Accessible text hidden from view but available to screen readers */
137
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
138
+
139
+ /* Orchestrator status banner */
140
+ #orchestrator-banner { margin-bottom: 16px; padding: 12px; background: #1a1a1a; border: 1px solid #333; border-radius: 4px; }
141
+ .status-none { color: #999; }
142
+ .status-activity { color: #8ac; font-weight: bold; }
143
+ .status-age { color: #666; font-size: 11px; margin-left: 8px; }
144
+ .status-stale-warn { color: #f80; font-weight: bold; }
145
+ .audit-banner { background: #1a1408; border: 1px solid #f80; border-radius: 4px; padding: 12px; margin: 12px 0; }
146
+ .audit-banner-title { color: #f80; font-weight: bold; font-size: 12px; margin-bottom: 6px; }
147
+ .audit-ascii { font-family: 'Monaco', 'Menlo', monospace; font-size: 11px; color: #8ac; line-height: 1.3; white-space: pre; margin: 6px 0; }
148
+ .audit-marquee { display: inline-block; animation: marqueeAnim 1.2s steps(3) infinite; }
149
+ @keyframes marqueeAnim { 0% { opacity: 0.5; } 50% { opacity: 1; } 100% { opacity: 0.5; } }
150
+
151
+ /* Tracker lanes */
152
+ .tracker-lanes { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-bottom: 20px; }
153
+ @media (max-width: 900px) { .tracker-lanes { grid-template-columns: 1fr; } }
154
+ .tracker-lane { background: #0f0f0f; border: 1px solid #2a2a2a; border-radius: 4px; padding: 12px; }
155
+ .lane-header { font-size: 12px; font-weight: bold; color: #8ac; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
156
+ .lane-count { font-size: 11px; color: #999; }
157
+ .lane-items { display: flex; flex-direction: column; gap: 8px; min-height: 60px; justify-content: center; }
158
+ .lane-items.empty { background: #0a0a0a; border-radius: 2px; padding: 12px 8px; }
159
+ .lane-empty-placeholder { font-size: 11px; color: #555; text-align: center; font-style: italic; }
160
+ .tracker-item { background: #1a1a1a; border: 1px solid #333; border-radius: 3px; padding: 10px; cursor: pointer; transition: all 0.2s ease; }
161
+ .tracker-item:hover { border-color: #8ac; background: #151515; }
162
+ .tracker-item:focus { outline: 2px solid #8ac; outline-offset: 0px; }
163
+ .tracker-item-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 6px; }
164
+ .tracker-item-title { flex: 1; font-size: 12px; font-weight: bold; color: #e0e0e0; word-break: break-word; }
165
+ .priority-chip { display: inline-block; padding: 2px 6px; border-radius: 2px; font-size: 10px; font-weight: bold; text-transform: uppercase; }
166
+ .priority-p0 { background: #c00; color: #fff; }
167
+ .priority-p1 { background: #f80; color: #000; }
168
+ .priority-p2 { background: #88f; color: #000; }
169
+ .priority-p3 { background: #333; color: #ddd; }
170
+ .tracker-item-tags { font-size: 10px; color: #8ac; margin-bottom: 6px; }
171
+ .tag { display: inline-block; margin-right: 4px; padding: 1px 3px; background: #0a0a0a; border: 1px solid #8ac; border-radius: 2px; }
172
+ .tracker-item-details { display: none; margin-top: 8px; padding-top: 8px; border-top: 1px solid #2a2a2a; font-size: 11px; color: #ccc; }
173
+ .tracker-item.expanded .tracker-item-details { display: block; }
174
+ .detail-row { margin-bottom: 4px; }
175
+ .detail-label { color: #8ac; font-weight: bold; }
176
+ .detail-value { color: #999; }
177
+ .tracker-item-actions { display: flex; gap: 4px; margin-top: 8px; }
178
+ .tracker-action-btn { flex: 1; padding: 4px 8px; background: #2a2a2a; border: 1px solid #444; color: #8ac; border-radius: 2px; cursor: pointer; font-size: 10px; font-weight: bold; transition: all 0.2s ease; }
179
+ .tracker-action-btn:hover { background: #333; border-color: #8ac; }
180
+ .tracker-action-btn.primary { background: #8ac; color: #000; border-color: #8ac; }
181
+ .tracker-action-btn.primary:hover { background: #9bd; }
182
+ .tracker-action-btn.danger { background: #f44; color: #fff; border-color: #f44; }
183
+ .tracker-action-btn.danger:hover { background: #f55; }
184
+
185
+ /* Tracker add-item form */
186
+ .tracker-form { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; margin-bottom: 20px; }
187
+ .form-group { margin-bottom: 8px; }
188
+ .form-label { font-size: 11px; color: #8ac; font-weight: bold; text-transform: uppercase; margin-bottom: 4px; display: block; }
189
+ .form-input { width: 100%; padding: 6px; background: #0a0a0a; border: 1px solid #333; color: #e0e0e0; border-radius: 2px; font-size: 12px; font-family: inherit; box-sizing: border-box; }
190
+ .form-input:focus { outline: none; border-color: #8ac; }
191
+ /* Priority select dropdown chevron affordance */
192
+ #tracker-priority { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M2 4l4 4 4-4z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; background-size: 12px; padding-right: 24px; }
193
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
194
+ .form-submit { padding: 8px 16px; background: #8ac; color: #000; border: none; border-radius: 2px; font-weight: bold; font-size: 11px; cursor: pointer; transition: all 0.2s ease; }
195
+ .form-submit:hover { background: #9bd; }
196
+ .form-submit:disabled { background: #555; cursor: not-allowed; }
197
+ .tracker-archived-summary { font-size: 11px; color: #666; padding: 8px; background: #0f0f0f; border: 1px solid #2a2a2a; border-radius: 3px; }
198
+ </style>
199
+ </head>
200
+ <body>
201
+ <div class="container">
202
+ <h1>Aesop Fleet Dashboard</h1>
203
+
204
+ <div id="orchestrator-banner" style="display: none;">
205
+ <div class="header-label">Orchestrator Status</div>
206
+ <div id="orchestrator-status" class="status-none" role="status" aria-live="polite">—</div>
207
+ </div>
208
+ <div id="audit-banner" class="audit-banner" style="display: none;">
209
+ <div class="sr-only" id="audit-status-label" role="status" aria-live="assertive" aria-atomic="true">Audit cycle running</div>
210
+ <div class="audit-banner-title">AUDIT CYCLE RUNNING</div>
211
+ <pre class="audit-ascii"> .-.--. ___
212
+ ( o___ )----- (o,o)
213
+ `-.__.' \_/ <span class="audit-marquee">░▒▓</span> scanning...</pre>
214
+ </div>
215
+
216
+ <div class="header" id="header">
217
+ <div class="header-item">
218
+ <div class="header-label">Watchdog Status</div>
219
+ <div class="header-value" id="watchdog-status">
220
+ <span id="watchdog-alive" class="status-unknown" role="status" aria-live="polite">—</span>
221
+ <span id="watchdog-age" style="color: #999; margin-left: 8px;">—</span>
222
+ </div>
223
+ </div>
224
+ <div class="header-item">
225
+ <div class="header-label">Monitor Status</div>
226
+ <div class="header-value" id="monitor-status">
227
+ <span id="monitor-alive" class="status-unknown" role="status" aria-live="polite">—</span>
228
+ <span id="monitor-age" style="color: #999; margin-left: 8px;">—</span>
229
+ </div>
230
+ </div>
231
+ <div class="header-item">
232
+ <div class="header-label">Security Alerts</div>
233
+ <div class="header-value" id="alert-count" style="color: #999;" role="status" aria-live="assertive">—</div>
234
+ </div>
235
+ <div class="header-item">
236
+ <div class="header-label">Running Agents</div>
237
+ <div class="header-value" id="running-count" style="color: #999; font-size: 16px; font-weight: 600;" role="status" aria-live="polite">—</div>
238
+ </div>
239
+ <div class="header-item" style="flex: 0 0 auto; text-align: right;">
240
+ <div class="header-label">Live</div>
241
+ <div class="header-value">
242
+ <span id="conn-live" style="color: #0a0;">● live</span>
243
+ <span id="conn-reconnecting" style="color: #f80; display: none;">◌ reconnecting…</span>
244
+ <span id="connection-degraded" style="color: #f44; display: none; margin-left: 8px;">⚠ frame error</span>
245
+ </div>
246
+ </div>
247
+ </div>
248
+
249
+ <div class="grid">
250
+ <div class="panel">
251
+ <div class="panel-title">
252
+ <span class="panel-title-emoji">⚡</span>
253
+ <span>Fleet Agents (<span id="running-agents-count" role="status" aria-live="polite">0</span> active)</span>
254
+ </div>
255
+ <div id="agents-list" class="loading" role="status" aria-live="polite" aria-atomic="false">—</div>
256
+ </div>
257
+
258
+ <div class="alerts-box">
259
+ <div class="panel-title">Security Alerts (Unreviewed)</div>
260
+ <div id="alerts-list" class="alert-none" role="status" aria-live="assertive" aria-atomic="false">—</div>
261
+ </div>
262
+ </div>
263
+
264
+ <div class="inbox-box">
265
+ <div class="inbox-label">Queue Work (Read by Orchestrator Each Turn)</div>
266
+ <input type="text" class="inbox-input" id="inbox-input" placeholder="Type your task here...">
267
+ <button class="inbox-button" id="inbox-button">Send to Inbox</button>
268
+ <div class="inbox-status" id="inbox-status">Queued ✓</div>
269
+ </div>
270
+
271
+ <div class="tracker-form">
272
+ <div class="form-label">Add Work Item</div>
273
+ <div class="form-group">
274
+ <input type="text" class="form-input" id="tracker-title" placeholder="Title" maxlength="200">
275
+ </div>
276
+ <div class="form-row">
277
+ <div class="form-group">
278
+ <select class="form-input" id="tracker-priority" style="appearance: none; padding-right: 24px;">
279
+ <option value="P0">P0 — Critical</option>
280
+ <option value="P1">P1 — High</option>
281
+ <option value="P2" selected>P2 — Medium</option>
282
+ <option value="P3">P3 — Low</option>
283
+ </select>
284
+ </div>
285
+ <div class="form-group">
286
+ <button class="form-submit" id="tracker-add-btn">+ Add Item</button>
287
+ </div>
288
+ </div>
289
+ <div class="form-group">
290
+ <input type="text" class="form-input" id="tracker-notes" placeholder="Notes (optional)" maxlength="500">
291
+ </div>
292
+ </div>
293
+
294
+ <div id="tracker-container" class="loading" style="margin-bottom: 20px;">
295
+ <div class="sr-only" id="tracker-live-region" role="status" aria-live="polite" aria-atomic="false"></div>
296
+ <div class="tracker-lanes" id="tracker-lanes">—</div>
297
+ </div>
298
+
299
+ <div class="panel" style="margin-bottom: 20px;">
300
+ <div class="panel-title">
301
+ <span class="panel-title-emoji">📋</span>
302
+ <span>Audit Backlog — Clearing Progress</span>
303
+ </div>
304
+ <div id="backlog-tiers" class="loading">—</div>
305
+ </div>
306
+
307
+ <div class="grid">
308
+ <div class="panel">
309
+ <div class="panel-title">Recent Events (Last 8)</div>
310
+ <div id="events-list" class="loading">—</div>
311
+ </div>
312
+
313
+ <div class="panel">
314
+ <div class="panel-title">Repos Status</div>
315
+ <div id="repos-list" class="loading">—</div>
316
+ </div>
317
+ </div>
318
+
319
+ <div class="panel">
320
+ <div class="panel-title">Main-Thread Prompts (Last ~12 Messages)</div>
321
+ <div class="messages-box" id="messages-list" class="loading">—</div>
322
+ </div>
323
+
324
+ <div style="text-align: center; margin-top: 30px; color: #666; font-size: 11px;">
325
+ Realtime via SSE (push on change) · Click agent rows to inspect dispatches ·
326
+ <button id="manual-refresh-button" style="background: none; border: 1px solid #444; color: #999; border-radius: 2px; padding: 2px 8px; cursor: pointer; font-size: 11px;">⟳ Refresh now</button>
327
+ </div>
328
+ </div>
329
+
330
+ <script>
331
+ // ------------------------------------------------------------------
332
+ // Realtime model: one EventSource('/events'), keyed in-place DOM
333
+ // patching per section. No interval polling, no full-page rebuild —
334
+ // clicks, expansion state, scroll position, and text selection all
335
+ // survive indefinitely because existing DOM nodes are mutated in
336
+ // place rather than replaced.
337
+ // ------------------------------------------------------------------
338
+
339
+ // Client-side cache of the last snapshot per section, used by the
340
+ // click handler (synchronous lookup — no network round-trip on click)
341
+ // and by the manual refresh fallback.
342
+ let latestAgents = [];
343
+ let latestBacklog = { tiers: [] };
344
+ let latestData = {};
345
+
346
+ function formatTimestamp(iso) {
347
+ if (!iso) return '';
348
+ const d = new Date(iso);
349
+ return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
350
+ }
351
+
352
+ function sanitize(text) {
353
+ const div = document.createElement('div');
354
+ div.textContent = text;
355
+ return div.innerHTML;
356
+ }
357
+
358
+ // URL-scheme allowlist: only http:/https: URLs may ever be placed into an
359
+ // href attribute. Anything else (javascript:, data:, vbscript:, etc.) is
360
+ // neutralized to an empty string so the resulting link is inert. This is
361
+ // defense-in-depth alongside the HTML-escaping done by sanitize() — escaping
362
+ // alone does not stop a javascript: or data: URI from executing on click.
363
+ function sanitizeURL(url) {
364
+ if (!url) return '';
365
+ try {
366
+ const parsed = new URL(url, location.href);
367
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
368
+ return url;
369
+ }
370
+ } catch (e) {
371
+ // Unparseable URL: treat as unsafe.
372
+ }
373
+ return '';
374
+ }
375
+
376
+ // CSS.escape wrapper (with a conservative fallback) so item/tier keys
377
+ // containing brackets, quotes, etc. can't break attribute selectors.
378
+ function cssEscape(s) {
379
+ if (window.CSS && typeof CSS.escape === 'function') return CSS.escape(String(s));
380
+ return String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\\\' + c);
381
+ }
382
+
383
+ // Test hook: expose cache size for verify_dash.py
384
+ window.__getPromptCacheSize = function() { return promptCache.size; };
385
+
386
+ function setConnectionStatus(connected) {
387
+ document.getElementById('conn-live').style.display = connected ? '' : 'none';
388
+ document.getElementById('conn-reconnecting').style.display = connected ? 'none' : '';
389
+ }
390
+
391
+ function setConnectionDegraded(degraded) {
392
+ document.getElementById('connection-degraded').style.display = degraded ? '' : 'none';
393
+ }
394
+
395
+ // ---- header (watchdog / monitor / alert count) --------------------
396
+ function getAlertSeverity(alerts) {
397
+ // Scan alert lines for severity keywords (HIGH takes precedence over MED)
398
+ if (!alerts || !alerts.lines || alerts.lines.length === 0) return 'none';
399
+ const text = alerts.lines.join(' ').toUpperCase();
400
+ if (text.includes('HIGH')) return 'high';
401
+ if (text.includes('MED') || text.includes('MEDIUM')) return 'med';
402
+ return 'low';
403
+ }
404
+
405
+ function normalizeStatusClass(status) {
406
+ if (!status) return 'unknown';
407
+ const s = status.toLowerCase().trim();
408
+ if (s.includes('alive') || s.includes('running')) return 'alive';
409
+ if (s.includes('stale')) return 'stale';
410
+ if (s.includes('not running')) return 'not-running';
411
+ return 'unknown';
412
+ }
413
+
414
+ function patchHeader(data) {
415
+ const watchdog = data.watchdog || {};
416
+ const watchdogAlive = document.getElementById('watchdog-alive');
417
+ watchdogAlive.textContent = watchdog.alive || '—';
418
+ watchdogAlive.className = 'status-' + normalizeStatusClass(watchdog.alive);
419
+ document.getElementById('watchdog-age').textContent = watchdog.age >= 0 ? watchdog.age + 's' : '—';
420
+
421
+ const monitor = data.monitor || {};
422
+ const monitorAlive = document.getElementById('monitor-alive');
423
+ monitorAlive.textContent = monitor.alive || '—';
424
+ monitorAlive.className = 'status-' + normalizeStatusClass(monitor.alive);
425
+ document.getElementById('monitor-age').textContent = monitor.age >= 0 ? monitor.age + 's' : '—';
426
+
427
+ // Alert count: color scales with severity
428
+ const alertCount = document.getElementById('alert-count');
429
+ const count = (data.alerts && data.alerts.count) || 0;
430
+ alertCount.textContent = count;
431
+ alertCount.className = ''; // Reset classes
432
+ if (count > 0) {
433
+ const severity = getAlertSeverity(data.alerts);
434
+ alertCount.classList.add('alarm-' + severity);
435
+ }
436
+ }
437
+
438
+ function patchHeaderRunningCount(agents) {
439
+ const running = (agents || []).filter(a => a.status === 'running').length;
440
+ document.getElementById('running-count').textContent = running;
441
+ document.getElementById('running-agents-count').textContent = (agents || []).length;
442
+ }
443
+
444
+ // ---- agents panel: keyed rows, click-to-expand survives updates --
445
+ function renderAgentDetails(row, agent) {
446
+ const detailsDiv = row.querySelector('.agent-details');
447
+ const now = Date.now();
448
+ const startTime = agent.startedAt ? new Date(agent.startedAt).getTime() : now;
449
+ const runtime = Math.floor((now - startTime) / 1000);
450
+ const runtimeStr = runtime < 60 ? runtime + 's' : Math.floor(runtime / 60) + 'm';
451
+
452
+ // BUGFIX: Field-level patching — only update nodes whose content changed.
453
+ // Do NOT clear detailsDiv.textContent; instead update existing fields or
454
+ // append new ones. This preserves scroll position and text selection in the
455
+ // prompt box.
456
+ const fields = [
457
+ ['Task:', agent.taskLabel || 'N/A'],
458
+ ['Status:', agent.status || 'unknown'],
459
+ ['Runtime:', runtimeStr],
460
+ ['Tokens:', String(agent.tokensUsed || 0)],
461
+ ];
462
+
463
+ // Ensure all detail rows exist and update values atomically
464
+ fields.forEach(([label, value]) => {
465
+ let rowEl = detailsDiv.querySelector(`[data-detail-label="${label}"]`);
466
+ if (!rowEl) {
467
+ rowEl = document.createElement('div');
468
+ rowEl.className = 'agent-detail-row';
469
+ rowEl.dataset.detailLabel = label;
470
+ const labelEl = document.createElement('span');
471
+ labelEl.className = 'agent-detail-label';
472
+ labelEl.textContent = label;
473
+ const valueEl = document.createElement('span');
474
+ valueEl.className = 'agent-detail-value';
475
+ valueEl.textContent = value;
476
+ rowEl.appendChild(labelEl);
477
+ rowEl.appendChild(document.createTextNode(' '));
478
+ rowEl.appendChild(valueEl);
479
+ detailsDiv.appendChild(rowEl);
480
+ } else {
481
+ // Update value only if it changed
482
+ const valueEl = rowEl.querySelector('.agent-detail-value');
483
+ if (valueEl && valueEl.textContent !== value) {
484
+ valueEl.textContent = value;
485
+ }
486
+ }
487
+ });
488
+
489
+ // Ensure prompt label row exists
490
+ let promptLabelRow = detailsDiv.querySelector('[data-detail-label="Prompt:"]');
491
+ if (!promptLabelRow) {
492
+ promptLabelRow = document.createElement('div');
493
+ promptLabelRow.className = 'agent-detail-row';
494
+ promptLabelRow.dataset.detailLabel = 'Prompt:';
495
+ const promptLabel = document.createElement('span');
496
+ promptLabel.className = 'agent-detail-label';
497
+ promptLabel.textContent = 'Prompt:';
498
+ promptLabelRow.appendChild(promptLabel);
499
+ detailsDiv.appendChild(promptLabelRow);
500
+ }
501
+
502
+ // Ensure prompt box exists and update only if content changed
503
+ let promptBox = detailsDiv.querySelector('.dispatch-prompt');
504
+ if (!promptBox) {
505
+ promptBox = document.createElement('div');
506
+ promptBox.className = 'dispatch-prompt';
507
+ detailsDiv.appendChild(promptBox);
508
+ }
509
+
510
+ // The list payload (dash-extra) has no prompt; the full dispatch prompt
511
+ // comes from GET /agent?id=. Fetch once per agent and cache — push
512
+ // updates re-render expanded rows, and must neither clobber a loaded
513
+ // prompt nor refetch on every SSE event.
514
+ const cached = promptCache.get(agent.id);
515
+ const promptText = (typeof cached === 'string') ? cached : 'Loading…';
516
+ if (promptBox.textContent !== promptText) {
517
+ promptBox.textContent = promptText;
518
+ }
519
+
520
+ if (!promptCache.has(agent.id)) fetchDispatchPrompt(agent.id);
521
+ }
522
+
523
+ const promptCache = new Map(); // agent id -> prompt string (null while in flight)
524
+ function fetchDispatchPrompt(agentId) {
525
+ promptCache.set(agentId, null);
526
+ fetch('/agent?id=' + encodeURIComponent(agentId))
527
+ .then(r => r.json())
528
+ .then(d => {
529
+ const text = d.dispatch_prompt || ('(' + (d.error || 'no prompt found') + ')');
530
+ promptCache.set(agentId, text);
531
+ const row = document.querySelector(`[data-agent-id="${cssEscape(agentId)}"]`);
532
+ if (row) {
533
+ const box = row.querySelector('.dispatch-prompt');
534
+ if (box) box.textContent = text; // textContent: XSS-safe
535
+ }
536
+ })
537
+ .catch(() => { promptCache.delete(agentId); });
538
+ }
539
+
540
+ function buildAgentRow(a) {
541
+ const row = document.createElement('div');
542
+ row.className = 'agent-row';
543
+ row.dataset.agentId = a.id;
544
+ row.tabIndex = 0;
545
+ row.role = 'button';
546
+ row.innerHTML = `
547
+ <span class="agent-status-icon"></span>
548
+ <div class="agent-row-header">
549
+ <span class="agent-id-badge"></span>
550
+ <span class="agent-age"></span>
551
+ <span class="agent-preview"></span>
552
+ </div>
553
+ <span class="agent-expand-toggle">▶</span>
554
+ <div class="agent-details"></div>
555
+ `;
556
+ return row;
557
+ }
558
+
559
+ function patchAgents(agents) {
560
+ latestAgents = agents || [];
561
+ const container = document.getElementById('agents-list');
562
+ container.classList.remove('loading');
563
+
564
+ if (latestAgents.length === 0) {
565
+ if (!container.querySelector('.empty-state')) {
566
+ container.innerHTML = '<div class="empty-state" style="color: #aaaaaa; font-size: 12px;">💤 No active agents — fleet is idle</div>';
567
+ }
568
+ return;
569
+ }
570
+ const emptyState = container.querySelector('.empty-state');
571
+ if (emptyState) emptyState.remove();
572
+
573
+ const newIds = new Set(latestAgents.map(a => a.id));
574
+ container.querySelectorAll('[data-agent-id]').forEach(row => {
575
+ if (!newIds.has(row.dataset.agentId)) {
576
+ // BUGFIX: Evict promptCache entries for agents no longer present
577
+ promptCache.delete(row.dataset.agentId);
578
+ row.remove();
579
+ }
580
+ });
581
+
582
+ latestAgents.forEach(a => {
583
+ const statusEmoji = a.status === 'running' ? '🟢' : (a.status === 'idle' ? '⚪' : '⚠️');
584
+ const preview = (a.hint || '').substring(0, 60);
585
+
586
+ let row = container.querySelector(`[data-agent-id="${cssEscape(a.id)}"]`);
587
+ if (!row) {
588
+ row = buildAgentRow(a);
589
+ container.appendChild(row);
590
+ }
591
+ row.querySelector('.agent-status-icon').textContent = statusEmoji;
592
+ row.querySelector('.agent-id-badge').textContent = a.id;
593
+ row.querySelector('.agent-age').textContent = a.age_s + 's';
594
+ row.querySelector('.agent-preview').textContent = preview;
595
+
596
+ // If this row is currently expanded, keep its live fields (runtime,
597
+ // tokens, age) fresh in place — the click-to-expand state itself is
598
+ // never touched here, so it survives every push update.
599
+ if (row.classList.contains('expanded')) {
600
+ renderAgentDetails(row, a);
601
+ }
602
+ });
603
+ }
604
+
605
+ // Event delegation on the stable container: attached once, works for
606
+ // every row (past, present, and future) without re-binding per row.
607
+ function toggleAgentExpand(row) {
608
+ row.classList.toggle('expanded');
609
+ if (row.classList.contains('expanded')) {
610
+ const agent = latestAgents.find(a => a.id === row.dataset.agentId);
611
+ if (agent) {
612
+ renderAgentDetails(row, agent);
613
+ } else {
614
+ row.querySelector('.agent-details').textContent = 'Agent details not found';
615
+ }
616
+ }
617
+ }
618
+
619
+ document.getElementById('agents-list').addEventListener('click', function (e) {
620
+ const row = e.target.closest('.agent-row');
621
+ if (!row || !this.contains(row)) return;
622
+ toggleAgentExpand(row);
623
+ });
624
+
625
+ document.getElementById('agents-list').addEventListener('keydown', function (e) {
626
+ if (e.key !== 'Enter' && e.key !== ' ') return;
627
+ const row = e.target.closest('.agent-row');
628
+ if (!row || !this.contains(row)) return;
629
+ e.preventDefault();
630
+ toggleAgentExpand(row);
631
+ });
632
+
633
+ // ---- repos / events / alerts / messages: presentational lists,
634
+ // no click/expand state to preserve, so a hash-gated (not interval-
635
+ // gated) full swap per section is safe. ----------------------------
636
+ function patchRepos(repos) {
637
+ const reposList = document.getElementById('repos-list');
638
+ reposList.classList.remove('loading');
639
+ if (repos && repos.length > 0) {
640
+ reposList.innerHTML = repos.map(r => {
641
+ const repo = r.repo || Object.keys(r)[0] || 'unknown';
642
+ const state = r.state || r[repo] || 'unknown';
643
+ return `<div class="item"><span class="item-id">${sanitize(repo.substring(0, 30))}</span> <span style="color: #999;">${sanitize(state)}</span></div>`;
644
+ }).join('');
645
+ } else {
646
+ reposList.textContent = '(no repos)';
647
+ reposList.style.color = '#aaaaaa';
648
+ }
649
+ }
650
+
651
+ function patchEvents(events) {
652
+ const eventsList = document.getElementById('events-list');
653
+ eventsList.classList.remove('loading');
654
+ if (events && events.length > 0) {
655
+ eventsList.innerHTML = events.map(e =>
656
+ `<div class="item"><span style="color: #999; font-size: 10px;">${sanitize(e.substring(0, 80))}</span></div>`
657
+ ).join('');
658
+ } else {
659
+ eventsList.textContent = '(no recent events)';
660
+ eventsList.style.color = '#aaaaaa';
661
+ }
662
+ }
663
+
664
+ function patchAlerts(alerts) {
665
+ const alertsList = document.getElementById('alerts-list');
666
+ const alertsBox = document.querySelector('.alerts-box');
667
+
668
+ // Defensive: ensure alerts-box exists before styling it
669
+ if (alertsBox) {
670
+ // Reset alerts-box styling
671
+ alertsBox.className = 'alerts-box';
672
+
673
+ if (alerts && alerts.lines && alerts.lines.length > 0) {
674
+ // Determine highest severity to style the container
675
+ const text = alerts.lines.join(' ').toUpperCase();
676
+ if (text.includes('HIGH')) {
677
+ alertsBox.classList.add('has-high-alerts');
678
+ } else if (text.includes('MED') || text.includes('MEDIUM')) {
679
+ alertsBox.classList.add('has-med-alerts');
680
+ } else {
681
+ alertsBox.classList.add('has-alerts');
682
+ }
683
+ }
684
+ }
685
+
686
+ if (alerts && alerts.lines && alerts.lines.length > 0) {
687
+ alertsList.innerHTML = alerts.lines.map(line => {
688
+ const upperLine = line.toUpperCase();
689
+ let severity = '';
690
+ if (upperLine.includes('HIGH')) {
691
+ severity = 'severity-high';
692
+ } else if (upperLine.includes('MED') || upperLine.includes('MEDIUM')) {
693
+ severity = 'severity-med';
694
+ }
695
+ return `<div class="alert-line ${severity}">${sanitize(line.substring(0, 120))}</div>`;
696
+ }).join('');
697
+ } else {
698
+ alertsList.innerHTML = '<div class="alert-none">(no alerts)</div>';
699
+ }
700
+ }
701
+
702
+ function patchMessages(messages) {
703
+ const messagesList = document.getElementById('messages-list');
704
+ messagesList.classList.remove('loading');
705
+ if (messages && messages.length > 0) {
706
+ messagesList.innerHTML = messages.map(m =>
707
+ `<div class="message"><span class="message-role">${sanitize(m.role)}</span><span class="message-time">${formatTimestamp(m.timestamp)}</span><div class="message-text">${sanitize(m.text)}</div></div>`
708
+ ).join('');
709
+ } else {
710
+ messagesList.textContent = '(no messages)';
711
+ messagesList.style.color = '#aaaaaa';
712
+ }
713
+ }
714
+
715
+ function patchDataSection(data) {
716
+ latestData = data || {};
717
+ patchHeader(latestData);
718
+ patchRepos(latestData.repos || []);
719
+ patchEvents(latestData.events || []);
720
+ patchAlerts(latestData.alerts || { count: 0, lines: [] });
721
+ patchMessages(latestData.messages || []);
722
+ }
723
+
724
+ // ---- audit backlog panel: tiers + items keyed, so live status
725
+ // changes (⬜ → 🔵 → ✅) update in place without losing scroll or
726
+ // flashing the whole panel on every unrelated tick. -----------------
727
+ function backlogItemKey(item) {
728
+ return (item.tag || '') + '||' + (item.title || '');
729
+ }
730
+
731
+ function buildBacklogTier(tier) {
732
+ const tierEl = document.createElement('div');
733
+ tierEl.className = 'backlog-tier';
734
+ tierEl.dataset.tier = tier.tier;
735
+ tierEl.innerHTML = `
736
+ <div class="backlog-tier-header"><span class="backlog-tier-name"></span></div>
737
+ <div class="backlog-progress-container">
738
+ <div class="backlog-progress-bar">
739
+ <div class="backlog-progress-done"></div>
740
+ <div class="backlog-progress-inflight"></div>
741
+ <div class="backlog-progress-empty"></div>
742
+ </div>
743
+ </div>
744
+ <div class="backlog-stats"></div>
745
+ <div class="backlog-items"></div>
746
+ `;
747
+ return tierEl;
748
+ }
749
+
750
+ function buildBacklogItem(key) {
751
+ const itemEl = document.createElement('div');
752
+ itemEl.className = 'backlog-item';
753
+ itemEl.dataset.itemKey = key;
754
+ itemEl.innerHTML = `
755
+ <span class="backlog-item-glyph"></span>
756
+ <span class="backlog-item-tag"></span>
757
+ <span class="backlog-item-title"></span>
758
+ `;
759
+ return itemEl;
760
+ }
761
+
762
+ function patchBacklog(backlogData) {
763
+ latestBacklog = backlogData || { tiers: [] };
764
+ const container = document.getElementById('backlog-tiers');
765
+ container.classList.remove('loading');
766
+ const tiers = latestBacklog.tiers || [];
767
+
768
+ if (tiers.length === 0) {
769
+ if (!container.querySelector('.empty-state')) {
770
+ container.innerHTML = '<div class="empty-state" style="color: #aaaaaa; font-size: 12px;">📋 No audit backlog found</div>';
771
+ }
772
+ return;
773
+ }
774
+ const emptyState = container.querySelector('.empty-state');
775
+ if (emptyState) emptyState.remove();
776
+
777
+ const newTierNames = new Set(tiers.map(t => t.tier));
778
+ container.querySelectorAll('[data-tier]').forEach(el => {
779
+ if (!newTierNames.has(el.dataset.tier)) el.remove();
780
+ });
781
+
782
+ tiers.forEach(tier => {
783
+ let tierEl = container.querySelector(`[data-tier="${cssEscape(tier.tier)}"]`);
784
+ if (!tierEl) {
785
+ tierEl = buildBacklogTier(tier);
786
+ container.appendChild(tierEl);
787
+ }
788
+ tierEl.querySelector('.backlog-tier-name').textContent = tier.tier;
789
+
790
+ const total = tier.total || 0;
791
+ const done = tier.done || 0;
792
+ const inflight = tier.inflight || 0;
793
+ const donePercent = total > 0 ? (done / total) * 100 : 0;
794
+ const inflightPercent = total > 0 ? (inflight / total) * 100 : 0;
795
+ tierEl.querySelector('.backlog-progress-done').style.width = donePercent + '%';
796
+ tierEl.querySelector('.backlog-progress-inflight').style.width = inflightPercent + '%';
797
+ tierEl.querySelector('.backlog-progress-empty').style.width = (100 - donePercent - inflightPercent) + '%';
798
+ tierEl.querySelector('.backlog-stats').textContent = `${done}/${total} cleared · ${inflight} in flight`;
799
+
800
+ const itemsContainer = tierEl.querySelector('.backlog-items');
801
+ const items = tier.items || [];
802
+ const newKeys = new Set(items.map(backlogItemKey));
803
+ itemsContainer.querySelectorAll('[data-item-key]').forEach(el => {
804
+ if (!newKeys.has(el.dataset.itemKey)) el.remove();
805
+ });
806
+
807
+ items.forEach(item => {
808
+ const key = backlogItemKey(item);
809
+ let itemEl = itemsContainer.querySelector(`[data-item-key="${cssEscape(key)}"]`);
810
+ if (!itemEl) {
811
+ itemEl = buildBacklogItem(key);
812
+ itemsContainer.appendChild(itemEl);
813
+ }
814
+ itemEl.classList.toggle('done', item.status === '✅');
815
+ itemEl.querySelector('.backlog-item-glyph').textContent = item.status;
816
+ itemEl.querySelector('.backlog-item-tag').textContent = item.tag;
817
+ itemEl.querySelector('.backlog-item-title').textContent = item.title;
818
+ });
819
+
820
+ // Truncation cue: fade the bottom when this tier's items overflow the 200px box (more below)
821
+ itemsContainer.classList.toggle('has-overflow', itemsContainer.scrollHeight > itemsContainer.clientHeight + 2);
822
+ });
823
+ }
824
+
825
+ // ---- tracker panel: lanes + keyed items ----------
826
+ let latestTracker = { items: [] };
827
+
828
+ function getItemLane(item) {
829
+ return item.lane || item.status || 'proposed';
830
+ }
831
+
832
+ function buildTrackerItem(item) {
833
+ const priorityClass = `priority-${item.priority || 'P3'}`.toLowerCase();
834
+ const tagsHtml = (item.tags || []).map(t => `<span class="tag">${sanitize(t)}</span>`).join('');
835
+ // Only http:/https: pr_link values become a clickable href; anything else
836
+ // (javascript:, data:, etc.) is neutralized — the label text is still shown
837
+ // (escaped) but with no href, so the link is inert and non-executable.
838
+ const safePrLink = item.pr_link ? sanitizeURL(item.pr_link) : '';
839
+ const itemEl = document.createElement('div');
840
+ itemEl.className = 'tracker-item';
841
+ itemEl.dataset.itemId = item.id;
842
+ itemEl.innerHTML = `
843
+ <div class="tracker-item-header">
844
+ <div class="tracker-item-title"></div>
845
+ <span class="priority-chip ${priorityClass}"></span>
846
+ </div>
847
+ <div class="tracker-item-tags">${tagsHtml}</div>
848
+ <div class="tracker-item-details" style="display: none;">
849
+ ${item.notes ? `<div class="detail-row"><span class="detail-label">Notes:</span> <span class="detail-value">${sanitize(item.notes)}</span></div>` : ''}
850
+ ${item.pr_link ? `<div class="detail-row"><span class="detail-label">PR:</span> ${safePrLink ? `<a href="${sanitize(safePrLink)}" target="_blank" rel="noopener noreferrer" style="color: #8ac;">${sanitize(item.pr_link.substring(0, 50))}</a>` : `<span class="detail-value">${sanitize(item.pr_link.substring(0, 50))}</span>`}</div>` : ''}
851
+ ${item.created_at ? `<div class="detail-row"><span class="detail-label">Created:</span> <span class="detail-value">${sanitize(item.created_at)}</span></div>` : ''}
852
+ ${item.completed_at ? `<div class="detail-row"><span class="detail-label">Completed:</span> <span class="detail-value">${sanitize(item.completed_at)}</span></div>` : ''}
853
+ </div>
854
+ <div class="tracker-item-actions">
855
+ <button class="tracker-action-btn claim-btn" data-id="${sanitize(item.id)}">Claim</button>
856
+ <button class="tracker-action-btn done-btn" data-id="${sanitize(item.id)}">Done</button>
857
+ <button class="tracker-action-btn danger archive-btn" data-id="${sanitize(item.id)}">Archive</button>
858
+ </div>
859
+ `;
860
+ itemEl.querySelector('.tracker-item-title').textContent = item.title || '(untitled)';
861
+ itemEl.querySelector('.priority-chip').textContent = item.priority || 'P3';
862
+ return itemEl;
863
+ }
864
+
865
+ function patchTracker(trackerData) {
866
+ if (Array.isArray(trackerData)) trackerData = { items: trackerData };
867
+ latestTracker = trackerData || { items: [] };
868
+ if (!Array.isArray(latestTracker.items)) latestTracker.items = [];
869
+ const container = document.getElementById('tracker-lanes');
870
+ container.innerHTML = '';
871
+
872
+ const lanes = { proposed: [], ranked: [], 'in-progress': [], done: [], archived: [] };
873
+ latestTracker.items.forEach(item => {
874
+ const lane = getItemLane(item);
875
+ if (lane === 'archived') lanes.archived.push(item);
876
+ else if (lanes[lane]) lanes[lane].push(item);
877
+ else lanes.proposed.push(item);
878
+ });
879
+
880
+ const laneOrder = ['proposed', 'ranked', 'in-progress', 'done'];
881
+ laneOrder.forEach(laneName => {
882
+ const items = lanes[laneName] || [];
883
+ const laneEl = document.createElement('div');
884
+ laneEl.className = 'tracker-lane';
885
+ const displayName = laneName.charAt(0).toUpperCase() + laneName.slice(1).replace('-', ' ');
886
+ laneEl.innerHTML = `
887
+ <div class="lane-header">
888
+ <span>${displayName}</span>
889
+ <span class="lane-count" aria-label="${displayName}: ${items.length} items">${items.length}</span>
890
+ </div>
891
+ <div class="lane-items" data-lane="${laneName}"></div>
892
+ `;
893
+ container.appendChild(laneEl);
894
+
895
+ const itemsContainer = laneEl.querySelector('.lane-items');
896
+ if (items.length === 0) {
897
+ itemsContainer.classList.add('empty');
898
+ const placeholder = document.createElement('div');
899
+ placeholder.className = 'lane-empty-placeholder';
900
+ placeholder.textContent = 'empty';
901
+ itemsContainer.appendChild(placeholder);
902
+ } else {
903
+ items.forEach(item => {
904
+ const itemEl = buildTrackerItem(item);
905
+ itemsContainer.appendChild(itemEl);
906
+ });
907
+ }
908
+ });
909
+
910
+ if (latestTracker.items.length === 0) {
911
+ const emptyHint = document.createElement('div');
912
+ emptyHint.className = 'loading';
913
+ emptyHint.style.cssText = 'grid-column: 1/-1; text-align: center; color: #aaa;';
914
+ emptyHint.textContent = 'No work items yet — add one above';
915
+ container.appendChild(emptyHint);
916
+ }
917
+
918
+ if (lanes.archived.length > 0) {
919
+ const archivedSummary = document.createElement('div');
920
+ archivedSummary.className = 'tracker-archived-summary';
921
+ archivedSummary.textContent = `${lanes.archived.length} archived item${lanes.archived.length !== 1 ? 's' : ''}`;
922
+ container.appendChild(archivedSummary);
923
+ }
924
+
925
+ document.getElementById('tracker-container').classList.remove('loading');
926
+ setupTrackerEventHandlers();
927
+ }
928
+
929
+ function setupTrackerEventHandlers() {
930
+ document.querySelectorAll('.tracker-item').forEach(row => {
931
+ row.tabIndex = 0;
932
+ row.role = 'button';
933
+
934
+ row.addEventListener('click', function(e) {
935
+ if (e.target.closest('.tracker-action-btn')) return;
936
+ this.classList.toggle('expanded');
937
+ const details = this.querySelector('.tracker-item-details');
938
+ if (details) details.style.display = this.classList.contains('expanded') ? 'block' : 'none';
939
+ });
940
+
941
+ row.addEventListener('keydown', function(e) {
942
+ if (e.key !== 'Enter' && e.key !== ' ') return;
943
+ e.preventDefault();
944
+ this.classList.toggle('expanded');
945
+ const details = this.querySelector('.tracker-item-details');
946
+ if (details) details.style.display = this.classList.contains('expanded') ? 'block' : 'none';
947
+ });
948
+ });
949
+
950
+ document.querySelectorAll('.claim-btn').forEach(btn => {
951
+ btn.addEventListener('click', (e) => {
952
+ e.stopPropagation();
953
+ const id = btn.dataset.id;
954
+ submitTrackerUpdate(id, { status: 'in-progress', lane: 'in-progress' });
955
+ });
956
+ });
957
+
958
+ document.querySelectorAll('.done-btn').forEach(btn => {
959
+ btn.addEventListener('click', (e) => {
960
+ e.stopPropagation();
961
+ const id = btn.dataset.id;
962
+ submitTrackerUpdate(id, { status: 'done', lane: 'done' });
963
+ });
964
+ });
965
+
966
+ document.querySelectorAll('.archive-btn').forEach(btn => {
967
+ btn.addEventListener('click', (e) => {
968
+ e.stopPropagation();
969
+ const id = btn.dataset.id;
970
+ submitTrackerDelete(id);
971
+ });
972
+ });
973
+ }
974
+
975
+ function submitTrackerUpdate(id, update) {
976
+ const csrfToken = window.__AESOP_CSRF_TOKEN__ || '';
977
+ fetch(`/api/tracker/${encodeURIComponent(id)}`, {
978
+ method: 'POST',
979
+ headers: {
980
+ 'Content-Type': 'application/json',
981
+ 'X-Aesop-Token': csrfToken
982
+ },
983
+ body: JSON.stringify(update)
984
+ })
985
+ .then(r => r.json())
986
+ .catch(() => console.error('Tracker update failed'));
987
+ }
988
+
989
+ function submitTrackerDelete(id) {
990
+ const csrfToken = window.__AESOP_CSRF_TOKEN__ || '';
991
+ fetch(`/api/tracker/${encodeURIComponent(id)}?action=delete`, {
992
+ method: 'POST',
993
+ headers: { 'X-Aesop-Token': csrfToken }
994
+ })
995
+ .then(r => r.json())
996
+ .catch(() => console.error('Tracker delete failed'));
997
+ }
998
+
999
+ // ---- orchestrator status panel --------
1000
+ function patchOrchestratorStatus(statusData) {
1001
+ const banner = document.getElementById('orchestrator-banner');
1002
+ const auditBanner = document.getElementById('audit-banner');
1003
+ const statusDiv = document.getElementById('orchestrator-status');
1004
+
1005
+ if (!statusData.orchestrators || statusData.orchestrators.length === 0) {
1006
+ banner.style.display = 'none';
1007
+ auditBanner.style.display = 'none';
1008
+ return;
1009
+ }
1010
+
1011
+ const orch = statusData.orchestrators[0];
1012
+ banner.style.display = 'block';
1013
+
1014
+ let statusText = orch.activity || 'no active session';
1015
+ if (orch.age_seconds !== undefined) {
1016
+ const mins = Math.floor(orch.age_seconds / 60);
1017
+ const secs = orch.age_seconds % 60;
1018
+ const ageStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
1019
+ statusText += ` · updated ${ageStr} ago`;
1020
+ if (orch.stale) statusDiv.className = 'status-stale-warn';
1021
+ else statusDiv.className = 'status-activity';
1022
+ }
1023
+ statusDiv.textContent = statusText;
1024
+
1025
+ // Show ASCII banner for audit phase
1026
+ if (orch.phase === 'audit') {
1027
+ auditBanner.style.display = 'block';
1028
+ } else {
1029
+ auditBanner.style.display = 'none';
1030
+ }
1031
+ }
1032
+
1033
+ // ---- SSE wiring ----------------------------------------------------
1034
+ const evtSource = new EventSource('/events');
1035
+
1036
+ evtSource.addEventListener('data', (e) => {
1037
+ try {
1038
+ patchDataSection(JSON.parse(e.data));
1039
+ setConnectionStatus(true);
1040
+ setConnectionDegraded(false);
1041
+ } catch (err) {
1042
+ console.error('Failed to parse data frame:', err);
1043
+ setConnectionDegraded(true);
1044
+ }
1045
+ });
1046
+ evtSource.addEventListener('backlog', (e) => {
1047
+ try {
1048
+ patchBacklog(JSON.parse(e.data));
1049
+ setConnectionStatus(true);
1050
+ setConnectionDegraded(false);
1051
+ } catch (err) {
1052
+ console.error('Failed to parse backlog frame:', err);
1053
+ setConnectionDegraded(true);
1054
+ }
1055
+ });
1056
+ evtSource.addEventListener('agents', (e) => {
1057
+ try {
1058
+ patchAgents(JSON.parse(e.data));
1059
+ patchHeaderRunningCount(latestAgents);
1060
+ setConnectionStatus(true);
1061
+ setConnectionDegraded(false);
1062
+ } catch (err) {
1063
+ console.error('Failed to parse agents frame:', err);
1064
+ setConnectionDegraded(true);
1065
+ }
1066
+ });
1067
+ evtSource.addEventListener('tracker', (e) => {
1068
+ try {
1069
+ patchTracker(JSON.parse(e.data));
1070
+ setConnectionStatus(true);
1071
+ setConnectionDegraded(false);
1072
+ } catch (err) {
1073
+ console.error('Failed to parse tracker frame:', err);
1074
+ setConnectionDegraded(true);
1075
+ }
1076
+ });
1077
+ evtSource.addEventListener('status', (e) => {
1078
+ try {
1079
+ patchOrchestratorStatus(JSON.parse(e.data));
1080
+ setConnectionStatus(true);
1081
+ setConnectionDegraded(false);
1082
+ } catch (err) {
1083
+ console.error('Failed to parse status frame:', err);
1084
+ setConnectionDegraded(true);
1085
+ }
1086
+ });
1087
+ evtSource.addEventListener('open', () => {
1088
+ setConnectionStatus(true);
1089
+ setConnectionDegraded(false);
1090
+ });
1091
+ evtSource.addEventListener('error', () => setConnectionStatus(false));
1092
+
1093
+ // Manual refresh fallback (one-shot fetch, reuses the exact same patch
1094
+ // functions as the push path so there's a single rendering code path).
1095
+ async function manualRefresh() {
1096
+ try {
1097
+ const [dataResp, backlogResp, agentsResp, trackerResp] = await Promise.all([
1098
+ fetch('/data'), fetch('/api/backlog'), fetch('/api/agents'), fetch('/api/tracker')
1099
+ ]);
1100
+ if (dataResp.ok) patchDataSection(await dataResp.json());
1101
+ if (backlogResp.ok) patchBacklog(await backlogResp.json());
1102
+ if (agentsResp.ok) {
1103
+ patchAgents(await agentsResp.json());
1104
+ patchHeaderRunningCount(latestAgents);
1105
+ }
1106
+ if (trackerResp.ok) patchTracker(await trackerResp.json());
1107
+ } catch (e) {
1108
+ console.error('Manual refresh error:', e);
1109
+ }
1110
+ }
1111
+ document.getElementById('manual-refresh-button').addEventListener('click', manualRefresh);
1112
+
1113
+ async function handleInboxSubmit() {
1114
+ const input = document.getElementById('inbox-input');
1115
+ const button = document.getElementById('inbox-button');
1116
+ const status = document.getElementById('inbox-status');
1117
+
1118
+ const text = input.value.trim();
1119
+ if (!text) return;
1120
+
1121
+ button.disabled = true;
1122
+ try {
1123
+ // Get CSRF token from window (injected by server)
1124
+ const csrfToken = window.__AESOP_CSRF_TOKEN__ || '';
1125
+ const response = await fetch('/submit', {
1126
+ method: 'POST',
1127
+ headers: {
1128
+ 'Content-Type': 'application/json',
1129
+ 'X-Aesop-Token': csrfToken
1130
+ },
1131
+ body: JSON.stringify({ text })
1132
+ });
1133
+ if (response.ok) {
1134
+ input.value = '';
1135
+ status.style.display = 'block';
1136
+ setTimeout(() => { status.style.display = 'none'; }, 3000);
1137
+ }
1138
+ } catch (e) {
1139
+ console.error('Submit error:', e);
1140
+ } finally {
1141
+ button.disabled = false;
1142
+ }
1143
+ }
1144
+
1145
+ document.getElementById('inbox-button').addEventListener('click', handleInboxSubmit);
1146
+ document.getElementById('inbox-input').addEventListener('keypress', (e) => {
1147
+ if (e.key === 'Enter') handleInboxSubmit();
1148
+ });
1149
+
1150
+ async function handleTrackerAddItem() {
1151
+ const titleInput = document.getElementById('tracker-title');
1152
+ const priorityInput = document.getElementById('tracker-priority');
1153
+ const notesInput = document.getElementById('tracker-notes');
1154
+ const button = document.getElementById('tracker-add-btn');
1155
+
1156
+ const title = titleInput.value.trim();
1157
+ if (!title) return;
1158
+
1159
+ const itemData = {
1160
+ title: title,
1161
+ priority: priorityInput.value || 'P2',
1162
+ notes: notesInput.value.trim() || null,
1163
+ source: 'dashboard',
1164
+ tags: [],
1165
+ status: 'todo',
1166
+ lane: 'proposed'
1167
+ };
1168
+
1169
+ button.disabled = true;
1170
+ try {
1171
+ const response = await fetch('/api/tracker', {
1172
+ method: 'POST',
1173
+ headers: {
1174
+ 'Content-Type': 'application/json',
1175
+ 'X-Aesop-Token': window.__AESOP_CSRF_TOKEN__ || ''
1176
+ },
1177
+ body: JSON.stringify(itemData)
1178
+ });
1179
+ if (response.ok) {
1180
+ titleInput.value = '';
1181
+ notesInput.value = '';
1182
+ priorityInput.value = 'P2';
1183
+ }
1184
+ } catch (e) {
1185
+ console.error('Tracker add error:', e);
1186
+ } finally {
1187
+ button.disabled = false;
1188
+ }
1189
+ }
1190
+
1191
+ document.getElementById('tracker-add-btn').addEventListener('click', handleTrackerAddItem);
1192
+ document.getElementById('tracker-title').addEventListener('keypress', (e) => {
1193
+ if (e.key === 'Enter') handleTrackerAddItem();
1194
+ });
1195
+
1196
+ // Initial fetch for tracker and status data
1197
+ Promise.all([fetch('/api/tracker')]).then(([trackerResp]) => {
1198
+ if (trackerResp.ok) trackerResp.json().then(data => patchTracker({ items: data }));
1199
+ }).catch(e => console.error('Initial tracker fetch error:', e));
1200
+ </script>
1201
+ </body>
1202
+ </html>