@sweny-ai/core 0.1.51 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/__tests__/publish.test.js +146 -0
  2. package/dist/__tests__/schema.test.js +156 -1
  3. package/dist/__tests__/skills.test.js +135 -4
  4. package/dist/__tests__/templates.test.d.ts +1 -0
  5. package/dist/__tests__/templates.test.js +49 -0
  6. package/dist/__tests__/version-consistency.test.js +7 -7
  7. package/dist/cli/config-file.d.ts +1 -1
  8. package/dist/cli/config-file.js +1 -1
  9. package/dist/cli/credentials.d.ts +1 -1
  10. package/dist/cli/credentials.js +1 -1
  11. package/dist/cli/credentials.test.js +1 -1
  12. package/dist/cli/e2e.d.ts +5 -1
  13. package/dist/cli/e2e.js +7 -3
  14. package/dist/cli/e2e.test.js +46 -1
  15. package/dist/cli/main.js +21 -7
  16. package/dist/cli/new.d.ts +101 -0
  17. package/dist/cli/new.js +670 -0
  18. package/dist/cli/new.test.d.ts +1 -0
  19. package/dist/cli/{init.test.js → new.test.js} +216 -78
  20. package/dist/cli/publish.d.ts +34 -0
  21. package/dist/cli/publish.js +368 -0
  22. package/dist/cli/templates.d.ts +13 -0
  23. package/dist/cli/templates.js +181 -0
  24. package/dist/executor.js +63 -20
  25. package/dist/executor.test.js +233 -0
  26. package/dist/index.d.ts +5 -5
  27. package/dist/index.js +3 -3
  28. package/dist/mcp.d.ts +2 -0
  29. package/dist/mcp.js +8 -0
  30. package/dist/mcp.test.js +67 -0
  31. package/dist/schema.d.ts +428 -18
  32. package/dist/schema.js +71 -4
  33. package/dist/skills/custom-loader.d.ts +12 -12
  34. package/dist/skills/custom-loader.js +105 -50
  35. package/dist/types.d.ts +18 -1
  36. package/dist/workflows/browser.js +3 -3
  37. package/package.json +10 -2
  38. package/dist/cli/init.d.ts +0 -53
  39. package/dist/cli/init.js +0 -521
  40. /package/dist/{cli/init.test.d.ts → __tests__/publish.test.d.ts} +0 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * sweny publish — Interactive CLI for publishing workflows and skills to the marketplace.
3
+ *
4
+ * Supports:
5
+ * - Workflow YAML files → marketplace community submission
6
+ * - Skill directories (SKILL.md) → marketplace skill submission
7
+ * - Three output modes: GitHub PR (via `gh`), copy to clipboard, save locally
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { execSync } from "node:child_process";
12
+ import * as p from "@clack/prompts";
13
+ import chalk from "chalk";
14
+ import { parse as parseYaml } from "yaml";
15
+ import { parseWorkflow, validateWorkflow } from "../schema.js";
16
+ // ── Types ──────────────────────────────────────────────────────────────
17
+ const VALID_CATEGORIES = ["triage", "security", "devops", "code-review", "testing", "content", "ops"];
18
+ const VALID_SKILL_ID = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
19
+ // ── Validation helpers ─────────────────────────────────────────────────
20
+ export function validateWorkflowFile(filePath) {
21
+ const errors = [];
22
+ if (!fs.existsSync(filePath)) {
23
+ return { valid: false, errors: ["File not found"] };
24
+ }
25
+ const raw = fs.readFileSync(filePath, "utf-8");
26
+ let parsed;
27
+ try {
28
+ parsed = parseYaml(raw);
29
+ }
30
+ catch (e) {
31
+ return { valid: false, errors: [`Invalid YAML: ${e instanceof Error ? e.message : e}`] };
32
+ }
33
+ try {
34
+ const workflow = parseWorkflow(parsed);
35
+ const schemaErrors = validateWorkflow(workflow);
36
+ if (schemaErrors.length > 0) {
37
+ return {
38
+ valid: false,
39
+ id: workflow.id,
40
+ name: workflow.name,
41
+ errors: schemaErrors.map((e) => e.message),
42
+ };
43
+ }
44
+ return {
45
+ valid: true,
46
+ id: workflow.id,
47
+ name: workflow.name,
48
+ nodeCount: Object.keys(workflow.nodes).length,
49
+ edgeCount: workflow.edges.length,
50
+ errors: [],
51
+ };
52
+ }
53
+ catch (e) {
54
+ return { valid: false, errors: [`Schema error: ${e instanceof Error ? e.message : e}`] };
55
+ }
56
+ }
57
+ export function validateSkillDir(dirPath) {
58
+ const errors = [];
59
+ const skillMdPath = path.join(dirPath, "SKILL.md");
60
+ if (!fs.existsSync(skillMdPath)) {
61
+ return { valid: false, hasInstruction: false, hasMcp: false, errors: ["SKILL.md not found in directory"] };
62
+ }
63
+ const content = fs.readFileSync(skillMdPath, "utf-8");
64
+ // Parse frontmatter
65
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
66
+ if (!fmMatch) {
67
+ return { valid: false, hasInstruction: false, hasMcp: false, errors: ["No YAML frontmatter found"] };
68
+ }
69
+ let fm;
70
+ try {
71
+ fm = parseYaml(fmMatch[1]);
72
+ }
73
+ catch (e) {
74
+ return {
75
+ valid: false,
76
+ hasInstruction: false,
77
+ hasMcp: false,
78
+ errors: [`Invalid frontmatter YAML: ${e instanceof Error ? e.message : e}`],
79
+ };
80
+ }
81
+ const id = String(fm.name ?? "");
82
+ if (!id) {
83
+ errors.push("Missing 'name' field in frontmatter");
84
+ }
85
+ else if (!VALID_SKILL_ID.test(id) || id.includes("--") || id.length > 64) {
86
+ errors.push(`Invalid skill ID: "${id}" — must be lowercase, hyphens, no consecutive hyphens, max 64 chars`);
87
+ }
88
+ // Check directory name matches skill name
89
+ const dirName = path.basename(dirPath);
90
+ if (id && dirName !== id) {
91
+ errors.push(`Directory name "${dirName}" doesn't match skill name "${id}"`);
92
+ }
93
+ const body = content.slice(fmMatch[0].length).trim();
94
+ const hasInstruction = body.length > 0;
95
+ const hasMcp = fm.mcp != null;
96
+ if (!hasInstruction && !hasMcp) {
97
+ errors.push("Skill must have an instruction body or an mcp config");
98
+ }
99
+ return {
100
+ valid: errors.length === 0,
101
+ id,
102
+ name: id,
103
+ hasInstruction,
104
+ hasMcp,
105
+ errors,
106
+ };
107
+ }
108
+ // ── GitHub PR helpers ──────────────────────────────────────────────────
109
+ function ghAvailable() {
110
+ try {
111
+ execSync("gh --version", { stdio: "pipe" });
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ function ghAuthenticated() {
119
+ try {
120
+ execSync("gh auth status", { stdio: "pipe" });
121
+ return true;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ // ── Cancel helper ──────────────────────────────────────────────────────
128
+ function cancel() {
129
+ p.cancel("Publish cancelled.");
130
+ process.exit(0);
131
+ }
132
+ // ── Main flow ──────────────────────────────────────────────────────────
133
+ export async function runPublish() {
134
+ p.intro("Publish to the SWEny Marketplace");
135
+ // Step 1: What to publish
136
+ const contentType = await p.select({
137
+ message: "What would you like to publish?",
138
+ options: [
139
+ { value: "workflow", label: "Workflow", hint: ".yml file" },
140
+ { value: "skill", label: "Skill", hint: "SKILL.md directory" },
141
+ ],
142
+ });
143
+ if (p.isCancel(contentType))
144
+ cancel();
145
+ if (contentType === "workflow") {
146
+ return await publishWorkflow();
147
+ }
148
+ else {
149
+ return await publishSkill();
150
+ }
151
+ }
152
+ async function publishWorkflow() {
153
+ // Step 2: Path to workflow
154
+ const filePath = await p.text({
155
+ message: "Path to workflow YAML file",
156
+ placeholder: ".sweny/workflows/my-workflow.yml",
157
+ validate: (v) => {
158
+ if (!v || v.trim().length === 0)
159
+ return "Path is required";
160
+ if (!fs.existsSync(v.trim()))
161
+ return "File not found";
162
+ return undefined;
163
+ },
164
+ });
165
+ if (p.isCancel(filePath))
166
+ cancel();
167
+ const absPath = path.resolve(filePath.trim());
168
+ const validation = validateWorkflowFile(absPath);
169
+ if (!validation.valid) {
170
+ p.log.error(`Validation failed:\n${validation.errors.map((e) => ` - ${e}`).join("\n")}`);
171
+ return null;
172
+ }
173
+ p.log.success(`Validated: ${chalk.cyan(validation.name)} — ${validation.nodeCount} nodes, ${validation.edgeCount} edges`);
174
+ // Step 3: Metadata
175
+ const raw = parseYaml(fs.readFileSync(absPath, "utf-8"));
176
+ let author = raw.author;
177
+ if (!author) {
178
+ const authorInput = await p.text({
179
+ message: "Author name",
180
+ placeholder: "your-username",
181
+ validate: (v) => (!v || v.trim().length === 0 ? "Author is required" : undefined),
182
+ });
183
+ if (p.isCancel(authorInput))
184
+ cancel();
185
+ author = authorInput.trim();
186
+ }
187
+ else {
188
+ p.log.info(`Author: ${chalk.cyan(author)}`);
189
+ }
190
+ let category = raw.category;
191
+ if (!category || !VALID_CATEGORIES.includes(category)) {
192
+ const catInput = await p.select({
193
+ message: "Category",
194
+ options: VALID_CATEGORIES.map((c) => ({ value: c, label: c })),
195
+ });
196
+ if (p.isCancel(catInput))
197
+ cancel();
198
+ category = catInput;
199
+ }
200
+ else {
201
+ p.log.info(`Category: ${chalk.cyan(category)}`);
202
+ }
203
+ let tags = raw.tags;
204
+ if (!tags || !Array.isArray(tags) || tags.length === 0) {
205
+ const tagsInput = await p.text({
206
+ message: "Tags (comma-separated)",
207
+ placeholder: "automation, testing, ci",
208
+ });
209
+ if (p.isCancel(tagsInput))
210
+ cancel();
211
+ tags = tagsInput
212
+ .split(",")
213
+ .map((t) => t.trim())
214
+ .filter(Boolean);
215
+ }
216
+ else {
217
+ p.log.info(`Tags: ${chalk.cyan(tags.join(", "))}`);
218
+ }
219
+ // Step 4: Output mode
220
+ return await selectOutputMode("workflow", validation.id, absPath, {
221
+ author,
222
+ category,
223
+ tags,
224
+ });
225
+ }
226
+ async function publishSkill() {
227
+ // Step 2: Path to skill directory
228
+ const dirPath = await p.text({
229
+ message: "Path to skill directory (containing SKILL.md)",
230
+ placeholder: ".sweny/skills/my-skill/",
231
+ validate: (v) => {
232
+ if (!v || v.trim().length === 0)
233
+ return "Path is required";
234
+ const resolved = path.resolve(v.trim());
235
+ if (!fs.existsSync(resolved))
236
+ return "Directory not found";
237
+ if (!fs.statSync(resolved).isDirectory())
238
+ return "Path must be a directory";
239
+ if (!fs.existsSync(path.join(resolved, "SKILL.md")))
240
+ return "SKILL.md not found in directory";
241
+ return undefined;
242
+ },
243
+ });
244
+ if (p.isCancel(dirPath))
245
+ cancel();
246
+ const absDir = path.resolve(dirPath.trim());
247
+ const validation = validateSkillDir(absDir);
248
+ if (!validation.valid) {
249
+ p.log.error(`Validation failed:\n${validation.errors.map((e) => ` - ${e}`).join("\n")}`);
250
+ return null;
251
+ }
252
+ const badges = [];
253
+ if (validation.hasInstruction)
254
+ badges.push("instruction");
255
+ if (validation.hasMcp)
256
+ badges.push("MCP");
257
+ p.log.success(`Validated: ${chalk.cyan(validation.id)} [${badges.join(" + ")}]`);
258
+ return await selectOutputMode("skill", validation.id, absDir, {});
259
+ }
260
+ async function selectOutputMode(type, id, sourcePath, meta) {
261
+ const hasGh = ghAvailable();
262
+ const options = [
263
+ ...(hasGh
264
+ ? [{ value: "pr", label: "Open GitHub PR", hint: "recommended — forks marketplace repo and opens a PR" }]
265
+ : []),
266
+ { value: "save", label: "Save submission file", hint: "saves to ./sweny-publish/" },
267
+ ];
268
+ const mode = await p.select({
269
+ message: "How would you like to publish?",
270
+ options,
271
+ });
272
+ if (p.isCancel(mode))
273
+ cancel();
274
+ if (mode === "pr") {
275
+ return await openGitHubPR(type, id, sourcePath, meta);
276
+ }
277
+ else {
278
+ return await saveLocally(type, id, sourcePath);
279
+ }
280
+ }
281
+ async function openGitHubPR(type, id, sourcePath, meta) {
282
+ if (!ghAuthenticated()) {
283
+ p.log.error("Not authenticated with GitHub. Run: gh auth login");
284
+ return null;
285
+ }
286
+ const s = p.spinner();
287
+ s.start("Forking marketplace repository...");
288
+ try {
289
+ // Fork the marketplace repo (idempotent — if already forked, just continues)
290
+ execSync("gh repo fork swenyai/marketplace --clone=false 2>&1", { stdio: "pipe" });
291
+ // Get the fork name
292
+ const forkInfo = execSync("gh api user", { stdio: "pipe", encoding: "utf-8" });
293
+ const username = JSON.parse(forkInfo).login;
294
+ const forkRepo = `${username}/marketplace`;
295
+ s.message("Creating branch...");
296
+ const branchName = `publish/${type}/${id}`;
297
+ // Clone to temp, create branch, add file, push, create PR
298
+ const tmpDir = fs.mkdtempSync(path.join(process.env.TMPDIR ?? "/tmp", "sweny-publish-"));
299
+ execSync(`gh repo clone ${forkRepo} ${tmpDir} -- --depth 1`, { stdio: "pipe" });
300
+ execSync(`git -C ${tmpDir} checkout -b ${branchName}`, { stdio: "pipe" });
301
+ if (type === "workflow") {
302
+ const dest = path.join(tmpDir, "workflows", "community", `${id}.yml`);
303
+ fs.copyFileSync(sourcePath, dest);
304
+ execSync(`git -C ${tmpDir} add workflows/community/${id}.yml`, { stdio: "pipe" });
305
+ }
306
+ else {
307
+ const destDir = path.join(tmpDir, "skills", "community", id);
308
+ fs.mkdirSync(destDir, { recursive: true });
309
+ // Copy entire skill directory
310
+ const files = fs.readdirSync(sourcePath);
311
+ for (const file of files) {
312
+ fs.copyFileSync(path.join(sourcePath, file), path.join(destDir, file));
313
+ }
314
+ execSync(`git -C ${tmpDir} add skills/community/${id}`, { stdio: "pipe" });
315
+ }
316
+ s.message("Pushing...");
317
+ const commitMsg = type === "workflow" ? `feat: add ${id} workflow` : `feat: add ${id} skill`;
318
+ execSync(`git -C ${tmpDir} commit -m "${commitMsg}"`, { stdio: "pipe" });
319
+ execSync(`git -C ${tmpDir} push origin ${branchName}`, { stdio: "pipe" });
320
+ s.message("Creating pull request...");
321
+ const prBody = type === "workflow"
322
+ ? `## New Workflow: ${id}\n\n**Author:** ${meta.author}\n**Category:** ${meta.category}\n**Tags:** ${meta.tags?.join(", ")}\n\nSubmitted via \`sweny publish\`.`
323
+ : `## New Skill: ${id}\n\nSubmitted via \`sweny publish\`.`;
324
+ const prResult = execSync(`gh pr create --repo swenyai/marketplace --head ${username}:${branchName} --title "feat: add ${type} ${id}" --body "${prBody.replace(/"/g, '\\"')}"`, { stdio: "pipe", encoding: "utf-8" });
325
+ const prUrl = prResult.trim();
326
+ // Cleanup temp dir
327
+ fs.rmSync(tmpDir, { recursive: true, force: true });
328
+ s.stop(`PR created: ${chalk.cyan(prUrl)}`);
329
+ return { type, id, name: id, prUrl };
330
+ }
331
+ catch (e) {
332
+ s.stop("Failed to create PR");
333
+ p.log.error(`GitHub error: ${e instanceof Error ? e.message : e}`);
334
+ p.log.info("Falling back to save locally...");
335
+ return await saveLocally(type, id, sourcePath);
336
+ }
337
+ }
338
+ async function saveLocally(type, id, sourcePath) {
339
+ const outputDir = path.resolve("sweny-publish");
340
+ fs.mkdirSync(outputDir, { recursive: true });
341
+ if (type === "workflow") {
342
+ const dest = path.join(outputDir, `${id}.yml`);
343
+ fs.copyFileSync(sourcePath, dest);
344
+ p.log.success(`Saved to ${chalk.cyan(dest)}`);
345
+ p.log.info(`Submit manually: copy to workflows/community/ in a fork of swenyai/marketplace`);
346
+ return { type, id, name: id, outputPath: dest };
347
+ }
348
+ else {
349
+ const destDir = path.join(outputDir, id);
350
+ fs.mkdirSync(destDir, { recursive: true });
351
+ const files = fs.readdirSync(sourcePath);
352
+ for (const file of files) {
353
+ fs.copyFileSync(path.join(sourcePath, file), path.join(destDir, file));
354
+ }
355
+ p.log.success(`Saved to ${chalk.cyan(destDir)}`);
356
+ p.log.info(`Submit manually: copy to skills/community/ in a fork of swenyai/marketplace`);
357
+ return { type, id, name: id, outputPath: destDir };
358
+ }
359
+ }
360
+ // ── Command registration ───────────────────────────────────────────────
361
+ export function registerPublishCommand(program) {
362
+ program
363
+ .command("publish")
364
+ .description("Publish a workflow or skill to the SWEny Marketplace")
365
+ .action(async () => {
366
+ await runPublish();
367
+ });
368
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Starter workflow templates offered during `sweny new`.
3
+ *
4
+ * Each template is a valid SWEny workflow YAML. Templates are self-contained
5
+ * strings — no filesystem reads needed, works from npm install.
6
+ */
7
+ export interface WorkflowTemplate {
8
+ id: string;
9
+ name: string;
10
+ description: string;
11
+ yaml: string;
12
+ }
13
+ export declare const WORKFLOW_TEMPLATES: WorkflowTemplate[];
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Starter workflow templates offered during `sweny new`.
3
+ *
4
+ * Each template is a valid SWEny workflow YAML. Templates are self-contained
5
+ * strings — no filesystem reads needed, works from npm install.
6
+ */
7
+ export const WORKFLOW_TEMPLATES = [
8
+ {
9
+ id: "pr-review",
10
+ name: "PR Review Bot",
11
+ description: "Automated code review on pull requests",
12
+ yaml: `id: pr-review
13
+ name: PR Review Bot
14
+ description: Review pull requests for code quality, security issues, and best practices.
15
+ entry: fetch-diff
16
+
17
+ nodes:
18
+ fetch-diff:
19
+ name: Fetch PR Changes
20
+ instruction: |
21
+ Fetch the pull request diff. Identify which files changed,
22
+ what was added, modified, or removed. Summarize the scope
23
+ of the change.
24
+ skills: [github]
25
+
26
+ review-code:
27
+ name: Review Code
28
+ instruction: |
29
+ Review the code changes for:
30
+ - Security vulnerabilities (injection, auth issues, secrets)
31
+ - Logic errors and edge cases
32
+ - Code style and readability
33
+ - Test coverage gaps
34
+
35
+ Be specific — reference file names and line numbers.
36
+ Categorize findings as critical, important, or minor.
37
+ skills: [github]
38
+
39
+ post-review:
40
+ name: Post Review Comment
41
+ instruction: |
42
+ Post a structured review comment on the pull request.
43
+ Lead with a summary, then list findings by severity.
44
+ If no issues found, approve with a brief note.
45
+ skills: [github]
46
+
47
+ edges:
48
+ - from: fetch-diff
49
+ to: review-code
50
+ - from: review-code
51
+ to: post-review
52
+ `,
53
+ },
54
+ {
55
+ id: "issue-triage",
56
+ name: "Issue Triage",
57
+ description: "Classify, prioritize, and label incoming issues",
58
+ yaml: `id: issue-triage
59
+ name: Issue Triage
60
+ description: Automatically classify, prioritize, and label new issues.
61
+ entry: classify
62
+
63
+ nodes:
64
+ classify:
65
+ name: Classify Issue
66
+ instruction: |
67
+ Read the issue title and body. Classify it as one of:
68
+ - bug: Something is broken
69
+ - feature: A new capability request
70
+ - question: Asking for help or clarification
71
+ - chore: Maintenance, refactoring, docs
72
+
73
+ Also assess priority:
74
+ - P0: Production is down or data loss
75
+ - P1: Major feature broken, workaround exists
76
+ - P2: Minor issue, low impact
77
+ - P3: Nice to have, no urgency
78
+ skills: [github]
79
+
80
+ label-and-assign:
81
+ name: Apply Labels
82
+ instruction: |
83
+ Based on the classification, apply the appropriate labels
84
+ to the issue (e.g. "bug", "P1", "needs-triage").
85
+ Add a comment explaining the classification rationale.
86
+ skills: [github]
87
+
88
+ edges:
89
+ - from: classify
90
+ to: label-and-assign
91
+ `,
92
+ },
93
+ {
94
+ id: "security-scan",
95
+ name: "Security Audit",
96
+ description: "Scan code and dependencies for security issues",
97
+ yaml: `id: security-scan
98
+ name: Security Audit
99
+ description: Scan repository code and dependencies for security vulnerabilities.
100
+ entry: scan-code
101
+
102
+ nodes:
103
+ scan-code:
104
+ name: Scan Code for Secrets
105
+ instruction: |
106
+ Search the repository for exposed secrets, API keys,
107
+ passwords, and tokens in code, config files, and
108
+ environment templates. Check for common patterns:
109
+ hardcoded credentials, leaked keys, insecure defaults.
110
+ skills: [github]
111
+
112
+ scan-deps:
113
+ name: Scan Dependencies
114
+ instruction: |
115
+ Review dependency manifests (package.json, requirements.txt,
116
+ go.mod, etc.) for known vulnerable versions. Check for
117
+ outdated packages with security advisories.
118
+ skills: [github]
119
+
120
+ compile-report:
121
+ name: Compile Security Report
122
+ instruction: |
123
+ Compile findings from the code and dependency scans into
124
+ a structured security report. Categorize by severity
125
+ (critical, high, medium, low). Include remediation steps
126
+ for each finding.
127
+ skills: [github]
128
+
129
+ edges:
130
+ - from: scan-code
131
+ to: scan-deps
132
+ - from: scan-deps
133
+ to: compile-report
134
+ `,
135
+ },
136
+ {
137
+ id: "release-notes",
138
+ name: "Release Notes",
139
+ description: "Generate release notes from commits and PRs",
140
+ yaml: `id: release-notes
141
+ name: Release Notes Generator
142
+ description: Generate structured release notes from recent commits and merged PRs.
143
+ entry: gather
144
+
145
+ nodes:
146
+ gather:
147
+ name: Gather Changes
148
+ instruction: |
149
+ List all commits and merged pull requests since the last
150
+ release tag. For each, extract: title, author, PR number,
151
+ and a brief description of what changed.
152
+ skills: [github]
153
+
154
+ categorize:
155
+ name: Categorize Changes
156
+ instruction: |
157
+ Group the changes into categories:
158
+ - Features: New capabilities
159
+ - Fixes: Bug fixes
160
+ - Improvements: Performance, refactoring, DX
161
+ - Breaking Changes: Anything that requires migration
162
+
163
+ Flag any breaking changes prominently.
164
+ skills: [github]
165
+
166
+ write-notes:
167
+ name: Write Release Notes
168
+ instruction: |
169
+ Write polished release notes in markdown. Lead with
170
+ highlights, then list changes by category. Credit
171
+ contributors. Keep it concise but informative.
172
+ skills: [github]
173
+
174
+ edges:
175
+ - from: gather
176
+ to: categorize
177
+ - from: categorize
178
+ to: write-notes
179
+ `,
180
+ },
181
+ ];