@sweny-ai/core 0.1.177 → 0.1.179

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.
@@ -373,9 +373,11 @@ export declare function checkSandboxSupport(platform?: NodeJS.Platform, pathEnv?
373
373
  /**
374
374
  * The SDK sandbox settings used when the sandbox is on.
375
375
  *
376
- * - `failIfUnavailable`: true under `strict` (the SDK errors instead of
377
- * running unsandboxed); false under `auto`, so a host that passes the
378
- * preflight but still cannot start the sandbox degrades instead of failing.
376
+ * - `failIfUnavailable`: true whenever settings are built, `auto` included:
377
+ * the preflight already passed, so a sandbox that then fails to start
378
+ * fails the node instead of silently running the agent unsandboxed (which
379
+ * the run would otherwise report as contained). `auto` on a host whose
380
+ * preflight fails builds no settings and runs unsandboxed, reported as such.
379
381
  * - `allowUnsandboxedCommands: false`: the model cannot escape via the
380
382
  * `dangerouslyDisableSandbox` parameter. Required, because nodes run under
381
383
  * `bypassPermissions` (#365) which would otherwise auto-approve the escape.
package/dist/agent-env.js CHANGED
@@ -29,7 +29,7 @@ import { execFileSync } from "node:child_process";
29
29
  import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
30
30
  import { homedir, tmpdir } from "node:os";
31
31
  import * as path from "node:path";
32
- import { runKeyDir } from "./journal.js";
32
+ import { runStateRoot } from "./journal.js";
33
33
  // ─── Scoped env ──────────────────────────────────────────────────
34
34
  /**
35
35
  * Exact variable names always passed to the agent subprocess.
@@ -938,9 +938,11 @@ function defaultProbe() {
938
938
  /**
939
939
  * The SDK sandbox settings used when the sandbox is on.
940
940
  *
941
- * - `failIfUnavailable`: true under `strict` (the SDK errors instead of
942
- * running unsandboxed); false under `auto`, so a host that passes the
943
- * preflight but still cannot start the sandbox degrades instead of failing.
941
+ * - `failIfUnavailable`: true whenever settings are built, `auto` included:
942
+ * the preflight already passed, so a sandbox that then fails to start
943
+ * fails the node instead of silently running the agent unsandboxed (which
944
+ * the run would otherwise report as contained). `auto` on a host whose
945
+ * preflight fails builds no settings and runs unsandboxed, reported as such.
944
946
  * - `allowUnsandboxedCommands: false`: the model cannot escape via the
945
947
  * `dangerouslyDisableSandbox` parameter. Required, because nodes run under
946
948
  * `bypassPermissions` (#365) which would otherwise auto-approve the escape.
@@ -963,10 +965,12 @@ export function buildSandboxSettings(domains, opts = {}) {
963
965
  envVars: AGENT_AUTH_VARS.map((name) => ({ name, mode: "deny" })),
964
966
  files: [
965
967
  { path: path.join(opts.home ?? homedir(), ".claude", ".credentials.json"), mode: "deny" },
966
- // Run journal keys: an agent that could read them could forge journal records.
967
- { path: runKeyDir(), mode: "deny" },
968
+ // Run journals and their keys: an agent that could read a key could forge records.
969
+ { path: runStateRoot(), mode: "deny" },
968
970
  ],
969
971
  },
972
+ // Nor may it write there: cut a journal, delete a lock, or plant a run.
973
+ filesystem: { denyRead: [runStateRoot()], denyWrite: [runStateRoot()] },
970
974
  };
971
975
  }
972
976
  /**
@@ -1003,7 +1007,8 @@ export function resolveAgentSandbox(opts) {
1003
1007
  return {
1004
1008
  mode,
1005
1009
  settings: buildSandboxSettings([...DEFAULT_SANDBOX_DOMAINS, ...(opts.nodeDomains ?? []), ...extra], {
1006
- failIfUnavailable: mode === "strict",
1010
+ // Preflight passed: from here the sandbox is enforced or the node fails.
1011
+ failIfUnavailable: true,
1007
1012
  }),
1008
1013
  };
1009
1014
  }
@@ -24,6 +24,27 @@ export declare function loadDotenv(cwd?: string): void;
24
24
  * treats as on/off.
25
25
  */
26
26
  export declare function applyAgentFileConfig(fileConfig: FileConfig, env?: NodeJS.ProcessEnv): void;
27
+ /**
28
+ * Operator config for the decision model (#357), one trust domain per
29
+ * credential. `.sweny.yml` is repo content a pull request can change, so:
30
+ *
31
+ * - SWENY_DECIDER_URL (operator env) may point anywhere public; only then is
32
+ * SWENY_DECIDER_API_KEY sent, and only SWENY_DECIDER_ALLOW_PRIVATE=true
33
+ * (env) admits loopback or private addresses.
34
+ * - A `.sweny.yml` `decider: { url, model }` URL must be loopback (local
35
+ * Ollama) and never gets a key.
36
+ *
37
+ * `trusted(key)` reads an operator env value; it must not see values a
38
+ * committed `.env` supplied. `noDecider` (`--no-decider`) returns false.
39
+ * Undefined when no URL and model are configured.
40
+ */
41
+ export declare function operatorDeciderConfig(fileConfig: FileConfig, env: NodeJS.ProcessEnv, noDecider: boolean, trusted?: (key: string) => string | undefined): {
42
+ url: string;
43
+ model: string;
44
+ apiKey?: string;
45
+ allowPrivate?: boolean;
46
+ loopbackOnly?: boolean;
47
+ } | false | undefined;
27
48
  /** Parsed config file — flat strings for scalar fields, arrays for list fields, objects for nested blocks. */
28
49
  export type FileConfig = Record<string, string | string[] | Record<string, unknown>>;
29
50
  /**
@@ -33,4 +54,4 @@ export type FileConfig = Record<string, string | string[] | Record<string, unkno
33
54
  */
34
55
  export declare function loadConfigFile(cwd?: string): FileConfig;
35
56
  /** Starter config written by `sweny new`. */
36
- export declare const STARTER_CONFIG = "# .sweny.yml \u2014 SWEny project configuration\n# Commit this file. Secrets (API keys, tokens) go in .env (gitignored).\n#\n# Every key matches a CLI flag: \"time-range: 4h\" is the same as \"--time-range 4h\".\n# CLI flags override this file; env vars override this file; this file overrides defaults.\n\n# \u2500\u2500 Providers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# observability-provider: datadog # datadog | sentry | cloudwatch | splunk | elastic | newrelic | loki | prometheus | pagerduty | heroku | opsgenie | vercel | supabase | netlify | fly | render | file\n# issue-tracker-provider: github-issues # github-issues | linear | jira\n# source-control-provider: github # github | gitlab\n# coding-agent-provider: claude # claude | codex | pi\n# notification-provider: console # console | slack | teams | discord | email | webhook\n\n# \u2500\u2500 Investigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# time-range: 24h\n# severity-focus: errors\n# service-filter: \"*\"\n# investigation-depth: standard # quick | standard | thorough\n\n# \u2500\u2500 PR / branch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# base-branch: main\n# pr-labels: agent,triage,needs-review\n\n# \u2500\u2500 Paths \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# service-map-path: .github/service-map.yml\n# log-file: ./logs/errors.json # required when observability-provider is \"file\"\n\n# \u2500\u2500 Cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# cache-dir: .sweny/cache\n# cache-ttl: 86400\n\n# \u2500\u2500 Run reporting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Opt-in and not currently available: token minting is not yet exposed, so\n# leaving this unset means no reporting request is ever made.\n# cloud-token: sweny_pk_...\n# Or set SWENY_CLOUD_TOKEN in your environment.\n\n# \u2500\u2500 Agent sandbox \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# See https://docs.sweny.ai/advanced/agent-sandbox/\n# sandbox: off # off (local default) | auto (CI default: sandbox if supported, else warn) | strict\n# sandbox-allowed-domains: [internal.example.com] # extra hosts agent commands may reach\n# env-scope: off # off (local default: full env) | on (CI default: allowlist)\n# env-passthrough: [NPM_TOKEN] # extra env vars the agent may see when scoped (\"*\" = all)\n\n# \u2500\u2500 MCP servers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Extend the coding agent with additional tools via MCP.\n# Value is a JSON object \u2014 each key is a server name you choose.\n# See https://docs.sweny.ai/advanced/mcp-servers/ for a full catalog with copy-paste configs.\n#\n# Example: GitHub MCP server (query PRs, issues, CI run logs)\n# mcp-servers-json: '{\"github\":{\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"-y\",\"@modelcontextprotocol/server-github@latest\"],\"env\":{\"GITHUB_PERSONAL_ACCESS_TOKEN\":\"ghp_...\"}}}'\n\n# \u2500\u2500 Local-only quick start \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Uncomment to run without any external services (just an LLM API key):\n# observability-provider: file\n# log-file: ./sample-errors.json\n# issue-tracker-provider: file\n# source-control-provider: file\n# notification-provider: file\n# output-dir: .sweny/output\n\n# \u2500\u2500 Credentials (.env) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Copy the relevant block into your .env file and fill in the values.\n#\n# Claude (coding agent) \u2014 https://console.anthropic.com/settings/api-keys\n# ANTHROPIC_API_KEY=sk-ant-...\n#\n# GitHub (source control + issue tracker)\n# GITHUB_TOKEN=ghp_... # https://github.com/settings/tokens (repo + issues scopes)\n#\n# Datadog (observability) \u2014 https://app.datadoghq.com/organization-settings\n# DD_API_KEY=... # Organization Settings > API Keys\n# DD_APP_KEY=... # Organization Settings > Application Keys\n# DD_SITE=datadoghq.com # or datadoghq.eu, us3.datadoghq.com, etc.\n#\n# Sentry (observability) \u2014 https://sentry.io/settings/auth-tokens/\n# SENTRY_AUTH_TOKEN=sntrys_...\n# SENTRY_ORG=your-org-slug # from sentry.io/organizations/<slug>/\n# SENTRY_PROJECT=your-project # Project Settings > General > Project Slug\n#\n# Linear (issue tracker) \u2014 https://linear.app/settings/api\n# LINEAR_API_KEY=lin_api_...\n# LINEAR_TEAM_ID=... # Settings > Workspace > Teams > [team] > copy ID from URL\n# LINEAR_BUG_LABEL_ID=... # Settings > Labels > [label] > copy ID from URL\n#\n# Jira (issue tracker) \u2014 https://your-org.atlassian.net\n# JIRA_BASE_URL=https://your-org.atlassian.net\n# JIRA_EMAIL=you@company.com # your Atlassian account email\n# JIRA_API_TOKEN=... # https://id.atlassian.com/manage-profile/security/api-tokens\n#\n# Vercel (observability) \u2014 https://vercel.com/account/tokens\n# VERCEL_TOKEN=...\n# VERCEL_PROJECT_ID=prj_... # Project Settings > General > Project ID\n# VERCEL_TEAM_ID=team_... # optional, for team-owned projects\n#\n# Supabase (observability) \u2014 https://supabase.com/dashboard/account/tokens\n# SUPABASE_MANAGEMENT_KEY=...\n# SUPABASE_PROJECT_REF=... # Project Settings > General > Reference ID\n#\n# Netlify (observability) \u2014 https://app.netlify.com/user/applications#personal-access-tokens\n# NETLIFY_TOKEN=...\n# NETLIFY_SITE_ID=... # Site Settings > General > Site ID\n#\n# Fly.io (observability) \u2014 https://fly.io/user/personal_access_tokens\n# FLY_TOKEN=...\n# FLY_APP_NAME=... # the name of your Fly.io application\n#\n# Render (observability) \u2014 https://dashboard.render.com/u/settings\n# RENDER_API_KEY=...\n# RENDER_SERVICE_ID=srv-... # from your service's Settings page\n#\n# Prometheus (observability) \u2014 self-hosted or Grafana Cloud\n# PROMETHEUS_URL=http://prometheus.internal:9090\n# PROMETHEUS_TOKEN=... # optional, for secured instances\n#\n# PagerDuty (observability) \u2014 https://your-account.pagerduty.com/api_keys\n# PAGERDUTY_API_KEY=...\n#\n# Honeycomb (observability) \u2014 https://docs.honeycomb.io/api/\n# HONEYCOMB_API_KEY=...\n# HONEYCOMB_DATASET=... # dataset name (e.g. production)\n#\n# Heroku (observability) \u2014 https://devcenter.heroku.com/articles/platform-api-reference\n# HEROKU_API_KEY=... # https://dashboard.heroku.com/account\n# HEROKU_APP_NAME=... # the name of your Heroku application\n#\n# OpsGenie (observability) \u2014 https://support.atlassian.com/opsgenie/docs/api-key-management/\n# OPSGENIE_API_KEY=...\n# OPSGENIE_REGION=us # or eu for EU-hosted accounts\n#\n# Slack (notifications) \u2014 https://api.slack.com/apps\n# NOTIFICATION_WEBHOOK_URL=https://hooks.slack.com/services/...\n# # or use a bot token: SLACK_BOT_TOKEN=xoxb-...\n";
57
+ export declare const STARTER_CONFIG = "# .sweny.yml \u2014 SWEny project configuration\n# Commit this file. Secrets (API keys, tokens) go in .env (gitignored).\n#\n# Every key matches a CLI flag: \"time-range: 4h\" is the same as \"--time-range 4h\".\n# CLI flags override this file; env vars override this file; this file overrides defaults.\n\n# \u2500\u2500 Providers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# observability-provider: datadog # datadog | sentry | cloudwatch | splunk | elastic | newrelic | loki | prometheus | pagerduty | heroku | opsgenie | vercel | supabase | netlify | fly | render | file\n# issue-tracker-provider: github-issues # github-issues | linear | jira\n# source-control-provider: github # github | gitlab\n# coding-agent-provider: claude # claude | codex | pi\n# notification-provider: console # console | slack | teams | discord | email | webhook\n\n# \u2500\u2500 Investigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# time-range: 24h\n# severity-focus: errors\n# service-filter: \"*\"\n# investigation-depth: standard # quick | standard | thorough\n\n# \u2500\u2500 PR / branch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# base-branch: main\n# pr-labels: agent,triage,needs-review\n\n# \u2500\u2500 Paths \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# service-map-path: .github/service-map.yml\n# log-file: ./logs/errors.json # required when observability-provider is \"file\"\n\n# \u2500\u2500 Cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# cache-dir: .sweny/cache\n# cache-ttl: 86400\n\n# \u2500\u2500 Run reporting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Opt-in and not currently available: token minting is not yet exposed, so\n# leaving this unset means no reporting request is ever made.\n# cloud-token: sweny_pk_...\n# Or set SWENY_CLOUD_TOKEN in your environment.\n\n# \u2500\u2500 Agent sandbox \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# See https://docs.sweny.ai/advanced/agent-sandbox/\n# sandbox: off # off (local default) | auto (CI default: sandbox if supported, else warn) | strict\n# sandbox-allowed-domains: [internal.example.com] # extra hosts agent commands may reach\n# env-scope: off # off (local default: full env) | on (CI default: allowlist)\n# env-passthrough: [NPM_TOKEN] # extra env vars the agent may see when scoped (\"*\" = all)\n\n# \u2500\u2500 MCP servers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Extend the coding agent with additional tools via MCP.\n# Value is a JSON object \u2014 each key is a server name you choose.\n# See https://docs.sweny.ai/advanced/mcp-servers/ for a full catalog with copy-paste configs.\n#\n# Example: GitHub MCP server (query PRs, issues, CI run logs)\n# mcp-servers-json: '{\"github\":{\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"-y\",\"@modelcontextprotocol/server-github@latest\"],\"env\":{\"GITHUB_PERSONAL_ACCESS_TOKEN\":\"ghp_...\"}}}'\n\n# \u2500\u2500 Local-only quick start \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Uncomment to run without any external services (just an LLM API key):\n# observability-provider: file\n# log-file: ./sample-errors.json\n# issue-tracker-provider: file\n# source-control-provider: file\n# notification-provider: file\n# output-dir: .sweny/output\n\n# \u2500\u2500 Credentials (.env) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Copy the relevant block into your .env file and fill in the values.\n#\n# Claude (coding agent) \u2014 https://console.anthropic.com/settings/api-keys\n# ANTHROPIC_API_KEY=sk-ant-...\n#\n# GitHub (source control + issue tracker)\n# GITHUB_TOKEN=ghp_... # https://github.com/settings/tokens (repo + issues scopes)\n#\n# Datadog (observability) \u2014 https://app.datadoghq.com/organization-settings\n# DD_API_KEY=... # Organization Settings > API Keys\n# DD_APP_KEY=... # Organization Settings > Application Keys\n# DD_SITE=datadoghq.com # or datadoghq.eu, us3.datadoghq.com, etc.\n#\n# Sentry (observability) \u2014 https://sentry.io/settings/auth-tokens/\n# SENTRY_AUTH_TOKEN=sntrys_...\n# SENTRY_ORG=your-org-slug # from sentry.io/organizations/<slug>/\n# SENTRY_PROJECT=your-project # Project Settings > General > Project Slug\n#\n# Linear (issue tracker) \u2014 https://linear.app/settings/api\n# LINEAR_API_KEY=lin_api_...\n# LINEAR_TEAM_ID=... # Settings > Workspace > Teams > [team] > copy ID from URL\n# LINEAR_BUG_LABEL_ID=... # Settings > Labels > [label] > copy ID from URL\n#\n# Jira (issue tracker) \u2014 https://your-org.atlassian.net\n# JIRA_BASE_URL=https://your-org.atlassian.net\n# JIRA_EMAIL=you@company.com # your Atlassian account email\n# JIRA_API_TOKEN=... # https://id.atlassian.com/manage-profile/security/api-tokens\n#\n# Vercel (observability) \u2014 https://vercel.com/account/tokens\n# VERCEL_TOKEN=...\n# VERCEL_PROJECT_ID=prj_... # Project Settings > General > Project ID\n# VERCEL_TEAM_ID=team_... # optional, for team-owned projects\n#\n# Supabase (observability) \u2014 https://supabase.com/dashboard/account/tokens\n# SUPABASE_MANAGEMENT_KEY=...\n# SUPABASE_PROJECT_REF=... # Project Settings > General > Reference ID\n#\n# Netlify (observability) \u2014 https://app.netlify.com/user/applications#personal-access-tokens\n# NETLIFY_TOKEN=...\n# NETLIFY_SITE_ID=... # Site Settings > General > Site ID\n#\n# Fly.io (observability) \u2014 https://fly.io/user/personal_access_tokens\n# FLY_TOKEN=...\n# FLY_APP_NAME=... # the name of your Fly.io application\n#\n# Render (observability) \u2014 https://dashboard.render.com/u/settings\n# RENDER_API_KEY=...\n# RENDER_SERVICE_ID=srv-... # from your service's Settings page\n#\n# Prometheus (observability) \u2014 self-hosted or Grafana Cloud\n# PROMETHEUS_URL=http://prometheus.internal:9090\n# PROMETHEUS_TOKEN=... # optional, for secured instances\n#\n# PagerDuty (observability) \u2014 https://your-account.pagerduty.com/api_keys\n# PAGERDUTY_API_KEY=...\n#\n# Honeycomb (observability) \u2014 https://docs.honeycomb.io/api/\n# HONEYCOMB_API_KEY=...\n# HONEYCOMB_DATASET=... # dataset name (e.g. production)\n#\n# Heroku (observability) \u2014 https://devcenter.heroku.com/articles/platform-api-reference\n# HEROKU_API_KEY=... # https://dashboard.heroku.com/account\n# HEROKU_APP_NAME=... # the name of your Heroku application\n#\n# OpsGenie (observability) \u2014 https://support.atlassian.com/opsgenie/docs/api-key-management/\n# OPSGENIE_API_KEY=...\n# OPSGENIE_REGION=us # or eu for EU-hosted accounts\n#\n# Slack (notifications) \u2014 https://api.slack.com/apps\n# NOTIFICATION_WEBHOOK_URL=https://hooks.slack.com/services/...\n# # or use a bot token: SLACK_BOT_TOKEN=xoxb-...\n#\n# Decision model (optional) for nodes with route_by: decider. https://docs.sweny.ai/advanced/decision-models/\n# Local Ollama: set it here (a URL in this file must be loopback and never gets a key):\n# decider:\n# url: http://localhost:11434\n# model: nimble\n# Remote server: set it in the CI or shell environment only, never in this file or .env:\n# SWENY_DECIDER_URL=https://... SWENY_DECIDER_MODEL=... SWENY_DECIDER_API_KEY=...\n";
@@ -91,6 +91,40 @@ export function applyAgentFileConfig(fileConfig, env = process.env) {
91
91
  }
92
92
  }
93
93
  }
94
+ /**
95
+ * Operator config for the decision model (#357), one trust domain per
96
+ * credential. `.sweny.yml` is repo content a pull request can change, so:
97
+ *
98
+ * - SWENY_DECIDER_URL (operator env) may point anywhere public; only then is
99
+ * SWENY_DECIDER_API_KEY sent, and only SWENY_DECIDER_ALLOW_PRIVATE=true
100
+ * (env) admits loopback or private addresses.
101
+ * - A `.sweny.yml` `decider: { url, model }` URL must be loopback (local
102
+ * Ollama) and never gets a key.
103
+ *
104
+ * `trusted(key)` reads an operator env value; it must not see values a
105
+ * committed `.env` supplied. `noDecider` (`--no-decider`) returns false.
106
+ * Undefined when no URL and model are configured.
107
+ */
108
+ export function operatorDeciderConfig(fileConfig, env, noDecider, trusted = (key) => env[key]) {
109
+ if (noDecider)
110
+ return false;
111
+ const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
112
+ const model = str(trusted("SWENY_DECIDER_MODEL")) ?? str(env.SWENY_DECIDER_MODEL) ?? str(fileConfig["decider.model"]);
113
+ const envUrl = str(trusted("SWENY_DECIDER_URL"));
114
+ if (envUrl) {
115
+ if (!model)
116
+ return undefined;
117
+ const apiKey = str(trusted("SWENY_DECIDER_API_KEY"));
118
+ const allowPrivate = ["1", "true"].includes((str(trusted("SWENY_DECIDER_ALLOW_PRIVATE")) ?? "").toLowerCase());
119
+ return { url: envUrl, model, ...(apiKey ? { apiKey } : {}), ...(allowPrivate ? { allowPrivate } : {}) };
120
+ }
121
+ // Repo content: a URL a committed `.env` put in the environment (untrusted),
122
+ // else the `.sweny.yml` one. Loopback only, never a key.
123
+ const repoUrl = str(env.SWENY_DECIDER_URL) ?? str(fileConfig["decider.url"]);
124
+ if (!repoUrl || !model)
125
+ return undefined;
126
+ return { url: repoUrl, model, loopbackOnly: true };
127
+ }
94
128
  /**
95
129
  * Search upward from `cwd` for `.sweny.yml` and parse it.
96
130
  * Scalar values are strings, list values (rules, context) are string arrays.
@@ -296,4 +330,12 @@ export const STARTER_CONFIG = `# .sweny.yml — SWEny project configuration
296
330
  # Slack (notifications) — https://api.slack.com/apps
297
331
  # NOTIFICATION_WEBHOOK_URL=https://hooks.slack.com/services/...
298
332
  # # or use a bot token: SLACK_BOT_TOKEN=xoxb-...
333
+ #
334
+ # Decision model (optional) for nodes with route_by: decider. https://docs.sweny.ai/advanced/decision-models/
335
+ # Local Ollama: set it here (a URL in this file must be loopback and never gets a key):
336
+ # decider:
337
+ # url: http://localhost:11434
338
+ # model: nimble
339
+ # Remote server: set it in the CI or shell environment only, never in this file or .env:
340
+ # SWENY_DECIDER_URL=https://... SWENY_DECIDER_MODEL=... SWENY_DECIDER_API_KEY=...
299
341
  `;
@@ -193,7 +193,10 @@ export function outputRelPath(runId) {
193
193
  export function writeFinalOutput(runId, markdown, cwd = process.cwd()) {
194
194
  try {
195
195
  const rel = outputRelPath(runId);
196
- writeFileNoFollow(path.join(cwd, rel), markdown, { root: cwd, mode: 0o600 });
196
+ const file = path.join(cwd, rel);
197
+ // Node output: keep the run dir out of commits (an agent's `git add -A` included).
198
+ writeFileNoFollow(path.join(path.dirname(file), ".gitignore"), "*\n", { root: cwd, mode: 0o644 });
199
+ writeFileNoFollow(file, markdown, { root: cwd, mode: 0o600 });
197
200
  return rel;
198
201
  }
199
202
  catch {
@@ -13,7 +13,8 @@ export declare function workflowRunAction(file: string | undefined, options: Rec
13
13
  maxTokens?: string;
14
14
  maxCost?: string;
15
15
  yes?: boolean;
16
- decider?: string;
16
+ /** False with `--no-decider`. */
17
+ decider?: boolean;
17
18
  },
18
19
  /** Set by `sweny workflow resume` (#363): the journal, run id and original input. */
19
20
  resume?: ResumeContext): Promise<void>;
package/dist/cli/main.js CHANGED
@@ -29,7 +29,7 @@ import { toMermaidBlock } from "../mermaid.js";
29
29
  import { runWorkflowDiagram } from "./diagram.js";
30
30
  import { DagRenderer } from "./renderer.js";
31
31
  import * as readline from "node:readline";
32
- import { loadDotenv, loadConfigFile, applyAgentFileConfig } from "./config-file.js";
32
+ import { loadDotenv, loadConfigFile, applyAgentFileConfig, operatorDeciderConfig } from "./config-file.js";
33
33
  import { buildCredentialMap } from "./credentials.js";
34
34
  import { nonInteractiveUsage, runNew } from "./new.js";
35
35
  import { formatFinalMarkdown, formatFinalOutput, formatSavedOutputLine, resolveFinalOutput, shouldPrintFinalOutput, writeFinalOutput, } from "./final-output.js";
@@ -753,10 +753,6 @@ export function loadWorkflowFile(filePath, knownSkills) {
753
753
  export async function workflowRunAction(file, options,
754
754
  /** Set by `sweny workflow resume` (#363): the journal, run id and original input. */
755
755
  resume) {
756
- if (options.decider !== undefined && options.decider !== "off" && options.decider !== "shadow") {
757
- console.error(chalk.red(`\n --decider must be off or shadow, got "${options.decider}"\n`));
758
- process.exit(1);
759
- }
760
756
  // Reject junk --timeout/--max-steps up front (both paths) instead of
761
757
  // silently falling back to a default.
762
758
  let budget;
@@ -1053,7 +1049,9 @@ resume) {
1053
1049
  fileRoot: config.fileRoot || undefined,
1054
1050
  signal,
1055
1051
  max_steps: wfMaxSteps,
1056
- ...(options.decider ? { decider: options.decider } : {}),
1052
+ // One trust domain per credential (#357): a URL, key or allowance a
1053
+ // committed .env introduced is repo content, read as untrusted.
1054
+ decider: operatorDeciderConfig(fileConfig, process.env, options.decider === false, (k) => trustedEnvValue(process.env, k)),
1057
1055
  stageOutputs: options.stage === true,
1058
1056
  journal,
1059
1057
  ...(spendBudget ? { budget: spendBudget } : {}),
@@ -1145,8 +1143,9 @@ resume) {
1145
1143
  progress.stop();
1146
1144
  console.error(formatCrashError(err));
1147
1145
  runLogger.flush();
1146
+ const resumable = journal?.active === true;
1148
1147
  journal?.end("crashed");
1149
- if (journal)
1148
+ if (resumable)
1150
1149
  console.error(c.subtle(` resume with: sweny workflow resume ${runId}`));
1151
1150
  recordHistory(nodeTimer.lastResults, undefined, true);
1152
1151
  const crashSummary = summarizeRun(new Map(), Date.now() - runStart, true);
@@ -1257,7 +1256,7 @@ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
1257
1256
  }
1258
1257
  workflowRunCmd.option("--no-history", "Do not record this run or save its output.md in .sweny/runs/ (or set `history: off` in .sweny.yml)");
1259
1258
  workflowRunCmd.option("--show-output", "Print the final answer in CI too (off by default in CI: only the path to output.md is printed)");
1260
- workflowRunCmd.option("--no-journal", "Do not write a run journal to .sweny/runs/<run-id>/ (or set `journal: off` in .sweny.yml); the run cannot be resumed");
1259
+ workflowRunCmd.option("--no-journal", "Do not write a run journal (kept in the sweny state dir; or set `journal: off` in .sweny.yml); the run cannot be resumed");
1261
1260
  const workflowResumeCmd = workflowCmd
1262
1261
  .command("resume <run-id>")
1263
1262
  .description("Resume a killed or failed `workflow run` from its journal: finished nodes are replayed without calling the agent, " +
@@ -6,7 +6,7 @@
6
6
  * Lives outside main.ts so tests can import it (main.ts parses argv on import).
7
7
  */
8
8
  import type { Logger, Workflow } from "../types.js";
9
- import { RunJournal, type ResumePlan } from "../journal.js";
9
+ import { RunJournal, type LockHooks, type ResumePlan } from "../journal.js";
10
10
  /** `sweny workflow resume` options (commander camelCases the flags). */
11
11
  export interface ResumeOptions {
12
12
  force?: boolean;
@@ -48,9 +48,18 @@ export interface PrepareResumeDeps {
48
48
  swenyVersion?: string;
49
49
  env?: Record<string, string | undefined>;
50
50
  logger?: Logger;
51
+ /** Where run journals live (default: {@link runStateRoot} of `env`). */
52
+ stateRoot?: string;
53
+ /** Test seam for the run lock. */
54
+ lockHooks?: LockHooks;
51
55
  }
52
56
  /** One line per planned visit, for humans. */
53
57
  export declare function formatResumePlan(plan: ResumePlan, mayRepeat: string[]): string[];
58
+ /**
59
+ * Check a run journal and build the resume. The run's lock is taken first,
60
+ * before anything is read or repaired, and is kept by the returned journal
61
+ * (released on every other outcome, `--plan` included).
62
+ */
54
63
  export declare function prepareResume(ref: string, opts: ResumeOptions, deps: PrepareResumeDeps): PrepareResumeResult;
55
64
  /** Journal is on unless `--no-journal` (options.journal === false) or `.sweny.yml` has `journal: off`. */
56
65
  export declare function journalDisabled(optionJournal: unknown, configJournal: unknown): boolean;
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import path from "node:path";
9
9
  import { validateRuntimeInput } from "../inputs.js";
10
- import { JOURNAL_DIR, JOURNAL_FILE, JournalKeyError, JournalLockedError, JournalVersionError, RunJournal, buildResumePlan, canonicalHash, checkJournalAgainstWorkflow, findJournalRun, journalDir, mayRepeatWrites, readJournal, workflowHashOf, } from "../journal.js";
10
+ import { existsSync } from "node:fs";
11
+ import { JournalKeyError, JournalLocationError, assertStateOutsideWorkspace, JournalLockedError, JournalRollbackError, JournalVersionError, RunJournal, buildResumePlan, canonicalHash, checkJournalAgainstWorkflow, findJournalRun, findLegacyJournal, journalFile, lockRun, mayRepeatWrites, readJournal, runStateRoot, workflowHashOf, } from "../journal.js";
11
12
  import { parseInputFlag } from "./workflow-input.js";
12
13
  /** Resume-only flags; the rest are shared with `workflow run`. */
13
14
  export const WORKFLOW_RESUME_OPTIONS = [
@@ -34,6 +35,7 @@ export const RESUME_SHARED_RUN_FLAGS = [
34
35
  "--input <json>",
35
36
  "--agent <id>",
36
37
  "--harness-policy <mode>",
38
+ "--no-decider",
37
39
  ];
38
40
  /** One line per planned visit, for humans. */
39
41
  export function formatResumePlan(plan, mayRepeat) {
@@ -68,23 +70,80 @@ export function formatResumePlan(plan, mayRepeat) {
68
70
  lines.push(" every node finished; the resume only closes the run");
69
71
  return lines;
70
72
  }
73
+ /**
74
+ * Check a run journal and build the resume. The run's lock is taken first,
75
+ * before anything is read or repaired, and is kept by the returned journal
76
+ * (released on every other outcome, `--plan` included).
77
+ */
71
78
  export function prepareResume(ref, opts, deps) {
72
79
  const cwd = deps.cwd ?? process.cwd();
73
- const lines = [];
74
- const fail = (error) => ({ ok: false, error, lines });
75
- const runId = findJournalRun(ref, cwd);
80
+ const root = deps.stateRoot ?? runStateRoot(deps.env ?? process.env);
81
+ // A state dir that resolves into the workspace is agent-writable: nothing in it can be trusted.
82
+ if (existsSync(root)) {
83
+ try {
84
+ assertStateOutsideWorkspace(root, cwd);
85
+ }
86
+ catch (err) {
87
+ if (err instanceof JournalLocationError)
88
+ return { ok: false, lines: [], error: err.message };
89
+ throw err;
90
+ }
91
+ }
92
+ const runId = findJournalRun(ref, cwd, root);
76
93
  if (!runId) {
77
- return fail(`no run journal matches "${ref}" in ${JOURNAL_DIR}/. Runs started with --no-journal (or before journals existed) cannot be resumed.`);
94
+ const legacy = findLegacyJournal(ref, cwd);
95
+ if (legacy) {
96
+ return {
97
+ ok: false,
98
+ lines: [],
99
+ error: `run ${ref} has a journal in the workspace (${path.relative(cwd, legacy)}), written by an older sweny. ` +
100
+ `Journals now live in the sweny state dir, where agents cannot edit them; a workspace journal cannot ` +
101
+ `be resumed safely. Start a new run.`,
102
+ };
103
+ }
104
+ return {
105
+ ok: false,
106
+ lines: [],
107
+ error: `no run journal matches "${ref}" for this workspace in ${root}. Runs started with --no-journal ` +
108
+ `(or by another user or state dir) cannot be resumed.`,
109
+ };
110
+ }
111
+ let lock;
112
+ try {
113
+ lock = lockRun(cwd, runId, root, deps.lockHooks);
114
+ }
115
+ catch (err) {
116
+ const error = err instanceof Error ? err.message : String(err);
117
+ return {
118
+ ok: false,
119
+ lines: [],
120
+ error: err instanceof JournalLockedError ? error : `cannot lock run ${runId}: ${error}`,
121
+ };
78
122
  }
79
- const file = path.join(journalDir(cwd, runId), JOURNAL_FILE);
123
+ let result;
124
+ try {
125
+ result = prepareLocked(runId, journalFile(cwd, runId, root), lock, opts, deps, cwd, root);
126
+ }
127
+ catch (err) {
128
+ lock.release();
129
+ throw err;
130
+ }
131
+ if (!(result.ok && "ctx" in result))
132
+ lock.release();
133
+ return result;
134
+ }
135
+ function prepareLocked(runId, file, lock, opts, deps, cwd, root) {
136
+ const lines = [];
137
+ const fail = (error) => ({ ok: false, error, lines });
80
138
  let read;
81
139
  try {
82
140
  // --plan stays read-only: a torn tail is reported, not cut.
83
141
  read = readJournal(file, { repair: !opts.plan });
84
142
  }
85
143
  catch (err) {
86
- if (err instanceof JournalVersionError || err instanceof JournalKeyError)
144
+ if (err instanceof JournalVersionError || err instanceof JournalKeyError || err instanceof JournalRollbackError) {
87
145
  return fail(err.message);
146
+ }
88
147
  return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
89
148
  }
90
149
  if (read.forgedAtLine !== undefined) {
@@ -100,6 +159,11 @@ export function prepareResume(ref, opts, deps) {
100
159
  ? `note: the journal ends in a torn record (${read.truncatedBytes} bytes); resuming drops it`
101
160
  : `note: dropped a torn record at the end of the journal (${read.truncatedBytes} bytes)`);
102
161
  }
162
+ if (read.restored) {
163
+ lines.push(opts.plan
164
+ ? `note: the journal's last record was not appended before the crash; resuming restores it from the head`
165
+ : `note: restored the journal's last record from the head (the run stopped before appending it)`);
166
+ }
103
167
  let plan;
104
168
  try {
105
169
  plan = buildResumePlan(read.records);
@@ -153,6 +217,10 @@ export function prepareResume(ref, opts, deps) {
153
217
  }
154
218
  const mayRepeat = mayRepeatWrites(plan, workflow, input);
155
219
  lines.push(...formatResumePlan(plan, mayRepeat));
220
+ if (plan.unsandboxed.length > 0) {
221
+ lines.push(`warning: an earlier attempt ran ${plan.unsandboxed.join(", ")} without an enforced sandbox, so that agent ` +
222
+ `could have read the run key and edited this run's journaled spend and write records (journal_unsandboxed)`);
223
+ }
156
224
  if (opts.plan)
157
225
  return { ok: true, planOnly: true, lines };
158
226
  if (mayRepeat.length > 0 && !opts.allowRepeatWrites) {
@@ -169,8 +237,10 @@ export function prepareResume(ref, opts, deps) {
169
237
  swenyVersion: deps.swenyVersion,
170
238
  env: deps.env,
171
239
  logger: deps.logger,
240
+ stateRoot: root,
172
241
  read,
173
242
  plan,
243
+ lock,
174
244
  force: opts.force === true,
175
245
  allowRepeatWrites: opts.allowRepeatWrites === true,
176
246
  });
@@ -10,7 +10,7 @@
10
10
  * builds on this format, so readers skip records with a version they do not
11
11
  * know instead of guessing.
12
12
  */
13
- import type { ExecutionEvent, ExecutionTrace, NodeResult, Workflow } from "../types.js";
13
+ import type { ExecutionEvent, ExecutionTrace, NodeResult, RouteRung, Workflow } from "../types.js";
14
14
  export declare const RUN_HISTORY_SCHEMA_VERSION = 1;
15
15
  export declare const RUN_HISTORY_DIR: string;
16
16
  export declare const RUN_HISTORY_KEEP = 200;
@@ -43,9 +43,11 @@ export interface RunRecord {
43
43
  duration_ms: number;
44
44
  status: RunStatus;
45
45
  nodes: RunNodeRecord[];
46
+ /** Edges taken. `rung` says who chose a conditional route: expr, decider or agent (#357). */
46
47
  routes: Array<{
47
48
  from: string;
48
49
  to: string;
50
+ rung?: RouteRung;
49
51
  }>;
50
52
  totals: RunTotals;
51
53
  /** Harness that ran the nodes (id + version). Absent for runs with no harness-tagged result. */
@@ -55,12 +57,6 @@ export interface RunRecord {
55
57
  };
56
58
  /** Opinions the harness could not honor natively on some node, deduped. Absent when none. */
57
59
  degraded?: string[];
58
- /** Shadow-mode decider agreement counts (#357). Absent when the decider was off. */
59
- decider?: {
60
- compared: number;
61
- agreed: number;
62
- fell_through: number;
63
- };
64
60
  }
65
61
  /** sha256 (hex) of the workflow as canonical JSON: keys sorted, undefined dropped. */
66
62
  export declare function hashWorkflow(workflow: Workflow): string;
@@ -103,7 +103,7 @@ export function buildRunRecord(i) {
103
103
  duration_ms: Math.max(0, Math.round(i.durationMs)),
104
104
  status: i.crashed ? "crashed" : summary.ok ? "success" : "failed",
105
105
  nodes,
106
- routes: (i.trace?.edges ?? []).map((e) => ({ from: e.from, to: e.to })),
106
+ routes: (i.trace?.edges ?? []).map((e) => ({ from: e.from, to: e.to, ...(e.rung ? { rung: e.rung } : {}) })),
107
107
  totals: {
108
108
  nodes_total: summary.nodesTotal,
109
109
  nodes_ok: summary.nodesOk,
@@ -114,15 +114,6 @@ export function buildRunRecord(i) {
114
114
  },
115
115
  ...(harness ? { harness: { id: harness.id, version: harness.version } } : {}),
116
116
  ...(degraded.length > 0 ? { degraded } : {}),
117
- ...(summary.decider
118
- ? {
119
- decider: {
120
- compared: summary.decider.compared,
121
- agreed: summary.decider.agreed,
122
- fell_through: summary.decider.fellThrough,
123
- },
124
- }
125
- : {}),
126
117
  };
127
118
  }
128
119
  // ── Write, prune, read ──────────────────────────────────────────
@@ -40,15 +40,19 @@ export interface RunSummary {
40
40
  spent: number;
41
41
  };
42
42
  /**
43
- * Shadow-mode decider agreement (#357), counts only. `compared` decisions
44
- * passed every gate; `agreed` matched the agent; the rest fell through.
45
- * Absent when the decider was off or never consulted.
43
+ * Who decided the run's conditional routes (#357), counts only: a `when`
44
+ * expression, the decision model, or the agent. The decider and expression
45
+ * counts are routes with no agent call. Absent when no route was a decision.
46
46
  */
47
- decider?: {
48
- compared: number;
49
- agreed: number;
50
- fellThrough: number;
51
- };
47
+ routes?: RouteCounts;
48
+ /** Why the decider did not run for some or all routes (missing config, breaker, cap). */
49
+ deciderOff?: string;
50
+ }
51
+ export interface RouteCounts {
52
+ total: number;
53
+ decider: number;
54
+ expr: number;
55
+ agent: number;
52
56
  }
53
57
  export interface RunPolicy {
54
58
  /** Agent env narrowed to the allowlist. Absent when no node reported it. */
@@ -68,6 +72,8 @@ export interface RunPolicy {
68
72
  export declare function degradedKey(entry: string): string;
69
73
  /** Sum the per-node results into one run summary. */
70
74
  export declare function summarizeRun(results: Map<string, NodeResult>, durationMs: number, crashed?: boolean, trace?: ExecutionTrace): RunSummary;
75
+ /** Conditional routes by rung. Unconditional edges decide nothing and are not counted. */
76
+ export declare function countRoutes(trace: ExecutionTrace): RouteCounts;
71
77
  /**
72
78
  * The policy facts the run knew, as short phrases: `env scoped`, `sandbox on`,
73
79
  * `1 output staged`. No phrase for a fact no node reported.
@@ -85,8 +91,8 @@ export declare function formatCost(usd: number): string;
85
91
  export declare function formatBudgetAmount(b: NonNullable<RunSummary["budget"]>): string;
86
92
  /** `budget exceeded: tokens 52k of 50k (run, at node gather)`. */
87
93
  export declare function formatBudgetOverrun(b: NonNullable<RunSummary["budget"]>): string;
88
- /** `agreed 4/5`, or `fell through 2/2` when no decision passed the gates. */
89
- export declare function formatDeciderCounts(d: NonNullable<RunSummary["decider"]>): string;
94
+ /** `5 (3 decider, 1 expr, 1 agent)`: zero rungs are left out. */
95
+ export declare function formatRouteCounts(r: RouteCounts): string;
90
96
  /**
91
97
  * One plain line: `✓ 3/3 nodes · 41 tool calls · 2m10s · 12k tokens · $0.18`.
92
98
  * Token and cost segments are omitted when the SDK reported none. A harness
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
9
9
  import { consoleLogger } from "../types.js";
10
- import { summarizeDecisions } from "../decider.js";
11
10
  import { toMermaidBlock } from "../mermaid.js";
12
11
  import { createPaint } from "./style.js";
13
12
  import { colorEnabled, glyphs } from "./terminal.js";
@@ -84,12 +83,20 @@ export function summarizeRun(results, durationMs, crashed = false, trace) {
84
83
  ...(degraded.size > 0 ? { degraded: [...degraded] } : {}),
85
84
  ...(Object.keys(policy).length > 0 ? { policy } : {}),
86
85
  ...(budget ? { budget } : {}),
87
- ...(trace?.decisions && trace.decisions.length > 0 ? { decider: decisionCounts(trace) } : {}),
86
+ ...(trace && countRoutes(trace).total > 0 ? { routes: countRoutes(trace) } : {}),
87
+ ...(trace?.deciderOff ? { deciderOff: trace.deciderOff } : {}),
88
88
  };
89
89
  }
90
- function decisionCounts(trace) {
91
- const d = summarizeDecisions(trace.decisions ?? []);
92
- return { compared: d.compared, agreed: d.agreed, fellThrough: d.fell_through };
90
+ /** Conditional routes by rung. Unconditional edges decide nothing and are not counted. */
91
+ export function countRoutes(trace) {
92
+ const c = { total: 0, decider: 0, expr: 0, agent: 0 };
93
+ for (const e of trace.edges) {
94
+ if (!e.rung)
95
+ continue;
96
+ c.total++;
97
+ c[e.rung]++;
98
+ }
99
+ return c;
93
100
  }
94
101
  /**
95
102
  * The policy facts the run knew, as short phrases: `env scoped`, `sandbox on`,
@@ -157,9 +164,10 @@ export function formatBudgetAmount(b) {
157
164
  export function formatBudgetOverrun(b) {
158
165
  return `budget exceeded: ${formatBudgetAmount(b)} (${b.scope}, at node ${b.node})`;
159
166
  }
160
- /** `agreed 4/5`, or `fell through 2/2` when no decision passed the gates. */
161
- export function formatDeciderCounts(d) {
162
- return d.compared > 0 ? `agreed ${d.agreed}/${d.compared}` : `fell through ${d.fellThrough}/${d.fellThrough}`;
167
+ /** `5 (3 decider, 1 expr, 1 agent)`: zero rungs are left out. */
168
+ export function formatRouteCounts(r) {
169
+ const parts = ["decider", "expr", "agent"].filter((k) => r[k] > 0).map((k) => `${r[k]} ${k}`);
170
+ return parts.length > 0 ? `${r.total} (${parts.join(", ")})` : String(r.total);
163
171
  }
164
172
  /**
165
173
  * One plain line: `✓ 3/3 nodes · 41 tool calls · 2m10s · 12k tokens · $0.18`.
@@ -180,7 +188,8 @@ export function formatReceipt(s) {
180
188
  ...(s.degraded && s.degraded.length > 0 ? [`degraded: ${s.degraded.join(", ")}`] : []),
181
189
  ...(formatPolicySegment(s.policy) ? [formatPolicySegment(s.policy)] : []),
182
190
  ...(s.budget ? [formatBudgetOverrun(s.budget)] : []),
183
- ...(s.decider ? [`decider ${formatDeciderCounts(s.decider)}`] : []),
191
+ ...(s.routes && s.routes.total > 0 ? [`routes ${formatRouteCounts(s.routes)}`] : []),
192
+ ...(s.deciderOff ? [`decider off: ${s.deciderOff}`] : []),
184
193
  ];
185
194
  return parts.join(" · ");
186
195
  }
@@ -317,8 +326,5 @@ export const WORKFLOW_RUN_OPTIONS = [
317
326
  "--harness-policy <mode>",
318
327
  "strict: refuse a node whose policy the agent cannot enforce; warn: run it and report what was not enforced (default: strict under GitHub Actions, warn elsewhere; env SWENY_HARNESS_POLICY)",
319
328
  ],
320
- [
321
- "--decider <mode>",
322
- "Decision model for route choices: off (default) or shadow. Shadow asks the workflow's decider.provider alongside the agent and reports agreement; the route is always the agent's. Needs decider.provider in the workflow (no default URL)",
323
- ],
329
+ ["--no-decider", "Skip the decision model for this run: the agent decides the routes of route_by: decider nodes"],
324
330
  ];
@@ -6,7 +6,7 @@
6
6
  * METADATA ONLY, like the receipt line: counts, durations, tokens, cost,
7
7
  * policy facts. Never prompts, node outputs, tool inputs, or model prose.
8
8
  */
9
- import { formatBudgetAmount, formatCost, formatDeciderCounts, formatReceiptDuration, formatTokenCount, policyParts, renderReceiptLine, } from "./run-output.js";
9
+ import { formatBudgetAmount, formatCost, formatRouteCounts, formatReceiptDuration, formatTokenCount, policyParts, renderReceiptLine, } from "./run-output.js";
10
10
  import { createPaint } from "./style.js";
11
11
  import { colorEnabled, glyphsFor, richOutput, supportsUnicode, terminalColumns, ticketBoxFor } from "./terminal.js";
12
12
  /** Widest ticket, in columns, frame included. */
@@ -69,7 +69,8 @@ function ticketFields(s) {
69
69
  ...(s.costUsd !== undefined ? [["cost", formatCost(s.costUsd)]] : []),
70
70
  ...(s.harness !== undefined && s.harness !== "claude-code" ? [["harness", s.harness]] : []),
71
71
  ...(s.degraded && s.degraded.length > 0 ? [["degraded", s.degraded.join(", ")]] : []),
72
- ...(s.decider ? [["decider", formatDeciderCounts(s.decider)]] : []),
72
+ ...(s.routes && s.routes.total > 0 ? [["routes", formatRouteCounts(s.routes)]] : []),
73
+ ...(s.deciderOff ? [["decider off", s.deciderOff]] : []),
73
74
  ...(s.budget
74
75
  ? [
75
76
  ["budget", `${formatBudgetAmount(s.budget)} (${s.budget.scope})`],