@foldrun/core 0.4.0 → 0.6.0

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 (170) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/browser-pod.d.ts +148 -0
  9. package/dist/src/browser-pod.js +333 -0
  10. package/dist/src/catalog.js +2 -1
  11. package/dist/src/clock.d.ts +49 -0
  12. package/dist/src/clock.js +121 -0
  13. package/dist/src/completions.js +17 -8
  14. package/dist/src/confine.d.ts +22 -0
  15. package/dist/src/confine.js +218 -3
  16. package/dist/src/container.d.ts +9 -0
  17. package/dist/src/container.js +115 -5
  18. package/dist/src/context-tools.d.ts +1 -1
  19. package/dist/src/deploy.d.ts +33 -0
  20. package/dist/src/deploy.js +145 -6
  21. package/dist/src/egress.d.ts +7 -0
  22. package/dist/src/egress.js +6 -0
  23. package/dist/src/eol.d.ts +2 -0
  24. package/dist/src/eol.js +7 -0
  25. package/dist/src/evals.d.ts +5 -0
  26. package/dist/src/evals.js +9 -2
  27. package/dist/src/flow-lint.d.ts +8 -0
  28. package/dist/src/flow-lint.js +223 -1
  29. package/dist/src/flow-patterns.d.ts +141 -0
  30. package/dist/src/flow-patterns.js +568 -0
  31. package/dist/src/gitrepo.d.ts +18 -0
  32. package/dist/src/gitrepo.js +49 -0
  33. package/dist/src/host-env.js +5 -0
  34. package/dist/src/kinds.js +1 -1
  35. package/dist/src/language.d.ts +17 -0
  36. package/dist/src/language.js +55 -0
  37. package/dist/src/layout.d.ts +69 -0
  38. package/dist/src/layout.js +200 -0
  39. package/dist/src/library.d.ts +9 -0
  40. package/dist/src/library.js +30 -3
  41. package/dist/src/limits.d.ts +84 -0
  42. package/dist/src/limits.js +287 -0
  43. package/dist/src/locale.d.ts +47 -0
  44. package/dist/src/locale.js +177 -0
  45. package/dist/src/mail.d.ts +88 -0
  46. package/dist/src/mail.js +247 -0
  47. package/dist/src/model-credential.d.ts +14 -0
  48. package/dist/src/model-credential.js +58 -0
  49. package/dist/src/model-probe.d.ts +3 -0
  50. package/dist/src/model-probe.js +1 -0
  51. package/dist/src/notify.d.ts +53 -7
  52. package/dist/src/notify.js +194 -126
  53. package/dist/src/openapi.js +5 -3
  54. package/dist/src/operator.d.ts +201 -0
  55. package/dist/src/operator.js +251 -0
  56. package/dist/src/paths.d.ts +28 -0
  57. package/dist/src/paths.js +55 -0
  58. package/dist/src/platform.d.ts +43 -3
  59. package/dist/src/platform.js +9 -1
  60. package/dist/src/provider-health.js +3 -2
  61. package/dist/src/providers.d.ts +481 -0
  62. package/dist/src/providers.js +1105 -6
  63. package/dist/src/refusal.d.ts +83 -0
  64. package/dist/src/refusal.js +281 -0
  65. package/dist/src/run-container.d.ts +149 -13
  66. package/dist/src/run-container.js +424 -43
  67. package/dist/src/runner.d.ts +177 -9
  68. package/dist/src/runner.js +1146 -154
  69. package/dist/src/runtime.d.ts +52 -0
  70. package/dist/src/runtime.js +493 -58
  71. package/dist/src/script-tools.d.ts +17 -2
  72. package/dist/src/script-tools.js +82 -10
  73. package/dist/src/secret-files.d.ts +6 -5
  74. package/dist/src/secret-files.js +24 -11
  75. package/dist/src/secret-health.d.ts +30 -6
  76. package/dist/src/secret-health.js +42 -20
  77. package/dist/src/secrets.d.ts +19 -0
  78. package/dist/src/secrets.js +155 -86
  79. package/dist/src/starter.d.ts +9 -0
  80. package/dist/src/starter.js +92 -11
  81. package/dist/src/step-exec.d.ts +72 -2
  82. package/dist/src/step-exec.js +431 -37
  83. package/dist/src/step-removal.d.ts +15 -0
  84. package/dist/src/step-removal.js +49 -0
  85. package/dist/src/storage.d.ts +23 -4
  86. package/dist/src/storage.js +125 -23
  87. package/dist/src/store.d.ts +222 -37
  88. package/dist/src/store.js +349 -62
  89. package/dist/src/subagents.d.ts +75 -0
  90. package/dist/src/subagents.js +149 -0
  91. package/dist/src/test-mode.d.ts +17 -2
  92. package/dist/src/test-mode.js +53 -13
  93. package/dist/src/tool-names.d.ts +13 -1
  94. package/dist/src/tool-names.js +38 -11
  95. package/dist/src/tool-programs.d.ts +26 -0
  96. package/dist/src/tool-programs.js +172 -0
  97. package/dist/src/tool-test.js +104 -12
  98. package/dist/src/translator.d.ts +8 -2
  99. package/dist/src/translator.js +69 -23
  100. package/dist/src/trigger-log.d.ts +1 -1
  101. package/dist/src/triggers.d.ts +3 -0
  102. package/dist/src/triggers.js +10 -2
  103. package/dist/src/untrusted-fetch.d.ts +11 -0
  104. package/dist/src/untrusted-fetch.js +77 -0
  105. package/dist/src/web-actions.d.ts +91 -0
  106. package/dist/src/web-actions.js +199 -0
  107. package/dist/src/webhook.d.ts +4 -0
  108. package/dist/src/webhook.js +39 -0
  109. package/index.ts +4 -0
  110. package/package.json +3 -3
  111. package/src/account.ts +149 -0
  112. package/src/api-tools.ts +32 -4
  113. package/src/browser-pod.ts +350 -0
  114. package/src/catalog.ts +2 -1
  115. package/src/clock.ts +149 -0
  116. package/src/completions.ts +17 -8
  117. package/src/confine.ts +218 -3
  118. package/src/container.ts +112 -5
  119. package/src/context-tools.ts +1 -1
  120. package/src/deploy.ts +137 -6
  121. package/src/egress.ts +13 -1
  122. package/src/eol.ts +7 -0
  123. package/src/evals.ts +14 -2
  124. package/src/flow-lint.ts +253 -1
  125. package/src/flow-patterns.ts +597 -0
  126. package/src/gitrepo.ts +48 -0
  127. package/src/host-env.ts +5 -0
  128. package/src/kinds.ts +1 -1
  129. package/src/language.ts +62 -0
  130. package/src/layout.ts +228 -0
  131. package/src/library.ts +37 -3
  132. package/src/limits.ts +290 -0
  133. package/src/locale.ts +217 -0
  134. package/src/mail.ts +318 -0
  135. package/src/model-credential.ts +71 -0
  136. package/src/model-probe.ts +4 -0
  137. package/src/notify.ts +189 -103
  138. package/src/openapi.ts +5 -3
  139. package/src/operator.ts +300 -0
  140. package/src/paths.ts +54 -0
  141. package/src/platform.ts +52 -3
  142. package/src/provider-health.ts +3 -2
  143. package/src/providers.ts +1368 -6
  144. package/src/refusal.ts +320 -0
  145. package/src/run-container.ts +460 -44
  146. package/src/runner.ts +1168 -155
  147. package/src/runtime.ts +487 -55
  148. package/src/script-tools.ts +81 -10
  149. package/src/secret-files.ts +22 -10
  150. package/src/secret-health.ts +64 -19
  151. package/src/secrets.ts +149 -87
  152. package/src/starter.ts +93 -11
  153. package/src/step-exec.ts +447 -38
  154. package/src/step-removal.ts +59 -0
  155. package/src/storage.ts +141 -22
  156. package/src/store.ts +506 -70
  157. package/src/subagents.ts +189 -0
  158. package/src/test-mode.ts +51 -11
  159. package/src/tool-names.ts +40 -11
  160. package/src/tool-programs.ts +179 -0
  161. package/src/tool-test.ts +110 -12
  162. package/src/translator.ts +72 -22
  163. package/src/trigger-log.ts +1 -0
  164. package/src/triggers.ts +10 -2
  165. package/src/untrusted-fetch.ts +88 -0
  166. package/src/web-actions.ts +237 -0
  167. package/src/webhook.ts +41 -0
  168. package/templates/blog-desk/agents/writer/agent.md +1 -1
  169. package/templates/hello/agents/curator/agent.md +4 -4
  170. package/templates/hello/agents/notetaker/agent.md +3 -3
package/src/egress.ts CHANGED
@@ -94,6 +94,11 @@ export interface EgressGrant {
94
94
  * API_KEY are two credentials, and one's refusal must not paint the
95
95
  * other red. Absent for a secret whose origin the runner did not know. */
96
96
  healthKeys?: Record<string, string>;
97
+ /** The step's own timezone, from the clock cascade. The proxy is the only
98
+ * party that sees an upstream response's headers, so it is the party
99
+ * that has to put a rate-limit reset into words — and a reset time is
100
+ * only useful in the calendar the step works to. See refusal.ts. */
101
+ timezone?: string;
97
102
  }
98
103
 
99
104
  export interface EgressLease {
@@ -124,7 +129,8 @@ export interface EgressHooks {
124
129
  * policy in test-mode.ts to every request on this lease. Carried on the
125
130
  * lease, not the request, because the pod is the party not trusted to
126
131
  * say so. */
127
- lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean }): Promise<EgressLease | null>;
132
+ /** `step` — the step's index in run.steps; keys that step's operator inbox. */
133
+ lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number }): Promise<EgressLease | null>;
128
134
  }
129
135
 
130
136
  /** Strip a header set of the proxy's own and hop-by-hop headers. */
@@ -188,5 +194,11 @@ export function addGrant(grant: EgressGrant, name: string, value: string, host:
188
194
  const existing = grant.secrets[name];
189
195
  if (existing) {
190
196
  if (!existing.hosts.includes(h)) existing.hosts.push(h);
197
+ // A name granted again with a different value is a rotation: the
198
+ // runner re-grants the model key after awaitRotatedCredential, on the
199
+ // same grant object the lease holds by reference. Keeping the first
200
+ // value here meant the "retry on the new one" sent the revoked token —
201
+ // every 4-hourly refresh on 2026-09-18..22 failed the step it landed on.
202
+ existing.value = value;
191
203
  } else grant.secrets[name] = { value, hosts: [h] };
192
204
  }
package/src/eol.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Flow (and other markdown) rewrites work on LF text: the parser and the
2
+ // line rules read "\n"-separated lines, and a CRLF line's trailing "\r"
3
+ // defeats them (an option line never matched, so setting it again added a
4
+ // second copy; a CRLF step line was no step at all). So each rewrite takes a
5
+ // CRLF file as LF and gives it back as CRLF.
6
+ export const toLF = (raw: string) => raw.replace(/\r\n/g, "\n");
7
+ export const asRaw = (raw: string, out: string) => (raw.includes("\r\n") ? out.replace(/\r?\n/g, "\r\n") : out);
package/src/evals.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  // they pass — no point paying a model to grade output already known to be
32
32
  // wrong.
33
33
 
34
+ import { sdkEnv } from "./step-exec.ts";
34
35
  import fs from "node:fs";
35
36
  import path from "node:path";
36
37
  import { deployedCommit } from "./deploy.ts";
@@ -84,6 +85,11 @@ export interface EvalInfo {
84
85
  * unless the file says `live: true`. A flow eval that must really send
85
86
  * to prove itself opts in; nothing opts in by accident. */
86
87
  live: boolean;
88
+ /** A file of saved inputs for a flow, not a test: `inputs: true`. Its
89
+ * cases are named tasks the dashboard's "Run with…" and `foldrun invoke
90
+ * --inputs` start the flow with; nothing is asserted, so no eval run,
91
+ * deploy or `foldrun eval` ever runs them. */
92
+ inputs: boolean;
87
93
  cases: EvalCase[];
88
94
  }
89
95
 
@@ -192,6 +198,7 @@ export function parseEval(file: string, raw: string): EvalInfo {
192
198
  effort: resolveEffort(data.effort ?? "low"),
193
199
  trigger: data.trigger === "manual" ? "manual" : "deploy",
194
200
  live: data.live === true,
201
+ inputs: data.inputs === true,
195
202
  cases,
196
203
  };
197
204
  }
@@ -485,6 +492,8 @@ async function judge(
485
492
  tools: [],
486
493
  allowedTools: [],
487
494
  settingSources: [],
495
+ strictMcpConfig: true,
496
+ env: sdkEnv(undefined),
488
497
  maxTurns: 1,
489
498
  },
490
499
  });
@@ -550,8 +559,11 @@ export async function runEval(
550
559
  ): Promise<EvalResult> {
551
560
  const startedAt = new Date().toISOString();
552
561
  const results: CaseResult[] = [];
562
+ // Saved inputs carry no assertions: running them would start and bill a
563
+ // run per case only to report every one failed. Nothing to grade.
564
+ const cases = info.inputs ? [] : info.cases;
553
565
 
554
- for (const testCase of info.cases) {
566
+ for (const testCase of cases) {
555
567
  const target = info.flow ? null : info.agent;
556
568
  let runId: string | null = null;
557
569
  let output = "";
@@ -768,7 +780,7 @@ export async function evaluateDeployed(
768
780
  if (!stamp) return { ran: 0, skipped: "no revision to stamp the result with" };
769
781
  // Only the evals that asked to run on deploy — a `trigger: manual` eval is
770
782
  // one a person runs on purpose, because it costs a whole flow run.
771
- const evals = listEvals(tenant, workspace).filter((e) => e.trigger !== "manual");
783
+ const evals = listEvals(tenant, workspace).filter((e) => e.trigger !== "manual" && !e.inputs);
772
784
  if (evals.length === 0) return { ran: 0, skipped: "no evals" };
773
785
  const dir = path.join(workspaceDir(tenant, workspace), "evals", ".results");
774
786
  fs.mkdirSync(dir, { recursive: true });
package/src/flow-lint.ts CHANGED
@@ -12,6 +12,19 @@
12
12
  // blocked — flows still run, warnings are advisory.
13
13
 
14
14
  import type { FlowInfo, FlowStep } from "./store.ts";
15
+ import { whenRowsPath } from "./store.ts";
16
+ import { timezoneProblem } from "./clock.ts";
17
+
18
+ /** What a `[[flow:x]]` step's options are called in a flow file, for the
19
+ * ones expandSubflows does not carry into the nested steps. */
20
+ const SUBFLOW_DROPPED: [keyof FlowStep, string][] = [
21
+ ["approve", "an approval (!)"], ["when", "when:"], ["case", "case:"], ["else", "else:"],
22
+ ["retry", "retry:"], ["timeout", "timeout:"], ["verify", "verify:"], ["model", "model:"],
23
+ ["effort", "effort:"], ["onFail", "on-fail:"], ["waitSecs", "wait:"], ["waitFor", "wait:"],
24
+ ["ask", "ask:"], ["preview", "preview:"], ["delegate", "delegate:"], ["loop", "loop:"],
25
+ ["until", "until:"], ["each", "each:"], ["max", "max:"], ["output", "output:"],
26
+ ["schema", "schema:"], ["parallel", "parallel:"], ["maxTurns", "max_turns:"], ["limits", "limits:"],
27
+ ];
15
28
 
16
29
  export interface FlowWarning {
17
30
  /** Index into flow.steps, or null for a whole-flow warning. */
@@ -24,6 +37,9 @@ export interface FlowWarning {
24
37
  * `foldrun check` must fail on rather than note. Absent means advisory,
25
38
  * which every structural warning in this file is. */
26
39
  level?: "error" | "warn";
40
+ /** A stable tag for warnings a caller wants to single out — the deploy
41
+ * surfaces `kind: "outward"` as a non-blocking warning. */
42
+ kind?: string;
27
43
  }
28
44
 
29
45
  // Anaphora: phrases that only mean something if earlier output exists.
@@ -37,6 +53,11 @@ const REFERS_BACK =
37
53
  * checks too. */
38
54
  export interface KnownNames {
39
55
  agents: string[];
56
+ /** Agents that hold a tool declaring `outward: true` — one that sends,
57
+ * posts, buys or publishes. The caller resolves the grants; this file only
58
+ * needs the answer. Absent means the caller did not look, and the outward
59
+ * rule is skipped rather than guessed at. */
60
+ outwardAgents?: string[];
40
61
  }
41
62
 
42
63
  /** One @, something either side, a dot in the domain, no whitespace. Written
@@ -51,10 +72,44 @@ function looksLikeAddress(a: string): boolean {
51
72
  return dot > 0 && dot < domain.length - 1;
52
73
  }
53
74
 
75
+ /** The assertion words a `verify:` may open with. Anything else is handed to
76
+ * a shell — see `checkVerify` in step-exec.ts, which owns this list. */
77
+ const VERIFY_ASSERTION = /^(contains|not-contains|matches|file|judge):\s/;
78
+
79
+ /** What a shell command has that a sentence does not: an operator that only
80
+ * means something to a shell, a path or bracket at the very start, or one
81
+ * of the commands these flows actually reach for. Deliberately NOT any
82
+ * punctuation a sentence might carry — the first version disqualified on
83
+ * `;`, `,`, `'` and `"`, so "the reply opens with GOOD; a GOOD reply names
84
+ * the SHA" read as shell and a guaranteed exit 127 sat in fix-desk unseen
85
+ * by the very lint written to catch it. */
86
+ const LOOKS_LIKE_SHELL =
87
+ /&&|\|\||\s\|\s|\$\(|\$\{|`|\s[<>]\s|\d?>[>&]?\s|^\[|^[.~/]|^\s*\b(test|grep|egrep|find|head|tail|cat|ls|node|npm|npx|bash|sh|zsh|python3?|jq|awk|sed|curl|git|make|exit|n=|[A-Za-z_]+=)/;
88
+
89
+ /** A `verify:` that is a claim in English rather than a command. Conservative
90
+ * on purpose, the same bargain as REFERS_BACK: it must have no assertion
91
+ * prefix, nothing shell-shaped anywhere in it, and enough words to be a
92
+ * sentence — so a bare `./check.sh` or a one-word command is left alone and
93
+ * the warning only fires where the author plainly wrote prose. */
94
+ function looksLikeProse(verify: string): boolean {
95
+ const v = verify.trim();
96
+ if (!v || VERIFY_ASSERTION.test(v)) return false;
97
+ if (LOOKS_LIKE_SHELL.test(v)) return false;
98
+ return v.split(/\s+/).length >= 4;
99
+ }
100
+
54
101
  export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
55
102
  const warnings: FlowWarning[] = [];
56
103
  const agentNames = known ? new Set(known.agents) : null;
57
104
 
105
+ // A frontmatter enum the parser could not read, or a step line whose
106
+ // `[[target]]` is malformed — recorded by parseFlow, reported here at
107
+ // error level so `foldrun check` fails on it, the way a bad step option
108
+ // does. A value nothing can read is never a silent default.
109
+ for (const fp of flow.frontProblems ?? []) {
110
+ warnings.push({ step: null, level: "error", message: fp, detail: "The line is written but its value cannot be read, so a run would use a silent default instead of what the author meant. Write one of the values the option names, or a target in lower case, digits and dashes." });
111
+ }
112
+
58
113
  // Cron's oldest trap, and an expensive one: when day-of-month AND
59
114
  // day-of-week are both restricted they are OR'd, not AND'd — the scheduler
60
115
  // does it too, correctly (`domRestricted && dowRestricted` → `domOk ||
@@ -87,6 +142,21 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
87
142
  "a second pair of eyes with `!` (or `approve: true`), or drop the approvers: line.",
88
143
  });
89
144
  }
145
+ // A zone nobody can read is two faults at once: the cron fires at the
146
+ // wrong hour, and every step's calendar quietly falls through to the level
147
+ // above. Both are invisible in a green run, so `foldrun check` refuses it.
148
+ const zoneProblem = timezoneProblem(flow.timezone);
149
+ if (zoneProblem) {
150
+ warnings.push({
151
+ step: null,
152
+ level: "error",
153
+ message: zoneProblem,
154
+ detail:
155
+ "This is the zone the schedule fires in AND the calendar every step in this flow works " +
156
+ "to. Unreadable, the schedule falls back to UTC and each step inherits the workspace's " +
157
+ "zone instead of this one.",
158
+ });
159
+ }
90
160
  // A deadline on a gate that does not exist reads as a safety net and is not
91
161
  // one. Same reasoning, said separately because the fix is different.
92
162
  if (flow.approveWithin && !flow.steps.some((s) => s.approve || s.ask)) {
@@ -187,6 +257,24 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
187
257
  });
188
258
  }
189
259
 
260
+ // An instruction written over more than one line. It is read in full
261
+ // now, but the parser had to decide where it stopped, and the line
262
+ // below a wrapped instruction is one edit away from being prose. Said
263
+ // out loud so an author who meant to wrap can see it, and one who did
264
+ // not can see what got joined on.
265
+ if (step.wrapped) {
266
+ warnings.push({
267
+ step: i,
268
+ line: step.line,
269
+ message: `"${step.subflow ?? step.agent}" has an instruction spread over more than one line`,
270
+ detail:
271
+ "A step's instruction ends where the step ends: the indented lines directly under it " +
272
+ "are joined on, and the first blank line, option, heading, bullet or unindented line " +
273
+ "finishes it. Put the whole instruction on the step's own line — one line, however " +
274
+ "long — so what the agent is told does not depend on where the paragraph below begins.",
275
+ });
276
+ }
277
+
190
278
  // The first group starts with context = null. An instruction that points
191
279
  // at earlier output therefore points at nothing.
192
280
  if (inFirstGroup && step.instruction && REFERS_BACK.test(step.instruction)) {
@@ -200,6 +288,31 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
200
288
  });
201
289
  }
202
290
 
291
+ // `../../storage/…` still works everywhere; `workspace/storage/…` is the
292
+ // spelling that also works in a shell, and the one the docs teach. Said
293
+ // once per step, advisory — never an error on a path that resolves.
294
+ {
295
+ const rowsOf = step.when ? whenRowsPath(step.when) : null;
296
+ const spots = [
297
+ step.eachPath ? ["each: rows of", step.eachPath] : null,
298
+ rowsOf ? ["when: rows of", rowsOf] : null,
299
+ step.verify ? ["verify:", step.verify] : null,
300
+ step.schemaPath ? ["schema:", step.schemaPath] : null,
301
+ step.instruction ? ["the instruction", step.instruction] : null,
302
+ ].filter((x): x is [string, string] => !!x && /\.\.\/\.\.\/(storage|state)\b/.test(x[1]));
303
+ if (spots.length) {
304
+ warnings.push({
305
+ step: i,
306
+ line: step.line,
307
+ kind: "spelling",
308
+ message: `"${step.subflow ?? step.agent}" writes ../../storage or ../../state in ${spots.map((x) => x[0]).join(", ")}`,
309
+ detail:
310
+ "It still works. The documented spelling is workspace/storage/… and workspace/state/… — " +
311
+ "the same place, and it also resolves in the shell and in scripts.",
312
+ });
313
+ }
314
+ }
315
+
203
316
  // A gate with no `preview:` shows only the previous step's reply and
204
317
  // whatever files that reply happens to name. The person deciding sees a
205
318
  // summary, not the thing — unless the agent remembered.
@@ -215,6 +328,45 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
215
328
  });
216
329
  }
217
330
 
331
+ // A step that can act on the outside world, with nothing to say whether
332
+ // it did. The two answers are a check the runtime can fail on, or a
333
+ // person; either is fine, neither is not. This is the one lint here that
334
+ // is an error: every other structural warning costs a reread, and this
335
+ // one costs a send nobody can take back.
336
+ if (known?.outwardAgents?.includes(step.agent ?? "") && !step.verify && !step.approve && !step.ask) {
337
+ warnings.push({
338
+ step: step.group,
339
+ line: step.line,
340
+ level: "error",
341
+ kind: "outward",
342
+ message: `[[${step.agent}]] can act outside this workspace and this step has no verify: and no gate`,
343
+ detail:
344
+ "A step that sends, posts, buys or publishes needs one of two things: a `verify:` the " +
345
+ "runtime can fail the step on — a receipt file, a ledger row, the reply's own verdict " +
346
+ "line — or a person, with `!` or `ask:`. Without either, a step that sent nothing and a " +
347
+ "step that sent everything end the same way: reported as done.",
348
+ });
349
+ }
350
+
351
+ // retry: re-runs a failed step from the start. For a step that sends,
352
+ // a failure can come after the send — the verify: or the JSON check
353
+ // failed on a step whose email went — and the retry sends it again. The
354
+ // runner declines that one retry (see actedThenFailedCheck in runner.ts)
355
+ // but a crash mid-send still reruns; the author should know.
356
+ if (known?.outwardAgents?.includes(step.agent ?? "") && step.retry) {
357
+ warnings.push({
358
+ step: i,
359
+ line: step.line,
360
+ kind: "outward",
361
+ message: `[[${step.agent}]] can act outside this workspace and this step has retry: ${step.retry}`,
362
+ detail:
363
+ "A retry runs the whole step again, sends included. The runner does not retry a failure " +
364
+ "that came from the check after the step's tools ran, but an attempt that failed mid-way — " +
365
+ "a timeout, a crash, a provider error after the first send — is retried and may repeat " +
366
+ "what it already did. Make the send idempotent (check a ledger before sending), or drop retry:.",
367
+ });
368
+ }
369
+
218
370
  // `each: items` fans out over DATA, and only an `output: json` step in
219
371
  // an earlier group produces any. Without one the step expands to
220
372
  // nothing every time, quietly — the exact failure this file exists for.
@@ -251,6 +403,46 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
251
403
  });
252
404
  }
253
405
 
406
+ // The step's own agent. `delegate:` and `on-fail:` are checked below and
407
+ // the step's target was not, which is the reference that stops the run
408
+ // rather than degrading it: the runner reaches the step, finds nothing to
409
+ // run and fails it with `agent "<name>" not found in workspace`. A
410
+ // scheduled flow does that at 3am, three times, before anyone looks.
411
+ if (agentNames && step.agent && !step.subflow && !agentNames.has(step.agent)) {
412
+ warnings.push({
413
+ step: i,
414
+ line: step.line,
415
+ level: "error",
416
+ message: `[[${step.agent}]] is not an agent in this workspace`,
417
+ detail:
418
+ "This step has nothing to run. Every run of this flow fails here. Check the spelling " +
419
+ "against `agents/<name>/agent.md` — the name is the folder's, not the file's heading — " +
420
+ "or add the agent.",
421
+ });
422
+ }
423
+
424
+ // A `verify:` written as a sentence. Two dialects share the key: a line
425
+ // beginning `contains:`, `not-contains:`, `matches:`, `file:` or
426
+ // `judge:` is an assertion, and ANYTHING else is handed to a shell. So
427
+ // `verify: the reply names the clone it made` runs a program called
428
+ // `the`, exits 127, and fails a step that did its job perfectly — every
429
+ // run, with an error about bash that says nothing about the flow. The
430
+ // fix is one word: the same sentence behind `judge:` is graded by a
431
+ // model, which is what the author meant.
432
+ if (step.verify && looksLikeProse(step.verify)) {
433
+ warnings.push({
434
+ step: i,
435
+ line: step.line,
436
+ level: "error",
437
+ message: `verify: reads as a sentence, so it will be run as a shell command`,
438
+ detail:
439
+ `\`${step.verify.slice(0, 60)}${step.verify.length > 60 ? "…" : ""}\` has no assertion ` +
440
+ "prefix, so the runtime executes it: the first word becomes the program and the step " +
441
+ "fails with `command not found`. Put `judge: ` in front to have a model grade the " +
442
+ "claim, or write it as a shell command that exits 0 when the claim holds.",
443
+ });
444
+ }
445
+
254
446
  // A step's OTHER agent references — the ones no parser validates,
255
447
  // because they are options rather than the step's target. Both fail
256
448
  // silently and at the worst possible moment: a misspelled `delegate:`
@@ -294,7 +486,8 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
294
486
  }
295
487
 
296
488
  // `when:` tests the previous results, which the first group doesn't have.
297
- if (inFirstGroup && step.when) {
489
+ // `when: rows of` reads a file instead, which may well exist already.
490
+ if (inFirstGroup && step.when && whenRowsPath(step.when) === null) {
298
491
  warnings.push({
299
492
  step: i,
300
493
  line: step.line,
@@ -304,6 +497,25 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
304
497
  "always be skipped. Move it after the step whose output it depends on.",
305
498
  });
306
499
  }
500
+
501
+ // A `[[flow:x]]` step is spliced in as the other flow's own steps, and
502
+ // only its `?` and its instruction survive the splice. Everything else
503
+ // written under it was dropped without a word — a `when:` that never
504
+ // gated, a `!` that never asked.
505
+ if (step.subflow) {
506
+ const dropped = SUBFLOW_DROPPED.filter(([field]) => step[field] !== undefined).map(([, name]) => name);
507
+ if (dropped.length) {
508
+ warnings.push({
509
+ step: i,
510
+ line: step.line,
511
+ message: `"flow:${step.subflow}" has ${dropped.join(", ")}, which a nested flow ignores`,
512
+ detail:
513
+ "A nested flow runs as its own steps, with their own options; what is written under the " +
514
+ "[[flow:…]] line is not applied to them. Put the option on the steps inside that flow, " +
515
+ "or make this an agent step.",
516
+ });
517
+ }
518
+ }
307
519
  });
308
520
 
309
521
  // The same agent twice in one parallel group gets two identical contexts and
@@ -327,6 +539,46 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
327
539
  });
328
540
  }
329
541
  }
542
+
543
+ // A gate parks its whole group, and on approval every step in it starts
544
+ // together. So `5. [[editor]]` beside `5! [[publisher]]` does not mean
545
+ // "edit, then ask, then publish": the editor waits for the approval too,
546
+ // and the publisher runs alongside it, reading files the editor has not
547
+ // written yet. medium-desk's publisher did exactly that on 2026-09-28
548
+ // (run-mukdjeh8-cr66): post.json was still empty when it started.
549
+ const gated = (s: FlowStep) => Boolean(s.approve || s.ask || s.waitFor === "event");
550
+ const gates = steps.filter(gated);
551
+ const others = steps.filter((s) => !gated(s));
552
+ if (gates.length && others.length) {
553
+ const name = (s: FlowStep) => (s.subflow ? `flow:${s.subflow}` : s.agent);
554
+ warnings.push({
555
+ step: flow.steps.indexOf(gates[0]),
556
+ line: gates[0].line,
557
+ message: `"${name(gates[0])}" waits for a person but shares number ${group} with "${others.map(name).join('", "')}"`,
558
+ detail:
559
+ "Steps with the same number run together. The gate holds all of them until it is " +
560
+ "approved, then they start at once — so the gated step cannot use what the others " +
561
+ `produce, and the approver sees none of it. To run the gated step after them, number it ${group + 1}.`,
562
+ });
563
+ }
564
+ }
565
+
566
+ // A number with nothing under it is harmless to the runner, which only
567
+ // orders groups, but it is almost always one step numbered twice: 3, 3, 4,
568
+ // 5, 5!, 7 is a renumbering that missed the 5! that was meant to be 6.
569
+ const numbers = [...byGroup.keys()].sort((a, b) => a - b);
570
+ for (let i = 1; i < numbers.length; i++) {
571
+ if (numbers[i] - numbers[i - 1] < 2) continue;
572
+ const missing = numbers[i - 1] + 1 === numbers[i] - 1 ? `${numbers[i - 1] + 1}` : `${numbers[i - 1] + 1} to ${numbers[i] - 1}`;
573
+ const shared = [...byGroup.entries()].filter(([, s]) => s.length > 1).map(([g]) => g);
574
+ warnings.push({
575
+ step: null,
576
+ message: `step numbers go from ${numbers[i - 1]} to ${numbers[i]}; nothing is numbered ${missing}`,
577
+ detail:
578
+ "The same number means \"run together\", so a gap usually means a step kept an old number " +
579
+ "after a renumbering" +
580
+ (shared.length ? ` — ${shared.map((g) => `number ${g}`).join(", ")} has more than one step; check whether one of them belongs in the gap.` : "."),
581
+ });
330
582
  }
331
583
 
332
584
  return warnings;