crewly 1.20.183 → 1.20.185

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 (260) hide show
  1. package/config/constants.ts +83 -0
  2. package/config/index.ts +2 -0
  3. package/config/oss-boundary-allowlist.json +10 -0
  4. package/config/skills/agent/core/ask-owner/SKILL.md +5 -2
  5. package/config/skills/agent/core/complete-task/execute.sh +4 -12
  6. package/config/skills/agent/core/complete-task/execute.test.sh +11 -0
  7. package/config/skills/agent/core/report-status/SKILL.md +1 -1
  8. package/config/skills/agent/core/report-status/execute.sh +5 -10
  9. package/dist/backend/backend/src/constants.d.ts +84 -0
  10. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  11. package/dist/backend/backend/src/constants.js +79 -1
  12. package/dist/backend/backend/src/constants.js.map +1 -1
  13. package/dist/backend/backend/src/controllers/decisions/decisions.controller.d.ts +11 -1
  14. package/dist/backend/backend/src/controllers/decisions/decisions.controller.d.ts.map +1 -1
  15. package/dist/backend/backend/src/controllers/decisions/decisions.controller.js +38 -0
  16. package/dist/backend/backend/src/controllers/decisions/decisions.controller.js.map +1 -1
  17. package/dist/backend/backend/src/controllers/google/google.controller.d.ts.map +1 -1
  18. package/dist/backend/backend/src/controllers/google/google.controller.js +3 -2
  19. package/dist/backend/backend/src/controllers/google/google.controller.js.map +1 -1
  20. package/dist/backend/backend/src/controllers/request/open-items.controller.d.ts +34 -0
  21. package/dist/backend/backend/src/controllers/request/open-items.controller.d.ts.map +1 -0
  22. package/dist/backend/backend/src/controllers/request/open-items.controller.js +96 -0
  23. package/dist/backend/backend/src/controllers/request/open-items.controller.js.map +1 -0
  24. package/dist/backend/backend/src/controllers/request/request.routes.d.ts +3 -0
  25. package/dist/backend/backend/src/controllers/request/request.routes.d.ts.map +1 -1
  26. package/dist/backend/backend/src/controllers/request/request.routes.js +9 -0
  27. package/dist/backend/backend/src/controllers/request/request.routes.js.map +1 -1
  28. package/dist/backend/backend/src/controllers/standing/standing.controller.d.ts +5 -2
  29. package/dist/backend/backend/src/controllers/standing/standing.controller.d.ts.map +1 -1
  30. package/dist/backend/backend/src/controllers/standing/standing.controller.js +3 -1
  31. package/dist/backend/backend/src/controllers/standing/standing.controller.js.map +1 -1
  32. package/dist/backend/backend/src/index.d.ts.map +1 -1
  33. package/dist/backend/backend/src/index.js +72 -2
  34. package/dist/backend/backend/src/index.js.map +1 -1
  35. package/dist/backend/backend/src/scripts/check-oss-boundary.d.ts +217 -0
  36. package/dist/backend/backend/src/scripts/check-oss-boundary.d.ts.map +1 -0
  37. package/dist/backend/backend/src/scripts/check-oss-boundary.js +490 -0
  38. package/dist/backend/backend/src/scripts/check-oss-boundary.js.map +1 -0
  39. package/dist/backend/backend/src/scripts/open-items-backfill.d.ts +16 -0
  40. package/dist/backend/backend/src/scripts/open-items-backfill.d.ts.map +1 -0
  41. package/dist/backend/backend/src/scripts/open-items-backfill.js +112 -0
  42. package/dist/backend/backend/src/scripts/open-items-backfill.js.map +1 -0
  43. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  44. package/dist/backend/backend/src/services/agent/agent-registration.service.js +4 -2
  45. package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
  46. package/dist/backend/backend/src/services/ai/prompt-modules/standing-answers.module.d.ts +3 -1
  47. package/dist/backend/backend/src/services/ai/prompt-modules/standing-answers.module.d.ts.map +1 -1
  48. package/dist/backend/backend/src/services/ai/prompt-modules/standing-answers.module.js +15 -4
  49. package/dist/backend/backend/src/services/ai/prompt-modules/standing-answers.module.js.map +1 -1
  50. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +9 -0
  51. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  52. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +11 -0
  53. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  54. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts +10 -0
  55. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js +20 -0
  57. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
  58. package/dist/backend/backend/src/services/decisions/decision-card.d.ts +31 -4
  59. package/dist/backend/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  60. package/dist/backend/backend/src/services/decisions/decision-card.js +60 -2
  61. package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -1
  62. package/dist/backend/backend/src/services/decisions/decision-skip-command.d.ts +56 -0
  63. package/dist/backend/backend/src/services/decisions/decision-skip-command.d.ts.map +1 -0
  64. package/dist/backend/backend/src/services/decisions/decision-skip-command.js +108 -0
  65. package/dist/backend/backend/src/services/decisions/decision-skip-command.js.map +1 -0
  66. package/dist/backend/backend/src/services/decisions/decision.service.d.ts +113 -3
  67. package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  68. package/dist/backend/backend/src/services/decisions/decision.service.js +199 -13
  69. package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
  70. package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts +8 -0
  71. package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -1
  72. package/dist/backend/backend/src/services/decisions/decision.wiring.js +25 -0
  73. package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -1
  74. package/dist/backend/backend/src/services/memory/agent-memory.service.d.ts +19 -0
  75. package/dist/backend/backend/src/services/memory/agent-memory.service.d.ts.map +1 -1
  76. package/dist/backend/backend/src/services/memory/agent-memory.service.js +48 -0
  77. package/dist/backend/backend/src/services/memory/agent-memory.service.js.map +1 -1
  78. package/dist/backend/backend/src/services/memory/memory.service.d.ts.map +1 -1
  79. package/dist/backend/backend/src/services/memory/memory.service.js +20 -0
  80. package/dist/backend/backend/src/services/memory/memory.service.js.map +1 -1
  81. package/dist/backend/backend/src/services/memory/project-memory.service.d.ts +40 -0
  82. package/dist/backend/backend/src/services/memory/project-memory.service.d.ts.map +1 -1
  83. package/dist/backend/backend/src/services/memory/project-memory.service.js +101 -0
  84. package/dist/backend/backend/src/services/memory/project-memory.service.js.map +1 -1
  85. package/dist/backend/backend/src/services/memory/role-knowledge-eligibility.d.ts +1 -1
  86. package/dist/backend/backend/src/services/memory/role-knowledge-eligibility.d.ts.map +1 -1
  87. package/dist/backend/backend/src/services/memory/role-knowledge-eligibility.js.map +1 -1
  88. package/dist/backend/backend/src/services/memory/standing-answers.service.d.ts +61 -1
  89. package/dist/backend/backend/src/services/memory/standing-answers.service.d.ts.map +1 -1
  90. package/dist/backend/backend/src/services/memory/standing-answers.service.js +71 -3
  91. package/dist/backend/backend/src/services/memory/standing-answers.service.js.map +1 -1
  92. package/dist/backend/backend/src/services/memory/standing-refresh.service.d.ts +7 -4
  93. package/dist/backend/backend/src/services/memory/standing-refresh.service.d.ts.map +1 -1
  94. package/dist/backend/backend/src/services/memory/standing-refresh.service.js +18 -9
  95. package/dist/backend/backend/src/services/memory/standing-refresh.service.js.map +1 -1
  96. package/dist/backend/backend/src/services/memory/task-log-filter.d.ts +27 -0
  97. package/dist/backend/backend/src/services/memory/task-log-filter.d.ts.map +1 -0
  98. package/dist/backend/backend/src/services/memory/task-log-filter.js +56 -0
  99. package/dist/backend/backend/src/services/memory/task-log-filter.js.map +1 -0
  100. package/dist/backend/backend/src/services/open-items/open-item-card.d.ts +66 -0
  101. package/dist/backend/backend/src/services/open-items/open-item-card.d.ts.map +1 -0
  102. package/dist/backend/backend/src/services/open-items/open-item-card.js +208 -0
  103. package/dist/backend/backend/src/services/open-items/open-item-card.js.map +1 -0
  104. package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts +114 -0
  105. package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -0
  106. package/dist/backend/backend/src/services/open-items/open-item-extractor.js +394 -0
  107. package/dist/backend/backend/src/services/open-items/open-item-extractor.js.map +1 -0
  108. package/dist/backend/backend/src/services/open-items/open-items-backfill.d.ts +95 -0
  109. package/dist/backend/backend/src/services/open-items/open-items-backfill.d.ts.map +1 -0
  110. package/dist/backend/backend/src/services/open-items/open-items-backfill.js +203 -0
  111. package/dist/backend/backend/src/services/open-items/open-items-backfill.js.map +1 -0
  112. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts +367 -0
  113. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -0
  114. package/dist/backend/backend/src/services/open-items/open-items.service.js +880 -0
  115. package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -0
  116. package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts +44 -0
  117. package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts.map +1 -0
  118. package/dist/backend/backend/src/services/open-items/open-items.wiring.js +185 -0
  119. package/dist/backend/backend/src/services/open-items/open-items.wiring.js.map +1 -0
  120. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
  121. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +4 -0
  122. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
  123. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +13 -0
  124. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  125. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +34 -11
  126. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  127. package/dist/backend/backend/src/services/v3/request.service.d.ts.map +1 -1
  128. package/dist/backend/backend/src/services/v3/request.service.js +15 -1
  129. package/dist/backend/backend/src/services/v3/request.service.js.map +1 -1
  130. package/dist/backend/backend/src/services/v3/ticket-review.service.d.ts.map +1 -1
  131. package/dist/backend/backend/src/services/v3/ticket-review.service.js +5 -1
  132. package/dist/backend/backend/src/services/v3/ticket-review.service.js.map +1 -1
  133. package/dist/backend/backend/src/services/wiki/wiki-process.service.d.ts.map +1 -1
  134. package/dist/backend/backend/src/services/wiki/wiki-process.service.js +4 -2
  135. package/dist/backend/backend/src/services/wiki/wiki-process.service.js.map +1 -1
  136. package/dist/backend/backend/src/services/wiki/wiki-queue.service.d.ts +103 -1
  137. package/dist/backend/backend/src/services/wiki/wiki-queue.service.d.ts.map +1 -1
  138. package/dist/backend/backend/src/services/wiki/wiki-queue.service.js +171 -20
  139. package/dist/backend/backend/src/services/wiki/wiki-queue.service.js.map +1 -1
  140. package/dist/backend/backend/src/services/wiki/wiki-workitem-bridge.service.d.ts +60 -0
  141. package/dist/backend/backend/src/services/wiki/wiki-workitem-bridge.service.d.ts.map +1 -1
  142. package/dist/backend/backend/src/services/wiki/wiki-workitem-bridge.service.js +121 -7
  143. package/dist/backend/backend/src/services/wiki/wiki-workitem-bridge.service.js.map +1 -1
  144. package/dist/backend/backend/src/types/decision.types.d.ts +33 -4
  145. package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
  146. package/dist/backend/backend/src/types/v2/index.d.ts +2 -0
  147. package/dist/backend/backend/src/types/v2/index.d.ts.map +1 -1
  148. package/dist/backend/backend/src/types/v2/index.js +1 -0
  149. package/dist/backend/backend/src/types/v2/index.js.map +1 -1
  150. package/dist/backend/backend/src/types/v2/open-item.types.d.ts +76 -0
  151. package/dist/backend/backend/src/types/v2/open-item.types.d.ts.map +1 -0
  152. package/dist/backend/backend/src/types/v2/open-item.types.js +14 -0
  153. package/dist/backend/backend/src/types/v2/open-item.types.js.map +1 -0
  154. package/dist/backend/backend/src/types/v2/request.types.d.ts +22 -1
  155. package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
  156. package/dist/backend/backend/src/types/v2/request.types.js +10 -5
  157. package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
  158. package/dist/backend/build-info.json +2 -2
  159. package/dist/backend/config/constants.d.ts +73 -0
  160. package/dist/backend/config/constants.d.ts.map +1 -1
  161. package/dist/backend/config/constants.js +80 -0
  162. package/dist/backend/config/constants.js.map +1 -1
  163. package/dist/backend/config/index.d.ts +10 -2
  164. package/dist/backend/config/index.d.ts.map +1 -1
  165. package/dist/backend/config/index.js +2 -2
  166. package/dist/backend/config/index.js.map +1 -1
  167. package/dist/cli/backend/src/constants.d.ts +84 -0
  168. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  169. package/dist/cli/backend/src/constants.js +79 -1
  170. package/dist/cli/backend/src/constants.js.map +1 -1
  171. package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  172. package/dist/cli/backend/src/services/agent/agent-registration.service.js +4 -2
  173. package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
  174. package/dist/cli/backend/src/services/ai/prompt-modules/standing-answers.module.d.ts +3 -1
  175. package/dist/cli/backend/src/services/ai/prompt-modules/standing-answers.module.d.ts.map +1 -1
  176. package/dist/cli/backend/src/services/ai/prompt-modules/standing-answers.module.js +15 -4
  177. package/dist/cli/backend/src/services/ai/prompt-modules/standing-answers.module.js.map +1 -1
  178. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +9 -0
  179. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  180. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +11 -0
  181. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  182. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts +10 -0
  183. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
  184. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js +20 -0
  185. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
  186. package/dist/cli/backend/src/services/decisions/decision-card.d.ts +31 -4
  187. package/dist/cli/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  188. package/dist/cli/backend/src/services/decisions/decision-card.js +60 -2
  189. package/dist/cli/backend/src/services/decisions/decision-card.js.map +1 -1
  190. package/dist/cli/backend/src/services/decisions/decision.service.d.ts +113 -3
  191. package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  192. package/dist/cli/backend/src/services/decisions/decision.service.js +199 -13
  193. package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
  194. package/dist/cli/backend/src/services/memory/agent-memory.service.d.ts +19 -0
  195. package/dist/cli/backend/src/services/memory/agent-memory.service.d.ts.map +1 -1
  196. package/dist/cli/backend/src/services/memory/agent-memory.service.js +48 -0
  197. package/dist/cli/backend/src/services/memory/agent-memory.service.js.map +1 -1
  198. package/dist/cli/backend/src/services/memory/memory.service.d.ts.map +1 -1
  199. package/dist/cli/backend/src/services/memory/memory.service.js +20 -0
  200. package/dist/cli/backend/src/services/memory/memory.service.js.map +1 -1
  201. package/dist/cli/backend/src/services/memory/project-memory.service.d.ts +40 -0
  202. package/dist/cli/backend/src/services/memory/project-memory.service.d.ts.map +1 -1
  203. package/dist/cli/backend/src/services/memory/project-memory.service.js +101 -0
  204. package/dist/cli/backend/src/services/memory/project-memory.service.js.map +1 -1
  205. package/dist/cli/backend/src/services/memory/role-knowledge-eligibility.d.ts +1 -1
  206. package/dist/cli/backend/src/services/memory/role-knowledge-eligibility.d.ts.map +1 -1
  207. package/dist/cli/backend/src/services/memory/role-knowledge-eligibility.js.map +1 -1
  208. package/dist/cli/backend/src/services/memory/standing-answers.service.d.ts +61 -1
  209. package/dist/cli/backend/src/services/memory/standing-answers.service.d.ts.map +1 -1
  210. package/dist/cli/backend/src/services/memory/standing-answers.service.js +71 -3
  211. package/dist/cli/backend/src/services/memory/standing-answers.service.js.map +1 -1
  212. package/dist/cli/backend/src/services/memory/task-log-filter.d.ts +27 -0
  213. package/dist/cli/backend/src/services/memory/task-log-filter.d.ts.map +1 -0
  214. package/dist/cli/backend/src/services/memory/task-log-filter.js +56 -0
  215. package/dist/cli/backend/src/services/memory/task-log-filter.js.map +1 -0
  216. package/dist/cli/backend/src/services/open-items/open-item-card.d.ts +66 -0
  217. package/dist/cli/backend/src/services/open-items/open-item-card.d.ts.map +1 -0
  218. package/dist/cli/backend/src/services/open-items/open-item-card.js +208 -0
  219. package/dist/cli/backend/src/services/open-items/open-item-card.js.map +1 -0
  220. package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts +114 -0
  221. package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -0
  222. package/dist/cli/backend/src/services/open-items/open-item-extractor.js +394 -0
  223. package/dist/cli/backend/src/services/open-items/open-item-extractor.js.map +1 -0
  224. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +13 -0
  225. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  226. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +34 -11
  227. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  228. package/dist/cli/backend/src/services/v3/request.service.d.ts.map +1 -1
  229. package/dist/cli/backend/src/services/v3/request.service.js +15 -1
  230. package/dist/cli/backend/src/services/v3/request.service.js.map +1 -1
  231. package/dist/cli/backend/src/services/v3/ticket-review.service.d.ts.map +1 -1
  232. package/dist/cli/backend/src/services/v3/ticket-review.service.js +5 -1
  233. package/dist/cli/backend/src/services/v3/ticket-review.service.js.map +1 -1
  234. package/dist/cli/backend/src/types/decision.types.d.ts +33 -4
  235. package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -1
  236. package/dist/cli/backend/src/types/v2/index.d.ts +2 -0
  237. package/dist/cli/backend/src/types/v2/index.d.ts.map +1 -1
  238. package/dist/cli/backend/src/types/v2/index.js +1 -0
  239. package/dist/cli/backend/src/types/v2/index.js.map +1 -1
  240. package/dist/cli/backend/src/types/v2/open-item.types.d.ts +76 -0
  241. package/dist/cli/backend/src/types/v2/open-item.types.d.ts.map +1 -0
  242. package/dist/cli/backend/src/types/v2/open-item.types.js +14 -0
  243. package/dist/cli/backend/src/types/v2/open-item.types.js.map +1 -0
  244. package/dist/cli/backend/src/types/v2/request.types.d.ts +22 -1
  245. package/dist/cli/backend/src/types/v2/request.types.d.ts.map +1 -1
  246. package/dist/cli/backend/src/types/v2/request.types.js +10 -5
  247. package/dist/cli/backend/src/types/v2/request.types.js.map +1 -1
  248. package/dist/cli/config/constants.d.ts +73 -0
  249. package/dist/cli/config/constants.d.ts.map +1 -1
  250. package/dist/cli/config/constants.js +80 -0
  251. package/dist/cli/config/constants.js.map +1 -1
  252. package/dist/cli/config/index.d.ts +10 -2
  253. package/dist/cli/config/index.d.ts.map +1 -1
  254. package/dist/cli/config/index.js +2 -2
  255. package/dist/cli/config/index.js.map +1 -1
  256. package/frontend/dist/assets/{index-9c91de50.js → index-1ee8a9a2.js} +273 -273
  257. package/frontend/dist/assets/index-a79c7852.css +42 -0
  258. package/frontend/dist/index.html +2 -2
  259. package/package.json +2 -1
  260. package/frontend/dist/assets/index-04d192f8.css +0 -42
@@ -0,0 +1,880 @@
1
+ /**
2
+ * Open items — Crewly tracks what agents promise and ask the owner in their
3
+ * replies, so nothing is silently dropped (specs/2026-10-01-reply-open-items.md).
4
+ *
5
+ * Incident (TKT-185, 2026-10-01): in #book-publish Atlas told the owner "Kai is
6
+ * going through the four episodes… 明天中午给我,我核过以后挑最有用的几条发你"
7
+ * and asked "第 13 章「互评当体检用」这个读法,你同意吗?". The ticket was already
8
+ * closed. Kai finished at 18:14 and Atlas verified it, but nobody delivered
9
+ * it and Atlas went idle; the question was never tracked.
10
+ *
11
+ * Now, for every agent message in a ticket's conversation:
12
+ *
13
+ * - **Extract** commitments and owner questions (rules, {@link extractOpenItems}).
14
+ * - **Hold the ticket open**: items live on `Request.openItems`; while one is
15
+ * active the ticket is `awaiting_followup` (RequestService gates `done`).
16
+ * - **Commitments**: a follow-up WorkItem for the agent, due at the parsed
17
+ * time; the delegated child work is linked; when that work is finished
18
+ * before the agent delivers, the agent is woken at once ("the work you
19
+ * promised the owner is ready — deliver it now"); past the due time the
20
+ * agent is nudged once, then the owner is told in the thread what is late
21
+ * and why; the agent's next post in the thread once the work is ready
22
+ * marks it delivered.
23
+ * - **Questions**: a decision card in the same thread, asked as the agent,
24
+ * with options and a default derived from its words ({@link deriveQuestionCard});
25
+ * answers reach the agent through the decision flow. No second card when
26
+ * the agent already asked the same thing with ask-owner.
27
+ *
28
+ * Collaborators are injected ({@link OpenItemsDeps}); `open-items.wiring.ts`
29
+ * connects the real ones.
30
+ *
31
+ * @module services/open-items/open-items.service
32
+ */
33
+ import { OPEN_ITEMS_CONSTANTS } from '../../constants.js';
34
+ import { isInterim } from '../slack/slack-typing-placeholder.service.js';
35
+ import { LoggerService } from '../core/logger.service.js';
36
+ import { ACTIVE_OPEN_ITEM_STATUSES } from '../../types/v2/open-item.types.js';
37
+ import { formatTicketNumber } from '../../types/v2/ticket.types.js';
38
+ import { extractOpenItems } from './open-item-extractor.js';
39
+ import { deriveQuestionCard, questionSimilarity } from './open-item-card.js';
40
+ import { formatWhen } from '../decisions/decision-card.js';
41
+ /** Thrown for open-item requests the caller must fix (HTTP 4xx). */
42
+ export class OpenItemsError extends Error {
43
+ status;
44
+ constructor(status, message) {
45
+ super(message);
46
+ this.status = status;
47
+ this.name = 'OpenItemsError';
48
+ }
49
+ }
50
+ /** Statuses of a child WorkItem that count as finished work. */
51
+ const CHILD_DONE = new Set(['done', 'verified']);
52
+ /** Ticket statuses the service follows (`cancelled` closes items instead). */
53
+ const FOLLOWED = new Set(['open', 'ready', 'running', 'blocked', 'waiting_confirmation', 'awaiting_followup', 'done']);
54
+ // ---------------------------------------------------------------------------
55
+ // Pure helpers
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * The Slack thread of a request's conversation (`origin.threadRef` is
59
+ * `slack:<channel>:<ts>`).
60
+ *
61
+ * @param request - Request
62
+ * @returns Place, or null for chat-only conversations
63
+ */
64
+ export function slackPlaceOf(request) {
65
+ const ref = request.origin?.threadRef;
66
+ if (typeof ref !== 'string')
67
+ return null;
68
+ const m = /^slack:([CDG][A-Z0-9]+):(\d{6,}\.\d+)$/.exec(ref);
69
+ return m ? { slackChannelId: m[1], threadTs: m[2] } : null;
70
+ }
71
+ /**
72
+ * Whether a message is in the request's conversation.
73
+ *
74
+ * @param request - Ticket with a chatRef
75
+ * @param message - chat-v2 message
76
+ * @returns True for its thread (or its root)
77
+ */
78
+ export function inRequestThread(request, message) {
79
+ const ref = request.chatRef;
80
+ if (!ref || ref.channelId !== message.channelId)
81
+ return false;
82
+ const root = message.threadId ?? message.id;
83
+ return root === ref.threadRootId || root === ref.messageId;
84
+ }
85
+ /**
86
+ * Whether a WorkItem is bookkeeping (verify / follow-up) rather than work.
87
+ *
88
+ * @param wi - WorkItem
89
+ * @returns True to ignore it as child work
90
+ */
91
+ function isBookkeeping(wi) {
92
+ const meta = (wi.metadata ?? {});
93
+ return (wi.type === 'review' ||
94
+ wi.id.includes(':verify:') ||
95
+ typeof meta.verifyOf === 'string' ||
96
+ meta[OPEN_ITEMS_CONSTANTS.FOLLOW_UP_METADATA_KEY] !== undefined);
97
+ }
98
+ /**
99
+ * Whether a WorkItem was started for this request's conversation: linked to
100
+ * the request, or (1.20.183 origin chain) carrying its thread as its origin.
101
+ *
102
+ * @param wi - WorkItem
103
+ * @param request - Request
104
+ * @returns True when it belongs to the request
105
+ */
106
+ function belongsTo(wi, request) {
107
+ if (wi.requestId === request.id || request.workItemIds.includes(wi.id))
108
+ return true;
109
+ const origin = wi.metadata?.origin;
110
+ if (!origin || origin.kind !== 'owner')
111
+ return false;
112
+ if (request.chatRef && origin.chatThreadId === request.chatRef.threadRootId)
113
+ return true;
114
+ const place = slackPlaceOf(request);
115
+ return !!place && origin.slackChannelId === place.slackChannelId && origin.threadTs === place.threadTs;
116
+ }
117
+ /**
118
+ * The delegated child work a commitment waits on: WorkItems of the request
119
+ * given to someone else, started around the promise and not already finished
120
+ * before it.
121
+ *
122
+ * @param request - Request
123
+ * @param item - Commitment
124
+ * @param pool - Every WorkItem
125
+ * @returns Child WorkItem ids
126
+ */
127
+ export function childWorkFor(request, item, pool) {
128
+ const at = Date.parse(item.createdAt);
129
+ const from = at - OPEN_ITEMS_CONSTANTS.CHILD_LOOKBACK_MS;
130
+ const to = at + OPEN_ITEMS_CONSTANTS.CHILD_LOOKAHEAD_MS;
131
+ const out = new Set();
132
+ for (const wi of pool) {
133
+ if (isBookkeeping(wi) || !belongsTo(wi, request))
134
+ continue;
135
+ if (!wi.target || wi.target === item.agent)
136
+ continue;
137
+ if (wi.status === 'cancelled')
138
+ continue;
139
+ const created = Date.parse(wi.createdAt);
140
+ if (!(created >= from && created <= to))
141
+ continue;
142
+ const finished = wi.completedAt ? Date.parse(wi.completedAt) : NaN;
143
+ if (CHILD_DONE.has(wi.status) && Number.isFinite(finished) && finished < at)
144
+ continue;
145
+ out.add(wi.id);
146
+ }
147
+ return [...out];
148
+ }
149
+ /**
150
+ * Whether every child of a commitment is finished (cancelled ones ignored).
151
+ *
152
+ * @param ids - Child ids
153
+ * @param pool - Every WorkItem
154
+ * @returns `ready` with the latest finish time, or not ready (with what is left)
155
+ */
156
+ export function childrenState(ids, pool) {
157
+ const byId = new Map(pool.map((w) => [w.id, w]));
158
+ const pending = [];
159
+ const done = [];
160
+ let latest = 0;
161
+ for (const id of ids) {
162
+ const wi = byId.get(id);
163
+ if (!wi || wi.status === 'cancelled')
164
+ continue;
165
+ if (CHILD_DONE.has(wi.status)) {
166
+ done.push(wi);
167
+ latest = Math.max(latest, Date.parse(wi.completedAt ?? '') || 0);
168
+ }
169
+ else {
170
+ pending.push(wi);
171
+ }
172
+ }
173
+ const ready = done.length > 0 && pending.length === 0;
174
+ return { ready, ...(ready && latest > 0 ? { finishedAt: new Date(latest).toISOString() } : {}), pending, done };
175
+ }
176
+ /**
177
+ * Whether two items are the same thing said once: same message, or the same
178
+ * words by the same agent within {@link OPEN_ITEMS_CONSTANTS.DUPLICATE_WINDOW_MS}.
179
+ *
180
+ * @param a - Item
181
+ * @param b - Item
182
+ * @returns True for a duplicate
183
+ */
184
+ export function isSameItem(a, b) {
185
+ if (a.text !== b.text)
186
+ return false;
187
+ if (a.sourceMessageId === b.sourceMessageId)
188
+ return true;
189
+ return a.agent === b.agent && Math.abs(Date.parse(a.createdAt) - Date.parse(b.createdAt)) <= OPEN_ITEMS_CONSTANTS.DUPLICATE_WINDOW_MS;
190
+ }
191
+ /**
192
+ * Clip for a one-line mention.
193
+ *
194
+ * @param s - Text
195
+ * @param max - Max characters
196
+ * @returns Clipped
197
+ */
198
+ function short(s, max = 120) {
199
+ const flat = s.replace(/\s+/g, ' ').trim();
200
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
201
+ }
202
+ /**
203
+ * `--thread <channel>:<ts>` hint for the agent, when the conversation is in Slack.
204
+ *
205
+ * @param request - Request
206
+ * @returns Hint text (leading space) or empty
207
+ */
208
+ function threadHint(request) {
209
+ const place = slackPlaceOf(request);
210
+ return place ? ` (--thread ${place.slackChannelId}:${place.threadTs})` : '';
211
+ }
212
+ /**
213
+ * The ticket's display id.
214
+ *
215
+ * @param request - Request
216
+ * @returns `TKT-185`, else the id prefix
217
+ */
218
+ function ticketLabel(request) {
219
+ return typeof request.ticketNumber === 'number' ? formatTicketNumber(request.ticketNumber) : request.id.slice(0, 8);
220
+ }
221
+ // ---------------------------------------------------------------------------
222
+ // Service
223
+ // ---------------------------------------------------------------------------
224
+ /**
225
+ * Tracks commitments and questions on Requests. See module docs.
226
+ */
227
+ export class OpenItemsService {
228
+ deps;
229
+ static instance = null;
230
+ logger;
231
+ now;
232
+ chain = Promise.resolve();
233
+ timer = null;
234
+ /**
235
+ * @param deps - Collaborators
236
+ */
237
+ constructor(deps) {
238
+ this.deps = deps;
239
+ this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('OpenItems');
240
+ this.now = deps.now ?? (() => new Date());
241
+ }
242
+ /** @returns The process-wide instance, or null before wiring */
243
+ static getInstance() {
244
+ return OpenItemsService.instance;
245
+ }
246
+ /** @param service - Instance to install (null clears) */
247
+ static setInstance(service) {
248
+ OpenItemsService.instance = service;
249
+ }
250
+ /** Start the periodic sweep. */
251
+ start() {
252
+ if (this.timer)
253
+ return;
254
+ this.timer = setInterval(() => {
255
+ void this.sweep().catch((err) => this.logger.warn('Open-items sweep failed', { error: errText(err) }));
256
+ }, OPEN_ITEMS_CONSTANTS.SWEEP_INTERVAL_MS);
257
+ this.timer.unref?.();
258
+ }
259
+ /** Stop the sweep. */
260
+ stop() {
261
+ if (this.timer)
262
+ clearInterval(this.timer);
263
+ this.timer = null;
264
+ }
265
+ /**
266
+ * One change at a time (a chat message, a WorkItem event and the sweep may
267
+ * touch the same request).
268
+ */
269
+ serial(fn) {
270
+ const run = this.chain.then(fn);
271
+ this.chain = run.catch(() => undefined);
272
+ return run;
273
+ }
274
+ // -------------------------------------------------------------------------
275
+ // Agent replies
276
+ // -------------------------------------------------------------------------
277
+ /**
278
+ * The ticket an agent message belongs to: the one whose thread it is in
279
+ * (any status but cancelled, closed within the lookback), else — for a
280
+ * top-level message — the newest open ticket in that channel the agent is
281
+ * answering.
282
+ *
283
+ * @param message - chat-v2 message
284
+ * @param all - Every request
285
+ * @returns The request, or null
286
+ */
287
+ findRequestFor(message, all) {
288
+ const since = this.now().getTime() - OPEN_ITEMS_CONSTANTS.LOOKBACK_MS;
289
+ const at = message.createdAt ?? this.now().getTime();
290
+ const tickets = all
291
+ .filter((r) => typeof r.ticketNumber === 'number' && !!r.chatRef && FOLLOWED.has(r.status) && Date.parse(r.updatedAt) >= since)
292
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
293
+ // Several tickets can share one thread (follow-up asks): the message
294
+ // belongs to the newest one opened before it — the open one first.
295
+ const sharing = tickets.filter((r) => inRequestThread(r, message));
296
+ const openedBefore = sharing.filter((r) => Date.parse(r.createdAt) <= at);
297
+ const inThread = openedBefore.length > 0 ? openedBefore : sharing;
298
+ if (inThread.length > 0)
299
+ return inThread.find((r) => r.status !== 'done') ?? inThread[0];
300
+ if (message.threadId)
301
+ return null;
302
+ return (tickets.find((r) => r.chatRef?.channelId === message.channelId &&
303
+ r.status !== 'done' &&
304
+ (r.assignee === message.senderId || r.reply?.by === message.senderId)) ?? null);
305
+ }
306
+ /**
307
+ * An agent posted in chat-v2: mark what it delivered, and track what it
308
+ * newly promised or asked.
309
+ *
310
+ * @param message - The message
311
+ * @returns The request it changed, or null
312
+ */
313
+ async onAgentMessage(message) {
314
+ if (message.senderType !== 'agent' || !message.content?.trim())
315
+ return null;
316
+ // "Got it — on it" placeholders are not the reply.
317
+ if (isInterim(message))
318
+ return null;
319
+ return this.serial(async () => {
320
+ const all = await this.deps.requests.listAll();
321
+ const request = this.findRequestFor(message, all);
322
+ if (!request)
323
+ return null;
324
+ const at = new Date(message.createdAt ?? this.now().getTime());
325
+ const pool = await this.deps.listWorkItems().catch(() => []);
326
+ const planned = await this.plan(request, message, at);
327
+ let items = [...(request.openItems ?? [])];
328
+ let changed = false;
329
+ // Deliveries first: this post may be what an earlier promise was waiting for.
330
+ const delivered = this.deliveries(items, message, at, pool);
331
+ for (const d of delivered) {
332
+ items = items.map((i) => (i.id === d.id ? d : i));
333
+ changed = true;
334
+ if (d.workItemId)
335
+ await this.closeFollowUp(d.workItemId, 'delivered', `Delivered to the owner (message ${message.id})`);
336
+ this.logger.info('Commitment delivered', { tkt: ticketLabel(request), item: d.id, by: message.senderId });
337
+ }
338
+ // The same words twice (a reply recorded both from the reply path and the
339
+ // Slack mirror) are one item.
340
+ const fresh = planned.filter((p) => !items.some((i) => isSameItem(i, p.item)));
341
+ for (const p of fresh) {
342
+ const item = await this.activate(request, p, pool);
343
+ items.push(item);
344
+ changed = true;
345
+ }
346
+ if (!changed)
347
+ return null;
348
+ return this.save(request, items, fresh.length > 0);
349
+ });
350
+ }
351
+ /**
352
+ * What a message would add, without side effects (also the backfill's view).
353
+ *
354
+ * @param request - Its request
355
+ * @param message - The agent message
356
+ * @param at - When it was posted
357
+ * @returns Planned items
358
+ */
359
+ async plan(request, message, at) {
360
+ const names = await this.deps.colleagueNames?.(message.senderId).catch(() => []);
361
+ const owner = this.deps.ownerSlackUserId?.() ?? (request.origin?.author?.startsWith('U') ? request.origin.author : null);
362
+ const found = extractOpenItems(message.content, {
363
+ now: at,
364
+ ...(names ? { colleagueNames: names } : {}),
365
+ ...(owner ? { ownerSlackUserId: owner } : {}),
366
+ });
367
+ const base = (type, n) => ({
368
+ id: `${type === 'commitment' ? 'c' : 'q'}-${message.id.slice(0, 8)}-${n}`,
369
+ agent: message.senderId,
370
+ sourceMessageId: message.id,
371
+ createdAt: at.toISOString(),
372
+ status: 'open',
373
+ });
374
+ const out = [];
375
+ found.commitments.forEach((c, i) => {
376
+ out.push({ item: { ...base('commitment', i + 1), type: 'commitment', text: c.text, due: c.due.toISOString(), dueSource: c.dueSource } });
377
+ });
378
+ let qi = 0;
379
+ for (const q of found.questions) {
380
+ qi += 1;
381
+ const item = { ...base('question', qi), type: 'question', text: q.text };
382
+ const skipped = await this.deps.skippedQuestion?.(request.id, message.senderId, q.text).catch(() => null);
383
+ if (skipped) {
384
+ out.push({ item, skippedDecisionId: skipped.id });
385
+ continue;
386
+ }
387
+ const asked = await this.alreadyAsked(message.senderId, q, at);
388
+ out.push(asked ? { item, linkedDecisionId: asked.id } : { item, card: deriveQuestionCard(q) });
389
+ }
390
+ return out;
391
+ }
392
+ /**
393
+ * The ask-owner decision the agent already made for this question, if any.
394
+ *
395
+ * @param agent - Asking agent
396
+ * @param q - The question
397
+ * @param at - When it was asked in the reply
398
+ * @returns The decision, or null
399
+ */
400
+ async alreadyAsked(agent, q, at) {
401
+ if (!this.deps.recentDecisionsBy)
402
+ return null;
403
+ const win = OPEN_ITEMS_CONSTANTS.ASK_OWNER_DEDUPE_WINDOW_MS;
404
+ const recent = await this.deps.recentDecisionsBy(agent, at.getTime() - win).catch(() => []);
405
+ return (recent.find((d) => d.kind !== 'reply_question' &&
406
+ Math.abs(Date.parse(d.createdAt) - at.getTime()) <= win &&
407
+ questionSimilarity(d.question, q.text) >= OPEN_ITEMS_CONSTANTS.SAME_QUESTION_SIMILARITY) ?? null);
408
+ }
409
+ /**
410
+ * Turn a planned item into a tracked one: link child work and queue the
411
+ * follow-up (commitment), or post / link the card (question).
412
+ *
413
+ * @param request - Its request
414
+ * @param p - Planned item
415
+ * @param pool - Every WorkItem
416
+ * @returns The stored item
417
+ */
418
+ async activate(request, p, pool, source = 'live') {
419
+ const place = slackPlaceOf(request);
420
+ let item = { ...p.item };
421
+ if (p.skippedDecisionId) {
422
+ this.logger.info('Question the owner already skipped — not asked again', { tkt: ticketLabel(request), decisionId: p.skippedDecisionId });
423
+ return { ...item, status: 'skipped', decisionId: p.skippedDecisionId, closedAt: this.now().toISOString(), closedReason: `owner skipped this before (${p.skippedDecisionId})` };
424
+ }
425
+ if (item.type === 'commitment') {
426
+ const children = childWorkFor(request, item, pool);
427
+ if (children.length > 0)
428
+ item.childWorkItemIds = children;
429
+ const wiId = await this.deps.createFollowUp?.({ request, item, place }).catch((err) => {
430
+ this.logger.warn('Follow-up WorkItem not created', { tkt: ticketLabel(request), error: errText(err) });
431
+ return null;
432
+ });
433
+ if (wiId)
434
+ item.workItemId = wiId;
435
+ this.logger.info('Commitment tracked', { tkt: ticketLabel(request), item: item.id, agent: item.agent, due: item.due, children: children.length });
436
+ // The child work may already be finished (the promise came late).
437
+ const state = childrenState(item.childWorkItemIds ?? [], pool);
438
+ if (state.ready)
439
+ item = await this.markReady(request, item, state.finishedAt, state.done);
440
+ return item;
441
+ }
442
+ if (p.linkedDecisionId) {
443
+ item.decisionId = p.linkedDecisionId;
444
+ this.logger.info('Question already asked with ask-owner — no second card', { tkt: ticketLabel(request), decisionId: p.linkedDecisionId });
445
+ return item;
446
+ }
447
+ if (p.card && this.deps.askQuestion) {
448
+ const d = await this.deps.askQuestion({ request, item, card: p.card, place, source }).catch((err) => {
449
+ this.logger.warn('Question card not posted', { tkt: ticketLabel(request), error: errText(err) });
450
+ return null;
451
+ });
452
+ if (d)
453
+ item.decisionId = d.id;
454
+ }
455
+ this.logger.info('Question tracked', { tkt: ticketLabel(request), item: item.id, agent: item.agent, decisionId: item.decisionId });
456
+ return item;
457
+ }
458
+ /**
459
+ * Commitments this message delivers.
460
+ *
461
+ * @param items - Current items
462
+ * @param message - The post
463
+ * @param at - Its time
464
+ * @param pool - Every WorkItem
465
+ * @returns Updated (delivered) items
466
+ */
467
+ deliveries(items, message, at, pool) {
468
+ const out = [];
469
+ for (const item of items) {
470
+ if (item.type !== 'commitment' || !ACTIVE_OPEN_ITEM_STATUSES.has(item.status))
471
+ continue;
472
+ if (!this.deliveredBy(item, { ...message, createdAt: at.getTime() }, pool))
473
+ continue;
474
+ out.push({ ...item, status: 'delivered', closedAt: at.toISOString(), closedReason: `posted in the thread (message ${message.id})` });
475
+ }
476
+ return out;
477
+ }
478
+ /**
479
+ * Whether a post in the thread delivers a commitment: by the promising
480
+ * agent (or one doing the child work), once the child work is finished.
481
+ * With no child work: a later post by the promising agent, at least
482
+ * {@link OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_GAP_MS} after the promise, that
483
+ * does not itself promise something new.
484
+ *
485
+ * @param item - Commitment
486
+ * @param message - The post (`createdAt` set)
487
+ * @param pool - Every WorkItem
488
+ * @returns True when it is the delivery
489
+ */
490
+ deliveredBy(item, message, pool) {
491
+ if (item.type !== 'commitment' || item.sourceMessageId === message.id)
492
+ return false;
493
+ if (message.senderType !== 'agent' || isInterim(message))
494
+ return false;
495
+ const at = message.createdAt ?? this.now().getTime();
496
+ if (at <= Date.parse(item.createdAt))
497
+ return false;
498
+ const children = item.childWorkItemIds ?? [];
499
+ if (children.length > 0) {
500
+ const childTargets = new Set(pool.filter((w) => children.includes(w.id)).map((w) => w.target));
501
+ if (message.senderId !== item.agent && !childTargets.has(message.senderId))
502
+ return false;
503
+ const state = childrenState(children, pool);
504
+ if (!state.ready)
505
+ return false;
506
+ // Posted after the work was finished.
507
+ return !state.finishedAt || at >= Date.parse(state.finishedAt);
508
+ }
509
+ if (message.senderId !== item.agent)
510
+ return false;
511
+ if (at - Date.parse(item.createdAt) < OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_GAP_MS)
512
+ return false;
513
+ return extractOpenItems(message.content, { now: new Date(at) }).commitments.length === 0;
514
+ }
515
+ /**
516
+ * Take planned items onto a request (the backfill's apply step).
517
+ *
518
+ * @param requestId - Request
519
+ * @param planned - Items from {@link plan}
520
+ * @param opts - `source: 'backfill'` marks the cards it posts as backfilled
521
+ * @returns Updated request, or null when it is gone
522
+ */
523
+ async adopt(requestId, planned, opts = {}) {
524
+ return this.serial(async () => {
525
+ const request = await this.deps.requests.getById(requestId);
526
+ if (!request)
527
+ return null;
528
+ const pool = await this.deps.listWorkItems().catch(() => []);
529
+ const items = [...(request.openItems ?? [])];
530
+ for (const p of planned) {
531
+ if (items.some((i) => i.id === p.item.id))
532
+ continue;
533
+ items.push(await this.activate(request, p, pool, opts.source ?? 'live'));
534
+ }
535
+ return this.save(request, items, true);
536
+ });
537
+ }
538
+ /**
539
+ * Store items, and move the request between `done` and `awaiting_followup`.
540
+ *
541
+ * @param request - Request as read
542
+ * @param items - New item list
543
+ * @param added - New active items were added
544
+ * @returns Updated request
545
+ */
546
+ async save(request, items, added) {
547
+ const active = items.some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status));
548
+ if (active && request.status === 'done' && added) {
549
+ this.logger.info('Closed ticket reopened: its agent left open items in the thread', { tkt: ticketLabel(request) });
550
+ return this.deps.requests.update(request.id, { openItems: items, status: 'awaiting_followup', reopenForFollowup: true });
551
+ }
552
+ if (!active && request.status === 'awaiting_followup') {
553
+ this.logger.info('Every open item closed — ticket done', { tkt: ticketLabel(request) });
554
+ return this.deps.requests.update(request.id, { openItems: items, status: 'done', accepted: true, ignoreDeadChildren: true });
555
+ }
556
+ return this.deps.requests.update(request.id, { openItems: items });
557
+ }
558
+ // -------------------------------------------------------------------------
559
+ // Child work
560
+ // -------------------------------------------------------------------------
561
+ /**
562
+ * A WorkItem finished (task:done / task:verified): wake whoever promised
563
+ * the owner the result, if that was the last piece.
564
+ *
565
+ * @param workItemId - The WorkItem
566
+ * @returns Items that became ready
567
+ */
568
+ async onWorkItemSettled(workItemId) {
569
+ return this.serial(async () => {
570
+ const woken = [];
571
+ const all = await this.deps.requests.listAll();
572
+ const affected = all.filter((r) => (r.openItems ?? []).some((i) => i.type === 'commitment' && i.status === 'open' && (i.childWorkItemIds ?? []).includes(workItemId)));
573
+ if (affected.length === 0)
574
+ return woken;
575
+ const pool = await this.deps.listWorkItems();
576
+ for (const request of affected) {
577
+ let changed = false;
578
+ const items = [];
579
+ for (const item of request.openItems ?? []) {
580
+ if (item.type === 'commitment' && item.status === 'open' && (item.childWorkItemIds ?? []).includes(workItemId)) {
581
+ const state = childrenState(item.childWorkItemIds ?? [], pool);
582
+ if (state.ready) {
583
+ const ready = await this.markReady(request, item, state.finishedAt, state.done);
584
+ items.push(ready);
585
+ woken.push(ready);
586
+ changed = true;
587
+ continue;
588
+ }
589
+ }
590
+ items.push(item);
591
+ }
592
+ if (changed)
593
+ await this.deps.requests.update(request.id, { openItems: items });
594
+ }
595
+ return woken;
596
+ });
597
+ }
598
+ /**
599
+ * The child work is finished: wake the agent to deliver.
600
+ *
601
+ * @param request - Request
602
+ * @param item - Commitment
603
+ * @param finishedAt - When the last child finished
604
+ * @param done - Finished children
605
+ * @returns Updated item (`ready`)
606
+ */
607
+ async markReady(request, item, finishedAt, done) {
608
+ const now = this.now();
609
+ const what = done.map((w) => `"${short(w.title, 80)}"`).join(', ');
610
+ const text = `[FOLLOW-UP ${ticketLabel(request)}] The work you promised the owner is ready (${what}) — deliver it now. ` +
611
+ `You said: "${short(item.text, 200)}". Post it in the same thread${threadHint(request)}; that closes the follow-up.`;
612
+ const ok = await this.deps.deliverToAgent(item.agent, text).catch(() => false);
613
+ this.logger.info('Promised work is ready — agent woken to deliver', { tkt: ticketLabel(request), item: item.id, agent: item.agent, delivered: ok });
614
+ return { ...item, status: 'ready', readyAt: finishedAt ?? now.toISOString(), ...(ok ? { wokeAt: now.toISOString() } : {}) };
615
+ }
616
+ // -------------------------------------------------------------------------
617
+ // Decisions
618
+ // -------------------------------------------------------------------------
619
+ /**
620
+ * A `reply_question` decision settled: close its open item and give the
621
+ * agent the answer (the generic decision note).
622
+ *
623
+ * @param d - The decision
624
+ * @param fallback - The generic note
625
+ * @returns The note for the agent (null when withdrawn)
626
+ */
627
+ async onDecisionSettled(d, fallback) {
628
+ const ref = d.requestRef;
629
+ if (!ref)
630
+ return fallback ?? null;
631
+ // Not awaited: the handler can run inside this service's own chain (the
632
+ // sweep withdrawing a card), and the note does not depend on the write.
633
+ void this.serial(async () => {
634
+ const request = await this.deps.requests.getById(ref.requestId);
635
+ if (!request)
636
+ return;
637
+ const now = this.now().toISOString();
638
+ const status = itemStatusFor(d);
639
+ if (status === 'open')
640
+ return; // parked: still waiting on the owner
641
+ const answer = d.chosenKey ? d.options.find((o) => o.key === d.chosenKey)?.label : d.answerText;
642
+ const items = (request.openItems ?? []).map((i) => i.id === ref.itemId && ACTIVE_OPEN_ITEM_STATUSES.has(i.status)
643
+ ? { ...i, status, closedAt: now, closedReason: `${d.id} ${d.status}`, ...(answer ? { answer } : {}) }
644
+ : i);
645
+ await this.save(request, items, false);
646
+ }).catch((err) => this.logger.warn('Answered question not recorded', { decisionId: d.id, error: errText(err) }));
647
+ if (d.status === 'cancelled')
648
+ return null;
649
+ const reply = d.chosenKey && d.options.find((o) => o.key === d.chosenKey)?.label === OPEN_ITEMS_CONSTANTS.REPLY_LABEL;
650
+ if (reply) {
651
+ return `[DECISION ${d.id}] The owner will answer "${d.question}" in words in the thread. Wait for their message there, then act on it.`;
652
+ }
653
+ return fallback ?? null;
654
+ }
655
+ // -------------------------------------------------------------------------
656
+ // Owner skips
657
+ // -------------------------------------------------------------------------
658
+ /**
659
+ * The owner skipped an open item ("I don't care about this anymore"). A
660
+ * promise is closed, its follow-up WorkItem cancelled and the agent told
661
+ * once to drop it; a question's card is skipped (which closes the item and
662
+ * tells the agent). The request completes when nothing else is open.
663
+ *
664
+ * @param requestId - Request
665
+ * @param itemId - Open item
666
+ * @returns The closed item
667
+ * @throws OpenItemsError(404 / 409)
668
+ */
669
+ async skipItem(requestId, itemId) {
670
+ const request = await this.deps.requests.getById(requestId);
671
+ if (!request)
672
+ throw new OpenItemsError(404, `Request ${requestId} not found`);
673
+ const item = (request.openItems ?? []).find((i) => i.id === itemId);
674
+ if (!item)
675
+ throw new OpenItemsError(404, `Open item ${itemId} not found on ${ticketLabel(request)}`);
676
+ if (!ACTIVE_OPEN_ITEM_STATUSES.has(item.status))
677
+ throw new OpenItemsError(409, `Open item ${itemId} is already ${item.status}`);
678
+ // A question with a pending card: skip the card; its settle handler closes the item.
679
+ if (item.type === 'question' && item.decisionId && this.deps.skipQuestion) {
680
+ await this.deps.skipQuestion(item.decisionId).catch(() => false);
681
+ }
682
+ let note = null;
683
+ const closed = await this.serial(async () => {
684
+ const fresh = await this.deps.requests.getById(requestId);
685
+ const cur = fresh?.openItems?.find((i) => i.id === itemId);
686
+ if (!fresh || !cur)
687
+ throw new OpenItemsError(404, `Open item ${itemId} not found`);
688
+ if (!ACTIVE_OPEN_ITEM_STATUSES.has(cur.status))
689
+ return cur;
690
+ const next = { ...cur, status: 'skipped', closedAt: this.now().toISOString(), closedReason: 'skipped by the owner' };
691
+ if (cur.type === 'commitment') {
692
+ if (cur.workItemId)
693
+ await this.closeFollowUp(cur.workItemId, 'cancelled', 'The owner skipped this promise');
694
+ note =
695
+ `[FOLLOW-UP ${ticketLabel(fresh)}] The owner skipped this — drop it, don't ask again. ` +
696
+ `You had promised: "${short(cur.text, 200)}". Don't deliver it unless the owner asks again.`;
697
+ }
698
+ else {
699
+ note = `[FOLLOW-UP ${ticketLabel(fresh)}] The owner skipped this — drop it, don't ask again: "${short(cur.text, 200)}".`;
700
+ }
701
+ const items = (fresh.openItems ?? []).map((i) => (i.id === itemId ? next : i));
702
+ await this.save(fresh, items, false);
703
+ return next;
704
+ });
705
+ if (note)
706
+ await this.deps.deliverToAgent(closed.agent, note).catch(() => false);
707
+ this.logger.info('Open item skipped by the owner', { tkt: ticketLabel(request), item: itemId, type: closed.type });
708
+ return closed;
709
+ }
710
+ // -------------------------------------------------------------------------
711
+ // Sweep
712
+ // -------------------------------------------------------------------------
713
+ /**
714
+ * Periodic pass: link late child work, catch missed readiness, nudge
715
+ * overdue promises once and then tell the owner, sync answered questions,
716
+ * expire stale items, close cancelled tickets' items.
717
+ *
718
+ * @returns Counts of what happened
719
+ */
720
+ async sweep() {
721
+ return this.serial(async () => {
722
+ const counts = { ready: 0, nudged: 0, ownerNotes: 0, expired: 0, closed: 0 };
723
+ const now = this.now();
724
+ const all = await this.deps.requests.listAll();
725
+ const tracked = all.filter((r) => r.status === 'awaiting_followup' || (r.openItems ?? []).some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status)));
726
+ if (tracked.length === 0)
727
+ return counts;
728
+ const pool = await this.deps.listWorkItems().catch(() => []);
729
+ for (const request of tracked) {
730
+ try {
731
+ const before = JSON.stringify(request.openItems);
732
+ const items = [];
733
+ for (const item of request.openItems ?? [])
734
+ items.push(await this.sweepItem(request, item, now, pool, counts));
735
+ const changed = JSON.stringify(items) !== before;
736
+ const allClosed = !items.some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status));
737
+ if (changed || (request.status === 'awaiting_followup' && allClosed))
738
+ await this.save(request, items, false);
739
+ }
740
+ catch (err) {
741
+ this.logger.warn('Open-items sweep step failed', { id: request.id, error: errText(err) });
742
+ }
743
+ }
744
+ if (counts.ready + counts.nudged + counts.ownerNotes + counts.expired + counts.closed > 0) {
745
+ this.logger.info('Open-items sweep', counts);
746
+ }
747
+ return counts;
748
+ });
749
+ }
750
+ /**
751
+ * One item's sweep step.
752
+ */
753
+ async sweepItem(request, item, now, pool, counts) {
754
+ if (!ACTIVE_OPEN_ITEM_STATUSES.has(item.status))
755
+ return item;
756
+ const nowIso = now.toISOString();
757
+ if (request.status === 'cancelled') {
758
+ counts.closed += 1;
759
+ if (item.workItemId)
760
+ await this.closeFollowUp(item.workItemId, 'cancelled', 'The ticket was cancelled');
761
+ if (item.decisionId)
762
+ await this.deps.cancelQuestion?.(item.decisionId, 'ticket cancelled').catch(() => undefined);
763
+ return { ...item, status: 'cancelled', closedAt: nowIso, closedReason: 'ticket cancelled' };
764
+ }
765
+ if (now.getTime() - Date.parse(item.createdAt) >= OPEN_ITEMS_CONSTANTS.EXPIRE_AFTER_MS) {
766
+ counts.expired += 1;
767
+ if (item.workItemId)
768
+ await this.closeFollowUp(item.workItemId, 'cancelled', 'Expired: nothing happened for 7 days');
769
+ return { ...item, status: 'expired', closedAt: nowIso, closedReason: 'no activity for 7 days' };
770
+ }
771
+ if (item.type === 'question') {
772
+ if (!item.decisionId || !this.deps.getDecision)
773
+ return item;
774
+ const d = await this.deps.getDecision(item.decisionId).catch(() => null);
775
+ if (!d || d.status === 'open' || d.status === 'parked')
776
+ return item;
777
+ const status = itemStatusFor(d);
778
+ const answer = d.chosenKey ? d.options.find((o) => o.key === d.chosenKey)?.label : d.answerText;
779
+ counts.closed += 1;
780
+ return { ...item, status, closedAt: nowIso, closedReason: `${d.id} ${d.status}`, ...(answer ? { answer } : {}) };
781
+ }
782
+ // Commitment: link child work started after the promise.
783
+ let current = item;
784
+ const children = childWorkFor(request, item, pool);
785
+ if (children.some((id) => !(item.childWorkItemIds ?? []).includes(id))) {
786
+ current = { ...current, childWorkItemIds: [...new Set([...(item.childWorkItemIds ?? []), ...children])] };
787
+ }
788
+ if (current.status === 'open' && (current.childWorkItemIds ?? []).length > 0) {
789
+ const state = childrenState(current.childWorkItemIds ?? [], pool);
790
+ if (state.ready) {
791
+ counts.ready += 1;
792
+ return this.markReady(request, current, state.finishedAt, state.done);
793
+ }
794
+ }
795
+ const due = current.due ? Date.parse(current.due) : NaN;
796
+ if (!Number.isFinite(due) || now.getTime() < due)
797
+ return current;
798
+ if (!current.nudgedAt) {
799
+ const text = `[FOLLOW-UP ${ticketLabel(request)}] You promised the owner: "${short(current.text, 200)}" — due ${formatWhen(new Date(due), now)}, and it hasn't been delivered. ` +
800
+ `Deliver it now in the same thread${threadHint(request)}, or tell the owner plainly when it will come and why.`;
801
+ const ok = await this.deps.deliverToAgent(current.agent, text).catch(() => false);
802
+ counts.nudged += 1;
803
+ this.logger.info('Overdue promise — agent nudged', { tkt: ticketLabel(request), item: current.id, agent: current.agent, delivered: ok });
804
+ return { ...current, status: 'overdue', nudgedAt: nowIso };
805
+ }
806
+ if (!current.ownerNotifiedAt && now.getTime() - Date.parse(current.nudgedAt) >= OPEN_ITEMS_CONSTANTS.OWNER_NOTE_AFTER_NUDGE_MS) {
807
+ const note = await this.ownerNote(request, current, pool, now);
808
+ const ok = await this.deps.postOwnerNote(request, note).catch(() => false);
809
+ counts.ownerNotes += 1;
810
+ this.logger.info('Overdue promise — owner told in the thread', { tkt: ticketLabel(request), item: current.id, posted: ok });
811
+ return ok ? { ...current, status: 'overdue', ownerNotifiedAt: nowIso } : current;
812
+ }
813
+ return current;
814
+ }
815
+ /**
816
+ * The owner's note for a late promise: what, by when, and why it is late.
817
+ *
818
+ * @param request - Request
819
+ * @param item - Commitment
820
+ * @param pool - Every WorkItem
821
+ * @param now - Clock
822
+ * @returns Note text (English harness text; the agent's words quoted)
823
+ */
824
+ async ownerNote(request, item, pool, now) {
825
+ const name = (await this.deps.displayName?.(item.agent).catch(() => undefined)) ?? item.agent;
826
+ const due = item.due ? formatWhen(new Date(item.due), now) : 'today';
827
+ const state = childrenState(item.childWorkItemIds ?? [], pool);
828
+ let why;
829
+ if (state.pending.length > 0) {
830
+ const parts = [];
831
+ for (const w of state.pending.slice(0, 2)) {
832
+ const who = w.target ? ((await this.deps.displayName?.(w.target).catch(() => undefined)) ?? w.target) : 'nobody';
833
+ parts.push(`${who}'s part ("${short(w.title, 60)}") is still ${w.status.replace(/_/g, ' ')}`);
834
+ }
835
+ why = parts.join('; ');
836
+ }
837
+ else if (state.ready || item.readyAt) {
838
+ const at = item.readyAt ? formatWhen(new Date(item.readyAt), now) : 'earlier';
839
+ why = `the work was ready at ${at}, but ${name} hasn't posted it`;
840
+ }
841
+ else {
842
+ why = `${name} hasn't posted it yet`;
843
+ }
844
+ return `${name} promised "${short(item.text, 160)}" by ${due}. It hasn't arrived: ${why}. ${name} has been reminded.`;
845
+ }
846
+ async closeFollowUp(workItemId, outcome, reason) {
847
+ await this.deps.closeFollowUp?.(workItemId, outcome, reason).catch((err) => this.logger.debug('Follow-up WorkItem not closed', { workItemId, error: errText(err) }));
848
+ }
849
+ }
850
+ /**
851
+ * The open-item status a settled decision leaves its question in.
852
+ *
853
+ * @param d - Decision
854
+ * @returns Status (`open` while it is still pending)
855
+ */
856
+ export function itemStatusFor(d) {
857
+ switch (d.status) {
858
+ case 'resolved':
859
+ case 'defaulted':
860
+ return 'resolved';
861
+ case 'cancelled':
862
+ return 'superseded';
863
+ case 'expired':
864
+ return 'expired';
865
+ case 'skipped':
866
+ return 'skipped';
867
+ default:
868
+ return 'open';
869
+ }
870
+ }
871
+ /**
872
+ * Message of an unknown error.
873
+ *
874
+ * @param err - Thrown value
875
+ * @returns Text
876
+ */
877
+ function errText(err) {
878
+ return err instanceof Error ? err.message : String(err);
879
+ }
880
+ //# sourceMappingURL=open-items.service.js.map