toolroll 0.9.33 → 0.9.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +7 -0
  3. package/dist/assignment.d.ts +1 -0
  4. package/dist/assignment.js +2 -2
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +17 -17
  7. package/dist/browser-crew.js +1 -0
  8. package/dist/browser-workspace.d.ts +5 -3
  9. package/dist/builder.js +4 -7
  10. package/dist/chat-acceptance.d.ts +1 -1
  11. package/dist/cli.d.ts +1 -1
  12. package/dist/cli.js +124 -63
  13. package/dist/contracts/contract-test.d.ts +45 -0
  14. package/dist/contracts/contract-test.js +69 -0
  15. package/dist/contracts/contract.d.ts +87 -0
  16. package/dist/contracts/contract.js +308 -0
  17. package/dist/contracts/flow-propose.d.ts +325 -0
  18. package/dist/contracts/flow-propose.js +41 -0
  19. package/dist/contracts/flow.d.ts +19400 -0
  20. package/dist/contracts/flow.js +359 -0
  21. package/dist/contracts/handoff.d.ts +91 -0
  22. package/dist/contracts/handoff.js +82 -0
  23. package/dist/contracts/plan.d.ts +58 -0
  24. package/dist/contracts/plan.js +67 -0
  25. package/dist/contracts/proof.d.ts +136 -0
  26. package/dist/contracts/proof.js +110 -0
  27. package/dist/contracts/verification-receipt.d.ts +148 -0
  28. package/dist/contracts/verification-receipt.js +87 -0
  29. package/dist/demo.js +28 -11
  30. package/dist/desktop-update.d.ts +1 -1
  31. package/dist/evidence.d.ts +3 -40
  32. package/dist/evidence.js +7 -3
  33. package/dist/flow-gallery.d.ts +3 -2
  34. package/dist/flow-share.d.ts +10 -32
  35. package/dist/flow-share.js +65 -83
  36. package/dist/flow-starters.d.ts +2 -1
  37. package/dist/flow-triggers.d.ts +15 -72
  38. package/dist/flow-triggers.js +94 -83
  39. package/dist/flows.d.ts +37 -218
  40. package/dist/flows.js +350 -385
  41. package/dist/mate-tools.js +14 -44
  42. package/dist/mcp-connect.d.ts +3 -0
  43. package/dist/mcp-connect.js +16 -4
  44. package/dist/operate.js +83 -12
  45. package/dist/plan.d.ts +12 -12
  46. package/dist/plan.js +109 -72
  47. package/dist/planner-source.js +2 -1
  48. package/dist/planner.js +55 -25
  49. package/dist/process-recovery-eligibility.d.ts +2 -2
  50. package/dist/process-recovery-eligibility.js +13 -8
  51. package/dist/project-admission.d.ts +93 -0
  52. package/dist/project-admission.js +134 -0
  53. package/dist/project-delete-ui.js +5 -2
  54. package/dist/proof.d.ts +15 -91
  55. package/dist/proof.js +90 -249
  56. package/dist/result-evidence-readers.d.ts +42 -0
  57. package/dist/result-evidence-readers.js +91 -0
  58. package/dist/runner.d.ts +10 -0
  59. package/dist/runner.js +16 -0
  60. package/dist/scope.js +4 -3
  61. package/dist/serve.d.ts +4 -0
  62. package/dist/serve.js +112 -160
  63. package/dist/setup-guide.d.ts +2 -2
  64. package/dist/store.d.ts +2 -0
  65. package/dist/store.js +4 -0
  66. package/dist/surface.js +16 -3
  67. package/dist/task-review-brief.d.ts +75 -0
  68. package/dist/task-review-brief.js +179 -0
  69. package/dist/text-limits.d.ts +87 -0
  70. package/dist/text-limits.js +86 -0
  71. package/dist/verification-evidence.js +24 -12
  72. package/dist/when-html.d.ts +2 -0
  73. package/dist/when-html.js +13 -0
  74. package/package.json +3 -2
@@ -125,6 +125,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
125
125
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
126
126
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
127
127
  export declare function desktopUpdateStatus(stateDir: string): {
128
+ version?: never;
128
129
  active: boolean;
129
130
  phase: string;
130
131
  detail: string;
@@ -138,7 +139,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
138
139
  wasRunning?: never;
139
140
  backupPath?: never;
140
141
  updateId?: never;
141
- version?: never;
142
142
  buildId?: never;
143
143
  workDir?: never;
144
144
  } | {
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import { type ParsedReport, type ReportImage } from "./scout-report.js";
17
17
  import { type ParsedProof } from "./proof.js";
18
+ import { type HandoffArtifact } from "./contracts/handoff.js";
19
+ export type { HandoffArtifact } from "./contracts/handoff.js";
18
20
  import type { Artifact, Store } from "./store.js";
19
21
  import type { ExecResult } from "./exec.js";
20
22
  /** The park mailbox pattern. Each run's actual name carries a nonce; see `mailboxName`. */
@@ -254,45 +256,6 @@ export type DiffStat = {
254
256
  * recorded as null — never coerced to zero.
255
257
  */
256
258
  export declare function parseNumstat(raw: string, base: string, head: string): DiffStat;
257
- /** The freshness-stamped handoff artifact's shape (M6.10). */
258
- export type HandoffArtifact = {
259
- schema: 1;
260
- taskId: string;
261
- runId: number;
262
- provider: string;
263
- /** v47: the exact model that ran, and the sealed route leg it ran as —
264
- * provenance a later reader can hold against the approval. Absent on
265
- * artifacts written before routes existed. */
266
- model?: string;
267
- route?: {
268
- digest: string;
269
- phase: "plan" | "build" | "repair" | "review";
270
- provider: string;
271
- model: string | null;
272
- chosen: "recommended" | "override" | "pinned" | "legacy" | "fallback";
273
- };
274
- sessionId: string | null;
275
- branch: string;
276
- worktree: string;
277
- base: string;
278
- head: string;
279
- outcome: "built" | "no-change";
280
- committed: boolean;
281
- /** Decision ids whose answers were in this run's brief — causality, not time. */
282
- decisionsIncorporated: number[];
283
- /** The agent's conclusion — agent-reported, and labeled so by its position here. */
284
- conclusion: string;
285
- /** Structured operator-facing output. Optional for artifacts written by
286
- * older versions; readers treat absence as an empty list. */
287
- changes?: string[];
288
- verification?: string[];
289
- followUps?: string[];
290
- freshness: {
291
- stampedAt: string;
292
- /** A successor proves this against the branch before trusting anything above. */
293
- currentAsOf: string;
294
- };
295
- };
296
259
  /**
297
260
  * Write the handoff artifact (M6.10): the machine's own statement of where
298
261
  * a finished run left the world — workspace identity, exact base and head,
@@ -301,7 +264,7 @@ export type HandoffArtifact = {
301
264
  * context. This is what makes a cold takeover context-bearing rather than
302
265
  * repo-only; a provider session is memory, not a freshness proof.
303
266
  */
304
- export declare function storeHandoffArtifact(store: Store, root: string, payload: HandoffArtifact, now: Date): number;
267
+ export declare function storeHandoffArtifact(store: Store, root: string, payload: Omit<HandoffArtifact, "version">, now: Date): number;
305
268
  /**
306
269
  * High-confidence secret shapes ONLY (audit IV-7): a detector that cries
307
270
  * wolf trains people to approve wolves. Each pattern is a format no
package/dist/evidence.js CHANGED
@@ -18,9 +18,10 @@ import { closeSync, existsSync, constants, fstatSync, mkdirSync, openSync, readd
18
18
  import { join, sep } from "node:path";
19
19
  import { namedPath } from "./names.js";
20
20
  import { LIMITS } from "./decision.js";
21
- import { PLAN_LIMITS } from "./plan.js";
21
+ import { TEXT_LIMITS } from "./text-limits.js";
22
22
  import { parseReport, REPORT_LIMITS } from "./scout-report.js";
23
23
  import { parseProof, PROOF_LIMITS } from "./proof.js";
24
+ import { HANDOFF_VERSION, readHandoffArtifact } from "./contracts/handoff.js";
24
25
  import { encodeBaseTreeSnapshot, parseBaseTreeSnapshot } from "./peek.js";
25
26
  /** The park mailbox pattern. Each run's actual name carries a nonce; see `mailboxName`. */
26
27
  export const MAILBOX_PREFIX = "STANDING-ORDERS-PARK-";
@@ -84,7 +85,7 @@ export const EVIDENCE_CAPS = {
84
85
  diff: 256 * 1024,
85
86
  status: 64 * 1024,
86
87
  "park-payload": LIMITS.payload,
87
- plan: PLAN_LIMITS.document,
88
+ plan: TEXT_LIMITS.planDocumentBytes,
88
89
  "terminal-diff": 256 * 1024,
89
90
  "diff-stat": 32 * 1024,
90
91
  // The enveloped base snapshot (live peek): 20k entries of path+sha+size.
@@ -618,7 +619,10 @@ export function parseNumstat(raw, base, head) {
618
619
  * repo-only; a provider session is memory, not a freshness proof.
619
620
  */
620
621
  export function storeHandoffArtifact(store, root, payload, now) {
621
- return storeEvidence(store, root, payload.runId, "handoff", "handoff.json", Buffer.from(JSON.stringify(payload, null, 2), "utf8"), "machine-authored handoff (exit 0)", now);
622
+ const read = readHandoffArtifact({ version: HANDOFF_VERSION, ...payload });
623
+ if (!read.ok)
624
+ throw new Error(`The handoff does not match its contract: ${read.issues.map(issue => issue.line).join("; ")}`);
625
+ return storeEvidence(store, root, payload.runId, "handoff", "handoff.json", Buffer.from(JSON.stringify(read.value, null, 2), "utf8"), "machine-authored handoff (exit 0)", now);
622
626
  }
623
627
  /**
624
628
  * High-confidence secret shapes ONLY (audit IV-7): a detector that cries
@@ -1,4 +1,5 @@
1
1
  import { type FlowDefinition, type FlowStepInput } from "./flows.js";
2
+ import type { TriggerInput } from "./contracts/flow.js";
2
3
  import type { Store } from "./store.js";
3
4
  export declare const GALLERY_GROUPS: readonly [{
4
5
  readonly id: "ship";
@@ -46,7 +47,7 @@ export type GalleryTemplate = {
46
47
  /** The steps in Toolroll's own repository, when they differ. */
47
48
  ownSteps?: FlowStepInput[];
48
49
  definition?: FlowDefinition;
49
- triggers: Record<string, unknown>[];
50
+ triggers: TriggerInput[];
50
51
  scripts?: {
51
52
  name: string;
52
53
  about: string;
@@ -100,7 +101,7 @@ export type GalleryBuilt = {
100
101
  repo: string;
101
102
  answers: GalleryAnswers;
102
103
  definition: FlowDefinition;
103
- triggers: Record<string, unknown>[];
104
+ triggers: TriggerInput[];
104
105
  scripts: {
105
106
  name: string;
106
107
  about: string;
@@ -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,30 @@ 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 { readVersioned } from "./contracts/contract.js";
26
+ import { FLOW_ALIASES, FLOW_FILE_FORMAT, FLOW_FILE_VERSION, flowFileSchema, PARAMETER_ID } from "./contracts/flow.js";
25
27
  import { parseSchedule } from "./routine.js";
26
- export const FLOW_FILE_FORMAT = "toolroll-flow";
27
- export const FLOW_FILE_VERSION = 1;
28
+ export { FLOW_FILE_FORMAT, FLOW_FILE_VERSION };
28
29
  /** The largest flow file Toolroll reads or fetches. */
29
30
  export const FLOW_FILE_MAX_BYTES = 256 * 1024;
30
31
  export const FLOW_FILE_SUFFIX = ".toolroll-flow.json";
31
32
  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
33
  const REMOVED = "[removed]";
34
34
  // ------------------------------------------------------------------ export
35
35
  /** A zone in the lead's words: only what it says, in the order a person reads it. */
36
36
  function stepOf(stage, ask) {
37
37
  const step = { id: stage.id, title: stage.title, kind: stage.kind };
38
38
  const words = (minutes) => durationMinutes(durationWords(minutes)) === minutes ? durationWords(minutes) : minutes;
39
- if (stage.instructions !== null)
39
+ // Only what the step's kind says: words a zone kept from a kind it was before stay behind.
40
+ if (stage.instructions !== null && (stage.kind === "task" || stage.kind === "report" || stage.kind === "draft" || stage.kind === "teammate"))
40
41
  step["instructions"] = stage.instructions;
41
42
  if (stage.kind === "task")
42
43
  step["planning"] = stage.planning;
43
44
  if (stage.kind === "approval")
44
45
  step["decider"] = stage.toOwner === true ? "owner" : stage.approver === null ? "anyone"
45
46
  : 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)
47
+ if (stage.message !== null && (stage.kind === "notify" || stage.kind === "update"))
47
48
  step["message"] = stage.message;
48
49
  if (stage.kind === "update")
49
50
  step["close"] = stage.close;
@@ -239,26 +240,28 @@ export function exportFlow(store, flow, dir) {
239
240
  format: FLOW_FILE_FORMAT, version: FLOW_FILE_VERSION, name: flow.name,
240
241
  about: ordered.map(one => one.title).join(" → "),
241
242
  needs: needsOf(definition, triggers, scripts.map(one => one.name)),
242
- parameters, zones, triggers, scripts,
243
+ // Zones and triggers in the file's words (stepOf, triggerOf): what flowFileSchema reads back (flow-share.test.ts).
244
+ parameters, zones: zones, triggers: triggers, scripts,
243
245
  };
244
246
  const file = scrubDeep(raw, scrubber(values, names), null, false);
245
247
  const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "flow";
246
248
  return { file, json: `${JSON.stringify(file, null, 2)}\n`, fileName: `${slug}${FLOW_FILE_SUFFIX}`, left };
247
249
  }
248
250
  // ------------------------------------------------------------------ reading
251
+ /** A flow file refused: why, in plain words, each problem naming its path in the file (`zones[0].routes[0]: ...`). */
249
252
  export class FlowFileError extends Error {
250
253
  }
251
254
  const refuse = (message) => { throw new FlowFileError(message); };
252
255
  /** A zone from the file as the canvas stores it: references by id (or by title), the rest checked by validateFlowDefinition. */
253
256
  function stageInputOf(step, index, find) {
254
- const title = typeof step["title"] === "string" ? step["title"] : `zone ${index + 1}`;
257
+ const at = `zones[${index}]`;
255
258
  const kind = step["kind"];
256
259
  const object = (key) => {
257
260
  const value = step[key];
258
261
  if (value === undefined || value === null)
259
262
  return undefined;
260
263
  if (typeof value !== "object" || Array.isArray(value))
261
- refuse(`Zone ${title}: ${key} must be an object.`);
264
+ refuse(`${at}.${key}: must be an object`);
262
265
  return value;
263
266
  };
264
267
  const list = (key) => {
@@ -266,59 +269,60 @@ function stageInputOf(step, index, find) {
266
269
  if (value === undefined || value === null)
267
270
  return undefined;
268
271
  if (!Array.isArray(value) || value.some(one => one === null || typeof one !== "object" || Array.isArray(one)))
269
- refuse(`Zone ${title}: ${key} must be a list.`);
272
+ refuse(`${at}.${key}: must be a list of objects`);
270
273
  return value;
271
274
  };
272
275
  const decider = step["decider"];
273
276
  if (decider !== undefined && decider !== null && typeof decider !== "string")
274
- refuse(`Zone ${title}: decider must be plain text.`);
277
+ refuse(`${at}.decider: must be a string`);
275
278
  const toOwner = kind === "approval" && typeof decider === "string" && decider.trim().toLowerCase() === "owner";
276
279
  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).`);
280
+ const place = object("at") ?? {};
281
+ const [failKey, fail] = ["ifNotSure", "ifNoReply", "ifReplied", "ifFails"].map(key => [key, step[key]]).find(([, value]) => value !== undefined && value !== null) ?? ["ifFails", undefined];
282
+ const minutes = (value, key) => value === undefined ? undefined : durationMinutes(value) ?? refuse(`${at}.${key}: say a time like "3 days" (up to 30 days)`);
280
283
  const sureAt = step["sureAt"];
281
284
  return {
282
285
  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"] },
286
+ zone: { x: place["x"], y: place["y"] ?? 0, w: place["w"], h: place["h"], color: place["color"] },
284
287
  instructions: step["instructions"], planning: step["planning"],
285
288
  ...(toOwner ? { toOwner: true } : { approver: anyone ? null : decider }),
286
289
  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) })),
290
+ routes: list("routes")?.map((one, n) => ({ answer: one["answer"], to: find(one["goesTo"], `${at}.routes[${n}].goesTo`) })),
288
291
  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) })),
292
+ ...(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
293
  sureAt: typeof sureAt === "number" && sureAt > 1 ? sureAt / 100 : sureAt, notes: list("alsoNote") ?? [] } } : {}),
291
294
  ...(kind === "request" ? { request: { method: step["method"], url: step["url"], headers: object("headers") ?? {}, body: step["body"] } } : {}),
292
295
  ...(kind === "email" ? { email: { to: step["to"], subject: step["subject"], body: step["body"] } } : {}),
293
296
  ...(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") } } : {}),
297
+ ...(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
298
  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),
299
+ ...(kind === "choose" ? { options: (list("options") ?? []).map((one, n) => ({ label: one["label"], to: one["goesTo"] === "end" ? "end" : find(one["goesTo"], `${at}.options[${n}].goesTo`) })) } : {}),
300
+ ...(step["remindAfter"] === undefined ? {} : { limit: { minutes: minutes(step["remindAfter"], "remindAfter"), to: find(step["thenMoveTo"], `${at}.thenMoveTo`) } }),
301
+ next: find(step["next"], `${at}.next`), onFail: find(fail, `${at}.${failKey}`),
299
302
  };
300
303
  }
301
304
  /** The zones as a flow, checked whole. Throws in plain words. */
302
305
  function definitionOf(zones) {
303
306
  const ids = zones.map(one => one["id"]);
304
- const find = (ref, from) => {
307
+ const find = (ref, path) => {
305
308
  if (ref === undefined || ref === null || ref === "")
306
309
  return null;
307
310
  if (typeof ref !== "string")
308
- return refuse(`Zone ${from}: a path names a zone by its id.`);
311
+ return refuse(`${path}: names a zone by its id`);
309
312
  const hit = zones.find(one => one["id"] === ref.trim()) ?? zones.find(one => typeof one["title"] === "string" && one["title"].trim().toLowerCase() === ref.trim().toLowerCase());
310
313
  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.`);
314
+ return refuse(`${path}: there's no zone called ${ref}`);
315
+ return typeof hit["id"] === "string" ? hit["id"] : refuse(`zones[${zones.indexOf(hit)}].id: required`);
313
316
  };
314
- if (ids.some(one => typeof one !== "string"))
315
- refuse("Every zone needs an id: lowercase letters, numbers and dashes.");
317
+ ids.forEach((one, index) => { if (typeof one !== "string")
318
+ refuse(`zones[${index}].id: required`); });
316
319
  const stages = zones.map((step, index) => stageInputOf(step, index, find));
320
+ // Its zones are the file's, in order: a zone's problem names the file's zone.
317
321
  try {
318
322
  return validateFlowDefinition({ version: 1, start: stages[0]?.["id"], stages });
319
323
  }
320
324
  catch (error) {
321
- return refuse(error instanceof Error ? error.message : "Those zones aren't a flow.");
325
+ 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
326
  }
323
327
  }
324
328
  const plainText = (value, cap, what, required) => {
@@ -328,17 +332,22 @@ const plainText = (value, cap, what, required) => {
328
332
  return refuse(`The file's ${what} must be plain text.`);
329
333
  if (value.length > cap)
330
334
  return refuse(`The file's ${what} is longer than ${cap} characters.`);
331
- if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f‪-‮⁦-⁩]/.test(value))
335
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/.test(value))
332
336
  return refuse(`The file's ${what} has hidden characters in it.`);
333
337
  return value.trim();
334
338
  };
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. */
339
+ /**
340
+ * Read a flow file: its size, its JSON and format, then its one schema (flowFileSchema: every zone, trigger, script
341
+ * and parameter, strict about unknown keys — `zones[0].routes[0]: unknown key 'to' (did you mean goesTo?)`), and a
342
+ * newer version refused plainly. Then what the schema can't say: no keys in a trigger, ids and names used once, each
343
+ * {{param.x}} declared, every script valid and the zones a flow. Throws FlowFileError in plain words.
344
+ */
336
345
  export function parseFlowFile(text) {
337
346
  if (Buffer.byteLength(text, "utf8") > FLOW_FILE_MAX_BYTES)
338
347
  refuse(`That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.`);
339
348
  let raw;
340
349
  try {
341
- raw = JSON.parse(text.replace(/^/, ""));
350
+ raw = JSON.parse(text.replace(/^\uFEFF/, ""));
342
351
  }
343
352
  catch {
344
353
  return refuse("That isn't a flow file: it isn't valid JSON.");
@@ -348,75 +357,48 @@ export function parseFlowFile(text) {
348
357
  const input = raw;
349
358
  if (input["format"] !== FLOW_FILE_FORMAT)
350
359
  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"];
360
+ // Triggers that name something on the installation they were made on stay there; say so, not that the kind is unknown.
361
+ (Array.isArray(input["triggers"]) ? input["triggers"] : []).forEach((one, index) => {
362
+ const kind = one !== null && typeof one === "object" ? one["kind"] : undefined;
388
363
  if (kind === "chat")
389
- return refuse("A chat channel trigger can't come from a file: connect the channel from the channel itself.");
364
+ refuse(`triggers[${index}].kind: a chat channel trigger can't come from a file; connect the channel from the channel itself`);
390
365
  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)}.`);
366
+ 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`);
367
+ });
368
+ const read = readVersioned(flowFileSchema, input, {}, FLOW_ALIASES);
369
+ if (!read.ok)
370
+ return refuse(read.issues.map(one => one.line).join("\n"));
371
+ const file = read.value;
372
+ const name = file.name.trim(), about = (file.about ?? "").trim();
373
+ if (name === "")
374
+ refuse("name: must not be empty");
375
+ const parameters = (file.parameters ?? []).map(one => ({ ...one, about: one.about.trim(), ...(one.default === undefined ? {} : { default: one.default.trim() }) }));
376
+ if (new Set(parameters.map(one => one.id)).size !== parameters.length)
377
+ refuse("parameters: two have the same id");
378
+ const triggers = file.triggers ?? [];
379
+ triggers.forEach((one, index) => {
394
380
  for (const [key, value] of Object.entries(one))
395
381
  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;
382
+ refuse(`triggers[${index}].${key}: looks like a key or password; keys never go in a flow file`);
398
383
  });
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;
384
+ const scripts = (file.scripts ?? []).map((row, index) => {
403
385
  let draft;
404
386
  try {
405
387
  draft = validateScript(row);
406
388
  }
407
389
  catch (error) {
408
- return refuse(`Script ${typeof row["name"] === "string" ? row["name"] : "(unnamed)"}: ${error instanceof Error ? error.message : "it isn't valid."}`);
390
+ return refuse(`scripts[${index}]: ${error instanceof Error ? error.message : "it isn't valid."}`);
409
391
  }
410
392
  return { name: draft.name, about: draft.about, language: draft.language, timeoutMinutes: draft.timeoutMinutes, ...(draft.file === null ? { body: draft.body } : { file: draft.file }) };
411
393
  });
412
394
  if (new Set(scripts.map(one => one.name)).size !== scripts.length)
413
- refuse("Two scripts have the same name.");
395
+ refuse("scripts: two have the same name");
414
396
  // Every {{param.x}} names a parameter the file declares.
415
- for (const match of JSON.stringify([zones, triggers]).matchAll(PARAM))
397
+ for (const match of JSON.stringify([file.zones, triggers]).matchAll(PARAM))
416
398
  if (!parameters.some(one => one.id === match[1]))
417
399
  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 };
400
+ definitionOf(file.zones);
401
+ 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
402
  }
421
403
  /** --param k=v lines (or a form's fields) as values; refuses parameters the file doesn't ask for. */
422
404
  export function parameterValues(file, given) {
@@ -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. */
@@ -1,77 +1,12 @@
1
1
  import type { Runner } from "./backend.js";
2
2
  import { type FlowAct } from "./flow-engine.js";
3
- import type { FlowDefinition } from "./flows.js";
3
+ import { type FlowDefinition } from "./flows.js";
4
+ import { type ContractResult } from "./contracts/contract.js";
5
+ import { FLOW_TRIGGER_KINDS, type ChatApp, type FlowTriggerKind, type TriggerConfig, type TriggerInput } from "./contracts/flow.js";
4
6
  import { type MailReader } from "./mailbox.js";
5
7
  import type { FlowCardSource, FlowRow, FlowTriggerRow, Store } from "./store.js";
6
- export declare const FLOW_TRIGGER_KINDS: readonly ["button", "schedule", "github", "linear", "flow", "webhook", "email", "chat", "plane-review"];
7
- export type FlowTriggerKind = (typeof FLOW_TRIGGER_KINDS)[number];
8
- export type TriggerConfig = {
9
- kind: "button";
10
- label: string;
11
- questions: string[];
12
- zone: string | null;
13
- }
14
- /** `script` (v90): run this project script on the schedule and make a card of each item it prints, instead of one card titled `title`. */
15
- | {
16
- kind: "schedule";
17
- schedule: string;
18
- title: string;
19
- description: string | null;
20
- zone: string | null;
21
- script?: string;
22
- secrets?: string[];
23
- } | {
24
- kind: "github";
25
- repo: string;
26
- watch: "issues" | "pulls" | "checks";
27
- label: string | null;
28
- branch: string | null;
29
- from: "team" | "anyone";
30
- delivery: "poll" | "webhook";
31
- zone: string | null;
32
- } | {
33
- kind: "linear";
34
- team: string | null;
35
- state: string | null;
36
- label: string | null;
37
- delivery: "poll" | "webhook";
38
- zone: string | null;
39
- } | {
40
- kind: "flow";
41
- flow: number;
42
- when: string;
43
- zone: string | null;
44
- } | {
45
- kind: "webhook";
46
- title: string;
47
- titleField: string | null;
48
- bodyField: string | null;
49
- zone: string | null;
50
- }
51
- /** `sender`: addresses or domains, comma-separated; `subject`: words the subject must contain. */
52
- | {
53
- kind: "email";
54
- folder: string;
55
- sender: string | null;
56
- subject: string | null;
57
- zone: string | null;
58
- }
59
- /** A channel in a chat app; `binding` is the pairing of the person who connected it (whose chat answers there). */
60
- | {
61
- kind: "chat";
62
- app: ChatApp;
63
- installation: string;
64
- chat: string;
65
- binding: number;
66
- zone: string | null;
67
- }
68
- /** `schedule`: always daily, in the routines' form ("daily:07:30@Europe/London"). */
69
- | {
70
- kind: "plane-review";
71
- schedule: string;
72
- zone: string | null;
73
- };
74
- export type ChatApp = "slack" | "discord" | "teams" | "telegram";
8
+ export { FLOW_TRIGGER_KINDS };
9
+ export type { ChatApp, FlowTriggerKind, TriggerConfig, TriggerInput };
75
10
  export declare const CHAT_APP_NAMES: Record<ChatApp, string>;
76
11
  /** What each kind is called on the canvas. */
77
12
  export declare const FLOW_TRIGGER_WORDS: Record<FlowTriggerKind, string>;
@@ -79,11 +14,18 @@ export declare const FLOW_TRIGGER_WORDS: Record<FlowTriggerKind, string>;
79
14
  export declare const POLL_EVERY_MS: number;
80
15
  /** At most this many cards from one check or one delivery; the rest wait for the next. */
81
16
  export declare const CARDS_PER_CHECK = 10;
17
+ /**
18
+ * A trigger's settings read by their one schema (`label: required`, `unknown key 'flows'`). A setting given as null is
19
+ * left out, and a button's blank question lines are dropped, as every release has read them. Then what the schema can't
20
+ * say, named by its path: no hidden characters and nothing that looks like a key, anywhere in the settings.
21
+ */
22
+ export declare function readTriggerSettings(raw: unknown): ContractResult<TriggerInput>;
82
23
  /** A schedule said in words ("every 2 hours", "daily 09:00 Europe/London", "monday 09:00") or in the routines' own form. */
83
24
  export declare function scheduleFromWords(text: string): string | null;
84
25
  /** A GitHub repository as owner/name, read from a project's origin remote when it is on GitHub. */
85
26
  export declare function githubRepoOf(path: string): string | null;
86
- /** A trigger's settings, checked whole against the flow it starts cards in. Throws in plain words. */
27
+ /** A trigger's settings, checked whole against the flow it starts cards in (readTriggerSettings, then the flow, its zones,
28
+ * the project's scripts and the schedule). Throws FlowContractError, each line naming its setting. */
87
29
  export declare function validateTriggerConfig(raw: unknown, context: {
88
30
  store: Store;
89
31
  flow: FlowRow;
@@ -105,6 +47,8 @@ export declare function triggerHeadline(config: TriggerConfig, store: Store): {
105
47
  detail: string;
106
48
  };
107
49
  export declare function triggerConfigOf(trigger: FlowTriggerRow): TriggerConfig | null;
50
+ /** A saved trigger's config (`configJson`, which carries no version) read by its one schema, what it leaves out filled. */
51
+ export declare function readTriggerConfig(raw: unknown): ContractResult<TriggerConfig>;
108
52
  /** Whether this trigger takes deliveries at a secret address rather than being checked. */
109
53
  export declare function takesDeliveries(config: TriggerConfig): boolean;
110
54
  /** A webhook's signing secret (GitHub: made here; Linear: pasted from Linear). 0600, beside the database, never a column. */
@@ -267,4 +211,3 @@ export type FormAnswer = {
267
211
  export declare function flowFormPage(store: Store, token: string, now: Date): FormAnswer;
268
212
  /** POST a shared form: at most one card per submission, keys refused, a limit per hour, and quiet about what a bot did wrong. */
269
213
  export declare function receiveFlowForm(store: Store, token: string, fields: URLSearchParams, now: Date): FormAnswer;
270
- export {};