crewly 1.20.190 → 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 (77) 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/dist/backend/backend/src/constants.d.ts +22 -1
  6. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  7. package/dist/backend/backend/src/constants.js +28 -1
  8. package/dist/backend/backend/src/constants.js.map +1 -1
  9. package/dist/backend/backend/src/controllers/decisions/decisions.controller.d.ts.map +1 -1
  10. package/dist/backend/backend/src/controllers/decisions/decisions.controller.js +3 -1
  11. package/dist/backend/backend/src/controllers/decisions/decisions.controller.js.map +1 -1
  12. package/dist/backend/backend/src/services/decisions/decision-card.d.ts +56 -5
  13. package/dist/backend/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  14. package/dist/backend/backend/src/services/decisions/decision-card.js +118 -11
  15. package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -1
  16. package/dist/backend/backend/src/services/decisions/decision-routing.d.ts +26 -1
  17. package/dist/backend/backend/src/services/decisions/decision-routing.d.ts.map +1 -1
  18. package/dist/backend/backend/src/services/decisions/decision-routing.js +36 -2
  19. package/dist/backend/backend/src/services/decisions/decision-routing.js.map +1 -1
  20. package/dist/backend/backend/src/services/decisions/decision.service.d.ts +64 -4
  21. package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  22. package/dist/backend/backend/src/services/decisions/decision.service.js +252 -30
  23. package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
  24. package/dist/backend/backend/src/services/decisions/decision.wiring.d.ts.map +1 -1
  25. package/dist/backend/backend/src/services/decisions/decision.wiring.js +16 -1
  26. package/dist/backend/backend/src/services/decisions/decision.wiring.js.map +1 -1
  27. package/dist/backend/backend/src/services/open-items/open-item-card.d.ts +34 -0
  28. package/dist/backend/backend/src/services/open-items/open-item-card.d.ts.map +1 -1
  29. package/dist/backend/backend/src/services/open-items/open-item-card.js +88 -0
  30. package/dist/backend/backend/src/services/open-items/open-item-card.js.map +1 -1
  31. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
  32. package/dist/backend/backend/src/services/open-items/open-items.service.js +8 -2
  33. package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -1
  34. package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts.map +1 -1
  35. package/dist/backend/backend/src/services/open-items/open-items.wiring.js +1 -0
  36. package/dist/backend/backend/src/services/open-items/open-items.wiring.js.map +1 -1
  37. package/dist/backend/backend/src/services/system/auto-update.service.d.ts +2 -2
  38. package/dist/backend/backend/src/services/system/auto-update.service.js +2 -2
  39. package/dist/backend/backend/src/types/decision.types.d.ts +30 -2
  40. package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
  41. package/dist/backend/backend/src/types/slack.types.d.ts +11 -0
  42. package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
  43. package/dist/backend/backend/src/types/slack.types.js.map +1 -1
  44. package/dist/backend/build-info.json +2 -2
  45. package/dist/backend/config/constants.d.ts +1 -1
  46. package/dist/backend/config/constants.d.ts.map +1 -1
  47. package/dist/backend/config/constants.js +2 -2
  48. package/dist/backend/config/constants.js.map +1 -1
  49. package/dist/cli/backend/src/constants.d.ts +22 -1
  50. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  51. package/dist/cli/backend/src/constants.js +28 -1
  52. package/dist/cli/backend/src/constants.js.map +1 -1
  53. package/dist/cli/backend/src/services/decisions/decision-card.d.ts +56 -5
  54. package/dist/cli/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  55. package/dist/cli/backend/src/services/decisions/decision-card.js +118 -11
  56. package/dist/cli/backend/src/services/decisions/decision-card.js.map +1 -1
  57. package/dist/cli/backend/src/services/decisions/decision.service.d.ts +64 -4
  58. package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  59. package/dist/cli/backend/src/services/decisions/decision.service.js +252 -30
  60. package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
  61. package/dist/cli/backend/src/services/open-items/open-item-card.d.ts +34 -0
  62. package/dist/cli/backend/src/services/open-items/open-item-card.d.ts.map +1 -1
  63. package/dist/cli/backend/src/services/open-items/open-item-card.js +88 -0
  64. package/dist/cli/backend/src/services/open-items/open-item-card.js.map +1 -1
  65. package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
  66. package/dist/cli/backend/src/services/open-items/open-items.service.js +8 -2
  67. package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -1
  68. package/dist/cli/backend/src/types/decision.types.d.ts +30 -2
  69. package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -1
  70. package/dist/cli/backend/src/types/slack.types.d.ts +11 -0
  71. package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
  72. package/dist/cli/backend/src/types/slack.types.js.map +1 -1
  73. package/dist/cli/config/constants.d.ts +1 -1
  74. package/dist/cli/config/constants.d.ts.map +1 -1
  75. package/dist/cli/config/constants.js +2 -2
  76. package/dist/cli/config/constants.js.map +1 -1
  77. package/package.json +1 -1
@@ -14,12 +14,13 @@
14
14
  *
15
15
  * @module services/decisions/decision.service
16
16
  */
17
+ import { isAudioOrVideo } from '../../utils/inbound-file-hint.utils.js';
17
18
  import { AgentPromptReferenceService } from '../orc/agent-prompt-reference.service.js';
18
19
  import { DECISION_CONSTANTS, OPEN_ITEMS_CONSTANTS, ORCHESTRATOR_SESSION_NAME } from '../../constants.js';
19
20
  import { questionSimilarity } from '../open-items/open-item-card.js';
20
21
  import { LoggerService } from '../core/logger.service.js';
21
22
  import { DecisionContractError, matchOption, parseOptions, resolveDefault, validateAskOwner } from './decision-contract.js';
22
- 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';
23
24
  import { PENDING_DECISION_STATUSES } from './decision-store.js';
24
25
  /** Thrown for requests the caller must fix (HTTP 4xx). */
25
26
  export class DecisionError extends Error {
@@ -85,6 +86,12 @@ export class DecisionService {
85
86
  now;
86
87
  timer = null;
87
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;
88
95
  /**
89
96
  * @param deps - Collaborators
90
97
  */
@@ -286,6 +293,7 @@ export class DecisionService {
286
293
  ...(ask.sensitive ? { sensitive: ask.sensitive } : {}),
287
294
  ...(ask.browser ? { browser: ask.browser } : {}),
288
295
  ...(ask.title ? { title: ask.title } : {}),
296
+ ...(ask.body?.length ? { body: ask.body } : {}),
289
297
  ...(ask.place ? { place: ask.place } : {}),
290
298
  ...(ask.requestRef ? { requestRef: ask.requestRef } : {}),
291
299
  ...(ask.source ? { source: ask.source } : {}),
@@ -493,8 +501,12 @@ export class DecisionService {
493
501
  return this.apply(decision, choice, 'reaction', event.user);
494
502
  }
495
503
  /**
496
- * An owner reply in a card's thread. The newest open card in that thread
497
- * 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.
498
510
  *
499
511
  * @param message - Inbound Slack message
500
512
  * @returns What happened
@@ -509,14 +521,75 @@ export class DecisionService {
509
521
  const candidates = await this.deps.store.list((d) => d.status === 'open' &&
510
522
  !!d.card &&
511
523
  d.card.slackChannelId === message.channelId &&
512
- (d.card.threadTs === message.threadTs || d.card.messageTs === message.threadTs));
513
- const decision = candidates[0];
514
- 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)
515
528
  return { handled: false, reason: 'no open card in this thread' };
516
- const choice = decision.system ? systemChoiceFromText(decision, message.text ?? '') : choiceFromText(decision, message.text ?? '');
517
- if (!choice)
518
- return { handled: false, reason: decision.system ? 'not one of the options' : 'empty reply', decision };
519
- 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] };
520
593
  }
521
594
  /**
522
595
  * The owner answered from the dashboard.
@@ -661,7 +734,9 @@ export class DecisionService {
661
734
  async cancelWhere(filter, note) {
662
735
  const open = await this.deps.store.list((d) => PENDING_DECISION_STATUSES.has(d.status) && filter(d));
663
736
  for (const d of open) {
664
- 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);
665
740
  if (!done)
666
741
  continue;
667
742
  await this.refreshCard(done);
@@ -702,7 +777,7 @@ export class DecisionService {
702
777
  * Apply a choice: resolve (or snooze), update the card, log the ticket,
703
778
  * tell the asker, close the watchdog entry.
704
779
  */
705
- async apply(decision, choice, via, user, batchNotes) {
780
+ async apply(decision, choice, via, user, batchNotes, files = []) {
706
781
  const now = this.now();
707
782
  if (choice.kind === 'skip')
708
783
  return this.applySkip(decision, via, user, batchNotes);
@@ -735,14 +810,22 @@ export class DecisionService {
735
810
  }
736
811
  const patch = choice.kind === 'option'
737
812
  ? { status: 'resolved', chosenKey: choice.key, answerText: undefined }
738
- : { 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;
739
818
  const resolved = await this.deps.store.update(decision.id, (cur) => PENDING_DECISION_STATUSES.has(cur.status)
740
819
  ? { ...patch, answeredVia: via, ...(user ? { answeredBy: user } : {}), resolvedAt: now.toISOString(), remindAt: undefined }
741
820
  : null);
742
821
  if (!resolved)
743
822
  return { handled: false, reason: 'already settled', decision };
744
823
  await this.refreshCard(resolved);
745
- 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}”`;
746
829
  if (resolved.ticket)
747
830
  await this.logTicket(resolved, `owner decision ${resolved.id}: ${answer} (${via})`, true);
748
831
  await this.notifyAsker(resolved, this.answerNote(resolved), batchNotes);
@@ -777,10 +860,17 @@ export class DecisionService {
777
860
  answerNote(d) {
778
861
  const where = this.whereLine(d);
779
862
  const about = `for: "${d.question}"${d.ticket ? ` (ticket ${d.ticket.id})` : ''}`;
863
+ const files = filesLine(d.answerFiles ?? []);
780
864
  if (d.chosenKey) {
781
- 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}`;
782
866
  }
783
- 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}`;
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}`);
872
+ }
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}`;
784
874
  }
785
875
  /**
786
876
  * Where the asker's follow-up belongs: a command naming the decision — the
@@ -807,6 +897,9 @@ export class DecisionService {
807
897
  const acted = [];
808
898
  try {
809
899
  const now = this.now();
900
+ if (this.startedAt === null)
901
+ this.startedAt = now.getTime();
902
+ const reminders = [];
810
903
  for (const d of await this.deps.store.list((x) => x.status === 'open')) {
811
904
  try {
812
905
  if (!d.card) {
@@ -824,6 +917,14 @@ export class DecisionService {
824
917
  }
825
918
  if (Date.parse(d.deadline) > now.getTime())
826
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
+ }
827
928
  // A default that never lets anything through (a held browser
828
929
  // action's No, a declined Terms card) is applied even when sensitive.
829
930
  if (d.sensitive && !defaultIsSafe(d)) {
@@ -838,6 +939,7 @@ export class DecisionService {
838
939
  this.logger.warn('Decision tick step failed', { decisionId: d.id, error: errText(err) });
839
940
  }
840
941
  }
942
+ acted.push(...(await this.postWaitReminders(reminders, now)));
841
943
  await this.deps.store.prune().catch(() => 0);
842
944
  }
843
945
  finally {
@@ -845,30 +947,111 @@ export class DecisionService {
845
947
  }
846
948
  return acted;
847
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
+ }
848
972
  /** Post the "Remind me tomorrow" reminder in the card's thread. */
849
973
  async remindNow(d) {
974
+ if (await this.withdrawIfMoot(d))
975
+ return;
850
976
  const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && cur.remindAt ? { remindAt: undefined } : null));
851
977
  if (!updated)
852
978
  return;
853
979
  const owner = this.deps.ownerUserId?.();
854
- 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.`);
855
981
  await this.refreshCard(updated);
856
982
  }
857
- /** Non-sensitive deadline: apply the default (or say it keeps waiting). */
858
- async applyDefault(d, now) {
859
- const line = deadlineDefaultLine(d, now);
860
- if (d.defaultKey === DECISION_CONSTANTS.WAIT_DEFAULT) {
861
- if (d.deadlineNoticeAt)
862
- 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';
863
992
  const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.deadlineNoticeAt ? { deadlineNoticeAt: now.toISOString() } : null));
864
993
  if (!updated)
865
- return false;
866
- await this.postInThread(updated, line);
994
+ return null;
867
995
  if (updated.ticket)
868
- await this.logTicket(updated, `owner decision ${updated.id}: no answer by the deadline — still waiting`, false);
869
- 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)}`);
870
- 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]);
871
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));
872
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);
873
1056
  if (!resolved)
874
1057
  return false;
@@ -887,6 +1070,8 @@ export class DecisionService {
887
1070
  const due = Math.max(Date.parse(d.deadline), Date.parse(d.createdAt) + DECISION_CONSTANTS.SENSITIVE_REASK_AFTER_MS);
888
1071
  if (now.getTime() < due)
889
1072
  return false;
1073
+ if (await this.withdrawIfMoot(d))
1074
+ return true;
890
1075
  const updated = await this.deps.store.update(d.id, (cur) => (cur.status === 'open' && !cur.reaskedAt ? { reaskedAt: now.toISOString() } : null));
891
1076
  if (!updated)
892
1077
  return false;
@@ -921,8 +1106,9 @@ export class DecisionService {
921
1106
  let ownerName;
922
1107
  if (!pending && d.answeredBy)
923
1108
  ownerName = await this.deps.userName?.(d.answeredBy).catch(() => undefined);
924
- const blocks = pending ? renderOpenCard(d, this.deps.instanceId(), now) : renderSettledCard(d, ownerName, now);
925
- 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)}`;
926
1112
  const token = d.card.ownBot ? (await this.deps.identityOf(d.card.postedBy).catch(() => ({}))).botToken : undefined;
927
1113
  try {
928
1114
  await slack.updateMessage(d.card.slackChannelId, d.card.messageTs, text, blocks, token);
@@ -933,6 +1119,16 @@ export class DecisionService {
933
1119
  return false;
934
1120
  }
935
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
+ }
936
1132
  /** Post a line in the card's thread, as the asker. */
937
1133
  async postInThread(d, text) {
938
1134
  const slack = this.deps.slack();
@@ -1031,6 +1227,32 @@ function systemChoiceFromText(d, text) {
1031
1227
  }
1032
1228
  return null;
1033
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
+ }
1034
1256
  /**
1035
1257
  * Message of an unknown error.
1036
1258
  *