@foldrun/core 1.3.0 → 1.4.1

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.
@@ -0,0 +1,167 @@
1
+ // Checking frontmatter against frontmatter-fields.ts: the values, not just
2
+ // the names. Node-side (it reads store's parsers); the browser takes the
3
+ // field list alone from frontmatter-fields.ts.
4
+
5
+ import { FRONTMATTER, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
6
+ import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS } from "./store.ts";
7
+ import { budgetProblem } from "./budget.ts";
8
+ import { cronProblem } from "./cron.ts";
9
+
10
+ export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
11
+
12
+ /** A value for a message: a scalar as written, anything else by its shape.
13
+ * Never String() of a map — its `toString` is whatever the YAML says. */
14
+ function shown(v: unknown): string {
15
+ return typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? String(v) : v instanceof Date ? v.toISOString() : Array.isArray(v) ? "a list" : "a map";
16
+ }
17
+
18
+ const DURATION = (min: number, max: number, unit = "") => (v: unknown) => {
19
+ if (typeof v !== "string" && typeof v !== "number") return `is ${shown(v)}, not a duration — write seconds (900) or 30s, 15m, 4h, 3d`;
20
+ const s = parseDuration(String(v));
21
+ if (s === undefined) return `"${String(v)}" is not a duration — write seconds (900) or 30s, 15m, 4h, 3d`;
22
+ if (s < min || s > max) return `"${String(v)}" is outside ${unit || `${min}s to ${max}s`}`;
23
+ return null;
24
+ };
25
+
26
+ /** Why a value is wrong, or null — per kind, per field: the rules a list of
27
+ * values cannot say (aliases, durations, cron, budgets). */
28
+ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => string | null>>> = {
29
+ "agent": {
30
+ effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max; the step runs at the model's default`),
31
+ apis: apisProblem,
32
+ budget: (v) => budgetProblem(v, ["run"]),
33
+ ask: (v) => askProblem(v),
34
+ },
35
+ "agents-md": {
36
+ budget: (v) => budgetProblem(v, ["day", "week", "month"]),
37
+ concurrency: (v) => (Number.isInteger(Number(v)) && Number(v) > 0 ? null : `${String(v)} is not a whole number of runs, 1 or more`),
38
+ },
39
+ "flow": {
40
+ schedule: cronProblem,
41
+ effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max`),
42
+ at: (v) => (parseInstant(v) ? null : `"${String(v)}" is not an ISO 8601 instant — a once flow with it never fires`),
43
+ url: (v) => (/^https?:\/\//i.test(String(v)) ? null : `"${String(v)}" is not an http(s) URL`),
44
+ every: DURATION(1, 30 * 86400, "1s to 30 days"),
45
+ debounce: DURATION(1, 30 * 86400, "1s to 30 days"),
46
+ throttle: DURATION(1, 30 * 86400, "1s to 30 days"),
47
+ sla: DURATION(1, 30 * 86400, "1s to 30 days"),
48
+ approve_within: DURATION(1, 30 * 86400, "1s to 30 days"),
49
+ disable_after: (v) => (Number(v) > 0 && Number(v) <= 100 ? null : `${String(v)} is not a count from 1 to 100; it is ignored`),
50
+ pause_when: (v) => (String(v).length <= 60 ? null : "is longer than 60 characters"),
51
+ budget: (v) => budgetProblem(v, ["run"]),
52
+ },
53
+ "tool": {
54
+ transport: (v) => (["http", "script", "mcp"].includes(String(v).toLowerCase()) ? null : `"${String(v)}" is not a transport — http, script or mcp`),
55
+ methods: methodsProblem,
56
+ rate: (v) => (parseRate(v) ? null : `"${shown(v)}" is not a rate — N/s, N/minute, N/hour, or a number per second, above 0`),
57
+ },
58
+ "eval": {
59
+ effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max; the judge runs at none`),
60
+ },
61
+ };
62
+
63
+ export interface FieldProblem {
64
+ key: string;
65
+ rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field";
66
+ level: "warn" | "info";
67
+ message: string;
68
+ }
69
+
70
+ // YAML reads an unquoted 2026-11-01T09:00:00Z as a Date; the runtime takes
71
+ // it as the string it was written as, so it is one here too.
72
+ const typeOf = (v: unknown): FieldType | "null" =>
73
+ v === null || v === undefined ? "null" : v instanceof Date ? "string" : Array.isArray(v) ? "list" : typeof v === "object" ? "object" : (typeof v as FieldType);
74
+
75
+ /** Where a key that is not read in this kind of file IS read, for a message
76
+ * that says where it belongs. */
77
+ function belongsIn(key: string, kind: SchemaKind): string | null {
78
+ const where: [SchemaKind, string][] = [["agent", "an agent"], ["agents-md", "AGENTS.md"], ["flow", "a flow"], ["tool", "a tool"]];
79
+ const homes = where.filter(([k]) => k !== kind && FRONTMATTER[k].some((f) => f.key === key && !f.legacy)).map(([, label]) => label);
80
+ return homes.length ? homes.join(" or ") : null;
81
+ }
82
+
83
+ /**
84
+ * What is wrong with one file's frontmatter, by its schema: a key nothing
85
+ * reads (a near-miss names the field it meant), a value of the wrong type,
86
+ * a value outside the ones that do something, a required field missing.
87
+ * `near` is the typo matcher; scan.ts passes its own.
88
+ */
89
+ export function frontmatterProblems(
90
+ kind: SchemaKind,
91
+ data: Record<string, unknown>,
92
+ near: (key: string, known: readonly string[]) => string | null,
93
+ ): FieldProblem[] {
94
+ const fields = FRONTMATTER[kind];
95
+ const known = fields.map((f) => f.key);
96
+ const out: FieldProblem[] = [];
97
+ for (const [key, value] of Object.entries(data)) {
98
+ const spec = fields.find((f) => f.key === key);
99
+ const check = CHECKS[kind]?.[key];
100
+ if (!spec) {
101
+ const meant = near(key, known);
102
+ const home = belongsIn(key, kind);
103
+ if (meant && meant !== key) {
104
+ out.push({ key, rule: "config/unknown-field", level: "warn", message: `\`${key}:\` is not a field foldrun reads — did you mean \`${meant}:\`? As written it is ignored` });
105
+ } else if (kind === "agent" && STEP_OPTION_KEYS.has(key)) {
106
+ // `timeout: 3600` on an agent reads like a setting and is not one:
107
+ // it is an option of a flow's step.
108
+ out.push({ key, rule: "config/unknown-field", level: "warn", message: `\`${key}:\` is not read on an agent — it is a step option: indent it under the step in the flow (\` ${key}: ${shown(value)}\`). Here it is ignored` });
109
+ } else if (home) {
110
+ out.push({ key, rule: "config/unknown-field", level: "warn", message: `\`${key}:\` is read in ${home}, not in ${kind === "agents-md" ? "AGENTS.md" : kind === "eval" ? "an eval" : `a ${kind}`} — here it is ignored` });
111
+ } else {
112
+ out.push({ key, rule: "config/unknown-field", level: "info", message: `\`${key}:\` is not a field foldrun reads; it is ignored` });
113
+ }
114
+ continue;
115
+ }
116
+ if (spec.validated || value === null || value === undefined) continue;
117
+ const want = Array.isArray(spec.type) ? spec.type : [spec.type];
118
+ const got = typeOf(value);
119
+ // A string where a list is wanted is a one-item list wherever the
120
+ // runtime splits it, and the list types above say so.
121
+ if (!want.includes(got as FieldType)) {
122
+ out.push({ key, rule: "config/wrong-type", level: "warn", message: `\`${key}:\` should be ${want.map((t) => (t === "list" ? "a list" : t === "object" ? "a map of keys" : `a ${t}`)).join(" or ")}, not ${got === "list" ? "a list" : got === "object" ? "a map" : `a ${got}`} — as written it is not read` });
123
+ continue;
124
+ }
125
+ // A field's own check knows its aliases (`effort: quick` is low), so it
126
+ // decides alone; `values` alone is for a closed set with none.
127
+ // A check reads customer YAML: a value shaped to break it (a map whose
128
+ // `toString` is a string) is a finding, never a crash of the whole scan.
129
+ let checked: string | null = null;
130
+ try {
131
+ checked = check ? check(value) : null;
132
+ } catch {
133
+ checked = "holds a value that cannot be read";
134
+ }
135
+ const why = check
136
+ ? checked
137
+ : spec.values && typeof value === "string" && !spec.values.includes(value.trim())
138
+ ? `"${value}" is not one of ${spec.values.join(", ")} — as written it is ignored`
139
+ : null;
140
+ if (why) out.push({ key, rule: "config/invalid-value", level: "warn", message: `\`${key}:\` ${why.replace(/^\w+: /, "")}` });
141
+ }
142
+ if (kind === "skill" && !(typeof data.description === "string" && data.description.trim())) {
143
+ out.push({ key: "description", rule: "config/missing-field", level: "warn", message: "no `description:` — a skill without one is never loaded; the model chooses a skill by it" });
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function askProblem(v: unknown): string | null {
149
+ const t = (v as { timeout?: unknown } | null)?.timeout;
150
+ return t === undefined ? null : DURATION(30, 86400, "30s to 24h")(t);
151
+ }
152
+
153
+ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
154
+ function methodsProblem(v: unknown): string | null {
155
+ const list = Array.isArray(v) ? v.map(String) : String(v).split(",");
156
+ const bad = list.map((m) => m.trim()).filter((m) => m && !METHODS.includes(m.toUpperCase()));
157
+ return bad.length ? `${bad.join(", ")} — not a method; it is dropped. GET, POST, PUT, PATCH or DELETE` : null;
158
+ }
159
+ function apisProblem(v: unknown): string | null {
160
+ for (const api of Array.isArray(v) ? v : []) {
161
+ if (!api || typeof api !== "object") return "each entry is {name, base, …}";
162
+ const m = (api as { methods?: unknown }).methods;
163
+ const p = m === undefined ? null : methodsProblem(m);
164
+ if (p) return `${String((api as { name?: unknown }).name ?? "an api")}: methods: ${p}`;
165
+ }
166
+ return null;
167
+ }
package/src/scan.ts CHANGED
@@ -30,6 +30,7 @@ import crypto from "node:crypto";
30
30
  import matter from "gray-matter";
31
31
  import { refNames } from "./refs.ts";
32
32
  import { resolveNetwork } from "./network.ts";
33
+ import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
33
34
 
34
35
  export type ScanLevel = "error" | "warn" | "info";
35
36
  export type ScanCategory = "secret" | "security" | "content" | "supply-chain" | "config";
@@ -176,10 +177,34 @@ export const SCAN_RULES: readonly ScanRule[] = [
176
177
  id: "config/unknown-field",
177
178
  category: "config",
178
179
  level: "warn",
179
- title: "A frontmatter field foldrun does not read — likely a typo",
180
+ title: "A frontmatter field foldrun does not read here",
180
181
  help:
181
182
  "A misspelt field is not an error to YAML, so it is silently ignored: `tolls: [web]` leaves the agent with no tools. " +
182
- "Use the field the message suggests.",
183
+ "A near-miss names the field it meant; a field read in another kind of file says where it belongs (warnings). " +
184
+ "A field like nothing foldrun reads is a note, since another tool may own it.",
185
+ },
186
+ {
187
+ id: "config/wrong-type",
188
+ category: "config",
189
+ level: "warn",
190
+ title: "A frontmatter field holding the wrong kind of value",
191
+ help: "A list where one name was meant, a map where a word was meant: the runtime does not read it. The message says what the field takes.",
192
+ },
193
+ {
194
+ id: "config/invalid-value",
195
+ category: "config",
196
+ level: "warn",
197
+ title: "A frontmatter value that does nothing",
198
+ help:
199
+ "`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
200
+ "so the agent or flow quietly does something else. The message lists what the field takes.",
201
+ },
202
+ {
203
+ id: "config/missing-field",
204
+ category: "config",
205
+ level: "warn",
206
+ title: "A required frontmatter field is missing",
207
+ help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
183
208
  },
184
209
  {
185
210
  id: "check/ignore-needs-reason",
@@ -510,29 +535,6 @@ function unpinned(pkg: string): boolean {
510
535
 
511
536
  // ── frontmatter ────────────────────────────────────────────────────────────
512
537
 
513
- /** What foldrun reads in an agent's frontmatter — the fields documented in
514
- * agents.md, the Claude Code subagent fields an import carries, and the
515
- * older spellings check already teaches about. Used only to suggest the
516
- * field a typo meant: an unknown key that is nothing like any of these is
517
- * left alone, since another tool may own it. */
518
- const AGENT_FIELDS = [
519
- "name", "description", "model", "effort", "size", "tools", "disallowedTools", "skills", "hooks", "scripts",
520
- "apis", "mcpServers", "agents", "subagents", "secrets", "limits", "network", "provider", "permissionMode",
521
- "runtime", "ask", "web", "budget", "timezone", "language", "region", "currency", "units", "schedule", "color",
522
- "memory", "foldrun_version", "use", "kind", "type", "notify", "triggers", "inputs", "outputs",
523
- ];
524
- const FLOW_FIELDS = [
525
- "name", "description", "trigger", "schedule", "timezone", "language", "region", "currency", "units", "model",
526
- "effort", "overlap", "priority", "throttle", "debounce", "sla", "catchup", "pause_when", "disable_after",
527
- "approvers", "approve_within", "notify", "at", "every", "budget", "limits", "size", "hooks", "inputs",
528
- "webhook", "signing_secret", "signature", "idempotency", "filter", "after", "tags", "network", "secrets",
529
- ];
530
- const TOOL_FIELDS = [
531
- "name", "description", "transport", "type", "base", "methods", "headers", "query", "auth", "run", "args",
532
- "command", "url", "env", "outward", "runtime", "secrets", "network", "timeout", "spec", "operations", "body",
533
- "oauth", "limits", "input", "output", "schema", "kind",
534
- ];
535
-
536
538
  function editDistance(a: string, b: string): number {
537
539
  const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array<number>(b.length).fill(0)]);
538
540
  for (let j = 1; j <= b.length; j++) d[0]![j] = j;
@@ -721,12 +723,20 @@ function ignored(ig: Ignores, rule: string, line?: number): boolean {
721
723
 
722
724
  // ── the scan ───────────────────────────────────────────────────────────────
723
725
 
724
- type Push = (rule: string, file: string, line: number | undefined, text: string, message: string) => void;
726
+ type Push = (rule: string, file: string, line: number | undefined, text: string, message: string, level?: ScanLevel) => void;
725
727
 
726
728
  /** Installed and built code: someone else's files, and a crypto library's
727
729
  * constants are not this workspace's keys. */
728
730
  const VENDORED = /(?:^|\/)(?:node_modules|\.venv|venv|site-packages|__pycache__|vendor|dist|build|\.next)\//;
729
731
 
732
+ /** What a run leaves in an agent's folder — the Claude CLI's `.cache/` logs,
733
+ * package caches — and copies back with it. Nobody wrote these, so they are
734
+ * not checked as prompts or code; they are still searched for credentials,
735
+ * since a key in a log here is a key in the workspace (seen live on
736
+ * 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
737
+ * text). */
738
+ const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.foldrun)\//;
739
+
730
740
  /** Text files only — a PNG read as UTF-8 is noise to every rule here. */
731
741
  function isText(f: ScanFile): boolean {
732
742
  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,14 +753,14 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
743
753
  const seen = new Set<string>();
744
754
  const ignoresByFile = new Map<string, Ignores>();
745
755
 
746
- const push: Push = (rule, file, line, text, message) => {
756
+ const push: Push = (rule, file, line, text, message, level) => {
747
757
  if (!enabled(rule)) return;
748
758
  const ig = ignoresByFile.get(file);
749
759
  if (ig && ignored(ig, rule, line)) return;
750
760
  const key = `${rule}\0${file}\0${line ?? ""}\0${message}`;
751
761
  if (seen.has(key)) return;
752
762
  seen.add(key);
753
- findings.push({ rule, level: scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
763
+ findings.push({ rule, level: level ?? scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
754
764
  };
755
765
 
756
766
  const outward = new Set(options.outwardTools ?? []);
@@ -761,18 +771,25 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
761
771
  const lines = f.content.split(/\r?\n/);
762
772
  ignoresByFile.set(f.path, readIgnores(f, lines, push));
763
773
  scanSecrets(f, lines, push);
774
+ if (RUNTIME_WRITTEN.test(f.path)) continue;
764
775
  scanContent(f, lines, push);
765
776
  scanSupplyChain(f, lines, push);
766
777
 
767
- const kind = docKind(f.path);
768
- if (!kind) continue;
769
- const data = frontOf(f.content);
778
+ const schema = schemaKindOf(f.path);
779
+ // A skill with no frontmatter at all is the clearest one that never
780
+ // 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;
770
782
  if (!data) continue;
771
- const known = kind === "agent" ? AGENT_FIELDS : kind === "flow" ? FLOW_FIELDS : TOOL_FIELDS;
772
- for (const key of Object.keys(data)) {
773
- const near = nearestField(key, known);
774
- if (near && near !== key) push("config/unknown-field", f.path, keyLine(lines, key), `field ${key}`, `\`${key}:\` is not a field foldrun reads — did you mean \`${near}:\`? As written it is ignored`);
783
+ if (schema) {
784
+ for (const p of frontmatterProblems(schema, data, nearestField)) {
785
+ // Fingerprint the key's own line, not the parsed value: a YAML alias
786
+ // can make the value circular, and JSON of it would throw.
787
+ const at = keyLine(lines, p.key);
788
+ push(p.rule, f.path, at, `${p.rule} ${at ? lines[at - 1] : p.key}`, p.message, p.level);
789
+ }
775
790
  }
791
+ const kind = docKind(f.path);
792
+ if (!kind) continue;
776
793
  if (kind === "agent") {
777
794
  const rel = f.path.replace(/^library\//, "");
778
795
  const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
package/src/store.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  // An account is a named directory (per OKF: tenancy is a hosting concern).
22
22
 
23
23
  import { expandPlatformVars } from "./host-env.ts";
24
+ import { EFFORT_LEVELS, FLOW_TRIGGERS } from "./frontmatter-fields.ts";
24
25
  import type { BrowserPodOutcome } from "./browser-pod.ts";
25
26
  import fs from "node:fs";
26
27
  import path from "node:path";
@@ -1082,7 +1083,7 @@ export function resolveModel(value: unknown): string {
1082
1083
  // model is which brain, effort is how long it thinks before answering. The
1083
1084
  // five levels are the SDK's own union — we neither invent our own scale nor
1084
1085
  // narrow theirs, so a level added upstream is one line here.
1085
- export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
1086
+ export { EFFORT_LEVELS } from "./frontmatter-fields.ts";
1086
1087
  export type Effort = (typeof EFFORT_LEVELS)[number];
1087
1088
 
1088
1089
  const EFFORT_ALIASES: Record<string, Effort> = {
@@ -1464,7 +1465,8 @@ export interface FlowInfo {
1464
1465
  steps: FlowStep[];
1465
1466
  }
1466
1467
 
1467
- export const FLOW_TRIGGERS = ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"] as const;
1468
+ // The list lives with every other frontmatter field (frontmatter-fields.ts).
1469
+ export { FLOW_TRIGGERS } from "./frontmatter-fields.ts";
1468
1470
  export type FlowTrigger = (typeof FLOW_TRIGGERS)[number];
1469
1471
 
1470
1472
  // Skills and memory files belonging to one agent — for the graph view and
@@ -1624,7 +1626,7 @@ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
1624
1626
  * publish" is prose that happens to contain a colon, and it used to be
1625
1627
  * swallowed as an unknown option, silently, along with any typo of a real
1626
1628
  * key. The consistency suite reads this list against the docs. */
1627
- const STEP_OPTION_KEYS = new Set([
1629
+ export const STEP_OPTION_KEYS: ReadonlySet<string> = new Set([
1628
1630
  "approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
1629
1631
  "on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
1630
1632
  ]);