foldrun 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/foldrun.mjs CHANGED
@@ -135,7 +135,7 @@ const HELP = `foldrun — agents are just folders
135
135
  foldrun hook rm <name> delete a hook folder — says which agents still turn it on, asks (--yes, --library)
136
136
  foldrun extract [dir] move single-file script tools into folders (tool.md + run.*)
137
137
  foldrun run <target> run an agent or flow HERE (target: name, or flow:name) — on the platform: flow run / invoke a flow, agent run one agent
138
- foldrun eval [name] run one eval, or all of them (--to <workspace>: the deployed evals, run on the platform)
138
+ foldrun eval [name] run one eval, or all of them (--to <workspace>: the deployed evals, run on the platform; --last reads their latest results, --run <id> one run's verdict, running nothing)
139
139
  foldrun eval new <name> a new eval in the deployed workspace, its agent the first one there — edit its cases, then eval <name> --to (--to)
140
140
  foldrun promote <run-id> keep a finished run as a regression case in evals/ (--eval <name>, --expect "contains: …" repeatable, --case <name>); a run that went wrong takes --should "what it should have done"
141
141
  foldrun probe <model> live check: can this model hold a tool loop here?
@@ -183,10 +183,11 @@ const HELP = `foldrun — agents are just folders
183
183
  foldrun billing card Stripe's page for saving a card for auto top-up (owner only)
184
184
  foldrun billing credit <usd> put credit on the balance by hand (--note "…") — only on an install without Stripe (self-hosted, dev); refused once billing is live
185
185
  foldrun gallery the platform's built-in tools (web) and whether you keep your own copy — also pull (a copy for offline runs), upgrade <tool>
186
- foldrun account the account's defaults — also set <key> <value>, clear <key> (timezone, notify, budget, concurrency, deploy_block none|secrets|security, network_enforce on|off), pause [--reason] [--stop] / resume (the kill switch), telemetry [set <url> --header name=\${SECRET} | clear | test] (runs as OpenTelemetry traces), providers (--check) (singular; accounts lists logins)
186
+ foldrun jobs the four jobs — governance, guardrail, observability, evaluation — and what each has set (--to <workspace>, --json)
187
+ foldrun account the account's defaults — also set <key> <value>, clear <key> (timezone, notify, budget, concurrency, deploy_block none|secrets|security, network_enforce on|off, owner, alerts <threshold> <value>, judge model|effort <value>; or <job>.<field>, e.g. governance.owner), pause [--reason] [--stop] / resume (the kill switch), telemetry [set <url> --header name=\${SECRET} | clear | test] (runs as OpenTelemetry traces), providers (--check) (singular; accounts lists logins)
187
188
  foldrun account export everything the platform holds about the account as one JSON file (owner only; --file <path>, --force)
188
189
  foldrun account model the account's own model key — set <provider> --key <api key> (--key - reads stdin; custom --base-url --format), remove
189
- foldrun workspace one deployed workspace's settings (--to) — also set <name|description|timezone|budget|notify|network_enforce> <value>, clear <key>, pause [--reason] [--stop] / resume (the kill switch)
190
+ foldrun workspace one deployed workspace's settings (--to) — also set <name|description|timezone|budget|notify|network_enforce|owner|alerts|judge> <value> (or <job>.<field>), clear <key>, pause [--reason] [--stop] / resume (the kill switch)
190
191
  foldrun workspace vocabulary the names the dashboard's editor completes in a workspace: agents, flows, skills, tools, secrets, scripts, types, documents (--to, --json)
191
192
  foldrun notify test send one test notification from a workspace and say what happened (--to <workspace>)
192
193
  foldrun webhooks deliveries the notify: webhooks a workspace sent, newest first — status, attempts, last answer (--failed, --status <s>, --event <e>, --limit, --offset, --to)
@@ -304,6 +305,7 @@ Options
304
305
  --step <n> approve, reject: decide only that step; default is every step that is waiting
305
306
  --payload <json|text|@f> approve: release a step waiting on wait: event with this body, recorded as you
306
307
  --note "<text>" approve, reject: guidance the agent reads — or the reason for a refusal; billing credit: the ledger line's note
308
+ --run <id> eval --to: that eval run's verdict, by the id the run printed; runs nothing
307
309
  --stale [days] agent ls: only agents not run in that many days (never run included); bare, those past alerts: stale_days
308
310
  --reason "<text>" account pause, workspace pause: why — shown to everyone who opens it
309
311
  --stop account pause, workspace pause: also stop every run going now (asks; --yes to mean it)
@@ -379,7 +381,7 @@ if (!command || command === "--help" || command === "-h") {
379
381
  // `--value` as the account's argument and stored an empty secret; `--force
380
382
  // ./dir` swallowed the directory. A flag followed by another flag is also
381
383
  // boolean, so an unlisted switch at least does not eat its neighbour.
382
- const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library", "clear", "security", "changed", "write-baseline", "no-baseline", "rules", "calls", "tree", "tool-error", "stop"]);
384
+ const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library", "clear", "security", "changed", "write-baseline", "no-baseline", "rules", "calls", "tree", "tool-error", "stop", "last"]);
383
385
  // Flags said more than once collect into a list: `--tag a --tag b`.
384
386
  const REPEATABLE = new Set(["tag", "expect", "header", "site", "add", "remove", "rule"]);
385
387
  // `--wait` is a switch everywhere but `flow add <flow> wait`, where it takes
@@ -491,7 +493,11 @@ function pinned() {
491
493
  return null;
492
494
  }
493
495
 
494
- const workspace = pinned() ?? here;
496
+ // `init` makes a new account in the directory it is given. It never resolves
497
+ // to a workspace above it: a home directory holding some unrelated `agents/`
498
+ // (a git repo of that name) passes the workspace test, and `init` anywhere
499
+ // under it used to scaffold into the home directory itself (2026-10-21).
500
+ const workspace = (command === "init" ? null : pinned()) ?? here;
495
501
  if (!ACCOUNT_WIDE.has(command)) process.env.FOLDRUN_WORKSPACE = workspace;
496
502
  // The account scope, decided once here and honoured by the core's
497
503
  // singleAccountRoot — so `library/` beside `workspaces/` is what an agent's
package/docs/agents.md CHANGED
@@ -43,6 +43,28 @@ by deleting its folder and redeploying.
43
43
  Only `name` and the body are required. Every other field widens or narrows
44
44
  what the agent can reach, and the default is narrow.
45
45
 
46
+ ### Grouped by job
47
+
48
+ The fields that govern an agent or guard it may be grouped under the job
49
+ they do. `governance:` holds `owner` and `budget`; `guardrail:` holds
50
+ `limits`, `network`, `hooks`, `disallowedTools`, `permissionMode` and
51
+ `secrets`:
52
+
53
+ ```yaml
54
+ ---
55
+ name: researcher
56
+ tools: [web, read]
57
+ governance:
58
+ owner: ana@acme.io
59
+ guardrail:
60
+ network: [api.example.com]
61
+ limits: {web.search: 40, repeat: 5}
62
+ ---
63
+ ```
64
+
65
+ It reads exactly as the same fields at the top. A field written both ways is
66
+ refused. See [The four jobs](four-jobs).
67
+
46
68
  ## Which model, and how hard it thinks
47
69
 
48
70
  | field | values | |
package/docs/api.md CHANGED
@@ -179,7 +179,7 @@ history with each message.
179
179
  | `/api/workspaces/<ws>/export` | GET | this workspace, one flow or one agent as a `.zip` — `?kind=flow&name=<flow>` (the flow, every flow it runs as a step, every agent those steps and their colleagues name, and the workspace tools, skills and scripts they grant), `?kind=agent&name=<agent>` (its folder and what it grants), or neither for the whole authored tree. Never memory/, state/, outputs/, storage, runs or a secret's value; `foldrun-package.json` inside says what it is. Any role that can read the workspace |
180
180
  | `/api/workspaces/<ws>/import` | POST | take a package in; the body is the `.zip` (`content-type: application/zip`, at most 20 MB). `?dryRun=1` answers `{ kind, name, from, creates, added, overwritten, unchanged, needs: { secrets, tools, skills, scripts, agents, flows } }` plus `skipped` (entries it will not write: memory, state, anything outside the authored folders) and writes nothing. Without it, applies as one revision; 409 when it would replace a file with different text unless `?overwrite=1`, or when a file cannot be written there (all or nothing). Never deletes. A `Content-Length` is required (411 without). A workspace package into a name that does not exist makes that workspace (`workspace:manage`, the plan's workspace count). 400 for anything not a package, or one holding memory/, state/ or outputs/. Idempotent |
181
181
  | `/api/workspaces/<ws>/evals` | GET, POST | list; create (`name`) |
182
- | `/api/workspaces/<ws>/evals/<eval>/run` | POST | run one eval |
182
+ | `/api/workspaces/<ws>/evals/<eval>/run` | GET, POST | POST runs one eval and answers with its result; with `{ async: true }` it answers `202 { started, eval, runId, startedAt }` at once and the eval runs on — an eval of slow cases outlives a proxy's read timeout (Cloudflare's is 125 s by default). One run of an eval at a time: a second is `409` naming the run that holds it. `GET ?run=<runId>` is what to poll — that run alone, whatever else runs or writes the eval's latest result meanwhile: `{ runId, state, running }` where `state` is `running`, `done` (with its `result`), `failed` (with `error`), `lost` (it stopped reporting before saving a result, e.g. a restart) or `unavailable` (an id not on record — the last 20 runs are kept). Plain `GET` answers who holds the eval now (`running`, `runId`, `startedAt`) and the latest `result`. The dashboard and `foldrun eval --to` start async and poll by run id |
183
183
  | `/api/workspaces/<ws>/tools/<tool>/test` | POST | exercise one tool (`args`, `path`). An http tool with an `operations:` allowlist is probed only with a GET to one of its operations (no `path`: the first GET without parameters), and fails if the allowlist resolves nothing, as a run does |
184
184
  | `/api/workspaces/<ws>/hooks` | GET | the [hooks](hooks) this workspace can turn on — its own and the library's (shadowing marked), invalid ones with the reason, and who turns each on — plus `recent`: every deny, rewrite and failure hooks wrote in the last `?runs=` runs (1–500, default 50), newest first; `?all=1` includes notes and context |
185
185
  | `/api/workspaces/<ws>/hooks/<name>/test` | POST | run one [hook](hooks) on a sample event: body `{ event }`, answer the hook's own (`ok`, `decision`, `reason`, `input`, `context`, `reply`, `note`, `failure`, `ms`). Shares the `hooks/` path with flow webhooks below — `/test` is only ever a hook's. Answers `404` for no such hook, `422` for an invalid `hook.md`, `400` for an event that is not an object. On an isolated install hook code never runs in the platform's own process, so this answers with where to test it instead (`foldrun hook test` locally, or a test run) |
@@ -343,6 +343,7 @@ Reads return which secrets exist, never their values.
343
343
  | `/api/issues/<id>` | GET POST | one issue; POST acts on it: `{ action: "acknowledge" | "snooze" | "resolve" | "reopen", for?: "30m" | "4h" | "1d" | "1w", until?: ISO, times?: n, note? }` (editor or above), kept in its history with who did it, and answers `{ issue }` as it now stands. A snooze needs `for`, a future `until`, or `times` (until it happens that many more times; with a time too, whichever comes first). A platform issue is the account's: closing it needs access to every workspace. A run that goes wrong the same way after a resolve opens the issue again |
344
344
  | `/api/account/export` | GET | the owner's copy of what the platform holds about the account, as one JSON file (`foldrun-<account>-export.json`): `workspaces` (with their flows), `members` (email, role, scope, since), `runs` (an index — id, workspace, flow, status, times, headline) and the whole `ledger` (each line's charge; the platform's provider cost is left out). `billing:manage`, so the owner; audited as `account.exported`. The markdown itself comes from `foldrun pull` or the source API |
345
345
  | `/api/account/telemetry/test` | POST | send one made-up run as an OpenTelemetry trace to the collector `telemetry:` names — the account's, or `{ workspace }`'s — with its header secrets filled from the vault; `{ ok, status?, error?, spans?, endpoint }`. `409` when no `telemetry:` is set. Needs `workspace:manage` |
346
+ | `/api/account/jobs?workspace=` | GET | the four jobs (governance, guardrail, observability, evaluation) and each `AGENTS.md` field of each: `{ jobs: [{ name, title, what, fields: [{ key, account, workspace?, combine, writable }] }] }` — the account's value and the workspace's when `workspace` is given (null: unset), as written with blocks folded; `combine` is how the two meet at run time (`workspace replaces`, `either turns it on`, `both apply`, `per key`, `per field`, `level nearest, sites from both`, `account only`); `writable` is whether that scope's settings PATCH (`/api/account`, or the workspace's) writes it — the rest are written in `AGENTS.md`. See [The four jobs](four-jobs) |
346
347
  | `/api/account/pause` | GET, POST, DELETE | the account's kill switch: the same as a workspace's `/pause`, for every workspace at once. GET `{ paused, account, workspaces }` (`workspaces`: those paused on their own). POST `{ reason?, stop?: true }`, DELETE resumes the account — a workspace paused on its own stays paused. Needs `workspace:manage` and access to every workspace |
347
348
  | `/api/account/close` | POST | the owner asks to close the account — in person, never a key, with `confirm` set to the account's name and an optional `reason`. Not a delete: the account is suspended at once (runs in flight finish, sign-in and the export still work), `closeRequested` is set on its admin state, and the super admin is mailed to settle the last invoice and any refund. Returns `{ ok, suspended, notified }`; 409 when already asked. Hosted installs only |
348
349
  | `/api/account/backups` | GET | what is backed up for this account: `policy` (`schedule`, `keepLocal`, `keepOffsiteDays`, `encrypted`, `covers`, `excludes`), `last`, `backups` (each nightly archive that holds this account, newest first: `id`, `at`, `bytes` (the encrypted object), `sha256` (the archive inside the encryption — what a decrypt must reproduce), `kinds` — files, database, keys — `verified`, `offsite`, `keepDays`, `encrypted` (sealed with age before it left the server; false for archives taken before October 2026), `recipient` (the key's fingerprint, `sha256:<16 hex>`, or null)), `requests` (earlier restore requests) and `workspaces` (`name`, `head`, `history`, `lastChange`) — what a restore can reach. See [Backups and restore](backups-and-restore.md) |
@@ -350,7 +351,7 @@ Reads return which secrets exist, never their values.
350
351
  | `/api/account/api` | GET, PATCH | how this account meets the API — GET `{ version: { current, pinned, effective, supported, supportMonths }, limits: { plan, windowSeconds, perCredential: { read, write }, perAccount: { read, write } }, usage }`. `usage` is the last 30 days — `{ days, versions: [{ version, requests, lastSeen }], deprecated: [{ route, requests, lastSeen, deprecatedAt, sunsetAt, link, callers: [{ credential, requests, lastSeen }] }] }`, `credential` naming a key by its label and prefix — or `null` when it could not be read; PATCH `{ version: "YYYY-MM-DD" \| null }` pins the version API-key requests without a `Foldrun-Version` header get (null: always current); the dashboard always gets current. `keys:manage`. See [api-usage](api-usage.md) |
351
352
  | `/api/account/flags` | GET | the feature flags as they stand for this account — `{ flags: [{ name, description, owner, default, on, source, rollout }] }`, `source` being what decided it: `account` (an override for this account), `global` (the install-wide setting), `rollout` (a percentage of accounts, by a stable hash of account and flag; `rollout` is the percentage) or `default`. Read-only, every role; only the super admin changes a flag. See [Feature flags](feature-flags.md) |
352
353
  | `/api/openapi.json` | GET | this API as an OpenAPI 3.1 document — every route and method, bearer auth, the rate-limit, version and Idempotency-Key headers, and request/response schemas for runs, flow runs, deploy, source, secrets, keys and billing. No key needed. Generated from the route files by `scripts/gen-openapi.mjs`; the dashboard renders it at Docs → API reference, `foldrun api spec` saves it |
353
- | `/api/account` | GET, PATCH | the account's defaults from its AGENTS.md frontmatter — `timezone`, `budget` (a USD cap — a number for a month, `"60/day"`, `"60/week"`, or `{ usd, period }`; null is no limit), `notify` (`{ url?, email?, events }`), `concurrency`, `deploy_block` (`none`, `secrets` or `security` — answered as `deployBlock`), `network_enforce` (answered as `networkEnforce`), `telemetry` (`{ otlp, headers }` — header values are `${SECRET}` names; answered as `{ endpoint, headers }`); PATCH sets the keys sent, null clears one, and edits the file in place |
354
+ | `/api/account` | GET, PATCH | the account's defaults from its AGENTS.md frontmatter — `timezone`, `budget` (a USD cap — a number for a month, `"60/day"`, `"60/week"`, or `{ usd, period }`; null is no limit), `notify` (`{ url?, email?, events }`), `concurrency`, `deploy_block` (`none`, `secrets` or `security` — answered as `deployBlock`), `network_enforce` (answered as `networkEnforce`), `owner`, `alerts` (as written), `judge` (`{ model?, effort? }`), `telemetry` (`{ otlp, headers }` — header values are `${SECRET}` names; answered as `{ endpoint, headers }`); PATCH sets the keys sent, null clears one, and edits the file in place |
354
355
  | `/api/audit?since&until&action&actor&workspace&limit&cursor&format` | GET | the account's audit log, newest first — the caller's account only, whatever a header, query or body names. The owner and unscoped admins (people, or `admin` keys); `audit:read`, and refused to a member or key scoped to some workspaces, because the log is the whole account's. `since`/`until`: `7d`, `24h`, `30m` or a date; `action`: exact (`apikey.created`) or a family (`apikey`); `actor`: exact (an email, `key:<label>`, `support:<email>`); `workspace`: entries about one; `limit` 1–1000 (100; 1000 for CSV); `cursor`: the `next` of the page before. JSON `{ entries: [{ id, at, actor, action, subject, detail }], next, retention }`; `format=csv` downloads the same rows (`x-next-cursor` header when there are more). Records sign-ins and refusals, sign-outs, session revokes, password and email changes, MFA on/off, key mint/rotate/revoke, invites, joins, role/scope changes, removals, ownership transfers, secrets set/deleted (by name — never a value), deploys, workspace deletes, plan and billing changes, export and close requests, and `support.viewed`/`support.ended`. The super admin's own levers (`operator.*`) are the platform's record and not listed. Kept for the life of the account |
355
356
  | `/api/avatars/<kind>/<id>` | GET, PUT, DELETE | a picture for a `user` (their own id), a `workspace` (its name) or the `account` (the tenant). PUT the bytes of a PNG, JPEG or WebP under 1 MB; a person sets their own, an admin a workspace's or the account's. GET is cached for a year — the URL's `?v=` changes with the picture |
356
357
  | `/api/search?q=` | GET | find anything in the account by name — workspaces, agents, flows, tools, skills, knowledge, memory, runs (by id, flow or headline); ranked, at most six per kind. The dashboard's Find palette reads it |
@@ -25,7 +25,7 @@ mistake the editor names rather than a cap it quietly keeps.
25
25
  When an agent's cap and the flow's both apply to one step, the tighter one
26
26
  wins, and the step's error says which line set it: `budget: in the flow
27
27
  file` or `budget: on the writer agent`.
28
- | **runs at once** | `concurrency: 3` in the account's `AGENTS.md`, or Settings → Defaults | the fourth run waits its turn in the queue — never refused, only later. Empty means the install's share (`FOLDRUN_ACCOUNT_CONCURRENCY`) or plan number (`FOLDRUN_PLAN_CONCURRENCY`, which applies only where billing is on), or no cap. Your own number can lower that, never raise it; a higher number comes with a plan |
28
+ | **runs at once** | `concurrency: 3` in the account's `AGENTS.md`, or Settings → Governance | the fourth run waits its turn in the queue — never refused, only later. Empty means the install's share (`FOLDRUN_ACCOUNT_CONCURRENCY`) or plan number (`FOLDRUN_PLAN_CONCURRENCY`, which applies only where billing is on), or no cap. Your own number can lower that, never raise it; a higher number comes with a plan |
29
29
  | **the queue lane** | the plan's lane (`high` \| `normal` \| `low`); `priority: low` in the account's `AGENTS.md` lowers it, and `high` there counts only with a plan that gives it | when slots are short, accounts take turns, weighted by lane — high 3, normal 2, low 1: every account's first run goes before anyone's second, a fast lane takes three turns to a slow lane's one and never all of them, an account with runs going waits behind one with none, and a run that has waited ten minutes moves up a turn. A flow's own `priority:` orders runs within that. The cap above still holds |
30
30
  | **room to start** | the install's `FOLDRUN_RUN_MEMORY_GIB` and `FOLDRUN_ACCOUNT_PODS` | a step whose sandbox would not fit, or whose account already has its most sandboxes running, waits to start — `room: waiting to start — …` on its trail — instead of failing. The wait is not billed; the run page's start-up chip shows it as `room`. A step still waiting after 30 minutes (`FOLDRUN_ROOM_WAIT_SEC`) fails, saying why |
31
31
 
package/docs/checks.md CHANGED
@@ -81,7 +81,7 @@ listed with it. `deploy_block:` in `AGENTS.md` turns them into refusals:
81
81
  | `security` | any security finding: `secret/*`, `content/*`, `security/*`, `supply-chain/*`, and `flow/outward-ungated` (a step that acts outside with no check or approval) |
82
82
 
83
83
  Set it in the account's `AGENTS.md` for every workspace, or in one
84
- workspace's `AGENTS.md`, which wins. Settings → Defaults → **Refuse a
84
+ workspace's `AGENTS.md`, which wins. Settings → Governance → **Refuse a
85
85
  deploy on**, or `foldrun account set deploy_block secrets`. A refused
86
86
  deploy names each finding and its rule; a `foldrun-ignore` with a reason
87
87
  (below) still accepts one.
@@ -351,6 +351,21 @@ place in `@foldrun/core` (`frontmatter-fields.ts`). The editor's completions
351
351
  come from the same list, so what the editor suggests and what `check`
352
352
  accepts cannot drift apart.
353
353
 
354
+ ### config/conflict
355
+
356
+ *error.* A field written twice: at the top of the frontmatter and inside its
357
+ job block (`limits:` and `guardrail: {limits: …}`). Only the top one would be
358
+ read, so the file is refused until one is removed. See
359
+ [The four jobs](four-jobs).
360
+
361
+ ### config/misplaced
362
+
363
+ *error.* A setting inside the wrong job's block — `network:` under
364
+ `governance:` — is not read, so a protection you wrote would not apply; the
365
+ file is refused until it moves. The message names the block it belongs in, or
366
+ says to write it at the top when it does none of the four jobs. A key foldrun
367
+ does not read at all, inside a block, is only a note.
368
+
354
369
  ### config/invalid-frontmatter
355
370
 
356
371
  *warn.* Frontmatter that YAML cannot read. The commonest cause is a value
@@ -381,10 +396,10 @@ filtered, ignored and baselined. Each problem keeps its own level.
381
396
  | Rule | Covers |
382
397
  |---|---|
383
398
  | `agent/config` | frontmatter that cannot be read; a tool, skill, colleague, sub-agent or hook that does not exist; retired tool names |
384
- | `flow/config` | a step naming nothing, an option the parser cannot read, `verify:` written as prose, an arrangement that cannot have what its instruction asks for |
399
+ | `flow/config` | a step naming nothing, an option the parser cannot read, `verify:` written as prose, a `verify: matches:` that is not a valid JavaScript regular expression, an arrangement that cannot have what its instruction asks for |
385
400
  | `tool/config` | a `run:` program that is missing, an import nothing declares, a leftover `type:` |
386
401
  | `skill/config` | a `SKILL.md` without a usable name or description |
387
- | `eval/config` | an eval that names nothing, or has no cases |
402
+ | `eval/config` | an eval that names nothing, has no cases, or has a `matches:` that is not a valid JavaScript regular expression |
388
403
  | `hook/config` | a hook folder that is not a valid hook; a `hooks:` name that resolves to nothing |
389
404
  | `knowledge/okf` | a knowledge or memory document that is not a conformant bundle entry, an unreadable date, a stale document |
390
405
  | `workspace/config` | an `AGENTS.md` setting that cannot be read; a path a deploy refuses |
package/docs/cli.md CHANGED
@@ -70,7 +70,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
70
70
  | `foldrun hook test <name>` | run one hook HERE on one event, exactly as a step runs it (the event on stdin, a stripped environment, its `timeout:`), and print its answer — decision, reason, a rewritten input, context, reply or note — and how long it took. `--event '<json>'` or `--event @file`; without it, a sample event for the hook's `on:`. A deny is an answer (exit 0); a hook that crashes, times out or answers in the wrong shape is a failure (exit 1) — which, on a gate, refuses the call. `--to <workspace>` runs the DEPLOYED hook on the platform instead (`POST …/hooks/<name>/test { event }`) |
71
71
  | `foldrun hook rm <name>` | delete a hook folder (`--library` for the account's). Says which agents and AGENTS.md files still turn it on — their steps are refused until it goes from their `hooks:` — and asks; `--yes` on a pipe |
72
72
  | `foldrun run <target>` | run an agent or a flow HERE, on this machine (`name`, or `flow:name`) — `--test` for a [test run](runs#test-runs). On a platform: `flow run`/`invoke` for a flow, `agent run` for one agent |
73
- | `foldrun eval [name]` | one eval, or all of them. `--to <workspace>` runs the DEPLOYED workspace's evals on the platform (`POST …/evals/<name>/run`), waiting for each verdict, and prints the same lines; exits 1 when a case fails |
73
+ | `foldrun eval [name]` | one eval, or all of them. `--to <workspace>` runs the DEPLOYED workspace's evals on the platform (`POST …/evals/<name>/run` with `async: true`, then polling `GET …/run?run=<id>` — an eval longer than a proxy's timeout still gets its own verdict), waiting for each (`--timeout <s>`, default 900; past it, the run id to read it with). `--run <id>` prints that run's verdict and runs nothing — a run that was lost or is unknown exits 1; `--last` prints each eval's latest result and runs nothing, and prints the same lines; exits 1 when a case fails |
74
74
  | `foldrun eval new <name>` | the Evals page's New, on the deployed workspace (`POST …/evals { name }`): an eval whose `agent:` is the workspace's first agent and whose case is a placeholder — edit it (`source cat`/`put`), then `foldrun eval <name> --to <workspace>` runs it. `--to <workspace>` |
75
75
  | `foldrun promote <run-id>` | keep a finished run as a regression case: its task and what the next run must still say go into `evals/<target>-regressions.md` (or `--eval <name>`), appended when the file exists. `--expect "contains: $34"` — repeatable, eval syntax — replaces the default judge line that holds the next run to the recorded conclusion; `--case <name>` names the case. A run that went wrong (failed, BLOCKED, BAD) takes `--should "what it should have done"` — the judge's claim — or `--expect` |
76
76
  | `foldrun probe <model>` | can this model hold a tool loop here? A live check |
@@ -142,8 +142,9 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
142
142
  | `foldrun billing top-up <usd>` | the Payment page's top-up: Stripe's checkout page for that amount, opened in the browser (`--no-browser`, or no terminal, prints it). The credit lands when Stripe confirms the payment. `billing card` is the same for saving a card for auto top-up. Both need `billing:manage` |
143
143
  | `foldrun billing credit <usd>` | put credit on the balance by hand (`POST /api/billing { usd, note }`, `--note "…"` on the ledger line) — only on an install without Stripe, a self-hosted or dev platform; once billing is live the platform refuses it and top-ups go through `billing top-up`. The dashboard's Billing → Add credit does the same while billing is off. Needs `billing:manage` |
144
144
  | `foldrun gallery` | the tools the platform ships to every account (`web`), and for each whether this account keeps its own copy and whether that copy still matches. `pull` lays the shelf down under `~/.foldrun/gallery/<host>/` for offline runs (`foldrun run` and `foldrun eval` refresh it themselves before each run); `upgrade <tool>` replaces the account's copy with the current gallery version, the old one a revision back |
145
- | `foldrun account` | the account's own defaults — the AGENTS.md frontmatter every workspace under it inherits: `timezone`, `notify`, `budget`, `concurrency`, `deploy_block`, `network_enforce`. `foldrun account set timezone Australia/Sydney`, `set notify email you@example.com` (`--events failed,awaiting-approval,completed`), `set notify url https://…`, `set budget 60/month`, `set concurrency 4`, `set deploy_block secrets` (`none`, `secrets` or `security` — what the scanner's findings do to a deploy), `set network_enforce on` (every workspace refuses a site off an agent's `network:` list; `off` reports only), `pause` (`--reason "…"`, `--stop` to also stop every run going now — asks, or `--yes`) and `resume`: the kill switch, no new run starts in any workspace while paused, `telemetry` — where each finished run goes as an OpenTelemetry trace: bare shows it, `set <url> --header authorization='${OTEL_TOKEN}'` (repeatable; a value is a vault secret's name), `clear`, `test` (one made-up run, and what the collector answered; exit 1 when not delivered), and `clear <key>` to unset one. `set notify` merges against what is there, because the platform replaces the whole block. `providers` lists the model providers the account's files use and the last daily key check — `--check` asks now, and a dead key is a non-zero exit. `model` is the account's own model key, what every step runs on unless a `provider:` block names another: `foldrun account model` shows it (never the key, only its last four; exit 1 when there is none and runs are refused), `model set <provider> --key <api key>` sets it — a preset name (anthropic, openrouter, openai, groq, …) or `custom --base-url https://… --format anthropic|openai`, `--key -` reads the key from stdin — and `model remove` removes it; a Claude login token is refused for any account not allowed the platform's own credential. `export` downloads everything the platform holds about the account — workspaces, runs, members and the whole ledger — as one JSON file (owner only; `--file <path>`, `--force`). Singular — `accounts` below is the list of logins on this machine |
146
- | `foldrun workspace` | one deployed workspace's own settings (`--to <workspace>`): description, timezone, budget and notify, read from its `AGENTS.md` — unset means the account's. `set timezone <tz>`, `set budget 20/week`, `set notify email <address>` / `set notify url <https://…>` (`--events`; merged with what the file says), `set network_enforce on` (refuse a site off an agent's `network:` list here; `off` or `clear` goes back to the account's), `pause` (`--reason`, `--stop`) and `resume` — this workspace's kill switch, `set description "…"`, `set name <new>` — a rename changes every webhook URL, so it asks first and names the flows whose URLs changed — and `clear <key>`. Singular — `workspaces` is the list |
145
+ | `foldrun jobs` | the four jobs — governance, guardrail, observability, evaluation — and every `AGENTS.md` field of each, with what is set (`GET /api/account/jobs`). `--to <workspace>` shows the workspace's value beside the account's and how the two combine; `--json` the document. `account set <job>.<field>` and `workspace set <job>.<field>` refuse a field that scope cannot set and name the `AGENTS.md` to write it in. See [The four jobs](four-jobs) |
146
+ | `foldrun account` | the account's own defaults — the AGENTS.md frontmatter every workspace under it inherits: `timezone`, `notify`, `budget`, `concurrency`, `deploy_block`, `network_enforce`. `foldrun account set timezone Australia/Sydney`, `set notify email you@example.com` (`--events failed,awaiting-approval,completed`), `set notify url https://…`, `set budget 60/month`, `set concurrency 4`, `set deploy_block secrets` (`none`, `secrets` or `security` — what the scanner's findings do to a deploy), `set network_enforce on` (every workspace refuses a site off an agent's `network:` list; `off` reports only), `set owner <email or name>`, `set alerts <threshold> <value>` (merged into what is set; `off` removes one), `set judge model|effort <value>`. Any setting may be named by its job — `set governance.deploy_block secrets`, `set observability.alerts stale_days 30` — and one named under the wrong job is refused with the right one, `pause` (`--reason "…"`, `--stop` to also stop every run going now — asks, or `--yes`) and `resume`: the kill switch, no new run starts in any workspace while paused, `telemetry` — where each finished run goes as an OpenTelemetry trace: bare shows it, `set <url> --header authorization='${OTEL_TOKEN}'` (repeatable; a value is a vault secret's name), `clear`, `test` (one made-up run, and what the collector answered; exit 1 when not delivered), and `clear <key>` to unset one. `set notify` merges against what is there, because the platform replaces the whole block. `providers` lists the model providers the account's files use and the last daily key check — `--check` asks now, and a dead key is a non-zero exit. `model` is the account's own model key, what every step runs on unless a `provider:` block names another: `foldrun account model` shows it (never the key, only its last four; exit 1 when there is none and runs are refused), `model set <provider> --key <api key>` sets it — a preset name (anthropic, openrouter, openai, groq, …) or `custom --base-url https://… --format anthropic|openai`, `--key -` reads the key from stdin — and `model remove` removes it; a Claude login token is refused for any account not allowed the platform's own credential. `export` downloads everything the platform holds about the account — workspaces, runs, members and the whole ledger — as one JSON file (owner only; `--file <path>`, `--force`). Singular — `accounts` below is the list of logins on this machine |
147
+ | `foldrun workspace` | one deployed workspace's own settings (`--to <workspace>`): description, timezone, budget and notify, read from its `AGENTS.md` — unset means the account's. `set timezone <tz>`, `set budget 20/week`, `set notify email <address>` / `set notify url <https://…>` (`--events`; merged with what the file says), `set network_enforce on` (refuse a site off an agent's `network:` list here; `off` or `clear` goes back to the account's), `set owner`, `set alerts <threshold> <value>`, `set judge model|effort <value>`, and `<job>.<field>` as on `account`, `pause` (`--reason`, `--stop`) and `resume` — this workspace's kill switch, `set description "…"`, `set name <new>` — a rename changes every webhook URL, so it asks first and names the flows whose URLs changed — and `clear <key>`. Singular — `workspaces` is the list |
147
148
  | `foldrun workspace vocabulary` | what the dashboard's editor completes as you type in a workspace (`--to`): agents, flows, skills, tools, secrets (names only), scripts, document types and linkable documents (`GET …/vocabulary`; `--json`) |
148
149
  | `foldrun notify test` | send one test notification to whatever the workspace's `notify:` resolves to, and print what happened — the provider's own words when it failed ("domain not verified"). Exits 1 when it did not go. `--to <workspace>` |
149
150
  | `foldrun webhooks deliveries` | the `notify:` webhooks a workspace sent, newest first: event, status (`delivered`, `retrying`, `failed`, `skipped`), attempts, the last answer and the delivery id — and a switched-off endpoint first, with the way back. `--failed` for the ones that gave up, `--status <s>`, `--event <e>`, `--limit`, `--offset`, `--json`. `--to <workspace>` |
package/docs/evals.md CHANGED
@@ -33,7 +33,7 @@ satisfy. The heading is the case's name, so make it the thing being checked.
33
33
  |---|---|
34
34
  | `name` | the eval's identity |
35
35
  | `agent` **or** `flow` | what runs. A flow eval runs the whole flow per case |
36
- | `model` | the **judge's** tier, not the subject's — cases run the agent on its own model |
36
+ | `model` | the **judge's** tier, not the subject's — cases run the agent on its own model. Unset, the workspace's `judge:` in `AGENTS.md` (see [The four jobs](four-jobs#judge--the-evaluation-default)), else the fast tier |
37
37
  | `effort` | the judge's effort. PASS/FAIL against one sentence is not deep work |
38
38
  | `trigger` | `deploy` (default) or `manual` |
39
39
  | `inputs` | `true` makes the file a flow's saved inputs, not a test: its cases are named tasks for [Run with…](flows#run-with-saved-inputs) and `foldrun invoke --inputs`, and nothing runs them as an eval |
@@ -45,7 +45,7 @@ satisfy. The heading is the case's name, so make it the thing being checked.
45
45
  |---|---|
46
46
  | `contains:` | the text must appear |
47
47
  | `not-contains:` | the text must not appear |
48
- | `matches:` | a regular expression over the reply |
48
+ | `matches:` | a JavaScript regular expression over the reply, case-insensitive. No inline flags such as `(?m)`: write `(^\|\n)` for the start of a line. `foldrun check` reports one that does not compile |
49
49
  | `file:` | the path exists and is non-empty |
50
50
  | `run:` | a shell command that must exit 0 |
51
51
  | `judge:` | a model grades the reply against this sentence |
package/docs/flows.md CHANGED
@@ -291,7 +291,7 @@ decided by a number belongs in a command that can be tested:
291
291
  verify: judge: quotes the real price
292
292
  ```
293
293
 
294
- `contains:`, `not-contains:` and `matches:` test the step's **conclusion** —
294
+ `contains:`, `not-contains:` and `matches:` (a JavaScript regular expression, case-insensitive; no inline `(?m)`, write `(^|\n)` for a line start) test the step's **conclusion** —
295
295
  its final reply, the same text the run's headline is read from — so a step
296
296
  that narrated between tool calls and then opened its answer with `BAD — …`
297
297
  passes `matches: ^BAD\b`. `judge:` grades the same conclusion as "the reply", with everything the step wrote before it shown alongside as context, so a claim about the work can still be checked.
@@ -492,6 +492,15 @@ before `low`. It adds to the account's lane, so a high flow on a low account
492
492
  is normal. The account's `concurrency:` still bounds how many of its runs are
493
493
  live at once.
494
494
 
495
+ ## Grouped by job
496
+
497
+ A flow's frontmatter may group its fields by the job they do:
498
+ `governance:` (`owner`, `approvers`, `approve_within`, `budget`, `priority`),
499
+ `guardrail:` (`throttle`, `debounce`, `overlap`, `disable_after`, `live`) and
500
+ `observability:` (`issues`, `sla`). It reads exactly as the same fields at
501
+ the top; a field written both ways is refused. Step options stay under their
502
+ step. See [The four jobs](four-jobs).
503
+
495
504
  ## Model resolution
496
505
 
497
506
  Nearest wins: the step, then the flow, then the agent's own frontmatter. The
@@ -0,0 +1,95 @@
1
+ # The four jobs
2
+
3
+ Running agents is four jobs, and every setting that controls them does one:
4
+
5
+ | Job | What it decides |
6
+ |---|---|
7
+ | **Governance** | who answers for it, and what it may spend, decide and ship |
8
+ | **Guardrail** | what it may reach and call, and what stops it mid-run |
9
+ | **Observability** | what is recorded, and who is told |
10
+ | **Evaluation** | how its work is judged |
11
+
12
+ These are the same settings foldrun has always had. You can write each one at
13
+ the top of the file, as before, or group them under the job they do:
14
+
15
+ ```yaml
16
+ ---
17
+ name: blog-desk
18
+ governance:
19
+ owner: ops@acme.io
20
+ budget: 60/month
21
+ deploy_block: secrets
22
+ guardrail:
23
+ limits: {calls: 300, repeat: 5}
24
+ network: trusted
25
+ observability:
26
+ notify: {email: ops@acme.io, events: [failed]}
27
+ alerts: {failure_rate: 30%, new_host: true}
28
+ evaluation:
29
+ judge: {model: fast}
30
+ ---
31
+ ```
32
+
33
+ reads exactly as `owner:`, `budget:`, `limits:` and the rest written at the
34
+ top. Either way works, and the two can be mixed in one file.
35
+
36
+ ## Which field belongs to which job
37
+
38
+ | File | `governance:` | `guardrail:` | `observability:` | `evaluation:` |
39
+ |---|---|---|---|---|
40
+ | `AGENTS.md` | `owner`, `budget`, `priority`, `concurrency`, `deploy_block` | `limits`, `network`, `network_enforce`, `hooks` | `alerts`, `telemetry`, `notify`, `capture` | `judge` |
41
+ | an agent | `owner`, `budget` | `limits`, `network`, `hooks`, `disallowedTools`, `permissionMode`, `secrets` | — | — |
42
+ | a flow | `owner`, `approvers`, `approve_within`, `budget`, `priority` | `throttle`, `debounce`, `overlap`, `disable_after`, `live` | `issues`, `sla` | — |
43
+
44
+ A field that does none of the four, such as `name`, `model`, `tools`,
45
+ `trigger` or `schedule`, is written at the top. Step options in a flow, such
46
+ as `verify:` and `limits:` under a step, stay where they are.
47
+
48
+ ## The rules
49
+
50
+ - **One place, not both.** A field written at the top *and* inside its block
51
+ is refused: `foldrun check` and a deploy stop on
52
+ [`config/conflict`](checks#config-conflict) until one is removed.
53
+ - **The right block.** A setting inside another job's block is not read —
54
+ a guardrail under `governance:` would guard nothing — so check refuses it
55
+ and says which block it belongs in
56
+ ([`config/misplaced`](checks#config-misplaced)).
57
+ - **Editing keeps the form.** Settings, the CLI and the flow canvas edit a
58
+ field where it is written, inside its block or at the top, and add a new
59
+ one at the top. A block written on one line (`guardrail: {network: [a.com]}`)
60
+ is not rewritten for you: the edit is refused (`409`) and asks for the block
61
+ one key per line.
62
+ - **Only the three settings files.** Blocks are read in an agent's
63
+ `agent.md`, a flow and `AGENTS.md`. In knowledge, memory, skills and every
64
+ other document, `governance:` and the rest are that document's own words
65
+ and are left as written.
66
+ - **The cascade is unchanged.** The account's `AGENTS.md`, then the
67
+ workspace's, then the agent, then the step: a block at one level and a flat
68
+ key at another combine exactly as two flat keys would.
69
+
70
+ ## `judge:` — the evaluation default
71
+
72
+ `judge: {model, effort}` in `AGENTS.md` is the model, and how hard it
73
+ thinks, for every grader in the workspace: each eval's `judge:` checks and
74
+ every flow step's `verify: judge:`. An eval that names its own `model:` keeps
75
+ it. Unset, judges run on the fast tier. Field by field, the workspace's wins:
76
+ a workspace that sets only `effort` keeps the account's `model`.
77
+
78
+ ## Where each job shows
79
+
80
+ | Job | In the dashboard | In the CLI |
81
+ |---|---|---|
82
+ | Governance | Settings → Governance: owner, budget, deploy rules, pause, team, API | `foldrun jobs`, `foldrun account set governance.<field>` |
83
+ | Guardrail | Settings → Guardrail: network enforcement; each agent's page | `foldrun check --security`, `foldrun agent network` |
84
+ | Observability | Settings → Observability: notify, alerts, telemetry; Observe, Issues | `foldrun issues`, `foldrun account telemetry` |
85
+ | Evaluation | Settings → Evaluation: the judge; the Evals page | `foldrun eval --to <workspace>` |
86
+
87
+ `foldrun jobs` prints all four; with `--to <workspace>`, each field's value
88
+ there, the account's, and how the two combine. They do not all simply
89
+ override: `network_enforce` is on when either turns it on, a workspace's
90
+ `budget` and the account's both apply, `hooks` run from both, `network`
91
+ takes its level from the nearest setting and its sites from every level, `limits` merge key by key, `judge`
92
+ field by field, and `priority` and `concurrency` are read from the account
93
+ alone. The rest — `owner`, `notify`, `alerts`, `telemetry` and others — the
94
+ workspace's replaces the account's whole. Fields the CLI cannot set (`limits`,
95
+ `network`, `hooks`, `capture`, …) are written in `AGENTS.md`.
package/docs/network.md CHANGED
@@ -144,7 +144,7 @@ The list is refused when any one of three says so:
144
144
 
145
145
  Each only switches it on. A workspace cannot turn off what its account
146
146
  turned on, and nothing in an account turns off the install's. Settings →
147
- Defaults (account) and a workspace's Settings have the switch, and so does
147
+ Guardrail, on the account's and a workspace's Settings, has the switch, and so does
148
148
  `foldrun account set network_enforce on` / `foldrun workspace set
149
149
  network_enforce on --to <workspace>`.
150
150
 
@@ -112,6 +112,11 @@ alerts:
112
112
  new_host: true # an agent reached a site it never reached before
113
113
  ```
114
114
 
115
+ A workspace's `alerts:` replaces the account's whole: once it has its own,
116
+ the account's thresholds no longer apply there, and `alerts: {}` means none
117
+ for that workspace (`new_host: false` turns off one the account has on).
118
+ Without an `alerts:` of its own it takes the account's.
119
+
115
120
  Each comes to `notify:` as event `alert` — like the three above it follows
116
121
  `failed`, or name `alert` in `events:` — at most once a day per rule, and once
117
122
  per slow run: "⚠ blog-desk: 6 of its last 15 runs failed", "⚠ blog-desk was
@@ -135,7 +140,7 @@ Three watch for behaviour that is out of character rather than broken:
135
140
  usual".
136
141
  - `runs_per_hour` — one flow started that many times in the last hour: a
137
142
  loop, a webhook sender retrying, a schedule written wrong. At most once a
138
- day per flow. `throttle:` caps it for good; Settings → Pause stops it now.
143
+ day per flow. `throttle:` caps it for good; Settings → Governance → Pause stops it now.
139
144
  - `new_host` — after each run, the sites every agent reached (from the
140
145
  egress lines on its trail) are compared with what it reached before, and a
141
146
  new one is told: "blog-desk: agent researcher reached a site it never
package/docs/runs.md CHANGED
@@ -153,7 +153,7 @@ telemetry:
153
153
  authorization: ${OTEL_TOKEN} # a vault secret, by name
154
154
  ```
155
155
 
156
- In the account's `AGENTS.md` (Settings → API → *Send runs to your
156
+ In the account's `AGENTS.md` (Settings → Observability → *Send runs to your
157
157
  tracing*, or `foldrun account telemetry set <url> --header
158
158
  authorization='${OTEL_TOKEN}'`), or a workspace's, which replaces the
159
159
  account's whole. Every run that finishes is sent as one trace: a span for
@@ -163,6 +163,11 @@ workspace's, and a workspace that does not takes the account's.
163
163
  trace; a workspace's block replaces the account's whole. See
164
164
  [Runs](runs#sending-the-trace-to-your-own-tracing).
165
165
 
166
+ The frontmatter may also group these defaults by the job they do —
167
+ `governance:`, `guardrail:`, `observability:` and `evaluation:` (where
168
+ `judge:` sets the model every grader here uses). A block reads exactly as the
169
+ same keys at the top. See [The four jobs](four-jobs) for which key goes where.
170
+
166
171
  `network_enforce: true` refuses a site off a step's [`network:`](network)
167
172
  list instead of only naming it in the trace. The account's `true` holds for
168
173
  every workspace; a workspace can add it for itself but not drop the
@@ -210,7 +215,7 @@ to the next level with one line in the trace rather than failing the step.
210
215
 
211
216
  When something is going wrong — an agent posting what it should not, a
212
217
  loop spending money — pause the workspace, or the whole account, from its
213
- Settings → Pause, `foldrun workspace pause` / `foldrun account pause`, or
218
+ Settings → Governance → Pause, `foldrun workspace pause` / `foldrun account pause`, or
214
219
  the API (`POST /api/workspaces/<ws>/pause`, `POST /api/account/pause`).
215
220
 
216
221
  While paused, nothing new starts: a schedule's fire, a webhook delivery, a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foldrun",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Agents are just folders. Write them, check them, run them — on your machine.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  "node": ">=22"
12
12
  },
13
13
  "dependencies": {
14
- "@foldrun/core": "^2.5.0"
14
+ "@foldrun/core": "^2.6.0"
15
15
  },
16
16
  "files": [
17
17
  "bin",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "e2e": "FOLDRUN_E2E=1 FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-e2e-XXXXXX) node --test --test-timeout=300000 tests/e2e.test.ts",
24
- "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-issues.test.ts tests/cli-capture.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-frontmatter.test.ts tests/cli-agent-import.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-report-cache.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts tests/cli-flags.test.ts tests/cli-api-coverage.test.ts tests/cli-hooks.test.ts tests/cli-release.test.ts tests/cli-terminal.test.ts tests/cli-network.test.ts tests/cli-assistant.test.ts tests/cli-check-scan.test.ts tests/cli-published.test.ts",
24
+ "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-issues.test.ts tests/cli-capture.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-frontmatter.test.ts tests/cli-agent-import.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-report-cache.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts tests/cli-flags.test.ts tests/cli-api-coverage.test.ts tests/cli-hooks.test.ts tests/cli-release.test.ts tests/cli-terminal.test.ts tests/cli-network.test.ts tests/cli-assistant.test.ts tests/cli-check-scan.test.ts tests/cli-published.test.ts tests/cli-init-target.test.ts",
25
25
  "release": "node scripts/release.mjs",
26
26
  "typecheck": "tsc -p tsconfig.json",
27
27
  "docs:sync": "node scripts/sync-docs.mjs",
package/src/commands.mjs CHANGED
@@ -5656,6 +5656,121 @@ async function telemetryCmd(url, args, flags) {
5656
5656
  return 0;
5657
5657
  }
5658
5658
 
5659
+ // ---------------------------------------------------------- the four jobs
5660
+ //
5661
+ // governance, guardrail, observability, evaluation — what each AGENTS.md
5662
+ // field does, as the platform maps it (GET /api/account/jobs). The CLI reads
5663
+ // the map from the platform rather than from @foldrun/core, so it always
5664
+ // matches the platform it is talking to.
5665
+
5666
+ /** GET /api/account/jobs, for the account or one workspace. */
5667
+ function jobsOf(url, flags, workspace) {
5668
+ return remoteCall(url, flags, `/api/account/jobs${workspace ? `?workspace=${enc(workspace)}` : ""}`);
5669
+ }
5670
+
5671
+ /**
5672
+ * `governance.deploy_block` → `deploy_block`, checked against the map: a
5673
+ * field written under the wrong job is refused with the right one named.
5674
+ * A bare key comes back as it was, with no request.
5675
+ */
5676
+ async function jobKey(url, flags, key, workspace) {
5677
+ if (typeof key !== "string" || !key.includes(".")) return { key, view: null };
5678
+ const job = key.slice(0, key.indexOf("."));
5679
+ const field = key.slice(key.indexOf(".") + 1);
5680
+ const view = await jobsOf(url, flags, workspace);
5681
+ const j = view.jobs.find((x) => x.name === job);
5682
+ if (!j) throw new Error(`"${job}" is not one of the four jobs — governance, guardrail, observability, evaluation`);
5683
+ const f = j.fields.find((x) => x.key === field);
5684
+ if (!f) {
5685
+ const right = view.jobs.find((x) => x.fields.some((y) => y.key === field));
5686
+ throw new Error(right ? `${field} is a ${right.name} setting, not a ${job} one — \`${right.name}.${field}\`` : `${job} has no setting "${field}" — it holds ${j.fields.map((y) => y.key).join(", ")}`);
5687
+ }
5688
+ // `writable` says what this scope's settings can write; the rest live in
5689
+ // AGENTS.md itself. A server too old to say leaves it to the PATCH.
5690
+ if (f.writable === false) {
5691
+ throw new Error(`${job}.${field} is not set from here — write \`${field}:\` in ${workspace ? `workspaces/${workspace}/AGENTS.md` : "the account's AGENTS.md"} (under \`${job}:\` or at the top) and deploy`);
5692
+ }
5693
+ return { key: field, view };
5694
+ }
5695
+
5696
+ /** The value a field has at one level (the account's, or the workspace's). */
5697
+ async function jobValue(url, flags, key, workspace, view) {
5698
+ const v = view ?? (await jobsOf(url, flags, workspace));
5699
+ for (const j of v.jobs) {
5700
+ const f = j.fields.find((x) => x.key === key);
5701
+ if (f) return workspace ? f.workspace : f.account;
5702
+ }
5703
+ return null;
5704
+ }
5705
+
5706
+ /**
5707
+ * `set alerts <threshold> <value>` and `set judge model|effort <value>` —
5708
+ * one entry merged into the block as it stands (the platform replaces a
5709
+ * block whole). `off` removes the entry. A workspace's alerts, once its own,
5710
+ * stay its own: the last threshold removed leaves `{}` ("none here"), and
5711
+ * `new_host off` is written as false so the account's `on` does not apply;
5712
+ * `clear alerts` goes back to the account's.
5713
+ */
5714
+ async function mergedEntry(url, flags, key, args, workspace, view, how) {
5715
+ const [entry, ...words] = args;
5716
+ const raw = words.join(" ").trim();
5717
+ const allowed = key === "alerts"
5718
+ ? ["failure_rate", "min_runs", "credits_day", "run_minutes", "stale_days", "cost_spike", "runs_per_hour", "new_host"]
5719
+ : ["model", "effort"];
5720
+ if (!allowed.includes(entry)) throw new Error(`${how} <${allowed.join("|")}> <value> — not "${entry ?? ""}"`);
5721
+ if (!raw) throw new Error(`${how} ${entry} <value> — or \`${entry} off\` to remove it`);
5722
+ const current = { ...((await jobValue(url, flags, key, workspace, view)) ?? {}) };
5723
+ if (raw === "off") delete current[entry];
5724
+ else if (entry === "new_host") current.new_host = onOff("new_host", raw, `${how} new_host on`);
5725
+ else current[entry] = key === "alerts" && entry !== "failure_rate" && /^\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;
5726
+ const own = key === "alerts" && workspace;
5727
+ // A workspace's `new_host: false` is an opt-out from the account's on —
5728
+ // kept whatever else is edited; at the account false is just unset.
5729
+ if (own && entry === "new_host" && raw === "off") current.new_host = false;
5730
+ else if (!own && current.new_host === false) delete current.new_host;
5731
+ if (own) return current;
5732
+ return Object.keys(current).length ? current : null;
5733
+ }
5734
+
5735
+ /** A setting's value as one short line. */
5736
+ function shortValue(v) {
5737
+ if (v === null || v === undefined) return null;
5738
+ return typeof v === "object" ? JSON.stringify(v).replace(/"([a-z_]+)":/g, "$1: ").replace(/,/g, ", ") : String(v);
5739
+ }
5740
+
5741
+ /**
5742
+ * `foldrun jobs [--to <workspace>]` — the four jobs and every AGENTS.md
5743
+ * field of each: the account's value, the workspace's, and how the two
5744
+ * combine (they do not all simply override — network_enforce is on if
5745
+ * either turns it on, both budgets apply).
5746
+ */
5747
+ async function jobsCmd(flags, layout) {
5748
+ const url = platformFor(flags, "jobs");
5749
+ const ws = typeof flags.to === "string" ? flags.to : null;
5750
+ const view = await jobsOf(url, flags, ws);
5751
+ if (flags.json === true) {
5752
+ console.log(JSON.stringify(view, null, 2));
5753
+ return 0;
5754
+ }
5755
+ console.log(`\n ${c.bold("The four jobs")} ${c.dim(ws ? `workspace ${ws}, over the account's` : "the account — every workspace inherits these")}`);
5756
+ for (const j of view.jobs) {
5757
+ console.log(`\n ${c.bold(j.title)} ${c.dim(j.what)}`);
5758
+ const w = Math.max(...j.fields.map((f) => f.key.length));
5759
+ for (const f of j.fields) {
5760
+ const account = shortValue(f.account);
5761
+ if (!ws) {
5762
+ console.log(` ${pad(f.key, w)} ${account ?? c.dim("unset")}`);
5763
+ continue;
5764
+ }
5765
+ const here = shortValue(f.workspace);
5766
+ const how = f.combine ? c.dim(` · ${f.combine}`) : "";
5767
+ console.log(` ${pad(f.key, w)} ${here ?? c.dim("unset")}${c.dim(` account: ${account ?? "unset"}`)}${how}`);
5768
+ }
5769
+ }
5770
+ console.log(`\n ${c.dim(`set one: foldrun ${ws ? `workspace set <job>.<field> … --to ${ws}` : "account set <job>.<field> …"} — e.g. governance.deploy_block secrets`)}\n`);
5771
+ return 0;
5772
+ }
5773
+
5659
5774
  /** A GET …/pause, or null from a platform older than the kill switch —
5660
5775
  * the settings still print, without the line. */
5661
5776
  async function pauseState(request) {
@@ -5748,15 +5863,23 @@ async function accountCmd(positional, flags) {
5748
5863
  throw new Error(`unknown account verb "${verb}" — \`foldrun account\` shows them, \`set\` and \`clear\` change one, \`model\` is the account's own model key, \`providers\` checks the model keys, \`export\` downloads everything, \`pause\`/\`resume\` the kill switch`);
5749
5864
  }
5750
5865
 
5751
- const key = positional[1];
5752
- const KEYS = ["timezone", "notify", "budget", "concurrency", "deploy_block", "network_enforce"];
5866
+ // `governance.deploy_block` names the job; checked, then sent as the field.
5867
+ const { key, view } = await jobKey(url, flags, positional[1], null);
5868
+ // telemetry: is set with its own verb (an endpoint and headers, not one
5869
+ // value); `clear` works here as for any other key.
5870
+ if (key === "telemetry" && verb === "set") {
5871
+ throw new Error("telemetry has its own verb — `foldrun account telemetry set <otlp-url> [--header name=value]`; `foldrun account clear telemetry` turns it off");
5872
+ }
5873
+ const KEYS = ["timezone", "notify", "budget", "concurrency", "deploy_block", "network_enforce", "owner", "alerts", "judge", ...(verb === "clear" ? ["telemetry"] : [])];
5753
5874
  if (!KEYS.includes(key)) {
5754
- throw new Error(`which setting? \`foldrun account ${verb} <${KEYS.join("|")}> …\``);
5875
+ throw new Error(`which setting? \`foldrun account ${verb} <${KEYS.join("|")}> …\` — or <job>.<field>, e.g. governance.owner (\`foldrun jobs\` lists them)`);
5755
5876
  }
5756
5877
 
5757
5878
  let patch;
5758
5879
  if (verb === "clear") {
5759
5880
  patch = { [key]: null };
5881
+ } else if (key === "alerts" || key === "judge") {
5882
+ patch = { [key]: await mergedEntry(url, flags, key, positional.slice(2), null, view, `foldrun account set ${key}`) };
5760
5883
  } else if (key === "notify") {
5761
5884
  // `set notify email you@example.com` / `set notify url https://…`, and
5762
5885
  // --events to change what is notified on.
@@ -8440,9 +8563,50 @@ function printEvalResult(result) {
8440
8563
  * run there. Each run waits for its verdict: an eval is something you wait
8441
8564
  * for, and the platform answers when every case has.
8442
8565
  */
8566
+ /**
8567
+ * One eval on the platform, started and polled (POST …/run { async: true },
8568
+ * then GET …/run) — a long eval outlives the proxy in front of the platform,
8569
+ * which cut the old waiting request at 100 s. A platform that answers the
8570
+ * POST with the result itself (older ones) is read as before.
8571
+ */
8572
+ async function remoteEvalRun(url, flags, ws, name) {
8573
+ const path = `/api/workspaces/${enc(ws)}/evals/${enc(name)}/run`;
8574
+ const started = await remoteCall(url, flags, path, { method: "POST", body: JSON.stringify({ async: true }) }, { seconds: explicitTimeout(flags) ?? 900 });
8575
+ if (!started?.started) return started;
8576
+ console.log(` ${c.dim(`run ${started.runId}`)}`);
8577
+ const deadline = Date.now() + (explicitTimeout(flags) ?? 900) * 1000;
8578
+ for (;;) {
8579
+ if (Date.now() > deadline) {
8580
+ throw new Error(`${name} is still running after ${explicitTimeout(flags) ?? 900}s — \`foldrun eval ${name} --to ${ws} --run ${started.runId}\` reads this run's verdict when it is done, without running it again`);
8581
+ }
8582
+ await new Promise((r) => setTimeout(r, Number(process.env.FOLDRUN_EVAL_POLL_MS) || 4000));
8583
+ const view = await remoteEvalRunView(url, flags, path, started.runId);
8584
+ if (view.state === "running") continue;
8585
+ return evalRunVerdict(name, view);
8586
+ }
8587
+ }
8588
+
8589
+ /** GET …/run?run=<id> — that one run's state, and nothing else's. */
8590
+ function remoteEvalRunView(url, flags, path, runId) {
8591
+ return remoteCall(url, flags, `${path}?run=${encodeURIComponent(runId)}`);
8592
+ }
8593
+
8594
+ /** A run's view as the result printEvalResult reads — a failure, a lost
8595
+ * lease or an unknown id are an error, never a pass. */
8596
+ function evalRunVerdict(name, view) {
8597
+ if (view.state === "done" && view.result) return view.result;
8598
+ return { eval: name, passed: 0, failed: 0, cases: [], error: view.error ?? `the run ended as ${view.state ?? "unknown"}, with no result` };
8599
+ }
8600
+
8443
8601
  async function remoteEvalsCmd(name, flags, layout) {
8444
8602
  const url = platformFor(flags, "eval --to <workspace>");
8445
8603
  const ws = platformWorkspace(flags, layout, "eval");
8604
+ // --run reads one run of one eval: a bare --run, or one with no eval
8605
+ // named, would otherwise start runs or read the id against every eval.
8606
+ if (flags.run !== undefined) {
8607
+ if (typeof flags.run !== "string" || !flags.run.trim()) throw new Error("--run takes the run id the eval printed — `foldrun eval <name> --to <ws> --run <id>`");
8608
+ if (!name) throw new Error("--run reads one eval's run — name it: `foldrun eval <name> --to <ws> --run <id>`");
8609
+ }
8446
8610
  const { evals = [] } = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/evals`);
8447
8611
  const all = evals.filter((e) => !name || e.name === name);
8448
8612
  if (!all.length) {
@@ -8451,7 +8615,36 @@ async function remoteEvalsCmd(name, flags, layout) {
8451
8615
  let failed = 0;
8452
8616
  for (const info of all) {
8453
8617
  console.log(`\n ${c.bold(info.name)} ${c.dim(`${info.cases?.length ?? "?"} cases · ${ws}`)}`);
8454
- const result = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/evals/${enc(info.name)}/run`, { method: "POST" }, { seconds: explicitTimeout(flags) ?? 900 });
8618
+ // --run <id>: that run's verdict, run nothing (GET …/run?run=).
8619
+ if (typeof flags.run === "string") {
8620
+ const view = await remoteEvalRunView(url, flags, `/api/workspaces/${enc(ws)}/evals/${enc(info.name)}/run`, flags.run);
8621
+ if (view.state === "running") {
8622
+ console.log(` ${c.dim(`run ${flags.run} is still running, since ${view.startedAt}`)}`);
8623
+ failed++;
8624
+ continue;
8625
+ }
8626
+ const result = evalRunVerdict(info.name, view);
8627
+ printEvalResult(result);
8628
+ failed += Number(result.failed ?? 0) + (result.error ? 1 : 0);
8629
+ continue;
8630
+ }
8631
+ // --last: what the platform has, run nothing (GET …/run).
8632
+ if (flags.last === true) {
8633
+ const state = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/evals/${enc(info.name)}/run`);
8634
+ if (state.running) console.log(` ${c.dim(`running since ${state.startedAt} — no result yet`)}`);
8635
+ if (state.error) {
8636
+ console.log(` ${c.red("✗")} the last run could not finish — ${state.error}`);
8637
+ failed++;
8638
+ }
8639
+ if (!state.result) {
8640
+ if (!state.running && !state.error) console.log(` ${c.dim("never run")}`);
8641
+ continue;
8642
+ }
8643
+ printEvalResult(state.result);
8644
+ failed += Number(state.result.failed ?? 0) + (state.result.error ? 1 : 0);
8645
+ continue;
8646
+ }
8647
+ const result = await remoteEvalRun(url, flags, ws, info.name);
8455
8648
  printEvalResult(result);
8456
8649
  failed += Number(result.failed ?? 0) + (result.error ? 1 : 0);
8457
8650
  }
@@ -9065,8 +9258,10 @@ async function workspaceDefaults(url, flags, ws) {
9065
9258
  async function workspaceCmd(positional, flags, layout) {
9066
9259
  const url = platformFor(flags, "workspace");
9067
9260
  const ws = platformWorkspace(flags, layout, "workspace");
9068
- const [verb, key, ...rest] = positional;
9069
- const KEYS = ["name", "description", "timezone", "budget", "notify", "network_enforce"];
9261
+ const [verb, written, ...rest] = positional;
9262
+ // `observability.notify` names the job; checked, then sent as the field.
9263
+ const { key, view } = verb === "set" || verb === "clear" ? await jobKey(url, flags, written, ws) : { key: written, view: null };
9264
+ const KEYS = ["name", "description", "timezone", "budget", "notify", "network_enforce", "owner", "alerts", "judge"];
9070
9265
  const show = (d, title) => {
9071
9266
  const notify = d.notify ? [d.notify.email, d.notify.url].filter(Boolean).join(", ") + c.dim(` on ${(d.notify.events ?? []).join(", ") || "nothing"}`) : c.dim("the account's");
9072
9267
  const budget = d.budget == null ? c.dim("the account's") : typeof d.budget === "object" ? `$${d.budget.usd} per ${d.budget.period}` : String(d.budget).includes("/") ? `$${String(d.budget).replace("/", " per ")}` : `$${d.budget} per month`;
@@ -9100,7 +9295,9 @@ async function workspaceCmd(positional, flags, layout) {
9100
9295
  /** @type {Record<string, any>} */
9101
9296
  let patch;
9102
9297
  if (verb === "clear") patch = { [key]: key === "description" ? "" : null };
9103
- else if (key === "notify") {
9298
+ else if (key === "alerts" || key === "judge") {
9299
+ patch = { [key]: await mergedEntry(url, flags, key, rest, ws, view, `foldrun workspace set ${key}`) };
9300
+ } else if (key === "notify") {
9104
9301
  const [channel, value] = rest;
9105
9302
  if (channel !== "email" && channel !== "url") throw new Error("`foldrun workspace set notify email <address>` or `set notify url <https://…>` — and --events failed,awaiting-approval,completed");
9106
9303
  if (!value && flags.events === undefined) throw new Error(`\`foldrun workspace set notify ${channel} <value>\` — or --events alone to change only what is notified on`);
@@ -10948,6 +11145,9 @@ export async function run(command, positional, flags, workspace, layout) {
10948
11145
  if (positional[0] === "new" && positional[1]) return evalNewCmd(positional[1], flags, layout);
10949
11146
  // --to names a deployed workspace: its evals run there.
10950
11147
  if (typeof flags.to === "string") return remoteEvalsCmd(positional[0], flags, layout);
11148
+ // --last reads the platform's results; a local eval has none to read,
11149
+ // and running the cases instead would break its promise to run nothing.
11150
+ if (flags.last === true || flags.run !== undefined) throw new Error("--last and --run read a deployed workspace's results — add --to <workspace>");
10951
11151
  needsOne(layout, "eval");
10952
11152
  return runEvals(positional[0], flags);
10953
11153
  case "promote":
@@ -11035,6 +11235,8 @@ export async function run(command, positional, flags, workspace, layout) {
11035
11235
  return rerunCmd(positional[0], flags, layout);
11036
11236
  case "account":
11037
11237
  return accountCmd(positional, flags);
11238
+ case "jobs":
11239
+ return jobsCmd(flags, layout);
11038
11240
  case "schedule":
11039
11241
  if (positional[0] === "tick") return scheduleTickCmd(flags);
11040
11242
  return scheduleCmd(flags);