@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.
- package/dist/src/completions.js +23 -4
- package/dist/src/cron.d.ts +17 -0
- package/dist/src/cron.js +107 -0
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +197 -0
- package/dist/src/frontmatter-schema.d.ts +15 -0
- package/dist/src/frontmatter-schema.js +156 -0
- package/dist/src/scan.js +51 -35
- package/dist/src/store.d.ts +9 -2
- package/dist/src/store.js +5 -3
- package/package.json +1 -1
- package/src/completions.ts +24 -4
- package/src/cron.ts +120 -0
- package/src/frontmatter-fields.ts +225 -0
- package/src/frontmatter-schema.ts +167 -0
- package/src/scan.ts +52 -35
- package/src/store.ts +5 -3
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
|
|
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
|
-
"
|
|
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++)
|
|
@@ -644,6 +646,13 @@ function ignored(ig, rule, line) {
|
|
|
644
646
|
/** Installed and built code: someone else's files, and a crypto library's
|
|
645
647
|
* constants are not this workspace's keys. */
|
|
646
648
|
const VENDORED = /(?:^|\/)(?:node_modules|\.venv|venv|site-packages|__pycache__|vendor|dist|build|\.next)\//;
|
|
649
|
+
/** What a run leaves in an agent's folder — the Claude CLI's `.cache/` logs,
|
|
650
|
+
* package caches — and copies back with it. Nobody wrote these, so they are
|
|
651
|
+
* not checked as prompts or code; they are still searched for credentials,
|
|
652
|
+
* since a key in a log here is a key in the workspace (seen live on
|
|
653
|
+
* 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
|
|
654
|
+
* text). */
|
|
655
|
+
const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.foldrun)\//;
|
|
647
656
|
/** Text files only — a PNG read as UTF-8 is noise to every rule here. */
|
|
648
657
|
function isText(f) {
|
|
649
658
|
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);
|
|
@@ -658,7 +667,7 @@ export function scanFiles(files, options = {}) {
|
|
|
658
667
|
const findings = [];
|
|
659
668
|
const seen = new Set();
|
|
660
669
|
const ignoresByFile = new Map();
|
|
661
|
-
const push = (rule, file, line, text, message) => {
|
|
670
|
+
const push = (rule, file, line, text, message, level) => {
|
|
662
671
|
if (!enabled(rule))
|
|
663
672
|
return;
|
|
664
673
|
const ig = ignoresByFile.get(file);
|
|
@@ -668,7 +677,7 @@ export function scanFiles(files, options = {}) {
|
|
|
668
677
|
if (seen.has(key))
|
|
669
678
|
return;
|
|
670
679
|
seen.add(key);
|
|
671
|
-
findings.push({ rule, level: scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
|
|
680
|
+
findings.push({ rule, level: level ?? scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
|
|
672
681
|
};
|
|
673
682
|
const outward = new Set(options.outwardTools ?? []);
|
|
674
683
|
const agents = [];
|
|
@@ -677,20 +686,27 @@ export function scanFiles(files, options = {}) {
|
|
|
677
686
|
const lines = f.content.split(/\r?\n/);
|
|
678
687
|
ignoresByFile.set(f.path, readIgnores(f, lines, push));
|
|
679
688
|
scanSecrets(f, lines, push);
|
|
689
|
+
if (RUNTIME_WRITTEN.test(f.path))
|
|
690
|
+
continue;
|
|
680
691
|
scanContent(f, lines, push);
|
|
681
692
|
scanSupplyChain(f, lines, push);
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const data = frontOf(f.content);
|
|
693
|
+
const schema = schemaKindOf(f.path);
|
|
694
|
+
// A skill with no frontmatter at all is the clearest one that never
|
|
695
|
+
// 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;
|
|
686
697
|
if (!data)
|
|
687
698
|
continue;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
699
|
+
if (schema) {
|
|
700
|
+
for (const p of frontmatterProblems(schema, data, nearestField)) {
|
|
701
|
+
// Fingerprint the key's own line, not the parsed value: a YAML alias
|
|
702
|
+
// can make the value circular, and JSON of it would throw.
|
|
703
|
+
const at = keyLine(lines, p.key);
|
|
704
|
+
push(p.rule, f.path, at, `${p.rule} ${at ? lines[at - 1] : p.key}`, p.message, p.level);
|
|
705
|
+
}
|
|
693
706
|
}
|
|
707
|
+
const kind = docKind(f.path);
|
|
708
|
+
if (!kind)
|
|
709
|
+
continue;
|
|
694
710
|
if (kind === "agent") {
|
|
695
711
|
const rel = f.path.replace(/^library\//, "");
|
|
696
712
|
const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
|
package/dist/src/store.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
package/src/completions.ts
CHANGED
|
@@ -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_]
|
|
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(
|
|
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
|
+
}
|