@foldrun/core 1.2.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.
@@ -9,6 +9,7 @@
9
9
  // go here. No React, no fetch — so the rules are testable and one component
10
10
  // renders them.
11
11
  import { PROVIDERS } from "./providers.js";
12
+ import { FRONTMATTER } from "./frontmatter-fields.js";
12
13
  const MODELS = [
13
14
  { label: "fast", hint: "haiku — cheap, for checks and triage" },
14
15
  { label: "default", hint: "sonnet — the working default" },
@@ -157,8 +158,6 @@ const FIELDS = {
157
158
  { label: "trigger", insert: "trigger: manual" },
158
159
  { label: "schedule", insert: "schedule: \"0 6 * * MON\"" },
159
160
  { label: "timezone", insert: "timezone: Australia/Sydney" },
160
- { label: "language", insert: "language: en-AU" },
161
- { label: "region", insert: "region: au", hint: "currency, units, calendar, weekend and text direction follow" },
162
161
  { label: "model", insert: "model: fast", hint: "every step, unless the step says" },
163
162
  { label: "effort", insert: "effort: high", hint: "every step, unless the step says" },
164
163
  ],
@@ -216,6 +215,26 @@ export function fileKind(path) {
216
215
  return "evalFile";
217
216
  return "note";
218
217
  }
218
+ /** Which frontmatter schema a completion kind offers. */
219
+ const SCHEMA_OF = { agent: "agent", flow: "flow", tool: "tool", skill: "skill", evalFile: "eval" };
220
+ /**
221
+ * The fields to offer at the start of a frontmatter line: the hand-written
222
+ * snippets above where a field has one, then every other field the schema
223
+ * says this kind of file takes — so a field added to frontmatter-fields.ts is
224
+ * offered here with no second edit, and a snippet for a field the runtime no
225
+ * longer reads is dropped. AGENTS.md takes its own set, not an agent's.
226
+ */
227
+ function fieldsFor(path, kind) {
228
+ const agentsMd = /(^|\/)AGENTS\.md$/.test(path) || path === "project.md";
229
+ const schema = agentsMd ? FRONTMATTER["agents-md"] : SCHEMA_OF[kind] ? FRONTMATTER[SCHEMA_OF[kind]] : null;
230
+ if (!schema)
231
+ return FIELDS[kind];
232
+ const offered = schema.filter((f) => !f.legacy);
233
+ const keys = new Set(offered.map((f) => f.key));
234
+ const curated = FIELDS[kind].filter((c) => keys.has(c.label.split(".")[0]));
235
+ const have = new Set(curated.map((c) => c.label.split(".")[0]));
236
+ return [...curated, ...offered.filter((f) => !have.has(f.key)).map((f) => ({ label: f.key, insert: `${f.key}: `, hint: f.help }))];
237
+ }
219
238
  /**
220
239
  * Does [[ ]] in this file get expanded into a path, or stay literal?
221
240
  *
@@ -581,9 +600,9 @@ export function completionsAt(path, text, cursor, vocab) {
581
600
  // this kind of file. This is where you learn the format by writing it.
582
601
  const fmEnd = text.indexOf("\n---", text.indexOf("---") + 3);
583
602
  const inFrontmatter = text.startsWith("---") && (fmEnd === -1 || cursor <= fmEnd);
584
- if (inFrontmatter && /^[a-zA-Z_]*$/.test(line)) {
603
+ if (inFrontmatter && /^[a-zA-Z_][a-zA-Z_-]*$|^$/.test(line)) {
585
604
  const from = cursor - line.length;
586
- return { from, query: line, items: filter(FIELDS[kind], line), title: `${kind} fields` };
605
+ return { from, query: line, items: filter(fieldsFor(path, kind), line), title: `${kind} fields` };
587
606
  }
588
607
  return null;
589
608
  }
@@ -0,0 +1,17 @@
1
+ export declare const NAMED: Record<string, string>;
2
+ export declare const DOW_NAMES: string[];
3
+ export interface Cron {
4
+ minute: Set<number>;
5
+ hour: Set<number>;
6
+ dom: Set<number>;
7
+ month: Set<number>;
8
+ dow: Set<number>;
9
+ domRestricted: boolean;
10
+ dowRestricted: boolean;
11
+ /** Neither minute nor hour is a wildcard: a job at a time of day, which
12
+ * is what the DST rules in cronMatches apply to (as in Vixie cron). */
13
+ fixedTime: boolean;
14
+ }
15
+ export declare function parseCron(expr: string): Cron | null;
16
+ /** Why `schedule:` is not a cron the scheduler can run, or null when it is. */
17
+ export declare function cronProblem(expr: unknown): string | null;
@@ -0,0 +1,107 @@
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
+ export const NAMED = {
8
+ "@hourly": "0 * * * *",
9
+ "@daily": "0 0 * * *",
10
+ "@midnight": "0 0 * * *",
11
+ "@weekly": "0 0 * * 0",
12
+ "@monthly": "0 0 1 * *",
13
+ };
14
+ export const DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
15
+ const MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
16
+ // Expand one cron field ("*", "*/15", "1-5", "mon,fri", "3") to a value set.
17
+ function expandField(field, min, max, names) {
18
+ const out = new Set();
19
+ for (const part of field.split(",")) {
20
+ const [rangePart, stepPart] = part.split("/");
21
+ const step = stepPart ? Number(stepPart) : 1;
22
+ if (!Number.isInteger(step) || step < 1)
23
+ return null;
24
+ let lo = min;
25
+ let hi = max;
26
+ if (rangePart !== "*") {
27
+ const bounds = rangePart.split("-");
28
+ const parse = (raw) => {
29
+ const token = raw.trim().toLowerCase();
30
+ if (names) {
31
+ // A name counts from the field's own minimum: SUN is 0 on the
32
+ // weekday field, JAN is 1 on the month field. Indexed from zero,
33
+ // JAN read as 0 (refused — the flow never fired) and FEB…DEC as
34
+ // the month before.
35
+ const idx = names.indexOf(token);
36
+ if (idx !== -1)
37
+ return min + idx;
38
+ }
39
+ const n = Number(token);
40
+ return Number.isInteger(n) ? n : null;
41
+ };
42
+ const a = parse(bounds[0]);
43
+ if (a === null)
44
+ return null;
45
+ lo = a;
46
+ if (bounds.length > 1) {
47
+ const b = parse(bounds[1]);
48
+ if (b === null)
49
+ return null;
50
+ hi = b;
51
+ }
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)
57
+ return null;
58
+ for (let v = lo; v <= hi; v += step)
59
+ out.add(v);
60
+ }
61
+ return out;
62
+ }
63
+ /** `@daily` → its five fields; anything else as written. An own-property
64
+ * lookup, so `schedule: constructor` is a bad cron, not a crash. */
65
+ function expand(expr) {
66
+ const key = expr.trim().toLowerCase();
67
+ return (Object.hasOwn(NAMED, key) ? NAMED[key] : expr).trim();
68
+ }
69
+ export function parseCron(expr) {
70
+ if (typeof expr !== "string")
71
+ return null;
72
+ const normalized = expand(expr);
73
+ const fields = normalized.split(/\s+/);
74
+ if (fields.length !== 5)
75
+ return null;
76
+ const [mi, ho, dm, mo, dw] = fields;
77
+ const minute = expandField(mi, 0, 59);
78
+ const hour = expandField(ho, 0, 23);
79
+ const dom = expandField(dm, 1, 31);
80
+ const month = expandField(mo, 1, 12, MONTH_NAMES);
81
+ const dowRaw = expandField(dw, 0, 7, DOW_NAMES);
82
+ if (!minute || !hour || !dom || !month || !dowRaw)
83
+ return null;
84
+ // cron allows both 0 and 7 for Sunday.
85
+ const dowSet = new Set([...dowRaw].map((d) => (d === 7 ? 0 : d)));
86
+ return {
87
+ minute,
88
+ hour,
89
+ dom,
90
+ month,
91
+ dow: dowSet,
92
+ domRestricted: dm.trim() !== "*",
93
+ dowRestricted: dw.trim() !== "*",
94
+ fixedTime: !mi.startsWith("*") && !ho.startsWith("*"),
95
+ };
96
+ }
97
+ /** Why `schedule:` is not a cron the scheduler can run, or null when it is. */
98
+ export function cronProblem(expr) {
99
+ if (typeof expr !== "string" || !expr.trim())
100
+ return "schedule: is a cron expression — five fields, or @hourly, @daily, @weekly, @monthly";
101
+ if (parseCron(expr))
102
+ return null;
103
+ const fields = expand(expr).split(/\s+/).length;
104
+ return fields !== 5
105
+ ? `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`
106
+ : `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`;
107
+ }
@@ -0,0 +1,27 @@
1
+ export type FieldType = "string" | "number" | "boolean" | "list" | "object";
2
+ export interface FieldSpec {
3
+ key: string;
4
+ /** What YAML may hand over. "list" is also offered where the runtime
5
+ * splits `a, b` on one line, so such fields list "string" too. */
6
+ type: FieldType | FieldType[];
7
+ /** The values that do something, when the set is closed and has no
8
+ * aliases. Anything else is reported. */
9
+ values?: readonly string[];
10
+ /** Another function validates this field in full and is the one place
11
+ * that does; the schema only says the field exists. */
12
+ validated?: boolean;
13
+ /** An older name still read, or kept so the right message (not
14
+ * "unknown") reaches whoever wrote it. Not offered as a completion. */
15
+ legacy?: boolean;
16
+ /** One line: what it is. The completion's hint. */
17
+ help: string;
18
+ }
19
+ export type SchemaKind = "agent" | "agents-md" | "flow" | "tool" | "skill" | "eval" | "hook";
20
+ /** What can start a flow. store.ts re-exports it; parseFlow and the editor
21
+ * read this one. */
22
+ export declare const FLOW_TRIGGERS: readonly ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
23
+ /** The effort levels; store.ts re-exports it with the aliases beside. */
24
+ export declare const EFFORT_LEVELS: readonly ["low", "medium", "high", "xhigh", "max"];
25
+ export declare const FRONTMATTER: Readonly<Record<SchemaKind, readonly FieldSpec[]>>;
26
+ /** Which schema a workspace-relative path is read with, or null. */
27
+ export declare function schemaKindOf(p: string): SchemaKind | null;
@@ -0,0 +1,197 @@
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
+ /** What can start a flow. store.ts re-exports it; parseFlow and the editor
19
+ * read this one. */
20
+ export const FLOW_TRIGGERS = ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
21
+ /** The effort levels; store.ts re-exports it with the aliases beside. */
22
+ export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
23
+ const LOCALE = { validated: true };
24
+ const AGENT = [
25
+ { key: "name", type: "string", help: "kebab-case; the folder is the identity" },
26
+ { key: "description", type: "string", help: "what it does and when — other agents and people read it" },
27
+ { key: "model", type: "string", help: "fast | default | max, an alias, or a full model id" },
28
+ { key: "effort", type: "string", values: EFFORT_LEVELS, help: "how long it thinks" },
29
+ { key: "size", type: "string", values: ["small", "large", "heavy"], help: "the sandbox it rents; anything else becomes large" },
30
+ { key: "permissionMode", type: "string", values: ["plan"], help: "plan = read-only; no other mode changes anything here" },
31
+ { key: "tools", type: ["list", "string"], help: "the one grant list" },
32
+ { key: "disallowedTools", type: ["list", "string"], help: "subtract from what it would otherwise have" },
33
+ { key: "skills", type: ["list", "string"], help: "an allowlist; absent inherits every skill" },
34
+ { key: "hooks", type: ["list", "string", "object"], help: "hook names; a Claude Code hooks: object is read as none" },
35
+ { key: "scripts", type: "list", help: "programs in scripts/, each a tool" },
36
+ { key: "apis", type: "list", help: "an HTTP API as a tool" },
37
+ { key: "mcpServers", type: "object", help: "MCP servers, name → {command | url, args, env, headers}" },
38
+ { key: "agents", type: ["list", "string"], help: "colleagues it may consult" },
39
+ { key: "subagents", type: ["list", "string"], help: "colleagues it may delegate to" },
40
+ { key: "secrets", type: ["list", "string"], help: "keys it spends itself" },
41
+ { key: "budget", type: ["number", "string"], help: "USD per run" },
42
+ { key: "limits", type: "object", validated: true, help: "per-step call limits" },
43
+ { key: "network", type: ["string", "list", "object"], validated: true, help: "open | trusted | none, sites, or {level, allow, defaults}" },
44
+ { key: "web", type: "object", validated: true, help: "providers for the web tool's actions" },
45
+ { key: "provider", type: "object", help: "an Anthropic- or OpenAI-format endpoint for this agent" },
46
+ { key: "runtime", type: "object", help: "packages and system packages for its sandbox" },
47
+ { key: "ask", type: "object", help: "{timeout} for tools: [ask]" },
48
+ { key: "timezone", type: "string", ...LOCALE, help: "IANA name or offset" },
49
+ { key: "language", type: "string", ...LOCALE, help: "BCP-47 tag" },
50
+ { key: "region", type: "string", ...LOCALE, help: "two-letter country" },
51
+ { key: "currency", type: "string", ...LOCALE, help: "three letters" },
52
+ { key: "units", type: "string", ...LOCALE, help: "metric | imperial" },
53
+ { key: "calendar", type: "string", ...LOCALE, help: "an Intl calendar" },
54
+ { key: "schedule", type: ["string", "object"], validated: true, legacy: true, help: "refused: only a flow has a clock" },
55
+ { key: "use", type: ["list", "string"], validated: true, legacy: true, help: "retired: write tools:" },
56
+ ];
57
+ const AGENTS_MD = [
58
+ { key: "foldrun_version", type: ["string", "number"], help: "the format version this account targets" },
59
+ { key: "mdagent_version", type: ["string", "number"], legacy: true, help: "older spelling of foldrun_version" },
60
+ { key: "name", type: "string", help: "the workspace's name" },
61
+ { key: "description", type: "string", help: "shown in the workspace list" },
62
+ { key: "provider", type: "object", help: "the model endpoint every agent here uses unless it names its own" },
63
+ { key: "runtime", type: "object", help: "packages for agents that declare none" },
64
+ { key: "web", type: "object", validated: true, help: "web.browse defaults" },
65
+ { key: "network", type: ["string", "list", "object"], validated: true, help: "every agent's default network" },
66
+ { key: "limits", type: "object", validated: true, help: "default call limits" },
67
+ { key: "hooks", type: ["list", "string"], help: "hooks every agent here runs" },
68
+ { key: "timezone", type: "string", ...LOCALE, help: "default timezone" },
69
+ { key: "language", type: "string", ...LOCALE, help: "default language" },
70
+ { key: "region", type: "string", ...LOCALE, help: "default region" },
71
+ { key: "currency", type: "string", ...LOCALE, help: "default currency" },
72
+ { key: "units", type: "string", ...LOCALE, help: "default units" },
73
+ { key: "calendar", type: "string", ...LOCALE, help: "default calendar" },
74
+ { key: "notify", type: ["string", "object"], help: "an email or URL told about failed runs and waiting approvals" },
75
+ { key: "budget", type: ["number", "string"], help: "USD per month, or N/day | N/week | N/month" },
76
+ { key: "priority", type: "string", values: ["high", "normal", "low"], help: "account: where its runs stand in the queue" },
77
+ { key: "concurrency", type: ["number", "string"], help: "account: runs at once" },
78
+ { key: "schedule", type: ["string", "object"], validated: true, legacy: true, help: "refused: only a flow has a clock" },
79
+ ];
80
+ const FLOW = [
81
+ { key: "name", type: "string", help: "the flow's name" },
82
+ { key: "description", type: "string", help: "what it is for" },
83
+ { key: "trigger", type: "string", values: FLOW_TRIGGERS, help: "what starts it; any other value never fires" },
84
+ { key: "schedule", type: "string", help: "cron, when trigger: schedule" },
85
+ { key: "timezone", type: "string", validated: true, help: "the zone its schedule reads in" },
86
+ { key: "model", type: "string", help: "every step's model unless the step says" },
87
+ { key: "effort", type: "string", values: EFFORT_LEVELS, help: "every step's effort unless the step says" },
88
+ { key: "overlap", type: "string", validated: true, help: "skip | queue" },
89
+ { key: "priority", type: "string", validated: true, help: "high | normal | low" },
90
+ { key: "catchup", type: "string", validated: true, help: "last | none" },
91
+ { key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
92
+ { key: "signature", type: "string", validated: true, help: "github | stripe | slack | hmac" },
93
+ { key: "signing_secret", type: "string", help: "the secret a webhook's signature is checked with" },
94
+ { key: "after", type: ["string", "list"], help: "trigger: flow — the flow this one follows" },
95
+ { key: "at", type: "string", help: "trigger: once — when" },
96
+ { key: "url", type: "string", help: "trigger: watch — the page watched" },
97
+ { key: "every", type: ["string", "number"], help: "trigger: watch — how often" },
98
+ { key: "path", type: "string", help: "trigger: storage — the prefix under storage/" },
99
+ { key: "idempotency", type: "string", help: "a header name, or body:<field>, that marks a repeat delivery" },
100
+ { key: "debounce", type: ["string", "number"], help: "wait for a burst to end" },
101
+ { key: "throttle", type: ["string", "number"], help: "at most one run per window" },
102
+ { key: "sla", type: ["string", "number"], help: "tell someone when a run outlives it" },
103
+ { key: "approve_within", type: ["string", "number"], help: "how long an approval may wait" },
104
+ { key: "disable_after", type: ["number", "string"], help: "switch the flow off after this many failures in a row" },
105
+ { key: "pause_when", type: "string", help: "a marker that pauses the flow when a run's result says it" },
106
+ { key: "approvers", type: ["list", "string"], help: "who may approve its gates" },
107
+ { key: "budget", type: ["number", "string"], help: "USD per run" },
108
+ { key: "live", type: "boolean", help: "true: steps may act for real in a test run" },
109
+ ];
110
+ const TOOL = [
111
+ { key: "name", type: "string", help: "defaults to the file or folder name" },
112
+ { key: "description", type: "string", help: "what it does — the model chooses by it" },
113
+ { key: "transport", type: "string", values: ["http", "script", "mcp"], help: "http | script | mcp" },
114
+ { key: "type", type: "string", legacy: true, help: "older spelling of transport" },
115
+ { key: "outward", type: "boolean", help: "true: it changes something outside the workspace" },
116
+ { key: "base", type: "string", help: "http: the API's base URL" },
117
+ { key: "headers", type: "object", help: "http or mcp: request headers; ${SECRET} is filled server-side" },
118
+ { key: "query", type: "object", help: "http: query parameters on every call" },
119
+ { key: "methods", type: ["list", "string"], help: "http: GET, POST, PUT, PATCH, DELETE" },
120
+ { key: "openapi", type: "string", help: "http: an OpenAPI document for the operations" },
121
+ { key: "operations", type: ["list", "object"], help: "http: which operations to offer" },
122
+ { key: "rate", type: ["string", "number"], help: "http: N/s, N/minute, N/hour, or a number per second" },
123
+ { key: "timeout", type: ["number", "string"], help: "seconds one call may take" },
124
+ { key: "run", type: "string", help: "script: the program, relative to the tool" },
125
+ { key: "args", type: ["object", "list"], help: "script: its arguments, name → what it is; mcp: the command's arguments" },
126
+ { key: "interpreter", type: "string", help: "script: what runs it" },
127
+ { key: "test_mode", type: "string", values: ["allow"], help: "script: allow — it may run in a test run" },
128
+ { key: "secrets", type: ["list", "string"], help: "script: secret names, or proxied | materialised" },
129
+ { key: "runtime", type: "object", help: "script: packages it needs" },
130
+ { key: "code", type: "string", help: "script: the program inline" },
131
+ { key: "command", type: "string", help: "mcp: the server's command" },
132
+ { key: "env", type: "object", help: "mcp: its environment" },
133
+ { key: "url", type: "string", help: "mcp: a server over http" },
134
+ { key: "guide", type: "string", legacy: true, help: "ignored — the guide is the body" },
135
+ ];
136
+ const SKILL = [
137
+ { key: "name", type: "string", help: "defaults to the folder" },
138
+ { key: "description", type: "string", help: "required — the model chooses a skill by it" },
139
+ { key: "when", type: ["list", "string"], help: "run tags it loads on" },
140
+ // The Agent Skills format's own optional fields: not read, not wrong.
141
+ { key: "license", type: "string", help: "Agent Skills format" },
142
+ { key: "compatibility", type: "string", help: "Agent Skills format" },
143
+ { key: "metadata", type: "object", help: "Agent Skills format" },
144
+ { key: "allowed-tools", type: ["string", "list"], help: "Agent Skills format: space-separated pre-approved tools (experimental)" },
145
+ ];
146
+ const EVAL = [
147
+ { key: "name", type: "string", help: "the eval's name" },
148
+ { key: "agent", type: "string", help: "the agent it tests" },
149
+ { key: "flow", type: "string", help: "or the flow" },
150
+ { key: "model", type: "string", help: "the judge's model" },
151
+ { key: "effort", type: "string", values: EFFORT_LEVELS, help: "the judge's effort" },
152
+ { key: "trigger", type: "string", values: ["deploy", "manual"], help: "deploy (default) | manual; anything else is deploy" },
153
+ { key: "live", type: "boolean", help: "true: real calls" },
154
+ { key: "inputs", type: "boolean", help: "true: cases carry inputs" },
155
+ ];
156
+ const HOOK = [
157
+ { key: "on", type: "string", validated: true, help: "tool.call | tool.result | step.start | step.end" },
158
+ { key: "tool", type: "string", validated: true, help: "only on tool events" },
159
+ { key: "timeout", type: "number", validated: true, help: "seconds, 1 to 120" },
160
+ { key: "description", type: "string", help: "what it guards" },
161
+ { key: "name", type: "string", help: "the folder is the name" },
162
+ ];
163
+ /** `kind:` and `type:` were how a document once said what it was; the path
164
+ * says it now, and `foldrun check` has its own message for them. */
165
+ const SAID_WHAT_IT_IS = [
166
+ { key: "kind", type: "string", validated: true, legacy: true, help: "no longer read — the path says what this is" },
167
+ { key: "type", type: "string", validated: true, legacy: true, help: "no longer read — the path says what this is" },
168
+ ];
169
+ export const FRONTMATTER = {
170
+ agent: [...AGENT, ...SAID_WHAT_IT_IS],
171
+ "agents-md": AGENTS_MD,
172
+ flow: [...FLOW, ...SAID_WHAT_IT_IS],
173
+ tool: [...TOOL, ...SAID_WHAT_IT_IS.filter((f) => f.key !== "type")],
174
+ skill: [...SKILL, ...SAID_WHAT_IT_IS],
175
+ eval: [...EVAL, ...SAID_WHAT_IT_IS],
176
+ hook: HOOK,
177
+ };
178
+ /** Which schema a workspace-relative path is read with, or null. */
179
+ export function schemaKindOf(p) {
180
+ // A library entry is `library/<kind>/<path>`; read it as `<kind>/<path>`.
181
+ const rel = p.replace(/^library\/(?=[a-z]+\/)/, "");
182
+ if (/^agents\/[^/]+\/agent\.md$/.test(rel) || /^\.(?:claude|agents)\/agents\/[^/]+\.md$/.test(rel))
183
+ return "agent";
184
+ if (rel === "AGENTS.md" || rel === "project.md")
185
+ return "agents-md";
186
+ if (/^flows\/[^/]+\.md$/.test(rel))
187
+ return "flow";
188
+ if (/^tools\/[^/]+\/tool\.md$/.test(rel) || /^tools\/[^/]+\.md$/.test(rel))
189
+ return "tool";
190
+ if (/(?:^|\/)skills\/[^/]+\/SKILL\.md$/.test(rel))
191
+ return "skill";
192
+ if (/^evals\/[^/]+\.md$/.test(rel))
193
+ return "eval";
194
+ if (/^hooks\/[^/]+\/hook\.md$/.test(rel))
195
+ return "hook";
196
+ return null;
197
+ }
@@ -0,0 +1,15 @@
1
+ import { type SchemaKind } from "./frontmatter-fields.ts";
2
+ export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
3
+ export interface FieldProblem {
4
+ key: string;
5
+ rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field";
6
+ level: "warn" | "info";
7
+ message: string;
8
+ }
9
+ /**
10
+ * What is wrong with one file's frontmatter, by its schema: a key nothing
11
+ * reads (a near-miss names the field it meant), a value of the wrong type,
12
+ * a value outside the ones that do something, a required field missing.
13
+ * `near` is the typo matcher; scan.ts passes its own.
14
+ */
15
+ export declare function frontmatterProblems(kind: SchemaKind, data: Record<string, unknown>, near: (key: string, known: readonly string[]) => string | null): FieldProblem[];
@@ -0,0 +1,156 @@
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
+ import { FRONTMATTER } from "./frontmatter-fields.js";
5
+ import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS } from "./store.js";
6
+ import { budgetProblem } from "./budget.js";
7
+ import { cronProblem } from "./cron.js";
8
+ export { FRONTMATTER, schemaKindOf } from "./frontmatter-fields.js";
9
+ /** A value for a message: a scalar as written, anything else by its shape.
10
+ * Never String() of a map — its `toString` is whatever the YAML says. */
11
+ function shown(v) {
12
+ return typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? String(v) : v instanceof Date ? v.toISOString() : Array.isArray(v) ? "a list" : "a map";
13
+ }
14
+ const DURATION = (min, max, unit = "") => (v) => {
15
+ if (typeof v !== "string" && typeof v !== "number")
16
+ return `is ${shown(v)}, not a duration — write seconds (900) or 30s, 15m, 4h, 3d`;
17
+ const s = parseDuration(String(v));
18
+ if (s === undefined)
19
+ return `"${String(v)}" is not a duration — write seconds (900) or 30s, 15m, 4h, 3d`;
20
+ if (s < min || s > max)
21
+ return `"${String(v)}" is outside ${unit || `${min}s to ${max}s`}`;
22
+ return null;
23
+ };
24
+ /** Why a value is wrong, or null — per kind, per field: the rules a list of
25
+ * values cannot say (aliases, durations, cron, budgets). */
26
+ const CHECKS = {
27
+ "agent": {
28
+ 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`),
29
+ apis: apisProblem,
30
+ budget: (v) => budgetProblem(v, ["run"]),
31
+ ask: (v) => askProblem(v),
32
+ },
33
+ "agents-md": {
34
+ budget: (v) => budgetProblem(v, ["day", "week", "month"]),
35
+ concurrency: (v) => (Number.isInteger(Number(v)) && Number(v) > 0 ? null : `${String(v)} is not a whole number of runs, 1 or more`),
36
+ },
37
+ "flow": {
38
+ schedule: cronProblem,
39
+ effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max`),
40
+ at: (v) => (parseInstant(v) ? null : `"${String(v)}" is not an ISO 8601 instant — a once flow with it never fires`),
41
+ url: (v) => (/^https?:\/\//i.test(String(v)) ? null : `"${String(v)}" is not an http(s) URL`),
42
+ every: DURATION(1, 30 * 86400, "1s to 30 days"),
43
+ debounce: DURATION(1, 30 * 86400, "1s to 30 days"),
44
+ throttle: DURATION(1, 30 * 86400, "1s to 30 days"),
45
+ sla: DURATION(1, 30 * 86400, "1s to 30 days"),
46
+ approve_within: DURATION(1, 30 * 86400, "1s to 30 days"),
47
+ disable_after: (v) => (Number(v) > 0 && Number(v) <= 100 ? null : `${String(v)} is not a count from 1 to 100; it is ignored`),
48
+ pause_when: (v) => (String(v).length <= 60 ? null : "is longer than 60 characters"),
49
+ budget: (v) => budgetProblem(v, ["run"]),
50
+ },
51
+ "tool": {
52
+ transport: (v) => (["http", "script", "mcp"].includes(String(v).toLowerCase()) ? null : `"${String(v)}" is not a transport — http, script or mcp`),
53
+ methods: methodsProblem,
54
+ rate: (v) => (parseRate(v) ? null : `"${shown(v)}" is not a rate — N/s, N/minute, N/hour, or a number per second, above 0`),
55
+ },
56
+ "eval": {
57
+ effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max; the judge runs at none`),
58
+ },
59
+ };
60
+ // YAML reads an unquoted 2026-11-01T09:00:00Z as a Date; the runtime takes
61
+ // it as the string it was written as, so it is one here too.
62
+ const typeOf = (v) => v === null || v === undefined ? "null" : v instanceof Date ? "string" : Array.isArray(v) ? "list" : typeof v === "object" ? "object" : typeof v;
63
+ /** Where a key that is not read in this kind of file IS read, for a message
64
+ * that says where it belongs. */
65
+ function belongsIn(key, kind) {
66
+ const where = [["agent", "an agent"], ["agents-md", "AGENTS.md"], ["flow", "a flow"], ["tool", "a tool"]];
67
+ const homes = where.filter(([k]) => k !== kind && FRONTMATTER[k].some((f) => f.key === key && !f.legacy)).map(([, label]) => label);
68
+ return homes.length ? homes.join(" or ") : null;
69
+ }
70
+ /**
71
+ * What is wrong with one file's frontmatter, by its schema: a key nothing
72
+ * reads (a near-miss names the field it meant), a value of the wrong type,
73
+ * a value outside the ones that do something, a required field missing.
74
+ * `near` is the typo matcher; scan.ts passes its own.
75
+ */
76
+ export function frontmatterProblems(kind, data, near) {
77
+ const fields = FRONTMATTER[kind];
78
+ const known = fields.map((f) => f.key);
79
+ const out = [];
80
+ for (const [key, value] of Object.entries(data)) {
81
+ const spec = fields.find((f) => f.key === key);
82
+ const check = CHECKS[kind]?.[key];
83
+ if (!spec) {
84
+ const meant = near(key, known);
85
+ const home = belongsIn(key, kind);
86
+ if (meant && meant !== key) {
87
+ 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` });
88
+ }
89
+ else if (kind === "agent" && STEP_OPTION_KEYS.has(key)) {
90
+ // `timeout: 3600` on an agent reads like a setting and is not one:
91
+ // it is an option of a flow's step.
92
+ 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` });
93
+ }
94
+ else if (home) {
95
+ 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` });
96
+ }
97
+ else {
98
+ out.push({ key, rule: "config/unknown-field", level: "info", message: `\`${key}:\` is not a field foldrun reads; it is ignored` });
99
+ }
100
+ continue;
101
+ }
102
+ if (spec.validated || value === null || value === undefined)
103
+ continue;
104
+ const want = Array.isArray(spec.type) ? spec.type : [spec.type];
105
+ const got = typeOf(value);
106
+ // A string where a list is wanted is a one-item list wherever the
107
+ // runtime splits it, and the list types above say so.
108
+ if (!want.includes(got)) {
109
+ 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` });
110
+ continue;
111
+ }
112
+ // A field's own check knows its aliases (`effort: quick` is low), so it
113
+ // decides alone; `values` alone is for a closed set with none.
114
+ // A check reads customer YAML: a value shaped to break it (a map whose
115
+ // `toString` is a string) is a finding, never a crash of the whole scan.
116
+ let checked = null;
117
+ try {
118
+ checked = check ? check(value) : null;
119
+ }
120
+ catch {
121
+ checked = "holds a value that cannot be read";
122
+ }
123
+ const why = check
124
+ ? checked
125
+ : spec.values && typeof value === "string" && !spec.values.includes(value.trim())
126
+ ? `"${value}" is not one of ${spec.values.join(", ")} — as written it is ignored`
127
+ : null;
128
+ if (why)
129
+ out.push({ key, rule: "config/invalid-value", level: "warn", message: `\`${key}:\` ${why.replace(/^\w+: /, "")}` });
130
+ }
131
+ if (kind === "skill" && !(typeof data.description === "string" && data.description.trim())) {
132
+ 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" });
133
+ }
134
+ return out;
135
+ }
136
+ function askProblem(v) {
137
+ const t = v?.timeout;
138
+ return t === undefined ? null : DURATION(30, 86400, "30s to 24h")(t);
139
+ }
140
+ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
141
+ function methodsProblem(v) {
142
+ const list = Array.isArray(v) ? v.map(String) : String(v).split(",");
143
+ const bad = list.map((m) => m.trim()).filter((m) => m && !METHODS.includes(m.toUpperCase()));
144
+ return bad.length ? `${bad.join(", ")} — not a method; it is dropped. GET, POST, PUT, PATCH or DELETE` : null;
145
+ }
146
+ function apisProblem(v) {
147
+ for (const api of Array.isArray(v) ? v : []) {
148
+ if (!api || typeof api !== "object")
149
+ return "each entry is {name, base, …}";
150
+ const m = api.methods;
151
+ const p = m === undefined ? null : methodsProblem(m);
152
+ if (p)
153
+ return `${String(api.name ?? "an api")}: methods: ${p}`;
154
+ }
155
+ return null;
156
+ }
@@ -33,6 +33,9 @@ export interface ScanOptions {
33
33
  outwardTools?: Iterable<string>;
34
34
  /** Only these rule ids (or categories, `secret`, `security`, …). */
35
35
  only?: string[];
36
+ /** The account AGENTS.md's `network:` — the level above the workspace's,
37
+ * which the files of one workspace do not carry. */
38
+ accountNetwork?: unknown;
36
39
  }
37
40
  export declare const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
38
41
  export declare const SCAN_RULES: readonly ScanRule[];
@@ -60,6 +63,9 @@ export declare function ruleDocsUrl(id: string): string;
60
63
  export declare function findingFingerprint(rule: string, file: string, text: string): string;
61
64
  /** What a finding may show of a secret: enough to find it, not to use it. */
62
65
  export declare function redactSecret(secret: string): string;
66
+ /** The text with every credential in it redacted — for a finding that
67
+ * quotes source, so no message ever carries a key whole. */
68
+ export declare function redactText(text: string): string;
63
69
  /** The known field a key was probably meant to be, or null. */
64
70
  export declare function nearestField(key: string, known: readonly string[]): string | null;
65
71
  /**