@sweny-ai/core 0.1.171 → 0.1.173

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent-env.js CHANGED
@@ -29,6 +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
33
  // ─── Scoped env ──────────────────────────────────────────────────
33
34
  /**
34
35
  * Exact variable names always passed to the agent subprocess.
@@ -956,7 +957,11 @@ export function buildSandboxSettings(domains, opts = {}) {
956
957
  },
957
958
  credentials: {
958
959
  envVars: AGENT_AUTH_VARS.map((name) => ({ name, mode: "deny" })),
959
- files: [{ path: path.join(opts.home ?? homedir(), ".claude", ".credentials.json"), mode: "deny" }],
960
+ files: [
961
+ { path: path.join(opts.home ?? homedir(), ".claude", ".credentials.json"), mode: "deny" },
962
+ // Run journal keys: an agent that could read them could forge journal records.
963
+ { path: runKeyDir(), mode: "deny" },
964
+ ],
960
965
  },
961
966
  };
962
967
  }
package/dist/budget.d.ts CHANGED
@@ -16,6 +16,8 @@
16
16
  * the attempt's signal, which stops the agent (harness cancel).
17
17
  * - between nodes: usage on the finished result is checked; a crossing fails the
18
18
  * node, and the run halts before the next node starts.
19
+ * - across a resume: the run journal records each attempt's spend (live and
20
+ * final), and `sweny workflow resume` seeds the run total with it.
19
21
  */
20
22
  import type { NodeUsage } from "./types.js";
21
23
  /** The `budget` field of a node or workflow. */
@@ -69,6 +71,12 @@ export declare class BudgetGuard {
69
71
  /** The run ceiling is already reached: no further attempt may start. */
70
72
  runExhausted(): BudgetOverrun | undefined;
71
73
  node(nodeLimits: SpendLimits): NodeBudget;
74
+ /**
75
+ * Spend the run made before this process (a resumed run's journaled usage).
76
+ * Counted against the run ceiling like any other spend, so a crash never
77
+ * resets the whole-run budget.
78
+ */
79
+ seed(s: Spend): void;
72
80
  /** @internal */
73
81
  commit(s: Spend): void;
74
82
  }
package/dist/budget.js CHANGED
@@ -16,6 +16,8 @@
16
16
  * the attempt's signal, which stops the agent (harness cancel).
17
17
  * - between nodes: usage on the finished result is checked; a crossing fails the
18
18
  * node, and the run halts before the next node starts.
19
+ * - across a resume: the run journal records each attempt's spend (live and
20
+ * final), and `sweny workflow resume` seeds the run total with it.
19
21
  */
20
22
  const isNum = (v) => typeof v === "number" && Number.isFinite(v);
21
23
  export function toLimits(b) {
@@ -89,6 +91,15 @@ export class BudgetGuard {
89
91
  node(nodeLimits) {
90
92
  return new NodeBudget(this, nodeLimits);
91
93
  }
94
+ /**
95
+ * Spend the run made before this process (a resumed run's journaled usage).
96
+ * Counted against the run ceiling like any other spend, so a crash never
97
+ * resets the whole-run budget.
98
+ */
99
+ seed(s) {
100
+ const ok = (n) => (Number.isFinite(n) && n > 0 ? n : 0);
101
+ this.commit({ tokens: ok(s.tokens), costUsd: ok(s.costUsd) });
102
+ }
92
103
  /** @internal */
93
104
  commit(s) {
94
105
  this.spent = { tokens: this.spent.tokens + s.tokens, costUsd: this.spent.costUsd + s.costUsd };
package/dist/cli/check.js CHANGED
@@ -63,7 +63,7 @@ export async function checkProviderConnectivity(config, opts = {}) {
63
63
  results.push(await checkSentry(creds));
64
64
  }
65
65
  else if (provider === "file") {
66
- results.push({ name: "Observability (file)", status: "skip", detail: "File provider — no network check needed" });
66
+ results.push({ name: "Observability (file)", status: "skip", detail: "File provider - no network check needed" });
67
67
  }
68
68
  else {
69
69
  results.push({
@@ -89,7 +89,7 @@ export async function checkProviderConnectivity(config, opts = {}) {
89
89
  results.push({
90
90
  name: "Issue tracker (file)",
91
91
  status: "skip",
92
- detail: "File provider — no network check needed",
92
+ detail: "File provider - no network check needed",
93
93
  });
94
94
  }
95
95
  else {
@@ -118,7 +118,7 @@ export async function checkProviderConnectivity(config, opts = {}) {
118
118
  results.push({
119
119
  name: "Source control (file)",
120
120
  status: "skip",
121
- detail: "File provider — no network check needed",
121
+ detail: "File provider - no network check needed",
122
122
  });
123
123
  }
124
124
  else {
@@ -145,7 +145,7 @@ async function checkAnthropic(apiKey) {
145
145
  return {
146
146
  name,
147
147
  status: "fail",
148
- detail: "401 Unauthorized — check ANTHROPIC_API_KEY at https://console.anthropic.com",
148
+ detail: "401 Unauthorized - check ANTHROPIC_API_KEY at https://console.anthropic.com",
149
149
  };
150
150
  }
151
151
  return { name, status: "fail", detail: `Unexpected HTTP ${res.status}` };
@@ -220,7 +220,7 @@ export async function checkDatadog(creds) {
220
220
  return {
221
221
  name,
222
222
  status: "fail",
223
- detail: `Invalid DD_SITE "${site}" — expected a hostname like datadoghq.com or datadoghq.eu`,
223
+ detail: `Invalid DD_SITE "${site}" - expected a hostname like datadoghq.com or datadoghq.eu`,
224
224
  };
225
225
  }
226
226
  try {
@@ -235,7 +235,7 @@ export async function checkDatadog(creds) {
235
235
  return {
236
236
  name,
237
237
  status: "fail",
238
- detail: "403 Forbidden — check DD_API_KEY and DD_APP_KEY at https://app.datadoghq.com/organization-settings/api-keys",
238
+ detail: "403 Forbidden - check DD_API_KEY and DD_APP_KEY at https://app.datadoghq.com/organization-settings/api-keys",
239
239
  };
240
240
  }
241
241
  return { name, status: "fail", detail: `Unexpected HTTP ${res.status}` };
@@ -262,7 +262,7 @@ async function checkSentry(creds) {
262
262
  return {
263
263
  name,
264
264
  status: "fail",
265
- detail: "401 Unauthorized — check SENTRY_AUTH_TOKEN at https://sentry.io/settings/auth-tokens/",
265
+ detail: "401 Unauthorized - check SENTRY_AUTH_TOKEN at https://sentry.io/settings/auth-tokens/",
266
266
  };
267
267
  }
268
268
  return { name, status: "fail", detail: `Unexpected HTTP ${res.status}` };
@@ -286,7 +286,7 @@ async function checkLinear(apiKey) {
286
286
  if (res.status === 200) {
287
287
  const body = (await res.json());
288
288
  if (body.errors?.length) {
289
- return { name, status: "fail", detail: "GraphQL error — check LINEAR_API_KEY" };
289
+ return { name, status: "fail", detail: "GraphQL error - check LINEAR_API_KEY" };
290
290
  }
291
291
  const displayName = body.data?.viewer?.name ?? "authenticated";
292
292
  return { name, status: "ok", detail: `Authenticated as ${displayName}` };
@@ -295,7 +295,7 @@ async function checkLinear(apiKey) {
295
295
  return {
296
296
  name,
297
297
  status: "fail",
298
- detail: "401 Unauthorized — check LINEAR_API_KEY at https://linear.app/settings/api",
298
+ detail: "401 Unauthorized - check LINEAR_API_KEY at https://linear.app/settings/api",
299
299
  };
300
300
  }
301
301
  return { name, status: "fail", detail: `Unexpected HTTP ${res.status}` };
@@ -321,7 +321,7 @@ async function checkGitHub(token, name) {
321
321
  return {
322
322
  name,
323
323
  status: "fail",
324
- detail: "401 Bad credentials — check GITHUB_TOKEN at https://github.com/settings/tokens",
324
+ detail: "401 Bad credentials - check GITHUB_TOKEN at https://github.com/settings/tokens",
325
325
  };
326
326
  }
327
327
  return { name, status: "fail", detail: `Unexpected HTTP ${res.status}` };
@@ -335,11 +335,11 @@ function networkErrorMessage(err) {
335
335
  // explicit abort surfaces as "AbortError". Either means we gave up waiting.
336
336
  const errName = err instanceof Error ? err.name : "";
337
337
  if (errName === "TimeoutError" || errName === "AbortError") {
338
- return `Connection timed out after ${CHECK_TIMEOUT_MS}ms — host unreachable or too slow`;
338
+ return `Connection timed out after ${CHECK_TIMEOUT_MS}ms - host unreachable or too slow`;
339
339
  }
340
340
  const msg = err instanceof Error ? err.message : String(err);
341
341
  if (/ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test(msg)) {
342
- return `Network error — check your internet connection (${msg})`;
342
+ return `Network error - check your internet connection (${msg})`;
343
343
  }
344
344
  return msg;
345
345
  }
@@ -149,7 +149,7 @@ export function parseCliInputs(options, fileConfig = {}) {
149
149
  const fromEnv = env.SWENY_CLOUD_TOKEN || "";
150
150
  const fromFile = f("cloud-token") || "";
151
151
  if (!fromEnv && fromFile) {
152
- console.warn("\x1b[33m⚠ cloud-token is set in .sweny.yml — consider using SWENY_CLOUD_TOKEN env var instead to avoid accidental commits.\x1b[0m");
152
+ console.warn("\x1b[33m⚠ cloud-token is set in .sweny.yml - consider using SWENY_CLOUD_TOKEN env var instead to avoid accidental commits.\x1b[0m");
153
153
  }
154
154
  return fromEnv || fromFile;
155
155
  })(),
@@ -229,7 +229,7 @@ export function validateInputs(config) {
229
229
  switch (config.codingAgentProvider) {
230
230
  case "claude":
231
231
  if (!config.anthropicApiKey && !config.claudeOauthToken) {
232
- errors.push("Missing: ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN — get a key at https://console.anthropic.com");
232
+ errors.push("Missing: ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN - get a key at https://console.anthropic.com");
233
233
  }
234
234
  break;
235
235
  case "codex":
@@ -262,13 +262,13 @@ export function validateInputs(config) {
262
262
  switch (provider) {
263
263
  case "datadog":
264
264
  if (!creds.apiKey)
265
- errors.push("Missing: DD_API_KEY — find API keys at https://app.datadoghq.com/organization-settings/api-keys");
265
+ errors.push("Missing: DD_API_KEY - find API keys at https://app.datadoghq.com/organization-settings/api-keys");
266
266
  if (!creds.appKey)
267
- errors.push("Missing: DD_APP_KEY — find application keys at https://app.datadoghq.com/organization-settings/application-keys");
267
+ errors.push("Missing: DD_APP_KEY - find application keys at https://app.datadoghq.com/organization-settings/application-keys");
268
268
  break;
269
269
  case "sentry":
270
270
  if (!creds.authToken)
271
- errors.push("Missing: SENTRY_AUTH_TOKEN — create a token at https://sentry.io/settings/auth-tokens/");
271
+ errors.push("Missing: SENTRY_AUTH_TOKEN - create a token at https://sentry.io/settings/auth-tokens/");
272
272
  if (!creds.organization)
273
273
  errors.push("Missing: --sentry-org is required for sentry provider");
274
274
  if (!creds.project)
@@ -342,33 +342,33 @@ export function validateInputs(config) {
342
342
  break;
343
343
  case "vercel":
344
344
  if (!creds.token)
345
- errors.push("Missing: VERCEL_TOKEN — create a token at https://vercel.com/account/tokens");
345
+ errors.push("Missing: VERCEL_TOKEN - create a token at https://vercel.com/account/tokens");
346
346
  if (!creds.projectId)
347
- errors.push("Missing: VERCEL_PROJECT_ID — find it in your Vercel project settings");
347
+ errors.push("Missing: VERCEL_PROJECT_ID - find it in your Vercel project settings");
348
348
  break;
349
349
  case "supabase":
350
350
  if (!creds.managementApiKey)
351
- errors.push("Missing: SUPABASE_MANAGEMENT_KEY — create a token at https://supabase.com/dashboard/account/tokens");
351
+ errors.push("Missing: SUPABASE_MANAGEMENT_KEY - create a token at https://supabase.com/dashboard/account/tokens");
352
352
  if (!creds.projectRef)
353
- errors.push("Missing: SUPABASE_PROJECT_REF — find it in your Supabase project settings > General");
353
+ errors.push("Missing: SUPABASE_PROJECT_REF - find it in your Supabase project settings > General");
354
354
  break;
355
355
  case "netlify":
356
356
  if (!creds.token)
357
- errors.push("Missing: NETLIFY_TOKEN — create a token at https://app.netlify.com/user/applications#personal-access-tokens");
357
+ errors.push("Missing: NETLIFY_TOKEN - create a token at https://app.netlify.com/user/applications#personal-access-tokens");
358
358
  if (!creds.siteId)
359
- errors.push("Missing: NETLIFY_SITE_ID — find it in Site Settings > General > Site details");
359
+ errors.push("Missing: NETLIFY_SITE_ID - find it in Site Settings > General > Site details");
360
360
  break;
361
361
  case "fly":
362
362
  if (!creds.token)
363
- errors.push("Missing: FLY_TOKEN — create a token at https://fly.io/user/personal_access_tokens");
363
+ errors.push("Missing: FLY_TOKEN - create a token at https://fly.io/user/personal_access_tokens");
364
364
  if (!creds.appName)
365
- errors.push("Missing: FLY_APP_NAME — the name of your Fly.io application");
365
+ errors.push("Missing: FLY_APP_NAME - the name of your Fly.io application");
366
366
  break;
367
367
  case "render":
368
368
  if (!creds.apiKey)
369
- errors.push("Missing: RENDER_API_KEY — create an API key at https://dashboard.render.com/u/settings");
369
+ errors.push("Missing: RENDER_API_KEY - create an API key at https://dashboard.render.com/u/settings");
370
370
  if (!creds.serviceId)
371
- errors.push("Missing: RENDER_SERVICE_ID — find it in your service's Settings page (srv-...)");
371
+ errors.push("Missing: RENDER_SERVICE_ID - find it in your service's Settings page (srv-...)");
372
372
  break;
373
373
  default:
374
374
  errors.push(`Unknown --observability-provider "${provider}". Valid values: none, datadog, sentry, cloudwatch, splunk, elastic, newrelic, loki, prometheus, pagerduty, honeycomb, heroku, opsgenie, axiom, betterstack, vercel, supabase, netlify, fly, render, file`);
@@ -378,17 +378,17 @@ export function validateInputs(config) {
378
378
  switch (config.issueTrackerProvider) {
379
379
  case "linear":
380
380
  if (!config.linearApiKey)
381
- errors.push("Missing: LINEAR_API_KEY — find API keys at https://linear.app/settings/api");
381
+ errors.push("Missing: LINEAR_API_KEY - find API keys at https://linear.app/settings/api");
382
382
  if (!config.linearTeamId)
383
- errors.push("Missing: LINEAR_TEAM_ID — find it in Linear > Settings > Workspace > Teams > [your team] > copy the ID from the URL");
383
+ errors.push("Missing: LINEAR_TEAM_ID - find it in Linear > Settings > Workspace > Teams > [your team] > copy the ID from the URL");
384
384
  break;
385
385
  case "jira":
386
386
  if (!config.jiraBaseUrl)
387
- errors.push("Missing: JIRA_BASE_URL — set to your Atlassian domain, e.g. https://your-org.atlassian.net");
387
+ errors.push("Missing: JIRA_BASE_URL - set to your Atlassian domain, e.g. https://your-org.atlassian.net");
388
388
  if (!config.jiraEmail)
389
- errors.push("Missing: JIRA_EMAIL — your Atlassian account email address");
389
+ errors.push("Missing: JIRA_EMAIL - your Atlassian account email address");
390
390
  if (!config.jiraApiToken)
391
- errors.push("Missing: JIRA_API_TOKEN — create a token at https://id.atlassian.com/manage-profile/security/api-tokens");
391
+ errors.push("Missing: JIRA_API_TOKEN - create a token at https://id.atlassian.com/manage-profile/security/api-tokens");
392
392
  break;
393
393
  case "github-issues":
394
394
  case "file":
@@ -401,13 +401,13 @@ export function validateInputs(config) {
401
401
  switch (config.sourceControlProvider) {
402
402
  case "github":
403
403
  if (!config.githubToken && !config.botToken)
404
- errors.push("Missing: GITHUB_TOKEN — create a Personal Access Token at https://github.com/settings/tokens");
404
+ errors.push("Missing: GITHUB_TOKEN - create a Personal Access Token at https://github.com/settings/tokens");
405
405
  break;
406
406
  case "gitlab":
407
407
  if (!config.gitlabToken)
408
- errors.push("Missing: GITLAB_TOKEN — create a Personal Access Token at https://gitlab.com/-/user_settings/personal_access_tokens (api + read_repository + write_repository scopes)");
408
+ errors.push("Missing: GITLAB_TOKEN - create a Personal Access Token at https://gitlab.com/-/user_settings/personal_access_tokens (api + read_repository + write_repository scopes)");
409
409
  if (!config.gitlabProjectId)
410
- errors.push("Missing: GITLAB_PROJECT_ID — find it in GitLab > [your project] > Settings > General (numeric ID at the top)");
410
+ errors.push("Missing: GITLAB_PROJECT_ID - find it in GitLab > [your project] > Settings > General (numeric ID at the top)");
411
411
  break;
412
412
  case "file":
413
413
  // No external credentials needed
@@ -670,7 +670,7 @@ export function registerImplementCommand(program) {
670
670
  .option("--coding-agent-provider <provider>", "Coding agent provider (alias for --agent)")
671
671
  .option("--issue-tracker-provider <provider>", "Issue tracker (linear|jira|github-issues|file)")
672
672
  .option("--source-control-provider <provider>", "Source control (github|gitlab|file)")
673
- .option("--dry-run", "Skip creating PR — report only", false)
673
+ .option("--dry-run", "Skip creating PR - report only", false)
674
674
  .option("--stage", "Run normally, but preview the PR and issue comment instead of writing them", false)
675
675
  .option("--max-implement-turns <n>", "Max coding agent turns (default: 40)")
676
676
  .option("--base-branch <branch>", "Base branch for PRs (default: main)")
package/dist/cli/e2e.js CHANGED
@@ -87,17 +87,17 @@ This returns element references like @e1, @e2, @e3.
87
87
  Use those refs with: agent-browser click @e5, agent-browser fill @e7 "text", etc.
88
88
 
89
89
  Available commands:
90
- - agent-browser open <url> — navigate to URL
91
- - agent-browser snapshot — get accessibility tree with @refs
92
- - agent-browser click <ref> — click an element
93
- - agent-browser fill <ref> <text> — clear input and fill with text
94
- - agent-browser press <key> — press keyboard key (Enter, Tab, Escape)
95
- - agent-browser get url — get current page URL
96
- - agent-browser get text <ref> — get element text content
97
- - agent-browser screenshot <path> — save screenshot to file
98
- - agent-browser scroll <direction> <pixels> — scroll page
99
- - agent-browser scrollintoview <ref> — scroll element into view
100
- - agent-browser select <ref> <value> — select dropdown option`,
90
+ - agent-browser open <url> - navigate to URL
91
+ - agent-browser snapshot - get accessibility tree with @refs
92
+ - agent-browser click <ref> - click an element
93
+ - agent-browser fill <ref> <text> - clear input and fill with text
94
+ - agent-browser press <key> - press keyboard key (Enter, Tab, Escape)
95
+ - agent-browser get url - get current page URL
96
+ - agent-browser get text <ref> - get element text content
97
+ - agent-browser screenshot <path> - save screenshot to file
98
+ - agent-browser scroll <direction> <pixels> - scroll page
99
+ - agent-browser scrollintoview <ref> - scroll element into view
100
+ - agent-browser select <ref> <value> - select dropdown option`,
101
101
  skills: [],
102
102
  output: {
103
103
  type: "object",
@@ -142,7 +142,7 @@ Use curl to call the Supabase Admin API:
142
142
  2. Filter for emails starting with "e2e-" and ending with "@yourapp.test"
143
143
  3. Delete each matching user: DELETE {SUPABASE_URL}/auth/v1/admin/users/{user_id}
144
144
 
145
- If no test users are found, that's fine — skip gracefully.
145
+ If no test users are found, that's fine - skip gracefully.
146
146
  If the service role key is not available, skip cleanup gracefully.`;
147
147
  break;
148
148
  case "firebase":
@@ -501,7 +501,7 @@ const CLEANUP_ENV_VARS = {
501
501
  */
502
502
  export function buildE2eEnvTemplate(selections) {
503
503
  const lines = [];
504
- lines.push("# E2E Testing — SWEny");
504
+ lines.push("# E2E Testing - SWEny");
505
505
  lines.push("# Fill in values, then run: sweny workflow run");
506
506
  lines.push("");
507
507
  lines.push(`E2E_BASE_URL=${selections.baseUrl}`);
@@ -640,7 +640,7 @@ export async function runE2eInit(options = {}) {
640
640
  if (p.isCancel(overwrite))
641
641
  cancel();
642
642
  if (!overwrite) {
643
- p.cancel("Setup cancelled — existing files preserved.");
643
+ p.cancel("Setup cancelled - existing files preserved.");
644
644
  process.exit(0);
645
645
  }
646
646
  }
@@ -741,7 +741,7 @@ export async function runE2eInit(options = {}) {
741
741
  p.log.success(`Appended E2E vars to ${chalk.cyan(".env")}`);
742
742
  }
743
743
  else {
744
- p.log.warn(".env already has E2E vars — skipped");
744
+ p.log.warn(".env already has E2E vars - skipped");
745
745
  }
746
746
  }
747
747
  else {
@@ -930,10 +930,10 @@ export async function runE2eRun(options) {
930
930
  const elapsed = ((Date.now() - enterTime) / 1000).toFixed(1);
931
931
  const status = event.result.data?.status || event.result.status;
932
932
  if (isTTY) {
933
- process.stderr.write(`\x1B[1A\x1B[2K ${icon} ${event.node} — ${status} (${elapsed}s)\n`);
933
+ process.stderr.write(`\x1B[1A\x1B[2K ${icon} ${event.node} - ${status} (${elapsed}s)\n`);
934
934
  }
935
935
  else {
936
- process.stderr.write(` ${icon} ${event.node} — ${status} (${elapsed}s)\n`);
936
+ process.stderr.write(` ${icon} ${event.node} - ${status} (${elapsed}s)\n`);
937
937
  }
938
938
  break;
939
939
  }
@@ -4,9 +4,15 @@
4
4
  *
5
5
  * Unlike run-history and the receipt, this DOES read node output. It is
6
6
  * local-only: printed to the terminal and saved to
7
- * `.sweny/runs/<run-id>/output.md`. Nothing here is sent to the cloud.
7
+ * `.sweny/runs/<run-id>/output.md` (mode 0600). Nothing here is sent to the cloud.
8
+ *
9
+ * Secrets: node data is redacted with the run journal's redactor before
10
+ * anything renders (secret env and skill credential values, secret-named
11
+ * fields, known token shapes). In CI the answer is not printed unless asked
12
+ * (`--show-output`), since CI logs are often visible to more people than the
13
+ * repo's secrets are.
8
14
  */
9
- import type { JSONSchema, NodeResult, Workflow } from "../types.js";
15
+ import type { ExecutionTrace, JSONSchema, NodeResult, Workflow } from "../types.js";
10
16
  /** Lines of the answer printed to the terminal before the rest is left in output.md. */
11
17
  export declare const FINAL_OUTPUT_MAX_LINES = 60;
12
18
  export interface FinalOutput {
@@ -18,12 +24,26 @@ export interface FinalOutput {
18
24
  }
19
25
  /** Render a node's declared output fields as a compact `key: value` block. Empty when nothing declared is present. */
20
26
  export declare function renderSchemaOutput(data: Record<string, unknown>, schema: JSONSchema): string[];
27
+ export interface ResolveFinalOutputOptions {
28
+ /** The run's trace: the answer comes from the last successful visit in execution order. */
29
+ trace?: Pick<ExecutionTrace, "steps">;
30
+ /** Secret values to redact (default: secret-named values in `process.env`). */
31
+ secrets?: string[];
32
+ }
21
33
  /**
22
- * Pick what the user should see: the last-run terminal node's result on
23
- * success, the first failed node's error on failure. Null when there is
34
+ * Pick what the user should see: the result of the last successful visit
35
+ * (from the trace, so a loop that ends on an earlier node shows that node) on
36
+ * success, the first failed node's error on failure. Without a trace, terminal
37
+ * nodes in result order. Everything is redacted first. Null when there is
24
38
  * nothing to show (no schema fields and no summary text).
25
39
  */
26
- export declare function resolveFinalOutput(workflow: Workflow, results: Map<string, NodeResult>): FinalOutput | null;
40
+ export declare function resolveFinalOutput(workflow: Workflow, results: Map<string, NodeResult>, opts?: ResolveFinalOutputOptions): FinalOutput | null;
41
+ /** True when the environment is CI (GitHub Actions, or a truthy `CI`). */
42
+ export declare function isCiEnv(env: Record<string, string | undefined>): boolean;
43
+ /** Print the answer block? Always outside CI; in CI only with `--show-output`. */
44
+ export declare function shouldPrintFinalOutput(showOutput: boolean, env: Record<string, string | undefined>): boolean;
45
+ /** The one line printed in CI instead of the answer. */
46
+ export declare function formatSavedOutputLine(savedTo: string | null): string;
27
47
  /**
28
48
  * Terminal text: the first `maxLines` lines, then
29
49
  * `... (N more lines, see .sweny/runs/<id>/output.md)`. When no file was
@@ -37,5 +57,5 @@ export declare function formatFinalOutput(out: FinalOutput, opts?: {
37
57
  export declare function formatFinalMarkdown(workflow: Workflow, out: FinalOutput): string;
38
58
  /** Relative path shown to the user: `.sweny/runs/<id>/output.md`. */
39
59
  export declare function outputRelPath(runId: string): string;
40
- /** Write output.md. Local only. Returns the relative path, or null on failure. Never throws. */
60
+ /** Write output.md (mode 0600). Local only. Returns the relative path, or null on failure. Never throws. */
41
61
  export declare function writeFinalOutput(runId: string, markdown: string, cwd?: string): string | null;
@@ -4,10 +4,17 @@
4
4
  *
5
5
  * Unlike run-history and the receipt, this DOES read node output. It is
6
6
  * local-only: printed to the terminal and saved to
7
- * `.sweny/runs/<run-id>/output.md`. Nothing here is sent to the cloud.
7
+ * `.sweny/runs/<run-id>/output.md` (mode 0600). Nothing here is sent to the cloud.
8
+ *
9
+ * Secrets: node data is redacted with the run journal's redactor before
10
+ * anything renders (secret env and skill credential values, secret-named
11
+ * fields, known token shapes). In CI the answer is not printed unless asked
12
+ * (`--show-output`), since CI logs are often visible to more people than the
13
+ * repo's secrets are.
8
14
  */
9
15
  import fs from "node:fs";
10
16
  import path from "node:path";
17
+ import { collectSecretValues, redact } from "../journal.js";
11
18
  import { RUN_HISTORY_DIR } from "./run-history.js";
12
19
  /** Lines of the answer printed to the terminal before the rest is left in output.md. */
13
20
  export const FINAL_OUTPUT_MAX_LINES = 60;
@@ -83,30 +90,52 @@ function trimBlock(text) {
83
90
  return lines.map((l) => l.replace(/\s+$/, ""));
84
91
  }
85
92
  /**
86
- * Pick what the user should see: the last-run terminal node's result on
87
- * success, the first failed node's error on failure. Null when there is
93
+ * Pick what the user should see: the result of the last successful visit
94
+ * (from the trace, so a loop that ends on an earlier node shows that node) on
95
+ * success, the first failed node's error on failure. Without a trace, terminal
96
+ * nodes in result order. Everything is redacted first. Null when there is
88
97
  * nothing to show (no schema fields and no summary text).
89
98
  */
90
- export function resolveFinalOutput(workflow, results) {
99
+ export function resolveFinalOutput(workflow, results, opts = {}) {
100
+ const secrets = opts.secrets ?? collectSecretValues(process.env);
101
+ const clean = (v) => redact(v, secrets).value;
91
102
  for (const [id, r] of results) {
92
103
  if (r.status !== "failed")
93
104
  continue;
94
105
  const err = r.data?.error;
95
- const text = typeof err === "string" && err.trim() !== "" ? err : "node failed";
106
+ const text = typeof err === "string" && err.trim() !== "" ? clean(err) : "node failed";
96
107
  return { kind: "error", node: id, lines: trimBlock(text) };
97
108
  }
98
- const hasOutgoing = new Set(workflow.edges.map((e) => e.from));
99
- const succeeded = [...results].filter(([, r]) => r.status === "success");
100
- const terminal = succeeded.filter(([id]) => !hasOutgoing.has(id));
101
- // Walk back from the end: the last node that actually yields something wins.
102
- const candidates = [...(terminal.length > 0 ? terminal : succeeded)].reverse();
109
+ let candidates;
110
+ if (opts.trace && opts.trace.steps.length > 0) {
111
+ // Last successful visit first: walk the trace backwards, each node once.
112
+ const seen = new Set();
113
+ candidates = [];
114
+ for (let i = opts.trace.steps.length - 1; i >= 0; i--) {
115
+ const step = opts.trace.steps[i];
116
+ if (step.status !== "success" || seen.has(step.node))
117
+ continue;
118
+ seen.add(step.node);
119
+ const r = results.get(step.node);
120
+ if (r?.status === "success")
121
+ candidates.push([step.node, r]);
122
+ }
123
+ }
124
+ else {
125
+ const hasOutgoing = new Set(workflow.edges.map((e) => e.from));
126
+ const succeeded = [...results].filter(([, r]) => r.status === "success");
127
+ const terminal = succeeded.filter(([id]) => !hasOutgoing.has(id));
128
+ // Walk back from the end: the last node that actually yields something wins.
129
+ candidates = [...(terminal.length > 0 ? terminal : succeeded)].reverse();
130
+ }
103
131
  for (const [id, r] of candidates) {
132
+ const data = clean(r.data ?? {});
104
133
  const schema = workflow.nodes[id]?.output;
105
134
  let lines = [];
106
135
  if (schema)
107
- lines = renderSchemaOutput(r.data ?? {}, schema);
136
+ lines = renderSchemaOutput(data, schema);
108
137
  if (lines.length === 0) {
109
- const summary = r.data?.summary;
138
+ const summary = data.summary;
110
139
  if (typeof summary === "string" && summary.trim() !== "")
111
140
  lines = trimBlock(summary);
112
141
  }
@@ -115,6 +144,23 @@ export function resolveFinalOutput(workflow, results) {
115
144
  }
116
145
  return null;
117
146
  }
147
+ /** True when the environment is CI (GitHub Actions, or a truthy `CI`). */
148
+ export function isCiEnv(env) {
149
+ if (env.GITHUB_ACTIONS === "true")
150
+ return true;
151
+ const ci = env.CI?.trim().toLowerCase();
152
+ return !!ci && ci !== "false" && ci !== "0";
153
+ }
154
+ /** Print the answer block? Always outside CI; in CI only with `--show-output`. */
155
+ export function shouldPrintFinalOutput(showOutput, env) {
156
+ return showOutput || !isCiEnv(env);
157
+ }
158
+ /** The one line printed in CI instead of the answer. */
159
+ export function formatSavedOutputLine(savedTo) {
160
+ return savedTo
161
+ ? ` answer saved to ${savedTo} (pass --show-output to print it in CI)`
162
+ : " answer not printed in CI (pass --show-output to print it)";
163
+ }
118
164
  /**
119
165
  * Terminal text: the first `maxLines` lines, then
120
166
  * `... (N more lines, see .sweny/runs/<id>/output.md)`. When no file was
@@ -140,13 +186,16 @@ export function formatFinalMarkdown(workflow, out) {
140
186
  export function outputRelPath(runId) {
141
187
  return path.posix.join(...RUN_HISTORY_DIR.split(path.sep), runId, "output.md");
142
188
  }
143
- /** Write output.md. Local only. Returns the relative path, or null on failure. Never throws. */
189
+ /** Write output.md (mode 0600). Local only. Returns the relative path, or null on failure. Never throws. */
144
190
  export function writeFinalOutput(runId, markdown, cwd = process.cwd()) {
145
191
  try {
146
192
  const rel = outputRelPath(runId);
147
193
  const file = path.join(cwd, rel);
148
- fs.mkdirSync(path.dirname(file), { recursive: true });
149
- fs.writeFileSync(file, markdown);
194
+ fs.mkdirSync(path.dirname(path.dirname(file)), { recursive: true });
195
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
196
+ fs.writeFileSync(file, markdown, { mode: 0o600 });
197
+ // mode only applies on create: a resumed run overwrites an existing file.
198
+ fs.chmodSync(file, 0o600);
150
199
  return rel;
151
200
  }
152
201
  catch {
package/dist/cli/main.js CHANGED
@@ -31,11 +31,11 @@ import * as readline from "node:readline";
31
31
  import { loadDotenv, loadConfigFile, applyAgentFileConfig } from "./config-file.js";
32
32
  import { buildCredentialMap } from "./credentials.js";
33
33
  import { nonInteractiveUsage, runNew } from "./new.js";
34
- import { formatFinalMarkdown, formatFinalOutput, resolveFinalOutput, writeFinalOutput } from "./final-output.js";
34
+ import { formatFinalMarkdown, formatFinalOutput, formatSavedOutputLine, resolveFinalOutput, shouldPrintFinalOutput, writeFinalOutput, } from "./final-output.js";
35
35
  import { buildRunRecord, createNodeTimer, historyDisabled, newRunId, recordRun } from "./run-history.js";
36
36
  import { registerRunsCommand } from "./runs.js";
37
37
  import { registerTryCommand } from "./try.js";
38
- import { JournalLockedError, JournalMismatchError, RunJournal } from "../journal.js";
38
+ import { JournalLockedError, JournalMismatchError, RunJournal, runSecretValues } from "../journal.js";
39
39
  import { journalDisabled, prepareResume, RESUME_SHARED_RUN_FLAGS, WORKFLOW_RESUME_OPTIONS, } from "./resume.js";
40
40
  import { runE2eRun, runWithWallClockBudget, DEFAULT_WORKFLOW_TIMEOUT_MS } from "./e2e.js";
41
41
  import { createVerboseToolObserver } from "./verbose-observer.js";
@@ -1102,12 +1102,18 @@ resume) {
1102
1102
  writeStepSummary(workflow, results, receipt, trace);
1103
1103
  // The answer, above the receipt: the terminal node's result, or the failed node's error.
1104
1104
  // Also saved to .sweny/runs/<id>/output.md (local only; --no-history skips the file).
1105
- const finalOutput = resolveFinalOutput(workflow, results);
1105
+ // Redacted with the journal's redactor; in CI printed only with --show-output.
1106
+ const finalOutput = resolveFinalOutput(workflow, results, {
1107
+ trace,
1108
+ secrets: runSecretValues([...skills.values(), ...builtinSkills], process.env),
1109
+ });
1106
1110
  if (finalOutput) {
1107
1111
  const savedTo = historyDisabled(options.history, fileConfig["history"])
1108
1112
  ? null
1109
1113
  : writeFinalOutput(runId, formatFinalMarkdown(workflow, finalOutput));
1110
- const block = formatFinalOutput(finalOutput, { outputPath: savedTo });
1114
+ const block = shouldPrintFinalOutput(options.showOutput === true, process.env)
1115
+ ? formatFinalOutput(finalOutput, { outputPath: savedTo })
1116
+ : formatSavedOutputLine(savedTo);
1111
1117
  if (wfHasFailed)
1112
1118
  console.error(`${block}\n`);
1113
1119
  else
@@ -1245,6 +1251,7 @@ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
1245
1251
  workflowRunCmd.option(flags, description);
1246
1252
  }
1247
1253
  workflowRunCmd.option("--no-history", "Do not record this run or save its output.md in .sweny/runs/ (or set `history: off` in .sweny.yml)");
1254
+ workflowRunCmd.option("--show-output", "Print the final answer in CI too (off by default in CI: only the path to output.md is printed)");
1248
1255
  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");
1249
1256
  const workflowResumeCmd = workflowCmd
1250
1257
  .command("resume <run-id>")
@@ -1283,6 +1290,7 @@ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
1283
1290
  workflowResumeCmd.option(flags, description);
1284
1291
  }
1285
1292
  workflowResumeCmd.option("--no-history", "Do not update this run's record in .sweny/runs/");
1293
+ workflowResumeCmd.option("--show-output", "Print the final answer in CI too (off by default in CI)");
1286
1294
  registerRunsCommand(program);
1287
1295
  workflowCmd
1288
1296
  .command("diagram <file>")
@@ -22,7 +22,7 @@ export async function fetchMarketplaceWorkflow(id) {
22
22
  res = await fetch(url);
23
23
  }
24
24
  catch {
25
- const err = new Error(`Could not reach github.com — check your connection`);
25
+ const err = new Error(`Could not reach github.com - check your connection`);
26
26
  err.kind = "network";
27
27
  throw err;
28
28
  }
@@ -147,8 +147,8 @@ export async function adaptWorkflowInteractive(workflow, options) {
147
147
  const action = await p.select({
148
148
  message: "Looks good?",
149
149
  options: [
150
- { value: "accept", label: "Yes — use this workflow" },
151
- { value: "refine", label: "Refine — describe what to change" },
150
+ { value: "accept", label: "Yes - use this workflow" },
151
+ { value: "refine", label: "Refine - describe what to change" },
152
152
  { value: "cancel", label: "Cancel" },
153
153
  ],
154
154
  });