@foldrun/core 2.3.0 → 2.5.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 (135) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +1 -1
  4. package/dist/src/agent-tools.js +1 -1
  5. package/dist/src/agents-md.js +1 -1
  6. package/dist/src/alerts.d.ts +60 -0
  7. package/dist/src/alerts.js +161 -0
  8. package/dist/src/browser-pod.js +4 -0
  9. package/dist/src/capture.d.ts +23 -0
  10. package/dist/src/capture.js +80 -0
  11. package/dist/src/compare.d.ts +57 -0
  12. package/dist/src/compare.js +116 -0
  13. package/dist/src/completions.js +1 -0
  14. package/dist/src/deploy.d.ts +36 -1
  15. package/dist/src/deploy.js +170 -11
  16. package/dist/src/egress.d.ts +16 -1
  17. package/dist/src/evals.d.ts +5 -1
  18. package/dist/src/evals.js +26 -18
  19. package/dist/src/frontmatter-fields.js +9 -0
  20. package/dist/src/frontmatter-schema.js +3 -0
  21. package/dist/src/history.d.ts +4 -0
  22. package/dist/src/history.js +15 -0
  23. package/dist/src/hooks.js +1 -1
  24. package/dist/src/insights.d.ts +30 -0
  25. package/dist/src/insights.js +152 -0
  26. package/dist/src/issues.d.ts +140 -0
  27. package/dist/src/issues.js +302 -0
  28. package/dist/src/library.js +1 -1
  29. package/dist/src/limits.d.ts +8 -0
  30. package/dist/src/limits.js +36 -5
  31. package/dist/src/linkable.js +1 -1
  32. package/dist/src/matter.d.ts +8 -0
  33. package/dist/src/matter.js +28 -0
  34. package/dist/src/model-credential.d.ts +14 -0
  35. package/dist/src/model-credential.js +40 -0
  36. package/dist/src/model-env.d.ts +14 -2
  37. package/dist/src/model-env.js +34 -9
  38. package/dist/src/network.d.ts +12 -0
  39. package/dist/src/network.js +25 -1
  40. package/dist/src/notify.d.ts +16 -0
  41. package/dist/src/notify.js +17 -7
  42. package/dist/src/okf.js +1 -1
  43. package/dist/src/online-checks.d.ts +76 -0
  44. package/dist/src/online-checks.js +432 -0
  45. package/dist/src/operator.d.ts +277 -0
  46. package/dist/src/operator.js +176 -2
  47. package/dist/src/otel-config.d.ts +18 -0
  48. package/dist/src/otel-config.js +69 -0
  49. package/dist/src/otel.d.ts +38 -0
  50. package/dist/src/otel.js +85 -0
  51. package/dist/src/outcome.d.ts +40 -0
  52. package/dist/src/outcome.js +77 -0
  53. package/dist/src/package.js +1 -1
  54. package/dist/src/platform.d.ts +1 -1
  55. package/dist/src/prompt-layers.d.ts +30 -0
  56. package/dist/src/prompt-layers.js +77 -0
  57. package/dist/src/provider-health.js +1 -1
  58. package/dist/src/providers.d.ts +5 -0
  59. package/dist/src/providers.js +2 -2
  60. package/dist/src/run-container.d.ts +16 -2
  61. package/dist/src/run-container.js +57 -5
  62. package/dist/src/runner.d.ts +53 -2
  63. package/dist/src/runner.js +356 -70
  64. package/dist/src/runs-filter.d.ts +48 -0
  65. package/dist/src/runs-filter.js +73 -0
  66. package/dist/src/scan.d.ts +19 -1
  67. package/dist/src/scan.js +60 -7
  68. package/dist/src/starter.js +4 -2
  69. package/dist/src/step-exec.d.ts +29 -1
  70. package/dist/src/step-exec.js +106 -6
  71. package/dist/src/store.d.ts +130 -3
  72. package/dist/src/store.js +145 -6
  73. package/dist/src/tokens.d.ts +38 -0
  74. package/dist/src/tokens.js +57 -0
  75. package/dist/src/tool-names.js +1 -1
  76. package/dist/src/tool-programs.js +1 -1
  77. package/dist/src/trace-tree.d.ts +40 -0
  78. package/dist/src/trace-tree.js +156 -0
  79. package/dist/src/translator.d.ts +8 -0
  80. package/dist/src/translator.js +7 -2
  81. package/dist/src/trigger-log.d.ts +1 -1
  82. package/dist/src/workspace-check.d.ts +18 -0
  83. package/dist/src/workspace-check.js +34 -0
  84. package/package.json +2 -2
  85. package/src/agent-activity.ts +32 -0
  86. package/src/agent-import.ts +1 -1
  87. package/src/agent-tools.ts +1 -1
  88. package/src/agents-md.ts +1 -1
  89. package/src/alerts.ts +190 -0
  90. package/src/browser-pod.ts +3 -0
  91. package/src/capture.ts +88 -0
  92. package/src/compare.ts +146 -0
  93. package/src/completions.ts +1 -0
  94. package/src/deploy.ts +184 -10
  95. package/src/egress.ts +12 -2
  96. package/src/evals.ts +26 -18
  97. package/src/frontmatter-fields.ts +9 -0
  98. package/src/frontmatter-schema.ts +3 -0
  99. package/src/history.ts +13 -0
  100. package/src/hooks.ts +1 -1
  101. package/src/insights.ts +174 -0
  102. package/src/issues.ts +396 -0
  103. package/src/library.ts +1 -1
  104. package/src/limits.ts +38 -5
  105. package/src/linkable.ts +1 -1
  106. package/src/matter.ts +33 -0
  107. package/src/model-credential.ts +45 -0
  108. package/src/model-env.ts +42 -10
  109. package/src/network.ts +25 -1
  110. package/src/notify.ts +22 -7
  111. package/src/okf.ts +1 -1
  112. package/src/online-checks.ts +450 -0
  113. package/src/operator.ts +213 -3
  114. package/src/otel-config.ts +67 -0
  115. package/src/otel.ts +100 -0
  116. package/src/outcome.ts +90 -0
  117. package/src/package.ts +1 -1
  118. package/src/platform.ts +1 -1
  119. package/src/prompt-layers.ts +90 -0
  120. package/src/provider-health.ts +1 -1
  121. package/src/providers.ts +7 -2
  122. package/src/run-container.ts +66 -7
  123. package/src/runner.ts +363 -46
  124. package/src/runs-filter.ts +88 -0
  125. package/src/scan.ts +68 -7
  126. package/src/starter.ts +4 -2
  127. package/src/step-exec.ts +147 -9
  128. package/src/store.ts +254 -8
  129. package/src/tokens.ts +68 -0
  130. package/src/tool-names.ts +1 -1
  131. package/src/tool-programs.ts +1 -1
  132. package/src/trace-tree.ts +187 -0
  133. package/src/translator.ts +13 -2
  134. package/src/trigger-log.ts +1 -0
  135. package/src/workspace-check.ts +41 -0
package/src/compare.ts ADDED
@@ -0,0 +1,146 @@
1
+ // Two runs of one flow side by side (FOL-72 step 6): "why did this get
2
+ // worse" read off the records — which files changed between the revisions
3
+ // they ran, and step by step what differs: status, the instruction, the
4
+ // model, time, tokens, model calls, the tools it used and the errors it hit.
5
+ // Pure; the caller hands in the changed files (history.ts changedPathsBetween).
6
+
7
+ import type { RunRecord, StepRecord } from "./store.ts";
8
+
9
+ export interface StepSide {
10
+ status: string;
11
+ instruction: string;
12
+ model?: string;
13
+ secs: number | null;
14
+ tokensIn: number;
15
+ tokensOut: number;
16
+ modelCalls: number;
17
+ /** Tool name → how many calls. */
18
+ tools: Record<string, number>;
19
+ toolErrors: number;
20
+ /** The first errors it wrote, each one line. */
21
+ errors: string[];
22
+ /** The first line of what it concluded. */
23
+ said: string | null;
24
+ }
25
+
26
+ export interface StepDiff {
27
+ /** 1-based, as the trace numbers steps. */
28
+ step: number;
29
+ agent: string;
30
+ base: StepSide | null;
31
+ run: StepSide | null;
32
+ /** What differs, in words, most telling first; [] when nothing does. */
33
+ changes: string[];
34
+ }
35
+
36
+ export interface RunComparison {
37
+ base: { id: string; status: string; startedAt: string; revision: string | null };
38
+ run: { id: string; status: string; startedAt: string; revision: string | null };
39
+ revisionChanged: boolean;
40
+ /** Files changed between the two revisions; null when that cannot be said. */
41
+ changedFiles: string[] | null;
42
+ steps: StepDiff[];
43
+ }
44
+
45
+ const firstLine = (s: string | null | undefined, n = 200) => {
46
+ const l = (s ?? "").split("\n").map((x) => x.trim()).find(Boolean);
47
+ return l ? l.slice(0, n) : null;
48
+ };
49
+
50
+ function side(s: StepRecord): StepSide {
51
+ const tools: Record<string, number> = {};
52
+ let toolErrors = 0;
53
+ for (const e of s.events) {
54
+ if (e.type !== "tool") continue;
55
+ // A pair is one call: count the call, and its completion's error.
56
+ if (e.ms == null) tools[e.text] = (tools[e.text] ?? 0) + 1;
57
+ else if (e.err) toolErrors++;
58
+ }
59
+ const start = s.startedAt ? Date.parse(s.startedAt) : NaN;
60
+ const end = s.finishedAt ? Date.parse(s.finishedAt) : NaN;
61
+ return {
62
+ status: s.status,
63
+ instruction: s.instruction,
64
+ ...(s.model ? { model: s.model } : {}),
65
+ secs: Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1000)) : null,
66
+ tokensIn: s.tokens?.input ?? 0,
67
+ tokensOut: s.tokens?.output ?? 0,
68
+ modelCalls: s.calls?.length ?? s.turns ?? 0,
69
+ tools,
70
+ toolErrors,
71
+ errors: s.events.filter((e) => e.type === "error").slice(0, 3).map((e) => firstLine(e.text) ?? ""),
72
+ said: firstLine(s.conclusion ?? s.result),
73
+ };
74
+ }
75
+
76
+ const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
77
+
78
+ /** Much more or much less: by a quarter and by a real amount. */
79
+ const moved = (a: number, b: number, floor: number) => Math.abs(b - a) >= floor && (a === 0 ? b > 0 : Math.abs(b - a) / a >= 0.25);
80
+
81
+ function changes(a: StepSide | null, b: StepSide | null): string[] {
82
+ if (!a && b) return ["new step: not in the base run"];
83
+ if (a && !b) return ["not in this run"];
84
+ if (!a || !b) return [];
85
+ const out: string[] = [];
86
+ if (a.status !== b.status) out.push(`status: ${a.status} → ${b.status}`);
87
+ const newErrors = b.errors.filter((e) => !a.errors.includes(e));
88
+ if (newErrors.length) out.push(`new error: ${newErrors[0]}`);
89
+ if (a.instruction.trim() !== b.instruction.trim()) out.push("instruction changed");
90
+ if ((a.model ?? "") !== (b.model ?? "")) out.push(`model: ${a.model ?? "default"} → ${b.model ?? "default"}`);
91
+ if (b.toolErrors > a.toolErrors) out.push(`tool errors: ${a.toolErrors} → ${b.toolErrors}`);
92
+ const names = new Set([...Object.keys(a.tools), ...Object.keys(b.tools)]);
93
+ const toolDiff = [...names]
94
+ .map((n) => ({ n, d: (b.tools[n] ?? 0) - (a.tools[n] ?? 0) }))
95
+ .filter((x) => x.d !== 0)
96
+ .sort((x, y) => Math.abs(y.d) - Math.abs(x.d))
97
+ .slice(0, 4)
98
+ .map((x) => `${x.d > 0 ? "+" : "−"}${x.n} ×${Math.abs(x.d)}`);
99
+ if (toolDiff.length) out.push(`tools: ${toolDiff.join(", ")}`);
100
+ if (a.secs !== null && b.secs !== null && moved(a.secs, b.secs, 10)) out.push(`took ${a.secs}s → ${b.secs}s`);
101
+ if (moved(a.tokensIn, b.tokensIn, 5000)) out.push(`tokens in ${k(a.tokensIn)} → ${k(b.tokensIn)}`);
102
+ if (moved(a.modelCalls, b.modelCalls, 3)) out.push(`model calls ${a.modelCalls} → ${b.modelCalls}`);
103
+ if (a.said !== b.said && b.said) out.push(`said: ${b.said}`);
104
+ return out;
105
+ }
106
+
107
+ /** Steps lined up: the flow's own steps by position where the agents agree,
108
+ * fan-out instances by agent and order. */
109
+ function align(a: StepRecord[], b: StepRecord[]): [StepRecord | null, StepRecord | null][] {
110
+ const pairs: [StepRecord | null, StepRecord | null][] = [];
111
+ const used = new Set<number>();
112
+ b.forEach((s, i) => {
113
+ let j = a[i] && a[i]!.agent === s.agent && !used.has(i) ? i : -1;
114
+ if (j < 0) j = a.findIndex((x, m) => !used.has(m) && x.agent === s.agent);
115
+ if (j >= 0) used.add(j);
116
+ pairs.push([j >= 0 ? a[j]! : null, s]);
117
+ });
118
+ a.forEach((s, m) => !used.has(m) && pairs.push([s, null]));
119
+ return pairs;
120
+ }
121
+
122
+ export function compareRuns(base: RunRecord, run: RunRecord, changedFiles: string[] | null = null): RunComparison {
123
+ const head = (r: RunRecord) => ({ id: r.id, status: r.status, startedAt: r.startedAt, revision: r.revision ?? null });
124
+ return {
125
+ base: head(base),
126
+ run: head(run),
127
+ revisionChanged: (base.revision ?? null) !== (run.revision ?? null),
128
+ changedFiles,
129
+ steps: align(base.steps, run.steps).map(([a, b], i) => {
130
+ const sa = a ? side(a) : null;
131
+ const sb = b ? side(b) : null;
132
+ return { step: b ? run.steps.indexOf(b) + 1 : i + 1, agent: (b ?? a)!.agent, base: sa, run: sb, changes: changes(sa, sb) };
133
+ }),
134
+ };
135
+ }
136
+
137
+ /** The run to compare a run with when none is named: the newest earlier run
138
+ * of the same flow that finished well (completed, not BLOCKED or BAD), or
139
+ * null. `outcomeOf` is the caller's, so this stays free of the verdict reader. */
140
+ export function lastGoodBefore<R extends { id: string; flow: string; startedAt: string }>(runs: R[], run: { id: string; flow: string; startedAt: string }, good: (r: R) => boolean): R | null {
141
+ return (
142
+ runs
143
+ .filter((r) => r.flow === run.flow && r.id !== run.id && r.startedAt < run.startedAt && good(r))
144
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0] ?? null
145
+ );
146
+ }
@@ -77,6 +77,7 @@ const TOOL_GROUPS: Completion[] = [
77
77
  { label: "code", hint: "Bash — runs anything in the sandbox" },
78
78
  { label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
79
79
  { label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
80
+ { label: "peers", hint: "peers_list + peers_message — the colleagues running beside it in its parallel group, and a message to one of them" },
80
81
  { label: "install", hint: "install apt, pip or npm packages mid-step — every agent has it; disallowedTools: [install] removes it" },
81
82
  ];
82
83
 
package/src/deploy.ts CHANGED
@@ -33,20 +33,26 @@ import {
33
33
  assertSafeName,
34
34
  type DeployFile,
35
35
  notifyWorkspaceChanged,
36
+ accountDir,
37
+ frontmatterErrorText,
36
38
  } from "./store.ts";
39
+ import { readAgentsMdKeys } from "./agents-md.ts";
40
+ import { libraryTools } from "./library.ts";
37
41
  import { lintFlow } from "./flow-lint.ts";
38
42
  import { refNames } from "./refs.ts";
39
- import matter from "gray-matter";
43
+ import matter from "./matter.ts";
40
44
  import { conformanceIssues } from "./okf.ts";
41
45
  import { timezoneProblem } from "./clock.ts";
42
46
  import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
43
47
  import { readLimits } from "./limits.ts";
48
+ import { parseAlerts } from "./alerts.ts";
49
+ import { parseTelemetry } from "./otel-config.ts";
44
50
  import { networkProblems } from "./network.ts";
45
51
  import { actionProblems } from "./web-actions.ts";
46
52
  import { languageProblem } from "./language.ts";
47
53
  import { localeProblems } from "./locale.ts";
48
54
  import { entriesNoFollow } from "./paths.ts";
49
- import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, type ScanFinding, type ScanOptions } from "./scan.ts";
55
+ import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, scanRule, type ScanFinding, type ScanOptions } from "./scan.ts";
50
56
 
51
57
  /** Directories a deploy never reads out of a source tree. */
52
58
  // `.cache` is what a run's tools leave in an agent's folder (HOME is that
@@ -121,9 +127,60 @@ export interface DeployResult extends DeployPlan {
121
127
  // agree on what counts as an agent.
122
128
  export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"] as const;
123
129
 
124
- export function readTree(dir: string): DeployFile[] {
130
+ /** What agents write as they run, not what anyone authored: state/ and
131
+ * memory/, the workspace's or one agent's. */
132
+ const DATA_PATH = /^(?:agents\/[^/]+\/)?(?:state|memory)\//;
133
+
134
+ /** Bounds for a reader that only looks at the text (the checks, the
135
+ * assistant): on 20 Oct 2026 the daily rescan read one desk's 1.2 GB of
136
+ * state/ into a 2 GB heap and crash-looped the worker. A deploy sets none. */
137
+ export interface ReadTreeOptions {
138
+ /** Data (DATA_PATH) larger than this is left out, unread. */
139
+ maxDataFileBytes?: number;
140
+ /** Data past this many bytes in all, or this many files, is left out. */
141
+ maxDataBytes?: number;
142
+ maxDataFiles?: number;
143
+ /** Everything else past this many bytes in all: readTree throws
144
+ * TreeTooLarge rather than return a part of the definitions, which a
145
+ * check would read as missing agents and flows. */
146
+ maxSourceBytes?: number;
147
+ /** Each data file left out, and its size. */
148
+ onSkip?: (path: string, bytes: number) => void;
149
+ }
150
+
151
+ export class TreeTooLarge extends Error {
152
+ /** What an HTTP route answers with it. */
153
+ readonly status = 413;
154
+ }
155
+
156
+ export function readTree(dir: string, opts: ReadTreeOptions = {}): DeployFile[] {
125
157
  const root = path.resolve(dir);
126
158
  const out: DeployFile[] = [];
159
+ let dataBytes = 0;
160
+ let dataFiles = 0;
161
+ let sourceBytes = 0;
162
+ /** May this file be read? Data over its budget is left out; source over
163
+ * its budget ends the read. */
164
+ const admit = (rel: string, bytes: number): boolean => {
165
+ if (DATA_PATH.test(rel)) {
166
+ const over =
167
+ (opts.maxDataFileBytes !== undefined && bytes > opts.maxDataFileBytes) ||
168
+ (opts.maxDataBytes !== undefined && dataBytes + bytes > opts.maxDataBytes) ||
169
+ (opts.maxDataFiles !== undefined && dataFiles >= opts.maxDataFiles);
170
+ if (over) {
171
+ opts.onSkip?.(rel, bytes);
172
+ return false;
173
+ }
174
+ dataBytes += bytes;
175
+ dataFiles++;
176
+ return true;
177
+ }
178
+ sourceBytes += bytes;
179
+ if (opts.maxSourceBytes !== undefined && sourceBytes > opts.maxSourceBytes) {
180
+ throw new TreeTooLarge(`the workspace's files other than state/ and memory/ are over ${Math.round(opts.maxSourceBytes / 1048576)} MiB — not read`);
181
+ }
182
+ return true;
183
+ };
127
184
 
128
185
  const walk = (abs: string, rel: string) => {
129
186
  let entries: string[];
@@ -148,6 +205,7 @@ export function readTree(dir: string): DeployFile[] {
148
205
  if (stat.isDirectory()) {
149
206
  walk(full, next);
150
207
  } else if (next === "AGENTS.md" || next === "project.md" || IN_WORKSPACE_DIR.test(next)) {
208
+ if (!admit(next, stat.size)) continue;
151
209
  out.push({ path: next, content: fs.readFileSync(full, "utf8") });
152
210
  }
153
211
  }
@@ -188,9 +246,12 @@ export function readTree(dir: string): DeployFile[] {
188
246
  if (nativeAgent.has(target) || out.some((fEntry) => fEntry.path === target)) continue;
189
247
  const full = path.join(root, importDir, entry);
190
248
  try {
191
- if (fs.lstatSync(full).isSymbolicLink()) continue;
249
+ const st = fs.lstatSync(full);
250
+ if (st.isSymbolicLink()) continue;
251
+ if (!admit(target, st.size)) continue;
192
252
  out.push({ path: target, content: fs.readFileSync(full, "utf8") });
193
- } catch {
253
+ } catch (err) {
254
+ if (err instanceof TreeTooLarge) throw err;
194
255
  // an unreadable import is skipped, not fatal
195
256
  }
196
257
  }
@@ -238,6 +299,90 @@ export function unopenedFrontmatter(raw: string): string | null {
238
299
  * advice (an agent that could leak, an unpinned package) is for `foldrun
239
300
  * check` and the Checks page, not for every deploy's output.
240
301
  */
302
+ /**
303
+ * What `deploy_block:` in AGENTS.md refuses (FOL-58). `none`, the default:
304
+ * the scanner's findings are warnings and the deploy goes ahead, as it always
305
+ * did. `secrets`: a key or password written into a file is refused.
306
+ * `security`: every security finding is — secrets, hidden or injected text,
307
+ * the lethal trifecta, pipe-to-shell, an unpinned MCP server, a step that
308
+ * acts outside with no check or approval. A
309
+ * `foldrun-ignore` with a reason still accepts one.
310
+ */
311
+ export const DEPLOY_BLOCK_MODES = ["none", "secrets", "security"] as const;
312
+ export type DeployBlockMode = (typeof DEPLOY_BLOCK_MODES)[number];
313
+
314
+ const blockMode = (v: unknown): DeployBlockMode | null =>
315
+ typeof v === "string" && (DEPLOY_BLOCK_MODES as readonly string[]).includes(v.trim()) ? (v.trim() as DeployBlockMode) : null;
316
+
317
+ /** The workspace's own AGENTS.md, else the account's, else none. */
318
+ export function deployBlockMode(files: DeployFile[], accountKeys: Record<string, unknown>): DeployBlockMode {
319
+ // project.md is the older name readAgentsMdKeys still reads (Codex review).
320
+ const own = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
321
+ let fromWorkspace: DeployBlockMode | null = null;
322
+ if (own) {
323
+ try {
324
+ fromWorkspace = blockMode((matter(own.content).data as Record<string, unknown>).deploy_block);
325
+ } catch {
326
+ // an AGENTS.md that does not parse is reported elsewhere
327
+ }
328
+ }
329
+ return fromWorkspace ?? blockMode(accountKeys.deploy_block) ?? "none";
330
+ }
331
+
332
+ function blockersFor(tenant: string, given: DeployFile[]): DeployIssue[] {
333
+ // The tree as it will be written: `./AGENTS.md` is saved as AGENTS.md, so
334
+ // it is read as that here too (Codex review).
335
+ // Two entries for one path: the save keeps the last, so this does too.
336
+ const byPath = new Map<string, DeployFile>();
337
+ for (const f of given) {
338
+ // path.normalize, as saveWorkspace writes it (a backslash is a filename
339
+ // character here, not a separator — Codex review).
340
+ const p = path.normalize(f.path);
341
+ byPath.set(p, { ...f, path: p });
342
+ }
343
+ const files = [...byPath.values()];
344
+ const account = readAgentsMdKeys(accountDir(tenant));
345
+ const mode = deployBlockMode(files, account);
346
+ if (mode === "none") return [];
347
+ // Nearest wins: a workspace tool of the same name replaces the library's,
348
+ // and the scanner adds the workspace's own outward tools itself. Named as
349
+ // the runtime names them — the definition's name:, else the file's.
350
+ const ownTools = new Set<string>();
351
+ for (const f of files) {
352
+ const m = /^tools\/([^/]+?)(?:\/tool)?\.md$/.exec(f.path);
353
+ if (!m) continue;
354
+ let data: Record<string, unknown> = {};
355
+ try {
356
+ data = matter(f.content).data as Record<string, unknown>;
357
+ } catch {
358
+ continue; // unreadable: the runtime skips it and keeps the library's
359
+ }
360
+ // Only a definition the runtime accepts replaces the library's; an
361
+ // invalid one is skipped there, and the library tool stays (Codex review).
362
+ const def = parseToolDef(data, m[1], f.content);
363
+ if (def) ownTools.add(def.name ?? m[1]);
364
+ }
365
+ const outwardTools = Object.entries(libraryTools(tenant))
366
+ .filter(([n, d]) => (d as { outward?: boolean }).outward === true && !ownTools.has(n))
367
+ .map(([n]) => n);
368
+ return deployBlockers(files, mode, { accountNetwork: account.network, outwardTools });
369
+ }
370
+
371
+ /** The findings `mode` turns into refusals, as deploy issues. */
372
+ export function deployBlockers(files: DeployFile[], mode: DeployBlockMode, options: ScanOptions = {}): DeployIssue[] {
373
+ if (mode === "none") return [];
374
+ // Every finding checkFiles reports — the scanner's and the structural
375
+ // ones, flow/outward-ungated among them (Codex review) — with the same
376
+ // ignores honoured, kept by its rule's category.
377
+ const categories = new Set(mode === "secrets" ? ["secret"] : ["secret", "content", "security", "supply-chain"]);
378
+ return checkFiles(files, options)
379
+ .filter((f) => f.level !== "info" && categories.has(scanRule(f.rule)?.category ?? ""))
380
+ .map((f) => ({
381
+ where: `${f.file}${f.line ? `:${f.line}` : ""}`,
382
+ message: `${f.message} [${f.rule}] — refused: deploy_block: ${mode} in AGENTS.md`,
383
+ }));
384
+ }
385
+
241
386
  export function deployWarnings(files: DeployFile[]): DeployIssue[] {
242
387
  const scanned = scanFiles(files, { only: ["secret", "content"] })
243
388
  .filter((f) => f.level !== "info")
@@ -261,19 +406,20 @@ export function checkFiles(files: DeployFile[], options: ScanOptions = {}): Scan
261
406
  };
262
407
  const own = [
263
408
  ...deployIssues(files).map((i) => finding(i, "error")),
264
- ...structuralWarnings(files).map((i) => finding(i, "warn", i.kind)),
409
+ ...structuralWarnings(files, options.outwardTools).map((i) => finding(i, "warn", i.kind)),
265
410
  ];
266
411
  return [...filterIgnored(own, files), ...scanFiles(files, options)].sort(
267
412
  (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule),
268
413
  );
269
414
  }
270
415
 
271
- function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string })[] {
416
+ function structuralWarnings(files: DeployFile[], extraOutward: Iterable<string> = []): (DeployIssue & { kind?: string })[] {
272
417
  const front = (content: string): Record<string, unknown> => {
273
418
  try { return matter(content).data as Record<string, unknown>; } catch { return {}; }
274
419
  };
275
- // Workspace tools that change something outside the workspace.
276
- const outwardTools = new Set<string>();
420
+ // Tools that change something outside the workspace: the workspace's own,
421
+ // and the library's the caller resolved (nearest wins already applied).
422
+ const outwardTools = new Set<string>(extraOutward);
277
423
  for (const f of files) {
278
424
  const m = f.path.match(/^tools\/([^/]+?)(?:\/tool)?\.md$/);
279
425
  if (!m) continue;
@@ -282,6 +428,8 @@ function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string
282
428
  // `outward: true` is read off the frontmatter, the same test the runtime
283
429
  // and the CLI use; parseToolDef does not fold it onto the def.
284
430
  if (def && data.outward === true) outwardTools.add(def.name ?? m[1]);
431
+ // A workspace tool that is not outward replaces a library one that was.
432
+ else if (def) outwardTools.delete(def.name ?? m[1]);
285
433
  }
286
434
  // Agents that grant one of them.
287
435
  const outwardAgents: string[] = [];
@@ -342,6 +490,24 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
342
490
  if (m) agents.add(m[1]);
343
491
  }
344
492
 
493
+ // Frontmatter whose YAML does not parse is a file the runtime cannot read:
494
+ // an agent with no tools and the default model, a flow that never fires,
495
+ // a tool that is skipped. Refused where it was written. Until 20 Oct 2026
496
+ // nothing said so — gray-matter's cache let a second parse pass — and one
497
+ // such agent file kept the worker from booting for every account.
498
+ // Definition files only: a skill is read leniently (an unquoted colon is
499
+ // quoted and retried, runner.ts safeMatter), and the markdown beside a
500
+ // definition — references, a tool's README — is prose, not frontmatter.
501
+ for (const f of files) {
502
+ if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md|flows\/[^/]+\.md|tools\/[^/]+\.md|tools\/[^/]+\/tool\.md|evals\/[^/]+\.md|hooks\/[^/]+\/hook\.md|\.claude\/agents\/[^/]+\.md)$/.test(f.path)) continue;
503
+ if (!/^\uFEFF?---\r?\n/.test(f.content)) continue;
504
+ try {
505
+ matter(f.content);
506
+ } catch (err) {
507
+ at(f.path, frontmatterErrorText(err));
508
+ }
509
+ }
510
+
345
511
  // `timezone:` is read at four levels and a value nothing can parse falls
346
512
  // silently through to the level above — so it is refused where it was
347
513
  // written, not discovered as a date that is a day out.
@@ -370,6 +536,11 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
370
536
  // `network:` that cannot be read would leave the step on the level
371
537
  // above it — wider or narrower than the author wrote.
372
538
  for (const w of networkProblems(front.network)) at(f.path, w);
539
+ // `alerts:` that cannot be read would never alert (FOL-72).
540
+ for (const w of parseAlerts(front.alerts).problems) at(f.path, w);
541
+ // `telemetry:` that cannot be read would never send — and a token
542
+ // written into it would ship in the file.
543
+ for (const w of parseTelemetry(front.telemetry).problems) at(f.path, w);
373
544
  const lang = languageProblem(front.language);
374
545
  if (lang) at(f.path, lang);
375
546
  for (const w of localeProblems(front)) at(f.path, w);
@@ -509,7 +680,10 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
509
680
  added: added.sort(),
510
681
  updated: updated.sort(),
511
682
  removed: removed.sort(),
512
- issues: deployIssues(files),
683
+ // deploy_block: (FOL-58) turns the scanner's findings into refusals —
684
+ // scanned with what the Checks page passes too: the account's network:
685
+ // and the library's outward tools (Codex review).
686
+ issues: [...deployIssues(files), ...blockersFor(tenant, files)],
513
687
  warnings: deployWarnings(files),
514
688
  blockedBy: runsInFlight(tenant, workspace),
515
689
  };
package/src/egress.ts CHANGED
@@ -43,6 +43,7 @@
43
43
  // platform's (it needs a long-lived process and a Service); the pod side is
44
44
  // here in core, because it is what the runner image runs.
45
45
 
46
+ import type { PeerInfo } from "./operator.ts";
46
47
  import { trimSlashes } from "./paths.ts";
47
48
  import type { TestEffect } from "./test-mode.ts";
48
49
  import type { NetworkLevel } from "./network.ts";
@@ -150,13 +151,22 @@ export type EgressLogLine = string | { text: string; effect: TestEffect };
150
151
  * means "no proxy here" and the runner falls back to materialising, exactly
151
152
  * as it always did.
152
153
  */
154
+
153
155
  export interface EgressHooks {
154
156
  /** `test` — the run is a test run: the proxy applies the outward-write
155
157
  * policy in test-mode.ts to every request on this lease. Carried on the
156
158
  * lease, not the request, because the pod is the party not trusted to
157
159
  * say so. */
158
- /** `step` — the step's index in run.steps; keys that step's operator inbox. */
159
- lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number }): Promise<EgressLease | null>;
160
+ /** `step` — the step's index in run.steps; keys that step's operator inbox.
161
+ * `agent` and `peers` — this step's agent and the other steps of its
162
+ * parallel group (tools: [peers]): the proxy serves the roster and
163
+ * addresses a colleague's message to the right inbox. */
164
+ lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number; agent?: string; peers?: PeerInfo[] }): Promise<EgressLease | null>;
165
+ /** The step is over — every attempt, however it ended (tools: [peers]).
166
+ * A lease comes and goes per attempt, so it cannot say this: a sibling
167
+ * between retries, or waiting for a slot, holds none and is not done.
168
+ * Optional: a platform without it keeps every sibling reachable. */
169
+ stepEnded?(args: { tenant: string; runId: string; step: number }): Promise<void>;
160
170
  }
161
171
 
162
172
  /** Strip a header set of the proxy's own and hop-by-hop headers. */
package/src/evals.ts CHANGED
@@ -31,18 +31,20 @@
31
31
  // they pass — no point paying a model to grade output already known to be
32
32
  // wrong.
33
33
 
34
- import { accountModelEnv } from "./model-env.ts";
34
+ import { accountModelEnv, withModelEnv } from "./model-env.ts";
35
+ import type { TranslatorSpec } from "./translator.ts";
35
36
  import { sdkEnv } from "./step-exec.ts";
36
37
  import fs from "node:fs";
37
38
  import path from "node:path";
38
39
  import { deployedCommit } from "./deploy.ts";
39
40
  import { latestRevisionId } from "./history.ts";
40
- import matter from "gray-matter";
41
+ import matter from "./matter.ts";
41
42
  import { spawn } from "node:child_process";
42
43
  import { query } from "@anthropic-ai/claude-agent-sdk";
43
44
  import {
44
45
  workspaceDir,
45
46
  readRun,
47
+ runVerdict,
46
48
  writeWorkspaceFile,
47
49
  resolveModel,
48
50
  resolveEffort,
@@ -265,16 +267,26 @@ export function promoteRunToEval(
265
267
  tenant: string,
266
268
  workspace: string,
267
269
  runId: string,
268
- opts: { evalName?: string; caseName?: string; expect?: string[]; by?: string } = {},
270
+ /** `should`: what a run that went wrong should have done, in a sentence —
271
+ * the judge's claim for the case (FOL-72 step 10). */
272
+ opts: { evalName?: string; caseName?: string; expect?: string[]; should?: string; by?: string } = {},
269
273
  ): { file: string; created: boolean; caseName: string } {
270
274
  const run = readRun(tenant, workspace, runId);
271
275
  if (!run) throw refuse(`run ${runId} not found`, 404);
272
- if (run.status !== "completed") {
273
- throw refuse(`only a completed run can become an eval — this one is ${run.status}`, 409);
276
+ if (run.status !== "completed" && run.status !== "failed") {
277
+ throw refuse(`only a finished run can become an eval — this one is ${run.status}`, 409);
278
+ }
279
+ // A run that went wrong is the test worth having most — but its own answer
280
+ // is the wrong one to hold the next run to, so it needs to be told what
281
+ // was right (FOL-72 step 10).
282
+ const should = oneLine(opts.should ?? "");
283
+ const wentWrong = run.status === "failed" || ["BAD", "BLOCKED"].includes(runVerdict(run) ?? "");
284
+ if (wentWrong && !should && !(opts.expect && opts.expect.length)) {
285
+ throw refuse("this run went wrong, so its answer is not the one to keep — say what it should have done (`should`, a sentence for the judge) or give `expect` lines", 409);
274
286
  }
275
287
  const produced = run.steps.filter((s) => (s.result ?? "").trim());
276
288
  const last = produced[produced.length - 1];
277
- if (!last) throw refuse("this run produced no result to hold the next one to", 409);
289
+ if (!last && !should && !(opts.expect && opts.expect.length)) throw refuse("this run produced no result to hold the next one to", 409);
278
290
  if (run.steps.length === 0 || !run.steps[0].instruction?.trim()) {
279
291
  throw refuse("this run has no task on record", 409);
280
292
  }
@@ -309,8 +321,10 @@ export function promoteRunToEval(
309
321
  }
310
322
  return `${m[1]}: ${m[2]}`;
311
323
  });
324
+ } else if (should) {
325
+ expect = [`judge: ${should.slice(0, 400)}`];
312
326
  } else {
313
- const conclusion = oneLine(run.summary?.trim() || (last.result ?? "")).slice(0, 200);
327
+ const conclusion = oneLine(run.summary?.trim() || (last!.result ?? "")).slice(0, 200);
314
328
  expect = [`judge: reaches the same conclusion as the recorded run: ${conclusion}`];
315
329
  }
316
330
 
@@ -515,7 +529,7 @@ async function checkAssertion(
515
529
  agentDir: string,
516
530
  model: string,
517
531
  effort: Effort | null,
518
- judgeEnv: () => Record<string, string | undefined> = () => sdkEnv(undefined),
532
+ judgeEnv: () => { env: Record<string, string | undefined>; translator?: TranslatorSpec | null } = () => ({ env: sdkEnv(undefined) }),
519
533
  ): Promise<AssertionResult> {
520
534
  const hay = output.toLowerCase();
521
535
  switch (assertion.type) {
@@ -550,13 +564,13 @@ async function checkAssertion(
550
564
  case "judge": {
551
565
  // The judge runs on the account's own model key, never quietly on the
552
566
  // platform's: a customer's eval is not the operator's bill.
553
- let env: Record<string, string | undefined>;
567
+ let m: ReturnType<typeof judgeEnv>;
554
568
  try {
555
- env = judgeEnv();
569
+ m = judgeEnv();
556
570
  } catch (err) {
557
571
  return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
558
572
  }
559
- const verdict = await judge(assertion.value, output, model, effort, env);
573
+ const verdict = await withModelEnv(m, (env) => judge(assertion.value, output, model, effort, env));
560
574
  const passed = /^\s*PASS\b/i.test(verdict);
561
575
  return { assertion, passed, detail: verdict.slice(0, 300) || "judge returned nothing" };
562
576
  }
@@ -639,13 +653,7 @@ export async function runEval(
639
653
  const judges = testCase.expect.filter((a) => a.type === "judge");
640
654
  for (const a of cheap) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
641
655
  if (assertions.every((r) => r.passed)) {
642
- const judgeEnv = () => {
643
- const m = accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
644
- if (m.format !== "anthropic") {
645
- throw new Error(`this account's provider speaks ${m.format}; a judge needs an Anthropic-format provider`);
646
- }
647
- return m.env;
648
- };
656
+ const judgeEnv = () => accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
649
657
  for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
650
658
  } else {
651
659
  for (const a of judges) {
@@ -50,6 +50,7 @@ const LOCALE = { validated: true } as const;
50
50
  const AGENT: FieldSpec[] = [
51
51
  { key: "name", type: "string", help: "kebab-case; the folder is the identity" },
52
52
  { key: "description", type: "string", help: "what it does and when — other agents and people read it" },
53
+ { key: "owner", type: "string", help: "the person accountable for it — an email or a name; else the workspace's, else the account's" },
53
54
  { key: "model", type: "string", help: "fast | default | max, an alias, or a full model id" },
54
55
  { key: "effort", type: "string", values: EFFORT_LEVELS, help: "how long it thinks" },
55
56
  { key: "size", type: "string", values: ["small", "large", "heavy"], help: "the sandbox it rents; anything else becomes large" },
@@ -86,6 +87,7 @@ const AGENTS_MD: FieldSpec[] = [
86
87
  { key: "mdagent_version", type: ["string", "number"], legacy: true, help: "older spelling of foldrun_version" },
87
88
  { key: "name", type: "string", help: "the workspace's name" },
88
89
  { key: "description", type: "string", help: "shown in the workspace list" },
90
+ { key: "owner", type: "string", help: "the person accountable for every agent and flow here that names none" },
89
91
  { key: "provider", type: "object", help: "the model endpoint every agent here uses unless it names its own" },
90
92
  { key: "runtime", type: "object", help: "packages for agents that declare none" },
91
93
  { key: "web", type: "object", validated: true, help: "web.browse defaults" },
@@ -102,18 +104,25 @@ const AGENTS_MD: FieldSpec[] = [
102
104
  { key: "budget", type: ["number", "string"], help: "USD per month, or N/day | N/week | N/month" },
103
105
  { key: "priority", type: "string", values: ["high", "normal", "low"], help: "account: where its runs stand in the queue" },
104
106
  { key: "concurrency", type: ["number", "string"], help: "account: runs at once" },
107
+ { key: "alerts", type: "object", validated: true, help: "thresholds that tell notify: — failure_rate (30%), min_runs, credits_day, run_minutes, stale_days, cost_spike, runs_per_hour, new_host" },
108
+ { key: "capture", type: "string", values: ["full", "metadata"], help: "what runs record of each tool call: full (its input and output, secrets masked) or metadata (name, size and time only)" },
109
+ { key: "network_enforce", type: "boolean", help: "true: a site off a step's network: list is refused, not only named in the trace. The account's or the workspace's true is enough" },
110
+ { key: "telemetry", type: "object", validated: true, help: "send each run as an OpenTelemetry trace — otlp: <https …/v1/traces>, headers: { authorization: ${SECRET} }" },
111
+ { key: "deploy_block", type: "string", values: ["none", "secrets", "security"], help: "refuse a deploy on scanner findings: secrets (a key written in) or security (any security warning)" },
105
112
  { key: "schedule", type: ["string", "object"], validated: true, legacy: true, help: "refused: only a flow has a clock" },
106
113
  ];
107
114
 
108
115
  const FLOW: FieldSpec[] = [
109
116
  { key: "name", type: "string", help: "the flow's name" },
110
117
  { key: "description", type: "string", help: "what it is for" },
118
+ { key: "owner", type: "string", help: "the person accountable for it — an email or a name; else the workspace's, else the account's" },
111
119
  { key: "trigger", type: "string", values: FLOW_TRIGGERS, help: "what starts it; any other value never fires" },
112
120
  { key: "schedule", type: "string", help: "cron, when trigger: schedule" },
113
121
  { key: "timezone", type: "string", validated: true, help: "the zone its schedule reads in" },
114
122
  { key: "model", type: "string", help: "every step's model unless the step says" },
115
123
  { key: "effort", type: "string", values: EFFORT_LEVELS, help: "every step's effort unless the step says" },
116
124
  { key: "overlap", type: "string", validated: true, help: "skip | queue" },
125
+ { key: "issues", type: "string", values: ["stay-open", "auto-close"], help: "what a good run does to this flow's open issues: stay-open (the default — a person resolves them) or auto-close" },
117
126
  { key: "priority", type: "string", validated: true, help: "high | normal | low" },
118
127
  { key: "catchup", type: "string", validated: true, help: "last | none" },
119
128
  { key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
@@ -6,6 +6,7 @@ import { FRONTMATTER, type FieldType, type SchemaKind } from "./frontmatter-fiel
6
6
  import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS } from "./store.ts";
7
7
  import { budgetProblem } from "./budget.ts";
8
8
  import { cronProblem } from "./cron.ts";
9
+ import { modelSecretCollision } from "./model-credential.ts";
9
10
 
10
11
  export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
11
12
 
@@ -31,6 +32,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
31
32
  apis: apisProblem,
32
33
  budget: (v) => budgetProblem(v, ["run"]),
33
34
  ask: (v) => askProblem(v),
35
+ secrets: modelSecretCollision,
34
36
  },
35
37
  "agents-md": {
36
38
  budget: (v) => budgetProblem(v, ["day", "week", "month"]),
@@ -54,6 +56,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
54
56
  transport: (v) => (["http", "script", "mcp"].includes(String(v).toLowerCase()) ? null : `"${String(v)}" is not a transport — http, script or mcp`),
55
57
  methods: methodsProblem,
56
58
  rate: (v) => (parseRate(v) ? null : `"${shown(v)}" is not a rate — N/s, N/minute, N/hour, or a number per second, above 0`),
59
+ secrets: modelSecretCollision,
57
60
  },
58
61
  "eval": {
59
62
  effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max; the judge runs at none`),