toolroll 0.9.6 → 0.9.7

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 (84) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +1 -0
  5. package/dist/assignment-ui.js +3 -1
  6. package/dist/assignment.d.ts +9 -0
  7. package/dist/assignment.js +62 -13
  8. package/dist/browser/workspace.css +2 -2
  9. package/dist/browser/workspace.js +28 -24
  10. package/dist/browser-workspace.d.ts +117 -2
  11. package/dist/builder.d.ts +1 -0
  12. package/dist/builder.js +33 -13
  13. package/dist/chat-actions.js +2 -1
  14. package/dist/chat-delivery.js +2 -0
  15. package/dist/chat-quiet.js +11 -1
  16. package/dist/chat-task-actions.d.ts +13 -0
  17. package/dist/chat-task-actions.js +19 -1
  18. package/dist/check-levels-ui.d.ts +21 -0
  19. package/dist/check-levels-ui.js +70 -0
  20. package/dist/check-levels.d.ts +67 -0
  21. package/dist/check-levels.js +171 -0
  22. package/dist/claim.d.ts +22 -0
  23. package/dist/claim.js +43 -0
  24. package/dist/cli.d.ts +1 -1
  25. package/dist/cli.js +4 -0
  26. package/dist/demo-chat.js +4 -1
  27. package/dist/dispatch.js +7 -2
  28. package/dist/dispose.d.ts +8 -0
  29. package/dist/dispose.js +7 -1
  30. package/dist/export.js +3 -2
  31. package/dist/first-run.d.ts +34 -0
  32. package/dist/first-run.js +37 -1
  33. package/dist/flow-gallery-ui.d.ts +30 -0
  34. package/dist/flow-gallery-ui.js +85 -0
  35. package/dist/flow-gallery.d.ts +111 -0
  36. package/dist/flow-gallery.js +445 -0
  37. package/dist/flow-share.d.ts +102 -0
  38. package/dist/flow-share.js +614 -0
  39. package/dist/flow-starters.d.ts +2 -0
  40. package/dist/flow-starters.js +1 -1
  41. package/dist/flow-steps.js +7 -0
  42. package/dist/flow-triggers.js +2 -0
  43. package/dist/flows-cli.d.ts +56 -0
  44. package/dist/flows-cli.js +109 -3
  45. package/dist/flows-ui.d.ts +15 -1
  46. package/dist/flows-ui.js +31 -6
  47. package/dist/guides.js +27 -2
  48. package/dist/lead-context.js +6 -2
  49. package/dist/mate-doors.d.ts +0 -15
  50. package/dist/mate-doors.js +5 -1
  51. package/dist/mate-tools.js +9 -4
  52. package/dist/models-ui.js +1 -1
  53. package/dist/operate.d.ts +4 -1
  54. package/dist/operate.js +601 -85
  55. package/dist/project-concurrency.d.ts +62 -0
  56. package/dist/project-concurrency.js +116 -0
  57. package/dist/project-delete-ui.d.ts +7 -0
  58. package/dist/project-delete-ui.js +19 -2
  59. package/dist/project-delete.js +4 -0
  60. package/dist/pull-request-flow.d.ts +9 -0
  61. package/dist/pull-request-flow.js +21 -3
  62. package/dist/result-follow-ups.d.ts +101 -0
  63. package/dist/result-follow-ups.js +333 -0
  64. package/dist/scope.js +11 -0
  65. package/dist/serve.d.ts +211 -2
  66. package/dist/serve.js +1009 -141
  67. package/dist/store.d.ts +76 -3
  68. package/dist/store.js +311 -22
  69. package/dist/surface.js +31 -8
  70. package/dist/tailnet.d.ts +11 -0
  71. package/dist/tailnet.js +47 -0
  72. package/dist/task-outcome-cli.js +2 -2
  73. package/dist/task-status.d.ts +8 -1
  74. package/dist/task-status.js +28 -10
  75. package/dist/team-cli.js +3 -0
  76. package/dist/telegram-status.js +2 -2
  77. package/dist/verification-evidence.js +21 -1
  78. package/dist/webhooks.js +5 -0
  79. package/dist/work-index.js +39 -6
  80. package/dist/work-summary.d.ts +0 -4
  81. package/dist/work-summary.js +2 -0
  82. package/dist/workspace-ui.d.ts +3 -0
  83. package/dist/workspace-ui.js +5 -3
  84. package/package.json +1 -1
package/dist/store.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Actor } from "./actor.js";
1
2
  import { type CriterionMatrixRow, type CriterionJudgementWord } from "./proof.js";
2
3
  import { type ExecutionProfile, type ChainEntry, type UnattendedPermissionMode, type AcceptanceCriterion } from "./scope.js";
3
4
  import { type PhaseRoute, type PublicationAuthority, type RouteStamp, type ReadinessLookup, type ReadinessObservation, type RiskLevel, type RouteOverride } from "./phase-routing.js";
@@ -19,6 +20,15 @@ import { type RetentionKind, type RetentionPeriods } from "./retention.js";
19
20
  import { type CheckoutCleanup } from "./storage.js";
20
21
  import { type LedgerChainReport } from "./ledger-chain.js";
21
22
  import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer } from "./approval-policy.js";
23
+ export type TaskAct = "filed" | "approved" | "cancelled" | "completed" | "asked";
24
+ export type TaskActRow = {
25
+ act: TaskAct;
26
+ account: string;
27
+ lead: boolean;
28
+ person: string | null;
29
+ why: string | null;
30
+ at: string;
31
+ };
22
32
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
23
33
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
24
34
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -676,6 +686,8 @@ export type FlowScriptRow = {
676
686
  savedAt: string;
677
687
  language: "shell" | "python" | "node";
678
688
  file: string | null;
689
+ /** Why it can't run yet ("imported": it came in with a flow file and waits for a person's approval); null when it runs. */
690
+ held: string | null;
679
691
  };
680
692
  /** Where a card came from when a trigger made it: what to call it and where to look (a GitHub issue, a Linear issue, another flow's card). */
681
693
  /** Where a card came from. `mail` (v89): the email it came from, so a reply to its sender stays in the thread.
@@ -1843,9 +1855,9 @@ export declare class Store {
1843
1855
  } | {
1844
1856
  kind: "machine";
1845
1857
  code: "mirror-latched" | "disowned-completion";
1846
- }, now: Date, admittedFrom: readonly TaskState[] | null): CancellationTransition;
1858
+ }, now: Date, admittedFrom: readonly TaskState[] | null, replacedBy?: string): CancellationTransition;
1847
1859
  private applyCancellationLocked;
1848
- setTaskState(id: string, state: TaskState, now: Date, mutation?: Mutation, cancellationReason?: string): {
1860
+ setTaskState(id: string, state: TaskState, now: Date, mutation?: Mutation, cancellationReason?: string, replacedBy?: string): {
1849
1861
  ok: true;
1850
1862
  } | {
1851
1863
  ok: false;
@@ -6446,7 +6458,10 @@ export declare class Store {
6446
6458
  by: string;
6447
6459
  language?: "shell" | "python" | "node";
6448
6460
  file?: string | null;
6461
+ held?: string | null;
6449
6462
  }, now: Date): number;
6463
+ /** A person approves a held script (one an imported flow brought): from now on it runs. False when it wasn't held. */
6464
+ approveFlowScript(repo: string, name: string): boolean;
6450
6465
  removeFlowScript(repo: string, name: string): boolean;
6451
6466
  /** Claim a card's visit to a step zone. False when another pass holds it or it already finished. */
6452
6467
  claimFlowStep(step: {
@@ -6746,7 +6761,8 @@ export declare class Store {
6746
6761
  getMateTurn(id: number): MateTurn | null;
6747
6762
  liveMateTurnFor(approver: string): MateTurn | null;
6748
6763
  mateStepsOf(mateTurn: number): ChatTurn[];
6749
- /** Remember a project was opened. Upsert keeps added_at; recency always moves. */
6764
+ /** Remember a project was opened. Upsert keeps added_at; recency always moves.
6765
+ * A project added for the first time starts on Quick checks. */
6750
6766
  upsertProject(path: string, name: string, now: Date): void;
6751
6767
  /** Most recently opened first — the opener page's order. */
6752
6768
  listProjects(): {
@@ -7147,6 +7163,12 @@ export declare class Store {
7147
7163
  * conversations never freeze the runner's queue.
7148
7164
  */
7149
7165
  liveClaimCount(runner: string, now: Date): number;
7166
+ /** The same live claims as liveClaimCount, by the project their task is placed in. */
7167
+ liveClaimCountsByRepo(runner: string, now: Date): Map<string, number>;
7168
+ /** Every builder's live attempts, by project: what `status` shows against each project's limit. */
7169
+ runningBuildsByRepo(now: Date): Map<string, number>;
7170
+ /** A project's build concurrency changed (project-concurrency.ts): the ledger keeps before → after. */
7171
+ recordProjectConcurrency(by: string, repo: string, before: number, after: number, now: Date): void;
7150
7172
  /** Forward only, and only under the live generation. A stale poller moves nothing. */
7151
7173
  /** v98: an update Telegram pushed, kept until the bridge applies it; false when it's already kept or applied. */
7152
7174
  queueTelegramUpdate(botId: string, updateId: number, payload: string, now: Date): boolean;
@@ -7339,6 +7361,55 @@ export declare class Store {
7339
7361
  }[];
7340
7362
  /** Claim one local day's digest for one person: true once, false if it was already considered. */
7341
7363
  claimEveningDigestDay(account: string, day: string): boolean;
7364
+ /** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
7365
+ mintLeadCredential(owner: string, by: string, now: Date): {
7366
+ id: string;
7367
+ token: string;
7368
+ };
7369
+ /** End every live lead token for one person. */
7370
+ revokeLeadCredentials(owner: string, by: string, now: Date): number;
7371
+ /** Whose lead a presented token is: a live token whose owner is still an approver, or null. */
7372
+ leadFor(presented: string): {
7373
+ owner: string;
7374
+ id: string;
7375
+ } | null;
7376
+ /** Record who did this to a task, when someone is acting. The lead's acts are also in the ledger as "lead for <owner>". */
7377
+ noteTaskAct(taskRef: number, act: TaskAct, now: Date, handed?: {
7378
+ person: string;
7379
+ why: string;
7380
+ }, actor?: Actor | null): void;
7381
+ /** Every recorded act on a task, oldest first. */
7382
+ taskActs(taskRef: number): TaskActRow[];
7383
+ /** Whose lead's work this task is: the last to file, approve or hand it on was that person's lead (null: a person's). */
7384
+ leadWorkOf(taskRef: number): string | null;
7385
+ /** The lead hands a task to a person: it becomes theirs, and they get one message saying why. */
7386
+ askPerson(taskId: string, person: string, why: string, now: Date): {
7387
+ ok: true;
7388
+ } | {
7389
+ ok: false;
7390
+ reason: "unknown-task" | "unknown-person" | "unplaced";
7391
+ };
7392
+ /** The task that replaced a cancelled one, or null. */
7393
+ replacementOf(taskId: string): string | null;
7394
+ /** Every replaced task in one read, for lists: task id → its successor. */
7395
+ replacements(): Map<string, string>;
7396
+ /** Mute or unmute one project's pings for one person. The console and digest keep everything. */
7397
+ setProjectMuted(account: string, repo: string, muted: boolean, now: Date): void;
7398
+ mutedProjects(account: string): string[];
7399
+ /** Whose act made this fact, if anyone's. */
7400
+ notificationActor(id: number): Actor | null;
7401
+ /**
7402
+ * Whether this fact may message this person (every chat, phone and browser asks here). Pings follow
7403
+ * responsibility: a security alert always does; nobody is messaged about their own act (or their lead's);
7404
+ * the lead's work messages nobody unless it is handed to a person or fails with nothing left for the lead
7405
+ * to try; and a person's muted project stays quiet. A fact addressed to one person is theirs. The console
7406
+ * and the evening digest keep every fact either way.
7407
+ */
7408
+ pingAllowed(row: Pick<Notification, "id" | "kind" | "recipient" | "project" | "taskRef">, account: string): boolean;
7409
+ /** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
7410
+ leadQuiet(row: Pick<Notification, "id" | "kind" | "taskRef">, actor?: Actor | null): boolean;
7411
+ /** A failure with nothing left for the lead to do: its attempts are spent, or the task has failed for good. */
7412
+ private failedForGood;
7342
7413
  /** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
7343
7414
  * a minute of it (up to eight tasks), or starts its own. */
7344
7415
  chatCardFor(destination: string, taskRef: number, filedAt: string, now: Date): ChatCard;
@@ -7487,6 +7558,8 @@ export declare class Store {
7487
7558
  export declare const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
7488
7559
  /** v83: a notification for another person, settled for this destination without sending. */
7489
7560
  export declare const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
7561
+ /** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
7562
+ export declare const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
7490
7563
  /**
7491
7564
  * Why the store itself holds a Telegram delivery back: authority,
7492
7565
  * provenance, ordering, a switched-off channel, or a wait it imposed. A
package/dist/store.js CHANGED
@@ -8,6 +8,7 @@ import { MEMORY_SCHEMA } from "./project-memory.js";
8
8
  import { MODELS_SCHEMA } from "./model-catalog.js";
9
9
  import { validateTaskText } from "./task-text.js";
10
10
  import { chatControlHref, chatResultHref } from "./chat-controls.js";
11
+ import { actorLabel, currentActor, leadSecretMatches, mintLeadToken, parseLeadToken } from "./actor.js";
11
12
  import { scanForSecrets } from "./evidence.js";
12
13
  /**
13
14
  * The database: a small task store, and the operational overlay beside it.
@@ -58,6 +59,7 @@ import { modeDigestOf, modeTermsFromJson } from "./modes.js";
58
59
  import { readVerifiedArtifact } from "./evidence.js";
59
60
  import { isProviderId } from "./provider.js";
60
61
  import { authenticate as runnerAuthenticate } from "./runner.js";
62
+ import { stampNewProjectLevel } from "./check-levels.js";
61
63
  import { parseCheckProgressSnapshot } from "./check-progress.js";
62
64
  import { normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
63
65
  import { LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V54_COLUMNS, LEDGER_V54_TABLE, installLedgerTriggers } from "./action-ledger.js";
@@ -164,6 +166,54 @@ CREATE TABLE IF NOT EXISTS chat_card_task (
164
166
  );
165
167
  CREATE INDEX IF NOT EXISTS chat_card_task_card ON chat_card_task(card);
166
168
  `;
169
+ /** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
170
+ * for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
171
+ * (`lead` 1: the owner's lead; `person`/`why`: who the lead asked, and why). `notification_actor` is whose act made
172
+ * a fact. `task_replacement` is the task that replaced a cancelled one. `project_mute` is a person's muted projects. */
173
+ const LEAD_QUIET_SCHEMA = `
174
+ CREATE TABLE IF NOT EXISTS lead_credential (
175
+ id TEXT PRIMARY KEY,
176
+ owner TEXT NOT NULL,
177
+ secret_hash TEXT NOT NULL,
178
+ created_at TEXT NOT NULL,
179
+ created_by TEXT NOT NULL,
180
+ revoked_at TEXT,
181
+ revoked_by TEXT
182
+ );
183
+ CREATE INDEX IF NOT EXISTS lead_credential_owner ON lead_credential (owner);
184
+ CREATE TABLE IF NOT EXISTS task_act (
185
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
186
+ task_ref INTEGER NOT NULL,
187
+ act TEXT NOT NULL CHECK (act IN ('filed', 'approved', 'cancelled', 'completed', 'asked')),
188
+ account TEXT NOT NULL,
189
+ lead INTEGER NOT NULL DEFAULT 0 CHECK (lead IN (0, 1)),
190
+ person TEXT,
191
+ why TEXT,
192
+ at TEXT NOT NULL
193
+ );
194
+ CREATE INDEX IF NOT EXISTS task_act_task ON task_act (task_ref, id);
195
+ CREATE TABLE IF NOT EXISTS notification_actor (
196
+ notification INTEGER PRIMARY KEY REFERENCES notification(id) ON DELETE CASCADE,
197
+ account TEXT NOT NULL,
198
+ lead INTEGER NOT NULL DEFAULT 0 CHECK (lead IN (0, 1))
199
+ );
200
+ CREATE TABLE IF NOT EXISTS task_replacement (
201
+ task_ref INTEGER PRIMARY KEY,
202
+ successor TEXT NOT NULL,
203
+ by TEXT,
204
+ at TEXT NOT NULL
205
+ );
206
+ CREATE TABLE IF NOT EXISTS project_mute (
207
+ account TEXT NOT NULL,
208
+ repo TEXT NOT NULL,
209
+ at TEXT NOT NULL,
210
+ PRIMARY KEY (account, repo)
211
+ );
212
+ `;
213
+ /** What still pings on the lead's own work: a security alert. */
214
+ const SECURITY_KINDS = new Set(["secret-detected", "security-release"]);
215
+ /** A failure that leaves nothing for the lead to try: the attempts are spent. */
216
+ const SPENT_KINDS = new Set(["attempts-exhausted", "plan-attempts-exhausted"]);
167
217
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
168
218
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
169
219
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -450,7 +500,8 @@ function readFlowRow(row) {
450
500
  function readFlowScriptRow(row) {
451
501
  return { id: Number(row["id"]), repo: String(row["repo"]), name: String(row["name"]), about: String(row["about"]), body: String(row["body"]), timeoutMinutes: Number(row["timeout_minutes"]),
452
502
  version: Number(row["version"]), digest: String(row["digest"]), savedBy: String(row["saved_by"]), savedAt: String(row["saved_at"]),
453
- language: row["language"] === "python" || row["language"] === "node" ? row["language"] : "shell", file: row["file"] === null || row["file"] === undefined ? null : String(row["file"]) };
503
+ language: row["language"] === "python" || row["language"] === "node" ? row["language"] : "shell", file: row["file"] === null || row["file"] === undefined ? null : String(row["file"]),
504
+ held: row["held"] === null || row["held"] === undefined ? null : String(row["held"]) };
454
505
  }
455
506
  function readFlowStepRunRow(row) {
456
507
  const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
@@ -3757,6 +3808,7 @@ function initializeStore(db, file) {
3757
3808
  db.exec(POLICY_SCHEMA);
3758
3809
  db.exec(RUN_CHECK_SCHEMA);
3759
3810
  db.exec(QUIET_CHAT_SCHEMA);
3811
+ db.exec(LEAD_QUIET_SCHEMA);
3760
3812
  mergeCheckTables(db);
3761
3813
  addColumn(db, "monitoring_status", "target", "TEXT");
3762
3814
  // v105: how a teammate's turn was billed (this computer's Claude sign-in, as last seen).
@@ -3778,6 +3830,8 @@ function initializeStore(db, file) {
3778
3830
  // v90: scripts in Python and Node, and scripts that run a file in the project.
3779
3831
  addColumn(db, "flow_script", "language", "TEXT NOT NULL DEFAULT 'shell'");
3780
3832
  addColumn(db, "flow_script", "file", "TEXT");
3833
+ // A script that came in with an imported flow waits for a person to approve it before it runs (no version bump: additive only).
3834
+ addColumn(db, "flow_script", "held", "TEXT");
3781
3835
  addColumn(db, "notification", "recipient", "TEXT");
3782
3836
  addColumn(db, "approver", "projects_json", "TEXT");
3783
3837
  addColumn(db, "invite", "projects_json", "TEXT");
@@ -6942,6 +6996,8 @@ export class Store {
6942
6996
  const earlier = this.db.prepare("SELECT 1 AS hit FROM task_ref WHERE external_id = ?").get(spec.id) !== undefined;
6943
6997
  // Created here, so it is ours — the one place that is true by construction.
6944
6998
  const ref = this.refFor(BUILT_IN, spec.id, "ours");
6999
+ // Who filed it, when someone is acting: the lead's work pings nobody.
7000
+ this.noteTaskAct(ref.id, "filed", now);
6945
7001
  if (earlier)
6946
7002
  this.recordAction({ at: stamp, actor: "system", repo: ref.repo, taskId: spec.id, runId: null, action: "task registered", outcome: "recorded", source: "work", detail: REGISTERED_AT_FILING });
6947
7003
  // v102: who filed it, set once with the filing (separation of duties reads it; nothing changes it).
@@ -7006,10 +7062,10 @@ export class Store {
7006
7062
  * they admit from (`null` = any). It returns raw changes; callers keep
7007
7063
  * their own refusal words and wake bumps.
7008
7064
  */
7009
- applyCancellation(taskId, reason, now, admittedFrom) {
7010
- return this.transact(() => this.applyCancellationLocked(taskId, reason, now, admittedFrom));
7065
+ applyCancellation(taskId, reason, now, admittedFrom, replacedBy) {
7066
+ return this.transact(() => this.applyCancellationLocked(taskId, reason, now, admittedFrom, replacedBy));
7011
7067
  }
7012
- applyCancellationLocked(taskId, reason, now, admittedFrom) {
7068
+ applyCancellationLocked(taskId, reason, now, admittedFrom, replacedBy) {
7013
7069
  const ref = this.db
7014
7070
  .prepare("SELECT id, coordinator_cid FROM task_ref WHERE backend = ? AND external_id = ?")
7015
7071
  .get(BUILT_IN, taskId);
@@ -7035,6 +7091,14 @@ export class Store {
7035
7091
  .run(now.toISOString(), taskId, ...(admittedFrom ?? []));
7036
7092
  if (Number(changes) === 0)
7037
7093
  return { changed: false };
7094
+ // Replaced: the successor is part of the cancellation, so every surface says "Replaced by <id>".
7095
+ if (ref !== undefined && replacedBy !== undefined) {
7096
+ this.db.prepare(`INSERT INTO task_replacement (task_ref, successor, by, at) VALUES (?, ?, ?, ?)
7097
+ ON CONFLICT (task_ref) DO UPDATE SET successor = excluded.successor, by = excluded.by, at = excluded.at`)
7098
+ .run(Number(ref["id"]), replacedBy, currentActor() === null ? null : actorLabel(currentActor()), now.toISOString());
7099
+ }
7100
+ if (ref !== undefined && prior?.["state"] !== "cancelled")
7101
+ this.noteTaskAct(Number(ref["id"]), "cancelled", now);
7038
7102
  // A cancelled task's pending steering settles superseded in the SAME
7039
7103
  // transaction, whichever road cancelled it — a note silently waiting
7040
7104
  // for a build that can no longer happen is wrong on every road, not
@@ -7058,18 +7122,20 @@ export class Store {
7058
7122
  if (ref !== undefined && prior?.["state"] !== "cancelled") {
7059
7123
  this.noteLifecycle({
7060
7124
  taskRef: Number(ref["id"]), kind: "task-cancelled", identity: `t${Number(ref["id"])}`,
7061
- subject: "Cancelled",
7062
- body: reason.kind === "operator"
7063
- ? `${cancellationWords(reason.text)} Nothing more runs for it.`
7064
- : reason.code === "mirror-latched"
7065
- ? "The tracker closed it. Nothing more runs for it."
7066
- : "The tracker closed it while it was being built, so the finished work was not accepted.",
7067
- link: chatControlHref("task", taskId),
7125
+ subject: replacedBy === undefined ? "Cancelled" : `Replaced by ${replacedBy}`,
7126
+ body: replacedBy !== undefined
7127
+ ? `${replacedBy} replaces it. Nothing more runs for it.`
7128
+ : reason.kind === "operator"
7129
+ ? `${cancellationWords(reason.text)} Nothing more runs for it.`
7130
+ : reason.code === "mirror-latched"
7131
+ ? "The tracker closed it. Nothing more runs for it."
7132
+ : "The tracker closed it while it was being built, so the finished work was not accepted.",
7133
+ link: chatControlHref("task", replacedBy ?? taskId),
7068
7134
  }, now);
7069
7135
  }
7070
7136
  return { changed: true };
7071
7137
  }
7072
- setTaskState(id, state, now, mutation = {}, cancellationReason) {
7138
+ setTaskState(id, state, now, mutation = {}, cancellationReason, replacedBy) {
7073
7139
  return this.once(mutation, "setTaskState", () => {
7074
7140
  // The terminal guard lives IN the primitive so every present and
7075
7141
  // future caller is covered (external dispatch, finding 23): a
@@ -7085,7 +7151,7 @@ export class Store {
7085
7151
  }
7086
7152
  return this.transact(() => {
7087
7153
  if (state === "cancelled") {
7088
- const done = this.applyCancellation(id, { kind: "operator", text: cancellationReason ?? null }, now, null);
7154
+ const done = this.applyCancellation(id, { kind: "operator", text: cancellationReason ?? null }, now, null, replacedBy);
7089
7155
  if (done.reason !== undefined)
7090
7156
  return { ok: false, reason: done.reason };
7091
7157
  if (!done.changed)
@@ -7099,6 +7165,11 @@ export class Store {
7099
7165
  .run(state, now.toISOString(), id);
7100
7166
  if (Number(changes) === 0)
7101
7167
  return { ok: false, reason: "unknown-task" };
7168
+ if (state === "done" && prior?.["state"] !== "done") {
7169
+ const done = this.lookupRef(id);
7170
+ if (done !== null)
7171
+ this.noteTaskAct(done.id, "completed", now);
7172
+ }
7102
7173
  // The return-to-queue fact (Telegram task updates): a running
7103
7174
  // task its worker released unfinished, or a closed one reopened
7104
7175
  // by the state verb. Marking done or failed by hand rides the
@@ -8475,6 +8546,8 @@ export class Store {
8475
8546
  // (setup review): the approval is what was stale, and it is new.
8476
8547
  const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId);
8477
8548
  if (ref !== undefined) {
8549
+ if (basis === undefined)
8550
+ this.noteTaskAct(Number(ref["id"]), "approved", now);
8478
8551
  this.db.prepare("DELETE FROM hold WHERE owner_kind = 'backoff' AND owner_id = ?").run(`stale:${Number(ref["id"])}`);
8479
8552
  this.db
8480
8553
  .prepare("UPDATE notification SET resolved_at = ? WHERE dedupe_key LIKE ? AND resolved_at IS NULL")
@@ -8488,7 +8561,7 @@ export class Store {
8488
8561
  taskRef: Number(ref["id"]), kind: "scope-approved", identity: `t${Number(ref["id"])}`,
8489
8562
  subject: "Scope approved",
8490
8563
  body: basis === undefined
8491
- ? `Approved by ${lifecycleWords(by, 40)}. A connected worker can take it next.`
8564
+ ? `Approved by ${currentActor()?.lead === true ? "the lead" : lifecycleWords(by, 40)}. A connected worker can take it next.`
8492
8565
  : "Approved automatically under the operating mode. A connected worker can take it next.",
8493
8566
  link: chatControlHref("task", taskId),
8494
8567
  }, now);
@@ -14928,10 +15001,15 @@ export class Store {
14928
15001
  : source !== undefined && "project" in source && source.project !== "" ? "project"
14929
15002
  : source !== undefined && "installation" in source ? "installation" : "unknown";
14930
15003
  const project = scope === "task" ? String(ref["repo"]) : scope === "project" && source !== undefined && "project" in source ? source.project : null;
14931
- const { changes } = this.db
15004
+ const { changes, lastInsertRowid } = this.db
14932
15005
  .prepare(`INSERT OR IGNORE INTO notification (dedupe_key, kind, subject, body, created_at, push_class, link, provenance_scope, project, task_ref, task_id, source_run, recipient)
14933
15006
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
14934
15007
  .run(notification.dedupeKey, notification.kind, notification.subject, notification.body, now.toISOString(), notification.pushClass ?? null, notification.link ?? null, scope, project, ref === undefined ? null : taskRef, ref === undefined ? null : String(ref["external_id"]), run?.id ?? null, notification.recipient ?? null);
15008
+ // Whose act made this fact: pings follow it (see pingAllowed).
15009
+ const actor = currentActor();
15010
+ if (Number(changes) > 0 && actor !== null) {
15011
+ this.db.prepare("INSERT OR IGNORE INTO notification_actor (notification, account, lead) VALUES (?, ?, ?)").run(Number(lastInsertRowid), actor.account, actor.lead ? 1 : 0);
15012
+ }
14935
15013
  return Number(changes) > 0;
14936
15014
  }
14937
15015
  /**
@@ -19161,11 +19239,16 @@ export class Store {
19161
19239
  const version = (current?.version ?? Number(this.db.prepare("SELECT COALESCE(MAX(version), 0) AS v FROM flow_script WHERE repo = ? AND name = ?").get(script.repo, script.name)?.["v"] ?? 0)) + 1;
19162
19240
  if (current !== null)
19163
19241
  this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE id = ?").run(current.id);
19164
- this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file)
19165
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`).run(script.repo, script.name, script.about, script.body, script.timeoutMinutes, version, script.digest, script.by, now.toISOString(), script.language ?? "shell", script.file ?? null);
19242
+ this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
19243
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(script.repo, script.name, script.about, script.body, script.timeoutMinutes, version, script.digest, script.by, now.toISOString(), script.language ?? "shell", script.file ?? null, script.held ?? null);
19166
19244
  return version;
19167
19245
  });
19168
19246
  }
19247
+ /** A person approves a held script (one an imported flow brought): from now on it runs. False when it wasn't held. */
19248
+ approveFlowScript(repo, name) {
19249
+ const { changes } = this.db.prepare("UPDATE flow_script SET held = NULL WHERE repo = ? AND name = ? AND state = 'active' AND held IS NOT NULL").run(repo, name);
19250
+ return Number(changes) === 1;
19251
+ }
19169
19252
  removeFlowScript(repo, name) {
19170
19253
  const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
19171
19254
  return Number(changes) === 1;
@@ -19819,12 +19902,18 @@ export class Store {
19819
19902
  return this.db.prepare("SELECT * FROM chat_turn WHERE mate_turn = ? ORDER BY id ASC").all(mateTurn).map(readChatTurn);
19820
19903
  }
19821
19904
  // ---- projects ------------------------------------------------------------
19822
- /** Remember a project was opened. Upsert keeps added_at; recency always moves. */
19905
+ /** Remember a project was opened. Upsert keeps added_at; recency always moves.
19906
+ * A project added for the first time starts on Quick checks. */
19823
19907
  upsertProject(path, name, now) {
19824
- this.db
19825
- .prepare(`INSERT INTO project (path, name, added_at, last_opened_at) VALUES (?, ?, ?, ?)
19826
- ON CONFLICT (path) DO UPDATE SET name = excluded.name, last_opened_at = excluded.last_opened_at`)
19827
- .run(path, name, now.toISOString(), now.toISOString());
19908
+ this.transact(() => {
19909
+ const added = this.db.prepare("SELECT 1 FROM project WHERE path = ?").get(path) === undefined;
19910
+ this.db
19911
+ .prepare(`INSERT INTO project (path, name, added_at, last_opened_at) VALUES (?, ?, ?, ?)
19912
+ ON CONFLICT (path) DO UPDATE SET name = excluded.name, last_opened_at = excluded.last_opened_at`)
19913
+ .run(path, name, now.toISOString(), now.toISOString());
19914
+ if (added)
19915
+ stampNewProjectLevel(this, path, now);
19916
+ });
19828
19917
  }
19829
19918
  /** Most recently opened first — the opener page's order. */
19830
19919
  listProjects() {
@@ -21182,6 +21271,48 @@ export class Store {
21182
21271
  .get(runner, now.toISOString());
21183
21272
  return Number(row?.["n"] ?? 0);
21184
21273
  }
21274
+ /** The same live claims as liveClaimCount, by the project their task is placed in. */
21275
+ liveClaimCountsByRepo(runner, now) {
21276
+ const rows = this.db
21277
+ .prepare(`SELECT task_ref.repo AS repo, COUNT(*) AS n FROM claim
21278
+ JOIN task_ref ON task_ref.id = claim.task_ref
21279
+ WHERE claim.runner = ? AND claim.released_at IS NULL AND claim.expires_at > ?
21280
+ AND claim.lease_generation = (
21281
+ SELECT MAX(newest.lease_generation) FROM claim AS newest
21282
+ WHERE newest.task_ref = claim.task_ref
21283
+ )
21284
+ AND claim.lease_id NOT IN (SELECT lease_id FROM held_session WHERE ended_at IS NULL)
21285
+ GROUP BY task_ref.repo`)
21286
+ .all(runner, now.toISOString());
21287
+ const counts = new Map();
21288
+ for (const row of rows)
21289
+ if (row["repo"] != null)
21290
+ counts.set(String(row["repo"]), Number(row["n"]));
21291
+ return counts;
21292
+ }
21293
+ /** Every builder's live attempts, by project: what `status` shows against each project's limit. */
21294
+ runningBuildsByRepo(now) {
21295
+ const rows = this.db
21296
+ .prepare(`SELECT task_ref.repo AS repo, COUNT(*) AS n FROM claim
21297
+ JOIN task_ref ON task_ref.id = claim.task_ref
21298
+ WHERE claim.released_at IS NULL AND claim.expires_at > ?
21299
+ AND claim.lease_generation = (
21300
+ SELECT MAX(newest.lease_generation) FROM claim AS newest
21301
+ WHERE newest.task_ref = claim.task_ref
21302
+ )
21303
+ AND claim.lease_id NOT IN (SELECT lease_id FROM held_session WHERE ended_at IS NULL)
21304
+ GROUP BY task_ref.repo`)
21305
+ .all(now.toISOString());
21306
+ const counts = new Map();
21307
+ for (const row of rows)
21308
+ if (row["repo"] != null)
21309
+ counts.set(String(row["repo"]), Number(row["n"]));
21310
+ return counts;
21311
+ }
21312
+ /** A project's build concurrency changed (project-concurrency.ts): the ledger keeps before → after. */
21313
+ recordProjectConcurrency(by, repo, before, after, now) {
21314
+ this.recordPolicy(by, repo, "builds at once", String(before), String(after), now);
21315
+ }
21185
21316
  /** Forward only, and only under the live generation. A stale poller moves nothing. */
21186
21317
  /** v98: an update Telegram pushed, kept until the bridge applies it; false when it's already kept or applied. */
21187
21318
  queueTelegramUpdate(botId, updateId, payload, now) {
@@ -21509,6 +21640,14 @@ export class Store {
21509
21640
  this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_ELSEWHERE}'
21510
21641
  WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
21511
21642
  AND notification IN (SELECT id FROM notification WHERE recipient IS NOT NULL AND recipient <> ?)`).run(destination, binding.approver);
21643
+ // Pings follow responsibility (the lead's work, this person's own act, a muted project): settled here, unsent.
21644
+ for (const raw of this.db.prepare(`SELECT n.* FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
21645
+ 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())) {
21646
+ const row = readNotification(raw);
21647
+ if (!this.pingAllowed(row, binding.approver)) {
21648
+ this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_QUIET}' WHERE notification = ? AND destination = ?`).run(row.id, destination);
21649
+ }
21650
+ }
21512
21651
  if (this.telegramRetryAt(binding.botId) > now.toISOString())
21513
21652
  return [];
21514
21653
  const rows = this.db.prepare(`SELECT n.*, d.claim_generation FROM notification n
@@ -21722,6 +21861,143 @@ export class Store {
21722
21861
  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 < ?)")
21723
21862
  .run(day, account, day).changes) === 1;
21724
21863
  }
21864
+ // ---- pings follow responsibility: the lead, who acted, replacements and muted projects ----
21865
+ /** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
21866
+ mintLeadCredential(owner, by, now) {
21867
+ return this.transact(() => {
21868
+ const stamp = now.toISOString();
21869
+ this.db.prepare("UPDATE lead_credential SET revoked_at = ?, revoked_by = ? WHERE owner = ? AND revoked_at IS NULL").run(stamp, by, owner);
21870
+ const minted = mintLeadToken();
21871
+ this.db.prepare("INSERT INTO lead_credential (id, owner, secret_hash, created_at, created_by) VALUES (?, ?, ?, ?, ?)").run(minted.id, owner, minted.hash, stamp, by);
21872
+ this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: "lead token created", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
21873
+ return { id: minted.id, token: minted.token };
21874
+ });
21875
+ }
21876
+ /** End every live lead token for one person. */
21877
+ revokeLeadCredentials(owner, by, now) {
21878
+ return this.transact(() => {
21879
+ const ended = Number(this.db.prepare("UPDATE lead_credential SET revoked_at = ?, revoked_by = ? WHERE owner = ? AND revoked_at IS NULL").run(now.toISOString(), by, owner).changes);
21880
+ if (ended > 0)
21881
+ this.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: "lead token revoked", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
21882
+ return ended;
21883
+ });
21884
+ }
21885
+ /** Whose lead a presented token is: a live token whose owner is still an approver, or null. */
21886
+ leadFor(presented) {
21887
+ const parsed = parseLeadToken(presented);
21888
+ if (parsed === null)
21889
+ return null;
21890
+ const row = this.db.prepare("SELECT owner, secret_hash FROM lead_credential WHERE id = ? AND revoked_at IS NULL").get(parsed.id);
21891
+ if (row === undefined || !leadSecretMatches(parsed.secret, String(row["secret_hash"])))
21892
+ return null;
21893
+ const owner = String(row["owner"]), account = this.accountOf(owner);
21894
+ return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id: parsed.id };
21895
+ }
21896
+ /** Record who did this to a task, when someone is acting. The lead's acts are also in the ledger as "lead for <owner>". */
21897
+ noteTaskAct(taskRef, act, now, handed, actor = currentActor()) {
21898
+ if (actor === null)
21899
+ return;
21900
+ this.db.prepare("INSERT INTO task_act (task_ref, act, account, lead, person, why, at) VALUES (?, ?, ?, ?, ?, ?, ?)")
21901
+ .run(taskRef, act, actor.account, actor.lead ? 1 : 0, handed?.person ?? null, handed?.why ?? null, now.toISOString());
21902
+ if (!actor.lead)
21903
+ return;
21904
+ const ref = this.db.prepare("SELECT repo, external_id FROM task_ref WHERE id = ?").get(taskRef);
21905
+ this.recordAction({ at: now.toISOString(), actor: actorLabel(actor), repo: ref?.["repo"] == null ? null : String(ref["repo"]), taskId: ref === undefined ? null : String(ref["external_id"]), runId: null,
21906
+ action: act === "asked" ? "task handed to a person" : `task ${act}`, outcome: "recorded", source: "work", detail: handed === undefined ? null : `${handed.person}: ${handed.why}` });
21907
+ }
21908
+ /** Every recorded act on a task, oldest first. */
21909
+ taskActs(taskRef) {
21910
+ return this.db.prepare("SELECT act, account, lead, person, why, at FROM task_act WHERE task_ref = ? ORDER BY id").all(taskRef).map(row => ({
21911
+ act: String(row["act"]), account: String(row["account"]), lead: Number(row["lead"]) === 1,
21912
+ person: row["person"] == null ? null : String(row["person"]), why: row["why"] == null ? null : String(row["why"]), at: String(row["at"])
21913
+ }));
21914
+ }
21915
+ /** Whose lead's work this task is: the last to file, approve or hand it on was that person's lead (null: a person's). */
21916
+ leadWorkOf(taskRef) {
21917
+ const row = this.db.prepare("SELECT account, lead, act FROM task_act WHERE task_ref = ? AND act IN ('filed', 'approved', 'asked') ORDER BY id DESC LIMIT 1").get(taskRef);
21918
+ return row === undefined || Number(row["lead"]) !== 1 || row["act"] === "asked" ? null : String(row["account"]);
21919
+ }
21920
+ /** The lead hands a task to a person: it becomes theirs, and they get one message saying why. */
21921
+ askPerson(taskId, person, why, now) {
21922
+ return this.transact(() => {
21923
+ const ref = this.lookupRef(taskId);
21924
+ if (ref === null)
21925
+ return { ok: false, reason: "unknown-task" };
21926
+ if (ref.repo === null)
21927
+ return { ok: false, reason: "unplaced" };
21928
+ const account = this.accountOf(person);
21929
+ if (account === null || account.revokedAt !== null || !this.accountCanAccess(person, ref.repo))
21930
+ return { ok: false, reason: "unknown-person" };
21931
+ this.noteTaskAct(ref.id, "asked", now, { person, why });
21932
+ const asks = this.taskActs(ref.id).filter(one => one.act === "asked").length;
21933
+ const actor = currentActor();
21934
+ this.enqueueNotification({ dedupeKey: `ask:${ref.id}:${asks}`, kind: "task-ask", pushClass: "attention", recipient: person,
21935
+ subject: `${actor?.lead ? "The lead" : actor?.account ?? "Someone"} needs you: ${lifecycleWords(this.getTask(taskId)?.title ?? taskId, 80)}`,
21936
+ body: lifecycleWords(why, 400), link: chatControlHref("task", taskId), source: { taskRef: ref.id } }, now);
21937
+ return { ok: true };
21938
+ });
21939
+ }
21940
+ /** The task that replaced a cancelled one, or null. */
21941
+ replacementOf(taskId) {
21942
+ const row = this.db.prepare("SELECT r.successor FROM task_replacement r JOIN task_ref t ON t.id = r.task_ref WHERE t.backend = ? AND t.external_id = ?").get(BUILT_IN, taskId);
21943
+ return row === undefined ? null : String(row["successor"]);
21944
+ }
21945
+ /** Every replaced task in one read, for lists: task id → its successor. */
21946
+ replacements() {
21947
+ return new Map(this.db.prepare("SELECT t.external_id, r.successor FROM task_replacement r JOIN task_ref t ON t.id = r.task_ref").all()
21948
+ .map(row => [String(row["external_id"]), String(row["successor"])]));
21949
+ }
21950
+ /** Mute or unmute one project's pings for one person. The console and digest keep everything. */
21951
+ setProjectMuted(account, repo, muted, now) {
21952
+ if (muted)
21953
+ this.db.prepare("INSERT OR IGNORE INTO project_mute (account, repo, at) VALUES (?, ?, ?)").run(account, repo, now.toISOString());
21954
+ else
21955
+ this.db.prepare("DELETE FROM project_mute WHERE account = ? AND repo = ?").run(account, repo);
21956
+ }
21957
+ mutedProjects(account) {
21958
+ return this.db.prepare("SELECT repo FROM project_mute WHERE account = ? ORDER BY repo").all(account).map(row => String(row["repo"]));
21959
+ }
21960
+ /** Whose act made this fact, if anyone's. */
21961
+ notificationActor(id) {
21962
+ const row = this.db.prepare("SELECT account, lead FROM notification_actor WHERE notification = ?").get(id);
21963
+ return row === undefined ? null : { account: String(row["account"]), lead: Number(row["lead"]) === 1 };
21964
+ }
21965
+ /**
21966
+ * Whether this fact may message this person (every chat, phone and browser asks here). Pings follow
21967
+ * responsibility: a security alert always does; nobody is messaged about their own act (or their lead's);
21968
+ * the lead's work messages nobody unless it is handed to a person or fails with nothing left for the lead
21969
+ * to try; and a person's muted project stays quiet. A fact addressed to one person is theirs. The console
21970
+ * and the evening digest keep every fact either way.
21971
+ */
21972
+ pingAllowed(row, account) {
21973
+ if (SECURITY_KINDS.has(row.kind) || row.kind === "evening-digest")
21974
+ return true;
21975
+ if (row.recipient !== null && row.kind === "task-ask")
21976
+ return true;
21977
+ const actor = this.notificationActor(row.id);
21978
+ if (actor !== null && actor.account === account)
21979
+ return false;
21980
+ if (row.recipient !== null)
21981
+ return true;
21982
+ if (this.leadQuiet(row, actor))
21983
+ return false;
21984
+ return row.project === null || this.db.prepare("SELECT 1 AS hit FROM project_mute WHERE account = ? AND repo = ?").get(account, row.project) === undefined;
21985
+ }
21986
+ /** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
21987
+ leadQuiet(row, actor = this.notificationActor(row.id)) {
21988
+ if (SECURITY_KINDS.has(row.kind))
21989
+ return false;
21990
+ const lead = actor?.lead === true || (row.taskRef !== null && this.leadWorkOf(row.taskRef) !== null);
21991
+ return lead && !this.failedForGood(row);
21992
+ }
21993
+ /** A failure with nothing left for the lead to do: its attempts are spent, or the task has failed for good. */
21994
+ failedForGood(row) {
21995
+ if (SPENT_KINDS.has(row.kind))
21996
+ return true;
21997
+ if (!/fail|exhausted/.test(row.kind) || row.taskRef === null)
21998
+ return false;
21999
+ return this.db.prepare("SELECT 1 AS hit FROM task t JOIN task_ref r ON r.external_id = t.id AND r.backend = ? WHERE r.id = ? AND t.state = 'failed'").get(BUILT_IN, row.taskRef) !== undefined;
22000
+ }
21725
22001
  /** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
21726
22002
  * a minute of it (up to eight tasks), or starts its own. */
21727
22003
  chatCardFor(destination, taskRef, filedAt, now) {
@@ -21927,6 +22203,17 @@ export class Store {
21927
22203
  AND push_subscription.retired_at IS NULL
21928
22204
  AND notification.id > push_subscription.starts_after_notification`)
21929
22205
  .run(now.toISOString());
22206
+ // Pings follow responsibility: a pair this person must not be alerted about retires before it is ever sent.
22207
+ if (Number(changes) > 0) {
22208
+ const fresh = this.db.prepare(`SELECT p.id AS pair, s.approver AS approver, n.* FROM push_delivery p
22209
+ JOIN push_subscription s ON s.id = p.subscription JOIN notification n ON n.id = p.notification
22210
+ WHERE p.state = 'pending' AND p.attempts = 0 AND p.created_at = ?`).all(now.toISOString());
22211
+ for (const raw of fresh) {
22212
+ if (!this.pingAllowed(readNotification(raw), String(raw["approver"]))) {
22213
+ this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'quiet' WHERE id = ? AND state = 'pending'").run(Number(raw["pair"]));
22214
+ }
22215
+ }
22216
+ }
21930
22217
  return Number(changes);
21931
22218
  }
21932
22219
  /**
@@ -22247,6 +22534,8 @@ function readHold(row) {
22247
22534
  export const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
22248
22535
  /** v83: a notification for another person, settled for this destination without sending. */
22249
22536
  export const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
22537
+ /** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
22538
+ export const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
22250
22539
  /** A destination receipt (`d` = notification_delivery) that still owes a
22251
22540
  * send: not delivered, and not explicitly skipped as pre-pairing history.
22252
22541
  * Every reader of Telegram receipts that means "undelivered" uses this, so