toolroll 0.9.34 → 0.9.36

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 (52) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +7 -0
  3. package/dist/assignment.d.ts +1 -0
  4. package/dist/assignment.js +2 -2
  5. package/dist/browser-workspace.d.ts +3 -3
  6. package/dist/builder.js +4 -7
  7. package/dist/chat-acceptance.d.ts +1 -1
  8. package/dist/contracts/contract.d.ts +33 -5
  9. package/dist/contracts/contract.js +143 -16
  10. package/dist/contracts/flow-propose.d.ts +325 -0
  11. package/dist/contracts/flow-propose.js +41 -0
  12. package/dist/contracts/flow.d.ts +19400 -0
  13. package/dist/contracts/flow.js +359 -0
  14. package/dist/contracts/handoff.d.ts +91 -0
  15. package/dist/contracts/handoff.js +82 -0
  16. package/dist/contracts/proof.d.ts +136 -0
  17. package/dist/contracts/proof.js +110 -0
  18. package/dist/contracts/verification-receipt.d.ts +148 -0
  19. package/dist/contracts/verification-receipt.js +87 -0
  20. package/dist/demo.js +28 -11
  21. package/dist/desktop-update.d.ts +2 -2
  22. package/dist/evidence.d.ts +3 -40
  23. package/dist/evidence.js +5 -1
  24. package/dist/flow-gallery.d.ts +3 -2
  25. package/dist/flow-share.d.ts +10 -32
  26. package/dist/flow-share.js +65 -83
  27. package/dist/flow-starters.d.ts +2 -1
  28. package/dist/flow-triggers.d.ts +15 -72
  29. package/dist/flow-triggers.js +94 -83
  30. package/dist/flows.d.ts +37 -218
  31. package/dist/flows.js +350 -385
  32. package/dist/mate-tools.js +14 -44
  33. package/dist/mcp-connect.d.ts +3 -0
  34. package/dist/mcp-connect.js +16 -4
  35. package/dist/operate.js +31 -2
  36. package/dist/plan.d.ts +5 -5
  37. package/dist/plan.js +5 -25
  38. package/dist/process-recovery-eligibility.d.ts +2 -2
  39. package/dist/process-recovery-eligibility.js +13 -8
  40. package/dist/proof.d.ts +15 -91
  41. package/dist/proof.js +90 -249
  42. package/dist/result-evidence-readers.d.ts +42 -0
  43. package/dist/result-evidence-readers.js +91 -0
  44. package/dist/serve.js +8 -99
  45. package/dist/setup-guide.d.ts +2 -2
  46. package/dist/surface.js +8 -1
  47. package/dist/task-review-brief.d.ts +75 -0
  48. package/dist/task-review-brief.js +179 -0
  49. package/dist/text-limits.d.ts +70 -0
  50. package/dist/text-limits.js +70 -0
  51. package/dist/verification-evidence.js +24 -12
  52. package/package.json +1 -1
package/dist/flows.js CHANGED
@@ -49,14 +49,9 @@
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 { 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, SURE_AT_MAX, SURE_AT_MIN, ZONE_ID, } from "./contracts/flow.js";
54
+ export { CHOICES_MAX, CHOICES_MIN, FLOW_COLORS, FLOW_END, FLOW_MERGE_METHODS, FLOW_STAGE_KINDS, LONGEST_WAIT_MINUTES, SCRIPT_LANGUAGES, SCRIPT_NAME };
60
55
  /** What each kind is called and does, in the words the canvas uses. */
61
56
  export const FLOW_KIND_WORDS = {
62
57
  inbox: { label: "Holding", about: "Cards wait here until someone moves them." },
@@ -78,134 +73,12 @@ export const FLOW_KIND_WORDS = {
78
73
  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
74
  done: { label: "Done", about: "The end of the flow." },
80
75
  };
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
76
  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
77
  /** How an answer is named to Jev: its words as a short key. */
101
78
  export const sortKeyOf = (answer) => answer.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "answer";
102
79
  /** The default for "sure enough to act alone". */
103
80
  export const SORT_SURE_AT = 0.8;
104
- const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
105
81
  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
82
  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
83
  /** "3 days", "4 hours", "90 minutes", "1 week" (or a number of minutes) as minutes; null when it isn't one. */
211
84
  export function durationMinutes(value) {
@@ -228,29 +101,12 @@ export function durationWords(minutes) {
228
101
  const days = Math.floor(minutes / (24 * 60)), hours = Math.floor((minutes % (24 * 60)) / 60), rest = minutes % 60;
229
102
  return [days > 0 ? plural(days, "day") : "", hours > 0 ? plural(hours, "hour") : "", rest > 0 ? plural(rest, "minute") : ""].filter(one => one !== "").join(" ");
230
103
  }
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
104
  /** "22:00", "9:30" as "HH:MM"; null when it isn't a time of day. */
249
105
  export function clockTime(value) {
250
106
  const match = typeof value === "string" ? /^\s*([01]?[0-9]|2[0-3]):([0-5][0-9])\s*$/.exec(value) : null;
251
107
  return match === null ? null : `${match[1].padStart(2, "0")}:${match[2]}`;
252
108
  }
253
- function knownTimeZone(zone) {
109
+ export function knownTimeZone(zone) {
254
110
  try {
255
111
  new Intl.DateTimeFormat("en-GB", { timeZone: zone });
256
112
  return true;
@@ -267,116 +123,278 @@ export function withinHours(wait, now) {
267
123
  const from = minutesOf(wait.from), to = minutesOf(wait.to);
268
124
  return from < to ? at >= from && at < to : at >= from || at < to;
269
125
  }
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
126
  /** Who decides at an approval zone: its named person, the flow's owner, or null for anyone who approves on the project. */
286
127
  export function deciderOf(stage, flow) {
287
128
  return stage.toOwner === true ? flow.owner : stage.approver;
288
129
  }
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
- };
130
+ /** A flow refused by its contract: every problem, each naming its path (`stages[2].instructions: required`). */
131
+ export class FlowContractError extends Error {
132
+ lines;
133
+ constructor(lines) {
134
+ super(lines.join("\n"));
135
+ this.lines = lines;
136
+ }
137
+ }
138
+ // ------------------------------------------------------------------ reading a drawing
139
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
140
+ /** Text as every release has kept it: trimmed, and blank as none. Anything else is left for the schema to name. */
141
+ const said = (value) => value === undefined || value === null ? null : typeof value === "string" ? value.trim() === "" ? null : value.trim() : value;
142
+ const coordinate = (value, min, max, fallback) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(max, Math.max(min, value))) : fallback;
143
+ const given = (value) => value !== undefined && value !== null;
144
+ /** A sort zone's settings as every release has kept them: an answer's meaning defaults to its name, sureAt to 0.8 (within
145
+ * 0.5–0.99), and each extra note gets a free id. */
146
+ function canonicalSort(input) {
147
+ const raw = isRecord(input) ? input : {};
148
+ const answers = Array.isArray(raw["answers"]) ? raw["answers"] : given(raw["answers"]) ? raw["answers"] : [];
149
+ 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;
150
+ const noteIds = new Set(["route"]);
151
+ const notes = Array.isArray(raw["notes"]) ? raw["notes"].map(one => {
152
+ const note = isRecord(one) ? one : {};
153
+ const question = said(note["question"]);
154
+ const kind = note["kind"] === "score" ? "score" : "yes-no";
155
+ const words = typeof question === "string" ? question : "";
156
+ let id = typeof note["id"] === "string" && ZONE_ID.test(note["id"]) ? note["id"] : sortKeyOf(words).slice(0, 24);
157
+ for (let n = 2; noteIds.has(id); n++)
158
+ id = `${sortKeyOf(words).slice(0, 20)}-${n}`;
159
+ noteIds.add(id);
160
+ const levels = kind === "score" ? (Array.isArray(note["levels"]) ? note["levels"] : []).map(said).filter(level => level !== null) : null;
161
+ return { id, kind, question, levels };
162
+ }) : given(raw["notes"]) ? raw["notes"] : [];
163
+ return {
164
+ question: said(raw["question"]),
165
+ answers: Array.isArray(answers) ? answers.map(one => {
166
+ const answer = isRecord(one) ? one : {};
167
+ const name = said(answer["answer"]);
168
+ return { answer: name, means: said(answer["means"]) ?? name, to: said(answer["to"]) };
169
+ }) : answers,
170
+ sureAt, notes,
171
+ };
172
+ }
173
+ /** A script's or teammate's answers as every release has kept them: trimmed, none left out. */
174
+ function canonicalRoutes(input) {
175
+ if (!given(input))
176
+ return undefined;
177
+ if (!Array.isArray(input))
178
+ return input;
179
+ if (input.length === 0)
180
+ return undefined;
181
+ return input.map(one => {
182
+ const row = isRecord(one) ? one : {};
183
+ return { answer: typeof row["answer"] === "string" ? row["answer"].trim() : row["answer"] ?? "", to: typeof row["to"] === "string" ? row["to"].trim() : row["to"] ?? "" };
338
184
  });
185
+ }
186
+ /** A Wait zone's settings: "hours" between two times of day, or a reply or a set time for so long. */
187
+ function canonicalWait(input) {
188
+ const raw = isRecord(input) ? input : {};
189
+ if (raw["for"] === "hours") {
190
+ const zone = typeof raw["timeZone"] === "string" && raw["timeZone"].trim() !== "" ? raw["timeZone"].trim() : null;
191
+ return { for: "hours", minutes: 0, from: clockTime(raw["from"]) ?? raw["from"], to: clockTime(raw["to"]) ?? raw["to"], ...(zone === null ? {} : { timeZone: zone }) };
192
+ }
193
+ return { for: raw["for"] === "time" ? "time" : "reply", minutes: durationMinutes(raw["minutes"]) };
194
+ }
195
+ /**
196
+ * 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
197
+ * that field's default, text is trimmed (blank is none), the canvas place is clamped, a field another kind uses is
198
+ * set aside, and a value no release accepted is left as it is for the schema to name.
199
+ */
200
+ function canonicalStage(input, index) {
201
+ if (!isRecord(input))
202
+ return input;
203
+ const stage = input;
204
+ const kind = stage["kind"];
205
+ const zone = isRecord(stage["zone"]) ? stage["zone"] : {};
206
+ const planning = stage["planning"] === "required" || stage["planning"] === "skip" || stage["planning"] === "auto" ? stage["planning"] : null;
207
+ const runIn = stage["runIn"] === undefined || stage["runIn"] === null ? undefined : stage["runIn"];
208
+ const routes = canonicalRoutes(stage["routes"]);
209
+ 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"];
210
+ const limit = !given(stage["limit"]) ? undefined : isRecord(stage["limit"]) ? { minutes: durationMinutes(stage["limit"]["minutes"]), to: said(stage["limit"]["to"]) } : stage["limit"];
211
+ const request = isRecord(stage["request"]) ? stage["request"] : {};
212
+ const method = ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(request["method"]) ? request["method"] : "POST";
213
+ const email = isRecord(stage["email"]) ? stage["email"] : {};
214
+ const tool = isRecord(stage["tool"]) ? stage["tool"] : {};
215
+ return {
216
+ id: stage["id"], title: said(stage["title"]), kind,
217
+ 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" },
218
+ instructions: said(stage["instructions"]),
219
+ planning: kind === "task" ? planning ?? "auto" : null,
220
+ approver: kind === "approval" && stage["toOwner"] !== true ? said(stage["approver"]) : null,
221
+ ...(kind === "approval" && stage["toOwner"] === true ? { toOwner: true } : {}),
222
+ message: said(stage["message"]),
223
+ close: kind === "update" ? stage["close"] !== false : null,
224
+ script: kind === "check" ? said(stage["script"]) : null,
225
+ // Left out, a script runs where every script ran before v90: in a copy of the card's work.
226
+ ...(kind === "check" ? { ...(runIn === undefined ? {} : { runIn }), ...(routes === undefined ? {} : { routes }), ...(secrets === undefined ? {} : { secrets }) } : {}),
227
+ sort: kind === "sort" ? canonicalSort(stage["sort"]) : null,
228
+ ...(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"]) } } : {}),
229
+ ...(kind === "email" ? { email: { to: said(email["to"]), subject: said(email["subject"]), body: said(email["body"]) } } : {}),
230
+ ...(kind === "tool" ? { tool: { server: said(tool["server"]), name: said(tool["name"]), args: said(tool["args"]) ?? "{}" } } : {}),
231
+ ...(kind === "wait" ? { wait: canonicalWait(stage["wait"]) } : {}),
232
+ // true is a squash, the default; false or none is no merge.
233
+ ...(kind === "pull-request" && given(stage["merge"]) && stage["merge"] !== false ? { merge: stage["merge"] === true ? "squash" : stage["merge"] } : {}),
234
+ ...((kind === "approval" || kind === "teammate") && given(stage["teammate"]) && stage["teammate"] !== "" ? { teammate: stage["teammate"] } : {}),
235
+ ...(kind === "teammate" && routes !== undefined ? { routes } : {}),
236
+ ...(kind === "teammate" && stage["reply"] === true ? { reply: true } : {}),
237
+ ...(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"] : [] } : {}),
238
+ ...(kind === "task" && given(stage["repo"]) && stage["repo"] !== "" ? { repo: said(stage["repo"]) } : {}),
239
+ ...(limit === undefined ? {} : { limit }),
240
+ next: kind === "sort" || kind === "choose" ? null : said(stage["next"]),
241
+ onFail: said(stage["onFail"]),
242
+ };
243
+ }
244
+ /** A version 1 drawing as every release has read it (canonicalStage); its start is the first zone unless it names one. */
245
+ function canonicalDefinition(input) {
246
+ const stages = Array.isArray(input["stages"]) ? input["stages"].map(canonicalStage) : input["stages"];
247
+ const ids = Array.isArray(stages) ? stages.map(one => (isRecord(one) ? one["id"] : undefined)) : [];
248
+ const start = typeof input["start"] === "string" && ids.includes(input["start"]) ? input["start"] : ids[0];
249
+ return { version: Object.prototype.hasOwnProperty.call(input, "version") ? input["version"] : FLOW_DEFINITION_VERSION, start, stages };
250
+ }
251
+ const issue = (path, what, kind = "invalid") => ({ path, kind, line: `${path}: ${what}` });
252
+ /** What JSON Schema can't say about a drawing, each named by its path: paths lead to zones that exist and never back into
253
+ * their own zone, answers and options have names of their own, a web address's host is written out, tool arguments are a
254
+ * JSON object, an hours wait's times differ in a real time zone, and a merge comes after a person's decision. */
255
+ export function drawingProblems(definition) {
256
+ const problems = [];
339
257
  const ids = new Set();
340
- for (const stage of stages) {
258
+ definition.stages.forEach((stage, index) => {
341
259
  if (ids.has(stage.id))
342
- throw new Error(`Two zones are called ${stage.id}.`);
260
+ problems.push(issue(`stages[${index}].id`, `two zones are called ${stage.id}`));
343
261
  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;
262
+ });
263
+ definition.stages.forEach((stage, index) => {
264
+ const at = `stages[${index}]`;
265
+ const target = (path, to) => {
266
+ if (to !== null && !ids.has(to))
267
+ problems.push(issue(`${at}.${path}`, `there's no zone called ${to}`));
268
+ else if (to === stage.id && path !== "next" && path !== "onFail")
269
+ problems.push(issue(`${at}.${path}`, "can't lead back into the same zone"));
270
+ };
271
+ target("next", stage.next);
272
+ target("onFail", stage.onFail);
273
+ if (stage.limit !== undefined)
274
+ target("limit.to", stage.limit.to);
275
+ stage.sort?.answers.forEach((one, n) => target(`sort.answers[${n}].to`, one.to));
276
+ stage.routes?.forEach((one, n) => target(`routes[${n}].to`, one.to));
277
+ stage.options?.forEach((one, n) => {
278
+ if (one.to !== FLOW_END)
279
+ target(`options[${n}].to`, one.to);
280
+ else if (ids.has(FLOW_END))
281
+ 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`));
282
+ });
283
+ const twice = (path, names, key) => {
284
+ const seen = new Set();
285
+ names.forEach((name, n) => {
286
+ if (seen.has(key(name)))
287
+ problems.push(issue(`${at}.${path}[${n}]`, `two are called ${name}`));
288
+ seen.add(key(name));
289
+ });
290
+ };
291
+ if (stage.sort !== null)
292
+ twice("sort.answers", stage.sort.answers.map(one => one.answer), sortKeyOf);
293
+ if (stage.routes !== undefined)
294
+ twice("routes", stage.routes.map(one => one.answer), name => name.toLowerCase());
295
+ if (stage.options !== undefined)
296
+ twice("options", stage.options.map(one => one.label), name => name.toLowerCase());
297
+ if (stage.request !== undefined) {
298
+ const origin = /^(https?):\/\/([^/?#]*)/i.exec(stage.request.url);
299
+ if (origin === null)
300
+ problems.push(issue(`${at}.request.url`, "must start with https:// or http://"));
301
+ else if (origin[2].includes("{{") || origin[2].includes("@") || origin[2] === "")
302
+ problems.push(issue(`${at}.request.url`, "write the address's host out in full; fill-ins go after it"));
303
+ const names = Object.keys(stage.request.headers);
304
+ for (const name of names)
305
+ if (!HEADER.test(name))
306
+ problems.push(issue(`${at}.request.headers`, `“${name}” isn't a header name`));
307
+ if (names.length > HEADERS_MAX)
308
+ problems.push(issue(`${at}.request.headers`, `at most ${HEADERS_MAX} headers`, "too-many"));
309
+ }
310
+ if (stage.tool !== undefined) {
311
+ let args;
312
+ try {
313
+ args = JSON.parse(stage.tool.args);
314
+ }
315
+ catch {
316
+ args = undefined;
317
+ }
318
+ if (!isRecord(args))
319
+ problems.push(issue(`${at}.tool.args`, `must be a JSON object, like {"text": "{{stage.draft}}"}`));
320
+ }
321
+ if (stage.wait !== undefined && (stage.wait.for === "hours") !== (stage.wait.minutes === 0))
322
+ 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"));
323
+ if (stage.wait !== undefined && (stage.wait.for === "hours") !== (stage.wait.from !== undefined && stage.wait.to !== undefined))
324
+ 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"));
325
+ if (stage.wait?.for === "hours") {
326
+ if (stage.wait.from === stage.wait.to)
327
+ problems.push(issue(`${at}.wait.to`, "must differ from from, like 22:00 to 06:00"));
328
+ if (stage.wait.timeZone !== undefined && !knownTimeZone(stage.wait.timeZone))
329
+ problems.push(issue(`${at}.wait.timeZone`, `${stage.wait.timeZone} isn't a time zone, like Europe/London`));
330
+ }
331
+ });
332
+ if (problems.length > 0)
333
+ return problems;
373
334
  // 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) {
335
+ const unapproved = reachableWithout(definition.stages, definition.start, one => one.kind === "approval");
336
+ definition.stages.forEach((stage, index) => {
376
337
  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 };
338
+ problems.push(issue(`stages[${index}].merge`, "a “Person decides” zone must come before it on every path"));
339
+ });
340
+ return problems;
341
+ }
342
+ /**
343
+ * Read a flow's drawing: the canvas's, a step list's once built, or one saved earlier (`stored`, which keeps working under
344
+ * rules added since: its instructions keep the length they were saved with). A version 1 drawing is read as every release
345
+ * has read it (canonicalStage), then the schema checks it, then drawingProblems; a newer version is refused plainly.
346
+ */
347
+ export function readFlowDefinition(input, options = {}) {
348
+ const body = isRecord(input) ? canonicalDefinition(input) : input;
349
+ const read = readVersioned(options.stored === true ? savedFlowDefinitionSchema : flowDefinitionSchema, body, {}, FLOW_ALIASES);
350
+ // The start is the first zone unless a zone it names exists: a problem with it is a problem with the zones, said there.
351
+ if (!read.ok)
352
+ return { ok: false, issues: read.issues.some(one => one.path.startsWith("stages")) ? read.issues.filter(one => one.path !== "start") : read.issues };
353
+ const problems = drawingProblems(read.value);
354
+ return problems.length === 0 ? read : { ok: false, issues: problems };
355
+ }
356
+ /** The saved drawing's fields in the words steps and flow files use for them. */
357
+ const STEP_WORDS = [
358
+ [/^tool\.args/, "args"], [/^tool\.server/, "server"], [/^tool\.name/, "tool"], [/^request\.(url|headers|body|method)/, "$1"], [/^email\.(to|subject|body)/, "$1"],
359
+ [/^sort\.answers\[(\d+)\]\.to/, "answers[$1].goesTo"], [/^sort\.answers/, "answers"], [/^sort\.question/, "question"], [/^sort\.sureAt/, "sureAt"], [/^sort\.notes/, "alsoNote"],
360
+ [/^(routes|options)\[(\d+)\]\.to/, "$1[$2].goesTo"], [/^limit\.to/, "thenMoveTo"], [/^limit(\.minutes)?/, "remindAfter"], [/^wait\.to/, "until"], [/^wait\.(from|timeZone)/, "$1"], [/^wait(\.minutes)?/, "wait"],
361
+ [/^approver/, "decider"], [/^toOwner/, "decider"],
362
+ ];
363
+ /** A refusal of the drawing a step list or flow file became, in its words: `stages[2].sort.answers[0].to` is
364
+ * `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. */
365
+ export function inStepWords(line, list, kinds) {
366
+ const match = /^stages\[(\d+)\](?:\.([^:]*))?:(.*)$/s.exec(line);
367
+ if (match === null)
368
+ return line;
369
+ const index = Number(match[1]), kind = kinds[index];
370
+ let field = match[2] ?? "";
371
+ if (field === "onFail")
372
+ field = kind === "sort" ? "ifNotSure" : kind === "wait" ? "ifNoReply" : kind === "choose" ? "ifReplied" : "ifFails";
373
+ else
374
+ for (const [from, to] of STEP_WORDS)
375
+ if (from.test(field)) {
376
+ field = field.replace(from, to);
377
+ break;
378
+ }
379
+ return `${list}[${index}]${field === "" ? "" : `.${field}`}:${match[3]}`;
380
+ }
381
+ /** A flow as drawn on the canvas, checked whole (readFlowDefinition). Throws FlowContractError, naming every path. */
382
+ export function validateFlowDefinition(input, options = {}) {
383
+ const read = readFlowDefinition(input, options);
384
+ if (!read.ok)
385
+ throw new FlowContractError(read.issues.map(one => one.line));
386
+ return read.value;
387
+ }
388
+ /** 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
389
+ * finds it on the canvas (`Build · stages[1].instructions: required`). */
390
+ export function withZoneNames(lines, input) {
391
+ const stages = isRecord(input) && Array.isArray(input["stages"]) ? input["stages"] : [];
392
+ return lines.map(line => {
393
+ const index = /^stages\[(\d+)\]/.exec(line)?.[1];
394
+ const stage = index === undefined ? undefined : stages[Number(index)];
395
+ const title = isRecord(stage) && typeof stage["title"] === "string" && stage["title"].trim() !== "" ? stage["title"].trim() : null;
396
+ return title === null ? line : `${title} · ${line}`;
397
+ }).join("\n");
380
398
  }
381
399
  /** 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
400
  * longer one won't load. A saved zone keeps its first part under it, and the rest in `instructionsMore`, which they ignore. */
@@ -431,14 +449,6 @@ export function flowDefinitionFromStore(json) {
431
449
  });
432
450
  return JSON.stringify({ ...raw, stages: whole });
433
451
  }
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
452
  /** The zones a card can reach from `start` without passing through a zone `stop` picks (those zones aren't included). */
443
453
  export function reachableWithout(stages, start, stop) {
444
454
  const seen = new Set();
@@ -461,82 +471,6 @@ export function choiceTargets(stage) {
461
471
  export function replyTarget(stage) {
462
472
  return stage.onFail ?? choiceTargets(stage)[0] ?? null;
463
473
  }
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
474
  /** What a card's work is held to: the zones' steps and paths, never where they sit on the canvas. */
541
475
  export function flowDigest(definition) {
542
476
  const terms = definition.stages.map(({ zone: _zone, ...rest }) => rest);
@@ -637,28 +571,29 @@ const idOf = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").r
637
571
  const slugOf = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 28) || "zone";
638
572
  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
573
  /** 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) {
574
+ function waitFromStep(step, old, at) {
641
575
  if ((step.waitFor ?? old?.for) === "hours") {
642
576
  const from = clockTime(step.from ?? old?.from ?? "22:00"), to = clockTime(step.until ?? old?.to ?? "06:00");
643
577
  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 }) };
578
+ throw new FlowContractError([`${at}.from: say the hours it waits for, like from 22:00 until 06:00`]);
579
+ const timeZone = step.timeZone ?? old?.timeZone;
580
+ return { for: "hours", minutes: 0, from, to, ...(timeZone === undefined ? {} : { timeZone }) };
646
581
  }
647
582
  const minutes = step.wait === undefined ? old?.minutes ?? 3 * 24 * 60 : durationMinutes(step.wait);
648
583
  if (minutes === null)
649
- throw new Error(`Step ${title}: say how long it waits, like "3 days" or "4 hours" (up to 30 days).`);
584
+ throw new FlowContractError([`${at}.wait: say how long it waits, like "3 days" or "4 hours" (up to 30 days)`]);
650
585
  return { for: step.waitFor ?? old?.for ?? "reply", minutes };
651
586
  }
652
587
  /** 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) {
588
+ function limitFromStep(step, old, at, find) {
654
589
  if (step.remindAfter === undefined)
655
- return old === null ? null : { ...old, ...(step.thenMoveTo === undefined ? {} : { to: step.thenMoveTo.trim() === "" ? null : find(step.thenMoveTo) }) };
590
+ return old === null ? null : { ...old, ...(step.thenMoveTo === undefined ? {} : { to: step.thenMoveTo.trim() === "" ? null : find(step.thenMoveTo, "thenMoveTo") }) };
656
591
  if (typeof step.remindAfter === "string" && /^\s*(none|never|no|off)?\s*$/i.test(step.remindAfter))
657
592
  return null;
658
593
  const minutes = durationMinutes(step.remindAfter);
659
594
  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 };
595
+ throw new FlowContractError([`${at}.remindAfter: say when to remind, like "2 days" (up to 30 days)`]);
596
+ return { minutes, to: typeof step.thenMoveTo === "string" && step.thenMoveTo.trim() !== "" ? find(step.thenMoveTo, "thenMoveTo") : step.thenMoveTo === undefined ? old?.to ?? null : null };
662
597
  }
663
598
  /** What a draft zone asks for when the steps leave it out. */
664
599
  export const DRAFT_DEFAULT = "Write a short, friendly reply to the person who sent this card, in plain words.";
@@ -671,69 +606,92 @@ function defaultInstructions(kind, earlier) {
671
606
  }
672
607
  /** Steps as the lead's flow tool and `toolroll flows` take them: "me" as a decider is the person asking, stored by name. */
673
608
  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;
609
+ 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;
610
+ }
611
+ /** 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
612
+ * reads as the one schema its kind has. Anything else it leaves out carries over when the flow is drawn (flowFromSteps). */
613
+ export function withKeptSteps(steps, previous) {
614
+ if (previous === null || !Array.isArray(steps))
615
+ return steps;
616
+ const kept = new Map(previous.stages.map(one => [one.id, one]));
617
+ const asked = new Set();
618
+ return steps.map(step => {
619
+ if (!isRecord(step) || typeof step["id"] !== "string")
620
+ return step;
621
+ const id = idOf(step["id"]);
622
+ const old = asked.has(id) ? undefined : kept.get(id);
623
+ asked.add(id);
624
+ if (old === undefined)
625
+ return step;
626
+ return {
627
+ ...step,
628
+ ...(step["kind"] === undefined ? { kind: old.kind } : {}),
629
+ ...(step["title"] === undefined || (typeof step["title"] === "string" && step["title"].trim() === "") ? { title: old.title } : {}),
630
+ };
631
+ });
632
+ }
633
+ /** A flow's steps, read by their one schema: `steps[0].routes[0]: unknown key 'to' (did you mean goesTo?)`. */
634
+ export function readFlowSteps(input, previous = null) {
635
+ const read = parseContract(flowStepsSchema, { steps: withKeptSteps(input, previous) }, FLOW_ALIASES);
636
+ return read.ok ? { ok: true, value: read.value.steps } : read;
675
637
  }
676
638
  /**
677
639
  * A flow from an ordered list of steps: ids from names, each step leading
678
640
  * to the next, a Done zone at the end when none is listed, and a decision
679
641
  * 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.
642
+ * are laid out in rows; zones kept from `previous` keep their place. Throws
643
+ * FlowContractError, each line naming the step's path (`steps[2].next: ...`).
681
644
  */
682
645
  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.");
646
+ const read = readFlowSteps(input, previous);
647
+ if (!read.ok)
648
+ throw new FlowContractError(read.issues.map(one => one.line));
687
649
  const kept = new Map((previous?.stages ?? []).map(one => [one.id, one]));
688
650
  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;
651
+ const drafts = read.value.map((step, index) => {
693
652
  const asked = typeof step.id === "string" ? idOf(step.id) : "";
694
653
  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 ?? "";
654
+ const title = step.title.trim() !== "" ? step.title.trim() : old?.title ?? "";
696
655
  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.`);
656
+ throw new FlowContractError([`steps[${index}].title: must not be empty`]);
657
+ const kind = step.kind;
701
658
  // 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;
659
+ const given = ZONE_ID.test(asked) && !used.has(asked) ? asked : null;
703
660
  let id = old?.id ?? given ?? slugOf(title);
704
661
  for (let n = 2; old === null && used.has(id); n++)
705
662
  id = `${slugOf(title).slice(0, 25)}-${n}`;
706
663
  used.add(id);
707
664
  const same = old !== null && old.kind === kind;
708
- return { step, old: same ? old : null, id, title, kind: kind };
665
+ return { step, old: same ? old : null, id, title, kind, at: `steps[${index}]` };
709
666
  });
710
667
  if (!drafts.some(one => one.kind === "done")) {
711
668
  let id = "done";
712
669
  for (let n = 2; used.has(id); n++)
713
670
  id = `done-${n}`;
714
- drafts.push({ step: {}, old: kept.get(id)?.kind === "done" ? kept.get(id) : null, id, title: "Done", kind: "done" });
671
+ 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
672
  }
716
- const find = (ref, from) => {
673
+ const findFrom = (at) => (ref, path) => {
717
674
  const wanted = ref.trim().toLowerCase();
718
675
  const hit = drafts.find(one => one.id === ref.trim() || one.id === idOf(ref) || one.title.toLowerCase() === wanted);
719
676
  if (hit === undefined)
720
- throw new Error(`Step ${from}: there's no step called ${ref}.`);
677
+ throw new FlowContractError([`${at}.${path}: there's no step called ${ref}`]);
721
678
  return hit.id;
722
679
  };
723
680
  const stages = [];
724
- drafts.forEach(({ step, old, id, title, kind }, index) => {
681
+ drafts.forEach(({ step, old, id, title, kind, at }, index) => {
682
+ const find = findFrom(at);
725
683
  const earlier = stages.slice();
726
684
  const following = drafts.slice(index + 1).find(() => true) ?? null;
727
685
  const next = kind === "done" || kind === "sort" || kind === "choose" ? null
728
- : typeof step.next === "string" && step.next.trim() !== "" ? find(step.next, title)
686
+ : typeof step.next === "string" && step.next.trim() !== "" ? find(step.next, "next")
729
687
  : following?.id ?? drafts.find(one => one.kind === "done").id;
730
688
  const keptFail = old?.onFail !== null && old?.onFail !== undefined && drafts.some(one => one.id === old.onFail) ? old.onFail : null;
731
689
  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;
690
+ const [failKey, notSure] = kind === "sort" ? ["ifNotSure", step.ifNotSure]
691
+ : kind === "wait" ? ["ifNoReply", step.ifNoReply]
692
+ : kind === "choose" ? ["ifReplied", step.ifReplied] : ["ifFails", step.ifFails];
735
693
  const onFail = kind === "done" ? null
736
- : typeof notSure === "string" && notSure.trim() !== "" ? find(notSure, title)
694
+ : typeof notSure === "string" && notSure.trim() !== "" ? find(notSure, failKey)
737
695
  : keptFail ?? (kind === "approval" ? worker?.id ?? (drafts[0].id === id ? null : drafts[0].id)
738
696
  // A reply to a choice says what to change: it goes back to the work before it, as its note.
739
697
  : kind === "choose" ? worker?.id ?? null
@@ -744,6 +702,7 @@ export function flowFromSteps(input, previous = null) {
744
702
  const approver = kind !== "approval" || toOwner ? null
745
703
  : step.decider === undefined ? old?.approver ?? null
746
704
  : step.decider === null || /^(anyone|any approver|anybody)$/i.test(step.decider.trim()) ? null : step.decider.trim();
705
+ const routesOf = (routes) => routes.length === 0 ? {} : { routes: routes.map((one, n) => ({ answer: one.answer.trim(), to: find(one.goesTo, `routes[${n}].goesTo`) })) };
747
706
  stages.push({
748
707
  id, title, kind,
749
708
  zone: old?.zone ?? { x: 0, y: 0, w: 260, h: kind === "done" || kind === "notify" || kind === "send" ? 220 : 300, color: KIND_COLORS[kind] },
@@ -763,27 +722,27 @@ export function flowFromSteps(input, previous = null) {
763
722
  // 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
723
  ...(kind === "check" ? {
765
724
  ...(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 }),
725
+ ...(step.routes !== undefined ? routesOf(step.routes) : old?.routes === undefined ? {} : { routes: old.routes }),
767
726
  ...(step.secrets !== undefined ? step.secrets.length === 0 ? {} : { secrets: step.secrets } : old?.secrets === undefined ? {} : { secrets: old.secrets }),
768
727
  } : {}),
769
- sort: kind === "sort" ? sortFromStep(step, old?.sort ?? null, ref => find(ref, title)) : null,
728
+ sort: kind === "sort" ? sortFromStep(step, old?.sort ?? null, find) : null,
770
729
  ...(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
730
  ...(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
731
  ...(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) } : {}),
732
+ ...(kind === "wait" ? { wait: waitFromStep(step, old?.wait ?? null, at) } : {}),
774
733
  ...(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
734
  // v92: "nobody" (or "none") takes a teammate off an approval step.
776
735
  ...((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 } : {}),
736
+ ...(kind === "teammate" ? step.routes !== undefined ? routesOf(step.routes) : old?.routes === undefined ? {} : { routes: old.routes } : {}),
778
737
  ...(kind === "teammate" && (step.reply ?? old?.reply) === true ? { reply: true } : {}),
779
- ...(kind === "choose" ? { options: choicesFromStep(step, old?.options ?? null, title, ref => find(ref, title)) } : {}),
738
+ ...(kind === "choose" ? { options: choicesFromStep(step, old?.options ?? null, at, find) } : {}),
780
739
  ...(kind === "task" && (step.repo ?? old?.repo) !== undefined && (step.repo ?? old?.repo).trim() !== "" ? { repo: (step.repo ?? old?.repo).trim() } : {}),
781
740
  ...(() => {
782
741
  // "If no reply" on a choice is where it moves once the reminder comes.
783
742
  const asked = kind === "choose" && step.thenMoveTo === undefined && typeof step.ifNoReply === "string" ? { ...step, thenMoveTo: step.ifNoReply } : step;
784
743
  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));
744
+ throw new FlowContractError([`${at}.remindAfter: say how long to wait for a choice first (like "2 days")`]);
745
+ const limit = kind === "wait" || kind === "done" ? null : limitFromStep(asked, old?.limit ?? null, at, find);
787
746
  return limit === null ? {} : { limit };
788
747
  })(),
789
748
  next, onFail,
@@ -852,29 +811,35 @@ export function flowFromSteps(input, previous = null) {
852
811
  stage.zone = at;
853
812
  placed.push(at);
854
813
  });
855
- return validateFlowDefinition({ version: 1, start: stages[0].id, stages });
814
+ // The drawing is checked as the canvas's is; its zones are the steps, in order, so a zone's problem names its step.
815
+ const drawn = readFlowDefinition({ version: FLOW_DEFINITION_VERSION, start: stages[0].id, stages });
816
+ if (!drawn.ok)
817
+ throw new FlowContractError(drawn.issues.map(one => inStepWords(one.line, "steps", stages.map(stage => stage.kind))));
818
+ return drawn.value;
856
819
  }
857
820
  /** 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) {
821
+ function choicesFromStep(step, old, at, find) {
822
+ if (step.options === undefined) {
860
823
  if (old !== null)
861
824
  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").`);
825
+ 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
826
  }
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) };
827
+ return step.options.map((one, n) => {
828
+ const label = one.label.trim();
829
+ const goes = (one.goesTo ?? "").trim();
830
+ return { label, to: goes === "" || /^(end|ignore|ignored|stop|close)$/i.test(goes) ? FLOW_END : find(goes, `options[${n}].goesTo`) };
868
831
  });
869
832
  }
870
833
  /** 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
834
  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) : "" }))
835
+ const answers = step.answers !== undefined
836
+ ? 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
837
  : old?.answers ?? [];
875
838
  // 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 }))
839
+ const sure = step.sureAt !== undefined ? (step.sureAt > 1 ? step.sureAt / 100 : step.sureAt) : old?.sureAt ?? SORT_SURE_AT;
840
+ const notes = step.alsoNote !== undefined ? step.alsoNote.map(one => one.kind === "score"
841
+ ? { id: one.id ?? "", kind: "score", question: one.question.trim(), levels: one.levels ?? [] }
842
+ : { id: one.id ?? "", kind: "yes-no", question: one.question.trim(), levels: null })
878
843
  : old?.notes ?? [];
879
844
  return { question: step.question?.trim() || old?.question || "", answers, sureAt: sure, notes };
880
845
  }