@foldrun/core 2.5.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.
Files changed (61) hide show
  1. package/dist/src/agent-import.js +2 -2
  2. package/dist/src/agent-tools.js +2 -2
  3. package/dist/src/agents-md.d.ts +5 -1
  4. package/dist/src/agents-md.js +42 -18
  5. package/dist/src/completions.js +25 -3
  6. package/dist/src/deploy.js +72 -7
  7. package/dist/src/evals.d.ts +22 -1
  8. package/dist/src/evals.js +43 -14
  9. package/dist/src/flow-lint.d.ts +14 -0
  10. package/dist/src/flow-lint.js +35 -0
  11. package/dist/src/flow-patterns.js +29 -14
  12. package/dist/src/frontmatter-fields.d.ts +27 -0
  13. package/dist/src/frontmatter-fields.js +51 -3
  14. package/dist/src/frontmatter-guard.d.ts +19 -0
  15. package/dist/src/frontmatter-guard.js +67 -0
  16. package/dist/src/frontmatter-schema.d.ts +2 -2
  17. package/dist/src/frontmatter-schema.js +30 -2
  18. package/dist/src/frontmatter-text.d.ts +31 -0
  19. package/dist/src/frontmatter-text.js +102 -0
  20. package/dist/src/judge-verdict.d.ts +19 -0
  21. package/dist/src/judge-verdict.js +54 -0
  22. package/dist/src/matter.d.ts +34 -0
  23. package/dist/src/matter.js +104 -0
  24. package/dist/src/network.d.ts +0 -1
  25. package/dist/src/network.js +10 -7
  26. package/dist/src/package.js +9 -2
  27. package/dist/src/provider-health.js +2 -2
  28. package/dist/src/run-container.d.ts +9 -0
  29. package/dist/src/run-container.js +4 -1
  30. package/dist/src/runner.js +119 -36
  31. package/dist/src/scan.d.ts +11 -0
  32. package/dist/src/scan.js +86 -8
  33. package/dist/src/starter.js +23 -0
  34. package/dist/src/step-exec.d.ts +10 -2
  35. package/dist/src/step-exec.js +12 -9
  36. package/dist/src/store.d.ts +17 -3
  37. package/dist/src/store.js +14 -10
  38. package/package.json +1 -1
  39. package/src/agent-import.ts +2 -2
  40. package/src/agent-tools.ts +2 -2
  41. package/src/agents-md.ts +39 -14
  42. package/src/completions.ts +26 -3
  43. package/src/deploy.ts +59 -7
  44. package/src/evals.ts +45 -13
  45. package/src/flow-lint.ts +36 -0
  46. package/src/flow-patterns.ts +26 -13
  47. package/src/frontmatter-fields.ts +74 -3
  48. package/src/frontmatter-guard.ts +70 -0
  49. package/src/frontmatter-schema.ts +30 -4
  50. package/src/frontmatter-text.ts +109 -0
  51. package/src/judge-verdict.ts +52 -0
  52. package/src/matter.ts +112 -0
  53. package/src/network.ts +10 -6
  54. package/src/package.ts +8 -2
  55. package/src/provider-health.ts +2 -2
  56. package/src/run-container.ts +11 -1
  57. package/src/runner.ts +118 -37
  58. package/src/scan.ts +86 -8
  59. package/src/starter.ts +23 -0
  60. package/src/step-exec.ts +21 -8
  61. package/src/store.ts +23 -10
package/src/deploy.ts CHANGED
@@ -38,21 +38,24 @@ import {
38
38
  } from "./store.ts";
39
39
  import { readAgentsMdKeys } from "./agents-md.ts";
40
40
  import { libraryTools } from "./library.ts";
41
- import { lintFlow } from "./flow-lint.ts";
41
+ import { lintFlow, regexProblem } from "./flow-lint.ts";
42
+ import { parseEval } from "./evals.ts";
42
43
  import { refNames } from "./refs.ts";
43
- import matter from "./matter.ts";
44
+ import matter, { configMatter } from "./matter.ts";
45
+ import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
44
46
  import { conformanceIssues } from "./okf.ts";
45
47
  import { timezoneProblem } from "./clock.ts";
46
48
  import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
47
49
  import { readLimits } from "./limits.ts";
48
50
  import { parseAlerts } from "./alerts.ts";
49
51
  import { parseTelemetry } from "./otel-config.ts";
52
+ import { parseJudge } from "./judge-verdict.ts";
50
53
  import { networkProblems } from "./network.ts";
51
54
  import { actionProblems } from "./web-actions.ts";
52
55
  import { languageProblem } from "./language.ts";
53
56
  import { localeProblems } from "./locale.ts";
54
57
  import { entriesNoFollow } from "./paths.ts";
55
- import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, scanRule, type ScanFinding, type ScanOptions } from "./scan.ts";
58
+ import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, scanRule, STRICT_FRONTMATTER_PATH, type ScanFinding, type ScanOptions } from "./scan.ts";
56
59
 
57
60
  /** Directories a deploy never reads out of a source tree. */
58
61
  // `.cache` is what a run's tools leave in an agent's folder (HOME is that
@@ -321,7 +324,7 @@ export function deployBlockMode(files: DeployFile[], accountKeys: Record<string,
321
324
  let fromWorkspace: DeployBlockMode | null = null;
322
325
  if (own) {
323
326
  try {
324
- fromWorkspace = blockMode((matter(own.content).data as Record<string, unknown>).deploy_block);
327
+ fromWorkspace = blockMode((configMatter(own.content).data as Record<string, unknown>).deploy_block);
325
328
  } catch {
326
329
  // an AGENTS.md that does not parse is reported elsewhere
327
330
  }
@@ -450,6 +453,32 @@ function structuralWarnings(files: DeployFile[], extraOutward: Iterable<string>
450
453
  if (refNames(data.tools).some((t: string) => outwardTools.has(t))) outwardAgents.push(m[1]);
451
454
  for (const w of webWarnings(data, { chrome, chromium })) out.push({ where: f.path, message: w });
452
455
  }
456
+ // An eval's `matches:` is the same JavaScript RegExp as a verify's; one
457
+ // that does not compile fails its case on every run. Read through
458
+ // parseEval, so a block scalar (`matches: |`) or a continued line is
459
+ // tested as the runtime will run it. The line is the item's own: the
460
+ // parsed assertions and the `- matches:` lines come in the same order.
461
+ for (const f of files) {
462
+ if (!/^evals\/[^/]+\.md$/.test(f.path)) continue;
463
+ let info;
464
+ try { info = parseEval(f.path, f.content); } catch { continue; }
465
+ const lines: number[] = [];
466
+ f.content.split("\n").forEach((line, n) => { if (/^\s*-\s*matches:/.test(line)) lines.push(n + 1); });
467
+ // When a `- matches:` line sits outside a case (an example in the
468
+ // prose), the order no longer pairs up: name the file, not a wrong line.
469
+ const parsed = info.cases.reduce((n, c) => n + c.expect.filter((a) => a.type === "matches").length, 0);
470
+ const pairs = lines.length === parsed;
471
+ let k = 0;
472
+ for (const c of info.cases) {
473
+ for (const a of c.expect) {
474
+ if (a.type !== "matches") continue;
475
+ const at = pairs ? lines[k] : undefined;
476
+ k++;
477
+ const problem = regexProblem(a.value);
478
+ if (problem) out.push({ where: at ? `${f.path}:${at}` : f.path, message: `case "${c.name}": matches: ${problem}` });
479
+ }
480
+ }
481
+ }
453
482
  if (!outwardAgents.length) return out;
454
483
  for (const f of files) {
455
484
  if (!/^flows\/[^/]+\.md$/.test(f.path)) continue;
@@ -496,10 +525,11 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
496
525
  // nothing said so — gray-matter's cache let a second parse pass — and one
497
526
  // such agent file kept the worker from booting for every account.
498
527
  // Definition files only: a skill is read leniently (an unquoted colon is
499
- // quoted and retried, runner.ts safeMatter), and the markdown beside a
528
+ // quoted and retried, matter.ts parseLenient), and the markdown beside a
500
529
  // definition — references, a tool's README — is prose, not frontmatter.
530
+ // The scanner warns about skills and library files (config/invalid-frontmatter).
501
531
  for (const f of files) {
502
- if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md|flows\/[^/]+\.md|tools\/[^/]+\.md|tools\/[^/]+\/tool\.md|evals\/[^/]+\.md|hooks\/[^/]+\/hook\.md|\.claude\/agents\/[^/]+\.md)$/.test(f.path)) continue;
532
+ if (!STRICT_FRONTMATTER_PATH.test(f.path)) continue;
503
533
  if (!/^\uFEFF?---\r?\n/.test(f.content)) continue;
504
534
  try {
505
535
  matter(f.content);
@@ -508,6 +538,26 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
508
538
  }
509
539
  }
510
540
 
541
+ // The four job blocks (frontmatter-fields.ts JOB_FIELDS): a setting
542
+ // written both at the top and in its block, or in another job's block, is
543
+ // a setting that does not do what the file says — a guardrail under
544
+ // governance: guards nothing — so the deploy refuses it as check does
545
+ // (config/conflict, config/misplaced; Codex debate, 21 Oct 2026).
546
+ for (const f of files) {
547
+ const kind = schemaKindOf(f.path);
548
+ if (kind !== "agent" && kind !== "flow" && kind !== "agents-md") continue;
549
+ if (!/^\uFEFF?---\r?\n/.test(f.content)) continue;
550
+ let front: Record<string, unknown>;
551
+ try {
552
+ front = configMatter(f.content).data as Record<string, unknown>;
553
+ } catch {
554
+ continue; // unparseable frontmatter is its own report
555
+ }
556
+ for (const p of frontmatterProblems(kind, front, () => null)) {
557
+ if (p.level === "error" && (p.rule === "config/conflict" || p.rule === "config/misplaced")) at(f.path, `${p.message} [${p.rule}]`);
558
+ }
559
+ }
560
+
511
561
  // `timezone:` is read at four levels and a value nothing can parse falls
512
562
  // silently through to the level above — so it is refused where it was
513
563
  // written, not discovered as a date that is a day out.
@@ -517,7 +567,7 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
517
567
  if (unopened) at(f.path, unopened);
518
568
  let front: Record<string, unknown> = {};
519
569
  try {
520
- front = matter(f.content).data as Record<string, unknown>;
570
+ front = configMatter(f.content).data as Record<string, unknown>;
521
571
  } catch {
522
572
  continue; // unparseable frontmatter is its own report
523
573
  }
@@ -541,6 +591,8 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
541
591
  // `telemetry:` that cannot be read would never send — and a token
542
592
  // written into it would ship in the file.
543
593
  for (const w of parseTelemetry(front.telemetry).problems) at(f.path, w);
594
+ // `judge:` that cannot be read would leave every judge on the default.
595
+ for (const w of parseJudge(front.judge).problems) at(f.path, w);
544
596
  const lang = languageProblem(front.language);
545
597
  if (lang) at(f.path, lang);
546
598
  for (const w of localeProblems(front)) at(f.path, w);
package/src/evals.ts CHANGED
@@ -34,15 +34,19 @@
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
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,
47
51
  runVerdict,
48
52
  writeWorkspaceFile,
@@ -146,7 +150,9 @@ function readScalar(
146
150
  return { value: [first, ...parts].join(" ").trim(), end: i };
147
151
  }
148
152
 
149
- export function parseEval(file: string, raw: string): EvalInfo {
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 {
150
156
  const { data, content } = matter(raw);
151
157
  const cases: EvalCase[] = [];
152
158
  let current: EvalCase | null = null;
@@ -197,8 +203,8 @@ export function parseEval(file: string, raw: string): EvalInfo {
197
203
  file,
198
204
  agent: typeof data.agent === "string" ? data.agent : null,
199
205
  flow: typeof data.flow === "string" ? data.flow : null,
200
- model: resolveModel(data.model ?? "fast"),
201
- effort: resolveEffort(data.effort ?? "low"),
206
+ model: resolveModel(data.model ?? defaults.model ?? "fast"),
207
+ effort: resolveEffort(data.effort ?? defaults.effort ?? "low"),
202
208
  trigger: data.trigger === "manual" ? "manual" : "deploy",
203
209
  live: data.live === true,
204
210
  inputs: data.inputs === true,
@@ -213,7 +219,18 @@ export function listEvals(tenant: string, workspace: string): EvalInfo[] {
213
219
  .readdirSync(dir)
214
220
  .filter((f) => f.endsWith(".md"))
215
221
  .sort()
216
- .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 } : {}) };
217
234
  }
218
235
 
219
236
  // ---------- promoting a run ----------
@@ -484,9 +501,12 @@ function shell(command: string, cwd: string): Promise<{ code: number | null; out
484
501
  });
485
502
  }
486
503
 
487
- // The judge gets the rubric and the output, and must answer with one word.
504
+ // The judge gets the rubric and the output, and ends with one word.
488
505
  // Constrained output beats asking for a score: "PASS" or "FAIL" can't be
489
- // 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).
490
510
  async function judge(
491
511
  rubric: string,
492
512
  output: string,
@@ -495,8 +515,7 @@ async function judge(
495
515
  env: Record<string, string | undefined>,
496
516
  ): Promise<AssertionResult["detail"] & string> {
497
517
  const prompt =
498
- `You are grading one piece of output against one criterion. Answer with exactly ` +
499
- `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` +
500
519
  `Criterion: ${rubric}\n\n<output>\n${output.slice(0, 20000)}\n</output>`;
501
520
 
502
521
  let text = "";
@@ -523,13 +542,26 @@ async function judge(
523
542
  return text.trim();
524
543
  }
525
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
+
526
558
  async function checkAssertion(
527
559
  assertion: Assertion,
528
560
  output: string,
529
561
  agentDir: string,
530
562
  model: string,
531
563
  effort: Effort | null,
532
- judgeEnv: () => { env: Record<string, string | undefined>; translator?: TranslatorSpec | null } = () => ({ env: sdkEnv(undefined) }),
564
+ judgeEnv: () => { env: Record<string, string | undefined>; translator?: TranslatorSpec | null } = () => ({ env: judgeProcessEnv() }),
533
565
  ): Promise<AssertionResult> {
534
566
  const hay = output.toLowerCase();
535
567
  switch (assertion.type) {
@@ -570,9 +602,9 @@ async function checkAssertion(
570
602
  } catch (err) {
571
603
  return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
572
604
  }
573
- const verdict = await withModelEnv(m, (env) => judge(assertion.value, output, model, effort, env));
574
- const passed = /^\s*PASS\b/i.test(verdict);
575
- return { assertion, passed, detail: verdict.slice(0, 300) || "judge returned nothing" };
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 };
576
608
  }
577
609
  }
578
610
  }
@@ -653,7 +685,7 @@ export async function runEval(
653
685
  const judges = testCase.expect.filter((a) => a.type === "judge");
654
686
  for (const a of cheap) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
655
687
  if (assertions.every((r) => r.passed)) {
656
- const judgeEnv = () => accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
688
+ const judgeEnv = () => accountModelEnv(tenant, workspace, judgeProcessEnv);
657
689
  for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
658
690
  } else {
659
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:`
@@ -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 re = new RegExp(`^${key.replace(/[-]/g, "\\-")}:(.*)$`);
479
- for (let i = 1; i < close; i++) {
480
- const m = lines[i].match(re);
481
- if (m) return { index: i, rest: m[1] };
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
- const k = keyLine(lines, b.close, key);
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+\S/.test(lines[end])) end++;
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
@@ -109,6 +134,7 @@ const AGENTS_MD: FieldSpec[] = [
109
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" },
110
135
  { key: "telemetry", type: "object", validated: true, help: "send each run as an OpenTelemetry trace — otlp: <https …/v1/traces>, headers: { authorization: ${SECRET} }" },
111
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" },
112
138
  { key: "schedule", type: ["string", "object"], validated: true, legacy: true, help: "refused: only a flow has a clock" },
113
139
  ];
114
140
 
@@ -209,10 +235,55 @@ const SAID_WHAT_IT_IS: FieldSpec[] = [
209
235
  { key: "type", type: "string", validated: true, legacy: true, help: "no longer read — the path says what this is" },
210
236
  ];
211
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
+
212
283
  export const FRONTMATTER: Readonly<Record<SchemaKind, readonly FieldSpec[]>> = {
213
- agent: [...AGENT, ...SAID_WHAT_IT_IS],
214
- "agents-md": AGENTS_MD,
215
- 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]),
216
287
  tool: [...TOOL, ...SAID_WHAT_IT_IS.filter((f) => f.key !== "type")],
217
288
  skill: [...SKILL, ...SAID_WHAT_IT_IS],
218
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,7 +2,8 @@
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";
@@ -65,8 +66,8 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
65
66
 
66
67
  export interface FieldProblem {
67
68
  key: string;
68
- rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field";
69
- 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";
70
71
  message: string;
71
72
  }
72
73
 
@@ -97,7 +98,31 @@ export function frontmatterProblems(
97
98
  const fields = FRONTMATTER[kind];
98
99
  const known = fields.map((f) => f.key);
99
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
+ }
100
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;
101
126
  const spec = fields.find((f) => f.key === key);
102
127
  const check = CHECKS[kind]?.[key];
103
128
  if (!spec) {
@@ -110,7 +135,8 @@ export function frontmatterProblems(
110
135
  // it is an option of a flow's step.
111
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` });
112
137
  } else if (home) {
113
- out.push({ key, rule: "config/unknown-field", level: "warn", message: `\`${key}:\` is read in ${home}, not in ${kind === "agents-md" ? "AGENTS.md" : kind === "eval" ? "an eval" : `a ${kind}`} — here it is ignored` });
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` });
114
140
  } else {
115
141
  out.push({ key, rule: "config/unknown-field", level: "info", message: `\`${key}:\` is not a field foldrun reads; it is ignored` });
116
142
  }