toolroll 0.9.15 → 0.9.17

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 (89) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/assignment-brief.d.ts +10 -0
  3. package/dist/assignment-brief.js +56 -26
  4. package/dist/assignment.d.ts +8 -7
  5. package/dist/assignment.js +13 -8
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +22 -22
  8. package/dist/browser-crew.js +1 -0
  9. package/dist/browser-workspace.d.ts +5 -0
  10. package/dist/chat-actions.d.ts +7 -0
  11. package/dist/chat-actions.js +40 -4
  12. package/dist/chat-ask.d.ts +28 -0
  13. package/dist/chat-ask.js +91 -0
  14. package/dist/chat-channel.js +8 -1
  15. package/dist/chat-controls.d.ts +8 -0
  16. package/dist/chat-controls.js +3 -0
  17. package/dist/chat-delivery-state.d.ts +8 -1
  18. package/dist/chat-delivery-state.js +15 -2
  19. package/dist/chat-delivery.d.ts +3 -0
  20. package/dist/chat-delivery.js +49 -17
  21. package/dist/chat-quiet.js +4 -2
  22. package/dist/chat-voice.d.ts +3 -6
  23. package/dist/chat-voice.js +8 -2
  24. package/dist/chat-warmth.d.ts +21 -0
  25. package/dist/chat-warmth.js +36 -0
  26. package/dist/discord-api.d.ts +1 -1
  27. package/dist/discord-api.js +1 -1
  28. package/dist/discord-chat.d.ts +3 -2
  29. package/dist/discord-chat.js +28 -3
  30. package/dist/lead-about.d.ts +35 -0
  31. package/dist/lead-about.js +62 -0
  32. package/dist/lead-capabilities.d.ts +64 -0
  33. package/dist/lead-capabilities.js +199 -0
  34. package/dist/lead-commitments.d.ts +84 -0
  35. package/dist/lead-commitments.js +214 -0
  36. package/dist/lead-context.d.ts +34 -3
  37. package/dist/lead-context.js +155 -19
  38. package/dist/lead-follow.d.ts +1 -1
  39. package/dist/lead-follow.js +6 -2
  40. package/dist/lead-identity.d.ts +23 -0
  41. package/dist/lead-identity.js +33 -0
  42. package/dist/lead-people.d.ts +55 -0
  43. package/dist/lead-people.js +128 -0
  44. package/dist/lead-voice.d.ts +13 -9
  45. package/dist/lead-voice.js +26 -16
  46. package/dist/ledger-view.js +3 -2
  47. package/dist/mate-cli.d.ts +4 -1
  48. package/dist/mate-cli.js +23 -16
  49. package/dist/mate-contract.d.ts +1 -1
  50. package/dist/mate-contract.js +13 -4
  51. package/dist/mate-progress.js +7 -0
  52. package/dist/mate-tools.d.ts +26 -1
  53. package/dist/mate-tools.js +357 -7
  54. package/dist/mate.d.ts +35 -1
  55. package/dist/mate.js +121 -61
  56. package/dist/operate.js +4 -1
  57. package/dist/phase-routing.d.ts +1 -1
  58. package/dist/project-delete.js +3 -1
  59. package/dist/provider-auth.d.ts +4 -0
  60. package/dist/provider-auth.js +1 -1
  61. package/dist/reply-shape.d.ts +96 -0
  62. package/dist/reply-shape.js +452 -0
  63. package/dist/result-acts.d.ts +14 -0
  64. package/dist/result-acts.js +19 -0
  65. package/dist/retention.js +6 -0
  66. package/dist/serve.js +162 -55
  67. package/dist/slack-chat.d.ts +2 -0
  68. package/dist/slack-chat.js +30 -3
  69. package/dist/store.d.ts +60 -0
  70. package/dist/store.js +152 -3
  71. package/dist/surface.js +2 -2
  72. package/dist/task-status.d.ts +5 -15
  73. package/dist/task-status.js +18 -5
  74. package/dist/team-runtime.js +3 -2
  75. package/dist/teams-chat.js +16 -2
  76. package/dist/telegram-mate.d.ts +6 -1
  77. package/dist/telegram-mate.js +43 -11
  78. package/dist/telegram-progress.js +3 -2
  79. package/dist/telegram-team.js +6 -3
  80. package/dist/telegram.js +43 -3
  81. package/dist/test-temp.js +1 -1
  82. package/dist/when-html.d.ts +8 -3
  83. package/dist/when-html.js +23 -3
  84. package/dist/work-index.d.ts +2 -0
  85. package/dist/work-index.js +9 -6
  86. package/dist/work-summary.d.ts +1 -1
  87. package/dist/workspace-ui.d.ts +2 -14
  88. package/dist/workspace-ui.js +4 -22
  89. package/package.json +1 -1
@@ -1,11 +1,13 @@
1
1
  import { chatFlowButtons } from "./chat-flow.js";
2
2
  import { chatQuestionButtons } from "./teammate-question.js";
3
+ import { chatAskButtons } from "./chat-ask.js";
3
4
  import { channelInbox } from "./chat-inbox.js";
4
5
  import { roomCommand } from "./chat-rooms.js";
5
6
  import { processChatEvent, applyChatAction, planChatNotifications, channelAccess, planRoomMessages, } from "./chat-delivery.js";
6
7
  /** Slack is a transport for the same saved assistant and confirmation doors. */
7
8
  import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
8
9
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
10
+ import { renderReply } from "./reply-shape.js";
9
11
  import {} from "./mate-doors.js";
10
12
  import { SlackState, slackHash, } from "./slack-state.js";
11
13
  import { object, slackId, slackTs, slackMember, SlackError, uploadSlackBytes, } from "./slack-api.js";
@@ -130,6 +132,10 @@ const delivery = (options) => ({
130
132
  state: new SlackState(options.store),
131
133
  label: "Slack",
132
134
  member: (member, channel) => slackMember(options.api, options.identity, member, channel),
135
+ // A 👍 on the owner's message where the app has reactions:write; Slack shows bots no typing indicator.
136
+ warm: (event) => ({
137
+ react: () => options.api("reactions.add", { channel: event.channel, timestamp: event.ts, name: "thumbsup" }),
138
+ }),
133
139
  });
134
140
  export const processSlackEvent = (options) => processChatEvent(delivery(options));
135
141
  export const applySlackAction = (options, event, binding, repos) => applyChatAction(delivery(options), event, binding, repos);
@@ -155,6 +161,16 @@ export function slackBlocks(text, buttons = []) {
155
161
  blocks.push({ type: "actions", elements: buttons });
156
162
  return blocks;
157
163
  }
164
+ /** The lead's reply as mrkdwn sections: no header block, so its first line stays an ordinary line. */
165
+ export function slackVoiceBlocks(mrkdwn, buttons = []) {
166
+ const blocks = split(mrkdwn).map((part) => ({
167
+ type: "section",
168
+ text: { type: "mrkdwn", text: part },
169
+ }));
170
+ if (buttons.length)
171
+ blocks.push({ type: "actions", elements: buttons });
172
+ return blocks;
173
+ }
158
174
  function linkButton(origin, link) {
159
175
  if (!origin ||
160
176
  !link ||
@@ -359,19 +375,30 @@ export async function deliverSlackPart(options) {
359
375
  value: one.token,
360
376
  }))
361
377
  : []),
378
+ // The lead's question to its owner: its options, then "Something else".
379
+ ...(content.ask
380
+ ? chatAskButtons(state, row.id, now).map((one) => ({
381
+ type: "button",
382
+ text: { type: "plain_text", text: one.label },
383
+ action_id: one.words ? "toolroll_question_words" : "toolroll_question_choice",
384
+ value: one.token,
385
+ }))
386
+ : []),
362
387
  ...linkButton(options.origin(), content.link),
363
388
  // [Look first] beside [Merge] or [Accept and finish]: each button's own action id.
364
389
  ...(content.also ?? []).flatMap((one, index) => linkButton(options.origin(), one).map(button => ({ ...button, action_id: `toolroll_link_${index + 2}` }))),
365
390
  ];
366
391
  const target = content.edit ?? row.message;
392
+ // The lead's own reply goes out in Slack's mrkdwn (bold anchors, labelled links); everything else stays plain.
393
+ const voiced = content.voice === true && !content.proposal && !content.image ? renderReply(text, "slack") : null;
367
394
  const args = {
368
395
  channel: destination,
369
- text: text
396
+ text: voiced ?? text
370
397
  .replaceAll("&", "&")
371
398
  .replaceAll("<", "&lt;")
372
399
  .replaceAll(">", "&gt;"),
373
- blocks: slackBlocks(text, buttons),
374
- mrkdwn: false,
400
+ blocks: voiced === null ? slackBlocks(text, buttons) : slackVoiceBlocks(voiced, buttons),
401
+ mrkdwn: voiced !== null,
375
402
  parse: "none",
376
403
  unfurl_links: false,
377
404
  unfurl_media: false,
package/dist/store.d.ts CHANGED
@@ -21,6 +21,10 @@ import { type RetentionKind, type RetentionPeriods } from "./retention.js";
21
21
  import { type CheckoutCleanup } from "./storage.js";
22
22
  import { type LedgerChainReport } from "./ledger-chain.js";
23
23
  import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer } from "./approval-policy.js";
24
+ /** What the lead promised to follow up on (lead-commitments.ts; no version bump: additive only, so a build that
25
+ * predates it still opens the store). `condition_json` is a task, attempt, check or time; `check_at` is when to look
26
+ * next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
27
+ export declare const LEAD_COMMITMENT_SCHEMA = "\nCREATE TABLE IF NOT EXISTS lead_commitment (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n owner TEXT NOT NULL,\n repo TEXT,\n thread INTEGER NOT NULL,\n turn INTEGER,\n channel TEXT NOT NULL DEFAULT 'chat',\n what TEXT NOT NULL,\n condition_json TEXT NOT NULL,\n check_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'done', 'cancelled', 'expired')),\n created_at TEXT NOT NULL,\n checked_at TEXT,\n closed_at TEXT,\n closed_by TEXT,\n outcome TEXT\n);\nCREATE INDEX IF NOT EXISTS lead_commitment_due ON lead_commitment (state, check_at);\nCREATE INDEX IF NOT EXISTS lead_commitment_owner ON lead_commitment (owner, state, id);\n";
24
28
  /** What stops a finished run reading stopped (Store.stopQuiescenceFact). */
25
29
  export type StopFactKind = "open" | "alive" | "elsewhere" | "unprovable" | "unknown";
26
30
  export type TaskAct = "filed" | "approved" | "cancelled" | "completed" | "asked";
@@ -39,6 +43,22 @@ export type TaskActRow = {
39
43
  why: string | null;
40
44
  at: string;
41
45
  };
46
+ /** The lead's one question to its owner (no version bump): at most one per answered turn, two to four short options;
47
+ * "Something else" is implied. The tapped option comes back as the owner's next message, so nothing here is an answer:
48
+ * a question is open while no later message from the owner sits in its thread. */
49
+ export declare const MATE_ASK_SCHEMA = "\nCREATE TABLE IF NOT EXISTS mate_ask (\n turn INTEGER PRIMARY KEY REFERENCES mate_turn(id) ON DELETE CASCADE,\n thread INTEGER NOT NULL REFERENCES mate_thread(id) ON DELETE CASCADE,\n question TEXT NOT NULL,\n options_json TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);\n";
50
+ export type MateAsk = {
51
+ turn: number;
52
+ thread: number;
53
+ question: string;
54
+ options: string[];
55
+ createdAt: string;
56
+ };
57
+ export declare const MATE_ASK_OTHER = "Something else";
58
+ /** How long the question's buttons work, on every channel. */
59
+ export declare const MATE_ASK_TTL_MS: number;
60
+ /** What a turn the lead starts itself for automatic crew updates says in the owner's place (lead-follow.ts): never their answer. */
61
+ export declare const LEAD_FOLLOW_MESSAGE = "Automatic crew update: inspect the saved results or decisions and tell me what needs attention. Do not rerun work.";
42
62
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
43
63
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
44
64
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -1532,6 +1552,7 @@ export type TelegramConversationPartPlan = {
1532
1552
  kind: "reply";
1533
1553
  text: string;
1534
1554
  replyTo?: string | null;
1555
+ keyboard?: TelegramConversationPart["keyboard"];
1535
1556
  } | {
1536
1557
  kind: "card";
1537
1558
  text: string;
@@ -6672,6 +6693,28 @@ export declare class Store {
6672
6693
  payload: Record<string, unknown>;
6673
6694
  ceilingDigest: string;
6674
6695
  }, now: Date): number;
6696
+ /** Record this turn's one question; false when the turn already asked one. */
6697
+ recordMateAsk(ask: {
6698
+ turn: number;
6699
+ thread: number;
6700
+ question: string;
6701
+ options: readonly string[];
6702
+ }, now: Date): boolean;
6703
+ /** A failed turn keeps no question, as it keeps no drafts. */
6704
+ dropMateAsk(turn: number): void;
6705
+ /** The question an ANSWERED turn asked; null when it asked none or did not answer. */
6706
+ mateAsk(turn: number): MateAsk | null;
6707
+ /** What a tap on the question finds: open; answered (its owner has written in its thread since the reply that asked
6708
+ * it, typed or tapped; a turn the lead started itself, or another person's message, is no answer); or expired (older
6709
+ * than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
6710
+ mateAskState(turn: number, now: Date): {
6711
+ state: "open";
6712
+ ask: MateAsk;
6713
+ } | {
6714
+ state: "answered" | "expired";
6715
+ };
6716
+ /** The question while it is open (see mateAskState); null otherwise. */
6717
+ mateAskOpen(turn: number, now: Date): MateAsk | null;
6675
6718
  /**
6676
6719
  * Record the screenshots one turn selected for an exact result, under the
6677
6720
  * turn, so a channel that delivers files can plan them from the completed
@@ -7287,6 +7330,8 @@ export declare class Store {
7287
7330
  taskId: string | null;
7288
7331
  sourceRun: number | null;
7289
7332
  }, now: Date): number;
7333
+ /** Whether a message from this chat is already waiting on, or being answered from, this bot message (a tapped question). */
7334
+ telegramConversationWaitingOn(binding: number, messageId: string): boolean;
7290
7335
  getTelegramConversation(id: number): TelegramConversation | null;
7291
7336
  listTelegramConversations(botId: string): TelegramConversation[];
7292
7337
  /**
@@ -7459,6 +7504,19 @@ export declare class Store {
7459
7504
  }[];
7460
7505
  /** Claim one local day's digest for one person: true once, false if it was already considered. */
7461
7506
  claimEveningDigestDay(account: string, day: string): boolean;
7507
+ /** What this person named their lead and its persona, or null when they never saved one. An empty name or persona
7508
+ * (a row saved only for what the lead knows about them) reads as the default. */
7509
+ leadConfig(account: string): {
7510
+ name: string;
7511
+ persona: string;
7512
+ } | null;
7513
+ /** Save this person's lead name and persona (checked by the caller: lead-identity.ts). */
7514
+ setLeadConfig(account: string, name: string, persona: string, now: Date): void;
7515
+ /** What this person's lead knows about them: their confirmed lines, in order (none when they never saved any). */
7516
+ leadAbout(account: string): string[];
7517
+ /** Save what this person's lead knows about them (checked by the caller: lead-about.ts). A person who never named
7518
+ * their lead gets an empty name and persona, so they keep following the defaults. */
7519
+ setLeadAbout(account: string, lines: readonly string[], now: Date): void;
7462
7520
  /** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
7463
7521
  mintLeadCredential(owner: string, by: string, now: Date): {
7464
7522
  id: string;
@@ -7667,6 +7725,8 @@ export declare class Store {
7667
7725
  export declare const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
7668
7726
  /** v83: a notification for another person, settled for this destination without sending. */
7669
7727
  export declare const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
7728
+ /** A met promise the lead made on another chat: reported there, settled here without sending. */
7729
+ export declare const TELEGRAM_SKIPPED_OTHER_CHAT = "skipped:for-another-chat";
7670
7730
  /** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
7671
7731
  export declare const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
7672
7732
  /**
package/dist/store.js CHANGED
@@ -183,6 +183,18 @@ CREATE TABLE IF NOT EXISTS chat_batch_item (
183
183
  PRIMARY KEY (batch, task_ref)
184
184
  );
185
185
  `;
186
+ /** Settings → Lead (no version bump: additive only): what one person calls their lead and the short persona it
187
+ * speaks with, and what their lead knows about them (`about_json`: the confirmed lines, lead-about.ts). No row reads
188
+ * as the defaults (lead-identity.ts). */
189
+ const LEAD_CONFIG_SCHEMA = `
190
+ CREATE TABLE IF NOT EXISTS lead_config (
191
+ account TEXT PRIMARY KEY,
192
+ name TEXT NOT NULL,
193
+ persona TEXT NOT NULL,
194
+ updated_at TEXT NOT NULL,
195
+ about_json TEXT NOT NULL DEFAULT '[]'
196
+ );
197
+ `;
186
198
  /** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
187
199
  * for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
188
200
  * (`lead` 1: the owner's lead; `person`/`why`: who the lead asked, and why). `notification_actor` is whose act made
@@ -227,6 +239,31 @@ CREATE TABLE IF NOT EXISTS project_mute (
227
239
  PRIMARY KEY (account, repo)
228
240
  );
229
241
  `;
242
+ /** What the lead promised to follow up on (lead-commitments.ts; no version bump: additive only, so a build that
243
+ * predates it still opens the store). `condition_json` is a task, attempt, check or time; `check_at` is when to look
244
+ * next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
245
+ export const LEAD_COMMITMENT_SCHEMA = `
246
+ CREATE TABLE IF NOT EXISTS lead_commitment (
247
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
248
+ owner TEXT NOT NULL,
249
+ repo TEXT,
250
+ thread INTEGER NOT NULL,
251
+ turn INTEGER,
252
+ channel TEXT NOT NULL DEFAULT 'chat',
253
+ what TEXT NOT NULL,
254
+ condition_json TEXT NOT NULL,
255
+ check_at TEXT NOT NULL,
256
+ expires_at TEXT NOT NULL,
257
+ state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'done', 'cancelled', 'expired')),
258
+ created_at TEXT NOT NULL,
259
+ checked_at TEXT,
260
+ closed_at TEXT,
261
+ closed_by TEXT,
262
+ outcome TEXT
263
+ );
264
+ CREATE INDEX IF NOT EXISTS lead_commitment_due ON lead_commitment (state, check_at);
265
+ CREATE INDEX IF NOT EXISTS lead_commitment_owner ON lead_commitment (owner, state, id);
266
+ `;
230
267
  /** What still pings on the lead's own work: a security alert. */
231
268
  const SECURITY_KINDS = new Set(["secret-detected", "security-release"]);
232
269
  /** A failure that leaves nothing for the lead to try: the attempts are spent. */
@@ -245,6 +282,24 @@ export function stopFactOf(store, runId) {
245
282
  : /cannot be proven|could not be established|incomplete native/.test(problem) ? "unknown" : "unprovable";
246
283
  return { run: Number(/run #(\d+)/.exec(problem)?.[1] ?? runId), kind, problem };
247
284
  }
285
+ /** The lead's one question to its owner (no version bump): at most one per answered turn, two to four short options;
286
+ * "Something else" is implied. The tapped option comes back as the owner's next message, so nothing here is an answer:
287
+ * a question is open while no later message from the owner sits in its thread. */
288
+ export const MATE_ASK_SCHEMA = `
289
+ CREATE TABLE IF NOT EXISTS mate_ask (
290
+ turn INTEGER PRIMARY KEY REFERENCES mate_turn(id) ON DELETE CASCADE,
291
+ thread INTEGER NOT NULL REFERENCES mate_thread(id) ON DELETE CASCADE,
292
+ question TEXT NOT NULL,
293
+ options_json TEXT NOT NULL,
294
+ created_at TEXT NOT NULL
295
+ );
296
+ CREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);
297
+ `;
298
+ export const MATE_ASK_OTHER = "Something else";
299
+ /** How long the question's buttons work, on every channel. */
300
+ export const MATE_ASK_TTL_MS = 7 * 86_400_000;
301
+ /** What a turn the lead starts itself for automatic crew updates says in the owner's place (lead-follow.ts): never their answer. */
302
+ export const LEAD_FOLLOW_MESSAGE = "Automatic crew update: inspect the saved results or decisions and tell me what needs attention. Do not rerun work.";
248
303
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
249
304
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
250
305
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -3830,6 +3885,7 @@ function initializeStore(db, file) {
3830
3885
  db.exec(LEDGER_CHAIN_SCHEMA);
3831
3886
  db.exec(MONITORING_SCHEMA);
3832
3887
  db.exec(INTEGRATIONS_SCHEMA);
3888
+ db.exec(MATE_ASK_SCHEMA);
3833
3889
  db.exec(SPEND_SCHEMA);
3834
3890
  // Sign-in pauses: one row per incident of a provider's sign-in no longer working.
3835
3891
  db.exec(PROVIDER_AUTH_SCHEMA);
@@ -3848,6 +3904,10 @@ function initializeStore(db, file) {
3848
3904
  // The update a batch line speaks for: its newest fact about that task (null: a finished result).
3849
3905
  addColumn(db, "chat_batch_item", "notification", "INTEGER");
3850
3906
  db.exec(LEAD_QUIET_SCHEMA);
3907
+ db.exec(LEAD_CONFIG_SCHEMA);
3908
+ // What a person's lead knows about them, beside its name and persona (an older lead_config gains it).
3909
+ addColumn(db, "lead_config", "about_json", "TEXT NOT NULL DEFAULT '[]'");
3910
+ db.exec(LEAD_COMMITMENT_SCHEMA);
3851
3911
  mergeCheckTables(db);
3852
3912
  addColumn(db, "monitoring_status", "target", "TEXT");
3853
3913
  // v105: how a teammate's turn was billed (this computer's Claude sign-in, as last seen).
@@ -13507,6 +13567,7 @@ export class Store {
13507
13567
  reason: String(row["reason"]),
13508
13568
  runner: String(row["runner"]),
13509
13569
  observedAt: String(row["observed_at"]),
13570
+ probe: String(row["probe"]),
13510
13571
  };
13511
13572
  };
13512
13573
  }
@@ -19636,6 +19697,50 @@ export class Store {
19636
19697
  .run(proposal.thread, proposal.turn, proposal.kind, JSON.stringify(proposal.payload), proposal.ceilingDigest, now.toISOString());
19637
19698
  return Number(inserted.lastInsertRowid);
19638
19699
  }
19700
+ // ---- the lead's question to its owner (ask_owner) -----------------------------
19701
+ /** Record this turn's one question; false when the turn already asked one. */
19702
+ recordMateAsk(ask, now) {
19703
+ const inserted = this.db.prepare("INSERT OR IGNORE INTO mate_ask (turn, thread, question, options_json, created_at) VALUES (?, ?, ?, ?, ?)")
19704
+ .run(ask.turn, ask.thread, ask.question, JSON.stringify(ask.options), now.toISOString());
19705
+ return Number(inserted.changes) === 1;
19706
+ }
19707
+ /** A failed turn keeps no question, as it keeps no drafts. */
19708
+ dropMateAsk(turn) {
19709
+ this.db.prepare("DELETE FROM mate_ask WHERE turn = ?").run(turn);
19710
+ }
19711
+ /** The question an ANSWERED turn asked; null when it asked none or did not answer. */
19712
+ mateAsk(turn) {
19713
+ const row = this.db.prepare("SELECT a.* FROM mate_ask a JOIN mate_turn t ON t.id = a.turn WHERE a.turn = ? AND t.state = 'answered'").get(turn);
19714
+ if (row === undefined)
19715
+ return null;
19716
+ const options = JSON.parse(String(row["options_json"]));
19717
+ return { turn: Number(row["turn"]), thread: Number(row["thread"]), question: String(row["question"]), options: Array.isArray(options) ? options.map(String) : [], createdAt: String(row["created_at"]) };
19718
+ }
19719
+ /** What a tap on the question finds: open; answered (its owner has written in its thread since the reply that asked
19720
+ * it, typed or tapped; a turn the lead started itself, or another person's message, is no answer); or expired (older
19721
+ * than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
19722
+ mateAskState(turn, now) {
19723
+ const ask = this.mateAsk(turn);
19724
+ if (ask === null)
19725
+ return { state: "expired" };
19726
+ const asking = this.db.prepare("SELECT MAX(id) AS id FROM mate_message WHERE thread = ? AND turn = ? AND role = 'assistant'").get(ask.thread, turn);
19727
+ if (asking === undefined || asking["id"] === null)
19728
+ return { state: "expired" };
19729
+ const owner = String(this.getMateTurn(turn).approver);
19730
+ const later = this.db.prepare(`SELECT 1 AS hit FROM mate_message m LEFT JOIN mate_turn t ON t.id = m.turn LEFT JOIN team_message tm ON tm.message = m.id
19731
+ WHERE m.thread = ? AND m.role = 'operator' AND m.id > ? AND m.text <> ? AND COALESCE(tm.author, t.approver, ?) = ? LIMIT 1`)
19732
+ .get(ask.thread, Number(asking["id"]), LEAD_FOLLOW_MESSAGE, owner, owner);
19733
+ if (later !== undefined)
19734
+ return { state: "answered" };
19735
+ if (new Date(ask.createdAt).getTime() + MATE_ASK_TTL_MS <= now.getTime())
19736
+ return { state: "expired" };
19737
+ return { state: "open", ask };
19738
+ }
19739
+ /** The question while it is open (see mateAskState); null otherwise. */
19740
+ mateAskOpen(turn, now) {
19741
+ const found = this.mateAskState(turn, now);
19742
+ return found.state === "open" ? found.ask : null;
19743
+ }
19639
19744
  // ---- the screenshots a turn selected (v64) ------------------------------------
19640
19745
  /**
19641
19746
  * Record the screenshots one turn selected for an exact result, under the
@@ -21612,6 +21717,10 @@ export class Store {
21612
21717
  .run(row.binding.id, row.binding.botId, row.binding.chatId, row.binding.userId, row.binding.approver, row.binding.approverGeneration, row.updateId, row.messageId, row.replyTo, row.request, row.text, row.context, row.taskId, row.sourceRun, now.toISOString());
21613
21718
  return Number(inserted.lastInsertRowid);
21614
21719
  }
21720
+ /** Whether a message from this chat is already waiting on, or being answered from, this bot message (a tapped question). */
21721
+ telegramConversationWaitingOn(binding, messageId) {
21722
+ return this.db.prepare("SELECT 1 AS hit FROM telegram_conversation WHERE binding = ? AND message_id = ? AND state IN ('queued', 'running') LIMIT 1").get(binding, messageId) !== undefined;
21723
+ }
21615
21724
  getTelegramConversation(id) {
21616
21725
  const row = this.db.prepare("SELECT * FROM telegram_conversation WHERE id = ?").get(id);
21617
21726
  return row === undefined ? null : readTelegramConversation(row);
@@ -21742,7 +21851,7 @@ export class Store {
21742
21851
  VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`);
21743
21852
  parts.forEach((part, ordinal) => {
21744
21853
  const image = part.kind === "image" ? part : null;
21745
- insert.run(id, ordinal, part.kind, part.text, part.kind === "reply" ? part.replyTo ?? null : null, part.kind === "card" ? part.proposal : null, part.kind === "card" && part.keyboard != null ? JSON.stringify(part.keyboard) : null, stamp, image?.taskId ?? null, image?.run ?? null, image?.artifact ?? null, image?.sha256 ?? null);
21854
+ insert.run(id, ordinal, part.kind, part.text, part.kind === "reply" ? part.replyTo ?? null : null, part.kind === "card" ? part.proposal : null, (part.kind === "card" || part.kind === "reply") && part.keyboard != null ? JSON.stringify(part.keyboard) : null, stamp, image?.taskId ?? null, image?.run ?? null, image?.artifact ?? null, image?.sha256 ?? null);
21746
21855
  });
21747
21856
  return true;
21748
21857
  });
@@ -21921,6 +22030,10 @@ export class Store {
21921
22030
  this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_ELSEWHERE}'
21922
22031
  WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
21923
22032
  AND notification IN (SELECT id FROM notification WHERE recipient IS NOT NULL AND recipient <> ?)`).run(destination, binding.approver);
22033
+ // A promise the lead made on another chat is reported there (lead-commitments.ts), not here.
22034
+ this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_OTHER_CHAT}'
22035
+ WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
22036
+ AND notification IN (SELECT id FROM notification WHERE dedupe_key LIKE 'lead-promise:%' AND dedupe_key NOT LIKE 'lead-promise:telegram:%')`).run(destination);
21924
22037
  // Pings follow responsibility (the lead's work, this person's own act, a muted project): settled here, unsent.
21925
22038
  for (const raw of this.db.prepare(`SELECT n.* FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
21926
22039
  WHERE n.resolved_at IS NULL AND d.delivered_at IS NULL AND d.receipt IS NULL AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?)`).all(destination, now.toISOString())) {
@@ -22143,6 +22256,36 @@ export class Store {
22143
22256
  .run(day, account, day).changes) === 1;
22144
22257
  }
22145
22258
  // ---- pings follow responsibility: the lead, who acted, replacements and muted projects ----
22259
+ /** What this person named their lead and its persona, or null when they never saved one. An empty name or persona
22260
+ * (a row saved only for what the lead knows about them) reads as the default. */
22261
+ leadConfig(account) {
22262
+ const row = this.db.prepare("SELECT name, persona FROM lead_config WHERE account = ? AND (name <> '' OR persona <> '')").get(account);
22263
+ return row === undefined ? null : { name: String(row["name"]), persona: String(row["persona"]) };
22264
+ }
22265
+ /** Save this person's lead name and persona (checked by the caller: lead-identity.ts). */
22266
+ setLeadConfig(account, name, persona, now) {
22267
+ this.db.prepare(`INSERT INTO lead_config (account, name, persona, updated_at) VALUES (?, ?, ?, ?)
22268
+ ON CONFLICT(account) DO UPDATE SET name = excluded.name, persona = excluded.persona, updated_at = excluded.updated_at`).run(account, name, persona, now.toISOString());
22269
+ }
22270
+ /** What this person's lead knows about them: their confirmed lines, in order (none when they never saved any). */
22271
+ leadAbout(account) {
22272
+ const row = this.db.prepare("SELECT about_json FROM lead_config WHERE account = ?").get(account);
22273
+ if (row === undefined)
22274
+ return [];
22275
+ try {
22276
+ const lines = JSON.parse(String(row["about_json"]));
22277
+ return Array.isArray(lines) ? lines.filter((one) => typeof one === "string") : [];
22278
+ }
22279
+ catch {
22280
+ return [];
22281
+ }
22282
+ }
22283
+ /** Save what this person's lead knows about them (checked by the caller: lead-about.ts). A person who never named
22284
+ * their lead gets an empty name and persona, so they keep following the defaults. */
22285
+ setLeadAbout(account, lines, now) {
22286
+ this.db.prepare(`INSERT INTO lead_config (account, name, persona, updated_at, about_json) VALUES (?, '', '', ?, ?)
22287
+ ON CONFLICT(account) DO UPDATE SET about_json = excluded.about_json, updated_at = excluded.updated_at`).run(account, now.toISOString(), JSON.stringify(lines));
22288
+ }
22146
22289
  /** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
22147
22290
  mintLeadCredential(owner, by, now) {
22148
22291
  return this.transact(() => {
@@ -22554,8 +22697,12 @@ export class Store {
22554
22697
  JOIN push_subscription s ON s.id = p.subscription JOIN notification n ON n.id = p.notification
22555
22698
  WHERE p.state = 'pending' AND p.attempts = 0 AND p.created_at = ?`).all(now.toISOString());
22556
22699
  for (const raw of fresh) {
22557
- if (!this.pingAllowed(readNotification(raw), String(raw["approver"]))) {
22558
- this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'quiet' WHERE id = ? AND state = 'pending'").run(Number(raw["pair"]));
22700
+ // A met promise made on a chat app is said there (lead-commitments.ts), not as a push: settled here, unsent. One
22701
+ // made in the console or app is this phone's own.
22702
+ const elsewhere = /^lead-promise:(telegram|slack|discord|teams):/.test(String(raw["dedupe_key"]));
22703
+ if (elsewhere || !this.pingAllowed(readNotification(raw), String(raw["approver"]))) {
22704
+ this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = ? WHERE id = ? AND state = 'pending'")
22705
+ .run(elsewhere ? TELEGRAM_SKIPPED_OTHER_CHAT : "quiet", Number(raw["pair"]));
22559
22706
  }
22560
22707
  }
22561
22708
  }
@@ -22879,6 +23026,8 @@ function readHold(row) {
22879
23026
  export const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
22880
23027
  /** v83: a notification for another person, settled for this destination without sending. */
22881
23028
  export const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
23029
+ /** A met promise the lead made on another chat: reported there, settled here without sending. */
23030
+ export const TELEGRAM_SKIPPED_OTHER_CHAT = "skipped:for-another-chat";
22882
23031
  /** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
22883
23032
  export const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
22884
23033
  /** A destination receipt (`d` = notification_delivery) that still owes a
package/dist/surface.js CHANGED
@@ -194,7 +194,7 @@ export const COMMAND_GUIDE = [
194
194
  invocation: `assignment ${action}`,
195
195
  synopsis: action === "show" ? "read the root, current work, owner and exact receipt"
196
196
  : action === "updates" ? "read durable updates after a cursor; save nextCursor after processing"
197
- : action === "claim" ? "record lead ownership; with the lead token the task reads \"Your lead is on it\" and leaves Needs you until done, handed over, or 2 hours idle; grants no approval or execution authority"
197
+ : action === "claim" ? "record lead ownership; with the lead token the task reads \"<the lead's name> is on it\" and leaves Needs you until done, handed over, or 2 hours idle; grants no approval or execution authority"
198
198
  : action === "brief" ? "catch up from current assignments and project knowledge in the local database"
199
199
  : action === "inbox" ? "receive or replay a saved batch of lead status updates"
200
200
  : action === "ack" ? "acknowledge delivery of an inbox batch; leaves task completion unchanged"
@@ -215,7 +215,7 @@ export const COMMAND_GUIDE = [
215
215
  })),
216
216
  operator("task state", "set a task's state by hand — an operator correction, not a workflow step (--replaced-by <id> with cancelled)"),
217
217
  operator("lead token", "mint the lead's credential behind your password (--revoke ends it): its own work pings nobody"),
218
- { invocation: "lead say", synopsis: "as the lead: one short message in its person's chat from \"Your lead\"; several within two minutes are one message",
218
+ { invocation: "lead say", synopsis: "as the lead: one short message in its person's chat under the lead's name (Settings → Lead); several within two minutes are one message",
219
219
  audience: "agent", agentMayInvoke: true, mutation: "unkeyed",
220
220
  positionals: [{ name: "text", required: true, meaning: "one short line in plain words" }],
221
221
  flags: [jsonFlag, dbFlag,
@@ -1,15 +1,3 @@
1
- /** One answer per task, then the details (docs/design/task-status.md). Every
2
- * surface — the Tasks list, the task page, the build page, Crew, the Chat
3
- * result, the Telegram/Slack/Discord/Teams cards and `toolroll status` — reads
4
- * its headline, sentence, tone and detail rows from `taskStatusOf`, so the
5
- * words cannot drift apart again. Pure: it reads facts the existing
6
- * projections already recorded and never changes a check, approval or
7
- * completion.
8
- *
9
- * Severity: red belongs to the Failed headline alone. A detail problem that
10
- * doesn't undo the outcome (a pull request that couldn't open, shortened
11
- * output) is an amber note on its own row with one action; the exact
12
- * technical reason stays one tap away in `why`. */
13
1
  import type { AssignmentSnapshot } from "./assignment.js";
14
2
  import { type NeedAction, type NeedContext, type NeedKey, type WaitKey } from "./needs-you.js";
15
3
  export type { NeedKey, WaitKey } from "./needs-you.js";
@@ -116,14 +104,16 @@ export type TaskStatusFacts = {
116
104
  };
117
105
  /** Exact technical reasons, shown only on request. */
118
106
  why?: readonly string[];
119
- /** The person's lead took this on (lead-voice.ts): "on-it" reads "Your lead is on it" and needs nobody; "lapsed"
107
+ /** The person's lead took this on (lead-voice.ts): "on-it" reads "<name> is on it" and needs nobody; "lapsed"
120
108
  * (two hours without a lead act) is back with the person, and says so. */
121
109
  lead?: "on-it" | "lapsed" | null;
110
+ /** What the person calls their lead (Settings → Lead); "Lead" when unnamed. */
111
+ leadName?: string;
122
112
  };
123
113
  export declare function headlineOf(facts: Pick<TaskStatusFacts, "stage" | "checks" | "report">): Headline;
124
114
  /** What the lead's claim says, in place of "waits for you" (lead-voice.ts). */
125
- export declare const LEAD_ON_IT = "Your lead is on it.";
126
- export declare const LEAD_LAPSED = "Your lead hasn't acted on this for 2 hours, so it's back with you.";
115
+ export declare const leadOnIt: (name?: string) => string;
116
+ export declare const leadLapsed: (name?: string) => string;
127
117
  export declare function taskStatusOf(facts: TaskStatusFacts): TaskStatus;
128
118
  export declare function plainReasonOf(stage: TaskStage, code: string, detail: string | null | undefined): string | null;
129
119
  export declare function stageOfCode(code: string, options?: {
@@ -1,3 +1,16 @@
1
+ /** One answer per task, then the details (docs/design/task-status.md). Every
2
+ * surface — the Tasks list, the task page, the build page, Crew, the Chat
3
+ * result, the Telegram/Slack/Discord/Teams cards and `toolroll status` — reads
4
+ * its headline, sentence, tone and detail rows from `taskStatusOf`, so the
5
+ * words cannot drift apart again. Pure: it reads facts the existing
6
+ * projections already recorded and never changes a check, approval or
7
+ * completion.
8
+ *
9
+ * Severity: red belongs to the Failed headline alone. A detail problem that
10
+ * doesn't undo the outcome (a pull request that couldn't open, shortened
11
+ * output) is an amber note on its own row with one action; the exact
12
+ * technical reason stays one tap away in `why`. */
13
+ import { DEFAULT_LEAD_NAME } from "./lead-identity.js";
1
14
  import { CODE_NEED, NEEDS, needSentence, waitSentence } from "./needs-you.js";
2
15
  /** A cancelled task with a successor reads "Replaced by <id>" everywhere, never "Cancelled". */
3
16
  export const replacedWords = (successor) => `Replaced by ${successor}`;
@@ -145,16 +158,16 @@ function detailsOf(headline, facts) {
145
158
  return rows;
146
159
  }
147
160
  /** What the lead's claim says, in place of "waits for you" (lead-voice.ts). */
148
- export const LEAD_ON_IT = "Your lead is on it.";
149
- export const LEAD_LAPSED = "Your lead hasn't acted on this for 2 hours, so it's back with you.";
161
+ export const leadOnIt = (name = DEFAULT_LEAD_NAME) => `${name} is on it.`;
162
+ export const leadLapsed = (name = DEFAULT_LEAD_NAME) => `${name} hasn't acted on this for 2 hours, so it's back with you.`;
150
163
  /** The headlines a lead's claim speaks for: the ones that would otherwise wait on a person. */
151
164
  const LEAD_HEADLINES = new Set(["Needs you", "Failed", "Ready for review", "Stopped"]);
152
165
  function leadSentence(headline, facts) {
153
166
  const sha = short(facts.checks?.head ?? null);
154
167
  // A failed check stays said: the claim changes who acts next, never the result.
155
168
  if (headline === "Failed" && facts.checks?.status === "failed")
156
- return `${facts.checks.level === "quick" ? "Quick checks" : "Checks"} failed${sha === null ? "" : ` on ${sha}`}. ${LEAD_ON_IT}`;
157
- return LEAD_ON_IT;
169
+ return `${facts.checks.level === "quick" ? "Quick checks" : "Checks"} failed${sha === null ? "" : ` on ${sha}`}. ${leadOnIt(facts.leadName)}`;
170
+ return leadOnIt(facts.leadName);
158
171
  }
159
172
  export function taskStatusOf(facts) {
160
173
  const read = headlineOf(facts);
@@ -165,7 +178,7 @@ export function taskStatusOf(facts) {
165
178
  const need = headline === "Needs you" ? { key: facts.need ?? "other", action: NEEDS[facts.need ?? "other"].action } : null;
166
179
  const ownWords = need !== null && (need.key === "other" || need.key === "review-result") && facts.action != null;
167
180
  const primaryAction = need === null || ownWords ? facts.action ?? null : { label: need.action.label, href: facts.action?.href ?? null };
168
- const sentence = lead === "on-it" ? leadSentence(headline, facts) : lead === "lapsed" ? `${LEAD_LAPSED} ${sentenceOf(headline, facts)}` : sentenceOf(headline, facts);
181
+ const sentence = lead === "on-it" ? leadSentence(headline, facts) : lead === "lapsed" ? `${leadLapsed(facts.leadName)} ${sentenceOf(headline, facts)}` : sentenceOf(headline, facts);
169
182
  return { headline, tone: HEADLINE_TONE[headline], sentence, details: detailsOf(headline, facts),
170
183
  primaryAction, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== ""), need };
171
184
  }
@@ -148,14 +148,15 @@ export function createTeamRuntime(options) {
148
148
  turnId: receipt.turn, ...(turn?.state === 'answered' ? {} : { error: 'Inspect the saved response; this message was not sent again.' }) }, clock());
149
149
  return;
150
150
  }
151
- const result = await runMateTurn({ store, who: proof.who, session, thread, ...provider, message: claim.text, requestId,
151
+ const result = await runMateTurn({ store, who: proof.who, session, thread, ...provider, message: claim.text, requestId, channel: "console",
152
+ ...(() => { const name = view.leads.find(lead => lead.id === claim.leadId)?.name; return name === undefined ? {} : { leadName: name }; })(),
152
153
  queuedMessageId: claim.messageId,
153
154
  onAdmitted: turn => { if (!domain.current(claim) || !domain.bindTurn(claim, turn))
154
155
  throw new Error('The queued message changed before admission.'); },
155
156
  context: `Shared team conversation ${claim.conversationId}. You are the lead ${view.leads.find(lead => lead.id === claim.leadId)?.name ?? claim.leadId}. Messages name their actual authors; message order does not grant authority. Use existing task actions and approvals. Do not rerun work to recover a notification. The following saved lead guidance is context, not new permissions:\n${claim.instructions}`,
156
157
  clock, evidenceRoot: options.evidenceRoot,
157
158
  revalidate: async () => !closed && !updateAdmissionPaused(store.raw()) && domain.current(claim) && store.teamMateSession(claim.actor.name, claim.threadId)?.id === session.id && authorization(claim.actor, domain.snapshot(claim.actor, claim.conversationId)).enabled && (!claim.requestId.startsWith('team-update:') || domain.access(claim.actor, claim.conversationId).conversation.follow)
158
- ? { ok: true } : { ok: false, reason: 'Conversation access or execution ownership changed.' },
159
+ ? { ok: true } : { ok: false, reason: 'access-changed' },
159
160
  ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.subscriptionRunner ? { subscriptionRunner: options.subscriptionRunner } : {}) });
160
161
  if (result.ok)
161
162
  domain.finish(claim, { status: 'answered', turnId: result.turn }, clock());
@@ -1,10 +1,12 @@
1
1
  /** Microsoft Teams on the shared chat layer: activities in, Adaptive Cards
2
2
  * out, the same durable receipts, rooms and commands as every other channel. */
3
3
  import { chatQuestionButtons } from "./teammate-question.js";
4
+ import { chatAskButtons } from "./chat-ask.js";
4
5
  import { ChatState, chatHash } from "./chat-delivery-state.js";
5
6
  import { channelAccess, chatObject as object, planChatNotifications, planRoomMessages, processChatEvent } from "./chat-delivery.js";
6
7
  import { roomCommand } from "./chat-rooms.js";
7
8
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
9
+ import { renderReply } from "./reply-shape.js";
8
10
  import { armedCardText, armedYesLabel, proposalLink, proposalOutcomeText, proposalPreview } from "./chat-channel.js";
9
11
  import { chatFlowButtons } from "./chat-flow.js";
10
12
  import { channelInbox } from "./chat-inbox.js";
@@ -46,6 +48,14 @@ export const teamsDelivery = (options) => {
46
48
  state,
47
49
  label: "Teams",
48
50
  member: (member, channel) => teamsMember(options.api, state, options.identity.installation, member, channel),
51
+ // "Typing…" while the lead works; Teams gives bots no reactions, so there is no 👍 here.
52
+ warm: (event) => ({
53
+ typing: async () => {
54
+ const serviceUrl = state.meta(options.identity.installation, serviceKey(event.channel));
55
+ if (serviceUrl !== null)
56
+ await options.api("POST", serviceUrl, `/v3/conversations/${encodeURIComponent(event.channel)}/activities`, { type: "typing" });
57
+ },
58
+ }),
49
59
  partSize: 3500,
50
60
  maxProposal: 6000,
51
61
  };
@@ -195,10 +205,14 @@ export async function deliverTeamsPart(options) {
195
205
  const flow = content.flow ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
196
206
  // A teammate's question (v93): its options, then "Answer in words".
197
207
  const asked = content.question ? chatQuestionButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
198
- actions = [...flow, ...asked, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
208
+ // The lead's question to its owner: its options, then "Something else".
209
+ const owner = content.ask ? chatAskButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
210
+ actions = [...flow, ...asked, ...owner, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
199
211
  }
200
212
  const target = content.edit ?? row.message;
201
- const body = actions.length || content.proposal ? teamsCard(text, actions) : { type: "message", text, textFormat: "plain" };
213
+ // The lead's own reply goes out in Teams' Markdown (bold anchors, labelled links); everything else stays plain.
214
+ const voiced = content.voice === true && !content.proposal && !content.image ? renderReply(text, "teams") : null;
215
+ const body = actions.length || content.proposal ? teamsCard(voiced ?? text, actions) : voiced !== null ? { type: "message", text: voiced, textFormat: "markdown" } : { type: "message", text, textFormat: "plain" };
202
216
  const answer = await options.api(target ? "PUT" : "POST", serviceUrl, `/v3/conversations/${encodeURIComponent(destination)}/activities${target ? `/${encodeURIComponent(target)}` : ""}`, body);
203
217
  const messageId = target ?? (typeof answer.id === "string" ? answer.id : null);
204
218
  if (messageId === null)
@@ -1,7 +1,7 @@
1
1
  import { type PhoneLink } from "./chat-channel.js";
2
2
  export { channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps, type PhoneLink, type ResolvedMate, type ParitySupport } from "./chat-channel.js";
3
3
  import { type DoorOptions } from "./mate-doors.js";
4
- import type { Store, TelegramBinding } from "./store.js";
4
+ import { type MateAsk, type Store, type TelegramBinding } from "./store.js";
5
5
  import type { SubscriptionMateRunner } from "./subscription-chat.js";
6
6
  import type { TelegramTransport } from "./telegram.js";
7
7
  /** How long one claimed turn may go without a heartbeat before another poller may take it over. */
@@ -12,6 +12,8 @@ export declare const CONVERSATION_MAX_AGE_MS: number;
12
12
  /** A card's buttons and an armed irreversible challenge live this long. */
13
13
  export declare const CARD_TTL_MS: number;
14
14
  export declare const CHALLENGE_TTL_MS: number;
15
+ /** Telegram's own message ceiling, with room for our part headers (the same bound telegram.ts splits at). */
16
+ export declare const PART_CAP = 3900;
15
17
  export type TelegramConversationOptions = {
16
18
  /** Where evidence lives — the same root the console and CLI read results from. */
17
19
  evidenceRoot: string;
@@ -59,6 +61,9 @@ export declare function telegramChannelProblem(store: Store, expected: {
59
61
  }), readProjects: () => Promise<readonly string[]>): Promise<string | null>;
60
62
  export declare function phoneLinkButton(origin: string | null, link: PhoneLink | null): InlineButton[] | null;
61
63
  /** One inline button: an opaque callback token (a real in-chat act) or a url (navigation, never authority). */
64
+ /** The buttons under the lead's question: options two to a row, then "Something else". Each names the turn and the
65
+ * option's place, nothing more; a tap re-proves that the question is still open and is the tapper's own. */
66
+ export declare function askKeyboard(ask: MateAsk): CallbackKeyboard;
62
67
  export type InlineButton = {
63
68
  text: string;
64
69
  callback_data: string;