crewly 1.20.180 → 1.20.182

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 (157) hide show
  1. package/config/skills/agent/remote-browser/SKILL.md +28 -8
  2. package/dist/backend/backend/src/constants.d.ts +89 -1
  3. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  4. package/dist/backend/backend/src/constants.js +99 -2
  5. package/dist/backend/backend/src/constants.js.map +1 -1
  6. package/dist/backend/backend/src/controllers/browser/browser.controller.d.ts +2 -1
  7. package/dist/backend/backend/src/controllers/browser/browser.controller.d.ts.map +1 -1
  8. package/dist/backend/backend/src/controllers/browser/browser.controller.js +15 -1
  9. package/dist/backend/backend/src/controllers/browser/browser.controller.js.map +1 -1
  10. package/dist/backend/backend/src/controllers/system/runtime-fallback.controller.d.ts +11 -1
  11. package/dist/backend/backend/src/controllers/system/runtime-fallback.controller.d.ts.map +1 -1
  12. package/dist/backend/backend/src/controllers/system/runtime-fallback.controller.js +44 -9
  13. package/dist/backend/backend/src/controllers/system/runtime-fallback.controller.js.map +1 -1
  14. package/dist/backend/backend/src/controllers/system/runtime-terms.controller.d.ts +31 -0
  15. package/dist/backend/backend/src/controllers/system/runtime-terms.controller.d.ts.map +1 -0
  16. package/dist/backend/backend/src/controllers/system/runtime-terms.controller.js +99 -0
  17. package/dist/backend/backend/src/controllers/system/runtime-terms.controller.js.map +1 -0
  18. package/dist/backend/backend/src/index.d.ts.map +1 -1
  19. package/dist/backend/backend/src/index.js +51 -0
  20. package/dist/backend/backend/src/index.js.map +1 -1
  21. package/dist/backend/backend/src/routes/modules/system.routes.d.ts.map +1 -1
  22. package/dist/backend/backend/src/routes/modules/system.routes.js +3 -0
  23. package/dist/backend/backend/src/routes/modules/system.routes.js.map +1 -1
  24. package/dist/backend/backend/src/services/agent/antigravity-runtime.service.d.ts.map +1 -1
  25. package/dist/backend/backend/src/services/agent/antigravity-runtime.service.js +5 -0
  26. package/dist/backend/backend/src/services/agent/antigravity-runtime.service.js.map +1 -1
  27. package/dist/backend/backend/src/services/browser/browser-approval.service.d.ts +202 -0
  28. package/dist/backend/backend/src/services/browser/browser-approval.service.d.ts.map +1 -0
  29. package/dist/backend/backend/src/services/browser/browser-approval.service.js +533 -0
  30. package/dist/backend/backend/src/services/browser/browser-approval.service.js.map +1 -0
  31. package/dist/backend/backend/src/services/browser/browser-bridge.service.d.ts +24 -0
  32. package/dist/backend/backend/src/services/browser/browser-bridge.service.d.ts.map +1 -1
  33. package/dist/backend/backend/src/services/browser/browser-bridge.service.js +55 -0
  34. package/dist/backend/backend/src/services/browser/browser-bridge.service.js.map +1 -1
  35. package/dist/backend/backend/src/services/browser/browser-session.service.d.ts +51 -0
  36. package/dist/backend/backend/src/services/browser/browser-session.service.d.ts.map +1 -1
  37. package/dist/backend/backend/src/services/browser/browser-session.service.js +82 -3
  38. package/dist/backend/backend/src/services/browser/browser-session.service.js.map +1 -1
  39. package/dist/backend/backend/src/services/browser/held-action-store.d.ts +125 -0
  40. package/dist/backend/backend/src/services/browser/held-action-store.d.ts.map +1 -0
  41. package/dist/backend/backend/src/services/browser/held-action-store.js +141 -0
  42. package/dist/backend/backend/src/services/browser/held-action-store.js.map +1 -0
  43. package/dist/backend/backend/src/services/decisions/decision-card.d.ts +23 -5
  44. package/dist/backend/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  45. package/dist/backend/backend/src/services/decisions/decision-card.js +56 -9
  46. package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -1
  47. package/dist/backend/backend/src/services/decisions/decision.service.d.ts +96 -1
  48. package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  49. package/dist/backend/backend/src/services/decisions/decision.service.js +190 -8
  50. package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
  51. package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -1
  52. package/dist/backend/backend/src/services/decisions/decision.wiring.js +7 -0
  53. package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -1
  54. package/dist/backend/backend/src/services/runtime-fallback/runtime-availability.d.ts +8 -0
  55. package/dist/backend/backend/src/services/runtime-fallback/runtime-availability.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/runtime-fallback/runtime-availability.js +5 -0
  57. package/dist/backend/backend/src/services/runtime-fallback/runtime-availability.js.map +1 -1
  58. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.service.d.ts +2 -0
  59. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.service.d.ts.map +1 -1
  60. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.service.js +4 -0
  61. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.service.js.map +1 -1
  62. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.types.d.ts +2 -0
  63. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.types.d.ts.map +1 -1
  64. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.types.js.map +1 -1
  65. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.wiring.d.ts.map +1 -1
  66. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.wiring.js +3 -0
  67. package/dist/backend/backend/src/services/runtime-fallback/runtime-fallback.wiring.js.map +1 -1
  68. package/dist/backend/backend/src/services/runtime-fallback/runtime-smoke-test.service.d.ts +15 -1
  69. package/dist/backend/backend/src/services/runtime-fallback/runtime-smoke-test.service.d.ts.map +1 -1
  70. package/dist/backend/backend/src/services/runtime-fallback/runtime-smoke-test.service.js +13 -2
  71. package/dist/backend/backend/src/services/runtime-fallback/runtime-smoke-test.service.js.map +1 -1
  72. package/dist/backend/backend/src/services/runtime-terms/antigravity-terms-driver.d.ts +92 -0
  73. package/dist/backend/backend/src/services/runtime-terms/antigravity-terms-driver.d.ts.map +1 -0
  74. package/dist/backend/backend/src/services/runtime-terms/antigravity-terms-driver.js +199 -0
  75. package/dist/backend/backend/src/services/runtime-terms/antigravity-terms-driver.js.map +1 -0
  76. package/dist/backend/backend/src/services/runtime-terms/antigravity-terms-screens.d.ts +79 -0
  77. package/dist/backend/backend/src/services/runtime-terms/antigravity-terms-screens.d.ts.map +1 -0
  78. package/dist/backend/backend/src/services/runtime-terms/antigravity-terms-screens.js +145 -0
  79. package/dist/backend/backend/src/services/runtime-terms/antigravity-terms-screens.js.map +1 -0
  80. package/dist/backend/backend/src/services/runtime-terms/fake-agy-tui.fixture.d.ts +75 -0
  81. package/dist/backend/backend/src/services/runtime-terms/fake-agy-tui.fixture.d.ts.map +1 -0
  82. package/dist/backend/backend/src/services/runtime-terms/fake-agy-tui.fixture.js +218 -0
  83. package/dist/backend/backend/src/services/runtime-terms/fake-agy-tui.fixture.js.map +1 -0
  84. package/dist/backend/backend/src/services/runtime-terms/runtime-terms-consent.service.d.ts +245 -0
  85. package/dist/backend/backend/src/services/runtime-terms/runtime-terms-consent.service.d.ts.map +1 -0
  86. package/dist/backend/backend/src/services/runtime-terms/runtime-terms-consent.service.js +428 -0
  87. package/dist/backend/backend/src/services/runtime-terms/runtime-terms-consent.service.js.map +1 -0
  88. package/dist/backend/backend/src/services/runtime-terms/runtime-terms.store.d.ts +67 -0
  89. package/dist/backend/backend/src/services/runtime-terms/runtime-terms.store.d.ts.map +1 -0
  90. package/dist/backend/backend/src/services/runtime-terms/runtime-terms.store.js +78 -0
  91. package/dist/backend/backend/src/services/runtime-terms/runtime-terms.store.js.map +1 -0
  92. package/dist/backend/backend/src/services/runtime-terms/runtime-terms.wiring.d.ts +39 -0
  93. package/dist/backend/backend/src/services/runtime-terms/runtime-terms.wiring.d.ts.map +1 -0
  94. package/dist/backend/backend/src/services/runtime-terms/runtime-terms.wiring.js +161 -0
  95. package/dist/backend/backend/src/services/runtime-terms/runtime-terms.wiring.js.map +1 -0
  96. package/dist/backend/backend/src/types/decision.types.d.ts +55 -2
  97. package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
  98. package/dist/backend/build-info.json +2 -2
  99. package/dist/cli/backend/src/constants.d.ts +89 -1
  100. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  101. package/dist/cli/backend/src/constants.js +99 -2
  102. package/dist/cli/backend/src/constants.js.map +1 -1
  103. package/dist/cli/backend/src/services/agent/antigravity-runtime.service.d.ts.map +1 -1
  104. package/dist/cli/backend/src/services/agent/antigravity-runtime.service.js +5 -0
  105. package/dist/cli/backend/src/services/agent/antigravity-runtime.service.js.map +1 -1
  106. package/dist/cli/backend/src/services/decisions/decision-card.d.ts +165 -0
  107. package/dist/cli/backend/src/services/decisions/decision-card.d.ts.map +1 -0
  108. package/dist/cli/backend/src/services/decisions/decision-card.js +358 -0
  109. package/dist/cli/backend/src/services/decisions/decision-card.js.map +1 -0
  110. package/dist/cli/backend/src/services/decisions/decision-contract.d.ts +83 -0
  111. package/dist/cli/backend/src/services/decisions/decision-contract.d.ts.map +1 -0
  112. package/dist/cli/backend/src/services/decisions/decision-contract.js +226 -0
  113. package/dist/cli/backend/src/services/decisions/decision-contract.js.map +1 -0
  114. package/dist/cli/backend/src/services/decisions/decision-store.d.ts +80 -0
  115. package/dist/cli/backend/src/services/decisions/decision-store.d.ts.map +1 -0
  116. package/dist/cli/backend/src/services/decisions/decision-store.js +151 -0
  117. package/dist/cli/backend/src/services/decisions/decision-store.js.map +1 -0
  118. package/dist/cli/backend/src/services/decisions/decision.service.d.ts +376 -0
  119. package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -0
  120. package/dist/cli/backend/src/services/decisions/decision.service.js +821 -0
  121. package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -0
  122. package/dist/cli/backend/src/services/decisions/ticket-thread-store.d.ts +97 -0
  123. package/dist/cli/backend/src/services/decisions/ticket-thread-store.d.ts.map +1 -0
  124. package/dist/cli/backend/src/services/decisions/ticket-thread-store.js +137 -0
  125. package/dist/cli/backend/src/services/decisions/ticket-thread-store.js.map +1 -0
  126. package/dist/cli/backend/src/services/runtime-fallback/runtime-fallback.types.d.ts +136 -0
  127. package/dist/cli/backend/src/services/runtime-fallback/runtime-fallback.types.d.ts.map +1 -0
  128. package/dist/cli/backend/src/services/runtime-fallback/runtime-fallback.types.js +152 -0
  129. package/dist/cli/backend/src/services/runtime-fallback/runtime-fallback.types.js.map +1 -0
  130. package/dist/cli/backend/src/services/runtime-fallback/runtime-smoke-test.service.d.ts +217 -0
  131. package/dist/cli/backend/src/services/runtime-fallback/runtime-smoke-test.service.d.ts.map +1 -0
  132. package/dist/cli/backend/src/services/runtime-fallback/runtime-smoke-test.service.js +430 -0
  133. package/dist/cli/backend/src/services/runtime-fallback/runtime-smoke-test.service.js.map +1 -0
  134. package/dist/cli/backend/src/services/runtime-terms/antigravity-terms-driver.d.ts +92 -0
  135. package/dist/cli/backend/src/services/runtime-terms/antigravity-terms-driver.d.ts.map +1 -0
  136. package/dist/cli/backend/src/services/runtime-terms/antigravity-terms-driver.js +199 -0
  137. package/dist/cli/backend/src/services/runtime-terms/antigravity-terms-driver.js.map +1 -0
  138. package/dist/cli/backend/src/services/runtime-terms/antigravity-terms-screens.d.ts +79 -0
  139. package/dist/cli/backend/src/services/runtime-terms/antigravity-terms-screens.d.ts.map +1 -0
  140. package/dist/cli/backend/src/services/runtime-terms/antigravity-terms-screens.js +145 -0
  141. package/dist/cli/backend/src/services/runtime-terms/antigravity-terms-screens.js.map +1 -0
  142. package/dist/cli/backend/src/services/runtime-terms/runtime-terms-consent.service.d.ts +245 -0
  143. package/dist/cli/backend/src/services/runtime-terms/runtime-terms-consent.service.d.ts.map +1 -0
  144. package/dist/cli/backend/src/services/runtime-terms/runtime-terms-consent.service.js +428 -0
  145. package/dist/cli/backend/src/services/runtime-terms/runtime-terms-consent.service.js.map +1 -0
  146. package/dist/cli/backend/src/services/runtime-terms/runtime-terms.store.d.ts +67 -0
  147. package/dist/cli/backend/src/services/runtime-terms/runtime-terms.store.d.ts.map +1 -0
  148. package/dist/cli/backend/src/services/runtime-terms/runtime-terms.store.js +78 -0
  149. package/dist/cli/backend/src/services/runtime-terms/runtime-terms.store.js.map +1 -0
  150. package/dist/cli/backend/src/types/decision.types.d.ts +192 -0
  151. package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -0
  152. package/dist/cli/backend/src/types/decision.types.js +13 -0
  153. package/dist/cli/backend/src/types/decision.types.js.map +1 -0
  154. package/dist/cli/cli/src/commands/harness-setup.js +2 -2
  155. package/frontend/dist/assets/{index-4271e95e.js → index-9c91de50.js} +138 -138
  156. package/frontend/dist/index.html +1 -1
  157. package/package.json +1 -1
@@ -0,0 +1,821 @@
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, matchOption, parseOptions, resolveDefault, validateAskOwner } from './decision-contract.js';
20
+ import { canRemind, cardFallbackText, choiceFromReaction, choiceFromText, deadlineDefaultLine, defaultIsSafe, defaultLabel, formatWhen, optionLabel, parseButtonValue, renderOpenCard, renderSettledCard, settledLine, noOption, 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
+ /** Handlers per kind (process-wide: the browser side may start before the service). */
32
+ const KIND_HANDLERS = new Map();
33
+ /**
34
+ * Next local `hour`:00 strictly tomorrow.
35
+ *
36
+ * @param now - Clock
37
+ * @param hour - Local hour
38
+ * @returns Date
39
+ */
40
+ function tomorrowAt(now, hour) {
41
+ const d = new Date(now.getTime());
42
+ d.setDate(d.getDate() + 1);
43
+ d.setHours(hour, 0, 0, 0);
44
+ return d;
45
+ }
46
+ /**
47
+ * Decision cards service.
48
+ */
49
+ export class DecisionService {
50
+ static instance = null;
51
+ deps;
52
+ logger;
53
+ now;
54
+ timer = null;
55
+ ticking = false;
56
+ /**
57
+ * @param deps - Collaborators
58
+ */
59
+ constructor(deps) {
60
+ this.deps = deps;
61
+ this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('DecisionCards');
62
+ this.now = deps.now ?? (() => new Date());
63
+ }
64
+ /** @returns The process-wide instance, or null before wiring */
65
+ static getInstance() {
66
+ return DecisionService.instance;
67
+ }
68
+ /** @param service - Instance to install (null clears) */
69
+ static setInstance(service) {
70
+ DecisionService.instance = service;
71
+ }
72
+ /**
73
+ * Register the handler of a decision kind.
74
+ *
75
+ * @param kind - Decision kind
76
+ * @param handler - Handler (null removes it)
77
+ */
78
+ static registerKindHandler(kind, handler) {
79
+ if (handler)
80
+ KIND_HANDLERS.set(kind, handler);
81
+ else
82
+ KIND_HANDLERS.delete(kind);
83
+ }
84
+ /** Start the deadline / reminder tick. */
85
+ start() {
86
+ if (this.timer)
87
+ return;
88
+ this.timer = setInterval(() => void this.tick(), DECISION_CONSTANTS.TICK_MS);
89
+ this.timer.unref?.();
90
+ }
91
+ /** Stop the tick. */
92
+ stop() {
93
+ if (this.timer)
94
+ clearInterval(this.timer);
95
+ this.timer = null;
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Asking
99
+ // ---------------------------------------------------------------------------
100
+ /**
101
+ * Ask the owner. Validates the contract, stores the decision and posts the
102
+ * card as the responsible agent's own bot.
103
+ *
104
+ * @param callerSession - Agent calling ask-owner (undefined = owner / dashboard)
105
+ * @param input - The ask
106
+ * @returns The stored decision (with `card`, or `postError` when Slack refused; retried on the tick)
107
+ * @throws DecisionError(400) for a contract violation, other 4xx from ticket resolution
108
+ */
109
+ async ask(callerSession, input) {
110
+ let ask;
111
+ try {
112
+ ask = validateAskOwner(input, this.now());
113
+ }
114
+ catch (err) {
115
+ if (err instanceof DecisionContractError)
116
+ throw new DecisionError(400, err.message);
117
+ throw err;
118
+ }
119
+ let asker = callerSession;
120
+ let ticket;
121
+ let teamId;
122
+ if (ask.ticketId && ask.project) {
123
+ const ctx = await this.deps.resolveTicket(ask.project, ask.ticketId, callerSession);
124
+ asker = ctx.asker;
125
+ teamId = ctx.teamId;
126
+ ticket = { projectId: ctx.projectId, projectPath: ctx.projectPath, projectName: ctx.projectName, id: ctx.id, title: ctx.title };
127
+ }
128
+ if (!asker)
129
+ throw new DecisionError(400, 'Who is asking? Run ask-owner from an agent session, or name a --ticket.');
130
+ if (!teamId)
131
+ teamId = await this.deps.teamOf(asker).catch(() => undefined);
132
+ const workItemId = callerSession ? await this.deps.currentWorkItemId?.(callerSession).catch(() => undefined) : undefined;
133
+ const decision = await this.deps.store.create({
134
+ question: ask.question,
135
+ options: ask.options,
136
+ defaultKey: ask.defaultKey,
137
+ deadline: ask.deadline.toISOString(),
138
+ ...(ask.sensitive ? { sensitive: ask.sensitive } : {}),
139
+ requestedBy: callerSession ?? 'owner',
140
+ asker,
141
+ ...(ticket ? { ticket } : {}),
142
+ ...(teamId ? { teamId } : {}),
143
+ ...(workItemId ? { workItemId } : {}),
144
+ status: 'open',
145
+ });
146
+ if (ticket && ask.ticketId && ask.project) {
147
+ await this.deps
148
+ .markTicketAsked({ ...ticket, asker, teamId }, ask.question, decision.id)
149
+ .catch((err) => this.logger.warn('Could not mark the ticket as waiting on the owner', { decisionId: decision.id, error: errText(err) }));
150
+ }
151
+ this.logger.info('Owner decision asked', { decisionId: decision.id, asker, requestedBy: decision.requestedBy, ticket: ticket?.id, sensitive: decision.sensitive });
152
+ return this.postCard(decision);
153
+ }
154
+ /**
155
+ * Ask the owner on the harness's own behalf (no agent): the card goes to
156
+ * the owner's DM with this machine's orc bot, and the answer goes to the
157
+ * kind's handler only — no agent is told or woken.
158
+ *
159
+ * @param input - The ask
160
+ * @returns The stored decision (with `card`, or `postError`; retried on the tick)
161
+ * @throws DecisionError(400) for bad options / default / deadline
162
+ */
163
+ async askSystem(input) {
164
+ let options;
165
+ let defaultKey;
166
+ try {
167
+ options = parseOptions(input.options);
168
+ defaultKey = resolveDefault(input.default, options);
169
+ }
170
+ catch (err) {
171
+ if (err instanceof DecisionContractError)
172
+ throw new DecisionError(400, err.message);
173
+ throw err;
174
+ }
175
+ if (input.deadline.getTime() <= this.now().getTime())
176
+ throw new DecisionError(400, 'deadline is in the past');
177
+ const decision = await this.deps.store.create({
178
+ kind: input.kind,
179
+ question: input.question.replace(/\s+/g, ' ').trim().slice(0, DECISION_CONSTANTS.QUESTION_MAX_CHARS),
180
+ options,
181
+ defaultKey,
182
+ deadline: input.deadline.toISOString(),
183
+ ...(input.sensitive ? { sensitive: input.sensitive } : {}),
184
+ requestedBy: 'crewly',
185
+ asker: ORCHESTRATOR_SESSION_NAME,
186
+ system: input.system,
187
+ title: input.title,
188
+ ...(input.body?.length ? { body: input.body } : {}),
189
+ status: 'open',
190
+ });
191
+ this.logger.info('System decision asked', { decisionId: decision.id, kind: input.kind, key: input.system.key });
192
+ return this.postCard(decision);
193
+ }
194
+ /**
195
+ * Ask a question Crewly built itself (e.g. a held browser action). The card
196
+ * goes where an ask-owner card without a ticket goes: the thread of the
197
+ * agent's current work item, else its team channel — from its own bot.
198
+ *
199
+ * @param ask - The prebuilt question
200
+ * @returns The stored decision (with `card`, or `postError`; retried on the tick)
201
+ */
202
+ async askPrebuilt(ask) {
203
+ const teamId = await this.deps.teamOf(ask.asker).catch(() => undefined);
204
+ const workItemId = await this.deps.currentWorkItemId?.(ask.asker).catch(() => undefined);
205
+ const decision = await this.deps.store.create({
206
+ kind: ask.kind,
207
+ question: ask.question,
208
+ options: ask.options,
209
+ defaultKey: ask.defaultKey,
210
+ ...(ask.yesKey ? { yesKey: ask.yesKey } : {}),
211
+ deadline: ask.deadline.toISOString(),
212
+ ...(ask.sensitive ? { sensitive: ask.sensitive } : {}),
213
+ ...(ask.browser ? { browser: ask.browser } : {}),
214
+ requestedBy: ask.asker,
215
+ asker: ask.asker,
216
+ ...(teamId ? { teamId } : {}),
217
+ ...(workItemId ? { workItemId } : {}),
218
+ status: 'open',
219
+ });
220
+ this.logger.info('Owner decision asked by Crewly', { decisionId: decision.id, kind: ask.kind, asker: ask.asker });
221
+ return this.postCard(decision);
222
+ }
223
+ /**
224
+ * Post a line in a decision's card thread (as the asker's bot).
225
+ *
226
+ * @param id - Decision id
227
+ * @param text - mrkdwn text
228
+ * @returns True when there was a card to reply to
229
+ */
230
+ async replyInThread(id, text) {
231
+ const d = await this.deps.store.get(id);
232
+ if (!d?.card)
233
+ return false;
234
+ await this.postInThread(d, text);
235
+ return true;
236
+ }
237
+ /**
238
+ * Mark an open decision as expired: what it asked about is gone. The card
239
+ * says so, and the kind's handler tells the agent.
240
+ *
241
+ * @param id - Decision id
242
+ * @returns The expired decision, or null when it was not pending
243
+ */
244
+ async expire(id) {
245
+ const expired = await this.deps.store.update(id, (cur) => PENDING_DECISION_STATUSES.has(cur.status) ? { status: 'expired', resolvedAt: this.now().toISOString(), remindAt: undefined } : null);
246
+ if (!expired)
247
+ return null;
248
+ await this.refreshCard(expired);
249
+ if (expired.ticket)
250
+ await this.logTicket(expired, `owner decision ${expired.id}: expired`, true);
251
+ await this.notifyAsker(expired, null);
252
+ this.logger.info('Owner decision expired', { decisionId: id });
253
+ return expired;
254
+ }
255
+ /**
256
+ * Post (or re-try posting) the card of an open decision.
257
+ *
258
+ * @param decision - Decision without a card
259
+ * @returns The decision with `card` or `postError`
260
+ */
261
+ async postCard(decision) {
262
+ const slack = this.deps.slack();
263
+ if (!slack || !slack.isConnected()) {
264
+ return (await this.deps.store.update(decision.id, () => ({ postError: 'Slack is not connected' }))) ?? decision;
265
+ }
266
+ try {
267
+ const identity = await this.deps.identityOf(decision.asker);
268
+ const place = await this.placeFor(decision, identity, slack);
269
+ const blocks = renderOpenCard(decision, this.deps.instanceId(), this.now());
270
+ const { ts, ownBot } = await this.send(slack, identity, {
271
+ channelId: place.slackChannelId,
272
+ text: cardFallbackText(decision),
273
+ blocks,
274
+ ...(place.threadTs ? { threadTs: place.threadTs } : {}),
275
+ });
276
+ const updated = await this.deps.store.update(decision.id, () => ({
277
+ card: {
278
+ slackChannelId: place.slackChannelId,
279
+ messageTs: ts,
280
+ ...(place.threadTs ? { threadTs: place.threadTs } : {}),
281
+ postedBy: ownBot ? decision.asker : 'crewly',
282
+ ownBot,
283
+ },
284
+ ...(place.teamId && !decision.teamId ? { teamId: place.teamId } : {}),
285
+ postError: undefined,
286
+ }));
287
+ this.logger.info('Decision card posted', { decisionId: decision.id, channel: place.slackChannelId, threaded: !!place.threadTs, ownBot });
288
+ return updated ?? decision;
289
+ }
290
+ catch (err) {
291
+ const msg = err instanceof DecisionError ? err.message : errText(err);
292
+ this.logger.warn('Decision card not posted (retried on the next tick)', { decisionId: decision.id, error: msg });
293
+ return (await this.deps.store.update(decision.id, () => ({ postError: msg }))) ?? decision;
294
+ }
295
+ }
296
+ /**
297
+ * Where the card goes: the ticket's thread (created on first use), else the
298
+ * asker's current work destination, else a new thread in its team channel.
299
+ */
300
+ async placeFor(decision, identity, slack) {
301
+ if (decision.system) {
302
+ const dm = await this.deps.ownerDmOf?.(identity);
303
+ if (!dm)
304
+ throw new DecisionError(409, "No Slack DM with the owner from this machine's orc bot");
305
+ return { slackChannelId: dm };
306
+ }
307
+ if (decision.ticket) {
308
+ const t = decision.ticket;
309
+ const existing = await this.deps.threads.get(t.projectPath, t.id);
310
+ if (existing)
311
+ return { slackChannelId: existing.slackChannelId, threadTs: existing.threadTs, teamId: existing.teamId };
312
+ const channel = decision.teamId ? await this.deps.teamChannelOf(decision.teamId) : null;
313
+ if (!channel)
314
+ throw new DecisionError(409, `No Slack team channel for ${t.id}'s team — link the team to Slack first`);
315
+ const { ts } = await this.send(slack, identity, { channelId: channel, text: ticketThreadRootText(t) });
316
+ const thread = await this.deps.threads.set(t.projectPath, t.id, { slackChannelId: channel, threadTs: ts, ...(decision.teamId ? { teamId: decision.teamId } : {}) });
317
+ return { slackChannelId: thread.slackChannelId, threadTs: thread.threadTs, teamId: thread.teamId };
318
+ }
319
+ const work = await this.deps.workDestination?.(decision.asker).catch(() => null);
320
+ if (work?.slackChannelId)
321
+ return work;
322
+ const channel = decision.teamId ? await this.deps.teamChannelOf(decision.teamId) : null;
323
+ if (!channel)
324
+ throw new DecisionError(409, `No Slack place to ask in: ${decision.asker} has no team channel and no Slack conversation in hand`);
325
+ return { slackChannelId: channel, ...(decision.teamId ? { teamId: decision.teamId } : {}) };
326
+ }
327
+ /**
328
+ * Send as the agent's own bot; when Slack refuses that bot (not in the
329
+ * channel, revoked), fall back to the shared bot with the agent's name.
330
+ */
331
+ async send(slack, identity, message) {
332
+ const base = { ...message, skipChatV2Mirror: true };
333
+ if (identity.botToken) {
334
+ try {
335
+ return { ts: await slack.sendMessage({ ...base, botToken: identity.botToken }), ownBot: true };
336
+ }
337
+ catch (err) {
338
+ this.logger.warn("Agent's own bot could not post the decision card — using the shared bot", { channel: message.channelId, error: errText(err) });
339
+ }
340
+ }
341
+ const { botToken: _drop, ...shared } = identity;
342
+ return { ts: await slack.sendMessage({ ...base, ...shared }), ownBot: false };
343
+ }
344
+ // ---------------------------------------------------------------------------
345
+ // Answers
346
+ // ---------------------------------------------------------------------------
347
+ /**
348
+ * A Slack `block_actions` payload (Cloud relay, Socket Mode or the HTTP
349
+ * endpoint). Ignores anything that is not one of this instance's cards.
350
+ *
351
+ * @param payload - Slack payload
352
+ * @returns What happened
353
+ */
354
+ async handleInteraction(payload) {
355
+ const action = payload?.actions?.[0];
356
+ if (!action?.action_id?.startsWith(DECISION_CONSTANTS.ACTION_PREFIX))
357
+ return { handled: false, reason: 'not a decision action' };
358
+ const value = parseButtonValue(action.value);
359
+ if (!value)
360
+ return { handled: false, reason: 'unreadable button value' };
361
+ const self = this.deps.instanceId();
362
+ if (value.i && self && value.i !== self)
363
+ return { handled: false, reason: `card belongs to instance ${value.i}` };
364
+ const decision = await this.deps.store.get(value.d);
365
+ if (!decision)
366
+ return { handled: false, reason: `unknown decision ${value.d}` };
367
+ const channel = payload.container?.channel_id ?? payload.channel?.id;
368
+ const ts = payload.container?.message_ts ?? payload.message?.ts;
369
+ if (!decision.card || decision.card.slackChannelId !== channel || decision.card.messageTs !== ts) {
370
+ return { handled: false, reason: 'click is not on the stored card' };
371
+ }
372
+ const user = payload.user?.id ?? '';
373
+ if (!user || !this.deps.isOwner(user)) {
374
+ this.logger.info('Decision click by someone other than the owner — ignored', { decisionId: decision.id, user });
375
+ return { handled: false, reason: 'not the owner', decision };
376
+ }
377
+ if (decision.status !== 'open')
378
+ return { handled: false, reason: `already ${decision.status}`, decision };
379
+ const choice = value.o === 'remind' ? { kind: 'remind' } : { kind: 'option', key: value.o };
380
+ return this.apply(decision, choice, 'button', user);
381
+ }
382
+ /**
383
+ * A reaction on a card: ✅ default/first, ❌ the "no" option, ⏰ remind.
384
+ *
385
+ * @param event - `reaction_added` event
386
+ * @returns What happened
387
+ */
388
+ async handleReaction(event) {
389
+ const channel = event?.item?.channel;
390
+ const ts = event?.item?.ts;
391
+ if (!channel || !ts || !event.reaction)
392
+ return { handled: false, reason: 'not a message reaction' };
393
+ const decision = await this.deps.store.findByCard(channel, ts);
394
+ if (!decision)
395
+ return { handled: false, reason: 'not a decision card' };
396
+ if (decision.status !== 'open')
397
+ return { handled: false, reason: `already ${decision.status}`, decision };
398
+ if (!event.user || !this.deps.isOwner(event.user))
399
+ return { handled: false, reason: 'not the owner', decision };
400
+ const choice = choiceFromReaction(decision, event.reaction);
401
+ if (!choice)
402
+ return { handled: false, reason: `reaction :${event.reaction}: means nothing here`, decision };
403
+ // A system decision takes only an unambiguous answer: ❌ = its "no" option.
404
+ const name = event.reaction.replace(/::skin-tone-\d$/, '');
405
+ if (decision.system && !(DECISION_CONSTANTS.REACTION_REJECT.includes(name) && choice.kind === 'option' && choice.key === noOption(decision.options)?.key)) {
406
+ return { handled: false, reason: 'system decisions take a button, an option name or ❌', decision };
407
+ }
408
+ return this.apply(decision, choice, 'reaction', event.user);
409
+ }
410
+ /**
411
+ * An owner reply in a card's thread. The newest open card in that thread
412
+ * takes it; words that match no option are passed to the asker verbatim.
413
+ *
414
+ * @param message - Inbound Slack message
415
+ * @returns What happened
416
+ */
417
+ async handleThreadReply(message) {
418
+ if (!message.threadTs || message.threadTs === message.ts)
419
+ return { handled: false, reason: 'not a thread reply' };
420
+ if (message.authorAgentSession)
421
+ return { handled: false, reason: 'written by an agent' };
422
+ if (!message.userId || !this.deps.isOwner(message.userId))
423
+ return { handled: false, reason: 'not the owner' };
424
+ const candidates = await this.deps.store.list((d) => d.status === 'open' &&
425
+ !!d.card &&
426
+ d.card.slackChannelId === message.channelId &&
427
+ (d.card.threadTs === message.threadTs || d.card.messageTs === message.threadTs));
428
+ const decision = candidates[0];
429
+ if (!decision)
430
+ return { handled: false, reason: 'no open card in this thread' };
431
+ const choice = decision.system ? systemChoiceFromText(decision, message.text ?? '') : choiceFromText(decision, message.text ?? '');
432
+ if (!choice)
433
+ return { handled: false, reason: decision.system ? 'not one of the options' : 'empty reply', decision };
434
+ return this.apply(decision, choice, 'reply', message.userId);
435
+ }
436
+ /**
437
+ * The owner answered from the dashboard.
438
+ *
439
+ * @param id - Decision id
440
+ * @param optionKey - Option key (or label / number)
441
+ * @returns The resolved decision
442
+ * @throws DecisionError(404/409/400)
443
+ */
444
+ async chooseFromDashboard(id, optionKey) {
445
+ const decision = await this.requirePending(id);
446
+ const opt = decision.options.find((o) => o.key === optionKey) ?? decision.options.find((o) => o.label.toLowerCase() === String(optionKey).toLowerCase());
447
+ if (!opt)
448
+ throw new DecisionError(400, `"${optionKey}" is not an option of ${id} (${decision.options.map((o) => o.key).join(', ')})`);
449
+ const out = await this.apply(decision, { kind: 'option', key: opt.key }, 'dashboard', this.deps.ownerUserId?.() ?? undefined);
450
+ return out.decision ?? decision;
451
+ }
452
+ /**
453
+ * "Remind me tomorrow" from the dashboard.
454
+ *
455
+ * @param id - Decision id
456
+ * @returns The snoozed decision
457
+ */
458
+ async remindFromDashboard(id) {
459
+ const decision = await this.requirePending(id);
460
+ const out = await this.apply(decision, { kind: 'remind' }, 'dashboard', this.deps.ownerUserId?.() ?? undefined);
461
+ return out.decision ?? decision;
462
+ }
463
+ /**
464
+ * Withdraw open decisions (the asker no longer needs an answer, or the
465
+ * ticket's mark was cleared).
466
+ *
467
+ * @param filter - Which decisions
468
+ * @param note - Why (ticket log)
469
+ * @returns How many were withdrawn
470
+ */
471
+ async cancelWhere(filter, note) {
472
+ const open = await this.deps.store.list((d) => PENDING_DECISION_STATUSES.has(d.status) && filter(d));
473
+ for (const d of open) {
474
+ const done = await this.deps.store.update(d.id, (cur) => (PENDING_DECISION_STATUSES.has(cur.status) ? { status: 'cancelled', resolvedAt: this.now().toISOString() } : null));
475
+ if (!done)
476
+ continue;
477
+ await this.refreshCard(done);
478
+ if (done.kind)
479
+ await this.notifyAsker(done, null);
480
+ this.logger.info('Owner decision withdrawn', { decisionId: d.id, note });
481
+ }
482
+ return open.length;
483
+ }
484
+ /**
485
+ * List decisions.
486
+ *
487
+ * @param which - `open` (open + parked) or `all`
488
+ * @returns Decisions, newest first
489
+ */
490
+ async list(which = 'open') {
491
+ const all = await this.deps.store.list(which === 'open' ? (d) => PENDING_DECISION_STATUSES.has(d.status) : undefined);
492
+ return all.slice(0, DECISION_CONSTANTS.MAX_LISTED);
493
+ }
494
+ /**
495
+ * One decision.
496
+ *
497
+ * @param id - Decision id
498
+ * @returns Decision or null
499
+ */
500
+ get(id) {
501
+ return this.deps.store.get(id);
502
+ }
503
+ async requirePending(id) {
504
+ const decision = await this.deps.store.get(id);
505
+ if (!decision)
506
+ throw new DecisionError(404, `Decision ${id} not found`);
507
+ if (!PENDING_DECISION_STATUSES.has(decision.status))
508
+ throw new DecisionError(409, `Decision ${id} is already ${decision.status}`);
509
+ return decision;
510
+ }
511
+ /**
512
+ * Apply a choice: resolve (or snooze), update the card, log the ticket,
513
+ * tell the asker, close the watchdog entry.
514
+ */
515
+ async apply(decision, choice, via, user) {
516
+ const now = this.now();
517
+ if (choice.kind === 'remind' && !canRemind(decision)) {
518
+ return { handled: false, reason: 'remind is not offered on this card', decision };
519
+ }
520
+ if (choice.kind === 'text' && decision.kind === 'browser_action') {
521
+ // A held click is approved or not: words that are neither leave it open.
522
+ return { handled: false, reason: 'reply is neither yes nor no', decision };
523
+ }
524
+ if (choice.kind === 'remind') {
525
+ const remindAt = tomorrowAt(now, DECISION_CONSTANTS.REMIND_HOUR_LOCAL);
526
+ const minDeadline = remindAt.getTime() + DECISION_CONSTANTS.REMIND_GRACE_MS;
527
+ const updated = await this.deps.store.update(decision.id, (cur) => PENDING_DECISION_STATUSES.has(cur.status)
528
+ ? {
529
+ status: 'open',
530
+ remindAt: remindAt.toISOString(),
531
+ deadline: new Date(Math.max(Date.parse(cur.deadline), minDeadline)).toISOString(),
532
+ // A parked sensitive ask that is snoozed gets its re-ask cycle back.
533
+ reaskedAt: cur.status === 'parked' ? undefined : cur.reaskedAt,
534
+ }
535
+ : null);
536
+ if (!updated)
537
+ return { handled: false, reason: 'already settled', decision };
538
+ await this.refreshCard(updated);
539
+ if (updated.ticket)
540
+ await this.logTicket(updated, `owner decision ${updated.id}: remind tomorrow (${via})`, false);
541
+ this.logger.info('Owner decision snoozed to tomorrow', { decisionId: decision.id, via });
542
+ return { handled: true, reason: 'snoozed', decision: updated };
543
+ }
544
+ const patch = choice.kind === 'option'
545
+ ? { status: 'resolved', chosenKey: choice.key, answerText: undefined }
546
+ : { status: 'resolved', chosenKey: undefined, answerText: choice.text.slice(0, 2000) };
547
+ const resolved = await this.deps.store.update(decision.id, (cur) => PENDING_DECISION_STATUSES.has(cur.status)
548
+ ? { ...patch, answeredVia: via, ...(user ? { answeredBy: user } : {}), resolvedAt: now.toISOString(), remindAt: undefined }
549
+ : null);
550
+ if (!resolved)
551
+ return { handled: false, reason: 'already settled', decision };
552
+ await this.refreshCard(resolved);
553
+ const answer = resolved.chosenKey ? optionLabel(resolved, resolved.chosenKey) : `“${resolved.answerText}”`;
554
+ if (resolved.ticket)
555
+ await this.logTicket(resolved, `owner decision ${resolved.id}: ${answer} (${via})`, true);
556
+ await this.notifyAsker(resolved, this.answerNote(resolved));
557
+ this.closeWatchdog(resolved);
558
+ this.logger.info('Owner decision resolved', { decisionId: resolved.id, via, chosen: resolved.chosenKey ?? 'text' });
559
+ return { handled: true, reason: 'resolved', decision: resolved };
560
+ }
561
+ /** The note the asker receives when the owner answered. */
562
+ answerNote(d) {
563
+ const where = this.whereLine(d);
564
+ const about = `for: "${d.question}"${d.ticket ? ` (ticket ${d.ticket.id})` : ''}`;
565
+ if (d.chosenKey) {
566
+ return `[DECISION ${d.id}] The owner chose "${optionLabel(d, d.chosenKey)}" ${about}. Act on it now.${where}`;
567
+ }
568
+ 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}`;
569
+ }
570
+ /** Where the asker's follow-up belongs. */
571
+ whereLine(d) {
572
+ if (!d.card)
573
+ return '';
574
+ const thread = d.card.threadTs ?? d.card.messageTs;
575
+ return ` Post any update in the card's thread (--thread ${d.card.slackChannelId}:${thread}).`;
576
+ }
577
+ // ---------------------------------------------------------------------------
578
+ // Deadlines and reminders
579
+ // ---------------------------------------------------------------------------
580
+ /**
581
+ * One pass: retry unposted cards, post due reminders, apply defaults at
582
+ * the deadline, re-ask / park sensitive asks, prune old decisions.
583
+ *
584
+ * @returns Ids acted on
585
+ */
586
+ async tick() {
587
+ if (this.ticking)
588
+ return [];
589
+ this.ticking = true;
590
+ const acted = [];
591
+ try {
592
+ const now = this.now();
593
+ for (const d of await this.deps.store.list((x) => x.status === 'open')) {
594
+ try {
595
+ if (!d.card) {
596
+ // A card Slack refused is retried every few minutes, not every tick.
597
+ if (d.postError && now.getTime() - Date.parse(d.updatedAt) < DECISION_CONSTANTS.POST_RETRY_MS)
598
+ continue;
599
+ if ((await this.postCard(d)).card)
600
+ acted.push(d.id);
601
+ continue;
602
+ }
603
+ if (d.remindAt && Date.parse(d.remindAt) <= now.getTime()) {
604
+ await this.remindNow(d);
605
+ acted.push(d.id);
606
+ continue;
607
+ }
608
+ if (Date.parse(d.deadline) > now.getTime())
609
+ continue;
610
+ // A default that never lets anything through (a held browser
611
+ // action's No, a declined Terms card) is applied even when sensitive.
612
+ if (d.sensitive && !defaultIsSafe(d)) {
613
+ if (await this.sensitiveStep(d, now))
614
+ acted.push(d.id);
615
+ }
616
+ else if (await this.applyDefault(d, now)) {
617
+ acted.push(d.id);
618
+ }
619
+ }
620
+ catch (err) {
621
+ this.logger.warn('Decision tick step failed', { decisionId: d.id, error: errText(err) });
622
+ }
623
+ }
624
+ await this.deps.store.prune().catch(() => 0);
625
+ }
626
+ finally {
627
+ this.ticking = false;
628
+ }
629
+ return acted;
630
+ }
631
+ /** Post the "Remind me tomorrow" reminder in the card's thread. */
632
+ async remindNow(d) {
633
+ const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && cur.remindAt ? { remindAt: undefined } : null));
634
+ if (!updated)
635
+ return;
636
+ const owner = this.deps.ownerUserId?.();
637
+ await this.postInThread(updated, `${owner ? `<@${owner}> ` : ''}Reminder: ${updated.question} (answer on the card above)`);
638
+ await this.refreshCard(updated);
639
+ }
640
+ /** Non-sensitive deadline: apply the default (or say it keeps waiting). */
641
+ async applyDefault(d, now) {
642
+ const line = deadlineDefaultLine(d, now);
643
+ if (d.defaultKey === DECISION_CONSTANTS.WAIT_DEFAULT) {
644
+ if (d.deadlineNoticeAt)
645
+ return false;
646
+ const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.deadlineNoticeAt ? { deadlineNoticeAt: now.toISOString() } : null));
647
+ if (!updated)
648
+ return false;
649
+ await this.postInThread(updated, line);
650
+ if (updated.ticket)
651
+ await this.logTicket(updated, `owner decision ${updated.id}: no answer by the deadline — still waiting`, false);
652
+ 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)}`);
653
+ return true;
654
+ }
655
+ const resolved = await this.deps.store.update(d.id, (cur) => cur.status === 'open' ? { status: 'defaulted', chosenKey: cur.defaultKey, answeredVia: 'deadline', resolvedAt: now.toISOString() } : null);
656
+ if (!resolved)
657
+ return false;
658
+ await this.refreshCard(resolved);
659
+ await this.postInThread(resolved, line);
660
+ if (resolved.ticket)
661
+ await this.logTicket(resolved, `owner decision ${resolved.id}: no answer — default "${defaultLabel(resolved)}" applied`, true);
662
+ await this.notifyAsker(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)}`);
663
+ this.closeWatchdog(resolved);
664
+ this.logger.info('Owner decision defaulted at the deadline', { decisionId: resolved.id, chosen: resolved.chosenKey });
665
+ return true;
666
+ }
667
+ /** Sensitive deadline: re-ask once after 24 h, then park. Never auto-applied. */
668
+ async sensitiveStep(d, now) {
669
+ if (!d.reaskedAt) {
670
+ const due = Math.max(Date.parse(d.deadline), Date.parse(d.createdAt) + DECISION_CONSTANTS.SENSITIVE_REASK_AFTER_MS);
671
+ if (now.getTime() < due)
672
+ return false;
673
+ const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.reaskedAt ? { reaskedAt: now.toISOString() } : null));
674
+ if (!updated)
675
+ return false;
676
+ const owner = this.deps.ownerUserId?.();
677
+ 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.`);
678
+ await this.refreshCard(updated);
679
+ this.logger.info('Sensitive owner decision re-asked', { decisionId: d.id });
680
+ return true;
681
+ }
682
+ if (now.getTime() < Date.parse(d.reaskedAt) + DECISION_CONSTANTS.SENSITIVE_PARK_AFTER_REASK_MS)
683
+ return false;
684
+ const parked = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' ? { status: 'parked' } : null));
685
+ if (!parked)
686
+ return false;
687
+ await this.refreshCard(parked);
688
+ if (parked.ticket)
689
+ await this.logTicket(parked, `owner decision ${parked.id}: no answer after a re-ask — parked (sensitive: ${parked.sensitive})`, false);
690
+ await this.notifyAsker(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.`);
691
+ this.logger.info('Sensitive owner decision parked', { decisionId: d.id });
692
+ return true;
693
+ }
694
+ // ---------------------------------------------------------------------------
695
+ // Slack helpers
696
+ // ---------------------------------------------------------------------------
697
+ /** Re-render the card for the decision's current state (with the posting bot's token). */
698
+ async refreshCard(d) {
699
+ const slack = this.deps.slack();
700
+ if (!d.card || !slack)
701
+ return;
702
+ const now = this.now();
703
+ const pending = d.status === 'open';
704
+ let ownerName;
705
+ if (!pending && d.answeredBy)
706
+ ownerName = await this.deps.userName?.(d.answeredBy).catch(() => undefined);
707
+ const blocks = pending ? renderOpenCard(d, this.deps.instanceId(), now) : renderSettledCard(d, ownerName, now);
708
+ const text = pending ? cardFallbackText(d) : `${cardFallbackText(d)} — ${settledLine(d, ownerName, now)}`;
709
+ const token = d.card.ownBot ? (await this.deps.identityOf(d.card.postedBy).catch(() => ({}))).botToken : undefined;
710
+ try {
711
+ await slack.updateMessage(d.card.slackChannelId, d.card.messageTs, text, blocks, token);
712
+ }
713
+ catch (err) {
714
+ this.logger.warn('Could not update the decision card', { decisionId: d.id, error: errText(err) });
715
+ }
716
+ }
717
+ /** Post a line in the card's thread, as the asker. */
718
+ async postInThread(d, text) {
719
+ const slack = this.deps.slack();
720
+ if (!d.card || !slack || !slack.isConnected())
721
+ return;
722
+ try {
723
+ const identity = await this.deps.identityOf(d.asker);
724
+ await this.send(slack, identity, { channelId: d.card.slackChannelId, text, threadTs: d.card.threadTs ?? d.card.messageTs });
725
+ }
726
+ catch (err) {
727
+ this.logger.warn('Could not post in the decision thread', { decisionId: d.id, error: errText(err) });
728
+ }
729
+ }
730
+ async logTicket(d, line, settled) {
731
+ if (!d.ticket)
732
+ return;
733
+ let clear = false;
734
+ if (settled) {
735
+ 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);
736
+ clear = others.length === 0;
737
+ }
738
+ 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) }));
739
+ }
740
+ /**
741
+ * Tell the asker how a decision settled: through its kind's handler when
742
+ * it has one (which also acts on the answer), else with `fallback`.
743
+ */
744
+ async notifyAsker(d, fallback) {
745
+ const handler = d.kind ? KIND_HANDLERS.get(d.kind) : undefined;
746
+ let text = fallback;
747
+ if (handler) {
748
+ try {
749
+ text = await handler.onSettled(d);
750
+ }
751
+ catch (err) {
752
+ this.logger.warn('Decision kind handler failed', { decisionId: d.id, kind: d.kind, error: errText(err) });
753
+ }
754
+ }
755
+ if (text)
756
+ await this.tellAsker(d, text);
757
+ }
758
+ async tellAsker(d, text) {
759
+ // A harness-owned decision is handled by its kind's handler; no agent is woken.
760
+ if (d.system)
761
+ return;
762
+ const ok = await this.deps.deliverToAgent(d.asker, text).catch(() => false);
763
+ if (!ok)
764
+ this.logger.warn('Could not deliver the decision to the asking agent', { decisionId: d.id, asker: d.asker });
765
+ // The orchestrator asked on the owner's behalf for a ticket it does not own: tell it too.
766
+ if (d.requestedBy !== d.asker && d.requestedBy === ORCHESTRATOR_SESSION_NAME) {
767
+ await this.deps.deliverToAgent(d.requestedBy, text).catch(() => false);
768
+ }
769
+ }
770
+ closeWatchdog(d) {
771
+ if (!d.card || !this.deps.closeWatchdog)
772
+ return;
773
+ try {
774
+ this.deps.closeWatchdog(d.asker, d.card.slackChannelId, d.card.threadTs ?? d.card.messageTs);
775
+ }
776
+ catch {
777
+ /* best-effort */
778
+ }
779
+ }
780
+ }
781
+ /**
782
+ * A system decision's thread reply: an option (label, key, number) or a
783
+ * "no" word for its "no" option. Yes-words are ambiguous (two ways to agree)
784
+ * and free text is not an answer.
785
+ *
786
+ * @param d - Decision
787
+ * @param text - Owner's reply
788
+ * @returns Choice, or null
789
+ */
790
+ function systemChoiceFromText(d, text) {
791
+ const clean = text.replace(/<@[A-Z0-9]+>/g, '').replace(/\s+/g, ' ').trim();
792
+ if (!clean)
793
+ return null;
794
+ const opt = matchOption(clean, d.options);
795
+ if (opt)
796
+ return { kind: 'option', key: opt.key };
797
+ const norm = clean.toLowerCase().replace(/[\s.。!!,,]+$/u, '');
798
+ if (DECISION_CONSTANTS.NO_WORDS.includes(norm)) {
799
+ const no = noOption(d.options);
800
+ if (no)
801
+ return { kind: 'option', key: no.key };
802
+ }
803
+ return null;
804
+ }
805
+ /**
806
+ * Message of an unknown error.
807
+ *
808
+ * @param err - Thrown value
809
+ * @returns Text
810
+ */
811
+ function errText(err) {
812
+ if (err && typeof err === 'object') {
813
+ const data = err.data;
814
+ if (typeof data?.error === 'string')
815
+ return data.error;
816
+ }
817
+ return err instanceof Error ? err.message : String(err);
818
+ }
819
+ /** Re-exported for the deadline line in the dashboard/API. */
820
+ export { formatWhen };
821
+ //# sourceMappingURL=decision.service.js.map