@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,88 @@
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
+
6
+ export interface RunFilter {
7
+ /** A step called a tool whose name contains this (`web`, `crm_export`). */
8
+ tool?: string;
9
+ /** A tool call errored. */
10
+ toolError?: boolean;
11
+ /** A step was this agent. */
12
+ agent?: string;
13
+ /** A step ran a model whose name contains this (`haiku`, `opus`). */
14
+ model?: string;
15
+ /** The run read at least this many input tokens, all steps together. */
16
+ minTokens?: number;
17
+ /** The run took at least this many seconds. */
18
+ minSecs?: number;
19
+ }
20
+
21
+ /** The query parameters a filter comes from: tool, tool_error, agent,
22
+ * model, min_tokens, min_secs. Unreadable numbers are refused in words. */
23
+ export function parseRunFilter(q: { get(name: string): string | null }): { filter: RunFilter; problems: string[] } {
24
+ const filter: RunFilter = {};
25
+ const problems: string[] = [];
26
+ const text = (k: string) => q.get(k)?.trim() || undefined;
27
+ const tool = text("tool");
28
+ if (tool) filter.tool = tool.toLowerCase();
29
+ const te = text("tool_error");
30
+ if (te) filter.toolError = te === "1" || te === "true" || te === "yes";
31
+ const agent = text("agent");
32
+ if (agent) filter.agent = agent;
33
+ const model = text("model");
34
+ if (model) filter.model = model.toLowerCase();
35
+ for (const [k, field] of [["min_tokens", "minTokens"], ["min_secs", "minSecs"]] as const) {
36
+ const v = text(k);
37
+ if (v === undefined) continue;
38
+ // "50k" and "1.5k" are thousands (Codex review: "1.5k" read as 1.5).
39
+ const kilo = /^(\d+(?:\.\d+)?)k$/i.exec(v);
40
+ const n = kilo ? Number(kilo[1]) * 1000 : Number(v);
41
+ if (!Number.isFinite(n) || n < 0) problems.push(`${k} is a number (${k === "min_tokens" ? "50000 or 50k" : "seconds"}), not ${v}`);
42
+ else filter[field] = n;
43
+ }
44
+ return { filter, problems };
45
+ }
46
+
47
+ export const isEmptyFilter = (f: RunFilter) => Object.keys(f).length === 0;
48
+
49
+ type FilterableStep = {
50
+ agent: string;
51
+ model?: string;
52
+ tools?: string[];
53
+ toolErrors?: number;
54
+ /** A whole record has events instead of the index's tools/toolErrors. */
55
+ events?: { type: string; text: string; ms?: number; err?: boolean }[];
56
+ tokens?: { input?: number } | null;
57
+ startedAt?: string;
58
+ finishedAt?: string;
59
+ };
60
+ type FilterableRun = { startedAt: string; finishedAt?: string | null; steps: FilterableStep[] };
61
+
62
+ /** The index's facts, or worked out from a whole record's events (Codex
63
+ * review: the workspace runs page reads whole records). */
64
+ function factsOf(s: FilterableStep): { tools: string[]; toolErrors: number } {
65
+ if (s.tools !== undefined || s.toolErrors !== undefined || !s.events) return { tools: s.tools ?? [], toolErrors: s.toolErrors ?? 0 };
66
+ const tools = new Set<string>();
67
+ let errs = 0;
68
+ for (const e of s.events) {
69
+ if (e.type !== "tool") continue;
70
+ if (e.ms == null) tools.add(e.text);
71
+ else if (e.err) errs++;
72
+ }
73
+ return { tools: [...tools], toolErrors: errs };
74
+ }
75
+
76
+ export function matchesRunFilter(run: FilterableRun, f: RunFilter, now = Date.now()): boolean {
77
+ const steps = (run.steps ?? []).map((s) => ({ ...s, ...factsOf(s) }));
78
+ if (f.tool && !steps.some((s) => (s.tools ?? []).some((t) => t.toLowerCase().includes(f.tool!)))) return false;
79
+ if (f.toolError !== undefined && steps.some((s) => (s.toolErrors ?? 0) > 0) !== f.toolError) return false;
80
+ if (f.agent && !steps.some((s) => s.agent === f.agent)) return false;
81
+ if (f.model && !steps.some((s) => (s.model ?? "").toLowerCase().includes(f.model!))) return false;
82
+ if (f.minTokens !== undefined && steps.reduce((n, s) => n + (s.tokens?.input ?? 0), 0) < f.minTokens) return false;
83
+ if (f.minSecs !== undefined) {
84
+ const secs = ((run.finishedAt ? Date.parse(run.finishedAt) : now) - Date.parse(run.startedAt)) / 1000;
85
+ if (!(secs >= f.minSecs)) return false;
86
+ }
87
+ return true;
88
+ }
package/src/scan.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  // reported, because "why is this allowed?" is the question a reviewer asks.
28
28
 
29
29
  import crypto from "node:crypto";
30
- import matter from "gray-matter";
30
+ import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, configMatter } from "./matter.ts";
31
31
  import { refNames } from "./refs.ts";
32
32
  import { resolveNetwork } from "./network.ts";
33
33
  import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
@@ -62,6 +62,10 @@ export interface ScanFinding {
62
62
  message: string;
63
63
  /** Stable across edits elsewhere in the file: rule + file + line text. */
64
64
  fingerprint: string;
65
+ /** This rule had more to say in this file than PER_FILE_RULE: this
66
+ * finding counts the rest, so what is not listed is not resolved. Its
67
+ * fingerprint is of everything it counts, so a change among them is new. */
68
+ truncated?: true;
65
69
  }
66
70
 
67
71
  export interface ScanOptions {
@@ -173,6 +177,26 @@ export const SCAN_RULES: readonly ScanRule[] = [
173
177
  "so a run tomorrow uses what was tested today.",
174
178
  owasp: ["ASI04"],
175
179
  },
180
+ {
181
+ id: "supply-chain/known-vulnerability",
182
+ category: "supply-chain",
183
+ level: "warn",
184
+ title: "A pinned runtime package with known vulnerabilities",
185
+ help:
186
+ "OSV.dev lists vulnerabilities for this exact version. Upgrade to a fixed one. An online check: `foldrun check --to <ws> --online`, " +
187
+ "Checks → Check online, and the daily rescan.",
188
+ owasp: ["ASI04"],
189
+ },
190
+ {
191
+ id: "content/mcp-tool-text",
192
+ category: "content",
193
+ level: "warn",
194
+ title: "An MCP server's tool text carries hidden or injected instructions",
195
+ help:
196
+ "What a remote MCP server says about its tools reaches the model as instructions. Hidden characters or an order in a tool " +
197
+ "description is a server steering your agent. An online check, for servers reached by URL; a command server is never run to check it.",
198
+ owasp: ["ASI01", "ASI04"],
199
+ },
176
200
  {
177
201
  id: "config/unknown-field",
178
202
  category: "config",
@@ -199,6 +223,35 @@ export const SCAN_RULES: readonly ScanRule[] = [
199
223
  "`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
200
224
  "so the agent or flow quietly does something else. The message lists what the field takes.",
201
225
  },
226
+ {
227
+ id: "config/conflict",
228
+ category: "config",
229
+ level: "error",
230
+ title: "A field set twice: at the top and inside its job block",
231
+ help:
232
+ "`limits:` at the top and `limits:` inside `guardrail:` cannot both be meant. The top one would be read and the other " +
233
+ "quietly ignored, so the file is refused until one is removed.",
234
+ },
235
+ {
236
+ id: "config/misplaced",
237
+ category: "config",
238
+ level: "error",
239
+ title: "A field inside the wrong job block",
240
+ help:
241
+ "Each of the four blocks (governance, guardrail, observability, evaluation) holds its own fields. A setting written in " +
242
+ "another block is not read — a guardrail under governance: guards nothing — so the file is refused until it is moved " +
243
+ "to the block the message names, or to the top. A word foldrun does not read at all is only a note.",
244
+ },
245
+ {
246
+ id: "config/invalid-frontmatter",
247
+ category: "config",
248
+ level: "warn",
249
+ title: "Frontmatter that YAML cannot read",
250
+ help:
251
+ "A value with a colon followed by a space (`description: the format (Markdown, ::: directives)`) ends the YAML. " +
252
+ "foldrun reads a skill like that by quoting the value, but the dashboard editor and the source API refuse to save it, " +
253
+ "and other tools that read the file may not. Quote the value: `description: \"…\"`.",
254
+ },
202
255
  {
203
256
  id: "config/missing-field",
204
257
  category: "config",
@@ -451,13 +504,15 @@ const INJECTION: RegExp[] = [
451
504
 
452
505
  const BLOB = /[A-Za-z0-9+/]{300,}={0,2}/g;
453
506
 
454
- function scanContent(file: ScanFile, lines: string[], push: Push) {
455
- const md = /\.md$/i.test(file.path);
456
- const data = isDataDoc(file.path);
507
+ function scanContent(file: ScanFile, lines: string[], push: Push, untrusted = false) {
508
+ // Untrusted text (a remote server's) is read as data, every line: it
509
+ // cannot fence an order off as an "example" either.
510
+ const md = untrusted || /\.md$/i.test(file.path);
511
+ const data = untrusted || isDataDoc(file.path);
457
512
  let fenced = false;
458
513
  for (let i = 0; i < lines.length; i++) {
459
514
  const line = i === 0 ? lines[i]!.replace(/^/, "") : lines[i]!;
460
- if (/^\s*(?:```|~~~)/.test(line)) fenced = !fenced;
515
+ if (!untrusted && /^\s*(?:```|~~~)/.test(line)) fenced = !fenced;
461
516
  HIDDEN.lastIndex = 0;
462
517
  if (HIDDEN.test(line)) {
463
518
  const kind = HIDDEN_NAMES.find(([re]) => re.test(line))?.[1] ?? "invisible characters";
@@ -481,6 +536,18 @@ function scanContent(file: ScanFile, lines: string[], push: Push) {
481
536
  }
482
537
  }
483
538
 
539
+ /**
540
+ * Text from outside the workspace — what a remote MCP server says about its
541
+ * tools — through the content rules with nothing the text itself can turn
542
+ * off: no foldrun-ignore, no binary or vendored-path skip, no fences
543
+ * (FOL-58, Codex review: each of those let a server silence its own finding).
544
+ */
545
+ export function untrustedTextFindings(text: string): { rule: string; line: number; message: string }[] {
546
+ const out: { rule: string; line: number; message: string }[] = [];
547
+ scanContent({ path: "untrusted", content: text }, text.split(/\r?\n/), (rule, _file, line, _text, message) => out.push({ rule, line: line ?? 0, message }), true);
548
+ return out;
549
+ }
550
+
484
551
  /** The text hidden in tag characters: each is U+E0000 + an ASCII code. */
485
552
  function decodeTags(s: string): string {
486
553
  let out = "";
@@ -563,17 +630,31 @@ export function nearestField(key: string, known: readonly string[]): string | nu
563
630
  }
564
631
 
565
632
  function keyLine(lines: string[], key: string): number | undefined {
566
- const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`);
567
- const i = lines.findIndex((l) => re.test(l));
568
- return i >= 0 ? i + 1 : undefined;
633
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
634
+ const top = new RegExp(`^${k}\\s*:`);
635
+ const i = lines.findIndex((l) => top.test(l));
636
+ if (i >= 0) return i + 1;
637
+ // A key inside one of the four job blocks: indented, under its header.
638
+ const inner = new RegExp(`^\\s+${k}\\s*:`);
639
+ let inBlock = false;
640
+ for (let n = 0; n < lines.length; n++) {
641
+ const l = lines[n]!;
642
+ if (/^(governance|guardrail|observability|evaluation)\s*:\s*(#.*)?$/.test(l)) inBlock = true;
643
+ else if (/^\S/.test(l)) inBlock = false;
644
+ else if (inBlock && inner.test(l)) return n + 1;
645
+ }
646
+ return undefined;
569
647
  }
570
648
 
571
649
  /** Read-only: gray-matter caches by content, so two files with the same
572
650
  * text share one object, and writing to it would leak between them. */
573
- function frontOf(content: string): Record<string, unknown> | null {
651
+ function frontOf(content: string, filePath?: string): Record<string, unknown> | null {
574
652
  if (!/^?---\r?\n/.test(content)) return null;
575
653
  try {
576
- return matter(content).data as Record<string, unknown>;
654
+ // An agent, a flow or AGENTS.md reads its job blocks as flat keys, as
655
+ // the runtime does; every other document is read as written.
656
+ const kind = filePath ? schemaKindOf(filePath) : null;
657
+ return (kind === "agent" || kind === "flow" || kind === "agents-md" ? configMatter(content) : matter(content)).data as Record<string, unknown>;
577
658
  } catch {
578
659
  return null; // a frontmatter YAML cannot read is check's finding, not ours
579
660
  }
@@ -735,6 +816,16 @@ const VENDORED = /(?:^|\/)(?:node_modules|\.venv|venv|site-packages|__pycache__|
735
816
  * since a key in a log here is a key in the workspace (seen live on
736
817
  * 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
737
818
  * text). */
819
+ /** The definition files a deploy refuses outright when their frontmatter does
820
+ * not parse (deploy.ts deployIssues): the workspace's own, by these paths.
821
+ * Everything else the platform reads frontmatter from — skills, and the
822
+ * account library's agents, tools and hooks — is the scanner's to report. */
823
+ 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)$/;
824
+
825
+ /** A skill file in either layout the runtime discovers: <name>/SKILL.md, or
826
+ * the flat <name>.md, under skills/ or .agents/skills/, in the library too. */
827
+ const SKILL_FILE = /(?:^|\/)skills\/(?:[^/]+\/SKILL\.md|[^/]+\.md)$/;
828
+
738
829
  const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.foldrun)\//;
739
830
 
740
831
  /** Text files only — a PNG read as UTF-8 is noise to every rule here. */
@@ -742,9 +833,13 @@ function isText(f: ScanFile): boolean {
742
833
  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);
743
834
  }
744
835
 
836
+ /** How many times one rule is listed for one file before the rest are counted. */
837
+ export const PER_FILE_RULE = 100;
838
+
745
839
  /**
746
840
  * Every finding in these files, ignores applied. Findings are sorted by file
747
- * then line, and one rule says one thing once per line.
841
+ * then line, and one rule says one thing once per line, and at most
842
+ * PER_FILE_RULE times in one file.
748
843
  */
749
844
  export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFinding[] {
750
845
  const want = options.only?.length ? new Set(options.only) : null;
@@ -752,6 +847,11 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
752
847
  const findings: ScanFinding[] = [];
753
848
  const seen = new Set<string>();
754
849
  const ignoresByFile = new Map<string, Ignores>();
850
+ // One rule in one file says it PER_FILE_RULE times, then counts the rest:
851
+ // a million lines of hidden characters is one problem, not a million
852
+ // findings held in memory (Codex review of the 20 Oct rescan outage).
853
+ const perFileRule = new Map<string, number>();
854
+ const more = new Map<string, { rule: string; file: string; level?: ScanFinding["level"]; n: number; hash: crypto.Hash }>();
755
855
 
756
856
  const push: Push = (rule, file, line, text, message, level) => {
757
857
  if (!enabled(rule)) return;
@@ -759,6 +859,16 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
759
859
  if (ig && ignored(ig, rule, line)) return;
760
860
  const key = `${rule}\0${file}\0${line ?? ""}\0${message}`;
761
861
  if (seen.has(key)) return;
862
+ const rf = `${rule}\0${file}`;
863
+ const said = perFileRule.get(rf) ?? 0;
864
+ if (said >= PER_FILE_RULE) {
865
+ const m = more.get(rf) ?? { rule, file, level, n: 0, hash: crypto.createHash("sha256") };
866
+ m.n++;
867
+ m.hash.update(`${findingFingerprint(rule, file, text)}\0`);
868
+ more.set(rf, m);
869
+ return;
870
+ }
871
+ perFileRule.set(rf, said + 1);
762
872
  seen.add(key);
763
873
  findings.push({ rule, level: level ?? scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
764
874
  };
@@ -776,9 +886,34 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
776
886
  scanSupplyChain(f, lines, push);
777
887
 
778
888
  const schema = schemaKindOf(f.path);
889
+ // Frontmatter YAML cannot read. Until 2026-10-21 nothing reported it for
890
+ // a skill or a library file: frontOf below reads it as "no frontmatter"
891
+ // and moves on, and the deploy's own refusal covers only the workspace's
892
+ // definition files. A vaza.ai skill with `description: … (Markdown,
893
+ // frontmatter, ::: directives) …` deployed clean, ran (the runtime quotes
894
+ // the value and retries), and was refused by the source API on its next
895
+ // save.
896
+ if ((schema || SKILL_FILE.test(f.path)) && !STRICT_FRONTMATTER_PATH.test(f.path) && /^\uFEFF?---\r?\n/.test(f.content)) {
897
+ let err: unknown = null;
898
+ try {
899
+ matter(f.content);
900
+ } catch (e) {
901
+ err = e;
902
+ }
903
+ if (err) {
904
+ const isSkill = schema === "skill" || SKILL_FILE.test(f.path);
905
+ const read = parseLenient(f.content);
906
+ const what = isSkill && !read.failed
907
+ ? "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"
908
+ : isSkill
909
+ ? "the runtime reads it as empty, so the skill has no name or description and is never loaded"
910
+ : "the runtime cannot read this file";
911
+ 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")}.`);
912
+ }
913
+ }
779
914
  // A skill with no frontmatter at all is the clearest one that never
780
915
  // loads: read it as empty, so its required fields are reported.
781
- const data = schema || docKind(f.path) ? (frontOf(f.content) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
916
+ const data = schema || docKind(f.path) ? (frontOf(f.content, f.path) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
782
917
  if (!data) continue;
783
918
  if (schema) {
784
919
  for (const p of frontmatterProblems(schema, data, nearestField)) {
@@ -800,8 +935,12 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
800
935
  toolDefs.set(f.path, { data, lines });
801
936
  }
802
937
  }
803
- const workspaceMd = files.find((f) => f.path === "AGENTS.md");
804
- scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
938
+ // project.md is the older name the runtime still reads (Codex review, FOL-58).
939
+ const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
940
+ scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
941
+ for (const m of more.values()) {
942
+ 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 });
943
+ }
805
944
 
806
945
  return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
807
946
  }
package/src/starter.ts CHANGED
@@ -62,6 +62,22 @@ foldrun_version: "0.1"
62
62
  #
63
63
  # The token is sent as a bearer credential. A gateway that wants it as a key
64
64
  # header asks for that by name: headers: { x-api-key: \${THE_SECRET} }.
65
+ #
66
+ # The four jobs of running agents, each a block — or write the same keys at
67
+ # the top; one place or the other, never both:
68
+ #
69
+ # governance: # who answers for it, what it may spend and ship
70
+ # owner: you@example.com
71
+ # budget: 60/month
72
+ # deploy_block: secrets
73
+ # guardrail: # what agents may reach and call
74
+ # limits: {calls: 300, repeat: 5}
75
+ # network: trusted
76
+ # observability: # what is recorded, and who is told
77
+ # notify: {email: you@example.com, events: [failed]}
78
+ # alerts: {failure_rate: 30%}
79
+ # evaluation: # how work is judged
80
+ # judge: {model: fast}
65
81
  ---
66
82
 
67
83
  # ${account}
@@ -373,7 +389,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
373
389
  | \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
374
390
 
375
391
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
376
- \`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
392
+ \`/api/approvals\`, \`/api/issues\` (what went wrong, open until resolved), \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
377
393
  \`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
378
394
  \`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
379
395
  \`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
@@ -407,6 +423,7 @@ subflow), \`foldrun flow rm-step\` removes a step, and \`foldrun agent link
407
423
  trigger: manual # manual | schedule | webhook
408
424
  schedule: "0 9 * * 1" # cron, when trigger: schedule
409
425
  timezone: Australia/Sydney
426
+ owner: ana@example.com # who answers for it; else the workspace's, else the account's
410
427
  model: default # a default for every step here
411
428
  effort: low
412
429
  overlap: skip # skip | queue — what a second run does while one is live
@@ -464,10 +481,11 @@ by a model: what this role is for, what it must not do, what good looks like.
464
481
  ---
465
482
  name: researcher
466
483
  description: Finds and summarises sources.
484
+ owner: ana@example.com # who answers for it; else the workspace's, else the account's
467
485
  model: default # fast | default | max
468
486
  effort: low # how hard to think about it
469
487
  size: large # small | large | heavy — the sandbox it rents
470
- tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
488
+ 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
471
489
  disallowedTools: [Bash] # subtract from what it would otherwise have
472
490
  skills: [house-style] # from skills/
473
491
  hooks: [no-force-push] # from hooks/ or the library: code at every tool call
@@ -490,6 +508,13 @@ runtime: # only if scripts need packages
490
508
  You research a topic and report what you found...
491
509
  \`\`\`
492
510
 
511
+ The fields that do one of the four jobs of running agents may be grouped
512
+ under it — \`governance:\` (owner, budget), \`guardrail:\` (limits, network,
513
+ hooks, disallowedTools, permissionMode, secrets), and in AGENTS.md also
514
+ \`observability:\` (alerts, telemetry, notify, capture) and \`evaluation:\`
515
+ (judge). A block reads exactly as the same keys at the top; a key written
516
+ both ways is refused (\`config/conflict\`). \`foldrun docs four-jobs\`.
517
+
493
518
  Only \`name\` and the prose are required. Every other field widens or narrows
494
519
  what the agent can reach, and the default is narrow.
495
520