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
package/dist/health.js ADDED
@@ -0,0 +1,1841 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) Reflectt AI
3
+ /**
4
+ * Team Health Monitoring
5
+ *
6
+ * Real-time team health diagnostics:
7
+ * - Silence detection (>3 heartbeats = ~45min)
8
+ * - Blocker tracking from messages
9
+ * - Overlapping work detection
10
+ * - Collaboration compliance (protocol v1)
11
+ */
12
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
13
+ import { dirname, resolve } from 'node:path';
14
+ import { presenceManager } from './presence.js';
15
+ import { chatManager } from './chat.js';
16
+ import { taskManager } from './tasks.js';
17
+ import { routeMessage } from './messageRouter.js';
18
+ import { resolveIdleNudgeLane } from './watchdog/idleNudgeLane.js';
19
+ import { getDb } from './db.js';
20
+ import { policyManager } from './policy.js';
21
+ import { recordSystemLoopTick } from './system-loop-state.js';
22
+ /**
23
+ * Validate a task timestamp is within reasonable bounds.
24
+ * Rejects: 0, negative, NaN, future timestamps (>1h ahead), impossibly old (>1 year).
25
+ * Returns the validated timestamp or null if invalid.
26
+ */
27
+ export function validateTaskTimestamp(ts, now) {
28
+ const n = Number(ts);
29
+ if (!n || !Number.isFinite(n) || n <= 0)
30
+ return null;
31
+ const currentTime = now ?? Date.now();
32
+ const ONE_HOUR_MS = 60 * 60 * 1000;
33
+ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
34
+ if (n > currentTime + ONE_HOUR_MS)
35
+ return null;
36
+ if (n < currentTime - ONE_YEAR_MS)
37
+ return null;
38
+ return n;
39
+ }
40
+ /**
41
+ * Verify a task still exists and is in an active (non-deleted, non-done) state.
42
+ * Returns the fresh task if valid, null otherwise.
43
+ */
44
+ export function verifyTaskExists(taskId) {
45
+ try {
46
+ const task = taskManager.getTask(taskId);
47
+ if (!task)
48
+ return null;
49
+ if (task.status === 'done')
50
+ return null;
51
+ return task;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /**
58
+ * Compute per-agent active lane from task board + presence data.
59
+ * Priority: doing > blocked > validating > offline > queue-clear
60
+ */
61
+ export function computeActiveLane(agentName, tasks, presenceStatus, lastSeenMs, offlineThresholdMs = 15 * 60 * 1000, now = Date.now()) {
62
+ const agent = agentName.toLowerCase();
63
+ const agentTasks = tasks.filter(t => (t.assignee || '').toLowerCase() === agent);
64
+ if (agentTasks.some(t => t.status === 'doing'))
65
+ return 'doing';
66
+ if (agentTasks.some(t => t.status === 'blocked'))
67
+ return 'blocked';
68
+ if (agentTasks.some(t => t.status === 'validating'))
69
+ return 'validating';
70
+ // Check if offline via presence
71
+ if (presenceStatus === 'offline')
72
+ return 'offline';
73
+ if (lastSeenMs !== undefined && lastSeenMs > 0 && (now - lastSeenMs) >= offlineThresholdMs)
74
+ return 'offline';
75
+ return 'queue-clear';
76
+ }
77
+ class TeamHealthMonitor {
78
+ blockerKeywords = [
79
+ 'blocked',
80
+ 'blocker',
81
+ 'waiting on',
82
+ 'waiting for',
83
+ 'need help',
84
+ 'stuck',
85
+ 'can\'t',
86
+ 'unable to',
87
+ 'no access',
88
+ 'missing',
89
+ ];
90
+ healthHistory = [];
91
+ MAX_HISTORY = 168; // 7 days at hourly snapshots
92
+ lastSnapshotTime = 0;
93
+ SNAPSHOT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
94
+ trioAgents = ['kai', 'link', 'pixel'];
95
+ workerAgents = ['link', 'pixel'];
96
+ workerCadenceMaxMin = 45;
97
+ leadCadenceMaxMin = 60;
98
+ blockedEscalationMin = 20;
99
+ trioSilenceMaxMin = 60;
100
+ // System idle nudge settings (configurable via env)
101
+ idleNudgeEnabled = process.env.IDLE_NUDGE_ENABLED !== 'false';
102
+ idleNudgeWarnMin = Number(process.env.IDLE_NUDGE_WARN_MIN || 45);
103
+ idleNudgeEscalateMin = Number(process.env.IDLE_NUDGE_ESCALATE_MIN || 60);
104
+ idleNudgeCooldownMin = Number(process.env.IDLE_NUDGE_COOLDOWN_MIN || 20);
105
+ idleNudgeSuppressRecentMin = Number(process.env.IDLE_NUDGE_SUPPRESS_RECENT_MIN || 20);
106
+ idleNudgeShipCooldownMin = Number(process.env.IDLE_NUDGE_SHIP_COOLDOWN_MIN || 30);
107
+ idleNudgeActiveTaskMaxAgeMin = Number(process.env.IDLE_NUDGE_ACTIVE_TASK_MAX_AGE_MIN || 180);
108
+ idleNudgeExcluded = new Set((process.env.IDLE_NUDGE_EXCLUDE || 'ryan,diag')
109
+ .split(',')
110
+ .map(s => s.trim().toLowerCase())
111
+ .filter(Boolean));
112
+ idleNudgeState = new Map();
113
+ idleNudgeLastDecisions = [];
114
+ cadenceWatchdogEnabled = process.env.CADENCE_WATCHDOG_ENABLED !== 'false';
115
+ cadenceSilenceMin = Number(process.env.CADENCE_SILENCE_MIN || 60);
116
+ cadenceWorkingStaleMin = Number(process.env.CADENCE_WORKING_STALE_MIN || 45);
117
+ cadenceWorkingTaskMaxAgeMin = Number(process.env.CADENCE_WORKING_TASK_MAX_AGE_MIN || 240);
118
+ cadenceAlertCooldownMin = Number(process.env.CADENCE_ALERT_COOLDOWN_MIN || 30);
119
+ /** Grace period after service start — suppress cadence alerts to avoid restart-triggered batch spam */
120
+ cadenceStartupGraceMs = Number(process.env.CADENCE_STARTUP_GRACE_MS || 10 * 60_000);
121
+ /** Window for rate-limit failure message suppression (ms) */
122
+ cadenceRateLimitSuppressMs = Number(process.env.CADENCE_RATE_LIMIT_SUPPRESS_MS || 20 * 60_000);
123
+ cadenceAlertState = new Map();
124
+ staleDoingThresholdMin = Number(process.env.STALE_DOING_THRESHOLD_MIN || 240);
125
+ // Mention rescue fallback: if Ryan pings trio and nobody replies quickly, emit a direct system ack.
126
+ mentionRescueEnabled = process.env.MENTION_RESCUE_ENABLED !== 'false';
127
+ // Default delay is intentionally non-zero to avoid noisy immediate fallback nudges.
128
+ // We also clamp to a minimum to prevent misconfig (e.g. env="0") from spamming #general.
129
+ // Override with MENTION_RESCUE_DELAY_MIN to increase (values <3 are treated as 3).
130
+ mentionRescueDelayMin = (() => {
131
+ const raw = process.env.MENTION_RESCUE_DELAY_MIN;
132
+ const parsed = (raw === undefined || raw.trim() === '') ? 5 : Number(raw);
133
+ const val = Number.isFinite(parsed) ? parsed : 5;
134
+ return Math.max(3, val);
135
+ })();
136
+ mentionRescueCooldownMin = Number(process.env.MENTION_RESCUE_COOLDOWN_MIN || 10);
137
+ mentionRescueGlobalCooldownMin = Number(process.env.MENTION_RESCUE_GLOBAL_COOLDOWN_MIN || 5);
138
+ /** Maps mentionId → { lastRescueAt, rescueCount }. Once rescued, won't rescue again (one-shot). */
139
+ mentionRescueState = new Map();
140
+ mentionRescueLastAt = 0;
141
+ /** Thread-level idempotency: tracks which thread keys have been rescued (persisted in SQLite). */
142
+ mentionRescueDbInitialized = false;
143
+ systemStartTime = Date.now();
144
+ requestCount = 0;
145
+ errorCount = 0;
146
+ requestTimes = [];
147
+ MAX_REQUEST_TIMES = 1000;
148
+ /**
149
+ * Get comprehensive team health snapshot
150
+ */
151
+ async getHealth() {
152
+ const now = Date.now();
153
+ const agents = await this.getAgentHealthStatuses(now);
154
+ const blockers = await this.extractBlockers();
155
+ const overlaps = await this.detectOverlaps();
156
+ const compliance = await this.getCollaborationCompliance(now);
157
+ const staleDoing = this.getStaleDoingSnapshot(now);
158
+ const silentAgents = agents
159
+ .filter(a => a.status === 'silent')
160
+ .map(a => a.agent);
161
+ const activeAgents = agents
162
+ .filter(a => a.status === 'active')
163
+ .map(a => a.agent);
164
+ return {
165
+ timestamp: now,
166
+ agents,
167
+ blockers,
168
+ overlaps,
169
+ silentAgents,
170
+ activeAgents,
171
+ compliance,
172
+ staleDoing,
173
+ };
174
+ }
175
+ getTaskLastActivityAt(taskId, fallbackUpdatedAt) {
176
+ const comments = taskManager.getTaskComments(taskId);
177
+ const latestCommentAt = comments.reduce((max, c) => Math.max(max, this.parseTimestamp(c.timestamp)), 0);
178
+ return Math.max(fallbackUpdatedAt, latestCommentAt);
179
+ }
180
+ extractTaskPrLink(task) {
181
+ if (!task?.metadata || typeof task.metadata !== 'object')
182
+ return null;
183
+ const metadata = task.metadata;
184
+ const candidates = [];
185
+ const directPrUrl = metadata.pr_url;
186
+ const directPrLink = metadata.pr_link;
187
+ if (typeof directPrUrl === 'string')
188
+ candidates.push(directPrUrl);
189
+ if (typeof directPrLink === 'string')
190
+ candidates.push(directPrLink);
191
+ const artifacts = metadata.artifacts;
192
+ if (Array.isArray(artifacts)) {
193
+ for (const item of artifacts) {
194
+ if (typeof item === 'string')
195
+ candidates.push(item);
196
+ }
197
+ }
198
+ const qaBundle = metadata.qa_bundle;
199
+ if (qaBundle && typeof qaBundle === 'object') {
200
+ const artifactLinks = qaBundle.artifact_links;
201
+ if (Array.isArray(artifactLinks)) {
202
+ for (const item of artifactLinks) {
203
+ if (typeof item === 'string')
204
+ candidates.push(item);
205
+ }
206
+ }
207
+ }
208
+ const pullUrlRegex = /https?:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+(?:[^\s]*)?/i;
209
+ for (const candidate of candidates) {
210
+ const trimmed = candidate.trim();
211
+ if (!trimmed)
212
+ continue;
213
+ const match = trimmed.match(pullUrlRegex);
214
+ if (match)
215
+ return match[0];
216
+ }
217
+ return null;
218
+ }
219
+ getStaleDoingSnapshot(now = Date.now()) {
220
+ const doing = taskManager.listTasks({ status: 'doing' });
221
+ const MAX_STALE_DISPLAY_MIN = 24 * 60;
222
+ const staleTasks = doing
223
+ .filter(task => Boolean(task.assignee))
224
+ // Verify task still exists (guards against stale cache / deleted tasks)
225
+ .filter(task => verifyTaskExists(task.id) !== null)
226
+ .map((task) => {
227
+ const lastActivityAt = this.getTaskLastActivityAt(task.id, this.parseTimestamp(task.updatedAt));
228
+ // Validate timestamp bounds — cap impossible ages
229
+ const validatedAt = validateTaskTimestamp(lastActivityAt, now);
230
+ const staleMinutes = validatedAt
231
+ ? Math.min(Math.max(0, Math.floor((now - validatedAt) / 60_000)), MAX_STALE_DISPLAY_MIN)
232
+ : MAX_STALE_DISPLAY_MIN;
233
+ return {
234
+ task_id: task.id,
235
+ assignee: task.assignee || 'unassigned',
236
+ title: task.title,
237
+ stale_minutes: staleMinutes,
238
+ last_activity_at: lastActivityAt,
239
+ };
240
+ })
241
+ .filter(task => task.stale_minutes >= this.staleDoingThresholdMin)
242
+ .sort((a, b) => b.stale_minutes - a.stale_minutes);
243
+ return {
244
+ thresholdMinutes: this.staleDoingThresholdMin,
245
+ count: staleTasks.length,
246
+ tasks: staleTasks,
247
+ };
248
+ }
249
+ async getAgentHealthSummary(now = Date.now()) {
250
+ const agents = await this.getAgentHealthStatuses(now);
251
+ const allTasks = taskManager.listTasks({});
252
+ const healthyMaxMs = 45 * 60 * 1000;
253
+ const stuckMinMs = 60 * 60 * 1000;
254
+ const offlineMinMs = 120 * 60 * 1000;
255
+ const rows = agents.map((agent) => {
256
+ const heartbeatAgeMs = Math.max(0, agent.minutesSinceLastSeen) * 60_000;
257
+ let state = 'healthy';
258
+ let staleReason = null;
259
+ if (agent.lastSeen <= 0 || heartbeatAgeMs >= offlineMinMs) {
260
+ state = 'offline';
261
+ staleReason = 'offline-no-heartbeat';
262
+ }
263
+ else if (agent.idleWithActiveTask && heartbeatAgeMs >= stuckMinMs) {
264
+ state = 'stuck';
265
+ staleReason = 'active-task-idle-over-60m';
266
+ }
267
+ else if (heartbeatAgeMs > healthyMaxMs) {
268
+ state = 'idle';
269
+ staleReason = 'heartbeat-age-over-45m';
270
+ }
271
+ const presenceStatus = state === 'offline' ? 'offline' : undefined;
272
+ const activeLane = computeActiveLane(agent.agent, allTasks, presenceStatus, agent.lastSeen, offlineMinMs, now);
273
+ return {
274
+ agent: agent.agent,
275
+ last_seen: agent.lastSeen,
276
+ active_task: agent.currentTask || null,
277
+ heartbeat_age_ms: heartbeatAgeMs,
278
+ last_shipped_at: agent.lastProductiveAt,
279
+ shipped_age_ms: agent.minutesSinceProductive === null ? null : Math.max(0, agent.minutesSinceProductive) * 60_000,
280
+ stale_reason: staleReason,
281
+ idle_with_active_task: agent.idleWithActiveTask,
282
+ state,
283
+ active_lane: activeLane,
284
+ };
285
+ });
286
+ return {
287
+ agents: rows,
288
+ thresholds: {
289
+ healthyMaxMs,
290
+ stuckMinMs,
291
+ offlineMinMs,
292
+ },
293
+ timestamp: now,
294
+ };
295
+ }
296
+ async getCollaborationCompliance(now = Date.now()) {
297
+ const tasks = taskManager.listTasks({});
298
+ const messages = chatManager.getMessages({ limit: 300 });
299
+ const incidents = await this.getComplianceIncidents(now, messages);
300
+ const complianceAgents = this.trioAgents.map((agent) => {
301
+ const expectedCadenceMin = agent === 'kai' ? this.leadCadenceMaxMin : this.workerCadenceMaxMin;
302
+ const lastValidStatusAt = this.findLastValidStatusAt(messages, agent);
303
+ const lastValidStatusAgeMin = lastValidStatusAt
304
+ ? Math.floor((now - lastValidStatusAt) / 1000 / 60)
305
+ : 9999;
306
+ let state = 'ok';
307
+ if (lastValidStatusAgeMin > expectedCadenceMin) {
308
+ state = 'violation';
309
+ }
310
+ else if (lastValidStatusAgeMin >= Math.max(0, expectedCadenceMin - 10)) {
311
+ state = 'warning';
312
+ }
313
+ const hasEscalation = incidents.some(i => i.agent === agent);
314
+ if (hasEscalation) {
315
+ state = 'escalated';
316
+ }
317
+ const activeTask = tasks.find(t => t.assignee === agent && t.status === 'doing');
318
+ return {
319
+ agent,
320
+ taskId: activeTask?.id || null,
321
+ lastValidStatusAt,
322
+ lastValidStatusAgeMin,
323
+ expectedCadenceMin,
324
+ state,
325
+ };
326
+ });
327
+ const workerWorstAgeMin = Math.max(...complianceAgents
328
+ .filter(a => this.workerAgents.includes(a.agent))
329
+ .map(a => a.lastValidStatusAgeMin), 0);
330
+ const leadAgeMin = complianceAgents.find(a => a.agent === 'kai')?.lastValidStatusAgeMin ?? 9999;
331
+ const blockerMessages = messages.filter(m => typeof m.content === 'string'
332
+ && /\bblocker\s*:\s*(?!none|no|n\/a|na\b).+/i.test(m.content)
333
+ && this.trioAgents.includes((m.from || '').toLowerCase()));
334
+ const oldestBlockerMin = blockerMessages.length > 0
335
+ ? Math.max(...blockerMessages.map(m => Math.floor((now - (m.timestamp || now)) / 1000 / 60)))
336
+ : 0;
337
+ const lastTrioGeneralUpdate = this.findLastTrioGeneralUpdate(messages);
338
+ const trioSilenceMin = Math.floor((now - lastTrioGeneralUpdate) / 1000 / 60);
339
+ return {
340
+ summary: {
341
+ workerCadenceMaxMin: this.workerCadenceMaxMin,
342
+ leadCadenceMaxMin: this.leadCadenceMaxMin,
343
+ blockedEscalationMin: this.blockedEscalationMin,
344
+ trioSilenceMaxMin: this.trioSilenceMaxMin,
345
+ workerWorstAgeMin,
346
+ leadAgeMin,
347
+ oldestBlockerMin,
348
+ trioSilenceMin,
349
+ },
350
+ agents: complianceAgents,
351
+ incidents,
352
+ };
353
+ }
354
+ parseTimestamp(value) {
355
+ if (typeof value === 'number' && Number.isFinite(value))
356
+ return value;
357
+ if (typeof value === 'string') {
358
+ const asNum = Number(value);
359
+ if (Number.isFinite(asNum) && asNum > 0)
360
+ return asNum;
361
+ const asDate = Date.parse(value);
362
+ if (Number.isFinite(asDate) && asDate > 0)
363
+ return asDate;
364
+ }
365
+ return 0;
366
+ }
367
+ getLatestGeneralMessageAt(messages, author) {
368
+ let lastAt = 0;
369
+ for (const m of messages) {
370
+ if ((m.from || '').toLowerCase() !== author)
371
+ continue;
372
+ if ((m.channel || 'general') !== 'general')
373
+ continue;
374
+ const ts = this.parseTimestamp(m.timestamp);
375
+ if (ts > lastAt)
376
+ lastAt = ts;
377
+ }
378
+ return lastAt;
379
+ }
380
+ /**
381
+ * Get the latest message timestamp from an agent across ALL channels.
382
+ * Used for activity suppression — if an agent is posting anywhere, they're not idle.
383
+ */
384
+ getLatestAnyMessageAt(messages, author) {
385
+ let lastAt = 0;
386
+ for (const m of messages) {
387
+ if ((m.from || '').toLowerCase() !== author)
388
+ continue;
389
+ const ts = this.parseTimestamp(m.timestamp);
390
+ if (ts > lastAt)
391
+ lastAt = ts;
392
+ }
393
+ return lastAt;
394
+ }
395
+ /**
396
+ * Returns true if the agent has posted a rate-limit / provider-failure message
397
+ * within the given window. Used to suppress false cadence alerts during outages.
398
+ *
399
+ * These messages look like: "⚠️ Agent failed before reply: All models failed..."
400
+ * They indicate the agent is alive and trying but blocked by infra — not genuinely idle.
401
+ */
402
+ hasRecentRateLimitFailure(messages, agent, now, windowMs) {
403
+ const cutoff = now - windowMs;
404
+ return messages.some((m) => {
405
+ if ((m.from || '').toLowerCase() !== agent)
406
+ return false;
407
+ const ts = Number(m.timestamp || 0);
408
+ if (ts < cutoff)
409
+ return false;
410
+ const content = typeof m.content === 'string' ? m.content : '';
411
+ return content.startsWith('⚠️ Agent failed before reply:') || content.startsWith('⚠️ API rate limit reached');
412
+ });
413
+ }
414
+ findLastValidStatusAt(messages, agent) {
415
+ let lastAt = null;
416
+ for (const m of messages) {
417
+ if ((m.from || '').toLowerCase() !== agent)
418
+ continue;
419
+ if ((m.channel || 'general') !== 'general')
420
+ continue;
421
+ const content = typeof m.content === 'string' ? m.content : '';
422
+ const hasTask = /\btask-[a-z0-9-]+\b/i.test(content);
423
+ const hasStrictTriplet = /1\)\s*(?:\*\*)?[^\n]*\bshipped\b/i.test(content)
424
+ && /2\)\s*(?:\*\*)?[^\n]*\bblocker\b/i.test(content)
425
+ && /3\)\s*(?:\*\*)?[^\n]*\bnext\b/i.test(content);
426
+ const hasLooseStatusSignals = (/\bshipped\b|\bartifact(?:s)?\b|\bcommit\b/i.test(content))
427
+ && /\bblocker\b/i.test(content)
428
+ && (/\bnext\b/i.test(content) || /\beta\b/i.test(content));
429
+ if (!hasTask || (!hasStrictTriplet && !hasLooseStatusSignals))
430
+ continue;
431
+ const ts = this.parseTimestamp(m.timestamp);
432
+ if (!ts)
433
+ continue;
434
+ if (!lastAt || ts > lastAt)
435
+ lastAt = ts;
436
+ }
437
+ return lastAt;
438
+ }
439
+ findLastTrioGeneralUpdate(messages) {
440
+ const trioSet = new Set(this.trioAgents);
441
+ let lastAt = 0;
442
+ for (const agent of trioSet) {
443
+ // Consider both #general and any-channel activity
444
+ const generalLast = this.getLatestGeneralMessageAt(messages, agent);
445
+ const anyLast = this.getLatestAnyMessageAt(messages, agent);
446
+ const agentLast = Math.max(generalLast, anyLast);
447
+ if (agentLast > lastAt)
448
+ lastAt = agentLast;
449
+ }
450
+ return lastAt || Date.now();
451
+ }
452
+ /** Check if agent posted a task comment recently (returns age in minutes or null) */
453
+ getTaskCommentAgeForAgent(taskId, agent, now) {
454
+ const comments = taskManager.getTaskComments(taskId);
455
+ if (!comments.length)
456
+ return null;
457
+ // Find most recent comment by this agent
458
+ let latestTs = 0;
459
+ for (const c of comments) {
460
+ if ((c.author || '').toLowerCase() !== agent)
461
+ continue;
462
+ const ts = this.parseTimestamp(c.timestamp);
463
+ if (ts > latestTs)
464
+ latestTs = ts;
465
+ }
466
+ if (!latestTs)
467
+ return null;
468
+ return Math.floor((now - latestTs) / 60_000);
469
+ }
470
+ /** Per-task focus window: agent started doing task recently → deep work window */
471
+ taskFocusWindows = new Map();
472
+ getTaskFocusWindow(taskId, agent, now) {
473
+ const key = `${agent}:${taskId}`;
474
+ const window = this.taskFocusWindows.get(key);
475
+ if (!window)
476
+ return null;
477
+ const elapsed = Math.floor((now - window.startedAt) / 60_000);
478
+ if (elapsed >= window.durationMin) {
479
+ this.taskFocusWindows.delete(key);
480
+ return null;
481
+ }
482
+ return { active: true, remainingMin: window.durationMin - elapsed };
483
+ }
484
+ /** Start a focus window for a task (called when agent moves task to doing) */
485
+ startTaskFocusWindow(agent, taskId, durationMin = 45) {
486
+ const key = `${agent}:${taskId}`;
487
+ this.taskFocusWindows.set(key, { agent, startedAt: Date.now(), durationMin });
488
+ }
489
+ /** Count recent status updates from agent on a task that mention ETA but no artifacts */
490
+ countRecentEtaOnlyUpdates(messages, agent, taskId) {
491
+ if (!taskId)
492
+ return 0;
493
+ let etaOnlyCount = 0;
494
+ const cutoff = Date.now() - (4 * 60 * 60 * 1000); // last 4 hours
495
+ for (const m of messages) {
496
+ if ((m.from || '').toLowerCase() !== agent)
497
+ continue;
498
+ const ts = this.parseTimestamp(m.timestamp);
499
+ if (!ts || ts < cutoff)
500
+ continue;
501
+ const content = typeof m.content === 'string' ? m.content : '';
502
+ if (!content.includes(taskId))
503
+ continue;
504
+ // Has ETA/time reference
505
+ const hasEta = /\beta\b|\b\d+\s*(?:min|m|h|hr|hour)/i.test(content);
506
+ if (!hasEta)
507
+ continue;
508
+ // Does NOT have artifact signal
509
+ const hasArtifact = /\b(shipped|artifact|commit|pr\s*#?\d+|pull request|merged|deployed|https?:\/\/github\.com)/i.test(content);
510
+ const hasBlocker = /\bblocker\b.*:.*\S/i.test(content) && !/\bblocker\b.*:\s*none\b/i.test(content);
511
+ if (!hasArtifact && !hasBlocker) {
512
+ etaOnlyCount++;
513
+ }
514
+ }
515
+ return etaOnlyCount;
516
+ }
517
+ findLastProductiveActionAt(messages, agent) {
518
+ let lastAt = null;
519
+ for (const m of messages) {
520
+ if ((m.from || '').toLowerCase() !== agent)
521
+ continue;
522
+ const content = typeof m.content === 'string' ? m.content : '';
523
+ if (!content)
524
+ continue;
525
+ // Productive shipping signal: artifact/commit/proof/merged/shipped references.
526
+ const hasProductiveSignal = /\b(shipped|shipped:|artifact|artifacts|commit|proof|merged|pr\s*#?\d+|pull request|deployed)\b/i.test(content);
527
+ if (!hasProductiveSignal)
528
+ continue;
529
+ const ts = this.parseTimestamp(m.timestamp);
530
+ if (!ts)
531
+ continue;
532
+ if (!lastAt || ts > lastAt)
533
+ lastAt = ts;
534
+ }
535
+ return lastAt;
536
+ }
537
+ hasStaleDoingTask(agent, tasks, now) {
538
+ const thresholdMs = this.idleNudgeActiveTaskMaxAgeMin * 60_000;
539
+ return tasks.some((task) => {
540
+ if ((task.assignee || '').toLowerCase() !== agent)
541
+ return false;
542
+ if (task.status !== 'doing')
543
+ return false;
544
+ const updatedAt = this.parseTimestamp(task.updatedAt) || this.parseTimestamp(task.createdAt);
545
+ const lastActivityAt = this.getTaskLastActivityAt(task.id, updatedAt);
546
+ if (!lastActivityAt)
547
+ return false;
548
+ return now - lastActivityAt > thresholdMs;
549
+ });
550
+ }
551
+ async getComplianceIncidents(now, messages) {
552
+ const fromWatchdog = await this.readWatchdogIncidents(now);
553
+ const inMemory = [];
554
+ const lastTrioGeneralUpdate = this.findLastTrioGeneralUpdate(messages);
555
+ const trioSilenceMin = Math.floor((now - lastTrioGeneralUpdate) / 1000 / 60);
556
+ if (trioSilenceMin > this.trioSilenceMaxMin) {
557
+ inMemory.push({
558
+ id: `inc-trio-${lastTrioGeneralUpdate}`,
559
+ agent: 'trio',
560
+ taskId: null,
561
+ type: 'trio-silence',
562
+ minutesOver: trioSilenceMin - this.trioSilenceMaxMin,
563
+ escalateTo: ['kai', 'link', 'pixel'],
564
+ openedAt: lastTrioGeneralUpdate + this.trioSilenceMaxMin * 60 * 1000,
565
+ });
566
+ }
567
+ return [...fromWatchdog, ...inMemory].sort((a, b) => b.openedAt - a.openedAt);
568
+ }
569
+ async readWatchdogIncidents(now) {
570
+ const paths = [
571
+ process.env.WATCHDOG_INCIDENT_LOG,
572
+ resolve(process.cwd(), '../workspace-link/openclaw-plugin-reflectt-node/incidents/watchdog-incidents.jsonl'),
573
+ resolve(process.cwd(), '../../workspace-link/openclaw-plugin-reflectt-node/incidents/watchdog-incidents.jsonl'),
574
+ ].filter((p) => Boolean(p));
575
+ for (const path of paths) {
576
+ try {
577
+ const raw = await readFile(path, 'utf8');
578
+ const lines = raw.trim().split('\n').filter(Boolean);
579
+ const recent = lines.slice(-100);
580
+ const incidents = recent
581
+ .map((line) => {
582
+ try {
583
+ return JSON.parse(line);
584
+ }
585
+ catch {
586
+ return null;
587
+ }
588
+ })
589
+ .filter((v) => v !== null)
590
+ .map((entry, idx) => this.mapWatchdogIncident(entry, idx, now))
591
+ .filter((v) => v !== null);
592
+ if (incidents.length > 0) {
593
+ return incidents;
594
+ }
595
+ }
596
+ catch {
597
+ // try next path
598
+ }
599
+ }
600
+ return [];
601
+ }
602
+ /** Maximum reasonable incident age (7 days). Anything older is stale/impossible. */
603
+ static MAX_INCIDENT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
604
+ /**
605
+ * Clamp minutesOver to a reasonable bound and reject impossible durations.
606
+ * Returns null if the calculated age exceeds MAX_INCIDENT_AGE_MS (impossible/stale).
607
+ */
608
+ clampIncidentAge(now, reference, thresholdMin) {
609
+ if (reference <= 0 || reference > now)
610
+ return null; // invalid timestamp
611
+ const ageMs = now - reference;
612
+ if (ageMs > TeamHealthMonitor.MAX_INCIDENT_AGE_MS)
613
+ return null; // impossible duration
614
+ return Math.max(0, Math.floor(ageMs / 60_000) - thresholdMin);
615
+ }
616
+ /**
617
+ * Validate that a task referenced by an incident still exists and is in a
618
+ * monitored status. Returns false for deleted or closed/done/cancelled tasks.
619
+ */
620
+ isTaskStillActive(taskId) {
621
+ if (!taskId)
622
+ return true; // no task reference — allow (e.g. trio silence)
623
+ const task = taskManager.getTask(taskId);
624
+ if (!task)
625
+ return false; // deleted (hard DELETE)
626
+ const closedStatuses = new Set(['done', 'cancelled']);
627
+ return !closedStatuses.has(task.status);
628
+ }
629
+ mapWatchdogIncident(entry, idx, now) {
630
+ const openedAt = entry.at || now;
631
+ const taskId = entry.taskId ?? null;
632
+ const rawType = entry.type || '';
633
+ // Skip incidents for tasks that no longer exist (hard-deleted) or are closed
634
+ if (taskId && !this.isTaskStillActive(taskId))
635
+ return null;
636
+ if (rawType === 'trio_general_silence') {
637
+ const thresholdMin = Math.floor((entry.thresholdMs || this.trioSilenceMaxMin * 60_000) / 60_000);
638
+ const reference = entry.lastUpdateAt || openedAt;
639
+ const minutesOver = this.clampIncidentAge(now, reference, thresholdMin);
640
+ if (minutesOver === null)
641
+ return null; // impossible duration — skip
642
+ return {
643
+ id: `inc-watchdog-trio-${openedAt}-${idx}`,
644
+ agent: 'trio',
645
+ taskId,
646
+ type: 'trio-silence',
647
+ minutesOver,
648
+ escalateTo: ['kai', 'link', 'pixel'],
649
+ openedAt,
650
+ };
651
+ }
652
+ if (rawType === 'stale_working') {
653
+ const agent = entry.agent || 'unknown';
654
+ const thresholdMin = Math.floor((entry.thresholdMs || this.workerCadenceMaxMin * 60_000) / 60_000);
655
+ const reference = entry.lastUpdateAt || openedAt;
656
+ const minutesOver = this.clampIncidentAge(now, reference, thresholdMin);
657
+ if (minutesOver === null)
658
+ return null; // impossible duration — skip
659
+ return {
660
+ id: `inc-watchdog-stale-${agent}-${openedAt}-${idx}`,
661
+ agent,
662
+ taskId,
663
+ type: 'stale-working',
664
+ minutesOver,
665
+ escalateTo: agent === 'pixel' ? ['kai', 'link'] : ['kai', 'pixel'],
666
+ openedAt,
667
+ };
668
+ }
669
+ if (rawType === 'blocked_without_handoff') {
670
+ const agent = entry.agent || 'unknown';
671
+ const thresholdMin = Math.floor((entry.thresholdMs || this.blockedEscalationMin * 60_000) / 60_000);
672
+ const reference = entry.lastUpdateAt || entry.blockedSinceAt || openedAt;
673
+ const minutesOver = this.clampIncidentAge(now, reference, thresholdMin);
674
+ if (minutesOver === null)
675
+ return null; // impossible duration — skip
676
+ return {
677
+ id: `inc-watchdog-blocked-${agent}-${openedAt}-${idx}`,
678
+ agent,
679
+ taskId,
680
+ type: 'blocked-overdue',
681
+ minutesOver,
682
+ escalateTo: agent === 'pixel' ? ['kai', 'link'] : ['kai', 'pixel'],
683
+ openedAt,
684
+ };
685
+ }
686
+ return null;
687
+ }
688
+ getLatestTaskCommentAgeMin(taskId, now) {
689
+ if (!taskId)
690
+ return null;
691
+ const comments = taskManager.getTaskComments(taskId);
692
+ if (!comments.length)
693
+ return null;
694
+ const latestTs = comments.reduce((max, c) => Math.max(max, this.parseTimestamp(c.timestamp)), 0);
695
+ if (!latestTs)
696
+ return null;
697
+ return Math.max(0, Math.floor((now - latestTs) / 60_000));
698
+ }
699
+ getLatestMentionAgeMin(messages, agent, now) {
700
+ const needle = `@${agent.toLowerCase()}`;
701
+ let latest = 0;
702
+ for (const m of messages) {
703
+ const from = (m?.from || '').toLowerCase();
704
+ if (!from || from === agent.toLowerCase())
705
+ continue;
706
+ const content = typeof m?.content === 'string' ? m.content.toLowerCase() : '';
707
+ if (!content.includes(needle))
708
+ continue;
709
+ const ts = this.parseTimestamp(m.timestamp);
710
+ if (ts > latest)
711
+ latest = ts;
712
+ }
713
+ if (!latest)
714
+ return null;
715
+ return Math.max(0, Math.floor((now - latest) / 60_000));
716
+ }
717
+ buildSuggestedAction(args) {
718
+ if (args.hasRecentBlocker || args.status === 'blocked') {
719
+ return 'Post blocker owner + unblock ETA in #general and request reviewer help if blocked >20m.';
720
+ }
721
+ if (args.idleWithActiveTask) {
722
+ return 'Post shipped/blocker/next+ETA now and either move task to validating with artifact or set blocked reason.';
723
+ }
724
+ if (args.status === 'silent' || args.status === 'offline') {
725
+ return 'Acknowledge in #general and confirm active lane status or set task to blocked/todo if paused.';
726
+ }
727
+ if (!args.hasTask && (args.status === 'idle' || args.status === 'active')) {
728
+ return 'Claim next backlog task or post explicit no-work state; avoid idle-without-lane drift.';
729
+ }
730
+ return 'Post a concrete next artifact ETA and keep task status aligned with actual execution state.';
731
+ }
732
+ /**
733
+ * Get health status for all agents
734
+ */
735
+ async getAgentHealthStatuses(now) {
736
+ const presences = presenceManager.getAllPresence();
737
+ const tasks = taskManager.listTasks({});
738
+ const messages = chatManager.getMessages({ limit: 300 });
739
+ const agentStatuses = [];
740
+ // Get unique agent list from all sources
741
+ const agentSet = new Set();
742
+ presences.forEach((p) => agentSet.add(p.agent));
743
+ tasks.forEach((t) => t.assignee && agentSet.add(t.assignee));
744
+ messages.forEach((m) => agentSet.add(m.from));
745
+ for (const agent of agentSet) {
746
+ const presence = presences.find((p) => p.agent === agent);
747
+ const agentTasks = tasks
748
+ .filter((t) => t.assignee === agent && t.status === 'doing')
749
+ .sort((a, b) => {
750
+ const aTs = Number(a.updatedAt || a.createdAt || 0);
751
+ const bTs = Number(b.updatedAt || b.createdAt || 0);
752
+ return bTs - aTs;
753
+ });
754
+ const agentMessages = messages.filter((m) => m.from === agent);
755
+ const lastSeen = presence?.lastUpdate || 0;
756
+ const minutesSinceLastSeen = Math.floor((now - lastSeen) / 1000 / 60);
757
+ // Count messages in last 24h
758
+ const oneDayAgo = now - (24 * 60 * 60 * 1000);
759
+ const messageCount24h = agentMessages.filter((m) => m.timestamp > oneDayAgo).length;
760
+ const lastProductiveAt = this.findLastProductiveActionAt(messages, agent);
761
+ const minutesSinceProductive = lastProductiveAt
762
+ ? Math.floor((now - lastProductiveAt) / 1000 / 60)
763
+ : null;
764
+ // Determine status
765
+ let status = 'offline';
766
+ if (minutesSinceLastSeen < 15) {
767
+ status = 'active';
768
+ }
769
+ else if (minutesSinceLastSeen < 45) {
770
+ status = 'idle';
771
+ }
772
+ else if (minutesSinceLastSeen < 120) {
773
+ status = 'silent'; // >45min = >3 heartbeats
774
+ }
775
+ // Check for blockers in recent messages
776
+ const recentBlockers = this.findBlockersInMessages(agentMessages.slice(-10));
777
+ // Override status if explicitly blocked
778
+ if (presence?.status === 'blocked' || recentBlockers.length > 0) {
779
+ status = 'blocked';
780
+ }
781
+ const activeTask = agentTasks[0];
782
+ const hasActiveTask = Boolean(activeTask);
783
+ const idleWithActiveTask = hasActiveTask && minutesSinceLastSeen > 60;
784
+ const mentionAgeMin = this.getLatestMentionAgeMin(messages, agent, now);
785
+ const lastTransition = activeTask?.metadata?.last_transition;
786
+ const lastTransitionTs = this.parseTimestamp(lastTransition?.timestamp);
787
+ const isFlagged = status === 'blocked' || status === 'silent' || status === 'offline' || idleWithActiveTask;
788
+ const actionable_reason = isFlagged
789
+ ? {
790
+ task_id: activeTask?.id || null,
791
+ last_task_comment_age_min: this.getLatestTaskCommentAgeMin(activeTask?.id, now),
792
+ last_transition: {
793
+ type: typeof lastTransition?.type === 'string' ? lastTransition.type : null,
794
+ actor: typeof lastTransition?.actor === 'string' ? lastTransition.actor : null,
795
+ age_min: lastTransitionTs ? Math.max(0, Math.floor((now - lastTransitionTs) / 60_000)) : null,
796
+ },
797
+ last_mention_age_min: mentionAgeMin,
798
+ suggested_action: this.buildSuggestedAction({
799
+ status,
800
+ idleWithActiveTask,
801
+ hasRecentBlocker: recentBlockers.length > 0,
802
+ hasTask: hasActiveTask,
803
+ }),
804
+ }
805
+ : null;
806
+ agentStatuses.push({
807
+ agent,
808
+ status,
809
+ lastSeen,
810
+ minutesSinceLastSeen,
811
+ currentTask: activeTask?.title,
812
+ activeTaskId: activeTask?.id,
813
+ activeTaskTitle: activeTask?.title,
814
+ activeTaskPrLink: this.extractTaskPrLink(activeTask),
815
+ recentBlockers,
816
+ messageCount24h,
817
+ lastProductiveAt,
818
+ minutesSinceProductive,
819
+ idleWithActiveTask,
820
+ actionable_reason,
821
+ });
822
+ }
823
+ return agentStatuses.sort((a, b) => b.lastSeen - a.lastSeen);
824
+ }
825
+ /**
826
+ * Extract blocker mentions from recent messages
827
+ */
828
+ async extractBlockers() {
829
+ const messages = chatManager.getMessages({ limit: 200 });
830
+ const blockerMap = new Map();
831
+ for (const msg of messages) {
832
+ const blockers = this.findBlockersInMessages([msg]);
833
+ for (const blocker of blockers) {
834
+ const key = `${msg.from}:${blocker}`;
835
+ if (blockerMap.has(key)) {
836
+ const existing = blockerMap.get(key);
837
+ existing.mentionCount++;
838
+ existing.lastMentioned = msg.timestamp;
839
+ }
840
+ else {
841
+ blockerMap.set(key, {
842
+ agent: msg.from,
843
+ blocker,
844
+ mentionCount: 1,
845
+ firstMentioned: msg.timestamp,
846
+ lastMentioned: msg.timestamp,
847
+ });
848
+ }
849
+ }
850
+ }
851
+ return Array.from(blockerMap.values())
852
+ .filter(b => b.mentionCount >= 2) // Only blockers mentioned multiple times
853
+ .sort((a, b) => b.lastMentioned - a.lastMentioned);
854
+ }
855
+ /**
856
+ * Find blocker keywords in messages (improved with false-positive reduction)
857
+ */
858
+ findBlockersInMessages(messages) {
859
+ const blockers = [];
860
+ for (const msg of messages) {
861
+ const from = (msg?.from || '').toLowerCase();
862
+ if (from === 'system' || from === 'watchdog') {
863
+ continue;
864
+ }
865
+ const rawContent = typeof msg?.content === 'string' ? msg.content : '';
866
+ if (!rawContent)
867
+ continue;
868
+ const content = rawContent.toLowerCase();
869
+ // Skip known non-actionable watchdog/template/fallback chatter.
870
+ const looksLikeStatusTemplate = /1\)\s*shipped:\s*</i.test(rawContent) ||
871
+ /2\)\s*blocker:\s*</i.test(rawContent) ||
872
+ /3\)\s*next:\s*</i.test(rawContent);
873
+ if (content.includes('post shipped / blocker / next+eta now') ||
874
+ content.includes('system watchdog') ||
875
+ content.includes('system fallback') ||
876
+ content.includes('idle nudge') ||
877
+ content.includes('required status now') ||
878
+ content.includes('system reminder: you appear idle') ||
879
+ content.includes('system escalation:') ||
880
+ looksLikeStatusTemplate) {
881
+ continue;
882
+ }
883
+ // Skip status reports and completed work mentions
884
+ if (content.includes('was blocked') ||
885
+ content.includes('unblocked') ||
886
+ content.includes('fixed') ||
887
+ content.includes('resolved') ||
888
+ content.includes('completed') ||
889
+ content.includes('done')) {
890
+ continue;
891
+ }
892
+ for (const keyword of this.blockerKeywords) {
893
+ if (content.includes(keyword)) {
894
+ // Additional context check: must be near agent name or "I" to be real blocker
895
+ const hasContext = content.includes(' i ') ||
896
+ content.includes('i\'m') ||
897
+ content.includes('we\'re') ||
898
+ content.match(/@\w+/);
899
+ if (!hasContext)
900
+ continue;
901
+ // Extract context around the keyword
902
+ const index = content.indexOf(keyword);
903
+ const start = Math.max(0, index - 20);
904
+ const end = Math.min(content.length, index + keyword.length + 40);
905
+ const context = rawContent.substring(start, end).trim();
906
+ blockers.push(context);
907
+ break; // Only one blocker per message
908
+ }
909
+ }
910
+ }
911
+ return blockers;
912
+ }
913
+ hasScopeSplitSignal(taskId) {
914
+ const comments = taskManager.getTaskComments(taskId);
915
+ const splitSignals = [
916
+ 'deconflict',
917
+ 'scope split',
918
+ 'owner map',
919
+ 'no overlap',
920
+ 'non-overlap',
921
+ 'boundary',
922
+ 'aligned',
923
+ 'split ownership',
924
+ 'avoid duplicate',
925
+ ];
926
+ return comments.some(comment => {
927
+ const content = comment.content.toLowerCase();
928
+ return splitSignals.some(signal => content.includes(signal));
929
+ });
930
+ }
931
+ /**
932
+ * Detect overlapping work (agents working on similar things)
933
+ */
934
+ async detectOverlaps() {
935
+ const tasks = taskManager
936
+ .listTasks({ status: 'doing' })
937
+ .filter(task => Boolean(task.assignee));
938
+ if (tasks.length < 2)
939
+ return [];
940
+ const taskKeywords = new Map();
941
+ for (const task of tasks) {
942
+ const keywords = this.extractKeywords(`${task.title} ${task.description || ''}`);
943
+ taskKeywords.set(task.id, new Set(keywords));
944
+ }
945
+ const overlapTopics = new Map();
946
+ for (let i = 0; i < tasks.length; i += 1) {
947
+ for (let j = i + 1; j < tasks.length; j += 1) {
948
+ const a = tasks[i];
949
+ const b = tasks[j];
950
+ if (!a.assignee || !b.assignee)
951
+ continue;
952
+ if (a.assignee === b.assignee)
953
+ continue;
954
+ // If either task explicitly carries deconfliction/scope-split notes,
955
+ // treat this pair as resolved and suppress recurring overlap alerts.
956
+ if (this.hasScopeSplitSignal(a.id) || this.hasScopeSplitSignal(b.id)) {
957
+ continue;
958
+ }
959
+ const aKeywords = taskKeywords.get(a.id) || new Set();
960
+ const bKeywords = taskKeywords.get(b.id) || new Set();
961
+ const shared = Array.from(aKeywords).filter(k => bKeywords.has(k));
962
+ // Require 2+ shared keywords to avoid generic single-word collisions.
963
+ if (shared.length < 2)
964
+ continue;
965
+ const topic = shared.slice(0, 2).join('+');
966
+ if (!overlapTopics.has(topic)) {
967
+ overlapTopics.set(topic, new Set());
968
+ }
969
+ overlapTopics.get(topic).add(a.assignee);
970
+ overlapTopics.get(topic).add(b.assignee);
971
+ }
972
+ }
973
+ const overlaps = [];
974
+ for (const [topic, agentsSet] of overlapTopics.entries()) {
975
+ const agents = Array.from(agentsSet);
976
+ if (agents.length < 2)
977
+ continue;
978
+ overlaps.push({
979
+ agents,
980
+ topic,
981
+ confidence: agents.length >= 3 ? 'high' : 'medium',
982
+ });
983
+ }
984
+ return overlaps;
985
+ }
986
+ /**
987
+ * Extract keywords from text
988
+ */
989
+ extractKeywords(text) {
990
+ const stopWords = new Set([
991
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by',
992
+ // Domain-generic terms that cause overlap false positives.
993
+ 'task', 'tasks', 'reflectt', 'node', 'agent', 'agents', 'lane', 'lanes', 'work', 'status',
994
+ ]);
995
+ return text
996
+ .toLowerCase()
997
+ .split(/\W+/)
998
+ .filter(word => word.length > 3 && !stopWords.has(word))
999
+ .slice(0, 8);
1000
+ }
1001
+ shouldEmitCadenceAlert(key, now, cooldownMin) {
1002
+ const lastAt = this.cadenceAlertState.get(key);
1003
+ if (!lastAt)
1004
+ return true;
1005
+ const cooldown = Number(cooldownMin ?? this.cadenceAlertCooldownMin);
1006
+ const cooldownMs = cooldown * 60_000;
1007
+ return now - lastAt >= cooldownMs;
1008
+ }
1009
+ markCadenceAlert(key, now) {
1010
+ this.cadenceAlertState.set(key, now);
1011
+ }
1012
+ /**
1013
+ * Enhanced suppression: check if agent has had ANY recent activity
1014
+ * (task comment, status update, chat message, task transition) since
1015
+ * the last alert for this key. If so, suppress the repeat.
1016
+ */
1017
+ hasRecentActivitySinceLastAlert(agent, key, now) {
1018
+ const lastAlertAt = this.cadenceAlertState.get(key);
1019
+ if (!lastAlertAt)
1020
+ return false; // No prior alert → can't suppress based on activity
1021
+ const messages = chatManager.getMessages({ limit: 200 });
1022
+ // Check for any message from this agent after the last alert
1023
+ const hasRecentMessage = messages.some((m) => {
1024
+ const from = (m.from || '').toLowerCase();
1025
+ const ts = Number(m.timestamp || 0);
1026
+ return from === agent && ts > lastAlertAt;
1027
+ });
1028
+ if (hasRecentMessage)
1029
+ return true;
1030
+ // Check for task comments from this agent after last alert
1031
+ const tasks = taskManager.listTasks({ status: 'doing' });
1032
+ for (const task of tasks) {
1033
+ if ((task.assignee || '').toLowerCase() !== agent)
1034
+ continue;
1035
+ const comments = taskManager.getTaskComments?.(task.id) || [];
1036
+ const hasRecentComment = comments.some((c) => {
1037
+ const author = (c.author || '').toLowerCase();
1038
+ const ts = Number(c.createdAt || 0);
1039
+ return author === agent && ts > lastAlertAt;
1040
+ });
1041
+ if (hasRecentComment)
1042
+ return true;
1043
+ }
1044
+ // Check for task status changes after last alert
1045
+ const updatedTask = tasks.find((t) => {
1046
+ const assignee = (t.assignee || '').toLowerCase();
1047
+ const updatedAt = Number(t.updatedAt || 0);
1048
+ return assignee === agent && updatedAt > lastAlertAt;
1049
+ });
1050
+ if (updatedTask)
1051
+ return true;
1052
+ return false;
1053
+ }
1054
+ async logWatchdogIncident(entry) {
1055
+ const path = process.env.WATCHDOG_INCIDENT_LOG
1056
+ || resolve(process.cwd(), 'incidents/watchdog-incidents.jsonl');
1057
+ await mkdir(dirname(path), { recursive: true });
1058
+ await appendFile(path, `${JSON.stringify(entry)}\n`, 'utf8');
1059
+ }
1060
+ resolveIdleNudgeLane(agent, presenceTaskRaw, tasks, now) {
1061
+ return resolveIdleNudgeLane(agent, presenceTaskRaw, tasks, now, this.idleNudgeActiveTaskMaxAgeMin);
1062
+ }
1063
+ async runCadenceWatchdogTick(now = Date.now(), options) {
1064
+ const dryRun = options?.dryRun === true;
1065
+ const alerts = [];
1066
+ // Persist tick time so /health/system can prove this watchdog is actually firing.
1067
+ recordSystemLoopTick('cadence_watchdog', now);
1068
+ // Source of truth: unified policy config (file + env overlays).
1069
+ // Fallback: legacy env-based flags.
1070
+ const cadenceCfg = policyManager.get().cadenceWatchdog;
1071
+ const cadenceEnabled = cadenceCfg?.enabled ?? this.cadenceWatchdogEnabled;
1072
+ const silenceMin = Number(cadenceCfg?.silenceMin ?? this.cadenceSilenceMin);
1073
+ const workingStaleMin = Number(cadenceCfg?.workingStaleMin ?? this.cadenceWorkingStaleMin);
1074
+ const workingTaskMaxAgeMin = Number(cadenceCfg?.workingTaskMaxAgeMin ?? this.cadenceWorkingTaskMaxAgeMin);
1075
+ const alertCooldownMin = Number(cadenceCfg?.alertCooldownMin ?? this.cadenceAlertCooldownMin);
1076
+ if (!cadenceEnabled) {
1077
+ return { alerts };
1078
+ }
1079
+ // Startup grace period — suppress alerts for first N minutes after service restart.
1080
+ // Prevents a fresh restart from immediately firing a batch of stale alerts for all agents.
1081
+ if (now - this.systemStartTime < this.cadenceStartupGraceMs) {
1082
+ return { alerts };
1083
+ }
1084
+ const tasks = taskManager.listTasks({});
1085
+ const messages = chatManager.getMessages({ limit: 300 });
1086
+ const lastTrioGeneralUpdate = this.findLastTrioGeneralUpdate(messages);
1087
+ const trioSilenceMin = Math.floor((now - lastTrioGeneralUpdate) / 60_000);
1088
+ if (trioSilenceMin >= silenceMin) {
1089
+ const key = 'trio_general_silence';
1090
+ if (this.shouldEmitCadenceAlert(key, now, alertCooldownMin)) {
1091
+ // Enhanced suppression: skip if any trio member has had activity since last alert
1092
+ const anyTrioActive = this.trioAgents.some(a => this.hasRecentActivitySinceLastAlert(a, key, now));
1093
+ if (anyTrioActive) {
1094
+ // Activity detected — extend cooldown without re-alerting
1095
+ this.markCadenceAlert(key, now);
1096
+ }
1097
+ else if (this.trioAgents.some(a => this.hasRecentRateLimitFailure(messages, a, now, this.cadenceRateLimitSuppressMs))) {
1098
+ // Rate-limit failure suppressor: trio is silent due to provider outage, not genuine inactivity
1099
+ this.markCadenceAlert(key, now);
1100
+ }
1101
+ else {
1102
+ const content = `🔁 **[Product Enforcement] Cadence reset**: no #general update from trio for ${trioSilenceMin}m (threshold ${silenceMin}m). @kai @link @pixel post status now: 1) shipped 2) blocker 3) next+ETA. *(Automated — no leadership action needed.)*`;
1103
+ alerts.push(content);
1104
+ if (!dryRun) {
1105
+ await routeMessage({ from: 'system', content, category: 'escalation', severity: 'warning', mentions: ['kai', 'link', 'pixel'] });
1106
+ await this.logWatchdogIncident({
1107
+ type: 'trio_general_silence',
1108
+ at: now,
1109
+ thresholdMs: silenceMin * 60_000,
1110
+ lastUpdateAt: lastTrioGeneralUpdate,
1111
+ });
1112
+ this.markCadenceAlert(key, now);
1113
+ }
1114
+ }
1115
+ }
1116
+ }
1117
+ const trioSet = new Set(this.trioAgents);
1118
+ const doingByAgent = new Map();
1119
+ for (const task of tasks) {
1120
+ if (!task.assignee)
1121
+ continue;
1122
+ // Only monitor actively-doing tasks; skip done/cancelled/blocked
1123
+ if (task.status !== 'doing')
1124
+ continue;
1125
+ const agent = (task.assignee || '').toLowerCase();
1126
+ if (!trioSet.has(agent))
1127
+ continue;
1128
+ const taskTs = Number(task.updatedAt || task.createdAt || 0);
1129
+ const taskAgeMin = taskTs > 0 ? Math.floor((now - taskTs) / 60_000) : Number.MAX_SAFE_INTEGER;
1130
+ if (taskAgeMin > workingTaskMaxAgeMin) {
1131
+ continue;
1132
+ }
1133
+ const current = doingByAgent.get(agent);
1134
+ const currentTs = current ? Number(current.updatedAt || current.createdAt || 0) : 0;
1135
+ if (!current || taskTs >= currentTs) {
1136
+ doingByAgent.set(agent, task);
1137
+ }
1138
+ }
1139
+ const workingTasks = Array.from(doingByAgent.values());
1140
+ for (const task of workingTasks) {
1141
+ const agent = (task.assignee || '').toLowerCase();
1142
+ // Re-check task status at nudge time (guards against race between list and nudge)
1143
+ // Also handles deleted tasks: if getTask returns null, the task was hard-deleted
1144
+ const freshTask = taskManager.getTask(task.id);
1145
+ if (!freshTask)
1146
+ continue; // task was deleted — skip alert
1147
+ if (freshTask.status !== 'doing')
1148
+ continue; // task moved to done/cancelled/blocked — skip
1149
+ const lastGeneralAt = this.getLatestGeneralMessageAt(messages, agent);
1150
+ const lastAnyAt = this.getLatestAnyMessageAt(messages, agent);
1151
+ // Use the more recent of #general or any-channel activity
1152
+ const lastAt = Math.max(lastGeneralAt, lastAnyAt);
1153
+ const rawStaleMin = lastAt > 0 ? Math.floor((now - lastAt) / 60_000) : 9999;
1154
+ // Cap at MAX_INCIDENT_AGE to prevent impossible durations (e.g. from stale cache)
1155
+ const maxStaleMin = Math.floor(TeamHealthMonitor.MAX_INCIDENT_AGE_MS / 60_000);
1156
+ const staleMin = Math.min(rawStaleMin, maxStaleMin);
1157
+ if (staleMin < workingStaleMin)
1158
+ continue;
1159
+ // Also check task comments as activity signal
1160
+ const taskCommentAge = this.getTaskCommentAgeForAgent(task.id, agent, now);
1161
+ if (taskCommentAge !== null && taskCommentAge < workingStaleMin)
1162
+ continue;
1163
+ const key = `stale_working:${agent}:${task.id}`;
1164
+ if (!this.shouldEmitCadenceAlert(key, now, alertCooldownMin))
1165
+ continue;
1166
+ // Enhanced suppression: skip if agent has had ANY activity since last alert
1167
+ if (this.hasRecentActivitySinceLastAlert(agent, key, now)) {
1168
+ this.markCadenceAlert(key, now);
1169
+ continue;
1170
+ }
1171
+ // Also suppress first-time alerts if agent posted ANY #general message recently
1172
+ const agentLastGeneralAt = this.getLatestGeneralMessageAt(messages, agent);
1173
+ if (agentLastGeneralAt > 0) {
1174
+ const sinceGeneralMin = Math.floor((now - agentLastGeneralAt) / 60_000);
1175
+ if (sinceGeneralMin < workingStaleMin)
1176
+ continue;
1177
+ }
1178
+ // Rate-limit failure suppressor: if the agent's silence is caused by infra failures
1179
+ // (rate limits, provider outage), suppress the alert and extend cooldown silently.
1180
+ // Prevents noisy false positives during provider outages when agents are trying but blocked.
1181
+ if (this.hasRecentRateLimitFailure(messages, agent, now, this.cadenceRateLimitSuppressMs)) {
1182
+ this.markCadenceAlert(key, now);
1183
+ continue;
1184
+ }
1185
+ const content = `@${agent} [Product Enforcement] status=working with no update for ${staleMin}m on ${task.id}. Post status now: 1) shipped 2) blocker 3) next+ETA. *(Automated — no leadership action needed.)*`;
1186
+ alerts.push(content);
1187
+ if (!dryRun) {
1188
+ await routeMessage({ from: 'system', content, category: 'watchdog-alert', severity: 'info', taskId: task.id, mentions: [agent, 'kai', 'pixel'] });
1189
+ await this.logWatchdogIncident({
1190
+ type: 'stale_working',
1191
+ at: now,
1192
+ agent,
1193
+ taskId: task.id,
1194
+ thresholdMs: workingStaleMin * 60_000,
1195
+ lastUpdateAt: lastAt || null,
1196
+ workingSinceAt: task.updatedAt || task.createdAt || null,
1197
+ });
1198
+ this.markCadenceAlert(key, now);
1199
+ }
1200
+ }
1201
+ return { alerts };
1202
+ }
1203
+ /**
1204
+ * Ensure the mention_rescue_state SQLite table exists.
1205
+ * Called lazily on first tick to avoid import-time side effects.
1206
+ */
1207
+ ensureMentionRescueDb() {
1208
+ if (this.mentionRescueDbInitialized)
1209
+ return;
1210
+ try {
1211
+ const db = getDb();
1212
+ db.exec(`
1213
+ CREATE TABLE IF NOT EXISTS mention_rescue_state (
1214
+ thread_key TEXT PRIMARY KEY,
1215
+ message_ids TEXT NOT NULL DEFAULT '[]',
1216
+ rescued_at INTEGER NOT NULL,
1217
+ rescue_count INTEGER NOT NULL DEFAULT 1
1218
+ )
1219
+ `);
1220
+ this.mentionRescueDbInitialized = true;
1221
+ }
1222
+ catch {
1223
+ // DB not available — fall back to in-memory only
1224
+ }
1225
+ }
1226
+ /**
1227
+ * Extract which trio agents were actually mentioned in a message.
1228
+ * Returns lowercase agent ids (subset of this.trioAgents).
1229
+ */
1230
+ extractMentionedTrioAgents(content) {
1231
+ if (!content)
1232
+ return [];
1233
+ const matches = content.match(/@(kai|link|pixel)\b/gi) || [];
1234
+ const uniq = new Set();
1235
+ for (const m of matches) {
1236
+ const name = m.replace('@', '').toLowerCase();
1237
+ if (this.trioAgents.includes(name))
1238
+ uniq.add(name);
1239
+ }
1240
+ return Array.from(uniq);
1241
+ }
1242
+ /**
1243
+ * Build a thread-level idempotency key for mention-rescue.
1244
+ * Groups mentions by thread context so that multiple messages in the same
1245
+ * thread/channel with the same mentioned agents produce one rescue, not many.
1246
+ *
1247
+ * Key format: `{channel}:{threadId || 'root'}:{sortedAgents}`
1248
+ */
1249
+ buildMentionThreadKey(mention) {
1250
+ const channel = String(mention.channel || 'general');
1251
+ const threadId = String(mention.threadId || mention.thread_id || 'root');
1252
+ const content = typeof mention.content === 'string' ? mention.content : '';
1253
+ const agents = this.extractMentionedTrioAgents(content)
1254
+ .slice()
1255
+ .sort()
1256
+ .join(',');
1257
+ return `${channel}:${threadId}:${agents}`;
1258
+ }
1259
+ /**
1260
+ * Check if a thread key has already been rescued (persisted or in-memory).
1261
+ */
1262
+ isThreadRescued(threadKey, cooldownMs, now) {
1263
+ // Check in-memory first (covers current session)
1264
+ for (const [, entry] of this.mentionRescueState) {
1265
+ if (entry.rescueCount > 0) {
1266
+ // In-memory entries are keyed by mentionId, not threadKey — checked below
1267
+ }
1268
+ }
1269
+ // Check SQLite for cross-restart persistence
1270
+ try {
1271
+ this.ensureMentionRescueDb();
1272
+ const db = getDb();
1273
+ const row = db.prepare('SELECT rescued_at, rescue_count FROM mention_rescue_state WHERE thread_key = ?').get(threadKey);
1274
+ if (row && row.rescue_count > 0) {
1275
+ // Within cooldown window — still suppressed
1276
+ if (now - row.rescued_at < cooldownMs)
1277
+ return true;
1278
+ // Beyond cooldown — allow re-rescue (but this is unusual; max age usually prevents it)
1279
+ return true; // One-shot per thread: once rescued, always rescued
1280
+ }
1281
+ }
1282
+ catch {
1283
+ // DB read failed — rely on in-memory only
1284
+ }
1285
+ return false;
1286
+ }
1287
+ /**
1288
+ * Record a thread rescue in both in-memory state and SQLite.
1289
+ */
1290
+ recordThreadRescue(threadKey, mentionId, now) {
1291
+ // In-memory
1292
+ this.mentionRescueState.set(mentionId, { lastRescueAt: now, rescueCount: 1 });
1293
+ this.mentionRescueLastAt = now;
1294
+ // SQLite persistence
1295
+ try {
1296
+ this.ensureMentionRescueDb();
1297
+ const db = getDb();
1298
+ const existing = db.prepare('SELECT message_ids, rescue_count FROM mention_rescue_state WHERE thread_key = ?').get(threadKey);
1299
+ if (existing) {
1300
+ const ids = JSON.parse(existing.message_ids || '[]');
1301
+ if (!ids.includes(mentionId))
1302
+ ids.push(mentionId);
1303
+ db.prepare('UPDATE mention_rescue_state SET message_ids = ?, rescued_at = ?, rescue_count = rescue_count + 1 WHERE thread_key = ?').run(JSON.stringify(ids), now, threadKey);
1304
+ }
1305
+ else {
1306
+ db.prepare('INSERT INTO mention_rescue_state (thread_key, message_ids, rescued_at, rescue_count) VALUES (?, ?, ?, 1)').run(threadKey, JSON.stringify([mentionId]), now);
1307
+ }
1308
+ }
1309
+ catch {
1310
+ // DB write failed — in-memory state still covers current session
1311
+ }
1312
+ }
1313
+ /**
1314
+ * Prune stale rescue state entries from both in-memory and SQLite.
1315
+ */
1316
+ pruneRescueState(now) {
1317
+ const pruneThresholdMs = 60 * 60_000;
1318
+ // Prune in-memory
1319
+ for (const [key, entry] of this.mentionRescueState) {
1320
+ if (now - entry.lastRescueAt > pruneThresholdMs) {
1321
+ this.mentionRescueState.delete(key);
1322
+ }
1323
+ }
1324
+ // Prune SQLite
1325
+ try {
1326
+ this.ensureMentionRescueDb();
1327
+ const db = getDb();
1328
+ db.prepare('DELETE FROM mention_rescue_state WHERE rescued_at < ?').run(now - pruneThresholdMs);
1329
+ }
1330
+ catch {
1331
+ // DB prune failed — non-critical
1332
+ }
1333
+ }
1334
+ async runMentionRescueTick(now = Date.now(), options) {
1335
+ const dryRun = options?.dryRun === true;
1336
+ const rescued = [];
1337
+ // Persist tick time so /health/system can prove this watchdog is actually firing.
1338
+ recordSystemLoopTick('mention_rescue', now);
1339
+ const policy = policyManager.get();
1340
+ const cfg = policy.mentionRescue;
1341
+ if (!cfg?.enabled) {
1342
+ return { rescued };
1343
+ }
1344
+ // Initialize DB table on first tick
1345
+ this.ensureMentionRescueDb();
1346
+ const messages = chatManager.getMessages({ limit: 300 });
1347
+ const mentions = messages.filter((m) => {
1348
+ const from = (m.from || '').toLowerCase();
1349
+ const channel = (m.channel || 'general');
1350
+ const content = typeof m.content === 'string' ? m.content : '';
1351
+ if (channel !== 'general' || from !== 'ryan')
1352
+ return false;
1353
+ return /@(kai|link|pixel)\b/i.test(content);
1354
+ });
1355
+ const trioSet = new Set(this.trioAgents);
1356
+ // Guardrails: never allow instant mention-rescue (creates #general spam).
1357
+ const delayMin = Math.max(3, Number(cfg.delayMin || 0));
1358
+ const cooldownMin = Math.max(1, Number(cfg.cooldownMin || 0));
1359
+ const globalCooldownMin = Math.max(1, Number(cfg.globalCooldownMin || 0));
1360
+ const delayMs = delayMin * 60_000;
1361
+ const cooldownMs = cooldownMin * 60_000;
1362
+ const globalCooldownMs = globalCooldownMin * 60_000;
1363
+ // Maximum age for mentions to be eligible for rescue (30 minutes).
1364
+ // Prevents stale mentions from hours/days ago from triggering infinite rescue loops.
1365
+ const maxMentionAgeMs = 30 * 60_000;
1366
+ // Track which thread keys we've already processed this tick to avoid
1367
+ // emitting multiple rescues for different messages in the same thread.
1368
+ const processedThreadKeys = new Set();
1369
+ for (const mention of mentions) {
1370
+ const mentionId = String(mention.id || mention.timestamp || '');
1371
+ if (!mentionId)
1372
+ continue;
1373
+ const mentionAt = Number(mention.timestamp || 0);
1374
+ if (!mentionAt || now - mentionAt < delayMs)
1375
+ continue;
1376
+ // Skip stale mentions — if a mention is older than maxMentionAgeMs, stop rescuing it.
1377
+ // This prevents infinite rescue loops for old unresolved mentions.
1378
+ if (now - mentionAt > maxMentionAgeMs)
1379
+ continue;
1380
+ // Global cooldown to avoid duplicate fallback nudges across near-identical mentions.
1381
+ if (now - this.mentionRescueLastAt < globalCooldownMs)
1382
+ continue;
1383
+ const mentionContent = typeof mention.content === 'string' ? mention.content : '';
1384
+ const mentionedAgents = this.extractMentionedTrioAgents(mentionContent);
1385
+ if (mentionedAgents.length === 0)
1386
+ continue;
1387
+ // ── Thread-level idempotency ─────────────────────────────────────
1388
+ // Build a thread key that groups mentions by channel + thread + mentioned agents.
1389
+ // This prevents duplicate rescues when Ryan sends multiple messages in the
1390
+ // same thread mentioning the same agents.
1391
+ const threadKey = this.buildMentionThreadKey(mention);
1392
+ // Skip if we already processed this thread key during this tick
1393
+ if (processedThreadKeys.has(threadKey))
1394
+ continue;
1395
+ // Skip if this thread was already rescued (persisted across restarts)
1396
+ if (this.isThreadRescued(threadKey, cooldownMs, now)) {
1397
+ processedThreadKeys.add(threadKey);
1398
+ continue;
1399
+ }
1400
+ // Also check in-memory per-message state (backward compat)
1401
+ const rescueEntry = this.mentionRescueState.get(mentionId);
1402
+ if (rescueEntry && rescueEntry.rescueCount > 0)
1403
+ continue;
1404
+ // Cancel mention-rescue only when the trio reply is in the same channel and
1405
+ // (when applicable) the same thread context as the original mention.
1406
+ //
1407
+ // Without this scoping, any trio chatter anywhere after the mention timestamp
1408
+ // can incorrectly suppress the rescue.
1409
+ const mentionChannel = String(mention.channel || 'general');
1410
+ const mentionThreadId = ((typeof mention.threadId === 'string' ? mention.threadId : null) ||
1411
+ (typeof mention.thread_id === 'string' ? mention.thread_id : null) ||
1412
+ null);
1413
+ const replied = messages.some((m) => {
1414
+ const from = (m.from || '').toLowerCase();
1415
+ if (!trioSet.has(from))
1416
+ return false;
1417
+ const ts = Number(m.timestamp || 0);
1418
+ if (!(ts > mentionAt))
1419
+ return false;
1420
+ const channel = String(m.channel || 'general');
1421
+ if (channel !== mentionChannel)
1422
+ return false;
1423
+ const threadId = (typeof m.threadId === 'string' ? m.threadId : null) ||
1424
+ (typeof m.thread_id === 'string' ? m.thread_id : null) ||
1425
+ null;
1426
+ // If the mention itself is inside a thread, only count replies inside that same thread.
1427
+ if (mentionThreadId)
1428
+ return threadId === mentionThreadId;
1429
+ // Root mention: count either a root reply (no thread) or a reply in the thread
1430
+ // directly attached to the mention (threadId === mentionId).
1431
+ if (threadId)
1432
+ return threadId === mentionId;
1433
+ return true;
1434
+ });
1435
+ if (replied) {
1436
+ processedThreadKeys.add(threadKey);
1437
+ continue;
1438
+ }
1439
+ // Focus mode is a hard suppressor for fallback nudges.
1440
+ const anyFocused = mentionedAgents.some(a => presenceManager.isInFocus(a) !== null);
1441
+ if (anyFocused)
1442
+ continue;
1443
+ const mentionList = mentionedAgents.map(a => `@${a}`).join(' ');
1444
+ const content = `[[reply_to:${mentionId}]] system fallback: mention received. ${mentionList} are being nudged to respond.`;
1445
+ rescued.push(content);
1446
+ if (!dryRun) {
1447
+ await routeMessage({
1448
+ from: 'system',
1449
+ content,
1450
+ category: 'mention-rescue',
1451
+ severity: 'warning',
1452
+ mentions: mentionedAgents,
1453
+ // Keep the fallback in the same channel as the original mention.
1454
+ forceChannel: String(mention.channel || 'general'),
1455
+ });
1456
+ this.recordThreadRescue(threadKey, mentionId, now);
1457
+ }
1458
+ processedThreadKeys.add(threadKey);
1459
+ }
1460
+ // Prune stale state from both in-memory and SQLite
1461
+ this.pruneRescueState(now);
1462
+ return { rescued };
1463
+ }
1464
+ async runIdleNudgeTick(now = Date.now(), options) {
1465
+ const dryRun = options?.dryRun === true;
1466
+ const nudged = [];
1467
+ const decisions = [];
1468
+ // Persist tick time so /health/system can prove this watchdog is actually firing.
1469
+ recordSystemLoopTick('idle_nudge', now);
1470
+ const presences = presenceManager.getAllPresence();
1471
+ const tasks = taskManager.listTasks({});
1472
+ const taskById = new Map(tasks.map((t) => [t.id, t]));
1473
+ const messages = chatManager.getMessages({ limit: 300 });
1474
+ for (const presence of presences) {
1475
+ const agent = (presence.agent || '').toLowerCase();
1476
+ if (!agent)
1477
+ continue;
1478
+ const lastActiveAt = presence.last_active || presence.lastUpdate || 0;
1479
+ const inactivityMin = lastActiveAt ? Math.floor((now - lastActiveAt) / 60_000) : 0;
1480
+ const tier = inactivityMin >= this.idleNudgeEscalateMin ? 2 : 1;
1481
+ const lane = this.resolveIdleNudgeLane(agent, presence.task, tasks, now);
1482
+ const taskId = lane.selectedTaskId;
1483
+ const baseDecision = {
1484
+ agent,
1485
+ taskId,
1486
+ idleMinutes: inactivityMin,
1487
+ warnMin: this.idleNudgeWarnMin,
1488
+ escalateMin: this.idleNudgeEscalateMin,
1489
+ cooldownMin: this.idleNudgeCooldownMin,
1490
+ recentSuppressMin: this.idleNudgeSuppressRecentMin,
1491
+ lane,
1492
+ at: now,
1493
+ };
1494
+ if (!this.idleNudgeEnabled) {
1495
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'disabled', renderedMessage: null });
1496
+ continue;
1497
+ }
1498
+ if (this.idleNudgeExcluded.has(agent)) {
1499
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'excluded', renderedMessage: null });
1500
+ continue;
1501
+ }
1502
+ // Respect focus mode — suppress idle nudges for focused agents
1503
+ const focusState = presenceManager.isInFocus(agent);
1504
+ if (focusState) {
1505
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'focus-mode-active', renderedMessage: null });
1506
+ continue;
1507
+ }
1508
+ if (presence.status === 'offline') {
1509
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'offline', renderedMessage: null });
1510
+ continue;
1511
+ }
1512
+ // Queue-clear (no doing/blocked/validating task) is eligible for engagement nudges — see no-active-lane handler below.
1513
+ if (presence.status === 'blocked') {
1514
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'blocked-task-suppressed', renderedMessage: null });
1515
+ continue;
1516
+ }
1517
+ if (!lastActiveAt) {
1518
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'no-last-active', renderedMessage: null });
1519
+ continue;
1520
+ }
1521
+ const hasStaleDoingTask = this.hasStaleDoingTask(agent, tasks, now);
1522
+ const lastProductiveActionAt = this.findLastProductiveActionAt(messages, agent);
1523
+ if (!hasStaleDoingTask && lastProductiveActionAt) {
1524
+ const sinceShipMin = Math.floor((now - lastProductiveActionAt) / 60_000);
1525
+ if (sinceShipMin < this.idleNudgeShipCooldownMin) {
1526
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'recent-shipped-cooldown', renderedMessage: null });
1527
+ continue;
1528
+ }
1529
+ }
1530
+ // Task-comment activity suppression: treat task comments as not-idle
1531
+ if (taskId) {
1532
+ const taskCommentAge = this.getTaskCommentAgeForAgent(taskId, agent, now);
1533
+ if (taskCommentAge !== null && taskCommentAge < 30) {
1534
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'recent-task-comment', renderedMessage: null });
1535
+ continue;
1536
+ }
1537
+ }
1538
+ // Per-task focus window: 45-60m deep work suppression
1539
+ if (taskId) {
1540
+ const focusWindow = this.getTaskFocusWindow(taskId, agent, now);
1541
+ if (focusWindow && focusWindow.active) {
1542
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'task-focus-window', renderedMessage: null });
1543
+ continue;
1544
+ }
1545
+ }
1546
+ if (inactivityMin < this.idleNudgeWarnMin) {
1547
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'below-warn-threshold', renderedMessage: null });
1548
+ continue;
1549
+ }
1550
+ // Suppress if agent posted ANY message recently (any channel, not just #general)
1551
+ const lastAnyMsgAt = this.getLatestAnyMessageAt(messages, agent);
1552
+ if (lastAnyMsgAt) {
1553
+ const sinceLastMsgMin = Math.floor((now - lastAnyMsgAt) / 60_000);
1554
+ if (sinceLastMsgMin < this.idleNudgeSuppressRecentMin) {
1555
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'recent-activity-suppressed', renderedMessage: null });
1556
+ continue;
1557
+ }
1558
+ }
1559
+ const lastValidStatusAt = this.findLastValidStatusAt(messages, agent);
1560
+ if (lastValidStatusAt) {
1561
+ const sinceLastStatusMin = Math.floor((now - lastValidStatusAt) / 60_000);
1562
+ if (sinceLastStatusMin < this.idleNudgeSuppressRecentMin) {
1563
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'recent-activity-suppressed', renderedMessage: null });
1564
+ continue;
1565
+ }
1566
+ }
1567
+ const state = this.idleNudgeState.get(agent);
1568
+ if (state) {
1569
+ const sinceNudgeMin = Math.floor((now - state.lastNudgeAt) / 60_000);
1570
+ if (sinceNudgeMin < this.idleNudgeCooldownMin) {
1571
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'cooldown-active', renderedMessage: null });
1572
+ continue;
1573
+ }
1574
+ }
1575
+ const hasValidatingTask = tasks.some((t) => (t.assignee || '').toLowerCase() === agent && t.status === 'validating');
1576
+ if (hasValidatingTask) {
1577
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'validating-task-suppressed', renderedMessage: null });
1578
+ continue;
1579
+ }
1580
+ // Engagement nudge: if agent is idle and has no active doing lane, prompt them to pull/claim work.
1581
+ if (lane.laneReason === 'no-active-lane') {
1582
+ const signature = `queue-clear:${agent}`;
1583
+ if (state && state.lastSignature === signature && state.unchangedNudgeCount >= 2) {
1584
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'max-repeat-reached', renderedMessage: null });
1585
+ continue;
1586
+ }
1587
+ const intro = tier === 1
1588
+ ? `@${agent} system reminder: you appear idle for ${inactivityMin}m and have no active task. Pull work now.`
1589
+ : `@${agent} @kai system escalation: ${inactivityMin}m idle and no active task. Pull work now.`;
1590
+ const template = [
1591
+ `1) Pull: GET /tasks/next?agent=${agent}`,
1592
+ `2) Claim: PATCH /tasks/<id> { "status": "doing", "assignee": "${agent}" }`,
1593
+ '3) Post: /tasks/<id>/comments with 1) shipped 2) blocker 3) next+ETA',
1594
+ ].join('\n');
1595
+ const renderedMessage = `${intro}\n${template}`;
1596
+ decisions.push({
1597
+ ...baseDecision,
1598
+ decision: tier === 1 ? 'warn' : 'escalate',
1599
+ reason: 'queue-clear',
1600
+ renderedMessage,
1601
+ });
1602
+ if (dryRun) {
1603
+ continue;
1604
+ }
1605
+ await routeMessage({
1606
+ from: 'system',
1607
+ content: renderedMessage,
1608
+ category: 'watchdog-alert',
1609
+ severity: tier === 2 ? 'warning' : 'info',
1610
+ mentions: tier === 2 ? [agent, 'kai'] : [agent],
1611
+ });
1612
+ const unchangedNudgeCount = state && state.lastSignature === signature
1613
+ ? state.unchangedNudgeCount + 1
1614
+ : 1;
1615
+ this.idleNudgeState.set(agent, {
1616
+ lastNudgeAt: now,
1617
+ lastTier: tier,
1618
+ lastSignature: signature,
1619
+ unchangedNudgeCount,
1620
+ });
1621
+ nudged.push(agent);
1622
+ continue;
1623
+ }
1624
+ if (lane.laneReason === 'stale-lane') {
1625
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'stale-active-task', renderedMessage: null });
1626
+ continue;
1627
+ }
1628
+ if (lane.laneReason === 'ambiguous-lane') {
1629
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'ambiguous-active-task', renderedMessage: null });
1630
+ continue;
1631
+ }
1632
+ if (lane.laneReason === 'presence-task-mismatch') {
1633
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'presence-task-mismatch', renderedMessage: null });
1634
+ continue;
1635
+ }
1636
+ // Safety guard: never emit when an active task is missing/invalid.
1637
+ if (!taskId || !/^task-[a-z0-9-]+$/i.test(taskId)) {
1638
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'missing-active-task', renderedMessage: null });
1639
+ continue;
1640
+ }
1641
+ const selectedTask = taskById.get(taskId);
1642
+ if (selectedTask?.status === 'blocked') {
1643
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'blocked-task-suppressed', renderedMessage: null });
1644
+ continue;
1645
+ }
1646
+ if (selectedTask && selectedTask.status !== 'doing') {
1647
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'done-task-suppressed', renderedMessage: null });
1648
+ continue;
1649
+ }
1650
+ const signature = `${taskId}:${selectedTask?.status || 'unknown'}:${selectedTask?.updatedAt || 0}`;
1651
+ if (state && state.lastSignature === signature && state.unchangedNudgeCount >= 2) {
1652
+ decisions.push({ ...baseDecision, decision: 'none', reason: 'max-repeat-reached', renderedMessage: null });
1653
+ continue;
1654
+ }
1655
+ // ETA-only escalation: after 2 repeated status updates without artifacts,
1656
+ // require artifact link or explicit blocker, else flag for reassignment
1657
+ const etaOnlyCount = this.countRecentEtaOnlyUpdates(messages, agent, taskId);
1658
+ const needsArtifact = etaOnlyCount >= 2;
1659
+ const intro = needsArtifact
1660
+ ? `@${agent} @kai escalation: ${etaOnlyCount} status updates on ${taskId} with no artifact or blocker. Post artifact link or explicit blocker now, or task will be flagged for reassignment.`
1661
+ : tier === 1
1662
+ ? `@${agent} system reminder: you appear idle for ${inactivityMin}m. Post a quick status update now.`
1663
+ : `@${agent} @kai system escalation: ${inactivityMin}m idle. Post required status format now.`;
1664
+ const template = needsArtifact
1665
+ ? [
1666
+ `Task: ${taskId}`,
1667
+ '1) Artifact: <PR link, commit, or file path> (REQUIRED)',
1668
+ '2) Blocker: <explicit blocker if no artifact>',
1669
+ ].join('\n')
1670
+ : [
1671
+ `Task: ${taskId}`,
1672
+ '1) Shipped: <artifact/commit/file>',
1673
+ '2) Blocker: <none or explicit blocker>',
1674
+ '3) Next: <next deliverable + ETA>',
1675
+ ].join('\n');
1676
+ const renderedMessage = `${intro}\n${template}`;
1677
+ decisions.push({
1678
+ ...baseDecision,
1679
+ decision: tier === 1 ? 'warn' : 'escalate',
1680
+ reason: 'eligible',
1681
+ renderedMessage,
1682
+ });
1683
+ if (dryRun) {
1684
+ continue;
1685
+ }
1686
+ await routeMessage({
1687
+ from: 'system',
1688
+ content: renderedMessage,
1689
+ category: 'watchdog-alert',
1690
+ severity: tier === 2 ? 'warning' : 'info',
1691
+ taskId: taskId || undefined,
1692
+ mentions: tier === 2 ? [agent, 'kai'] : [agent],
1693
+ });
1694
+ const unchangedNudgeCount = state && state.lastSignature === signature
1695
+ ? state.unchangedNudgeCount + 1
1696
+ : 1;
1697
+ this.idleNudgeState.set(agent, {
1698
+ lastNudgeAt: now,
1699
+ lastTier: tier,
1700
+ lastSignature: signature,
1701
+ unchangedNudgeCount,
1702
+ });
1703
+ nudged.push(agent);
1704
+ }
1705
+ this.idleNudgeLastDecisions = decisions;
1706
+ return { nudged, decisions };
1707
+ }
1708
+ getIdleNudgeDebug() {
1709
+ const decisionCounts = { none: 0, warn: 0, escalate: 0 };
1710
+ const reasonCounts = {};
1711
+ const laneReasonCounts = {};
1712
+ for (const decision of this.idleNudgeLastDecisions) {
1713
+ decisionCounts[decision.decision] += 1;
1714
+ reasonCounts[decision.reason] = (reasonCounts[decision.reason] || 0) + 1;
1715
+ laneReasonCounts[decision.lane.laneReason] = (laneReasonCounts[decision.lane.laneReason] || 0) + 1;
1716
+ }
1717
+ return {
1718
+ config: {
1719
+ enabled: this.idleNudgeEnabled,
1720
+ warnMin: this.idleNudgeWarnMin,
1721
+ escalateMin: this.idleNudgeEscalateMin,
1722
+ cooldownMin: this.idleNudgeCooldownMin,
1723
+ recentSuppressMin: this.idleNudgeSuppressRecentMin,
1724
+ shipCooldownMin: this.idleNudgeShipCooldownMin,
1725
+ activeTaskMaxAgeMin: this.idleNudgeActiveTaskMaxAgeMin,
1726
+ excluded: Array.from(this.idleNudgeExcluded.values()).sort(),
1727
+ },
1728
+ state: Array.from(this.idleNudgeState.entries()).map(([agent, s]) => ({
1729
+ agent,
1730
+ lastNudgeAt: s.lastNudgeAt,
1731
+ lastTier: s.lastTier,
1732
+ lastSignature: s.lastSignature,
1733
+ unchangedNudgeCount: s.unchangedNudgeCount,
1734
+ })),
1735
+ summary: {
1736
+ decisionCounts,
1737
+ reasonCounts,
1738
+ laneReasonCounts,
1739
+ },
1740
+ lastDecisions: this.idleNudgeLastDecisions,
1741
+ timestamp: Date.now(),
1742
+ };
1743
+ }
1744
+ /**
1745
+ * Get simple summary for quick display
1746
+ */
1747
+ async getSummary() {
1748
+ const health = await this.getHealth();
1749
+ const lines = [
1750
+ `🏥 **Team Health** (${new Date(health.timestamp).toLocaleTimeString()})`,
1751
+ '',
1752
+ `**Active:** ${health.activeAgents.join(', ') || 'none'}`,
1753
+ `**Silent >45min:** ${health.silentAgents.join(', ') || 'none'}`,
1754
+ ];
1755
+ if (health.blockers.length > 0) {
1756
+ lines.push('');
1757
+ lines.push('**🚫 Blockers:**');
1758
+ health.blockers.slice(0, 3).forEach(b => {
1759
+ lines.push(`- ${b.agent}: ${b.blocker} (${b.mentionCount}x)`);
1760
+ });
1761
+ }
1762
+ if (health.overlaps.length > 0) {
1763
+ lines.push('');
1764
+ lines.push('**⚠️ Overlapping work:**');
1765
+ health.overlaps.slice(0, 3).forEach(o => {
1766
+ lines.push(`- ${o.agents.join(', ')}: ${o.topic}`);
1767
+ });
1768
+ }
1769
+ return lines.join('\n');
1770
+ }
1771
+ /**
1772
+ * Record health snapshot for history tracking
1773
+ */
1774
+ async recordSnapshot() {
1775
+ const now = Date.now();
1776
+ // Only snapshot once per hour
1777
+ if (now - this.lastSnapshotTime < this.SNAPSHOT_INTERVAL_MS) {
1778
+ return;
1779
+ }
1780
+ const health = await this.getHealth();
1781
+ this.healthHistory.push(health);
1782
+ this.lastSnapshotTime = now;
1783
+ // Trim old history
1784
+ if (this.healthHistory.length > this.MAX_HISTORY) {
1785
+ this.healthHistory = this.healthHistory.slice(-this.MAX_HISTORY);
1786
+ }
1787
+ }
1788
+ /**
1789
+ * Get health history for trends
1790
+ */
1791
+ getHealthHistory(days = 7) {
1792
+ const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
1793
+ return this.healthHistory.filter(h => h.timestamp >= cutoff);
1794
+ }
1795
+ /**
1796
+ * Track request for system health monitoring
1797
+ */
1798
+ trackRequest(duration) {
1799
+ this.requestCount++;
1800
+ this.requestTimes.push(duration);
1801
+ // Keep only recent request times
1802
+ if (this.requestTimes.length > this.MAX_REQUEST_TIMES) {
1803
+ this.requestTimes = this.requestTimes.slice(-this.MAX_REQUEST_TIMES);
1804
+ }
1805
+ }
1806
+ /**
1807
+ * Track error for system health monitoring
1808
+ */
1809
+ trackError() {
1810
+ this.errorCount++;
1811
+ }
1812
+ /**
1813
+ * Get system health metrics
1814
+ */
1815
+ getSystemHealth() {
1816
+ const uptime = Date.now() - this.systemStartTime;
1817
+ const uptimeHours = Math.floor(uptime / 1000 / 60 / 60);
1818
+ // Calculate response time percentiles
1819
+ const sorted = this.requestTimes.slice().sort((a, b) => a - b);
1820
+ const avgResponseTime = sorted.length > 0
1821
+ ? sorted.reduce((a, b) => a + b, 0) / sorted.length
1822
+ : 0;
1823
+ const p95Index = Math.floor(sorted.length * 0.95);
1824
+ const p95ResponseTime = sorted[p95Index] || 0;
1825
+ const errorRate = this.requestCount > 0
1826
+ ? this.errorCount / this.requestCount
1827
+ : 0;
1828
+ return {
1829
+ uptime,
1830
+ uptimeHours,
1831
+ memory: process.memoryUsage(),
1832
+ requestCount: this.requestCount,
1833
+ errorCount: this.errorCount,
1834
+ avgResponseTime: Math.round(avgResponseTime),
1835
+ p95ResponseTime: Math.round(p95ResponseTime),
1836
+ errorRate: Math.round(errorRate * 10000) / 100, // percentage
1837
+ };
1838
+ }
1839
+ }
1840
+ export const healthMonitor = new TeamHealthMonitor();
1841
+ //# sourceMappingURL=health.js.map