toolroll 0.9.52 → 0.9.54

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 (187) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +55 -80
  3. package/dist/agentconfig.d.ts +11 -36
  4. package/dist/agentconfig.js +3 -62
  5. package/dist/approval-policy.d.ts +1 -1
  6. package/dist/approval-policy.js +1 -1
  7. package/dist/board.d.ts +3 -19
  8. package/dist/board.js +4 -42
  9. package/dist/browser/workspace.js +18 -18
  10. package/dist/browser-workspace.d.ts +2 -1
  11. package/dist/builder.d.ts +9 -40
  12. package/dist/builder.js +50 -257
  13. package/dist/chat-actions.js +3 -14
  14. package/dist/chat-ask.js +8 -8
  15. package/dist/chat-controls.d.ts +0 -4
  16. package/dist/chat-controls.js +0 -1
  17. package/dist/chat-core.d.ts +171 -0
  18. package/dist/chat-core.js +288 -0
  19. package/dist/chat-decide.d.ts +10 -8
  20. package/dist/chat-decide.js +27 -24
  21. package/dist/chat-delivery-state.d.ts +51 -75
  22. package/dist/chat-delivery-state.js +140 -170
  23. package/dist/chat-delivery.d.ts +21 -4
  24. package/dist/chat-delivery.js +313 -195
  25. package/dist/chat-flow.d.ts +29 -1
  26. package/dist/chat-flow.js +24 -50
  27. package/dist/chat-messages.d.ts +319 -0
  28. package/dist/chat-messages.js +841 -0
  29. package/dist/chat-migration.d.ts +30 -0
  30. package/dist/chat-migration.js +802 -0
  31. package/dist/chat-quiet.d.ts +3 -0
  32. package/dist/chat-quiet.js +7 -2
  33. package/dist/chat-task-actions.d.ts +1 -1
  34. package/dist/chat-task-actions.js +0 -2
  35. package/dist/claim.d.ts +79 -244
  36. package/dist/claim.js +688 -1023
  37. package/dist/cli.d.ts +2 -0
  38. package/dist/cli.js +10 -2
  39. package/dist/contracts/chat-content.d.ts +71 -2
  40. package/dist/contracts/chat-content.js +22 -3
  41. package/dist/contracts/chat-tables.d.ts +11 -4
  42. package/dist/contracts/chat-tables.js +165 -87
  43. package/dist/contracts/cli.d.ts +0 -13
  44. package/dist/contracts/cli.js +0 -2
  45. package/dist/contracts/console-api.d.ts +5 -10
  46. package/dist/contracts/console-api.js +4 -10
  47. package/dist/contracts/discord-callback.js +2 -1
  48. package/dist/contracts/flow.d.ts +50 -0
  49. package/dist/contracts/flow.js +26 -1
  50. package/dist/contracts/lead-tools.d.ts +0 -13
  51. package/dist/contracts/lead-tools.js +3 -3
  52. package/dist/contracts/slack-callback.js +3 -2
  53. package/dist/contracts/store-json.d.ts +1 -29
  54. package/dist/contracts/store-json.js +4 -17
  55. package/dist/contracts/teams-callback.js +2 -1
  56. package/dist/converse.d.ts +1 -11
  57. package/dist/converse.js +6 -38
  58. package/dist/coordinator.d.ts +0 -1
  59. package/dist/coordinator.js +3 -5
  60. package/dist/demo.js +16 -26
  61. package/dist/desktop-update-gate.js +0 -1
  62. package/dist/desktop-update.js +1 -3
  63. package/dist/discord-chat.js +19 -38
  64. package/dist/discord-settings.js +4 -13
  65. package/dist/discord.js +4 -14
  66. package/dist/dispatch.d.ts +1 -1
  67. package/dist/dispatch.js +1 -1
  68. package/dist/dispose.d.ts +2 -3
  69. package/dist/dispose.js +17 -92
  70. package/dist/envelope.js +0 -3
  71. package/dist/exec.d.ts +0 -64
  72. package/dist/exec.js +3 -356
  73. package/dist/exhaustion.d.ts +22 -76
  74. package/dist/exhaustion.js +14 -140
  75. package/dist/export.js +8 -8
  76. package/dist/flow-engine.js +1 -1
  77. package/dist/flow-schedule.d.ts +154 -0
  78. package/dist/flow-schedule.js +321 -0
  79. package/dist/flow-share.js +1 -1
  80. package/dist/flow-triggers.d.ts +6 -1
  81. package/dist/flow-triggers.js +65 -10
  82. package/dist/flows-cli.js +8 -4
  83. package/dist/flows-ui.js +2 -0
  84. package/dist/flows.js +1 -1
  85. package/dist/guides.js +9 -29
  86. package/dist/invoke.d.ts +1 -24
  87. package/dist/invoke.js +17 -259
  88. package/dist/lead-status.js +2 -4
  89. package/dist/lead-voice.js +1 -1
  90. package/dist/mate-doors.d.ts +1 -2
  91. package/dist/mate-doors.js +12 -25
  92. package/dist/mate-tools.d.ts +2 -2
  93. package/dist/mate-tools.js +13 -24
  94. package/dist/modes.d.ts +19 -8
  95. package/dist/modes.js +24 -14
  96. package/dist/notification-rows.d.ts +30 -0
  97. package/dist/notification-rows.js +102 -0
  98. package/dist/operate-remote-arguments.js +2 -6
  99. package/dist/operate-remote.js +6 -7
  100. package/dist/operate.d.ts +0 -6
  101. package/dist/operate.js +226 -2248
  102. package/dist/phase-routing.d.ts +13 -23
  103. package/dist/phase-routing.js +13 -35
  104. package/dist/plan-auto.js +2 -2
  105. package/dist/plane-review.js +5 -11
  106. package/dist/planner-source.js +2 -1
  107. package/dist/policy.d.ts +1 -4
  108. package/dist/policy.js +0 -8
  109. package/dist/process-recovery-eligibility.js +2 -3
  110. package/dist/project-delete.js +24 -41
  111. package/dist/proposal.d.ts +4 -24
  112. package/dist/proposal.js +3 -88
  113. package/dist/provider-auth.d.ts +3 -3
  114. package/dist/provider-auth.js +4 -6
  115. package/dist/provider.d.ts +11 -25
  116. package/dist/provider.js +4 -36
  117. package/dist/push.js +1 -1
  118. package/dist/recipe-ui.js +6 -6
  119. package/dist/recipes.d.ts +3 -3
  120. package/dist/recipes.js +27 -23
  121. package/dist/result-shots.js +2 -2
  122. package/dist/retention.js +1 -1
  123. package/dist/run-closeout.d.ts +1 -1
  124. package/dist/run-closeout.js +1 -4
  125. package/dist/scope.d.ts +11 -65
  126. package/dist/scope.js +17 -82
  127. package/dist/serve.js +15 -24
  128. package/dist/server/chat.js +7 -30
  129. package/dist/server/flows.js +11 -10
  130. package/dist/server/guards.js +0 -1
  131. package/dist/server/pages-assets.js +1 -1
  132. package/dist/server/pages.js +0 -2
  133. package/dist/server/people-tokens.js +0 -4
  134. package/dist/server/route-table.d.ts +1 -1
  135. package/dist/server/route-table.js +2 -17
  136. package/dist/server/settings.js +6 -12
  137. package/dist/server/shared.d.ts +28 -212
  138. package/dist/server/shared.js +71 -780
  139. package/dist/server/task-views.js +9 -76
  140. package/dist/server/tasks.d.ts +1 -50
  141. package/dist/server/tasks.js +36 -946
  142. package/dist/setup-guide.d.ts +2 -2
  143. package/dist/slack-api.d.ts +5 -1
  144. package/dist/slack-api.js +2 -2
  145. package/dist/slack-chat.d.ts +9 -6
  146. package/dist/slack-chat.js +29 -53
  147. package/dist/slack-settings.js +6 -15
  148. package/dist/slack.js +6 -16
  149. package/dist/store.d.ts +200 -1702
  150. package/dist/store.js +811 -5439
  151. package/dist/surface.js +10 -24
  152. package/dist/task-composer.d.ts +1 -1
  153. package/dist/task-composer.js +1 -1
  154. package/dist/task-control.d.ts +3 -10
  155. package/dist/task-control.js +2 -7
  156. package/dist/teammate-desk.js +1 -1
  157. package/dist/teammate-question.js +12 -12
  158. package/dist/teammates-ui.js +1 -1
  159. package/dist/teams-chat.js +12 -9
  160. package/dist/teams-settings.js +2 -2
  161. package/dist/teams.js +4 -5
  162. package/dist/telegram-flow.js +5 -5
  163. package/dist/telegram-mate.d.ts +0 -3
  164. package/dist/telegram-mate.js +0 -1
  165. package/dist/telegram.js +4 -4
  166. package/dist/templates.d.ts +11 -7
  167. package/dist/templates.js +12 -9
  168. package/dist/toolroll-update.d.ts +20 -0
  169. package/dist/toolroll-update.js +104 -5
  170. package/dist/work-index.js +2 -6
  171. package/dist/workspace-revision.js +3 -1
  172. package/dist/workspace-ui.js +1 -1
  173. package/package.json +1 -1
  174. package/scripts/recovery-canary.mjs +3 -3
  175. package/dist/contest.d.ts +0 -318
  176. package/dist/contest.js +0 -753
  177. package/dist/held.d.ts +0 -150
  178. package/dist/held.js +0 -799
  179. package/dist/liveness.d.ts +0 -30
  180. package/dist/liveness.js +0 -42
  181. package/dist/pricing.d.ts +0 -45
  182. package/dist/pricing.js +0 -77
  183. package/dist/routine.d.ts +0 -228
  184. package/dist/routine.js +0 -764
  185. package/dist/slack-state.d.ts +0 -13
  186. package/dist/slack-state.js +0 -10
  187. package/dist/supervisor.mjs +0 -297
@@ -14,7 +14,7 @@ import { answerChatFlowPrompt, answerChoiceMessage, applyChatFlowTap, flowDecisi
14
14
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
15
15
  import { triggerConfigOf } from "./flow-triggers.js";
16
16
  import { telegramProgressCard } from "./telegram-progress.js";
17
- import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
17
+ import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView, wantedBeforePairing } from "./chat-quiet.js";
18
18
  import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
19
19
  import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
20
20
  import { leadChannelOf } from "./lead-context.js";
@@ -25,6 +25,8 @@ import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
25
25
  import { isShotsKind, resultShotsFor } from "./result-shots.js";
26
26
  import { messageTeammate } from "./teammate-desk.js";
27
27
  import { CHAT_APP_NAMES } from "./flow-triggers.js";
28
+ import { answersPrompt, applyDecideFeedback, applyDecideTap, decideFallbackLink, decideOffer, decideTargetOf, hasLiveDecideTokens, linksFor, mergedText, mintDecideButtons, offerFingerprint, openPromptFor, recordChatMerge } from "./chat-decide.js";
29
+ import { mergePullRequest } from "./pull-request-flow.js";
28
30
  export const chatObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
29
31
  ? v
30
32
  : {};
@@ -69,6 +71,72 @@ export async function channelAccess(options, binding, ceiling) {
69
71
  throw new ChatDeliveryError("Chat access changed");
70
72
  return latest;
71
73
  }
74
+ // ---- decisions that finish in the chat (chat-decide.ts), the same in every app --------------------------------------
75
+ /** Where a paired person taps: their binding, and the chat the card is in. */
76
+ const decideSeat = (state, binding, chat = binding.channel) => ({ channel: state.provider, binding: binding.id, chat, approver: binding.approver, generation: binding.generation });
77
+ /** What a card about one result, plan, failure or pull request may act on in this person's chat now, under their
78
+ * current ceiling; null keeps its link. Reads only. */
79
+ function decideOfferFor(options, binding, target, repos, now) {
80
+ if (target == null)
81
+ return null;
82
+ const principal = verifyApproverStanding(options.store, binding.approver, binding.generation, repos);
83
+ return principal.ok ? decideOffer(options.store, target, principal.who, now, options.state.provider, options.evidenceRoot) : null;
84
+ }
85
+ /** A card's buttons as a part carries them. */
86
+ const decideRows = (rows) => rows.map(row => row.map(one => "token" in one ? { label: one.label, token: one.token } : { label: one.label, link: one.link }));
87
+ /** The offer's buttons, minted now: they ride the part's message once it is sent (ChatState.partSent). */
88
+ function mintDecide(options, binding, target, offer, now) {
89
+ return { rows: decideRows(mintDecideButtons(options.store, decideSeat(options.state, binding), target, offer, now).rows) };
90
+ }
91
+ /**
92
+ * A decide button (chat-decide.ts), inside the tap's transaction: the same door, words and two taps as on Telegram.
93
+ * The card it rode is repainted in place; Request changes asks for the next message; a merge goes out after.
94
+ */
95
+ function applyChatDecideTap(options, event, binding, token, repos, now) {
96
+ const { store, state } = options;
97
+ // The card the button rode: this person's part on that message.
98
+ const row = state.prepare(`SELECT p.id, p.payload FROM chat_part p JOIN chat_event e ON e.provider = p.provider AND e.id = p.event
99
+ WHERE p.provider = :provider AND e.binding = ? AND p.message = ? ORDER BY p.id DESC LIMIT 1`).get(binding.id, event.ts);
100
+ const read = row === undefined ? null : readChatPart(String(row["payload"]));
101
+ const card = read?.ok === true ? read.value : null;
102
+ const tapped = applyDecideTap(store, decideSeat(state, binding, event.channel), { token, message: event.ts, shown: card?.text ?? "", repos, root: options.evidenceRoot, now });
103
+ if (tapped === null) {
104
+ state.plan(event.id, [{ text: "That button doesn't do anything now." }], now);
105
+ return null;
106
+ }
107
+ const replies = [];
108
+ let repainted = null;
109
+ if (tapped.edit !== undefined && row !== undefined && card !== null) {
110
+ const { decide: _decide, link: _link, also: _also, edit: _edit, ...kept } = card;
111
+ repainted = { ...kept, text: tapped.edit.text, ...(tapped.edit.rows.length === 0 ? {} : { decide: { rows: decideRows(tapped.edit.rows) } }) };
112
+ state.repaint(Number(row["id"]), repainted);
113
+ }
114
+ else
115
+ replies.push({ text: tapped.ack });
116
+ if (tapped.prompt !== undefined)
117
+ replies.push({ text: tapped.prompt.text, prompt: tapped.prompt.id });
118
+ state.plan(event.id, replies, now);
119
+ return tapped.merge === undefined || row === undefined || repainted === null ? null
120
+ : { merge: tapped.merge, part: Number(row["id"]), shown: tapped.edit?.text ?? card?.text ?? "", content: repainted };
121
+ }
122
+ /** GitHub is a network call: after the tap's transaction, then the card says how it went, and the ledger too. */
123
+ async function mergeFromChat(options, pending) {
124
+ const { merge } = pending;
125
+ let merged;
126
+ try {
127
+ const result = options.merge !== undefined ? await options.merge({ runId: merge.runId, by: merge.by })
128
+ : await mergePullRequest(options.store, { runId: merge.runId, by: merge.by, clock: () => nowOf(options) });
129
+ merged = result.ok ? { ok: true } : { ok: false, message: result.message };
130
+ }
131
+ catch {
132
+ merged = { ok: false, message: "GitHub couldn't be reached just now. Try again from the task." };
133
+ }
134
+ recordChatMerge(options.store, merge, merged, nowOf(options));
135
+ const { decide: _decide, ...kept } = pending.content;
136
+ // A merge that didn't land keeps a way to the task.
137
+ options.state.repaint(pending.part, { ...kept, text: mergedText(pending.shown, merged),
138
+ ...(merged.ok ? {} : { decide: { rows: decideRows(linksFor({ kind: "merge", taskId: merge.taskId, run: merge.runId })) } }) });
139
+ }
72
140
  export async function processChatEvent(options) {
73
141
  const { store, identity } = options, state = options.state, now = nowOf(options);
74
142
  if (!state.owns(identity.installation, options.owner, now) ||
@@ -88,14 +156,14 @@ export async function processChatEvent(options) {
88
156
  return false;
89
157
  const body = readChatPairBody(event.payload);
90
158
  const paired = body.ok
91
- ? state.pair(identity, body.value.hash, event.member, event.channel, nowOf(options))
159
+ ? state.pair(identity, body.value.hash, event.member, event.channel, nowOf(options), event.id)
92
160
  : null;
93
161
  if (!paired) {
94
162
  state.finish(event.id, true);
95
163
  return true;
96
164
  }
97
165
  state
98
- .prepare("UPDATE chat_event SET binding=? WHERE id=?")
166
+ .prepare("UPDATE chat_event SET binding=? WHERE provider=:provider AND id=?")
99
167
  .run(paired.id, event.id);
100
168
  state.plan(event.id, [
101
169
  {
@@ -114,7 +182,9 @@ export async function processChatEvent(options) {
114
182
  }
115
183
  const repos = await channelAccess(options, binding);
116
184
  if (event.kind === "action") {
117
- applyChatAction(options, event, binding, repos);
185
+ const merging = applyChatAction(options, event, binding, repos);
186
+ if (merging !== null)
187
+ await mergeFromChat(options, merging);
118
188
  return true;
119
189
  }
120
190
  const body = readChatMessageBody(event.payload);
@@ -134,12 +204,22 @@ export async function processChatEvent(options) {
134
204
  // A flow decision asked for this person's next message (Edit, Send back): it is the draft or the note.
135
205
  if (answerChatFlowPrompt({ store, state, label: options.label }, event, binding, { text, ...(typeof input.originalLength === "number" ? { originalLength: input.originalLength } : {}) }, repos, nowOf(options)))
136
206
  return true;
207
+ // After Request changes asked "What should change?": the person's next message in their own chat is the feedback
208
+ // (a reply in another message's thread keeps its own meaning).
209
+ if (event.channel === binding.channel && text.trim() !== "" && phoneCommand(text) === null) {
210
+ const prompt = openPromptFor(store, { channel: state.provider, binding: binding.id, chat: binding.channel }, nowOf(options));
211
+ if (prompt !== null && answersPrompt(prompt, event.thread !== "" && event.thread !== event.ts ? event.thread : null)) {
212
+ const answer = applyDecideFeedback(store, decideSeat(state, binding), prompt, text, repos, options.evidenceRoot, nowOf(options));
213
+ state.plan(event.id, [{ text: answer.said, ...(answer.link === null ? {} : { link: answer.link }) }], nowOf(options));
214
+ return true;
215
+ }
216
+ }
137
217
  // A "Person chooses" notice's thread reply is the note; the first other message after it is asked about (chat-flow.ts).
138
218
  if (answerChoiceMessage({ store, state, label: options.label }, event, binding, { text, ...(typeof input.originalLength === "number" ? { originalLength: input.originalLength } : {}), lead: input.lead === true }, nowOf(options), repos))
139
219
  return true;
140
220
  // A message to a teammate by name (v96), in someone's own chat with Toolroll: a card on its desk.
141
221
  if (event.channel === binding.channel) {
142
- const handed = messageTeammate(store, { who: binding.approver, repos, via: CHAT_APP_NAMES[state.channel] ?? "Chat" }, text, nowOf(options));
222
+ const handed = messageTeammate(store, { who: binding.approver, repos, via: CHAT_APP_NAMES[state.provider] ?? "Chat" }, text, nowOf(options));
143
223
  if (handed !== null) {
144
224
  state.plan(event.id, [{ text: handed.said, ...(handed.link === undefined ? {} : { link: handed.link }) }], nowOf(options));
145
225
  return true;
@@ -190,7 +270,7 @@ export async function processChatEvent(options) {
190
270
  const replies = [];
191
271
  const registry = await options.readProjects();
192
272
  const consumed = applyRoomInbound({
193
- store, channel: state.channel, backend: state.roomBackend(identity.installation, now), chatId: event.channel, isGroup: isRoom,
273
+ store, channel: state.provider, backend: state.roomBackend(identity.installation, now), chatId: event.channel, isGroup: isRoom,
194
274
  sender: { approver: binding.approver, generation: binding.generation, binding: binding.id }, updateKey: event.id, text,
195
275
  projects: registry, origin: options.origin(), now, report: { ignored: 0 },
196
276
  say: (reply, link) => replies.push({ text: reply, ...(link ? { link } : {}), ...(isRoom ? { channel: event.channel } : {}) }),
@@ -208,7 +288,7 @@ export async function processChatEvent(options) {
208
288
  ], nowOf(options));
209
289
  return true;
210
290
  }
211
- const request = chatHash(`${options.state.channel}:${binding.id}:${event.id}`).slice(0, 32);
291
+ const request = chatHash(`${options.state.provider}:${binding.id}:${event.id}`).slice(0, 32);
212
292
  let receipt = event.session === null
213
293
  ? null
214
294
  : store.mateRequestReceipt(event.session, request);
@@ -241,10 +321,10 @@ export async function processChatEvent(options) {
241
321
  return true;
242
322
  }
243
323
  state
244
- .prepare("UPDATE chat_event SET session=? WHERE id=? AND state='queued'")
324
+ .prepare("UPDATE chat_event SET session=? WHERE provider=:provider AND id=? AND state='queued'")
245
325
  .run(resolved.session.id, event.id);
246
326
  const contexts = state
247
- .prepare("SELECT p.payload FROM chat_part p JOIN chat_event e ON e.id=p.event WHERE e.binding=? AND e.channel=? AND (p.message=? OR e.thread=?) AND p.state='sent' ORDER BY p.id DESC LIMIT 100")
327
+ .prepare("SELECT p.payload FROM chat_part p JOIN chat_event e ON e.provider=p.provider AND e.id=p.event WHERE p.provider=:provider AND e.binding=? AND e.channel=? AND (p.message=? OR e.thread=?) AND p.state='sent' ORDER BY p.id DESC LIMIT 100")
248
328
  .all(binding.id, binding.channel, event.thread, event.thread)
249
329
  // Context only: a part that can't be read adds none.
250
330
  .flatMap((row) => { const read = readChatPart(String(row.payload)); return read.ok ? [read.value] : []; })
@@ -275,7 +355,7 @@ export async function processChatEvent(options) {
275
355
  if (context?.task) {
276
356
  about = { task: context.task, run: context.run ?? null };
277
357
  state
278
- .prepare("UPDATE chat_event SET payload=? WHERE id=? AND state='queued'")
358
+ .prepare("UPDATE chat_event SET payload=? WHERE provider=:provider AND id=? AND state='queued'")
279
359
  .run(JSON.stringify({ ...input, about }), event.id);
280
360
  }
281
361
  // An unknown surface names no channel rather than a guess.
@@ -397,16 +477,16 @@ export async function processChatEvent(options) {
397
477
  : new ChatDeliveryError(`${options.label} is waiting to retry`);
398
478
  if (problem.permanent) {
399
479
  state.plan(event.id, [{ text: problem.message }], nowOf(options));
400
- state.prepare("UPDATE chat_event SET next_at=NULL,problem=? WHERE id=?").run(problem.message, event.id);
480
+ state.prepare("UPDATE chat_event SET next_at=NULL,problem=? WHERE provider=:provider AND id=?").run(problem.message, event.id);
401
481
  return true;
402
482
  }
403
483
  state
404
- .prepare("UPDATE chat_runtime SET problem=? WHERE installation=? AND owner=?")
484
+ .prepare("UPDATE chat_runtime SET problem=? WHERE provider=:provider AND installation=? AND owner=?")
405
485
  .run(problem.message, identity.installation, options.owner);
406
486
  state.defer(event.id, problem.message, new Date(nowOf(options).getTime() + problem.retryMs));
407
487
  if (problem.code === "ratelimited")
408
488
  state
409
- .prepare("UPDATE chat_runtime SET retry_at=? WHERE installation=?")
489
+ .prepare("UPDATE chat_runtime SET retry_at=? WHERE provider=:provider AND installation=?")
410
490
  .run(new Date(nowOf(options).getTime() + problem.retryMs).toISOString(), identity.installation);
411
491
  return true;
412
492
  }
@@ -416,7 +496,7 @@ export async function processChatEvent(options) {
416
496
  * or that the channel's flow takes as a card. False: not for an inbox.
417
497
  */
418
498
  async function channelInboxEvent(options, event) {
419
- const { store, identity } = options, state = options.state, app = state.channel;
499
+ const { store, identity } = options, state = options.state, app = state.provider;
420
500
  const binding = event.binding === null ? null : state.bindingById(event.binding);
421
501
  if (binding !== null && event.channel === binding.channel)
422
502
  return false;
@@ -454,13 +534,14 @@ async function channelInboxEvent(options, event) {
454
534
  state.finish(event.id);
455
535
  return true;
456
536
  }
457
- state.prepare("UPDATE chat_event SET binding=? WHERE id=?").run(grant.id, event.id);
537
+ state.prepare("UPDATE chat_event SET binding=? WHERE provider=:provider AND id=?").run(grant.id, event.id);
458
538
  state.plan(event.id, [{ text: taken.said, channel: event.channel, ...(taken.link === undefined ? {} : { link: taken.link }) }], nowOf(options));
459
539
  return true;
460
540
  }
461
541
  /** Bound to the paired member, exact channel/message, pending proposal and current ceiling. */
462
542
  export function applyChatAction(options, event, binding, repos) {
463
543
  const state = options.state, { store } = options, now = nowOf(options), signals = [];
544
+ let merging = null;
464
545
  store.transact(() => {
465
546
  if (!state.owns(options.identity.installation, options.owner, now) ||
466
547
  !options.current() ||
@@ -476,6 +557,11 @@ export function applyChatAction(options, event, binding, repos) {
476
557
  return;
477
558
  }
478
559
  const token = body.value.token;
560
+ // A result's, plan's, failure's or pull request's own button (chat-decide.ts).
561
+ if (token.startsWith("d:")) {
562
+ merging = applyChatDecideTap(options, event, binding, token, repos, now);
563
+ return;
564
+ }
479
565
  // A flow decision's button (v88) is answered by the flow's own door.
480
566
  if (applyChatFlowTap({ store, state, label: options.label }, event, binding, token, repos, now))
481
567
  return;
@@ -486,7 +572,7 @@ export function applyChatAction(options, event, binding, repos) {
486
572
  if (applyChatAskTap({ store, state }, event, binding, token, now))
487
573
  return;
488
574
  const saved = state
489
- .prepare("SELECT a.*,p.message,e.binding,e.channel,e.thread FROM chat_action a JOIN chat_part p ON p.id=a.part JOIN chat_event e ON e.id=p.event WHERE token=?")
575
+ .prepare("SELECT a.token,a.part,a.proposal,a.phase,a.expires,a.consumed,p.message,e.binding,e.channel,e.thread FROM chat_action a JOIN chat_part p ON p.provider=a.provider AND p.id=a.part JOIN chat_event e ON e.provider=p.provider AND e.id=p.event WHERE a.provider=:provider AND a.token=?")
490
576
  .get(token);
491
577
  // The saved button, read by its schema: one that can't be read is a spent button, never a guess.
492
578
  const read = saved === undefined ? null : readProposalActionRow({ ...saved });
@@ -495,7 +581,7 @@ export function applyChatAction(options, event, binding, repos) {
495
581
  action.binding !== binding.id ||
496
582
  action.channel !== event.channel ||
497
583
  action.message !== event.ts ||
498
- (options.state.channel === "slack" && action.thread !== event.thread) ||
584
+ (options.state.provider === "slack" && action.thread !== event.thread) ||
499
585
  action.consumed !== null ||
500
586
  action.expires <= now.toISOString();
501
587
  if (invalid) {
@@ -517,7 +603,7 @@ export function applyChatAction(options, event, binding, repos) {
517
603
  proposal: proposal.id,
518
604
  edit: event.ts,
519
605
  };
520
- const preview = proposalPreview(store, proposal, repos, options.state.channel);
606
+ const preview = proposalPreview(store, proposal, repos, options.state.provider);
521
607
  const phase = action.phase;
522
608
  if (proposal.state !== "pending")
523
609
  content = { text: proposalOutcomeText(proposal), edit: event.ts };
@@ -544,10 +630,9 @@ export function applyChatAction(options, event, binding, repos) {
544
630
  }
545
631
  else {
546
632
  const outcome = confirmMateProposal(store, verified.who, proposal.id, now, {
547
- via: options.state.channel,
633
+ via: options.state.provider,
548
634
  evidenceRoot: options.evidenceRoot,
549
635
  confirm: phase === "yes",
550
- ...(options.held ? { held: options.held } : {}),
551
636
  deferSignal: (signal) => signals.push(signal),
552
637
  });
553
638
  if (!outcome.ok && outcome.reason === "needs-confirm") {
@@ -567,20 +652,21 @@ export function applyChatAction(options, event, binding, repos) {
567
652
  }
568
653
  // Repaint the original persisted card; retain one placement and fresh tokens.
569
654
  state
570
- .prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE id=?")
655
+ .prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE provider=:provider AND id=?")
571
656
  .run(savedChatPart(content), action.part);
572
657
  if (!content.proposal)
573
658
  state
574
- .prepare("UPDATE chat_action SET consumed=? WHERE proposal=? AND consumed IS NULL")
659
+ .prepare("UPDATE chat_action SET consumed=? WHERE provider=:provider AND proposal=? AND consumed IS NULL")
575
660
  .run(now.toISOString(), proposal.id);
576
661
  else
577
662
  state
578
- .prepare("UPDATE chat_action SET consumed=? WHERE token=?")
663
+ .prepare("UPDATE chat_action SET consumed=? WHERE provider=:provider AND token=?")
579
664
  .run(now.toISOString(), token);
580
665
  state.finish(event.id);
581
666
  });
582
667
  for (const signal of signals)
583
668
  signal();
669
+ return merging;
584
670
  }
585
671
  /**
586
672
  * Carry new conversation messages to every room that follows one: the
@@ -610,7 +696,7 @@ export async function planRoomMessages(options) {
610
696
  if (row === null)
611
697
  continue;
612
698
  for (const message of roomMessagesAfter(store, row.thread, room.cursor)) {
613
- const text = roomMessageText(message, state.channel, room.chat);
699
+ const text = roomMessageText(message, state.provider, room.chat);
614
700
  let carrier = null;
615
701
  if (room.kind === "private")
616
702
  carrier = live.find(one => one.channel === room.chat) ?? null;
@@ -632,7 +718,7 @@ export async function planRoomMessages(options) {
632
718
  if (message.role === "assistant" && message.turn !== null)
633
719
  for (const proposal of store.listMateProposals(row.thread, ["pending"]).filter(one => one.turn === message.turn))
634
720
  parts.push({ text: "", proposal: proposal.id, channel: room.chat });
635
- const id = chatHash(`${state.channel}:room:${room.id}:${message.id}`);
721
+ const id = chatHash(`${state.provider}:room:${room.id}:${message.id}`);
636
722
  const target = carrier;
637
723
  store.transact(() => {
638
724
  if (parts.length > 0 && state.enqueue({ id, installation: identity.installation, binding: target.id, kind: "message", channel: room.chat, member: target.member, ts: "", thread: "", payload: {}, created: now.toISOString() }))
@@ -648,7 +734,7 @@ function planQuietCard(options, binding, notification, now) {
648
734
  const { state, store, identity } = options;
649
735
  if (notification.taskRef === null || notification.taskId === null)
650
736
  return;
651
- const card = store.chatCardFor(`${state.channel}:${binding.id}`, notification.taskRef, store.getTask(notification.taskId)?.createdAt ?? notification.createdAt, now);
737
+ const card = store.chatCardFor(`${state.provider}:${binding.id}`, notification.taskRef, store.getTask(notification.taskId)?.createdAt ?? notification.createdAt, now);
652
738
  const view = quietCardView(store, card.tasks, now, options.evidenceRoot, binding.approver);
653
739
  if (view === null)
654
740
  return;
@@ -657,45 +743,53 @@ function planQuietCard(options, binding, notification, now) {
657
743
  if (card.message !== null) {
658
744
  if (card.digest === shown)
659
745
  return;
660
- state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(savedChatPart(content), now.toISOString(), Number(card.message));
746
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE provider=:provider AND id=? AND state!='dropped'").run(savedChatPart(content), now.toISOString(), Number(card.message));
661
747
  store.setChatCardMessage(card.id, card.message, shown);
662
748
  return;
663
749
  }
664
- const id = chatHash(`${state.channel}:card:${binding.id}:${card.id}`);
750
+ const id = chatHash(`${state.provider}:card:${binding.id}:${card.id}`);
665
751
  state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
666
752
  ts: "", thread: "", payload: {}, created: now.toISOString() });
667
753
  state.plan(id, [content], now);
668
- const part = state.prepare("SELECT id FROM chat_part WHERE event=?").get(id);
754
+ const part = state.prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=?").get(id);
669
755
  if (part !== undefined)
670
756
  store.setChatCardMessage(card.id, String(part.id), shown);
671
757
  }
672
758
  /** Any update for this person joins their open batch (two minutes from its first), and the batch's one message
673
759
  * is planned once, then repainted in place as more updates land. `notification`: an update that is not a finished
674
760
  * result (null for one that is). False when nothing in the batch reads as a line, so the update goes out on its own. */
675
- function planFinished(options, binding, taskRef, run, now, notification = null) {
761
+ function planFinished(options, binding, taskRef, run, now, notification = null, repos = []) {
676
762
  const { state, store, identity } = options;
677
- const batch = store.chatBatchFor(`${state.channel}:${binding.id}`, taskRef, run, now, BATCH_MS, notification);
763
+ const batch = store.chatBatchFor(`${state.provider}:${binding.id}`, taskRef, run, now, BATCH_MS, notification);
678
764
  const view = finishedView(store, batch, now, options.evidenceRoot, binding.approver);
679
765
  if (view === null)
680
766
  return false;
681
767
  // A single update keeps its task and run, so a reply to it names that work.
682
768
  const single = batch.items.length === 1 ? batch.items[0] : null;
683
769
  const task = single === null ? null : store.refById(single.taskRef)?.externalId ?? null;
684
- const content = { text: view.text, link: view.link, ...(view.also === undefined ? {} : { also: view.also }),
685
- ...(task === null ? {} : { task }), ...(single?.run == null ? {} : { run: single.run }) };
686
- const shown = chatHash(JSON.stringify(content));
770
+ const about = { ...(task === null ? {} : { task }), ...(single?.run == null ? {} : { run: single.run }) };
771
+ // A message about one result, plan, failure or pull request acts in place (chat-decide.ts), as on Telegram: its
772
+ // buttons are minted only when what it offers changed, or its own were spent (Not now), so an unchanged repaint keeps them.
773
+ const offer = decideOfferFor(options, binding, view.target, repos, now);
774
+ const content = offer === null
775
+ ? { text: view.text, link: view.link, ...(view.also === undefined ? {} : { also: view.also }), ...about }
776
+ : { text: offer.text ?? view.text, ...about };
777
+ const shown = chatHash(JSON.stringify(offer === null ? content : [content, offerFingerprint(offer)]));
687
778
  if (batch.message !== null) {
688
- if (batch.digest === shown)
779
+ const placed = state.part(Number(batch.message));
780
+ const live = offer === null || placed?.state === "pending" || (placed?.message != null && hasLiveDecideTokens(store, state.provider, binding.channel, placed.message, now));
781
+ if (batch.digest === shown && live)
689
782
  return true;
690
- state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(savedChatPart(content), now.toISOString(), Number(batch.message));
783
+ const painted = offer === null ? content : { ...content, decide: mintDecide(options, binding, view.target, offer, now) };
784
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE provider=:provider AND id=? AND state!='dropped'").run(savedChatPart(painted), now.toISOString(), Number(batch.message));
691
785
  store.setChatBatchMessage(batch.id, batch.message, shown);
692
786
  return true;
693
787
  }
694
- const id = chatHash(`${state.channel}:batch:${binding.id}:${batch.id}`);
788
+ const id = chatHash(`${state.provider}:batch:${binding.id}:${batch.id}`);
695
789
  state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
696
790
  ts: "", thread: "", payload: {}, created: now.toISOString() });
697
- state.plan(id, [content], now);
698
- const part = state.prepare("SELECT id FROM chat_part WHERE event=?").get(id);
791
+ state.plan(id, [offer === null ? content : { ...content, decide: mintDecide(options, binding, view.target, offer, now) }], now);
792
+ const part = state.prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=?").get(id);
699
793
  if (part !== undefined)
700
794
  store.setChatBatchMessage(batch.id, String(part.id), shown);
701
795
  return true;
@@ -716,20 +810,20 @@ function planResultShots(options, binding, notification, repos, id, now) {
716
810
  if (visit !== null) {
717
811
  for (const key of [`flow-send:${visit[1]}:${visit[2]}`, `flow-choose:${visit[1]}:${visit[2]}`]) {
718
812
  const notice = store.handle.prepare("SELECT id FROM notification WHERE dedupe_key=?").get(key);
719
- const part = notice === undefined ? undefined : state.prepare("SELECT id FROM chat_part WHERE event=? ORDER BY id DESC LIMIT 1").get(chatHash(`${state.channel}:notice:${binding.id}:${Number(notice["id"])}`));
813
+ const part = notice === undefined ? undefined : state.prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=? ORDER BY id DESC LIMIT 1").get(chatHash(`${state.provider}:notice:${binding.id}:${Number(notice["id"])}`));
720
814
  if (part !== undefined)
721
815
  return Number(part.id);
722
816
  }
723
817
  return null;
724
818
  }
725
- const batch = notification.taskRef === null ? null : store.chatBatchMessageFor(`${state.channel}:${binding.id}`, notification.taskRef, notification.run);
819
+ const batch = notification.taskRef === null ? null : store.chatBatchMessageFor(`${state.provider}:${binding.id}`, notification.taskRef, notification.run);
726
820
  if (batch !== null)
727
821
  return Number(batch);
728
- const card = state.prepare("SELECT part FROM chat_progress WHERE binding=? AND run=?").get(binding.id, notification.run);
822
+ const card = state.prepare("SELECT part FROM chat_progress WHERE provider=:provider AND binding=? AND run=?").get(binding.id, notification.run);
729
823
  if (card !== undefined)
730
824
  return Number(card.part);
731
825
  for (const one of store.handle.prepare("SELECT id FROM notification WHERE source_run=? AND recipient IS NULL AND id<? ORDER BY id DESC").all(notification.run, notification.id)) {
732
- const part = state.prepare("SELECT id FROM chat_part WHERE event=? ORDER BY id LIMIT 1").get(chatHash(`${state.channel}:notice:${binding.id}:${Number(one["id"])}`));
826
+ const part = state.prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=? ORDER BY id LIMIT 1").get(chatHash(`${state.provider}:notice:${binding.id}:${Number(one["id"])}`));
733
827
  if (part !== undefined)
734
828
  return Number(part.id);
735
829
  }
@@ -738,7 +832,7 @@ function planResultShots(options, binding, notification, repos, id, now) {
738
832
  const image = (artifact, sha256) => ({ taskId: where.task, run: where.run, artifact, sha256 });
739
833
  const parts = plan.kind === "line" ? [{ text: plan.text, ...where }]
740
834
  // Teams has no file upload here: one message that links to the saved result.
741
- : state.channel === "teams" ? [{ text: plan.shots.length === 1 ? plan.shots[0].caption : `${plan.shots[0].caption} · ${plan.shots.length} screenshots`,
835
+ : state.provider === "teams" ? [{ text: plan.shots.length === 1 ? plan.shots[0].caption : `${plan.shots[0].caption} · ${plan.shots.length} screenshots`,
742
836
  image: image(plan.shots[0].artifact, plan.shots[0].sha256), shot: { follows: null }, ...where }]
743
837
  : plan.shots.map(one => ({ text: one.caption, image: image(one.artifact, one.sha256), shot: { follows }, ...where }));
744
838
  state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
@@ -760,163 +854,187 @@ export async function planChatNotifications(options) {
760
854
  catch { /* Retried on the next pass. */ }
761
855
  enqueueLeadLapses(store, nowOf(options));
762
856
  const cursor = Number(state
763
- .prepare("SELECT notification FROM chat_runtime WHERE installation=?")
857
+ .prepare("SELECT notification FROM chat_runtime WHERE provider=:provider AND installation=?")
764
858
  .get(identity.installation)?.notification ?? 0);
859
+ // A person who paired after the cursor passed is still owed what wanted them before: once per pairing, their
860
+ // unresolved attention notices and open decisions from before it, through the same door as every new one.
861
+ for (const binding of bindings) {
862
+ const owed = `owed-before-pairing:${binding.id}`;
863
+ if (state.meta(identity.installation, owed) !== null)
864
+ continue;
865
+ for (const notification of store.notificationsWantedBefore(binding.created, cursor))
866
+ offerNotice(options, notification, binding, registry);
867
+ state.setMeta(identity.installation, owed, "offered", nowOf(options));
868
+ }
765
869
  // Every paired person is a destination of their own, under their own ceiling.
766
870
  for (const notification of store.notificationsAfter(cursor, 100)) {
767
- for (const binding of bindings) {
768
- const repos = channelRepos(store, binding.approver, registry);
769
- store.transact(() => {
770
- // One person's notification (v83) goes to that person alone, task or not.
771
- const personal = notification.recipient !== null;
772
- if (notification.createdAt >= binding.created &&
773
- notification.resolvedAt === null &&
774
- // A promise the lead made on another chat is reported there (lead-commitments.ts); one its owner asked for on
775
- // this chat is always said here, however quiet the lead's own work is kept.
776
- (promiseChannelOf(notification) ?? state.channel) === state.channel &&
777
- // Pings follow responsibility: the lead's work, this person's own act and a muted project stay in the console.
778
- (promiseChannelOf(notification) === state.channel || store.pingAllowed(notification, binding.approver)) &&
779
- // A flow decision for "anyone who approves" reaches every approver who can see the project.
780
- // A notification addressed to this person reaches them whatever project
781
- // their channel follows (a sign-in pause is the installation's, v108).
782
- (personal
783
- ? notification.recipient === binding.approver && (notification.project === null || repos.includes(notification.project))
784
- : (notification.taskId || notification.kind === "flow-decision") && notification.project !== null && repos.includes(notification.project))) {
785
- const run = store.telegramProgressRun(notification);
786
- const id = chatHash(`${options.state.channel}:notice:${binding.id}:${notification.id}`), now = nowOf(options);
787
- // Only when I'm needed (the default): the task's one card is edited in place, and a new message
788
- // follows only when this person is needed. Every step keeps the branches below unchanged.
789
- const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
790
- if (isShotsKind(notification.kind)) {
791
- planResultShots(options, binding, notification, repos, id, now);
792
- return;
793
- }
794
- if (notification.kind === LEAD_SAY_KIND) {
795
- // The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
796
- const content = { text: leadSayText(store, notification, binding.approver),
797
- ...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
798
- const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE event=?")
799
- .get(chatHash(`${options.state.channel}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
800
- if (earlier !== undefined) {
801
- state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(savedChatPart(content), now.toISOString(), Number(earlier.id));
802
- return;
803
- }
804
- state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
805
- ts: "", thread: "", payload: {}, created: now.toISOString() });
806
- state.plan(id, [content], now);
871
+ for (const binding of bindings)
872
+ offerNotice(options, notification, binding, registry);
873
+ state
874
+ .prepare("UPDATE chat_runtime SET notification=? WHERE provider=:provider AND installation=?")
875
+ .run(notification.id, identity.installation);
876
+ }
877
+ }
878
+ /** One notification for one paired person: planned as their parts, or nothing. Never twice: each plan's event id is
879
+ * the notification's for this binding. */
880
+ function offerNotice(options, notification, binding, registry) {
881
+ const { state, store, identity } = options;
882
+ const repos = channelRepos(store, binding.approver, registry);
883
+ store.transact(() => {
884
+ // One person's notification (v83) goes to that person alone, task or not.
885
+ const personal = notification.recipient !== null;
886
+ if (
887
+ // From before the pairing, only what still wants a person (the rule every chat app shares, wantedBeforePairing).
888
+ (notification.createdAt >= binding.created || wantedBeforePairing(notification)) &&
889
+ notification.resolvedAt === null &&
890
+ // A promise the lead made on another chat is reported there (lead-commitments.ts); one its owner asked for on
891
+ // this chat is always said here, however quiet the lead's own work is kept.
892
+ (promiseChannelOf(notification) ?? state.provider) === state.provider &&
893
+ // Pings follow responsibility: the lead's work, this person's own act and a muted project stay in the console.
894
+ (promiseChannelOf(notification) === state.provider || store.pingAllowed(notification, binding.approver)) &&
895
+ // A flow decision for "anyone who approves" reaches every approver who can see the project.
896
+ // A notification addressed to this person reaches them whatever project
897
+ // their channel follows (a sign-in pause is the installation's, v108).
898
+ (personal
899
+ ? notification.recipient === binding.approver && (notification.project === null || repos.includes(notification.project))
900
+ : (notification.taskId || notification.kind === "flow-decision") && notification.project !== null && repos.includes(notification.project))) {
901
+ const run = store.telegramProgressRun(notification);
902
+ const id = chatHash(`${options.state.provider}:notice:${binding.id}:${notification.id}`), now = nowOf(options);
903
+ // Only when I'm needed (the default): the task's one card is edited in place, and a new message
904
+ // follows only when this person is needed. Every step keeps the branches below unchanged.
905
+ const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
906
+ if (isShotsKind(notification.kind)) {
907
+ planResultShots(options, binding, notification, repos, id, now);
908
+ return;
909
+ }
910
+ if (notification.kind === LEAD_SAY_KIND) {
911
+ // The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
912
+ const content = { text: leadSayText(store, notification, binding.approver),
913
+ ...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
914
+ const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=?")
915
+ .get(chatHash(`${options.state.provider}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
916
+ if (earlier !== undefined) {
917
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE provider=:provider AND id=? AND state!='dropped'").run(savedChatPart(content), now.toISOString(), Number(earlier.id));
918
+ return;
919
+ }
920
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
921
+ ts: "", thread: "", payload: {}, created: now.toISOString() });
922
+ state.plan(id, [content], now);
923
+ return;
924
+ }
925
+ if (quiet) {
926
+ planQuietCard(options, binding, notification, now);
927
+ if (!needsPerson(notification))
928
+ return;
929
+ if (notification.kind === "run-finished" && run) {
930
+ // Finished work goes out only as the batch line; one left out on purpose (a release check, a replaced
931
+ // task, the reader's own completion) stays quiet rather than falling through to another message.
932
+ planFinished(options, binding, notification.taskRef, run.id, now, null, repos);
933
+ return;
934
+ }
935
+ // Every other update for this person within two minutes joins the same one message.
936
+ else if (notification.kind !== "run-finished" && joinsBatch(notification)) {
937
+ if (planFinished(options, binding, notification.taskRef, run?.id ?? null, now, notification.id, repos))
807
938
  return;
939
+ }
940
+ }
941
+ if (!quiet && run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
942
+ const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot, binding.approver);
943
+ const content = {
944
+ text: card.text,
945
+ task: notification.taskId,
946
+ run: run.id,
947
+ link: card.link,
948
+ };
949
+ const digest = chatHash(JSON.stringify(content));
950
+ const prior = state
951
+ .prepare("SELECT part,digest FROM chat_progress WHERE provider=:provider AND binding=? AND run=?")
952
+ .get(binding.id, run.id);
953
+ if (prior) {
954
+ if (prior.digest !== digest) {
955
+ state
956
+ .prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE provider=:provider AND id=? AND state!='dropped'")
957
+ .run(JSON.stringify(content), now.toISOString(), Number(prior.part));
958
+ state
959
+ .prepare("UPDATE chat_progress SET digest=? WHERE provider=:provider AND binding=? AND run=?")
960
+ .run(digest, binding.id, run.id);
808
961
  }
809
- if (quiet) {
810
- planQuietCard(options, binding, notification, now);
811
- if (!needsPerson(notification))
812
- return;
813
- if (notification.kind === "run-finished" && run) {
814
- // Finished work goes out only as the batch line; one left out on purpose (a release check, a replaced
815
- // task, the reader's own completion) stays quiet rather than falling through to another message.
816
- planFinished(options, binding, notification.taskRef, run.id, now);
817
- return;
818
- }
819
- // Every other update for this person within two minutes joins the same one message.
820
- else if (notification.kind !== "run-finished" && joinsBatch(notification)) {
821
- if (planFinished(options, binding, notification.taskRef, run?.id ?? null, now, notification.id))
822
- return;
823
- }
824
- }
825
- if (!quiet && run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
826
- const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot, binding.approver);
827
- const content = {
828
- text: card.text,
829
- task: notification.taskId,
830
- run: run.id,
831
- link: card.link,
832
- };
833
- const digest = chatHash(JSON.stringify(content));
834
- const prior = state
835
- .prepare("SELECT part,digest FROM chat_progress WHERE binding=? AND run=?")
836
- .get(binding.id, run.id);
837
- if (prior) {
838
- if (prior.digest !== digest) {
839
- state
840
- .prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'")
841
- .run(JSON.stringify(content), now.toISOString(), Number(prior.part));
842
- state
843
- .prepare("UPDATE chat_progress SET digest=? WHERE binding=? AND run=?")
844
- .run(digest, binding.id, run.id);
845
- }
846
- }
847
- else {
848
- state.enqueue({
849
- id,
850
- installation: identity.installation,
851
- binding: binding.id,
852
- kind: "notice",
853
- channel: binding.channel,
854
- member: binding.member,
855
- ts: "",
856
- thread: "",
857
- payload: {},
858
- created: now.toISOString(),
859
- });
860
- state.plan(id, [content], now);
861
- const part = state
862
- .prepare("SELECT id FROM chat_part WHERE event=?")
863
- .get(id);
864
- state
865
- .prepare("INSERT INTO chat_progress VALUES(?,?,?,?)")
866
- .run(binding.id, run.id, Number(part.id), digest);
867
- }
868
- }
869
- else if (notification.kind === "flow-decision") {
870
- // The draft as written, and Approve / Edit / Send back on its last part; a card that moved on is not news.
871
- const parts = flowDecisionParts(store, notification, state.channel);
872
- if (parts !== null) {
873
- state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
874
- ts: "", thread: "", payload: {}, created: now.toISOString() });
875
- state.plan(id, parts, now);
876
- }
877
- }
878
- else if (personal && flowSendParts(store, notification, state.channel) !== null) {
879
- // A flow's "Send to me" (flow-send.ts): what was done, with its links as buttons.
880
- state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
881
- ts: "", thread: "", payload: {}, created: now.toISOString() });
882
- state.plan(id, flowSendParts(store, notification, state.channel), now);
883
- }
884
- else if (notification.kind === "flow-card" && questionParts(store, notification, binding) !== null) {
885
- // A teammate's question (v93): its options and "Answer in words" on the notice, for the person it asks.
886
- state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
887
- ts: "", thread: "", payload: {}, created: now.toISOString() });
888
- state.plan(id, questionParts(store, notification, binding), now);
889
- }
890
- else if (notification.pushClass !== null || personal || quiet) {
891
- state.enqueue({
892
- id,
893
- installation: identity.installation,
894
- binding: binding.id,
895
- kind: "notice",
896
- channel: binding.channel,
897
- member: binding.member,
898
- ts: "",
899
- thread: "",
900
- payload: {},
901
- created: now.toISOString(),
902
- });
903
- state.plan(id, [
904
- {
905
- // Never the task's id or a "— revision" suffix (chat-voice.ts).
906
- text: chatText(phoneText(notification.body === "" ? leadSubjectOf(store, notification, binding.approver) : `${leadSubjectOf(store, notification, binding.approver)}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
907
- ...(notification.taskId ? { task: notification.taskId } : {}),
908
- ...(run ? { run: run.id } : {}),
909
- ...(notification.link
962
+ }
963
+ else {
964
+ state.enqueue({
965
+ id,
966
+ installation: identity.installation,
967
+ binding: binding.id,
968
+ kind: "notice",
969
+ channel: binding.channel,
970
+ member: binding.member,
971
+ ts: "",
972
+ thread: "",
973
+ payload: {},
974
+ created: now.toISOString(),
975
+ });
976
+ state.plan(id, [content], now);
977
+ const part = state
978
+ .prepare("SELECT id FROM chat_part WHERE provider=:provider AND event=?")
979
+ .get(id);
980
+ state
981
+ .prepare("INSERT INTO chat_progress (provider,binding,run,part,digest) VALUES(:provider,?,?,?,?)")
982
+ .run(binding.id, run.id, Number(part.id), digest);
983
+ }
984
+ }
985
+ else if (notification.kind === "flow-decision") {
986
+ // The draft as written, and Approve / Edit / Send back on its last part; a card that moved on is not news.
987
+ const parts = flowDecisionParts(store, notification, state.provider);
988
+ if (parts !== null) {
989
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
990
+ ts: "", thread: "", payload: {}, created: now.toISOString() });
991
+ state.plan(id, parts, now);
992
+ }
993
+ }
994
+ else if (personal && flowSendParts(store, notification, state.provider) !== null) {
995
+ // A flow's "Send to me" (flow-send.ts): what was done, with its links as buttons.
996
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
997
+ ts: "", thread: "", payload: {}, created: now.toISOString() });
998
+ state.plan(id, flowSendParts(store, notification, state.provider), now);
999
+ }
1000
+ else if (notification.kind === "flow-card" && questionParts(store, notification, binding) !== null) {
1001
+ // A teammate's question (v93): its options and "Answer in words" on the notice, for the person it asks.
1002
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
1003
+ ts: "", thread: "", payload: {}, created: now.toISOString() });
1004
+ state.plan(id, questionParts(store, notification, binding), now);
1005
+ }
1006
+ else if (notification.pushClass !== null || personal || quiet) {
1007
+ state.enqueue({
1008
+ id,
1009
+ installation: identity.installation,
1010
+ binding: binding.id,
1011
+ kind: "notice",
1012
+ channel: binding.channel,
1013
+ member: binding.member,
1014
+ ts: "",
1015
+ thread: "",
1016
+ payload: {},
1017
+ created: now.toISOString(),
1018
+ });
1019
+ // A result, plan, failure or ready pull request acts in place (chat-decide.ts); otherwise its one link (a plan's
1020
+ // is Review & start, where the password step lives).
1021
+ const target = decideTargetOf(notification);
1022
+ const offer = decideOfferFor(options, binding, target, repos, now);
1023
+ const fallback = offer === null ? decideFallbackLink(target) : null;
1024
+ state.plan(id, [
1025
+ {
1026
+ // Never the task's id or a "— revision" suffix (chat-voice.ts).
1027
+ text: offer?.text ?? chatText(phoneText(notification.body === "" ? leadSubjectOf(store, notification, binding.approver) : `${leadSubjectOf(store, notification, binding.approver)}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
1028
+ ...(notification.taskId ? { task: notification.taskId } : {}),
1029
+ ...(run ? { run: run.id } : {}),
1030
+ ...(offer !== null ? { decide: mintDecide(options, binding, target, offer, now) }
1031
+ : fallback !== null ? { link: fallback }
1032
+ : notification.link
910
1033
  ? { link: { label: notification.kind === "pull-request-ready" ? "Merge" : personal ? "Open" : "Review", path: notification.link } }
911
1034
  : {}),
912
- },
913
- ], now);
914
- }
915
- }
916
- });
1035
+ },
1036
+ ], now);
1037
+ }
917
1038
  }
918
- state
919
- .prepare("UPDATE chat_runtime SET notification=? WHERE installation=?")
920
- .run(notification.id, identity.installation);
921
- }
1039
+ });
922
1040
  }