toolroll 0.9.11 → 0.9.13
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.
- package/CHANGELOG.md +24 -0
- package/dist/assignment-ui.js +1 -1
- package/dist/assignment.d.ts +3 -0
- package/dist/assignment.js +3 -1
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +25 -23
- package/dist/browser-crew.js +1 -1
- package/dist/browser-workspace.d.ts +79 -0
- package/dist/chat-delivery.js +17 -0
- package/dist/chat-quiet.js +1 -0
- package/dist/chat-voice.d.ts +3 -1
- package/dist/chat-voice.js +13 -5
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/coding-update.d.ts +13 -0
- package/dist/coding-update.js +68 -0
- package/dist/desktop-update.d.ts +7 -0
- package/dist/desktop-update.js +42 -1
- package/dist/flow-triggers.js +17 -1
- package/dist/guides.js +9 -0
- package/dist/lead-status.d.ts +10 -1
- package/dist/lead-status.js +13 -3
- package/dist/lead-voice.d.ts +81 -0
- package/dist/lead-voice.js +275 -0
- package/dist/needs-you.d.ts +7 -0
- package/dist/needs-you.js +9 -0
- package/dist/operate.js +80 -4
- package/dist/result-acts.d.ts +44 -0
- package/dist/result-acts.js +36 -0
- package/dist/result-review.js +1 -1
- package/dist/serve.d.ts +6 -1
- package/dist/serve.js +500 -239
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.js +8 -0
- package/dist/surface.js +8 -1
- package/dist/task-status.d.ts +6 -0
- package/dist/task-status.js +19 -2
- package/dist/team-cli.js +1 -1
- package/dist/telegram-progress.js +7 -0
- package/dist/telegram.js +34 -3
- package/dist/test-temp.js +1 -1
- package/dist/toolroll-update.d.ts +3 -0
- package/dist/toolroll-update.js +13 -2
- package/dist/work-index.d.ts +9 -0
- package/dist/work-index.js +57 -20
- package/dist/work-summary.d.ts +4 -1
- package/dist/workspace-ui.d.ts +29 -0
- package/dist/workspace-ui.js +32 -3
- package/package.json +1 -1
package/dist/lead-status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/needs-you.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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];
|