crewly 1.20.188 → 1.20.190

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 (140) hide show
  1. package/config/skills/agent/core/attach-file/execute.sh +13 -4
  2. package/config/skills/agent/core/complete-task/execute.sh +3 -0
  3. package/config/skills/agent/core/reply/SKILL.md +32 -4
  4. package/config/skills/agent/core/reply/execute.sh +23 -1
  5. package/config/skills/agent/core/reply/execute.test.sh +9 -0
  6. package/config/skills/agent/core/reply-chat/SKILL.md +9 -2
  7. package/config/skills/agent/core/reply-chat/execute.sh +13 -1
  8. package/config/skills/agent/core/report-status/execute.sh +5 -0
  9. package/config/skills/agent/core/send-chat-response/execute.sh +1 -1
  10. package/dist/backend/backend/src/constants.d.ts +23 -0
  11. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  12. package/dist/backend/backend/src/constants.js +27 -1
  13. package/dist/backend/backend/src/constants.js.map +1 -1
  14. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts +14 -1
  15. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
  16. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +66 -78
  17. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
  18. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts +12 -0
  19. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
  20. package/dist/backend/backend/src/controllers/chat/chat.controller.js +148 -24
  21. package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
  22. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
  23. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +62 -0
  24. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
  25. package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
  26. package/dist/backend/backend/src/controllers/slack/slack.controller.js +120 -35
  27. package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
  28. package/dist/backend/backend/src/scripts/repair-orphan-superseded-decisions.d.ts +56 -0
  29. package/dist/backend/backend/src/scripts/repair-orphan-superseded-decisions.d.ts.map +1 -0
  30. package/dist/backend/backend/src/scripts/repair-orphan-superseded-decisions.js +114 -0
  31. package/dist/backend/backend/src/scripts/repair-orphan-superseded-decisions.js.map +1 -0
  32. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +7 -0
  33. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  34. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +9 -0
  35. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  36. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts +7 -0
  37. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js +19 -0
  39. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
  40. package/dist/backend/backend/src/services/decisions/decision.service.d.ts +4 -1
  41. package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/decisions/decision.service.js +10 -3
  43. package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
  44. package/dist/backend/backend/src/services/memory/project-memory.service.d.ts.map +1 -1
  45. package/dist/backend/backend/src/services/memory/project-memory.service.js +21 -10
  46. package/dist/backend/backend/src/services/memory/project-memory.service.js.map +1 -1
  47. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts +29 -0
  48. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
  49. package/dist/backend/backend/src/services/open-items/open-items.service.js +92 -8
  50. package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -1
  51. package/dist/backend/backend/src/services/open-items/open-items.wiring.js +2 -2
  52. package/dist/backend/backend/src/services/open-items/open-items.wiring.js.map +1 -1
  53. package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.d.ts +81 -0
  54. package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.d.ts.map +1 -0
  55. package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.js +88 -0
  56. package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.js.map +1 -0
  57. package/dist/backend/backend/src/services/orc/orc-reply-route.service.d.ts +8 -0
  58. package/dist/backend/backend/src/services/orc/orc-reply-route.service.d.ts.map +1 -1
  59. package/dist/backend/backend/src/services/orc/orc-reply-route.service.js +11 -0
  60. package/dist/backend/backend/src/services/orc/orc-reply-route.service.js.map +1 -1
  61. package/dist/backend/backend/src/services/orc/reply-destination-resolver.d.ts +218 -0
  62. package/dist/backend/backend/src/services/orc/reply-destination-resolver.d.ts.map +1 -0
  63. package/dist/backend/backend/src/services/orc/reply-destination-resolver.js +343 -0
  64. package/dist/backend/backend/src/services/orc/reply-destination-resolver.js.map +1 -0
  65. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts +98 -0
  66. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts.map +1 -0
  67. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js +362 -0
  68. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js.map +1 -0
  69. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts +9 -1
  70. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
  71. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js +32 -7
  72. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
  73. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts +2 -2
  74. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts.map +1 -1
  75. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js +9 -0
  76. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js.map +1 -1
  77. package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
  78. package/dist/backend/backend/src/services/slack/slack-initializer.js +4 -1
  79. package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
  80. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +1 -1
  81. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  82. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +24 -4
  83. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  84. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.d.ts +89 -7
  85. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.d.ts.map +1 -1
  86. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.js +159 -21
  87. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.js.map +1 -1
  88. package/dist/backend/backend/src/services/v3/ticket-intake.service.d.ts +23 -4
  89. package/dist/backend/backend/src/services/v3/ticket-intake.service.d.ts.map +1 -1
  90. package/dist/backend/backend/src/services/v3/ticket-intake.service.js +42 -4
  91. package/dist/backend/backend/src/services/v3/ticket-intake.service.js.map +1 -1
  92. package/dist/backend/build-info.json +2 -2
  93. package/dist/cli/backend/src/constants.d.ts +23 -0
  94. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  95. package/dist/cli/backend/src/constants.js +27 -1
  96. package/dist/cli/backend/src/constants.js.map +1 -1
  97. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +7 -0
  98. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  99. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +9 -0
  100. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  101. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts +7 -0
  102. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
  103. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js +19 -0
  104. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
  105. package/dist/cli/backend/src/services/decisions/decision.service.d.ts +4 -1
  106. package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  107. package/dist/cli/backend/src/services/decisions/decision.service.js +10 -3
  108. package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
  109. package/dist/cli/backend/src/services/memory/project-memory.service.d.ts.map +1 -1
  110. package/dist/cli/backend/src/services/memory/project-memory.service.js +21 -10
  111. package/dist/cli/backend/src/services/memory/project-memory.service.js.map +1 -1
  112. package/dist/cli/backend/src/services/open-items/open-items.service.d.ts +457 -0
  113. package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -0
  114. package/dist/cli/backend/src/services/open-items/open-items.service.js +1158 -0
  115. package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -0
  116. package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.d.ts +81 -0
  117. package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.d.ts.map +1 -0
  118. package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.js +88 -0
  119. package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.js.map +1 -0
  120. package/dist/cli/backend/src/services/orc/orc-reply-route.service.d.ts +8 -0
  121. package/dist/cli/backend/src/services/orc/orc-reply-route.service.d.ts.map +1 -1
  122. package/dist/cli/backend/src/services/orc/orc-reply-route.service.js +11 -0
  123. package/dist/cli/backend/src/services/orc/orc-reply-route.service.js.map +1 -1
  124. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts +9 -1
  125. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
  126. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js +32 -7
  127. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
  128. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +1 -1
  129. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  130. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +24 -4
  131. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  132. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.d.ts +89 -7
  133. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.d.ts.map +1 -1
  134. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.js +159 -21
  135. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.js.map +1 -1
  136. package/dist/cli/backend/src/services/v3/ticket-intake.service.d.ts +23 -4
  137. package/dist/cli/backend/src/services/v3/ticket-intake.service.d.ts.map +1 -1
  138. package/dist/cli/backend/src/services/v3/ticket-intake.service.js +42 -4
  139. package/dist/cli/backend/src/services/v3/ticket-intake.service.js.map +1 -1
  140. package/package.json +1 -1
@@ -0,0 +1,1158 @@
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, REPLY_ROUTING_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, parseDue } from './open-item-extractor.js';
39
+ import { deriveQuestionCard, questionSimilarity } from './open-item-card.js';
40
+ import { formatWhen } from '../decisions/decision-card.js';
41
+ import { AgentPromptReferenceService } from '../orc/agent-prompt-reference.service.js';
42
+ /** Thrown for open-item requests the caller must fix (HTTP 4xx). */
43
+ export class OpenItemsError extends Error {
44
+ status;
45
+ constructor(status, message) {
46
+ super(message);
47
+ this.status = status;
48
+ this.name = 'OpenItemsError';
49
+ }
50
+ }
51
+ /** Statuses of a child WorkItem that count as finished work. */
52
+ const CHILD_DONE = new Set(['done', 'verified']);
53
+ /** Ticket statuses the service follows (`cancelled` closes items instead). */
54
+ const FOLLOWED = new Set(['open', 'ready', 'running', 'blocked', 'waiting_confirmation', 'awaiting_followup', 'done']);
55
+ // ---------------------------------------------------------------------------
56
+ // Pure helpers
57
+ // ---------------------------------------------------------------------------
58
+ /**
59
+ * The Slack thread of a request's conversation (`origin.threadRef` is
60
+ * `slack:<channel>:<ts>`).
61
+ *
62
+ * @param request - Request
63
+ * @returns Place, or null for chat-only conversations
64
+ */
65
+ export function slackPlaceOf(request) {
66
+ const ref = request.origin?.threadRef;
67
+ if (typeof ref !== 'string')
68
+ return null;
69
+ const m = /^slack:([CDG][A-Z0-9]+):(\d{6,}\.\d+)$/.exec(ref);
70
+ return m ? { slackChannelId: m[1], threadTs: m[2] } : null;
71
+ }
72
+ /**
73
+ * Whether a message is in the request's conversation.
74
+ *
75
+ * @param request - Ticket with a chatRef
76
+ * @param message - chat-v2 message
77
+ * @returns True for its thread (or its root)
78
+ */
79
+ export function inRequestThread(request, message) {
80
+ const ref = request.chatRef;
81
+ if (!ref || ref.channelId !== message.channelId)
82
+ return false;
83
+ const root = message.threadId ?? message.id;
84
+ return root === ref.threadRootId || root === ref.messageId;
85
+ }
86
+ /**
87
+ * Whether a WorkItem is bookkeeping (verify / follow-up) rather than work.
88
+ *
89
+ * @param wi - WorkItem
90
+ * @returns True to ignore it as child work
91
+ */
92
+ function isBookkeeping(wi) {
93
+ const meta = (wi.metadata ?? {});
94
+ return (wi.type === 'review' ||
95
+ wi.id.includes(':verify:') ||
96
+ typeof meta.verifyOf === 'string' ||
97
+ meta[OPEN_ITEMS_CONSTANTS.FOLLOW_UP_METADATA_KEY] !== undefined);
98
+ }
99
+ /**
100
+ * Whether a WorkItem was started for this request's conversation: linked to
101
+ * the request, or (1.20.183 origin chain) carrying its thread as its origin.
102
+ *
103
+ * @param wi - WorkItem
104
+ * @param request - Request
105
+ * @returns True when it belongs to the request
106
+ */
107
+ function belongsTo(wi, request) {
108
+ if (wi.requestId === request.id || request.workItemIds.includes(wi.id))
109
+ return true;
110
+ const origin = wi.metadata?.origin;
111
+ if (!origin || origin.kind !== 'owner')
112
+ return false;
113
+ if (request.chatRef && origin.chatThreadId === request.chatRef.threadRootId)
114
+ return true;
115
+ const place = slackPlaceOf(request);
116
+ return !!place && origin.slackChannelId === place.slackChannelId && origin.threadTs === place.threadTs;
117
+ }
118
+ /**
119
+ * The delegated child work a commitment waits on: WorkItems of the request
120
+ * given to someone else, started around the promise and not already finished
121
+ * before it.
122
+ *
123
+ * @param request - Request
124
+ * @param item - Commitment
125
+ * @param pool - Every WorkItem
126
+ * @returns Child WorkItem ids
127
+ */
128
+ export function childWorkFor(request, item, pool) {
129
+ const at = Date.parse(item.createdAt);
130
+ const from = at - OPEN_ITEMS_CONSTANTS.CHILD_LOOKBACK_MS;
131
+ const to = at + OPEN_ITEMS_CONSTANTS.CHILD_LOOKAHEAD_MS;
132
+ const out = new Set();
133
+ for (const wi of pool) {
134
+ if (isBookkeeping(wi) || !belongsTo(wi, request))
135
+ continue;
136
+ if (!wi.target || wi.target === item.agent)
137
+ continue;
138
+ if (wi.status === 'cancelled')
139
+ continue;
140
+ const created = Date.parse(wi.createdAt);
141
+ if (!(created >= from && created <= to))
142
+ continue;
143
+ const finished = wi.completedAt ? Date.parse(wi.completedAt) : NaN;
144
+ if (CHILD_DONE.has(wi.status) && Number.isFinite(finished) && finished < at)
145
+ continue;
146
+ out.add(wi.id);
147
+ }
148
+ return [...out];
149
+ }
150
+ /**
151
+ * Whether every child of a commitment is finished (cancelled ones ignored).
152
+ *
153
+ * @param ids - Child ids
154
+ * @param pool - Every WorkItem
155
+ * @returns `ready` with the latest finish time, or not ready (with what is left)
156
+ */
157
+ export function childrenState(ids, pool) {
158
+ const byId = new Map(pool.map((w) => [w.id, w]));
159
+ const pending = [];
160
+ const done = [];
161
+ let latest = 0;
162
+ for (const id of ids) {
163
+ const wi = byId.get(id);
164
+ if (!wi || wi.status === 'cancelled')
165
+ continue;
166
+ if (CHILD_DONE.has(wi.status)) {
167
+ done.push(wi);
168
+ latest = Math.max(latest, Date.parse(wi.completedAt ?? '') || 0);
169
+ }
170
+ else {
171
+ pending.push(wi);
172
+ }
173
+ }
174
+ const ready = done.length > 0 && pending.length === 0;
175
+ return { ready, ...(ready && latest > 0 ? { finishedAt: new Date(latest).toISOString() } : {}), pending, done };
176
+ }
177
+ /**
178
+ * Whether two items are the same thing said once: same message, or the same
179
+ * words by the same agent within {@link OPEN_ITEMS_CONSTANTS.DUPLICATE_WINDOW_MS}.
180
+ *
181
+ * @param a - Item
182
+ * @param b - Item
183
+ * @returns True for a duplicate
184
+ */
185
+ export function isSameItem(a, b) {
186
+ if (a.text !== b.text)
187
+ return false;
188
+ if (a.sourceMessageId === b.sourceMessageId)
189
+ return true;
190
+ return a.agent === b.agent && Math.abs(Date.parse(a.createdAt) - Date.parse(b.createdAt)) <= OPEN_ITEMS_CONSTANTS.DUPLICATE_WINDOW_MS;
191
+ }
192
+ /** Whether the owner's message agrees ("可以", "同意", "ok", "go ahead", 👍) and does not hold back. */
193
+ export function isApproval(text) {
194
+ const t = text.replace(/<@[A-Z0-9]+>/g, ' ').trim().toLowerCase();
195
+ if (!t || t.length > 120)
196
+ return false;
197
+ if (/不(?:行|可以|同意|好|要|用|必)|别|先别|等等|再想想|\bno\b|don'?t|\bwait\b|\bhold\b|\bstop\b|不对/.test(t))
198
+ return false;
199
+ return /^(?:ok|okay|yes|yep|yeah|sure|go|go ahead|approved?|lgtm|agreed?|👍|✅|可以|好的?|行|同意|没问题|批准|确认|通过|发吧|做吧|开始吧?|就这样|按你说的)/u.test(t) || /(?:可以|同意|没问题|go ahead|approved?)[。!!.\s]*$/u.test(t);
200
+ }
201
+ /**
202
+ * Kinds of deliverable a promise can name; a post delivers such a promise
203
+ * only with a link, an attachment, or the same kind of thing named.
204
+ */
205
+ const DELIVERABLE_FAMILIES = [
206
+ ['预览', 'preview'],
207
+ ['pdf'],
208
+ ['链接', 'link', 'url'],
209
+ ['报告', 'report'],
210
+ ['结论', 'conclusion', 'finding'],
211
+ ['文章', 'article', '稿', 'draft'],
212
+ ['名单', 'list'],
213
+ ['截图', 'screenshot', 'image'],
214
+ ['文件', 'file', '附件', 'attachment', '表格', 'sheet'],
215
+ ];
216
+ /** A post that only acknowledges or reports progress — not a delivery. */
217
+ const NOT_A_DELIVERY = /^\s*(?:(?:收到|好的?|ok(?:ay)?|got it|on it|嗯+|明白)[\s,,。.!!~]*$|(?:在做|正在|还在|马上|稍等|working on|still working|in progress))/i;
218
+ /**
219
+ * Whether a post plausibly fulfils a promise (specs/2026-10-02-harness-owned-routing.md §5):
220
+ * the harness marked it as the ticket's delivery; or, for a promise of a
221
+ * deliverable (preview, PDF, link, report, file…), it carries a link, an
222
+ * attachment or names the same kind of thing; or, for a plain promise, it
223
+ * is a substantive post — not an ack, a progress line, or only a question.
224
+ *
225
+ * @param promise - The commitment's words
226
+ * @param message - The post
227
+ * @returns True when it can be the delivery
228
+ */
229
+ export function plausiblyFulfils(promise, message) {
230
+ const meta = message.metadata ?? {};
231
+ if (meta[REPLY_ROUTING_CONSTANTS.DELIVERS_TICKET_METADATA_KEY])
232
+ return true;
233
+ const text = (message.content ?? '').trim();
234
+ if (!text)
235
+ return false;
236
+ const hasLink = /https?:\/\/\S+/i.test(text) || /<https?:[^>]+>/i.test(text);
237
+ const hasAttachment = (Array.isArray(meta.attachments) && meta.attachments.length > 0) ||
238
+ (Array.isArray(meta.files) && meta.files.length > 0) ||
239
+ /\[file uploaded:/i.test(text);
240
+ const p = promise.toLowerCase();
241
+ const t = text.toLowerCase();
242
+ const families = DELIVERABLE_FAMILIES.filter((f) => f.some((w) => p.includes(w)));
243
+ if (families.length > 0) {
244
+ return hasLink || hasAttachment || families.some((f) => f.some((w) => t.includes(w)));
245
+ }
246
+ if (hasLink || hasAttachment)
247
+ return true;
248
+ if (NOT_A_DELIVERY.test(text))
249
+ return false;
250
+ // Only a question back ("where should I send it?") is not the delivery.
251
+ const sentences = text.split(/[。.!!\n]+/).map((x) => x.trim()).filter(Boolean);
252
+ if (sentences.length > 0 && sentences.every((x) => /[??]$/.test(x)))
253
+ return false;
254
+ return text.length >= OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_CHARS;
255
+ }
256
+ /** Named people / tools a promise mentions (Nova, Vera, CDC…). */
257
+ function namesIn(s) {
258
+ return new Set((s.match(/\b[A-Z][A-Za-z]{2,}\b/g) ?? []).map((n) => n.toLowerCase()));
259
+ }
260
+ const DELIVERABLE_NOUNS = ['预览', 'preview', 'pdf', '报告', '结论', '文章', '稿', '名单', 'report', 'draft'];
261
+ /**
262
+ * Whether two commitments are the same promise: the same words, or — from the
263
+ * same agent within {@link OPEN_ITEMS_CONSTANTS.PROMISE_DUPLICATE_WINDOW_MS} —
264
+ * very similar words, or the same deliverable (same person named and the same
265
+ * kind of thing handed over).
266
+ *
267
+ * @param a - One item
268
+ * @param b - The other
269
+ * @returns True for one promise said twice
270
+ */
271
+ export function isSamePromise(a, b) {
272
+ if (a.type !== 'commitment' || b.type !== 'commitment')
273
+ return false;
274
+ if (isSameItem(a, b))
275
+ return true;
276
+ if (a.agent !== b.agent || Math.abs(Date.parse(a.createdAt) - Date.parse(b.createdAt)) > OPEN_ITEMS_CONSTANTS.PROMISE_DUPLICATE_WINDOW_MS)
277
+ return false;
278
+ if (questionSimilarity(a.text, b.text) >= OPEN_ITEMS_CONSTANTS.SAME_QUESTION_SIMILARITY)
279
+ return true;
280
+ const na = namesIn(a.text);
281
+ const shared = [...namesIn(b.text)].some((n) => na.has(n));
282
+ const la = a.text.toLowerCase();
283
+ const lb = b.text.toLowerCase();
284
+ return shared && DELIVERABLE_NOUNS.some((w) => la.includes(w) && lb.includes(w));
285
+ }
286
+ /**
287
+ * Clip for a one-line mention.
288
+ *
289
+ * @param s - Text
290
+ * @param max - Max characters
291
+ * @returns Clipped
292
+ */
293
+ function short(s, max = 120) {
294
+ const flat = s.replace(/\s+/g, ' ').trim();
295
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
296
+ }
297
+ /**
298
+ * The reference a follow-up prompt names (specs/2026-10-02-harness-owned-routing.md §4).
299
+ *
300
+ * @param request - Request
301
+ * @param item - Commitment
302
+ * @returns The ticket (and follow-up work item) the agent answers about
303
+ */
304
+ export function followUpReference(request, item) {
305
+ return {
306
+ ...(typeof request.ticketNumber === 'number' ? { ticket: formatTicketNumber(request.ticketNumber) } : {}),
307
+ ...(item.workItemId ? { workItemId: item.workItemId } : {}),
308
+ };
309
+ }
310
+ /**
311
+ * The exact command a follow-up prompt tells the agent to run — a reference,
312
+ * never a raw thread key (the harness finds the thread).
313
+ *
314
+ * @param ref - Follow-up reference
315
+ * @returns Command text
316
+ */
317
+ export function followUpCommand(ref) {
318
+ if (ref.ticket)
319
+ return `reply --ticket ${ref.ticket} "<your message>"`;
320
+ if (ref.workItemId)
321
+ return `reply --work-item ${ref.workItemId} "<your message>"`;
322
+ return 'reply "<your message>"';
323
+ }
324
+ /**
325
+ * The ticket's display id.
326
+ *
327
+ * @param request - Request
328
+ * @returns `TKT-185`, else the id prefix
329
+ */
330
+ function ticketLabel(request) {
331
+ return typeof request.ticketNumber === 'number' ? formatTicketNumber(request.ticketNumber) : request.id.slice(0, 8);
332
+ }
333
+ // ---------------------------------------------------------------------------
334
+ // Service
335
+ // ---------------------------------------------------------------------------
336
+ /**
337
+ * Tracks commitments and questions on Requests. See module docs.
338
+ */
339
+ export class OpenItemsService {
340
+ deps;
341
+ static instance = null;
342
+ logger;
343
+ now;
344
+ chain = Promise.resolve();
345
+ timer = null;
346
+ /**
347
+ * @param deps - Collaborators
348
+ */
349
+ constructor(deps) {
350
+ this.deps = deps;
351
+ this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('OpenItems');
352
+ this.now = deps.now ?? (() => new Date());
353
+ }
354
+ /** @returns The process-wide instance, or null before wiring */
355
+ static getInstance() {
356
+ return OpenItemsService.instance;
357
+ }
358
+ /** @param service - Instance to install (null clears) */
359
+ static setInstance(service) {
360
+ OpenItemsService.instance = service;
361
+ }
362
+ /** Start the periodic sweep. */
363
+ start() {
364
+ if (this.timer)
365
+ return;
366
+ this.timer = setInterval(() => {
367
+ void this.sweep().catch((err) => this.logger.warn('Open-items sweep failed', { error: errText(err) }));
368
+ }, OPEN_ITEMS_CONSTANTS.SWEEP_INTERVAL_MS);
369
+ this.timer.unref?.();
370
+ }
371
+ /** Stop the sweep. */
372
+ stop() {
373
+ if (this.timer)
374
+ clearInterval(this.timer);
375
+ this.timer = null;
376
+ }
377
+ /**
378
+ * One change at a time (a chat message, a WorkItem event and the sweep may
379
+ * touch the same request).
380
+ */
381
+ serial(fn) {
382
+ const run = this.chain.then(fn);
383
+ this.chain = run.catch(() => undefined);
384
+ return run;
385
+ }
386
+ // -------------------------------------------------------------------------
387
+ // Agent replies
388
+ // -------------------------------------------------------------------------
389
+ /**
390
+ * The ticket an agent message belongs to: the one whose thread it is in
391
+ * (any status but cancelled, closed within the lookback), else — for a
392
+ * top-level message — the newest open ticket in that channel the agent is
393
+ * answering.
394
+ *
395
+ * @param message - chat-v2 message
396
+ * @param all - Every request
397
+ * @returns The request, or null
398
+ */
399
+ findRequestFor(message, all) {
400
+ const since = this.now().getTime() - OPEN_ITEMS_CONSTANTS.LOOKBACK_MS;
401
+ const at = message.createdAt ?? this.now().getTime();
402
+ const tickets = all
403
+ .filter((r) => typeof r.ticketNumber === 'number' && !!r.chatRef && FOLLOWED.has(r.status) && Date.parse(r.updatedAt) >= since)
404
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
405
+ // Several tickets can share one thread (follow-up asks): the message
406
+ // belongs to the newest one opened before it — the open one first.
407
+ const sharing = tickets.filter((r) => inRequestThread(r, message));
408
+ const openedBefore = sharing.filter((r) => Date.parse(r.createdAt) <= at);
409
+ const inThread = openedBefore.length > 0 ? openedBefore : sharing;
410
+ if (inThread.length > 0)
411
+ return inThread.find((r) => r.status !== 'done') ?? inThread[0];
412
+ if (message.threadId)
413
+ return null;
414
+ return (tickets.find((r) => r.chatRef?.channelId === message.channelId &&
415
+ r.status !== 'done' &&
416
+ (r.assignee === message.senderId || r.reply?.by === message.senderId)) ?? null);
417
+ }
418
+ /**
419
+ * An agent posted in chat-v2: mark what it delivered, and track what it
420
+ * newly promised or asked.
421
+ *
422
+ * @param message - The message
423
+ * @returns The request it changed, or null
424
+ */
425
+ async onAgentMessage(message) {
426
+ if (message.senderType === 'user')
427
+ return this.onOwnerMessage(message);
428
+ if (message.senderType !== 'agent' || !message.content?.trim())
429
+ return null;
430
+ // "Got it — on it" placeholders are not the reply.
431
+ if (isInterim(message))
432
+ return null;
433
+ return this.serial(async () => {
434
+ const all = await this.deps.requests.listAll();
435
+ const request = this.findRequestFor(message, all);
436
+ if (!request)
437
+ return null;
438
+ const at = new Date(message.createdAt ?? this.now().getTime());
439
+ const pool = await this.deps.listWorkItems().catch(() => []);
440
+ const planned = await this.plan(request, message, at);
441
+ let items = [...(request.openItems ?? [])];
442
+ let changed = false;
443
+ // Deliveries first: this post may be what an earlier promise was waiting for.
444
+ const delivered = this.deliveries(items, message, at, pool);
445
+ for (const d of delivered) {
446
+ items = items.map((i) => (i.id === d.id ? d : i));
447
+ changed = true;
448
+ if (d.workItemId)
449
+ await this.closeFollowUp(d.workItemId, 'delivered', `Delivered to the owner (message ${message.id})`);
450
+ this.logger.info('Commitment delivered', { tkt: ticketLabel(request), item: d.id, by: message.senderId });
451
+ }
452
+ // The same words twice (a reply recorded both from the reply path and the
453
+ // Slack mirror) are one item.
454
+ // A post that delivers something restates it ("结论在下面:…"): the words are
455
+ // the delivery, not a new promise.
456
+ const restated = (p) => p.item.type === 'commitment' &&
457
+ delivered.some((d) => questionSimilarity(d.text, p.item.text) >= OPEN_ITEMS_CONSTANTS.SAME_QUESTION_SIMILARITY);
458
+ const fresh = planned.filter((p) => !items.some((i) => isSameItem(i, p.item)) && !restated(p));
459
+ for (const p of fresh) {
460
+ // The same promise said again (a plan, then "收到,按刚才说的做"): the newest one stands.
461
+ for (let k = 0; k < items.length; k++) {
462
+ if (isSamePromise(items[k], p.item) && ACTIVE_OPEN_ITEM_STATUSES.has(items[k].status)) {
463
+ if (items[k].workItemId)
464
+ await this.closeFollowUp(items[k].workItemId, 'cancelled', `Superseded by a newer promise (${p.item.id})`);
465
+ items[k] = { ...items[k], status: 'superseded', closedAt: at.toISOString(), closedReason: `said again in message ${message.id}` };
466
+ }
467
+ }
468
+ const item = await this.activate(request, p, pool);
469
+ items.push(item);
470
+ changed = true;
471
+ }
472
+ if (!changed)
473
+ return null;
474
+ return this.save(request, items, fresh.length > 0);
475
+ });
476
+ }
477
+ /**
478
+ * The owner posted in a ticket's thread: a yes opens the conditional
479
+ * promises that wait on them.
480
+ *
481
+ * @param message - The owner's message
482
+ * @returns The request it changed, or null
483
+ */
484
+ async onOwnerMessage(message) {
485
+ if (!message.content?.trim() || !isApproval(message.content))
486
+ return null;
487
+ return this.serial(async () => {
488
+ const all = await this.deps.requests.listAll();
489
+ const request = all.find((r) => r.status !== 'cancelled' && inRequestThread(r, message) && (r.openItems ?? []).some((i) => i.status === 'waiting_owner'));
490
+ if (!request)
491
+ return null;
492
+ const at = new Date(message.createdAt ?? this.now().getTime());
493
+ return this.openWaiting(request, at, `the owner said yes in the thread (message ${message.id})`, () => true);
494
+ });
495
+ }
496
+ /**
497
+ * Open waiting promises: their due time is counted from `at`, and the
498
+ * follow-up WorkItem is created now.
499
+ *
500
+ * @param request - Request
501
+ * @param at - When the owner agreed
502
+ * @param why - For the log
503
+ * @param pick - Which waiting items
504
+ * @returns The updated request, or null when none matched
505
+ */
506
+ async openWaiting(request, at, why, pick) {
507
+ const pool = await this.deps.listWorkItems().catch(() => []);
508
+ const items = [];
509
+ let changed = false;
510
+ for (const i of request.openItems ?? []) {
511
+ if (i.status !== 'waiting_owner' || !pick(i)) {
512
+ items.push(i);
513
+ continue;
514
+ }
515
+ const { due, source } = parseDue(i.text, at);
516
+ const opened = await this.activate(request, { item: { ...i, status: 'open', due: due.toISOString(), dueSource: source } }, pool);
517
+ this.logger.info('Conditional promise opened', { tkt: ticketLabel(request), item: i.id, due: opened.due, why });
518
+ items.push(opened);
519
+ changed = true;
520
+ }
521
+ return changed ? this.save(request, items, false) : null;
522
+ }
523
+ /**
524
+ * What a message would add, without side effects (also the backfill's view).
525
+ *
526
+ * @param request - Its request
527
+ * @param message - The agent message
528
+ * @param at - When it was posted
529
+ * @returns Planned items
530
+ */
531
+ async plan(request, message, at) {
532
+ const names = await this.deps.colleagueNames?.(message.senderId).catch(() => []);
533
+ const owner = this.deps.ownerSlackUserId?.() ?? (request.origin?.author?.startsWith('U') ? request.origin.author : null);
534
+ const found = extractOpenItems(message.content, {
535
+ now: at,
536
+ ...(names ? { colleagueNames: names } : {}),
537
+ ...(owner ? { ownerSlackUserId: owner } : {}),
538
+ });
539
+ const base = (type, n) => ({
540
+ id: `${type === 'commitment' ? 'c' : 'q'}-${message.id.slice(0, 8)}-${n}`,
541
+ agent: message.senderId,
542
+ sourceMessageId: message.id,
543
+ createdAt: at.toISOString(),
544
+ status: 'open',
545
+ });
546
+ const out = [];
547
+ for (const [i, c] of found.commitments.entries()) {
548
+ if (c.waitsOnOwner) {
549
+ // Conditional on the owner: no due time, no follow-up, no nudges until they say yes.
550
+ const gate = await this.recentDecision(message.senderId, at);
551
+ out.push({ item: { ...base('commitment', i + 1), type: 'commitment', text: c.text, status: 'waiting_owner', ...(gate ? { gateDecisionId: gate.id } : {}) } });
552
+ continue;
553
+ }
554
+ out.push({ item: { ...base('commitment', i + 1), type: 'commitment', text: c.text, due: c.due.toISOString(), dueSource: c.dueSource } });
555
+ }
556
+ let qi = 0;
557
+ for (const q of found.questions) {
558
+ qi += 1;
559
+ const item = { ...base('question', qi), type: 'question', text: q.text };
560
+ const skipped = await this.deps.skippedQuestion?.(request.id, message.senderId, q.text).catch(() => null);
561
+ if (skipped) {
562
+ out.push({ item, skippedDecisionId: skipped.id });
563
+ continue;
564
+ }
565
+ const asked = await this.alreadyAsked(message.senderId, q, at);
566
+ out.push(asked ? { item, linkedDecisionId: asked.id } : { item, card: deriveQuestionCard(q) });
567
+ }
568
+ return out;
569
+ }
570
+ /**
571
+ * The newest ask-owner decision this agent made shortly before `at` (the card a conditional promise waits on).
572
+ *
573
+ * @param agent - Agent
574
+ * @param at - When the promise was made
575
+ * @returns The decision, or null
576
+ */
577
+ async recentDecision(agent, at) {
578
+ if (!this.deps.recentDecisionsBy)
579
+ return null;
580
+ const win = OPEN_ITEMS_CONSTANTS.ASK_OWNER_DEDUPE_WINDOW_MS;
581
+ const recent = await this.deps.recentDecisionsBy(agent, at.getTime() - win).catch(() => []);
582
+ return recent.filter((d) => d.kind !== 'reply_question' && Date.parse(d.createdAt) <= at.getTime() + 60_000).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0] ?? null;
583
+ }
584
+ /**
585
+ * The ask-owner decision the agent already made for this question, if any.
586
+ *
587
+ * @param agent - Asking agent
588
+ * @param q - The question
589
+ * @param at - When it was asked in the reply
590
+ * @returns The decision, or null
591
+ */
592
+ async alreadyAsked(agent, q, at) {
593
+ if (!this.deps.recentDecisionsBy)
594
+ return null;
595
+ const win = OPEN_ITEMS_CONSTANTS.ASK_OWNER_DEDUPE_WINDOW_MS;
596
+ const recent = await this.deps.recentDecisionsBy(agent, at.getTime() - win).catch(() => []);
597
+ return (recent.find((d) => d.kind !== 'reply_question' &&
598
+ Math.abs(Date.parse(d.createdAt) - at.getTime()) <= win &&
599
+ questionSimilarity(d.question, q.text) >= OPEN_ITEMS_CONSTANTS.SAME_QUESTION_SIMILARITY) ?? null);
600
+ }
601
+ /**
602
+ * Turn a planned item into a tracked one: link child work and queue the
603
+ * follow-up (commitment), or post / link the card (question).
604
+ *
605
+ * @param request - Its request
606
+ * @param p - Planned item
607
+ * @param pool - Every WorkItem
608
+ * @returns The stored item
609
+ */
610
+ async activate(request, p, pool, source = 'live') {
611
+ const place = slackPlaceOf(request);
612
+ let item = { ...p.item };
613
+ if (p.skippedDecisionId) {
614
+ this.logger.info('Question the owner already skipped — not asked again', { tkt: ticketLabel(request), decisionId: p.skippedDecisionId });
615
+ return { ...item, status: 'skipped', decisionId: p.skippedDecisionId, closedAt: this.now().toISOString(), closedReason: `owner skipped this before (${p.skippedDecisionId})` };
616
+ }
617
+ if (item.type === 'commitment' && item.status === 'waiting_owner') {
618
+ this.logger.info('Conditional promise — waiting on the owner', { tkt: ticketLabel(request), item: item.id, agent: item.agent, gate: item.gateDecisionId });
619
+ return item;
620
+ }
621
+ if (item.type === 'commitment') {
622
+ const children = childWorkFor(request, item, pool);
623
+ if (children.length > 0)
624
+ item.childWorkItemIds = children;
625
+ const wiId = await this.deps.createFollowUp?.({ request, item, place }).catch((err) => {
626
+ this.logger.warn('Follow-up WorkItem not created', { tkt: ticketLabel(request), error: errText(err) });
627
+ return null;
628
+ });
629
+ if (wiId)
630
+ item.workItemId = wiId;
631
+ this.logger.info('Commitment tracked', { tkt: ticketLabel(request), item: item.id, agent: item.agent, due: item.due, children: children.length });
632
+ // The child work may already be finished (the promise came late).
633
+ const state = childrenState(item.childWorkItemIds ?? [], pool);
634
+ if (state.ready)
635
+ item = await this.markReady(request, item, state.finishedAt, state.done);
636
+ return item;
637
+ }
638
+ if (p.linkedDecisionId) {
639
+ item.decisionId = p.linkedDecisionId;
640
+ this.logger.info('Question already asked with ask-owner — no second card', { tkt: ticketLabel(request), decisionId: p.linkedDecisionId });
641
+ return item;
642
+ }
643
+ if (p.card && this.deps.askQuestion) {
644
+ const d = await this.deps.askQuestion({ request, item, card: p.card, place, source }).catch((err) => {
645
+ this.logger.warn('Question card not posted', { tkt: ticketLabel(request), error: errText(err) });
646
+ return null;
647
+ });
648
+ if (d)
649
+ item.decisionId = d.id;
650
+ }
651
+ this.logger.info('Question tracked', { tkt: ticketLabel(request), item: item.id, agent: item.agent, decisionId: item.decisionId });
652
+ return item;
653
+ }
654
+ /**
655
+ * Commitments this message delivers.
656
+ *
657
+ * @param items - Current items
658
+ * @param message - The post
659
+ * @param at - Its time
660
+ * @param pool - Every WorkItem
661
+ * @returns Updated (delivered) items
662
+ */
663
+ deliveries(items, message, at, pool) {
664
+ const out = [];
665
+ for (const item of items) {
666
+ if (item.type !== 'commitment' || !ACTIVE_OPEN_ITEM_STATUSES.has(item.status) || item.status === 'waiting_owner')
667
+ continue;
668
+ if (!this.deliveredBy(item, { ...message, createdAt: at.getTime() }, pool))
669
+ continue;
670
+ out.push({ ...item, status: 'delivered', closedAt: at.toISOString(), closedReason: `posted in the thread (message ${message.id})` });
671
+ }
672
+ return out;
673
+ }
674
+ /**
675
+ * Whether a post in the thread delivers a commitment: by the promising
676
+ * agent (or one doing the child work), once the child work is finished.
677
+ * With no child work: a later post by the promising agent, at least
678
+ * {@link OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_GAP_MS} after the promise, that
679
+ * does not itself promise something new.
680
+ *
681
+ * @param item - Commitment
682
+ * @param message - The post (`createdAt` set)
683
+ * @param pool - Every WorkItem
684
+ * @returns True when it is the delivery
685
+ */
686
+ deliveredBy(item, message, pool) {
687
+ if (item.type !== 'commitment' || item.sourceMessageId === message.id)
688
+ return false;
689
+ if (message.senderType !== 'agent' || isInterim(message))
690
+ return false;
691
+ const at = message.createdAt ?? this.now().getTime();
692
+ if (at <= Date.parse(item.createdAt))
693
+ return false;
694
+ const children = item.childWorkItemIds ?? [];
695
+ if (children.length > 0) {
696
+ const childTargets = new Set(pool.filter((w) => children.includes(w.id)).map((w) => w.target));
697
+ if (message.senderId !== item.agent && !childTargets.has(message.senderId))
698
+ return false;
699
+ const state = childrenState(children, pool);
700
+ if (!state.ready)
701
+ return false;
702
+ // Posted after the work was finished. A verify pass can stamp the child's
703
+ // completedAt after the agent has posted, so allow a grace before it.
704
+ const postedBefore = state.finishedAt ? Date.parse(state.finishedAt) - at : 0;
705
+ if (postedBefore > OPEN_ITEMS_CONSTANTS.DELIVERY_FINISH_GRACE_MS)
706
+ return false;
707
+ // Only a post that plausibly IS the promised thing (spec 2026-10-02 §5).
708
+ if (!plausiblyFulfils(item.text, message))
709
+ return false;
710
+ // A post that makes a new promise is not the delivery.
711
+ return postedBefore <= 0 || !this.promisesAnew(item, message, at);
712
+ }
713
+ if (message.senderId !== item.agent)
714
+ return false;
715
+ if (at - Date.parse(item.createdAt) < OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_GAP_MS)
716
+ return false;
717
+ if (!plausiblyFulfils(item.text, message))
718
+ return false;
719
+ return !this.promisesAnew(item, message, at);
720
+ }
721
+ /**
722
+ * Whether a post makes a NEW promise: restating the one it delivers does not count.
723
+ *
724
+ * @param item - The commitment being delivered
725
+ * @param message - The post
726
+ * @param at - Its time (epoch ms)
727
+ * @returns True when it promises something else
728
+ */
729
+ promisesAnew(item, message, at) {
730
+ return extractOpenItems(message.content, { now: new Date(at) }).commitments.some((c) => questionSimilarity(c.text, item.text) < OPEN_ITEMS_CONSTANTS.SAME_QUESTION_SIMILARITY);
731
+ }
732
+ /**
733
+ * The agent that owns a follow-up WorkItem closes it as already delivered:
734
+ * the open item is closed and the WorkItem finished (no done_by_worker step).
735
+ *
736
+ * @param workItemId - The follow-up WorkItem
737
+ * @param agent - The session closing it (must be the promising agent)
738
+ * @param summary - What the agent says (where it was delivered)
739
+ * @returns True when an active item of that agent was closed
740
+ */
741
+ async closeByAgent(workItemId, agent, summary) {
742
+ return this.serial(async () => {
743
+ const all = await this.deps.requests.listAll();
744
+ const request = all.find((r) => (r.openItems ?? []).some((i) => i.workItemId === workItemId));
745
+ if (!request)
746
+ return false;
747
+ const target = (request.openItems ?? []).find((i) => i.workItemId === workItemId);
748
+ if (!target || target.agent !== agent || !ACTIVE_OPEN_ITEM_STATUSES.has(target.status))
749
+ return false;
750
+ const at = this.now().toISOString();
751
+ const items = (request.openItems ?? []).map((i) => i.id === target.id ? { ...i, status: 'delivered', closedAt: at, closedReason: `closed by ${agent}: ${short(summary, 200)}` } : i);
752
+ await this.deps.closeFollowUp?.(workItemId, 'delivered', `Closed by ${agent}`);
753
+ await this.save(request, items, false);
754
+ return true;
755
+ });
756
+ }
757
+ /**
758
+ * Take planned items onto a request (the backfill's apply step).
759
+ *
760
+ * @param requestId - Request
761
+ * @param planned - Items from {@link plan}
762
+ * @param opts - `source: 'backfill'` marks the cards it posts as backfilled; `caller` is
763
+ * the session of whoever asked for it (logged: who ran an apply)
764
+ * @returns Updated request, or null when it is gone
765
+ */
766
+ async adopt(requestId, planned, opts = {}) {
767
+ return this.serial(async () => {
768
+ this.logger.info('Open items adopted (backfill apply)', { requestId, count: planned.length, caller: opts.caller ?? 'unknown', source: opts.source ?? 'live' });
769
+ const request = await this.deps.requests.getById(requestId);
770
+ if (!request)
771
+ return null;
772
+ const pool = await this.deps.listWorkItems().catch(() => []);
773
+ const items = [...(request.openItems ?? [])];
774
+ for (const p of planned) {
775
+ if (items.some((i) => i.id === p.item.id))
776
+ continue;
777
+ items.push(await this.activate(request, p, pool, opts.source ?? 'live'));
778
+ }
779
+ return this.save(request, items, true);
780
+ });
781
+ }
782
+ /**
783
+ * Store items, and move the request between `done` and `awaiting_followup`.
784
+ *
785
+ * @param request - Request as read
786
+ * @param items - New item list
787
+ * @param added - New active items were added
788
+ * @returns Updated request
789
+ */
790
+ async save(request, items, added) {
791
+ const active = items.some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status));
792
+ if (active && request.status === 'done' && added) {
793
+ this.logger.info('Closed ticket reopened: its agent left open items in the thread', { tkt: ticketLabel(request) });
794
+ return this.deps.requests.update(request.id, { openItems: items, status: 'awaiting_followup', reopenForFollowup: true });
795
+ }
796
+ if (!active && request.status === 'awaiting_followup') {
797
+ this.logger.info('Every open item closed — ticket done', { tkt: ticketLabel(request) });
798
+ return this.deps.requests.update(request.id, { openItems: items, status: 'done', accepted: true, ignoreDeadChildren: true });
799
+ }
800
+ return this.deps.requests.update(request.id, { openItems: items });
801
+ }
802
+ // -------------------------------------------------------------------------
803
+ // Child work
804
+ // -------------------------------------------------------------------------
805
+ /**
806
+ * A WorkItem finished (task:done / task:verified): wake whoever promised
807
+ * the owner the result, if that was the last piece.
808
+ *
809
+ * @param workItemId - The WorkItem
810
+ * @returns Items that became ready
811
+ */
812
+ async onWorkItemSettled(workItemId) {
813
+ return this.serial(async () => {
814
+ const woken = [];
815
+ const all = await this.deps.requests.listAll();
816
+ const affected = all.filter((r) => (r.openItems ?? []).some((i) => i.type === 'commitment' && i.status === 'open' && (i.childWorkItemIds ?? []).includes(workItemId)));
817
+ if (affected.length === 0)
818
+ return woken;
819
+ const pool = await this.deps.listWorkItems();
820
+ for (const request of affected) {
821
+ let changed = false;
822
+ const items = [];
823
+ for (const item of request.openItems ?? []) {
824
+ if (item.type === 'commitment' && item.status === 'open' && (item.childWorkItemIds ?? []).includes(workItemId)) {
825
+ const state = childrenState(item.childWorkItemIds ?? [], pool);
826
+ if (state.ready) {
827
+ const ready = await this.markReady(request, item, state.finishedAt, state.done);
828
+ items.push(ready);
829
+ woken.push(ready);
830
+ changed = true;
831
+ continue;
832
+ }
833
+ }
834
+ items.push(item);
835
+ }
836
+ if (changed)
837
+ await this.deps.requests.update(request.id, { openItems: items });
838
+ }
839
+ return woken;
840
+ });
841
+ }
842
+ /**
843
+ * The child work is finished: wake the agent to deliver.
844
+ *
845
+ * @param request - Request
846
+ * @param item - Commitment
847
+ * @param finishedAt - When the last child finished
848
+ * @param done - Finished children
849
+ * @returns Updated item (`ready`)
850
+ */
851
+ async markReady(request, item, finishedAt, done) {
852
+ const now = this.now();
853
+ const what = done.map((w) => `"${short(w.title, 80)}"`).join(', ');
854
+ const ref = followUpReference(request, item);
855
+ const text = `[FOLLOW-UP ${ticketLabel(request)}] The work you promised the owner is ready (${what}) — deliver it now. ` +
856
+ `You said: "${short(item.text, 200)}". Run: ${followUpCommand(ref)} — Crewly posts it in the ticket's thread; that closes the follow-up.`;
857
+ const ok = await this.deps.deliverToAgent(item.agent, text).catch(() => false);
858
+ if (ok)
859
+ AgentPromptReferenceService.getInstance().note(item.agent, ref, `[FOLLOW-UP ${ticketLabel(request)}]`);
860
+ this.logger.info('Promised work is ready — agent woken to deliver', { tkt: ticketLabel(request), item: item.id, agent: item.agent, delivered: ok });
861
+ return { ...item, status: 'ready', readyAt: finishedAt ?? now.toISOString(), ...(ok ? { wokeAt: now.toISOString() } : {}) };
862
+ }
863
+ // -------------------------------------------------------------------------
864
+ // Decisions
865
+ // -------------------------------------------------------------------------
866
+ /**
867
+ * A `reply_question` decision settled: close its open item and give the
868
+ * agent the answer (the generic decision note).
869
+ *
870
+ * @param d - The decision
871
+ * @param fallback - The generic note
872
+ * @returns The note for the agent (null when withdrawn)
873
+ */
874
+ async onDecisionSettled(d, fallback) {
875
+ const ref = d.requestRef;
876
+ if (!ref)
877
+ return fallback ?? null;
878
+ // Not awaited: the handler can run inside this service's own chain (the
879
+ // sweep withdrawing a card), and the note does not depend on the write.
880
+ void this.serial(async () => {
881
+ const request = await this.deps.requests.getById(ref.requestId);
882
+ if (!request)
883
+ return;
884
+ const now = this.now().toISOString();
885
+ const status = itemStatusFor(d);
886
+ if (status === 'open')
887
+ return; // parked: still waiting on the owner
888
+ const answer = d.chosenKey ? d.options.find((o) => o.key === d.chosenKey)?.label : d.answerText;
889
+ const items = (request.openItems ?? []).map((i) => i.id === ref.itemId && ACTIVE_OPEN_ITEM_STATUSES.has(i.status)
890
+ ? { ...i, status, closedAt: now, closedReason: `${d.id} ${d.status}`, ...(answer ? { answer } : {}) }
891
+ : i);
892
+ await this.save(request, items, false);
893
+ }).catch((err) => this.logger.warn('Answered question not recorded', { decisionId: d.id, error: errText(err) }));
894
+ if (d.status === 'cancelled')
895
+ return null;
896
+ const reply = d.chosenKey && d.options.find((o) => o.key === d.chosenKey)?.label === OPEN_ITEMS_CONSTANTS.REPLY_LABEL;
897
+ if (reply) {
898
+ return `[DECISION ${d.id}] The owner will answer "${d.question}" in words in the thread. Wait for their message there, then act on it.`;
899
+ }
900
+ return fallback ?? null;
901
+ }
902
+ // -------------------------------------------------------------------------
903
+ // Owner skips
904
+ // -------------------------------------------------------------------------
905
+ /**
906
+ * The owner skipped an open item ("I don't care about this anymore"). A
907
+ * promise is closed, its follow-up WorkItem cancelled and the agent told
908
+ * once to drop it; a question's card is skipped (which closes the item and
909
+ * tells the agent). The request completes when nothing else is open.
910
+ *
911
+ * @param requestId - Request
912
+ * @param itemId - Open item
913
+ * @returns The closed item
914
+ * @throws OpenItemsError(404 / 409)
915
+ */
916
+ async skipItem(requestId, itemId) {
917
+ const request = await this.deps.requests.getById(requestId);
918
+ if (!request)
919
+ throw new OpenItemsError(404, `Request ${requestId} not found`);
920
+ const item = (request.openItems ?? []).find((i) => i.id === itemId);
921
+ if (!item)
922
+ throw new OpenItemsError(404, `Open item ${itemId} not found on ${ticketLabel(request)}`);
923
+ if (!ACTIVE_OPEN_ITEM_STATUSES.has(item.status))
924
+ throw new OpenItemsError(409, `Open item ${itemId} is already ${item.status}`);
925
+ // A question with a pending card: skip the card; its settle handler closes the item.
926
+ if (item.type === 'question' && item.decisionId && this.deps.skipQuestion) {
927
+ await this.deps.skipQuestion(item.decisionId).catch(() => false);
928
+ }
929
+ let note = null;
930
+ const closed = await this.serial(async () => {
931
+ const fresh = await this.deps.requests.getById(requestId);
932
+ const cur = fresh?.openItems?.find((i) => i.id === itemId);
933
+ if (!fresh || !cur)
934
+ throw new OpenItemsError(404, `Open item ${itemId} not found`);
935
+ if (!ACTIVE_OPEN_ITEM_STATUSES.has(cur.status))
936
+ return cur;
937
+ const next = { ...cur, status: 'skipped', closedAt: this.now().toISOString(), closedReason: 'skipped by the owner' };
938
+ if (cur.type === 'commitment') {
939
+ if (cur.workItemId)
940
+ await this.closeFollowUp(cur.workItemId, 'cancelled', 'The owner skipped this promise');
941
+ note =
942
+ `[FOLLOW-UP ${ticketLabel(fresh)}] The owner skipped this — drop it, don't ask again. ` +
943
+ `You had promised: "${short(cur.text, 200)}". Don't deliver it unless the owner asks again.`;
944
+ }
945
+ else {
946
+ note = `[FOLLOW-UP ${ticketLabel(fresh)}] The owner skipped this — drop it, don't ask again: "${short(cur.text, 200)}".`;
947
+ }
948
+ const items = (fresh.openItems ?? []).map((i) => (i.id === itemId ? next : i));
949
+ await this.save(fresh, items, false);
950
+ return next;
951
+ });
952
+ if (note)
953
+ await this.deps.deliverToAgent(closed.agent, note).catch(() => false);
954
+ this.logger.info('Open item skipped by the owner', { tkt: ticketLabel(request), item: itemId, type: closed.type });
955
+ return closed;
956
+ }
957
+ // -------------------------------------------------------------------------
958
+ // Sweep
959
+ // -------------------------------------------------------------------------
960
+ /**
961
+ * Periodic pass: link late child work, catch missed readiness, nudge
962
+ * overdue promises once and then tell the owner, sync answered questions,
963
+ * expire stale items, close cancelled tickets' items.
964
+ *
965
+ * @returns Counts of what happened
966
+ */
967
+ async sweep() {
968
+ return this.serial(async () => {
969
+ const counts = { ready: 0, nudged: 0, ownerNotes: 0, expired: 0, closed: 0 };
970
+ const now = this.now();
971
+ const all = await this.deps.requests.listAll();
972
+ const tracked = all.filter((r) => r.status === 'awaiting_followup' || (r.openItems ?? []).some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status)));
973
+ if (tracked.length === 0)
974
+ return counts;
975
+ const pool = await this.deps.listWorkItems().catch(() => []);
976
+ for (const request of tracked) {
977
+ try {
978
+ const before = JSON.stringify(request.openItems);
979
+ const items = [];
980
+ for (const item of request.openItems ?? [])
981
+ items.push(await this.sweepItem(request, item, now, pool, counts));
982
+ const changed = JSON.stringify(items) !== before;
983
+ const allClosed = !items.some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status));
984
+ if (changed || (request.status === 'awaiting_followup' && allClosed))
985
+ await this.save(request, items, false);
986
+ }
987
+ catch (err) {
988
+ this.logger.warn('Open-items sweep step failed', { id: request.id, error: errText(err) });
989
+ }
990
+ }
991
+ if (counts.ready + counts.nudged + counts.ownerNotes + counts.expired + counts.closed > 0) {
992
+ this.logger.info('Open-items sweep', counts);
993
+ }
994
+ return counts;
995
+ });
996
+ }
997
+ /**
998
+ * One item's sweep step.
999
+ */
1000
+ async sweepItem(request, item, now, pool, counts) {
1001
+ if (!ACTIVE_OPEN_ITEM_STATUSES.has(item.status))
1002
+ return item;
1003
+ const nowIso = now.toISOString();
1004
+ if (request.status === 'cancelled') {
1005
+ counts.closed += 1;
1006
+ if (item.workItemId)
1007
+ await this.closeFollowUp(item.workItemId, 'cancelled', 'The ticket was cancelled');
1008
+ if (item.decisionId)
1009
+ await this.deps.cancelQuestion?.(item.decisionId, 'ticket cancelled').catch(() => undefined);
1010
+ return { ...item, status: 'cancelled', closedAt: nowIso, closedReason: 'ticket cancelled' };
1011
+ }
1012
+ if (now.getTime() - Date.parse(item.createdAt) >= OPEN_ITEMS_CONSTANTS.EXPIRE_AFTER_MS) {
1013
+ counts.expired += 1;
1014
+ if (item.workItemId)
1015
+ await this.closeFollowUp(item.workItemId, 'cancelled', 'Expired: nothing happened for 7 days');
1016
+ return { ...item, status: 'expired', closedAt: nowIso, closedReason: 'no activity for 7 days' };
1017
+ }
1018
+ if (item.type === 'question') {
1019
+ if (!item.decisionId || !this.deps.getDecision)
1020
+ return item;
1021
+ const d = await this.deps.getDecision(item.decisionId).catch(() => null);
1022
+ if (!d || d.status === 'open' || d.status === 'parked')
1023
+ return item;
1024
+ const status = itemStatusFor(d);
1025
+ const answer = d.chosenKey ? d.options.find((o) => o.key === d.chosenKey)?.label : d.answerText;
1026
+ counts.closed += 1;
1027
+ return { ...item, status, closedAt: nowIso, closedReason: `${d.id} ${d.status}`, ...(answer ? { answer } : {}) };
1028
+ }
1029
+ if (item.status === 'waiting_owner') {
1030
+ // No due time, no nudges. A card answer opens it; a declined card closes it.
1031
+ if (!item.gateDecisionId || !this.deps.getDecision)
1032
+ return item;
1033
+ const d = await this.deps.getDecision(item.gateDecisionId).catch(() => null);
1034
+ if (!d || d.status === 'open' || d.status === 'parked')
1035
+ return item;
1036
+ if (d.status === 'cancelled' || d.status === 'expired' || (d.chosenKey && d.yesKey && d.chosenKey !== d.yesKey)) {
1037
+ counts.closed += 1;
1038
+ return { ...item, status: 'superseded', closedAt: nowIso, closedReason: `${d.id} ${d.status}: the owner did not agree` };
1039
+ }
1040
+ const at = d.resolvedAt ? new Date(d.resolvedAt) : now;
1041
+ const { due, source } = parseDue(item.text, at);
1042
+ return this.activate(request, { item: { ...item, status: 'open', due: due.toISOString(), dueSource: source } }, pool);
1043
+ }
1044
+ // Commitment whose follow-up WorkItem was finished or cancelled (by anyone,
1045
+ // through the task API): the item is closed with it, so it cannot nudge or be re-armed.
1046
+ const followUp = item.workItemId ? pool.find((w) => w.id === item.workItemId) : undefined;
1047
+ if (followUp && ['done', 'verified', 'cancelled'].includes(followUp.status)) {
1048
+ counts.closed += 1;
1049
+ const delivered = followUp.status !== 'cancelled';
1050
+ return {
1051
+ ...item,
1052
+ status: delivered ? 'delivered' : 'cancelled',
1053
+ closedAt: nowIso,
1054
+ closedReason: `follow-up ${followUp.id.slice(0, 8)} is ${followUp.status}`,
1055
+ };
1056
+ }
1057
+ // Commitment: link child work started after the promise.
1058
+ let current = item;
1059
+ const children = childWorkFor(request, item, pool);
1060
+ if (children.some((id) => !(item.childWorkItemIds ?? []).includes(id))) {
1061
+ current = { ...current, childWorkItemIds: [...new Set([...(item.childWorkItemIds ?? []), ...children])] };
1062
+ }
1063
+ if (current.status === 'open' && (current.childWorkItemIds ?? []).length > 0) {
1064
+ const state = childrenState(current.childWorkItemIds ?? [], pool);
1065
+ if (state.ready) {
1066
+ counts.ready += 1;
1067
+ return this.markReady(request, current, state.finishedAt, state.done);
1068
+ }
1069
+ }
1070
+ const due = current.due ? Date.parse(current.due) : NaN;
1071
+ if (!Number.isFinite(due) || now.getTime() < due)
1072
+ return current;
1073
+ if (!current.nudgedAt) {
1074
+ const ref = followUpReference(request, current);
1075
+ 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. ` +
1076
+ `Deliver it now, or tell the owner plainly when it will come and why. Run: ${followUpCommand(ref)} — Crewly posts it in the ticket's thread.`;
1077
+ const ok = await this.deps.deliverToAgent(current.agent, text).catch(() => false);
1078
+ if (ok)
1079
+ AgentPromptReferenceService.getInstance().note(current.agent, ref, `[FOLLOW-UP ${ticketLabel(request)}]`);
1080
+ counts.nudged += 1;
1081
+ this.logger.info('Overdue promise — agent nudged', { tkt: ticketLabel(request), item: current.id, agent: current.agent, delivered: ok });
1082
+ return { ...current, status: 'overdue', nudgedAt: nowIso };
1083
+ }
1084
+ if (!current.ownerNotifiedAt && now.getTime() - Date.parse(current.nudgedAt) >= OPEN_ITEMS_CONSTANTS.OWNER_NOTE_AFTER_NUDGE_MS) {
1085
+ const note = await this.ownerNote(request, current, pool, now);
1086
+ const ok = await this.deps.postOwnerNote(request, note).catch(() => false);
1087
+ counts.ownerNotes += 1;
1088
+ this.logger.info('Overdue promise — owner told in the thread', { tkt: ticketLabel(request), item: current.id, posted: ok });
1089
+ return ok ? { ...current, status: 'overdue', ownerNotifiedAt: nowIso } : current;
1090
+ }
1091
+ return current;
1092
+ }
1093
+ /**
1094
+ * The owner's note for a late promise: what, by when, and why it is late.
1095
+ *
1096
+ * @param request - Request
1097
+ * @param item - Commitment
1098
+ * @param pool - Every WorkItem
1099
+ * @param now - Clock
1100
+ * @returns Note text (English harness text; the agent's words quoted)
1101
+ */
1102
+ async ownerNote(request, item, pool, now) {
1103
+ const name = (await this.deps.displayName?.(item.agent).catch(() => undefined)) ?? item.agent;
1104
+ const due = item.due ? formatWhen(new Date(item.due), now) : 'today';
1105
+ const state = childrenState(item.childWorkItemIds ?? [], pool);
1106
+ let why;
1107
+ if (state.pending.length > 0) {
1108
+ const parts = [];
1109
+ for (const w of state.pending.slice(0, 2)) {
1110
+ const who = w.target ? ((await this.deps.displayName?.(w.target).catch(() => undefined)) ?? w.target) : 'nobody';
1111
+ parts.push(`${who}'s part ("${short(w.title, 60)}") is still ${w.status.replace(/_/g, ' ')}`);
1112
+ }
1113
+ why = parts.join('; ');
1114
+ }
1115
+ else if (state.ready || item.readyAt) {
1116
+ const at = item.readyAt ? formatWhen(new Date(item.readyAt), now) : 'earlier';
1117
+ why = `the work was ready at ${at}, but ${name} hasn't posted it`;
1118
+ }
1119
+ else {
1120
+ why = `${name} hasn't posted it yet`;
1121
+ }
1122
+ return `${name} promised "${short(item.text, 160)}" by ${due}. It hasn't arrived: ${why}. ${name} has been reminded.`;
1123
+ }
1124
+ async closeFollowUp(workItemId, outcome, reason) {
1125
+ await this.deps.closeFollowUp?.(workItemId, outcome, reason).catch((err) => this.logger.debug('Follow-up WorkItem not closed', { workItemId, error: errText(err) }));
1126
+ }
1127
+ }
1128
+ /**
1129
+ * The open-item status a settled decision leaves its question in.
1130
+ *
1131
+ * @param d - Decision
1132
+ * @returns Status (`open` while it is still pending)
1133
+ */
1134
+ export function itemStatusFor(d) {
1135
+ switch (d.status) {
1136
+ case 'resolved':
1137
+ case 'defaulted':
1138
+ return 'resolved';
1139
+ case 'cancelled':
1140
+ return 'superseded';
1141
+ case 'expired':
1142
+ return 'expired';
1143
+ case 'skipped':
1144
+ return 'skipped';
1145
+ default:
1146
+ return 'open';
1147
+ }
1148
+ }
1149
+ /**
1150
+ * Message of an unknown error.
1151
+ *
1152
+ * @param err - Thrown value
1153
+ * @returns Text
1154
+ */
1155
+ function errText(err) {
1156
+ return err instanceof Error ? err.message : String(err);
1157
+ }
1158
+ //# sourceMappingURL=open-items.service.js.map