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/telegram.js CHANGED
@@ -31,6 +31,7 @@ import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_
31
31
  import { telegramProgressCard } from "./telegram-progress.js";
32
32
  import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
33
33
  import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
34
+ import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText } from "./lead-voice.js";
34
35
  import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
35
36
  import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
36
37
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
@@ -194,7 +195,7 @@ export function createTransport(token, timeoutMs = 30_000) {
194
195
  catch (error) {
195
196
  return {
196
197
  ok: false,
197
- description: scrub(error instanceof Error ? error.message : String(error), token),
198
+ description: scrub(transportError(error), token),
198
199
  uncertain: true,
199
200
  };
200
201
  }
@@ -204,6 +205,48 @@ export function createTransport(token, timeoutMs = 30_000) {
204
205
  }
205
206
  };
206
207
  }
208
+ /**
209
+ * Why this process can't reach Telegram the way the service does, or null.
210
+ * A coding agent's sandbox fences the network: Codex says so in its own
211
+ * variables, and Claude Code's sandbox routes traffic through a proxy on
212
+ * this computer, which curl uses and Node's fetch does not — so sends fail
213
+ * "fetch failed" while curl works (Oct 2). Builds, checks and agents also
214
+ * get a database of their own, so none of them can hold the live bridge;
215
+ * this keeps an agent-launched `watch` or `bridge` from holding it either.
216
+ */
217
+ export function networkFence(env) {
218
+ const set = (name) => (env[name] ?? "") !== "";
219
+ if (set("CODEX_SANDBOX_NETWORK_DISABLED") || set("CODEX_SANDBOX"))
220
+ return "a Codex sandbox";
221
+ const agent = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"].some(set);
222
+ const loopbackProxy = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"].some(name => {
223
+ const value = env[name] ?? "";
224
+ if (value === "")
225
+ return false;
226
+ try {
227
+ return /^(?:localhost|127\.\d+\.\d+\.\d+|\[::1\])$/i.test(new URL(value.includes("://") ? value : `http://${value}`).hostname);
228
+ }
229
+ catch {
230
+ return false;
231
+ }
232
+ });
233
+ return agent && loopbackProxy ? "a Claude Code sandbox" : null;
234
+ }
235
+ /** The plain line a fenced process says instead of polling or sending. */
236
+ export function networkFenceLine(reason) {
237
+ return `Telegram: not connecting from here — this process runs inside ${reason}, which blocks its network. Replies and notifications go out from the Toolroll service.`;
238
+ }
239
+ /** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
240
+ export function transportError(error) {
241
+ const message = error instanceof Error ? error.message : String(error);
242
+ const cause = error instanceof Error ? error.cause : undefined;
243
+ if (cause === undefined || cause === null)
244
+ return message;
245
+ const code = typeof cause.code === "string" ? cause.code : null;
246
+ const detail = cause instanceof Error ? cause.message : String(cause);
247
+ const why = code !== null && !detail.includes(code) ? `${code}: ${detail}` : detail;
248
+ return why === "" || why === message ? message : `${message} (${why.slice(0, 160)})`;
249
+ }
207
250
  /** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
208
251
  export function multipartOf(params, upload) {
209
252
  const form = new FormData();
@@ -332,12 +375,16 @@ export async function followBridge(store, options) {
332
375
  // turn must not stall the long poll, and each cycle's lease re-acquire is
333
376
  // the renewal that keeps this the only live poller meanwhile. One
334
377
  // processor at a time; its counts land on the totals when it finishes.
378
+ // It only persists the reply: the cycle sends it, in the same slot as
379
+ // notifications, so a reply never needs a connection of its own beside
380
+ // the long poll (Oct 2: replies failed "fetch failed" for minutes while
381
+ // notifications from the same process went out).
335
382
  let inFlight = null;
336
383
  const kick = () => {
337
384
  if (inFlight !== null || options.conversation === undefined || options.readProjects === undefined)
338
385
  return;
339
386
  const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
340
- inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal)
387
+ inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal, "turns")
341
388
  .catch(error => { report.problems.push(`telegram chat: ${error instanceof Error ? error.message : String(error)}`); })
342
389
  .finally(() => {
343
390
  inFlight = null;
@@ -435,12 +482,13 @@ export async function followBridge(store, options) {
435
482
  // the picker first. Best effort; typed commands work either way.
436
483
  if (!commandsListed && options.conversation !== undefined) {
437
484
  commandsListed = true;
485
+ // Both carry the stop signal: a stop never waits on a slow Bot API for them.
438
486
  try {
439
- await transport("setMyCommands", { commands: TELEGRAM_COMMANDS });
487
+ await transport("setMyCommands", { commands: TELEGRAM_COMMANDS }, signal);
440
488
  }
441
489
  catch { /* typed commands still work */ }
442
490
  // After an upgrade, a bot still called StandingOrders becomes Toolroll, once per start; any other name is kept.
443
- await nameTelegramBot(transport, "upgrade");
491
+ await nameTelegramBot(transport, "upgrade", signal);
444
492
  }
445
493
  const startedAt = Date.now();
446
494
  const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
@@ -449,13 +497,18 @@ export async function followBridge(store, options) {
449
497
  await deliverOutbox(store, botId, transport, owner, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin, options.conversation?.evidenceRoot);
450
498
  await deliverTeam(store, botId, transport, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin);
451
499
  }
500
+ // Replies the turn worker has planned go out here, like notifications.
501
+ if (options.conversation !== undefined && options.readProjects !== undefined) {
502
+ await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal, "replies");
503
+ }
452
504
  // Pushed updates first (some may wait from before a fall back), then — only while
453
505
  // Telegram isn't pushing — ask for more.
454
506
  const pushed = await drainInbox({ store, botId, transport, clock, report, readProjects: options.readProjects, conversation: options.conversation, projects: null }, owner, lease.generation);
455
507
  if (pushed > 0)
456
508
  report.pushed = pushed;
457
509
  if (!pushing()) {
458
- await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, pollSeconds, signal, options.readProjects, options.conversation);
510
+ // While a turn is running, poll briefly so its reply is sent within a second or two of being written.
511
+ await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, inFlight === null ? pollSeconds : 1, signal, options.readProjects, options.conversation);
459
512
  // A push address nobody here wants any more (the public address was removed): take it down, so asking works again.
460
513
  if (report.webhookActive === true && push === null)
461
514
  await transport("deleteWebhook", { drop_pending_updates: false }, signal);
@@ -474,11 +527,13 @@ export async function followBridge(store, options) {
474
527
  total.chatRefused = (total.chatRefused ?? 0) + report.chatRefused;
475
528
  if (report.chatConfirmed !== undefined)
476
529
  total.chatConfirmed = (total.chatConfirmed ?? 0) + report.chatConfirmed;
530
+ if (report.chatAnswered !== undefined)
531
+ total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
477
532
  total.problems.push(...report.problems);
478
- if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
533
+ if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatAnswered ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
479
534
  options.onCycle?.(report);
480
535
  }
481
- if (report.problems.length > 0 && report.sent === 0 && report.answered === 0) {
536
+ if (report.problems.length > 0 && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0) {
482
537
  // The wire is down. Back off; the counter resets on the first clean cycle.
483
538
  if (!signal.aborted) {
484
539
  await wait(FOLLOW_BACKOFF_MS[Math.min(failures, FOLLOW_BACKOFF_MS.length - 1)]);
@@ -493,7 +548,7 @@ export async function followBridge(store, options) {
493
548
  const took = Date.now() - startedAt;
494
549
  // While Telegram pushes (v98) there is no long poll to wait on: a pushed update waits at most this long.
495
550
  const floor = pushing() ? PUSH_IDLE_MS : FOLLOW_IDLE_FLOOR_MS;
496
- if (!signal.aborted && report.sent === 0 && report.answered === 0 && took < floor) {
551
+ if (!signal.aborted && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0 && took < floor) {
497
552
  await wait(floor - took);
498
553
  }
499
554
  }
@@ -501,16 +556,25 @@ export async function followBridge(store, options) {
501
556
  finally {
502
557
  // A turn in flight finishes on its own bounds (the engine's wall clock);
503
558
  // its reply is fenced on the claim and the channel like every other part.
504
- if (inFlight !== null)
559
+ if (inFlight !== null) {
505
560
  await inFlight;
561
+ // Its reply was written for the cycle that will not come: send it now, while the lease is still ours.
562
+ if (options.conversation !== undefined && options.readProjects !== undefined) {
563
+ const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
564
+ await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, undefined, "replies").catch(() => { });
565
+ if (report.chatAnswered !== undefined)
566
+ total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
567
+ total.problems.push(...report.problems);
568
+ }
569
+ }
506
570
  store.releaseBridgeLease(botId, owner, clock());
507
571
  }
508
572
  return total;
509
573
  }
510
574
  /** The queued turns for one bot, counted onto the pass's report. */
511
- async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal) {
575
+ async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal, only) {
512
576
  const chat = { answered: 0, refused: 0, problems: [] };
513
- await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }) });
577
+ await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }), ...(only === undefined ? {} : { only }) });
514
578
  if (chat.answered > 0)
515
579
  report.chatAnswered = (report.chatAnswered ?? 0) + chat.answered;
516
580
  if (chat.refused > 0)
@@ -580,6 +644,8 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
580
644
  catch {
581
645
  report.problems.push("evening digest could not be prepared");
582
646
  }
647
+ // A lead claim that went two hours quiet repaints its owner's card back to the task's real state.
648
+ enqueueLeadLapses(store, now);
583
649
  const digestDue = digest.everyMs === null || digest.lastSentAt === null || now.getTime() >= new Date(digest.lastSentAt).getTime() + digest.everyMs;
584
650
  // Every paired person is a destination of their own: each binding claims
585
651
  // and settles its own rows under its own ceiling, in turn.
@@ -852,8 +918,35 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
852
918
  }
853
919
  return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
854
920
  };
921
+ /** The lead's words (lead-voice.ts): one message; a later say within two minutes edits it in place. */
922
+ const leadSayOutcome = async (fact) => {
923
+ const words = leadSayText(fact);
924
+ const button = fact.link === null ? null : factButton(phoneOrigin, fact.link);
925
+ const keyboard = button === null ? [] : [button];
926
+ const earlier = leadSayEarlier(store, fact).map(id => store.telegramMessageOf(id, fact.destination)).find(one => one !== null) ?? null;
927
+ if (earlier !== null) {
928
+ const problem = (await readAccess()) ?? fence();
929
+ if (problem !== null)
930
+ return { ok: false, error: problem };
931
+ const edited = await editProgress(transport, binding.chatId, earlier, words, keyboard);
932
+ if (edited.ok) {
933
+ store.recordTelegramMessage(fact, binding, earlier, clock());
934
+ const after = (await readAccess()) ?? fence();
935
+ return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, earlier) } : { ok: false, error: after };
936
+ }
937
+ if (edited.retryAfter !== undefined)
938
+ store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
939
+ // Only Telegram's definitive missing/uneditable answer permits a new message.
940
+ if (!edited.replace)
941
+ return edited;
942
+ }
943
+ const sent = await sender(words, keyboard.length === 0 ? undefined : keyboard, [fact]);
944
+ return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
945
+ };
855
946
  let outcome;
856
- if (quiet && rows.length === 1 && isTaskFact(row))
947
+ if (rows.length === 1 && row.kind === LEAD_SAY_KIND)
948
+ outcome = await leadSayOutcome(row);
949
+ else if (quiet && rows.length === 1 && isTaskFact(row))
857
950
  outcome = await quietOutcome(row);
858
951
  else {
859
952
  // A failure or decision still gets its own alert. Refresh an existing
package/dist/test-temp.js CHANGED
@@ -18,7 +18,7 @@ export const TEST_TEMP_PREFIXES = [
18
18
  "flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
19
19
  "proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
20
20
  "task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
21
- "needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-",
21
+ "needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-",
22
22
  // Playwright's browser profiles and artifacts.
23
23
  "playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
24
24
  ];
@@ -1,5 +1,6 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
2
  import { type LingeringRun, type UpdateWaiting } from "./desktop-update.js";
3
+ import { type ReleasedCodingOwner } from "./coding-update.js";
3
4
  import { type SupervisorRunner } from "./daemon.js";
4
5
  export declare const PROVENANCE_REPOSITORY = "https://github.com/ap9000/toolroll";
5
6
  export declare const PROVENANCE_WORKFLOW = ".github/workflows/publish.yml";
@@ -103,6 +104,8 @@ export type RuntimeUpdateJournal = {
103
104
  /** The restore put the backup back: a retried restore never puts it back again (what was written since belongs to
104
105
  * the restored version and would be lost), and it keeps a fresh copy aside before every attempt until then. */
105
106
  restoredDatabase?: boolean;
107
+ /** A stale coding owner record this run released after proving its processes gone. */
108
+ codingOwnerReleased?: ReleasedCodingOwner;
106
109
  seen?: boolean;
107
110
  /** The finished run the update is waiting on (or stopped waiting on): what is in the way and the command that
108
111
  * clears it. Absent while it waits on ordinary running work. */
@@ -31,7 +31,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
31
31
  import { createRequire } from "node:module";
32
32
  import { clearWords, durableJson, LINGERING_LIMIT_MS, lingeringRun, lingeringWords, settleLeftoverRecords, sqliteLock, desktopUpdateWaitingOf, stoppedWaitingWords, updateWaitingWords, verifiedDatabaseBackup } from "./desktop-update.js";
33
33
  import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused, updateGateOwned } from "./desktop-update-gate.js";
34
- import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, removeCodingUpdateGate } from "./coding-update.js";
34
+ import { processMayBeAlive } from "./process-liveness.js";
35
+ import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
35
36
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
36
37
  import { NAME } from "./names.js";
37
38
  import { isNewer, REGISTRY } from "./releases.js";
@@ -887,6 +888,12 @@ async function driveRuntimeUpdate(j, system) {
887
888
  refuseWhileWatching(j, system);
888
889
  const db = new (sqlite().DatabaseSync)(j.databaseFile, { readOnly: true });
889
890
  try {
891
+ // An older runtime killed before its close left its owner record behind: released once its processes are proved gone.
892
+ const released = releaseStaleCodingOwner(db, [...j.service?.pids ?? [], ...(j.watches ?? []).flatMap(w => w.pids)], (pid, group) => system.processAlive(pid) || processMayBeAlive(pid, group));
893
+ if (released !== null) {
894
+ j.codingOwnerReleased = released;
895
+ ledger(j.databaseFile, system.now(), j.actor, "toolroll coding owner released", "released", `${j.from.version} stopped without releasing the coding workspace; process ${released.pid}${released.nativePid === null ? "" : ` and agent ${released.nativePid}`} proved gone`);
896
+ }
890
897
  assertCodingUpdateStopped(db);
891
898
  }
892
899
  finally {
@@ -1392,7 +1399,11 @@ export function machineSystem(home = homedir(), env = process.env, seams = {}) {
1392
1399
  servicePids: async (unit) => {
1393
1400
  const printed = await supervise("launchctl", ["print", `${domain}/${labelOf(unit)}`]);
1394
1401
  const pid = Number(printed.stdout.match(/\n\s*pid = (\d+)/)?.[1]);
1395
- return printed.code === 0 && pid > 1 ? [pid] : [];
1402
+ if (printed.code !== 0 || !(pid > 1))
1403
+ return [];
1404
+ // Its `up` child too: the process that owns the coding catalog, so its exit is proved as well.
1405
+ const children = await supervise("pgrep", ["-P", String(pid)]);
1406
+ return [pid, ...(children.code === 0 ? children.stdout.trim().split(/\s+/).map(Number).filter(one => Number.isSafeInteger(one) && one > 1) : [])];
1396
1407
  },
1397
1408
  stopService: async (unit) => {
1398
1409
  // As long as a watch's toolroll up takes to exit.
@@ -2,6 +2,7 @@ import { type Store, type TaskState } from './store.js';
2
2
  import type { AssignmentSnapshot } from './assignment.js';
3
3
  import type { WorkAction, WorkSummaryAccess } from './work-summary.js';
4
4
  import type { WorkStatus, WorkView } from './workspace-ui.js';
5
+ import { type Ask } from './needs-you.js';
5
6
  export type WorkIndexCounts = Record<WorkView, number>;
6
7
  export type WorkIndexItem = {
7
8
  rootId: string;
@@ -22,6 +23,8 @@ export type WorkIndexItem = {
22
23
  resultOutcome: string | null;
23
24
  publicationUrl: string | null;
24
25
  status: WorkStatus;
26
+ /** What a Needs you row asks of a person (needs-you.ts); null for every other row. */
27
+ ask: Ask | null;
25
28
  primaryAction: WorkAction | null;
26
29
  completion: {
27
30
  actor: string;
@@ -38,9 +41,13 @@ export type WorkIndexOptions = {
38
41
  state?: TaskState;
39
42
  leadId?: string;
40
43
  };
44
+ /** How the list is grouped: the Needs you rows by their ask, then building, then the rest. */
45
+ export type WorkIndexGroup = Ask | 'building' | 'rest';
46
+ export type WorkIndexGroupCounts = Record<WorkIndexGroup, number>;
41
47
  export type WorkIndexPage = {
42
48
  items: WorkIndexItem[];
43
49
  totals: WorkIndexCounts;
50
+ groups: WorkIndexGroupCounts;
44
51
  projects: WorkProjectCounts[];
45
52
  nextCursor: string | null;
46
53
  limit: number;
@@ -54,6 +61,8 @@ export type WorkProjectCounts = {
54
61
  };
55
62
  export declare const WORK_INDEX_PAGE_LIMIT = 40;
56
63
  export declare const WORK_INDEX_MAX_LIMIT = 100;
64
+ /** A code's ask: a saved result is Review (unless its build may still run), otherwise its need's ask. */
65
+ export declare function askOfCode(code: string, custody?: boolean): Ask;
57
66
  export declare class WorkIndexCursorError extends Error {
58
67
  constructor();
59
68
  }
@@ -11,10 +11,12 @@ import { plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } f
11
11
  import { isCheckLevel } from './check-levels.js';
12
12
  import { MARKER } from './worktree.js';
13
13
  import { withFollowUps } from './result-follow-ups.js';
14
- import { NEEDS, processNeedOf } from './needs-you.js';
14
+ import { ASKS, NEED_ASK, NEEDS, processNeedOf } from './needs-you.js';
15
+ import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
15
16
  export const WORK_INDEX_PAGE_LIMIT = 40;
16
17
  export const WORK_INDEX_MAX_LIMIT = 100;
17
18
  const EMPTY = { all: 0, 'needs-you': 0, running: 0, completed: 0 };
19
+ const NO_GROUPS = { decide: 0, review: 0, unblock: 0, building: 0, rest: 0 };
18
20
  const FAMILY_PROBLEM = 'Task history is unavailable or incomplete. This task is shown separately.';
19
21
  const VIEWS = ['all', 'needs-you', 'running', 'completed'];
20
22
  const n = (row, key) => Number(row[key] ?? 0);
@@ -25,6 +27,27 @@ const s = (row, key) => row[key] == null ? null : String(row[key]);
25
27
  * MATERIALIZED prevents counts + page selection repeating correlated reads. */
26
28
  /** A gate note counts only while its pause is on one of the task's current providers. */
27
29
  const GATE_PAUSE_ON_TASK = pauseOnTaskProviders('p', 'c.ref_id');
30
+ /** Index codes that wait on a person, as their need (needs-you.ts); dispatch codes come from task-status's own table. */
31
+ const INDEX_NEED = {
32
+ 'earlier-active': 'earlier-version', 'process-needs-attention': 'confirm-stopped', 'invalid-scope': 'define-task', 'decision-queue': 'questions',
33
+ };
34
+ /** Every index code that can wait on a person. */
35
+ const PERSON_CODES = ['history-problem', 'earlier-active', 'ready-to-check', 'waiting-decision', 'decision-queue', 'result-needs-attention',
36
+ 'process-needs-attention', 'failed', 'vanished-run', 'stopping', 'stopped', 'held', 'waiting-incident', 'terminal-dependency', 'needs-project',
37
+ 'needs-scope', 'invalid-scope', 'needs-agent-profile', 'needs-approval', 'missing-requirement', 'no-worker-registered', 'no-worker-online', 'signed-out'];
38
+ /** A code's ask: a saved result is Review (unless its build may still run), otherwise its need's ask. */
39
+ export function askOfCode(code, custody = false) {
40
+ if (custody)
41
+ return 'unblock';
42
+ if (code === 'ready-to-check' || code === 'result-needs-attention')
43
+ return 'review';
44
+ return NEED_ASK[INDEX_NEED[code] ?? stageOfCode(code, { needsPerson: true }).need ?? 'other'];
45
+ }
46
+ const codesOf = (ask) => PERSON_CODES.filter(code => askOfCode(code) === ask).map(code => `'${code}'`).join(',');
47
+ /** The ask as its place in the list (ASKS order), read by the same code the row's status reads. */
48
+ const ASK_RANK = `CASE WHEN code IN (${codesOf('decide')}) THEN 0 WHEN code IN (${codesOf('review')}) AND NOT custody_unresolved THEN 1 ELSE 2 END`;
49
+ /** Where a row sits in the list: Decide, Review, Unblock, then building, then the rest by recency. */
50
+ const ORDER = `(CASE WHEN rank=0 THEN ask_rank WHEN rank=1 THEN 3 ELSE 4 END)`;
28
51
  /** A row whose build may still be running (the store's reading, custodyReadings): Waiting, not Needs you. */
29
52
  const CUSTODY_WAITS = `(custody_unresolved AND code IN ('process-needs-attention','result-needs-attention') AND ref_id IN (SELECT value FROM json_each($waitingCustody)))`;
30
53
  const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
@@ -204,13 +227,18 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
204
227
  WHEN deliverable='report' THEN 'scouting-ready'
205
228
  ELSE 'queued' END code
206
229
  FROM checked f
230
+ ), claimed AS MATERIALIZED (
231
+ SELECT c.*,CASE WHEN code IN ('cancelled','complete') THEN 0 ELSE ${LEAD_CLAIM_SQL} END lead_claim FROM classified c
207
232
  ), ranked AS MATERIALIZED (
208
- SELECT c.*,code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') AND NOT ${CUSTODY_WAITS} needs,
233
+ -- A task the person's lead is on (lead-voice.ts) waits on nobody: it leaves Needs you until the claim ends or lapses.
234
+ SELECT c.*,code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') AND NOT ${CUSTODY_WAITS} AND lead_claim<>1 needs,
235
+ code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') AND NOT ${CUSTODY_WAITS} person_need,
209
236
  CASE WHEN code='complete' THEN 3 WHEN code='cancelled' THEN 4 WHEN code='running' THEN 1
210
237
  WHEN ${CUSTODY_WAITS} OR code IN ('updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') THEN 2 ELSE 0 END rank,
238
+ ${ASK_RANK} ask_rank,
211
239
  CASE WHEN code='complete' THEN completed.at ELSE family_updated END sort_at,
212
240
  completed.actor checked_actor,completed.at checked_at,completed.outcome checked_digest
213
- FROM classified c LEFT JOIN action_ledger completed ON completed.id=c.checked_id WHERE ($state IS NULL OR c.state=$state) AND ($leadId IS NULL OR EXISTS(SELECT 1 FROM team_task_owner own WHERE own.task_ref=c.root_ref AND own.lead=$leadId))
241
+ FROM claimed c LEFT JOIN action_ledger completed ON completed.id=c.checked_id WHERE ($state IS NULL OR c.state=$state) AND ($leadId IS NULL OR EXISTS(SELECT 1 FROM team_task_owner own WHERE own.task_ref=c.root_ref AND own.lead=$leadId))
214
242
  )`;
215
243
  const registered = new WeakMap();
216
244
  /** Connection-local pure parsing, with exactly the authoritative stored-scope
@@ -277,12 +305,19 @@ function parameters(now, access, options, custody) {
277
305
  $now: now.toISOString(), $alive: new Date(now.getTime() - 180_000).toISOString(),
278
306
  $all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []),
279
307
  $unplaced: access.principal === 'operator' && access.includeUnplaced === true ? 1 : 0,
280
- $projectSet: options.project != null ? 1 : 0, $project: options.project ?? null, $state: options.state ?? null, $leadId: options.leadId ?? null };
308
+ $projectSet: options.project != null ? 1 : 0, $project: options.project ?? null, $state: options.state ?? null, $leadId: options.leadId ?? null,
309
+ $leadIdleSince: new Date(now.getTime() - LEAD_IDLE_MS).toISOString(),
310
+ $leadOf: access.principal === 'operator' && access.viewer != null && access.viewer !== '' ? `lead for ${access.viewer}` : null };
281
311
  }
282
312
  function counts(row) {
283
313
  return { all: n(row, 'all_count'), 'needs-you': n(row, 'needs_count'), running: n(row, 'running_count'), completed: n(row, 'completed_count') };
284
314
  }
285
315
  const TOTALS = `COUNT(*) all_count,COALESCE(SUM(needs),0) needs_count,COALESCE(SUM(family_running),0) running_count,COALESCE(SUM(code='complete'),0) completed_count`;
316
+ const GROUP_TOTALS = `COALESCE(SUM(rank=0 AND ask_rank=0),0) decide_count,COALESCE(SUM(rank=0 AND ask_rank=1),0) review_count,COALESCE(SUM(rank=0 AND ask_rank=2),0) unblock_count,`
317
+ + `COALESCE(SUM(rank=1),0) building_count,COALESCE(SUM(rank>1),0) rest_count`;
318
+ function groupCounts(row) {
319
+ return { decide: n(row, 'decide_count'), review: n(row, 'review_count'), unblock: n(row, 'unblock_count'), building: n(row, 'building_count'), rest: n(row, 'rest_count') };
320
+ }
286
321
  export class WorkIndexCursorError extends Error {
287
322
  constructor() { super('This work page cursor is invalid or belongs to a different view.'); this.name = 'WorkIndexCursorError'; }
288
323
  }
@@ -299,7 +334,7 @@ function readCursor(value, scope) {
299
334
  if (value.length > 1024 || !/^[\w-]+$/.test(value))
300
335
  throw Error();
301
336
  const c = JSON.parse(Buffer.from(value, 'base64url').toString());
302
- if (c.version !== 1 || c.scope !== scope || !Number.isSafeInteger(c.rank) || c.rank < 0 || c.rank > 4 ||
337
+ if (c.version !== 2 || c.scope !== scope || !Number.isSafeInteger(c.rank) || c.rank < 0 || c.rank > 4 ||
303
338
  !Number.isSafeInteger(c.root) || c.root < 1 || typeof c.at !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(c.at))
304
339
  throw Error();
305
340
  return c;
@@ -323,16 +358,16 @@ export function workIndexPage(store, now, access, options = {}) {
323
358
  const scope = cursorScope(access, options, view), cursor = readCursor(options.cursor, scope);
324
359
  const filter = view === 'needs-you' ? 'needs=1' : view === 'running' ? 'family_running=1' : view === 'completed' ? "code='complete'" : '1';
325
360
  const rows = store.handle.prepare(`${projection}, selected_page AS MATERIALIZED (
326
- SELECT * FROM ranked WHERE ${filter} AND ($cursorRoot=0 OR rank>$cursorRank OR
327
- (rank=$cursorRank AND (sort_at<$cursorAt OR (sort_at=$cursorAt AND root_ref<$cursorRoot))))
328
- ORDER BY rank,sort_at DESC,root_ref DESC LIMIT $limit
329
- ), page AS (SELECT p.*,COALESCE(p.result_id,(SELECT id FROM run WHERE task_ref=p.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1)) page_result_id FROM selected_page p), totals AS (SELECT ${TOTALS} FROM ranked), project_totals AS (
361
+ SELECT * FROM ranked WHERE ${filter} AND ($cursorRoot=0 OR
362
+ ${ORDER}>$cursorRank OR (${ORDER}=$cursorRank AND (sort_at<$cursorAt OR (sort_at=$cursorAt AND root_ref<$cursorRoot))))
363
+ ORDER BY ${ORDER},sort_at DESC,root_ref DESC LIMIT $limit
364
+ ), page AS (SELECT p.*,COALESCE(p.result_id,(SELECT id FROM run WHERE task_ref=p.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1)) page_result_id FROM selected_page p), totals AS (SELECT ${TOTALS},${GROUP_TOTALS} FROM ranked), project_totals AS (
330
365
  SELECT repo,${TOTALS},SUM(state='queued' AND family_running=0) queued,
331
366
  SUM(state='done' AND family_running=0 AND updated_at>=$recent) done_recently FROM ranked GROUP BY repo
332
367
  )
333
368
  SELECT 0 record_kind,totals.*,(SELECT json_group_array(json_object('repo',repo,'all_count',all_count,'needs_count',needs_count,
334
369
  'running_count',running_count,'completed_count',completed_count,'queued',queued,'done_recently',done_recently)) FROM project_totals) row_json FROM totals
335
- UNION ALL SELECT 1,0,0,0,0,json_object('root_ref',root_ref,'root_id',root_id,'id',id,'root_title',root_title,'repo',repo,
370
+ UNION ALL SELECT 1,0,0,0,0,0,0,0,0,0,json_object('root_ref',root_ref,'root_id',root_id,'id',id,'root_title',root_title,'repo',repo,
336
371
  'state',state,'created_at',created_at,'family_updated',family_updated,'version_count',version_count,'earlier_active',earlier_active,
337
372
  'earlier_id',earlier_id,'broken',broken,'live_run',live_run,'result_id',page_result_id,'result_outcome',(SELECT outcome FROM run WHERE id=page_result_id),
338
373
  'publication_url',(SELECT pr_url FROM publication WHERE run=page_result_id),
@@ -342,7 +377,7 @@ export function workIndexPage(store, now, access, options = {}) {
342
377
  'check_follows',(SELECT json_group_array(q.outcome||' '||COALESCE((SELECT substr(f.outcome,instr(f.outcome,':')+1) FROM action_ledger f WHERE f.repo IS page.repo AND f.run_id=page_result_id
343
378
  AND f.action='checks finished' AND f.outcome LIKE q.id||':%' LIMIT 1),'open')) FROM (SELECT id,outcome FROM action_ledger WHERE repo IS page.repo AND run_id=page_result_id AND action='checks requested' ORDER BY id) q),
344
379
  'proof_verdict',(SELECT COALESCE(machine_verdict,verdict) FROM proof_verdict WHERE run=page_result_id),
345
- 'proof_failed_check',(SELECT verdict='refuted' AND reasons_json LIKE '%approved verification command exited%' FROM proof_verdict WHERE run=page_result_id),'code',code,'needs',needs,'family_running',family_running,'rank',rank,'sort_at',sort_at,
380
+ 'proof_failed_check',(SELECT verdict='refuted' AND reasons_json LIKE '%approved verification command exited%' FROM proof_verdict WHERE run=page_result_id),'code',code,'needs',needs,'person_need',person_need,'lead_claim',lead_claim,'family_running',family_running,'rank',rank,'ask_rank',ask_rank,'list_order',${ORDER},'sort_at',sort_at,
346
381
  'checked_actor',checked_actor,'checked_at',checked_at,'checked_digest',checked_digest,'question_id',question_id,
347
382
  'dependency_id',dependency_id,'dependency_state',dependency_state,'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,300),'stop_run',stop_run,'unfinished',unfinished,'signed_out',signed_out,
348
383
  'question_run',(SELECT run FROM decision WHERE id=page.question_id),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
@@ -351,15 +386,16 @@ export function workIndexPage(store, now, access, options = {}) {
351
386
  .all({ ...parameters(now, access, options, custody), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
352
387
  const selected = rows.slice(1).map(row => JSON.parse(String(row['row_json'])));
353
388
  const page = selected.slice(0, limit), last = page.at(-1);
354
- const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version: 1, scope,
355
- rank: n(last, 'rank'), at: s(last, 'sort_at'), root: n(last, 'root_ref') })).toString('base64url') : null;
389
+ const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version: 2, scope,
390
+ rank: n(last, 'list_order'), at: s(last, 'sort_at'), root: n(last, 'root_ref') })).toString('base64url') : null;
356
391
  const projects = JSON.parse(String(rows[0]?.['row_json'] ?? '[]')).map(row => ({ repo: s(row, 'repo'), totals: counts(row), queued: n(row, 'queued'), doneRecently: n(row, 'done_recently') }));
357
392
  // A row whose build Toolroll can't confirm stopped reads the store's own answer, as the task page does.
358
393
  const probe = (taskId) => {
359
394
  const ref = store.lookupRef(taskId);
360
395
  return ref === null ? null : custody.readings.get(ref.id) ?? null;
361
396
  };
362
- return { items: page.map(row => itemOf(row, access.principal, probe)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]), nextCursor, limit, view };
397
+ return { items: page.map(row => itemOf(row, access.principal, probe)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
398
+ groups: rows[0] === undefined ? { ...NO_GROUPS } : groupCounts(rows[0]), nextCursor, limit, view };
363
399
  }
364
400
  export function workCountsByProject(store, now, access) {
365
401
  const projection = registerValidators(store);
@@ -404,16 +440,15 @@ function listChecksOf(own, level, follows) {
404
440
  return null;
405
441
  return { status: read.status, exitCode: null, head: null, level: read.level, running: read.running };
406
442
  }
407
- /** Index codes that wait on a person, as their need (needs-you.ts); dispatch codes come from task-status's own table. */
408
- const INDEX_NEED = {
409
- 'earlier-active': 'earlier-version', 'process-needs-attention': 'confirm-stopped', 'invalid-scope': 'define-task', 'decision-queue': 'questions',
410
- };
411
443
  function itemOf(row, principal, probe) {
412
444
  const code = String(row['code']), id = String(row['id']);
413
445
  const custody = code === 'process-needs-attention' || (code === 'result-needs-attention' && n(row, 'custody_unresolved') === 1);
414
446
  const process = custody && probe !== undefined ? processNeedOf(probe(id)) : null;
415
447
  const waiting = process !== null && 'wait' in process;
416
448
  const need = n(row, 'needs') === 1 && !waiting, running = n(row, 'family_running') === 1;
449
+ // The lead's claim (lead-voice.ts): the row still reads its own stage, worded "Your lead is on it".
450
+ const lead = n(row, 'lead_claim') === 1 ? 'on-it' : n(row, 'lead_claim') === 2 ? 'lapsed' : null;
451
+ const personNeed = (n(row, 'person_need') === 1 || need) && !waiting;
417
452
  const assignmentState = code === 'complete' ? 'complete' : code === 'ready-to-check' ? 'ready-to-check'
418
453
  : code === 'cancelled' ? 'cancelled' : need ? 'needs-decision' : 'working';
419
454
  const words = {
@@ -499,7 +534,7 @@ function itemOf(row, principal, probe) {
499
534
  access: read ? 'read' : principal === 'operator' ? 'operator-control' : ['answer-decision', 'write-scope', 'unhold'].includes(actionCode) ? 'proposal-only' : 'operator-handoff',
500
535
  retry: actionCode === 'reconcile-run' ? 'reconcile-before-retry' : read ? 'read-again' : 'refresh-before-acting' };
501
536
  // The one shared status: the headline and sentence every surface uses.
502
- const coded = stageOfCode(code, { needsPerson: need, planning: s(row, 'live_role') === 'planner', operatorHold: s(row, 'hold_kind') === 'operator' });
537
+ const coded = stageOfCode(code, { needsPerson: personNeed, planning: s(row, 'live_role') === 'planner', operatorHold: s(row, 'hold_kind') === 'operator' });
503
538
  const reading = process !== null
504
539
  ? 'wait' in process ? { stage: 'waiting', wait: process.wait } : { stage: 'needs-you', need: process.need }
505
540
  : custody ? { stage: 'needs-you', need: 'confirm-stopped' }
@@ -511,7 +546,8 @@ function itemOf(row, principal, probe) {
511
546
  ...(process === null ? {} : { needContext: { build: process.build } }),
512
547
  reason: finished ? null : plainReasonOf(reading.stage, code, detail || null), report: s(row, 'result_role') === 'scout',
513
548
  checks: !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']),
514
- completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, '') });
549
+ completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, ''),
550
+ ...(lead === null ? {} : { lead }) });
515
551
  label = shared.headline;
516
552
  detail = shared.sentence;
517
553
  // Every Needs you action wears its need's own words.
@@ -533,6 +569,7 @@ function itemOf(row, principal, probe) {
533
569
  resultOutcome: s(row, 'result_outcome'), publicationUrl: s(row, 'publication_url'),
534
570
  status: { token: `assignment-${assignmentState}`, label, detail, tone: workToneOf(shared.headline),
535
571
  action: primaryAction === null ? null : { label: actionLabel, kind: actionCode === 'open-result' ? 'open-result' : 'open-task' }, views, rank: n(row, 'rank') },
572
+ ask: need ? ASKS[n(row, 'ask_rank')] ?? 'unblock' : null,
536
573
  primaryAction, completion: code === 'complete' ? { actor: String(row['checked_actor']), at: String(row['checked_at']), digest: String(row['checked_digest']) } : null,
537
574
  evidence: 'recorded' };
538
575
  }
@@ -41,10 +41,13 @@ export declare function openWorkDecisionOf(store: Store, taskRef: number, now: D
41
41
  export declare function workDecisionAction(taskId: string, decision: NonNullable<WorkFacts["openDecision"]>, principal: WorkPrincipal): WorkAction;
42
42
  /** Pure counterpart for callers already holding the same facts. */
43
43
  export declare function workSummaryOf(facts: WorkFacts, principal: WorkPrincipal, resultDisplay?: DisplayStatus): WorkSummary;
44
- export type WorkSummaryAccess = {
44
+ export type WorkSummaryAccess =
45
+ /** `viewer`: the person reading, whose own lead's claim (lead-voice.ts) a task reads as "Your lead is on it". */
46
+ {
45
47
  principal: "operator";
46
48
  repos: readonly string[] | null;
47
49
  includeUnplaced?: boolean;
50
+ viewer?: string | null;
48
51
  } | {
49
52
  principal: "coordinator";
50
53
  repos: readonly string[];
@@ -190,6 +190,31 @@ export declare function dispatchActionLabel(dispatch: DispatchDiagnosis | null):
190
190
  /** A finished result's display status in the shared headline's words: a
191
191
  * failed project check is Failed, a result missing its record needs a
192
192
  * person, every other saved result is Ready for review. Token and action stay. */
193
+ /** The result page's own sentence when the decision is on it. */
194
+ export declare const RESULT_DECISION_SENTENCE = "Review the change, then accept it or ask for changes.";
195
+ /** What the result page's Accept says, from facts it already shows: the
196
+ * recorded checks, the requirements not met, the ones a person still checks,
197
+ * whether the saved proof reads, and what the button posts. "Accept" only when everything is met and the
198
+ * checks passed; otherwise "Accept without checks" and one line naming what.
199
+ * `effect` says what pressing it does, in the action's own terms. */
200
+ export type AcceptFacts = {
201
+ checks: "passed" | "failed" | "not-run" | "off" | "running" | "unavailable" | null;
202
+ unmet: number;
203
+ yours: number;
204
+ /** `complete`: marks the exact result complete; `accept`: records a person's acceptance only. */
205
+ action: "complete" | "accept";
206
+ /** `pull-request`: one can be opened from the task after; `off`: publishing isn't set up; `other`: neither is said. Accept itself never publishes. */
207
+ publishing: "pull-request" | "off" | "other";
208
+ /** False when the saved proof is missing or can't be read: nothing on record says what was met. */
209
+ proof: boolean;
210
+ };
211
+ export type AcceptWords = {
212
+ label: "Accept" | "Accept without checks";
213
+ ready: boolean;
214
+ why: string | null;
215
+ effect: string;
216
+ };
217
+ export declare function acceptWordsOf(facts: AcceptFacts): AcceptWords;
193
218
  export declare function resultHeadlineOf<T extends DisplayStatus>(status: T): T;
194
219
  /** The one shared headline (task-status.ts) over the work status below: the
195
220
  * token, views, rank, action and diagnostics stay; the words and tone are
@@ -195,7 +195,7 @@ function storedResultStatusOf(result, publication) {
195
195
  };
196
196
  }
197
197
  if (result.verdict === "short" && humanReview) {
198
- return { token: "verification-needed", label: "Ready to inspect", detail: withPublication("The remaining requirements need a person's inspection. Open the result and record your decision."), tone: "attention", action: { label: "Inspect the result", kind: "open-review" } };
198
+ return { token: "verification-needed", label: "Ready to inspect", detail: withPublication("The remaining requirements need a person's inspection."), tone: "attention", action: { label: "Inspect the result", kind: "open-review" } };
199
199
  }
200
200
  if (result.verdict === "short") {
201
201
  return {
@@ -312,6 +312,25 @@ const VERIFIED_RESULT = new Set(["ready-to-review", "pr-opened", "merge-observed
312
312
  /** A finished result's display status in the shared headline's words: a
313
313
  * failed project check is Failed, a result missing its record needs a
314
314
  * person, every other saved result is Ready for review. Token and action stay. */
315
+ /** The result page's own sentence when the decision is on it. */
316
+ export const RESULT_DECISION_SENTENCE = "Review the change, then accept it or ask for changes.";
317
+ export function acceptWordsOf(facts) {
318
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
319
+ const parts = [
320
+ ...(facts.proof ? [] : ["nothing on record says what was met"]),
321
+ ...(facts.checks === "passed" ? [] : [facts.checks === "failed" ? "checks failed" : facts.checks === "off" ? "checks are off for this project"
322
+ : facts.checks === "running" ? "checks are still running" : facts.checks === "unavailable" ? "saved checks can't be read" : "checks didn't run"]),
323
+ ...(facts.unmet > 0 ? [`${plural(facts.unmet, "requirement isn't", "requirements aren't")} met`] : []),
324
+ ...(facts.yours > 0 ? [`${plural(facts.yours, "item still needs", "items still need")} your check`] : []),
325
+ ];
326
+ const ready = parts.length === 0;
327
+ const joined = parts.length <= 1 ? parts.join("") : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
328
+ const effect = facts.action === "accept" ? "Records that you accept it. You mark it complete next."
329
+ : facts.publishing === "pull-request" ? "Marks it complete. No pull request opens; you can open one from the task after."
330
+ : facts.publishing === "off" ? "Marks it complete. The branch stays; publishing isn't set up."
331
+ : "Marks it complete. Nothing is published.";
332
+ return { label: ready ? "Accept" : "Accept without checks", ready, why: ready ? null : `${joined.charAt(0).toUpperCase()}${joined.slice(1)}.`, effect };
333
+ }
315
334
  export function resultHeadlineOf(status) {
316
335
  const read = RESULT_NEEDS_YOU.has(status.token) ? { stage: "needs-you", need: "other" }
317
336
  : status.token === "checks-failed" ? { stage: "failed" } : { stage: "finished" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"