toolroll 0.9.19 → 0.9.21

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 (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/browser/workspace.css +1 -1
  3. package/dist/browser/workspace.js +22 -21
  4. package/dist/browser-workspace.d.ts +43 -1
  5. package/dist/chat-actions.d.ts +5 -0
  6. package/dist/chat-actions.js +57 -6
  7. package/dist/chat-delivery-state.d.ts +15 -0
  8. package/dist/chat-delivery-state.js +27 -2
  9. package/dist/chat-delivery.js +24 -4
  10. package/dist/chat-flow.d.ts +26 -3
  11. package/dist/chat-flow.js +217 -5
  12. package/dist/desktop-update.d.ts +1 -1
  13. package/dist/discord-chat.js +2 -2
  14. package/dist/evidence.d.ts +13 -1
  15. package/dist/evidence.js +10 -1
  16. package/dist/flow-engine.d.ts +7 -0
  17. package/dist/flow-engine.js +71 -27
  18. package/dist/flow-gallery-ui.d.ts +1 -1
  19. package/dist/flow-gallery-ui.js +8 -7
  20. package/dist/flow-gallery.d.ts +8 -0
  21. package/dist/flow-gallery.js +37 -3
  22. package/dist/flow-send.d.ts +80 -0
  23. package/dist/flow-send.js +249 -0
  24. package/dist/flow-share.js +7 -3
  25. package/dist/flow-steps.js +1 -1
  26. package/dist/flow-triggers.d.ts +7 -0
  27. package/dist/flow-triggers.js +17 -5
  28. package/dist/flows-cli.js +11 -3
  29. package/dist/flows-ui.js +22 -6
  30. package/dist/flows.d.ts +27 -1
  31. package/dist/flows.js +104 -18
  32. package/dist/invoke.d.ts +3 -0
  33. package/dist/invoke.js +1 -0
  34. package/dist/mate-tools.js +33 -10
  35. package/dist/operate.js +41 -0
  36. package/dist/plane-review.d.ts +15 -0
  37. package/dist/plane-review.js +62 -20
  38. package/dist/project-delete.js +4 -0
  39. package/dist/project-demo.d.ts +12 -0
  40. package/dist/project-demo.js +71 -0
  41. package/dist/provider.d.ts +14 -0
  42. package/dist/provider.js +8 -1
  43. package/dist/report-summary.d.ts +9 -0
  44. package/dist/report-summary.js +9 -0
  45. package/dist/result-shots.d.ts +2 -0
  46. package/dist/result-shots.js +24 -5
  47. package/dist/scout-net.d.ts +22 -0
  48. package/dist/scout-net.js +214 -0
  49. package/dist/scout-report.d.ts +160 -0
  50. package/dist/scout-report.js +171 -1
  51. package/dist/scout.d.ts +42 -0
  52. package/dist/scout.js +380 -69
  53. package/dist/serve.js +43 -5
  54. package/dist/slack-chat.js +3 -3
  55. package/dist/store.d.ts +53 -0
  56. package/dist/store.js +95 -4
  57. package/dist/teams-chat.js +1 -1
  58. package/dist/telegram-flow.d.ts +16 -2
  59. package/dist/telegram-flow.js +58 -0
  60. package/dist/telegram.js +46 -10
  61. package/package.json +1 -1
@@ -3,7 +3,8 @@
3
3
  * its promise, a small drawing of its zones, what it needs and "Use this". A template's page asks only what it
4
4
  * needs, previews in plain words what it will do and never do, and creates it — the same answers it previewed.
5
5
  */
6
- import { BLANK, GALLERY, GALLERY_GROUPS, galleryDiagram, OUTDATED_COMMANDS } from "./flow-gallery.js";
6
+ import { BLANK, GALLERY, GALLERY_GROUPS, galleryDiagram, OUTDATED_COMMANDS, SEND_RESULT } from "./flow-gallery.js";
7
+ import { choiceTargets } from "./flows.js";
7
8
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8
9
  export const GALLERY_CSS = `.gallery{max-width:1120px;min-width:0}.gallery h2{margin:28px 0 10px;font-size:.9375rem}.gallery h2:first-of-type{margin-top:8px}` +
9
10
  `.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}` +
@@ -16,7 +17,7 @@ export const GALLERY_CSS = `.gallery{max-width:1120px;min-width:0}.gallery h2{ma
16
17
  `.gallery .gallery-card a.button-link{align-self:flex-start;min-height:36px;background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
17
18
  `.gallery-blank{margin-top:20px}` +
18
19
  `.gallery-use{max-width:720px;min-width:0}.gallery-use .gallery-zones{height:88px;margin:4px 0 12px}.gallery-use form{display:grid;gap:12px;margin:0}.gallery-use label{display:grid;gap:4px;font-weight:500}` +
19
- `.gallery-use label small{font-weight:400;color:var(--so-muted)}` +
20
+ `.gallery-use label small{font-weight:400;color:var(--so-muted)}.gallery-use label.gallery-check{display:flex;align-items:flex-start;gap:8px;font-weight:500}.gallery-use label.gallery-check input{margin-top:3px;width:16px;height:16px;min-height:0;flex:none}.gallery-use label.gallery-check small{display:block}` +
20
21
  `.gallery-use input:not([type=hidden]),.gallery-use select{box-sizing:border-box;width:100%;max-width:100%;min-height:32px;padding:0 10px;border:1px solid var(--so-input-line);border-radius:8px;background:var(--so-paper);color:var(--so-ink);font:inherit}` +
21
22
  `.gallery-preview{display:grid;gap:8px;padding:16px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);min-width:0}.gallery-preview h2{margin:0;font-size:.9375rem}` +
22
23
  `.gallery-preview ul{margin:0;padding-left:18px;line-height:1.55}.gallery-preview p{margin:0;overflow-wrap:anywhere}.gallery-preview .gallery-never{font-weight:600}` +
@@ -34,11 +35,11 @@ export function zonesDiagram(definition, label) {
34
35
  const pad = 40;
35
36
  const box = `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`;
36
37
  const centre = (id) => { const at = definition.stages.find(one => one.id === id)?.zone; return at === undefined ? null : { x: at.x + at.w / 2, y: at.y + at.h / 2 }; };
37
- const lines = definition.stages.flatMap(stage => [stage.next, ...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? [])].map(to => {
38
+ const lines = definition.stages.flatMap(stage => [stage.next, ...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? []), ...choiceTargets(stage)].map(to => {
38
39
  const from = centre(stage.id), end = centre(to);
39
40
  return from === null || end === null ? "" : `<line x1="${from.x}" y1="${from.y}" x2="${end.x}" y2="${end.y}" vector-effect="non-scaling-stroke"/>`;
40
41
  })).join("");
41
- const rects = definition.stages.map(one => `<rect x="${one.zone.x}" y="${one.zone.y}" width="${one.zone.w}" height="${one.zone.h}" rx="36" data-person="${one.kind === "approval"}" vector-effect="non-scaling-stroke"/>`).join("");
42
+ const rects = definition.stages.map(one => `<rect x="${one.zone.x}" y="${one.zone.y}" width="${one.zone.w}" height="${one.zone.h}" rx="36" data-person="${one.kind === "approval" || one.kind === "choose"}" vector-effect="non-scaling-stroke"/>`).join("");
42
43
  return `<svg class="gallery-zones" viewBox="${box}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${e(label)}">${lines}${rects}</svg>`;
43
44
  }
44
45
  const needsList = (template) => template.needs.length === 0 ? `<ul class="gallery-needs"><li>Nothing to connect</li></ul>`
@@ -73,13 +74,13 @@ export function galleryUseHtml(input) {
73
74
  `<p class="gallery-never">${e(input.preview.built.never)}</p>` +
74
75
  (input.preview.startsFrom.length === 0 ? `<p class="meta">Cards start when you add them.</p>` : input.preview.startsFrom.map(one => `<p class="meta">Starts from: ${e(one)}</p>`).join("")) +
75
76
  `<details><summary>Every step</summary><ol>${input.preview.steps.map(one => `<li>${e(one.replace(/^\d+\.\s/, ""))}</li>`).join("")}</ol></details></section>`;
76
- const asks = template.asks.length > 0 || input.projects.length > 1;
77
77
  return `<section class="gallery-use" data-gallery-template="${e(template.id)}">` +
78
78
  (input.problem === null ? "" : `<p class="problem" role="alert">${e(input.problem)}</p>`) +
79
79
  `<p>${e(template.promise)}</p>${input.diagram === null ? "" : zonesDiagram(input.diagram, `Zones: ${input.diagram.stages.map(one => one.title).join(", ")}`)}${needsList(template)}` +
80
80
  `<form method="post" action="/flows/new/${e(template.id)}" data-gallery-use><input type="hidden" name="csrf" value="${e(input.csrf)}">` +
81
81
  `<input type="hidden" name="previewed" value="${e(input.preview?.digest ?? "")}">${project}${template.asks.map(field).join("")}` +
82
- `<label>Name<input name="name" value="${e(input.name)}" maxlength="80"></label>${preview}` +
83
- `<p class="gallery-actions">${input.preview === null ? "" : `<button type="submit" name="intent" value="create">Create flow</button>`}${asks || input.preview === null ? `<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button>` : ""}</p>` +
82
+ `<label>Name<input name="name" value="${e(input.name)}" maxlength="80"></label>` +
83
+ `<label class="gallery-check"><input type="checkbox" name="${SEND_RESULT.key}" value="yes"${input.answers[SEND_RESULT.key] === "yes" ? " checked" : ""} data-send-result><span>${e(SEND_RESULT.title)}<small>When a card finishes, what was done comes to you in your chat apps.</small></span></label>${preview}` +
84
+ `<p class="gallery-actions">${input.preview === null ? "" : `<button type="submit" name="intent" value="create">Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>` +
84
85
  `</form><p class="meta"><a href="/flows/new">All templates</a></p></section>`;
85
86
  }
@@ -62,6 +62,14 @@ export declare const galleryTemplateOf: (id: string) => GalleryTemplate | null;
62
62
  export type GalleryAnswers = Record<string, string>;
63
63
  /** Each question's default for a project: the branch pull requests go to, when that's set up. */
64
64
  export declare function galleryDefaults(store: Store, template: GalleryTemplate, repo: string | null): GalleryAnswers;
65
+ /** The optional last step every template offers, and the answer that turns it on. */
66
+ export declare const SEND_RESULT: {
67
+ readonly key: "send-result";
68
+ readonly title: "Send me the result";
69
+ readonly does: "When a card finishes, sends you what was done (its summary, links and screenshots) in your chat apps.";
70
+ };
71
+ /** A flow with "Send me the result" before its end: every path into its main Done zone passes through it first. */
72
+ export declare function withSendResult(definition: FlowDefinition): FlowDefinition;
65
73
  /** The answers, checked in plain words, with what they fill in. Throws. */
66
74
  export declare function checkAnswers(template: GalleryTemplate, given: GalleryAnswers): GalleryAnswers;
67
75
  export type GalleryBuilt = {
@@ -8,6 +8,9 @@
8
8
  * where an answer goes and `{{script.<name>}}` where its script's name goes — so it can be written out as a
9
9
  * shared flow file later. Every one is built only from existing zone and trigger kinds, and none merges:
10
10
  * a pull request it opens waits for a person.
11
+ *
12
+ * Every template can end with "Send me the result" (a "Send to me" zone before its end), off unless asked for
13
+ * (`send-result: yes`): what the flow did reaches the person in their chat apps.
11
14
  */
12
15
  import { createHash } from "node:crypto";
13
16
  import { scanForSecrets } from "./evidence.js";
@@ -15,7 +18,7 @@ import { flowDefinitionOf } from "./flow-engine.js";
15
18
  import { saveScript, validateScript } from "./flow-scripts.js";
16
19
  import { isToolrollRepo, planeReviewSteps } from "./flow-starters.js";
17
20
  import { addFlowTriggerTo, describeTrigger, githubRepoOf, scheduleFromWords, validateTriggerConfig } from "./flow-triggers.js";
18
- import { clockTime, flowFromSteps, flowTerms, FLOW_TEMPLATES, ISSUE_LABEL } from "./flows.js";
21
+ import { choiceTargets, clockTime, flowFromSteps, flowTerms, FLOW_TEMPLATES, ISSUE_LABEL } from "./flows.js";
19
22
  import { publishingOf } from "./pull-request-flow.js";
20
23
  export const GALLERY_GROUPS = [
21
24
  { id: "ship", label: "Ship code" },
@@ -291,9 +294,38 @@ const oneLine = (value, cap, what) => {
291
294
  throw new Error(`That ${what} looks like it holds a key or password. Keep secrets out of it.`);
292
295
  return said;
293
296
  };
297
+ /** The optional last step every template offers, and the answer that turns it on. */
298
+ export const SEND_RESULT = { key: "send-result", title: "Send me the result", does: "When a card finishes, sends you what was done (its summary, links and screenshots) in your chat apps." };
299
+ /** A flow with "Send me the result" before its end: every path into its main Done zone passes through it first. */
300
+ export function withSendResult(definition) {
301
+ // Its main end: Done, else an end that isn't a filtered-out one (drawn in rose), else any end.
302
+ const ends = definition.stages.filter(one => one.kind === "done");
303
+ const end = ends.find(one => one.id === "done") ?? ends.find(one => one.zone.color !== "rose") ?? ends[0];
304
+ if (end === undefined || definition.stages.some(one => one.kind === "send" && one.next === end.id))
305
+ return definition;
306
+ let id = "send-result";
307
+ for (let n = 2; definition.stages.some(one => one.id === id); n++)
308
+ id = `send-result-${n}`;
309
+ const into = (to) => to === end.id ? id : to;
310
+ const stages = definition.stages.map(one => ({
311
+ ...one, next: one.next === null ? null : into(one.next), onFail: one.onFail === null ? null : into(one.onFail),
312
+ ...(one.limit === undefined ? {} : { limit: { ...one.limit, to: one.limit.to === null ? null : into(one.limit.to) } }),
313
+ sort: one.sort === null ? null : { ...one.sort, answers: one.sort.answers.map(answer => ({ ...answer, to: into(answer.to) })) },
314
+ ...(one.routes === undefined ? {} : { routes: one.routes.map(route => ({ ...route, to: into(route.to) })) }),
315
+ ...(one.options === undefined || choiceTargets(one).length === 0 ? {} : { options: one.options.map(option => ({ ...option, to: into(option.to) })) }),
316
+ }));
317
+ // Where the end was, and the end moves along to make room.
318
+ const send = { id, title: SEND_RESULT.title, kind: "send", zone: { ...end.zone, color: "green", h: 220 }, instructions: null, planning: null, approver: null,
319
+ message: null, close: null, script: null, sort: null, next: end.id, onFail: null };
320
+ const moved = stages.map(one => one.id === end.id ? { ...one, zone: { ...one.zone, x: one.zone.x + Math.max(300, one.zone.w + 40) } } : one);
321
+ return { ...definition, ...(definition.start === end.id ? { start: id } : {}), stages: [...moved.filter(one => one.id !== end.id), send, moved.find(one => one.id === end.id)] };
322
+ }
294
323
  /** The answers, checked in plain words, with what they fill in. Throws. */
295
324
  export function checkAnswers(template, given) {
296
325
  const answers = {};
326
+ // Off unless asked for; only said when on, so a template's answers read as before.
327
+ if (given[SEND_RESULT.key] === "yes")
328
+ answers[SEND_RESULT.key] = "yes";
297
329
  for (const ask of template.asks) {
298
330
  const value = String(given[ask.key] ?? ask.default).trim();
299
331
  if (ask.key === "label")
@@ -386,10 +418,12 @@ export function buildFromGallery(store, template, repo, given) {
386
418
  return { name, about: draft.about, body: draft.body, timeoutMinutes: draft.timeoutMinutes, existing };
387
419
  });
388
420
  const github = githubRepoOf(repo);
389
- const definition = template.definition !== undefined ? structuredClone(template.definition)
421
+ const drawn = template.definition !== undefined ? structuredClone(template.definition)
390
422
  : flowFromSteps(fill(template.ownSteps !== undefined && isToolrollRepo(repo) ? template.ownSteps : template.steps, answers, names, github), null);
423
+ const sends = answers[SEND_RESULT.key] === "yes";
424
+ const definition = sends ? withSendResult(drawn) : drawn;
391
425
  return { template, repo, answers, definition, triggers: fill(template.triggers, answers, names, github), scripts,
392
- does: fill(template.does, answers, names, github), never: template.never };
426
+ does: [...fill(template.does, answers, names, github), ...(sends && definition !== drawn ? [SEND_RESULT.does] : [])], never: template.never };
393
427
  }
394
428
  /** A template's zones as its defaults draw them, for the gallery's small drawing; no project needed. */
395
429
  export function galleryDiagram(template) {
@@ -0,0 +1,80 @@
1
+ import { type FlowDecision } from "./flow-engine.js";
2
+ import { type FlowDefinition, type FlowStage } from "./flows.js";
3
+ import { type FlowCardRow, type FlowRow, type Store } from "./store.js";
4
+ /** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
5
+ export declare const FLOW_SEND_KEY: RegExp;
6
+ /** The notice a "Person chooses" visit sends: `flow-choose:<card>:<entry>`, a "flow-decision" like an approval's. */
7
+ export declare const FLOW_CHOOSE_KEY: RegExp;
8
+ /** What the person is asked under the buttons. */
9
+ export declare const REPLY_ASK = "Or reply with what you'd change.";
10
+ export type FlowSendLink = {
11
+ label: string;
12
+ path: string;
13
+ } | {
14
+ label: string;
15
+ url: string;
16
+ };
17
+ export type FlowSendContent = {
18
+ /** The card's title and the step it comes from. */
19
+ title: string;
20
+ /** The step it comes from, when known. */
21
+ from?: string;
22
+ summary: string;
23
+ links: FlowSendLink[];
24
+ /** The build's result whose screenshots go with it, when it saved any. */
25
+ shots: {
26
+ taskId: string;
27
+ run: number;
28
+ } | null;
29
+ /** choose: the options as they were offered, and whether a reply is taken. */
30
+ options?: {
31
+ choice: number;
32
+ label: string;
33
+ }[];
34
+ reply?: boolean;
35
+ };
36
+ /** Who a card's "Send to me" and "Person chooses" zones are for: its owner, else the flow's. */
37
+ export declare function flowPersonOf(card: Pick<FlowCardRow, "owner">, flow: Pick<FlowRow, "owner">): string;
38
+ /** What a visit sends its person, worked out from the card as it is now. */
39
+ export declare function flowSendContent(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, evidenceRoot?: string): FlowSendContent;
40
+ /** A kept content, read back; null when it can't be. */
41
+ export declare function readFlowSend(json: string): FlowSendContent | null;
42
+ /** The words of a sent visit: its title, summary and pull request (the one link that isn't a page of Toolroll's). */
43
+ export declare function flowSendText(content: FlowSendContent): string;
44
+ /** The links a sent visit's message carries as buttons: Toolroll's own pages, the first one first. */
45
+ export declare function flowSendPaths(content: FlowSendContent): {
46
+ label: string;
47
+ path: string;
48
+ }[];
49
+ /** A "Send to me" visit: what the step before produced, to the person once; the card moves on after. */
50
+ export declare function sendStep(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, evidenceRoot?: string): void;
51
+ /** A "Person chooses" visit: the same content with the zone's options, to the person once; the card waits for their choice. */
52
+ export declare function chooseStep(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, evidenceRoot?: string): void;
53
+ /** The words above a choice's buttons: what was done, then what to do. */
54
+ export declare function flowChooseBody(content: FlowSendContent): string;
55
+ export type FlowChoiceVisit = {
56
+ card: FlowCardRow;
57
+ flow: FlowRow;
58
+ stage: FlowStage;
59
+ definition: FlowDefinition;
60
+ person: string;
61
+ };
62
+ /** The card, if it is still at that visit, waiting for its person's choice. */
63
+ export declare function flowChoiceAt(store: Store, cardId: number, entry: number): FlowChoiceVisit | null;
64
+ /**
65
+ * A person's choice on a card waiting at a "Person chooses" zone: an option (by its place, and the words and zone they saw), or a
66
+ * reply (`choice` null) that becomes the note for where replies go. `where` names the place it was made, for the card's
67
+ * history and the ledger: "Telegram", "Slack", "the console".
68
+ */
69
+ export declare function chooseFlowCard(store: Store, input: {
70
+ card: number;
71
+ entry?: number;
72
+ choice: number | null;
73
+ label?: string;
74
+ to?: string;
75
+ note: string | null;
76
+ actor: string;
77
+ where: string;
78
+ repos: readonly string[];
79
+ evidenceRoot?: string;
80
+ }, now: Date): FlowDecision;
@@ -0,0 +1,249 @@
1
+ /**
2
+ * "Send to me" and "Person chooses" zones (flows.ts): what a card's step before produced, sent to the card's person.
3
+ *
4
+ * - The person is the card's owner, else the flow's.
5
+ * - What is sent: the card's title and the step it comes from, a summary (the build's own report, a research report,
6
+ * or what the step before wrote), its links (the result, its pull request, the report, the card) and the build's
7
+ * screenshots. It is worked out once per visit and kept (flow_send), so every chat app and the card show the same.
8
+ * - It goes as one notification to that person, which each chat app they paired delivers (Telegram: the screenshots as
9
+ * one album; Slack and Discord: uploads in its thread; Teams: a link to them), and the card shows it in the console.
10
+ * - A choose visit carries the zone's options as buttons (telegram-flow.ts, chat-flow.ts, and the card). Each leads to
11
+ * a zone, or ends the card as Ignored; a reply instead is the {{note}} for where replies go. Every choice is made
12
+ * here (chooseFlowCard), for exactly the visit the person saw, by that person alone, and ledgered with where.
13
+ *
14
+ * Deterministic and model-free, in the worker's flow pass.
15
+ */
16
+ import { assignmentOf } from "./assignment.js";
17
+ import { withActor } from "./actor.js";
18
+ import { flowCardHref, flowDefinitionOf, revisionWithNote } from "./flow-engine.js";
19
+ import { notifyPeople } from "./flow-people.js";
20
+ import { FLOW_END, replyTarget } from "./flows.js";
21
+ import { FLOW_SHOTS_KIND } from "./store.js";
22
+ /** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
23
+ export const FLOW_SEND_KEY = /^flow-send:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/;
24
+ /** The notice a "Person chooses" visit sends: `flow-choose:<card>:<entry>`, a "flow-decision" like an approval's. */
25
+ export const FLOW_CHOOSE_KEY = /^flow-choose:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/;
26
+ /** What the person is asked under the buttons. */
27
+ export const REPLY_ASK = "Or reply with what you'd change.";
28
+ const SUMMARY_CHARS = 1500;
29
+ const STEP_CHARS = 500;
30
+ /** Who a card's "Send to me" and "Person chooses" zones are for: its owner, else the flow's. */
31
+ export function flowPersonOf(card, flow) {
32
+ return card.owner ?? flow.owner;
33
+ }
34
+ const cut = (text, cap) => text.length <= cap ? text : `${text.slice(0, cap - 1).trimEnd()}…`;
35
+ const plain = (text) => text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").replace(/\n{3,}/g, "\n\n").trim();
36
+ /** The zone the card came from into this visit: its latest move, else a zone that leads here. */
37
+ function stepBefore(store, definition, card) {
38
+ const events = store.flowEvents(card.id);
39
+ const last = events[events.length - 1];
40
+ const from = last !== undefined && last.toStage === card.stage && last.fromStage !== card.stage ? last.fromStage : null;
41
+ return definition.stages.find(one => one.id === from)
42
+ ?? definition.stages.find(one => one.next === card.stage || one.onFail === card.stage || (one.options ?? []).some(option => option.to === card.stage)) ?? null;
43
+ }
44
+ /** A result's facts for the person, read as the operator would: its report, its pull request and screenshots. */
45
+ function resultOf(store, taskId, now, evidenceRoot) {
46
+ const repo = store.lookupRef(taskId)?.repo ?? null;
47
+ if (repo === null)
48
+ return null;
49
+ const snapshot = assignmentOf(store, taskId, now, { principal: "operator", repos: [repo] }, evidenceRoot);
50
+ const receipt = snapshot?.receipt ?? null;
51
+ const url = snapshot?.publication?.prUrl ?? null;
52
+ return {
53
+ taskId: receipt?.taskId ?? snapshot?.activeTaskId ?? taskId, report: receipt?.agentReport ?? null,
54
+ pullRequest: url !== null && /^https:\/\/[^\s<>"]+$/.test(url) ? url : null,
55
+ shots: receipt !== null && store.artifactsFor(receipt.runId).some(one => one.kind === "screenshot") ? { taskId: receipt.taskId, run: receipt.runId } : null,
56
+ };
57
+ }
58
+ /** What a visit sends its person, worked out from the card as it is now. */
59
+ export function flowSendContent(store, flow, definition, stage, card, now, evidenceRoot) {
60
+ const before = stepBefore(store, definition, card);
61
+ const output = before === null ? undefined : card.outputs[before.id]?.trim() || undefined;
62
+ // The task the step before produced (a build or research zone hands it over), and the card's main build.
63
+ const handed = card.task !== null && before !== null && (before.kind === "task" || before.kind === "report") ? card.task : null;
64
+ const report = handed !== null && before?.kind === "report" ? handed : null;
65
+ const build = before?.kind === "task" && handed !== null ? handed : card.primaryTask;
66
+ const result = build === null ? null : resultOf(store, build, now, evidenceRoot);
67
+ const lines = [];
68
+ if (before?.kind === "report" && output !== undefined)
69
+ lines.push(cut(output, SUMMARY_CHARS));
70
+ else if (result?.report != null && result.report.trim() !== "") {
71
+ lines.push(cut(result.report.trim(), SUMMARY_CHARS));
72
+ if (output !== undefined && before?.kind !== "task")
73
+ lines.push(`${before.title}: ${cut(output, STEP_CHARS)}`);
74
+ }
75
+ else if (output !== undefined)
76
+ lines.push(cut(output, SUMMARY_CHARS));
77
+ else {
78
+ // Nothing from the step before: the latest thing any zone wrote, else the card's own details.
79
+ const latest = [...definition.stages].reverse().find(one => one.id !== stage.id && card.outputs[one.id]?.trim());
80
+ lines.push(latest !== undefined ? `${latest.title}: ${cut(card.outputs[latest.id].trim(), SUMMARY_CHARS)}` : card.description === null ? "Nothing was written down for this step." : cut(card.description, SUMMARY_CHARS));
81
+ }
82
+ const links = [];
83
+ if (result !== null)
84
+ links.push({ label: "Result", path: `/t/${encodeURIComponent(result.taskId)}` });
85
+ if (result?.pullRequest != null)
86
+ links.push({ label: "Pull request", url: result.pullRequest });
87
+ if (report !== null)
88
+ links.push({ label: "Report", path: `/t/${encodeURIComponent(report)}` });
89
+ links.push({ label: "Card", path: flowCardHref(flow.id, card.id) });
90
+ const options = stage.kind === "choose" ? (stage.options ?? []).map((one, choice) => ({ choice, label: one.label })) : undefined;
91
+ return {
92
+ title: cut(plain(before === null ? card.title : `${card.title} · ${before.title}`), 200), ...(before === null ? {} : { from: before.title }),
93
+ summary: plain(lines.join("\n\n")), links, shots: result?.shots ?? null,
94
+ ...(options === undefined ? {} : { options, reply: replyTarget(stage) !== null }),
95
+ };
96
+ }
97
+ /** A kept content, read back; null when it can't be. */
98
+ export function readFlowSend(json) {
99
+ try {
100
+ const raw = JSON.parse(json);
101
+ return typeof raw.title === "string" && typeof raw.summary === "string" && Array.isArray(raw.links) ? raw : null;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ /** The words of a sent visit: its title, summary and pull request (the one link that isn't a page of Toolroll's). */
108
+ export function flowSendText(content) {
109
+ const pull = content.links.find((one) => "url" in one);
110
+ return [content.title, content.summary, ...(pull === undefined ? [] : [`${pull.label}: ${pull.url}`])].filter(one => one !== "").join("\n\n");
111
+ }
112
+ /** The links a sent visit's message carries as buttons: Toolroll's own pages, the first one first. */
113
+ export function flowSendPaths(content) {
114
+ return content.links.filter((one) => "path" in one);
115
+ }
116
+ /** A visit's content, kept the first time; later passes (and every chat app) read the same one. */
117
+ function keep(store, flow, definition, stage, card, now, evidenceRoot) {
118
+ const person = flowPersonOf(card, flow);
119
+ const kept = store.flowSend(card.id, card.entry);
120
+ const read = kept === null ? null : readFlowSend(kept.contentJson);
121
+ if (kept !== null && read !== null)
122
+ return { content: read, person: kept.person, fresh: false };
123
+ const content = flowSendContent(store, flow, definition, stage, card, now, evidenceRoot);
124
+ const fresh = store.recordFlowSend({ card: card.id, entry: card.entry, stage: stage.id, person, contentJson: JSON.stringify(content) }, now);
125
+ return { content, person, fresh };
126
+ }
127
+ /** One notice to the person, and its screenshots after it. Never as anyone's own act: they asked for it in the flow. */
128
+ function enqueue(store, flow, card, person, content, notice, now) {
129
+ if (!store.accountCanAccess(person, flow.repo))
130
+ return;
131
+ withActor(null, () => {
132
+ store.enqueueNotification({ dedupeKey: notice.key, kind: notice.kind, recipient: person, ...(notice.attention ? { pushClass: "attention" } : {}),
133
+ subject: notice.subject.slice(0, 200), body: notice.body.slice(0, 4000), link: flowSendPaths(content)[0]?.path ?? flowCardHref(flow.id, card.id), source: { project: flow.repo } }, now);
134
+ if (content.shots !== null && store.lookupRef(content.shots.taskId)?.repo != null && store.accountCanAccess(person, store.lookupRef(content.shots.taskId).repo)) {
135
+ store.enqueueNotification({ dedupeKey: `flow-shots:${card.id}:${card.entry}`, kind: FLOW_SHOTS_KIND, recipient: person, subject: "Screenshots with this result", body: "",
136
+ link: `/t/${encodeURIComponent(content.shots.taskId)}`, source: { run: content.shots.run } }, now);
137
+ }
138
+ });
139
+ }
140
+ /** A "Send to me" visit: what the step before produced, to the person once; the card moves on after. */
141
+ export function sendStep(store, flow, definition, stage, card, now, evidenceRoot) {
142
+ const { content, person, fresh } = keep(store, flow, definition, stage, card, now, evidenceRoot);
143
+ if (!fresh)
144
+ return;
145
+ store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: flowSendText(content) } }, now);
146
+ enqueue(store, flow, card, person, content, { key: `flow-send:${card.id}:${card.entry}`, kind: "flow-card", attention: false,
147
+ subject: `${flow.name}: ${content.title}`, body: [content.summary, ...content.links.filter((one) => "url" in one).map(one => `${one.label}: ${one.url}`)].join("\n\n") }, now);
148
+ }
149
+ /** A "Person chooses" visit: the same content with the zone's options, to the person once; the card waits for their choice. */
150
+ export function chooseStep(store, flow, definition, stage, card, now, evidenceRoot) {
151
+ const { content, person, fresh } = keep(store, flow, definition, stage, card, now, evidenceRoot);
152
+ const waiting = `Waiting for ${person} to choose`;
153
+ if (card.waiting !== waiting)
154
+ store.updateFlowCard(card.id, { waiting }, now);
155
+ if (!fresh)
156
+ return;
157
+ enqueue(store, flow, card, person, content, { key: `flow-choose:${card.id}:${card.entry}`, kind: "flow-decision", attention: true,
158
+ subject: `${flow.name}: ${content.title}`, body: flowChooseBody(content) }, now);
159
+ }
160
+ /** The words above a choice's buttons: what was done, then what to do. */
161
+ export function flowChooseBody(content) {
162
+ const pull = content.links.find((one) => "url" in one);
163
+ return [content.summary, ...(pull === undefined ? [] : [`${pull.label}: ${pull.url}`]), content.reply === true ? `Choose one. ${REPLY_ASK}` : "Choose one."].join("\n\n");
164
+ }
165
+ /** The card, if it is still at that visit, waiting for its person's choice. */
166
+ export function flowChoiceAt(store, cardId, entry) {
167
+ const card = store.getFlowCard(cardId);
168
+ const flow = card === null ? null : store.getFlow(card.flow);
169
+ const definition = flow === null ? null : flowDefinitionOf(flow);
170
+ if (card === null || flow === null || definition === null || card.state !== "active" || card.entry !== entry)
171
+ return null;
172
+ const stage = definition.stages.find(one => one.id === card.stage);
173
+ if (stage === undefined || stage.kind !== "choose")
174
+ return null;
175
+ return { card, flow, stage, definition, person: flowPersonOf(card, flow) };
176
+ }
177
+ const via = (where) => where === "" ? "" : ` in ${where}`;
178
+ /**
179
+ * A person's choice on a card waiting at a "Person chooses" zone: an option (by its place, and the words and zone they saw), or a
180
+ * reply (`choice` null) that becomes the note for where replies go. `where` names the place it was made, for the card's
181
+ * history and the ledger: "Telegram", "Slack", "the console".
182
+ */
183
+ export function chooseFlowCard(store, input, now) {
184
+ const card = store.getFlowCard(input.card);
185
+ const flow = card === null ? null : store.getFlow(card.flow);
186
+ if (card === null || flow === null || card.state !== "active" || !input.repos.includes(flow.repo))
187
+ return { ok: false, message: "That card is no longer waiting." };
188
+ const visit = flowChoiceAt(store, card.id, input.entry ?? card.entry);
189
+ if (visit === null)
190
+ return { ok: false, message: "That card has moved on since; nothing was changed." };
191
+ const { stage, definition, person } = visit;
192
+ if (person !== input.actor)
193
+ return { ok: false, message: `Only ${person} chooses here.` };
194
+ const titleOf = (id) => definition.stages.find(one => one.id === id)?.title ?? id;
195
+ const ledger = (outcome, said) => store.recordAction({ at: now.toISOString(), actor: input.actor, repo: flow.repo, taskId: card.primaryTask, runId: null,
196
+ action: "flow choice", outcome, source: "request", detail: `${flow.name} · card ${card.id} · ${stage.title}: ${said} · via ${input.where || "Toolroll"}` });
197
+ if (input.choice !== null) {
198
+ const option = (stage.options ?? [])[input.choice];
199
+ if (option === undefined || (input.label !== undefined && option.label !== input.label) || (input.to !== undefined && option.to !== input.to))
200
+ return { ok: false, message: "Those options changed since; nothing was changed." };
201
+ if (option.to === FLOW_END) {
202
+ const closed = store.transact(() => {
203
+ if (!store.moveFlowCard(card.id, { to: card.stage, outcome: "cancelled", actor: input.actor, historyNote: `Ignored: chose “${option.label}”${via(input.where)}`, expectEntry: card.entry }, now))
204
+ return false;
205
+ store.updateFlowCard(card.id, { state: "cancelled", waiting: "Ignored" }, now);
206
+ store.retireFlowChoices(card.id, card.entry, now);
207
+ ledger("ignored", `“${option.label}”`);
208
+ return true;
209
+ });
210
+ return closed ? { ok: true, said: "Ignored. The card is closed." } : { ok: false, message: "That card has moved on since; nothing was changed." };
211
+ }
212
+ const moved = store.transact(() => {
213
+ if (!store.moveFlowCard(card.id, { to: option.to, outcome: "moved", actor: input.actor, historyNote: `Chose “${option.label}”${via(input.where)}`, expectEntry: card.entry }, now))
214
+ return false;
215
+ store.retireFlowChoices(card.id, card.entry, now);
216
+ ledger("chosen", `“${option.label}”`);
217
+ return true;
218
+ });
219
+ if (!moved)
220
+ return { ok: false, message: "That card has moved on since; nothing was changed." };
221
+ if (card.owner !== null && card.owner !== input.actor)
222
+ notifyPeople(store, card, [card.owner], input.actor, { key: `chose:${card.entry}`, subject: `${input.actor} chose “${option.label}” for “${card.title}”`, body: `It moves to ${titleOf(option.to)}.` }, now);
223
+ const title = titleOf(option.to);
224
+ return { ok: true, said: `${option.label}. Moved to ${title}${/[.?!]$/.test(title) ? "" : "."}` };
225
+ }
226
+ const note = (input.note ?? "").trim();
227
+ const target = replyTarget(stage);
228
+ if (target === null)
229
+ return { ok: false, message: "Choose one of the options; this step doesn't take a reply." };
230
+ if (input.to !== undefined && target !== input.to)
231
+ return { ok: false, message: "Where replies go changed since; nothing was changed." };
232
+ if (note === "")
233
+ return { ok: false, message: "Say what you'd change." };
234
+ if (note.length > 2000)
235
+ return { ok: false, message: "Keep it under 2,000 characters." };
236
+ const revision = revisionWithNote(store, card, definition.stages.find(one => one.id === target), note, input.actor, input.repos, input.evidenceRoot, now);
237
+ const moved = store.transact(() => {
238
+ if (!store.moveFlowCard(card.id, { to: target, outcome: "sent-back", actor: input.actor, note, ...(revision === null ? {} : { task: revision }), expectEntry: card.entry }, now))
239
+ return false;
240
+ store.retireFlowChoices(card.id, card.entry, now);
241
+ ledger("replied", `a reply to ${titleOf(target)}`);
242
+ return true;
243
+ });
244
+ if (!moved)
245
+ return { ok: false, message: "That card has moved on since; nothing was changed." };
246
+ if (card.owner !== null && card.owner !== input.actor)
247
+ notifyPeople(store, card, [card.owner], input.actor, { key: `replied:${card.entry}`, subject: `${input.actor} replied on “${card.title}”`, body: `Sent to ${titleOf(target)}:\n\n${note}`, attention: true }, now);
248
+ return { ok: true, said: revision === null ? `Sent to ${titleOf(target)} with your note.` : `Sent to ${titleOf(target)}: a revision was made with your note.` };
249
+ }
@@ -81,12 +81,15 @@ function stepOf(stage, ask) {
81
81
  step["teammate"] = stage.teammate;
82
82
  if (stage.reply === true)
83
83
  step["reply"] = true;
84
+ // A choice's buttons; "end" ignores the card. A build in another project names a path on this computer, so it stays here.
85
+ if (stage.options !== undefined)
86
+ step["options"] = stage.options.map(one => ({ label: one.label, goesTo: one.to }));
84
87
  if (stage.limit !== undefined)
85
88
  Object.assign(step, { remindAfter: words(stage.limit.minutes), ...(stage.limit.to === null ? {} : { thenMoveTo: stage.limit.to }) });
86
89
  if (stage.next !== null)
87
90
  step["next"] = stage.next;
88
91
  if (stage.onFail !== null)
89
- step[stage.kind === "sort" ? "ifNotSure" : stage.kind === "wait" ? "ifNoReply" : "ifFails"] = stage.onFail;
92
+ step[stage.kind === "sort" ? "ifNotSure" : stage.kind === "wait" ? "ifNoReply" : stage.kind === "choose" ? "ifReplied" : "ifFails"] = stage.onFail;
90
93
  step["at"] = { x: stage.zone.x, y: stage.zone.y, w: stage.zone.w, h: stage.zone.h, color: stage.zone.color };
91
94
  return step;
92
95
  }
@@ -181,7 +184,7 @@ function scrubber(values, names) {
181
184
  };
182
185
  }
183
186
  /** Structural fields: ids and references, never prose. Scrubbing them would break the flow's paths. */
184
- const STRUCTURAL = new Set(["format", "id", "kind", "goesTo", "next", "ifFails", "ifNotSure", "ifNoReply", "thenMoveTo", "zone", "script", "language", "planning", "runIn", "waitFor", "method", "watch", "from", "delivery", "color", "merge"]);
187
+ const STRUCTURAL = new Set(["format", "id", "kind", "goesTo", "next", "ifFails", "ifNotSure", "ifNoReply", "ifReplied", "thenMoveTo", "zone", "script", "language", "planning", "runIn", "waitFor", "method", "watch", "from", "delivery", "color", "merge"]);
185
188
  function scrubDeep(value, scrub, key, inScripts) {
186
189
  if (typeof value === "string")
187
190
  return key !== null && (STRUCTURAL.has(key) || (inScripts && key === "name")) ? value : scrub(value);
@@ -272,7 +275,7 @@ function stageInputOf(step, index, find) {
272
275
  const toOwner = kind === "approval" && typeof decider === "string" && decider.trim().toLowerCase() === "owner";
273
276
  const anyone = decider === undefined || decider === null || (typeof decider === "string" && /^(anyone|any approver|)$/i.test(decider.trim()));
274
277
  const at = object("at") ?? {};
275
- const failKey = step["ifNotSure"] ?? step["ifNoReply"] ?? step["ifFails"];
278
+ const failKey = step["ifNotSure"] ?? step["ifNoReply"] ?? step["ifReplied"] ?? step["ifFails"];
276
279
  const minutes = (value) => value === undefined ? undefined : durationMinutes(value) ?? refuse(`Zone ${title}: say a time like "3 days" (up to 30 days).`);
277
280
  const sureAt = step["sureAt"];
278
281
  return {
@@ -290,6 +293,7 @@ function stageInputOf(step, index, find) {
290
293
  ...(kind === "tool" ? { tool: { server: step["server"], name: step["tool"], args: typeof step["args"] === "object" && step["args"] !== null ? JSON.stringify(step["args"]) : step["args"] } } : {}),
291
294
  ...(kind === "wait" ? { wait: step["waitFor"] === "hours" ? { for: "hours", from: step["from"], to: step["until"], timeZone: step["timeZone"] } : { for: step["waitFor"] ?? "reply", minutes: minutes(step["wait"] ?? "3 days") } } : {}),
292
295
  merge: step["merge"], teammate: step["teammate"], reply: step["reply"],
296
+ ...(kind === "choose" ? { options: (list("options") ?? []).map(one => ({ label: one["label"], to: one["goesTo"] === "end" ? "end" : find(one["goesTo"], title) })) } : {}),
293
297
  ...(step["remindAfter"] === undefined ? {} : { limit: { minutes: minutes(step["remindAfter"]), to: find(step["thenMoveTo"], title) } }),
294
298
  next: find(step["next"], title), onFail: find(failKey, title),
295
299
  };
@@ -350,7 +350,7 @@ async function runCheck(store, flow, stage, script, card, now, io) {
350
350
  return { state: "failed", said: `The project's setup failed before ${script.name} ran (exit ${prepared.code}).\n${tail(`${prepared.stdout}\n${prepared.stderr}`)}`, log, exitCode: prepared.code };
351
351
  }
352
352
  const definition = flowDefinitionOf(flow);
353
- const titleOf = (id) => definition?.stages.find(one => one.id === id)?.title ?? id;
353
+ const titleOf = (id) => definition?.stages.find(one => one.id === id.split(".")[0])?.title ?? id;
354
354
  // The card as data: never part of a command.
355
355
  const input = {
356
356
  card: { id: card.id, title: card.title, description: card.description, email: cardEmailOf(card) || null, note: card.note, owner: card.owner, source: card.source === null ? null : { kind: card.source.kind, label: card.source.label, url: card.source.url } },
@@ -199,6 +199,13 @@ export declare function firePlaneReview(store: Store, trigger: FlowTriggerRow, c
199
199
  joined: number;
200
200
  said: string;
201
201
  }>;
202
+ /** Where a person sees everything that waits on them. */
203
+ export declare const NEEDS_YOU_HREF = "/work?view=needs-you";
204
+ /** A plane review whose summary mentions long waits on a person links to Needs you. */
205
+ export declare function planeStatusLink(config: TriggerConfig | null, status: string | null): {
206
+ label: string;
207
+ href: string;
208
+ } | null;
202
209
  /** v96: a schedule (a teammate's routine) makes its card now, once per press, and keeps its own times. */
203
210
  export declare function runScheduleNow(store: Store, trigger: FlowTriggerRow, actor: string, now: Date): {
204
211
  ok: true;
@@ -16,7 +16,9 @@
16
16
  * - plane-review — every day at a set time (07:30 in the computer's time zone unless it says),
17
17
  * the plane's last 24 hours read straight from the store (plane-review.ts): one card
18
18
  * per distinct problem worth fixing, and the same problem on a later day joins its
19
- * card while that card is still open. A clean day makes nothing. Its row is kept as a
19
+ * card while that card is still open. A clean day makes nothing. Waits only a person
20
+ * can end make no card; ones over three days are mentioned once in its summary line,
21
+ * linked to Needs you. Its row is kept as a
20
22
  * 'schedule' (the table's kinds are fixed); its settings say what it is.
21
23
  *
22
24
  * GitHub and Linear are checked by the worker's pass — `gh` and one HTTPS
@@ -615,10 +617,14 @@ export async function firePlaneReview(store, trigger, config, now, scheduled) {
615
617
  if (flow === null || flow.state !== "active")
616
618
  return done("The flow isn't active.");
617
619
  // Loaded when a review first runs: the reader reaches the integrations and task status, which reach back to these triggers.
618
- const { problemCard, problemText, reviewPlane } = await import("./plane-review.js");
620
+ const { longPersonWaits, longWaitWords, problemCard, problemText, reviewPlane } = await import("./plane-review.js");
619
621
  // Only the projects the flow's owner may see: a flow's cards are read by everyone on its project.
620
622
  const owner = store.accountOf(flow.owner);
621
- const problems = reviewPlane(store, now, repo => owner === null || (repo !== null && store.accountCanAccess(flow.owner, repo)) || (repo === null && owner.projects === null));
623
+ const canSee = (repo) => owner === null || (repo !== null && store.accountCanAccess(flow.owner, repo)) || (repo === null && owner.projects === null);
624
+ const problems = reviewPlane(store, now, canSee);
625
+ // Long waits on a person are said here once, linked to Needs you (planeStatusLink), and file nothing.
626
+ const waited = longWaitWords(longPersonWaits(store, now, canSee));
627
+ const said = (words) => waited === null ? words : `${words} ${waited}`;
622
628
  const timeZone = schedule !== null && schedule.kind !== "every" ? schedule.timezone ?? "UTC" : "UTC";
623
629
  const day = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone }).format(now);
624
630
  const dayWords = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone }).format(now);
@@ -646,10 +652,16 @@ export async function firePlaneReview(store, trigger, config, now, scheduled) {
646
652
  skipped.push(made.note);
647
653
  }
648
654
  if (problems.length === 0)
649
- return done("A clean day: nothing to fix.");
655
+ return done(said("A clean day: nothing to fix."));
650
656
  const parts = [added > 0 ? `Added ${plural(added, "card")}.` : null, joined > 0 ? `${plural(joined, "problem")} came back and joined ${joined === 1 ? "its card" : "their cards"}.` : null,
651
657
  skipped.length > 0 ? `Left out ${plural(skipped.length, "problem")}: ${skipped[0]}.` : null].filter(one => one !== null);
652
- return done(parts.length === 0 ? "Nothing new since this morning's review." : parts.join(" "), added, joined);
658
+ return done(said(parts.length === 0 ? "Nothing new since this morning's review." : parts.join(" ")), added, joined);
659
+ }
660
+ /** Where a person sees everything that waits on them. */
661
+ export const NEEDS_YOU_HREF = "/work?view=needs-you";
662
+ /** A plane review whose summary mentions long waits on a person links to Needs you. */
663
+ export function planeStatusLink(config, status) {
664
+ return config?.kind === "plane-review" && status !== null && /waited over \d+ days for you\.$/.test(status) ? { label: "Needs you", href: NEEDS_YOU_HREF } : null;
653
665
  }
654
666
  /** v96: a schedule (a teammate's routine) makes its card now, once per press, and keeps its own times. */
655
667
  export function runScheduleNow(store, trigger, actor, now) {