crewly 1.20.189 → 1.20.191

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 (181) hide show
  1. package/config/constants.ts +2 -2
  2. package/config/skills/agent/core/ask-owner/SKILL.md +9 -2
  3. package/config/skills/agent/core/ask-owner/execute.sh +7 -3
  4. package/config/skills/agent/core/ask-owner/execute.test.sh +3 -0
  5. package/config/skills/agent/core/attach-file/execute.sh +13 -4
  6. package/config/skills/agent/core/complete-task/execute.sh +3 -0
  7. package/config/skills/agent/core/reply/SKILL.md +32 -4
  8. package/config/skills/agent/core/reply/execute.sh +23 -1
  9. package/config/skills/agent/core/reply/execute.test.sh +9 -0
  10. package/config/skills/agent/core/reply-chat/SKILL.md +9 -2
  11. package/config/skills/agent/core/reply-chat/execute.sh +13 -1
  12. package/config/skills/agent/core/report-status/execute.sh +5 -0
  13. package/config/skills/agent/core/send-chat-response/execute.sh +1 -1
  14. package/dist/backend/backend/src/constants.d.ts +45 -1
  15. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  16. package/dist/backend/backend/src/constants.js +55 -2
  17. package/dist/backend/backend/src/constants.js.map +1 -1
  18. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts +14 -1
  19. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
  20. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +66 -78
  21. package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
  22. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts +12 -0
  23. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
  24. package/dist/backend/backend/src/controllers/chat/chat.controller.js +148 -24
  25. package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
  26. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
  27. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +62 -0
  28. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
  29. package/dist/backend/backend/src/controllers/decisions/decisions.controller.d.ts.map +1 -1
  30. package/dist/backend/backend/src/controllers/decisions/decisions.controller.js +3 -1
  31. package/dist/backend/backend/src/controllers/decisions/decisions.controller.js.map +1 -1
  32. package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
  33. package/dist/backend/backend/src/controllers/slack/slack.controller.js +120 -35
  34. package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
  35. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +7 -0
  36. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  37. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +9 -0
  38. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  39. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts +7 -0
  40. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
  41. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js +19 -0
  42. package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
  43. package/dist/backend/backend/src/services/decisions/decision-card.d.ts +56 -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 +118 -11
  46. package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -1
  47. package/dist/backend/backend/src/services/decisions/decision-routing.d.ts +26 -1
  48. package/dist/backend/backend/src/services/decisions/decision-routing.d.ts.map +1 -1
  49. package/dist/backend/backend/src/services/decisions/decision-routing.js +36 -2
  50. package/dist/backend/backend/src/services/decisions/decision-routing.js.map +1 -1
  51. package/dist/backend/backend/src/services/decisions/decision.service.d.ts +68 -5
  52. package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  53. package/dist/backend/backend/src/services/decisions/decision.service.js +262 -33
  54. package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
  55. package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/decisions/decision.wiring.js +16 -1
  57. package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -1
  58. package/dist/backend/backend/src/services/open-items/open-item-card.d.ts +34 -0
  59. package/dist/backend/backend/src/services/open-items/open-item-card.d.ts.map +1 -1
  60. package/dist/backend/backend/src/services/open-items/open-item-card.js +88 -0
  61. package/dist/backend/backend/src/services/open-items/open-item-card.js.map +1 -1
  62. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts +29 -0
  63. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
  64. package/dist/backend/backend/src/services/open-items/open-items.service.js +100 -10
  65. package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -1
  66. package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts.map +1 -1
  67. package/dist/backend/backend/src/services/open-items/open-items.wiring.js +3 -2
  68. package/dist/backend/backend/src/services/open-items/open-items.wiring.js.map +1 -1
  69. package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.d.ts +81 -0
  70. package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.d.ts.map +1 -0
  71. package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.js +88 -0
  72. package/dist/backend/backend/src/services/orc/agent-prompt-reference.service.js.map +1 -0
  73. package/dist/backend/backend/src/services/orc/orc-reply-route.service.d.ts +8 -0
  74. package/dist/backend/backend/src/services/orc/orc-reply-route.service.d.ts.map +1 -1
  75. package/dist/backend/backend/src/services/orc/orc-reply-route.service.js +11 -0
  76. package/dist/backend/backend/src/services/orc/orc-reply-route.service.js.map +1 -1
  77. package/dist/backend/backend/src/services/orc/reply-destination-resolver.d.ts +218 -0
  78. package/dist/backend/backend/src/services/orc/reply-destination-resolver.d.ts.map +1 -0
  79. package/dist/backend/backend/src/services/orc/reply-destination-resolver.js +343 -0
  80. package/dist/backend/backend/src/services/orc/reply-destination-resolver.js.map +1 -0
  81. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts +98 -0
  82. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts.map +1 -0
  83. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js +362 -0
  84. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js.map +1 -0
  85. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts +9 -1
  86. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
  87. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js +32 -7
  88. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
  89. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts +2 -2
  90. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts.map +1 -1
  91. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js +9 -0
  92. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js.map +1 -1
  93. package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
  94. package/dist/backend/backend/src/services/slack/slack-initializer.js +4 -1
  95. package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
  96. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +1 -1
  97. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  98. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +24 -4
  99. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  100. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.d.ts +89 -7
  101. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.d.ts.map +1 -1
  102. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.js +159 -21
  103. package/dist/backend/backend/src/services/slack/slack-typing-placeholder.service.js.map +1 -1
  104. package/dist/backend/backend/src/services/system/auto-update.service.d.ts +2 -2
  105. package/dist/backend/backend/src/services/system/auto-update.service.js +2 -2
  106. package/dist/backend/backend/src/services/v3/ticket-intake.service.d.ts +23 -4
  107. package/dist/backend/backend/src/services/v3/ticket-intake.service.d.ts.map +1 -1
  108. package/dist/backend/backend/src/services/v3/ticket-intake.service.js +42 -4
  109. package/dist/backend/backend/src/services/v3/ticket-intake.service.js.map +1 -1
  110. package/dist/backend/backend/src/types/decision.types.d.ts +30 -2
  111. package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
  112. package/dist/backend/backend/src/types/slack.types.d.ts +11 -0
  113. package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
  114. package/dist/backend/backend/src/types/slack.types.js.map +1 -1
  115. package/dist/backend/build-info.json +2 -2
  116. package/dist/backend/config/constants.d.ts +1 -1
  117. package/dist/backend/config/constants.d.ts.map +1 -1
  118. package/dist/backend/config/constants.js +2 -2
  119. package/dist/backend/config/constants.js.map +1 -1
  120. package/dist/cli/backend/src/constants.d.ts +45 -1
  121. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  122. package/dist/cli/backend/src/constants.js +55 -2
  123. package/dist/cli/backend/src/constants.js.map +1 -1
  124. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +7 -0
  125. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  126. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +9 -0
  127. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  128. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts +7 -0
  129. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
  130. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js +19 -0
  131. package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
  132. package/dist/cli/backend/src/services/decisions/decision-card.d.ts +56 -5
  133. package/dist/cli/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  134. package/dist/cli/backend/src/services/decisions/decision-card.js +118 -11
  135. package/dist/cli/backend/src/services/decisions/decision-card.js.map +1 -1
  136. package/dist/cli/backend/src/services/decisions/decision.service.d.ts +68 -5
  137. package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  138. package/dist/cli/backend/src/services/decisions/decision.service.js +262 -33
  139. package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
  140. package/dist/cli/backend/src/services/open-items/open-item-card.d.ts +34 -0
  141. package/dist/cli/backend/src/services/open-items/open-item-card.d.ts.map +1 -1
  142. package/dist/cli/backend/src/services/open-items/open-item-card.js +88 -0
  143. package/dist/cli/backend/src/services/open-items/open-item-card.js.map +1 -1
  144. package/dist/cli/backend/src/services/open-items/open-items.service.d.ts +457 -0
  145. package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -0
  146. package/dist/cli/backend/src/services/open-items/open-items.service.js +1164 -0
  147. package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -0
  148. package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.d.ts +81 -0
  149. package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.d.ts.map +1 -0
  150. package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.js +88 -0
  151. package/dist/cli/backend/src/services/orc/agent-prompt-reference.service.js.map +1 -0
  152. package/dist/cli/backend/src/services/orc/orc-reply-route.service.d.ts +8 -0
  153. package/dist/cli/backend/src/services/orc/orc-reply-route.service.d.ts.map +1 -1
  154. package/dist/cli/backend/src/services/orc/orc-reply-route.service.js +11 -0
  155. package/dist/cli/backend/src/services/orc/orc-reply-route.service.js.map +1 -1
  156. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts +9 -1
  157. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
  158. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js +32 -7
  159. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
  160. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +1 -1
  161. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  162. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +24 -4
  163. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  164. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.d.ts +89 -7
  165. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.d.ts.map +1 -1
  166. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.js +159 -21
  167. package/dist/cli/backend/src/services/slack/slack-typing-placeholder.service.js.map +1 -1
  168. package/dist/cli/backend/src/services/v3/ticket-intake.service.d.ts +23 -4
  169. package/dist/cli/backend/src/services/v3/ticket-intake.service.d.ts.map +1 -1
  170. package/dist/cli/backend/src/services/v3/ticket-intake.service.js +42 -4
  171. package/dist/cli/backend/src/services/v3/ticket-intake.service.js.map +1 -1
  172. package/dist/cli/backend/src/types/decision.types.d.ts +30 -2
  173. package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -1
  174. package/dist/cli/backend/src/types/slack.types.d.ts +11 -0
  175. package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
  176. package/dist/cli/backend/src/types/slack.types.js.map +1 -1
  177. package/dist/cli/config/constants.d.ts +1 -1
  178. package/dist/cli/config/constants.d.ts.map +1 -1
  179. package/dist/cli/config/constants.js +2 -2
  180. package/dist/cli/config/constants.js.map +1 -1
  181. package/package.json +1 -1
@@ -14,11 +14,13 @@
14
14
  *
15
15
  * @module services/decisions/decision.service
16
16
  */
17
+ import { isAudioOrVideo } from '../../utils/inbound-file-hint.utils.js';
18
+ import { AgentPromptReferenceService } from '../orc/agent-prompt-reference.service.js';
17
19
  import { DECISION_CONSTANTS, OPEN_ITEMS_CONSTANTS, ORCHESTRATOR_SESSION_NAME } from '../../constants.js';
18
20
  import { questionSimilarity } from '../open-items/open-item-card.js';
19
21
  import { LoggerService } from '../core/logger.service.js';
20
22
  import { DecisionContractError, matchOption, parseOptions, resolveDefault, validateAskOwner } from './decision-contract.js';
21
- import { canRemind, cardFallbackText, choiceFromReaction, choiceFromText, deadlineDefaultLine, defaultIsSafe, defaultLabel, formatWhen, optionLabel, parseButtonValue, renderOpenCard, renderSettledCard, settledLine, noOption, isSkipWord, skipChoice, ticketThreadRootText, } from './decision-card.js';
23
+ import { answerFilesOf, canRemind, cardFallbackText, choiceFromReaction, choiceFromText, deadlineDefaultLine, defaultIsSafe, describeAnswerFiles, slackTsAfter, waitReminderLine, defaultLabel, formatWhen, optionLabel, parseButtonValue, renderOpenCard, renderSettledCard, settledLine, noOption, isSkipWord, skipChoice, ticketThreadRootText, } from './decision-card.js';
22
24
  import { PENDING_DECISION_STATUSES } from './decision-store.js';
23
25
  /** Thrown for requests the caller must fix (HTTP 4xx). */
24
26
  export class DecisionError extends Error {
@@ -84,6 +86,12 @@ export class DecisionService {
84
86
  now;
85
87
  timer = null;
86
88
  ticking = false;
89
+ /**
90
+ * When this process started ticking (epoch ms; set by the first tick). A
91
+ * `wait` card whose asker was told about the deadline before that is never
92
+ * reminded: after an upgrade, old cards must not all ping the owner at once.
93
+ */
94
+ startedAt = null;
87
95
  /**
88
96
  * @param deps - Collaborators
89
97
  */
@@ -285,6 +293,7 @@ export class DecisionService {
285
293
  ...(ask.sensitive ? { sensitive: ask.sensitive } : {}),
286
294
  ...(ask.browser ? { browser: ask.browser } : {}),
287
295
  ...(ask.title ? { title: ask.title } : {}),
296
+ ...(ask.body?.length ? { body: ask.body } : {}),
288
297
  ...(ask.place ? { place: ask.place } : {}),
289
298
  ...(ask.requestRef ? { requestRef: ask.requestRef } : {}),
290
299
  ...(ask.source ? { source: ask.source } : {}),
@@ -492,8 +501,12 @@ export class DecisionService {
492
501
  return this.apply(decision, choice, 'reaction', event.user);
493
502
  }
494
503
  /**
495
- * An owner reply in a card's thread. The newest open card in that thread
496
- * takes it; words that match no option are passed to the asker verbatim.
504
+ * An owner reply in a card's thread (channel or DM thread), posted after
505
+ * the card. Text: the newest open card takes it; words that match no
506
+ * option are passed to the asker verbatim. A voice note, audio or other
507
+ * file with no text answers every open card in the thread "in thread"
508
+ * (specs/2026-10-02-decision-card-thread-answers.md §1). Every such owner
509
+ * message stamps `ownerRepliedAt` on the open cards there.
497
510
  *
498
511
  * @param message - Inbound Slack message
499
512
  * @returns What happened
@@ -508,14 +521,75 @@ export class DecisionService {
508
521
  const candidates = await this.deps.store.list((d) => d.status === 'open' &&
509
522
  !!d.card &&
510
523
  d.card.slackChannelId === message.channelId &&
511
- (d.card.threadTs === message.threadTs || d.card.messageTs === message.threadTs));
512
- const decision = candidates[0];
513
- if (!decision)
524
+ (d.card.threadTs === message.threadTs || d.card.messageTs === message.threadTs) &&
525
+ // Only what the owner said after the card went up answers it.
526
+ slackTsAfter(message.ts, d.card.messageTs));
527
+ if (candidates.length === 0)
514
528
  return { handled: false, reason: 'no open card in this thread' };
515
- const choice = decision.system ? systemChoiceFromText(decision, message.text ?? '') : choiceFromText(decision, message.text ?? '');
516
- if (!choice)
517
- return { handled: false, reason: decision.system ? 'not one of the options' : 'empty reply', decision };
518
- return this.apply(decision, choice, 'reply', message.userId);
529
+ const at = this.now().toISOString();
530
+ for (const c of candidates) {
531
+ await this.deps.store.update(c.id, (cur) => (cur.status === 'open' ? { ownerRepliedAt: at } : null)).catch(() => null);
532
+ }
533
+ const decision = candidates[0];
534
+ const files = answerFilesOf(message.files);
535
+ const text = message.text ?? '';
536
+ if (decision.system) {
537
+ const choice = systemChoiceFromText(decision, text);
538
+ if (!choice)
539
+ return { handled: false, reason: 'not one of the options', decision };
540
+ return this.apply(decision, choice, 'reply', message.userId);
541
+ }
542
+ if (text.replace(/<@[A-Z0-9]+>/g, '').trim()) {
543
+ const choice = choiceFromText(decision, text);
544
+ if (!choice)
545
+ return { handled: false, reason: 'empty reply', decision };
546
+ return this.apply(decision, choice, 'reply', message.userId, undefined, files);
547
+ }
548
+ if (files.length === 0)
549
+ return { handled: false, reason: 'empty reply', decision };
550
+ return this.applyThreadFiles(candidates, files, message.userId);
551
+ }
552
+ /**
553
+ * A voice note / file with no text in a thread with open cards
554
+ * (specs/2026-10-02-decision-card-thread-answers.md §1):
555
+ *
556
+ * - sensitive, system and browser cards are never settled by a file; their
557
+ * askers get the file as information;
558
+ * - a voice note / audio / video answers the newest other card; any file
559
+ * answers the only other card when there is exactly one;
560
+ * - an image or other file with several open cards settles nothing.
561
+ *
562
+ * Every asker whose card stays open is told what the owner posted, once.
563
+ */
564
+ async applyThreadFiles(candidates, files, user) {
565
+ const plain = candidates.filter((c) => !c.system && c.kind !== 'browser_action' && !c.sensitive);
566
+ const voice = files.some((f) => isAudioOrVideo({ name: f.name, mimetype: f.mimetype ?? '' }));
567
+ const newest = [...plain].sort((x, y) => Date.parse(y.createdAt) - Date.parse(x.createdAt) || idNumber(y.id) - idNumber(x.id))[0];
568
+ const target = newest && (voice || plain.length === 1) ? newest : null;
569
+ const notes = new Map();
570
+ let outcome = null;
571
+ if (target) {
572
+ const transcript = files.map((f) => f.transcript).filter((t) => !!t).join(' ');
573
+ outcome = await this.apply(target, { kind: 'thread', files, ...(transcript ? { text: transcript } : {}) }, 'thread', user, notes);
574
+ }
575
+ const what = describeAnswerFiles(files);
576
+ for (const c of candidates) {
577
+ if (c.system || (target && c.id === target.id))
578
+ continue;
579
+ const why = c.sensitive
580
+ ? `It needs the owner's explicit OK (${c.sensitive}), so it stays open — do not go ahead on this file alone.`
581
+ : c.kind === 'browser_action'
582
+ ? 'A held browser action needs a Let it / No, so it stays open.'
583
+ : `It is not clear which open question it answers, so this card stays open. If it answers yours, act on it and withdraw the card: ask-owner --cancel ${c.id} --reason "answered in the thread".`;
584
+ notes.set(c.asker, [...(notes.get(c.asker) ?? []), `[DECISION ${c.id}] The owner posted ${what} in the thread of your question "${c.question}".${filesLine(files)} ${why}`]);
585
+ }
586
+ for (const [asker, lines] of notes) {
587
+ const d = candidates.find((c) => c.asker === asker && !c.system) ?? candidates[0];
588
+ await this.tellAsker(d, lines.join('\n'));
589
+ }
590
+ if (outcome)
591
+ return outcome;
592
+ return { handled: false, reason: plain.length === 0 ? 'a file does not answer this card' : 'a file does not say which card it answers', decision: candidates[0] };
519
593
  }
520
594
  /**
521
595
  * The owner answered from the dashboard.
@@ -660,7 +734,9 @@ export class DecisionService {
660
734
  async cancelWhere(filter, note) {
661
735
  const open = await this.deps.store.list((d) => PENDING_DECISION_STATUSES.has(d.status) && filter(d));
662
736
  for (const d of open) {
663
- const done = await this.deps.store.update(d.id, (cur) => (PENDING_DECISION_STATUSES.has(cur.status) ? { status: 'cancelled', resolvedAt: this.now().toISOString() } : null));
737
+ const done = await this.deps.store.update(d.id, (cur) => PENDING_DECISION_STATUSES.has(cur.status)
738
+ ? { status: 'cancelled', resolvedAt: this.now().toISOString(), remindAt: undefined, ...(note?.trim() ? { closedReason: note.trim() } : {}) }
739
+ : null);
664
740
  if (!done)
665
741
  continue;
666
742
  await this.refreshCard(done);
@@ -701,7 +777,7 @@ export class DecisionService {
701
777
  * Apply a choice: resolve (or snooze), update the card, log the ticket,
702
778
  * tell the asker, close the watchdog entry.
703
779
  */
704
- async apply(decision, choice, via, user, batchNotes) {
780
+ async apply(decision, choice, via, user, batchNotes, files = []) {
705
781
  const now = this.now();
706
782
  if (choice.kind === 'skip')
707
783
  return this.applySkip(decision, via, user, batchNotes);
@@ -734,14 +810,22 @@ export class DecisionService {
734
810
  }
735
811
  const patch = choice.kind === 'option'
736
812
  ? { status: 'resolved', chosenKey: choice.key, answerText: undefined }
737
- : { status: 'resolved', chosenKey: undefined, answerText: choice.text.slice(0, 2000) };
813
+ : choice.kind === 'thread'
814
+ ? { status: 'resolved', chosenKey: undefined, answerText: choice.text?.slice(0, 2000), answerFiles: choice.files }
815
+ : { status: 'resolved', chosenKey: undefined, answerText: choice.text.slice(0, 2000) };
816
+ if (files.length > 0 && choice.kind !== 'thread')
817
+ patch.answerFiles = files;
738
818
  const resolved = await this.deps.store.update(decision.id, (cur) => PENDING_DECISION_STATUSES.has(cur.status)
739
819
  ? { ...patch, answeredVia: via, ...(user ? { answeredBy: user } : {}), resolvedAt: now.toISOString(), remindAt: undefined }
740
820
  : null);
741
821
  if (!resolved)
742
822
  return { handled: false, reason: 'already settled', decision };
743
823
  await this.refreshCard(resolved);
744
- const answer = resolved.chosenKey ? optionLabel(resolved, resolved.chosenKey) : `“${resolved.answerText}”`;
824
+ const answer = resolved.chosenKey
825
+ ? optionLabel(resolved, resolved.chosenKey)
826
+ : resolved.answeredVia === 'thread'
827
+ ? `answered in the thread with ${describeAnswerFiles(resolved.answerFiles ?? [])}`
828
+ : `“${resolved.answerText}”`;
745
829
  if (resolved.ticket)
746
830
  await this.logTicket(resolved, `owner decision ${resolved.id}: ${answer} (${via})`, true);
747
831
  await this.notifyAsker(resolved, this.answerNote(resolved), batchNotes);
@@ -776,17 +860,26 @@ export class DecisionService {
776
860
  answerNote(d) {
777
861
  const where = this.whereLine(d);
778
862
  const about = `for: "${d.question}"${d.ticket ? ` (ticket ${d.ticket.id})` : ''}`;
863
+ const files = filesLine(d.answerFiles ?? []);
779
864
  if (d.chosenKey) {
780
- return `[DECISION ${d.id}] The owner chose "${optionLabel(d, d.chosenKey)}" ${about}. Act on it now.${where}`;
865
+ return `[DECISION ${d.id}] The owner chose "${optionLabel(d, d.chosenKey)}" ${about}. Act on it now.${files}${where}`;
866
+ }
867
+ if (d.answeredVia === 'thread') {
868
+ const what = describeAnswerFiles(d.answerFiles ?? []);
869
+ const transcript = d.answerText ? ` Slack's transcript: "${d.answerText}".` : '';
870
+ return (`[DECISION ${d.id}] The owner answered ${about} in the card's thread with ${what} (no text).${transcript}${files} ` +
871
+ `Read it as their decision and act on it; if it is genuinely unclear, ask once more with ask-owner.${where}`);
781
872
  }
782
- 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}`;
873
+ return `[DECISION ${d.id}] The owner answered in words ${about}: "${d.answerText ?? ''}".${files} Read it as their decision and act on it; if it is genuinely unclear, ask once more with ask-owner.${where}`;
783
874
  }
784
- /** Where the asker's follow-up belongs. */
875
+ /**
876
+ * Where the asker's follow-up belongs: a command naming the decision — the
877
+ * harness finds the card's thread (specs/2026-10-02-harness-owned-routing.md §4).
878
+ */
785
879
  whereLine(d) {
786
880
  if (!d.card)
787
881
  return '';
788
- const thread = d.card.threadTs ?? d.card.messageTs;
789
- return ` Post any update in the card's thread (--thread ${d.card.slackChannelId}:${thread}).`;
882
+ return ` To post an update for the owner, run: reply --decision ${d.id} "<your message>" — Crewly posts it in the card's thread.`;
790
883
  }
791
884
  // ---------------------------------------------------------------------------
792
885
  // Deadlines and reminders
@@ -804,6 +897,9 @@ export class DecisionService {
804
897
  const acted = [];
805
898
  try {
806
899
  const now = this.now();
900
+ if (this.startedAt === null)
901
+ this.startedAt = now.getTime();
902
+ const reminders = [];
807
903
  for (const d of await this.deps.store.list((x) => x.status === 'open')) {
808
904
  try {
809
905
  if (!d.card) {
@@ -821,6 +917,14 @@ export class DecisionService {
821
917
  }
822
918
  if (Date.parse(d.deadline) > now.getTime())
823
919
  continue;
920
+ if (d.defaultKey === DECISION_CONSTANTS.WAIT_DEFAULT && !d.sensitive) {
921
+ const step = await this.waitStep(d, now);
922
+ if (step === 'acted')
923
+ acted.push(d.id);
924
+ else if (step === 'remind')
925
+ reminders.push(d);
926
+ continue;
927
+ }
824
928
  // A default that never lets anything through (a held browser
825
929
  // action's No, a declined Terms card) is applied even when sensitive.
826
930
  if (d.sensitive && !defaultIsSafe(d)) {
@@ -835,6 +939,7 @@ export class DecisionService {
835
939
  this.logger.warn('Decision tick step failed', { decisionId: d.id, error: errText(err) });
836
940
  }
837
941
  }
942
+ acted.push(...(await this.postWaitReminders(reminders, now)));
838
943
  await this.deps.store.prune().catch(() => 0);
839
944
  }
840
945
  finally {
@@ -842,30 +947,111 @@ export class DecisionService {
842
947
  }
843
948
  return acted;
844
949
  }
950
+ /**
951
+ * Withdraw a card whose tracked item is already closed, silently: the
952
+ * card says why, nothing is posted in the thread and the asker is not woken
953
+ * (specs/2026-10-02-decision-card-thread-answers.md §3).
954
+ *
955
+ * @param d - Open decision about to post something to the owner
956
+ * @returns True when it was withdrawn
957
+ */
958
+ async withdrawIfMoot(d) {
959
+ if (!this.deps.trackedClosed)
960
+ return false;
961
+ const reason = await this.deps.trackedClosed(d).catch(() => null);
962
+ if (!reason)
963
+ return false;
964
+ const n = await this.cancelWhere((x) => x.id === d.id, reason);
965
+ if (n === 0)
966
+ return false;
967
+ this.logger.info('Decision card withdrawn before posting: what it tracks is already closed', { decisionId: d.id, reason });
968
+ // One short note, so a ticket closed by mistake is recoverable.
969
+ await this.tellAsker(d, `[DECISION ${d.id}] Crewly closed your question "${d.question}" without asking the owner: ${reason}. Nothing was posted to the owner. If that is wrong and the work is still open, ask again with ask-owner.`);
970
+ return true;
971
+ }
845
972
  /** Post the "Remind me tomorrow" reminder in the card's thread. */
846
973
  async remindNow(d) {
974
+ if (await this.withdrawIfMoot(d))
975
+ return;
847
976
  const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && cur.remindAt ? { remindAt: undefined } : null));
848
977
  if (!updated)
849
978
  return;
850
979
  const owner = this.deps.ownerUserId?.();
851
- await this.postInThread(updated, `${owner ? `<@${owner}> ` : ''}Reminder: ${updated.question} (answer on the card above)`);
980
+ await this.postInThread(updated, `${owner ? `<@${owner}> ` : ''}Reminder: ${updated.question} — tap an answer on the card above, or reply here.`);
852
981
  await this.refreshCard(updated);
853
982
  }
854
- /** Non-sensitive deadline: apply the default (or say it keeps waiting). */
855
- async applyDefault(d, now) {
856
- const line = deadlineDefaultLine(d, now);
857
- if (d.defaultKey === DECISION_CONSTANTS.WAIT_DEFAULT) {
858
- if (d.deadlineNoticeAt)
859
- return false;
983
+ /**
984
+ * A `wait` card past its deadline (specs/2026-10-02-decision-card-thread-answers.md §2):
985
+ * first only the asker is told — nothing goes to the owner; later, once, a
986
+ * reminder that says what to do, when the owner has not touched the thread.
987
+ */
988
+ async waitStep(d, now) {
989
+ if (!d.deadlineNoticeAt) {
990
+ if (await this.withdrawIfMoot(d))
991
+ return 'acted';
860
992
  const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.deadlineNoticeAt ? { deadlineNoticeAt: now.toISOString() } : null));
861
993
  if (!updated)
862
- return false;
863
- await this.postInThread(updated, line);
994
+ return null;
864
995
  if (updated.ticket)
865
- await this.logTicket(updated, `owner decision ${updated.id}: no answer by the deadline — still waiting`, false);
866
- 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)}`);
867
- return true;
996
+ await this.logTicket(updated, `owner decision ${updated.id}: no answer by the deadline — still waiting (nothing posted to the owner)`, false);
997
+ await this.tellAsker(updated, `[DECISION ${updated.id}] The deadline for "${updated.question}" passed with no answer. Nothing was posted to the owner. ` +
998
+ `Keep this work parked until they answer. If it is already settled or no longer needed, withdraw it: ask-owner --cancel ${updated.id} --reason "<why>".${this.whereLine(updated)}`);
999
+ this.logger.info('Wait-default decision past its deadline — asker told, nothing posted', { decisionId: updated.id });
1000
+ return 'acted';
1001
+ }
1002
+ if (d.waitReminderAt || d.ownerRepliedAt)
1003
+ return null;
1004
+ if (this.startedAt !== null && Date.parse(d.deadlineNoticeAt) < this.startedAt) {
1005
+ // Noticed before this process started (e.g. before the upgrade that
1006
+ // added reminders): marked, never reminded — no burst on first boot.
1007
+ await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.waitReminderAt ? { waitReminderAt: now.toISOString() } : null));
1008
+ this.logger.debug('Wait-default decision noticed before this start — no reminder', { decisionId: d.id });
1009
+ return null;
1010
+ }
1011
+ if (now.getTime() < Date.parse(d.deadlineNoticeAt) + DECISION_CONSTANTS.WAIT_REMINDER_DELAY_MS)
1012
+ return null;
1013
+ if (await this.withdrawIfMoot(d))
1014
+ return 'acted';
1015
+ return 'remind';
1016
+ }
1017
+ /**
1018
+ * Post the due `wait` reminders: ONE per thread, listing that thread's open
1019
+ * questions; every listed card is marked as reminded.
1020
+ *
1021
+ * @param due - Cards due for their reminder (already checked)
1022
+ * @param now - Clock
1023
+ * @returns Ids reminded
1024
+ */
1025
+ async postWaitReminders(due, now) {
1026
+ const byThread = new Map();
1027
+ for (const d of due) {
1028
+ if (!d.card)
1029
+ continue;
1030
+ const key = `${d.card.slackChannelId}:${d.card.threadTs ?? d.card.messageTs}`;
1031
+ byThread.set(key, [...(byThread.get(key) ?? []), d]);
868
1032
  }
1033
+ const reminded = [];
1034
+ for (const group of byThread.values()) {
1035
+ const marked = [];
1036
+ for (const d of group) {
1037
+ const updated = await this.deps.store.update(d.id, (cur) => cur.status === 'open' && !cur.waitReminderAt && !cur.ownerRepliedAt ? { waitReminderAt: now.toISOString() } : null);
1038
+ if (updated)
1039
+ marked.push(updated);
1040
+ }
1041
+ if (marked.length === 0)
1042
+ continue;
1043
+ marked.sort((x, y) => Date.parse(x.createdAt) - Date.parse(y.createdAt) || idNumber(x.id) - idNumber(y.id));
1044
+ await this.postInThread(marked[marked.length - 1], waitReminderLine(marked, this.deps.ownerUserId?.()));
1045
+ reminded.push(...marked.map((d) => d.id));
1046
+ this.logger.info('Wait-default decisions: one reminder posted for the thread', { decisionIds: marked.map((d) => d.id) });
1047
+ }
1048
+ return reminded;
1049
+ }
1050
+ /** Non-sensitive deadline with a real default: apply it and say who does what. */
1051
+ async applyDefault(d, now) {
1052
+ if (await this.withdrawIfMoot(d))
1053
+ return true;
1054
+ const line = deadlineDefaultLine(d, now, await this.askerName(d));
869
1055
  const resolved = await this.deps.store.update(d.id, (cur) => cur.status === 'open' ? { status: 'defaulted', chosenKey: cur.defaultKey, answeredVia: 'deadline', resolvedAt: now.toISOString() } : null);
870
1056
  if (!resolved)
871
1057
  return false;
@@ -884,6 +1070,8 @@ export class DecisionService {
884
1070
  const due = Math.max(Date.parse(d.deadline), Date.parse(d.createdAt) + DECISION_CONSTANTS.SENSITIVE_REASK_AFTER_MS);
885
1071
  if (now.getTime() < due)
886
1072
  return false;
1073
+ if (await this.withdrawIfMoot(d))
1074
+ return true;
887
1075
  const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.reaskedAt ? { reaskedAt: now.toISOString() } : null));
888
1076
  if (!updated)
889
1077
  return false;
@@ -918,8 +1106,9 @@ export class DecisionService {
918
1106
  let ownerName;
919
1107
  if (!pending && d.answeredBy)
920
1108
  ownerName = await this.deps.userName?.(d.answeredBy).catch(() => undefined);
921
- const blocks = pending ? renderOpenCard(d, this.deps.instanceId(), now) : renderSettledCard(d, ownerName, now);
922
- const text = pending ? cardFallbackText(d) : `${cardFallbackText(d)} — ${settledLine(d, ownerName, now)}`;
1109
+ const askerName = d.status === 'defaulted' ? await this.askerName(d) : undefined;
1110
+ const blocks = pending ? renderOpenCard(d, this.deps.instanceId(), now) : renderSettledCard(d, ownerName, now, askerName);
1111
+ const text = pending ? cardFallbackText(d) : `${cardFallbackText(d)} — ${settledLine(d, ownerName, now, askerName)}`;
923
1112
  const token = d.card.ownBot ? (await this.deps.identityOf(d.card.postedBy).catch(() => ({}))).botToken : undefined;
924
1113
  try {
925
1114
  await slack.updateMessage(d.card.slackChannelId, d.card.messageTs, text, blocks, token);
@@ -930,6 +1119,16 @@ export class DecisionService {
930
1119
  return false;
931
1120
  }
932
1121
  }
1122
+ /** The asking agent's display name ("Owen"), when known. */
1123
+ async askerName(d) {
1124
+ if (d.system)
1125
+ return undefined;
1126
+ const named = await this.deps.displayName?.(d.asker).catch(() => undefined);
1127
+ if (named)
1128
+ return named;
1129
+ const identity = await this.deps.identityOf(d.asker).catch(() => ({}));
1130
+ return identity.username && identity.username !== d.asker ? identity.username : undefined;
1131
+ }
933
1132
  /** Post a line in the card's thread, as the asker. */
934
1133
  async postInThread(d, text) {
935
1134
  const slack = this.deps.slack();
@@ -982,6 +1181,10 @@ export class DecisionService {
982
1181
  if (d.system)
983
1182
  return;
984
1183
  const ok = await this.deps.deliverToAgent(d.asker, text).catch(() => false);
1184
+ // A bare `reply` after this prompt follows the decision, not an unrelated
1185
+ // newer work item — recorded only once it was delivered.
1186
+ if (ok && (d.card || d.ticket))
1187
+ AgentPromptReferenceService.getInstance().note(d.asker, { decisionId: d.id }, `[DECISION ${d.id}]`);
985
1188
  if (!ok)
986
1189
  this.logger.warn('Could not deliver the decision to the asking agent', { decisionId: d.id, asker: d.asker });
987
1190
  // The orchestrator asked on the owner's behalf for a ticket it does not own: tell it too.
@@ -1024,6 +1227,32 @@ function systemChoiceFromText(d, text) {
1024
1227
  }
1025
1228
  return null;
1026
1229
  }
1230
+ /**
1231
+ * The number of a `D-<n>` id (0 when unreadable), for ordering.
1232
+ *
1233
+ * @param id - Decision id
1234
+ * @returns n
1235
+ */
1236
+ function idNumber(id) {
1237
+ return Number(/(\d+)$/.exec(id)?.[1] ?? 0);
1238
+ }
1239
+ /**
1240
+ * The files line of an answer note: name, type and link of each file, and how
1241
+ * to hear a voice note that came without a transcript.
1242
+ *
1243
+ * @param files - Answer files
1244
+ * @returns Text starting with a space, or '' for none
1245
+ */
1246
+ function filesLine(files) {
1247
+ if (files.length === 0)
1248
+ return '';
1249
+ const list = files.map((f) => `${f.name}${f.mimetype ? ` (${f.mimetype})` : ''}${f.permalink ? ` ${f.permalink}` : ''}`).join('; ');
1250
+ const unheard = files.some((f) => !f.transcript && /^(audio|video)\//.test(f.mimetype ?? ''));
1251
+ const hint = unheard
1252
+ ? ' If you have not heard it yet, transcribe it with the transcribe-audio skill (the file reached you with the owner\'s message, or fetch it from the link).'
1253
+ : '';
1254
+ return ` Files: ${list}.${hint}`;
1255
+ }
1027
1256
  /**
1028
1257
  * Message of an unknown error.
1029
1258
  *