@sweny-ai/core 0.1.176 → 0.1.178

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/agent-env.d.ts +9 -0
  2. package/dist/agent-env.js +4 -0
  3. package/dist/cli/comment-output.js +4 -5
  4. package/dist/cli/config-file.d.ts +37 -2
  5. package/dist/cli/config-file.js +68 -3
  6. package/dist/cli/final-output.d.ts +4 -1
  7. package/dist/cli/final-output.js +6 -8
  8. package/dist/cli/main.d.ts +2 -1
  9. package/dist/cli/main.js +18 -10
  10. package/dist/cli/resume.js +1 -0
  11. package/dist/cli/run-history.d.ts +3 -7
  12. package/dist/cli/run-history.js +5 -12
  13. package/dist/cli/run-output.d.ts +16 -10
  14. package/dist/cli/run-output.js +30 -16
  15. package/dist/cli/ticket.js +3 -2
  16. package/dist/cli/try.js +2 -3
  17. package/dist/decider.d.ts +176 -64
  18. package/dist/decider.js +475 -155
  19. package/dist/executor.d.ts +55 -7
  20. package/dist/executor.js +511 -99
  21. package/dist/harness/acp.js +2 -2
  22. package/dist/harness/claude-code.js +6 -4
  23. package/dist/harness/codex.js +2 -2
  24. package/dist/harness/pi.js +2 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +2 -2
  27. package/dist/journal.d.ts +34 -5
  28. package/dist/journal.js +42 -13
  29. package/dist/safe-file.d.ts +74 -0
  30. package/dist/safe-file.js +211 -0
  31. package/dist/safe-outputs.d.ts +7 -1
  32. package/dist/safe-outputs.js +7 -1
  33. package/dist/schema.d.ts +189 -241
  34. package/dist/schema.js +56 -36
  35. package/dist/skills/git-push.d.ts +99 -10
  36. package/dist/skills/git-push.js +413 -52
  37. package/dist/skills/github.d.ts +19 -15
  38. package/dist/skills/github.js +91 -15
  39. package/dist/startup-env.d.ts +30 -0
  40. package/dist/startup-env.js +48 -0
  41. package/dist/types.d.ts +86 -4
  42. package/dist/types.js +22 -0
  43. package/dist/when.d.ts +8 -1
  44. package/dist/when.js +8 -1
  45. package/dist/workflow-builder.js +3 -1
  46. package/dist/workflows/browser.js +2 -2
  47. package/dist/workflows/implement.yml +37 -5
  48. package/dist/workflows/triage.yml +40 -6
  49. package/package.json +1 -1
@@ -4,11 +4,6 @@
4
4
  * Replaces: source-control/github.ts + issue-tracking/github-issues.ts
5
5
  * ~800 lines → ~120 lines
6
6
  */
7
- let branchPusher;
8
- /** Register (or clear, with `undefined`) the sweny-side branch pusher. */
9
- export function setBranchPusher(fn) {
10
- branchPusher = fn;
11
- }
12
7
  class GitHubApiError extends Error {
13
8
  status;
14
9
  body;
@@ -19,8 +14,28 @@ class GitHubApiError extends Error {
19
14
  this.body = body;
20
15
  }
21
16
  }
17
+ /**
18
+ * The REST base: `ctx.githubApiUrl` (the operator's GITHUB_API_URL, for GHES)
19
+ * or https://api.github.com. Only a plain https URL: no credentials, query or
20
+ * fragment, since the token goes to it.
21
+ */
22
+ export function githubApiBase(apiUrl) {
23
+ if (!apiUrl)
24
+ return "https://api.github.com";
25
+ let u;
26
+ try {
27
+ u = new URL(apiUrl);
28
+ }
29
+ catch {
30
+ throw new Error(`[GitHub] GITHUB_API_URL is not a URL`);
31
+ }
32
+ if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash) {
33
+ throw new Error(`[GitHub] GITHUB_API_URL is not a plain https URL`);
34
+ }
35
+ return `${u.origin}${u.pathname.replace(/\/+$/, "")}`;
36
+ }
22
37
  async function gh(path, ctx, init) {
23
- const res = await fetch(`https://api.github.com${path}`, {
38
+ const res = await fetch(`${githubApiBase(ctx.githubApiUrl)}${path}`, {
24
39
  ...init,
25
40
  headers: {
26
41
  Authorization: `token ${ctx.config.GITHUB_TOKEN}`,
@@ -34,6 +49,49 @@ async function gh(path, ctx, init) {
34
49
  throw new GitHubApiError(res.status, await res.text());
35
50
  return res.json();
36
51
  }
52
+ /**
53
+ * Whether sweny may push the PR's head branch, from the API (#498). The agent
54
+ * names head and base, so head could be an existing integration branch
55
+ * ("release") that is neither base nor default. A head that does not exist
56
+ * remotely is pushed with an empty lease (it must still not exist). One that
57
+ * exists is pushed only when it is unprotected and already has an open PR from
58
+ * this repo into `base`, leased on the sha the API returned. Any API failure
59
+ * means no push.
60
+ */
61
+ export async function headPushLease(repo, head, base, ctx) {
62
+ const branch = head.replace(/^refs\/heads\//, "");
63
+ const target = base.replace(/^refs\/heads\//, "");
64
+ const seg = branch.split("/").map(encodeURIComponent).join("/");
65
+ let info;
66
+ try {
67
+ info = (await gh(`/repos/${repo}/branches/${seg}`, ctx));
68
+ }
69
+ catch (err) {
70
+ if (err instanceof GitHubApiError && err.status === 404)
71
+ return { remoteSha: "" };
72
+ return { skip: `cannot check head ${branch} on ${repo}: ${err instanceof Error ? err.message : String(err)}` };
73
+ }
74
+ const sha = info?.commit?.sha;
75
+ if (typeof sha !== "string" || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(sha)) {
76
+ return { skip: `cannot check head ${branch} on ${repo}: no branch sha in the API answer` };
77
+ }
78
+ if (info.protected !== false)
79
+ return { skip: `head ${branch} exists on ${repo} and is protected` };
80
+ const owner = repo.split("/")[0];
81
+ let open;
82
+ try {
83
+ open = await gh(`/repos/${repo}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&base=${encodeURIComponent(target)}&state=open&per_page=1`, ctx);
84
+ }
85
+ catch (err) {
86
+ return {
87
+ skip: `cannot check open PRs for ${branch} on ${repo}: ${err instanceof Error ? err.message : String(err)}`,
88
+ };
89
+ }
90
+ if (!Array.isArray(open) || open.length === 0) {
91
+ return { skip: `head ${branch} already exists on ${repo} with no open PR into ${target}` };
92
+ }
93
+ return { remoteSha: sha };
94
+ }
37
95
  function isAlreadyExistsError(err) {
38
96
  if (!(err instanceof GitHubApiError) || err.status !== 422)
39
97
  return false;
@@ -187,15 +245,33 @@ export const github = {
187
245
  // recommended `persist-credentials: false` checkout still ships the
188
246
  // branch and the agent never holds a write token. Skipped unless the
189
247
  // branch exists locally and origin is this repo; never forced.
190
- if (branchPusher) {
191
- const push = await branchPusher({
192
- repo: input.repo,
193
- head: input.head,
194
- base: input.base ?? "main",
195
- token: ctx.config.GITHUB_TOKEN,
196
- });
197
- if (push.pushed)
198
- ctx.logger?.info?.(` github_create_pr: pushed ${input.head} to origin`);
248
+ if (ctx.pushBranch) {
249
+ // The default branch comes from the API, not the agent-written
250
+ // checkout's origin/HEAD. Unknown means no push.
251
+ let defaultBranch;
252
+ try {
253
+ const meta = (await gh(`/repos/${input.repo}`, ctx));
254
+ if (typeof meta?.default_branch === "string")
255
+ defaultBranch = meta.default_branch;
256
+ }
257
+ catch {
258
+ // The pusher refuses without it; the PR request below reports the API error.
259
+ }
260
+ const lease = await headPushLease(input.repo, input.head, input.base ?? "main", ctx);
261
+ const push = "skip" in lease
262
+ ? { pushed: false, attempted: false, reason: lease.skip }
263
+ : await ctx.pushBranch({
264
+ repo: input.repo,
265
+ head: input.head,
266
+ base: input.base ?? "main",
267
+ ...(defaultBranch ? { defaultBranch } : {}),
268
+ remoteSha: lease.remoteSha,
269
+ token: ctx.config.GITHUB_TOKEN,
270
+ });
271
+ if ("skip" in lease)
272
+ ctx.logger?.warn?.(` github_create_pr: not pushing: ${lease.skip}`);
273
+ else if (push.pushed)
274
+ ctx.logger?.info?.(` github_create_pr: pushed ${input.head} to ${input.repo}`);
199
275
  else if (push.attempted)
200
276
  ctx.logger?.warn?.(` github_create_pr: ${push.reason}; requesting the PR anyway`);
201
277
  else
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The process environment as it was before sweny read any workspace file.
3
+ *
4
+ * The workspace is agent-writable, and its `.env` (cli/config-file.ts
5
+ * `loadDotenv`) may set any variable the real environment left unset. The
6
+ * privileged git push (skills/git-push.ts) must not take its PATH, proxy or CA
7
+ * settings, or the server it sends the token to, from a value the workspace
8
+ * introduced. This module is imported by config-file.ts, so the snapshot is
9
+ * taken when the module graph loads, before `loadDotenv()` runs.
10
+ *
11
+ * Node only.
12
+ */
13
+ /** The environment captured at process startup, before any workspace file was read. */
14
+ export declare function startupEnv(): Readonly<NodeJS.ProcessEnv>;
15
+ /**
16
+ * The temp dir from the startup env. `os.tmpdir()` reads TMPDIR at call time,
17
+ * which a workspace `.env` can set; this never moves after startup.
18
+ */
19
+ export declare function startupTmpdir(): string;
20
+ /** Record that a workspace file (`.env`) set `key` in `process.env`. */
21
+ export declare function markWorkspaceEnv(key: string): void;
22
+ /** Test seam: forget that a workspace file set `key`. */
23
+ export declare function unmarkWorkspaceEnv(key: string): void;
24
+ /**
25
+ * The value of `key` the privileged path may use. From `process.env` itself
26
+ * (the CLI's run env) it is always the startup value. From a caller-built env
27
+ * it is the caller's value, unless a workspace file introduced the key (a copy
28
+ * of `process.env` taken after `loadDotenv`), which yields the startup value.
29
+ */
30
+ export declare function trustedEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The process environment as it was before sweny read any workspace file.
3
+ *
4
+ * The workspace is agent-writable, and its `.env` (cli/config-file.ts
5
+ * `loadDotenv`) may set any variable the real environment left unset. The
6
+ * privileged git push (skills/git-push.ts) must not take its PATH, proxy or CA
7
+ * settings, or the server it sends the token to, from a value the workspace
8
+ * introduced. This module is imported by config-file.ts, so the snapshot is
9
+ * taken when the module graph loads, before `loadDotenv()` runs.
10
+ *
11
+ * Node only.
12
+ */
13
+ import { tmpdir } from "node:os";
14
+ const SNAPSHOT = Object.freeze({ ...process.env });
15
+ /** os.tmpdir() as resolved from the startup env (TMPDIR, TEMP, TMP), before any workspace file was read. */
16
+ const TMP = tmpdir();
17
+ /** Variable names a workspace file wrote into `process.env` after startup. */
18
+ const introduced = new Set();
19
+ /** The environment captured at process startup, before any workspace file was read. */
20
+ export function startupEnv() {
21
+ return SNAPSHOT;
22
+ }
23
+ /**
24
+ * The temp dir from the startup env. `os.tmpdir()` reads TMPDIR at call time,
25
+ * which a workspace `.env` can set; this never moves after startup.
26
+ */
27
+ export function startupTmpdir() {
28
+ return TMP;
29
+ }
30
+ /** Record that a workspace file (`.env`) set `key` in `process.env`. */
31
+ export function markWorkspaceEnv(key) {
32
+ introduced.add(key);
33
+ }
34
+ /** Test seam: forget that a workspace file set `key`. */
35
+ export function unmarkWorkspaceEnv(key) {
36
+ introduced.delete(key);
37
+ }
38
+ /**
39
+ * The value of `key` the privileged path may use. From `process.env` itself
40
+ * (the CLI's run env) it is always the startup value. From a caller-built env
41
+ * it is the caller's value, unless a workspace file introduced the key (a copy
42
+ * of `process.env` taken after `loadDotenv`), which yields the startup value.
43
+ */
44
+ export function trustedEnvValue(env, key) {
45
+ if (env === process.env || introduced.has(key))
46
+ return SNAPSHOT[key];
47
+ return env[key];
48
+ }
package/dist/types.d.ts CHANGED
@@ -12,7 +12,43 @@ export interface ToolContext {
12
12
  config: Record<string, string>;
13
13
  /** Structured logger */
14
14
  logger: Logger;
15
- }
15
+ /** The run's working directory (`ExecuteOptions.cwd`, else `process.cwd()`). */
16
+ cwd?: string;
17
+ /**
18
+ * A staged or dry run: no side effects. The executor refuses every
19
+ * non-`read` tool before its handler runs.
20
+ */
21
+ staged?: boolean;
22
+ /**
23
+ * Pushes a PR's head branch from the sweny process (#473), bound to this
24
+ * run's checkout. Set by the Node executor; absent in the browser build,
25
+ * where `github_create_pr` only calls the API.
26
+ */
27
+ pushBranch?: BranchPusher;
28
+ /**
29
+ * The operator's GITHUB_API_URL (GHES), read through the startup env
30
+ * snapshot by the Node executor. Absent means https://api.github.com.
31
+ */
32
+ githubApiUrl?: string;
33
+ }
34
+ /** The sweny-side head-branch push behind `github_create_pr` (#473). Never throws. */
35
+ export type BranchPusher = (opts: {
36
+ repo: string;
37
+ head: string;
38
+ base: string;
39
+ /** The repo's default branch from the GitHub API; without it nothing is pushed. */
40
+ defaultBranch?: string;
41
+ /**
42
+ * The head branch's sha on the remote when it was checked through the API,
43
+ * or "" when it did not exist. The push leases on it. Without it nothing is pushed.
44
+ */
45
+ remoteSha?: string;
46
+ token?: string;
47
+ }) => Promise<{
48
+ pushed: boolean;
49
+ attempted: boolean;
50
+ reason?: string;
51
+ }>;
16
52
  /**
17
53
  * Side-effect class of a tool. `"read"` only reads. `"write"` can change
18
54
  * something outside the run: create, update, delete, post, send, invoke.
@@ -382,6 +418,13 @@ export interface Node {
382
418
  * Distinct from `requires.on_fail`, which is the pre-condition gate.
383
419
  */
384
420
  on_fail?: NodeOnFail;
421
+ /**
422
+ * Who picks this node's natural-language route (#357). `agent` (default):
423
+ * the agent's route evaluation. `decider`: the operator's decision model is
424
+ * asked first and its confident answer is the route; otherwise the agent.
425
+ * Expressions always win over both. Safety conditions belong in expressions.
426
+ */
427
+ route_by?: RouteBy;
385
428
  /**
386
429
  * What this node's agent may do (#365). `read` runs the node read-only:
387
430
  * only `access: "read"` skill tools, no external skill MCP servers, no
@@ -519,6 +562,12 @@ export interface Edge {
519
562
  * evaluated by sweny with no model call.
520
563
  */
521
564
  when?: EdgeWhen;
565
+ /**
566
+ * The condition in natural language (#357). For an `{ expr }` edge it is
567
+ * what the next rung (decider or agent) reads when the expression cannot be
568
+ * evaluated because a field it needs is missing or invalid.
569
+ */
570
+ description?: string;
522
571
  /** Max times this edge can be followed (enables retry loops). Default: unlimited. */
523
572
  max_iterations?: number;
524
573
  }
@@ -533,6 +582,25 @@ export interface Edge {
533
582
  */
534
583
  export declare const WORKFLOW_TYPES: readonly ["pr_review", "e2e_test", "content_generation", "monitor", "data_sync", "generic"];
535
584
  export type WorkflowType = (typeof WORKFLOW_TYPES)[number];
585
+ /**
586
+ * Decision model gates (#357). Shared by the Zod schema, the JSON schema and
587
+ * the executor; kept here so schema-only (browser) consumers need no Node code.
588
+ */
589
+ /** Default: accept a decider answer only at or above this confidence. */
590
+ export declare const DECIDER_MIN_CONFIDENCE = 0.85;
591
+ /** Default: ... and only when the top label leads the runner-up by at least this much. */
592
+ export declare const DECIDER_MIN_MARGIN = 0.2;
593
+ /** A workflow may lower `min_confidence` to this and no further. */
594
+ export declare const DECIDER_CONFIDENCE_FLOOR = 0.7;
595
+ /** A workflow may lower `min_margin` to this and no further. */
596
+ export declare const DECIDER_MARGIN_FLOOR = 0.1;
597
+ /** `route_by` values: who picks a node's natural-language route. */
598
+ export declare const ROUTE_BY: readonly ["agent", "decider"];
599
+ export type RouteBy = (typeof ROUTE_BY)[number];
600
+ /** Load error for a workflow that still declares the removed `decider.mode`. */
601
+ export declare const DECIDER_MODE_REMOVED: string;
602
+ /** Load error for a workflow that declares the provider itself. */
603
+ export declare const DECIDER_PROVIDER_MOVED: string;
536
604
  /** A complete workflow definition. Pure data, fully serializable. */
537
605
  export interface Workflow {
538
606
  id: string;
@@ -562,8 +630,9 @@ export interface Workflow {
562
630
  */
563
631
  budget?: Budget;
564
632
  /**
565
- * Decision model for route choices (#357). `shadow` asks it alongside the
566
- * agent and logs agreement; the route is always the agent's. Default: off.
633
+ * Decision model thresholds (#357) for the nodes that set `route_by:
634
+ * decider`. Never looser than the floors. The provider itself comes only
635
+ * from operator config, never from the workflow.
567
636
  */
568
637
  decider?: DeciderConfig;
569
638
  /**
@@ -765,12 +834,19 @@ export interface TraceStep {
765
834
  /** 0-indexed retry attempt for this iteration. Absent when no retry fired. */
766
835
  retryAttempt?: number;
767
836
  }
837
+ /**
838
+ * Which rung of the route ladder chose a conditional route (#357): a `when`
839
+ * expression, the decision model, or the agent's route evaluation.
840
+ */
841
+ export type RouteRung = "expr" | "decider" | "agent";
768
842
  /** A routing decision between nodes */
769
843
  export interface TraceEdge {
770
844
  from: string;
771
845
  to: string;
772
846
  /** Why this edge was chosen (condition text or "only path") */
773
847
  reason: string;
848
+ /** Who chose it. Absent for an unconditional edge (nothing to decide). */
849
+ rung?: RouteRung;
774
850
  }
775
851
  /** Full execution trace — ordered steps + edges taken */
776
852
  export interface ExecutionTrace {
@@ -780,8 +856,14 @@ export interface ExecutionTrace {
780
856
  edges: TraceEdge[];
781
857
  /** Resolved sources keyed by field path (e.g. "nodes.gather.instruction") */
782
858
  sources: Record<string, _ResolvedSource>;
783
- /** Shadow-mode decider records, one per route decision (#357). Metadata only. Absent when the decider is off. */
859
+ /** One record per decision-model consultation (#357). Metadata only. Absent when no decider runs. */
784
860
  decisions?: DeciderRecord[];
861
+ /**
862
+ * Why the decider did not run for some or all of this run's `route_by:
863
+ * decider` nodes (#357): missing operator config, `--no-decider`, a rejected
864
+ * URL, an open breaker or a spent call cap. Absent when nothing turned it off.
865
+ */
866
+ deciderOff?: string;
785
867
  }
786
868
  /** Result of execute() — final node results + full execution trace */
787
869
  export interface ExecutionResult {
package/dist/types.js CHANGED
@@ -117,6 +117,28 @@ export const WORKFLOW_TYPES = [
117
117
  "data_sync",
118
118
  "generic",
119
119
  ];
120
+ /**
121
+ * Decision model gates (#357). Shared by the Zod schema, the JSON schema and
122
+ * the executor; kept here so schema-only (browser) consumers need no Node code.
123
+ */
124
+ /** Default: accept a decider answer only at or above this confidence. */
125
+ export const DECIDER_MIN_CONFIDENCE = 0.85;
126
+ /** Default: ... and only when the top label leads the runner-up by at least this much. */
127
+ export const DECIDER_MIN_MARGIN = 0.2;
128
+ /** A workflow may lower `min_confidence` to this and no further. */
129
+ export const DECIDER_CONFIDENCE_FLOOR = 0.7;
130
+ /** A workflow may lower `min_margin` to this and no further. */
131
+ export const DECIDER_MARGIN_FLOOR = 0.1;
132
+ /** `route_by` values: who picks a node's natural-language route. */
133
+ export const ROUTE_BY = ["agent", "decider"];
134
+ /** Load error for a workflow that still declares the removed `decider.mode`. */
135
+ export const DECIDER_MODE_REMOVED = "decider.mode was removed with shadow mode. A decider now decides the routes of nodes that set `route_by: decider`, " +
136
+ "using the provider from operator config (.sweny.yml `decider:` or SWENY_DECIDER_URL and SWENY_DECIDER_MODEL). " +
137
+ "Delete the mode line; to keep routing with the agent only, delete the whole decider block.";
138
+ /** Load error for a workflow that declares the provider itself. */
139
+ export const DECIDER_PROVIDER_MOVED = "decider.provider is not allowed in a workflow: the endpoint and key come only from operator config " +
140
+ "(.sweny.yml `decider: { url, model }`, or SWENY_DECIDER_URL and SWENY_DECIDER_MODEL; the key only from SWENY_DECIDER_API_KEY). " +
141
+ "Move it there. A workflow may only set min_confidence and min_margin here, and `route_by: decider` on nodes.";
120
142
  export const consoleLogger = {
121
143
  info: (msg, data) => console.log(`[info] ${msg}`, data ?? ""),
122
144
  warn: (msg, data) => console.warn(`[warn] ${msg}`, data ?? ""),
package/dist/when.d.ts CHANGED
@@ -95,7 +95,14 @@ export interface ExpressionResult {
95
95
  * Evaluate a parsed expression against node outputs. Never throws: a missing
96
96
  * field or a type mismatch yields `{ value: false, problem }`.
97
97
  */
98
- export declare function evaluateExpression(ast: ExprNode, scope: ExpressionScope): ExpressionResult;
98
+ export declare function evaluateExpression(ast: ExprNode, scope: ExpressionScope, opts?: {
99
+ /**
100
+ * `node.field` paths whose value broke its declared type or enum. Any
101
+ * reference to one (or below it), `exists` included, is a problem: the
102
+ * value is unknown, which is neither present nor absent.
103
+ */
104
+ invalid?: ReadonlySet<string>;
105
+ }): ExpressionResult;
99
106
  /**
100
107
  * Check an expression against the workflow it routes. Returns one message per
101
108
  * problem, empty when the expression is valid:
package/dist/when.js CHANGED
@@ -324,8 +324,14 @@ function lookup(scope, segments) {
324
324
  * Evaluate a parsed expression against node outputs. Never throws: a missing
325
325
  * field or a type mismatch yields `{ value: false, problem }`.
326
326
  */
327
- export function evaluateExpression(ast, scope) {
327
+ export function evaluateExpression(ast, scope, opts = {}) {
328
+ const checkInvalid = (path) => {
329
+ if (opts.invalid && path.length >= 2 && opts.invalid.has(`${path[0]}.${path[1]}`)) {
330
+ throw new Abort(`field '${path[0]}.${path[1]}' does not match its declared type`);
331
+ }
332
+ };
328
333
  const read = (path) => {
334
+ checkInvalid(path);
329
335
  const v = lookup(scope, path);
330
336
  if (v === undefined)
331
337
  throw new Abort(`field '${path.join(".")}' is missing`);
@@ -345,6 +351,7 @@ export function evaluateExpression(ast, scope) {
345
351
  case "path":
346
352
  return read(node.segments);
347
353
  case "exists": {
354
+ checkInvalid(node.path.segments);
348
355
  const v = lookup(scope, node.path.segments);
349
356
  return v !== undefined && v !== null;
350
357
  }
@@ -56,7 +56,9 @@ export function buildSystemPrompt(skills, existingWorkflow) {
56
56
  "- Use snake_case for node IDs (e.g. gather_errors, create_ticket)",
57
57
  "- Set `entry` to the first node in the flow",
58
58
  "- Only reference skill IDs from the list above in node `skills` arrays",
59
- "- Use natural language for edge `when` conditions",
59
+ '- Prefer `when: { expr: "..." }` expressions for edge conditions: declare the deciding values as enum, number, integer or boolean properties in the source node\'s `output`, then compare them (e.g. `{ "expr": "triage.severity in [\'high\', \'critical\']" }`). Expressions route with no model call and always the same way',
60
+ "- Use a natural-language `when` only for a fuzzy condition no declared field can express",
61
+ "- Put safety conditions (anything that gates a write, a PR or an issue) in expressions, never in natural language",
60
62
  "- Every node must be reachable from the entry node",
61
63
  "- Return ONLY the workflow JSON object — no markdown fences, no explanation",
62
64
  ];