toolroll 0.9.11 → 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 (44) hide show
  1. package/CHANGELOG.md +18 -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-delivery.js +17 -0
  8. package/dist/chat-quiet.js +1 -0
  9. package/dist/chat-voice.d.ts +3 -1
  10. package/dist/chat-voice.js +13 -5
  11. package/dist/cli.d.ts +1 -1
  12. package/dist/cli.js +1 -0
  13. package/dist/coding-update.d.ts +13 -0
  14. package/dist/coding-update.js +68 -0
  15. package/dist/desktop-update.d.ts +7 -0
  16. package/dist/desktop-update.js +42 -1
  17. package/dist/flow-triggers.js +17 -1
  18. package/dist/guides.js +9 -0
  19. package/dist/lead-status.d.ts +10 -1
  20. package/dist/lead-status.js +13 -3
  21. package/dist/lead-voice.d.ts +81 -0
  22. package/dist/lead-voice.js +275 -0
  23. package/dist/needs-you.d.ts +7 -0
  24. package/dist/needs-you.js +9 -0
  25. package/dist/operate.js +80 -4
  26. package/dist/serve.d.ts +5 -1
  27. package/dist/serve.js +438 -224
  28. package/dist/setup-guide.d.ts +2 -2
  29. package/dist/store.js +8 -0
  30. package/dist/surface.js +8 -1
  31. package/dist/task-status.d.ts +6 -0
  32. package/dist/task-status.js +19 -2
  33. package/dist/team-cli.js +1 -1
  34. package/dist/telegram-progress.js +7 -0
  35. package/dist/telegram.js +34 -3
  36. package/dist/test-temp.js +1 -1
  37. package/dist/toolroll-update.d.ts +3 -0
  38. package/dist/toolroll-update.js +13 -2
  39. package/dist/work-index.d.ts +9 -0
  40. package/dist/work-index.js +57 -20
  41. package/dist/work-summary.d.ts +4 -1
  42. package/dist/workspace-ui.d.ts +25 -0
  43. package/dist/workspace-ui.js +20 -1
  44. package/package.json +1 -1
@@ -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.js CHANGED
@@ -89,6 +89,7 @@ import { pushPass } from "./push.js";
89
89
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
90
90
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
91
91
  import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
92
+ import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
92
93
  import { createServer as createNetServer } from "node:net";
93
94
  import { spawn as spawnChild } from "node:child_process";
94
95
  import { envelopeJson } from "./envelope.js";
@@ -638,6 +639,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
638
639
  "replaced-by", "replaces",
639
640
  // Check levels: a project's level, the quick command, a task's choice.
640
641
  "quick", "level", "checks",
642
+ // lead say: the task the lead's words are about.
643
+ "task",
641
644
  ]);
642
645
  export const OPERATE_BOOLEAN_FLAGS = new Set([
643
646
  "json", "yes", "all", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
@@ -968,6 +971,9 @@ async function dispatch(command, positional, flags, context) {
968
971
  return projectConcurrencyCommand(positional, flags, context);
969
972
  return runProjectCommand(positional, flags, context);
970
973
  case "assignment":
974
+ // The person's own lead (its lead token) claims as the lead; coordinator credentials keep their own path.
975
+ if (positional[0] === "claim" && currentActor()?.lead === true && !flags.has("token-env") && !flags.has("token-file"))
976
+ return leadClaimCommand(positional, flags, context);
971
977
  return runAssignmentCommand(positional, flags, context);
972
978
  case "knowledge":
973
979
  return runKnowledgeCommand(positional, flags, { ...context, now: context.clock() });
@@ -7334,6 +7340,8 @@ function startTelegramFollower(args) {
7334
7340
  return null;
7335
7341
  });
7336
7342
  }
7343
+ /** How long a stopping service waits for the aborted Telegram follower. */
7344
+ const TELEGRAM_STOP_WAIT_MS = 5_000;
7337
7345
  let staleStatusesRepaired = false;
7338
7346
  async function runWatchLoop(args) {
7339
7347
  const { context, flags, runner, token, repo, progress } = args;
@@ -8488,6 +8496,10 @@ async function upCommand(flags, context) {
8488
8496
  resolveStopped();
8489
8497
  for (const controller of followControllers)
8490
8498
  controller.abort();
8499
+ // The coding owner record is released first, whatever else is still
8500
+ // finishing: launchd kills a service that outlasts its exit window, and a
8501
+ // killed process cannot release it. Its outcome is read again at close.
8502
+ console_.server.closeCoding().catch(() => { });
8491
8503
  const building = store.liveClaimCount(runnerName, clock());
8492
8504
  if (building > 0) {
8493
8505
  progress(`Stopping: ${building} build(s) running. Waiting up to ${Math.round(stopGraceMs / 1000)}s for them to finish; any still running then is saved and resumes when the builder starts again.`);
@@ -8591,7 +8603,11 @@ async function upCommand(flags, context) {
8591
8603
  const pause = (ms) => new Promise(resolvePause => {
8592
8604
  const timer = setTimeout(done, ms);
8593
8605
  function done() { clearTimeout(timer); telegramController.signal.removeEventListener("abort", done); resolvePause(); }
8594
- telegramController.signal.addEventListener("abort", done, { once: true });
8606
+ // An aborted signal never fires again: without this a stop waited the whole pause.
8607
+ if (telegramController.signal.aborted)
8608
+ done();
8609
+ else
8610
+ telegramController.signal.addEventListener("abort", done, { once: true });
8595
8611
  });
8596
8612
  while (!stopping && !telegramController.signal.aborted) {
8597
8613
  const follower = startTelegramFollower({
@@ -8740,7 +8756,14 @@ async function upCommand(flags, context) {
8740
8756
  await stopped;
8741
8757
  await registrySupervisor;
8742
8758
  await Promise.all([...loopResults.values()]);
8743
- await telegramSupervisor;
8759
+ // The follower was aborted with the stop. A send or a chat turn it is still
8760
+ // finishing keeps its own durable record; the service does not wait past
8761
+ // this for it.
8762
+ await new Promise(done => {
8763
+ const timer = setTimeout(done, TELEGRAM_STOP_WAIT_MS);
8764
+ const finish = () => { clearTimeout(timer); done(); };
8765
+ void telegramSupervisor.then(finish, finish);
8766
+ });
8744
8767
  clearInterval(runnerHeartbeat);
8745
8768
  if (runTimer !== undefined)
8746
8769
  clearTimeout(runTimer);
@@ -9630,7 +9653,9 @@ async function statusCommand(positional, flags, context) {
9630
9653
  }
9631
9654
  if (positional.length > 0)
9632
9655
  return fail(context.write, context.json, command, "usage", "Use `toolroll status [--json]`.", EXIT.usage);
9633
- const status = installationStatus(context.store, context.clock());
9656
+ // Whose lead the status line follows: the lead's own person, else the remembered login; nobody shows no lead.
9657
+ const viewer = currentActor()?.account ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name ?? null;
9658
+ const status = installationStatus(context.store, context.clock(), viewer);
9634
9659
  // One line, only when a newer Toolroll exists; offline or switched off says nothing.
9635
9660
  const current = PACKAGE_VERSION;
9636
9661
  const method = installMethod(context.installBin);
@@ -9770,6 +9795,19 @@ function taskCommand(positional, flags, context) {
9770
9795
  context.write(OPERATE_HELP);
9771
9796
  return EXIT.ok;
9772
9797
  }
9798
+ // The lead's work on a task it took on counts as acting on it (lead-voice.ts): its claim stays fresh.
9799
+ const actor = currentActor();
9800
+ if (actor?.lead === true && rest[0] !== undefined && LEAD_WORK_VERBS.has(action)) {
9801
+ const noted = (code) => { if (code === EXIT.ok)
9802
+ noteLeadWork(context.store, actor, rest[0], action, context.clock()); return code; };
9803
+ const done = taskAction(action, rest, flags, context);
9804
+ return typeof done === "number" ? noted(done) : done.then(noted);
9805
+ }
9806
+ return taskAction(action, rest, flags, context);
9807
+ }
9808
+ /** Task verbs that are the lead working on a task, not reading it. */
9809
+ const LEAD_WORK_VERBS = new Set(["state", "steer", "reopen", "requeue", "regate", "repair", "resume", "stop", "scope", "approve", "plan", "checks", "revise", "hold", "unhold", "route", "require"]);
9810
+ function taskAction(action, rest, flags, context) {
9773
9811
  switch (action) {
9774
9812
  case "complete":
9775
9813
  case "revise":
@@ -10598,9 +10636,11 @@ function askTask(positional, flags, context) {
10598
10636
  async function leadCommand(positional, flags, context) {
10599
10637
  const { store, write, json } = context;
10600
10638
  const [action, ...extra] = positional;
10639
+ if (action === "say")
10640
+ return leadSayCommand(extra, flags, context);
10601
10641
  const command = "lead token";
10602
10642
  if (action !== "token" || extra.length > 0)
10603
- return fail(write, json, `lead ${action ?? ""}`.trim(), "usage", "`toolroll lead token [--revoke] --as <you> --token <password>`", EXIT.usage);
10643
+ return fail(write, json, `lead ${action ?? ""}`.trim(), "usage", "`toolroll lead token [--revoke] --as <you> --token <password>` · `toolroll lead say \"<text>\" [--task <id>]` with the lead token", EXIT.usage);
10604
10644
  for (const name of flags.keys())
10605
10645
  if (!["as", "token", "revoke", "db", "json"].includes(name))
10606
10646
  return fail(write, json, command, "usage", `--${name} is not a lead token option.`, EXIT.usage);
@@ -10624,6 +10664,42 @@ async function leadCommand(positional, flags, context) {
10624
10664
  "it reaches you when the lead asks you (task ask), when it fails with nothing left to try, or for a security alert.",
10625
10665
  ]);
10626
10666
  }
10667
+ /** `lead say "<text>" [--task <id>]` with the lead token: one short message in its person's chat from "Your lead".
10668
+ * Several within two minutes are one message. */
10669
+ function leadSayCommand(positional, flags, context) {
10670
+ const { store, write, json } = context;
10671
+ const command = "lead say";
10672
+ for (const name of flags.keys())
10673
+ if (!["task", "db", "json"].includes(name))
10674
+ return fail(write, json, command, "usage", `--${name} is not a lead say option.`, EXIT.usage);
10675
+ const task = flags.get("task");
10676
+ if (positional.length === 0 || task === true)
10677
+ return fail(write, json, command, "usage", "`toolroll lead say \"<what you're doing>\" [--task <id>]` with the lead token", EXIT.usage);
10678
+ const actor = currentActor();
10679
+ if (actor?.lead !== true)
10680
+ return fail(write, json, command, "unauthenticated", "Only the lead speaks as the lead: pass its lead token (--token or TOOLROLL_LEAD_TOKEN).", EXIT.refused);
10681
+ const said = leadSay(store, actor, positional.join(" "), task ?? null, context.clock());
10682
+ if (!said.ok)
10683
+ return fail(write, json, command, said.reason, said.message, said.reason === "usage" ? EXIT.usage : EXIT.refused);
10684
+ return succeed(write, json, command, { owner: actor.account, task: task ?? null, joined: said.joined, notification: said.notification }, () => [said.joined ? `Added to your last message to ${actor.account}.` : `Told ${actor.account}.`]);
10685
+ }
10686
+ /** `assignment claim <task>` with the lead token: the task reads "Your lead is on it" and leaves Needs you until the
10687
+ * lead completes it, hands it on (task ask), or two hours pass with no lead act on it. */
10688
+ function leadClaimCommand(positional, flags, context) {
10689
+ const { store, write, json } = context;
10690
+ const command = "assignment claim";
10691
+ for (const name of flags.keys())
10692
+ if (!["db", "json"].includes(name))
10693
+ return fail(write, json, command, "usage", `--${name} is not an assignment claim option.`, EXIT.usage);
10694
+ const id = positional[1];
10695
+ if (id === undefined || positional.length !== 2)
10696
+ return fail(write, json, command, "usage", "assignment claim takes exactly one task id.", EXIT.usage);
10697
+ const actor = currentActor();
10698
+ const claimed = leadClaim(store, actor, id, context.clock());
10699
+ if (!claimed.ok)
10700
+ return fail(write, json, command, claimed.reason, claimed.message, claimed.reason === "usage" ? EXIT.usage : EXIT.refused);
10701
+ return succeed(write, json, command, { task: id, root: claimed.root, lead: claimed.claim }, () => [`${id} is yours: it reads "Your lead is on it" until you complete it, hand it on with task ask, or go two hours without acting on it.`]);
10702
+ }
10627
10703
  function blockTask(positional, flags, context) {
10628
10704
  const { store, write, json } = context;
10629
10705
  const id = positional[0];
package/dist/serve.d.ts CHANGED
@@ -189,10 +189,14 @@ export type ServeOptions = {
189
189
  /** Where `up`'s one-time sign-in link points, and how long it works. */
190
190
  export declare const SIGN_IN_LINK_PATH = "/login/once/";
191
191
  export declare const SIGN_IN_LINK_MS: number;
192
- /** The console's server, and the one-time sign-in link `up` opens: a path on this server, or null for no such approver. */
192
+ /** The console's server, and the one-time sign-in link `up` opens: a path on this server, or null for no such approver.
193
+ * closeCoding starts the coding shutdown on its own, ahead of the rest of a stop; close() awaits the same promise. */
193
194
  export type DecisionServer = Server & {
194
195
  mintSignInLink(account: string): string | null;
196
+ closeCoding(): Promise<void>;
195
197
  };
198
+ /** How long a stop waits for teammates and the lead's follow pass before closing anyway. */
199
+ export declare const SHUTDOWN_WAIT_MS = 5000;
196
200
  export declare function createDecisionServer(options: ServeOptions): DecisionServer;
197
201
  /**
198
202
  * A vscode://file href, or null (arc 6, finding 2): the link exists only