@sweny-ai/core 0.1.25 → 0.1.27

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.
@@ -275,6 +275,92 @@ describe("executor", () => {
275
275
  // All nodes still execute (executor doesn't short-circuit on failure)
276
276
  expect(results.get("a")?.status).toBe("failed");
277
277
  });
278
+ it("executes a retry loop with max_iterations", async () => {
279
+ const retryWorkflow = {
280
+ id: "retry",
281
+ name: "Retry",
282
+ description: "A→B→A (retry once)→B→C",
283
+ entry: "extract",
284
+ nodes: {
285
+ extract: { name: "Extract", instruction: "Extract data", skills: [] },
286
+ judge: { name: "Judge", instruction: "Judge quality", skills: [] },
287
+ done: { name: "Done", instruction: "Finish", skills: [] },
288
+ },
289
+ edges: [
290
+ { from: "extract", to: "judge" },
291
+ { from: "judge", to: "extract", when: "quality is low", max_iterations: 1 },
292
+ { from: "judge", to: "done", when: "quality is high or retries exhausted" },
293
+ ],
294
+ };
295
+ // Track how many times each node runs
296
+ let extractCount = 0;
297
+ let judgeCount = 0;
298
+ const mockClaude = {
299
+ async run(opts) {
300
+ if (opts.instruction.includes("Extract")) {
301
+ extractCount++;
302
+ return { status: "success", data: { extracted: true, attempt: extractCount }, toolCalls: [] };
303
+ }
304
+ if (opts.instruction.includes("Judge")) {
305
+ judgeCount++;
306
+ // First judge: low quality (triggers retry). Second judge: still low but edge exhausted.
307
+ return { status: "success", data: { quality: "low" }, toolCalls: [] };
308
+ }
309
+ return { status: "success", data: {}, toolCalls: [] };
310
+ },
311
+ async evaluate(opts) {
312
+ // Always try to route to extract (retry), but max_iterations will block it on 2nd attempt
313
+ const retryChoice = opts.choices.find((c) => c.id === "extract");
314
+ if (retryChoice)
315
+ return "extract";
316
+ return opts.choices[0].id;
317
+ },
318
+ };
319
+ const results = await execute(retryWorkflow, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
320
+ // extract ran twice (initial + 1 retry), judge ran twice, done ran once
321
+ expect(extractCount).toBe(2);
322
+ expect(judgeCount).toBe(2);
323
+ expect(results.has("done")).toBe(true);
324
+ // The second extract result overwrites the first
325
+ expect(results.get("extract")?.data.attempt).toBe(2);
326
+ });
327
+ it("respects max_iterations=1 as single retry", async () => {
328
+ const singleRetry = {
329
+ id: "single-retry",
330
+ name: "Single Retry",
331
+ description: "A↔B with max 1 loop",
332
+ entry: "a",
333
+ nodes: {
334
+ a: { name: "A", instruction: "Step A", skills: [] },
335
+ b: { name: "B", instruction: "Step B", skills: [] },
336
+ end: { name: "End", instruction: "End", skills: [] },
337
+ },
338
+ edges: [
339
+ { from: "a", to: "b" },
340
+ { from: "b", to: "a", when: "retry", max_iterations: 1 },
341
+ { from: "b", to: "end", when: "done" },
342
+ ],
343
+ };
344
+ let aCount = 0;
345
+ const mockClaude = {
346
+ async run(opts) {
347
+ if (opts.instruction.includes("Step A"))
348
+ aCount++;
349
+ return { status: "success", data: {}, toolCalls: [] };
350
+ },
351
+ async evaluate(opts) {
352
+ // Always try to loop back
353
+ const retry = opts.choices.find((c) => c.id === "a");
354
+ if (retry)
355
+ return "a";
356
+ return opts.choices[0].id;
357
+ },
358
+ };
359
+ const results = await execute(singleRetry, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
360
+ // A runs twice (once initially, once via retry), then edge is exhausted → falls to "end"
361
+ expect(aCount).toBe(2);
362
+ expect(results.has("end")).toBe(true);
363
+ });
278
364
  it("validates route choice falls back on invalid Claude response", async () => {
279
365
  // Create a claude mock that returns an invalid route
280
366
  const badRouteClaude = {
@@ -60,6 +60,16 @@ describe("Zod schemas", () => {
60
60
  it("rejects empty to", () => {
61
61
  expect(() => edgeZ.parse({ from: "a", to: "" })).toThrow();
62
62
  });
63
+ it("parses an edge with max_iterations", () => {
64
+ const result = edgeZ.parse({ from: "a", to: "b", when: "needs retry", max_iterations: 3 });
65
+ expect(result.max_iterations).toBe(3);
66
+ });
67
+ it("rejects max_iterations less than 1", () => {
68
+ expect(() => edgeZ.parse({ from: "a", to: "b", max_iterations: 0 })).toThrow();
69
+ });
70
+ it("rejects non-integer max_iterations", () => {
71
+ expect(() => edgeZ.parse({ from: "a", to: "b", max_iterations: 1.5 })).toThrow();
72
+ });
63
73
  });
64
74
  describe("toolZ", () => {
65
75
  it("parses a valid tool", () => {
@@ -158,7 +168,7 @@ describe("validateWorkflow", () => {
158
168
  const errors = validateWorkflow(bad);
159
169
  expect(errors).toContainEqual(expect.objectContaining({ code: "UNKNOWN_EDGE_TARGET" }));
160
170
  });
161
- it("detects self-loops", () => {
171
+ it("detects self-loops without max_iterations", () => {
162
172
  const loopy = {
163
173
  ...validWorkflow,
164
174
  edges: [
@@ -170,6 +180,42 @@ describe("validateWorkflow", () => {
170
180
  const errors = validateWorkflow(loopy);
171
181
  expect(errors).toContainEqual(expect.objectContaining({ code: "SELF_LOOP", nodeId: "a" }));
172
182
  });
183
+ it("allows self-loops with max_iterations", () => {
184
+ const loopy = {
185
+ ...validWorkflow,
186
+ edges: [
187
+ { from: "a", to: "a", when: "needs retry", max_iterations: 2 },
188
+ { from: "a", to: "b" },
189
+ { from: "b", to: "c" },
190
+ ],
191
+ };
192
+ const errors = validateWorkflow(loopy);
193
+ expect(errors).toEqual([]);
194
+ });
195
+ it("detects unbounded multi-node cycles", () => {
196
+ const cyclic = {
197
+ ...validWorkflow,
198
+ edges: [
199
+ { from: "a", to: "b" },
200
+ { from: "b", to: "a", when: "retry" }, // cycle without max_iterations
201
+ { from: "b", to: "c" },
202
+ ],
203
+ };
204
+ const errors = validateWorkflow(cyclic);
205
+ expect(errors).toContainEqual(expect.objectContaining({ code: "UNBOUNDED_CYCLE" }));
206
+ });
207
+ it("allows multi-node cycles with max_iterations", () => {
208
+ const cyclic = {
209
+ ...validWorkflow,
210
+ edges: [
211
+ { from: "a", to: "b" },
212
+ { from: "b", to: "a", when: "retry", max_iterations: 3 },
213
+ { from: "b", to: "c" },
214
+ ],
215
+ };
216
+ const errors = validateWorkflow(cyclic);
217
+ expect(errors).toEqual([]);
218
+ });
173
219
  it("detects unreachable nodes", () => {
174
220
  const disconnected = {
175
221
  ...validWorkflow,
@@ -231,6 +277,12 @@ describe("workflowJsonSchema", () => {
231
277
  expect(workflowJsonSchema.properties.edges.items.required).toContain("from");
232
278
  expect(workflowJsonSchema.properties.edges.items.required).toContain("to");
233
279
  });
280
+ it("edge items include max_iterations", () => {
281
+ const edgeProps = workflowJsonSchema.properties.edges.items.properties;
282
+ expect(edgeProps.max_iterations).toBeDefined();
283
+ expect(edgeProps.max_iterations.type).toBe("integer");
284
+ expect(edgeProps.max_iterations.minimum).toBe(1);
285
+ });
234
286
  it("node items require name and instruction", () => {
235
287
  expect(workflowJsonSchema.properties.nodes.additionalProperties.required).toContain("name");
236
288
  expect(workflowJsonSchema.properties.nodes.additionalProperties.required).toContain("instruction");
@@ -4,6 +4,7 @@ export declare function loadWorkflowFile(filePath: string): Workflow;
4
4
  export declare function workflowRunAction(file: string, options: Record<string, unknown> & {
5
5
  json?: boolean;
6
6
  stream?: boolean;
7
+ mermaid?: boolean;
7
8
  }): Promise<void>;
8
9
  export declare function workflowExportAction(name: string): void;
9
10
  export declare function workflowValidateAction(file: string, options: {
package/dist/cli/main.js CHANGED
@@ -16,6 +16,7 @@ import { loadAdditionalContext } from "../templates.js";
16
16
  import { validateWorkflow as validateWorkflowSchema } from "../schema.js";
17
17
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
18
18
  import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
19
+ import { toMermaid, toMermaidBlock } from "../mermaid.js";
19
20
  import { DagRenderer } from "./renderer.js";
20
21
  import * as readline from "node:readline";
21
22
  import { loadDotenv, loadConfigFile, STARTER_CONFIG } from "./config-file.js";
@@ -635,6 +636,14 @@ export async function workflowRunAction(file, options) {
635
636
  process.exit(hasFailed ? 1 : 0);
636
637
  return;
637
638
  }
639
+ // Mermaid diagram with execution state
640
+ if (options.mermaid) {
641
+ const state = {};
642
+ for (const [nodeId, result] of results) {
643
+ state[nodeId] = result.status === "success" ? "success" : result.status === "failed" ? "failed" : "skipped";
644
+ }
645
+ process.stdout.write(toMermaidBlock(workflow, { state, title: workflow.name }) + "\n");
646
+ }
638
647
  const hasFailed = [...results.values()].some((r) => r.status === "failed");
639
648
  if (hasFailed) {
640
649
  console.error(chalk.red(` Workflow failed\n`));
@@ -722,8 +731,43 @@ workflowCmd
722
731
  .option("--dry-run", "Validate workflow without running")
723
732
  .option("--json", "Output result as JSON on stdout; suppress progress output")
724
733
  .option("--stream", "Stream NDJSON events to stdout (for Studio / automation)")
734
+ .option("--mermaid", "Output a Mermaid diagram with execution state after run")
725
735
  .option("--input <json>", "JSON string of input data to pass to the workflow")
726
736
  .action(workflowRunAction);
737
+ workflowCmd
738
+ .command("diagram <file>")
739
+ .description("Render a workflow as a Mermaid diagram")
740
+ .option("--direction <dir>", "Graph direction: TB (top-bottom) or LR (left-right)", "TB")
741
+ .option("--title <title>", "Diagram title (defaults to workflow name)")
742
+ .option("--block", "Wrap in ```mermaid fenced code block (default)", true)
743
+ .option("--no-block", "Output raw Mermaid without code fence")
744
+ .action((file, options) => {
745
+ let workflow;
746
+ // Support builtin workflow names
747
+ if (file === "triage") {
748
+ workflow = triageWorkflow;
749
+ }
750
+ else if (file === "implement") {
751
+ workflow = implementWorkflow;
752
+ }
753
+ else if (file === "seed-content") {
754
+ workflow = seedContentWorkflow;
755
+ }
756
+ else {
757
+ try {
758
+ workflow = loadWorkflowFile(file);
759
+ }
760
+ catch (err) {
761
+ console.error(chalk.red(` Error: ${err instanceof Error ? err.message : String(err)}`));
762
+ process.exit(1);
763
+ return;
764
+ }
765
+ }
766
+ const direction = (options.direction === "LR" ? "LR" : "TB");
767
+ const title = options.title ?? workflow.name;
768
+ const render = options.block !== false ? toMermaidBlock : toMermaid;
769
+ process.stdout.write(render(workflow, { direction, title }) + "\n");
770
+ });
727
771
  workflowCmd
728
772
  .command("export <name>")
729
773
  .description("Print a built-in workflow as YAML (triage or implement)")
@@ -1,11 +1,15 @@
1
1
  /**
2
- * DAG Workflow Executor
2
+ * Workflow Executor
3
3
  *
4
4
  * Walks a workflow graph node-by-node. At each node, Claude gets
5
5
  * the node's instruction + available skill tools + context from
6
6
  * prior nodes. Claude does the work, then the executor resolves
7
7
  * which edge to follow next.
8
8
  *
9
+ * Supports controlled cycles via max_iterations on edges — when
10
+ * an edge has been followed max_iterations times, it is excluded
11
+ * from routing, causing flow to fall through to alternative paths.
12
+ *
9
13
  * This replaces ~8k lines of engine + recipe step code.
10
14
  */
11
15
  import type { Workflow, Skill, Claude, Observer, NodeResult, Logger } from "./types.js";
package/dist/executor.js CHANGED
@@ -1,11 +1,15 @@
1
1
  /**
2
- * DAG Workflow Executor
2
+ * Workflow Executor
3
3
  *
4
4
  * Walks a workflow graph node-by-node. At each node, Claude gets
5
5
  * the node's instruction + available skill tools + context from
6
6
  * prior nodes. Claude does the work, then the executor resolves
7
7
  * which edge to follow next.
8
8
  *
9
+ * Supports controlled cycles via max_iterations on edges — when
10
+ * an edge has been followed max_iterations times, it is excluded
11
+ * from routing, causing flow to fall through to alternative paths.
12
+ *
9
13
  * This replaces ~8k lines of engine + recipe step code.
10
14
  */
11
15
  import { consoleLogger } from "./types.js";
@@ -19,6 +23,7 @@ export async function execute(workflow, input, options) {
19
23
  const config = resolveConfig(skills, options.config);
20
24
  const logger = options.logger ?? consoleLogger;
21
25
  const results = new Map();
26
+ const edgeCounts = new Map(); // "from→to" → times followed
22
27
  validate(workflow, skills);
23
28
  safeObserve(observer, { type: "workflow:start", workflow: workflow.id }, logger);
24
29
  let currentId = workflow.entry;
@@ -75,7 +80,7 @@ export async function execute(workflow, input, options) {
75
80
  }
76
81
  }
77
82
  // Resolve next node via edge conditions
78
- currentId = await resolveNext(workflow, currentId, results, input, claude, observer);
83
+ currentId = await resolveNext(workflow, currentId, results, input, claude, observer, edgeCounts, logger);
79
84
  }
80
85
  safeObserve(observer, {
81
86
  type: "workflow:end",
@@ -160,14 +165,31 @@ function resolveConfig(skills, overrides) {
160
165
  * - 0 out-edges → terminal (return null)
161
166
  * - 1 unconditional edge → follow it
162
167
  * - Multiple or conditional → Claude evaluates
168
+ *
169
+ * Edges with max_iterations are filtered out once exhausted.
163
170
  */
164
- async function resolveNext(workflow, current, results, input, claude, observer) {
165
- const outEdges = workflow.edges.filter((e) => e.from === current);
171
+ async function resolveNext(workflow, current, results, input, claude, observer, edgeCounts, logger) {
172
+ // Filter out edges that have exceeded their max_iterations
173
+ const outEdges = workflow.edges.filter((e) => {
174
+ if (e.from !== current)
175
+ return false;
176
+ if (e.max_iterations && edgeCounts) {
177
+ const key = `${e.from}→${e.to}`;
178
+ const count = edgeCounts.get(key) ?? 0;
179
+ if (count >= e.max_iterations)
180
+ return false;
181
+ }
182
+ return true;
183
+ });
166
184
  if (outEdges.length === 0)
167
185
  return null;
168
186
  // Single unconditional edge — just follow it
169
187
  if (outEdges.length === 1 && !outEdges[0].when) {
170
- safeObserve(observer, { type: "route", from: current, to: outEdges[0].to, reason: "only path" });
188
+ if (edgeCounts) {
189
+ const key = `${current}→${outEdges[0].to}`;
190
+ edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
191
+ }
192
+ safeObserve(observer, { type: "route", from: current, to: outEdges[0].to, reason: "only path" }, logger);
171
193
  return outEdges[0].to;
172
194
  }
173
195
  // Check for a default (unconditional) edge among conditionals
@@ -194,12 +216,17 @@ async function resolveNext(workflow, current, results, input, claude, observer)
194
216
  // Validate that Claude returned a valid target
195
217
  const validTargets = new Set(outEdges.map((e) => e.to));
196
218
  const resolved = validTargets.has(chosen) ? chosen : (defaultEdge?.to ?? outEdges[0].to); // fall back to default or first edge
219
+ // Track edge usage for max_iterations
220
+ if (edgeCounts) {
221
+ const key = `${current}→${resolved}`;
222
+ edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
223
+ }
197
224
  safeObserve(observer, {
198
225
  type: "route",
199
226
  from: current,
200
227
  to: resolved,
201
228
  reason: choices.find((c) => c.id === resolved)?.description ?? "default",
202
- });
229
+ }, logger);
203
230
  return resolved;
204
231
  }
205
232
  /**
package/dist/index.d.ts CHANGED
@@ -38,5 +38,7 @@ export { buildWorkflow, refineWorkflow } from "./workflow-builder.js";
38
38
  export type { BuildWorkflowOptions } from "./workflow-builder.js";
39
39
  export { resolveTemplates, loadAdditionalContext, loadTemplate } from "./templates.js";
40
40
  export type { Templates } from "./templates.js";
41
+ export { toMermaid, toMermaidBlock } from "./mermaid.js";
42
+ export type { MermaidOptions, NodeStatus } from "./mermaid.js";
41
43
  export { loadConfigFile } from "./cli/config-file.js";
42
44
  export type { FileConfig } from "./cli/config-file.js";
package/dist/index.js CHANGED
@@ -36,5 +36,7 @@ export { buildAutoMcpServers, buildProviderContext } from "./mcp.js";
36
36
  export { buildWorkflow, refineWorkflow } from "./workflow-builder.js";
37
37
  // Templates
38
38
  export { resolveTemplates, loadAdditionalContext, loadTemplate } from "./templates.js";
39
+ // Mermaid export
40
+ export { toMermaid, toMermaidBlock } from "./mermaid.js";
39
41
  // Config file
40
42
  export { loadConfigFile } from "./cli/config-file.js";
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Mermaid diagram export
3
+ *
4
+ * Converts a Workflow into a Mermaid flowchart string.
5
+ * Renders natively in GitHub (job summaries, READMEs, issues, PRs),
6
+ * VS Code preview, documentation sites, and anywhere Mermaid is supported.
7
+ *
8
+ * Accepts optional execution state to color nodes by status
9
+ * (current/success/failed/skipped).
10
+ */
11
+ import type { Workflow } from "./types.js";
12
+ export type NodeStatus = "current" | "success" | "failed" | "skipped";
13
+ export interface MermaidOptions {
14
+ /** Execution state — nodes not in this map render as default (pending) */
15
+ state?: Record<string, NodeStatus>;
16
+ /** Graph direction: TB (top-bottom) or LR (left-right). Default: TB */
17
+ direction?: "TB" | "LR";
18
+ /** Title rendered above the diagram */
19
+ title?: string;
20
+ }
21
+ /**
22
+ * Convert a Workflow to a Mermaid flowchart diagram string.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { toMermaid } from '@sweny-ai/core';
27
+ *
28
+ * const md = toMermaid(workflow, {
29
+ * state: { triage: 'success', investigate: 'current' },
30
+ * });
31
+ * // Wrap in ```mermaid fence for GitHub rendering
32
+ * ```
33
+ */
34
+ export declare function toMermaid(workflow: Workflow, options?: MermaidOptions): string;
35
+ /**
36
+ * Convenience: wrap toMermaid output in a fenced code block
37
+ * ready for GitHub markdown.
38
+ */
39
+ export declare function toMermaidBlock(workflow: Workflow, options?: MermaidOptions): string;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Mermaid diagram export
3
+ *
4
+ * Converts a Workflow into a Mermaid flowchart string.
5
+ * Renders natively in GitHub (job summaries, READMEs, issues, PRs),
6
+ * VS Code preview, documentation sites, and anywhere Mermaid is supported.
7
+ *
8
+ * Accepts optional execution state to color nodes by status
9
+ * (current/success/failed/skipped).
10
+ */
11
+ /**
12
+ * Convert a Workflow to a Mermaid flowchart diagram string.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { toMermaid } from '@sweny-ai/core';
17
+ *
18
+ * const md = toMermaid(workflow, {
19
+ * state: { triage: 'success', investigate: 'current' },
20
+ * });
21
+ * // Wrap in ```mermaid fence for GitHub rendering
22
+ * ```
23
+ */
24
+ export function toMermaid(workflow, options = {}) {
25
+ const { state = {}, direction = "TB", title } = options;
26
+ const lines = [];
27
+ if (title) {
28
+ lines.push("---");
29
+ lines.push(`title: ${title}`);
30
+ lines.push("---");
31
+ }
32
+ lines.push(`graph ${direction}`);
33
+ // Nodes
34
+ for (const [id, node] of Object.entries(workflow.nodes)) {
35
+ const label = escapeLabel(node.name);
36
+ const isEntry = id === workflow.entry;
37
+ const entryTag = isEntry ? " \\u25B6" : "";
38
+ // Use stadium shape ([...]) for entry, rounded ([(...)]) for others
39
+ if (isEntry) {
40
+ lines.push(` ${sanitizeId(id)}([${label}${entryTag}])`);
41
+ }
42
+ else {
43
+ lines.push(` ${sanitizeId(id)}[${label}]`);
44
+ }
45
+ }
46
+ lines.push("");
47
+ // Edges
48
+ for (const edge of workflow.edges) {
49
+ const from = sanitizeId(edge.from);
50
+ const to = sanitizeId(edge.to);
51
+ if (edge.when) {
52
+ const label = escapeLabel(edge.when);
53
+ if (edge.max_iterations) {
54
+ lines.push(` ${from} -->|"${label} (max ${edge.max_iterations}x)"| ${to}`);
55
+ }
56
+ else {
57
+ lines.push(` ${from} -->|"${label}"| ${to}`);
58
+ }
59
+ }
60
+ else if (edge.max_iterations) {
61
+ lines.push(` ${from} -->|"max ${edge.max_iterations}x"| ${to}`);
62
+ }
63
+ else {
64
+ lines.push(` ${from} --> ${to}`);
65
+ }
66
+ }
67
+ // Status styling
68
+ const statusNodes = groupByStatus(state);
69
+ if (statusNodes.size > 0) {
70
+ lines.push("");
71
+ // Define style classes
72
+ lines.push(" classDef current fill:#3b82f6,stroke:#2563eb,color:#fff,stroke-width:2px");
73
+ lines.push(" classDef success fill:#22c55e,stroke:#16a34a,color:#fff,stroke-width:2px");
74
+ lines.push(" classDef failed fill:#ef4444,stroke:#dc2626,color:#fff,stroke-width:2px");
75
+ lines.push(" classDef skipped fill:#6b7280,stroke:#4b5563,color:#fff,stroke-dasharray:5 5");
76
+ for (const [status, ids] of statusNodes) {
77
+ const sanitized = ids.map(sanitizeId).join(",");
78
+ lines.push(` class ${sanitized} ${status}`);
79
+ }
80
+ }
81
+ return lines.join("\n");
82
+ }
83
+ /**
84
+ * Convenience: wrap toMermaid output in a fenced code block
85
+ * ready for GitHub markdown.
86
+ */
87
+ export function toMermaidBlock(workflow, options = {}) {
88
+ return "```mermaid\n" + toMermaid(workflow, options) + "\n```";
89
+ }
90
+ // ─── Helpers ───────────────────────────────────────────────────────
91
+ /** Mermaid node IDs must be alphanumeric + underscores + hyphens */
92
+ function sanitizeId(id) {
93
+ return id.replace(/[^a-zA-Z0-9_-]/g, "_");
94
+ }
95
+ /** Escape characters that break Mermaid labels */
96
+ function escapeLabel(text) {
97
+ return text.replace(/"/g, "&quot;").replace(/\[/g, "&#91;").replace(/\]/g, "&#93;");
98
+ }
99
+ /** Group node IDs by their status */
100
+ function groupByStatus(state) {
101
+ const groups = new Map();
102
+ for (const [id, status] of Object.entries(state)) {
103
+ let list = groups.get(status);
104
+ if (!list) {
105
+ list = [];
106
+ groups.set(status, list);
107
+ }
108
+ list.push(id);
109
+ }
110
+ return groups;
111
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,217 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { toMermaid, toMermaidBlock } from "./mermaid.js";
3
+ import { triageWorkflow, implementWorkflow } from "./workflows/index.js";
4
+ // ─── Fixtures ──────────────────────────────────────────────────────
5
+ const simple = {
6
+ id: "simple",
7
+ name: "Simple",
8
+ description: "A→B→C",
9
+ entry: "a",
10
+ nodes: {
11
+ a: { name: "Triage", instruction: "Triage the alert", skills: ["github"] },
12
+ b: { name: "Investigate", instruction: "Investigate the issue", skills: ["sentry"] },
13
+ c: { name: "Report", instruction: "Write report", skills: ["slack"] },
14
+ },
15
+ edges: [
16
+ { from: "a", to: "b" },
17
+ { from: "b", to: "c" },
18
+ ],
19
+ };
20
+ const branching = {
21
+ id: "branching",
22
+ name: "Branching",
23
+ description: "Conditional routing",
24
+ entry: "triage",
25
+ nodes: {
26
+ triage: { name: "Triage", instruction: "Classify severity", skills: [] },
27
+ investigate: { name: "Investigate", instruction: "Deep dive", skills: [] },
28
+ notify: { name: "Notify", instruction: "Send alert", skills: [] },
29
+ close: { name: "Close", instruction: "Close ticket", skills: [] },
30
+ },
31
+ edges: [
32
+ { from: "triage", to: "investigate", when: "severity is high or critical" },
33
+ { from: "triage", to: "notify", when: "severity is low" },
34
+ { from: "investigate", to: "close" },
35
+ { from: "notify", to: "close" },
36
+ ],
37
+ };
38
+ const withLoop = {
39
+ id: "loop",
40
+ name: "Loop",
41
+ description: "Retry loop",
42
+ entry: "a",
43
+ nodes: {
44
+ a: { name: "Try", instruction: "Attempt fix", skills: [] },
45
+ b: { name: "Done", instruction: "Wrap up", skills: [] },
46
+ },
47
+ edges: [
48
+ { from: "a", to: "a", when: "needs retry", max_iterations: 3 },
49
+ { from: "a", to: "b" },
50
+ ],
51
+ };
52
+ // ─── Tests ─────────────────────────────────────────────────────────
53
+ describe("toMermaid", () => {
54
+ it("renders a simple linear workflow", () => {
55
+ const result = toMermaid(simple);
56
+ expect(result).toContain("graph TB");
57
+ // Entry node gets stadium shape
58
+ expect(result).toContain("a([Triage \\u25B6])");
59
+ // Non-entry nodes get default shape
60
+ expect(result).toContain("b[Investigate]");
61
+ expect(result).toContain("c[Report]");
62
+ // Edges
63
+ expect(result).toContain("a --> b");
64
+ expect(result).toContain("b --> c");
65
+ });
66
+ it("renders conditional edges with labels", () => {
67
+ const result = toMermaid(branching);
68
+ expect(result).toContain('triage -->|"severity is high or critical"| investigate');
69
+ expect(result).toContain('triage -->|"severity is low"| notify');
70
+ expect(result).toContain("investigate --> close");
71
+ expect(result).toContain("notify --> close");
72
+ });
73
+ it("renders max_iterations on edges", () => {
74
+ const result = toMermaid(withLoop);
75
+ expect(result).toContain('a -->|"needs retry (max 3x)"| a');
76
+ expect(result).toContain("a --> b");
77
+ });
78
+ it("renders max_iterations without condition", () => {
79
+ const wf = {
80
+ ...simple,
81
+ edges: [
82
+ { from: "a", to: "b", max_iterations: 5 },
83
+ { from: "b", to: "c" },
84
+ ],
85
+ };
86
+ const result = toMermaid(wf);
87
+ expect(result).toContain('a -->|"max 5x"| b');
88
+ });
89
+ it("applies execution state styling", () => {
90
+ const result = toMermaid(simple, {
91
+ state: {
92
+ a: "success",
93
+ b: "current",
94
+ },
95
+ });
96
+ expect(result).toContain("classDef current fill:#3b82f6");
97
+ expect(result).toContain("classDef success fill:#22c55e");
98
+ expect(result).toContain("class a success");
99
+ expect(result).toContain("class b current");
100
+ // c has no state — no class applied
101
+ expect(result).not.toContain("class c");
102
+ });
103
+ it("applies failed and skipped styling", () => {
104
+ const result = toMermaid(simple, {
105
+ state: {
106
+ a: "success",
107
+ b: "failed",
108
+ c: "skipped",
109
+ },
110
+ });
111
+ expect(result).toContain("classDef failed fill:#ef4444");
112
+ expect(result).toContain("classDef skipped fill:#6b7280");
113
+ expect(result).toContain("class b failed");
114
+ expect(result).toContain("class c skipped");
115
+ });
116
+ it("omits style block when no state provided", () => {
117
+ const result = toMermaid(simple);
118
+ expect(result).not.toContain("classDef");
119
+ expect(result).not.toContain("class ");
120
+ });
121
+ it("supports LR direction", () => {
122
+ const result = toMermaid(simple, { direction: "LR" });
123
+ expect(result).toContain("graph LR");
124
+ });
125
+ it("supports title", () => {
126
+ const result = toMermaid(simple, { title: "My Workflow" });
127
+ expect(result).toContain("---");
128
+ expect(result).toContain("title: My Workflow");
129
+ });
130
+ it("sanitizes node IDs with special characters", () => {
131
+ const wf = {
132
+ id: "special",
133
+ name: "Special",
134
+ description: "",
135
+ entry: "step.one",
136
+ nodes: {
137
+ "step.one": { name: "Step One", instruction: "Do it", skills: [] },
138
+ "step two": { name: "Step Two", instruction: "Do it", skills: [] },
139
+ },
140
+ edges: [{ from: "step.one", to: "step two" }],
141
+ };
142
+ const result = toMermaid(wf);
143
+ expect(result).toContain("step_one");
144
+ expect(result).toContain("step_two");
145
+ expect(result).not.toContain("step.one");
146
+ expect(result).not.toContain("step two");
147
+ });
148
+ it("escapes special characters in labels", () => {
149
+ const wf = {
150
+ id: "escape",
151
+ name: "Escape",
152
+ description: "",
153
+ entry: "a",
154
+ nodes: {
155
+ a: { name: 'Say "hello"', instruction: "x", skills: [] },
156
+ b: { name: "Done", instruction: "x", skills: [] },
157
+ },
158
+ edges: [{ from: "a", to: "b" }],
159
+ };
160
+ const result = toMermaid(wf);
161
+ expect(result).toContain("&quot;");
162
+ expect(result).not.toContain('Say "hello"');
163
+ });
164
+ it("groups multiple nodes with same status", () => {
165
+ const result = toMermaid(simple, {
166
+ state: { a: "success", b: "success", c: "current" },
167
+ });
168
+ expect(result).toContain("class a,b success");
169
+ expect(result).toContain("class c current");
170
+ });
171
+ });
172
+ describe("builtin workflows", () => {
173
+ it("renders triage workflow", () => {
174
+ const result = toMermaid(triageWorkflow);
175
+ expect(result).toContain("graph TB");
176
+ // Every node should appear
177
+ for (const [id, node] of Object.entries(triageWorkflow.nodes)) {
178
+ expect(result).toContain(node.name);
179
+ }
180
+ // Every edge should appear
181
+ for (const edge of triageWorkflow.edges) {
182
+ expect(result).toContain(edge.from);
183
+ expect(result).toContain(edge.to);
184
+ }
185
+ });
186
+ it("renders implement workflow", () => {
187
+ const result = toMermaid(implementWorkflow);
188
+ expect(result).toContain("graph TB");
189
+ for (const node of Object.values(implementWorkflow.nodes)) {
190
+ expect(result).toContain(node.name);
191
+ }
192
+ });
193
+ it("renders triage with execution state", () => {
194
+ const ids = Object.keys(triageWorkflow.nodes);
195
+ const state = {};
196
+ state[ids[0]] = "success";
197
+ if (ids[1])
198
+ state[ids[1]] = "current";
199
+ const result = toMermaid(triageWorkflow, { state, title: triageWorkflow.name });
200
+ expect(result).toContain("classDef success");
201
+ expect(result).toContain("classDef current");
202
+ expect(result).toContain(`class ${ids[0]} success`);
203
+ });
204
+ });
205
+ describe("toMermaidBlock", () => {
206
+ it("wraps in fenced code block", () => {
207
+ const result = toMermaidBlock(simple);
208
+ expect(result).toMatch(/^```mermaid\n/);
209
+ expect(result).toMatch(/\n```$/);
210
+ expect(result).toContain("graph TB");
211
+ });
212
+ it("passes options through", () => {
213
+ const result = toMermaidBlock(simple, { direction: "LR", state: { a: "success" } });
214
+ expect(result).toContain("graph LR");
215
+ expect(result).toContain("class a success");
216
+ });
217
+ });
package/dist/schema.d.ts CHANGED
@@ -109,12 +109,12 @@ export declare const nodeZ: z.ZodObject<{
109
109
  output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
110
110
  }, "strip", z.ZodTypeAny, {
111
111
  skills: string[];
112
- name: string;
113
112
  instruction: string;
113
+ name: string;
114
114
  output?: Record<string, unknown> | undefined;
115
115
  }, {
116
- name: string;
117
116
  instruction: string;
117
+ name: string;
118
118
  skills?: string[] | undefined;
119
119
  output?: Record<string, unknown> | undefined;
120
120
  }>;
@@ -122,14 +122,17 @@ export declare const edgeZ: z.ZodObject<{
122
122
  from: z.ZodString;
123
123
  to: z.ZodString;
124
124
  when: z.ZodOptional<z.ZodString>;
125
+ max_iterations: z.ZodOptional<z.ZodNumber>;
125
126
  }, "strip", z.ZodTypeAny, {
126
- from: string;
127
127
  to: string;
128
+ from: string;
128
129
  when?: string | undefined;
130
+ max_iterations?: number | undefined;
129
131
  }, {
130
- from: string;
131
132
  to: string;
133
+ from: string;
132
134
  when?: string | undefined;
135
+ max_iterations?: number | undefined;
133
136
  }>;
134
137
  export declare const workflowZ: z.ZodObject<{
135
138
  id: z.ZodString;
@@ -142,12 +145,12 @@ export declare const workflowZ: z.ZodObject<{
142
145
  output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
143
146
  }, "strip", z.ZodTypeAny, {
144
147
  skills: string[];
145
- name: string;
146
148
  instruction: string;
149
+ name: string;
147
150
  output?: Record<string, unknown> | undefined;
148
151
  }, {
149
- name: string;
150
152
  instruction: string;
153
+ name: string;
151
154
  skills?: string[] | undefined;
152
155
  output?: Record<string, unknown> | undefined;
153
156
  }>>;
@@ -155,14 +158,17 @@ export declare const workflowZ: z.ZodObject<{
155
158
  from: z.ZodString;
156
159
  to: z.ZodString;
157
160
  when: z.ZodOptional<z.ZodString>;
161
+ max_iterations: z.ZodOptional<z.ZodNumber>;
158
162
  }, "strip", z.ZodTypeAny, {
159
- from: string;
160
163
  to: string;
164
+ from: string;
161
165
  when?: string | undefined;
166
+ max_iterations?: number | undefined;
162
167
  }, {
163
- from: string;
164
168
  to: string;
169
+ from: string;
165
170
  when?: string | undefined;
171
+ max_iterations?: number | undefined;
166
172
  }>, "many">;
167
173
  entry: z.ZodString;
168
174
  }, "strip", z.ZodTypeAny, {
@@ -171,29 +177,31 @@ export declare const workflowZ: z.ZodObject<{
171
177
  description: string;
172
178
  nodes: Record<string, {
173
179
  skills: string[];
174
- name: string;
175
180
  instruction: string;
181
+ name: string;
176
182
  output?: Record<string, unknown> | undefined;
177
183
  }>;
178
184
  edges: {
179
- from: string;
180
185
  to: string;
186
+ from: string;
181
187
  when?: string | undefined;
188
+ max_iterations?: number | undefined;
182
189
  }[];
183
190
  entry: string;
184
191
  }, {
185
192
  id: string;
186
193
  name: string;
187
194
  nodes: Record<string, {
188
- name: string;
189
195
  instruction: string;
196
+ name: string;
190
197
  skills?: string[] | undefined;
191
198
  output?: Record<string, unknown> | undefined;
192
199
  }>;
193
200
  edges: {
194
- from: string;
195
201
  to: string;
202
+ from: string;
196
203
  when?: string | undefined;
204
+ max_iterations?: number | undefined;
197
205
  }[];
198
206
  entry: string;
199
207
  description?: string | undefined;
@@ -205,19 +213,20 @@ export declare function parseWorkflow(raw: unknown): {
205
213
  description: string;
206
214
  nodes: Record<string, {
207
215
  skills: string[];
208
- name: string;
209
216
  instruction: string;
217
+ name: string;
210
218
  output?: Record<string, unknown> | undefined;
211
219
  }>;
212
220
  edges: {
213
- from: string;
214
221
  to: string;
222
+ from: string;
215
223
  when?: string | undefined;
224
+ max_iterations?: number | undefined;
216
225
  }[];
217
226
  entry: string;
218
227
  };
219
228
  export interface WorkflowError {
220
- code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "NO_OUTGOING_EDGE";
229
+ code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE";
221
230
  message: string;
222
231
  nodeId?: string;
223
232
  }
@@ -229,7 +238,6 @@ export interface WorkflowError {
229
238
  * - All edge sources and targets reference existing nodes
230
239
  * - No self-loops
231
240
  * - All nodes are reachable from entry
232
- * - All non-terminal nodes have at least one outgoing edge
233
241
  * - (optional) All referenced skills exist in the provided skill set
234
242
  */
235
243
  export declare function validateWorkflow(workflow: z.infer<typeof workflowZ>, knownSkills?: Set<string>): WorkflowError[];
@@ -241,7 +249,7 @@ export declare const workflowJsonSchema: {
241
249
  readonly $schema: "https://json-schema.org/draft/2020-12/schema";
242
250
  readonly $id: "https://sweny.ai/schemas/workflow.json";
243
251
  readonly title: "SWEny Workflow";
244
- readonly description: "A DAG workflow definition for skill-based orchestration";
252
+ readonly description: "A workflow definition for skill-based orchestration (supports controlled cycles via max_iterations)";
245
253
  readonly type: "object";
246
254
  readonly required: readonly ["id", "name", "nodes", "edges", "entry"];
247
255
  readonly additionalProperties: false;
@@ -311,6 +319,11 @@ export declare const workflowJsonSchema: {
311
319
  readonly type: "string";
312
320
  readonly description: "Natural language condition — Claude evaluates at runtime";
313
321
  };
322
+ readonly max_iterations: {
323
+ readonly type: "integer";
324
+ readonly minimum: 1;
325
+ readonly description: "Max times this edge can be followed — enables controlled retry loops";
326
+ };
314
327
  };
315
328
  };
316
329
  };
package/dist/schema.js CHANGED
@@ -43,6 +43,7 @@ export const edgeZ = z.object({
43
43
  from: z.string().min(1),
44
44
  to: z.string().min(1),
45
45
  when: z.string().optional(),
46
+ max_iterations: z.number().int().min(1).optional(),
46
47
  });
47
48
  export const workflowZ = z.object({
48
49
  id: z.string().min(1),
@@ -64,7 +65,6 @@ export function parseWorkflow(raw) {
64
65
  * - All edge sources and targets reference existing nodes
65
66
  * - No self-loops
66
67
  * - All nodes are reachable from entry
67
- * - All non-terminal nodes have at least one outgoing edge
68
68
  * - (optional) All referenced skills exist in the provided skill set
69
69
  */
70
70
  export function validateWorkflow(workflow, knownSkills) {
@@ -93,10 +93,10 @@ export function validateWorkflow(workflow, knownSkills) {
93
93
  nodeId: edge.to,
94
94
  });
95
95
  }
96
- if (edge.from === edge.to) {
96
+ if (edge.from === edge.to && !edge.max_iterations) {
97
97
  errors.push({
98
98
  code: "SELF_LOOP",
99
- message: `Edge from "${edge.from}" to itself`,
99
+ message: `Edge from "${edge.from}" to itself (add max_iterations to allow)`,
100
100
  nodeId: edge.from,
101
101
  });
102
102
  }
@@ -127,12 +127,41 @@ export function validateWorkflow(workflow, knownSkills) {
127
127
  });
128
128
  }
129
129
  }
130
- // Non-terminal nodes must have outgoing edges
131
- for (const nodeId of nodeIds) {
132
- const hasOutgoing = workflow.edges.some((e) => e.from === nodeId);
133
- if (!hasOutgoing && visited.has(nodeId)) {
134
- // Terminal node — fine, no error. This is intentional.
135
- // But if it has outgoing in the graph, it's fine too.
130
+ // Detect unbounded cycles: the graph with max_iterations edges removed must be acyclic.
131
+ // If removing bounded edges still leaves a cycle, it can loop forever.
132
+ const unboundedEdges = workflow.edges.filter((e) => !e.max_iterations && e.from !== e.to);
133
+ // DFS-based cycle detection on the unbounded subgraph
134
+ const WHITE = 0, GRAY = 1, BLACK = 2;
135
+ const color = new Map();
136
+ for (const id of nodeIds)
137
+ color.set(id, WHITE);
138
+ function dfs(node) {
139
+ color.set(node, GRAY);
140
+ for (const e of unboundedEdges) {
141
+ if (e.from !== node)
142
+ continue;
143
+ const c = color.get(e.to);
144
+ if (c === GRAY)
145
+ return e.to; // back-edge found → cycle
146
+ if (c === WHITE) {
147
+ const cycle = dfs(e.to);
148
+ if (cycle)
149
+ return cycle;
150
+ }
151
+ }
152
+ color.set(node, BLACK);
153
+ return null;
154
+ }
155
+ for (const id of nodeIds) {
156
+ if (color.get(id) === WHITE) {
157
+ const cycleNode = dfs(id);
158
+ if (cycleNode) {
159
+ errors.push({
160
+ code: "UNBOUNDED_CYCLE",
161
+ message: `Unbounded cycle detected involving node "${cycleNode}" — add max_iterations to at least one edge in the cycle`,
162
+ nodeId: cycleNode,
163
+ });
164
+ }
136
165
  }
137
166
  }
138
167
  // Skill references
@@ -160,7 +189,7 @@ export const workflowJsonSchema = {
160
189
  $schema: "https://json-schema.org/draft/2020-12/schema",
161
190
  $id: "https://sweny.ai/schemas/workflow.json",
162
191
  title: "SWEny Workflow",
163
- description: "A DAG workflow definition for skill-based orchestration",
192
+ description: "A workflow definition for skill-based orchestration (supports controlled cycles via max_iterations)",
164
193
  type: "object",
165
194
  required: ["id", "name", "nodes", "edges", "entry"],
166
195
  additionalProperties: false,
@@ -200,6 +229,11 @@ export const workflowJsonSchema = {
200
229
  from: { type: "string", minLength: 1 },
201
230
  to: { type: "string", minLength: 1 },
202
231
  when: { type: "string", description: "Natural language condition — Claude evaluates at runtime" },
232
+ max_iterations: {
233
+ type: "integer",
234
+ minimum: 1,
235
+ description: "Max times this edge can be followed — enables controlled retry loops",
236
+ },
203
237
  },
204
238
  },
205
239
  },
@@ -123,15 +123,29 @@ export const github = {
123
123
  },
124
124
  required: ["repo", "title", "head"],
125
125
  },
126
- handler: async (input, ctx) => gh(`/repos/${input.repo}/pulls`, ctx, {
127
- method: "POST",
128
- body: JSON.stringify({
129
- title: input.title,
130
- body: input.body,
131
- head: input.head,
132
- base: input.base ?? "main",
133
- }),
134
- }),
126
+ handler: async (input, ctx) => {
127
+ const pr = (await gh(`/repos/${input.repo}/pulls`, ctx, {
128
+ method: "POST",
129
+ body: JSON.stringify({
130
+ title: input.title,
131
+ body: input.body,
132
+ head: input.head,
133
+ base: input.base ?? "main",
134
+ }),
135
+ }));
136
+ try {
137
+ if (pr.number) {
138
+ await gh(`/repos/${input.repo}/issues/${pr.number}/labels`, ctx, {
139
+ method: "POST",
140
+ body: JSON.stringify({ labels: ["sweny"] }),
141
+ });
142
+ }
143
+ }
144
+ catch {
145
+ // Label failure is non-fatal
146
+ }
147
+ return pr;
148
+ },
135
149
  },
136
150
  {
137
151
  name: "github_list_recent_commits",
package/dist/types.d.ts CHANGED
@@ -48,6 +48,8 @@ export interface Edge {
48
48
  to: string;
49
49
  /** Natural language condition — Claude evaluates at runtime */
50
50
  when?: string;
51
+ /** Max times this edge can be followed (enables retry loops). Default: unlimited. */
52
+ max_iterations?: number;
51
53
  }
52
54
  /** A complete workflow definition — pure data, fully serializable */
53
55
  export interface Workflow {
@@ -1,3 +1,3 @@
1
- export const triageWorkflow = {"id":"triage","name":"Alert Triage","description":"Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team","entry":"prepare","nodes":{"prepare":{"name":"Load Rules & Context","instruction":"You are preparing for a triage workflow. Your job is to fetch and review any knowledge documents listed in the input.\n\n1. Check input for `rulesUrls`, `contextUrls`, `rules_urls`, and `context_urls` arrays (any of these may be present).\n2. For each URL, fetch its content:\n - Linear document URLs → use the Linear MCP tools (get_document or search_documentation)\n - Other HTTP URLs → fetch directly\n3. Summarize the key rules and context that downstream workflow nodes should follow.\n4. Output the consolidated information so it's available to all subsequent steps.\n\nIf there are no URLs to fetch, just pass through — this step is a no-op.","skills":["linear"]},"gather":{"name":"Gather Context","instruction":"You are investigating a production alert. Gather all relevant context using the available tools:\n\n1. **Observability**: Pull error details, stack traces, recent logs, metrics, and active incidents around the time of the alert. Use whichever observability tools are available to you.\n2. **Source control**: Check recent commits, pull requests, and deploys that might be related.\n3. **Issue tracker**: Search for similar past issues or known problems.\n\nBe thorough — the investigation step depends on complete context. Use every tool available to you.","skills":["github","sentry","datadog","betterstack","linear"]},"investigate":{"name":"Root Cause Analysis","instruction":"Based on the gathered context, classify every distinct issue you found into one of two buckets: **novel** or **duplicate**.\n\nFor EACH issue found:\n1. Identify the root cause and affected code/service.\n2. Assess severity: critical (service down), high (major feature broken), medium (degraded), low (cosmetic/minor).\n3. Assess fix complexity: \"simple\" (a few lines, clear change), \"moderate\" (multiple files but well-understood), or \"complex\" (architectural, risky, or unclear).\n4. **Novelty check (REQUIRED):** Search the issue tracker for existing issues (BOTH open AND closed) that cover the same root cause, error pattern, or affected service. Use github_search_issues and/or linear_search_issues with multiple keyword variations.\n - A match = same root cause, same error message/pattern, or a human would call it \"the same bug.\"\n - If matched → it's a **duplicate**. Record the existing issue ID.\n - If no match → it's **novel**.\n\n**Output rules:**\n- `findings`: array of ALL issues found (both novel and duplicate).\n- `novel_count`: how many findings are novel (not duplicates).\n- `highest_severity`: the highest severity across ALL findings.\n- `recommendation`: what should happen next.\n\nDownstream nodes will act ONLY on novel findings. Duplicates will be +1'd automatically.","skills":["github","linear"],"output":{"type":"object","properties":{"findings":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string","description":"Short description of the issue"},"root_cause":{"type":"string"},"severity":{"type":"string","enum":["critical","high","medium","low"]},"affected_services":{"type":"array","items":{"type":"string"}},"is_duplicate":{"type":"boolean"},"duplicate_of":{"type":"string","description":"Existing issue ID/URL if duplicate"},"fix_approach":{"type":"string"},"fix_complexity":{"type":"string","enum":["simple","moderate","complex"]}},"required":["title","root_cause","severity","is_duplicate"]}},"novel_count":{"type":"number","description":"Count of novel (non-duplicate) findings"},"highest_severity":{"type":"string","enum":["critical","high","medium","low"]},"recommendation":{"type":"string"}},"required":["findings","novel_count","highest_severity","recommendation"]}},"create_issue":{"name":"Create Issues & Triage Duplicates","instruction":"Process ALL findings from the investigation. The findings array contains both novel and duplicate issues.\n\n**For each NOVEL finding** (is_duplicate = false):\n1. Create a new issue with a clear, actionable title.\n2. Include: root cause, severity, affected services, reproduction steps, and recommended fix.\n3. Add appropriate labels (bug, severity level, affected service).\n4. Link to relevant commits, PRs, or existing issues.\n\n**For each DUPLICATE finding** (is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the existing issue is closed/done, reopen it.\n\nIf context.issueTemplate is provided, use it as the format for new issue bodies. Otherwise use a clear structure with: Summary, Root Cause, Impact, Steps to Reproduce, and Recommended Fix.\n\nUse the issue tracker specified in context (e.g. Linear, Jira, or GitHub Issues) — do NOT fall back to a different tracker. Output the created/updated issue identifiers.","skills":["linear","github"]},"skip":{"name":"Skip — All Duplicates or Low Priority","instruction":"Every finding from the investigation was either a duplicate or low-priority. No new issues need to be created.\n\nFor each **duplicate** finding (check the findings array for items where is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the issue is closed/done, reopen it.\n\nFor **low priority** findings, log a brief note about why they were skipped.","skills":["linear","github"]},"implement":{"name":"Implement Fix","instruction":"Implement the fix identified during investigation:\n\n1. Create a feature branch from the base branch (check context for baseBranch, default \"main\").\n2. Read the relevant source files to understand the current code.\n3. Make the necessary code changes — fix the bug, nothing more.\n4. Run any existing tests if available to verify the fix doesn't break anything.\n5. Stage and commit with a clear commit message referencing the issue.\n\nKeep changes minimal and focused. Do not refactor surrounding code or add unrelated improvements.\n\nIf the fix turns out to be more complex than expected, stop and explain why — do not force a bad fix.","skills":["github"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create a PR with a clear title referencing the issue (e.g. \"[OFF-1020] fix: guard empty pdf_texts before access\").\n3. In the PR body, include: summary of the bug, what the fix does, and a link to the issue.\n4. Add appropriate labels if available.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nReturn the PR URL and number.","skills":["github"]},"notify":{"name":"Notify Team","instruction":"Send a notification summarizing the triage result:\n\n1. Include: alert summary, severity, root cause (1-2 sentences), and links to any created issues or PRs.\n2. For critical/high severity, make the notification urgent.\n3. For medium/low, a standard notification is fine.\n\nUse whichever notification channel is available to you.","skills":["slack","notification"]}},"edges":[{"from":"prepare","to":"gather"},{"from":"gather","to":"investigate"},{"from":"investigate","to":"create_issue","when":"novel_count is greater than 0 AND highest_severity is medium or higher"},{"from":"investigate","to":"skip","when":"novel_count is 0, OR highest_severity is low"},{"from":"create_issue","to":"implement","when":"at least one novel finding has fix_complexity simple or moderate AND fix_approach is provided"},{"from":"create_issue","to":"notify","when":"all novel findings have fix_complexity complex, OR no clear fix_approach"},{"from":"skip","to":"notify"},{"from":"implement","to":"create_pr"},{"from":"create_pr","to":"notify"}]};
2
- export const implementWorkflow = {"id":"implement","name":"Implement Fix","description":"Implement a code fix for an issue and open a pull request","entry":"analyze","nodes":{"analyze":{"name":"Analyze Issue","instruction":"Read the issue details and understand what needs to be fixed:\n\n1. Fetch the issue from the tracker (GitHub or Linear).\n2. Read the relevant source files to understand the current code.\n3. Identify the exact files and lines that need to change.\n4. Plan the fix approach.\n\nOutput a clear analysis of what needs to change and why.","skills":["github","linear"],"output":{"type":"object","properties":{"issue_summary":{"type":"string"},"files_to_change":{"type":"array","items":{"type":"string"}},"fix_plan":{"type":"string"},"risk_level":{"type":"string","enum":["low","medium","high"]}},"required":["issue_summary","fix_plan"]}},"implement":{"name":"Implement Fix","instruction":"Implement the planned fix:\n\n1. Create a feature branch.\n2. Make the necessary code changes.\n3. Ensure changes are minimal and focused — fix the bug, nothing more.\n4. Stage and commit with a clear commit message referencing the issue.\n\nIf the fix is too risky or complex, explain why and skip.","skills":["github"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create a PR with a clear title and description.\n3. Reference the original issue in the PR body.\n4. Add appropriate reviewers or labels if possible.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nReturn the PR URL.","skills":["github"]},"notify":{"name":"Notify","instruction":"Send a notification about the implementation result:\n\n1. Include: issue reference, PR link, brief summary of changes.\n2. Keep it concise — one message, not a wall of text.","skills":["slack","notification"]},"skip":{"name":"Skip — Too Complex","instruction":"The fix was determined to be too complex or risky for automated implementation.\nAdd a comment to the issue explaining what was found and why it needs manual attention.","skills":["github","linear"]}},"edges":[{"from":"analyze","to":"implement","when":"Fix risk level is low or medium and a clear plan exists"},{"from":"analyze","to":"skip","when":"Fix is too complex, risky, or unclear"},{"from":"implement","to":"create_pr"},{"from":"skip","to":"notify"},{"from":"create_pr","to":"notify"}]};
1
+ export const triageWorkflow = {"id":"triage","name":"Alert Triage","description":"Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team","entry":"prepare","nodes":{"prepare":{"name":"Load Rules & Context","instruction":"You are preparing for a triage workflow. Your job is to fetch and review any knowledge documents listed in the input.\n\n1. Check input for `rulesUrls`, `contextUrls`, `rules_urls`, and `context_urls` arrays (any of these may be present).\n2. For each URL, fetch its content:\n - Linear document URLs → use the Linear MCP tools (get_document or search_documentation)\n - Other HTTP URLs → fetch directly\n3. Summarize the key rules and context that downstream workflow nodes should follow.\n4. Output the consolidated information so it's available to all subsequent steps.\n\nIf there are no URLs to fetch, just pass through — this step is a no-op.","skills":["linear"]},"gather":{"name":"Gather Context","instruction":"You are investigating a production alert. Gather all relevant context using the available tools:\n\n1. **Observability**: Pull error details, stack traces, recent logs, metrics, and active incidents around the time of the alert. Use whichever observability tools are available to you.\n2. **Source control**: Check recent commits, pull requests, and deploys that might be related.\n3. **Issue tracker**: Search for similar past issues or known problems.\n\nBe thorough — the investigation step depends on complete context. Use every tool available to you.","skills":["github","sentry","datadog","betterstack","linear"]},"investigate":{"name":"Root Cause Analysis","instruction":"Based on the gathered context, classify every distinct issue you found into one of two buckets: **novel** or **duplicate**.\n\nFor EACH issue found:\n1. Identify the root cause and affected code/service.\n2. Assess severity: critical (service down), high (major feature broken), medium (degraded), low (cosmetic/minor).\n3. Assess fix complexity: \"simple\" (a few lines, clear change), \"moderate\" (multiple files but well-understood), or \"complex\" (architectural, risky, or unclear).\n4. **Novelty check (REQUIRED):** Search the issue tracker for existing issues (BOTH open AND closed) that cover the same root cause, error pattern, or affected service. Use github_search_issues and/or linear_search_issues with multiple keyword variations.\n - A match = same root cause, same error message/pattern, or a human would call it \"the same bug.\"\n - If matched → it's a **duplicate**. Record the existing issue ID.\n - If no match → it's **novel**.\n\n**Output rules:**\n- `findings`: array of ALL issues found (both novel and duplicate).\n- `novel_count`: how many findings are novel (not duplicates).\n- `highest_severity`: the highest severity across ALL findings.\n- `recommendation`: what should happen next.\n\nDownstream nodes will act ONLY on novel findings. Duplicates will be +1'd automatically.","skills":["github","linear"],"output":{"type":"object","properties":{"findings":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string","description":"Short description of the issue"},"root_cause":{"type":"string"},"severity":{"type":"string","enum":["critical","high","medium","low"]},"affected_services":{"type":"array","items":{"type":"string"}},"is_duplicate":{"type":"boolean"},"duplicate_of":{"type":"string","description":"Existing issue ID/URL if duplicate"},"fix_approach":{"type":"string"},"fix_complexity":{"type":"string","enum":["simple","moderate","complex"]}},"required":["title","root_cause","severity","is_duplicate"]}},"novel_count":{"type":"number","description":"Count of novel (non-duplicate) findings"},"highest_severity":{"type":"string","enum":["critical","high","medium","low"]},"recommendation":{"type":"string"}},"required":["findings","novel_count","highest_severity","recommendation"]}},"create_issue":{"name":"Create Issues & Triage Duplicates","instruction":"Process ALL findings from the investigation. The findings array contains both novel and duplicate issues.\n\n**For each NOVEL finding** (is_duplicate = false):\n1. Create a new issue with a clear, actionable title.\n2. Include: root cause, severity, affected services, reproduction steps, and recommended fix.\n3. Add appropriate labels (bug, severity level, affected service).\n4. Link to relevant commits, PRs, or existing issues.\n\n**For each DUPLICATE finding** (is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the existing issue is closed/done, reopen it.\n\nIf context.issueTemplate is provided, use it as the format for new issue bodies. Otherwise use a clear structure with: Summary, Root Cause, Impact, Steps to Reproduce, and Recommended Fix.\n\nUse the issue tracker specified in context (e.g. Linear, Jira, or GitHub Issues) — do NOT fall back to a different tracker. Output the created/updated issue identifiers.","skills":["linear","github"]},"skip":{"name":"Skip — All Duplicates or Low Priority","instruction":"Every finding from the investigation was either a duplicate or low-priority. No new issues need to be created.\n\nFor each **duplicate** finding (check the findings array for items where is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the issue is closed/done, reopen it.\n\nFor **low priority** findings, log a brief note about why they were skipped.","skills":["linear","github"]},"implement":{"name":"Implement Fix","instruction":"Implement the fix identified during investigation:\n\n1. Create a feature branch from the base branch (check context for baseBranch, default \"main\").\n2. Read the relevant source files to understand the current code.\n3. Make the necessary code changes — fix the bug, nothing more.\n4. Run any existing tests if available to verify the fix doesn't break anything.\n5. Stage and commit with a clear commit message referencing the issue.\n\nKeep changes minimal and focused. Do not refactor surrounding code or add unrelated improvements.\n\nIf the fix turns out to be more complex than expected, stop and explain why — do not force a bad fix.","skills":["github"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create a PR with a clear title referencing the issue (e.g. \"[OFF-1020] fix: guard empty pdf_texts before access\").\n3. In the PR body, include: summary of the bug, what the fix does, and a link to the issue.\n4. Add appropriate labels if available.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nIf context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!-- sweny:run_id:{context.cloudRunId} -->\n\nReturn the PR URL and number.","skills":["github"]},"notify":{"name":"Notify Team","instruction":"Send a notification summarizing the triage result:\n\n1. Include: alert summary, severity, root cause (1-2 sentences), and links to any created issues or PRs.\n2. For critical/high severity, make the notification urgent.\n3. For medium/low, a standard notification is fine.\n\nUse whichever notification channel is available to you.","skills":["slack","notification"]}},"edges":[{"from":"prepare","to":"gather"},{"from":"gather","to":"investigate"},{"from":"investigate","to":"create_issue","when":"novel_count is greater than 0 AND highest_severity is medium or higher"},{"from":"investigate","to":"skip","when":"novel_count is 0, OR highest_severity is low"},{"from":"create_issue","to":"implement","when":"at least one novel finding has fix_complexity simple or moderate AND fix_approach is provided"},{"from":"create_issue","to":"notify","when":"all novel findings have fix_complexity complex, OR no clear fix_approach"},{"from":"skip","to":"notify"},{"from":"implement","to":"create_pr"},{"from":"create_pr","to":"notify"}]};
2
+ export const implementWorkflow = {"id":"implement","name":"Implement Fix","description":"Implement a code fix for an issue and open a pull request","entry":"analyze","nodes":{"analyze":{"name":"Analyze Issue","instruction":"Read the issue details and understand what needs to be fixed:\n\n1. Fetch the issue from the tracker (GitHub or Linear).\n2. Read the relevant source files to understand the current code.\n3. Identify the exact files and lines that need to change.\n4. Plan the fix approach.\n\nOutput a clear analysis of what needs to change and why.","skills":["github","linear"],"output":{"type":"object","properties":{"issue_summary":{"type":"string"},"files_to_change":{"type":"array","items":{"type":"string"}},"fix_plan":{"type":"string"},"risk_level":{"type":"string","enum":["low","medium","high"]}},"required":["issue_summary","fix_plan"]}},"implement":{"name":"Implement Fix","instruction":"Implement the planned fix:\n\n1. Create a feature branch.\n2. Make the necessary code changes.\n3. Ensure changes are minimal and focused — fix the bug, nothing more.\n4. Stage and commit with a clear commit message referencing the issue.\n\nIf the fix is too risky or complex, explain why and skip.","skills":["github"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create a PR with a clear title and description.\n3. Reference the original issue in the PR body.\n4. Add appropriate reviewers or labels if possible.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nIf context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!-- sweny:run_id:{context.cloudRunId} -->\n\nReturn the PR URL.","skills":["github"]},"notify":{"name":"Notify","instruction":"Send a notification about the implementation result:\n\n1. Include: issue reference, PR link, brief summary of changes.\n2. Keep it concise — one message, not a wall of text.","skills":["slack","notification"]},"skip":{"name":"Skip — Too Complex","instruction":"The fix was determined to be too complex or risky for automated implementation.\nAdd a comment to the issue explaining what was found and why it needs manual attention.","skills":["github","linear"]}},"edges":[{"from":"analyze","to":"implement","when":"Fix risk level is low or medium and a clear plan exists"},{"from":"analyze","to":"skip","when":"Fix is too complex, risky, or unclear"},{"from":"implement","to":"create_pr"},{"from":"skip","to":"notify"},{"from":"create_pr","to":"notify"}]};
3
3
  export const seedContentWorkflow = {"id":"seed-content","name":"Content Generation Pipeline","description":"Audit content gaps, generate educational content, validate quality, and publish to Supabase","entry":"audit","nodes":{"audit":{"name":"Audit Content Gaps","instruction":"You are auditing an educational platform's content library.\n\nUse supabase_count to check how many items exist for each combination of:\n- Content types: worksheets, games, lessons\n- Subjects: math, science, english, history, geography, economics, music, art, life-skills, foreign-language\n- Difficulties: beginner, easy, medium, hard\n\nAlso check the input parameters — if the user specified a subject, topic, difficulty, and count,\nskip the full audit and just report what they want generated.\n\nReturn a structured content plan: what to generate, how many, at what difficulty and grade level.\nPrioritize gaps where a subject has ZERO content of a given type.","skills":["supabase"],"output":{"type":"object","properties":{"plan":{"type":"array","items":{"type":"object","properties":{"contentType":{"type":"string"},"subject":{"type":"string"},"topic":{"type":"string"},"difficulty":{"type":"string"},"gradeLevel":{"type":"string"},"count":{"type":"number"},"reason":{"type":"string"}},"required":["contentType","subject","topic","difficulty","count"]}},"summary":{"type":"string"}},"required":["plan","summary"]}},"generate":{"name":"Generate Content","instruction":"You are an expert educator creating content for children ages 5-14.\n\nFor each item in the content plan from the previous step, generate the content directly.\nThe content must be high-quality, engaging, age-appropriate, and educationally sound.\n\n**For worksheets:**\nGenerate a JSON array of problems. Each problem needs: \"problem\", \"answer\", \"format\" (answer-line | multiple-choice | write-on).\nRequirements:\n- Every answer MUST be correct. Double-check math, facts, spelling.\n- Problems must progress in difficulty within the set.\n- Use at least 2 different problem structures (e.g., equations + word problems for math).\n- Vary phrasing — no two problems should feel copy-pasted.\n- For the target grade level, use appropriate vocabulary and complexity.\n\n**For lessons:**\nGenerate complete HTML + CSS + JS for an interactive lesson.\nRequirements:\n- Must have substantial educational content (not just a shell with navigation).\n- Include interactive elements: quizzes, click-to-reveal, drag-and-drop.\n- Must be visually engaging with colors, icons, and clear typography.\n- Include practice exercises with immediate feedback.\n- Minimum 5 teaching sections plus 2 practice activities.\n\n**For games:**\nGenerate a description and educational requirements only (actual game code is generated\nby the platform's Phaser-based generator).\n\nUse supabase_insert to write each piece of content to the appropriate table.\n\nTable schemas:\n- worksheets: id (auto), title, subject, topic, difficulty, grade_level, num_questions, problems (jsonb), status='published', source='ai-seed'\n- lessons: id (auto), title, subject, difficulty, grade_level, lesson_type, html_code, css_code, js_code, status='published', source='ai-seed'\n- games: id (auto), title, subject, difficulty, grade_level, game_type, html_code, css_code, js_code, status='published', source='ai-seed'\n\nReturn a summary of what was generated and inserted.","skills":["supabase"],"output":{"type":"object","properties":{"generated":{"type":"array","items":{"type":"object","properties":{"contentType":{"type":"string"},"title":{"type":"string"},"subject":{"type":"string"},"id":{"type":"string"},"status":{"type":"string"}}}},"totalGenerated":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["generated","totalGenerated"]}},"validate":{"name":"Validate Content Quality","instruction":"Review the content that was just generated and inserted.\n\nFor each item, use supabase_query to fetch it back and verify:\n\n**Worksheets:**\n- Parse the problems JSON — are all answers correct?\n- Are problems diverse (not repetitive)?\n- Is difficulty appropriate for the stated grade level?\n- Are there the right number of questions?\n\n**Lessons:**\n- Is the HTML substantial (> 500 characters)?\n- Does it contain actual educational content (not just navigation shells)?\n- Are there interactive elements?\n\n**Games:**\n- Does the HTML contain a working game structure?\n\nFlag any issues. If critical issues are found (wrong answers, empty content),\nuse supabase_update to set status='draft' and add a note.\n\nReturn a quality report.","skills":["supabase"],"output":{"type":"object","properties":{"totalReviewed":{"type":"number"},"passed":{"type":"number"},"flagged":{"type":"number"},"issues":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"issue":{"type":"string"},"severity":{"type":"string"},"action":{"type":"string"}}}},"summary":{"type":"string"}},"required":["totalReviewed","passed","flagged","summary"]}},"report":{"name":"Generate Report","instruction":"Compile a final report of the content generation run.\n\nInclude:\n- Total content generated by type and subject\n- Quality validation results\n- Any issues found and actions taken\n- Recommendations for next content generation run (what gaps remain)\n\nFormat as a clean markdown summary that a solo operator can scan in 30 seconds.","skills":[],"output":{"type":"object","properties":{"markdown":{"type":"string"},"nextPriorities":{"type":"array","items":{"type":"string"}}},"required":["markdown"]}}},"edges":[{"from":"audit","to":"generate"},{"from":"generate","to":"validate"},{"from":"validate","to":"report"}]};
@@ -69,6 +69,10 @@ nodes:
69
69
  Summary, Changes, Testing, and Related Issues.
70
70
 
71
71
 
72
+ If context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!--
73
+ sweny:run_id:{context.cloudRunId} -->
74
+
75
+
72
76
  Return the PR URL.
73
77
  skills:
74
78
  - github
@@ -239,6 +239,10 @@ nodes:
239
239
  Summary, Changes, Testing, and Related Issues.
240
240
 
241
241
 
242
+ If context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!--
243
+ sweny:run_id:{context.cloudRunId} -->
244
+
245
+
242
246
  Return the PR URL and number.
243
247
  skills:
244
248
  - github
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"