toolroll 0.9.10 → 0.9.12

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 (53) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +3 -1
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +20 -18
  6. package/dist/browser-workspace.d.ts +61 -0
  7. package/dist/chat-channel.d.ts +9 -7
  8. package/dist/chat-channel.js +23 -17
  9. package/dist/chat-delivery.js +21 -0
  10. package/dist/chat-quiet.js +1 -0
  11. package/dist/chat-voice.d.ts +3 -1
  12. package/dist/chat-voice.js +13 -5
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +1 -0
  15. package/dist/coding-update.d.ts +13 -0
  16. package/dist/coding-update.js +68 -0
  17. package/dist/desktop-update.d.ts +7 -0
  18. package/dist/desktop-update.js +42 -1
  19. package/dist/flow-triggers.js +17 -1
  20. package/dist/guides.js +9 -0
  21. package/dist/lead-status.d.ts +10 -1
  22. package/dist/lead-status.js +13 -3
  23. package/dist/lead-voice.d.ts +81 -0
  24. package/dist/lead-voice.js +275 -0
  25. package/dist/needs-you.d.ts +7 -0
  26. package/dist/needs-you.js +9 -0
  27. package/dist/operate.d.ts +2 -0
  28. package/dist/operate.js +186 -38
  29. package/dist/serve.d.ts +5 -1
  30. package/dist/serve.js +447 -226
  31. package/dist/setup-guide.d.ts +2 -2
  32. package/dist/store.d.ts +15 -1
  33. package/dist/store.js +38 -1
  34. package/dist/surface.js +8 -1
  35. package/dist/task-status.d.ts +6 -0
  36. package/dist/task-status.js +19 -2
  37. package/dist/team-cli.js +1 -1
  38. package/dist/telegram-mate.d.ts +7 -0
  39. package/dist/telegram-mate.js +50 -21
  40. package/dist/telegram-progress.js +7 -0
  41. package/dist/telegram-settings.d.ts +3 -0
  42. package/dist/telegram-settings.js +11 -0
  43. package/dist/telegram.d.ts +14 -0
  44. package/dist/telegram.js +105 -12
  45. package/dist/test-temp.js +1 -1
  46. package/dist/toolroll-update.d.ts +3 -0
  47. package/dist/toolroll-update.js +13 -2
  48. package/dist/work-index.d.ts +9 -0
  49. package/dist/work-index.js +57 -20
  50. package/dist/work-summary.d.ts +4 -1
  51. package/dist/workspace-ui.d.ts +25 -0
  52. package/dist/workspace-ui.js +20 -1
  53. package/package.json +1 -1
@@ -446,7 +446,13 @@ function polled(config) {
446
446
  function startZone(config, definition) {
447
447
  return config.zone !== null && definition.stages.some(one => one.id === config.zone) ? config.zone : definition.start;
448
448
  }
449
- /** One outside thing into one card, at most once per trigger. Keys and control characters never reach a card. */
449
+ /** The active flow that already made a card for this failing commit in a project, if any. */
450
+ function checksCardOf(store, repo, key) {
451
+ const row = store.handle.prepare(`SELECT f.name FROM flow_trigger_event e JOIN flow_trigger t ON t.id = e.trigger JOIN flow f ON f.id = t.flow
452
+ WHERE f.repo = ? AND f.state = 'active' AND e.key = ? AND e.card IS NOT NULL LIMIT 1`).get(repo, key);
453
+ return row === undefined ? null : `“${String(row["name"])}”`;
454
+ }
455
+ /** One outside thing into one card, at most once per trigger (a failing run, once per project). Keys and control characters never reach a card. */
450
456
  function makeCard(store, trigger, config, item, by, now) {
451
457
  const flow = store.getFlow(trigger.flow);
452
458
  const definition = flow === null ? null : flowDefinitionOf(flow);
@@ -460,7 +466,17 @@ function makeCard(store, trigger, config, item, by, now) {
460
466
  store.recordFlowTriggerEvent(trigger.id, item.key, null, note, now);
461
467
  return { made: "skipped", note: `${item.source.label}: ${note}`, card: null };
462
468
  }
469
+ // Claimed inside the write lock: a worker and a console (or an old and a new worker) checking at once make one card.
463
470
  return store.transact(() => {
471
+ if (store.flowTriggerSaw(trigger.id, item.key))
472
+ return { made: "seen", note: null, card: null };
473
+ // One failing run is one fix: a second trigger watching the same checks in this project leaves it to the first.
474
+ const filed = item.key.startsWith("checks:") ? checksCardOf(store, flow.repo, item.key) : null;
475
+ if (filed !== null) {
476
+ const note = `already a card in ${filed}`;
477
+ store.recordFlowTriggerEvent(trigger.id, item.key, null, note, now);
478
+ return { made: "skipped", note: `${item.source.label}: ${note}`, card: null };
479
+ }
464
480
  const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: startZone(config, definition), by, source: item.source }, now);
465
481
  store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now);
466
482
  return { made: "added", note: null, card };
package/dist/guides.js CHANGED
@@ -95,6 +95,15 @@ login, so your housekeeping never pings them.
95
95
  or for a security alert.
96
96
  - Hand a task to a person: \`task ask <id> --person <name> --why "<what
97
97
  they need to do>"\` — one message, and the task is theirs from then on.
98
+ - Tell your person what you are doing, at milestones: \`lead say "<one
99
+ short line>" [--task <id>]\` when you start work, when you are handling a
100
+ failure, and when you are done. Plain words ("Fixing the release check's
101
+ failing test"); several within two minutes become one message.
102
+ - Take on a task you are fixing: \`assignment claim <id>\`. It reads "Your
103
+ lead is on it" instead of waiting for them and leaves Needs you until you
104
+ complete it, hand it over with \`task ask\`, or go two hours without
105
+ acting on it (then it is back with them, and says so). Claim a failure
106
+ before you start on it, and \`lead say\` what you are doing.
98
107
  - Re-filing (a release check re-gated as \`release-<name>b\`): \`task add
99
108
  "<title>" --id <new> --replaces <old>\`, or \`task state <old> cancelled
100
109
  --replaced-by <new>\`. The old task reads "Replaced by <new>", never
@@ -76,6 +76,14 @@ export type InstallationStatus = {
76
76
  headline: string;
77
77
  sentence: string;
78
78
  }[];
79
+ /** What the person's lead is doing now and when it last acted (lead-voice.ts), as one line. */
80
+ lead: {
81
+ owner: string;
82
+ doing: string;
83
+ at: string;
84
+ task: string | null;
85
+ line: string;
86
+ } | null;
79
87
  };
80
88
  /** One indexed observation for `task wait`. `watchedRun` is the attempt the
81
89
  * caller first saw. Each poll follows the task's newest attempt from there, so
@@ -93,6 +101,7 @@ export declare function repairStaleStatuses(store: Store, now: Date): {
93
101
  exitsRecorded: number;
94
102
  readyForReview: number;
95
103
  };
96
- export declare function installationStatus(store: Store, now: Date): InstallationStatus;
104
+ /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
105
+ export declare function installationStatus(store: Store, now: Date, viewer?: string | null): InstallationStatus;
97
106
  export declare function renderInstallationStatus(status: InstallationStatus): string[];
98
107
  export declare function renderTaskWait(snapshot: TaskWaitSnapshot, outcome?: string): string;
@@ -5,6 +5,8 @@ import { windowLabel } from "./provider-limits.js";
5
5
  import { openAuthPauses, signInCommand, signInReason } from "./provider-auth.js";
6
6
  import { BUILT_IN, checkSuitesOf } from "./store.js";
7
7
  import { workIndexPage, workReadyForReview } from "./work-index.js";
8
+ import { currentActor } from "./actor.js";
9
+ import { leadActivity, leadActivityLine } from "./lead-voice.js";
8
10
  const maybeNumber = (value) => value === null || value === undefined || !Number.isInteger(Number(value)) ? null : Number(value);
9
11
  const CHECK_COLUMNS = "rc.status AS check_status, rc.exit_code AS check_exit_code, rc.suites_json AS check_suites_json";
10
12
  function checkOf(row) {
@@ -177,7 +179,8 @@ export function repairStaleStatuses(store, now) {
177
179
  const exitsRecorded = store.recordFinishedRunExits(now);
178
180
  return { exitsRecorded, readyForReview: workReadyForReview(store, now, { principal: "operator", repos: null, includeUnplaced: true }, 0).count };
179
181
  }
180
- export function installationStatus(store, now) {
182
+ /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
183
+ export function installationStatus(store, now, viewer = currentActor()?.account ?? null) {
181
184
  const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.external_id AS task
182
185
  FROM run INDEXED BY work_unfinished
183
186
  JOIN task_ref AS ref ON ref.id = run.task_ref
@@ -251,8 +254,13 @@ export function installationStatus(store, now) {
251
254
  observedAt: String(row["observed_at"]),
252
255
  })),
253
256
  signIn: pauses.map(one => ({ provider: one.provider, reason: signInReason(one), command: signInCommand(one), since: one.openedAt })),
254
- tasks: workIndexPage(store, now, { principal: "operator", repos: null, includeUnplaced: true }, { limit: 8 }).items
257
+ tasks: workIndexPage(store, now, { principal: "operator", repos: null, includeUnplaced: true, viewer }, { limit: 8 }).items
255
258
  .map(one => ({ task: one.rootId, title: one.title, headline: one.status.label, sentence: one.status.detail })),
259
+ lead: (() => {
260
+ // Only the asker's own lead: another person's lead is never "Your lead".
261
+ const activity = leadActivity(store, viewer);
262
+ return activity === null ? null : { owner: activity.owner, doing: activity.doing, at: activity.at, task: activity.taskId, line: leadActivityLine(activity, now) };
263
+ })(),
256
264
  };
257
265
  }
258
266
  export function renderInstallationStatus(status) {
@@ -260,6 +268,8 @@ export function renderInstallationStatus(status) {
260
268
  // A paused provider comes first: it is the one thing a person must do.
261
269
  for (const one of status.signIn)
262
270
  lines.push(`${one.reason} — run \`${one.command}\`. Its tasks wait until then.`);
271
+ if (status.lead != null)
272
+ lines.push(status.lead.line);
263
273
  // Each task's one headline, the same words as the console and chat cards.
264
274
  if (status.tasks.length > 0) {
265
275
  lines.push("Tasks:");
@@ -295,7 +305,7 @@ export function renderInstallationStatus(status) {
295
305
  lines.push(`Plan windows: ${provider} — ${detail}`);
296
306
  }
297
307
  }
298
- return lines.slice(0, 12 + status.signIn.length + (status.tasks.length === 0 ? 0 : status.tasks.length + 1));
308
+ return lines.slice(0, 12 + status.signIn.length + (status.lead == null ? 0 : 1) + (status.tasks.length === 0 ? 0 : status.tasks.length + 1));
299
309
  }
300
310
  export function renderTaskWait(snapshot, outcome = snapshot.outcome) {
301
311
  const check = snapshot.check.status === "unknown" ? "checks unknown" : snapshot.check.status === "not-run" ? "checks not run" : `checks ${snapshot.check.status}${snapshot.check.exitCode === null ? "" : ` (exit ${snapshot.check.exitCode})`}`;
@@ -0,0 +1,81 @@
1
+ /** The lead's voice: what the person's lead (an agent under their lead token) is doing, in their chat and on the
2
+ * console. `lead say` posts one short message from "Your lead"; several within two minutes are one message, edited
3
+ * in place as it grows. `assignment claim` marks a task the lead's: it reads "Your lead is on it" and leaves Needs
4
+ * you until the lead completes it, hands it to a person, or two hours pass with no lead act on it.
5
+ *
6
+ * Every fact is an append-only ledger row under "lead for <owner>"; no table of its own. The chat message is an
7
+ * ordinary notification addressed to the owner, so every chat delivers it with the usual receipts. */
8
+ import { type Actor } from "./actor.js";
9
+ import { type Notification, type Store } from "./store.js";
10
+ /** Who the person's chat says the lead's messages are from. */
11
+ export declare const LEAD_NAME = "Your lead";
12
+ export declare const LEAD_SAY_KIND = "lead-say";
13
+ /** Repaints the owner's task card when the lead takes a task on: it never makes a new message in quiet chat. */
14
+ export declare const LEAD_ON_IT_KIND = "lead-on-it";
15
+ /** Repaints the owner's task card when a claim lapses: the card reads its real state again, and says so. */
16
+ export declare const LEAD_LAPSED_KIND = "lead-lapsed";
17
+ export declare const LEAD_SAID = "lead said";
18
+ export declare const LEAD_CLAIMED = "lead is on it";
19
+ /** A claim lapses after this long without a lead act on the task. */
20
+ export declare const LEAD_IDLE_MS: number;
21
+ /** The lead's acts that hand the task back: done, handed to a person, or cancelled. */
22
+ export declare const LEAD_ENDS: readonly ["task completed", "task handed to a person", "task cancelled"];
23
+ export declare const LEAD_SAY_MAX = 300;
24
+ /** The work index's reading of a claim, per row of its `classified` projection: 0 none, 1 on it, 2 lapsed. Only the
25
+ * viewer's own lead counts (`$leadOf`, "lead for <viewer>"; null for nobody): another person's lead is not yours. */
26
+ export declare const LEAD_CLAIM_SQL: string;
27
+ export type LeadClaim = {
28
+ state: "on-it" | "lapsed";
29
+ owner: string;
30
+ since: string;
31
+ lastActedAt: string;
32
+ };
33
+ export type LeadActivity = {
34
+ owner: string;
35
+ doing: string;
36
+ at: string;
37
+ taskId: string | null;
38
+ };
39
+ type Failure = {
40
+ ok: false;
41
+ reason: "usage" | "unknown-task" | "refused";
42
+ message: string;
43
+ };
44
+ /** One line of the lead's words: plain, short, no control characters. */
45
+ export declare function leadWordsProblem(text: string): string | null;
46
+ /** `lead say`: record the words as the lead's act, and post them in the owner's chat. Within two minutes of the
47
+ * first, a later say joins it: the new row carries every line, and the earlier one stops waiting to be sent (a
48
+ * chat that already showed it edits that message in place). */
49
+ export declare function leadSay(store: Store, actor: Actor, text: string, taskId: string | null, now: Date): {
50
+ ok: true;
51
+ joined: boolean;
52
+ notification: number | null;
53
+ } | Failure;
54
+ /** The words a chat shows for a lead-say row: "Your lead", then each line, plain, with no task id. */
55
+ export declare function leadSayText(row: Pick<Notification, "subject" | "body">): string;
56
+ /** The earlier rows of a lead-say message (newest first): a chat that showed one edits it rather than sending again. */
57
+ export declare function leadSayEarlier(store: Store, row: Pick<Notification, "id" | "kind" | "dedupeKey" | "recipient">): number[];
58
+ /** `assignment claim` under the lead token: the task is the lead's until it completes it, hands it on, or goes
59
+ * quiet on it for two hours. Repeating a claim is safe and counts as a lead act. */
60
+ export declare function leadClaim(store: Store, actor: Actor, taskId: string, now: Date): {
61
+ ok: true;
62
+ root: string;
63
+ claim: LeadClaim;
64
+ } | Failure;
65
+ /** Whether `viewer`'s own lead has this task now: on it, lapsed after two quiet hours (it says so), or null (never
66
+ * claimed by their lead, handed on by it since, or completed or cancelled by anyone). Another person's lead never counts. */
67
+ export declare function leadClaimOf(store: Store, taskId: string, now: Date, viewer: string | null | undefined): LeadClaim | null;
68
+ /** A claim that lapsed repaints its owner's card once per lapse: the card reads the task's real state again and
69
+ * says it is back with them. Run beside the evening digests on every chat pass; claims are found through their
70
+ * repaint rows (an indexed key range), so nothing scans the ledger. */
71
+ export declare function enqueueLeadLapses(store: Store, now: Date): number;
72
+ /** The lead's act on a task that a claim counts (a retry, a state change, a steer): one ledger row as the lead. */
73
+ export declare function noteLeadWork(store: Store, actor: Actor, taskId: string, what: string, now: Date): void;
74
+ /** What `owner`'s own lead is doing now and when it last acted, both from its one newest act: its words when that
75
+ * was a say, else the act in words. Nobody (null) has no lead line: another person's lead is never "Your lead". */
76
+ export declare function leadActivity(store: Store, owner: string | null | undefined): LeadActivity | null;
77
+ /** "just now", "3 min ago", "2 h ago", "yesterday", "4 days ago". */
78
+ export declare function agoWords(at: string, now: Date): string;
79
+ /** The one line `toolroll status` and the console say: "Your lead: fixing 0.9.12's release check · 3 min ago". */
80
+ export declare function leadActivityLine(activity: LeadActivity, now: Date): string;
81
+ export {};
@@ -0,0 +1,275 @@
1
+ /** The lead's voice: what the person's lead (an agent under their lead token) is doing, in their chat and on the
2
+ * console. `lead say` posts one short message from "Your lead"; several within two minutes are one message, edited
3
+ * in place as it grows. `assignment claim` marks a task the lead's: it reads "Your lead is on it" and leaves Needs
4
+ * you until the lead completes it, hands it to a person, or two hours pass with no lead act on it.
5
+ *
6
+ * Every fact is an append-only ledger row under "lead for <owner>"; no table of its own. The chat message is an
7
+ * ordinary notification addressed to the owner, so every chat delivers it with the usual receipts. */
8
+ import { actorLabel, withActor } from "./actor.js";
9
+ import { chatControlHref } from "./chat-controls.js";
10
+ import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
11
+ import { BUILT_IN } from "./store.js";
12
+ import { LEAD_LAPSED } from "./task-status.js";
13
+ import { phoneText as plain } from "./telegram-status.js";
14
+ /** Who the person's chat says the lead's messages are from. */
15
+ export const LEAD_NAME = "Your lead";
16
+ export const LEAD_SAY_KIND = "lead-say";
17
+ /** Repaints the owner's task card when the lead takes a task on: it never makes a new message in quiet chat. */
18
+ export const LEAD_ON_IT_KIND = "lead-on-it";
19
+ /** Repaints the owner's task card when a claim lapses: the card reads its real state again, and says so. */
20
+ export const LEAD_LAPSED_KIND = "lead-lapsed";
21
+ export const LEAD_SAID = "lead said";
22
+ export const LEAD_CLAIMED = "lead is on it";
23
+ /** A claim lapses after this long without a lead act on the task. */
24
+ export const LEAD_IDLE_MS = 2 * 3_600_000;
25
+ /** The lead's acts that hand the task back: done, handed to a person, or cancelled. */
26
+ export const LEAD_ENDS = ["task completed", "task handed to a person", "task cancelled"];
27
+ /** Acts by anyone that end a claim: a completed or cancelled task is nobody's to be on. */
28
+ const DONE_ACTS_SQL = "'completed','cancelled'";
29
+ export const LEAD_SAY_MAX = 300;
30
+ /** Lines one batched message keeps; the oldest go first. */
31
+ const BATCH_LINES = 8;
32
+ const ENDS_SQL = LEAD_ENDS.map(one => `'${one}'`).join(",");
33
+ /** The work index's reading of a claim, per row of its `classified` projection: 0 none, 1 on it, 2 lapsed. Only the
34
+ * viewer's own lead counts (`$leadOf`, "lead for <viewer>"; null for nobody): another person's lead is not yours. */
35
+ export const LEAD_CLAIM_SQL = `COALESCE((SELECT CASE
36
+ WHEN EXISTS(SELECT 1 FROM action_ledger e WHERE e.task_id IN (SELECT m.id FROM ordered m WHERE m.root_ref = c.root_ref)
37
+ AND e.id > claim.id AND e.actor = claim.actor AND e.action IN (${ENDS_SQL})) THEN 0
38
+ WHEN EXISTS(SELECT 1 FROM task_act t WHERE t.task_ref IN (SELECT m.ref_id FROM ordered m WHERE m.root_ref = c.root_ref)
39
+ AND t.act IN (${DONE_ACTS_SQL}) AND t.at >= claim.at) THEN 0
40
+ WHEN (SELECT MAX(a.at) FROM action_ledger a WHERE a.task_id IN (SELECT m.id FROM ordered m WHERE m.root_ref = c.root_ref)
41
+ AND a.id >= claim.id AND a.actor = claim.actor) > $leadIdleSince THEN 1 ELSE 2 END
42
+ FROM action_ledger claim WHERE claim.id = (SELECT MAX(id) FROM action_ledger WHERE task_id = c.root_id AND action = '${LEAD_CLAIMED}' AND actor = $leadOf)), 0)`;
43
+ /** A task family's root and every version, following revisions both ways. */
44
+ function familyOf(store, taskId) {
45
+ const root = store.handle.prepare(`WITH RECURSIVE up(id, depth) AS (SELECT ?, 0 UNION ALL
46
+ SELECT r.revision_of, up.depth + 1 FROM up JOIN task_ref r ON r.backend = ? AND r.external_id = up.id WHERE r.revision_of IS NOT NULL AND up.depth < 64)
47
+ SELECT id FROM up ORDER BY depth DESC LIMIT 1`).get(taskId, BUILT_IN);
48
+ const top = root === undefined ? taskId : String(root["id"]);
49
+ const ids = store.handle.prepare(`WITH RECURSIVE down(id, depth) AS (SELECT ?, 0 UNION ALL
50
+ SELECT r.external_id, down.depth + 1 FROM down JOIN task_ref r ON r.backend = ? AND r.revision_of = down.id WHERE down.depth < 64)
51
+ SELECT DISTINCT id FROM down`).all(top, BUILT_IN).map(row => String(row["id"]));
52
+ return { root: top, ids };
53
+ }
54
+ /** One line of the lead's words: plain, short, no control characters. */
55
+ export function leadWordsProblem(text) {
56
+ const words = text.trim();
57
+ if (words === "")
58
+ return "Say something: `toolroll lead say \"<what you're doing>\"`.";
59
+ if (words.length > LEAD_SAY_MAX)
60
+ return `Keep it short: at most ${LEAD_SAY_MAX} characters.`;
61
+ if (/[\u0000-\u001f\u007f]/.test(words))
62
+ return "One line of plain text.";
63
+ return null;
64
+ }
65
+ /** The task the lead names, if its owner can see it. */
66
+ function admittedTask(store, owner, taskId) {
67
+ const ref = store.lookupRef(taskId);
68
+ if (ref === null || store.getTask(taskId) === null)
69
+ return { ok: false, reason: "unknown-task", message: `No task \`${taskId}\`.` };
70
+ if (ref.repo !== null && !store.accountCanAccess(owner, ref.repo))
71
+ return { ok: false, reason: "unknown-task", message: `No task \`${taskId}\`.` };
72
+ return { refId: ref.id, repo: ref.repo };
73
+ }
74
+ /** `lead say`: record the words as the lead's act, and post them in the owner's chat. Within two minutes of the
75
+ * first, a later say joins it: the new row carries every line, and the earlier one stops waiting to be sent (a
76
+ * chat that already showed it edits that message in place). */
77
+ export function leadSay(store, actor, text, taskId, now) {
78
+ if (!actor.lead)
79
+ return { ok: false, reason: "refused", message: "Only the lead speaks as the lead: pass its lead token." };
80
+ const problem = leadWordsProblem(text);
81
+ if (problem !== null)
82
+ return { ok: false, reason: "usage", message: problem };
83
+ const task = taskId === null ? null : admittedTask(store, actor.account, taskId);
84
+ if (task !== null && "ok" in task)
85
+ return task;
86
+ // The task's own id never reaches a chat: it reads as the task's short title (chat-voice.ts).
87
+ const said = taskId === null ? text.trim() : chatText(text.trim(), [{ id: taskId, title: chatTitle(store, taskId) }]);
88
+ return store.transact(() => {
89
+ const stamp = now.toISOString();
90
+ const ledger = store.recordAction({ at: stamp, actor: actorLabel(actor), repo: task?.repo ?? null, taskId, runId: null,
91
+ action: LEAD_SAID, outcome: "recorded", source: "work", detail: text.trim() });
92
+ const open = store.handle.prepare(`SELECT id, dedupe_key, body FROM notification WHERE kind = ? AND recipient = ? ORDER BY id DESC LIMIT 1`)
93
+ .get(LEAD_SAY_KIND, actor.account);
94
+ const opened = open === undefined ? null : /^lead-say:(.+):\d+$/.exec(String(open["dedupe_key"]))?.[1] ?? null;
95
+ const joined = open !== undefined && opened !== null && Date.parse(opened) > now.getTime() - BATCH_MS && Date.parse(opened) <= now.getTime();
96
+ const lines = [...(joined ? String(open["body"]).split("\n") : []), said].slice(-BATCH_LINES);
97
+ if (joined)
98
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL").run(stamp, Number(open["id"]));
99
+ const key = `lead-say:${joined ? opened : stamp}:${ledger}`;
100
+ store.enqueueNotification({ dedupeKey: key, kind: LEAD_SAY_KIND, subject: LEAD_NAME, body: lines.join("\n"),
101
+ recipient: actor.account, source: { installation: true }, ...(taskId === null ? {} : { link: chatControlHref("task", taskId) }) }, now);
102
+ const row = store.handle.prepare("SELECT id FROM notification WHERE dedupe_key = ?").get(key);
103
+ return { ok: true, joined, notification: row === undefined ? null : Number(row["id"]) };
104
+ });
105
+ }
106
+ /** The words a chat shows for a lead-say row: "Your lead", then each line, plain, with no task id. */
107
+ export function leadSayText(row) {
108
+ return chatText([plain(row.subject, 60), "", ...row.body.split("\n").map(line => plain(line, LEAD_SAY_MAX)).filter(line => line !== "")].join("\n"));
109
+ }
110
+ /** The earlier rows of a lead-say message (newest first): a chat that showed one edits it rather than sending again. */
111
+ export function leadSayEarlier(store, row) {
112
+ if (row.kind !== LEAD_SAY_KIND || row.recipient === null)
113
+ return [];
114
+ const opened = /^(lead-say:.+:)\d+$/.exec(row.dedupeKey)?.[1];
115
+ if (opened === undefined)
116
+ return [];
117
+ return store.handle.prepare(`SELECT id FROM notification WHERE kind = ? AND recipient = ? AND id < ? AND substr(dedupe_key, 1, ?) = ? ORDER BY id DESC`)
118
+ .all(LEAD_SAY_KIND, row.recipient, row.id, opened.length, opened).map(one => Number(one["id"]));
119
+ }
120
+ /** `assignment claim` under the lead token: the task is the lead's until it completes it, hands it on, or goes
121
+ * quiet on it for two hours. Repeating a claim is safe and counts as a lead act. */
122
+ export function leadClaim(store, actor, taskId, now) {
123
+ if (!actor.lead)
124
+ return { ok: false, reason: "refused", message: "Only the lead takes a task on as the lead: pass its lead token." };
125
+ const task = admittedTask(store, actor.account, taskId);
126
+ if ("ok" in task)
127
+ return task;
128
+ const { root } = familyOf(store, taskId);
129
+ store.transact(() => {
130
+ const ledger = store.recordAction({ at: now.toISOString(), actor: actorLabel(actor), repo: task.repo, taskId: root, runId: null,
131
+ action: LEAD_CLAIMED, outcome: "recorded", source: "work" });
132
+ // The owner's card for this task repaints to "Your lead is on it": quiet chat edits its card, and every-update
133
+ // chat edits the attempt's own card (the Failed alert), or follows it up when that message is gone.
134
+ store.enqueueNotification({ dedupeKey: `lead-on-it:${ledger}`, kind: LEAD_ON_IT_KIND, subject: "Your lead is on it", body: "",
135
+ link: chatControlHref("task", taskId), source: cardSource(store, root) ?? { taskRef: task.refId } }, now);
136
+ });
137
+ return { ok: true, root, claim: leadClaimOf(store, taskId, now, actor.account) };
138
+ }
139
+ /** Where a task's chat card lives: the family's newest attempt (the card every-update chat edits), else its newest
140
+ * version. Null for a task with no reference. */
141
+ function cardSource(store, root) {
142
+ const refs = familyOf(store, root).ids.map(id => store.lookupRef(id)).filter(ref => ref !== null).sort((a, b) => b.id - a.id);
143
+ const newest = refs[0];
144
+ if (newest === undefined)
145
+ return null;
146
+ const runs = refs.flatMap(ref => store.runsFor(ref.id)).filter(one => one.role === "builder" && one.contestant === null);
147
+ const run = runs.reduce((top, one) => top === null || one.id > top ? one.id : top, null);
148
+ return run === null ? { taskRef: newest.id } : { run };
149
+ }
150
+ /** Whether anyone completed or cancelled the task family (since `since`, when given), or its newest version is
151
+ * cancelled however that happened: a finished task is nobody's to be on. */
152
+ function familyEnded(store, ids, since) {
153
+ const refs = ids.map(id => store.lookupRef(id)).filter(ref => ref !== null).sort((a, b) => b.id - a.id);
154
+ if (refs[0] === undefined)
155
+ return false;
156
+ if (store.getTask(refs[0].externalId)?.state === "cancelled")
157
+ return true;
158
+ return store.handle.prepare(`SELECT 1 AS hit FROM task_act WHERE task_ref IN (${refs.map(() => "?").join(",")}) AND act IN (${DONE_ACTS_SQL})
159
+ AND at >= ? LIMIT 1`).get(...refs.map(ref => ref.id), since ?? "") !== undefined;
160
+ }
161
+ /** Whether `viewer`'s own lead has this task now: on it, lapsed after two quiet hours (it says so), or null (never
162
+ * claimed by their lead, handed on by it since, or completed or cancelled by anyone). Another person's lead never counts. */
163
+ export function leadClaimOf(store, taskId, now, viewer) {
164
+ if (viewer == null || viewer === "")
165
+ return null;
166
+ try {
167
+ const { root, ids } = familyOf(store, taskId);
168
+ const claim = store.handle.prepare("SELECT id, at, actor FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? ORDER BY id DESC LIMIT 1")
169
+ .get(root, LEAD_CLAIMED, `lead for ${viewer}`);
170
+ if (claim === undefined)
171
+ return null;
172
+ const family = `task_id IN (${ids.map(() => "?").join(",")}) AND id >= ? AND actor = ?`;
173
+ const args = [...ids, Number(claim["id"]), String(claim["actor"])];
174
+ // Completed, handed on or cancelled by the lead since its claim: the claim is over.
175
+ if (store.handle.prepare(`SELECT 1 AS hit FROM action_ledger WHERE ${family} AND action IN (${ENDS_SQL}) LIMIT 1`).get(...args) !== undefined)
176
+ return null;
177
+ if (familyEnded(store, ids, String(claim["at"])))
178
+ return null;
179
+ const latest = store.handle.prepare(`SELECT MAX(at) AS at FROM action_ledger WHERE ${family}`).get(...args);
180
+ const lastActedAt = String(latest?.["at"] ?? claim["at"]);
181
+ return { state: Date.parse(lastActedAt) > now.getTime() - LEAD_IDLE_MS ? "on-it" : "lapsed",
182
+ owner: String(claim["actor"]).replace(/^lead for /, ""), since: String(claim["at"]), lastActedAt };
183
+ }
184
+ catch {
185
+ // An older store being read by a newer build: no claim is the safe reading.
186
+ return null;
187
+ }
188
+ }
189
+ /** A claim that lapsed repaints its owner's card once per lapse: the card reads the task's real state again and
190
+ * says it is back with them. Run beside the evening digests on every chat pass; claims are found through their
191
+ * repaint rows (an indexed key range), so nothing scans the ledger. */
192
+ export function enqueueLeadLapses(store, now) {
193
+ let count = 0;
194
+ try {
195
+ const since = new Date(now.getTime() - 14 * 86_400_000).toISOString();
196
+ const claims = store.handle.prepare(`SELECT DISTINCT n.task_id, a.account FROM notification n JOIN notification_actor a ON a.notification = n.id AND a.lead = 1
197
+ WHERE n.dedupe_key >= 'lead-on-it:' AND n.dedupe_key < 'lead-on-it;' AND n.created_at >= ? AND n.task_id IS NOT NULL`).all(since);
198
+ for (const row of claims) {
199
+ const owner = String(row["account"]);
200
+ const taskId = String(row["task_id"]);
201
+ const claim = leadClaimOf(store, taskId, now, owner);
202
+ if (claim?.state !== "lapsed")
203
+ continue;
204
+ const { root, ids } = familyOf(store, taskId);
205
+ // A completed or cancelled task never repaints as back with them.
206
+ if (store.getTask(root) === null || familyEnded(store, ids, null))
207
+ continue;
208
+ const source = cardSource(store, root);
209
+ if (source === null)
210
+ continue;
211
+ // The owner's lead let it go: only its owner's card hears of it (store.pingAllowed).
212
+ if (withActor({ account: owner, lead: true }, () => store.enqueueNotification({ dedupeKey: `lead-lapsed:${root}:${owner}:${claim.lastActedAt}`,
213
+ kind: LEAD_LAPSED_KIND, subject: LEAD_LAPSED, body: "", link: chatControlHref("task", root), source }, now)))
214
+ count++;
215
+ }
216
+ }
217
+ catch {
218
+ // An older store being read by a newer build: nothing to repaint.
219
+ }
220
+ return count;
221
+ }
222
+ /** The lead's act on a task that a claim counts (a retry, a state change, a steer): one ledger row as the lead. */
223
+ export function noteLeadWork(store, actor, taskId, what, now) {
224
+ if (!actor.lead)
225
+ return;
226
+ const ref = store.lookupRef(taskId);
227
+ if (ref === null)
228
+ return;
229
+ store.recordAction({ at: now.toISOString(), actor: actorLabel(actor), repo: ref.repo, taskId, runId: null, action: `lead ${what}`, outcome: "recorded", source: "work" });
230
+ }
231
+ const ACT_WORDS = {
232
+ "task filed": "filed", "task approved": "approved", "task cancelled": "cancelled", "task completed": "completed",
233
+ "task handed to a person": "handed on", [LEAD_CLAIMED]: "working on",
234
+ };
235
+ /** What `owner`'s own lead is doing now and when it last acted, both from its one newest act: its words when that
236
+ * was a say, else the act in words. Nobody (null) has no lead line: another person's lead is never "Your lead". */
237
+ export function leadActivity(store, owner) {
238
+ if (owner == null || owner === "")
239
+ return null;
240
+ try {
241
+ const last = store.handle.prepare("SELECT id, at, actor, task_id, action, detail FROM action_ledger WHERE actor = ? ORDER BY id DESC LIMIT 1").get(`lead for ${owner}`);
242
+ if (last === undefined)
243
+ return null;
244
+ const task = last["task_id"] == null ? null : String(last["task_id"]);
245
+ const title = task === null ? "" : (() => { try {
246
+ return ` ${chatTitle(store, task)}`;
247
+ }
248
+ catch {
249
+ return "";
250
+ } })();
251
+ const doing = last["action"] === LEAD_SAID && last["detail"] != null ? chatText(String(last["detail"]), task === null ? [] : [{ id: task, title: title.trim() }])
252
+ : `${ACT_WORDS[String(last["action"])] ?? String(last["action"]).replace(/^lead /, "")}${title}`;
253
+ return { owner, doing: doing.replace(/[.\s]+$/u, ""), at: String(last["at"]), taskId: task };
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }
259
+ /** "just now", "3 min ago", "2 h ago", "yesterday", "4 days ago". */
260
+ export function agoWords(at, now) {
261
+ const minutes = Math.max(0, Math.floor((now.getTime() - Date.parse(at)) / 60_000));
262
+ if (!Number.isFinite(minutes) || minutes < 1)
263
+ return "just now";
264
+ if (minutes < 60)
265
+ return `${minutes} min ago`;
266
+ const hours = Math.floor(minutes / 60);
267
+ if (hours < 24)
268
+ return `${hours} h ago`;
269
+ const days = Math.floor(hours / 24);
270
+ return days === 1 ? "yesterday" : `${days} days ago`;
271
+ }
272
+ /** The one line `toolroll status` and the console say: "Your lead: fixing 0.9.12's release check · 3 min ago". */
273
+ export function leadActivityLine(activity, now) {
274
+ return `${LEAD_NAME}: ${activity.doing} · ${agoWords(activity.at, now)}`;
275
+ }
@@ -29,6 +29,13 @@ type Need = {
29
29
  withReason?: (reason: string) => string;
30
30
  };
31
31
  export declare const NEEDS: Readonly<Record<NeedKey, Need>>;
32
+ /** What a waiting task asks of a person, in three kinds the Tasks list groups
33
+ * by: a choice to make, a result to accept or send back, or something in the
34
+ * way to clear. Decide comes first. */
35
+ export type Ask = "decide" | "review" | "unblock";
36
+ export declare const ASKS: readonly Ask[];
37
+ export declare const ASK_LABEL: Readonly<Record<Ask, string>>;
38
+ export declare const NEED_ASK: Readonly<Record<NeedKey, Ask>>;
32
39
  export declare const WAITS: Readonly<Record<WaitKey, (context: NeedContext) => string>>;
33
40
  /** Words that name the machine's internals; no Needs you or Waiting sentence carries them. */
34
41
  export declare const BANNED_WORDS: readonly ["witness", "unproven", "quiescence", "quiescent", "digest", "scope digest"];
package/dist/needs-you.js CHANGED
@@ -34,6 +34,15 @@ export const NEEDS = {
34
34
  "earlier-version": { sentence: () => "An earlier version of this task still needs you. Finish it first.", action: { code: "inspect-task", label: "Open earlier version" } },
35
35
  other: { sentence: () => "Open the task to see what it needs from you.", action: { code: "inspect-task", label: "Open the task" }, useReason: always },
36
36
  };
37
+ export const ASKS = ["decide", "review", "unblock"];
38
+ export const ASK_LABEL = { decide: "Decide", review: "Review", unblock: "Unblock" };
39
+ export const NEED_ASK = {
40
+ approval: "decide", card: "decide", answer: "decide", questions: "decide", "choose-project": "decide", "define-task": "decide",
41
+ "fix-request": "decide", "choose-agent": "decide",
42
+ "review-result": "review", rebuild: "review",
43
+ "sign-in": "unblock", "confirm-stopped": "unblock", "check-stopped": "unblock", hold: "unblock", "fix-dependency": "unblock",
44
+ "add-requirement": "unblock", "connect-builder": "unblock", "start-builder": "unblock", vanished: "unblock", "earlier-version": "unblock", other: "unblock",
45
+ };
37
46
  export const WAITS = {
38
47
  "build-stopping": context => `Waiting for ${build(context)} to stop. Nothing is needed from you.`,
39
48
  "other-computer": context => `Waiting for the computer that ran ${build(context)} to confirm it stopped.`,
package/dist/operate.d.ts CHANGED
@@ -50,6 +50,8 @@ export type OperateOptions = {
50
50
  gitRunner?: CommandRunner;
51
51
  /** Injected by tests: the Telegram Bot API. Production dials the real one. */
52
52
  telegramTransport?: TelegramTransport;
53
+ /** Injected by tests: the environment checked for a fenced network before Telegram is reached (default: this process's, with the real transport). */
54
+ telegramEnv?: Record<string, string | undefined>;
53
55
  publishExec?: PublishExec;
54
56
  /** Injected by tests: the stop fence a watch would set. */
55
57
  shouldStop?: () => boolean;