toolroll 0.9.34 → 0.9.37

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 (195) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -0
  3. package/dist/assignment-adapters.d.ts +12 -48
  4. package/dist/assignment-adapters.js +33 -43
  5. package/dist/assignment.d.ts +1 -0
  6. package/dist/assignment.js +2 -2
  7. package/dist/browser-workspace.d.ts +3 -3
  8. package/dist/build-review.d.ts +1 -1
  9. package/dist/build-review.js +2 -1
  10. package/dist/builder.js +4 -7
  11. package/dist/chat-acceptance.d.ts +2 -2
  12. package/dist/chat-actions.d.ts +18 -12
  13. package/dist/chat-actions.js +15 -65
  14. package/dist/chat-ask.js +5 -5
  15. package/dist/chat-decide.d.ts +14 -1
  16. package/dist/chat-decide.js +12 -6
  17. package/dist/chat-delivery-state.d.ts +12 -70
  18. package/dist/chat-delivery-state.js +17 -135
  19. package/dist/chat-delivery.js +58 -30
  20. package/dist/chat-flow.js +8 -6
  21. package/dist/chat-inbox.js +1 -1
  22. package/dist/coding-context.d.ts +2 -39
  23. package/dist/coding-context.js +2 -1
  24. package/dist/coding-handoff.d.ts +6 -25
  25. package/dist/coding-handoff.js +11 -5
  26. package/dist/coding-types.d.ts +2 -26
  27. package/dist/coding-workspace.d.ts +2 -0
  28. package/dist/coding-workspace.js +50 -14
  29. package/dist/contracts/acceptance-terms.d.ts +14 -0
  30. package/dist/contracts/acceptance-terms.js +16 -0
  31. package/dist/contracts/chat-actions.d.ts +328 -0
  32. package/dist/contracts/chat-actions.js +133 -0
  33. package/dist/contracts/chat-callback-rows.d.ts +59 -0
  34. package/dist/contracts/chat-callback-rows.js +46 -0
  35. package/dist/contracts/chat-content.d.ts +276 -0
  36. package/dist/contracts/chat-content.js +127 -0
  37. package/dist/contracts/chat-tables.d.ts +6 -0
  38. package/dist/contracts/chat-tables.js +136 -0
  39. package/dist/contracts/coding-context.d.ts +97 -0
  40. package/dist/contracts/coding-context.js +60 -0
  41. package/dist/contracts/coding-handoff.d.ts +44 -0
  42. package/dist/contracts/coding-handoff.js +47 -0
  43. package/dist/contracts/coding-workspace.d.ts +165 -0
  44. package/dist/contracts/coding-workspace.js +71 -0
  45. package/dist/contracts/contract.d.ts +40 -7
  46. package/dist/contracts/contract.js +155 -19
  47. package/dist/contracts/decision.d.ts +45 -0
  48. package/dist/contracts/decision.js +45 -0
  49. package/dist/contracts/discord-callback.d.ts +45 -0
  50. package/dist/contracts/discord-callback.js +34 -0
  51. package/dist/contracts/flow-card.d.ts +50 -0
  52. package/dist/contracts/flow-card.js +46 -0
  53. package/dist/contracts/flow-propose.d.ts +325 -0
  54. package/dist/contracts/flow-propose.js +41 -0
  55. package/dist/contracts/flow-send.d.ts +119 -0
  56. package/dist/contracts/flow-send.js +112 -0
  57. package/dist/contracts/flow.d.ts +19405 -0
  58. package/dist/contracts/flow.js +364 -0
  59. package/dist/contracts/gateway-tools.d.ts +374 -0
  60. package/dist/contracts/gateway-tools.js +94 -0
  61. package/dist/contracts/handoff.d.ts +91 -0
  62. package/dist/contracts/handoff.js +82 -0
  63. package/dist/contracts/lead-context.d.ts +165 -0
  64. package/dist/contracts/lead-context.js +73 -0
  65. package/dist/contracts/lead-tools.d.ts +1905 -0
  66. package/dist/contracts/lead-tools.js +265 -0
  67. package/dist/contracts/memory-pass.d.ts +158 -0
  68. package/dist/contracts/memory-pass.js +102 -0
  69. package/dist/contracts/project-knowledge.d.ts +126 -0
  70. package/dist/contracts/project-knowledge.js +93 -0
  71. package/dist/contracts/project-memory.d.ts +117 -0
  72. package/dist/contracts/project-memory.js +51 -0
  73. package/dist/contracts/project-skills.d.ts +121 -0
  74. package/dist/contracts/project-skills.js +76 -0
  75. package/dist/contracts/proof.d.ts +136 -0
  76. package/dist/contracts/proof.js +110 -0
  77. package/dist/contracts/review-findings.d.ts +46 -0
  78. package/dist/contracts/review-findings.js +48 -0
  79. package/dist/contracts/route.d.ts +484 -0
  80. package/dist/contracts/route.js +128 -0
  81. package/dist/contracts/scope.d.ts +105 -0
  82. package/dist/contracts/scope.js +114 -0
  83. package/dist/contracts/scout-report.d.ts +234 -0
  84. package/dist/contracts/scout-report.js +141 -0
  85. package/dist/contracts/slack-callback.d.ts +43 -0
  86. package/dist/contracts/slack-callback.js +35 -0
  87. package/dist/contracts/sort-answer.d.ts +83 -0
  88. package/dist/contracts/sort-answer.js +77 -0
  89. package/dist/contracts/stage-output.d.ts +127 -0
  90. package/dist/contracts/stage-output.js +166 -0
  91. package/dist/contracts/task-sizing.d.ts +24 -0
  92. package/dist/contracts/task-sizing.js +28 -0
  93. package/dist/contracts/teammate-turn.d.ts +43 -0
  94. package/dist/contracts/teammate-turn.js +51 -0
  95. package/dist/contracts/teams-callback.d.ts +14 -0
  96. package/dist/contracts/teams-callback.js +14 -0
  97. package/dist/contracts/telegram-callback.d.ts +120 -0
  98. package/dist/contracts/telegram-callback.js +97 -0
  99. package/dist/contracts/verification-receipt.d.ts +148 -0
  100. package/dist/contracts/verification-receipt.js +87 -0
  101. package/dist/decision.d.ts +11 -20
  102. package/dist/decision.js +86 -167
  103. package/dist/demo.js +28 -11
  104. package/dist/discord-chat.js +31 -20
  105. package/dist/evidence.d.ts +3 -40
  106. package/dist/evidence.js +5 -1
  107. package/dist/flow-engine.js +14 -17
  108. package/dist/flow-gallery.d.ts +3 -2
  109. package/dist/flow-items.d.ts +4 -13
  110. package/dist/flow-items.js +1 -15
  111. package/dist/flow-send.d.ts +6 -37
  112. package/dist/flow-send.js +18 -14
  113. package/dist/flow-share.d.ts +10 -32
  114. package/dist/flow-share.js +72 -84
  115. package/dist/flow-sort.d.ts +7 -26
  116. package/dist/flow-sort.js +25 -20
  117. package/dist/flow-starters.d.ts +2 -1
  118. package/dist/flow-steps.js +3 -2
  119. package/dist/flow-triggers.d.ts +15 -72
  120. package/dist/flow-triggers.js +94 -83
  121. package/dist/flows.d.ts +37 -220
  122. package/dist/flows.js +356 -387
  123. package/dist/invoke.js +4 -4
  124. package/dist/lead-context.d.ts +4 -2
  125. package/dist/lead-context.js +24 -17
  126. package/dist/mate-tools.d.ts +4 -0
  127. package/dist/mate-tools.js +111 -308
  128. package/dist/mcp-connect.d.ts +27 -4
  129. package/dist/mcp-connect.js +92 -17
  130. package/dist/mcp.js +84 -216
  131. package/dist/memory-pass.d.ts +17 -42
  132. package/dist/memory-pass.js +54 -26
  133. package/dist/operate.js +31 -2
  134. package/dist/phase-routing.d.ts +19 -72
  135. package/dist/phase-routing.js +34 -164
  136. package/dist/plan.d.ts +5 -5
  137. package/dist/plan.js +5 -25
  138. package/dist/process-recovery-eligibility.d.ts +2 -2
  139. package/dist/process-recovery-eligibility.js +13 -8
  140. package/dist/project-knowledge.d.ts +16 -35
  141. package/dist/project-knowledge.js +42 -21
  142. package/dist/project-memory.d.ts +2 -28
  143. package/dist/project-memory.js +28 -13
  144. package/dist/project-skills.d.ts +16 -38
  145. package/dist/project-skills.js +41 -32
  146. package/dist/project-tools.d.ts +1 -0
  147. package/dist/project-tools.js +4 -4
  148. package/dist/proof.d.ts +15 -91
  149. package/dist/proof.js +90 -249
  150. package/dist/provider.d.ts +2 -0
  151. package/dist/provider.js +4 -16
  152. package/dist/result-evidence-readers.d.ts +42 -0
  153. package/dist/result-evidence-readers.js +91 -0
  154. package/dist/result-shots.d.ts +1 -1
  155. package/dist/result-shots.js +2 -1
  156. package/dist/review-switch.d.ts +3 -8
  157. package/dist/reviewer.d.ts +8 -12
  158. package/dist/reviewer.js +11 -35
  159. package/dist/scope.d.ts +21 -43
  160. package/dist/scope.js +42 -128
  161. package/dist/scout-report.d.ts +9 -194
  162. package/dist/scout-report.js +113 -264
  163. package/dist/scout.js +15 -11
  164. package/dist/serve.js +17 -101
  165. package/dist/slack-chat.js +26 -20
  166. package/dist/slack-state.js +2 -1
  167. package/dist/store.d.ts +9 -32
  168. package/dist/store.js +18 -15
  169. package/dist/structured-output.js +2 -1
  170. package/dist/subscription-chat.d.ts +4 -0
  171. package/dist/subscription-chat.js +4 -1
  172. package/dist/surface.js +8 -1
  173. package/dist/task-review-brief.d.ts +69 -0
  174. package/dist/task-review-brief.js +179 -0
  175. package/dist/task-sizing.d.ts +3 -27
  176. package/dist/task-sizing.js +11 -26
  177. package/dist/task-text.d.ts +0 -5
  178. package/dist/task-text.js +1 -3
  179. package/dist/teammate-question.js +7 -5
  180. package/dist/teammate-work.js +9 -5
  181. package/dist/teammates.d.ts +13 -64
  182. package/dist/teammates.js +32 -33
  183. package/dist/teams-chat.js +20 -11
  184. package/dist/telegram-flow.js +4 -3
  185. package/dist/telegram-mate.d.ts +4 -14
  186. package/dist/telegram-mate.js +8 -7
  187. package/dist/telegram-team.d.ts +2 -19
  188. package/dist/telegram.d.ts +1 -1
  189. package/dist/telegram.js +52 -42
  190. package/dist/text-limits.d.ts +212 -1
  191. package/dist/text-limits.js +214 -1
  192. package/dist/tools-ui.d.ts +2 -1
  193. package/dist/tools-ui.js +7 -4
  194. package/dist/verification-evidence.js +24 -12
  195. package/package.json +1 -1
package/dist/flows.js CHANGED
@@ -49,14 +49,10 @@
49
49
  */
50
50
  import { TEXT_LIMITS } from "./text-limits.js";
51
51
  import { createHash } from "node:crypto";
52
- export const FLOW_STAGE_KINDS = ["inbox", "task", "report", "approval", "check", "pull-request", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "send", "choose", "done"];
53
- export const FLOW_COLORS = ["slate", "blue", "violet", "amber", "green", "rose"];
54
- /** How a Pull request zone merges once checks pass. */
55
- export const FLOW_MERGE_METHODS = ["squash", "merge", "rebase"];
56
- /** Where a choose option that ends the card leads. */
57
- export const FLOW_END = "end";
58
- /** How many options a choose zone offers. */
59
- export const CHOICES_MIN = 2, CHOICES_MAX = 4;
52
+ import { parseContract, readVersioned } from "./contracts/contract.js";
53
+ import { stageReferenceProblems } from "./contracts/stage-output.js";
54
+ import { CHOICES_MAX, CHOICES_MIN, FLOW_ALIASES, FLOW_COLORS, FLOW_DEFINITION_VERSION, FLOW_END, FLOW_MERGE_METHODS, FLOW_STAGE_KINDS, flowDefinitionSchema, flowStepsSchema, HEADERS_MAX, LONGEST_WAIT_MINUTES, savedFlowDefinitionSchema, SCRIPT_LANGUAGES, SCRIPT_NAME, sortKeyOf, SURE_AT_MAX, SURE_AT_MIN, ZONE_ID, } from "./contracts/flow.js";
55
+ export { CHOICES_MAX, CHOICES_MIN, FLOW_COLORS, FLOW_END, FLOW_MERGE_METHODS, FLOW_STAGE_KINDS, LONGEST_WAIT_MINUTES, SCRIPT_LANGUAGES, SCRIPT_NAME, sortKeyOf };
60
56
  /** What each kind is called and does, in the words the canvas uses. */
61
57
  export const FLOW_KIND_WORDS = {
62
58
  inbox: { label: "Holding", about: "Cards wait here until someone moves them." },
@@ -78,134 +74,10 @@ export const FLOW_KIND_WORDS = {
78
74
  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." },
79
75
  done: { label: "Done", about: "The end of the flow." },
80
76
  };
81
- const ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
82
- /** A project script's name: short, lowercase, dashes — how zones and chat refer to it. */
83
- export const SCRIPT_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
84
- /** The languages a project script is written in (v90). */
85
- export const SCRIPT_LANGUAGES = ["shell", "python", "node"];
86
77
  export const LANGUAGE_WORDS = { shell: "Shell", python: "Python", node: "Node" };
87
- const text = (value, cap) => {
88
- if (value === null || value === undefined)
89
- return null;
90
- if (typeof value !== "string")
91
- throw new Error("Zone text must be plain text.");
92
- const trimmed = value.trim();
93
- if (trimmed.length > cap)
94
- throw new Error(`Zone text is up to ${cap} characters.`);
95
- if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(trimmed))
96
- throw new Error("Zone text cannot contain control characters.");
97
- return trimmed === "" ? null : trimmed;
98
- };
99
- const coordinate = (value, min, max, fallback) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(max, Math.max(min, value))) : fallback;
100
- /** How an answer is named to Jev: its words as a short key. */
101
- export const sortKeyOf = (answer) => answer.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "answer";
102
78
  /** The default for "sure enough to act alone". */
103
79
  export const SORT_SURE_AT = 0.8;
104
- const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
105
80
  const HEADER = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
106
- /** A web request's settings, checked: an http(s) address whose scheme and host are written out, not filled in. */
107
- function validateRequest(input, title) {
108
- const raw = (input ?? {});
109
- const method = METHODS.includes(raw["method"]) ? raw["method"] : "POST";
110
- const url = text(raw["url"], 2000);
111
- if (url === null)
112
- throw new Error(`Zone ${title}: give the address it calls.`);
113
- const origin = /^(https?):\/\/([^/?#]*)/i.exec(url);
114
- if (origin === null)
115
- throw new Error(`Zone ${title}: the address must start with https:// or http://.`);
116
- if (origin[2].includes("{{") || origin[2].includes("@") || origin[2] === "")
117
- throw new Error(`Zone ${title}: write the address's host out in full; fill-ins go after it.`);
118
- const headers = {};
119
- for (const [name, value] of Object.entries((raw["headers"] ?? {}))) {
120
- if (!HEADER.test(name))
121
- throw new Error(`Zone ${title}: “${name}” isn't a header name.`);
122
- const said = text(value, 500);
123
- if (said !== null)
124
- headers[name] = said;
125
- }
126
- if (Object.keys(headers).length > 10)
127
- throw new Error(`Zone ${title}: up to 10 headers.`);
128
- return { method, url, headers, body: method === "GET" || method === "DELETE" ? null : text(raw["body"], 8000) };
129
- }
130
- /** An email's settings, checked. */
131
- function validateEmail(input, title) {
132
- const raw = (input ?? {});
133
- const to = text(raw["to"], 500), subject = text(raw["subject"], 200), body = text(raw["body"], 8000);
134
- if (to === null)
135
- throw new Error(`Zone ${title}: say who it goes to, for example {{card.email}}.`);
136
- if (subject === null)
137
- throw new Error(`Zone ${title}: give the email a subject.`);
138
- if (body === null)
139
- throw new Error(`Zone ${title}: write what the email says.`);
140
- return { to, subject, body };
141
- }
142
- /** A tool call's settings, checked: the arguments are a JSON object. */
143
- function validateTool(input, title) {
144
- const raw = (input ?? {});
145
- const server = text(raw["server"], 64), name = text(raw["name"], 100);
146
- if (server === null || name === null)
147
- throw new Error(`Zone ${title}: choose the tool it uses.`);
148
- const args = text(raw["args"], 4000) ?? "{}";
149
- let parsed;
150
- try {
151
- parsed = JSON.parse(args);
152
- }
153
- catch {
154
- throw new Error(`Zone ${title}: the arguments aren't valid JSON.`);
155
- }
156
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
157
- throw new Error(`Zone ${title}: the arguments are a JSON object, like {"text": "{{stage.draft}}"}.`);
158
- return { server, name, args };
159
- }
160
- /** A sort zone's settings, checked. Answers name zones by id; `ids` says which exist. */
161
- function validateSort(input, title) {
162
- const raw = (input ?? {});
163
- const question = text(raw["question"], 300);
164
- if (question === null)
165
- throw new Error(`Zone ${title}: write the question it sorts by.`);
166
- const answers = Array.isArray(raw["answers"]) ? raw["answers"] : [];
167
- if (answers.length < 2 || answers.length > 12)
168
- throw new Error(`Zone ${title}: give it 2 to 12 answers.`);
169
- const keys = new Set();
170
- const checked = answers.map(one => {
171
- const answer = one;
172
- const name = text(answer["answer"], 40);
173
- if (name === null)
174
- throw new Error(`Zone ${title}: every answer needs a name.`);
175
- const means = text(answer["means"], 200) ?? name;
176
- const to = text(answer["to"], 32);
177
- if (to === null)
178
- throw new Error(`Zone ${title}: say where “${name}” goes.`);
179
- const key = sortKeyOf(name);
180
- if (keys.has(key))
181
- throw new Error(`Zone ${title}: two answers are called ${name}.`);
182
- keys.add(key);
183
- return { answer: name, means, to };
184
- });
185
- const sureAt = typeof raw["sureAt"] === "number" && Number.isFinite(raw["sureAt"]) ? Math.round(Math.min(0.99, Math.max(0.5, raw["sureAt"])) * 100) / 100 : SORT_SURE_AT;
186
- const notes = Array.isArray(raw["notes"]) ? raw["notes"] : [];
187
- if (notes.length > 3)
188
- throw new Error(`Zone ${title}: it can note up to 3 other things.`);
189
- const noteIds = new Set(["route"]);
190
- const checkedNotes = notes.map(one => {
191
- const note = one;
192
- const noteQuestion = text(note["question"], 300);
193
- if (noteQuestion === null)
194
- throw new Error(`Zone ${title}: every extra note needs a question.`);
195
- const kind = note["kind"] === "score" ? "score" : "yes-no";
196
- let id = typeof note["id"] === "string" && ID.test(note["id"]) ? note["id"] : sortKeyOf(noteQuestion).slice(0, 24);
197
- for (let n = 2; noteIds.has(id); n++)
198
- id = `${sortKeyOf(noteQuestion).slice(0, 20)}-${n}`;
199
- noteIds.add(id);
200
- const levels = kind === "score" ? (Array.isArray(note["levels"]) ? note["levels"] : []).map(level => text(level, 120)).filter((level) => level !== null) : null;
201
- if (levels !== null && (levels.length < 2 || levels.length > 10))
202
- throw new Error(`Zone ${title}: a score needs 2 to 10 levels, lowest first.`);
203
- return { id, kind, question: noteQuestion, levels };
204
- });
205
- return { question, answers: checked, sureAt, notes: checkedNotes };
206
- }
207
- /** The longest a zone waits or a limit runs: 30 days. */
208
- export const LONGEST_WAIT_MINUTES = 30 * 24 * 60;
209
81
  const UNITS = [[/^(m|min|mins|minute|minutes)$/, 1], [/^(h|hr|hrs|hour|hours)$/, 60], [/^(d|day|days)$/, 24 * 60], [/^(w|wk|wks|week|weeks)$/, 7 * 24 * 60]];
210
82
  /** "3 days", "4 hours", "90 minutes", "1 week" (or a number of minutes) as minutes; null when it isn't one. */
211
83
  export function durationMinutes(value) {
@@ -228,29 +100,12 @@ export function durationWords(minutes) {
228
100
  const days = Math.floor(minutes / (24 * 60)), hours = Math.floor((minutes % (24 * 60)) / 60), rest = minutes % 60;
229
101
  return [days > 0 ? plural(days, "day") : "", hours > 0 ? plural(hours, "hour") : "", rest > 0 ? plural(rest, "minute") : ""].filter(one => one !== "").join(" ");
230
102
  }
231
- /** A Wait zone's settings, checked. */
232
- function validateWait(input, title) {
233
- const raw = (input ?? {});
234
- if (raw["for"] === "hours") {
235
- const from = clockTime(raw["from"]), to = clockTime(raw["to"]);
236
- if (from === null || to === null || from === to)
237
- throw new Error(`Zone ${title}: say the hours it waits for, like 22:00 to 06:00.`);
238
- const zone = typeof raw["timeZone"] === "string" && raw["timeZone"].trim() !== "" ? raw["timeZone"].trim() : null;
239
- if (zone !== null && !knownTimeZone(zone))
240
- throw new Error(`Zone ${title}: ${zone} isn't a time zone, like Europe/London.`);
241
- return { for: "hours", minutes: 0, from, to, ...(zone === null ? {} : { timeZone: zone }) };
242
- }
243
- const minutes = durationMinutes(raw["minutes"]);
244
- if (minutes === null)
245
- throw new Error(`Zone ${title}: say how long it waits, from 1 minute to 30 days.`);
246
- return { for: raw["for"] === "time" ? "time" : "reply", minutes };
247
- }
248
103
  /** "22:00", "9:30" as "HH:MM"; null when it isn't a time of day. */
249
104
  export function clockTime(value) {
250
105
  const match = typeof value === "string" ? /^\s*([01]?[0-9]|2[0-3]):([0-5][0-9])\s*$/.exec(value) : null;
251
106
  return match === null ? null : `${match[1].padStart(2, "0")}:${match[2]}`;
252
107
  }
253
- function knownTimeZone(zone) {
108
+ export function knownTimeZone(zone) {
254
109
  try {
255
110
  new Intl.DateTimeFormat("en-GB", { timeZone: zone });
256
111
  return true;
@@ -267,116 +122,278 @@ export function withinHours(wait, now) {
267
122
  const from = minutesOf(wait.from), to = minutesOf(wait.to);
268
123
  return from < to ? at >= from && at < to : at >= from || at < to;
269
124
  }
270
- /** A zone's time limit, checked; null when it has none. Only Holding and "Person decides" zones move a card on. */
271
- function validateLimit(input, kind, title) {
272
- if (input === undefined || input === null)
273
- return null;
274
- const raw = input;
275
- if (kind === "wait" || kind === "done")
276
- throw new Error(`Zone ${title}: ${kind === "wait" ? "a Wait zone has its own time" : "the end has no time limit"}.`);
277
- const minutes = durationMinutes(raw["minutes"]);
278
- if (minutes === null)
279
- throw new Error(`Zone ${title}: say how long a card may wait, from 1 minute to 30 days.`);
280
- const to = text(raw["to"], 32);
281
- if (to !== null && kind !== "inbox" && kind !== "approval" && kind !== "choose")
282
- 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.`);
283
- return { minutes, to };
284
- }
285
125
  /** Who decides at an approval zone: its named person, the flow's owner, or null for anyone who approves on the project. */
286
126
  export function deciderOf(stage, flow) {
287
127
  return stage.toOwner === true ? flow.owner : stage.approver;
288
128
  }
289
- /** A flow as drawn on the canvas, checked whole. Throws in plain words. */
290
- /** `stored`: reading a flow saved earlier, which keeps working under rules added since (its cards say what's wrong). */
291
- export function validateFlowDefinition(input, options = {}) {
292
- const raw = input;
293
- if (raw === null || typeof raw !== "object" || !Array.isArray(raw.stages))
294
- throw new Error("A flow is a list of zones.");
295
- if (raw.stages.length === 0 || raw.stages.length > 24)
296
- throw new Error("A flow has 1 to 24 zones.");
297
- const stages = raw.stages.map((one, index) => {
298
- const stage = one;
299
- const id = typeof stage["id"] === "string" ? stage["id"] : "";
300
- if (!ID.test(id))
301
- throw new Error("Each zone needs a short id: lowercase letters, numbers and dashes.");
302
- const kind = stage["kind"];
303
- if (!FLOW_STAGE_KINDS.includes(kind))
304
- throw new Error(`Zone ${id}: choose what it does.`);
305
- const title = text(stage["title"], 60);
306
- if (title === null)
307
- throw new Error(`Zone ${id} needs a name.`);
308
- const zone = (stage["zone"] ?? {});
309
- const color = FLOW_COLORS.includes(zone["color"]) ? zone["color"] : "slate";
310
- const planning = stage["planning"] === "required" || stage["planning"] === "skip" || stage["planning"] === "auto" ? stage["planning"] : null;
311
- return {
312
- id, title, kind: kind,
313
- zone: { x: coordinate(zone["x"], -20000, 20000, index * 320), y: coordinate(zone["y"], -20000, 20000, 0), w: coordinate(zone["w"], 220, 1200, 280), h: coordinate(zone["h"], 160, 1600, 360), color },
314
- // Reading a saved flow never re-checks length: a limit is for writing.
315
- instructions: text(stage["instructions"], options.stored ? Number.POSITIVE_INFINITY : TEXT_LIMITS.flowInstructions),
316
- planning: kind === "task" ? planning ?? "auto" : null,
317
- approver: kind === "approval" && stage["toOwner"] !== true ? text(stage["approver"], 64) : null,
318
- ...(kind === "approval" && stage["toOwner"] === true ? { toOwner: true } : {}),
319
- message: text(stage["message"], 1000),
320
- close: kind === "update" ? stage["close"] !== false : null,
321
- script: kind === "check" ? text(stage["script"], 40) : null,
322
- ...(kind === "check" ? validateCode(stage, title) : {}),
323
- sort: kind === "sort" ? validateSort(stage["sort"], title) : null,
324
- ...(kind === "request" ? { request: validateRequest(stage["request"], title) } : {}),
325
- ...(kind === "email" ? { email: validateEmail(stage["email"], title) } : {}),
326
- ...(kind === "tool" ? { tool: validateTool(stage["tool"], title) } : {}),
327
- ...(kind === "wait" ? { wait: validateWait(stage["wait"], title) } : {}),
328
- ...(kind === "pull-request" && stage["merge"] !== undefined && stage["merge"] !== null && stage["merge"] !== false ? { merge: validateMerge(stage["merge"], title) } : {}),
329
- ...((kind === "approval" || kind === "teammate") && stage["teammate"] !== undefined && stage["teammate"] !== null && stage["teammate"] !== "" ? { teammate: validateTeammate(stage["teammate"], title) } : {}),
330
- ...(kind === "teammate" ? validateRoutes(stage, title) : {}),
331
- ...(kind === "teammate" && stage["reply"] === true ? { reply: true } : {}),
332
- ...(kind === "choose" ? { options: validateChoices(stage["options"], title) } : {}),
333
- ...(kind === "task" && stage["repo"] !== undefined && stage["repo"] !== null && stage["repo"] !== "" ? { repo: validateRepo(stage["repo"], title) } : {}),
334
- ...(() => { const limit = validateLimit(stage["limit"], kind, title); return limit === null ? {} : { limit }; })(),
335
- next: kind === "sort" || kind === "choose" ? null : text(stage["next"], 32),
336
- onFail: text(stage["onFail"], 32),
337
- };
129
+ /** A flow refused by its contract: every problem, each naming its path (`stages[2].instructions: required`). */
130
+ export class FlowContractError extends Error {
131
+ lines;
132
+ constructor(lines) {
133
+ super(lines.join("\n"));
134
+ this.lines = lines;
135
+ }
136
+ }
137
+ // ------------------------------------------------------------------ reading a drawing
138
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
139
+ /** Text as every release has kept it: trimmed, and blank as none. Anything else is left for the schema to name. */
140
+ const said = (value) => value === undefined || value === null ? null : typeof value === "string" ? value.trim() === "" ? null : value.trim() : value;
141
+ const coordinate = (value, min, max, fallback) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(max, Math.max(min, value))) : fallback;
142
+ const given = (value) => value !== undefined && value !== null;
143
+ /** A sort zone's settings as every release has kept them: an answer's meaning defaults to its name, sureAt to 0.8 (within
144
+ * 0.5–0.99), and each extra note gets a free id. */
145
+ function canonicalSort(input) {
146
+ const raw = isRecord(input) ? input : {};
147
+ const answers = Array.isArray(raw["answers"]) ? raw["answers"] : given(raw["answers"]) ? raw["answers"] : [];
148
+ const sureAt = typeof raw["sureAt"] === "number" && Number.isFinite(raw["sureAt"]) ? Math.round(Math.min(SURE_AT_MAX, Math.max(SURE_AT_MIN, raw["sureAt"])) * 100) / 100 : SORT_SURE_AT;
149
+ const noteIds = new Set(["route"]);
150
+ const notes = Array.isArray(raw["notes"]) ? raw["notes"].map(one => {
151
+ const note = isRecord(one) ? one : {};
152
+ const question = said(note["question"]);
153
+ const kind = note["kind"] === "score" ? "score" : "yes-no";
154
+ const words = typeof question === "string" ? question : "";
155
+ let id = typeof note["id"] === "string" && ZONE_ID.test(note["id"]) ? note["id"] : sortKeyOf(words).slice(0, 24);
156
+ for (let n = 2; noteIds.has(id); n++)
157
+ id = `${sortKeyOf(words).slice(0, 20)}-${n}`;
158
+ noteIds.add(id);
159
+ const levels = kind === "score" ? (Array.isArray(note["levels"]) ? note["levels"] : []).map(said).filter(level => level !== null) : null;
160
+ return { id, kind, question, levels };
161
+ }) : given(raw["notes"]) ? raw["notes"] : [];
162
+ return {
163
+ question: said(raw["question"]),
164
+ answers: Array.isArray(answers) ? answers.map(one => {
165
+ const answer = isRecord(one) ? one : {};
166
+ const name = said(answer["answer"]);
167
+ return { answer: name, means: said(answer["means"]) ?? name, to: said(answer["to"]) };
168
+ }) : answers,
169
+ sureAt, notes,
170
+ };
171
+ }
172
+ /** A script's or teammate's answers as every release has kept them: trimmed, none left out. */
173
+ function canonicalRoutes(input) {
174
+ if (!given(input))
175
+ return undefined;
176
+ if (!Array.isArray(input))
177
+ return input;
178
+ if (input.length === 0)
179
+ return undefined;
180
+ return input.map(one => {
181
+ const row = isRecord(one) ? one : {};
182
+ return { answer: typeof row["answer"] === "string" ? row["answer"].trim() : row["answer"] ?? "", to: typeof row["to"] === "string" ? row["to"].trim() : row["to"] ?? "" };
338
183
  });
184
+ }
185
+ /** A Wait zone's settings: "hours" between two times of day, or a reply or a set time for so long. */
186
+ function canonicalWait(input) {
187
+ const raw = isRecord(input) ? input : {};
188
+ if (raw["for"] === "hours") {
189
+ const zone = typeof raw["timeZone"] === "string" && raw["timeZone"].trim() !== "" ? raw["timeZone"].trim() : null;
190
+ return { for: "hours", minutes: 0, from: clockTime(raw["from"]) ?? raw["from"], to: clockTime(raw["to"]) ?? raw["to"], ...(zone === null ? {} : { timeZone: zone }) };
191
+ }
192
+ return { for: raw["for"] === "time" ? "time" : "reply", minutes: durationMinutes(raw["minutes"]) };
193
+ }
194
+ /**
195
+ * One zone of a version 1 drawing, as every release from 0.9.0 has read it: a zone saved before a field existed gets
196
+ * that field's default, text is trimmed (blank is none), the canvas place is clamped, a field another kind uses is
197
+ * set aside, and a value no release accepted is left as it is for the schema to name.
198
+ */
199
+ function canonicalStage(input, index) {
200
+ if (!isRecord(input))
201
+ return input;
202
+ const stage = input;
203
+ const kind = stage["kind"];
204
+ const zone = isRecord(stage["zone"]) ? stage["zone"] : {};
205
+ const planning = stage["planning"] === "required" || stage["planning"] === "skip" || stage["planning"] === "auto" ? stage["planning"] : null;
206
+ const runIn = stage["runIn"] === undefined || stage["runIn"] === null ? undefined : stage["runIn"];
207
+ const routes = canonicalRoutes(stage["routes"]);
208
+ const secrets = !given(stage["secrets"]) ? undefined : Array.isArray(stage["secrets"]) ? stage["secrets"].length === 0 ? undefined : stage["secrets"].every(one => typeof one === "string") ? [...new Set(stage["secrets"])] : stage["secrets"] : stage["secrets"];
209
+ const limit = !given(stage["limit"]) ? undefined : isRecord(stage["limit"]) ? { minutes: durationMinutes(stage["limit"]["minutes"]), to: said(stage["limit"]["to"]) } : stage["limit"];
210
+ const request = isRecord(stage["request"]) ? stage["request"] : {};
211
+ const method = ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(request["method"]) ? request["method"] : "POST";
212
+ const email = isRecord(stage["email"]) ? stage["email"] : {};
213
+ const tool = isRecord(stage["tool"]) ? stage["tool"] : {};
214
+ return {
215
+ id: stage["id"], title: said(stage["title"]), kind,
216
+ zone: { x: coordinate(zone["x"], -20000, 20000, index * 320), y: coordinate(zone["y"], -20000, 20000, 0), w: coordinate(zone["w"], 220, 1200, 280), h: coordinate(zone["h"], 160, 1600, 360), color: FLOW_COLORS.includes(zone["color"]) ? zone["color"] : "slate" },
217
+ instructions: said(stage["instructions"]),
218
+ planning: kind === "task" ? planning ?? "auto" : null,
219
+ approver: kind === "approval" && stage["toOwner"] !== true ? said(stage["approver"]) : null,
220
+ ...(kind === "approval" && stage["toOwner"] === true ? { toOwner: true } : {}),
221
+ message: said(stage["message"]),
222
+ close: kind === "update" ? stage["close"] !== false : null,
223
+ script: kind === "check" ? said(stage["script"]) : null,
224
+ // Left out, a script runs where every script ran before v90: in a copy of the card's work.
225
+ ...(kind === "check" ? { ...(runIn === undefined ? {} : { runIn }), ...(routes === undefined ? {} : { routes }), ...(secrets === undefined ? {} : { secrets }) } : {}),
226
+ sort: kind === "sort" ? canonicalSort(stage["sort"]) : null,
227
+ ...(kind === "request" ? { request: { method, url: said(request["url"]), headers: Object.fromEntries(Object.entries(isRecord(request["headers"]) ? request["headers"] : {}).flatMap(([name, value]) => { const one = said(value); return one === null ? [] : [[name, one]]; })), body: method === "GET" || method === "DELETE" ? null : said(request["body"]) } } : {}),
228
+ ...(kind === "email" ? { email: { to: said(email["to"]), subject: said(email["subject"]), body: said(email["body"]) } } : {}),
229
+ ...(kind === "tool" ? { tool: { server: said(tool["server"]), name: said(tool["name"]), args: said(tool["args"]) ?? "{}" } } : {}),
230
+ ...(kind === "wait" ? { wait: canonicalWait(stage["wait"]) } : {}),
231
+ // true is a squash, the default; false or none is no merge.
232
+ ...(kind === "pull-request" && given(stage["merge"]) && stage["merge"] !== false ? { merge: stage["merge"] === true ? "squash" : stage["merge"] } : {}),
233
+ ...((kind === "approval" || kind === "teammate") && given(stage["teammate"]) && stage["teammate"] !== "" ? { teammate: stage["teammate"] } : {}),
234
+ ...(kind === "teammate" && routes !== undefined ? { routes } : {}),
235
+ ...(kind === "teammate" && stage["reply"] === true ? { reply: true } : {}),
236
+ ...(kind === "choose" ? { options: Array.isArray(stage["options"]) ? stage["options"].map(one => { const row = isRecord(one) ? one : {}; return { label: said(row["label"]), to: said(row["to"]) }; }) : given(stage["options"]) ? stage["options"] : [] } : {}),
237
+ ...(kind === "task" && given(stage["repo"]) && stage["repo"] !== "" ? { repo: said(stage["repo"]) } : {}),
238
+ ...(limit === undefined ? {} : { limit }),
239
+ next: kind === "sort" || kind === "choose" ? null : said(stage["next"]),
240
+ onFail: said(stage["onFail"]),
241
+ };
242
+ }
243
+ /** A version 1 drawing as every release has read it (canonicalStage); its start is the first zone unless it names one. */
244
+ function canonicalDefinition(input) {
245
+ const stages = Array.isArray(input["stages"]) ? input["stages"].map(canonicalStage) : input["stages"];
246
+ const ids = Array.isArray(stages) ? stages.map(one => (isRecord(one) ? one["id"] : undefined)) : [];
247
+ const start = typeof input["start"] === "string" && ids.includes(input["start"]) ? input["start"] : ids[0];
248
+ return { version: Object.prototype.hasOwnProperty.call(input, "version") ? input["version"] : FLOW_DEFINITION_VERSION, start, stages };
249
+ }
250
+ const issue = (path, what, kind = "invalid") => ({ path, kind, line: `${path}: ${what}` });
251
+ /** What JSON Schema can't say about a drawing, each named by its path: paths lead to zones that exist and never back into
252
+ * their own zone, answers and options have names of their own, a web address's host is written out, tool arguments are a
253
+ * JSON object, an hours wait's times differ in a real time zone, and a merge comes after a person's decision. */
254
+ export function drawingProblems(definition) {
255
+ const problems = [];
339
256
  const ids = new Set();
340
- for (const stage of stages) {
257
+ definition.stages.forEach((stage, index) => {
341
258
  if (ids.has(stage.id))
342
- throw new Error(`Two zones are called ${stage.id}.`);
259
+ problems.push(issue(`stages[${index}].id`, `two zones are called ${stage.id}`));
343
260
  ids.add(stage.id);
344
- }
345
- for (const stage of stages) {
346
- 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)])
347
- if (target !== null && !ids.has(target))
348
- throw new Error(`Zone ${stage.title} points at a zone that no longer exists.`);
349
- if (stage.options !== undefined && ids.has(FLOW_END) && stage.options.some(one => one.to === FLOW_END))
350
- 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.`);
351
- if (stage.options?.some(one => one.to === stage.id))
352
- throw new Error(`Zone ${stage.title}: an option can't send cards back into the same zone.`);
353
- if (stage.limit?.to === stage.id)
354
- throw new Error(`Zone ${stage.title}: a card that waits too long can't move back into the same zone.`);
355
- if (stage.routes?.some(one => one.to === stage.id))
356
- throw new Error(`Zone ${stage.title}: an answer can't send cards back into the same zone.`);
357
- if (stage.sort !== null && stage.sort.answers.some(one => one.to === stage.id))
358
- throw new Error(`Zone ${stage.title}: an answer can't send cards back into the same zone.`);
359
- if ((stage.kind === "task" || stage.kind === "report") && stage.instructions === null)
360
- throw new Error(`Zone ${stage.title}: say what the agent should do.`);
361
- if (stage.kind === "draft" && stage.instructions === null)
362
- throw new Error(`Zone ${stage.title}: say what Claude should write.`);
363
- if (stage.kind === "notify" && stage.message === null)
364
- throw new Error(`Zone ${stage.title}: write the message to post.`);
365
- if (stage.kind === "check" && (stage.script === null || !SCRIPT_NAME.test(stage.script)))
366
- throw new Error(`Zone ${stage.title}: choose which script it runs.`);
367
- if (stage.kind === "done" && (stage.next !== null || stage.onFail !== null))
368
- throw new Error(`Zone ${stage.title} is the end; it can't lead anywhere.`);
369
- if (stage.kind === "teammate" && stage.teammate === undefined)
370
- throw new Error(`Zone ${stage.title}: choose which teammate handles it.`);
371
- }
372
- const start = typeof raw.start === "string" && ids.has(raw.start) ? raw.start : stages[0].id;
261
+ });
262
+ definition.stages.forEach((stage, index) => {
263
+ const at = `stages[${index}]`;
264
+ const target = (path, to) => {
265
+ if (to !== null && !ids.has(to))
266
+ problems.push(issue(`${at}.${path}`, `there's no zone called ${to}`));
267
+ else if (to === stage.id && path !== "next" && path !== "onFail")
268
+ problems.push(issue(`${at}.${path}`, "can't lead back into the same zone"));
269
+ };
270
+ target("next", stage.next);
271
+ target("onFail", stage.onFail);
272
+ if (stage.limit !== undefined)
273
+ target("limit.to", stage.limit.to);
274
+ stage.sort?.answers.forEach((one, n) => target(`sort.answers[${n}].to`, one.to));
275
+ stage.routes?.forEach((one, n) => target(`routes[${n}].to`, one.to));
276
+ stage.options?.forEach((one, n) => {
277
+ if (one.to !== FLOW_END)
278
+ target(`options[${n}].to`, one.to);
279
+ else if (ids.has(FLOW_END))
280
+ problems.push(issue(`${at}.options[${n}].to`, `an option that ends the card can't be told apart from the zone called ${FLOW_END}; rename that zone`));
281
+ });
282
+ const twice = (path, names, key) => {
283
+ const seen = new Set();
284
+ names.forEach((name, n) => {
285
+ if (seen.has(key(name)))
286
+ problems.push(issue(`${at}.${path}[${n}]`, `two are called ${name}`));
287
+ seen.add(key(name));
288
+ });
289
+ };
290
+ if (stage.sort !== null)
291
+ twice("sort.answers", stage.sort.answers.map(one => one.answer), sortKeyOf);
292
+ if (stage.routes !== undefined)
293
+ twice("routes", stage.routes.map(one => one.answer), name => name.toLowerCase());
294
+ if (stage.options !== undefined)
295
+ twice("options", stage.options.map(one => one.label), name => name.toLowerCase());
296
+ if (stage.request !== undefined) {
297
+ const origin = /^(https?):\/\/([^/?#]*)/i.exec(stage.request.url);
298
+ if (origin === null)
299
+ problems.push(issue(`${at}.request.url`, "must start with https:// or http://"));
300
+ else if (origin[2].includes("{{") || origin[2].includes("@") || origin[2] === "")
301
+ problems.push(issue(`${at}.request.url`, "write the address's host out in full; fill-ins go after it"));
302
+ const names = Object.keys(stage.request.headers);
303
+ for (const name of names)
304
+ if (!HEADER.test(name))
305
+ problems.push(issue(`${at}.request.headers`, `“${name}” isn't a header name`));
306
+ if (names.length > HEADERS_MAX)
307
+ problems.push(issue(`${at}.request.headers`, `at most ${HEADERS_MAX} headers`, "too-many"));
308
+ }
309
+ if (stage.tool !== undefined) {
310
+ let args;
311
+ try {
312
+ args = JSON.parse(stage.tool.args);
313
+ }
314
+ catch {
315
+ args = undefined;
316
+ }
317
+ if (!isRecord(args))
318
+ problems.push(issue(`${at}.tool.args`, `must be a JSON object, like {"text": "{{stage.draft}}"}`));
319
+ }
320
+ if (stage.wait !== undefined && (stage.wait.for === "hours") !== (stage.wait.minutes === 0))
321
+ problems.push(issue(`${at}.wait.minutes`, stage.wait.for === "hours" ? "must be 0: an hours wait waits for the clock" : "say how long it waits, from 1 minute to 30 days"));
322
+ if (stage.wait !== undefined && (stage.wait.for === "hours") !== (stage.wait.from !== undefined && stage.wait.to !== undefined))
323
+ problems.push(issue(`${at}.wait`, stage.wait.for === "hours" ? "say the hours it waits for, like 22:00 to 06:00" : "only an hours wait has from and to"));
324
+ if (stage.wait?.for === "hours") {
325
+ if (stage.wait.from === stage.wait.to)
326
+ problems.push(issue(`${at}.wait.to`, "must differ from from, like 22:00 to 06:00"));
327
+ if (stage.wait.timeZone !== undefined && !knownTimeZone(stage.wait.timeZone))
328
+ problems.push(issue(`${at}.wait.timeZone`, `${stage.wait.timeZone} isn't a time zone, like Europe/London`));
329
+ }
330
+ });
331
+ if (problems.length > 0)
332
+ return problems;
373
333
  // A zone that merges comes after a person's decision on every path to it: never merged without one.
374
- const unapproved = reachableWithout(stages, start, one => one.kind === "approval");
375
- for (const stage of stages) {
334
+ const unapproved = reachableWithout(definition.stages, definition.start, one => one.kind === "approval");
335
+ definition.stages.forEach((stage, index) => {
376
336
  if (stage.merge !== undefined && unapproved.has(stage.id))
377
- throw new Error(`Zone ${stage.title} merges, so a “Person decides” zone must come before it on every path.`);
378
- }
379
- return { version: 1, start, stages };
337
+ problems.push(issue(`stages[${index}].merge`, "a “Person decides” zone must come before it on every path"));
338
+ });
339
+ return problems;
340
+ }
341
+ /**
342
+ * Read a flow's drawing: the canvas's, a step list's once built, or one saved earlier (`stored`, which keeps working under
343
+ * rules added since: its instructions keep the length they were saved with). A version 1 drawing is read as every release
344
+ * has read it (canonicalStage), then the schema checks it, then drawingProblems; a newer version is refused plainly.
345
+ */
346
+ export function readFlowDefinition(input, options = {}) {
347
+ const body = isRecord(input) ? canonicalDefinition(input) : input;
348
+ const read = readVersioned(options.stored === true ? savedFlowDefinitionSchema : flowDefinitionSchema, body, {}, FLOW_ALIASES);
349
+ // The start is the first zone unless a zone it names exists: a problem with it is a problem with the zones, said there.
350
+ if (!read.ok)
351
+ return { ok: false, issues: read.issues.some(one => one.path.startsWith("stages")) ? read.issues.filter(one => one.path !== "start") : read.issues };
352
+ const problems = drawingProblems(read.value);
353
+ return problems.length === 0 ? read : { ok: false, issues: problems };
354
+ }
355
+ /** The saved drawing's fields in the words steps and flow files use for them. */
356
+ const STEP_WORDS = [
357
+ [/^tool\.args/, "args"], [/^tool\.server/, "server"], [/^tool\.name/, "tool"], [/^request\.(url|headers|body|method)/, "$1"], [/^email\.(to|subject|body)/, "$1"],
358
+ [/^sort\.answers\[(\d+)\]\.to/, "answers[$1].goesTo"], [/^sort\.answers/, "answers"], [/^sort\.question/, "question"], [/^sort\.sureAt/, "sureAt"], [/^sort\.notes/, "alsoNote"],
359
+ [/^(routes|options)\[(\d+)\]\.to/, "$1[$2].goesTo"], [/^limit\.to/, "thenMoveTo"], [/^limit(\.minutes)?/, "remindAfter"], [/^wait\.to/, "until"], [/^wait\.(from|timeZone)/, "$1"], [/^wait(\.minutes)?/, "wait"],
360
+ [/^approver/, "decider"], [/^toOwner/, "decider"],
361
+ ];
362
+ /** A refusal of the drawing a step list or flow file became, in its words: `stages[2].sort.answers[0].to` is
363
+ * `steps[2].answers[0].goesTo` (each zone is the step at its index), and a zone's failure path is the step's own name for it. */
364
+ export function inStepWords(line, list, kinds) {
365
+ const match = /^stages\[(\d+)\](?:\.([^:]*))?:(.*)$/s.exec(line);
366
+ if (match === null)
367
+ return line;
368
+ const index = Number(match[1]), kind = kinds[index];
369
+ let field = match[2] ?? "";
370
+ if (field === "onFail")
371
+ field = kind === "sort" ? "ifNotSure" : kind === "wait" ? "ifNoReply" : kind === "choose" ? "ifReplied" : "ifFails";
372
+ else
373
+ for (const [from, to] of STEP_WORDS)
374
+ if (from.test(field)) {
375
+ field = field.replace(from, to);
376
+ break;
377
+ }
378
+ return `${list}[${index}]${field === "" ? "" : `.${field}`}:${match[3]}`;
379
+ }
380
+ /** A flow as drawn on the canvas, checked whole (readFlowDefinition). Throws FlowContractError, naming every path. */
381
+ export function validateFlowDefinition(input, options = {}) {
382
+ const read = readFlowDefinition(input, options);
383
+ if (!read.ok)
384
+ throw new FlowContractError(read.issues.map(one => one.line));
385
+ return read.value;
386
+ }
387
+ /** A drawing's refusal as the canvas shows it: each line as it is, after the name of the zone it is about, so a person
388
+ * finds it on the canvas (`Build · stages[1].instructions: required`). */
389
+ export function withZoneNames(lines, input) {
390
+ const stages = isRecord(input) && Array.isArray(input["stages"]) ? input["stages"] : [];
391
+ return lines.map(line => {
392
+ const index = /^stages\[(\d+)\]/.exec(line)?.[1];
393
+ const stage = index === undefined ? undefined : stages[Number(index)];
394
+ const title = isRecord(stage) && typeof stage["title"] === "string" && stage["title"].trim() !== "" ? stage["title"].trim() : null;
395
+ return title === null ? line : `${title} · ${line}`;
396
+ }).join("\n");
380
397
  }
381
398
  /** The instructions 0.9.26 and earlier read from a saved zone: they re-check this length on every read, and a flow with a
382
399
  * longer one won't load. A saved zone keeps its first part under it, and the rest in `instructionsMore`, which they ignore. */
@@ -431,14 +448,6 @@ export function flowDefinitionFromStore(json) {
431
448
  });
432
449
  return JSON.stringify({ ...raw, stages: whole });
433
450
  }
434
- /** A Pull request zone's merge: true is squash, the default. */
435
- function validateMerge(value, title) {
436
- if (value === true)
437
- return "squash";
438
- if (typeof value === "string" && FLOW_MERGE_METHODS.includes(value))
439
- return value;
440
- throw new Error(`Zone ${title}: merge by squash, merge or rebase.`);
441
- }
442
451
  /** The zones a card can reach from `start` without passing through a zone `stop` picks (those zones aren't included). */
443
452
  export function reachableWithout(stages, start, stop) {
444
453
  const seen = new Set();
@@ -461,82 +470,6 @@ export function choiceTargets(stage) {
461
470
  export function replyTarget(stage) {
462
471
  return stage.onFail ?? choiceTargets(stage)[0] ?? null;
463
472
  }
464
- /** A choose zone's options, checked: 2 to 4, each with short words of its own and where it leads. */
465
- function validateChoices(input, title) {
466
- const raw = Array.isArray(input) ? input : [];
467
- if (raw.length < CHOICES_MIN || raw.length > CHOICES_MAX)
468
- throw new Error(`Zone ${title}: give it ${CHOICES_MIN} to ${CHOICES_MAX} options.`);
469
- const seen = new Set();
470
- return raw.map(one => {
471
- const row = (one ?? {});
472
- const label = text(row["label"], 40);
473
- const to = text(row["to"], 32);
474
- if (label === null || to === null)
475
- throw new Error(`Zone ${title}: each option needs a few words and where it leads.`);
476
- if (seen.has(label.toLowerCase()))
477
- throw new Error(`Zone ${title}: two options are called ${label}.`);
478
- seen.add(label.toLowerCase());
479
- return { label, to };
480
- });
481
- }
482
- /** 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. */
483
- function validateRepo(value, title) {
484
- const repo = text(value, 1000);
485
- if (repo === null)
486
- throw new Error(`Zone ${title}: choose the project it builds in.`);
487
- return repo;
488
- }
489
- /** 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. */
490
- function validateTeammate(value, title) {
491
- if (typeof value !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(value))
492
- throw new Error(`Zone ${title}: choose a teammate by its short name, like maya.`);
493
- return value;
494
- }
495
- /** The answers a zone may pick, each with the zone it leads to (a script's "goto:" line, or a teammate's choice). */
496
- function validateRoutes(stage, title) {
497
- const rawRoutes = stage["routes"] === undefined || stage["routes"] === null ? [] : stage["routes"];
498
- if (!Array.isArray(rawRoutes) || rawRoutes.length > 12)
499
- throw new Error(`Zone ${title}: pick from up to 12 answers.`);
500
- const seen = new Set();
501
- const routes = rawRoutes.map(one => {
502
- const row = (one ?? {});
503
- const answer = typeof row["answer"] === "string" ? row["answer"].trim() : "";
504
- const to = typeof row["to"] === "string" ? row["to"].trim() : "";
505
- if (!/^[^\n]{1,40}$/.test(answer) || to === "")
506
- throw new Error(`Zone ${title}: each answer needs a short name and the zone it leads to.`);
507
- if (seen.has(answer.toLowerCase()))
508
- throw new Error(`Zone ${title}: two answers are called ${answer}.`);
509
- seen.add(answer.toLowerCase());
510
- return { answer, to };
511
- });
512
- return routes.length === 0 ? {} : { routes };
513
- }
514
- /** A code zone's settings (v90): where it runs, its answers, and its secrets. Throws in plain words. */
515
- function validateCode(stage, title) {
516
- // Left out, it runs where every script ran before v90: in a copy of the card's work (and the zone's digest stays the same).
517
- const runIn = stage["runIn"] === "folder" || stage["runIn"] === "copy" ? stage["runIn"] : stage["runIn"] === undefined || stage["runIn"] === null ? undefined : null;
518
- if (runIn === null)
519
- throw new Error(`Zone ${title}: choose where the script runs: a clean folder or a copy of the card's work.`);
520
- const rawRoutes = stage["routes"] === undefined || stage["routes"] === null ? [] : stage["routes"];
521
- if (!Array.isArray(rawRoutes) || rawRoutes.length > 12)
522
- throw new Error(`Zone ${title}: a script picks from up to 12 answers.`);
523
- const seen = new Set();
524
- const routes = rawRoutes.map(one => {
525
- const row = (one ?? {});
526
- const answer = typeof row["answer"] === "string" ? row["answer"].trim() : "";
527
- const to = typeof row["to"] === "string" ? row["to"].trim() : "";
528
- if (!/^[^\n]{1,40}$/.test(answer) || to === "")
529
- throw new Error(`Zone ${title}: each answer needs a short name and the zone it leads to.`);
530
- if (seen.has(answer.toLowerCase()))
531
- throw new Error(`Zone ${title}: two answers are called ${answer}.`);
532
- seen.add(answer.toLowerCase());
533
- return { answer, to };
534
- });
535
- const rawSecrets = stage["secrets"] === undefined || stage["secrets"] === null ? [] : stage["secrets"];
536
- if (!Array.isArray(rawSecrets) || rawSecrets.length > 10 || rawSecrets.some(one => typeof one !== "string" || !/^[A-Z][A-Z0-9_]{0,39}$/.test(one)))
537
- throw new Error(`Zone ${title}: name up to 10 saved secrets in capitals, like API_TOKEN.`);
538
- return { ...(runIn === undefined ? {} : { runIn }), ...(routes.length === 0 ? {} : { routes }), ...(rawSecrets.length === 0 ? {} : { secrets: [...new Set(rawSecrets)] }) };
539
- }
540
473
  /** What a card's work is held to: the zones' steps and paths, never where they sit on the canvas. */
541
474
  export function flowDigest(definition) {
542
475
  const terms = definition.stages.map(({ zone: _zone, ...rest }) => rest);
@@ -637,28 +570,29 @@ const idOf = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").r
637
570
  const slugOf = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 28) || "zone";
638
571
  const overlaps = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
639
572
  /** A wait step's settings: what it waits for and how long (3 days when neither the step nor the zone it keeps says). */
640
- function waitFromStep(step, old, title) {
573
+ function waitFromStep(step, old, at) {
641
574
  if ((step.waitFor ?? old?.for) === "hours") {
642
575
  const from = clockTime(step.from ?? old?.from ?? "22:00"), to = clockTime(step.until ?? old?.to ?? "06:00");
643
576
  if (from === null || to === null)
644
- throw new Error(`Step ${title}: say the hours it waits for, like from 22:00 until 06:00.`);
645
- return { for: "hours", minutes: 0, from, to, ...(old?.timeZone === undefined ? {} : { timeZone: old.timeZone }) };
577
+ throw new FlowContractError([`${at}.from: say the hours it waits for, like from 22:00 until 06:00`]);
578
+ const timeZone = step.timeZone ?? old?.timeZone;
579
+ return { for: "hours", minutes: 0, from, to, ...(timeZone === undefined ? {} : { timeZone }) };
646
580
  }
647
581
  const minutes = step.wait === undefined ? old?.minutes ?? 3 * 24 * 60 : durationMinutes(step.wait);
648
582
  if (minutes === null)
649
- throw new Error(`Step ${title}: say how long it waits, like "3 days" or "4 hours" (up to 30 days).`);
583
+ throw new FlowContractError([`${at}.wait: say how long it waits, like "3 days" or "4 hours" (up to 30 days)`]);
650
584
  return { for: step.waitFor ?? old?.for ?? "reply", minutes };
651
585
  }
652
586
  /** A step's time limit: the one it gives, the one its zone had, or none ("none" removes one). */
653
- function limitFromStep(step, old, title, find) {
587
+ function limitFromStep(step, old, at, find) {
654
588
  if (step.remindAfter === undefined)
655
- return old === null ? null : { ...old, ...(step.thenMoveTo === undefined ? {} : { to: step.thenMoveTo.trim() === "" ? null : find(step.thenMoveTo) }) };
589
+ return old === null ? null : { ...old, ...(step.thenMoveTo === undefined ? {} : { to: step.thenMoveTo.trim() === "" ? null : find(step.thenMoveTo, "thenMoveTo") }) };
656
590
  if (typeof step.remindAfter === "string" && /^\s*(none|never|no|off)?\s*$/i.test(step.remindAfter))
657
591
  return null;
658
592
  const minutes = durationMinutes(step.remindAfter);
659
593
  if (minutes === null)
660
- throw new Error(`Step ${title}: say when to remind, like "2 days" (up to 30 days).`);
661
- return { minutes, to: typeof step.thenMoveTo === "string" && step.thenMoveTo.trim() !== "" ? find(step.thenMoveTo) : step.thenMoveTo === undefined ? old?.to ?? null : null };
594
+ throw new FlowContractError([`${at}.remindAfter: say when to remind, like "2 days" (up to 30 days)`]);
595
+ return { minutes, to: typeof step.thenMoveTo === "string" && step.thenMoveTo.trim() !== "" ? find(step.thenMoveTo, "thenMoveTo") : step.thenMoveTo === undefined ? old?.to ?? null : null };
662
596
  }
663
597
  /** What a draft zone asks for when the steps leave it out. */
664
598
  export const DRAFT_DEFAULT = "Write a short, friendly reply to the person who sent this card, in plain words.";
@@ -671,69 +605,92 @@ function defaultInstructions(kind, earlier) {
671
605
  }
672
606
  /** Steps as the lead's flow tool and `toolroll flows` take them: "me" as a decider is the person asking, stored by name. */
673
607
  export function stepsFor(steps, name) {
674
- return Array.isArray(steps) ? steps.map(step => step !== null && typeof step === "object" && typeof step.decider === "string" && /^(me|myself|i|you|the operator)$/i.test(step.decider.trim()) ? { ...step, decider: name } : step) : steps;
608
+ return Array.isArray(steps) ? steps.map(step => isRecord(step) && typeof step["decider"] === "string" && /^(me|myself|i|you|the operator)$/i.test(step["decider"].trim()) ? { ...step, decider: name } : step) : steps;
609
+ }
610
+ /** An edit's steps with what a kept step leaves out of its kind and name filled from the zone it keeps (by id), so each
611
+ * reads as the one schema its kind has. Anything else it leaves out carries over when the flow is drawn (flowFromSteps). */
612
+ export function withKeptSteps(steps, previous) {
613
+ if (previous === null || !Array.isArray(steps))
614
+ return steps;
615
+ const kept = new Map(previous.stages.map(one => [one.id, one]));
616
+ const asked = new Set();
617
+ return steps.map(step => {
618
+ if (!isRecord(step) || typeof step["id"] !== "string")
619
+ return step;
620
+ const id = idOf(step["id"]);
621
+ const old = asked.has(id) ? undefined : kept.get(id);
622
+ asked.add(id);
623
+ if (old === undefined)
624
+ return step;
625
+ return {
626
+ ...step,
627
+ ...(step["kind"] === undefined ? { kind: old.kind } : {}),
628
+ ...(step["title"] === undefined || (typeof step["title"] === "string" && step["title"].trim() === "") ? { title: old.title } : {}),
629
+ };
630
+ });
631
+ }
632
+ /** A flow's steps, read by their one schema: `steps[0].routes[0]: unknown key 'to' (did you mean goesTo?)`. */
633
+ export function readFlowSteps(input, previous = null) {
634
+ const read = parseContract(flowStepsSchema, { steps: withKeptSteps(input, previous) }, FLOW_ALIASES);
635
+ return read.ok ? { ok: true, value: read.value.steps } : read;
675
636
  }
676
637
  /**
677
638
  * A flow from an ordered list of steps: ids from names, each step leading
678
639
  * to the next, a Done zone at the end when none is listed, and a decision
679
640
  * sending work back to the nearest earlier step that does work. New zones
680
- * are laid out in rows; zones kept from `previous` keep their place.
641
+ * are laid out in rows; zones kept from `previous` keep their place. Throws
642
+ * FlowContractError, each line naming the step's path (`steps[2].next: ...`).
681
643
  */
682
644
  export function flowFromSteps(input, previous = null) {
683
- if (!Array.isArray(input) || input.length === 0)
684
- throw new Error("List the flow's steps in order.");
685
- if (input.length > 24)
686
- throw new Error("A flow has 1 to 24 steps.");
645
+ const read = readFlowSteps(input, previous);
646
+ if (!read.ok)
647
+ throw new FlowContractError(read.issues.map(one => one.line));
687
648
  const kept = new Map((previous?.stages ?? []).map(one => [one.id, one]));
688
649
  const used = new Set();
689
- const drafts = input.map((raw, index) => {
690
- if (raw === null || typeof raw !== "object" || Array.isArray(raw))
691
- throw new Error(`Step ${index + 1} isn't a step.`);
692
- const step = raw;
650
+ const drafts = read.value.map((step, index) => {
693
651
  const asked = typeof step.id === "string" ? idOf(step.id) : "";
694
652
  const old = asked !== "" && !used.has(asked) ? kept.get(asked) ?? null : null;
695
- const title = typeof step.title === "string" && step.title.trim() !== "" ? step.title.trim() : old?.title ?? "";
653
+ const title = step.title.trim() !== "" ? step.title.trim() : old?.title ?? "";
696
654
  if (title === "")
697
- throw new Error(`Step ${index + 1} needs a name.`);
698
- const kind = step.kind ?? old?.kind;
699
- if (!FLOW_STAGE_KINDS.includes(kind))
700
- throw new Error(`Step ${title}: choose what it does.`);
655
+ throw new FlowContractError([`steps[${index}].title: must not be empty`]);
656
+ const kind = step.kind;
701
657
  // A new step keeps an id it is given (so the other steps can point at it by that id); otherwise its name makes one.
702
- const given = ID.test(asked) && !used.has(asked) ? asked : null;
658
+ const given = ZONE_ID.test(asked) && !used.has(asked) ? asked : null;
703
659
  let id = old?.id ?? given ?? slugOf(title);
704
660
  for (let n = 2; old === null && used.has(id); n++)
705
661
  id = `${slugOf(title).slice(0, 25)}-${n}`;
706
662
  used.add(id);
707
663
  const same = old !== null && old.kind === kind;
708
- return { step, old: same ? old : null, id, title, kind: kind };
664
+ return { step, old: same ? old : null, id, title, kind, at: `steps[${index}]` };
709
665
  });
710
666
  if (!drafts.some(one => one.kind === "done")) {
711
667
  let id = "done";
712
668
  for (let n = 2; used.has(id); n++)
713
669
  id = `done-${n}`;
714
- drafts.push({ step: {}, old: kept.get(id)?.kind === "done" ? kept.get(id) : null, id, title: "Done", kind: "done" });
670
+ drafts.push({ step: { title: "Done", kind: "done" }, old: kept.get(id)?.kind === "done" ? kept.get(id) : null, id, title: "Done", kind: "done", at: `steps[${drafts.length}]` });
715
671
  }
716
- const find = (ref, from) => {
672
+ const findFrom = (at) => (ref, path) => {
717
673
  const wanted = ref.trim().toLowerCase();
718
674
  const hit = drafts.find(one => one.id === ref.trim() || one.id === idOf(ref) || one.title.toLowerCase() === wanted);
719
675
  if (hit === undefined)
720
- throw new Error(`Step ${from}: there's no step called ${ref}.`);
676
+ throw new FlowContractError([`${at}.${path}: there's no step called ${ref}`]);
721
677
  return hit.id;
722
678
  };
723
679
  const stages = [];
724
- drafts.forEach(({ step, old, id, title, kind }, index) => {
680
+ drafts.forEach(({ step, old, id, title, kind, at }, index) => {
681
+ const find = findFrom(at);
725
682
  const earlier = stages.slice();
726
683
  const following = drafts.slice(index + 1).find(() => true) ?? null;
727
684
  const next = kind === "done" || kind === "sort" || kind === "choose" ? null
728
- : typeof step.next === "string" && step.next.trim() !== "" ? find(step.next, title)
685
+ : typeof step.next === "string" && step.next.trim() !== "" ? find(step.next, "next")
729
686
  : following?.id ?? drafts.find(one => one.kind === "done").id;
730
687
  const keptFail = old?.onFail !== null && old?.onFail !== undefined && drafts.some(one => one.id === old.onFail) ? old.onFail : null;
731
688
  const worker = [...earlier].reverse().find(one => one.kind === "task" || one.kind === "report");
732
- const notSure = kind === "sort" && typeof step.ifNotSure === "string" && step.ifNotSure.trim() !== "" ? step.ifNotSure
733
- : kind === "wait" && typeof step.ifNoReply === "string" && step.ifNoReply.trim() !== "" ? step.ifNoReply
734
- : kind === "choose" ? step.ifReplied : step.ifFails;
689
+ const [failKey, notSure] = kind === "sort" ? ["ifNotSure", step.ifNotSure]
690
+ : kind === "wait" ? ["ifNoReply", step.ifNoReply]
691
+ : kind === "choose" ? ["ifReplied", step.ifReplied] : ["ifFails", step.ifFails];
735
692
  const onFail = kind === "done" ? null
736
- : typeof notSure === "string" && notSure.trim() !== "" ? find(notSure, title)
693
+ : typeof notSure === "string" && notSure.trim() !== "" ? find(notSure, failKey)
737
694
  : keptFail ?? (kind === "approval" ? worker?.id ?? (drafts[0].id === id ? null : drafts[0].id)
738
695
  // A reply to a choice says what to change: it goes back to the work before it, as its note.
739
696
  : kind === "choose" ? worker?.id ?? null
@@ -744,6 +701,7 @@ export function flowFromSteps(input, previous = null) {
744
701
  const approver = kind !== "approval" || toOwner ? null
745
702
  : step.decider === undefined ? old?.approver ?? null
746
703
  : step.decider === null || /^(anyone|any approver|anybody)$/i.test(step.decider.trim()) ? null : step.decider.trim();
704
+ const routesOf = (routes) => routes.length === 0 ? {} : { routes: routes.map((one, n) => ({ answer: one.answer.trim(), to: find(one.goesTo, `routes[${n}].goesTo`) })) };
747
705
  stages.push({
748
706
  id, title, kind,
749
707
  zone: old?.zone ?? { x: 0, y: 0, w: 260, h: kind === "done" || kind === "notify" || kind === "send" ? 220 : 300, color: KIND_COLORS[kind] },
@@ -763,27 +721,27 @@ export function flowFromSteps(input, previous = null) {
763
721
  // Left out, a code step runs in a copy of the project (the card's work, or the main branch), as scripts always have.
764
722
  ...(kind === "check" ? {
765
723
  ...(step.runIn !== undefined ? { runIn: step.runIn } : old?.runIn === undefined ? {} : { runIn: old.runIn }),
766
- ...(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 }),
724
+ ...(step.routes !== undefined ? routesOf(step.routes) : old?.routes === undefined ? {} : { routes: old.routes }),
767
725
  ...(step.secrets !== undefined ? step.secrets.length === 0 ? {} : { secrets: step.secrets } : old?.secrets === undefined ? {} : { secrets: old.secrets }),
768
726
  } : {}),
769
- sort: kind === "sort" ? sortFromStep(step, old?.sort ?? null, ref => find(ref, title)) : null,
727
+ sort: kind === "sort" ? sortFromStep(step, old?.sort ?? null, find) : null,
770
728
  ...(kind === "request" ? { request: { method: step.method ?? old?.request?.method ?? "POST", url: step.url ?? old?.request?.url ?? "", headers: step.headers ?? old?.request?.headers ?? {}, body: step.body ?? old?.request?.body ?? null } } : {}),
771
729
  ...(kind === "email" ? { email: { to: step.to ?? old?.email?.to ?? "{{card.email}}", subject: step.subject ?? old?.email?.subject ?? "Re: {{card.title}}", body: step.body ?? old?.email?.body ?? "" } } : {}),
772
730
  ...(kind === "tool" ? { tool: { server: step.server ?? old?.tool?.server ?? "", name: step.tool ?? old?.tool?.name ?? "", args: typeof step.args === "string" ? step.args : step.args !== undefined ? JSON.stringify(step.args) : old?.tool?.args ?? "{}" } } : {}),
773
- ...(kind === "wait" ? { wait: waitFromStep(step, old?.wait ?? null, title) } : {}),
731
+ ...(kind === "wait" ? { wait: waitFromStep(step, old?.wait ?? null, at) } : {}),
774
732
  ...(kind === "pull-request" && (step.merge === undefined ? old?.merge !== undefined : step.merge !== false) ? { merge: step.merge === undefined || step.merge === true || step.merge === false ? old?.merge ?? "squash" : step.merge } : {}),
775
733
  // v92: "nobody" (or "none") takes a teammate off an approval step.
776
734
  ...((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) } : {}),
777
- ...(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 } : {}),
735
+ ...(kind === "teammate" ? step.routes !== undefined ? routesOf(step.routes) : old?.routes === undefined ? {} : { routes: old.routes } : {}),
778
736
  ...(kind === "teammate" && (step.reply ?? old?.reply) === true ? { reply: true } : {}),
779
- ...(kind === "choose" ? { options: choicesFromStep(step, old?.options ?? null, title, ref => find(ref, title)) } : {}),
737
+ ...(kind === "choose" ? { options: choicesFromStep(step, old?.options ?? null, at, find) } : {}),
780
738
  ...(kind === "task" && (step.repo ?? old?.repo) !== undefined && (step.repo ?? old?.repo).trim() !== "" ? { repo: (step.repo ?? old?.repo).trim() } : {}),
781
739
  ...(() => {
782
740
  // "If no reply" on a choice is where it moves once the reminder comes.
783
741
  const asked = kind === "choose" && step.thenMoveTo === undefined && typeof step.ifNoReply === "string" ? { ...step, thenMoveTo: step.ifNoReply } : step;
784
742
  if (kind === "choose" && asked.thenMoveTo !== undefined && asked.remindAfter === undefined && old?.limit === undefined)
785
- throw new Error(`Step ${title}: say how long to wait for a choice first, with remindAfter (like "2 days").`);
786
- const limit = kind === "wait" || kind === "done" ? null : limitFromStep(asked, old?.limit ?? null, title, ref => find(ref, title));
743
+ throw new FlowContractError([`${at}.remindAfter: say how long to wait for a choice first (like "2 days")`]);
744
+ const limit = kind === "wait" || kind === "done" ? null : limitFromStep(asked, old?.limit ?? null, at, find);
787
745
  return limit === null ? {} : { limit };
788
746
  })(),
789
747
  next, onFail,
@@ -852,29 +810,40 @@ export function flowFromSteps(input, previous = null) {
852
810
  stage.zone = at;
853
811
  placed.push(at);
854
812
  });
855
- return validateFlowDefinition({ version: 1, start: stages[0].id, stages });
813
+ // The drawing is checked as the canvas's is; its zones are the steps, in order, so a zone's problem names its step.
814
+ const drawn = readFlowDefinition({ version: FLOW_DEFINITION_VERSION, start: stages[0].id, stages });
815
+ const refuse = (issues) => new FlowContractError(issues.map(one => inStepWords(one.line, "steps", stages.map(stage => stage.kind))));
816
+ if (!drawn.ok)
817
+ throw refuse(drawn.issues);
818
+ // A {{stage.…}} these steps add must be one its zone hands on (src/contracts/stage-output.ts); ones the flow had stay.
819
+ const references = stageReferenceProblems(drawn.value, previous);
820
+ if (references.length > 0)
821
+ throw refuse(references);
822
+ return drawn.value;
856
823
  }
857
824
  /** 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. */
858
- function choicesFromStep(step, old, title, find) {
859
- if (!Array.isArray(step.options) || step.options.length === 0) {
825
+ function choicesFromStep(step, old, at, find) {
826
+ if (step.options === undefined) {
860
827
  if (old !== null)
861
828
  return old;
862
- 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").`);
829
+ throw new FlowContractError([`${at}.options: give it ${CHOICES_MIN} to ${CHOICES_MAX} options, each with a label and the step it goes to (or "end")`]);
863
830
  }
864
- return step.options.map(one => {
865
- const label = String(one?.label ?? "").trim();
866
- const goes = typeof one?.goesTo === "string" ? one.goesTo.trim() : "";
867
- return { label, to: goes === "" || /^(end|ignore|ignored|stop|close)$/i.test(goes) ? FLOW_END : find(goes) };
831
+ return step.options.map((one, n) => {
832
+ const label = one.label.trim();
833
+ const goes = (one.goesTo ?? "").trim();
834
+ return { label, to: goes === "" || /^(end|ignore|ignored|stop|close)$/i.test(goes) ? FLOW_END : find(goes, `options[${n}].goesTo`) };
868
835
  });
869
836
  }
870
837
  /** 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. */
871
838
  function sortFromStep(step, old, find) {
872
- const answers = Array.isArray(step.answers) && step.answers.length > 0
873
- ? step.answers.map(one => ({ answer: String(one?.answer ?? "").trim(), means: String(one?.means ?? "").trim() || String(one?.answer ?? "").trim(), to: typeof one?.goesTo === "string" && one.goesTo.trim() !== "" ? find(one.goesTo) : "" }))
839
+ const answers = step.answers !== undefined
840
+ ? step.answers.map((one, n) => ({ answer: one.answer.trim(), means: (one.means ?? "").trim() || one.answer.trim(), to: one.goesTo.trim() !== "" ? find(one.goesTo, `answers[${n}].goesTo`) : "" }))
874
841
  : old?.answers ?? [];
875
842
  // A percentage (80) or a fraction (0.8).
876
- const sure = typeof step.sureAt === "number" && Number.isFinite(step.sureAt) ? (step.sureAt > 1 ? step.sureAt / 100 : step.sureAt) : old?.sureAt ?? SORT_SURE_AT;
877
- const notes = Array.isArray(step.alsoNote) ? step.alsoNote.map(one => ({ id: "", kind: one?.kind === "score" ? "score" : "yes-no", question: String(one?.question ?? "").trim(), levels: one?.kind === "score" ? (one.levels ?? []).map(String) : null }))
843
+ const sure = step.sureAt !== undefined ? (step.sureAt > 1 ? step.sureAt / 100 : step.sureAt) : old?.sureAt ?? SORT_SURE_AT;
844
+ const notes = step.alsoNote !== undefined ? step.alsoNote.map(one => one.kind === "score"
845
+ ? { id: one.id ?? "", kind: "score", question: one.question.trim(), levels: one.levels ?? [] }
846
+ : { id: one.id ?? "", kind: "yes-no", question: one.question.trim(), levels: null })
878
847
  : old?.notes ?? [];
879
848
  return { question: step.question?.trim() || old?.question || "", answers, sureAt: sure, notes };
880
849
  }