@sweny-ai/core 0.1.158 → 0.1.160

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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { ExecutionTrace, NodeResult, Workflow } from "../types.js";
10
10
  import { type RunSummary } from "./run-output.js";
11
- export declare const RUN_COMMENT_FOOTER = "Run with [SWEny](https://sweny.ai): `npx @sweny-ai/core new`";
11
+ export declare const RUN_COMMENT_FOOTER = "Run with [SWEny](https://github.com/swenyai/sweny): `npx @sweny-ai/core new`";
12
12
  export interface RunCommentOptions {
13
13
  trace?: ExecutionTrace;
14
14
  /** Per-node wall-clock duration in ms, keyed by node id. Missing entries render as "-". */
@@ -10,7 +10,7 @@ import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import { toMermaidBlock } from "../mermaid.js";
12
12
  import { STEP_SUMMARY_CLASS_DEFS, formatReceipt, formatReceiptDuration, nodeStates, } from "./run-output.js";
13
- export const RUN_COMMENT_FOOTER = "Run with [SWEny](https://sweny.ai): `npx @sweny-ai/core new`";
13
+ export const RUN_COMMENT_FOOTER = "Run with [SWEny](https://github.com/swenyai/sweny): `npx @sweny-ai/core new`";
14
14
  /** Hidden marker the poster searches for to update the comment in place. */
15
15
  export function runCommentMarker(workflowId) {
16
16
  const safe = workflowId.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/-{2,}/g, "-") || "workflow";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The answer at the end of `sweny workflow run`: the terminal node's result
3
+ * (or the failed node's error), rendered for a human.
4
+ *
5
+ * Unlike run-history and the receipt, this DOES read node output. It is
6
+ * local-only: printed to the terminal and saved to
7
+ * `.sweny/runs/<run-id>/output.md`. Nothing here is sent to the cloud.
8
+ */
9
+ import type { JSONSchema, NodeResult, Workflow } from "../types.js";
10
+ /** Lines of the answer printed to the terminal before the rest is left in output.md. */
11
+ export declare const FINAL_OUTPUT_MAX_LINES = 60;
12
+ export interface FinalOutput {
13
+ kind: "result" | "error";
14
+ /** Node id the text came from. */
15
+ node: string;
16
+ /** Rendered lines, uncapped. */
17
+ lines: string[];
18
+ }
19
+ /** Render a node's declared output fields as a compact `key: value` block. Empty when nothing declared is present. */
20
+ export declare function renderSchemaOutput(data: Record<string, unknown>, schema: JSONSchema): string[];
21
+ /**
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
24
+ * nothing to show (no schema fields and no summary text).
25
+ */
26
+ export declare function resolveFinalOutput(workflow: Workflow, results: Map<string, NodeResult>): FinalOutput | null;
27
+ /**
28
+ * Terminal text: the first `maxLines` lines, then
29
+ * `... (N more lines, see .sweny/runs/<id>/output.md)`. When no file was
30
+ * saved the pointer is dropped, never invented.
31
+ */
32
+ export declare function formatFinalOutput(out: FinalOutput, opts?: {
33
+ maxLines?: number;
34
+ outputPath?: string | null;
35
+ }): string;
36
+ /** Markdown saved to output.md: the full, uncapped answer. */
37
+ export declare function formatFinalMarkdown(workflow: Workflow, out: FinalOutput): string;
38
+ /** Relative path shown to the user: `.sweny/runs/<id>/output.md`. */
39
+ export declare function outputRelPath(runId: string): string;
40
+ /** Write output.md. Local only. Returns the relative path, or null on failure. Never throws. */
41
+ export declare function writeFinalOutput(runId: string, markdown: string, cwd?: string): string | null;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The answer at the end of `sweny workflow run`: the terminal node's result
3
+ * (or the failed node's error), rendered for a human.
4
+ *
5
+ * Unlike run-history and the receipt, this DOES read node output. It is
6
+ * local-only: printed to the terminal and saved to
7
+ * `.sweny/runs/<run-id>/output.md`. Nothing here is sent to the cloud.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { RUN_HISTORY_DIR } from "./run-history.js";
12
+ /** Lines of the answer printed to the terminal before the rest is left in output.md. */
13
+ export const FINAL_OUTPUT_MAX_LINES = 60;
14
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
15
+ function isEmpty(v) {
16
+ return (v === undefined ||
17
+ v === null ||
18
+ (typeof v === "string" && v.trim() === "") ||
19
+ (Array.isArray(v) && v.length === 0) ||
20
+ (isObj(v) && Object.keys(v).length === 0));
21
+ }
22
+ function scalar(v) {
23
+ return typeof v === "string" ? v : JSON.stringify(v);
24
+ }
25
+ function indentLines(lines, pad) {
26
+ return lines.map((l) => (l === "" ? l : pad + l));
27
+ }
28
+ /** One value as lines under a label: `key: value`, or `key:` plus an indented block. */
29
+ function renderField(label, value, schema) {
30
+ if (Array.isArray(value)) {
31
+ const itemSchema = isObj(schema?.items) ? schema.items : undefined;
32
+ const out = [`${label}:`];
33
+ for (const item of value)
34
+ out.push(...renderListItem(item, itemSchema));
35
+ return out;
36
+ }
37
+ if (isObj(value)) {
38
+ return [`${label}:`, ...indentLines(renderObject(value, schema), " ")];
39
+ }
40
+ const text = scalar(value);
41
+ if (!text.includes("\n"))
42
+ return [`${label}: ${text}`];
43
+ return [`${label}:`, ...indentLines(text.split("\n"), " ")];
44
+ }
45
+ function renderListItem(item, schema) {
46
+ if (isObj(item)) {
47
+ const inner = renderObject(item, schema);
48
+ if (inner.length === 0)
49
+ return [];
50
+ return [`- ${inner[0]}`, ...indentLines(inner.slice(1), " ")];
51
+ }
52
+ if (Array.isArray(item)) {
53
+ return [`- ${item.map(scalar).join(", ")}`];
54
+ }
55
+ const text = scalar(item);
56
+ const [first, ...rest] = text.split("\n");
57
+ return [`- ${first}`, ...indentLines(rest, " ")];
58
+ }
59
+ /** Declared fields first (schema order), then none of the extras: the schema is the contract. */
60
+ function renderObject(data, schema) {
61
+ const props = isObj(schema?.properties) ? schema.properties : undefined;
62
+ const keys = props ? Object.keys(props) : Object.keys(data);
63
+ const out = [];
64
+ for (const key of keys) {
65
+ const value = data[key];
66
+ if (isEmpty(value))
67
+ continue;
68
+ const propSchema = props && isObj(props[key]) ? props[key] : undefined;
69
+ const title = typeof propSchema?.title === "string" && propSchema.title.trim() ? propSchema.title.trim() : key;
70
+ out.push(...renderField(title, value, propSchema));
71
+ }
72
+ return out;
73
+ }
74
+ /** Render a node's declared output fields as a compact `key: value` block. Empty when nothing declared is present. */
75
+ export function renderSchemaOutput(data, schema) {
76
+ const props = schema.properties;
77
+ if (!isObj(props) || Object.keys(props).length === 0)
78
+ return [];
79
+ return renderObject(data, schema);
80
+ }
81
+ function trimBlock(text) {
82
+ const lines = text.replace(/\r\n/g, "\n").trim().split("\n");
83
+ return lines.map((l) => l.replace(/\s+$/, ""));
84
+ }
85
+ /**
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
88
+ * nothing to show (no schema fields and no summary text).
89
+ */
90
+ export function resolveFinalOutput(workflow, results) {
91
+ for (const [id, r] of results) {
92
+ if (r.status !== "failed")
93
+ continue;
94
+ const err = r.data?.error;
95
+ const text = typeof err === "string" && err.trim() !== "" ? err : "node failed";
96
+ return { kind: "error", node: id, lines: trimBlock(text) };
97
+ }
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();
103
+ for (const [id, r] of candidates) {
104
+ const schema = workflow.nodes[id]?.output;
105
+ let lines = [];
106
+ if (schema)
107
+ lines = renderSchemaOutput(r.data ?? {}, schema);
108
+ if (lines.length === 0) {
109
+ const summary = r.data?.summary;
110
+ if (typeof summary === "string" && summary.trim() !== "")
111
+ lines = trimBlock(summary);
112
+ }
113
+ if (lines.length > 0)
114
+ return { kind: "result", node: id, lines };
115
+ }
116
+ return null;
117
+ }
118
+ /**
119
+ * Terminal text: the first `maxLines` lines, then
120
+ * `... (N more lines, see .sweny/runs/<id>/output.md)`. When no file was
121
+ * saved the pointer is dropped, never invented.
122
+ */
123
+ export function formatFinalOutput(out, opts = {}) {
124
+ const max = opts.maxLines ?? FINAL_OUTPUT_MAX_LINES;
125
+ const shown = out.lines.slice(0, max);
126
+ const hidden = out.lines.length - shown.length;
127
+ if (hidden > 0) {
128
+ const noun = hidden === 1 ? "line" : "lines";
129
+ shown.push(opts.outputPath ? `... (${hidden} more ${noun}, see ${opts.outputPath})` : `... (${hidden} more ${noun})`);
130
+ }
131
+ return shown.map((l) => (l === "" ? "" : ` ${l}`)).join("\n");
132
+ }
133
+ /** Markdown saved to output.md: the full, uncapped answer. */
134
+ export function formatFinalMarkdown(workflow, out) {
135
+ const nodeName = workflow.nodes[out.node]?.name ?? out.node;
136
+ const head = out.kind === "error" ? `# ${workflow.name}: ${nodeName} failed` : `# ${workflow.name}`;
137
+ return `${head}\n\n${out.lines.join("\n")}\n`;
138
+ }
139
+ /** Relative path shown to the user: `.sweny/runs/<id>/output.md`. */
140
+ export function outputRelPath(runId) {
141
+ return path.posix.join(...RUN_HISTORY_DIR.split(path.sep), runId, "output.md");
142
+ }
143
+ /** Write output.md. Local only. Returns the relative path, or null on failure. Never throws. */
144
+ export function writeFinalOutput(runId, markdown, cwd = process.cwd()) {
145
+ try {
146
+ const rel = outputRelPath(runId);
147
+ const file = path.join(cwd, rel);
148
+ fs.mkdirSync(path.dirname(file), { recursive: true });
149
+ fs.writeFileSync(file, markdown);
150
+ return rel;
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
package/dist/cli/main.js CHANGED
@@ -30,6 +30,7 @@ import * as readline from "node:readline";
30
30
  import { loadDotenv, loadConfigFile, applyAgentFileConfig } from "./config-file.js";
31
31
  import { buildCredentialMap } from "./credentials.js";
32
32
  import { nonInteractiveUsage, runNew } from "./new.js";
33
+ import { formatFinalMarkdown, formatFinalOutput, resolveFinalOutput, writeFinalOutput } from "./final-output.js";
33
34
  import { buildRunRecord, createNodeTimer, historyDisabled, newRunId, recordRun } from "./run-history.js";
34
35
  import { registerRunsCommand } from "./runs.js";
35
36
  import { runE2eRun, runWithWallClockBudget, DEFAULT_WORKFLOW_TIMEOUT_MS } from "./e2e.js";
@@ -82,7 +83,7 @@ const program = new Command()
82
83
  // ── sweny new ─────────────────────────────────────────────────────────
83
84
  program
84
85
  .command("new [id]")
85
- .description("Create a new workflow. With no id, opens the interactive picker. With an id, uses that built-in template, or installs it from the marketplace (swenyai/workflows).")
86
+ .description("Create a new workflow. With no id, opens the interactive picker. With an id, uses that built-in template.")
86
87
  .option("--template <id>", "Use a built-in template without the picker")
87
88
  .option("-y, --yes", "Skip every prompt (never overwrites existing files)")
88
89
  .action(async (id, options) => {
@@ -1049,6 +1050,19 @@ export async function workflowRunAction(file, options) {
1049
1050
  runLogger.flush();
1050
1051
  const receipt = summarizeRun(results, wfDurationMs);
1051
1052
  writeStepSummary(workflow, results, receipt, trace);
1053
+ // The answer, above the receipt: the terminal node's result, or the failed node's error.
1054
+ // Also saved to .sweny/runs/<id>/output.md (local only; --no-history skips the file).
1055
+ const finalOutput = resolveFinalOutput(workflow, results);
1056
+ if (finalOutput) {
1057
+ const savedTo = historyDisabled(options.history, fileConfig["history"])
1058
+ ? null
1059
+ : writeFinalOutput(runId, formatFinalMarkdown(workflow, finalOutput));
1060
+ const block = formatFinalOutput(finalOutput, { outputPath: savedTo });
1061
+ if (wfHasFailed)
1062
+ console.error(`${block}\n`);
1063
+ else
1064
+ console.log(`${block}\n`);
1065
+ }
1052
1066
  if (wfHasFailed) {
1053
1067
  console.error(` ${renderReceiptLine(receipt, isTTY)}\n`);
1054
1068
  process.exit(1);
@@ -1142,7 +1156,7 @@ const workflowRunCmd = workflowCmd
1142
1156
  for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
1143
1157
  workflowRunCmd.option(flags, description);
1144
1158
  }
1145
- workflowRunCmd.option("--no-history", "Do not record this run in .sweny/runs/ (or set `history: off` in .sweny.yml)");
1159
+ workflowRunCmd.option("--no-history", "Do not record this run or save its output.md in .sweny/runs/ (or set `history: off` in .sweny.yml)");
1146
1160
  registerRunsCommand(program);
1147
1161
  workflowCmd
1148
1162
  .command("diagram <file>")
@@ -27,7 +27,7 @@ export async function fetchMarketplaceWorkflow(id) {
27
27
  throw err;
28
28
  }
29
29
  if (res.status === 404) {
30
- const err = new Error(`Workflow "${id}" not found in ${MARKETPLACE_REPO}. See https://marketplace.sweny.ai for available workflows.`);
30
+ const err = new Error(`Workflow "${id}" not found in ${MARKETPLACE_REPO}.`);
31
31
  err.kind = "not-found";
32
32
  throw err;
33
33
  }
package/dist/cli/new.js CHANGED
@@ -625,12 +625,9 @@ export async function runNew(options) {
625
625
  const entries = await fetchMarketplaceIndex({ timeoutMs: 3000 });
626
626
  if (Array.isArray(entries) && entries.length > 0)
627
627
  marketplaceEntries = entries;
628
- else
629
- p.log.info("Marketplace has no workflows yet. Showing built-in templates.");
630
628
  }
631
- catch (err) {
632
- const reason = err instanceof Error ? err.message : String(err);
633
- p.log.info(`Marketplace unavailable (${reason}). Showing built-in templates.`);
629
+ catch {
630
+ // No reachable index: built-in templates only, and nothing about a marketplace is shown.
634
631
  }
635
632
  templateChoice = await p.select({
636
633
  message: "What do you want to do?",
@@ -153,8 +153,11 @@ export function pruneRuns(cwd = process.cwd(), keep = RUN_HISTORY_KEEP) {
153
153
  .filter((f) => f.endsWith(".json") && RUN_ID_RE.test(f.slice(0, -5)))
154
154
  .sort();
155
155
  const extra = files.slice(0, Math.max(0, files.length - keep));
156
- for (const f of extra)
156
+ for (const f of extra) {
157
157
  fs.rmSync(path.join(dir, f), { force: true });
158
+ // output.md lives beside the record in <run-id>/ (final-output.ts)
159
+ fs.rmSync(path.join(dir, f.slice(0, -5)), { recursive: true, force: true });
160
+ }
158
161
  return extra.length;
159
162
  }
160
163
  catch {
@@ -25,6 +25,12 @@ export interface RunSummary {
25
25
  * (`max_turns`, `egress allowlist`, `deny [write]`). Absent when none.
26
26
  */
27
27
  degraded?: string[];
28
+ /**
29
+ * What the run's opinions did, from facts the nodes reported: env scope and
30
+ * sandbox (Claude Code only) and safe-output counts. Absent when the run
31
+ * knows none of it.
32
+ */
33
+ policy?: RunPolicy;
28
34
  /** The spend budget a node crossed (#449). Absent when none was. */
29
35
  budget?: {
30
36
  node: string;
@@ -34,10 +40,29 @@ export interface RunSummary {
34
40
  spent: number;
35
41
  };
36
42
  }
43
+ export interface RunPolicy {
44
+ /** Agent env narrowed to the allowlist. Absent when no node reported it. */
45
+ envScope?: boolean;
46
+ /** Sandbox facts. `started` is false when the mode asked for one but the host could not. */
47
+ sandbox?: {
48
+ mode: "off" | "auto" | "strict";
49
+ started: boolean;
50
+ };
51
+ /** Safe outputs staged (previewed) and applied (written), summed over nodes. */
52
+ outputs?: {
53
+ staged: number;
54
+ applied: number;
55
+ };
56
+ }
37
57
  /** The short key of a `degraded` entry: the text before its first colon. */
38
58
  export declare function degradedKey(entry: string): string;
39
59
  /** Sum the per-node results into one run summary. */
40
60
  export declare function summarizeRun(results: Map<string, NodeResult>, durationMs: number, crashed?: boolean): RunSummary;
61
+ /**
62
+ * `policy: env scoped, sandbox on, 1 output staged`. Only what the run knew:
63
+ * no segment for a fact no node reported. Undefined when there is nothing to say.
64
+ */
65
+ export declare function formatPolicySegment(p: RunPolicy | undefined): string | undefined;
41
66
  export declare function formatReceiptDuration(ms: number): string;
42
67
  export declare function formatTokenCount(n: number): string;
43
68
  export declare function formatCost(usd: number): string;
@@ -25,6 +25,9 @@ export function summarizeRun(results, durationMs, crashed = false) {
25
25
  let harness;
26
26
  const degraded = new Set();
27
27
  let budget;
28
+ const policy = {};
29
+ let staged = 0;
30
+ let applied = 0;
28
31
  const isNum = (v) => typeof v === "number" && Number.isFinite(v);
29
32
  for (const [nodeId, r] of results) {
30
33
  if (r.budget && !budget)
@@ -32,6 +35,20 @@ export function summarizeRun(results, durationMs, crashed = false) {
32
35
  harness ??= r.harness?.id;
33
36
  for (const d of r.degraded ?? [])
34
37
  degraded.add(degradedKey(d));
38
+ if (r.policy) {
39
+ // Any node that ran unscoped or unsandboxed counts: the receipt must not overstate.
40
+ policy.envScope = policy.envScope === undefined ? r.policy.envScope : policy.envScope && r.policy.envScope;
41
+ const sb = { mode: r.policy.sandbox, started: r.policy.sandboxStarted };
42
+ // Keep the weakest node: the first one that did not run sandboxed.
43
+ if (!policy.sandbox || (policy.sandbox.started && !sb.started))
44
+ policy.sandbox = sb;
45
+ }
46
+ for (const o of r.outputs ?? []) {
47
+ if (o.status === "staged")
48
+ staged++;
49
+ else if (o.status === "applied")
50
+ applied++;
51
+ }
35
52
  if (r.status === "success")
36
53
  nodesOk++;
37
54
  else if (r.status === "skipped")
@@ -49,6 +66,8 @@ export function summarizeRun(results, durationMs, crashed = false) {
49
66
  costUsd = (costUsd ?? 0) + u.costUsd;
50
67
  }
51
68
  }
69
+ if (staged > 0 || applied > 0)
70
+ policy.outputs = { staged, applied };
52
71
  return {
53
72
  ok: !failed,
54
73
  nodesOk,
@@ -60,9 +79,36 @@ export function summarizeRun(results, durationMs, crashed = false) {
60
79
  ...(costUsd !== undefined ? { costUsd } : {}),
61
80
  ...(harness !== undefined ? { harness } : {}),
62
81
  ...(degraded.size > 0 ? { degraded: [...degraded] } : {}),
82
+ ...(Object.keys(policy).length > 0 ? { policy } : {}),
63
83
  ...(budget ? { budget } : {}),
64
84
  };
65
85
  }
86
+ /**
87
+ * `policy: env scoped, sandbox on, 1 output staged`. Only what the run knew:
88
+ * no segment for a fact no node reported. Undefined when there is nothing to say.
89
+ */
90
+ export function formatPolicySegment(p) {
91
+ if (!p)
92
+ return undefined;
93
+ const parts = [];
94
+ if (p.envScope !== undefined)
95
+ parts.push(p.envScope ? "env scoped" : "env unscoped");
96
+ if (p.sandbox) {
97
+ parts.push(p.sandbox.mode === "off"
98
+ ? "sandbox off"
99
+ : p.sandbox.started
100
+ ? "sandbox on"
101
+ : "sandbox unavailable (ran unsandboxed)");
102
+ }
103
+ if (p.outputs) {
104
+ const n = (count) => `${count} ${count === 1 ? "output" : "outputs"}`;
105
+ if (p.outputs.staged > 0)
106
+ parts.push(`${n(p.outputs.staged)} staged`);
107
+ if (p.outputs.applied > 0)
108
+ parts.push(`${n(p.outputs.applied)} applied`);
109
+ }
110
+ return parts.length > 0 ? `policy: ${parts.join(", ")}` : undefined;
111
+ }
66
112
  export function formatReceiptDuration(ms) {
67
113
  if (ms < 1000)
68
114
  return `${Math.max(0, Math.round(ms))}ms`;
@@ -108,6 +154,7 @@ export function formatReceipt(s) {
108
154
  ...(s.costUsd !== undefined ? [formatCost(s.costUsd)] : []),
109
155
  ...(s.harness !== undefined && s.harness !== "claude-code" ? [s.harness] : []),
110
156
  ...(s.degraded && s.degraded.length > 0 ? [`degraded: ${s.degraded.join(", ")}`] : []),
157
+ ...(formatPolicySegment(s.policy) ? [formatPolicySegment(s.policy)] : []),
111
158
  ...(s.budget ? [formatBudgetOverrun(s.budget)] : []),
112
159
  ];
113
160
  return parts.join(" · ");
@@ -31,7 +31,33 @@ nodes:
31
31
  From the survey, write a short plain-English explanation:
32
32
  what this project does, how it is organized, how to build and
33
33
  run it, and where a new contributor should start.
34
- Keep it under 300 words.
34
+ Keep every field short and concrete: a sentence or a few
35
+ bullets, in plain English, naming real files and commands.
36
+ output:
37
+ type: object
38
+ properties:
39
+ purpose:
40
+ type: string
41
+ title: What it is
42
+ description: One or two sentences on what this project does and who it is for.
43
+ how_to_run:
44
+ type: array
45
+ title: How to run it
46
+ description: The commands, in order, to install, build, test, and start it.
47
+ items:
48
+ type: string
49
+ key_files:
50
+ type: array
51
+ title: Key files
52
+ description: The files and directories a new contributor should open first, each with a few words on why.
53
+ items:
54
+ type: string
55
+ risks:
56
+ type: array
57
+ title: Watch out for
58
+ description: Sharp edges, missing docs, or things that look fragile. Empty if none.
59
+ items:
60
+ type: string
35
61
 
36
62
  edges:
37
63
  - from: survey
@@ -150,6 +150,8 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
150
150
  private sandboxProbe;
151
151
  private sandboxWarned;
152
152
  private envScope;
153
+ /** Floor facts from the latest runQuery, read by run() for the receipt. */
154
+ private lastPolicyFacts;
153
155
  private toolBridge;
154
156
  private toolBridgeShim;
155
157
  private authProbe;
@@ -217,6 +217,8 @@ export class ClaudeCodeHarness {
217
217
  sandboxProbe;
218
218
  sandboxWarned = false;
219
219
  envScope;
220
+ /** Floor facts from the latest runQuery, read by run() for the receipt. */
221
+ lastPolicyFacts;
220
222
  toolBridge;
221
223
  toolBridgeShim;
222
224
  authProbe;
@@ -312,7 +314,12 @@ export class ClaudeCodeHarness {
312
314
  sandboxMode: policy.sandbox,
313
315
  agentAccess: { envVars: req.agentAccess?.envVars ?? [], domains: policy.egress, noPush: req.agentAccess?.noPush },
314
316
  });
315
- return { ...result, harness: this.info(), degraded: gate.degraded };
317
+ return {
318
+ ...result,
319
+ harness: this.info(),
320
+ degraded: gate.degraded,
321
+ ...(this.lastPolicyFacts ? { policy: this.lastPolicyFacts } : {}),
322
+ };
316
323
  }
317
324
  async runQuery(opts) {
318
325
  const { instruction, context, tools, outputSchema, onProgress, maxTurns, model, timeoutMs, signal, readOnly, agentAccess, onUsage, } = opts;
@@ -334,6 +341,11 @@ export class ClaudeCodeHarness {
334
341
  probe: this.sandboxProbe,
335
342
  logger: this.logger,
336
343
  });
344
+ this.lastPolicyFacts = {
345
+ envScope: resolveEnvScope(process.env, this.envScope),
346
+ sandbox: sandbox.mode,
347
+ sandboxStarted: sandbox.settings !== undefined,
348
+ };
337
349
  if (sandbox.error) {
338
350
  this.logger.error(sandbox.error);
339
351
  return { status: "failed", data: { error: sandbox.error, refused: true }, toolCalls: [] };
package/dist/types.d.ts CHANGED
@@ -622,6 +622,11 @@ export interface NodeResult {
622
622
  };
623
623
  /** Opinions this run could not honor natively. Always empty for Claude Code. */
624
624
  degraded?: string[];
625
+ /**
626
+ * Agent floor this node actually ran under, for the run receipt. Set by the
627
+ * Claude Code harness only; absent for harnesses that do not report it.
628
+ */
629
+ policy?: NodePolicyFacts;
625
630
  /** Safe outputs (#365): what the write stage did with each intent. Absent when the node declares none. */
626
631
  outputs?: SafeOutputReceipt[];
627
632
  /**
@@ -631,6 +636,15 @@ export interface NodeResult {
631
636
  */
632
637
  budget?: BudgetOverrun;
633
638
  }
639
+ /** Facts about the agent floor (scoped env, process sandbox) a node ran under. Booleans and a mode only. */
640
+ export interface NodePolicyFacts {
641
+ /** The agent subprocess env was narrowed to the allowlist. */
642
+ envScope: boolean;
643
+ /** Requested sandbox mode. */
644
+ sandbox: "off" | "auto" | "strict";
645
+ /** The sandbox was enabled for this node (false under `off`, or `auto` on a host that cannot sandbox). */
646
+ sandboxStarted: boolean;
647
+ }
634
648
  export interface ToolCall {
635
649
  tool: string;
636
650
  input: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.158",
3
+ "version": "0.1.160",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"