@sweny-ai/core 0.1.113 → 0.1.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * - `buildWorkflow` / `refineWorkflow` — depend on the Claude interface.
14
14
  * - Testing helpers — `MockClaude`, `createFileSkill` use `node:fs`.
15
15
  */
16
- export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
16
+ export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
17
17
  export { consoleLogger } from "./types.js";
18
18
  export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, } from "./types.js";
19
19
  export { github, linear, slack, sentry, datadog, betterstack, notification, supabase, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
package/dist/claude.js CHANGED
@@ -318,9 +318,17 @@ export class ClaudeClient {
318
318
  // incomplete output.
319
319
  const terminalReason = resultMsg.terminal_reason;
320
320
  if (terminalReason && terminalReason !== "completed") {
321
+ // Preserve whatever partial text the agent produced before the
322
+ // early termination. Callers that fail hard ignore it; a node
323
+ // with `fail_soft: true` passes it downstream as the partial
324
+ // gather/work product instead of dropping it on the floor.
325
+ const partial = typeof resultMsg.result === "string" ? resultMsg.result.trim() : "";
321
326
  return {
322
327
  status: "failed",
323
- data: { error: `Claude query terminated early: ${terminalReason}` },
328
+ data: {
329
+ error: `Claude query terminated early: ${terminalReason}`,
330
+ ...(partial !== "" ? { summary: partial } : {}),
331
+ },
324
332
  toolCalls,
325
333
  };
326
334
  }
package/dist/executor.js CHANGED
@@ -131,15 +131,18 @@ export async function execute(workflow, input, options) {
131
131
  const resolvedInstruction = resolvedSources[`nodes.${currentId}.instruction`].content;
132
132
  safeObserve(observer, { type: "node:enter", node: currentId, instruction: resolvedInstruction }, logger);
133
133
  logger.info(`→ ${node.name}`, { node: currentId });
134
- // Gather tools from the node's skills
135
- const tools = resolveTools(node.skills, skills);
134
+ // Gather tools from the node's skills, then apply the node's optional
135
+ // skill-tool filter (`tools.allow` / `tools.deny`). Filtered tools are
136
+ // never registered for the run, so the model cannot see or call them.
137
+ const resolvedSkillTools = resolveTools(node.skills, skills);
138
+ const tools = filterNodeTools(resolvedSkillTools, node.tools, currentId, logger);
136
139
  const skillInstructions = resolveSkillInstructions(node.skills, skills);
137
140
  // Runtime guard: if this node declares skills but none resolved, the node
138
141
  // cannot do its job (e.g. "create a Linear issue" with no linear skill).
139
142
  // The startup validate() warns about this possibility, but only throw when
140
143
  // the node is actually reached — unreachable nodes with missing skills are fine.
141
144
  // Instruction-only skills (no tools) are valid — they inject context into the prompt.
142
- if (node.skills.length > 0 && tools.length === 0 && skillInstructions.length === 0) {
145
+ if (node.skills.length > 0 && resolvedSkillTools.length === 0 && skillInstructions.length === 0) {
143
146
  throw new Error(`Node "${currentId}" requires skills [${node.skills.join(", ")}] but none are configured. ` +
144
147
  `Set the required environment variables and try again.`);
145
148
  }
@@ -197,6 +200,11 @@ export async function execute(workflow, input, options) {
197
200
  // Run Claude on this node, with optional eval-failure retry loop.
198
201
  let attempt = 0;
199
202
  let result;
203
+ // Distinguishes an agent-level failure (claude.run returned non-success:
204
+ // max turns, early termination, SDK error) from an eval failure. Only
205
+ // agent-level failures are eligible for fail_soft below; evals are
206
+ // correctness gates and are never softened.
207
+ let agentRunFailed = false;
200
208
  let currentInstruction = instruction;
201
209
  const retry = node.retry;
202
210
  // Per-node execution model: node.model ?? workflow.model. When undefined,
@@ -218,8 +226,10 @@ export async function execute(workflow, input, options) {
218
226
  },
219
227
  });
220
228
  // Retry only triggers on eval failure. Bail on tool/API errors.
221
- if (result.status !== "success")
229
+ if (result.status !== "success") {
230
+ agentRunFailed = true;
222
231
  break;
232
+ }
223
233
  // Required-field gate: if the source node's declared output schema
224
234
  // marks fields as `required`, those fields are the routing contract.
225
235
  // An agent that omits a required field has violated the contract; we
@@ -289,6 +299,23 @@ export async function execute(workflow, input, options) {
289
299
  safeObserve(observer, { type: "node:retry", node: currentId, attempt: attempt + 1, reason: outcome.error ?? "eval failed", preamble }, logger);
290
300
  attempt++;
291
301
  }
302
+ // Fail-soft: when the node declares `fail_soft: true` and the failure was
303
+ // agent-level (max turns, early termination, SDK error), downgrade to
304
+ // success so the workflow proceeds with whatever partial output exists
305
+ // instead of failing outright. The original error stays in `data.error`
306
+ // and `data.fail_soft` marks the downgrade for downstream nodes and
307
+ // observers. Eval failures never take this path (agentRunFailed guards it).
308
+ if (agentRunFailed && result.status === "failed" && node.fail_soft === true) {
309
+ const failError = result.data?.error;
310
+ logger.warn(` fail_soft: node failed (${String(failError ?? "unknown error")}); continuing with partial output`, {
311
+ node: currentId,
312
+ });
313
+ result = {
314
+ ...result,
315
+ status: "success",
316
+ data: { ...(result.data ?? {}), fail_soft: true },
317
+ };
318
+ }
292
319
  results.set(currentId, result);
293
320
  trace.steps.push(attempt > 0
294
321
  ? { node: currentId, status: result.status, iteration, retryAttempt: attempt }
@@ -614,6 +641,34 @@ function resolveTools(skillIds, skills) {
614
641
  .filter((s) => s != null)
615
642
  .flatMap((s) => s.tools);
616
643
  }
644
+ /**
645
+ * Apply a node's optional skill-tool filter (`tools.allow` / `tools.deny`).
646
+ *
647
+ * - No filter declared: all resolved tools pass through (back-compat).
648
+ * - `allow` present: only tools whose name is in `allow` survive.
649
+ * - `deny` present: tools whose name is in `deny` are removed. Applied
650
+ * after `allow` when both are declared, so deny always wins.
651
+ *
652
+ * Filter entries that match no resolved tool get a warn (typo detection;
653
+ * also fires legitimately when a skill is unconfigured and its tools are
654
+ * absent, which is informational rather than fatal).
655
+ */
656
+ function filterNodeTools(tools, filter, nodeId, logger) {
657
+ if (!filter)
658
+ return tools;
659
+ const resolvedNames = new Set(tools.map((t) => t.name));
660
+ for (const name of [...(filter.allow ?? []), ...(filter.deny ?? [])]) {
661
+ if (!resolvedNames.has(name)) {
662
+ logger.warn(` tools filter: '${name}' does not match any tool resolved for node '${nodeId}'`, {
663
+ node: nodeId,
664
+ tool: name,
665
+ });
666
+ }
667
+ }
668
+ const allow = filter.allow ? new Set(filter.allow) : null;
669
+ const deny = new Set(filter.deny ?? []);
670
+ return tools.filter((t) => (allow ? allow.has(t.name) : true) && !deny.has(t.name));
671
+ }
617
672
  /** Collect instruction strings from skills that have them, in array order. */
618
673
  function resolveSkillInstructions(skillIds, skills) {
619
674
  return skillIds
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  * })
22
22
  * ```
23
23
  */
24
- export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
24
+ export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
25
25
  export { WORKFLOW_INPUT_TYPES } from "./types.js";
26
26
  export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, } from "./types.js";
27
27
  export { consoleLogger } from "./types.js";
package/dist/schema.d.ts CHANGED
@@ -744,6 +744,28 @@ export declare const nodeRequiresZ: z.ZodEffects<z.ZodObject<{
744
744
  }[] | undefined;
745
745
  on_fail?: "fail" | "skip" | undefined;
746
746
  }>;
747
+ /**
748
+ * Per-node skill-tool filter. `allow` keeps only the listed skill tools;
749
+ * `deny` removes the listed skill tools (applied after `allow`). At least
750
+ * one of the two must be declared. Complements `disallowed_tools`, which
751
+ * covers built-in agent tools only.
752
+ */
753
+ export declare const nodeToolsZ: z.ZodEffects<z.ZodObject<{
754
+ allow: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
755
+ deny: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
756
+ }, "strict", z.ZodTypeAny, {
757
+ allow?: string[] | undefined;
758
+ deny?: string[] | undefined;
759
+ }, {
760
+ allow?: string[] | undefined;
761
+ deny?: string[] | undefined;
762
+ }>, {
763
+ allow?: string[] | undefined;
764
+ deny?: string[] | undefined;
765
+ }, {
766
+ allow?: string[] | undefined;
767
+ deny?: string[] | undefined;
768
+ }>;
747
769
  export declare const nodeRetryZ: z.ZodObject<{
748
770
  max: z.ZodNumber;
749
771
  instruction: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodObject<{
@@ -802,6 +824,23 @@ export declare const nodeZ: z.ZodObject<{
802
824
  output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
803
825
  max_turns: z.ZodOptional<z.ZodNumber>;
804
826
  disallowed_tools: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
827
+ tools: z.ZodOptional<z.ZodEffects<z.ZodObject<{
828
+ allow: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
829
+ deny: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
830
+ }, "strict", z.ZodTypeAny, {
831
+ allow?: string[] | undefined;
832
+ deny?: string[] | undefined;
833
+ }, {
834
+ allow?: string[] | undefined;
835
+ deny?: string[] | undefined;
836
+ }>, {
837
+ allow?: string[] | undefined;
838
+ deny?: string[] | undefined;
839
+ }, {
840
+ allow?: string[] | undefined;
841
+ deny?: string[] | undefined;
842
+ }>>;
843
+ fail_soft: z.ZodOptional<z.ZodBoolean>;
805
844
  rules: z.ZodOptional<z.ZodUnion<[z.ZodArray<z.ZodUnion<[z.ZodString, z.ZodObject<{
806
845
  inline: z.ZodString;
807
846
  }, "strict", z.ZodTypeAny, {
@@ -1199,10 +1238,15 @@ export declare const nodeZ: z.ZodObject<{
1199
1238
  type?: string | undefined;
1200
1239
  };
1201
1240
  skills: string[];
1241
+ tools?: {
1242
+ allow?: string[] | undefined;
1243
+ deny?: string[] | undefined;
1244
+ } | undefined;
1202
1245
  model?: string | undefined;
1203
1246
  output?: Record<string, unknown> | undefined;
1204
1247
  max_turns?: number | undefined;
1205
1248
  disallowed_tools?: string[] | undefined;
1249
+ fail_soft?: boolean | undefined;
1206
1250
  rules?: (string | {
1207
1251
  inline: string;
1208
1252
  } | {
@@ -1288,11 +1332,16 @@ export declare const nodeZ: z.ZodObject<{
1288
1332
  url: string;
1289
1333
  type?: string | undefined;
1290
1334
  };
1335
+ tools?: {
1336
+ allow?: string[] | undefined;
1337
+ deny?: string[] | undefined;
1338
+ } | undefined;
1291
1339
  model?: string | undefined;
1292
1340
  skills?: string[] | undefined;
1293
1341
  output?: Record<string, unknown> | undefined;
1294
1342
  max_turns?: number | undefined;
1295
1343
  disallowed_tools?: string[] | undefined;
1344
+ fail_soft?: boolean | undefined;
1296
1345
  rules?: (string | {
1297
1346
  inline: string;
1298
1347
  } | {
@@ -1432,6 +1481,23 @@ export declare const workflowZ: z.ZodObject<{
1432
1481
  output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
1433
1482
  max_turns: z.ZodOptional<z.ZodNumber>;
1434
1483
  disallowed_tools: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1484
+ tools: z.ZodOptional<z.ZodEffects<z.ZodObject<{
1485
+ allow: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1486
+ deny: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1487
+ }, "strict", z.ZodTypeAny, {
1488
+ allow?: string[] | undefined;
1489
+ deny?: string[] | undefined;
1490
+ }, {
1491
+ allow?: string[] | undefined;
1492
+ deny?: string[] | undefined;
1493
+ }>, {
1494
+ allow?: string[] | undefined;
1495
+ deny?: string[] | undefined;
1496
+ }, {
1497
+ allow?: string[] | undefined;
1498
+ deny?: string[] | undefined;
1499
+ }>>;
1500
+ fail_soft: z.ZodOptional<z.ZodBoolean>;
1435
1501
  rules: z.ZodOptional<z.ZodUnion<[z.ZodArray<z.ZodUnion<[z.ZodString, z.ZodObject<{
1436
1502
  inline: z.ZodString;
1437
1503
  }, "strict", z.ZodTypeAny, {
@@ -1829,10 +1895,15 @@ export declare const workflowZ: z.ZodObject<{
1829
1895
  type?: string | undefined;
1830
1896
  };
1831
1897
  skills: string[];
1898
+ tools?: {
1899
+ allow?: string[] | undefined;
1900
+ deny?: string[] | undefined;
1901
+ } | undefined;
1832
1902
  model?: string | undefined;
1833
1903
  output?: Record<string, unknown> | undefined;
1834
1904
  max_turns?: number | undefined;
1835
1905
  disallowed_tools?: string[] | undefined;
1906
+ fail_soft?: boolean | undefined;
1836
1907
  rules?: (string | {
1837
1908
  inline: string;
1838
1909
  } | {
@@ -1918,11 +1989,16 @@ export declare const workflowZ: z.ZodObject<{
1918
1989
  url: string;
1919
1990
  type?: string | undefined;
1920
1991
  };
1992
+ tools?: {
1993
+ allow?: string[] | undefined;
1994
+ deny?: string[] | undefined;
1995
+ } | undefined;
1921
1996
  model?: string | undefined;
1922
1997
  skills?: string[] | undefined;
1923
1998
  output?: Record<string, unknown> | undefined;
1924
1999
  max_turns?: number | undefined;
1925
2000
  disallowed_tools?: string[] | undefined;
2001
+ fail_soft?: boolean | undefined;
1926
2002
  rules?: (string | {
1927
2003
  inline: string;
1928
2004
  } | {
@@ -2211,10 +2287,15 @@ export declare const workflowZ: z.ZodObject<{
2211
2287
  type?: string | undefined;
2212
2288
  };
2213
2289
  skills: string[];
2290
+ tools?: {
2291
+ allow?: string[] | undefined;
2292
+ deny?: string[] | undefined;
2293
+ } | undefined;
2214
2294
  model?: string | undefined;
2215
2295
  output?: Record<string, unknown> | undefined;
2216
2296
  max_turns?: number | undefined;
2217
2297
  disallowed_tools?: string[] | undefined;
2298
+ fail_soft?: boolean | undefined;
2218
2299
  rules?: (string | {
2219
2300
  inline: string;
2220
2301
  } | {
@@ -2338,11 +2419,16 @@ export declare const workflowZ: z.ZodObject<{
2338
2419
  url: string;
2339
2420
  type?: string | undefined;
2340
2421
  };
2422
+ tools?: {
2423
+ allow?: string[] | undefined;
2424
+ deny?: string[] | undefined;
2425
+ } | undefined;
2341
2426
  model?: string | undefined;
2342
2427
  skills?: string[] | undefined;
2343
2428
  output?: Record<string, unknown> | undefined;
2344
2429
  max_turns?: number | undefined;
2345
2430
  disallowed_tools?: string[] | undefined;
2431
+ fail_soft?: boolean | undefined;
2346
2432
  rules?: (string | {
2347
2433
  inline: string;
2348
2434
  } | {
@@ -2509,10 +2595,15 @@ export declare function parseWorkflow(raw: unknown): {
2509
2595
  type?: string | undefined;
2510
2596
  };
2511
2597
  skills: string[];
2598
+ tools?: {
2599
+ allow?: string[] | undefined;
2600
+ deny?: string[] | undefined;
2601
+ } | undefined;
2512
2602
  model?: string | undefined;
2513
2603
  output?: Record<string, unknown> | undefined;
2514
2604
  max_turns?: number | undefined;
2515
2605
  disallowed_tools?: string[] | undefined;
2606
+ fail_soft?: boolean | undefined;
2516
2607
  rules?: (string | {
2517
2608
  inline: string;
2518
2609
  } | {
@@ -3075,6 +3166,38 @@ export declare const workflowJsonSchema: {
3075
3166
  };
3076
3167
  readonly description: "Built-in tool names the agent cannot use at this node (e.g. ['Bash']). Forwarded to the Claude Agent SDK's disallowedTools, which removes the tools from the model context entirely.";
3077
3168
  };
3169
+ readonly tools: {
3170
+ readonly type: "object";
3171
+ readonly description: "Per-node filter over skill-provided tools. 'allow' keeps only the listed skill tools; 'deny' removes the listed skill tools (applied after 'allow'). Filtered tools are never registered for the node's run. Absent field = all skill tools exposed. Complements disallowed_tools, which covers built-in agent tools only.";
3172
+ readonly additionalProperties: false;
3173
+ readonly anyOf: readonly [{
3174
+ readonly required: readonly ["allow"];
3175
+ }, {
3176
+ readonly required: readonly ["deny"];
3177
+ }];
3178
+ readonly properties: {
3179
+ readonly allow: {
3180
+ readonly type: "array";
3181
+ readonly items: {
3182
+ readonly type: "string";
3183
+ readonly minLength: 1;
3184
+ };
3185
+ readonly minItems: 1;
3186
+ };
3187
+ readonly deny: {
3188
+ readonly type: "array";
3189
+ readonly items: {
3190
+ readonly type: "string";
3191
+ readonly minLength: 1;
3192
+ };
3193
+ readonly minItems: 1;
3194
+ };
3195
+ };
3196
+ };
3197
+ readonly fail_soft: {
3198
+ readonly type: "boolean";
3199
+ readonly description: "When true, an agent-level failure at this node (max turns, early termination, SDK error) is downgraded to success with fail_soft: true and the error preserved in data; routing proceeds with partial output. Eval failures are not softened. Default false.";
3200
+ };
3078
3201
  readonly rules: {
3079
3202
  readonly $ref: "#/$defs/NodeSources";
3080
3203
  readonly description: "Per-node rules. Additive by default; set { only: true } to block cascade.";
package/dist/schema.js CHANGED
@@ -204,6 +204,21 @@ export const nodeRequiresZ = z
204
204
  .refine((r) => r.output_required !== undefined || r.output_matches !== undefined, {
205
205
  message: "requires must declare at least one check (output_required or output_matches)",
206
206
  });
207
+ /**
208
+ * Per-node skill-tool filter. `allow` keeps only the listed skill tools;
209
+ * `deny` removes the listed skill tools (applied after `allow`). At least
210
+ * one of the two must be declared. Complements `disallowed_tools`, which
211
+ * covers built-in agent tools only.
212
+ */
213
+ export const nodeToolsZ = z
214
+ .object({
215
+ allow: z.array(z.string().min(1)).min(1).optional(),
216
+ deny: z.array(z.string().min(1)).min(1).optional(),
217
+ })
218
+ .strict()
219
+ .refine((t) => t.allow !== undefined || t.deny !== undefined, {
220
+ message: "tools must declare at least one of allow or deny",
221
+ });
207
222
  const retryInstructionAutoZ = z.object({ auto: z.literal(true) }).strict();
208
223
  const retryInstructionReflectZ = z.object({ reflect: z.string().min(1) }).strict();
209
224
  export const nodeRetryZ = z
@@ -220,6 +235,8 @@ export const nodeZ = z
220
235
  output: jsonSchemaZ.optional(),
221
236
  max_turns: z.number().int().min(1).optional(),
222
237
  disallowed_tools: z.array(z.string().min(1)).optional(),
238
+ tools: nodeToolsZ.optional(),
239
+ fail_soft: z.boolean().optional(),
223
240
  rules: nodeSourcesZ.optional(),
224
241
  context: nodeSourcesZ.optional(),
225
242
  eval: z.array(evaluatorZ).min(1).optional(),
@@ -784,6 +801,28 @@ export const workflowJsonSchema = {
784
801
  items: { type: "string", minLength: 1 },
785
802
  description: "Built-in tool names the agent cannot use at this node (e.g. ['Bash']). Forwarded to the Claude Agent SDK's disallowedTools, which removes the tools from the model context entirely.",
786
803
  },
804
+ tools: {
805
+ type: "object",
806
+ description: "Per-node filter over skill-provided tools. 'allow' keeps only the listed skill tools; 'deny' removes the listed skill tools (applied after 'allow'). Filtered tools are never registered for the node's run. Absent field = all skill tools exposed. Complements disallowed_tools, which covers built-in agent tools only.",
807
+ additionalProperties: false,
808
+ anyOf: [{ required: ["allow"] }, { required: ["deny"] }],
809
+ properties: {
810
+ allow: {
811
+ type: "array",
812
+ items: { type: "string", minLength: 1 },
813
+ minItems: 1,
814
+ },
815
+ deny: {
816
+ type: "array",
817
+ items: { type: "string", minLength: 1 },
818
+ minItems: 1,
819
+ },
820
+ },
821
+ },
822
+ fail_soft: {
823
+ type: "boolean",
824
+ description: "When true, an agent-level failure at this node (max turns, early termination, SDK error) is downgraded to success with fail_soft: true and the error preserved in data; routing proceeds with partial output. Eval failures are not softened. Default false.",
825
+ },
787
826
  rules: {
788
827
  $ref: "#/$defs/NodeSources",
789
828
  description: "Per-node rules. Additive by default; set { only: true } to block cascade.",
package/dist/types.d.ts CHANGED
@@ -294,6 +294,43 @@ export interface Node {
294
294
  * `git push`. Names follow the SDK's tool naming (e.g. `Bash`, `Write`).
295
295
  */
296
296
  disallowed_tools?: string[];
297
+ /**
298
+ * Per-node filter over skill-provided tools. Complements
299
+ * `disallowed_tools` (which covers built-in agent tools only): this field
300
+ * restricts which of the node's skill tools are registered for the run.
301
+ * Filtered tools are never exposed to the model at all. Structural
302
+ * enforcement, not prompt-begging.
303
+ *
304
+ * - `allow`: when present, ONLY these skill tool names are exposed.
305
+ * - `deny`: these skill tool names are removed (applied after `allow`).
306
+ *
307
+ * Absent field = all skill tools exposed (previous behavior, back-compat).
308
+ * Driving incident: the triage `gather` node, scoped to read-only context
309
+ * gathering, created Linear issues and GitHub PRs mid-gather because every
310
+ * write tool from its skills was in context (letsoffload/permit-service
311
+ * scheduled run on 2026-06-08, PRs #101/#102).
312
+ */
313
+ tools?: NodeToolFilter;
314
+ /**
315
+ * When true, an agent-level failure at this node (max turns reached,
316
+ * early termination, SDK error) does not fail the workflow: the node's
317
+ * result is downgraded to `success` with `fail_soft: true` set and the
318
+ * original `error` preserved in `data`, and routing proceeds so
319
+ * downstream nodes can work with whatever partial output exists.
320
+ * Eval failures are NOT softened; evals are correctness gates.
321
+ * Default false (previous behavior, back-compat).
322
+ */
323
+ fail_soft?: boolean;
324
+ }
325
+ /**
326
+ * Per-node skill-tool filter. See {@link Node.tools}.
327
+ * At least one of `allow` / `deny` must be declared (schema-enforced).
328
+ */
329
+ export interface NodeToolFilter {
330
+ /** When present, only these skill tool names are exposed at the node. */
331
+ allow?: string[];
332
+ /** Skill tool names removed from the node (applied after `allow`). */
333
+ deny?: string[];
297
334
  }
298
335
  /** An edge connecting two nodes */
299
336
  export interface Edge {
@@ -1,3 +1,3 @@
1
- export const triageWorkflow = {"id":"triage","name":"Alert Triage","workflow_type":"pr_review","description":"Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team","entry":"gather","nodes":{"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 — you MUST call tools for this step):** Filing a duplicate ticket is worse than under-reporting — the team inherits sprawl that humans have to clean up later. Complete EVERY step below before classifying anything as novel. Do NOT skip these searches even if the gather context already mentions existing issues or branches — you must verify independently.\n4a. **Search broadly — THIS REQUIRES TOOL CALLS.** You MUST call `linear_search_issues` (or `github_search_issues`) with multiple queries against the issue tracker (BOTH open AND closed): the affected file or module path, the error message or log string verbatim, the feature or service name, and the symptom family (e.g. \"startup crash\", \"data dropped silently\"). Do not rely on a single keyword — a narrow search is how duplicates slip through. If you skip this search, the eval check will fail.\n4b. **Read the full body of every promising candidate — do NOT match on title alone.** For each top match, open the issue and read its entire description, including any \"Recommended Fix\", \"Proposed Fix\", \"Action Items\", or \"Acceptance Criteria\" section. An existing open issue whose recommended fix already prescribes the change you were about to propose IS a duplicate, even if the symptom you just observed is a new instance of the same underlying bug.\n4c. **Check for meta-issues and bug families.** If an existing ticket names a pattern that your finding is another instance of (same root cause, same function/module, different input or caller), treat your finding as a sibling of that family and comment on the existing ticket. Do not file a parallel ticket per instance — that is the exact pattern that produces backlog sprawl.\n4d. **When uncertain, comment don't file.** If you are less than ~80% confident a finding is truly novel after steps 4a–4c, classify it as a duplicate and add a \"+1 — another instance\" comment to the closest existing issue with the new context. Over-reporting duplicates is cheap; ticket sprawl is expensive.\n\n - If matched → **duplicate**. Record the existing issue ID in `duplicate_of`.\n - If no match after 4a–4d → **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.\n\n**IMPORTANT — scope boundaries for this node:**\n- DO NOT create issues (Linear, GitHub, or any tracker). A downstream `create_issue` node handles that.\n- DO NOT create branches, commits, or pull requests. A downstream `implement` node handles that.\n- DO NOT call any write tools (`linear_create_issue`, `github_create_issue`, `github_create_pr`, `linear_add_comment`, `github_add_comment`, etc.). Downstream nodes handle all writes — issue creation, +1 comments, and PRs.\n- Your ONLY job is read, search, classify, and output. If you create an issue or add a comment here, the downstream eval check will fail or duplicate work.","skills":["github","linear"],"eval":[{"name":"novelty_search_was_performed","kind":"function","rule":{"any_tool_called":["linear_search_issues","github_search_issues"]}}],"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. **First, check if a prior triage run already created an issue for this exact bug.** Call `linear_search_issues` (or `github_search_issues`) with the error message or root cause. If a matching issue already exists with the same root cause:\n - Treat it as a duplicate: DO NOT create a new issue.\n - Populate `issueIdentifier`, `issueTitle`, and `issueUrl` from the existing issue.\n - Add a \"+1\" comment if appropriate (using `linear_add_comment` or `github_add_comment`).\n - Set the `action` to `updated` in the `issues` array.\n - This prevents duplicate tickets when the same alert fires multiple times.\n\n2. If no matching issue exists, create a new one with a clear, actionable title by invoking `linear_create_issue` or `github_create_issue`. You MUST actually call the tool — do NOT synthesize an identifier from an alert ID, commit SHA, or your own imagination. If the tool errors, stop and report the failure; do not invent a fallback identifier. **Use the exact tool names above** — do NOT use generic alternatives like `create_issue` or `create_pull_request` from ToolSearch. The eval check requires the specific tool names listed here.\n3. Include: root cause, severity, affected services, reproduction steps, and recommended fix.\n4. Apply the `sweny`, `agent`, and `triage` labels. For Linear: call `linear_list_labels` to find the IDs for labels named `sweny`, `agent`, and `triage`, then pass those IDs in `labelIds` when calling `linear_create_issue` (skip any that don't exist in the team — don't invent IDs). For GitHub: pass `labels: [\"sweny\", \"agent\", \"triage\"]` to `github_create_issue`.\n5. Link to relevant commits, PRs, or existing issues.\n6. Copy the identifier returned by the tool verbatim into the `issueIdentifier` output field. The identifier must match `^[A-Z][A-Z0-9]*-\\d+$` (e.g. `OFF-1234`) — do not prefix it with anything else.\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.\n\n**IMPORTANT — scope boundaries for this node:**\n- DO NOT create branches, commits, or pull requests. The `implement` and `create_pr` nodes handle that.\n- DO NOT call `create_pull_request`, `github_create_pr`, or any PR/branch tools.\n- Your job is issue creation and duplicate management ONLY.","skills":["linear","github"],"eval":[{"name":"issue_tracker_tool_was_called","kind":"function","rule":{"any_tool_called":["linear_create_issue","github_create_issue","linear_search_issues","github_search_issues","linear_add_comment","github_add_comment"]}}],"output":{"type":"object","properties":{"issueIdentifier":{"type":"string","pattern":"^[A-Z][A-Z0-9]*-\\d+$","description":"Primary issue identifier (e.g. OFF-1234). MUST be the identifier returned by `linear_create_issue` / `github_create_issue` — do NOT invent a value from a Sentry short-ID or commit SHA. Used by downstream nodes for branch naming and PR linking."},"issueTitle":{"type":"string","description":"Title of the primary issue created"},"issueUrl":{"type":"string","pattern":"^https?://","description":"URL of the primary issue created, as returned by the create-issue tool"},"issues":{"type":"array","minItems":1,"description":"All issues created or updated during this step","items":{"type":"object","properties":{"identifier":{"type":"string"},"title":{"type":"string"},"url":{"type":"string"},"action":{"type":"string","enum":["created","updated","reopened"]}},"required":["identifier","action"]}}},"required":["issueIdentifier","issueTitle","issueUrl","issues"]},"retry":{"max":1,"instruction":{"auto":true}}},"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, plus the tests that pin\nit as a regression guard. The issue created in the previous step is in your\ncontext — use its identifier for branch naming and commit messages.\n\n## Quality bar (non-negotiable)\n\nEvery change you make must clear all of these. If you cannot, STOP and\nreturn a failed status — do not ship a half-measure.\n\n- **Correctness**: the fix actually closes the failure mode in the issue.\n- **Completeness**: handles the obvious edge cases (nulls, empties, off-by-one,\n concurrent paths). If the issue lists multiple symptoms, address them all\n or explicitly state which are out of scope and why.\n- **Idiomatic for THIS repo, not in general.** Read at least one\n neighboring file in the same module before you write any code, and\n match the patterns you see: error-handling primitives the codebase\n already uses, type discipline, naming, file layout. Examples to make\n the shape concrete:\n - In a NestJS service, throwing `NotFoundException` /\n `BadRequestException` is idiomatic; `throw new Error(...)` is not.\n - In a Go service, returning a wrapped `fmt.Errorf(\"...: %w\", err)`\n beats panicking.\n - In a TypeScript repo, narrow types properly — do not `as any` past\n the type checker.\n These are illustrations. Generalize: whatever the repo already does,\n keep doing.\n- **No hacky shortcuts**: no `try/catch` to swallow errors, no commented-out\n guards, no `// TODO: revisit`, no global flags to bypass validation, no\n copy-pasted bodies. If the right fix needs a small refactor (e.g. extract\n a helper), do it; if it needs a large refactor, file a follow-up issue\n and ship the minimal correct fix without the refactor.\n- **Well-tested**: see Test Requirements below.\n\n## Steps\n\n0a. **Precondition — verify context.create_issue.issueIdentifier is well-formed.**\n The identifier MUST match `^[A-Z][A-Z0-9]*-\\d+$` (e.g. `OFF-1234`). If it\n is missing, empty, or matches a Sentry short-ID / slug / any other shape,\n STOP immediately — do NOT invent a branch name from the Sentry event,\n commit SHA, or alert payload. Return a failed status with a clear message\n instead; the workflow will route to notify and a human will pick it up.\n\n0b. **Precondition — check for an existing open PR for this issue.** Call\n `github_search_issues` (which hits GitHub's `/search/issues` endpoint\n and returns both issues and PRs) with `type:pr state:open` and the\n issue identifier wrapped in brackets — e.g. query\n `[OFF-1234] in:title type:pr state:open` and pass `repo` for scoping.\n If the response contains one or more PRs whose title starts with\n `[<issueIdentifier>]`, STOP. Do NOT create a branch, commit, or any\n new PR. Output:\n\n ```\n test_status: \"skipped\"\n skipped_reason: \"open-pr-exists\"\n existing_pr_url: \"<url of the existing PR>\"\n test_files_changed: []\n files_changed: []\n branch: \"\"\n commit_sha: \"\"\n ```\n\n The downstream `create_pr` node will be bypassed and the workflow will\n route to `notify` so the team is told the prior PR is still pending.\n\n Rationale: re-implementing a fix while an open PR already exists\n produces a duplicate (or worse, the agent closes its own prior PR\n mid-run and opens a new one with a different approach — exactly the\n thrash observed in `letsoffload/permit-service` PR #81 → #82 and\n `letsoffload/offload` PR #572 → #573, where two PRs for the same\n identifier were opened-and-closed 12 to 16 seconds apart inside a\n single workflow run).\n\n Only proceed to Step 1 if no open PR exists for the identifier.\n\n1. Create a feature branch from the base branch (check context for\n baseBranch, default `main`). **Branch name MUST include the issue\n identifier** exactly as provided in `context.create_issue.issueIdentifier`.\n Format: lowercase the identifier and use it as a prefix, e.g.\n `off-1234-fix-null-check`. If the repository uses a different branch\n convention (check context.branchPattern or the rules/CLAUDE.md for a\n regex), follow that — but the identifier piece MUST still come from\n `context.create_issue.issueIdentifier`, never from a Sentry short-ID.\n\n2. Read the relevant source files to understand the current code AND read\n the nearest existing test file (e.g. `foo.service.spec.ts` next to\n `foo.service.ts`, or `foo_test.py` next to `foo.py`) so your new tests\n match the existing test conventions: framework, mock style, factory use,\n describe/it shape, naming.\n\n3. Make the necessary code change. Keep it minimal and focused — do not\n refactor surrounding code or add unrelated improvements.\n\n4. **Write tests for the fix.** See Test Requirements below.\n\n5. **Run the test suite for the affected package** and confirm green\n (the package's `test` command from package.json, `pytest` for Python,\n etc.). If the test runner is not obvious, read the project's\n CLAUDE.md / README / package.json scripts. If you cannot run the\n tests at all in this environment, say so explicitly in the output —\n do not claim \"tests pass\" when you didn't run them.\n\n6. Stage and commit with a clear commit message. Start the message with the\n issue identifier in brackets, e.g.\n `[OFF-1234] fix: guard null projectId before database lookup`.\n\n## Scope boundary — do NOT open the PR here\n\nStop after the commit. Do NOT push the branch, do NOT run `gh pr create`,\ndo NOT call any PR-creation tool. The dedicated `create_pr` node opens the\nPR via `github_create_pr`. Opening it from this node bypasses the workflow's\neval gate and triggers a self-close-and-recreate workaround (PR opened via\nshell, then `github_create_pr` returns 422 \"already exists\" against the same\nbranch, then the agent closes the first PR to satisfy the eval). The\nidempotent fallback in `github_create_pr` will recover if you ignore this,\nbut the cleaner path is: commit only here, let `create_pr` push and open.\n\n## Test Requirements\n\nA code change without a test is unfinished work. Every fix this node ships\nMUST come with at least one new test case that:\n\n- **Would fail on the unfixed code.** A test that passes both before and\n after the fix is decoration, not a regression guard. Mentally run it\n against the previous version; if it would have passed, it is not the\n right test.\n- **Asserts the user-facing contract**, not just an implementation\n detail. For an API/handler bug: assert the response shape, status code,\n or error type the client sees. For a data corruption bug: assert the\n persisted row's final state, not just an internal call count.\n- **Covers the obvious adjacent edge cases.** If the bug was \"throws plain\n Error on missing record\", add the missing-record case AND at least one\n success-path case to confirm the fix didn't break the happy path.\n- **Lives in the right file** (next to the source under test, in the\n project's existing test layout).\n\nThe eval gate on this node REQUIRES `test_status` to be either\n`pass` (tests ran and all passed) OR `no-framework` (the project\ngenuinely has no test runner — not \"I couldn't be bothered\").\n\nIf the project HAS a test framework, you must write tests and they\nmust pass. Empty `test_files_changed` with `test_status: pass` is a\nlie and eval will reject it via the schema's required-fields\ncontract; `not-run` and `fail` will retry once with reflection and\nthen halt for human attention.\n\nIf the fix turns out to be more complex than expected, stop and\nexplain why — do not force a bad fix.","skills":["github"],"disallowed_tools":["WebFetch","WebSearch"],"output":{"type":"object","properties":{"branch":{"type":"string","description":"Feature branch name (must include the issue identifier)."},"commit_sha":{"type":"string","description":"SHA of the commit shipping the fix and tests."},"files_changed":{"type":"array","description":"Source files modified by the fix (NOT including test files).","items":{"type":"string"}},"test_files_changed":{"type":"array","description":"Test files added or modified to cover the fix. Should be\nnon-empty whenever `test_status: pass`. May be empty when\n`test_status: no-framework` (genuinely no test runner in\nthe repo).\n","items":{"type":"string"}},"test_command":{"type":"string","description":"Exact command run to validate (e.g. `npx nx test server`)."},"test_status":{"type":"string","enum":["pass","fail","no-framework","not-run","skipped"],"description":"Outcome of running the test suite.\n- `pass` — tests ran end-to-end and every assertion passed.\n `test_files_changed` MUST be non-empty in this case (you\n changed code; tests must cover it).\n- `no-framework` — the repo genuinely has no test runner\n (e.g. a docs-only repo, a YAML-config repo, a research\n notebook collection). Reserved for cases where there is\n no test infrastructure to extend — not a permission slip\n to skip tests in a normal codebase.\n- `fail` — tests ran and at least one failed. Do not push\n a broken fix. Return this status, retry will fire once.\n- `not-run` — tests exist but the environment couldn't\n execute them. Explain in `test_notes`. Retry will fire.\n- `skipped` — the node short-circuited before running tests\n because a precondition was not met. Today the only\n accepted reason is `open-pr-exists` (Step 0b found an\n open PR with the issue identifier in its title).\n `skipped_reason` and `existing_pr_url` MUST be populated.\n Downstream `create_pr` is bypassed and the workflow\n routes to `notify`.\n"},"skipped_reason":{"type":"string","description":"Why the node short-circuited. Only set when `test_status` is\n`skipped`. Today the only valid value is `open-pr-exists`.\n","enum":["open-pr-exists"]},"existing_pr_url":{"type":"string","description":"URL of the open PR that triggered the skip. Set when\n`skipped_reason` is `open-pr-exists` so the notify node\ncan link to the prior PR.\n","pattern":"^https?://"},"test_notes":{"type":"string","description":"One-paragraph explanation of which tests were added and what they assert."}},"required":["branch","commit_sha","files_changed","test_files_changed","test_status"]},"eval":[{"name":"test_status_is_pass_or_no_framework","kind":"value","rule":{"output_matches":[{"path":"test_status","in":["pass","no-framework","skipped"]}]}},{"name":"tests_present_when_pass_claimed","kind":"judge","rubric":"You are checking the contract between test_status and test_files_changed.\n\nRead result.data.test_status and result.data.test_files_changed.\n\nRules:\n- If test_status is \"pass\": test_files_changed MUST contain at least\n one real test file path (paths with extensions like .spec.ts,\n .test.ts, .test.tsx, .test.py, _test.go, _spec.rb, .test.rs,\n etc., or paths in a __tests__/, tests/, or spec/ directory).\n An empty array, or paths that are clearly not test files\n (README.md, package.json, source files), is a contract violation.\n- If test_status is \"no-framework\": test_files_changed MAY be empty.\n Pass vacuously.\n- If test_status is \"skipped\": test_files_changed MAY be empty.\n The node short-circuited before writing any code (Step 0b\n found an open PR for the issue). `skipped_reason` and\n `existing_pr_url` MUST be populated; if they are missing,\n return no.\n- If test_status is \"fail\" or \"not-run\": this evaluator should not\n run (the prior value evaluator catches that). Pass vacuously.\n\nRespond yes if the contract is satisfied, no if it is violated.\n","pass_when":"yes"}],"retry":{"max":1,"instruction":{"auto":true}}},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix. The issue identifier and URL are in your context from the create_issue step.\n\n1. Push the branch to the remote.\n2. Create the PR using the `github_create_pr` tool. **Use this exact tool name** — do NOT use `create_pull_request` or any other variant from ToolSearch. The eval check requires `github_create_pr` specifically.\n**If `github_create_pr` returns a result with `reused: true`**, the implement node (or a prior run) already opened a PR for this branch and the tool recovered it instead of failing on 422. Treat this as success and return the PR's URL and number — do NOT close it and call `github_create_pr` again.\n3. PR title MUST start with the issue identifier in brackets. Format: `[{context.create_issue.issueIdentifier}] fix: {short description}`.\n4. In the PR body, include: summary of the bug, what the fix does, and a link to the issue (context.create_issue.issueUrl). If the investigation found a Sentry issue, include \"Fixes {sentryShortId}\" in the PR body so Sentry auto-resolves on merge.\n5. Pass `labels: [\"sweny\", \"agent\", \"triage\"]` to `github_create_pr`.\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"],"eval":[{"name":"pr_was_created","kind":"function","rule":{"any_tool_called":["github_create_pr"]}}],"retry":{"max":1,"instruction":{"auto":true}}},"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":"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":"notify","when":"implement.test_status is \"skipped\" (Step 0b found an open PR already exists for the issue identifier)"},{"from":"implement","to":"create_pr","when":"implement.test_status is \"pass\" or \"no-framework\" (no existing PR; fix was implemented or there is no test framework)"},{"from":"create_pr","to":"notify"}],"skills":{}};
2
- export const implementWorkflow = {"id":"implement","name":"Implement Fix","workflow_type":"pr_review","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. **Check for an existing open PR for this issue.** Call\n `github_search_issues` (GitHub's `/search/issues` endpoint returns\n both issues and PRs) with `type:pr state:open` and the issue\n identifier wrapped in brackets — e.g. query\n `[OFF-1234] in:title type:pr state:open`. If the response contains\n one or more PRs whose title starts with `[<issueIdentifier>]`,\n STOP analysis. Output `existing_pr_url`\n (the URL of the open PR) and a short `issue_summary` noting the\n skip; leave `fix_plan` set to a one-line \"Skip: open PR\n <url> already exists\". The workflow will route to `notify` and\n the team will be told the prior PR is still pending.\n Rationale: re-implementing while an open PR exists produces a\n duplicate (or worse, the agent closes its own prior PR mid-run\n and opens a new one — exactly the thrash seen in\n `letsoffload/permit-service` PR #81 → #82 and\n `letsoffload/offload` PR #572 → #573).\n3. Read the relevant source files to understand the current code.\n4. Identify the exact files and lines that need to change.\n5. 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"]},"existing_pr_url":{"type":"string","description":"URL of an open PR found in step 2 whose title starts with the\nissue identifier in brackets. Set ONLY when a prior PR exists\nand the analyze node is short-circuiting. Triggers the\n`analyze → notify` edge.\n","pattern":"^https?://"}},"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\nStop after the commit. Do NOT push or open the PR — the dedicated `create_pr`\nnode handles that via `github_create_pr` and its eval gate is what unblocks\nthe rest of the workflow.\n\nIf the fix is too risky or complex, explain why and skip.","skills":["github"],"disallowed_tools":["WebFetch","WebSearch"]},"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. Pass `labels: [\"sweny\", \"agent\", \"task\"]` to `github_create_pr` (or `[\"sweny\", \"agent\", \"feature\"]` if the issue is a feature request).\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":"notify","when":"analyze.existing_pr_url is set (an open PR for this issue already exists; skip the implement and create_pr nodes)"},{"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"}],"skills":{}};
1
+ export const triageWorkflow = {"id":"triage","name":"Alert Triage","workflow_type":"pr_review","description":"Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team","entry":"gather","nodes":{"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, but stay in scope: read, search, and summarize. The investigation step depends on complete context, not on fixes.\n\n**Scope boundary (this node is READ-ONLY):**\n- DO NOT create issues, comments, branches, commits, or pull requests, and DO NOT implement fixes. Downstream nodes (`create_issue`, `implement`, `create_pr`) handle ALL writes.\n- Your ONLY job is to collect context and report it, then stop. Doing downstream work here burns your turn budget and duplicates what later nodes will do.","skills":["github","sentry","datadog","betterstack","linear"],"tools":{"deny":["linear_create_issue","linear_add_comment","linear_update_issue","github_create_issue","github_add_comment","github_create_pr"]},"disallowed_tools":["Write","Edit","NotebookEdit"],"fail_soft":true},"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 — you MUST call tools for this step):** Filing a duplicate ticket is worse than under-reporting — the team inherits sprawl that humans have to clean up later. Complete EVERY step below before classifying anything as novel. Do NOT skip these searches even if the gather context already mentions existing issues or branches — you must verify independently.\n4a. **Search broadly — THIS REQUIRES TOOL CALLS.** You MUST call `linear_search_issues` (or `github_search_issues`) with multiple queries against the issue tracker (BOTH open AND closed): the affected file or module path, the error message or log string verbatim, the feature or service name, and the symptom family (e.g. \"startup crash\", \"data dropped silently\"). Do not rely on a single keyword — a narrow search is how duplicates slip through. If you skip this search, the eval check will fail.\n4b. **Read the full body of every promising candidate — do NOT match on title alone.** For each top match, open the issue and read its entire description, including any \"Recommended Fix\", \"Proposed Fix\", \"Action Items\", or \"Acceptance Criteria\" section. An existing open issue whose recommended fix already prescribes the change you were about to propose IS a duplicate, even if the symptom you just observed is a new instance of the same underlying bug.\n4c. **Check for meta-issues and bug families.** If an existing ticket names a pattern that your finding is another instance of (same root cause, same function/module, different input or caller), treat your finding as a sibling of that family and comment on the existing ticket. Do not file a parallel ticket per instance — that is the exact pattern that produces backlog sprawl.\n4d. **When uncertain, comment don't file.** If you are less than ~80% confident a finding is truly novel after steps 4a–4c, classify it as a duplicate and add a \"+1 — another instance\" comment to the closest existing issue with the new context. Over-reporting duplicates is cheap; ticket sprawl is expensive.\n\n - If matched → **duplicate**. Record the existing issue ID in `duplicate_of`.\n - If no match after 4a–4d → **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.\n\n**IMPORTANT — scope boundaries for this node:**\n- DO NOT create issues (Linear, GitHub, or any tracker). A downstream `create_issue` node handles that.\n- DO NOT create branches, commits, or pull requests. A downstream `implement` node handles that.\n- DO NOT call any write tools (`linear_create_issue`, `github_create_issue`, `github_create_pr`, `linear_add_comment`, `github_add_comment`, etc.). Downstream nodes handle all writes — issue creation, +1 comments, and PRs.\n- Your ONLY job is read, search, classify, and output. If you create an issue or add a comment here, the downstream eval check will fail or duplicate work.","skills":["github","linear"],"tools":{"deny":["linear_create_issue","linear_add_comment","linear_update_issue","github_create_issue","github_add_comment","github_create_pr"]},"eval":[{"name":"novelty_search_was_performed","kind":"function","rule":{"any_tool_called":["linear_search_issues","github_search_issues"]}}],"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. **First, check if a prior triage run already created an issue for this exact bug.** Call `linear_search_issues` (or `github_search_issues`) with the error message or root cause. If a matching issue already exists with the same root cause:\n - Treat it as a duplicate: DO NOT create a new issue.\n - Populate `issueIdentifier`, `issueTitle`, and `issueUrl` from the existing issue.\n - Add a \"+1\" comment if appropriate (using `linear_add_comment` or `github_add_comment`).\n - Set the `action` to `updated` in the `issues` array.\n - This prevents duplicate tickets when the same alert fires multiple times.\n\n2. If no matching issue exists, create a new one with a clear, actionable title by invoking `linear_create_issue` or `github_create_issue`. You MUST actually call the tool — do NOT synthesize an identifier from an alert ID, commit SHA, or your own imagination. If the tool errors, stop and report the failure; do not invent a fallback identifier. **Use the exact tool names above** — do NOT use generic alternatives like `create_issue` or `create_pull_request` from ToolSearch. The eval check requires the specific tool names listed here.\n3. Include: root cause, severity, affected services, reproduction steps, and recommended fix.\n4. Apply the `sweny`, `agent`, and `triage` labels. For Linear: call `linear_list_labels` to find the IDs for labels named `sweny`, `agent`, and `triage`, then pass those IDs in `labelIds` when calling `linear_create_issue` (skip any that don't exist in the team — don't invent IDs). For GitHub: pass `labels: [\"sweny\", \"agent\", \"triage\"]` to `github_create_issue`.\n5. Link to relevant commits, PRs, or existing issues.\n6. Copy the identifier returned by the tool verbatim into the `issueIdentifier` output field. The identifier must match `^[A-Z][A-Z0-9]*-\\d+$` (e.g. `OFF-1234`) — do not prefix it with anything else.\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.\n\n**IMPORTANT — scope boundaries for this node:**\n- DO NOT create branches, commits, or pull requests. The `implement` and `create_pr` nodes handle that.\n- DO NOT call `create_pull_request`, `github_create_pr`, or any PR/branch tools.\n- Your job is issue creation and duplicate management ONLY.","skills":["linear","github"],"tools":{"deny":["github_create_pr"]},"eval":[{"name":"issue_tracker_tool_was_called","kind":"function","rule":{"any_tool_called":["linear_create_issue","github_create_issue","linear_search_issues","github_search_issues","linear_add_comment","github_add_comment"]}}],"output":{"type":"object","properties":{"issueIdentifier":{"type":"string","pattern":"^[A-Z][A-Z0-9]*-\\d+$","description":"Primary issue identifier (e.g. OFF-1234). MUST be the identifier returned by `linear_create_issue` / `github_create_issue` — do NOT invent a value from a Sentry short-ID or commit SHA. Used by downstream nodes for branch naming and PR linking."},"issueTitle":{"type":"string","description":"Title of the primary issue created"},"issueUrl":{"type":"string","pattern":"^https?://","description":"URL of the primary issue created, as returned by the create-issue tool"},"issues":{"type":"array","minItems":1,"description":"All issues created or updated during this step","items":{"type":"object","properties":{"identifier":{"type":"string"},"title":{"type":"string"},"url":{"type":"string"},"action":{"type":"string","enum":["created","updated","reopened"]}},"required":["identifier","action"]}}},"required":["issueIdentifier","issueTitle","issueUrl","issues"]},"retry":{"max":1,"instruction":{"auto":true}}},"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"],"tools":{"deny":["linear_create_issue","github_create_issue","github_create_pr"]}},"implement":{"name":"Implement Fix","instruction":"Implement the fix identified during investigation, plus the tests that pin\nit as a regression guard. The issue created in the previous step is in your\ncontext — use its identifier for branch naming and commit messages.\n\n## Quality bar (non-negotiable)\n\nEvery change you make must clear all of these. If you cannot, STOP and\nreturn a failed status — do not ship a half-measure.\n\n- **Correctness**: the fix actually closes the failure mode in the issue.\n- **Completeness**: handles the obvious edge cases (nulls, empties, off-by-one,\n concurrent paths). If the issue lists multiple symptoms, address them all\n or explicitly state which are out of scope and why.\n- **Idiomatic for THIS repo, not in general.** Read at least one\n neighboring file in the same module before you write any code, and\n match the patterns you see: error-handling primitives the codebase\n already uses, type discipline, naming, file layout. Examples to make\n the shape concrete:\n - In a NestJS service, throwing `NotFoundException` /\n `BadRequestException` is idiomatic; `throw new Error(...)` is not.\n - In a Go service, returning a wrapped `fmt.Errorf(\"...: %w\", err)`\n beats panicking.\n - In a TypeScript repo, narrow types properly — do not `as any` past\n the type checker.\n These are illustrations. Generalize: whatever the repo already does,\n keep doing.\n- **No hacky shortcuts**: no `try/catch` to swallow errors, no commented-out\n guards, no `// TODO: revisit`, no global flags to bypass validation, no\n copy-pasted bodies. If the right fix needs a small refactor (e.g. extract\n a helper), do it; if it needs a large refactor, file a follow-up issue\n and ship the minimal correct fix without the refactor.\n- **Well-tested**: see Test Requirements below.\n\n## Steps\n\n0a. **Precondition — verify context.create_issue.issueIdentifier is well-formed.**\n The identifier MUST match `^[A-Z][A-Z0-9]*-\\d+$` (e.g. `OFF-1234`). If it\n is missing, empty, or matches a Sentry short-ID / slug / any other shape,\n STOP immediately — do NOT invent a branch name from the Sentry event,\n commit SHA, or alert payload. Return a failed status with a clear message\n instead; the workflow will route to notify and a human will pick it up.\n\n0b. **Precondition — check for an existing open PR for this issue.** Call\n `github_search_issues` (which hits GitHub's `/search/issues` endpoint\n and returns both issues and PRs) with `type:pr state:open` and the\n issue identifier wrapped in brackets — e.g. query\n `[OFF-1234] in:title type:pr state:open` and pass `repo` for scoping.\n If the response contains one or more PRs whose title starts with\n `[<issueIdentifier>]`, STOP. Do NOT create a branch, commit, or any\n new PR. Output:\n\n ```\n test_status: \"skipped\"\n skipped_reason: \"open-pr-exists\"\n existing_pr_url: \"<url of the existing PR>\"\n test_files_changed: []\n files_changed: []\n branch: \"\"\n commit_sha: \"\"\n ```\n\n The downstream `create_pr` node will be bypassed and the workflow will\n route to `notify` so the team is told the prior PR is still pending.\n\n Rationale: re-implementing a fix while an open PR already exists\n produces a duplicate (or worse, the agent closes its own prior PR\n mid-run and opens a new one with a different approach — exactly the\n thrash observed in `letsoffload/permit-service` PR #81 → #82 and\n `letsoffload/offload` PR #572 → #573, where two PRs for the same\n identifier were opened-and-closed 12 to 16 seconds apart inside a\n single workflow run).\n\n Only proceed to Step 1 if no open PR exists for the identifier.\n\n1. Create a feature branch from the base branch (check context for\n baseBranch, default `main`). **Branch name MUST include the issue\n identifier** exactly as provided in `context.create_issue.issueIdentifier`.\n Format: lowercase the identifier and use it as a prefix, e.g.\n `off-1234-fix-null-check`. If the repository uses a different branch\n convention (check context.branchPattern or the rules/CLAUDE.md for a\n regex), follow that — but the identifier piece MUST still come from\n `context.create_issue.issueIdentifier`, never from a Sentry short-ID.\n\n2. Read the relevant source files to understand the current code AND read\n the nearest existing test file (e.g. `foo.service.spec.ts` next to\n `foo.service.ts`, or `foo_test.py` next to `foo.py`) so your new tests\n match the existing test conventions: framework, mock style, factory use,\n describe/it shape, naming.\n\n3. Make the necessary code change. Keep it minimal and focused — do not\n refactor surrounding code or add unrelated improvements.\n\n4. **Write tests for the fix.** See Test Requirements below.\n\n5. **Run the test suite for the affected package** and confirm green\n (the package's `test` command from package.json, `pytest` for Python,\n etc.). If the test runner is not obvious, read the project's\n CLAUDE.md / README / package.json scripts. If you cannot run the\n tests at all in this environment, say so explicitly in the output —\n do not claim \"tests pass\" when you didn't run them.\n\n6. Stage and commit with a clear commit message. Start the message with the\n issue identifier in brackets, e.g.\n `[OFF-1234] fix: guard null projectId before database lookup`.\n\n## Scope boundary — do NOT open the PR here\n\nStop after the commit. Do NOT push the branch, do NOT run `gh pr create`,\ndo NOT call any PR-creation tool. The dedicated `create_pr` node opens the\nPR via `github_create_pr`. Opening it from this node bypasses the workflow's\neval gate and triggers a self-close-and-recreate workaround (PR opened via\nshell, then `github_create_pr` returns 422 \"already exists\" against the same\nbranch, then the agent closes the first PR to satisfy the eval). The\nidempotent fallback in `github_create_pr` will recover if you ignore this,\nbut the cleaner path is: commit only here, let `create_pr` push and open.\n\n## Test Requirements\n\nA code change without a test is unfinished work. Every fix this node ships\nMUST come with at least one new test case that:\n\n- **Would fail on the unfixed code.** A test that passes both before and\n after the fix is decoration, not a regression guard. Mentally run it\n against the previous version; if it would have passed, it is not the\n right test.\n- **Asserts the user-facing contract**, not just an implementation\n detail. For an API/handler bug: assert the response shape, status code,\n or error type the client sees. For a data corruption bug: assert the\n persisted row's final state, not just an internal call count.\n- **Covers the obvious adjacent edge cases.** If the bug was \"throws plain\n Error on missing record\", add the missing-record case AND at least one\n success-path case to confirm the fix didn't break the happy path.\n- **Lives in the right file** (next to the source under test, in the\n project's existing test layout).\n\nThe eval gate on this node REQUIRES `test_status` to be either\n`pass` (tests ran and all passed) OR `no-framework` (the project\ngenuinely has no test runner — not \"I couldn't be bothered\").\n\nIf the project HAS a test framework, you must write tests and they\nmust pass. Empty `test_files_changed` with `test_status: pass` is a\nlie and eval will reject it via the schema's required-fields\ncontract; `not-run` and `fail` will retry once with reflection and\nthen halt for human attention.\n\nIf the fix turns out to be more complex than expected, stop and\nexplain why — do not force a bad fix.","skills":["github"],"disallowed_tools":["WebFetch","WebSearch"],"tools":{"deny":["github_create_pr"]},"output":{"type":"object","properties":{"branch":{"type":"string","description":"Feature branch name (must include the issue identifier)."},"commit_sha":{"type":"string","description":"SHA of the commit shipping the fix and tests."},"files_changed":{"type":"array","description":"Source files modified by the fix (NOT including test files).","items":{"type":"string"}},"test_files_changed":{"type":"array","description":"Test files added or modified to cover the fix. Should be\nnon-empty whenever `test_status: pass`. May be empty when\n`test_status: no-framework` (genuinely no test runner in\nthe repo).\n","items":{"type":"string"}},"test_command":{"type":"string","description":"Exact command run to validate (e.g. `npx nx test server`)."},"test_status":{"type":"string","enum":["pass","fail","no-framework","not-run","skipped"],"description":"Outcome of running the test suite.\n- `pass` — tests ran end-to-end and every assertion passed.\n `test_files_changed` MUST be non-empty in this case (you\n changed code; tests must cover it).\n- `no-framework` — the repo genuinely has no test runner\n (e.g. a docs-only repo, a YAML-config repo, a research\n notebook collection). Reserved for cases where there is\n no test infrastructure to extend — not a permission slip\n to skip tests in a normal codebase.\n- `fail` — tests ran and at least one failed. Do not push\n a broken fix. Return this status, retry will fire once.\n- `not-run` — tests exist but the environment couldn't\n execute them. Explain in `test_notes`. Retry will fire.\n- `skipped` — the node short-circuited before running tests\n because a precondition was not met. Today the only\n accepted reason is `open-pr-exists` (Step 0b found an\n open PR with the issue identifier in its title).\n `skipped_reason` and `existing_pr_url` MUST be populated.\n Downstream `create_pr` is bypassed and the workflow\n routes to `notify`.\n"},"skipped_reason":{"type":"string","description":"Why the node short-circuited. Only set when `test_status` is\n`skipped`. Today the only valid value is `open-pr-exists`.\n","enum":["open-pr-exists"]},"existing_pr_url":{"type":"string","description":"URL of the open PR that triggered the skip. Set when\n`skipped_reason` is `open-pr-exists` so the notify node\ncan link to the prior PR.\n","pattern":"^https?://"},"test_notes":{"type":"string","description":"One-paragraph explanation of which tests were added and what they assert."}},"required":["branch","commit_sha","files_changed","test_files_changed","test_status"]},"eval":[{"name":"test_status_is_pass_or_no_framework","kind":"value","rule":{"output_matches":[{"path":"test_status","in":["pass","no-framework","skipped"]}]}},{"name":"tests_present_when_pass_claimed","kind":"judge","rubric":"You are checking the contract between test_status and test_files_changed.\n\nRead result.data.test_status and result.data.test_files_changed.\n\nRules:\n- If test_status is \"pass\": test_files_changed MUST contain at least\n one real test file path (paths with extensions like .spec.ts,\n .test.ts, .test.tsx, .test.py, _test.go, _spec.rb, .test.rs,\n etc., or paths in a __tests__/, tests/, or spec/ directory).\n An empty array, or paths that are clearly not test files\n (README.md, package.json, source files), is a contract violation.\n- If test_status is \"no-framework\": test_files_changed MAY be empty.\n Pass vacuously.\n- If test_status is \"skipped\": test_files_changed MAY be empty.\n The node short-circuited before writing any code (Step 0b\n found an open PR for the issue). `skipped_reason` and\n `existing_pr_url` MUST be populated; if they are missing,\n return no.\n- If test_status is \"fail\" or \"not-run\": this evaluator should not\n run (the prior value evaluator catches that). Pass vacuously.\n\nRespond yes if the contract is satisfied, no if it is violated.\n","pass_when":"yes"}],"retry":{"max":1,"instruction":{"auto":true}}},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix. The issue identifier and URL are in your context from the create_issue step.\n\n1. Push the branch to the remote.\n2. Create the PR using the `github_create_pr` tool. **Use this exact tool name** — do NOT use `create_pull_request` or any other variant from ToolSearch. The eval check requires `github_create_pr` specifically.\n**If `github_create_pr` returns a result with `reused: true`**, the implement node (or a prior run) already opened a PR for this branch and the tool recovered it instead of failing on 422. Treat this as success and return the PR's URL and number — do NOT close it and call `github_create_pr` again.\n3. PR title MUST start with the issue identifier in brackets. Format: `[{context.create_issue.issueIdentifier}] fix: {short description}`.\n4. In the PR body, include: summary of the bug, what the fix does, and a link to the issue (context.create_issue.issueUrl). If the investigation found a Sentry issue, include \"Fixes {sentryShortId}\" in the PR body so Sentry auto-resolves on merge. If the issue tracker is Linear, also include the line \"Fixes {context.create_issue.issueIdentifier}\" (e.g. \"Fixes OFF-1234\") so Linear links the PR and moves the issue to Done on merge.\n5. Pass `labels: [\"sweny\", \"agent\", \"triage\"]` to `github_create_pr`.\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"],"eval":[{"name":"pr_was_created","kind":"function","rule":{"any_tool_called":["github_create_pr"]}}],"retry":{"max":1,"instruction":{"auto":true}}},"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":"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":"notify","when":"implement.test_status is \"skipped\" (Step 0b found an open PR already exists for the issue identifier)"},{"from":"implement","to":"create_pr","when":"implement.test_status is \"pass\" or \"no-framework\" (no existing PR; fix was implemented or there is no test framework)"},{"from":"create_pr","to":"notify"}],"skills":{}};
2
+ export const implementWorkflow = {"id":"implement","name":"Implement Fix","workflow_type":"pr_review","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. **Check for an existing open PR for this issue.** Call\n `github_search_issues` (GitHub's `/search/issues` endpoint returns\n both issues and PRs) with `type:pr state:open` and the issue\n identifier wrapped in brackets — e.g. query\n `[OFF-1234] in:title type:pr state:open`. If the response contains\n one or more PRs whose title starts with `[<issueIdentifier>]`,\n STOP analysis. Output `existing_pr_url`\n (the URL of the open PR) and a short `issue_summary` noting the\n skip; leave `fix_plan` set to a one-line \"Skip: open PR\n <url> already exists\". The workflow will route to `notify` and\n the team will be told the prior PR is still pending.\n Rationale: re-implementing while an open PR exists produces a\n duplicate (or worse, the agent closes its own prior PR mid-run\n and opens a new one — exactly the thrash seen in\n `letsoffload/permit-service` PR #81 → #82 and\n `letsoffload/offload` PR #572 → #573).\n3. Read the relevant source files to understand the current code.\n4. Identify the exact files and lines that need to change.\n5. 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"]},"existing_pr_url":{"type":"string","description":"URL of an open PR found in step 2 whose title starts with the\nissue identifier in brackets. Set ONLY when a prior PR exists\nand the analyze node is short-circuiting. Triggers the\n`analyze → notify` edge.\n","pattern":"^https?://"}},"required":["issue_summary","fix_plan"]}},"implement":{"name":"Implement Fix","instruction":"Implement the planned fix:\n\n1. Create a feature branch. The branch name MUST start with the issue\n identifier from `context.input.issueIdentifier`, lowercased, followed\n by a short kebab-case description: e.g. `off-1234-fix-null-check`.\n2. Make the necessary code changes.\n3. Ensure changes are minimal and focused — fix the bug, nothing more.\n4. Stage and commit. Start the commit message with the issue identifier\n in brackets, e.g. `[OFF-1234] fix: guard null projectId`.\n\nStop after the commit. Do NOT push or open the PR — the dedicated `create_pr`\nnode handles that via `github_create_pr` and its eval gate is what unblocks\nthe rest of the workflow.\n\nIf the fix is too risky or complex, explain why and skip.","skills":["github"],"disallowed_tools":["WebFetch","WebSearch"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create the PR using `github_create_pr`. The PR title MUST start with the issue identifier in brackets: `[{context.input.issueIdentifier}] type: short description` (e.g. `[OFF-1234] fix: guard null projectId`).\n3. Reference the original issue in the PR body. If the issue tracker is Linear, include the line \"Fixes {context.input.issueIdentifier}\" (e.g. \"Fixes OFF-1234\") so Linear links the PR and moves the issue to Done on merge.\n4. Pass `labels: [\"sweny\", \"agent\", \"task\"]` to `github_create_pr` (or `[\"sweny\", \"agent\", \"feature\"]` if the issue is a feature request).\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":"notify","when":"analyze.existing_pr_url is set (an open PR for this issue already exists; skip the implement and create_pr nodes)"},{"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"}],"skills":{}};
3
3
  export const seedContentWorkflow = {"id":"seed-content","name":"Content Generation Pipeline","workflow_type":"content_generation","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"}],"skills":{}};
@@ -67,10 +67,13 @@ nodes:
67
67
  instruction: |-
68
68
  Implement the planned fix:
69
69
 
70
- 1. Create a feature branch.
70
+ 1. Create a feature branch. The branch name MUST start with the issue
71
+ identifier from `context.input.issueIdentifier`, lowercased, followed
72
+ by a short kebab-case description: e.g. `off-1234-fix-null-check`.
71
73
  2. Make the necessary code changes.
72
74
  3. Ensure changes are minimal and focused — fix the bug, nothing more.
73
- 4. Stage and commit with a clear commit message referencing the issue.
75
+ 4. Stage and commit. Start the commit message with the issue identifier
76
+ in brackets, e.g. `[OFF-1234] fix: guard null projectId`.
74
77
 
75
78
  Stop after the commit. Do NOT push or open the PR — the dedicated `create_pr`
76
79
  node handles that via `github_create_pr` and its eval gate is what unblocks
@@ -93,9 +96,13 @@ nodes:
93
96
 
94
97
  1. Push the branch to the remote.
95
98
 
96
- 2. Create a PR with a clear title and description.
99
+ 2. Create the PR using `github_create_pr`. The PR title MUST start with the issue identifier in
100
+ brackets: `[{context.input.issueIdentifier}] type: short description` (e.g. `[OFF-1234] fix: guard
101
+ null projectId`).
97
102
 
98
- 3. Reference the original issue in the PR body.
103
+ 3. Reference the original issue in the PR body. If the issue tracker is Linear, include the line
104
+ "Fixes {context.input.issueIdentifier}" (e.g. "Fixes OFF-1234") so Linear links the PR and moves
105
+ the issue to Done on merge.
99
106
 
100
107
  4. Pass `labels: ["sweny", "agent", "task"]` to `github_create_pr` (or `["sweny", "agent", "feature"]` if the issue is a feature request).
101
108
 
@@ -18,13 +18,53 @@ nodes:
18
18
  3. **Issue tracker**: Search for similar past issues or known problems.
19
19
 
20
20
 
21
- Be thorough — the investigation step depends on complete context. Use every tool available to you.
21
+ Be thorough, but stay in scope: read, search, and summarize. The investigation step depends on
22
+ complete context, not on fixes.
23
+
24
+
25
+ **Scope boundary (this node is READ-ONLY):**
26
+
27
+ - DO NOT create issues, comments, branches, commits, or pull requests, and DO NOT implement
28
+ fixes. Downstream nodes (`create_issue`, `implement`, `create_pr`) handle ALL writes.
29
+
30
+ - Your ONLY job is to collect context and report it, then stop. Doing downstream work here
31
+ burns your turn budget and duplicates what later nodes will do.
22
32
  skills:
23
33
  - github
24
34
  - sentry
25
35
  - datadog
26
36
  - betterstack
27
37
  - linear
38
+ # Structural enforcement of the read-only scope above: write tools from
39
+ # the github/linear skills are never registered for this node, so the
40
+ # model cannot call them no matter what the prompt fails to prevent.
41
+ # Driving incident: the 2026-06-08 letsoffload/permit-service scheduled
42
+ # run, where gather created Linear issues OFF-2332/OFF-2333 and PRs
43
+ # #101/#102 from inside this node and then died at the 50-turn cap.
44
+ tools:
45
+ deny:
46
+ - linear_create_issue
47
+ - linear_add_comment
48
+ - linear_update_issue
49
+ - github_create_issue
50
+ - github_add_comment
51
+ - github_create_pr
52
+ # Same incident: gather authored code changes mid-gather. Write/Edit are
53
+ # removed from the model context entirely. Bash stays available because
54
+ # read-side investigation legitimately uses it (git log, grep); blocking
55
+ # specific Bash invocations (git commit, gh pr create) needs the SDK's
56
+ # canUseTool callback and is tracked as follow-up work (see the implement
57
+ # node's note below).
58
+ disallowed_tools:
59
+ - Write
60
+ - Edit
61
+ - NotebookEdit
62
+ # When gather still dies early (turn cap, SDK error), proceed with the
63
+ # partial context instead of failing the whole workflow: investigate has
64
+ # its own tools and can re-derive what gather missed. The 2026-06-08 run
65
+ # proved this empirically: after gather "failed", investigate completed
66
+ # fine and produced a correct no-novel-issues verdict.
67
+ fail_soft: true
28
68
  investigate:
29
69
  name: Root Cause Analysis
30
70
  instruction: >-
@@ -100,6 +140,16 @@ nodes:
100
140
  skills:
101
141
  - github
102
142
  - linear
143
+ # Structural enforcement of the scope boundaries above: the write tools
144
+ # this node is told not to call are not registered for it at all.
145
+ tools:
146
+ deny:
147
+ - linear_create_issue
148
+ - linear_add_comment
149
+ - linear_update_issue
150
+ - github_create_issue
151
+ - github_add_comment
152
+ - github_create_pr
103
153
  eval:
104
154
  # The novelty check REQUIRES searching the issue tracker. If the node finishes
105
155
  # with 0 search tool calls, it means the agent skipped the novelty check entirely
@@ -239,6 +289,10 @@ nodes:
239
289
  skills:
240
290
  - linear
241
291
  - github
292
+ # Structural enforcement: this node manages issues, never PRs.
293
+ tools:
294
+ deny:
295
+ - github_create_pr
242
296
  eval:
243
297
  # Require a real issue-tracker tool call. linear_create_issue / github_create_issue
244
298
  # for novel findings; linear_search_issues / github_search_issues for duplicate-check
@@ -326,6 +380,13 @@ nodes:
326
380
  skills:
327
381
  - linear
328
382
  - github
383
+ # Structural enforcement: this node comments on and reopens existing
384
+ # issues only. It never creates issues or PRs.
385
+ tools:
386
+ deny:
387
+ - linear_create_issue
388
+ - github_create_issue
389
+ - github_create_pr
329
390
  implement:
330
391
  name: Implement Fix
331
392
  instruction: |-
@@ -490,6 +551,12 @@ nodes:
490
551
  disallowed_tools:
491
552
  - WebFetch
492
553
  - WebSearch
554
+ # Structural enforcement of the "do NOT open the PR here" boundary: the
555
+ # PR tool is not registered for this node. The dedicated create_pr node
556
+ # is the only place github_create_pr exists.
557
+ tools:
558
+ deny:
559
+ - github_create_pr
493
560
  output:
494
561
  type: object
495
562
  properties:
@@ -636,7 +703,9 @@ nodes:
636
703
 
637
704
  4. In the PR body, include: summary of the bug, what the fix does, and a link to the issue
638
705
  (context.create_issue.issueUrl). If the investigation found a Sentry issue, include "Fixes {sentryShortId}"
639
- in the PR body so Sentry auto-resolves on merge.
706
+ in the PR body so Sentry auto-resolves on merge. If the issue tracker is Linear, also include the line
707
+ "Fixes {context.create_issue.issueIdentifier}" (e.g. "Fixes OFF-1234") so Linear links the PR and moves
708
+ the issue to Done on merge.
640
709
 
641
710
  5. Pass `labels: ["sweny", "agent", "triage"]` to `github_create_pr`.
642
711
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"