toolroll 0.9.84 → 0.9.85

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 (81) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +94 -1193
  3. package/dist/agent-fence.d.ts +3 -3
  4. package/dist/agent-fence.js +3 -3
  5. package/dist/agent-onboard.d.ts +2 -0
  6. package/dist/agent-onboard.js +3 -0
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +31 -31
  9. package/dist/browser-workspace.d.ts +5 -0
  10. package/dist/chat-channel.d.ts +10 -1
  11. package/dist/chat-channel.js +35 -5
  12. package/dist/chat-decide.d.ts +16 -4
  13. package/dist/chat-decide.js +239 -31
  14. package/dist/chat-messages.d.ts +5 -0
  15. package/dist/chat-messages.js +1 -1
  16. package/dist/chat-quiet.js +3 -2
  17. package/dist/chat-review.d.ts +4 -0
  18. package/dist/chat-review.js +13 -4
  19. package/dist/chat-terms.d.ts +9 -0
  20. package/dist/chat-terms.js +46 -0
  21. package/dist/contracts/console-api.d.ts +2 -1
  22. package/dist/contracts/console-api.js +2 -1
  23. package/dist/contracts/lead-tools.d.ts +30 -0
  24. package/dist/contracts/lead-tools.js +3 -0
  25. package/dist/contracts/youiyoux-request.d.ts +248 -0
  26. package/dist/contracts/youiyoux-request.js +152 -0
  27. package/dist/flow-cadence.d.ts +8 -2
  28. package/dist/flow-cadence.js +16 -3
  29. package/dist/flow-card-task.d.ts +9 -0
  30. package/dist/flow-card-task.js +15 -6
  31. package/dist/integrations-ui.js +9 -4
  32. package/dist/integrations.d.ts +9 -1
  33. package/dist/integrations.js +43 -2
  34. package/dist/landed-cleanup.d.ts +4 -0
  35. package/dist/landed-cleanup.js +3 -2
  36. package/dist/lead-capabilities.js +2 -1
  37. package/dist/lead-cli.js +3 -1
  38. package/dist/lead-tools.d.ts +2 -0
  39. package/dist/lead-tools.js +30 -4
  40. package/dist/lead.d.ts +2 -0
  41. package/dist/lead.js +3 -3
  42. package/dist/operate.d.ts +1 -2
  43. package/dist/operate.js +11 -15
  44. package/dist/project-activity.d.ts +20 -0
  45. package/dist/project-activity.js +23 -0
  46. package/dist/project-cli.d.ts +1 -0
  47. package/dist/project-cli.js +36 -19
  48. package/dist/project-knowledge.d.ts +3 -3
  49. package/dist/project-outcomes.d.ts +119 -0
  50. package/dist/project-outcomes.js +79 -0
  51. package/dist/result-actions.d.ts +4 -0
  52. package/dist/result-actions.js +41 -9
  53. package/dist/result-review.d.ts +9 -0
  54. package/dist/result-review.js +45 -5
  55. package/dist/server/console.css +12 -0
  56. package/dist/server/pages.js +11 -1
  57. package/dist/server/render-chat.js +3 -2
  58. package/dist/server/render-pages.d.ts +2 -2
  59. package/dist/server/render-pages.js +7 -2
  60. package/dist/server/render-results.d.ts +2 -0
  61. package/dist/server/render-results.js +20 -7
  62. package/dist/server/render-tasks.d.ts +1 -0
  63. package/dist/server/render-tasks.js +15 -5
  64. package/dist/server/route-table.js +1 -0
  65. package/dist/server/settings.js +18 -1
  66. package/dist/server/task-views.js +6 -0
  67. package/dist/server/tasks.js +42 -6
  68. package/dist/store.d.ts +46 -3
  69. package/dist/store.js +165 -6
  70. package/dist/task-source.d.ts +13 -1
  71. package/dist/task-source.js +17 -1
  72. package/dist/telegram-lead.js +7 -5
  73. package/dist/telegram.js +1 -1
  74. package/dist/toolroll-update.js +2 -2
  75. package/dist/webhooks.d.ts +6 -0
  76. package/dist/webhooks.js +25 -0
  77. package/dist/work-index.d.ts +4 -0
  78. package/dist/work-index.js +32 -0
  79. package/dist/youiyoux-intake.d.ts +234 -0
  80. package/dist/youiyoux-intake.js +908 -0
  81. package/package.json +11 -8
@@ -624,6 +624,10 @@ export type BrowserProjectRow = {
624
624
  };
625
625
  export type BrowserProjectsView = {
626
626
  kind: 'projects';
627
+ activity?: {
628
+ project: string;
629
+ report: import('./project-outcomes.js').ProjectOutcomes | null;
630
+ };
627
631
  /** Arrived from New task: choosing where the task belongs. */
628
632
  choosing: boolean;
629
633
  problem: string | null;
@@ -711,6 +715,7 @@ export type BrowserCheckItem = {
711
715
  src: string;
712
716
  href: string;
713
717
  caption: string;
718
+ image?: import("./result-review.js").FeedbackImage;
714
719
  }[];
715
720
  };
716
721
  /** The result's one decision: what Accept is called, why it isn't plain
@@ -1,3 +1,4 @@
1
+ import { type FeedbackImage } from "./result-review.js";
1
2
  import type { DoorOutcome } from "./lead-doors.js";
2
3
  import { type VerifiedApprover } from "./principal.js";
3
4
  import type { ChatConfig, LeadProposal, LeadSession, LeadThread, Store, SubscriptionChatProviderId } from "./store.js";
@@ -40,7 +41,7 @@ export declare function tooLongText(length: number): string;
40
41
  /** A reply to a message about several tasks is never guessed: it names them and says how to choose. */
41
42
  export declare function whichTaskText(titles: readonly string[]): string;
42
43
  /** The server-authored context a reply to a result message carries into the turn — the exact execution and run, never a guess. */
43
- export declare function replyContextFor(taskId: string, run: number | null): string;
44
+ export declare function replyContextFor(taskId: string, run: number | null, image?: FeedbackImage): string;
44
45
  /** The context a chosen task (/tasks, /task <name>) carries into each turn from that chat. */
45
46
  export declare function focusContextFor(taskId: string): string;
46
47
  /** No trusted https origin is configured: one honest line, no localhost, no promise. */
@@ -69,6 +70,14 @@ export declare function armedCardText(proposal: LeadProposal, previewText: strin
69
70
  /** The "yes" button's label for an armed card. */
70
71
  export declare function armedYesLabel(proposal: LeadProposal): string;
71
72
  export declare function proposalLink(store: Store, proposal: LeadProposal, repos: readonly string[], channel?: ChatChannelName | null): PhoneLink | null;
73
+ /** The personal Telegram card: the shared preview, or a handoff to the lead conversation when it cannot be shown in full. */
74
+ export declare function phoneProposalPreview(store: Store, proposal: LeadProposal, repos: readonly string[]): {
75
+ text: string;
76
+ buttons: boolean;
77
+ closed?: true;
78
+ };
79
+ /** The personal Telegram card's link: the lead conversation holding a card too long to show, else the shared link. */
80
+ export declare function phoneProposalLink(store: Store, proposal: LeadProposal, repos: readonly string[], channel?: ChatChannelName | null): PhoneLink | null;
72
81
  /**
73
82
  * The link a CONFIRMED card carries: the exact recorded task the outcome
74
83
  * names, where its remaining step lives — the approval control while the
@@ -1,6 +1,7 @@
1
+ import { parseFeedbackImage } from "./result-review.js";
1
2
  /** Shared membership-chat identity, proposal previews and result links. Transport adapters never grant authority. */
2
3
  import { FLOW_HREF } from "./flow-engine.js";
3
- import { closedQuestionWords } from "./decision.js";
4
+ import { closedQuestionWords, LIMITS } from "./decision.js";
4
5
  import { createHash } from "node:crypto";
5
6
  import { sharedActionPayload, sharedActionNeedsReview, sharedActionReviewPath, sharedActionAllowsChallenge, CHAT_ACTIONS, } from "./chat-actions.js";
6
7
  import { isDirectChatProvider, subscriptionCredentialKey } from "./converse.js";
@@ -132,12 +133,13 @@ export function whichTaskText(titles) {
132
133
  ].join("\n");
133
134
  }
134
135
  /** The server-authored context a reply to a result message carries into the turn — the exact execution and run, never a guess. */
135
- export function replyContextFor(taskId, run) {
136
+ export function replyContextFor(taskId, run, image) {
136
137
  return [
137
138
  `Current task: ${taskId}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.`,
138
139
  run === null
139
140
  ? `The operator is replying to a message about execution ${taskId}.`
140
141
  : `The operator is replying to result #${run} from execution ${taskId}. Use get_result for that exact execution and run when responding to feedback; do not substitute another result. If the operator asks for changes, use propose_review revise; use note only when they explicitly ask to save feedback without starting work.`,
142
+ ...(image === undefined ? [] : [`The reply targets screenshot ${image.artifact} on result #${image.run}. Retain image: ${image.artifact} in propose_review so the exact screenshot stays with new feedback; pass image: null only when the feedback is not about it. Read get_result_images for this result first.`]),
141
143
  ].join(" ");
142
144
  }
143
145
  /** The context a chosen task (/tasks, /task <name>) carries into each turn from that chat. */
@@ -146,6 +148,10 @@ export function focusContextFor(taskId) {
146
148
  }
147
149
  /** A step the phone cannot take itself: said once; the button (or its absence) says where. */
148
150
  const HANDOFF = "This step finishes in Toolroll.";
151
+ /** One Telegram message; a card that cannot fit is never confirmed from a shortened copy. */
152
+ const TELEGRAM_TEXT_MAX = 4_096;
153
+ const PHONE_TOO_LONG = "This is too long to show in full here. It is waiting in your Toolroll chat to review and confirm.";
154
+ const LEAD_CHAT_LINK = { label: "Open chat", path: "/chat" };
149
155
  /** No trusted https origin is configured: one honest line, no localhost, no promise. */
150
156
  export const NO_PHONE_LINK = "Phone access isn't configured. Open Toolroll on your computer.";
151
157
  /** An origin exists but the card's task is not one this phone may reach now: said as that, never as missing setup. */
@@ -229,6 +235,23 @@ export function proposalLink(store, proposal, repos, channel = null) {
229
235
  }
230
236
  return null;
231
237
  }
238
+ /** A personal Telegram card fits one message, or it waits, whole, in the person's lead conversation in Toolroll. */
239
+ function fitsPhone(preview) {
240
+ return !preview.buttons || preview.text.length <= TELEGRAM_TEXT_MAX;
241
+ }
242
+ /** The personal Telegram card: the shared preview, or a handoff to the lead conversation when it cannot be shown in full. */
243
+ export function phoneProposalPreview(store, proposal, repos) {
244
+ const preview = proposalPreview(store, proposal, repos, "telegram");
245
+ return fitsPhone(preview)
246
+ ? preview
247
+ : { text: [preview.text.split("\n")[0], PHONE_TOO_LONG, "", HANDOFF].join("\n"), buttons: false };
248
+ }
249
+ /** The personal Telegram card's link: the lead conversation holding a card too long to show, else the shared link. */
250
+ export function phoneProposalLink(store, proposal, repos, channel = null) {
251
+ return fitsPhone(proposalPreview(store, proposal, repos, "telegram"))
252
+ ? proposalLink(store, proposal, repos, channel)
253
+ : LEAD_CHAT_LINK;
254
+ }
232
255
  /**
233
256
  * The link a CONFIRMED card carries: the exact recorded task the outcome
234
257
  * names, where its remaining step lives — the approval control while the
@@ -410,20 +433,22 @@ export function proposalPreview(store, proposal, repos, channel = null) {
410
433
  ? payload["notes"]
411
434
  : [];
412
435
  const saved = (snapshot?.notes ?? []).filter((one) => selected.includes(one.id));
413
- const note = t("note", 1_000);
436
+ const note = t("note", LIMITS.note);
437
+ const image = parseFeedbackImage(payload["image"]);
414
438
  const path = t("path", 300);
415
439
  return card(`${revise ? "Request changes to" : "Save feedback on"} result #${String(payload["run"] ?? "?")} of ${taskName}`, [
416
440
  ...(note === ""
417
441
  ? []
418
442
  : [
419
443
  `Your feedback${path === "" ? "" : ` (${path}${typeof payload["line"] === "number" ? `:${payload["line"]}` : ""})`}:`,
420
- ...lines(note, 1_000),
444
+ ...(image ? [`Screenshot ${image.artifact} · result #${image.run}`] : []),
445
+ ...lines(note, LIMITS.note),
421
446
  ]),
422
447
  ...(saved.length === 0
423
448
  ? []
424
449
  : [
425
450
  "Saved notes included:",
426
- ...saved.map((one) => `• ${one.path === null ? "" : `${phoneText(one.path, 120)}${one.line === null ? "" : `:${one.line}`}: `}${phoneText(one.note, 300)}`),
451
+ ...saved.map((one) => `• ${one.image ? `Screenshot ${one.image.artifact} · result #${one.image.run}: ` : ""}${one.path === null ? "" : `${phoneText(one.path, 120)}${one.line === null ? "" : `:${one.line}`}: `}${phoneText(one.note, LIMITS.note)}`),
427
452
  ]),
428
453
  ], revise
429
454
  ? "Creates a revision of the same task. Its approval follows your settings."
@@ -484,6 +509,11 @@ export const CHAT_ACTION_PARITY = {
484
509
  how: "Reads current tasks, decisions, results and project knowledge from the local database through the shared engine, within the enrolled projects.",
485
510
  gap: "Dedicated Telegram and Slack journeys for this tool and live channel rendering remain unverified.",
486
511
  },
512
+ get_project_activity: {
513
+ support: "direct",
514
+ how: "Reads the project's Ready backlog, finished-to-decision elapsed time, explicit revisions and recorded cost for completed families in an accessible enrolled project through the shared engine.",
515
+ gap: "Dedicated Telegram and Slack journeys for this tool and live channel rendering remain unverified.",
516
+ },
487
517
  get_project_context: {
488
518
  support: "direct",
489
519
  how: "Reads bounded source excerpts or advisory import impact in an accessible enrolled project through the shared engine, with source-search fallback when its index is unavailable.",
@@ -4,6 +4,8 @@ import { type VerifiedApprover } from "./principal.js";
4
4
  import type { Scope } from "./scope.js";
5
5
  import { type ReleaseCard } from "./release-card.js";
6
6
  import type { Store } from "./store.js";
7
+ import { permissionPlainWords } from "./chat-terms.js";
8
+ export { permissionPlainWords };
7
9
  /** The tokens live in chat_decide_action and the open "What should change?" in chat_decide_prompt (store.ts). */
8
10
  export type DecideAct = (typeof DECIDE_ACTS)[number];
9
11
  export type DecideTarget = {
@@ -20,6 +22,11 @@ export type DecideTarget = {
20
22
  kind: "merge";
21
23
  taskId: string;
22
24
  run: number;
25
+ }
26
+ /** The tasks one youiyoux pass filed (youiyoux-intake.ts), named by any one of them. */
27
+ | {
28
+ kind: "requests";
29
+ taskId: string;
23
30
  };
24
31
  export type DecideChannel = "telegram" | "slack" | "discord" | "teams";
25
32
  /** Who is tapping, where: one paired person's binding on one channel, and the chat the card is in. */
@@ -56,11 +63,13 @@ export declare const DECIDE_LABELS: {
56
63
  readonly cancel: "Cancel";
57
64
  };
58
65
  export declare const WHAT_SHOULD_CHANGE = "What should change?";
66
+ /** `taskId`: the task the button acts on, when it isn't the card's own (a youiyoux card's one-task Approve). */
59
67
  type ActSpec = {
60
68
  act: DecideAct;
61
69
  run: number | null;
62
70
  digest: string;
63
71
  label?: string;
72
+ taskId?: string;
64
73
  };
65
74
  /** What a card offers: an optional replacement text (the plan card) and rows of acts and links; a release card also
66
75
  * sends its full terms as a file with it (`file`), where the chat app can. */
@@ -85,11 +94,11 @@ export declare function chatApproveMode(store: Store, repo: string | null, appro
85
94
  ok: false;
86
95
  why: string;
87
96
  };
88
- /** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
89
- export declare function permissionPlainWords(profile: Scope["profile"] | null | undefined): string | null;
90
97
  /** A plan this person's chat may approve now: the term, the plan's exact bytes, and none of the reasons it must open
91
98
  * Toolroll instead. `digest` binds the scope and the plan document the card shows. */
92
- export declare function planInChat(store: Store, taskId: string, approver: string, now: Date, channel: DecideChannel, root?: string): {
99
+ export declare function planInChat(store: Store, taskId: string, approver: string, now: Date, channel: DecideChannel, root?: string,
100
+ /** A youiyoux card asking (requestsOffer): its own words are checked there, every one shown. */
101
+ from?: "requests"): {
93
102
  ok: true;
94
103
  modeDigest: string;
95
104
  source: "setting" | "mode";
@@ -109,6 +118,10 @@ export declare function mergeInChat(store: Store, taskId: string, run: number, a
109
118
  ok: false;
110
119
  why: string;
111
120
  };
121
+ /** Whether a card's words arrive as one message that shows each of them as written: within Telegram's part, Slack's and
122
+ * (after its escapes) Discord's limits, with room for the question a tap adds. Teams draws card text as Markdown, where
123
+ * a link's label hides its address, and Slack turns `:name:` into an emoji: there such words never approve. */
124
+ export declare function shownWhole(text: string, channel: DecideChannel): boolean;
112
125
  /** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. On Telegram the yes's
113
126
  * terms come second, under the title, and the steps are fewer: the full plan is behind Edit. */
114
127
  export declare function planCardText(store: Store, taskId: string, scope: Scope, root?: string, channel?: DecideChannel): string;
@@ -260,4 +273,3 @@ export declare function decideTargetOf(row: {
260
273
  taskId: string | null;
261
274
  run: number | null;
262
275
  }): DecideTarget | null;
263
- export {};
@@ -7,7 +7,8 @@
7
7
  * Approving a plan or merging needs the password, which never goes in chat, so they act in chat only where the owner
8
8
  * turned approving from chat on (their lasting setting, chat-approval.ts, or a signed mode's chatApprove term), for
9
9
  * their own chat; otherwise, and for a plan that widens permissions, exceeds the limit or touches protected paths, the
10
- * card keeps its link.
10
+ * card keeps its link. The change requests one youiyoux pass filed come as one card that shows every word of each
11
+ * task's terms, with Approve all and each one's own Approve (the requests card, below).
11
12
  *
12
13
  * The same in every chat app: Telegram's bridge and the shared engine Slack, Discord and Teams run on (chat-delivery.ts)
13
14
  * offer, mint and answer these through the same tokens (the `channel` column). Every act runs through the door the
@@ -20,7 +21,7 @@ import { acceptAndCompleteAsOperator, assignmentOf, owedAcceptance } from "./ass
20
21
  import { chatControlHref, chatResultHref } from "./chat-controls.js";
21
22
  import { applyChatTaskAction, chatTaskStamp } from "./chat-task-actions.js";
22
23
  import { chatTitle } from "./chat-voice.js";
23
- import { readVerifiedArtifact } from "./evidence.js";
24
+ import { readVerifiedArtifact, scanForSecrets } from "./evidence.js";
24
25
  import { effectiveChatApproval } from "./chat-approval.js";
25
26
  import { chatApproveChatsOf, chatNames, modeTermsFromJson } from "./modes.js";
26
27
  import { parseExecutionPlanDocument } from "./plan.js";
@@ -31,7 +32,13 @@ import { revisionSourceOf } from "./result-review.js";
31
32
  import { APPROVAL_PROOF, approvalOf, approvalProofDetail, deployGateScope, quarantinedAuthor } from "./scope.js";
32
33
  import { releaseCardOf } from "./release-card.js";
33
34
  import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
34
- import { phoneText } from "./telegram-status.js";
35
+ import { phoneText, projectLabel } from "./telegram-status.js";
36
+ import { permissionPlainWords, scopeTermsText, shownTermsDigest } from "./chat-terms.js";
37
+ import { YOUIYOUX_CARD_KIND, YOUIYOUX_FILED_VIA, YOUIYOUX_HOME_PATH } from "./contracts/youiyoux-request.js";
38
+ import { FILED_PER_PASS, requestKindWord, requestPassOf } from "./youiyoux-intake.js";
39
+ import { discordPlain } from "./reply-shape.js";
40
+ import { PLATFORM_LIMITS } from "./text-limits.js";
41
+ export { permissionPlainWords };
35
42
  /** The words, said once. */
36
43
  export const DECIDE_LABELS = {
37
44
  accept: "Accept and finish",
@@ -75,22 +82,6 @@ export function chatApproveMode(store, repo, approver, now, channel) {
75
82
  return { ok: false, why: `Your mode approves from ${chatNames(chats)} only. Sign it again to approve from ${chatNames([channel])}.` };
76
83
  return { ok: true, digest: mode.digest, limits: { fullAccess: terms.permissionDefault === "escalated", capMicrousd: terms.perAttemptBudgetMicrousd }, source: "mode" };
77
84
  }
78
- /** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
79
- export function permissionPlainWords(profile) {
80
- if (profile === null || profile === undefined)
81
- return null;
82
- const full = "full access, nothing asks first";
83
- if (profile.provider === "claude") {
84
- return profile.permissionArgv === "bypassPermissions" ? full
85
- : profile.permissionArgv === "auto" ? "file edits and routine commands; anything risky stops"
86
- : "file edits only; commands are refused";
87
- }
88
- if (profile.provider === "gemini")
89
- return profile.approvalArgv === "yolo" ? full : "file edits only; other tools are refused";
90
- if (isAcpProfile(profile))
91
- return "full access inside the OS fence, nothing asks first";
92
- return profile.sandboxMode === "danger-full-access" ? full : "file edits and commands inside the project only";
93
- }
94
85
  function fullAccess(profile) {
95
86
  if (profile === null || profile === undefined)
96
87
  return false;
@@ -100,7 +91,12 @@ function fullAccess(profile) {
100
91
  const money = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
101
92
  /** A plan this person's chat may approve now: the term, the plan's exact bytes, and none of the reasons it must open
102
93
  * Toolroll instead. `digest` binds the scope and the plan document the card shows. */
103
- export function planInChat(store, taskId, approver, now, channel, root) {
94
+ export function planInChat(store, taskId, approver, now, channel, root,
95
+ /** A youiyoux card asking (requestsOffer): its own words are checked there, every one shown. */
96
+ from) {
97
+ // A change request's terms are outside text: they approve in chat only on its own card, which shows every word.
98
+ if (from !== "requests" && store.filedViaOf(taskId) === YOUIYOUX_FILED_VIA)
99
+ return { ok: false, why: OUTSIDE_TERMS };
104
100
  const ref = store.lookupRef(taskId);
105
101
  const scope = store.getScope(taskId);
106
102
  if (ref === null || ref.repo === null || scope === null)
@@ -184,6 +180,194 @@ export function mergeInChat(store, taskId, run, approver, now, channel) {
184
180
  return mode;
185
181
  return { ok: true, modeDigest: mode.digest, source: mode.source, head: publication.headSha };
186
182
  }
183
+ // ---- a youiyoux pass's card -----------------------------------------------------------------
184
+ /**
185
+ * What one youiyoux pass filed (youiyoux-intake.ts), as one card: every task still waiting on this person, Approve all,
186
+ * and each one's own Approve and Open. Its terms are outside text (anyone who can write the project's folder wrote
187
+ * them), so a task is approved here only when this card shows every word of its terms (chat-terms.ts), the whole card
188
+ * arrives as one message every chat app shows as written, and every plan rule holds (planInChat). Its button carries
189
+ * each task with the digest of the words shown; the seal recomputes that digest from the live scope (store.ts). A task
190
+ * that can't be shown whole, or may not be approved here, is named with its link to Toolroll instead.
191
+ */
192
+ const REQUESTS_DIGEST = "youiyoux:";
193
+ const OPEN_IN_TOOLROLL = "Open in Toolroll";
194
+ const OUTSIDE_TERMS = "A youiyoux request is approved from its own youiyoux card, which shows every word, or in Toolroll.";
195
+ const TOO_LONG = "its full terms don't fit in one chat message";
196
+ /** Room left under each app's limit for the question a tap adds ("Approve 10 tasks?") and the names it leaves out. */
197
+ const ROOM = 400;
198
+ const listedDigest = (list) => `${REQUESTS_DIGEST}${list.map(one => `${one.taskId}=${one.digest}`).join(",")}`;
199
+ const LISTABLE_ID = /^[A-Za-z0-9._-]{1,64}$/;
200
+ function listedOf(digest) {
201
+ if (!digest.startsWith(REQUESTS_DIGEST))
202
+ return null;
203
+ const list = [];
204
+ for (const one of digest.slice(REQUESTS_DIGEST.length).split(",")) {
205
+ const match = /^([A-Za-z0-9._-]{1,64})=([0-9a-f]{64})$/.exec(one);
206
+ if (match === null)
207
+ return null;
208
+ list.push({ taskId: match[1], digest: match[2] });
209
+ }
210
+ return list.length === 0 || list.length > FILED_PER_PASS ? null : list;
211
+ }
212
+ /** Whether a card's words arrive as one message that shows each of them as written: within Telegram's part, Slack's and
213
+ * (after its escapes) Discord's limits, with room for the question a tap adds. Teams draws card text as Markdown, where
214
+ * a link's label hides its address, and Slack turns `:name:` into an emoji: there such words never approve. */
215
+ export function shownWhole(text, channel) {
216
+ switch (channel) {
217
+ case "telegram": return text.length + ROOM <= 3_900;
218
+ case "slack": return text.length + ROOM <= PLATFORM_LIMITS.slack && !/:[a-z0-9_+'-]+:/i.test(text);
219
+ case "discord": return discordPlain(text).length + ROOM <= PLATFORM_LIMITS.discord;
220
+ case "teams": return false;
221
+ }
222
+ }
223
+ /** Words a chat message never carries (chat-display.ts hides them elsewhere): a key, or an absolute folder on this
224
+ * computer (a home folder, a drive). A project's own relative paths and a page's route are fine. */
225
+ function privateWords(text) {
226
+ return scanForSecrets(text).length > 0 || /\b\d{5,}:[A-Za-z0-9_-]{20,}\b/.test(text) || /(?:^|[\s"'`(<[=:,])(?:\/Users\/|\/home\/|\/root\/|~\/)|(?:[A-Za-z]:[\\/]|\\\\)/.test(text);
227
+ }
228
+ /** Still waiting on a person's yes: not finished, and no approval on its current terms. */
229
+ function waitsForApproval(store, taskId) {
230
+ const state = store.getTask(taskId)?.state;
231
+ const scope = store.getScope(taskId);
232
+ return state !== undefined && state !== "done" && state !== "cancelled" && scope !== null && !(scope.approvedDigest != null && scope.approvedDigest === scope.digest);
233
+ }
234
+ /** The card for the pass `taskId` was filed in, as it stands for this person now; null when nothing waits on them. */
235
+ function requestsOffer(store, taskId, who, now, channel, root, said) {
236
+ const waiting = requestPassOf(store, taskId).filter(id => {
237
+ const ref = store.lookupRef(id);
238
+ return ref !== null && ref.repo !== null && who.repos.includes(ref.repo) && store.filedViaOf(id) === YOUIYOUX_FILED_VIA && waitsForApproval(store, id);
239
+ });
240
+ if (waiting.length === 0)
241
+ return null;
242
+ const repo = store.lookupRef(waiting[0]).repo;
243
+ const what = requestKindWord(store, waiting);
244
+ const head = `${waiting.length === 1 ? `A youiyoux ${what} waits` : `${waiting.length} youiyoux ${what}s wait`} for you · ${projectLabel(repo)}`;
245
+ const mode = chatApproveMode(store, repo, who.name, now, channel);
246
+ const items = waiting.map((id, index) => ({ n: index + 1, taskId: id, name: title(store, id), listed: null, why: null }));
247
+ const block = (item) => `${item.n}. ${item.name}\n${scopeTermsText(store.getScope(item.taskId))}`;
248
+ const compose = () => {
249
+ const shown = items.filter(one => one.listed !== null);
250
+ const open = items.filter(one => one.listed === null);
251
+ return [...(said === undefined ? [] : [said]), head,
252
+ ...shown.map(block),
253
+ ...(open.length === 0 ? [] : [[...(shown.length === 0 ? [] : ["Open in Toolroll to approve:"]), ...open.map(one => `${one.n}. ${one.name}${one.why === null ? "" : `: ${one.why}`}`)].join("\n")])].join("\n\n");
254
+ };
255
+ if (!mode.ok) {
256
+ // Nothing approves here: each task's title, files and goal in short, and its link.
257
+ const summary = items.map(one => {
258
+ const scope = store.getScope(one.taskId);
259
+ // The request's own goal, without the line the intake puts first (which request, on which route).
260
+ const goal = scope.goal.startsWith("From youiyoux request ") && scope.goal.includes("\n\n") ? scope.goal.slice(scope.goal.indexOf("\n\n") + 2) : scope.goal;
261
+ return [`${one.n}. ${one.name}`, `Files: ${scope.touches.length === 0 ? "any file (no file list)" : phoneText(scope.touches.join(", "), 160)}`, phoneText(goal, 200)].join("\n");
262
+ });
263
+ return { text: [head, `${mode.why} Open each to approve it in Toolroll.`, ...summary].join("\n\n"),
264
+ rows: items.map(one => [{ label: waiting.length === 1 ? OPEN_IN_TOOLROLL : `Open ${one.n}`, path: chatControlHref("approval", one.taskId) }]) };
265
+ }
266
+ for (const item of items) {
267
+ const plan = planInChat(store, item.taskId, who.name, now, channel, root, "requests");
268
+ if (!plan.ok) {
269
+ item.why = plan.why;
270
+ continue;
271
+ }
272
+ if (passwordOnlySeal(plan.scope) || deployGateScope(plan.scope) || !LISTABLE_ID.test(item.taskId)) {
273
+ item.why = "it needs your password in Toolroll";
274
+ continue;
275
+ }
276
+ // Shown whole means shown raw: words chat text otherwise hides (a key, a folder on this computer) stay in Toolroll.
277
+ if (privateWords(scopeTermsText(plan.scope))) {
278
+ item.why = "its terms name a key or a folder on this computer";
279
+ continue;
280
+ }
281
+ // In order, each whole or not at all: one that doesn't fit beside the ones before it is named instead.
282
+ item.listed = { taskId: item.taskId, digest: shownTermsDigest(plan.scope) };
283
+ if (!shownWhole(compose(), channel)) {
284
+ item.listed = null;
285
+ item.why = TOO_LONG;
286
+ }
287
+ }
288
+ const shown = items.filter(one => one.listed !== null);
289
+ const rows = [];
290
+ if (shown.length > 0)
291
+ rows.push([{ act: "approve", run: null, digest: listedDigest(shown.map(one => one.listed)), taskId: shown[0].taskId,
292
+ label: shown.length > 1 ? `Approve all (${shown.length})` : items.length === 1 ? DECIDE_LABELS.approve : `Approve ${shown[0].n}` }]);
293
+ for (const item of items) {
294
+ rows.push([...(item.listed !== null && shown.length > 1 ? [{ act: "approve", run: null, digest: listedDigest([item.listed]), taskId: item.taskId, label: `Approve ${item.n}` }] : []),
295
+ { label: waiting.length === 1 ? OPEN_IN_TOOLROLL : `Open ${item.n}`, path: chatControlHref("approval", item.taskId) }]);
296
+ }
297
+ return { text: compose(), rows };
298
+ }
299
+ /** The tasks a youiyoux card's button names, as they stand now: each still waiting, within every rule, on exactly the
300
+ * words the card showed (ready), or named with why not (skipped). */
301
+ function requestsNow(store, tokenTask, digest, who, now, channel, root) {
302
+ const listed = listedOf(digest);
303
+ const repo = store.lookupRef(tokenTask)?.repo ?? null;
304
+ if (listed === null || repo === null)
305
+ return { ok: false, why: "That button can't be read." };
306
+ const mode = chatApproveMode(store, repo, who.name, now, channel);
307
+ if (!mode.ok)
308
+ return mode;
309
+ const ready = [];
310
+ const skipped = [];
311
+ for (const one of listed) {
312
+ const ref = store.lookupRef(one.taskId);
313
+ const name = title(store, one.taskId);
314
+ if (ref === null || ref.repo !== repo || !who.repos.includes(repo) || store.filedViaOf(one.taskId) !== YOUIYOUX_FILED_VIA) {
315
+ skipped.push({ name, why: "it isn't available here now" });
316
+ continue;
317
+ }
318
+ const plan = planInChat(store, one.taskId, who.name, now, channel, root, "requests");
319
+ if (!plan.ok) {
320
+ skipped.push({ name, why: plan.why });
321
+ continue;
322
+ }
323
+ if (passwordOnlySeal(plan.scope) || deployGateScope(plan.scope)) {
324
+ skipped.push({ name, why: "it needs your password in Toolroll" });
325
+ continue;
326
+ }
327
+ if (shownTermsDigest(plan.scope) !== one.digest) {
328
+ skipped.push({ name, why: "its terms changed since this card was sent" });
329
+ continue;
330
+ }
331
+ ready.push({ taskId: one.taskId, name, digest: one.digest, plan });
332
+ }
333
+ return { ok: true, ready, skipped };
334
+ }
335
+ /** The owner's own yes from chat, sealed as theirs ("chat"), never as a mode's: it stands like the console's after the
336
+ * mode or setting ends. `terms`: the digest of every word a youiyoux card showed, which the seal checks. */
337
+ function sealPlanInChat(store, who, seat, repo, taskId, plan, now, terms) {
338
+ if (!store.sealScopeApproval(taskId, who.name, now, {}, { kind: "chat", modeDigest: plan.modeDigest, ...(terms === undefined ? {} : { card: "terms", cardDigest: terms }) }))
339
+ return false;
340
+ // The person's proof beside the seal, bound to these exact words (approval policy §1.2): a paired-chat tap, never a
341
+ // password. Release, deploy and the protected counters read it as a person's.
342
+ const sealedDigest = store.getScope(taskId)?.approvedDigest ?? "";
343
+ store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId, runId: null, action: APPROVAL_PROOF, outcome: "chat",
344
+ source: "request", detail: approvalProofDetail(now.getTime(), store.accountOf(who.name)?.generation ?? 0, sealedDigest) });
345
+ store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId, runId: null, action: "plan approved in chat", outcome: "approved",
346
+ source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · ${plan.source} ${plan.modeDigest}${terms === undefined ? "" : ` · youiyoux card, terms ${terms}`}` });
347
+ return true;
348
+ }
349
+ /** A youiyoux card's yes: each task it names that still stands as shown is sealed, each with its own proof; the rest are
350
+ * named, never approved. The card then offers what still waits, so one can be approved after another. */
351
+ function approveRequests(store, seat, who, tokenTask, digest, target, body, message, root, now) {
352
+ const read = requestsNow(store, tokenTask, digest, who, now, seat.channel, root);
353
+ if (!read.ok)
354
+ return { ack: "Not done.", edit: { text: `${body}\n\n✗ Not done: ${read.why}`, rows: linksFor(target), tokens: [] } };
355
+ const approved = [];
356
+ const skipped = [...read.skipped];
357
+ for (const one of read.ready) {
358
+ if (sealPlanInChat(store, who, seat, store.lookupRef(one.taskId).repo, one.taskId, one.plan, now, one.digest))
359
+ approved.push(one.name);
360
+ else
361
+ skipped.push({ name: one.name, why: "it couldn't be approved from chat. Open it in Toolroll." });
362
+ }
363
+ const said = [...(approved.length === 0 ? [] : [`✓ Approved ${approved.map(name => `"${name}"`).join(", ")}. Work starts when a worker is free.`]),
364
+ ...skipped.map(one => `✗ Not approved: "${one.name}": ${one.why}`)].join("\n");
365
+ const ack = approved.length === 0 ? "Not done." : "Approved.";
366
+ const again = requestsOffer(store, tokenTask, who, now, seat.channel, root, said);
367
+ if (again === null)
368
+ return { ack, edit: { text: `${body}\n\n${said}`, rows: skipped.length === 0 ? [] : linksFor(target), tokens: [] } };
369
+ return { ack, edit: { text: again.text ?? said, ...mintDecideButtons(store, seat, target, again, now, message) } };
370
+ }
187
371
  /** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. On Telegram the yes's
188
372
  * terms come second, under the title, and the steps are fewer: the full plan is behind Edit. */
189
373
  export function planCardText(store, taskId, scope, root, channel) {
@@ -292,6 +476,7 @@ export function decideOffer(store, target, who, now, channel, root) {
292
476
  return { text: planCardText(store, target.taskId, plan.scope, root, channel),
293
477
  rows: [[{ act: "approve", run: null, digest: plan.digest }], [{ label: DECIDE_LABELS.edit, path: chatControlHref("approval", target.taskId) }, { act: "not-now", run: null, digest: plan.digest }]] };
294
478
  }
479
+ case "requests": return requestsOffer(store, target.taskId, who, now, channel, root);
295
480
  case "merge": {
296
481
  const merge = mergeInChat(store, target.taskId, target.run, who.name, now, channel);
297
482
  if (!merge.ok)
@@ -345,7 +530,7 @@ export function mintDecideButtons(store, seat, target, offer, now, message = nul
345
530
  const rows = offer.rows.map(row => row.map((one) => {
346
531
  if (!isAct(one))
347
532
  return { label: one.label, link: one };
348
- const token = mint(store, seat, message, one, target.taskId, "offer", now);
533
+ const token = mint(store, seat, message, one, one.taskId ?? target.taskId, "offer", now);
349
534
  tokens.push(token);
350
535
  return { label: one.label ?? DECIDE_LABELS[one.act], token };
351
536
  }));
@@ -444,6 +629,8 @@ const QUESTIONS = {
444
629
  merge: name => `Merge "${name}"?`,
445
630
  };
446
631
  function targetOf(row) {
632
+ if (row.digest.startsWith(REQUESTS_DIGEST))
633
+ return { kind: "requests", taskId: row.taskId };
447
634
  switch (row.act) {
448
635
  case "accept":
449
636
  case "changes": return { kind: "result", taskId: row.taskId, run: row.run ?? 0 };
@@ -496,6 +683,14 @@ function staleWhy(store, row, who, now, channel, root) {
496
683
  return release.why;
497
684
  return releaseDigest(release.card) === row.digest ? null : "This release changed while you were reading.";
498
685
  }
686
+ if (row.digest.startsWith(REQUESTS_DIGEST)) {
687
+ const read = requestsNow(store, row.taskId, row.digest, who, now, channel, root);
688
+ if (!read.ok)
689
+ return read.why;
690
+ if (read.ready.length > 0)
691
+ return null;
692
+ return read.skipped.length === 1 ? `"${read.skipped[0].name}": ${read.skipped[0].why}` : "None of these wait for your approval as this card showed them.";
693
+ }
499
694
  const plan = planInChat(store, row.taskId, who.name, now, channel, root);
500
695
  if (!plan.ok)
501
696
  return plan.why;
@@ -521,6 +716,7 @@ export function linksFor(target) {
521
716
  case "merge": return [[{ label: "Open task", link: { label: "Open task", path: chatControlHref("task", target.taskId) } }]];
522
717
  case "plan": return [[{ label: "Review & start", link: { label: "Review & start", path: chatControlHref("approval", target.taskId) } }]];
523
718
  case "failed": return [[{ label: "Open task", link: { label: "Open task", path: chatControlHref("task", target.taskId) } }]];
719
+ case "requests": return [[{ label: OPEN_IN_TOOLROLL, link: { label: OPEN_IN_TOOLROLL, path: YOUIYOUX_HOME_PATH } }]];
524
720
  }
525
721
  }
526
722
  /** Where a plan card's task is now, for a card whose buttons no longer act: the family's current version, named only
@@ -548,7 +744,7 @@ const QUESTION_STARTS = ["Accept and finish \"", "Retry \"", "Approve and start
548
744
  /** The card's words without a question this module appended. */
549
745
  function cardBody(shown) {
550
746
  const at = shown.lastIndexOf("\n\n");
551
- return at >= 0 && QUESTION_STARTS.some(start => shown.startsWith(start, at + 2)) ? shown.slice(0, at) : shown;
747
+ return at >= 0 && (QUESTION_STARTS.some(start => shown.startsWith(start, at + 2)) || /^Approve \d+ tasks\?/.test(shown.slice(at + 2))) ? shown.slice(0, at) : shown;
552
748
  }
553
749
  /**
554
750
  * One tap on a decide button, inside the caller's transaction. Null: not this module's token. The seat must be the
@@ -637,6 +833,20 @@ export function applyDecideTap(store, seat, input) {
637
833
  spend();
638
834
  return releaseContinue(store, seat, input.message, row.taskId, name, release.card, now, "Read the terms, then approve.", null);
639
835
  }
836
+ // A youiyoux card's Approve: how many still stand as the card showed them, and which don't (named, never approved).
837
+ if (row.act === "approve" && row.digest.startsWith(REQUESTS_DIGEST)) {
838
+ const read = requestsNow(store, row.taskId, row.digest, who, now, seat.channel, input.root);
839
+ if (!read.ok)
840
+ return stale(read.why);
841
+ spend();
842
+ const spec = { act: row.act, run: row.run, digest: row.digest };
843
+ const yes = mint(store, seat, input.message, spec, row.taskId, "yes", now);
844
+ const cancel = mint(store, seat, input.message, spec, row.taskId, "cancel", now);
845
+ const asked = [read.ready.length === 1 ? QUESTIONS.approve(read.ready[0].name) : `Approve ${read.ready.length} tasks?`,
846
+ ...read.skipped.map(one => `"${one.name}" isn't included: ${one.why}`)].join("\n");
847
+ return { ack: "Confirm below.", edit: { text: `${body}\n\n${asked}`,
848
+ rows: [[{ label: DECIDE_LABELS.yes, token: yes }, { label: DECIDE_LABELS.cancel, token: cancel }]], tokens: [yes, cancel] } };
849
+ }
640
850
  // The first tap asks; only the yes acts, bound to exactly what this button was.
641
851
  spend();
642
852
  const spec = { act: row.act, run: row.run, digest: row.digest };
@@ -687,6 +897,8 @@ export function applyDecideTap(store, seat, input) {
687
897
  link: chatControlHref("task", row.taskId), source: { taskRef: ref.id } }, now);
688
898
  return done("✓ Release approved. It deploys when its check passes and someone runs the release.");
689
899
  }
900
+ if (row.digest.startsWith(REQUESTS_DIGEST))
901
+ return approveRequests(store, seat, who, row.taskId, row.digest, target, body, input.message, input.root, now);
690
902
  const plan = planInChat(store, row.taskId, who.name, now, seat.channel, input.root);
691
903
  if (!plan.ok)
692
904
  return notDone(plan.why);
@@ -694,16 +906,8 @@ export function applyDecideTap(store, seat, input) {
694
906
  return notDone("This is a release: approve it from its release card.");
695
907
  if (passwordOnlySeal(plan.scope))
696
908
  return notDone(needsPassword(plan.scope));
697
- // The owner's own yes, sealed as theirs ("chat"), never as a mode's: it stands like the console's after the mode or setting ends.
698
- if (!store.sealScopeApproval(row.taskId, who.name, now, {}, { kind: "chat", modeDigest: plan.modeDigest }))
909
+ if (!sealPlanInChat(store, who, seat, ref.repo, row.taskId, plan, now))
699
910
  return notDone("the plan couldn't be approved from chat. Open it in Toolroll.");
700
- // The person's proof beside the seal, bound to these exact words (approval policy §1.2): a paired-chat tap, never a
701
- // password. Release, deploy and the protected counters read it as a person's.
702
- const sealedDigest = store.getScope(row.taskId)?.approvedDigest ?? "";
703
- store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: APPROVAL_PROOF, outcome: "chat",
704
- source: "request", detail: approvalProofDetail(now.getTime(), store.accountOf(who.name)?.generation ?? 0, sealedDigest) });
705
- store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: "plan approved in chat", outcome: "approved",
706
- source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · ${plan.source} ${plan.modeDigest}` });
707
911
  return done("✓ Approved. Work starts when a worker is free.");
708
912
  }
709
913
  case "merge": {
@@ -781,6 +985,8 @@ export function applyDecideFeedback(store, seat, prompt, text, repos, root, now)
781
985
  }
782
986
  /** The link a card keeps when it can't act in chat: a plan's is Review & start, where the password step lives. */
783
987
  export function decideFallbackLink(target) {
988
+ if (target?.kind === "requests")
989
+ return { label: OPEN_IN_TOOLROLL, path: YOUIYOUX_HOME_PATH };
784
990
  return target?.kind === "plan" ? { label: "Review & start", path: chatControlHref("approval", target.taskId) } : null;
785
991
  }
786
992
  /** Which card a pushed notification is, from its recorded kind and task — never from its words. */
@@ -791,6 +997,8 @@ export function decideTargetOf(row) {
791
997
  return { kind: "result", taskId: row.taskId, run: row.run };
792
998
  if (row.kind === "plan-ready")
793
999
  return { kind: "plan", taskId: row.taskId };
1000
+ if (row.kind === YOUIYOUX_CARD_KIND)
1001
+ return { kind: "requests", taskId: row.taskId };
794
1002
  if (row.kind === "pull-request-ready" && row.run !== null)
795
1003
  return { kind: "merge", taskId: row.taskId, run: row.run };
796
1004
  if (/fail|exhausted|stalled|fenced/.test(row.kind))
@@ -267,6 +267,11 @@ export declare class ChatMessages extends ChatCore {
267
267
  taskRef: number | null;
268
268
  run: number | null;
269
269
  project: string | null;
270
+ image?: {
271
+ run: number;
272
+ artifact: number;
273
+ sha256: string;
274
+ };
270
275
  }[];
271
276
  createProposalAction(action: {
272
277
  token: string;
@@ -609,7 +609,7 @@ export class ChatMessages extends ChatCore {
609
609
  const images = parts.filter(one => one.part.kind === "image").map(({ part }) => {
610
610
  const taskId = part.taskId;
611
611
  const ref = this.store.lookupRef(taskId);
612
- return { taskId, taskRef: ref?.id ?? null, run: part.run, project: ref?.repo ?? null };
612
+ return { taskId, taskRef: ref?.id ?? null, run: part.run, project: ref?.repo ?? null, ...(part.run === null || part.artifact === null || part.sha256 === null ? {} : { image: { run: part.run, artifact: part.artifact, sha256: part.sha256 } }) };
613
613
  });
614
614
  // The lead's own messages: a reply from a turn that was about one task,
615
615
  // and a card, which names its task (or, once it filed one, the new task).
@@ -13,6 +13,7 @@ import { phoneText, projectLabel } from "./telegram-status.js";
13
13
  import { isLifecycleNotification } from "./store.js";
14
14
  import { leadSubjectOf } from "./lead-voice.js";
15
15
  import { decideFallbackLink, decideTargetOf } from "./chat-decide.js";
16
+ import { YOUIYOUX_CARD_KIND } from "./contracts/youiyoux-request.js";
16
17
  /** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
17
18
  * attention fact, a result Ready for review, a security alert or release, and anything addressed to one person
18
19
  * (a sign-in or plan-limit pause, an evening digest). */
@@ -104,8 +105,8 @@ export function quietCardView(store, taskRefs, now, root, viewer) {
104
105
  return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/work" } };
105
106
  }
106
107
  /** Facts that carry their own controls or must stand alone (a security alert, a decision, a flow card, a
107
- * screenshot, an acceptance packet) are never folded into a batch. */
108
- const OWN_MESSAGE_KINDS = new Set(["security-release", "secret-detected", "flow-decision", "flow-card", "acceptance-ready", "acceptance-evidence"]);
108
+ * screenshot, an acceptance packet, a youiyoux pass's card) are never folded into a batch. */
109
+ const OWN_MESSAGE_KINDS = new Set(["security-release", "secret-detected", "flow-decision", "flow-card", "acceptance-ready", "acceptance-evidence", YOUIYOUX_CARD_KIND]);
109
110
  /** Whether this update for a person joins their two-minute batch: anything about one task that needs them,
110
111
  * except a fact that must stand alone. */
111
112
  export function joinsBatch(row) {