@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/runner.ts CHANGED
@@ -10,7 +10,7 @@ import { hookNames } from "./hooks.ts";
10
10
  import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
- import matter from "./matter.ts";
13
+ import matter, { parseLenient, configMatter } from "./matter.ts";
14
14
  import { spawn } from "node:child_process";
15
15
  import {
16
16
  knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
@@ -21,6 +21,7 @@ import { eventUrl } from "./webhook.ts";
21
21
  import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
22
22
  import { hostSafeEnv, platformApiUrl } from "./host-env.ts";
23
23
  import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.ts";
24
+ import { parseJudge } from "./judge-verdict.ts";
24
25
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
25
26
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
26
27
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
@@ -70,6 +71,7 @@ import {
70
71
  checkFormatVersion,
71
72
  readRun,
72
73
  writeRun,
74
+ isRescueRecord,
73
75
  runSummary,
74
76
  verdictOf,
75
77
  type FlowStep,
@@ -335,28 +337,9 @@ const toTags = (v: unknown): string[] =>
335
337
  // rather than dropping the skill, so a cross-client skill still loads here.
336
338
  function safeMatter(raw: string): { data: Record<string, unknown> } {
337
339
  // gray-matter caches a *failed* parse as a hollow success; ./matter.ts
338
- // parses every call, so the throw below is deterministic and the fallback
339
- // fires every time (it used to clear gray-matter's cache here for that).
340
- try {
341
- return matter(raw) as { data: Record<string, unknown> };
342
- } catch {
343
- const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
344
- if (fm) {
345
- const fixed = fm[1].replace(
346
- /^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm,
347
- (_m, k: string, v: string) => `${k}: ${JSON.stringify(v)}`,
348
- );
349
- try {
350
- // A function replacer, not a string: a fixed block containing `$&` or
351
- // `$$` (a value with those in it) would otherwise be treated as a
352
- // replacement pattern and splice the whole frontmatter into itself.
353
- return matter(raw.replace(fm[1], () => fixed)) as { data: Record<string, unknown> };
354
- } catch {
355
- /* fall through */
356
- }
357
- }
358
- return { data: {} };
359
- }
340
+ // parses every call, so the strict read throws every time and the lenient
341
+ // one (shared with check, which warns about it) fires every time.
342
+ return { data: parseLenient(raw).data };
360
343
  }
361
344
 
362
345
  // Both layouts: <root>/<name>/SKILL.md (Agent Skills standard) and the flat
@@ -452,7 +435,7 @@ export function readAgentsMd(dir: string): { data: Record<string, unknown>; body
452
435
  const file = path.join(dir, name);
453
436
  if (!fs.existsSync(file)) continue;
454
437
  try {
455
- const parsed = matter(fs.readFileSync(file, "utf8"));
438
+ const parsed = configMatter(fs.readFileSync(file, "utf8"));
456
439
  return { data: parsed.data as Record<string, unknown>, body: withoutAgentRules(parsed.content).trim() };
457
440
  } catch {
458
441
  return null;
@@ -855,7 +838,7 @@ export function agentContext(
855
838
  // agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
856
839
  // workspace a secret should resolve against is two levels up.
857
840
  const workspace = path.basename(path.resolve(agentDir, "..", ".."));
858
- const { data: front, content: body } = matter(
841
+ const { data: front, content: body } = configMatter(
859
842
  fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"),
860
843
  );
861
844
  // The prompt's sections, by name and by how often they change. Added in
@@ -2089,6 +2072,15 @@ async function runStep(
2089
2072
  // AGENTS.md — every model call always; each tool call's input and output
2090
2073
  // unless it says `metadata`.
2091
2074
  const capture: "metadata" | "full" = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
2075
+ // `judge:` (AGENTS.md): the model and effort every `verify: judge:` here
2076
+ // grades with — field by field, the workspace's else the account's.
2077
+ const { judgeModel, judgeEffort } = (() => {
2078
+ const ws = parseJudge(readAgentsMd(workspaceRootOf(agentDir))?.data?.judge);
2079
+ const account = parseJudge(readAgentsMd(accountDir(tenant))?.data?.judge);
2080
+ const model = ws.model ?? account.model;
2081
+ const effort = resolveEffort(ws.effort ?? account.effort ?? "");
2082
+ return { judgeModel: model ? resolveModel(model) : undefined, judgeEffort: effort ?? undefined };
2083
+ })();
2092
2084
  // A step that pours out content stops being recorded past this — its
2093
2085
  // calls keep their names, sizes and times (FOL-54 step 4).
2094
2086
  let capturedChars = 0;
@@ -2857,6 +2849,8 @@ async function runStep(
2857
2849
  budgetNote: stepBudgetNote,
2858
2850
  price: tokenPrice,
2859
2851
  verify: step.verify,
2852
+ ...(judgeModel ? { judgeModel } : {}),
2853
+ ...(judgeEffort ? { judgeEffort } : {}),
2860
2854
  output: step.output,
2861
2855
  schema: resolvedSchema ?? undefined,
2862
2856
  maxTurns: step.maxTurns,
@@ -2913,6 +2907,9 @@ async function runStep(
2913
2907
  emit: pushWatching,
2914
2908
  // Stamps the sandbox, so stopping this run can destroy it.
2915
2909
  runId,
2910
+ // A step still waiting for room has no sandbox to destroy; the
2911
+ // executor asks this before it creates one (pod-room.ts).
2912
+ ...(stopRequested ? { stopRequested } : {}),
2916
2913
  size,
2917
2914
  // The pod's network, for the executor's own wall (the k8s tier's
2918
2915
  // network policy): a filtered pod reaches the proxy and DNS only.
@@ -3252,6 +3249,8 @@ async function runStep(
3252
3249
  budgetNote: stepBudgetNote,
3253
3250
  price: tokenPrice,
3254
3251
  verify: step.verify,
3252
+ ...(judgeModel ? { judgeModel } : {}),
3253
+ ...(judgeEffort ? { judgeEffort } : {}),
3255
3254
  output: step.output,
3256
3255
  schema: resolvedSchema ?? undefined,
3257
3256
  maxTurns: step.maxTurns,
@@ -3455,7 +3454,7 @@ export function copyTreeBytes(from: string, to: string) {
3455
3454
  function openApiSources(agentDir: string, tenant: string): string[] {
3456
3455
  const out = new Set<string>();
3457
3456
  try {
3458
- const { data } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3457
+ const { data } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3459
3458
  const front = data as { apis?: unknown; tools?: unknown };
3460
3459
  for (const api of parseApis(front.apis)) if (api.openapi?.startsWith("https://")) out.add(api.openapi);
3461
3460
  const workspace = path.basename(path.resolve(agentDir, "..", ".."));
@@ -4080,10 +4079,30 @@ function driveRunInner(
4080
4079
  *
4081
4080
  * The flag is one-way: nothing here clears it, so the read-modify-write can
4082
4081
  * only ever turn a stop on, never off.
4082
+ *
4083
+ * And once it is on, this copy may not undo what stopRun wrote: the run
4084
+ * stays failed, and steps that had not started stay skipped. Before
4085
+ * 21 Oct 2026 only the flag came across, so the next save put the record
4086
+ * back to `running` with its steps pending; a second stop then answered
4087
+ * "failed" instead of "already failed", and the group kept starting pods
4088
+ * (medium-desk run-mvho48wp-99e1). A step running in THIS process is left
4089
+ * as it is: its sandbox is gone, it ends on its own, and records how.
4083
4090
  */
4084
4091
  const save = () => {
4085
- if (!run.stopRequested && readRun(tenant, workspace, run.id)?.stopRequested) {
4086
- run.stopRequested = true;
4092
+ const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
4093
+ if (disk?.stopRequested) run.stopRequested = true;
4094
+ if (run.stopRequested) {
4095
+ if (run.status !== "completed" && run.status !== "failed") {
4096
+ run.status = "failed";
4097
+ run.finishedAt ??= disk?.finishedAt ?? new Date().toISOString();
4098
+ }
4099
+ run.parkedAt = null;
4100
+ for (const s of run.steps) {
4101
+ if (s.status === "pending" || s.status === "awaiting-approval") {
4102
+ s.status = "skipped";
4103
+ s.skipReason ??= "run stopped";
4104
+ }
4105
+ }
4087
4106
  }
4088
4107
  writeRun(tenant, workspace, run);
4089
4108
  };
@@ -4301,13 +4320,20 @@ function driveRunInner(
4301
4320
  for (let i = gi - 1; i >= 0; i--) if (groupData[i] !== undefined) return groupData[i];
4302
4321
  return undefined;
4303
4322
  };
4304
- const dataOf = (group: StepRecord[]): unknown => {
4323
+ // A rescue's result already stands on the step it took over, so the
4324
+ // three handoffs below read the flow's steps only: counting the rescue
4325
+ // too handed the next group the same output twice, or a stale one
4326
+ // from an evaluator loop's earlier cycle.
4327
+ const flowStepsOf = (group: StepRecord[]) => group.filter((s) => !isRescueRecord(s));
4328
+ const dataOf = (all: StepRecord[]): unknown => {
4329
+ const group = flowStepsOf(all);
4305
4330
  const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
4306
4331
  if (carrying.length === 0) return undefined;
4307
4332
  if (carrying.length === 1 && !carrying[0].item) return carrying[0].data;
4308
4333
  return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
4309
4334
  };
4310
- const resultsOf = (group: StepRecord[]): string | null => {
4335
+ const resultsOf = (all: StepRecord[]): string | null => {
4336
+ const group = flowStepsOf(all);
4311
4337
  const results = group
4312
4338
  .filter((s) => s.status === "completed" && s.result)
4313
4339
  .map((s) =>
@@ -4321,8 +4347,8 @@ function driveRunInner(
4321
4347
  };
4322
4348
  // What each step concluded — its final turn — for the handoffs that
4323
4349
  // should read a conclusion rather than a transcript (`each: lines`).
4324
- const conclusionsOf = (group: StepRecord[]): string | null => {
4325
- const out = group
4350
+ const conclusionsOf = (all: StepRecord[]): string | null => {
4351
+ const out = flowStepsOf(all)
4326
4352
  .filter((s) => s.status === "completed" && (s.conclusion || s.result))
4327
4353
  .map((s) => s.conclusion || s.result!);
4328
4354
  return out.length ? out.join("\n") : null;
@@ -4709,7 +4735,7 @@ function driveRunInner(
4709
4735
  save();
4710
4736
  if (freshGroup.every((s) => s.status !== "pending")) {
4711
4737
  // everything in this group was rejected
4712
- if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
4738
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
4713
4739
  run.status = "failed";
4714
4740
  break;
4715
4741
  }
@@ -4723,7 +4749,11 @@ function driveRunInner(
4723
4749
  // itself mid-turn at its share, so a fan-out of twenty cannot end
4724
4750
  // twenty steps over — the shares sum to what was left. Between
4725
4751
  // groups the check above still refuses to start the next one.
4726
- const launching = freshGroup.filter((s) => s.status === "pending");
4752
+ // A rescue record is the on-fail of a step in this group, not a step
4753
+ // of the flow: one left pending by a drive that died is picked up by
4754
+ // rescueStep below, never launched alongside the group (21 Oct 2026:
4755
+ // a resumed run launched 77 leftover fixers at once).
4756
+ const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
4727
4757
  // tools: [peers] — what each step of the group is told about the
4728
4758
  // others, and the bus they share when they run in this process.
4729
4759
  const peerBus = localPeerBus();
@@ -4788,7 +4818,7 @@ function driveRunInner(
4788
4818
  // lead's reply or for a slot — must not start it: the
4789
4819
  // between-groups check runs only once this group is over,
4790
4820
  // and save() is what carries the flag in from the record.
4791
- if (lead || pool) {
4821
+ {
4792
4822
  save();
4793
4823
  if (run.stopRequested) {
4794
4824
  step.status = "skipped";
@@ -4968,6 +4998,13 @@ function driveRunInner(
4968
4998
  // runs out of cycles below, which used to fail the run without ever
4969
4999
  // reaching the step's on-fail (medium-desk's claim-trimmer was
4970
5000
  // written for exactly that case and could never run).
5001
+ // A rescue hears a stop the way a group step does: its model loop and
5002
+ // its executor's wait for room both poll this (no sandbox exists yet
5003
+ // for a stop to destroy while it waits).
5004
+ const stopPoll = () => {
5005
+ save();
5006
+ return run.stopRequested === true;
5007
+ };
4971
5008
  const rescueStep = async (step: StepRecord): Promise<void> => {
4972
5009
  if (!step.onFail) return;
4973
5010
  const rescuerDir = path.join(pDir, "agents", step.onFail);
@@ -4979,6 +5016,38 @@ function driveRunInner(
4979
5016
  });
4980
5017
  return;
4981
5018
  }
5019
+ // One rescue per failure, across drives. A worker that died
5020
+ // mid-rescue left an unfinished record behind it; the drive that
5021
+ // resumes picks that one up instead of splicing another — a crash
5022
+ // loop used to add one per restart (blog-desk run-mvh1zra7-ae4k,
5023
+ // ~80). The leftover carries the same instruction, built from the
5024
+ // same failure, and a running one may still hold a sandbox to
5025
+ // re-attach to, so it is THE rescue; any further unfinished copies
5026
+ // are superseded. A FINISHED rescue is history, never reused: it
5027
+ // answered an earlier failure — an evaluator loop's previous cycle
5028
+ // — not this one.
5029
+ const leftovers: StepRecord[] = [];
5030
+ for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
5031
+ const r = run.steps[j];
5032
+ if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
5033
+ leftovers.push(r);
5034
+ }
5035
+ }
5036
+ const resumed = leftovers[0];
5037
+ for (const r of leftovers.slice(1)) {
5038
+ r.status = "skipped";
5039
+ r.skipReason = "superseded on resume — one rescue per failure";
5040
+ }
5041
+ if (resumed) {
5042
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `resuming the rescue by [[${step.onFail}]] that an earlier drive started` });
5043
+ resumed.rescueOf ??= step.agent;
5044
+ resumed.attempts = Math.max(1, resumed.attempts ?? 0);
5045
+ resumed.status = "running";
5046
+ save();
5047
+ await runStep(rescuerDir, tenant, resumed, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(resumed));
5048
+ settleRescue(step, resumed);
5049
+ return;
5050
+ }
4982
5051
  step.events.push({
4983
5052
  t: new Date().toISOString(),
4984
5053
  type: "info",
@@ -4995,6 +5064,7 @@ function driveRunInner(
4995
5064
  const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
4996
5065
  const rescue: StepRecord = {
4997
5066
  agent: step.onFail,
5067
+ rescueOf: step.agent,
4998
5068
  instruction:
4999
5069
  `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
5000
5070
  `Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
@@ -5025,7 +5095,11 @@ function driveRunInner(
5025
5095
  rescue.attempts = 1;
5026
5096
  rescue.status = "running";
5027
5097
  save();
5028
- await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, undefined, flowTimezone, run.steps.indexOf(rescue));
5098
+ await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(rescue));
5099
+ settleRescue(step, rescue);
5100
+ };
5101
+ // What a rescue's end means for the step it took over.
5102
+ const settleRescue = (step: StepRecord, rescue: StepRecord): void => {
5029
5103
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
5030
5104
  const rescueOutcome: string = rescue.status;
5031
5105
  if (rescueOutcome === "completed") {
@@ -5057,7 +5131,10 @@ function driveRunInner(
5057
5131
  // here: the person chose to start after it. Counting it stopped
5058
5132
  // fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
5059
5133
  // a second, before the one step it was asked to run.
5060
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
5134
+ // A rescue record's own failure is already on the step it took over
5135
+ // ("also failed"); counted here it would fail a group whose step a
5136
+ // later rescue completed.
5137
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
5061
5138
  if (requiredFailed) {
5062
5139
  run.status = "failed";
5063
5140
  break;
@@ -5133,6 +5210,10 @@ function driveRunInner(
5133
5210
  });
5134
5211
  for (const s of [...ordered[gi - 1], ...freshGroup]) {
5135
5212
  if (s.status === "skipped" && s.each) continue; // an expanded template stays expanded
5213
+ // A rescue answered its own failure and stays as it ended: reset
5214
+ // to pending it read as an interrupted rescue, and the next
5215
+ // cycle's failure was handed the old one's instructions.
5216
+ if (isRescueRecord(s)) continue;
5136
5217
  s.status = "pending";
5137
5218
  s.result = null;
5138
5219
  }
package/src/scan.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  // reported, because "why is this allowed?" is the question a reviewer asks.
28
28
 
29
29
  import crypto from "node:crypto";
30
- import matter from "./matter.ts";
30
+ import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, configMatter } from "./matter.ts";
31
31
  import { refNames } from "./refs.ts";
32
32
  import { resolveNetwork } from "./network.ts";
33
33
  import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
@@ -223,6 +223,35 @@ export const SCAN_RULES: readonly ScanRule[] = [
223
223
  "`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
224
224
  "so the agent or flow quietly does something else. The message lists what the field takes.",
225
225
  },
226
+ {
227
+ id: "config/conflict",
228
+ category: "config",
229
+ level: "error",
230
+ title: "A field set twice: at the top and inside its job block",
231
+ help:
232
+ "`limits:` at the top and `limits:` inside `guardrail:` cannot both be meant. The top one would be read and the other " +
233
+ "quietly ignored, so the file is refused until one is removed.",
234
+ },
235
+ {
236
+ id: "config/misplaced",
237
+ category: "config",
238
+ level: "error",
239
+ title: "A field inside the wrong job block",
240
+ help:
241
+ "Each of the four blocks (governance, guardrail, observability, evaluation) holds its own fields. A setting written in " +
242
+ "another block is not read — a guardrail under governance: guards nothing — so the file is refused until it is moved " +
243
+ "to the block the message names, or to the top. A word foldrun does not read at all is only a note.",
244
+ },
245
+ {
246
+ id: "config/invalid-frontmatter",
247
+ category: "config",
248
+ level: "warn",
249
+ title: "Frontmatter that YAML cannot read",
250
+ help:
251
+ "A value with a colon followed by a space (`description: the format (Markdown, ::: directives)`) ends the YAML. " +
252
+ "foldrun reads a skill like that by quoting the value, but the dashboard editor and the source API refuse to save it, " +
253
+ "and other tools that read the file may not. Quote the value: `description: \"…\"`.",
254
+ },
226
255
  {
227
256
  id: "config/missing-field",
228
257
  category: "config",
@@ -601,17 +630,31 @@ export function nearestField(key: string, known: readonly string[]): string | nu
601
630
  }
602
631
 
603
632
  function keyLine(lines: string[], key: string): number | undefined {
604
- const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`);
605
- const i = lines.findIndex((l) => re.test(l));
606
- return i >= 0 ? i + 1 : undefined;
633
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
634
+ const top = new RegExp(`^${k}\\s*:`);
635
+ const i = lines.findIndex((l) => top.test(l));
636
+ if (i >= 0) return i + 1;
637
+ // A key inside one of the four job blocks: indented, under its header.
638
+ const inner = new RegExp(`^\\s+${k}\\s*:`);
639
+ let inBlock = false;
640
+ for (let n = 0; n < lines.length; n++) {
641
+ const l = lines[n]!;
642
+ if (/^(governance|guardrail|observability|evaluation)\s*:\s*(#.*)?$/.test(l)) inBlock = true;
643
+ else if (/^\S/.test(l)) inBlock = false;
644
+ else if (inBlock && inner.test(l)) return n + 1;
645
+ }
646
+ return undefined;
607
647
  }
608
648
 
609
649
  /** Read-only: gray-matter caches by content, so two files with the same
610
650
  * text share one object, and writing to it would leak between them. */
611
- function frontOf(content: string): Record<string, unknown> | null {
651
+ function frontOf(content: string, filePath?: string): Record<string, unknown> | null {
612
652
  if (!/^?---\r?\n/.test(content)) return null;
613
653
  try {
614
- return matter(content).data as Record<string, unknown>;
654
+ // An agent, a flow or AGENTS.md reads its job blocks as flat keys, as
655
+ // the runtime does; every other document is read as written.
656
+ const kind = filePath ? schemaKindOf(filePath) : null;
657
+ return (kind === "agent" || kind === "flow" || kind === "agents-md" ? configMatter(content) : matter(content)).data as Record<string, unknown>;
615
658
  } catch {
616
659
  return null; // a frontmatter YAML cannot read is check's finding, not ours
617
660
  }
@@ -773,6 +816,16 @@ const VENDORED = /(?:^|\/)(?:node_modules|\.venv|venv|site-packages|__pycache__|
773
816
  * since a key in a log here is a key in the workspace (seen live on
774
817
  * 17 Oct 2026: zero-width characters in a CLI MCP log, reported as hidden
775
818
  * text). */
819
+ /** The definition files a deploy refuses outright when their frontmatter does
820
+ * not parse (deploy.ts deployIssues): the workspace's own, by these paths.
821
+ * Everything else the platform reads frontmatter from — skills, and the
822
+ * account library's agents, tools and hooks — is the scanner's to report. */
823
+ 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)$/;
824
+
825
+ /** A skill file in either layout the runtime discovers: <name>/SKILL.md, or
826
+ * the flat <name>.md, under skills/ or .agents/skills/, in the library too. */
827
+ const SKILL_FILE = /(?:^|\/)skills\/(?:[^/]+\/SKILL\.md|[^/]+\.md)$/;
828
+
776
829
  const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.foldrun)\//;
777
830
 
778
831
  /** Text files only — a PNG read as UTF-8 is noise to every rule here. */
@@ -833,9 +886,34 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
833
886
  scanSupplyChain(f, lines, push);
834
887
 
835
888
  const schema = schemaKindOf(f.path);
889
+ // Frontmatter YAML cannot read. Until 2026-10-21 nothing reported it for
890
+ // a skill or a library file: frontOf below reads it as "no frontmatter"
891
+ // and moves on, and the deploy's own refusal covers only the workspace's
892
+ // definition files. A vaza.ai skill with `description: … (Markdown,
893
+ // frontmatter, ::: directives) …` deployed clean, ran (the runtime quotes
894
+ // the value and retries), and was refused by the source API on its next
895
+ // save.
896
+ if ((schema || SKILL_FILE.test(f.path)) && !STRICT_FRONTMATTER_PATH.test(f.path) && /^\uFEFF?---\r?\n/.test(f.content)) {
897
+ let err: unknown = null;
898
+ try {
899
+ matter(f.content);
900
+ } catch (e) {
901
+ err = e;
902
+ }
903
+ if (err) {
904
+ const isSkill = schema === "skill" || SKILL_FILE.test(f.path);
905
+ const read = parseLenient(f.content);
906
+ const what = isSkill && !read.failed
907
+ ? "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"
908
+ : isSkill
909
+ ? "the runtime reads it as empty, so the skill has no name or description and is never loaded"
910
+ : "the runtime cannot read this file";
911
+ 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")}.`);
912
+ }
913
+ }
836
914
  // A skill with no frontmatter at all is the clearest one that never
837
915
  // loads: read it as empty, so its required fields are reported.
838
- const data = schema || docKind(f.path) ? (frontOf(f.content) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
916
+ const data = schema || docKind(f.path) ? (frontOf(f.content, f.path) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
839
917
  if (!data) continue;
840
918
  if (schema) {
841
919
  for (const p of frontmatterProblems(schema, data, nearestField)) {
@@ -859,7 +937,7 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
859
937
  }
860
938
  // project.md is the older name the runtime still reads (Codex review, FOL-58).
861
939
  const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
862
- scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
940
+ scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
863
941
  for (const m of more.values()) {
864
942
  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 });
865
943
  }
package/src/starter.ts CHANGED
@@ -62,6 +62,22 @@ foldrun_version: "0.1"
62
62
  #
63
63
  # The token is sent as a bearer credential. A gateway that wants it as a key
64
64
  # header asks for that by name: headers: { x-api-key: \${THE_SECRET} }.
65
+ #
66
+ # The four jobs of running agents, each a block — or write the same keys at
67
+ # the top; one place or the other, never both:
68
+ #
69
+ # governance: # who answers for it, what it may spend and ship
70
+ # owner: you@example.com
71
+ # budget: 60/month
72
+ # deploy_block: secrets
73
+ # guardrail: # what agents may reach and call
74
+ # limits: {calls: 300, repeat: 5}
75
+ # network: trusted
76
+ # observability: # what is recorded, and who is told
77
+ # notify: {email: you@example.com, events: [failed]}
78
+ # alerts: {failure_rate: 30%}
79
+ # evaluation: # how work is judged
80
+ # judge: {model: fast}
65
81
  ---
66
82
 
67
83
  # ${account}
@@ -492,6 +508,13 @@ runtime: # only if scripts need packages
492
508
  You research a topic and report what you found...
493
509
  \`\`\`
494
510
 
511
+ The fields that do one of the four jobs of running agents may be grouped
512
+ under it — \`governance:\` (owner, budget), \`guardrail:\` (limits, network,
513
+ hooks, disallowedTools, permissionMode, secrets), and in AGENTS.md also
514
+ \`observability:\` (alerts, telemetry, notify, capture) and \`evaluation:\`
515
+ (judge). A block reads exactly as the same keys at the top; a key written
516
+ both ways is refused (\`config/conflict\`). \`foldrun docs four-jobs\`.
517
+
495
518
  Only \`name\` and the prose are required. Every other field widens or narrows
496
519
  what the agent can reach, and the default is narrow.
497
520
 
package/src/step-exec.ts CHANGED
@@ -8,6 +8,7 @@
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
10
 
11
+ import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION } from "./judge-verdict.ts";
11
12
  import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks, type HookDef, type HookRecord } from "./hooks.ts";
12
13
  import fs from "node:fs";
13
14
  import os from "node:os";
@@ -133,6 +134,11 @@ export interface ExecOptions {
133
134
  * `matches: re`, `file: path`, `judge: sentence`) — see checkVerify. */
134
135
  verify?: string;
135
136
  verifyEnv?: Record<string, string>;
137
+ /** The model `verify: judge:` grades with — the workspace's `judge:` from
138
+ * AGENTS.md, resolved; absent, the fast tier. */
139
+ judgeModel?: string;
140
+ /** And how long it thinks — `judge: {effort}`; absent, the SDK's default. */
141
+ judgeEffort?: Effort;
136
142
  /** `output: json` — the reply must carry one JSON value; extracting it is
137
143
  * part of finishing the step, and failing to is failing the step. */
138
144
  output?: "json";
@@ -1066,6 +1072,8 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
1066
1072
  data,
1067
1073
  modelEnv: opts.env,
1068
1074
  signal: halt.signal,
1075
+ ...(opts.judgeModel ? { judgeModel: opts.judgeModel } : {}),
1076
+ ...(opts.judgeEffort ? { judgeEffort: opts.judgeEffort } : {}),
1069
1077
  });
1070
1078
  } finally {
1071
1079
  for (const t of clocks) clearTimeout(t);
@@ -1170,6 +1178,9 @@ export async function checkVerify(
1170
1178
  /** The step's own model environment, for `judge:` — it grades on the
1171
1179
  * fast tier through the same credential the step rode. */
1172
1180
  modelEnv?: Record<string, string | undefined>;
1181
+ /** The judge's model and effort (the workspace's `judge:`). */
1182
+ judgeModel?: string;
1183
+ judgeEffort?: Effort;
1173
1184
  /** Ends a shell check or a judge mid-way: the step's clock ran out, or a
1174
1185
  * person asked the run to stop. */
1175
1186
  signal?: AbortSignal;
@@ -1233,9 +1244,9 @@ export async function checkVerify(
1233
1244
  // GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
1234
1245
  // text opened with the narration before it.
1235
1246
  const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
1236
- const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
1237
- const ok = /^\s*PASS\b/i.test(verdict);
1238
- return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
1247
+ const said = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal, ctx.judgeModel, ctx.judgeEffort);
1248
+ const { passed: ok, detail } = judgeVerdict(said);
1249
+ return { ok, headline: ok ? "PASS" : "FAIL", detail };
1239
1250
  }
1240
1251
  }
1241
1252
  return { ok: false, headline: `unknown check "${kind}"`, detail: "" };
@@ -1243,8 +1254,8 @@ export async function checkVerify(
1243
1254
 
1244
1255
  /**
1245
1256
  * A toolless, fast-tier grading call: does the reply satisfy the sentence?
1246
- * Same shape as the eval judge, and answered with one word first so the
1247
- * verdict is a prefix test rather than a reading.
1257
+ * Same shape as the eval judge: reasoning first, then the verdict alone on
1258
+ * the last line (judge-verdict.ts says why).
1248
1259
  */
1249
1260
  export function judgePrompt(rubric: string, reply: string, work: string): string {
1250
1261
  const shown = reply.slice(0, 20_000);
@@ -1253,8 +1264,7 @@ export function judgePrompt(rubric: string, reply: string, work: string): string
1253
1264
  `never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
1254
1265
  : "";
1255
1266
  return (
1256
- `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
1257
- `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
1267
+ `You are grading a reply against one requirement. ${JUDGE_VERDICT_INSTRUCTION}\n\nRequirement: ${rubric}\n\n` +
1258
1268
  context +
1259
1269
  `<reply>\n${shown}\n</reply>`
1260
1270
  );
@@ -1266,6 +1276,8 @@ async function judgeReply(
1266
1276
  work: string,
1267
1277
  env: Record<string, string | undefined>,
1268
1278
  signal?: AbortSignal,
1279
+ model = "haiku",
1280
+ effort?: Effort,
1269
1281
  ): Promise<string> {
1270
1282
  const texts: string[] = [];
1271
1283
  const abortController = new AbortController();
@@ -1275,7 +1287,8 @@ async function judgeReply(
1275
1287
  const q = query({
1276
1288
  prompt: judgePrompt(rubric, reply, work),
1277
1289
  options: {
1278
- model: "haiku",
1290
+ model,
1291
+ ...(effort ? { effort } : {}),
1279
1292
  systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
1280
1293
  tools: [],
1281
1294
  settingSources: [],