toolroll 0.9.14 → 0.9.16

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 (84) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/assignment-brief.d.ts +10 -0
  3. package/dist/assignment-brief.js +49 -26
  4. package/dist/assignment-ui.d.ts +1 -0
  5. package/dist/assignment-ui.js +6 -4
  6. package/dist/assignment.d.ts +34 -1
  7. package/dist/assignment.js +86 -7
  8. package/dist/browser/workspace.css +2 -2
  9. package/dist/browser/workspace.js +38 -38
  10. package/dist/browser-crew.d.ts +2 -1
  11. package/dist/browser-crew.js +3 -1
  12. package/dist/browser-workspace.d.ts +93 -18
  13. package/dist/chat-actions.d.ts +1 -1
  14. package/dist/chat-actions.js +6 -4
  15. package/dist/chat-ask.d.ts +28 -0
  16. package/dist/chat-ask.js +77 -0
  17. package/dist/chat-channel.js +6 -1
  18. package/dist/chat-controls.d.ts +8 -0
  19. package/dist/chat-controls.js +3 -0
  20. package/dist/chat-delivery-state.d.ts +7 -2
  21. package/dist/chat-delivery-state.js +15 -2
  22. package/dist/chat-delivery.js +20 -3
  23. package/dist/chat-quiet.js +4 -2
  24. package/dist/chat-voice.d.ts +6 -9
  25. package/dist/chat-voice.js +11 -5
  26. package/dist/demo.js +82 -2
  27. package/dist/disclosure.d.ts +6 -0
  28. package/dist/disclosure.js +13 -0
  29. package/dist/discord-chat.js +10 -0
  30. package/dist/lead-commitments.d.ts +84 -0
  31. package/dist/lead-commitments.js +208 -0
  32. package/dist/lead-context.d.ts +32 -3
  33. package/dist/lead-context.js +133 -19
  34. package/dist/lead-follow.js +3 -0
  35. package/dist/lead-identity.d.ts +23 -0
  36. package/dist/lead-identity.js +33 -0
  37. package/dist/lead-status.js +2 -2
  38. package/dist/lead-voice.d.ts +12 -9
  39. package/dist/lead-voice.js +25 -16
  40. package/dist/ledger-view.js +3 -2
  41. package/dist/mate-cli.d.ts +1 -1
  42. package/dist/mate-cli.js +8 -2
  43. package/dist/mate-contract.d.ts +1 -1
  44. package/dist/mate-contract.js +11 -4
  45. package/dist/mate-progress.js +5 -0
  46. package/dist/mate-tools.d.ts +18 -1
  47. package/dist/mate-tools.js +250 -6
  48. package/dist/mate.d.ts +9 -0
  49. package/dist/mate.js +34 -38
  50. package/dist/needs-you.d.ts +48 -0
  51. package/dist/needs-you.js +93 -1
  52. package/dist/operate.js +2 -1
  53. package/dist/project-delete.js +3 -1
  54. package/dist/result-acts.d.ts +63 -5
  55. package/dist/result-acts.js +53 -3
  56. package/dist/result-review.d.ts +4 -3
  57. package/dist/result-review.js +7 -3
  58. package/dist/retention.js +6 -0
  59. package/dist/serve.d.ts +3 -0
  60. package/dist/serve.js +1220 -694
  61. package/dist/slack-chat.js +11 -1
  62. package/dist/store.d.ts +51 -0
  63. package/dist/store.js +115 -1
  64. package/dist/surface.js +2 -2
  65. package/dist/task-status.d.ts +26 -19
  66. package/dist/task-status.js +59 -21
  67. package/dist/team-runtime.js +2 -1
  68. package/dist/teams-chat.js +4 -1
  69. package/dist/telegram-mate.d.ts +4 -1
  70. package/dist/telegram-mate.js +17 -1
  71. package/dist/telegram-progress.js +3 -2
  72. package/dist/telegram.js +43 -3
  73. package/dist/test-temp.js +1 -1
  74. package/dist/transitions-recipes.d.ts +1 -1
  75. package/dist/transitions-recipes.js +5 -9
  76. package/dist/when-html.d.ts +24 -2
  77. package/dist/when-html.js +98 -11
  78. package/dist/work-index.d.ts +5 -1
  79. package/dist/work-index.js +22 -9
  80. package/dist/work-summary.d.ts +1 -1
  81. package/dist/workspace-motion.js +3 -3
  82. package/dist/workspace-ui.d.ts +44 -20
  83. package/dist/workspace-ui.js +63 -37
  84. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  /** Microsoft Teams on the shared chat layer: activities in, Adaptive Cards
2
2
  * out, the same durable receipts, rooms and commands as every other channel. */
3
3
  import { chatQuestionButtons } from "./teammate-question.js";
4
+ import { chatAskButtons } from "./chat-ask.js";
4
5
  import { ChatState, chatHash } from "./chat-delivery-state.js";
5
6
  import { channelAccess, chatObject as object, planChatNotifications, planRoomMessages, processChatEvent } from "./chat-delivery.js";
6
7
  import { roomCommand } from "./chat-rooms.js";
@@ -195,7 +196,9 @@ export async function deliverTeamsPart(options) {
195
196
  const flow = content.flow ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
196
197
  // A teammate's question (v93): its options, then "Answer in words".
197
198
  const asked = content.question ? chatQuestionButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
198
- actions = [...flow, ...asked, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
199
+ // The lead's question to its owner: its options, then "Something else".
200
+ const owner = content.ask ? chatAskButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
201
+ actions = [...flow, ...asked, ...owner, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
199
202
  }
200
203
  const target = content.edit ?? row.message;
201
204
  const body = actions.length || content.proposal ? teamsCard(text, actions) : { type: "message", text, textFormat: "plain" };
@@ -1,7 +1,7 @@
1
1
  import { type PhoneLink } from "./chat-channel.js";
2
2
  export { channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps, type PhoneLink, type ResolvedMate, type ParitySupport } from "./chat-channel.js";
3
3
  import { type DoorOptions } from "./mate-doors.js";
4
- import type { Store, TelegramBinding } from "./store.js";
4
+ import { type MateAsk, type Store, type TelegramBinding } from "./store.js";
5
5
  import type { SubscriptionMateRunner } from "./subscription-chat.js";
6
6
  import type { TelegramTransport } from "./telegram.js";
7
7
  /** How long one claimed turn may go without a heartbeat before another poller may take it over. */
@@ -59,6 +59,9 @@ export declare function telegramChannelProblem(store: Store, expected: {
59
59
  }), readProjects: () => Promise<readonly string[]>): Promise<string | null>;
60
60
  export declare function phoneLinkButton(origin: string | null, link: PhoneLink | null): InlineButton[] | null;
61
61
  /** One inline button: an opaque callback token (a real in-chat act) or a url (navigation, never authority). */
62
+ /** The buttons under the lead's question: options two to a row, then "Something else". Each names the turn and the
63
+ * option's place, nothing more; a tap re-proves that the question is still open and is the tapper's own. */
64
+ export declare function askKeyboard(ask: MateAsk): CallbackKeyboard;
62
65
  export type InlineButton = {
63
66
  text: string;
64
67
  callback_data: string;
@@ -26,6 +26,7 @@ import { createHash, randomBytes } from "node:crypto";
26
26
  import { MATE_MESSAGE_MAX_CHARS, runMateTurn } from "./mate.js";
27
27
  import { confirmMateProposal, dismissMateProposal } from "./mate-doors.js";
28
28
  import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
29
+ import { MATE_ASK_OTHER } from "./store.js";
29
30
  import { phoneText } from "./telegram-status.js";
30
31
  import { TeamLeads } from "./team-leads.js";
31
32
  import { chatResultHref } from "./chat-controls.js";
@@ -100,6 +101,17 @@ function keyboardWith(callbacks, link) {
100
101
  const rows = [...(callbacks ?? []), ...(link === null ? [] : [link])];
101
102
  return rows.length === 0 ? undefined : rows;
102
103
  }
104
+ // ---- proposal cards on the wire ------------------------------------------------------
105
+ /** One inline button: an opaque callback token (a real in-chat act) or a url (navigation, never authority). */
106
+ /** The buttons under the lead's question: options two to a row, then "Something else". Each names the turn and the
107
+ * option's place, nothing more; a tap re-proves that the question is still open and is the tapper's own. */
108
+ export function askKeyboard(ask) {
109
+ const rows = [];
110
+ for (let at = 0; at < ask.options.length; at += 2)
111
+ rows.push(ask.options.slice(at, at + 2).map((label, offset) => ({ text: label.slice(0, 60), callback_data: `ask:${ask.turn}:${at + offset}` })));
112
+ rows.push([{ text: MATE_ASK_OTHER, callback_data: `ask:${ask.turn}:x` }]);
113
+ return rows;
114
+ }
103
115
  /** Mint the card's tokens before the send, so a tap can never name a token that does not exist. */
104
116
  export function mintCardTokens(store, binding, proposal, now, messageId, chatId = binding.chatId) {
105
117
  const confirm = randomBytes(16).toString("hex");
@@ -579,6 +591,10 @@ async function runTelegramConversation(row, args) {
579
591
  return store.transact(() => {
580
592
  const now = clock();
581
593
  const parts = splitParts(reply).map((text, index) => ({ kind: "reply", text, replyTo: index === 0 ? row.messageId : null }));
594
+ // The lead's question to its owner: one tap per option, then "Something else".
595
+ const ask = store.mateAsk(turn);
596
+ if (ask !== null)
597
+ parts.push({ kind: "reply", text: phoneText(ask.question, 1_000), keyboard: askKeyboard(ask) });
582
598
  // Only a turn that answered may have its selection sent: a failed or revoked turn's rows were deleted with its drafts, and the state is read again here.
583
599
  const selected = store.getMateTurn(turn)?.state === "answered" ? store.listMateTurnEvidence(turn) : [];
584
600
  for (const image of selected)
@@ -694,7 +710,7 @@ async function runTelegramConversation(row, args) {
694
710
  store, who, session, thread, config, key: null, message: row.text, requestId: row.request,
695
711
  ...(row.context === null ? {} : { context: row.context }),
696
712
  ...(options.subscriptionRunner === undefined ? {} : { subscriptionRunner: options.subscriptionRunner }),
697
- clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents",
713
+ clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram",
698
714
  });
699
715
  }
700
716
  finally {
@@ -9,6 +9,7 @@ import { chatResultHref, chatControlHref } from "./chat-controls.js";
9
9
  import { phoneText, projectLabel } from "./telegram-status.js";
10
10
  import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
11
11
  import { leadClaimOf } from "./lead-voice.js";
12
+ import { leadNameOf } from "./lead-identity.js";
12
13
  import { chatTitle } from "./chat-voice.js";
13
14
  import { manualReviewOnly } from "./proof.js";
14
15
  import { failedCheckExit } from "./workspace-ui.js";
@@ -79,11 +80,11 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
79
80
  const completer = assignment?.completion?.actor.replace(/^(?:operator|coordinator|lead):/, "") ?? null;
80
81
  if (viewer !== undefined && facts.completedBy != null && (completer === viewer || facts.completedBy === viewer))
81
82
  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
+ // The person's lead took it on: "<name> is on it" rather than waiting for them (lead-voice.ts).
83
84
  if (facts.lead === undefined) {
84
85
  const lead = leadClaimOf(store, taskId, now, viewer);
85
86
  if (lead !== null)
86
- facts = { ...facts, lead: lead.state };
87
+ facts = { ...facts, lead: lead.state, leadName: leadNameOf(store, lead.owner) };
87
88
  }
88
89
  const status = taskStatusOf(facts);
89
90
  const checkProgress = store.checkProgress(run.id);
package/dist/telegram.js CHANGED
@@ -31,7 +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
+ import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
35
35
  import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
36
36
  import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
37
37
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
@@ -920,7 +920,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
920
920
  };
921
921
  /** The lead's words (lead-voice.ts): one message; a later say within two minutes edits it in place. */
922
922
  const leadSayOutcome = async (fact) => {
923
- const words = leadSayText(fact);
923
+ const words = leadSayText(store, fact);
924
924
  const button = fact.link === null ? null : factButton(phoneOrigin, fact.link);
925
925
  const keyboard = button === null ? [] : [button];
926
926
  const earlier = leadSayEarlier(store, fact).map(id => store.telegramMessageOf(id, fact.destination)).find(one => one !== null) ?? null;
@@ -1064,7 +1064,7 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1064
1064
  // A short title, never the task's id or a "— revision" suffix (chat-voice.ts).
1065
1065
  const title = notification.taskId === null ? undefined : chatTitle(store, notification.taskId);
1066
1066
  const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
1067
- const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : notification.subject}${body === "" ? "" : `\n\n${body}`}`, task);
1067
+ const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : leadSubjectOf(store, notification, binding.approver)}${body === "" ? "" : `\n\n${body}`}`, task);
1068
1068
  // The task's title once: in front, unless the words already name it.
1069
1069
  const parts = split(`${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
1070
1070
  const button = notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
@@ -1970,6 +1970,46 @@ function applyCallback(context, update, effects) {
1970
1970
  }
1971
1971
  return;
1972
1972
  }
1973
+ // The lead's question to its owner: an option is sent as their next message; "Something else" asks them to type it.
1974
+ const askTap = /^ask:([1-9][0-9]{0,14}):([0-3]|x)$/.exec(token);
1975
+ if (askTap !== null) {
1976
+ if (tapChat !== binding.chatId || context.conversation === undefined) {
1977
+ report.ignored++;
1978
+ return;
1979
+ }
1980
+ const turn = Number(askTap[1]);
1981
+ const found = store.getMateTurn(turn)?.approver === binding.approver ? store.mateAskState(turn, clock()) : { state: "expired" };
1982
+ if (found.state !== "open") {
1983
+ const line = found.state === "answered" ? "That question was already answered." : "That question expired. Send your answer as a message.";
1984
+ ack(line);
1985
+ editText(`${message.text ?? ""}\n\n${line}`.trim().slice(0, 4000));
1986
+ return;
1987
+ }
1988
+ const ask = found.ask;
1989
+ if (store.telegramConversationWaitingOn(binding.id, String(message.message_id))) {
1990
+ ack("Your answer is on its way.");
1991
+ return;
1992
+ }
1993
+ if (askTap[2] === "x") {
1994
+ ack("Type your answer as a message.");
1995
+ return;
1996
+ }
1997
+ const option = ask.options[Number(askTap[2])];
1998
+ if (option === undefined) {
1999
+ ack("That option is no longer there.");
2000
+ return;
2001
+ }
2002
+ const focused = store.chatFocus("telegram", binding.id);
2003
+ store.enqueueTelegramConversation({
2004
+ binding, updateId: update.update_id, messageId: String(message.message_id), replyTo: null,
2005
+ request: telegramRequestId(botId, binding.id, update.update_id), text: option,
2006
+ context: focused === null ? null : focusContextFor(focused), taskId: focused, sourceRun: null,
2007
+ }, clock());
2008
+ report.chatQueued = (report.chatQueued ?? 0) + 1;
2009
+ ack(`Sent: ${option}`.slice(0, 190));
2010
+ editText(`${message.text ?? ask.question}\n\nYou chose: ${option}`.slice(0, 4000));
2011
+ return;
2012
+ }
1973
2013
  const action = store.getTelegramAction(token);
1974
2014
  if (action === null && context.conversation !== undefined && store.getTelegramProposalAction(token) !== null) {
1975
2015
  // A proposal card's button: the shared confirm door, inside this
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-", "deploy-restore-", "result-review-", "one-result-page-",
21
+ "needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-", "deploy-before-swap-", "one-vocabulary-", "accept-finishes-", "lead-promises-",
22
22
  // Playwright's browser profiles and artifacts.
23
23
  "playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
24
24
  ];
@@ -1 +1 @@
1
- export declare const TRANSITIONS_CSS = "\n/* Transitions.dev \u2014 05-menu-dropdown.md */\n:root {\n --dropdown-open-dur: 250ms;\n --dropdown-close-dur: 150ms;\n --dropdown-pre-scale: 0.97;\n --dropdown-closing-scale: 0.99;\n --dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.t-dropdown {\n transform-origin: top left;\n transform: scale(var(--dropdown-pre-scale));\n opacity: 0;\n pointer-events: none;\n transition:\n transform var(--dropdown-open-dur) var(--dropdown-ease),\n opacity var(--dropdown-open-dur) var(--dropdown-ease);\n will-change: transform, opacity;\n}\n.t-dropdown[data-origin=\"top-right\"] { transform-origin: top right; }\n.t-dropdown[data-origin=\"top-center\"] { transform-origin: top center; }\n.t-dropdown[data-origin=\"bottom-left\"] { transform-origin: bottom left; }\n.t-dropdown[data-origin=\"bottom-center\"] { transform-origin: bottom center; }\n.t-dropdown[data-origin=\"bottom-right\"] { transform-origin: bottom right; }\n\n.t-dropdown.is-open {\n transform: scale(1);\n opacity: 1;\n pointer-events: auto;\n}\n.t-dropdown.is-closing {\n transform: scale(var(--dropdown-closing-scale));\n opacity: 0;\n pointer-events: none;\n transition:\n transform var(--dropdown-close-dur) var(--dropdown-ease),\n opacity var(--dropdown-close-dur) var(--dropdown-ease);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-dropdown { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 07-panel-reveal.md */\n:root {\n --panel-open-dur: 400ms;\n --panel-close-dur: 350ms;\n --panel-translate-y: 100px;\n --panel-blur: 2px;\n --panel-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.t-panel-slide {\n transform: translateY(var(--panel-translate-y));\n opacity: 0;\n filter: blur(var(--panel-blur));\n pointer-events: none;\n transition:\n transform var(--panel-close-dur) var(--panel-ease),\n opacity var(--panel-close-dur) var(--panel-ease),\n filter var(--panel-close-dur) var(--panel-ease);\n will-change: transform, opacity, filter;\n}\n.t-panel-slide[data-open=\"true\"] {\n transform: translateY(0);\n opacity: 1;\n filter: blur(0);\n pointer-events: auto;\n transition:\n transform var(--panel-open-dur) var(--panel-ease),\n opacity var(--panel-open-dur) var(--panel-ease),\n filter var(--panel-open-dur) var(--panel-ease);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-panel-slide { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 16-tabs-sliding.md */\n:root {\n --tabs-dur: 250ms;\n --tabs-ease: cubic-bezier(0.22, 1, 0.36, 1);\n --tabs-text-muted: rgba(15, 15, 15, 0.8);\n --tabs-text-active: #0f0f0f;\n --tabs-bar-bg: #f1f1f1;\n --tabs-pill-bg: #ffffff;\n}\n\n/* The bar is just a flex container with padding for the pill\n to sit inside. Tabs sit on z-index: 1, the pill on z-index: 0,\n so labels read above the pill background. */\n.t-tabs {\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: 3px;\n padding: 3px;\n border-radius: 48px;\n background: var(--tabs-bar-bg);\n}\n.t-tab {\n position: relative;\n appearance: none;\n border: 0;\n background: transparent;\n height: 30px;\n padding: 4px 12px;\n color: var(--tabs-text-muted);\n cursor: pointer;\n border-radius: 48px;\n z-index: 1;\n transition: color var(--tabs-dur) var(--tabs-ease);\n}\n.t-tab:not([aria-selected=\"true\"]):hover,\n.t-tab[aria-selected=\"true\"] {\n color: var(--tabs-text-active);\n}\n\n/* The pill: width + transform are written inline by JS so\n the transition tweens between the previous and next\n measured positions. */\n.t-tabs-pill {\n position: absolute;\n top: 3px;\n left: 0;\n height: 30px;\n width: 0;\n background: var(--tabs-pill-bg);\n border-radius: 48px;\n transform: translateX(0);\n transition:\n transform var(--tabs-dur) var(--tabs-ease),\n width var(--tabs-dur) var(--tabs-ease);\n will-change: transform, width;\n z-index: 0;\n pointer-events: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-tabs-pill, .t-tab { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 21-accordion.md */\n:root {\n --acc-expand: 250ms;\n --acc-collapse: 250ms;\n --acc-chevron: 250ms;\n --acc-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n/* grid-template-rows 0fr \u2192 1fr gives a clean height animation\n with no JS measurement; the inner element clips overflow. */\n.t-acc-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--acc-collapse) var(--acc-ease);\n}\n.t-acc[data-open=\"true\"] .t-acc-panel {\n grid-template-rows: 1fr;\n transition: grid-template-rows var(--acc-expand) var(--acc-ease);\n}\n.t-acc-panel-inner {\n overflow: hidden;\n opacity: 0;\n filter: blur(2px);\n transition:\n opacity var(--acc-collapse) var(--acc-ease),\n filter var(--acc-collapse) var(--acc-ease);\n}\n.t-acc[data-open=\"true\"] .t-acc-panel-inner {\n opacity: 1;\n filter: blur(0);\n transition:\n opacity var(--acc-expand) var(--acc-ease),\n filter var(--acc-expand) var(--acc-ease);\n}\n/* Flip the chevron vertically to turn the \"v\" into a \"^\".\n scaleY(-1) about the centre passes through a flat line at\n the midpoint (same look as a `d:` path morph) but animates\n in every browser, unlike CSS `d:` morphing (Chromium only).\n The chevron path is symmetric about the 16x16 viewBox\n centre, so the flip lands exactly on the \"^\"; non-scaling\n -stroke keeps the stroke width constant through the flip. */\n.t-acc-chevron {\n display: inline-flex;\n transform: scaleY(1);\n transform-origin: center;\n transition: transform var(--acc-chevron) var(--acc-ease);\n}\n.t-acc-chevron path { vector-effect: non-scaling-stroke; }\n.t-acc[data-open=\"true\"] .t-acc-chevron {\n transform: scaleY(-1);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-acc-panel, .t-acc-panel-inner, .t-acc-chevron {\n transition: none !important;\n }\n}\n";
1
+ export declare const TRANSITIONS_CSS = "\n/* Transitions.dev \u2014 05-menu-dropdown.md */\n:root {\n --dropdown-open-dur: 250ms;\n --dropdown-close-dur: 150ms;\n --dropdown-pre-scale: 0.97;\n --dropdown-closing-scale: 0.99;\n --dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.t-dropdown {\n transform-origin: top left;\n transform: scale(var(--dropdown-pre-scale));\n opacity: 0;\n pointer-events: none;\n transition:\n transform var(--dropdown-open-dur) var(--dropdown-ease),\n opacity var(--dropdown-open-dur) var(--dropdown-ease);\n will-change: transform, opacity;\n}\n.t-dropdown[data-origin=\"top-right\"] { transform-origin: top right; }\n.t-dropdown[data-origin=\"top-center\"] { transform-origin: top center; }\n.t-dropdown[data-origin=\"bottom-left\"] { transform-origin: bottom left; }\n.t-dropdown[data-origin=\"bottom-center\"] { transform-origin: bottom center; }\n.t-dropdown[data-origin=\"bottom-right\"] { transform-origin: bottom right; }\n\n.t-dropdown.is-open {\n transform: scale(1);\n opacity: 1;\n pointer-events: auto;\n}\n.t-dropdown.is-closing {\n transform: scale(var(--dropdown-closing-scale));\n opacity: 0;\n pointer-events: none;\n transition:\n transform var(--dropdown-close-dur) var(--dropdown-ease),\n opacity var(--dropdown-close-dur) var(--dropdown-ease);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-dropdown { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 07-panel-reveal.md */\n:root {\n --panel-open-dur: 400ms;\n --panel-close-dur: 350ms;\n --panel-translate-y: 100px;\n --panel-blur: 2px;\n --panel-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.t-panel-slide {\n transform: translateY(var(--panel-translate-y));\n opacity: 0;\n filter: blur(var(--panel-blur));\n pointer-events: none;\n transition:\n transform var(--panel-close-dur) var(--panel-ease),\n opacity var(--panel-close-dur) var(--panel-ease),\n filter var(--panel-close-dur) var(--panel-ease);\n will-change: transform, opacity, filter;\n}\n.t-panel-slide[data-open=\"true\"] {\n transform: translateY(0);\n opacity: 1;\n filter: blur(0);\n pointer-events: auto;\n transition:\n transform var(--panel-open-dur) var(--panel-ease),\n opacity var(--panel-open-dur) var(--panel-ease),\n filter var(--panel-open-dur) var(--panel-ease);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-panel-slide { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 16-tabs-sliding.md */\n:root {\n --tabs-dur: 250ms;\n --tabs-ease: cubic-bezier(0.22, 1, 0.36, 1);\n --tabs-text-muted: rgba(15, 15, 15, 0.8);\n --tabs-text-active: #0f0f0f;\n --tabs-bar-bg: #f1f1f1;\n --tabs-pill-bg: #ffffff;\n}\n\n/* The bar is just a flex container with padding for the pill\n to sit inside. Tabs sit on z-index: 1, the pill on z-index: 0,\n so labels read above the pill background. */\n.t-tabs {\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: 3px;\n padding: 3px;\n border-radius: 48px;\n background: var(--tabs-bar-bg);\n}\n.t-tab {\n position: relative;\n appearance: none;\n border: 0;\n background: transparent;\n height: 30px;\n padding: 4px 12px;\n color: var(--tabs-text-muted);\n cursor: pointer;\n border-radius: 48px;\n z-index: 1;\n transition: color var(--tabs-dur) var(--tabs-ease);\n}\n.t-tab:not([aria-selected=\"true\"]):hover,\n.t-tab[aria-selected=\"true\"] {\n color: var(--tabs-text-active);\n}\n\n/* The pill: width + transform are written inline by JS so\n the transition tweens between the previous and next\n measured positions. */\n.t-tabs-pill {\n position: absolute;\n top: 3px;\n left: 0;\n height: 30px;\n width: 0;\n background: var(--tabs-pill-bg);\n border-radius: 48px;\n transform: translateX(0);\n transition:\n transform var(--tabs-dur) var(--tabs-ease),\n width var(--tabs-dur) var(--tabs-ease);\n will-change: transform, width;\n z-index: 0;\n pointer-events: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-tabs-pill, .t-tab { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 21-accordion.md */\n:root {\n --acc-expand: 250ms;\n --acc-collapse: 250ms;\n --acc-chevron: 250ms;\n --acc-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n/* grid-template-rows 0fr \u2192 1fr gives a clean height animation\n with no JS measurement; the inner element clips overflow. */\n.t-acc-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--acc-collapse) var(--acc-ease);\n}\n.t-acc[data-open=\"true\"] .t-acc-panel {\n grid-template-rows: 1fr;\n transition: grid-template-rows var(--acc-expand) var(--acc-ease);\n}\n.t-acc-panel-inner {\n overflow: hidden;\n opacity: 0;\n filter: blur(2px);\n transition:\n opacity var(--acc-collapse) var(--acc-ease),\n filter var(--acc-collapse) var(--acc-ease);\n}\n.t-acc[data-open=\"true\"] .t-acc-panel-inner {\n opacity: 1;\n filter: blur(0);\n transition:\n opacity var(--acc-expand) var(--acc-ease),\n filter var(--acc-expand) var(--acc-ease);\n}\n/* The one disclosure glyph (disclosure.ts): a right chevron that\n turns down when the fold opens; non-scaling-stroke keeps the\n stroke width constant through the turn. */\n.t-acc-chevron {\n display: inline-flex;\n transform: rotate(0deg);\n transform-origin: center;\n transition: transform var(--acc-chevron) var(--acc-ease);\n}\n.t-acc-chevron path { vector-effect: non-scaling-stroke; }\n.t-acc[data-open=\"true\"] .t-acc-chevron {\n transform: rotate(90deg);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-acc-panel, .t-acc-panel-inner, .t-acc-chevron {\n transition: none !important;\n }\n}\n";
@@ -177,22 +177,18 @@ export const TRANSITIONS_CSS = `
177
177
  opacity var(--acc-expand) var(--acc-ease),
178
178
  filter var(--acc-expand) var(--acc-ease);
179
179
  }
180
- /* Flip the chevron vertically to turn the "v" into a "^".
181
- scaleY(-1) about the centre passes through a flat line at
182
- the midpoint (same look as a \`d:\` path morph) but animates
183
- in every browser, unlike CSS \`d:\` morphing (Chromium only).
184
- The chevron path is symmetric about the 16x16 viewBox
185
- centre, so the flip lands exactly on the "^"; non-scaling
186
- -stroke keeps the stroke width constant through the flip. */
180
+ /* The one disclosure glyph (disclosure.ts): a right chevron that
181
+ turns down when the fold opens; non-scaling-stroke keeps the
182
+ stroke width constant through the turn. */
187
183
  .t-acc-chevron {
188
184
  display: inline-flex;
189
- transform: scaleY(1);
185
+ transform: rotate(0deg);
190
186
  transform-origin: center;
191
187
  transition: transform var(--acc-chevron) var(--acc-ease);
192
188
  }
193
189
  .t-acc-chevron path { vector-effect: non-scaling-stroke; }
194
190
  .t-acc[data-open="true"] .t-acc-chevron {
195
- transform: scaleY(-1);
191
+ transform: rotate(90deg);
196
192
  }
197
193
 
198
194
  @media (prefers-reduced-motion: reduce) {
@@ -1,5 +1,27 @@
1
- /** "16:39" today, "Yesterday 16:39" / "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year). */
2
- export declare function shortWhen(iso: string, now?: Date): string;
1
+ /** The viewer's own time zone (a browser's); UTC where there is none to read. */
2
+ export declare function viewerZone(): string;
3
+ /** The one formatter for a time a person reads, on every surface: "16:39" today, "Yesterday 16:39" /
4
+ * "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year), counted in `zone`. With `clock`,
5
+ * a date further out keeps its time of day too ("Sep 28 16:39"), for deadlines and histories where the hour matters. */
6
+ export declare function shortWhen(iso: string, now?: Date, zone?: string, clock?: boolean): string;
7
+ /** A flow card's deadline in the viewer's zone, always with its time of day: "No reply by 16:30" today,
8
+ * "No reply by Tomorrow 16:30", "Moves on Oct 2 16:30" further out (a label's trailing " at" reads wrong before a date). */
9
+ export declare function deadlineWords(deadline: {
10
+ at: string;
11
+ label: string;
12
+ }, now?: Date, zone?: string): string;
13
+ /** The exact minute in `zone` ("2026-09-30 16:39", with " UTC" when it is UTC): a time's title, one hover away. */
14
+ export declare function fullWhen(iso: string, zone?: string): string;
15
+ /** Every server-rendered stamp (`whenHtml`'s `<time data-when>`) inside `root`, reworded by the one formatter in the
16
+ * viewer's zone: the same words on a desk and a phone, the exact minute in the title. An exact stamp
17
+ * (`exactWhenHtml`'s `data-when="exact"`, the ledger's seconds) keeps its seconds, in the viewer's zone, and its
18
+ * ISO title. The server's UTC words stay only without script. Times React writes and relative ages ("3 min ago")
19
+ * carry no `data-when` and are never touched. */
20
+ export declare function localizeTimes(root: ParentNode, now?: Date, zone?: string): void;
21
+ /** The exact second in `zone` ("2026-09-30 16:39:12", with " UTC" when it is UTC): an audit record's stamp. */
22
+ export declare function exactWhen(iso: string, zone?: string): string;
23
+ /** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
24
+ export declare function exactWhenHtml(iso: string): string;
3
25
  /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
4
26
  export declare function whenHtml(iso: string | null, full: string, now?: Date): string;
5
27
  /** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
package/dist/when-html.js CHANGED
@@ -1,34 +1,121 @@
1
1
  /**
2
2
  * A time a person reads. A desk shows the full stamp, as before; a phone (760px and narrower) shows the short
3
3
  * one — "16:39" today, "Yesterday 16:39", "Sep 28" otherwise — and the full stamp stays in the title. Stamps are
4
- * UTC to the minute, so the short form is counted in UTC too. The CSS that picks one lives in the shared page
4
+ * UTC to the minute, so the server counts in UTC; in the browser `localizeTimes` rewords each of these with the same
5
+ * formatter in the viewer's own zone, as the React views do. The CSS that picks one lives in the shared page
5
6
  * stylesheet (`.so-when-*` in serve.ts).
6
7
  */
7
8
  const escape = (value) => value.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8
9
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
9
10
  const DAY_MS = 86_400_000;
10
- /** "16:39" today, "Yesterday 16:39" / "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year). */
11
- export function shortWhen(iso, now = new Date()) {
11
+ /** One `Intl.DateTimeFormat` per zone: building one is the slow part, and every time on a page shares a few zones. */
12
+ const formats = new Map();
13
+ function formatIn(zone) {
14
+ let format = formats.get(zone);
15
+ if (format === undefined) {
16
+ format = new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
17
+ formats.set(zone, format);
18
+ }
19
+ return format;
20
+ }
21
+ /** A stamp's calendar day, clock and month in a time zone ("UTC" on the server, the viewer's own zone in a browser). */
22
+ function partsIn(at, zone) {
23
+ const read = (z) => Object.fromEntries(formatIn(z).formatToParts(at).map(part => [part.type, part.value]));
24
+ let parts;
25
+ try {
26
+ parts = read(zone);
27
+ }
28
+ catch {
29
+ parts = read("UTC");
30
+ }
31
+ return { year: Number(parts["year"]), month: Number(parts["month"]), day: Number(parts["day"]), clock: `${parts["hour"]}:${parts["minute"]}` };
32
+ }
33
+ const dayNumber = (p) => Math.floor(Date.UTC(p.year, p.month - 1, p.day) / DAY_MS);
34
+ /** The viewer's own time zone (a browser's); UTC where there is none to read. */
35
+ export function viewerZone() {
36
+ try {
37
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
38
+ }
39
+ catch {
40
+ return "UTC";
41
+ }
42
+ }
43
+ /** The one formatter for a time a person reads, on every surface: "16:39" today, "Yesterday 16:39" /
44
+ * "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year), counted in `zone`. With `clock`,
45
+ * a date further out keeps its time of day too ("Sep 28 16:39"), for deadlines and histories where the hour matters. */
46
+ export function shortWhen(iso, now = new Date(), zone = "UTC", clock = false) {
12
47
  const at = new Date(iso);
13
48
  if (Number.isNaN(at.getTime()))
14
49
  return iso;
15
- const clock = iso.length >= 16 && iso[10] === "T" ? iso.slice(11, 16) : at.toISOString().slice(11, 16);
16
- const days = Math.floor(at.getTime() / DAY_MS) - Math.floor(now.getTime() / DAY_MS);
50
+ const then = partsIn(at, zone), today = partsIn(now, zone);
51
+ const days = dayNumber(then) - dayNumber(today);
17
52
  if (days === 0)
18
- return clock;
53
+ return then.clock;
19
54
  if (days === -1)
20
- return `Yesterday ${clock}`;
55
+ return `Yesterday ${then.clock}`;
21
56
  if (days === 1)
22
- return `Tomorrow ${clock}`;
23
- const date = `${MONTHS[at.getUTCMonth()]} ${at.getUTCDate()}`;
24
- return at.getUTCFullYear() === now.getUTCFullYear() ? date : `${date} ${at.getUTCFullYear()}`;
57
+ return `Tomorrow ${then.clock}`;
58
+ const date = `${MONTHS[then.month - 1]} ${then.day}`;
59
+ return `${then.year === today.year ? date : `${date} ${then.year}`}${clock ? ` ${then.clock}` : ""}`;
60
+ }
61
+ /** A flow card's deadline in the viewer's zone, always with its time of day: "No reply by 16:30" today,
62
+ * "No reply by Tomorrow 16:30", "Moves on Oct 2 16:30" further out (a label's trailing " at" reads wrong before a date). */
63
+ export function deadlineWords(deadline, now = new Date(), zone = viewerZone()) {
64
+ if (Number.isNaN(new Date(deadline.at).getTime()))
65
+ return deadline.label;
66
+ const words = shortWhen(deadline.at, now, zone, true);
67
+ return /^(?:\d|Tomorrow|Yesterday)/.test(words) ? `${deadline.label} ${words}` : `${deadline.label.replace(/ at$/, "")} ${words}`;
68
+ }
69
+ /** The exact minute in `zone` ("2026-09-30 16:39", with " UTC" when it is UTC): a time's title, one hover away. */
70
+ export function fullWhen(iso, zone = "UTC") {
71
+ const at = new Date(iso);
72
+ if (Number.isNaN(at.getTime()))
73
+ return iso;
74
+ const p = partsIn(at, zone);
75
+ return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")} ${p.clock}${zone === "UTC" ? " UTC" : ""}`;
76
+ }
77
+ /** Every server-rendered stamp (`whenHtml`'s `<time data-when>`) inside `root`, reworded by the one formatter in the
78
+ * viewer's zone: the same words on a desk and a phone, the exact minute in the title. An exact stamp
79
+ * (`exactWhenHtml`'s `data-when="exact"`, the ledger's seconds) keeps its seconds, in the viewer's zone, and its
80
+ * ISO title. The server's UTC words stay only without script. Times React writes and relative ages ("3 min ago")
81
+ * carry no `data-when` and are never touched. */
82
+ export function localizeTimes(root, now = new Date(), zone = viewerZone()) {
83
+ root.querySelectorAll("time[data-when][datetime]").forEach(node => {
84
+ const iso = node.getAttribute("datetime") ?? "";
85
+ if (iso === "" || Number.isNaN(new Date(iso).getTime()))
86
+ return;
87
+ if (node.getAttribute("data-when") === "exact") {
88
+ const words = exactWhen(iso, zone);
89
+ if (node.textContent !== words)
90
+ node.textContent = words;
91
+ return;
92
+ }
93
+ const words = shortWhen(iso, now, zone), full = fullWhen(iso, zone);
94
+ if (node.title !== full)
95
+ node.title = full;
96
+ if (node.textContent !== words)
97
+ node.textContent = words;
98
+ });
99
+ }
100
+ /** The exact second in `zone` ("2026-09-30 16:39:12", with " UTC" when it is UTC): an audit record's stamp. */
101
+ export function exactWhen(iso, zone = "UTC") {
102
+ const at = new Date(iso);
103
+ if (Number.isNaN(at.getTime()))
104
+ return iso;
105
+ const seconds = String(at.getUTCSeconds()).padStart(2, "0");
106
+ const minute = fullWhen(iso, zone);
107
+ return zone === "UTC" ? minute.replace(/ UTC$/, `:${seconds} UTC`) : `${minute}:${seconds}`;
108
+ }
109
+ /** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
110
+ export function exactWhenHtml(iso) {
111
+ return `<time data-when="exact" datetime="${escape(iso)}" title="${escape(iso)}">${escape(exactWhen(iso))}</time>`;
25
112
  }
26
113
  /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
27
114
  export function whenHtml(iso, full, now = new Date()) {
28
115
  if (iso === null || iso === "")
29
116
  return "";
30
117
  const words = escape(full);
31
- return `<time datetime="${escape(iso)}" title="${words}"><span class="so-when-full">${words}</span><span class="so-when-short">${escape(shortWhen(iso, now))}</span></time>`;
118
+ return `<time data-when datetime="${escape(iso)}" title="${words}"><span class="so-when-full">${words}</span><span class="so-when-short">${escape(shortWhen(iso, now))}</span></time>`;
32
119
  }
33
120
  /** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
34
121
  export const utcMinute = (iso) => `${iso.slice(0, 16).replace("T", " ")} UTC`;
@@ -2,7 +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
+ import { type Ask, type AskChip } from './needs-you.js';
6
6
  export type WorkIndexCounts = Record<WorkView, number>;
7
7
  export type WorkIndexItem = {
8
8
  rootId: string;
@@ -25,6 +25,8 @@ export type WorkIndexItem = {
25
25
  status: WorkStatus;
26
26
  /** What a Needs you row asks of a person (needs-you.ts); null for every other row. */
27
27
  ask: Ask | null;
28
+ /** The row's chip under its group: the specific ask (needs-you.ts askChipOf), or none. */
29
+ chip: AskChip | null;
28
30
  primaryAction: WorkAction | null;
29
31
  completion: {
30
32
  actor: string;
@@ -32,6 +34,8 @@ export type WorkIndexItem = {
32
34
  digest: string;
33
35
  } | null;
34
36
  evidence: 'recorded';
37
+ /** The viewer's own lead took it on (lead-voice.ts): "<name> is on it.", by the name they gave it, read now. */
38
+ lead?: string;
35
39
  };
36
40
  export type WorkIndexOptions = {
37
41
  view?: WorkView;
@@ -1,17 +1,18 @@
1
1
  /** Paginated, permission-bound summaries of saved task facts. These reads do
2
2
  * not verify artifacts or authorize task operations; a build Toolroll can't
3
3
  * yet confirm stopped is read through the store's own read-only probe. */
4
+ import { leadNameOf } from './lead-identity.js';
4
5
  import { createHash } from 'node:crypto';
5
6
  import { existsSync } from 'node:fs';
6
7
  import { join } from 'node:path';
7
8
  import { readProjectAccess } from './project-access.js';
8
9
  import { pauseOnTaskProviders, providerName, signInReason } from './provider-auth.js';
9
10
  import { scopeTermsProblem, stopFactOf } from './store.js';
10
- import { plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } from './task-status.js';
11
+ import { leadOnIt, plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } from './task-status.js';
11
12
  import { isCheckLevel } from './check-levels.js';
12
13
  import { MARKER } from './worktree.js';
13
14
  import { withFollowUps } from './result-follow-ups.js';
14
- import { ASKS, NEED_ASK, NEEDS, failedAttemptSentence, processNeedOf } from './needs-you.js';
15
+ import { ASKS, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, processNeedOf, resultHoldUpSentence } from './needs-you.js';
15
16
  import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
16
17
  export const WORK_INDEX_PAGE_LIMIT = 40;
17
18
  export const WORK_INDEX_MAX_LIMIT = 100;
@@ -379,7 +380,8 @@ export function workIndexPage(store, now, access, options = {}) {
379
380
  'proof_verdict',(SELECT COALESCE(machine_verdict,verdict) FROM proof_verdict WHERE run=page_result_id),
380
381
  '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,
381
382
  'checked_actor',checked_actor,'checked_at',checked_at,'checked_digest',checked_digest,'question_id',question_id,
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,
383
+ 'dependency_id',dependency_id,'dependency_state',dependency_state,'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,300),'hold_id',hold_id,'live_lease',live_lease IS NOT NULL,'approved',COALESCE(approved,0),
384
+ 'plan_changed',scope_digest IS NOT NULL AND digest IS NOT scope_digest,'head_ok',length(head_revision)=40 AND head_revision NOT GLOB '*[^a-f0-9]*','stop_run',stop_run,'unfinished',unfinished,'signed_out',signed_out,
383
385
  'question_run',(SELECT run FROM decision WHERE id=page.question_id),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
384
386
  'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),
385
387
  'question_task',(SELECT r.external_id FROM decision d JOIN run ON run.id=d.run JOIN task_ref r ON r.id=run.task_ref WHERE d.id=page.question_id)) FROM page`)
@@ -394,7 +396,9 @@ export function workIndexPage(store, now, access, options = {}) {
394
396
  const ref = store.lookupRef(taskId);
395
397
  return ref === null ? null : custody.readings.get(ref.id) ?? null;
396
398
  };
397
- return { items: page.map(row => itemOf(row, access.principal, probe)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
399
+ // A claimed row says the viewer's own name for their lead.
400
+ const leadName = page.some(row => n(row, 'lead_claim') !== 0) && access.principal === 'operator' && access.viewer != null ? leadNameOf(store, access.viewer) : undefined;
401
+ return { items: page.map(row => itemOf(row, access.principal, probe, leadName)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
398
402
  groups: rows[0] === undefined ? { ...NO_GROUPS } : groupCounts(rows[0]), nextCursor, limit, view };
399
403
  }
400
404
  export function workCountsByProject(store, now, access) {
@@ -440,13 +444,13 @@ function listChecksOf(own, level, follows) {
440
444
  return null;
441
445
  return { status: read.status, exitCode: null, head: null, level: read.level, running: read.running };
442
446
  }
443
- function itemOf(row, principal, probe) {
447
+ function itemOf(row, principal, probe, leadName) {
444
448
  const code = String(row['code']), id = String(row['id']);
445
449
  const custody = code === 'process-needs-attention' || (code === 'result-needs-attention' && n(row, 'custody_unresolved') === 1);
446
450
  const process = custody && probe !== undefined ? processNeedOf(probe(id)) : null;
447
451
  const waiting = process !== null && 'wait' in process;
448
452
  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".
453
+ // The lead's claim (lead-voice.ts): the row still reads its own stage, worded "<name> is on it".
450
454
  const lead = n(row, 'lead_claim') === 1 ? 'on-it' : n(row, 'lead_claim') === 2 ? 'lapsed' : null;
451
455
  const personNeed = (n(row, 'person_need') === 1 || need) && !waiting;
452
456
  const assignmentState = code === 'complete' ? 'complete' : code === 'ready-to-check' ? 'ready-to-check'
@@ -460,7 +464,8 @@ function itemOf(row, principal, probe) {
460
464
  running: ['Running now', '', null, ''],
461
465
  'waiting-decision': ['Needs your decision', 'An unanswered question is waiting.', n(row, 'question_id') ? 'answer-decision' : 'inspect-decisions', 'Answer question'],
462
466
  'decision-queue': ['Needs your decision', 'The decision queue is full.', 'inspect-decisions', 'Review open questions'],
463
- 'result-needs-attention': ['Needs your decision', 'Inspect the saved result and resolve its remaining execution or scope issue.', 'inspect-run', 'Inspect run'],
467
+ // Its own reason replaces this below (resultHoldUpSentence).
468
+ 'result-needs-attention': ['Needs your decision', '', 'inspect-run', 'Inspect run'],
464
469
  'process-needs-attention': ['Needs your decision', 'A process exit is not recorded. Open the attempt to check whether its work has stopped.', 'inspect-run', 'Inspect run'],
465
470
  failed: ['Needs your decision', 'The last attempt stopped.', 'retry-task', 'Review and retry'],
466
471
  'vanished-run': ['Needs your decision', 'An unfinished attempt has no current live claim.', 'reconcile-run', 'Check the unfinished attempt'],
@@ -505,6 +510,13 @@ function itemOf(row, principal, probe) {
505
510
  // A failed task says the latest finished attempt across its whole family, whatever its outcome: its recorded reason, or that none was recorded.
506
511
  if (code === 'failed')
507
512
  detail = failedAttemptSentence(s(row, 'attempt_reason'));
513
+ // A saved result that can't be accepted yet says its own reason, never one line for every such row.
514
+ const planChanged = code === 'result-needs-attention' && n(row, 'plan_changed') === 1;
515
+ if (code === 'result-needs-attention')
516
+ detail = resultHoldUpSentence({ hold: s(row, 'hold_id') === null ? null : s(row, 'hold_reason') ?? '', planChanged,
517
+ unapproved: n(row, 'approved') !== 1, question: s(row, 'question_id') !== null, running: n(row, 'live_lease') === 1, unfinished: s(row, 'unfinished') !== null,
518
+ noCommit: !['built', 'no-change'].includes(s(row, 'result_outcome') ?? '') || (s(row, 'result_role') === 'builder' && n(row, 'head_ok') !== 1),
519
+ report: s(row, 'result_role') === 'scout' });
508
520
  if (code === 'result-needs-attention' && n(row, 'custody_unresolved'))
509
521
  detail = 'A process exit is not recorded. Open the result to check whether its work has stopped.';
510
522
  if (code === 'result-needs-attention' && ['built', 'no-change'].includes(s(row, 'result_outcome') ?? '')) {
@@ -550,7 +562,7 @@ function itemOf(row, principal, probe) {
550
562
  reason: finished ? null : plainReasonOf(reading.stage, code, detail || null), report: s(row, 'result_role') === 'scout',
551
563
  checks: !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']),
552
564
  completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, ''),
553
- ...(lead === null ? {} : { lead }) });
565
+ ...(lead === null ? {} : { lead, ...(leadName === undefined ? {} : { leadName }) }) });
554
566
  label = shared.headline;
555
567
  detail = shared.sentence;
556
568
  // Every Needs you action wears its need's own words.
@@ -573,6 +585,7 @@ function itemOf(row, principal, probe) {
573
585
  status: { token: `assignment-${assignmentState}`, label, detail, tone: workToneOf(shared.headline),
574
586
  action: primaryAction === null ? null : { label: actionLabel, kind: actionCode === 'open-result' ? 'open-result' : 'open-task' }, views, rank: n(row, 'rank') },
575
587
  ask: need ? ASKS[n(row, 'ask_rank')] ?? 'unblock' : null,
588
+ chip: need ? askChipOf({ headline: shared.headline, need: shared.need?.key ?? null, planChanged, mismatch: verdict === 'refuted' && n(row, 'proof_failed_check') !== 1, ask: ASKS[n(row, 'ask_rank')] ?? 'unblock' }) : null,
576
589
  primaryAction, completion: code === 'complete' ? { actor: String(row['checked_actor']), at: String(row['checked_at']), digest: String(row['checked_digest']) } : null,
577
- evidence: 'recorded' };
590
+ evidence: 'recorded', ...(lead === 'on-it' ? { lead: leadOnIt(leadName) } : {}) };
578
591
  }
@@ -42,7 +42,7 @@ export declare function workDecisionAction(taskId: string, decision: NonNullable
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
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". */
45
+ /** `viewer`: the person reading, whose own lead's claim (lead-voice.ts) a task reads as "<name> is on it". */
46
46
  {
47
47
  principal: "operator";
48
48
  repos: readonly string[] | null;
@@ -21,7 +21,7 @@ export const WORKSPACE_MOTION_CSS = String.raw `
21
21
  .t-acc[data-open="false"] > .t-acc-panel { grid-template-rows: 0fr; }
22
22
  .t-acc[data-open="false"] > .t-acc-panel > .t-acc-panel-inner { opacity: 0; }
23
23
  .t-acc > summary { cursor: pointer; }
24
- .t-acc > summary .t-acc-chevron { width: 14px; height: 14px; margin-left: .4rem; }
24
+ .t-acc > summary .t-acc-chevron { width: 14px; height: 14px; margin-right: .375rem; flex: none; }
25
25
  .t-acc > summary .t-acc-chevron svg { width: 100%; height: 100%; }
26
26
  .t-acc > summary::before, .t-acc > summary::after { content: none !important; }
27
27
  .t-acc > summary::-webkit-details-marker { display: none; }
@@ -60,9 +60,9 @@ export const WORKSPACE_MOTION_SCRIPT = String.raw `
60
60
  Array.from(details.childNodes).forEach(function(node){if(node!==head)inner.appendChild(node);});
61
61
  panel.appendChild(inner);details.appendChild(panel);details.classList.add('t-acc');
62
62
  var chevron=document.createElement('span');chevron.className='t-acc-chevron';chevron.setAttribute('aria-hidden','true');
63
- chevron.innerHTML='<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6.5L8 10.5L12 6.5"/></svg>';
63
+ chevron.innerHTML='<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 4L10 8L6 12"/></svg>';
64
64
  // Use the existing icon when present, rather than rendering two.
65
- var old=head.querySelector(':scope > svg');if(old)old.remove();head.appendChild(chevron);
65
+ var old=head.querySelector(':scope > svg');if(old)old.remove();head.prepend(chevron);
66
66
  }
67
67
  details.dataset.motionReady='true';
68
68
  var timer=null,closing=false;