@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
@@ -0,0 +1,116 @@
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
+ const firstLine = (s, n = 200) => {
7
+ const l = (s ?? "").split("\n").map((x) => x.trim()).find(Boolean);
8
+ return l ? l.slice(0, n) : null;
9
+ };
10
+ function side(s) {
11
+ const tools = {};
12
+ let toolErrors = 0;
13
+ for (const e of s.events) {
14
+ if (e.type !== "tool")
15
+ continue;
16
+ // A pair is one call: count the call, and its completion's error.
17
+ if (e.ms == null)
18
+ tools[e.text] = (tools[e.text] ?? 0) + 1;
19
+ else if (e.err)
20
+ toolErrors++;
21
+ }
22
+ const start = s.startedAt ? Date.parse(s.startedAt) : NaN;
23
+ const end = s.finishedAt ? Date.parse(s.finishedAt) : NaN;
24
+ return {
25
+ status: s.status,
26
+ instruction: s.instruction,
27
+ ...(s.model ? { model: s.model } : {}),
28
+ secs: Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1000)) : null,
29
+ tokensIn: s.tokens?.input ?? 0,
30
+ tokensOut: s.tokens?.output ?? 0,
31
+ modelCalls: s.calls?.length ?? s.turns ?? 0,
32
+ tools,
33
+ toolErrors,
34
+ errors: s.events.filter((e) => e.type === "error").slice(0, 3).map((e) => firstLine(e.text) ?? ""),
35
+ said: firstLine(s.conclusion ?? s.result),
36
+ };
37
+ }
38
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
39
+ /** Much more or much less: by a quarter and by a real amount. */
40
+ const moved = (a, b, floor) => Math.abs(b - a) >= floor && (a === 0 ? b > 0 : Math.abs(b - a) / a >= 0.25);
41
+ function changes(a, b) {
42
+ if (!a && b)
43
+ return ["new step: not in the base run"];
44
+ if (a && !b)
45
+ return ["not in this run"];
46
+ if (!a || !b)
47
+ return [];
48
+ const out = [];
49
+ if (a.status !== b.status)
50
+ out.push(`status: ${a.status} → ${b.status}`);
51
+ const newErrors = b.errors.filter((e) => !a.errors.includes(e));
52
+ if (newErrors.length)
53
+ out.push(`new error: ${newErrors[0]}`);
54
+ if (a.instruction.trim() !== b.instruction.trim())
55
+ out.push("instruction changed");
56
+ if ((a.model ?? "") !== (b.model ?? ""))
57
+ out.push(`model: ${a.model ?? "default"} → ${b.model ?? "default"}`);
58
+ if (b.toolErrors > a.toolErrors)
59
+ out.push(`tool errors: ${a.toolErrors} → ${b.toolErrors}`);
60
+ const names = new Set([...Object.keys(a.tools), ...Object.keys(b.tools)]);
61
+ const toolDiff = [...names]
62
+ .map((n) => ({ n, d: (b.tools[n] ?? 0) - (a.tools[n] ?? 0) }))
63
+ .filter((x) => x.d !== 0)
64
+ .sort((x, y) => Math.abs(y.d) - Math.abs(x.d))
65
+ .slice(0, 4)
66
+ .map((x) => `${x.d > 0 ? "+" : "−"}${x.n} ×${Math.abs(x.d)}`);
67
+ if (toolDiff.length)
68
+ out.push(`tools: ${toolDiff.join(", ")}`);
69
+ if (a.secs !== null && b.secs !== null && moved(a.secs, b.secs, 10))
70
+ out.push(`took ${a.secs}s → ${b.secs}s`);
71
+ if (moved(a.tokensIn, b.tokensIn, 5000))
72
+ out.push(`tokens in ${k(a.tokensIn)} → ${k(b.tokensIn)}`);
73
+ if (moved(a.modelCalls, b.modelCalls, 3))
74
+ out.push(`model calls ${a.modelCalls} → ${b.modelCalls}`);
75
+ if (a.said !== b.said && b.said)
76
+ out.push(`said: ${b.said}`);
77
+ return out;
78
+ }
79
+ /** Steps lined up: the flow's own steps by position where the agents agree,
80
+ * fan-out instances by agent and order. */
81
+ function align(a, b) {
82
+ const pairs = [];
83
+ const used = new Set();
84
+ b.forEach((s, i) => {
85
+ let j = a[i] && a[i].agent === s.agent && !used.has(i) ? i : -1;
86
+ if (j < 0)
87
+ j = a.findIndex((x, m) => !used.has(m) && x.agent === s.agent);
88
+ if (j >= 0)
89
+ used.add(j);
90
+ pairs.push([j >= 0 ? a[j] : null, s]);
91
+ });
92
+ a.forEach((s, m) => !used.has(m) && pairs.push([s, null]));
93
+ return pairs;
94
+ }
95
+ export function compareRuns(base, run, changedFiles = null) {
96
+ const head = (r) => ({ id: r.id, status: r.status, startedAt: r.startedAt, revision: r.revision ?? null });
97
+ return {
98
+ base: head(base),
99
+ run: head(run),
100
+ revisionChanged: (base.revision ?? null) !== (run.revision ?? null),
101
+ changedFiles,
102
+ steps: align(base.steps, run.steps).map(([a, b], i) => {
103
+ const sa = a ? side(a) : null;
104
+ const sb = b ? side(b) : null;
105
+ return { step: b ? run.steps.indexOf(b) + 1 : i + 1, agent: (b ?? a).agent, base: sa, run: sb, changes: changes(sa, sb) };
106
+ }),
107
+ };
108
+ }
109
+ /** The run to compare a run with when none is named: the newest earlier run
110
+ * of the same flow that finished well (completed, not BLOCKED or BAD), or
111
+ * null. `outcomeOf` is the caller's, so this stays free of the verdict reader. */
112
+ export function lastGoodBefore(runs, run, good) {
113
+ return (runs
114
+ .filter((r) => r.flow === run.flow && r.id !== run.id && r.startedAt < run.startedAt && good(r))
115
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0] ?? null);
116
+ }
@@ -9,7 +9,7 @@
9
9
  // go here. No React, no fetch — so the rules are testable and one component
10
10
  // renders them.
11
11
  import { PROVIDERS } from "./providers.js";
12
- import { FRONTMATTER } from "./frontmatter-fields.js";
12
+ import { FRONTMATTER, JOBS, fieldsOfJob } from "./frontmatter-fields.js";
13
13
  const MODELS = [
14
14
  { label: "fast", hint: "haiku — cheap, for checks and triage" },
15
15
  { label: "default", hint: "sonnet — the working default" },
@@ -37,6 +37,7 @@ const TOOL_GROUPS = [
37
37
  { label: "code", hint: "Bash — runs anything in the sandbox" },
38
38
  { label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
39
39
  { label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
40
+ { label: "peers", hint: "peers_list + peers_message — the colleagues running beside it in its parallel group, and a message to one of them" },
40
41
  { label: "install", hint: "install apt, pip or npm packages mid-step — every agent has it; disallowedTools: [install] removes it" },
41
42
  ];
42
43
  const SDK_TOOL_NAMES = [
@@ -586,8 +587,30 @@ export function completionsAt(path, text, cursor, vocab) {
586
587
  };
587
588
  }
588
589
  }
589
- // 6. An indented option under a flow step.
590
- if (kind === "flow" && /^\s+[a-z]*$/.test(line) && line.trim().length >= 0) {
590
+ // 5b. An indented line inside one of the four job blocks (governance,
591
+ // guardrail, observability, evaluation): that job's fields for this
592
+ // kind of file — the block takes nothing else.
593
+ const fmClose = text.indexOf("\n---", text.indexOf("---") + 3);
594
+ const insideFrontmatter = text.startsWith("---") && (fmClose === -1 || cursor <= fmClose);
595
+ const nested = line.match(/^(\s+)([a-zA-Z_]*)$/);
596
+ if (insideFrontmatter && nested) {
597
+ const header = before.slice(0, lineStart).split("\n").reverse().find((l) => /^\S/.test(l));
598
+ const job = JOBS.find((j) => new RegExp(`^${j.name}\\s*:\\s*$`).test(header ?? ""));
599
+ if (job) {
600
+ const schemaKind = /(^|\/)AGENTS\.md$/.test(path) || path === "project.md" ? "agents-md" : SCHEMA_OF[kind];
601
+ const keys = new Set(schemaKind ? fieldsOfJob(schemaKind, job.name) : []);
602
+ const partial = nested[2] ?? "";
603
+ return {
604
+ from: cursor - partial.length,
605
+ query: partial,
606
+ items: filter(fieldsFor(path, kind).filter((c) => keys.has(c.label.split(".")[0])), partial),
607
+ title: `${job.title} — ${job.what}`,
608
+ };
609
+ }
610
+ }
611
+ // 6. An indented option under a flow step — the body's steps, never a
612
+ // line inside the frontmatter.
613
+ if (kind === "flow" && !insideFrontmatter && /^\s+[a-z]*$/.test(line) && line.trim().length >= 0) {
591
614
  const partial = line.trim();
592
615
  return {
593
616
  from: cursor - partial.length,
@@ -47,7 +47,27 @@ export interface DeployResult extends DeployPlan {
47
47
  * other things is the normal case, not an error.
48
48
  */
49
49
  export declare const SUBAGENT_IMPORT_DIRS: readonly [".agents/agents", ".claude/agents"];
50
- export declare function readTree(dir: string): DeployFile[];
50
+ /** Bounds for a reader that only looks at the text (the checks, the
51
+ * assistant): on 20 Oct 2026 the daily rescan read one desk's 1.2 GB of
52
+ * state/ into a 2 GB heap and crash-looped the worker. A deploy sets none. */
53
+ export interface ReadTreeOptions {
54
+ /** Data (DATA_PATH) larger than this is left out, unread. */
55
+ maxDataFileBytes?: number;
56
+ /** Data past this many bytes in all, or this many files, is left out. */
57
+ maxDataBytes?: number;
58
+ maxDataFiles?: number;
59
+ /** Everything else past this many bytes in all: readTree throws
60
+ * TreeTooLarge rather than return a part of the definitions, which a
61
+ * check would read as missing agents and flows. */
62
+ maxSourceBytes?: number;
63
+ /** Each data file left out, and its size. */
64
+ onSkip?: (path: string, bytes: number) => void;
65
+ }
66
+ export declare class TreeTooLarge extends Error {
67
+ /** What an HTTP route answers with it. */
68
+ readonly status = 413;
69
+ }
70
+ export declare function readTree(dir: string, opts?: ReadTreeOptions): DeployFile[];
51
71
  /**
52
72
  * Everything wrong with an incoming workspace, before any of it is live.
53
73
  *
@@ -77,6 +97,21 @@ export declare function unopenedFrontmatter(raw: string): string | null;
77
97
  * advice (an agent that could leak, an unpinned package) is for `foldrun
78
98
  * check` and the Checks page, not for every deploy's output.
79
99
  */
100
+ /**
101
+ * What `deploy_block:` in AGENTS.md refuses (FOL-58). `none`, the default:
102
+ * the scanner's findings are warnings and the deploy goes ahead, as it always
103
+ * did. `secrets`: a key or password written into a file is refused.
104
+ * `security`: every security finding is — secrets, hidden or injected text,
105
+ * the lethal trifecta, pipe-to-shell, an unpinned MCP server, a step that
106
+ * acts outside with no check or approval. A
107
+ * `foldrun-ignore` with a reason still accepts one.
108
+ */
109
+ export declare const DEPLOY_BLOCK_MODES: readonly ["none", "secrets", "security"];
110
+ export type DeployBlockMode = (typeof DEPLOY_BLOCK_MODES)[number];
111
+ /** The workspace's own AGENTS.md, else the account's, else none. */
112
+ export declare function deployBlockMode(files: DeployFile[], accountKeys: Record<string, unknown>): DeployBlockMode;
113
+ /** The findings `mode` turns into refusals, as deploy issues. */
114
+ export declare function deployBlockers(files: DeployFile[], mode: DeployBlockMode, options?: ScanOptions): DeployIssue[];
80
115
  export declare function deployWarnings(files: DeployFile[]): DeployIssue[];
81
116
  /**
82
117
  * Everything the platform can say about a workspace's files, as findings
@@ -20,20 +20,27 @@
20
20
  import fs from "node:fs";
21
21
  import os from "node:os";
22
22
  import path from "node:path";
23
- import { saveWorkspace, keptOnDeploy, workspaceDir, listRunSummaries, listWorkspaceFiles, parseFlow, parseToolDef, WORKSPACE_DIRS, assertSafeName, } from "./store.js";
24
- import { lintFlow } from "./flow-lint.js";
23
+ import { saveWorkspace, keptOnDeploy, workspaceDir, listRunSummaries, listWorkspaceFiles, parseFlow, parseToolDef, WORKSPACE_DIRS, assertSafeName, accountDir, frontmatterErrorText, } from "./store.js";
24
+ import { readAgentsMdKeys } from "./agents-md.js";
25
+ import { libraryTools } from "./library.js";
26
+ import { lintFlow, regexProblem } from "./flow-lint.js";
27
+ import { parseEval } from "./evals.js";
25
28
  import { refNames } from "./refs.js";
26
- import matter from "gray-matter";
29
+ import matter, { configMatter } from "./matter.js";
30
+ import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
27
31
  import { conformanceIssues } from "./okf.js";
28
32
  import { timezoneProblem } from "./clock.js";
29
33
  import { webProblems, webWarnings, runnerEngines } from "./providers.js";
30
34
  import { readLimits } from "./limits.js";
35
+ import { parseAlerts } from "./alerts.js";
36
+ import { parseTelemetry } from "./otel-config.js";
37
+ import { parseJudge } from "./judge-verdict.js";
31
38
  import { networkProblems } from "./network.js";
32
39
  import { actionProblems } from "./web-actions.js";
33
40
  import { languageProblem } from "./language.js";
34
41
  import { localeProblems } from "./locale.js";
35
42
  import { entriesNoFollow } from "./paths.js";
36
- import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles } from "./scan.js";
43
+ import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, scanRule, STRICT_FRONTMATTER_PATH } from "./scan.js";
37
44
  /** Directories a deploy never reads out of a source tree. */
38
45
  // `.cache` is what a run's tools leave in an agent's folder (HOME is that
39
46
  // folder): logs and font caches, never authored.
@@ -73,9 +80,40 @@ export function isSourcePath(rel) {
73
80
  // head. Shared by the deploy import (readTree) and the CLI checker so the two
74
81
  // agree on what counts as an agent.
75
82
  export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"];
76
- export function readTree(dir) {
83
+ /** What agents write as they run, not what anyone authored: state/ and
84
+ * memory/, the workspace's or one agent's. */
85
+ const DATA_PATH = /^(?:agents\/[^/]+\/)?(?:state|memory)\//;
86
+ export class TreeTooLarge extends Error {
87
+ /** What an HTTP route answers with it. */
88
+ status = 413;
89
+ }
90
+ export function readTree(dir, opts = {}) {
77
91
  const root = path.resolve(dir);
78
92
  const out = [];
93
+ let dataBytes = 0;
94
+ let dataFiles = 0;
95
+ let sourceBytes = 0;
96
+ /** May this file be read? Data over its budget is left out; source over
97
+ * its budget ends the read. */
98
+ const admit = (rel, bytes) => {
99
+ if (DATA_PATH.test(rel)) {
100
+ const over = (opts.maxDataFileBytes !== undefined && bytes > opts.maxDataFileBytes) ||
101
+ (opts.maxDataBytes !== undefined && dataBytes + bytes > opts.maxDataBytes) ||
102
+ (opts.maxDataFiles !== undefined && dataFiles >= opts.maxDataFiles);
103
+ if (over) {
104
+ opts.onSkip?.(rel, bytes);
105
+ return false;
106
+ }
107
+ dataBytes += bytes;
108
+ dataFiles++;
109
+ return true;
110
+ }
111
+ sourceBytes += bytes;
112
+ if (opts.maxSourceBytes !== undefined && sourceBytes > opts.maxSourceBytes) {
113
+ throw new TreeTooLarge(`the workspace's files other than state/ and memory/ are over ${Math.round(opts.maxSourceBytes / 1048576)} MiB — not read`);
114
+ }
115
+ return true;
116
+ };
79
117
  const walk = (abs, rel) => {
80
118
  let entries;
81
119
  try {
@@ -104,6 +142,8 @@ export function readTree(dir) {
104
142
  walk(full, next);
105
143
  }
106
144
  else if (next === "AGENTS.md" || next === "project.md" || IN_WORKSPACE_DIR.test(next)) {
145
+ if (!admit(next, stat.size))
146
+ continue;
107
147
  out.push({ path: next, content: fs.readFileSync(full, "utf8") });
108
148
  }
109
149
  }
@@ -143,11 +183,16 @@ export function readTree(dir) {
143
183
  continue;
144
184
  const full = path.join(root, importDir, entry);
145
185
  try {
146
- if (fs.lstatSync(full).isSymbolicLink())
186
+ const st = fs.lstatSync(full);
187
+ if (st.isSymbolicLink())
188
+ continue;
189
+ if (!admit(target, st.size))
147
190
  continue;
148
191
  out.push({ path: target, content: fs.readFileSync(full, "utf8") });
149
192
  }
150
- catch {
193
+ catch (err) {
194
+ if (err instanceof TreeTooLarge)
195
+ throw err;
151
196
  // an unreadable import is skipped, not fatal
152
197
  }
153
198
  }
@@ -195,6 +240,89 @@ export function unopenedFrontmatter(raw) {
195
240
  * advice (an agent that could leak, an unpinned package) is for `foldrun
196
241
  * check` and the Checks page, not for every deploy's output.
197
242
  */
243
+ /**
244
+ * What `deploy_block:` in AGENTS.md refuses (FOL-58). `none`, the default:
245
+ * the scanner's findings are warnings and the deploy goes ahead, as it always
246
+ * did. `secrets`: a key or password written into a file is refused.
247
+ * `security`: every security finding is — secrets, hidden or injected text,
248
+ * the lethal trifecta, pipe-to-shell, an unpinned MCP server, a step that
249
+ * acts outside with no check or approval. A
250
+ * `foldrun-ignore` with a reason still accepts one.
251
+ */
252
+ export const DEPLOY_BLOCK_MODES = ["none", "secrets", "security"];
253
+ const blockMode = (v) => typeof v === "string" && DEPLOY_BLOCK_MODES.includes(v.trim()) ? v.trim() : null;
254
+ /** The workspace's own AGENTS.md, else the account's, else none. */
255
+ export function deployBlockMode(files, accountKeys) {
256
+ // project.md is the older name readAgentsMdKeys still reads (Codex review).
257
+ const own = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
258
+ let fromWorkspace = null;
259
+ if (own) {
260
+ try {
261
+ fromWorkspace = blockMode(configMatter(own.content).data.deploy_block);
262
+ }
263
+ catch {
264
+ // an AGENTS.md that does not parse is reported elsewhere
265
+ }
266
+ }
267
+ return fromWorkspace ?? blockMode(accountKeys.deploy_block) ?? "none";
268
+ }
269
+ function blockersFor(tenant, given) {
270
+ // The tree as it will be written: `./AGENTS.md` is saved as AGENTS.md, so
271
+ // it is read as that here too (Codex review).
272
+ // Two entries for one path: the save keeps the last, so this does too.
273
+ const byPath = new Map();
274
+ for (const f of given) {
275
+ // path.normalize, as saveWorkspace writes it (a backslash is a filename
276
+ // character here, not a separator — Codex review).
277
+ const p = path.normalize(f.path);
278
+ byPath.set(p, { ...f, path: p });
279
+ }
280
+ const files = [...byPath.values()];
281
+ const account = readAgentsMdKeys(accountDir(tenant));
282
+ const mode = deployBlockMode(files, account);
283
+ if (mode === "none")
284
+ return [];
285
+ // Nearest wins: a workspace tool of the same name replaces the library's,
286
+ // and the scanner adds the workspace's own outward tools itself. Named as
287
+ // the runtime names them — the definition's name:, else the file's.
288
+ const ownTools = new Set();
289
+ for (const f of files) {
290
+ const m = /^tools\/([^/]+?)(?:\/tool)?\.md$/.exec(f.path);
291
+ if (!m)
292
+ continue;
293
+ let data = {};
294
+ try {
295
+ data = matter(f.content).data;
296
+ }
297
+ catch {
298
+ continue; // unreadable: the runtime skips it and keeps the library's
299
+ }
300
+ // Only a definition the runtime accepts replaces the library's; an
301
+ // invalid one is skipped there, and the library tool stays (Codex review).
302
+ const def = parseToolDef(data, m[1], f.content);
303
+ if (def)
304
+ ownTools.add(def.name ?? m[1]);
305
+ }
306
+ const outwardTools = Object.entries(libraryTools(tenant))
307
+ .filter(([n, d]) => d.outward === true && !ownTools.has(n))
308
+ .map(([n]) => n);
309
+ return deployBlockers(files, mode, { accountNetwork: account.network, outwardTools });
310
+ }
311
+ /** The findings `mode` turns into refusals, as deploy issues. */
312
+ export function deployBlockers(files, mode, options = {}) {
313
+ if (mode === "none")
314
+ return [];
315
+ // Every finding checkFiles reports — the scanner's and the structural
316
+ // ones, flow/outward-ungated among them (Codex review) — with the same
317
+ // ignores honoured, kept by its rule's category.
318
+ const categories = new Set(mode === "secrets" ? ["secret"] : ["secret", "content", "security", "supply-chain"]);
319
+ return checkFiles(files, options)
320
+ .filter((f) => f.level !== "info" && categories.has(scanRule(f.rule)?.category ?? ""))
321
+ .map((f) => ({
322
+ where: `${f.file}${f.line ? `:${f.line}` : ""}`,
323
+ message: `${f.message} [${f.rule}] — refused: deploy_block: ${mode} in AGENTS.md`,
324
+ }));
325
+ }
198
326
  export function deployWarnings(files) {
199
327
  const scanned = scanFiles(files, { only: ["secret", "content"] })
200
328
  .filter((f) => f.level !== "info")
@@ -217,11 +345,11 @@ export function checkFiles(files, options = {}) {
217
345
  };
218
346
  const own = [
219
347
  ...deployIssues(files).map((i) => finding(i, "error")),
220
- ...structuralWarnings(files).map((i) => finding(i, "warn", i.kind)),
348
+ ...structuralWarnings(files, options.outwardTools).map((i) => finding(i, "warn", i.kind)),
221
349
  ];
222
350
  return [...filterIgnored(own, files), ...scanFiles(files, options)].sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
223
351
  }
224
- function structuralWarnings(files) {
352
+ function structuralWarnings(files, extraOutward = []) {
225
353
  const front = (content) => {
226
354
  try {
227
355
  return matter(content).data;
@@ -230,8 +358,9 @@ function structuralWarnings(files) {
230
358
  return {};
231
359
  }
232
360
  };
233
- // Workspace tools that change something outside the workspace.
234
- const outwardTools = new Set();
361
+ // Tools that change something outside the workspace: the workspace's own,
362
+ // and the library's the caller resolved (nearest wins already applied).
363
+ const outwardTools = new Set(extraOutward);
235
364
  for (const f of files) {
236
365
  const m = f.path.match(/^tools\/([^/]+?)(?:\/tool)?\.md$/);
237
366
  if (!m)
@@ -242,6 +371,9 @@ function structuralWarnings(files) {
242
371
  // and the CLI use; parseToolDef does not fold it onto the def.
243
372
  if (def && data.outward === true)
244
373
  outwardTools.add(def.name ?? m[1]);
374
+ // A workspace tool that is not outward replaces a library one that was.
375
+ else if (def)
376
+ outwardTools.delete(def.name ?? m[1]);
245
377
  }
246
378
  // Agents that grant one of them.
247
379
  const outwardAgents = [];
@@ -265,6 +397,41 @@ function structuralWarnings(files) {
265
397
  for (const w of webWarnings(data, { chrome, chromium }))
266
398
  out.push({ where: f.path, message: w });
267
399
  }
400
+ // An eval's `matches:` is the same JavaScript RegExp as a verify's; one
401
+ // that does not compile fails its case on every run. Read through
402
+ // parseEval, so a block scalar (`matches: |`) or a continued line is
403
+ // tested as the runtime will run it. The line is the item's own: the
404
+ // parsed assertions and the `- matches:` lines come in the same order.
405
+ for (const f of files) {
406
+ if (!/^evals\/[^/]+\.md$/.test(f.path))
407
+ continue;
408
+ let info;
409
+ try {
410
+ info = parseEval(f.path, f.content);
411
+ }
412
+ catch {
413
+ continue;
414
+ }
415
+ const lines = [];
416
+ f.content.split("\n").forEach((line, n) => { if (/^\s*-\s*matches:/.test(line))
417
+ lines.push(n + 1); });
418
+ // When a `- matches:` line sits outside a case (an example in the
419
+ // prose), the order no longer pairs up: name the file, not a wrong line.
420
+ const parsed = info.cases.reduce((n, c) => n + c.expect.filter((a) => a.type === "matches").length, 0);
421
+ const pairs = lines.length === parsed;
422
+ let k = 0;
423
+ for (const c of info.cases) {
424
+ for (const a of c.expect) {
425
+ if (a.type !== "matches")
426
+ continue;
427
+ const at = pairs ? lines[k] : undefined;
428
+ k++;
429
+ const problem = regexProblem(a.value);
430
+ if (problem)
431
+ out.push({ where: at ? `${f.path}:${at}` : f.path, message: `case "${c.name}": matches: ${problem}` });
432
+ }
433
+ }
434
+ }
268
435
  if (!outwardAgents.length)
269
436
  return out;
270
437
  for (const f of files) {
@@ -311,6 +478,50 @@ export function deployIssues(files) {
311
478
  if (m)
312
479
  agents.add(m[1]);
313
480
  }
481
+ // Frontmatter whose YAML does not parse is a file the runtime cannot read:
482
+ // an agent with no tools and the default model, a flow that never fires,
483
+ // a tool that is skipped. Refused where it was written. Until 20 Oct 2026
484
+ // nothing said so — gray-matter's cache let a second parse pass — and one
485
+ // such agent file kept the worker from booting for every account.
486
+ // Definition files only: a skill is read leniently (an unquoted colon is
487
+ // quoted and retried, matter.ts parseLenient), and the markdown beside a
488
+ // definition — references, a tool's README — is prose, not frontmatter.
489
+ // The scanner warns about skills and library files (config/invalid-frontmatter).
490
+ for (const f of files) {
491
+ if (!STRICT_FRONTMATTER_PATH.test(f.path))
492
+ continue;
493
+ if (!/^\uFEFF?---\r?\n/.test(f.content))
494
+ continue;
495
+ try {
496
+ matter(f.content);
497
+ }
498
+ catch (err) {
499
+ at(f.path, frontmatterErrorText(err));
500
+ }
501
+ }
502
+ // The four job blocks (frontmatter-fields.ts JOB_FIELDS): a setting
503
+ // written both at the top and in its block, or in another job's block, is
504
+ // a setting that does not do what the file says — a guardrail under
505
+ // governance: guards nothing — so the deploy refuses it as check does
506
+ // (config/conflict, config/misplaced; Codex debate, 21 Oct 2026).
507
+ for (const f of files) {
508
+ const kind = schemaKindOf(f.path);
509
+ if (kind !== "agent" && kind !== "flow" && kind !== "agents-md")
510
+ continue;
511
+ if (!/^\uFEFF?---\r?\n/.test(f.content))
512
+ continue;
513
+ let front;
514
+ try {
515
+ front = configMatter(f.content).data;
516
+ }
517
+ catch {
518
+ continue; // unparseable frontmatter is its own report
519
+ }
520
+ for (const p of frontmatterProblems(kind, front, () => null)) {
521
+ if (p.level === "error" && (p.rule === "config/conflict" || p.rule === "config/misplaced"))
522
+ at(f.path, `${p.message} [${p.rule}]`);
523
+ }
524
+ }
314
525
  // `timezone:` is read at four levels and a value nothing can parse falls
315
526
  // silently through to the level above — so it is refused where it was
316
527
  // written, not discovered as a date that is a day out.
@@ -322,7 +533,7 @@ export function deployIssues(files) {
322
533
  at(f.path, unopened);
323
534
  let front = {};
324
535
  try {
325
- front = matter(f.content).data;
536
+ front = configMatter(f.content).data;
326
537
  }
327
538
  catch {
328
539
  continue; // unparseable frontmatter is its own report
@@ -346,6 +557,16 @@ export function deployIssues(files) {
346
557
  // above it — wider or narrower than the author wrote.
347
558
  for (const w of networkProblems(front.network))
348
559
  at(f.path, w);
560
+ // `alerts:` that cannot be read would never alert (FOL-72).
561
+ for (const w of parseAlerts(front.alerts).problems)
562
+ at(f.path, w);
563
+ // `telemetry:` that cannot be read would never send — and a token
564
+ // written into it would ship in the file.
565
+ for (const w of parseTelemetry(front.telemetry).problems)
566
+ at(f.path, w);
567
+ // `judge:` that cannot be read would leave every judge on the default.
568
+ for (const w of parseJudge(front.judge).problems)
569
+ at(f.path, w);
349
570
  const lang = languageProblem(front.language);
350
571
  if (lang)
351
572
  at(f.path, lang);
@@ -489,7 +710,10 @@ export function planDeploy(tenant, workspace, files, opts = {}) {
489
710
  added: added.sort(),
490
711
  updated: updated.sort(),
491
712
  removed: removed.sort(),
492
- issues: deployIssues(files),
713
+ // deploy_block: (FOL-58) turns the scanner's findings into refusals —
714
+ // scanned with what the Checks page passes too: the account's network:
715
+ // and the library's outward tools (Codex review).
716
+ issues: [...deployIssues(files), ...blockersFor(tenant, files)],
493
717
  warnings: deployWarnings(files),
494
718
  blockedBy: runsInFlight(tenant, workspace),
495
719
  };
@@ -1,3 +1,4 @@
1
+ import type { PeerInfo } from "./operator.ts";
1
2
  import type { TestEffect } from "./test-mode.ts";
2
3
  import type { NetworkLevel } from "./network.ts";
3
4
  /** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
@@ -92,14 +93,28 @@ export interface EgressHooks {
92
93
  * policy in test-mode.ts to every request on this lease. Carried on the
93
94
  * lease, not the request, because the pod is the party not trusted to
94
95
  * say so. */
95
- /** `step` — the step's index in run.steps; keys that step's operator inbox. */
96
+ /** `step` — the step's index in run.steps; keys that step's operator inbox.
97
+ * `agent` and `peers` — this step's agent and the other steps of its
98
+ * parallel group (tools: [peers]): the proxy serves the roster and
99
+ * addresses a colleague's message to the right inbox. */
96
100
  lease(args: {
97
101
  tenant: string;
98
102
  runId: string;
99
103
  grant: EgressGrant;
100
104
  test?: boolean;
101
105
  step?: number;
106
+ agent?: string;
107
+ peers?: PeerInfo[];
102
108
  }): Promise<EgressLease | null>;
109
+ /** The step is over — every attempt, however it ended (tools: [peers]).
110
+ * A lease comes and goes per attempt, so it cannot say this: a sibling
111
+ * between retries, or waiting for a slot, holds none and is not done.
112
+ * Optional: a platform without it keeps every sibling reachable. */
113
+ stepEnded?(args: {
114
+ tenant: string;
115
+ runId: string;
116
+ step: number;
117
+ }): Promise<void>;
103
118
  }
104
119
  /** Strip a header set of the proxy's own and hop-by-hop headers. */
105
120
  export declare const HOP_BY_HOP: Set<string>;
@@ -49,8 +49,20 @@ export interface EvalInfo {
49
49
  inputs: boolean;
50
50
  cases: EvalCase[];
51
51
  }
52
- export declare function parseEval(file: string, raw: string): EvalInfo;
52
+ /** `defaults`: the workspace's `judge:` (AGENTS.md), used where the eval
53
+ * names no model or effort of its own. */
54
+ export declare function parseEval(file: string, raw: string, defaults?: {
55
+ model?: string;
56
+ effort?: string;
57
+ }): EvalInfo;
53
58
  export declare function listEvals(tenant: string, workspace: string): EvalInfo[];
59
+ /** `judge:` from AGENTS.md, field by field: the workspace's model (or
60
+ * effort), else the account's — so a workspace that sets only an effort
61
+ * keeps the account's model (Codex debate: the form says so). */
62
+ export declare function judgeDefaults(tenant: string, workspace: string): {
63
+ model?: string;
64
+ effort?: string;
65
+ };
54
66
  /**
55
67
  * Turn a finished run into a regression case.
56
68
  *
@@ -62,10 +74,14 @@ export declare function listEvals(tenant: string, workspace: string): EvalInfo[]
62
74
  * regressions file when there is one, so a flow collects its cases in one
63
75
  * place instead of one file per press.
64
76
  */
65
- export declare function promoteRunToEval(tenant: string, workspace: string, runId: string, opts?: {
77
+ export declare function promoteRunToEval(tenant: string, workspace: string, runId: string,
78
+ /** `should`: what a run that went wrong should have done, in a sentence —
79
+ * the judge's claim for the case (FOL-72 step 10). */
80
+ opts?: {
66
81
  evalName?: string;
67
82
  caseName?: string;
68
83
  expect?: string[];
84
+ should?: string;
69
85
  by?: string;
70
86
  }): {
71
87
  file: string;
@@ -101,6 +117,15 @@ export interface EvalResult {
101
117
  * answers. */
102
118
  error?: string;
103
119
  }
120
+ /**
121
+ * The judge's environment: this process's, with the platform's model
122
+ * credential read live. The process's own CLAUDE_CODE_OAUTH_TOKEN is the copy
123
+ * it was handed at boot; the box refreshes the login every four hours and the
124
+ * old token is then revoked, so a judge on the boot copy failed with "OAuth
125
+ * token revoked" from the first refresh until the pod restarted, while the
126
+ * agent steps (which read the live file) kept working (2026-10-21).
127
+ */
128
+ export declare function judgeProcessEnv(): Record<string, string | undefined>;
104
129
  export declare function runEval(tenant: string, workspace: string, info: EvalInfo): Promise<EvalResult>;
105
130
  /**
106
131
  * The latest result, and one line of history.