@sweny-ai/core 0.1.141 → 0.1.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/agent-env.d.ts +24 -0
  2. package/dist/agent-env.js +40 -7
  3. package/dist/cli/config-file.d.ts +1 -1
  4. package/dist/cli/config-file.js +1 -1
  5. package/dist/cli/config.d.ts +2 -0
  6. package/dist/cli/config.js +15 -7
  7. package/dist/cli/main.js +45 -15
  8. package/dist/cli/output.d.ts +2 -0
  9. package/dist/cli/output.js +11 -0
  10. package/dist/cli/run-history.d.ts +2 -0
  11. package/dist/cli/run-history.js +2 -0
  12. package/dist/cli/run-output.d.ts +13 -2
  13. package/dist/cli/run-output.js +23 -2
  14. package/dist/executor.js +30 -25
  15. package/dist/harness/abort.d.ts +17 -0
  16. package/dist/harness/abort.js +53 -0
  17. package/dist/harness/agents.d.ts +11 -0
  18. package/dist/harness/agents.js +15 -0
  19. package/dist/harness/capabilities.d.ts +23 -0
  20. package/dist/harness/capabilities.js +35 -0
  21. package/dist/harness/claude-code.d.ts +9 -40
  22. package/dist/harness/claude-code.js +21 -308
  23. package/dist/harness/codex.d.ts +175 -0
  24. package/dist/harness/codex.js +917 -0
  25. package/dist/harness/index.d.ts +11 -3
  26. package/dist/harness/index.js +8 -8
  27. package/dist/harness/parse.d.ts +49 -0
  28. package/dist/harness/parse.js +253 -0
  29. package/dist/harness/policy.d.ts +23 -1
  30. package/dist/harness/policy.js +73 -15
  31. package/dist/harness/prompts.d.ts +7 -1
  32. package/dist/harness/prompts.js +16 -0
  33. package/dist/harness/types.d.ts +13 -2
  34. package/dist/harness/types.js +2 -2
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +2 -2
  37. package/dist/schema.d.ts +12 -11
  38. package/dist/schema.js +7 -3
  39. package/dist/types.d.ts +8 -1
  40. package/package.json +1 -1
@@ -42,9 +42,33 @@ export declare const AGENT_ENV_PREFIXES: readonly string[];
42
42
  export declare const AGENT_AUTH_VARS: readonly string[];
43
43
  /** Split a comma/whitespace separated list, dropping empties. */
44
44
  export declare function parseList(v: string | undefined): string[];
45
+ /**
46
+ * Codex's own credentials and home (openai/codex rust-v0.159.2,
47
+ * login/src/auth/manager.rs): `codex exec` authenticates with `CODEX_API_KEY`
48
+ * (it does not read `OPENAI_API_KEY` for this; see {@link resolveCodexAuthEnv})
49
+ * or `CODEX_ACCESS_TOKEN`, else the login stored in `$CODEX_HOME/auth.json`.
50
+ */
51
+ export declare const CODEX_AUTH_VARS: readonly string[];
52
+ /** Prefixes for a Codex run: locale only. `ANTHROPIC_*` / `CLAUDE_*` never reach Codex. */
53
+ export declare const CODEX_ENV_PREFIXES: readonly string[];
54
+ /**
55
+ * `codex exec` reads `CODEX_API_KEY`, not `OPENAI_API_KEY`. Copy the OpenAI
56
+ * key into `CODEX_API_KEY` when only the former is set, so either works.
57
+ * Pure: returns a copy.
58
+ */
59
+ export declare function resolveCodexAuthEnv(env: Record<string, string>): Record<string, string>;
60
+ /** A stored Codex login (`codex login`): `auth.json` under `CODEX_HOME`, default `~/.codex`. */
61
+ export declare function hasCodexLogin(env?: Record<string, string | undefined>, exists?: (p: string) => boolean): boolean;
45
62
  export interface BuildAgentEnvOpts {
46
63
  /** Per-call names to include (a node's declared skill env vars). */
47
64
  extraVars?: readonly string[];
65
+ /**
66
+ * The harness's own credential names (Codex: {@link CODEX_AUTH_VARS}).
67
+ * Default: none beyond the Claude prefixes.
68
+ */
69
+ authVars?: readonly string[];
70
+ /** Prefixes always kept. Default: {@link AGENT_ENV_PREFIXES} (Claude Code's). */
71
+ prefixes?: readonly string[];
48
72
  /** Operator passthrough list (`env-passthrough`). `"*"` inherits everything. */
49
73
  passthrough?: readonly string[];
50
74
  logger?: Pick<Logger, "warn">;
package/dist/agent-env.js CHANGED
@@ -138,6 +138,31 @@ export function parseList(v) {
138
138
  .map((s) => s.trim())
139
139
  .filter(Boolean);
140
140
  }
141
+ /**
142
+ * Codex's own credentials and home (openai/codex rust-v0.159.2,
143
+ * login/src/auth/manager.rs): `codex exec` authenticates with `CODEX_API_KEY`
144
+ * (it does not read `OPENAI_API_KEY` for this; see {@link resolveCodexAuthEnv})
145
+ * or `CODEX_ACCESS_TOKEN`, else the login stored in `$CODEX_HOME/auth.json`.
146
+ */
147
+ export const CODEX_AUTH_VARS = ["CODEX_API_KEY", "CODEX_ACCESS_TOKEN", "CODEX_HOME"];
148
+ /** Prefixes for a Codex run: locale only. `ANTHROPIC_*` / `CLAUDE_*` never reach Codex. */
149
+ export const CODEX_ENV_PREFIXES = ["LC_"];
150
+ /**
151
+ * `codex exec` reads `CODEX_API_KEY`, not `OPENAI_API_KEY`. Copy the OpenAI
152
+ * key into `CODEX_API_KEY` when only the former is set, so either works.
153
+ * Pure: returns a copy.
154
+ */
155
+ export function resolveCodexAuthEnv(env) {
156
+ const out = { ...env };
157
+ if (!out.CODEX_API_KEY && out.OPENAI_API_KEY)
158
+ out.CODEX_API_KEY = out.OPENAI_API_KEY;
159
+ return out;
160
+ }
161
+ /** A stored Codex login (`codex login`): `auth.json` under `CODEX_HOME`, default `~/.codex`. */
162
+ export function hasCodexLogin(env = process.env, exists = existsSync) {
163
+ const home = env.CODEX_HOME || path.join(homedir(), ".codex");
164
+ return exists(path.join(home, "auth.json"));
165
+ }
141
166
  /**
142
167
  * Whether the agent env is scoped to the allowlist. `SWENY_ENV_SCOPE` (or the
143
168
  * explicit value) `on|true|1` / `off|false|0` wins everywhere; otherwise on
@@ -319,13 +344,21 @@ export function buildAgentEnv(source, opts = {}) {
319
344
  opts.logger?.warn("[sweny] env-passthrough contains '*': the agent inherits the full environment, including every secret.");
320
345
  return Object.fromEntries(Object.entries(source).filter((e) => e[1] != null));
321
346
  }
322
- const exact = new Set([...AGENT_ENV_ALLOWLIST, ...(opts.extraVars ?? []), ...passthrough]);
323
- const prefixes = [...AGENT_ENV_PREFIXES];
324
- if (truthy(source.CLAUDE_CODE_USE_BEDROCK))
325
- prefixes.push(...BEDROCK_PREFIXES);
326
- if (truthy(source.CLAUDE_CODE_USE_VERTEX))
327
- for (const v of VERTEX_VARS)
328
- exact.add(v);
347
+ const exact = new Set([
348
+ ...AGENT_ENV_ALLOWLIST,
349
+ ...(opts.extraVars ?? []),
350
+ ...(opts.authVars ?? []),
351
+ ...passthrough,
352
+ ]);
353
+ const prefixes = [...(opts.prefixes ?? AGENT_ENV_PREFIXES)];
354
+ // Bedrock / Vertex routing is a Claude Code setting; only its default prefixes carry it.
355
+ if (!opts.prefixes) {
356
+ if (truthy(source.CLAUDE_CODE_USE_BEDROCK))
357
+ prefixes.push(...BEDROCK_PREFIXES);
358
+ if (truthy(source.CLAUDE_CODE_USE_VERTEX))
359
+ for (const v of VERTEX_VARS)
360
+ exact.add(v);
361
+ }
329
362
  const out = {};
330
363
  for (const [k, v] of Object.entries(source)) {
331
364
  if (v == null)
@@ -19,4 +19,4 @@ export type FileConfig = Record<string, string | string[] | Record<string, unkno
19
19
  */
20
20
  export declare function loadConfigFile(cwd?: string): FileConfig;
21
21
  /** Starter config written by `sweny new`. */
22
- 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 (the only supported agent)\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";
22
+ 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\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";
@@ -148,7 +148,7 @@ export const STARTER_CONFIG = `# .sweny.yml — SWEny project configuration
148
148
  # observability-provider: datadog # datadog | sentry | cloudwatch | splunk | elastic | newrelic | loki | prometheus | pagerduty | heroku | opsgenie | vercel | supabase | netlify | fly | render | file
149
149
  # issue-tracker-provider: github-issues # github-issues | linear | jira
150
150
  # source-control-provider: github # github | gitlab
151
- # coding-agent-provider: claude # claude (the only supported agent)
151
+ # coding-agent-provider: claude # claude | codex
152
152
  # notification-provider: console # console | slack | teams | discord | email | webhook
153
153
 
154
154
  # ── Investigation ────────────────────────────────────────────────────
@@ -11,6 +11,8 @@ export interface CliConfig {
11
11
  /** Auth precedence mode: auto (default), api-key, or oauth. */
12
12
  swenyAuth: "auto" | "api-key" | "oauth";
13
13
  openaiApiKey: string;
14
+ /** Codex's own credential: CODEX_API_KEY, else CODEX_ACCESS_TOKEN. */
15
+ codexApiKey?: string;
14
16
  geminiApiKey: string;
15
17
  observabilityProviders: string[];
16
18
  observabilityCredentials: Record<string, Record<string, string>>;
@@ -1,12 +1,14 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
+ import { hasCodexLogin } from "../agent-env.js";
4
+ import { unsupportedAgentError } from "../harness/agents.js";
3
5
  export function registerTriageCommand(program) {
4
6
  // NOTE: File-eligible options omit Commander defaults so parseCliInputs() can
5
7
  // distinguish "user passed flag" from "not passed" and fall through to .sweny.yml.
6
8
  return program
7
9
  .command("triage")
8
10
  .description("Run the SWEny triage workflow")
9
- .option("--agent <provider>", "Coding agent: claude (the only supported agent)")
11
+ .option("--agent <provider>", "Coding agent: claude (default) or codex")
10
12
  .option("--coding-agent-provider <provider>", "Coding agent provider (alias for --agent)")
11
13
  .option("--observability-provider <provider>", "Observability provider (default: none)")
12
14
  .option("--issue-tracker-provider <provider>", "Issue tracker provider (default: github-issues)")
@@ -107,6 +109,7 @@ export function parseCliInputs(options, fileConfig = {}) {
107
109
  anthropicBaseUrl: env.ANTHROPIC_BASE_URL || f("anthropic-base-url") || "",
108
110
  swenyAuth: normalizeSwenyAuth(env.SWENY_AUTH || f("sweny-auth")),
109
111
  openaiApiKey: env.OPENAI_API_KEY || "",
112
+ codexApiKey: env.CODEX_API_KEY || env.CODEX_ACCESS_TOKEN || "",
110
113
  geminiApiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY || "",
111
114
  observabilityProviders: obsProviders,
112
115
  observabilityCredentials: obsCreds,
@@ -228,12 +231,17 @@ export function validateInputs(config) {
228
231
  errors.push("Missing: ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN — get a key at https://console.anthropic.com");
229
232
  }
230
233
  break;
234
+ case "codex":
235
+ // codex exec authenticates with CODEX_API_KEY (OPENAI_API_KEY is mapped
236
+ // to it) or a stored `codex login` under CODEX_HOME.
237
+ if (!config.openaiApiKey && !config.codexApiKey && !hasCodexLogin(process.env)) {
238
+ errors.push("Missing: CODEX_API_KEY or OPENAI_API_KEY, or a Codex login (`codex login`), for --agent codex");
239
+ }
240
+ break;
231
241
  default:
232
- // Honest --agent (#330): SWEny runs headless Claude Code only. Nothing
233
- // dispatches on other values, so accepting them would silently run
234
- // Claude under a different name.
235
- errors.push(`Unsupported coding agent "${config.codingAgentProvider}": the only supported agent is "claude" ` +
236
- `(headless Claude Code). Remove --agent / coding-agent-provider or set it to claude.`);
242
+ // Honest --agent (#330): only agents with an adapter are accepted.
243
+ // Anything else would silently run a different agent under that name.
244
+ errors.push(unsupportedAgentError(config.codingAgentProvider));
237
245
  }
238
246
  // Repository required unless all providers are file-based
239
247
  const allLocal = config.issueTrackerProvider === "file" && config.sourceControlProvider === "file";
@@ -650,7 +658,7 @@ export function registerImplementCommand(program) {
650
658
  return program
651
659
  .command("implement <issueId>")
652
660
  .description("Implement a fix for a specific issue and open a PR")
653
- .option("--agent <provider>", "Coding agent: claude (the only supported agent)")
661
+ .option("--agent <provider>", "Coding agent: claude (default) or codex")
654
662
  .option("--coding-agent-provider <provider>", "Coding agent provider (alias for --agent)")
655
663
  .option("--issue-tracker-provider <provider>", "Issue tracker (linear|jira|github-issues|file)")
656
664
  .option("--source-control-provider <provider>", "Source control (github|gitlab|file)")
package/dist/cli/main.js CHANGED
@@ -10,6 +10,8 @@ import { execute } from "../executor.js";
10
10
  import { triageWorkflow, implementWorkflow, seedContentWorkflow } from "../workflows/index.js";
11
11
  import { consoleLogger } from "../types.js";
12
12
  import { createHarness } from "../harness/index.js";
13
+ import { isSupportedAgent, unsupportedAgentError } from "../harness/agents.js";
14
+ import { resolveHarnessPolicy } from "../harness/policy.js";
13
15
  import { builtinSkills, createSkillMap, validateWorkflowSkills } from "../skills/index.js";
14
16
  import { formatMissingSkillLines, skillEnvWarnings } from "./skill-env.js";
15
17
  import { configuredSkills, configuredSkillsWithDiagnostics } from "../skills/custom-loader.js";
@@ -34,7 +36,7 @@ import { createVerboseToolObserver } from "./verbose-observer.js";
34
36
  import { createRunLogger, renderReceiptLine, summarizeRun, writeStepSummary, WORKFLOW_RUN_DESCRIPTION, WORKFLOW_RUN_OPTIONS, } from "./run-output.js";
35
37
  import { writeRunComment } from "./comment-output.js";
36
38
  import { registerTriageCommand, registerImplementCommand, parseCliInputs, validateInputs, validateWarnings, parsePositiveInt, } from "./config.js";
37
- import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatDagResultMarkdown, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
39
+ import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatDagResultMarkdown, writeResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
38
40
  import { checkProviderConnectivity, detectClaudeCodeLogin, discoverWorkflowSkillIds, validateCheckInputs, } from "./check.js";
39
41
  import { registerSetupCommand } from "./setup.js";
40
42
  import { registerPublishCommand } from "./publish.js";
@@ -175,6 +177,28 @@ async function resolveRulesAndContext(config) {
175
177
  context: contextResult.resolved,
176
178
  };
177
179
  }
180
+ /**
181
+ * Build the harness for `--agent` (#331). A non-Claude agent is checked before
182
+ * any node runs, so a missing or too-old CLI fails fast with the fix.
183
+ */
184
+ async function harnessFor(agent, opts, harnessPolicy) {
185
+ if (!isSupportedAgent(agent)) {
186
+ console.error(chalk.red(`\n ${unsupportedAgentError(agent)}\n`));
187
+ process.exit(1);
188
+ }
189
+ const harness = createHarness(agent, {
190
+ ...opts,
191
+ policy: resolveHarnessPolicy(process.env, harnessPolicy, opts.logger),
192
+ });
193
+ if (agent !== "claude") {
194
+ const pre = await harness.preflight();
195
+ if (!pre.ok) {
196
+ console.error(chalk.red(`\n ${pre.reason}\n`));
197
+ process.exit(1);
198
+ }
199
+ }
200
+ return harness;
201
+ }
178
202
  // ── sweny triage ──────────────────────────────────────────────────────
179
203
  const triageCmd = registerTriageCommand(program);
180
204
  triageCmd.action(async (options) => {
@@ -203,7 +227,7 @@ triageCmd.action(async (options) => {
203
227
  const skills = createSkillMap(triageSkillDiscovery.skills);
204
228
  const mcpAutoConfig = buildMcpAutoConfig(config);
205
229
  const mcpServers = buildAutoMcpServers(mcpAutoConfig);
206
- const claude = createHarness("claude-code", {
230
+ const claude = await harnessFor(config.codingAgentProvider, {
207
231
  maxTurns: config.maxInvestigateTurns || 50,
208
232
  cwd: process.cwd(),
209
233
  logger: consoleLogger,
@@ -392,7 +416,7 @@ triageCmd.action(async (options) => {
392
416
  const durationMs = Date.now() - runStart;
393
417
  // Output
394
418
  if (config.json) {
395
- console.log(formatResultJson(results));
419
+ await writeResultJson(results);
396
420
  }
397
421
  else {
398
422
  console.log(formatDagResultHuman(results, durationMs, config));
@@ -477,7 +501,7 @@ triageCmd.action(async (options) => {
477
501
  }
478
502
  });
479
503
  // ── sweny implement ───────────────────────────────────────────────────
480
- const implementCmd = registerImplementCommand(program);
504
+ const implementCmd = registerImplementCommand(program).option("--json", "Output result as JSON", false);
481
505
  implementCmd.action(async (issueId, options) => {
482
506
  const fileConfig = loadConfigFile();
483
507
  // Build a minimal CliConfig for the implement command by merging CLI opts with env/file
@@ -513,7 +537,7 @@ implementCmd.action(async (issueId, options) => {
513
537
  const skills = createSkillMap(implementSkillDiscovery.skills);
514
538
  const mcpAutoConfig = buildMcpAutoConfig(config);
515
539
  const mcpServers = buildAutoMcpServers(mcpAutoConfig);
516
- const claude = createHarness("claude-code", {
540
+ const claude = await harnessFor(config.codingAgentProvider, {
517
541
  maxTurns: config.maxImplementTurns || 40,
518
542
  cwd: process.cwd(),
519
543
  logger: consoleLogger,
@@ -604,17 +628,22 @@ implementCmd.action(async (issueId, options) => {
604
628
  catch {
605
629
  // silent
606
630
  }
631
+ if (config.json) {
632
+ await writeResultJson(results);
633
+ }
607
634
  if (hasFailed) {
608
635
  console.error(chalk.red(`\n Implement workflow failed\n`));
609
636
  process.exit(1);
610
637
  }
611
- const prResult = results.get("create_pr");
612
- const prUrl = prResult?.data?.prUrl;
613
- if (prUrl) {
614
- console.log(chalk.green(`\n PR created: ${prUrl}\n`));
615
- }
616
- else {
617
- console.log(chalk.green(`\n Implement workflow completed\n`));
638
+ if (!config.json) {
639
+ const prResult = results.get("create_pr");
640
+ const prUrl = prResult?.data?.prUrl;
641
+ if (prUrl) {
642
+ console.log(chalk.green(`\n PR created: ${prUrl}\n`));
643
+ }
644
+ else {
645
+ console.log(chalk.green(`\n Implement workflow completed\n`));
646
+ }
618
647
  }
619
648
  // Legacy /api/report back-compat — see triage path.
620
649
  try {
@@ -756,14 +785,15 @@ export async function workflowRunAction(file, options) {
756
785
  });
757
786
  // Raw [info]/[debug] lines are verbose-only; warnings are rendered (#383).
758
787
  const runLogger = createRunLogger({ verbose: Boolean(options.verbose), tty: isTTY });
759
- const claude = createHarness("claude-code", {
788
+ // --agent picks the harness (#331): claude (default) or codex.
789
+ const claude = await harnessFor(config.codingAgentProvider, {
760
790
  maxTurns: config.maxInvestigateTurns || 50,
761
791
  cwd: process.cwd(),
762
792
  logger: runLogger,
763
793
  defaultMcpServers: mcpServers,
764
794
  mcpServers: config.mcpServers,
765
795
  model: workflow.model,
766
- });
796
+ }, typeof options.harnessPolicy === "string" ? options.harnessPolicy : undefined);
767
797
  // Track per-node entry time to compute elapsed on exit
768
798
  const nodeEnterTimes = new Map();
769
799
  const wfProgressObserver = isJson
@@ -956,7 +986,7 @@ export async function workflowRunAction(file, options) {
956
986
  });
957
987
  }
958
988
  if (isJson) {
959
- process.stdout.write(JSON.stringify(Object.fromEntries(results), null, 2) + "\n");
989
+ await writeResultJson(results);
960
990
  process.exit(wfHasFailed ? 1 : 0);
961
991
  return;
962
992
  }
@@ -24,6 +24,8 @@ export declare function extractCredentialHint(err: unknown): string | null;
24
24
  export declare function formatCrashError(error: unknown): string;
25
25
  export declare function formatCheckResults(results: CheckResult[]): string;
26
26
  export declare function formatResultJson(results: Map<string, NodeResult>): string;
27
+ /** Drain terminal JSON before process.exit(), including when stdout is a pipe. */
28
+ export declare function writeResultJson(results: Map<string, NodeResult>): Promise<void>;
27
29
  /**
28
30
  * Format triage results as GitHub-flavored markdown for `$GITHUB_STEP_SUMMARY`.
29
31
  *
@@ -405,6 +405,17 @@ export function formatCheckResults(results) {
405
405
  export function formatResultJson(results) {
406
406
  return JSON.stringify(Object.fromEntries(results), null, 2);
407
407
  }
408
+ /** Drain terminal JSON before process.exit(), including when stdout is a pipe. */
409
+ export function writeResultJson(results) {
410
+ return new Promise((resolve, reject) => {
411
+ process.stdout.write(formatResultJson(results) + "\n", (error) => {
412
+ if (error)
413
+ reject(error);
414
+ else
415
+ resolve();
416
+ });
417
+ });
418
+ }
408
419
  export function formatDagResultMarkdown(results, durationMs, config, options = {}) {
409
420
  const duration = formatDuration(durationMs);
410
421
  const lines = [];
@@ -53,6 +53,8 @@ export interface RunRecord {
53
53
  id: string;
54
54
  version: string;
55
55
  };
56
+ /** Opinions the harness could not honor natively on some node, deduped. Absent when none. */
57
+ degraded?: string[];
56
58
  }
57
59
  /** sha256 (hex) of the workflow as canonical JSON: keys sorted, undefined dropped. */
58
60
  export declare function hashWorkflow(workflow: Workflow): string;
@@ -92,6 +92,7 @@ export function buildRunRecord(i) {
92
92
  };
93
93
  });
94
94
  const harness = [...i.results.values()].find((r) => r.harness)?.harness;
95
+ const degraded = [...new Set([...i.results.values()].flatMap((r) => r.degraded ?? []))];
95
96
  return {
96
97
  schema_version: RUN_HISTORY_SCHEMA_VERSION,
97
98
  run_id: i.runId,
@@ -111,6 +112,7 @@ export function buildRunRecord(i) {
111
112
  cost_usd: summary.costUsd ?? null,
112
113
  },
113
114
  ...(harness ? { harness: { id: harness.id, version: harness.version } } : {}),
115
+ ...(degraded.length > 0 ? { degraded } : {}),
114
116
  };
115
117
  }
116
118
  // ── Write, prune, read ──────────────────────────────────────────
@@ -18,7 +18,16 @@ export interface RunSummary {
18
18
  tokens?: number;
19
19
  /** Sum of SDK-reported cost. Undefined when the SDK reported none (never estimated). */
20
20
  costUsd?: number;
21
+ /** Harness that ran the nodes, when a result was tagged. */
22
+ harness?: string;
23
+ /**
24
+ * Opinions some node's harness could not honor natively, as short keys
25
+ * (`max_turns`, `egress allowlist`, `deny [write]`). Absent when none.
26
+ */
27
+ degraded?: string[];
21
28
  }
29
+ /** The short key of a `degraded` entry: the text before its first colon. */
30
+ export declare function degradedKey(entry: string): string;
22
31
  /** Sum the per-node results into one run summary. */
23
32
  export declare function summarizeRun(results: Map<string, NodeResult>, durationMs: number, crashed?: boolean): RunSummary;
24
33
  export declare function formatReceiptDuration(ms: number): string;
@@ -26,8 +35,10 @@ export declare function formatTokenCount(n: number): string;
26
35
  export declare function formatCost(usd: number): string;
27
36
  /**
28
37
  * One plain line: `✓ 3/3 nodes · 41 tool calls · 2m10s · 12k tokens · $0.18`.
29
- * Token and cost segments are omitted when the SDK reported none. No ANSI;
30
- * callers colorize only when writing to a TTY.
38
+ * Token and cost segments are omitted when the SDK reported none. A harness
39
+ * other than Claude Code is named, and anything it could not honor natively
40
+ * is listed (`· codex · degraded: max_turns`). No ANSI; callers colorize only
41
+ * when writing to a TTY.
31
42
  */
32
43
  export declare function formatReceipt(s: RunSummary): string;
33
44
  /** Receipt for a terminal: colored only when `color` is true. */
@@ -9,6 +9,11 @@ import fs from "node:fs";
9
9
  import chalk from "chalk";
10
10
  import { consoleLogger } from "../types.js";
11
11
  import { toMermaidBlock } from "../mermaid.js";
12
+ /** The short key of a `degraded` entry: the text before its first colon. */
13
+ export function degradedKey(entry) {
14
+ const i = entry.indexOf(":");
15
+ return (i === -1 ? entry : entry.slice(0, i)).trim();
16
+ }
12
17
  /** Sum the per-node results into one run summary. */
13
18
  export function summarizeRun(results, durationMs, crashed = false) {
14
19
  let nodesOk = 0;
@@ -17,8 +22,13 @@ export function summarizeRun(results, durationMs, crashed = false) {
17
22
  let toolCalls = 0;
18
23
  let tokens;
19
24
  let costUsd;
25
+ let harness;
26
+ const degraded = new Set();
20
27
  const isNum = (v) => typeof v === "number" && Number.isFinite(v);
21
28
  for (const r of results.values()) {
29
+ harness ??= r.harness?.id;
30
+ for (const d of r.degraded ?? [])
31
+ degraded.add(degradedKey(d));
22
32
  if (r.status === "success")
23
33
  nodesOk++;
24
34
  else if (r.status === "skipped")
@@ -45,6 +55,8 @@ export function summarizeRun(results, durationMs, crashed = false) {
45
55
  durationMs,
46
56
  ...(tokens !== undefined ? { tokens } : {}),
47
57
  ...(costUsd !== undefined ? { costUsd } : {}),
58
+ ...(harness !== undefined ? { harness } : {}),
59
+ ...(degraded.size > 0 ? { degraded: [...degraded] } : {}),
48
60
  };
49
61
  }
50
62
  export function formatReceiptDuration(ms) {
@@ -71,8 +83,10 @@ export function formatCost(usd) {
71
83
  }
72
84
  /**
73
85
  * One plain line: `✓ 3/3 nodes · 41 tool calls · 2m10s · 12k tokens · $0.18`.
74
- * Token and cost segments are omitted when the SDK reported none. No ANSI;
75
- * callers colorize only when writing to a TTY.
86
+ * Token and cost segments are omitted when the SDK reported none. A harness
87
+ * other than Claude Code is named, and anything it could not honor natively
88
+ * is listed (`· codex · degraded: max_turns`). No ANSI; callers colorize only
89
+ * when writing to a TTY.
76
90
  */
77
91
  export function formatReceipt(s) {
78
92
  const parts = [
@@ -82,6 +96,8 @@ export function formatReceipt(s) {
82
96
  formatReceiptDuration(s.durationMs),
83
97
  ...(s.tokens !== undefined ? [`${formatTokenCount(s.tokens)} tokens`] : []),
84
98
  ...(s.costUsd !== undefined ? [formatCost(s.costUsd)] : []),
99
+ ...(s.harness !== undefined && s.harness !== "claude-code" ? [s.harness] : []),
100
+ ...(s.degraded && s.degraded.length > 0 ? [`degraded: ${s.degraded.join(", ")}`] : []),
85
101
  ];
86
102
  return parts.join(" · ");
87
103
  }
@@ -195,4 +211,9 @@ export const WORKFLOW_RUN_OPTIONS = [
195
211
  "Write a PR-comment markdown (run receipt, status-colored DAG, per-node table; metadata only) to <path> so any CI can post it",
196
212
  ],
197
213
  ["--input <json>", "JSON string of input data to pass to the workflow"],
214
+ ["--agent <id>", "Coding agent that runs the nodes: claude (default) or codex"],
215
+ [
216
+ "--harness-policy <mode>",
217
+ "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)",
218
+ ],
198
219
  ];
package/dist/executor.js CHANGED
@@ -23,6 +23,7 @@ import { validateWorkflow } from "./schema.js";
23
23
  import { resolveAgentAccess } from "./agent-env.js";
24
24
  import { fenceUntrusted } from "./untrusted.js";
25
25
  import { asClaude } from "./harness/compat.js";
26
+ import { isToolClass } from "./harness/policy.js";
26
27
  /**
27
28
  * Default workflow-level step budget. Counts total node executions across the
28
29
  * whole run (loop iterations included). Generous enough for legitimate deep
@@ -171,8 +172,8 @@ export async function execute(workflow, input, options) {
171
172
  // Build context: input + all prior node results.
172
173
  // Each prior node entry is the node's `data` augmented with `evals`
173
174
  // (a Record<name, EvalResult> for downstream lookup like
174
- // `priorNode.evals.tests_run_clean.pass`). When `data` already has an
175
- // `evals` key, the data field wins (back-compat with existing workflows).
175
+ // `priorNode.evals.tests_run_clean.pass`). This namespace is reserved
176
+ // for runtime verdicts; an agent-provided `data.evals` never overrides it.
176
177
  const context = {
177
178
  input,
178
179
  ...Object.fromEntries([...results.entries()].map(([k, v]) => [k, buildPriorNodeContext(v)])),
@@ -240,6 +241,11 @@ export async function execute(workflow, input, options) {
240
241
  // Per-node execution model: node.model ?? workflow.model. When undefined,
241
242
  // claude.run falls back to its own client default (then Claude Code's).
242
243
  const nodeModel = resolveExecutionModel(node, workflow);
244
+ // `tools.deny` entries that name a portable class (shell, write, edit, net,
245
+ // subagent) also deny that class of built-in agent tools; the harness
246
+ // compiles them or reports them in `degraded`.
247
+ const denyClasses = (node.tools?.deny ?? []).filter(isToolClass);
248
+ let degradedLogged = false;
243
249
  while (true) {
244
250
  result = await claude.run({
245
251
  instruction: currentInstruction,
@@ -248,6 +254,7 @@ export async function execute(workflow, input, options) {
248
254
  outputSchema: node.output,
249
255
  maxTurns: node.max_turns,
250
256
  disallowedTools: node.disallowed_tools,
257
+ ...(denyClasses.length > 0 ? { deny: denyClasses } : {}),
251
258
  model: nodeModel,
252
259
  signal,
253
260
  timeoutMs,
@@ -258,6 +265,12 @@ export async function execute(workflow, input, options) {
258
265
  safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
259
266
  },
260
267
  });
268
+ // Opinions the harness could not honor natively: said once per node, never silently dropped.
269
+ if (!degradedLogged && result.degraded && result.degraded.length > 0) {
270
+ degradedLogged = true;
271
+ const who = result.harness?.id ?? "harness";
272
+ logger.warn(` ${who} could not honor natively: ${result.degraded.join("; ")}`, { node: currentId });
273
+ }
261
274
  // Retry only triggers on eval failure. Bail on tool/API errors.
262
275
  if (result.status !== "success") {
263
276
  agentRunFailed = true;
@@ -355,7 +368,9 @@ export async function execute(workflow, input, options) {
355
368
  // instead of failing outright. The original error stays in `data.error`
356
369
  // and `data.fail_soft` marks the downgrade for downstream nodes and
357
370
  // observers. Eval failures never take this path (agentRunFailed guards it).
358
- if (agentRunFailed && result.status === "failed" && node.fail_soft === true) {
371
+ // A strict-policy refusal (#331) is not an agent failure: fail_soft never softens it.
372
+ const refused = result.data?.refused === true;
373
+ if (agentRunFailed && result.status === "failed" && node.fail_soft === true && !refused) {
359
374
  const failError = result.data?.error;
360
375
  logger.warn(` fail_soft: node failed (${String(failError ?? "unknown error")}); continuing with partial output`, {
361
376
  node: currentId,
@@ -488,23 +503,15 @@ function warnOnJudgeBudget(workflow, logger) {
488
503
  *
489
504
  * Spec contract (https://spec.sweny.ai/nodes/#evalresult-type): downstream
490
505
  * nodes can read `priorNode.evals.<name>.pass` via the natural lookup path.
491
- * To honor this without breaking back-compat with workflows that read
492
- * `priorNode.<dataField>` directly, we spread `data` first and then attach
493
- * an `evals` namespace keyed by evaluator name. If the agent's structured
494
- * output happens to include a literal `evals` field, that field wins (and
495
- * the lookup degrades to "evals not available" for downstream readers).
506
+ * Preserve ordinary data fields, but reserve `evals` for runtime verdicts.
507
+ * Agent output must not forge a pass for downstream `requires` gates,
508
+ * including when no evaluator ran (the trusted namespace is then empty).
496
509
  */
497
510
  function buildPriorNodeContext(result) {
498
511
  const data = (result.data ?? {});
499
512
  const evals = result.evals ?? [];
500
- if (evals.length === 0)
501
- return data;
502
- const evalsByName = {};
503
- for (const e of evals) {
504
- evalsByName[e.name] = e;
505
- }
506
- // Spread evals first so a data-side `evals` key shadows it (back-compat).
507
- return { evals: evalsByName, ...data };
513
+ const evalsByName = Object.fromEntries(evals.map((e) => [e.name, e]));
514
+ return { ...data, evals: evalsByName };
508
515
  }
509
516
  /**
510
517
  * Build the prior-node entry shown to the LLM route evaluator.
@@ -564,14 +571,9 @@ function buildRouteEvalEntry(result, sourceNode) {
564
571
  dataView = fullData;
565
572
  }
566
573
  const evals = result.evals ?? [];
567
- if (evals.length === 0)
568
- return { view: dataView, missing };
569
- const evalsByName = {};
570
- for (const e of evals) {
571
- evalsByName[e.name] = e;
572
- }
573
- // Spread evals first so a data-side `evals` key shadows it (back-compat).
574
- return { view: { evals: evalsByName, ...dataView }, missing };
574
+ const evalsByName = Object.fromEntries(evals.map((e) => [e.name, e]));
575
+ // Schema projection never grants agent data authority over runtime verdicts.
576
+ return { view: { ...dataView, evals: evalsByName }, missing };
575
577
  }
576
578
  /**
577
579
  * Return the declared output property names for a node, or null when the
@@ -762,7 +764,10 @@ function filterNodeTools(tools, filter, nodeId, logger) {
762
764
  if (!filter)
763
765
  return tools;
764
766
  const resolvedNames = new Set(tools.map((t) => t.name));
765
- for (const name of [...(filter.allow ?? []), ...(filter.deny ?? [])]) {
767
+ // A deny entry that names a tool class (write, shell, ...) targets built-in
768
+ // agent tools too, so it is not a typo when no skill tool has that name.
769
+ const denyNames = (filter.deny ?? []).filter((n) => !isToolClass(n));
770
+ for (const name of [...(filter.allow ?? []), ...denyNames]) {
766
771
  if (!resolvedNames.has(name)) {
767
772
  logger.warn(` tools filter: '${name}' does not match any tool resolved for node '${nodeId}'`, {
768
773
  node: nodeId,