toolroll 0.8.2 → 0.9.1

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 (68) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-onboard.d.ts +24 -1
  3. package/dist/agent-onboard.js +53 -1
  4. package/dist/backup-ui.js +4 -2
  5. package/dist/browser/workspace.css +2 -2
  6. package/dist/browser/workspace.js +33 -32
  7. package/dist/browser-workspace.d.ts +22 -2
  8. package/dist/chat-actions.d.ts +5 -0
  9. package/dist/chat-actions.js +43 -3
  10. package/dist/chat-delivery.js +51 -3
  11. package/dist/chat-quiet.d.ts +28 -0
  12. package/dist/chat-quiet.js +161 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +4 -1
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/digest-times.d.ts +3 -0
  17. package/dist/digest-times.js +7 -0
  18. package/dist/dispose.js +2 -0
  19. package/dist/export.js +1 -1
  20. package/dist/flow-engine.js +13 -1
  21. package/dist/flow-pull-request.d.ts +11 -0
  22. package/dist/flow-pull-request.js +123 -0
  23. package/dist/flow-starters-ui.d.ts +20 -0
  24. package/dist/flow-starters-ui.js +35 -0
  25. package/dist/flow-starters.d.ts +55 -0
  26. package/dist/flow-starters.js +104 -0
  27. package/dist/flow-steps.js +14 -0
  28. package/dist/flows-ui.d.ts +1 -1
  29. package/dist/flows-ui.js +4 -3
  30. package/dist/flows.d.ts +30 -6
  31. package/dist/flows.js +110 -6
  32. package/dist/integrations-ui.d.ts +13 -0
  33. package/dist/integrations-ui.js +70 -0
  34. package/dist/integrations.d.ts +113 -0
  35. package/dist/integrations.js +526 -0
  36. package/dist/lead-context.d.ts +1 -1
  37. package/dist/lead-context.js +1 -1
  38. package/dist/mate-contract.js +1 -1
  39. package/dist/mate-tools.js +18 -5
  40. package/dist/models-ui.js +3 -2
  41. package/dist/monitoring-ui.js +2 -1
  42. package/dist/operate.d.ts +5 -2
  43. package/dist/operate.js +169 -4
  44. package/dist/policy-ui.js +2 -1
  45. package/dist/project-delete.js +1 -0
  46. package/dist/publish.d.ts +11 -0
  47. package/dist/publish.js +9 -2
  48. package/dist/pull-request-flow.d.ts +172 -0
  49. package/dist/pull-request-flow.js +475 -0
  50. package/dist/pull-request-schema.d.ts +5 -0
  51. package/dist/pull-request-schema.js +21 -0
  52. package/dist/pull-request-ui.d.ts +23 -0
  53. package/dist/pull-request-ui.js +55 -0
  54. package/dist/pulls.d.ts +5 -0
  55. package/dist/pulls.js +11 -0
  56. package/dist/serve.d.ts +6 -0
  57. package/dist/serve.js +371 -58
  58. package/dist/setup-guide.d.ts +2 -2
  59. package/dist/skills-ui.js +2 -1
  60. package/dist/store.d.ts +61 -0
  61. package/dist/store.js +144 -3
  62. package/dist/surface.js +3 -1
  63. package/dist/telegram-progress.d.ts +1 -0
  64. package/dist/telegram-progress.js +1 -1
  65. package/dist/telegram.js +95 -15
  66. package/dist/when-html.d.ts +8 -0
  67. package/dist/when-html.js +36 -0
  68. package/package.json +1 -1
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
- content?: never;
19
18
  ok: false;
20
19
  message: string;
21
20
  plan?: never;
21
+ content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
- content?: never;
34
33
  ok: false;
35
34
  message: string;
36
35
  plan?: never;
36
+ content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
package/dist/skills-ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { whenUtc } from "./when-html.js";
2
3
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3
4
  const hidden = (data) => Object.entries(data)
4
5
  .map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`)
@@ -41,7 +42,7 @@ export function skillsHtml(view, csrf, canManage, options = {}) {
41
42
  ? `<details class="card"${options.error && options.draft?.["method"] ? " open" : ""}><summary>Add skill</summary><form method="post" action="/settings/skills/import" data-skill-import>${hidden(base)}<label>Add from<select name="method" data-skill-method><option value="paste"${d["method"] === "paste" ? " selected" : ""}>Paste SKILL.md</option><option value="github"${d["method"] === "github" ? " selected" : ""}>GitHub folder</option><option value="folder"${d["method"] === "folder" ? " selected" : ""}>Local folder</option></select></label><div data-skill-input="paste"><label>SKILL.md<textarea name="content" rows="8" maxlength="24576" placeholder="---&#10;name: design-review&#10;description: Review interface copy and layout.&#10;---&#10;Write your instructions here.">${e(d["content"])}</textarea></label></div><div data-skill-input="github"><label>Public GitHub folder<input name="url" type="url" value="${e(d["url"])}" placeholder="https://github.com/owner/repo/tree/main/skills/review"></label></div><div data-skill-input="folder"><label>Skill folder<input type="file" webkitdirectory multiple data-skill-folder></label><input type="hidden" name="files" value=""><p class="meta">Choose the folder containing SKILL.md. Up to 64 files, 1 MB total.</p><noscript>Folder uploads require JavaScript. You can paste SKILL.md or use a GitHub folder.</noscript></div><p class="meta">Added to your library first. Review its source and requirements before enabling it.</p><p data-skill-error role="alert" hidden></p><button>Add to library</button></form></details>`
42
43
  : "";
43
44
  const history = view.history.length
44
- ? `<details><summary>Change history</summary>${view.history.map((h) => `<div class="card"><p>Selection ${h.revision} · ${e(h.actor)} · ${e(h.at.slice(0, 16).replace("T", " "))} UTC</p><ul>${h.enabled.length ? h.enabled.map((s) => `<li>${e(s.name)} · ${e(s.version)}</li>`).join("") : "<li>No skills enabled</li>"}</ul>${manage && h.revision !== view.revision ? form("restore", "<button>Restore selection</button>", { restore: h.revision }) : '<span class="meta">Current selection</span>'}</div>`).join("")}</details>`
45
+ ? `<details><summary>Change history</summary>${view.history.map((h) => `<div class="card"><p>Selection ${h.revision} · ${e(h.actor)} · ${whenUtc(h.at)}</p><ul>${h.enabled.length ? h.enabled.map((s) => `<li>${e(s.name)} · ${e(s.version)}</li>`).join("") : "<li>No skills enabled</li>"}</ul>${manage && h.revision !== view.revision ? form("restore", "<button>Restore selection</button>", { restore: h.revision }) : '<span class="meta">Current selection</span>'}</div>`).join("")}</details>`
45
46
  : "";
46
47
  return `<section class="skills">${options.error ? `<p class="problem" role="alert">${e(options.error)}</p>` : ""}<p>${active ? `${active} skill${active === 1 ? "" : "s"} enabled for new tasks.` : "No skills enabled for this project."} Active tasks keep their saved versions.</p>${add}${search}${cards || "<p>Your library is empty. Add a skill to review and enable it here.</p>"}${history}</section>`;
47
48
  }
package/dist/store.d.ts CHANGED
@@ -19,6 +19,10 @@ import { type RetentionKind, type RetentionPeriods } from "./retention.js";
19
19
  import { type CheckoutCleanup } from "./storage.js";
20
20
  import { type LedgerChainReport } from "./ledger-chain.js";
21
21
  import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer } from "./approval-policy.js";
22
+ /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
23
+ * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
24
+ * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
25
+ export declare const INTEGRATIONS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS integration_check (\n key TEXT PRIMARY KEY,\n outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'failed', 'absent')),\n account TEXT,\n detail TEXT,\n problem TEXT,\n checked_at TEXT NOT NULL,\n ok_at TEXT,\n error TEXT,\n error_at TEXT\n);\n";
22
26
  export type BackupSettings = {
23
27
  enabled: boolean;
24
28
  everyHours: number;
@@ -944,6 +948,26 @@ export type TelegramDelivery = Notification & {
944
948
  destination: string;
945
949
  claimGeneration: number;
946
950
  };
951
+ /** How chats reach one person (quiet chat): only when they are needed, or every step. */
952
+ export type NotificationMode = "quiet" | "all";
953
+ export type NotificationPreference = {
954
+ mode: NotificationMode;
955
+ digestAt: string | null;
956
+ digestOn: string | null;
957
+ updatedAt: string | null;
958
+ };
959
+ /** Tasks filed this close together share one chat message. */
960
+ export declare const QUIET_GROUP_MS = 60000;
961
+ export declare function isDigestTime(value: string): boolean;
962
+ /** The one chat message a destination keeps for a task, or for several tasks filed together. */
963
+ export type ChatCard = {
964
+ id: number;
965
+ destination: string;
966
+ anchorAt: string;
967
+ message: string | null;
968
+ digest: string | null;
969
+ tasks: number[];
970
+ };
947
971
  /**
948
972
  * What one recovery walk over a gone runner's work settled (P0.1a): the run
949
973
  * ids it finished as failed/interrupted and the built-in task ids it returned
@@ -1544,6 +1568,11 @@ export type PublicationGrant = {
1544
1568
  merge?: boolean;
1545
1569
  mergeMethod?: "squash" | "merge" | "rebase" | null;
1546
1570
  mergeDeleteBranch?: boolean;
1571
+ /** When a pull request is owed: every finished build ("build", the original grant), or only when a person
1572
+ * chooses "Complete and open a pull request" ("complete", the one-step project setup). */
1573
+ publishOn?: "build" | "complete";
1574
+ /** The project's optional "Merge when checks pass", off by default (complete-mode grants only). */
1575
+ mergeWhenGreen?: boolean;
1547
1576
  };
1548
1577
  /** One run's road to a PR, durable at every phase. */
1549
1578
  export type Publication = {
@@ -5876,6 +5905,8 @@ export declare class Store {
5876
5905
  merge?: boolean;
5877
5906
  mergeMethod?: "squash" | "merge" | "rebase" | null;
5878
5907
  mergeDeleteBranch?: boolean;
5908
+ publishOn?: "build" | "complete";
5909
+ mergeWhenGreen?: boolean;
5879
5910
  }, now: Date): void;
5880
5911
  /**
5881
5912
  * Reserve the next observation generation for a PR BEFORE the network
@@ -7256,6 +7287,8 @@ export declare class Store {
7256
7287
  /** A confirmed network message remains history even if authority changed
7257
7288
  * while it was in flight. It only binds the OLD destination, never new work. */
7258
7289
  recordTelegramMessage(row: TelegramDelivery, binding: TelegramBinding, messageId: string, now: Date): void;
7290
+ /** The newest confirmed message this notification became at one destination, if any. */
7291
+ telegramMessageOf(notification: number, destination: string): string | null;
7259
7292
  finalizeTelegramDelivery(row: TelegramDelivery, binding: TelegramBinding, owner: string, outcome: {
7260
7293
  ok: true;
7261
7294
  receipt: string | null;
@@ -7287,6 +7320,34 @@ export declare class Store {
7287
7320
  /** Set (or clear, with null) the cadence. Turning it on starts the
7288
7321
  * window NOW — nothing held before the choice is dumped at once. */
7289
7322
  setTelegramDigest(everyMs: number | null, by: string, now: Date): void;
7323
+ /** How chats reach this person: only when they are needed unless they chose every step. */
7324
+ notificationPreference(account: string): NotificationPreference;
7325
+ /** Change one person's choice; a digest time is a local HH:MM, or null for no evening digest. */
7326
+ setNotificationPreference(account: string, change: {
7327
+ mode?: NotificationMode;
7328
+ digestAt?: string | null;
7329
+ }, by: string, now: Date): NotificationPreference;
7330
+ /** Everyone who asked for an evening digest, with the local day theirs was last considered. */
7331
+ eveningDigestPeople(): {
7332
+ account: string;
7333
+ digestAt: string;
7334
+ digestOn: string | null;
7335
+ }[];
7336
+ /** Claim one local day's digest for one person: true once, false if it was already considered. */
7337
+ claimEveningDigestDay(account: string, day: string): boolean;
7338
+ /** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
7339
+ * a minute of it (up to eight tasks), or starts its own. */
7340
+ chatCardFor(destination: string, taskRef: number, filedAt: string, now: Date): ChatCard;
7341
+ chatCard(id: number): ChatCard | null;
7342
+ /** The newest lifecycle fact a task has recorded — what its card says before any attempt starts. */
7343
+ latestTaskFact(taskRef: number): {
7344
+ kind: string;
7345
+ createdAt: string;
7346
+ } | null;
7347
+ /** Task-bound facts recorded since an instant, newest last (bounded): what an evening digest reads. */
7348
+ taskFactsSince(since: string, limit?: number): Notification[];
7349
+ /** The message now showing this card, and what it shows. */
7350
+ setChatCardMessage(id: number, message: string, digest: string): void;
7290
7351
  markTelegramDigestSent(now: Date): void;
7291
7352
  /** Every batched row finalized delivered AND the anchor moved, in ONE
7292
7353
  * transaction (v4 review, finding 5): a digest is either recorded whole
package/dist/store.js CHANGED
@@ -71,6 +71,7 @@ import { CHECKOUT_CLEANUP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storag
71
71
  import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
72
72
  import { NO_RULES, filerFor, isProtectedWork, protectedChanges, rulesWords } from "./approval-policy.js";
73
73
  import { configBase, envValue, namedPath } from "./names.js";
74
+ import { PULL_REQUEST_SCHEMA } from "./pull-request-schema.js";
74
75
  // v60 fenced older readers before the chat action cards; v61 adds notification
75
76
  // provenance and Telegram destination receipts, and readers below v61 refuse it.
76
77
  // v62 adds the durable Telegram conversation queue and proposal-card tokens,
@@ -131,6 +132,54 @@ CREATE TABLE IF NOT EXISTS monitoring_status (
131
132
  held_until TEXT
132
133
  );
133
134
  `;
135
+ /** Quiet chat: each person's choice of how chats reach them — only when they are needed (the default, no row),
136
+ * every step, and an optional evening digest at a local HH:MM. `digest_on` is the local day the last evening
137
+ * digest was considered, so each day is judged once. A task's card is the one chat message a destination keeps
138
+ * for it (or for several tasks filed within a minute): `message` is the Telegram message id or the chat app's
139
+ * part id, `digest` the text last shown, so an unchanged card is never re-sent. */
140
+ const QUIET_CHAT_SCHEMA = `
141
+ CREATE TABLE IF NOT EXISTS notification_preference (
142
+ account TEXT PRIMARY KEY,
143
+ mode TEXT NOT NULL DEFAULT 'quiet' CHECK (mode IN ('quiet', 'all')),
144
+ digest_at TEXT CHECK (digest_at IS NULL OR (length(digest_at) = 5 AND digest_at GLOB '[0-2][0-9]:[0-5][0-9]')),
145
+ digest_on TEXT,
146
+ updated_at TEXT NOT NULL,
147
+ updated_by TEXT NOT NULL
148
+ );
149
+ CREATE TABLE IF NOT EXISTS chat_card (
150
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
151
+ destination TEXT NOT NULL,
152
+ anchor_at TEXT NOT NULL,
153
+ message TEXT,
154
+ digest TEXT,
155
+ created_at TEXT NOT NULL
156
+ );
157
+ CREATE INDEX IF NOT EXISTS chat_card_destination ON chat_card(destination, anchor_at);
158
+ CREATE TABLE IF NOT EXISTS chat_card_task (
159
+ destination TEXT NOT NULL,
160
+ task_ref INTEGER NOT NULL,
161
+ card INTEGER NOT NULL REFERENCES chat_card(id),
162
+ joined_at TEXT NOT NULL,
163
+ PRIMARY KEY (destination, task_ref)
164
+ );
165
+ CREATE INDEX IF NOT EXISTS chat_card_task_card ON chat_card_task(card);
166
+ `;
167
+ /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
168
+ * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
169
+ * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
170
+ export const INTEGRATIONS_SCHEMA = `
171
+ CREATE TABLE IF NOT EXISTS integration_check (
172
+ key TEXT PRIMARY KEY,
173
+ outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'failed', 'absent')),
174
+ account TEXT,
175
+ detail TEXT,
176
+ problem TEXT,
177
+ checked_at TEXT NOT NULL,
178
+ ok_at TEXT,
179
+ error TEXT,
180
+ error_at TEXT
181
+ );
182
+ `;
134
183
  /** Sprint 8: database backups. One settings row (off, or every so many hours, keeping the newest N in a folder; no
135
184
  * folder means `backups` beside the database), every backup's outcome, and a lease the console's backup loop holds
136
185
  * and renews while it runs: one process backs up at a time, and a restore sees that the console is running. */
@@ -582,6 +631,11 @@ const LIFECYCLE_HOLD_WORDS = {
582
631
  revision: "a plan revision awaiting approval",
583
632
  stop: "a stopped attempt that has not been resumed",
584
633
  };
634
+ /** Tasks filed this close together share one chat message. */
635
+ export const QUIET_GROUP_MS = 60_000;
636
+ export function isDigestTime(value) {
637
+ return /^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/.test(value);
638
+ }
585
639
  /** The bounded activity vocabulary. Machine-authored — a model's prose never becomes one of these. */
586
640
  export const RUN_PHASES = [
587
641
  "agent-running",
@@ -3690,6 +3744,7 @@ function initializeStore(db, file) {
3690
3744
  db.exec(APPROVAL_SCHEMA);
3691
3745
  db.exec(LEDGER_CHAIN_SCHEMA);
3692
3746
  db.exec(MONITORING_SCHEMA);
3747
+ db.exec(INTEGRATIONS_SCHEMA);
3693
3748
  db.exec(SPEND_SCHEMA);
3694
3749
  // Sign-in pauses: one row per incident of a provider's sign-in no longer working.
3695
3750
  db.exec(PROVIDER_AUTH_SCHEMA);
@@ -3701,6 +3756,7 @@ function initializeStore(db, file) {
3701
3756
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
3702
3757
  db.exec(POLICY_SCHEMA);
3703
3758
  db.exec(RUN_CHECK_SCHEMA);
3759
+ db.exec(QUIET_CHAT_SCHEMA);
3704
3760
  mergeCheckTables(db);
3705
3761
  addColumn(db, "monitoring_status", "target", "TEXT");
3706
3762
  // v105: how a teammate's turn was billed (this computer's Claude sign-in, as last seen).
@@ -3709,6 +3765,13 @@ function initializeStore(db, file) {
3709
3765
  addColumn(db, "run_spend", "billing", "TEXT NOT NULL DEFAULT 'subscription' CHECK (billing IN ('subscription', 'api-key'))");
3710
3766
  addColumn(db, "run_spend", "billing_fixed", "INTEGER NOT NULL DEFAULT 0");
3711
3767
  migrate(db, preflight === null ? null : Math.abs(preflight));
3768
+ // Complete → pull request → merge: when a grant publishes, the project's "Merge when checks pass", and each
3769
+ // followed pull request's CI revisions and merge record (no version bump: additive only).
3770
+ addColumn(db, "publication_grant", "publish_on", "TEXT NOT NULL DEFAULT 'build' CHECK (publish_on IN ('build', 'complete'))");
3771
+ addColumn(db, "publication_grant", "merge_when_green", "INTEGER NOT NULL DEFAULT 0 CHECK (merge_when_green IN (0, 1))");
3772
+ db.exec(PULL_REQUEST_SCHEMA);
3773
+ // A pull request a flow's Pull request zone opened: the zone follows its CI, so Complete's follower leaves it alone.
3774
+ addColumn(db, "pull_request_follow", "flow_card", "INTEGER REFERENCES flow_card(id)");
3712
3775
  addColumn(db, "flow_card", "source_json", "TEXT");
3713
3776
  addColumn(db, "flow_card", "owner", "TEXT");
3714
3777
  addColumn(db, "flow", "owner", "TEXT");
@@ -18108,9 +18171,9 @@ export class Store {
18108
18171
  this.db
18109
18172
  .prepare(`INSERT INTO publication_grant
18110
18173
  (repo, github_repo, remote, head_prefix, base, capabilities, selector, draft, granted_by, granted_at,
18111
- merge, merge_method, merge_delete_branch)
18112
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
18113
- .run(grant.repo, grant.githubRepo, grant.remote, grant.headPrefix, grant.base, JSON.stringify(grant.capabilities), grant.selector, grant.draft ? 1 : 0, grant.grantedBy, now.toISOString(), grant.merge === true ? 1 : 0, grant.mergeMethod ?? null, grant.mergeDeleteBranch === true ? 1 : 0);
18174
+ merge, merge_method, merge_delete_branch, publish_on, merge_when_green)
18175
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
18176
+ .run(grant.repo, grant.githubRepo, grant.remote, grant.headPrefix, grant.base, JSON.stringify(grant.capabilities), grant.selector, grant.draft ? 1 : 0, grant.grantedBy, now.toISOString(), grant.merge === true ? 1 : 0, grant.mergeMethod ?? null, grant.mergeDeleteBranch === true ? 1 : 0, grant.publishOn ?? "build", grant.mergeWhenGreen === true ? 1 : 0);
18114
18177
  });
18115
18178
  }
18116
18179
  // ---- the merge grant's machinery (v21) ----------------------------------
@@ -21522,6 +21585,11 @@ export class Store {
21522
21585
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
21523
21586
  .run(binding.id, binding.botId, binding.chatId, messageId, row.id, row.destination, row.project, row.taskRef, row.taskId, row.run, now.toISOString());
21524
21587
  }
21588
+ /** The newest confirmed message this notification became at one destination, if any. */
21589
+ telegramMessageOf(notification, destination) {
21590
+ const row = this.db.prepare("SELECT message_id FROM telegram_outbound_message WHERE notification = ? AND destination = ? ORDER BY CAST(message_id AS INTEGER) DESC LIMIT 1").get(notification, destination);
21591
+ return row === undefined ? null : String(row["message_id"]);
21592
+ }
21525
21593
  finalizeTelegramDelivery(row, binding, owner, outcome, now) {
21526
21594
  return this.transact(() => {
21527
21595
  if (!this.telegramClaimHeld(row, owner, now) || row.destination !== this.telegramDestination(binding))
@@ -21615,6 +21683,77 @@ export class Store {
21615
21683
  .prepare("UPDATE telegram_digest SET every_ms = ?, set_by = ?, set_at = ?, last_sent_at = CASE WHEN ? IS NULL THEN NULL ELSE COALESCE(last_sent_at, ?) END WHERE id = 1")
21616
21684
  .run(everyMs, by, now.toISOString(), everyMs, now.toISOString());
21617
21685
  }
21686
+ // ---- quiet chat: each person's notification choice and each task's one message ----
21687
+ /** How chats reach this person: only when they are needed unless they chose every step. */
21688
+ notificationPreference(account) {
21689
+ const row = this.db.prepare("SELECT mode, digest_at, digest_on, updated_at FROM notification_preference WHERE account = ?").get(account);
21690
+ return row === undefined ? { mode: "quiet", digestAt: null, digestOn: null, updatedAt: null }
21691
+ : { mode: row["mode"] === "all" ? "all" : "quiet", digestAt: row["digest_at"] == null ? null : String(row["digest_at"]),
21692
+ digestOn: row["digest_on"] == null ? null : String(row["digest_on"]), updatedAt: String(row["updated_at"]) };
21693
+ }
21694
+ /** Change one person's choice; a digest time is a local HH:MM, or null for no evening digest. */
21695
+ setNotificationPreference(account, change, by, now) {
21696
+ if (change.digestAt != null && !isDigestTime(change.digestAt))
21697
+ throw new Error("digest time must be HH:MM");
21698
+ const current = this.notificationPreference(account);
21699
+ const mode = change.mode ?? current.mode;
21700
+ const digestAt = change.digestAt === undefined ? current.digestAt : change.digestAt;
21701
+ this.db.prepare(`INSERT INTO notification_preference (account, mode, digest_at, digest_on, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?)
21702
+ ON CONFLICT (account) DO UPDATE SET mode = excluded.mode, digest_at = excluded.digest_at, updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
21703
+ .run(account, mode, digestAt, current.digestOn, now.toISOString(), by);
21704
+ return this.notificationPreference(account);
21705
+ }
21706
+ /** Everyone who asked for an evening digest, with the local day theirs was last considered. */
21707
+ eveningDigestPeople() {
21708
+ return this.db.prepare("SELECT account, digest_at, digest_on FROM notification_preference WHERE digest_at IS NOT NULL ORDER BY account").all()
21709
+ .map(row => ({ account: String(row["account"]), digestAt: String(row["digest_at"]), digestOn: row["digest_on"] == null ? null : String(row["digest_on"]) }));
21710
+ }
21711
+ /** Claim one local day's digest for one person: true once, false if it was already considered. */
21712
+ claimEveningDigestDay(account, day) {
21713
+ return Number(this.db.prepare("UPDATE notification_preference SET digest_on = ? WHERE account = ? AND digest_at IS NOT NULL AND (digest_on IS NULL OR digest_on < ?)")
21714
+ .run(day, account, day).changes) === 1;
21715
+ }
21716
+ /** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
21717
+ * a minute of it (up to eight tasks), or starts its own. */
21718
+ chatCardFor(destination, taskRef, filedAt, now) {
21719
+ return this.transact(() => {
21720
+ const known = this.db.prepare("SELECT card FROM chat_card_task WHERE destination = ? AND task_ref = ?").get(destination, taskRef);
21721
+ if (known !== undefined)
21722
+ return this.chatCard(Number(known["card"]));
21723
+ const at = Date.parse(filedAt);
21724
+ const low = new Date(at - QUIET_GROUP_MS).toISOString(), high = new Date(at + QUIET_GROUP_MS).toISOString();
21725
+ const together = Number.isFinite(at) ? this.db.prepare(`SELECT id FROM chat_card c WHERE destination = ? AND anchor_at BETWEEN ? AND ?
21726
+ AND (SELECT COUNT(*) FROM chat_card_task t WHERE t.card = c.id) < 8 ORDER BY id DESC LIMIT 1`).get(destination, low, high) : undefined;
21727
+ const card = together !== undefined ? Number(together["id"])
21728
+ : Number(this.db.prepare("INSERT INTO chat_card (destination, anchor_at, created_at) VALUES (?, ?, ?)")
21729
+ .run(destination, Number.isFinite(at) ? new Date(at).toISOString() : now.toISOString(), now.toISOString()).lastInsertRowid);
21730
+ this.db.prepare("INSERT INTO chat_card_task (destination, task_ref, card, joined_at) VALUES (?, ?, ?, ?)").run(destination, taskRef, card, now.toISOString());
21731
+ return this.chatCard(card);
21732
+ });
21733
+ }
21734
+ chatCard(id) {
21735
+ const row = this.db.prepare("SELECT * FROM chat_card WHERE id = ?").get(id);
21736
+ if (row === undefined)
21737
+ return null;
21738
+ const tasks = this.db.prepare("SELECT task_ref FROM chat_card_task WHERE card = ? ORDER BY joined_at, task_ref").all(id).map(one => Number(one["task_ref"]));
21739
+ return { id, destination: String(row["destination"]), anchorAt: String(row["anchor_at"]), message: row["message"] == null ? null : String(row["message"]),
21740
+ digest: row["digest"] == null ? null : String(row["digest"]), tasks };
21741
+ }
21742
+ /** The newest lifecycle fact a task has recorded — what its card says before any attempt starts. */
21743
+ latestTaskFact(taskRef) {
21744
+ const row = this.db.prepare("SELECT kind, created_at FROM notification WHERE task_ref = ? AND substr(dedupe_key, 1, ?) = ? ORDER BY id DESC LIMIT 1")
21745
+ .get(taskRef, LIFECYCLE_KEY_PREFIX.length, LIFECYCLE_KEY_PREFIX);
21746
+ return row === undefined ? null : { kind: String(row["kind"]), createdAt: String(row["created_at"]) };
21747
+ }
21748
+ /** Task-bound facts recorded since an instant, newest last (bounded): what an evening digest reads. */
21749
+ taskFactsSince(since, limit = 2000) {
21750
+ return this.db.prepare("SELECT * FROM notification WHERE created_at >= ? AND task_ref IS NOT NULL AND project IS NOT NULL ORDER BY id DESC LIMIT ?")
21751
+ .all(since, limit).map(readNotification).reverse();
21752
+ }
21753
+ /** The message now showing this card, and what it shows. */
21754
+ setChatCardMessage(id, message, digest) {
21755
+ this.db.prepare("UPDATE chat_card SET message = ?, digest = ? WHERE id = ?").run(message, digest, id);
21756
+ }
21618
21757
  markTelegramDigestSent(now) {
21619
21758
  this.db.prepare("UPDATE telegram_digest SET last_sent_at = ? WHERE id = 1").run(now.toISOString());
21620
21759
  }
@@ -22430,6 +22569,8 @@ function readPublicationGrant(row) {
22430
22569
  ? null
22431
22570
  : String(row["merge_method"]),
22432
22571
  mergeDeleteBranch: Number(row["merge_delete_branch"] ?? 0) === 1,
22572
+ publishOn: row["publish_on"] === "complete" ? "complete" : "build",
22573
+ mergeWhenGreen: Number(row["merge_when_green"] ?? 0) === 1,
22433
22574
  };
22434
22575
  }
22435
22576
  function readPublication(row) {
package/dist/surface.js CHANGED
@@ -118,6 +118,8 @@ export const COMMAND_GUIDE = [
118
118
  ...MODELS_DESCRIPTORS.map(spec => ({ invocation: `models ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "target", required: true, meaning: "the CLI to update, or on/off" }] } : {}) })),
119
119
  // ---- the queue (agent surface) ----
120
120
  { invocation: "status", synopsis: "running work, queued reasons, results to review, the latest release check and plan windows", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
121
+ { invocation: "integrations", synopsis: "every integration as Connected, Not set up or Broken, with its last success and error, what uses it and what to do; --saved skips new checks; never a secret", audience: "agent", agentMayInvoke: true, mutation: "none",
122
+ flags: [jsonFlag, dbFlag, { name: "saved", takesValue: false, meaning: "show the last checks without checking again" }] },
121
123
  { invocation: "ready", synopsis: "what could be dispatched right now (rows carry reservedFor)", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
122
124
  { invocation: "brief", synopsis: "with a saved central profile, --lead <id> --conversation <id> reads messages, proposals and saved work; --request-id inspects your saved receipt without resending; --local uses local DB catch-up, where --history selects the older operational report", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
123
125
  { invocation: "gaps", synopsis: "requirement gaps blocking dispatch", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag, repoFlag] },
@@ -349,6 +351,6 @@ export const COMMAND_GUIDE = [
349
351
  operator("serve", "the operations console (HTTP)"),
350
352
  operator("watch", "the unattended loop, kept running"),
351
353
  operator("up", "console + worker + browser, one command"),
352
- operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), and print the handoff"),
354
+ operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), offer the starter flows (--starter <ids>), and print the handoff"),
353
355
  operator("daemon", "install the loop under the OS service manager"),
354
356
  ];
@@ -11,4 +11,5 @@ export declare function telegramProgressCard(store: Store, run: Run, taskId: str
11
11
  text: string;
12
12
  entities: ProgressEntity[];
13
13
  link: PhoneTaskLink;
14
+ next: string;
14
15
  };
@@ -123,7 +123,7 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
123
123
  ...(next ? ["", next] : []),
124
124
  ...(acceptance === null ? [] : [acceptance]),
125
125
  ...(delivery === null ? [] : ["", delivery]), "", `${projectLabel(project)} · #${run.id}`].join("\n");
126
- return { text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
126
+ return { text, next, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
127
127
  link: { label, path: operatorHold || accessBlocked || (!built && run.outcome !== null)
128
128
  ? chatControlHref("recovery", taskId) : built ? chatResultHref(taskId, run.id, checksFailed ? "checks" : "summary") : chatControlHref("task", taskId) } };
129
129
  }
package/dist/telegram.js CHANGED
@@ -29,6 +29,7 @@ import { join } from "node:path";
29
29
  import { validateNote } from "./decision.js";
30
30
  import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
31
31
  import { telegramProgressCard } from "./telegram-progress.js";
32
+ import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView, readyPingView } from "./chat-quiet.js";
32
33
  import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
33
34
  import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
34
35
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
@@ -519,6 +520,8 @@ async function processConversations(store, botId, transport, owner, clock, repor
519
520
  function factLinkLabel(path) {
520
521
  if (/^\/review\?result=[^&]+&run=\d+&tab=checks$/.test(path))
521
522
  return "Inspect result";
523
+ if (/^\/t\/[^?#]+#merge$/.test(path))
524
+ return "Merge";
522
525
  if (/^\/chat\?task=[^&]+&result=/.test(path))
523
526
  return "Open result";
524
527
  if (/^\/(?:chat\?task=|t\/)/.test(path))
@@ -563,6 +566,13 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
563
566
  }
564
567
  const digest = store.telegramDigest();
565
568
  const now = clock();
569
+ // An evening digest someone asked for is due: record it as their own notification, delivered below.
570
+ try {
571
+ enqueueEveningDigests(store, now, evidenceRoot);
572
+ }
573
+ catch {
574
+ report.problems.push("evening digest could not be prepared");
575
+ }
566
576
  const digestDue = digest.everyMs === null || digest.lastSentAt === null || now.getTime() >= new Date(digest.lastSentAt).getTime() + digest.everyMs;
567
577
  // Every paired person is a destination of their own: each binding claims
568
578
  // and settles its own rows under its own ceiling, in turn.
@@ -571,13 +581,16 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
571
581
  }
572
582
  async function deliverOutboxTo(store, botId, binding, transport, owner, clock, report, digest, digestDue, readProjects, canDeliver, phoneOrigin, evidenceRoot) {
573
583
  const now = clock();
574
- const claimed = store.claimTelegramDeliveries(binding, owner, DELIVERY_CLAIM_MS, now, digestDue ? "all" : "urgent");
584
+ // Only when I'm needed (the default): a task's facts edit its one card and only what needs this person makes a
585
+ // new message, so the installation's routine-fact cadence does not apply. Every step keeps today's behaviour.
586
+ const quiet = store.notificationPreference(binding.approver).mode === "quiet";
587
+ const claimed = store.claimTelegramDeliveries(binding, owner, DELIVERY_CLAIM_MS, now, quiet || digestDue ? "all" : "urgent");
575
588
  // Preserve ID order, flushing earlier routine facts before an urgent task
576
589
  // update. An earlier failed/retrying row also fences later rows for that task.
577
590
  const groups = [];
578
591
  for (const row of claimed) {
579
592
  const last = groups[groups.length - 1];
580
- if (digest.everyMs !== null && !isUrgent(row) && last !== undefined && !isUrgent(last[0]))
593
+ if (!quiet && digest.everyMs !== null && !isUrgent(row) && last !== undefined && !isUrgent(last[0]))
581
594
  last.push(row);
582
595
  else
583
596
  groups.push([row]);
@@ -638,12 +651,12 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
638
651
  }
639
652
  return null;
640
653
  };
641
- const sender = async (text, keyboard, messageRows = rows, entities) => {
654
+ const sender = async (text, keyboard, messageRows = rows, entities, silent) => {
642
655
  // Finish every await before the synchronous fence and transport call.
643
656
  const problem = (await readAccess()) ?? fence();
644
657
  if (problem !== null)
645
658
  return { ok: false, error: problem };
646
- const sent = await send(transport, binding.chatId, text, keyboard, entities);
659
+ const sent = await send(transport, binding.chatId, text, keyboard, entities, silent);
647
660
  if (!sent.ok) {
648
661
  if (sent.retryAfter !== undefined)
649
662
  store.deferTelegram(botId, new Date(clock().getTime() + sent.retryAfter * 1_000).toISOString());
@@ -694,7 +707,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
694
707
  const after = (await readAccess()) ?? fence();
695
708
  return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, String(messageId)) } : { ok: false, error: after };
696
709
  };
697
- const batched = digest.everyMs !== null && !isUrgent(rows[0]);
710
+ const batched = !quiet && digest.everyMs !== null && !isUrgent(rows[0]);
698
711
  const row = rows[0];
699
712
  const progressRun = batched ? null : store.telegramProgressRun(row);
700
713
  const progress = progressRun !== null && isTelegramProgressNotification(row);
@@ -731,15 +744,80 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
731
744
  const after = (await readAccess()) ?? fence();
732
745
  return after === null ? edited : { ok: false, error: after };
733
746
  };
734
- // A failure or decision still gets its own alert. Refresh an existing
735
- // card first so it does not keep saying the build is running.
736
- if (!progress && progressRun !== null)
737
- await updateProgress(true);
738
- const updated = progress ? await updateProgress(false) : null;
739
- const outcome = updated !== null ? updated.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, updated.messageId) } : updated
740
- : rows[0].kind === "acceptance-evidence" ? await imageSender(rows[0]) : batched
741
- ? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock)
742
- : await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
747
+ /** Quiet mode: the task's one card (or its group's), created silently and then only ever edited. */
748
+ const updateCard = async (fact) => {
749
+ const problem = (await readAccess()) ?? fence();
750
+ if (problem !== null)
751
+ return { ok: false, error: problem };
752
+ const card = store.chatCardFor(fact.destination, fact.taskRef, store.getTask(fact.taskId)?.createdAt ?? fact.createdAt, clock());
753
+ const view = quietCardView(store, card.tasks, clock(), evidenceRoot);
754
+ if (view === null)
755
+ return { ok: true, messageId: card.message };
756
+ let button = null;
757
+ try {
758
+ button = phoneLinkButton(phoneOrigin?.() ?? null, view.link);
759
+ }
760
+ catch { /* No trusted origin. */ }
761
+ const keyboard = button === null ? [] : [button];
762
+ const shown = createHash("sha256").update(JSON.stringify([view.text, view.entities, keyboard])).digest("hex");
763
+ if (card.message !== null && card.digest === shown) {
764
+ store.recordTelegramMessage(fact, binding, card.message, clock());
765
+ return { ok: true, messageId: card.message };
766
+ }
767
+ if (card.message !== null) {
768
+ const edited = await editProgress(transport, binding.chatId, card.message, view.text, keyboard, view.entities);
769
+ if (edited.ok) {
770
+ store.setChatCardMessage(card.id, card.message, shown);
771
+ store.recordTelegramMessage(fact, binding, card.message, clock());
772
+ const after = (await readAccess()) ?? fence();
773
+ return after === null ? edited : { ok: false, error: after };
774
+ }
775
+ if (edited.retryAfter !== undefined)
776
+ store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
777
+ // Only Telegram's definitive missing/uneditable answer permits a replacement card.
778
+ if (!edited.replace)
779
+ return edited;
780
+ }
781
+ const sent = await sender(view.text, keyboard, [fact], view.entities, true);
782
+ // A card that reached the chat is kept even if a later fence refused the acknowledgement: never a second card.
783
+ const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
784
+ if (placed !== null)
785
+ store.setChatCardMessage(card.id, placed, shown);
786
+ return sent;
787
+ };
788
+ const quietOutcome = async (fact) => {
789
+ const card = await updateCard(fact);
790
+ if (!card.ok)
791
+ return card;
792
+ if (!needsPerson(fact))
793
+ return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
794
+ const readyRun = fact.kind === "run-finished" ? store.telegramProgressRun(fact) : null;
795
+ if (readyRun !== null) {
796
+ const ping = readyPingView(store, store.getRun(readyRun.id), fact.taskId, fact.project, clock(), evidenceRoot);
797
+ let button = null;
798
+ try {
799
+ button = phoneLinkButton(phoneOrigin?.() ?? null, ping.link);
800
+ }
801
+ catch { /* No trusted origin. */ }
802
+ const sent = await sender(ping.text, button === null ? undefined : [button], [fact], ping.entities);
803
+ return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
804
+ }
805
+ return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
806
+ };
807
+ let outcome;
808
+ if (quiet && rows.length === 1 && isTaskFact(row))
809
+ outcome = await quietOutcome(row);
810
+ else {
811
+ // A failure or decision still gets its own alert. Refresh an existing
812
+ // card first so it does not keep saying the build is running.
813
+ if (!progress && progressRun !== null)
814
+ await updateProgress(true);
815
+ const updated = progress ? await updateProgress(false) : null;
816
+ outcome = updated !== null ? updated.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, updated.messageId) } : updated
817
+ : rows[0].kind === "acceptance-evidence" ? await imageSender(rows[0]) : batched
818
+ ? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock)
819
+ : await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
820
+ }
743
821
  const finalProblem = outcome.ok ? (await readAccess()) ?? fence() : null;
744
822
  const settled = finalProblem === null ? outcome : { ok: false, error: finalProblem };
745
823
  // A skipped screenshot settles its row but nothing reached the phone.
@@ -934,7 +1012,7 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
934
1012
  }
935
1013
  return { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) };
936
1014
  }
937
- async function send(transport, chatId, text, keyboard, entities) {
1015
+ async function send(transport, chatId, text, keyboard, entities, silent) {
938
1016
  // No markup parsing. Only machine-selected heading ranges may be bold;
939
1017
  // agent text remains literal and URLs never trigger link previews.
940
1018
  let answer;
@@ -944,6 +1022,8 @@ async function send(transport, chatId, text, keyboard, entities) {
944
1022
  text,
945
1023
  ...(entities === undefined ? {} : { entities }),
946
1024
  link_preview_options: { is_disabled: true },
1025
+ // A new quiet card arrives without a sound; only a ping should buzz.
1026
+ ...(silent === true ? { disable_notification: true } : {}),
947
1027
  ...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
948
1028
  });
949
1029
  }
@@ -0,0 +1,8 @@
1
+ /** "16:39" today, "Yesterday 16:39" / "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year). */
2
+ export declare function shortWhen(iso: string, now?: Date): string;
3
+ /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
4
+ export declare function whenHtml(iso: string | null, full: string, now?: Date): string;
5
+ /** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
6
+ export declare const utcMinute: (iso: string) => string;
7
+ /** Both the desk and phone forms of a UTC stamp. */
8
+ export declare const whenUtc: (iso: string | null, now?: Date) => string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A time a person reads. A desk shows the full stamp, as before; a phone (760px and narrower) shows the short
3
+ * one — "16:39" today, "Yesterday 16:39", "Sep 28" otherwise — and the full stamp stays in the title. Stamps are
4
+ * UTC to the minute, so the short form is counted in UTC too. The CSS that picks one lives in the shared page
5
+ * stylesheet (`.so-when-*` in serve.ts).
6
+ */
7
+ const escape = (value) => value.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
9
+ const DAY_MS = 86_400_000;
10
+ /** "16:39" today, "Yesterday 16:39" / "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year). */
11
+ export function shortWhen(iso, now = new Date()) {
12
+ const at = new Date(iso);
13
+ if (Number.isNaN(at.getTime()))
14
+ return iso;
15
+ const clock = iso.length >= 16 && iso[10] === "T" ? iso.slice(11, 16) : at.toISOString().slice(11, 16);
16
+ const days = Math.floor(at.getTime() / DAY_MS) - Math.floor(now.getTime() / DAY_MS);
17
+ if (days === 0)
18
+ return clock;
19
+ if (days === -1)
20
+ return `Yesterday ${clock}`;
21
+ if (days === 1)
22
+ return `Tomorrow ${clock}`;
23
+ const date = `${MONTHS[at.getUTCMonth()]} ${at.getUTCDate()}`;
24
+ return at.getUTCFullYear() === now.getUTCFullYear() ? date : `${date} ${at.getUTCFullYear()}`;
25
+ }
26
+ /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
27
+ export function whenHtml(iso, full, now = new Date()) {
28
+ if (iso === null || iso === "")
29
+ return "";
30
+ const words = escape(full);
31
+ return `<time datetime="${escape(iso)}" title="${words}"><span class="so-when-full">${words}</span><span class="so-when-short">${escape(shortWhen(iso, now))}</span></time>`;
32
+ }
33
+ /** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
34
+ export const utcMinute = (iso) => `${iso.slice(0, 16).replace("T", " ")} UTC`;
35
+ /** Both the desk and phone forms of a UTC stamp. */
36
+ export const whenUtc = (iso, now = new Date()) => iso === null || iso === "" ? "" : whenHtml(iso, utcMinute(iso), now);