@wipcomputer/wip-release 1.2.4 → 1.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,9 +3,6 @@
3
3
 
4
4
 
5
5
 
6
-
7
-
8
-
9
6
  ## 1.2.4 (2026-02-21)
10
7
 
11
8
  Align description across all sources
package/LICENSE CHANGED
@@ -1,6 +1,10 @@
1
- MIT License
1
+ Dual License: MIT + AGPLv3
2
2
 
3
- Copyright (c) 2026 Parker Todd Brooks
3
+ Copyright (c) 2026 WIP Computer, Inc.
4
+
5
+
6
+ 1. MIT License (local and personal use)
7
+ ---------------------------------------
4
8
 
5
9
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
10
  of this software and associated documentation files (the "Software"), to deal
@@ -19,3 +23,30 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
23
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
24
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
25
  SOFTWARE.
26
+
27
+
28
+ 2. GNU Affero General Public License v3.0 (commercial and cloud use)
29
+ --------------------------------------------------------------------
30
+
31
+ If you run this software as part of a hosted service, cloud platform,
32
+ marketplace listing, or any network-accessible offering for commercial
33
+ purposes, the AGPLv3 terms apply. You must either:
34
+
35
+ a) Release your complete source code under AGPLv3, or
36
+ b) Obtain a commercial license.
37
+
38
+ This program is free software: you can redistribute it and/or modify
39
+ it under the terms of the GNU Affero General Public License as published
40
+ by the Free Software Foundation, either version 3 of the License, or
41
+ (at your option) any later version.
42
+
43
+ This program is distributed in the hope that it will be useful,
44
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
45
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
46
+ GNU Affero General Public License for more details.
47
+
48
+ You should have received a copy of the GNU Affero General Public License
49
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
50
+
51
+
52
+ AGPLv3 for personal use is free. Commercial licenses available.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  You ship a fix. Now you have to bump package.json, update CHANGELOG.md, sync the version in SKILL.md, commit, tag, push, publish to npm, publish to GitHub Packages, and create a GitHub release. Every time. Miss a step and versions drift.
8
8
 
9
- `wip-release` does all of it in one command.
9
+ `wip-release` does all of it in one command. It also checks that product docs (dev update, roadmap, readme-first) are up to date before publishing. Patches get a warning. Minor and major releases are blocked until docs are updated.
10
10
 
11
11
  ## Install
12
12
 
@@ -35,6 +35,11 @@ See [REFERENCE.md](REFERENCE.md) for full usage, pipeline steps, flags, auth, an
35
35
 
36
36
  ## License
37
37
 
38
- MIT
38
+ ```
39
+ CLI, MCP server, skills MIT (use anywhere, no restrictions)
40
+ Hosted or cloud service use AGPL (network service distribution)
41
+ ```
42
+
43
+ AGPL for personal use is free.
39
44
 
40
- Built by Parker Todd Brooks, with Claude Code and Lēsa (OpenClaw).
45
+ Built by Parker Todd Brooks, Lēsa (OpenClaw, Claude Opus 4.6), Claude Code (Claude Opus 4.6).
package/SKILL.md CHANGED
@@ -1,9 +1,13 @@
1
1
  ---
2
- name: WIP.release
3
- version: 1.2.4
2
+ name: wip-release
4
3
  description: One-command release pipeline. Bumps version, updates changelog + SKILL.md, publishes to npm + GitHub.
5
- homepage: https://github.com/wipcomputer/wip-release
4
+ license: MIT
5
+ interface: [cli, module, mcp]
6
6
  metadata:
7
+ display-name: "Release Pipeline"
8
+ version: "1.2.4"
9
+ homepage: "https://github.com/wipcomputer/wip-release"
10
+ author: "Parker Todd Brooks"
7
11
  category: dev-tools
8
12
  capabilities:
9
13
  - version-bump
@@ -11,22 +15,25 @@ metadata:
11
15
  - skill-sync
12
16
  - npm-publish
13
17
  - github-release
14
- dependencies: []
15
- interface: CLI
16
18
  requires:
17
- binaries: [git, npm, gh, op, clawhub]
19
+ bins: [git, npm, gh, op, clawhub]
18
20
  secrets:
19
21
  - path: ~/.openclaw/secrets/op-sa-token
20
22
  description: 1Password service account token
21
23
  - vault: Agent Secrets
22
24
  item: npm Token
23
25
  description: npm publish token
24
- openclaw:
25
- emoji: "🚀"
26
- install:
27
- env: []
28
- author:
29
- name: Parker Todd Brooks
26
+ openclaw:
27
+ requires:
28
+ bins: [git, npm, gh, op]
29
+ install:
30
+ - id: node
31
+ kind: node
32
+ package: "@wipcomputer/wip-release"
33
+ bins: [wip-release]
34
+ label: "Install via npm"
35
+ emoji: "🚀"
36
+ compatibility: Requires git, npm, gh, op (1Password CLI). Node.js 18+.
30
37
  ---
31
38
 
32
39
  # wip-release
@@ -56,11 +63,25 @@ Local release pipeline. One command bumps version, updates all docs, publishes e
56
63
  ### CLI
57
64
 
58
65
  ```bash
59
- wip-release patch --notes="fix X" # full pipeline
60
- wip-release minor --dry-run # preview only
61
- wip-release major --no-publish # bump + tag only
66
+ wip-release patch --notes="fix X" # full pipeline
67
+ wip-release minor --dry-run # preview only
68
+ wip-release major --no-publish # bump + tag only
69
+ wip-release patch --skip-product-check # skip product docs gate
62
70
  ```
63
71
 
72
+ ### Product Docs Gate
73
+
74
+ wip-release checks that product docs (dev update, roadmap, readme-first) were updated before publishing. Only runs on repos with an `ai/` directory.
75
+
76
+ - **patch**: warns if product docs are stale (non-blocking)
77
+ - **minor/major**: blocks release until product docs are updated
78
+ - **--skip-product-check**: bypasses the gate
79
+
80
+ Checks:
81
+ 1. `ai/dev-updates/` has a file from the last 3 days
82
+ 2. `ai/product/plans-prds/roadmap.md` was modified since last release
83
+ 3. `ai/product/readme-first-product.md` was modified since last release
84
+
64
85
  ### Module
65
86
 
66
87
  ```javascript
@@ -79,3 +100,17 @@ Branch protection may prevent direct pushes. Make sure you're on main after merg
79
100
 
80
101
  ### SKILL.md not updated
81
102
  Only updates if the file has a YAML frontmatter `version:` field between `---` markers.
103
+
104
+ ## MCP
105
+
106
+ Tools: `release`, `release_status`
107
+
108
+ Add to `.mcp.json`:
109
+ ```json
110
+ {
111
+ "wip-release": {
112
+ "command": "node",
113
+ "args": ["/path/to/tools/wip-release/mcp-server.mjs"]
114
+ }
115
+ }
116
+ ```
package/cli.js CHANGED
@@ -18,7 +18,69 @@ function flag(name) {
18
18
 
19
19
  const dryRun = args.includes('--dry-run');
20
20
  const noPublish = args.includes('--no-publish');
21
- const notes = flag('notes');
21
+ const skipProductCheck = args.includes('--skip-product-check');
22
+ const skipStaleCheck = args.includes('--skip-stale-check');
23
+ const notesFilePath = flag('notes-file');
24
+ let notes = flag('notes');
25
+ let notesSource = notes ? 'flag' : 'none'; // track where notes came from
26
+
27
+ // Auto-detect RELEASE-NOTES-v{version}.md if no --notes or --notes-file provided.
28
+ // Also supports explicit --notes-file for custom paths.
29
+ {
30
+ const { readFileSync, existsSync } = await import('node:fs');
31
+ const { resolve, join } = await import('node:path');
32
+
33
+ if (notesFilePath) {
34
+ // Explicit --notes-file
35
+ const resolved = resolve(notesFilePath);
36
+ if (!existsSync(resolved)) {
37
+ console.error(` ✗ Notes file not found: ${resolved}`);
38
+ process.exit(1);
39
+ }
40
+ notes = readFileSync(resolved, 'utf8').trim();
41
+ notesSource = 'file';
42
+ } else if (!notes && level) {
43
+ // Auto-detect: compute the next version and look for RELEASE-NOTES-v{version}.md
44
+ try {
45
+ const { detectCurrentVersion, bumpSemver } = await import('./core.mjs');
46
+ const cwd = process.cwd();
47
+ const currentVersion = detectCurrentVersion(cwd);
48
+ const newVersion = bumpSemver(currentVersion, level);
49
+ const dashed = newVersion.replace(/\./g, '-');
50
+ const autoFile = join(cwd, `RELEASE-NOTES-v${dashed}.md`);
51
+ if (existsSync(autoFile)) {
52
+ notes = readFileSync(autoFile, 'utf8').trim();
53
+ notesSource = 'file';
54
+ console.log(` ✓ Found RELEASE-NOTES-v${dashed}.md`);
55
+ }
56
+ } catch {}
57
+ }
58
+
59
+ // Auto-detect dev update from ai/dev-updates/ if notes are missing or thin
60
+ if (level && (!notes || notes.length < 100)) {
61
+ try {
62
+ const { readdirSync } = await import('node:fs');
63
+ const devUpdatesDir = join(process.cwd(), 'ai', 'dev-updates');
64
+ if (existsSync(devUpdatesDir)) {
65
+ const today = new Date().toISOString().split('T')[0];
66
+ const todayFiles = readdirSync(devUpdatesDir)
67
+ .filter(f => f.startsWith(today) && f.endsWith('.md'))
68
+ .sort()
69
+ .reverse();
70
+
71
+ if (todayFiles.length > 0) {
72
+ const devUpdatePath = join(devUpdatesDir, todayFiles[0]);
73
+ const devUpdateContent = readFileSync(devUpdatePath, 'utf8').trim();
74
+ if (devUpdateContent.length > (notes || '').length) {
75
+ notes = devUpdateContent;
76
+ notesSource = 'dev-update';
77
+ console.log(` ✓ Found dev update: ai/dev-updates/${todayFiles[0]}`);
78
+ }
79
+ }
80
+ }
81
+ } catch {}
82
+ }
83
+ }
22
84
 
23
85
  if (!level || args.includes('--help') || args.includes('-h')) {
24
86
  const cwd = process.cwd();
@@ -33,9 +95,19 @@ Usage:
33
95
  wip-release major 1.0.0 -> 2.0.0
34
96
 
35
97
  Flags:
36
- --notes="description" Changelog entry text
98
+ --notes="description" Release narrative (what was built and why)
99
+ --notes-file=path Read release narrative from a markdown file
37
100
  --dry-run Show what would happen, change nothing
38
101
  --no-publish Bump + tag only, skip npm/GitHub
102
+ --skip-product-check Skip product docs check (dev update, roadmap, readme-first)
103
+ --skip-stale-check Skip stale remote branch check
104
+
105
+ Release notes:
106
+ Auto-detects notes from three sources (first match wins):
107
+ 1. --notes-file=path Explicit file path
108
+ 2. RELEASE-NOTES-v{ver}.md In repo root (e.g. RELEASE-NOTES-v1-7-4.md)
109
+ 3. ai/dev-updates/YYYY-MM-DD* Today's dev update files (most recent first)
110
+ Write dev updates as you work. wip-release picks them up automatically.
39
111
 
40
112
  Pipeline:
41
113
  1. Bump package.json version
@@ -53,8 +125,11 @@ release({
53
125
  repoPath: process.cwd(),
54
126
  level,
55
127
  notes,
128
+ notesSource,
56
129
  dryRun,
57
130
  noPublish,
131
+ skipProductCheck,
132
+ skipStaleCheck,
58
133
  }).catch(err => {
59
134
  console.error(` ✗ ${err.message}`);
60
135
  process.exit(1);
package/core.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { execSync, execFileSync } from 'node:child_process';
9
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
9
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync } from 'node:fs';
10
10
  import { join, basename } from 'node:path';
11
11
 
12
12
  // ── Version ─────────────────────────────────────────────────────────
@@ -54,10 +54,27 @@ export function syncSkillVersion(repoPath, newVersion) {
54
54
  if (!existsSync(skillPath)) return false;
55
55
 
56
56
  let content = readFileSync(skillPath, 'utf8');
57
- // Match version: X.Y.Z in YAML frontmatter (between --- markers)
57
+
58
+ // Check for staleness: if SKILL.md version is more than a patch behind,
59
+ // warn that content may need updating (not just the version number)
60
+ const skillVersionMatch = content.match(/^---[\s\S]*?version:\s*"?(\d+\.\d+\.\d+)"?[\s\S]*?---/);
61
+ if (skillVersionMatch) {
62
+ const skillVersion = skillVersionMatch[1];
63
+ const [sMaj, sMin] = skillVersion.split('.').map(Number);
64
+ const [nMaj, nMin] = newVersion.split('.').map(Number);
65
+ if (nMaj > sMaj || nMin > sMin + 1) {
66
+ console.warn(` ! SKILL.md is at ${skillVersion}, releasing ${newVersion}`);
67
+ console.warn(` SKILL.md content may be stale. Review tool list and interfaces.`);
68
+ }
69
+ }
70
+
71
+ // Match version line in YAML frontmatter (between --- markers).
72
+ // Uses "[^\n]* for quoted values (including corrupted multi-quote strings
73
+ // like "1.9.5".9.4".9.3") or \S+ for unquoted values. This replaces the
74
+ // ENTIRE value on the line, preventing the accumulation bug (#71).
58
75
  const updated = content.replace(
59
- /^(---[\s\S]*?)(version:\s*)\S+([\s\S]*?---)/,
60
- `$1$2${newVersion}$3`
76
+ /^(---[\s\S]*?version:\s*)(?:"[^\n]*|\S+)([\s\S]*?---)/,
77
+ `$1"${newVersion}"$2`
61
78
  );
62
79
 
63
80
  if (updated === content) return false;
@@ -95,6 +112,25 @@ export function updateChangelog(repoPath, newVersion, notes) {
95
112
 
96
113
  // ── Git ─────────────────────────────────────────────────────────────
97
114
 
115
+ /**
116
+ * Move all RELEASE-NOTES-v*.md files to _trash/.
117
+ * Returns the number of files moved.
118
+ */
119
+ function trashReleaseNotes(repoPath) {
120
+ const files = readdirSync(repoPath).filter(f => /^RELEASE-NOTES-v.*\.md$/i.test(f));
121
+ if (files.length === 0) return 0;
122
+
123
+ const trashDir = join(repoPath, '_trash');
124
+ if (!existsSync(trashDir)) mkdirSync(trashDir);
125
+
126
+ for (const f of files) {
127
+ renameSync(join(repoPath, f), join(trashDir, f));
128
+ execFileSync('git', ['add', join('_trash', f)], { cwd: repoPath, stdio: 'pipe' });
129
+ execFileSync('git', ['rm', '--cached', f], { cwd: repoPath, stdio: 'pipe' });
130
+ }
131
+ return files.length;
132
+ }
133
+
98
134
  function gitCommitAndTag(repoPath, newVersion, notes) {
99
135
  const msg = `v${newVersion}: ${notes || 'Release'}`;
100
136
  // Stage known files (ignore missing ones)
@@ -134,55 +170,223 @@ export function publishGitHubPackages(repoPath) {
134
170
  }
135
171
 
136
172
  /**
137
- * Build detailed release notes from git history and repo metadata.
173
+ * Categorize a commit message into a section.
174
+ * Returns: 'changes', 'fixes', 'docs', 'internal'
175
+ */
176
+ function categorizeCommit(subject) {
177
+ const lower = subject.toLowerCase();
178
+
179
+ // Fixes
180
+ if (lower.startsWith('fix') || lower.startsWith('hotfix') || lower.startsWith('bugfix') ||
181
+ lower.includes('fix:') || lower.includes('bug:')) {
182
+ return 'fixes';
183
+ }
184
+
185
+ // Docs
186
+ if (lower.startsWith('doc') || lower.startsWith('readme') ||
187
+ lower.includes('docs:') || lower.includes('doc:') ||
188
+ lower.startsWith('update readme') || lower.startsWith('rewrite readme') ||
189
+ lower.startsWith('update technical') || lower.startsWith('rewrite relay') ||
190
+ lower.startsWith('update relay')) {
191
+ return 'docs';
192
+ }
193
+
194
+ // Internal (skip in release notes)
195
+ if (lower.startsWith('chore') || lower.startsWith('auto-commit') ||
196
+ lower.startsWith('merge pull request') || lower.startsWith('merge branch') ||
197
+ lower.match(/^v\d+\.\d+\.\d+/) || lower.startsWith('mark ') ||
198
+ lower.startsWith('clean up todo') || lower.startsWith('keep ')) {
199
+ return 'internal';
200
+ }
201
+
202
+ // Everything else is a change
203
+ return 'changes';
204
+ }
205
+
206
+ /**
207
+ * Check release notes quality. Returns { ok, issues[] }.
208
+ *
209
+ * notesSource: 'file' (RELEASE-NOTES-v*.md or --notes-file),
210
+ * 'dev-update' (ai/dev-updates/ fallback),
211
+ * 'flag' (bare --notes="string"),
212
+ * 'none' (nothing provided).
213
+ *
214
+ * For minor/major: BLOCKS if notes came from bare --notes flag or are missing.
215
+ * Agents must write a RELEASE-NOTES-v{version}.md file and commit it.
216
+ * For patch: WARNS only.
217
+ */
218
+ function checkReleaseNotes(notes, notesSource, level) {
219
+ const issues = [];
220
+ const isMinorOrMajor = level === 'minor' || level === 'major';
221
+
222
+ if (!notes) {
223
+ issues.push('No release notes provided. Write a RELEASE-NOTES-v{version}.md file.');
224
+ return { ok: false, issues };
225
+ }
226
+
227
+ // Bare --notes flag is not acceptable for minor/major.
228
+ // Agents must write a file, not pass a one-liner.
229
+ if (notesSource === 'flag') {
230
+ if (isMinorOrMajor) {
231
+ issues.push('Release notes came from --notes flag, not a file.');
232
+ issues.push('Write RELEASE-NOTES-v{version}.md (dashes not dots) and commit it.');
233
+ issues.push('wip-release auto-detects the file. No --notes flag needed.');
234
+ } else if (notes.length < 50) {
235
+ issues.push('Release notes are very short. Consider writing a RELEASE-NOTES file.');
236
+ }
237
+ }
238
+
239
+ // Check for changelog-style one-liners regardless of source
240
+ const looksLikeChangelog = /^(fix|add|update|remove|bump|chore|refactor|docs?)[\s:]/i.test(notes);
241
+ if (looksLikeChangelog && notes.length < 100) {
242
+ issues.push('Notes look like a changelog entry, not a narrative.');
243
+ }
244
+
245
+ return { ok: issues.length === 0, issues };
246
+ }
247
+
248
+ /**
249
+ * Check if a file was modified in commits since the last git tag.
250
+ */
251
+ function fileModifiedSinceLastTag(repoPath, relativePath) {
252
+ try {
253
+ const lastTag = execFileSync('git', ['describe', '--tags', '--abbrev=0'],
254
+ { cwd: repoPath, encoding: 'utf8' }).trim();
255
+ const diff = execFileSync('git', ['diff', '--name-only', lastTag, 'HEAD'],
256
+ { cwd: repoPath, encoding: 'utf8' });
257
+ return diff.split('\n').some(f => f.trim() === relativePath);
258
+ } catch {
259
+ // No tags yet or git error ... skip check
260
+ return true;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Check that product docs were updated for this release.
266
+ * Returns { missing: string[], ok: boolean, skipped: boolean }.
267
+ * Only runs if ai/ directory structure exists.
268
+ */
269
+ function checkProductDocs(repoPath) {
270
+ const missing = [];
271
+
272
+ // Skip repos without ai/ structure
273
+ const aiDir = join(repoPath, 'ai');
274
+ if (!existsSync(aiDir)) return { missing: [], ok: true, skipped: true };
275
+
276
+ // 1. Dev update: file from today (or last 3 days)
277
+ const devUpdatesDir = join(aiDir, 'dev-updates');
278
+ if (existsSync(devUpdatesDir)) {
279
+ const now = new Date();
280
+ const recentDates = [];
281
+ for (let i = 0; i < 3; i++) {
282
+ const d = new Date(now);
283
+ d.setDate(d.getDate() - i);
284
+ recentDates.push(d.toISOString().split('T')[0]);
285
+ }
286
+ const files = readdirSync(devUpdatesDir).filter(f => f.endsWith('.md'));
287
+ const hasRecent = files.some(f => recentDates.some(d => f.startsWith(d)));
288
+ if (!hasRecent) missing.push('ai/dev-updates/ (no dev update from last 3 days)');
289
+ }
290
+
291
+ // 2. Roadmap: modified since last tag
292
+ const roadmapPath = 'ai/product/plans-prds/roadmap.md';
293
+ if (existsSync(join(repoPath, roadmapPath))) {
294
+ if (!fileModifiedSinceLastTag(repoPath, roadmapPath)) {
295
+ missing.push('ai/product/plans-prds/roadmap.md (not updated since last release)');
296
+ }
297
+ }
298
+
299
+ // 3. Readme-first: modified since last tag
300
+ const readmeFirstPath = 'ai/product/readme-first-product.md';
301
+ if (existsSync(join(repoPath, readmeFirstPath))) {
302
+ if (!fileModifiedSinceLastTag(repoPath, readmeFirstPath)) {
303
+ missing.push('ai/product/readme-first-product.md (not updated since last release)');
304
+ }
305
+ }
306
+
307
+ return { missing, ok: missing.length === 0, skipped: false };
308
+ }
309
+
310
+ /**
311
+ * Build release notes with narrative first, commit details second.
312
+ *
313
+ * Release notes should tell the story: what was built, why, and why it matters.
314
+ * Commit history is included as supporting detail, not the main content.
315
+ * ai/ files are excluded from the files-changed stats.
138
316
  */
139
317
  export function buildReleaseNotes(repoPath, currentVersion, newVersion, notes) {
140
318
  const slug = detectRepoSlug(repoPath);
141
319
  const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
142
320
  const lines = [];
143
321
 
144
- // What changed section
145
- lines.push('## What changed\n');
322
+ // Narrative summary (the main content of the release notes)
146
323
  if (notes) {
147
324
  lines.push(notes);
148
325
  lines.push('');
149
326
  }
150
327
 
151
- // Commits since last tag
328
+ // Gather commits since last tag
152
329
  const prevTag = `v${currentVersion}`;
153
- let commits = '';
330
+ let rawCommits = [];
154
331
  try {
155
- commits = execFileSync('git', [
156
- 'log', `${prevTag}..HEAD`, '--pretty=format:- %s (%h)'
332
+ const raw = execFileSync('git', [
333
+ 'log', `${prevTag}..HEAD`, '--pretty=format:%h\t%s'
157
334
  ], { cwd: repoPath, encoding: 'utf8' }).trim();
335
+ if (raw) rawCommits = raw.split('\n').map(line => {
336
+ const [hash, ...rest] = line.split('\t');
337
+ return { hash, subject: rest.join('\t') };
338
+ });
158
339
  } catch {
159
- // No previous tag ... show all commits on branch
160
340
  try {
161
- commits = execFileSync('git', [
162
- 'log', '--pretty=format:- %s (%h)', '-20'
341
+ const raw = execFileSync('git', [
342
+ 'log', '--pretty=format:%h\t%s', '-30'
163
343
  ], { cwd: repoPath, encoding: 'utf8' }).trim();
344
+ if (raw) rawCommits = raw.split('\n').map(line => {
345
+ const [hash, ...rest] = line.split('\t');
346
+ return { hash, subject: rest.join('\t') };
347
+ });
164
348
  } catch {}
165
349
  }
166
350
 
167
- if (commits) {
168
- lines.push('### Commits\n');
169
- lines.push(commits);
170
- lines.push('');
351
+ // Categorize commits
352
+ const categories = { changes: [], fixes: [], docs: [], internal: [] };
353
+ for (const commit of rawCommits) {
354
+ const cat = categorizeCommit(commit.subject);
355
+ categories[cat].push(commit);
171
356
  }
172
357
 
173
- // Files changed
174
- let filesChanged = '';
175
- try {
176
- filesChanged = execFileSync('git', [
177
- 'diff', `${prevTag}..HEAD`, '--stat'
178
- ], { cwd: repoPath, encoding: 'utf8' }).trim();
179
- } catch {}
358
+ // Commit details section (supporting detail, not the headline)
359
+ const hasCommits = categories.changes.length + categories.fixes.length + categories.docs.length > 0;
360
+ if (hasCommits) {
361
+ lines.push('<details>');
362
+ lines.push('<summary>What changed (commits)</summary>');
363
+ lines.push('');
364
+
365
+ if (categories.changes.length > 0) {
366
+ lines.push('**Changes**');
367
+ for (const c of categories.changes) {
368
+ lines.push(`- ${c.subject} (${c.hash})`);
369
+ }
370
+ lines.push('');
371
+ }
372
+
373
+ if (categories.fixes.length > 0) {
374
+ lines.push('**Fixes**');
375
+ for (const c of categories.fixes) {
376
+ lines.push(`- ${c.subject} (${c.hash})`);
377
+ }
378
+ lines.push('');
379
+ }
380
+
381
+ if (categories.docs.length > 0) {
382
+ lines.push('**Docs**');
383
+ for (const c of categories.docs) {
384
+ lines.push(`- ${c.subject} (${c.hash})`);
385
+ }
386
+ lines.push('');
387
+ }
180
388
 
181
- if (filesChanged) {
182
- lines.push('### Files changed\n');
183
- lines.push('```');
184
- lines.push(filesChanged);
185
- lines.push('```');
389
+ lines.push('</details>');
186
390
  lines.push('');
187
391
  }
188
392
 
@@ -198,9 +402,13 @@ export function buildReleaseNotes(repoPath, currentVersion, newVersion, notes) {
198
402
  lines.push('```');
199
403
  lines.push('');
200
404
 
405
+ // Attribution
406
+ lines.push('---');
407
+ lines.push('');
408
+ lines.push('Built by Parker Todd Brooks, Lēsa (OpenClaw, Claude Opus 4.6), Claude Code (Claude Opus 4.6).');
409
+
201
410
  // Compare URL
202
411
  if (slug) {
203
- lines.push('---');
204
412
  lines.push('');
205
413
  lines.push(`Full changelog: https://github.com/${slug}/compare/v${currentVersion}...v${newVersion}`);
206
414
  }
@@ -264,8 +472,18 @@ function getNpmToken() {
264
472
  }
265
473
 
266
474
  function detectSkillSlug(repoPath) {
267
- // Slug must be lowercase and url-safe. Use directory name, not SKILL.md name
268
- // (SKILL.md name can be display-formatted like "WIP.release").
475
+ // Read the name field from SKILL.md frontmatter (agentskills.io spec: lowercase-hyphen slug).
476
+ // Falls back to directory name.
477
+ const skillPath = join(repoPath, 'SKILL.md');
478
+ if (existsSync(skillPath)) {
479
+ const content = readFileSync(skillPath, 'utf8');
480
+ const nameMatch = content.match(/^---[\s\S]*?\nname:\s*(.+?)\n/);
481
+ if (nameMatch) {
482
+ const name = nameMatch[1].trim().replace(/^["']|["']$/g, '');
483
+ // Only use if it looks like a slug (lowercase, hyphens)
484
+ if (/^[a-z][a-z0-9-]*$/.test(name)) return name;
485
+ }
486
+ }
269
487
  return basename(repoPath).toLowerCase();
270
488
  }
271
489
 
@@ -280,12 +498,61 @@ function detectRepoSlug(repoPath) {
280
498
  }
281
499
  }
282
500
 
501
+ // ── Stale Branch Check ──────────────────────────────────────────────
502
+
503
+ /**
504
+ * Check for remote branches that are already merged into origin/main.
505
+ * These should be cleaned up before releasing.
506
+ *
507
+ * For patch: WARN (non-blocking, just print stale branches).
508
+ * For minor/major: BLOCK (return { failed: true }).
509
+ *
510
+ * Filters out origin/main, origin/HEAD, and already-renamed --merged- branches.
511
+ */
512
+ export function checkStaleBranches(repoPath, level) {
513
+ try {
514
+ // Fetch latest remote state so --merged check is accurate
515
+ try {
516
+ execFileSync('git', ['fetch', '--prune'], { cwd: repoPath, stdio: 'pipe' });
517
+ } catch {
518
+ // Non-fatal: proceed with local state if fetch fails
519
+ }
520
+
521
+ const raw = execFileSync('git', ['branch', '-r', '--merged', 'origin/main'], {
522
+ cwd: repoPath, encoding: 'utf8'
523
+ }).trim();
524
+
525
+ if (!raw) return { stale: [], ok: true };
526
+
527
+ const stale = raw.split('\n')
528
+ .map(b => b.trim())
529
+ .filter(b =>
530
+ b &&
531
+ !b.includes('origin/main') &&
532
+ !b.includes('origin/HEAD') &&
533
+ !b.includes('--merged-')
534
+ );
535
+
536
+ if (stale.length === 0) return { stale: [], ok: true };
537
+
538
+ const isMinorOrMajor = level === 'minor' || level === 'major';
539
+ return {
540
+ stale,
541
+ ok: !isMinorOrMajor,
542
+ blocked: isMinorOrMajor,
543
+ };
544
+ } catch {
545
+ // Git command failed... skip check gracefully
546
+ return { stale: [], ok: true, skipped: true };
547
+ }
548
+ }
549
+
283
550
  // ── Main ────────────────────────────────────────────────────────────
284
551
 
285
552
  /**
286
553
  * Run the full release pipeline.
287
554
  */
288
- export async function release({ repoPath, level, notes, dryRun, noPublish }) {
555
+ export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck }) {
289
556
  repoPath = repoPath || process.cwd();
290
557
  const currentVersion = detectCurrentVersion(repoPath);
291
558
  const newVersion = bumpSemver(currentVersion, level);
@@ -295,7 +562,149 @@ export async function release({ repoPath, level, notes, dryRun, noPublish }) {
295
562
  console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (${level})`);
296
563
  console.log(` ${'─'.repeat(40)}`);
297
564
 
565
+ // 0. License compliance gate
566
+ const configPath = join(repoPath, '.license-guard.json');
567
+ if (existsSync(configPath)) {
568
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
569
+ const licenseIssues = [];
570
+
571
+ const licensePath = join(repoPath, 'LICENSE');
572
+ if (!existsSync(licensePath)) {
573
+ licenseIssues.push('LICENSE file is missing');
574
+ } else {
575
+ const licenseText = readFileSync(licensePath, 'utf8');
576
+ if (!licenseText.includes(config.copyright)) {
577
+ licenseIssues.push(`LICENSE copyright does not match "${config.copyright}"`);
578
+ }
579
+ if (config.license === 'MIT+AGPL' && !licenseText.includes('AGPL') && !licenseText.includes('GNU Affero')) {
580
+ licenseIssues.push('LICENSE is MIT-only but config requires MIT+AGPL');
581
+ }
582
+ }
583
+
584
+ if (!existsSync(join(repoPath, 'CLA.md'))) {
585
+ licenseIssues.push('CLA.md is missing');
586
+ }
587
+
588
+ const readmePath = join(repoPath, 'README.md');
589
+ if (existsSync(readmePath)) {
590
+ const readme = readFileSync(readmePath, 'utf8');
591
+ if (!readme.includes('## License')) licenseIssues.push('README.md missing ## License section');
592
+ if (config.license === 'MIT+AGPL' && !readme.includes('AGPL')) licenseIssues.push('README.md License section missing AGPL reference');
593
+ }
594
+
595
+ if (licenseIssues.length > 0) {
596
+ console.log(` ✗ License compliance failed:`);
597
+ for (const issue of licenseIssues) console.log(` - ${issue}`);
598
+ console.log(`\n Run \`wip-license-guard check --fix\` to auto-repair, then try again.`);
599
+ console.log('');
600
+ return { currentVersion, newVersion, dryRun: false, failed: true };
601
+ }
602
+ console.log(` ✓ License compliance passed`);
603
+ }
604
+
605
+ // 0.5. Product docs check
606
+ if (!skipProductCheck) {
607
+ const productCheck = checkProductDocs(repoPath);
608
+ if (!productCheck.skipped) {
609
+ if (productCheck.ok) {
610
+ console.log(' ✓ Product docs up to date');
611
+ } else {
612
+ const isMinorOrMajor = level === 'minor' || level === 'major';
613
+ const prefix = isMinorOrMajor ? '✗' : '!';
614
+ console.log(` ${prefix} Product docs need attention:`);
615
+ for (const m of productCheck.missing) console.log(` - ${m}`);
616
+ if (isMinorOrMajor) {
617
+ console.log('');
618
+ console.log(' Update product docs before a minor/major release.');
619
+ console.log(' Use --skip-product-check to override.');
620
+ console.log('');
621
+ return { currentVersion, newVersion, dryRun: false, failed: true };
622
+ }
623
+ }
624
+ }
625
+ }
626
+
627
+ // 0.75. Release notes quality gate
628
+ {
629
+ const notesCheck = checkReleaseNotes(notes, notesSource || 'flag', level);
630
+ if (notesCheck.ok) {
631
+ const sourceLabel = notesSource === 'file' ? 'from file' : notesSource === 'dev-update' ? 'from dev update' : 'from --notes';
632
+ console.log(` ✓ Release notes OK (${sourceLabel})`);
633
+ } else {
634
+ const isMinorOrMajor = level === 'minor' || level === 'major';
635
+ const prefix = isMinorOrMajor ? '✗' : '!';
636
+ console.log(` ${prefix} Release notes need attention:`);
637
+ for (const issue of notesCheck.issues) console.log(` - ${issue}`);
638
+ if (isMinorOrMajor) {
639
+ console.log('');
640
+ console.log(' Minor/major releases require a RELEASE-NOTES file, not a --notes one-liner.');
641
+ console.log(' Write RELEASE-NOTES-v{version}.md (dashes not dots), commit it, then release.');
642
+ console.log('');
643
+ return { currentVersion, newVersion, dryRun: false, failed: true };
644
+ }
645
+ }
646
+ }
647
+
648
+ // 0.8. Stale remote branch check
649
+ if (!skipStaleCheck) {
650
+ const staleCheck = checkStaleBranches(repoPath, level);
651
+ if (staleCheck.skipped) {
652
+ // Silently skip if git command failed
653
+ } else if (staleCheck.stale.length === 0) {
654
+ console.log(' ✓ No stale remote branches');
655
+ } else {
656
+ const isMinorOrMajor = level === 'minor' || level === 'major';
657
+ const prefix = isMinorOrMajor ? '✗' : '!';
658
+ console.log(` ${prefix} Stale remote branches merged into main:`);
659
+ for (const b of staleCheck.stale) console.log(` - ${b}`);
660
+ if (isMinorOrMajor) {
661
+ console.log('');
662
+ console.log(' Clean up stale branches before a minor/major release.');
663
+ console.log(' Delete them with: git push origin --delete <branch>');
664
+ console.log(' Use --skip-stale-check to override.');
665
+ console.log('');
666
+ return { currentVersion, newVersion, dryRun: false, failed: true };
667
+ }
668
+ }
669
+ }
670
+
298
671
  if (dryRun) {
672
+ // Product docs check (dry-run)
673
+ if (!skipProductCheck) {
674
+ const productCheck = checkProductDocs(repoPath);
675
+ if (!productCheck.skipped) {
676
+ if (productCheck.ok) {
677
+ console.log(' [dry run] ✓ Product docs up to date');
678
+ } else {
679
+ const isMinorOrMajor = level === 'minor' || level === 'major';
680
+ console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: product docs need updates`);
681
+ for (const m of productCheck.missing) console.log(` - ${m}`);
682
+ }
683
+ }
684
+ }
685
+ // Release notes check (dry-run)
686
+ {
687
+ const notesCheck = checkReleaseNotes(notes, notesSource || 'flag', level);
688
+ if (notesCheck.ok) {
689
+ const sourceLabel = notesSource === 'file' ? 'from file' : notesSource === 'dev-update' ? 'from dev update' : 'from --notes';
690
+ console.log(` [dry run] ✓ Release notes OK (${sourceLabel})`);
691
+ } else {
692
+ const isMinorOrMajor = level === 'minor' || level === 'major';
693
+ console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: release notes need attention`);
694
+ for (const issue of notesCheck.issues) console.log(` - ${issue}`);
695
+ }
696
+ }
697
+ // Stale branch check (dry-run)
698
+ if (!skipStaleCheck) {
699
+ const staleCheck = checkStaleBranches(repoPath, level);
700
+ if (!staleCheck.skipped && staleCheck.stale.length > 0) {
701
+ const isMinorOrMajor = level === 'minor' || level === 'major';
702
+ console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: stale remote branches`);
703
+ for (const b of staleCheck.stale) console.log(` - ${b}`);
704
+ } else if (!staleCheck.skipped) {
705
+ console.log(' [dry run] ✓ No stale remote branches');
706
+ }
707
+ }
299
708
  const hasSkill = existsSync(join(repoPath, 'SKILL.md'));
300
709
  console.log(` [dry run] Would bump package.json to ${newVersion}`);
301
710
  if (hasSkill) console.log(` [dry run] Would update SKILL.md version`);
@@ -317,6 +726,30 @@ export async function release({ repoPath, level, notes, dryRun, noPublish }) {
317
726
  writePackageVersion(repoPath, newVersion);
318
727
  console.log(` ✓ package.json -> ${newVersion}`);
319
728
 
729
+ // 1.5. Bump sub-tool versions in toolbox repos (tools/*/)
730
+ const toolsDir = join(repoPath, 'tools');
731
+ if (existsSync(toolsDir)) {
732
+ let subBumped = 0;
733
+ try {
734
+ const entries = readdirSync(toolsDir, { withFileTypes: true });
735
+ for (const entry of entries) {
736
+ if (!entry.isDirectory()) continue;
737
+ const subPkgPath = join(toolsDir, entry.name, 'package.json');
738
+ if (existsSync(subPkgPath)) {
739
+ try {
740
+ const subPkg = JSON.parse(readFileSync(subPkgPath, 'utf8'));
741
+ subPkg.version = newVersion;
742
+ writeFileSync(subPkgPath, JSON.stringify(subPkg, null, 2) + '\n');
743
+ subBumped++;
744
+ } catch {}
745
+ }
746
+ }
747
+ } catch {}
748
+ if (subBumped > 0) {
749
+ console.log(` ✓ ${subBumped} sub-tool(s) -> ${newVersion}`);
750
+ }
751
+ }
752
+
320
753
  // 2. Sync SKILL.md
321
754
  if (syncSkillVersion(repoPath, newVersion)) {
322
755
  console.log(` ✓ SKILL.md -> ${newVersion}`);
@@ -326,6 +759,12 @@ export async function release({ repoPath, level, notes, dryRun, noPublish }) {
326
759
  updateChangelog(repoPath, newVersion, notes);
327
760
  console.log(` ✓ CHANGELOG.md updated`);
328
761
 
762
+ // 3.5. Move RELEASE-NOTES-v*.md to _trash/
763
+ const trashed = trashReleaseNotes(repoPath);
764
+ if (trashed > 0) {
765
+ console.log(` ✓ Moved ${trashed} RELEASE-NOTES file(s) to _trash/`);
766
+ }
767
+
329
768
  // 4. Git commit + tag
330
769
  gitCommitAndTag(repoPath, newVersion, notes);
331
770
  console.log(` ✓ Committed and tagged v${newVersion}`);
@@ -338,41 +777,205 @@ export async function release({ repoPath, level, notes, dryRun, noPublish }) {
338
777
  console.log(` ! Push failed (maybe branch protection). Push manually.`);
339
778
  }
340
779
 
780
+ // Distribution results collector (#104)
781
+ const distResults = [];
782
+
341
783
  if (!noPublish) {
342
784
  // 6. npm publish
343
785
  try {
344
786
  publishNpm(repoPath);
787
+ const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
788
+ distResults.push({ target: 'npm', status: 'ok', detail: `${pkg.name}@${newVersion}` });
345
789
  console.log(` ✓ Published to npm`);
346
790
  } catch (e) {
791
+ distResults.push({ target: 'npm', status: 'failed', detail: e.message });
347
792
  console.log(` ✗ npm publish failed: ${e.message}`);
348
793
  }
349
794
 
350
795
  // 7. GitHub Packages
351
796
  try {
352
797
  publishGitHubPackages(repoPath);
798
+ distResults.push({ target: 'GitHub Packages', status: 'ok', detail: `${newVersion}` });
353
799
  console.log(` ✓ Published to GitHub Packages`);
354
800
  } catch (e) {
801
+ distResults.push({ target: 'GitHub Packages', status: 'failed', detail: e.message });
355
802
  console.log(` ✗ GitHub Packages publish failed: ${e.message}`);
356
803
  }
357
804
 
358
805
  // 8. GitHub release
359
806
  try {
360
807
  createGitHubRelease(repoPath, newVersion, notes, currentVersion);
808
+ distResults.push({ target: 'GitHub', status: 'ok', detail: `v${newVersion}` });
361
809
  console.log(` ✓ GitHub release v${newVersion} created`);
362
810
  } catch (e) {
811
+ distResults.push({ target: 'GitHub', status: 'failed', detail: e.message });
363
812
  console.log(` ✗ GitHub release failed: ${e.message}`);
364
813
  }
365
814
 
366
- // 9. ClawHub skill publish
367
- const skillPath = join(repoPath, 'SKILL.md');
368
- if (existsSync(skillPath)) {
815
+ // 9. ClawHub skill publish (root + sub-tools)
816
+ const rootSkill = join(repoPath, 'SKILL.md');
817
+ const toolsDir = join(repoPath, 'tools');
818
+
819
+ // Publish root SKILL.md
820
+ if (existsSync(rootSkill)) {
369
821
  try {
370
822
  publishClawHub(repoPath, newVersion, notes);
371
- console.log(` ✓ Published to ClawHub`);
823
+ const slug = detectSkillSlug(repoPath);
824
+ distResults.push({ target: `ClawHub`, status: 'ok', detail: `${slug}@${newVersion}` });
825
+ console.log(` ✓ Published to ClawHub: ${slug}`);
372
826
  } catch (e) {
827
+ distResults.push({ target: 'ClawHub (root)', status: 'failed', detail: e.message });
373
828
  console.log(` ✗ ClawHub publish failed: ${e.message}`);
374
829
  }
375
830
  }
831
+
832
+ // Publish each sub-tool SKILL.md (#97)
833
+ if (existsSync(toolsDir)) {
834
+ for (const tool of readdirSync(toolsDir)) {
835
+ const toolPath = join(toolsDir, tool);
836
+ const toolSkill = join(toolPath, 'SKILL.md');
837
+ if (existsSync(toolSkill)) {
838
+ try {
839
+ publishClawHub(toolPath, newVersion, notes);
840
+ const slug = detectSkillSlug(toolPath);
841
+ distResults.push({ target: `ClawHub`, status: 'ok', detail: `${slug}@${newVersion}` });
842
+ console.log(` ✓ Published to ClawHub: ${slug}`);
843
+ } catch (e) {
844
+ const slug = detectSkillSlug(toolPath);
845
+ distResults.push({ target: `ClawHub (${slug})`, status: 'failed', detail: e.message });
846
+ console.log(` ✗ ClawHub publish failed for ${slug}: ${e.message}`);
847
+ }
848
+ }
849
+ }
850
+ }
851
+ }
852
+
853
+ // Distribution summary (#104)
854
+ if (distResults.length > 0) {
855
+ console.log('');
856
+ console.log(' Distribution:');
857
+ for (const r of distResults) {
858
+ const icon = r.status === 'ok' ? '✓' : '✗';
859
+ console.log(` ${icon} ${r.target}: ${r.detail}`);
860
+ }
861
+ const failed = distResults.filter(r => r.status !== 'ok');
862
+ if (failed.length > 0) {
863
+ console.log(`\n ! ${failed.length} of ${distResults.length} target(s) failed.`);
864
+ }
865
+ }
866
+
867
+ // 10. Post-merge branch cleanup: rename merged branches with --merged-YYYY-MM-DD
868
+ try {
869
+ const merged = execSync(
870
+ 'git branch --merged main', { cwd: repoPath, encoding: 'utf8' }
871
+ ).split('\n')
872
+ .map(b => b.trim())
873
+ .filter(b => b && b !== 'main' && b !== 'master' && !b.startsWith('*') && !b.includes('--merged-'));
874
+
875
+ if (merged.length > 0) {
876
+ console.log(` Scanning ${merged.length} merged branch(es) for rename...`);
877
+ for (const branch of merged) {
878
+ const current = execSync('git branch --show-current', { cwd: repoPath, encoding: 'utf8' }).trim();
879
+ if (branch === current) continue;
880
+
881
+ let mergeDate;
882
+ try {
883
+ const mergeBase = execSync(`git merge-base main ${branch}`, { cwd: repoPath, encoding: 'utf8' }).trim();
884
+ mergeDate = execSync(
885
+ `git log main --format="%ai" --ancestry-path ${mergeBase}..main`,
886
+ { cwd: repoPath, encoding: 'utf8' }
887
+ ).trim().split('\n').pop().split(' ')[0];
888
+ } catch {}
889
+ if (!mergeDate) {
890
+ try {
891
+ mergeDate = execSync(`git log ${branch} -1 --format="%ai"`, { cwd: repoPath, encoding: 'utf8' }).trim().split(' ')[0];
892
+ } catch {}
893
+ }
894
+ if (!mergeDate) continue;
895
+
896
+ const newName = `${branch}--merged-${mergeDate}`;
897
+ try {
898
+ execSync(`git branch -m "${branch}" "${newName}"`, { cwd: repoPath, stdio: 'pipe' });
899
+ execSync(`git push origin "${newName}"`, { cwd: repoPath, stdio: 'pipe' });
900
+ execSync(`git push origin --delete "${branch}"`, { cwd: repoPath, stdio: 'pipe' });
901
+ console.log(` ✓ Renamed: ${branch} -> ${newName}`);
902
+ } catch (e) {
903
+ console.log(` ! Could not rename ${branch}: ${e.message}`);
904
+ }
905
+ }
906
+ }
907
+ } catch (e) {
908
+ // Non-fatal: branch cleanup is a convenience, not a blocker
909
+ console.log(` ! Branch cleanup skipped: ${e.message}`);
910
+ }
911
+
912
+ // 11. Prune old merged branches (keep last 3 per developer prefix)
913
+ try {
914
+ const KEEP_COUNT = 3;
915
+ const remoteBranches = execSync(
916
+ 'git branch -r', { cwd: repoPath, encoding: 'utf8' }
917
+ ).split('\n')
918
+ .map(b => b.trim())
919
+ .filter(b => b && !b.includes('HEAD') && b.includes('--merged-'))
920
+ .map(b => b.replace('origin/', ''));
921
+
922
+ if (remoteBranches.length > 0) {
923
+ // Group by developer prefix (everything before first /)
924
+ const byPrefix = {};
925
+ for (const branch of remoteBranches) {
926
+ const prefix = branch.split('/')[0];
927
+ if (!byPrefix[prefix]) byPrefix[prefix] = [];
928
+ byPrefix[prefix].push(branch);
929
+ }
930
+
931
+ let pruned = 0;
932
+ for (const [prefix, branches] of Object.entries(byPrefix)) {
933
+ // Sort by date descending (date is at the end: --merged-YYYY-MM-DD)
934
+ branches.sort((a, b) => {
935
+ const dateA = a.match(/--merged-(\d{4}-\d{2}-\d{2})/)?.[1] || '';
936
+ const dateB = b.match(/--merged-(\d{4}-\d{2}-\d{2})/)?.[1] || '';
937
+ return dateB.localeCompare(dateA);
938
+ });
939
+
940
+ for (let i = KEEP_COUNT; i < branches.length; i++) {
941
+ try {
942
+ execSync(`git push origin --delete "${branches[i]}"`, { cwd: repoPath, stdio: 'pipe' });
943
+ execSync(`git branch -d "${branches[i]}" 2>/dev/null || true`, { cwd: repoPath, stdio: 'pipe', shell: true });
944
+ pruned++;
945
+ } catch {}
946
+ }
947
+ }
948
+
949
+ if (pruned > 0) {
950
+ console.log(` ✓ Pruned ${pruned} old merged branch(es)`);
951
+ }
952
+ }
953
+
954
+ // Clean stale branches (merged into main but never renamed)
955
+ const current = execSync('git branch --show-current', { cwd: repoPath, encoding: 'utf8' }).trim();
956
+ const allRemote = execSync(
957
+ 'git branch -r', { cwd: repoPath, encoding: 'utf8' }
958
+ ).split('\n')
959
+ .map(b => b.trim())
960
+ .filter(b => b && !b.includes('HEAD') && !b.includes('origin/main') && !b.includes('--merged-'))
961
+ .map(b => b.replace('origin/', ''));
962
+
963
+ let staleCleaned = 0;
964
+ for (const branch of allRemote) {
965
+ if (branch === current) continue;
966
+ try {
967
+ execSync(`git merge-base --is-ancestor origin/${branch} origin/main`, { cwd: repoPath, stdio: 'pipe' });
968
+ // If we get here, branch is fully merged
969
+ execSync(`git push origin --delete "${branch}"`, { cwd: repoPath, stdio: 'pipe' });
970
+ execSync(`git branch -d "${branch}" 2>/dev/null || true`, { cwd: repoPath, stdio: 'pipe', shell: true });
971
+ staleCleaned++;
972
+ } catch {}
973
+ }
974
+ if (staleCleaned > 0) {
975
+ console.log(` ✓ Cleaned ${staleCleaned} stale branch(es)`);
976
+ }
977
+ } catch (e) {
978
+ console.log(` ! Branch prune skipped: ${e.message}`);
376
979
  }
377
980
 
378
981
  console.log('');
package/mcp-server.mjs ADDED
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ // wip-release/mcp-server.mjs
3
+ // MCP server exposing release pipeline as tools.
4
+ // Wraps core.mjs. Registered via .mcp.json.
5
+
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
+ import {
10
+ release, detectCurrentVersion, bumpSemver, buildReleaseNotes,
11
+ } from './core.mjs';
12
+
13
+ const server = new Server(
14
+ { name: 'wip-release', version: '1.3.0' },
15
+ { capabilities: { tools: {} } }
16
+ );
17
+
18
+ // ── Tool Definitions ──
19
+
20
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
21
+ tools: [
22
+ {
23
+ name: 'release',
24
+ description: 'Run the full release pipeline. Bumps version, updates changelog + SKILL.md, commits, tags, publishes to npm + GitHub. Must be run from repo root or provide repoPath.',
25
+ inputSchema: {
26
+ type: 'object',
27
+ properties: {
28
+ repoPath: { type: 'string', description: 'Absolute path to the repo. Defaults to cwd.' },
29
+ level: { type: 'string', enum: ['patch', 'minor', 'major'], description: 'Semver bump level' },
30
+ notes: { type: 'string', description: 'Changelog entry and release notes summary' },
31
+ dryRun: { type: 'boolean', description: 'Preview only, no changes', default: false },
32
+ noPublish: { type: 'boolean', description: 'Bump + tag only, skip npm/GitHub publish', default: false },
33
+ skipProductCheck: { type: 'boolean', description: 'Skip product doc freshness check', default: false },
34
+ },
35
+ required: ['level', 'notes'],
36
+ },
37
+ },
38
+ {
39
+ name: 'release_status',
40
+ description: 'Check current version and what the next version would be for a given bump level.',
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ repoPath: { type: 'string', description: 'Absolute path to the repo. Defaults to cwd.' },
45
+ level: { type: 'string', enum: ['patch', 'minor', 'major'], description: 'Semver bump level to preview' },
46
+ },
47
+ required: ['level'],
48
+ },
49
+ },
50
+ ],
51
+ }));
52
+
53
+ // ── Tool Handlers ──
54
+
55
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
56
+ const { name, arguments: args } = req.params;
57
+
58
+ if (name === 'release') {
59
+ try {
60
+ const result = await release({
61
+ repoPath: args.repoPath || process.cwd(),
62
+ level: args.level,
63
+ notes: args.notes,
64
+ dryRun: args.dryRun || false,
65
+ notesSource: 'flag', // MCP always passes notes directly
66
+ noPublish: args.noPublish || false,
67
+ skipProductCheck: args.skipProductCheck || false,
68
+ });
69
+ return {
70
+ content: [{
71
+ type: 'text',
72
+ text: `Release complete: ${result.currentVersion} -> ${result.newVersion}${result.dryRun ? ' (dry run)' : ''}`,
73
+ }],
74
+ };
75
+ } catch (err) {
76
+ return {
77
+ content: [{ type: 'text', text: `Release failed: ${err.message}` }],
78
+ isError: true,
79
+ };
80
+ }
81
+ }
82
+
83
+ if (name === 'release_status') {
84
+ try {
85
+ const repoPath = args.repoPath || process.cwd();
86
+ const current = detectCurrentVersion(repoPath);
87
+ const next = bumpSemver(current, args.level);
88
+ return {
89
+ content: [{
90
+ type: 'text',
91
+ text: `Current: ${current}\nNext (${args.level}): ${next}`,
92
+ }],
93
+ };
94
+ } catch (err) {
95
+ return {
96
+ content: [{ type: 'text', text: `Status check failed: ${err.message}` }],
97
+ isError: true,
98
+ };
99
+ }
100
+ }
101
+
102
+ return {
103
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
104
+ isError: true,
105
+ };
106
+ });
107
+
108
+ const transport = new StdioServerTransport();
109
+ await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-release",
3
- "version": "1.2.4",
3
+ "version": "1.9.7",
4
4
  "type": "module",
5
5
  "description": "One-command release pipeline. Bumps version, updates changelog + SKILL.md, publishes to npm + GitHub.",
6
6
  "main": "core.mjs",
@@ -12,7 +12,7 @@
12
12
  "./cli": "./cli.js"
13
13
  },
14
14
  "scripts": {
15
- "test": "node cli.mjs --help"
15
+ "test": "node cli.js --help"
16
16
  },
17
17
  "keywords": [
18
18
  "release",
@@ -29,5 +29,8 @@
29
29
  "type": "git",
30
30
  "url": "git+https://github.com/wipcomputer/wip-release.git"
31
31
  },
32
- "homepage": "https://github.com/wipcomputer/wip-release"
32
+ "homepage": "https://github.com/wipcomputer/wip-ai-devops-toolbox",
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.0.0"
35
+ }
33
36
  }