toolroll 0.9.9 → 0.9.11

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.11 — 2026-10-02
4
+
5
+ - **The Telegram bot always answers.** Replies and notifications go out from
6
+ the service itself, never from inside a build. If it can't answer, it says
7
+ why in plain words, and a session mix-up starts a fresh conversation instead
8
+ of refusing.
9
+
10
+ ## 0.9.10 — 2026-10-02
11
+
12
+ - **Chat reads like a teammate.** Short messages, outcome first, with real
13
+ choices: Merge or Mark complete, and Look first. Updates within two minutes
14
+ arrive as one message. You're never told about your own actions, release
15
+ checks and replaced tasks stay quiet, and a release sends one "is live" line.
16
+ The Telegram bot is named Toolroll.
17
+
3
18
  ## 0.9.9 — 2026-10-02
4
19
 
5
20
  - **Faster, lighter tests.** The unit suite runs about three times faster
@@ -409,8 +409,10 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
409
409
  store.recordAction({ at: now.toISOString(), actor, repo: current.repo,
410
410
  taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work" });
411
411
  const ref = store.lookupRef(current.rootId);
412
+ // Whose act this is, even outside a command that named its person: the operator who marked it.
413
+ const by = currentActor() ?? (actor.startsWith("operator:") ? { account: actor.slice("operator:".length), lead: false } : null);
412
414
  if (ref !== null)
413
- store.noteTaskAct(ref.id, "completed", now);
415
+ store.noteTaskAct(ref.id, "completed", now, undefined, by);
414
416
  store.bumpWake();
415
417
  }
416
418
  const assignment = assignmentOf(store, current.rootId, now, access, root);
@@ -12,18 +12,18 @@ export type ResolvedMate = {
12
12
  };
13
13
  } | {
14
14
  ok: false;
15
- reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "session-mismatch" | "thread-mismatch";
15
+ reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "busy" | "no-session";
16
16
  said: string | null;
17
17
  };
18
18
  /**
19
19
  * The session and thread a paired phone speaks in: the approver's live
20
20
  * session when it is compatible (same generation, same membership
21
- * credential, same ceiling), else one minted under the very terms the
22
- * console and CLI state for membership chat (no dollar ceiling, this
23
- * ceiling digest) — and only when NO session is live, because minting
24
- * ends the previous one and an unrelated console conversation is not
25
- * this phone's to end. The same rule for the thread: reused when its
26
- * ceiling matches, never closed from here.
21
+ * credential, same ceiling), else a fresh one minted under the very terms
22
+ * the console and CLI state for membership chat (no dollar ceiling, this
23
+ * ceiling digest). An incompatible session or thread left open on the
24
+ * computer is replaced rather than refused, so a message from the phone
25
+ * always gets an answer — except while a turn is still running there,
26
+ * which is "busy": the message waits its turn instead of ending it.
27
27
  */
28
28
  export declare function resolveChannelMate(store: Store, binding: {
29
29
  approver: string;
@@ -34,6 +34,8 @@ export declare function resolveChannelMate(store: Store, binding: {
34
34
  * chat as well, so the task shows everything asked of it wherever it was
35
35
  * asked. Best effort: the phone conversation never depends on it. */
36
36
  export declare function mirrorToTaskChat(store: Store, who: VerifiedApprover, taskId: string, surface: string, message: string, reply: string, now: Date): void;
37
+ /** The one short message a person gets when their question could not be answered. */
38
+ export declare function couldNotAnswerText(reason: string): string;
37
39
  export declare function tooLongText(length: number): string;
38
40
  /** A reply to a message about several tasks is never guessed: it names them and says how to choose. */
39
41
  export declare function whichTaskText(titles: readonly string[]): string;
@@ -25,12 +25,12 @@ export function channelRepos(store, approver, registry) {
25
25
  /**
26
26
  * The session and thread a paired phone speaks in: the approver's live
27
27
  * session when it is compatible (same generation, same membership
28
- * credential, same ceiling), else one minted under the very terms the
29
- * console and CLI state for membership chat (no dollar ceiling, this
30
- * ceiling digest) — and only when NO session is live, because minting
31
- * ends the previous one and an unrelated console conversation is not
32
- * this phone's to end. The same rule for the thread: reused when its
33
- * ceiling matches, never closed from here.
28
+ * credential, same ceiling), else a fresh one minted under the very terms
29
+ * the console and CLI state for membership chat (no dollar ceiling, this
30
+ * ceiling digest). An incompatible session or thread left open on the
31
+ * computer is replaced rather than refused, so a message from the phone
32
+ * always gets an answer — except while a turn is still running there,
33
+ * which is "busy": the message waits its turn instead of ending it.
34
34
  */
35
35
  export function resolveChannelMate(store, binding, repos, now) {
36
36
  const config = store.getChatConfig();
@@ -60,18 +60,17 @@ export function resolveChannelMate(store, binding, repos, now) {
60
60
  return { ok: false, reason: "unpaired", said: null };
61
61
  const who = verified.who;
62
62
  const credentialKey = subscriptionCredentialKey(config.provider);
63
- const mismatch = "Your open chat session on the computer covers different projects or another provider. Finish or end it there, then message here again. Nothing was changed.";
64
63
  let session = store.activeMateSession(who.name);
65
- if (session !== null &&
64
+ const liveThread = store.liveMateThreadFor(who.name);
65
+ const sessionMismatch = session !== null &&
66
66
  (session.approverGeneration !== who.generation ||
67
67
  session.credentialKey !== credentialKey ||
68
- session.ceilingDigest !== who.ceilingDigest)) {
69
- return { ok: false, reason: "session-mismatch", said: mismatch };
70
- }
71
- const liveThread = store.liveMateThreadFor(who.name);
72
- if (liveThread !== null && liveThread.ceilingDigest !== who.ceilingDigest)
73
- return { ok: false, reason: "thread-mismatch", said: mismatch };
74
- if (session === null) {
68
+ session.ceilingDigest !== who.ceilingDigest);
69
+ const threadMismatch = liveThread !== null && liveThread.ceilingDigest !== who.ceilingDigest;
70
+ // Replacing either would end a turn still running on the computer.
71
+ if ((sessionMismatch || threadMismatch) && store.liveMateTurnFor(who.name) !== null)
72
+ return { ok: false, reason: "busy", said: null };
73
+ if (session === null || sessionMismatch) {
75
74
  const termsDigest = createHash("sha256")
76
75
  .update(`0\n${who.ceilingDigest}`)
77
76
  .digest("hex");
@@ -85,9 +84,12 @@ export function resolveChannelMate(store, binding, repos, now) {
85
84
  }, now);
86
85
  session = store.getMateSession(id);
87
86
  if (session === null)
88
- return { ok: false, reason: "session-mismatch", said: mismatch };
87
+ return { ok: false, reason: "no-session", said: couldNotAnswerText("a new chat session couldn't be started") };
89
88
  }
90
- const thread = liveThread ?? store.openMateThread(who.name, who.ceilingDigest, now).thread;
89
+ // openMateThread closes a thread kept under another ceiling.
90
+ const thread = threadMismatch || liveThread === null
91
+ ? store.openMateThread(who.name, who.ceilingDigest, now).thread
92
+ : liveThread;
91
93
  return {
92
94
  ok: true,
93
95
  who,
@@ -113,6 +115,10 @@ export function mirrorToTaskChat(store, who, taskId, surface, message, reply, no
113
115
  // The task chat is a copy; the phone's own conversation already has it.
114
116
  }
115
117
  }
118
+ /** The one short message a person gets when their question could not be answered. */
119
+ export function couldNotAnswerText(reason) {
120
+ return `I couldn't answer that just now: ${reason}. Ask again, or open the console.`;
121
+ }
116
122
  export function tooLongText(length) {
117
123
  return `That message is ${length.toLocaleString("en-US")} characters; chat takes up to ${MATE_MESSAGE_MAX_CHARS.toLocaleString("en-US")}. Nothing was sent to the assistant. Send it in shorter parts, or say which part matters most.`;
118
124
  }
@@ -76,6 +76,11 @@ export type ChatContent = {
76
76
  label: string;
77
77
  path: string;
78
78
  };
79
+ /** More link buttons after `link`: [Look first] beside [Merge] or [Mark complete]. */
80
+ also?: Array<{
81
+ label: string;
82
+ path: string;
83
+ }>;
79
84
  /** A flow decision's buttons ride this part (v88): minted when it is planned. */
80
85
  flow?: {
81
86
  card: number;
@@ -9,7 +9,8 @@ import { answerChatFlowPrompt, applyChatFlowTap, flowDecisionParts } from "./cha
9
9
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
10
10
  import { triggerConfigOf } from "./flow-triggers.js";
11
11
  import { telegramProgressCard } from "./telegram-progress.js";
12
- import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView, readyPingView } from "./chat-quiet.js";
12
+ import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
13
+ import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
13
14
  import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
14
15
  import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
15
16
  import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
@@ -201,6 +202,10 @@ export async function processChatEvent(options) {
201
202
  return true;
202
203
  }
203
204
  const resolved = resolveChannelMate(store, { approver: binding.approver, approverGeneration: binding.generation }, repos, now);
205
+ if (!resolved.ok && resolved.reason === "busy") {
206
+ state.defer(event.id, "Assistant is answering another message", new Date(now.getTime() + 5000));
207
+ return true;
208
+ }
204
209
  if (!resolved.ok) {
205
210
  state.plan(event.id, [
206
211
  {
@@ -582,7 +587,7 @@ function planQuietCard(options, binding, notification, now) {
582
587
  if (notification.taskRef === null || notification.taskId === null)
583
588
  return;
584
589
  const card = store.chatCardFor(`${state.channel}:${binding.id}`, notification.taskRef, store.getTask(notification.taskId)?.createdAt ?? notification.createdAt, now);
585
- const view = quietCardView(store, card.tasks, now, options.evidenceRoot);
590
+ const view = quietCardView(store, card.tasks, now, options.evidenceRoot, binding.approver);
586
591
  if (view === null)
587
592
  return;
588
593
  const content = { text: view.text, link: view.link, ...(card.tasks.length === 1 ? { task: notification.taskId } : {}) };
@@ -602,6 +607,37 @@ function planQuietCard(options, binding, notification, now) {
602
607
  if (part !== undefined)
603
608
  store.setChatCardMessage(card.id, String(part.id), shown);
604
609
  }
610
+ /** Any update for this person joins their open batch (two minutes from its first), and the batch's one message
611
+ * is planned once, then repainted in place as more updates land. `notification`: an update that is not a finished
612
+ * result (null for one that is). False when nothing in the batch reads as a line, so the update goes out on its own. */
613
+ function planFinished(options, binding, taskRef, run, now, notification = null) {
614
+ const { state, store, identity } = options;
615
+ const batch = store.chatBatchFor(`${state.channel}:${binding.id}`, taskRef, run, now, BATCH_MS, notification);
616
+ const view = finishedView(store, batch, now, options.evidenceRoot, binding.approver);
617
+ if (view === null)
618
+ return false;
619
+ // A single update keeps its task and run, so a reply to it names that work.
620
+ const single = batch.items.length === 1 ? batch.items[0] : null;
621
+ const task = single === null ? null : store.refById(single.taskRef)?.externalId ?? null;
622
+ const content = { text: view.text, link: view.link, ...(view.also === undefined ? {} : { also: view.also }),
623
+ ...(task === null ? {} : { task }), ...(single?.run == null ? {} : { run: single.run }) };
624
+ const shown = chatHash(JSON.stringify(content));
625
+ if (batch.message !== null) {
626
+ if (batch.digest === shown)
627
+ return true;
628
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(JSON.stringify(content), now.toISOString(), Number(batch.message));
629
+ store.setChatBatchMessage(batch.id, batch.message, shown);
630
+ return true;
631
+ }
632
+ const id = chatHash(`${state.channel}:batch:${binding.id}:${batch.id}`);
633
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
634
+ ts: "", thread: "", payload: "{}", created: now.toISOString() });
635
+ state.plan(id, [content], now);
636
+ const part = state.prepare("SELECT id FROM chat_part WHERE event=?").get(id);
637
+ if (part !== undefined)
638
+ store.setChatBatchMessage(batch.id, String(part.id), shown);
639
+ return true;
640
+ }
605
641
  /** One progress card per exact result; separate urgent facts retain their own review link. */
606
642
  export async function planChatNotifications(options) {
607
643
  const state = options.state, { store, identity } = options, bindings = state.bindings(identity.installation).filter(one => state.live(one));
@@ -645,15 +681,19 @@ export async function planChatNotifications(options) {
645
681
  if (!needsPerson(notification))
646
682
  return;
647
683
  if (notification.kind === "run-finished" && run) {
648
- const ping = readyPingView(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
649
- state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
650
- ts: "", thread: "", payload: "{}", created: now.toISOString() });
651
- state.plan(id, [{ text: ping.text, task: notification.taskId, run: run.id, link: ping.link }], now);
684
+ // Finished work goes out only as the batch line; one left out on purpose (a release check, a replaced
685
+ // task, the reader's own completion) stays quiet rather than falling through to another message.
686
+ planFinished(options, binding, notification.taskRef, run.id, now);
652
687
  return;
653
688
  }
689
+ // Every other update for this person within two minutes joins the same one message.
690
+ else if (notification.kind !== "run-finished" && joinsBatch(notification)) {
691
+ if (planFinished(options, binding, notification.taskRef, run?.id ?? null, now, notification.id))
692
+ return;
693
+ }
654
694
  }
655
695
  if (!quiet && run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
656
- const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
696
+ const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot, binding.approver);
657
697
  const content = {
658
698
  text: card.text,
659
699
  task: notification.taskId,
@@ -726,7 +766,8 @@ export async function planChatNotifications(options) {
726
766
  });
727
767
  state.plan(id, [
728
768
  {
729
- text: phoneText(`${notification.subject}\n\n${notification.body}`, 2500),
769
+ // Never the task's id or a "— revision" suffix (chat-voice.ts).
770
+ text: chatText(phoneText(notification.body === "" ? notification.subject : `${notification.subject}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
730
771
  ...(notification.taskId ? { task: notification.taskId } : {}),
731
772
  ...(run ? { run: run.id } : {}),
732
773
  ...(notification.link
@@ -1,10 +1,12 @@
1
1
  import { type ProgressEntity } from "./telegram-progress.js";
2
2
  import { type PhoneTaskLink } from "./telegram-status.js";
3
- import { type Notification, type Run, type Store } from "./store.js";
3
+ import { type ChatBatch, type Notification, type Store } from "./store.js";
4
+ /** `also`: more buttons after `link`, such as [Look first] beside [Merge]. */
4
5
  export type QuietView = {
5
6
  text: string;
6
7
  entities: ProgressEntity[];
7
8
  link: PhoneTaskLink;
9
+ also?: PhoneTaskLink[];
8
10
  };
9
11
  export declare function needsPerson(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient">): boolean;
10
12
  /** A fact about one placed task: in quiet mode it lands on that task's card. */
@@ -14,9 +16,13 @@ export declare function isTaskFact(row: Pick<Notification, "taskRef" | "taskId"
14
16
  project: string;
15
17
  };
16
18
  /** The card's words now: one task's progress card, or one line per task when several were filed together. */
17
- export declare function quietCardView(store: Store, taskRefs: readonly number[], now: Date, root?: string): QuietView | null;
18
- /** The one ping for a Ready result: its state and title, what to do next, and a link to that exact result. */
19
- export declare function readyPingView(store: Store, run: Run, taskId: string, project: string, now: Date, root?: string): QuietView;
19
+ export declare function quietCardView(store: Store, taskRefs: readonly number[], now: Date, root?: string, viewer?: string): QuietView | null;
20
+ /** Whether this update for a person joins their two-minute batch: anything about one task that needs them,
21
+ * except a fact that must stand alone. */
22
+ export declare function joinsBatch(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient" | "taskRef">): boolean;
23
+ /** One person's update message: one task says what happened with its next step; several updates within two
24
+ * minutes become "4 tasks finished: …" (or "3 updates: …") with one Open button. Null when none can be read. */
25
+ export declare function finishedView(store: Store, batch: Pick<ChatBatch, "items">, now: Date, root?: string, viewer?: string): QuietView | null;
20
26
  /** This computer's local day and time, which is what a person means by "the evening". */
21
27
  export declare function localDay(now: Date): string;
22
28
  export declare function localTime(now: Date): string;
@@ -5,9 +5,10 @@
5
5
  * Delivery receipts, claims and retries stay with each transport; the
6
6
  * console's activity keeps every fact whatever a chat showed. */
7
7
  import { assignmentOf } from "./assignment.js";
8
- import { chatControlHref } from "./chat-controls.js";
8
+ import { chatControlHref, chatResultHref } from "./chat-controls.js";
9
9
  import { telegramProgressCard } from "./telegram-progress.js";
10
- import { assignmentStatusFacts, headlineEmoji, replacedWords, taskStatusOf } from "./task-status.js";
10
+ import { assignmentStatusFacts, headlineEmoji, taskStatusOf } from "./task-status.js";
11
+ import { asksToFinish, batchLine, chatText, chatTitle, factLinkLabel, mentions, READY_ACTIONS } from "./chat-voice.js";
11
12
  import { phoneText, projectLabel } from "./telegram-status.js";
12
13
  import { isLifecycleNotification } from "./store.js";
13
14
  /** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
@@ -49,25 +50,27 @@ const BEFORE_RUN = {
49
50
  "task-requeued": before("Queued"),
50
51
  "task-cancelled": before("Stopped"),
51
52
  };
52
- function taskLine(store, taskRef, now, root) {
53
+ function taskLine(store, taskRef, now, root, viewer) {
53
54
  const ref = store.refById(taskRef);
54
55
  if (ref === null || ref.repo === null)
55
56
  return null;
56
57
  const task = store.getTask(ref.externalId);
57
- const title = phoneText(task?.title ?? ref.externalId, 88);
58
- // Replaced, never "Cancelled": the card names its successor and links to it.
58
+ // A short human title: never "— revision" or an id (chat-voice.ts).
59
+ const title = chatTitle(store, ref.externalId);
60
+ // Replaced, never "Cancelled": the card says a newer task took over and links to it, without either id.
59
61
  const successor = task?.state === "cancelled" ? store.replacementOf(ref.externalId) : null;
60
62
  if (successor !== null) {
61
- const heading = `${headlineEmoji("Stopped")} ${replacedWords(successor)}`;
62
- const text = [title, heading, "", `${projectLabel(ref.repo)} · ${ref.externalId}`].join("\n");
63
- return { title, icon: headlineEmoji("Stopped"), status: replacedWords(successor), project: ref.repo, view: {
63
+ const words = "Replaced by a newer task";
64
+ const heading = `${headlineEmoji("Stopped")} ${words}`;
65
+ const text = [title, heading, "", projectLabel(ref.repo)].join("\n");
66
+ return { title, icon: headlineEmoji("Stopped"), status: words, project: ref.repo, view: {
64
67
  text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
65
- link: { label: `Open ${phoneText(successor, 40)}`, path: chatControlHref("task", successor) },
68
+ link: { label: "Open the new task", path: chatControlHref("task", successor) },
66
69
  } };
67
70
  }
68
71
  const run = cardRun(store, taskRef);
69
72
  if (run !== null) {
70
- const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root);
73
+ const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root, viewer);
71
74
  const heading = view.entities[1] === undefined ? "" : view.text.slice(view.entities[1].offset, view.entities[1].offset + view.entities[1].length);
72
75
  const split = heading.indexOf(" ");
73
76
  return { title, icon: heading.slice(0, split), status: heading.slice(split + 1), project: ref.repo, view };
@@ -75,15 +78,15 @@ function taskLine(store, taskRef, now, root) {
75
78
  const fact = store.latestTaskFact(taskRef);
76
79
  const words = task?.state === "cancelled" ? BEFORE_RUN["task-cancelled"] : BEFORE_RUN[fact?.kind ?? ""] ?? BEFORE_RUN["task-filed"];
77
80
  const heading = `${words.icon} ${words.status}`;
78
- const text = [title, heading, "", `${projectLabel(ref.repo)} · ${ref.externalId}`].join("\n");
81
+ const text = [title, heading, "", projectLabel(ref.repo)].join("\n");
79
82
  return { title, icon: words.icon, status: words.status, project: ref.repo, view: {
80
83
  text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
81
84
  link: { label: "Open task", path: chatControlHref("task", ref.externalId) },
82
85
  } };
83
86
  }
84
87
  /** The card's words now: one task's progress card, or one line per task when several were filed together. */
85
- export function quietCardView(store, taskRefs, now, root) {
86
- const lines = taskRefs.map(ref => taskLine(store, ref, now, root)).filter((one) => one !== null);
88
+ export function quietCardView(store, taskRefs, now, root, viewer) {
89
+ const lines = taskRefs.map(ref => taskLine(store, ref, now, root, viewer)).filter((one) => one !== null);
87
90
  if (lines.length === 0)
88
91
  return null;
89
92
  if (lines.length === 1)
@@ -93,14 +96,76 @@ export function quietCardView(store, taskRefs, now, root) {
93
96
  const text = [heading, ...lines.map(one => `${one.icon} ${phoneText(one.title, 60)} · ${one.status}`), "", projects.join(", ")].join("\n");
94
97
  return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/tasks" } };
95
98
  }
96
- /** The one ping for a Ready result: its state and title, what to do next, and a link to that exact result. */
97
- export function readyPingView(store, run, taskId, project, now, root) {
98
- const card = telegramProgressCard(store, run, taskId, project, now, root);
99
- const [title = "", shown = ""] = card.entities.map(one => card.text.slice(one.offset, one.offset + one.length));
100
- const heading = shown;
101
- const next = card.next;
102
- const first = `${heading} · ${title}`;
103
- return { text: next === "" ? first : `${first}\n${next}`, entities: [{ type: "bold", offset: 0, length: heading.length }], link: card.link };
99
+ /** Facts that carry their own controls or must stand alone (a security alert, a decision, a flow card, a
100
+ * screenshot, an acceptance packet) are never folded into a batch. */
101
+ const OWN_MESSAGE_KINDS = new Set(["security-release", "secret-detected", "flow-decision", "flow-card", "acceptance-ready", "acceptance-evidence"]);
102
+ /** Whether this update for a person joins their two-minute batch: anything about one task that needs them,
103
+ * except a fact that must stand alone. */
104
+ export function joinsBatch(row) {
105
+ return row.taskRef !== null && row.recipient === null && needsPerson(row) && !OWN_MESSAGE_KINDS.has(row.kind) && !/^decision:\d+$/.test(row.dedupeKey);
106
+ }
107
+ /** A few words for an update in a list, and how it counts: "has a plan to review", "failed". */
108
+ function updatePhrase(row) {
109
+ if (row.kind === "plan-ready")
110
+ return { phrase: "has a plan to review", headline: "Needs you" };
111
+ if (row.kind === "report-ready")
112
+ return { phrase: "has a report to read", headline: "Update" };
113
+ if (row.kind === "stale-approval" || row.kind === "approval-withdrawn")
114
+ return { phrase: "needs a fresh approval", headline: "Needs you" };
115
+ if (row.pushClass === "merge" || row.kind === "pull-request-ready")
116
+ return { phrase: "is ready to merge", headline: "Update" };
117
+ if (/fail|exhausted|stalled|fenced/.test(row.kind))
118
+ return { phrase: "failed", headline: "Failed" };
119
+ return { phrase: "needs you", headline: "Needs you" };
120
+ }
121
+ /** One task's line in a batch: a finished result in a person's words with its real next step, or another
122
+ * update in its own words with its own link. */
123
+ function lineOf(store, item, now, root, viewer) {
124
+ const ref = store.refById(item.taskRef);
125
+ if (ref === null || ref.repo === null)
126
+ return null;
127
+ const summary = chatTitle(store, ref.externalId);
128
+ const row = item.notification === null ? null : store.notificationById(item.notification);
129
+ if (row !== null && row.kind !== "run-finished") {
130
+ // Never the task's id or a "— revision" suffix; the title once, in front unless the words already name it.
131
+ const words = chatText(phoneText(row.body === "" ? row.subject : `${row.subject}\n\n${row.body}`, 2500), [{ id: ref.externalId, title: summary }]);
132
+ const { phrase, headline } = updatePhrase(row);
133
+ return { also: [], link: row.link === null ? { label: "Open task", path: chatControlHref("task", ref.externalId) } : { label: factLinkLabel(row.link), path: row.link },
134
+ fact: { summary, headline, checks: null, report: false, completedBy: null, update: { words: mentions(words, summary) ? words : `${summary} · ${words}`, phrase } } };
135
+ }
136
+ const run = item.run === null ? null : store.getRun(item.run);
137
+ if (run === null)
138
+ return null;
139
+ const card = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root, viewer);
140
+ const checks = card.facts.checks ?? null;
141
+ const pullRequest = card.facts.pullRequest?.state === "open";
142
+ const fact = {
143
+ summary,
144
+ headline: card.status.headline,
145
+ checks: checks === null ? null : checks.level === "off" && checks.status !== "passed" ? "off"
146
+ : checks.status === "passed" || checks.status === "failed" || checks.status === "not-run" ? checks.status : null,
147
+ report: card.facts.report === true,
148
+ completedBy: card.facts.completedBy ?? null,
149
+ pullRequest,
150
+ // The lead speaks as "I" for what it did: the work it filed or approved, or the completion it recorded.
151
+ lead: card.status.headline === "Complete" ? card.completedByLead : store.leadWorkOf(item.taskRef) !== null,
152
+ };
153
+ // A result ready for a person offers its real next step, then a look first.
154
+ if (asksToFinish(fact))
155
+ return { fact, also: [{ label: READY_ACTIONS.look, path: chatResultHref(ref.externalId, run.id, "changes") }],
156
+ link: pullRequest ? { label: READY_ACTIONS.merge, path: `/t/${encodeURIComponent(ref.externalId)}#merge` } : { label: READY_ACTIONS.complete, path: chatResultHref(ref.externalId, run.id) } };
157
+ return { fact, link: card.link, also: [] };
158
+ }
159
+ /** One person's update message: one task says what happened with its next step; several updates within two
160
+ * minutes become "4 tasks finished: …" (or "3 updates: …") with one Open button. Null when none can be read. */
161
+ export function finishedView(store, batch, now, root, viewer) {
162
+ const lines = batch.items.map(one => lineOf(store, one, now, root, viewer)).filter((one) => one !== null);
163
+ if (lines.length === 0)
164
+ return null;
165
+ const text = batchLine(lines.map(one => one.fact));
166
+ if (lines.length > 1)
167
+ return { text, entities: [], link: { label: "Open", path: "/tasks" } };
168
+ return { text, entities: [], link: lines[0].link, ...(lines[0].also.length === 0 ? {} : { also: lines[0].also }) };
104
169
  }
105
170
  // ---- the evening digest ----------------------------------------------------------
106
171
  const FAILURE = /fail|exhausted|fenced/;
@@ -118,7 +183,7 @@ export function eveningDigestText(store, account, now, root) {
118
183
  const since = new Date(now.getTime() - 86_400_000).toISOString();
119
184
  const recent = new Date(now.getTime() - 14 * 86_400_000).toISOString();
120
185
  const visible = (row) => row.project !== null && store.accountCanAccess(account, row.project);
121
- const title = (taskId) => phoneText(store.getTask(taskId)?.title ?? taskId, 80);
186
+ const title = (taskId) => chatTitle(store, taskId);
122
187
  const finished = new Map(), failed = new Map(), waits = new Map();
123
188
  const facts = store.taskFactsSince(recent).filter(visible);
124
189
  for (const row of facts) {
@@ -0,0 +1,68 @@
1
+ /** How chat sounds: a teammate talking. One or two short sentences, outcome
2
+ * first, plain words, one action at most. Every chat (Telegram, Slack,
3
+ * Discord, Teams) takes its titles and its finished-work lines from here, so a
4
+ * task id, a "— revision" suffix or a person's own act never reaches a chat.
5
+ * The console keeps every exact name and fact. */
6
+ import type { TelegramTransport } from "./telegram.js";
7
+ import type { Store } from "./store.js";
8
+ /** The bot's display name in every chat app. */
9
+ export declare const BOT_NAME = "Toolroll";
10
+ /** Updates for one person landing within this window become one message, edited in place as it grows. */
11
+ export declare const BATCH_MS: number;
12
+ export declare const SHORT_TITLE_MAX = 60;
13
+ /** A short human summary of a task's title, at most 60 characters: no "— revision", and no task ids. Only the
14
+ * task's own id and tokens `isTaskId` names as real tasks are dropped; `node-20` and `utf-8` stay. */
15
+ export declare function shortTitle(title: string | null | undefined, taskId?: string | null, isTaskId?: (token: string) => boolean): string;
16
+ /** A task's title as every chat says it: short, with its own id and any other real task's id dropped. */
17
+ export declare function chatTitle(store: Pick<Store, "getTask">, taskId: string): string;
18
+ /** A title inside a sentence or list: "faster tests", but "API keys" and "iOS" keep their capitals. */
19
+ export declare function inSentence(summary: string): string;
20
+ /** The facts one line of a chat update is made of, read from the shared task status. */
21
+ export type FinishedFact = {
22
+ summary: string;
23
+ headline: string;
24
+ checks: "passed" | "failed" | "not-run" | "off" | null;
25
+ report: boolean;
26
+ completedBy: string | null;
27
+ /** An open pull request carries this result: the ask is "Merge it?" rather than "Mark it complete?". */
28
+ pullRequest?: boolean;
29
+ /** The lead did this (built it, or marked it complete), so the lead says "I". */
30
+ lead?: boolean;
31
+ /** Not a finished result but another update about the task: its own words when it stands alone, and a few
32
+ * words for a list ("has a plan to review"). */
33
+ update?: {
34
+ words: string;
35
+ phrase: string;
36
+ } | null;
37
+ };
38
+ /** The two buttons under a result ready for a person: the real next step, then a look first. */
39
+ export declare const READY_ACTIONS: {
40
+ readonly merge: "Merge";
41
+ readonly complete: "Mark complete";
42
+ readonly look: "Look first";
43
+ };
44
+ /** Whether this line asks for the result's next step, so it carries [Merge] or [Mark complete] and [Look first]. */
45
+ export declare function asksToFinish(fact: FinishedFact): boolean;
46
+ /** One update, said plainly: what happened, then what happens next. The lead says "I" for what it did. */
47
+ export declare function finishedLine(fact: FinishedFact): string;
48
+ /** Several updates in one message: "4 tasks finished: faster tests, cleanup, …" (or "3 updates: …" when not all of
49
+ * them are finished work), then what broke and what happens next. */
50
+ export declare function batchLine(facts: readonly FinishedFact[]): string;
51
+ /**
52
+ * Name the Telegram bot Toolroll. On pairing it always becomes Toolroll; on upgrade only a bot still called
53
+ * StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
54
+ * leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
55
+ */
56
+ export declare function nameTelegramBot(transport: TelegramTransport, when: "pairing" | "upgrade"): Promise<boolean>;
57
+ /** The last word on any pushed chat text: a task's own id (bare or in brackets), a "— revision" suffix and a
58
+ * "Replaced by <id>" never reach a chat, whatever words the fact was recorded with. A bare id reads as the
59
+ * task's short title when one is given, else "this task". */
60
+ export declare function chatText(text: string, tasks?: readonly (string | null | undefined | {
61
+ id: string;
62
+ title?: string;
63
+ })[]): string;
64
+ /** The one button a plain fact may carry: its label names where its machine-minted console path goes; nothing in
65
+ * it comes from the fact's text. */
66
+ export declare function factLinkLabel(path: string): string;
67
+ /** Whether the words already name this title as a whole phrase (so it need not be said twice). */
68
+ export declare function mentions(text: string, title: string): boolean;