@sweny-ai/core 0.1.151 → 0.1.152

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.
@@ -33,6 +33,7 @@ const LEGACY_WRITE_TOOLS = {
33
33
  comment: ["github_add_comment", "linear_add_comment", "linear_update_issue"],
34
34
  pr: ["github_create_pr"],
35
35
  label: ["github_add_labels"],
36
+ issue_state: ["linear_update_issue", "linear_set_issue_state", "github_set_issue_state"],
36
37
  };
37
38
  const writeToolsBySkill = new Map(builtinSkills.map((s) => [s.id, s.tools.filter((t) => t.access !== "read").map((t) => t.name)]));
38
39
  function skillWriteTools(node) {
@@ -59,7 +60,7 @@ export function readGaps(p) {
59
60
  }
60
61
  /** The skill that applies this output type for these providers, or undefined when none can. */
61
62
  export function outputSkill(type, p) {
62
- if (type === "issue" || type === "comment")
63
+ if (type === "issue" || type === "comment" || type === "issue_state")
63
64
  return TRACKER_SKILL[p.issueTracker];
64
65
  return p.sourceControl === "github" ? "github" : undefined;
65
66
  }
@@ -9,7 +9,7 @@
9
9
  * the write, so this holds on every harness.
10
10
  *
11
11
  * Order of checks per intent (all deterministic): declared type, workflow
12
- * ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
12
+ * ceiling, actor trust, expiry, skill, target pin, issue pin, state, shape and size,
13
13
  * labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
14
14
  * over the intents that passed and can only veto them.
15
15
  */
@@ -31,6 +31,8 @@ export interface SafeOutputIntent {
31
31
  labels?: string[];
32
32
  head?: string;
33
33
  base?: string;
34
+ /** issue_state: `reopen` or `close`. */
35
+ state?: string;
34
36
  dedupe_key?: string;
35
37
  /** ms since epoch when the intent was recorded. */
36
38
  recordedAt: number;
@@ -9,7 +9,7 @@
9
9
  * the write, so this holds on every harness.
10
10
  *
11
11
  * Order of checks per intent (all deterministic): declared type, workflow
12
- * ceiling, actor trust, expiry, skill, target pin, issue pin, shape and size,
12
+ * ceiling, actor trust, expiry, skill, target pin, issue pin, state, shape and size,
13
13
  * labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
14
14
  * over the intents that passed and can only veto them.
15
15
  */
@@ -49,7 +49,7 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
49
49
  return {
50
50
  name: EMIT_OUTPUT_TOOL,
51
51
  access: "read",
52
- description: "Request a write (comment, issue, pr or label). This does NOT write anything now: sweny checks the request " +
52
+ description: "Request a write (comment, issue, pr, label or issue_state). This does NOT write anything now: sweny checks the request " +
53
53
  "against this step's declared outputs and limits, and applies it after the step finishes. Call once per write. " +
54
54
  `Allowed types here: ${types.join(", ")}.`,
55
55
  input_schema: {
@@ -64,8 +64,9 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
64
64
  },
65
65
  number: {
66
66
  type: "string",
67
- description: "Issue or PR number (GitHub) or issue id (Linear), for comment and label",
67
+ description: "Issue or PR number (GitHub) or issue id (Linear), for comment, label and issue_state",
68
68
  },
69
+ state: { type: "string", enum: ["reopen", "close"], description: "issue_state only: reopen or close" },
69
70
  labels: { type: "array", items: { type: "string" }, description: "Labels to add" },
70
71
  head: { type: "string", description: "pr only: branch with the changes" },
71
72
  base: { type: "string", description: "pr only: target branch (default main)" },
@@ -88,7 +89,7 @@ export function createEmitOutputTool(declarations, buffer, now = Date.now) {
88
89
  return { recorded: false, error: `limit reached: at most ${max} ${type} write(s) from this step` };
89
90
  }
90
91
  const intent = { type, recordedAt: now() };
91
- const fields = ["title", "body", "target", "number", "head", "base", "dedupe_key"];
92
+ const fields = ["title", "body", "target", "number", "head", "base", "state", "dedupe_key"];
92
93
  for (const f of fields) {
93
94
  const v = str(input?.[f]);
94
95
  if (v !== undefined)
@@ -117,6 +118,8 @@ export function safeOutputsInstruction(declarations, input) {
117
118
  const pin = resolvePin(d.number, input);
118
119
  parts.push(pin ? `only issue or PR ${pin} (the number may be omitted)` : "pinned issue not set for this run");
119
120
  }
121
+ if (d.type === "issue_state")
122
+ parts.push(d.state ? `only to ${d.state}` : "reopen or close");
120
123
  if (d.title_prefix)
121
124
  parts.push(`title prefix "${d.title_prefix}"`);
122
125
  if (d.type === "label" && d.labels?.length)
@@ -247,6 +250,11 @@ const APPLIERS = {
247
250
  needsTarget: true,
248
251
  build: (i) => ({ repo: i.target, issue_number: Number(i.number), labels: i.labels }),
249
252
  },
253
+ issue_state: {
254
+ tool: "github_set_issue_state",
255
+ needsTarget: true,
256
+ build: (i) => ({ repo: i.target, issue_number: Number(i.number), state: i.state }),
257
+ },
250
258
  },
251
259
  linear: {
252
260
  comment: {
@@ -264,6 +272,11 @@ const APPLIERS = {
264
272
  ...(i.labels ? { labelIds: i.labels } : {}),
265
273
  }),
266
274
  },
275
+ issue_state: {
276
+ tool: "linear_set_issue_state",
277
+ needsTarget: false,
278
+ build: (i) => ({ issueId: i.number, state: i.state }),
279
+ },
267
280
  },
268
281
  };
269
282
  function dedupeKey(w, explicit) {
@@ -279,6 +292,7 @@ function dedupeKey(w, explicit) {
279
292
  [...(w.labels ?? [])].sort().join(","),
280
293
  w.head ?? "",
281
294
  w.base ?? "",
295
+ ...(w.state ? [w.state] : []),
282
296
  ];
283
297
  return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
284
298
  }
@@ -377,9 +391,11 @@ export async function applySafeOutputs(o) {
377
391
  refuse(type === "comment" && !body.trim() ? "missing body" : "body too long", at);
378
392
  continue;
379
393
  }
380
- // Issue pin: a pinned comment or label may only land on that issue or PR.
394
+ // Issue pin: a pinned comment, label or state change may only land on that issue or PR.
395
+ // issue_state never guesses an issue: it needs the pin or a number on the intent.
396
+ const onIssue = type === "comment" || type === "label" || type === "issue_state";
381
397
  let issueRef = intent.number?.trim().replace(/^#/, "") || undefined;
382
- if ((type === "comment" || type === "label") && decl.number !== undefined) {
398
+ if (onIssue && decl.number !== undefined) {
383
399
  const pin = resolvePin(decl.number, o.input);
384
400
  if (!pin) {
385
401
  refuse("pinned issue is not set for this run", at);
@@ -391,10 +407,29 @@ export async function applySafeOutputs(o) {
391
407
  }
392
408
  issueRef = pin;
393
409
  }
394
- if ((type === "comment" || type === "label") && !issueRef) {
410
+ if (onIssue && !issueRef) {
395
411
  refuse("missing number", at);
396
412
  continue;
397
413
  }
414
+ // State change: reopen or close, inside the declared direction.
415
+ let state;
416
+ if (type === "issue_state") {
417
+ const asked = intent.state?.trim().toLowerCase() || decl.state;
418
+ if (asked !== "reopen" && asked !== "close") {
419
+ refuse("state must be reopen or close", at);
420
+ continue;
421
+ }
422
+ if (decl.state && asked !== decl.state) {
423
+ refuse("state outside the declared state", at);
424
+ continue;
425
+ }
426
+ // Closing needs the declaration to pin the issue (a workflow that skipped validation cannot bypass it).
427
+ if (asked === "close" && decl.number === undefined) {
428
+ refuse("close needs a pinned issue", at);
429
+ continue;
430
+ }
431
+ state = asked;
432
+ }
398
433
  if (via === "github" && issueRef !== undefined && !/^[1-9][0-9]*$/.test(issueRef)) {
399
434
  refuse("number must be an issue or PR number", at);
400
435
  continue;
@@ -432,11 +467,11 @@ export async function applySafeOutputs(o) {
432
467
  refuse("title too long", at);
433
468
  continue;
434
469
  }
435
- // A Linear comment's issue ID does not encode or enforce its declared team.
470
+ // A Linear comment's (or state change's) issue ID does not encode or enforce its declared team.
436
471
  // Resolve it through the configured read tool and carry its immutable ID to
437
472
  // the write. Missing lookup support or unverifiable membership fails closed.
438
473
  let number = issueRef;
439
- if (via === "linear" && type === "comment" && target) {
474
+ if (via === "linear" && (type === "comment" || type === "issue_state") && target) {
440
475
  const lookup = o.skills.get(via)?.tools.find((t) => t.name === "linear_get_issue" && t.access === "read");
441
476
  let issue;
442
477
  try {
@@ -464,7 +499,8 @@ export async function applySafeOutputs(o) {
464
499
  ...(target ? { target } : {}),
465
500
  ...(number !== undefined ? { number } : {}),
466
501
  ...(title ? { title } : {}),
467
- ...(type !== "label" ? { body } : {}),
502
+ ...(type === "comment" || type === "issue" || type === "pr" ? { body } : {}),
503
+ ...(state ? { state } : {}),
468
504
  ...(labels ? { labels } : {}),
469
505
  ...(type === "pr" ? { head: intent.head, ...(intent.base ? { base: intent.base } : {}) } : {}),
470
506
  };
@@ -546,6 +582,8 @@ export async function applySafeOutputs(o) {
546
582
  function printPreview(w, logger) {
547
583
  const where = `${w.target ?? ""}${w.number ? `#${w.number}` : ""}`;
548
584
  logger.info(` [staged] ${w.type} via ${w.via}${where ? ` -> ${where}` : ""}${w.title ? `: ${w.title}` : ""}`);
585
+ if (w.state)
586
+ logger.info(` state: ${w.state}`);
549
587
  if (w.labels?.length)
550
588
  logger.info(` labels: ${w.labels.join(", ")}`);
551
589
  if (w.type === "pr")
package/dist/schema.d.ts CHANGED
@@ -850,7 +850,7 @@ export declare const nodePermissionsZ: z.ZodUnion<[z.ZodEnum<["read", "write"]>,
850
850
  }>]>;
851
851
  /** One typed write intent a node may emit (#365). */
852
852
  export declare const safeOutputDeclarationZ: z.ZodObject<{
853
- type: z.ZodEnum<["comment", "issue", "pr", "label"]>;
853
+ type: z.ZodEnum<["comment", "issue", "pr", "label", "issue_state"]>;
854
854
  via: z.ZodOptional<z.ZodString>;
855
855
  max: z.ZodOptional<z.ZodNumber>;
856
856
  target: z.ZodOptional<z.ZodString>;
@@ -864,23 +864,26 @@ export declare const safeOutputDeclarationZ: z.ZodObject<{
864
864
  }, {
865
865
  input: string;
866
866
  }>]>>;
867
+ state: z.ZodOptional<z.ZodEnum<["reopen", "close"]>>;
867
868
  }, "strict", z.ZodTypeAny, {
868
- type: "comment" | "issue" | "pr" | "label";
869
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
869
870
  number?: string | number | {
870
871
  input: string;
871
872
  } | undefined;
872
873
  labels?: string[] | undefined;
874
+ state?: "reopen" | "close" | undefined;
873
875
  max?: number | undefined;
874
876
  via?: string | undefined;
875
877
  target?: string | undefined;
876
878
  title_prefix?: string | undefined;
877
879
  expires?: string | undefined;
878
880
  }, {
879
- type: "comment" | "issue" | "pr" | "label";
881
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
880
882
  number?: string | number | {
881
883
  input: string;
882
884
  } | undefined;
883
885
  labels?: string[] | undefined;
886
+ state?: "reopen" | "close" | undefined;
884
887
  max?: number | undefined;
885
888
  via?: string | undefined;
886
889
  target?: string | undefined;
@@ -889,7 +892,7 @@ export declare const safeOutputDeclarationZ: z.ZodObject<{
889
892
  }>;
890
893
  /** Workflow-level safe-output policy (#365): the ceiling and run-wide limits. */
891
894
  export declare const safeOutputsPolicyZ: z.ZodObject<{
892
- allow: z.ZodOptional<z.ZodArray<z.ZodEnum<["comment", "issue", "pr", "label"]>, "many">>;
895
+ allow: z.ZodOptional<z.ZodArray<z.ZodEnum<["comment", "issue", "pr", "label", "issue_state"]>, "many">>;
893
896
  max: z.ZodOptional<z.ZodNumber>;
894
897
  staged: z.ZodOptional<z.ZodBoolean>;
895
898
  trusted_actors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
@@ -897,14 +900,14 @@ export declare const safeOutputsPolicyZ: z.ZodObject<{
897
900
  screen: z.ZodOptional<z.ZodBoolean>;
898
901
  }, "strict", z.ZodTypeAny, {
899
902
  staged?: boolean | undefined;
900
- allow?: ("comment" | "issue" | "pr" | "label")[] | undefined;
903
+ allow?: ("comment" | "issue" | "pr" | "label" | "issue_state")[] | undefined;
901
904
  max?: number | undefined;
902
905
  trusted_actors?: string[] | undefined;
903
906
  trusted_associations?: ("OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIME_CONTRIBUTOR" | "FIRST_TIMER" | "NONE")[] | undefined;
904
907
  screen?: boolean | undefined;
905
908
  }, {
906
909
  staged?: boolean | undefined;
907
- allow?: ("comment" | "issue" | "pr" | "label")[] | undefined;
910
+ allow?: ("comment" | "issue" | "pr" | "label" | "issue_state")[] | undefined;
908
911
  max?: number | undefined;
909
912
  trusted_actors?: string[] | undefined;
910
913
  trusted_associations?: ("OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIME_CONTRIBUTOR" | "FIRST_TIMER" | "NONE")[] | undefined;
@@ -978,7 +981,7 @@ export declare const nodeZ: z.ZodObject<{
978
981
  access?: "write" | "read" | undefined;
979
982
  }>]>>;
980
983
  outputs: z.ZodOptional<z.ZodArray<z.ZodObject<{
981
- type: z.ZodEnum<["comment", "issue", "pr", "label"]>;
984
+ type: z.ZodEnum<["comment", "issue", "pr", "label", "issue_state"]>;
982
985
  via: z.ZodOptional<z.ZodString>;
983
986
  max: z.ZodOptional<z.ZodNumber>;
984
987
  target: z.ZodOptional<z.ZodString>;
@@ -992,23 +995,26 @@ export declare const nodeZ: z.ZodObject<{
992
995
  }, {
993
996
  input: string;
994
997
  }>]>>;
998
+ state: z.ZodOptional<z.ZodEnum<["reopen", "close"]>>;
995
999
  }, "strict", z.ZodTypeAny, {
996
- type: "comment" | "issue" | "pr" | "label";
1000
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
997
1001
  number?: string | number | {
998
1002
  input: string;
999
1003
  } | undefined;
1000
1004
  labels?: string[] | undefined;
1005
+ state?: "reopen" | "close" | undefined;
1001
1006
  max?: number | undefined;
1002
1007
  via?: string | undefined;
1003
1008
  target?: string | undefined;
1004
1009
  title_prefix?: string | undefined;
1005
1010
  expires?: string | undefined;
1006
1011
  }, {
1007
- type: "comment" | "issue" | "pr" | "label";
1012
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
1008
1013
  number?: string | number | {
1009
1014
  input: string;
1010
1015
  } | undefined;
1011
1016
  labels?: string[] | undefined;
1017
+ state?: "reopen" | "close" | undefined;
1012
1018
  max?: number | undefined;
1013
1019
  via?: string | undefined;
1014
1020
  target?: string | undefined;
@@ -1428,11 +1434,12 @@ export declare const nodeZ: z.ZodObject<{
1428
1434
  access?: "write" | "read" | undefined;
1429
1435
  } | undefined;
1430
1436
  outputs?: {
1431
- type: "comment" | "issue" | "pr" | "label";
1437
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
1432
1438
  number?: string | number | {
1433
1439
  input: string;
1434
1440
  } | undefined;
1435
1441
  labels?: string[] | undefined;
1442
+ state?: "reopen" | "close" | undefined;
1436
1443
  max?: number | undefined;
1437
1444
  via?: string | undefined;
1438
1445
  target?: string | undefined;
@@ -1541,11 +1548,12 @@ export declare const nodeZ: z.ZodObject<{
1541
1548
  access?: "write" | "read" | undefined;
1542
1549
  } | undefined;
1543
1550
  outputs?: {
1544
- type: "comment" | "issue" | "pr" | "label";
1551
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
1545
1552
  number?: string | number | {
1546
1553
  input: string;
1547
1554
  } | undefined;
1548
1555
  labels?: string[] | undefined;
1556
+ state?: "reopen" | "close" | undefined;
1549
1557
  max?: number | undefined;
1550
1558
  via?: string | undefined;
1551
1559
  target?: string | undefined;
@@ -1731,7 +1739,7 @@ export declare const workflowZ: z.ZodObject<{
1731
1739
  access?: "write" | "read" | undefined;
1732
1740
  }>]>>;
1733
1741
  outputs: z.ZodOptional<z.ZodArray<z.ZodObject<{
1734
- type: z.ZodEnum<["comment", "issue", "pr", "label"]>;
1742
+ type: z.ZodEnum<["comment", "issue", "pr", "label", "issue_state"]>;
1735
1743
  via: z.ZodOptional<z.ZodString>;
1736
1744
  max: z.ZodOptional<z.ZodNumber>;
1737
1745
  target: z.ZodOptional<z.ZodString>;
@@ -1745,23 +1753,26 @@ export declare const workflowZ: z.ZodObject<{
1745
1753
  }, {
1746
1754
  input: string;
1747
1755
  }>]>>;
1756
+ state: z.ZodOptional<z.ZodEnum<["reopen", "close"]>>;
1748
1757
  }, "strict", z.ZodTypeAny, {
1749
- type: "comment" | "issue" | "pr" | "label";
1758
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
1750
1759
  number?: string | number | {
1751
1760
  input: string;
1752
1761
  } | undefined;
1753
1762
  labels?: string[] | undefined;
1763
+ state?: "reopen" | "close" | undefined;
1754
1764
  max?: number | undefined;
1755
1765
  via?: string | undefined;
1756
1766
  target?: string | undefined;
1757
1767
  title_prefix?: string | undefined;
1758
1768
  expires?: string | undefined;
1759
1769
  }, {
1760
- type: "comment" | "issue" | "pr" | "label";
1770
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
1761
1771
  number?: string | number | {
1762
1772
  input: string;
1763
1773
  } | undefined;
1764
1774
  labels?: string[] | undefined;
1775
+ state?: "reopen" | "close" | undefined;
1765
1776
  max?: number | undefined;
1766
1777
  via?: string | undefined;
1767
1778
  target?: string | undefined;
@@ -2181,11 +2192,12 @@ export declare const workflowZ: z.ZodObject<{
2181
2192
  access?: "write" | "read" | undefined;
2182
2193
  } | undefined;
2183
2194
  outputs?: {
2184
- type: "comment" | "issue" | "pr" | "label";
2195
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
2185
2196
  number?: string | number | {
2186
2197
  input: string;
2187
2198
  } | undefined;
2188
2199
  labels?: string[] | undefined;
2200
+ state?: "reopen" | "close" | undefined;
2189
2201
  max?: number | undefined;
2190
2202
  via?: string | undefined;
2191
2203
  target?: string | undefined;
@@ -2294,11 +2306,12 @@ export declare const workflowZ: z.ZodObject<{
2294
2306
  access?: "write" | "read" | undefined;
2295
2307
  } | undefined;
2296
2308
  outputs?: {
2297
- type: "comment" | "issue" | "pr" | "label";
2309
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
2298
2310
  number?: string | number | {
2299
2311
  input: string;
2300
2312
  } | undefined;
2301
2313
  labels?: string[] | undefined;
2314
+ state?: "reopen" | "close" | undefined;
2302
2315
  max?: number | undefined;
2303
2316
  via?: string | undefined;
2304
2317
  target?: string | undefined;
@@ -2587,7 +2600,7 @@ export declare const workflowZ: z.ZodObject<{
2587
2600
  access?: "write" | "read" | undefined;
2588
2601
  }>]>>;
2589
2602
  safe_outputs: z.ZodOptional<z.ZodObject<{
2590
- allow: z.ZodOptional<z.ZodArray<z.ZodEnum<["comment", "issue", "pr", "label"]>, "many">>;
2603
+ allow: z.ZodOptional<z.ZodArray<z.ZodEnum<["comment", "issue", "pr", "label", "issue_state"]>, "many">>;
2591
2604
  max: z.ZodOptional<z.ZodNumber>;
2592
2605
  staged: z.ZodOptional<z.ZodBoolean>;
2593
2606
  trusted_actors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
@@ -2595,14 +2608,14 @@ export declare const workflowZ: z.ZodObject<{
2595
2608
  screen: z.ZodOptional<z.ZodBoolean>;
2596
2609
  }, "strict", z.ZodTypeAny, {
2597
2610
  staged?: boolean | undefined;
2598
- allow?: ("comment" | "issue" | "pr" | "label")[] | undefined;
2611
+ allow?: ("comment" | "issue" | "pr" | "label" | "issue_state")[] | undefined;
2599
2612
  max?: number | undefined;
2600
2613
  trusted_actors?: string[] | undefined;
2601
2614
  trusted_associations?: ("OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIME_CONTRIBUTOR" | "FIRST_TIMER" | "NONE")[] | undefined;
2602
2615
  screen?: boolean | undefined;
2603
2616
  }, {
2604
2617
  staged?: boolean | undefined;
2605
- allow?: ("comment" | "issue" | "pr" | "label")[] | undefined;
2618
+ allow?: ("comment" | "issue" | "pr" | "label" | "issue_state")[] | undefined;
2606
2619
  max?: number | undefined;
2607
2620
  trusted_actors?: string[] | undefined;
2608
2621
  trusted_associations?: ("OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIME_CONTRIBUTOR" | "FIRST_TIMER" | "NONE")[] | undefined;
@@ -2652,11 +2665,12 @@ export declare const workflowZ: z.ZodObject<{
2652
2665
  access?: "write" | "read" | undefined;
2653
2666
  } | undefined;
2654
2667
  outputs?: {
2655
- type: "comment" | "issue" | "pr" | "label";
2668
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
2656
2669
  number?: string | number | {
2657
2670
  input: string;
2658
2671
  } | undefined;
2659
2672
  labels?: string[] | undefined;
2673
+ state?: "reopen" | "close" | undefined;
2660
2674
  max?: number | undefined;
2661
2675
  via?: string | undefined;
2662
2676
  target?: string | undefined;
@@ -2780,7 +2794,7 @@ export declare const workflowZ: z.ZodObject<{
2780
2794
  }> | undefined;
2781
2795
  safe_outputs?: {
2782
2796
  staged?: boolean | undefined;
2783
- allow?: ("comment" | "issue" | "pr" | "label")[] | undefined;
2797
+ allow?: ("comment" | "issue" | "pr" | "label" | "issue_state")[] | undefined;
2784
2798
  max?: number | undefined;
2785
2799
  trusted_actors?: string[] | undefined;
2786
2800
  trusted_associations?: ("OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIME_CONTRIBUTOR" | "FIRST_TIMER" | "NONE")[] | undefined;
@@ -2816,11 +2830,12 @@ export declare const workflowZ: z.ZodObject<{
2816
2830
  access?: "write" | "read" | undefined;
2817
2831
  } | undefined;
2818
2832
  outputs?: {
2819
- type: "comment" | "issue" | "pr" | "label";
2833
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
2820
2834
  number?: string | number | {
2821
2835
  input: string;
2822
2836
  } | undefined;
2823
2837
  labels?: string[] | undefined;
2838
+ state?: "reopen" | "close" | undefined;
2824
2839
  max?: number | undefined;
2825
2840
  via?: string | undefined;
2826
2841
  target?: string | undefined;
@@ -2958,7 +2973,7 @@ export declare const workflowZ: z.ZodObject<{
2958
2973
  }> | undefined;
2959
2974
  safe_outputs?: {
2960
2975
  staged?: boolean | undefined;
2961
- allow?: ("comment" | "issue" | "pr" | "label")[] | undefined;
2976
+ allow?: ("comment" | "issue" | "pr" | "label" | "issue_state")[] | undefined;
2962
2977
  max?: number | undefined;
2963
2978
  trusted_actors?: string[] | undefined;
2964
2979
  trusted_associations?: ("OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIME_CONTRIBUTOR" | "FIRST_TIMER" | "NONE")[] | undefined;
@@ -3022,11 +3037,12 @@ export declare function parseWorkflow(raw: unknown): {
3022
3037
  access?: "write" | "read" | undefined;
3023
3038
  } | undefined;
3024
3039
  outputs?: {
3025
- type: "comment" | "issue" | "pr" | "label";
3040
+ type: "comment" | "issue" | "pr" | "label" | "issue_state";
3026
3041
  number?: string | number | {
3027
3042
  input: string;
3028
3043
  } | undefined;
3029
3044
  labels?: string[] | undefined;
3045
+ state?: "reopen" | "close" | undefined;
3030
3046
  max?: number | undefined;
3031
3047
  via?: string | undefined;
3032
3048
  target?: string | undefined;
@@ -3150,7 +3166,7 @@ export declare function parseWorkflow(raw: unknown): {
3150
3166
  }> | undefined;
3151
3167
  safe_outputs?: {
3152
3168
  staged?: boolean | undefined;
3153
- allow?: ("comment" | "issue" | "pr" | "label")[] | undefined;
3169
+ allow?: ("comment" | "issue" | "pr" | "label" | "issue_state")[] | undefined;
3154
3170
  max?: number | undefined;
3155
3171
  trusted_actors?: string[] | undefined;
3156
3172
  trusted_associations?: ("OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIME_CONTRIBUTOR" | "FIRST_TIMER" | "NONE")[] | undefined;
@@ -3425,7 +3441,7 @@ export declare const workflowJsonSchema: {
3425
3441
  readonly properties: {
3426
3442
  readonly type: {
3427
3443
  readonly type: "string";
3428
- readonly enum: readonly ["comment", "issue", "pr", "label"];
3444
+ readonly enum: readonly ["comment", "issue", "pr", "label", "issue_state"];
3429
3445
  };
3430
3446
  readonly via: {
3431
3447
  readonly type: "string";
@@ -3464,7 +3480,7 @@ export declare const workflowJsonSchema: {
3464
3480
  readonly description: "Drop an intent older than this when the write stage runs (e.g. 30m, 2h, 7d).";
3465
3481
  };
3466
3482
  readonly number: {
3467
- readonly description: "comment / label: the only issue or PR this output may write to (GitHub number, Linear identifier), or { input: <name> } to pin it to a run input.";
3483
+ readonly description: "comment / label / issue_state: the only issue or PR this output may write to (GitHub number, Linear identifier), or { input: <name> } to pin it to a run input.";
3468
3484
  readonly oneOf: readonly [{
3469
3485
  readonly type: "string";
3470
3486
  readonly minLength: 1;
@@ -3483,6 +3499,11 @@ export declare const workflowJsonSchema: {
3483
3499
  };
3484
3500
  }];
3485
3501
  };
3502
+ readonly state: {
3503
+ readonly type: "string";
3504
+ readonly enum: readonly ["reopen", "close"];
3505
+ readonly description: "issue_state: the one change this output may make (reopen or close). Default: either.";
3506
+ };
3486
3507
  };
3487
3508
  };
3488
3509
  };
@@ -3545,7 +3566,7 @@ export declare const workflowJsonSchema: {
3545
3566
  readonly type: "array";
3546
3567
  readonly items: {
3547
3568
  readonly type: "string";
3548
- readonly enum: readonly ["comment", "issue", "pr", "label"];
3569
+ readonly enum: readonly ["comment", "issue", "pr", "label", "issue_state"];
3549
3570
  };
3550
3571
  readonly minItems: 1;
3551
3572
  readonly description: "Output types any node may declare. Absent: all types.";
package/dist/schema.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * parsing untrusted input (JSON/YAML import, Studio editor).
8
8
  */
9
9
  import { z } from "zod";
10
- import { AUTHOR_ASSOCIATIONS, EVALUATOR_KINDS, EVAL_POLICIES, MCP_TRANSPORTS, NODE_ACCESS, NODE_ON_FAIL, REQUIRES_ON_FAIL, SAFE_OUTPUT_APPLIERS, SAFE_OUTPUT_EXPIRES_PATTERN, SAFE_OUTPUT_MAX_CEILING, SAFE_OUTPUT_TYPES, SKILL_CATEGORIES, SKILL_ID_MAX_LENGTH, SKILL_ID_PATTERN, TOOL_ACCESS, TOOL_CLASSES, WORKFLOW_TYPES, } from "./types.js";
10
+ import { AUTHOR_ASSOCIATIONS, EVALUATOR_KINDS, EVAL_POLICIES, MCP_TRANSPORTS, NODE_ACCESS, NODE_ON_FAIL, REQUIRES_ON_FAIL, SAFE_OUTPUT_APPLIERS, SAFE_OUTPUT_EXPIRES_PATTERN, SAFE_OUTPUT_MAX_CEILING, SAFE_OUTPUT_STATES, SAFE_OUTPUT_TYPES, SKILL_CATEGORIES, SKILL_ID_MAX_LENGTH, SKILL_ID_PATTERN, TOOL_ACCESS, TOOL_CLASSES, WORKFLOW_TYPES, } from "./types.js";
11
11
  import { sourceZ } from "./sources.js";
12
12
  import { workflowInputsZ, WORKFLOW_INPUT_TYPES } from "./inputs.js";
13
13
  export { sourceZ };
@@ -275,6 +275,7 @@ export const safeOutputDeclarationZ = z
275
275
  number: z
276
276
  .union([z.string().min(1), z.number().int().min(1), z.object({ input: z.string().min(1) }).strict()])
277
277
  .optional(),
278
+ state: z.enum(SAFE_OUTPUT_STATES).optional(),
278
279
  })
279
280
  .strict();
280
281
  /** Workflow-level safe-output policy (#365): the ceiling and run-wide limits. */
@@ -636,10 +637,26 @@ export function validateWorkflow(workflow, knownSkills) {
636
637
  nodeId,
637
638
  });
638
639
  }
639
- if (out.number !== undefined && out.type !== "comment" && out.type !== "label") {
640
+ if (out.number !== undefined && out.type !== "comment" && out.type !== "label" && out.type !== "issue_state") {
640
641
  errors.push({
641
642
  code: "UNSUPPORTED_OUTPUT",
642
- message: `Node "${nodeId}" output "${out.type}" pins number, which only applies to comment and label outputs`,
643
+ message: `Node "${nodeId}" output "${out.type}" pins number, which only applies to comment, label and issue_state outputs`,
644
+ nodeId,
645
+ });
646
+ }
647
+ // Closing is destructive: an injected agent must not pick the issue. Only a
648
+ // reopen-only output may rely on the issue named in the request.
649
+ if (out.type === "issue_state" && out.state !== "reopen" && out.number === undefined) {
650
+ errors.push({
651
+ code: "UNSUPPORTED_OUTPUT",
652
+ message: `Node "${nodeId}" output "issue_state" can close issues, so it must pin number (a literal or { input: name }); only state: reopen may omit the pin`,
653
+ nodeId,
654
+ });
655
+ }
656
+ if (out.state !== undefined && out.type !== "issue_state") {
657
+ errors.push({
658
+ code: "UNSUPPORTED_OUTPUT",
659
+ message: `Node "${nodeId}" output "${out.type}" sets state, which only applies to issue_state outputs`,
643
660
  nodeId,
644
661
  });
645
662
  }
@@ -866,7 +883,7 @@ export const workflowJsonSchema = {
866
883
  description: "Drop an intent older than this when the write stage runs (e.g. 30m, 2h, 7d).",
867
884
  },
868
885
  number: {
869
- description: "comment / label: the only issue or PR this output may write to (GitHub number, Linear identifier), or { input: <name> } to pin it to a run input.",
886
+ description: "comment / label / issue_state: the only issue or PR this output may write to (GitHub number, Linear identifier), or { input: <name> } to pin it to a run input.",
870
887
  oneOf: [
871
888
  { type: "string", minLength: 1 },
872
889
  { type: "integer", minimum: 1 },
@@ -878,6 +895,11 @@ export const workflowJsonSchema = {
878
895
  },
879
896
  ],
880
897
  },
898
+ state: {
899
+ type: "string",
900
+ enum: [...SAFE_OUTPUT_STATES],
901
+ description: "issue_state: the one change this output may make (reopen or close). Default: either.",
902
+ },
881
903
  },
882
904
  },
883
905
  },
@@ -247,6 +247,30 @@ export const github = {
247
247
  body: JSON.stringify({ labels: input.labels }),
248
248
  }),
249
249
  },
250
+ {
251
+ name: "github_set_issue_state",
252
+ access: "write",
253
+ description: "Reopen a closed GitHub issue or pull request, or close an open one",
254
+ input_schema: {
255
+ type: "object",
256
+ properties: {
257
+ repo: { type: "string", description: "owner/repo" },
258
+ issue_number: { type: "number", description: "Issue or PR number" },
259
+ state: { type: "string", enum: ["reopen", "close"], description: "reopen or close" },
260
+ },
261
+ required: ["repo", "issue_number", "state"],
262
+ },
263
+ handler: async (input, ctx) => {
264
+ if (input.state !== "reopen" && input.state !== "close") {
265
+ throw new Error(`[GitHub] github_set_issue_state: state must be "reopen" or "close"`);
266
+ }
267
+ const reopen = input.state === "reopen";
268
+ return gh(`/repos/${input.repo}/issues/${input.issue_number}`, ctx, {
269
+ method: "PATCH",
270
+ body: JSON.stringify({ state: reopen ? "open" : "closed", state_reason: reopen ? "reopened" : "completed" }),
271
+ });
272
+ },
273
+ },
250
274
  {
251
275
  name: "github_list_recent_commits",
252
276
  access: "read",
@@ -195,6 +195,50 @@ export const linear = {
195
195
  return data;
196
196
  },
197
197
  },
198
+ {
199
+ name: "linear_set_issue_state",
200
+ access: "write",
201
+ description: "Reopen a closed Linear issue (moves it to the team's first unstarted or backlog state) or close an open one (first completed state). Does nothing when the issue is already in the requested state.",
202
+ input_schema: {
203
+ type: "object",
204
+ properties: {
205
+ issueId: { type: "string", description: "Linear issue ID (UUID) or identifier (e.g. 'OFF-1020')" },
206
+ state: { type: "string", enum: ["reopen", "close"], description: "reopen or close" },
207
+ },
208
+ required: ["issueId", "state"],
209
+ },
210
+ handler: async (input, ctx) => {
211
+ if (input.state !== "reopen" && input.state !== "close") {
212
+ throw new Error(`[Linear] linear_set_issue_state: state must be "reopen" or "close"`);
213
+ }
214
+ const found = await linearGql(`query($id: String!) {
215
+ issue(id: $id) {
216
+ id identifier url
217
+ state { type }
218
+ team { states { nodes { id type position } } }
219
+ }
220
+ }`, { id: input.issueId }, ctx);
221
+ const issue = found?.issue;
222
+ if (!issue?.id)
223
+ throw new Error(`[Linear] linear_set_issue_state: issue ${input.issueId} not found`);
224
+ const isClosed = issue.state?.type === "completed" || issue.state?.type === "canceled";
225
+ const wantClosed = input.state === "close";
226
+ if (isClosed === wantClosed) {
227
+ return { issueUpdate: { success: true, changed: false, issue } };
228
+ }
229
+ const states = issue.team?.states?.nodes ?? [];
230
+ const first = (type) => states.filter((s) => s.type === type).sort((a, b) => a.position - b.position)[0];
231
+ const target = wantClosed ? first("completed") : (first("unstarted") ?? first("backlog"));
232
+ if (!target) {
233
+ throw new Error(`[Linear] linear_set_issue_state: no ${wantClosed ? "completed" : "unstarted"} state for ${input.issueId}`);
234
+ }
235
+ const data = await linearGql(`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { id identifier url title state { name } } } }`, { id: issue.id, input: { stateId: target.id } }, ctx);
236
+ if (!data?.issueUpdate?.success) {
237
+ throw new Error(`[Linear] linear_set_issue_state failed for ${input.issueId}. Raw response: ${JSON.stringify(data)}`);
238
+ }
239
+ return data;
240
+ },
241
+ },
198
242
  ],
199
243
  // Equivalent tool names on Linear's official remote MCP server (mcp.linear.app).
200
244
  //
package/dist/types.d.ts CHANGED
@@ -393,8 +393,11 @@ export interface NodePermissionsSpec {
393
393
  /** `read` / `write` shorthand, or the object form. */
394
394
  export type NodePermissions = NodeAccess | NodePermissionsSpec;
395
395
  /** Write operations a node may request through `emit_output`. */
396
- export declare const SAFE_OUTPUT_TYPES: readonly ["comment", "issue", "pr", "label"];
396
+ export declare const SAFE_OUTPUT_TYPES: readonly ["comment", "issue", "pr", "label", "issue_state"];
397
397
  export type SafeOutputType = (typeof SAFE_OUTPUT_TYPES)[number];
398
+ /** What an `issue_state` output may do to an issue: reopen a closed one, or close an open one. */
399
+ export declare const SAFE_OUTPUT_STATES: readonly ["reopen", "close"];
400
+ export type SafeOutputState = (typeof SAFE_OUTPUT_STATES)[number];
398
401
  /**
399
402
  * Skills that can apply each output type. The write stage calls the skill's
400
403
  * own tool handler; a skill not listed here cannot apply safe outputs.
@@ -423,12 +426,15 @@ export interface SafeOutputDeclaration {
423
426
  /** Drop an intent older than this when the write stage runs (e.g. `30m`, `2h`, `7d`). */
424
427
  expires?: string;
425
428
  /**
426
- * comment / label: the only issue or PR this output may write to (GitHub
427
- * issue or PR number, Linear issue identifier), or `{ input: <name> }` to pin
428
- * it to a run input. An intent naming another is refused; an intent naming
429
- * none uses the pin. An empty pinned input refuses the write.
429
+ * comment / label / issue_state: the only issue or PR this output may write
430
+ * to (GitHub issue or PR number, Linear issue identifier), or `{ input: <name> }`
431
+ * to pin it to a run input. An intent naming another is refused; an intent
432
+ * naming none uses the pin. An empty pinned input refuses the write.
433
+ * `issue_state` always needs an issue: the pin, or a number on the intent.
430
434
  */
431
435
  number?: SafeOutputPin;
436
+ /** issue_state only: the one change this output may make. Default: either. */
437
+ state?: SafeOutputState;
432
438
  }
433
439
  /** A pinned issue / PR: a literal number or identifier, or the name of a run input that holds one. */
434
440
  export type SafeOutputPin = string | number | {
package/dist/types.js CHANGED
@@ -73,14 +73,16 @@ export const NODE_ACCESS = ["read", "write"];
73
73
  /** Portable built-in tool classes, compiled per harness (Claude Code: native tool names). */
74
74
  export const TOOL_CLASSES = ["shell", "write", "edit", "net", "subagent"];
75
75
  /** Write operations a node may request through `emit_output`. */
76
- export const SAFE_OUTPUT_TYPES = ["comment", "issue", "pr", "label"];
76
+ export const SAFE_OUTPUT_TYPES = ["comment", "issue", "pr", "label", "issue_state"];
77
+ /** What an `issue_state` output may do to an issue: reopen a closed one, or close an open one. */
78
+ export const SAFE_OUTPUT_STATES = ["reopen", "close"];
77
79
  /**
78
80
  * Skills that can apply each output type. The write stage calls the skill's
79
81
  * own tool handler; a skill not listed here cannot apply safe outputs.
80
82
  */
81
83
  export const SAFE_OUTPUT_APPLIERS = {
82
- github: ["comment", "issue", "pr", "label"],
83
- linear: ["comment", "issue"],
84
+ github: ["comment", "issue", "pr", "label", "issue_state"],
85
+ linear: ["comment", "issue", "issue_state"],
84
86
  };
85
87
  /** GitHub author associations accepted by `safe_outputs.trusted_associations`. */
86
88
  export const AUTHOR_ASSOCIATIONS = [
@@ -1,3 +1,3 @@
1
- export const triageWorkflow = {"id":"triage","name":"Alert Triage","workflow_type":"monitor","description":"Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team","entry":"gather","permissions":"write","safe_outputs":{"allow":["issue","comment","pr"],"max":25},"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"],"permissions":"read","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"],"permissions":"read","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**How writes work here.** If you have the `emit_output` tool, you cannot write to the tracker directly: request each new issue and each comment with `emit_output`, and sweny files them after this step, within its limits. A requested issue has no identifier yet; sweny records it for the next steps. Without `emit_output`, use the tracker's own tools (for example `linear_create_issue`, `github_create_issue`, `linear_add_comment`, `github_add_comment`, or the configured tracker's MCP tools) and copy the identifier they return.\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.\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, file a new one with a clear, actionable title (type `issue`). Do NOT synthesize an identifier from an alert ID, commit SHA, or your own imagination: a new issue's identifier comes only from the tracker. Set `dedupe_key` to a short stable fingerprint of the root cause, so two findings with the same cause are filed once. Set the `action` to `requested` (or `created` when you filed it with a tracker tool and have its identifier).\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`, and pass those IDs as the labels (skip any that don't exist in the team; don't invent IDs). For Linear, the target is the team ID (`linear_list_teams`). For GitHub: labels `[\"sweny\", \"agent\", \"triage\"]`.\n5. Link to relevant commits, PRs, or existing issues.\n6. When you know the identifier (an existing issue, or one a tracker tool returned), copy it verbatim into `issueIdentifier`. It must match `^[A-Z][A-Z0-9]*-\\d+$` (e.g. `OFF-1234`); do not prefix it with anything else. Leave `issueIdentifier` unset for an issue you only requested.\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 (type `comment`, `number` = the issue ID or number): \"+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 or done: reopen it when you have a tool that can; otherwise say in the comment that it is closed and was seen again.\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 issues you requested, created, or updated.\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- Your job is issue creation and duplicate management ONLY.","skills":["linear","github"],"outputs":[{"type":"issue","max":10},{"type":"comment","max":10}],"tools":{"deny":["github_create_pr"]},"eval":[{"name":"issue_tracker_tool_was_called","kind":"function","rule":{"any_tool_called":["emit_output","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) when known in this step: an existing issue the duplicate check matched, or one a tracker tool returned. Unset for an issue only requested through emit_output; sweny records its identifier in safe_outputs. Never invented from a Sentry short-ID or commit SHA."},"issueTitle":{"type":"string","description":"Title of the primary issue"},"issueUrl":{"type":"string","pattern":"^https?://","description":"URL of the primary issue, when known in this step"},"issues":{"type":"array","minItems":1,"description":"All issues requested, created, or updated during this step","items":{"type":"object","properties":{"identifier":{"type":"string"},"title":{"type":"string"},"url":{"type":"string"},"action":{"type":"string","enum":["requested","created","updated","reopened"]}},"required":["action"]}}},"required":["issueTitle","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 (with `emit_output` type `comment` when you have it, `number` = the issue ID or number): \"+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 or done: reopen it when you have a tool that can; otherwise say in the comment that it is closed and was seen again.\n\nFor **low priority** findings, log a brief note about why they were skipped.","skills":["linear","github"],"outputs":[{"type":"comment","max":10}]},"implement":{"name":"Implement Fix","instruction":"Implement the fix identified during investigation, plus the tests that pin\nit as a regression guard. The issue from the previous step is in your\ncontext. Use its identifier for branch naming and commit messages.\n\n**The issue identifier** (ISSUE_ID below): the `ref` of the `issue` entry\nwith status `applied` in `context.create_issue.safe_outputs` (the issue\nsweny filed after the previous step), else\n`context.create_issue.issueIdentifier` (an existing issue the previous\nstep matched, or one it filed directly).\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\n0. **Precondition: staged preview.** If the `issue` entry in\n `context.create_issue.safe_outputs` has status `staged`, this run is a\n preview (`--stage`) and no issue exists. STOP before touching the\n checkout. Output `test_status: \"skipped\"`, `skipped_reason: \"staged\"`,\n empty file lists, `branch: \"\"`, `commit_sha: \"\"`.\n\n0a. **Precondition: verify ISSUE_ID 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; 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 ISSUE_ID above.\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 ISSUE_ID, 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 pushes the\nbranch and requests the 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"],"permissions":{"access":"write","deny":["net"]},"tools":{"deny":["github_create_pr","github_create_issue","github_add_comment","github_add_labels"]},"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: `open-pr-exists` (Step\n 0b found an open PR with the issue identifier in its\n title; `existing_pr_url` MUST be populated) or `staged`\n (Step 0: a preview run filed no issue). `skipped_reason`\n MUST be populated. Downstream `create_pr` is bypassed and\n the workflow routes to `notify`.\n"},"skipped_reason":{"type":"string","description":"Why the node short-circuited. Only set when `test_status` is\n`skipped`: `open-pr-exists` (Step 0b) or `staged` (Step 0).\n","enum":["open-pr-exists","staged"]},"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. When\n skipped_reason is \"open-pr-exists\" (Step 0b found an open PR\n for the issue), `existing_pr_url` MUST be populated; when it\n is \"staged\" (a preview run), nothing else is required. If\n skipped_reason is missing, 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","on_fail":"continue","instruction":"Open a pull request for the fix. The issue is in your context from the create_issue step; its identifier (ISSUE_ID) is the `ref` of the applied `issue` entry in `context.create_issue.safe_outputs`, else `context.create_issue.issueIdentifier`. The branch is `context.implement.branch`.\n\n1. Push the branch to the remote.\n2. Request the PR: with `emit_output` (type `pr`, `head` = the branch, `base` = the base branch, default `main`) when you have it; sweny opens it after this step and reuses an open PR for the same branch instead of failing. Without `emit_output`, call `github_create_pr` (use this exact tool name, not `create_pull_request` or any other variant from ToolSearch); if it returns `reused: true`, treat it as success and do NOT close it and call again.\n3. PR title MUST start with the issue identifier in brackets. Format: `[ISSUE_ID] fix: {short description}`.\n4. In the PR body, include: summary of the bug, what the fix does, and a link to the issue (its `url` in `context.create_issue.safe_outputs`, else 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 ISSUE_ID\" (e.g. \"Fixes OFF-1234\") so Linear links the PR and moves the issue to Done on merge.\n5. Labels: `[\"sweny\", \"agent\", \"triage\"]`.\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 branch and the PR title you requested (and the PR URL and number when a tool returned them).","skills":["github"],"permissions":"write","outputs":[{"type":"pr","max":1,"labels":["sweny","agent","triage"]}],"tools":{"deny":["github_create_pr","github_create_issue","github_add_comment","github_add_labels"]},"eval":[{"name":"pr_was_created","kind":"function","rule":{"any_tool_called":["emit_output","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.\n Issues and PRs sweny filed are in `safe_outputs` of the create_issue, skip and create_pr steps\n (`ref` and `url` of each `applied` entry).\n2. For critical/high severity, make the notification urgent.\n3. For medium/low, a standard notification is fine.\n4. If any `safe_outputs` entry has status `staged`, this run is a preview: send nothing, and put the\n notification you would have sent in your summary.\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, or Step 0 found a staged preview)"},{"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":{}};
1
+ export const triageWorkflow = {"id":"triage","name":"Alert Triage","workflow_type":"monitor","description":"Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team","entry":"gather","permissions":"write","safe_outputs":{"allow":["issue","comment","issue_state","pr"],"max":25},"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"],"permissions":"read","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"],"permissions":"read","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**How writes work here.** If you have the `emit_output` tool, you cannot write to the tracker directly: request each new issue and each comment with `emit_output`, and sweny files them after this step, within its limits. A requested issue has no identifier yet; sweny records it for the next steps. Without `emit_output`, use the tracker's own tools (for example `linear_create_issue`, `github_create_issue`, `linear_add_comment`, `github_add_comment`, or the configured tracker's MCP tools) and copy the identifier they return.\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.\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, file a new one with a clear, actionable title (type `issue`). Do NOT synthesize an identifier from an alert ID, commit SHA, or your own imagination: a new issue's identifier comes only from the tracker. Set `dedupe_key` to a short stable fingerprint of the root cause, so two findings with the same cause are filed once. Set the `action` to `requested` (or `created` when you filed it with a tracker tool and have its identifier).\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`, and pass those IDs as the labels (skip any that don't exist in the team; don't invent IDs). For Linear, the target is the team ID (`linear_list_teams`). For GitHub: labels `[\"sweny\", \"agent\", \"triage\"]`.\n5. Link to relevant commits, PRs, or existing issues.\n6. When you know the identifier (an existing issue, or one a tracker tool returned), copy it verbatim into `issueIdentifier`. It must match `^[A-Z][A-Z0-9]*-\\d+$` (e.g. `OFF-1234`); do not prefix it with anything else. Leave `issueIdentifier` unset for an issue you only requested.\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 (type `comment`, `number` = the issue ID or number): \"+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 or done: reopen it (type `issue_state`, `state` = `reopen`, `number` = the issue ID or number) when you have `emit_output`; with a tracker tool instead, use one that can reopen. If you can do neither, say in the comment that it is closed and was seen again.\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 issues you requested, created, or updated.\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- Your job is issue creation and duplicate management ONLY.","skills":["linear","github"],"outputs":[{"type":"issue","max":10},{"type":"comment","max":10},{"type":"issue_state","state":"reopen","max":10}],"tools":{"deny":["github_create_pr"]},"eval":[{"name":"issue_tracker_tool_was_called","kind":"function","rule":{"any_tool_called":["emit_output","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) when known in this step: an existing issue the duplicate check matched, or one a tracker tool returned. Unset for an issue only requested through emit_output; sweny records its identifier in safe_outputs. Never invented from a Sentry short-ID or commit SHA."},"issueTitle":{"type":"string","description":"Title of the primary issue"},"issueUrl":{"type":"string","pattern":"^https?://","description":"URL of the primary issue, when known in this step"},"issues":{"type":"array","minItems":1,"description":"All issues requested, created, or updated during this step","items":{"type":"object","properties":{"identifier":{"type":"string"},"title":{"type":"string"},"url":{"type":"string"},"action":{"type":"string","enum":["requested","created","updated","reopened"]}},"required":["action"]}}},"required":["issueTitle","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 (with `emit_output` type `comment` when you have it, `number` = the issue ID or number): \"+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 or done: reopen it (`emit_output` type `issue_state`, `state` = `reopen`, `number` = the issue ID or number) when you have `emit_output`; with a tracker tool instead, use one that can reopen. If you can do neither, say in the comment that it is closed and was seen again.\n\nFor **low priority** findings, log a brief note about why they were skipped.","skills":["linear","github"],"outputs":[{"type":"comment","max":10},{"type":"issue_state","state":"reopen","max":10}]},"implement":{"name":"Implement Fix","instruction":"Implement the fix identified during investigation, plus the tests that pin\nit as a regression guard. The issue from the previous step is in your\ncontext. Use its identifier for branch naming and commit messages.\n\n**The issue identifier** (ISSUE_ID below): the `ref` of the `issue` entry\nwith status `applied` in `context.create_issue.safe_outputs` (the issue\nsweny filed after the previous step), else\n`context.create_issue.issueIdentifier` (an existing issue the previous\nstep matched, or one it filed directly).\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\n0. **Precondition: staged preview.** If the `issue` entry in\n `context.create_issue.safe_outputs` has status `staged`, this run is a\n preview (`--stage`) and no issue exists. STOP before touching the\n checkout. Output `test_status: \"skipped\"`, `skipped_reason: \"staged\"`,\n empty file lists, `branch: \"\"`, `commit_sha: \"\"`.\n\n0a. **Precondition: verify ISSUE_ID 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; 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 ISSUE_ID above.\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 ISSUE_ID, 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 pushes the\nbranch and requests the 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"],"permissions":{"access":"write","deny":["net"]},"tools":{"deny":["github_create_pr","github_create_issue","github_add_comment","github_add_labels"]},"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: `open-pr-exists` (Step\n 0b found an open PR with the issue identifier in its\n title; `existing_pr_url` MUST be populated) or `staged`\n (Step 0: a preview run filed no issue). `skipped_reason`\n MUST be populated. Downstream `create_pr` is bypassed and\n the workflow routes to `notify`.\n"},"skipped_reason":{"type":"string","description":"Why the node short-circuited. Only set when `test_status` is\n`skipped`: `open-pr-exists` (Step 0b) or `staged` (Step 0).\n","enum":["open-pr-exists","staged"]},"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. When\n skipped_reason is \"open-pr-exists\" (Step 0b found an open PR\n for the issue), `existing_pr_url` MUST be populated; when it\n is \"staged\" (a preview run), nothing else is required. If\n skipped_reason is missing, 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","on_fail":"continue","instruction":"Open a pull request for the fix. The issue is in your context from the create_issue step; its identifier (ISSUE_ID) is the `ref` of the applied `issue` entry in `context.create_issue.safe_outputs`, else `context.create_issue.issueIdentifier`. The branch is `context.implement.branch`.\n\n1. Push the branch to the remote.\n2. Request the PR: with `emit_output` (type `pr`, `head` = the branch, `base` = the base branch, default `main`) when you have it; sweny opens it after this step and reuses an open PR for the same branch instead of failing. Without `emit_output`, call `github_create_pr` (use this exact tool name, not `create_pull_request` or any other variant from ToolSearch); if it returns `reused: true`, treat it as success and do NOT close it and call again.\n3. PR title MUST start with the issue identifier in brackets. Format: `[ISSUE_ID] fix: {short description}`.\n4. In the PR body, include: summary of the bug, what the fix does, and a link to the issue (its `url` in `context.create_issue.safe_outputs`, else 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 ISSUE_ID\" (e.g. \"Fixes OFF-1234\") so Linear links the PR and moves the issue to Done on merge.\n5. Labels: `[\"sweny\", \"agent\", \"triage\"]`.\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 branch and the PR title you requested (and the PR URL and number when a tool returned them).","skills":["github"],"permissions":"write","outputs":[{"type":"pr","max":1,"labels":["sweny","agent","triage"]}],"tools":{"deny":["github_create_pr","github_create_issue","github_add_comment","github_add_labels"]},"eval":[{"name":"pr_was_created","kind":"function","rule":{"any_tool_called":["emit_output","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.\n Issues and PRs sweny filed are in `safe_outputs` of the create_issue, skip and create_pr steps\n (`ref` and `url` of each `applied` entry).\n2. For critical/high severity, make the notification urgent.\n3. For medium/low, a standard notification is fine.\n4. If any `safe_outputs` entry has status `staged`, this run is a preview: send nothing, and put the\n notification you would have sent in your summary.\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, or Step 0 found a staged preview)"},{"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
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","permissions":"write","safe_outputs":{"allow":["comment","pr"],"max":3},"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"],"permissions":"read","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 pushes the branch and requests the PR.\n\nIf the fix is too risky or complex, explain why and skip.","skills":["github"],"permissions":{"access":"write","deny":["net"]},"tools":{"deny":["github_create_pr","github_create_issue","github_add_comment","github_add_labels"]}},"create_pr":{"name":"Open Pull Request","on_fail":"continue","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Request the PR: with `emit_output` (type `pr`, `head` = the branch, `base` = the base branch, default `main`) when you have it; sweny opens it after this step and reuses an open PR for the same branch instead of failing. Without `emit_output`, call `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. Labels: add `task` (or `feature` if the issue is a feature request); `sweny` and `agent` are always added.\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 branch and the PR title you requested.","skills":["github"],"permissions":"write","outputs":[{"type":"pr","max":1,"labels":["sweny","agent"]}],"tools":{"deny":["github_create_pr","github_create_issue","github_add_comment","github_add_labels"]}},"notify":{"name":"Notify","instruction":"Send a notification about the implementation result:\n\n1. Include: issue reference, PR link, brief summary of changes. The PR sweny opened is in\n `context.create_pr.safe_outputs` (`ref` and `url` of the `applied` entry).\n2. Keep it concise: one message, not a wall of text.\n3. If any `safe_outputs` entry has status `staged`, this run is a preview: send nothing, and put the\n notification you would have sent in your summary.","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\n(with `emit_output` type `comment` when you have it).","skills":["linear","github"],"outputs":[{"type":"comment","max":1,"number":{"input":"issueIdentifier"}}]}},"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":{}};
@@ -9,7 +9,7 @@ entry: gather
9
9
  # by sweny after the node, within these types and this run-wide cap.
10
10
  permissions: write
11
11
  safe_outputs:
12
- allow: [issue, comment, pr]
12
+ allow: [issue, comment, issue_state, pr]
13
13
  max: 25
14
14
  nodes:
15
15
  gather:
@@ -260,8 +260,9 @@ nodes:
260
260
  {UTC timestamp}. {one sentence of new context}." (Keep it under 2 lines. No markdown headers, no emoji,
261
261
  no formatting.)
262
262
 
263
- 4. If the existing issue is closed or done: reopen it when you have a tool that can; otherwise say in
264
- the comment that it is closed and was seen again.
263
+ 4. If the existing issue is closed or done: reopen it (type `issue_state`, `state` = `reopen`, `number`
264
+ = the issue ID or number) when you have `emit_output`; with a tracker tool instead, use one that can
265
+ reopen. If you can do neither, say in the comment that it is closed and was seen again.
265
266
 
266
267
 
267
268
  If context.issueTemplate is provided, use it as the format for new issue bodies. Otherwise use a clear structure
@@ -291,6 +292,12 @@ nodes:
291
292
  max: 10
292
293
  - type: comment
293
294
  max: 10
295
+ # Reopening a closed duplicate (what triage did before #365). Reopen
296
+ # only: this node never closes an issue. The issue must be named on the
297
+ # request; sweny checks it is in the tracker and only moves a closed one.
298
+ - type: issue_state
299
+ state: reopen
300
+ max: 10
294
301
  # Legacy runs (a tracker with no safe-output skill, see
295
302
  # cli/builtin-workflows.ts) still never open PRs from here.
296
303
  tools:
@@ -375,19 +382,23 @@ nodes:
375
382
  issue ID or number): "+1 seen again {UTC timestamp}. {one sentence of new context}." (Keep it under 2
376
383
  lines. No markdown headers, no emoji, no formatting.)
377
384
 
378
- 4. If the issue is closed or done: reopen it when you have a tool that can; otherwise say in the comment
379
- that it is closed and was seen again.
385
+ 4. If the issue is closed or done: reopen it (`emit_output` type `issue_state`, `state` = `reopen`,
386
+ `number` = the issue ID or number) when you have `emit_output`; with a tracker tool instead, use one
387
+ that can reopen. If you can do neither, say in the comment that it is closed and was seen again.
380
388
 
381
389
 
382
390
  For **low priority** findings, log a brief note about why they were skipped.
383
391
  skills:
384
392
  - linear
385
393
  - github
386
- # #365: comments on existing issues only, as a declared output. Read-only
387
- # otherwise: it never creates issues or PRs.
394
+ # #365: comments on existing issues, and reopening closed ones, as declared
395
+ # outputs. Read-only otherwise: it never creates issues or PRs.
388
396
  outputs:
389
397
  - type: comment
390
398
  max: 10
399
+ - type: issue_state
400
+ state: reopen
401
+ max: 10
391
402
  implement:
392
403
  name: Implement Fix
393
404
  instruction: |-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.151",
3
+ "version": "0.1.152",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"