@sweny-ai/core 0.1.159 → 0.1.161

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.
@@ -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
+ }
@@ -17,3 +17,6 @@ export declare function workflowExportAction(name: string): void;
17
17
  export declare function workflowValidateAction(file: string, options: {
18
18
  json?: boolean;
19
19
  }): void;
20
+ export declare function workflowUpgradeAction(file: string, options: {
21
+ dryRun?: boolean;
22
+ }): void;
package/dist/cli/main.js CHANGED
@@ -19,6 +19,7 @@ import { configuredSkills, configuredSkillsWithDiagnostics } from "../skills/cus
19
19
  import { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from "../mcp.js";
20
20
  import { loadAdditionalContext } from "../templates.js";
21
21
  import { loadAndValidateWorkflow } from "../loader.js";
22
+ import { upgradeWorkflowFile } from "./workflow-upgrade.js";
22
23
  import { validateRuntimeInput } from "../inputs.js";
23
24
  import { mergeDryRunIntoInput, parseInputFlag, parseRunBudgetFlags, parseSpendFlags } from "./workflow-input.js";
24
25
  import { stringify as stringifyYaml } from "yaml";
@@ -30,6 +31,7 @@ import * as readline from "node:readline";
30
31
  import { loadDotenv, loadConfigFile, applyAgentFileConfig } from "./config-file.js";
31
32
  import { buildCredentialMap } from "./credentials.js";
32
33
  import { nonInteractiveUsage, runNew } from "./new.js";
34
+ import { formatFinalMarkdown, formatFinalOutput, resolveFinalOutput, writeFinalOutput } from "./final-output.js";
33
35
  import { buildRunRecord, createNodeTimer, historyDisabled, newRunId, recordRun } from "./run-history.js";
34
36
  import { registerRunsCommand } from "./runs.js";
35
37
  import { runE2eRun, runWithWallClockBudget, DEFAULT_WORKFLOW_TIMEOUT_MS } from "./e2e.js";
@@ -724,6 +726,8 @@ export function loadWorkflowFile(filePath, knownSkills) {
724
726
  if (!result.ok) {
725
727
  throw new Error(`Invalid workflow file:\n${result.errors.map((e) => ` ${e.message}`).join("\n")}`);
726
728
  }
729
+ for (const w of result.warnings ?? [])
730
+ console.error(chalk.yellow(` \u26A0 ${w}`));
727
731
  return result.workflow;
728
732
  }
729
733
  export async function workflowRunAction(file, options) {
@@ -1049,6 +1053,19 @@ export async function workflowRunAction(file, options) {
1049
1053
  runLogger.flush();
1050
1054
  const receipt = summarizeRun(results, wfDurationMs);
1051
1055
  writeStepSummary(workflow, results, receipt, trace);
1056
+ // The answer, above the receipt: the terminal node's result, or the failed node's error.
1057
+ // Also saved to .sweny/runs/<id>/output.md (local only; --no-history skips the file).
1058
+ const finalOutput = resolveFinalOutput(workflow, results);
1059
+ if (finalOutput) {
1060
+ const savedTo = historyDisabled(options.history, fileConfig["history"])
1061
+ ? null
1062
+ : writeFinalOutput(runId, formatFinalMarkdown(workflow, finalOutput));
1063
+ const block = formatFinalOutput(finalOutput, { outputPath: savedTo });
1064
+ if (wfHasFailed)
1065
+ console.error(`${block}\n`);
1066
+ else
1067
+ console.log(`${block}\n`);
1068
+ }
1052
1069
  if (wfHasFailed) {
1053
1070
  console.error(` ${renderReceiptLine(receipt, isTTY)}\n`);
1054
1071
  process.exit(1);
@@ -1108,7 +1125,10 @@ export function workflowValidateAction(file, options) {
1108
1125
  const result = loadAndValidateWorkflow(file);
1109
1126
  // Missing skill env is a warning here, not a failure: `run` is where it blocks.
1110
1127
  const warnings = result.ok
1111
- ? skillEnvWarnings(result.workflow, process.env, builtinSkills.concat(configuredSkills(process.env, process.cwd())))
1128
+ ? [
1129
+ ...(result.warnings ?? []),
1130
+ ...skillEnvWarnings(result.workflow, process.env, builtinSkills.concat(configuredSkills(process.env, process.cwd()))),
1131
+ ]
1112
1132
  : [];
1113
1133
  if (options.json) {
1114
1134
  const errs = result.ok ? [] : result.errors;
@@ -1135,6 +1155,29 @@ workflowCmd
1135
1155
  .description("Validate a workflow YAML or JSON file")
1136
1156
  .option("--json", "Output result as JSON")
1137
1157
  .action(workflowValidateAction);
1158
+ export function workflowUpgradeAction(file, options) {
1159
+ const result = upgradeWorkflowFile(file, { dryRun: options.dryRun });
1160
+ if (result.status === "error") {
1161
+ console.error(chalk.red(` \u2717 ${result.message}`));
1162
+ process.exit(1);
1163
+ return;
1164
+ }
1165
+ if (result.status === "current") {
1166
+ console.log(chalk.green(` \u2713 ${file} is already at spec_version "${result.version}"`));
1167
+ return;
1168
+ }
1169
+ const verb = result.status === "dry-run" ? "Would upgrade" : "Upgraded";
1170
+ console.log(chalk.green(` \u2713 ${verb} ${file}: spec_version "${result.from}" -> "${result.to}"`));
1171
+ for (const step of result.steps)
1172
+ console.log(chalk.dim(` - ${step}`));
1173
+ if (result.status === "dry-run")
1174
+ console.log(chalk.dim(" (dry run, file not changed)"));
1175
+ }
1176
+ workflowCmd
1177
+ .command("upgrade <file>")
1178
+ .description("Rewrite a workflow file at the current spec version (comments are kept where possible)")
1179
+ .option("--dry-run", "Show what would change without writing the file")
1180
+ .action(workflowUpgradeAction);
1138
1181
  const workflowRunCmd = workflowCmd
1139
1182
  .command("run [file]")
1140
1183
  .description(WORKFLOW_RUN_DESCRIPTION)
@@ -1142,7 +1185,7 @@ const workflowRunCmd = workflowCmd
1142
1185
  for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
1143
1186
  workflowRunCmd.option(flags, description);
1144
1187
  }
1145
- workflowRunCmd.option("--no-history", "Do not record this run in .sweny/runs/ (or set `history: off` in .sweny.yml)");
1188
+ 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
1189
  registerRunsCommand(program);
1147
1190
  workflowCmd
1148
1191
  .command("diagram <file>")
@@ -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
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `sweny workflow upgrade <file> [--dry-run]`
3
+ *
4
+ * Rewrites a workflow file at the current spec version by running the same
5
+ * migration chain the loader applies in memory.
6
+ *
7
+ * Comment preservation (YAML): the file is edited as a YAML document, not
8
+ * re-serialized from scratch. Comments, blank lines, key order and quoting on
9
+ * everything a migration did not touch are kept. NOT preserved: a comment
10
+ * attached to a value a migration replaced or removed, the original position of
11
+ * keys a migration renamed (they are added at the end of their map), and
12
+ * formatting of arrays whose length a migration changed (rewritten whole).
13
+ * JSON files carry no comments and are rewritten with 2-space indentation.
14
+ */
15
+ import { type MigrationConfig } from "../migrations.js";
16
+ export type UpgradeResult = {
17
+ status: "error";
18
+ message: string;
19
+ } | {
20
+ status: "current";
21
+ version: number;
22
+ } | {
23
+ status: "upgraded" | "dry-run";
24
+ from: number;
25
+ to: number;
26
+ /** Migration descriptions applied, in order. Empty when only `spec_version` was stamped. */
27
+ steps: string[];
28
+ /** The full text that was (or, for dry-run, would be) written. */
29
+ content: string;
30
+ };
31
+ export interface UpgradeOptions {
32
+ dryRun?: boolean;
33
+ migration?: MigrationConfig;
34
+ }
35
+ export declare function upgradeWorkflowFile(filePath: string, options?: UpgradeOptions): UpgradeResult;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `sweny workflow upgrade <file> [--dry-run]`
3
+ *
4
+ * Rewrites a workflow file at the current spec version by running the same
5
+ * migration chain the loader applies in memory.
6
+ *
7
+ * Comment preservation (YAML): the file is edited as a YAML document, not
8
+ * re-serialized from scratch. Comments, blank lines, key order and quoting on
9
+ * everything a migration did not touch are kept. NOT preserved: a comment
10
+ * attached to a value a migration replaced or removed, the original position of
11
+ * keys a migration renamed (they are added at the end of their map), and
12
+ * formatting of arrays whose length a migration changed (rewritten whole).
13
+ * JSON files carry no comments and are rewritten with 2-space indentation.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { isMap, parseDocument } from "yaml";
18
+ import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow } from "../migrations.js";
19
+ import { validateParsed } from "../loader.js";
20
+ function isPlainObject(v) {
21
+ return typeof v === "object" && v !== null && !Array.isArray(v);
22
+ }
23
+ function withoutSpecVersion(obj) {
24
+ const copy = { ...obj };
25
+ delete copy.spec_version;
26
+ return copy;
27
+ }
28
+ function sameJson(a, b) {
29
+ return JSON.stringify(a) === JSON.stringify(b);
30
+ }
31
+ /** Apply the difference old -> next onto the YAML document, touching only what changed. */
32
+ function applyDiff(doc, at, old, next) {
33
+ if (sameJson(old, next))
34
+ return;
35
+ if (isPlainObject(old) && isPlainObject(next)) {
36
+ for (const key of Object.keys(old)) {
37
+ if (!(key in next))
38
+ doc.deleteIn([...at, key]);
39
+ }
40
+ for (const key of Object.keys(next)) {
41
+ if (key in old)
42
+ applyDiff(doc, [...at, key], old[key], next[key]);
43
+ else
44
+ doc.setIn([...at, key], next[key]);
45
+ }
46
+ return;
47
+ }
48
+ if (Array.isArray(old) && Array.isArray(next) && old.length === next.length) {
49
+ next.forEach((item, i) => applyDiff(doc, [...at, i], old[i], item));
50
+ return;
51
+ }
52
+ if (at.length === 0)
53
+ doc.contents = doc.createNode(next);
54
+ else
55
+ doc.setIn(at, next);
56
+ }
57
+ function setSpecVersion(doc, version) {
58
+ const root = doc.contents;
59
+ if (!isMap(root))
60
+ return;
61
+ if (root.has("spec_version")) {
62
+ root.set("spec_version", version);
63
+ return;
64
+ }
65
+ // Insert as the first key. A comment sitting directly above the old first key
66
+ // is the file's header comment; carry it onto the new pair so it stays on top.
67
+ const first = root.items[0];
68
+ const pair = doc.createPair("spec_version", version);
69
+ const firstKey = first?.key;
70
+ if (firstKey && firstKey.commentBefore) {
71
+ pair.key.commentBefore = firstKey.commentBefore;
72
+ firstKey.commentBefore = undefined;
73
+ }
74
+ root.items.unshift(pair);
75
+ }
76
+ export function upgradeWorkflowFile(filePath, options = {}) {
77
+ const config = options.migration ?? DEFAULT_MIGRATION_CONFIG;
78
+ let content;
79
+ try {
80
+ content = fs.readFileSync(filePath, "utf-8");
81
+ }
82
+ catch (err) {
83
+ return { status: "error", message: `Could not read "${filePath}": ${err instanceof Error ? err.message : err}` };
84
+ }
85
+ const isYaml = [".yaml", ".yml"].includes(path.extname(filePath).toLowerCase());
86
+ let doc;
87
+ let raw;
88
+ try {
89
+ if (isYaml) {
90
+ doc = parseDocument(content);
91
+ if (doc.errors.length > 0)
92
+ throw doc.errors[0];
93
+ raw = doc.toJS();
94
+ }
95
+ else {
96
+ raw = JSON.parse(content);
97
+ }
98
+ }
99
+ catch (err) {
100
+ return { status: "error", message: `Could not parse "${filePath}": ${err instanceof Error ? err.message : err}` };
101
+ }
102
+ if (!isPlainObject(raw))
103
+ return { status: "error", message: `Expected a workflow object in "${filePath}"` };
104
+ const migrated = migrateWorkflow(raw, config);
105
+ if (!migrated.ok)
106
+ return { status: "error", message: migrated.message };
107
+ const versionText = String(migrated.to);
108
+ if (migrated.applied.length === 0 && raw.spec_version === versionText) {
109
+ return { status: "current", version: migrated.to };
110
+ }
111
+ // Never write a file that would not load.
112
+ const check = validateParsed(migrated.workflow, { migration: config });
113
+ if (!check.ok) {
114
+ return {
115
+ status: "error",
116
+ message: `The upgraded workflow is not valid, so "${filePath}" was left unchanged:\n${check.errors
117
+ .map((e) => ` ${e.message}`)
118
+ .join("\n")}`,
119
+ };
120
+ }
121
+ let out;
122
+ if (doc) {
123
+ applyDiff(doc, [], withoutSpecVersion(raw), withoutSpecVersion(migrated.workflow));
124
+ setSpecVersion(doc, versionText);
125
+ out = doc.toString();
126
+ }
127
+ else {
128
+ out = JSON.stringify({ spec_version: versionText, ...withoutSpecVersion(migrated.workflow) }, null, 2) + "\n";
129
+ }
130
+ if (!options.dryRun)
131
+ fs.writeFileSync(filePath, out, "utf-8");
132
+ return {
133
+ status: options.dryRun ? "dry-run" : "upgraded",
134
+ from: migrated.from,
135
+ to: migrated.to,
136
+ steps: migrated.applied.map((m) => m.description),
137
+ content: out,
138
+ };
139
+ }
@@ -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/index.d.ts CHANGED
@@ -45,6 +45,8 @@ export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, so
45
45
  export type { WorkflowError } from "./schema.js";
46
46
  export { loadAndValidateWorkflow, validateParsed } from "./loader.js";
47
47
  export type { LoaderResult, LoaderError, LoaderOptions } from "./loader.js";
48
+ export { CURRENT_SPEC_VERSION, MIGRATIONS, migrateWorkflow } from "./migrations.js";
49
+ export type { Migration, MigrationConfig, MigrateResult } from "./migrations.js";
48
50
  export { validateRuntimeInput, summarizeInputShape } from "./inputs.js";
49
51
  export { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from "./mcp.js";
50
52
  export type { ProviderContextOptions, SkillMcpOptions } from "./mcp.js";
package/dist/index.js CHANGED
@@ -47,6 +47,7 @@ export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, so
47
47
  // the CLI uses for `workflow run`, `workflow validate`, and `publish`).
48
48
  // Node-only: loader.ts imports `node:fs`, so this is NOT mirrored in browser.ts.
49
49
  export { loadAndValidateWorkflow, validateParsed } from "./loader.js";
50
+ export { CURRENT_SPEC_VERSION, MIGRATIONS, migrateWorkflow } from "./migrations.js";
50
51
  // Workflow input validation
51
52
  export { validateRuntimeInput, summarizeInputShape } from "./inputs.js";
52
53
  // MCP auto-injection
package/dist/loader.d.ts CHANGED
@@ -11,15 +11,18 @@
11
11
  */
12
12
  import type { Workflow } from "./types.js";
13
13
  import { type WorkflowError } from "./schema.js";
14
+ import { type MigrationConfig } from "./migrations.js";
14
15
  /** Structural/schema error. `code` is present only for structural errors. */
15
16
  export interface LoaderError {
16
17
  message: string;
17
- code?: WorkflowError["code"] | "IO" | "PARSE" | "SCHEMA";
18
+ code?: WorkflowError["code"] | "IO" | "PARSE" | "SCHEMA" | "SPEC_VERSION";
18
19
  nodeId?: string;
19
20
  }
20
21
  export type LoaderResult = {
21
22
  ok: true;
22
23
  workflow: Workflow;
24
+ /** Non-fatal notes, e.g. the one-line "migrated in memory" warning. */
25
+ warnings?: string[];
23
26
  } | {
24
27
  ok: false;
25
28
  errors: LoaderError[];
@@ -27,6 +30,10 @@ export type LoaderResult = {
27
30
  export interface LoaderOptions {
28
31
  /** Optional set of known skill IDs for UNKNOWN_SKILL validation. */
29
32
  knownSkills?: Set<string>;
33
+ /** Spec version + migration registry. Defaults to the built-in one; tests inject their own. */
34
+ migration?: MigrationConfig;
35
+ /** Path shown in the migration warning. Set by loadAndValidateWorkflow. */
36
+ filePath?: string;
30
37
  }
31
38
  /**
32
39
  * Load a workflow YAML or JSON file, parse with the canonical Zod schema,
package/dist/loader.js CHANGED
@@ -14,6 +14,7 @@ import * as path from "node:path";
14
14
  import { parse as parseYaml } from "yaml";
15
15
  import { ZodError } from "zod";
16
16
  import { workflowZ, validateWorkflow, preflightLegacyVerify } from "./schema.js";
17
+ import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow, migrationWarning, } from "./migrations.js";
17
18
  /**
18
19
  * Load a workflow YAML or JSON file, parse with the canonical Zod schema,
19
20
  * and run structural validation. All three phases contribute to `errors`.
@@ -36,7 +37,7 @@ export function loadAndValidateWorkflow(filePath, options = {}) {
36
37
  const msg = err instanceof Error ? err.message : String(err);
37
38
  return { ok: false, errors: [{ message: `Could not parse "${filePath}": ${msg}`, code: "PARSE" }] };
38
39
  }
39
- return validateParsed(raw, options);
40
+ return validateParsed(raw, { ...options, filePath: options.filePath ?? filePath });
40
41
  }
41
42
  /**
42
43
  * Validate an already-parsed workflow object (e.g. from a marketplace fetch
@@ -47,6 +48,15 @@ export function validateParsed(raw, options = {}) {
47
48
  const kind = raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw;
48
49
  return { ok: false, errors: [{ message: `Expected a workflow object, got ${kind}`, code: "SCHEMA" }] };
49
50
  }
51
+ // Apply spec_version migrations in memory (older versions), or refuse a
52
+ // version newer than this build supports. Runs first so a migration can
53
+ // reshape a file before the checks below see it.
54
+ const migrated = migrateWorkflow(raw, options.migration ?? DEFAULT_MIGRATION_CONFIG);
55
+ if (!migrated.ok) {
56
+ return { ok: false, errors: [{ message: migrated.message, code: "SPEC_VERSION" }] };
57
+ }
58
+ raw = migrated.workflow;
59
+ const warnings = migrated.applied.length > 0 ? [migrationWarning(migrated.from, migrated.to, options.filePath)] : [];
50
60
  // Detect legacy `verify:` blocks (renamed to `eval:` in v0.2.0) before the
51
61
  // Zod parse. workflowZ is intentionally non-strict at the top level, so a
52
62
  // top-level `verify:` would be silently STRIPPED instead of surfacing the
@@ -83,5 +93,5 @@ export function validateParsed(raw, options = {}) {
83
93
  errors: structural.map((e) => ({ message: e.message, code: e.code, nodeId: e.nodeId })),
84
94
  };
85
95
  }
86
- return { ok: true, workflow: parsed };
96
+ return warnings.length > 0 ? { ok: true, workflow: parsed, warnings } : { ok: true, workflow: parsed };
87
97
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Workflow spec versioning and migrations.
3
+ *
4
+ * Every workflow file carries an optional top-level `spec_version` (a string
5
+ * holding a positive integer, e.g. "1"). A file without the field is version 1:
6
+ * that is what every file written before the field existed means.
7
+ *
8
+ * Breaking spec changes bump CURRENT_SPEC_VERSION and add one entry to
9
+ * MIGRATIONS: a pure function from the raw (unvalidated) vN object to the raw
10
+ * vN+1 object. The loader applies the chain in memory so old files keep
11
+ * running; `sweny workflow upgrade <file>` writes the result back to disk.
12
+ *
13
+ * Adding a migration: see `__tests__/migrations.test.ts` for the harness
14
+ * (registry validity check plus a chain test to copy).
15
+ */
16
+ /** A raw, not-yet-validated workflow object as parsed from YAML or JSON. */
17
+ export type RawWorkflow = Record<string, unknown>;
18
+ export interface Migration {
19
+ /** Version this migration reads. */
20
+ from: number;
21
+ /** Version it produces. Always `from + 1`. */
22
+ to: number;
23
+ /** One line shown by `sweny workflow upgrade`. */
24
+ description: string;
25
+ /** Pure: must not mutate its input. Returns the next-version object. */
26
+ migrate: (raw: RawWorkflow) => RawWorkflow;
27
+ }
28
+ /** The spec version this build of sweny reads natively. */
29
+ export declare const CURRENT_SPEC_VERSION = 1;
30
+ /**
31
+ * Ordered registry, one entry per version step. Empty while v1 is current.
32
+ * Append only; never edit a shipped migration.
33
+ */
34
+ export declare const MIGRATIONS: readonly Migration[];
35
+ export interface MigrationConfig {
36
+ current: number;
37
+ migrations: readonly Migration[];
38
+ }
39
+ export declare const DEFAULT_MIGRATION_CONFIG: MigrationConfig;
40
+ export type MigrateError = {
41
+ code: "INVALID_VERSION" | "TOO_NEW" | "MIGRATION_FAILED";
42
+ message: string;
43
+ };
44
+ export type MigrateResult = {
45
+ ok: true;
46
+ /** The (possibly migrated) object. `spec_version` is a string when it was declared or migrated. */
47
+ workflow: RawWorkflow;
48
+ /** Version the file declared (1 when absent). */
49
+ from: number;
50
+ /** Version of `workflow` after migration. */
51
+ to: number;
52
+ /** Whether the input declared `spec_version`. */
53
+ declared: boolean;
54
+ /** Migrations applied, in order. Empty when the file is already current. */
55
+ applied: Migration[];
56
+ } | ({
57
+ ok: false;
58
+ } & MigrateError);
59
+ /** Throws if a registry is not an unbroken chain of single steps ending at `current`. */
60
+ export declare function assertRegistryValid(config: MigrationConfig): void;
61
+ export declare function migrateWorkflow(raw: RawWorkflow, config?: MigrationConfig): MigrateResult;
62
+ /** The single warning the loader emits when it migrated in memory. */
63
+ export declare function migrationWarning(from: number, to: number, filePath?: string): string;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Workflow spec versioning and migrations.
3
+ *
4
+ * Every workflow file carries an optional top-level `spec_version` (a string
5
+ * holding a positive integer, e.g. "1"). A file without the field is version 1:
6
+ * that is what every file written before the field existed means.
7
+ *
8
+ * Breaking spec changes bump CURRENT_SPEC_VERSION and add one entry to
9
+ * MIGRATIONS: a pure function from the raw (unvalidated) vN object to the raw
10
+ * vN+1 object. The loader applies the chain in memory so old files keep
11
+ * running; `sweny workflow upgrade <file>` writes the result back to disk.
12
+ *
13
+ * Adding a migration: see `__tests__/migrations.test.ts` for the harness
14
+ * (registry validity check plus a chain test to copy).
15
+ */
16
+ /** The spec version this build of sweny reads natively. */
17
+ export const CURRENT_SPEC_VERSION = 1;
18
+ /**
19
+ * Ordered registry, one entry per version step. Empty while v1 is current.
20
+ * Append only; never edit a shipped migration.
21
+ */
22
+ export const MIGRATIONS = [];
23
+ export const DEFAULT_MIGRATION_CONFIG = {
24
+ current: CURRENT_SPEC_VERSION,
25
+ migrations: MIGRATIONS,
26
+ };
27
+ /** Throws if a registry is not an unbroken chain of single steps ending at `current`. */
28
+ export function assertRegistryValid(config) {
29
+ const { current, migrations } = config;
30
+ if (!Number.isInteger(current) || current < 1)
31
+ throw new Error("current spec version must be a positive integer");
32
+ if (migrations.length !== current - 1) {
33
+ throw new Error(`expected ${current - 1} migration(s) to reach v${current}, found ${migrations.length}`);
34
+ }
35
+ migrations.forEach((m, i) => {
36
+ if (m.from !== i + 1 || m.to !== i + 2) {
37
+ throw new Error(`migration #${i} must be v${i + 1} -> v${i + 2}, got v${m.from} -> v${m.to}`);
38
+ }
39
+ });
40
+ }
41
+ function parseVersion(value) {
42
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1)
43
+ return value;
44
+ if (typeof value === "string" && /^[1-9]\d*$/.test(value))
45
+ return Number(value);
46
+ return null;
47
+ }
48
+ export function migrateWorkflow(raw, config = DEFAULT_MIGRATION_CONFIG) {
49
+ const declared = "spec_version" in raw && raw.spec_version !== undefined;
50
+ const from = declared ? parseVersion(raw.spec_version) : 1;
51
+ if (from === null) {
52
+ return {
53
+ ok: false,
54
+ code: "INVALID_VERSION",
55
+ message: `spec_version must be a positive integer string like "1", got ${JSON.stringify(raw.spec_version)}`,
56
+ };
57
+ }
58
+ if (from > config.current) {
59
+ return {
60
+ ok: false,
61
+ code: "TOO_NEW",
62
+ message: `This workflow declares spec_version "${from}", but this version of sweny supports up to "${config.current}". ` +
63
+ `Upgrade sweny (run "sweny upgrade", or npm install -g @sweny-ai/core@latest) and try again.`,
64
+ };
65
+ }
66
+ let current = declared ? { ...raw, spec_version: String(from) } : raw;
67
+ const applied = [];
68
+ for (let v = from; v < config.current; v++) {
69
+ const step = config.migrations.find((m) => m.from === v);
70
+ if (!step) {
71
+ return { ok: false, code: "MIGRATION_FAILED", message: `No migration registered from spec_version "${v}".` };
72
+ }
73
+ try {
74
+ current = { ...step.migrate(current), spec_version: String(step.to) };
75
+ }
76
+ catch (err) {
77
+ const msg = err instanceof Error ? err.message : String(err);
78
+ return {
79
+ ok: false,
80
+ code: "MIGRATION_FAILED",
81
+ message: `Migration from spec_version "${step.from}" to "${step.to}" failed: ${msg}`,
82
+ };
83
+ }
84
+ applied.push(step);
85
+ }
86
+ return { ok: true, workflow: current, from, to: config.current, declared, applied };
87
+ }
88
+ /** The single warning the loader emits when it migrated in memory. */
89
+ export function migrationWarning(from, to, filePath) {
90
+ const target = filePath ? `"${filePath}"` : "<file>";
91
+ return (`Workflow uses spec_version "${from}" (current is "${to}"); migrated in memory for this run. ` +
92
+ `Run \`sweny workflow upgrade ${target}\` to update the file.`);
93
+ }
package/dist/schema.d.ts CHANGED
@@ -1714,6 +1714,7 @@ export declare const workflowZ: z.ZodObject<{
1714
1714
  id: z.ZodString;
1715
1715
  name: z.ZodString;
1716
1716
  description: z.ZodDefault<z.ZodString>;
1717
+ spec_version: z.ZodOptional<z.ZodString>;
1717
1718
  workflow_type: z.ZodOptional<z.ZodEnum<["pr_review", "e2e_test", "content_generation", "monitor", "data_sync", "generic"]>>;
1718
1719
  nodes: z.ZodRecord<z.ZodString, z.ZodObject<{
1719
1720
  name: z.ZodString;
@@ -2876,6 +2877,7 @@ export declare const workflowZ: z.ZodObject<{
2876
2877
  type?: string | undefined;
2877
2878
  })[] | undefined;
2878
2879
  judge_model?: string | undefined;
2880
+ spec_version?: string | undefined;
2879
2881
  workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
2880
2882
  judge_budget?: number | undefined;
2881
2883
  context_mode?: "full" | "bounded" | undefined;
@@ -3064,6 +3066,7 @@ export declare const workflowZ: z.ZodObject<{
3064
3066
  type?: string | undefined;
3065
3067
  })[] | undefined;
3066
3068
  judge_model?: string | undefined;
3069
+ spec_version?: string | undefined;
3067
3070
  workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
3068
3071
  judge_budget?: number | undefined;
3069
3072
  context_mode?: "full" | "bounded" | undefined;
@@ -3266,6 +3269,7 @@ export declare function parseWorkflow(raw: unknown): {
3266
3269
  type?: string | undefined;
3267
3270
  })[] | undefined;
3268
3271
  judge_model?: string | undefined;
3272
+ spec_version?: string | undefined;
3269
3273
  workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
3270
3274
  judge_budget?: number | undefined;
3271
3275
  context_mode?: "full" | "bounded" | undefined;
@@ -3649,6 +3653,11 @@ export declare const workflowJsonSchema: {
3649
3653
  readonly description: {
3650
3654
  readonly type: "string";
3651
3655
  };
3656
+ readonly spec_version: {
3657
+ readonly type: "string";
3658
+ readonly pattern: "^[1-9][0-9]*$";
3659
+ readonly description: "Workflow spec version as a positive integer string. Absent means 1. Older versions are migrated in memory on load; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.";
3660
+ };
3652
3661
  readonly entry: {
3653
3662
  readonly type: "string";
3654
3663
  readonly minLength: 1;
package/dist/schema.js CHANGED
@@ -357,6 +357,10 @@ export const workflowZ = z.object({
357
357
  id: z.string().min(1),
358
358
  name: z.string().min(1),
359
359
  description: z.string().default(""),
360
+ spec_version: z
361
+ .string()
362
+ .regex(/^[1-9]\d*$/, "spec_version must be a positive integer string")
363
+ .optional(),
360
364
  workflow_type: workflowTypeZ.optional(),
361
365
  nodes: z.record(nodeZ),
362
366
  edges: z.array(edgeZ),
@@ -954,6 +958,11 @@ export const workflowJsonSchema = {
954
958
  id: { type: "string", minLength: 1 },
955
959
  name: { type: "string", minLength: 1 },
956
960
  description: { type: "string" },
961
+ spec_version: {
962
+ type: "string",
963
+ pattern: "^[1-9][0-9]*$",
964
+ description: "Workflow spec version as a positive integer string. Absent means 1. Older versions are migrated in memory on load; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.",
965
+ },
957
966
  entry: { type: "string", minLength: 1, description: "ID of the entry node" },
958
967
  rules: {
959
968
  type: "array",
package/dist/types.d.ts CHANGED
@@ -514,6 +514,8 @@ export interface Workflow {
514
514
  id: string;
515
515
  name: string;
516
516
  description: string;
517
+ /** Spec version, a positive integer string (e.g. "1"). Absent means "1". See `migrations.ts`. */
518
+ spec_version?: string;
517
519
  /** Optional. Defaults to "generic" when absent. Required on marketplace templates. */
518
520
  workflow_type?: WorkflowType;
519
521
  nodes: Record<string, Node>;
@@ -622,6 +624,11 @@ export interface NodeResult {
622
624
  };
623
625
  /** Opinions this run could not honor natively. Always empty for Claude Code. */
624
626
  degraded?: string[];
627
+ /**
628
+ * Agent floor this node actually ran under, for the run receipt. Set by the
629
+ * Claude Code harness only; absent for harnesses that do not report it.
630
+ */
631
+ policy?: NodePolicyFacts;
625
632
  /** Safe outputs (#365): what the write stage did with each intent. Absent when the node declares none. */
626
633
  outputs?: SafeOutputReceipt[];
627
634
  /**
@@ -631,6 +638,15 @@ export interface NodeResult {
631
638
  */
632
639
  budget?: BudgetOverrun;
633
640
  }
641
+ /** Facts about the agent floor (scoped env, process sandbox) a node ran under. Booleans and a mode only. */
642
+ export interface NodePolicyFacts {
643
+ /** The agent subprocess env was narrowed to the allowlist. */
644
+ envScope: boolean;
645
+ /** Requested sandbox mode. */
646
+ sandbox: "off" | "auto" | "strict";
647
+ /** The sandbox was enabled for this node (false under `off`, or `auto` on a host that cannot sandbox). */
648
+ sandboxStarted: boolean;
649
+ }
634
650
  export interface ToolCall {
635
651
  tool: string;
636
652
  input: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.159",
3
+ "version": "0.1.161",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"