@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,48 @@
1
+ export interface RunFilter {
2
+ /** A step called a tool whose name contains this (`web`, `crm_export`). */
3
+ tool?: string;
4
+ /** A tool call errored. */
5
+ toolError?: boolean;
6
+ /** A step was this agent. */
7
+ agent?: string;
8
+ /** A step ran a model whose name contains this (`haiku`, `opus`). */
9
+ model?: string;
10
+ /** The run read at least this many input tokens, all steps together. */
11
+ minTokens?: number;
12
+ /** The run took at least this many seconds. */
13
+ minSecs?: number;
14
+ }
15
+ /** The query parameters a filter comes from: tool, tool_error, agent,
16
+ * model, min_tokens, min_secs. Unreadable numbers are refused in words. */
17
+ export declare function parseRunFilter(q: {
18
+ get(name: string): string | null;
19
+ }): {
20
+ filter: RunFilter;
21
+ problems: string[];
22
+ };
23
+ export declare const isEmptyFilter: (f: RunFilter) => boolean;
24
+ type FilterableStep = {
25
+ agent: string;
26
+ model?: string;
27
+ tools?: string[];
28
+ toolErrors?: number;
29
+ /** A whole record has events instead of the index's tools/toolErrors. */
30
+ events?: {
31
+ type: string;
32
+ text: string;
33
+ ms?: number;
34
+ err?: boolean;
35
+ }[];
36
+ tokens?: {
37
+ input?: number;
38
+ } | null;
39
+ startedAt?: string;
40
+ finishedAt?: string;
41
+ };
42
+ type FilterableRun = {
43
+ startedAt: string;
44
+ finishedAt?: string | null;
45
+ steps: FilterableStep[];
46
+ };
47
+ export declare function matchesRunFilter(run: FilterableRun, f: RunFilter, now?: number): boolean;
48
+ export {};
@@ -0,0 +1,73 @@
1
+ // Search over runs (FOL-72 step 9): one filter for the runs API, the runs
2
+ // pages and `foldrun runs`, read off the run index — the tools each step
3
+ // called, its tool errors, its agent and model, tokens and how long it took.
4
+ // Pure.
5
+ /** The query parameters a filter comes from: tool, tool_error, agent,
6
+ * model, min_tokens, min_secs. Unreadable numbers are refused in words. */
7
+ export function parseRunFilter(q) {
8
+ const filter = {};
9
+ const problems = [];
10
+ const text = (k) => q.get(k)?.trim() || undefined;
11
+ const tool = text("tool");
12
+ if (tool)
13
+ filter.tool = tool.toLowerCase();
14
+ const te = text("tool_error");
15
+ if (te)
16
+ filter.toolError = te === "1" || te === "true" || te === "yes";
17
+ const agent = text("agent");
18
+ if (agent)
19
+ filter.agent = agent;
20
+ const model = text("model");
21
+ if (model)
22
+ filter.model = model.toLowerCase();
23
+ for (const [k, field] of [["min_tokens", "minTokens"], ["min_secs", "minSecs"]]) {
24
+ const v = text(k);
25
+ if (v === undefined)
26
+ continue;
27
+ // "50k" and "1.5k" are thousands (Codex review: "1.5k" read as 1.5).
28
+ const kilo = /^(\d+(?:\.\d+)?)k$/i.exec(v);
29
+ const n = kilo ? Number(kilo[1]) * 1000 : Number(v);
30
+ if (!Number.isFinite(n) || n < 0)
31
+ problems.push(`${k} is a number (${k === "min_tokens" ? "50000 or 50k" : "seconds"}), not ${v}`);
32
+ else
33
+ filter[field] = n;
34
+ }
35
+ return { filter, problems };
36
+ }
37
+ export const isEmptyFilter = (f) => Object.keys(f).length === 0;
38
+ /** The index's facts, or worked out from a whole record's events (Codex
39
+ * review: the workspace runs page reads whole records). */
40
+ function factsOf(s) {
41
+ if (s.tools !== undefined || s.toolErrors !== undefined || !s.events)
42
+ return { tools: s.tools ?? [], toolErrors: s.toolErrors ?? 0 };
43
+ const tools = new Set();
44
+ let errs = 0;
45
+ for (const e of s.events) {
46
+ if (e.type !== "tool")
47
+ continue;
48
+ if (e.ms == null)
49
+ tools.add(e.text);
50
+ else if (e.err)
51
+ errs++;
52
+ }
53
+ return { tools: [...tools], toolErrors: errs };
54
+ }
55
+ export function matchesRunFilter(run, f, now = Date.now()) {
56
+ const steps = (run.steps ?? []).map((s) => ({ ...s, ...factsOf(s) }));
57
+ if (f.tool && !steps.some((s) => (s.tools ?? []).some((t) => t.toLowerCase().includes(f.tool))))
58
+ return false;
59
+ if (f.toolError !== undefined && steps.some((s) => (s.toolErrors ?? 0) > 0) !== f.toolError)
60
+ return false;
61
+ if (f.agent && !steps.some((s) => s.agent === f.agent))
62
+ return false;
63
+ if (f.model && !steps.some((s) => (s.model ?? "").toLowerCase().includes(f.model)))
64
+ return false;
65
+ if (f.minTokens !== undefined && steps.reduce((n, s) => n + (s.tokens?.input ?? 0), 0) < f.minTokens)
66
+ return false;
67
+ if (f.minSecs !== undefined) {
68
+ const secs = ((run.finishedAt ? Date.parse(run.finishedAt) : now) - Date.parse(run.startedAt)) / 1000;
69
+ if (!(secs >= f.minSecs))
70
+ return false;
71
+ }
72
+ return true;
73
+ }
@@ -25,6 +25,10 @@ export interface ScanFinding {
25
25
  message: string;
26
26
  /** Stable across edits elsewhere in the file: rule + file + line text. */
27
27
  fingerprint: string;
28
+ /** This rule had more to say in this file than PER_FILE_RULE: this
29
+ * finding counts the rest, so what is not listed is not resolved. Its
30
+ * fingerprint is of everything it counts, so a change among them is new. */
31
+ truncated?: true;
28
32
  }
29
33
  export interface ScanOptions {
30
34
  /** Tool names whose definition says `outward: true` — the CLI and the
@@ -66,11 +70,36 @@ export declare function redactSecret(secret: string): string;
66
70
  /** The text with every credential in it redacted — for a finding that
67
71
  * quotes source, so no message ever carries a key whole. */
68
72
  export declare function redactText(text: string): string;
73
+ /**
74
+ * Text from outside the workspace — what a remote MCP server says about its
75
+ * tools — through the content rules with nothing the text itself can turn
76
+ * off: no foldrun-ignore, no binary or vendored-path skip, no fences
77
+ * (FOL-58, Codex review: each of those let a server silence its own finding).
78
+ */
79
+ export declare function untrustedTextFindings(text: string): {
80
+ rule: string;
81
+ line: number;
82
+ message: string;
83
+ }[];
69
84
  /** The known field a key was probably meant to be, or null. */
70
85
  export declare function nearestField(key: string, known: readonly string[]): string | null;
86
+ /** What a run leaves in an agent's folder — the Claude CLI's `.cache/` logs,
87
+ * package caches — and copies back with it. Nobody wrote these, so they are
88
+ * not checked as prompts or code; they are still searched for credentials,
89
+ * since a key in a log here is a key in the workspace (seen live on
90
+ * 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
91
+ * text). */
92
+ /** The definition files a deploy refuses outright when their frontmatter does
93
+ * not parse (deploy.ts deployIssues): the workspace's own, by these paths.
94
+ * Everything else the platform reads frontmatter from — skills, and the
95
+ * account library's agents, tools and hooks — is the scanner's to report. */
96
+ export declare const STRICT_FRONTMATTER_PATH: RegExp;
97
+ /** How many times one rule is listed for one file before the rest are counted. */
98
+ export declare const PER_FILE_RULE = 100;
71
99
  /**
72
100
  * Every finding in these files, ignores applied. Findings are sorted by file
73
- * then line, and one rule says one thing once per line.
101
+ * then line, and one rule says one thing once per line, and at most
102
+ * PER_FILE_RULE times in one file.
74
103
  */
75
104
  export declare function scanFiles(files: ScanFile[], options?: ScanOptions): ScanFinding[];
76
105
  /**
package/dist/src/scan.js CHANGED
@@ -26,7 +26,7 @@
26
26
  // The reason is required: an ignore without one ignores nothing and is itself
27
27
  // reported, because "why is this allowed?" is the question a reviewer asks.
28
28
  import crypto from "node:crypto";
29
- import matter from "gray-matter";
29
+ import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, configMatter } from "./matter.js";
30
30
  import { refNames } from "./refs.js";
31
31
  import { resolveNetwork } from "./network.js";
32
32
  import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
@@ -117,6 +117,24 @@ export const SCAN_RULES = [
117
117
  "so a run tomorrow uses what was tested today.",
118
118
  owasp: ["ASI04"],
119
119
  },
120
+ {
121
+ id: "supply-chain/known-vulnerability",
122
+ category: "supply-chain",
123
+ level: "warn",
124
+ title: "A pinned runtime package with known vulnerabilities",
125
+ help: "OSV.dev lists vulnerabilities for this exact version. Upgrade to a fixed one. An online check: `foldrun check --to <ws> --online`, " +
126
+ "Checks → Check online, and the daily rescan.",
127
+ owasp: ["ASI04"],
128
+ },
129
+ {
130
+ id: "content/mcp-tool-text",
131
+ category: "content",
132
+ level: "warn",
133
+ title: "An MCP server's tool text carries hidden or injected instructions",
134
+ help: "What a remote MCP server says about its tools reaches the model as instructions. Hidden characters or an order in a tool " +
135
+ "description is a server steering your agent. An online check, for servers reached by URL; a command server is never run to check it.",
136
+ owasp: ["ASI01", "ASI04"],
137
+ },
120
138
  {
121
139
  id: "config/unknown-field",
122
140
  category: "config",
@@ -141,6 +159,32 @@ export const SCAN_RULES = [
141
159
  help: "`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
142
160
  "so the agent or flow quietly does something else. The message lists what the field takes.",
143
161
  },
162
+ {
163
+ id: "config/conflict",
164
+ category: "config",
165
+ level: "error",
166
+ title: "A field set twice: at the top and inside its job block",
167
+ help: "`limits:` at the top and `limits:` inside `guardrail:` cannot both be meant. The top one would be read and the other " +
168
+ "quietly ignored, so the file is refused until one is removed.",
169
+ },
170
+ {
171
+ id: "config/misplaced",
172
+ category: "config",
173
+ level: "error",
174
+ title: "A field inside the wrong job block",
175
+ help: "Each of the four blocks (governance, guardrail, observability, evaluation) holds its own fields. A setting written in " +
176
+ "another block is not read — a guardrail under governance: guards nothing — so the file is refused until it is moved " +
177
+ "to the block the message names, or to the top. A word foldrun does not read at all is only a note.",
178
+ },
179
+ {
180
+ id: "config/invalid-frontmatter",
181
+ category: "config",
182
+ level: "warn",
183
+ title: "Frontmatter that YAML cannot read",
184
+ help: "A value with a colon followed by a space (`description: the format (Markdown, ::: directives)`) ends the YAML. " +
185
+ "foldrun reads a skill like that by quoting the value, but the dashboard editor and the source API refuse to save it, " +
186
+ "and other tools that read the file may not. Quote the value: `description: \"…\"`.",
187
+ },
144
188
  {
145
189
  id: "config/missing-field",
146
190
  category: "config",
@@ -399,13 +443,15 @@ const INJECTION = [
399
443
  /\b(?:send|post|upload|exfiltrate)\s+(?:all\s+|the\s+|every\s+)?(?:secrets?|credentials|api\s+keys?|tokens?|env(?:ironment)?\s+variables)\s+to\b/i,
400
444
  ];
401
445
  const BLOB = /[A-Za-z0-9+/]{300,}={0,2}/g;
402
- function scanContent(file, lines, push) {
403
- const md = /\.md$/i.test(file.path);
404
- const data = isDataDoc(file.path);
446
+ function scanContent(file, lines, push, untrusted = false) {
447
+ // Untrusted text (a remote server's) is read as data, every line: it
448
+ // cannot fence an order off as an "example" either.
449
+ const md = untrusted || /\.md$/i.test(file.path);
450
+ const data = untrusted || isDataDoc(file.path);
405
451
  let fenced = false;
406
452
  for (let i = 0; i < lines.length; i++) {
407
453
  const line = i === 0 ? lines[i].replace(/^/, "") : lines[i];
408
- if (/^\s*(?:```|~~~)/.test(line))
454
+ if (!untrusted && /^\s*(?:```|~~~)/.test(line))
409
455
  fenced = !fenced;
410
456
  HIDDEN.lastIndex = 0;
411
457
  if (HIDDEN.test(line)) {
@@ -431,6 +477,17 @@ function scanContent(file, lines, push) {
431
477
  }
432
478
  }
433
479
  }
480
+ /**
481
+ * Text from outside the workspace — what a remote MCP server says about its
482
+ * tools — through the content rules with nothing the text itself can turn
483
+ * off: no foldrun-ignore, no binary or vendored-path skip, no fences
484
+ * (FOL-58, Codex review: each of those let a server silence its own finding).
485
+ */
486
+ export function untrustedTextFindings(text) {
487
+ const out = [];
488
+ scanContent({ path: "untrusted", content: text }, text.split(/\r?\n/), (rule, _file, line, _text, message) => out.push({ rule, line: line ?? 0, message }), true);
489
+ return out;
490
+ }
434
491
  /** The text hidden in tag characters: each is U+E0000 + an ASCII code. */
435
492
  function decodeTags(s) {
436
493
  let out = "";
@@ -513,17 +570,35 @@ export function nearestField(key, known) {
513
570
  return bestD <= allowed ? best : null;
514
571
  }
515
572
  function keyLine(lines, key) {
516
- const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`);
517
- const i = lines.findIndex((l) => re.test(l));
518
- return i >= 0 ? i + 1 : undefined;
573
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
574
+ const top = new RegExp(`^${k}\\s*:`);
575
+ const i = lines.findIndex((l) => top.test(l));
576
+ if (i >= 0)
577
+ return i + 1;
578
+ // A key inside one of the four job blocks: indented, under its header.
579
+ const inner = new RegExp(`^\\s+${k}\\s*:`);
580
+ let inBlock = false;
581
+ for (let n = 0; n < lines.length; n++) {
582
+ const l = lines[n];
583
+ if (/^(governance|guardrail|observability|evaluation)\s*:\s*(#.*)?$/.test(l))
584
+ inBlock = true;
585
+ else if (/^\S/.test(l))
586
+ inBlock = false;
587
+ else if (inBlock && inner.test(l))
588
+ return n + 1;
589
+ }
590
+ return undefined;
519
591
  }
520
592
  /** Read-only: gray-matter caches by content, so two files with the same
521
593
  * text share one object, and writing to it would leak between them. */
522
- function frontOf(content) {
594
+ function frontOf(content, filePath) {
523
595
  if (!/^?---\r?\n/.test(content))
524
596
  return null;
525
597
  try {
526
- return matter(content).data;
598
+ // An agent, a flow or AGENTS.md reads its job blocks as flat keys, as
599
+ // the runtime does; every other document is read as written.
600
+ const kind = filePath ? schemaKindOf(filePath) : null;
601
+ return (kind === "agent" || kind === "flow" || kind === "agents-md" ? configMatter(content) : matter(content)).data;
527
602
  }
528
603
  catch {
529
604
  return null; // a frontmatter YAML cannot read is check's finding, not ours
@@ -652,14 +727,25 @@ const VENDORED = /(?:^|\/)(?:node_modules|\.venv|venv|site-packages|__pycache__|
652
727
  * since a key in a log here is a key in the workspace (seen live on
653
728
  * 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
654
729
  * text). */
730
+ /** The definition files a deploy refuses outright when their frontmatter does
731
+ * not parse (deploy.ts deployIssues): the workspace's own, by these paths.
732
+ * Everything else the platform reads frontmatter from — skills, and the
733
+ * account library's agents, tools and hooks — is the scanner's to report. */
734
+ export const STRICT_FRONTMATTER_PATH = /^(AGENTS\.md|agents\/[^/]+\/agent\.md|flows\/[^/]+\.md|tools\/[^/]+\.md|tools\/[^/]+\/tool\.md|evals\/[^/]+\.md|hooks\/[^/]+\/hook\.md|\.claude\/agents\/[^/]+\.md)$/;
735
+ /** A skill file in either layout the runtime discovers: <name>/SKILL.md, or
736
+ * the flat <name>.md, under skills/ or .agents/skills/, in the library too. */
737
+ const SKILL_FILE = /(?:^|\/)skills\/(?:[^/]+\/SKILL\.md|[^/]+\.md)$/;
655
738
  const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.foldrun)\//;
656
739
  /** Text files only — a PNG read as UTF-8 is noise to every rule here. */
657
740
  function isText(f) {
658
741
  return !VENDORED.test(f.path) && !f.content.slice(0, 8000).includes("\u0000") && !/\.(?:png|jpe?g|gif|webp|ico|pdf|zip|gz|woff2?|ttf|mp[34]|mov|wasm)$/i.test(f.path);
659
742
  }
743
+ /** How many times one rule is listed for one file before the rest are counted. */
744
+ export const PER_FILE_RULE = 100;
660
745
  /**
661
746
  * Every finding in these files, ignores applied. Findings are sorted by file
662
- * then line, and one rule says one thing once per line.
747
+ * then line, and one rule says one thing once per line, and at most
748
+ * PER_FILE_RULE times in one file.
663
749
  */
664
750
  export function scanFiles(files, options = {}) {
665
751
  const want = options.only?.length ? new Set(options.only) : null;
@@ -667,6 +753,11 @@ export function scanFiles(files, options = {}) {
667
753
  const findings = [];
668
754
  const seen = new Set();
669
755
  const ignoresByFile = new Map();
756
+ // One rule in one file says it PER_FILE_RULE times, then counts the rest:
757
+ // a million lines of hidden characters is one problem, not a million
758
+ // findings held in memory (Codex review of the 20 Oct rescan outage).
759
+ const perFileRule = new Map();
760
+ const more = new Map();
670
761
  const push = (rule, file, line, text, message, level) => {
671
762
  if (!enabled(rule))
672
763
  return;
@@ -676,6 +767,16 @@ export function scanFiles(files, options = {}) {
676
767
  const key = `${rule}\0${file}\0${line ?? ""}\0${message}`;
677
768
  if (seen.has(key))
678
769
  return;
770
+ const rf = `${rule}\0${file}`;
771
+ const said = perFileRule.get(rf) ?? 0;
772
+ if (said >= PER_FILE_RULE) {
773
+ const m = more.get(rf) ?? { rule, file, level, n: 0, hash: crypto.createHash("sha256") };
774
+ m.n++;
775
+ m.hash.update(`${findingFingerprint(rule, file, text)}\0`);
776
+ more.set(rf, m);
777
+ return;
778
+ }
779
+ perFileRule.set(rf, said + 1);
679
780
  seen.add(key);
680
781
  findings.push({ rule, level: level ?? scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
681
782
  };
@@ -691,9 +792,35 @@ export function scanFiles(files, options = {}) {
691
792
  scanContent(f, lines, push);
692
793
  scanSupplyChain(f, lines, push);
693
794
  const schema = schemaKindOf(f.path);
795
+ // Frontmatter YAML cannot read. Until 2026-10-21 nothing reported it for
796
+ // a skill or a library file: frontOf below reads it as "no frontmatter"
797
+ // and moves on, and the deploy's own refusal covers only the workspace's
798
+ // definition files. A vaza.ai skill with `description: … (Markdown,
799
+ // frontmatter, ::: directives) …` deployed clean, ran (the runtime quotes
800
+ // the value and retries), and was refused by the source API on its next
801
+ // save.
802
+ if ((schema || SKILL_FILE.test(f.path)) && !STRICT_FRONTMATTER_PATH.test(f.path) && /^\uFEFF?---\r?\n/.test(f.content)) {
803
+ let err = null;
804
+ try {
805
+ matter(f.content);
806
+ }
807
+ catch (e) {
808
+ err = e;
809
+ }
810
+ if (err) {
811
+ const isSkill = schema === "skill" || SKILL_FILE.test(f.path);
812
+ const read = parseLenient(f.content);
813
+ const what = isSkill && !read.failed
814
+ ? "foldrun reads this skill anyway by quoting the value, but the dashboard editor and the source API refuse to save it, and other skills clients may not load it"
815
+ : isSkill
816
+ ? "the runtime reads it as empty, so the skill has no name or description and is never loaded"
817
+ : "the runtime cannot read this file";
818
+ push("config/invalid-frontmatter", f.path, frontmatterErrorLine(err), `config/invalid-frontmatter ${frontmatterErrorText(err)}`, `${frontmatterErrorText(err)} ${what === "the runtime cannot read this file" ? "The runtime cannot read this file" : what.replace(/^foldrun/, "foldrun").replace(/^the/, "The")}.`);
819
+ }
820
+ }
694
821
  // A skill with no frontmatter at all is the clearest one that never
695
822
  // loads: read it as empty, so its required fields are reported.
696
- const data = schema || docKind(f.path) ? (frontOf(f.content) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
823
+ const data = schema || docKind(f.path) ? (frontOf(f.content, f.path) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
697
824
  if (!data)
698
825
  continue;
699
826
  if (schema) {
@@ -719,8 +846,12 @@ export function scanFiles(files, options = {}) {
719
846
  toolDefs.set(f.path, { data, lines });
720
847
  }
721
848
  }
722
- const workspaceMd = files.find((f) => f.path === "AGENTS.md");
723
- scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
849
+ // project.md is the older name the runtime still reads (Codex review, FOL-58).
850
+ const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
851
+ scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
852
+ for (const m of more.values()) {
853
+ findings.push({ rule: m.rule, level: m.level ?? scanRule(m.rule)?.level ?? "warn", file: m.file, message: `and ${m.n} more like this in this file, not listed`, fingerprint: m.hash.digest("hex").slice(0, 16), truncated: true });
854
+ }
724
855
  return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
725
856
  }
726
857
  /**
@@ -55,6 +55,22 @@ foldrun_version: "0.1"
55
55
  #
56
56
  # The token is sent as a bearer credential. A gateway that wants it as a key
57
57
  # header asks for that by name: headers: { x-api-key: \${THE_SECRET} }.
58
+ #
59
+ # The four jobs of running agents, each a block — or write the same keys at
60
+ # the top; one place or the other, never both:
61
+ #
62
+ # governance: # who answers for it, what it may spend and ship
63
+ # owner: you@example.com
64
+ # budget: 60/month
65
+ # deploy_block: secrets
66
+ # guardrail: # what agents may reach and call
67
+ # limits: {calls: 300, repeat: 5}
68
+ # network: trusted
69
+ # observability: # what is recorded, and who is told
70
+ # notify: {email: you@example.com, events: [failed]}
71
+ # alerts: {failure_rate: 30%}
72
+ # evaluation: # how work is judged
73
+ # judge: {model: fast}
58
74
  ---
59
75
 
60
76
  # ${account}
@@ -357,7 +373,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
357
373
  | \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
358
374
 
359
375
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
360
- \`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
376
+ \`/api/approvals\`, \`/api/issues\` (what went wrong, open until resolved), \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
361
377
  \`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
362
378
  \`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
363
379
  \`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
@@ -391,6 +407,7 @@ subflow), \`foldrun flow rm-step\` removes a step, and \`foldrun agent link
391
407
  trigger: manual # manual | schedule | webhook
392
408
  schedule: "0 9 * * 1" # cron, when trigger: schedule
393
409
  timezone: Australia/Sydney
410
+ owner: ana@example.com # who answers for it; else the workspace's, else the account's
394
411
  model: default # a default for every step here
395
412
  effort: low
396
413
  overlap: skip # skip | queue — what a second run does while one is live
@@ -448,10 +465,11 @@ by a model: what this role is for, what it must not do, what good looks like.
448
465
  ---
449
466
  name: researcher
450
467
  description: Finds and summarises sources.
468
+ owner: ana@example.com # who answers for it; else the workspace's, else the account's
451
469
  model: default # fast | default | max
452
470
  effort: low # how hard to think about it
453
471
  size: large # small | large | heavy — the sandbox it rents
454
- tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
472
+ tools: [web, read, ask, peers, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step, \`peers\` lets it message the steps running beside it
455
473
  disallowedTools: [Bash] # subtract from what it would otherwise have
456
474
  skills: [house-style] # from skills/
457
475
  hooks: [no-force-push] # from hooks/ or the library: code at every tool call
@@ -474,6 +492,13 @@ runtime: # only if scripts need packages
474
492
  You research a topic and report what you found...
475
493
  \`\`\`
476
494
 
495
+ The fields that do one of the four jobs of running agents may be grouped
496
+ under it — \`governance:\` (owner, budget), \`guardrail:\` (limits, network,
497
+ hooks, disallowedTools, permissionMode, secrets), and in AGENTS.md also
498
+ \`observability:\` (alerts, telemetry, notify, capture) and \`evaluation:\`
499
+ (judge). A block reads exactly as the same keys at the top; a key written
500
+ both ways is refused (\`config/conflict\`). \`foldrun docs four-jobs\`.
501
+
477
502
  Only \`name\` and the prose are required. Every other field widens or narrows
478
503
  what the agent can reach, and the default is narrow.
479
504
 
@@ -1,6 +1,6 @@
1
1
  import { type HookDef, type HookRecord } from "./hooks.ts";
2
2
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
3
- import type { Effort } from "./store.ts";
3
+ import { type Effort, type ModelCall } from "./store.ts";
4
4
  import { type TokenPrice } from "./catalog.ts";
5
5
  import type { TestEffect } from "./test-mode.ts";
6
6
  import type { OperatorEvent } from "./operator.ts";
@@ -57,6 +57,11 @@ export interface ExecOutcome {
57
57
  browserPod?: BrowserPodOutcome;
58
58
  }
59
59
  export interface ExecOptions {
60
+ /** What the step reports beyond its events (FOL-54 step 4): `metadata`
61
+ * sends each model call (`turn`); `full` also sends each tool call's input
62
+ * and output (`io`). Unset sends neither — the consult, the probe and any
63
+ * caller that does not store them. */
64
+ capture?: "metadata" | "full";
60
65
  /** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
61
66
  * inside its sandbox. `hookBase` is what every hook event carries. */
62
67
  hooks?: HookDef[];
@@ -99,6 +104,11 @@ export interface ExecOptions {
99
104
  * `matches: re`, `file: path`, `judge: sentence`) — see checkVerify. */
100
105
  verify?: string;
101
106
  verifyEnv?: Record<string, string>;
107
+ /** The model `verify: judge:` grades with — the workspace's `judge:` from
108
+ * AGENTS.md, resolved; absent, the fast tier. */
109
+ judgeModel?: string;
110
+ /** And how long it thinks — `judge: {effort}`; absent, the SDK's default. */
111
+ judgeEffort?: Effort;
102
112
  /** `output: json` — the reply must carry one JSON value; extracting it is
103
113
  * part of finishing the step, and failing to is failing the step. */
104
114
  output?: "json";
@@ -175,7 +185,30 @@ export type EventExtra = {
175
185
  subagent?: string;
176
186
  check?: boolean;
177
187
  hook?: HookRecord;
188
+ /** A tool call's content, on its way to the runner (FOL-54 step 4): the
189
+ * runner masks it, stores it beside the run and keeps only `captured` on
190
+ * the event. Sent only under `capture: "full"`. */
191
+ io?: CallIo;
192
+ /** One model call, on its way to the runner's `step.calls` — not an event. */
193
+ turn?: ModelCall;
194
+ /** The model's first response of the step has begun streaming — the
195
+ * moment the prompt cache entry its request wrote becomes readable. Sent
196
+ * once; the runner's fan-out gate reads it. Not an event. */
197
+ responding?: true;
198
+ /** Set by the runner once it stored `io` (RunEvent.captured). */
199
+ captured?: boolean;
200
+ /** On a tool call: the model call (its message id) that asked for it. */
201
+ inCall?: string;
178
202
  };
203
+ /** A tool call's input (on the call) or output (on its completion), capped. */
204
+ export interface CallIo {
205
+ input?: string;
206
+ output?: string;
207
+ /** The whole input's / output's length, in characters, before any cut. */
208
+ inputChars?: number;
209
+ outputChars?: number;
210
+ truncated?: boolean;
211
+ }
179
212
  /** Conservative per-token rates for a model nothing else can price — an
180
213
  * unknown id on a gateway with no catalogue. Opus-class, so a ceiling
181
214
  * still means something. Only ever reached when `knownPrice` below does
@@ -273,14 +306,17 @@ export declare function checkVerify(agentDir: string, verify: string, ctx: {
273
306
  /** The step's own model environment, for `judge:` — it grades on the
274
307
  * fast tier through the same credential the step rode. */
275
308
  modelEnv?: Record<string, string | undefined>;
309
+ /** The judge's model and effort (the workspace's `judge:`). */
310
+ judgeModel?: string;
311
+ judgeEffort?: Effort;
276
312
  /** Ends a shell check or a judge mid-way: the step's clock ran out, or a
277
313
  * person asked the run to stop. */
278
314
  signal?: AbortSignal;
279
315
  }): Promise<VerifyVerdict>;
280
316
  /**
281
317
  * A toolless, fast-tier grading call: does the reply satisfy the sentence?
282
- * Same shape as the eval judge, and answered with one word first so the
283
- * verdict is a prefix test rather than a reading.
318
+ * Same shape as the eval judge: reasoning first, then the verdict alone on
319
+ * the last line (judge-verdict.ts says why).
284
320
  */
285
321
  export declare function judgePrompt(rubric: string, reply: string, work: string): string;
286
322
  /**