toolroll 0.9.6 → 0.9.8

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 (102) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +2 -0
  5. package/dist/assignment-adapters.js +2 -0
  6. package/dist/assignment-ui.js +3 -1
  7. package/dist/assignment.d.ts +15 -0
  8. package/dist/assignment.js +80 -16
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +28 -24
  11. package/dist/browser-workspace.d.ts +117 -2
  12. package/dist/build-review.d.ts +38 -0
  13. package/dist/build-review.js +341 -0
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-actions.js +2 -1
  17. package/dist/chat-delivery.js +2 -0
  18. package/dist/chat-quiet.js +11 -1
  19. package/dist/chat-task-actions.d.ts +13 -0
  20. package/dist/chat-task-actions.js +19 -1
  21. package/dist/check-levels-ui.d.ts +26 -0
  22. package/dist/check-levels-ui.js +84 -0
  23. package/dist/check-levels.d.ts +67 -0
  24. package/dist/check-levels.js +171 -0
  25. package/dist/claim.d.ts +22 -0
  26. package/dist/claim.js +43 -0
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +6 -0
  29. package/dist/demo-chat.js +4 -1
  30. package/dist/dispatch.js +9 -3
  31. package/dist/dispose.d.ts +12 -3
  32. package/dist/dispose.js +19 -5
  33. package/dist/exec.d.ts +5 -0
  34. package/dist/exec.js +30 -4
  35. package/dist/export.js +3 -2
  36. package/dist/first-run.d.ts +34 -0
  37. package/dist/first-run.js +37 -1
  38. package/dist/flow-gallery-ui.d.ts +30 -0
  39. package/dist/flow-gallery-ui.js +85 -0
  40. package/dist/flow-gallery.d.ts +111 -0
  41. package/dist/flow-gallery.js +445 -0
  42. package/dist/flow-share.d.ts +102 -0
  43. package/dist/flow-share.js +614 -0
  44. package/dist/flow-starters.d.ts +2 -0
  45. package/dist/flow-starters.js +1 -1
  46. package/dist/flow-steps.js +7 -0
  47. package/dist/flow-triggers.js +2 -0
  48. package/dist/flows-cli.d.ts +56 -0
  49. package/dist/flows-cli.js +109 -3
  50. package/dist/flows-ui.d.ts +15 -1
  51. package/dist/flows-ui.js +31 -6
  52. package/dist/guides.js +27 -2
  53. package/dist/invoke.js +5 -0
  54. package/dist/lead-context.js +6 -2
  55. package/dist/mate-doors.d.ts +0 -15
  56. package/dist/mate-doors.js +5 -1
  57. package/dist/mate-tools.js +9 -4
  58. package/dist/models-ui.js +1 -1
  59. package/dist/modes.d.ts +3 -1
  60. package/dist/modes.js +3 -1
  61. package/dist/operate.d.ts +4 -1
  62. package/dist/operate.js +711 -98
  63. package/dist/process-custody.js +8 -0
  64. package/dist/project-concurrency.d.ts +62 -0
  65. package/dist/project-concurrency.js +116 -0
  66. package/dist/project-delete-ui.d.ts +7 -0
  67. package/dist/project-delete-ui.js +19 -2
  68. package/dist/project-delete.js +4 -0
  69. package/dist/provider.js +16 -0
  70. package/dist/pull-request-flow.d.ts +9 -0
  71. package/dist/pull-request-flow.js +21 -3
  72. package/dist/result-follow-ups.d.ts +101 -0
  73. package/dist/result-follow-ups.js +333 -0
  74. package/dist/retention-ui.d.ts +3 -1
  75. package/dist/retention-ui.js +4 -4
  76. package/dist/retention.d.ts +13 -6
  77. package/dist/retention.js +50 -12
  78. package/dist/review-switch.d.ts +41 -0
  79. package/dist/review-switch.js +88 -0
  80. package/dist/reviewer.d.ts +24 -2
  81. package/dist/reviewer.js +49 -1
  82. package/dist/scope.js +11 -0
  83. package/dist/serve.d.ts +211 -2
  84. package/dist/serve.js +1038 -145
  85. package/dist/store.d.ts +112 -4
  86. package/dist/store.js +447 -27
  87. package/dist/surface.js +36 -9
  88. package/dist/tailnet.d.ts +11 -0
  89. package/dist/tailnet.js +47 -0
  90. package/dist/task-outcome-cli.js +2 -2
  91. package/dist/task-status.d.ts +13 -3
  92. package/dist/task-status.js +38 -16
  93. package/dist/team-cli.js +3 -0
  94. package/dist/telegram-status.js +2 -2
  95. package/dist/verification-evidence.js +21 -1
  96. package/dist/webhooks.js +5 -0
  97. package/dist/work-index.js +39 -6
  98. package/dist/work-summary.d.ts +0 -4
  99. package/dist/work-summary.js +4 -1
  100. package/dist/workspace-ui.d.ts +6 -1
  101. package/dist/workspace-ui.js +14 -11
  102. package/package.json +1 -1
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";
@@ -65,8 +67,9 @@ import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
65
67
  import { RECIPE_SCHEMA } from "./recipes.js";
66
68
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, attendedRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
67
69
  import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
70
+ import { REVIEW_SCHEMA, reviewSwitchWords } from "./review-switch.js";
68
71
  import { SPEND_SCHEMA, billingOf, budgetStates, canPrice, claudeMachineBilling, countsToward, filersOf, monthOf, priceWork, seenBilling as seenBillingOf, spendItems, teammateFilers, usd } from "./spend.js";
69
- import { FOREVER, RETENTION_SCHEMA, periodWords } from "./retention.js";
72
+ import { DEFAULT_PERIODS, RETENTION_SCHEMA, periodWords, widenRetentionSchema } from "./retention.js";
70
73
  import { CHECKOUT_CLEANUP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storage.js";
71
74
  import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
72
75
  import { NO_RULES, filerFor, isProtectedWork, protectedChanges, rulesWords } from "./approval-policy.js";
@@ -164,6 +167,54 @@ CREATE TABLE IF NOT EXISTS chat_card_task (
164
167
  );
165
168
  CREATE INDEX IF NOT EXISTS chat_card_task_card ON chat_card_task(card);
166
169
  `;
170
+ /** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
171
+ * for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
172
+ * (`lead` 1: the owner's lead; `person`/`why`: who the lead asked, and why). `notification_actor` is whose act made
173
+ * a fact. `task_replacement` is the task that replaced a cancelled one. `project_mute` is a person's muted projects. */
174
+ const LEAD_QUIET_SCHEMA = `
175
+ CREATE TABLE IF NOT EXISTS lead_credential (
176
+ id TEXT PRIMARY KEY,
177
+ owner TEXT NOT NULL,
178
+ secret_hash TEXT NOT NULL,
179
+ created_at TEXT NOT NULL,
180
+ created_by TEXT NOT NULL,
181
+ revoked_at TEXT,
182
+ revoked_by TEXT
183
+ );
184
+ CREATE INDEX IF NOT EXISTS lead_credential_owner ON lead_credential (owner);
185
+ CREATE TABLE IF NOT EXISTS task_act (
186
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
187
+ task_ref INTEGER NOT NULL,
188
+ act TEXT NOT NULL CHECK (act IN ('filed', 'approved', 'cancelled', 'completed', 'asked')),
189
+ account TEXT NOT NULL,
190
+ lead INTEGER NOT NULL DEFAULT 0 CHECK (lead IN (0, 1)),
191
+ person TEXT,
192
+ why TEXT,
193
+ at TEXT NOT NULL
194
+ );
195
+ CREATE INDEX IF NOT EXISTS task_act_task ON task_act (task_ref, id);
196
+ CREATE TABLE IF NOT EXISTS notification_actor (
197
+ notification INTEGER PRIMARY KEY REFERENCES notification(id) ON DELETE CASCADE,
198
+ account TEXT NOT NULL,
199
+ lead INTEGER NOT NULL DEFAULT 0 CHECK (lead IN (0, 1))
200
+ );
201
+ CREATE TABLE IF NOT EXISTS task_replacement (
202
+ task_ref INTEGER PRIMARY KEY,
203
+ successor TEXT NOT NULL,
204
+ by TEXT,
205
+ at TEXT NOT NULL
206
+ );
207
+ CREATE TABLE IF NOT EXISTS project_mute (
208
+ account TEXT NOT NULL,
209
+ repo TEXT NOT NULL,
210
+ at TEXT NOT NULL,
211
+ PRIMARY KEY (account, repo)
212
+ );
213
+ `;
214
+ /** What still pings on the lead's own work: a security alert. */
215
+ const SECURITY_KINDS = new Set(["secret-detected", "security-release"]);
216
+ /** A failure that leaves nothing for the lead to try: the attempts are spent. */
217
+ const SPENT_KINDS = new Set(["attempts-exhausted", "plan-attempts-exhausted"]);
167
218
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
168
219
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
169
220
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -450,7 +501,8 @@ function readFlowRow(row) {
450
501
  function readFlowScriptRow(row) {
451
502
  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
503
  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"]) };
504
+ language: row["language"] === "python" || row["language"] === "node" ? row["language"] : "shell", file: row["file"] === null || row["file"] === undefined ? null : String(row["file"]),
505
+ held: row["held"] === null || row["held"] === undefined ? null : String(row["held"]) };
454
506
  }
455
507
  function readFlowStepRunRow(row) {
456
508
  const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
@@ -3750,13 +3802,17 @@ function initializeStore(db, file) {
3750
3802
  db.exec(PROVIDER_AUTH_SCHEMA);
3751
3803
  // The sign-in pause the dispatch gate last left this task waiting on (null: none), for the work index.
3752
3804
  addColumn(db, "task_ref", "auth_wait_pause", "INTEGER REFERENCES provider_auth_pause(id)");
3805
+ db.exec(REVIEW_SCHEMA);
3753
3806
  db.exec(RETENTION_SCHEMA);
3807
+ // 1-day evidence: an older file's retention_setting only allowed 7 days or more (no version bump: its rows carry over).
3808
+ widenRetentionSchema(db);
3754
3809
  db.exec(CHECKOUT_CLEANUP_SCHEMA);
3755
3810
  db.exec(BACKUP_SCHEMA);
3756
3811
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
3757
3812
  db.exec(POLICY_SCHEMA);
3758
3813
  db.exec(RUN_CHECK_SCHEMA);
3759
3814
  db.exec(QUIET_CHAT_SCHEMA);
3815
+ db.exec(LEAD_QUIET_SCHEMA);
3760
3816
  mergeCheckTables(db);
3761
3817
  addColumn(db, "monitoring_status", "target", "TEXT");
3762
3818
  // v105: how a teammate's turn was billed (this computer's Claude sign-in, as last seen).
@@ -3778,6 +3834,8 @@ function initializeStore(db, file) {
3778
3834
  // v90: scripts in Python and Node, and scripts that run a file in the project.
3779
3835
  addColumn(db, "flow_script", "language", "TEXT NOT NULL DEFAULT 'shell'");
3780
3836
  addColumn(db, "flow_script", "file", "TEXT");
3837
+ // A script that came in with an imported flow waits for a person to approve it before it runs (no version bump: additive only).
3838
+ addColumn(db, "flow_script", "held", "TEXT");
3781
3839
  addColumn(db, "notification", "recipient", "TEXT");
3782
3840
  addColumn(db, "approver", "projects_json", "TEXT");
3783
3841
  addColumn(db, "invite", "projects_json", "TEXT");
@@ -6942,6 +7000,8 @@ export class Store {
6942
7000
  const earlier = this.db.prepare("SELECT 1 AS hit FROM task_ref WHERE external_id = ?").get(spec.id) !== undefined;
6943
7001
  // Created here, so it is ours — the one place that is true by construction.
6944
7002
  const ref = this.refFor(BUILT_IN, spec.id, "ours");
7003
+ // Who filed it, when someone is acting: the lead's work pings nobody.
7004
+ this.noteTaskAct(ref.id, "filed", now);
6945
7005
  if (earlier)
6946
7006
  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
7007
  // v102: who filed it, set once with the filing (separation of duties reads it; nothing changes it).
@@ -7006,10 +7066,10 @@ export class Store {
7006
7066
  * they admit from (`null` = any). It returns raw changes; callers keep
7007
7067
  * their own refusal words and wake bumps.
7008
7068
  */
7009
- applyCancellation(taskId, reason, now, admittedFrom) {
7010
- return this.transact(() => this.applyCancellationLocked(taskId, reason, now, admittedFrom));
7069
+ applyCancellation(taskId, reason, now, admittedFrom, replacedBy) {
7070
+ return this.transact(() => this.applyCancellationLocked(taskId, reason, now, admittedFrom, replacedBy));
7011
7071
  }
7012
- applyCancellationLocked(taskId, reason, now, admittedFrom) {
7072
+ applyCancellationLocked(taskId, reason, now, admittedFrom, replacedBy) {
7013
7073
  const ref = this.db
7014
7074
  .prepare("SELECT id, coordinator_cid FROM task_ref WHERE backend = ? AND external_id = ?")
7015
7075
  .get(BUILT_IN, taskId);
@@ -7035,6 +7095,14 @@ export class Store {
7035
7095
  .run(now.toISOString(), taskId, ...(admittedFrom ?? []));
7036
7096
  if (Number(changes) === 0)
7037
7097
  return { changed: false };
7098
+ // Replaced: the successor is part of the cancellation, so every surface says "Replaced by <id>".
7099
+ if (ref !== undefined && replacedBy !== undefined) {
7100
+ this.db.prepare(`INSERT INTO task_replacement (task_ref, successor, by, at) VALUES (?, ?, ?, ?)
7101
+ ON CONFLICT (task_ref) DO UPDATE SET successor = excluded.successor, by = excluded.by, at = excluded.at`)
7102
+ .run(Number(ref["id"]), replacedBy, currentActor() === null ? null : actorLabel(currentActor()), now.toISOString());
7103
+ }
7104
+ if (ref !== undefined && prior?.["state"] !== "cancelled")
7105
+ this.noteTaskAct(Number(ref["id"]), "cancelled", now);
7038
7106
  // A cancelled task's pending steering settles superseded in the SAME
7039
7107
  // transaction, whichever road cancelled it — a note silently waiting
7040
7108
  // for a build that can no longer happen is wrong on every road, not
@@ -7058,18 +7126,20 @@ export class Store {
7058
7126
  if (ref !== undefined && prior?.["state"] !== "cancelled") {
7059
7127
  this.noteLifecycle({
7060
7128
  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),
7129
+ subject: replacedBy === undefined ? "Cancelled" : `Replaced by ${replacedBy}`,
7130
+ body: replacedBy !== undefined
7131
+ ? `${replacedBy} replaces it. Nothing more runs for it.`
7132
+ : reason.kind === "operator"
7133
+ ? `${cancellationWords(reason.text)} Nothing more runs for it.`
7134
+ : reason.code === "mirror-latched"
7135
+ ? "The tracker closed it. Nothing more runs for it."
7136
+ : "The tracker closed it while it was being built, so the finished work was not accepted.",
7137
+ link: chatControlHref("task", replacedBy ?? taskId),
7068
7138
  }, now);
7069
7139
  }
7070
7140
  return { changed: true };
7071
7141
  }
7072
- setTaskState(id, state, now, mutation = {}, cancellationReason) {
7142
+ setTaskState(id, state, now, mutation = {}, cancellationReason, replacedBy) {
7073
7143
  return this.once(mutation, "setTaskState", () => {
7074
7144
  // The terminal guard lives IN the primitive so every present and
7075
7145
  // future caller is covered (external dispatch, finding 23): a
@@ -7085,7 +7155,7 @@ export class Store {
7085
7155
  }
7086
7156
  return this.transact(() => {
7087
7157
  if (state === "cancelled") {
7088
- const done = this.applyCancellation(id, { kind: "operator", text: cancellationReason ?? null }, now, null);
7158
+ const done = this.applyCancellation(id, { kind: "operator", text: cancellationReason ?? null }, now, null, replacedBy);
7089
7159
  if (done.reason !== undefined)
7090
7160
  return { ok: false, reason: done.reason };
7091
7161
  if (!done.changed)
@@ -7099,6 +7169,11 @@ export class Store {
7099
7169
  .run(state, now.toISOString(), id);
7100
7170
  if (Number(changes) === 0)
7101
7171
  return { ok: false, reason: "unknown-task" };
7172
+ if (state === "done" && prior?.["state"] !== "done") {
7173
+ const done = this.lookupRef(id);
7174
+ if (done !== null)
7175
+ this.noteTaskAct(done.id, "completed", now);
7176
+ }
7102
7177
  // The return-to-queue fact (Telegram task updates): a running
7103
7178
  // task its worker released unfinished, or a closed one reopened
7104
7179
  // by the state verb. Marking done or failed by hand rides the
@@ -8475,6 +8550,8 @@ export class Store {
8475
8550
  // (setup review): the approval is what was stale, and it is new.
8476
8551
  const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId);
8477
8552
  if (ref !== undefined) {
8553
+ if (basis === undefined)
8554
+ this.noteTaskAct(Number(ref["id"]), "approved", now);
8478
8555
  this.db.prepare("DELETE FROM hold WHERE owner_kind = 'backoff' AND owner_id = ?").run(`stale:${Number(ref["id"])}`);
8479
8556
  this.db
8480
8557
  .prepare("UPDATE notification SET resolved_at = ? WHERE dedupe_key LIKE ? AND resolved_at IS NULL")
@@ -8488,7 +8565,7 @@ export class Store {
8488
8565
  taskRef: Number(ref["id"]), kind: "scope-approved", identity: `t${Number(ref["id"])}`,
8489
8566
  subject: "Scope approved",
8490
8567
  body: basis === undefined
8491
- ? `Approved by ${lifecycleWords(by, 40)}. A connected worker can take it next.`
8568
+ ? `Approved by ${currentActor()?.lead === true ? "the lead" : lifecycleWords(by, 40)}. A connected worker can take it next.`
8492
8569
  : "Approved automatically under the operating mode. A connected worker can take it next.",
8493
8570
  link: chatControlHref("task", taskId),
8494
8571
  }, now);
@@ -9260,6 +9337,27 @@ export class Store {
9260
9337
  return;
9261
9338
  this.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action, outcome, source: "policy", detail: `${before} → ${after}` });
9262
9339
  }
9340
+ /** The project's automatic review switch: its explicit setting, else on
9341
+ * while a hands-off mode is active on it, else off. */
9342
+ reviewSwitch(repo, now) {
9343
+ const row = this.db.prepare("SELECT enabled, changed_by, changed_at FROM review_switch WHERE repo = ?").get(repo);
9344
+ if (row !== undefined)
9345
+ return { on: Number(row["enabled"]) === 1, source: "project", changedBy: String(row["changed_by"]), changedAt: String(row["changed_at"]) };
9346
+ const handsOff = this.activeMode(repo, now)?.name === "hands-off";
9347
+ return { on: handsOff, source: handsOff ? "hands-off" : "default", changedBy: null, changedAt: null };
9348
+ }
9349
+ /** Turn a project's automatic review on or off; the policy log keeps before → after. */
9350
+ setReviewSwitch(repo, on, by, now) {
9351
+ return this.transact(() => {
9352
+ const before = this.reviewSwitch(repo, now);
9353
+ this.db.prepare(`INSERT INTO review_switch (repo, enabled, changed_by, changed_at) VALUES (?, ?, ?, ?)
9354
+ ON CONFLICT(repo) DO UPDATE SET enabled = excluded.enabled, changed_by = excluded.changed_by, changed_at = excluded.changed_at`)
9355
+ .run(repo, on ? 1 : 0, by, now.toISOString());
9356
+ const after = this.reviewSwitch(repo, now);
9357
+ this.recordPolicy(by, repo, "automatic review", reviewSwitchWords(before), reviewSwitchWords(after), now);
9358
+ return { before, after };
9359
+ });
9360
+ }
9263
9361
  /** A project deleted (project-delete.ts): who, and what it held → how it went. Returns the entry's id. */
9264
9362
  recordProjectDeleted(by, repo, held, how, now) {
9265
9363
  this.recordPolicy(by, repo, "project deleted", held, how, now, "deleted");
@@ -14928,10 +15026,15 @@ export class Store {
14928
15026
  : source !== undefined && "project" in source && source.project !== "" ? "project"
14929
15027
  : source !== undefined && "installation" in source ? "installation" : "unknown";
14930
15028
  const project = scope === "task" ? String(ref["repo"]) : scope === "project" && source !== undefined && "project" in source ? source.project : null;
14931
- const { changes } = this.db
15029
+ const { changes, lastInsertRowid } = this.db
14932
15030
  .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
15031
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
14934
15032
  .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);
15033
+ // Whose act made this fact: pings follow it (see pingAllowed).
15034
+ const actor = currentActor();
15035
+ if (Number(changes) > 0 && actor !== null) {
15036
+ this.db.prepare("INSERT OR IGNORE INTO notification_actor (notification, account, lead) VALUES (?, ?, ?)").run(Number(lastInsertRowid), actor.account, actor.lead ? 1 : 0);
15037
+ }
14935
15038
  return Number(changes) > 0;
14936
15039
  }
14937
15040
  /**
@@ -16275,13 +16378,17 @@ export class Store {
16275
16378
  });
16276
16379
  }
16277
16380
  // ---- Retention (v105) -----------------------------------------------------
16278
- /** How long each kind of data is kept; null is forever (every kind, until someone chooses). */
16381
+ /** How long each kind of data is kept; null is forever. A kind nobody chose keeps its default (evidence 28 days, the rest forever). */
16279
16382
  retentionPeriods() {
16280
- const periods = { ...FOREVER };
16383
+ return { ...DEFAULT_PERIODS, ...this.retentionChosen() };
16384
+ }
16385
+ /** The periods someone chose: only kinds with a saved row. */
16386
+ retentionChosen() {
16387
+ const chosen = {};
16281
16388
  for (const row of this.db.prepare("SELECT kind, days FROM retention_setting").all()) {
16282
- periods[String(row["kind"])] = row["days"] === null ? null : Number(row["days"]);
16389
+ chosen[String(row["kind"])] = row["days"] === null ? null : Number(row["days"]);
16283
16390
  }
16284
- return periods;
16391
+ return chosen;
16285
16392
  }
16286
16393
  /** Set how long one kind is kept (null: forever); the ledger keeps before → after. */
16287
16394
  setRetentionPeriod(kind, days, by, now) {
@@ -19161,11 +19268,16 @@ export class Store {
19161
19268
  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
19269
  if (current !== null)
19163
19270
  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);
19271
+ this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
19272
+ 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
19273
  return version;
19167
19274
  });
19168
19275
  }
19276
+ /** A person approves a held script (one an imported flow brought): from now on it runs. False when it wasn't held. */
19277
+ approveFlowScript(repo, name) {
19278
+ 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);
19279
+ return Number(changes) === 1;
19280
+ }
19169
19281
  removeFlowScript(repo, name) {
19170
19282
  const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
19171
19283
  return Number(changes) === 1;
@@ -19819,12 +19931,18 @@ export class Store {
19819
19931
  return this.db.prepare("SELECT * FROM chat_turn WHERE mate_turn = ? ORDER BY id ASC").all(mateTurn).map(readChatTurn);
19820
19932
  }
19821
19933
  // ---- projects ------------------------------------------------------------
19822
- /** Remember a project was opened. Upsert keeps added_at; recency always moves. */
19934
+ /** Remember a project was opened. Upsert keeps added_at; recency always moves.
19935
+ * A project added for the first time starts on Quick checks. */
19823
19936
  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());
19937
+ this.transact(() => {
19938
+ const added = this.db.prepare("SELECT 1 FROM project WHERE path = ?").get(path) === undefined;
19939
+ this.db
19940
+ .prepare(`INSERT INTO project (path, name, added_at, last_opened_at) VALUES (?, ?, ?, ?)
19941
+ ON CONFLICT (path) DO UPDATE SET name = excluded.name, last_opened_at = excluded.last_opened_at`)
19942
+ .run(path, name, now.toISOString(), now.toISOString());
19943
+ if (added)
19944
+ stampNewProjectLevel(this, path, now);
19945
+ });
19828
19946
  }
19829
19947
  /** Most recently opened first — the opener page's order. */
19830
19948
  listProjects() {
@@ -20971,6 +21089,108 @@ export class Store {
20971
21089
  this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND pid IS NULL AND ((containment IS NULL AND container IS NULL) OR container_empty_at IS NOT NULL)")
20972
21090
  .run(now.toISOString(), witness);
20973
21091
  }
21092
+ /** The reconcile road for a pid-less witness a crash or a thrown spawn
21093
+ * left behind: once its run has finished, no live child is tracked for it
21094
+ * here, every other witness of the run has exited and none of the run's
21095
+ * process groups is alive, it settles as never started, with a ledger
21096
+ * entry. A sole pid-less witness has no group to prove gone and stays for
21097
+ * `run settle`. Native witnesses keep their OS-only road. */
21098
+ settleUnspawnedWitnesses(now) {
21099
+ const candidates = this.db.prepare(`SELECT DISTINCT p.run FROM run_process p JOIN run r ON r.id = p.run
21100
+ WHERE p.exited_at IS NULL AND p.pid IS NULL AND p.containment IS NULL AND p.container IS NULL
21101
+ AND p.host = ? AND r.outcome IS NOT NULL AND r.finished_at IS NOT NULL ORDER BY p.run`).all(hostname());
21102
+ let settled = 0;
21103
+ for (const candidate of candidates) {
21104
+ const runId = Number(candidate["run"]);
21105
+ this.recordRunProcessExits(runId, now);
21106
+ settled += this.transact(() => {
21107
+ const run = this.getRun(runId);
21108
+ if (run === null || run.outcome === null)
21109
+ return 0;
21110
+ if (ownedProcessCount(runOwnerTag(this, runId)) > 0)
21111
+ return 0;
21112
+ const held = this.heldSessionOf(runId);
21113
+ if (held !== null && held.endedAt === null)
21114
+ return 0;
21115
+ const rows = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(runId);
21116
+ const pidless = rows.filter(row => row["exited_at"] === null && row["pid"] === null && row["containment"] === null && row["container"] === null && row["host"] === hostname());
21117
+ const others = rows.filter(row => !pidless.includes(row));
21118
+ if (pidless.length === 0 || others.length === 0)
21119
+ return 0;
21120
+ if (others.some(row => row["exited_at"] === null))
21121
+ return 0;
21122
+ // Exit was recorded once; the group's members are asked again now.
21123
+ const groups = others.filter(row => row["pid"] !== null && row["host"] === hostname());
21124
+ if (groups.length === 0 || groups.some(row => processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt })))
21125
+ return 0;
21126
+ const ref = this.refById(run.taskRef);
21127
+ let count = 0;
21128
+ for (const row of pidless) {
21129
+ const changed = this.db.prepare(`UPDATE run_process SET exited_at = ?
21130
+ WHERE id = ? AND run = ? AND pid IS NULL AND exited_at IS NULL AND containment IS NULL AND container IS NULL`)
21131
+ .run(now.toISOString(), row["id"], runId);
21132
+ if (Number(changed.changes) !== 1)
21133
+ continue;
21134
+ count++;
21135
+ this.recordAction({ at: now.toISOString(), actor: "system", repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId,
21136
+ action: "process witness settled", outcome: "never started", source: "work",
21137
+ detail: `witness ${String(row["id"])}: no pid; the run finished, every other witness exited and none of its process groups is alive` });
21138
+ }
21139
+ return count;
21140
+ });
21141
+ }
21142
+ return settled;
21143
+ }
21144
+ /** `run settle` (an approver's last resort): every witness of a finished
21145
+ * run that cannot be proven either way is recorded settled under the
21146
+ * approver's reason, in the ledger. Refuses while anything of the run is
21147
+ * alive or could be checked only from another host. Never signals. */
21148
+ settleRunWitnessesByApprover(args, now) {
21149
+ return this.transact(() => {
21150
+ const run = this.getRun(args.runId);
21151
+ if (run === null)
21152
+ return { ok: false, reason: "unknown-run", detail: `no run #${args.runId}` };
21153
+ if (run.outcome === null)
21154
+ return { ok: false, reason: "still-running", detail: `run #${args.runId} is still open` };
21155
+ const alive = (detail) => ({ ok: false, reason: "alive", detail });
21156
+ if (ownedProcessCount(runOwnerTag(this, args.runId)) > 0)
21157
+ return alive(`run #${args.runId} still has an owned subprocess`);
21158
+ const held = this.heldSessionOf(args.runId);
21159
+ if (held !== null && held.endedAt === null)
21160
+ return alive(`run #${args.runId}'s held supervisor has not finished shutdown`);
21161
+ const rows = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(args.runId);
21162
+ const open = rows.filter(row => row["exited_at"] === null);
21163
+ for (const row of rows) {
21164
+ if (row["pid"] === null)
21165
+ continue;
21166
+ if (row["host"] !== hostname()) {
21167
+ if (row["exited_at"] === null)
21168
+ return alive(`run #${args.runId}'s process ${String(row["pid"])} belongs to ${String(row["host"])}; settle it there`);
21169
+ continue;
21170
+ }
21171
+ if (processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt }))
21172
+ return alive(`run #${args.runId}'s process ${String(row["pid"])} is still running`);
21173
+ }
21174
+ for (const row of open) {
21175
+ if (row["pid"] === null && row["host"] !== hostname())
21176
+ return alive(`run #${args.runId} has a witness from ${String(row["host"])}; settle it there`);
21177
+ if (row["containment"] != null && row["container"] != null && row["container_empty_at"] == null &&
21178
+ containerEmptiness(String(row["containment"]), String(row["container"]), process.platform, row["container_identity"] == null ? null : String(row["container_identity"])) === "populated") {
21179
+ return alive(`run #${args.runId}'s ${String(row["containment"])} object ${String(row["container"])} still has members`);
21180
+ }
21181
+ }
21182
+ if (open.length === 0)
21183
+ return { ok: true, witnesses: [], repeated: true };
21184
+ const ids = open.map(row => Number(row["id"]));
21185
+ for (const id of ids)
21186
+ this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND run = ? AND exited_at IS NULL").run(now.toISOString(), id, args.runId);
21187
+ const ref = this.refById(run.taskRef);
21188
+ this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
21189
+ action: "process witness settled by approver", outcome: `witness ${ids.join(", ")}`, source: "request", detail: args.why });
21190
+ this.settleQuiescentStops(now);
21191
+ return { ok: true, witnesses: ids, repeated: false };
21192
+ });
21193
+ }
20974
21194
  recordRunProcess(runId, pid, now, group = true, witness) {
20975
21195
  if (!Number.isSafeInteger(pid) || pid <= 0)
20976
21196
  throw new Error("a process witness needs a valid spawned PID");
@@ -21182,6 +21402,48 @@ export class Store {
21182
21402
  .get(runner, now.toISOString());
21183
21403
  return Number(row?.["n"] ?? 0);
21184
21404
  }
21405
+ /** The same live claims as liveClaimCount, by the project their task is placed in. */
21406
+ liveClaimCountsByRepo(runner, now) {
21407
+ const rows = this.db
21408
+ .prepare(`SELECT task_ref.repo AS repo, COUNT(*) AS n FROM claim
21409
+ JOIN task_ref ON task_ref.id = claim.task_ref
21410
+ WHERE claim.runner = ? AND claim.released_at IS NULL AND claim.expires_at > ?
21411
+ AND claim.lease_generation = (
21412
+ SELECT MAX(newest.lease_generation) FROM claim AS newest
21413
+ WHERE newest.task_ref = claim.task_ref
21414
+ )
21415
+ AND claim.lease_id NOT IN (SELECT lease_id FROM held_session WHERE ended_at IS NULL)
21416
+ GROUP BY task_ref.repo`)
21417
+ .all(runner, now.toISOString());
21418
+ const counts = new Map();
21419
+ for (const row of rows)
21420
+ if (row["repo"] != null)
21421
+ counts.set(String(row["repo"]), Number(row["n"]));
21422
+ return counts;
21423
+ }
21424
+ /** Every builder's live attempts, by project: what `status` shows against each project's limit. */
21425
+ runningBuildsByRepo(now) {
21426
+ const rows = this.db
21427
+ .prepare(`SELECT task_ref.repo AS repo, COUNT(*) AS n FROM claim
21428
+ JOIN task_ref ON task_ref.id = claim.task_ref
21429
+ WHERE claim.released_at IS NULL AND claim.expires_at > ?
21430
+ AND claim.lease_generation = (
21431
+ SELECT MAX(newest.lease_generation) FROM claim AS newest
21432
+ WHERE newest.task_ref = claim.task_ref
21433
+ )
21434
+ AND claim.lease_id NOT IN (SELECT lease_id FROM held_session WHERE ended_at IS NULL)
21435
+ GROUP BY task_ref.repo`)
21436
+ .all(now.toISOString());
21437
+ const counts = new Map();
21438
+ for (const row of rows)
21439
+ if (row["repo"] != null)
21440
+ counts.set(String(row["repo"]), Number(row["n"]));
21441
+ return counts;
21442
+ }
21443
+ /** A project's build concurrency changed (project-concurrency.ts): the ledger keeps before → after. */
21444
+ recordProjectConcurrency(by, repo, before, after, now) {
21445
+ this.recordPolicy(by, repo, "builds at once", String(before), String(after), now);
21446
+ }
21185
21447
  /** Forward only, and only under the live generation. A stale poller moves nothing. */
21186
21448
  /** v98: an update Telegram pushed, kept until the bridge applies it; false when it's already kept or applied. */
21187
21449
  queueTelegramUpdate(botId, updateId, payload, now) {
@@ -21509,6 +21771,14 @@ export class Store {
21509
21771
  this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_ELSEWHERE}'
21510
21772
  WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
21511
21773
  AND notification IN (SELECT id FROM notification WHERE recipient IS NOT NULL AND recipient <> ?)`).run(destination, binding.approver);
21774
+ // Pings follow responsibility (the lead's work, this person's own act, a muted project): settled here, unsent.
21775
+ for (const raw of this.db.prepare(`SELECT n.* FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
21776
+ 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())) {
21777
+ const row = readNotification(raw);
21778
+ if (!this.pingAllowed(row, binding.approver)) {
21779
+ this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_QUIET}' WHERE notification = ? AND destination = ?`).run(row.id, destination);
21780
+ }
21781
+ }
21512
21782
  if (this.telegramRetryAt(binding.botId) > now.toISOString())
21513
21783
  return [];
21514
21784
  const rows = this.db.prepare(`SELECT n.*, d.claim_generation FROM notification n
@@ -21722,6 +21992,143 @@ export class Store {
21722
21992
  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
21993
  .run(day, account, day).changes) === 1;
21724
21994
  }
21995
+ // ---- pings follow responsibility: the lead, who acted, replacements and muted projects ----
21996
+ /** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
21997
+ mintLeadCredential(owner, by, now) {
21998
+ return this.transact(() => {
21999
+ const stamp = now.toISOString();
22000
+ this.db.prepare("UPDATE lead_credential SET revoked_at = ?, revoked_by = ? WHERE owner = ? AND revoked_at IS NULL").run(stamp, by, owner);
22001
+ const minted = mintLeadToken();
22002
+ this.db.prepare("INSERT INTO lead_credential (id, owner, secret_hash, created_at, created_by) VALUES (?, ?, ?, ?, ?)").run(minted.id, owner, minted.hash, stamp, by);
22003
+ this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: "lead token created", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
22004
+ return { id: minted.id, token: minted.token };
22005
+ });
22006
+ }
22007
+ /** End every live lead token for one person. */
22008
+ revokeLeadCredentials(owner, by, now) {
22009
+ return this.transact(() => {
22010
+ 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);
22011
+ if (ended > 0)
22012
+ 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}` });
22013
+ return ended;
22014
+ });
22015
+ }
22016
+ /** Whose lead a presented token is: a live token whose owner is still an approver, or null. */
22017
+ leadFor(presented) {
22018
+ const parsed = parseLeadToken(presented);
22019
+ if (parsed === null)
22020
+ return null;
22021
+ const row = this.db.prepare("SELECT owner, secret_hash FROM lead_credential WHERE id = ? AND revoked_at IS NULL").get(parsed.id);
22022
+ if (row === undefined || !leadSecretMatches(parsed.secret, String(row["secret_hash"])))
22023
+ return null;
22024
+ const owner = String(row["owner"]), account = this.accountOf(owner);
22025
+ return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id: parsed.id };
22026
+ }
22027
+ /** Record who did this to a task, when someone is acting. The lead's acts are also in the ledger as "lead for <owner>". */
22028
+ noteTaskAct(taskRef, act, now, handed, actor = currentActor()) {
22029
+ if (actor === null)
22030
+ return;
22031
+ this.db.prepare("INSERT INTO task_act (task_ref, act, account, lead, person, why, at) VALUES (?, ?, ?, ?, ?, ?, ?)")
22032
+ .run(taskRef, act, actor.account, actor.lead ? 1 : 0, handed?.person ?? null, handed?.why ?? null, now.toISOString());
22033
+ if (!actor.lead)
22034
+ return;
22035
+ const ref = this.db.prepare("SELECT repo, external_id FROM task_ref WHERE id = ?").get(taskRef);
22036
+ 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,
22037
+ action: act === "asked" ? "task handed to a person" : `task ${act}`, outcome: "recorded", source: "work", detail: handed === undefined ? null : `${handed.person}: ${handed.why}` });
22038
+ }
22039
+ /** Every recorded act on a task, oldest first. */
22040
+ taskActs(taskRef) {
22041
+ return this.db.prepare("SELECT act, account, lead, person, why, at FROM task_act WHERE task_ref = ? ORDER BY id").all(taskRef).map(row => ({
22042
+ act: String(row["act"]), account: String(row["account"]), lead: Number(row["lead"]) === 1,
22043
+ person: row["person"] == null ? null : String(row["person"]), why: row["why"] == null ? null : String(row["why"]), at: String(row["at"])
22044
+ }));
22045
+ }
22046
+ /** 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). */
22047
+ leadWorkOf(taskRef) {
22048
+ 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);
22049
+ return row === undefined || Number(row["lead"]) !== 1 || row["act"] === "asked" ? null : String(row["account"]);
22050
+ }
22051
+ /** The lead hands a task to a person: it becomes theirs, and they get one message saying why. */
22052
+ askPerson(taskId, person, why, now) {
22053
+ return this.transact(() => {
22054
+ const ref = this.lookupRef(taskId);
22055
+ if (ref === null)
22056
+ return { ok: false, reason: "unknown-task" };
22057
+ if (ref.repo === null)
22058
+ return { ok: false, reason: "unplaced" };
22059
+ const account = this.accountOf(person);
22060
+ if (account === null || account.revokedAt !== null || !this.accountCanAccess(person, ref.repo))
22061
+ return { ok: false, reason: "unknown-person" };
22062
+ this.noteTaskAct(ref.id, "asked", now, { person, why });
22063
+ const asks = this.taskActs(ref.id).filter(one => one.act === "asked").length;
22064
+ const actor = currentActor();
22065
+ this.enqueueNotification({ dedupeKey: `ask:${ref.id}:${asks}`, kind: "task-ask", pushClass: "attention", recipient: person,
22066
+ subject: `${actor?.lead ? "The lead" : actor?.account ?? "Someone"} needs you: ${lifecycleWords(this.getTask(taskId)?.title ?? taskId, 80)}`,
22067
+ body: lifecycleWords(why, 400), link: chatControlHref("task", taskId), source: { taskRef: ref.id } }, now);
22068
+ return { ok: true };
22069
+ });
22070
+ }
22071
+ /** The task that replaced a cancelled one, or null. */
22072
+ replacementOf(taskId) {
22073
+ 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);
22074
+ return row === undefined ? null : String(row["successor"]);
22075
+ }
22076
+ /** Every replaced task in one read, for lists: task id → its successor. */
22077
+ replacements() {
22078
+ 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()
22079
+ .map(row => [String(row["external_id"]), String(row["successor"])]));
22080
+ }
22081
+ /** Mute or unmute one project's pings for one person. The console and digest keep everything. */
22082
+ setProjectMuted(account, repo, muted, now) {
22083
+ if (muted)
22084
+ this.db.prepare("INSERT OR IGNORE INTO project_mute (account, repo, at) VALUES (?, ?, ?)").run(account, repo, now.toISOString());
22085
+ else
22086
+ this.db.prepare("DELETE FROM project_mute WHERE account = ? AND repo = ?").run(account, repo);
22087
+ }
22088
+ mutedProjects(account) {
22089
+ return this.db.prepare("SELECT repo FROM project_mute WHERE account = ? ORDER BY repo").all(account).map(row => String(row["repo"]));
22090
+ }
22091
+ /** Whose act made this fact, if anyone's. */
22092
+ notificationActor(id) {
22093
+ const row = this.db.prepare("SELECT account, lead FROM notification_actor WHERE notification = ?").get(id);
22094
+ return row === undefined ? null : { account: String(row["account"]), lead: Number(row["lead"]) === 1 };
22095
+ }
22096
+ /**
22097
+ * Whether this fact may message this person (every chat, phone and browser asks here). Pings follow
22098
+ * responsibility: a security alert always does; nobody is messaged about their own act (or their lead's);
22099
+ * the lead's work messages nobody unless it is handed to a person or fails with nothing left for the lead
22100
+ * to try; and a person's muted project stays quiet. A fact addressed to one person is theirs. The console
22101
+ * and the evening digest keep every fact either way.
22102
+ */
22103
+ pingAllowed(row, account) {
22104
+ if (SECURITY_KINDS.has(row.kind) || row.kind === "evening-digest")
22105
+ return true;
22106
+ if (row.recipient !== null && row.kind === "task-ask")
22107
+ return true;
22108
+ const actor = this.notificationActor(row.id);
22109
+ if (actor !== null && actor.account === account)
22110
+ return false;
22111
+ if (row.recipient !== null)
22112
+ return true;
22113
+ if (this.leadQuiet(row, actor))
22114
+ return false;
22115
+ return row.project === null || this.db.prepare("SELECT 1 AS hit FROM project_mute WHERE account = ? AND repo = ?").get(account, row.project) === undefined;
22116
+ }
22117
+ /** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
22118
+ leadQuiet(row, actor = this.notificationActor(row.id)) {
22119
+ if (SECURITY_KINDS.has(row.kind))
22120
+ return false;
22121
+ const lead = actor?.lead === true || (row.taskRef !== null && this.leadWorkOf(row.taskRef) !== null);
22122
+ return lead && !this.failedForGood(row);
22123
+ }
22124
+ /** A failure with nothing left for the lead to do: its attempts are spent, or the task has failed for good. */
22125
+ failedForGood(row) {
22126
+ if (SPENT_KINDS.has(row.kind))
22127
+ return true;
22128
+ if (!/fail|exhausted/.test(row.kind) || row.taskRef === null)
22129
+ return false;
22130
+ 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;
22131
+ }
21725
22132
  /** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
21726
22133
  * a minute of it (up to eight tasks), or starts its own. */
21727
22134
  chatCardFor(destination, taskRef, filedAt, now) {
@@ -21927,6 +22334,17 @@ export class Store {
21927
22334
  AND push_subscription.retired_at IS NULL
21928
22335
  AND notification.id > push_subscription.starts_after_notification`)
21929
22336
  .run(now.toISOString());
22337
+ // Pings follow responsibility: a pair this person must not be alerted about retires before it is ever sent.
22338
+ if (Number(changes) > 0) {
22339
+ const fresh = this.db.prepare(`SELECT p.id AS pair, s.approver AS approver, n.* FROM push_delivery p
22340
+ JOIN push_subscription s ON s.id = p.subscription JOIN notification n ON n.id = p.notification
22341
+ WHERE p.state = 'pending' AND p.attempts = 0 AND p.created_at = ?`).all(now.toISOString());
22342
+ for (const raw of fresh) {
22343
+ if (!this.pingAllowed(readNotification(raw), String(raw["approver"]))) {
22344
+ this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'quiet' WHERE id = ? AND state = 'pending'").run(Number(raw["pair"]));
22345
+ }
22346
+ }
22347
+ }
21930
22348
  return Number(changes);
21931
22349
  }
21932
22350
  /**
@@ -22247,6 +22665,8 @@ function readHold(row) {
22247
22665
  export const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
22248
22666
  /** v83: a notification for another person, settled for this destination without sending. */
22249
22667
  export const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
22668
+ /** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
22669
+ export const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
22250
22670
  /** A destination receipt (`d` = notification_delivery) that still owes a
22251
22671
  * send: not delivered, and not explicitly skipped as pre-pairing history.
22252
22672
  * Every reader of Telegram receipts that means "undelivered" uses this, so