toolroll 0.9.36 → 0.9.38

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 (175) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/assignment-adapters.d.ts +12 -48
  3. package/dist/assignment-adapters.js +33 -43
  4. package/dist/browser/THIRD_PARTY_NOTICES.txt +3 -3
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +26 -26
  7. package/dist/browser-crew.js +5 -0
  8. package/dist/browser-workspace.d.ts +2 -0
  9. package/dist/build-review.d.ts +1 -1
  10. package/dist/build-review.js +2 -1
  11. package/dist/chat-acceptance.d.ts +1 -1
  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-channel.js +20 -9
  16. package/dist/chat-decide.d.ts +18 -4
  17. package/dist/chat-decide.js +31 -11
  18. package/dist/chat-delivery-state.d.ts +12 -70
  19. package/dist/chat-delivery-state.js +17 -135
  20. package/dist/chat-delivery.js +58 -30
  21. package/dist/chat-flow.js +8 -6
  22. package/dist/chat-inbox.js +1 -1
  23. package/dist/coding-context.d.ts +2 -39
  24. package/dist/coding-context.js +2 -1
  25. package/dist/coding-handoff.d.ts +6 -25
  26. package/dist/coding-handoff.js +11 -5
  27. package/dist/coding-types.d.ts +2 -26
  28. package/dist/coding-workspace.d.ts +2 -0
  29. package/dist/coding-workspace.js +50 -14
  30. package/dist/contracts/acceptance-terms.d.ts +14 -0
  31. package/dist/contracts/acceptance-terms.js +16 -0
  32. package/dist/contracts/chat-actions.d.ts +328 -0
  33. package/dist/contracts/chat-actions.js +133 -0
  34. package/dist/contracts/chat-callback-rows.d.ts +59 -0
  35. package/dist/contracts/chat-callback-rows.js +46 -0
  36. package/dist/contracts/chat-content.d.ts +276 -0
  37. package/dist/contracts/chat-content.js +127 -0
  38. package/dist/contracts/chat-tables.d.ts +6 -0
  39. package/dist/contracts/chat-tables.js +136 -0
  40. package/dist/contracts/coding-context.d.ts +97 -0
  41. package/dist/contracts/coding-context.js +60 -0
  42. package/dist/contracts/coding-handoff.d.ts +44 -0
  43. package/dist/contracts/coding-handoff.js +47 -0
  44. package/dist/contracts/coding-workspace.d.ts +165 -0
  45. package/dist/contracts/coding-workspace.js +71 -0
  46. package/dist/contracts/contract.d.ts +7 -2
  47. package/dist/contracts/contract.js +21 -12
  48. package/dist/contracts/decision.d.ts +45 -0
  49. package/dist/contracts/decision.js +45 -0
  50. package/dist/contracts/discord-callback.d.ts +45 -0
  51. package/dist/contracts/discord-callback.js +34 -0
  52. package/dist/contracts/flow-card.d.ts +50 -0
  53. package/dist/contracts/flow-card.js +46 -0
  54. package/dist/contracts/flow-send.d.ts +119 -0
  55. package/dist/contracts/flow-send.js +112 -0
  56. package/dist/contracts/flow.d.ts +5 -0
  57. package/dist/contracts/flow.js +5 -0
  58. package/dist/contracts/gateway-tools.d.ts +374 -0
  59. package/dist/contracts/gateway-tools.js +94 -0
  60. package/dist/contracts/lead-context.d.ts +165 -0
  61. package/dist/contracts/lead-context.js +73 -0
  62. package/dist/contracts/lead-tools.d.ts +1905 -0
  63. package/dist/contracts/lead-tools.js +265 -0
  64. package/dist/contracts/memory-pass.d.ts +158 -0
  65. package/dist/contracts/memory-pass.js +102 -0
  66. package/dist/contracts/project-knowledge.d.ts +126 -0
  67. package/dist/contracts/project-knowledge.js +93 -0
  68. package/dist/contracts/project-memory.d.ts +117 -0
  69. package/dist/contracts/project-memory.js +51 -0
  70. package/dist/contracts/project-skills.d.ts +121 -0
  71. package/dist/contracts/project-skills.js +76 -0
  72. package/dist/contracts/review-findings.d.ts +46 -0
  73. package/dist/contracts/review-findings.js +48 -0
  74. package/dist/contracts/route.d.ts +484 -0
  75. package/dist/contracts/route.js +128 -0
  76. package/dist/contracts/scope.d.ts +105 -0
  77. package/dist/contracts/scope.js +114 -0
  78. package/dist/contracts/scout-report.d.ts +234 -0
  79. package/dist/contracts/scout-report.js +141 -0
  80. package/dist/contracts/slack-callback.d.ts +43 -0
  81. package/dist/contracts/slack-callback.js +35 -0
  82. package/dist/contracts/sort-answer.d.ts +83 -0
  83. package/dist/contracts/sort-answer.js +77 -0
  84. package/dist/contracts/stage-output.d.ts +127 -0
  85. package/dist/contracts/stage-output.js +166 -0
  86. package/dist/contracts/task-sizing.d.ts +24 -0
  87. package/dist/contracts/task-sizing.js +28 -0
  88. package/dist/contracts/teammate-turn.d.ts +43 -0
  89. package/dist/contracts/teammate-turn.js +51 -0
  90. package/dist/contracts/teams-callback.d.ts +14 -0
  91. package/dist/contracts/teams-callback.js +14 -0
  92. package/dist/contracts/telegram-callback.d.ts +120 -0
  93. package/dist/contracts/telegram-callback.js +97 -0
  94. package/dist/decision.d.ts +11 -20
  95. package/dist/decision.js +86 -167
  96. package/dist/desktop-update.d.ts +1 -1
  97. package/dist/discord-chat.js +31 -20
  98. package/dist/flow-engine.js +14 -17
  99. package/dist/flow-items.d.ts +4 -13
  100. package/dist/flow-items.js +1 -15
  101. package/dist/flow-send.d.ts +6 -37
  102. package/dist/flow-send.js +18 -14
  103. package/dist/flow-share.js +7 -1
  104. package/dist/flow-sort.d.ts +7 -26
  105. package/dist/flow-sort.js +25 -20
  106. package/dist/flow-steps.js +3 -2
  107. package/dist/flows.d.ts +2 -4
  108. package/dist/flows.js +9 -5
  109. package/dist/invoke.js +4 -4
  110. package/dist/lead-context.d.ts +4 -2
  111. package/dist/lead-context.js +24 -17
  112. package/dist/mate-progress.d.ts +26 -0
  113. package/dist/mate-progress.js +24 -0
  114. package/dist/mate-tools.d.ts +4 -0
  115. package/dist/mate-tools.js +108 -275
  116. package/dist/mate.js +11 -2
  117. package/dist/mcp-connect.d.ts +24 -4
  118. package/dist/mcp-connect.js +78 -15
  119. package/dist/mcp.js +84 -216
  120. package/dist/memory-pass.d.ts +17 -42
  121. package/dist/memory-pass.js +54 -26
  122. package/dist/phase-routing.d.ts +19 -72
  123. package/dist/phase-routing.js +34 -164
  124. package/dist/project-knowledge.d.ts +16 -35
  125. package/dist/project-knowledge.js +42 -21
  126. package/dist/project-memory.d.ts +2 -28
  127. package/dist/project-memory.js +28 -13
  128. package/dist/project-skills.d.ts +16 -38
  129. package/dist/project-skills.js +41 -32
  130. package/dist/project-tools.d.ts +1 -0
  131. package/dist/project-tools.js +4 -4
  132. package/dist/provider.d.ts +2 -0
  133. package/dist/provider.js +4 -16
  134. package/dist/result-shots.d.ts +1 -1
  135. package/dist/result-shots.js +2 -1
  136. package/dist/review-switch.d.ts +3 -8
  137. package/dist/reviewer.d.ts +8 -12
  138. package/dist/reviewer.js +11 -35
  139. package/dist/scope.d.ts +21 -43
  140. package/dist/scope.js +42 -128
  141. package/dist/scout-report.d.ts +9 -194
  142. package/dist/scout-report.js +113 -264
  143. package/dist/scout.js +15 -11
  144. package/dist/serve.js +23 -5
  145. package/dist/setup-guide.d.ts +2 -2
  146. package/dist/slack-chat.js +26 -20
  147. package/dist/slack-state.js +2 -1
  148. package/dist/store.d.ts +9 -32
  149. package/dist/store.js +18 -15
  150. package/dist/structured-output.js +2 -1
  151. package/dist/subscription-chat.d.ts +4 -0
  152. package/dist/subscription-chat.js +4 -1
  153. package/dist/task-review-brief.d.ts +1 -7
  154. package/dist/task-review-brief.js +6 -6
  155. package/dist/task-sizing.d.ts +3 -27
  156. package/dist/task-sizing.js +11 -26
  157. package/dist/task-text.d.ts +0 -5
  158. package/dist/task-text.js +1 -3
  159. package/dist/teammate-question.js +7 -5
  160. package/dist/teammate-work.js +9 -5
  161. package/dist/teammates.d.ts +13 -64
  162. package/dist/teammates.js +32 -33
  163. package/dist/teams-chat.js +20 -11
  164. package/dist/telegram-flow.d.ts +9 -0
  165. package/dist/telegram-flow.js +44 -8
  166. package/dist/telegram-mate.d.ts +4 -14
  167. package/dist/telegram-mate.js +9 -8
  168. package/dist/telegram-team.d.ts +2 -19
  169. package/dist/telegram.d.ts +1 -1
  170. package/dist/telegram.js +94 -68
  171. package/dist/text-limits.d.ts +142 -1
  172. package/dist/text-limits.js +144 -1
  173. package/dist/tools-ui.d.ts +2 -1
  174. package/dist/tools-ui.js +7 -4
  175. package/package.json +17 -12
@@ -5,7 +5,9 @@ import { chatFlowButtons } from "./chat-flow.js";
5
5
  import { refuseResultShots, resultShotsPruned, shotWaitsUntil } from "./result-shots.js";
6
6
  import { channelInbox } from "./chat-inbox.js";
7
7
  import { roomCommand } from "./chat-rooms.js";
8
- import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.js";
8
+ import { ChatState, ChatDeliveryError, chatHash, partContent, } from "./chat-delivery-state.js";
9
+ import { readChatPart } from "./contracts/chat-content.js";
10
+ import { readDiscordButtonToken, readDiscordComponentInteraction } from "./contracts/discord-callback.js";
9
11
  import { processChatEvent, planChatNotifications, channelAccess, chatObject as object, planRoomMessages, } from "./chat-delivery.js";
10
12
  import { chatResultHref } from "./chat-controls.js";
11
13
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
@@ -69,21 +71,25 @@ export function receiveDiscord(state, identity, type, raw, now) {
69
71
  };
70
72
  }
71
73
  else if (type === "INTERACTION_CREATE") {
72
- const data = object(body.data), message = object(body.message);
73
- if (body.type !== 3 ||
74
- body.application_id !== identity.app ||
75
- (isRoom ? object(body.channel).type !== 0 : object(body.channel).type !== 1) ||
76
- object(message.author).id !== identity.bot ||
77
- message.channel_id !== body.channel_id ||
78
- !/^so_[a-f0-9]{32}$/.test(String(data.custom_id)))
74
+ // Only a tap on one of this bot's own messages, in a DM (or a followed guild text channel), from this app.
75
+ const interaction = readDiscordComponentInteraction(body);
76
+ if (!interaction.ok)
79
77
  return false;
80
- member = isRoom ? object(object(body.member).user).id : object(body.user).id;
81
- channel = body.channel_id;
82
- id = body.id;
83
- ts = message.id;
84
- thread = message.id;
78
+ const tap = interaction.value;
79
+ if (tap.application_id !== identity.app ||
80
+ tap.channel.type !== (isRoom ? 0 : 1) ||
81
+ tap.message.author.id !== identity.bot ||
82
+ tap.message.channel_id !== tap.channel_id)
83
+ return false;
84
+ member = isRoom ? tap.member?.user.id : tap.user?.id;
85
+ channel = tap.channel_id;
86
+ id = tap.id;
87
+ ts = tap.message.id;
88
+ thread = tap.message.id;
85
89
  kind = "action";
86
- payload = { token: String(data.custom_id).slice(3) };
90
+ // A button that isn't one Toolroll made is answered with why, once the tap is proved to be the paired person's below.
91
+ const button = readDiscordButtonToken(tap);
92
+ payload = button.ok ? { token: button.value } : { problem: button.issues.map(issue => issue.line).join("; ") };
87
93
  }
88
94
  else
89
95
  return false;
@@ -94,9 +100,10 @@ export function receiveDiscord(state, identity, type, raw, now) {
94
100
  !discordId(thread) ||
95
101
  member === identity.bot)
96
102
  return false;
103
+ const words = "text" in payload ? payload.text ?? "" : "";
97
104
  // v89: a channel that feeds a flow takes anyone's message (as a card, with no say over anything); "flow 12" connects one.
98
- const inbox = isRoom && kind === "message" ? channelInbox(state.store, "discord", identity.installation, String(channel), String(payload.text ?? "")) : { watched: false, command: false };
99
- const roomish = isRoom && (state.room(identity.installation, String(channel)) !== null || (kind === "message" && roomCommand(String(payload.text ?? "")) !== null) || inbox.watched || inbox.command);
105
+ const inbox = isRoom && kind === "message" ? channelInbox(state.store, "discord", identity.installation, String(channel), words) : { watched: false, command: false };
106
+ const roomish = isRoom && (state.room(identity.installation, String(channel)) !== null || (kind === "message" && roomCommand(words) !== null) || inbox.watched || inbox.command);
100
107
  if (isRoom && !roomish)
101
108
  return false;
102
109
  const binding = state.bindingFor(identity.installation, member);
@@ -116,7 +123,7 @@ export function receiveDiscord(state, identity, type, raw, now) {
116
123
  member,
117
124
  ts,
118
125
  thread,
119
- payload: JSON.stringify(payload),
126
+ payload,
120
127
  created: now.toISOString(),
121
128
  });
122
129
  }
@@ -200,7 +207,7 @@ export async function deliverDiscordPart(options) {
200
207
  const session = event.session === null ? null : store.getMateSession(event.session), repos = await channelAccess(shared, binding, session?.ceilingDigest);
201
208
  if (event.kind === "notice" && options.canNotify?.() === false)
202
209
  return false;
203
- const content = JSON.parse(row.payload);
210
+ const content = partContent(row.payload);
204
211
  const destination = content.channel ?? binding.channel;
205
212
  if (content.task &&
206
213
  !repos.includes(store.lookupRef(content.task)?.repo ?? ""))
@@ -398,9 +405,13 @@ export async function deliverDiscordPart(options) {
398
405
  return true;
399
406
  }
400
407
  catch (error) {
408
+ if (error instanceof ChatDeliveryError && error.permanent) {
409
+ state.prepare("UPDATE chat_part SET state='dropped',next_at=NULL,problem=? WHERE id=?").run(error.message, row.id);
410
+ return true;
411
+ }
401
412
  // No permission to attach files here: one plain line instead of the result's screenshots.
402
- const content = JSON.parse(row.payload);
403
- if (content.image && content.shot && error instanceof DiscordError && error.code === DISCORD_REFUSED) {
413
+ const saved = readChatPart(row.payload), content = saved.ok ? saved.value : null;
414
+ if (content !== null && content.image && content.shot && error instanceof DiscordError && error.code === DISCORD_REFUSED) {
404
415
  refuseResultShots(state, store, row, content, "Discord", options.clock?.() ?? new Date());
405
416
  return true;
406
417
  }
@@ -19,6 +19,7 @@ import { cardFollowers, notifyPeople } from "./flow-people.js";
19
19
  import { DRAFT_CHARS, keptDraft } from "./flow-draft.js";
20
20
  import { chooseStep, flowPersonOf, sendStep } from "./flow-send.js";
21
21
  import { parseSoul, teammateLabel } from "./teammates.js";
22
+ import { withStageHandoff } from "./contracts/stage-output.js";
22
23
  /** The flow's drawing, or null when it can't be read (the card then says so and waits). */
23
24
  export function flowDefinitionOf(flow) {
24
25
  try {
@@ -302,7 +303,7 @@ function workStage(store, flow, definition, stage, card, now, options, outcome,
302
303
  const checks = assignmentOf(store, current, now, { principal: "operator", repos: [repo] }, options.evidenceRoot)?.receipt?.checks ?? null;
303
304
  if (checks?.status === "failed") {
304
305
  const said = `The checks failed on its result: ${checks.detail}`;
305
- store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: `${said} (task ${current})` }, primaryTask: current }, now);
306
+ store.updateFlowCard(card.id, { outputs: withStageHandoff(card.outputs, stage.id, { text: `${said} (task ${current})` }), primaryTask: current }, now);
306
307
  if (stage.onFail !== null)
307
308
  onward("fail", said);
308
309
  else if (card.waiting !== `${said} Look at the result, then move the card on or back.`)
@@ -310,27 +311,21 @@ function workStage(store, flow, definition, stage, card, now, options, outcome,
310
311
  return;
311
312
  }
312
313
  }
313
- const outputs = { ...card.outputs };
314
+ let handoff = { text: `Result ready on task ${current}.` };
314
315
  if (stage.kind === "report") {
315
316
  const ref = store.lookupRef(current);
316
317
  const summary = ref === null ? null : reportSummaryFor(store, options.evidenceRoot, ref.id);
317
- // The summary whole (a report's summary is within TEXT_LIMITS.reportSummary bytes; an older, longer one is linked).
318
- outputs[stage.id] = summary !== null && "summary" in summary ? passOn(summary.summary, TEXT_LIMITS.stageOutput, { label: `task ${current}`, href: null }).text : "The report is ready on its task.";
319
318
  // {{stage.<id>.items}} and {{stage.<id>.report}}: what it found and the whole report, for the zones after it.
320
319
  // A visit's report replaces the last visit's, so nothing stale is left behind.
321
320
  const view = ref === null || options.evidenceRoot === undefined ? null : readVerifiedReport(store, options.evidenceRoot, ref.id);
322
321
  const found = view?.ok === true ? reportFillIns(view.report) : { items: "", report: "" };
323
- for (const [part, text] of Object.entries(found)) {
324
- if (text === "")
325
- delete outputs[`${stage.id}.${part}`];
326
- else
327
- outputs[`${stage.id}.${part}`] = text;
328
- }
329
- }
330
- else {
331
- outputs[stage.id] = `Result ready on task ${current}.`;
322
+ handoff = {
323
+ // The summary whole (a report's summary is within TEXT_LIMITS.reportSummary bytes; an older, longer one is linked).
324
+ text: summary !== null && "summary" in summary ? passOn(summary.summary, TEXT_LIMITS.stageOutput, { label: `task ${current}`, href: null }).text : "The report is ready on its task.",
325
+ ...(found.items === "" ? {} : { items: found.items }), ...(found.report === "" ? {} : { report: found.report }),
326
+ };
332
327
  }
333
- store.updateFlowCard(card.id, { outputs, ...(stage.kind === "task" ? { primaryTask: current } : {}), waiting: null }, now);
328
+ store.updateFlowCard(card.id, { outputs: withStageHandoff(card.outputs, stage.id, handoff), ...(stage.kind === "task" ? { primaryTask: current } : {}), waiting: null }, now);
334
329
  // A "Send to me" or "Person chooses" zone next reads what this task produced: the task goes with the card there.
335
330
  const following = stage.next === null ? undefined : flowDefinitionStage(definition, stage.next);
336
331
  onward("ok", undefined, following?.kind === "send" || following?.kind === "choose" ? current : undefined);
@@ -380,8 +375,10 @@ function goalCuts(store, taskId, goal) {
380
375
  if (seen.has(match[1]))
381
376
  continue;
382
377
  seen.add(match[1]);
383
- const text = fillFlowText(`{{${match[1]}}}`, card);
384
- if (text === "" || goal.includes(text))
378
+ // An output too long to pass on is kept whole on the card (src/contracts/stage-output.ts): the agent gets it whole.
379
+ const whole = match[2] !== undefined && match[3] === undefined ? card.attached[match[2]] : undefined;
380
+ const text = whole ?? fillFlowText(`{{${match[1]}}}`, card);
381
+ if (text === "" || (whole === undefined && goal.includes(text)))
385
382
  continue;
386
383
  cuts.push({ label: flowValueLabel(match[1], id => titleIn(definition, id)), text: text.slice(0, FLOW_ATTACHED_LIMIT) });
387
384
  }
@@ -496,7 +493,7 @@ export function decideFlowCard(store, input, now) {
496
493
  return { ok: false, message: `Keep the draft to ${DRAFT_CHARS.toLocaleString("en-US")} characters; this is ${input.draft.trim().length.toLocaleString("en-US")}.` };
497
494
  }
498
495
  if (draft !== null && typeof input.draft === "string" && input.draft.trim() !== "" && input.draft.trim() !== card.outputs[draft.id]?.trim()) {
499
- store.updateFlowCard(card.id, { outputs: { ...card.outputs, [draft.id]: keptDraft(input.draft) } }, now);
496
+ store.updateFlowCard(card.id, { outputs: withStageHandoff(card.outputs, draft.id, { text: keptDraft(input.draft) }) }, now);
500
497
  store.addFlowComment({ card: card.id, author: input.actor, body: "Edited the draft before approving it.", mentions: [] }, now);
501
498
  }
502
499
  if (stage.next === null) {
@@ -8,17 +8,10 @@
8
8
  * in its own format. Its words go through the channel's own cleaner first. Fitted to a channel's limit, each why is
9
9
  * shortened evenly first; then the summary, then the titles; past that it is split into parts. Links are never cut.
10
10
  */
11
- export type FlowSendItem = {
12
- title: string;
13
- why: string;
14
- url: string;
15
- /** Where the link goes, by name: "Mobbin", "Linear", "Apple". */
16
- source: string;
17
- /** One line: how it plugs in (from the why, else the image's own caption). */
18
- plug: string;
19
- /** The item's screenshot (its evidence id in the report's run), when it has one. */
20
- shot: number | null;
21
- };
11
+ import type { FlowSendItem } from "./contracts/flow-send.js";
12
+ /** One item as the message lists it, and kept items read back (src/contracts/flow-send.ts). */
13
+ export type { FlowSendItem };
14
+ export { readFlowItems } from "./contracts/flow-send.js";
22
15
  /** A link's source by name: a few known sites as they write themselves, else the site's own name from its address. */
23
16
  export declare function itemSource(url: string): string;
24
17
  /** A why as plain lines: its own lines, or "the pattern; why it fits us; how it plugs in" one to a line. */
@@ -42,5 +35,3 @@ export declare function cleanFlowMessage(message: FlowMessage, clean: (text: str
42
35
  * blocks (else between lines). Every link stays.
43
36
  */
44
37
  export declare function fitFlowMessage(message: FlowMessage, limit: number, measure: (shaped: string) => number): string[];
45
- /** Items read back from a kept content; anything malformed is left out. */
46
- export declare function readFlowItems(raw: unknown): FlowSendItem[];
@@ -8,6 +8,7 @@
8
8
  * in its own format. Its words go through the channel's own cleaner first. Fitted to a channel's limit, each why is
9
9
  * shortened evenly first; then the summary, then the titles; past that it is split into parts. Links are never cut.
10
10
  */
11
+ export { readFlowItems } from "./contracts/flow-send.js";
11
12
  const PLUG_CHARS = 160;
12
13
  const NAMES = {
13
14
  "mobbin.com": "Mobbin", "github.com": "GitHub", "figma.com": "Figma", "dribbble.com": "Dribbble", "behance.net": "Behance",
@@ -135,18 +136,3 @@ export function fitFlowMessage(message, limit, measure) {
135
136
  return [compose(message, 0, 0, title)];
136
137
  return pack(blocks(message, Infinity, Infinity, Infinity), "\n\n", fits, block => pack(block.split("\n"), "\n", fits, line => chop(line, fits)));
137
138
  }
138
- /** Items read back from a kept content; anything malformed is left out. */
139
- export function readFlowItems(raw) {
140
- if (!Array.isArray(raw))
141
- return [];
142
- return raw.flatMap((one) => {
143
- if (typeof one !== "object" || one === null)
144
- return [];
145
- const item = one;
146
- const text = (key) => typeof item[key] === "string" ? item[key] : null;
147
- const [title, why, url, source, plug] = [text("title"), text("why"), text("url"), text("source"), text("plug")];
148
- if (title === null || why === null || url === null || source === null || plug === null || !/^https?:\/\//.test(url))
149
- return [];
150
- return [{ title, why, url, source, plug, shot: typeof item["shot"] === "number" && Number.isSafeInteger(item["shot"]) ? item["shot"] : null }];
151
- });
152
- }
@@ -1,5 +1,5 @@
1
1
  import { type FlowDecision } from "./flow-engine.js";
2
- import { type FlowSendItem } from "./flow-items.js";
2
+ import { type FlowChoiceAnswer, type FlowSendContent, type FlowSendLink } from "./contracts/flow-send.js";
3
3
  import { type FlowDefinition, type FlowStage } from "./flows.js";
4
4
  import { type FlowCardRow, type FlowRow, type Store } from "./store.js";
5
5
  /** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
@@ -8,39 +8,14 @@ export declare const FLOW_SEND_KEY: RegExp;
8
8
  export declare const FLOW_CHOOSE_KEY: RegExp;
9
9
  /** What the person is asked under the buttons. */
10
10
  export declare const REPLY_ASK = "Or reply with what you'd change.";
11
- export type FlowSendLink = {
12
- label: string;
13
- path: string;
14
- } | {
15
- label: string;
16
- url: string;
17
- };
18
- export type FlowSendContent = {
19
- /** The card's title and the step it comes from. */
20
- title: string;
21
- /** The step it comes from, when known. */
22
- from?: string;
23
- summary: string;
24
- links: FlowSendLink[];
25
- /** The build's result whose screenshots go with it, when it saved any. */
26
- shots: {
27
- taskId: string;
28
- run: number;
29
- } | null;
30
- /** After research: the report's items, numbered in this order in every message and screenshot caption (flow-items.ts). */
31
- items?: FlowSendItem[];
32
- /** choose: the options as they were offered, and whether a reply is taken. */
33
- options?: {
34
- choice: number;
35
- label: string;
36
- }[];
37
- reply?: boolean;
38
- };
11
+ /** A visit's content and its links (src/contracts/flow-send.ts). */
12
+ export type { FlowSendContent, FlowSendLink };
39
13
  /** Who a card's "Send to me" and "Person chooses" zones are for: its owner, else the flow's. */
40
14
  export declare function flowPersonOf(card: Pick<FlowCardRow, "owner">, flow: Pick<FlowRow, "owner">): string;
41
15
  /** What a visit sends its person, worked out from the card as it is now. */
42
16
  export declare function flowSendContent(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, evidenceRoot?: string): FlowSendContent;
43
- /** A kept content, read back; null when it can't be. */
17
+ /** A kept content, read back (src/contracts/flow-send.ts: one kept before it carried a version reads as it did); null
18
+ * when it can't be, and the visit's content is worked out again. */
44
19
  export declare function readFlowSend(json: string): FlowSendContent | null;
45
20
  /** The words of a sent visit: its title, summary and pull request (the one link that isn't a page of Toolroll's). */
46
21
  export declare function flowSendText(content: FlowSendContent): string;
@@ -71,13 +46,7 @@ export declare function flowChoiceAt(store: Store, cardId: number, entry: number
71
46
  * reply (`choice` null) that becomes the note for where replies go. `where` names the place it was made, for the card's
72
47
  * history and the ledger: "Telegram", "Slack", "the console".
73
48
  */
74
- export declare function chooseFlowCard(store: Store, input: {
75
- card: number;
76
- entry?: number;
77
- choice: number | null;
78
- label?: string;
79
- to?: string;
80
- note: string | null;
49
+ export declare function chooseFlowCard(store: Store, input: FlowChoiceAnswer & {
81
50
  actor: string;
82
51
  where: string;
83
52
  repos: readonly string[];
package/dist/flow-send.js CHANGED
@@ -17,7 +17,9 @@ import { assignmentOf } from "./assignment.js";
17
17
  import { TEXT_LIMITS } from "./text-limits.js";
18
18
  import { withActor } from "./actor.js";
19
19
  import { flowCardHref, flowDefinitionOf, revisionWithNote } from "./flow-engine.js";
20
- import { itemPlug, itemSource, readFlowItems } from "./flow-items.js";
20
+ import { itemPlug, itemSource } from "./flow-items.js";
21
+ import { withStageHandoff } from "./contracts/stage-output.js";
22
+ import { flowSendForStore, readFlowChoiceAnswer, readFlowItems, readFlowSendPayload } from "./contracts/flow-send.js";
21
23
  import { notifyPeople } from "./flow-people.js";
22
24
  import { readVerifiedReport } from "./evidence.js";
23
25
  import { FLOW_END, replyTarget } from "./flows.js";
@@ -120,21 +122,18 @@ export function flowSendContent(store, flow, definition, stage, card, now, evide
120
122
  ...(options === undefined ? {} : { options, reply: replyTarget(stage) !== null }),
121
123
  };
122
124
  }
123
- /** A kept content, read back; null when it can't be. */
125
+ /** A kept content, read back (src/contracts/flow-send.ts: one kept before it carried a version reads as it did); null
126
+ * when it can't be, and the visit's content is worked out again. */
124
127
  export function readFlowSend(json) {
128
+ let raw;
125
129
  try {
126
- const raw = JSON.parse(json);
127
- if (typeof raw.title !== "string" || typeof raw.summary !== "string" || !Array.isArray(raw.links))
128
- return null;
129
- if (raw.items === undefined)
130
- return raw;
131
- const items = readFlowItems(raw.items);
132
- const { items: _, ...rest } = raw;
133
- return items.length === 0 ? rest : { ...rest, items };
130
+ raw = JSON.parse(json);
134
131
  }
135
132
  catch {
136
133
  return null;
137
134
  }
135
+ const read = readFlowSendPayload(raw);
136
+ return read.ok ? read.value : null;
138
137
  }
139
138
  /** The words of a sent visit: its title, summary and pull request (the one link that isn't a page of Toolroll's). */
140
139
  export function flowSendText(content) {
@@ -153,7 +152,7 @@ function keep(store, flow, definition, stage, card, now, evidenceRoot) {
153
152
  if (kept !== null && read !== null)
154
153
  return { content: read, person: kept.person, fresh: false };
155
154
  const content = flowSendContent(store, flow, definition, stage, card, now, evidenceRoot);
156
- const fresh = store.recordFlowSend({ card: card.id, entry: card.entry, stage: stage.id, person, contentJson: JSON.stringify(content) }, now);
155
+ const fresh = store.recordFlowSend({ card: card.id, entry: card.entry, stage: stage.id, person, contentJson: flowSendForStore(content) }, now);
157
156
  return { content, person, fresh };
158
157
  }
159
158
  /** One notice to the person, and its screenshots after it. Never as anyone's own act: they asked for it in the flow. */
@@ -174,7 +173,7 @@ export function sendStep(store, flow, definition, stage, card, now, evidenceRoot
174
173
  const { content, person, fresh } = keep(store, flow, definition, stage, card, now, evidenceRoot);
175
174
  if (!fresh)
176
175
  return;
177
- store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: flowSendText(content) } }, now);
176
+ store.updateFlowCard(card.id, { outputs: withStageHandoff(card.outputs, stage.id, { text: flowSendText(content) }) }, now);
178
177
  enqueue(store, flow, card, person, content, { key: `flow-send:${card.id}:${card.entry}`, kind: "flow-card", attention: false,
179
178
  subject: `${flow.name}: ${content.title}`, body: [content.summary, ...content.links.filter((one) => "url" in one).map(one => `${one.label}: ${one.url}`)].join("\n\n") }, now);
180
179
  }
@@ -217,11 +216,16 @@ const via = (where) => where === "" ? "" : ` in ${where}`;
217
216
  * history and the ledger: "Telegram", "Slack", "the console".
218
217
  */
219
218
  export function chooseFlowCard(store, input, now) {
220
- const card = store.getFlowCard(input.card);
219
+ // The answer itself (src/contracts/flow-send.ts): an option that isn't one of the places offered is a changed option.
220
+ const { actor: _actor, where: _where, repos: _repos, evidenceRoot: _root, ...given } = input;
221
+ const answer = readFlowChoiceAnswer(given);
222
+ if (!answer.ok)
223
+ return { ok: false, message: answer.issues.every(one => one.path === "choice") ? "Those options changed since; nothing was changed." : "That card is no longer waiting." };
224
+ const card = store.getFlowCard(answer.value.card);
221
225
  const flow = card === null ? null : store.getFlow(card.flow);
222
226
  if (card === null || flow === null || card.state !== "active" || !input.repos.includes(flow.repo))
223
227
  return { ok: false, message: "That card is no longer waiting." };
224
- const visit = flowChoiceAt(store, card.id, input.entry ?? card.entry);
228
+ const visit = flowChoiceAt(store, card.id, answer.value.entry ?? card.entry);
225
229
  if (visit === null)
226
230
  return { ok: false, message: "That card has moved on since; nothing was changed." };
227
231
  const { stage, definition, person } = visit;
@@ -22,6 +22,7 @@ import { readFlowSecrets } from "./flow-secrets.js";
22
22
  import { scriptDigest, validateScript } from "./flow-scripts.js";
23
23
  import { addFlowTriggerTo, describeTrigger, HOOK_PATH, readHooksBase, triggerConfigOf, validateTriggerConfig } from "./flow-triggers.js";
24
24
  import { durationMinutes, durationWords, FlowContractError, flowTerms, inStepWords, LANGUAGE_WORDS, validateFlowDefinition } from "./flows.js";
25
+ import { stageReferenceProblems } from "./contracts/stage-output.js";
25
26
  import { readVersioned } from "./contracts/contract.js";
26
27
  import { FLOW_ALIASES, FLOW_FILE_FORMAT, FLOW_FILE_VERSION, flowFileSchema, PARAMETER_ID } from "./contracts/flow.js";
27
28
  import { parseSchedule } from "./routine.js";
@@ -319,7 +320,12 @@ function definitionOf(zones) {
319
320
  const stages = zones.map((step, index) => stageInputOf(step, index, find));
320
321
  // Its zones are the file's, in order: a zone's problem names the file's zone.
321
322
  try {
322
- return validateFlowDefinition({ version: 1, start: stages[0]?.["id"], stages });
323
+ const definition = validateFlowDefinition({ version: 1, start: stages[0]?.["id"], stages });
324
+ // An imported flow is a new one: each {{stage.…}} must be one its zone hands on.
325
+ const references = stageReferenceProblems(definition, null);
326
+ if (references.length > 0)
327
+ throw new FlowContractError(references.map(one => one.line));
328
+ return definition;
323
329
  }
324
330
  catch (error) {
325
331
  return refuse(error instanceof FlowContractError ? error.lines.map(line => inStepWords(line, "zones", zones.map(one => typeof one["kind"] === "string" ? one["kind"] : undefined))).join("\n") : error instanceof Error ? error.message : "Those zones aren't a flow.");
@@ -1,29 +1,8 @@
1
- import { type FlowSort, type FlowStage } from "./flows.js";
1
+ import { type SortDecision } from "./contracts/sort-answer.js";
2
+ import type { FlowSort, FlowStage } from "./flows.js";
2
3
  export declare const JEV_URL = "https://openrouter.ai/api/alpha/decisions";
3
4
  export declare const JEV_MODEL = "~typesafe/jev-latest";
4
- export type SortNoteAnswer = {
5
- id: string;
6
- question: string;
7
- kind: "score" | "yes-no";
8
- answer: string;
9
- sure: number;
10
- };
11
- /** What a sort step decided, kept on its run (decision_json) and shown on the card. */
12
- export type SortDecision = {
13
- model: string;
14
- /** The answer as the zone names it, and how sure Jev was (0–1). */
15
- answer: string;
16
- sure: number;
17
- sureAt: number;
18
- /** Sure enough to act alone, and where the card went (null: it waited in the zone). */
19
- confident: boolean;
20
- to: string | null;
21
- /** Every answer's chance, by name. */
22
- chances: Record<string, number>;
23
- notes: SortNoteAnswer[];
24
- cost: number | null;
25
- ms: number;
26
- };
5
+ export type { SortDecision, SortNoteAnswer } from "./contracts/sort-answer.js";
27
6
  export type SortCard = {
28
7
  title: string;
29
8
  description: string | null;
@@ -67,7 +46,10 @@ export type JevAsked = {
67
46
  };
68
47
  /** Ask Jev through OpenRouter. Trouble reaching it (or a refused key, or no credit) is a reason to try again, in words. */
69
48
  export declare function askJev(fetcher: typeof fetch, key: string, request: JevRequest): Promise<JevAsked>;
70
- /** Jev's answers, read back against the zone's own options only; anything else is a problem, never a route. */
49
+ /**
50
+ * Jev's answers, read through the reply's contract (src/contracts/sort-answer.ts) and then against the zone's own
51
+ * options only; anything else is a problem, never a route. Confidences and chances are clamped into 0–1.
52
+ */
71
53
  export declare function readJevAnswers(stage: FlowStage, body: Record<string, unknown>, ms: number): SortDecision | {
72
54
  problem: string;
73
55
  };
@@ -79,4 +61,3 @@ export declare function sortLog(stage: FlowStage, decision: SortDecision): strin
79
61
  export declare function parseSortDecision(json: string | null): SortDecision | null;
80
62
  /** A card's chip on the canvas: the answer and how sure, and the first score's short level. */
81
63
  export declare function sortChip(decision: SortDecision): string;
82
- export {};
package/dist/flow-sort.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * is instructions: an answer is only ever one of the zone's own options.
9
9
  */
10
10
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
11
- import { sortKeyOf } from "./flows.js";
11
+ import { SORT_ROUTE_KEY, sortKeyOf, sortNoteKeyOf } from "./contracts/flow.js";
12
+ import { readJevReply } from "./contracts/sort-answer.js";
12
13
  export const JEV_URL = "https://openrouter.ai/api/alpha/decisions";
13
14
  export const JEV_MODEL = "~typesafe/jev-latest";
14
15
  /** Jev reads up to 32,000 tokens; the card is kept well under that. */
@@ -39,10 +40,10 @@ export function sortState(card, earlier) {
39
40
  /** The one request for a card: the zone's question over its answers, plus what it also notes. */
40
41
  export function sortRequest(sort, state) {
41
42
  const questions = {
42
- route: { type: "choice", instructions: `${sort.question} Read the card's title and details.`, criteria: Object.fromEntries(sort.answers.map(one => [sortKeyOf(one.answer), one.means])) },
43
+ [SORT_ROUTE_KEY]: { type: "choice", instructions: `${sort.question} Read the card's title and details.`, criteria: Object.fromEntries(sort.answers.map(one => [sortKeyOf(one.answer), one.means])) },
43
44
  };
44
45
  for (const note of sort.notes) {
45
- questions[`note_${note.id.replace(/-/g, "_")}`] = note.kind === "score" && note.levels !== null
46
+ questions[sortNoteKeyOf(note.id)] = note.kind === "score" && note.levels !== null
46
47
  ? { type: "score", instructions: note.question, criteria: note.levels }
47
48
  : { type: "noul", instructions: note.question };
48
49
  }
@@ -80,38 +81,42 @@ export async function askJev(fetcher, key, request) {
80
81
  return { ok: true, body, ms: Date.now() - started };
81
82
  }
82
83
  const shortLevel = (level) => level.split(":")[0].trim();
83
- /** Jev's answers, read back against the zone's own options only; anything else is a problem, never a route. */
84
+ /**
85
+ * Jev's answers, read through the reply's contract (src/contracts/sort-answer.ts) and then against the zone's own
86
+ * options only; anything else is a problem, never a route. Confidences and chances are clamped into 0–1.
87
+ */
84
88
  export function readJevAnswers(stage, body, ms) {
85
89
  const sort = stage.sort;
86
- const answers = body["answers"];
87
- const route = answers?.["route"];
88
- if (route === undefined || typeof route["choice"] !== "string")
89
- return { problem: "Jev's answer didn't say which way to go." };
90
- const picked = sort.answers.find(one => sortKeyOf(one.answer) === route["choice"]);
90
+ const read = readJevReply(body);
91
+ if (!read.ok)
92
+ return { problem: `Jev's answer didn't say which way to go (${read.issues.map(issue => issue.line).join("; ")}).` };
93
+ const reply = read.value;
94
+ const route = reply.answers[SORT_ROUTE_KEY];
95
+ const picked = sort.answers.find(one => sortKeyOf(one.answer) === route.choice);
91
96
  if (picked === undefined)
92
- return { problem: "Jev picked an answer this zone doesn't have." };
97
+ return { problem: "answers.route.choice: Jev picked an answer this zone doesn't have." };
93
98
  const number = (value) => typeof value === "number" && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
94
- const sure = number(route["confidence"]);
95
- const probabilities = (route["probabilities"] ?? {});
99
+ const sure = number(route.confidence);
100
+ const probabilities = (route.probabilities ?? {});
96
101
  const chances = Object.fromEntries(sort.answers.map(one => [one.answer, Math.round(number(probabilities[sortKeyOf(one.answer)]) * 100) / 100]));
97
102
  const notes = [];
98
103
  for (const note of sort.notes) {
99
- const said = answers?.[`note_${note.id.replace(/-/g, "_")}`];
104
+ const said = reply.answers[sortNoteKeyOf(note.id)];
100
105
  if (said === undefined)
101
106
  continue;
102
- if (note.kind === "score" && note.levels !== null && typeof said["score"] === "number") {
103
- const level = note.levels[Math.min(note.levels.length - 1, Math.max(0, Math.round(said["score"])))];
104
- notes.push({ id: note.id, question: note.question, kind: "score", answer: level, sure: Math.round(number(said["confidence"]) * 100) / 100 });
107
+ if (note.kind === "score" && note.levels !== null && typeof said.score === "number") {
108
+ const level = note.levels[Math.min(note.levels.length - 1, Math.max(0, Math.round(said.score)))];
109
+ notes.push({ id: note.id, question: note.question, kind: "score", answer: level, sure: Math.round(number(said.confidence) * 100) / 100 });
105
110
  }
106
- else if (typeof said["noul"] === "number") {
107
- const yes = number(said["noul"]);
111
+ else if (typeof said.noul === "number") {
112
+ const yes = number(said.noul);
108
113
  notes.push({ id: note.id, question: note.question, kind: "yes-no", answer: yes >= 0.5 ? "yes" : "no", sure: Math.round(Math.max(yes, 1 - yes) * 100) / 100 });
109
114
  }
110
115
  }
111
116
  const confident = sure >= sort.sureAt;
112
- const usage = body["usage"];
117
+ const usage = reply.usage;
113
118
  return {
114
- model: typeof body["model"] === "string" ? body["model"] : JEV_MODEL,
119
+ model: typeof reply.model === "string" ? reply.model : JEV_MODEL,
115
120
  answer: picked.answer, sure: Math.round(sure * 100) / 100, sureAt: sort.sureAt,
116
121
  confident, to: confident ? picked.to : stage.onFail, chances, notes,
117
122
  cost: typeof usage?.cost === "number" ? usage.cost : null, ms,
@@ -44,6 +44,7 @@ import { ASKED, teammateReady, teammateTurn } from "./teammate-work.js";
44
44
  import { budgetHoldWords, claudeMachineBilling, monthOf } from "./spend.js";
45
45
  import { toolRefusal } from "./policy.js";
46
46
  import { pullRequestStep } from "./flow-pull-request.js";
47
+ import { withStageHandoff } from "./contracts/stage-output.js";
47
48
  const RETRY_MS = [5 * 60_000, 15 * 60_000];
48
49
  /** A teammate's turn tries again sooner: a card waiting on a person's colleague shouldn't wait long for a hiccup. */
49
50
  const TEAMMATE_RETRY_MS = [60_000, 5 * 60_000];
@@ -248,12 +249,12 @@ function settle(store, definition, stage, card, outcome, now, durationMs) {
248
249
  }
249
250
  store.finishFlowStep(card.id, card.entry, { state: outcome.state === "passed" ? "passed" : "failed", result: outcome.said, ...kept }, now);
250
251
  // What the steps after it read: whole up to TEXT_LIMITS.stageOutput; longer is attached whole to the card's discussion,
251
- // and they read a link to it. Never cut.
252
+ // and they read a link to it, while a task filed after it is given it whole (the card keeps it attached). Never cut.
252
253
  const whole = outcome.output ?? outcome.said;
253
254
  const passed = passOn(whole, TEXT_LIMITS.stageOutput, { label: "the card's discussion", href: flowCardHref(card.flow, card.id) });
254
255
  if (passed.kept)
255
256
  store.addFlowComment({ card: card.id, author: "flow", body: `What ${stage.title} produced, in full (${whole.length.toLocaleString("en-US")} characters):\n\n${whole}`, mentions: [] }, now);
256
- store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: passed.text }, waiting: null }, now);
257
+ store.updateFlowCard(card.id, { outputs: withStageHandoff(card.outputs, stage.id, { text: passed.text }), ...(passed.kept ? { attached: { [stage.id]: whole } } : {}), waiting: null }, now);
257
258
  const titleOf = (id) => definition.stages.find(one => one.id === id)?.title ?? "another zone";
258
259
  if (outcome.state === "passed") {
259
260
  // A sort names where the card goes; one it isn't sure about is a person's to place.
package/dist/flows.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type ContractIssue, type ContractResult } from "./contracts/contract.js";
2
- import { CHOICES_MAX, CHOICES_MIN, FLOW_COLORS, FLOW_END, FLOW_MERGE_METHODS, FLOW_STAGE_KINDS, LONGEST_WAIT_MINUTES, SCRIPT_LANGUAGES, SCRIPT_NAME, type FlowDefinition, type FlowStage, type FlowStageKind, type FlowStepFields, type FlowWait, type ScriptLanguage, type TriggerInput } from "./contracts/flow.js";
3
- export { CHOICES_MAX, CHOICES_MIN, FLOW_COLORS, FLOW_END, FLOW_MERGE_METHODS, FLOW_STAGE_KINDS, LONGEST_WAIT_MINUTES, SCRIPT_LANGUAGES, SCRIPT_NAME };
2
+ import { CHOICES_MAX, CHOICES_MIN, FLOW_COLORS, FLOW_END, FLOW_MERGE_METHODS, FLOW_STAGE_KINDS, LONGEST_WAIT_MINUTES, SCRIPT_LANGUAGES, SCRIPT_NAME, sortKeyOf, type FlowDefinition, type FlowStage, type FlowStageKind, type FlowStepFields, type FlowWait, type ScriptLanguage, type TriggerInput } from "./contracts/flow.js";
3
+ export { CHOICES_MAX, CHOICES_MIN, FLOW_COLORS, FLOW_END, FLOW_MERGE_METHODS, FLOW_STAGE_KINDS, LONGEST_WAIT_MINUTES, SCRIPT_LANGUAGES, SCRIPT_NAME, sortKeyOf };
4
4
  export type { FlowChoice, FlowColor, FlowDefinition, FlowEmail, FlowLimit, FlowMergeMethod, FlowRequest, FlowSort, FlowSortAnswer, FlowSortNote, FlowStage, FlowStageKind, FlowStepInput, FlowTool, FlowWait, FlowZone, ScriptLanguage, } from "./contracts/flow.js";
5
5
  /** What each kind is called and does, in the words the canvas uses. */
6
6
  export declare const FLOW_KIND_WORDS: Record<FlowStageKind, {
@@ -8,8 +8,6 @@ export declare const FLOW_KIND_WORDS: Record<FlowStageKind, {
8
8
  about: string;
9
9
  }>;
10
10
  export declare const LANGUAGE_WORDS: Record<ScriptLanguage, string>;
11
- /** How an answer is named to Jev: its words as a short key. */
12
- export declare const sortKeyOf: (answer: string) => string;
13
11
  /** The default for "sure enough to act alone". */
14
12
  export declare const SORT_SURE_AT = 0.8;
15
13
  /** "3 days", "4 hours", "90 minutes", "1 week" (or a number of minutes) as minutes; null when it isn't one. */
package/dist/flows.js CHANGED
@@ -50,8 +50,9 @@
50
50
  import { TEXT_LIMITS } from "./text-limits.js";
51
51
  import { createHash } from "node:crypto";
52
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 };
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 };
55
56
  /** What each kind is called and does, in the words the canvas uses. */
56
57
  export const FLOW_KIND_WORDS = {
57
58
  inbox: { label: "Holding", about: "Cards wait here until someone moves them." },
@@ -74,8 +75,6 @@ export const FLOW_KIND_WORDS = {
74
75
  done: { label: "Done", about: "The end of the flow." },
75
76
  };
76
77
  export const LANGUAGE_WORDS = { shell: "Shell", python: "Python", node: "Node" };
77
- /** How an answer is named to Jev: its words as a short key. */
78
- export const sortKeyOf = (answer) => answer.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "answer";
79
78
  /** The default for "sure enough to act alone". */
80
79
  export const SORT_SURE_AT = 0.8;
81
80
  const HEADER = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
@@ -813,8 +812,13 @@ export function flowFromSteps(input, previous = null) {
813
812
  });
814
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.
815
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
816
  if (!drawn.ok)
817
- throw new FlowContractError(drawn.issues.map(one => inStepWords(one.line, "steps", stages.map(stage => stage.kind))));
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);
818
822
  return drawn.value;
819
823
  }
820
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. */