@foldrun/core 2.4.0 → 2.6.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-activity.d.ts +5 -0
- package/dist/src/agent-activity.js +31 -0
- 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/alerts.d.ts +60 -0
- package/dist/src/alerts.js +161 -0
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/capture.d.ts +23 -0
- package/dist/src/capture.js +80 -0
- package/dist/src/compare.d.ts +57 -0
- package/dist/src/compare.js +116 -0
- package/dist/src/completions.js +26 -3
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +237 -13
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +27 -2
- package/dist/src/evals.js +62 -19
- 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 +60 -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 +33 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -0
- package/dist/src/history.d.ts +4 -0
- package/dist/src/history.js +15 -0
- package/dist/src/hooks.js +1 -1
- package/dist/src/insights.d.ts +30 -0
- package/dist/src/insights.js +152 -0
- package/dist/src/issues.d.ts +140 -0
- package/dist/src/issues.js +302 -0
- package/dist/src/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -0
- package/dist/src/library.js +1 -1
- package/dist/src/limits.d.ts +8 -0
- package/dist/src/limits.js +36 -5
- package/dist/src/linkable.js +1 -1
- package/dist/src/matter.d.ts +42 -0
- package/dist/src/matter.js +132 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/network.d.ts +12 -1
- package/dist/src/network.js +35 -8
- package/dist/src/notify.d.ts +16 -0
- package/dist/src/notify.js +17 -7
- package/dist/src/okf.js +1 -1
- package/dist/src/online-checks.d.ts +76 -0
- package/dist/src/online-checks.js +432 -0
- package/dist/src/operator.d.ts +277 -0
- package/dist/src/operator.js +176 -2
- package/dist/src/otel-config.d.ts +18 -0
- package/dist/src/otel-config.js +69 -0
- package/dist/src/otel.d.ts +38 -0
- package/dist/src/otel.js +85 -0
- package/dist/src/outcome.d.ts +40 -0
- package/dist/src/outcome.js +77 -0
- package/dist/src/package.js +9 -2
- package/dist/src/platform.d.ts +1 -1
- package/dist/src/prompt-layers.d.ts +30 -0
- package/dist/src/prompt-layers.js +77 -0
- package/dist/src/provider-health.js +2 -2
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +25 -2
- package/dist/src/run-container.js +61 -6
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +471 -102
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +30 -1
- package/dist/src/scan.js +145 -14
- package/dist/src/starter.js +27 -2
- package/dist/src/step-exec.d.ts +39 -3
- package/dist/src/step-exec.js +118 -15
- package/dist/src/store.d.ts +144 -3
- package/dist/src/store.js +150 -7
- package/dist/src/tokens.d.ts +38 -0
- package/dist/src/tokens.js +57 -0
- package/dist/src/tool-names.js +1 -1
- package/dist/src/tool-programs.js +1 -1
- package/dist/src/trace-tree.d.ts +40 -0
- package/dist/src/trace-tree.js +156 -0
- package/dist/src/translator.d.ts +8 -0
- package/dist/src/translator.js +7 -2
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/workspace-check.d.ts +18 -0
- package/dist/src/workspace-check.js +34 -0
- package/package.json +2 -2
- package/src/agent-activity.ts +32 -0
- package/src/agent-import.ts +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- package/src/alerts.ts +190 -0
- package/src/browser-pod.ts +3 -0
- package/src/capture.ts +88 -0
- package/src/compare.ts +146 -0
- package/src/completions.ts +27 -3
- package/src/deploy.ts +238 -12
- package/src/egress.ts +12 -2
- package/src/evals.ts +64 -19
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +83 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +33 -4
- package/src/frontmatter-text.ts +109 -0
- package/src/history.ts +13 -0
- package/src/hooks.ts +1 -1
- package/src/insights.ts +174 -0
- package/src/issues.ts +396 -0
- package/src/judge-verdict.ts +52 -0
- package/src/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +145 -0
- package/src/model-credential.ts +45 -0
- package/src/network.ts +35 -7
- package/src/notify.ts +22 -7
- package/src/okf.ts +1 -1
- package/src/online-checks.ts +450 -0
- package/src/operator.ts +213 -3
- package/src/otel-config.ts +67 -0
- package/src/otel.ts +100 -0
- package/src/outcome.ts +90 -0
- package/src/package.ts +8 -2
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +2 -2
- package/src/providers.ts +7 -2
- package/src/run-container.ts +77 -8
- package/src/runner.ts +477 -79
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +153 -14
- package/src/starter.ts +27 -2
- package/src/step-exec.ts +168 -17
- package/src/store.ts +268 -9
- package/src/tokens.ts +68 -0
- package/src/tool-names.ts +1 -1
- package/src/tool-programs.ts +1 -1
- package/src/trace-tree.ts +187 -0
- package/src/translator.ts +13 -2
- package/src/trigger-log.ts +1 -0
- package/src/workspace-check.ts +41 -0
package/src/evals.ts
CHANGED
|
@@ -34,16 +34,21 @@
|
|
|
34
34
|
import { accountModelEnv, withModelEnv } from "./model-env.ts";
|
|
35
35
|
import type { TranslatorSpec } from "./translator.ts";
|
|
36
36
|
import { sdkEnv } from "./step-exec.ts";
|
|
37
|
+
import { platformModelEnv } from "./runner.ts";
|
|
37
38
|
import fs from "node:fs";
|
|
38
39
|
import path from "node:path";
|
|
39
40
|
import { deployedCommit } from "./deploy.ts";
|
|
40
41
|
import { latestRevisionId } from "./history.ts";
|
|
41
|
-
import matter from "
|
|
42
|
+
import matter from "./matter.ts";
|
|
42
43
|
import { spawn } from "node:child_process";
|
|
44
|
+
import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION, parseJudge } from "./judge-verdict.ts";
|
|
45
|
+
import { readAgentsMdKeys } from "./agents-md.ts";
|
|
43
46
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
44
47
|
import {
|
|
45
48
|
workspaceDir,
|
|
49
|
+
accountDir,
|
|
46
50
|
readRun,
|
|
51
|
+
runVerdict,
|
|
47
52
|
writeWorkspaceFile,
|
|
48
53
|
resolveModel,
|
|
49
54
|
resolveEffort,
|
|
@@ -145,7 +150,9 @@ function readScalar(
|
|
|
145
150
|
return { value: [first, ...parts].join(" ").trim(), end: i };
|
|
146
151
|
}
|
|
147
152
|
|
|
148
|
-
|
|
153
|
+
/** `defaults`: the workspace's `judge:` (AGENTS.md), used where the eval
|
|
154
|
+
* names no model or effort of its own. */
|
|
155
|
+
export function parseEval(file: string, raw: string, defaults: { model?: string; effort?: string } = {}): EvalInfo {
|
|
149
156
|
const { data, content } = matter(raw);
|
|
150
157
|
const cases: EvalCase[] = [];
|
|
151
158
|
let current: EvalCase | null = null;
|
|
@@ -196,8 +203,8 @@ export function parseEval(file: string, raw: string): EvalInfo {
|
|
|
196
203
|
file,
|
|
197
204
|
agent: typeof data.agent === "string" ? data.agent : null,
|
|
198
205
|
flow: typeof data.flow === "string" ? data.flow : null,
|
|
199
|
-
model: resolveModel(data.model ?? "fast"),
|
|
200
|
-
effort: resolveEffort(data.effort ?? "low"),
|
|
206
|
+
model: resolveModel(data.model ?? defaults.model ?? "fast"),
|
|
207
|
+
effort: resolveEffort(data.effort ?? defaults.effort ?? "low"),
|
|
201
208
|
trigger: data.trigger === "manual" ? "manual" : "deploy",
|
|
202
209
|
live: data.live === true,
|
|
203
210
|
inputs: data.inputs === true,
|
|
@@ -212,7 +219,18 @@ export function listEvals(tenant: string, workspace: string): EvalInfo[] {
|
|
|
212
219
|
.readdirSync(dir)
|
|
213
220
|
.filter((f) => f.endsWith(".md"))
|
|
214
221
|
.sort()
|
|
215
|
-
.map((f) => parseEval(f, fs.readFileSync(path.join(dir, f), "utf8")));
|
|
222
|
+
.map((f) => parseEval(f, fs.readFileSync(path.join(dir, f), "utf8"), judgeDefaults(tenant, workspace)));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** `judge:` from AGENTS.md, field by field: the workspace's model (or
|
|
226
|
+
* effort), else the account's — so a workspace that sets only an effort
|
|
227
|
+
* keeps the account's model (Codex debate: the form says so). */
|
|
228
|
+
export function judgeDefaults(tenant: string, workspace: string): { model?: string; effort?: string } {
|
|
229
|
+
const ws = parseJudge(readAgentsMdKeys(workspaceDir(tenant, workspace)).judge);
|
|
230
|
+
const account = parseJudge(readAgentsMdKeys(accountDir(tenant)).judge);
|
|
231
|
+
const model = ws.model ?? account.model;
|
|
232
|
+
const effort = ws.effort ?? account.effort;
|
|
233
|
+
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) };
|
|
216
234
|
}
|
|
217
235
|
|
|
218
236
|
// ---------- promoting a run ----------
|
|
@@ -266,16 +284,26 @@ export function promoteRunToEval(
|
|
|
266
284
|
tenant: string,
|
|
267
285
|
workspace: string,
|
|
268
286
|
runId: string,
|
|
269
|
-
|
|
287
|
+
/** `should`: what a run that went wrong should have done, in a sentence —
|
|
288
|
+
* the judge's claim for the case (FOL-72 step 10). */
|
|
289
|
+
opts: { evalName?: string; caseName?: string; expect?: string[]; should?: string; by?: string } = {},
|
|
270
290
|
): { file: string; created: boolean; caseName: string } {
|
|
271
291
|
const run = readRun(tenant, workspace, runId);
|
|
272
292
|
if (!run) throw refuse(`run ${runId} not found`, 404);
|
|
273
|
-
if (run.status !== "completed") {
|
|
274
|
-
throw refuse(`only a
|
|
293
|
+
if (run.status !== "completed" && run.status !== "failed") {
|
|
294
|
+
throw refuse(`only a finished run can become an eval — this one is ${run.status}`, 409);
|
|
295
|
+
}
|
|
296
|
+
// A run that went wrong is the test worth having most — but its own answer
|
|
297
|
+
// is the wrong one to hold the next run to, so it needs to be told what
|
|
298
|
+
// was right (FOL-72 step 10).
|
|
299
|
+
const should = oneLine(opts.should ?? "");
|
|
300
|
+
const wentWrong = run.status === "failed" || ["BAD", "BLOCKED"].includes(runVerdict(run) ?? "");
|
|
301
|
+
if (wentWrong && !should && !(opts.expect && opts.expect.length)) {
|
|
302
|
+
throw refuse("this run went wrong, so its answer is not the one to keep — say what it should have done (`should`, a sentence for the judge) or give `expect` lines", 409);
|
|
275
303
|
}
|
|
276
304
|
const produced = run.steps.filter((s) => (s.result ?? "").trim());
|
|
277
305
|
const last = produced[produced.length - 1];
|
|
278
|
-
if (!last) throw refuse("this run produced no result to hold the next one to", 409);
|
|
306
|
+
if (!last && !should && !(opts.expect && opts.expect.length)) throw refuse("this run produced no result to hold the next one to", 409);
|
|
279
307
|
if (run.steps.length === 0 || !run.steps[0].instruction?.trim()) {
|
|
280
308
|
throw refuse("this run has no task on record", 409);
|
|
281
309
|
}
|
|
@@ -310,8 +338,10 @@ export function promoteRunToEval(
|
|
|
310
338
|
}
|
|
311
339
|
return `${m[1]}: ${m[2]}`;
|
|
312
340
|
});
|
|
341
|
+
} else if (should) {
|
|
342
|
+
expect = [`judge: ${should.slice(0, 400)}`];
|
|
313
343
|
} else {
|
|
314
|
-
const conclusion = oneLine(run.summary?.trim() || (last
|
|
344
|
+
const conclusion = oneLine(run.summary?.trim() || (last!.result ?? "")).slice(0, 200);
|
|
315
345
|
expect = [`judge: reaches the same conclusion as the recorded run: ${conclusion}`];
|
|
316
346
|
}
|
|
317
347
|
|
|
@@ -471,9 +501,12 @@ function shell(command: string, cwd: string): Promise<{ code: number | null; out
|
|
|
471
501
|
});
|
|
472
502
|
}
|
|
473
503
|
|
|
474
|
-
// The judge gets the rubric and the output, and
|
|
504
|
+
// The judge gets the rubric and the output, and ends with one word.
|
|
475
505
|
// Constrained output beats asking for a score: "PASS" or "FAIL" can't be
|
|
476
|
-
// misread, where "7/10" needs a threshold nobody agreed on.
|
|
506
|
+
// misread, where "7/10" needs a threshold nobody agreed on. The word comes
|
|
507
|
+
// LAST, after the reasoning: asked for it first, the judge committed before
|
|
508
|
+
// it had looked, then argued the other way — "FAIL … which meets the
|
|
509
|
+
// criterion" (vaza evals, 21 Oct 2026).
|
|
477
510
|
async function judge(
|
|
478
511
|
rubric: string,
|
|
479
512
|
output: string,
|
|
@@ -482,8 +515,7 @@ async function judge(
|
|
|
482
515
|
env: Record<string, string | undefined>,
|
|
483
516
|
): Promise<AssertionResult["detail"] & string> {
|
|
484
517
|
const prompt =
|
|
485
|
-
`You are grading one piece of output against one criterion.
|
|
486
|
-
`PASS or FAIL on the first line, then one short sentence of reason.\n\n` +
|
|
518
|
+
`You are grading one piece of output against one criterion. ${JUDGE_VERDICT_INSTRUCTION}\n\n` +
|
|
487
519
|
`Criterion: ${rubric}\n\n<output>\n${output.slice(0, 20000)}\n</output>`;
|
|
488
520
|
|
|
489
521
|
let text = "";
|
|
@@ -510,13 +542,26 @@ async function judge(
|
|
|
510
542
|
return text.trim();
|
|
511
543
|
}
|
|
512
544
|
|
|
545
|
+
/**
|
|
546
|
+
* The judge's environment: this process's, with the platform's model
|
|
547
|
+
* credential read live. The process's own CLAUDE_CODE_OAUTH_TOKEN is the copy
|
|
548
|
+
* it was handed at boot; the box refreshes the login every four hours and the
|
|
549
|
+
* old token is then revoked, so a judge on the boot copy failed with "OAuth
|
|
550
|
+
* token revoked" from the first refresh until the pod restarted, while the
|
|
551
|
+
* agent steps (which read the live file) kept working (2026-10-21).
|
|
552
|
+
*/
|
|
553
|
+
export function judgeProcessEnv(): Record<string, string | undefined> {
|
|
554
|
+
const live = Object.fromEntries(Object.entries(platformModelEnv()).filter(([, v]) => v !== undefined));
|
|
555
|
+
return sdkEnv({ ...process.env, ...live });
|
|
556
|
+
}
|
|
557
|
+
|
|
513
558
|
async function checkAssertion(
|
|
514
559
|
assertion: Assertion,
|
|
515
560
|
output: string,
|
|
516
561
|
agentDir: string,
|
|
517
562
|
model: string,
|
|
518
563
|
effort: Effort | null,
|
|
519
|
-
judgeEnv: () => { env: Record<string, string | undefined>; translator?: TranslatorSpec | null } = () => ({ env:
|
|
564
|
+
judgeEnv: () => { env: Record<string, string | undefined>; translator?: TranslatorSpec | null } = () => ({ env: judgeProcessEnv() }),
|
|
520
565
|
): Promise<AssertionResult> {
|
|
521
566
|
const hay = output.toLowerCase();
|
|
522
567
|
switch (assertion.type) {
|
|
@@ -557,9 +602,9 @@ async function checkAssertion(
|
|
|
557
602
|
} catch (err) {
|
|
558
603
|
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
559
604
|
}
|
|
560
|
-
const
|
|
561
|
-
const passed =
|
|
562
|
-
return { assertion, passed, detail
|
|
605
|
+
const text = await withModelEnv(m, (env) => judge(assertion.value, output, model, effort, env));
|
|
606
|
+
const { passed, detail } = judgeVerdict(text);
|
|
607
|
+
return { assertion, passed, detail };
|
|
563
608
|
}
|
|
564
609
|
}
|
|
565
610
|
}
|
|
@@ -640,7 +685,7 @@ export async function runEval(
|
|
|
640
685
|
const judges = testCase.expect.filter((a) => a.type === "judge");
|
|
641
686
|
for (const a of cheap) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
|
|
642
687
|
if (assertions.every((r) => r.passed)) {
|
|
643
|
-
const judgeEnv = () => accountModelEnv(tenant, workspace,
|
|
688
|
+
const judgeEnv = () => accountModelEnv(tenant, workspace, judgeProcessEnv);
|
|
644
689
|
for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
|
|
645
690
|
} else {
|
|
646
691
|
for (const a of judges) {
|
package/src/flow-lint.ts
CHANGED
|
@@ -91,6 +91,27 @@ const LOOKS_LIKE_SHELL =
|
|
|
91
91
|
* prefix, nothing shell-shaped anywhere in it, and enough words to be a
|
|
92
92
|
* sentence — so a bare `./check.sh` or a one-word command is left alone and
|
|
93
93
|
* the warning only fires where the author plainly wrote prose. */
|
|
94
|
+
/**
|
|
95
|
+
* Why a `matches:` pattern will not compile, or null when it does. The
|
|
96
|
+
* runtime tests it as a JavaScript RegExp with the `i` flag (step-exec.ts,
|
|
97
|
+
* evals.ts), and a pattern that throws fails its check on every run with
|
|
98
|
+
* "invalid regular expression". The usual cause is another dialect: on
|
|
99
|
+
* 2026-10-21 Python's inline `(?m)` broke five eval checks on vaza, and
|
|
100
|
+
* nothing said so before they ran.
|
|
101
|
+
*/
|
|
102
|
+
export function regexProblem(pattern: string): string | null {
|
|
103
|
+
try {
|
|
104
|
+
new RegExp(pattern, "i");
|
|
105
|
+
return null;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const why = err instanceof Error ? err.message.replace(/^Invalid regular expression: /, "") : String(err);
|
|
108
|
+
const inline = /\(\?[a-z]+\)/.test(pattern)
|
|
109
|
+
? " — JavaScript has no inline flags like (?m); write (^|\\n) for the start of a line"
|
|
110
|
+
: "";
|
|
111
|
+
return `is not a valid JavaScript regular expression (${why})${inline}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
94
115
|
function looksLikeProse(verify: string): boolean {
|
|
95
116
|
const v = verify.trim();
|
|
96
117
|
if (!v || VERIFY_ASSERTION.test(v)) return false;
|
|
@@ -443,6 +464,21 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
443
464
|
});
|
|
444
465
|
}
|
|
445
466
|
|
|
467
|
+
// A `verify: matches:` the runtime cannot compile fails the step on
|
|
468
|
+
// every run, whatever the agent did.
|
|
469
|
+
const verifyRe = step.verify ? /^matches:\s*(.*)$/.exec(step.verify.trim()) : null;
|
|
470
|
+
// The same normalisation checkVerify applies (step-exec.ts): one quote
|
|
471
|
+
// off each end, so the pattern checked is the pattern that runs.
|
|
472
|
+
const reProblem = verifyRe ? regexProblem(verifyRe[1].trim().replace(/^["']|["']$/g, "")) : null;
|
|
473
|
+
if (reProblem) {
|
|
474
|
+
warnings.push({
|
|
475
|
+
step: i,
|
|
476
|
+
line: step.line,
|
|
477
|
+
message: `verify: matches: ${reProblem}`,
|
|
478
|
+
detail: "The runtime tests matches: as a JavaScript regular expression, case-insensitive. One that does not compile fails this step on every run.",
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
446
482
|
// A step's OTHER agent references — the ones no parser validates,
|
|
447
483
|
// because they are options rather than the step's target. Both fail
|
|
448
484
|
// silently and at the worst possible moment: a misspelled `delegate:`
|
package/src/flow-patterns.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { joinGroup, splitToRail, type Groups } from "./arrange.ts";
|
|
|
25
25
|
import { lintFlow } from "./flow-lint.ts";
|
|
26
26
|
import { toLF, asRaw } from "./eol.ts";
|
|
27
27
|
import { removalImpact, stepLabel, type RemovalImpact } from "./step-removal.ts";
|
|
28
|
+
import { keySpan, InlineBlockError } from "./frontmatter-text.ts";
|
|
28
29
|
|
|
29
30
|
// ---------- flow files ----------
|
|
30
31
|
|
|
@@ -474,13 +475,15 @@ function splitComment(value: string): { value: string; comment: string } {
|
|
|
474
475
|
return m ? { value: m[1], comment: m[2] } : { value, comment: "" };
|
|
475
476
|
}
|
|
476
477
|
|
|
478
|
+
/** Where a key is written — at the top or inside its job block
|
|
479
|
+
* (frontmatter-text.ts) — with the text after its colon and its indent. */
|
|
477
480
|
function keyLine(lines: string[], close: number, key: string) {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
483
|
-
return null;
|
|
481
|
+
const where = keySpan(lines.slice(1, close), key);
|
|
482
|
+
if (!where) return null;
|
|
483
|
+
const index = where.at + 1;
|
|
484
|
+
// Quoted or not, as keySpan matched it.
|
|
485
|
+
const m = lines[index].slice(where.indent.length).match(new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&")}["']?\\s*:(.*)$`));
|
|
486
|
+
return m ? { index, rest: m[1], indent: where.indent } : null;
|
|
484
487
|
}
|
|
485
488
|
|
|
486
489
|
/** Block-style item lines under a key: ` - name`, until anything else. */
|
|
@@ -502,7 +505,15 @@ export function frontmatterList(raw: string, key: string): string[] {
|
|
|
502
505
|
const lines = raw.split(eolOf(raw));
|
|
503
506
|
const b = frontBounds(lines);
|
|
504
507
|
if (!b) return [];
|
|
505
|
-
|
|
508
|
+
let k: ReturnType<typeof keyLine>;
|
|
509
|
+
try {
|
|
510
|
+
k = keyLine(lines, b.close, key);
|
|
511
|
+
} catch (err) {
|
|
512
|
+
// A block these text edits do not handle (frontmatter-text.ts) is the
|
|
513
|
+
// writer's to refuse; a read of it is empty, not an error.
|
|
514
|
+
if (err instanceof InlineBlockError) return [];
|
|
515
|
+
throw err;
|
|
516
|
+
}
|
|
506
517
|
if (!k) return [];
|
|
507
518
|
const v = splitComment(k.rest).value.trim();
|
|
508
519
|
if (v.startsWith("[")) return v.replace(/^\[|\]$/g, "").split(",").map(itemName).filter(Boolean);
|
|
@@ -523,9 +534,11 @@ export function editFrontmatterList(raw: string, key: string, name: string, acti
|
|
|
523
534
|
const lines = raw.split(eol);
|
|
524
535
|
const b = frontBounds(lines);
|
|
525
536
|
if (!b) return action === "remove" ? raw : ["---", `${key}: [${name}]`, "---", ...lines].join(eol);
|
|
537
|
+
// Located first, so a key in a block these edits do not handle is refused
|
|
538
|
+
// (InlineBlockError) rather than read as an empty list.
|
|
539
|
+
const k = keyLine(lines, b.close, key);
|
|
526
540
|
const has = frontmatterList(raw, key).includes(name);
|
|
527
541
|
if ((action === "add") === has) return raw;
|
|
528
|
-
const k = keyLine(lines, b.close, key);
|
|
529
542
|
if (!k) {
|
|
530
543
|
lines.splice(b.close, 0, `${key}: [${name}]`);
|
|
531
544
|
return lines.join(eol);
|
|
@@ -536,18 +549,18 @@ export function editFrontmatterList(raw: string, key: string, name: string, acti
|
|
|
536
549
|
// Flow style, or a lone scalar (read as a one-item list): that one line.
|
|
537
550
|
const tokens = v.startsWith("[") ? v.replace(/^\[|\]$/g, "").split(",").map((t) => t.trim()).filter(Boolean) : [v];
|
|
538
551
|
const next = action === "add" ? [...tokens, name] : tokens.filter((t) => itemName(t) !== name);
|
|
539
|
-
lines[k.index] = `${key}: [${next.join(", ")}]${comment}`;
|
|
552
|
+
lines[k.index] = `${k.indent}${key}: [${next.join(", ")}]${comment}`;
|
|
540
553
|
return lines.join(eol);
|
|
541
554
|
}
|
|
542
555
|
const items = blockItems(lines, k.index, b.close);
|
|
543
556
|
if (action === "add") {
|
|
544
|
-
const indent = items.length ? lines[items[0]].match(/^(\s*)-/)![1] :
|
|
557
|
+
const indent = items.length ? lines[items[0]].match(/^(\s*)-/)![1] : `${k.indent} `;
|
|
545
558
|
lines.splice(items.length ? items[items.length - 1] + 1 : k.index + 1, 0, `${indent}- ${name}`);
|
|
546
559
|
return lines.join(eol);
|
|
547
560
|
}
|
|
548
561
|
const drop = items.filter((i) => blockItemName(lines[i]) === name);
|
|
549
562
|
for (const i of [...drop].reverse()) lines.splice(i, 1);
|
|
550
|
-
if (drop.length === items.length) lines[k.index] = `${key}: []${comment}`;
|
|
563
|
+
if (drop.length === items.length) lines[k.index] = `${k.indent}${key}: []${comment}`;
|
|
551
564
|
return lines.join(eol);
|
|
552
565
|
}
|
|
553
566
|
|
|
@@ -577,9 +590,9 @@ export function setFrontmatterScalar(raw: string, key: string, value: string): s
|
|
|
577
590
|
const { value: v, comment } = splitComment(k.rest);
|
|
578
591
|
let end = k.index + 1;
|
|
579
592
|
if (/^\s*[|>][-+]?\d*\s*$/.test(v) || !v.trim()) {
|
|
580
|
-
while (end < b.close && /^\s
|
|
593
|
+
while (end < b.close && (lines[end].match(/^\s*/)?.[0].length ?? 0) > k.indent.length && /\S/.test(lines[end])) end++;
|
|
581
594
|
}
|
|
582
|
-
lines.splice(k.index, end - k.index, `${line}${comment}`);
|
|
595
|
+
lines.splice(k.index, end - k.index, `${k.indent}${line}${comment}`);
|
|
583
596
|
return lines.join(eol);
|
|
584
597
|
}
|
|
585
598
|
const name = keyLine(lines, b.close, "name");
|
|
@@ -34,8 +34,33 @@ export interface FieldSpec {
|
|
|
34
34
|
legacy?: boolean;
|
|
35
35
|
/** One line: what it is. The completion's hint. */
|
|
36
36
|
help: string;
|
|
37
|
+
/** Which of the four jobs it does, when it does one (set from JOB_FIELDS
|
|
38
|
+
* below, not by hand): the block it may also be written in. */
|
|
39
|
+
job?: Job;
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The four jobs of running agents — governance, guardrail, observability,
|
|
44
|
+
* evaluation (the owner's choice of words, 21 Oct 2026). A field that does
|
|
45
|
+
* one may be written flat, as always, or inside a block of that name:
|
|
46
|
+
*
|
|
47
|
+
* guardrail:
|
|
48
|
+
* limits: {repeat: 5}
|
|
49
|
+
* network: [api.example.com]
|
|
50
|
+
*
|
|
51
|
+
* reads exactly as `limits:` and `network:` at the top. matter.ts folds the
|
|
52
|
+
* blocks as it parses, so every reader sees flat keys; check reports a key
|
|
53
|
+
* set both ways (config/conflict) or in the wrong block.
|
|
54
|
+
*/
|
|
55
|
+
export type Job = "governance" | "guardrail" | "observability" | "evaluation";
|
|
56
|
+
|
|
57
|
+
export const JOBS: readonly { name: Job; title: string; what: string }[] = [
|
|
58
|
+
{ name: "governance", title: "Governance", what: "who answers for it and what it may spend, decide and ship" },
|
|
59
|
+
{ name: "guardrail", title: "Guardrail", what: "what it may reach and call, and what stops it" },
|
|
60
|
+
{ name: "observability", title: "Observability", what: "what is recorded, and who is told" },
|
|
61
|
+
{ name: "evaluation", title: "Evaluation", what: "how its work is judged" },
|
|
62
|
+
];
|
|
63
|
+
|
|
39
64
|
export type SchemaKind = "agent" | "agents-md" | "flow" | "tool" | "skill" | "eval" | "hook";
|
|
40
65
|
|
|
41
66
|
/** What can start a flow. store.ts re-exports it; parseFlow and the editor
|
|
@@ -50,6 +75,7 @@ const LOCALE = { validated: true } as const;
|
|
|
50
75
|
const AGENT: FieldSpec[] = [
|
|
51
76
|
{ key: "name", type: "string", help: "kebab-case; the folder is the identity" },
|
|
52
77
|
{ key: "description", type: "string", help: "what it does and when — other agents and people read it" },
|
|
78
|
+
{ key: "owner", type: "string", help: "the person accountable for it — an email or a name; else the workspace's, else the account's" },
|
|
53
79
|
{ key: "model", type: "string", help: "fast | default | max, an alias, or a full model id" },
|
|
54
80
|
{ key: "effort", type: "string", values: EFFORT_LEVELS, help: "how long it thinks" },
|
|
55
81
|
{ key: "size", type: "string", values: ["small", "large", "heavy"], help: "the sandbox it rents; anything else becomes large" },
|
|
@@ -86,6 +112,7 @@ const AGENTS_MD: FieldSpec[] = [
|
|
|
86
112
|
{ key: "mdagent_version", type: ["string", "number"], legacy: true, help: "older spelling of foldrun_version" },
|
|
87
113
|
{ key: "name", type: "string", help: "the workspace's name" },
|
|
88
114
|
{ key: "description", type: "string", help: "shown in the workspace list" },
|
|
115
|
+
{ key: "owner", type: "string", help: "the person accountable for every agent and flow here that names none" },
|
|
89
116
|
{ key: "provider", type: "object", help: "the model endpoint every agent here uses unless it names its own" },
|
|
90
117
|
{ key: "runtime", type: "object", help: "packages for agents that declare none" },
|
|
91
118
|
{ key: "web", type: "object", validated: true, help: "web.browse defaults" },
|
|
@@ -102,18 +129,26 @@ const AGENTS_MD: FieldSpec[] = [
|
|
|
102
129
|
{ key: "budget", type: ["number", "string"], help: "USD per month, or N/day | N/week | N/month" },
|
|
103
130
|
{ key: "priority", type: "string", values: ["high", "normal", "low"], help: "account: where its runs stand in the queue" },
|
|
104
131
|
{ key: "concurrency", type: ["number", "string"], help: "account: runs at once" },
|
|
132
|
+
{ key: "alerts", type: "object", validated: true, help: "thresholds that tell notify: — failure_rate (30%), min_runs, credits_day, run_minutes, stale_days, cost_spike, runs_per_hour, new_host" },
|
|
133
|
+
{ key: "capture", type: "string", values: ["full", "metadata"], help: "what runs record of each tool call: full (its input and output, secrets masked) or metadata (name, size and time only)" },
|
|
134
|
+
{ 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" },
|
|
135
|
+
{ key: "telemetry", type: "object", validated: true, help: "send each run as an OpenTelemetry trace — otlp: <https …/v1/traces>, headers: { authorization: ${SECRET} }" },
|
|
136
|
+
{ 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)" },
|
|
137
|
+
{ 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" },
|
|
105
138
|
{ key: "schedule", type: ["string", "object"], validated: true, legacy: true, help: "refused: only a flow has a clock" },
|
|
106
139
|
];
|
|
107
140
|
|
|
108
141
|
const FLOW: FieldSpec[] = [
|
|
109
142
|
{ key: "name", type: "string", help: "the flow's name" },
|
|
110
143
|
{ key: "description", type: "string", help: "what it is for" },
|
|
144
|
+
{ key: "owner", type: "string", help: "the person accountable for it — an email or a name; else the workspace's, else the account's" },
|
|
111
145
|
{ key: "trigger", type: "string", values: FLOW_TRIGGERS, help: "what starts it; any other value never fires" },
|
|
112
146
|
{ key: "schedule", type: "string", help: "cron, when trigger: schedule" },
|
|
113
147
|
{ key: "timezone", type: "string", validated: true, help: "the zone its schedule reads in" },
|
|
114
148
|
{ key: "model", type: "string", help: "every step's model unless the step says" },
|
|
115
149
|
{ key: "effort", type: "string", values: EFFORT_LEVELS, help: "every step's effort unless the step says" },
|
|
116
150
|
{ key: "overlap", type: "string", validated: true, help: "skip | queue" },
|
|
151
|
+
{ key: "issues", type: "string", values: ["stay-open", "auto-close"], help: "what a good run does to this flow's open issues: stay-open (the default — a person resolves them) or auto-close" },
|
|
117
152
|
{ key: "priority", type: "string", validated: true, help: "high | normal | low" },
|
|
118
153
|
{ key: "catchup", type: "string", validated: true, help: "last | none" },
|
|
119
154
|
{ key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
|
|
@@ -200,10 +235,55 @@ const SAID_WHAT_IT_IS: FieldSpec[] = [
|
|
|
200
235
|
{ key: "type", type: "string", validated: true, legacy: true, help: "no longer read — the path says what this is" },
|
|
201
236
|
];
|
|
202
237
|
|
|
238
|
+
/** Which fields each job holds, per kind of file. One table: the blocks'
|
|
239
|
+
* help, the fold, the editor's completions and check all read it. */
|
|
240
|
+
const JOB_FIELDS: Partial<Record<SchemaKind, Partial<Record<Job, readonly string[]>>>> = {
|
|
241
|
+
agent: {
|
|
242
|
+
governance: ["owner", "budget"],
|
|
243
|
+
guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets"],
|
|
244
|
+
},
|
|
245
|
+
flow: {
|
|
246
|
+
governance: ["owner", "approvers", "approve_within", "budget", "priority"],
|
|
247
|
+
guardrail: ["throttle", "debounce", "overlap", "disable_after", "live"],
|
|
248
|
+
observability: ["issues", "sla"],
|
|
249
|
+
},
|
|
250
|
+
"agents-md": {
|
|
251
|
+
governance: ["owner", "budget", "priority", "concurrency", "deploy_block"],
|
|
252
|
+
guardrail: ["limits", "network", "network_enforce", "hooks"],
|
|
253
|
+
observability: ["alerts", "telemetry", "notify", "capture"],
|
|
254
|
+
evaluation: ["judge"],
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** The fields of one kind that do this job, in schema order. */
|
|
259
|
+
export function fieldsOfJob(kind: SchemaKind, job: Job): string[] {
|
|
260
|
+
return [...(JOB_FIELDS[kind]?.[job] ?? [])];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Every key any kind lets a block hold, per job — what matter.ts may lift
|
|
264
|
+
* out of a block without knowing which kind of file it is reading. */
|
|
265
|
+
export const FOLDABLE: Readonly<Record<Job, ReadonlySet<string>>> = Object.fromEntries(
|
|
266
|
+
JOBS.map(({ name }) => [name, new Set(Object.values(JOB_FIELDS).flatMap((byJob) => byJob?.[name] ?? []))]),
|
|
267
|
+
) as unknown as Record<Job, ReadonlySet<string>>;
|
|
268
|
+
|
|
269
|
+
/** A kind's fields with their jobs set, and a block field per job it has. */
|
|
270
|
+
function withJobs(kind: SchemaKind, fields: FieldSpec[]): FieldSpec[] {
|
|
271
|
+
const byJob = JOB_FIELDS[kind] ?? {};
|
|
272
|
+
const jobOf = new Map<string, Job>();
|
|
273
|
+
for (const [job, keys] of Object.entries(byJob) as [Job, readonly string[]][]) for (const k of keys) jobOf.set(k, job);
|
|
274
|
+
const tagged = fields.map((f) => (jobOf.has(f.key) ? { ...f, job: jobOf.get(f.key)! } : f));
|
|
275
|
+
const blocks: FieldSpec[] = JOBS.filter((j) => (byJob[j.name]?.length ?? 0) > 0).map((j) => ({
|
|
276
|
+
key: j.name,
|
|
277
|
+
type: "object",
|
|
278
|
+
help: `${j.title} — ${j.what}: ${byJob[j.name]!.join(", ")}, as one block`,
|
|
279
|
+
}));
|
|
280
|
+
return [...tagged, ...blocks];
|
|
281
|
+
}
|
|
282
|
+
|
|
203
283
|
export const FRONTMATTER: Readonly<Record<SchemaKind, readonly FieldSpec[]>> = {
|
|
204
|
-
agent: [...AGENT, ...SAID_WHAT_IT_IS],
|
|
205
|
-
"agents-md": AGENTS_MD,
|
|
206
|
-
flow: [...FLOW, ...SAID_WHAT_IT_IS],
|
|
284
|
+
agent: withJobs("agent", [...AGENT, ...SAID_WHAT_IT_IS]),
|
|
285
|
+
"agents-md": withJobs("agents-md", AGENTS_MD),
|
|
286
|
+
flow: withJobs("flow", [...FLOW, ...SAID_WHAT_IT_IS]),
|
|
207
287
|
tool: [...TOOL, ...SAID_WHAT_IT_IS.filter((f) => f.key !== "type")],
|
|
208
288
|
skill: [...SKILL, ...SAID_WHAT_IT_IS],
|
|
209
289
|
eval: [...EVAL, ...SAID_WHAT_IT_IS],
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
|
|
10
|
+
import { isDeepStrictEqual } from "node:util";
|
|
11
|
+
import { configMatter, jobFold } from "./matter.ts";
|
|
12
|
+
|
|
13
|
+
/** A write the guard refused. 409: the file is fine, this edit is not. */
|
|
14
|
+
export class UnsafeEditError extends Error {
|
|
15
|
+
status = 409;
|
|
16
|
+
constructor(why: string) {
|
|
17
|
+
super(`not saved — ${why}. Edit the file itself, or write its blocks one key per line`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const front = (text: string) => configMatter(text).data as Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
/** What a changed key must read back as: a value (null: absent), or a test
|
|
24
|
+
* of what it reads as, for a writer whose output form is its own (a canvas
|
|
25
|
+
* list, a scalar YAML may type). */
|
|
26
|
+
export type Expect = unknown | ((value: unknown) => boolean);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `after` if it is `before` with only `changed` keys different and each
|
|
30
|
+
* `expect`ed key reading back as expected; throws UnsafeEditError otherwise.
|
|
31
|
+
* Checked on every call — an edit that changed nothing must still have done
|
|
32
|
+
* what it said — and refused when `before` cannot be read, since nothing
|
|
33
|
+
* then could be compared.
|
|
34
|
+
*/
|
|
35
|
+
export function guardEdit(before: string, after: string, changed: readonly string[], expect: Record<string, Expect> = {}): string {
|
|
36
|
+
let was: Record<string, unknown>;
|
|
37
|
+
try {
|
|
38
|
+
was = front(before);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new UnsafeEditError("the frontmatter does not parse as it is, so this edit could not be checked — fix the file first");
|
|
41
|
+
}
|
|
42
|
+
let now: Record<string, unknown>;
|
|
43
|
+
try {
|
|
44
|
+
now = front(after);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new UnsafeEditError(`the result would not parse (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`);
|
|
47
|
+
}
|
|
48
|
+
const conflictsBefore = jobFold(was)?.conflicts ?? new Map();
|
|
49
|
+
for (const [k, block] of jobFold(now)?.conflicts ?? new Map()) {
|
|
50
|
+
if (!conflictsBefore.has(k)) throw new UnsafeEditError(`\`${k}\` would be written both at the top and inside \`${block}:\``);
|
|
51
|
+
}
|
|
52
|
+
for (const [k, want] of Object.entries(expect)) {
|
|
53
|
+
const ok = typeof want === "function" ? (want as (v: unknown) => boolean)(now[k]) : isDeepStrictEqual(now[k], want === null ? undefined : want);
|
|
54
|
+
if (!ok) throw new UnsafeEditError(`\`${k}\` would not read back as written`);
|
|
55
|
+
}
|
|
56
|
+
for (const k of new Set([...Object.keys(was), ...Object.keys(now)])) {
|
|
57
|
+
if (changed.includes(k)) continue;
|
|
58
|
+
if (!isDeepStrictEqual(was[k], now[k])) throw new UnsafeEditError(`\`${k}\` would change too`);
|
|
59
|
+
}
|
|
60
|
+
return after;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The front of `text` as a run reads it, or null when it does not parse. */
|
|
64
|
+
export function readFront(text: string): Record<string, unknown> | null {
|
|
65
|
+
try {
|
|
66
|
+
return front(text);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -2,10 +2,12 @@
|
|
|
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
4
|
|
|
5
|
-
import { FRONTMATTER, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
|
|
5
|
+
import { FRONTMATTER, JOBS, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
|
|
6
|
+
import { jobFold } from "./matter.ts";
|
|
6
7
|
import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS } from "./store.ts";
|
|
7
8
|
import { budgetProblem } from "./budget.ts";
|
|
8
9
|
import { cronProblem } from "./cron.ts";
|
|
10
|
+
import { modelSecretCollision } from "./model-credential.ts";
|
|
9
11
|
|
|
10
12
|
export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
|
|
11
13
|
|
|
@@ -31,6 +33,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
|
|
|
31
33
|
apis: apisProblem,
|
|
32
34
|
budget: (v) => budgetProblem(v, ["run"]),
|
|
33
35
|
ask: (v) => askProblem(v),
|
|
36
|
+
secrets: modelSecretCollision,
|
|
34
37
|
},
|
|
35
38
|
"agents-md": {
|
|
36
39
|
budget: (v) => budgetProblem(v, ["day", "week", "month"]),
|
|
@@ -54,6 +57,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
|
|
|
54
57
|
transport: (v) => (["http", "script", "mcp"].includes(String(v).toLowerCase()) ? null : `"${String(v)}" is not a transport — http, script or mcp`),
|
|
55
58
|
methods: methodsProblem,
|
|
56
59
|
rate: (v) => (parseRate(v) ? null : `"${shown(v)}" is not a rate — N/s, N/minute, N/hour, or a number per second, above 0`),
|
|
60
|
+
secrets: modelSecretCollision,
|
|
57
61
|
},
|
|
58
62
|
"eval": {
|
|
59
63
|
effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max; the judge runs at none`),
|
|
@@ -62,8 +66,8 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
|
|
|
62
66
|
|
|
63
67
|
export interface FieldProblem {
|
|
64
68
|
key: string;
|
|
65
|
-
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field";
|
|
66
|
-
level: "warn" | "info";
|
|
69
|
+
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced";
|
|
70
|
+
level: "error" | "warn" | "info";
|
|
67
71
|
message: string;
|
|
68
72
|
}
|
|
69
73
|
|
|
@@ -94,7 +98,31 @@ export function frontmatterProblems(
|
|
|
94
98
|
const fields = FRONTMATTER[kind];
|
|
95
99
|
const known = fields.map((f) => f.key);
|
|
96
100
|
const out: FieldProblem[] = [];
|
|
101
|
+
// The four job blocks (matter.ts folds them): a key set both ways, and a
|
|
102
|
+
// key left in a block that does not hold it.
|
|
103
|
+
const fold = jobFold(data);
|
|
104
|
+
for (const [key, job] of fold?.conflicts ?? []) {
|
|
105
|
+
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` });
|
|
106
|
+
}
|
|
107
|
+
for (const { name: job } of JOBS) {
|
|
108
|
+
const block = data[job];
|
|
109
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) continue;
|
|
110
|
+
for (const inner of Object.keys(block as Record<string, unknown>)) {
|
|
111
|
+
const spec = fields.find((f) => f.key === inner);
|
|
112
|
+
const message = spec?.job
|
|
113
|
+
? `\`${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`
|
|
114
|
+
: spec
|
|
115
|
+
? `\`${inner}:\` belongs to none of the four jobs — write it at the top. Inside \`${job}:\` it is not read`
|
|
116
|
+
: `\`${inner}:\` inside \`${job}:\` is not a field foldrun reads${belongsIn(inner, kind) ? ` here — it is read in ${belongsIn(inner, kind)}` : ""}; it is ignored`;
|
|
117
|
+
// A real setting in the wrong block is a setting silently not applied
|
|
118
|
+
// — a guardrail written under governance: guards nothing — so it is an
|
|
119
|
+
// error (Codex debate). A word foldrun does not read at all is a note.
|
|
120
|
+
out.push({ key: inner, rule: "config/misplaced", level: spec ? "error" : "info", message });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
97
123
|
for (const [key, value] of Object.entries(data)) {
|
|
124
|
+
// A block holding only misplaced keys is reported above, key by key.
|
|
125
|
+
if (JOBS.some((j) => j.name === key) && value && typeof value === "object" && !Array.isArray(value)) continue;
|
|
98
126
|
const spec = fields.find((f) => f.key === key);
|
|
99
127
|
const check = CHECKS[kind]?.[key];
|
|
100
128
|
if (!spec) {
|
|
@@ -107,7 +135,8 @@ export function frontmatterProblems(
|
|
|
107
135
|
// it is an option of a flow's step.
|
|
108
136
|
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` });
|
|
109
137
|
} else if (home) {
|
|
110
|
-
|
|
138
|
+
const where = fold?.folded.get(key);
|
|
139
|
+
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` });
|
|
111
140
|
} else {
|
|
112
141
|
out.push({ key, rule: "config/unknown-field", level: "info", message: `\`${key}:\` is not a field foldrun reads; it is ignored` });
|
|
113
142
|
}
|