@link-assistant/hive-mind 1.46.1 → 1.46.3

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
@@ -1,5 +1,28 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.46.3
4
+
5
+ ### Patch Changes
6
+
7
+ - c425744: Standardize /version output — strip OS/arch, normalize dates, enhance platform detection (Issue #1524)
8
+ - Strip OS/architecture info (e.g. x86_64-unknown-linux-gnu, linux/amd64) from version strings for cleaner output
9
+ - Normalize date formats to ISO (YYYY-MM-DD) across all version components
10
+ - Enhance platform detection for consistent environment reporting
11
+
12
+ ## 1.46.2
13
+
14
+ ### Patch Changes
15
+
16
+ - 37daeb7: Auto-recover from non-fork repositories during fork validation (Issue #1518)
17
+ - When a repository exists but is NOT a proper GitHub fork (or has wrong parent), safely auto-recover by comparing commits against upstream first — only delete and re-fork if no additional commits would be lost
18
+ - Add verbose logging of fork commands for debugging non-fork creation scenarios
19
+ - Add post-creation fork validation to detect non-fork repos immediately after `gh repo fork`
20
+ - Report non-fork creation to Sentry for monitoring
21
+ - Add `--allow-force-non-fork-repository-deletion` flag to force deletion even when additional commits would be lost
22
+ - Add case study documenting the root cause analysis of konard/MixaByk1996-elements-app
23
+ - Document all previously undocumented solve options in CONFIGURATION.md (12 options including --allow-force-non-fork-repository-deletion)
24
+ - Add CI/CD test to verify documentation stays in sync with code options (prevents drift)
25
+
3
26
  ## 1.46.1
4
27
 
5
28
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.46.1",
3
+ "version": "1.46.3",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -199,6 +199,7 @@ const KNOWN_OPTION_NAMES = [
199
199
  'only-prepare-command',
200
200
  'auto-merge-default-branch-to-pull-request-branch',
201
201
  'allow-fork-divergence-resolution-using-force-push-with-lease',
202
+ 'allow-force-non-fork-repository-deletion',
202
203
  'allow-to-push-to-contributors-pull-requests-as-maintainer',
203
204
  'prefix-fork-name-with-owner-name',
204
205
  'auto-restart-max-iterations',
@@ -273,6 +273,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
273
273
  description: 'Allow automatic force-push (--force-with-lease) when fork diverges from upstream (DANGEROUS: can overwrite fork history)',
274
274
  default: false,
275
275
  },
276
+ 'allow-force-non-fork-repository-deletion': {
277
+ type: 'boolean',
278
+ description: 'Allow deletion of non-fork repositories even when they contain additional commits that would be lost (DANGEROUS: data loss possible)',
279
+ default: false,
280
+ },
276
281
  'allow-to-push-to-contributors-pull-requests-as-maintainer': {
277
282
  type: 'boolean',
278
283
  description: 'When continuing a fork PR as a maintainer, attempt to push directly to the contributor\'s fork if "Allow edits by maintainers" is enabled. Requires --auto-fork to be enabled.',
@@ -490,78 +490,77 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
490
490
 
491
491
  const forkValidation = await validateForkParent(existingForkName, `${owner}/${repo}`);
492
492
 
493
- if (!forkValidation.isValid) {
493
+ if (forkValidation.isValid) {
494
+ // Fork is valid — use it
495
+ await log(`${formatAligned('✅', 'Fork parent validated:', `${forkValidation.parent}`)}`);
496
+ repoToClone = existingForkName;
497
+ forkedRepo = existingForkName;
498
+ upstreamRemote = `${owner}/${repo}`;
499
+ } else if (forkValidation.isNetworkError) {
494
500
  // Issue #1311: Handle network errors separately from fork mismatch errors
495
- if (forkValidation.isNetworkError) {
496
- await log('');
497
- await log(`${formatAligned('❌', 'NETWORK ERROR DURING FORK VALIDATION', '')}`, { level: 'error' });
498
- await log('');
499
- await log(' 🔍 What happened:');
500
- await log(` Failed to connect to GitHub API while validating fork.`);
501
- await log(` Error: ${forkValidation.error}`);
502
- await log('');
503
- await log(' 💡 This is likely a temporary network issue. You can:');
504
- await log(' 1. Wait a moment and try again');
505
- await log(' 2. Check your internet connection');
506
- await log(' 3. Check GitHub status: https://www.githubstatus.com/');
507
- await log('');
508
- await log(' Or use --no-fork to skip fork validation if you have write access.');
509
- await log('');
510
- await safeExit(1, 'Network error during fork validation - please retry');
511
- }
512
-
513
- // Fork parent mismatch detected - this prevents issue #967
514
501
  await log('');
515
- await log(`${formatAligned('❌', 'FORK PARENT MISMATCH DETECTED', '')}`, { level: 'error' });
502
+ await log(`${formatAligned('❌', 'NETWORK ERROR DURING FORK VALIDATION', '')}`, { level: 'error' });
516
503
  await log('');
517
504
  await log(' 🔍 What happened:');
518
- if (!forkValidation.isFork) {
519
- await log(` The repository ${existingForkName} is NOT a GitHub fork.`);
520
- await log(' It may have been created by cloning and pushing instead of forking.');
521
- } else {
522
- await log(` Your fork ${existingForkName} was created from an intermediate fork,`);
523
- await log(` not directly from the target repository ${owner}/${repo}.`);
524
- }
525
- await log('');
526
- await log(' 📦 Fork relationship:');
527
- await log(` • Your fork: ${existingForkName}`);
528
- await log(` • Fork parent: ${forkValidation.parent || 'N/A (not a fork)'}`);
529
- await log(` • Fork source (root): ${forkValidation.source || 'N/A'}`);
530
- await log(` • Expected parent: ${owner}/${repo}`);
531
- await log('');
532
- await log(' ⚠️ Why this is a problem:');
533
- await log(' When a fork is created from an intermediate fork (a "fork of a fork"),');
534
- await log(' any commits that exist in the intermediate fork but not in the target');
535
- await log(' repository will be included in your pull requests. This can result in');
536
- await log(' pull requests with hundreds or thousands of unexpected commits.');
505
+ await log(` Failed to connect to GitHub API while validating fork.`);
506
+ await log(` Error: ${forkValidation.error}`);
537
507
  await log('');
538
- await log(' 📖 Case study: See issue #967');
539
- await log(' A fork created from veb86/zcadvelecAI (which had 1,678 extra commits)');
540
- await log(' instead of zamtmn/zcad resulted in a PR with 1,681 commits');
541
- await log(' instead of the expected 3 commits.');
508
+ await log(' 💡 This is likely a temporary network issue. You can:');
509
+ await log(' 1. Wait a moment and try again');
510
+ await log(' 2. Check your internet connection');
511
+ await log(' 3. Check GitHub status: https://www.githubstatus.com/');
542
512
  await log('');
543
- await log(' 💡 How to fix:');
513
+ await log(' Or use --no-fork to skip fork validation if you have write access.');
544
514
  await log('');
545
- await log(' Option 1: Delete the problematic fork and create a fresh one');
546
- await log(` gh repo delete ${existingForkName}`);
547
- await log(` Then run this command again to create a proper fork of ${owner}/${repo}`);
548
- await log('');
549
- await log(' Option 2: Use --prefix-fork-name-with-owner-name to create a new fork');
550
- await log(` This creates a fork named ${currentUser}/${owner}-${repo} instead`);
551
- await log(` ./solve.mjs "${issueUrl || `https://github.com/${owner}/${repo}/issues/<number>`}" --prefix-fork-name-with-owner-name --fork`);
552
- await log('');
553
- await log(' Option 3: Work directly on the repository (if you have write access)');
554
- await log(` ./solve.mjs "${issueUrl || `https://github.com/${owner}/${repo}/issues/<number>`}" --no-fork`);
515
+ await safeExit(1, 'Network error during fork validation - please retry');
516
+ } else {
517
+ // Issue #1518: Auto-recovery delete non-fork/mismatched repo and re-fork, but only if no commits would be lost
555
518
  await log('');
556
-
557
- await safeExit(1, 'Fork parent mismatch - fork was created from intermediate fork');
519
+ await log(`${formatAligned('⚠️', 'FORK PARENT MISMATCH DETECTED', '')}`, { level: 'warning' });
520
+ const detail = !forkValidation.isFork ? `Repository ${existingForkName} is NOT a GitHub fork (see issue #1518)` : `Fork ${existingForkName} was created from ${forkValidation.parent} instead of ${owner}/${repo} (see issue #967)`;
521
+ await log(`${formatAligned('', '', detail)}`);
522
+ await log(`${formatAligned('', '', `Fork parent: ${forkValidation.parent || 'N/A (not a fork)'}, source: ${forkValidation.source || 'N/A'}, expected: ${owner}/${repo}`)}`);
523
+ // Safety check: compare commits before deleting to avoid data loss
524
+ await log(`${formatAligned('🔍', 'Safety check:', 'Comparing commits against upstream...')}`);
525
+ let safeToDelete = false;
526
+ try {
527
+ const cmp = await $`gh api repos/${owner}/${repo}/compare/${owner}:HEAD...${existingForkName.split('/')[0]}:HEAD --jq '.ahead_by' 2>&1`;
528
+ if (cmp.code === 0 && parseInt(cmp.stdout.toString().trim(), 10) === 0) {
529
+ await log(`${formatAligned('✅', 'Safe to delete:', 'No additional commits in non-fork repository')}`);
530
+ safeToDelete = true;
531
+ } else if (cmp.code === 0) {
532
+ await log(`${formatAligned('⚠️', 'UNSAFE:', `Repository has ${cmp.stdout.toString().trim()} commit(s) ahead of upstream that would be lost`)}`, { level: 'warning' });
533
+ } else {
534
+ await log(`${formatAligned('⚠️', 'Compare failed:', ((cmp.stderr?.toString() || '') + (cmp.stdout?.toString() || '')).split('\n')[0])}`, { level: 'warning' });
535
+ }
536
+ } catch (e) {
537
+ await log(`${formatAligned('⚠️', 'Compare error:', e.message)}`, { level: 'warning' });
538
+ }
539
+ if (!safeToDelete) {
540
+ if (argv.allowForceNonForkRepositoryDeletion) {
541
+ await log(`${formatAligned('⚠️', 'Force deletion ENABLED:', '--allow-force-non-fork-repository-deletion — proceeding despite potential data loss')}`, { level: 'warning' });
542
+ safeToDelete = true;
543
+ } else {
544
+ await log(` 💡 Manual fix required: back up work, then: gh repo delete ${existingForkName} --yes`);
545
+ await log(` Then run this command again to create a proper fork of ${owner}/${repo}`);
546
+ await log(` 🔧 Or force deletion (DANGEROUS): solve ${argv.url || argv['issue-url'] || argv._[0] || '<issue-url>'} --allow-force-non-fork-repository-deletion`);
547
+ await safeExit(1, 'Auto-recovery skipped - repository may contain commits that would be lost');
548
+ }
549
+ }
550
+ await log(`${formatAligned('🔄', 'Auto-recovery:', 'Deleting non-fork repository and creating fresh fork...')}`);
551
+ const deleteResult = await $`gh repo delete ${existingForkName} --yes 2>&1`;
552
+ if (deleteResult.code !== 0) {
553
+ const delOut = (deleteResult.stderr?.toString() || '') + (deleteResult.stdout?.toString() || '');
554
+ await log(`${formatAligned('❌', 'Delete failed:', delOut.split('\n')[0])}`, { level: 'error' });
555
+ await log(` 💡 Manual fix: gh repo delete ${existingForkName} --yes, then re-run`);
556
+ await safeExit(1, 'Auto-recovery failed - could not delete problematic repository');
557
+ }
558
+ await log(`${formatAligned('✅', 'Deleted:', existingForkName)}`);
559
+ existingForkName = null; // Fall through to fork creation below
558
560
  }
561
+ }
559
562
 
560
- await log(`${formatAligned('✅', 'Fork parent validated:', `${forkValidation.parent}`)}`);
561
- repoToClone = existingForkName;
562
- forkedRepo = existingForkName;
563
- upstreamRemote = `${owner}/${repo}`;
564
- } else {
563
+ if (!existingForkName) {
565
564
  // Need to create fork with retry logic for concurrent scenarios
566
565
  await log(`${formatAligned('🔄', 'Creating fork...', '')}`);
567
566
 
@@ -575,19 +574,18 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
575
574
  let actualForkName = `${currentUser}/${defaultForkName}`;
576
575
 
577
576
  for (let attempt = 1; attempt <= maxForkRetries; attempt++) {
578
- // Try to create fork with optional custom name
579
577
  let forkResult;
578
+ // Issue #1518: Log the exact fork command for debugging non-fork creation scenarios
579
+ if (argv.verbose) await log(`${formatAligned('🔧', 'Fork command:', argv.prefixForkNameWithOwnerName ? `gh repo fork ${owner}/${repo} --fork-name ${owner}-${repo} --clone=false` : `gh repo fork ${owner}/${repo} --clone=false`)}`);
580
580
  if (argv.prefixForkNameWithOwnerName) {
581
- // Use --fork-name flag to create fork with owner prefix
582
581
  forkResult = await $`gh repo fork ${owner}/${repo} --fork-name ${owner}-${repo} --clone=false 2>&1`;
583
582
  } else {
584
- // Standard fork creation (no custom name)
585
583
  forkResult = await $`gh repo fork ${owner}/${repo} --clone=false 2>&1`;
586
584
  }
587
585
 
588
586
  // Always capture output to parse actual fork name
589
587
  const forkOutput = (forkResult.stderr ? forkResult.stderr.toString() : '') + (forkResult.stdout ? forkResult.stdout.toString() : '');
590
-
588
+ if (argv.verbose) await log(`${formatAligned('🔧', 'Fork output:', forkOutput.split('\n')[0] || '(empty)')}`); // Issue #1518
591
589
  // Parse actual fork name from output (e.g., "konard/netkeep80-jsonRVM already exists")
592
590
  // GitHub may create forks with modified names to avoid conflicts
593
591
  // Use regex that won't match domain names like "github.com/user" -> "com/user"
@@ -792,11 +790,20 @@ Thank you!`;
792
790
  await safeExit(1, 'Repository setup failed');
793
791
  }
794
792
 
795
- // Wait a moment for fork to be fully ready
796
793
  if (forkCreated) {
794
+ // Wait a moment for fork to be fully ready
797
795
  await log(`${formatAligned('⏳', 'Waiting:', 'For fork to be fully ready...')}`);
798
796
  await new Promise(resolve => setTimeout(resolve, 3000));
799
797
  }
798
+ // Issue #1518: Validate fork parent after creation/discovery to detect non-fork repos early (covers concurrent worker scenarios too)
799
+ await log(`${formatAligned('🔍', 'Validating fork parent...', '')}`);
800
+ const pcv = await validateForkParent(actualForkName, `${owner}/${repo}`);
801
+ if (pcv.isValid) {
802
+ await log(`${formatAligned('✅', 'Fork parent validated:', `${pcv.parent}`)}`);
803
+ } else if (!pcv.isNetworkError) {
804
+ await log(`${formatAligned('⚠️', 'WARNING:', `Fork failed validation (possible gh CLI bug, see issue #1518): ${pcv.error}`)}`, { level: 'warning' });
805
+ reportError(new Error(`Fork created as non-fork: ${pcv.error}`), { context: 'fork_creation_validation', forkRepo: actualForkName, expectedUpstream: `${owner}/${repo}`, isFork: pcv.isFork, parent: pcv.parent, source: pcv.source });
806
+ }
800
807
  }
801
808
 
802
809
  repoToClone = actualForkName;
@@ -13,6 +13,89 @@ import { promisify } from 'util';
13
13
 
14
14
  const execAsync = promisify(exec);
15
15
 
16
+ /**
17
+ * Month name/abbreviation to zero-padded number mapping
18
+ */
19
+ const MONTH_MAP = {
20
+ jan: '01',
21
+ january: '01',
22
+ feb: '02',
23
+ february: '02',
24
+ mar: '03',
25
+ march: '03',
26
+ apr: '04',
27
+ april: '04',
28
+ may: '05',
29
+ jun: '06',
30
+ june: '06',
31
+ jul: '07',
32
+ july: '07',
33
+ aug: '08',
34
+ august: '08',
35
+ sep: '09',
36
+ september: '09',
37
+ oct: '10',
38
+ october: '10',
39
+ nov: '11',
40
+ november: '11',
41
+ dec: '12',
42
+ december: '12',
43
+ };
44
+
45
+ /**
46
+ * Normalize a date string to ISO format (YYYY-MM-DD).
47
+ * Handles formats like:
48
+ * "20-Aug-23" → "2023-08-20"
49
+ * "20 April 2009" → "2009-04-20"
50
+ * "July 5th 2008" → "2008-07-05"
51
+ * "Jan 13 2026" → "2026-01-13"
52
+ * "2024-02-29" → "2024-02-29" (passthrough)
53
+ * Returns the original string if parsing fails.
54
+ * @param {string} dateStr - Date string to normalize
55
+ * @returns {string} ISO date string or original
56
+ */
57
+ export function normalizeDate(dateStr) {
58
+ if (!dateStr) return dateStr;
59
+ const s = dateStr.trim();
60
+
61
+ // Already ISO: YYYY-MM-DD
62
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
63
+
64
+ // "DD-Mon-YY" (e.g. "20-Aug-23")
65
+ const dmy = s.match(/^(\d{1,2})-([A-Za-z]{3})-(\d{2})$/);
66
+ if (dmy) {
67
+ const month = MONTH_MAP[dmy[2].toLowerCase()];
68
+ if (month) {
69
+ const year = parseInt(dmy[3], 10);
70
+ const fullYear = year >= 70 ? `19${dmy[3]}` : `20${dmy[3]}`;
71
+ return `${fullYear}-${month}-${dmy[1].padStart(2, '0')}`;
72
+ }
73
+ }
74
+
75
+ // "DD Month YYYY" (e.g. "20 April 2009")
76
+ const dmY = s.match(/^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/);
77
+ if (dmY) {
78
+ const month = MONTH_MAP[dmY[2].toLowerCase()];
79
+ if (month) return `${dmY[3]}-${month}-${dmY[1].padStart(2, '0')}`;
80
+ }
81
+
82
+ // "Month DDth YYYY" or "Month DD YYYY" (e.g. "July 5th 2008", "Jan 13 2026")
83
+ const mdY = s.match(/^([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\s+(\d{4})$/);
84
+ if (mdY) {
85
+ const month = MONTH_MAP[mdY[1].toLowerCase()];
86
+ if (month) return `${mdY[3]}-${month}-${mdY[2].padStart(2, '0')}`;
87
+ }
88
+
89
+ // "Month DD YYYY HH:MM:SS" (e.g. "Jan 13 2026 22:36:55")
90
+ const mdYt = s.match(/^([A-Za-z]+)\s+(\d{1,2})\s+(\d{4})\s+(\d{2}:\d{2}:\d{2})$/);
91
+ if (mdYt) {
92
+ const month = MONTH_MAP[mdYt[1].toLowerCase()];
93
+ if (month) return `${mdYt[3]}-${month}-${mdYt[2].padStart(2, '0')} ${mdYt[4]}`;
94
+ }
95
+
96
+ return s;
97
+ }
98
+
16
99
  /**
17
100
  * Execute a command asynchronously and return its output, or null if it fails
18
101
  * @param {string} command - Command to execute
@@ -58,19 +141,30 @@ const VERSION_PARSERS = {
58
141
  const extra = m[2] ? m[2].trim().split(/\s+/) : [];
59
142
  return { version: m[1], extra };
60
143
  },
61
- // go version go1.26.1 linux/amd64
144
+ // go version go1.26.1 linux/amd64 → strip platform/arch
62
145
  go: raw => {
63
- const m = raw.match(/go([\d.]+(?:\S*)?)\s+(.*)/);
146
+ const m = raw.match(/go([\d.]+(?:\S*)?)/);
64
147
  if (!m) return null;
65
- return { version: m[1], extra: [m[2].trim()] };
148
+ return { version: m[1], extra: [] };
66
149
  },
67
- // PHP 8.3.30 (cli) (built: Jan 13 2026 22:36:55) (NTS)
150
+ // PHP 8.3.30 (cli) (built: Jan 13 2026 22:36:55) (NTS) → strip cli, normalize date
68
151
  php: raw => {
69
152
  const m = raw.match(/^PHP\s+([\d.]+(?:-\S+)?)\s*(.*)/);
70
153
  if (!m) return null;
71
154
  const tags = [];
72
155
  const parts = m[2].matchAll(/\(([^)]+)\)/g);
73
- for (const p of parts) tags.push(p[1]);
156
+ for (const p of parts) {
157
+ const tag = p[1].trim();
158
+ // Skip "cli" — not meaningful for version display
159
+ if (tag === 'cli') continue;
160
+ // Normalize "built: Jan 13 2026 22:36:55" → "2026-01-13 22:36:55"
161
+ const built = tag.match(/^built:\s+(.+)$/);
162
+ if (built) {
163
+ tags.push(normalizeDate(built[1]));
164
+ continue;
165
+ }
166
+ tags.push(tag);
167
+ }
74
168
  return { version: m[1], extra: tags };
75
169
  },
76
170
  // openjdk version "21" 2023-09-19 LTS
@@ -79,27 +173,32 @@ const VERSION_PARSERS = {
79
173
  if (!m) return null;
80
174
  return { version: m[1], extra: m[2] ? [m[2].trim()] : [] };
81
175
  },
82
- // gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 use full distro version
176
+ // gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 use base version only
83
177
  gcc: raw => {
84
- const m = raw.match(/^gcc\s+(?:\((\S+)\s+([\d.]+\S*)\)\s+)?([\d.]+)/);
178
+ const m = raw.match(/^gcc\s+(?:\([^)]*\)\s+)?([\d.]+)/);
85
179
  if (!m) return null;
86
- // If distro info present, use full distro version (e.g. 13.3.0-6ubuntu2~24.04.1)
87
- if (m[1] && m[2]) return { version: m[2], extra: [] };
88
- return { version: m[3], extra: [] };
180
+ return { version: m[1], extra: [] };
89
181
  },
90
- // g++ (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 use full distro version
182
+ // g++ (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 use base version only
91
183
  gpp: raw => {
92
- const m = raw.match(/^g\+\+\s+(?:\((\S+)\s+([\d.]+\S*)\)\s+)?([\d.]+)/);
184
+ const m = raw.match(/^g\+\+\s+(?:\([^)]*\)\s+)?([\d.]+)/);
93
185
  if (!m) return null;
94
- // If distro info present, use full distro version (e.g. 13.3.0-6ubuntu2~24.04.1)
95
- if (m[1] && m[2]) return { version: m[2], extra: [] };
96
- return { version: m[3], extra: [] };
186
+ return { version: m[1], extra: [] };
97
187
  },
98
- // clang version 17.0.0 (https://github.com/... commit)
188
+ // clang version 17.0.0 (https://github.com/... 2e6139970eda) → strip URL, keep commit
99
189
  clang: raw => {
100
190
  const m = raw.match(/^clang\s+version\s+([\d.]+(?:-\S+)?)\s*(?:\(([^)]+)\))?/);
101
191
  if (!m) return null;
102
- return { version: m[1], extra: m[2] ? [m[2].trim()] : [] };
192
+ if (m[2]) {
193
+ // Remove URLs, keep only hex commit hashes
194
+ const parts = m[2]
195
+ .trim()
196
+ .split(/\s+/)
197
+ .filter(p => !p.includes('://') && !p.includes('.git'));
198
+ const commitParts = parts.filter(p => /^[0-9a-f]{7,}$/i.test(p));
199
+ return { version: m[1], extra: commitParts };
200
+ }
201
+ return { version: m[1], extra: [] };
103
202
  },
104
203
  // LLD 17.0.0 (compatible with GNU linkers) — only version number matters
105
204
  lld: raw => {
@@ -113,34 +212,47 @@ const VERSION_PARSERS = {
113
212
  if (!m) return null;
114
213
  return { version: m[1], extra: [] };
115
214
  },
116
- // ruby 3.4.9 (2026-03-11 revision 76cca827ab) +PRISM [x86_64-linux]
215
+ // ruby 3.4.9 (2026-03-11 revision 76cca827ab) +PRISM [x86_64-linux] → strip arch, reformat
117
216
  ruby: raw => {
118
217
  const m = raw.match(/^ruby\s+([\d.]+(?:p\d+)?)\s*(?:\(([^)]+)\))?\s*(.*)/);
119
218
  if (!m) return null;
120
219
  const extra = [];
121
- if (m[2]) extra.push(m[2].trim());
220
+ if (m[2]) {
221
+ // Parse "2026-03-11 revision 76cca827ab" → commit, date
222
+ const revMatch = m[2].match(/^(\d{4}-\d{2}-\d{2})\s+revision\s+(\w+)$/);
223
+ if (revMatch) {
224
+ extra.push(revMatch[2]); // commit first
225
+ extra.push(revMatch[1]); // then date
226
+ } else {
227
+ extra.push(m[2].trim());
228
+ }
229
+ }
230
+ // Capture +PRISM or similar flags, but strip [arch] info
122
231
  const tail = m[3] ? m[3].trim() : '';
123
- if (tail) extra.push(tail);
232
+ if (tail) {
233
+ const cleaned = tail.replace(/\[[\w-]+\]/g, '').trim();
234
+ if (cleaned) extra.push(cleaned);
235
+ }
124
236
  return { version: m[1], extra };
125
237
  },
126
- // Kotlin version 2.3.20-release-208 (JRE 21+35-LTS)
238
+ // Kotlin version 2.3.20-release-208 (JRE 21+35-LTS) → strip -release-NNN suffix
127
239
  kotlin: raw => {
128
- const m = raw.match(/^Kotlin\s+version\s+([\d.\-\w]+)\s*(?:\(([^)]+)\))?/);
240
+ const m = raw.match(/^Kotlin\s+version\s+([\d.]+)(?:-release-\d+)?\s*(?:\(([^)]+)\))?/);
129
241
  if (!m) return null;
130
242
  return { version: m[1], extra: m[2] ? [m[2].trim()] : [] };
131
243
  },
132
- // Swift version 6.0.3 (swift-6.0.3-RELEASE)
244
+ // Swift version 6.0.3 (swift-6.0.3-RELEASE) → strip redundant release tag
133
245
  swift: raw => {
134
- const m = raw.match(/^Swift\s+version\s+([\d.]+(?:\.\d+)?)\s*(?:\(([^)]+)\))?/);
246
+ const m = raw.match(/^Swift\s+version\s+([\d.]+(?:\.\d+)?)/);
135
247
  if (!m) return null;
136
- return { version: m[1], extra: m[2] ? [m[2].trim()] : [] };
248
+ return { version: m[1], extra: [] };
137
249
  },
138
- // R version 4.3.3 (2024-02-29) -- "Angel Food Cake"
250
+ // R version 4.3.3 (2024-02-29) -- "Angel Food Cake" → normalize date
139
251
  r: raw => {
140
252
  const m = raw.match(/^R\s+version\s+([\d.]+)\s*(?:\(([^)]+)\))?(?:\s+--\s+"([^"]+)")?/);
141
253
  if (!m) return null;
142
254
  const extra = [];
143
- if (m[2]) extra.push(m[2]);
255
+ if (m[2]) extra.push(normalizeDate(m[2]));
144
256
  if (m[3]) extra.push(m[3]);
145
257
  return { version: m[1], extra };
146
258
  },
@@ -162,11 +274,11 @@ const VERSION_PARSERS = {
162
274
  if (!m) return null;
163
275
  return { version: m[1], extra: [] };
164
276
  },
165
- // curl 8.19.0 (x86_64-pc-linux-gnu) libcurl/8.19.0 ...
277
+ // curl 8.19.0 (x86_64-pc-linux-gnu) libcurl/8.19.0 ... → strip arch info
166
278
  curl: raw => {
167
- const m = raw.match(/^curl\s+([\d.]+)\s*(?:\(([^)]+)\))?/);
279
+ const m = raw.match(/^curl\s+([\d.]+)/);
168
280
  if (!m) return null;
169
- return { version: m[1], extra: m[2] ? [m[2]] : [] };
281
+ return { version: m[1], extra: [] };
170
282
  },
171
283
  // GNU Wget 1.21.4 built on linux-gnu.
172
284
  wget: raw => {
@@ -198,13 +310,13 @@ const VERSION_PARSERS = {
198
310
  if (!m) return null;
199
311
  return { version: m[1], extra: [] };
200
312
  },
201
- // Screen version 4.09.01 (GNU) 20-Aug-23
313
+ // Screen version 4.09.01 (GNU) 20-Aug-23 → normalize date, strip GNU
202
314
  screen: raw => {
203
- const m = raw.match(/^Screen\s+version\s+([\d.]+)\s*(?:\(([^)]+)\))?\s*(.*)/);
315
+ const m = raw.match(/^Screen\s+version\s+([\d.]+)\s*(?:\([^)]*\))?\s*(.*)/);
204
316
  if (!m) return null;
205
317
  const extra = [];
206
- if (m[2]) extra.push(m[2]);
207
- if (m[3] && m[3].trim()) extra.push(m[3].trim());
318
+ const dateStr = m[2] ? m[2].trim() : '';
319
+ if (dateStr) extra.push(normalizeDate(dateStr));
208
320
  return { version: m[1], extra };
209
321
  },
210
322
  // expect version 5.45.4
@@ -232,7 +344,7 @@ const VERSION_PARSERS = {
232
344
  const extra = m[2] ? m[2].trim().split(/\s+/) : [];
233
345
  return { version: m[1], extra };
234
346
  },
235
- // Lean (version 4.29.0, x86_64-unknown-linux-gnu, commit abc123, Release)
347
+ // Lean (version 4.29.0, x86_64-unknown-linux-gnu, commit abc123, Release) → strip arch/Release
236
348
  lean: raw => {
237
349
  const m = raw.match(/version\s+([\d.]+)(?:,\s*(.+?))\)?$/);
238
350
  if (!m) return null;
@@ -240,7 +352,13 @@ const VERSION_PARSERS = {
240
352
  ? m[2]
241
353
  .split(',')
242
354
  .map(s => s.trim().replace(/\)$/, ''))
243
- .filter(Boolean)
355
+ .filter(s => {
356
+ if (!s) return false;
357
+ // Strip arch patterns and "Release"
358
+ if (/^\w+[-_]\w+[-_]\w+[-_]\w+$/.test(s)) return false;
359
+ if (s === 'Release') return false;
360
+ return true;
361
+ })
244
362
  : [];
245
363
  return { version: m[1], extra };
246
364
  },
@@ -268,7 +386,7 @@ const VERSION_PARSERS = {
268
386
  if (!m) return null;
269
387
  return { version: m[1], extra: [] };
270
388
  },
271
- // deno 2.7.9 (stable, release, x86_64-unknown-linux-gnu)
389
+ // deno 2.7.9 (stable, release, x86_64-unknown-linux-gnu) → keep only channel (stable)
272
390
  deno: raw => {
273
391
  const m = raw.match(/^deno\s+([\d.]+)\s*(?:\(([^)]+)\))?/);
274
392
  if (!m) return null;
@@ -276,7 +394,7 @@ const VERSION_PARSERS = {
276
394
  ? m[2]
277
395
  .split(',')
278
396
  .map(s => s.trim())
279
- .filter(Boolean)
397
+ .filter(s => s && s !== 'release' && !s.includes('-') && !s.includes('/'))
280
398
  : [];
281
399
  return { version: m[1], extra };
282
400
  },
@@ -304,11 +422,11 @@ const VERSION_PARSERS = {
304
422
  if (!m) return null;
305
423
  return { version: m[1], extra: [] };
306
424
  },
307
- // 2.1.87 (Claude Code)
425
+ // 2.1.87 (Claude Code) → strip redundant product name
308
426
  claudeCode: raw => {
309
- const m = raw.match(/([\d.]+)\s*(?:\(([^)]+)\))?/);
427
+ const m = raw.match(/([\d.]+)/);
310
428
  if (!m) return null;
311
- return { version: m[1], extra: m[2] ? [m[2]] : [] };
429
+ return { version: m[1], extra: [] };
312
430
  },
313
431
  // GitHub Copilot CLI 1.0.14.\nRun 'copilot update'...
314
432
  copilot: raw => {
@@ -342,25 +460,26 @@ const VERSION_PARSERS = {
342
460
  if (!m) return null;
343
461
  return { version: m[1], extra: [] };
344
462
  },
345
- // This is Zip 3.0 (July 5th 2008), by Info-ZIP.
463
+ // This is Zip 3.0 (July 5th 2008), by Info-ZIP. → normalize date
346
464
  zip: raw => {
347
465
  const m = raw.match(/Zip\s+([\d.]+)\s*(?:\(([^)]+)\))?/);
348
466
  if (!m) return null;
349
- return { version: m[1], extra: m[2] ? [m[2]] : [] };
467
+ return { version: m[1], extra: m[2] ? [normalizeDate(m[2])] : [] };
350
468
  },
351
- // UnZip 6.00 of 20 April 2009, by Debian.
469
+ // UnZip 6.00 of 20 April 2009, by Debian. → normalize date
352
470
  unzip: raw => {
353
471
  const m = raw.match(/UnZip\s+([\d.]+)\s*(?:of\s+([^,]+))?/);
354
472
  if (!m) return null;
355
- return { version: m[1], extra: m[2] ? [m[2].trim()] : [] };
473
+ return { version: m[1], extra: m[2] ? [normalizeDate(m[2].trim())] : [] };
356
474
  },
357
- // ii xvfb 2:21.1.12-1ubuntu1.5 amd64 Virtual Framebuffer...
475
+ // ii xvfb 2:21.1.12-1ubuntu1.5 amd64 Virtual Framebuffer... → base version only
358
476
  xvfb: raw => {
359
477
  // dpkg output format
360
478
  const dpkg = raw.match(/^ii\s+xvfb\s+(\S+)/);
361
479
  if (dpkg) {
362
480
  // Strip epoch (e.g. "2:21.1.12-1ubuntu1.5" -> "21.1.12-1ubuntu1.5")
363
- const ver = dpkg[1].replace(/^\d+:/, '');
481
+ // Then strip distro suffix (e.g. "21.1.12-1ubuntu1.5" -> "21.1.12")
482
+ const ver = dpkg[1].replace(/^\d+:/, '').replace(/-.*$/, '');
364
483
  return { version: ver, extra: [] };
365
484
  }
366
485
  // X.Org X Server version output (if it ever works)
@@ -559,6 +678,95 @@ async function executeVersionCommand(cmdDef, verbose) {
559
678
  return { key: cmdDef.key, value: result };
560
679
  }
561
680
 
681
+ /**
682
+ * Map of process.arch values to human-friendly architecture names
683
+ */
684
+ const ARCH_NAMES = {
685
+ x64: 'AMD64 (x86-64)',
686
+ arm64: 'ARM64 (aarch64)',
687
+ arm: 'ARM32',
688
+ ia32: 'x86 (IA-32)',
689
+ mips: 'MIPS',
690
+ mipsel: 'MIPS (LE)',
691
+ ppc64: 'PowerPC 64',
692
+ s390x: 's390x',
693
+ riscv64: 'RISC-V 64',
694
+ };
695
+
696
+ /**
697
+ * Detect detailed platform information: environment type, OS, architecture, kernel.
698
+ * @param {boolean} verbose - Enable verbose logging
699
+ * @returns {Promise<{environment: string, arch: string, os: string, kernel: string}>}
700
+ */
701
+ async function detectPlatformInfo(verbose) {
702
+ const info = { environment: '', arch: '', os: '', kernel: '' };
703
+
704
+ // Architecture
705
+ info.arch = ARCH_NAMES[process.arch] || process.arch;
706
+
707
+ // Kernel
708
+ const uname = await execCommandAsync('uname -r 2>/dev/null');
709
+ if (uname) {
710
+ info.kernel = `Linux ${uname}`;
711
+ } else if (process.platform === 'darwin') {
712
+ const darwinVer = await execCommandAsync('uname -r 2>/dev/null');
713
+ info.kernel = darwinVer ? `Darwin ${darwinVer}` : 'Darwin';
714
+ } else if (process.platform === 'win32') {
715
+ info.kernel = 'Windows NT';
716
+ }
717
+
718
+ // OS detection
719
+ if (process.platform === 'linux') {
720
+ // Try /etc/os-release for distro info
721
+ const osRelease = await execCommandAsync('cat /etc/os-release 2>/dev/null');
722
+ if (osRelease) {
723
+ const nameMatch = osRelease.match(/^PRETTY_NAME="?([^"\n]+)"?/m);
724
+ if (nameMatch) {
725
+ info.os = nameMatch[1];
726
+ } else {
727
+ const idMatch = osRelease.match(/^ID="?([^"\n]+)"?/m);
728
+ const versionMatch = osRelease.match(/^VERSION_ID="?([^"\n]+)"?/m);
729
+ if (idMatch) {
730
+ info.os = versionMatch ? `${idMatch[1]} ${versionMatch[1]}` : idMatch[1];
731
+ }
732
+ }
733
+ }
734
+ if (!info.os) info.os = 'Linux';
735
+ } else if (process.platform === 'darwin') {
736
+ const swVers = await execCommandAsync('sw_vers -productVersion 2>/dev/null');
737
+ info.os = swVers ? `macOS ${swVers}` : 'macOS';
738
+ } else if (process.platform === 'win32') {
739
+ info.os = 'Windows';
740
+ } else {
741
+ info.os = process.platform;
742
+ }
743
+
744
+ // Environment detection: docker container, VM, or host
745
+ const isDocker = await execCommandAsync('cat /proc/1/cgroup 2>/dev/null | grep -qi docker && echo docker || test -f /.dockerenv && echo docker || echo no');
746
+ if (isDocker && isDocker.trim() === 'docker') {
747
+ info.environment = 'docker container';
748
+ } else {
749
+ // Check for VM/hypervisor
750
+ const systemdDetect = await execCommandAsync('systemd-detect-virt 2>/dev/null');
751
+ if (systemdDetect && systemdDetect !== 'none') {
752
+ info.environment = `virtual machine (${systemdDetect})`;
753
+ } else {
754
+ const dmi = await execCommandAsync('cat /sys/class/dmi/id/product_name 2>/dev/null');
755
+ if (dmi && /virtual|vmware|kvm|qemu|hyper-v|xen|bochs/i.test(dmi)) {
756
+ info.environment = `virtual machine`;
757
+ } else {
758
+ info.environment = 'host machine';
759
+ }
760
+ }
761
+ }
762
+
763
+ if (verbose) {
764
+ console.log(`[VERBOSE] Platform detection: ${JSON.stringify(info)}`);
765
+ }
766
+
767
+ return info;
768
+ }
769
+
562
770
  /**
563
771
  * Get comprehensive version information for all components
564
772
  * Uses Promise.all for parallel execution (issue #1320)
@@ -595,12 +803,16 @@ export async function getVersionInfo(verbose = false, processVersion = null) {
595
803
  console.log(`[VERBOSE] Node.js version: ${versions.node}`);
596
804
  }
597
805
 
598
- // Platform information
599
- const platform = process.platform;
600
- const arch = process.arch;
601
- versions.platform = `${platform} (${arch})`;
806
+ // Platform information — detailed detection
807
+ const platformInfo = await detectPlatformInfo(verbose);
808
+ versions.platformEnvironment = platformInfo.environment;
809
+ versions.platformArch = platformInfo.arch;
810
+ versions.platformOs = platformInfo.os;
811
+ versions.platformKernel = platformInfo.kernel;
812
+ // Keep legacy field for backward compat
813
+ versions.platform = platformInfo.os;
602
814
  if (verbose) {
603
- console.log(`[VERBOSE] Platform: ${versions.platform}`);
815
+ console.log(`[VERBOSE] Platform: env=${platformInfo.environment}, arch=${platformInfo.arch}, os=${platformInfo.os}, kernel=${platformInfo.kernel}`);
604
816
  }
605
817
 
606
818
  // Check if process version differs from installed version (restart warning)
@@ -717,8 +929,12 @@ export async function getVersionInfo(verbose = false, processVersion = null) {
717
929
  screen: versions.screen,
718
930
  xvfb: versions.xvfb,
719
931
 
720
- // Platform
932
+ // Platform (detailed)
721
933
  platform: versions.platform,
934
+ platformEnvironment: versions.platformEnvironment,
935
+ platformArch: versions.platformArch,
936
+ platformOs: versions.platformOs,
937
+ platformKernel: versions.platformKernel,
722
938
  },
723
939
  // Performance metrics
724
940
  gatherTimeMs: Date.now() - startTime,
@@ -941,7 +1157,7 @@ export function formatVersionMessage(versions) {
941
1157
 
942
1158
  if (cppLines.length > 0) {
943
1159
  lines.push('');
944
- lines.push('*🔨 C/C++/Assembly*');
1160
+ lines.push('*🔨 C, C++, Assembly*');
945
1161
  lines.push(...cppLines);
946
1162
  }
947
1163
 
@@ -997,11 +1213,22 @@ export function formatVersionMessage(versions) {
997
1213
  lines.push(...toolLines);
998
1214
  }
999
1215
 
1000
- // === Platform ===
1001
- if (versions.platform) {
1002
- lines.push('');
1003
- lines.push('*💻 Platform*');
1004
- lines.push(`• System: \`${versions.platform}\``);
1216
+ // === Platform (detailed) ===
1217
+ {
1218
+ const platformLines = [];
1219
+ if (versions.platformEnvironment) platformLines.push(`• Environment: \`${versions.platformEnvironment}\``);
1220
+ if (versions.platformArch) platformLines.push(`• Architecture: \`${versions.platformArch}\``);
1221
+ if (versions.platformOs) platformLines.push(`• OS: \`${versions.platformOs}\``);
1222
+ if (versions.platformKernel) platformLines.push(`• Kernel: \`${versions.platformKernel}\``);
1223
+ // Fallback to legacy single-line format
1224
+ if (platformLines.length === 0 && versions.platform) {
1225
+ platformLines.push(`• System: \`${versions.platform}\``);
1226
+ }
1227
+ if (platformLines.length > 0) {
1228
+ lines.push('');
1229
+ lines.push('*💻 Platform*');
1230
+ lines.push(...platformLines);
1231
+ }
1005
1232
  }
1006
1233
 
1007
1234
  return lines.join('\n');
@@ -1011,4 +1238,5 @@ export default {
1011
1238
  getVersionInfo,
1012
1239
  formatVersionMessage,
1013
1240
  parseVersion,
1241
+ normalizeDate,
1014
1242
  };