toolroll 0.9.53 → 0.9.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/chat-ask.js +8 -8
  3. package/dist/chat-core.d.ts +171 -0
  4. package/dist/chat-core.js +288 -0
  5. package/dist/chat-decide.d.ts +10 -8
  6. package/dist/chat-decide.js +26 -21
  7. package/dist/chat-delivery-state.d.ts +51 -75
  8. package/dist/chat-delivery-state.js +140 -170
  9. package/dist/chat-delivery.d.ts +21 -2
  10. package/dist/chat-delivery.js +313 -194
  11. package/dist/chat-flow.d.ts +29 -1
  12. package/dist/chat-flow.js +24 -50
  13. package/dist/chat-messages.d.ts +319 -0
  14. package/dist/chat-messages.js +841 -0
  15. package/dist/chat-migration.d.ts +30 -0
  16. package/dist/chat-migration.js +802 -0
  17. package/dist/chat-quiet.d.ts +3 -0
  18. package/dist/chat-quiet.js +5 -0
  19. package/dist/contracts/chat-content.d.ts +71 -2
  20. package/dist/contracts/chat-content.js +22 -3
  21. package/dist/contracts/chat-tables.d.ts +11 -4
  22. package/dist/contracts/chat-tables.js +165 -87
  23. package/dist/contracts/discord-callback.js +2 -1
  24. package/dist/contracts/slack-callback.js +3 -2
  25. package/dist/contracts/store-json.d.ts +1 -5
  26. package/dist/contracts/store-json.js +1 -4
  27. package/dist/contracts/teams-callback.js +2 -1
  28. package/dist/discord-chat.js +19 -38
  29. package/dist/discord-settings.js +4 -13
  30. package/dist/discord.js +4 -14
  31. package/dist/export.js +4 -4
  32. package/dist/mate-tools.js +3 -2
  33. package/dist/modes.d.ts +13 -1
  34. package/dist/modes.js +17 -1
  35. package/dist/notification-rows.d.ts +30 -0
  36. package/dist/notification-rows.js +102 -0
  37. package/dist/operate.js +5 -2
  38. package/dist/plane-review.js +5 -11
  39. package/dist/project-delete.js +21 -26
  40. package/dist/result-shots.js +2 -2
  41. package/dist/retention.js +1 -1
  42. package/dist/server/settings.js +5 -4
  43. package/dist/slack-api.d.ts +5 -1
  44. package/dist/slack-api.js +2 -2
  45. package/dist/slack-chat.d.ts +9 -4
  46. package/dist/slack-chat.js +29 -52
  47. package/dist/slack-settings.js +6 -15
  48. package/dist/slack.js +6 -16
  49. package/dist/store.d.ts +97 -364
  50. package/dist/store.js +207 -1531
  51. package/dist/teammate-question.js +12 -12
  52. package/dist/teams-chat.js +12 -9
  53. package/dist/teams-settings.js +2 -2
  54. package/dist/teams.js +4 -5
  55. package/dist/telegram-flow.js +5 -5
  56. package/dist/telegram.js +4 -4
  57. package/dist/toolroll-update.d.ts +10 -0
  58. package/dist/toolroll-update.js +50 -3
  59. package/dist/workspace-revision.js +3 -1
  60. package/package.json +1 -1
  61. package/dist/slack-state.d.ts +0 -13
  62. package/dist/slack-state.js +0 -10
@@ -5,8 +5,9 @@
5
5
  * is answered with why (`value.so: must be a Toolroll button token`), and nothing is done.
6
6
  */
7
7
  import { z } from "zod";
8
+ import { CHAT_BUTTON_TOKEN } from "./chat-content.js";
8
9
  import { parseContract } from "./contract.js";
9
- export const teamsSubmitSchema = z.object({ so: z.string().regex(/^[a-f0-9]{32}$/, { error: "must be a Toolroll button token" }) });
10
+ export const teamsSubmitSchema = z.object({ so: z.string().regex(CHAT_BUTTON_TOKEN, { error: "must be a Toolroll button token" }) });
10
11
  /** A submit's one-time token. */
11
12
  export function readTeamsSubmit(value) {
12
13
  const read = parseContract(z.object({ value: teamsSubmitSchema }), { value });
@@ -190,17 +190,13 @@ export async function deliverDiscordPart(options) {
190
190
  if (!options.current() ||
191
191
  !state.owns(identity.installation, options.owner, now))
192
192
  return false;
193
- const row = state
194
- .prepare("SELECT p.* FROM chat_part p JOIN chat_event e ON e.id=p.event WHERE e.installation=? AND p.state='pending' AND (e.kind!='notice' OR ?=1) AND (p.next_at IS NULL OR p.next_at<=?) ORDER BY p.id LIMIT 1")
195
- .get(identity.installation, options.canNotify?.() === false ? 0 : 1, now.toISOString());
193
+ const row = state.nextPart(identity.installation, options.canNotify?.() !== false, now);
196
194
  if (!row)
197
195
  return false;
198
196
  const event = state.event(row.event), binding = event.binding === null ? null : state.bindingById(event.binding);
199
197
  if (!binding ||
200
198
  new Date(row.created).getTime() + 86_400_000 < now.getTime()) {
201
- state
202
- .prepare("UPDATE chat_part SET state='dropped',problem='Delivery expired or access changed; open the saved chat' WHERE id=?")
203
- .run(row.id);
199
+ state.dropPart(row.id, "Delivery expired or access changed; open the saved chat");
204
200
  return true;
205
201
  }
206
202
  try {
@@ -214,16 +210,16 @@ export async function deliverDiscordPart(options) {
214
210
  throw new DiscordError("Connected projects changed");
215
211
  let text = content.text, buttons = [], file;
216
212
  if (content.image && content.shot && resultShotsPruned(options.evidenceRoot, content.image.run)) {
217
- state.prepare("UPDATE chat_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
213
+ state.dropPart(row.id, "Removed by retention");
218
214
  return true;
219
215
  }
220
216
  // A screenshot sent with a result replies to that result's message once it is placed, and waits until then.
221
- const result = content.shot?.follows == null ? undefined : state.prepare("SELECT message,state,next_at FROM chat_part WHERE id=?").get(content.shot.follows);
222
- if (result !== undefined && !discordId(result["message"]) && result["state"] === "pending") {
223
- state.prepare("UPDATE chat_part SET next_at=? WHERE id=?").run(shotWaitsUntil(result["next_at"], now), row.id);
217
+ const result = content.shot?.follows == null ? null : state.part(content.shot.follows);
218
+ if (result !== null && !discordId(result.message) && result.state === "pending") {
219
+ state.holdPart(row.id, shotWaitsUntil(result.next_at, now));
224
220
  return true;
225
221
  }
226
- const follows = result?.["message"];
222
+ const follows = result?.message;
227
223
  const thread = discordId(follows) ? String(follows) : event.thread;
228
224
  if (content.image) {
229
225
  const image = verifyResultImage(store, options.evidenceRoot, repos, content.image);
@@ -260,8 +256,7 @@ export async function deliverDiscordPart(options) {
260
256
  preview.text.length <= 3400 &&
261
257
  discordPlain(text).length <= 3900) {
262
258
  buttons = state
263
- .prepare("SELECT token,phase FROM chat_action WHERE part=? AND consumed IS NULL AND expires>? ORDER BY rowid")
264
- .all(row.id, now.toISOString())
259
+ .partTokens(row.id, now)
265
260
  .map((action) => ({
266
261
  type: 2,
267
262
  style: action.phase === "yes" ? 4 : action.phase === "confirm" ? 1 : 2,
@@ -317,6 +312,10 @@ export async function deliverDiscordPart(options) {
317
312
  custom_id: `so_${one.token}`,
318
313
  }))
319
314
  : []),
315
+ // A result's, plan's, failure's or pull request's own buttons (chat-decide.ts): its acts, then its links.
316
+ ...(content.decide?.rows.flat() ?? []).flatMap((one, index) => "token" in one
317
+ ? [{ type: 2, style: index === 0 ? 1 : 2, label: one.label.slice(0, 80), custom_id: `so_${one.token}` }]
318
+ : link(options.origin(), one.link)),
320
319
  ...link(options.origin(), content.link),
321
320
  ...(content.also ?? []).flatMap(one => link(options.origin(), one)),
322
321
  ];
@@ -355,13 +354,7 @@ export async function deliverDiscordPart(options) {
355
354
  // Preserve the original remote attachment; never upload it again on an edit.
356
355
  file = undefined;
357
356
  }
358
- state
359
- .prepare("UPDATE chat_part SET message=?,uploaded=? WHERE id=?")
360
- .run(target, content.image &&
361
- Array.isArray(found.attachments) &&
362
- found.attachments.length
363
- ? 1
364
- : 0, row.id);
357
+ state.setPartFile(row.id, null, Boolean(content.image && Array.isArray(found.attachments) && found.attachments.length), target);
365
358
  }
366
359
  }
367
360
  await channelAccess(shared, binding, session?.ceilingDigest);
@@ -396,17 +389,13 @@ export async function deliverDiscordPart(options) {
396
389
  !answer.attachments.some((v) => object(v).filename === file.name &&
397
390
  Number(object(v).size) === file.bytes.length))))
398
391
  throw new DiscordError("Discord did not confirm the message and file identity", 15_000, true);
399
- state
400
- .prepare("UPDATE chat_part SET state='sent',message=?,attempts=attempts+1,problem=NULL,next_at=NULL WHERE id=?")
401
- .run(answer.id, row.id);
402
- state
403
- .prepare("UPDATE chat_runtime SET problem=NULL WHERE installation=? AND owner=?")
404
- .run(identity.installation, options.owner);
392
+ state.partSent(row.id, String(answer.id), options.clock?.() ?? new Date());
393
+ state.setProblem(identity.installation, options.owner, null);
405
394
  return true;
406
395
  }
407
396
  catch (error) {
408
397
  if (error instanceof ChatDeliveryError && error.permanent) {
409
- state.prepare("UPDATE chat_part SET state='dropped',next_at=NULL,problem=? WHERE id=?").run(error.message, row.id);
398
+ state.dropPart(row.id, error.message);
410
399
  return true;
411
400
  }
412
401
  // No permission to attach files here: one plain line instead of the result's screenshots.
@@ -420,18 +409,10 @@ export async function deliverDiscordPart(options) {
420
409
  : new DiscordError("Discord delivery is waiting to retry", 15_000, true);
421
410
  const until = new Date((options.clock?.() ?? new Date()).getTime() +
422
411
  Math.max(problem.retryMs, [5000, 15000, 60000, 300000][Math.min(row.attempts, 3)])).toISOString();
423
- state
424
- .prepare("UPDATE chat_part SET attempts=attempts+1,uncertain=uncertain+?,next_at=?,problem=? WHERE id=?")
425
- .run(problem.uncertain ? 1 : 0, until, problem.message, row.id);
426
- state
427
- .prepare("UPDATE chat_runtime SET problem=? WHERE installation=? AND owner=?")
428
- .run(problem.code === "ratelimited"
429
- ? "Discord asked us to wait. Saved replies will retry."
430
- : problem.message, identity.installation, options.owner);
412
+ state.partFailed(row.id, { problem: problem.message, until, uncertain: problem.uncertain });
413
+ state.setProblem(identity.installation, options.owner, problem.code === "ratelimited" ? "Discord asked us to wait. Saved replies will retry." : problem.message);
431
414
  if (problem.code === "ratelimited")
432
- state
433
- .prepare("UPDATE chat_runtime SET retry_at=? WHERE installation=?")
434
- .run(until, identity.installation);
415
+ state.deferUntil(identity.installation, until);
435
416
  return true;
436
417
  }
437
418
  }
@@ -9,14 +9,10 @@ const escape = (text) => text
9
9
  .replaceAll("'", "&#39;");
10
10
  export function discordSettingsHtml(store, dir, csrf, options = {}) {
11
11
  const credentials = loadDiscordCredentials(dir), state = new ChatState(store, "discord"), bindings = credentials ? state.bindings(credentials.installation).filter(one => state.live(one)) : [], binding = options.who === undefined ? null : bindings.find(one => one.approver === options.who) ?? null, others = bindings.length - (binding === null ? 0 : 1);
12
- const runtime = credentials
13
- ? state.db
14
- .prepare("SELECT connected,problem,lease_until FROM discord_runtime WHERE installation=?")
15
- .get(credentials.installation)
16
- : null;
12
+ const runtime = credentials ? state.runtime(credentials.installation) : null;
17
13
  const now = options.now ?? new Date(), live = runtime &&
18
- typeof runtime.lease_until === "string" &&
19
- runtime.lease_until > now.toISOString() &&
14
+ runtime.leaseUntil !== null &&
15
+ runtime.leaseUntil > now.toISOString() &&
20
16
  runtime.connected;
21
17
  const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}">`;
22
18
  const password = '<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>';
@@ -44,12 +40,7 @@ export function discordSettingsHtml(store, dir, csrf, options = {}) {
44
40
  '<a class="button-link" style="min-height:44px;white-space:nowrap" href="/settings/discord">Check connection</a>';
45
41
  }
46
42
  else if (binding && state.live(binding)) {
47
- const pending = Number(state.db
48
- .prepare("SELECT count(*) n FROM discord_part p JOIN discord_event e ON e.id=p.event WHERE e.binding=? AND p.state='pending'")
49
- .get(binding.id)?.n ?? 0);
50
- const failed = Number(state.db
51
- .prepare("SELECT count(*) n FROM discord_part p JOIN discord_event e ON e.id=p.event WHERE e.binding=? AND p.state='dropped'")
52
- .get(binding.id)?.n ?? 0);
43
+ const { pending, dropped: failed } = state.partCounts(binding.id);
53
44
  content +=
54
45
  `<p>Your Discord account is paired.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} paired too.` : ""}${pending ? ` ${pending} replies waiting to send.` : ""}${failed ? ` ${failed} replies could not be delivered. Open the saved chat to recover them.` : ""}</p>` +
55
46
  "<p>Try “What needs my attention?” or “Show the evidence for the latest result.”</p>" +
package/dist/discord.js CHANGED
@@ -37,9 +37,7 @@ export async function followDiscord(options) {
37
37
  current.botToken === credentials.botToken &&
38
38
  state.owns(credentials.installation, owner));
39
39
  };
40
- const problem = (message) => state
41
- .prepare("UPDATE chat_runtime SET problem=? WHERE installation=? AND owner=?")
42
- .run(message, credentials.installation, owner);
40
+ const problem = (message) => state.setProblem(credentials.installation, owner, message);
43
41
  const api = discordApi(credentials.botToken);
44
42
  const client = new Client({
45
43
  // Guild messages and their content are needed for rooms; the Message
@@ -63,9 +61,7 @@ export async function followDiscord(options) {
63
61
  if (!same() || !validated)
64
62
  return;
65
63
  connected = true;
66
- state
67
- .prepare("UPDATE chat_runtime SET connected=?,problem=NULL WHERE installation=? AND owner=?")
68
- .run(new Date().toISOString(), credentials.installation, owner);
64
+ state.setConnected(credentials.installation, owner, new Date());
69
65
  };
70
66
  client.on("shardReady", online);
71
67
  client.on("shardResume", online);
@@ -133,11 +129,7 @@ export async function followDiscord(options) {
133
129
  try {
134
130
  await client.login(credentials.botToken);
135
131
  while (same()) {
136
- const retry = state
137
- .prepare("SELECT retry_at FROM chat_runtime WHERE installation=?")
138
- .get(credentials.installation)?.retry_at;
139
- if (!connected ||
140
- (typeof retry === "string" && retry > new Date().toISOString())) {
132
+ if (!connected || state.retryAt(credentials.installation) > new Date().toISOString()) {
141
133
  await pause(1000);
142
134
  continue;
143
135
  }
@@ -160,9 +152,7 @@ export async function followDiscord(options) {
160
152
  alive = false;
161
153
  options.signal.removeEventListener("abort", stop);
162
154
  await client.destroy().catch(() => { });
163
- state
164
- .prepare("UPDATE chat_runtime SET owner=NULL,lease_until=NULL,connected=NULL WHERE installation=? AND owner=?")
165
- .run(credentials.installation, owner);
155
+ state.stop(credentials.installation, owner);
166
156
  }
167
157
  await pause(5000);
168
158
  }
package/dist/export.js CHANGED
@@ -29,20 +29,20 @@ import { readEmailSettings } from "./email-settings.js";
29
29
  import { SUBSCRIPTION_CAPABLE, readAuthMode } from "./keys.js";
30
30
  export const EXPORT_FORMAT = "standing-orders/export/v1";
31
31
  /** Tables that hold nothing but secrets or short-lived sign-in material, and derived search indexes. */
32
- const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "telegram_pairing", "slack_pair", "discord_pair", "teams_pair", "oauth_code", "oauth_refresh"]);
32
+ const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "chat_pair", "oauth_code", "oauth_refresh"]);
33
33
  const DERIVED_TABLE = /^(sqlite_|memory_search)/;
34
34
  /** Columns that hold a secret (or what stands in for one): never exported. */
35
35
  const SECRET_COLUMNS = new Set([
36
36
  "api_token.secret_hash", "lead_credential.secret_hash", "approver.credential_hash", "coordinator_credential.credential_hash", "runner.credential_hash", "invite.token_hash",
37
37
  "flow_trigger.hook_hash", "push_subscription.endpoint", "push_subscription.p256dh", "push_subscription.auth",
38
- "chat_turn.credential_key", "mate_session.credential_key", "mate_turn.credential_key", "bridge_lease.push_url", "workflow_preview.token",
38
+ "chat_turn.credential_key", "mate_session.credential_key", "mate_turn.credential_key", "chat_runtime.push_url", "workflow_preview.token",
39
39
  "quota.credential_fp", "oauth_grant.renew_hash", "oauth_client.source_hash",
40
40
  ]);
41
41
  /** A column named like this holds a secret unless it is reviewed below; an unreviewed one is dropped. */
42
42
  const SECRET_NAME = /hash|secret|token|passw|cookie|csrf|credential|p256dh|^auth$|key|nonce|endpoint|url/i;
43
43
  /** Reviewed: named like a secret, but it isn't one (a seal, a digest, a lookup name, an address with no key). */
44
44
  const REVIEWED_COLUMNS = new Set([
45
- "artifact.key", "budget.scope_key", "mate_thread.scope_key", "diff_comment.source_key", "discord_meta.key", "slack_meta.key", "teams_meta.key",
45
+ "artifact.key", "budget.scope_key", "mate_thread.scope_key", "diff_comment.source_key", "chat_meta.key",
46
46
  "flow_trigger_event.key", "installation_fact.key", "mcp_idempotency.key", "memory_gap.key", "mutation.idempotency_key", "notification.dedupe_key",
47
47
  "service_cursor.key", "ledger_checkpoint.hash", "ledger_seal.hash", "merge_intent.grant_terms_hash", "plan_revision.parent_hash",
48
48
  "publication.body_hash", "publication.pr_url", "team_message.payload_hash", "team_request.payload_hash", "backend_grant.credential_scope",
@@ -55,7 +55,7 @@ export function categoryOf(table) {
55
55
  [/^(action_ledger|ledger_seal|ledger_checkpoint|sync_ledger)$/, "ledger"],
56
56
  [/^teammate/, "teammates"],
57
57
  [/^(flow|workflow_)/, "flows"],
58
- [/^(chat_|mate_|team_|telegram_|slack_|discord_|teams_|notification|push_|bridge_lease)/, "chats"],
58
+ [/^(chat_|mate_|team_|notification|push_)/, "chats"],
59
59
  [/^(approver|api_token|lead_credential|invite|sso_identity|coordinator_|oauth_)/, "people"],
60
60
  [/^(run|artifact|claim|worktree$|execution_slot|criterion_review|diff_comment|proof_|review_request|repair_chain|incident|publication$|merge_|side_spend|knowledge_snapshot|learning_snapshot|skill_snapshot)/, "runs"],
61
61
  [/^(task|hold$|plan_|scope_|tool_seal|decision|external_|skill_test)/, "tasks"],
@@ -494,7 +494,7 @@ const MATE_TOOL_HANDLERS = {
494
494
  description: "When the owner asks to approve one thing (a plan waiting on them, or a flow card waiting at a Person decides step), send that item's own Approve and Send back buttons to their paired chat right away. Their tap there is the approval (two taps, as on any decision card); you never approve. Give task for a plan, or card for a flow card (its number from get_flows). When it can't be approved in chat, the answer says why: say that in one line and open show_control approval (a plan) or the flow, never that you can't approve.",
495
495
  handle: (ctx, args) => {
496
496
  const pairedHere = ctx.channel === "telegram";
497
- const paired = ctx.store.handle.prepare("SELECT 1 FROM telegram_binding WHERE approver = ? AND approver_generation = ? AND revoked_at IS NULL LIMIT 1")
497
+ const paired = ctx.store.handle.prepare("SELECT 1 FROM chat_binding WHERE provider = 'telegram' AND approver = ? AND generation = ? AND revoked IS NULL LIMIT 1")
498
498
  .get(ctx.who.name, ctx.store.accountOf(ctx.who.name)?.generation ?? -1) !== undefined;
499
499
  const stamp = ctx.now.getTime();
500
500
  if (args["card"] !== undefined) {
@@ -520,7 +520,8 @@ const MATE_TOOL_HANDLERS = {
520
520
  if (task === null || admittedRef(ctx, task) === null)
521
521
  return notFound();
522
522
  const current = ctx.store.taskFamilyOf(task, ctx.who.repos, false)?.current.id ?? task;
523
- const plan = planInChat(ctx.store, current, ctx.who.name, ctx.now, ctx.evidenceRoot);
523
+ // Its buttons go to the paired Telegram chat (pairedHere), so it is that chat's authority that counts.
524
+ const plan = planInChat(ctx.store, current, ctx.who.name, ctx.now, "telegram", ctx.evidenceRoot);
524
525
  if (!plan.ok)
525
526
  return { ok: true, body: { offered: false, task: current, why: plan.why, control: "approval" } };
526
527
  if (!pairedHere || !paired)
package/dist/modes.d.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * owns the transactions; the tick owns the rails; serve owns the
14
14
  * ceremony screens.
15
15
  */
16
+ import { type ChatProvider } from "./contracts/chat-tables.js";
16
17
  export type ModeName = "standard" | "hands-off";
17
18
  export type ModeTerms = {
18
19
  name: ModeName;
@@ -64,12 +65,16 @@ export type ModeTerms = {
64
65
  * `repairAuto` is also true — a mode may sign a cap without signing the
65
66
  * authority, but never the reverse. */
66
67
  repairMaxAttempts: number;
67
- /** Explicit opt-in: the signer's paired Telegram chat may approve this
68
+ /** Explicit opt-in: the signer's paired chat may approve this
68
69
  * repository's plans and merge its ready pull requests with two taps,
69
70
  * for the mode's lifetime. A plan that widens permissions, exceeds the
70
71
  * per-attempt budget or touches protected paths still opens Toolroll.
71
72
  * Legacy modes read FALSE — never inherited, only freshly signed. */
72
73
  chatApprove: boolean;
74
+ /** The chat apps `chatApprove` names, signed with it. Absent on a mode signed before chat apps other than Telegram
75
+ * could approve: its words said "your paired Telegram chat", so it stays Telegram only (chatApproveChatsOf). A wider
76
+ * grant is only ever a fresh signature naming the apps. */
77
+ chatApproveChats?: readonly ChatProvider[];
73
78
  absoluteExpiry: string;
74
79
  };
75
80
  export declare const MODE_MAX_DAYS = 7;
@@ -82,6 +87,13 @@ export declare function modeTermsJson(terms: ModeTerms): string;
82
87
  export declare function modeDigestOf(terms: ModeTerms): string;
83
88
  /** Strict rehydration — anything unexpected is null, never a guess. */
84
89
  export declare function modeTermsFromJson(json: string | null): ModeTerms | null;
90
+ /** The chat apps a mode lets its signer approve from: none without chatApprove; Telegram alone for a grant signed
91
+ * before it named its apps (that is what its words said); otherwise exactly the apps it names. */
92
+ export declare function chatApproveChatsOf(terms: Pick<ModeTerms, "chatApprove" | "chatApproveChats">): readonly ChatProvider[];
93
+ /** What a new chatApprove signature names: every chat app, said in its words. */
94
+ export declare const CHAT_APPROVE_ALL: readonly ChatProvider[];
95
+ /** "Telegram", "Telegram or Slack", "Telegram, Slack, Discord or Teams". */
96
+ export declare const chatNames: (chats: readonly ChatProvider[]) => string;
85
97
  /** Every term in words — what the ceremony renders and the password
86
98
  * signs. The reversal sentence is verbatim from the chain (C1). */
87
99
  export declare function modeWords(terms: ModeTerms): string[];
package/dist/modes.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * ceremony screens.
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
+ import { CHAT_PROVIDERS, isChatProvider } from "./contracts/chat-tables.js";
17
18
  export const MODE_MAX_DAYS = 7;
18
19
  /** The presets are STARTING POINTS the ceremony renders in full — the
19
20
  * signature always covers the resolved terms, never a label. */
@@ -120,6 +121,10 @@ export function modeTermsFromJson(json) {
120
121
  (t["repairMaxAttempts"] === undefined ||
121
122
  (typeof t["repairMaxAttempts"] === "number" && Number.isInteger(t["repairMaxAttempts"]) && t["repairMaxAttempts"] >= 0 && t["repairMaxAttempts"] <= 3)) &&
122
123
  (t["chatApprove"] === undefined || typeof t["chatApprove"] === "boolean") &&
124
+ // chatApproveChats: absent (a Telegram-only grant, or none), or the apps a chatApprove term names, each once.
125
+ (t["chatApproveChats"] === undefined ||
126
+ (t["chatApprove"] === true && Array.isArray(t["chatApproveChats"]) && t["chatApproveChats"].length > 0 &&
127
+ t["chatApproveChats"].every(isChatProvider) && new Set(t["chatApproveChats"]).size === t["chatApproveChats"].length)) &&
123
128
  typeof t["absoluteExpiry"] === "string" &&
124
129
  !Number.isNaN(Date.parse(t["absoluteExpiry"]))) {
125
130
  return {
@@ -138,11 +143,22 @@ export function modeTermsFromJson(json) {
138
143
  repairAuto: t["repairAuto"] === true,
139
144
  repairMaxAttempts: typeof t["repairMaxAttempts"] === "number" ? t["repairMaxAttempts"] : 0,
140
145
  chatApprove: t["chatApprove"] === true,
146
+ ...(Array.isArray(t["chatApproveChats"]) ? { chatApproveChats: [...t["chatApproveChats"]] } : {}),
141
147
  absoluteExpiry: t["absoluteExpiry"],
142
148
  };
143
149
  }
144
150
  return null;
145
151
  }
152
+ /** The chat apps a mode lets its signer approve from: none without chatApprove; Telegram alone for a grant signed
153
+ * before it named its apps (that is what its words said); otherwise exactly the apps it names. */
154
+ export function chatApproveChatsOf(terms) {
155
+ return !terms.chatApprove ? [] : terms.chatApproveChats ?? ["telegram"];
156
+ }
157
+ /** What a new chatApprove signature names: every chat app, said in its words. */
158
+ export const CHAT_APPROVE_ALL = CHAT_PROVIDERS;
159
+ const CHAT_NAMES = { telegram: "Telegram", slack: "Slack", discord: "Discord", teams: "Teams" };
160
+ /** "Telegram", "Telegram or Slack", "Telegram, Slack, Discord or Teams". */
161
+ export const chatNames = (chats) => chats.length <= 1 ? chats.map(one => CHAT_NAMES[one]).join("") : `${chats.slice(0, -1).map(one => CHAT_NAMES[one]).join(", ")} or ${CHAT_NAMES[chats[chats.length - 1]]}`;
146
162
  /** Every term in words — what the ceremony renders and the password
147
163
  * signs. The reversal sentence is verbatim from the chain (C1). */
148
164
  export function modeWords(terms) {
@@ -178,7 +194,7 @@ export function modeWords(terms) {
178
194
  ...(terms.repairAuto
179
195
  ? ["historical automatic repair grants are retained on record but no longer schedule work"] : []),
180
196
  ...(terms.chatApprove
181
- ? ["your paired Telegram chat may approve this repository's plans and merge its ready pull requests, two taps each, without your password; a plan that widens permissions, exceeds the per-attempt cap above or touches protected paths still opens Toolroll"] : []),
197
+ ? [`your paired ${chatNames(chatApproveChatsOf(terms))} chat may approve this repository's plans and merge its ready pull requests, two taps each, without your password; a plan that widens permissions, exceeds the per-attempt cap above or touches protected paths still opens Toolroll`] : []),
182
198
  `everything above ends at ${terms.absoluteExpiry.slice(0, 16).replace("T", " ")} — revoking it earlier is one click, and every act it covered falls back to its own ceremony`,
183
199
  ];
184
200
  }
@@ -0,0 +1,30 @@
1
+ /** Saved notifications, read and told apart without a store: the row readers, the lifecycle key and the predicates
2
+ * the store and the chat core (chat-messages.ts) share. A leaf: it imports types only. */
3
+ import type { MateProposal, Notification, NotificationReceipt } from "./store.js";
4
+ /** The one task a card is about: a task its confirmation created (a new
5
+ * task, a revision), else the task it names (and, for feedback on a result,
6
+ * that result). Null for a card about no single task. A pointer for
7
+ * replies, never an authority. */
8
+ export declare function proposalTaskOf(proposal: MateProposal | null): {
9
+ task: string;
10
+ run: number | null;
11
+ } | null;
12
+ /** Every lifecycle dedupe key starts here: `life:<kind>:<identity>:<ordinal>`. */
13
+ export declare const LIFECYCLE_KEY_PREFIX = "life:";
14
+ /**
15
+ * Task lifecycle updates (Telegram task updates, 2026-09-16): the CLOSED
16
+ * vocabulary of routine progress facts the shared mutations record beside
17
+ * the change that made them true. None carries a push class — they are
18
+ * digest-eligible progress, never a page — and every one is project-bound
19
+ * through the task it names. A decision, incident, gap, stall, publication
20
+ * or merge page keeps its own producer; nothing here repeats one.
21
+ */
22
+ export declare const LIFECYCLE_KINDS: readonly ["task-filed", "scope-approved", "approval-withdrawn", "task-held", "task-released", "task-queued", "task-requeued", "task-cancelled", "run-started", "run-phase", "check-progress", "run-finished", "run-stopping", "run-stopped", "run-resumed", "review-requested", "review-finished", "acceptance-evidence", "acceptance-ready"];
23
+ export type LifecycleKind = (typeof LIFECYCLE_KINDS)[number];
24
+ /** A progress fact, told apart by its durable key — never by display text. */
25
+ export declare function isLifecycleNotification(row: Pick<Notification, "dedupeKey">): boolean;
26
+ /** Only known progress producers can repaint a card. Decisions and urgent
27
+ * incidents keep their own alerts; a routine retry updates its saved attempt. */
28
+ export declare function isTelegramProgressNotification(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "run">): boolean;
29
+ export declare function readNotification(row: Record<string, unknown>): Notification;
30
+ export declare function readNotificationReceipt(row: Record<string, unknown>): NotificationReceipt;
@@ -0,0 +1,102 @@
1
+ /** The one task a card is about: a task its confirmation created (a new
2
+ * task, a revision), else the task it names (and, for feedback on a result,
3
+ * that result). Null for a card about no single task. A pointer for
4
+ * replies, never an authority. */
5
+ export function proposalTaskOf(proposal) {
6
+ if (proposal === null)
7
+ return null;
8
+ const named = typeof proposal.payload["task"] === "string" && proposal.payload["task"] !== "" ? proposal.payload["task"] : null;
9
+ const creates = proposal.kind === "task" || (proposal.kind === "review" && proposal.payload["operation"] === "revise");
10
+ const made = creates && proposal.state === "confirmed" ? proposal.outcome?.["taskId"] : undefined;
11
+ if (typeof made === "string" && made !== "" && made !== named)
12
+ return { task: made, run: null };
13
+ if (named === null)
14
+ return null;
15
+ const run = proposal.kind === "review" ? proposal.payload["run"] : null;
16
+ return { task: named, run: typeof run === "number" && Number.isSafeInteger(run) ? run : null };
17
+ }
18
+ /** Every lifecycle dedupe key starts here: `life:<kind>:<identity>:<ordinal>`. */
19
+ export const LIFECYCLE_KEY_PREFIX = "life:";
20
+ /**
21
+ * Task lifecycle updates (Telegram task updates, 2026-09-16): the CLOSED
22
+ * vocabulary of routine progress facts the shared mutations record beside
23
+ * the change that made them true. None carries a push class — they are
24
+ * digest-eligible progress, never a page — and every one is project-bound
25
+ * through the task it names. A decision, incident, gap, stall, publication
26
+ * or merge page keeps its own producer; nothing here repeats one.
27
+ */
28
+ export const LIFECYCLE_KINDS = [
29
+ "task-filed",
30
+ "scope-approved",
31
+ "approval-withdrawn",
32
+ "task-held",
33
+ "task-released",
34
+ "task-queued",
35
+ "task-requeued",
36
+ "task-cancelled",
37
+ "run-started",
38
+ "run-phase",
39
+ "check-progress",
40
+ "run-finished",
41
+ "run-stopping",
42
+ "run-stopped",
43
+ "run-resumed",
44
+ "review-requested",
45
+ "review-finished",
46
+ "acceptance-evidence",
47
+ "acceptance-ready",
48
+ ];
49
+ /** A progress fact, told apart by its durable key — never by display text. */
50
+ export function isLifecycleNotification(row) {
51
+ return row.dedupeKey.startsWith(LIFECYCLE_KEY_PREFIX);
52
+ }
53
+ /** Only known progress producers can repaint a card. Decisions and urgent
54
+ * incidents keep their own alerts; a routine retry updates its saved attempt. */
55
+ export function isTelegramProgressNotification(row) {
56
+ if (row.kind === "check-progress" && row.pushClass === "progress" && row.run !== null)
57
+ return isLifecycleNotification(row);
58
+ if (row.pushClass !== null)
59
+ return false;
60
+ if (row.kind === "build-failed" && row.run !== null && row.dedupeKey === `run:${row.run}:failed`)
61
+ return true;
62
+ // The owner's lead took the attempt on, or let it lapse (lead-voice.ts): its card repaints in place.
63
+ if ((row.kind === "lead-on-it" || row.kind === "lead-lapsed") && row.run !== null)
64
+ return true;
65
+ return isLifecycleNotification(row) &&
66
+ ["run-started", "run-phase", "run-finished", "review-requested", "review-finished", "run-stopping", "run-stopped", "run-resumed", "task-held", "task-released"].includes(row.kind);
67
+ }
68
+ export function readNotification(row) {
69
+ return {
70
+ id: Number(row["id"]),
71
+ recipient: row["recipient"] == null ? null : String(row["recipient"]),
72
+ dedupeKey: String(row["dedupe_key"]),
73
+ scope: row["provenance_scope"],
74
+ project: row["project"] == null ? null : String(row["project"]),
75
+ taskRef: row["task_ref"] == null ? null : Number(row["task_ref"]),
76
+ taskId: row["task_id"] == null ? null : String(row["task_id"]),
77
+ run: row["source_run"] == null ? null : Number(row["source_run"]),
78
+ kind: String(row["kind"]),
79
+ subject: String(row["subject"]),
80
+ body: String(row["body"]),
81
+ createdAt: String(row["created_at"]),
82
+ resolvedAt: row["resolved_at"] === null || row["resolved_at"] === undefined
83
+ ? null
84
+ : String(row["resolved_at"]),
85
+ pushClass: (row["push_class"] === null || row["push_class"] === undefined) && String(row["kind"]) === "check-progress"
86
+ ? "progress"
87
+ : row["push_class"] === null || row["push_class"] === undefined
88
+ ? null
89
+ : String(row["push_class"]),
90
+ link: row["link"] === null || row["link"] === undefined ? null : String(row["link"]),
91
+ };
92
+ }
93
+ export function readNotificationReceipt(row) {
94
+ return {
95
+ ...readNotification(row),
96
+ attempts: Number(row["attempts"]),
97
+ lastAttemptAt: row["last_attempt_at"] == null ? null : String(row["last_attempt_at"]),
98
+ lastError: row["last_error"] == null ? null : String(row["last_error"]),
99
+ deliveredAt: row["delivered_at"] == null ? null : String(row["delivered_at"]),
100
+ receipt: row["receipt"] == null ? null : String(row["receipt"]),
101
+ };
102
+ }
package/dist/operate.js CHANGED
@@ -175,7 +175,7 @@ import { mintCoordinator, revokeCoordinator, listCoordinators } from "./coordina
175
175
  import { serveMcp } from "./mcp.js";
176
176
  import { createInterface } from "node:readline";
177
177
  import { propose, approve, addApprover, authenticateAccount, authenticateApprover as passwordApprover, describeScope, approvalOf, hashToken as hashApproverToken, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha } from "./scope.js";
178
- import { presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
178
+ import { CHAT_APPROVE_ALL, presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
179
179
  import { WorktreePool } from "./worktree.js";
180
180
  import { requestTaskStop, resumeTaskStop, taskControlOf } from "./task-control.js";
181
181
  import { worktreeAdoptionNotice } from "./worktree-notices.js";
@@ -4659,8 +4659,11 @@ async function modeCommand(positional, flags, context) {
4659
4659
  return fail(write, json, "mode set", "invalid", "--plan-auto requires automatic filing approval", EXIT.refused);
4660
4660
  }
4661
4661
  // Approving plans and merges from the signer's paired chat: never a preset default, only this flag.
4662
- if (flag(flags, "chat-approve"))
4662
+ // The signature names every chat app it covers: a grant signed before that named Telegram alone, and stays so.
4663
+ if (flag(flags, "chat-approve")) {
4663
4664
  terms.chatApprove = true;
4665
+ terms.chatApproveChats = CHAT_APPROVE_ALL;
4666
+ }
4664
4667
  // Automerge requires a live merge-capable grant on the repo (D1).
4665
4668
  if (terms.publication === "automerge" && !store.hasMergeCapableGrant(repo, now)) {
4666
4669
  return fail(write, json, "mode set", "no-grant", "automerge needs a merge-capable publication grant on this repo — file one first", EXIT.refused);
@@ -175,17 +175,11 @@ export function reviewPlane(store, now, canSee = () => true) {
175
175
  }
176
176
  }
177
177
  // Chat replies that couldn't be delivered.
178
- for (const app of ["slack", "discord", "teams"]) {
179
- if (!tableExists(store, `${app}_part`))
180
- continue;
181
- const name = { slack: "Slack", discord: "Discord", teams: "Teams" }[app];
182
- for (const row of store.handle.prepare(`SELECT id, problem, attempts FROM ${app}_part WHERE state = 'dropped' AND created >= ? ORDER BY id`).all(since)) {
183
- add(`chat/${app}`, `${name} replies weren't delivered`, ["reply", "replies"], { evidence: `After ${plural(Number(row["attempts"] ?? 0), "try", "tries")}: ${excerpt(str(row["problem"])) || "dropped"}` });
184
- }
185
- }
186
- if (tableExists(store, "telegram_conversation_part")) {
187
- for (const row of store.handle.prepare("SELECT last_error, attempts FROM telegram_conversation_part WHERE state = 'dropped' AND created_at >= ?").all(since)) {
188
- add("chat/telegram", "Telegram replies weren't delivered", ["reply", "replies"], { evidence: `After ${plural(Number(row["attempts"] ?? 0), "try", "tries")}: ${excerpt(str(row["last_error"])) || "dropped"}` });
178
+ if (tableExists(store, "chat_part")) {
179
+ const names = { telegram: "Telegram", slack: "Slack", discord: "Discord", teams: "Teams" };
180
+ for (const row of store.handle.prepare("SELECT provider, problem, attempts FROM chat_part WHERE state = 'dropped' AND created >= ? ORDER BY provider = 'telegram' DESC, provider, id").all(since)) {
181
+ const app = String(row["provider"]);
182
+ add(`chat/${app}`, `${names[app] ?? app} replies weren't delivered`, ["reply", "replies"], { evidence: `After ${plural(Number(row["attempts"] ?? 0), "try", "tries")}: ${excerpt(str(row["problem"])) || "dropped"}` });
189
183
  }
190
184
  }
191
185
  if (tableExists(store, "notification_delivery")) {
@@ -207,35 +207,30 @@ function deleteRows(store, repo, d, now) {
207
207
  const notifications = json(ids(db, `SELECT id FROM notification WHERE project = ? OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, T, R));
208
208
  const questions = json(ids(db, `SELECT id FROM teammate_question WHERE ${IN("teammate")} OR ${IN("card")}`, M, C));
209
209
  const proposals = json(ids(db, `SELECT id FROM mate_proposal WHERE ${IN("thread")}`, H));
210
- const conversations = json(ids(db, `SELECT id FROM telegram_conversation WHERE ${IN("task_id")}`, K));
211
- // Chats about the project's work, on every surface.
212
- for (const surface of ["discord", "slack", "teams"]) {
213
- del(`${surface}_progress`, IN("run"), R);
214
- del(`${surface}_action`, IN("proposal"), proposals);
215
- del(`${surface}_flow_action`, IN("card"), C);
216
- del(`${surface}_flow_prompt`, IN("card"), C);
217
- del(`${surface}_flow_choice`, IN("card"), C);
218
- del(`${surface}_flow_note`, IN("card"), C);
219
- del(`${surface}_question_action`, IN("question"), questions);
220
- del(`${surface}_question_prompt`, IN("question"), questions);
221
- del(`${surface}_ask_action`, IN("turn"), U);
222
- }
223
- del("telegram_action", IN("decision"), decisions);
224
- del("telegram_decision_message", IN("decision"), decisions);
225
- del("telegram_note_draft", IN("decision"), decisions);
226
- del("telegram_proposal_action", IN("proposal"), proposals);
227
- del("telegram_flow_action", IN("card"), C);
228
- del("telegram_flow_prompt", IN("card"), C);
229
- del("telegram_flow_choice", IN("card"), C);
210
+ // A chat message about one of the project's tasks (its turn named it), with its reply. Event ids are text ('m123'),
211
+ // and the same id can be another app's: each is kept whole, as provider:id.
212
+ const conversations = json(list(db, `SELECT provider || ':' || id FROM chat_event WHERE kind = 'message' AND json_extract(payload, '$.about.task') IN (SELECT value FROM json_each(?))`, K));
213
+ // Every app's planned parts about the project's work: a result screenshot, or a message naming one of its tasks or runs.
214
+ const parts = `SELECT provider || ':' || id FROM chat_part WHERE provider || ':' || event IN (SELECT value FROM json_each(?))
215
+ OR json_extract(payload, '$.image.taskId') IN (SELECT value FROM json_each(?)) OR json_extract(payload, '$.image.run') IN (SELECT value FROM json_each(?))`;
216
+ const doomedParts = `provider || ':' || part IN (${parts})`;
217
+ // Chats about the project's work, on every app.
218
+ del("chat_progress", IN("run"), R);
219
+ del("chat_action", `${IN("proposal")} OR ${IN("decision")} OR ${doomedParts}`, proposals, decisions, conversations, K, R);
220
+ del("chat_flow_action", `${IN("card")} OR ${doomedParts}`, C, conversations, K, R);
221
+ del("chat_flow_prompt", IN("card"), C);
222
+ del("chat_flow_choice", `${IN("card")} OR ${doomedParts}`, C, conversations, K, R);
223
+ del("chat_flow_note", IN("card"), C);
224
+ del("chat_question_action", `${IN("question")} OR ${doomedParts}`, questions, conversations, K, R);
225
+ del("chat_question_prompt", IN("question"), questions);
226
+ del("chat_ask_action", `${IN("turn")} OR ${doomedParts}`, U, conversations, K, R);
227
+ del("chat_note_draft", IN("decision"), decisions);
230
228
  del("flow_send", IN("card"), C);
231
- del("telegram_question_action", IN("question"), questions);
232
- del("telegram_question_prompt", IN("question"), questions);
233
229
  del("chat_decide_action", IN("task_id"), K);
234
230
  del("chat_decide_prompt", IN("task_id"), K);
235
- del("telegram_task_message", `${IN("task_id")} OR ${IN("source_run")}`, K, R);
236
- del("telegram_conversation_part", `${IN("conversation")} OR ${IN("source_run")} OR ${IN("task_id")}`, conversations, R, K);
237
- del("telegram_conversation", IN("id"), conversations);
238
- del("telegram_outbound_message", `project = ? OR ${IN("notification")} OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, notifications, T, R);
231
+ del("chat_message_ref", `${IN("decision")} OR ${IN("task_id")} OR ${IN("run")} OR project = ? OR ${IN("notification")} OR ${IN("task_ref")}`, decisions, K, R, repo, notifications, T);
232
+ del("chat_part", `provider || ':' || id IN (${parts})`, conversations, K, R);
233
+ del("chat_event", `provider || ':' || id IN (SELECT value FROM json_each(?))`, conversations);
239
234
  del("chat_card_task", IN("task_ref"), T);
240
235
  del("chat_batch_item", IN("task_ref"), T);
241
236
  del("result_shot_sent", IN("run"), R);
@@ -169,6 +169,6 @@ export function refuseResultShots(state, store, part, content, app, now) {
169
169
  const title = chatTitle(store, content.image?.taskId ?? content.task ?? "");
170
170
  const line = { text: `Screenshots for ${title} weren't sent: the ${app} app isn't allowed to upload files here.`,
171
171
  ...(content.task === undefined ? {} : { task: content.task }), ...(content.run === undefined ? {} : { run: content.run }) };
172
- state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL,problem=NULL,file=NULL,uploaded=0,uncertain=0,created=? WHERE id=?").run(savedChatPart(line), now.toISOString(), part.id);
173
- state.prepare("UPDATE chat_part SET state='dropped',problem='Uploads are not allowed here' WHERE event=? AND id<>? AND state='pending'").run(part.event, part.id);
172
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL,problem=NULL,file=NULL,uploaded=0,uncertain=0,created=? WHERE provider=:provider AND id=?").run(savedChatPart(line), now.toISOString(), part.id);
173
+ state.prepare("UPDATE chat_part SET state='dropped',problem='Uploads are not allowed here' WHERE provider=:provider AND event=? AND id<>? AND state='pending'").run(part.event, part.id);
174
174
  }