toolroll 0.9.13 → 0.9.15

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 (43) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/assignment-ui.d.ts +1 -0
  3. package/dist/assignment-ui.js +8 -5
  4. package/dist/assignment.d.ts +32 -0
  5. package/dist/assignment.js +81 -7
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +38 -38
  8. package/dist/browser-crew.d.ts +2 -1
  9. package/dist/browser-crew.js +4 -2
  10. package/dist/browser-workspace.d.ts +109 -11
  11. package/dist/chat-actions.d.ts +1 -1
  12. package/dist/chat-actions.js +6 -4
  13. package/dist/chat-delivery-state.d.ts +1 -1
  14. package/dist/chat-voice.d.ts +3 -3
  15. package/dist/chat-voice.js +3 -3
  16. package/dist/demo.js +82 -2
  17. package/dist/disclosure.d.ts +6 -0
  18. package/dist/disclosure.js +13 -0
  19. package/dist/dispatch.js +7 -2
  20. package/dist/lead-status.js +2 -2
  21. package/dist/needs-you.d.ts +72 -0
  22. package/dist/needs-you.js +193 -1
  23. package/dist/result-acts.d.ts +51 -5
  24. package/dist/result-acts.js +36 -3
  25. package/dist/result-review.d.ts +4 -3
  26. package/dist/result-review.js +7 -3
  27. package/dist/serve.d.ts +3 -0
  28. package/dist/serve.js +1235 -711
  29. package/dist/slack-chat.js +1 -1
  30. package/dist/task-status.d.ts +24 -3
  31. package/dist/task-status.js +48 -17
  32. package/dist/telegram-progress.js +2 -2
  33. package/dist/test-temp.js +1 -1
  34. package/dist/transitions-recipes.d.ts +1 -1
  35. package/dist/transitions-recipes.js +5 -9
  36. package/dist/when-html.d.ts +19 -2
  37. package/dist/when-html.js +78 -11
  38. package/dist/work-index.d.ts +3 -1
  39. package/dist/work-index.js +18 -5
  40. package/dist/workspace-motion.js +3 -3
  41. package/dist/workspace-ui.d.ts +86 -6
  42. package/dist/workspace-ui.js +143 -17
  43. package/package.json +1 -1
@@ -360,7 +360,7 @@ export async function deliverSlackPart(options) {
360
360
  }))
361
361
  : []),
362
362
  ...linkButton(options.origin(), content.link),
363
- // [Look first] beside [Merge] or [Mark complete]: each button's own action id.
363
+ // [Look first] beside [Merge] or [Accept and finish]: each button's own action id.
364
364
  ...(content.also ?? []).flatMap((one, index) => linkButton(options.origin(), one).map(button => ({ ...button, action_id: `toolroll_link_${index + 2}` }))),
365
365
  ];
366
366
  const target = content.edit ?? row.message;
@@ -20,6 +20,11 @@ export declare const HEADLINES: readonly ["Queued", "Planning", "Needs you", "Wa
20
20
  export type Headline = (typeof HEADLINES)[number];
21
21
  export type HeadlineTone = "neutral" | "live" | "attention" | "ready" | "success" | "danger";
22
22
  export declare const HEADLINE_TONE: Readonly<Record<Headline, HeadlineTone>>;
23
+ /** The project's own checks and the pull request's CI are two different things, never one word (Checks beside CI). */
24
+ export declare const CHECKS_LABEL = "Project checks";
25
+ export declare const PR_CI_LABEL = "PR CI";
26
+ /** The one verb for opening a saved result, on every surface. */
27
+ export declare const OPEN_RESULT = "Open result";
23
28
  export type DetailKey = "checks" | "pull-request" | "requirements" | "evidence";
24
29
  /** The row's small icon: the only coloured part of a detail. `failed` is red
25
30
  * and only ever appears under the Failed headline; `note` is amber. */
@@ -86,10 +91,14 @@ export type TaskStatusFacts = {
86
91
  report?: boolean;
87
92
  checks?: ChecksFact | null;
88
93
  pullRequest?: PullRequestFact | null;
94
+ /** `unverified`: the report is refuted, so no requirement counts as met however it was marked. `missed`: the ones it
95
+ * failed or left unanswered. */
89
96
  requirements?: {
90
97
  met: number;
91
98
  total: number;
92
99
  yours: number;
100
+ missed?: number;
101
+ unverified?: boolean;
93
102
  } | null;
94
103
  evidence?: {
95
104
  shortened: number;
@@ -151,14 +160,21 @@ export declare function assignmentStageOf(assignment: Pick<AssignmentSnapshot, "
151
160
  need?: TaskStatusFacts["need"];
152
161
  wait?: WaitKey;
153
162
  };
154
- /** Requirements from the stored matrix: met, the ones only a person confirms, and the total. */
155
- export declare function requirementsOf(matrix: readonly {
163
+ type RequirementRow = {
156
164
  state: string;
157
165
  assessment?: {
158
166
  evidenceState?: string;
159
167
  } | undefined;
160
168
  review?: unknown;
161
- }[] | null | undefined): NonNullable<TaskStatusFacts["requirements"]> | null;
169
+ };
170
+ /** One requirement's state in words: the card's Requirements row and the Checks tab read this one source, so they
171
+ * can't disagree. A refuted report verifies none of its requirements, so each reads Unverified, as the card does. */
172
+ export type RequirementWord = "Met" | "You check" | "Not shown yet" | "Not met" | "Unverified";
173
+ export declare function requirementWordOf(row: RequirementRow, verdict?: string | null): RequirementWord;
174
+ /** Requirements from the stored matrix: met, the ones only a person confirms, and the total. */
175
+ export declare function requirementsOf(matrix: readonly RequirementRow[] | null | undefined): NonNullable<TaskStatusFacts["requirements"]> | null;
176
+ /** A refuted report verifies none of its requirements, whatever it marked met (a person's acceptance doesn't change that). */
177
+ export declare function unverifiedWhenRefuted(requirements: NonNullable<TaskStatusFacts["requirements"]> | null, verdict: string | null | undefined): NonNullable<TaskStatusFacts["requirements"]> | null;
162
178
  /** Saved evidence health from the receipt's artifact list and its caveats. */
163
179
  export declare function evidenceOf(receipt: AssignmentSnapshot["receipt"], problems?: readonly string[]): NonNullable<TaskStatusFacts["evidence"]> | null;
164
180
  /** The status facts of one assignment, as every surface reads them. */
@@ -184,6 +200,8 @@ export declare function headlineEmoji(headline: Headline): string;
184
200
  export declare const statusIconSvg: (mark: DetailMark) => string;
185
201
  /** The detail rows as server HTML: quiet rows, colour only on the icon. */
186
202
  export declare function statusDetailsHtml(status: TaskStatus): string;
203
+ /** The status card's fold of exact reasons: "More", never a third thing called Details. */
204
+ export declare const STATUS_MORE = "More";
187
205
  /** The technical reasons, one tap away. */
188
206
  export declare function statusWhyHtml(status: TaskStatus, extra?: readonly string[], diagnostics?: readonly {
189
207
  token: string;
@@ -207,3 +225,6 @@ export declare function stageOfDispatch(d: {
207
225
  stage: TaskStage;
208
226
  need?: TaskStatusFacts["need"];
209
227
  };
228
+ /** The demo runs no checks: a Checks row that would offer to run one (or open one that never ran) says so instead. */
229
+ export declare const DEMO_CHECKS = "Can't run in the demo";
230
+ export declare function demoChecksOf(status: TaskStatus): TaskStatus;
@@ -8,6 +8,11 @@ export const HEADLINE_TONE = {
8
8
  Queued: "neutral", Planning: "live", "Needs you": "attention", Waiting: "neutral", Building: "live",
9
9
  "Ready for review": "ready", Complete: "success", Failed: "danger", Stopped: "neutral",
10
10
  };
11
+ /** The project's own checks and the pull request's CI are two different things, never one word (Checks beside CI). */
12
+ export const CHECKS_LABEL = "Project checks";
13
+ export const PR_CI_LABEL = "PR CI";
14
+ /** The one verb for opening a saved result, on every surface. */
15
+ export const OPEN_RESULT = "Open result";
11
16
  const short = (sha) => sha !== null && /^[a-f0-9]{7,40}$/.test(sha) ? sha.slice(0, 7) : null;
12
17
  export function headlineOf(facts) {
13
18
  switch (facts.stage) {
@@ -76,19 +81,20 @@ function detailsOf(headline, facts) {
76
81
  const runChecks = facts.links?.runChecks === undefined ? null : { label: "Run checks", href: facts.links.runChecks };
77
82
  const quick = checks.level === "quick";
78
83
  if (checks.running != null)
79
- row("checks", "Checks", `${checks.running === "quick" ? "Quick" : "Full"} checks running`, "running", { href: checksHref });
84
+ row("checks", CHECKS_LABEL, `${checks.running === "quick" ? "Quick" : "Full"} checks running`, "running", { href: checksHref });
80
85
  else if (checks.status === "passed")
81
- row("checks", "Checks", `${quick ? "Quick checks passed" : "Passed"}${sha === null ? "" : ` on ${sha}`}`, "ok", quick && runChecks !== null && headline !== "Complete" ? { href: checksHref, action: { label: "Run full checks", href: runChecks.href } } : { href: checksHref });
86
+ row("checks", CHECKS_LABEL, `${quick ? "Quick checks passed" : "Passed"}${sha === null ? "" : ` on ${sha}`}`, "ok", quick && runChecks !== null && headline !== "Complete" ? { href: checksHref, action: { label: "Run full checks", href: runChecks.href } } : { href: checksHref });
82
87
  else if (checks.status === "failed")
83
- row("checks", "Checks", `${quick ? "Quick checks failed" : "Failed"}${checks.exitCode === null ? "" : ` (exit ${checks.exitCode})`}`, problem(headline), headline === "Failed" ? { href: checksHref } : { action: { label: "See what failed", href: checksHref } });
88
+ row("checks", CHECKS_LABEL, `${quick ? "Quick checks failed" : "Failed"}${checks.exitCode === null ? "" : ` (exit ${checks.exitCode})`}`, problem(headline), headline === "Failed" ? { href: checksHref } : { action: { label: "See what failed", href: checksHref } });
84
89
  else if (checks.status === "running")
85
- row("checks", "Checks", "Running", "running");
90
+ row("checks", CHECKS_LABEL, "Running", "running");
86
91
  else if (checks.level === "off")
87
- row("checks", "Checks", "Off for this project", "none", runChecks === null ? {} : { action: runChecks });
92
+ row("checks", CHECKS_LABEL, "Off for this project", "none", runChecks === null ? {} : { action: runChecks });
88
93
  else if (checks.status === "not-run")
89
- row("checks", "Checks", "Didn't run", "none", runChecks === null ? {} : { action: runChecks });
94
+ row("checks", CHECKS_LABEL, "Didn't run", "none", runChecks === null ? {} : { action: runChecks });
95
+ // A saved check that can't be read is run again where that is possible.
90
96
  else
91
- row("checks", "Checks", "Couldn't be read", "note", { action: { label: "Open the result", href: facts.links?.result ?? null } });
97
+ row("checks", CHECKS_LABEL, "Couldn't be read", "note", { action: runChecks ?? { label: OPEN_RESULT, href: facts.links?.result ?? null } });
92
98
  }
93
99
  const pr = facts.pullRequest;
94
100
  if (pr != null) {
@@ -105,16 +111,21 @@ function detailsOf(headline, facts) {
105
111
  else if (pr.state === "failed")
106
112
  row("pull-request", "Pull request", "Couldn't open", "note", { action: pr.compareUrl ? { label: "Open it on GitHub", href: pr.compareUrl } : { label: "See why", href: facts.links?.pullRequest ?? null }, why: pr.error === null ? "Publishing gave up. The commit is safe locally." : `${pr.error} The commit is safe locally.` });
107
113
  else if (pr.ci === "running")
108
- row("pull-request", "Pull request", `${name} open, CI running`, "running", { href: github });
114
+ row("pull-request", "Pull request", `${name} open · ${PR_CI_LABEL} running`, "running", { href: github });
109
115
  else if (pr.ci === "passing")
110
- row("pull-request", "Pull request", `${name} open, CI passed`, "ok", { href: github });
116
+ row("pull-request", "Pull request", `${name} open · ${PR_CI_LABEL} passed`, "ok", { href: github });
111
117
  else if (pr.ci === "failing")
112
- row("pull-request", "Pull request", `${name} open, CI failing`, "note", { action: { label: "Open it on GitHub", href: github } });
118
+ row("pull-request", "Pull request", `${name} open · ${PR_CI_LABEL} failing`, "note", { action: { label: "Open it on GitHub", href: github } });
113
119
  else
114
120
  row("pull-request", "Pull request", `${name} open`, "none", { href: github });
115
121
  }
116
122
  const req = facts.requirements;
117
- if (req != null && req.total > 0) {
123
+ // A failed result names how many it missed, refuted or not: that is what failed.
124
+ if (req != null && req.total > 0 && headline === "Failed" && (req.missed ?? 0) > 0)
125
+ row("requirements", "Requirements", `${req.missed} missed`, "failed");
126
+ else if (req != null && req.total > 0 && req.unverified === true)
127
+ row("requirements", "Requirements", "Unverified", "none");
128
+ else if (req != null && req.total > 0) {
118
129
  const unmet = req.total - req.met - req.yours;
119
130
  const text = `${req.met} of ${req.total} met${req.yours > 0 ? ` · You check ${req.yours}` : ""}`;
120
131
  row("requirements", "Requirements", text, unmet > 0 ? problem(headline) : req.met === req.total ? "ok" : "none", unmet > 0 && headline !== "Failed" ? { action: { label: "See which", href: facts.links?.checks ?? facts.links?.result ?? null } } : {});
@@ -256,13 +267,25 @@ export function assignmentStageOf(assignment, work, planning = false) {
256
267
  }
257
268
  }
258
269
  }
270
+ /** Evidence that passed and only awaits the retired assessment step is met. */
271
+ const requirementMet = (row) => row.state === "pass" || (row.assessment?.evidenceState === "pass" && (row.review ?? null) === null);
272
+ export function requirementWordOf(row, verdict) {
273
+ if (verdict === "refuted")
274
+ return "Unverified";
275
+ if (requirementMet(row))
276
+ return "Met";
277
+ return row.state === "manual-review" ? "You check" : row.state === "missing" ? "Not shown yet" : "Not met";
278
+ }
259
279
  /** Requirements from the stored matrix: met, the ones only a person confirms, and the total. */
260
280
  export function requirementsOf(matrix) {
261
281
  if (!matrix || matrix.length === 0)
262
282
  return null;
263
- // Evidence that passed and only awaits the retired assessment step is met.
264
- const met = (row) => row.state === "pass" || (row.assessment?.evidenceState === "pass" && (row.review ?? null) === null);
265
- return { met: matrix.filter(met).length, total: matrix.length, yours: matrix.filter(row => !met(row) && row.state === "manual-review").length };
283
+ return { met: matrix.filter(requirementMet).length, total: matrix.length, yours: matrix.filter(row => requirementWordOf(row) === "You check").length,
284
+ missed: matrix.filter(row => !requirementMet(row) && (row.state === "failed" || row.state === "missing")).length };
285
+ }
286
+ /** A refuted report verifies none of its requirements, whatever it marked met (a person's acceptance doesn't change that). */
287
+ export function unverifiedWhenRefuted(requirements, verdict) {
288
+ return requirements === null || verdict !== "refuted" ? requirements : { ...requirements, unverified: true };
266
289
  }
267
290
  /** Saved evidence health from the receipt's artifact list and its caveats. */
268
291
  export function evidenceOf(receipt, problems = receipt?.caveats ?? []) {
@@ -297,7 +320,7 @@ export function assignmentStatusFacts(assignment, options = {}) {
297
320
  stage, ...(need === undefined ? {} : { need }), ...(wait === undefined ? {} : { wait }), ...(build === null ? {} : { needContext: { build } }), reason, report,
298
321
  checks,
299
322
  pullRequest: withResult ? pullRequest : null,
300
- requirements: withResult ? requirementsOf(receipt?.proof?.matrix) : null,
323
+ requirements: withResult ? unverifiedWhenRefuted(requirementsOf(receipt?.proof?.matrix), receipt?.proof?.verdict) : null,
301
324
  evidence: withResult ? options.evidence ?? evidenceOf(receipt, [...(receipt?.caveats ?? []), ...assignment.attention]) : null,
302
325
  completedBy: assignment.completion === null ? null : assignment.completion.lead === true ? "the lead" : assignment.completion.actor.replace(/^(?:operator|coordinator|lead):/, ""),
303
326
  action: options.action ?? null,
@@ -341,18 +364,20 @@ export const statusIconSvg = (mark) => `<svg class="status-icon status-icon--${m
341
364
  export function statusDetailsHtml(status) {
342
365
  if (status.details.length === 0)
343
366
  return "";
344
- return `<ul class="status-details" aria-label="Details">` + status.details.map(one => {
367
+ return `<ul class="status-details" aria-label="Status">` + status.details.map(one => {
345
368
  const text = one.href === null ? escapeHtml(one.text) : `<a href="${escapeHtml(one.href)}">${escapeHtml(one.text)}</a>`;
346
369
  const action = one.action === null ? "" : one.action.href === null ? `<span class="status-detail-act">${escapeHtml(one.action.label)}</span>` : `<a class="status-detail-act" href="${escapeHtml(one.action.href)}">${escapeHtml(one.action.label)}</a>`;
347
370
  return `<li class="status-detail status-detail--${one.mark}" data-status-detail="${one.key}" data-mark="${one.mark}">${statusIconSvg(one.mark)}<span class="status-detail-label">${escapeHtml(one.label)}</span><span class="status-detail-text"><span>${text}</span>${action}</span></li>`;
348
371
  }).join("") + `</ul>`;
349
372
  }
373
+ /** The status card's fold of exact reasons: "More", never a third thing called Details. */
374
+ export const STATUS_MORE = "More";
350
375
  /** The technical reasons, one tap away. */
351
376
  export function statusWhyHtml(status, extra = [], diagnostics = []) {
352
377
  const lines = [...new Set([...status.details.flatMap(one => one.why === null ? [] : [`${one.label}: ${one.why}`]), ...status.why, ...extra])];
353
378
  if (lines.length === 0 && diagnostics.length === 0)
354
379
  return "";
355
- return `<details class="status-why"><summary>Details</summary>${lines.map(one => `<p class="meta">${escapeHtml(one)}</p>`).join("")}` +
380
+ return `<details class="status-why"><summary>${STATUS_MORE}</summary>${lines.map(one => `<p class="meta">${escapeHtml(one)}</p>`).join("")}` +
356
381
  diagnostics.map(one => `<p class="meta" data-work-diagnostic="${escapeHtml(one.token)}">${escapeHtml(one.label)} · ${escapeHtml(one.detail)}</p>`).join("") + `</details>`;
357
382
  }
358
383
  /** Shared CSS for the server-rendered status: neutral rows, coloured icons. */
@@ -371,3 +396,9 @@ export function stageOfDispatch(d, options = {}) {
371
396
  return { stage: "needs-you", need: "review-result" };
372
397
  return stageOfCode(d.code, { needsPerson: d.condition === "waiting" && d.action !== null, planning: d.condition === "running" && d.role === "planner", operatorHold: d.action === "unhold" });
373
398
  }
399
+ /** The demo runs no checks: a Checks row that would offer to run one (or open one that never ran) says so instead. */
400
+ export const DEMO_CHECKS = "Can't run in the demo";
401
+ export function demoChecksOf(status) {
402
+ return { ...status, details: status.details.map(one => one.key !== "checks" || one.mark === "ok" || one.mark === "failed" || one.mark === "running" ? one
403
+ : { ...one, text: DEMO_CHECKS, mark: "none", href: null, action: null, why: null }) };
404
+ }
@@ -7,7 +7,7 @@
7
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
- import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, taskStatusOf } from "./task-status.js";
10
+ import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
11
11
  import { leadClaimOf } from "./lead-voice.js";
12
12
  import { chatTitle } from "./chat-voice.js";
13
13
  import { manualReviewOnly } from "./proof.js";
@@ -49,7 +49,7 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
49
49
  : exit !== null ? { status: "failed", exitCode: exit, head: run.headRevision }
50
50
  : machine === "verified" ? { status: "passed", exitCode: null, head: run.headRevision }
51
51
  : machine === "attested" ? { status: "not-run", exitCode: null, head: null } : null;
52
- facts = { stage: "finished", report: run.role === "scout", checks, requirements: requirementsOf(proof?.matrix),
52
+ facts = { stage: "finished", report: run.role === "scout", checks, requirements: unverifiedWhenRefuted(requirementsOf(proof?.matrix), proof?.verdict),
53
53
  ...(pullRequest === undefined ? {} : { pullRequest }) };
54
54
  }
55
55
  else if (accessBlocked) {
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-",
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,22 @@
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. The server's UTC words stay
17
+ * only without script. Times React writes, relative ages ("3 min ago") and exact stamps (the ledger's seconds) carry
18
+ * no `data-when` and are never touched. */
19
+ export declare function localizeTimes(root: ParentNode, now?: Date, zone?: string): void;
3
20
  /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
4
21
  export declare function whenHtml(iso: string | null, full: string, now?: Date): string;
5
22
  /** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
package/dist/when-html.js CHANGED
@@ -1,34 +1,101 @@
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. The server's UTC words stay
79
+ * only without script. Times React writes, relative ages ("3 min ago") and exact stamps (the ledger's seconds) carry
80
+ * no `data-when` and are never touched. */
81
+ export function localizeTimes(root, now = new Date(), zone = viewerZone()) {
82
+ root.querySelectorAll("time[data-when][datetime]").forEach(node => {
83
+ const iso = node.getAttribute("datetime") ?? "";
84
+ if (iso === "" || Number.isNaN(new Date(iso).getTime()))
85
+ return;
86
+ const words = shortWhen(iso, now, zone), full = fullWhen(iso, zone);
87
+ if (node.title !== full)
88
+ node.title = full;
89
+ if (node.textContent !== words)
90
+ node.textContent = words;
91
+ });
25
92
  }
26
93
  /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
27
94
  export function whenHtml(iso, full, now = new Date()) {
28
95
  if (iso === null || iso === "")
29
96
  return "";
30
97
  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>`;
98
+ 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
99
  }
33
100
  /** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
34
101
  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;
@@ -11,7 +11,7 @@ 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 { ASKS, NEED_ASK, NEEDS, processNeedOf } from './needs-you.js';
14
+ import { ASKS, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, processNeedOf, resultHoldUpSentence } from './needs-you.js';
15
15
  import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
16
16
  export const WORK_INDEX_PAGE_LIMIT = 40;
17
17
  export const WORK_INDEX_MAX_LIMIT = 100;
@@ -369,7 +369,7 @@ export function workIndexPage(store, now, access, options = {}) {
369
369
  'running_count',running_count,'completed_count',completed_count,'queued',queued,'done_recently',done_recently)) FROM project_totals) row_json FROM totals
370
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,
371
371
  'state',state,'created_at',created_at,'family_updated',family_updated,'version_count',version_count,'earlier_active',earlier_active,
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),
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),'attempt_reason',(SELECT substr(reason,1,1000) FROM run WHERE task_ref IN (SELECT ref_id FROM members WHERE root_ref=page.root_ref) AND (finished_at IS NOT NULL OR outcome IS NOT NULL) AND role NOT IN ('planner','reviewer') ORDER BY id DESC LIMIT 1),
373
373
  'publication_url',(SELECT pr_url FROM publication WHERE run=page_result_id),
374
374
  'result_role',(SELECT role FROM run WHERE id=page_result_id),'live_role',(SELECT role FROM run WHERE id=page.live_run),
375
375
  'check_status',(SELECT status FROM run_check WHERE run=page_result_id),
@@ -379,7 +379,8 @@ export function workIndexPage(store, now, access, options = {}) {
379
379
  'proof_verdict',(SELECT COALESCE(machine_verdict,verdict) FROM proof_verdict WHERE run=page_result_id),
380
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,
381
381
  '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,
382
+ '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),
383
+ '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
384
  '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
385
  'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),
385
386
  '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`)
@@ -460,9 +461,10 @@ function itemOf(row, principal, probe) {
460
461
  running: ['Running now', '', null, ''],
461
462
  'waiting-decision': ['Needs your decision', 'An unanswered question is waiting.', n(row, 'question_id') ? 'answer-decision' : 'inspect-decisions', 'Answer question'],
462
463
  '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'],
464
+ // Its own reason replaces this below (resultHoldUpSentence).
465
+ 'result-needs-attention': ['Needs your decision', '', 'inspect-run', 'Inspect run'],
464
466
  '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
- failed: ['Needs your decision', 'The last attempt stopped; review its incident before retrying.', 'retry-task', 'Review and retry'],
467
+ failed: ['Needs your decision', 'The last attempt stopped.', 'retry-task', 'Review and retry'],
466
468
  'vanished-run': ['Needs your decision', 'An unfinished attempt has no current live claim.', 'reconcile-run', 'Check the unfinished attempt'],
467
469
  stopping: ['Needs your decision', 'Stopping. The work so far is kept.', 'inspect-stop', 'View stop details'],
468
470
  stopped: ['Needs your decision', 'Paused by a person. The work so far is kept.', 'resume-run', 'Review pause'],
@@ -502,6 +504,16 @@ function itemOf(row, principal, probe) {
502
504
  }
503
505
  if (code === 'terminal-dependency' && s(row, 'dependency_id') !== null)
504
506
  detail = `${s(row, 'dependency_id')} ${s(row, 'dependency_state') === 'cancelled' ? 'was cancelled' : 'failed'} before it finished.`;
507
+ // A failed task says the latest finished attempt across its whole family, whatever its outcome: its recorded reason, or that none was recorded.
508
+ if (code === 'failed')
509
+ detail = failedAttemptSentence(s(row, 'attempt_reason'));
510
+ // A saved result that can't be accepted yet says its own reason, never one line for every such row.
511
+ const planChanged = code === 'result-needs-attention' && n(row, 'plan_changed') === 1;
512
+ if (code === 'result-needs-attention')
513
+ detail = resultHoldUpSentence({ hold: s(row, 'hold_id') === null ? null : s(row, 'hold_reason') ?? '', planChanged,
514
+ unapproved: n(row, 'approved') !== 1, question: s(row, 'question_id') !== null, running: n(row, 'live_lease') === 1, unfinished: s(row, 'unfinished') !== null,
515
+ noCommit: !['built', 'no-change'].includes(s(row, 'result_outcome') ?? '') || (s(row, 'result_role') === 'builder' && n(row, 'head_ok') !== 1),
516
+ report: s(row, 'result_role') === 'scout' });
505
517
  if (code === 'result-needs-attention' && n(row, 'custody_unresolved'))
506
518
  detail = 'A process exit is not recorded. Open the result to check whether its work has stopped.';
507
519
  if (code === 'result-needs-attention' && ['built', 'no-change'].includes(s(row, 'result_outcome') ?? '')) {
@@ -570,6 +582,7 @@ function itemOf(row, principal, probe) {
570
582
  status: { token: `assignment-${assignmentState}`, label, detail, tone: workToneOf(shared.headline),
571
583
  action: primaryAction === null ? null : { label: actionLabel, kind: actionCode === 'open-result' ? 'open-result' : 'open-task' }, views, rank: n(row, 'rank') },
572
584
  ask: need ? ASKS[n(row, 'ask_rank')] ?? 'unblock' : null,
585
+ 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,
573
586
  primaryAction, completion: code === 'complete' ? { actor: String(row['checked_actor']), at: String(row['checked_at']), digest: String(row['checked_digest']) } : null,
574
587
  evidence: 'recorded' };
575
588
  }
@@ -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;