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
package/dist/operate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { UNSENT_REPLY_MS } from "./telegram-settings.js";
1
2
  import { maybeTriggerRepair } from "./dispose.js";
2
3
  import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
3
4
  import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
@@ -88,6 +89,7 @@ import { pushPass } from "./push.js";
88
89
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
89
90
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
90
91
  import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
92
+ import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
91
93
  import { createServer as createNetServer } from "node:net";
92
94
  import { spawn as spawnChild } from "node:child_process";
93
95
  import { envelopeJson } from "./envelope.js";
@@ -138,7 +140,7 @@ import { diagnoseTaskDispatch, diagnosisIsDispatchable, scopeApprovedForDispatch
138
140
  import { createDecisionServer } from "./serve.js";
139
141
  import { readTailnetNames } from "./tailnet.js";
140
142
  import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
141
- import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
143
+ import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, networkFence, networkFenceLine, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
142
144
  import { scanRepo } from "./capscan.js";
143
145
  import { computeGaps, describeCapability } from "./gaps.js";
144
146
  import { ask, askHidden, confirm, interactive, underAgent } from "./prompt.js";
@@ -637,6 +639,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
637
639
  "replaced-by", "replaces",
638
640
  // Check levels: a project's level, the quick command, a task's choice.
639
641
  "quick", "level", "checks",
642
+ // lead say: the task the lead's words are about.
643
+ "task",
640
644
  ]);
641
645
  export const OPERATE_BOOLEAN_FLAGS = new Set([
642
646
  "json", "yes", "all", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
@@ -820,6 +824,7 @@ export async function runOperate(command, argv, write, options = {}) {
820
824
  ...(options.agentRunner === undefined ? {} : { agentRunner: options.agentRunner }),
821
825
  ...(options.gitRunner === undefined ? {} : { gitRunner: options.gitRunner }),
822
826
  ...(options.telegramTransport === undefined ? {} : { telegramTransport: options.telegramTransport }),
827
+ ...(options.telegramEnv === undefined ? {} : { telegramEnv: options.telegramEnv }),
823
828
  ...(options.publishExec === undefined ? {} : { publishExec: options.publishExec }),
824
829
  ...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
825
830
  ...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
@@ -966,6 +971,9 @@ async function dispatch(command, positional, flags, context) {
966
971
  return projectConcurrencyCommand(positional, flags, context);
967
972
  return runProjectCommand(positional, flags, context);
968
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);
969
977
  return runAssignmentCommand(positional, flags, context);
970
978
  case "knowledge":
971
979
  return runKnowledgeCommand(positional, flags, { ...context, now: context.clock() });
@@ -7288,6 +7296,52 @@ async function daemonCommand(positional, flags, context) {
7288
7296
  const WATCH_LEASE_MS = 90_000;
7289
7297
  const WATCH_HEARTBEAT_MS = 30_000;
7290
7298
  /** The start repair runs once per process, not once per project loop. */
7299
+ /**
7300
+ * The Telegram follower: one actor on the wire that sends notifications and
7301
+ * replies and reads what arrives, all over the same transport. Null when no
7302
+ * bot token is saved, or when this process's network is fenced (it says so
7303
+ * once instead of failing every send).
7304
+ */
7305
+ function startTelegramFollower(args) {
7306
+ const { context, progress, label } = args;
7307
+ const source = loadBotToken(process.env, context.telegramTokenFile);
7308
+ if (source === null)
7309
+ return null;
7310
+ const fence = networkFence(context.telegramEnv ?? (context.telegramTransport === undefined ? process.env : {}));
7311
+ if (fence !== null) {
7312
+ progress(networkFenceLine(fence));
7313
+ return null;
7314
+ }
7315
+ const transport = context.telegramTransport ?? createTransport(source.token);
7316
+ // v98: with a public hooks address (Tailscale Funnel covers /hooks), Telegram pushes updates to it instead.
7317
+ const pushUrl = telegramPushUrl(readHooksBase(dirname(context.databaseFile)));
7318
+ return followBridge(context.store, {
7319
+ push: pushUrl === null ? null : { url: pushUrl, secret: telegramHookSecret(dirname(context.databaseFile), true) },
7320
+ readProjects: telegramReadProjects(context),
7321
+ canDeliver: telegramCanDeliver(context, source.token),
7322
+ conversation: telegramConversation(context, args.publicUrl === undefined ? {} : { serverOrigin: args.publicUrl }),
7323
+ botId: source.botId,
7324
+ transport,
7325
+ signal: args.signal,
7326
+ ...(args.owner === undefined ? {} : { owner: args.owner }),
7327
+ onCycle: cycle => {
7328
+ // progress(), not write(): under --json this line went to stdout
7329
+ // BESIDE the final envelope — the one stdout contamination in the
7330
+ // watch (round-4 finding 12).
7331
+ progress(`${label} sent ${cycle.sent}, answered ${cycle.answered}, paired ${cycle.paired}` +
7332
+ ((cycle.statusReplies ?? 0) > 0 ? `, status replies ${cycle.statusReplies}` : "") +
7333
+ ((cycle.chatQueued ?? 0) > 0 ? `, chat received ${cycle.chatQueued}` : "") +
7334
+ ((cycle.chatAnswered ?? 0) > 0 ? `, chat replied ${cycle.chatAnswered}` : "") +
7335
+ ((cycle.chatConfirmed ?? 0) > 0 ? `, chat confirmed ${cycle.chatConfirmed}` : "") +
7336
+ (cycle.problems.length > 0 ? ` — ${cycle.problems.length} problem(s): ${cycle.problems[0].slice(0, 200)}` : ""));
7337
+ },
7338
+ }).catch(error => {
7339
+ progress(`${args.died.prefix}the telegram follower died — ${describe(error)}; ${args.died.then}`);
7340
+ return null;
7341
+ });
7342
+ }
7343
+ /** How long a stopping service waits for the aborted Telegram follower. */
7344
+ const TELEGRAM_STOP_WAIT_MS = 5_000;
7291
7345
  let staleStatusesRepaired = false;
7292
7346
  async function runWatchLoop(args) {
7293
7347
  const { context, flags, runner, token, repo, progress } = args;
@@ -7366,38 +7420,13 @@ async function runWatchLoop(args) {
7366
7420
  // moment they arrive, and answering bumps the wake sequence, so the very
7367
7421
  // loop below wakes and resumes the freed task — phone to build, seconds.
7368
7422
  // The poll lease keeps this the only live poller; a cron `bridge
7369
- // telegram` overlapping it loses the lease race and reports busy.
7370
- let follower = null;
7371
- const followSource = loadBotToken(process.env, context.telegramTokenFile);
7372
- if (followSource !== null) {
7373
- const transport = context.telegramTransport ?? createTransport(followSource.token);
7374
- const publicUrl = text(flags, "public-url");
7375
- // v98: with a public hooks address (Tailscale Funnel covers /hooks), Telegram pushes updates to it instead.
7376
- const pushUrl = telegramPushUrl(readHooksBase(dirname(context.databaseFile)));
7377
- follower = followBridge(store, {
7378
- push: pushUrl === null ? null : { url: pushUrl, secret: telegramHookSecret(dirname(context.databaseFile), true) },
7379
- readProjects: telegramReadProjects(context),
7380
- canDeliver: telegramCanDeliver(context, followSource.token),
7381
- conversation: telegramConversation(context, publicUrl === undefined ? {} : { serverOrigin: publicUrl }),
7382
- botId: followSource.botId,
7383
- transport,
7384
- signal: followController.signal,
7385
- onCycle: cycle => {
7386
- // progress(), not write(): under --json this line went to stdout
7387
- // BESIDE the final envelope — the one stdout contamination in the
7388
- // watch (round-4 finding 12).
7389
- progress(`watch: bridge sent ${cycle.sent}, answered ${cycle.answered}, paired ${cycle.paired}` +
7390
- ((cycle.statusReplies ?? 0) > 0 ? `, status replies ${cycle.statusReplies}` : "") +
7391
- ((cycle.chatQueued ?? 0) > 0 ? `, chat received ${cycle.chatQueued}` : "") +
7392
- ((cycle.chatAnswered ?? 0) > 0 ? `, chat replied ${cycle.chatAnswered}` : "") +
7393
- ((cycle.chatConfirmed ?? 0) > 0 ? `, chat confirmed ${cycle.chatConfirmed}` : "") +
7394
- (cycle.problems.length > 0 ? ` — ${cycle.problems.length} problem(s): ${cycle.problems[0].slice(0, 200)}` : ""));
7395
- },
7396
- }).catch(error => {
7397
- progress(`watch: the telegram follower died — ${describe(error)}; taps wait for the next watch`);
7398
- return null;
7399
- });
7400
- }
7423
+ // telegram` overlapping it loses the lease race and reports busy. Under
7424
+ // `up` the service runs the one follower itself and project loops leave
7425
+ // Telegram to it.
7426
+ const serviceBridge = args.serviceBridge === true;
7427
+ const follower = serviceBridge
7428
+ ? null
7429
+ : startTelegramFollower({ context, publicUrl: text(flags, "public-url"), signal: followController.signal, progress, label: "watch: bridge", died: { prefix: "watch: ", then: "taps wait for the next watch" } });
7401
7430
  const slackFollower = followSlack({ store, dir: dirname(context.databaseFile), signal: followController.signal,
7402
7431
  readProjects: telegramReadProjects(context), evidenceRoot: context.evidenceRoot,
7403
7432
  ...(context.mateSeams?.subscriptionRunner ? { subscriptionRunner: context.mateSeams.subscriptionRunner } : {}),
@@ -7690,7 +7719,7 @@ async function runWatchLoop(args) {
7690
7719
  // pass is the fallback shape for a watch started before a token existed.
7691
7720
  if (follower === null && now - lastBridge >= bridgeEveryMs) {
7692
7721
  lastBridge = now;
7693
- const source = loadBotToken(process.env, context.telegramTokenFile);
7722
+ const source = serviceBridge ? null : loadBotToken(process.env, context.telegramTokenFile);
7694
7723
  const dir = dirname(context.databaseFile);
7695
7724
  // Exactly ONE service carries the pages — the chosen primary, or
7696
7725
  // the sensible implicit one. Telegram keeps draining taps and
@@ -8467,6 +8496,10 @@ async function upCommand(flags, context) {
8467
8496
  resolveStopped();
8468
8497
  for (const controller of followControllers)
8469
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(() => { });
8470
8503
  const building = store.liveClaimCount(runnerName, clock());
8471
8504
  if (building > 0) {
8472
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.`);
@@ -8538,6 +8571,7 @@ async function upCommand(flags, context) {
8538
8571
  onFollowController: controller => followControllers.push(controller),
8539
8572
  containmentLines,
8540
8573
  passes: projectPasses,
8574
+ serviceBridge: true,
8541
8575
  onReady: () => {
8542
8576
  markReady();
8543
8577
  if (bind)
@@ -8557,6 +8591,37 @@ async function upCommand(flags, context) {
8557
8591
  await ready;
8558
8592
  };
8559
8593
  await Promise.all(repos.map(repo => launchRepo(repo, false)));
8594
+ // Telegram: one follower for the whole service, never one per project
8595
+ // loop. Replies and notifications go out from it, over the same transport
8596
+ // in the same cycle (Oct 2: a reply sent by a project loop's own follower
8597
+ // failed "fetch failed" while notifications went out). A token saved
8598
+ // while the service runs connects within seconds; a follower that dies
8599
+ // is started again.
8600
+ const telegramController = new AbortController();
8601
+ followControllers.push(telegramController);
8602
+ const telegramSupervisor = (async () => {
8603
+ const pause = (ms) => new Promise(resolvePause => {
8604
+ const timer = setTimeout(done, ms);
8605
+ function done() { clearTimeout(timer); telegramController.signal.removeEventListener("abort", done); resolvePause(); }
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 });
8611
+ });
8612
+ while (!stopping && !telegramController.signal.aborted) {
8613
+ const follower = startTelegramFollower({
8614
+ context, publicUrl: text(flags, "public-url"), signal: telegramController.signal, progress, label: "telegram:",
8615
+ died: { prefix: "", then: "starting it again" }, owner: `service-${randomUUID()}`,
8616
+ });
8617
+ if (follower !== null)
8618
+ await follower;
8619
+ // Without a token (or on a fenced network) look again shortly; the fence line is said once.
8620
+ if (follower === null && loadBotToken(process.env, context.telegramTokenFile) !== null)
8621
+ break;
8622
+ await pause(follower === null ? 2_000 : 5_000);
8623
+ }
8624
+ })();
8560
8625
  const dynamicCeiling = resolveCeiling(repos, projectRoots).ceiling;
8561
8626
  const rejected = new Set();
8562
8627
  const registrySupervisor = (async () => {
@@ -8691,6 +8756,14 @@ async function upCommand(flags, context) {
8691
8756
  await stopped;
8692
8757
  await registrySupervisor;
8693
8758
  await Promise.all([...loopResults.values()]);
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
+ });
8694
8767
  clearInterval(runnerHeartbeat);
8695
8768
  if (runTimer !== undefined)
8696
8769
  clearTimeout(runTimer);
@@ -8738,6 +8811,7 @@ async function upCommand(flags, context) {
8738
8811
  * bridge telegram token [<bot-token>|--clear] set the credential file
8739
8812
  * bridge telegram status
8740
8813
  * bridge telegram digest [--every 30m|2h|24h | --off] away mode: routine facts batch, decisions still page
8814
+ * bridge telegram retry send unsent assistant replies again now
8741
8815
  *
8742
8816
  * The bot token comes from ${TOKEN_ENV} or the credential file this command
8743
8817
  * writes (0600, beside the database). Cron the pass right after tick; a
@@ -8754,7 +8828,14 @@ async function bridgeCommand(positional, flags, context) {
8754
8828
  return demoFence;
8755
8829
  const [channel, action] = positional;
8756
8830
  if (channel !== "telegram") {
8757
- return fail(write, json, "bridge", "usage", "`toolroll bridge telegram [pair|unpair|token|status|digest]`", EXIT.usage);
8831
+ return fail(write, json, "bridge", "usage", "`toolroll bridge telegram [pair|unpair|token|status|digest|retry]`", EXIT.usage);
8832
+ }
8833
+ if (action === "retry") {
8834
+ // Replies already written and fenced on the pairing like every send: this only moves their next attempt to now.
8835
+ const retried = store.retryTelegramReplies(null, clock());
8836
+ return succeed(write, json, "bridge retry", { retried }, () => [
8837
+ retried === 0 ? "No unsent Telegram replies." : `Sending ${retried} Telegram ${retried === 1 ? "reply" : "replies"} again on the next bridge pass.`,
8838
+ ]);
8758
8839
  }
8759
8840
  if (action === "token") {
8760
8841
  const value = positional[2];
@@ -8871,6 +8952,9 @@ async function bridgeCommand(positional, flags, context) {
8871
8952
  if (source === null) {
8872
8953
  return fail(write, json, "bridge", "no-token", `no bot token — set ${TOKEN_ENV}, run \`toolroll bridge telegram token <t>\`, or use the serve settings card`, EXIT.refused);
8873
8954
  }
8955
+ const fenced = networkFence(context.telegramEnv ?? (context.telegramTransport === undefined ? process.env : {}));
8956
+ if (fenced !== null)
8957
+ return fail(write, json, "bridge", "network-fenced", networkFenceLine(fenced), EXIT.refused);
8874
8958
  const transport = context.telegramTransport ?? createTransport(source.token);
8875
8959
  // --follow: stay on the wire. One long-poll actor holds the poll lease;
8876
8960
  // an answer tapped on a phone lands in seconds instead of at the next
@@ -9569,7 +9653,9 @@ async function statusCommand(positional, flags, context) {
9569
9653
  }
9570
9654
  if (positional.length > 0)
9571
9655
  return fail(context.write, context.json, command, "usage", "Use `toolroll status [--json]`.", EXIT.usage);
9572
- 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);
9573
9659
  // One line, only when a newer Toolroll exists; offline or switched off says nothing.
9574
9660
  const current = PACKAGE_VERSION;
9575
9661
  const method = installMethod(context.installBin);
@@ -9588,7 +9674,18 @@ async function statusCommand(positional, flags, context) {
9588
9674
  const projects = projectBuildsStatus(context);
9589
9675
  const projectsLine = projects.length === 0 ? null
9590
9676
  : `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
9591
- return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting }, () => [...(waiting === null ? [] : [waiting.words]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9677
+ // A Telegram reply written but still not sent after two minutes, with its Retry.
9678
+ const unsent = context.store.unsentTelegramReplies(null, null, new Date(context.clock().getTime() - UNSENT_REPLY_MS));
9679
+ const unsentLine = unsentRepliesLine(unsent);
9680
+ const unsentReplies = unsent.length === 0 ? {} : { unsentReplies: unsent.map(one => ({ ...one, retry: "toolroll bridge telegram retry" })) };
9681
+ return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9682
+ }
9683
+ function unsentRepliesLine(unsent) {
9684
+ if (unsent.length === 0)
9685
+ return null;
9686
+ const oldest = unsent[0];
9687
+ const what = unsent.length === 1 ? "A Telegram reply hasn't" : `${unsent.length} Telegram replies haven't`;
9688
+ return `${what} been sent since ${oldest.since.slice(11, 16)} UTC${oldest.error === null ? "" : ` (${oldest.error})`}. Retry: toolroll bridge telegram retry`;
9592
9689
  }
9593
9690
  /** Each known project's running builds against its limit: its own number, never past its workers' capacity. */
9594
9691
  function projectBuildsStatus(context) {
@@ -9698,6 +9795,19 @@ function taskCommand(positional, flags, context) {
9698
9795
  context.write(OPERATE_HELP);
9699
9796
  return EXIT.ok;
9700
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) {
9701
9811
  switch (action) {
9702
9812
  case "complete":
9703
9813
  case "revise":
@@ -10526,9 +10636,11 @@ function askTask(positional, flags, context) {
10526
10636
  async function leadCommand(positional, flags, context) {
10527
10637
  const { store, write, json } = context;
10528
10638
  const [action, ...extra] = positional;
10639
+ if (action === "say")
10640
+ return leadSayCommand(extra, flags, context);
10529
10641
  const command = "lead token";
10530
10642
  if (action !== "token" || extra.length > 0)
10531
- 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);
10532
10644
  for (const name of flags.keys())
10533
10645
  if (!["as", "token", "revoke", "db", "json"].includes(name))
10534
10646
  return fail(write, json, command, "usage", `--${name} is not a lead token option.`, EXIT.usage);
@@ -10552,6 +10664,42 @@ async function leadCommand(positional, flags, context) {
10552
10664
  "it reaches you when the lead asks you (task ask), when it fails with nothing left to try, or for a security alert.",
10553
10665
  ]);
10554
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
+ }
10555
10703
  function blockTask(positional, flags, context) {
10556
10704
  const { store, write, json } = context;
10557
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