@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.
- 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.d.ts +6 -0
- package/dist/src/scan.js +94 -47
- 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 +96 -47
- package/src/store.ts +5 -3
package/dist/src/scan.js
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
import crypto from "node:crypto";
|
|
29
29
|
import matter from "gray-matter";
|
|
30
30
|
import { refNames } from "./refs.js";
|
|
31
|
+
import { resolveNetwork } from "./network.js";
|
|
32
|
+
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
|
|
31
33
|
export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
|
|
32
34
|
export const SCAN_RULES = [
|
|
33
35
|
{
|
|
@@ -74,7 +76,8 @@ export const SCAN_RULES = [
|
|
|
74
76
|
level: "warn",
|
|
75
77
|
title: "Reads untrusted content, holds private data and can send it out",
|
|
76
78
|
help: "An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
|
|
77
|
-
"Take one leg away:
|
|
79
|
+
"Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
|
|
80
|
+
"and an agent that reads the web stays open), split the reading and the sending into two agents, " +
|
|
78
81
|
"or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
|
|
79
82
|
owasp: ["ASI01", "ASI02", "ASI03"],
|
|
80
83
|
},
|
|
@@ -118,9 +121,32 @@ export const SCAN_RULES = [
|
|
|
118
121
|
id: "config/unknown-field",
|
|
119
122
|
category: "config",
|
|
120
123
|
level: "warn",
|
|
121
|
-
title: "A frontmatter field foldrun does not read
|
|
124
|
+
title: "A frontmatter field foldrun does not read here",
|
|
122
125
|
help: "A misspelt field is not an error to YAML, so it is silently ignored: `tolls: [web]` leaves the agent with no tools. " +
|
|
123
|
-
"
|
|
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.",
|
|
124
150
|
},
|
|
125
151
|
{
|
|
126
152
|
id: "check/ignore-needs-reason",
|
|
@@ -272,6 +298,41 @@ function credentialLike(value) {
|
|
|
272
298
|
export function redactSecret(secret) {
|
|
273
299
|
return secret.length <= 8 ? "****" : `${secret.slice(0, 4)}…${secret.slice(-2)}`;
|
|
274
300
|
}
|
|
301
|
+
/** Every credential in a piece of text, as [value, what]. One reading for
|
|
302
|
+
* the secret rule and for any other rule that quotes the line. */
|
|
303
|
+
function credentialsIn(line) {
|
|
304
|
+
const out = [];
|
|
305
|
+
const seen = new Set();
|
|
306
|
+
const add = (v, what) => {
|
|
307
|
+
if (!seen.has(v)) {
|
|
308
|
+
seen.add(v);
|
|
309
|
+
out.push([v, what]);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
for (const [re, what] of SECRET_SHAPES) {
|
|
313
|
+
if (what === "a private key")
|
|
314
|
+
continue; // a header names a key; it is not one
|
|
315
|
+
for (const m of line.matchAll(re)) {
|
|
316
|
+
if (!PLACEHOLDER.test(m[0]) && entropy(m[0]) >= 3)
|
|
317
|
+
add(m[0], what);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
for (const m of line.matchAll(ASSIGNED))
|
|
321
|
+
if (credentialLike(m[2]))
|
|
322
|
+
add(m[2], `\`${m[1]}\` with a literal value`);
|
|
323
|
+
for (const m of line.matchAll(BEARER))
|
|
324
|
+
if (credentialLike(m[1]))
|
|
325
|
+
add(m[1], "a bearer token");
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
/** The text with every credential in it redacted — for a finding that
|
|
329
|
+
* quotes source, so no message ever carries a key whole. */
|
|
330
|
+
export function redactText(text) {
|
|
331
|
+
let out = text;
|
|
332
|
+
for (const [v] of credentialsIn(text).sort((a, b) => b[0].length - a[0].length))
|
|
333
|
+
out = out.split(v).join(redactSecret(v));
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
275
336
|
function scanSecrets(file, lines, push) {
|
|
276
337
|
for (let i = 0; i < lines.length; i++) {
|
|
277
338
|
const line = lines[i];
|
|
@@ -351,13 +412,13 @@ function scanContent(file, lines, push) {
|
|
|
351
412
|
const kind = HIDDEN_NAMES.find(([re]) => re.test(line))?.[1] ?? "invisible characters";
|
|
352
413
|
const n = line.match(HIDDEN)?.length ?? 0;
|
|
353
414
|
const tagged = /\uDB40[\uDC00-\uDC7F]/.test(line) ? ` that read as: "${decodeTags(line).slice(0, 80)}"` : "";
|
|
354
|
-
push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${tagged} — invisible to a reviewer, read by the model`);
|
|
415
|
+
push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${redactText(tagged)} — invisible to a reviewer, read by the model`);
|
|
355
416
|
}
|
|
356
417
|
// An instruction quoted inside a fence is an example of one, not one.
|
|
357
418
|
if (data && !fenced) {
|
|
358
419
|
const m = INJECTION.find((re) => re.test(line));
|
|
359
420
|
if (m)
|
|
360
|
-
push("content/injection-phrase", file.path, i + 1, line, `"${line.trim().slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
|
|
421
|
+
push("content/injection-phrase", file.path, i + 1, line, `"${redactText(line.trim()).slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
|
|
361
422
|
}
|
|
362
423
|
if (md) {
|
|
363
424
|
for (const b of line.matchAll(BLOB)) {
|
|
@@ -422,28 +483,6 @@ function unpinned(pkg) {
|
|
|
422
483
|
return !/[=<>~!]/.test(s) && !/.@./.test(s.replace(/^@/, "x"));
|
|
423
484
|
}
|
|
424
485
|
// ── frontmatter ────────────────────────────────────────────────────────────
|
|
425
|
-
/** What foldrun reads in an agent's frontmatter — the fields documented in
|
|
426
|
-
* agents.md, the Claude Code subagent fields an import carries, and the
|
|
427
|
-
* older spellings check already teaches about. Used only to suggest the
|
|
428
|
-
* field a typo meant: an unknown key that is nothing like any of these is
|
|
429
|
-
* left alone, since another tool may own it. */
|
|
430
|
-
const AGENT_FIELDS = [
|
|
431
|
-
"name", "description", "model", "effort", "size", "tools", "disallowedTools", "skills", "hooks", "scripts",
|
|
432
|
-
"apis", "mcpServers", "agents", "subagents", "secrets", "limits", "network", "provider", "permissionMode",
|
|
433
|
-
"runtime", "ask", "web", "budget", "timezone", "language", "region", "currency", "units", "schedule", "color",
|
|
434
|
-
"memory", "foldrun_version", "use", "kind", "type", "notify", "triggers", "inputs", "outputs",
|
|
435
|
-
];
|
|
436
|
-
const FLOW_FIELDS = [
|
|
437
|
-
"name", "description", "trigger", "schedule", "timezone", "language", "region", "currency", "units", "model",
|
|
438
|
-
"effort", "overlap", "priority", "throttle", "debounce", "sla", "catchup", "pause_when", "disable_after",
|
|
439
|
-
"approvers", "approve_within", "notify", "at", "every", "budget", "limits", "size", "hooks", "inputs",
|
|
440
|
-
"webhook", "signing_secret", "signature", "idempotency", "filter", "after", "tags", "network", "secrets",
|
|
441
|
-
];
|
|
442
|
-
const TOOL_FIELDS = [
|
|
443
|
-
"name", "description", "transport", "type", "base", "methods", "headers", "query", "auth", "run", "args",
|
|
444
|
-
"command", "url", "env", "outward", "runtime", "secrets", "network", "timeout", "spec", "operations", "body",
|
|
445
|
-
"oauth", "limits", "input", "output", "schema", "kind",
|
|
446
|
-
];
|
|
447
486
|
function editDistance(a, b) {
|
|
448
487
|
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
449
488
|
for (let j = 1; j <= b.length; j++)
|
|
@@ -502,15 +541,13 @@ function docKind(p) {
|
|
|
502
541
|
}
|
|
503
542
|
// ── agents: what one can reach, taken together ─────────────────────────────
|
|
504
543
|
const WRITE_METHODS = /\b(?:POST|PUT|PATCH|DELETE)\b/i;
|
|
505
|
-
function scanAgents(agents, outward, toolDefs, push) {
|
|
544
|
+
function scanAgents(agents, outward, toolDefs, push, networks) {
|
|
506
545
|
const byName = new Map(agents.map((a) => [a.name, a]));
|
|
507
546
|
for (const a of agents) {
|
|
508
547
|
const d = a.data;
|
|
509
548
|
const tools = refNames(d.tools);
|
|
510
549
|
const denied = new Set(refNames(d.disallowedTools));
|
|
511
550
|
const has = (t) => tools.includes(t) && !denied.has(t);
|
|
512
|
-
const network = d.network;
|
|
513
|
-
const netLevel = typeof network === "string" ? network : Array.isArray(network) ? "list" : network && typeof network === "object" ? "list" : null;
|
|
514
551
|
// Untrusted: it reads the open web. (A webhook body is the other source;
|
|
515
552
|
// that is a flow's trigger, and the flow-level rule is a later one.)
|
|
516
553
|
const readsWeb = has("web") || tools.some((t) => /browse|fetch|scrape|crawl/i.test(t) && has(t));
|
|
@@ -519,16 +556,18 @@ function scanAgents(agents, outward, toolDefs, push) {
|
|
|
519
556
|
const apis = Array.isArray(d.apis) ? d.apis : [];
|
|
520
557
|
const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
|
|
521
558
|
const privateLeg = [...secrets.map((s) => `secret ${s}`), ...apis.map((x) => `api ${x?.name ?? "?"}`), ...mcp.map((m) => `MCP ${m}`)];
|
|
522
|
-
// Out: a sandbox open to every site
|
|
523
|
-
//
|
|
524
|
-
|
|
559
|
+
// Out: a sandbox open to every site, a tool marked outward, or an API it
|
|
560
|
+
// may write to. The level is the runtime's own resolution — agent, then
|
|
561
|
+
// workspace, then account, and open by default for an agent that reads
|
|
562
|
+
// the web; a bare list of sites adds to a level, it does not set one.
|
|
563
|
+
const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
|
|
525
564
|
const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
|
|
526
565
|
const writeApis = apis.filter((x) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
|
|
527
566
|
const outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x) => `writes to api ${x?.name ?? "?"}`)];
|
|
528
567
|
const gated = refNames(d.hooks).length > 0 || d.permissionMode === "plan";
|
|
529
568
|
if (readsWeb && privateLeg.length && outLeg.length && !gated) {
|
|
530
569
|
push("security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`, `${a.name} reads the web, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
|
|
531
|
-
`a page it reads can tell it where to send what it holds.
|
|
570
|
+
`a page it reads can tell it where to send what it holds. Take it off the open network (network: { level: trusted, allow: [...] }), split it, or add a hooks: gate`);
|
|
532
571
|
}
|
|
533
572
|
// Sub-agents declaring tools the parent lacks.
|
|
534
573
|
for (const s of refNames(d.subagents)) {
|
|
@@ -621,7 +660,7 @@ export function scanFiles(files, options = {}) {
|
|
|
621
660
|
const findings = [];
|
|
622
661
|
const seen = new Set();
|
|
623
662
|
const ignoresByFile = new Map();
|
|
624
|
-
const push = (rule, file, line, text, message) => {
|
|
663
|
+
const push = (rule, file, line, text, message, level) => {
|
|
625
664
|
if (!enabled(rule))
|
|
626
665
|
return;
|
|
627
666
|
const ig = ignoresByFile.get(file);
|
|
@@ -631,7 +670,7 @@ export function scanFiles(files, options = {}) {
|
|
|
631
670
|
if (seen.has(key))
|
|
632
671
|
return;
|
|
633
672
|
seen.add(key);
|
|
634
|
-
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) });
|
|
635
674
|
};
|
|
636
675
|
const outward = new Set(options.outwardTools ?? []);
|
|
637
676
|
const agents = [];
|
|
@@ -642,18 +681,23 @@ export function scanFiles(files, options = {}) {
|
|
|
642
681
|
scanSecrets(f, lines, push);
|
|
643
682
|
scanContent(f, lines, push);
|
|
644
683
|
scanSupplyChain(f, lines, push);
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
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;
|
|
649
688
|
if (!data)
|
|
650
689
|
continue;
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
+
}
|
|
656
697
|
}
|
|
698
|
+
const kind = docKind(f.path);
|
|
699
|
+
if (!kind)
|
|
700
|
+
continue;
|
|
657
701
|
if (kind === "agent") {
|
|
658
702
|
const rel = f.path.replace(/^library\//, "");
|
|
659
703
|
const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
|
|
@@ -666,7 +710,8 @@ export function scanFiles(files, options = {}) {
|
|
|
666
710
|
toolDefs.set(f.path, { data, lines });
|
|
667
711
|
}
|
|
668
712
|
}
|
|
669
|
-
|
|
713
|
+
const workspaceMd = files.find((f) => f.path === "AGENTS.md");
|
|
714
|
+
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
|
|
670
715
|
return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
|
|
671
716
|
}
|
|
672
717
|
/**
|
|
@@ -698,7 +743,9 @@ export function applyBaseline(findings, baseline) {
|
|
|
698
743
|
const b = baseline;
|
|
699
744
|
if (!b || b.version !== 1 || !Array.isArray(b.findings))
|
|
700
745
|
return { kept: findings, baselined: 0 };
|
|
701
|
-
|
|
746
|
+
// An entry that is not {rule, file, fingerprint} hides nothing.
|
|
747
|
+
const entries = b.findings.filter((f) => !!f && typeof f === "object" && typeof f.rule === "string" && typeof f.file === "string" && typeof f.fingerprint === "string");
|
|
748
|
+
const known = new Set(entries.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
|
|
702
749
|
const kept = findings.filter((f) => !known.has(`${f.rule}\0${f.file}\0${f.fingerprint}`));
|
|
703
750
|
return { kept, baselined: findings.length - kept.length };
|
|
704
751
|
}
|
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
|
+
}
|