@foldrun/core 2.4.0 → 2.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 (149) 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 +2 -2
  4. package/dist/src/agent-tools.js +2 -2
  5. package/dist/src/agents-md.d.ts +5 -1
  6. package/dist/src/agents-md.js +42 -18
  7. package/dist/src/alerts.d.ts +60 -0
  8. package/dist/src/alerts.js +161 -0
  9. package/dist/src/browser-pod.js +4 -0
  10. package/dist/src/capture.d.ts +23 -0
  11. package/dist/src/capture.js +80 -0
  12. package/dist/src/compare.d.ts +57 -0
  13. package/dist/src/compare.js +116 -0
  14. package/dist/src/completions.js +26 -3
  15. package/dist/src/deploy.d.ts +36 -1
  16. package/dist/src/deploy.js +237 -13
  17. package/dist/src/egress.d.ts +16 -1
  18. package/dist/src/evals.d.ts +27 -2
  19. package/dist/src/evals.js +62 -19
  20. package/dist/src/flow-lint.d.ts +14 -0
  21. package/dist/src/flow-lint.js +35 -0
  22. package/dist/src/flow-patterns.js +29 -14
  23. package/dist/src/frontmatter-fields.d.ts +27 -0
  24. package/dist/src/frontmatter-fields.js +60 -3
  25. package/dist/src/frontmatter-guard.d.ts +19 -0
  26. package/dist/src/frontmatter-guard.js +67 -0
  27. package/dist/src/frontmatter-schema.d.ts +2 -2
  28. package/dist/src/frontmatter-schema.js +33 -2
  29. package/dist/src/frontmatter-text.d.ts +31 -0
  30. package/dist/src/frontmatter-text.js +102 -0
  31. package/dist/src/history.d.ts +4 -0
  32. package/dist/src/history.js +15 -0
  33. package/dist/src/hooks.js +1 -1
  34. package/dist/src/insights.d.ts +30 -0
  35. package/dist/src/insights.js +152 -0
  36. package/dist/src/issues.d.ts +140 -0
  37. package/dist/src/issues.js +302 -0
  38. package/dist/src/judge-verdict.d.ts +19 -0
  39. package/dist/src/judge-verdict.js +54 -0
  40. package/dist/src/library.js +1 -1
  41. package/dist/src/limits.d.ts +8 -0
  42. package/dist/src/limits.js +36 -5
  43. package/dist/src/linkable.js +1 -1
  44. package/dist/src/matter.d.ts +42 -0
  45. package/dist/src/matter.js +132 -0
  46. package/dist/src/model-credential.d.ts +14 -0
  47. package/dist/src/model-credential.js +40 -0
  48. package/dist/src/network.d.ts +12 -1
  49. package/dist/src/network.js +35 -8
  50. package/dist/src/notify.d.ts +16 -0
  51. package/dist/src/notify.js +17 -7
  52. package/dist/src/okf.js +1 -1
  53. package/dist/src/online-checks.d.ts +76 -0
  54. package/dist/src/online-checks.js +432 -0
  55. package/dist/src/operator.d.ts +277 -0
  56. package/dist/src/operator.js +176 -2
  57. package/dist/src/otel-config.d.ts +18 -0
  58. package/dist/src/otel-config.js +69 -0
  59. package/dist/src/otel.d.ts +38 -0
  60. package/dist/src/otel.js +85 -0
  61. package/dist/src/outcome.d.ts +40 -0
  62. package/dist/src/outcome.js +77 -0
  63. package/dist/src/package.js +9 -2
  64. package/dist/src/platform.d.ts +1 -1
  65. package/dist/src/prompt-layers.d.ts +30 -0
  66. package/dist/src/prompt-layers.js +77 -0
  67. package/dist/src/provider-health.js +2 -2
  68. package/dist/src/providers.d.ts +5 -0
  69. package/dist/src/providers.js +2 -2
  70. package/dist/src/run-container.d.ts +25 -2
  71. package/dist/src/run-container.js +61 -6
  72. package/dist/src/runner.d.ts +53 -2
  73. package/dist/src/runner.js +471 -102
  74. package/dist/src/runs-filter.d.ts +48 -0
  75. package/dist/src/runs-filter.js +73 -0
  76. package/dist/src/scan.d.ts +30 -1
  77. package/dist/src/scan.js +145 -14
  78. package/dist/src/starter.js +27 -2
  79. package/dist/src/step-exec.d.ts +39 -3
  80. package/dist/src/step-exec.js +118 -15
  81. package/dist/src/store.d.ts +144 -3
  82. package/dist/src/store.js +150 -7
  83. package/dist/src/tokens.d.ts +38 -0
  84. package/dist/src/tokens.js +57 -0
  85. package/dist/src/tool-names.js +1 -1
  86. package/dist/src/tool-programs.js +1 -1
  87. package/dist/src/trace-tree.d.ts +40 -0
  88. package/dist/src/trace-tree.js +156 -0
  89. package/dist/src/translator.d.ts +8 -0
  90. package/dist/src/translator.js +7 -2
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/workspace-check.d.ts +18 -0
  93. package/dist/src/workspace-check.js +34 -0
  94. package/package.json +2 -2
  95. package/src/agent-activity.ts +32 -0
  96. package/src/agent-import.ts +2 -2
  97. package/src/agent-tools.ts +2 -2
  98. package/src/agents-md.ts +39 -14
  99. package/src/alerts.ts +190 -0
  100. package/src/browser-pod.ts +3 -0
  101. package/src/capture.ts +88 -0
  102. package/src/compare.ts +146 -0
  103. package/src/completions.ts +27 -3
  104. package/src/deploy.ts +238 -12
  105. package/src/egress.ts +12 -2
  106. package/src/evals.ts +64 -19
  107. package/src/flow-lint.ts +36 -0
  108. package/src/flow-patterns.ts +26 -13
  109. package/src/frontmatter-fields.ts +83 -3
  110. package/src/frontmatter-guard.ts +70 -0
  111. package/src/frontmatter-schema.ts +33 -4
  112. package/src/frontmatter-text.ts +109 -0
  113. package/src/history.ts +13 -0
  114. package/src/hooks.ts +1 -1
  115. package/src/insights.ts +174 -0
  116. package/src/issues.ts +396 -0
  117. package/src/judge-verdict.ts +52 -0
  118. package/src/library.ts +1 -1
  119. package/src/limits.ts +38 -5
  120. package/src/linkable.ts +1 -1
  121. package/src/matter.ts +145 -0
  122. package/src/model-credential.ts +45 -0
  123. package/src/network.ts +35 -7
  124. package/src/notify.ts +22 -7
  125. package/src/okf.ts +1 -1
  126. package/src/online-checks.ts +450 -0
  127. package/src/operator.ts +213 -3
  128. package/src/otel-config.ts +67 -0
  129. package/src/otel.ts +100 -0
  130. package/src/outcome.ts +90 -0
  131. package/src/package.ts +8 -2
  132. package/src/platform.ts +1 -1
  133. package/src/prompt-layers.ts +90 -0
  134. package/src/provider-health.ts +2 -2
  135. package/src/providers.ts +7 -2
  136. package/src/run-container.ts +77 -8
  137. package/src/runner.ts +477 -79
  138. package/src/runs-filter.ts +88 -0
  139. package/src/scan.ts +153 -14
  140. package/src/starter.ts +27 -2
  141. package/src/step-exec.ts +168 -17
  142. package/src/store.ts +268 -9
  143. package/src/tokens.ts +68 -0
  144. package/src/tool-names.ts +1 -1
  145. package/src/tool-programs.ts +1 -1
  146. package/src/trace-tree.ts +187 -0
  147. package/src/translator.ts +13 -2
  148. package/src/trigger-log.ts +1 -0
  149. 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
+ }
@@ -10,7 +10,7 @@
10
10
  // renders them.
11
11
 
12
12
  import { PROVIDERS } from "./providers.ts";
13
- import { FRONTMATTER, type SchemaKind } from "./frontmatter-fields.ts";
13
+ import { FRONTMATTER, JOBS, fieldsOfJob, type SchemaKind } from "./frontmatter-fields.ts";
14
14
 
15
15
  export interface Vocabulary {
16
16
  agents: string[];
@@ -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
 
@@ -659,8 +660,31 @@ export function completionsAt(
659
660
  }
660
661
  }
661
662
 
662
- // 6. An indented option under a flow step.
663
- if (kind === "flow" && /^\s+[a-z]*$/.test(line) && line.trim().length >= 0) {
663
+ // 5b. An indented line inside one of the four job blocks (governance,
664
+ // guardrail, observability, evaluation): that job's fields for this
665
+ // kind of file — the block takes nothing else.
666
+ const fmClose = text.indexOf("\n---", text.indexOf("---") + 3);
667
+ const insideFrontmatter = text.startsWith("---") && (fmClose === -1 || cursor <= fmClose);
668
+ const nested = line.match(/^(\s+)([a-zA-Z_]*)$/);
669
+ if (insideFrontmatter && nested) {
670
+ const header = before.slice(0, lineStart).split("\n").reverse().find((l) => /^\S/.test(l));
671
+ const job = JOBS.find((j) => new RegExp(`^${j.name}\\s*:\\s*$`).test(header ?? ""));
672
+ if (job) {
673
+ const schemaKind = /(^|\/)AGENTS\.md$/.test(path) || path === "project.md" ? "agents-md" : SCHEMA_OF[kind];
674
+ const keys = new Set(schemaKind ? fieldsOfJob(schemaKind, job.name) : []);
675
+ const partial = nested[2] ?? "";
676
+ return {
677
+ from: cursor - partial.length,
678
+ query: partial,
679
+ items: filter(fieldsFor(path, kind).filter((c) => keys.has(c.label.split(".")[0]!)), partial),
680
+ title: `${job.title} — ${job.what}`,
681
+ };
682
+ }
683
+ }
684
+
685
+ // 6. An indented option under a flow step — the body's steps, never a
686
+ // line inside the frontmatter.
687
+ if (kind === "flow" && !insideFrontmatter && /^\s+[a-z]*$/.test(line) && line.trim().length >= 0) {
664
688
  const partial = line.trim();
665
689
  return {
666
690
  from: cursor - partial.length,
package/src/deploy.ts CHANGED
@@ -33,20 +33,29 @@ import {
33
33
  assertSafeName,
34
34
  type DeployFile,
35
35
  notifyWorkspaceChanged,
36
+ accountDir,
37
+ frontmatterErrorText,
36
38
  } from "./store.ts";
37
- import { lintFlow } from "./flow-lint.ts";
39
+ import { readAgentsMdKeys } from "./agents-md.ts";
40
+ import { libraryTools } from "./library.ts";
41
+ import { lintFlow, regexProblem } from "./flow-lint.ts";
42
+ import { parseEval } from "./evals.ts";
38
43
  import { refNames } from "./refs.ts";
39
- import matter from "gray-matter";
44
+ import matter, { configMatter } from "./matter.ts";
45
+ import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
40
46
  import { conformanceIssues } from "./okf.ts";
41
47
  import { timezoneProblem } from "./clock.ts";
42
48
  import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
43
49
  import { readLimits } from "./limits.ts";
50
+ import { parseAlerts } from "./alerts.ts";
51
+ import { parseTelemetry } from "./otel-config.ts";
52
+ import { parseJudge } from "./judge-verdict.ts";
44
53
  import { networkProblems } from "./network.ts";
45
54
  import { actionProblems } from "./web-actions.ts";
46
55
  import { languageProblem } from "./language.ts";
47
56
  import { localeProblems } from "./locale.ts";
48
57
  import { entriesNoFollow } from "./paths.ts";
49
- import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, type ScanFinding, type ScanOptions } from "./scan.ts";
58
+ import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, scanRule, STRICT_FRONTMATTER_PATH, type ScanFinding, type ScanOptions } from "./scan.ts";
50
59
 
51
60
  /** Directories a deploy never reads out of a source tree. */
52
61
  // `.cache` is what a run's tools leave in an agent's folder (HOME is that
@@ -121,9 +130,60 @@ export interface DeployResult extends DeployPlan {
121
130
  // agree on what counts as an agent.
122
131
  export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"] as const;
123
132
 
124
- export function readTree(dir: string): DeployFile[] {
133
+ /** What agents write as they run, not what anyone authored: state/ and
134
+ * memory/, the workspace's or one agent's. */
135
+ const DATA_PATH = /^(?:agents\/[^/]+\/)?(?:state|memory)\//;
136
+
137
+ /** Bounds for a reader that only looks at the text (the checks, the
138
+ * assistant): on 20 Oct 2026 the daily rescan read one desk's 1.2 GB of
139
+ * state/ into a 2 GB heap and crash-looped the worker. A deploy sets none. */
140
+ export interface ReadTreeOptions {
141
+ /** Data (DATA_PATH) larger than this is left out, unread. */
142
+ maxDataFileBytes?: number;
143
+ /** Data past this many bytes in all, or this many files, is left out. */
144
+ maxDataBytes?: number;
145
+ maxDataFiles?: number;
146
+ /** Everything else past this many bytes in all: readTree throws
147
+ * TreeTooLarge rather than return a part of the definitions, which a
148
+ * check would read as missing agents and flows. */
149
+ maxSourceBytes?: number;
150
+ /** Each data file left out, and its size. */
151
+ onSkip?: (path: string, bytes: number) => void;
152
+ }
153
+
154
+ export class TreeTooLarge extends Error {
155
+ /** What an HTTP route answers with it. */
156
+ readonly status = 413;
157
+ }
158
+
159
+ export function readTree(dir: string, opts: ReadTreeOptions = {}): DeployFile[] {
125
160
  const root = path.resolve(dir);
126
161
  const out: DeployFile[] = [];
162
+ let dataBytes = 0;
163
+ let dataFiles = 0;
164
+ let sourceBytes = 0;
165
+ /** May this file be read? Data over its budget is left out; source over
166
+ * its budget ends the read. */
167
+ const admit = (rel: string, bytes: number): boolean => {
168
+ if (DATA_PATH.test(rel)) {
169
+ const over =
170
+ (opts.maxDataFileBytes !== undefined && bytes > opts.maxDataFileBytes) ||
171
+ (opts.maxDataBytes !== undefined && dataBytes + bytes > opts.maxDataBytes) ||
172
+ (opts.maxDataFiles !== undefined && dataFiles >= opts.maxDataFiles);
173
+ if (over) {
174
+ opts.onSkip?.(rel, bytes);
175
+ return false;
176
+ }
177
+ dataBytes += bytes;
178
+ dataFiles++;
179
+ return true;
180
+ }
181
+ sourceBytes += bytes;
182
+ if (opts.maxSourceBytes !== undefined && sourceBytes > opts.maxSourceBytes) {
183
+ throw new TreeTooLarge(`the workspace's files other than state/ and memory/ are over ${Math.round(opts.maxSourceBytes / 1048576)} MiB — not read`);
184
+ }
185
+ return true;
186
+ };
127
187
 
128
188
  const walk = (abs: string, rel: string) => {
129
189
  let entries: string[];
@@ -148,6 +208,7 @@ export function readTree(dir: string): DeployFile[] {
148
208
  if (stat.isDirectory()) {
149
209
  walk(full, next);
150
210
  } else if (next === "AGENTS.md" || next === "project.md" || IN_WORKSPACE_DIR.test(next)) {
211
+ if (!admit(next, stat.size)) continue;
151
212
  out.push({ path: next, content: fs.readFileSync(full, "utf8") });
152
213
  }
153
214
  }
@@ -188,9 +249,12 @@ export function readTree(dir: string): DeployFile[] {
188
249
  if (nativeAgent.has(target) || out.some((fEntry) => fEntry.path === target)) continue;
189
250
  const full = path.join(root, importDir, entry);
190
251
  try {
191
- if (fs.lstatSync(full).isSymbolicLink()) continue;
252
+ const st = fs.lstatSync(full);
253
+ if (st.isSymbolicLink()) continue;
254
+ if (!admit(target, st.size)) continue;
192
255
  out.push({ path: target, content: fs.readFileSync(full, "utf8") });
193
- } catch {
256
+ } catch (err) {
257
+ if (err instanceof TreeTooLarge) throw err;
194
258
  // an unreadable import is skipped, not fatal
195
259
  }
196
260
  }
@@ -238,6 +302,90 @@ export function unopenedFrontmatter(raw: string): string | null {
238
302
  * advice (an agent that could leak, an unpinned package) is for `foldrun
239
303
  * check` and the Checks page, not for every deploy's output.
240
304
  */
305
+ /**
306
+ * What `deploy_block:` in AGENTS.md refuses (FOL-58). `none`, the default:
307
+ * the scanner's findings are warnings and the deploy goes ahead, as it always
308
+ * did. `secrets`: a key or password written into a file is refused.
309
+ * `security`: every security finding is — secrets, hidden or injected text,
310
+ * the lethal trifecta, pipe-to-shell, an unpinned MCP server, a step that
311
+ * acts outside with no check or approval. A
312
+ * `foldrun-ignore` with a reason still accepts one.
313
+ */
314
+ export const DEPLOY_BLOCK_MODES = ["none", "secrets", "security"] as const;
315
+ export type DeployBlockMode = (typeof DEPLOY_BLOCK_MODES)[number];
316
+
317
+ const blockMode = (v: unknown): DeployBlockMode | null =>
318
+ typeof v === "string" && (DEPLOY_BLOCK_MODES as readonly string[]).includes(v.trim()) ? (v.trim() as DeployBlockMode) : null;
319
+
320
+ /** The workspace's own AGENTS.md, else the account's, else none. */
321
+ export function deployBlockMode(files: DeployFile[], accountKeys: Record<string, unknown>): DeployBlockMode {
322
+ // project.md is the older name readAgentsMdKeys still reads (Codex review).
323
+ const own = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
324
+ let fromWorkspace: DeployBlockMode | null = null;
325
+ if (own) {
326
+ try {
327
+ fromWorkspace = blockMode((configMatter(own.content).data as Record<string, unknown>).deploy_block);
328
+ } catch {
329
+ // an AGENTS.md that does not parse is reported elsewhere
330
+ }
331
+ }
332
+ return fromWorkspace ?? blockMode(accountKeys.deploy_block) ?? "none";
333
+ }
334
+
335
+ function blockersFor(tenant: string, given: DeployFile[]): DeployIssue[] {
336
+ // The tree as it will be written: `./AGENTS.md` is saved as AGENTS.md, so
337
+ // it is read as that here too (Codex review).
338
+ // Two entries for one path: the save keeps the last, so this does too.
339
+ const byPath = new Map<string, DeployFile>();
340
+ for (const f of given) {
341
+ // path.normalize, as saveWorkspace writes it (a backslash is a filename
342
+ // character here, not a separator — Codex review).
343
+ const p = path.normalize(f.path);
344
+ byPath.set(p, { ...f, path: p });
345
+ }
346
+ const files = [...byPath.values()];
347
+ const account = readAgentsMdKeys(accountDir(tenant));
348
+ const mode = deployBlockMode(files, account);
349
+ if (mode === "none") return [];
350
+ // Nearest wins: a workspace tool of the same name replaces the library's,
351
+ // and the scanner adds the workspace's own outward tools itself. Named as
352
+ // the runtime names them — the definition's name:, else the file's.
353
+ const ownTools = new Set<string>();
354
+ for (const f of files) {
355
+ const m = /^tools\/([^/]+?)(?:\/tool)?\.md$/.exec(f.path);
356
+ if (!m) continue;
357
+ let data: Record<string, unknown> = {};
358
+ try {
359
+ data = matter(f.content).data as Record<string, unknown>;
360
+ } catch {
361
+ continue; // unreadable: the runtime skips it and keeps the library's
362
+ }
363
+ // Only a definition the runtime accepts replaces the library's; an
364
+ // invalid one is skipped there, and the library tool stays (Codex review).
365
+ const def = parseToolDef(data, m[1], f.content);
366
+ if (def) ownTools.add(def.name ?? m[1]);
367
+ }
368
+ const outwardTools = Object.entries(libraryTools(tenant))
369
+ .filter(([n, d]) => (d as { outward?: boolean }).outward === true && !ownTools.has(n))
370
+ .map(([n]) => n);
371
+ return deployBlockers(files, mode, { accountNetwork: account.network, outwardTools });
372
+ }
373
+
374
+ /** The findings `mode` turns into refusals, as deploy issues. */
375
+ export function deployBlockers(files: DeployFile[], mode: DeployBlockMode, options: ScanOptions = {}): DeployIssue[] {
376
+ if (mode === "none") return [];
377
+ // Every finding checkFiles reports — the scanner's and the structural
378
+ // ones, flow/outward-ungated among them (Codex review) — with the same
379
+ // ignores honoured, kept by its rule's category.
380
+ const categories = new Set(mode === "secrets" ? ["secret"] : ["secret", "content", "security", "supply-chain"]);
381
+ return checkFiles(files, options)
382
+ .filter((f) => f.level !== "info" && categories.has(scanRule(f.rule)?.category ?? ""))
383
+ .map((f) => ({
384
+ where: `${f.file}${f.line ? `:${f.line}` : ""}`,
385
+ message: `${f.message} [${f.rule}] — refused: deploy_block: ${mode} in AGENTS.md`,
386
+ }));
387
+ }
388
+
241
389
  export function deployWarnings(files: DeployFile[]): DeployIssue[] {
242
390
  const scanned = scanFiles(files, { only: ["secret", "content"] })
243
391
  .filter((f) => f.level !== "info")
@@ -261,19 +409,20 @@ export function checkFiles(files: DeployFile[], options: ScanOptions = {}): Scan
261
409
  };
262
410
  const own = [
263
411
  ...deployIssues(files).map((i) => finding(i, "error")),
264
- ...structuralWarnings(files).map((i) => finding(i, "warn", i.kind)),
412
+ ...structuralWarnings(files, options.outwardTools).map((i) => finding(i, "warn", i.kind)),
265
413
  ];
266
414
  return [...filterIgnored(own, files), ...scanFiles(files, options)].sort(
267
415
  (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule),
268
416
  );
269
417
  }
270
418
 
271
- function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string })[] {
419
+ function structuralWarnings(files: DeployFile[], extraOutward: Iterable<string> = []): (DeployIssue & { kind?: string })[] {
272
420
  const front = (content: string): Record<string, unknown> => {
273
421
  try { return matter(content).data as Record<string, unknown>; } catch { return {}; }
274
422
  };
275
- // Workspace tools that change something outside the workspace.
276
- const outwardTools = new Set<string>();
423
+ // Tools that change something outside the workspace: the workspace's own,
424
+ // and the library's the caller resolved (nearest wins already applied).
425
+ const outwardTools = new Set<string>(extraOutward);
277
426
  for (const f of files) {
278
427
  const m = f.path.match(/^tools\/([^/]+?)(?:\/tool)?\.md$/);
279
428
  if (!m) continue;
@@ -282,6 +431,8 @@ function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string
282
431
  // `outward: true` is read off the frontmatter, the same test the runtime
283
432
  // and the CLI use; parseToolDef does not fold it onto the def.
284
433
  if (def && data.outward === true) outwardTools.add(def.name ?? m[1]);
434
+ // A workspace tool that is not outward replaces a library one that was.
435
+ else if (def) outwardTools.delete(def.name ?? m[1]);
285
436
  }
286
437
  // Agents that grant one of them.
287
438
  const outwardAgents: string[] = [];
@@ -302,6 +453,32 @@ function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string
302
453
  if (refNames(data.tools).some((t: string) => outwardTools.has(t))) outwardAgents.push(m[1]);
303
454
  for (const w of webWarnings(data, { chrome, chromium })) out.push({ where: f.path, message: w });
304
455
  }
456
+ // An eval's `matches:` is the same JavaScript RegExp as a verify's; one
457
+ // that does not compile fails its case on every run. Read through
458
+ // parseEval, so a block scalar (`matches: |`) or a continued line is
459
+ // tested as the runtime will run it. The line is the item's own: the
460
+ // parsed assertions and the `- matches:` lines come in the same order.
461
+ for (const f of files) {
462
+ if (!/^evals\/[^/]+\.md$/.test(f.path)) continue;
463
+ let info;
464
+ try { info = parseEval(f.path, f.content); } catch { continue; }
465
+ const lines: number[] = [];
466
+ f.content.split("\n").forEach((line, n) => { if (/^\s*-\s*matches:/.test(line)) lines.push(n + 1); });
467
+ // When a `- matches:` line sits outside a case (an example in the
468
+ // prose), the order no longer pairs up: name the file, not a wrong line.
469
+ const parsed = info.cases.reduce((n, c) => n + c.expect.filter((a) => a.type === "matches").length, 0);
470
+ const pairs = lines.length === parsed;
471
+ let k = 0;
472
+ for (const c of info.cases) {
473
+ for (const a of c.expect) {
474
+ if (a.type !== "matches") continue;
475
+ const at = pairs ? lines[k] : undefined;
476
+ k++;
477
+ const problem = regexProblem(a.value);
478
+ if (problem) out.push({ where: at ? `${f.path}:${at}` : f.path, message: `case "${c.name}": matches: ${problem}` });
479
+ }
480
+ }
481
+ }
305
482
  if (!outwardAgents.length) return out;
306
483
  for (const f of files) {
307
484
  if (!/^flows\/[^/]+\.md$/.test(f.path)) continue;
@@ -342,6 +519,45 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
342
519
  if (m) agents.add(m[1]);
343
520
  }
344
521
 
522
+ // Frontmatter whose YAML does not parse is a file the runtime cannot read:
523
+ // an agent with no tools and the default model, a flow that never fires,
524
+ // a tool that is skipped. Refused where it was written. Until 20 Oct 2026
525
+ // nothing said so — gray-matter's cache let a second parse pass — and one
526
+ // such agent file kept the worker from booting for every account.
527
+ // Definition files only: a skill is read leniently (an unquoted colon is
528
+ // quoted and retried, matter.ts parseLenient), and the markdown beside a
529
+ // definition — references, a tool's README — is prose, not frontmatter.
530
+ // The scanner warns about skills and library files (config/invalid-frontmatter).
531
+ for (const f of files) {
532
+ if (!STRICT_FRONTMATTER_PATH.test(f.path)) continue;
533
+ if (!/^\uFEFF?---\r?\n/.test(f.content)) continue;
534
+ try {
535
+ matter(f.content);
536
+ } catch (err) {
537
+ at(f.path, frontmatterErrorText(err));
538
+ }
539
+ }
540
+
541
+ // The four job blocks (frontmatter-fields.ts JOB_FIELDS): a setting
542
+ // written both at the top and in its block, or in another job's block, is
543
+ // a setting that does not do what the file says — a guardrail under
544
+ // governance: guards nothing — so the deploy refuses it as check does
545
+ // (config/conflict, config/misplaced; Codex debate, 21 Oct 2026).
546
+ for (const f of files) {
547
+ const kind = schemaKindOf(f.path);
548
+ if (kind !== "agent" && kind !== "flow" && kind !== "agents-md") continue;
549
+ if (!/^\uFEFF?---\r?\n/.test(f.content)) continue;
550
+ let front: Record<string, unknown>;
551
+ try {
552
+ front = configMatter(f.content).data as Record<string, unknown>;
553
+ } catch {
554
+ continue; // unparseable frontmatter is its own report
555
+ }
556
+ for (const p of frontmatterProblems(kind, front, () => null)) {
557
+ if (p.level === "error" && (p.rule === "config/conflict" || p.rule === "config/misplaced")) at(f.path, `${p.message} [${p.rule}]`);
558
+ }
559
+ }
560
+
345
561
  // `timezone:` is read at four levels and a value nothing can parse falls
346
562
  // silently through to the level above — so it is refused where it was
347
563
  // written, not discovered as a date that is a day out.
@@ -351,7 +567,7 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
351
567
  if (unopened) at(f.path, unopened);
352
568
  let front: Record<string, unknown> = {};
353
569
  try {
354
- front = matter(f.content).data as Record<string, unknown>;
570
+ front = configMatter(f.content).data as Record<string, unknown>;
355
571
  } catch {
356
572
  continue; // unparseable frontmatter is its own report
357
573
  }
@@ -370,6 +586,13 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
370
586
  // `network:` that cannot be read would leave the step on the level
371
587
  // above it — wider or narrower than the author wrote.
372
588
  for (const w of networkProblems(front.network)) at(f.path, w);
589
+ // `alerts:` that cannot be read would never alert (FOL-72).
590
+ for (const w of parseAlerts(front.alerts).problems) at(f.path, w);
591
+ // `telemetry:` that cannot be read would never send — and a token
592
+ // written into it would ship in the file.
593
+ for (const w of parseTelemetry(front.telemetry).problems) at(f.path, w);
594
+ // `judge:` that cannot be read would leave every judge on the default.
595
+ for (const w of parseJudge(front.judge).problems) at(f.path, w);
373
596
  const lang = languageProblem(front.language);
374
597
  if (lang) at(f.path, lang);
375
598
  for (const w of localeProblems(front)) at(f.path, w);
@@ -509,7 +732,10 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
509
732
  added: added.sort(),
510
733
  updated: updated.sort(),
511
734
  removed: removed.sort(),
512
- issues: deployIssues(files),
735
+ // deploy_block: (FOL-58) turns the scanner's findings into refusals —
736
+ // scanned with what the Checks page passes too: the account's network:
737
+ // and the library's outward tools (Codex review).
738
+ issues: [...deployIssues(files), ...blockersFor(tenant, files)],
513
739
  warnings: deployWarnings(files),
514
740
  blockedBy: runsInFlight(tenant, workspace),
515
741
  };
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. */