foldrun 2.2.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/src/commands.mjs CHANGED
@@ -440,20 +440,34 @@ async function remoteListCmd(kind, flags, layout) {
440
440
  const url = platformFor(flags, `${one} ls`);
441
441
  const ws = platformWorkspace(flags, layout, `${one} ls`);
442
442
  const r = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/${kind}`);
443
- const list = r[kind] ?? [];
443
+ let list = r[kind] ?? [];
444
+ // --stale [days]: the agents nobody has run lately — with a number, not
445
+ // run in that many days (never run included); bare, the ones past the
446
+ // workspace's alerts: stale_days, as the platform counts them.
447
+ if (kind === "agents" && flags.stale !== undefined) {
448
+ const days = flags.stale === true ? null : Number(flags.stale);
449
+ if (days !== null && !(Number.isInteger(days) && days > 0)) throw new Error(`--stale takes a whole number of days, e.g. --stale 30 — not "${flags.stale}"`);
450
+ list = list.filter((x) => (days === null ? x.stale === true : !x.lastRunAt || Date.now() - Date.parse(x.lastRunAt) >= days * 86_400_000));
451
+ }
444
452
  if (flags.json === true) {
445
453
  console.log(JSON.stringify(list, null, 2));
446
454
  return 0;
447
455
  }
448
456
  if (!list.length) {
449
- console.log(`\n ${c.dim(`no ${kind} in ${ws} on ${url} — \`foldrun ${one} new <name> --platform --to ${ws}\` makes one`)}\n`);
457
+ console.log(`\n ${c.dim(flags.stale !== undefined ? `no stale agents in ${ws}` : `no ${kind} in ${ws} on ${url} — \`foldrun ${one} new <name> --platform --to ${ws}\` makes one`)}\n`);
450
458
  return 0;
451
459
  }
452
460
  const w = Math.max(...list.map((x) => x.name.length));
461
+ // Who answers for each one (owner:, or the workspace's, or the account's);
462
+ // the column only when someone is named, so a list with none is unchanged.
463
+ const ow = kind === "agents" ? Math.max(0, ...list.map((x) => (x.owner ?? "").length)) : 0;
453
464
  console.log();
454
465
  for (const x of list) {
455
466
  if (kind === "agents") {
456
- console.log(` ${c.bold(pad(x.name, w))} ${pad(x.model ?? "", 8)} ${c.dim(firstLine(x.description ?? "", 80))}`);
467
+ const owner = ow ? `${pad(x.owner ?? "—", Math.max(ow, 1))} ` : "";
468
+ // When it last ran, from a platform that says (lastRunAt is absent before it did).
469
+ const ran = "lastRunAt" in x ? `${pad(x.lastRunAt ? `${Math.floor((Date.now() - Date.parse(x.lastRunAt)) / 86_400_000)}d ago` : "never", 7)} ` : "";
470
+ console.log(` ${c.bold(pad(x.name, w))} ${pad(x.model ?? "", 8)} ${ran}${owner}${x.stale ? c.amber("stale ") : ""}${c.dim(firstLine(x.description ?? "", 80))}`);
457
471
  } else {
458
472
  const when = x.trigger === "schedule" && x.schedule ? `schedule ${x.schedule}${x.timezone ? ` ${x.timezone}` : ""}` : (x.trigger ?? "manual");
459
473
  console.log(` ${c.bold(pad(x.name, w))} ${c.dim(when)}`);
@@ -973,7 +987,7 @@ async function checkWorkspace(workspace, flags, opts) {
973
987
  let accountNetwork;
974
988
  try {
975
989
  const md = path.join(accountRoot, "AGENTS.md");
976
- if (md !== path.join(workspace, "AGENTS.md") && fs.existsSync(md)) accountNetwork = (await import("gray-matter")).default(fs.readFileSync(md, "utf8")).data?.network;
990
+ if (md !== path.join(workspace, "AGENTS.md") && fs.existsSync(md)) accountNetwork = (await import("gray-matter")).default(fs.readFileSync(md, "utf8"), {}).data?.network;
977
991
  } catch {
978
992
  // an unreadable account AGENTS.md is reported by its own check
979
993
  }
@@ -1228,12 +1242,17 @@ async function checkProblems(workspace, flags = {}) {
1228
1242
  if (webWarnings) {
1229
1243
  try {
1230
1244
  const matter = (await import("gray-matter")).default;
1231
- const front = matter(fs.readFileSync(path.join(workspace, "agents", a.name, "agent.md"), "utf8")).data ?? {};
1245
+ // `{}`: gray-matter caches a failed parse as an empty success without it.
1246
+ const front = matter(fs.readFileSync(path.join(workspace, "agents", a.name, "agent.md"), "utf8"), {}).data ?? {};
1232
1247
  for (const w of webWarnings(front, runnerBrowsers)) note("warn", `agents/${a.name}`, w);
1233
1248
  } catch {
1234
1249
  // an unreadable agent.md is already reported by the loader
1235
1250
  }
1236
1251
  }
1252
+ // agent.md whose YAML does not parse: core lists it with defaults and the
1253
+ // reason (core 2.5) rather than throwing; the file is unreadable to a run.
1254
+ const frontmatterError = /** @type {{ frontmatterError?: string }} */ (a).frontmatterError;
1255
+ if (frontmatterError) note("error", `agents/${a.name}`, frontmatterError);
1237
1256
  // `schedule:` on an agent fires nothing — only a flow has a clock.
1238
1257
  if (a.scheduleProblem) note("error", `agents/${a.name}`, a.scheduleProblem);
1239
1258
  // `language:` that is not a tag — "Persian" where `fa` was meant.
@@ -5250,10 +5269,11 @@ async function rerunCmd(runId, flags, layout) {
5250
5269
  const ws = await workspaceOfRun(url, flags, layout, runId);
5251
5270
  const body = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/rerun`, {
5252
5271
  method: "POST",
5253
- body: JSON.stringify(agent ? { agent } : { step: from }),
5272
+ // --instruction: what that step is told this once (FOL-72 step 6).
5273
+ body: JSON.stringify({ ...(agent ? { agent } : { step: from }), ...(typeof flags.instruction === "string" ? { instruction: flags.instruction } : {}) }),
5254
5274
  });
5255
5275
  const id = body.runId ?? body.id;
5256
- console.log(`\n ${c.green("✓")} ${c.bold(id)} ${c.dim(`${ws} · again from ${agent ? `agent ${agent}` : `step ${from}`} of ${runId} · ${body.steps ?? "?"} steps`)}`);
5276
+ console.log(`\n ${c.green("✓")} ${c.bold(id)} ${c.dim(`${ws} · again from ${agent ? `agent ${agent}` : `step ${from}`} of ${runId} · ${body.steps ?? "?"} steps${typeof flags.instruction === "string" ? " · with your instruction for that step" : ""}`)}`);
5257
5277
  console.log(` ${c.dim(`foldrun logs ${id} --to ${ws}${flags.wait === true ? "" : " · --wait follows it"}`)}\n`);
5258
5278
  if (flags.wait !== true) return 0;
5259
5279
  return logsCmd([id], { ...flags, follow: true, to: ws }, layout);
@@ -5573,6 +5593,234 @@ function printDefaults(d) {
5573
5593
  console.log(` notify ${notify}`);
5574
5594
  console.log(` budget ${d.budget ? `$${d.budget.usd} per ${d.budget.period}` : c.dim("no cap")}`);
5575
5595
  console.log(` concurrency ${d.concurrency ?? c.dim("the plan's number")}`);
5596
+ console.log(` deploy_block ${d.deployBlock && d.deployBlock !== "none" ? `${d.deployBlock} ${c.dim(d.deployBlock === "secrets" ? "— a written-in key refuses a deploy" : "— any security finding refuses a deploy")}` : c.dim("none — security findings only warn")}`);
5597
+ console.log(` network ${networkEnforceText(d.networkEnforce, "the install")}`);
5598
+ }
5599
+
5600
+ /** network_enforce: as a person reads it. Off is never "allowed" outright:
5601
+ * the level above (the account, or the install's own switch) may refuse
5602
+ * anyway, and this file cannot see either, so off says what it leaves to. */
5603
+ function networkEnforceText(on, above) {
5604
+ return on ? `refused ${c.dim("— a site off an agent's network: list gets a 403")}` : c.dim(`${above}'s — this file does not ask to refuse`);
5605
+ }
5606
+
5607
+ /** `on`/`off` (or true/false, yes/no) for network_enforce, or an error. */
5608
+ function onOff(key, value, how) {
5609
+ const v = String(value ?? "").toLowerCase();
5610
+ if (["on", "true", "yes"].includes(v)) return true;
5611
+ if (["off", "false", "no"].includes(v)) return false;
5612
+ throw new Error(`${key} is on or off — e.g. \`${how}\` — not "${value ?? ""}"`);
5613
+ }
5614
+
5615
+ /**
5616
+ * `foldrun account telemetry` — where each finished run goes as an
5617
+ * OpenTelemetry trace (the account's `telemetry:`). `set <url> --header
5618
+ * authorization=\${OTEL_TOKEN}`, `clear`, `test` (POST …/telemetry/test:
5619
+ * one made-up run, and what the collector answered).
5620
+ */
5621
+ async function telemetryCmd(url, args, flags) {
5622
+ const [sub, endpoint] = args;
5623
+ if (!sub) {
5624
+ const { defaults } = await remoteCall(url, flags, "/api/account");
5625
+ const t = defaults?.telemetry;
5626
+ if (!t) {
5627
+ console.log(`\n ${c.dim("runs are not sent anywhere — foldrun account telemetry set https://<collector>/v1/traces --header authorization=\${OTEL_TOKEN}")}\n`);
5628
+ return 0;
5629
+ }
5630
+ console.log(`\n ${c.bold("telemetry")} ${t.endpoint}`);
5631
+ for (const [k, v] of Object.entries(t.headers ?? {})) console.log(` ${c.dim(k)} ${v}`);
5632
+ console.log();
5633
+ return 0;
5634
+ }
5635
+ if (sub === "test") {
5636
+ const r = await remoteCall(url, flags, "/api/account/telemetry/test", { method: "POST", body: "{}" });
5637
+ if (r.ok) console.log(`\n ${c.green("✓")} ${r.endpoint} took it — ${r.spans} spans, HTTP ${r.status}. Look for the trace "run foldrun-telemetry-test".\n`);
5638
+ else console.log(`\n ${c.red("✗")} not delivered to ${r.endpoint} — ${r.error}\n`);
5639
+ return r.ok ? 0 : 1;
5640
+ }
5641
+ if (sub === "clear") {
5642
+ await remoteCall(url, flags, "/api/account", { method: "PATCH", body: JSON.stringify({ telemetry: null }) });
5643
+ console.log(`\n ${c.green("✓")} runs are no longer sent\n`);
5644
+ return 0;
5645
+ }
5646
+ if (sub !== "set") throw new Error(`unknown telemetry verb "${sub}" — set <url> [--header name=\${SECRET}], clear, test (bare: show it)`);
5647
+ if (!endpoint) throw new Error("`foldrun account telemetry set https://<collector>/v1/traces --header authorization=${OTEL_TOKEN}`");
5648
+ const headers = {};
5649
+ for (const h of [flags.header].flat().filter((x) => typeof x === "string")) {
5650
+ const i = h.indexOf("=");
5651
+ if (i < 1) throw new Error(`--header is name=value, e.g. --header authorization='\${OTEL_TOKEN}' — not "${h}"`);
5652
+ headers[h.slice(0, i).trim().toLowerCase()] = h.slice(i + 1).trim();
5653
+ }
5654
+ const r = await remoteCall(url, flags, "/api/account", { method: "PATCH", body: JSON.stringify({ telemetry: { otlp: endpoint, ...(Object.keys(headers).length ? { headers } : {}) } }) });
5655
+ console.log(`\n ${c.green("✓")} every run that finishes is sent to ${r.defaults?.telemetry?.endpoint ?? endpoint} — \`foldrun account telemetry test\` proves it arrives\n`);
5656
+ return 0;
5657
+ }
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
+
5774
+ /** A GET …/pause, or null from a platform older than the kill switch —
5775
+ * the settings still print, without the line. */
5776
+ async function pauseState(request) {
5777
+ try {
5778
+ return await request;
5779
+ } catch (err) {
5780
+ if (err instanceof HttpError && err.status === 404) return null;
5781
+ throw err;
5782
+ }
5783
+ }
5784
+
5785
+ /** One pause mark as a line: since when, by whom, why. */
5786
+ function pauseLine(m) {
5787
+ return `${m.at.slice(0, 16).replace("T", " ")} UTC by ${m.by}${m.reason ? ` — ${m.reason}` : ""}`;
5788
+ }
5789
+
5790
+ /**
5791
+ * `foldrun account pause|resume`, `foldrun workspace pause|resume` — the
5792
+ * kill switch (POST / DELETE …/pause). Pausing is undone by a resume, so it
5793
+ * asks nothing; --stop also stops the runs going now, which nothing undoes,
5794
+ * so that asks.
5795
+ */
5796
+ async function pauseCmd(flags, verb, what, { post, del }) {
5797
+ if (verb === "resume") {
5798
+ const r = await del();
5799
+ const still = r.paused;
5800
+ // An account resume leaves a workspace paused on its own paused.
5801
+ const own = Object.keys(r.workspaces ?? {});
5802
+ const held = still
5803
+ ? c.amber(` — but still held: ${still.scope === "account" ? "the account" : "this workspace"} is paused (${pauseLine(still)})`)
5804
+ : own.length
5805
+ ? c.amber(` — but ${own.join(", ")} ${own.length === 1 ? "is" : "are"} paused on ${own.length === 1 ? "its" : "their"} own (\`foldrun workspace resume --to <name>\`)`)
5806
+ : " — runs start again";
5807
+ console.log(`\n ${c.green("✓")} resumed ${what}${held}\n`);
5808
+ return 0;
5809
+ }
5810
+ const stop = flags.stop === true;
5811
+ if (stop && !(await confirmed(flags, "pause --stop", `Pause ${what} and stop every run going now? [y/N] `))) {
5812
+ console.log(`\n ${c.dim("nothing paused, nothing stopped")}\n`);
5813
+ return 1;
5814
+ }
5815
+ const body = { ...(typeof flags.reason === "string" && flags.reason.trim() ? { reason: flags.reason.trim() } : {}), ...(stop ? { stop: true } : {}) };
5816
+ const r = await post(JSON.stringify(body));
5817
+ console.log(`\n ${c.green("✓")} paused ${what} — no new run starts until \`resume\``);
5818
+ if (stop) console.log(` ${c.dim(`${r.stopped?.length ?? 0} run(s) stopped`)}`);
5819
+ // A stop that failed is a run that may still be going: named, and the
5820
+ // exit says so, rather than assumed to have finished on its own.
5821
+ for (const f of r.failed ?? []) console.log(` ${c.red("✗")} ${f.workspace}/${f.runId} not stopped — ${f.error}`);
5822
+ console.log();
5823
+ return r.failed?.length ? 1 : 0;
5576
5824
  }
5577
5825
 
5578
5826
  /**
@@ -5595,25 +5843,43 @@ async function accountCmd(positional, flags) {
5595
5843
  if (!verb) {
5596
5844
  const { defaults } = await remoteCall(url, flags, "/api/account");
5597
5845
  printDefaults(defaults ?? {});
5846
+ const halt = await pauseState(remoteCall(url, flags, "/api/account/pause"));
5847
+ const own = Object.keys(halt?.workspaces ?? {});
5848
+ if (halt) console.log(` paused ${halt.account ? c.red(pauseLine(halt.account)) : c.dim("no")}${own.length ? c.dim(` · on their own: ${own.join(", ")}`) : ""}`);
5598
5849
  console.log(`\n ${c.dim("foldrun account set timezone Australia/Sydney · set notify email you@example.com · set budget 60/month · clear budget")}\n`);
5599
5850
  return 0;
5600
5851
  }
5601
5852
  if (verb === "providers") return providersCmd(url, flags);
5853
+ if (verb === "telemetry") return telemetryCmd(url, positional.slice(1), flags);
5854
+ if (verb === "pause" || verb === "resume") {
5855
+ return pauseCmd(flags, verb, "every workspace in the account", {
5856
+ post: (body) => remoteCall(url, flags, "/api/account/pause", { method: "POST", body }),
5857
+ del: () => remoteCall(url, flags, "/api/account/pause", { method: "DELETE" }),
5858
+ });
5859
+ }
5602
5860
  if (verb === "model") return accountModelCmd(url, positional.slice(1), flags);
5603
5861
  if (verb === "export") return accountExportCmd(url, flags);
5604
5862
  if (verb !== "set" && verb !== "clear") {
5605
- 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`);
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`);
5606
5864
  }
5607
5865
 
5608
- const key = positional[1];
5609
- const KEYS = ["timezone", "notify", "budget", "concurrency"];
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"] : [])];
5610
5874
  if (!KEYS.includes(key)) {
5611
- 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)`);
5612
5876
  }
5613
5877
 
5614
5878
  let patch;
5615
5879
  if (verb === "clear") {
5616
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}`) };
5617
5883
  } else if (key === "notify") {
5618
5884
  // `set notify email you@example.com` / `set notify url https://…`, and
5619
5885
  // --events to change what is notified on.
@@ -5647,7 +5913,13 @@ async function accountCmd(positional, flags) {
5647
5913
  if (key === "concurrency" && !/^\d+$/.test(value)) {
5648
5914
  throw new Error(`concurrency is a whole number of runs at once, e.g. \`foldrun account set concurrency 4\` — not "${value}"`);
5649
5915
  }
5650
- patch = { [key]: key === "concurrency" ? Number(value) : value };
5916
+ // deploy_block (FOL-58): none | secrets | security.
5917
+ if (key === "deploy_block" && !["none", "secrets", "security"].includes(value)) {
5918
+ throw new Error(`deploy_block is none, secrets or security — e.g. \`foldrun account set deploy_block secrets\` — not "${value}"`);
5919
+ }
5920
+ patch = {
5921
+ [key]: key === "concurrency" ? Number(value) : key === "network_enforce" ? onOff(key, value, "foldrun account set network_enforce on") : value,
5922
+ };
5651
5923
  }
5652
5924
 
5653
5925
  const answer = await remoteCall(url, flags, "/api/account", { method: "PATCH", body: JSON.stringify(patch) });
@@ -5929,6 +6201,127 @@ const eventSteps = (run) =>
5929
6201
  * steps in one group is two decisions, and a list that folded them into one
5930
6202
  * line named one agent and asked neither question.
5931
6203
  */
6204
+ /** `foldrun runs views` — your saved run searches (FOL-72 step 9). */
6205
+ async function runViewsCmd(flags) {
6206
+ const url = platformFor(flags, "runs");
6207
+ const { runViews = [] } = await remoteCall(url, flags, "/api/me/preferences");
6208
+ if (flags.json === true) return console.log(JSON.stringify(runViews, null, 2)), 0;
6209
+ if (!runViews.length) return console.log(`\n ${c.dim("no saved views — save one from the runs page's search")}\n`), 0;
6210
+ console.log();
6211
+ for (const v of runViews) console.log(` ${c.bold(v.name)} ${c.dim(v.query)}`);
6212
+ console.log(`\n ${c.dim("foldrun runs --view <name> opens one")}\n`);
6213
+ return 0;
6214
+ }
6215
+
6216
+ // ---------------------------------------------------------------- insights
6217
+
6218
+ /** `foldrun insights` (FOL-72 step 8): recurring problems found in the runs,
6219
+ * with evidence, what they cost and what to do — GET /api/insights. */
6220
+ async function insightsCmd(flags) {
6221
+ const url = platformFor(flags, "insights");
6222
+ const qs = new URLSearchParams({ ...(flags.days !== undefined ? { days: String(flags.days) } : {}), ...(typeof flags.to === "string" ? { workspace: flags.to } : {}) });
6223
+ const { insights = [], days = 7 } = await remoteCall(url, flags, `/api/insights${qs.size ? `?${qs}` : ""}`);
6224
+ if (flags.json === true) return console.log(JSON.stringify(insights, null, 2)), 0;
6225
+ if (!insights.length) {
6226
+ console.log(`\n ${c.dim(`nothing recurring in the last ${days} days`)}\n`);
6227
+ return 0;
6228
+ }
6229
+ console.log();
6230
+ for (const i of insights) {
6231
+ const cost = i.credits ? ` · about ${i.credits.toLocaleString()} credits` : "";
6232
+ console.log(` ${c.amber("◆")} ${c.bold(i.title)} ${c.dim(`${i.workspace} · ${i.steps} step${i.steps === 1 ? "" : "s"}${cost}`)}`);
6233
+ for (const line of wrap(i.why, 84)) console.log(` ${c.dim(line)}`);
6234
+ for (const [k, line] of wrap(i.fix, 80).entries()) console.log(` ${k === 0 ? "→ " : " "}${line}`);
6235
+ console.log(` ${c.dim(i.evidence.map((e) => `${e.runId}#${e.step}`).join(" "))}`);
6236
+ console.log();
6237
+ }
6238
+ return 0;
6239
+ }
6240
+
6241
+ // ---------------------------------------------------------------- issues
6242
+
6243
+ const ISSUE_VERBS = { ack: "acknowledge", acknowledge: "acknowledge", snooze: "snooze", resolve: "resolve", reopen: "reopen" };
6244
+
6245
+ /** What the run doctor reads for an issue: its newest run, that run's
6246
+ * workspace, and the question the run page's Doctor asks — core's
6247
+ * issueDoctor (issues.ts), repeated here as the cache note is until the
6248
+ * next core release. Null for a missed schedule, which has no run. */
6249
+ function issueDoctor(issue) {
6250
+ const run = (issue.runs ?? []).find((r) => !r.missed);
6251
+ if (!run || issue.kind === "missed") return null;
6252
+ const ended = issue.kind === "blocked" ? "end BLOCKED" : issue.kind === "bad" ? "end BAD" : "fail";
6253
+ return { workspace: run.workspace, runId: run.id, prompt: `Doctor: why did run ${run.id} ${ended}, and how do I fix it?` };
6254
+ }
6255
+
6256
+ /** `foldrun issues` (FOL-54 step 3): what went wrong, grouped, open until a
6257
+ * person resolves it; `foldrun issues ack|snooze|resolve|reopen <id>` acts
6258
+ * on one — the dashboard's Issues page and its buttons, same route.
6259
+ * `foldrun issues doctor <id>` is the card's Doctor: the run doctor on the
6260
+ * issue's newest run, in that run's workspace. */
6261
+ async function issuesCmd(positional, flags, layout) {
6262
+ const url = platformFor(flags, "issues");
6263
+ const [verb, id] = positional;
6264
+ if (verb === "doctor") {
6265
+ if (!id || !/^iss_[a-z0-9]{2,32}$/.test(id)) throw new Error("`foldrun issues doctor <issue-id>` — the id is the iss_… on each line of `foldrun issues`");
6266
+ const { issue } = await remoteCall(url, flags, `/api/issues/${id}`);
6267
+ const doc = issueDoctor(issue);
6268
+ if (!doc) throw new Error(`${id} is a missed schedule: no run started, so there is nothing for the doctor to read — open the flow's schedule instead`);
6269
+ return assistantCmd([doc.prompt], { ...flags, to: doc.workspace }, layout);
6270
+ }
6271
+ if (verb && verb !== "show") {
6272
+ const action = ISSUE_VERBS[verb];
6273
+ if (!action) throw new Error(`\`foldrun issues\` lists them; its verbs are show, doctor, ack, snooze, resolve, reopen — not ${verb}`);
6274
+ if (!id || !/^iss_[a-z0-9]{2,32}$/.test(id)) throw new Error(`\`foldrun issues ${verb} <issue-id>\` — the id is the iss_… on each line of \`foldrun issues\``);
6275
+ if (action === "snooze" && typeof flags.for !== "string" && typeof flags.until !== "string" && flags.times === undefined) throw new Error("a snooze takes --for 30m|4h|1d|1w, --until <ISO time>, or --times <n> (until it happens n more times)");
6276
+ const body = {
6277
+ action,
6278
+ ...(typeof flags.for === "string" ? { for: flags.for } : {}),
6279
+ ...(typeof flags.until === "string" ? { until: flags.until } : {}),
6280
+ ...(flags.times !== undefined ? { times: Number(flags.times) } : {}),
6281
+ ...(typeof flags.note === "string" ? { note: flags.note } : {}),
6282
+ };
6283
+ const { issue } = await remoteCall(url, flags, `/api/issues/${id}`, { method: "POST", body: JSON.stringify(body) });
6284
+ if (flags.json === true) return console.log(JSON.stringify(issue, null, 2)), 0;
6285
+ console.log(`\n ${c.green("✓")} ${issue.id} is ${issue.status}${issue.snoozedUntil ? ` until ${when(issue.snoozedUntil)}` : ""}${issue.snoozedFor ? ` for ${issue.snoozedFor} more` : ""} — ${issue.what}\n`);
6286
+ return 0;
6287
+ }
6288
+ if (verb === "show") {
6289
+ if (!id) throw new Error("`foldrun issues show <issue-id>`");
6290
+ const { issue } = await remoteCall(url, flags, `/api/issues/${id}`);
6291
+ if (flags.json === true) return console.log(JSON.stringify(issue, null, 2)), 0;
6292
+ printIssue(issue, true);
6293
+ return 0;
6294
+ }
6295
+ const status = typeof flags.status === "string" ? flags.status : flags.all === true ? "all" : "active";
6296
+ const qs = new URLSearchParams({ status, ...(typeof flags.to === "string" ? { workspace: flags.to } : {}) });
6297
+ const { issues = [] } = await remoteCall(url, flags, `/api/issues?${qs}`);
6298
+ if (flags.json === true) return console.log(JSON.stringify(issues, null, 2)), 0;
6299
+ if (!issues.length) {
6300
+ console.log(`\n ${c.dim(status === "active" ? "no open issues" : `no ${status} issues`)}\n`);
6301
+ return 0;
6302
+ }
6303
+ console.log();
6304
+ for (const i of issues) printIssue(i, false);
6305
+ console.log(` ${c.dim("foldrun issues show <id> for its runs and history; doctor <id> asks the run doctor why; ack, snooze --for 1d, resolve or reopen <id> act on one; --all lists resolved too")}\n`);
6306
+ return 0;
6307
+ }
6308
+
6309
+ function printIssue(i, full) {
6310
+ const mark = i.kind === "platform" ? c.bold("▲") : i.kind === "failed" ? c.red("✗") : i.kind === "blocked" ? c.red("⛔") : i.kind === "missed" ? c.amber("◷") : c.amber("!");
6311
+ const where = i.kind === "platform" ? i.workspaces.join(", ") : `${i.workspace} · ${i.what}${i.agent ? ` at ${i.agent}` : ""}`;
6312
+ const label = i.kind === "missed" ? "missed schedule" : i.kind;
6313
+ const state = (i.status === "open" ? "" : c.dim(` [${i.status}${i.snoozedUntil ? ` until ${when(i.snoozedUntil)}` : ""}${i.snoozedFor ? ` for ${i.snoozedFor} more` : ""}${i.resolved?.by?.startsWith("auto:") ? ", auto" : ""}]`)) + (i.regressed ? ` ${c.red(`regressed${i.sinceRevision ? ` since ${String(i.sinceRevision).slice(0, 7)}` : ""}`)}` : "");
6314
+ console.log(` ${mark} ${c.bold(i.kind === "platform" ? i.what : label)} ${where}${state} ${c.dim(i.id)}`);
6315
+ console.log(` ${c.dim(`${i.count}× · first ${ago(i.firstSeen)} ago · latest ${ago(i.lastSeen)} ago${i.recoveredAt ? ` · fine for ${ago(i.recoveredAt)}` : ""}`)}`);
6316
+ for (const line of wrap(i.reason, 84).slice(0, full ? 20 : 2)) console.log(` ${line}`);
6317
+ console.log(` ${c.dim("→")} ${i.action}`);
6318
+ if (full) {
6319
+ console.log(` ${c.dim(`runs: ${i.runs.map((r) => `${r.workspace}/${r.id}`).join(", ")}`)}`);
6320
+ for (const e of i.history) console.log(` ${c.dim(`${when(e.at)} ${e.action} by ${e.by}${e.until ? ` until ${when(e.until)}` : ""}${e.note ? ` — ${e.note}` : ""}`)}`);
6321
+ }
6322
+ console.log();
6323
+ }
6324
+
5932
6325
  async function approvalsCmd(flags, layout) {
5933
6326
  const url = platformFor(flags, "approvals");
5934
6327
  const only = typeof flags.to === "string" ? flags.to : null;
@@ -6151,6 +6544,23 @@ async function stopCmd(runId, flags, layout) {
6151
6544
  */
6152
6545
  async function runsCmd(flags, layout) {
6153
6546
  const url = platformFor(flags, "runs");
6547
+ // --view <name>: a saved search (the dashboard's) read first, so every
6548
+ // filter in it — status, since, workspace, flow, q and the search's own —
6549
+ // applies as if typed; a flag typed too wins (Codex review).
6550
+ if (typeof flags.view === "string") {
6551
+ const { runViews = [] } = await remoteCall(url, flags, "/api/me/preferences");
6552
+ const view = runViews.find((v) => v.name === flags.view);
6553
+ if (!view) throw new Error(`no saved view called ${flags.view}${runViews.length ? ` — yours: ${runViews.map((v) => v.name).join(", ")}` : " — save one on the runs page"}`);
6554
+ const as = { workspace: "to", min_tokens: "min-tokens", min_secs: "min-secs" };
6555
+ const fromView = {};
6556
+ for (const [k, v] of new URLSearchParams(view.query)) {
6557
+ if (k === "tool_error") fromView["tool-error"] = v === "1" || v === "true";
6558
+ // The dashboard's Today chip is its 24-hour window (Codex review).
6559
+ else if (k === "since" && v === "today") fromView.since = "24h";
6560
+ else fromView[as[k] ?? k] = v;
6561
+ }
6562
+ flags = { ...fromView, ...flags };
6563
+ }
6154
6564
  const names = typeof flags.to === "string" ? [flags.to] : await remoteWorkspaceNames(url, flags);
6155
6565
  if (!names.length) {
6156
6566
  console.log(`\n ${c.dim(`no workspaces on ${url}`)}\n`);
@@ -6162,6 +6572,17 @@ async function runsCmd(flags, layout) {
6162
6572
  typeof flags.status === "string" ? new Set(flags.status.split(",").map((s) => s.trim()).filter(Boolean)) : null;
6163
6573
  const verdicts =
6164
6574
  typeof flags.verdict === "string" ? new Set(flags.verdict.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean)) : null;
6575
+ // --outcome (FOL-54): the one word every surface shows — needs-you is the
6576
+ // dashboard's name for waiting. A platform from before it sends none.
6577
+ const OUTCOME_WORDS = ["running", "queued", "waiting", "good", "done", "quiet", "blocked", "bad", "failed", "stopped"];
6578
+ const outcomes =
6579
+ typeof flags.outcome === "string"
6580
+ ? new Set(flags.outcome.split(",").map((s) => s.trim().toLowerCase().replace(/^needs-?you$/, "waiting")).filter(Boolean))
6581
+ : null;
6582
+ if (outcomes) {
6583
+ const unknown = [...outcomes].filter((o) => !OUTCOME_WORDS.includes(o));
6584
+ if (unknown.length) throw new Error(`--outcome takes ${OUTCOME_WORDS.join(", ")} (or needs-you) — not ${unknown.join(", ")}`);
6585
+ }
6165
6586
 
6166
6587
  // Each desk is asked for its own newest rows before the merge, so one
6167
6588
  // busy workspace cannot crowd every quiet one out of the answer. With a
@@ -6169,11 +6590,23 @@ async function runsCmd(flags, layout) {
6169
6590
  // the filter runs here, after the fetch, and a desk with twenty recent
6170
6591
  // successes was hiding its older failures from `--status failed` because
6171
6592
  // only its newest twenty of any status ever arrived.
6172
- const perWorkspace = wanted || verdicts || cutoff !== null ? 500 : limit;
6593
+ // Search (FOL-72 step 9): by what happened inside a run, filtered by the
6594
+ // platform; --view <name> starts from a saved search (the dashboard's).
6595
+ const search = new URLSearchParams();
6596
+ for (const [flag, key] of [["tool", "tool"], ["agent", "agent"], ["model", "model"], ["min-tokens", "min_tokens"], ["min-secs", "min_secs"]]) {
6597
+ if (flags[flag] !== undefined && flags[flag] !== true) search.set(key, String(flags[flag]));
6598
+ }
6599
+ if (flags["tool-error"] === true) search.set("tool_error", "1");
6600
+ const searching = [...search.keys()].some((k) => ["tool", "tool_error", "agent", "model", "min_tokens", "min_secs"].includes(k));
6601
+ // A view's flow or text filters here, after the fetch: ask for the ceiling
6602
+ // as for a status, or matches past the newest few go missing (Codex review).
6603
+ const localText = typeof flags.flow === "string" || typeof flags.q === "string";
6604
+ const perWorkspace = wanted || verdicts || outcomes || cutoff !== null || searching || localText ? 500 : limit;
6173
6605
  const unreachable = [];
6174
6606
  const lists = await pool(names, 8, async (ws) => {
6175
6607
  try {
6176
- const { runs = [] } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs?limit=${perWorkspace}`);
6608
+ const forApi = new URLSearchParams([...search].filter(([k]) => ["tool", "tool_error", "agent", "model", "min_tokens", "min_secs"].includes(k)));
6609
+ const { runs = [] } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs?limit=${perWorkspace}${forApi.size ? `&${forApi}` : ""}`);
6177
6610
  return runs.map((r) => ({ ...r, workspace: ws }));
6178
6611
  } catch (err) {
6179
6612
  unreachable.push([ws, explain(err)]);
@@ -6183,14 +6616,34 @@ async function runsCmd(flags, layout) {
6183
6616
 
6184
6617
  const rows = lists
6185
6618
  .flat()
6186
- .filter((r) => (wanted ? wanted.has(r.status) : true))
6619
+ // The status the dashboard shows: a stopped run is "stopped", not the
6620
+ // "failed" its record says (Codex review — a saved status=stopped view).
6621
+ .filter((r) => (wanted ? wanted.has(r.outcome === "stopped" ? "stopped" : r.status) : true))
6187
6622
  .filter((r) => (verdicts ? r.status === "completed" && verdicts.has(verdictOf(r)) : true))
6623
+ .filter((r) => (outcomes ? typeof r.outcome === "string" && outcomes.has(r.outcome) : true))
6624
+ // A view's flow and free text (the runs page's): "agent: x" is a direct run.
6625
+ // The dashboard names a run by what ran (whatRan): a direct agent run's
6626
+ // or an eval's own name, without adhoc:/eval: (Codex review).
6627
+ .filter((r) => (typeof flags.flow === "string" ? r.flow === flags.flow || r.flow.replace(/^(adhoc|eval):/, "") === flags.flow : true))
6628
+ // The runs page's text search, field for field (its runSearchText): every
6629
+ // word, any order, across id, what ran, workspace, the status it shows,
6630
+ // verdict, summary and the failure (Codex review).
6631
+ .filter((r) => {
6632
+ if (typeof flags.q !== "string") return true;
6633
+ const words = flags.q.toLowerCase().replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
6634
+ const shown = r.outcome === "stopped" ? "stopped" : r.status;
6635
+ const hay = [r.id, r.flow.replace(/^(adhoc|eval):/, ""), r.workspace, shown, verdictOf(r) ?? "", r.summary ?? "", r.failure ? `${r.failure.agent} ${r.failure.reason}` : ""]
6636
+ .join(" ")
6637
+ .toLowerCase()
6638
+ .replace(/\s+/g, " ");
6639
+ return words.every((w) => hay.includes(w));
6640
+ })
6188
6641
  .filter((r) => (cutoff === null ? true : Date.parse(r.startedAt) >= cutoff))
6189
6642
  .sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1))
6190
6643
  .slice(0, limit);
6191
6644
 
6192
6645
  if (!rows.length) {
6193
- const how = [wanted ? `status ${[...wanted].join(", ")}` : null, verdicts ? `verdict ${[...verdicts].join(", ")}` : null, flags.since ? `the last ${flags.since}` : null]
6646
+ const how = [wanted ? `status ${[...wanted].join(", ")}` : null, verdicts ? `verdict ${[...verdicts].join(", ")}` : null, outcomes ? `outcome ${[...outcomes].join(", ")}` : null, flags.since ? `the last ${flags.since}` : null]
6194
6647
  .filter(Boolean)
6195
6648
  .join(" in ");
6196
6649
  console.log(`\n ${c.dim(`no runs${how ? ` matching ${how}` : ""}${flags.to ? ` in ${flags.to}` : ""}`)}\n`);
@@ -6210,7 +6663,9 @@ async function runsCmd(flags, layout) {
6210
6663
  verdictWidth: (r.status === "completed" ? verdictOf(r) ?? "" : "").length,
6211
6664
  steps: `${done}/${(r.steps ?? []).length}`,
6212
6665
  took: runDuration(r),
6213
- cost: `$${runCost(r).toFixed(2)}`,
6666
+ // FOL-54: credits charged, as the dashboard shows; a run not settled
6667
+ // yet (or a platform from before chargedUsd) shows its token cost, in dollars.
6668
+ cost: typeof r.chargedUsd === "number" ? `${Math.round(r.chargedUsd * 100)} credits` : `$${runCost(r).toFixed(2)}`,
6214
6669
  id: r.id,
6215
6670
  summary: firstLine(r.summary, 110),
6216
6671
  };
@@ -6354,6 +6809,28 @@ function measuredShare(list) {
6354
6809
  return all > 0 ? read / all : null;
6355
6810
  }
6356
6811
  const cachedLabel = (rate) => (rate == null ? null : `${Math.round(rate * 100)}% cached`);
6812
+ /** Why a step's prompt cache missed — core's rule (tokens.ts promptCacheNote),
6813
+ * repeated here as `cachedShare` is: a step that wrote a fifth or more of its
6814
+ * input to the cache says which layer of its prompt changed since the same
6815
+ * agent's previous step in the run. Nothing for a step without layers on
6816
+ * record (before 20 Oct 2026) or one the cache served. */
6817
+ function promptCacheNote(steps, index) {
6818
+ const step = steps[index];
6819
+ const t = step?.tokens;
6820
+ if (!step?.promptLayers || !t || t.cacheWrite === undefined || !(t.input > 0)) return null;
6821
+ const share = t.cacheWrite / t.input;
6822
+ if (share < 0.2) return null;
6823
+ const head = `${Math.round(share * 100)}% of input written to the cache`;
6824
+ let prev = -1;
6825
+ for (let j = index - 1; j >= 0; j--) if (steps[j].agent === step.agent && steps[j].promptLayers) { prev = j; break; }
6826
+ if (prev < 0) return `${head} — the agent's first step in this run`;
6827
+ const before = new Map(steps[prev].promptLayers.map((l) => [l.name, l.hash]));
6828
+ const after = new Map(step.promptLayers.map((l) => [l.name, l.hash]));
6829
+ const changed = [...after].filter(([name, hash]) => before.get(name) !== hash).map(([name]) => name);
6830
+ for (const name of before.keys()) if (!after.has(name)) changed.push(name);
6831
+ if (!changed.length) return `${head} — the same prompt as step ${prev + 1}; the entry had expired or was still being written`;
6832
+ return `${head} — ${changed.join(", ")} changed since step ${prev + 1}`;
6833
+ }
6357
6834
  function sumTokens(list) {
6358
6835
  const out = { input: 0, output: 0 };
6359
6836
  for (const t of list) {
@@ -6368,10 +6845,86 @@ function sumTokens(list) {
6368
6845
  return out;
6369
6846
  }
6370
6847
 
6848
+ /** `foldrun report <run-id> call <call-id>` — one tool call's recorded input
6849
+ * and output, secrets masked (FOL-54 step 4). */
6850
+ async function reportCallCmd(runId, callId, flags, layout) {
6851
+ const url = platformFor(flags, "report");
6852
+ if (!runId || !callId) throw new Error("`foldrun report <run-id> call <call-id>` — `foldrun report <run-id> --calls` lists the recorded calls");
6853
+ const ws = await workspaceOfRun(url, flags, layout, runId);
6854
+ const { call } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/calls/${encodeURIComponent(callId)}`);
6855
+ if (flags.json === true) return console.log(JSON.stringify(call, null, 2)), 0;
6856
+ console.log(`\n ${c.bold(call.tool)} ${c.dim(call.id)}`);
6857
+ for (const [label, text, whole, wasCut] of [["input", call.input, call.inputChars, call.inputCut], ["output", call.output, call.outputChars, call.outputCut]]) {
6858
+ if (text === undefined) continue;
6859
+ const cut = wasCut === true && typeof whole === "number" && whole > text.length ? c.dim(` (the first ${text.length.toLocaleString()} of ${whole.toLocaleString()} characters)`) : "";
6860
+ console.log(`\n ${c.dim(label)}${cut}`);
6861
+ for (const line of String(text).split("\n")) console.log(` ${line}`);
6862
+ }
6863
+ console.log();
6864
+ return 0;
6865
+ }
6866
+
6867
+ /** `foldrun report <run-id> --tree` — the run as one tree, run → step →
6868
+ * model call → tool call, from GET …/runs/<id>/trace (FOL-72 step 5). */
6869
+ async function reportTreeCmd(url, flags, ws, runId) {
6870
+ const { tree, failure = [] } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/trace`);
6871
+ if (flags.json === true) return console.log(JSON.stringify({ tree, failure }, null, 2)), 0;
6872
+ // The deepest span on the way to the failure is the one to point at.
6873
+ const culprit = failure.length ? failure[failure.length - 1] : null;
6874
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : String(n));
6875
+ const mark = { run: "▣", step: "◆", model: "✦", tool: "→", error: "✗" };
6876
+ const lines = [];
6877
+ const walk = (s, prefix, last, root) => {
6878
+ const dur = s.start != null && s.end != null ? humanDuration(Math.max(0, s.end - s.start)) : "";
6879
+ const facts = [
6880
+ s.kind === "model" && s.tokens ? `${k(s.tokens.input)} in · ${k(s.tokens.output)} out` : null,
6881
+ s.stop ?? null,
6882
+ s.subagent ? `↳ ${s.subagent}` : null,
6883
+ s.kind === "tool" && s.captured && s.call ? s.call : null,
6884
+ ].filter(Boolean);
6885
+ const text = s.kind === "error" ? firstLine(s.text ?? "", 90) : s.label;
6886
+ const head = `${root ? "" : `${prefix}${last ? "└ " : "├ "}`}${mark[s.kind] ?? "·"} ${s.status === "error" ? c.red(text) : s.kind === "step" || s.kind === "run" ? c.bold(text) : text}`;
6887
+ lines.push(` ${head}${facts.length ? ` ${c.dim(facts.join(" · "))}` : ""}${dur && s.kind !== "error" ? ` ${c.dim(dur)}` : ""}${s.id === culprit ? ` ${c.red("← what went wrong")}` : ""}`);
6888
+ const kids = s.children ?? [];
6889
+ kids.forEach((ch, i) => walk(ch, root ? "" : `${prefix}${last ? " " : "│ "}`, i === kids.length - 1, false));
6890
+ };
6891
+ walk(tree, "", true, true);
6892
+ console.log(`\n${lines.join("\n")}\n`);
6893
+ if ((tree.children ?? []).some((s) => (s.children ?? []).some((ch) => (ch.children ?? []).some((t) => t.captured)))) console.log(` ${c.dim(`\`foldrun report ${runId} call <id>\` shows a call's input and output`)}\n`);
6894
+ return 0;
6895
+ }
6896
+
6897
+ /** `foldrun report <run-id> --compare [<other>]` — this run beside the flow's
6898
+ * last good run (or the one named), from GET …/compare (FOL-72 step 6). */
6899
+ async function reportCompareCmd(url, flags, ws, runId) {
6900
+ const other = typeof flags.compare === "string" ? flags.compare : null;
6901
+ const { comparison: cmp, chosen } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/compare${other ? `?with=${encodeURIComponent(other)}` : ""}`);
6902
+ if (flags.json === true) return console.log(JSON.stringify({ comparison: cmp, chosen }, null, 2)), 0;
6903
+ console.log(`\n ${c.bold(runId)} ${c.dim(`(${cmp.run.status})`)} beside ${c.bold(cmp.base.id)} ${c.dim(`(${chosen}, ${cmp.base.status})`)}`);
6904
+ const files = !cmp.revisionChanged
6905
+ ? "both ran the same version of the files"
6906
+ : cmp.changedFiles === null
6907
+ ? "they ran different versions of the files"
6908
+ : cmp.changedFiles.length
6909
+ ? `files changed between them: ${cmp.changedFiles.slice(0, 8).join(", ")}${cmp.changedFiles.length > 8 ? ` and ${cmp.changedFiles.length - 8} more` : ""}`
6910
+ : "different revisions, no file changes between them";
6911
+ console.log(` ${c.dim(files)}\n`);
6912
+ const changed = cmp.steps.filter((s) => s.changes.length);
6913
+ if (!changed.length) console.log(` ${c.dim("no step differs in status, instruction, model, tools, errors, time or tokens")}`);
6914
+ for (const s of changed) {
6915
+ console.log(` ${c.bold(`step ${s.step} · ${s.agent}`)}`);
6916
+ for (const ch of s.changes) console.log(` ${/^(status|new error|tool errors)/.test(ch) ? c.red("•") : c.dim("•")} ${ch}`);
6917
+ }
6918
+ console.log();
6919
+ return 0;
6920
+ }
6921
+
6371
6922
  async function reportCmd(runId, flags, layout) {
6372
6923
  const url = platformFor(flags, "report");
6373
6924
  if (!runId) throw new Error("which run? `foldrun report <run-id>` — `foldrun runs` lists them");
6374
6925
  const ws = await workspaceOfRun(url, flags, layout, runId);
6926
+ if (flags.tree === true) return reportTreeCmd(url, flags, ws, runId);
6927
+ if (flags.compare !== undefined) return reportCompareCmd(url, flags, ws, runId);
6375
6928
  const run = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}`);
6376
6929
 
6377
6930
  if (flags.json === true) {
@@ -6391,6 +6944,9 @@ async function reportCmd(runId, flags, layout) {
6391
6944
  console.log(` ${c.dim(`${tokens.input.toLocaleString()} in / ${tokens.output.toLocaleString()} out tokens${cached ? ` · ${cached}` : ""}`)}`);
6392
6945
  }
6393
6946
  if (run.budgetUsd) console.log(` ${c.dim(`budget $${Number(run.budgetUsd).toFixed(2)}`)}`);
6947
+ // Which files it ran and how long it waited for a worker (FOL-54 step 4).
6948
+ const ranOn = [run.revision ? `ran on ${String(run.revision).slice(0, 7)}` : null, run.drivenAt ? `queued ${humanDuration(Math.max(0, Date.parse(run.drivenAt) - Date.parse(run.startedAt)))}` : null].filter(Boolean);
6949
+ if (ranOn.length) console.log(` ${c.dim(ranOn.join(" · "))}`);
6394
6950
  if (run.summary) {
6395
6951
  console.log();
6396
6952
  for (const line of wrap(run.summary, 84)) console.log(` ${line}`);
@@ -6413,6 +6969,10 @@ async function reportCmd(runId, flags, layout) {
6413
6969
  console.log(
6414
6970
  ` ${statusMark(step.status)} ${pad(`${i + 1}.`, 4)}${c.dim(`g${step.group ?? 1}`)} ${c.bold(step.agent)} ${c.dim(bits.join(" · "))}`,
6415
6971
  );
6972
+ // A step the prompt cache did not serve says why — which layer of its
6973
+ // prompt changed since the agent's last step — the way the run page does.
6974
+ const cacheNote = promptCacheNote(run.steps ?? [], i);
6975
+ if (cacheNote) console.log(` ${c.dim("·")} ${c.dim(`prompt cache: ${cacheNote}`)}`);
6416
6976
  if (step.verify) {
6417
6977
  // A step that completed with a verify held it — the runtime fails the
6418
6978
  // step otherwise, so status IS the verdict, and saying it out loud is
@@ -6433,6 +6993,26 @@ async function reportCmd(runId, flags, layout) {
6433
6993
  if (step.status === "awaiting-approval") {
6434
6994
  console.log(` ${c.amber("⏸")} ${c.dim(step.waitFor === "event" ? "waiting on an event" : `waiting on a person: ${firstLine(step.ask ?? step.instruction, 70)}`)}`);
6435
6995
  }
6996
+ // Each model call (FOL-54 step 4): one line, or every call with --calls,
6997
+ // and the tool calls whose input and output were recorded.
6998
+ const calls = Array.isArray(step.calls) ? step.calls : [];
6999
+ if (calls.length) {
7000
+ const slowest = Math.max(...calls.map((m) => m.ms ?? 0));
7001
+ console.log(` ${c.dim(`${calls.length} model call${calls.length === 1 ? "" : "s"} · slowest ${humanDuration(slowest)} · largest ${Math.max(...calls.map((m) => m.tokens?.input ?? 0)).toLocaleString()} in`)}`);
7002
+ if (flags.calls === true) {
7003
+ for (const m of calls) {
7004
+ const t = m.tokens ?? {};
7005
+ const cached = t.input > 0 && t.cacheRead > 0 ? ` (${Math.round((100 * t.cacheRead) / t.input)}% cached)` : "";
7006
+ console.log(` ${c.dim(`${m.n}. ${m.model ?? "model"}${m.subagent ? ` ↳ ${m.subagent}` : ""} · ${humanDuration(m.ms ?? 0)} · ${(t.input ?? 0).toLocaleString()} in${cached} · ${(t.output ?? 0).toLocaleString()} out${m.stop ? ` · ${m.stop}` : ""}${m.tools ? ` · ${m.tools} tool${m.tools === 1 ? "" : "s"}` : ""}`)}`);
7007
+ }
7008
+ }
7009
+ }
7010
+ if (flags.calls === true) {
7011
+ // Either half recorded counts, once per call.
7012
+ const recorded = [...new Map((step.events ?? []).filter((e) => e.type === "tool" && e.captured && e.call).map((e) => [e.call, e])).values()];
7013
+ for (const e of recorded) console.log(` ${c.dim(`→ ${e.text} ${e.call}`)}`);
7014
+ if (recorded.length) console.log(` ${c.dim(`\`foldrun report ${run.id} call <id>\` shows one call's input and output`)}`);
7015
+ }
6436
7016
  const said = firstLine(step.conclusion ?? step.result, 96);
6437
7017
  if (said) console.log(` ${said}`);
6438
7018
  const err = stepError(step);
@@ -6634,6 +7214,38 @@ async function agentRunCmd(positional, flags, layout) {
6634
7214
  return reportCmd(runId, { ...flags, to: ws }, layout);
6635
7215
  }
6636
7216
 
7217
+ /**
7218
+ * `foldrun check --to <ws> --online` (FOL-58): the network checks, from the
7219
+ * platform — known vulnerabilities for exact runtime pins (OSV) and what
7220
+ * remote MCP servers tell the model about their tools — and, as loudly,
7221
+ * what could not be checked. Exit 1 on a finding at --fail-on or above
7222
+ * (warn under --security), as the local check does.
7223
+ */
7224
+ async function checkOnline(url, flags, ws) {
7225
+ const answer = await remoteCall(
7226
+ url,
7227
+ flags,
7228
+ `/api/workspaces/${enc(ws)}/check?online=1&rule=supply-chain/known-vulnerability,content/mcp-tool-text`,
7229
+ {},
7230
+ { seconds: Math.max(timeoutSeconds(flags), 120) },
7231
+ );
7232
+ // --rule narrows these as it narrows the offline ones (Codex review).
7233
+ const { rules } = checkOptions(flags);
7234
+ const want = new Set(rules);
7235
+ const findings = (answer.findings ?? []).filter(
7236
+ (f) => !rules.length || want.has(f.rule) || want.has(f.rule.split("/")[0]) || want.has(categoryOf(f.rule)),
7237
+ );
7238
+ const problems = answer.online?.problems ?? [];
7239
+ console.log(` ${c.bold("online checks")} ${c.dim("OSV for exact runtime pins · remote MCP servers' tool text")}`);
7240
+ for (const f of findings) console.log(` ${c.amber(" warn")} ${c.bold(`${f.file}${f.line ? `:${f.line}` : ""}`)} ${f.message} ${c.dim(`[${f.rule}]`)}`);
7241
+ if (!findings.length) console.log(` ${c.green("✓")} ${c.dim(problems.length ? "nothing found in what could be checked" : "nothing found")}`);
7242
+ for (const p of problems) console.log(` ${c.amber("!")} ${c.dim("not checked:")} ${p}`);
7243
+ if (answer.rescan?.checkedAt) console.log(` ${c.dim(`the daily rescan last ran ${answer.rescan.checkedAt}`)}`);
7244
+ console.log();
7245
+ const { failOn } = checkOptions(flags);
7246
+ return findings.some((f) => (RANK[f.level] ?? 0) >= FAIL_ON[failOn]) ? 1 : 0;
7247
+ }
7248
+
6637
7249
  /**
6638
7250
  * `foldrun check --to <workspace>` — validate the copy that is DEPLOYED.
6639
7251
  *
@@ -6679,6 +7291,7 @@ async function checkRemote(flags, layout) {
6679
7291
  // The baseline belongs to the folder it was written in, not to a temp copy.
6680
7292
  const code = await check(dir, { ...flags, "no-baseline": true });
6681
7293
  say(` ${c.dim(`checked what is deployed, not this folder — ${ws} · ${url}`)}\n`);
7294
+ if (flags.online === true) return Math.max(code, await checkOnline(url, flags, ws));
6682
7295
  return code;
6683
7296
  } finally {
6684
7297
  if (before.workspace === undefined) delete process.env.FOLDRUN_WORKSPACE;
@@ -7950,9 +8563,50 @@ function printEvalResult(result) {
7950
8563
  * run there. Each run waits for its verdict: an eval is something you wait
7951
8564
  * for, and the platform answers when every case has.
7952
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
+
7953
8601
  async function remoteEvalsCmd(name, flags, layout) {
7954
8602
  const url = platformFor(flags, "eval --to <workspace>");
7955
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
+ }
7956
8610
  const { evals = [] } = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/evals`);
7957
8611
  const all = evals.filter((e) => !name || e.name === name);
7958
8612
  if (!all.length) {
@@ -7961,7 +8615,36 @@ async function remoteEvalsCmd(name, flags, layout) {
7961
8615
  let failed = 0;
7962
8616
  for (const info of all) {
7963
8617
  console.log(`\n ${c.bold(info.name)} ${c.dim(`${info.cases?.length ?? "?"} cases · ${ws}`)}`);
7964
- 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);
7965
8648
  printEvalResult(result);
7966
8649
  failed += Number(result.failed ?? 0) + (result.error ? 1 : 0);
7967
8650
  }
@@ -7984,6 +8667,8 @@ async function promoteCmd(runId, flags, layout) {
7984
8667
  ...(typeof flags.eval === "string" ? { evalName: flags.eval } : {}),
7985
8668
  ...(typeof flags.case === "string" ? { caseName: flags.case } : {}),
7986
8669
  ...(expect.length ? { expect } : {}),
8670
+ // A run that went wrong: what it should have done (FOL-72 step 10).
8671
+ ...(typeof flags.should === "string" ? { should: flags.should } : {}),
7987
8672
  }),
7988
8673
  });
7989
8674
  const evalName = path.basename(String(r.file ?? ""), ".md");
@@ -8573,8 +9258,10 @@ async function workspaceDefaults(url, flags, ws) {
8573
9258
  async function workspaceCmd(positional, flags, layout) {
8574
9259
  const url = platformFor(flags, "workspace");
8575
9260
  const ws = platformWorkspace(flags, layout, "workspace");
8576
- const [verb, key, ...rest] = positional;
8577
- const KEYS = ["name", "description", "timezone", "budget", "notify"];
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"];
8578
9265
  const show = (d, title) => {
8579
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");
8580
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`;
@@ -8583,15 +9270,24 @@ async function workspaceCmd(positional, flags, layout) {
8583
9270
  console.log(` timezone ${d.timezone ?? c.dim("the account's")}`);
8584
9271
  console.log(` notify ${notify}`);
8585
9272
  console.log(` budget ${budget}`);
9273
+ if (d.networkEnforce !== undefined) console.log(` network ${networkEnforceText(d.networkEnforce, "the account")}`);
8586
9274
  };
8587
9275
 
8588
9276
  if (!verb) {
8589
9277
  const d = await workspaceDefaults(url, flags, ws);
8590
- show({ description: typeof d.description === "string" ? d.description : "", timezone: d.timezone ?? null, budget: d.budget ?? null, notify: d.notify ?? null }, ws);
9278
+ show({ description: typeof d.description === "string" ? d.description : "", timezone: d.timezone ?? null, budget: d.budget ?? null, notify: d.notify ?? null, networkEnforce: d.network_enforce === true }, ws);
9279
+ const halt = await pauseState(remoteCall(url, flags, `/api/workspaces/${enc(ws)}/pause`));
9280
+ if (halt) console.log(` paused ${halt.paused ? c.red(`${halt.paused.scope === "account" ? "by the account: " : ""}${pauseLine(halt.paused)}`) : c.dim("no")}`);
8591
9281
  console.log(`\n ${c.dim(`foldrun workspace set timezone Australia/Sydney --to ${ws} · set budget 20/week · set notify email you@example.com · set name <new> · clear budget`)}\n`);
8592
9282
  return 0;
8593
9283
  }
8594
- if (verb !== "set" && verb !== "clear") throw new Error(`unknown workspace verb "${verb}" — set <key> <value>, clear <key> (bare: show them)`);
9284
+ if (verb === "pause" || verb === "resume") {
9285
+ return pauseCmd(flags, verb, `workspace ${ws}`, {
9286
+ post: (body) => remoteCall(url, flags, `/api/workspaces/${enc(ws)}/pause`, { method: "POST", body }),
9287
+ del: () => remoteCall(url, flags, `/api/workspaces/${enc(ws)}/pause`, { method: "DELETE" }),
9288
+ });
9289
+ }
9290
+ if (verb !== "set" && verb !== "clear") throw new Error(`unknown workspace verb "${verb}" — set <key> <value>, clear <key>, pause, resume (bare: show them)`);
8595
9291
  if (!KEYS.includes(key) || (verb === "clear" && key === "name")) {
8596
9292
  throw new Error(`which setting? \`foldrun workspace ${verb} <${(verb === "clear" ? KEYS.filter((k) => k !== "name") : KEYS).join("|")}> … --to <workspace>\``);
8597
9293
  }
@@ -8599,7 +9295,9 @@ async function workspaceCmd(positional, flags, layout) {
8599
9295
  /** @type {Record<string, any>} */
8600
9296
  let patch;
8601
9297
  if (verb === "clear") patch = { [key]: key === "description" ? "" : null };
8602
- 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") {
8603
9301
  const [channel, value] = rest;
8604
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");
8605
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`);
@@ -8612,7 +9310,7 @@ async function workspaceCmd(positional, flags, layout) {
8612
9310
  } else {
8613
9311
  const value = rest.join(" ").trim();
8614
9312
  if (!value) throw new Error(`\`foldrun workspace set ${key} <value> --to ${ws}\`${key === "name" ? "" : ` — or \`clear ${key}\``}`);
8615
- patch = { [key]: value };
9313
+ patch = { [key]: key === "network_enforce" ? onOff(key, value, `foldrun workspace set network_enforce on --to ${ws}`) : value };
8616
9314
  }
8617
9315
 
8618
9316
  if (key === "name" && verb === "set") {
@@ -9286,6 +9984,7 @@ async function flowDraftCmd(positional, flags, layout) {
9286
9984
  const existing = typeof flags.flow === "string" ? flags.flow : undefined;
9287
9985
  console.log(`\n ${c.dim(`drafting in ${ws}${existing ? ` (redraft of ${existing})` : ""} — one model call, charged like a step…`)}`);
9288
9986
  const d = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/flows/draft`, { method: "POST", body: JSON.stringify({ description, ...(existing ? { flow: existing } : {}) }) }, { seconds: Math.max(timeoutSeconds(flags), 180) });
9987
+ /** @type {{ path: string, content: string, before: string | null, sha256?: string | null }[]} */
9289
9988
  const files = d.files ?? [];
9290
9989
  if (d.notes) console.log(`\n ${d.notes}`);
9291
9990
  console.log();
@@ -9656,11 +10355,14 @@ async function historyRestoreCmd(positional, flags, layout) {
9656
10355
  return 0;
9657
10356
  }
9658
10357
  // The dashboard's own message, so the two read alike in the history.
9659
- await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/source`, {
10358
+ // A restore is an exact version someone picked: saved unchecked, and it
10359
+ // says so when today's checks would refuse it, as the dashboard does (FOL-57).
10360
+ const saved = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/source`, {
9660
10361
  method: "PUT",
9661
10362
  body: JSON.stringify({ path: file, content: f.after, message: `restored ${label} from history`, ifMatch: current.sha256 ?? null }),
9662
10363
  });
9663
10364
  console.log(` ${c.green("✓")} ${file} ${c.dim(`reads as it did at ${label} — a new revision; what it said before is still in the history`)}\n`);
10365
+ for (const i of (saved?.issues ?? []).filter((x) => x.level === "error")) console.log(` ${c.amber("!")} ${c.dim("does not pass today's checks:")} ${i.where} — ${i.message}`);
9664
10366
  return 0;
9665
10367
  }
9666
10368
 
@@ -10175,12 +10877,13 @@ async function evalNewCmd(name, flags, layout) {
10175
10877
  /**
10176
10878
  * `foldrun team` — who is in the account, their role and which workspaces
10177
10879
  * they may open (GET /api/team). Read-only on purpose: inviting, changing a
10178
- * role, removing someone and transferring the account are a person's acts,
10179
- * and the platform refuses them from a key.
10880
+ * role or workspace access, removing someone and transferring the account are
10881
+ * a person's acts, and the platform refuses them from a key (requirePerson,
10882
+ * server/api.ts) — so there is nothing for a key-holding CLI to send.
10180
10883
  */
10181
10884
  async function teamCmd(positional, flags) {
10182
10885
  if (positional[0]) {
10183
- throw new Error("`foldrun team` lists the members — invites, role changes, removals and transfer are done by a signed-in person in Settings → Team; the platform refuses them from a key");
10886
+ throw new Error("`foldrun team` lists the members — inviting, changing a role or workspace access, removing a member and transferring the account are a signed-in person's acts, done in Settings → Team; the platform refuses them from a key");
10184
10887
  }
10185
10888
  const url = platformFor(flags, "team");
10186
10889
  const { members = [] } = await remoteCall(url, flags, "/api/team");
@@ -10402,6 +11105,14 @@ export async function run(command, positional, flags, workspace, layout) {
10402
11105
  say(` ${c.dim(`coding-agent rules written for ${codingAgent()} — AGENTS.md ${r.agentsMd}, CLAUDE.md ${r.claudeMd}; read \`foldrun docs\` before editing`)}`);
10403
11106
  }
10404
11107
  if (flags.rules === true) return checkRulesCmd(flags);
11108
+ // --online (FOL-58): the network checks run on the platform, which has
11109
+ // the guarded fetch and the deployed copy — so it needs --to.
11110
+ if (flags.online === true && typeof flags.to !== "string") {
11111
+ throw new Error("`foldrun check --to <workspace> --online` — the online checks (OSV, remote MCP tool text) run on the platform, against the deployed copy");
11112
+ }
11113
+ if (flags.online === true && (flags.json === true || (typeof flags.format === "string" && flags.format !== "text"))) {
11114
+ throw new Error("--online prints text; for JSON ask the API: GET /api/workspaces/<ws>/check?online=1");
11115
+ }
10405
11116
  // --to names a workspace on a PLATFORM, so there is nothing local to
10406
11117
  // check and the folder this terminal stands in is beside the point.
10407
11118
  return typeof flags.to === "string"
@@ -10434,6 +11145,9 @@ export async function run(command, positional, flags, workspace, layout) {
10434
11145
  if (positional[0] === "new" && positional[1]) return evalNewCmd(positional[1], flags, layout);
10435
11146
  // --to names a deployed workspace: its evals run there.
10436
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>");
10437
11151
  needsOne(layout, "eval");
10438
11152
  return runEvals(positional[0], flags);
10439
11153
  case "promote":
@@ -10485,14 +11199,28 @@ export async function run(command, positional, flags, workspace, layout) {
10485
11199
  // the local store has no table to draw across workspaces, because a
10486
11200
  // folder on a laptop is one workspace.
10487
11201
  if (positional[0] === "rm") return runRmCmd(positional[1], flags, layout);
11202
+ if (positional[0] === "views") return runViewsCmd(flags);
11203
+ // The run doctor (FOL-22): the assistant reads the run and its files,
11204
+ // says why it failed and proposes the fix as diffs — saved on yes.
11205
+ if (positional[0] === "doctor") {
11206
+ if (positional.length !== 2 || !/^[A-Za-z0-9_.-]{1,80}$/.test(positional[1])) {
11207
+ throw new Error("`foldrun runs doctor <run-id> --to <workspace>` — the run doctor reads that run and proposes the fix");
11208
+ }
11209
+ return assistantCmd([`Doctor: why did run ${positional[1]} fail, and how do I fix it?`], flags, layout);
11210
+ }
10488
11211
  return flags.local === true ? logsCmd(positional, flags, layout) : runsCmd(flags, layout);
10489
11212
  case "approvals":
10490
11213
  return approvalsCmd(flags, layout);
11214
+ case "issues":
11215
+ return issuesCmd(positional, flags, layout);
11216
+ case "insights":
11217
+ return insightsCmd(flags);
10491
11218
  case "approve":
10492
11219
  case "reject":
10493
11220
  return decideCmd(command, positional[0], flags, layout);
10494
11221
  case "report":
10495
11222
  if (positional[1] === "live") return reportLiveCmd(positional[0], flags, layout);
11223
+ if (positional[1] === "call") return reportCallCmd(positional[0], positional[2], flags, layout);
10496
11224
  return positional[1] === "get" ? reportGetCmd(positional[0], positional[2], flags, layout) : reportCmd(positional[0], flags, layout);
10497
11225
  case "stop":
10498
11226
  // No run id and a filter: every run the filter matches (runs/bulk).
@@ -10507,6 +11235,8 @@ export async function run(command, positional, flags, workspace, layout) {
10507
11235
  return rerunCmd(positional[0], flags, layout);
10508
11236
  case "account":
10509
11237
  return accountCmd(positional, flags);
11238
+ case "jobs":
11239
+ return jobsCmd(flags, layout);
10510
11240
  case "schedule":
10511
11241
  if (positional[0] === "tick") return scheduleTickCmd(flags);
10512
11242
  return scheduleCmd(flags);