@wipcomputer/wip-release 1.9.40 → 1.9.42

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/cli.js CHANGED
@@ -21,6 +21,8 @@ const noPublish = args.includes('--no-publish');
21
21
  const skipProductCheck = args.includes('--skip-product-check');
22
22
  const skipStaleCheck = args.includes('--skip-stale-check');
23
23
  const skipWorktreeCheck = args.includes('--skip-worktree-check');
24
+ const skipTechDocsCheck = args.includes('--skip-tech-docs-check');
25
+ const skipCoverageCheck = args.includes('--skip-coverage-check');
24
26
  const notesFilePath = flag('notes-file');
25
27
  let notes = flag('notes');
26
28
  // Bug fix #121: use strict check, not truthiness. --notes="" is empty, not absent.
@@ -75,7 +77,8 @@ let notesSource = (notes !== null && notes !== undefined && notes !== '') ? 'fla
75
77
  const { readdirSync } = await import('node:fs');
76
78
  const devUpdatesDir = join(process.cwd(), 'ai', 'dev-updates');
77
79
  if (existsSync(devUpdatesDir)) {
78
- const today = new Date().toISOString().split('T')[0];
80
+ const d = new Date();
81
+ const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
79
82
  const todayFiles = readdirSync(devUpdatesDir)
80
83
  .filter(f => f.startsWith(today) && f.endsWith('.md'))
81
84
  .sort()
@@ -164,6 +167,8 @@ release({
164
167
  skipProductCheck,
165
168
  skipStaleCheck,
166
169
  skipWorktreeCheck,
170
+ skipTechDocsCheck,
171
+ skipCoverageCheck,
167
172
  }).catch(err => {
168
173
  console.error(` ✗ ${err.message}`);
169
174
  process.exit(1);
package/core.mjs CHANGED
@@ -89,7 +89,8 @@ export function syncSkillVersion(repoPath, newVersion) {
89
89
  */
90
90
  export function updateChangelog(repoPath, newVersion, notes) {
91
91
  const changelogPath = join(repoPath, 'CHANGELOG.md');
92
- const date = new Date().toISOString().split('T')[0];
92
+ const d = new Date();
93
+ const date = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
93
94
 
94
95
  // Bug fix #121: never silently default to "Release." when notes are empty.
95
96
  // If notes are empty at this point, warn loudly.
@@ -383,6 +384,194 @@ function checkProductDocs(repoPath) {
383
384
  return { missing, ok: missing.length === 0, skipped: false };
384
385
  }
385
386
 
387
+ /**
388
+ * Check that technical docs (SKILL.md, TECHNICAL.md) were updated
389
+ * when source code changed since last release tag.
390
+ * Returns { missing: string[], ok: boolean, skipped: boolean }.
391
+ */
392
+ function checkTechnicalDocs(repoPath) {
393
+ try {
394
+ let lastTag;
395
+ try {
396
+ lastTag = execFileSync('git', ['describe', '--tags', '--abbrev=0'],
397
+ { cwd: repoPath, encoding: 'utf8' }).trim();
398
+ } catch {
399
+ return { missing: [], ok: true, skipped: true }; // No tags yet
400
+ }
401
+
402
+ const diff = execFileSync('git', ['diff', '--name-only', lastTag, 'HEAD'],
403
+ { cwd: repoPath, encoding: 'utf8' });
404
+ const changedFiles = diff.split('\n').map(f => f.trim()).filter(Boolean);
405
+
406
+ // Find source code changes (*.mjs, *.js, *.ts) excluding non-source dirs
407
+ const excludePattern = /\/(node_modules|dist|_trash|examples)\//;
408
+ const sourcePattern = /\.(mjs|js|ts)$/;
409
+ const sourceChanges = changedFiles.filter(f =>
410
+ sourcePattern.test(f) && !excludePattern.test(f) && !f.startsWith('ai/')
411
+ );
412
+
413
+ if (sourceChanges.length === 0) {
414
+ return { missing: [], ok: true, skipped: false }; // No source changes
415
+ }
416
+
417
+ // Check if any doc files were also modified
418
+ const docChanges = changedFiles.filter(f =>
419
+ f === 'SKILL.md' || f === 'TECHNICAL.md' ||
420
+ /^tools\/[^/]+\/SKILL\.md$/.test(f) ||
421
+ /^tools\/[^/]+\/TECHNICAL\.md$/.test(f)
422
+ );
423
+
424
+ if (docChanges.length > 0) {
425
+ return { missing: [], ok: true, skipped: false }; // Docs updated
426
+ }
427
+
428
+ // Source changed but no doc updates
429
+ const missing = [];
430
+ const preview = sourceChanges.slice(0, 5).join(', ');
431
+ const more = sourceChanges.length > 5 ? ` (and ${sourceChanges.length - 5} more)` : '';
432
+ missing.push('Source files changed since last tag but no SKILL.md or TECHNICAL.md was updated');
433
+ missing.push(`Changed: ${preview}${more}`);
434
+ missing.push('Update SKILL.md or TECHNICAL.md to document these changes');
435
+
436
+ return { missing, ok: false, skipped: false };
437
+ } catch {
438
+ return { missing: [], ok: true, skipped: true }; // Graceful fallback
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Parse the interface coverage table from a markdown file.
444
+ * Returns array of { name, cli, module, mcp, openclaw, skill, ccHook } or null.
445
+ */
446
+ function parseInterfaceCoverageTable(filePath) {
447
+ if (!existsSync(filePath)) return null;
448
+ const content = readFileSync(filePath, 'utf8');
449
+ const lines = content.split('\n');
450
+
451
+ const headerIdx = lines.findIndex(l => /^\|\s*#\s*\|\s*Tool\s*\|/i.test(l));
452
+ if (headerIdx === -1) return null;
453
+
454
+ const rows = [];
455
+ for (let i = headerIdx + 2; i < lines.length; i++) {
456
+ const line = lines[i].trim();
457
+ if (!line.startsWith('|')) break;
458
+ const cells = line.split('|').map(c => c.trim()).filter(c => c !== '');
459
+ if (cells.length < 8) continue;
460
+ // Skip category header rows (# cell is empty, non-numeric, or bold)
461
+ const num = cells[0].trim();
462
+ if (!num || /^\*\*/.test(num) || isNaN(parseInt(num))) continue;
463
+ rows.push({
464
+ name: cells[1].trim(),
465
+ cli: /^Y$/i.test(cells[2]),
466
+ module: /^Y$/i.test(cells[3]),
467
+ mcp: /^Y$/i.test(cells[4]),
468
+ openclaw: /^Y$/i.test(cells[5]),
469
+ skill: /^Y$/i.test(cells[6]),
470
+ ccHook: /^Y$/i.test(cells[7]),
471
+ });
472
+ }
473
+ return rows.length > 0 ? rows : null;
474
+ }
475
+
476
+ /**
477
+ * Read display name from a tool's SKILL.md frontmatter.
478
+ * Tries display-name, then name field. Falls back to null.
479
+ */
480
+ function getToolDisplayName(toolPath) {
481
+ const skillPath = join(toolPath, 'SKILL.md');
482
+ if (!existsSync(skillPath)) return null;
483
+ try {
484
+ const content = readFileSync(skillPath, 'utf8');
485
+ const displayMatch = content.match(/^\s*display-name:\s*"?([^"\n]+)"?/m);
486
+ if (displayMatch) return displayMatch[1].trim();
487
+ const nameMatch = content.match(/^name:\s*"?([^"\n]+)"?/m);
488
+ if (nameMatch) return nameMatch[1].trim();
489
+ } catch {}
490
+ return null;
491
+ }
492
+
493
+ /**
494
+ * Check that the interface coverage table in README.md and SKILL.md
495
+ * matches the actual interfaces detected in tools/*/.
496
+ * Returns { missing: string[], ok: boolean, skipped: boolean }.
497
+ */
498
+ function checkInterfaceCoverage(repoPath) {
499
+ try {
500
+ // Only applies to toolbox repos
501
+ const toolsDir = join(repoPath, 'tools');
502
+ if (!existsSync(toolsDir)) return { missing: [], ok: true, skipped: true };
503
+
504
+ const entries = readdirSync(toolsDir, { withFileTypes: true });
505
+ const tools = entries
506
+ .filter(e => e.isDirectory() && existsSync(join(toolsDir, e.name, 'package.json')))
507
+ .map(e => ({ name: e.name, path: join(toolsDir, e.name) }));
508
+
509
+ if (tools.length === 0) return { missing: [], ok: true, skipped: true };
510
+
511
+ // Detect actual interfaces for each tool
512
+ const actualMap = {};
513
+ for (const tool of tools) {
514
+ const pkg = JSON.parse(readFileSync(join(tool.path, 'package.json'), 'utf8'));
515
+ actualMap[tool.name] = {
516
+ displayName: getToolDisplayName(tool.path) || tool.name,
517
+ cli: !!(pkg.bin),
518
+ module: !!(pkg.main || pkg.exports),
519
+ mcp: ['mcp-server.mjs', 'mcp-server.js', 'dist/mcp-server.js'].some(f => existsSync(join(tool.path, f))),
520
+ openclaw: existsSync(join(tool.path, 'openclaw.plugin.json')),
521
+ skill: existsSync(join(tool.path, 'SKILL.md')),
522
+ ccHook: !!(pkg.claudeCode?.hook) || existsSync(join(tool.path, 'guard.mjs')),
523
+ };
524
+ }
525
+
526
+ const missing = [];
527
+
528
+ // Check both README.md and SKILL.md tables
529
+ for (const [label, filePath] of [['README.md', join(repoPath, 'README.md')], ['SKILL.md', join(repoPath, 'SKILL.md')]]) {
530
+ const tableRows = parseInterfaceCoverageTable(filePath);
531
+ if (!tableRows) continue;
532
+
533
+ // Tool count
534
+ if (tools.length !== tableRows.length) {
535
+ missing.push(`${label}: tool count mismatch (${tools.length} in tools/, ${tableRows.length} in table)`);
536
+ }
537
+
538
+ // Check each actual tool against the table
539
+ for (const tool of tools) {
540
+ const actual = actualMap[tool.name];
541
+ const displayName = actual.displayName;
542
+ const tableRow = tableRows.find(r =>
543
+ r.name === displayName ||
544
+ r.name.toLowerCase() === displayName.toLowerCase() ||
545
+ r.name.toLowerCase().includes(tool.name.replace(/^wip-/, '').replace(/-/g, ' '))
546
+ );
547
+
548
+ if (!tableRow) {
549
+ missing.push(`${label}: ${tool.name} (${displayName}) missing from coverage table`);
550
+ continue;
551
+ }
552
+
553
+ const ifaceMap = [
554
+ ['cli', 'CLI'], ['module', 'Module'], ['mcp', 'MCP'],
555
+ ['openclaw', 'OC Plugin'], ['skill', 'Skill'], ['ccHook', 'CC Hook']
556
+ ];
557
+
558
+ for (const [key, name] of ifaceMap) {
559
+ if (actual[key] && !tableRow[key]) {
560
+ missing.push(`${label}: ${displayName} has ${name} but table says no`);
561
+ }
562
+ if (tableRow[key] && !actual[key]) {
563
+ missing.push(`${label}: ${displayName} marked ${name} in table but not detected`);
564
+ }
565
+ }
566
+ }
567
+ }
568
+
569
+ return { missing, ok: missing.length === 0, skipped: false };
570
+ } catch {
571
+ return { missing: [], ok: true, skipped: true }; // Graceful fallback
572
+ }
573
+ }
574
+
386
575
  /**
387
576
  * Auto-update version/date lines in product docs before the release commit.
388
577
  * Updates roadmap.md "Current version" and "Last updated",
@@ -391,7 +580,8 @@ function checkProductDocs(repoPath) {
391
580
  */
392
581
  function syncProductDocs(repoPath, newVersion) {
393
582
  let updated = 0;
394
- const today = new Date().toISOString().split('T')[0];
583
+ const td = new Date();
584
+ const today = `${td.getFullYear()}-${String(td.getMonth()+1).padStart(2,'0')}-${String(td.getDate()).padStart(2,'0')}`;
395
585
 
396
586
  // 1. roadmap.md
397
587
  const roadmapPath = join(repoPath, 'ai', 'product', 'plans-prds', 'roadmap.md');
@@ -818,7 +1008,7 @@ export function checkStaleBranches(repoPath, level) {
818
1008
  /**
819
1009
  * Run the full release pipeline.
820
1010
  */
821
- export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck }) {
1011
+ export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck, skipTechDocsCheck, skipCoverageCheck }) {
822
1012
  repoPath = repoPath || process.cwd();
823
1013
  const currentVersion = detectCurrentVersion(repoPath);
824
1014
  const newVersion = bumpSemver(currentVersion, level);
@@ -962,6 +1152,50 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
962
1152
  }
963
1153
  }
964
1154
 
1155
+ // 0.85. Technical docs check
1156
+ if (!skipTechDocsCheck) {
1157
+ const techDocsCheck = checkTechnicalDocs(repoPath);
1158
+ if (!techDocsCheck.skipped) {
1159
+ if (techDocsCheck.ok) {
1160
+ console.log(' ✓ Technical docs up to date');
1161
+ } else {
1162
+ const isMinorOrMajor = level === 'minor' || level === 'major';
1163
+ const prefix = isMinorOrMajor ? '✗' : '!';
1164
+ console.log(` ${prefix} Technical docs need attention:`);
1165
+ for (const m of techDocsCheck.missing) console.log(` - ${m}`);
1166
+ if (isMinorOrMajor) {
1167
+ console.log('');
1168
+ console.log(' Update SKILL.md or TECHNICAL.md before a minor/major release.');
1169
+ console.log(' Use --skip-tech-docs-check to override.');
1170
+ console.log('');
1171
+ return { currentVersion, newVersion, dryRun: false, failed: true };
1172
+ }
1173
+ }
1174
+ }
1175
+ }
1176
+
1177
+ // 0.9. Interface coverage check
1178
+ if (!skipCoverageCheck) {
1179
+ const coverageCheck = checkInterfaceCoverage(repoPath);
1180
+ if (!coverageCheck.skipped) {
1181
+ if (coverageCheck.ok) {
1182
+ console.log(' ✓ Interface coverage table matches');
1183
+ } else {
1184
+ const isMinorOrMajor = level === 'minor' || level === 'major';
1185
+ const prefix = isMinorOrMajor ? '✗' : '!';
1186
+ console.log(` ${prefix} Interface coverage table has mismatches:`);
1187
+ for (const m of coverageCheck.missing) console.log(` - ${m}`);
1188
+ if (isMinorOrMajor) {
1189
+ console.log('');
1190
+ console.log(' Update the coverage table in README.md and SKILL.md.');
1191
+ console.log(' Use --skip-coverage-check to override.');
1192
+ console.log('');
1193
+ return { currentVersion, newVersion, dryRun: false, failed: true };
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1198
+
965
1199
  if (dryRun) {
966
1200
  // Product docs check (dry-run)
967
1201
  if (!skipProductCheck) {
@@ -999,6 +1233,32 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
999
1233
  console.log(' [dry run] ✓ No stale remote branches');
1000
1234
  }
1001
1235
  }
1236
+ // Technical docs check (dry-run)
1237
+ if (!skipTechDocsCheck) {
1238
+ const techDocsCheck = checkTechnicalDocs(repoPath);
1239
+ if (!techDocsCheck.skipped) {
1240
+ if (techDocsCheck.ok) {
1241
+ console.log(' [dry run] ✓ Technical docs up to date');
1242
+ } else {
1243
+ const isMinorOrMajor = level === 'minor' || level === 'major';
1244
+ console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: technical docs need updates`);
1245
+ for (const m of techDocsCheck.missing) console.log(` - ${m}`);
1246
+ }
1247
+ }
1248
+ }
1249
+ // Interface coverage check (dry-run)
1250
+ if (!skipCoverageCheck) {
1251
+ const coverageCheck = checkInterfaceCoverage(repoPath);
1252
+ if (!coverageCheck.skipped) {
1253
+ if (coverageCheck.ok) {
1254
+ console.log(' [dry run] ✓ Interface coverage table matches');
1255
+ } else {
1256
+ const isMinorOrMajor = level === 'minor' || level === 'major';
1257
+ console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: interface coverage mismatches`);
1258
+ for (const m of coverageCheck.missing) console.log(` - ${m}`);
1259
+ }
1260
+ }
1261
+ }
1002
1262
  const hasSkill = existsSync(join(repoPath, 'SKILL.md'));
1003
1263
  console.log(` [dry run] Would bump package.json to ${newVersion}`);
1004
1264
  if (hasSkill) console.log(` [dry run] Would update SKILL.md version`);
@@ -1312,6 +1572,18 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1312
1572
  console.log(` ! Branch prune skipped: ${e.message}`);
1313
1573
  }
1314
1574
 
1575
+ // Write release marker so branch guard blocks immediate install (#73)
1576
+ try {
1577
+ const markerDir = join(process.env.HOME || '', '.ldm', 'state');
1578
+ const { mkdirSync, writeFileSync } = await import('node:fs');
1579
+ mkdirSync(markerDir, { recursive: true });
1580
+ writeFileSync(join(markerDir, '.last-release'), JSON.stringify({
1581
+ repo: repoName,
1582
+ version: newVersion,
1583
+ timestamp: new Date().toISOString(),
1584
+ }) + '\n');
1585
+ } catch {}
1586
+
1315
1587
  console.log('');
1316
1588
  console.log(` Done. ${repoName} v${newVersion} released.`);
1317
1589
  console.log('');
package/mcp-server.mjs CHANGED
@@ -31,6 +31,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
31
31
  dryRun: { type: 'boolean', description: 'Preview only, no changes', default: false },
32
32
  noPublish: { type: 'boolean', description: 'Bump + tag only, skip npm/GitHub publish', default: false },
33
33
  skipProductCheck: { type: 'boolean', description: 'Skip product doc freshness check', default: false },
34
+ skipTechDocsCheck: { type: 'boolean', description: 'Skip technical docs freshness check', default: false },
35
+ skipCoverageCheck: { type: 'boolean', description: 'Skip interface coverage table check', default: false },
34
36
  },
35
37
  required: ['level', 'notes'],
36
38
  },
@@ -65,6 +67,8 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
65
67
  notesSource: 'flag', // MCP always passes notes directly
66
68
  noPublish: args.noPublish || false,
67
69
  skipProductCheck: args.skipProductCheck || false,
70
+ skipTechDocsCheck: args.skipTechDocsCheck || false,
71
+ skipCoverageCheck: args.skipCoverageCheck || false,
68
72
  });
69
73
  return {
70
74
  content: [{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-release",
3
- "version": "1.9.40",
3
+ "version": "1.9.42",
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",