crewly 1.20.182 → 1.20.184

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