@foldrun/core 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +2 -2
  4. package/dist/src/agent-tools.js +2 -2
  5. package/dist/src/agents-md.d.ts +5 -1
  6. package/dist/src/agents-md.js +42 -18
  7. package/dist/src/alerts.d.ts +60 -0
  8. package/dist/src/alerts.js +161 -0
  9. package/dist/src/browser-pod.js +4 -0
  10. package/dist/src/capture.d.ts +23 -0
  11. package/dist/src/capture.js +80 -0
  12. package/dist/src/compare.d.ts +57 -0
  13. package/dist/src/compare.js +116 -0
  14. package/dist/src/completions.js +26 -3
  15. package/dist/src/deploy.d.ts +36 -1
  16. package/dist/src/deploy.js +237 -13
  17. package/dist/src/egress.d.ts +16 -1
  18. package/dist/src/evals.d.ts +27 -2
  19. package/dist/src/evals.js +62 -19
  20. package/dist/src/flow-lint.d.ts +14 -0
  21. package/dist/src/flow-lint.js +35 -0
  22. package/dist/src/flow-patterns.js +29 -14
  23. package/dist/src/frontmatter-fields.d.ts +27 -0
  24. package/dist/src/frontmatter-fields.js +60 -3
  25. package/dist/src/frontmatter-guard.d.ts +19 -0
  26. package/dist/src/frontmatter-guard.js +67 -0
  27. package/dist/src/frontmatter-schema.d.ts +2 -2
  28. package/dist/src/frontmatter-schema.js +33 -2
  29. package/dist/src/frontmatter-text.d.ts +31 -0
  30. package/dist/src/frontmatter-text.js +102 -0
  31. package/dist/src/history.d.ts +4 -0
  32. package/dist/src/history.js +15 -0
  33. package/dist/src/hooks.js +1 -1
  34. package/dist/src/insights.d.ts +30 -0
  35. package/dist/src/insights.js +152 -0
  36. package/dist/src/issues.d.ts +140 -0
  37. package/dist/src/issues.js +302 -0
  38. package/dist/src/judge-verdict.d.ts +19 -0
  39. package/dist/src/judge-verdict.js +54 -0
  40. package/dist/src/library.js +1 -1
  41. package/dist/src/limits.d.ts +8 -0
  42. package/dist/src/limits.js +36 -5
  43. package/dist/src/linkable.js +1 -1
  44. package/dist/src/matter.d.ts +42 -0
  45. package/dist/src/matter.js +132 -0
  46. package/dist/src/model-credential.d.ts +14 -0
  47. package/dist/src/model-credential.js +40 -0
  48. package/dist/src/network.d.ts +12 -1
  49. package/dist/src/network.js +35 -8
  50. package/dist/src/notify.d.ts +16 -0
  51. package/dist/src/notify.js +17 -7
  52. package/dist/src/okf.js +1 -1
  53. package/dist/src/online-checks.d.ts +76 -0
  54. package/dist/src/online-checks.js +432 -0
  55. package/dist/src/operator.d.ts +277 -0
  56. package/dist/src/operator.js +176 -2
  57. package/dist/src/otel-config.d.ts +18 -0
  58. package/dist/src/otel-config.js +69 -0
  59. package/dist/src/otel.d.ts +38 -0
  60. package/dist/src/otel.js +85 -0
  61. package/dist/src/outcome.d.ts +40 -0
  62. package/dist/src/outcome.js +77 -0
  63. package/dist/src/package.js +9 -2
  64. package/dist/src/platform.d.ts +1 -1
  65. package/dist/src/prompt-layers.d.ts +30 -0
  66. package/dist/src/prompt-layers.js +77 -0
  67. package/dist/src/provider-health.js +2 -2
  68. package/dist/src/providers.d.ts +5 -0
  69. package/dist/src/providers.js +2 -2
  70. package/dist/src/run-container.d.ts +25 -2
  71. package/dist/src/run-container.js +61 -6
  72. package/dist/src/runner.d.ts +53 -2
  73. package/dist/src/runner.js +471 -102
  74. package/dist/src/runs-filter.d.ts +48 -0
  75. package/dist/src/runs-filter.js +73 -0
  76. package/dist/src/scan.d.ts +30 -1
  77. package/dist/src/scan.js +145 -14
  78. package/dist/src/starter.js +27 -2
  79. package/dist/src/step-exec.d.ts +39 -3
  80. package/dist/src/step-exec.js +118 -15
  81. package/dist/src/store.d.ts +144 -3
  82. package/dist/src/store.js +150 -7
  83. package/dist/src/tokens.d.ts +38 -0
  84. package/dist/src/tokens.js +57 -0
  85. package/dist/src/tool-names.js +1 -1
  86. package/dist/src/tool-programs.js +1 -1
  87. package/dist/src/trace-tree.d.ts +40 -0
  88. package/dist/src/trace-tree.js +156 -0
  89. package/dist/src/translator.d.ts +8 -0
  90. package/dist/src/translator.js +7 -2
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/workspace-check.d.ts +18 -0
  93. package/dist/src/workspace-check.js +34 -0
  94. package/package.json +2 -2
  95. package/src/agent-activity.ts +32 -0
  96. package/src/agent-import.ts +2 -2
  97. package/src/agent-tools.ts +2 -2
  98. package/src/agents-md.ts +39 -14
  99. package/src/alerts.ts +190 -0
  100. package/src/browser-pod.ts +3 -0
  101. package/src/capture.ts +88 -0
  102. package/src/compare.ts +146 -0
  103. package/src/completions.ts +27 -3
  104. package/src/deploy.ts +238 -12
  105. package/src/egress.ts +12 -2
  106. package/src/evals.ts +64 -19
  107. package/src/flow-lint.ts +36 -0
  108. package/src/flow-patterns.ts +26 -13
  109. package/src/frontmatter-fields.ts +83 -3
  110. package/src/frontmatter-guard.ts +70 -0
  111. package/src/frontmatter-schema.ts +33 -4
  112. package/src/frontmatter-text.ts +109 -0
  113. package/src/history.ts +13 -0
  114. package/src/hooks.ts +1 -1
  115. package/src/insights.ts +174 -0
  116. package/src/issues.ts +396 -0
  117. package/src/judge-verdict.ts +52 -0
  118. package/src/library.ts +1 -1
  119. package/src/limits.ts +38 -5
  120. package/src/linkable.ts +1 -1
  121. package/src/matter.ts +145 -0
  122. package/src/model-credential.ts +45 -0
  123. package/src/network.ts +35 -7
  124. package/src/notify.ts +22 -7
  125. package/src/okf.ts +1 -1
  126. package/src/online-checks.ts +450 -0
  127. package/src/operator.ts +213 -3
  128. package/src/otel-config.ts +67 -0
  129. package/src/otel.ts +100 -0
  130. package/src/outcome.ts +90 -0
  131. package/src/package.ts +8 -2
  132. package/src/platform.ts +1 -1
  133. package/src/prompt-layers.ts +90 -0
  134. package/src/provider-health.ts +2 -2
  135. package/src/providers.ts +7 -2
  136. package/src/run-container.ts +77 -8
  137. package/src/runner.ts +477 -79
  138. package/src/runs-filter.ts +88 -0
  139. package/src/scan.ts +153 -14
  140. package/src/starter.ts +27 -2
  141. package/src/step-exec.ts +168 -17
  142. package/src/store.ts +268 -9
  143. package/src/tokens.ts +68 -0
  144. package/src/tool-names.ts +1 -1
  145. package/src/tool-programs.ts +1 -1
  146. package/src/trace-tree.ts +187 -0
  147. package/src/translator.ts +13 -2
  148. package/src/trigger-log.ts +1 -0
  149. package/src/workspace-check.ts +41 -0
package/dist/src/evals.js CHANGED
@@ -32,14 +32,17 @@
32
32
  // wrong.
33
33
  import { accountModelEnv, withModelEnv } from "./model-env.js";
34
34
  import { sdkEnv } from "./step-exec.js";
35
+ import { platformModelEnv } from "./runner.js";
35
36
  import fs from "node:fs";
36
37
  import path from "node:path";
37
38
  import { deployedCommit } from "./deploy.js";
38
39
  import { latestRevisionId } from "./history.js";
39
- import matter from "gray-matter";
40
+ import matter from "./matter.js";
40
41
  import { spawn } from "node:child_process";
42
+ import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION, parseJudge } from "./judge-verdict.js";
43
+ import { readAgentsMdKeys } from "./agents-md.js";
41
44
  import { query } from "@anthropic-ai/claude-agent-sdk";
42
- import { workspaceDir, readRun, writeWorkspaceFile, resolveModel, resolveEffort, assertSafeName, } from "./store.js";
45
+ import { workspaceDir, accountDir, readRun, runVerdict, writeWorkspaceFile, resolveModel, resolveEffort, assertSafeName, } from "./store.js";
43
46
  import { startFlowRun, loadFlow } from "./runner.js";
44
47
  import { platform, platformQueues } from "./platform.js";
45
48
  const ASSERTION_TYPES = ["contains", "not-contains", "matches", "file", "run", "judge"];
@@ -85,7 +88,9 @@ function readScalar(lines, start, inline, indent) {
85
88
  const first = inline.trim().replace(/^["']|["']$/g, "");
86
89
  return { value: [first, ...parts].join(" ").trim(), end: i };
87
90
  }
88
- export function parseEval(file, raw) {
91
+ /** `defaults`: the workspace's `judge:` (AGENTS.md), used where the eval
92
+ * names no model or effort of its own. */
93
+ export function parseEval(file, raw, defaults = {}) {
89
94
  const { data, content } = matter(raw);
90
95
  const cases = [];
91
96
  let current = null;
@@ -137,8 +142,8 @@ export function parseEval(file, raw) {
137
142
  file,
138
143
  agent: typeof data.agent === "string" ? data.agent : null,
139
144
  flow: typeof data.flow === "string" ? data.flow : null,
140
- model: resolveModel(data.model ?? "fast"),
141
- effort: resolveEffort(data.effort ?? "low"),
145
+ model: resolveModel(data.model ?? defaults.model ?? "fast"),
146
+ effort: resolveEffort(data.effort ?? defaults.effort ?? "low"),
142
147
  trigger: data.trigger === "manual" ? "manual" : "deploy",
143
148
  live: data.live === true,
144
149
  inputs: data.inputs === true,
@@ -153,7 +158,17 @@ export function listEvals(tenant, workspace) {
153
158
  .readdirSync(dir)
154
159
  .filter((f) => f.endsWith(".md"))
155
160
  .sort()
156
- .map((f) => parseEval(f, fs.readFileSync(path.join(dir, f), "utf8")));
161
+ .map((f) => parseEval(f, fs.readFileSync(path.join(dir, f), "utf8"), judgeDefaults(tenant, workspace)));
162
+ }
163
+ /** `judge:` from AGENTS.md, field by field: the workspace's model (or
164
+ * effort), else the account's — so a workspace that sets only an effort
165
+ * keeps the account's model (Codex debate: the form says so). */
166
+ export function judgeDefaults(tenant, workspace) {
167
+ const ws = parseJudge(readAgentsMdKeys(workspaceDir(tenant, workspace)).judge);
168
+ const account = parseJudge(readAgentsMdKeys(accountDir(tenant)).judge);
169
+ const model = ws.model ?? account.model;
170
+ const effort = ws.effort ?? account.effort;
171
+ return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) };
157
172
  }
158
173
  // ---------- promoting a run ----------
159
174
  /** An error a route can turn into the right status without knowing HTTP. */
@@ -198,16 +213,27 @@ function layoutTask(task) {
198
213
  * regressions file when there is one, so a flow collects its cases in one
199
214
  * place instead of one file per press.
200
215
  */
201
- export function promoteRunToEval(tenant, workspace, runId, opts = {}) {
216
+ export function promoteRunToEval(tenant, workspace, runId,
217
+ /** `should`: what a run that went wrong should have done, in a sentence —
218
+ * the judge's claim for the case (FOL-72 step 10). */
219
+ opts = {}) {
202
220
  const run = readRun(tenant, workspace, runId);
203
221
  if (!run)
204
222
  throw refuse(`run ${runId} not found`, 404);
205
- if (run.status !== "completed") {
206
- throw refuse(`only a completed run can become an eval — this one is ${run.status}`, 409);
223
+ if (run.status !== "completed" && run.status !== "failed") {
224
+ throw refuse(`only a finished run can become an eval — this one is ${run.status}`, 409);
225
+ }
226
+ // A run that went wrong is the test worth having most — but its own answer
227
+ // is the wrong one to hold the next run to, so it needs to be told what
228
+ // was right (FOL-72 step 10).
229
+ const should = oneLine(opts.should ?? "");
230
+ const wentWrong = run.status === "failed" || ["BAD", "BLOCKED"].includes(runVerdict(run) ?? "");
231
+ if (wentWrong && !should && !(opts.expect && opts.expect.length)) {
232
+ throw refuse("this run went wrong, so its answer is not the one to keep — say what it should have done (`should`, a sentence for the judge) or give `expect` lines", 409);
207
233
  }
208
234
  const produced = run.steps.filter((s) => (s.result ?? "").trim());
209
235
  const last = produced[produced.length - 1];
210
- if (!last)
236
+ if (!last && !should && !(opts.expect && opts.expect.length))
211
237
  throw refuse("this run produced no result to hold the next one to", 409);
212
238
  if (run.steps.length === 0 || !run.steps[0].instruction?.trim()) {
213
239
  throw refuse("this run has no task on record", 409);
@@ -239,6 +265,9 @@ export function promoteRunToEval(tenant, workspace, runId, opts = {}) {
239
265
  return `${m[1]}: ${m[2]}`;
240
266
  });
241
267
  }
268
+ else if (should) {
269
+ expect = [`judge: ${should.slice(0, 400)}`];
270
+ }
242
271
  else {
243
272
  const conclusion = oneLine(run.summary?.trim() || (last.result ?? "")).slice(0, 200);
244
273
  expect = [`judge: reaches the same conclusion as the recorded run: ${conclusion}`];
@@ -351,12 +380,14 @@ function shell(command, cwd) {
351
380
  child.on("error", (err) => resolve({ code: null, out: String(err) }));
352
381
  });
353
382
  }
354
- // The judge gets the rubric and the output, and must answer with one word.
383
+ // The judge gets the rubric and the output, and ends with one word.
355
384
  // Constrained output beats asking for a score: "PASS" or "FAIL" can't be
356
- // misread, where "7/10" needs a threshold nobody agreed on.
385
+ // misread, where "7/10" needs a threshold nobody agreed on. The word comes
386
+ // LAST, after the reasoning: asked for it first, the judge committed before
387
+ // it had looked, then argued the other way — "FAIL … which meets the
388
+ // criterion" (vaza evals, 21 Oct 2026).
357
389
  async function judge(rubric, output, model, effort, env) {
358
- const prompt = `You are grading one piece of output against one criterion. Answer with exactly ` +
359
- `PASS or FAIL on the first line, then one short sentence of reason.\n\n` +
390
+ const prompt = `You are grading one piece of output against one criterion. ${JUDGE_VERDICT_INSTRUCTION}\n\n` +
360
391
  `Criterion: ${rubric}\n\n<output>\n${output.slice(0, 20000)}\n</output>`;
361
392
  let text = "";
362
393
  const q = query({
@@ -382,7 +413,19 @@ async function judge(rubric, output, model, effort, env) {
382
413
  }
383
414
  return text.trim();
384
415
  }
385
- async function checkAssertion(assertion, output, agentDir, model, effort, judgeEnv = () => ({ env: sdkEnv(undefined) })) {
416
+ /**
417
+ * The judge's environment: this process's, with the platform's model
418
+ * credential read live. The process's own CLAUDE_CODE_OAUTH_TOKEN is the copy
419
+ * it was handed at boot; the box refreshes the login every four hours and the
420
+ * old token is then revoked, so a judge on the boot copy failed with "OAuth
421
+ * token revoked" from the first refresh until the pod restarted, while the
422
+ * agent steps (which read the live file) kept working (2026-10-21).
423
+ */
424
+ export function judgeProcessEnv() {
425
+ const live = Object.fromEntries(Object.entries(platformModelEnv()).filter(([, v]) => v !== undefined));
426
+ return sdkEnv({ ...process.env, ...live });
427
+ }
428
+ async function checkAssertion(assertion, output, agentDir, model, effort, judgeEnv = () => ({ env: judgeProcessEnv() })) {
386
429
  const hay = output.toLowerCase();
387
430
  switch (assertion.type) {
388
431
  case "contains": {
@@ -424,9 +467,9 @@ async function checkAssertion(assertion, output, agentDir, model, effort, judgeE
424
467
  catch (err) {
425
468
  return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
426
469
  }
427
- const verdict = await withModelEnv(m, (env) => judge(assertion.value, output, model, effort, env));
428
- const passed = /^\s*PASS\b/i.test(verdict);
429
- return { assertion, passed, detail: verdict.slice(0, 300) || "judge returned nothing" };
470
+ const text = await withModelEnv(m, (env) => judge(assertion.value, output, model, effort, env));
471
+ const { passed, detail } = judgeVerdict(text);
472
+ return { assertion, passed, detail };
430
473
  }
431
474
  }
432
475
  }
@@ -495,7 +538,7 @@ export async function runEval(tenant, workspace, info) {
495
538
  for (const a of cheap)
496
539
  assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
497
540
  if (assertions.every((r) => r.passed)) {
498
- const judgeEnv = () => accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
541
+ const judgeEnv = () => accountModelEnv(tenant, workspace, judgeProcessEnv);
499
542
  for (const a of judges)
500
543
  assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
501
544
  }
@@ -25,4 +25,18 @@ export interface KnownNames {
25
25
  * rule is skipped rather than guessed at. */
26
26
  outwardAgents?: string[];
27
27
  }
28
+ /** A `verify:` that is a claim in English rather than a command. Conservative
29
+ * on purpose, the same bargain as REFERS_BACK: it must have no assertion
30
+ * prefix, nothing shell-shaped anywhere in it, and enough words to be a
31
+ * sentence — so a bare `./check.sh` or a one-word command is left alone and
32
+ * the warning only fires where the author plainly wrote prose. */
33
+ /**
34
+ * Why a `matches:` pattern will not compile, or null when it does. The
35
+ * runtime tests it as a JavaScript RegExp with the `i` flag (step-exec.ts,
36
+ * evals.ts), and a pattern that throws fails its check on every run with
37
+ * "invalid regular expression". The usual cause is another dialect: on
38
+ * 2026-10-21 Python's inline `(?m)` broke five eval checks on vaza, and
39
+ * nothing said so before they ran.
40
+ */
41
+ export declare function regexProblem(pattern: string): string | null;
28
42
  export declare function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[];
@@ -54,6 +54,27 @@ const LOOKS_LIKE_SHELL = /&&|\|\||\s\|\s|\$\(|\$\{|`|\s[<>]\s|\d?>[>&]?\s|^\[|^[
54
54
  * prefix, nothing shell-shaped anywhere in it, and enough words to be a
55
55
  * sentence — so a bare `./check.sh` or a one-word command is left alone and
56
56
  * the warning only fires where the author plainly wrote prose. */
57
+ /**
58
+ * Why a `matches:` pattern will not compile, or null when it does. The
59
+ * runtime tests it as a JavaScript RegExp with the `i` flag (step-exec.ts,
60
+ * evals.ts), and a pattern that throws fails its check on every run with
61
+ * "invalid regular expression". The usual cause is another dialect: on
62
+ * 2026-10-21 Python's inline `(?m)` broke five eval checks on vaza, and
63
+ * nothing said so before they ran.
64
+ */
65
+ export function regexProblem(pattern) {
66
+ try {
67
+ new RegExp(pattern, "i");
68
+ return null;
69
+ }
70
+ catch (err) {
71
+ const why = err instanceof Error ? err.message.replace(/^Invalid regular expression: /, "") : String(err);
72
+ const inline = /\(\?[a-z]+\)/.test(pattern)
73
+ ? " — JavaScript has no inline flags like (?m); write (^|\\n) for the start of a line"
74
+ : "";
75
+ return `is not a valid JavaScript regular expression (${why})${inline}`;
76
+ }
77
+ }
57
78
  function looksLikeProse(verify) {
58
79
  const v = verify.trim();
59
80
  if (!v || VERIFY_ASSERTION.test(v))
@@ -377,6 +398,20 @@ export function lintFlow(flow, known) {
377
398
  "claim, or write it as a shell command that exits 0 when the claim holds.",
378
399
  });
379
400
  }
401
+ // A `verify: matches:` the runtime cannot compile fails the step on
402
+ // every run, whatever the agent did.
403
+ const verifyRe = step.verify ? /^matches:\s*(.*)$/.exec(step.verify.trim()) : null;
404
+ // The same normalisation checkVerify applies (step-exec.ts): one quote
405
+ // off each end, so the pattern checked is the pattern that runs.
406
+ const reProblem = verifyRe ? regexProblem(verifyRe[1].trim().replace(/^["']|["']$/g, "")) : null;
407
+ if (reProblem) {
408
+ warnings.push({
409
+ step: i,
410
+ line: step.line,
411
+ message: `verify: matches: ${reProblem}`,
412
+ detail: "The runtime tests matches: as a JavaScript regular expression, case-insensitive. One that does not compile fails this step on every run.",
413
+ });
414
+ }
380
415
  // A step's OTHER agent references — the ones no parser validates,
381
416
  // because they are options rather than the step's target. Both fail
382
417
  // silently and at the worst possible moment: a misspelled `delegate:`
@@ -24,6 +24,7 @@ import { joinGroup, splitToRail } from "./arrange.js";
24
24
  import { lintFlow } from "./flow-lint.js";
25
25
  import { toLF, asRaw } from "./eol.js";
26
26
  import { removalImpact, stepLabel } from "./step-removal.js";
27
+ import { keySpan, InlineBlockError } from "./frontmatter-text.js";
27
28
  // ---------- flow files ----------
28
29
  /** A flow's steps as groups of indices into its parsed steps — what
29
30
  * reorderFlowSteps and the arrange helpers take. */
@@ -437,14 +438,16 @@ function splitComment(value) {
437
438
  const m = value.match(/^(.*?)(\s+#.*)$/);
438
439
  return m ? { value: m[1], comment: m[2] } : { value, comment: "" };
439
440
  }
441
+ /** Where a key is written — at the top or inside its job block
442
+ * (frontmatter-text.ts) — with the text after its colon and its indent. */
440
443
  function keyLine(lines, close, key) {
441
- const re = new RegExp(`^${key.replace(/[-]/g, "\\-")}:(.*)$`);
442
- for (let i = 1; i < close; i++) {
443
- const m = lines[i].match(re);
444
- if (m)
445
- return { index: i, rest: m[1] };
446
- }
447
- return null;
444
+ const where = keySpan(lines.slice(1, close), key);
445
+ if (!where)
446
+ return null;
447
+ const index = where.at + 1;
448
+ // Quoted or not, as keySpan matched it.
449
+ const m = lines[index].slice(where.indent.length).match(new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&")}["']?\\s*:(.*)$`));
450
+ return m ? { index, rest: m[1], indent: where.indent } : null;
448
451
  }
449
452
  /** Block-style item lines under a key: ` - name`, until anything else. */
450
453
  function blockItems(lines, from, close) {
@@ -467,7 +470,17 @@ export function frontmatterList(raw, key) {
467
470
  const b = frontBounds(lines);
468
471
  if (!b)
469
472
  return [];
470
- const k = keyLine(lines, b.close, key);
473
+ let k;
474
+ try {
475
+ k = keyLine(lines, b.close, key);
476
+ }
477
+ catch (err) {
478
+ // A block these text edits do not handle (frontmatter-text.ts) is the
479
+ // writer's to refuse; a read of it is empty, not an error.
480
+ if (err instanceof InlineBlockError)
481
+ return [];
482
+ throw err;
483
+ }
471
484
  if (!k)
472
485
  return [];
473
486
  const v = splitComment(k.rest).value.trim();
@@ -491,10 +504,12 @@ export function editFrontmatterList(raw, key, name, action) {
491
504
  const b = frontBounds(lines);
492
505
  if (!b)
493
506
  return action === "remove" ? raw : ["---", `${key}: [${name}]`, "---", ...lines].join(eol);
507
+ // Located first, so a key in a block these edits do not handle is refused
508
+ // (InlineBlockError) rather than read as an empty list.
509
+ const k = keyLine(lines, b.close, key);
494
510
  const has = frontmatterList(raw, key).includes(name);
495
511
  if ((action === "add") === has)
496
512
  return raw;
497
- const k = keyLine(lines, b.close, key);
498
513
  if (!k) {
499
514
  lines.splice(b.close, 0, `${key}: [${name}]`);
500
515
  return lines.join(eol);
@@ -505,12 +520,12 @@ export function editFrontmatterList(raw, key, name, action) {
505
520
  // Flow style, or a lone scalar (read as a one-item list): that one line.
506
521
  const tokens = v.startsWith("[") ? v.replace(/^\[|\]$/g, "").split(",").map((t) => t.trim()).filter(Boolean) : [v];
507
522
  const next = action === "add" ? [...tokens, name] : tokens.filter((t) => itemName(t) !== name);
508
- lines[k.index] = `${key}: [${next.join(", ")}]${comment}`;
523
+ lines[k.index] = `${k.indent}${key}: [${next.join(", ")}]${comment}`;
509
524
  return lines.join(eol);
510
525
  }
511
526
  const items = blockItems(lines, k.index, b.close);
512
527
  if (action === "add") {
513
- const indent = items.length ? lines[items[0]].match(/^(\s*)-/)[1] : " ";
528
+ const indent = items.length ? lines[items[0]].match(/^(\s*)-/)[1] : `${k.indent} `;
514
529
  lines.splice(items.length ? items[items.length - 1] + 1 : k.index + 1, 0, `${indent}- ${name}`);
515
530
  return lines.join(eol);
516
531
  }
@@ -518,7 +533,7 @@ export function editFrontmatterList(raw, key, name, action) {
518
533
  for (const i of [...drop].reverse())
519
534
  lines.splice(i, 1);
520
535
  if (drop.length === items.length)
521
- lines[k.index] = `${key}: []${comment}`;
536
+ lines[k.index] = `${k.indent}${key}: []${comment}`;
522
537
  return lines.join(eol);
523
538
  }
524
539
  const YAML_WORDS = /^(true|false|yes|no|on|off|null|~|y|n)$/i;
@@ -546,10 +561,10 @@ export function setFrontmatterScalar(raw, key, value) {
546
561
  const { value: v, comment } = splitComment(k.rest);
547
562
  let end = k.index + 1;
548
563
  if (/^\s*[|>][-+]?\d*\s*$/.test(v) || !v.trim()) {
549
- while (end < b.close && /^\s+\S/.test(lines[end]))
564
+ while (end < b.close && (lines[end].match(/^\s*/)?.[0].length ?? 0) > k.indent.length && /\S/.test(lines[end]))
550
565
  end++;
551
566
  }
552
- lines.splice(k.index, end - k.index, `${line}${comment}`);
567
+ lines.splice(k.index, end - k.index, `${k.indent}${line}${comment}`);
553
568
  return lines.join(eol);
554
569
  }
555
570
  const name = keyLine(lines, b.close, "name");
@@ -15,13 +15,40 @@ export interface FieldSpec {
15
15
  legacy?: boolean;
16
16
  /** One line: what it is. The completion's hint. */
17
17
  help: string;
18
+ /** Which of the four jobs it does, when it does one (set from JOB_FIELDS
19
+ * below, not by hand): the block it may also be written in. */
20
+ job?: Job;
18
21
  }
22
+ /**
23
+ * The four jobs of running agents — governance, guardrail, observability,
24
+ * evaluation (the owner's choice of words, 21 Oct 2026). A field that does
25
+ * one may be written flat, as always, or inside a block of that name:
26
+ *
27
+ * guardrail:
28
+ * limits: {repeat: 5}
29
+ * network: [api.example.com]
30
+ *
31
+ * reads exactly as `limits:` and `network:` at the top. matter.ts folds the
32
+ * blocks as it parses, so every reader sees flat keys; check reports a key
33
+ * set both ways (config/conflict) or in the wrong block.
34
+ */
35
+ export type Job = "governance" | "guardrail" | "observability" | "evaluation";
36
+ export declare const JOBS: readonly {
37
+ name: Job;
38
+ title: string;
39
+ what: string;
40
+ }[];
19
41
  export type SchemaKind = "agent" | "agents-md" | "flow" | "tool" | "skill" | "eval" | "hook";
20
42
  /** What can start a flow. store.ts re-exports it; parseFlow and the editor
21
43
  * read this one. */
22
44
  export declare const FLOW_TRIGGERS: readonly ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
23
45
  /** The effort levels; store.ts re-exports it with the aliases beside. */
24
46
  export declare const EFFORT_LEVELS: readonly ["low", "medium", "high", "xhigh", "max"];
47
+ /** The fields of one kind that do this job, in schema order. */
48
+ export declare function fieldsOfJob(kind: SchemaKind, job: Job): string[];
49
+ /** Every key any kind lets a block hold, per job — what matter.ts may lift
50
+ * out of a block without knowing which kind of file it is reading. */
51
+ export declare const FOLDABLE: Readonly<Record<Job, ReadonlySet<string>>>;
25
52
  export declare const FRONTMATTER: Readonly<Record<SchemaKind, readonly FieldSpec[]>>;
26
53
  /** Which schema a workspace-relative path is read with, or null. */
27
54
  export declare function schemaKindOf(p: string): SchemaKind | null;
@@ -15,6 +15,12 @@
15
15
  // in the docs.
16
16
  //
17
17
  // A new field is added here first, then read.
18
+ export const JOBS = [
19
+ { name: "governance", title: "Governance", what: "who answers for it and what it may spend, decide and ship" },
20
+ { name: "guardrail", title: "Guardrail", what: "what it may reach and call, and what stops it" },
21
+ { name: "observability", title: "Observability", what: "what is recorded, and who is told" },
22
+ { name: "evaluation", title: "Evaluation", what: "how its work is judged" },
23
+ ];
18
24
  /** What can start a flow. store.ts re-exports it; parseFlow and the editor
19
25
  * read this one. */
20
26
  export const FLOW_TRIGGERS = ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
@@ -24,6 +30,7 @@ const LOCALE = { validated: true };
24
30
  const AGENT = [
25
31
  { key: "name", type: "string", help: "kebab-case; the folder is the identity" },
26
32
  { key: "description", type: "string", help: "what it does and when — other agents and people read it" },
33
+ { key: "owner", type: "string", help: "the person accountable for it — an email or a name; else the workspace's, else the account's" },
27
34
  { key: "model", type: "string", help: "fast | default | max, an alias, or a full model id" },
28
35
  { key: "effort", type: "string", values: EFFORT_LEVELS, help: "how long it thinks" },
29
36
  { key: "size", type: "string", values: ["small", "large", "heavy"], help: "the sandbox it rents; anything else becomes large" },
@@ -59,6 +66,7 @@ const AGENTS_MD = [
59
66
  { key: "mdagent_version", type: ["string", "number"], legacy: true, help: "older spelling of foldrun_version" },
60
67
  { key: "name", type: "string", help: "the workspace's name" },
61
68
  { key: "description", type: "string", help: "shown in the workspace list" },
69
+ { key: "owner", type: "string", help: "the person accountable for every agent and flow here that names none" },
62
70
  { key: "provider", type: "object", help: "the model endpoint every agent here uses unless it names its own" },
63
71
  { key: "runtime", type: "object", help: "packages for agents that declare none" },
64
72
  { key: "web", type: "object", validated: true, help: "web.browse defaults" },
@@ -75,17 +83,25 @@ const AGENTS_MD = [
75
83
  { key: "budget", type: ["number", "string"], help: "USD per month, or N/day | N/week | N/month" },
76
84
  { key: "priority", type: "string", values: ["high", "normal", "low"], help: "account: where its runs stand in the queue" },
77
85
  { key: "concurrency", type: ["number", "string"], help: "account: runs at once" },
86
+ { key: "alerts", type: "object", validated: true, help: "thresholds that tell notify: — failure_rate (30%), min_runs, credits_day, run_minutes, stale_days, cost_spike, runs_per_hour, new_host" },
87
+ { key: "capture", type: "string", values: ["full", "metadata"], help: "what runs record of each tool call: full (its input and output, secrets masked) or metadata (name, size and time only)" },
88
+ { key: "network_enforce", type: "boolean", help: "true: a site off a step's network: list is refused, not only named in the trace. The account's or the workspace's true is enough" },
89
+ { key: "telemetry", type: "object", validated: true, help: "send each run as an OpenTelemetry trace — otlp: <https …/v1/traces>, headers: { authorization: ${SECRET} }" },
90
+ { key: "deploy_block", type: "string", values: ["none", "secrets", "security"], help: "refuse a deploy on scanner findings: secrets (a key written in) or security (any security warning)" },
91
+ { key: "judge", type: "object", validated: true, help: "the model every judge: here grades with — {model: fast | default | max, effort}; an eval's own model: wins" },
78
92
  { key: "schedule", type: ["string", "object"], validated: true, legacy: true, help: "refused: only a flow has a clock" },
79
93
  ];
80
94
  const FLOW = [
81
95
  { key: "name", type: "string", help: "the flow's name" },
82
96
  { key: "description", type: "string", help: "what it is for" },
97
+ { key: "owner", type: "string", help: "the person accountable for it — an email or a name; else the workspace's, else the account's" },
83
98
  { key: "trigger", type: "string", values: FLOW_TRIGGERS, help: "what starts it; any other value never fires" },
84
99
  { key: "schedule", type: "string", help: "cron, when trigger: schedule" },
85
100
  { key: "timezone", type: "string", validated: true, help: "the zone its schedule reads in" },
86
101
  { key: "model", type: "string", help: "every step's model unless the step says" },
87
102
  { key: "effort", type: "string", values: EFFORT_LEVELS, help: "every step's effort unless the step says" },
88
103
  { key: "overlap", type: "string", validated: true, help: "skip | queue" },
104
+ { key: "issues", type: "string", values: ["stay-open", "auto-close"], help: "what a good run does to this flow's open issues: stay-open (the default — a person resolves them) or auto-close" },
89
105
  { key: "priority", type: "string", validated: true, help: "high | normal | low" },
90
106
  { key: "catchup", type: "string", validated: true, help: "last | none" },
91
107
  { key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
@@ -166,10 +182,51 @@ const SAID_WHAT_IT_IS = [
166
182
  { key: "kind", type: "string", validated: true, legacy: true, help: "no longer read — the path says what this is" },
167
183
  { key: "type", type: "string", validated: true, legacy: true, help: "no longer read — the path says what this is" },
168
184
  ];
185
+ /** Which fields each job holds, per kind of file. One table: the blocks'
186
+ * help, the fold, the editor's completions and check all read it. */
187
+ const JOB_FIELDS = {
188
+ agent: {
189
+ governance: ["owner", "budget"],
190
+ guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets"],
191
+ },
192
+ flow: {
193
+ governance: ["owner", "approvers", "approve_within", "budget", "priority"],
194
+ guardrail: ["throttle", "debounce", "overlap", "disable_after", "live"],
195
+ observability: ["issues", "sla"],
196
+ },
197
+ "agents-md": {
198
+ governance: ["owner", "budget", "priority", "concurrency", "deploy_block"],
199
+ guardrail: ["limits", "network", "network_enforce", "hooks"],
200
+ observability: ["alerts", "telemetry", "notify", "capture"],
201
+ evaluation: ["judge"],
202
+ },
203
+ };
204
+ /** The fields of one kind that do this job, in schema order. */
205
+ export function fieldsOfJob(kind, job) {
206
+ return [...(JOB_FIELDS[kind]?.[job] ?? [])];
207
+ }
208
+ /** Every key any kind lets a block hold, per job — what matter.ts may lift
209
+ * out of a block without knowing which kind of file it is reading. */
210
+ export const FOLDABLE = Object.fromEntries(JOBS.map(({ name }) => [name, new Set(Object.values(JOB_FIELDS).flatMap((byJob) => byJob?.[name] ?? []))]));
211
+ /** A kind's fields with their jobs set, and a block field per job it has. */
212
+ function withJobs(kind, fields) {
213
+ const byJob = JOB_FIELDS[kind] ?? {};
214
+ const jobOf = new Map();
215
+ for (const [job, keys] of Object.entries(byJob))
216
+ for (const k of keys)
217
+ jobOf.set(k, job);
218
+ const tagged = fields.map((f) => (jobOf.has(f.key) ? { ...f, job: jobOf.get(f.key) } : f));
219
+ const blocks = JOBS.filter((j) => (byJob[j.name]?.length ?? 0) > 0).map((j) => ({
220
+ key: j.name,
221
+ type: "object",
222
+ help: `${j.title} — ${j.what}: ${byJob[j.name].join(", ")}, as one block`,
223
+ }));
224
+ return [...tagged, ...blocks];
225
+ }
169
226
  export const FRONTMATTER = {
170
- agent: [...AGENT, ...SAID_WHAT_IT_IS],
171
- "agents-md": AGENTS_MD,
172
- flow: [...FLOW, ...SAID_WHAT_IT_IS],
227
+ agent: withJobs("agent", [...AGENT, ...SAID_WHAT_IT_IS]),
228
+ "agents-md": withJobs("agents-md", AGENTS_MD),
229
+ flow: withJobs("flow", [...FLOW, ...SAID_WHAT_IT_IS]),
173
230
  tool: [...TOOL, ...SAID_WHAT_IT_IS.filter((f) => f.key !== "type")],
174
231
  skill: [...SKILL, ...SAID_WHAT_IT_IS],
175
232
  eval: [...EVAL, ...SAID_WHAT_IT_IS],
@@ -0,0 +1,19 @@
1
+ /** A write the guard refused. 409: the file is fine, this edit is not. */
2
+ export declare class UnsafeEditError extends Error {
3
+ status: number;
4
+ constructor(why: string);
5
+ }
6
+ /** What a changed key must read back as: a value (null: absent), or a test
7
+ * of what it reads as, for a writer whose output form is its own (a canvas
8
+ * list, a scalar YAML may type). */
9
+ export type Expect = unknown | ((value: unknown) => boolean);
10
+ /**
11
+ * `after` if it is `before` with only `changed` keys different and each
12
+ * `expect`ed key reading back as expected; throws UnsafeEditError otherwise.
13
+ * Checked on every call — an edit that changed nothing must still have done
14
+ * what it said — and refused when `before` cannot be read, since nothing
15
+ * then could be compared.
16
+ */
17
+ export declare function guardEdit(before: string, after: string, changed: readonly string[], expect?: Record<string, Expect>): string;
18
+ /** The front of `text` as a run reads it, or null when it does not parse. */
19
+ export declare function readFront(text: string): Record<string, unknown> | null;
@@ -0,0 +1,67 @@
1
+ // The last word on every frontmatter edit made as text (agents-md.ts
2
+ // patchFrontmatter, network.ts setNetworkSetting, the canvas's list and
3
+ // scalar edits): the result is read back the way a run reads it, and the
4
+ // edit is refused — the file left as it was — unless it parses, set what it
5
+ // said it set, left everything else as it was, and made no key both flat and
6
+ // in its block. The text writers handle the shapes people write; this makes
7
+ // a shape they do not handle a refusal, never a broken or doubled file
8
+ // (Codex debate, 21 Oct 2026).
9
+ import { isDeepStrictEqual } from "node:util";
10
+ import { configMatter, jobFold } from "./matter.js";
11
+ /** A write the guard refused. 409: the file is fine, this edit is not. */
12
+ export class UnsafeEditError extends Error {
13
+ status = 409;
14
+ constructor(why) {
15
+ super(`not saved — ${why}. Edit the file itself, or write its blocks one key per line`);
16
+ }
17
+ }
18
+ const front = (text) => configMatter(text).data;
19
+ /**
20
+ * `after` if it is `before` with only `changed` keys different and each
21
+ * `expect`ed key reading back as expected; throws UnsafeEditError otherwise.
22
+ * Checked on every call — an edit that changed nothing must still have done
23
+ * what it said — and refused when `before` cannot be read, since nothing
24
+ * then could be compared.
25
+ */
26
+ export function guardEdit(before, after, changed, expect = {}) {
27
+ let was;
28
+ try {
29
+ was = front(before);
30
+ }
31
+ catch {
32
+ throw new UnsafeEditError("the frontmatter does not parse as it is, so this edit could not be checked — fix the file first");
33
+ }
34
+ let now;
35
+ try {
36
+ now = front(after);
37
+ }
38
+ catch (err) {
39
+ throw new UnsafeEditError(`the result would not parse (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`);
40
+ }
41
+ const conflictsBefore = jobFold(was)?.conflicts ?? new Map();
42
+ for (const [k, block] of jobFold(now)?.conflicts ?? new Map()) {
43
+ if (!conflictsBefore.has(k))
44
+ throw new UnsafeEditError(`\`${k}\` would be written both at the top and inside \`${block}:\``);
45
+ }
46
+ for (const [k, want] of Object.entries(expect)) {
47
+ const ok = typeof want === "function" ? want(now[k]) : isDeepStrictEqual(now[k], want === null ? undefined : want);
48
+ if (!ok)
49
+ throw new UnsafeEditError(`\`${k}\` would not read back as written`);
50
+ }
51
+ for (const k of new Set([...Object.keys(was), ...Object.keys(now)])) {
52
+ if (changed.includes(k))
53
+ continue;
54
+ if (!isDeepStrictEqual(was[k], now[k]))
55
+ throw new UnsafeEditError(`\`${k}\` would change too`);
56
+ }
57
+ return after;
58
+ }
59
+ /** The front of `text` as a run reads it, or null when it does not parse. */
60
+ export function readFront(text) {
61
+ try {
62
+ return front(text);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
@@ -2,8 +2,8 @@ import { type SchemaKind } from "./frontmatter-fields.ts";
2
2
  export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
3
3
  export interface FieldProblem {
4
4
  key: string;
5
- rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field";
6
- level: "warn" | "info";
5
+ rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced";
6
+ level: "error" | "warn" | "info";
7
7
  message: string;
8
8
  }
9
9
  /**