toolroll 0.9.11 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +3 -1
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +20 -18
  6. package/dist/browser-workspace.d.ts +61 -0
  7. package/dist/chat-delivery.js +17 -0
  8. package/dist/chat-quiet.js +1 -0
  9. package/dist/chat-voice.d.ts +3 -1
  10. package/dist/chat-voice.js +13 -5
  11. package/dist/cli.d.ts +1 -1
  12. package/dist/cli.js +1 -0
  13. package/dist/coding-update.d.ts +13 -0
  14. package/dist/coding-update.js +68 -0
  15. package/dist/desktop-update.d.ts +7 -0
  16. package/dist/desktop-update.js +42 -1
  17. package/dist/flow-triggers.js +17 -1
  18. package/dist/guides.js +9 -0
  19. package/dist/lead-status.d.ts +10 -1
  20. package/dist/lead-status.js +13 -3
  21. package/dist/lead-voice.d.ts +81 -0
  22. package/dist/lead-voice.js +275 -0
  23. package/dist/needs-you.d.ts +7 -0
  24. package/dist/needs-you.js +9 -0
  25. package/dist/operate.js +80 -4
  26. package/dist/serve.d.ts +5 -1
  27. package/dist/serve.js +438 -224
  28. package/dist/setup-guide.d.ts +2 -2
  29. package/dist/store.js +8 -0
  30. package/dist/surface.js +8 -1
  31. package/dist/task-status.d.ts +6 -0
  32. package/dist/task-status.js +19 -2
  33. package/dist/team-cli.js +1 -1
  34. package/dist/telegram-progress.js +7 -0
  35. package/dist/telegram.js +34 -3
  36. package/dist/test-temp.js +1 -1
  37. package/dist/toolroll-update.d.ts +3 -0
  38. package/dist/toolroll-update.js +13 -2
  39. package/dist/work-index.d.ts +9 -0
  40. package/dist/work-index.js +57 -20
  41. package/dist/work-summary.d.ts +4 -1
  42. package/dist/workspace-ui.d.ts +25 -0
  43. package/dist/workspace-ui.js +20 -1
  44. package/package.json +1 -1
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
- content?: never;
19
18
  ok: false;
20
19
  message: string;
21
20
  plan?: never;
21
+ content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
- content?: never;
34
33
  ok: false;
35
34
  message: string;
36
35
  plan?: never;
36
+ content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
package/dist/store.js CHANGED
@@ -657,6 +657,9 @@ export function isTelegramProgressNotification(row) {
657
657
  return false;
658
658
  if (row.kind === "build-failed" && row.run !== null && row.dedupeKey === `run:${row.run}:failed`)
659
659
  return true;
660
+ // The owner's lead took the attempt on, or let it lapse (lead-voice.ts): its card repaints in place.
661
+ if ((row.kind === "lead-on-it" || row.kind === "lead-lapsed") && row.run !== null)
662
+ return true;
660
663
  return isLifecycleNotification(row) &&
661
664
  ["run-started", "run-phase", "run-finished", "review-requested", "review-finished", "run-stopping", "run-stopped", "run-resumed", "task-held", "task-released"].includes(row.kind);
662
665
  }
@@ -22253,7 +22256,12 @@ export class Store {
22253
22256
  return true;
22254
22257
  if (row.recipient !== null && row.kind === "task-ask")
22255
22258
  return true;
22259
+ // The lead's own words reach the person it speaks for, and its claim repaints only that person's card (lead-voice.ts).
22260
+ if (row.kind === "lead-say")
22261
+ return row.recipient === account;
22256
22262
  const actor = this.notificationActor(row.id);
22263
+ if (row.kind === "lead-on-it" || row.kind === "lead-lapsed")
22264
+ return actor?.lead === true && actor.account === account;
22257
22265
  if (actor !== null && actor.account === account)
22258
22266
  return false;
22259
22267
  if (row.recipient !== null)
package/dist/surface.js CHANGED
@@ -194,7 +194,7 @@ export const COMMAND_GUIDE = [
194
194
  invocation: `assignment ${action}`,
195
195
  synopsis: action === "show" ? "read the root, current work, owner and exact receipt"
196
196
  : action === "updates" ? "read durable updates after a cursor; save nextCursor after processing"
197
- : action === "claim" ? "record coordinator lead ownership; grants no approval or execution authority"
197
+ : action === "claim" ? "record lead ownership; with the lead token the task reads \"Your lead is on it\" and leaves Needs you until done, handed over, or 2 hours idle; grants no approval or execution authority"
198
198
  : action === "brief" ? "catch up from current assignments and project knowledge in the local database"
199
199
  : action === "inbox" ? "receive or replay a saved batch of lead status updates"
200
200
  : action === "ack" ? "acknowledge delivery of an inbox batch; leaves task completion unchanged"
@@ -215,6 +215,13 @@ export const COMMAND_GUIDE = [
215
215
  })),
216
216
  operator("task state", "set a task's state by hand — an operator correction, not a workflow step (--replaced-by <id> with cancelled)"),
217
217
  operator("lead token", "mint the lead's credential behind your password (--revoke ends it): its own work pings nobody"),
218
+ { invocation: "lead say", synopsis: "as the lead: one short message in its person's chat from \"Your lead\"; several within two minutes are one message",
219
+ audience: "agent", agentMayInvoke: true, mutation: "unkeyed",
220
+ positionals: [{ name: "text", required: true, meaning: "one short line in plain words" }],
221
+ flags: [jsonFlag, dbFlag,
222
+ { name: "task", takesValue: true, meaning: "the task it is about" },
223
+ { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" }],
224
+ notableReasons: ["usage", "unknown-task", "unauthenticated"] },
218
225
  { invocation: "task block", synopsis: "make one task wait for another", audience: "agent", agentMayInvoke: true, mutation: "keyed",
219
226
  positionals: [{ name: "id", required: true, meaning: "the waiting task" }],
220
227
  flags: [jsonFlag, dbFlag, keyFlag, { name: "on", takesValue: true, meaning: "the blocker's id" }],
@@ -107,8 +107,14 @@ export type TaskStatusFacts = {
107
107
  };
108
108
  /** Exact technical reasons, shown only on request. */
109
109
  why?: readonly string[];
110
+ /** The person's lead took this on (lead-voice.ts): "on-it" reads "Your lead is on it" and needs nobody; "lapsed"
111
+ * (two hours without a lead act) is back with the person, and says so. */
112
+ lead?: "on-it" | "lapsed" | null;
110
113
  };
111
114
  export declare function headlineOf(facts: Pick<TaskStatusFacts, "stage" | "checks" | "report">): Headline;
115
+ /** What the lead's claim says, in place of "waits for you" (lead-voice.ts). */
116
+ export declare const LEAD_ON_IT = "Your lead is on it.";
117
+ export declare const LEAD_LAPSED = "Your lead hasn't acted on this for 2 hours, so it's back with you.";
112
118
  export declare function taskStatusOf(facts: TaskStatusFacts): TaskStatus;
113
119
  export declare function plainReasonOf(stage: TaskStage, code: string, detail: string | null | undefined): string | null;
114
120
  export declare function stageOfCode(code: string, options?: {
@@ -133,13 +133,29 @@ function detailsOf(headline, facts) {
133
133
  }
134
134
  return rows;
135
135
  }
136
+ /** What the lead's claim says, in place of "waits for you" (lead-voice.ts). */
137
+ export const LEAD_ON_IT = "Your lead is on it.";
138
+ export const LEAD_LAPSED = "Your lead hasn't acted on this for 2 hours, so it's back with you.";
139
+ /** The headlines a lead's claim speaks for: the ones that would otherwise wait on a person. */
140
+ const LEAD_HEADLINES = new Set(["Needs you", "Failed", "Ready for review", "Stopped"]);
141
+ function leadSentence(headline, facts) {
142
+ const sha = short(facts.checks?.head ?? null);
143
+ // A failed check stays said: the claim changes who acts next, never the result.
144
+ if (headline === "Failed" && facts.checks?.status === "failed")
145
+ return `${facts.checks.level === "quick" ? "Quick checks" : "Checks"} failed${sha === null ? "" : ` on ${sha}`}. ${LEAD_ON_IT}`;
146
+ return LEAD_ON_IT;
147
+ }
136
148
  export function taskStatusOf(facts) {
137
- const headline = headlineOf(facts);
149
+ const read = headlineOf(facts);
150
+ const lead = facts.lead != null && LEAD_HEADLINES.has(read) ? facts.lead : null;
151
+ // The lead has it: nothing waits on the person, so a Needs you reads Waiting.
152
+ const headline = lead === "on-it" && read === "Needs you" ? "Waiting" : read;
138
153
  // Every Needs you carries the one action that resolves it, worded the same everywhere.
139
154
  const need = headline === "Needs you" ? { key: facts.need ?? "other", action: NEEDS[facts.need ?? "other"].action } : null;
140
155
  const ownWords = need !== null && (need.key === "other" || need.key === "review-result") && facts.action != null;
141
156
  const primaryAction = need === null || ownWords ? facts.action ?? null : { label: need.action.label, href: facts.action?.href ?? null };
142
- return { headline, tone: HEADLINE_TONE[headline], sentence: sentenceOf(headline, facts), details: detailsOf(headline, facts),
157
+ const sentence = lead === "on-it" ? leadSentence(headline, facts) : lead === "lapsed" ? `${LEAD_LAPSED} ${sentenceOf(headline, facts)}` : sentenceOf(headline, facts);
158
+ return { headline, tone: HEADLINE_TONE[headline], sentence, details: detailsOf(headline, facts),
143
159
  primaryAction, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== ""), need };
144
160
  }
145
161
  // ---- reading the existing projections ----------------------------------------
@@ -287,6 +303,7 @@ export function assignmentStatusFacts(assignment, options = {}) {
287
303
  action: options.action ?? null,
288
304
  ...(options.links === undefined ? {} : { links: options.links }),
289
305
  why: options.why ?? [],
306
+ ...(assignment.lead == null ? {} : { lead: assignment.lead.state }),
290
307
  };
291
308
  }
292
309
  // ---- rendering helpers shared by the server pages and chat cards ------------
package/dist/team-cli.js CHANGED
@@ -306,7 +306,7 @@ function render(reply, listing) {
306
306
  export async function maybeRunTeamCommand(argv, write, options = {}) {
307
307
  const command = argv[0];
308
308
  // `lead token` is local: the credential a lead agent acts with on this computer.
309
- if (command === 'lead' && argv[1] === 'token')
309
+ if (command === 'lead' && (argv[1] === 'token' || argv[1] === 'say'))
310
310
  return null;
311
311
  if (!['connect', 'lead', 'conversation', 'chat', 'brief'].includes(command ?? '') && !argv.includes('--profile'))
312
312
  return null;
@@ -8,6 +8,7 @@ import { assignmentOf } from "./assignment.js";
8
8
  import { chatResultHref, chatControlHref } from "./chat-controls.js";
9
9
  import { phoneText, projectLabel } from "./telegram-status.js";
10
10
  import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, taskStatusOf } from "./task-status.js";
11
+ import { leadClaimOf } from "./lead-voice.js";
11
12
  import { chatTitle } from "./chat-voice.js";
12
13
  import { manualReviewOnly } from "./proof.js";
13
14
  import { failedCheckExit } from "./workspace-ui.js";
@@ -78,6 +79,12 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
78
79
  const completer = assignment?.completion?.actor.replace(/^(?:operator|coordinator|lead):/, "") ?? null;
79
80
  if (viewer !== undefined && facts.completedBy != null && (completer === viewer || facts.completedBy === viewer))
80
81
  facts = { ...facts, completedBy: null };
82
+ // The person's lead took it on: "Your lead is on it" rather than waiting for them (lead-voice.ts).
83
+ if (facts.lead === undefined) {
84
+ const lead = leadClaimOf(store, taskId, now, viewer);
85
+ if (lead !== null)
86
+ facts = { ...facts, lead: lead.state };
87
+ }
81
88
  const status = taskStatusOf(facts);
82
89
  const checkProgress = store.checkProgress(run.id);
83
90
  const progress = status.headline === "Building" && checkProgress !== null ? [`● ${checkProgress.line}`] : [];
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";
@@ -481,12 +482,13 @@ export async function followBridge(store, options) {
481
482
  // the picker first. Best effort; typed commands work either way.
482
483
  if (!commandsListed && options.conversation !== undefined) {
483
484
  commandsListed = true;
485
+ // Both carry the stop signal: a stop never waits on a slow Bot API for them.
484
486
  try {
485
- await transport("setMyCommands", { commands: TELEGRAM_COMMANDS });
487
+ await transport("setMyCommands", { commands: TELEGRAM_COMMANDS }, signal);
486
488
  }
487
489
  catch { /* typed commands still work */ }
488
490
  // After an upgrade, a bot still called StandingOrders becomes Toolroll, once per start; any other name is kept.
489
- await nameTelegramBot(transport, "upgrade");
491
+ await nameTelegramBot(transport, "upgrade", signal);
490
492
  }
491
493
  const startedAt = Date.now();
492
494
  const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
@@ -642,6 +644,8 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
642
644
  catch {
643
645
  report.problems.push("evening digest could not be prepared");
644
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);
645
649
  const digestDue = digest.everyMs === null || digest.lastSentAt === null || now.getTime() >= new Date(digest.lastSentAt).getTime() + digest.everyMs;
646
650
  // Every paired person is a destination of their own: each binding claims
647
651
  // and settles its own rows under its own ceiling, in turn.
@@ -914,8 +918,35 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
914
918
  }
915
919
  return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
916
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
+ };
917
946
  let outcome;
918
- 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))
919
950
  outcome = await quietOutcome(row);
920
951
  else {
921
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" };