@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/dist/src/scan.js CHANGED
@@ -26,7 +26,7 @@
26
26
  // The reason is required: an ignore without one ignores nothing and is itself
27
27
  // reported, because "why is this allowed?" is the question a reviewer asks.
28
28
  import crypto from "node:crypto";
29
- import matter from "./matter.js";
29
+ import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, configMatter } from "./matter.js";
30
30
  import { refNames } from "./refs.js";
31
31
  import { resolveNetwork } from "./network.js";
32
32
  import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
@@ -159,6 +159,32 @@ export const SCAN_RULES = [
159
159
  help: "`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
160
160
  "so the agent or flow quietly does something else. The message lists what the field takes.",
161
161
  },
162
+ {
163
+ id: "config/conflict",
164
+ category: "config",
165
+ level: "error",
166
+ title: "A field set twice: at the top and inside its job block",
167
+ help: "`limits:` at the top and `limits:` inside `guardrail:` cannot both be meant. The top one would be read and the other " +
168
+ "quietly ignored, so the file is refused until one is removed.",
169
+ },
170
+ {
171
+ id: "config/misplaced",
172
+ category: "config",
173
+ level: "error",
174
+ title: "A field inside the wrong job block",
175
+ help: "Each of the four blocks (governance, guardrail, observability, evaluation) holds its own fields. A setting written in " +
176
+ "another block is not read — a guardrail under governance: guards nothing — so the file is refused until it is moved " +
177
+ "to the block the message names, or to the top. A word foldrun does not read at all is only a note.",
178
+ },
179
+ {
180
+ id: "config/invalid-frontmatter",
181
+ category: "config",
182
+ level: "warn",
183
+ title: "Frontmatter that YAML cannot read",
184
+ help: "A value with a colon followed by a space (`description: the format (Markdown, ::: directives)`) ends the YAML. " +
185
+ "foldrun reads a skill like that by quoting the value, but the dashboard editor and the source API refuse to save it, " +
186
+ "and other tools that read the file may not. Quote the value: `description: \"…\"`.",
187
+ },
162
188
  {
163
189
  id: "config/missing-field",
164
190
  category: "config",
@@ -544,17 +570,35 @@ export function nearestField(key, known) {
544
570
  return bestD <= allowed ? best : null;
545
571
  }
546
572
  function keyLine(lines, key) {
547
- const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`);
548
- const i = lines.findIndex((l) => re.test(l));
549
- return i >= 0 ? i + 1 : undefined;
573
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
574
+ const top = new RegExp(`^${k}\\s*:`);
575
+ const i = lines.findIndex((l) => top.test(l));
576
+ if (i >= 0)
577
+ return i + 1;
578
+ // A key inside one of the four job blocks: indented, under its header.
579
+ const inner = new RegExp(`^\\s+${k}\\s*:`);
580
+ let inBlock = false;
581
+ for (let n = 0; n < lines.length; n++) {
582
+ const l = lines[n];
583
+ if (/^(governance|guardrail|observability|evaluation)\s*:\s*(#.*)?$/.test(l))
584
+ inBlock = true;
585
+ else if (/^\S/.test(l))
586
+ inBlock = false;
587
+ else if (inBlock && inner.test(l))
588
+ return n + 1;
589
+ }
590
+ return undefined;
550
591
  }
551
592
  /** Read-only: gray-matter caches by content, so two files with the same
552
593
  * text share one object, and writing to it would leak between them. */
553
- function frontOf(content) {
594
+ function frontOf(content, filePath) {
554
595
  if (!/^?---\r?\n/.test(content))
555
596
  return null;
556
597
  try {
557
- return matter(content).data;
598
+ // An agent, a flow or AGENTS.md reads its job blocks as flat keys, as
599
+ // the runtime does; every other document is read as written.
600
+ const kind = filePath ? schemaKindOf(filePath) : null;
601
+ return (kind === "agent" || kind === "flow" || kind === "agents-md" ? configMatter(content) : matter(content)).data;
558
602
  }
559
603
  catch {
560
604
  return null; // a frontmatter YAML cannot read is check's finding, not ours
@@ -683,6 +727,14 @@ const VENDORED = /(?:^|\/)(?:node_modules|\.venv|venv|site-packages|__pycache__|
683
727
  * since a key in a log here is a key in the workspace (seen live on
684
728
  * 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
685
729
  * text). */
730
+ /** The definition files a deploy refuses outright when their frontmatter does
731
+ * not parse (deploy.ts deployIssues): the workspace's own, by these paths.
732
+ * Everything else the platform reads frontmatter from — skills, and the
733
+ * account library's agents, tools and hooks — is the scanner's to report. */
734
+ export const STRICT_FRONTMATTER_PATH = /^(AGENTS\.md|agents\/[^/]+\/agent\.md|flows\/[^/]+\.md|tools\/[^/]+\.md|tools\/[^/]+\/tool\.md|evals\/[^/]+\.md|hooks\/[^/]+\/hook\.md|\.claude\/agents\/[^/]+\.md)$/;
735
+ /** A skill file in either layout the runtime discovers: <name>/SKILL.md, or
736
+ * the flat <name>.md, under skills/ or .agents/skills/, in the library too. */
737
+ const SKILL_FILE = /(?:^|\/)skills\/(?:[^/]+\/SKILL\.md|[^/]+\.md)$/;
686
738
  const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.foldrun)\//;
687
739
  /** Text files only — a PNG read as UTF-8 is noise to every rule here. */
688
740
  function isText(f) {
@@ -740,9 +792,35 @@ export function scanFiles(files, options = {}) {
740
792
  scanContent(f, lines, push);
741
793
  scanSupplyChain(f, lines, push);
742
794
  const schema = schemaKindOf(f.path);
795
+ // Frontmatter YAML cannot read. Until 2026-10-21 nothing reported it for
796
+ // a skill or a library file: frontOf below reads it as "no frontmatter"
797
+ // and moves on, and the deploy's own refusal covers only the workspace's
798
+ // definition files. A vaza.ai skill with `description: … (Markdown,
799
+ // frontmatter, ::: directives) …` deployed clean, ran (the runtime quotes
800
+ // the value and retries), and was refused by the source API on its next
801
+ // save.
802
+ if ((schema || SKILL_FILE.test(f.path)) && !STRICT_FRONTMATTER_PATH.test(f.path) && /^\uFEFF?---\r?\n/.test(f.content)) {
803
+ let err = null;
804
+ try {
805
+ matter(f.content);
806
+ }
807
+ catch (e) {
808
+ err = e;
809
+ }
810
+ if (err) {
811
+ const isSkill = schema === "skill" || SKILL_FILE.test(f.path);
812
+ const read = parseLenient(f.content);
813
+ const what = isSkill && !read.failed
814
+ ? "foldrun reads this skill anyway by quoting the value, but the dashboard editor and the source API refuse to save it, and other skills clients may not load it"
815
+ : isSkill
816
+ ? "the runtime reads it as empty, so the skill has no name or description and is never loaded"
817
+ : "the runtime cannot read this file";
818
+ push("config/invalid-frontmatter", f.path, frontmatterErrorLine(err), `config/invalid-frontmatter ${frontmatterErrorText(err)}`, `${frontmatterErrorText(err)} ${what === "the runtime cannot read this file" ? "The runtime cannot read this file" : what.replace(/^foldrun/, "foldrun").replace(/^the/, "The")}.`);
819
+ }
820
+ }
743
821
  // A skill with no frontmatter at all is the clearest one that never
744
822
  // loads: read it as empty, so its required fields are reported.
745
- const data = schema || docKind(f.path) ? (frontOf(f.content) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
823
+ const data = schema || docKind(f.path) ? (frontOf(f.content, f.path) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
746
824
  if (!data)
747
825
  continue;
748
826
  if (schema) {
@@ -770,7 +848,7 @@ export function scanFiles(files, options = {}) {
770
848
  }
771
849
  // project.md is the older name the runtime still reads (Codex review, FOL-58).
772
850
  const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
773
- scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
851
+ scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
774
852
  for (const m of more.values()) {
775
853
  findings.push({ rule: m.rule, level: m.level ?? scanRule(m.rule)?.level ?? "warn", file: m.file, message: `and ${m.n} more like this in this file, not listed`, fingerprint: m.hash.digest("hex").slice(0, 16), truncated: true });
776
854
  }
@@ -55,6 +55,22 @@ foldrun_version: "0.1"
55
55
  #
56
56
  # The token is sent as a bearer credential. A gateway that wants it as a key
57
57
  # header asks for that by name: headers: { x-api-key: \${THE_SECRET} }.
58
+ #
59
+ # The four jobs of running agents, each a block — or write the same keys at
60
+ # the top; one place or the other, never both:
61
+ #
62
+ # governance: # who answers for it, what it may spend and ship
63
+ # owner: you@example.com
64
+ # budget: 60/month
65
+ # deploy_block: secrets
66
+ # guardrail: # what agents may reach and call
67
+ # limits: {calls: 300, repeat: 5}
68
+ # network: trusted
69
+ # observability: # what is recorded, and who is told
70
+ # notify: {email: you@example.com, events: [failed]}
71
+ # alerts: {failure_rate: 30%}
72
+ # evaluation: # how work is judged
73
+ # judge: {model: fast}
58
74
  ---
59
75
 
60
76
  # ${account}
@@ -476,6 +492,13 @@ runtime: # only if scripts need packages
476
492
  You research a topic and report what you found...
477
493
  \`\`\`
478
494
 
495
+ The fields that do one of the four jobs of running agents may be grouped
496
+ under it — \`governance:\` (owner, budget), \`guardrail:\` (limits, network,
497
+ hooks, disallowedTools, permissionMode, secrets), and in AGENTS.md also
498
+ \`observability:\` (alerts, telemetry, notify, capture) and \`evaluation:\`
499
+ (judge). A block reads exactly as the same keys at the top; a key written
500
+ both ways is refused (\`config/conflict\`). \`foldrun docs four-jobs\`.
501
+
479
502
  Only \`name\` and the prose are required. Every other field widens or narrows
480
503
  what the agent can reach, and the default is narrow.
481
504
 
@@ -104,6 +104,11 @@ export interface ExecOptions {
104
104
  * `matches: re`, `file: path`, `judge: sentence`) — see checkVerify. */
105
105
  verify?: string;
106
106
  verifyEnv?: Record<string, string>;
107
+ /** The model `verify: judge:` grades with — the workspace's `judge:` from
108
+ * AGENTS.md, resolved; absent, the fast tier. */
109
+ judgeModel?: string;
110
+ /** And how long it thinks — `judge: {effort}`; absent, the SDK's default. */
111
+ judgeEffort?: Effort;
107
112
  /** `output: json` — the reply must carry one JSON value; extracting it is
108
113
  * part of finishing the step, and failing to is failing the step. */
109
114
  output?: "json";
@@ -301,14 +306,17 @@ export declare function checkVerify(agentDir: string, verify: string, ctx: {
301
306
  /** The step's own model environment, for `judge:` — it grades on the
302
307
  * fast tier through the same credential the step rode. */
303
308
  modelEnv?: Record<string, string | undefined>;
309
+ /** The judge's model and effort (the workspace's `judge:`). */
310
+ judgeModel?: string;
311
+ judgeEffort?: Effort;
304
312
  /** Ends a shell check or a judge mid-way: the step's clock ran out, or a
305
313
  * person asked the run to stop. */
306
314
  signal?: AbortSignal;
307
315
  }): Promise<VerifyVerdict>;
308
316
  /**
309
317
  * A toolless, fast-tier grading call: does the reply satisfy the sentence?
310
- * Same shape as the eval judge, and answered with one word first so the
311
- * verdict is a prefix test rather than a reading.
318
+ * Same shape as the eval judge: reasoning first, then the verdict alone on
319
+ * the last line (judge-verdict.ts says why).
312
320
  */
313
321
  export declare function judgePrompt(rubric: string, reply: string, work: string): string;
314
322
  /**
@@ -7,6 +7,7 @@
7
7
  // assembles the system prompt and builds MCP servers, then hands this the
8
8
  // results. That is what lets the same function run somewhere the vault, the
9
9
  // library and the account do not exist.
10
+ import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION } from "./judge-verdict.js";
10
11
  import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks } from "./hooks.js";
11
12
  import fs from "node:fs";
12
13
  import os from "node:os";
@@ -901,6 +902,8 @@ async function executeStepInner(opts, runQuery) {
901
902
  data,
902
903
  modelEnv: opts.env,
903
904
  signal: halt.signal,
905
+ ...(opts.judgeModel ? { judgeModel: opts.judgeModel } : {}),
906
+ ...(opts.judgeEffort ? { judgeEffort: opts.judgeEffort } : {}),
904
907
  });
905
908
  }
906
909
  finally {
@@ -1045,17 +1048,17 @@ export async function checkVerify(agentDir, verify, ctx) {
1045
1048
  // GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
1046
1049
  // text opened with the narration before it.
1047
1050
  const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
1048
- const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
1049
- const ok = /^\s*PASS\b/i.test(verdict);
1050
- return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
1051
+ const said = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal, ctx.judgeModel, ctx.judgeEffort);
1052
+ const { passed: ok, detail } = judgeVerdict(said);
1053
+ return { ok, headline: ok ? "PASS" : "FAIL", detail };
1051
1054
  }
1052
1055
  }
1053
1056
  return { ok: false, headline: `unknown check "${kind}"`, detail: "" };
1054
1057
  }
1055
1058
  /**
1056
1059
  * A toolless, fast-tier grading call: does the reply satisfy the sentence?
1057
- * Same shape as the eval judge, and answered with one word first so the
1058
- * verdict is a prefix test rather than a reading.
1060
+ * Same shape as the eval judge: reasoning first, then the verdict alone on
1061
+ * the last line (judge-verdict.ts says why).
1059
1062
  */
1060
1063
  export function judgePrompt(rubric, reply, work) {
1061
1064
  const shown = reply.slice(0, 20_000);
@@ -1063,12 +1066,11 @@ export function judgePrompt(rubric, reply, work) {
1063
1066
  ? `Everything the step wrote while working, for context. "The reply" means only the reply block below, ` +
1064
1067
  `never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
1065
1068
  : "";
1066
- return (`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
1067
- `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
1069
+ return (`You are grading a reply against one requirement. ${JUDGE_VERDICT_INSTRUCTION}\n\nRequirement: ${rubric}\n\n` +
1068
1070
  context +
1069
1071
  `<reply>\n${shown}\n</reply>`);
1070
1072
  }
1071
- async function judgeReply(rubric, reply, work, env, signal) {
1073
+ async function judgeReply(rubric, reply, work, env, signal, model = "haiku", effort) {
1072
1074
  const texts = [];
1073
1075
  const abortController = new AbortController();
1074
1076
  if (signal?.aborted)
@@ -1078,7 +1080,8 @@ async function judgeReply(rubric, reply, work, env, signal) {
1078
1080
  const q = query({
1079
1081
  prompt: judgePrompt(rubric, reply, work),
1080
1082
  options: {
1081
- model: "haiku",
1083
+ model,
1084
+ ...(effort ? { effort } : {}),
1082
1085
  systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
1083
1086
  tools: [],
1084
1087
  settingSources: [],
@@ -3,6 +3,7 @@ import type { BrowserPodOutcome } from "./browser-pod.ts";
3
3
  import { singleWorkspace } from "./paths.ts";
4
4
  import type { TestEffect } from "./test-mode.ts";
5
5
  import type { OperatorEvent } from "./operator.ts";
6
+ import { frontmatterErrorText } from "./matter.ts";
6
7
  /**
7
8
  * The frontmatter of one markdown file, as the runtime reads it. Exported
8
9
  * for the CLI: `foldrun probe` used to import gray-matter itself, which is
@@ -640,9 +641,7 @@ export interface AgentAssets {
640
641
  }
641
642
  export declare function agentAssets(tenant: string, workspace: string, agent: string): AgentAssets;
642
643
  export declare function listAgents(tenant: string, workspace: string): AgentInfo[];
643
- /** The first line of a YAML parse error, with where it is: what a person
644
- * needs to fix the file, without the parser's excerpt. */
645
- export declare function frontmatterErrorText(err: unknown): string;
644
+ export { frontmatterErrorText };
646
645
  /** The option keys a step actually has. An indented `word: rest` that is
647
646
  * NOT one of these is a line of the instruction — "Warning: do not
648
647
  * publish" is prose that happens to contain a colon, and it used to be
@@ -1069,6 +1068,14 @@ export interface StepAttempt {
1069
1068
  export declare function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs" | "turns">;
1070
1069
  export { type TokenUsage, type PromptLayerRecord, tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens, promptLayersChanged, promptCacheNote } from "./tokens.ts";
1071
1070
  import { type TokenUsage, type PromptLayerRecord } from "./tokens.ts";
1071
+ /** The marker rescueStep puts in a rescue's instruction; how a rescue record
1072
+ * written before `rescueOf` existed is still recognised. */
1073
+ export declare const RESCUE_MARKER = "<rescue>\nYou are taking this step over:";
1074
+ /** Is this record an on-fail rescue rather than a step of the flow? */
1075
+ export declare function isRescueRecord(step: {
1076
+ rescueOf?: string;
1077
+ instruction?: string | null;
1078
+ }): boolean;
1072
1079
  export interface StepRecord {
1073
1080
  agent: string;
1074
1081
  instruction: string;
@@ -1228,6 +1235,13 @@ export interface StepRecord {
1228
1235
  * actually executed in. Carried steps provide context and are never
1229
1236
  * billed again — the original run's ledger line already paid for them. */
1230
1237
  carriedFrom?: string;
1238
+ /** Set on an on-fail rescue record (runner.ts rescueStep): the agent of the
1239
+ * failed step it took over. A rescue is not a step of the flow: a resumed
1240
+ * drive must not launch it as one of its group, and a re-run must not
1241
+ * copy it as work (21 Oct 2026: one rerun carried 77 fixers as pending
1242
+ * steps). Records written before this field are recognised by their
1243
+ * instruction's `<rescue>` block instead — see isRescueRecord. */
1244
+ rescueOf?: string;
1231
1245
  /**
1232
1246
  * Sandbox seconds this step rented, and how many of them were cold start.
1233
1247
  * Set only on the isolated path — an in-process step rents nothing, so
package/dist/src/store.js CHANGED
@@ -27,7 +27,7 @@ import { recordRevision, registerTreeReader } from "./history.js";
27
27
  import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.js";
28
28
  import { platform } from "./platform.js";
29
29
  import { toLF, asRaw } from "./eol.js";
30
- import matter from "./matter.js";
30
+ import matter, { frontmatterErrorText, configMatter } from "./matter.js";
31
31
  import { readAgentsMdKeys } from "./agents-md.js";
32
32
  /**
33
33
  * The frontmatter of one markdown file, as the runtime reads it. Exported
@@ -39,7 +39,7 @@ import { readAgentsMdKeys } from "./agents-md.js";
39
39
  * the runtime, and there is nothing to fail to import.
40
40
  */
41
41
  export function readFrontmatter(file) {
42
- return matter(fs.readFileSync(file, "utf8")).data;
42
+ return configMatter(fs.readFileSync(file, "utf8")).data;
43
43
  }
44
44
  import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.js";
45
45
  import { refNames } from "./refs.js";
@@ -1126,7 +1126,7 @@ export function listAgents(tenant, workspace) {
1126
1126
  let data = {};
1127
1127
  let frontmatterError;
1128
1128
  try {
1129
- ({ data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8")));
1129
+ ({ data } = configMatter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8")));
1130
1130
  }
1131
1131
  catch (err) {
1132
1132
  frontmatterError = frontmatterErrorText(err);
@@ -1181,12 +1181,9 @@ export function listAgents(tenant, workspace) {
1181
1181
  };
1182
1182
  });
1183
1183
  }
1184
- /** The first line of a YAML parse error, with where it is: what a person
1185
- * needs to fix the file, without the parser's excerpt. */
1186
- export function frontmatterErrorText(err) {
1187
- const msg = err instanceof Error ? err.message : String(err);
1188
- return `frontmatter does not parse — ${msg.split("\n")[0].trim()}. Quote a value that contains a colon followed by a space.`;
1189
- }
1184
+ // Kept here for callers that import it from the store; it lives in matter.ts
1185
+ // so the scanner can use it without the store.
1186
+ export { frontmatterErrorText };
1190
1187
  /** The body of a file whose frontmatter would not parse: what follows the
1191
1188
  * closing fence, so a flow's steps can still be read and shown. */
1192
1189
  function bodyAfterFrontmatter(raw) {
@@ -1360,7 +1357,7 @@ export function parseFlow(file, raw) {
1360
1357
  let content;
1361
1358
  let frontError = null;
1362
1359
  try {
1363
- ({ data, content } = matter(raw));
1360
+ ({ data, content } = configMatter(raw));
1364
1361
  }
1365
1362
  catch (err) {
1366
1363
  frontError = frontmatterErrorText(err);
@@ -2658,6 +2655,13 @@ export function sumAttempts(tries) {
2658
2655
  }
2659
2656
  export { tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens, promptLayersChanged, promptCacheNote } from "./tokens.js";
2660
2657
  import { sumTokens } from "./tokens.js";
2658
+ /** The marker rescueStep puts in a rescue's instruction; how a rescue record
2659
+ * written before `rescueOf` existed is still recognised. */
2660
+ export const RESCUE_MARKER = "<rescue>\nYou are taking this step over:";
2661
+ /** Is this record an on-fail rescue rather than a step of the flow? */
2662
+ export function isRescueRecord(step) {
2663
+ return !!step.rescueOf || (step.instruction ?? "").includes(RESCUE_MARKER);
2664
+ }
2661
2665
  // ---------------------------------------------------------------- captured calls
2662
2666
  //
2663
2667
  // What each tool call took and gave (FOL-54 step 4), beside the run under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -15,7 +15,7 @@
15
15
 
16
16
  import fs from "node:fs";
17
17
  import path from "node:path";
18
- import matter from "./matter.ts";
18
+ import { configMatter } from "./matter.ts";
19
19
  import {
20
20
  assertSafeName,
21
21
  listWorkspaceFiles,
@@ -99,7 +99,7 @@ export function importAgent(
99
99
  let content = readWorkspaceFile(tenant, from, src);
100
100
  const rel = src.slice(`agents/${agent}/`.length);
101
101
  if (rel === "agent.md") {
102
- front = matter(content).data as Record<string, unknown>;
102
+ front = configMatter(content).data as Record<string, unknown>;
103
103
  // The folder is the name flows address; a `name:` that disagrees with
104
104
  // it is the first thing check would complain about.
105
105
  if (name !== agent) content = content.replace(/^(---\r?\n(?:[\s\S]*?\r?\n)?)name:[^\n]*$/m, `$1name: ${name}`);
@@ -17,7 +17,7 @@
17
17
  import fs from "node:fs";
18
18
  import os from "node:os";
19
19
  import path from "node:path";
20
- import matter from "./matter.ts";
20
+ import { configMatter } from "./matter.ts";
21
21
  import { z } from "zod";
22
22
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
23
23
  import { resolveModel, resolveEffort, type Effort } from "./store.ts";
@@ -51,7 +51,7 @@ export function gatherConsults(
51
51
  missing.push(name);
52
52
  continue;
53
53
  }
54
- const parsed = matter(fs.readFileSync(file, "utf8"));
54
+ const parsed = configMatter(fs.readFileSync(file, "utf8"));
55
55
  consults.push({
56
56
  name,
57
57
  systemPrompt:
package/src/agents-md.ts CHANGED
@@ -9,7 +9,12 @@
9
9
 
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
- import matter from "./matter.ts";
12
+ import matter, { configMatter } from "./matter.ts";
13
+ import { keySpan, dropEmptyBlock } from "./frontmatter-text.ts";
14
+ import { guardEdit } from "./frontmatter-guard.ts";
15
+ import { JOBS, FOLDABLE } from "./frontmatter-fields.ts";
16
+
17
+ export { keySpan };
13
18
 
14
19
  const FRONT = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n|$)/;
15
20
 
@@ -19,7 +24,7 @@ export function readAgentsMdKeys(dir: string): Record<string, unknown> {
19
24
  const file = path.join(dir, name);
20
25
  if (!fs.existsSync(file)) continue;
21
26
  try {
22
- return (matter(fs.readFileSync(file, "utf8")).data ?? {}) as Record<string, unknown>;
27
+ return (configMatter(fs.readFileSync(file, "utf8")).data ?? {}) as Record<string, unknown>;
23
28
  } catch {
24
29
  return {};
25
30
  }
@@ -37,8 +42,31 @@ function dumpKey(key: string, value: unknown): string[] {
37
42
 
38
43
  /** The text of `content` with each key in `patch` replaced (or removed for
39
44
  * null), everything else byte for byte as it was. A file with no
40
- * frontmatter gets one. */
45
+ * frontmatter gets one. Read back before it is returned (frontmatter-guard
46
+ * .ts): an edit that would not parse, would change another key or would
47
+ * double one into its block throws UnsafeEditError (409). */
41
48
  export function patchFrontmatter(content: string, patch: Record<string, unknown | null>): string {
49
+ // The guard reads the result folded (matter.ts configMatter), so a patch
50
+ // that writes a whole job block is checked as the keys it holds: each
51
+ // field the block may hold may change, and each one it sets reads back.
52
+ const changed = new Set<string>();
53
+ const expect: Record<string, unknown> = {};
54
+ for (const [key, value] of Object.entries(patch)) {
55
+ changed.add(key);
56
+ const job = JOBS.find((j) => j.name === key)?.name;
57
+ if (!job) {
58
+ expect[key] = value;
59
+ continue;
60
+ }
61
+ for (const k of FOLDABLE[job]) changed.add(k);
62
+ if (value && typeof value === "object" && !Array.isArray(value)) {
63
+ for (const [k, v] of Object.entries(value)) if (FOLDABLE[job].has(k)) expect[k] = v;
64
+ }
65
+ }
66
+ return guardEdit(content, patchFrontmatterText(content, patch), [...changed], expect);
67
+ }
68
+
69
+ function patchFrontmatterText(content: string, patch: Record<string, unknown | null>): string {
42
70
  const m = content.match(FRONT);
43
71
  let open = "---\n";
44
72
  let body = "";
@@ -52,18 +80,15 @@ export function patchFrontmatter(content: string, patch: Record<string, unknown
52
80
  }
53
81
  let lines = body === "" ? [] : body.split("\n");
54
82
  for (const [key, value] of Object.entries(patch)) {
55
- const at = lines.findIndex((l) => new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`).test(l));
56
- let end = at;
57
- if (at !== -1) {
58
- end = at + 1;
59
- // The block continues while lines are indented or blank-inside-block.
60
- while (end < lines.length && (/^[ \t]/.test(lines[end]) || lines[end].trim() === "")) end++;
61
- // A blank line at the block's foot belongs to what follows.
62
- while (end > at + 1 && lines[end - 1].trim() === "") end--;
83
+ // Where it is written now — at the top or inside its job block — is
84
+ // where it is edited; a key written nowhere is added at the top.
85
+ const where = keySpan(lines, key);
86
+ const replacement = value === null || value === undefined ? [] : dumpKey(key, value).map((l) => `${where?.indent ?? ""}${l}`);
87
+ if (!where) lines = [...lines, ...replacement];
88
+ else {
89
+ lines = [...lines.slice(0, where.at), ...replacement, ...lines.slice(where.end)];
90
+ if (!replacement.length && where.block !== null) lines = dropEmptyBlock(lines, where.block);
63
91
  }
64
- const replacement = value === null || value === undefined ? [] : dumpKey(key, value);
65
- if (at === -1) lines = [...lines, ...replacement];
66
- else lines = [...lines.slice(0, at), ...replacement, ...lines.slice(end)];
67
92
  }
68
93
  const front = lines.length ? `${open}${lines.join("\n")}${close}` : "";
69
94
  if (!front) return rest.replace(/^(\r?\n)+/, "");
@@ -10,7 +10,7 @@
10
10
  // renders them.
11
11
 
12
12
  import { PROVIDERS } from "./providers.ts";
13
- import { FRONTMATTER, type SchemaKind } from "./frontmatter-fields.ts";
13
+ import { FRONTMATTER, JOBS, fieldsOfJob, type SchemaKind } from "./frontmatter-fields.ts";
14
14
 
15
15
  export interface Vocabulary {
16
16
  agents: string[];
@@ -660,8 +660,31 @@ export function completionsAt(
660
660
  }
661
661
  }
662
662
 
663
- // 6. An indented option under a flow step.
664
- if (kind === "flow" && /^\s+[a-z]*$/.test(line) && line.trim().length >= 0) {
663
+ // 5b. An indented line inside one of the four job blocks (governance,
664
+ // guardrail, observability, evaluation): that job's fields for this
665
+ // kind of file — the block takes nothing else.
666
+ const fmClose = text.indexOf("\n---", text.indexOf("---") + 3);
667
+ const insideFrontmatter = text.startsWith("---") && (fmClose === -1 || cursor <= fmClose);
668
+ const nested = line.match(/^(\s+)([a-zA-Z_]*)$/);
669
+ if (insideFrontmatter && nested) {
670
+ const header = before.slice(0, lineStart).split("\n").reverse().find((l) => /^\S/.test(l));
671
+ const job = JOBS.find((j) => new RegExp(`^${j.name}\\s*:\\s*$`).test(header ?? ""));
672
+ if (job) {
673
+ const schemaKind = /(^|\/)AGENTS\.md$/.test(path) || path === "project.md" ? "agents-md" : SCHEMA_OF[kind];
674
+ const keys = new Set(schemaKind ? fieldsOfJob(schemaKind, job.name) : []);
675
+ const partial = nested[2] ?? "";
676
+ return {
677
+ from: cursor - partial.length,
678
+ query: partial,
679
+ items: filter(fieldsFor(path, kind).filter((c) => keys.has(c.label.split(".")[0]!)), partial),
680
+ title: `${job.title} — ${job.what}`,
681
+ };
682
+ }
683
+ }
684
+
685
+ // 6. An indented option under a flow step — the body's steps, never a
686
+ // line inside the frontmatter.
687
+ if (kind === "flow" && !insideFrontmatter && /^\s+[a-z]*$/.test(line) && line.trim().length >= 0) {
665
688
  const partial = line.trim();
666
689
  return {
667
690
  from: cursor - partial.length,