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
package/dist/flows-cli.js CHANGED
@@ -10,7 +10,7 @@
10
10
  import { readFileSync, statSync, writeFileSync } from 'node:fs';
11
11
  import { resolve } from 'node:path';
12
12
  import { envelopeJson } from './envelope.js';
13
- import { addCardToFlow, advanceFlows, flowDefinitionOf } from './flow-engine.js';
13
+ import { addCardToFlow, advanceFlows, crossProjectProblem, flowDefinitionOf } from './flow-engine.js';
14
14
  import { saveScript } from './flow-scripts.js';
15
15
  import { exportFlow, fetchFlowFile, FLOW_FILE_MAX_BYTES, FlowFileError, importFlow, parseFlowFile, planFlowImport } from './flow-share.js';
16
16
  import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, switchOnStarter } from './flow-starters.js';
@@ -213,6 +213,9 @@ export async function runFlowsCommand(positional, flags, context) {
213
213
  const flowName = name || template?.label || '';
214
214
  if (flowName === '')
215
215
  return fail('usage', 'Name the flow with --name.', EXIT.usage);
216
+ const elsewhere = crossProjectProblem(store, definition, { repo: project, owner: who });
217
+ if (elsewhere !== null)
218
+ return refuse(project, 'invalid-steps', elsewhere);
216
219
  const terms = flowTerms(definition, null);
217
220
  if (template?.trigger !== undefined) {
218
221
  const draft = { id: 0, repo: project, name: flowName, definitionJson: JSON.stringify(definition), revision: 1, state: 'active', createdBy: who, createdAt: now.toISOString(), updatedBy: who, updatedAt: now.toISOString(), owner: who };
@@ -377,6 +380,9 @@ export async function runFlowsCommand(positional, flags, context) {
377
380
  const redrawn = flowDigest(next) !== flowDigest(definition);
378
381
  if (!redrawn && name === flow.name)
379
382
  return fail('no-change', 'That\'s the flow as it is now.');
383
+ const elsewhere = crossProjectProblem(store, next, flow, who);
384
+ if (elsewhere !== null)
385
+ return refuse(flow.repo, 'invalid-steps', elsewhere);
380
386
  if (!flags.has('yes'))
381
387
  return preview(flow.repo, `Change the ${flow.name} flow`, [...(name === flow.name ? [] : [`Renames it to ${name}.`]), ...(redrawn ? flowTerms(next, definition) : [])], { flowId: flow.id, revision: flow.revision, definition: next });
382
388
  if (!store.saveFlow(flow.id, { name, definitionJson: JSON.stringify(next), sawRevision: flow.revision, by: who }, now))
@@ -454,7 +460,7 @@ function listed(store, flow) {
454
460
  const cards = store.flowCards(flow.id, false);
455
461
  return { id: flow.id, name: flow.name, repo: flow.repo, project: projectName(flow.repo), zones: definition?.stages.map(one => one.title) ?? [],
456
462
  triggers: store.flowTriggers(flow.id).filter(one => one.state === 'active').length, cardsWaiting: cards.length,
457
- needDecision: cards.filter(card => definition?.stages.find(one => one.id === card.stage)?.kind === 'approval').length };
463
+ needDecision: cards.filter(card => ['approval', 'choose'].includes(definition?.stages.find(one => one.id === card.stage)?.kind ?? '')).length };
458
464
  }
459
465
  /** One zone and every path out of it, by zone id. */
460
466
  function zoneOf(stage, flow) {
@@ -468,6 +474,8 @@ function zoneOf(stage, flow) {
468
474
  ...(stage.script === null ? {} : { script: stage.script }),
469
475
  ...(stage.merge === undefined ? {} : { merge: stage.merge }),
470
476
  ...(stage.teammate === undefined ? {} : { teammate: stage.teammate }),
477
+ ...(stage.options === undefined ? {} : { options: stage.options.map(one => ({ label: one.label, to: one.to })) }),
478
+ ...(stage.repo === undefined ? {} : { repo: stage.repo }),
471
479
  };
472
480
  }
473
481
  function cardOf(definition, card) {
@@ -499,7 +507,7 @@ function showLines(flow) {
499
507
  ...('answers' in zone && zone.answers !== undefined ? zone.answers.map(one => ` ${one.answer} → ${title(one.to)}`) : []),
500
508
  ...('routes' in zone && zone.routes !== undefined ? zone.routes.map(one => ` ${one.answer} → ${title(one.to)}`) : []),
501
509
  ...(zone.next === null ? [] : [` next → ${title(zone.next)}`]),
502
- ...(zone.ifFails === null ? [] : [` ${zone.kind === 'approval' ? 'sent back' : zone.kind === 'sort' ? 'not sure' : zone.kind === 'wait' ? 'no reply' : 'if it fails'} → ${title(zone.ifFails)}`]),
510
+ ...(zone.ifFails === null ? [] : [` ${zone.kind === 'approval' ? 'sent back' : zone.kind === 'sort' ? 'not sure' : zone.kind === 'wait' ? 'no reply' : zone.kind === 'choose' ? 'a reply' : 'if it fails'} → ${title(zone.ifFails)}`]),
503
511
  ]),
504
512
  '', 'Triggers',
505
513
  ...(flow.triggers.length === 0 ? [' none — add one with toolroll flows trigger add'] : flow.triggers.map(one => ` #${one.id} ${one.state === 'active' ? '' : `(${one.state}) `}${one.words} → ${title(one.zone)}${one.lastOutcome === null ? '' : ` · last: ${one.lastOutcome}`}`)),
package/dist/flows-ui.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { draftFor, flowDefinitionOf } from "./flow-engine.js";
2
- import { describeTrigger, triggerHeadline, FLOW_TRIGGER_KINDS, FLOW_TRIGGER_WORDS, githubRepoOf, HOOK_PATH, hookReady, readHooksBase, readLinearKey, takesDeliveries, triggerConfigOf } from "./flow-triggers.js";
2
+ import { describeTrigger, triggerHeadline, FLOW_TRIGGER_KINDS, FLOW_TRIGGER_WORDS, githubRepoOf, HOOK_PATH, hookReady, planeStatusLink, readHooksBase, readLinearKey, takesDeliveries, triggerConfigOf } from "./flow-triggers.js";
3
3
  import { deciderOf, FLOW_COLORS, FLOW_KIND_WORDS, FLOW_STAGE_KINDS } from "./flows.js";
4
4
  import { flowInsights, troubleWords } from "./flow-insights.js";
5
5
  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 ?? "" } },
@@ -152,7 +167,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
152
167
  return {
153
168
  id: trigger.id, kind: trigger.kind, words: config === null ? "This trigger can't be read." : describeTrigger(config, store),
154
169
  ...(config === null ? { name: "Trigger", detail: "Can't be read" } : triggerHeadline(config, store)),
155
- zone: title(config?.zone ?? definition?.start ?? ""), zoneId: config?.zone ?? definition?.start ?? "", state: trigger.state, status: trigger.lastOutcome, statusAt: trigger.lastAt, failing: trigger.failures > 0,
170
+ zone: title(config?.zone ?? definition?.start ?? ""), zoneId: config?.zone ?? definition?.start ?? "", state: trigger.state, status: trigger.lastOutcome, statusLink: planeStatusLink(config, trigger.lastOutcome), statusAt: trigger.lastAt, failing: trigger.failures > 0,
156
171
  button: config?.kind === "button" ? { label: config.label, questions: config.questions } : null,
157
172
  hook: config !== null && takesDeliveries(config) ? { ready: hookReady(trigger, setup.dir), needsSecret: config.kind === "linear" } : null,
158
173
  checkable: ((config?.kind === "github" || config?.kind === "linear") && config.delivery === "poll") || config?.kind === "email" || (config?.kind === "schedule" && config.script !== undefined) || config?.kind === "plane-review",
@@ -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;
package/dist/invoke.d.ts CHANGED
@@ -39,6 +39,9 @@ export type AgentOutcome = {
39
39
  sessionId: string | null;
40
40
  /** The agent's spoken conclusion — diagnostics, never the handoff. */
41
41
  finalMessage: string | null;
42
+ /** Claude's `structured_output` alone (see ParsedEnvelope) — the only
43
+ * field a structured handback may be read from. */
44
+ structuredOutput?: string | null;
42
45
  /** The harness's own account of the ending, when its dialect has one. */
43
46
  ending?: AgentEnding | null;
44
47
  usage: ProviderUsage;
package/dist/invoke.js CHANGED
@@ -550,6 +550,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
550
550
  notFound: result.notFound,
551
551
  sessionId: envelope.sessionId,
552
552
  finalMessage: envelope.finalMessage,
553
+ structuredOutput: envelope.structuredOutput ?? null,
553
554
  ending: envelope.ending ?? null,
554
555
  usage: {
555
556
  tokensIn: envelope.tokensIn,