@foldrun/core 2.5.0 → 2.7.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/agent-import.js +2 -2
- package/dist/src/agent-tools.js +2 -2
- package/dist/src/agents-md.d.ts +5 -1
- package/dist/src/agents-md.js +42 -18
- package/dist/src/capture.js +39 -1
- package/dist/src/completions.js +25 -3
- package/dist/src/deploy.js +72 -7
- package/dist/src/egress.d.ts +16 -0
- package/dist/src/evals.d.ts +22 -1
- package/dist/src/evals.js +43 -14
- package/dist/src/flow-lint.d.ts +14 -0
- package/dist/src/flow-lint.js +35 -0
- package/dist/src/flow-patterns.js +29 -14
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +51 -3
- package/dist/src/frontmatter-guard.d.ts +19 -0
- package/dist/src/frontmatter-guard.js +67 -0
- package/dist/src/frontmatter-schema.d.ts +2 -2
- package/dist/src/frontmatter-schema.js +30 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -0
- package/dist/src/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -0
- package/dist/src/matter.d.ts +34 -0
- package/dist/src/matter.js +104 -0
- package/dist/src/network.d.ts +0 -1
- package/dist/src/network.js +10 -7
- package/dist/src/package.js +9 -2
- package/dist/src/provider-health.js +2 -2
- package/dist/src/run-container.d.ts +9 -0
- package/dist/src/run-container.js +34 -1
- package/dist/src/runner.js +193 -39
- package/dist/src/scan.d.ts +11 -0
- package/dist/src/scan.js +86 -8
- package/dist/src/secret-files.js +23 -2
- package/dist/src/secrets.d.ts +62 -2
- package/dist/src/secrets.js +206 -20
- package/dist/src/starter.js +23 -0
- package/dist/src/step-exec.d.ts +10 -2
- package/dist/src/step-exec.js +12 -9
- package/dist/src/store.d.ts +17 -3
- package/dist/src/store.js +14 -10
- package/package.json +1 -1
- package/src/agent-import.ts +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- package/src/capture.ts +33 -1
- package/src/completions.ts +26 -3
- package/src/deploy.ts +59 -7
- package/src/egress.ts +15 -1
- package/src/evals.ts +45 -13
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +74 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +30 -4
- package/src/frontmatter-text.ts +109 -0
- package/src/judge-verdict.ts +52 -0
- package/src/matter.ts +112 -0
- package/src/network.ts +10 -6
- package/src/package.ts +8 -2
- package/src/provider-health.ts +2 -2
- package/src/run-container.ts +41 -1
- package/src/runner.ts +189 -40
- package/src/scan.ts +86 -8
- package/src/secret-files.ts +22 -2
- package/src/secrets.ts +237 -23
- package/src/starter.ts +23 -0
- package/src/step-exec.ts +21 -8
- package/src/store.ts +23 -10
|
@@ -24,6 +24,7 @@ import { joinGroup, splitToRail } from "./arrange.js";
|
|
|
24
24
|
import { lintFlow } from "./flow-lint.js";
|
|
25
25
|
import { toLF, asRaw } from "./eol.js";
|
|
26
26
|
import { removalImpact, stepLabel } from "./step-removal.js";
|
|
27
|
+
import { keySpan, InlineBlockError } from "./frontmatter-text.js";
|
|
27
28
|
// ---------- flow files ----------
|
|
28
29
|
/** A flow's steps as groups of indices into its parsed steps — what
|
|
29
30
|
* reorderFlowSteps and the arrange helpers take. */
|
|
@@ -437,14 +438,16 @@ function splitComment(value) {
|
|
|
437
438
|
const m = value.match(/^(.*?)(\s+#.*)$/);
|
|
438
439
|
return m ? { value: m[1], comment: m[2] } : { value, comment: "" };
|
|
439
440
|
}
|
|
441
|
+
/** Where a key is written — at the top or inside its job block
|
|
442
|
+
* (frontmatter-text.ts) — with the text after its colon and its indent. */
|
|
440
443
|
function keyLine(lines, close, key) {
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
447
|
-
return null;
|
|
444
|
+
const where = keySpan(lines.slice(1, close), key);
|
|
445
|
+
if (!where)
|
|
446
|
+
return null;
|
|
447
|
+
const index = where.at + 1;
|
|
448
|
+
// Quoted or not, as keySpan matched it.
|
|
449
|
+
const m = lines[index].slice(where.indent.length).match(new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&")}["']?\\s*:(.*)$`));
|
|
450
|
+
return m ? { index, rest: m[1], indent: where.indent } : null;
|
|
448
451
|
}
|
|
449
452
|
/** Block-style item lines under a key: ` - name`, until anything else. */
|
|
450
453
|
function blockItems(lines, from, close) {
|
|
@@ -467,7 +470,17 @@ export function frontmatterList(raw, key) {
|
|
|
467
470
|
const b = frontBounds(lines);
|
|
468
471
|
if (!b)
|
|
469
472
|
return [];
|
|
470
|
-
|
|
473
|
+
let k;
|
|
474
|
+
try {
|
|
475
|
+
k = keyLine(lines, b.close, key);
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
// A block these text edits do not handle (frontmatter-text.ts) is the
|
|
479
|
+
// writer's to refuse; a read of it is empty, not an error.
|
|
480
|
+
if (err instanceof InlineBlockError)
|
|
481
|
+
return [];
|
|
482
|
+
throw err;
|
|
483
|
+
}
|
|
471
484
|
if (!k)
|
|
472
485
|
return [];
|
|
473
486
|
const v = splitComment(k.rest).value.trim();
|
|
@@ -491,10 +504,12 @@ export function editFrontmatterList(raw, key, name, action) {
|
|
|
491
504
|
const b = frontBounds(lines);
|
|
492
505
|
if (!b)
|
|
493
506
|
return action === "remove" ? raw : ["---", `${key}: [${name}]`, "---", ...lines].join(eol);
|
|
507
|
+
// Located first, so a key in a block these edits do not handle is refused
|
|
508
|
+
// (InlineBlockError) rather than read as an empty list.
|
|
509
|
+
const k = keyLine(lines, b.close, key);
|
|
494
510
|
const has = frontmatterList(raw, key).includes(name);
|
|
495
511
|
if ((action === "add") === has)
|
|
496
512
|
return raw;
|
|
497
|
-
const k = keyLine(lines, b.close, key);
|
|
498
513
|
if (!k) {
|
|
499
514
|
lines.splice(b.close, 0, `${key}: [${name}]`);
|
|
500
515
|
return lines.join(eol);
|
|
@@ -505,12 +520,12 @@ export function editFrontmatterList(raw, key, name, action) {
|
|
|
505
520
|
// Flow style, or a lone scalar (read as a one-item list): that one line.
|
|
506
521
|
const tokens = v.startsWith("[") ? v.replace(/^\[|\]$/g, "").split(",").map((t) => t.trim()).filter(Boolean) : [v];
|
|
507
522
|
const next = action === "add" ? [...tokens, name] : tokens.filter((t) => itemName(t) !== name);
|
|
508
|
-
lines[k.index] = `${key}: [${next.join(", ")}]${comment}`;
|
|
523
|
+
lines[k.index] = `${k.indent}${key}: [${next.join(", ")}]${comment}`;
|
|
509
524
|
return lines.join(eol);
|
|
510
525
|
}
|
|
511
526
|
const items = blockItems(lines, k.index, b.close);
|
|
512
527
|
if (action === "add") {
|
|
513
|
-
const indent = items.length ? lines[items[0]].match(/^(\s*)-/)[1] :
|
|
528
|
+
const indent = items.length ? lines[items[0]].match(/^(\s*)-/)[1] : `${k.indent} `;
|
|
514
529
|
lines.splice(items.length ? items[items.length - 1] + 1 : k.index + 1, 0, `${indent}- ${name}`);
|
|
515
530
|
return lines.join(eol);
|
|
516
531
|
}
|
|
@@ -518,7 +533,7 @@ export function editFrontmatterList(raw, key, name, action) {
|
|
|
518
533
|
for (const i of [...drop].reverse())
|
|
519
534
|
lines.splice(i, 1);
|
|
520
535
|
if (drop.length === items.length)
|
|
521
|
-
lines[k.index] = `${key}: []${comment}`;
|
|
536
|
+
lines[k.index] = `${k.indent}${key}: []${comment}`;
|
|
522
537
|
return lines.join(eol);
|
|
523
538
|
}
|
|
524
539
|
const YAML_WORDS = /^(true|false|yes|no|on|off|null|~|y|n)$/i;
|
|
@@ -546,10 +561,10 @@ export function setFrontmatterScalar(raw, key, value) {
|
|
|
546
561
|
const { value: v, comment } = splitComment(k.rest);
|
|
547
562
|
let end = k.index + 1;
|
|
548
563
|
if (/^\s*[|>][-+]?\d*\s*$/.test(v) || !v.trim()) {
|
|
549
|
-
while (end < b.close && /^\s
|
|
564
|
+
while (end < b.close && (lines[end].match(/^\s*/)?.[0].length ?? 0) > k.indent.length && /\S/.test(lines[end]))
|
|
550
565
|
end++;
|
|
551
566
|
}
|
|
552
|
-
lines.splice(k.index, end - k.index, `${line}${comment}`);
|
|
567
|
+
lines.splice(k.index, end - k.index, `${k.indent}${line}${comment}`);
|
|
553
568
|
return lines.join(eol);
|
|
554
569
|
}
|
|
555
570
|
const name = keyLine(lines, b.close, "name");
|
|
@@ -15,13 +15,40 @@ export interface FieldSpec {
|
|
|
15
15
|
legacy?: boolean;
|
|
16
16
|
/** One line: what it is. The completion's hint. */
|
|
17
17
|
help: string;
|
|
18
|
+
/** Which of the four jobs it does, when it does one (set from JOB_FIELDS
|
|
19
|
+
* below, not by hand): the block it may also be written in. */
|
|
20
|
+
job?: Job;
|
|
18
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* The four jobs of running agents — governance, guardrail, observability,
|
|
24
|
+
* evaluation (the owner's choice of words, 21 Oct 2026). A field that does
|
|
25
|
+
* one may be written flat, as always, or inside a block of that name:
|
|
26
|
+
*
|
|
27
|
+
* guardrail:
|
|
28
|
+
* limits: {repeat: 5}
|
|
29
|
+
* network: [api.example.com]
|
|
30
|
+
*
|
|
31
|
+
* reads exactly as `limits:` and `network:` at the top. matter.ts folds the
|
|
32
|
+
* blocks as it parses, so every reader sees flat keys; check reports a key
|
|
33
|
+
* set both ways (config/conflict) or in the wrong block.
|
|
34
|
+
*/
|
|
35
|
+
export type Job = "governance" | "guardrail" | "observability" | "evaluation";
|
|
36
|
+
export declare const JOBS: readonly {
|
|
37
|
+
name: Job;
|
|
38
|
+
title: string;
|
|
39
|
+
what: string;
|
|
40
|
+
}[];
|
|
19
41
|
export type SchemaKind = "agent" | "agents-md" | "flow" | "tool" | "skill" | "eval" | "hook";
|
|
20
42
|
/** What can start a flow. store.ts re-exports it; parseFlow and the editor
|
|
21
43
|
* read this one. */
|
|
22
44
|
export declare const FLOW_TRIGGERS: readonly ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
|
|
23
45
|
/** The effort levels; store.ts re-exports it with the aliases beside. */
|
|
24
46
|
export declare const EFFORT_LEVELS: readonly ["low", "medium", "high", "xhigh", "max"];
|
|
47
|
+
/** The fields of one kind that do this job, in schema order. */
|
|
48
|
+
export declare function fieldsOfJob(kind: SchemaKind, job: Job): string[];
|
|
49
|
+
/** Every key any kind lets a block hold, per job — what matter.ts may lift
|
|
50
|
+
* out of a block without knowing which kind of file it is reading. */
|
|
51
|
+
export declare const FOLDABLE: Readonly<Record<Job, ReadonlySet<string>>>;
|
|
25
52
|
export declare const FRONTMATTER: Readonly<Record<SchemaKind, readonly FieldSpec[]>>;
|
|
26
53
|
/** Which schema a workspace-relative path is read with, or null. */
|
|
27
54
|
export declare function schemaKindOf(p: string): SchemaKind | null;
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
// in the docs.
|
|
16
16
|
//
|
|
17
17
|
// A new field is added here first, then read.
|
|
18
|
+
export const JOBS = [
|
|
19
|
+
{ name: "governance", title: "Governance", what: "who answers for it and what it may spend, decide and ship" },
|
|
20
|
+
{ name: "guardrail", title: "Guardrail", what: "what it may reach and call, and what stops it" },
|
|
21
|
+
{ name: "observability", title: "Observability", what: "what is recorded, and who is told" },
|
|
22
|
+
{ name: "evaluation", title: "Evaluation", what: "how its work is judged" },
|
|
23
|
+
];
|
|
18
24
|
/** What can start a flow. store.ts re-exports it; parseFlow and the editor
|
|
19
25
|
* read this one. */
|
|
20
26
|
export const FLOW_TRIGGERS = ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
|
|
@@ -82,6 +88,7 @@ const AGENTS_MD = [
|
|
|
82
88
|
{ key: "network_enforce", type: "boolean", help: "true: a site off a step's network: list is refused, not only named in the trace. The account's or the workspace's true is enough" },
|
|
83
89
|
{ key: "telemetry", type: "object", validated: true, help: "send each run as an OpenTelemetry trace — otlp: <https …/v1/traces>, headers: { authorization: ${SECRET} }" },
|
|
84
90
|
{ key: "deploy_block", type: "string", values: ["none", "secrets", "security"], help: "refuse a deploy on scanner findings: secrets (a key written in) or security (any security warning)" },
|
|
91
|
+
{ key: "judge", type: "object", validated: true, help: "the model every judge: here grades with — {model: fast | default | max, effort}; an eval's own model: wins" },
|
|
85
92
|
{ key: "schedule", type: ["string", "object"], validated: true, legacy: true, help: "refused: only a flow has a clock" },
|
|
86
93
|
];
|
|
87
94
|
const FLOW = [
|
|
@@ -175,10 +182,51 @@ const SAID_WHAT_IT_IS = [
|
|
|
175
182
|
{ key: "kind", type: "string", validated: true, legacy: true, help: "no longer read — the path says what this is" },
|
|
176
183
|
{ key: "type", type: "string", validated: true, legacy: true, help: "no longer read — the path says what this is" },
|
|
177
184
|
];
|
|
185
|
+
/** Which fields each job holds, per kind of file. One table: the blocks'
|
|
186
|
+
* help, the fold, the editor's completions and check all read it. */
|
|
187
|
+
const JOB_FIELDS = {
|
|
188
|
+
agent: {
|
|
189
|
+
governance: ["owner", "budget"],
|
|
190
|
+
guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets"],
|
|
191
|
+
},
|
|
192
|
+
flow: {
|
|
193
|
+
governance: ["owner", "approvers", "approve_within", "budget", "priority"],
|
|
194
|
+
guardrail: ["throttle", "debounce", "overlap", "disable_after", "live"],
|
|
195
|
+
observability: ["issues", "sla"],
|
|
196
|
+
},
|
|
197
|
+
"agents-md": {
|
|
198
|
+
governance: ["owner", "budget", "priority", "concurrency", "deploy_block"],
|
|
199
|
+
guardrail: ["limits", "network", "network_enforce", "hooks"],
|
|
200
|
+
observability: ["alerts", "telemetry", "notify", "capture"],
|
|
201
|
+
evaluation: ["judge"],
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
/** The fields of one kind that do this job, in schema order. */
|
|
205
|
+
export function fieldsOfJob(kind, job) {
|
|
206
|
+
return [...(JOB_FIELDS[kind]?.[job] ?? [])];
|
|
207
|
+
}
|
|
208
|
+
/** Every key any kind lets a block hold, per job — what matter.ts may lift
|
|
209
|
+
* out of a block without knowing which kind of file it is reading. */
|
|
210
|
+
export const FOLDABLE = Object.fromEntries(JOBS.map(({ name }) => [name, new Set(Object.values(JOB_FIELDS).flatMap((byJob) => byJob?.[name] ?? []))]));
|
|
211
|
+
/** A kind's fields with their jobs set, and a block field per job it has. */
|
|
212
|
+
function withJobs(kind, fields) {
|
|
213
|
+
const byJob = JOB_FIELDS[kind] ?? {};
|
|
214
|
+
const jobOf = new Map();
|
|
215
|
+
for (const [job, keys] of Object.entries(byJob))
|
|
216
|
+
for (const k of keys)
|
|
217
|
+
jobOf.set(k, job);
|
|
218
|
+
const tagged = fields.map((f) => (jobOf.has(f.key) ? { ...f, job: jobOf.get(f.key) } : f));
|
|
219
|
+
const blocks = JOBS.filter((j) => (byJob[j.name]?.length ?? 0) > 0).map((j) => ({
|
|
220
|
+
key: j.name,
|
|
221
|
+
type: "object",
|
|
222
|
+
help: `${j.title} — ${j.what}: ${byJob[j.name].join(", ")}, as one block`,
|
|
223
|
+
}));
|
|
224
|
+
return [...tagged, ...blocks];
|
|
225
|
+
}
|
|
178
226
|
export const FRONTMATTER = {
|
|
179
|
-
agent: [...AGENT, ...SAID_WHAT_IT_IS],
|
|
180
|
-
"agents-md": AGENTS_MD,
|
|
181
|
-
flow: [...FLOW, ...SAID_WHAT_IT_IS],
|
|
227
|
+
agent: withJobs("agent", [...AGENT, ...SAID_WHAT_IT_IS]),
|
|
228
|
+
"agents-md": withJobs("agents-md", AGENTS_MD),
|
|
229
|
+
flow: withJobs("flow", [...FLOW, ...SAID_WHAT_IT_IS]),
|
|
182
230
|
tool: [...TOOL, ...SAID_WHAT_IT_IS.filter((f) => f.key !== "type")],
|
|
183
231
|
skill: [...SKILL, ...SAID_WHAT_IT_IS],
|
|
184
232
|
eval: [...EVAL, ...SAID_WHAT_IT_IS],
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** A write the guard refused. 409: the file is fine, this edit is not. */
|
|
2
|
+
export declare class UnsafeEditError extends Error {
|
|
3
|
+
status: number;
|
|
4
|
+
constructor(why: string);
|
|
5
|
+
}
|
|
6
|
+
/** What a changed key must read back as: a value (null: absent), or a test
|
|
7
|
+
* of what it reads as, for a writer whose output form is its own (a canvas
|
|
8
|
+
* list, a scalar YAML may type). */
|
|
9
|
+
export type Expect = unknown | ((value: unknown) => boolean);
|
|
10
|
+
/**
|
|
11
|
+
* `after` if it is `before` with only `changed` keys different and each
|
|
12
|
+
* `expect`ed key reading back as expected; throws UnsafeEditError otherwise.
|
|
13
|
+
* Checked on every call — an edit that changed nothing must still have done
|
|
14
|
+
* what it said — and refused when `before` cannot be read, since nothing
|
|
15
|
+
* then could be compared.
|
|
16
|
+
*/
|
|
17
|
+
export declare function guardEdit(before: string, after: string, changed: readonly string[], expect?: Record<string, Expect>): string;
|
|
18
|
+
/** The front of `text` as a run reads it, or null when it does not parse. */
|
|
19
|
+
export declare function readFront(text: string): Record<string, unknown> | null;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The last word on every frontmatter edit made as text (agents-md.ts
|
|
2
|
+
// patchFrontmatter, network.ts setNetworkSetting, the canvas's list and
|
|
3
|
+
// scalar edits): the result is read back the way a run reads it, and the
|
|
4
|
+
// edit is refused — the file left as it was — unless it parses, set what it
|
|
5
|
+
// said it set, left everything else as it was, and made no key both flat and
|
|
6
|
+
// in its block. The text writers handle the shapes people write; this makes
|
|
7
|
+
// a shape they do not handle a refusal, never a broken or doubled file
|
|
8
|
+
// (Codex debate, 21 Oct 2026).
|
|
9
|
+
import { isDeepStrictEqual } from "node:util";
|
|
10
|
+
import { configMatter, jobFold } from "./matter.js";
|
|
11
|
+
/** A write the guard refused. 409: the file is fine, this edit is not. */
|
|
12
|
+
export class UnsafeEditError extends Error {
|
|
13
|
+
status = 409;
|
|
14
|
+
constructor(why) {
|
|
15
|
+
super(`not saved — ${why}. Edit the file itself, or write its blocks one key per line`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const front = (text) => configMatter(text).data;
|
|
19
|
+
/**
|
|
20
|
+
* `after` if it is `before` with only `changed` keys different and each
|
|
21
|
+
* `expect`ed key reading back as expected; throws UnsafeEditError otherwise.
|
|
22
|
+
* Checked on every call — an edit that changed nothing must still have done
|
|
23
|
+
* what it said — and refused when `before` cannot be read, since nothing
|
|
24
|
+
* then could be compared.
|
|
25
|
+
*/
|
|
26
|
+
export function guardEdit(before, after, changed, expect = {}) {
|
|
27
|
+
let was;
|
|
28
|
+
try {
|
|
29
|
+
was = front(before);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new UnsafeEditError("the frontmatter does not parse as it is, so this edit could not be checked — fix the file first");
|
|
33
|
+
}
|
|
34
|
+
let now;
|
|
35
|
+
try {
|
|
36
|
+
now = front(after);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw new UnsafeEditError(`the result would not parse (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`);
|
|
40
|
+
}
|
|
41
|
+
const conflictsBefore = jobFold(was)?.conflicts ?? new Map();
|
|
42
|
+
for (const [k, block] of jobFold(now)?.conflicts ?? new Map()) {
|
|
43
|
+
if (!conflictsBefore.has(k))
|
|
44
|
+
throw new UnsafeEditError(`\`${k}\` would be written both at the top and inside \`${block}:\``);
|
|
45
|
+
}
|
|
46
|
+
for (const [k, want] of Object.entries(expect)) {
|
|
47
|
+
const ok = typeof want === "function" ? want(now[k]) : isDeepStrictEqual(now[k], want === null ? undefined : want);
|
|
48
|
+
if (!ok)
|
|
49
|
+
throw new UnsafeEditError(`\`${k}\` would not read back as written`);
|
|
50
|
+
}
|
|
51
|
+
for (const k of new Set([...Object.keys(was), ...Object.keys(now)])) {
|
|
52
|
+
if (changed.includes(k))
|
|
53
|
+
continue;
|
|
54
|
+
if (!isDeepStrictEqual(was[k], now[k]))
|
|
55
|
+
throw new UnsafeEditError(`\`${k}\` would change too`);
|
|
56
|
+
}
|
|
57
|
+
return after;
|
|
58
|
+
}
|
|
59
|
+
/** The front of `text` as a run reads it, or null when it does not parse. */
|
|
60
|
+
export function readFront(text) {
|
|
61
|
+
try {
|
|
62
|
+
return front(text);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -2,8 +2,8 @@ import { type SchemaKind } from "./frontmatter-fields.ts";
|
|
|
2
2
|
export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
|
|
3
3
|
export interface FieldProblem {
|
|
4
4
|
key: string;
|
|
5
|
-
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field";
|
|
6
|
-
level: "warn" | "info";
|
|
5
|
+
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced";
|
|
6
|
+
level: "error" | "warn" | "info";
|
|
7
7
|
message: string;
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Checking frontmatter against frontmatter-fields.ts: the values, not just
|
|
2
2
|
// the names. Node-side (it reads store's parsers); the browser takes the
|
|
3
3
|
// field list alone from frontmatter-fields.ts.
|
|
4
|
-
import { FRONTMATTER } from "./frontmatter-fields.js";
|
|
4
|
+
import { FRONTMATTER, JOBS } from "./frontmatter-fields.js";
|
|
5
|
+
import { jobFold } from "./matter.js";
|
|
5
6
|
import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS } from "./store.js";
|
|
6
7
|
import { budgetProblem } from "./budget.js";
|
|
7
8
|
import { cronProblem } from "./cron.js";
|
|
@@ -80,7 +81,33 @@ export function frontmatterProblems(kind, data, near) {
|
|
|
80
81
|
const fields = FRONTMATTER[kind];
|
|
81
82
|
const known = fields.map((f) => f.key);
|
|
82
83
|
const out = [];
|
|
84
|
+
// The four job blocks (matter.ts folds them): a key set both ways, and a
|
|
85
|
+
// key left in a block that does not hold it.
|
|
86
|
+
const fold = jobFold(data);
|
|
87
|
+
for (const [key, job] of fold?.conflicts ?? []) {
|
|
88
|
+
out.push({ key, rule: "config/conflict", level: "error", message: `\`${key}:\` is set twice — at the top and inside \`${job}:\`. Only the top one would be read; keep one` });
|
|
89
|
+
}
|
|
90
|
+
for (const { name: job } of JOBS) {
|
|
91
|
+
const block = data[job];
|
|
92
|
+
if (!block || typeof block !== "object" || Array.isArray(block))
|
|
93
|
+
continue;
|
|
94
|
+
for (const inner of Object.keys(block)) {
|
|
95
|
+
const spec = fields.find((f) => f.key === inner);
|
|
96
|
+
const message = spec?.job
|
|
97
|
+
? `\`${inner}:\` is a ${spec.job} field, not a ${job} one — move it into \`${spec.job}:\` or to the top. Inside \`${job}:\` it is not read`
|
|
98
|
+
: spec
|
|
99
|
+
? `\`${inner}:\` belongs to none of the four jobs — write it at the top. Inside \`${job}:\` it is not read`
|
|
100
|
+
: `\`${inner}:\` inside \`${job}:\` is not a field foldrun reads${belongsIn(inner, kind) ? ` here — it is read in ${belongsIn(inner, kind)}` : ""}; it is ignored`;
|
|
101
|
+
// A real setting in the wrong block is a setting silently not applied
|
|
102
|
+
// — a guardrail written under governance: guards nothing — so it is an
|
|
103
|
+
// error (Codex debate). A word foldrun does not read at all is a note.
|
|
104
|
+
out.push({ key: inner, rule: "config/misplaced", level: spec ? "error" : "info", message });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
83
107
|
for (const [key, value] of Object.entries(data)) {
|
|
108
|
+
// A block holding only misplaced keys is reported above, key by key.
|
|
109
|
+
if (JOBS.some((j) => j.name === key) && value && typeof value === "object" && !Array.isArray(value))
|
|
110
|
+
continue;
|
|
84
111
|
const spec = fields.find((f) => f.key === key);
|
|
85
112
|
const check = CHECKS[kind]?.[key];
|
|
86
113
|
if (!spec) {
|
|
@@ -95,7 +122,8 @@ export function frontmatterProblems(kind, data, near) {
|
|
|
95
122
|
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` });
|
|
96
123
|
}
|
|
97
124
|
else if (home) {
|
|
98
|
-
|
|
125
|
+
const where = fold?.folded.get(key);
|
|
126
|
+
out.push({ key, rule: "config/unknown-field", level: "warn", message: `\`${key}:\`${where ? ` (inside \`${where}:\`)` : ""} is read in ${home}, not in ${kind === "agents-md" ? "AGENTS.md" : kind === "eval" ? "an eval" : `a ${kind}`} — here it is ignored` });
|
|
99
127
|
}
|
|
100
128
|
else {
|
|
101
129
|
out.push({ key, rule: "config/unknown-field", level: "info", message: `\`${key}:\` is not a field foldrun reads; it is ignored` });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface KeySpan {
|
|
2
|
+
/** The key's line. */
|
|
3
|
+
at: number;
|
|
4
|
+
/** One past its last line. */
|
|
5
|
+
end: number;
|
|
6
|
+
/** What its line starts with. */
|
|
7
|
+
indent: string;
|
|
8
|
+
/** The block header's line, when it is inside one. */
|
|
9
|
+
block: number | null;
|
|
10
|
+
}
|
|
11
|
+
/** Thrown for a key a writer cannot edit in place: one that is, or may be,
|
|
12
|
+
* inside a block not written one key per line (`guardrail: {…}`, an alias).
|
|
13
|
+
* Rewriting it would mean re-serialising the person's YAML, and adding the
|
|
14
|
+
* key at the top beside it would be a config/conflict — so the writer says
|
|
15
|
+
* what to write instead. */
|
|
16
|
+
export declare class InlineBlockError extends Error {
|
|
17
|
+
status: number;
|
|
18
|
+
constructor(key: string, block: string);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Where a key is written in frontmatter lines: at the top, or a direct child
|
|
22
|
+
* of one of the four job blocks (`guardrail:` and so on). Every writer that
|
|
23
|
+
* edits a key as text reads it here, so a file written in blocks is edited
|
|
24
|
+
* where it is — never given a second, flat copy that check would refuse as
|
|
25
|
+
* config/conflict. A key inside an inline block map throws InlineBlockError.
|
|
26
|
+
*/
|
|
27
|
+
export declare function keySpan(lines: string[], key: string): KeySpan | null;
|
|
28
|
+
/** A block header left with nothing under it is removed with its last key.
|
|
29
|
+
* Blank lines and comments between it and its next key do not count as
|
|
30
|
+
* nothing: the header stays while any indented key follows them. */
|
|
31
|
+
export declare function dropEmptyBlock(lines: string[], header: number): string[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Frontmatter edited as text, where it is written: at the top or inside one
|
|
2
|
+
// of the four job blocks. No node imports — the flow canvas (flow-patterns.ts)
|
|
3
|
+
// runs in the browser and edits frontmatter with it too.
|
|
4
|
+
/** A block header on its own line — an anchor and a trailing `# comment`
|
|
5
|
+
* allowed. */
|
|
6
|
+
const JOB_BLOCK = /^["']?(governance|guardrail|observability|evaluation)["']?\s*:\s*(&\S+\s*)?(#.*)?$/;
|
|
7
|
+
/** Any other block header: an inline map, an alias, a tag — a form these
|
|
8
|
+
* writers do not edit. */
|
|
9
|
+
const JOB_OTHER = /^["']?(governance|guardrail|observability|evaluation)["']?\s*:\s*\S/;
|
|
10
|
+
/** An inline map closed on its own line: `guardrail: {network: [a.com]}`. */
|
|
11
|
+
const JOB_ONE_LINE = /^["']?(governance|guardrail|observability|evaluation)["']?\s*:\s*\{.*\}\s*(#.*)?$/;
|
|
12
|
+
const indentOf = (l) => l.match(/^[ \t]*/)?.[0].length ?? 0;
|
|
13
|
+
/** Thrown for a key a writer cannot edit in place: one that is, or may be,
|
|
14
|
+
* inside a block not written one key per line (`guardrail: {…}`, an alias).
|
|
15
|
+
* Rewriting it would mean re-serialising the person's YAML, and adding the
|
|
16
|
+
* key at the top beside it would be a config/conflict — so the writer says
|
|
17
|
+
* what to write instead. */
|
|
18
|
+
export class InlineBlockError extends Error {
|
|
19
|
+
status = 409;
|
|
20
|
+
constructor(key, block) {
|
|
21
|
+
super(`\`${key}\` may be inside \`${block}:\`, which is not written one key per line — write \`${block}:\` one key per line to change it here, or edit the file`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** A comment or blank line: neither ends a block nor is one of its keys. */
|
|
25
|
+
const filler = (l) => l.trim() === "" || /^\s*#/.test(l);
|
|
26
|
+
/**
|
|
27
|
+
* Where a key is written in frontmatter lines: at the top, or a direct child
|
|
28
|
+
* of one of the four job blocks (`guardrail:` and so on). Every writer that
|
|
29
|
+
* edits a key as text reads it here, so a file written in blocks is edited
|
|
30
|
+
* where it is — never given a second, flat copy that check would refuse as
|
|
31
|
+
* config/conflict. A key inside an inline block map throws InlineBlockError.
|
|
32
|
+
*/
|
|
33
|
+
export function keySpan(lines, key) {
|
|
34
|
+
const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
35
|
+
const span = (at, indent, block) => {
|
|
36
|
+
let end = at + 1;
|
|
37
|
+
// The key continues while lines are indented deeper, are an item of its
|
|
38
|
+
// list written at its own indent (YAML allows `key:\n- a`), or are blank
|
|
39
|
+
// lines and comments inside it.
|
|
40
|
+
while (end < lines.length &&
|
|
41
|
+
(filler(lines[end]) ||
|
|
42
|
+
indentOf(lines[end]) > indent.length ||
|
|
43
|
+
(indentOf(lines[end]) === indent.length && /^[ \t]*-(\s|$)/.test(lines[end]))))
|
|
44
|
+
end++;
|
|
45
|
+
// Blank lines and comments at its foot, no deeper than the key, belong
|
|
46
|
+
// to what follows.
|
|
47
|
+
while (end > at + 1 && filler(lines[end - 1]) && (lines[end - 1].trim() === "" || indentOf(lines[end - 1]) <= indent.length))
|
|
48
|
+
end--;
|
|
49
|
+
return { at, end, indent, block };
|
|
50
|
+
};
|
|
51
|
+
// A key may be quoted: `"network": none`.
|
|
52
|
+
const named = `["']?${k}["']?\\s*:`;
|
|
53
|
+
const top = lines.findIndex((l) => new RegExp(`^${named}`).test(l));
|
|
54
|
+
if (top !== -1)
|
|
55
|
+
return span(top, "", null);
|
|
56
|
+
for (let h = 0; h < lines.length; h++) {
|
|
57
|
+
const other = !JOB_BLOCK.test(lines[h]) && JOB_OTHER.exec(lines[h]);
|
|
58
|
+
if (other) {
|
|
59
|
+
// One closed line can be read: refuse only if the key is in it. Any
|
|
60
|
+
// other form (a map over several lines, an alias) cannot be, so any
|
|
61
|
+
// key not found at the top is refused rather than added beside it.
|
|
62
|
+
if (!JOB_ONE_LINE.test(lines[h]) || new RegExp(`[{,]\\s*${named}`).test(lines[h]))
|
|
63
|
+
throw new InlineBlockError(key, other[1]);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!JOB_BLOCK.test(lines[h]))
|
|
67
|
+
continue;
|
|
68
|
+
// Its children sit at the indent of its first indented line; a deeper
|
|
69
|
+
// line of the same name belongs to one of them, not to the block.
|
|
70
|
+
// Comments and blank lines, indented or not, do not end it.
|
|
71
|
+
let child = -1;
|
|
72
|
+
let merged = false;
|
|
73
|
+
for (let n = h + 1; n < lines.length && (/^[ \t]/.test(lines[n]) || filler(lines[n])); n++) {
|
|
74
|
+
if (filler(lines[n]))
|
|
75
|
+
continue;
|
|
76
|
+
if (child === -1)
|
|
77
|
+
child = indentOf(lines[n]);
|
|
78
|
+
if (indentOf(lines[n]) !== child)
|
|
79
|
+
continue;
|
|
80
|
+
const m = new RegExp(`^([ \t]+)${named}`).exec(lines[n]);
|
|
81
|
+
if (m)
|
|
82
|
+
return span(n, m[1], h);
|
|
83
|
+
if (/^[ \t]+<<\s*:/.test(lines[n]))
|
|
84
|
+
merged = true;
|
|
85
|
+
}
|
|
86
|
+
// `<<: *policy` may bring the key in from elsewhere: not one this text
|
|
87
|
+
// edit can see, so it is refused rather than added beside it.
|
|
88
|
+
if (merged)
|
|
89
|
+
throw new InlineBlockError(key, JOB_BLOCK.exec(lines[h])[1]);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
/** A block header left with nothing under it is removed with its last key.
|
|
94
|
+
* Blank lines and comments between it and its next key do not count as
|
|
95
|
+
* nothing: the header stays while any indented key follows them. */
|
|
96
|
+
export function dropEmptyBlock(lines, header) {
|
|
97
|
+
let n = header + 1;
|
|
98
|
+
while (n < lines.length && filler(lines[n]))
|
|
99
|
+
n++;
|
|
100
|
+
const next = lines[n];
|
|
101
|
+
return next === undefined || !/^[ \t]+\S/.test(next) ? [...lines.slice(0, header), ...lines.slice(header + 1)] : lines;
|
|
102
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The line to end a judge prompt with. */
|
|
2
|
+
export declare const JUDGE_VERDICT_INSTRUCTION = "Check each part against the text in a few short sentences. Then, on the last line and alone, write exactly PASS or FAIL \u2014 PASS only if every part holds.";
|
|
3
|
+
/**
|
|
4
|
+
* The judge's verdict: the PASS or FAIL alone on its last line, with the
|
|
5
|
+
* reasoning before it as the detail. An answer in the old shape (the word
|
|
6
|
+
* first) still reads; one with no verdict at all is a FAIL that says so.
|
|
7
|
+
*/
|
|
8
|
+
export declare function judgeVerdict(text: string): {
|
|
9
|
+
passed: boolean;
|
|
10
|
+
detail: string;
|
|
11
|
+
};
|
|
12
|
+
/** `judge:` in AGENTS.md — the model and effort every judge in the workspace
|
|
13
|
+
* grades with, unless an eval names its own. `model` is a tier or alias as
|
|
14
|
+
* anywhere else; what is wrong with the block, in words, for check. */
|
|
15
|
+
export declare function parseJudge(raw: unknown): {
|
|
16
|
+
model?: string;
|
|
17
|
+
effort?: string;
|
|
18
|
+
problems: string[];
|
|
19
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What a judge said: the PASS or FAIL alone on its last line, after its
|
|
2
|
+
// reasoning. Shared by the eval judge (evals.ts) and a flow step's
|
|
3
|
+
// `verify: judge:` (step-exec.ts), which ask for the same shape.
|
|
4
|
+
//
|
|
5
|
+
// The word comes last because, asked for it first, a judge commits before
|
|
6
|
+
// it has looked and then argues the other way — "FAIL … which meets the
|
|
7
|
+
// criterion" (vaza evals, 21 Oct 2026).
|
|
8
|
+
import { EFFORT_LEVELS } from "./frontmatter-fields.js";
|
|
9
|
+
/** The line to end a judge prompt with. */
|
|
10
|
+
export const JUDGE_VERDICT_INSTRUCTION = "Check each part against the text in a few short sentences. Then, on the last line and alone, write exactly PASS or FAIL — PASS only if every part holds.";
|
|
11
|
+
/**
|
|
12
|
+
* The judge's verdict: the PASS or FAIL alone on its last line, with the
|
|
13
|
+
* reasoning before it as the detail. An answer in the old shape (the word
|
|
14
|
+
* first) still reads; one with no verdict at all is a FAIL that says so.
|
|
15
|
+
*/
|
|
16
|
+
export function judgeVerdict(text) {
|
|
17
|
+
const lines = text.trim().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
18
|
+
const word = (l) => /^[*_`#\s]*(PASS|FAIL)[*_`.\s]*$/i.exec(l ?? "")?.[1]?.toUpperCase();
|
|
19
|
+
let verdict = word(lines[lines.length - 1]);
|
|
20
|
+
let reason = lines.slice(0, -1).join(" ");
|
|
21
|
+
if (!verdict) {
|
|
22
|
+
const first = /^[*_`#\s]*(PASS|FAIL)\b[*_`:.\s-]*(.*)$/i.exec(lines[0] ?? "");
|
|
23
|
+
if (first) {
|
|
24
|
+
verdict = first[1].toUpperCase();
|
|
25
|
+
reason = [first[2], ...lines.slice(1)].join(" ").trim();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!verdict)
|
|
29
|
+
return { passed: false, detail: `judge gave no PASS or FAIL: ${text.trim().slice(0, 300) || "nothing"}` };
|
|
30
|
+
// The end of the reasoning is the part that decided it.
|
|
31
|
+
const why = reason.length > 400 ? `…${reason.slice(-400)}` : reason;
|
|
32
|
+
return { passed: verdict === "PASS", detail: `${verdict}${why ? ` — ${why}` : ""}` };
|
|
33
|
+
}
|
|
34
|
+
/** `judge:` in AGENTS.md — the model and effort every judge in the workspace
|
|
35
|
+
* grades with, unless an eval names its own. `model` is a tier or alias as
|
|
36
|
+
* anywhere else; what is wrong with the block, in words, for check. */
|
|
37
|
+
export function parseJudge(raw) {
|
|
38
|
+
if (raw === undefined || raw === null)
|
|
39
|
+
return { problems: [] };
|
|
40
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
41
|
+
return { problems: ["judge: is a block — {model: fast | default | max, effort: low…max}"] };
|
|
42
|
+
const o = raw;
|
|
43
|
+
const problems = [];
|
|
44
|
+
for (const k of Object.keys(o))
|
|
45
|
+
if (k !== "model" && k !== "effort")
|
|
46
|
+
problems.push(`judge: ${k} — not a key; the keys are model and effort`);
|
|
47
|
+
const model = typeof o.model === "string" && o.model.trim() ? o.model.trim() : undefined;
|
|
48
|
+
if (o.model !== undefined && !model)
|
|
49
|
+
problems.push("judge: model — a tier (fast, default, max), an alias or a model id");
|
|
50
|
+
const effort = typeof o.effort === "string" && EFFORT_LEVELS.includes(o.effort.trim()) ? o.effort.trim() : undefined;
|
|
51
|
+
if (o.effort !== undefined && !effort)
|
|
52
|
+
problems.push(`judge: effort ${String(o.effort)} — low, medium, high, xhigh or max`);
|
|
53
|
+
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}), problems };
|
|
54
|
+
}
|
package/dist/src/matter.d.ts
CHANGED
|
@@ -1,8 +1,42 @@
|
|
|
1
1
|
import grayMatter from "gray-matter";
|
|
2
|
+
import { type Job } from "./frontmatter-fields.ts";
|
|
2
3
|
type Options = NonNullable<Parameters<typeof grayMatter>[1]>;
|
|
4
|
+
export interface JobFold {
|
|
5
|
+
/** A key lifted out of a block, and the block it was in. */
|
|
6
|
+
folded: Map<string, Job>;
|
|
7
|
+
/** A key set both at the top and inside a block: the top one is kept, and
|
|
8
|
+
* check refuses the file (config/conflict). */
|
|
9
|
+
conflicts: Map<string, Job>;
|
|
10
|
+
}
|
|
11
|
+
/** What the fold did to this data, or null when it had no blocks. */
|
|
12
|
+
export declare function jobFold(data: unknown): JobFold | null;
|
|
3
13
|
declare function matter(input: string, options?: Options): grayMatter.GrayMatterFile<string>;
|
|
4
14
|
declare namespace matter {
|
|
5
15
|
var stringify: typeof grayMatter.stringify;
|
|
6
16
|
var test: typeof grayMatter.test;
|
|
7
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* matter(), for the three files that hold configuration — an agent, a flow,
|
|
20
|
+
* AGENTS.md — with the four job blocks read as flat keys. Every reader of
|
|
21
|
+
* those files goes through this (tests/jobs.test.ts holds them to it);
|
|
22
|
+
* knowledge, memory, skills and every other document keep matter(), so a
|
|
23
|
+
* note's own `governance:` is its own (Codex debate, 21 Oct 2026).
|
|
24
|
+
*/
|
|
25
|
+
export declare function configMatter(input: string, options?: Options): grayMatter.GrayMatterFile<string>;
|
|
8
26
|
export default matter;
|
|
27
|
+
/** The first line of a YAML parse error, with where it is: what a person
|
|
28
|
+
* needs to fix the file, without the parser's excerpt. */
|
|
29
|
+
export declare function frontmatterErrorText(err: unknown): string;
|
|
30
|
+
/** The 1-based file line a YAML parse error points at, when it says. The
|
|
31
|
+
* text gray-matter hands the parser starts with the newline after the
|
|
32
|
+
* opening `---`, so the parser's 0-based line is the file's 1-based one. */
|
|
33
|
+
export declare function frontmatterErrorLine(err: unknown): number | undefined;
|
|
34
|
+
/** Frontmatter as the runtime reads a skill: strictly first, then once more
|
|
35
|
+
* with every unquoted value that holds a colon quoted (the commonest way a
|
|
36
|
+
* hand-written SKILL.md breaks, and one other skills clients forgive), and
|
|
37
|
+
* empty when even that fails. `lenient` says the second read was needed. */
|
|
38
|
+
export declare function parseLenient(raw: string): {
|
|
39
|
+
data: Record<string, unknown>;
|
|
40
|
+
lenient: boolean;
|
|
41
|
+
failed: boolean;
|
|
42
|
+
};
|