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
@@ -725,7 +725,7 @@ export type BrowserRunChecks = {
725
725
  export type BrowserFlowStage = {
726
726
  id: string;
727
727
  title: string;
728
- kind: "inbox" | "task" | "report" | "approval" | "check" | "pull-request" | "update" | "notify" | "sort" | "draft" | "request" | "email" | "tool" | "wait" | "teammate" | "done";
728
+ kind: "inbox" | "task" | "report" | "approval" | "check" | "pull-request" | "update" | "notify" | "sort" | "draft" | "request" | "email" | "tool" | "wait" | "teammate" | "send" | "choose" | "done";
729
729
  zone: {
730
730
  x: number;
731
731
  y: number;
@@ -799,6 +799,13 @@ export type BrowserFlowStage = {
799
799
  teammate?: string | undefined;
800
800
  /** v96: a teammate zone sends what the teammate writes back to whoever asked. */
801
801
  reply?: boolean | undefined;
802
+ /** A "Person chooses" zone's buttons: each one's words and the zone it leads to ("end" ignores the card). */
803
+ options?: {
804
+ label: string;
805
+ to: string;
806
+ }[] | undefined;
807
+ /** A Build zone in another project: its path. */
808
+ repo?: string | undefined;
802
809
  next: string | null;
803
810
  onFail: string | null;
804
811
  };
@@ -884,6 +891,34 @@ export type BrowserFlowCard = {
884
891
  from: string;
885
892
  note: string;
886
893
  } | null;
894
+ /** Waiting at a "Person chooses" zone: what was sent, its options and whether the viewer is the one who chooses. */
895
+ choose?: {
896
+ entry: number;
897
+ title: string;
898
+ summary: string;
899
+ links: {
900
+ label: string;
901
+ href: string;
902
+ }[];
903
+ person: string;
904
+ mine: boolean;
905
+ options: {
906
+ choice: number;
907
+ label: string;
908
+ }[];
909
+ reply: boolean;
910
+ } | null;
911
+ /** What a "Send to me" (or an earlier choice) last sent the card's person. */
912
+ sent?: {
913
+ title: string;
914
+ summary: string;
915
+ links: {
916
+ label: string;
917
+ href: string;
918
+ }[];
919
+ person: string;
920
+ at: string;
921
+ } | null;
887
922
  /** v94: every tool call teammates made or asked to make on this card, oldest first: the receipts. */
888
923
  calls?: {
889
924
  id: number;
@@ -912,6 +947,8 @@ export type BrowserFlowTrigger = {
912
947
  status: string | null;
913
948
  statusAt: string | null;
914
949
  failing: boolean;
950
+ /** Where the status points a person (a plane review's long waits: Needs you). */
951
+ statusLink: BrowserLink | null;
915
952
  button: {
916
953
  label: string;
917
954
  questions: string[];
@@ -1013,6 +1050,11 @@ export type BrowserFlowView = {
1013
1050
  live: string | null;
1014
1051
  canEdit: boolean;
1015
1052
  approvers: string[];
1053
+ /** The projects a Build zone may work in: the flow's own first, then others the viewer can reach. */
1054
+ projects?: {
1055
+ path: string;
1056
+ name: string;
1057
+ }[];
1016
1058
  kinds: {
1017
1059
  kind: BrowserFlowStage["kind"];
1018
1060
  label: string;
@@ -91,6 +91,11 @@ export declare const CHAT_ACTIONS: {
91
91
  readonly protected: false;
92
92
  readonly password: false;
93
93
  };
94
+ readonly flow_card_choose: {
95
+ readonly label: "Choose";
96
+ readonly protected: false;
97
+ readonly password: false;
98
+ };
94
99
  readonly flow_card_cancel: {
95
100
  readonly label: "Cancel card";
96
101
  readonly protected: false;
@@ -16,9 +16,10 @@ import { acceptAndCompleteAsOperator, assignmentOf } from "./assignment.js";
16
16
  import { getDecision, recordDecision, retireDecision } from "./project-memory.js";
17
17
  import { resumeTaskStop, taskControlOf } from "./task-control.js";
18
18
  import { addToolTo, catalogTool, projectToolsOf, removeToolFrom, toolCommandLine, validateToolSpec } from "./project-tools.js";
19
- import { deciderOf, FLOW_KIND_WORDS, flowDigest, flowTerms, validateFlowDefinition } from "./flows.js";
19
+ import { deciderOf, FLOW_KIND_WORDS, flowDigest, flowTerms, replyTarget, validateFlowDefinition } from "./flows.js";
20
20
  import { saveScript, scriptDigest, validateScript } from "./flow-scripts.js";
21
- import { addCardToFlow, advanceFlows, cancelFlowCard, decideFlowCard, flowCardHref, flowCardText, flowDefinitionOf, moveCardInFlow } from "./flow-engine.js";
21
+ import { addCardToFlow, advanceFlows, cancelFlowCard, crossProjectProblem, decideFlowCard, flowCardHref, flowCardText, flowDefinitionOf, moveCardInFlow } from "./flow-engine.js";
22
+ import { chooseFlowCard, flowPersonOf } from "./flow-send.js";
22
23
  import { assignFlowCard, commentOnFlowCard, flowPeople, mentionsIn, watchFlowCard } from "./flow-people.js";
23
24
  import { addFlowTriggerTo, describeTrigger, readLinearKey, removeFlowTrigger, scheduleFromWords, takesDeliveries, triggerConfigOf, validateTriggerConfig } from "./flow-triggers.js";
24
25
  import { describeSchedule, parseSchedule } from "./routine.js";
@@ -87,6 +88,7 @@ export const CHAT_ACTIONS = {
87
88
  flow_card_move: { label: "Move card", protected: false, password: false },
88
89
  flow_card_approve: { label: "Approve", protected: false, password: false },
89
90
  flow_card_send_back: { label: "Send back", protected: false, password: false },
91
+ flow_card_choose: { label: "Choose", protected: false, password: false },
90
92
  flow_card_cancel: { label: "Cancel card", protected: false, password: false },
91
93
  flow_card_comment: { label: "Comment", protected: false, password: false },
92
94
  flow_card_assign: { label: "Set owner", protected: false, password: false },
@@ -144,6 +146,7 @@ export const CHAT_ACTION_FIELDS = {
144
146
  flow_card_move: ["card", "zone"],
145
147
  flow_card_approve: ["card", "note"],
146
148
  flow_card_send_back: ["card", "note"],
149
+ flow_card_choose: ["card", "choice", "note"],
147
150
  flow_card_cancel: ["card"],
148
151
  flow_card_comment: ["card", "note"],
149
152
  flow_card_assign: ["card", "owner"],
@@ -580,6 +583,9 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
580
583
  if (operation === "flow_create") {
581
584
  const name = text(input, "name", 80).trim();
582
585
  const definition = flowDrawingOf(store, input, repo);
586
+ const elsewhere = crossProjectProblem(store, definition, { repo, owner: who.name });
587
+ if (elsewhere !== null)
588
+ throw Error(elsewhere);
583
589
  request["name"] = name;
584
590
  request["definition"] = definition;
585
591
  state = {};
@@ -613,6 +619,9 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
613
619
  const name = input["name"] === undefined ? flow.name : text(input, "name", 80).trim();
614
620
  const definition = input["definition"] === undefined ? before : flowDrawingOf(store, input, repo);
615
621
  const redrawn = flowDigest(definition) !== flowDigest(before);
622
+ const elsewhere = crossProjectProblem(store, definition, flow, who.name);
623
+ if (elsewhere !== null)
624
+ throw Error(elsewhere);
616
625
  if (!redrawn && name === flow.name)
617
626
  throw Error("That's the flow as it is now.");
618
627
  Object.assign(request, { name, definition });
@@ -728,6 +737,38 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
728
737
  title = `Take ${quoted(card.title)} out of the flow`;
729
738
  terms.push("The card leaves the flow. Any tasks it filed stay as they are.");
730
739
  }
740
+ else if (operation === "flow_card_choose") {
741
+ // "Person chooses" (flow-send.ts): one of the zone's own options, or a reply that becomes the note.
742
+ if (at?.kind !== "choose")
743
+ throw Error("That card isn't waiting for a choice.");
744
+ const person = flowPersonOf(card, flowTarget.flow);
745
+ if (person !== who.name)
746
+ throw Error(`Only ${person} chooses here.`);
747
+ const titleOf = (id) => definition.stages.find(one => one.id === id)?.title ?? id;
748
+ if (input["choice"] !== undefined && input["choice"] !== null) {
749
+ const index = Number(input["choice"]) - 1;
750
+ const option = Number.isSafeInteger(input["choice"]) ? (at.options ?? [])[index] : undefined;
751
+ if (option === undefined)
752
+ throw Error(`Choose one of its ${(at.options ?? []).length} options, by number.`);
753
+ delete request["note"];
754
+ // Bound to the option as shown: if the flow's options change before it is confirmed, it is refused.
755
+ Object.assign(state, { label: option.label, to: option.to });
756
+ title = `Choose “${option.label}” for ${quoted(card.title)}`;
757
+ terms.push(option.to === "end" ? `${at.title}: “${option.label}”. The card is closed as Ignored.` : `${at.title}: “${option.label}”. It moves to ${titleOf(option.to)}.`);
758
+ }
759
+ else {
760
+ const note = input["note"] === undefined ? "" : text(input, "note", 2000).trim();
761
+ const target = replyTarget(at);
762
+ if (target === null)
763
+ throw Error("This step takes one of its options, not a reply.");
764
+ if (note === "")
765
+ throw Error("Say what you'd change, or choose an option by number.");
766
+ request["note"] = note;
767
+ state["to"] = target;
768
+ title = `Reply on ${quoted(card.title)}`;
769
+ terms.push(`${at.title}: your reply goes to ${titleOf(target)} as its note:\n${note}`);
770
+ }
771
+ }
731
772
  else {
732
773
  if (at?.kind !== "approval")
733
774
  throw Error("That card isn't waiting for a decision.");
@@ -1342,10 +1383,20 @@ function runFlowAction(store, payload, actor, repos, root, now) {
1342
1383
  }
1343
1384
  const acted = payload.operation === "flow_card_move" ? moveCardInFlow(store, card, String(req["zone"]), actor, now)
1344
1385
  : payload.operation === "flow_card_cancel" ? cancelFlowCard(store, card, actor, now)
1345
- : (() => {
1346
- const decided = decideFlowCard(store, { card: card.id, decision: payload.operation === "flow_card_approve" ? "approve" : "send-back", note: typeof req["note"] === "string" ? req["note"] : null, actor, repos, ...(root === undefined ? {} : { evidenceRoot: root }) }, now);
1347
- return decided.ok ? { ok: true, said: decided.said, card: card.id } : { ok: false, message: decided.message };
1348
- })();
1386
+ : payload.operation === "flow_card_choose" ? (() => {
1387
+ // The option (its words and where it leads) or reply target exactly as proposed; a proposal from before carries neither and is refused.
1388
+ const bound = payload.state;
1389
+ if (typeof bound["to"] !== "string" || (Number.isSafeInteger(req["choice"]) && typeof bound["label"] !== "string"))
1390
+ return { ok: false, message: "Those options changed since; nothing was changed. Ask for a fresh proposal." };
1391
+ const chosen = chooseFlowCard(store, { card: card.id, entry: Number(bound["entry"]), choice: Number.isSafeInteger(req["choice"]) ? Number(req["choice"]) - 1 : null,
1392
+ ...(typeof bound["label"] === "string" ? { label: bound["label"] } : {}), to: bound["to"],
1393
+ note: typeof req["note"] === "string" ? req["note"] : null, actor, where: "chat", repos, ...(root === undefined ? {} : { evidenceRoot: root }) }, now);
1394
+ return chosen.ok ? { ok: true, said: chosen.said, card: card.id } : { ok: false, message: chosen.message };
1395
+ })()
1396
+ : (() => {
1397
+ const decided = decideFlowCard(store, { card: card.id, decision: payload.operation === "flow_card_approve" ? "approve" : "send-back", note: typeof req["note"] === "string" ? req["note"] : null, actor, repos, ...(root === undefined ? {} : { evidenceRoot: root }) }, now);
1398
+ return decided.ok ? { ok: true, said: decided.said, card: card.id } : { ok: false, message: decided.message };
1399
+ })();
1349
1400
  if (!acted.ok)
1350
1401
  throw Error(acted.message);
1351
1402
  return settle(card.flow, acted.said, card.id);
@@ -92,6 +92,21 @@ export type ChatContent = {
92
92
  entry: number;
93
93
  actions: Array<"approve" | "edit" | "send-back">;
94
94
  };
95
+ /** A flow's "Person chooses" buttons ride this part: minted when it is planned. */
96
+ choose?: {
97
+ card: number;
98
+ entry: number;
99
+ options: Array<{
100
+ choice: number;
101
+ label: string;
102
+ }>;
103
+ };
104
+ /** "Use this as your note?" Yes / No about the person's message `held` (an event id), for one choice's visit. */
105
+ note?: {
106
+ card: number;
107
+ entry: number;
108
+ held: string;
109
+ };
95
110
  /** A teammate's question's buttons ride this part (v93): each option, then one to answer in words (choice null). */
96
111
  question?: {
97
112
  id: number;
@@ -78,6 +78,25 @@ CREATE TABLE IF NOT EXISTS chat_flow_prompt (
78
78
  );
79
79
  CREATE INDEX IF NOT EXISTS chat_flow_prompt_open ON chat_flow_prompt(binding, consumed);
80
80
  `;
81
+ /** A flow's "Person chooses" zone in the chat app (flow-send.ts): one button per option (choice is its index, label its words
82
+ * when sent) for one visit of one card, on the part it rides. A reply in that notice's thread is the note; chat_flow_note
83
+ * is "Use this as your note?" Yes / No about one other message (held: its event; words: what it said, kept only until
84
+ * answered, as an event's own payload is cleared once it is handled), asked once per visit.
85
+ * Never in CHAT_TABLES: an older database has no such table until this schema creates it. */
86
+ const CHAT_FLOW_CHOICE_SCHEMA = `
87
+ CREATE TABLE IF NOT EXISTS chat_flow_choice (
88
+ token TEXT PRIMARY KEY, part INTEGER NOT NULL REFERENCES chat_part(id),
89
+ card INTEGER NOT NULL REFERENCES flow_card(id), entry INTEGER NOT NULL,
90
+ choice INTEGER NOT NULL, label TEXT NOT NULL, expires TEXT NOT NULL, consumed TEXT
91
+ );
92
+ CREATE INDEX IF NOT EXISTS chat_flow_choice_visit ON chat_flow_choice(card, entry);
93
+ CREATE TABLE IF NOT EXISTS chat_flow_note (
94
+ token TEXT PRIMARY KEY, part INTEGER NOT NULL REFERENCES chat_part(id),
95
+ card INTEGER NOT NULL REFERENCES flow_card(id), entry INTEGER NOT NULL,
96
+ held TEXT NOT NULL, words TEXT, answer TEXT NOT NULL CHECK(answer IN ('yes','no')), expires TEXT NOT NULL, consumed TEXT
97
+ );
98
+ CREATE INDEX IF NOT EXISTS chat_flow_note_visit ON chat_flow_note(card, entry);
99
+ `;
81
100
  /** v93: a teammate's question in the chat app. One button per option (choice)
82
101
  * and one to answer in words (choice NULL); that one opens a prompt the
83
102
  * person's next message in their DM answers. Never in CHAT_TABLES: an older
@@ -121,7 +140,7 @@ export class ChatState {
121
140
  this.channel = channel;
122
141
  }
123
142
  prepare(sql) {
124
- return this.db.prepare(sql.replace(/\bchat_(binding|pair|event|part|action|progress|runtime|room|meta|flow_action|flow_prompt|question_action|question_prompt|ask_action)\b/g, `${this.channel}_$1`));
143
+ return this.db.prepare(sql.replace(/\bchat_(binding|pair|event|part|action|progress|runtime|room|meta|flow_action|flow_prompt|flow_choice|flow_note|question_action|question_prompt|ask_action)\b/g, `${this.channel}_$1`));
125
144
  }
126
145
  /** Small transport facts a channel must remember per chat (a Teams service URL). Never credentials. */
127
146
  meta(installation, key) {
@@ -286,6 +305,12 @@ export class ChatState {
286
305
  if (Number(inserted.changes) && part.ask)
287
306
  for (const choice of [...part.ask.options.map((_, index) => index), null])
288
307
  this.prepare("INSERT INTO chat_ask_action(token,part,turn,choice,expires) VALUES(?,?,?,?,?)").run(randomBytes(16).toString("hex"), Number(inserted.lastInsertRowid), part.ask.turn, choice, new Date(now.getTime() + MATE_ASK_TTL_MS).toISOString());
308
+ if (Number(inserted.changes) && part.choose)
309
+ for (const one of part.choose.options)
310
+ this.prepare("INSERT INTO chat_flow_choice(token,part,card,entry,choice,label,expires) VALUES(?,?,?,?,?,?,?)").run(randomBytes(16).toString("hex"), Number(inserted.lastInsertRowid), part.choose.card, part.choose.entry, one.choice, one.label, new Date(now.getTime() + 7 * 86_400_000).toISOString());
311
+ if (Number(inserted.changes) && part.note)
312
+ for (const answer of ["yes", "no"])
313
+ this.prepare("INSERT INTO chat_flow_note(token,part,card,entry,held,answer,expires) VALUES(?,?,?,?,?,?,?)").run(randomBytes(16).toString("hex"), Number(inserted.lastInsertRowid), part.note.card, part.note.entry, part.note.held, answer, new Date(now.getTime() + 86_400_000).toISOString());
289
314
  if (Number(inserted.changes) && part.flow)
290
315
  for (const action of part.flow.actions)
291
316
  this.prepare("INSERT INTO chat_flow_action(token,part,card,entry,action,expires) VALUES(?,?,?,?,?,?)").run(randomBytes(16).toString("hex"), Number(inserted.lastInsertRowid), part.flow.card, part.flow.entry, action, new Date(now.getTime() + 7 * 86_400_000).toISOString());
@@ -307,7 +332,7 @@ export class ChatState {
307
332
  return !!this.prepare("SELECT 1 FROM chat_runtime WHERE installation=? AND owner=? AND lease_until>?").get(installation, owner, now.toISOString());
308
333
  }
309
334
  }
310
- export const chatSchema = (channel) => (CHAT_SCHEMA + CHAT_ROOM_SCHEMA + CHAT_FLOW_SCHEMA + CHAT_QUESTION_SCHEMA + CHAT_ASK_SCHEMA).replaceAll("chat_", `${channel}_`);
335
+ export const chatSchema = (channel) => (CHAT_SCHEMA + CHAT_ROOM_SCHEMA + CHAT_FLOW_SCHEMA + CHAT_FLOW_CHOICE_SCHEMA + CHAT_QUESTION_SCHEMA + CHAT_ASK_SCHEMA).replaceAll("chat_", `${channel}_`);
311
336
  export const chatTables = (channel) => CHAT_TABLES.map((name) => name.replace("chat_", `${channel}_`));
312
337
  export class ChatDeliveryError extends Error {
313
338
  code;
@@ -8,7 +8,7 @@ import { warmTurn } from "./chat-warmth.js";
8
8
  import { confirmMateProposal, dismissMateProposal, } from "./mate-doors.js";
9
9
  import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
10
10
  import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.js";
11
- import { answerChatFlowPrompt, applyChatFlowTap, flowDecisionParts } from "./chat-flow.js";
11
+ import { answerChatFlowPrompt, answerChoiceMessage, applyChatFlowTap, flowDecisionParts, flowSendParts } from "./chat-flow.js";
12
12
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
13
13
  import { triggerConfigOf } from "./flow-triggers.js";
14
14
  import { telegramProgressCard } from "./telegram-progress.js";
@@ -19,8 +19,8 @@ import { leadChannelOf } from "./lead-context.js";
19
19
  import { promiseChannelOf } from "./lead-commitments.js";
20
20
  import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
21
21
  import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
22
- import { isTelegramProgressNotification, proposalTaskOf, RESULT_SHOTS_KIND } from "./store.js";
23
- import { resultShotsFor } from "./result-shots.js";
22
+ import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
23
+ import { isShotsKind, resultShotsFor } from "./result-shots.js";
24
24
  import { messageTeammate } from "./teammate-desk.js";
25
25
  import { CHAT_APP_NAMES } from "./flow-triggers.js";
26
26
  export const chatObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
@@ -124,6 +124,9 @@ export async function processChatEvent(options) {
124
124
  // A flow decision asked for this person's next message (Edit, Send back): it is the draft or the note.
125
125
  if (answerChatFlowPrompt({ store, state, label: options.label }, event, binding, { text, ...(typeof input.originalLength === "number" ? { originalLength: input.originalLength } : {}) }, repos, nowOf(options)))
126
126
  return true;
127
+ // A "Person chooses" notice's thread reply is the note; the first other message after it is asked about (chat-flow.ts).
128
+ if (answerChoiceMessage({ store, state, label: options.label }, event, binding, { text, ...(typeof input.originalLength === "number" ? { originalLength: input.originalLength } : {}), lead: input.lead === true }, nowOf(options), repos))
129
+ return true;
127
130
  // A message to a teammate by name (v96), in someone's own chat with Toolroll: a card on its desk.
128
131
  if (event.channel === binding.channel) {
129
132
  const handed = messageTeammate(store, { who: binding.approver, repos, via: CHAT_APP_NAMES[state.channel] ?? "Chat" }, text, nowOf(options));
@@ -680,6 +683,17 @@ function planResultShots(options, binding, notification, repos, id, now) {
680
683
  // The result's message here: its finished-work batch (quiet), its progress card (every step), or the result's own
681
684
  // notice. The transport threads the screenshots under it once it is posted, and holds them until then.
682
685
  const follows = (() => {
686
+ // A flow's screenshots follow the "Send to me" or "Person chooses" notice they go with.
687
+ const visit = /^flow-shots:([1-9][0-9]*):([1-9][0-9]*)$/.exec(notification.dedupeKey);
688
+ if (visit !== null) {
689
+ for (const key of [`flow-send:${visit[1]}:${visit[2]}`, `flow-choose:${visit[1]}:${visit[2]}`]) {
690
+ const notice = store.handle.prepare("SELECT id FROM notification WHERE dedupe_key=?").get(key);
691
+ const part = notice === undefined ? undefined : state.prepare("SELECT id FROM chat_part WHERE event=? ORDER BY id DESC LIMIT 1").get(chatHash(`${state.channel}:notice:${binding.id}:${Number(notice["id"])}`));
692
+ if (part !== undefined)
693
+ return Number(part.id);
694
+ }
695
+ return null;
696
+ }
683
697
  const batch = notification.taskRef === null ? null : store.chatBatchMessageFor(`${state.channel}:${binding.id}`, notification.taskRef, notification.run);
684
698
  if (batch !== null)
685
699
  return Number(batch);
@@ -745,7 +759,7 @@ export async function planChatNotifications(options) {
745
759
  // Only when I'm needed (the default): the task's one card is edited in place, and a new message
746
760
  // follows only when this person is needed. Every step keeps the branches below unchanged.
747
761
  const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
748
- if (notification.kind === RESULT_SHOTS_KIND) {
762
+ if (isShotsKind(notification.kind)) {
749
763
  planResultShots(options, binding, notification, repos, id, now);
750
764
  return;
751
765
  }
@@ -833,6 +847,12 @@ export async function planChatNotifications(options) {
833
847
  state.plan(id, parts, now);
834
848
  }
835
849
  }
850
+ else if (personal && flowSendParts(store, notification) !== null) {
851
+ // A flow's "Send to me" (flow-send.ts): what was done, with its links as buttons.
852
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
853
+ ts: "", thread: "", payload: "{}", created: now.toISOString() });
854
+ state.plan(id, flowSendParts(store, notification), now);
855
+ }
836
856
  else if (notification.kind === "flow-card" && questionParts(store, notification, binding) !== null) {
837
857
  // A teammate's question (v93): its options and "Answer in words" on the notice, for the person it asks.
838
858
  state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
@@ -1,9 +1,15 @@
1
- import type { ChatBinding, ChatContent, ChatEvent, ChatState } from "./chat-delivery-state.js";
1
+ import { type ChatBinding, type ChatContent, type ChatEvent, type ChatState } from "./chat-delivery-state.js";
2
2
  import type { Store } from "./store.js";
3
3
  export type ChatFlowAction = "approve" | "edit" | "send-back";
4
4
  export declare const CHAT_FLOW_LABELS: Record<ChatFlowAction, string>;
5
5
  /** Words kept as written, line breaks and all; only control characters go. */
6
6
  export declare function chatFlowText(text: string): string;
7
+ /** A "Send to me" notice as chat parts: what was done, then its links; null when it isn't one. */
8
+ export declare function flowSendParts(store: Store, notification: {
9
+ dedupeKey: string;
10
+ subject: string;
11
+ body: string;
12
+ }): ChatContent[] | null;
7
13
  /**
8
14
  * A "flow-decision" notice as chat parts, or null when it isn't one (or the
9
15
  * card has already moved on, and there is nothing left to decide).
@@ -14,11 +20,13 @@ export declare function flowDecisionParts(store: Store, notification: {
14
20
  body: string;
15
21
  link: string | null;
16
22
  }): ChatContent[] | null;
17
- /** The live buttons on one part, in the order they were minted. */
23
+ /** The live buttons on one part, in the order they were minted: a decision's, a choice's options in their order, or
24
+ * "Use this as your note?" Yes / No. `key` is unique on the part (Slack names each button by it). */
18
25
  export declare function chatFlowButtons(state: ChatState, part: number, now: Date): Array<{
19
26
  token: string;
20
- action: ChatFlowAction;
27
+ action: ChatFlowAction | "choose" | "note";
21
28
  label: string;
29
+ key: string;
22
30
  }>;
23
31
  /**
24
32
  * A tapped flow button, inside the action's transaction. false when the
@@ -30,6 +38,21 @@ export declare function applyChatFlowTap(options: {
30
38
  state: ChatState;
31
39
  label: string;
32
40
  }, event: ChatEvent, binding: ChatBinding, token: string, repos: readonly string[], now: Date): boolean;
41
+ /**
42
+ * A message in the person's own chat while one of their choices waits. Only a reply in the Choose notice's thread (Slack,
43
+ * Discord) is the note. Any other message there is the lead's, except the first one after a choice that takes replies:
44
+ * that one is asked about, once — "Use this as your note?" — and only Yes makes it the note (No passes it on to the lead).
45
+ * false when the message isn't about a choice at all.
46
+ */
47
+ export declare function answerChoiceMessage(options: {
48
+ store: Store;
49
+ state: ChatState;
50
+ label: string;
51
+ }, event: ChatEvent, binding: ChatBinding, input: {
52
+ text: string;
53
+ originalLength?: number;
54
+ lead?: boolean;
55
+ }, now: Date, repos: readonly string[]): boolean;
33
56
  /**
34
57
  * A message from the person while a prompt is open: the new draft or the
35
58
  * note. false when no prompt is open (the message goes on as usual).