toolroll 0.9.20 → 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 (55) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/browser/workspace.css +1 -1
  3. package/dist/browser/workspace.js +22 -21
  4. package/dist/browser-workspace.d.ts +41 -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/flows-cli.js +11 -3
  27. package/dist/flows-ui.js +20 -4
  28. package/dist/flows.d.ts +27 -1
  29. package/dist/flows.js +104 -18
  30. package/dist/mate-tools.js +33 -10
  31. package/dist/operate.js +41 -0
  32. package/dist/project-delete.js +4 -0
  33. package/dist/project-demo.d.ts +12 -0
  34. package/dist/project-demo.js +71 -0
  35. package/dist/provider.d.ts +6 -0
  36. package/dist/provider.js +4 -1
  37. package/dist/report-summary.d.ts +9 -0
  38. package/dist/report-summary.js +9 -0
  39. package/dist/result-shots.d.ts +2 -0
  40. package/dist/result-shots.js +24 -5
  41. package/dist/scout-net.d.ts +22 -0
  42. package/dist/scout-net.js +214 -0
  43. package/dist/scout-report.d.ts +67 -0
  44. package/dist/scout-report.js +108 -1
  45. package/dist/scout.d.ts +42 -0
  46. package/dist/scout.js +225 -16
  47. package/dist/serve.js +43 -5
  48. package/dist/slack-chat.js +3 -3
  49. package/dist/store.d.ts +53 -0
  50. package/dist/store.js +95 -4
  51. package/dist/teams-chat.js +1 -1
  52. package/dist/telegram-flow.d.ts +16 -2
  53. package/dist/telegram-flow.js +58 -0
  54. package/dist/telegram.js +46 -10
  55. package/package.json +1 -1
package/dist/flows-ui.js CHANGED
@@ -6,6 +6,7 @@ import { parseSortDecision, sortChip } from "./flow-sort.js";
6
6
  import { flowSecretNames, readEmailSettings, sendingReady } from "./flow-actions.js";
7
7
  import { projectToolsOf, secretsSetFor, toolStanding } from "./project-tools.js";
8
8
  import { flowFingerprint } from "./flow-live.js";
9
+ import { flowChoiceAt, readFlowSend } from "./flow-send.js";
9
10
  import { FLOW_FILE_MAX_BYTES } from "./flow-share.js";
10
11
  import { googleConnected } from "./google-mail.js";
11
12
  import { mailboxReady } from "./mailbox.js";
@@ -20,7 +21,7 @@ export function flowsListHtml(store, flows, projects, csrf, canCreate, problem)
20
21
  const rows = flows.map(flow => {
21
22
  const cards = store.flowCards(flow.id, false);
22
23
  const definition = flowDefinitionOf(flow);
23
- const waiting = cards.filter(card => definition?.stages.find(one => one.id === card.stage)?.kind === "approval").length;
24
+ const waiting = cards.filter(card => ["approval", "choose"].includes(definition?.stages.find(one => one.id === card.stage)?.kind ?? "")).length;
24
25
  const buttons = store.flowTriggers(flow.id).filter(one => one.state === "active").flatMap(one => { const config = triggerConfigOf(one); return config?.kind === "button" ? [{ id: one.id, label: config.label }] : []; });
25
26
  const trouble = troubleWords(flowInsights(store, flow, new Date(), 7));
26
27
  return `<article class="card"><div class="flow-row"><h2><a href="/flows/${flow.id}">${e(flow.name)}</a></h2><span class="flow-counts">${e(projectName(flow.repo))} · ${cards.length} card${cards.length === 1 ? "" : "s"} in progress${waiting > 0 ? ` · ${waiting} waiting for a decision` : ""}${trouble === null ? "" : ` · ${e(trouble)}`}</span></div>` +
@@ -68,7 +69,8 @@ const historyText = (event, title, sorts = new Set()) => {
68
69
  case "approved": return `Approved${who}${note}`;
69
70
  case "sent-back": return `Sent back to ${title(event.toStage)}${who}${note}`;
70
71
  case "fail": return `Moved to ${title(event.toStage)} after a problem${note}`;
71
- case "cancelled": return `Cancelled${who}`;
72
+ // "Ignore" on a choice closes the card (flow-send.ts): said as such, with what was chosen and where.
73
+ case "cancelled": return event.note?.startsWith("Ignored") ? `Ignored${who}${event.note.slice("Ignored".length)}` : `Cancelled${who}`;
72
74
  case "ok": return event.fromStage !== null && sorts.has(event.fromStage) ? `Sorted into ${title(event.toStage)}` : `Moved on to ${title(event.toStage)}${note}`;
73
75
  default: return `Moved to ${title(event.toStage)}${who}${note}`;
74
76
  }
@@ -121,21 +123,34 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
121
123
  }
122
124
  }
123
125
  const asker = asked === null || asked.state !== "open" ? null : store.getTeammate(asked.teammate);
126
+ // "Person chooses" (flow-send.ts): what was sent, and the options, for the person it waits on; the latest send otherwise.
127
+ const choosing = card.state === "active" && stage?.kind === "choose" ? flowChoiceAt(store, card.id, card.entry) : null;
128
+ const latest = store.flowSends(card.id)[0] ?? null;
129
+ const sentContent = latest === null ? null : readFlowSend(latest.contentJson);
130
+ // On the card itself: where it came from (the card's title is right above), and only the links the panel doesn't already have.
131
+ const own = new Set([`/flows/${flow.id}?card=${card.id}`, ...(task === null ? [] : [`/t/${encodeURIComponent(task)}`])]);
132
+ const sentView = (content) => ({ title: content.from === undefined ? "What was done" : `From ${content.from}`, summary: content.summary,
133
+ links: content.links.map(one => ({ label: one.label, href: "url" in one ? one.url : one.path })).filter(one => !own.has(one.href)) });
124
134
  return {
125
135
  id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state, waiting: card.waiting,
126
136
  task: task === null ? null : { id: task, href: `/t/${encodeURIComponent(task)}` },
127
137
  createdBy: card.createdBy, updatedAt: card.updatedAt,
128
138
  canDecide,
129
- outputs: Object.entries(card.outputs).map(([id, text]) => ({ stage: id, title: title(id), text })),
139
+ // What a "Send to me" sent shows once, in its own box (sent), not again here; a report zone's items and full report
140
+ // (`<id>.items`, `<id>.report`) are for later zones, and its summary is shown.
141
+ outputs: Object.entries(card.outputs).filter(([id]) => !id.includes(".") && stages.find(one => one.id === id)?.kind !== "send").map(([id, text]) => ({ stage: id, title: title(id), text })),
130
142
  history: [...moves, ...owned].sort((a, b) => b.at.localeCompare(a.at)).slice(0, 30),
131
143
  source: card.source,
132
144
  owner: card.owner, watchers, watching: watchers.includes(viewer.name),
133
- mine: card.owner === viewer.name || watchers.includes(viewer.name) || canDecide,
145
+ mine: card.owner === viewer.name || watchers.includes(viewer.name) || canDecide || choosing?.person === viewer.name,
134
146
  comments: discussion.filter(one => one.kind === "comment").map(one => ({ id: one.id, author: one.author, body: one.body, mentions: one.mentions, at: one.at })),
135
147
  sorted: decision === null ? null : { chip: sortChip(decision), confident: decision.confident },
136
148
  draft: shown === null || card.state !== "active" || card.outputs[shown.id] === undefined ? null : { zone: shown.id, title: shown.title, text: card.outputs[shown.id] },
137
149
  deadline,
138
150
  handoff,
151
+ choose: choosing === null || sentContent === null || latest?.entry !== card.entry ? null : { entry: card.entry, ...sentView(sentContent), person: choosing.person, mine: choosing.person === viewer.name,
152
+ options: (sentContent.options ?? []).filter(one => choosing.stage.options?.[one.choice]?.label === one.label), reply: sentContent.reply === true },
153
+ sent: sentContent === null || (choosing !== null && latest?.entry === card.entry) ? null : { ...sentView(sentContent), person: latest.person, at: latest.createdAt },
139
154
  question: asked === null || asked.state !== "open" || asker === null ? null
140
155
  : { id: asked.id, from: labelOf(asker), question: asked.question, options: asked.options, askedOf: asked.askedOf, mine: asked.askedOf === viewer.name,
141
156
  call: pending === null ? null : { why: pending.why, rule: pending.result ?? "" } },
@@ -188,6 +203,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
188
203
  live: flowFingerprint(store, flow.id),
189
204
  canEdit: viewer.approver,
190
205
  approvers: store.listApprovers().map(one => one.name).filter(name => store.accountCanAccess(name, flow.repo)),
206
+ projects: [flow.repo, ...setup.repos.filter(one => one !== flow.repo)].map(path => ({ path, name: projectName(path) })),
191
207
  kinds: FLOW_STAGE_KINDS.map(kind => ({ kind, label: FLOW_KIND_WORDS[kind].label, about: FLOW_KIND_WORDS[kind].about })),
192
208
  colors: [...FLOW_COLORS],
193
209
  };
package/dist/flows.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const FLOW_STAGE_KINDS: readonly ["inbox", "task", "report", "approval", "check", "pull-request", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "done"];
1
+ export declare const FLOW_STAGE_KINDS: readonly ["inbox", "task", "report", "approval", "check", "pull-request", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "send", "choose", "done"];
2
2
  export type FlowStageKind = (typeof FLOW_STAGE_KINDS)[number];
3
3
  export declare const FLOW_COLORS: readonly ["slate", "blue", "violet", "amber", "green", "rose"];
4
4
  export type FlowColor = (typeof FLOW_COLORS)[number];
@@ -60,6 +60,15 @@ export type FlowWait = {
60
60
  /** How a Pull request zone merges once checks pass. */
61
61
  export declare const FLOW_MERGE_METHODS: readonly ["squash", "merge", "rebase"];
62
62
  export type FlowMergeMethod = (typeof FLOW_MERGE_METHODS)[number];
63
+ /** A choose zone's option: its button's words, and the zone it leads to (FLOW_END closes the card as Ignored). */
64
+ export type FlowChoice = {
65
+ label: string;
66
+ to: string;
67
+ };
68
+ /** Where a choose option that ends the card leads. */
69
+ export declare const FLOW_END = "end";
70
+ /** How many options a choose zone offers. */
71
+ export declare const CHOICES_MIN = 2, CHOICES_MAX = 4;
63
72
  /** A time limit on a zone (v91): after this long, the person it waits on is reminded, and a Holding or
64
73
  * "Person decides" zone can move the card on (`to`). */
65
74
  export type FlowLimit = {
@@ -109,6 +118,10 @@ export type FlowStage = {
109
118
  teammate?: string;
110
119
  /** v96: a "Teammate handles it" zone sends what the teammate writes back to whoever asked (the person who added the card, its chat thread, or the teammate's manager). */
111
120
  reply?: boolean;
121
+ /** choose: the buttons the person picks from, in order. Its onFail is where a reply goes, with the reply as {{note}}. */
122
+ options?: FlowChoice[];
123
+ /** task: the project it builds in, when not the flow's own (one the flow's owner may file in). */
124
+ repo?: string;
112
125
  /** pull-request: merge once checks pass, this way. Only allowed after a "Person decides" zone, and a card merges only when a person approved it after it was built. */
113
126
  merge?: FlowMergeMethod;
114
127
  /** Where a card goes when this zone's step succeeds, and when it fails or is sent back (sort: when it isn't sure). */
@@ -156,6 +169,10 @@ export declare function validateFlowDefinition(input: unknown, options?: {
156
169
  }): FlowDefinition;
157
170
  /** The zones a card can reach from `start` without passing through a zone `stop` picks (those zones aren't included). */
158
171
  export declare function reachableWithout(stages: readonly FlowStage[], start: string, stop: (stage: FlowStage) => boolean): Set<string>;
172
+ /** The zones a choose zone's options lead to (an option that ends the card leads to none). */
173
+ export declare function choiceTargets(stage: Pick<FlowStage, "options">): string[];
174
+ /** Where a reply to a choice goes, as its {{note}}: the zone's reply path, else its first option that leads somewhere; null when none does. */
175
+ export declare function replyTarget(stage: Pick<FlowStage, "options" | "onFail">): string | null;
159
176
  /** What a card's work is held to: the zones' steps and paths, never where they sit on the canvas. */
160
177
  export declare function flowDigest(definition: FlowDefinition): string;
161
178
  type FlowFillCard = {
@@ -243,6 +260,15 @@ export type FlowStepInput = {
243
260
  teammate?: string;
244
261
  /** v96: a teammate step sends what it writes back to whoever asked. */
245
262
  reply?: boolean;
263
+ /** choose: 2 to 4 buttons, each with its words and the step it goes to ("end" ignores the card); where a reply goes instead
264
+ * (the reply is its {{note}}; the nearest earlier build or research step when left out). ifNoReply (with remindAfter) moves it on. */
265
+ options?: {
266
+ label?: string;
267
+ goesTo?: string;
268
+ }[];
269
+ ifReplied?: string;
270
+ /** task: the project it builds in (path), when not the flow's own. */
271
+ repo?: string;
246
272
  next?: string;
247
273
  ifFails?: string;
248
274
  ifNotSure?: string;
package/dist/flows.js CHANGED
@@ -35,16 +35,27 @@
35
35
  * waits for CI, and moves on when it passes or down the
36
36
  * failure path, naming the failing check, when it fails. It
37
37
  * can merge once checks pass, only after a person approved.
38
+ * - send — "Send to me": sends the card's person (its owner, else the
39
+ * flow's) what the step before produced — its summary, links
40
+ * and images — in each chat app they paired and on the card,
41
+ * then moves on.
42
+ * - choose — "Person chooses": the same content, with 2 to 4 buttons the
43
+ * flow names (each leads to a zone, or ends the card as
44
+ * Ignored). A reply instead is the {{note}} for where replies go.
38
45
  * - done — the end.
39
46
  *
40
47
  * The engine is deterministic and model-free: it runs in the worker's pass
41
48
  * beside routines, and no card ever skips an approval the task itself needs.
42
49
  */
43
50
  import { createHash } from "node:crypto";
44
- export const FLOW_STAGE_KINDS = ["inbox", "task", "report", "approval", "check", "pull-request", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "done"];
51
+ export const FLOW_STAGE_KINDS = ["inbox", "task", "report", "approval", "check", "pull-request", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "send", "choose", "done"];
45
52
  export const FLOW_COLORS = ["slate", "blue", "violet", "amber", "green", "rose"];
46
53
  /** How a Pull request zone merges once checks pass. */
47
54
  export const FLOW_MERGE_METHODS = ["squash", "merge", "rebase"];
55
+ /** Where a choose option that ends the card leads. */
56
+ export const FLOW_END = "end";
57
+ /** How many options a choose zone offers. */
58
+ export const CHOICES_MIN = 2, CHOICES_MAX = 4;
48
59
  /** What each kind is called and does, in the words the canvas uses. */
49
60
  export const FLOW_KIND_WORDS = {
50
61
  inbox: { label: "Holding", about: "Cards wait here until someone moves them." },
@@ -62,6 +73,8 @@ export const FLOW_KIND_WORDS = {
62
73
  sort: { label: "Sort", about: "Jev reads the card in under a second and sends it where its answer leads. Cards it isn't sure about take the not-sure path." },
63
74
  wait: { label: "Wait", about: "Waits for a reply to the card's email, or for a set time. A reply moves the card on; if none comes in time, it takes the no-reply path." },
64
75
  teammate: { label: "Teammate handles it", about: "An AI teammate reads the card, decides where it goes within its rules, and writes what the next zones send. When its rules say to ask, it asks you first." },
76
+ send: { label: "Send to me", about: "Sends the card's owner what the step before produced: its summary, links and any screenshots, in each chat app they use and on the card. Then moves on." },
77
+ choose: { label: "Person chooses", about: "Sends the card's owner what was done with 2 to 4 buttons you name, in their chat apps and here. Each leads to a zone or ignores the card; a reply instead goes where replies go, as the note." },
65
78
  done: { label: "Done", about: "The end of the flow." },
66
79
  };
67
80
  const ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
@@ -264,8 +277,8 @@ function validateLimit(input, kind, title) {
264
277
  if (minutes === null)
265
278
  throw new Error(`Zone ${title}: say how long a card may wait, from 1 minute to 30 days.`);
266
279
  const to = text(raw["to"], 32);
267
- if (to !== null && kind !== "inbox" && kind !== "approval")
268
- throw new Error(`Zone ${title}: only Holding and "Person decides" zones move a card on when it waits too long; other zones remind.`);
280
+ if (to !== null && kind !== "inbox" && kind !== "approval" && kind !== "choose")
281
+ throw new Error(`Zone ${title}: only Holding, "Person decides" and "Person chooses" zones move a card on when it waits too long; other zones remind.`);
269
282
  return { minutes, to };
270
283
  }
271
284
  /** Who decides at an approval zone: its named person, the flow's owner, or null for anyone who approves on the project. */
@@ -314,8 +327,10 @@ export function validateFlowDefinition(input, options = {}) {
314
327
  ...((kind === "approval" || kind === "teammate") && stage["teammate"] !== undefined && stage["teammate"] !== null && stage["teammate"] !== "" ? { teammate: validateTeammate(stage["teammate"], title) } : {}),
315
328
  ...(kind === "teammate" ? validateRoutes(stage, title) : {}),
316
329
  ...(kind === "teammate" && stage["reply"] === true ? { reply: true } : {}),
330
+ ...(kind === "choose" ? { options: validateChoices(stage["options"], title) } : {}),
331
+ ...(kind === "task" && stage["repo"] !== undefined && stage["repo"] !== null && stage["repo"] !== "" ? { repo: validateRepo(stage["repo"], title) } : {}),
317
332
  ...(() => { const limit = validateLimit(stage["limit"], kind, title); return limit === null ? {} : { limit }; })(),
318
- next: kind === "sort" ? null : text(stage["next"], 32),
333
+ next: kind === "sort" || kind === "choose" ? null : text(stage["next"], 32),
319
334
  onFail: text(stage["onFail"], 32),
320
335
  };
321
336
  });
@@ -326,9 +341,13 @@ export function validateFlowDefinition(input, options = {}) {
326
341
  ids.add(stage.id);
327
342
  }
328
343
  for (const stage of stages) {
329
- for (const target of [stage.next, stage.onFail, stage.limit?.to ?? null, ...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? [])])
344
+ for (const target of [stage.next, stage.onFail, stage.limit?.to ?? null, ...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? []), ...choiceTargets(stage)])
330
345
  if (target !== null && !ids.has(target))
331
346
  throw new Error(`Zone ${stage.title} points at a zone that no longer exists.`);
347
+ if (stage.options !== undefined && ids.has(FLOW_END) && stage.options.some(one => one.to === FLOW_END))
348
+ throw new Error(`Zone ${stage.title}: an option that ends the card can't be told apart from the zone called ${FLOW_END}. Rename that zone.`);
349
+ if (stage.options?.some(one => one.to === stage.id))
350
+ throw new Error(`Zone ${stage.title}: an option can't send cards back into the same zone.`);
332
351
  if (stage.limit?.to === stage.id)
333
352
  throw new Error(`Zone ${stage.title}: a card that waits too long can't move back into the same zone.`);
334
353
  if (stage.routes?.some(one => one.to === stage.id))
@@ -380,10 +399,43 @@ export function reachableWithout(stages, start, stop) {
380
399
  if (stage === undefined || seen.has(stage.id) || stop(stage))
381
400
  continue;
382
401
  seen.add(stage.id);
383
- queue.push(...[stage.next, stage.onFail, stage.limit?.to ?? null, ...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? [])].filter((one) => one !== null));
402
+ queue.push(...[stage.next, stage.onFail, stage.limit?.to ?? null, ...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? []), ...choiceTargets(stage)].filter((one) => one !== null));
384
403
  }
385
404
  return seen;
386
405
  }
406
+ /** The zones a choose zone's options lead to (an option that ends the card leads to none). */
407
+ export function choiceTargets(stage) {
408
+ return (stage.options ?? []).map(one => one.to).filter(to => to !== FLOW_END);
409
+ }
410
+ /** Where a reply to a choice goes, as its {{note}}: the zone's reply path, else its first option that leads somewhere; null when none does. */
411
+ export function replyTarget(stage) {
412
+ return stage.onFail ?? choiceTargets(stage)[0] ?? null;
413
+ }
414
+ /** A choose zone's options, checked: 2 to 4, each with short words of its own and where it leads. */
415
+ function validateChoices(input, title) {
416
+ const raw = Array.isArray(input) ? input : [];
417
+ if (raw.length < CHOICES_MIN || raw.length > CHOICES_MAX)
418
+ throw new Error(`Zone ${title}: give it ${CHOICES_MIN} to ${CHOICES_MAX} options.`);
419
+ const seen = new Set();
420
+ return raw.map(one => {
421
+ const row = (one ?? {});
422
+ const label = text(row["label"], 40);
423
+ const to = text(row["to"], 32);
424
+ if (label === null || to === null)
425
+ throw new Error(`Zone ${title}: each option needs a few words and where it leads.`);
426
+ if (seen.has(label.toLowerCase()))
427
+ throw new Error(`Zone ${title}: two options are called ${label}.`);
428
+ seen.add(label.toLowerCase());
429
+ return { label, to };
430
+ });
431
+ }
432
+ /** A build zone's project, when it isn't the flow's: its path. Whether the flow's owner may file there is checked when work is filed. */
433
+ function validateRepo(value, title) {
434
+ const repo = text(value, 1000);
435
+ if (repo === null)
436
+ throw new Error(`Zone ${title}: choose the project it builds in.`);
437
+ return repo;
438
+ }
387
439
  /** A teammate's handle on a zone (v92): which teammate works it. Whether it exists is the project's to say, when a card arrives. */
388
440
  function validateTeammate(value, title) {
389
441
  if (typeof value !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(value))
@@ -440,7 +492,8 @@ export function flowDigest(definition) {
440
492
  const terms = definition.stages.map(({ zone: _zone, ...rest }) => rest);
441
493
  return createHash("sha256").update(JSON.stringify({ start: definition.start, terms })).digest("hex").slice(0, 32);
442
494
  }
443
- const FILLED = /\{\{\s*(card\.title|card\.description|card\.email|note|stage\.([a-z0-9-]+))\s*\}\}/g;
495
+ /** {{stage.<id>}} is an earlier zone's output; a report zone also fills {{stage.<id>.items}} and {{stage.<id>.report}}. */
496
+ const FILLED = /\{\{\s*(card\.title|card\.description|card\.email|note|stage\.([a-z0-9-]+(?:\.(?:items|report))?))\s*\}\}/g;
444
497
  /** Fill a zone's text from the card: title, description, the latest note and earlier zones' reports. */
445
498
  export function fillFlowText(template, card, encode = value => value) {
446
499
  return template.replace(FILLED, (_match, key, stage) => encode(fillOne(key, stage, card))).trim();
@@ -524,7 +577,7 @@ function fillOne(key, stage, card) {
524
577
  return stage === undefined ? "" : card.outputs[stage] ?? "";
525
578
  }
526
579
  }
527
- const KIND_COLORS = { inbox: "slate", task: "blue", report: "violet", approval: "amber", check: "blue", "pull-request": "blue", update: "green", notify: "green", sort: "violet", draft: "violet", request: "blue", email: "green", tool: "blue", wait: "slate", teammate: "violet", done: "green" };
580
+ const KIND_COLORS = { inbox: "slate", task: "blue", report: "violet", approval: "amber", check: "blue", "pull-request": "blue", update: "green", notify: "green", sort: "violet", draft: "violet", request: "blue", email: "green", tool: "blue", wait: "slate", teammate: "violet", send: "green", choose: "amber", done: "green" };
528
581
  /** A step id as the lead may write it (sort_by_hand, Sort-By-Hand) in the one form zones use. */
529
582
  const idOf = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
530
583
  const slugOf = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 28) || "zone";
@@ -617,18 +670,21 @@ export function flowFromSteps(input, previous = null) {
617
670
  drafts.forEach(({ step, old, id, title, kind }, index) => {
618
671
  const earlier = stages.slice();
619
672
  const following = drafts.slice(index + 1).find(() => true) ?? null;
620
- const next = kind === "done" || kind === "sort" ? null
673
+ const next = kind === "done" || kind === "sort" || kind === "choose" ? null
621
674
  : typeof step.next === "string" && step.next.trim() !== "" ? find(step.next, title)
622
675
  : following?.id ?? drafts.find(one => one.kind === "done").id;
623
676
  const keptFail = old?.onFail !== null && old?.onFail !== undefined && drafts.some(one => one.id === old.onFail) ? old.onFail : null;
624
677
  const worker = [...earlier].reverse().find(one => one.kind === "task" || one.kind === "report");
625
678
  const notSure = kind === "sort" && typeof step.ifNotSure === "string" && step.ifNotSure.trim() !== "" ? step.ifNotSure
626
- : kind === "wait" && typeof step.ifNoReply === "string" && step.ifNoReply.trim() !== "" ? step.ifNoReply : step.ifFails;
679
+ : kind === "wait" && typeof step.ifNoReply === "string" && step.ifNoReply.trim() !== "" ? step.ifNoReply
680
+ : kind === "choose" ? step.ifReplied : step.ifFails;
627
681
  const onFail = kind === "done" ? null
628
682
  : typeof notSure === "string" && notSure.trim() !== "" ? find(notSure, title)
629
683
  : keptFail ?? (kind === "approval" ? worker?.id ?? (drafts[0].id === id ? null : drafts[0].id)
630
- // Red CI goes back to the build before it, carrying the failing check.
631
- : kind === "pull-request" ? [...earlier].reverse().find(one => one.kind === "task")?.id ?? null : null);
684
+ // A reply to a choice says what to change: it goes back to the work before it, as its note.
685
+ : kind === "choose" ? worker?.id ?? null
686
+ // Red CI goes back to the build before it, carrying the failing check.
687
+ : kind === "pull-request" ? [...earlier].reverse().find(one => one.kind === "task")?.id ?? null : null);
632
688
  // "owner": whoever owns the flow when the card arrives.
633
689
  const toOwner = kind === "approval" && (step.decider === undefined ? old?.toOwner === true : typeof step.decider === "string" && /^(owner|the owner|flow owner|the flow owner|the flow's owner)$/i.test(step.decider.trim()));
634
690
  const approver = kind !== "approval" || toOwner ? null
@@ -636,7 +692,7 @@ export function flowFromSteps(input, previous = null) {
636
692
  : step.decider === null || /^(anyone|any approver|anybody)$/i.test(step.decider.trim()) ? null : step.decider.trim();
637
693
  stages.push({
638
694
  id, title, kind,
639
- zone: old?.zone ?? { x: 0, y: 0, w: 260, h: kind === "done" || kind === "notify" ? 220 : 300, color: KIND_COLORS[kind] },
695
+ zone: old?.zone ?? { x: 0, y: 0, w: 260, h: kind === "done" || kind === "notify" || kind === "send" ? 220 : 300, color: KIND_COLORS[kind] },
640
696
  // A kept step on our default instructions gets the default again, so it reads any research step added before it.
641
697
  instructions: kind === "teammate" ? step.instructions?.trim() || old?.instructions || "Read the card and decide what happens next."
642
698
  : kind === "draft" ? step.instructions?.trim() || old?.instructions || DRAFT_DEFAULT
@@ -666,15 +722,24 @@ export function flowFromSteps(input, previous = null) {
666
722
  ...((kind === "approval" || kind === "teammate") && (step.teammate === undefined ? old?.teammate !== undefined : !/^(nobody|none|no one)$/i.test(step.teammate.trim())) ? { teammate: idOf(step.teammate ?? old.teammate) } : {}),
667
723
  ...(kind === "teammate" ? step.routes !== undefined ? step.routes.length === 0 ? {} : { routes: step.routes.map(one => ({ answer: String(one.answer ?? "").trim(), to: find(String(one.goesTo ?? ""), title) })) } : old?.routes === undefined ? {} : { routes: old.routes } : {}),
668
724
  ...(kind === "teammate" && (step.reply ?? old?.reply) === true ? { reply: true } : {}),
669
- ...(() => { const limit = kind === "wait" || kind === "done" ? null : limitFromStep(step, old?.limit ?? null, title, ref => find(ref, title)); return limit === null ? {} : { limit }; })(),
725
+ ...(kind === "choose" ? { options: choicesFromStep(step, old?.options ?? null, title, ref => find(ref, title)) } : {}),
726
+ ...(kind === "task" && (step.repo ?? old?.repo) !== undefined && (step.repo ?? old?.repo).trim() !== "" ? { repo: (step.repo ?? old?.repo).trim() } : {}),
727
+ ...(() => {
728
+ // "If no reply" on a choice is where it moves once the reminder comes.
729
+ const asked = kind === "choose" && step.thenMoveTo === undefined && typeof step.ifNoReply === "string" ? { ...step, thenMoveTo: step.ifNoReply } : step;
730
+ if (kind === "choose" && asked.thenMoveTo !== undefined && asked.remindAfter === undefined && old?.limit === undefined)
731
+ throw new Error(`Step ${title}: say how long to wait for a choice first, with remindAfter (like "2 days").`);
732
+ const limit = kind === "wait" || kind === "done" ? null : limitFromStep(asked, old?.limit ?? null, title, ref => find(ref, title));
733
+ return limit === null ? {} : { limit };
734
+ })(),
670
735
  next, onFail,
671
736
  });
672
737
  });
673
738
  // {{stage.<ref>}} in a step's words names a step as the lead wrote it (draftReply, Draft reply):
674
739
  // it is rewritten to that step's id, the same way the steps themselves are named.
675
- const refs = (text) => text.replace(/\{\{\s*stage\.([A-Za-z0-9_ -]{1,60}?)\s*\}\}/g, (whole, ref) => {
740
+ const refs = (text) => text.replace(/\{\{\s*stage\.([A-Za-z0-9_ -]{1,60}?)(\.items|\.report)?\s*\}\}/g, (whole, ref, part) => {
676
741
  const hit = drafts.find(one => one.id === ref) ?? drafts.find(one => one.id === idOf(ref) || one.id.replace(/-/g, "") === idOf(ref).replace(/-/g, "") || one.title.toLowerCase() === ref.trim().toLowerCase());
677
- return hit === undefined ? whole : `{{stage.${hit.id}}}`;
742
+ return hit === undefined ? whole : `{{stage.${hit.id}${part ?? ""}}}`;
678
743
  });
679
744
  for (const stage of stages) {
680
745
  if (stage.instructions !== null)
@@ -692,14 +757,14 @@ export function flowFromSteps(input, previous = null) {
692
757
  // A new flow that branches (a sort, or a script's answers) is laid out in columns by step instead,
693
758
  // each branch below the one before, so no arrow crosses a zone.
694
759
  const placed = stages.filter(one => kept.get(one.id)?.zone === one.zone).map(one => one.zone);
695
- const branches = previous === null && stages.some(one => (one.sort?.answers.length ?? 0) > 0 || (one.routes?.length ?? 0) > 0);
760
+ const branches = previous === null && stages.some(one => (one.sort?.answers.length ?? 0) > 0 || (one.routes?.length ?? 0) > 0 || choiceTargets(one).length > 0);
696
761
  const columns = new Map();
697
762
  if (branches) {
698
763
  const queue = [stages[0].id];
699
764
  columns.set(stages[0].id, 0);
700
765
  while (queue.length > 0) {
701
766
  const id = queue.shift(), stage = stages.find(one => one.id === id);
702
- for (const to of [...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? []), stage.next, stage.onFail]) {
767
+ for (const to of [...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? []), ...choiceTargets(stage), stage.next, stage.onFail]) {
703
768
  if (to === null || to === "" || columns.has(to))
704
769
  continue;
705
770
  columns.set(to, columns.get(id) + 1);
@@ -735,6 +800,19 @@ export function flowFromSteps(input, previous = null) {
735
800
  });
736
801
  return validateFlowDefinition({ version: 1, start: stages[0].id, stages });
737
802
  }
803
+ /** A choose step's options: each with its words and the step it goes to ("end", or no step, ignores the card); kept ones carry over. */
804
+ function choicesFromStep(step, old, title, find) {
805
+ if (!Array.isArray(step.options) || step.options.length === 0) {
806
+ if (old !== null)
807
+ return old;
808
+ throw new Error(`Step ${title}: give it ${CHOICES_MIN} to ${CHOICES_MAX} options, each with a label and the step it goes to (or "end").`);
809
+ }
810
+ return step.options.map(one => {
811
+ const label = String(one?.label ?? "").trim();
812
+ const goes = typeof one?.goesTo === "string" ? one.goesTo.trim() : "";
813
+ return { label, to: goes === "" || /^(end|ignore|ignored|stop|close)$/i.test(goes) ? FLOW_END : find(goes) };
814
+ });
815
+ }
738
816
  /** A sort step as the lead describes it: answers name the steps they go to; what it leaves out carries over from the zone it keeps. */
739
817
  function sortFromStep(step, old, find) {
740
818
  const answers = Array.isArray(step.answers) && step.answers.length > 0
@@ -767,6 +845,8 @@ export function flowTerms(definition, previous) {
767
845
  }
768
846
  if (stage.kind === "task" && stage.planning !== "auto")
769
847
  lines.push(stage.planning === "required" ? "Plans first." : "Builds without a plan.");
848
+ if (stage.kind === "task" && stage.repo !== undefined)
849
+ lines.push(`Builds in another project: ${stage.repo}`);
770
850
  if (stage.kind === "draft")
771
851
  lines.push(`Claude writes: ${plain(stage.instructions ?? "")}`, `Then → ${to(stage.next)}`);
772
852
  else if (stage.kind === "request" && stage.request !== undefined) {
@@ -783,6 +863,10 @@ export function flowTerms(definition, previous) {
783
863
  lines.push(`The AI teammate ${stage.teammate ?? "(none)"} handles it${stage.instructions === null ? "" : `: ${plain(stage.instructions)}`}`, ...(stage.routes === undefined || stage.routes.length === 0 ? [`Then → ${to(stage.next)}`] : stage.routes.map(one => `${one.answer} → ${to(one.to)}`)), `It asks the flow's owner when its rules say to.${stage.reply === true ? " It sends what it writes back to whoever asked." : ""}${stage.onFail === null ? "" : ` If it can't → ${to(stage.onFail)}`}`);
784
864
  else if (stage.kind === "notify")
785
865
  lines.push(`Posts: ${plain(stage.message ?? "")}`, `Then → ${to(stage.next)}`);
866
+ else if (stage.kind === "send")
867
+ lines.push("Sends the card's owner (or the flow's) what the step before produced: its summary, links and any screenshots, in each chat app they use.", `Then → ${to(stage.next)}`);
868
+ else if (stage.kind === "choose")
869
+ lines.push("Sends the card's owner (or the flow's) what the step before produced, and asks them to choose:", ...(stage.options ?? []).map(one => `${one.label} → ${one.to === FLOW_END ? "ignores the card." : to(one.to)}`), `Or a reply with what they'd change → ${replyTarget(stage) === null ? "not possible." : to(replyTarget(stage))}`);
786
870
  else if (stage.kind === "update")
787
871
  lines.push(`Comments on the issue the card came from: ${plain(stage.message ?? "")}${stage.close === true ? " Then closes it (Linear: moves it to done)." : ""}`, `Then → ${to(stage.next)}${stage.onFail === null ? "" : ` If it can't → ${to(stage.onFail)}`}`);
788
872
  else if (stage.kind === "sort" && stage.sort !== null) {
@@ -835,6 +919,8 @@ export function flowTerms(definition, previous) {
835
919
  terms.push(`Pull request steps push the card's branch to GitHub and open a pull request under this project's pull request setup.${definition.stages.some(one => one.merge !== undefined) ? " A merge happens only after a person approves the card; nothing merges without one." : " Nothing merges on its own."}`);
836
920
  if (definition.stages.some(one => one.kind === "draft"))
837
921
  terms.push("Draft steps send each card's text to Claude through the lead chat's sign-in. Nothing a draft writes is sent until a later step sends it.");
922
+ if (definition.stages.some(one => one.kind === "task" && one.repo !== undefined))
923
+ terms.push("A build step in another project files its task there, only when the flow's owner may file work in that project.");
838
924
  if (definition.stages.some(one => one.teammate !== undefined))
839
925
  terms.push("AI teammates decide and act within their soul files' rules, reading each card through Claude on this computer's sign-in; they never approve code tasks or merges.");
840
926
  return terms;
@@ -46,6 +46,7 @@ import { agentsSummary, chosenWords, isRiskLevel, PHASES, postureWords, RISK_CHO
46
46
  import { TOOL_CATALOG, discoverTools, projectToolsOf, secretsSetFor, toolCommandLine, toolStanding } from "./project-tools.js";
47
47
  import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_STAGE_KINDS, FLOW_TEMPLATES, flowFromSteps, stepsFor } from "./flows.js";
48
48
  import { flowDefinitionOf } from "./flow-engine.js";
49
+ import { flowPersonOf } from "./flow-send.js";
49
50
  import { flowInsights } from "./flow-insights.js";
50
51
  import { describeTrigger, FLOW_TRIGGER_KINDS, triggerConfigOf } from "./flow-triggers.js";
51
52
  export const MATE_MAX_PROPOSALS_PER_TURN = 5;
@@ -796,8 +797,11 @@ export const MATE_TOOLS = [
796
797
  inputSchema: schema({ repo: REPO_ARG, flow: { type: "integer", minimum: 1 }, card: { type: "integer", minimum: 1 } }),
797
798
  handle: (ctx, args) => {
798
799
  const reachable = (repo) => ctx.who.repos.includes(repo) && ctx.store.accountCanAccess(ctx.who.name, repo);
799
- const needsYou = (flow, definition, stage) => {
800
- const at = definition?.stages.find(one => one.id === stage);
800
+ const needsYou = (flow, definition, card) => {
801
+ const at = definition?.stages.find(one => one.id === card.stage);
802
+ // A "Person chooses" zone waits for the card's person; a "Person decides" one for its decider.
803
+ if (at?.kind === "choose")
804
+ return flowPersonOf(card, flow) === ctx.who.name;
801
805
  if (at?.kind !== "approval")
802
806
  return false;
803
807
  const decider = deciderOf(at, flow);
@@ -832,15 +836,19 @@ export const MATE_TOOLS = [
832
836
  ...(stage.merge === undefined ? {} : { merge: stage.merge }),
833
837
  ...(stage.teammate === undefined ? {} : { teammate: stage.teammate }),
834
838
  ...(stage.kind === "teammate" && stage.routes !== undefined ? { routes: stage.routes.map(one => ({ answer: one.answer, goesTo: titleOf(one.to) })) } : {}),
839
+ ...(stage.kind === "choose" ? { options: (stage.options ?? []).map((one, index) => ({ choice: index + 1, label: one.label, goesTo: one.to === "end" ? "end" : titleOf(one.to) })) } : {}),
840
+ ...(stage.kind === "task" && stage.repo !== undefined ? { repo: repoIdOf(ctx.who, stage.repo) ?? "another project" } : {}),
835
841
  ...(stage.limit === undefined ? {} : { remindAfter: durationWords(stage.limit.minutes), ...(stage.limit.to === null ? {} : { thenMoveTo: titleOf(stage.limit.to) }) }),
836
- next: titleOf(stage.next), ...(stage.kind === "sort" ? { ifNotSure: titleOf(stage.onFail) } : stage.kind === "wait" ? { ifNoReply: titleOf(stage.onFail) } : { ifFails: titleOf(stage.onFail) }),
842
+ next: titleOf(stage.next), ...(stage.kind === "sort" ? { ifNotSure: titleOf(stage.onFail) } : stage.kind === "wait" ? { ifNoReply: titleOf(stage.onFail) } : stage.kind === "choose" ? { ifReplied: titleOf(stage.onFail) } : { ifFails: titleOf(stage.onFail) }),
837
843
  })),
838
844
  cards: [...active, ...finished].map(card => ({
839
845
  card: card.id, title: card.title, ...(card.description === null ? {} : { description: card.description.slice(0, 300) }),
840
- at: titleOf(card.stage) ?? card.stage, state: card.state, needsYou: card.state === "active" && needsYou(flow, definition, card.stage),
846
+ at: titleOf(card.stage) ?? card.stage, state: card.state, needsYou: card.state === "active" && needsYou(flow, definition, card),
841
847
  // Names never reach the model; a decision says whose it is in its own terms.
842
- waiting: card.state !== "active" || definition.stages.find(one => one.id === card.stage)?.kind !== "approval" || card.waiting === null ? card.waiting
843
- : needsYou(flow, definition, card.stage) ? "Waiting for you to approve or send it back" : "Waiting for someone else to decide",
848
+ waiting: card.state === "active" && definition.stages.find(one => one.id === card.stage)?.kind === "choose" && card.waiting !== null
849
+ ? (flowPersonOf(card, flow) === ctx.who.name ? "Waiting for you to choose (operation choose: choice by number, or note to reply)" : "Waiting for someone else to choose")
850
+ : card.state !== "active" || definition.stages.find(one => one.id === card.stage)?.kind !== "approval" || card.waiting === null ? card.waiting
851
+ : needsYou(flow, definition, card) ? "Waiting for you to approve or send it back" : "Waiting for someone else to decide",
844
852
  task: card.task ?? card.primaryTask, ...(card.note === null ? {} : { lastNote: card.note.slice(0, 300) }),
845
853
  // Names never reach the model: people read as you or a teammate.
846
854
  owner: card.owner === null ? null : card.owner === ctx.who.name ? "you" : "a teammate", following: ctx.store.flowCardWatchers(card.id).includes(ctx.who.name),
@@ -867,7 +875,7 @@ export const MATE_TOOLS = [
867
875
  const definition = flowDefinitionOf(flow);
868
876
  const cards = ctx.store.flowCards(flow.id, false);
869
877
  return { flow: flow.id, name: flow.name, project: repoIdOf(ctx.who, flow.repo), steps: definition === null ? "can't be read" : definition.stages.map(one => one.title).join(" → "),
870
- cards: cards.length, needYou: cards.filter(card => needsYou(flow, definition, card.stage)).length, triggers: ctx.store.flowTriggers(flow.id).length };
878
+ cards: cards.length, needYou: cards.filter(card => needsYou(flow, definition, card)).length, triggers: ctx.store.flowTriggers(flow.id).length };
871
879
  }),
872
880
  templates: FLOW_TEMPLATES.map(one => ({ template: one.id, about: one.about })),
873
881
  // Scripts belong to a project, not a flow: they exist (and can be saved) before any flow does.
@@ -878,9 +886,9 @@ export const MATE_TOOLS = [
878
886
  },
879
887
  {
880
888
  name: "propose_flow",
881
- description: "Draft a flow change as a card the operator confirms. To make a flow, create with a template or the steps in plain names; leave instructions out unless the operator gave them, and use decider 'me' when they decide. To change one, edit with the whole step list, keeping existing steps by id. For cards use add_card, move_card, approve, send_back with the operator's note, or cancel_card; find the card by what it is about; comment (with @name to ping someone), assign and follow here too. For triage, spam, lead, effort or exception routing, start from the matching template, with the sort as the first step. Use remindAfter and thenMoveTo for follow-ups and decisions that stall. The Issues to PRs template runs GitHub issues labelled toolroll through build, approval, pull request and a comment on the issue. When the operator asks for something to happen every time (fix CI when it fails, turn labelled issues into tasks, queue work overnight, review what went wrong every morning), offer the matching starter; each is one yes, and none merges without a person. When someone new wants a support desk, bug triage, sales follow-up or requests handled, a kit sets up the teammate and its flow in one card. Teammates: propose_teammate adds one (from a template: support, sales, ops, triage), changes one rule section (edit_section), pauses, resumes or removes it, passes it a note, or answers its question for the operator; put it to work with a flow here. When the operator says something like 'Maya can approve refunds up to $100 now', change that section of her soul file; when it's for this week or a one-off, pass it as a note. Teammates use the project's tools under a rule per action (do it, ask first: the operator approves each exact call, never, or up to a limit on a number); propose_teammate use_tool, stop_tool and tool_rule change them, and when a rule has a limit also change the soul file so its words agree. forget or edit_memory when the operator says a teammate has something wrong; a rule change it suggests is a question for the operator, answered only as they say. propose_teammate add_routine puts a card on its desk on a schedule; propose_teammate undo undoes a tool call only when its action has an undo set (tool_rule undoWith). Actions: starter: switch on a starter flow in a project (repo, starter: ci-fix files a fix task when CI fails on the main branch, issue-task makes a task of each GitHub issue labelled toolroll, overnight holds cards added in the day until 22:00 and leaves results for the morning, plane-review reviews the plane's last 24 hours every morning and turns each problem worth fixing into a researched fix and a pull request) — its zones and trigger in one card; offer the matching one when the operator says to do something every time. kit: set up a starter kit in a project (repo, kit: support-desk, bug-triage, sales-follow-up or ops-requests) — a teammate, the flow it works and its buttons, in one card; prefer it when the operator wants a support desk, bug triage, sales follow-up or requests handled. create: a template, or the steps in order (each leads to the next; Done is added; instructions may be left out). 'request' calls a web address (method, url with its host written out, headers — {{secret.NAME}} uses a secret the operator saved on the step, never in chat — and body); 'email' sends mail (to, subject, body; {{card.email}} is the card's email address); 'tool' calls one of the project's tools (server: the tool's name from get_project_tools, tool: its function, args: an object). Put a decision before any of these when they send what a model wrote or what an outsider sent. A 'draft' step has Claude write something from the card (instructions: what to write); follow it with an approval step (decider 'owner' asks the flow's owner in their chat app, where they can approve, edit or send it back), then an 'update' or 'notify' step whose message is '{{stage.<draft id>}}'. A 'sort' step has Jev pick one of its answers; make it the first step (never a holding step before it, or new cards wait unsorted): question, answers (answer, means: a few words Jev reads, goesTo: a step), sureAt (percent, default 80), ifNotSure (a step; otherwise the card waits for a person), and up to 3 alsoNote (score with levels lowest first, or yes-no); a sort has no next, so give each branch's last step its own next. A 'pull-request' step opens a pull request for the card's built result and waits for CI: next when it passes, ifFails when it fails (only this step defaults it: to the build before it, as a revision carrying the failing check); merge ('squash', 'merge' or 'rebase') merges once checks pass, and needs an approval step before it on every path. waitFor 'hours' with from and until (like '22:00' and '06:00') holds cards until the clock is inside those hours. A 'wait' step, after an 'email' step, waits for a reply to that email from someone it went to (waitFor 'reply', the default): next is where a reply goes (the reply is {{stage.<wait id>}}), ifNoReply where the card goes when none comes within wait (like '3 days', up to 30 days); waitFor 'time' just waits, then next. Any step but wait and done can have remindAfter (like '2 days': whoever it waits on is reminded once; 'none' removes it) and, on holding and approval steps, thenMoveTo (a step the card moves to then). AI teammates (get_teammates): an approval step with teammate (its short name) is decided by that teammate within its rules, and it hands hard ones to the step's decider; a 'teammate' step (teammate, instructions, routes of answer and goesTo) has it read the card, pick where it goes and write what the next steps send (the email body is then {{stage.<id>}}), asking the flow's owner when its rules say to. edit: the full step list, keeping existing steps by id — what a kept step leaves out carries over. add_card (starts in the first zone unless zone is named), move_card, approve, send_back (needs a note), cancel_card, comment (note; @name pings that person), assign (owner: a name, 'me', or 'nobody'), follow, unfollow, save_script (repo, and script: name, about, language python|node|shell, and either body (short) or file (a path in the project, like scripts/enrich.py), and timeoutMinutes; scripts belong to the project, so no flow is needed; a 'check' step in any of its flows names it). A 'check' step runs its script with the card as JSON on stdin (and in $FLOW_INPUT); what it prints is its result for later steps ({{stage.<id>}}); runIn 'folder' (an empty folder: for scripts that work on data) or 'copy' (a copy of the card's work, after setup: for tests on code; the default); routes (answer, goesTo) that a last printed line 'goto: <answer>' picks; ifFails is where a failing script sends the card, such as back to the build (it has no default: without it the card waits there); secrets (names of saved secrets it gets as variables). add_trigger with settings (kind button: label, questions; schedule: schedule like 'daily 09:00 Europe/London', and title, or script (a saved script whose printed items — one per line, a title or JSON with title, description, key — each become a card, once) with secrets; github: repo owner/name, watch issues|pulls|checks, label, branch, from team|anyone; linear: team, state, label; flow: follow (another flow's id), when (its zone); email: folder (default INBOX), sender (addresses or domains), subject (words it must contain); plane-review: at (HH:MM, default 07:30), timeZone — every day it reads the plane's last 24 hours and makes one card per problem worth fixing, a returning problem joining its card); pause_trigger, resume_trigger, remove_trigger with trigger. Read get_flows first except to create.",
889
+ description: "Draft a flow change as a card the operator confirms. To make a flow, create with a template or the steps in plain names; leave instructions out unless the operator gave them, and use decider 'me' when they decide. To change one, edit with the whole step list, keeping existing steps by id. For cards use add_card, move_card, approve, send_back with the operator's note, or cancel_card; find the card by what it is about; comment (with @name to ping someone), assign and follow here too. For triage, spam, lead, effort or exception routing, start from the matching template, with the sort as the first step. Use remindAfter and thenMoveTo for follow-ups and decisions that stall. The Issues to PRs template runs GitHub issues labelled toolroll through build, approval, pull request and a comment on the issue. When the operator asks for something to happen every time (fix CI when it fails, turn labelled issues into tasks, queue work overnight, review what went wrong every morning), offer the matching starter; each is one yes, and none merges without a person. When someone new wants a support desk, bug triage, sales follow-up or requests handled, a kit sets up the teammate and its flow in one card. Teammates: propose_teammate adds one (from a template: support, sales, ops, triage), changes one rule section (edit_section), pauses, resumes or removes it, passes it a note, or answers its question for the operator; put it to work with a flow here. When the operator says something like 'Maya can approve refunds up to $100 now', change that section of her soul file; when it's for this week or a one-off, pass it as a note. Teammates use the project's tools under a rule per action (do it, ask first: the operator approves each exact call, never, or up to a limit on a number); propose_teammate use_tool, stop_tool and tool_rule change them, and when a rule has a limit also change the soul file so its words agree. forget or edit_memory when the operator says a teammate has something wrong; a rule change it suggests is a question for the operator, answered only as they say. propose_teammate add_routine puts a card on its desk on a schedule; propose_teammate undo undoes a tool call only when its action has an undo set (tool_rule undoWith). Actions: starter: switch on a starter flow in a project (repo, starter: ci-fix files a fix task when CI fails on the main branch, issue-task makes a task of each GitHub issue labelled toolroll, overnight holds cards added in the day until 22:00 and leaves results for the morning, plane-review reviews the plane's last 24 hours every morning and turns each problem worth fixing into a researched fix and a pull request) — its zones and trigger in one card; offer the matching one when the operator says to do something every time. kit: set up a starter kit in a project (repo, kit: support-desk, bug-triage, sales-follow-up or ops-requests) — a teammate, the flow it works and its buttons, in one card; prefer it when the operator wants a support desk, bug triage, sales follow-up or requests handled. create: a template, or the steps in order (each leads to the next; Done is added; instructions may be left out). 'request' calls a web address (method, url with its host written out, headers — {{secret.NAME}} uses a secret the operator saved on the step, never in chat — and body); 'email' sends mail (to, subject, body; {{card.email}} is the card's email address); 'tool' calls one of the project's tools (server: the tool's name from get_project_tools, tool: its function, args: an object). Put a decision before any of these when they send what a model wrote or what an outsider sent. A 'draft' step has Claude write something from the card (instructions: what to write); follow it with an approval step (decider 'owner' asks the flow's owner in their chat app, where they can approve, edit or send it back), then an 'update' or 'notify' step whose message is '{{stage.<draft id>}}'. A 'sort' step has Jev pick one of its answers; make it the first step (never a holding step before it, or new cards wait unsorted): question, answers (answer, means: a few words Jev reads, goesTo: a step), sureAt (percent, default 80), ifNotSure (a step; otherwise the card waits for a person), and up to 3 alsoNote (score with levels lowest first, or yes-no); a sort has no next, so give each branch's last step its own next. A 'pull-request' step opens a pull request for the card's built result and waits for CI: next when it passes, ifFails when it fails (only this step defaults it: to the build before it, as a revision carrying the failing check); merge ('squash', 'merge' or 'rebase') merges once checks pass, and needs an approval step before it on every path. waitFor 'hours' with from and until (like '22:00' and '06:00') holds cards until the clock is inside those hours. A 'wait' step, after an 'email' step, waits for a reply to that email from someone it went to (waitFor 'reply', the default): next is where a reply goes (the reply is {{stage.<wait id>}}), ifNoReply where the card goes when none comes within wait (like '3 days', up to 30 days); waitFor 'time' just waits, then next. Any step but wait and done can have remindAfter (like '2 days': whoever it waits on is reminded once; 'none' removes it) and, on holding and approval steps, thenMoveTo (a step the card moves to then). AI teammates (get_teammates): an approval step with teammate (its short name) is decided by that teammate within its rules, and it hands hard ones to the step's decider; a 'teammate' step (teammate, instructions, routes of answer and goesTo) has it read the card, pick where it goes and write what the next steps send (the email body is then {{stage.<id>}}), asking the flow's owner when its rules say to. edit: the full step list, keeping existing steps by id — what a kept step leaves out carries over. add_card (starts in the first zone unless zone is named), move_card, approve, send_back (needs a note), cancel_card, comment (note; @name pings that person), assign (owner: a name, 'me', or 'nobody'), follow, unfollow, save_script (repo, and script: name, about, language python|node|shell, and either body (short) or file (a path in the project, like scripts/enrich.py), and timeoutMinutes; scripts belong to the project, so no flow is needed; a 'check' step in any of its flows names it). A 'check' step runs its script with the card as JSON on stdin (and in $FLOW_INPUT); what it prints is its result for later steps ({{stage.<id>}}); runIn 'folder' (an empty folder: for scripts that work on data) or 'copy' (a copy of the card's work, after setup: for tests on code; the default); routes (answer, goesTo) that a last printed line 'goto: <answer>' picks; ifFails is where a failing script sends the card, such as back to the build (it has no default: without it the card waits there); secrets (names of saved secrets it gets as variables). add_trigger with settings (kind button: label, questions; schedule: schedule like 'daily 09:00 Europe/London', and title, or script (a saved script whose printed items — one per line, a title or JSON with title, description, key — each become a card, once) with secrets; github: repo owner/name, watch issues|pulls|checks, label, branch, from team|anyone; linear: team, state, label; flow: follow (another flow's id), when (its zone); email: folder (default INBOX), sender (addresses or domains), subject (words it must contain); plane-review: at (HH:MM, default 07:30), timeZone — every day it reads the plane's last 24 hours and makes one card per problem worth fixing, a returning problem joining its card); pause_trigger, resume_trigger, remove_trigger with trigger. A 'send' step ('Send to me') sends the card's owner what the step before produced (summary, links, screenshots) in their chat apps, then moves on; a 'choose' step sends the same with 2 to 4 options (label, goesTo: a step, or 'end' to ignore the card) as buttons, ifReplied (where a reply goes as {{note}}; the build before by default), and remindAfter with ifNoReply. choose (card, and choice by number or a note to reply) answers one for the operator. A 'task' step may build in another project: repo (from list_repos). Read get_flows first except to create.",
882
890
  inputSchema: schema({
883
- operation: { type: "string", enum: ["create", "edit", "add_card", "move_card", "approve", "send_back", "cancel_card", "comment", "assign", "follow", "unfollow", "save_script", "add_trigger", "pause_trigger", "resume_trigger", "remove_trigger", "kit", "starter"] },
891
+ operation: { type: "string", enum: ["create", "edit", "add_card", "move_card", "approve", "send_back", "choose", "cancel_card", "comment", "assign", "follow", "unfollow", "save_script", "add_trigger", "pause_trigger", "resume_trigger", "remove_trigger", "kit", "starter"] },
884
892
  repo: REPO_ARG, flow: { type: "integer", minimum: 1 }, card: { type: "integer", minimum: 1 }, kit: { type: "string", enum: KITS.map(one => one.id) },
885
893
  starter: { type: "string", enum: [...STARTER_IDS] },
886
894
  name: { type: "string", maxLength: 80 }, template: { type: "string", enum: FLOW_TEMPLATES.map(one => one.id) },
@@ -901,9 +909,12 @@ export const MATE_TOOLS = [
901
909
  waitFor: { type: "string", enum: ["reply", "time", "hours"] }, wait: { type: "string", maxLength: 40 }, ifNoReply: { type: "string", maxLength: 60 },
902
910
  from: { type: "string", maxLength: 5 }, until: { type: "string", maxLength: 5 }, merge: { type: "string", enum: ["squash", "merge", "rebase"] },
903
911
  remindAfter: { type: "string", maxLength: 40 }, thenMoveTo: { type: "string", maxLength: 60 }, teammate: { type: "string", maxLength: 40 },
912
+ options: { type: "array", minItems: 2, maxItems: 4, items: { type: "object", additionalProperties: false, properties: { label: { type: "string", maxLength: 40 }, goesTo: { type: "string", maxLength: 60 } } } },
913
+ ifReplied: { type: "string", maxLength: 60 }, repo: REPO_ARG,
904
914
  next: { type: "string", maxLength: 60 }, ifFails: { type: "string", maxLength: 60 }, ifNotSure: { type: "string", maxLength: 60 },
905
915
  } } },
906
916
  title: { type: "string", maxLength: 200 }, description: { type: "string", maxLength: 4000 }, zone: { type: "string", maxLength: 60 }, note: { type: "string", maxLength: 2000 },
917
+ choice: { type: "integer", minimum: 1, maximum: 4 },
907
918
  trigger: { type: "integer", minimum: 1 }, owner: { type: "string", maxLength: 64 },
908
919
  script: { type: "object", additionalProperties: false, properties: { name: { type: "string", maxLength: 40 }, about: { type: "string", maxLength: 160 }, body: { type: "string", maxLength: 1600 }, timeoutMinutes: { type: "integer", minimum: 1, maximum: 60 },
909
920
  language: { type: "string", enum: ["python", "node", "shell"] }, file: { type: "string", maxLength: 200 } } },
@@ -921,7 +932,15 @@ export const MATE_TOOLS = [
921
932
  handle: (ctx, args) => {
922
933
  const pick = (keys) => Object.fromEntries(keys.filter(key => args[key] !== undefined).map(key => [key, args[key]]));
923
934
  // "me" in a step means the operator; the drawing stores their name.
924
- const steps = () => stepsFor(Array.isArray(args["steps"]) ? args["steps"] : [], ctx.who.name);
935
+ // A build step in another project names it as list_repos does (r2); the drawing keeps its path.
936
+ const steps = () => stepsFor(Array.isArray(args["steps"]) ? args["steps"] : [], ctx.who.name).map(step => {
937
+ if (step === null || typeof step !== "object" || step.repo === undefined)
938
+ return step;
939
+ const path = repoPathOf(ctx.who, step.repo);
940
+ if (path === null)
941
+ throw new Error("A build step's repo is a project from list_repos.");
942
+ return { ...step, repo: path };
943
+ });
925
944
  const flowOf = () => {
926
945
  const flow = Number.isSafeInteger(args["flow"]) ? ctx.store.getFlow(Number(args["flow"])) : null;
927
946
  return flow !== null && ctx.who.repos.includes(flow.repo) ? flow : null;
@@ -988,6 +1007,10 @@ export const MATE_TOOLS = [
988
1007
  operation = "flow_card_send_back";
989
1008
  input = pick(["card", "note"]);
990
1009
  break;
1010
+ case "choose":
1011
+ operation = "flow_card_choose";
1012
+ input = pick(["card", "choice", "note"]);
1013
+ break;
991
1014
  case "cancel_card":
992
1015
  operation = "flow_card_cancel";
993
1016
  input = pick(["card"]);