@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
@@ -20,14 +20,42 @@
20
20
  import fs from "node:fs";
21
21
  import os from "node:os";
22
22
  import path from "node:path";
23
- import { saveWorkspace, workspaceDir, listRuns, parseFlow, WORKSPACE_DIRS, assertSafeName, notifyWorkspaceChanged, } from "./store.js";
23
+ import { saveWorkspace, keptOnDeploy, workspaceDir, listRuns, parseFlow, parseToolDef, WORKSPACE_DIRS, assertSafeName, notifyWorkspaceChanged, } from "./store.js";
24
+ import { lintFlow } from "./flow-lint.js";
25
+ import { refNames } from "./refs.js";
26
+ import matter from "gray-matter";
24
27
  import { conformanceIssues } from "./okf.js";
28
+ import { timezoneProblem } from "./clock.js";
29
+ import { webProblems, webWarnings, runnerEngines } from "./providers.js";
30
+ import { readLimits } from "./limits.js";
31
+ import { actionProblems } from "./web-actions.js";
32
+ import { languageProblem } from "./language.js";
33
+ import { localeProblems } from "./locale.js";
25
34
  import { repoDir } from "./gitrepo.js";
35
+ import { entriesNoFollow } from "./paths.js";
26
36
  /** Directories a deploy never reads out of a source tree. */
27
37
  const NOT_SOURCE = new Set([".git", "node_modules", "runs", "outputs", ".foldrun", ".results"]);
28
38
  /** Files that are never part of a workspace, whatever directory they sit in. */
29
39
  const NOT_SOURCE_FILE = new Set([".DS_Store", "secrets.json"]);
30
40
  const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
41
+ /**
42
+ * Is this path, relative to a workspace root, one `readTree` would carry?
43
+ *
44
+ * The same question the walk above asks, asked of a path that is not on this
45
+ * machine — what the platform lists for a workspace, so `foldrun status` and
46
+ * `foldrun pull` compare like with like. Without it the comparison counted
47
+ * every run artefact and bookkeeping file the platform keeps as a local
48
+ * deletion, and every status was a wall of red.
49
+ */
50
+ export function isSourcePath(rel) {
51
+ const norm = rel.replaceAll("\\", "/");
52
+ const parts = norm.split("/");
53
+ if (parts.some((p) => NOT_SOURCE.has(p)))
54
+ return false;
55
+ if (NOT_SOURCE_FILE.has(parts[parts.length - 1]))
56
+ return false;
57
+ return norm === "AGENTS.md" || norm === "project.md" || IN_WORKSPACE_DIR.test(norm);
58
+ }
31
59
  /**
32
60
  * Read a directory into the file list a deploy ships.
33
61
  *
@@ -130,6 +158,72 @@ export function readTree(dir) {
130
158
  * not on disk yet. A deploy that would leave the workspace failing its own
131
159
  * checker should not be a deploy.
132
160
  */
161
+ /** Best-effort warnings for a deploy: an agent's `web:` warnings (see
162
+ * webWarnings), and the outward gate — a step whose agent grants
163
+ * a workspace tool marked `outward: true` and has no `verify:`/gate. Reads
164
+ * only the deployed files, so a library or gallery outward tool is not seen
165
+ * here — `foldrun check`, which resolves every scope, remains the real gate.
166
+ * Non-blocking by decision: the deploy proceeds and says so. */
167
+ export function deployWarnings(files) {
168
+ const front = (content) => {
169
+ try {
170
+ return matter(content).data;
171
+ }
172
+ catch {
173
+ return {};
174
+ }
175
+ };
176
+ // Workspace tools that change something outside the workspace.
177
+ const outwardTools = new Set();
178
+ for (const f of files) {
179
+ const m = f.path.match(/^tools\/([^/]+?)(?:\/tool)?\.md$/);
180
+ if (!m)
181
+ continue;
182
+ const data = front(f.content);
183
+ const def = parseToolDef(data, m[1], f.content);
184
+ // `outward: true` is read off the frontmatter, the same test the runtime
185
+ // and the CLI use; parseToolDef does not fold it onto the def.
186
+ if (def && data.outward === true)
187
+ outwardTools.add(def.name ?? m[1]);
188
+ }
189
+ // Agents that grant one of them.
190
+ const outwardAgents = [];
191
+ const agentNames = [];
192
+ const out = [];
193
+ // The web: warnings check prints — the old session: key, and a pinned
194
+ // user agent behind the runner image's Chrome, which the platform knows
195
+ // from FOLDRUN_RUNNER_ENGINES (the deploy read it from the image).
196
+ const { chrome, chromium } = runnerEngines() ?? {};
197
+ for (const f of files) {
198
+ const m = f.path.match(/^agents\/([^/]+)\/agent\.md$/);
199
+ if (!m)
200
+ continue;
201
+ agentNames.push(m[1]);
202
+ const data = front(f.content);
203
+ if (refNames(data.tools).some((t) => outwardTools.has(t)))
204
+ outwardAgents.push(m[1]);
205
+ for (const w of webWarnings(data, { chrome, chromium }))
206
+ out.push({ where: f.path, message: w });
207
+ }
208
+ if (!outwardAgents.length)
209
+ return out;
210
+ for (const f of files) {
211
+ if (!/^flows\/[^/]+\.md$/.test(f.path))
212
+ continue;
213
+ let flow;
214
+ try {
215
+ flow = parseFlow(path.basename(f.path), f.content);
216
+ }
217
+ catch {
218
+ continue;
219
+ }
220
+ for (const w of lintFlow(flow, { agents: agentNames, outwardAgents })) {
221
+ if (w.kind === "outward")
222
+ out.push({ where: `${f.path}${w.line ? `:${w.line}` : ""}`, message: w.message });
223
+ }
224
+ }
225
+ return out;
226
+ }
133
227
  export function deployIssues(files) {
134
228
  const issues = [];
135
229
  const at = (where, message) => issues.push({ where, message });
@@ -157,6 +251,40 @@ export function deployIssues(files) {
157
251
  if (m)
158
252
  agents.add(m[1]);
159
253
  }
254
+ // `timezone:` is read at four levels and a value nothing can parse falls
255
+ // silently through to the level above — so it is refused where it was
256
+ // written, not discovered as a date that is a day out.
257
+ for (const f of files) {
258
+ if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md)$/.test(f.path))
259
+ continue;
260
+ let front = {};
261
+ try {
262
+ front = matter(f.content).data;
263
+ }
264
+ catch {
265
+ continue; // unparseable frontmatter is its own report
266
+ }
267
+ const problem = timezoneProblem(front.timezone);
268
+ if (problem)
269
+ at(f.path, problem);
270
+ // `schedule:` on an agent or AGENTS.md runs nothing — only a flow has a
271
+ // clock. Refuse it where it was written rather than deploy it dead.
272
+ if (front.schedule !== undefined) {
273
+ at(f.path, "schedule: an agent has no schedule of its own — only a flow runs on a clock. Put schedule: on the flow that runs this agent, or remove it.");
274
+ }
275
+ // The same refusal check gives: a web key that cannot work never ships.
276
+ for (const w of [...webProblems(front), ...actionProblems(front)])
277
+ at(f.path, w);
278
+ // `limits:` that cannot be read — a count of 0, a word, web.<not an
279
+ // action> — would run uncapped where the author meant a cap.
280
+ for (const w of readLimits(front.limits).problems)
281
+ at(f.path, w);
282
+ const lang = languageProblem(front.language);
283
+ if (lang)
284
+ at(f.path, lang);
285
+ for (const w of localeProblems(front))
286
+ at(f.path, w);
287
+ }
160
288
  if (agents.size === 0) {
161
289
  at("agents/", "no agents — a workspace needs at least one agents/<name>/agent.md");
162
290
  }
@@ -184,6 +312,8 @@ export function deployIssues(files) {
184
312
  }
185
313
  if (parsed.steps.length === 0)
186
314
  at(f.path, "no steps");
315
+ for (const fp of parsed.frontProblems)
316
+ at(f.path, fp);
187
317
  for (const s of parsed.steps) {
188
318
  const target = s.subflow ?? s.agent;
189
319
  const known = s.subflow ? flowNames.has(target) : agents.has(target);
@@ -245,7 +375,7 @@ export function planDeploy(tenant, workspace, files) {
245
375
  const shipped = new Map(files.map((f) => [path.normalize(f.path), f.content]));
246
376
  const present = new Set();
247
377
  if (fs.existsSync(dir)) {
248
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
378
+ for (const entry of entriesNoFollow(dir)) {
249
379
  const rel = String(entry).split(path.sep).join("/");
250
380
  if (NOT_SOURCE.has(rel.split("/")[0]))
251
381
  continue;
@@ -278,16 +408,18 @@ export function planDeploy(tenant, workspace, files) {
278
408
  updated.push(rel);
279
409
  }
280
410
  }
281
- // What saveWorkspace will keep regardless — reporting these as removals
282
- // would be a lie, and the alarming kind.
283
- const kept = (rel) => /(^|\/)state\//.test(rel) || /(^|\/)memory\/[^/]+\.md$/.test(rel);
284
- const removed = [...present].filter((rel) => !shipped.has(rel) && !kept(rel));
411
+ // What saveWorkspace will keep regardless — storage/, state/, agent
412
+ // memory, the platform's own files. Reporting these as removals would be
413
+ // a lie, and the alarming kind; one predicate serves both.
414
+ const shippedPaths = new Set(shipped.keys());
415
+ const removed = [...present].filter((rel) => !shipped.has(rel) && !keptOnDeploy(rel, shippedPaths));
285
416
  return {
286
417
  files,
287
418
  added: added.sort(),
288
419
  updated: updated.sort(),
289
420
  removed: removed.sort(),
290
421
  issues: deployIssues(files),
422
+ warnings: deployWarnings(files),
291
423
  blockedBy: runsInFlight(tenant, workspace),
292
424
  };
293
425
  }
@@ -351,6 +483,13 @@ export function deployWorkspace(tenant, workspace, files, opts = {}) {
351
483
  if (plan.issues.length > 0 || blocked) {
352
484
  return { ...plan, applied: false, commit: null, preserved: 0 };
353
485
  }
486
+ if (opts.expectRemoved) {
487
+ const ok = new Set(opts.expectRemoved.map((p) => path.normalize(p).split(path.sep).join("/")));
488
+ const unexpectedRemovals = plan.removed.filter((p) => !ok.has(p));
489
+ if (unexpectedRemovals.length) {
490
+ return { ...plan, applied: false, commit: null, preserved: 0, unexpectedRemovals };
491
+ }
492
+ }
354
493
  const { preserved } = saveWorkspace(tenant, workspace, files, { commit: opts.commit ?? null, by: opts.by ?? "deploy", message: opts.message });
355
494
  const commit = opts.commit ?? null;
356
495
  if (commit)
@@ -36,6 +36,11 @@ export interface EgressGrant {
36
36
  * API_KEY are two credentials, and one's refusal must not paint the
37
37
  * other red. Absent for a secret whose origin the runner did not know. */
38
38
  healthKeys?: Record<string, string>;
39
+ /** The step's own timezone, from the clock cascade. The proxy is the only
40
+ * party that sees an upstream response's headers, so it is the party
41
+ * that has to put a rate-limit reset into words — and a reset time is
42
+ * only useful in the calendar the step works to. See refusal.ts. */
43
+ timezone?: string;
39
44
  }
40
45
  export interface EgressLease {
41
46
  /** The base the pod sends through: `http://…/e/<token>`. */
@@ -66,11 +71,13 @@ export interface EgressHooks {
66
71
  * policy in test-mode.ts to every request on this lease. Carried on the
67
72
  * lease, not the request, because the pod is the party not trusted to
68
73
  * say so. */
74
+ /** `step` — the step's index in run.steps; keys that step's operator inbox. */
69
75
  lease(args: {
70
76
  tenant: string;
71
77
  runId: string;
72
78
  grant: EgressGrant;
73
79
  test?: boolean;
80
+ step?: number;
74
81
  }): Promise<EgressLease | null>;
75
82
  }
76
83
  /** Strip a header set of the proxy's own and hop-by-hop headers. */
@@ -128,6 +128,12 @@ export function addGrant(grant, name, value, host) {
128
128
  if (existing) {
129
129
  if (!existing.hosts.includes(h))
130
130
  existing.hosts.push(h);
131
+ // A name granted again with a different value is a rotation: the
132
+ // runner re-grants the model key after awaitRotatedCredential, on the
133
+ // same grant object the lease holds by reference. Keeping the first
134
+ // value here meant the "retry on the new one" sent the revoked token —
135
+ // every 4-hourly refresh on 2026-09-18..22 failed the step it landed on.
136
+ existing.value = value;
131
137
  }
132
138
  else
133
139
  grant.secrets[name] = { value, hosts: [h] };
@@ -0,0 +1,2 @@
1
+ export declare const toLF: (raw: string) => string;
2
+ export declare const asRaw: (raw: string, out: string) => string;
@@ -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) => raw.replace(/\r\n/g, "\n");
7
+ export const asRaw = (raw, out) => (raw.includes("\r\n") ? out.replace(/\r?\n/g, "\r\n") : out);
@@ -42,6 +42,11 @@ export interface EvalInfo {
42
42
  * unless the file says `live: true`. A flow eval that must really send
43
43
  * to prove itself opts in; nothing opts in by accident. */
44
44
  live: boolean;
45
+ /** A file of saved inputs for a flow, not a test: `inputs: true`. Its
46
+ * cases are named tasks the dashboard's "Run with…" and `foldrun invoke
47
+ * --inputs` start the flow with; nothing is asserted, so no eval run,
48
+ * deploy or `foldrun eval` ever runs them. */
49
+ inputs: boolean;
45
50
  cases: EvalCase[];
46
51
  }
47
52
  export declare function parseEval(file: string, raw: string): EvalInfo;
package/dist/src/evals.js CHANGED
@@ -30,6 +30,7 @@
30
30
  // Deterministic checks run first and cost nothing. The judge only runs if
31
31
  // they pass — no point paying a model to grade output already known to be
32
32
  // wrong.
33
+ import { sdkEnv } from "./step-exec.js";
33
34
  import fs from "node:fs";
34
35
  import path from "node:path";
35
36
  import { deployedCommit } from "./deploy.js";
@@ -139,6 +140,7 @@ export function parseEval(file, raw) {
139
140
  effort: resolveEffort(data.effort ?? "low"),
140
141
  trigger: data.trigger === "manual" ? "manual" : "deploy",
141
142
  live: data.live === true,
143
+ inputs: data.inputs === true,
142
144
  cases,
143
145
  };
144
146
  }
@@ -364,6 +366,8 @@ async function judge(rubric, output, model, effort) {
364
366
  tools: [],
365
367
  allowedTools: [],
366
368
  settingSources: [],
369
+ strictMcpConfig: true,
370
+ env: sdkEnv(undefined),
367
371
  maxTurns: 1,
368
372
  },
369
373
  });
@@ -419,7 +423,10 @@ async function checkAssertion(assertion, output, agentDir, model, effort) {
419
423
  export async function runEval(tenant, workspace, info) {
420
424
  const startedAt = new Date().toISOString();
421
425
  const results = [];
422
- for (const testCase of info.cases) {
426
+ // Saved inputs carry no assertions: running them would start and bill a
427
+ // run per case only to report every one failed. Nothing to grade.
428
+ const cases = info.inputs ? [] : info.cases;
429
+ for (const testCase of cases) {
423
430
  const target = info.flow ? null : info.agent;
424
431
  let runId = null;
425
432
  let output = "";
@@ -601,7 +608,7 @@ commit = null) {
601
608
  return { ran: 0, skipped: "no revision to stamp the result with" };
602
609
  // Only the evals that asked to run on deploy — a `trigger: manual` eval is
603
610
  // one a person runs on purpose, because it costs a whole flow run.
604
- const evals = listEvals(tenant, workspace).filter((e) => e.trigger !== "manual");
611
+ const evals = listEvals(tenant, workspace).filter((e) => e.trigger !== "manual" && !e.inputs);
605
612
  if (evals.length === 0)
606
613
  return { ran: 0, skipped: "no evals" };
607
614
  const dir = path.join(workspaceDir(tenant, workspace), "evals", ".results");
@@ -10,11 +10,19 @@ export interface FlowWarning {
10
10
  * `foldrun check` must fail on rather than note. Absent means advisory,
11
11
  * which every structural warning in this file is. */
12
12
  level?: "error" | "warn";
13
+ /** A stable tag for warnings a caller wants to single out — the deploy
14
+ * surfaces `kind: "outward"` as a non-blocking warning. */
15
+ kind?: string;
13
16
  }
14
17
  /** What exists in the workspace, when the caller knows. Optional: the lint
15
18
  * is useful without it, and a caller that has the lists gets the reference
16
19
  * checks too. */
17
20
  export interface KnownNames {
18
21
  agents: string[];
22
+ /** Agents that hold a tool declaring `outward: true` — one that sends,
23
+ * posts, buys or publishes. The caller resolves the grants; this file only
24
+ * needs the answer. Absent means the caller did not look, and the outward
25
+ * rule is skipped rather than guessed at. */
26
+ outwardAgents?: string[];
19
27
  }
20
28
  export declare function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[];
@@ -10,6 +10,18 @@
10
10
  // Every check here is *structural*: it reasons about what a step can possibly
11
11
  // have in context, never about whether the model did a good job. Nothing is
12
12
  // blocked — flows still run, warnings are advisory.
13
+ import { whenRowsPath } from "./store.js";
14
+ import { timezoneProblem } from "./clock.js";
15
+ /** What a `[[flow:x]]` step's options are called in a flow file, for the
16
+ * ones expandSubflows does not carry into the nested steps. */
17
+ const SUBFLOW_DROPPED = [
18
+ ["approve", "an approval (!)"], ["when", "when:"], ["case", "case:"], ["else", "else:"],
19
+ ["retry", "retry:"], ["timeout", "timeout:"], ["verify", "verify:"], ["model", "model:"],
20
+ ["effort", "effort:"], ["onFail", "on-fail:"], ["waitSecs", "wait:"], ["waitFor", "wait:"],
21
+ ["ask", "ask:"], ["preview", "preview:"], ["delegate", "delegate:"], ["loop", "loop:"],
22
+ ["until", "until:"], ["each", "each:"], ["max", "max:"], ["output", "output:"],
23
+ ["schema", "schema:"], ["parallel", "parallel:"], ["maxTurns", "max_turns:"], ["limits", "limits:"],
24
+ ];
13
25
  // Anaphora: phrases that only mean something if earlier output exists.
14
26
  // Deliberately conservative — a false warning is worse than a missed one,
15
27
  // because noisy warnings get ignored and then the real one is invisible too.
@@ -26,9 +38,40 @@ function looksLikeAddress(a) {
26
38
  const dot = domain.indexOf(".");
27
39
  return dot > 0 && dot < domain.length - 1;
28
40
  }
41
+ /** The assertion words a `verify:` may open with. Anything else is handed to
42
+ * a shell — see `checkVerify` in step-exec.ts, which owns this list. */
43
+ const VERIFY_ASSERTION = /^(contains|not-contains|matches|file|judge):\s/;
44
+ /** What a shell command has that a sentence does not: an operator that only
45
+ * means something to a shell, a path or bracket at the very start, or one
46
+ * of the commands these flows actually reach for. Deliberately NOT any
47
+ * punctuation a sentence might carry — the first version disqualified on
48
+ * `;`, `,`, `'` and `"`, so "the reply opens with GOOD; a GOOD reply names
49
+ * the SHA" read as shell and a guaranteed exit 127 sat in fix-desk unseen
50
+ * by the very lint written to catch it. */
51
+ const LOOKS_LIKE_SHELL = /&&|\|\||\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_]+=)/;
52
+ /** A `verify:` that is a claim in English rather than a command. Conservative
53
+ * on purpose, the same bargain as REFERS_BACK: it must have no assertion
54
+ * prefix, nothing shell-shaped anywhere in it, and enough words to be a
55
+ * sentence — so a bare `./check.sh` or a one-word command is left alone and
56
+ * the warning only fires where the author plainly wrote prose. */
57
+ function looksLikeProse(verify) {
58
+ const v = verify.trim();
59
+ if (!v || VERIFY_ASSERTION.test(v))
60
+ return false;
61
+ if (LOOKS_LIKE_SHELL.test(v))
62
+ return false;
63
+ return v.split(/\s+/).length >= 4;
64
+ }
29
65
  export function lintFlow(flow, known) {
30
66
  const warnings = [];
31
67
  const agentNames = known ? new Set(known.agents) : null;
68
+ // A frontmatter enum the parser could not read, or a step line whose
69
+ // `[[target]]` is malformed — recorded by parseFlow, reported here at
70
+ // error level so `foldrun check` fails on it, the way a bad step option
71
+ // does. A value nothing can read is never a silent default.
72
+ for (const fp of flow.frontProblems ?? []) {
73
+ 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." });
74
+ }
32
75
  // Cron's oldest trap, and an expensive one: when day-of-month AND
33
76
  // day-of-week are both restricted they are OR'd, not AND'd — the scheduler
34
77
  // does it too, correctly (`domRestricted && dowRestricted` → `domOk ||
@@ -60,6 +103,20 @@ export function lintFlow(flow, known) {
60
103
  "a second pair of eyes with `!` (or `approve: true`), or drop the approvers: line.",
61
104
  });
62
105
  }
106
+ // A zone nobody can read is two faults at once: the cron fires at the
107
+ // wrong hour, and every step's calendar quietly falls through to the level
108
+ // above. Both are invisible in a green run, so `foldrun check` refuses it.
109
+ const zoneProblem = timezoneProblem(flow.timezone);
110
+ if (zoneProblem) {
111
+ warnings.push({
112
+ step: null,
113
+ level: "error",
114
+ message: zoneProblem,
115
+ detail: "This is the zone the schedule fires in AND the calendar every step in this flow works " +
116
+ "to. Unreadable, the schedule falls back to UTC and each step inherits the workspace's " +
117
+ "zone instead of this one.",
118
+ });
119
+ }
63
120
  // A deadline on a gate that does not exist reads as a safety net and is not
64
121
  // one. Same reasoning, said separately because the fix is different.
65
122
  if (flow.approveWithin && !flow.steps.some((s) => s.approve || s.ask)) {
@@ -153,6 +210,22 @@ export function lintFlow(flow, known) {
153
210
  "within the key's cap (retry and loop at most 5, max at most 20).",
154
211
  });
155
212
  }
213
+ // An instruction written over more than one line. It is read in full
214
+ // now, but the parser had to decide where it stopped, and the line
215
+ // below a wrapped instruction is one edit away from being prose. Said
216
+ // out loud so an author who meant to wrap can see it, and one who did
217
+ // not can see what got joined on.
218
+ if (step.wrapped) {
219
+ warnings.push({
220
+ step: i,
221
+ line: step.line,
222
+ message: `"${step.subflow ?? step.agent}" has an instruction spread over more than one line`,
223
+ detail: "A step's instruction ends where the step ends: the indented lines directly under it " +
224
+ "are joined on, and the first blank line, option, heading, bullet or unindented line " +
225
+ "finishes it. Put the whole instruction on the step's own line — one line, however " +
226
+ "long — so what the agent is told does not depend on where the paragraph below begins.",
227
+ });
228
+ }
156
229
  // The first group starts with context = null. An instruction that points
157
230
  // at earlier output therefore points at nothing.
158
231
  if (inFirstGroup && step.instruction && REFERS_BACK.test(step.instruction)) {
@@ -164,6 +237,29 @@ export function lintFlow(flow, known) {
164
237
  "invent what it was asked to review rather than fail. Move it to a later group.",
165
238
  });
166
239
  }
240
+ // `../../storage/…` still works everywhere; `workspace/storage/…` is the
241
+ // spelling that also works in a shell, and the one the docs teach. Said
242
+ // once per step, advisory — never an error on a path that resolves.
243
+ {
244
+ const rowsOf = step.when ? whenRowsPath(step.when) : null;
245
+ const spots = [
246
+ step.eachPath ? ["each: rows of", step.eachPath] : null,
247
+ rowsOf ? ["when: rows of", rowsOf] : null,
248
+ step.verify ? ["verify:", step.verify] : null,
249
+ step.schemaPath ? ["schema:", step.schemaPath] : null,
250
+ step.instruction ? ["the instruction", step.instruction] : null,
251
+ ].filter((x) => !!x && /\.\.\/\.\.\/(storage|state)\b/.test(x[1]));
252
+ if (spots.length) {
253
+ warnings.push({
254
+ step: i,
255
+ line: step.line,
256
+ kind: "spelling",
257
+ message: `"${step.subflow ?? step.agent}" writes ../../storage or ../../state in ${spots.map((x) => x[0]).join(", ")}`,
258
+ detail: "It still works. The documented spelling is workspace/storage/… and workspace/state/… — " +
259
+ "the same place, and it also resolves in the shell and in scripts.",
260
+ });
261
+ }
262
+ }
167
263
  // A gate with no `preview:` shows only the previous step's reply and
168
264
  // whatever files that reply happens to name. The person deciding sees a
169
265
  // summary, not the thing — unless the agent remembered.
@@ -177,6 +273,41 @@ export function lintFlow(flow, known) {
177
273
  "always sees the document, not a pointer, even when the agent forgets to name it.",
178
274
  });
179
275
  }
276
+ // A step that can act on the outside world, with nothing to say whether
277
+ // it did. The two answers are a check the runtime can fail on, or a
278
+ // person; either is fine, neither is not. This is the one lint here that
279
+ // is an error: every other structural warning costs a reread, and this
280
+ // one costs a send nobody can take back.
281
+ if (known?.outwardAgents?.includes(step.agent ?? "") && !step.verify && !step.approve && !step.ask) {
282
+ warnings.push({
283
+ step: step.group,
284
+ line: step.line,
285
+ level: "error",
286
+ kind: "outward",
287
+ message: `[[${step.agent}]] can act outside this workspace and this step has no verify: and no gate`,
288
+ detail: "A step that sends, posts, buys or publishes needs one of two things: a `verify:` the " +
289
+ "runtime can fail the step on — a receipt file, a ledger row, the reply's own verdict " +
290
+ "line — or a person, with `!` or `ask:`. Without either, a step that sent nothing and a " +
291
+ "step that sent everything end the same way: reported as done.",
292
+ });
293
+ }
294
+ // retry: re-runs a failed step from the start. For a step that sends,
295
+ // a failure can come after the send — the verify: or the JSON check
296
+ // failed on a step whose email went — and the retry sends it again. The
297
+ // runner declines that one retry (see actedThenFailedCheck in runner.ts)
298
+ // but a crash mid-send still reruns; the author should know.
299
+ if (known?.outwardAgents?.includes(step.agent ?? "") && step.retry) {
300
+ warnings.push({
301
+ step: i,
302
+ line: step.line,
303
+ kind: "outward",
304
+ message: `[[${step.agent}]] can act outside this workspace and this step has retry: ${step.retry}`,
305
+ detail: "A retry runs the whole step again, sends included. The runner does not retry a failure " +
306
+ "that came from the check after the step's tools ran, but an attempt that failed mid-way — " +
307
+ "a timeout, a crash, a provider error after the first send — is retried and may repeat " +
308
+ "what it already did. Make the send idempotent (check a ledger before sending), or drop retry:.",
309
+ });
310
+ }
180
311
  // `each: items` fans out over DATA, and only an `output: json` step in
181
312
  // an earlier group produces any. Without one the step expands to
182
313
  // nothing every time, quietly — the exact failure this file exists for.
@@ -210,6 +341,42 @@ export function lintFlow(flow, known) {
210
341
  detail: "schema: describes the JSON value an output: json step returns. Without output: json nothing is parsed, so nothing is checked. Add `output: json`.",
211
342
  });
212
343
  }
344
+ // The step's own agent. `delegate:` and `on-fail:` are checked below and
345
+ // the step's target was not, which is the reference that stops the run
346
+ // rather than degrading it: the runner reaches the step, finds nothing to
347
+ // run and fails it with `agent "<name>" not found in workspace`. A
348
+ // scheduled flow does that at 3am, three times, before anyone looks.
349
+ if (agentNames && step.agent && !step.subflow && !agentNames.has(step.agent)) {
350
+ warnings.push({
351
+ step: i,
352
+ line: step.line,
353
+ level: "error",
354
+ message: `[[${step.agent}]] is not an agent in this workspace`,
355
+ detail: "This step has nothing to run. Every run of this flow fails here. Check the spelling " +
356
+ "against `agents/<name>/agent.md` — the name is the folder's, not the file's heading — " +
357
+ "or add the agent.",
358
+ });
359
+ }
360
+ // A `verify:` written as a sentence. Two dialects share the key: a line
361
+ // beginning `contains:`, `not-contains:`, `matches:`, `file:` or
362
+ // `judge:` is an assertion, and ANYTHING else is handed to a shell. So
363
+ // `verify: the reply names the clone it made` runs a program called
364
+ // `the`, exits 127, and fails a step that did its job perfectly — every
365
+ // run, with an error about bash that says nothing about the flow. The
366
+ // fix is one word: the same sentence behind `judge:` is graded by a
367
+ // model, which is what the author meant.
368
+ if (step.verify && looksLikeProse(step.verify)) {
369
+ warnings.push({
370
+ step: i,
371
+ line: step.line,
372
+ level: "error",
373
+ message: `verify: reads as a sentence, so it will be run as a shell command`,
374
+ detail: `\`${step.verify.slice(0, 60)}${step.verify.length > 60 ? "…" : ""}\` has no assertion ` +
375
+ "prefix, so the runtime executes it: the first word becomes the program and the step " +
376
+ "fails with `command not found`. Put `judge: ` in front to have a model grade the " +
377
+ "claim, or write it as a shell command that exits 0 when the claim holds.",
378
+ });
379
+ }
213
380
  // A step's OTHER agent references — the ones no parser validates,
214
381
  // because they are options rather than the step's target. Both fail
215
382
  // silently and at the worst possible moment: a misspelled `delegate:`
@@ -249,7 +416,8 @@ export function lintFlow(flow, known) {
249
416
  });
250
417
  }
251
418
  // `when:` tests the previous results, which the first group doesn't have.
252
- if (inFirstGroup && step.when) {
419
+ // `when: rows of` reads a file instead, which may well exist already.
420
+ if (inFirstGroup && step.when && whenRowsPath(step.when) === null) {
253
421
  warnings.push({
254
422
  step: i,
255
423
  line: step.line,
@@ -258,6 +426,23 @@ export function lintFlow(flow, known) {
258
426
  "always be skipped. Move it after the step whose output it depends on.",
259
427
  });
260
428
  }
429
+ // A `[[flow:x]]` step is spliced in as the other flow's own steps, and
430
+ // only its `?` and its instruction survive the splice. Everything else
431
+ // written under it was dropped without a word — a `when:` that never
432
+ // gated, a `!` that never asked.
433
+ if (step.subflow) {
434
+ const dropped = SUBFLOW_DROPPED.filter(([field]) => step[field] !== undefined).map(([, name]) => name);
435
+ if (dropped.length) {
436
+ warnings.push({
437
+ step: i,
438
+ line: step.line,
439
+ message: `"flow:${step.subflow}" has ${dropped.join(", ")}, which a nested flow ignores`,
440
+ detail: "A nested flow runs as its own steps, with their own options; what is written under the " +
441
+ "[[flow:…]] line is not applied to them. Put the option on the steps inside that flow, " +
442
+ "or make this an agent step.",
443
+ });
444
+ }
445
+ }
261
446
  });
262
447
  // The same agent twice in one parallel group gets two identical contexts and
263
448
  // no way to divide the work — almost always a drag that landed wrong.
@@ -280,6 +465,43 @@ export function lintFlow(flow, known) {
280
465
  });
281
466
  }
282
467
  }
468
+ // A gate parks its whole group, and on approval every step in it starts
469
+ // together. So `5. [[editor]]` beside `5! [[publisher]]` does not mean
470
+ // "edit, then ask, then publish": the editor waits for the approval too,
471
+ // and the publisher runs alongside it, reading files the editor has not
472
+ // written yet. medium-desk's publisher did exactly that on 2026-09-28
473
+ // (run-mukdjeh8-cr66): post.json was still empty when it started.
474
+ const gated = (s) => Boolean(s.approve || s.ask || s.waitFor === "event");
475
+ const gates = steps.filter(gated);
476
+ const others = steps.filter((s) => !gated(s));
477
+ if (gates.length && others.length) {
478
+ const name = (s) => (s.subflow ? `flow:${s.subflow}` : s.agent);
479
+ warnings.push({
480
+ step: flow.steps.indexOf(gates[0]),
481
+ line: gates[0].line,
482
+ message: `"${name(gates[0])}" waits for a person but shares number ${group} with "${others.map(name).join('", "')}"`,
483
+ detail: "Steps with the same number run together. The gate holds all of them until it is " +
484
+ "approved, then they start at once — so the gated step cannot use what the others " +
485
+ `produce, and the approver sees none of it. To run the gated step after them, number it ${group + 1}.`,
486
+ });
487
+ }
488
+ }
489
+ // A number with nothing under it is harmless to the runner, which only
490
+ // orders groups, but it is almost always one step numbered twice: 3, 3, 4,
491
+ // 5, 5!, 7 is a renumbering that missed the 5! that was meant to be 6.
492
+ const numbers = [...byGroup.keys()].sort((a, b) => a - b);
493
+ for (let i = 1; i < numbers.length; i++) {
494
+ if (numbers[i] - numbers[i - 1] < 2)
495
+ continue;
496
+ const missing = numbers[i - 1] + 1 === numbers[i] - 1 ? `${numbers[i - 1] + 1}` : `${numbers[i - 1] + 1} to ${numbers[i] - 1}`;
497
+ const shared = [...byGroup.entries()].filter(([, s]) => s.length > 1).map(([g]) => g);
498
+ warnings.push({
499
+ step: null,
500
+ message: `step numbers go from ${numbers[i - 1]} to ${numbers[i]}; nothing is numbered ${missing}`,
501
+ detail: "The same number means \"run together\", so a gap usually means a step kept an old number " +
502
+ "after a renumbering" +
503
+ (shared.length ? ` — ${shared.map((g) => `number ${g}`).join(", ")} has more than one step; check whether one of them belongs in the gap.` : "."),
504
+ });
283
505
  }
284
506
  return warnings;
285
507
  }