@sweny-ai/core 0.1.172 → 0.1.174

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 };
@@ -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,14 +31,15 @@ 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";
42
+ import { createStreamObserver } from "./stream-observer.js";
42
43
  import { createRunLogger, summarizeRun, writeStepSummary, WORKFLOW_RUN_DESCRIPTION, WORKFLOW_RUN_OPTIONS, } from "./run-output.js";
43
44
  import { writeRunComment } from "./comment-output.js";
44
45
  import { writeReceipt } from "./ticket.js";
@@ -56,15 +57,9 @@ import { beginCloudLifecycle, finishCloudLifecycle, createCloudStreamObserver }
56
57
  import { runUpgrade, fetchLatestFromNpm } from "./upgrade.js";
57
58
  import { maybeNudge, defaultCachePath } from "./version-check.js";
58
59
  import { spawnSync } from "node:child_process";
59
- // ── Stream observer (NDJSON) ────────────────────────────────────────
60
- /**
61
- * Create an observer that writes NDJSON ExecutionEvents to stdout.
62
- * Studio and other consumers parse these line-by-line.
63
- */
64
- function createStreamObserver() {
65
- return (event) => {
66
- process.stdout.write(JSON.stringify(event) + "\n");
67
- };
60
+ /** Secret values a run can see, for redacting everything that prints node output. */
61
+ function outputSecrets(skills) {
62
+ return runSecretValues([...(skills?.values() ?? []), ...builtinSkills], process.env);
68
63
  }
69
64
  // Verbose tool-detail observer lives in ./verbose-observer.ts so tests can
70
65
  // import it without triggering main.ts's top-level CLI parser.
@@ -446,7 +441,7 @@ triageCmd.action(async (options) => {
446
441
  if (cloudHandle?.dashboardUrl) {
447
442
  console.log(c.subtle(` cloud: ${cloudHandle.dashboardUrl}`));
448
443
  }
449
- const observer = composeObservers(progressObserver, config.verbose ? createVerboseToolObserver() : undefined, config.stream ? createStreamObserver() : undefined, createCloudStreamObserver(config, cloudHandle));
444
+ const observer = composeObservers(progressObserver, config.verbose ? createVerboseToolObserver() : undefined, config.stream ? createStreamObserver(outputSecrets(skills)) : undefined, createCloudStreamObserver(config, cloudHandle));
450
445
  // #365: pin safe outputs to the configured tracker and repo; nodes these
451
446
  // providers cannot run read-only or through safe outputs keep their old shape.
452
447
  const triageRun = bindForCli(triageWorkflow, config);
@@ -466,7 +461,7 @@ triageCmd.action(async (options) => {
466
461
  const durationMs = Date.now() - runStart;
467
462
  // Output
468
463
  if (config.json) {
469
- await writeResultJson(results);
464
+ await writeResultJson(results, outputSecrets(skills));
470
465
  }
471
466
  else {
472
467
  console.log(formatDagResultHuman(results, durationMs, config));
@@ -654,7 +649,7 @@ implementCmd.action(async (issueId, options) => {
654
649
  if (implCloudHandle?.dashboardUrl) {
655
650
  console.log(c.subtle(` cloud: ${implCloudHandle.dashboardUrl}`));
656
651
  }
657
- const observer = composeObservers(implProgressObserver, config.verbose ? createVerboseToolObserver() : undefined, Boolean(options.stream) ? createStreamObserver() : undefined, createCloudStreamObserver(config, implCloudHandle));
652
+ const observer = composeObservers(implProgressObserver, config.verbose ? createVerboseToolObserver() : undefined, Boolean(options.stream) ? createStreamObserver(outputSecrets(skills)) : undefined, createCloudStreamObserver(config, implCloudHandle));
658
653
  const implementRun = bindForCli(implementWorkflow, config);
659
654
  try {
660
655
  const { results } = await execute(implementRun, workflowInput, {
@@ -681,7 +676,7 @@ implementCmd.action(async (issueId, options) => {
681
676
  // silent
682
677
  }
683
678
  if (config.json) {
684
- await writeResultJson(results);
679
+ await writeResultJson(results, outputSecrets(skills));
685
680
  }
686
681
  if (hasFailed) {
687
682
  console.error(chalk.red(`\n Implement workflow failed\n`));
@@ -1023,7 +1018,7 @@ resume) {
1023
1018
  // history must never fail a run
1024
1019
  }
1025
1020
  };
1026
- const observer = composeObservers(wfProgressObserver, nodeTimer.observer, options.verbose ? createVerboseToolObserver() : undefined, options.stream ? createStreamObserver() : undefined, createCloudStreamObserver(config, wfCloudHandle));
1021
+ const observer = composeObservers(wfProgressObserver, nodeTimer.observer, options.verbose ? createVerboseToolObserver() : undefined, options.stream ? createStreamObserver(outputSecrets(skills)) : undefined, createCloudStreamObserver(config, wfCloudHandle));
1027
1022
  // #325: this is the primary run path (GitHub Action / CLI / MCP all land
1028
1023
  // here), and previously never passed signal/timeoutMs/max_steps to
1029
1024
  // execute() at all: a wedged node (hung tool call, runaway model turn)
@@ -1077,7 +1072,7 @@ resume) {
1077
1072
  });
1078
1073
  }
1079
1074
  if (isJson) {
1080
- await writeResultJson(results);
1075
+ await writeResultJson(results, outputSecrets(skills));
1081
1076
  process.exit(wfHasFailed ? 1 : 0);
1082
1077
  return;
1083
1078
  }
@@ -1102,12 +1097,18 @@ resume) {
1102
1097
  writeStepSummary(workflow, results, receipt, trace);
1103
1098
  // The answer, above the receipt: the terminal node's result, or the failed node's error.
1104
1099
  // Also saved to .sweny/runs/<id>/output.md (local only; --no-history skips the file).
1105
- const finalOutput = resolveFinalOutput(workflow, results);
1100
+ // Redacted with the journal's redactor; in CI printed only with --show-output.
1101
+ const finalOutput = resolveFinalOutput(workflow, results, {
1102
+ trace,
1103
+ secrets: runSecretValues([...skills.values(), ...builtinSkills], process.env),
1104
+ });
1106
1105
  if (finalOutput) {
1107
1106
  const savedTo = historyDisabled(options.history, fileConfig["history"])
1108
1107
  ? null
1109
1108
  : writeFinalOutput(runId, formatFinalMarkdown(workflow, finalOutput));
1110
- const block = formatFinalOutput(finalOutput, { outputPath: savedTo });
1109
+ const block = shouldPrintFinalOutput(options.showOutput === true, process.env)
1110
+ ? formatFinalOutput(finalOutput, { outputPath: savedTo })
1111
+ : formatSavedOutputLine(savedTo);
1111
1112
  if (wfHasFailed)
1112
1113
  console.error(`${block}\n`);
1113
1114
  else
@@ -1245,6 +1246,7 @@ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
1245
1246
  workflowRunCmd.option(flags, description);
1246
1247
  }
1247
1248
  workflowRunCmd.option("--no-history", "Do not record this run or save its output.md in .sweny/runs/ (or set `history: off` in .sweny.yml)");
1249
+ 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
1250
  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
1251
  const workflowResumeCmd = workflowCmd
1250
1252
  .command("resume <run-id>")
@@ -1283,6 +1285,7 @@ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
1283
1285
  workflowResumeCmd.option(flags, description);
1284
1286
  }
1285
1287
  workflowResumeCmd.option("--no-history", "Do not update this run's record in .sweny/runs/");
1288
+ workflowResumeCmd.option("--show-output", "Print the final answer in CI too (off by default in CI)");
1286
1289
  registerRunsCommand(program);
1287
1290
  workflowCmd
1288
1291
  .command("diagram <file>")
@@ -24,9 +24,13 @@ export declare function formatValidationErrors(errors: string[]): string;
24
24
  export declare function extractCredentialHint(err: unknown): string | null;
25
25
  export declare function formatCrashError(error: unknown): string;
26
26
  export declare function formatCheckResults(results: CheckResult[]): string;
27
- export declare function formatResultJson(results: Map<string, NodeResult>): string;
27
+ /**
28
+ * Node results as JSON, redacted with the run journal's redactor (secret
29
+ * values, secret-named fields, known token shapes). Structure and keys stay.
30
+ */
31
+ export declare function formatResultJson(results: Map<string, NodeResult>, secrets?: string[]): string;
28
32
  /** Drain terminal JSON before process.exit(), including when stdout is a pipe. */
29
- export declare function writeResultJson(results: Map<string, NodeResult>): Promise<void>;
33
+ export declare function writeResultJson(results: Map<string, NodeResult>, secrets?: string[]): Promise<void>;
30
34
  /**
31
35
  * Format triage results as GitHub-flavored markdown for `$GITHUB_STEP_SUMMARY`.
32
36
  *
@@ -1,4 +1,5 @@
1
1
  import chalk from "chalk";
2
+ import { redact } from "../journal.js";
2
3
  import { toMermaidBlock } from "../mermaid.js";
3
4
  import { PALETTE, ROLE_COLORS } from "../theme.js";
4
5
  // ── Color palette (tokens from theme.ts; one color per meaning) ─
@@ -404,13 +405,17 @@ export function formatCheckResults(results) {
404
405
  ].join("\n");
405
406
  }
406
407
  // ── JSON output ─────────────────────────────────────────────────
407
- export function formatResultJson(results) {
408
- return JSON.stringify(Object.fromEntries(results), null, 2);
408
+ /**
409
+ * Node results as JSON, redacted with the run journal's redactor (secret
410
+ * values, secret-named fields, known token shapes). Structure and keys stay.
411
+ */
412
+ export function formatResultJson(results, secrets = []) {
413
+ return JSON.stringify(redact(Object.fromEntries(results), secrets).value, null, 2);
409
414
  }
410
415
  /** Drain terminal JSON before process.exit(), including when stdout is a pipe. */
411
- export function writeResultJson(results) {
416
+ export function writeResultJson(results, secrets = []) {
412
417
  return new Promise((resolve, reject) => {
413
- process.stdout.write(formatResultJson(results) + "\n", (error) => {
418
+ process.stdout.write(formatResultJson(results, secrets) + "\n", (error) => {
414
419
  if (error)
415
420
  reject(error);
416
421
  else
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import path from "node:path";
9
9
  import { validateRuntimeInput } from "../inputs.js";
10
- import { JOURNAL_DIR, JOURNAL_FILE, JournalLockedError, JournalVersionError, RunJournal, buildResumePlan, canonicalHash, findJournalRun, journalDir, mayRepeatWrites, readJournal, workflowHashOf, } from "../journal.js";
10
+ import { JOURNAL_DIR, JOURNAL_FILE, JournalKeyError, JournalLockedError, JournalVersionError, RunJournal, buildResumePlan, canonicalHash, checkJournalAgainstWorkflow, findJournalRun, journalDir, mayRepeatWrites, readJournal, workflowHashOf, } from "../journal.js";
11
11
  import { parseInputFlag } from "./workflow-input.js";
12
12
  /** Resume-only flags; the rest are shared with `workflow run`. */
13
13
  export const WORKFLOW_RESUME_OPTIONS = [
@@ -83,10 +83,14 @@ export function prepareResume(ref, opts, deps) {
83
83
  read = readJournal(file, { repair: !opts.plan });
84
84
  }
85
85
  catch (err) {
86
- if (err instanceof JournalVersionError)
86
+ if (err instanceof JournalVersionError || err instanceof JournalKeyError)
87
87
  return fail(err.message);
88
88
  return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
89
89
  }
90
+ if (read.forgedAtLine !== undefined) {
91
+ return fail(`run journal ${file} line ${read.forgedAtLine} fails authentication: it was edited or written by something ` +
92
+ `other than this run. Refusing to resume. Start a new run.`);
93
+ }
90
94
  if (read.corruptAtLine !== undefined) {
91
95
  return fail(`run journal ${file} is damaged at line ${read.corruptAtLine}, with valid records after it. ` +
92
96
  `That is not a torn last write, so it cannot be repaired safely. Start a new run.`);
@@ -122,6 +126,10 @@ export function prepareResume(ref, opts, deps) {
122
126
  }
123
127
  lines.push(`warning: --force: workflow ${workflowFile} changed since the run started`);
124
128
  }
129
+ // Replayed control flow must exist in the workflow (not overridable by --force).
130
+ const flaw = checkJournalAgainstWorkflow(read.records, workflow);
131
+ if (flaw)
132
+ return fail(`cannot resume run ${runId}: ${flaw}. Start a new run.`);
125
133
  let input;
126
134
  if (opts.input !== undefined) {
127
135
  const parsed = parseInputFlag(opts.input);
@@ -0,0 +1,10 @@
1
+ import type { Observer } from "../types.js";
2
+ /**
3
+ * Observer that writes NDJSON ExecutionEvents to stdout (`--stream`).
4
+ * Studio and other consumers parse these line-by-line.
5
+ *
6
+ * Every event goes through the run journal's redactor first (secret values,
7
+ * secret-named fields, known token shapes): node data, summaries, errors,
8
+ * tool inputs and outputs. Keys and structure are untouched.
9
+ */
10
+ export declare function createStreamObserver(secrets?: string[], write?: (line: string) => void): Observer;
@@ -0,0 +1,14 @@
1
+ import { redact } from "../journal.js";
2
+ /**
3
+ * Observer that writes NDJSON ExecutionEvents to stdout (`--stream`).
4
+ * Studio and other consumers parse these line-by-line.
5
+ *
6
+ * Every event goes through the run journal's redactor first (secret values,
7
+ * secret-named fields, known token shapes): node data, summaries, errors,
8
+ * tool inputs and outputs. Keys and structure are untouched.
9
+ */
10
+ export function createStreamObserver(secrets = [], write = (line) => void process.stdout.write(line)) {
11
+ return (event) => {
12
+ write(JSON.stringify(redact(event, secrets).value) + "\n");
13
+ };
14
+ }
package/dist/executor.js CHANGED
@@ -28,7 +28,7 @@ import "./skills/git-push.js";
28
28
  import { fenceUntrusted } from "./untrusted.js";
29
29
  import { asClaude } from "./harness/compat.js";
30
30
  import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
31
- import { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
31
+ import { BudgetGuard, describeOverrun, minLimits, spendOf, toLimits } from "./budget.js";
32
32
  import { createShadowDecider, finishShadowDecision, startShadowDecision } from "./decider.js";
33
33
  import { buildNodePolicy, resolveNodePermissions } from "./node-policy.js";
34
34
  import { applySafeOutputs, createEmitOutputTool, createWriteStageState, resolveActor, safeOutputsInstruction, unresolvedOutputs, SAFE_OUTPUT_SCREEN_INSTRUCTION, } from "./safe-outputs.js";
@@ -161,6 +161,11 @@ export async function execute(workflow, input, options) {
161
161
  writeState,
162
162
  harnessId: options.harness?.id,
163
163
  });
164
+ // Resume (#363): the run budget is the logical run's, so it starts from what
165
+ // earlier attempts already spent, not from zero.
166
+ const priorSpend = journal?.priorSpend?.();
167
+ if (priorSpend)
168
+ budgetGuard.seed(priorSpend);
164
169
  safeObserve(observer, { type: "workflow:start", workflow: workflow.id }, logger);
165
170
  safeObserve(observer, { type: "sources:resolved", sources: resolvedSources }, logger);
166
171
  let currentId = workflow.entry;
@@ -205,8 +210,14 @@ export async function execute(workflow, input, options) {
205
210
  else {
206
211
  if (replay.next !== null) {
207
212
  const key = `${from}→${replay.next}`;
208
- edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
209
- const reason = whenLabel(workflow.edges.find((e) => e.from === from && e.to === replay.next)?.when) ?? "only path";
213
+ const edge = workflow.edges.find((e) => e.from === from && e.to === replay.next);
214
+ const taken = (edgeCounts.get(key) ?? 0) + 1;
215
+ // A replayed route must be a real edge its max_iterations still allows.
216
+ if (!edge || (edge.max_iterations !== undefined && taken > edge.max_iterations)) {
217
+ throw new Error(`run journal routes ${from} -> ${replay.next}, which ${edge ? "exceeds the edge's max_iterations" : "is not an edge of this workflow"}; refusing to replay it`);
218
+ }
219
+ edgeCounts.set(key, taken);
220
+ const reason = whenLabel(edge.when) ?? "only path";
210
221
  trace.edges.push({ from, to: replay.next, reason });
211
222
  }
212
223
  safeObserve(observer, { type: "route", from, to: replay.next ?? "(end)", reason: "replayed" }, logger);
@@ -431,6 +442,23 @@ export async function execute(workflow, input, options) {
431
442
  // Only the final attempt's intents may be applied.
432
443
  intents.length = 0;
433
444
  const attemptBudget = budgetOn ? nodeBudget.attempt(signal) : undefined;
445
+ // Resume (#363): spend is journaled as it is reported, so a crash mid-node
446
+ // does not hand the resumed run a fresh budget.
447
+ const usageNode = currentId;
448
+ const usageAttempt = attempt;
449
+ let liveUsage;
450
+ const journalUsage = journal?.usage
451
+ ? (u) => {
452
+ liveUsage = { ...liveUsage, ...u };
453
+ journal.usage(usageNode, iteration, usageAttempt, liveUsage, false);
454
+ }
455
+ : undefined;
456
+ const onUsage = attemptBudget || journalUsage
457
+ ? (u) => {
458
+ attemptBudget?.onUsage(u);
459
+ journalUsage?.(u);
460
+ }
461
+ : undefined;
434
462
  try {
435
463
  result = await claude.run({
436
464
  nodeId: currentId,
@@ -448,7 +476,7 @@ export async function execute(workflow, input, options) {
448
476
  ...(nodePolicy ? { policy: nodePolicy } : {}),
449
477
  ...(readOnlyNode ? {} : { mcpServers: skillMcpServers }),
450
478
  ...(readOnlyNode ? { readOnly: true } : {}),
451
- ...(attemptBudget ? { onUsage: attemptBudget.onUsage } : {}),
479
+ ...(onUsage ? { onUsage } : {}),
452
480
  onProgress: (message) => {
453
481
  safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
454
482
  },
@@ -458,6 +486,16 @@ export async function execute(workflow, input, options) {
458
486
  attemptBudget?.dispose();
459
487
  }
460
488
  previous = result;
489
+ // The attempt's final spend: the larger of the result's usage and the last live report, per unit.
490
+ if (journal?.usage) {
491
+ const finalSpend = spendOf(result.usage);
492
+ const seen = spendOf(liveUsage);
493
+ const tokens = Math.max(finalSpend.tokens ?? 0, seen.tokens ?? 0);
494
+ const costUsd = Math.max(finalSpend.costUsd ?? 0, seen.costUsd ?? 0);
495
+ if (finalSpend.tokens !== undefined || finalSpend.costUsd !== undefined || liveUsage) {
496
+ journal.usage(usageNode, iteration, usageAttempt, { inputTokens: tokens, outputTokens: 0, costUsd }, true);
497
+ }
498
+ }
461
499
  if (budgetCheck && budgetCheck.degraded.length > 0) {
462
500
  result = { ...result, degraded: [...new Set([...(result.degraded ?? []), ...budgetCheck.degraded])] };
463
501
  }
@@ -48,6 +48,7 @@ import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises
48
48
  import { homedir, tmpdir } from "node:os";
49
49
  import * as path from "node:path";
50
50
  import { DEFAULT_SANDBOX_DOMAINS, parseList, resolveSandboxMode } from "../agent-env.js";
51
+ import { JOURNAL_DIR, runKeyDir } from "../journal.js";
51
52
  import { policyGate } from "./policy.js";
52
53
  // ─── srt settings ────────────────────────────────────────────────
53
54
  /**
@@ -131,10 +132,17 @@ export function buildSrtSettings(req, opts) {
131
132
  // cannot rename one out from under its read deny.
132
133
  const masked = [...new Set((req.denyRead ?? []).map(real))].filter((p) => exists(p));
133
134
  denyRead.push(...masked.filter((p) => !denyRead.includes(p)));
135
+ // Run journal keys (outside the workspace) are never readable by the agent,
136
+ // and the journals themselves are not writable.
137
+ const keyDir = runKeyDir();
138
+ if (exists(keyDir) && !denyRead.includes(keyDir))
139
+ denyRead.push(keyDir);
140
+ const journals = path.join(real(req.cwd), JOURNAL_DIR);
141
+ const denyWrite = !req.readOnly && exists(journals) ? [...masked, journals] : masked;
134
142
  const allowWrite = req.readOnly ? [opts.home] : [real(req.cwd), opts.home];
135
143
  return {
136
144
  network: { allowedDomains, deniedDomains: [], strictAllowlist: true, allowLocalBinding: false },
137
- filesystem: { denyRead, allowRead: opts.isolationRoot ? [opts.home] : [], allowWrite, denyWrite: masked },
145
+ filesystem: { denyRead, allowRead: opts.isolationRoot ? [opts.home] : [], allowWrite, denyWrite },
138
146
  };
139
147
  }
140
148
  /** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */