toolroll 0.9.16 → 0.9.18

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 (70) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/assignment-brief.js +11 -4
  3. package/dist/browser/workspace.js +14 -14
  4. package/dist/browser-workspace.d.ts +2 -0
  5. package/dist/chat-actions.d.ts +7 -0
  6. package/dist/chat-actions.js +40 -4
  7. package/dist/chat-ask.js +29 -15
  8. package/dist/chat-channel.js +3 -1
  9. package/dist/chat-decide.d.ts +203 -0
  10. package/dist/chat-decide.js +554 -0
  11. package/dist/chat-delivery-state.d.ts +7 -0
  12. package/dist/chat-delivery.d.ts +3 -0
  13. package/dist/chat-delivery.js +63 -17
  14. package/dist/chat-quiet.d.ts +4 -1
  15. package/dist/chat-quiet.js +6 -2
  16. package/dist/chat-warmth.d.ts +21 -0
  17. package/dist/chat-warmth.js +36 -0
  18. package/dist/desktop-update.d.ts +1 -1
  19. package/dist/discord-api.d.ts +3 -1
  20. package/dist/discord-api.js +4 -2
  21. package/dist/discord-chat.d.ts +3 -2
  22. package/dist/discord-chat.js +35 -6
  23. package/dist/lead-about.d.ts +35 -0
  24. package/dist/lead-about.js +62 -0
  25. package/dist/lead-capabilities.d.ts +64 -0
  26. package/dist/lead-capabilities.js +199 -0
  27. package/dist/lead-commitments.js +13 -7
  28. package/dist/lead-context.d.ts +5 -3
  29. package/dist/lead-context.js +29 -7
  30. package/dist/lead-follow.d.ts +1 -1
  31. package/dist/lead-follow.js +3 -2
  32. package/dist/lead-identity.js +1 -1
  33. package/dist/lead-people.d.ts +55 -0
  34. package/dist/lead-people.js +128 -0
  35. package/dist/lead-voice.d.ts +3 -2
  36. package/dist/lead-voice.js +4 -3
  37. package/dist/mate-cli.d.ts +3 -0
  38. package/dist/mate-cli.js +15 -14
  39. package/dist/mate-contract.d.ts +1 -1
  40. package/dist/mate-contract.js +6 -4
  41. package/dist/mate-progress.js +2 -0
  42. package/dist/mate-tools.d.ts +8 -0
  43. package/dist/mate-tools.js +112 -6
  44. package/dist/mate.d.ts +26 -1
  45. package/dist/mate.js +89 -25
  46. package/dist/modes.d.ts +6 -0
  47. package/dist/modes.js +6 -0
  48. package/dist/operate.js +20 -8
  49. package/dist/phase-routing.d.ts +1 -1
  50. package/dist/project-delete.js +3 -0
  51. package/dist/provider-auth.d.ts +4 -0
  52. package/dist/provider-auth.js +1 -1
  53. package/dist/reply-shape.d.ts +96 -0
  54. package/dist/reply-shape.js +452 -0
  55. package/dist/result-shots.d.ts +51 -0
  56. package/dist/result-shots.js +119 -0
  57. package/dist/serve.js +76 -49
  58. package/dist/slack-chat.d.ts +2 -0
  59. package/dist/slack-chat.js +38 -7
  60. package/dist/store.d.ts +36 -3
  61. package/dist/store.js +163 -16
  62. package/dist/team-runtime.js +1 -1
  63. package/dist/teams-chat.js +17 -1
  64. package/dist/telegram-mate.d.ts +12 -0
  65. package/dist/telegram-mate.js +27 -11
  66. package/dist/telegram-team.js +6 -3
  67. package/dist/telegram.d.ts +5 -3
  68. package/dist/telegram.js +227 -21
  69. package/dist/test-temp.js +1 -1
  70. package/package.json +1 -1
package/dist/telegram.js CHANGED
@@ -27,7 +27,8 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
27
27
  import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
28
  import { join } from "node:path";
29
29
  import { validateNote } from "./decision.js";
30
- import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
30
+ import { resultShotsFor } from "./result-shots.js";
31
+ import { isLifecycleNotification, isTelegramProgressNotification, RESULT_SHOTS_KIND, TELEGRAM_HOLD_REASONS } from "./store.js";
31
32
  import { telegramProgressCard } from "./telegram-progress.js";
32
33
  import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
33
34
  import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
@@ -36,6 +37,8 @@ import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team
36
37
  import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
37
38
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
38
39
  import { focusContextFor, taskInCeiling } from "./chat-channel.js";
40
+ import { answersPrompt, applyDecideFeedback, applyDecideTap, decideFallbackLink, dropDecideTokens, hasLiveDecideTokens, linksFor, placeDecidePrompt, decideOffer, decideTargetOf, isDecideToken, mergedText, mintDecideButtons, offerFingerprint, openPromptFor, placeDecideTokens, recordChatMerge, retireDecideTokens } from "./chat-decide.js";
41
+ import { mergePullRequest } from "./pull-request-flow.js";
39
42
  import { phoneCommand, phoneStatus, phoneTaskView, PHONE_CONSOLE_FOOTER, PHONE_HELP, notificationIdentity, phoneTaskChoices, resolvePhoneTask, phoneFocusText, phoneTaskListText, phoneText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
40
43
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
41
44
  import { envValue } from "./names.js";
@@ -159,7 +162,7 @@ export function scrub(text, token) {
159
162
  return token === "" ? text : text.split(token).join(redactToken(token));
160
163
  }
161
164
  export function createTransport(token, timeoutMs = 30_000) {
162
- return async (method, params, signal, upload) => {
165
+ return async (method, params, signal, upload, more) => {
163
166
  const controller = new AbortController();
164
167
  const timer = setTimeout(() => controller.abort(), timeoutMs);
165
168
  const onAbort = () => controller.abort();
@@ -176,7 +179,7 @@ export function createTransport(token, timeoutMs = 30_000) {
176
179
  // the verified bytes as one Blob under the file name given.
177
180
  const request = upload === undefined
178
181
  ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(params), signal: controller.signal }
179
- : { method: "POST", body: multipartOf(params, upload), signal: controller.signal };
182
+ : { method: "POST", body: multipartOf(params, upload, ...(more ?? [])), signal: controller.signal };
180
183
  const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, request);
181
184
  const body = (await response.json());
182
185
  // A missing or malformed acknowledgement does not prove a send failed.
@@ -248,14 +251,15 @@ export function transportError(error) {
248
251
  return why === "" || why === message ? message : `${message} (${why.slice(0, 160)})`;
249
252
  }
250
253
  /** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
251
- export function multipartOf(params, upload) {
254
+ export function multipartOf(params, upload, ...more) {
252
255
  const form = new FormData();
253
256
  for (const [key, value] of Object.entries(params)) {
254
257
  if (value === undefined || value === null)
255
258
  continue;
256
259
  form.append(key, typeof value === "object" ? JSON.stringify(value) : String(value));
257
260
  }
258
- form.append(upload.field, new Blob([new Uint8Array(upload.bytes)], { type: upload.contentType }), upload.fileName);
261
+ for (const one of [upload, ...more])
262
+ form.append(one.field, new Blob([new Uint8Array(one.bytes)], { type: one.contentType }), one.fileName);
259
263
  return form;
260
264
  }
261
265
  // ---- pairing ---------------------------------------------------------------
@@ -601,6 +605,29 @@ function viewKeyboard(phoneOrigin, view) {
601
605
  return [];
602
606
  }
603
607
  }
608
+ /** Where a paired person taps: their binding and private chat. */
609
+ function decideSeat(binding) {
610
+ return { channel: "telegram", binding: binding.id, chat: binding.chatId, approver: binding.approver, generation: binding.approverGeneration };
611
+ }
612
+ /** Decide buttons as Telegram draws them: an act's opaque token, or a link under the trusted origin read now (none without one). */
613
+ function decideKeyboard(phoneOrigin, rows) {
614
+ let origin = null;
615
+ try {
616
+ origin = phoneOrigin?.() ?? null;
617
+ }
618
+ catch {
619
+ origin = null;
620
+ }
621
+ return rows.map(row => row.flatMap((one) => "token" in one ? [{ text: one.label, callback_data: one.token }]
622
+ : phoneLinkButton(origin, one.link) ?? [])).filter(row => row.length > 0);
623
+ }
624
+ /** What this card may act on in the chat now, for this person under their current ceiling; null keeps its link. */
625
+ function decideOfferFor(store, binding, target, projects, now, root) {
626
+ if (target === null || target === undefined)
627
+ return null;
628
+ const principal = verifyApproverStanding(store, binding.approver, binding.approverGeneration, telegramConversationRepos(store, binding.approver, projects));
629
+ return principal.ok ? decideOffer(store, target, principal.who, now, root) : null;
630
+ }
604
631
  /** Team-conversation traffic to the chats that follow one — after the outbox, under the same delivery switch, never a problem to raise when nothing follows anything. */
605
632
  async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
606
633
  if (store.listTelegramTeamChats(botId).length === 0)
@@ -631,7 +658,7 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
631
658
  // Routine progress facts are not a problem to fix: a first pairing
632
659
  // starts from now and settles them as history. Anything else pending
633
660
  // is named, once per pass, as before.
634
- if (store.listNotifications("pending").some(row => !isLifecycleNotification(row)))
661
+ if (store.listNotifications("pending").some(row => !isLifecycleNotification(row) && row.kind !== RESULT_SHOTS_KIND))
635
662
  report.problems.push("outbox rows are pending but no chat is paired — `toolroll bridge telegram pair`");
636
663
  return;
637
664
  }
@@ -780,6 +807,55 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
780
807
  const after = (await readAccess()) ?? fence();
781
808
  return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, String(messageId)) } : { ok: false, error: after };
782
809
  };
810
+ /** Screenshots with a result (result-shots.ts): photos, several as one album, re-proved before every call. Each is
811
+ * marked sent before it goes, so an unconfirmed send is never repeated; only Telegram's outright refusal unmarks it. */
812
+ const shotsSender = async (row) => {
813
+ let receipt = null;
814
+ for (let call = 0; call <= 4; call++) {
815
+ const problem = (await readAccess()) ?? fence();
816
+ if (problem !== null)
817
+ return { ok: false, error: problem };
818
+ const plan = resultShotsFor(store, evidenceRoot, telegramConversationRepos(store, binding.approver, projects), row, store.resultShotsSent(row.destination, row.run ?? 0));
819
+ if (plan.kind === "none")
820
+ return call > 0 ? { ok: true, receipt } : { ok: true, receipt: `skipped:screenshots-${plan.why}` };
821
+ if (plan.kind === "line") {
822
+ const sent = await sender(plan.text, undefined, [row]);
823
+ return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
824
+ }
825
+ // No await between the access fence, the files' verification and the upload.
826
+ const photos = plan.shots.filter(one => one.photo);
827
+ const batch = photos.length >= 2 ? photos : [plan.shots[0]];
828
+ const upload = (one, field) => ({ field, fileName: one.fileName, contentType: one.format === "png" ? "image/png" : "image/jpeg", bytes: one.bytes });
829
+ const ids = batch.map(one => one.artifact);
830
+ store.markResultShotsSent(row.destination, plan.run, ids, clock());
831
+ let answer;
832
+ try {
833
+ answer = batch.length > 1
834
+ ? await transport("sendMediaGroup", { chat_id: binding.chatId, media: batch.map((one, index) => ({ type: "photo", media: `attach://shot${index}`, ...(index === 0 ? { caption: one.caption } : {}) })) }, undefined, upload(batch[0], "shot0"), batch.slice(1).map((one, index) => upload(one, `shot${index + 1}`)))
835
+ : batch[0].photo
836
+ ? await transport("sendPhoto", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "photo"))
837
+ : await transport("sendDocument", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "document"));
838
+ }
839
+ catch {
840
+ answer = { ok: false, uncertain: true };
841
+ }
842
+ if (!answer.ok && answer.uncertain !== true) {
843
+ store.unmarkResultShotsSent(row.destination, plan.run, ids);
844
+ if (answer.parameters?.retry_after !== undefined)
845
+ store.deferTelegram(botId, new Date(clock().getTime() + answer.parameters.retry_after * 1000).toISOString());
846
+ return { ok: false, error: "Telegram did not accept the screenshots" };
847
+ }
848
+ const messages = (Array.isArray(answer.result) ? answer.result : [answer.result])
849
+ .map(one => one?.message_id).filter((one) => Number.isSafeInteger(one));
850
+ // An unconfirmed send stays marked: it may have arrived, so it is never sent again.
851
+ if (!answer.ok || messages.length === 0)
852
+ report.problems.push(`notification ${row.id}: Telegram did not confirm the screenshots; they are not sent again`);
853
+ for (const one of messages)
854
+ store.recordTelegramMessage(row, binding, String(one), clock());
855
+ receipt = receiptFor(botId, binding.chatId, messages.length === 0 ? null : String(messages[0]));
856
+ }
857
+ return { ok: true, receipt };
858
+ };
783
859
  const batched = !quiet && digest.everyMs !== null && !isUrgent(rows[0]);
784
860
  const row = rows[0];
785
861
  const progressRun = batched ? null : store.telegramProgressRun(row);
@@ -885,42 +961,72 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
885
961
  return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
886
962
  return deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
887
963
  }
964
+ // A message about one result, plan, failure or pull request acts in place (chat-decide.ts); its buttons are
965
+ // minted only when what it offers changed, or its own were spent (Not now), so an unchanged repaint keeps the
966
+ // ones already in the chat.
967
+ const offer = decideOfferFor(store, binding, view.target, projects, clock(), evidenceRoot);
968
+ const text = offer?.text ?? view.text;
888
969
  // While a lone update is still the only item, its edit keeps that update's own action button.
889
- const own = lone && fact.link !== null ? factButton(phoneOrigin, fact.link) : null;
890
- const keyboard = own !== null ? [own] : viewKeyboard(phoneOrigin, view);
891
- const shown = createHash("sha256").update(JSON.stringify([view.text, keyboard])).digest("hex");
970
+ const own = offer === null && lone && fact.link !== null ? factButton(phoneOrigin, fact.link) : null;
971
+ let keyboard = own !== null ? [own] : viewKeyboard(phoneOrigin, view);
972
+ const shown = createHash("sha256").update(JSON.stringify(offer === null ? [view.text, keyboard] : [text, offerFingerprint(offer)])).digest("hex");
973
+ let tokens = [];
974
+ // Minted unplaced: they ride a message only once it shows them, and until then the old card's buttons work.
975
+ const mintFor = () => {
976
+ if (offer === null)
977
+ return;
978
+ const minted = mintDecideButtons(store, decideSeat(binding), view.target, offer, clock());
979
+ keyboard = decideKeyboard(phoneOrigin, minted.rows);
980
+ tokens = minted.tokens;
981
+ };
892
982
  if (batch.message !== null) {
893
- if (batch.digest === shown) {
983
+ if (batch.digest === shown && (offer === null || hasLiveDecideTokens(store, "telegram", binding.chatId, batch.message, clock()))) {
894
984
  store.recordTelegramMessage(fact, binding, batch.message, clock());
895
985
  return { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) };
896
986
  }
897
987
  const problem = (await readAccess()) ?? fence();
898
988
  if (problem !== null)
899
989
  return { ok: false, error: problem };
900
- const edited = await editProgress(transport, binding.chatId, batch.message, view.text, keyboard);
990
+ mintFor();
991
+ const edited = await editProgress(transport, binding.chatId, batch.message, text, keyboard);
901
992
  if (edited.ok) {
993
+ // The repaint landed: the old words' buttons retire and the new ones ride the message.
994
+ retireDecideTokens(store, "telegram", binding.chatId, batch.message, clock());
995
+ placeDecideTokens(store, tokens, batch.message);
902
996
  store.setChatBatchMessage(batch.id, batch.message, shown);
903
997
  store.recordTelegramMessage(fact, binding, batch.message, clock());
904
998
  const after = (await readAccess()) ?? fence();
905
999
  return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) } : { ok: false, error: after };
906
1000
  }
1001
+ // A failed edit: the buttons it carried never act, and the card's old ones keep working.
1002
+ dropDecideTokens(store, tokens, clock());
907
1003
  if (edited.retryAfter !== undefined)
908
1004
  store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
909
1005
  // Only Telegram's definitive missing/uneditable answer permits a new message.
910
1006
  if (!edited.replace)
911
1007
  return edited;
912
1008
  }
913
- const sent = await sender(view.text, keyboard.length === 0 ? undefined : keyboard, [fact]);
1009
+ // A new message: its buttons are minted for it, never the ones a failed edit was given.
1010
+ mintFor();
1011
+ const sent = await sender(text, keyboard.length === 0 ? undefined : keyboard, [fact]);
914
1012
  const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
915
1013
  if (placed !== null)
916
1014
  store.setChatBatchMessage(batch.id, placed, shown);
1015
+ if (sent.ok && sent.messageId !== null) {
1016
+ // The new card replaces the old one: only its buttons act.
1017
+ if (batch.message !== null)
1018
+ retireDecideTokens(store, "telegram", binding.chatId, batch.message, clock());
1019
+ placeDecideTokens(store, tokens, sent.messageId);
1020
+ }
1021
+ else
1022
+ dropDecideTokens(store, tokens, clock());
917
1023
  return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
918
1024
  }
919
1025
  return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
920
1026
  };
921
1027
  /** The lead's words (lead-voice.ts): one message; a later say within two minutes edits it in place. */
922
1028
  const leadSayOutcome = async (fact) => {
923
- const words = leadSayText(store, fact);
1029
+ const words = leadSayText(store, fact, binding.approver);
924
1030
  const button = fact.link === null ? null : factButton(phoneOrigin, fact.link);
925
1031
  const keyboard = button === null ? [] : [button];
926
1032
  const earlier = leadSayEarlier(store, fact).map(id => store.telegramMessageOf(id, fact.destination)).find(one => one !== null) ?? null;
@@ -944,7 +1050,9 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
944
1050
  return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
945
1051
  };
946
1052
  let outcome;
947
- if (rows.length === 1 && row.kind === LEAD_SAY_KIND)
1053
+ if (rows.length === 1 && row.kind === RESULT_SHOTS_KIND)
1054
+ outcome = await shotsSender(row);
1055
+ else if (rows.length === 1 && row.kind === LEAD_SAY_KIND)
948
1056
  outcome = await leadSayOutcome(row);
949
1057
  else if (quiet && rows.length === 1 && isTaskFact(row))
950
1058
  outcome = await quietOutcome(row);
@@ -986,7 +1094,8 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
986
1094
  }
987
1095
  /** What pages singly whatever the cadence: a decision, or an attention-class fact. */
988
1096
  function isUrgent(notification) {
989
- return /^decision:\d+$/.test(notification.dedupeKey) || notification.pushClass === "attention" || notification.kind === "acceptance-evidence" || notification.kind === "acceptance-ready";
1097
+ return /^decision:\d+$/.test(notification.dedupeKey) || notification.pushClass === "attention" || notification.kind === "acceptance-evidence" || notification.kind === "acceptance-ready"
1098
+ || notification.kind === RESULT_SHOTS_KIND;
990
1099
  }
991
1100
  /** The digest text: a header with the count and the window, then one
992
1101
  * fact per entry — its subject, then its body's first line, indented.
@@ -1066,8 +1175,19 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1066
1175
  const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
1067
1176
  const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : leadSubjectOf(store, notification, binding.approver)}${body === "" ? "" : `\n\n${body}`}`, task);
1068
1177
  // The task's title once: in front, unless the words already name it.
1069
- const parts = split(`${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
1070
- const button = notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
1178
+ // A result, plan, failure or ready pull request acts in place (chat-decide.ts); otherwise its one link.
1179
+ const target = alreadyAccepted || !current ? null : decideTargetOf(notification);
1180
+ const offer = decideOfferFor(store, binding, target, projects, clock(), evidenceRoot);
1181
+ const decided = offer === null ? null : mintDecideButtons(store, decideSeat(binding), target, offer, clock());
1182
+ const parts = split(offer?.text ?? `${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
1183
+ const fallback = offer === null ? decideFallbackLink(target) : null;
1184
+ const button = fallback !== null ? (() => { try {
1185
+ return phoneLinkButton(phoneOrigin?.() ?? null, fallback);
1186
+ }
1187
+ catch {
1188
+ return null;
1189
+ } })()
1190
+ : offer === null && notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
1071
1191
  // A flow card waiting on a decision (v86): Approve, Edit, Send back on the last part, for this visit only.
1072
1192
  const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
1073
1193
  const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
@@ -1079,16 +1199,22 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1079
1199
  let last = null;
1080
1200
  for (const [index, part] of parts.entries()) {
1081
1201
  const final = index === parts.length - 1;
1082
- const keyboard = !final ? undefined : keys !== null ? [...keys.keyboard, ...(button === null ? [] : [button])] : button !== null ? [button] : undefined;
1202
+ const decideKeys = decided === null ? [] : decideKeyboard(phoneOrigin, decided.rows);
1203
+ const keyboard = !final ? undefined : keys !== null ? [...keys.keyboard, ...(button === null ? [] : [button])] : decideKeys.length > 0 ? decideKeys : button !== null ? [button] : undefined;
1083
1204
  const sent = await sender(part, keyboard);
1084
- if (!sent.ok)
1205
+ if (!sent.ok) {
1206
+ if (decided !== null)
1207
+ dropDecideTokens(store, decided.tokens, clock());
1085
1208
  return { ok: false, error: sent.error };
1209
+ }
1086
1210
  last = sent.messageId;
1087
1211
  }
1088
1212
  if (flowKeys !== null && last !== null)
1089
1213
  store.placeTelegramFlowActions(flowKeys.tokens, last);
1090
1214
  if (questionKeys !== null && last !== null)
1091
1215
  store.placeTelegramQuestionActions(questionKeys.tokens, last);
1216
+ if (decided !== null && last !== null)
1217
+ placeDecideTokens(store, decided.tokens, last);
1092
1218
  return { ok: true, receipt: receiptFor(botId, binding.chatId, last) };
1093
1219
  }
1094
1220
  // A decision. Every safety-bearing word goes out before anything tappable
@@ -1335,13 +1461,15 @@ async function projectsForTap(context, update) {
1335
1461
  const callback = update.callback_query;
1336
1462
  // Flow decision buttons (v86) work with or without the lead's conversation on this phone.
1337
1463
  const flowTap = callback !== undefined && context.store.getTelegramFlowAction(callback.data ?? "") !== null;
1338
- if (callback === undefined || context.readProjects === undefined || (context.conversation === undefined && !flowTap))
1464
+ // So do a result's, plan's or pull request's own buttons (chat-decide.ts).
1465
+ const decideTap = callback !== undefined && isDecideToken(context.store, callback.data ?? "");
1466
+ if (callback === undefined || context.readProjects === undefined || (context.conversation === undefined && !flowTap && !decideTap))
1339
1467
  return null;
1340
1468
  const binding = callback.from === undefined ? null : context.store.liveTelegramBindingFor(context.botId, String(callback.from.id));
1341
1469
  if (binding === null || callback.from === undefined ||
1342
1470
  callback.message?.chat === undefined ||
1343
1471
  // Proposal cards and task picks read the chat's project ceiling.
1344
- (context.store.getTelegramProposalAction(callback.data ?? "") === null && context.store.getTelegramFlowAction(callback.data ?? "") === null && !(callback.data ?? "").startsWith("pick:")))
1472
+ (context.store.getTelegramProposalAction(callback.data ?? "") === null && context.store.getTelegramFlowAction(callback.data ?? "") === null && !decideTap && !(callback.data ?? "").startsWith("pick:")))
1345
1473
  return null;
1346
1474
  const chat = callback.message.chat;
1347
1475
  const chatId = String(chat.id);
@@ -1482,6 +1610,26 @@ function applyMessage(context, update, effects) {
1482
1610
  }
1483
1611
  return;
1484
1612
  }
1613
+ // After Request changes asked "What should change?": the person's next message in their own chat is the feedback.
1614
+ // A reply to some other message (a decision, a result) keeps its own meaning.
1615
+ const decidePrompt = binding !== null && chat.type === "private" && String(chat.id) === binding.chatId && typeof message.text === "string" && message.text.trim() !== "" && !message.text.startsWith("/")
1616
+ ? openPromptFor(store, { channel: "telegram", binding: binding.id, chat: binding.chatId }, clock()) : null;
1617
+ if (binding !== null && decidePrompt !== null && answersPrompt(decidePrompt, message.reply_to_message === undefined ? null : String(message.reply_to_message.message_id))) {
1618
+ const repos = context.projects === null ? [] : telegramConversationRepos(store, binding.approver, context.projects);
1619
+ const answer = applyDecideFeedback(store, decideSeat(binding), decidePrompt, message.text ?? "", repos, context.conversation?.evidenceRoot, clock());
1620
+ effects.push(async () => {
1621
+ let button = null;
1622
+ try {
1623
+ button = phoneLinkButton(context.conversation?.phoneOrigin?.() ?? null, answer.link);
1624
+ }
1625
+ catch {
1626
+ button = null;
1627
+ }
1628
+ await transport("sendMessage", { chat_id: binding.chatId, text: answer.said, link_preview_options: { is_disabled: true }, reply_parameters: { message_id: message.message_id },
1629
+ ...(button === null ? {} : { reply_markup: { inline_keyboard: [button] } }) });
1630
+ });
1631
+ return;
1632
+ }
1485
1633
  // A message to a teammate by name (v96), in the person's own chat: a card on its desk, and the answer comes back here.
1486
1634
  if (binding !== null && message.reply_to_message === undefined && message.chat?.type === "private" && String(chat.id) === binding.chatId && store.accountOf(binding.approver)?.role === "approver") {
1487
1635
  const repos = context.projects === null ? store.knownRepos().filter(repo => store.accountCanAccess(binding.approver, repo)) : telegramConversationRepos(store, binding.approver, context.projects);
@@ -1881,6 +2029,14 @@ function applyCallback(context, update, effects) {
1881
2029
  const binding = from === undefined ? null : store.liveTelegramBindingFor(botId, String(from.id));
1882
2030
  const tapChat = message?.chat === undefined ? null : String(message.chat.id);
1883
2031
  const followedGroup = tapChat !== null && binding !== null && tapChat !== binding.chatId && store.telegramTeamChat(botId, tapChat)?.kind === "group";
2032
+ // A result's, plan's, failure's or pull request's own buttons act only in the paired person's own chat; a tap
2033
+ // anywhere else is still answered, so the button never just spins.
2034
+ if (isDecideToken(store, token) && (binding === null || tapChat === null || tapChat !== binding.chatId)) {
2035
+ report.ignored++;
2036
+ ack(binding === null ? "These buttons work only for the person they were sent to. Nothing was done."
2037
+ : "These buttons work only in your own chat with the bot. Nothing was done.");
2038
+ return;
2039
+ }
1884
2040
  if (binding === null ||
1885
2041
  from === undefined ||
1886
2042
  message === undefined ||
@@ -2010,6 +2166,56 @@ function applyCallback(context, update, effects) {
2010
2166
  editText(`${message.text ?? ask.question}\n\nYou chose: ${option}`.slice(0, 4000));
2011
2167
  return;
2012
2168
  }
2169
+ // A result's, plan's, failure's or pull request's own buttons (chat-decide.ts): in the person's own chat only.
2170
+ if (isDecideToken(store, token)) {
2171
+ const repos = context.projects === null ? null : telegramConversationRepos(store, binding.approver, context.projects);
2172
+ if (repos === null) {
2173
+ ack("Your projects couldn't be read just now. Try again.");
2174
+ return;
2175
+ }
2176
+ const messageId = String(message.message_id);
2177
+ const tapped = applyDecideTap(store, decideSeat(binding), { token, message: messageId, shown: message.text ?? "", repos, ...(context.conversation === undefined ? {} : { root: context.conversation.evidenceRoot }), now: clock() });
2178
+ if (tapped === null) {
2179
+ report.ignored++;
2180
+ ack("That button doesn't do anything now.");
2181
+ return;
2182
+ }
2183
+ if (tapped.ignored === true)
2184
+ report.ignored++;
2185
+ ack(tapped.ack.slice(0, 190));
2186
+ if (tapped.edit !== undefined)
2187
+ editText(tapped.edit.text.slice(0, 4000), decideKeyboard(context.conversation?.phoneOrigin, tapped.edit.rows));
2188
+ if (tapped.prompt !== undefined) {
2189
+ const prompt = tapped.prompt;
2190
+ effects.push(async () => {
2191
+ const answer = await transport("sendMessage", { chat_id: binding.chatId, text: prompt.text, link_preview_options: { is_disabled: true },
2192
+ reply_parameters: { message_id: message.message_id }, reply_markup: { force_reply: true, input_field_placeholder: "What should change?" } });
2193
+ const id = answer.result?.message_id;
2194
+ if (answer.ok && Number.isSafeInteger(id))
2195
+ placeDecidePrompt(store, prompt.id, String(id));
2196
+ });
2197
+ }
2198
+ if (tapped.merge !== undefined) {
2199
+ const merge = tapped.merge, shown = tapped.edit?.text ?? message.text ?? "";
2200
+ // GitHub is a network call: after the tap's transaction, then the card says how it went.
2201
+ effects.push(async () => {
2202
+ let merged;
2203
+ try {
2204
+ merged = context.conversation?.merge !== undefined ? await context.conversation.merge({ runId: merge.runId, by: merge.by })
2205
+ : await mergePullRequest(store, { runId: merge.runId, by: merge.by, clock });
2206
+ }
2207
+ catch {
2208
+ merged = { ok: false, message: "GitHub couldn't be reached just now. Try again from the task." };
2209
+ }
2210
+ // The ledger says what happened, failure included, once GitHub has answered.
2211
+ recordChatMerge(store, merge, merged.ok ? { ok: true } : { ok: false, message: merged.message }, clock());
2212
+ // A merge that didn't land keeps a way to the task.
2213
+ await transport("editMessageText", { chat_id: binding.chatId, message_id: message.message_id, text: mergedText(shown, merged.ok ? { ok: true } : { ok: false, message: merged.message }).slice(0, 4000),
2214
+ link_preview_options: { is_disabled: true }, reply_markup: { inline_keyboard: merged.ok ? [] : decideKeyboard(context.conversation?.phoneOrigin, linksFor({ kind: "merge", taskId: merge.taskId, run: merge.runId })) } });
2215
+ });
2216
+ }
2217
+ return;
2218
+ }
2013
2219
  const action = store.getTelegramAction(token);
2014
2220
  if (action === null && context.conversation !== undefined && store.getTelegramProposalAction(token) !== null) {
2015
2221
  // A proposal card's button: the shared confirm door, inside this
package/dist/test-temp.js CHANGED
@@ -18,7 +18,7 @@ export const TEST_TEMP_PREFIXES = [
18
18
  "flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
19
19
  "proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
20
20
  "task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
21
- "needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-", "deploy-before-swap-", "one-vocabulary-", "accept-finishes-", "lead-promises-",
21
+ "needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-", "deploy-before-swap-", "one-vocabulary-", "accept-finishes-", "lead-promises-", "lead-people-",
22
22
  // Playwright's browser profiles and artifacts.
23
23
  "playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
24
24
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.16",
3
+ "version": "0.9.18",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"