@foldrun/core 1.3.0 → 1.4.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.
package/dist/src/scan.js CHANGED
@@ -29,6 +29,7 @@ import crypto from "node:crypto";
29
29
  import matter from "gray-matter";
30
30
  import { refNames } from "./refs.js";
31
31
  import { resolveNetwork } from "./network.js";
32
+ import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
32
33
  export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
33
34
  export const SCAN_RULES = [
34
35
  {
@@ -120,9 +121,32 @@ export const SCAN_RULES = [
120
121
  id: "config/unknown-field",
121
122
  category: "config",
122
123
  level: "warn",
123
- title: "A frontmatter field foldrun does not read — likely a typo",
124
+ title: "A frontmatter field foldrun does not read here",
124
125
  help: "A misspelt field is not an error to YAML, so it is silently ignored: `tolls: [web]` leaves the agent with no tools. " +
125
- "Use the field the message suggests.",
126
+ "A near-miss names the field it meant; a field read in another kind of file says where it belongs (warnings). " +
127
+ "A field like nothing foldrun reads is a note, since another tool may own it.",
128
+ },
129
+ {
130
+ id: "config/wrong-type",
131
+ category: "config",
132
+ level: "warn",
133
+ title: "A frontmatter field holding the wrong kind of value",
134
+ 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.",
135
+ },
136
+ {
137
+ id: "config/invalid-value",
138
+ category: "config",
139
+ level: "warn",
140
+ title: "A frontmatter value that does nothing",
141
+ help: "`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
142
+ "so the agent or flow quietly does something else. The message lists what the field takes.",
143
+ },
144
+ {
145
+ id: "config/missing-field",
146
+ category: "config",
147
+ level: "warn",
148
+ title: "A required frontmatter field is missing",
149
+ help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
126
150
  },
127
151
  {
128
152
  id: "check/ignore-needs-reason",
@@ -459,28 +483,6 @@ function unpinned(pkg) {
459
483
  return !/[=<>~!]/.test(s) && !/.@./.test(s.replace(/^@/, "x"));
460
484
  }
461
485
  // ── frontmatter ────────────────────────────────────────────────────────────
462
- /** What foldrun reads in an agent's frontmatter — the fields documented in
463
- * agents.md, the Claude Code subagent fields an import carries, and the
464
- * older spellings check already teaches about. Used only to suggest the
465
- * field a typo meant: an unknown key that is nothing like any of these is
466
- * left alone, since another tool may own it. */
467
- const AGENT_FIELDS = [
468
- "name", "description", "model", "effort", "size", "tools", "disallowedTools", "skills", "hooks", "scripts",
469
- "apis", "mcpServers", "agents", "subagents", "secrets", "limits", "network", "provider", "permissionMode",
470
- "runtime", "ask", "web", "budget", "timezone", "language", "region", "currency", "units", "schedule", "color",
471
- "memory", "foldrun_version", "use", "kind", "type", "notify", "triggers", "inputs", "outputs",
472
- ];
473
- const FLOW_FIELDS = [
474
- "name", "description", "trigger", "schedule", "timezone", "language", "region", "currency", "units", "model",
475
- "effort", "overlap", "priority", "throttle", "debounce", "sla", "catchup", "pause_when", "disable_after",
476
- "approvers", "approve_within", "notify", "at", "every", "budget", "limits", "size", "hooks", "inputs",
477
- "webhook", "signing_secret", "signature", "idempotency", "filter", "after", "tags", "network", "secrets",
478
- ];
479
- const TOOL_FIELDS = [
480
- "name", "description", "transport", "type", "base", "methods", "headers", "query", "auth", "run", "args",
481
- "command", "url", "env", "outward", "runtime", "secrets", "network", "timeout", "spec", "operations", "body",
482
- "oauth", "limits", "input", "output", "schema", "kind",
483
- ];
484
486
  function editDistance(a, b) {
485
487
  const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
486
488
  for (let j = 1; j <= b.length; j++)
@@ -658,7 +660,7 @@ export function scanFiles(files, options = {}) {
658
660
  const findings = [];
659
661
  const seen = new Set();
660
662
  const ignoresByFile = new Map();
661
- const push = (rule, file, line, text, message) => {
663
+ const push = (rule, file, line, text, message, level) => {
662
664
  if (!enabled(rule))
663
665
  return;
664
666
  const ig = ignoresByFile.get(file);
@@ -668,7 +670,7 @@ export function scanFiles(files, options = {}) {
668
670
  if (seen.has(key))
669
671
  return;
670
672
  seen.add(key);
671
- findings.push({ rule, level: scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
673
+ findings.push({ rule, level: level ?? scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
672
674
  };
673
675
  const outward = new Set(options.outwardTools ?? []);
674
676
  const agents = [];
@@ -679,18 +681,23 @@ export function scanFiles(files, options = {}) {
679
681
  scanSecrets(f, lines, push);
680
682
  scanContent(f, lines, push);
681
683
  scanSupplyChain(f, lines, push);
682
- const kind = docKind(f.path);
683
- if (!kind)
684
- continue;
685
- const data = frontOf(f.content);
684
+ const schema = schemaKindOf(f.path);
685
+ // A skill with no frontmatter at all is the clearest one that never
686
+ // loads: read it as empty, so its required fields are reported.
687
+ const data = schema || docKind(f.path) ? (frontOf(f.content) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
686
688
  if (!data)
687
689
  continue;
688
- const known = kind === "agent" ? AGENT_FIELDS : kind === "flow" ? FLOW_FIELDS : TOOL_FIELDS;
689
- for (const key of Object.keys(data)) {
690
- const near = nearestField(key, known);
691
- if (near && near !== key)
692
- 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`);
690
+ if (schema) {
691
+ for (const p of frontmatterProblems(schema, data, nearestField)) {
692
+ // Fingerprint the key's own line, not the parsed value: a YAML alias
693
+ // can make the value circular, and JSON of it would throw.
694
+ const at = keyLine(lines, p.key);
695
+ push(p.rule, f.path, at, `${p.rule} ${at ? lines[at - 1] : p.key}`, p.message, p.level);
696
+ }
693
697
  }
698
+ const kind = docKind(f.path);
699
+ if (!kind)
700
+ continue;
694
701
  if (kind === "agent") {
695
702
  const rel = f.path.replace(/^library\//, "");
696
703
  const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
@@ -1,3 +1,4 @@
1
+ import { EFFORT_LEVELS, FLOW_TRIGGERS } from "./frontmatter-fields.ts";
1
2
  import type { BrowserPodOutcome } from "./browser-pod.ts";
2
3
  import { singleWorkspace } from "./paths.ts";
3
4
  import type { TestEffect } from "./test-mode.ts";
@@ -354,7 +355,7 @@ export type Tier = keyof typeof MODEL_TIERS;
354
355
  * `model:` does — one vocabulary, not two. */
355
356
  export declare function resolveTier(value: unknown): Tier | null;
356
357
  export declare function resolveModel(value: unknown): string;
357
- export declare const EFFORT_LEVELS: readonly ["low", "medium", "high", "xhigh", "max"];
358
+ export { EFFORT_LEVELS } from "./frontmatter-fields.ts";
358
359
  export type Effort = (typeof EFFORT_LEVELS)[number];
359
360
  /**
360
361
  * `null` for both "not set" and "not a level we know" — unlike a model, the
@@ -607,7 +608,7 @@ export interface FlowInfo {
607
608
  frontProblems: string[];
608
609
  steps: FlowStep[];
609
610
  }
610
- export declare const FLOW_TRIGGERS: readonly ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
611
+ export { FLOW_TRIGGERS } from "./frontmatter-fields.ts";
611
612
  export type FlowTrigger = (typeof FLOW_TRIGGERS)[number];
612
613
  export interface AgentAssets {
613
614
  skills: string[];
@@ -615,6 +616,12 @@ export interface AgentAssets {
615
616
  }
616
617
  export declare function agentAssets(tenant: string, workspace: string, agent: string): AgentAssets;
617
618
  export declare function listAgents(tenant: string, workspace: string): AgentInfo[];
619
+ /** The option keys a step actually has. An indented `word: rest` that is
620
+ * NOT one of these is a line of the instruction — "Warning: do not
621
+ * publish" is prose that happens to contain a colon, and it used to be
622
+ * swallowed as an unknown option, silently, along with any typo of a real
623
+ * key. The consistency suite reads this list against the docs. */
624
+ export declare const STEP_OPTION_KEYS: ReadonlySet<string>;
618
625
  /** The most fan-out instances `parallel:` may run at once — the fan-out's
619
626
  * own hard cap, since more slots than items is no cap. */
620
627
  export declare const PARALLEL_CAP = 20;
package/dist/src/store.js CHANGED
@@ -20,6 +20,7 @@
20
20
  //
21
21
  // An account is a named directory (per OKF: tenancy is a hosting concern).
22
22
  import { expandPlatformVars } from "./host-env.js";
23
+ import { FLOW_TRIGGERS } from "./frontmatter-fields.js";
23
24
  import fs from "node:fs";
24
25
  import path from "node:path";
25
26
  import { recordRevision, registerTreeReader } from "./history.js";
@@ -899,7 +900,7 @@ export function resolveModel(value) {
899
900
  // model is which brain, effort is how long it thinks before answering. The
900
901
  // five levels are the SDK's own union — we neither invent our own scale nor
901
902
  // narrow theirs, so a level added upstream is one line here.
902
- export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
903
+ export { EFFORT_LEVELS } from "./frontmatter-fields.js";
903
904
  const EFFORT_ALIASES = {
904
905
  low: "low",
905
906
  min: "low",
@@ -1020,7 +1021,8 @@ export function parseApis(raw) {
1020
1021
  }
1021
1022
  return out;
1022
1023
  }
1023
- export const FLOW_TRIGGERS = ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
1024
+ // The list lives with every other frontmatter field (frontmatter-fields.ts).
1025
+ export { FLOW_TRIGGERS } from "./frontmatter-fields.js";
1024
1026
  export function agentAssets(tenant, workspace, agent) {
1025
1027
  const base = path.join(workspaceDir(tenant, workspace), "agents", agent);
1026
1028
  const nameOf = (file, fallback) => {
@@ -1177,7 +1179,7 @@ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
1177
1179
  * publish" is prose that happens to contain a colon, and it used to be
1178
1180
  * swallowed as an unknown option, silently, along with any typo of a real
1179
1181
  * key. The consistency suite reads this list against the docs. */
1180
- const STEP_OPTION_KEYS = new Set([
1182
+ export const STEP_OPTION_KEYS = new Set([
1181
1183
  "approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
1182
1184
  "on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
1183
1185
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  // renders them.
11
11
 
12
12
  import { PROVIDERS } from "./providers.ts";
13
+ import { FRONTMATTER, type SchemaKind } from "./frontmatter-fields.ts";
13
14
 
14
15
  export interface Vocabulary {
15
16
  agents: string[];
@@ -207,8 +208,6 @@ const FIELDS: Record<string, Completion[]> = {
207
208
  { label: "trigger", insert: "trigger: manual" },
208
209
  { label: "schedule", insert: "schedule: \"0 6 * * MON\"" },
209
210
  { label: "timezone", insert: "timezone: Australia/Sydney" },
210
- { label: "language", insert: "language: en-AU" },
211
- { label: "region", insert: "region: au", hint: "currency, units, calendar, weekend and text direction follow" },
212
211
  { label: "model", insert: "model: fast", hint: "every step, unless the step says" },
213
212
  { label: "effort", insert: "effort: high", hint: "every step, unless the step says" },
214
213
  ],
@@ -264,6 +263,27 @@ export function fileKind(path: string): FileKind {
264
263
  return "note";
265
264
  }
266
265
 
266
+ /** Which frontmatter schema a completion kind offers. */
267
+ const SCHEMA_OF: Partial<Record<FileKind, SchemaKind>> = { agent: "agent", flow: "flow", tool: "tool", skill: "skill", evalFile: "eval" };
268
+
269
+ /**
270
+ * The fields to offer at the start of a frontmatter line: the hand-written
271
+ * snippets above where a field has one, then every other field the schema
272
+ * says this kind of file takes — so a field added to frontmatter-fields.ts is
273
+ * offered here with no second edit, and a snippet for a field the runtime no
274
+ * longer reads is dropped. AGENTS.md takes its own set, not an agent's.
275
+ */
276
+ function fieldsFor(path: string, kind: FileKind): Completion[] {
277
+ const agentsMd = /(^|\/)AGENTS\.md$/.test(path) || path === "project.md";
278
+ const schema = agentsMd ? FRONTMATTER["agents-md"] : SCHEMA_OF[kind] ? FRONTMATTER[SCHEMA_OF[kind]!] : null;
279
+ if (!schema) return FIELDS[kind];
280
+ const offered = schema.filter((f) => !f.legacy);
281
+ const keys = new Set(offered.map((f) => f.key));
282
+ const curated = FIELDS[kind].filter((c) => keys.has(c.label.split(".")[0]!));
283
+ const have = new Set(curated.map((c) => c.label.split(".")[0]));
284
+ return [...curated, ...offered.filter((f) => !have.has(f.key)).map((f) => ({ label: f.key, insert: `${f.key}: `, hint: f.help }))];
285
+ }
286
+
267
287
  /**
268
288
  * Does [[ ]] in this file get expanded into a path, or stay literal?
269
289
  *
@@ -654,9 +674,9 @@ export function completionsAt(
654
674
  // this kind of file. This is where you learn the format by writing it.
655
675
  const fmEnd = text.indexOf("\n---", text.indexOf("---") + 3);
656
676
  const inFrontmatter = text.startsWith("---") && (fmEnd === -1 || cursor <= fmEnd);
657
- if (inFrontmatter && /^[a-zA-Z_]*$/.test(line)) {
677
+ if (inFrontmatter && /^[a-zA-Z_][a-zA-Z_-]*$|^$/.test(line)) {
658
678
  const from = cursor - line.length;
659
- return { from, query: line, items: filter(FIELDS[kind], line), title: `${kind} fields` };
679
+ return { from, query: line, items: filter(fieldsFor(path, kind), line), title: `${kind} fields` };
660
680
  }
661
681
 
662
682
  return null;
package/src/cron.ts ADDED
@@ -0,0 +1,120 @@
1
+ // Cron: the one reading of a flow's `schedule:`.
2
+ //
3
+ // Moved here from the platform's scheduler so `foldrun check`, the deploy and
4
+ // the scheduler ask the same function whether a schedule is one — a cron the
5
+ // scheduler cannot parse used to be skipped silently, and nothing earlier
6
+ // said so. The scheduler imports this; the expansion is unchanged.
7
+
8
+
9
+ export const NAMED: Record<string, string> = {
10
+ "@hourly": "0 * * * *",
11
+ "@daily": "0 0 * * *",
12
+ "@midnight": "0 0 * * *",
13
+ "@weekly": "0 0 * * 0",
14
+ "@monthly": "0 0 1 * *",
15
+ };
16
+
17
+ export const DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
18
+ const MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
19
+
20
+ // Expand one cron field ("*", "*/15", "1-5", "mon,fri", "3") to a value set.
21
+ function expandField(field: string, min: number, max: number, names?: string[]): Set<number> | null {
22
+ const out = new Set<number>();
23
+ for (const part of field.split(",")) {
24
+ const [rangePart, stepPart] = part.split("/");
25
+ const step = stepPart ? Number(stepPart) : 1;
26
+ if (!Number.isInteger(step) || step < 1) return null;
27
+
28
+ let lo = min;
29
+ let hi = max;
30
+ if (rangePart !== "*") {
31
+ const bounds = rangePart.split("-");
32
+ const parse = (raw: string): number | null => {
33
+ const token = raw.trim().toLowerCase();
34
+ if (names) {
35
+ // A name counts from the field's own minimum: SUN is 0 on the
36
+ // weekday field, JAN is 1 on the month field. Indexed from zero,
37
+ // JAN read as 0 (refused — the flow never fired) and FEB…DEC as
38
+ // the month before.
39
+ const idx = names.indexOf(token);
40
+ if (idx !== -1) return min + idx;
41
+ }
42
+ const n = Number(token);
43
+ return Number.isInteger(n) ? n : null;
44
+ };
45
+ const a = parse(bounds[0]);
46
+ if (a === null) return null;
47
+ lo = a;
48
+ if (bounds.length > 1) {
49
+ const b = parse(bounds[1]);
50
+ if (b === null) return null;
51
+ hi = b;
52
+ } else {
53
+ hi = stepPart ? max : a; // "5" → just 5; "5/10" → 5,15,25...
54
+ }
55
+ }
56
+ if (lo < min || hi > max || lo > hi) return null;
57
+ for (let v = lo; v <= hi; v += step) out.add(v);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ export interface Cron {
63
+ minute: Set<number>;
64
+ hour: Set<number>;
65
+ dom: Set<number>;
66
+ month: Set<number>;
67
+ dow: Set<number>;
68
+ domRestricted: boolean;
69
+ dowRestricted: boolean;
70
+ /** Neither minute nor hour is a wildcard: a job at a time of day, which
71
+ * is what the DST rules in cronMatches apply to (as in Vixie cron). */
72
+ fixedTime: boolean;
73
+ }
74
+
75
+ /** `@daily` → its five fields; anything else as written. An own-property
76
+ * lookup, so `schedule: constructor` is a bad cron, not a crash. */
77
+ function expand(expr: string): string {
78
+ const key = expr.trim().toLowerCase();
79
+ return (Object.hasOwn(NAMED, key) ? NAMED[key]! : expr).trim();
80
+ }
81
+
82
+ export function parseCron(expr: string): Cron | null {
83
+ if (typeof expr !== "string") return null;
84
+ const normalized = expand(expr);
85
+ const fields = normalized.split(/\s+/);
86
+ if (fields.length !== 5) return null;
87
+ const [mi, ho, dm, mo, dw] = fields;
88
+
89
+ const minute = expandField(mi, 0, 59);
90
+ const hour = expandField(ho, 0, 23);
91
+ const dom = expandField(dm, 1, 31);
92
+ const month = expandField(mo, 1, 12, MONTH_NAMES);
93
+ const dowRaw = expandField(dw, 0, 7, DOW_NAMES);
94
+ if (!minute || !hour || !dom || !month || !dowRaw) return null;
95
+
96
+ // cron allows both 0 and 7 for Sunday.
97
+ const dowSet = new Set([...dowRaw].map((d) => (d === 7 ? 0 : d)));
98
+
99
+ return {
100
+ minute,
101
+ hour,
102
+ dom,
103
+ month,
104
+ dow: dowSet,
105
+ domRestricted: dm.trim() !== "*",
106
+ dowRestricted: dw.trim() !== "*",
107
+ fixedTime: !mi.startsWith("*") && !ho.startsWith("*"),
108
+ };
109
+ }
110
+
111
+
112
+ /** Why `schedule:` is not a cron the scheduler can run, or null when it is. */
113
+ export function cronProblem(expr: unknown): string | null {
114
+ if (typeof expr !== "string" || !expr.trim()) return "schedule: is a cron expression — five fields, or @hourly, @daily, @weekly, @monthly";
115
+ if (parseCron(expr)) return null;
116
+ const fields = expand(expr).split(/\s+/).length;
117
+ return fields !== 5
118
+ ? `schedule: "${expr}" has ${fields} field${fields === 1 ? "" : "s"}; cron has five — minute hour day-of-month month day-of-week ("0 9 * * MON"), or @hourly, @daily, @weekly, @monthly. As written the flow never fires`
119
+ : `schedule: "${expr}" is not a cron the scheduler can read — a field is out of range or not a number (minute 0-59, hour 0-23, day 1-31, month 1-12, weekday 0-7 or sun-sat). As written the flow never fires`;
120
+ }
@@ -0,0 +1,225 @@
1
+ // Every frontmatter field foldrun reads, per kind of file — as data.
2
+ //
3
+ // The one list. Before it, the fields lived in five places that drifted: the
4
+ // runtime's reads, the dashboard's completions, the scanner's typo lists, the
5
+ // docs, and the assistant's vocabulary — the completions offered `language:`
6
+ // on a flow, which no run reads. Now:
7
+ //
8
+ // · completions.ts offers exactly these, in the browser (so this file is
9
+ // pure data: no fs, no store);
10
+ // · frontmatter-schema.ts checks values against them, for `foldrun check`,
11
+ // the Checks page, the editor and the deploy's view;
12
+ // · tests/frontmatter-schema.test.ts fails when the runner, parseFlow or a
13
+ // tool parser reads a field missing here, or a completion offers one
14
+ // that is not; foldrun-web's consistency test fails when one here is not
15
+ // in the docs.
16
+ //
17
+ // A new field is added here first, then read.
18
+
19
+ export type FieldType = "string" | "number" | "boolean" | "list" | "object";
20
+
21
+ export interface FieldSpec {
22
+ key: string;
23
+ /** What YAML may hand over. "list" is also offered where the runtime
24
+ * splits `a, b` on one line, so such fields list "string" too. */
25
+ type: FieldType | FieldType[];
26
+ /** The values that do something, when the set is closed and has no
27
+ * aliases. Anything else is reported. */
28
+ values?: readonly string[];
29
+ /** Another function validates this field in full and is the one place
30
+ * that does; the schema only says the field exists. */
31
+ validated?: boolean;
32
+ /** An older name still read, or kept so the right message (not
33
+ * "unknown") reaches whoever wrote it. Not offered as a completion. */
34
+ legacy?: boolean;
35
+ /** One line: what it is. The completion's hint. */
36
+ help: string;
37
+ }
38
+
39
+ export type SchemaKind = "agent" | "agents-md" | "flow" | "tool" | "skill" | "eval" | "hook";
40
+
41
+ /** What can start a flow. store.ts re-exports it; parseFlow and the editor
42
+ * read this one. */
43
+ export const FLOW_TRIGGERS = ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"] as const;
44
+
45
+ /** The effort levels; store.ts re-exports it with the aliases beside. */
46
+ export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
47
+
48
+ const LOCALE = { validated: true } as const;
49
+
50
+ const AGENT: FieldSpec[] = [
51
+ { key: "name", type: "string", help: "kebab-case; the folder is the identity" },
52
+ { key: "description", type: "string", help: "what it does and when — other agents and people read it" },
53
+ { key: "model", type: "string", help: "fast | default | max, an alias, or a full model id" },
54
+ { key: "effort", type: "string", values: EFFORT_LEVELS, help: "how long it thinks" },
55
+ { key: "size", type: "string", values: ["small", "large", "heavy"], help: "the sandbox it rents; anything else becomes large" },
56
+ { key: "permissionMode", type: "string", values: ["plan"], help: "plan = read-only; no other mode changes anything here" },
57
+ { key: "tools", type: ["list", "string"], help: "the one grant list" },
58
+ { key: "disallowedTools", type: ["list", "string"], help: "subtract from what it would otherwise have" },
59
+ { key: "skills", type: ["list", "string"], help: "an allowlist; absent inherits every skill" },
60
+ { key: "hooks", type: ["list", "string", "object"], help: "hook names; a Claude Code hooks: object is read as none" },
61
+ { key: "scripts", type: "list", help: "programs in scripts/, each a tool" },
62
+ { key: "apis", type: "list", help: "an HTTP API as a tool" },
63
+ { key: "mcpServers", type: "object", help: "MCP servers, name → {command | url, args, env, headers}" },
64
+ { key: "agents", type: ["list", "string"], help: "colleagues it may consult" },
65
+ { key: "subagents", type: ["list", "string"], help: "colleagues it may delegate to" },
66
+ { key: "secrets", type: ["list", "string"], help: "keys it spends itself" },
67
+ { key: "budget", type: ["number", "string"], help: "USD per run" },
68
+ { key: "limits", type: "object", validated: true, help: "per-step call limits" },
69
+ { key: "network", type: ["string", "list", "object"], validated: true, help: "open | trusted | none, sites, or {level, allow, defaults}" },
70
+ { key: "web", type: "object", validated: true, help: "providers for the web tool's actions" },
71
+ { key: "provider", type: "object", help: "an Anthropic- or OpenAI-format endpoint for this agent" },
72
+ { key: "runtime", type: "object", help: "packages and system packages for its sandbox" },
73
+ { key: "ask", type: "object", help: "{timeout} for tools: [ask]" },
74
+ { key: "timezone", type: "string", ...LOCALE, help: "IANA name or offset" },
75
+ { key: "language", type: "string", ...LOCALE, help: "BCP-47 tag" },
76
+ { key: "region", type: "string", ...LOCALE, help: "two-letter country" },
77
+ { key: "currency", type: "string", ...LOCALE, help: "three letters" },
78
+ { key: "units", type: "string", ...LOCALE, help: "metric | imperial" },
79
+ { key: "calendar", type: "string", ...LOCALE, help: "an Intl calendar" },
80
+ { key: "schedule", type: ["string", "object"], validated: true, legacy: true, help: "refused: only a flow has a clock" },
81
+ { key: "use", type: ["list", "string"], validated: true, legacy: true, help: "retired: write tools:" },
82
+ ];
83
+
84
+ const AGENTS_MD: FieldSpec[] = [
85
+ { key: "foldrun_version", type: ["string", "number"], help: "the format version this account targets" },
86
+ { key: "mdagent_version", type: ["string", "number"], legacy: true, help: "older spelling of foldrun_version" },
87
+ { key: "name", type: "string", help: "the workspace's name" },
88
+ { key: "description", type: "string", help: "shown in the workspace list" },
89
+ { key: "provider", type: "object", help: "the model endpoint every agent here uses unless it names its own" },
90
+ { key: "runtime", type: "object", help: "packages for agents that declare none" },
91
+ { key: "web", type: "object", validated: true, help: "web.browse defaults" },
92
+ { key: "network", type: ["string", "list", "object"], validated: true, help: "every agent's default network" },
93
+ { key: "limits", type: "object", validated: true, help: "default call limits" },
94
+ { key: "hooks", type: ["list", "string"], help: "hooks every agent here runs" },
95
+ { key: "timezone", type: "string", ...LOCALE, help: "default timezone" },
96
+ { key: "language", type: "string", ...LOCALE, help: "default language" },
97
+ { key: "region", type: "string", ...LOCALE, help: "default region" },
98
+ { key: "currency", type: "string", ...LOCALE, help: "default currency" },
99
+ { key: "units", type: "string", ...LOCALE, help: "default units" },
100
+ { key: "calendar", type: "string", ...LOCALE, help: "default calendar" },
101
+ { key: "notify", type: ["string", "object"], help: "an email or URL told about failed runs and waiting approvals" },
102
+ { key: "budget", type: ["number", "string"], help: "USD per month, or N/day | N/week | N/month" },
103
+ { key: "priority", type: "string", values: ["high", "normal", "low"], help: "account: where its runs stand in the queue" },
104
+ { key: "concurrency", type: ["number", "string"], help: "account: runs at once" },
105
+ { key: "schedule", type: ["string", "object"], validated: true, legacy: true, help: "refused: only a flow has a clock" },
106
+ ];
107
+
108
+ const FLOW: FieldSpec[] = [
109
+ { key: "name", type: "string", help: "the flow's name" },
110
+ { key: "description", type: "string", help: "what it is for" },
111
+ { key: "trigger", type: "string", values: FLOW_TRIGGERS, help: "what starts it; any other value never fires" },
112
+ { key: "schedule", type: "string", help: "cron, when trigger: schedule" },
113
+ { key: "timezone", type: "string", validated: true, help: "the zone its schedule reads in" },
114
+ { key: "model", type: "string", help: "every step's model unless the step says" },
115
+ { key: "effort", type: "string", values: EFFORT_LEVELS, help: "every step's effort unless the step says" },
116
+ { key: "overlap", type: "string", validated: true, help: "skip | queue" },
117
+ { key: "priority", type: "string", validated: true, help: "high | normal | low" },
118
+ { key: "catchup", type: "string", validated: true, help: "last | none" },
119
+ { key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
120
+ { key: "signature", type: "string", validated: true, help: "github | stripe | slack | hmac" },
121
+ { key: "signing_secret", type: "string", help: "the secret a webhook's signature is checked with" },
122
+ { key: "after", type: ["string", "list"], help: "trigger: flow — the flow this one follows" },
123
+ { key: "at", type: "string", help: "trigger: once — when" },
124
+ { key: "url", type: "string", help: "trigger: watch — the page watched" },
125
+ { key: "every", type: ["string", "number"], help: "trigger: watch — how often" },
126
+ { key: "path", type: "string", help: "trigger: storage — the prefix under storage/" },
127
+ { key: "idempotency", type: "string", help: "a header name, or body:<field>, that marks a repeat delivery" },
128
+ { key: "debounce", type: ["string", "number"], help: "wait for a burst to end" },
129
+ { key: "throttle", type: ["string", "number"], help: "at most one run per window" },
130
+ { key: "sla", type: ["string", "number"], help: "tell someone when a run outlives it" },
131
+ { key: "approve_within", type: ["string", "number"], help: "how long an approval may wait" },
132
+ { key: "disable_after", type: ["number", "string"], help: "switch the flow off after this many failures in a row" },
133
+ { key: "pause_when", type: "string", help: "a marker that pauses the flow when a run's result says it" },
134
+ { key: "approvers", type: ["list", "string"], help: "who may approve its gates" },
135
+ { key: "budget", type: ["number", "string"], help: "USD per run" },
136
+ { key: "live", type: "boolean", help: "true: steps may act for real in a test run" },
137
+ ];
138
+
139
+ const TOOL: FieldSpec[] = [
140
+ { key: "name", type: "string", help: "defaults to the file or folder name" },
141
+ { key: "description", type: "string", help: "what it does — the model chooses by it" },
142
+ { key: "transport", type: "string", values: ["http", "script", "mcp"], help: "http | script | mcp" },
143
+ { key: "type", type: "string", legacy: true, help: "older spelling of transport" },
144
+ { key: "outward", type: "boolean", help: "true: it changes something outside the workspace" },
145
+ { key: "base", type: "string", help: "http: the API's base URL" },
146
+ { key: "headers", type: "object", help: "http or mcp: request headers; ${SECRET} is filled server-side" },
147
+ { key: "query", type: "object", help: "http: query parameters on every call" },
148
+ { key: "methods", type: ["list", "string"], help: "http: GET, POST, PUT, PATCH, DELETE" },
149
+ { key: "openapi", type: "string", help: "http: an OpenAPI document for the operations" },
150
+ { key: "operations", type: ["list", "object"], help: "http: which operations to offer" },
151
+ { key: "rate", type: ["string", "number"], help: "http: N/s, N/minute, N/hour, or a number per second" },
152
+ { key: "timeout", type: ["number", "string"], help: "seconds one call may take" },
153
+ { key: "run", type: "string", help: "script: the program, relative to the tool" },
154
+ { key: "args", type: ["object", "list"], help: "script: its arguments, name → what it is; mcp: the command's arguments" },
155
+ { key: "interpreter", type: "string", help: "script: what runs it" },
156
+ { key: "test_mode", type: "string", values: ["allow"], help: "script: allow — it may run in a test run" },
157
+ { key: "secrets", type: ["list", "string"], help: "script: secret names, or proxied | materialised" },
158
+ { key: "runtime", type: "object", help: "script: packages it needs" },
159
+ { key: "code", type: "string", help: "script: the program inline" },
160
+ { key: "command", type: "string", help: "mcp: the server's command" },
161
+ { key: "env", type: "object", help: "mcp: its environment" },
162
+ { key: "url", type: "string", help: "mcp: a server over http" },
163
+ { key: "guide", type: "string", legacy: true, help: "ignored — the guide is the body" },
164
+ ];
165
+
166
+ const SKILL: FieldSpec[] = [
167
+ { key: "name", type: "string", help: "defaults to the folder" },
168
+ { key: "description", type: "string", help: "required — the model chooses a skill by it" },
169
+ { key: "when", type: ["list", "string"], help: "run tags it loads on" },
170
+ // The Agent Skills format's own optional fields: not read, not wrong.
171
+ { key: "license", type: "string", help: "Agent Skills format" },
172
+ { key: "compatibility", type: "string", help: "Agent Skills format" },
173
+ { key: "metadata", type: "object", help: "Agent Skills format" },
174
+ { key: "allowed-tools", type: ["string", "list"], help: "Agent Skills format: space-separated pre-approved tools (experimental)" },
175
+ ];
176
+
177
+ const EVAL: FieldSpec[] = [
178
+ { key: "name", type: "string", help: "the eval's name" },
179
+ { key: "agent", type: "string", help: "the agent it tests" },
180
+ { key: "flow", type: "string", help: "or the flow" },
181
+ { key: "model", type: "string", help: "the judge's model" },
182
+ { key: "effort", type: "string", values: EFFORT_LEVELS, help: "the judge's effort" },
183
+ { key: "trigger", type: "string", values: ["deploy", "manual"], help: "deploy (default) | manual; anything else is deploy" },
184
+ { key: "live", type: "boolean", help: "true: real calls" },
185
+ { key: "inputs", type: "boolean", help: "true: cases carry inputs" },
186
+ ];
187
+
188
+ const HOOK: FieldSpec[] = [
189
+ { key: "on", type: "string", validated: true, help: "tool.call | tool.result | step.start | step.end" },
190
+ { key: "tool", type: "string", validated: true, help: "only on tool events" },
191
+ { key: "timeout", type: "number", validated: true, help: "seconds, 1 to 120" },
192
+ { key: "description", type: "string", help: "what it guards" },
193
+ { key: "name", type: "string", help: "the folder is the name" },
194
+ ];
195
+
196
+ /** `kind:` and `type:` were how a document once said what it was; the path
197
+ * says it now, and `foldrun check` has its own message for them. */
198
+ const SAID_WHAT_IT_IS: FieldSpec[] = [
199
+ { key: "kind", type: "string", validated: true, legacy: true, help: "no longer read — the path says what this is" },
200
+ { key: "type", type: "string", validated: true, legacy: true, help: "no longer read — the path says what this is" },
201
+ ];
202
+
203
+ export const FRONTMATTER: Readonly<Record<SchemaKind, readonly FieldSpec[]>> = {
204
+ agent: [...AGENT, ...SAID_WHAT_IT_IS],
205
+ "agents-md": AGENTS_MD,
206
+ flow: [...FLOW, ...SAID_WHAT_IT_IS],
207
+ tool: [...TOOL, ...SAID_WHAT_IT_IS.filter((f) => f.key !== "type")],
208
+ skill: [...SKILL, ...SAID_WHAT_IT_IS],
209
+ eval: [...EVAL, ...SAID_WHAT_IT_IS],
210
+ hook: HOOK,
211
+ };
212
+
213
+ /** Which schema a workspace-relative path is read with, or null. */
214
+ export function schemaKindOf(p: string): SchemaKind | null {
215
+ // A library entry is `library/<kind>/<path>`; read it as `<kind>/<path>`.
216
+ const rel = p.replace(/^library\/(?=[a-z]+\/)/, "");
217
+ if (/^agents\/[^/]+\/agent\.md$/.test(rel) || /^\.(?:claude|agents)\/agents\/[^/]+\.md$/.test(rel)) return "agent";
218
+ if (rel === "AGENTS.md" || rel === "project.md") return "agents-md";
219
+ if (/^flows\/[^/]+\.md$/.test(rel)) return "flow";
220
+ if (/^tools\/[^/]+\/tool\.md$/.test(rel) || /^tools\/[^/]+\.md$/.test(rel)) return "tool";
221
+ if (/(?:^|\/)skills\/[^/]+\/SKILL\.md$/.test(rel)) return "skill";
222
+ if (/^evals\/[^/]+\.md$/.test(rel)) return "eval";
223
+ if (/^hooks\/[^/]+\/hook\.md$/.test(rel)) return "hook";
224
+ return null;
225
+ }