toolroll 0.9.15 → 0.9.16

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 (67) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/assignment-brief.d.ts +10 -0
  3. package/dist/assignment-brief.js +49 -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-ask.d.ts +28 -0
  11. package/dist/chat-ask.js +77 -0
  12. package/dist/chat-channel.js +6 -1
  13. package/dist/chat-controls.d.ts +8 -0
  14. package/dist/chat-controls.js +3 -0
  15. package/dist/chat-delivery-state.d.ts +6 -1
  16. package/dist/chat-delivery-state.js +15 -2
  17. package/dist/chat-delivery.js +20 -3
  18. package/dist/chat-quiet.js +4 -2
  19. package/dist/chat-voice.d.ts +3 -6
  20. package/dist/chat-voice.js +8 -2
  21. package/dist/discord-chat.js +10 -0
  22. package/dist/lead-commitments.d.ts +84 -0
  23. package/dist/lead-commitments.js +208 -0
  24. package/dist/lead-context.d.ts +32 -3
  25. package/dist/lead-context.js +133 -19
  26. package/dist/lead-follow.js +3 -0
  27. package/dist/lead-identity.d.ts +23 -0
  28. package/dist/lead-identity.js +33 -0
  29. package/dist/lead-voice.d.ts +12 -9
  30. package/dist/lead-voice.js +25 -16
  31. package/dist/ledger-view.js +3 -2
  32. package/dist/mate-cli.d.ts +1 -1
  33. package/dist/mate-cli.js +8 -2
  34. package/dist/mate-contract.d.ts +1 -1
  35. package/dist/mate-contract.js +11 -4
  36. package/dist/mate-progress.js +5 -0
  37. package/dist/mate-tools.d.ts +18 -1
  38. package/dist/mate-tools.js +250 -6
  39. package/dist/mate.d.ts +9 -0
  40. package/dist/mate.js +34 -38
  41. package/dist/operate.js +2 -1
  42. package/dist/project-delete.js +3 -1
  43. package/dist/result-acts.d.ts +14 -0
  44. package/dist/result-acts.js +19 -0
  45. package/dist/retention.js +6 -0
  46. package/dist/serve.js +111 -29
  47. package/dist/slack-chat.js +10 -0
  48. package/dist/store.d.ts +51 -0
  49. package/dist/store.js +115 -1
  50. package/dist/surface.js +2 -2
  51. package/dist/task-status.d.ts +5 -15
  52. package/dist/task-status.js +18 -5
  53. package/dist/team-runtime.js +2 -1
  54. package/dist/teams-chat.js +4 -1
  55. package/dist/telegram-mate.d.ts +4 -1
  56. package/dist/telegram-mate.js +17 -1
  57. package/dist/telegram-progress.js +3 -2
  58. package/dist/telegram.js +43 -3
  59. package/dist/test-temp.js +1 -1
  60. package/dist/when-html.d.ts +8 -3
  61. package/dist/when-html.js +23 -3
  62. package/dist/work-index.d.ts +2 -0
  63. package/dist/work-index.js +9 -6
  64. package/dist/work-summary.d.ts +1 -1
  65. package/dist/workspace-ui.d.ts +2 -14
  66. package/dist/workspace-ui.js +4 -22
  67. package/package.json +1 -1
package/dist/store.js CHANGED
@@ -183,6 +183,16 @@ 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. No row reads as the defaults (lead-identity.ts). */
188
+ const LEAD_CONFIG_SCHEMA = `
189
+ CREATE TABLE IF NOT EXISTS lead_config (
190
+ account TEXT PRIMARY KEY,
191
+ name TEXT NOT NULL,
192
+ persona TEXT NOT NULL,
193
+ updated_at TEXT NOT NULL
194
+ );
195
+ `;
186
196
  /** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
187
197
  * for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
188
198
  * (`lead` 1: the owner's lead; `person`/`why`: who the lead asked, and why). `notification_actor` is whose act made
@@ -227,6 +237,31 @@ CREATE TABLE IF NOT EXISTS project_mute (
227
237
  PRIMARY KEY (account, repo)
228
238
  );
229
239
  `;
240
+ /** What the lead promised to follow up on (lead-commitments.ts; no version bump: additive only, so a build that
241
+ * predates it still opens the store). `condition_json` is a task, attempt, check or time; `check_at` is when to look
242
+ * next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
243
+ export const LEAD_COMMITMENT_SCHEMA = `
244
+ CREATE TABLE IF NOT EXISTS lead_commitment (
245
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
246
+ owner TEXT NOT NULL,
247
+ repo TEXT,
248
+ thread INTEGER NOT NULL,
249
+ turn INTEGER,
250
+ channel TEXT NOT NULL DEFAULT 'chat',
251
+ what TEXT NOT NULL,
252
+ condition_json TEXT NOT NULL,
253
+ check_at TEXT NOT NULL,
254
+ expires_at TEXT NOT NULL,
255
+ state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'done', 'cancelled', 'expired')),
256
+ created_at TEXT NOT NULL,
257
+ checked_at TEXT,
258
+ closed_at TEXT,
259
+ closed_by TEXT,
260
+ outcome TEXT
261
+ );
262
+ CREATE INDEX IF NOT EXISTS lead_commitment_due ON lead_commitment (state, check_at);
263
+ CREATE INDEX IF NOT EXISTS lead_commitment_owner ON lead_commitment (owner, state, id);
264
+ `;
230
265
  /** What still pings on the lead's own work: a security alert. */
231
266
  const SECURITY_KINDS = new Set(["secret-detected", "security-release"]);
232
267
  /** A failure that leaves nothing for the lead to try: the attempts are spent. */
@@ -245,6 +280,22 @@ export function stopFactOf(store, runId) {
245
280
  : /cannot be proven|could not be established|incomplete native/.test(problem) ? "unknown" : "unprovable";
246
281
  return { run: Number(/run #(\d+)/.exec(problem)?.[1] ?? runId), kind, problem };
247
282
  }
283
+ /** The lead's one question to its owner (no version bump): at most one per answered turn, two to four short options;
284
+ * "Something else" is implied. The tapped option comes back as the owner's next message, so nothing here is an answer:
285
+ * a question is open while no later message from the owner sits in its thread. */
286
+ export const MATE_ASK_SCHEMA = `
287
+ CREATE TABLE IF NOT EXISTS mate_ask (
288
+ turn INTEGER PRIMARY KEY REFERENCES mate_turn(id) ON DELETE CASCADE,
289
+ thread INTEGER NOT NULL REFERENCES mate_thread(id) ON DELETE CASCADE,
290
+ question TEXT NOT NULL,
291
+ options_json TEXT NOT NULL,
292
+ created_at TEXT NOT NULL
293
+ );
294
+ CREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);
295
+ `;
296
+ export const MATE_ASK_OTHER = "Something else";
297
+ /** How long the question's buttons work, on every channel. */
298
+ export const MATE_ASK_TTL_MS = 7 * 86_400_000;
248
299
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
249
300
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
250
301
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -3830,6 +3881,7 @@ function initializeStore(db, file) {
3830
3881
  db.exec(LEDGER_CHAIN_SCHEMA);
3831
3882
  db.exec(MONITORING_SCHEMA);
3832
3883
  db.exec(INTEGRATIONS_SCHEMA);
3884
+ db.exec(MATE_ASK_SCHEMA);
3833
3885
  db.exec(SPEND_SCHEMA);
3834
3886
  // Sign-in pauses: one row per incident of a provider's sign-in no longer working.
3835
3887
  db.exec(PROVIDER_AUTH_SCHEMA);
@@ -3848,6 +3900,8 @@ function initializeStore(db, file) {
3848
3900
  // The update a batch line speaks for: its newest fact about that task (null: a finished result).
3849
3901
  addColumn(db, "chat_batch_item", "notification", "INTEGER");
3850
3902
  db.exec(LEAD_QUIET_SCHEMA);
3903
+ db.exec(LEAD_CONFIG_SCHEMA);
3904
+ db.exec(LEAD_COMMITMENT_SCHEMA);
3851
3905
  mergeCheckTables(db);
3852
3906
  addColumn(db, "monitoring_status", "target", "TEXT");
3853
3907
  // v105: how a teammate's turn was billed (this computer's Claude sign-in, as last seen).
@@ -19636,6 +19690,46 @@ export class Store {
19636
19690
  .run(proposal.thread, proposal.turn, proposal.kind, JSON.stringify(proposal.payload), proposal.ceilingDigest, now.toISOString());
19637
19691
  return Number(inserted.lastInsertRowid);
19638
19692
  }
19693
+ // ---- the lead's question to its owner (ask_owner) -----------------------------
19694
+ /** Record this turn's one question; false when the turn already asked one. */
19695
+ recordMateAsk(ask, now) {
19696
+ const inserted = this.db.prepare("INSERT OR IGNORE INTO mate_ask (turn, thread, question, options_json, created_at) VALUES (?, ?, ?, ?, ?)")
19697
+ .run(ask.turn, ask.thread, ask.question, JSON.stringify(ask.options), now.toISOString());
19698
+ return Number(inserted.changes) === 1;
19699
+ }
19700
+ /** A failed turn keeps no question, as it keeps no drafts. */
19701
+ dropMateAsk(turn) {
19702
+ this.db.prepare("DELETE FROM mate_ask WHERE turn = ?").run(turn);
19703
+ }
19704
+ /** The question an ANSWERED turn asked; null when it asked none or did not answer. */
19705
+ mateAsk(turn) {
19706
+ 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);
19707
+ if (row === undefined)
19708
+ return null;
19709
+ const options = JSON.parse(String(row["options_json"]));
19710
+ 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"]) };
19711
+ }
19712
+ /** What a tap on the question finds: open; answered (the owner has written in its thread since the reply that asked it);
19713
+ * or expired (older than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
19714
+ mateAskState(turn, now) {
19715
+ const ask = this.mateAsk(turn);
19716
+ if (ask === null)
19717
+ return { state: "expired" };
19718
+ const asking = this.db.prepare("SELECT MAX(id) AS id FROM mate_message WHERE thread = ? AND turn = ? AND role = 'assistant'").get(ask.thread, turn);
19719
+ if (asking === undefined || asking["id"] === null)
19720
+ return { state: "expired" };
19721
+ const later = this.db.prepare("SELECT 1 AS hit FROM mate_message WHERE thread = ? AND role = 'operator' AND id > ? LIMIT 1").get(ask.thread, Number(asking["id"]));
19722
+ if (later !== undefined)
19723
+ return { state: "answered" };
19724
+ if (new Date(ask.createdAt).getTime() + MATE_ASK_TTL_MS <= now.getTime())
19725
+ return { state: "expired" };
19726
+ return { state: "open", ask };
19727
+ }
19728
+ /** The question while it is open (see mateAskState); null otherwise. */
19729
+ mateAskOpen(turn, now) {
19730
+ const found = this.mateAskState(turn, now);
19731
+ return found.state === "open" ? found.ask : null;
19732
+ }
19639
19733
  // ---- the screenshots a turn selected (v64) ------------------------------------
19640
19734
  /**
19641
19735
  * Record the screenshots one turn selected for an exact result, under the
@@ -21612,6 +21706,10 @@ export class Store {
21612
21706
  .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
21707
  return Number(inserted.lastInsertRowid);
21614
21708
  }
21709
+ /** Whether a message from this chat is already waiting on, or being answered from, this bot message (a tapped question). */
21710
+ telegramConversationWaitingOn(binding, messageId) {
21711
+ 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;
21712
+ }
21615
21713
  getTelegramConversation(id) {
21616
21714
  const row = this.db.prepare("SELECT * FROM telegram_conversation WHERE id = ?").get(id);
21617
21715
  return row === undefined ? null : readTelegramConversation(row);
@@ -21742,7 +21840,7 @@ export class Store {
21742
21840
  VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`);
21743
21841
  parts.forEach((part, ordinal) => {
21744
21842
  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);
21843
+ 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
21844
  });
21747
21845
  return true;
21748
21846
  });
@@ -21921,6 +22019,10 @@ export class Store {
21921
22019
  this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_ELSEWHERE}'
21922
22020
  WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
21923
22021
  AND notification IN (SELECT id FROM notification WHERE recipient IS NOT NULL AND recipient <> ?)`).run(destination, binding.approver);
22022
+ // A promise the lead made on another chat is reported there (lead-commitments.ts), not here.
22023
+ this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_OTHER_CHAT}'
22024
+ WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
22025
+ AND notification IN (SELECT id FROM notification WHERE dedupe_key LIKE 'lead-promise:%' AND dedupe_key NOT LIKE 'lead-promise:telegram:%')`).run(destination);
21924
22026
  // Pings follow responsibility (the lead's work, this person's own act, a muted project): settled here, unsent.
21925
22027
  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
22028
  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 +22245,16 @@ export class Store {
22143
22245
  .run(day, account, day).changes) === 1;
22144
22246
  }
22145
22247
  // ---- pings follow responsibility: the lead, who acted, replacements and muted projects ----
22248
+ /** What this person named their lead and its persona, or null when they never saved one. */
22249
+ leadConfig(account) {
22250
+ const row = this.db.prepare("SELECT name, persona FROM lead_config WHERE account = ?").get(account);
22251
+ return row === undefined ? null : { name: String(row["name"]), persona: String(row["persona"]) };
22252
+ }
22253
+ /** Save this person's lead name and persona (checked by the caller: lead-identity.ts). */
22254
+ setLeadConfig(account, name, persona, now) {
22255
+ this.db.prepare(`INSERT INTO lead_config (account, name, persona, updated_at) VALUES (?, ?, ?, ?)
22256
+ ON CONFLICT(account) DO UPDATE SET name = excluded.name, persona = excluded.persona, updated_at = excluded.updated_at`).run(account, name, persona, now.toISOString());
22257
+ }
22146
22258
  /** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
22147
22259
  mintLeadCredential(owner, by, now) {
22148
22260
  return this.transact(() => {
@@ -22879,6 +22991,8 @@ function readHold(row) {
22879
22991
  export const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
22880
22992
  /** v83: a notification for another person, settled for this destination without sending. */
22881
22993
  export const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
22994
+ /** A met promise the lead made on another chat: reported there, settled here without sending. */
22995
+ export const TELEGRAM_SKIPPED_OTHER_CHAT = "skipped:for-another-chat";
22882
22996
  /** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
22883
22997
  export const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
22884
22998
  /** 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,7 +148,8 @@ 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.'); },
@@ -1,6 +1,7 @@
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";
@@ -195,7 +196,9 @@ export async function deliverTeamsPart(options) {
195
196
  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
197
  // A teammate's question (v93): its options, then "Answer in words".
197
198
  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))];
199
+ // The lead's question to its owner: its options, then "Something else".
200
+ const owner = content.ask ? chatAskButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
201
+ actions = [...flow, ...asked, ...owner, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
199
202
  }
200
203
  const target = content.edit ?? row.message;
201
204
  const body = actions.length || content.proposal ? teamsCard(text, actions) : { type: "message", text, textFormat: "plain" };
@@ -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. */
@@ -59,6 +59,9 @@ export declare function telegramChannelProblem(store: Store, expected: {
59
59
  }), readProjects: () => Promise<readonly string[]>): Promise<string | null>;
60
60
  export declare function phoneLinkButton(origin: string | null, link: PhoneLink | null): InlineButton[] | null;
61
61
  /** One inline button: an opaque callback token (a real in-chat act) or a url (navigation, never authority). */
62
+ /** The buttons under the lead's question: options two to a row, then "Something else". Each names the turn and the
63
+ * option's place, nothing more; a tap re-proves that the question is still open and is the tapper's own. */
64
+ export declare function askKeyboard(ask: MateAsk): CallbackKeyboard;
62
65
  export type InlineButton = {
63
66
  text: string;
64
67
  callback_data: string;
@@ -26,6 +26,7 @@ import { createHash, randomBytes } from "node:crypto";
26
26
  import { MATE_MESSAGE_MAX_CHARS, runMateTurn } from "./mate.js";
27
27
  import { confirmMateProposal, dismissMateProposal } from "./mate-doors.js";
28
28
  import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
29
+ import { MATE_ASK_OTHER } from "./store.js";
29
30
  import { phoneText } from "./telegram-status.js";
30
31
  import { TeamLeads } from "./team-leads.js";
31
32
  import { chatResultHref } from "./chat-controls.js";
@@ -100,6 +101,17 @@ function keyboardWith(callbacks, link) {
100
101
  const rows = [...(callbacks ?? []), ...(link === null ? [] : [link])];
101
102
  return rows.length === 0 ? undefined : rows;
102
103
  }
104
+ // ---- proposal cards on the wire ------------------------------------------------------
105
+ /** One inline button: an opaque callback token (a real in-chat act) or a url (navigation, never authority). */
106
+ /** The buttons under the lead's question: options two to a row, then "Something else". Each names the turn and the
107
+ * option's place, nothing more; a tap re-proves that the question is still open and is the tapper's own. */
108
+ export function askKeyboard(ask) {
109
+ const rows = [];
110
+ for (let at = 0; at < ask.options.length; at += 2)
111
+ rows.push(ask.options.slice(at, at + 2).map((label, offset) => ({ text: label.slice(0, 60), callback_data: `ask:${ask.turn}:${at + offset}` })));
112
+ rows.push([{ text: MATE_ASK_OTHER, callback_data: `ask:${ask.turn}:x` }]);
113
+ return rows;
114
+ }
103
115
  /** Mint the card's tokens before the send, so a tap can never name a token that does not exist. */
104
116
  export function mintCardTokens(store, binding, proposal, now, messageId, chatId = binding.chatId) {
105
117
  const confirm = randomBytes(16).toString("hex");
@@ -579,6 +591,10 @@ async function runTelegramConversation(row, args) {
579
591
  return store.transact(() => {
580
592
  const now = clock();
581
593
  const parts = splitParts(reply).map((text, index) => ({ kind: "reply", text, replyTo: index === 0 ? row.messageId : null }));
594
+ // The lead's question to its owner: one tap per option, then "Something else".
595
+ const ask = store.mateAsk(turn);
596
+ if (ask !== null)
597
+ parts.push({ kind: "reply", text: phoneText(ask.question, 1_000), keyboard: askKeyboard(ask) });
582
598
  // Only a turn that answered may have its selection sent: a failed or revoked turn's rows were deleted with its drafts, and the state is read again here.
583
599
  const selected = store.getMateTurn(turn)?.state === "answered" ? store.listMateTurnEvidence(turn) : [];
584
600
  for (const image of selected)
@@ -694,7 +710,7 @@ async function runTelegramConversation(row, args) {
694
710
  store, who, session, thread, config, key: null, message: row.text, requestId: row.request,
695
711
  ...(row.context === null ? {} : { context: row.context }),
696
712
  ...(options.subscriptionRunner === undefined ? {} : { subscriptionRunner: options.subscriptionRunner }),
697
- clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents",
713
+ clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram",
698
714
  });
699
715
  }
700
716
  finally {
@@ -9,6 +9,7 @@ import { chatResultHref, chatControlHref } from "./chat-controls.js";
9
9
  import { phoneText, projectLabel } from "./telegram-status.js";
10
10
  import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
11
11
  import { leadClaimOf } from "./lead-voice.js";
12
+ import { leadNameOf } from "./lead-identity.js";
12
13
  import { chatTitle } from "./chat-voice.js";
13
14
  import { manualReviewOnly } from "./proof.js";
14
15
  import { failedCheckExit } from "./workspace-ui.js";
@@ -79,11 +80,11 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
79
80
  const completer = assignment?.completion?.actor.replace(/^(?:operator|coordinator|lead):/, "") ?? null;
80
81
  if (viewer !== undefined && facts.completedBy != null && (completer === viewer || facts.completedBy === viewer))
81
82
  facts = { ...facts, completedBy: null };
82
- // The person's lead took it on: "Your lead is on it" rather than waiting for them (lead-voice.ts).
83
+ // The person's lead took it on: "<name> is on it" rather than waiting for them (lead-voice.ts).
83
84
  if (facts.lead === undefined) {
84
85
  const lead = leadClaimOf(store, taskId, now, viewer);
85
86
  if (lead !== null)
86
- facts = { ...facts, lead: lead.state };
87
+ facts = { ...facts, lead: lead.state, leadName: leadNameOf(store, lead.owner) };
87
88
  }
88
89
  const status = taskStatusOf(facts);
89
90
  const checkProgress = store.checkProgress(run.id);
package/dist/telegram.js CHANGED
@@ -31,7 +31,7 @@ import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_
31
31
  import { telegramProgressCard } from "./telegram-progress.js";
32
32
  import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
33
33
  import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
34
- import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText } from "./lead-voice.js";
34
+ import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
35
35
  import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
36
36
  import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
37
37
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
@@ -920,7 +920,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
920
920
  };
921
921
  /** The lead's words (lead-voice.ts): one message; a later say within two minutes edits it in place. */
922
922
  const leadSayOutcome = async (fact) => {
923
- const words = leadSayText(fact);
923
+ const words = leadSayText(store, fact);
924
924
  const button = fact.link === null ? null : factButton(phoneOrigin, fact.link);
925
925
  const keyboard = button === null ? [] : [button];
926
926
  const earlier = leadSayEarlier(store, fact).map(id => store.telegramMessageOf(id, fact.destination)).find(one => one !== null) ?? null;
@@ -1064,7 +1064,7 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1064
1064
  // A short title, never the task's id or a "— revision" suffix (chat-voice.ts).
1065
1065
  const title = notification.taskId === null ? undefined : chatTitle(store, notification.taskId);
1066
1066
  const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
1067
- const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : notification.subject}${body === "" ? "" : `\n\n${body}`}`, task);
1067
+ const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : leadSubjectOf(store, notification, binding.approver)}${body === "" ? "" : `\n\n${body}`}`, task);
1068
1068
  // The task's title once: in front, unless the words already name it.
1069
1069
  const parts = split(`${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
1070
1070
  const button = notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
@@ -1970,6 +1970,46 @@ function applyCallback(context, update, effects) {
1970
1970
  }
1971
1971
  return;
1972
1972
  }
1973
+ // The lead's question to its owner: an option is sent as their next message; "Something else" asks them to type it.
1974
+ const askTap = /^ask:([1-9][0-9]{0,14}):([0-3]|x)$/.exec(token);
1975
+ if (askTap !== null) {
1976
+ if (tapChat !== binding.chatId || context.conversation === undefined) {
1977
+ report.ignored++;
1978
+ return;
1979
+ }
1980
+ const turn = Number(askTap[1]);
1981
+ const found = store.getMateTurn(turn)?.approver === binding.approver ? store.mateAskState(turn, clock()) : { state: "expired" };
1982
+ if (found.state !== "open") {
1983
+ const line = found.state === "answered" ? "That question was already answered." : "That question expired. Send your answer as a message.";
1984
+ ack(line);
1985
+ editText(`${message.text ?? ""}\n\n${line}`.trim().slice(0, 4000));
1986
+ return;
1987
+ }
1988
+ const ask = found.ask;
1989
+ if (store.telegramConversationWaitingOn(binding.id, String(message.message_id))) {
1990
+ ack("Your answer is on its way.");
1991
+ return;
1992
+ }
1993
+ if (askTap[2] === "x") {
1994
+ ack("Type your answer as a message.");
1995
+ return;
1996
+ }
1997
+ const option = ask.options[Number(askTap[2])];
1998
+ if (option === undefined) {
1999
+ ack("That option is no longer there.");
2000
+ return;
2001
+ }
2002
+ const focused = store.chatFocus("telegram", binding.id);
2003
+ store.enqueueTelegramConversation({
2004
+ binding, updateId: update.update_id, messageId: String(message.message_id), replyTo: null,
2005
+ request: telegramRequestId(botId, binding.id, update.update_id), text: option,
2006
+ context: focused === null ? null : focusContextFor(focused), taskId: focused, sourceRun: null,
2007
+ }, clock());
2008
+ report.chatQueued = (report.chatQueued ?? 0) + 1;
2009
+ ack(`Sent: ${option}`.slice(0, 190));
2010
+ editText(`${message.text ?? ask.question}\n\nYou chose: ${option}`.slice(0, 4000));
2011
+ return;
2012
+ }
1973
2013
  const action = store.getTelegramAction(token);
1974
2014
  if (action === null && context.conversation !== undefined && store.getTelegramProposalAction(token) !== null) {
1975
2015
  // 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-",
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-",
22
22
  // Playwright's browser profiles and artifacts.
23
23
  "playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
24
24
  ];
@@ -13,10 +13,15 @@ export declare function deadlineWords(deadline: {
13
13
  /** The exact minute in `zone` ("2026-09-30 16:39", with " UTC" when it is UTC): a time's title, one hover away. */
14
14
  export declare function fullWhen(iso: string, zone?: string): string;
15
15
  /** Every server-rendered stamp (`whenHtml`'s `<time data-when>`) inside `root`, reworded by the one formatter in the
16
- * viewer's zone: the same words on a desk and a phone, the exact minute in the title. The server's UTC words stay
17
- * only without script. Times React writes, relative ages ("3 min ago") and exact stamps (the ledger's seconds) carry
18
- * no `data-when` and are never touched. */
16
+ * viewer's zone: the same words on a desk and a phone, the exact minute in the title. An exact stamp
17
+ * (`exactWhenHtml`'s `data-when="exact"`, the ledger's seconds) keeps its seconds, in the viewer's zone, and its
18
+ * ISO title. The server's UTC words stay only without script. Times React writes and relative ages ("3 min ago")
19
+ * carry no `data-when` and are never touched. */
19
20
  export declare function localizeTimes(root: ParentNode, now?: Date, zone?: string): void;
21
+ /** The exact second in `zone` ("2026-09-30 16:39:12", with " UTC" when it is UTC): an audit record's stamp. */
22
+ export declare function exactWhen(iso: string, zone?: string): string;
23
+ /** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
24
+ export declare function exactWhenHtml(iso: string): string;
20
25
  /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
21
26
  export declare function whenHtml(iso: string | null, full: string, now?: Date): string;
22
27
  /** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
package/dist/when-html.js CHANGED
@@ -75,14 +75,21 @@ export function fullWhen(iso, zone = "UTC") {
75
75
  return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")} ${p.clock}${zone === "UTC" ? " UTC" : ""}`;
76
76
  }
77
77
  /** Every server-rendered stamp (`whenHtml`'s `<time data-when>`) inside `root`, reworded by the one formatter in the
78
- * viewer's zone: the same words on a desk and a phone, the exact minute in the title. The server's UTC words stay
79
- * only without script. Times React writes, relative ages ("3 min ago") and exact stamps (the ledger's seconds) carry
80
- * no `data-when` and are never touched. */
78
+ * viewer's zone: the same words on a desk and a phone, the exact minute in the title. An exact stamp
79
+ * (`exactWhenHtml`'s `data-when="exact"`, the ledger's seconds) keeps its seconds, in the viewer's zone, and its
80
+ * ISO title. The server's UTC words stay only without script. Times React writes and relative ages ("3 min ago")
81
+ * carry no `data-when` and are never touched. */
81
82
  export function localizeTimes(root, now = new Date(), zone = viewerZone()) {
82
83
  root.querySelectorAll("time[data-when][datetime]").forEach(node => {
83
84
  const iso = node.getAttribute("datetime") ?? "";
84
85
  if (iso === "" || Number.isNaN(new Date(iso).getTime()))
85
86
  return;
87
+ if (node.getAttribute("data-when") === "exact") {
88
+ const words = exactWhen(iso, zone);
89
+ if (node.textContent !== words)
90
+ node.textContent = words;
91
+ return;
92
+ }
86
93
  const words = shortWhen(iso, now, zone), full = fullWhen(iso, zone);
87
94
  if (node.title !== full)
88
95
  node.title = full;
@@ -90,6 +97,19 @@ export function localizeTimes(root, now = new Date(), zone = viewerZone()) {
90
97
  node.textContent = words;
91
98
  });
92
99
  }
100
+ /** The exact second in `zone` ("2026-09-30 16:39:12", with " UTC" when it is UTC): an audit record's stamp. */
101
+ export function exactWhen(iso, zone = "UTC") {
102
+ const at = new Date(iso);
103
+ if (Number.isNaN(at.getTime()))
104
+ return iso;
105
+ const seconds = String(at.getUTCSeconds()).padStart(2, "0");
106
+ const minute = fullWhen(iso, zone);
107
+ return zone === "UTC" ? minute.replace(/ UTC$/, `:${seconds} UTC`) : `${minute}:${seconds}`;
108
+ }
109
+ /** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
110
+ export function exactWhenHtml(iso) {
111
+ return `<time data-when="exact" datetime="${escape(iso)}" title="${escape(iso)}">${escape(exactWhen(iso))}</time>`;
112
+ }
93
113
  /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
94
114
  export function whenHtml(iso, full, now = new Date()) {
95
115
  if (iso === null || iso === "")
@@ -34,6 +34,8 @@ export type WorkIndexItem = {
34
34
  digest: string;
35
35
  } | null;
36
36
  evidence: 'recorded';
37
+ /** The viewer's own lead took it on (lead-voice.ts): "<name> is on it.", by the name they gave it, read now. */
38
+ lead?: string;
37
39
  };
38
40
  export type WorkIndexOptions = {
39
41
  view?: WorkView;