crewly 1.20.179 → 1.20.180

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 (145) hide show
  1. package/config/roles/team-leader/prompt.md +2 -2
  2. package/config/roles/team-leader/tl-addon.md +2 -2
  3. package/config/skills/agent/core/ask-owner/SKILL.md +79 -0
  4. package/config/skills/agent/core/ask-owner/execute.sh +66 -0
  5. package/config/skills/agent/core/ask-owner/execute.test.sh +62 -0
  6. package/config/skills/agent/core/project-tickets/SKILL.md +21 -9
  7. package/config/skills/agent/core/project-tickets/execute.sh +28 -9
  8. package/config/skills/agent/core/project-tickets/execute.test.sh +12 -4
  9. package/config/skills/agent/core/reply/SKILL.md +22 -5
  10. package/config/skills/agent/core/reply/execute.sh +12 -5
  11. package/config/skills/agent/core/reply/execute.test.sh +8 -0
  12. package/config/skills/agent/core/schedule-followup/SKILL.md +8 -0
  13. package/config/skills/agent/core/schedule-followup/execute.sh +7 -0
  14. package/config/slack-app-manifest.json +5 -1
  15. package/config/slack-master-app-manifest.json +7 -2
  16. package/dist/backend/backend/src/constants.d.ts +76 -5
  17. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  18. package/dist/backend/backend/src/constants.js +83 -5
  19. package/dist/backend/backend/src/constants.js.map +1 -1
  20. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts +3 -0
  21. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
  22. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +54 -1
  23. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
  24. package/dist/backend/backend/src/controllers/decisions/decisions.controller.d.ts +67 -0
  25. package/dist/backend/backend/src/controllers/decisions/decisions.controller.d.ts.map +1 -0
  26. package/dist/backend/backend/src/controllers/decisions/decisions.controller.js +215 -0
  27. package/dist/backend/backend/src/controllers/decisions/decisions.controller.js.map +1 -0
  28. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts +8 -5
  29. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts.map +1 -1
  30. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js +51 -12
  31. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js.map +1 -1
  32. package/dist/backend/backend/src/index.d.ts +4 -0
  33. package/dist/backend/backend/src/index.d.ts.map +1 -1
  34. package/dist/backend/backend/src/index.js +88 -2
  35. package/dist/backend/backend/src/index.js.map +1 -1
  36. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  37. package/dist/backend/backend/src/routes/api.routes.js +6 -0
  38. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  39. package/dist/backend/backend/src/services/boot/boot-announce.service.d.ts +1 -1
  40. package/dist/backend/backend/src/services/boot/boot-announce.service.js +13 -13
  41. package/dist/backend/backend/src/services/boot/boot-announce.service.js.map +1 -1
  42. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.service.js +6 -6
  43. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.service.js.map +1 -1
  44. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.d.ts +3 -3
  45. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.js +14 -13
  47. package/dist/backend/backend/src/services/cloud/cloud-disconnect-notice.utils.js.map +1 -1
  48. package/dist/backend/backend/src/services/decisions/decision-card.d.ts +147 -0
  49. package/dist/backend/backend/src/services/decisions/decision-card.d.ts.map +1 -0
  50. package/dist/backend/backend/src/services/decisions/decision-card.js +311 -0
  51. package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -0
  52. package/dist/backend/backend/src/services/decisions/decision-contract.d.ts +83 -0
  53. package/dist/backend/backend/src/services/decisions/decision-contract.d.ts.map +1 -0
  54. package/dist/backend/backend/src/services/decisions/decision-contract.js +226 -0
  55. package/dist/backend/backend/src/services/decisions/decision-contract.js.map +1 -0
  56. package/dist/backend/backend/src/services/decisions/decision-routing.d.ts +34 -0
  57. package/dist/backend/backend/src/services/decisions/decision-routing.d.ts.map +1 -0
  58. package/dist/backend/backend/src/services/decisions/decision-routing.js +64 -0
  59. package/dist/backend/backend/src/services/decisions/decision-routing.js.map +1 -0
  60. package/dist/backend/backend/src/services/decisions/decision-store.d.ts +80 -0
  61. package/dist/backend/backend/src/services/decisions/decision-store.d.ts.map +1 -0
  62. package/dist/backend/backend/src/services/decisions/decision-store.js +151 -0
  63. package/dist/backend/backend/src/services/decisions/decision-store.js.map +1 -0
  64. package/dist/backend/backend/src/services/decisions/decision.service.d.ts +281 -0
  65. package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -0
  66. package/dist/backend/backend/src/services/decisions/decision.service.js +639 -0
  67. package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -0
  68. package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts +70 -0
  69. package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -0
  70. package/dist/backend/backend/src/services/decisions/decision.wiring.js +172 -0
  71. package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -0
  72. package/dist/backend/backend/src/services/decisions/ticket-thread-store.d.ts +97 -0
  73. package/dist/backend/backend/src/services/decisions/ticket-thread-store.d.ts.map +1 -0
  74. package/dist/backend/backend/src/services/decisions/ticket-thread-store.js +137 -0
  75. package/dist/backend/backend/src/services/decisions/ticket-thread-store.js.map +1 -0
  76. package/dist/backend/backend/src/services/orc/work-item-destination.d.ts +165 -0
  77. package/dist/backend/backend/src/services/orc/work-item-destination.d.ts.map +1 -0
  78. package/dist/backend/backend/src/services/orc/work-item-destination.js +239 -0
  79. package/dist/backend/backend/src/services/orc/work-item-destination.js.map +1 -0
  80. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts +134 -0
  81. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts.map +1 -0
  82. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js +217 -0
  83. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js.map +1 -0
  84. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.d.ts +1 -30
  85. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.d.ts.map +1 -1
  86. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.js +1 -22
  87. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.js.map +1 -1
  88. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.d.ts +2 -15
  89. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.d.ts.map +1 -1
  90. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.js +8 -22
  91. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.js.map +1 -1
  92. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts +11 -12
  93. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts.map +1 -1
  94. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js +27 -52
  95. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js.map +1 -1
  96. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts +6 -0
  97. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts.map +1 -1
  98. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js +3 -1
  99. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js.map +1 -1
  100. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +12 -12
  101. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  102. package/dist/backend/backend/src/services/slack/slack.service.d.ts +16 -1
  103. package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
  104. package/dist/backend/backend/src/services/slack/slack.service.js +56 -2
  105. package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
  106. package/dist/backend/backend/src/services/system/auto-update.service.js +2 -2
  107. package/dist/backend/backend/src/services/system/auto-update.service.js.map +1 -1
  108. package/dist/backend/backend/src/services/system/auto-update.utils.js +2 -2
  109. package/dist/backend/backend/src/services/system/auto-update.utils.js.map +1 -1
  110. package/dist/backend/backend/src/types/decision.types.d.ts +139 -0
  111. package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -0
  112. package/dist/backend/backend/src/types/decision.types.js +13 -0
  113. package/dist/backend/backend/src/types/decision.types.js.map +1 -0
  114. package/dist/backend/backend/src/types/slack.types.d.ts +30 -0
  115. package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
  116. package/dist/backend/backend/src/types/slack.types.js.map +1 -1
  117. package/dist/backend/backend/src/types/v2/trigger.types.d.ts +17 -0
  118. package/dist/backend/backend/src/types/v2/trigger.types.d.ts.map +1 -1
  119. package/dist/backend/backend/src/types/v2/trigger.types.js +17 -0
  120. package/dist/backend/backend/src/types/v2/trigger.types.js.map +1 -1
  121. package/dist/backend/build-info.json +2 -2
  122. package/dist/cli/backend/src/constants.d.ts +76 -5
  123. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  124. package/dist/cli/backend/src/constants.js +83 -5
  125. package/dist/cli/backend/src/constants.js.map +1 -1
  126. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +12 -12
  127. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  128. package/dist/cli/backend/src/services/slack/slack.service.d.ts +16 -1
  129. package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
  130. package/dist/cli/backend/src/services/slack/slack.service.js +56 -2
  131. package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
  132. package/dist/cli/backend/src/services/system/auto-update.utils.js +2 -2
  133. package/dist/cli/backend/src/services/system/auto-update.utils.js.map +1 -1
  134. package/dist/cli/backend/src/types/slack.types.d.ts +30 -0
  135. package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
  136. package/dist/cli/backend/src/types/slack.types.js.map +1 -1
  137. package/dist/cli/backend/src/types/v2/trigger.types.d.ts +17 -0
  138. package/dist/cli/backend/src/types/v2/trigger.types.d.ts.map +1 -1
  139. package/dist/cli/backend/src/types/v2/trigger.types.js +17 -0
  140. package/dist/cli/backend/src/types/v2/trigger.types.js.map +1 -1
  141. package/frontend/dist/assets/index-04d192f8.css +42 -0
  142. package/frontend/dist/assets/{index-69af46c3.js → index-4271e95e.js} +259 -259
  143. package/frontend/dist/index.html +2 -2
  144. package/package.json +1 -1
  145. package/frontend/dist/assets/index-cdc52122.css +0 -42
@@ -0,0 +1,639 @@
1
+ /**
2
+ * Decision cards (specs/2026-10-01-decision-cards.md).
3
+ *
4
+ * The responsible agent asks the owner ONE structured question; its own
5
+ * Slack bot posts a Block Kit card in the team channel (the ticket's thread,
6
+ * or a new thread), and the owner answers with a button, a reaction, a
7
+ * thread reply or the dashboard. The answer updates the card in place, goes
8
+ * into the ticket log, is delivered to the asking agent and clears the
9
+ * owner-message watchdog for that thread. At the deadline the default is
10
+ * applied — except for sensitive asks, which are re-asked once and parked.
11
+ *
12
+ * Every collaborator is injected ({@link DecisionServiceDeps}); the wiring
13
+ * with the real Slack / tickets / agents lives in `decision.wiring.ts`.
14
+ *
15
+ * @module services/decisions/decision.service
16
+ */
17
+ import { DECISION_CONSTANTS, ORCHESTRATOR_SESSION_NAME } from '../../constants.js';
18
+ import { LoggerService } from '../core/logger.service.js';
19
+ import { DecisionContractError, validateAskOwner } from './decision-contract.js';
20
+ import { cardFallbackText, choiceFromReaction, choiceFromText, deadlineDefaultLine, defaultLabel, formatWhen, optionLabel, parseButtonValue, renderOpenCard, renderSettledCard, settledLine, ticketThreadRootText, } from './decision-card.js';
21
+ import { PENDING_DECISION_STATUSES } from './decision-store.js';
22
+ /** Thrown for requests the caller must fix (HTTP 4xx). */
23
+ export class DecisionError extends Error {
24
+ status;
25
+ constructor(status, message) {
26
+ super(message);
27
+ this.status = status;
28
+ this.name = 'DecisionError';
29
+ }
30
+ }
31
+ /**
32
+ * Next local `hour`:00 strictly tomorrow.
33
+ *
34
+ * @param now - Clock
35
+ * @param hour - Local hour
36
+ * @returns Date
37
+ */
38
+ function tomorrowAt(now, hour) {
39
+ const d = new Date(now.getTime());
40
+ d.setDate(d.getDate() + 1);
41
+ d.setHours(hour, 0, 0, 0);
42
+ return d;
43
+ }
44
+ /**
45
+ * Decision cards service.
46
+ */
47
+ export class DecisionService {
48
+ static instance = null;
49
+ deps;
50
+ logger;
51
+ now;
52
+ timer = null;
53
+ ticking = false;
54
+ /**
55
+ * @param deps - Collaborators
56
+ */
57
+ constructor(deps) {
58
+ this.deps = deps;
59
+ this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('DecisionCards');
60
+ this.now = deps.now ?? (() => new Date());
61
+ }
62
+ /** @returns The process-wide instance, or null before wiring */
63
+ static getInstance() {
64
+ return DecisionService.instance;
65
+ }
66
+ /** @param service - Instance to install (null clears) */
67
+ static setInstance(service) {
68
+ DecisionService.instance = service;
69
+ }
70
+ /** Start the deadline / reminder tick. */
71
+ start() {
72
+ if (this.timer)
73
+ return;
74
+ this.timer = setInterval(() => void this.tick(), DECISION_CONSTANTS.TICK_MS);
75
+ this.timer.unref?.();
76
+ }
77
+ /** Stop the tick. */
78
+ stop() {
79
+ if (this.timer)
80
+ clearInterval(this.timer);
81
+ this.timer = null;
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Asking
85
+ // ---------------------------------------------------------------------------
86
+ /**
87
+ * Ask the owner. Validates the contract, stores the decision and posts the
88
+ * card as the responsible agent's own bot.
89
+ *
90
+ * @param callerSession - Agent calling ask-owner (undefined = owner / dashboard)
91
+ * @param input - The ask
92
+ * @returns The stored decision (with `card`, or `postError` when Slack refused; retried on the tick)
93
+ * @throws DecisionError(400) for a contract violation, other 4xx from ticket resolution
94
+ */
95
+ async ask(callerSession, input) {
96
+ let ask;
97
+ try {
98
+ ask = validateAskOwner(input, this.now());
99
+ }
100
+ catch (err) {
101
+ if (err instanceof DecisionContractError)
102
+ throw new DecisionError(400, err.message);
103
+ throw err;
104
+ }
105
+ let asker = callerSession;
106
+ let ticket;
107
+ let teamId;
108
+ if (ask.ticketId && ask.project) {
109
+ const ctx = await this.deps.resolveTicket(ask.project, ask.ticketId, callerSession);
110
+ asker = ctx.asker;
111
+ teamId = ctx.teamId;
112
+ ticket = { projectId: ctx.projectId, projectPath: ctx.projectPath, projectName: ctx.projectName, id: ctx.id, title: ctx.title };
113
+ }
114
+ if (!asker)
115
+ throw new DecisionError(400, 'Who is asking? Run ask-owner from an agent session, or name a --ticket.');
116
+ if (!teamId)
117
+ teamId = await this.deps.teamOf(asker).catch(() => undefined);
118
+ const workItemId = callerSession ? await this.deps.currentWorkItemId?.(callerSession).catch(() => undefined) : undefined;
119
+ const decision = await this.deps.store.create({
120
+ question: ask.question,
121
+ options: ask.options,
122
+ defaultKey: ask.defaultKey,
123
+ deadline: ask.deadline.toISOString(),
124
+ ...(ask.sensitive ? { sensitive: ask.sensitive } : {}),
125
+ requestedBy: callerSession ?? 'owner',
126
+ asker,
127
+ ...(ticket ? { ticket } : {}),
128
+ ...(teamId ? { teamId } : {}),
129
+ ...(workItemId ? { workItemId } : {}),
130
+ status: 'open',
131
+ });
132
+ if (ticket && ask.ticketId && ask.project) {
133
+ await this.deps
134
+ .markTicketAsked({ ...ticket, asker, teamId }, ask.question, decision.id)
135
+ .catch((err) => this.logger.warn('Could not mark the ticket as waiting on the owner', { decisionId: decision.id, error: errText(err) }));
136
+ }
137
+ this.logger.info('Owner decision asked', { decisionId: decision.id, asker, requestedBy: decision.requestedBy, ticket: ticket?.id, sensitive: decision.sensitive });
138
+ return this.postCard(decision);
139
+ }
140
+ /**
141
+ * Post (or re-try posting) the card of an open decision.
142
+ *
143
+ * @param decision - Decision without a card
144
+ * @returns The decision with `card` or `postError`
145
+ */
146
+ async postCard(decision) {
147
+ const slack = this.deps.slack();
148
+ if (!slack || !slack.isConnected()) {
149
+ return (await this.deps.store.update(decision.id, () => ({ postError: 'Slack is not connected' }))) ?? decision;
150
+ }
151
+ try {
152
+ const identity = await this.deps.identityOf(decision.asker);
153
+ const place = await this.placeFor(decision, identity, slack);
154
+ const blocks = renderOpenCard(decision, this.deps.instanceId(), this.now());
155
+ const { ts, ownBot } = await this.send(slack, identity, {
156
+ channelId: place.slackChannelId,
157
+ text: cardFallbackText(decision),
158
+ blocks,
159
+ ...(place.threadTs ? { threadTs: place.threadTs } : {}),
160
+ });
161
+ const updated = await this.deps.store.update(decision.id, () => ({
162
+ card: {
163
+ slackChannelId: place.slackChannelId,
164
+ messageTs: ts,
165
+ ...(place.threadTs ? { threadTs: place.threadTs } : {}),
166
+ postedBy: ownBot ? decision.asker : 'crewly',
167
+ ownBot,
168
+ },
169
+ ...(place.teamId && !decision.teamId ? { teamId: place.teamId } : {}),
170
+ postError: undefined,
171
+ }));
172
+ this.logger.info('Decision card posted', { decisionId: decision.id, channel: place.slackChannelId, threaded: !!place.threadTs, ownBot });
173
+ return updated ?? decision;
174
+ }
175
+ catch (err) {
176
+ const msg = err instanceof DecisionError ? err.message : errText(err);
177
+ this.logger.warn('Decision card not posted (retried on the next tick)', { decisionId: decision.id, error: msg });
178
+ return (await this.deps.store.update(decision.id, () => ({ postError: msg }))) ?? decision;
179
+ }
180
+ }
181
+ /**
182
+ * Where the card goes: the ticket's thread (created on first use), else the
183
+ * asker's current work destination, else a new thread in its team channel.
184
+ */
185
+ async placeFor(decision, identity, slack) {
186
+ if (decision.ticket) {
187
+ const t = decision.ticket;
188
+ const existing = await this.deps.threads.get(t.projectPath, t.id);
189
+ if (existing)
190
+ return { slackChannelId: existing.slackChannelId, threadTs: existing.threadTs, teamId: existing.teamId };
191
+ const channel = decision.teamId ? await this.deps.teamChannelOf(decision.teamId) : null;
192
+ if (!channel)
193
+ throw new DecisionError(409, `No Slack team channel for ${t.id}'s team — link the team to Slack first`);
194
+ const { ts } = await this.send(slack, identity, { channelId: channel, text: ticketThreadRootText(t) });
195
+ const thread = await this.deps.threads.set(t.projectPath, t.id, { slackChannelId: channel, threadTs: ts, ...(decision.teamId ? { teamId: decision.teamId } : {}) });
196
+ return { slackChannelId: thread.slackChannelId, threadTs: thread.threadTs, teamId: thread.teamId };
197
+ }
198
+ const work = await this.deps.workDestination?.(decision.asker).catch(() => null);
199
+ if (work?.slackChannelId)
200
+ return work;
201
+ const channel = decision.teamId ? await this.deps.teamChannelOf(decision.teamId) : null;
202
+ if (!channel)
203
+ throw new DecisionError(409, `No Slack place to ask in: ${decision.asker} has no team channel and no Slack conversation in hand`);
204
+ return { slackChannelId: channel, ...(decision.teamId ? { teamId: decision.teamId } : {}) };
205
+ }
206
+ /**
207
+ * Send as the agent's own bot; when Slack refuses that bot (not in the
208
+ * channel, revoked), fall back to the shared bot with the agent's name.
209
+ */
210
+ async send(slack, identity, message) {
211
+ const base = { ...message, skipChatV2Mirror: true };
212
+ if (identity.botToken) {
213
+ try {
214
+ return { ts: await slack.sendMessage({ ...base, botToken: identity.botToken }), ownBot: true };
215
+ }
216
+ catch (err) {
217
+ this.logger.warn("Agent's own bot could not post the decision card — using the shared bot", { channel: message.channelId, error: errText(err) });
218
+ }
219
+ }
220
+ const { botToken: _drop, ...shared } = identity;
221
+ return { ts: await slack.sendMessage({ ...base, ...shared }), ownBot: false };
222
+ }
223
+ // ---------------------------------------------------------------------------
224
+ // Answers
225
+ // ---------------------------------------------------------------------------
226
+ /**
227
+ * A Slack `block_actions` payload (Cloud relay, Socket Mode or the HTTP
228
+ * endpoint). Ignores anything that is not one of this instance's cards.
229
+ *
230
+ * @param payload - Slack payload
231
+ * @returns What happened
232
+ */
233
+ async handleInteraction(payload) {
234
+ const action = payload?.actions?.[0];
235
+ if (!action?.action_id?.startsWith(DECISION_CONSTANTS.ACTION_PREFIX))
236
+ return { handled: false, reason: 'not a decision action' };
237
+ const value = parseButtonValue(action.value);
238
+ if (!value)
239
+ return { handled: false, reason: 'unreadable button value' };
240
+ const self = this.deps.instanceId();
241
+ if (value.i && self && value.i !== self)
242
+ return { handled: false, reason: `card belongs to instance ${value.i}` };
243
+ const decision = await this.deps.store.get(value.d);
244
+ if (!decision)
245
+ return { handled: false, reason: `unknown decision ${value.d}` };
246
+ const channel = payload.container?.channel_id ?? payload.channel?.id;
247
+ const ts = payload.container?.message_ts ?? payload.message?.ts;
248
+ if (!decision.card || decision.card.slackChannelId !== channel || decision.card.messageTs !== ts) {
249
+ return { handled: false, reason: 'click is not on the stored card' };
250
+ }
251
+ const user = payload.user?.id ?? '';
252
+ if (!user || !this.deps.isOwner(user)) {
253
+ this.logger.info('Decision click by someone other than the owner — ignored', { decisionId: decision.id, user });
254
+ return { handled: false, reason: 'not the owner', decision };
255
+ }
256
+ if (decision.status !== 'open')
257
+ return { handled: false, reason: `already ${decision.status}`, decision };
258
+ const choice = value.o === 'remind' ? { kind: 'remind' } : { kind: 'option', key: value.o };
259
+ return this.apply(decision, choice, 'button', user);
260
+ }
261
+ /**
262
+ * A reaction on a card: ✅ default/first, ❌ the "no" option, ⏰ remind.
263
+ *
264
+ * @param event - `reaction_added` event
265
+ * @returns What happened
266
+ */
267
+ async handleReaction(event) {
268
+ const channel = event?.item?.channel;
269
+ const ts = event?.item?.ts;
270
+ if (!channel || !ts || !event.reaction)
271
+ return { handled: false, reason: 'not a message reaction' };
272
+ const decision = await this.deps.store.findByCard(channel, ts);
273
+ if (!decision)
274
+ return { handled: false, reason: 'not a decision card' };
275
+ if (decision.status !== 'open')
276
+ return { handled: false, reason: `already ${decision.status}`, decision };
277
+ if (!event.user || !this.deps.isOwner(event.user))
278
+ return { handled: false, reason: 'not the owner', decision };
279
+ const choice = choiceFromReaction(decision, event.reaction);
280
+ if (!choice)
281
+ return { handled: false, reason: `reaction :${event.reaction}: means nothing here`, decision };
282
+ return this.apply(decision, choice, 'reaction', event.user);
283
+ }
284
+ /**
285
+ * An owner reply in a card's thread. The newest open card in that thread
286
+ * takes it; words that match no option are passed to the asker verbatim.
287
+ *
288
+ * @param message - Inbound Slack message
289
+ * @returns What happened
290
+ */
291
+ async handleThreadReply(message) {
292
+ if (!message.threadTs || message.threadTs === message.ts)
293
+ return { handled: false, reason: 'not a thread reply' };
294
+ if (message.authorAgentSession)
295
+ return { handled: false, reason: 'written by an agent' };
296
+ if (!message.userId || !this.deps.isOwner(message.userId))
297
+ return { handled: false, reason: 'not the owner' };
298
+ const candidates = await this.deps.store.list((d) => d.status === 'open' &&
299
+ !!d.card &&
300
+ d.card.slackChannelId === message.channelId &&
301
+ (d.card.threadTs === message.threadTs || d.card.messageTs === message.threadTs));
302
+ const decision = candidates[0];
303
+ if (!decision)
304
+ return { handled: false, reason: 'no open card in this thread' };
305
+ const choice = choiceFromText(decision, message.text ?? '');
306
+ if (!choice)
307
+ return { handled: false, reason: 'empty reply', decision };
308
+ return this.apply(decision, choice, 'reply', message.userId);
309
+ }
310
+ /**
311
+ * The owner answered from the dashboard.
312
+ *
313
+ * @param id - Decision id
314
+ * @param optionKey - Option key (or label / number)
315
+ * @returns The resolved decision
316
+ * @throws DecisionError(404/409/400)
317
+ */
318
+ async chooseFromDashboard(id, optionKey) {
319
+ const decision = await this.requirePending(id);
320
+ const opt = decision.options.find((o) => o.key === optionKey) ?? decision.options.find((o) => o.label.toLowerCase() === String(optionKey).toLowerCase());
321
+ if (!opt)
322
+ throw new DecisionError(400, `"${optionKey}" is not an option of ${id} (${decision.options.map((o) => o.key).join(', ')})`);
323
+ const out = await this.apply(decision, { kind: 'option', key: opt.key }, 'dashboard', this.deps.ownerUserId?.() ?? undefined);
324
+ return out.decision ?? decision;
325
+ }
326
+ /**
327
+ * "Remind me tomorrow" from the dashboard.
328
+ *
329
+ * @param id - Decision id
330
+ * @returns The snoozed decision
331
+ */
332
+ async remindFromDashboard(id) {
333
+ const decision = await this.requirePending(id);
334
+ const out = await this.apply(decision, { kind: 'remind' }, 'dashboard', this.deps.ownerUserId?.() ?? undefined);
335
+ return out.decision ?? decision;
336
+ }
337
+ /**
338
+ * Withdraw open decisions (the asker no longer needs an answer, or the
339
+ * ticket's mark was cleared).
340
+ *
341
+ * @param filter - Which decisions
342
+ * @param note - Why (ticket log)
343
+ * @returns How many were withdrawn
344
+ */
345
+ async cancelWhere(filter, note) {
346
+ const open = await this.deps.store.list((d) => PENDING_DECISION_STATUSES.has(d.status) && filter(d));
347
+ for (const d of open) {
348
+ const done = await this.deps.store.update(d.id, (cur) => (PENDING_DECISION_STATUSES.has(cur.status) ? { status: 'cancelled', resolvedAt: this.now().toISOString() } : null));
349
+ if (!done)
350
+ continue;
351
+ await this.refreshCard(done);
352
+ this.logger.info('Owner decision withdrawn', { decisionId: d.id, note });
353
+ }
354
+ return open.length;
355
+ }
356
+ /**
357
+ * List decisions.
358
+ *
359
+ * @param which - `open` (open + parked) or `all`
360
+ * @returns Decisions, newest first
361
+ */
362
+ async list(which = 'open') {
363
+ const all = await this.deps.store.list(which === 'open' ? (d) => PENDING_DECISION_STATUSES.has(d.status) : undefined);
364
+ return all.slice(0, DECISION_CONSTANTS.MAX_LISTED);
365
+ }
366
+ /**
367
+ * One decision.
368
+ *
369
+ * @param id - Decision id
370
+ * @returns Decision or null
371
+ */
372
+ get(id) {
373
+ return this.deps.store.get(id);
374
+ }
375
+ async requirePending(id) {
376
+ const decision = await this.deps.store.get(id);
377
+ if (!decision)
378
+ throw new DecisionError(404, `Decision ${id} not found`);
379
+ if (!PENDING_DECISION_STATUSES.has(decision.status))
380
+ throw new DecisionError(409, `Decision ${id} is already ${decision.status}`);
381
+ return decision;
382
+ }
383
+ /**
384
+ * Apply a choice: resolve (or snooze), update the card, log the ticket,
385
+ * tell the asker, close the watchdog entry.
386
+ */
387
+ async apply(decision, choice, via, user) {
388
+ const now = this.now();
389
+ if (choice.kind === 'remind') {
390
+ const remindAt = tomorrowAt(now, DECISION_CONSTANTS.REMIND_HOUR_LOCAL);
391
+ const minDeadline = remindAt.getTime() + DECISION_CONSTANTS.REMIND_GRACE_MS;
392
+ const updated = await this.deps.store.update(decision.id, (cur) => PENDING_DECISION_STATUSES.has(cur.status)
393
+ ? {
394
+ status: 'open',
395
+ remindAt: remindAt.toISOString(),
396
+ deadline: new Date(Math.max(Date.parse(cur.deadline), minDeadline)).toISOString(),
397
+ // A parked sensitive ask that is snoozed gets its re-ask cycle back.
398
+ reaskedAt: cur.status === 'parked' ? undefined : cur.reaskedAt,
399
+ }
400
+ : null);
401
+ if (!updated)
402
+ return { handled: false, reason: 'already settled', decision };
403
+ await this.refreshCard(updated);
404
+ if (updated.ticket)
405
+ await this.logTicket(updated, `owner decision ${updated.id}: remind tomorrow (${via})`, false);
406
+ this.logger.info('Owner decision snoozed to tomorrow', { decisionId: decision.id, via });
407
+ return { handled: true, reason: 'snoozed', decision: updated };
408
+ }
409
+ const patch = choice.kind === 'option'
410
+ ? { status: 'resolved', chosenKey: choice.key, answerText: undefined }
411
+ : { status: 'resolved', chosenKey: undefined, answerText: choice.text.slice(0, 2000) };
412
+ const resolved = await this.deps.store.update(decision.id, (cur) => PENDING_DECISION_STATUSES.has(cur.status)
413
+ ? { ...patch, answeredVia: via, ...(user ? { answeredBy: user } : {}), resolvedAt: now.toISOString(), remindAt: undefined }
414
+ : null);
415
+ if (!resolved)
416
+ return { handled: false, reason: 'already settled', decision };
417
+ await this.refreshCard(resolved);
418
+ const answer = resolved.chosenKey ? optionLabel(resolved, resolved.chosenKey) : `“${resolved.answerText}”`;
419
+ if (resolved.ticket)
420
+ await this.logTicket(resolved, `owner decision ${resolved.id}: ${answer} (${via})`, true);
421
+ await this.tellAsker(resolved, this.answerNote(resolved));
422
+ this.closeWatchdog(resolved);
423
+ this.logger.info('Owner decision resolved', { decisionId: resolved.id, via, chosen: resolved.chosenKey ?? 'text' });
424
+ return { handled: true, reason: 'resolved', decision: resolved };
425
+ }
426
+ /** The note the asker receives when the owner answered. */
427
+ answerNote(d) {
428
+ const where = this.whereLine(d);
429
+ const about = `for: "${d.question}"${d.ticket ? ` (ticket ${d.ticket.id})` : ''}`;
430
+ if (d.chosenKey) {
431
+ return `[DECISION ${d.id}] The owner chose "${optionLabel(d, d.chosenKey)}" ${about}. Act on it now.${where}`;
432
+ }
433
+ return `[DECISION ${d.id}] The owner answered in words ${about}: "${d.answerText ?? ''}". Read it as their decision and act on it; if it is genuinely unclear, ask once more with ask-owner.${where}`;
434
+ }
435
+ /** Where the asker's follow-up belongs. */
436
+ whereLine(d) {
437
+ if (!d.card)
438
+ return '';
439
+ const thread = d.card.threadTs ?? d.card.messageTs;
440
+ return ` Post any update in the card's thread (--thread ${d.card.slackChannelId}:${thread}).`;
441
+ }
442
+ // ---------------------------------------------------------------------------
443
+ // Deadlines and reminders
444
+ // ---------------------------------------------------------------------------
445
+ /**
446
+ * One pass: retry unposted cards, post due reminders, apply defaults at
447
+ * the deadline, re-ask / park sensitive asks, prune old decisions.
448
+ *
449
+ * @returns Ids acted on
450
+ */
451
+ async tick() {
452
+ if (this.ticking)
453
+ return [];
454
+ this.ticking = true;
455
+ const acted = [];
456
+ try {
457
+ const now = this.now();
458
+ for (const d of await this.deps.store.list((x) => x.status === 'open')) {
459
+ try {
460
+ if (!d.card) {
461
+ // A card Slack refused is retried every few minutes, not every tick.
462
+ if (d.postError && now.getTime() - Date.parse(d.updatedAt) < DECISION_CONSTANTS.POST_RETRY_MS)
463
+ continue;
464
+ if ((await this.postCard(d)).card)
465
+ acted.push(d.id);
466
+ continue;
467
+ }
468
+ if (d.remindAt && Date.parse(d.remindAt) <= now.getTime()) {
469
+ await this.remindNow(d);
470
+ acted.push(d.id);
471
+ continue;
472
+ }
473
+ if (Date.parse(d.deadline) > now.getTime())
474
+ continue;
475
+ if (d.sensitive) {
476
+ if (await this.sensitiveStep(d, now))
477
+ acted.push(d.id);
478
+ }
479
+ else if (await this.applyDefault(d, now)) {
480
+ acted.push(d.id);
481
+ }
482
+ }
483
+ catch (err) {
484
+ this.logger.warn('Decision tick step failed', { decisionId: d.id, error: errText(err) });
485
+ }
486
+ }
487
+ await this.deps.store.prune().catch(() => 0);
488
+ }
489
+ finally {
490
+ this.ticking = false;
491
+ }
492
+ return acted;
493
+ }
494
+ /** Post the "Remind me tomorrow" reminder in the card's thread. */
495
+ async remindNow(d) {
496
+ const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && cur.remindAt ? { remindAt: undefined } : null));
497
+ if (!updated)
498
+ return;
499
+ const owner = this.deps.ownerUserId?.();
500
+ await this.postInThread(updated, `${owner ? `<@${owner}> ` : ''}Reminder: ${updated.question} (answer on the card above)`);
501
+ await this.refreshCard(updated);
502
+ }
503
+ /** Non-sensitive deadline: apply the default (or say it keeps waiting). */
504
+ async applyDefault(d, now) {
505
+ const line = deadlineDefaultLine(d, now);
506
+ if (d.defaultKey === DECISION_CONSTANTS.WAIT_DEFAULT) {
507
+ if (d.deadlineNoticeAt)
508
+ return false;
509
+ const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.deadlineNoticeAt ? { deadlineNoticeAt: now.toISOString() } : null));
510
+ if (!updated)
511
+ return false;
512
+ await this.postInThread(updated, line);
513
+ if (updated.ticket)
514
+ await this.logTicket(updated, `owner decision ${updated.id}: no answer by the deadline — still waiting`, false);
515
+ await this.tellAsker(updated, `[DECISION ${updated.id}] No answer by the deadline for: "${updated.question}". The default is to wait — keep this work parked until the owner answers.${this.whereLine(updated)}`);
516
+ return true;
517
+ }
518
+ const resolved = await this.deps.store.update(d.id, (cur) => cur.status === 'open' ? { status: 'defaulted', chosenKey: cur.defaultKey, answeredVia: 'deadline', resolvedAt: now.toISOString() } : null);
519
+ if (!resolved)
520
+ return false;
521
+ await this.refreshCard(resolved);
522
+ await this.postInThread(resolved, line);
523
+ if (resolved.ticket)
524
+ await this.logTicket(resolved, `owner decision ${resolved.id}: no answer — default "${defaultLabel(resolved)}" applied`, true);
525
+ await this.tellAsker(resolved, `[DECISION ${resolved.id}] No answer by the deadline for: "${resolved.question}"${resolved.ticket ? ` (ticket ${resolved.ticket.id})` : ''}. Going with the default: "${defaultLabel(resolved)}". Act on it now.${this.whereLine(resolved)}`);
526
+ this.closeWatchdog(resolved);
527
+ this.logger.info('Owner decision defaulted at the deadline', { decisionId: resolved.id, chosen: resolved.chosenKey });
528
+ return true;
529
+ }
530
+ /** Sensitive deadline: re-ask once after 24 h, then park. Never auto-applied. */
531
+ async sensitiveStep(d, now) {
532
+ if (!d.reaskedAt) {
533
+ const due = Math.max(Date.parse(d.deadline), Date.parse(d.createdAt) + DECISION_CONSTANTS.SENSITIVE_REASK_AFTER_MS);
534
+ if (now.getTime() < due)
535
+ return false;
536
+ const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.reaskedAt ? { reaskedAt: now.toISOString() } : null));
537
+ if (!updated)
538
+ return false;
539
+ const owner = this.deps.ownerUserId?.();
540
+ await this.postInThread(updated, `${owner ? `<@${owner}> ` : ''}Still need your answer: ${updated.question} This needs your OK (${updated.sensitive}), so I'm not going ahead without it.`);
541
+ await this.refreshCard(updated);
542
+ this.logger.info('Sensitive owner decision re-asked', { decisionId: d.id });
543
+ return true;
544
+ }
545
+ if (now.getTime() < Date.parse(d.reaskedAt) + DECISION_CONSTANTS.SENSITIVE_PARK_AFTER_REASK_MS)
546
+ return false;
547
+ const parked = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' ? { status: 'parked' } : null));
548
+ if (!parked)
549
+ return false;
550
+ await this.refreshCard(parked);
551
+ if (parked.ticket)
552
+ await this.logTicket(parked, `owner decision ${parked.id}: no answer after a re-ask — parked (sensitive: ${parked.sensitive})`, false);
553
+ await this.tellAsker(parked, `[DECISION ${parked.id}] No answer to: "${parked.question}" even after a re-ask. It needs the owner's OK (${parked.sensitive}), so it is PARKED: do not do it. Move on to other work; you will get a [DECISION] message if the owner answers.`);
554
+ this.logger.info('Sensitive owner decision parked', { decisionId: d.id });
555
+ return true;
556
+ }
557
+ // ---------------------------------------------------------------------------
558
+ // Slack helpers
559
+ // ---------------------------------------------------------------------------
560
+ /** Re-render the card for the decision's current state (with the posting bot's token). */
561
+ async refreshCard(d) {
562
+ const slack = this.deps.slack();
563
+ if (!d.card || !slack)
564
+ return;
565
+ const now = this.now();
566
+ const pending = d.status === 'open';
567
+ let ownerName;
568
+ if (!pending && d.answeredBy)
569
+ ownerName = await this.deps.userName?.(d.answeredBy).catch(() => undefined);
570
+ const blocks = pending ? renderOpenCard(d, this.deps.instanceId(), now) : renderSettledCard(d, ownerName, now);
571
+ const text = pending ? cardFallbackText(d) : `${cardFallbackText(d)} — ${settledLine(d, ownerName, now)}`;
572
+ const token = d.card.ownBot ? (await this.deps.identityOf(d.card.postedBy).catch(() => ({}))).botToken : undefined;
573
+ try {
574
+ await slack.updateMessage(d.card.slackChannelId, d.card.messageTs, text, blocks, token);
575
+ }
576
+ catch (err) {
577
+ this.logger.warn('Could not update the decision card', { decisionId: d.id, error: errText(err) });
578
+ }
579
+ }
580
+ /** Post a line in the card's thread, as the asker. */
581
+ async postInThread(d, text) {
582
+ const slack = this.deps.slack();
583
+ if (!d.card || !slack || !slack.isConnected())
584
+ return;
585
+ try {
586
+ const identity = await this.deps.identityOf(d.asker);
587
+ await this.send(slack, identity, { channelId: d.card.slackChannelId, text, threadTs: d.card.threadTs ?? d.card.messageTs });
588
+ }
589
+ catch (err) {
590
+ this.logger.warn('Could not post in the decision thread', { decisionId: d.id, error: errText(err) });
591
+ }
592
+ }
593
+ async logTicket(d, line, settled) {
594
+ if (!d.ticket)
595
+ return;
596
+ let clear = false;
597
+ if (settled) {
598
+ const others = await this.deps.store.list((x) => x.id !== d.id && PENDING_DECISION_STATUSES.has(x.status) && x.ticket?.projectPath === d.ticket?.projectPath && x.ticket?.id === d.ticket?.id);
599
+ clear = others.length === 0;
600
+ }
601
+ await this.deps.logTicket(d.ticket, line, clear).catch((err) => this.logger.warn('Could not log the decision on the ticket', { decisionId: d.id, error: errText(err) }));
602
+ }
603
+ async tellAsker(d, text) {
604
+ const ok = await this.deps.deliverToAgent(d.asker, text).catch(() => false);
605
+ if (!ok)
606
+ this.logger.warn('Could not deliver the decision to the asking agent', { decisionId: d.id, asker: d.asker });
607
+ // The orchestrator asked on the owner's behalf for a ticket it does not own: tell it too.
608
+ if (d.requestedBy !== d.asker && d.requestedBy === ORCHESTRATOR_SESSION_NAME) {
609
+ await this.deps.deliverToAgent(d.requestedBy, text).catch(() => false);
610
+ }
611
+ }
612
+ closeWatchdog(d) {
613
+ if (!d.card || !this.deps.closeWatchdog)
614
+ return;
615
+ try {
616
+ this.deps.closeWatchdog(d.asker, d.card.slackChannelId, d.card.threadTs ?? d.card.messageTs);
617
+ }
618
+ catch {
619
+ /* best-effort */
620
+ }
621
+ }
622
+ }
623
+ /**
624
+ * Message of an unknown error.
625
+ *
626
+ * @param err - Thrown value
627
+ * @returns Text
628
+ */
629
+ function errText(err) {
630
+ if (err && typeof err === 'object') {
631
+ const data = err.data;
632
+ if (typeof data?.error === 'string')
633
+ return data.error;
634
+ }
635
+ return err instanceof Error ? err.message : String(err);
636
+ }
637
+ /** Re-exported for the deadline line in the dashboard/API. */
638
+ export { formatWhen };
639
+ //# sourceMappingURL=decision.service.js.map