crewly 1.20.272 → 1.20.274

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 (61) hide show
  1. package/config/skills/agent/core/ask-owner/SKILL.md +20 -6
  2. package/config/skills/agent/core/ask-owner/execute.sh +18 -6
  3. package/config/skills/agent/core/ask-owner/execute.test.sh +19 -1
  4. package/config/skills/agent/core/reply/SKILL.md +11 -0
  5. package/dist/backend/backend/src/constants.d.ts +16 -6
  6. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  7. package/dist/backend/backend/src/constants.js +16 -6
  8. package/dist/backend/backend/src/constants.js.map +1 -1
  9. package/dist/backend/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  10. package/dist/backend/backend/src/services/decisions/decision-card.js +4 -2
  11. package/dist/backend/backend/src/services/decisions/decision-card.js.map +1 -1
  12. package/dist/backend/backend/src/services/decisions/decision-routing.js +1 -1
  13. package/dist/backend/backend/src/services/decisions/decision-routing.js.map +1 -1
  14. package/dist/backend/backend/src/services/decisions/decision.service.d.ts +93 -2
  15. package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  16. package/dist/backend/backend/src/services/decisions/decision.service.js +266 -19
  17. package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
  18. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.d.ts +2 -0
  19. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.d.ts.map +1 -1
  20. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.js +11 -0
  21. package/dist/backend/backend/src/services/messaging/owner-thread-sentinel.service.js.map +1 -1
  22. package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts +12 -3
  23. package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -1
  24. package/dist/backend/backend/src/services/open-items/open-item-extractor.js +125 -11
  25. package/dist/backend/backend/src/services/open-items/open-item-extractor.js.map +1 -1
  26. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts +7 -7
  27. package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
  28. package/dist/backend/backend/src/services/open-items/open-items.service.js +12 -21
  29. package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -1
  30. package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts.map +1 -1
  31. package/dist/backend/backend/src/services/open-items/open-items.wiring.js +0 -8
  32. package/dist/backend/backend/src/services/open-items/open-items.wiring.js.map +1 -1
  33. package/dist/backend/backend/src/types/decision.types.d.ts +10 -0
  34. package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
  35. package/dist/backend/build-info.json +2 -2
  36. package/dist/cli/backend/src/constants.d.ts +16 -6
  37. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  38. package/dist/cli/backend/src/constants.js +16 -6
  39. package/dist/cli/backend/src/constants.js.map +1 -1
  40. package/dist/cli/backend/src/services/decisions/decision-card.d.ts.map +1 -1
  41. package/dist/cli/backend/src/services/decisions/decision-card.js +4 -2
  42. package/dist/cli/backend/src/services/decisions/decision-card.js.map +1 -1
  43. package/dist/cli/backend/src/services/decisions/decision.service.d.ts +93 -2
  44. package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
  45. package/dist/cli/backend/src/services/decisions/decision.service.js +266 -19
  46. package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
  47. package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.d.ts +2 -0
  48. package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.d.ts.map +1 -1
  49. package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.js +11 -0
  50. package/dist/cli/backend/src/services/messaging/owner-thread-sentinel.service.js.map +1 -1
  51. package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts +12 -3
  52. package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -1
  53. package/dist/cli/backend/src/services/open-items/open-item-extractor.js +125 -11
  54. package/dist/cli/backend/src/services/open-items/open-item-extractor.js.map +1 -1
  55. package/dist/cli/backend/src/services/open-items/open-items.service.d.ts +7 -7
  56. package/dist/cli/backend/src/services/open-items/open-items.service.d.ts.map +1 -1
  57. package/dist/cli/backend/src/services/open-items/open-items.service.js +12 -21
  58. package/dist/cli/backend/src/services/open-items/open-items.service.js.map +1 -1
  59. package/dist/cli/backend/src/types/decision.types.d.ts +10 -0
  60. package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -1
  61. package/package.json +1 -1
@@ -64,6 +64,48 @@ function scopeOf(d) {
64
64
  asker: d.asker,
65
65
  };
66
66
  }
67
+ /**
68
+ * The Slack thread a decision's card is in (or will go to): `thread:<channel>:<ts>`.
69
+ *
70
+ * @param d - Decision
71
+ * @returns Key, or null when it has no thread yet
72
+ */
73
+ export function threadKeyOf(d) {
74
+ if (d.card)
75
+ return `thread:${d.card.slackChannelId}:${d.card.threadTs ?? d.card.messageTs}`;
76
+ if (d.place?.slackChannelId && d.place.threadTs)
77
+ return `thread:${d.place.slackChannelId}:${d.place.threadTs}`;
78
+ return null;
79
+ }
80
+ /**
81
+ * The topic keys of a decision: its Request, its ticket and its thread. Two
82
+ * decisions from the same asker that share one are about the same thing.
83
+ *
84
+ * @param d - Decision (or an ask about to be stored)
85
+ * @returns Keys
86
+ */
87
+ export function topicKeys(d) {
88
+ const keys = [];
89
+ if (d.requestRef)
90
+ keys.push(`request:${d.requestRef.requestId}`);
91
+ if (d.ticket)
92
+ keys.push(`ticket:${d.ticket.projectPath}#${d.ticket.id}`);
93
+ const thread = threadKeyOf(d);
94
+ if (thread)
95
+ keys.push(thread);
96
+ return keys;
97
+ }
98
+ /**
99
+ * Cards the harness may close for the owner (an answer elsewhere settles
100
+ * them): plain ask-owner cards and question cards — never a sensitive,
101
+ * system, browser or app card, which need their own explicit answer.
102
+ *
103
+ * @param d - Decision
104
+ * @returns True when it may be closed as a duplicate
105
+ */
106
+ function closableAsDuplicate(d) {
107
+ return !d.system && !d.sensitive && (!d.kind || d.kind === 'reply_question');
108
+ }
67
109
  /**
68
110
  * Next local `hour`:00 strictly tomorrow.
69
111
  *
@@ -108,6 +150,8 @@ export class DecisionService {
108
150
  * reminded: after an upgrade, old cards must not all ping the owner at once.
109
151
  */
110
152
  startedAt = null;
153
+ /** Last time open cards were checked for what they track having closed (epoch ms) */
154
+ lastMootCheck = 0;
111
155
  /**
112
156
  * @param deps - Collaborators
113
157
  */
@@ -232,6 +276,14 @@ export class DecisionService {
232
276
  }
233
277
  if (!teamId)
234
278
  teamId = await this.deps.teamOf(asker).catch(() => undefined);
279
+ // The same question already waits on an open card of this asker, in the
280
+ // same thread / ticket: that card stands — no second post.
281
+ const thread = await this.previewThreadKey(asker, ticket);
282
+ const same = await this.findOpenDuplicate(asker, ask.question, [...topicKeys({ ...(ticket ? { ticket } : {}) }), ...(thread ? [thread] : [])], (d) => !d.kind);
283
+ if (same) {
284
+ this.logger.info('Owner decision asked again while the same card is open — reusing it', { decisionId: same.id, asker });
285
+ return { ...same, reused: true };
286
+ }
235
287
  const workItemId = callerSession ? await this.deps.currentWorkItemId?.(callerSession).catch(() => undefined) : undefined;
236
288
  const decision = await this.deps.store.create({
237
289
  question: ask.question,
@@ -309,6 +361,16 @@ export class DecisionService {
309
361
  const skipped = await this.findSkipped({ ...(ask.requestRef ? { requestId: ask.requestRef.requestId } : {}), asker: ask.asker }, ask.question);
310
362
  if (skipped)
311
363
  throw this.alreadySkippedError(skipped);
364
+ if (ask.kind === 'reply_question') {
365
+ // A question the agent already has an open card for, here: no new card.
366
+ const thread = ask.place ? threadKeyOf({ place: ask.place }) : await this.previewThreadKey(ask.asker, undefined);
367
+ const keys = [...topicKeys({ ...(ask.requestRef ? { requestRef: ask.requestRef } : {}) }), ...(thread ? [thread] : [])];
368
+ const same = await this.findOpenDuplicate(ask.asker, ask.question, keys, closableAsDuplicate);
369
+ if (same) {
370
+ this.logger.info('Question asked again while its card is open — reusing it', { decisionId: same.id, asker: ask.asker });
371
+ return { ...same, reused: true };
372
+ }
373
+ }
312
374
  const teamId = await this.deps.teamOf(ask.asker).catch(() => undefined);
313
375
  const workItemId = await this.deps.currentWorkItemId?.(ask.asker).catch(() => undefined);
314
376
  const decision = await this.deps.store.create({
@@ -413,8 +475,18 @@ export class DecisionService {
413
475
  }
414
476
  }
415
477
  /**
416
- * Where the card goes: the ticket's thread (created on first use), else the
417
- * asker's current work destination, else a new thread in its team channel.
478
+ * Where the card goes — always the conversation the question belongs to:
479
+ *
480
+ * 1. the thread the question was asked in (`place`: reply questions);
481
+ * 2. the owner thread the asker owes right now (the owner is waiting there);
482
+ * 3. the ticket's thread, when it has one;
483
+ * 4. the thread of the asker's current work;
484
+ * 5. a new ticket thread (ticket asks);
485
+ * 6. top-level, only when there is no thread at all.
486
+ *
487
+ * The owner's conversation used to lose to the ticket thread: a ticket ask
488
+ * landed in an old ticket thread while the owner waited elsewhere
489
+ * (2026-10-08 card audit).
418
490
  */
419
491
  async placeFor(decision, identity, slack) {
420
492
  if (decision.system) {
@@ -423,35 +495,132 @@ export class DecisionService {
423
495
  throw new DecisionError(409, "No Slack DM with the owner from this machine's orc bot");
424
496
  return { slackChannelId: dm };
425
497
  }
498
+ const team = decision.teamId ? { teamId: decision.teamId } : {};
499
+ if (decision.place?.slackChannelId)
500
+ return { ...decision.place, ...team };
501
+ const thread = await this.threadPlace(decision.asker, decision.ticket);
502
+ if (thread)
503
+ return { ...team, ...thread };
426
504
  if (decision.ticket) {
427
505
  const t = decision.ticket;
428
- const existing = await this.deps.threads.get(t.projectPath, t.id);
429
- if (existing)
430
- return { slackChannelId: existing.slackChannelId, threadTs: existing.threadTs, teamId: existing.teamId };
431
506
  const channel = decision.teamId ? await this.deps.teamChannelOf(decision.teamId) : null;
432
507
  if (!channel)
433
508
  throw new DecisionError(409, `No Slack team channel for ${t.id}'s team — link the team to Slack first`);
434
509
  const { ts } = await this.send(slack, identity, { channelId: channel, text: ticketThreadRootText(t) });
435
- const thread = await this.deps.threads.set(t.projectPath, t.id, { slackChannelId: channel, threadTs: ts, ...(decision.teamId ? { teamId: decision.teamId } : {}) });
436
- return { slackChannelId: thread.slackChannelId, threadTs: thread.threadTs, teamId: thread.teamId };
437
- }
438
- if (decision.place?.slackChannelId) {
439
- return { ...decision.place, ...(decision.teamId ? { teamId: decision.teamId } : {}) };
510
+ const created = await this.deps.threads.set(t.projectPath, t.id, { slackChannelId: channel, threadTs: ts, ...(decision.teamId ? { teamId: decision.teamId } : {}) });
511
+ return { slackChannelId: created.slackChannelId, threadTs: created.threadTs, teamId: created.teamId };
440
512
  }
441
- const work = await this.deps.workDestination?.(decision.asker).catch(() => null);
442
- if (work?.slackChannelId && work.threadTs)
443
- return work;
444
- // No thread for the work (a new top-level post in the team channel): the
445
- // owner thread the asker owes is where the owner is looking.
446
513
  const ownerThread = await this.deps.ownerThreadOf?.(decision.asker).catch(() => null);
447
514
  if (ownerThread?.slackChannelId)
448
- return { ...ownerThread, ...(decision.teamId ? { teamId: decision.teamId } : {}) };
515
+ return { ...ownerThread, ...team };
516
+ const work = await this.deps.workDestination?.(decision.asker).catch(() => null);
449
517
  if (work?.slackChannelId)
450
518
  return work;
451
519
  const channel = decision.teamId ? await this.deps.teamChannelOf(decision.teamId) : null;
452
520
  if (!channel)
453
521
  throw new DecisionError(409, `No Slack place to ask in: ${decision.asker} has no team channel and no Slack conversation in hand`);
454
- return { slackChannelId: channel, ...(decision.teamId ? { teamId: decision.teamId } : {}) };
522
+ return { slackChannelId: channel, ...team };
523
+ }
524
+ /**
525
+ * The existing thread a card of `asker` goes to (steps 2–4 of
526
+ * {@link placeFor}), without creating anything.
527
+ *
528
+ * @param asker - Asking agent
529
+ * @param ticket - The ticket asked about, if any
530
+ * @returns The thread, or null when the card would start one / go top-level
531
+ */
532
+ async threadPlace(asker, ticket) {
533
+ const ownerThread = await this.deps.ownerThreadOf?.(asker).catch(() => null);
534
+ if (ownerThread?.slackChannelId && ownerThread.threadTs)
535
+ return ownerThread;
536
+ if (ticket) {
537
+ const existing = await this.deps.threads.get(ticket.projectPath, ticket.id).catch(() => null);
538
+ if (existing)
539
+ return { slackChannelId: existing.slackChannelId, threadTs: existing.threadTs, ...(existing.teamId ? { teamId: existing.teamId } : {}) };
540
+ }
541
+ const work = await this.deps.workDestination?.(asker).catch(() => null);
542
+ if (work?.slackChannelId && work.threadTs)
543
+ return work;
544
+ return null;
545
+ }
546
+ /**
547
+ * The thread key a new card of `asker` would land in, for the duplicate check.
548
+ *
549
+ * @param asker - Asking agent
550
+ * @param ticket - The ticket asked about, if any
551
+ * @returns Key, or null
552
+ */
553
+ async previewThreadKey(asker, ticket) {
554
+ const place = await this.threadPlace(asker, ticket).catch(() => null);
555
+ return place?.threadTs ? `thread:${place.slackChannelId}:${place.threadTs}` : null;
556
+ }
557
+ /**
558
+ * An open card of `asker` that shares a topic key and asks a similar
559
+ * question ({@link DECISION_CONSTANTS.SAME_OPEN_QUESTION_SIMILARITY}).
560
+ *
561
+ * @param asker - Asking agent
562
+ * @param question - The new question
563
+ * @param keys - Topic keys of the new ask ({@link topicKeys})
564
+ * @param eligible - Which open cards may stand in for it
565
+ * @returns The open card, or null
566
+ */
567
+ async findOpenDuplicate(asker, question, keys, eligible) {
568
+ if (keys.length === 0 || !question.trim())
569
+ return null;
570
+ const want = new Set(keys);
571
+ const open = await this.deps.store.list((d) => d.status === 'open' &&
572
+ d.asker === asker &&
573
+ eligible(d) &&
574
+ topicKeys(d).some((k) => want.has(k)) &&
575
+ questionSimilarity(d.question, question) >= DECISION_CONSTANTS.SAME_OPEN_QUESTION_SIMILARITY);
576
+ return open.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0] ?? null;
577
+ }
578
+ /**
579
+ * The owner settled `d`: close every other open card of the same asker
580
+ * that the same answer settles —
581
+ *
582
+ * - a similar question in the same thread / ticket / request
583
+ * ("✓ Closed — answered in D-7");
584
+ * - when the owner answered in words in the thread: the asker's older
585
+ * question cards in that thread, which his message moved past
586
+ * ("✓ Closed — answered by your reply to D-7").
587
+ *
588
+ * Sensitive, system, browser and app cards are never closed this way.
589
+ *
590
+ * @param d - The settled decision
591
+ * @returns Ids closed
592
+ */
593
+ async closeSiblings(d) {
594
+ if (!closableAsDuplicate(d))
595
+ return [];
596
+ const keys = new Set(topicKeys(d));
597
+ if (keys.size === 0)
598
+ return [];
599
+ const thread = threadKeyOf(d);
600
+ // Only words: a voice note / file does not say which card it answers (they stay open).
601
+ const words = d.status === 'resolved' && d.answeredVia === 'reply';
602
+ const answeredAt = Date.parse(d.resolvedAt ?? d.updatedAt);
603
+ const R = DECISION_CONSTANTS.CLOSED_REASONS;
604
+ const reasons = new Map();
605
+ const open = await this.deps.store.list((x) => x.id !== d.id && PENDING_DECISION_STATUSES.has(x.status) && x.asker === d.asker && closableAsDuplicate(x));
606
+ for (const x of open) {
607
+ if (!topicKeys(x).some((k) => keys.has(k)))
608
+ continue;
609
+ if (questionSimilarity(x.question, d.question) >= DECISION_CONSTANTS.SAME_OPEN_QUESTION_SIMILARITY) {
610
+ reasons.set(x.id, `${R.ANSWERED_IN_PREFIX}${d.id}`);
611
+ }
612
+ else if (words && x.kind === 'reply_question' && thread && threadKeyOf(x) === thread && Date.parse(x.createdAt) <= answeredAt) {
613
+ reasons.set(x.id, `${R.ANSWERED_BY_REPLY_PREFIX}${d.id}`);
614
+ }
615
+ }
616
+ const closed = [];
617
+ for (const [id, reason] of reasons) {
618
+ if ((await this.cancelWhere((x) => x.id === id, reason).catch(() => 0)) > 0)
619
+ closed.push(id);
620
+ }
621
+ if (closed.length > 0)
622
+ this.logger.info('Closed the open cards the same answer settles', { decisionId: d.id, closed });
623
+ return closed;
455
624
  }
456
625
  /**
457
626
  * Send as the agent's own bot; when Slack refuses that bot (not in the
@@ -674,6 +843,9 @@ export class DecisionService {
674
843
  for (const c of candidates) {
675
844
  if (c.system || (target && c.id === target.id))
676
845
  continue;
846
+ // Closed by the same answer (a duplicate of the target): nothing left to say.
847
+ if (target && (await this.deps.store.get(c.id))?.status === 'cancelled')
848
+ continue;
677
849
  const why = c.sensitive
678
850
  ? `It needs the owner's explicit OK (${c.sensitive}), so it stays open — do not go ahead on this file alone.`
679
851
  : c.kind === 'browser_action'
@@ -932,6 +1104,7 @@ export class DecisionService {
932
1104
  if (!resolved)
933
1105
  return { handled: false, reason: 'already settled', decision };
934
1106
  await this.refreshCard(resolved);
1107
+ const siblings = await this.closeSiblings(resolved);
935
1108
  const answer = resolved.chosenKey
936
1109
  ? optionLabel(resolved, resolved.chosenKey)
937
1110
  : resolved.answeredVia === 'thread'
@@ -939,7 +1112,7 @@ export class DecisionService {
939
1112
  : `“${resolved.answerText}”`;
940
1113
  if (resolved.ticket)
941
1114
  await this.logTicket(resolved, `owner decision ${resolved.id}: ${answer} (${via})`, true);
942
- await this.notifyAsker(resolved, this.answerNote(resolved), batchNotes, { owner: true });
1115
+ await this.notifyAsker(resolved, `${this.answerNote(resolved)}${siblingsLine(siblings)}`, batchNotes, { owner: true });
943
1116
  this.closeWatchdog(resolved);
944
1117
  this.logger.info('Owner decision resolved', { decisionId: resolved.id, via, chosen: resolved.chosenKey ?? 'text' });
945
1118
  return { handled: true, reason: 'resolved', decision: resolved };
@@ -956,9 +1129,10 @@ export class DecisionService {
956
1129
  if (!skipped)
957
1130
  return { handled: false, reason: 'already settled', decision };
958
1131
  await this.refreshCard(skipped);
1132
+ const siblings = await this.closeSiblings(skipped);
959
1133
  if (skipped.ticket)
960
1134
  await this.logTicket(skipped, `owner decision ${skipped.id}: skipped by the owner (${via})`, true);
961
- await this.notifyAsker(skipped, this.skipNote(skipped), batchNotes, { owner: true });
1135
+ await this.notifyAsker(skipped, `${this.skipNote(skipped)}${siblingsLine(siblings)}`, batchNotes, { owner: true });
962
1136
  this.closeWatchdog(skipped);
963
1137
  this.logger.info('Owner decision skipped', { decisionId: skipped.id, via });
964
1138
  return { handled: true, reason: 'skipped', decision: skipped };
@@ -1058,6 +1232,8 @@ export class DecisionService {
1058
1232
  }
1059
1233
  }
1060
1234
  acted.push(...(await this.postWaitReminders(reminders, now)));
1235
+ acted.push(...(await this.retryCardSync(now)));
1236
+ acted.push(...(await this.withdrawMootCards(now)));
1061
1237
  await this.deps.store.prune().catch(() => 0);
1062
1238
  }
1063
1239
  finally {
@@ -1087,6 +1263,50 @@ export class DecisionService {
1087
1263
  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.`);
1088
1264
  return true;
1089
1265
  }
1266
+ /**
1267
+ * Redraw cards Slack refused to update (a closed decision must not keep
1268
+ * live buttons), at most every {@link DECISION_CONSTANTS.CARD_SYNC_RETRY_MS}.
1269
+ *
1270
+ * @param now - Clock
1271
+ * @returns Ids redrawn
1272
+ */
1273
+ async retryCardSync(now) {
1274
+ const due = await this.deps.store.list((d) => d.cardSyncPending === true && !!d.card && now.getTime() - Date.parse(d.updatedAt) >= DECISION_CONSTANTS.CARD_SYNC_RETRY_MS);
1275
+ const done = [];
1276
+ for (const d of due) {
1277
+ if (await this.refreshCard(d))
1278
+ done.push(d.id);
1279
+ }
1280
+ if (done.length > 0)
1281
+ this.logger.info('Redrew decision cards Slack had refused to update', { decisionIds: done });
1282
+ return done;
1283
+ }
1284
+ /**
1285
+ * Withdraw open cards whose tracked item / ticket has closed meanwhile (the
1286
+ * owner answered in words, the ticket finished, the item expired) — the
1287
+ * card closes in Slack instead of dangling. Silent: nothing is posted to the
1288
+ * owner and the asker is not woken. At most every {@link DECISION_CONSTANTS.MOOT_CHECK_MS}.
1289
+ *
1290
+ * @param now - Clock
1291
+ * @returns Ids withdrawn
1292
+ */
1293
+ async withdrawMootCards(now) {
1294
+ if (!this.deps.trackedClosed || now.getTime() - this.lastMootCheck < DECISION_CONSTANTS.MOOT_CHECK_MS)
1295
+ return [];
1296
+ this.lastMootCheck = now.getTime();
1297
+ const open = await this.deps.store.list((d) => PENDING_DECISION_STATUSES.has(d.status) && !d.system && (!!d.requestRef || !!d.ticket));
1298
+ const done = [];
1299
+ for (const d of open) {
1300
+ const reason = await this.deps.trackedClosed(d).catch(() => null);
1301
+ if (!reason)
1302
+ continue;
1303
+ if ((await this.cancelWhere((x) => x.id === d.id, reason).catch(() => 0)) > 0)
1304
+ done.push(d.id);
1305
+ }
1306
+ if (done.length > 0)
1307
+ this.logger.info('Withdrew open cards whose question is already closed', { decisionIds: done });
1308
+ return done;
1309
+ }
1090
1310
  /** Post the "Remind me tomorrow" reminder in the card's thread. */
1091
1311
  async remindNow(d) {
1092
1312
  if (await this.withdrawIfMoot(d))
@@ -1119,6 +1339,11 @@ export class DecisionService {
1119
1339
  }
1120
1340
  if (d.waitReminderAt || d.ownerRepliedAt)
1121
1341
  return null;
1342
+ // A question Crewly lifted out of a reply never nudges the owner: he
1343
+ // answers in the thread when he wants to. Only explicit ask-owner cards
1344
+ // get the one reminder (2026-10-08: "Still waiting on you: 你自己知道吗?").
1345
+ if (d.kind === 'reply_question')
1346
+ return null;
1122
1347
  if (this.startedAt !== null && Date.parse(d.deadlineNoticeAt) < this.startedAt) {
1123
1348
  // Noticed before this process started (e.g. before the upgrade that
1124
1349
  // added reminders): marked, never reminded — no burst on first boot.
@@ -1216,6 +1441,17 @@ export class DecisionService {
1216
1441
  // ---------------------------------------------------------------------------
1217
1442
  /** Re-render the card for the decision's current state (with the posting bot's token). */
1218
1443
  async refreshCard(d) {
1444
+ if (!d.card)
1445
+ return false;
1446
+ const ok = await this.drawCard(d);
1447
+ // A failed redraw is retried on the tick (retryCardSync) until Slack takes it.
1448
+ await this.deps.store
1449
+ .update(d.id, (cur) => (ok ? (cur.cardSyncPending ? { cardSyncPending: undefined } : null) : cur.cardSyncPending ? null : { cardSyncPending: true }))
1450
+ .catch(() => null);
1451
+ return ok;
1452
+ }
1453
+ /** {@link refreshCard} without the retry bookkeeping. */
1454
+ async drawCard(d) {
1219
1455
  const slack = this.deps.slack();
1220
1456
  if (!d.card || !slack)
1221
1457
  return false;
@@ -1418,6 +1654,17 @@ function systemChoiceFromText(d, text) {
1418
1654
  }
1419
1655
  return null;
1420
1656
  }
1657
+ /**
1658
+ * The line telling the asker which of its other cards the answer closed.
1659
+ *
1660
+ * @param ids - Closed decision ids
1661
+ * @returns Text starting with a space, or '' for none
1662
+ */
1663
+ function siblingsLine(ids) {
1664
+ if (ids.length === 0)
1665
+ return '';
1666
+ return ` Crewly closed your other open card${ids.length === 1 ? '' : 's'} on this (${ids.join(', ')}): this answer covers ${ids.length === 1 ? 'it' : 'them'} — don't ask again.`;
1667
+ }
1421
1668
  /**
1422
1669
  * The number of a `D-<n>` id (0 when unreadable), for ordering.
1423
1670
  *