toolroll 0.9.34 → 0.9.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -0
  3. package/dist/assignment-adapters.d.ts +12 -48
  4. package/dist/assignment-adapters.js +33 -43
  5. package/dist/assignment.d.ts +1 -0
  6. package/dist/assignment.js +2 -2
  7. package/dist/browser-workspace.d.ts +3 -3
  8. package/dist/build-review.d.ts +1 -1
  9. package/dist/build-review.js +2 -1
  10. package/dist/builder.js +4 -7
  11. package/dist/chat-acceptance.d.ts +2 -2
  12. package/dist/chat-actions.d.ts +18 -12
  13. package/dist/chat-actions.js +15 -65
  14. package/dist/chat-ask.js +5 -5
  15. package/dist/chat-decide.d.ts +14 -1
  16. package/dist/chat-decide.js +12 -6
  17. package/dist/chat-delivery-state.d.ts +12 -70
  18. package/dist/chat-delivery-state.js +17 -135
  19. package/dist/chat-delivery.js +58 -30
  20. package/dist/chat-flow.js +8 -6
  21. package/dist/chat-inbox.js +1 -1
  22. package/dist/coding-context.d.ts +2 -39
  23. package/dist/coding-context.js +2 -1
  24. package/dist/coding-handoff.d.ts +6 -25
  25. package/dist/coding-handoff.js +11 -5
  26. package/dist/coding-types.d.ts +2 -26
  27. package/dist/coding-workspace.d.ts +2 -0
  28. package/dist/coding-workspace.js +50 -14
  29. package/dist/contracts/acceptance-terms.d.ts +14 -0
  30. package/dist/contracts/acceptance-terms.js +16 -0
  31. package/dist/contracts/chat-actions.d.ts +328 -0
  32. package/dist/contracts/chat-actions.js +133 -0
  33. package/dist/contracts/chat-callback-rows.d.ts +59 -0
  34. package/dist/contracts/chat-callback-rows.js +46 -0
  35. package/dist/contracts/chat-content.d.ts +276 -0
  36. package/dist/contracts/chat-content.js +127 -0
  37. package/dist/contracts/chat-tables.d.ts +6 -0
  38. package/dist/contracts/chat-tables.js +136 -0
  39. package/dist/contracts/coding-context.d.ts +97 -0
  40. package/dist/contracts/coding-context.js +60 -0
  41. package/dist/contracts/coding-handoff.d.ts +44 -0
  42. package/dist/contracts/coding-handoff.js +47 -0
  43. package/dist/contracts/coding-workspace.d.ts +165 -0
  44. package/dist/contracts/coding-workspace.js +71 -0
  45. package/dist/contracts/contract.d.ts +40 -7
  46. package/dist/contracts/contract.js +155 -19
  47. package/dist/contracts/decision.d.ts +45 -0
  48. package/dist/contracts/decision.js +45 -0
  49. package/dist/contracts/discord-callback.d.ts +45 -0
  50. package/dist/contracts/discord-callback.js +34 -0
  51. package/dist/contracts/flow-card.d.ts +50 -0
  52. package/dist/contracts/flow-card.js +46 -0
  53. package/dist/contracts/flow-propose.d.ts +325 -0
  54. package/dist/contracts/flow-propose.js +41 -0
  55. package/dist/contracts/flow-send.d.ts +119 -0
  56. package/dist/contracts/flow-send.js +112 -0
  57. package/dist/contracts/flow.d.ts +19405 -0
  58. package/dist/contracts/flow.js +364 -0
  59. package/dist/contracts/gateway-tools.d.ts +374 -0
  60. package/dist/contracts/gateway-tools.js +94 -0
  61. package/dist/contracts/handoff.d.ts +91 -0
  62. package/dist/contracts/handoff.js +82 -0
  63. package/dist/contracts/lead-context.d.ts +165 -0
  64. package/dist/contracts/lead-context.js +73 -0
  65. package/dist/contracts/lead-tools.d.ts +1905 -0
  66. package/dist/contracts/lead-tools.js +265 -0
  67. package/dist/contracts/memory-pass.d.ts +158 -0
  68. package/dist/contracts/memory-pass.js +102 -0
  69. package/dist/contracts/project-knowledge.d.ts +126 -0
  70. package/dist/contracts/project-knowledge.js +93 -0
  71. package/dist/contracts/project-memory.d.ts +117 -0
  72. package/dist/contracts/project-memory.js +51 -0
  73. package/dist/contracts/project-skills.d.ts +121 -0
  74. package/dist/contracts/project-skills.js +76 -0
  75. package/dist/contracts/proof.d.ts +136 -0
  76. package/dist/contracts/proof.js +110 -0
  77. package/dist/contracts/review-findings.d.ts +46 -0
  78. package/dist/contracts/review-findings.js +48 -0
  79. package/dist/contracts/route.d.ts +484 -0
  80. package/dist/contracts/route.js +128 -0
  81. package/dist/contracts/scope.d.ts +105 -0
  82. package/dist/contracts/scope.js +114 -0
  83. package/dist/contracts/scout-report.d.ts +234 -0
  84. package/dist/contracts/scout-report.js +141 -0
  85. package/dist/contracts/slack-callback.d.ts +43 -0
  86. package/dist/contracts/slack-callback.js +35 -0
  87. package/dist/contracts/sort-answer.d.ts +83 -0
  88. package/dist/contracts/sort-answer.js +77 -0
  89. package/dist/contracts/stage-output.d.ts +127 -0
  90. package/dist/contracts/stage-output.js +166 -0
  91. package/dist/contracts/task-sizing.d.ts +24 -0
  92. package/dist/contracts/task-sizing.js +28 -0
  93. package/dist/contracts/teammate-turn.d.ts +43 -0
  94. package/dist/contracts/teammate-turn.js +51 -0
  95. package/dist/contracts/teams-callback.d.ts +14 -0
  96. package/dist/contracts/teams-callback.js +14 -0
  97. package/dist/contracts/telegram-callback.d.ts +120 -0
  98. package/dist/contracts/telegram-callback.js +97 -0
  99. package/dist/contracts/verification-receipt.d.ts +148 -0
  100. package/dist/contracts/verification-receipt.js +87 -0
  101. package/dist/decision.d.ts +11 -20
  102. package/dist/decision.js +86 -167
  103. package/dist/demo.js +28 -11
  104. package/dist/discord-chat.js +31 -20
  105. package/dist/evidence.d.ts +3 -40
  106. package/dist/evidence.js +5 -1
  107. package/dist/flow-engine.js +14 -17
  108. package/dist/flow-gallery.d.ts +3 -2
  109. package/dist/flow-items.d.ts +4 -13
  110. package/dist/flow-items.js +1 -15
  111. package/dist/flow-send.d.ts +6 -37
  112. package/dist/flow-send.js +18 -14
  113. package/dist/flow-share.d.ts +10 -32
  114. package/dist/flow-share.js +72 -84
  115. package/dist/flow-sort.d.ts +7 -26
  116. package/dist/flow-sort.js +25 -20
  117. package/dist/flow-starters.d.ts +2 -1
  118. package/dist/flow-steps.js +3 -2
  119. package/dist/flow-triggers.d.ts +15 -72
  120. package/dist/flow-triggers.js +94 -83
  121. package/dist/flows.d.ts +37 -220
  122. package/dist/flows.js +356 -387
  123. package/dist/invoke.js +4 -4
  124. package/dist/lead-context.d.ts +4 -2
  125. package/dist/lead-context.js +24 -17
  126. package/dist/mate-tools.d.ts +4 -0
  127. package/dist/mate-tools.js +111 -308
  128. package/dist/mcp-connect.d.ts +27 -4
  129. package/dist/mcp-connect.js +92 -17
  130. package/dist/mcp.js +84 -216
  131. package/dist/memory-pass.d.ts +17 -42
  132. package/dist/memory-pass.js +54 -26
  133. package/dist/operate.js +31 -2
  134. package/dist/phase-routing.d.ts +19 -72
  135. package/dist/phase-routing.js +34 -164
  136. package/dist/plan.d.ts +5 -5
  137. package/dist/plan.js +5 -25
  138. package/dist/process-recovery-eligibility.d.ts +2 -2
  139. package/dist/process-recovery-eligibility.js +13 -8
  140. package/dist/project-knowledge.d.ts +16 -35
  141. package/dist/project-knowledge.js +42 -21
  142. package/dist/project-memory.d.ts +2 -28
  143. package/dist/project-memory.js +28 -13
  144. package/dist/project-skills.d.ts +16 -38
  145. package/dist/project-skills.js +41 -32
  146. package/dist/project-tools.d.ts +1 -0
  147. package/dist/project-tools.js +4 -4
  148. package/dist/proof.d.ts +15 -91
  149. package/dist/proof.js +90 -249
  150. package/dist/provider.d.ts +2 -0
  151. package/dist/provider.js +4 -16
  152. package/dist/result-evidence-readers.d.ts +42 -0
  153. package/dist/result-evidence-readers.js +91 -0
  154. package/dist/result-shots.d.ts +1 -1
  155. package/dist/result-shots.js +2 -1
  156. package/dist/review-switch.d.ts +3 -8
  157. package/dist/reviewer.d.ts +8 -12
  158. package/dist/reviewer.js +11 -35
  159. package/dist/scope.d.ts +21 -43
  160. package/dist/scope.js +42 -128
  161. package/dist/scout-report.d.ts +9 -194
  162. package/dist/scout-report.js +113 -264
  163. package/dist/scout.js +15 -11
  164. package/dist/serve.js +17 -101
  165. package/dist/slack-chat.js +26 -20
  166. package/dist/slack-state.js +2 -1
  167. package/dist/store.d.ts +9 -32
  168. package/dist/store.js +18 -15
  169. package/dist/structured-output.js +2 -1
  170. package/dist/subscription-chat.d.ts +4 -0
  171. package/dist/subscription-chat.js +4 -1
  172. package/dist/surface.js +8 -1
  173. package/dist/task-review-brief.d.ts +69 -0
  174. package/dist/task-review-brief.js +179 -0
  175. package/dist/task-sizing.d.ts +3 -27
  176. package/dist/task-sizing.js +11 -26
  177. package/dist/task-text.d.ts +0 -5
  178. package/dist/task-text.js +1 -3
  179. package/dist/teammate-question.js +7 -5
  180. package/dist/teammate-work.js +9 -5
  181. package/dist/teammates.d.ts +13 -64
  182. package/dist/teammates.js +32 -33
  183. package/dist/teams-chat.js +20 -11
  184. package/dist/telegram-flow.js +4 -3
  185. package/dist/telegram-mate.d.ts +4 -14
  186. package/dist/telegram-mate.js +8 -7
  187. package/dist/telegram-team.d.ts +2 -19
  188. package/dist/telegram.d.ts +1 -1
  189. package/dist/telegram.js +52 -42
  190. package/dist/text-limits.d.ts +212 -1
  191. package/dist/text-limits.js +214 -1
  192. package/dist/tools-ui.d.ts +2 -1
  193. package/dist/tools-ui.js +7 -4
  194. package/dist/verification-evidence.js +24 -12
  195. package/package.json +1 -1
@@ -1,39 +1,11 @@
1
- import { type ScriptDraft } from "./flow-scripts.js";
2
1
  import { type FlowDefinition } from "./flows.js";
2
+ import { FLOW_FILE_FORMAT, FLOW_FILE_VERSION, type FlowFile, type FlowFileParameter, type FlowFileScript } from "./contracts/flow.js";
3
3
  import type { FlowRow, Store } from "./store.js";
4
- export declare const FLOW_FILE_FORMAT = "toolroll-flow";
5
- export declare const FLOW_FILE_VERSION = 1;
4
+ export { FLOW_FILE_FORMAT, FLOW_FILE_VERSION };
5
+ export type { FlowFile, FlowFileParameter, FlowFileScript };
6
6
  /** The largest flow file Toolroll reads or fetches. */
7
7
  export declare const FLOW_FILE_MAX_BYTES: number;
8
8
  export declare const FLOW_FILE_SUFFIX = ".toolroll-flow.json";
9
- /** Something the import asks for: `{{param.<id>}}` in a zone or trigger is replaced by its value. */
10
- export type FlowFileParameter = {
11
- id: string;
12
- about: string;
13
- default?: string;
14
- optional?: boolean;
15
- };
16
- export type FlowFileScript = {
17
- name: string;
18
- about: string;
19
- language: ScriptDraft["language"];
20
- timeoutMinutes: number;
21
- body?: string;
22
- file?: string;
23
- };
24
- export type FlowFile = {
25
- format: typeof FLOW_FILE_FORMAT;
26
- version: typeof FLOW_FILE_VERSION;
27
- name: string;
28
- about: string;
29
- needs: string[];
30
- parameters: FlowFileParameter[];
31
- /** The zones in the lead's step vocabulary, the start first. */
32
- zones: Record<string, unknown>[];
33
- /** Each trigger's settings, as `toolroll flows trigger add` takes them. */
34
- triggers: Record<string, unknown>[];
35
- scripts: FlowFileScript[];
36
- };
37
9
  export type FlowExported = {
38
10
  file: FlowFile;
39
11
  json: string;
@@ -42,9 +14,15 @@ export type FlowExported = {
42
14
  };
43
15
  /** A flow as a file: its zones and paths, its triggers' settings and the scripts it runs, nothing of this installation's. */
44
16
  export declare function exportFlow(store: Store, flow: FlowRow, dir: string | null): FlowExported;
17
+ /** A flow file refused: why, in plain words, each problem naming its path in the file (`zones[0].routes[0]: ...`). */
45
18
  export declare class FlowFileError extends Error {
46
19
  }
47
- /** Read a flow file: its size, its JSON, its format and version, and every zone, trigger, script and parameter. Throws FlowFileError in plain words. */
20
+ /**
21
+ * Read a flow file: its size, its JSON and format, then its one schema (flowFileSchema: every zone, trigger, script
22
+ * and parameter, strict about unknown keys — `zones[0].routes[0]: unknown key 'to' (did you mean goesTo?)`), and a
23
+ * newer version refused plainly. Then what the schema can't say: no keys in a trigger, ids and names used once, each
24
+ * {{param.x}} declared, every script valid and the zones a flow. Throws FlowFileError in plain words.
25
+ */
48
26
  export declare function parseFlowFile(text: string): FlowFile;
49
27
  /** --param k=v lines (or a form's fields) as values; refuses parameters the file doesn't ask for. */
50
28
  export declare function parameterValues(file: FlowFile, given: Record<string, string>): Record<string, string>;
@@ -21,29 +21,31 @@ import { flowDefinitionOf } from "./flow-engine.js";
21
21
  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
- import { durationMinutes, durationWords, flowTerms, LANGUAGE_WORDS, validateFlowDefinition } from "./flows.js";
24
+ import { durationMinutes, durationWords, FlowContractError, flowTerms, inStepWords, LANGUAGE_WORDS, validateFlowDefinition } from "./flows.js";
25
+ import { stageReferenceProblems } from "./contracts/stage-output.js";
26
+ import { readVersioned } from "./contracts/contract.js";
27
+ import { FLOW_ALIASES, FLOW_FILE_FORMAT, FLOW_FILE_VERSION, flowFileSchema, PARAMETER_ID } from "./contracts/flow.js";
25
28
  import { parseSchedule } from "./routine.js";
26
- export const FLOW_FILE_FORMAT = "toolroll-flow";
27
- export const FLOW_FILE_VERSION = 1;
29
+ export { FLOW_FILE_FORMAT, FLOW_FILE_VERSION };
28
30
  /** The largest flow file Toolroll reads or fetches. */
29
31
  export const FLOW_FILE_MAX_BYTES = 256 * 1024;
30
32
  export const FLOW_FILE_SUFFIX = ".toolroll-flow.json";
31
33
  const PARAM = /\{\{\s*param\.([a-z0-9][a-z0-9-]{0,39})\s*\}\}/g;
32
- const PARAM_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
33
34
  const REMOVED = "[removed]";
34
35
  // ------------------------------------------------------------------ export
35
36
  /** A zone in the lead's words: only what it says, in the order a person reads it. */
36
37
  function stepOf(stage, ask) {
37
38
  const step = { id: stage.id, title: stage.title, kind: stage.kind };
38
39
  const words = (minutes) => durationMinutes(durationWords(minutes)) === minutes ? durationWords(minutes) : minutes;
39
- if (stage.instructions !== null)
40
+ // Only what the step's kind says: words a zone kept from a kind it was before stay behind.
41
+ if (stage.instructions !== null && (stage.kind === "task" || stage.kind === "report" || stage.kind === "draft" || stage.kind === "teammate"))
40
42
  step["instructions"] = stage.instructions;
41
43
  if (stage.kind === "task")
42
44
  step["planning"] = stage.planning;
43
45
  if (stage.kind === "approval")
44
46
  step["decider"] = stage.toOwner === true ? "owner" : stage.approver === null ? "anyone"
45
47
  : ask({ id: `decider-${stage.id}`.slice(0, 40), about: `Who decides at ${stage.title}: a person's sign-in name, owner (the flow's owner) or anyone`, default: "owner" });
46
- if (stage.message !== null)
48
+ if (stage.message !== null && (stage.kind === "notify" || stage.kind === "update"))
47
49
  step["message"] = stage.message;
48
50
  if (stage.kind === "update")
49
51
  step["close"] = stage.close;
@@ -239,26 +241,28 @@ export function exportFlow(store, flow, dir) {
239
241
  format: FLOW_FILE_FORMAT, version: FLOW_FILE_VERSION, name: flow.name,
240
242
  about: ordered.map(one => one.title).join(" → "),
241
243
  needs: needsOf(definition, triggers, scripts.map(one => one.name)),
242
- parameters, zones, triggers, scripts,
244
+ // Zones and triggers in the file's words (stepOf, triggerOf): what flowFileSchema reads back (flow-share.test.ts).
245
+ parameters, zones: zones, triggers: triggers, scripts,
243
246
  };
244
247
  const file = scrubDeep(raw, scrubber(values, names), null, false);
245
248
  const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "flow";
246
249
  return { file, json: `${JSON.stringify(file, null, 2)}\n`, fileName: `${slug}${FLOW_FILE_SUFFIX}`, left };
247
250
  }
248
251
  // ------------------------------------------------------------------ reading
252
+ /** A flow file refused: why, in plain words, each problem naming its path in the file (`zones[0].routes[0]: ...`). */
249
253
  export class FlowFileError extends Error {
250
254
  }
251
255
  const refuse = (message) => { throw new FlowFileError(message); };
252
256
  /** A zone from the file as the canvas stores it: references by id (or by title), the rest checked by validateFlowDefinition. */
253
257
  function stageInputOf(step, index, find) {
254
- const title = typeof step["title"] === "string" ? step["title"] : `zone ${index + 1}`;
258
+ const at = `zones[${index}]`;
255
259
  const kind = step["kind"];
256
260
  const object = (key) => {
257
261
  const value = step[key];
258
262
  if (value === undefined || value === null)
259
263
  return undefined;
260
264
  if (typeof value !== "object" || Array.isArray(value))
261
- refuse(`Zone ${title}: ${key} must be an object.`);
265
+ refuse(`${at}.${key}: must be an object`);
262
266
  return value;
263
267
  };
264
268
  const list = (key) => {
@@ -266,59 +270,65 @@ function stageInputOf(step, index, find) {
266
270
  if (value === undefined || value === null)
267
271
  return undefined;
268
272
  if (!Array.isArray(value) || value.some(one => one === null || typeof one !== "object" || Array.isArray(one)))
269
- refuse(`Zone ${title}: ${key} must be a list.`);
273
+ refuse(`${at}.${key}: must be a list of objects`);
270
274
  return value;
271
275
  };
272
276
  const decider = step["decider"];
273
277
  if (decider !== undefined && decider !== null && typeof decider !== "string")
274
- refuse(`Zone ${title}: decider must be plain text.`);
278
+ refuse(`${at}.decider: must be a string`);
275
279
  const toOwner = kind === "approval" && typeof decider === "string" && decider.trim().toLowerCase() === "owner";
276
280
  const anyone = decider === undefined || decider === null || (typeof decider === "string" && /^(anyone|any approver|)$/i.test(decider.trim()));
277
- const at = object("at") ?? {};
278
- const failKey = step["ifNotSure"] ?? step["ifNoReply"] ?? step["ifReplied"] ?? step["ifFails"];
279
- const minutes = (value) => value === undefined ? undefined : durationMinutes(value) ?? refuse(`Zone ${title}: say a time like "3 days" (up to 30 days).`);
281
+ const place = object("at") ?? {};
282
+ const [failKey, fail] = ["ifNotSure", "ifNoReply", "ifReplied", "ifFails"].map(key => [key, step[key]]).find(([, value]) => value !== undefined && value !== null) ?? ["ifFails", undefined];
283
+ const minutes = (value, key) => value === undefined ? undefined : durationMinutes(value) ?? refuse(`${at}.${key}: say a time like "3 days" (up to 30 days)`);
280
284
  const sureAt = step["sureAt"];
281
285
  return {
282
286
  id: step["id"], title: step["title"], kind,
283
- zone: { x: at["x"], y: at["y"] ?? 0, w: at["w"], h: at["h"], color: at["color"] },
287
+ zone: { x: place["x"], y: place["y"] ?? 0, w: place["w"], h: place["h"], color: place["color"] },
284
288
  instructions: step["instructions"], planning: step["planning"],
285
289
  ...(toOwner ? { toOwner: true } : { approver: anyone ? null : decider }),
286
290
  message: step["message"], close: step["close"], script: step["script"], runIn: step["runIn"],
287
- routes: list("routes")?.map(one => ({ answer: one["answer"], to: find(one["goesTo"], title) })),
291
+ routes: list("routes")?.map((one, n) => ({ answer: one["answer"], to: find(one["goesTo"], `${at}.routes[${n}].goesTo`) })),
288
292
  secrets: step["secrets"],
289
- ...(kind === "sort" ? { sort: { question: step["question"], answers: (list("answers") ?? []).map(one => ({ answer: one["answer"], means: one["means"], to: find(one["goesTo"], title) })),
293
+ ...(kind === "sort" ? { sort: { question: step["question"], answers: (list("answers") ?? []).map((one, n) => ({ answer: one["answer"], means: one["means"], to: find(one["goesTo"], `${at}.answers[${n}].goesTo`) })),
290
294
  sureAt: typeof sureAt === "number" && sureAt > 1 ? sureAt / 100 : sureAt, notes: list("alsoNote") ?? [] } } : {}),
291
295
  ...(kind === "request" ? { request: { method: step["method"], url: step["url"], headers: object("headers") ?? {}, body: step["body"] } } : {}),
292
296
  ...(kind === "email" ? { email: { to: step["to"], subject: step["subject"], body: step["body"] } } : {}),
293
297
  ...(kind === "tool" ? { tool: { server: step["server"], name: step["tool"], args: typeof step["args"] === "object" && step["args"] !== null ? JSON.stringify(step["args"]) : step["args"] } } : {}),
294
- ...(kind === "wait" ? { wait: step["waitFor"] === "hours" ? { for: "hours", from: step["from"], to: step["until"], timeZone: step["timeZone"] } : { for: step["waitFor"] ?? "reply", minutes: minutes(step["wait"] ?? "3 days") } } : {}),
298
+ ...(kind === "wait" ? { wait: step["waitFor"] === "hours" ? { for: "hours", from: step["from"], to: step["until"], timeZone: step["timeZone"] } : { for: step["waitFor"] ?? "reply", minutes: minutes(step["wait"] ?? "3 days", "wait") } } : {}),
295
299
  merge: step["merge"], teammate: step["teammate"], reply: step["reply"],
296
- ...(kind === "choose" ? { options: (list("options") ?? []).map(one => ({ label: one["label"], to: one["goesTo"] === "end" ? "end" : find(one["goesTo"], title) })) } : {}),
297
- ...(step["remindAfter"] === undefined ? {} : { limit: { minutes: minutes(step["remindAfter"]), to: find(step["thenMoveTo"], title) } }),
298
- next: find(step["next"], title), onFail: find(failKey, title),
300
+ ...(kind === "choose" ? { options: (list("options") ?? []).map((one, n) => ({ label: one["label"], to: one["goesTo"] === "end" ? "end" : find(one["goesTo"], `${at}.options[${n}].goesTo`) })) } : {}),
301
+ ...(step["remindAfter"] === undefined ? {} : { limit: { minutes: minutes(step["remindAfter"], "remindAfter"), to: find(step["thenMoveTo"], `${at}.thenMoveTo`) } }),
302
+ next: find(step["next"], `${at}.next`), onFail: find(fail, `${at}.${failKey}`),
299
303
  };
300
304
  }
301
305
  /** The zones as a flow, checked whole. Throws in plain words. */
302
306
  function definitionOf(zones) {
303
307
  const ids = zones.map(one => one["id"]);
304
- const find = (ref, from) => {
308
+ const find = (ref, path) => {
305
309
  if (ref === undefined || ref === null || ref === "")
306
310
  return null;
307
311
  if (typeof ref !== "string")
308
- return refuse(`Zone ${from}: a path names a zone by its id.`);
312
+ return refuse(`${path}: names a zone by its id`);
309
313
  const hit = zones.find(one => one["id"] === ref.trim()) ?? zones.find(one => typeof one["title"] === "string" && one["title"].trim().toLowerCase() === ref.trim().toLowerCase());
310
314
  if (hit === undefined)
311
- return refuse(`Zone ${from}: there's no zone called ${ref}.`);
312
- return typeof hit["id"] === "string" ? hit["id"] : refuse(`Zone ${from}: every zone needs an id.`);
315
+ return refuse(`${path}: there's no zone called ${ref}`);
316
+ return typeof hit["id"] === "string" ? hit["id"] : refuse(`zones[${zones.indexOf(hit)}].id: required`);
313
317
  };
314
- if (ids.some(one => typeof one !== "string"))
315
- refuse("Every zone needs an id: lowercase letters, numbers and dashes.");
318
+ ids.forEach((one, index) => { if (typeof one !== "string")
319
+ refuse(`zones[${index}].id: required`); });
316
320
  const stages = zones.map((step, index) => stageInputOf(step, index, find));
321
+ // Its zones are the file's, in order: a zone's problem names the file's zone.
317
322
  try {
318
- 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;
319
329
  }
320
330
  catch (error) {
321
- return refuse(error instanceof Error ? error.message : "Those zones aren't a flow.");
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.");
322
332
  }
323
333
  }
324
334
  const plainText = (value, cap, what, required) => {
@@ -328,17 +338,22 @@ const plainText = (value, cap, what, required) => {
328
338
  return refuse(`The file's ${what} must be plain text.`);
329
339
  if (value.length > cap)
330
340
  return refuse(`The file's ${what} is longer than ${cap} characters.`);
331
- if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f‪-‮⁦-⁩]/.test(value))
341
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/.test(value))
332
342
  return refuse(`The file's ${what} has hidden characters in it.`);
333
343
  return value.trim();
334
344
  };
335
- /** Read a flow file: its size, its JSON, its format and version, and every zone, trigger, script and parameter. Throws FlowFileError in plain words. */
345
+ /**
346
+ * Read a flow file: its size, its JSON and format, then its one schema (flowFileSchema: every zone, trigger, script
347
+ * and parameter, strict about unknown keys — `zones[0].routes[0]: unknown key 'to' (did you mean goesTo?)`), and a
348
+ * newer version refused plainly. Then what the schema can't say: no keys in a trigger, ids and names used once, each
349
+ * {{param.x}} declared, every script valid and the zones a flow. Throws FlowFileError in plain words.
350
+ */
336
351
  export function parseFlowFile(text) {
337
352
  if (Buffer.byteLength(text, "utf8") > FLOW_FILE_MAX_BYTES)
338
353
  refuse(`That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.`);
339
354
  let raw;
340
355
  try {
341
- raw = JSON.parse(text.replace(/^/, ""));
356
+ raw = JSON.parse(text.replace(/^\uFEFF/, ""));
342
357
  }
343
358
  catch {
344
359
  return refuse("That isn't a flow file: it isn't valid JSON.");
@@ -348,75 +363,48 @@ export function parseFlowFile(text) {
348
363
  const input = raw;
349
364
  if (input["format"] !== FLOW_FILE_FORMAT)
350
365
  refuse("That isn't a Toolroll flow file: its format isn't \"toolroll-flow\".");
351
- if (input["version"] !== FLOW_FILE_VERSION)
352
- refuse(typeof input["version"] === "number" && input["version"] > FLOW_FILE_VERSION
353
- ? `This flow file is version ${input["version"]}; this Toolroll reads version ${FLOW_FILE_VERSION}. Update Toolroll, then import it.`
354
- : `This flow file has no version Toolroll reads; it should say "version": ${FLOW_FILE_VERSION}.`);
355
- const name = plainText(input["name"], 80, "name", true);
356
- const about = plainText(input["about"], 600, "about line", false);
357
- const array = (key, cap) => {
358
- const value = input[key];
359
- if (value === undefined || value === null)
360
- return [];
361
- if (!Array.isArray(value))
362
- return refuse(`The file's ${key} must be a list.`);
363
- if (value.length > cap)
364
- return refuse(`The file has more than ${cap} ${key}.`);
365
- return value;
366
- };
367
- const needs = array("needs", 40).map(one => plainText(one, 80, "needs", true));
368
- const parameters = array("parameters", 20).map((one) => {
369
- if (one === null || typeof one !== "object" || Array.isArray(one))
370
- return refuse("Each parameter is an object with an id and what it asks for.");
371
- const row = one;
372
- const id = typeof row["id"] === "string" && PARAM_ID.test(row["id"]) ? row["id"] : refuse("Each parameter needs a short id: lowercase letters, numbers and dashes.");
373
- const fallback = row["default"] === undefined || row["default"] === null ? undefined : plainText(row["default"], 200, `default for ${id}`, false);
374
- return { id, about: plainText(row["about"], 200, `question for ${id}`, true), ...(fallback === undefined ? {} : { default: fallback }), ...(row["optional"] === true ? { optional: true } : {}) };
375
- });
376
- if (new Set(parameters.map(one => one.id)).size !== parameters.length)
377
- refuse("Two parameters have the same id.");
378
- const rawZones = array("zones", 24);
379
- if (rawZones.length === 0)
380
- refuse("The file has no zones.");
381
- if (rawZones.some(one => one === null || typeof one !== "object" || Array.isArray(one)))
382
- refuse("Each zone is an object with an id, a title and a kind.");
383
- const zones = rawZones;
384
- const triggers = array("triggers", 10).map(one => {
385
- if (one === null || typeof one !== "object" || Array.isArray(one))
386
- return refuse("Each trigger is an object with a kind.");
387
- const kind = one["kind"];
366
+ // Triggers that name something on the installation they were made on stay there; say so, not that the kind is unknown.
367
+ (Array.isArray(input["triggers"]) ? input["triggers"] : []).forEach((one, index) => {
368
+ const kind = one !== null && typeof one === "object" ? one["kind"] : undefined;
388
369
  if (kind === "chat")
389
- return refuse("A chat channel trigger can't come from a file: connect the channel from the channel itself.");
370
+ refuse(`triggers[${index}].kind: a chat channel trigger can't come from a file; connect the channel from the channel itself`);
390
371
  if (kind === "flow")
391
- return refuse("A trigger from another flow can't come from a file: it names a flow on the installation it was made on.");
392
- if (!["button", "schedule", "github", "linear", "webhook", "email", "plane-review"].includes(kind))
393
- return refuse(`The file has a trigger Toolroll doesn't know: ${String(kind)}.`);
372
+ refuse(`triggers[${index}].kind: a trigger from another flow can't come from a file; it names a flow on the installation it was made on`);
373
+ });
374
+ const read = readVersioned(flowFileSchema, input, {}, FLOW_ALIASES);
375
+ if (!read.ok)
376
+ return refuse(read.issues.map(one => one.line).join("\n"));
377
+ const file = read.value;
378
+ const name = file.name.trim(), about = (file.about ?? "").trim();
379
+ if (name === "")
380
+ refuse("name: must not be empty");
381
+ const parameters = (file.parameters ?? []).map(one => ({ ...one, about: one.about.trim(), ...(one.default === undefined ? {} : { default: one.default.trim() }) }));
382
+ if (new Set(parameters.map(one => one.id)).size !== parameters.length)
383
+ refuse("parameters: two have the same id");
384
+ const triggers = file.triggers ?? [];
385
+ triggers.forEach((one, index) => {
394
386
  for (const [key, value] of Object.entries(one))
395
387
  if (typeof value === "string" && scanForSecrets(value).length > 0)
396
- refuse(`A ${String(kind)} trigger's ${key} looks like a key or password. Keys never go in a flow file.`);
397
- return one;
388
+ refuse(`triggers[${index}].${key}: looks like a key or password; keys never go in a flow file`);
398
389
  });
399
- const scripts = array("scripts", 20).map((one) => {
400
- if (one === null || typeof one !== "object" || Array.isArray(one))
401
- return refuse("Each script is an object with a name.");
402
- const row = one;
390
+ const scripts = (file.scripts ?? []).map((row, index) => {
403
391
  let draft;
404
392
  try {
405
393
  draft = validateScript(row);
406
394
  }
407
395
  catch (error) {
408
- return refuse(`Script ${typeof row["name"] === "string" ? row["name"] : "(unnamed)"}: ${error instanceof Error ? error.message : "it isn't valid."}`);
396
+ return refuse(`scripts[${index}]: ${error instanceof Error ? error.message : "it isn't valid."}`);
409
397
  }
410
398
  return { name: draft.name, about: draft.about, language: draft.language, timeoutMinutes: draft.timeoutMinutes, ...(draft.file === null ? { body: draft.body } : { file: draft.file }) };
411
399
  });
412
400
  if (new Set(scripts.map(one => one.name)).size !== scripts.length)
413
- refuse("Two scripts have the same name.");
401
+ refuse("scripts: two have the same name");
414
402
  // Every {{param.x}} names a parameter the file declares.
415
- for (const match of JSON.stringify([zones, triggers]).matchAll(PARAM))
403
+ for (const match of JSON.stringify([file.zones, triggers]).matchAll(PARAM))
416
404
  if (!parameters.some(one => one.id === match[1]))
417
405
  refuse(`The file uses {{param.${match[1]}}} but doesn't say what it asks for.`);
418
- definitionOf(zones);
419
- return { format: FLOW_FILE_FORMAT, version: FLOW_FILE_VERSION, name, about, needs, parameters, zones, triggers, scripts };
406
+ definitionOf(file.zones);
407
+ return { format: FLOW_FILE_FORMAT, version: FLOW_FILE_VERSION, name, about, needs: (file.needs ?? []).map(one => one.trim()), parameters, zones: file.zones, triggers, scripts };
420
408
  }
421
409
  /** --param k=v lines (or a form's fields) as values; refuses parameters the file doesn't ask for. */
422
410
  export function parameterValues(file, given) {
@@ -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,
@@ -1,4 +1,5 @@
1
1
  import { type FlowStepInput } from "./flows.js";
2
+ import type { TriggerInput } from "./contracts/flow.js";
2
3
  import type { FlowRow, Store } from "./store.js";
3
4
  export declare const STARTER_IDS: readonly ["ci-fix", "issue-task", "overnight", "plane-review"];
4
5
  export type StarterId = (typeof STARTER_IDS)[number];
@@ -22,7 +23,7 @@ export type StarterFlow = {
22
23
  ownSteps?: FlowStepInput[];
23
24
  trigger: (where: {
24
25
  branch: string;
25
- }) => Record<string, unknown>;
26
+ }) => TriggerInput;
26
27
  };
27
28
  export declare const STARTER_FLOWS: readonly StarterFlow[];
28
29
  /** The morning plane review's steps: find the cause, fix it, open a pull request; any failure waits for a person. */
@@ -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.