reflectt-node 0.1.0

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 (486) hide show
  1. package/LICENSE +178 -0
  2. package/README.md +188 -0
  3. package/dist/activationEvents.d.ts +110 -0
  4. package/dist/activationEvents.d.ts.map +1 -0
  5. package/dist/activationEvents.js +378 -0
  6. package/dist/activationEvents.js.map +1 -0
  7. package/dist/activity-signal.d.ts +30 -0
  8. package/dist/activity-signal.d.ts.map +1 -0
  9. package/dist/activity-signal.js +93 -0
  10. package/dist/activity-signal.js.map +1 -0
  11. package/dist/alert-integrity.d.ts +100 -0
  12. package/dist/alert-integrity.d.ts.map +1 -0
  13. package/dist/alert-integrity.js +333 -0
  14. package/dist/alert-integrity.js.map +1 -0
  15. package/dist/alert-preflight.d.ts +40 -0
  16. package/dist/alert-preflight.d.ts.map +1 -0
  17. package/dist/alert-preflight.js +235 -0
  18. package/dist/alert-preflight.js.map +1 -0
  19. package/dist/analytics.d.ts +131 -0
  20. package/dist/analytics.d.ts.map +1 -0
  21. package/dist/analytics.js +371 -0
  22. package/dist/analytics.js.map +1 -0
  23. package/dist/artifact-mirror.d.ts +26 -0
  24. package/dist/artifact-mirror.d.ts.map +1 -0
  25. package/dist/artifact-mirror.js +170 -0
  26. package/dist/artifact-mirror.js.map +1 -0
  27. package/dist/artifact-resolver.d.ts +48 -0
  28. package/dist/artifact-resolver.d.ts.map +1 -0
  29. package/dist/artifact-resolver.js +164 -0
  30. package/dist/artifact-resolver.js.map +1 -0
  31. package/dist/assignment.d.ts +116 -0
  32. package/dist/assignment.d.ts.map +1 -0
  33. package/dist/assignment.js +475 -0
  34. package/dist/assignment.js.map +1 -0
  35. package/dist/auditLedger.d.ts +50 -0
  36. package/dist/auditLedger.d.ts.map +1 -0
  37. package/dist/auditLedger.js +136 -0
  38. package/dist/auditLedger.js.map +1 -0
  39. package/dist/boardHealthWorker.d.ts +134 -0
  40. package/dist/boardHealthWorker.d.ts.map +1 -0
  41. package/dist/boardHealthWorker.js +882 -0
  42. package/dist/boardHealthWorker.js.map +1 -0
  43. package/dist/bootstrap-team.d.ts +42 -0
  44. package/dist/bootstrap-team.d.ts.map +1 -0
  45. package/dist/bootstrap-team.js +111 -0
  46. package/dist/bootstrap-team.js.map +1 -0
  47. package/dist/buildInfo.d.ts +17 -0
  48. package/dist/buildInfo.d.ts.map +1 -0
  49. package/dist/buildInfo.js +56 -0
  50. package/dist/buildInfo.js.map +1 -0
  51. package/dist/calendar-events.d.ts +133 -0
  52. package/dist/calendar-events.d.ts.map +1 -0
  53. package/dist/calendar-events.js +615 -0
  54. package/dist/calendar-events.js.map +1 -0
  55. package/dist/calendar-ical.d.ts +41 -0
  56. package/dist/calendar-ical.d.ts.map +1 -0
  57. package/dist/calendar-ical.js +413 -0
  58. package/dist/calendar-ical.js.map +1 -0
  59. package/dist/calendar-reminder-engine.d.ts +10 -0
  60. package/dist/calendar-reminder-engine.d.ts.map +1 -0
  61. package/dist/calendar-reminder-engine.js +143 -0
  62. package/dist/calendar-reminder-engine.js.map +1 -0
  63. package/dist/calendar.d.ts +75 -0
  64. package/dist/calendar.d.ts.map +1 -0
  65. package/dist/calendar.js +391 -0
  66. package/dist/calendar.js.map +1 -0
  67. package/dist/canvas-multiplexer.d.ts +44 -0
  68. package/dist/canvas-multiplexer.d.ts.map +1 -0
  69. package/dist/canvas-multiplexer.js +150 -0
  70. package/dist/canvas-multiplexer.js.map +1 -0
  71. package/dist/canvas-slots.d.ts +83 -0
  72. package/dist/canvas-slots.d.ts.map +1 -0
  73. package/dist/canvas-slots.js +144 -0
  74. package/dist/canvas-slots.js.map +1 -0
  75. package/dist/canvas-types.d.ts +56 -0
  76. package/dist/canvas-types.d.ts.map +1 -0
  77. package/dist/canvas-types.js +54 -0
  78. package/dist/canvas-types.js.map +1 -0
  79. package/dist/cf-keepalive.d.ts +40 -0
  80. package/dist/cf-keepalive.d.ts.map +1 -0
  81. package/dist/cf-keepalive.js +153 -0
  82. package/dist/cf-keepalive.js.map +1 -0
  83. package/dist/changeFeed.d.ts +38 -0
  84. package/dist/changeFeed.d.ts.map +1 -0
  85. package/dist/changeFeed.js +324 -0
  86. package/dist/changeFeed.js.map +1 -0
  87. package/dist/channels.d.ts +28 -0
  88. package/dist/channels.d.ts.map +1 -0
  89. package/dist/channels.js +23 -0
  90. package/dist/channels.js.map +1 -0
  91. package/dist/chat-approval-detector.d.ts +47 -0
  92. package/dist/chat-approval-detector.d.ts.map +1 -0
  93. package/dist/chat-approval-detector.js +224 -0
  94. package/dist/chat-approval-detector.js.map +1 -0
  95. package/dist/chat.d.ts +119 -0
  96. package/dist/chat.d.ts.map +1 -0
  97. package/dist/chat.js +666 -0
  98. package/dist/chat.js.map +1 -0
  99. package/dist/cli.d.ts +3 -0
  100. package/dist/cli.d.ts.map +1 -0
  101. package/dist/cli.js +1142 -0
  102. package/dist/cli.js.map +1 -0
  103. package/dist/cloud.d.ts +45 -0
  104. package/dist/cloud.d.ts.map +1 -0
  105. package/dist/cloud.js +962 -0
  106. package/dist/cloud.js.map +1 -0
  107. package/dist/config.d.ts +17 -0
  108. package/dist/config.d.ts.map +1 -0
  109. package/dist/config.js +33 -0
  110. package/dist/config.js.map +1 -0
  111. package/dist/connectivity.d.ts +59 -0
  112. package/dist/connectivity.d.ts.map +1 -0
  113. package/dist/connectivity.js +173 -0
  114. package/dist/connectivity.js.map +1 -0
  115. package/dist/contacts.d.ts +59 -0
  116. package/dist/contacts.d.ts.map +1 -0
  117. package/dist/contacts.js +183 -0
  118. package/dist/contacts.js.map +1 -0
  119. package/dist/content.d.ts +130 -0
  120. package/dist/content.d.ts.map +1 -0
  121. package/dist/content.js +186 -0
  122. package/dist/content.js.map +1 -0
  123. package/dist/context-budget.d.ts +87 -0
  124. package/dist/context-budget.d.ts.map +1 -0
  125. package/dist/context-budget.js +459 -0
  126. package/dist/context-budget.js.map +1 -0
  127. package/dist/continuity-loop.d.ts +55 -0
  128. package/dist/continuity-loop.d.ts.map +1 -0
  129. package/dist/continuity-loop.js +267 -0
  130. package/dist/continuity-loop.js.map +1 -0
  131. package/dist/dashboard.d.ts +6 -0
  132. package/dist/dashboard.d.ts.map +1 -0
  133. package/dist/dashboard.js +2348 -0
  134. package/dist/dashboard.js.map +1 -0
  135. package/dist/db.d.ts +44 -0
  136. package/dist/db.d.ts.map +1 -0
  137. package/dist/db.js +648 -0
  138. package/dist/db.js.map +1 -0
  139. package/dist/doctor.d.ts +30 -0
  140. package/dist/doctor.d.ts.map +1 -0
  141. package/dist/doctor.js +159 -0
  142. package/dist/doctor.js.map +1 -0
  143. package/dist/duplicateClosureGuard.d.ts +31 -0
  144. package/dist/duplicateClosureGuard.d.ts.map +1 -0
  145. package/dist/duplicateClosureGuard.js +83 -0
  146. package/dist/duplicateClosureGuard.js.map +1 -0
  147. package/dist/embeddings.d.ts +13 -0
  148. package/dist/embeddings.d.ts.map +1 -0
  149. package/dist/embeddings.js +78 -0
  150. package/dist/embeddings.js.map +1 -0
  151. package/dist/escalation.d.ts +80 -0
  152. package/dist/escalation.d.ts.map +1 -0
  153. package/dist/escalation.js +213 -0
  154. package/dist/escalation.js.map +1 -0
  155. package/dist/events.d.ts +130 -0
  156. package/dist/events.d.ts.map +1 -0
  157. package/dist/events.js +382 -0
  158. package/dist/events.js.map +1 -0
  159. package/dist/executionSweeper.d.ts +97 -0
  160. package/dist/executionSweeper.d.ts.map +1 -0
  161. package/dist/executionSweeper.js +875 -0
  162. package/dist/executionSweeper.js.map +1 -0
  163. package/dist/experiments.d.ts +47 -0
  164. package/dist/experiments.d.ts.map +1 -0
  165. package/dist/experiments.js +133 -0
  166. package/dist/experiments.js.map +1 -0
  167. package/dist/feedback.d.ts +179 -0
  168. package/dist/feedback.d.ts.map +1 -0
  169. package/dist/feedback.js +397 -0
  170. package/dist/feedback.js.map +1 -0
  171. package/dist/files.d.ts +52 -0
  172. package/dist/files.d.ts.map +1 -0
  173. package/dist/files.js +172 -0
  174. package/dist/files.js.map +1 -0
  175. package/dist/format-duration.d.ts +19 -0
  176. package/dist/format-duration.d.ts.map +1 -0
  177. package/dist/format-duration.js +33 -0
  178. package/dist/format-duration.js.map +1 -0
  179. package/dist/github-actor-auth.d.ts +20 -0
  180. package/dist/github-actor-auth.d.ts.map +1 -0
  181. package/dist/github-actor-auth.js +54 -0
  182. package/dist/github-actor-auth.js.map +1 -0
  183. package/dist/github-ci.d.ts +16 -0
  184. package/dist/github-ci.d.ts.map +1 -0
  185. package/dist/github-ci.js +37 -0
  186. package/dist/github-ci.js.map +1 -0
  187. package/dist/github-identity.d.ts +30 -0
  188. package/dist/github-identity.d.ts.map +1 -0
  189. package/dist/github-identity.js +96 -0
  190. package/dist/github-identity.js.map +1 -0
  191. package/dist/github-reviews.d.ts +24 -0
  192. package/dist/github-reviews.d.ts.map +1 -0
  193. package/dist/github-reviews.js +56 -0
  194. package/dist/github-reviews.js.map +1 -0
  195. package/dist/health.d.ts +391 -0
  196. package/dist/health.d.ts.map +1 -0
  197. package/dist/health.js +1841 -0
  198. package/dist/health.js.map +1 -0
  199. package/dist/host-keepalive.d.ts +22 -0
  200. package/dist/host-keepalive.d.ts.map +1 -0
  201. package/dist/host-keepalive.js +126 -0
  202. package/dist/host-keepalive.js.map +1 -0
  203. package/dist/host-registry.d.ts +43 -0
  204. package/dist/host-registry.d.ts.map +1 -0
  205. package/dist/host-registry.js +93 -0
  206. package/dist/host-registry.js.map +1 -0
  207. package/dist/inbox.d.ts +87 -0
  208. package/dist/inbox.d.ts.map +1 -0
  209. package/dist/inbox.js +410 -0
  210. package/dist/inbox.js.map +1 -0
  211. package/dist/index.d.ts +2 -0
  212. package/dist/index.d.ts.map +1 -0
  213. package/dist/index.js +306 -0
  214. package/dist/index.js.map +1 -0
  215. package/dist/insight-mutation.d.ts +32 -0
  216. package/dist/insight-mutation.d.ts.map +1 -0
  217. package/dist/insight-mutation.js +160 -0
  218. package/dist/insight-mutation.js.map +1 -0
  219. package/dist/insight-promotion.d.ts +89 -0
  220. package/dist/insight-promotion.d.ts.map +1 -0
  221. package/dist/insight-promotion.js +278 -0
  222. package/dist/insight-promotion.js.map +1 -0
  223. package/dist/insight-task-bridge.d.ts +77 -0
  224. package/dist/insight-task-bridge.d.ts.map +1 -0
  225. package/dist/insight-task-bridge.js +556 -0
  226. package/dist/insight-task-bridge.js.map +1 -0
  227. package/dist/insights.d.ts +222 -0
  228. package/dist/insights.d.ts.map +1 -0
  229. package/dist/insights.js +871 -0
  230. package/dist/insights.js.map +1 -0
  231. package/dist/intake-pipeline.d.ts +74 -0
  232. package/dist/intake-pipeline.d.ts.map +1 -0
  233. package/dist/intake-pipeline.js +199 -0
  234. package/dist/intake-pipeline.js.map +1 -0
  235. package/dist/intensity.d.ts +31 -0
  236. package/dist/intensity.d.ts.map +1 -0
  237. package/dist/intensity.js +94 -0
  238. package/dist/intensity.js.map +1 -0
  239. package/dist/knowledge-auto-index.d.ts +37 -0
  240. package/dist/knowledge-auto-index.d.ts.map +1 -0
  241. package/dist/knowledge-auto-index.js +149 -0
  242. package/dist/knowledge-auto-index.js.map +1 -0
  243. package/dist/knowledge-docs.d.ts +45 -0
  244. package/dist/knowledge-docs.d.ts.map +1 -0
  245. package/dist/knowledge-docs.js +188 -0
  246. package/dist/knowledge-docs.js.map +1 -0
  247. package/dist/lane-config.d.ts +25 -0
  248. package/dist/lane-config.d.ts.map +1 -0
  249. package/dist/lane-config.js +105 -0
  250. package/dist/lane-config.js.map +1 -0
  251. package/dist/lineage.d.ts +86 -0
  252. package/dist/lineage.d.ts.map +1 -0
  253. package/dist/lineage.js +303 -0
  254. package/dist/lineage.js.map +1 -0
  255. package/dist/logStore.d.ts +25 -0
  256. package/dist/logStore.d.ts.map +1 -0
  257. package/dist/logStore.js +83 -0
  258. package/dist/logStore.js.map +1 -0
  259. package/dist/manage.d.ts +12 -0
  260. package/dist/manage.d.ts.map +1 -0
  261. package/dist/manage.js +253 -0
  262. package/dist/manage.js.map +1 -0
  263. package/dist/mcp.d.ts +5 -0
  264. package/dist/mcp.d.ts.map +1 -0
  265. package/dist/mcp.js +604 -0
  266. package/dist/mcp.js.map +1 -0
  267. package/dist/memory.d.ts +47 -0
  268. package/dist/memory.d.ts.map +1 -0
  269. package/dist/memory.js +149 -0
  270. package/dist/memory.js.map +1 -0
  271. package/dist/mention-ack.d.ts +80 -0
  272. package/dist/mention-ack.d.ts.map +1 -0
  273. package/dist/mention-ack.js +175 -0
  274. package/dist/mention-ack.js.map +1 -0
  275. package/dist/messageRouter.d.ts +60 -0
  276. package/dist/messageRouter.d.ts.map +1 -0
  277. package/dist/messageRouter.js +309 -0
  278. package/dist/messageRouter.js.map +1 -0
  279. package/dist/mutationAlert.d.ts +44 -0
  280. package/dist/mutationAlert.d.ts.map +1 -0
  281. package/dist/mutationAlert.js +174 -0
  282. package/dist/mutationAlert.js.map +1 -0
  283. package/dist/noise-budget.d.ts +136 -0
  284. package/dist/noise-budget.d.ts.map +1 -0
  285. package/dist/noise-budget.js +340 -0
  286. package/dist/noise-budget.js.map +1 -0
  287. package/dist/notifications.d.ts +67 -0
  288. package/dist/notifications.d.ts.map +1 -0
  289. package/dist/notifications.js +253 -0
  290. package/dist/notifications.js.map +1 -0
  291. package/dist/openclaw.d.ts +34 -0
  292. package/dist/openclaw.d.ts.map +1 -0
  293. package/dist/openclaw.js +208 -0
  294. package/dist/openclaw.js.map +1 -0
  295. package/dist/pause-controls.d.ts +31 -0
  296. package/dist/pause-controls.d.ts.map +1 -0
  297. package/dist/pause-controls.js +130 -0
  298. package/dist/pause-controls.js.map +1 -0
  299. package/dist/pidlock.d.ts +25 -0
  300. package/dist/pidlock.d.ts.map +1 -0
  301. package/dist/pidlock.js +179 -0
  302. package/dist/pidlock.js.map +1 -0
  303. package/dist/policy.d.ts +139 -0
  304. package/dist/policy.d.ts.map +1 -0
  305. package/dist/policy.js +264 -0
  306. package/dist/policy.js.map +1 -0
  307. package/dist/polls.d.ts +47 -0
  308. package/dist/polls.d.ts.map +1 -0
  309. package/dist/polls.js +162 -0
  310. package/dist/polls.js.map +1 -0
  311. package/dist/portability.d.ts +55 -0
  312. package/dist/portability.d.ts.map +1 -0
  313. package/dist/portability.js +292 -0
  314. package/dist/portability.js.map +1 -0
  315. package/dist/pr-integrity.d.ts +45 -0
  316. package/dist/pr-integrity.d.ts.map +1 -0
  317. package/dist/pr-integrity.js +124 -0
  318. package/dist/pr-integrity.js.map +1 -0
  319. package/dist/prAutoMerge.d.ts +62 -0
  320. package/dist/prAutoMerge.d.ts.map +1 -0
  321. package/dist/prAutoMerge.js +493 -0
  322. package/dist/prAutoMerge.js.map +1 -0
  323. package/dist/preflight.d.ts +66 -0
  324. package/dist/preflight.d.ts.map +1 -0
  325. package/dist/preflight.js +864 -0
  326. package/dist/preflight.js.map +1 -0
  327. package/dist/presence.d.ts +98 -0
  328. package/dist/presence.d.ts.map +1 -0
  329. package/dist/presence.js +347 -0
  330. package/dist/presence.js.map +1 -0
  331. package/dist/provisioning.d.ts +101 -0
  332. package/dist/provisioning.d.ts.map +1 -0
  333. package/dist/provisioning.js +430 -0
  334. package/dist/provisioning.js.map +1 -0
  335. package/dist/reflection-automation.d.ts +59 -0
  336. package/dist/reflection-automation.d.ts.map +1 -0
  337. package/dist/reflection-automation.js +350 -0
  338. package/dist/reflection-automation.js.map +1 -0
  339. package/dist/reflections.d.ts +65 -0
  340. package/dist/reflections.d.ts.map +1 -0
  341. package/dist/reflections.js +306 -0
  342. package/dist/reflections.js.map +1 -0
  343. package/dist/release.d.ts +67 -0
  344. package/dist/release.d.ts.map +1 -0
  345. package/dist/release.js +275 -0
  346. package/dist/release.js.map +1 -0
  347. package/dist/request-tracker.d.ts +36 -0
  348. package/dist/request-tracker.d.ts.map +1 -0
  349. package/dist/request-tracker.js +109 -0
  350. package/dist/request-tracker.js.map +1 -0
  351. package/dist/research.d.ts +75 -0
  352. package/dist/research.d.ts.map +1 -0
  353. package/dist/research.js +171 -0
  354. package/dist/research.js.map +1 -0
  355. package/dist/routing-approvals.d.ts +73 -0
  356. package/dist/routing-approvals.d.ts.map +1 -0
  357. package/dist/routing-approvals.js +88 -0
  358. package/dist/routing-approvals.js.map +1 -0
  359. package/dist/routing-override.d.ts +94 -0
  360. package/dist/routing-override.d.ts.map +1 -0
  361. package/dist/routing-override.js +290 -0
  362. package/dist/routing-override.js.map +1 -0
  363. package/dist/scope-routing.d.ts +18 -0
  364. package/dist/scope-routing.d.ts.map +1 -0
  365. package/dist/scope-routing.js +29 -0
  366. package/dist/scope-routing.js.map +1 -0
  367. package/dist/secrets.d.ts +77 -0
  368. package/dist/secrets.d.ts.map +1 -0
  369. package/dist/secrets.js +287 -0
  370. package/dist/secrets.js.map +1 -0
  371. package/dist/server.d.ts +3 -0
  372. package/dist/server.d.ts.map +1 -0
  373. package/dist/server.js +10887 -0
  374. package/dist/server.js.map +1 -0
  375. package/dist/service-probe.d.ts +53 -0
  376. package/dist/service-probe.d.ts.map +1 -0
  377. package/dist/service-probe.js +225 -0
  378. package/dist/service-probe.js.map +1 -0
  379. package/dist/shared-workspace-api.d.ts +73 -0
  380. package/dist/shared-workspace-api.d.ts.map +1 -0
  381. package/dist/shared-workspace-api.js +281 -0
  382. package/dist/shared-workspace-api.js.map +1 -0
  383. package/dist/shipped-heartbeat.d.ts +91 -0
  384. package/dist/shipped-heartbeat.d.ts.map +1 -0
  385. package/dist/shipped-heartbeat.js +272 -0
  386. package/dist/shipped-heartbeat.js.map +1 -0
  387. package/dist/starter-team.d.ts +23 -0
  388. package/dist/starter-team.d.ts.map +1 -0
  389. package/dist/starter-team.js +88 -0
  390. package/dist/starter-team.js.map +1 -0
  391. package/dist/suppression-ledger.d.ts +73 -0
  392. package/dist/suppression-ledger.d.ts.map +1 -0
  393. package/dist/suppression-ledger.js +125 -0
  394. package/dist/suppression-ledger.js.map +1 -0
  395. package/dist/system-loop-state.d.ts +4 -0
  396. package/dist/system-loop-state.d.ts.map +1 -0
  397. package/dist/system-loop-state.js +40 -0
  398. package/dist/system-loop-state.js.map +1 -0
  399. package/dist/taskCommentIngest.d.ts +43 -0
  400. package/dist/taskCommentIngest.d.ts.map +1 -0
  401. package/dist/taskCommentIngest.js +59 -0
  402. package/dist/taskCommentIngest.js.map +1 -0
  403. package/dist/taskPrecheck.d.ts +20 -0
  404. package/dist/taskPrecheck.d.ts.map +1 -0
  405. package/dist/taskPrecheck.js +329 -0
  406. package/dist/taskPrecheck.js.map +1 -0
  407. package/dist/taskStateSync.d.ts +8 -0
  408. package/dist/taskStateSync.d.ts.map +1 -0
  409. package/dist/taskStateSync.js +79 -0
  410. package/dist/taskStateSync.js.map +1 -0
  411. package/dist/tasks.d.ts +140 -0
  412. package/dist/tasks.d.ts.map +1 -0
  413. package/dist/tasks.js +1281 -0
  414. package/dist/tasks.js.map +1 -0
  415. package/dist/team-config.d.ts +24 -0
  416. package/dist/team-config.d.ts.map +1 -0
  417. package/dist/team-config.js +221 -0
  418. package/dist/team-config.js.map +1 -0
  419. package/dist/team-doctor.d.ts +22 -0
  420. package/dist/team-doctor.d.ts.map +1 -0
  421. package/dist/team-doctor.js +270 -0
  422. package/dist/team-doctor.js.map +1 -0
  423. package/dist/team-pulse.d.ts +52 -0
  424. package/dist/team-pulse.d.ts.map +1 -0
  425. package/dist/team-pulse.js +176 -0
  426. package/dist/team-pulse.js.map +1 -0
  427. package/dist/telemetry.d.ts +74 -0
  428. package/dist/telemetry.d.ts.map +1 -0
  429. package/dist/telemetry.js +256 -0
  430. package/dist/telemetry.js.map +1 -0
  431. package/dist/test-task-filter.d.ts +21 -0
  432. package/dist/test-task-filter.d.ts.map +1 -0
  433. package/dist/test-task-filter.js +48 -0
  434. package/dist/test-task-filter.js.map +1 -0
  435. package/dist/types.d.ts +126 -0
  436. package/dist/types.d.ts.map +1 -0
  437. package/dist/types.js +4 -0
  438. package/dist/types.js.map +1 -0
  439. package/dist/usage-tracking.d.ts +101 -0
  440. package/dist/usage-tracking.d.ts.map +1 -0
  441. package/dist/usage-tracking.js +325 -0
  442. package/dist/usage-tracking.js.map +1 -0
  443. package/dist/vector-store.d.ts +87 -0
  444. package/dist/vector-store.d.ts.map +1 -0
  445. package/dist/vector-store.js +247 -0
  446. package/dist/vector-store.js.map +1 -0
  447. package/dist/watchdog/idleNudgeLane.d.ts +22 -0
  448. package/dist/watchdog/idleNudgeLane.d.ts.map +1 -0
  449. package/dist/watchdog/idleNudgeLane.js +98 -0
  450. package/dist/watchdog/idleNudgeLane.js.map +1 -0
  451. package/dist/webhooks.d.ts +103 -0
  452. package/dist/webhooks.d.ts.map +1 -0
  453. package/dist/webhooks.js +398 -0
  454. package/dist/webhooks.js.map +1 -0
  455. package/dist/working-contract.d.ts +42 -0
  456. package/dist/working-contract.d.ts.map +1 -0
  457. package/dist/working-contract.js +228 -0
  458. package/dist/working-contract.js.map +1 -0
  459. package/dist/ws-heartbeat.d.ts +66 -0
  460. package/dist/ws-heartbeat.d.ts.map +1 -0
  461. package/dist/ws-heartbeat.js +174 -0
  462. package/dist/ws-heartbeat.js.map +1 -0
  463. package/package.json +87 -0
  464. package/plugins/reflectt-channel/README.md +96 -0
  465. package/plugins/reflectt-channel/index.ts +789 -0
  466. package/plugins/reflectt-channel/openclaw.plugin.json +23 -0
  467. package/plugins/reflectt-channel/package.json +23 -0
  468. package/plugins/reflectt-channel/src/channel.ts +433 -0
  469. package/plugins/reflectt-channel/src/types.ts +29 -0
  470. package/public/avatars/echo.png +0 -0
  471. package/public/avatars/harmony.png +0 -0
  472. package/public/avatars/kai.png +0 -0
  473. package/public/avatars/link.png +0 -0
  474. package/public/avatars/pixel.png +0 -0
  475. package/public/avatars/rhythm.png +0 -0
  476. package/public/avatars/ryan.png +0 -0
  477. package/public/avatars/sage.png +0 -0
  478. package/public/avatars/scout.png +0 -0
  479. package/public/avatars/spark.png +0 -0
  480. package/public/dashboard-animations.css +381 -0
  481. package/public/dashboard.js +3479 -0
  482. package/public/docs.md +1062 -0
  483. package/public/file-upload-mock.html +1097 -0
  484. package/public/og-card.png +0 -0
  485. package/public/ui-kit.html +318 -0
  486. package/public/widget/feedback.js +194 -0
@@ -0,0 +1,3479 @@
1
+ const BASE = location.origin;
2
+
3
+ // Keyboard a11y: activate [role="button"] on Enter/Space
4
+ document.addEventListener('keydown', function(e) {
5
+ if ((e.key === 'Enter' || e.key === ' ') && e.target.getAttribute('role') === 'button') {
6
+ e.preventDefault();
7
+ e.target.click();
8
+ }
9
+ });
10
+
11
+ /* ============================================================
12
+ SIDEBAR NAV — hash-based client-side routing
13
+ ============================================================ */
14
+ const VALID_PAGES = ['overview', 'tasks', 'chat', 'reviews', 'health', 'outcomes', 'research', 'artifacts'];
15
+
16
+ function navigateTo(page) {
17
+ if (!VALID_PAGES.includes(page)) page = 'overview';
18
+ location.hash = page === 'overview' ? '' : page;
19
+ activatePage(page);
20
+ // Close mobile sidebar
21
+ const sidebar = document.getElementById('sidebar');
22
+ const overlay = document.getElementById('sidebar-overlay');
23
+ if (sidebar) sidebar.classList.remove('open');
24
+ if (overlay) overlay.classList.remove('open');
25
+ }
26
+
27
+ function activatePage(page) {
28
+ // Hide all pages, show target
29
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
30
+ const target = document.getElementById('page-' + page);
31
+ if (target) target.classList.add('active');
32
+ // Update sidebar active state
33
+ document.querySelectorAll('.sidebar-link[data-page]').forEach(link => {
34
+ link.classList.toggle('active', link.dataset.page === page);
35
+ });
36
+ }
37
+
38
+ function toggleSidebar() {
39
+ const sidebar = document.getElementById('sidebar');
40
+ const overlay = document.getElementById('sidebar-overlay');
41
+ if (sidebar) sidebar.classList.toggle('open');
42
+ if (overlay) overlay.classList.toggle('open');
43
+ }
44
+
45
+ // Init: read hash on load
46
+ function initRouter() {
47
+ const hash = location.hash.replace('#', '') || 'overview';
48
+ activatePage(VALID_PAGES.includes(hash) ? hash : 'overview');
49
+ }
50
+ window.addEventListener('hashchange', () => {
51
+ const hash = location.hash.replace('#', '') || 'overview';
52
+ activatePage(VALID_PAGES.includes(hash) ? hash : 'overview');
53
+ });
54
+ initRouter();
55
+
56
+ let currentChannel = 'all';
57
+ let currentProject = 'all';
58
+ let currentStatusFilter = localStorage.getItem('taskStatusFilter') || 'open'; // 'open' | 'all'
59
+ let hideTestTasks = localStorage.getItem('hideTestTasks') !== 'false'; // default true
60
+ let allMessages = [];
61
+ let allTasks = [];
62
+ let allEvents = [];
63
+ let taskById = new Map();
64
+ let healthAgentMap = new Map();
65
+ let focusModeActive = false;
66
+
67
+ const TASK_ID_PATTERN = /\b(task-[a-z0-9-]+)\b/gi;
68
+
69
+ // Delta cursors for lower payload refreshes
70
+ let lastTaskSync = 0;
71
+ let lastChatSync = 0;
72
+ let lastActivitySync = 0;
73
+
74
+ // Health caching: summary each refresh, detail every 60s
75
+ let cachedHealth = null;
76
+ let lastHealthDetailSync = 0;
77
+ let refreshCount = 0;
78
+ let lastReleaseStatusSync = 0;
79
+
80
+ // Agent registry — populated from /team/roles API, with static fallback
81
+ let AGENTS = [
82
+ { name: 'ryan', emoji: '👤', role: 'Founder' },
83
+ { name: 'kai', emoji: '🤖', role: 'Lead' },
84
+ { name: 'link', emoji: '🔗', role: 'Builder' },
85
+ { name: 'sage', emoji: '🧠', role: 'Strategy' },
86
+ { name: 'rhythm', emoji: '🥁', role: 'Ops' },
87
+ { name: 'pixel', emoji: '🎨', role: 'Design' },
88
+ { name: 'echo', emoji: '📝', role: 'Content' },
89
+ { name: 'scout', emoji: '🔍', role: 'Research' },
90
+ { name: 'harmony', emoji: '🫶', role: 'Health' },
91
+ { name: 'spark', emoji: '🚀', role: 'Growth' },
92
+ ];
93
+ let AGENT_INDEX = new Map(AGENTS.map(a => [a.name, a]));
94
+
95
+ /** Fetch live agent roles (including display names) and merge into AGENT_INDEX. */
96
+ async function refreshAgentRegistry() {
97
+ try {
98
+ const res = await fetch('/team/roles');
99
+ if (!res.ok) return;
100
+ const data = await res.json();
101
+ if (!data.agents || !Array.isArray(data.agents)) return;
102
+ // Replace the registry entirely with what the server reports
103
+ AGENT_INDEX.clear();
104
+ AGENTS = [];
105
+ for (const agent of data.agents) {
106
+ const entry = {
107
+ name: agent.name,
108
+ displayName: agent.displayName || undefined,
109
+ emoji: agent.emoji || '',
110
+ role: agent.role || '',
111
+ };
112
+ AGENT_INDEX.set(agent.name, entry);
113
+ AGENTS.push(entry);
114
+ }
115
+ } catch { /* network error — use static fallback */ }
116
+ }
117
+
118
+ /** Get the display label for an agent (displayName if set, else name). */
119
+ function agentLabel(nameOrFrom) {
120
+ const agent = AGENT_INDEX.get(nameOrFrom);
121
+ return (agent && agent.displayName) ? agent.displayName : nameOrFrom;
122
+ }
123
+
124
+ const SSOT_LINKS = [
125
+ { label: 'Promotion Evidence Index', url: 'https://github.com/reflectt/reflectt-node/blob/main/docs/TASK_LINKIFY_PROMOTION_EVIDENCE_INDEX.md' },
126
+ { label: 'Promotion Day Quickstart', url: 'https://github.com/reflectt/reflectt-node/blob/main/docs/TASK_LINKIFY_PROMOTION_DAY_QUICKSTART.md' },
127
+ { label: 'Live Promotion Checklist', url: 'https://github.com/reflectt/reflectt-node/blob/main/docs/TASK_LINKIFY_LIVE_PROMOTION_CHECKLIST_FINAL.md' },
128
+ { label: 'Required-Check Runbook', url: 'https://github.com/reflectt/reflectt-node/blob/main/docs/TASK_LINKIFY_REQUIRED_CHECK_RUNBOOK.md' },
129
+ { label: 'Promotion Run-Window + Comms', url: 'https://github.com/reflectt/reflectt-node/blob/main/docs/TASK_LINKIFY_PROMOTION_RUN_WINDOW_AND_COMMS.md' },
130
+ { label: 'Promotion-Day Smoke Script', url: 'https://github.com/reflectt/reflectt-node/blob/main/tools/task-linkify-promotion-smoke.sh' },
131
+ { label: 'Rollback Drill Notes (pending)', url: null },
132
+ ];
133
+
134
+ const SSOT_INDEX_RAW_URL = 'https://raw.githubusercontent.com/reflectt/reflectt-node/main/docs/TASK_LINKIFY_PROMOTION_EVIDENCE_INDEX.md';
135
+ let ssotMetaCache = { fetchedAt: 0, lastVerifiedUtc: null };
136
+ const SSOT_META_CACHE_MS = 5 * 60 * 1000;
137
+
138
+ function ago(ts) {
139
+ const s = Math.floor((Date.now() - ts) / 1000);
140
+ if (s < 60) return s + 's';
141
+ if (s < 3600) return Math.floor(s / 60) + 'm';
142
+ if (s < 86400) return Math.floor(s / 3600) + 'h';
143
+ return Math.floor(s / 86400) + 'd';
144
+ }
145
+
146
+ function formatProductiveText(agent) {
147
+ if (!agent || !agent.lastProductiveAt) return 'No recent shipped signal';
148
+ return 'Last shipped signal: ' + ago(agent.lastProductiveAt) + ' ago';
149
+ }
150
+ function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
151
+ function formatBytes(b) { if (!b || b < 1024) return b + ' B'; if (b < 1048576) return (b/1024).toFixed(1) + ' KB'; return (b/1048576).toFixed(1) + ' MB'; }
152
+ function truncate(s, n) { return s && s.length > n ? s.slice(0, n) + '…' : (s || ''); }
153
+ function renderTaskTags(tags) {
154
+ if (!Array.isArray(tags) || tags.length === 0) return '';
155
+ const shown = tags.filter(Boolean).slice(0, 3);
156
+ if (shown.length === 0) return '';
157
+ return shown.map(tag => `<span class="assignee-tag" style="color:var(--purple)">#${esc(String(tag))}</span>`).join(' ');
158
+ }
159
+
160
+ function extractTaskPrLink(task) {
161
+ if (!task || !task.metadata || typeof task.metadata !== 'object') return null;
162
+ const metadata = task.metadata;
163
+ const candidates = [];
164
+ if (typeof metadata.pr_url === 'string') candidates.push(metadata.pr_url);
165
+ if (typeof metadata.pr_link === 'string') candidates.push(metadata.pr_link);
166
+ if (Array.isArray(metadata.artifacts)) {
167
+ metadata.artifacts.forEach(item => { if (typeof item === 'string') candidates.push(item); });
168
+ }
169
+ if (metadata.qa_bundle && typeof metadata.qa_bundle === 'object' && Array.isArray(metadata.qa_bundle.artifact_links)) {
170
+ metadata.qa_bundle.artifact_links.forEach(item => { if (typeof item === 'string') candidates.push(item); });
171
+ }
172
+
173
+ const regex = /https?:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+(?:[^\s]*)?/i;
174
+ for (const c of candidates) {
175
+ const m = String(c || '').match(regex);
176
+ if (m) return m[0];
177
+ }
178
+ return null;
179
+ }
180
+
181
+ function renderBlockedByLinks(task, options = {}) {
182
+ const ids = Array.isArray(task?.blocked_by) ? task.blocked_by.filter(Boolean) : [];
183
+ if (ids.length === 0) return '';
184
+
185
+ const compact = Boolean(options.compact);
186
+ const blockerLinks = ids.slice(0, compact ? 2 : 6).map(blockerId => {
187
+ const blocker = taskById.get(blockerId);
188
+ const label = blocker?.title ? truncate(blocker.title, compact ? 28 : 60) : blockerId;
189
+ return `<button class="assignee-tag" style="cursor:pointer" onclick="event.stopPropagation(); openTaskModal('${esc(blockerId)}')">↳ ${esc(label)}</button>`;
190
+ }).join(' ');
191
+
192
+ const extraCount = ids.length - (compact ? 2 : 6);
193
+ const extraText = extraCount > 0 ? ` <span class="assignee-tag">+${extraCount} more</span>` : '';
194
+ return `<div class="task-meta" style="margin-top:6px">⛔ blocker${ids.length > 1 ? 's' : ''}: ${blockerLinks}${extraText}</div>`;
195
+ }
196
+
197
+ function getStatusContractWarnings(task) {
198
+ if (!task || !task.status) return [];
199
+ const warnings = [];
200
+ const eta = task?.metadata?.eta;
201
+ const artifactPath = task?.metadata?.artifact_path;
202
+
203
+ if (task.status === 'doing') {
204
+ if (!task.reviewer) warnings.push('doing: missing reviewer');
205
+ if (!eta) warnings.push('doing: missing ETA');
206
+ }
207
+
208
+ if (task.status === 'validating') {
209
+ if (!artifactPath) warnings.push('validating: missing artifact_path');
210
+ }
211
+
212
+ return warnings;
213
+ }
214
+
215
+ function renderStatusContractWarning(task) {
216
+ const warnings = getStatusContractWarnings(task);
217
+ if (warnings.length === 0) return '';
218
+ return `<div style="margin-top:6px;font-size:11px;color:var(--yellow)">⚠ ${esc(warnings.join(' · '))}</div>`;
219
+ }
220
+
221
+ function renderLaneTransitionMeta(task) {
222
+ const laneState = task?.metadata?.lane_state;
223
+ const last = task?.metadata?.last_transition;
224
+ const actor = typeof last?.actor === 'string' ? last.actor : null;
225
+ const ts = typeof last?.timestamp === 'number' ? last.timestamp : null;
226
+ const type = typeof last?.type === 'string' ? last.type : null;
227
+
228
+ if (!laneState && !actor && !ts && !type) return '';
229
+
230
+ const parts = [];
231
+ if (laneState) parts.push(`lane:${laneState}`);
232
+ if (type) parts.push(type);
233
+ if (actor) parts.push(`by ${actor}`);
234
+ if (ts) parts.push(ago(ts) + ' ago');
235
+
236
+ return `<div style="margin-top:6px;font-size:11px;color:var(--text-muted)">🧭 ${esc(parts.join(' · '))}</div>`;
237
+ }
238
+
239
+ function mentionsRyan(message) { return /@ryan\b/i.test(message || ''); }
240
+
241
+ function resolveSSOTState(lastVerifiedUtc) {
242
+ if (!lastVerifiedUtc) return { state: 'unknown', label: 'unknown', text: 'verification timestamp unavailable' };
243
+ const ts = Date.parse(lastVerifiedUtc);
244
+ if (!Number.isFinite(ts)) return { state: 'unknown', label: 'unknown', text: 'verification timestamp unavailable' };
245
+
246
+ const ageMs = Date.now() - ts;
247
+ const dayMs = 24 * 60 * 60 * 1000;
248
+ if (ageMs <= dayMs) return { state: 'fresh', label: 'fresh', text: 'last verified ' + ago(ts) + ' ago' };
249
+ if (ageMs <= 3 * dayMs) return { state: 'warn', label: 'review soon', text: 'last verified ' + ago(ts) + ' ago' };
250
+ return { state: 'stale', label: 'stale evidence', text: 'last verified ' + ago(ts) + ' ago' };
251
+ }
252
+
253
+ async function fetchSSOTMeta() {
254
+ const now = Date.now();
255
+ if (now - ssotMetaCache.fetchedAt < SSOT_META_CACHE_MS) return ssotMetaCache;
256
+
257
+ try {
258
+ const response = await fetch(SSOT_INDEX_RAW_URL, { cache: 'no-store' });
259
+ if (!response.ok) throw new Error('status ' + response.status);
260
+ const text = await response.text();
261
+ const match = text.match(/^-\s*last_verified_utc:\s*(.+)$/m);
262
+ ssotMetaCache = {
263
+ fetchedAt: now,
264
+ lastVerifiedUtc: match ? match[1].trim() : null,
265
+ };
266
+ } catch {
267
+ ssotMetaCache = {
268
+ fetchedAt: now,
269
+ lastVerifiedUtc: null,
270
+ };
271
+ }
272
+
273
+ return ssotMetaCache;
274
+ }
275
+
276
+ async function renderPromotionSSOT() {
277
+ const body = document.getElementById('ssot-body');
278
+ const count = document.getElementById('ssot-count');
279
+ if (!body || !count) return;
280
+
281
+ const available = SSOT_LINKS.filter(item => Boolean(item.url));
282
+ count.textContent = available.length + '/' + SSOT_LINKS.length + ' links';
283
+
284
+ const meta = await fetchSSOTMeta();
285
+ const state = resolveSSOTState(meta.lastVerifiedUtc);
286
+
287
+ const metaHtml = '<div class="ssot-meta">'
288
+ + '<span class="ssot-meta-text">' + esc(state.text) + '</span>'
289
+ + '<span class="ssot-state-badge ' + state.state + '" aria-label="verification state ' + esc(state.label) + '">' + esc(state.label) + '</span>'
290
+ + '</div>';
291
+
292
+ body.innerHTML = metaHtml + '<div class="ssot-list">' + SSOT_LINKS.map(item => {
293
+ const missing = !item.url;
294
+ const action = missing
295
+ ? '<span class="ssot-missing" aria-label="missing target">missing</span>'
296
+ : '<a class="ssot-link" href="' + esc(item.url) + '" target="_blank" rel="noreferrer noopener" aria-label="Open ' + esc(item.label) + '">Open</a>';
297
+ return '<div class="ssot-item"><span class="ssot-item-label">' + esc(item.label) + '</span>' + action + '</div>';
298
+ }).join('') + '</div>';
299
+ }
300
+
301
+ function isTaskTokenInsideUrl(text, start, end) {
302
+ let segStart = start;
303
+ while (segStart > 0 && !/\s/.test(text[segStart - 1])) segStart -= 1;
304
+ let segEnd = end;
305
+ while (segEnd < text.length && !/\s/.test(text[segEnd])) segEnd += 1;
306
+ const tokenSegment = text.slice(segStart, segEnd);
307
+ return /^(https?:\/\/|www\.)/i.test(tokenSegment);
308
+ }
309
+
310
+ function isTaskTokenLinkable(text, start, end) {
311
+ const leftOk = start === 0 || /[^A-Za-z0-9_]/.test(text[start - 1]);
312
+ const rightOk = end >= text.length || /[^A-Za-z0-9_]/.test(text[end]);
313
+ if (!leftOk || !rightOk) return false;
314
+ if (isTaskTokenInsideUrl(text, start, end)) return false;
315
+ return true;
316
+ }
317
+
318
+ function renderMessageContentWithTaskLinks(content) {
319
+ const text = typeof content === 'string' ? content : '';
320
+ if (!text) return '';
321
+
322
+ let html = '';
323
+ let cursor = 0;
324
+ TASK_ID_PATTERN.lastIndex = 0;
325
+
326
+ let match;
327
+ while ((match = TASK_ID_PATTERN.exec(text)) !== null) {
328
+ const taskId = match[1];
329
+ const start = match.index;
330
+ const end = start + taskId.length;
331
+
332
+ html += esc(text.slice(cursor, start));
333
+
334
+ if (isTaskTokenLinkable(text, start, end)) {
335
+ const task = taskById.get(taskId);
336
+ const linkText = task ? (task.title + ' (' + taskId + ')') : taskId;
337
+ const tooltip = task
338
+ ? '<span class="task-preview-tooltip"><span class="tp-title">' + esc(task.title) + '</span><span class="tp-meta">' + esc(task.status || '?') + ' · ' + esc(task.assignee || '?') + '</span></span>'
339
+ : '<span class="task-preview-tooltip"><span class="tp-title">' + esc(taskId) + '</span><span class="tp-meta">task not found</span></span>';
340
+ html += '<a href="#" class="task-id-link" data-task-id="' + esc(taskId) + '" style="position:relative">' + esc(linkText) + tooltip + '</a>';
341
+ } else {
342
+ html += esc(taskId);
343
+ }
344
+
345
+ cursor = end;
346
+ }
347
+
348
+ html += esc(text.slice(cursor));
349
+ return html;
350
+ }
351
+
352
+ function toggleMessageContent(el) {
353
+ if (!el || el.dataset.collapsible !== 'true') return;
354
+ el.classList.toggle('collapsed');
355
+ el.classList.toggle('expanded');
356
+ }
357
+
358
+ function bindTaskLinkHandlers(el) {
359
+ if (!el || el.dataset.taskLinkBound === 'true') return;
360
+
361
+ el.addEventListener('click', (event) => {
362
+ const link = event.target && event.target.closest ? event.target.closest('.task-id-link') : null;
363
+ if (link) {
364
+ event.preventDefault();
365
+ event.stopPropagation();
366
+ openTaskModal(link.dataset.taskId || '');
367
+ }
368
+ });
369
+
370
+ el.addEventListener('keydown', (event) => {
371
+ const link = event.target && event.target.closest ? event.target.closest('.task-id-link') : null;
372
+ if (!link) return;
373
+ if (event.key === 'Enter' || event.key === ' ') {
374
+ event.preventDefault();
375
+ openTaskModal(link.dataset.taskId || '');
376
+ }
377
+ });
378
+
379
+ el.dataset.taskLinkBound = 'true';
380
+ }
381
+
382
+ function initChatInteractions() {
383
+ const body = document.getElementById('chat-body');
384
+ if (!body || body.dataset.taskLinkBound === 'true') return;
385
+
386
+ body.addEventListener('click', (event) => {
387
+ const link = event.target && event.target.closest ? event.target.closest('.task-id-link') : null;
388
+ if (link) {
389
+ event.preventDefault();
390
+ event.stopPropagation();
391
+ openTaskModal(link.dataset.taskId || '');
392
+ return;
393
+ }
394
+
395
+ const contentEl = event.target && event.target.closest ? event.target.closest('.msg-content') : null;
396
+ if (contentEl) toggleMessageContent(contentEl);
397
+ });
398
+
399
+ body.addEventListener('keydown', (event) => {
400
+ const link = event.target && event.target.closest ? event.target.closest('.task-id-link') : null;
401
+ if (!link) return;
402
+ if (event.key === 'Enter' || event.key === ' ') {
403
+ event.preventDefault();
404
+ openTaskModal(link.dataset.taskId || '');
405
+ }
406
+ });
407
+
408
+ body.dataset.taskLinkBound = 'true';
409
+ }
410
+
411
+ function initComplianceInteractions() {
412
+ bindTaskLinkHandlers(document.getElementById('compliance-body'));
413
+ }
414
+
415
+ function complianceState(value, threshold) {
416
+ if (value > threshold) return 'violation';
417
+ if (value >= Math.max(0, threshold - 10)) return 'warning';
418
+ return 'ok';
419
+ }
420
+
421
+ /** Format minutes into human-readable duration, capping extreme values */
422
+ function formatDurationMin(min) {
423
+ if (min == null || min < 0) return '—';
424
+ if (min >= 1440) return Math.floor(min / 1440) + 'd';
425
+ if (min >= 120) return Math.floor(min / 60) + 'h';
426
+ return min + 'm';
427
+ }
428
+
429
+ function statusTemplateFor(agent, taskId) {
430
+ const mentions = agent === 'pixel'
431
+ ? '@kai @link'
432
+ : agent === 'link'
433
+ ? '@kai @pixel'
434
+ : agent === 'kai'
435
+ ? '@link @pixel'
436
+ : '@kai @pixel';
437
+ return [
438
+ mentions,
439
+ 'Task: ' + (taskId || '<task-id>'),
440
+ '1) Shipped: <artifact/commit/file>',
441
+ '2) Blocker: <none or explicit blocker>',
442
+ '3) Next: <next deliverable + ETA>',
443
+ ].join('\n');
444
+ }
445
+
446
+ async function copyStatusTemplate(agent, taskId) {
447
+ const text = statusTemplateFor(agent, taskId);
448
+ try {
449
+ await navigator.clipboard.writeText(text);
450
+ } catch {
451
+ const ta = document.createElement('textarea');
452
+ ta.value = text;
453
+ document.body.appendChild(ta);
454
+ ta.select();
455
+ document.execCommand('copy');
456
+ document.body.removeChild(ta);
457
+ }
458
+ }
459
+
460
+ function renderCompliance(compliance) {
461
+ const body = document.getElementById('compliance-body');
462
+ const count = document.getElementById('compliance-count');
463
+
464
+ if (!compliance) {
465
+ count.textContent = 'no data';
466
+ body.innerHTML = '<div class="empty">No compliance data available</div>';
467
+ return;
468
+ }
469
+
470
+ const s = compliance.summary || {};
471
+ const chips = [
472
+ { label: 'Working updates <= 45m', value: s.workerWorstAgeMin || 0, threshold: s.workerCadenceMaxMin || 45 },
473
+ { label: 'Lead watchdog <= 60m', value: s.leadAgeMin || 0, threshold: s.leadCadenceMaxMin || 60 },
474
+ { label: 'Blocked unresolved > 20m', value: s.oldestBlockerMin || 0, threshold: s.blockedEscalationMin || 20 },
475
+ { label: 'Trio silence <= 60m', value: s.trioSilenceMin || 0, threshold: s.trioSilenceMaxMin || 60 },
476
+ ];
477
+
478
+ const agents = compliance.agents || [];
479
+ const incidents = compliance.incidents || [];
480
+ count.textContent = incidents.length + ' incident' + (incidents.length === 1 ? '' : 's');
481
+
482
+ const chipsHtml = chips.map(c => {
483
+ const state = complianceState(c.value, c.threshold);
484
+ return '<div class="sla-chip ' + state + '"><span>' + esc(c.label) + '</span><strong>' + formatDurationMin(c.value) + '</strong></div>';
485
+ }).join('');
486
+
487
+ const rows = agents.map(a => {
488
+ const taskValue = a.taskId || '';
489
+ const taskCell = taskValue ? renderMessageContentWithTaskLinks(taskValue) : '—';
490
+ return '<tr>' +
491
+ '<td>' + esc(a.agent) + '</td>' +
492
+ '<td>' + taskCell + '</td>' +
493
+ '<td>' + formatDurationMin(a.lastValidStatusAgeMin) + '</td>' +
494
+ '<td>' + a.expectedCadenceMin + 'm</td>' +
495
+ '<td><span class="state-pill ' + a.state + ' compliance-state-' + a.state + '">' + esc(a.state) + '</span></td>' +
496
+ '<td><button class="copy-template-btn" data-agent="' + esc(a.agent) + '" data-task="' + esc(taskValue) + '" onclick="copyStatusTemplate(this.dataset.agent, this.dataset.task)">Copy template</button></td>' +
497
+ '</tr>';
498
+ }).join('');
499
+
500
+ const incidentsHtml = incidents.length > 0
501
+ ? incidents.map(i => '<div class="incident-item"><div class="incident-type">' + esc(i.type) + '</div><div>@' + esc(i.agent) + ' • ' + esc(i.taskId || 'no-task') + ' • ' + i.minutesOver + 'm over • escalate ' + esc((i.escalateTo || []).map(function(a){ return '@' + a; }).join(' ')) + '</div></div>').join('')
502
+ : '<div class="empty">No active compliance incidents</div>';
503
+
504
+ const linkRow = agents.find(function(a){ return a.agent === 'link'; });
505
+ const linkTemplate = statusTemplateFor('link', (linkRow && linkRow.taskId) || '<task-id>');
506
+
507
+ body.innerHTML =
508
+ '<div class="compliance-summary">' + chipsHtml + '</div>' +
509
+ '<table class="compliance-table">' +
510
+ '<thead><tr><th>Agent</th><th>Task</th><th>Last status age</th><th>Cadence</th><th>State</th><th>Action</th></tr></thead>' +
511
+ '<tbody>' + (rows || '<tr><td colspan="6" class="empty">No agent compliance data</td></tr>') + '</tbody>' +
512
+ '</table>' +
513
+ '<div class="health-section-title">Incident Queue</div>' +
514
+ incidentsHtml +
515
+ '<div class="health-section-title" style="margin-top:10px;">Status Template</div>' +
516
+ '<div class="template-box">' + esc(linkTemplate) + '</div>';
517
+ }
518
+
519
+ function renderIdleNudgeSummary(idleNudgeDebug) {
520
+ if (!idleNudgeDebug || !idleNudgeDebug.summary) {
521
+ return '<div class="health-section"><div class="health-section-title">🔕 Idle Nudge Summary</div><div class="empty">No idle-nudge summary available</div></div>';
522
+ }
523
+
524
+ const reasonCounts = idleNudgeDebug.summary.reasonCounts || {};
525
+ const suppressedReasons = ['recent-activity-suppressed', 'validating-task-suppressed', 'missing-active-task'];
526
+ const rows = suppressedReasons
527
+ .map(reason => ({ reason, count: Number(reasonCounts[reason] || 0) }))
528
+ .filter(row => row.count > 0);
529
+
530
+ const totalSuppressed = rows.reduce((sum, row) => sum + row.count, 0);
531
+ const totalNudged = Number((idleNudgeDebug.summary.decisionCounts || {}).warn || 0) + Number((idleNudgeDebug.summary.decisionCounts || {}).escalate || 0);
532
+
533
+ const detail = rows.length > 0
534
+ ? rows.map(row => `<div class="event-row"><span class="event-type">suppressed</span><span class="event-desc">${esc(row.reason)}: ${row.count}</span></div>`).join('')
535
+ : '<div class="empty">No suppressions in latest tick</div>';
536
+
537
+ return `<div class="health-section"><div class="health-section-title">🔕 Idle Nudge Summary</div><div class="event-row"><span class="event-type">nudged</span><span class="event-desc">warn/escalate: ${totalNudged}</span></div><div class="event-row"><span class="event-type">suppressed</span><span class="event-desc">total: ${totalSuppressed}</span></div>${detail}</div>`;
538
+ }
539
+
540
+ function deriveHealthSignal(agent) {
541
+ if (agent.status !== 'blocked') return { status: agent.status, lowConfidence: false };
542
+
543
+ const blockers = agent.recentBlockers || [];
544
+ if (blockers.length === 0) return { status: 'blocked', lowConfidence: false };
545
+
546
+ const likelyNoise = blockers.some(b => /no blockers?|unblocked|not blocked|blocked-state|blocker tracking|false.?alarm|status update|dashboard/i.test(b));
547
+ if (likelyNoise || blockers.length === 1) {
548
+ return { status: agent.minutesSinceLastSeen >= 60 ? 'silent' : 'watch', lowConfidence: true };
549
+ }
550
+
551
+ return { status: 'blocked', lowConfidence: false };
552
+ }
553
+
554
+ function healthPriorityRank(agent) {
555
+ if (agent.idleWithActiveTask || agent.displayStatus === 'blocked') return 0;
556
+ if (agent.displayStatus === 'silent' || agent.displayStatus === 'watch' || agent.lowConfidence) return 1;
557
+ return 2;
558
+ }
559
+
560
+ function classifyProject(task) {
561
+ const text = ((task.title || '') + ' ' + (task.description || '')).toLowerCase();
562
+ if (/dashboard|reflectt-node|api|mcp|sse|persistence|event|server|cli|node/.test(text)) return 'reflectt-node';
563
+ if (/foragents|getting.?started|skills|directory|agents\\.dev/.test(text)) return 'forAgents.dev';
564
+ if (/heartbeat|health|roles|team|ops|cleanup|agent.?roles|monitoring|deploy/.test(text)) return 'Team Ops';
565
+ return 'Other';
566
+ }
567
+
568
+ // ---- Presence ----
569
+ async function loadPresence() {
570
+ let presenceMap = {};
571
+ try {
572
+ const r = await fetch(BASE + '/presence');
573
+ const d = await r.json();
574
+ const list = d.presences || {};
575
+ if (Array.isArray(list)) list.forEach(p => { presenceMap[p.agent] = p; });
576
+ else Object.entries(list).forEach(([k, p]) => { presenceMap[k] = p; });
577
+ } catch (e) {}
578
+
579
+ const agentTasks = {};
580
+ allTasks.filter(t => t.status === 'doing').forEach(t => {
581
+ if (t.assignee) agentTasks[t.assignee] = t.title;
582
+ });
583
+
584
+ const strip = document.getElementById('agent-strip');
585
+ // Build dynamic agent list from registry + presence (not hardcoded AGENTS)
586
+ const registeredAgents = Array.from(AGENT_INDEX.values());
587
+ // Add any agents from presence not already in registry
588
+ Object.keys(presenceMap).forEach(name => {
589
+ if (!AGENT_INDEX.has(name)) {
590
+ registeredAgents.push({ name, emoji: '🤖', role: '' });
591
+ }
592
+ });
593
+ // Add any agents with active tasks not already listed
594
+ Object.keys(agentTasks).forEach(name => {
595
+ if (!AGENT_INDEX.has(name) && !presenceMap[name]) {
596
+ registeredAgents.push({ name, emoji: '🤖', role: '' });
597
+ }
598
+ });
599
+ strip.innerHTML = registeredAgents.map(a => {
600
+ const p = presenceMap[a.name];
601
+ const taskTitle = agentTasks[a.name];
602
+ const healthRow = healthAgentMap.get(a.name);
603
+ const activeTaskTitle = healthRow?.activeTaskTitle || healthRow?.currentTask || taskTitle || '';
604
+ const activeTaskId = healthRow?.activeTaskId || null;
605
+ const activeTaskPr = healthRow?.activeTaskPrLink || null;
606
+
607
+ const isActive = p && p.status && p.status !== 'offline';
608
+ const isWorking = Boolean(activeTaskTitle);
609
+ const statusClass = isWorking ? 'active' : (isActive ? 'idle' : 'offline');
610
+ const badgeClass = isWorking ? 'working' : (isActive ? 'idle' : 'offline');
611
+ const badgeText = isWorking ? 'Working' : (isActive ? 'Idle' : 'Offline');
612
+
613
+ const lastSeenText = (p && p.lastUpdate) ? ago(p.lastUpdate) + ' ago' : '';
614
+ const taskText = activeTaskTitle ? truncate(activeTaskTitle, 40) : lastSeenText;
615
+ const prHtml = activeTaskPr
616
+ ? `<a class="agent-pr-link" href="${esc(activeTaskPr)}" target="_blank" rel="noreferrer noopener" onclick="event.stopPropagation()">PR ↗</a>`
617
+ : '';
618
+ const taskIdHtml = activeTaskId ? `<span class="assignee-tag" style="margin-left:4px">${esc(activeTaskId.slice(0, 12))}</span>` : '';
619
+
620
+ return `<div class="agent-card ${statusClass}">
621
+ <img src="/avatars/${a.name}.png" alt="${a.emoji}" class="agent-avatar" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline';">
622
+ <span class="agent-emoji" style="display:none;">${a.emoji}</span>
623
+ <div class="agent-info">
624
+ <div class="agent-role">${esc(a.role)}</div>
625
+ <div class="agent-name">${esc(a.name)}</div>
626
+ <div class="agent-status-text">${esc(taskText)} ${taskIdHtml}</div>
627
+ ${prHtml}
628
+ </div>
629
+ <span class="agent-badge ${badgeClass}">${badgeText}</span>
630
+ </div>`;
631
+ }).join('');
632
+
633
+ // Toggle scroll-fade indicator on agent strip wrapper
634
+ const wrapper = document.getElementById('agent-strip-wrapper');
635
+ if (wrapper && strip) {
636
+ const hasOverflow = strip.scrollWidth > strip.clientWidth;
637
+ wrapper.classList.toggle('has-overflow', hasOverflow);
638
+ // Update on scroll (hide fade when scrolled to end)
639
+ if (!strip._overflowListener) {
640
+ strip._overflowListener = true;
641
+ strip.addEventListener('scroll', () => {
642
+ const atEnd = strip.scrollLeft + strip.clientWidth >= strip.scrollWidth - 8;
643
+ wrapper.classList.toggle('has-overflow', !atEnd && strip.scrollWidth > strip.clientWidth);
644
+ }, { passive: true });
645
+ }
646
+ }
647
+ }
648
+
649
+ // ---- Tasks ----
650
+ async function loadTasks(forceFull = false) {
651
+ try {
652
+ const useDelta = !forceFull && lastTaskSync > 0;
653
+ const qs = new URLSearchParams();
654
+ qs.set('limit', '80');
655
+ if (useDelta) qs.set('updatedSince', String(lastTaskSync));
656
+
657
+ const r = await fetch(BASE + '/tasks?' + qs.toString());
658
+ const d = await r.json();
659
+ const incoming = d.tasks || [];
660
+
661
+ if (useDelta && incoming.length === 0) {
662
+ return;
663
+ }
664
+
665
+ if (useDelta) {
666
+ const byId = new Map(allTasks.map(t => [t.id, t]));
667
+ incoming.forEach(t => byId.set(t.id, t));
668
+ allTasks = Array.from(byId.values());
669
+ } else {
670
+ allTasks = incoming;
671
+ }
672
+
673
+ const maxUpdated = incoming.reduce((max, t) => Math.max(max, t.updatedAt || 0), 0);
674
+ if (maxUpdated > 0) lastTaskSync = Math.max(lastTaskSync, maxUpdated);
675
+ } catch (e) {
676
+ if (!allTasks.length) allTasks = [];
677
+ }
678
+
679
+ taskById = new Map();
680
+ allTasks.forEach(task => {
681
+ if (task && task.id) taskById.set(task.id, task);
682
+ });
683
+
684
+ renderProjectTabs();
685
+ renderStatusFilterTabs();
686
+ renderKanban();
687
+ renderBacklog();
688
+ renderOutcomeFeed();
689
+ const visibleTasks = getVisibleTasks();
690
+ const openCount = visibleTasks.filter(t => t.status !== 'done').length;
691
+ document.getElementById('task-count').textContent = currentStatusFilter === 'open'
692
+ ? openCount + ' open tasks'
693
+ : visibleTasks.length + ' tasks';
694
+ // Update sidebar badge
695
+ const navTaskBadge = document.getElementById('nav-task-count');
696
+ if (navTaskBadge) navTaskBadge.textContent = allTasks.length;
697
+ }
698
+
699
+ function renderProjectTabs() {
700
+ const projects = ['All', 'reflectt-node', 'forAgents.dev', 'Team Ops', 'Other'];
701
+ const icons = { 'All': '📋', 'reflectt-node': '🔧', 'forAgents.dev': '🌐', 'Team Ops': '🏢', 'Other': '📦' };
702
+ const tabs = document.getElementById('project-tabs');
703
+ tabs.innerHTML = projects.map(p => {
704
+ const key = p === 'All' ? 'all' : p;
705
+ return `<button class="project-tab ${currentProject === key ? 'active' : ''}" onclick="switchProject('${key}')">${icons[p] || ''} ${p}</button>`;
706
+ }).join('');
707
+ }
708
+ function switchProject(p) { currentProject = p; renderProjectTabs(); renderKanban(); }
709
+ function switchStatusFilter(f) {
710
+ currentStatusFilter = f;
711
+ localStorage.setItem('taskStatusFilter', f);
712
+ renderStatusFilterTabs();
713
+ renderKanban();
714
+ }
715
+ function toggleTestTasks() {
716
+ hideTestTasks = !hideTestTasks;
717
+ localStorage.setItem('hideTestTasks', String(!hideTestTasks));
718
+ renderStatusFilterTabs();
719
+ renderKanban();
720
+ // Update task count
721
+ const openCount = getVisibleTasks().filter(t => t.status !== 'done').length;
722
+ document.getElementById('task-count').textContent = currentStatusFilter === 'open'
723
+ ? openCount + ' open tasks'
724
+ : getVisibleTasks().length + ' tasks';
725
+ }
726
+ function isTestTask(t) {
727
+ const title = (t.title || '').trim();
728
+ if (/^TEST[:\s]/i.test(title)) return true;
729
+ if (/^tmp\b/i.test(title)) return true;
730
+ const mtype = (t.metadata?.type || '').toLowerCase();
731
+ if (mtype === 'test' || mtype === 'synthetic') return true;
732
+ return false;
733
+ }
734
+ function getVisibleTasks() {
735
+ return hideTestTasks ? allTasks.filter(t => !isTestTask(t)) : allTasks;
736
+ }
737
+
738
+ function renderStatusFilterTabs() {
739
+ const container = document.getElementById('status-filter-tabs');
740
+ if (!container) return;
741
+ const options = [
742
+ { key: 'open', label: '🟢 Open', title: 'Todo, Doing, Blocked, Validating' },
743
+ { key: 'all', label: '📋 All', title: 'All statuses including Done' },
744
+ ];
745
+ const testCount = allTasks.filter(t => isTestTask(t)).length;
746
+ const testToggle = testCount > 0
747
+ ? ` <button class="project-tab ${hideTestTasks ? '' : 'active'}" title="${hideTestTasks ? 'Show' : 'Hide'} ${testCount} test/synthetic tasks" onclick="toggleTestTasks()" style="font-size:10px;opacity:${hideTestTasks ? '0.6' : '1'}">🧪 ${hideTestTasks ? 'Show' : 'Hide'} test (${testCount})</button>`
748
+ : '';
749
+ container.innerHTML = options.map(o =>
750
+ `<button class="project-tab ${currentStatusFilter === o.key ? 'active' : ''}" title="${o.title}" onclick="switchStatusFilter('${o.key}')">${o.label}</button>`
751
+ ).join('') + testToggle;
752
+ }
753
+
754
+ function renderKanban() {
755
+ const visible = getVisibleTasks();
756
+ const filtered = currentProject === 'all' ? visible : visible.filter(t => classifyProject(t) === currentProject);
757
+ const cols = currentStatusFilter === 'open'
758
+ ? ['todo', 'doing', 'blocked', 'validating']
759
+ : ['todo', 'doing', 'blocked', 'validating', 'done'];
760
+ const grouped = {}; cols.forEach(c => grouped[c] = []);
761
+ filtered.forEach(t => { const s = t.status || 'todo'; if (grouped[s]) grouped[s].push(t); else grouped['todo'].push(t); });
762
+ const pOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
763
+ cols.forEach(c => grouped[c].sort((a, b) => (pOrder[a.priority] ?? 9) - (pOrder[b.priority] ?? 9)));
764
+
765
+ const kanban = document.getElementById('kanban');
766
+ kanban.innerHTML = cols.map(col => {
767
+ const items = grouped[col];
768
+ const isDone = col === 'done';
769
+ const shown = isDone ? items.slice(0, 3) : items;
770
+ const cards = shown.length === 0
771
+ ? '<div class="empty">—</div>'
772
+ : shown.map(t => {
773
+ const assigneeAgent = t.assignee ? AGENTS.find(a => a.name === t.assignee) : null;
774
+ const assigneeDisplay = t.assignee
775
+ ? `<span class="assignee-tag">👤 ${esc(t.assignee)}${assigneeAgent ? ' <span class="role-small">' + esc(assigneeAgent.role) + '</span>' : ''}</span>`
776
+ : '<span class="assignee-tag" style="color:var(--yellow)">unassigned</span>';
777
+ const branchDisplay = t.metadata?.branch && t.status === 'doing'
778
+ ? `<div style="margin-top:4px"><span class="assignee-tag" style="font-family:monospace;font-size:10px;color:var(--accent)">🌿 ${esc(t.metadata.branch)}</span></div>`
779
+ : '';
780
+ return `
781
+ <div class="task-card" data-task-id="${t.id}">
782
+ <div class="task-title">${esc(truncate(t.title, 60))}</div>
783
+ <div class="task-meta">
784
+ ${t.priority ? '<span class="priority-badge ' + t.priority + '">' + t.priority + '</span>' : ''}
785
+ ${assigneeDisplay}
786
+ ${(t.commentCount || 0) > 0 ? '<span class="assignee-tag">💬 ' + t.commentCount + '</span>' : ''}
787
+ ${renderTaskTags(t.tags)}
788
+ </div>
789
+ ${branchDisplay}
790
+ ${renderBlockedByLinks(t, { compact: true })}
791
+ ${renderStatusContractWarning(t)}
792
+ ${renderLaneTransitionMeta(t)}
793
+ ${renderQaContract(t)}
794
+ </div>`;
795
+ }).join('');
796
+ const extra = isDone && items.length > 3
797
+ ? `<button class="done-toggle" onclick="this.parentElement.querySelectorAll('.task-card.hidden').forEach(c=>c.classList.remove('hidden'));this.remove()">+ ${items.length - 3} more</button>` : '';
798
+ return `<div class="kanban-col" data-status="${col}">
799
+ <div class="kanban-col-header">${col} <span class="cnt">${items.length}</span></div>
800
+ ${cards}${extra}
801
+ </div>`;
802
+ }).join('');
803
+
804
+ // Add click/touch handlers for task cards (mobile-friendly)
805
+ setTimeout(() => {
806
+ document.querySelectorAll('.task-card').forEach(card => {
807
+ const taskId = card.getAttribute('data-task-id');
808
+ if (taskId) {
809
+ card.addEventListener('click', (e) => {
810
+ e.preventDefault();
811
+ e.stopPropagation();
812
+ openTaskModal(taskId);
813
+ });
814
+ }
815
+ });
816
+ }, 0);
817
+ }
818
+
819
+ // ---- Backlog (Available Work) ----
820
+ function renderBacklog() {
821
+ const panel = document.getElementById('backlog-panel');
822
+ const body = document.getElementById('backlog-body');
823
+ const count = document.getElementById('backlog-count');
824
+ if (!body || !panel) return;
825
+
826
+ const pOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
827
+ const backlog = getVisibleTasks()
828
+ .filter(t => t.status === 'todo' && !t.assignee)
829
+ .sort((a, b) => {
830
+ const pa = pOrder[a.priority] ?? 9;
831
+ const pb = pOrder[b.priority] ?? 9;
832
+ if (pa !== pb) return pa - pb;
833
+ return a.createdAt - b.createdAt;
834
+ });
835
+
836
+ if (backlog.length === 0) {
837
+ panel.style.display = 'none';
838
+ if (count) count.textContent = '0 items';
839
+ body.innerHTML = '';
840
+ return;
841
+ }
842
+
843
+ panel.style.display = '';
844
+ if (count) count.textContent = backlog.length + ' items';
845
+
846
+ body.innerHTML = backlog.map(t => {
847
+ const criteriaList = Array.isArray(t.done_criteria) ? t.done_criteria : [];
848
+ const criteriaCount = criteriaList.length;
849
+ const criteriaPreview = criteriaCount > 0 ? esc(truncate(criteriaList[0], 72)) : 'No done criteria listed';
850
+
851
+ return `<div class="backlog-item" role="button" tabindex="0" style="padding:10px 14px;border-bottom:1px solid var(--border-subtle);cursor:pointer" onclick="openTaskModal('${t.id}')">
852
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
853
+ ${t.priority ? '<span class="priority-badge ' + t.priority + '">' + t.priority + '</span>' : ''}
854
+ <span style="color:var(--text-bright);font-size:13px;font-weight:500">${esc(truncate(t.title, 70))}</span>
855
+ </div>
856
+ <div style="font-size:11px;color:var(--text-muted)">
857
+ ${criteriaCount} done criteria${t.reviewer ? ' · reviewer: ' + esc(t.reviewer) : ''}${(t.commentCount || 0) > 0 ? ' · 💬 ' + t.commentCount : ''}
858
+ </div>
859
+ ${Array.isArray(t.tags) && t.tags.length > 0 ? `<div style="margin-top:4px;display:flex;flex-wrap:wrap;gap:6px">${renderTaskTags(t.tags)}</div>` : ''}
860
+ ${renderStatusContractWarning(t)}
861
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:6px">
862
+ <div style="font-size:11px;color:var(--text-dim)">↳ ${criteriaPreview}</div>
863
+ <button onclick="claimBacklogTask('${t.id}', event)" style="background:var(--accent);border:0;border-radius:8px;color:white;font-size:11px;padding:4px 9px;cursor:pointer;white-space:nowrap">Claim</button>
864
+ </div>
865
+ </div>`;
866
+ }).join('');
867
+ }
868
+
869
+ async function claimBacklogTask(taskId, event) {
870
+ if (event) {
871
+ event.preventDefault();
872
+ event.stopPropagation();
873
+ }
874
+
875
+ const defaultAgent = localStorage.getItem('reflectt-dashboard-agent') || 'scout';
876
+ const agent = (window.prompt('Claim this task as which agent?', defaultAgent) || '').trim().toLowerCase();
877
+ if (!agent) return;
878
+
879
+ localStorage.setItem('reflectt-dashboard-agent', agent);
880
+
881
+ try {
882
+ const r = await fetch(`${BASE}/tasks/${taskId}/claim`, {
883
+ method: 'POST',
884
+ headers: { 'Content-Type': 'application/json' },
885
+ body: JSON.stringify({ agent }),
886
+ });
887
+ const d = await r.json();
888
+ if (!d.success) {
889
+ alert(d.error || 'Failed to claim task');
890
+ return;
891
+ }
892
+ await loadTasks(true);
893
+ } catch (err) {
894
+ console.error('Claim failed:', err);
895
+ alert('Failed to claim task');
896
+ }
897
+ }
898
+
899
+ function resolveOutcomeImpact(task) {
900
+ const priority = String(task?.priority || 'P3').toUpperCase();
901
+ const outcome = task?.metadata?.outcome_checkpoint || {};
902
+ const verdict = String(outcome.verdict || '').toUpperCase();
903
+
904
+ if (verdict === 'FAIL' || verdict === 'BLOCKED' || priority === 'P0') return 'high';
905
+ if (priority === 'P1' || priority === 'P2') return 'medium';
906
+ return 'low';
907
+ }
908
+
909
+ function taskHasShippedProof(task) {
910
+ const metadata = task?.metadata || {};
911
+ const artifacts = Array.isArray(metadata.artifacts) ? metadata.artifacts : [];
912
+ const qaBundle = metadata.qa_bundle;
913
+ return artifacts.length > 0 || Boolean(metadata.artifact_path) || Boolean(qaBundle);
914
+ }
915
+
916
+ function renderOutcomeFeed() {
917
+ const body = document.getElementById('outcome-body');
918
+ const count = document.getElementById('outcome-count');
919
+ if (!body || !count) return;
920
+
921
+ const shippedDone = allTasks
922
+ .filter(task => task.status === 'done' && taskHasShippedProof(task))
923
+ .sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
924
+
925
+ count.textContent = shippedDone.length + ' shipped';
926
+
927
+ if (shippedDone.length === 0) {
928
+ body.innerHTML = '<div class="empty">No shipped outcomes yet</div>';
929
+ return;
930
+ }
931
+
932
+ const rollup = { high: 0, medium: 0, low: 0 };
933
+ shippedDone.forEach(task => {
934
+ const impact = resolveOutcomeImpact(task);
935
+ rollup[impact] += 1;
936
+ });
937
+
938
+ const itemsHtml = shippedDone.slice(0, 8).map(task => {
939
+ const impact = resolveOutcomeImpact(task);
940
+ const outcome = task?.metadata?.outcome_checkpoint || {};
941
+ const verdict = outcome.verdict ? String(outcome.verdict).toUpperCase() : 'N/A';
942
+ const artifactPath = task?.metadata?.artifact_path;
943
+ const artifactLink = typeof artifactPath === 'string' && artifactPath.startsWith('http')
944
+ ? `<a class="ssot-link" href="${esc(artifactPath)}" target="_blank" rel="noreferrer noopener">artifact</a>`
945
+ : (artifactPath ? `<span>${esc(truncate(String(artifactPath), 42))}</span>` : '<span>no artifact link</span>');
946
+
947
+ return `<div class="outcome-item">
948
+ <div class="outcome-item-title">${esc(truncate(task.title || task.id, 78))}</div>
949
+ <div class="outcome-item-meta">
950
+ <span class="outcome-impact-pill ${impact}">${impact}</span>
951
+ <span>${esc(task.priority || 'P3')}</span>
952
+ <span>verdict: ${esc(verdict)}</span>
953
+ <span>by @${esc(task.assignee || 'unknown')}</span>
954
+ <span>${ago(task.updatedAt || task.createdAt || Date.now())} ago</span>
955
+ </div>
956
+ <div class="outcome-item-meta">${artifactLink}</div>
957
+ </div>`;
958
+ }).join('');
959
+
960
+ body.innerHTML = `
961
+ <div class="outcome-rollup">
962
+ <div class="outcome-rollup-card high"><div class="label">high impact</div><div class="value">${rollup.high}</div></div>
963
+ <div class="outcome-rollup-card medium"><div class="label">medium impact</div><div class="value">${rollup.medium}</div></div>
964
+ <div class="outcome-rollup-card low"><div class="label">low impact</div><div class="value">${rollup.low}</div></div>
965
+ </div>
966
+ ${itemsHtml}
967
+ `;
968
+ }
969
+
970
+ // ---- Chat ----
971
+ async function loadChat(forceFull = false) {
972
+ try {
973
+ const qs = new URLSearchParams();
974
+ qs.set('limit', '80');
975
+ if (!forceFull && lastChatSync > 0) qs.set('since', String(lastChatSync));
976
+
977
+ const r = await fetch(BASE + '/chat/messages?' + qs.toString());
978
+ const d = await r.json();
979
+ const incoming = d.messages || [];
980
+
981
+ if (!forceFull && lastChatSync > 0 && incoming.length === 0) {
982
+ return;
983
+ }
984
+
985
+ if (!forceFull && lastChatSync > 0) {
986
+ const byId = new Map(allMessages.map(m => [m.id, m]));
987
+ incoming.forEach(m => byId.set(m.id, m));
988
+ allMessages = Array.from(byId.values())
989
+ .sort((a, b) => b.timestamp - a.timestamp)
990
+ .slice(0, 200);
991
+ } else {
992
+ allMessages = incoming.sort((a, b) => b.timestamp - a.timestamp);
993
+ }
994
+
995
+ const maxTs = incoming.reduce((max, m) => Math.max(max, m.timestamp || 0), 0);
996
+ if (maxTs > 0) lastChatSync = Math.max(lastChatSync, maxTs);
997
+ } catch (e) {
998
+ if (!allMessages.length) allMessages = [];
999
+ }
1000
+ const channels = new Set(['all']);
1001
+ allMessages.forEach(m => { if (m.channel) channels.add(m.channel); });
1002
+
1003
+ const channelStats = new Map();
1004
+ allMessages.forEach(m => {
1005
+ const ch = m.channel || 'general';
1006
+ if (!channelStats.has(ch)) channelStats.set(ch, { total: 0, mentions: 0 });
1007
+ const stats = channelStats.get(ch);
1008
+ stats.total += 1;
1009
+ if (mentionsRyan(m.content)) stats.mentions += 1;
1010
+ });
1011
+
1012
+ const tabs = document.getElementById('channel-tabs');
1013
+ tabs.innerHTML = Array.from(channels).map(ch => {
1014
+ const stats = ch === 'all'
1015
+ ? { total: allMessages.length, mentions: allMessages.filter(m => mentionsRyan(m.content)).length }
1016
+ : (channelStats.get(ch) || { total: 0, mentions: 0 });
1017
+ const label = ch === 'all' ? '🌐 all' : '#' + esc(ch);
1018
+ const countMeta = `<span class="meta">${stats.total}</span>`;
1019
+ const mentionDot = (stats.mentions > 0 && ch !== 'all') ? '<span class="mention-dot" title="mentions"></span>' : '';
1020
+ return `<button class="channel-tab ${ch === currentChannel ? 'active' : ''}" data-channel="${esc(ch)}" onclick="switchChannel('${ch}')">${label}${countMeta}${mentionDot}</button>`;
1021
+ }).join('');
1022
+ renderChat();
1023
+ }
1024
+ function switchChannel(ch) {
1025
+ currentChannel = ch;
1026
+ const sendChannel = document.getElementById('chat-channel');
1027
+ if (sendChannel && ch !== 'all' && Array.from(sendChannel.options).some(o => o.value === ch)) {
1028
+ sendChannel.value = ch;
1029
+ }
1030
+ document.querySelectorAll('.channel-tab').forEach(t => {
1031
+ const normalized = t.getAttribute('data-channel') || '';
1032
+ t.classList.toggle('active', normalized === ch);
1033
+ });
1034
+ renderChat();
1035
+ }
1036
+ function renderChat() {
1037
+ const filtered = currentChannel === 'all' ? allMessages : allMessages.filter(m => m.channel === currentChannel);
1038
+ const shown = filtered.slice(0, 40);
1039
+ document.getElementById('chat-count').textContent = filtered.length + ' messages';
1040
+ const body = document.getElementById('chat-body');
1041
+ initChatInteractions();
1042
+ if (shown.length === 0) { body.innerHTML = '<div class="empty">No messages</div>'; return; }
1043
+ body.innerHTML = shown.map(m => {
1044
+ const agent = AGENT_INDEX.get(m.from);
1045
+ const roleTag = agent ? `<span class="msg-role">${esc(agent.role)}</span>` : '';
1046
+ const mentioned = mentionsRyan(m.content);
1047
+ const channelTag = m.channel ? '<span class="msg-channel">#' + esc(m.channel) + '</span>' : '';
1048
+ const editedTag = m.metadata && m.metadata.editedAt ? '<span class="msg-edited">(edited)</span>' : '';
1049
+ return `
1050
+ <div class="msg ${mentioned ? 'mentioned' : ''}">
1051
+ <div class="msg-header">
1052
+ <span class="msg-from">${esc(agentLabel(m.from))}</span>
1053
+ ${roleTag}
1054
+ ${channelTag}
1055
+ <span class="msg-time">${ago(m.timestamp)}</span>
1056
+ ${editedTag}
1057
+ </div>
1058
+ <div class="msg-content">${renderMessageContentWithTaskLinks(m.content)}</div>
1059
+ ${m.attachments && m.attachments.length ? '<div class="msg-attachments">' + m.attachments.map(a => {
1060
+ const isImage = a.mimeType && a.mimeType.startsWith('image/');
1061
+ const url = a.url || ('/files/' + a.fileId);
1062
+ const name = a.name || a.fileId || 'file';
1063
+ const size = a.size ? ' <span class="att-size">(' + formatBytes(a.size) + ')</span>' : '';
1064
+ if (isImage) return '<a href="' + esc(url) + '" target="_blank" class="msg-att-img"><img src="' + esc(url) + '" alt="' + esc(name) + '" loading="lazy"></a>';
1065
+ return '<a href="' + esc(url) + '" target="_blank" class="msg-att-file">📎 ' + esc(name) + size + '</a>';
1066
+ }).join('') + '</div>' : ''}
1067
+ </div>`;
1068
+ }).join('');
1069
+ }
1070
+
1071
+ // ---- Send chat message ----
1072
+ async function sendChat() {
1073
+ const input = document.getElementById('chat-input');
1074
+ const channel = document.getElementById('chat-channel').value;
1075
+ const btn = document.getElementById('chat-send');
1076
+ const content = input.value.trim();
1077
+ const hasAttachments = typeof _pendingChatAttachments !== 'undefined' && _pendingChatAttachments.length > 0;
1078
+ if (!content && !hasAttachments) return;
1079
+
1080
+ btn.disabled = true;
1081
+ try {
1082
+ // Upload pending attachments first
1083
+ const attachments = [];
1084
+ if (hasAttachments) {
1085
+ for (const file of _pendingChatAttachments) {
1086
+ const fd = new FormData();
1087
+ fd.append('file', file);
1088
+ fd.append('uploadedBy', 'ryan');
1089
+ try {
1090
+ const res = await fetch('/files', { method: 'POST', body: fd });
1091
+ const data = await res.json();
1092
+ if (data.success && data.file) {
1093
+ attachments.push({
1094
+ id: data.file.id,
1095
+ name: data.file.originalName || data.file.filename,
1096
+ size: data.file.size,
1097
+ mimeType: data.file.mimeType,
1098
+ url: '/files/' + data.file.id,
1099
+ });
1100
+ }
1101
+ } catch (e) { console.error('Attachment upload failed:', e); }
1102
+ }
1103
+ _pendingChatAttachments = [];
1104
+ if (typeof renderChatAttachments === 'function') renderChatAttachments();
1105
+ }
1106
+
1107
+ // Build message with attachments
1108
+ const payload = { from: 'ryan', content: content || '', channel };
1109
+ if (attachments.length) payload.attachments = attachments;
1110
+
1111
+ await fetch(BASE + '/chat/messages', {
1112
+ method: 'POST',
1113
+ headers: { 'Content-Type': 'application/json' },
1114
+ body: JSON.stringify(payload),
1115
+ });
1116
+ input.value = '';
1117
+ await loadChat(true);
1118
+ } catch (e) { console.error('Send error:', e); }
1119
+ btn.disabled = false;
1120
+ input.focus();
1121
+ }
1122
+
1123
+ // Enter key sends + quick channel switching
1124
+ function rotateChannel(direction) {
1125
+ const tabs = Array.from(document.querySelectorAll('.channel-tab'));
1126
+ if (!tabs.length) return;
1127
+ const channels = tabs.map(t => t.getAttribute('data-channel') || '').filter(Boolean);
1128
+ const currentIndex = Math.max(0, channels.indexOf(currentChannel));
1129
+ const nextIndex = (currentIndex + direction + channels.length) % channels.length;
1130
+ switchChannel(channels[nextIndex]);
1131
+ }
1132
+
1133
+ document.addEventListener('DOMContentLoaded', () => {
1134
+ document.getElementById('chat-input').addEventListener('keydown', e => {
1135
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
1136
+ });
1137
+
1138
+ document.addEventListener('keydown', e => {
1139
+ if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); rotateChannel(1); }
1140
+ if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); rotateChannel(-1); }
1141
+ });
1142
+ });
1143
+
1144
+ // ---- Activity ----
1145
+ async function loadActivity(forceFull = false) {
1146
+ try {
1147
+ const qs = new URLSearchParams();
1148
+ qs.set('limit', '25');
1149
+ if (!forceFull && lastActivitySync > 0) qs.set('since', String(lastActivitySync));
1150
+
1151
+ const r = await fetch(BASE + '/activity?' + qs.toString());
1152
+ const d = await r.json();
1153
+ const incoming = d.events || [];
1154
+
1155
+ if (!forceFull && lastActivitySync > 0 && incoming.length === 0) {
1156
+ return;
1157
+ }
1158
+
1159
+ if (!forceFull && lastActivitySync > 0) {
1160
+ const seen = new Set(allEvents.map(e => e.id));
1161
+ const merged = [...incoming.filter(e => !seen.has(e.id)), ...allEvents];
1162
+ allEvents = merged.sort((a, b) => b.timestamp - a.timestamp).slice(0, 120);
1163
+ } else {
1164
+ allEvents = incoming;
1165
+ }
1166
+
1167
+ const maxTs = incoming.reduce((max, e) => Math.max(max, e.timestamp || 0), 0);
1168
+ if (maxTs > 0) lastActivitySync = Math.max(lastActivitySync, maxTs);
1169
+
1170
+ document.getElementById('activity-count').textContent = allEvents.length + ' events';
1171
+ const body = document.getElementById('activity-body');
1172
+ if (allEvents.length === 0) { body.innerHTML = '<div class="empty">No recent activity</div>'; return; }
1173
+ body.innerHTML = allEvents.slice(0, 20).map(e => `
1174
+ <div class="event-row">
1175
+ <span class="event-type">${esc(e.type || 'event')}</span>
1176
+ ${e.agent ? '<span class="event-agent">' + esc(e.agent) + '</span>' : ''}
1177
+ <span class="event-desc">${esc(truncate(e.summary || e.description || '', 60))}</span>
1178
+ <span class="event-time">${ago(e.timestamp)}</span>
1179
+ </div>`).join('');
1180
+ } catch (e) {}
1181
+ }
1182
+
1183
+ function getSlaBadge(dueAt, status) {
1184
+ if (!dueAt || status === 'answered' || status === 'archived') return '<span class="assignee-tag">no SLA</span>';
1185
+ const ms = dueAt - Date.now();
1186
+ if (ms <= 0) return '<span class="assignee-tag" style="color:var(--red)">overdue</span>';
1187
+ const hours = Math.ceil(ms / (60 * 60 * 1000));
1188
+ if (hours <= 24) return `<span class="assignee-tag" style="color:var(--yellow)">${hours}h left</span>`;
1189
+ const days = Math.ceil(hours / 24);
1190
+ return `<span class="assignee-tag" style="color:var(--green)">${days}d left</span>`;
1191
+ }
1192
+
1193
+ // ---- Research Intake ----
1194
+ async function loadResearch() {
1195
+ try {
1196
+ const [reqRes, findingRes] = await Promise.all([
1197
+ fetch(BASE + '/research/requests?limit=12'),
1198
+ fetch(BASE + '/research/findings?limit=20'),
1199
+ ]);
1200
+
1201
+ const reqData = await reqRes.json();
1202
+ const findingData = await findingRes.json();
1203
+
1204
+ const requests = reqData.requests || [];
1205
+ const findings = findingData.findings || [];
1206
+ const findingMap = new Map();
1207
+ findings.forEach(f => {
1208
+ findingMap.set(f.requestId, (findingMap.get(f.requestId) || 0) + 1);
1209
+ });
1210
+
1211
+ const body = document.getElementById('research-body');
1212
+ const count = document.getElementById('research-count');
1213
+ if (!body || !count) return;
1214
+
1215
+ count.textContent = requests.length + ' requests';
1216
+
1217
+ if (requests.length === 0) {
1218
+ body.innerHTML = '<div class="empty">No research requests yet</div>';
1219
+ return;
1220
+ }
1221
+
1222
+ body.innerHTML = requests.map(r => {
1223
+ const q = esc(truncate(r.question || '', 88));
1224
+ const findingCount = findingMap.get(r.id) || 0;
1225
+ const sla = getSlaBadge(r.dueAt, r.status);
1226
+ return `<div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
1227
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
1228
+ <div style="font-size:13px;color:var(--text-bright);font-weight:500">${esc(truncate(r.title || 'Untitled request', 58))}</div>
1229
+ ${sla}
1230
+ </div>
1231
+ <div style="font-size:11px;color:var(--text-muted);margin-top:3px">${q}</div>
1232
+ <div style="font-size:11px;color:var(--text-dim);margin-top:5px">
1233
+ ${r.category ? '#' + esc(r.category) + ' · ' : ''}${r.owner ? 'owner: ' + esc(r.owner) + ' · ' : ''}status: ${esc(r.status || 'open')} · findings: ${findingCount}
1234
+ </div>
1235
+ </div>`;
1236
+ }).join('');
1237
+ } catch (e) {
1238
+ const body = document.getElementById('research-body');
1239
+ if (body) body.innerHTML = '<div class="empty">Failed to load research requests</div>';
1240
+ }
1241
+ }
1242
+
1243
+ // ---- Shared Artifacts (shared workspace: process/) ----
1244
+ async function loadSharedArtifacts() {
1245
+ const body = document.getElementById('shared-artifacts-body');
1246
+ const count = document.getElementById('shared-artifacts-count');
1247
+ if (!body || !count) return;
1248
+
1249
+ const fmtBytes = (n) => {
1250
+ if (typeof n !== 'number' || !isFinite(n)) return '';
1251
+ if (n < 1024) return n + ' B';
1252
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
1253
+ return (n / (1024 * 1024)).toFixed(1) + ' MB';
1254
+ };
1255
+
1256
+ try {
1257
+ const res = await fetch(BASE + '/shared/list?path=process/&limit=80');
1258
+ const data = await res.json();
1259
+
1260
+ if (!data || data.success !== true) {
1261
+ count.textContent = 'unavailable';
1262
+ const msg = esc((data && data.error) ? data.error : 'Shared workspace not available');
1263
+ body.innerHTML = `<div class="empty" style="color:var(--text-muted)">${msg}</div>`;
1264
+ return;
1265
+ }
1266
+
1267
+ const entries = Array.isArray(data.entries) ? data.entries : [];
1268
+ const files = entries.filter(e => e && e.type === 'file');
1269
+ count.textContent = files.length + ' files';
1270
+
1271
+ // Pinned: Ryan's thoughts (if symlinked into shared workspace process/)
1272
+ const pinned = [
1273
+ { name: "RYANS-THOUGHTS.md", label: "Ryan's thoughts" },
1274
+ ];
1275
+
1276
+ const pinnedRows = pinned.map(p => {
1277
+ const found = files.find(f => (String(f.name || '')).toLowerCase() === p.name.toLowerCase());
1278
+ if (!found) {
1279
+ return `<div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
1280
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
1281
+ <div style="font-size:13px;color:var(--text-bright);font-weight:600">${esc(p.label)}</div>
1282
+ <span class="assignee-tag" style="color:var(--yellow)">missing</span>
1283
+ </div>
1284
+ <div style="font-size:11px;color:var(--text-muted);margin-top:4px">
1285
+ To make this available here, create a symlink in the shared workspace:<br/>
1286
+ <code>ln -s ../RYANS-THOUGHTS.md ~/.openclaw/workspace-shared/process/RYANS-THOUGHTS.md</code>
1287
+ </div>
1288
+ </div>`;
1289
+ }
1290
+ const href = '/shared/view?path=' + encodeURIComponent(found.path);
1291
+ return `<div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
1292
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
1293
+ <a href="${href}" target="_blank" style="font-size:13px;color:var(--accent);font-weight:600;text-decoration:none">${esc(p.label)} ↗</a>
1294
+ <span class="assignee-tag" style="color:var(--green)">ready</span>
1295
+ </div>
1296
+ <div style="font-size:11px;color:var(--text-muted);margin-top:4px">path: <code>${esc(found.path)}</code></div>
1297
+ </div>`;
1298
+ }).join('');
1299
+
1300
+ // Listing: show most relevant files first (TASK- docs later)
1301
+ const nonTask = files.filter(f => !(String(f.name || '').startsWith('TASK-')));
1302
+ const taskDocs = files.filter(f => (String(f.name || '').startsWith('TASK-')));
1303
+ const ordered = [...nonTask, ...taskDocs].slice(0, 40);
1304
+
1305
+ const listRows = ordered.map(e => {
1306
+ const href = '/shared/view?path=' + encodeURIComponent(e.path);
1307
+ const size = fmtBytes(e.size);
1308
+ return `<div class="backlog-item" style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
1309
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
1310
+ <a href="${href}" target="_blank" style="color:var(--text-bright);text-decoration:none;font-size:12px">${esc(truncate(e.name || e.path, 70))}</a>
1311
+ <span style="font-size:11px;color:var(--text-muted)">${esc(size)}</span>
1312
+ </div>
1313
+ <div style="font-size:11px;color:var(--text-muted);margin-top:4px">${esc(e.path)}</div>
1314
+ </div>`;
1315
+ }).join('');
1316
+
1317
+ body.innerHTML = `
1318
+ <div style="border:1px solid var(--border-subtle);border-radius:8px;overflow:hidden">${pinnedRows}</div>
1319
+ <div style="height:10px"></div>
1320
+ <div style="font-size:11px;color:var(--text-muted);margin:0 0 8px">Shared workspace directory: <code>process/</code></div>
1321
+ <div style="border:1px solid var(--border-subtle);border-radius:8px;overflow:hidden">${listRows}</div>
1322
+ `;
1323
+ } catch (e) {
1324
+ count.textContent = 'error';
1325
+ body.innerHTML = '<div class="empty" style="color:var(--red)">Failed to load shared artifacts</div>';
1326
+ }
1327
+ }
1328
+
1329
+ // ---- Team Health ----
1330
+ async function loadHealth() {
1331
+ try {
1332
+ const now = Date.now();
1333
+ const shouldRefreshDetail = !cachedHealth || (now - lastHealthDetailSync) > 120000;
1334
+
1335
+ if (shouldRefreshDetail) {
1336
+ const [teamRes, agentsRes, idleNudgeRes, workflowRes] = await Promise.all([
1337
+ fetch(BASE + '/health/team'),
1338
+ fetch(BASE + '/health/agents'),
1339
+ fetch(BASE + '/health/idle-nudge/debug'),
1340
+ fetch(BASE + '/health/workflow'),
1341
+ ]);
1342
+ const team = await teamRes.json();
1343
+ const agentsSummary = await agentsRes.json();
1344
+ const idleNudgeDebug = await idleNudgeRes.json();
1345
+ const workflow = await workflowRes.json();
1346
+ cachedHealth = { team, agentsSummary, idleNudgeDebug, workflow };
1347
+ lastHealthDetailSync = now;
1348
+ }
1349
+
1350
+ const health = cachedHealth || { team: { blockers: [], overlaps: [], compliance: null }, agentsSummary: { agents: [] }, idleNudgeDebug: null, workflow: { agents: [] } };
1351
+
1352
+ const team = health.team || { blockers: [], overlaps: [], compliance: null, agents: [] };
1353
+ const agentsSummary = health.agentsSummary || { agents: [] };
1354
+ const idleNudgeDebug = health.idleNudgeDebug || null;
1355
+
1356
+ healthAgentMap = new Map((team.agents || []).map(a => [String(a.agent || '').toLowerCase(), a]));
1357
+ const workflow = health.workflow || { agents: [] };
1358
+
1359
+ const teamAgentsByName = new Map((team.agents || []).map(a => [a.agent, a]));
1360
+ const summaryRows = (agentsSummary.agents && agentsSummary.agents.length > 0)
1361
+ ? agentsSummary.agents
1362
+ : (team.agents || []).map(a => ({
1363
+ agent: a.agent,
1364
+ state: a.idleWithActiveTask ? 'stuck' : (a.status === 'active' ? 'healthy' : (a.status === 'offline' ? 'offline' : 'idle')),
1365
+ last_seen: a.lastSeen,
1366
+ heartbeat_age_ms: Math.max(0, a.minutesSinceLastSeen || 0) * 60000,
1367
+ active_task: a.currentTask || null,
1368
+ last_shipped_at: a.lastProductiveAt || null,
1369
+ shipped_age_ms: a.minutesSinceProductive == null ? null : Math.max(0, a.minutesSinceProductive) * 60000,
1370
+ stale_reason: a.idleWithActiveTask ? 'active-task-idle-over-60m' : null,
1371
+ idle_with_active_task: Boolean(a.idleWithActiveTask),
1372
+ }));
1373
+ const agents = summaryRows.map(row => {
1374
+ const fromTeam = teamAgentsByName.get(row.agent) || {};
1375
+ const minutesSinceLastSeen = Math.floor((Number(row.heartbeat_age_ms || 0)) / 60000);
1376
+ const mappedStatus = row.state === 'stuck'
1377
+ ? 'blocked'
1378
+ : (row.state === 'healthy' ? 'active' : (row.state === 'idle' ? 'silent' : 'offline'));
1379
+ return {
1380
+ agent: row.agent,
1381
+ status: mappedStatus,
1382
+ lastSeen: Number(row.last_seen || 0),
1383
+ minutesSinceLastSeen,
1384
+ currentTask: row.active_task || fromTeam.currentTask || null,
1385
+ recentBlockers: fromTeam.recentBlockers || [],
1386
+ messageCount24h: fromTeam.messageCount24h || 0,
1387
+ lastProductiveAt: row.last_shipped_at || row.last_productive_at || null,
1388
+ minutesSinceProductive: (row.shipped_age_ms ?? row.productive_age_ms) == null ? null : Math.floor(Number(row.shipped_age_ms ?? row.productive_age_ms) / 60000),
1389
+ staleReason: row.stale_reason || null,
1390
+ idleWithActiveTask: Boolean(row.idle_with_active_task),
1391
+ };
1392
+ });
1393
+ const blockers = team.blockers || [];
1394
+ const overlaps = team.overlaps || [];
1395
+ const compliance = team.compliance || null;
1396
+
1397
+ const statusCounts = { active: 0, idle: 0, silent: 0, blocked: 0, offline: 0, watch: 0 };
1398
+ let stuckActiveCount = 0;
1399
+ const displayAgents = agents.map(a => {
1400
+ const derived = deriveHealthSignal(a);
1401
+ const displayStatus = a.status === 'silent'
1402
+ ? (a.minutesSinceLastSeen >= 120 ? 'blocked' : (a.minutesSinceLastSeen >= 60 ? 'silent' : 'watch'))
1403
+ : derived.status;
1404
+ statusCounts[displayStatus] = (statusCounts[displayStatus] || 0) + 1;
1405
+ if (a.idleWithActiveTask) stuckActiveCount += 1;
1406
+ return { ...a, displayStatus, lowConfidence: derived.lowConfidence };
1407
+ }).sort((a, b) => {
1408
+ const pa = healthPriorityRank(a);
1409
+ const pb = healthPriorityRank(b);
1410
+ if (pa !== pb) return pa - pb;
1411
+ if ((b.minutesSinceLastSeen || 0) !== (a.minutesSinceLastSeen || 0)) {
1412
+ return (b.minutesSinceLastSeen || 0) - (a.minutesSinceLastSeen || 0);
1413
+ }
1414
+ return a.agent.localeCompare(b.agent);
1415
+ });
1416
+
1417
+ const healthSummary = `${statusCounts.active} active • ${statusCounts.watch + statusCounts.silent} quiet • ${statusCounts.blocked} blocked • ${stuckActiveCount} stuck`;
1418
+ document.getElementById('health-count').textContent = healthSummary;
1419
+
1420
+ const body = document.getElementById('health-body');
1421
+ let html = '';
1422
+
1423
+ // Agent Health Grid
1424
+ if (displayAgents.length > 0) {
1425
+ html += '<div class="health-section"><div class="health-section-title">Agent Status</div><div class="health-grid">';
1426
+ html += displayAgents.map(a => {
1427
+ const statusText = a.minutesSinceLastSeen < 1 ? 'just now' : ago(a.lastSeen) + ' ago';
1428
+ const taskDisplay = a.currentTask ? `<div class="health-task">📋 ${esc(truncate(a.currentTask, 35))}</div>` : '';
1429
+ const productiveText = `<div class="health-task">🧾 ${esc(formatProductiveText(a))}</div>`;
1430
+ const statusLabel = a.displayStatus === 'blocked'
1431
+ ? ' • 🚫 blocked'
1432
+ : (a.displayStatus === 'silent' ? ' • ⚠️ quiet' : (a.displayStatus === 'watch' ? ' • 👀 watch' : ''));
1433
+ const confidenceLabel = a.lowConfidence ? ' • needs review' : '';
1434
+ const stuckLabel = a.idleWithActiveTask ? ' • ⛔ active-task idle>60m' : '';
1435
+ const staleReasonLabel = a.staleReason ? ' • ' + a.staleReason : '';
1436
+ const hierarchyClass = healthPriorityRank(a) === 0 ? 'health-critical' : (healthPriorityRank(a) === 1 ? 'health-warning' : 'health-info');
1437
+ const isStaleGhost = a.minutesSinceLastSeen > 43200; // >30 days
1438
+ const cardClasses = [
1439
+ 'health-card',
1440
+ hierarchyClass,
1441
+ a.lowConfidence ? 'needs-review' : '',
1442
+ a.idleWithActiveTask ? 'stuck-active-task' : '',
1443
+ isStaleGhost ? 'stale-ghost' : '',
1444
+ ].filter(Boolean).join(' ');
1445
+ return `
1446
+ <div class="${cardClasses}">
1447
+ <div class="health-indicator ${a.idleWithActiveTask ? 'blocked' : a.displayStatus}"></div>
1448
+ <div class="health-info">
1449
+ <div class="health-name">${esc(a.agent)}</div>
1450
+ <div class="health-status">${statusText}${statusLabel}${confidenceLabel}${stuckLabel}${staleReasonLabel}</div>
1451
+ ${taskDisplay}
1452
+ ${productiveText}
1453
+ </div>
1454
+ </div>`;
1455
+ }).join('');
1456
+ html += '</div></div>';
1457
+ }
1458
+
1459
+ // Blockers
1460
+ if (blockers.length > 0) {
1461
+ html += '<div class="health-section"><div class="health-section-title">🚫 Active Blockers</div>';
1462
+ html += blockers.slice(0, 5).map(b => `
1463
+ <div class="blocker-item">
1464
+ <div class="blocker-agent">${esc(b.agent)}</div>
1465
+ <div class="blocker-text">${esc(b.blocker)}</div>
1466
+ <div class="blocker-meta">Mentioned ${b.mentionCount}x • Last: ${ago(b.lastMentioned)}</div>
1467
+ </div>`).join('');
1468
+ html += '</div>';
1469
+ }
1470
+
1471
+ // Overlaps
1472
+ if (overlaps.length > 0) {
1473
+ html += '<div class="health-section"><div class="health-section-title">⚠️ Overlapping Work</div>';
1474
+ html += overlaps.slice(0, 3).map(o => `
1475
+ <div class="overlap-item">
1476
+ <div class="overlap-agents">${o.agents.join(', ')}</div>
1477
+ <div class="overlap-topic">${esc(o.topic)} (${o.confidence} confidence)</div>
1478
+ </div>`).join('');
1479
+ html += '</div>';
1480
+ }
1481
+
1482
+ // Unified workflow state (task + shipped + blocker + PR)
1483
+ if (Array.isArray(workflow.agents) && workflow.agents.length > 0) {
1484
+ html += '<div class="health-section"><div class="health-section-title">🧭 Workflow State</div>';
1485
+ html += workflow.agents.slice(0, 8).map(w => {
1486
+ const taskText = w.doingTaskId ? esc(truncate(w.doingTaskId, 28)) : 'no active task';
1487
+ const taskAge = w.doingTaskAgeMs == null ? 'n/a' : `${Math.floor(Number(w.doingTaskAgeMs) / 60000)}m`;
1488
+ const shipped = w.lastShippedAt ? ago(Number(w.lastShippedAt)) + ' ago' : 'none';
1489
+ const prState = w.prState || 'none';
1490
+ const prText = w.pr ? `<a href="${esc(w.pr)}" target="_blank" rel="noopener">PR</a>` : 'no PR';
1491
+ const blocker = w.blockerActive ? '🚫 blocker' : '✅ clear';
1492
+ return `<div class="blocker-item">
1493
+ <div class="blocker-agent">${esc(w.agent)}</div>
1494
+ <div class="blocker-meta">task: ${taskText} (${taskAge}) • shipped: ${esc(shipped)} • ${blocker}</div>
1495
+ <div class="blocker-meta">pr: ${prText} (${esc(prState)})${w.artifactPath ? ` • artifact: ${esc(truncate(w.artifactPath, 40))}` : ''}</div>
1496
+ </div>`;
1497
+ }).join('');
1498
+ html += '</div>';
1499
+ }
1500
+
1501
+ html += renderIdleNudgeSummary(idleNudgeDebug);
1502
+
1503
+ if (agents.length === 0 && blockers.length === 0 && overlaps.length === 0 && !idleNudgeDebug) {
1504
+ html = '<div class="empty">No health data available</div>';
1505
+ }
1506
+
1507
+ body.innerHTML = html;
1508
+ renderCompliance(compliance);
1509
+ initComplianceInteractions();
1510
+ } catch (e) {
1511
+ console.error('Health load error:', e);
1512
+ document.getElementById('health-body').innerHTML = '<div class="empty">Failed to load health data</div>';
1513
+ document.getElementById('compliance-body').innerHTML = '<div class="empty">Failed to load compliance data</div>';
1514
+ }
1515
+ }
1516
+
1517
+ async function loadReleaseStatus(force = false) {
1518
+ const badge = document.getElementById('release-badge');
1519
+ if (!badge) return;
1520
+
1521
+ const now = Date.now();
1522
+ if (!force && (now - lastReleaseStatusSync) < 30000) return;
1523
+
1524
+ try {
1525
+ const r = await fetch(BASE + '/release/status');
1526
+ const status = await r.json();
1527
+
1528
+ const stale = Boolean(status.stale);
1529
+ badge.classList.toggle('stale', stale);
1530
+ badge.classList.toggle('fresh', !stale);
1531
+ badge.textContent = stale ? 'deploy: stale' : 'deploy: in sync';
1532
+
1533
+ const reasons = Array.isArray(status.reasons) ? status.reasons : [];
1534
+ const startupCommit = status.startup && status.startup.commit ? status.startup.commit.slice(0, 8) : 'unknown';
1535
+ const currentCommit = status.current && status.current.commit ? status.current.commit.slice(0, 8) : 'unknown';
1536
+ const reasonText = reasons.length > 0 ? reasons.join('; ') : 'no mismatch detected';
1537
+ badge.title = `startup ${startupCommit} • current ${currentCommit} • ${reasonText}`;
1538
+
1539
+ lastReleaseStatusSync = now;
1540
+ } catch (err) {
1541
+ badge.classList.remove('fresh');
1542
+ badge.classList.add('stale');
1543
+ badge.textContent = 'deploy: unknown';
1544
+ badge.title = 'Failed to load deploy status';
1545
+ }
1546
+ }
1547
+
1548
+ async function loadBuildInfo() {
1549
+ const badge = document.getElementById('build-badge');
1550
+ if (!badge) return;
1551
+
1552
+ try {
1553
+ const r = await fetch(BASE + '/health/build');
1554
+ const info = await r.json();
1555
+
1556
+ const sha = info.gitShortSha || 'unknown';
1557
+ const branch = info.gitBranch || 'unknown';
1558
+ const uptime = info.uptime || 0;
1559
+ const uptimeStr = uptime < 60 ? `${uptime}s` :
1560
+ uptime < 3600 ? `${Math.floor(uptime / 60)}m` :
1561
+ `${Math.floor(uptime / 3600)}h${Math.floor((uptime % 3600) / 60)}m`;
1562
+
1563
+ badge.classList.toggle('fresh', branch === 'main');
1564
+ badge.classList.toggle('stale', branch !== 'main');
1565
+ badge.textContent = `${sha} • ${uptimeStr}`;
1566
+ badge.title = `SHA: ${info.gitSha}\nBranch: ${branch}\nCommit: ${info.gitMessage}\nAuthor: ${info.gitAuthor}\nPID: ${info.pid}\nNode: ${info.nodeVersion}\nStarted: ${info.startedAt}`;
1567
+ } catch (err) {
1568
+ badge.textContent = 'build: error';
1569
+ badge.title = 'Failed to load build info';
1570
+ }
1571
+ }
1572
+
1573
+ async function loadRuntimeTruthCard() {
1574
+ const body = document.getElementById('truth-body');
1575
+ const count = document.getElementById('truth-count');
1576
+ if (!body || !count) return;
1577
+
1578
+ try {
1579
+ const r = await fetch(BASE + '/runtime/truth');
1580
+ if (!r.ok) throw new Error('status ' + r.status);
1581
+ const truth = await r.json();
1582
+
1583
+ const deployLabel = truth?.deploy?.stale ? 'stale' : 'in sync';
1584
+ const cloudLabel = truth?.cloud?.registered
1585
+ ? `registered • hb ${truth?.cloud?.heartbeatCount ?? 0}`
1586
+ : 'not registered';
1587
+
1588
+ count.textContent = `${truth?.repo?.shortSha || 'unknown'} • ${deployLabel}`;
1589
+
1590
+ body.innerHTML = `
1591
+ <div class="truth-grid">
1592
+ <div class="truth-item">
1593
+ <div class="truth-label">Repo</div>
1594
+ <div class="truth-value">${esc(truth?.repo?.name || 'reflectt/reflectt-node')}<br>${esc(truth?.repo?.branch || 'unknown')} • ${esc((truth?.repo?.shortSha || 'unknown'))}</div>
1595
+ </div>
1596
+ <div class="truth-item">
1597
+ <div class="truth-label">Runtime</div>
1598
+ <div class="truth-value">PID ${esc(String(truth?.runtime?.pid ?? 'n/a'))} • Node ${esc(truth?.runtime?.nodeVersion || 'n/a')}<br>${esc(String(truth?.runtime?.host || '0.0.0.0'))}:${esc(String(truth?.runtime?.port || 'n/a'))} • up ${esc(String(truth?.runtime?.uptimeSec ?? 0))}s</div>
1599
+ </div>
1600
+ <div class="truth-item">
1601
+ <div class="truth-label">Deploy</div>
1602
+ <div class="truth-value">${esc(deployLabel)}<br>startup ${esc((truth?.deploy?.startupCommit || 'unknown').slice(0, 8))} → current ${esc((truth?.deploy?.currentCommit || 'unknown').slice(0, 8))}</div>
1603
+ </div>
1604
+ <div class="truth-item">
1605
+ <div class="truth-label">Cloud</div>
1606
+ <div class="truth-value">${esc(cloudLabel)}<br>host ${esc(String(truth?.cloud?.hostId || 'none'))}</div>
1607
+ </div>
1608
+ <div class="truth-item">
1609
+ <div class="truth-label">Paths</div>
1610
+ <div class="truth-value">home ${esc(String(truth?.paths?.reflecttHome || 'n/a'))}</div>
1611
+ </div>
1612
+ </div>
1613
+ `;
1614
+ } catch (err) {
1615
+ count.textContent = 'unavailable';
1616
+ body.innerHTML = '<div class="empty">Failed to load runtime truth card</div>';
1617
+ }
1618
+ }
1619
+
1620
+ function updateClock() {
1621
+ document.getElementById('clock').textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1622
+ }
1623
+
1624
+ // ---- Review Queue Panel ----
1625
+ const REVIEW_SLA_HOURS = 4; // 4h default SLA for reviews
1626
+ const REVIEW_SLA_WARNING_HOURS = 2; // warning at 2h
1627
+
1628
+ function getReviewSlaState(timeInReviewMs) {
1629
+ const hours = timeInReviewMs / (1000 * 60 * 60);
1630
+ if (hours >= REVIEW_SLA_HOURS) return 'breach';
1631
+ if (hours >= REVIEW_SLA_WARNING_HOURS) return 'warning';
1632
+ return 'ok';
1633
+ }
1634
+
1635
+ function getReviewSlaLabel(state) {
1636
+ if (state === 'breach') return '⏰ SLA BREACH';
1637
+ if (state === 'warning') return '⚠ Near SLA';
1638
+ return '✓ On track';
1639
+ }
1640
+
1641
+ function formatDuration(ms) {
1642
+ const totalMin = Math.floor(ms / 60000);
1643
+ if (totalMin < 60) return totalMin + 'm';
1644
+ const h = Math.floor(totalMin / 60);
1645
+ const m = totalMin % 60;
1646
+ if (h < 24) return h + 'h ' + m + 'm';
1647
+ const d = Math.floor(h / 24);
1648
+ return d + 'd ' + (h % 24) + 'h';
1649
+ }
1650
+
1651
+ // Normalize epoch: detect seconds vs ms, clamp future values
1652
+ function normalizeEpochMs(v) {
1653
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return 0;
1654
+ // Values below ~2001-09-09 in ms are likely seconds
1655
+ if (v < 100000000000) return v * 1000;
1656
+ return v;
1657
+ }
1658
+
1659
+ function renderReviewQueue() {
1660
+ const panel = document.getElementById('review-queue-panel');
1661
+ const body = document.getElementById('review-queue-body');
1662
+ const count = document.getElementById('review-queue-count');
1663
+ if (!body || !panel) return;
1664
+
1665
+ const now = Date.now();
1666
+ const MAX_REVIEW_MS = 30 * 24 * 60 * 60 * 1000; // 30 days clamp
1667
+ const validating = allTasks
1668
+ .filter(t => t.status === 'validating')
1669
+ .map(t => {
1670
+ const rawEntered = t.metadata?.entered_validating_at || t.updatedAt || t.createdAt;
1671
+ const enteredAt = normalizeEpochMs(rawEntered) || now;
1672
+ const timeInReview = Math.min(Math.max(0, now - enteredAt), MAX_REVIEW_MS);
1673
+ const slaState = getReviewSlaState(timeInReview);
1674
+ return { ...t, timeInReview, slaState, enteredAt };
1675
+ })
1676
+ .sort((a, b) => {
1677
+ // Breaches first, then by time descending
1678
+ const order = { breach: 0, warning: 1, ok: 2 };
1679
+ const diff = (order[a.slaState] || 2) - (order[b.slaState] || 2);
1680
+ if (diff !== 0) return diff;
1681
+ return b.timeInReview - a.timeInReview;
1682
+ });
1683
+
1684
+ if (validating.length === 0) {
1685
+ panel.style.display = 'none';
1686
+ return;
1687
+ }
1688
+
1689
+ panel.style.display = '';
1690
+ count.textContent = validating.length + ' awaiting review';
1691
+ // Update sidebar badge
1692
+ const navReviewBadge = document.getElementById('nav-review-count');
1693
+ if (navReviewBadge) navReviewBadge.textContent = validating.length;
1694
+
1695
+ const breachCount = validating.filter(t => t.slaState === 'breach').length;
1696
+ const headerExtra = breachCount > 0
1697
+ ? ' <span style="color:var(--red);font-size:11px;font-weight:600">' + breachCount + ' breach' + (breachCount > 1 ? 'es' : '') + '</span>'
1698
+ : '';
1699
+ count.innerHTML = validating.length + ' awaiting review' + headerExtra;
1700
+
1701
+ body.innerHTML = validating.map(t => {
1702
+ const reviewer = t.reviewer || '<span style="color:var(--yellow)">unassigned</span>';
1703
+ const assignee = t.assignee || '?';
1704
+ const priority = t.priority || 'P3';
1705
+ const slaLabel = getReviewSlaLabel(t.slaState);
1706
+ const duration = formatDuration(t.timeInReview);
1707
+ const tags = renderTaskTags(t.tags);
1708
+
1709
+ return '<div class="review-item" role="button" tabindex="0" onclick="openTaskModal(\'' + esc(t.id) + '\')">'
1710
+ + '<div class="review-item-left">'
1711
+ + '<div class="review-item-title">' + esc(truncate(t.title, 70)) + '</div>'
1712
+ + '<div class="review-item-meta">'
1713
+ + '<span>👤 ' + reviewer + '</span>'
1714
+ + '<span>⏱ ' + esc(duration) + '</span>'
1715
+ + '<span class="assignee-tag">' + esc(priority) + '</span>'
1716
+ + '<span>by ' + esc(assignee) + '</span>'
1717
+ + (tags ? ' ' + tags : '')
1718
+ + '</div>'
1719
+ + '</div>'
1720
+ + '<div class="review-item-right">'
1721
+ + '<span class="sla-badge ' + t.slaState + '">' + slaLabel + '</span>'
1722
+ + '</div>'
1723
+ + '</div>';
1724
+ }).join('');
1725
+
1726
+ bindTaskLinkHandlers(body);
1727
+
1728
+ // SLA breach escalation: post to watchdog if any breach found
1729
+ if (breachCount > 0) {
1730
+ escalateReviewBreaches(validating.filter(t => t.slaState === 'breach'));
1731
+ }
1732
+ }
1733
+
1734
+ let lastReviewEscalationAt = 0;
1735
+ const REVIEW_ESCALATION_COOLDOWN = 20 * 60 * 1000; // 20m
1736
+
1737
+ async function escalateReviewBreaches(breachedTasks) {
1738
+ const now = Date.now();
1739
+ if (now - lastReviewEscalationAt < REVIEW_ESCALATION_COOLDOWN) return;
1740
+ lastReviewEscalationAt = now;
1741
+
1742
+ const lines = breachedTasks.slice(0, 5).map(t => {
1743
+ const reviewer = t.reviewer || 'unassigned';
1744
+ return '- ' + t.id + ' (' + (t.title || '').slice(0, 50) + ') — reviewer: @' + reviewer + ', waiting ' + formatDuration(t.timeInReview);
1745
+ });
1746
+
1747
+ const content = '@kai Review SLA breach detected:\n' + lines.join('\n');
1748
+
1749
+ try {
1750
+ await fetch(BASE + '/chat/messages', {
1751
+ method: 'POST',
1752
+ headers: { 'Content-Type': 'application/json' },
1753
+ body: JSON.stringify({
1754
+ from: 'system',
1755
+ content,
1756
+ channel: 'general',
1757
+ timestamp: now
1758
+ })
1759
+ });
1760
+ } catch (err) {
1761
+ console.error('Failed to escalate review breach:', err);
1762
+ }
1763
+ }
1764
+
1765
+ // ---- Feedback ----
1766
+ let feedbackData = null;
1767
+
1768
+ async function loadFeedback() {
1769
+ try {
1770
+ const res = await fetch(BASE + '/feedback?status=all&limit=50');
1771
+ feedbackData = await res.json();
1772
+ renderFeedback();
1773
+ } catch (e) {
1774
+ const body = document.getElementById('feedback-body');
1775
+ if (body) body.innerHTML = '<div class="empty">Failed to load feedback</div>';
1776
+ }
1777
+ }
1778
+
1779
+ function renderFeedback() {
1780
+ const body = document.getElementById('feedback-body');
1781
+ const count = document.getElementById('feedback-count');
1782
+ if (!body || !feedbackData) return;
1783
+
1784
+ const items = feedbackData.items || [];
1785
+ const newCount = feedbackData.newCount || 0;
1786
+ count.textContent = newCount > 0 ? newCount + ' new' : items.length + ' total';
1787
+
1788
+ if (items.length === 0) {
1789
+ body.innerHTML = '<div class="empty" style="text-align:center;padding:20px;color:var(--text-dim)">💬 No feedback yet.<br><span style="font-size:11px">Embed the widget to start collecting.</span></div>';
1790
+ return;
1791
+ }
1792
+
1793
+ body.innerHTML = items.map(function(fb) {
1794
+ var catIcon = fb.category === 'bug' ? '🐛' : fb.category === 'feature' ? '✨' : '💬';
1795
+ var catClass = fb.category || 'general';
1796
+ var domain = '';
1797
+ if (fb.url) { try { domain = new URL(fb.url).hostname; } catch(e) {} }
1798
+ return '<div class="feedback-card">' +
1799
+ '<div class="fb-header">' +
1800
+ '<span class="fb-category ' + catClass + '">' + catIcon + ' ' + esc(fb.category) + '</span>' +
1801
+ (domain ? '<span class="fb-source"> · ' + esc(domain) + '</span>' : '') +
1802
+ '<span class="fb-time">' + ago(fb.createdAt) + '</span>' +
1803
+ '</div>' +
1804
+ '<div class="fb-message">"' + esc(fb.messagePreview) + '"</div>' +
1805
+ '<div class="fb-footer">' +
1806
+ (fb.email ? '<span class="fb-email">' + esc(fb.email) + '</span>' : '') +
1807
+ (fb.votes > 0 ? '<span class="fb-votes" onclick="voteFeedback(\'' + esc(fb.id) + '\')">▲ ' + fb.votes + '</span>' : '<span class="fb-votes" onclick="voteFeedback(\'' + esc(fb.id) + '\')">▲ 0</span>') +
1808
+ '<span class="fb-actions">' +
1809
+ (fb.status === 'new' ? '<button onclick="triageFeedback(\'' + esc(fb.id) + '\')">Triage</button>' : '') +
1810
+ (fb.status !== 'archived' ? '<button onclick="archiveFeedback(\'' + esc(fb.id) + '\')">Archive</button>' : '<button onclick="unarchiveFeedback(\'' + esc(fb.id) + '\')">Unarchive</button>') +
1811
+ '</span>' +
1812
+ '</div>' +
1813
+ '</div>';
1814
+ }).join('');
1815
+ }
1816
+
1817
+ async function triageFeedback(id) {
1818
+ var notes = prompt('Triage notes (optional):') || '';
1819
+ try {
1820
+ await fetch(BASE + '/feedback/' + encodeURIComponent(id), {
1821
+ method: 'PATCH',
1822
+ headers: { 'Content-Type': 'application/json' },
1823
+ body: JSON.stringify({ status: 'triaged', notes: notes })
1824
+ });
1825
+ await loadFeedback();
1826
+ } catch (e) { console.error('Triage failed:', e); }
1827
+ }
1828
+
1829
+ async function archiveFeedback(id) {
1830
+ try {
1831
+ await fetch(BASE + '/feedback/' + encodeURIComponent(id), {
1832
+ method: 'PATCH',
1833
+ headers: { 'Content-Type': 'application/json' },
1834
+ body: JSON.stringify({ status: 'archived' })
1835
+ });
1836
+ await loadFeedback();
1837
+ } catch (e) { console.error('Archive failed:', e); }
1838
+ }
1839
+
1840
+ async function unarchiveFeedback(id) {
1841
+ try {
1842
+ await fetch(BASE + '/feedback/' + encodeURIComponent(id), {
1843
+ method: 'PATCH',
1844
+ headers: { 'Content-Type': 'application/json' },
1845
+ body: JSON.stringify({ status: 'triaged' })
1846
+ });
1847
+ await loadFeedback();
1848
+ } catch (e) { console.error('Unarchive failed:', e); }
1849
+ }
1850
+
1851
+ async function voteFeedback(id) {
1852
+ try {
1853
+ await fetch(BASE + '/feedback/' + encodeURIComponent(id) + '/vote', { method: 'POST' });
1854
+ await loadFeedback();
1855
+ } catch (e) { console.error('Vote failed:', e); }
1856
+ }
1857
+
1858
+ // ---- Approval Queue ----
1859
+ let approvalQueueData = null;
1860
+ let routingPolicyVisible = false;
1861
+ let routingPolicyData = null;
1862
+ let policyEdits = {};
1863
+
1864
+ async function loadApprovalQueue() {
1865
+ try {
1866
+ const res = await fetch(BASE + '/approval-queue');
1867
+ approvalQueueData = await res.json();
1868
+ renderApprovalQueue();
1869
+ } catch (e) {
1870
+ const body = document.getElementById('approval-queue-body');
1871
+ if (body) body.innerHTML = '<div class="empty">Failed to load approval queue</div>';
1872
+ }
1873
+ }
1874
+
1875
+ function renderApprovalQueue() {
1876
+ const body = document.getElementById('approval-queue-body');
1877
+ const count = document.getElementById('approval-queue-count');
1878
+ if (!body || !approvalQueueData) return;
1879
+
1880
+ const items = approvalQueueData.items || [];
1881
+ const highCount = approvalQueueData.highConfidenceCount || 0;
1882
+ const needsCount = approvalQueueData.needsReviewCount || 0;
1883
+ count.textContent = items.length + ' pending';
1884
+
1885
+ if (items.length === 0) {
1886
+ body.innerHTML = '<div class="empty" style="text-align:center;padding:20px;color:var(--text-dim)">✓ Queue is clear — no tasks waiting for approval.</div>';
1887
+ return;
1888
+ }
1889
+
1890
+ let html = '';
1891
+
1892
+ // Batch approve bar
1893
+ if (highCount > 0) {
1894
+ html += '<div class="batch-approve-bar">';
1895
+ html += '<span>' + highCount + ' high-confidence · ' + needsCount + ' need review</span>';
1896
+ html += '<button onclick="batchApproveHighConfidence()">Approve All High-Confidence (' + highCount + ')</button>';
1897
+ html += '</div>';
1898
+ }
1899
+
1900
+ // High confidence section
1901
+ const highItems = items.filter(function(i) { return i.confidenceScore >= 0.85; });
1902
+ const lowItems = items.filter(function(i) { return i.confidenceScore < 0.85; });
1903
+
1904
+ if (highItems.length > 0) {
1905
+ html += '<div style="padding:6px 12px;font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin-top:4px">High Confidence (≥ 85%)</div>';
1906
+ highItems.forEach(function(item) { html += renderApprovalCard(item, true); });
1907
+ }
1908
+
1909
+ if (lowItems.length > 0) {
1910
+ html += '<div style="padding:6px 12px;font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin-top:4px">Needs Review (&lt; 85%)</div>';
1911
+ lowItems.forEach(function(item) { html += renderApprovalCard(item, false); });
1912
+ }
1913
+
1914
+ body.innerHTML = html;
1915
+ }
1916
+
1917
+ function renderApprovalCard(item, isHigh) {
1918
+ const icon = isHigh ? '✦' : '⚠';
1919
+ const pct = Math.round(item.confidenceScore * 100);
1920
+ const confClass = isHigh ? 'high' : 'low';
1921
+ return '<div class="approval-card">' +
1922
+ '<div class="approval-header">' +
1923
+ '<span>' + icon + '</span> ' +
1924
+ '<span class="approval-title">' + esc(item.title.substring(0, 60)) + '</span>' +
1925
+ '<span class="assignee-tag">' + esc(item.priority) + '</span>' +
1926
+ '<span class="confidence-score ' + confClass + '">' + pct + '%</span>' +
1927
+ '</div>' +
1928
+ '<div class="approval-meta">' +
1929
+ 'Suggested: @' + esc(item.suggestedAgent || '?') + ' — ' + esc(item.confidenceReason || '') +
1930
+ '</div>' +
1931
+ '<div class="approval-actions">' +
1932
+ (isHigh ? '' : '<button class="btn-reject" onclick="rejectApproval(\'' + esc(item.taskId) + '\')">✗ Reject</button>') +
1933
+ '<button class="btn-edit" onclick="openTaskModal(\'' + esc(item.taskId) + '\')">Edit</button>' +
1934
+ '<button class="btn-approve" onclick="approveTask(\'' + esc(item.taskId) + '\', \'' + esc(item.suggestedAgent || '') + '\')">✓ Approve</button>' +
1935
+ '</div>' +
1936
+ '</div>';
1937
+ }
1938
+
1939
+ async function approveTask(taskId, agent) {
1940
+ try {
1941
+ await fetch(BASE + '/approval-queue/' + encodeURIComponent(taskId) + '/approve', {
1942
+ method: 'POST',
1943
+ headers: { 'Content-Type': 'application/json' },
1944
+ body: JSON.stringify({ assignedAgent: agent, reviewedBy: 'dashboard' })
1945
+ });
1946
+ await loadApprovalQueue();
1947
+ } catch (e) { console.error('Approve failed:', e); }
1948
+ }
1949
+
1950
+ async function rejectApproval(taskId) {
1951
+ const reason = prompt('Rejection reason (optional):') || '';
1952
+ try {
1953
+ await fetch(BASE + '/approval-queue/' + encodeURIComponent(taskId) + '/reject', {
1954
+ method: 'POST',
1955
+ headers: { 'Content-Type': 'application/json' },
1956
+ body: JSON.stringify({ reason: reason, reviewedBy: 'dashboard' })
1957
+ });
1958
+ await loadApprovalQueue();
1959
+ } catch (e) { console.error('Reject failed:', e); }
1960
+ }
1961
+
1962
+ async function batchApproveHighConfidence() {
1963
+ if (!approvalQueueData) return;
1964
+ const highItems = (approvalQueueData.items || []).filter(function(i) { return i.confidenceScore >= 0.85; });
1965
+ if (highItems.length === 0) return;
1966
+ if (!confirm('Approve ' + highItems.length + ' high-confidence tasks? They will be assigned immediately.')) return;
1967
+
1968
+ try {
1969
+ await fetch(BASE + '/approval-queue/batch-approve', {
1970
+ method: 'POST',
1971
+ headers: { 'Content-Type': 'application/json' },
1972
+ body: JSON.stringify({ taskIds: highItems.map(function(i) { return i.taskId; }), reviewedBy: 'dashboard' })
1973
+ });
1974
+ await loadApprovalQueue();
1975
+ } catch (e) { console.error('Batch approve failed:', e); }
1976
+ }
1977
+
1978
+ // ---- Routing Policy Editor ----
1979
+ function toggleRoutingPolicy() {
1980
+ routingPolicyVisible = !routingPolicyVisible;
1981
+ const panel = document.getElementById('routing-policy-panel');
1982
+ if (!panel) return;
1983
+ panel.style.display = routingPolicyVisible ? '' : 'none';
1984
+ if (routingPolicyVisible) loadRoutingPolicy();
1985
+ }
1986
+
1987
+ async function loadRoutingPolicy() {
1988
+ try {
1989
+ const res = await fetch(BASE + '/routing-policy');
1990
+ routingPolicyData = await res.json();
1991
+ policyEdits = {};
1992
+ renderRoutingPolicy();
1993
+ } catch (e) {
1994
+ const panel = document.getElementById('routing-policy-panel');
1995
+ if (panel) panel.innerHTML = '<div class="empty">Failed to load routing policy</div>';
1996
+ }
1997
+ }
1998
+
1999
+ function renderRoutingPolicy() {
2000
+ const panel = document.getElementById('routing-policy-panel');
2001
+ if (!panel || !routingPolicyData) return;
2002
+
2003
+ const agents = routingPolicyData.agents || [];
2004
+ let html = '<div style="font-size:12px;font-weight:600;color:var(--text-bright);margin-bottom:8px">Agent Affinity Maps</div>';
2005
+ html += '<div style="font-size:10px;color:var(--text-muted);margin-bottom:12px">Edit which task types each agent is preferred for. Confidence scores are calculated from these affinities.</div>';
2006
+
2007
+ agents.forEach(function(agent, idx) {
2008
+ const edited = policyEdits[agent.agentId] || agent;
2009
+ const tags = edited.affinityTags || [];
2010
+ const weight = typeof edited.weight === 'number' ? edited.weight : 0.5;
2011
+
2012
+ html += '<div class="policy-agent-card">';
2013
+ html += '<div class="agent-name">@' + esc(agent.agentId) + '</div>';
2014
+ html += '<div class="tag-row">';
2015
+ tags.forEach(function(tag, ti) {
2016
+ html += '<span class="tag-chip">' + esc(tag) + ' <span class="tag-remove" onclick="removePolicyTag(\'' + esc(agent.agentId) + '\',' + ti + ')">×</span></span>';
2017
+ });
2018
+ html += '<input type="text" placeholder="+ tag" style="font-size:10px;width:60px;background:none;border:1px solid var(--border-subtle);color:var(--text-bright);padding:2px 6px;border-radius:10px" onkeydown="addPolicyTag(event,\'' + esc(agent.agentId) + '\')">';
2019
+ html += '</div>';
2020
+ html += '<div class="weight-row">';
2021
+ html += '<span style="color:var(--text-dim)">Weight:</span>';
2022
+ html += '<input type="range" min="0" max="10" value="' + Math.round(weight * 10) + '" oninput="updatePolicyWeight(\'' + esc(agent.agentId) + '\', this.value)">';
2023
+ html += '<span class="weight-val">' + weight.toFixed(1) + '</span>';
2024
+ html += '</div>';
2025
+ html += '</div>';
2026
+ });
2027
+
2028
+ // Save bar
2029
+ const editCount = Object.keys(policyEdits).length;
2030
+ if (editCount > 0) {
2031
+ html += '<div class="policy-save-bar">';
2032
+ html += '<span style="font-size:10px;color:var(--text-muted)">' + editCount + ' unsaved change' + (editCount !== 1 ? 's' : '') + '</span>';
2033
+ html += '<button class="btn-discard" onclick="loadRoutingPolicy()">Discard</button>';
2034
+ html += '<button class="btn-save" onclick="saveRoutingPolicy()">Save</button>';
2035
+ html += '</div>';
2036
+ }
2037
+
2038
+ panel.innerHTML = html;
2039
+ }
2040
+
2041
+ function removePolicyTag(agentId, tagIndex) {
2042
+ if (!routingPolicyData) return;
2043
+ const agent = routingPolicyData.agents.find(function(a) { return a.agentId === agentId; });
2044
+ if (!agent) return;
2045
+ const edited = policyEdits[agentId] || JSON.parse(JSON.stringify(agent));
2046
+ edited.affinityTags.splice(tagIndex, 1);
2047
+ policyEdits[agentId] = edited;
2048
+ renderRoutingPolicy();
2049
+ }
2050
+
2051
+ function addPolicyTag(event, agentId) {
2052
+ if (event.key !== 'Enter') return;
2053
+ const val = event.target.value.trim();
2054
+ if (!val) return;
2055
+ if (!routingPolicyData) return;
2056
+ const agent = routingPolicyData.agents.find(function(a) { return a.agentId === agentId; });
2057
+ if (!agent) return;
2058
+ const edited = policyEdits[agentId] || JSON.parse(JSON.stringify(agent));
2059
+ if (!edited.affinityTags.includes(val)) {
2060
+ edited.affinityTags.push(val);
2061
+ }
2062
+ policyEdits[agentId] = edited;
2063
+ event.target.value = '';
2064
+ renderRoutingPolicy();
2065
+ }
2066
+
2067
+ function updatePolicyWeight(agentId, sliderVal) {
2068
+ if (!routingPolicyData) return;
2069
+ const agent = routingPolicyData.agents.find(function(a) { return a.agentId === agentId; });
2070
+ if (!agent) return;
2071
+ const edited = policyEdits[agentId] || JSON.parse(JSON.stringify(agent));
2072
+ edited.weight = Number(sliderVal) / 10;
2073
+ policyEdits[agentId] = edited;
2074
+ renderRoutingPolicy();
2075
+ }
2076
+
2077
+ async function saveRoutingPolicy() {
2078
+ if (!routingPolicyData) return;
2079
+ const agents = routingPolicyData.agents.map(function(a) {
2080
+ return policyEdits[a.agentId] || a;
2081
+ });
2082
+ try {
2083
+ const res = await fetch(BASE + '/routing-policy', {
2084
+ method: 'PUT',
2085
+ headers: { 'Content-Type': 'application/json' },
2086
+ body: JSON.stringify({ agents: agents, updatedBy: 'dashboard' })
2087
+ });
2088
+ const result = await res.json();
2089
+ if (result.success) {
2090
+ policyEdits = {};
2091
+ await loadRoutingPolicy();
2092
+ }
2093
+ } catch (e) { console.error('Save policy failed:', e); }
2094
+ }
2095
+
2096
+ // ---- Task Search (Office Suite Spine) ----
2097
+ async function runTaskSearch() {
2098
+ const input = document.getElementById('task-search-input');
2099
+ const resultsEl = document.getElementById('task-search-results');
2100
+ const countEl = document.getElementById('search-count');
2101
+ if (!input || !resultsEl || !countEl) return;
2102
+
2103
+ const q = (input.value || '').trim();
2104
+ if (!q) {
2105
+ countEl.textContent = '';
2106
+ resultsEl.innerHTML = '<div class="empty" style="color:var(--text-muted)">Type a query and press Enter…</div>';
2107
+ return;
2108
+ }
2109
+
2110
+ resultsEl.innerHTML = '<div class="empty" style="color:var(--text-muted)">Searching…</div>';
2111
+
2112
+ try {
2113
+ const res = await fetch(BASE + '/tasks/search?q=' + encodeURIComponent(q) + '&limit=12');
2114
+ if (!res.ok) throw new Error('status ' + res.status);
2115
+ const data = await res.json();
2116
+ const tasks = Array.isArray(data.tasks) ? data.tasks : [];
2117
+
2118
+ countEl.textContent = tasks.length + ' result' + (tasks.length === 1 ? '' : 's');
2119
+
2120
+ if (tasks.length === 0) {
2121
+ resultsEl.innerHTML = '<div class="empty" style="color:var(--text-muted)">No matches</div>';
2122
+ return;
2123
+ }
2124
+
2125
+ resultsEl.innerHTML = tasks.map(t => {
2126
+ const assignee = t.assignee ? '@' + esc(t.assignee) : '<span style="color:var(--yellow)">unassigned</span>';
2127
+ const pri = t.priority ? '<span class="priority-badge ' + esc(t.priority) + '">' + esc(t.priority) + '</span>' : '';
2128
+ const title = esc(truncate(t.title || t.id, 80));
2129
+ const id = esc(t.id);
2130
+ const status = esc(t.status || 'todo');
2131
+ return '<div class="backlog-item" role="button" tabindex="0" style="padding:10px 14px;border-bottom:1px solid var(--border-subtle);cursor:pointer" onclick="openTaskModal(\'' + id + '\')">'
2132
+ + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">'
2133
+ + pri
2134
+ + '<span style="color:var(--text-bright);font-size:13px;font-weight:500">' + title + '</span>'
2135
+ + '</div>'
2136
+ + '<div style="font-size:11px;color:var(--text-muted)">'
2137
+ + '<span>' + id + '</span> · <span>' + status + '</span> · <span>' + assignee + '</span>'
2138
+ + '</div>'
2139
+ + '</div>';
2140
+ }).join('');
2141
+ } catch (err) {
2142
+ countEl.textContent = 'error';
2143
+ resultsEl.innerHTML = '<div class="empty" style="color:var(--red)">Search failed</div>';
2144
+ }
2145
+ }
2146
+
2147
+ async function refresh() {
2148
+ refreshCount += 1;
2149
+ const forceFull = refreshCount % 12 === 0; // full sync less often with adaptive polling
2150
+ if (refreshCount === 1 || forceFull) await refreshAgentRegistry();
2151
+ await loadTasks(forceFull);
2152
+ renderReviewQueue();
2153
+ await Promise.all([loadPresence(), loadChat(forceFull), loadActivity(forceFull), loadResearch(), loadSharedArtifacts(), loadHealth(), loadReleaseStatus(forceFull), loadBuildInfo(), loadRuntimeTruthCard(), loadApprovalQueue(), loadFeedback(), loadPauseStatus(), loadIntensityControl(), loadPolls()]);
2154
+ await renderPromotionSSOT();
2155
+ }
2156
+
2157
+ let refreshTimer = null;
2158
+ let refreshInFlight = false;
2159
+
2160
+ // SSE live updates
2161
+ let eventSource = null;
2162
+ let sseReconnectTimer = null;
2163
+ let sseRefreshTimer = null;
2164
+ let sseBackoffMs = 1500;
2165
+ const SSE_MAX_BACKOFF_MS = 20000;
2166
+ const SSE_TOPICS = 'task,message,presence,memory';
2167
+
2168
+ function getRefreshIntervalMs() {
2169
+ if (document.hidden) return 60000; // background tabs poll lightly
2170
+ const recentActivityMs = Date.now() - Math.max(lastChatSync || 0, lastActivitySync || 0, lastTaskSync || 0);
2171
+ if (recentActivityMs < 2 * 60 * 1000) return 20000; // active team chatter
2172
+ return 30000; // normal foreground cadence
2173
+ }
2174
+
2175
+ async function scheduleNextRefresh() {
2176
+ if (refreshInFlight) return;
2177
+ refreshInFlight = true;
2178
+ try {
2179
+ await refresh();
2180
+ } finally {
2181
+ refreshInFlight = false;
2182
+ refreshTimer = setTimeout(scheduleNextRefresh, getRefreshIntervalMs());
2183
+ }
2184
+ }
2185
+
2186
+ function startAdaptiveRefresh() {
2187
+ if (refreshTimer) clearTimeout(refreshTimer);
2188
+ refreshTimer = setTimeout(scheduleNextRefresh, getRefreshIntervalMs());
2189
+ }
2190
+
2191
+ function queueSseRefresh() {
2192
+ if (sseRefreshTimer) return;
2193
+ sseRefreshTimer = setTimeout(async () => {
2194
+ sseRefreshTimer = null;
2195
+ try {
2196
+ await refresh();
2197
+ startAdaptiveRefresh();
2198
+ } catch (err) {
2199
+ console.error('SSE refresh failed:', err);
2200
+ }
2201
+ }, 250);
2202
+ }
2203
+
2204
+ function handleSsePayload(eventType, payload) {
2205
+ if (eventType === 'batch' && Array.isArray(payload)) {
2206
+ queueSseRefresh();
2207
+ return;
2208
+ }
2209
+
2210
+ switch (eventType) {
2211
+ case 'message_posted':
2212
+ case 'task_created':
2213
+ case 'task_assigned':
2214
+ case 'task_updated':
2215
+ case 'presence_updated':
2216
+ case 'memory_written':
2217
+ queueSseRefresh();
2218
+ break;
2219
+ default:
2220
+ // ignore unknown event types
2221
+ break;
2222
+ }
2223
+ }
2224
+
2225
+ function connectEventStream() {
2226
+ if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
2227
+ if (eventSource) return;
2228
+
2229
+ const url = `${BASE}/events?topics=${encodeURIComponent(SSE_TOPICS)}`;
2230
+ const es = new EventSource(url);
2231
+ eventSource = es;
2232
+
2233
+ const onAnyEvent = (event) => {
2234
+ try {
2235
+ const payload = event && event.data ? JSON.parse(event.data) : null;
2236
+ handleSsePayload(event.type || 'message', payload);
2237
+ } catch {
2238
+ queueSseRefresh();
2239
+ }
2240
+ };
2241
+
2242
+ es.onopen = () => {
2243
+ sseBackoffMs = 1500;
2244
+ };
2245
+
2246
+ es.onerror = () => {
2247
+ if (eventSource) {
2248
+ eventSource.close();
2249
+ eventSource = null;
2250
+ }
2251
+ if (sseReconnectTimer) return;
2252
+
2253
+ sseReconnectTimer = setTimeout(() => {
2254
+ sseReconnectTimer = null;
2255
+ connectEventStream();
2256
+ }, sseBackoffMs);
2257
+
2258
+ sseBackoffMs = Math.min(SSE_MAX_BACKOFF_MS, Math.floor(sseBackoffMs * 1.8));
2259
+ };
2260
+
2261
+ ['message_posted', 'task_created', 'task_assigned', 'task_updated', 'presence_updated', 'memory_written', 'batch']
2262
+ .forEach(type => es.addEventListener(type, onAnyEvent));
2263
+ }
2264
+
2265
+ document.addEventListener('visibilitychange', () => {
2266
+ startAdaptiveRefresh();
2267
+ if (!document.hidden && !eventSource) connectEventStream();
2268
+ });
2269
+
2270
+ window.addEventListener('beforeunload', () => {
2271
+ if (eventSource) {
2272
+ eventSource.close();
2273
+ eventSource = null;
2274
+ }
2275
+ if (sseReconnectTimer) {
2276
+ clearTimeout(sseReconnectTimer);
2277
+ sseReconnectTimer = null;
2278
+ }
2279
+ });
2280
+
2281
+ // ---- Task Modal ----
2282
+ let currentTask = null;
2283
+
2284
+ function setTaskModalInteractivity(enabled) {
2285
+ document.querySelectorAll('.status-btn').forEach(btn => {
2286
+ btn.disabled = !enabled;
2287
+ });
2288
+ const assigneeInput = document.getElementById('modal-task-assignee');
2289
+ if (assigneeInput) assigneeInput.disabled = !enabled;
2290
+ }
2291
+
2292
+ // --- Artifacts (task modal) ---
2293
+ let taskArtifactsPreviewCache = new Map(); // taskId -> Map(path -> artifact)
2294
+
2295
+ function renderTaskArtifactsLoading() {
2296
+ const el = document.getElementById('modal-task-artifacts');
2297
+ if (!el) return;
2298
+ el.innerHTML = '<div class="empty" style="color:var(--text-muted)">Loading artifacts…</div>';
2299
+ }
2300
+
2301
+ function renderTaskArtifactsEmpty(message) {
2302
+ const el = document.getElementById('modal-task-artifacts');
2303
+ if (!el) return;
2304
+ el.innerHTML = '<div class="empty" style="color:var(--text-muted)">' + esc(message || 'No artifacts attached') + '</div>';
2305
+ }
2306
+
2307
+ function renderTaskArtifactsError(message) {
2308
+ const el = document.getElementById('modal-task-artifacts');
2309
+ if (!el) return;
2310
+ el.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(message || 'Failed to load artifacts') + '</div>';
2311
+ }
2312
+
2313
+ async function fetchTaskArtifacts(taskId, includeMode) {
2314
+ const qs = includeMode ? ('?include=' + encodeURIComponent(includeMode)) : '';
2315
+ const url = BASE + '/tasks/' + encodeURIComponent(taskId) + '/artifacts' + qs;
2316
+ const res = await fetch(url);
2317
+ if (!res.ok) {
2318
+ throw new Error('HTTP ' + res.status);
2319
+ }
2320
+ return await res.json();
2321
+ }
2322
+
2323
+ function renderArtifactRow(taskId, a, idx) {
2324
+ const path = String(a.path || '');
2325
+ const source = String(a.source || '');
2326
+ const type = String(a.type || '');
2327
+ const accessible = Boolean(a.accessible);
2328
+
2329
+ const pill = '<span class="artifact-pill ' + (accessible ? 'ok' : 'missing') + '">' + (accessible ? 'OK' : 'MISSING') + '</span>';
2330
+
2331
+ const metaParts = [];
2332
+ if (type) metaParts.push(type);
2333
+ if (source) metaParts.push(source);
2334
+ if (!accessible && a.error) metaParts.push(String(a.error));
2335
+
2336
+ const previewElId = 'artifact-preview-' + idx;
2337
+ const encTaskId = encodeURIComponent(taskId);
2338
+ const encPath = encodeURIComponent(path);
2339
+
2340
+ let actions = '';
2341
+ if (accessible) {
2342
+ if (type === 'file' && path.startsWith('process/')) {
2343
+ actions += '<button class="artifact-btn" onclick="toggleArtifactPreview(\'' + encTaskId + '\',\'' + encPath + '\',\'' + previewElId + '\')">Preview</button>';
2344
+ }
2345
+ if (type === 'url') {
2346
+ const url = String(a.resolvedPath || a.path || '');
2347
+ if (url) {
2348
+ actions += '<a class="artifact-btn" href="' + esc(url) + '" target="_blank" rel="noreferrer noopener">Open ↗</a>';
2349
+ }
2350
+ }
2351
+ }
2352
+
2353
+ const actionsHtml = actions ? '<div class="artifact-actions">' + actions + '</div>' : '';
2354
+
2355
+ const previewBox = '<pre id="' + esc(previewElId) + '" class="artifact-preview" style="display:none;margin-top:10px;white-space:pre-wrap;word-break:break-word;background:#0f141a;border:1px solid var(--border-subtle);border-radius:10px;padding:10px;font-size:12px;line-height:1.5"></pre>';
2356
+
2357
+ return '<div class="artifact-row">'
2358
+ + '<div class="artifact-top">'
2359
+ + '<div class="artifact-path">' + esc(path || '(missing path)') + '</div>'
2360
+ + pill
2361
+ + '</div>'
2362
+ + '<div class="artifact-meta">' + esc(metaParts.filter(Boolean).join(' · ') || '—') + '</div>'
2363
+ + actionsHtml
2364
+ + previewBox
2365
+ + '</div>';
2366
+ }
2367
+
2368
+ async function loadTaskArtifacts(taskId) {
2369
+ const el = document.getElementById('modal-task-artifacts');
2370
+ if (!el) return;
2371
+
2372
+ if (!taskId) {
2373
+ renderTaskArtifactsEmpty('Not available');
2374
+ return;
2375
+ }
2376
+
2377
+ renderTaskArtifactsLoading();
2378
+ taskArtifactsPreviewCache.delete(taskId);
2379
+
2380
+ try {
2381
+ const data = await fetchTaskArtifacts(taskId);
2382
+ const artifacts = Array.isArray(data.artifacts) ? data.artifacts : [];
2383
+
2384
+ if (artifacts.length === 0) {
2385
+ renderTaskArtifactsEmpty();
2386
+ return;
2387
+ }
2388
+
2389
+ el.innerHTML = artifacts.map((a, i) => renderArtifactRow(taskId, a, i)).join('');
2390
+ } catch (err) {
2391
+ renderTaskArtifactsError('Failed to load artifacts');
2392
+ }
2393
+ }
2394
+
2395
+ async function getPreviewMapForTask(taskId) {
2396
+ if (taskArtifactsPreviewCache.has(taskId)) return taskArtifactsPreviewCache.get(taskId);
2397
+
2398
+ const data = await fetchTaskArtifacts(taskId, 'preview');
2399
+ const artifacts = Array.isArray(data.artifacts) ? data.artifacts : [];
2400
+ const m = new Map();
2401
+ artifacts.forEach(a => {
2402
+ if (a && a.path) m.set(String(a.path), a);
2403
+ });
2404
+ taskArtifactsPreviewCache.set(taskId, m);
2405
+ return m;
2406
+ }
2407
+
2408
+ async function toggleArtifactPreview(encTaskId, encPath, previewElId) {
2409
+ const taskId = decodeURIComponent(encTaskId || '');
2410
+ const path = decodeURIComponent(encPath || '');
2411
+ const el = document.getElementById(previewElId);
2412
+ if (!el) return;
2413
+
2414
+ // toggle
2415
+ const isHidden = el.style.display === 'none' || !el.style.display;
2416
+ if (!isHidden) {
2417
+ el.style.display = 'none';
2418
+ return;
2419
+ }
2420
+ el.style.display = 'block';
2421
+
2422
+ // already loaded
2423
+ if (el.dataset.loaded === '1') return;
2424
+
2425
+ el.textContent = 'Loading preview…';
2426
+
2427
+ try {
2428
+ const m = await getPreviewMapForTask(taskId);
2429
+ const a = m.get(path);
2430
+
2431
+ if (!a || !a.preview) {
2432
+ el.textContent = 'Preview not available (only process/* file artifacts are previewable).';
2433
+ el.dataset.loaded = '1';
2434
+ return;
2435
+ }
2436
+
2437
+ const truncated = Boolean(a.previewTruncated);
2438
+ el.textContent = String(a.preview) + (truncated ? '\n\n[truncated]' : '');
2439
+ el.dataset.loaded = '1';
2440
+ } catch (err) {
2441
+ el.textContent = 'Failed to load preview';
2442
+ }
2443
+ }
2444
+
2445
+ function openTaskModal(taskId) {
2446
+ currentTask = allTasks.find(t => t.id === taskId);
2447
+
2448
+ if (!currentTask) {
2449
+ setTaskModalInteractivity(false);
2450
+ document.getElementById('modal-task-title').textContent = 'Task not found: ' + (taskId || '(missing id)');
2451
+ document.getElementById('modal-task-desc').textContent = 'This task ID was referenced in chat but is not present in the current task set. It may be archived, deleted, or not yet synced.';
2452
+ document.getElementById('modal-task-id').textContent = taskId || '(missing id)';
2453
+ document.getElementById('modal-task-assignee').value = '';
2454
+ document.getElementById('modal-task-priority').textContent = '—';
2455
+ document.getElementById('modal-task-created').textContent = 'Not available';
2456
+ const blockerEl = document.getElementById('modal-task-blockers');
2457
+ if (blockerEl) blockerEl.textContent = 'Not available';
2458
+ renderTaskArtifactsEmpty('Not available');
2459
+ document.querySelectorAll('.status-btn').forEach(btn => btn.classList.remove('active'));
2460
+ document.getElementById('task-modal').classList.add('show');
2461
+ return;
2462
+ }
2463
+
2464
+ setTaskModalInteractivity(true);
2465
+ const creatorAgent = AGENTS.find(a => a.name === currentTask.createdBy);
2466
+ const createdText = creatorAgent
2467
+ ? `${currentTask.createdBy} (${creatorAgent.role}) • ${ago(currentTask.createdAt)}`
2468
+ : `${currentTask.createdBy} • ${ago(currentTask.createdAt)}`;
2469
+
2470
+ document.getElementById('modal-task-title').textContent = currentTask.title;
2471
+ document.getElementById('modal-task-desc').textContent = currentTask.description || '(no description)';
2472
+ document.getElementById('modal-task-id').textContent = currentTask.id || '(missing id)';
2473
+ document.getElementById('modal-task-assignee').value = currentTask.assignee || '';
2474
+ document.getElementById('modal-task-priority').textContent = currentTask.priority || 'P3';
2475
+
2476
+ // Branch display
2477
+ const branchSection = document.getElementById('modal-branch-section');
2478
+ const branchEl = document.getElementById('modal-task-branch');
2479
+ if (branchSection && branchEl) {
2480
+ const branch = currentTask.metadata?.branch;
2481
+ if (branch) {
2482
+ branchEl.textContent = branch;
2483
+ branchSection.style.display = '';
2484
+ } else {
2485
+ branchSection.style.display = 'none';
2486
+ }
2487
+ }
2488
+
2489
+ document.getElementById('modal-task-created').textContent = createdText;
2490
+
2491
+ const blockerEl = document.getElementById('modal-task-blockers');
2492
+ if (blockerEl) {
2493
+ const blockedHtml = renderBlockedByLinks(currentTask) || '<span style="color:var(--text-dim)">None</span>';
2494
+ blockerEl.innerHTML = blockedHtml;
2495
+ }
2496
+
2497
+ // Set active status button
2498
+ document.querySelectorAll('.status-btn').forEach(btn => {
2499
+ btn.classList.toggle('active', btn.dataset.status === currentTask.status);
2500
+ });
2501
+
2502
+ document.getElementById('task-modal').classList.add('show');
2503
+
2504
+ // Load artifacts section
2505
+ loadTaskArtifacts(currentTask.id);
2506
+
2507
+ // Load PR review quality panel
2508
+ loadPrReviewPanel(currentTask);
2509
+ }
2510
+
2511
+ function formatDuration(sec) {
2512
+ if (sec == null) return '';
2513
+ if (sec < 60) return sec + 's';
2514
+ return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
2515
+ }
2516
+
2517
+ async function loadPrReviewPanel(task) {
2518
+ const panel = document.getElementById('pr-review-panel');
2519
+ const loading = document.getElementById('pr-review-loading');
2520
+ const content = document.getElementById('pr-review-content');
2521
+ if (!panel || !loading || !content) return;
2522
+
2523
+ // Check if task might have PR data
2524
+ const prUrl = extractTaskPrLink(task);
2525
+ const isReviewable = task && (task.status === 'validating' || task.status === 'done' || prUrl);
2526
+ if (!isReviewable) {
2527
+ panel.style.display = 'none';
2528
+ return;
2529
+ }
2530
+
2531
+ panel.style.display = '';
2532
+ loading.style.display = '';
2533
+ content.style.display = 'none';
2534
+
2535
+ try {
2536
+ const res = await fetch(BASE + '/tasks/' + encodeURIComponent(task.id) + '/pr-review');
2537
+ const data = await res.json();
2538
+
2539
+ if (!data.available) {
2540
+ panel.style.display = 'none';
2541
+ return;
2542
+ }
2543
+
2544
+ loading.style.display = 'none';
2545
+ content.style.display = '';
2546
+ content.innerHTML = renderPrReviewPanel(data);
2547
+ } catch (e) {
2548
+ panel.style.display = 'none';
2549
+ }
2550
+ }
2551
+
2552
+ function renderPrReviewPanel(data) {
2553
+ const pr = data.pr || {};
2554
+ const diff = data.diffScope || {};
2555
+ const ci = data.ci || {};
2556
+ const alignment = data.doneCriteriaAlignment || {};
2557
+
2558
+ let html = '';
2559
+
2560
+ // PR Header
2561
+ html += '<div class="pr-review-header">';
2562
+ html += '<div class="pr-title">' + esc(pr.title || 'PR #' + pr.number) + '</div>';
2563
+ html += '<div class="pr-meta">';
2564
+ html += (pr.state === 'closed' && pr.merged ? '🟣 Merged' : pr.state === 'closed' ? '🔴 Closed' : '🟢 Open');
2565
+ html += ' · ' + esc(pr.author || 'unknown');
2566
+ if (pr.updatedAt) html += ' · Updated ' + ago(new Date(pr.updatedAt).getTime());
2567
+ html += ' · <a href="' + esc(pr.url) + '" target="_blank">View on GitHub ↗</a>';
2568
+ html += '</div></div>';
2569
+
2570
+ // Diff Scope
2571
+ html += '<div class="pr-review-section">';
2572
+ html += '<div class="pr-review-section-title">📊 Diff Scope <span class="risk-badge ' + esc(diff.riskLevel || 'small') + '">' + esc(diff.riskLevel || 'small') + ' change</span></div>';
2573
+ html += '<div class="diff-scope-grid">';
2574
+ html += '<div class="diff-stat-card"><div class="stat-value">' + (diff.changedFiles || 0) + '</div><div class="stat-label">Files</div></div>';
2575
+ html += '<div class="diff-stat-card"><div class="stat-value" style="color:var(--green)">+' + (diff.additions || 0) + '</div><div class="stat-label">Added</div></div>';
2576
+ html += '<div class="diff-stat-card"><div class="stat-value" style="color:var(--red)">-' + (diff.deletions || 0) + '</div><div class="stat-label">Deleted</div></div>';
2577
+ html += '<div class="diff-stat-card"><div class="stat-value">' + (diff.commits || 0) + '</div><div class="stat-label">Commits</div></div>';
2578
+ html += '</div>';
2579
+
2580
+ // Directory breakdown
2581
+ if (diff.directories && diff.directories.length > 0) {
2582
+ html += '<div style="margin-top:6px">';
2583
+ diff.directories.slice(0, 8).forEach(function(d) {
2584
+ html += '<div class="dir-row">';
2585
+ html += '<span class="dir-name">' + esc(d.dir) + '/</span>';
2586
+ html += '<span class="dir-stats">' + d.files + ' file' + (d.files !== 1 ? 's' : '') + ' <span style="color:var(--green)">+' + d.additions + '</span> / <span style="color:var(--red)">-' + d.deletions + '</span></span>';
2587
+ html += '</div>';
2588
+ });
2589
+ html += '</div>';
2590
+ }
2591
+ html += '</div>';
2592
+
2593
+ // CI Checks
2594
+ if (ci.total > 0 || (ci.qaBundleChecks && ci.qaBundleChecks.length > 0)) {
2595
+ html += '<div class="pr-review-section">';
2596
+ const allPass = ci.failed === 0 && ci.total > 0;
2597
+ html += '<div class="pr-review-section-title">' + (allPass ? '✅' : '❌') + ' CI Checks (' + ci.passed + '/' + ci.total + ' passed)</div>';
2598
+
2599
+ ci.checks.forEach(function(c) {
2600
+ const icon = c.conclusion === 'success' ? '✅' : c.conclusion === 'failure' ? '❌' : c.conclusion === 'skipped' ? '⏭️' : '⏳';
2601
+ html += '<div class="ci-check-row">';
2602
+ html += '<span class="check-icon">' + icon + '</span>';
2603
+ html += '<span class="check-name">' + esc(c.name) + '</span>';
2604
+ if (c.durationSec != null) html += '<span class="check-duration">' + formatDuration(c.durationSec) + '</span>';
2605
+ if (c.detailsUrl) html += '<a href="' + esc(c.detailsUrl) + '" target="_blank">logs</a>';
2606
+ html += '</div>';
2607
+ });
2608
+
2609
+ // QA bundle manual checks
2610
+ if (ci.qaBundleChecks && ci.qaBundleChecks.length > 0) {
2611
+ html += '<div style="margin-top:8px;font-size:11px;color:var(--text-muted);font-weight:600">Manual QA</div>';
2612
+ ci.qaBundleChecks.forEach(function(c) {
2613
+ html += '<div class="ci-check-row"><span class="check-icon">✓</span><span class="check-name" style="color:var(--text-muted)">' + esc(c) + '</span></div>';
2614
+ });
2615
+ }
2616
+
2617
+ html += '</div>';
2618
+ }
2619
+
2620
+ // Done Criteria Alignment
2621
+ if (alignment.criteria && alignment.criteria.length > 0) {
2622
+ const summary = alignment.summary || {};
2623
+ const coverageIcon = summary.none === 0 ? '✅' : summary.none <= 1 ? '⚠️' : '❌';
2624
+ html += '<div class="pr-review-section">';
2625
+ html += '<div class="pr-review-section-title">' + coverageIcon + ' Done Criteria (' + (summary.total - summary.none) + '/' + summary.total + ' aligned)</div>';
2626
+
2627
+ alignment.criteria.forEach(function(c, i) {
2628
+ const icon = c.confidence === 'high' ? '✅' : c.confidence === 'medium' ? '🟡' : c.confidence === 'low' ? '⚠️' : '❌';
2629
+ html += '<div class="criterion-row">';
2630
+ html += '<div class="criterion-text"><span>' + icon + '</span> <span>' + esc(c.criterion) + '</span></div>';
2631
+ html += '<div class="criterion-evidence">';
2632
+ html += '<span class="confidence-badge ' + esc(c.confidence) + '">' + esc(c.confidence) + '</span>';
2633
+ if (c.fileMatches && c.fileMatches.length > 0) {
2634
+ html += '<div class="evidence-item">Files: ' + c.fileMatches.map(function(f) { return '<code style="font-size:10px">' + esc(f) + '</code>'; }).join(', ') + '</div>';
2635
+ }
2636
+ if (c.testMatches && c.testMatches.length > 0) {
2637
+ html += '<div class="evidence-item">Tests: ' + c.testMatches.map(function(t) { return esc(t); }).join(', ') + '</div>';
2638
+ }
2639
+ if (c.hasArtifact) {
2640
+ html += '<div class="evidence-item">Artifact: present</div>';
2641
+ }
2642
+ if (c.confidence === 'none') {
2643
+ html += '<div class="evidence-item" style="color:var(--red)">⚠ No matching evidence — manual review needed</div>';
2644
+ }
2645
+ html += '</div></div>';
2646
+ });
2647
+
2648
+ html += '<div style="font-size:10px;color:var(--text-dim);margin-top:6px">Confidence: ' + summary.high + ' high, ' + summary.medium + ' medium, ' + summary.low + ' low, ' + summary.none + ' none</div>';
2649
+ html += '</div>';
2650
+ }
2651
+
2652
+ return html;
2653
+ }
2654
+
2655
+ async function copyTaskId() {
2656
+ const taskId = currentTask && currentTask.id ? currentTask.id : document.getElementById('modal-task-id').textContent;
2657
+ if (!taskId) return;
2658
+ try {
2659
+ await navigator.clipboard.writeText(taskId);
2660
+ } catch (_e) {
2661
+ // Fallback for older browser contexts
2662
+ const ta = document.createElement('textarea');
2663
+ ta.value = taskId;
2664
+ ta.style.position = 'fixed';
2665
+ ta.style.opacity = '0';
2666
+ document.body.appendChild(ta);
2667
+ ta.focus();
2668
+ ta.select();
2669
+ document.execCommand('copy');
2670
+ document.body.removeChild(ta);
2671
+ }
2672
+ }
2673
+
2674
+ function closeTaskModal() {
2675
+ document.getElementById('task-modal').classList.remove('show');
2676
+ currentTask = null;
2677
+ setTaskModalInteractivity(true);
2678
+ }
2679
+
2680
+ async function updateTaskStatus(status) {
2681
+ if (!currentTask) return;
2682
+ try {
2683
+ const r = await fetch(`${BASE}/tasks/${currentTask.id}`, {
2684
+ method: 'PATCH',
2685
+ headers: { 'Content-Type': 'application/json' },
2686
+ body: JSON.stringify({ status })
2687
+ });
2688
+ if (r.ok) {
2689
+ await loadTasks();
2690
+ closeTaskModal();
2691
+ }
2692
+ } catch (e) {
2693
+ console.error('Failed to update task status:', e);
2694
+ }
2695
+ }
2696
+
2697
+ async function updateTaskAssignee() {
2698
+ if (!currentTask) return;
2699
+ const assignee = document.getElementById('modal-task-assignee').value.trim() || undefined;
2700
+ try {
2701
+ const r = await fetch(`${BASE}/tasks/${currentTask.id}`, {
2702
+ method: 'PATCH',
2703
+ headers: { 'Content-Type': 'application/json' },
2704
+ body: JSON.stringify({ assignee })
2705
+ });
2706
+ if (r.ok) {
2707
+ await loadTasks();
2708
+ currentTask.assignee = assignee;
2709
+ }
2710
+ } catch (e) {
2711
+ console.error('Failed to update task assignee:', e);
2712
+ }
2713
+ }
2714
+
2715
+ // ============ FOCUS MODE ============
2716
+
2717
+ function toggleFocusMode() {
2718
+ focusModeActive = !focusModeActive;
2719
+ document.body.classList.toggle('focus-mode', focusModeActive);
2720
+ const btn = document.getElementById('focus-toggle');
2721
+ if (btn) btn.classList.toggle('active', focusModeActive);
2722
+
2723
+ // Persist preference
2724
+ try { localStorage.setItem('reflectt-focus-mode', focusModeActive ? '1' : '0'); } catch {}
2725
+
2726
+ // Re-render kanban to add/remove QA contract details
2727
+ renderKanban();
2728
+
2729
+ // Toggle collapsed panels — allow click to temporarily expand
2730
+ document.querySelectorAll('.panel.focus-collapse').forEach(panel => {
2731
+ if (!panel.dataset.focusClickBound) {
2732
+ panel.addEventListener('click', () => {
2733
+ if (!focusModeActive) return;
2734
+ panel.classList.toggle('focus-expanded');
2735
+ if (panel.classList.contains('focus-expanded')) {
2736
+ panel.style.opacity = '1';
2737
+ panel.querySelectorAll('.panel-body, .channel-tabs, .chat-input-bar, .project-tabs, .kanban').forEach(el => {
2738
+ el.style.display = '';
2739
+ });
2740
+ } else {
2741
+ panel.style.opacity = '';
2742
+ // CSS will re-hide via focus-collapse rules
2743
+ }
2744
+ });
2745
+ panel.dataset.focusClickBound = 'true';
2746
+ }
2747
+ // Reset expanded state when toggling focus mode
2748
+ panel.classList.remove('focus-expanded');
2749
+ panel.style.opacity = '';
2750
+ });
2751
+ }
2752
+
2753
+ function renderQaContract(task) {
2754
+ if (!focusModeActive) return '';
2755
+ const meta = task.metadata || {};
2756
+ const reviewer = task.reviewer || null;
2757
+ const eta = meta.eta || null;
2758
+ const hasArtifact = !!(meta.artifact_path || (Array.isArray(meta.artifacts) && meta.artifacts.length > 0));
2759
+ const prUrl = extractTaskPrLink(task);
2760
+
2761
+ return `<div class="qa-contract">
2762
+ <div class="qa-row">
2763
+ <span class="qa-label">Owner</span>
2764
+ <span class="qa-value">${task.assignee ? esc(task.assignee) : '<span class="missing">unassigned</span>'}</span>
2765
+ </div>
2766
+ <div class="qa-row">
2767
+ <span class="qa-label">Reviewer</span>
2768
+ <span class="qa-value${!reviewer ? ' missing' : ''}">${reviewer ? esc(reviewer) : 'none'}</span>
2769
+ </div>
2770
+ <div class="qa-row">
2771
+ <span class="qa-label">ETA</span>
2772
+ <span class="qa-value${!eta ? ' missing' : ''}">${eta ? esc(String(eta)) : 'not set'}</span>
2773
+ </div>
2774
+ <div class="qa-row">
2775
+ <span class="qa-label">Artifact</span>
2776
+ <span class="qa-value${hasArtifact ? ' has-artifact' : ' missing'}">${hasArtifact ? (prUrl ? '<a href="' + esc(prUrl) + '" target="_blank" style="color:var(--green)">PR ↗</a>' : '✓ present') : 'none'}</span>
2777
+ </div>
2778
+ </div>`;
2779
+ }
2780
+
2781
+ // Restore focus mode from localStorage
2782
+ (function restoreFocusMode() {
2783
+ try {
2784
+ if (localStorage.getItem('reflectt-focus-mode') === '1') {
2785
+ focusModeActive = true;
2786
+ document.body.classList.add('focus-mode');
2787
+ const btn = document.getElementById('focus-toggle');
2788
+ if (btn) btn.classList.add('active');
2789
+ }
2790
+ } catch {}
2791
+ })();
2792
+
2793
+ // ── Getting Started panel ──────────────────────────────────
2794
+ async function checkGettingStarted() {
2795
+ const panel = document.getElementById('getting-started');
2796
+ if (!panel) return;
2797
+
2798
+ // Respect manual dismiss
2799
+ try {
2800
+ if (localStorage.getItem('reflectt-gs-dismissed') === '1') {
2801
+ panel.classList.add('hidden');
2802
+ return;
2803
+ }
2804
+ } catch {}
2805
+
2806
+ // Check system state to auto-hide and mark steps done
2807
+ try {
2808
+ const res = await fetch(BASE + '/health');
2809
+ if (!res.ok) return;
2810
+ const health = await res.json();
2811
+
2812
+ // Step 1 done: check if system health loops are ticking (not just uptime > 0)
2813
+ const hasHeartbeat = !!(health.system?.loops?.lastTickAt || (health.tasks?.total > 0));
2814
+ const hasTasks = (health.tasks?.total || 0) > 0;
2815
+ const hasMessages = (health.chat?.total || 0) > 0;
2816
+
2817
+ // Step 1: preflight — done if server is healthy
2818
+ const step1 = document.getElementById('gs-preflight');
2819
+ if (step1 && hasHeartbeat) {
2820
+ step1.classList.add('done');
2821
+ step1.querySelector('.gs-icon').textContent = '✓';
2822
+ }
2823
+
2824
+ // Step 2: connect — check if OpenClaw gateway is configured
2825
+ const step2 = document.getElementById('gs-connect');
2826
+ if (step2 && health.openclaw) {
2827
+ const ocStatus = typeof health.openclaw === 'string' ? health.openclaw : health.openclaw.status;
2828
+ if (ocStatus === 'configured') {
2829
+ step2.classList.add('done');
2830
+ step2.querySelector('.gs-icon').textContent = '✓';
2831
+ }
2832
+ }
2833
+
2834
+ // Step 3: first task/message — done if any exist
2835
+ const step3 = document.getElementById('gs-task');
2836
+ if (step3 && (hasTasks || hasMessages)) {
2837
+ step3.classList.add('done');
2838
+ step3.querySelector('.gs-icon').textContent = '✓';
2839
+ }
2840
+
2841
+ // Auto-hide if all steps done
2842
+ const allDone = panel.querySelectorAll('.gs-step.done').length === 3;
2843
+ if (allDone) {
2844
+ panel.classList.add('hidden');
2845
+ }
2846
+ } catch {}
2847
+ }
2848
+
2849
+ function dismissGettingStarted() {
2850
+ const panel = document.getElementById('getting-started');
2851
+ if (panel) panel.classList.add('hidden');
2852
+ try { localStorage.setItem('reflectt-gs-dismissed', '1'); } catch {}
2853
+ }
2854
+
2855
+ updateClock();
2856
+ setInterval(updateClock, 30000);
2857
+ checkGettingStarted();
2858
+ refresh();
2859
+ connectEventStream();
2860
+ startAdaptiveRefresh();
2861
+ // ── Pause banner ──
2862
+ async function checkPauseBanner() {
2863
+ try {
2864
+ const res = await fetch(BASE + '/pause/status');
2865
+ const data = await res.json();
2866
+ const banner = document.getElementById('pause-banner');
2867
+ const msgEl = document.getElementById('pause-message');
2868
+ if (!banner) return;
2869
+
2870
+ const activeEntries = (data.entries || []).filter(e => e.paused);
2871
+ if (activeEntries.length > 0) {
2872
+ const entry = activeEntries[0];
2873
+ const target = entry.target === '__team__' ? 'Team' : entry.target;
2874
+ let msg = `${target} paused by ${entry.pausedBy}: ${entry.reason}`;
2875
+ if (entry.pausedUntil) {
2876
+ const remaining = Math.max(0, Math.ceil((entry.pausedUntil - Date.now()) / 60000));
2877
+ msg += ` (${remaining}m remaining)`;
2878
+ } else {
2879
+ msg += ' (indefinite)';
2880
+ }
2881
+ msgEl.textContent = msg;
2882
+ banner.style.display = 'flex';
2883
+ } else if (data.paused) {
2884
+ msgEl.textContent = data.message || 'Paused';
2885
+ banner.style.display = 'flex';
2886
+ } else {
2887
+ banner.style.display = 'none';
2888
+ }
2889
+ } catch {}
2890
+ }
2891
+
2892
+ async function resumeFromBanner() {
2893
+ try {
2894
+ // Try to unpause team first, then individual entries
2895
+ await fetch(BASE + '/pause?target=team', { method: 'DELETE' });
2896
+ const banner = document.getElementById('pause-banner');
2897
+ if (banner) banner.style.display = 'none';
2898
+ checkPauseBanner();
2899
+ } catch {}
2900
+ }
2901
+
2902
+ // ── Pause toggle button ──
2903
+ // Poll pause status every 30s
2904
+ setInterval(checkPauseBanner, 30000);
2905
+ checkPauseBanner();
2906
+
2907
+ // ═══ TEAM INTENSITY ═══
2908
+
2909
+ async function loadIntensityControl() {
2910
+ const control = document.getElementById('intensity-control');
2911
+ if (!control) return;
2912
+ try {
2913
+ const r = await fetch(BASE + '/policy/intensity');
2914
+ if (!r.ok) return;
2915
+ const data = await r.json();
2916
+ const preset = data.preset || 'normal';
2917
+ const btns = control.querySelectorAll('.intensity-btn');
2918
+ btns.forEach(btn => {
2919
+ const isActive = btn.dataset.preset === preset;
2920
+ btn.classList.toggle('intensity-active', isActive);
2921
+ btn.setAttribute('aria-checked', String(isActive));
2922
+ btn.setAttribute('tabindex', isActive ? '0' : '-1');
2923
+ });
2924
+ const info = document.getElementById('intensity-info');
2925
+ if (info) {
2926
+ const l = data.limits || {};
2927
+ info.textContent = `WIP ${l.wipLimit || '?'} · ${l.maxPullsPerHour || '?'} pulls/hr` +
2928
+ (l.batchIntervalMs > 0 ? ` · ${Math.round(l.batchIntervalMs / 60000)}m batch` : '');
2929
+ }
2930
+ } catch {}
2931
+ }
2932
+
2933
+ async function setIntensity(preset) {
2934
+ try {
2935
+ const r = await fetch(BASE + '/policy/intensity', {
2936
+ method: 'PUT',
2937
+ headers: { 'Content-Type': 'application/json' },
2938
+ body: JSON.stringify({ preset, updatedBy: 'dashboard' }),
2939
+ });
2940
+ if (r.ok) await loadIntensityControl();
2941
+ } catch {}
2942
+ }
2943
+
2944
+ // Keyboard nav for intensity radiogroup (arrow keys)
2945
+ document.addEventListener('keydown', (e) => {
2946
+ const control = document.getElementById('intensity-control');
2947
+ if (!control || !control.contains(document.activeElement)) return;
2948
+ const btns = Array.from(control.querySelectorAll('.intensity-btn'));
2949
+ const idx = btns.indexOf(document.activeElement);
2950
+ if (idx < 0) return;
2951
+ let next = -1;
2952
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % btns.length;
2953
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (idx - 1 + btns.length) % btns.length;
2954
+ if (next >= 0) {
2955
+ e.preventDefault();
2956
+ btns[next].focus();
2957
+ btns[next].click();
2958
+ }
2959
+ });
2960
+
2961
+ loadIntensityControl();
2962
+
2963
+ // ═══ PAUSE TOGGLE ═══
2964
+
2965
+ async function toggleTeamPause() {
2966
+ const btn = document.getElementById('pause-toggle-btn');
2967
+ if (!btn) return;
2968
+
2969
+ const isPaused = btn.classList.contains('paused');
2970
+ if (isPaused) {
2971
+ // Resume
2972
+ await fetch(BASE + '/pause?target=team', { method: 'DELETE' });
2973
+ } else {
2974
+ // Pause — prompt for duration
2975
+ const durStr = prompt('Pause duration in minutes (leave empty for indefinite):');
2976
+ const body = { target: 'team', reason: 'Dashboard pause', pausedBy: 'dashboard' };
2977
+ if (durStr && parseInt(durStr, 10) > 0) body.durationMin = parseInt(durStr, 10);
2978
+ await fetch(BASE + '/pause', {
2979
+ method: 'POST',
2980
+ headers: { 'Content-Type': 'application/json' },
2981
+ body: JSON.stringify(body),
2982
+ });
2983
+ }
2984
+ await syncPauseToggle();
2985
+ await checkPauseBanner();
2986
+ }
2987
+
2988
+ async function syncPauseToggle() {
2989
+ const btn = document.getElementById('pause-toggle-btn');
2990
+ if (!btn) return;
2991
+ try {
2992
+ const r = await fetch(BASE + '/pause/status');
2993
+ const data = await r.json();
2994
+ const entries = data.entries || [];
2995
+ const teamPaused = entries.some(e => e.target === '__team__' && e.paused);
2996
+ btn.classList.toggle('paused', teamPaused);
2997
+ btn.textContent = teamPaused ? '▶️ Resume' : '⏸️ Pause';
2998
+ btn.setAttribute('aria-label', teamPaused ? 'Resume team' : 'Pause team');
2999
+ } catch {}
3000
+ }
3001
+
3002
+ syncPauseToggle();
3003
+ setInterval(syncPauseToggle, 30000);
3004
+
3005
+ // ═══ TEAM POLLS ═══
3006
+
3007
+ // Agent color palette for voter dots
3008
+ const VOTER_COLORS = ['#60a5fa','#f472b6','#34d399','#fbbf24','#a78bfa','#fb923c','#22d3ee','#e879f9'];
3009
+ function voterColor(name) {
3010
+ let h = 0;
3011
+ for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
3012
+ return VOTER_COLORS[Math.abs(h) % VOTER_COLORS.length];
3013
+ }
3014
+ function voterInitial(name) {
3015
+ return (name || '?')[0].toUpperCase();
3016
+ }
3017
+
3018
+ async function loadPolls() {
3019
+ const body = document.getElementById('polls-body');
3020
+ const count = document.getElementById('polls-count');
3021
+ if (!body) return;
3022
+
3023
+ try {
3024
+ const res = await fetch(BASE + '/polls?limit=10');
3025
+ const data = await res.json();
3026
+ const polls = data.polls || [];
3027
+ if (count) count.textContent = polls.length ? `(${polls.length})` : '';
3028
+
3029
+ if (!polls.length) {
3030
+ body.innerHTML = '<div class="empty" style="padding:8px 0;font-size:12px;color:var(--text-muted)">No polls yet. Click + New Poll to create one.</div>';
3031
+ return;
3032
+ }
3033
+
3034
+ let html = '';
3035
+ for (const poll of polls) {
3036
+ const rr = await fetch(BASE + '/polls/' + poll.id);
3037
+ const rd = await rr.json();
3038
+ const results = rd.poll || poll;
3039
+ const isActive = results.status === 'active';
3040
+ const totalVotes = results.total_votes || 0;
3041
+ const tally = results.tally || [];
3042
+ const maxCount = Math.max(...tally.map(t => t.count), 1);
3043
+
3044
+ // Meta row
3045
+ html += '<div class="poll-card">';
3046
+ html += '<div class="poll-meta">';
3047
+ html += '<span>' + esc(agentLabel(results.created_by || 'unknown')) + '</span>';
3048
+ html += '<span>·</span>';
3049
+ html += '<span>' + timeAgo(results.created_at) + '</span>';
3050
+ html += isActive
3051
+ ? '<span class="poll-badge-open">Open</span>'
3052
+ : '<span class="poll-badge-closed">Closed</span>';
3053
+ html += '</div>';
3054
+
3055
+ // Question
3056
+ html += '<div class="poll-question">' + esc(results.question) + '</div>';
3057
+
3058
+ // Options as radiogroup
3059
+ html += '<div role="radiogroup" aria-label="' + esc(results.question) + '">';
3060
+ for (let i = 0; i < tally.length; i++) {
3061
+ const t = tally[i];
3062
+ const pct = totalVotes > 0 ? Math.round(t.count / totalVotes * 100) : 0;
3063
+ const barWidth = maxCount > 0 ? Math.round(t.count / maxCount * 100) : 0;
3064
+ const voters = t.voters || [];
3065
+ const clickable = isActive ? ' onclick="votePoll(\'' + esc(results.id) + '\',' + i + ')"' : '';
3066
+ const tabIdx = isActive ? (i === 0 ? '0' : '-1') : '-1';
3067
+
3068
+ html += '<div class="poll-option" role="radio" aria-checked="false" aria-label="' + esc(t.option) + ', ' + pct + '%" tabindex="' + tabIdx + '"' + clickable + '>';
3069
+ html += '<div class="poll-option-bar" style="width:' + barWidth + '%"></div>';
3070
+ html += '<div class="poll-option-content">';
3071
+ html += '<div class="poll-option-label">';
3072
+ html += '<div class="poll-option-check"></div>';
3073
+ html += '<span>' + esc(t.option) + '</span>';
3074
+ html += '</div>';
3075
+ html += '<div class="poll-option-stats">';
3076
+
3077
+ // Voter dots
3078
+ if (voters.length > 0) {
3079
+ html += '<div class="poll-voter-dots">';
3080
+ const shown = voters.slice(0, 5);
3081
+ for (const v of shown) {
3082
+ html += '<div class="poll-voter-dot" style="background:' + voterColor(v) + '" title="' + esc(v) + '">' + voterInitial(v) + '</div>';
3083
+ }
3084
+ if (voters.length > 5) html += '<div class="poll-voter-dot" style="background:var(--border)" title="' + (voters.length - 5) + ' more">+' + (voters.length - 5) + '</div>';
3085
+ html += '</div>';
3086
+ }
3087
+
3088
+ html += '<span>' + t.count + ' (' + pct + '%)</span>';
3089
+ html += '</div>';
3090
+ html += '</div>';
3091
+ html += '</div>';
3092
+ }
3093
+ html += '</div>';
3094
+
3095
+ // Footer
3096
+ html += '<div class="poll-footer">';
3097
+ html += '<span>' + totalVotes + ' vote' + (totalVotes !== 1 ? 's' : '') + '</span>';
3098
+ if (isActive && results.expires_at) {
3099
+ const remaining = Math.max(0, Math.ceil((results.expires_at - Date.now()) / 60000));
3100
+ html += '<span>' + (remaining > 60 ? Math.ceil(remaining / 60) + 'h' : remaining + 'm') + ' remaining</span>';
3101
+ } else if (!isActive) {
3102
+ const winner = tally.reduce((a, b) => b.count > a.count ? b : a, tally[0]);
3103
+ if (winner && winner.count > 0) html += '<span>Winner: ' + esc(winner.option) + '</span>';
3104
+ }
3105
+ html += '</div>';
3106
+ html += '</div>';
3107
+ }
3108
+ body.innerHTML = html;
3109
+ } catch (err) {
3110
+ body.innerHTML = '<div style="color:var(--text-muted);font-size:12px">Failed to load polls</div>';
3111
+ }
3112
+ }
3113
+
3114
+ function timeAgo(ts) {
3115
+ const s = Math.floor((Date.now() - ts) / 1000);
3116
+ if (s < 60) return 'just now';
3117
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
3118
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
3119
+ return Math.floor(s / 86400) + 'd ago';
3120
+ }
3121
+
3122
+ function showCreatePollForm() {
3123
+ const form = document.getElementById('create-poll-form');
3124
+ if (form) { form.style.display = 'block'; document.getElementById('poll-question')?.focus(); }
3125
+ }
3126
+
3127
+ function hideCreatePollForm() {
3128
+ const form = document.getElementById('create-poll-form');
3129
+ if (form) form.style.display = 'none';
3130
+ }
3131
+
3132
+ function addPollOption() {
3133
+ const container = document.getElementById('poll-options-inputs');
3134
+ if (!container) return;
3135
+ const count = container.querySelectorAll('.poll-option-input').length;
3136
+ if (count >= 6) return;
3137
+ const input = document.createElement('input');
3138
+ input.type = 'text';
3139
+ input.className = 'poll-option-input poll-input';
3140
+ input.placeholder = 'Option ' + (count + 1);
3141
+ input.setAttribute('aria-label', 'Poll option ' + (count + 1));
3142
+ container.appendChild(input);
3143
+ }
3144
+
3145
+ async function submitPoll() {
3146
+ const question = document.getElementById('poll-question')?.value.trim();
3147
+ const optionInputs = document.querySelectorAll('.poll-option-input');
3148
+ const options = Array.from(optionInputs).map(i => i.value.trim()).filter(Boolean);
3149
+ const expiryEl = document.getElementById('poll-expiry');
3150
+ const expiryMin = expiryEl ? parseInt(expiryEl.value, 10) : 0;
3151
+ const anonymous = document.getElementById('poll-anonymous')?.checked || false;
3152
+
3153
+ if (!question || options.length < 2) {
3154
+ alert('Need a question and at least 2 options');
3155
+ return;
3156
+ }
3157
+
3158
+ try {
3159
+ const body = { question, options, createdBy: 'dashboard', anonymous };
3160
+ if (expiryMin > 0) body.expiresInMinutes = expiryMin;
3161
+ const res = await fetch(BASE + '/polls', {
3162
+ method: 'POST',
3163
+ headers: { 'Content-Type': 'application/json' },
3164
+ body: JSON.stringify(body),
3165
+ });
3166
+ const data = await res.json();
3167
+ if (data.success) {
3168
+ hideCreatePollForm();
3169
+ document.getElementById('poll-question').value = '';
3170
+ document.querySelectorAll('.poll-option-input').forEach(i => { i.value = ''; });
3171
+ await loadPolls();
3172
+ } else {
3173
+ alert(data.error || 'Failed to create poll');
3174
+ }
3175
+ } catch (err) {
3176
+ alert('Failed to create poll');
3177
+ }
3178
+ }
3179
+
3180
+ async function votePoll(pollId, optionIndex) {
3181
+ const voter = prompt('Your name:');
3182
+ if (!voter) return;
3183
+ try {
3184
+ const res = await fetch(BASE + '/polls/' + pollId + '/vote', {
3185
+ method: 'POST',
3186
+ headers: { 'Content-Type': 'application/json' },
3187
+ body: JSON.stringify({ voter, choice: optionIndex }),
3188
+ });
3189
+ const data = await res.json();
3190
+ if (data.success) {
3191
+ await loadPolls();
3192
+ }
3193
+ } catch {}
3194
+ }
3195
+
3196
+ // Keyboard nav for poll options (arrow keys within radiogroup)
3197
+ document.addEventListener('keydown', (e) => {
3198
+ const opt = document.activeElement;
3199
+ if (!opt || !opt.classList.contains('poll-option')) return;
3200
+ const group = opt.closest('[role="radiogroup"]');
3201
+ if (!group) return;
3202
+ const opts = Array.from(group.querySelectorAll('.poll-option'));
3203
+ const idx = opts.indexOf(opt);
3204
+ if (idx < 0) return;
3205
+ let next = -1;
3206
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = (idx + 1) % opts.length;
3207
+ else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = (idx - 1 + opts.length) % opts.length;
3208
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); opt.click(); return; }
3209
+ if (next >= 0) {
3210
+ e.preventDefault();
3211
+ opts.forEach(o => o.setAttribute('tabindex', '-1'));
3212
+ opts[next].setAttribute('tabindex', '0');
3213
+ opts[next].focus();
3214
+ }
3215
+ });
3216
+
3217
+ // Load polls on init
3218
+ if (document.getElementById('polls-body')) {
3219
+ loadPolls();
3220
+ setInterval(loadPolls, 30000);
3221
+ }
3222
+
3223
+ // ── File Upload Integration ──
3224
+ let _allFiles = [];
3225
+ let _fileViewMode = 'grid';
3226
+ let _pendingChatAttachments = [];
3227
+
3228
+ function fileIcon(mimeType, name) {
3229
+ if (mimeType && mimeType.startsWith('image/')) return '🖼️';
3230
+ if (name && name.endsWith('.pdf')) return '📄';
3231
+ if (name && (name.endsWith('.csv') || name.endsWith('.xlsx'))) return '📊';
3232
+ if (name && (name.endsWith('.md') || name.endsWith('.txt'))) return '📝';
3233
+ return '📎';
3234
+ }
3235
+
3236
+ function formatFileSize(bytes) {
3237
+ if (!bytes) return '0 B';
3238
+ if (bytes < 1024) return bytes + ' B';
3239
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
3240
+ return (bytes / 1048576).toFixed(1) + ' MB';
3241
+ }
3242
+
3243
+ function timeAgo(ts) {
3244
+ const d = typeof ts === 'string' ? new Date(ts) : new Date(ts);
3245
+ const s = Math.floor((Date.now() - d.getTime()) / 1000);
3246
+ if (s < 60) return 'just now';
3247
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
3248
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
3249
+ return Math.floor(s / 86400) + 'd ago';
3250
+ }
3251
+
3252
+ async function loadFiles() {
3253
+ try {
3254
+ const res = await fetch('/files?limit=100');
3255
+ const data = await res.json();
3256
+ _allFiles = data.files || [];
3257
+ renderFiles(_allFiles);
3258
+ } catch (e) {
3259
+ console.error('Failed to load files:', e);
3260
+ }
3261
+ }
3262
+
3263
+ function renderFiles(files) {
3264
+ const grid = document.getElementById('files-grid');
3265
+ const list = document.getElementById('files-list');
3266
+ const empty = document.getElementById('files-empty');
3267
+ const count = document.getElementById('files-count');
3268
+ if (!grid || !list) return;
3269
+
3270
+ if (count) count.textContent = files.length || '';
3271
+
3272
+ if (!files.length) {
3273
+ grid.style.display = 'none';
3274
+ list.style.display = 'none';
3275
+ if (empty) empty.style.display = '';
3276
+ return;
3277
+ }
3278
+
3279
+ if (empty) empty.style.display = 'none';
3280
+ grid.style.display = _fileViewMode === 'grid' ? '' : 'none';
3281
+ list.style.display = _fileViewMode === 'list' ? '' : 'none';
3282
+
3283
+ // Grid view
3284
+ grid.innerHTML = files.map(f => {
3285
+ const icon = fileIcon(f.mimeType, f.originalName || f.filename);
3286
+ const isImg = f.mimeType && f.mimeType.startsWith('image/');
3287
+ const thumb = isImg
3288
+ ? '<img src="/files/' + f.id + '" alt="' + (f.originalName || f.filename) + '" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm)">'
3289
+ : '<span style="font-size:32px">' + icon + '</span>';
3290
+ return '<div class="file-card" tabindex="0" role="button" aria-label="' + (f.originalName || f.filename) + '">' +
3291
+ '<div class="thumb" style="display:flex;align-items:center;justify-content:center;height:80px;background:var(--surface-raised);border-radius:var(--radius-sm);overflow:hidden">' + thumb + '</div>' +
3292
+ '<div class="card-name">' + (f.originalName || f.filename) + '</div>' +
3293
+ '<div class="card-meta">' + formatFileSize(f.size) + ' · ' + timeAgo(f.uploadedAt || f.createdAt) + '</div>' +
3294
+ '<div class="card-actions">' +
3295
+ '<a href="/files/' + f.id + '" download="' + (f.originalName || f.filename) + '" class="action-btn" aria-label="Download" onclick="event.stopPropagation()">⬇</a>' +
3296
+ '<button class="action-btn delete" aria-label="Delete" onclick="event.stopPropagation();deleteFile(\'' + f.id + '\')">🗑</button>' +
3297
+ '</div></div>';
3298
+ }).join('');
3299
+
3300
+ // List view
3301
+ list.innerHTML = files.map(f => {
3302
+ const icon = fileIcon(f.mimeType, f.originalName || f.filename);
3303
+ return '<div class="file-list-item" tabindex="0" role="button">' +
3304
+ '<span class="list-icon">' + icon + '</span>' +
3305
+ '<span class="list-name">' + (f.originalName || f.filename) + '</span>' +
3306
+ '<span class="list-meta">' + formatFileSize(f.size) + '</span>' +
3307
+ '<span class="list-meta">' + timeAgo(f.uploadedAt || f.createdAt) + '</span>' +
3308
+ '<div class="list-actions">' +
3309
+ '<a href="/files/' + f.id + '" download="' + (f.originalName || f.filename) + '" class="action-btn" aria-label="Download" onclick="event.stopPropagation()">⬇</a>' +
3310
+ '<button class="action-btn delete" aria-label="Delete" onclick="event.stopPropagation();deleteFile(\'' + f.id + '\')">🗑</button>' +
3311
+ '</div></div>';
3312
+ }).join('');
3313
+ }
3314
+
3315
+ function toggleFileView(mode) {
3316
+ _fileViewMode = mode;
3317
+ const gridBtn = document.getElementById('viewGridBtn');
3318
+ const listBtn = document.getElementById('viewListBtn');
3319
+ if (gridBtn) { gridBtn.classList.toggle('active', mode === 'grid'); gridBtn.setAttribute('aria-checked', mode === 'grid'); }
3320
+ if (listBtn) { listBtn.classList.toggle('active', mode === 'list'); listBtn.setAttribute('aria-checked', mode === 'list'); }
3321
+ renderFiles(_allFiles);
3322
+ }
3323
+
3324
+ function filterFiles(query) {
3325
+ if (!query) return renderFiles(_allFiles);
3326
+ const q = query.toLowerCase();
3327
+ renderFiles(_allFiles.filter(f => ((f.originalName || f.filename) || '').toLowerCase().includes(q)));
3328
+ }
3329
+
3330
+ async function deleteFile(id) {
3331
+ if (!confirm('Delete this file?')) return;
3332
+ try {
3333
+ await fetch('/files/' + id, { method: 'DELETE' });
3334
+ loadFiles();
3335
+ } catch (e) {
3336
+ console.error('Delete failed:', e);
3337
+ }
3338
+ }
3339
+
3340
+ function addProgressItem(name, size) {
3341
+ const q = document.getElementById('uploadQueue');
3342
+ if (!q) return;
3343
+ q.style.display = '';
3344
+ const item = document.createElement('div');
3345
+ item.className = 'upload-item';
3346
+ item.id = 'upload-' + name.replace(/[^a-zA-Z0-9]/g, '_');
3347
+ item.innerHTML =
3348
+ '<span class="file-icon">' + fileIcon(null, name) + '</span>' +
3349
+ '<div class="file-info"><div class="file-name">' + name + '</div><div class="file-size">' + formatFileSize(size) + '</div></div>' +
3350
+ '<div class="progress-bar"><div class="progress-fill uploading" style="width:30%"></div></div>' +
3351
+ '<span class="status-icon" aria-label="Uploading">⏳</span>';
3352
+ q.appendChild(item);
3353
+ }
3354
+
3355
+ function updateProgressItem(name, status, error) {
3356
+ const id = 'upload-' + name.replace(/[^a-zA-Z0-9]/g, '_');
3357
+ const item = document.getElementById(id);
3358
+ if (!item) return;
3359
+ const fill = item.querySelector('.progress-fill');
3360
+ const icon = item.querySelector('.status-icon');
3361
+ if (status === 'done') {
3362
+ if (fill) { fill.style.width = '100%'; fill.className = 'progress-fill done'; }
3363
+ if (icon) { icon.textContent = '✓'; icon.className = 'status-icon done'; icon.setAttribute('aria-label', 'Complete'); }
3364
+ setTimeout(() => { item.style.opacity = '0.5'; }, 2000);
3365
+ } else if (status === 'error') {
3366
+ if (fill) { fill.style.width = '100%'; fill.className = 'progress-fill error'; }
3367
+ if (icon) { icon.textContent = '✕'; icon.className = 'status-icon error'; icon.setAttribute('aria-label', 'Failed: ' + (error || 'unknown')); }
3368
+ const sz = item.querySelector('.file-size');
3369
+ if (sz && error) sz.textContent = error;
3370
+ }
3371
+ }
3372
+
3373
+ async function uploadFiles(fileList) {
3374
+ for (const file of fileList) {
3375
+ addProgressItem(file.name, file.size);
3376
+ const fd = new FormData();
3377
+ fd.append('file', file);
3378
+ fd.append('uploadedBy', 'ryan');
3379
+ try {
3380
+ const res = await fetch('/files', { method: 'POST', body: fd });
3381
+ const data = await res.json();
3382
+ if (data.success) {
3383
+ updateProgressItem(file.name, 'done');
3384
+ } else {
3385
+ updateProgressItem(file.name, 'error', data.error || 'Upload failed');
3386
+ }
3387
+ } catch (err) {
3388
+ updateProgressItem(file.name, 'error', err.message);
3389
+ }
3390
+ }
3391
+ loadFiles();
3392
+ }
3393
+
3394
+ // Init drop zone
3395
+ function initDropZone() {
3396
+ const dz = document.getElementById('dropZone');
3397
+ const fi = document.getElementById('fileInput');
3398
+ if (!dz || !fi || dz._initialized) return;
3399
+ dz._initialized = true;
3400
+
3401
+ ['dragenter', 'dragover'].forEach(e => dz.addEventListener(e, ev => {
3402
+ ev.preventDefault(); dz.classList.add('drag-over');
3403
+ }));
3404
+ ['dragleave', 'drop'].forEach(e => dz.addEventListener(e, ev => {
3405
+ ev.preventDefault(); dz.classList.remove('drag-over');
3406
+ }));
3407
+ dz.addEventListener('drop', ev => { if (ev.dataTransfer.files.length) uploadFiles(ev.dataTransfer.files); });
3408
+ fi.addEventListener('change', () => { if (fi.files.length) uploadFiles(fi.files); fi.value = ''; });
3409
+ dz.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fi.click(); } });
3410
+ }
3411
+
3412
+ // Init chat attachment
3413
+ function initChatAttach() {
3414
+ const fi = document.getElementById('chatFileInput');
3415
+ if (!fi || fi._initialized) return;
3416
+ fi._initialized = true;
3417
+ fi.addEventListener('change', () => {
3418
+ for (const file of fi.files) {
3419
+ _pendingChatAttachments.push(file);
3420
+ }
3421
+ renderChatAttachments();
3422
+ fi.value = '';
3423
+ });
3424
+ }
3425
+
3426
+ function renderChatAttachments() {
3427
+ let strip = document.getElementById('chatAttachmentPreview');
3428
+ const bar = document.querySelector('.chat-input-bar');
3429
+ if (!bar) return;
3430
+
3431
+ if (!strip) {
3432
+ strip = document.createElement('div');
3433
+ strip.id = 'chatAttachmentPreview';
3434
+ strip.className = 'attachment-preview';
3435
+ bar.parentNode.insertBefore(strip, bar);
3436
+ }
3437
+
3438
+ if (!_pendingChatAttachments.length) {
3439
+ strip.style.display = 'none';
3440
+ return;
3441
+ }
3442
+
3443
+ strip.style.display = '';
3444
+ strip.innerHTML = _pendingChatAttachments.map((f, i) =>
3445
+ '<div class="attachment-chip">' +
3446
+ '<span>' + fileIcon(f.type, f.name) + '</span>' +
3447
+ '<span class="chip-name">' + f.name + '</span>' +
3448
+ '<button class="chip-remove" aria-label="Remove ' + f.name + '" onclick="removeChatAttachment(' + i + ')">✕</button>' +
3449
+ '</div>'
3450
+ ).join('');
3451
+ }
3452
+
3453
+ function removeChatAttachment(idx) {
3454
+ _pendingChatAttachments.splice(idx, 1);
3455
+ renderChatAttachments();
3456
+ }
3457
+
3458
+ // Hook into page navigation — load files when artifacts page shows
3459
+ const _origActivatePage = typeof activatePage === 'function' ? activatePage : null;
3460
+ if (_origActivatePage) {
3461
+ activatePage = function(page) {
3462
+ _origActivatePage(page);
3463
+ if (page === 'artifacts') {
3464
+ initDropZone();
3465
+ loadFiles();
3466
+ }
3467
+ };
3468
+ }
3469
+
3470
+ // Init on load
3471
+ document.addEventListener('DOMContentLoaded', () => {
3472
+ initChatAttach();
3473
+ // If starting on artifacts page, init immediately
3474
+ const hash = location.hash.replace('#', '') || 'overview';
3475
+ if (hash === 'artifacts') {
3476
+ initDropZone();
3477
+ loadFiles();
3478
+ }
3479
+ });