@nerviq/cli 1.8.7 → 1.8.9

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/README.md CHANGED
@@ -347,16 +347,3 @@ If Nerviq helped you, consider giving it a ⭐ on [GitHub](https://github.com/ne
347
347
  | `BETA` | Works but has limited real-world testing. API may change |
348
348
  | `EXPERIMENTAL` | Early stage, static rules, results may vary |
349
349
 
350
- ## Previously nerviq-cli
351
-
352
- Nerviq was previously published as `nerviq-cli`. If you were using it:
353
-
354
- ```bash
355
- # Old
356
- npx nerviq-cli
357
-
358
- # New
359
- npx @nerviq/cli audit
360
- ```
361
-
362
- All features are preserved and expanded.
package/bin/cli.js CHANGED
@@ -285,6 +285,56 @@ function printWorkspaceSummary(summary, options) {
285
285
  console.log('');
286
286
  }
287
287
 
288
+ function printScanDetail(summary, options) {
289
+ if (options.json) {
290
+ console.log(JSON.stringify(summary, null, 2));
291
+ return;
292
+ }
293
+
294
+ console.log('');
295
+ console.log('\x1b[1m nerviq scan — per-repo comparison\x1b[0m');
296
+ console.log('\x1b[2m ═══════════════════════════════════════\x1b[0m');
297
+ console.log(` Platform: ${summary.platform} | Repos: ${summary.repoCount} | Average: \x1b[1m${summary.averageScore}/100\x1b[0m`);
298
+ console.log('');
299
+
300
+ for (const item of summary.repos) {
301
+ if (item.error) {
302
+ console.log(` \x1b[31m✗ ${item.name}\x1b[0m — ${item.error}`);
303
+ console.log('');
304
+ continue;
305
+ }
306
+ const scoreColor = item.score >= 80 ? '\x1b[32m' : item.score >= 50 ? '\x1b[33m' : '\x1b[31m';
307
+ console.log(` \x1b[1m${item.name}\x1b[0m ${scoreColor}${item.score}/100\x1b[0m (${item.passed}/${item.total} checks passed)`);
308
+
309
+ // Show per-category breakdown if result is available
310
+ if (item.result && item.result.results) {
311
+ const STACK_LANGUAGES = new Set(['python', 'go', 'rust', 'java', 'ruby', 'dotnet', 'php', 'flutter', 'swift', 'kotlin']);
312
+ const categories = {};
313
+ for (const r of item.result.results) {
314
+ const cat = r.category || 'other';
315
+ if (!categories[cat]) categories[cat] = { passed: 0, total: 0 };
316
+ categories[cat].total++;
317
+ if (r.passed) categories[cat].passed++;
318
+ }
319
+ const catEntries = Object.entries(categories)
320
+ .filter(([cat, v]) => v.passed > 0 || !STACK_LANGUAGES.has(cat))
321
+ .sort((a, b) => (a[1].passed / a[1].total) - (b[1].passed / b[1].total));
322
+ const catLine = catEntries.map(([cat, v]) => `${cat}: ${v.passed}/${v.total}`).join(' ');
323
+ console.log(` \x1b[2m${catLine}\x1b[0m`);
324
+ }
325
+
326
+ // Show top 3 gaps
327
+ if (item.result && item.result.topNextActions && item.result.topNextActions.length > 0) {
328
+ const gaps = item.result.topNextActions.slice(0, 3);
329
+ console.log(' Top gaps:');
330
+ for (const gap of gaps) {
331
+ console.log(` \x1b[33m→\x1b[0m ${gap.name || gap.key}${gap.impact ? ` \x1b[2m(+${gap.impact})\x1b[0m` : ''}`);
332
+ }
333
+ }
334
+ console.log('');
335
+ }
336
+ }
337
+
288
338
  function printOrgSummary(summary, options) {
289
339
  if (options.json) {
290
340
  console.log(JSON.stringify(summary, null, 2));
@@ -525,6 +575,21 @@ async function main() {
525
575
  }
526
576
  }
527
577
 
578
+ // Apply built-in governance profile (--profile flag) to audit options
579
+ if (parsed.profile && parsed.profile !== 'safe-write') {
580
+ const { getPermissionProfile } = require('../src/governance');
581
+ const govProfile = getPermissionProfile(parsed.profile);
582
+ if (govProfile) {
583
+ options.governanceProfile = govProfile;
584
+ if (govProfile.deny && govProfile.deny.length > 0) {
585
+ options.suppressedChecks = options.suppressedChecks || [];
586
+ }
587
+ if (!options.json) {
588
+ console.log(` Using governance profile: ${govProfile.label} (${govProfile.risk} risk)`);
589
+ }
590
+ }
591
+ }
592
+
528
593
  const SUPPORTED_PLATFORMS = ['claude', 'codex', 'gemini', 'copilot', 'cursor', 'windsurf', 'aider', 'opencode'];
529
594
  if (!SUPPORTED_PLATFORMS.includes(options.platform)) {
530
595
  console.error(`\n Error: Unsupported platform '${options.platform}'.`);
@@ -595,7 +660,7 @@ async function main() {
595
660
  // Harmony + Synergy (cross-platform)
596
661
  'harmony-audit', 'harmony-sync', 'harmony-drift', 'harmony-advise',
597
662
  'harmony-watch', 'harmony-governance', 'harmony-add', 'synergy-report', 'anti-patterns', 'rules-export',
598
- 'freshness', 'profile',
663
+ 'freshness', 'profile', 'migrate',
599
664
  ]);
600
665
 
601
666
  if (options.platform === 'codex') {
@@ -648,7 +713,7 @@ async function main() {
648
713
  process.exit(1);
649
714
  }
650
715
  const summary = await scanOrg(scanDirs, options.platform);
651
- printOrgSummary(summary, options);
716
+ printScanDetail(summary, options);
652
717
  if (options.threshold !== null && summary.averageScore < options.threshold) {
653
718
  process.exit(1);
654
719
  }
@@ -1340,12 +1405,54 @@ async function main() {
1340
1405
  console.error('\n Error: Profile name required. Usage: nerviq profile load <name>\n');
1341
1406
  process.exit(1);
1342
1407
  }
1343
- const profile = loadProfile(options.dir, profileArg);
1408
+ let profile;
1409
+ try {
1410
+ profile = loadProfile(options.dir, profileArg);
1411
+ } catch {
1412
+ // Not found as a user-saved profile — try built-in governance profiles
1413
+ const { getPermissionProfile } = require('../src/governance');
1414
+ const builtIn = getPermissionProfile(profileArg);
1415
+ if (builtIn && builtIn.key === profileArg) {
1416
+ profile = { name: builtIn.label, platforms: ['claude'], threshold: builtIn.threshold || 0, ...builtIn };
1417
+ }
1418
+ }
1419
+ if (!profile) {
1420
+ console.error(`\n Error: Profile '${profileArg}' not found. Run 'nerviq profile list' to see available profiles.\n`);
1421
+ process.exit(1);
1422
+ }
1423
+
1424
+ // Apply profile settings to .claude/settings.json
1425
+ const fs = require('fs');
1426
+ const settingsPath = require('path').join(options.dir, '.claude', 'settings.json');
1427
+ let settings = {};
1428
+ if (fs.existsSync(settingsPath)) {
1429
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
1430
+ }
1431
+ // Apply deny rules from governance profile if platforms include claude
1432
+ if (profile.platforms && profile.platforms.includes('claude')) {
1433
+ const { getPermissionProfile } = require('../src/governance');
1434
+ const govProfile = getPermissionProfile(profileArg);
1435
+ if (govProfile && govProfile.deny && govProfile.deny.length > 0) {
1436
+ settings.deny = govProfile.deny;
1437
+ }
1438
+ }
1439
+ // Apply threshold and suppressed checks
1440
+ if (profile.threshold != null) {
1441
+ settings.threshold = profile.threshold;
1442
+ }
1443
+ if (profile.suppressedChecks && profile.suppressedChecks.length > 0) {
1444
+ settings.suppressedChecks = profile.suppressedChecks;
1445
+ }
1446
+ const settingsDir = require('path').dirname(settingsPath);
1447
+ fs.mkdirSync(settingsDir, { recursive: true });
1448
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
1449
+
1344
1450
  if (options.json) {
1345
1451
  console.log(JSON.stringify(profile, null, 2));
1346
1452
  } else {
1347
1453
  console.log('');
1348
1454
  console.log(formatProfile(profile));
1455
+ console.log(`\n Settings applied to ${settingsPath}`);
1349
1456
  console.log('');
1350
1457
  }
1351
1458
  process.exit(0);
@@ -1368,8 +1475,35 @@ async function main() {
1368
1475
  process.exit(1);
1369
1476
  }
1370
1477
  } else if (normalizedCommand === 'synergy-report') {
1371
- // Placeholder synergy report is referenced but may not be implemented yet
1372
- console.log('\n Synergy report: coming soon.\n');
1478
+ const { formatSynergyReport } = require('../src/synergy/report');
1479
+ const { detectActivePlatforms: detectSynergyPlatforms } = require('../src/harmony/canon');
1480
+ const presentPlatforms = detectSynergyPlatforms(options.dir).map(p => p.platform);
1481
+ if (presentPlatforms.length === 0) {
1482
+ console.log('\n No platform configurations detected.');
1483
+ console.log(' Run "nerviq harmony-audit" first, or "nerviq setup" to bootstrap a platform.\n');
1484
+ process.exit(0);
1485
+ }
1486
+ const platformAudits = {};
1487
+ const activePlatforms = [];
1488
+ for (const plat of presentPlatforms) {
1489
+ try {
1490
+ const result = await audit({ dir: options.dir, silent: true, platform: plat });
1491
+ if (result && typeof result.score === 'number') {
1492
+ platformAudits[plat] = result;
1493
+ activePlatforms.push(plat);
1494
+ }
1495
+ } catch (_e) { /* platform not available */ }
1496
+ }
1497
+ if (activePlatforms.length === 0) {
1498
+ console.log('\n No auditable platforms found. Run "nerviq harmony-audit" first.\n');
1499
+ process.exit(0);
1500
+ }
1501
+ const report = formatSynergyReport({ platformAudits, activePlatforms });
1502
+ if (options.json) {
1503
+ console.log(JSON.stringify({ activePlatforms, platformAudits }, null, 2));
1504
+ } else {
1505
+ console.log(report);
1506
+ }
1373
1507
  process.exit(0);
1374
1508
  } else if (normalizedCommand === 'doctor') {
1375
1509
  const { runDoctor } = require('../src/doctor');
@@ -1404,7 +1538,8 @@ async function main() {
1404
1538
  const fixKey = parsed.extraArgs[0] || null;
1405
1539
  const allCritical = flags.includes('--all-critical');
1406
1540
  const promptOnly = flags.includes('--prompt');
1407
- const autoApply = options.auto || options.dryRun;
1541
+ const autoApply = options.auto;
1542
+ const isDryRun = options.dryRun;
1408
1543
 
1409
1544
  // Step 1: Run silent audit to find failed checks (only actual failures, not skipped/null)
1410
1545
  const auditResult = await audit({ dir: options.dir, silent: true, platform: options.platform });
@@ -1447,6 +1582,13 @@ async function main() {
1447
1582
  for (const entry of denyEntries) {
1448
1583
  if (!settings.permissions.deny.includes(entry)) settings.permissions.deny.push(entry);
1449
1584
  }
1585
+ // Remove overly broad allow:["*"] if present
1586
+ if (Array.isArray(settings.permissions.allow) && settings.permissions.allow.includes('*')) {
1587
+ settings.permissions.allow = settings.permissions.allow.filter(a => a !== '*');
1588
+ if (settings.permissions.allow.length === 0) {
1589
+ delete settings.permissions.allow;
1590
+ }
1591
+ }
1450
1592
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
1451
1593
  return true;
1452
1594
  },
@@ -1558,7 +1700,7 @@ async function main() {
1558
1700
  const predictedScore = maxScore > 0 ? Math.round((simulatedEarned / maxScore) * 100) : 0;
1559
1701
  const predictedDelta = predictedScore - preScore;
1560
1702
 
1561
- if (!autoApply) {
1703
+ if (!autoApply && !isDryRun) {
1562
1704
  console.log('');
1563
1705
  if (allCritical && fixableTargets.length > 1) {
1564
1706
  // Multi-fix summary
@@ -1607,9 +1749,9 @@ async function main() {
1607
1749
  const allCreatedFiles = [];
1608
1750
  const fixResults = []; // { key, name, status, delta }
1609
1751
 
1610
- if (!options.dryRun && targetKeys.length > 0) {
1611
- // Snapshot existing files for rollback
1612
- const snapshotFiles = {};
1752
+ const snapshotFiles = {};
1753
+ if (!isDryRun && targetKeys.length > 0) {
1754
+ // Snapshot existing files for rollback (before applying fixes)
1613
1755
  for (const key of targetKeys) {
1614
1756
  const technique = TECHNIQUES[key];
1615
1757
  if (technique && technique.template && technique.template.path) {
@@ -1619,14 +1761,6 @@ async function main() {
1619
1761
  }
1620
1762
  }
1621
1763
  }
1622
- const rollbackArtifact = writeRollbackArtifact(options.dir, {
1623
- sourcePlan: 'fix-batch',
1624
- preSnapshot: snapshotFiles,
1625
- createdFiles: [],
1626
- patchedFiles: Object.keys(snapshotFiles),
1627
- rollbackInstructions: ['Use nerviq rollback to undo these fixes'],
1628
- });
1629
- rollbackId = rollbackArtifact.id;
1630
1764
  }
1631
1765
 
1632
1766
  // Step 3b: Apply fixes sequentially with progress
@@ -1641,7 +1775,7 @@ async function main() {
1641
1775
  const progress = isBatch ? `${i + 1}/${targetKeys.length}: ` : '';
1642
1776
 
1643
1777
  if (technique && technique.template) {
1644
- if (options.dryRun) {
1778
+ if (isDryRun) {
1645
1779
  console.log(` [dry-run] Would fix: ${progress}${failedCheck.name} (${key})`);
1646
1780
  fixResults.push({ key, name: failedCheck.name, status: 'dry-run', delta: 0 });
1647
1781
  fixed++;
@@ -1671,7 +1805,7 @@ async function main() {
1671
1805
  }
1672
1806
  }
1673
1807
  } else if (INLINE_FIXERS[key]) {
1674
- if (options.dryRun) {
1808
+ if (isDryRun) {
1675
1809
  console.log(` [dry-run] Would fix: ${progress}${failedCheck.name} (${key})`);
1676
1810
  fixResults.push({ key, name: failedCheck.name, status: 'dry-run', delta: 0 });
1677
1811
  fixed++;
@@ -1716,26 +1850,33 @@ async function main() {
1716
1850
  }
1717
1851
 
1718
1852
  // Record accepted patterns for successfully fixed checks
1719
- if (!options.dryRun) {
1853
+ if (!isDryRun) {
1720
1854
  for (const key of targetKeys) {
1721
1855
  const fr = fixResults.find(r => r.key === key);
1722
1856
  recordPattern(options.dir, key, fr && fr.status === 'fixed' ? 'accepted' : 'rejected');
1723
1857
  }
1724
1858
  }
1725
1859
 
1726
- // Update rollback artifact with actual created files
1727
- if (!options.dryRun && rollbackId && allCreatedFiles.length > 0) {
1728
- const { ensureArtifactDirs } = require('../src/activity');
1729
- const { rollbackDir } = ensureArtifactDirs(options.dir);
1730
- const rbFiles = fs.readdirSync(rollbackDir).filter(f => f.includes(rollbackId));
1731
- if (rbFiles.length > 0) {
1732
- const rbPath = pathMod.join(rollbackDir, rbFiles[0]);
1733
- try {
1734
- const rbData = JSON.parse(fs.readFileSync(rbPath, 'utf8'));
1735
- rbData.createdFiles = allCreatedFiles;
1736
- fs.writeFileSync(rbPath, JSON.stringify(rbData, null, 2), 'utf8');
1737
- } catch { /* best effort */ }
1860
+ // Write rollback artifact AFTER fixes are applied (with actual file lists)
1861
+ if (!isDryRun && targetKeys.length > 0 && fixed > 0) {
1862
+ const allPatchedFiles = Object.keys(snapshotFiles);
1863
+ // Also track inline-fixer patched files
1864
+ for (const fr of fixResults) {
1865
+ if (fr.status === 'fixed' && INLINE_FIXERS[fr.key]) {
1866
+ const inlinePath = fr.key === 'gitIgnoreEnv' ? '.gitignore' : fr.key === 'secretsProtection' ? '.claude/settings.json' : null;
1867
+ if (inlinePath && !allPatchedFiles.includes(inlinePath)) {
1868
+ allPatchedFiles.push(inlinePath);
1869
+ }
1870
+ }
1738
1871
  }
1872
+ const rollbackArtifact = writeRollbackArtifact(options.dir, {
1873
+ sourcePlan: 'fix-batch',
1874
+ preSnapshot: snapshotFiles,
1875
+ createdFiles: allCreatedFiles,
1876
+ patchedFiles: allPatchedFiles,
1877
+ rollbackInstructions: ['Use nerviq rollback to undo these fixes'],
1878
+ });
1879
+ rollbackId = rollbackArtifact.id;
1739
1880
  }
1740
1881
 
1741
1882
  // Step 4: Show batch summary or simple score impact
@@ -1751,10 +1892,10 @@ async function main() {
1751
1892
  const totalDelta = runningScore - preScore;
1752
1893
  console.log('');
1753
1894
  console.log(` Score: ${preScore} → ${runningScore} (${totalDelta >= 0 ? '+' : ''}${totalDelta})`);
1754
- if (rollbackId && !options.dryRun) {
1895
+ if (rollbackId && !isDryRun) {
1755
1896
  console.log(` Rollback available: nerviq rollback --id ${rollbackId}`);
1756
1897
  }
1757
- } else if (fixed > 0 && !options.dryRun) {
1898
+ } else if (fixed > 0 && !isDryRun) {
1758
1899
  const postResult = await audit({ dir: options.dir, silent: true, platform: options.platform });
1759
1900
  const delta = postResult.score - preScore;
1760
1901
  console.log('');
@@ -1791,6 +1932,16 @@ async function main() {
1791
1932
  process.exit(0);
1792
1933
  }
1793
1934
  const result = await audit(options);
1935
+ if (options.out) {
1936
+ const fs = require('fs');
1937
+ const path = require('path');
1938
+ const outPath = path.resolve(options.out);
1939
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1940
+ fs.writeFileSync(outPath, JSON.stringify(result, null, 2), 'utf8');
1941
+ if (!options.json) {
1942
+ console.log(`\n Audit report written to ${options.out}\n`);
1943
+ }
1944
+ }
1794
1945
  if (options.webhookUrl) {
1795
1946
  try {
1796
1947
  const { sendWebhook, formatSlackMessage } = require('../src/integrations');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nerviq/cli",
3
- "version": "1.8.7",
3
+ "version": "1.8.9",
4
4
  "description": "The intelligent nervous system for AI coding agents — 2,431 checks (8 platforms × ~300 governance rules), 10 languages, 62 domain packs. Audit, align, and amplify.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/benchmark.js CHANGED
@@ -321,9 +321,13 @@ function printBenchmark(report, options = {}) {
321
321
  console.log(' ═══════════════════════════════════════');
322
322
  console.log(' Runs in an isolated temp copy. Your current repo is not modified.');
323
323
  console.log('');
324
- console.log(` Before: ${report.before.score}/100 (organic ${report.before.organicScore}/100)`);
325
- console.log(` After: ${report.after.score}/100 (organic ${report.after.organicScore}/100)`);
326
- console.log(` Delta: score ${report.delta.score >= 0 ? '+' : ''}${report.delta.score}, organic ${report.delta.organicScore >= 0 ? '+' : ''}${report.delta.organicScore}`);
324
+ const orgDeltaSign = report.delta.organicScore >= 0 ? '+' : '';
325
+ const totalDeltaSign = report.delta.score >= 0 ? '+' : '';
326
+ console.log(` Organic improvement: \x1b[1m${orgDeltaSign}${report.delta.organicScore} points\x1b[0m (your actual config quality)`);
327
+ console.log(` Total with nerviq setup: ${totalDeltaSign}${report.delta.score} points`);
328
+ console.log('');
329
+ console.log(` Before: organic ${report.before.organicScore}/100, total ${report.before.score}/100`);
330
+ console.log(` After: organic ${report.after.organicScore}/100, total ${report.after.score}/100`);
327
331
  console.log('');
328
332
  console.log(` ${report.executiveSummary.headline}`);
329
333
  console.log(` Recommendation: ${report.executiveSummary.decisionGuidance}`);
@@ -37,10 +37,14 @@ async function certifyProject(dir) {
37
37
 
38
38
  // Run per-platform audits
39
39
  const platformScores = {};
40
+ const allAuditResults = [];
40
41
  for (const platform of platforms) {
41
42
  try {
42
43
  const result = await audit({ dir: resolvedDir, platform, silent: true });
43
44
  platformScores[platform] = result.score;
45
+ if (Array.isArray(result.results)) {
46
+ allAuditResults.push(...result.results);
47
+ }
44
48
  } catch {
45
49
  platformScores[platform] = 0;
46
50
  }
@@ -55,18 +59,36 @@ async function certifyProject(dir) {
55
59
  harmonyScore = 0;
56
60
  }
57
61
 
58
- // Determine certification level
62
+ // Determine certification level with security gates
59
63
  const scores = Object.values(platformScores);
60
64
  const allAbove70 = scores.length > 0 && scores.every(s => s >= 70);
61
65
  const allAbove50 = scores.length > 0 && scores.every(s => s >= 50);
62
66
  const anyAbove40 = scores.some(s => s >= 40);
63
67
 
68
+ // Security gate helpers — check whether specific audit checks passed
69
+ const checkPassed = (key) => {
70
+ const match = allAuditResults.find(r => r.key === key);
71
+ return match ? match.passed === true : false;
72
+ };
73
+
74
+ const gitIgnoreOk = checkPassed('gitIgnoreEnv');
75
+ const secretsOk = checkPassed('secretsProtection');
76
+ const criticalAntiPatterns = allAuditResults.filter(
77
+ r => r.passed === false && r.impact === 'critical'
78
+ );
79
+ const noCriticalAntiPatterns = criticalAntiPatterns.length === 0;
80
+
81
+ // Bronze gate: score >= 40 AND basic security (gitignore + secrets protection)
82
+ const bronzeSecurityGate = gitIgnoreOk && secretsOk;
83
+ // Silver gate: Bronze requirements AND no critical anti-patterns
84
+ const silverSecurityGate = bronzeSecurityGate && noCriticalAntiPatterns;
85
+
64
86
  let level;
65
- if (harmonyScore >= 80 && allAbove70) {
87
+ if (harmonyScore >= 80 && allAbove70 && silverSecurityGate) {
66
88
  level = LEVELS.GOLD;
67
- } else if (harmonyScore >= 60 && allAbove50) {
89
+ } else if (harmonyScore >= 60 && allAbove50 && silverSecurityGate) {
68
90
  level = LEVELS.SILVER;
69
- } else if (anyAbove40) {
91
+ } else if (anyAbove40 && bronzeSecurityGate) {
70
92
  level = LEVELS.BRONZE;
71
93
  } else {
72
94
  level = LEVELS.NONE;
@@ -80,6 +102,12 @@ async function certifyProject(dir) {
80
102
  platformScores,
81
103
  platforms,
82
104
  badge,
105
+ securityGates: {
106
+ gitIgnoreEnv: gitIgnoreOk,
107
+ secretsProtection: secretsOk,
108
+ noCriticalAntiPatterns,
109
+ criticalAntiPatternCount: criticalAntiPatterns.length,
110
+ },
83
111
  };
84
112
  }
85
113
 
package/src/context.js CHANGED
@@ -185,12 +185,38 @@ class ProjectContext {
185
185
  return deps;
186
186
  }
187
187
 
188
+ /**
189
+ * Recursively check if a file or directory name exists anywhere under a given base directory.
190
+ * Searches up to maxDepth levels deep.
191
+ */
192
+ _findInSubdirs(name, baseDir, maxDepth = 3) {
193
+ if (maxDepth <= 0) return false;
194
+ try {
195
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
196
+ for (const entry of entries) {
197
+ if (entry.name === 'node_modules' || entry.name === '__pycache__' || entry.name === '.git') continue;
198
+ if (entry.name === name || entry.name.endsWith(name)) return true;
199
+ if (entry.isDirectory()) {
200
+ if (this._findInSubdirs(name, path.join(baseDir, entry.name), maxDepth - 1)) return true;
201
+ }
202
+ }
203
+ } catch {
204
+ // directory not readable
205
+ }
206
+ return false;
207
+ }
208
+
188
209
  detectStacks(STACKS) {
189
210
  const detected = [];
190
211
  for (const [key, stack] of Object.entries(STACKS)) {
191
- const hasFile = stack.files.some(f => {
212
+ // Check root-level files first (fast path)
213
+ let hasFile = stack.files.some(f => {
192
214
  return this.files.some(pf => pf.startsWith(f));
193
215
  });
216
+ // If not found at root, search subdirectories (up to 3 levels deep)
217
+ if (!hasFile) {
218
+ hasFile = stack.files.some(f => this._findInSubdirs(f, this.dir));
219
+ }
194
220
  if (!hasFile) continue;
195
221
 
196
222
  let contentMatch = true;
package/src/convert.js CHANGED
@@ -98,7 +98,7 @@ function readSourceConfig(dir, from) {
98
98
  if (descMatch) desc = descMatch[1].trim();
99
99
  }
100
100
  canonical.rules.push({
101
- name: file.replace('.mdc', ''),
101
+ name: file.replace(/\.(mdc|md|txt)$/i, ''),
102
102
  content,
103
103
  alwaysOn,
104
104
  glob,
@@ -165,7 +165,11 @@ function readSourceConfig(dir, from) {
165
165
 
166
166
  function buildTargetOutput(canonical, to, { dryRun = false } = {}) {
167
167
  const outputs = []; // Array of { path, content }
168
- const combinedContent = canonical.rules.map(r => r.content).join('\n\n');
168
+ // Strip MDC frontmatter from rule content for non-cursor targets to prevent leaking
169
+ const stripFrontmatter = (text) => text.replace(/^---[\s\S]*?---\n/m, '').trim();
170
+ const combinedContent = to === 'cursor'
171
+ ? canonical.rules.map(r => r.content).join('\n\n')
172
+ : canonical.rules.map(r => stripFrontmatter(r.content)).join('\n\n');
169
173
 
170
174
  if (to === 'claude') {
171
175
  // Extract or create CLAUDE.md from combined rules
package/src/governance.js CHANGED
@@ -55,7 +55,7 @@ const HOOK_REGISTRY = [
55
55
  key: 'protect-secrets',
56
56
  file: '.claude/hooks/protect-secrets.sh',
57
57
  triggerPoint: 'PreToolUse',
58
- matcher: 'Read|Write|Edit',
58
+ matcher: 'Read|Write|Edit|Bash',
59
59
  purpose: 'Blocks direct access to secret or credential files before a tool runs.',
60
60
  filesTouched: [],
61
61
  sideEffects: ['Stops the action and returns a block decision when a secret path is targeted.'],
@@ -322,7 +322,7 @@ function buildHookConfig(hookFiles, profileKey) {
322
322
  const secretsFile = uniqueFiles.find(isSecrets);
323
323
  if (secretsFile) {
324
324
  hookConfig.PreToolUse = [{
325
- matcher: 'Read|Write|Edit',
325
+ matcher: 'Read|Write|Edit|Bash',
326
326
  hooks: [{
327
327
  type: 'command',
328
328
  command: hookCommand(secretsFile),
package/src/mcp-server.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * Nerviq MCP Server
4
4
  *
package/src/server.js CHANGED
@@ -18,6 +18,10 @@ const SUPPORTED_PLATFORMS = new Set([
18
18
  'opencode',
19
19
  ]);
20
20
 
21
+ function envelope(data) {
22
+ return { data, meta: { version, timestamp: new Date().toISOString() } };
23
+ }
24
+
21
25
  function sendJson(res, statusCode, payload) {
22
26
  const body = JSON.stringify(payload, null, 2);
23
27
  res.writeHead(statusCode, {
@@ -57,6 +61,11 @@ function createServer(options = {}) {
57
61
  const baseDir = path.resolve(options.baseDir || process.cwd());
58
62
 
59
63
  return http.createServer(async (req, res) => {
64
+ res.setHeader('Access-Control-Allow-Origin', '*');
65
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
66
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
67
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
68
+
60
69
  const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
61
70
 
62
71
  if (req.method !== 'GET') {
@@ -66,16 +75,16 @@ function createServer(options = {}) {
66
75
 
67
76
  try {
68
77
  if (requestUrl.pathname === '/api/health') {
69
- sendJson(res, 200, {
78
+ sendJson(res, 200, envelope({
70
79
  status: 'ok',
71
80
  version,
72
81
  checks: getCatalog().length,
73
- });
82
+ }));
74
83
  return;
75
84
  }
76
85
 
77
86
  if (requestUrl.pathname === '/api/catalog') {
78
- sendJson(res, 200, getCatalog());
87
+ sendJson(res, 200, envelope(getCatalog()));
79
88
  return;
80
89
  }
81
90
 
@@ -83,14 +92,14 @@ function createServer(options = {}) {
83
92
  const dir = resolveRequestDir(baseDir, requestUrl.searchParams.get('dir'));
84
93
  const platform = normalizePlatform(requestUrl.searchParams.get('platform'));
85
94
  const result = await audit({ dir, platform, silent: true });
86
- sendJson(res, 200, result);
95
+ sendJson(res, 200, envelope(result));
87
96
  return;
88
97
  }
89
98
 
90
99
  if (requestUrl.pathname === '/api/harmony') {
91
100
  const dir = resolveRequestDir(baseDir, requestUrl.searchParams.get('dir'));
92
101
  const result = await harmonyAudit({ dir, silent: true });
93
- sendJson(res, 200, result);
102
+ sendJson(res, 200, envelope(result));
94
103
  return;
95
104
  }
96
105
 
package/src/setup.js CHANGED
@@ -10,6 +10,7 @@ const { ProjectContext } = require('./context');
10
10
  const { audit } = require('./audit');
11
11
  const { buildSettingsForProfile } = require('./governance');
12
12
  const { getMcpPackPreflight } = require('./mcp-packs');
13
+ const { writeRollbackArtifact } = require('./activity');
13
14
  const { setupCodex } = require('./codex/setup');
14
15
 
15
16
  // ============================================================
@@ -797,20 +798,27 @@ try {
797
798
  } catch (e) { /* linter not available or failed - non-blocking */ }
798
799
  `,
799
800
  'protect-secrets.js': `#!/usr/bin/env node
800
- // PreToolUse hook - blocks reads of secret files
801
+ // PreToolUse hook - blocks reads of secret files (Read/Write/Edit AND Bash)
801
802
  let input = '';
802
803
  process.stdin.on('data', d => input += d);
803
804
  process.stdin.on('end', () => {
804
805
  try {
805
806
  const data = JSON.parse(input);
807
+ // Check file_path (for Read/Write/Edit)
806
808
  const fp = (data.tool_input && data.tool_input.file_path) || '';
807
- if (/\\.env$|\\.env\\.|secrets[\\/\\\\]|credentials|\\.pem$|\\.key$/i.test(fp)) {
809
+ // Check command (for Bash)
810
+ const cmd = (data.tool_input && data.tool_input.command) || '';
811
+
812
+ const secretPattern = /\\.env($|\\.)|secrets[\\/\\\\]|credentials|\\.pem$|\\.key$/i;
813
+ const bashSecretPattern = /\\bcat\\s+\\.env|\\bless\\s+\\.env|\\bhead\\s+\\.env|\\btail\\s+\\.env|\\bgrep\\b.*\\.env|\\bcp\\s+\\.env|\\bmv\\s+\\.env|\\bbase64\\s+\\.env|\\bxxd\\s+\\.env|secrets\\/|credentials|\\.pem\\b|\\.key\\b/i;
814
+
815
+ if (secretPattern.test(fp) || bashSecretPattern.test(cmd)) {
808
816
  console.log(JSON.stringify({ decision: 'block', reason: 'Blocked: accessing secret/credential files is not allowed.' }));
809
817
  } else {
810
818
  console.log(JSON.stringify({ decision: 'allow' }));
811
819
  }
812
820
  } catch (e) {
813
- console.log(JSON.stringify({ decision: 'allow' }));
821
+ console.log(JSON.stringify({ decision: 'block', reason: 'Hook error - blocking for safety' }));
814
822
  }
815
823
  });
816
824
  `,
@@ -1062,7 +1070,14 @@ Prepare a release candidate for: $ARGUMENTS
1062
1070
  - Mock external dependencies, not internal logic
1063
1071
  - Include both happy path and edge case tests
1064
1072
  `;
1065
- rules['repository.md'] = `When changing release, packaging, or workflow files:
1073
+ rules['repository.md'] = hasPython
1074
+ ? `When changing release, packaging, or workflow files:
1075
+ - Keep pyproject.toml (or requirements.txt), CHANGELOG.md, README.md, and docs in sync
1076
+ - Prefer tagged release references over floating branch references in public docs
1077
+ - Preserve backward compatibility in CLI flags where practical
1078
+ - Any automation that writes files must document rollback expectations
1079
+ `
1080
+ : `When changing release, packaging, or workflow files:
1066
1081
  - Keep package.json, CHANGELOG.md, README.md, and docs in sync
1067
1082
  - Prefer tagged release references over floating branch references in public docs
1068
1083
  - Preserve backward compatibility in CLI flags where practical
@@ -1115,6 +1130,18 @@ async function setup(options) {
1115
1130
  if (options.platform === 'codex') {
1116
1131
  return setupCodex(options);
1117
1132
  }
1133
+ if (options.platform === 'windsurf') {
1134
+ const { setupWindsurf } = require('./windsurf/setup');
1135
+ return setupWindsurf(options);
1136
+ }
1137
+ if (options.platform === 'aider') {
1138
+ const { setupAider } = require('./aider/setup');
1139
+ return setupAider(options);
1140
+ }
1141
+ if (options.platform === 'cursor') {
1142
+ const { setupCursor } = require('./cursor/setup');
1143
+ return setupCursor(options);
1144
+ }
1118
1145
 
1119
1146
  const ctx = new ProjectContext(options.dir);
1120
1147
  const stacks = ctx.detectStacks(STACKS);
@@ -1124,6 +1151,17 @@ async function setup(options) {
1124
1151
  const mcpPreflightWarnings = getMcpPackPreflight(options.mcpPacks || [])
1125
1152
  .filter(item => item.missingEnvVars.length > 0);
1126
1153
 
1154
+ // Snapshot settings.json before any changes for rollback support
1155
+ const settingsPathForSnapshot = path.join(options.dir, '.claude/settings.json');
1156
+ let settingsSnapshotBefore = null;
1157
+ if (fs.existsSync(settingsPathForSnapshot)) {
1158
+ try {
1159
+ settingsSnapshotBefore = fs.readFileSync(settingsPathForSnapshot, 'utf8');
1160
+ } catch (_) {
1161
+ // Ignore read errors
1162
+ }
1163
+ }
1164
+
1127
1165
  function log(message = '') {
1128
1166
  if (!silent) {
1129
1167
  console.log(message);
@@ -1218,21 +1256,50 @@ async function setup(options) {
1218
1256
  }
1219
1257
  }
1220
1258
 
1221
- // Auto-register hooks in settings if hooks were created but no settings exist
1259
+ // Auto-register hooks in settings always merge hooks into settings.json
1222
1260
  const hooksDir = path.join(options.dir, '.claude/hooks');
1223
1261
  const settingsPath = path.join(options.dir, '.claude/settings.json');
1224
- if (fs.existsSync(hooksDir) && !fs.existsSync(settingsPath)) {
1262
+ if (fs.existsSync(hooksDir)) {
1225
1263
  const hookFiles = fs.readdirSync(hooksDir).filter(f => f.endsWith('.sh') || f.endsWith('.js'));
1226
1264
  if (hookFiles.length > 0) {
1227
- const settings = buildSettingsForProfile({
1265
+ const newSettings = buildSettingsForProfile({
1228
1266
  profileKey: options.profile || 'safe-write',
1229
1267
  hookFiles,
1230
1268
  mcpPackKeys: options.mcpPacks || [],
1231
1269
  });
1232
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
1233
- writtenFiles.push('.claude/settings.json');
1234
- log(` \x1b[32m✅\x1b[0m Created .claude/settings.json (hooks registered)`);
1235
- created++;
1270
+ // Merge new settings into existing settings.json, preserving all fields
1271
+ let existingSettings = {};
1272
+ if (fs.existsSync(settingsPath)) {
1273
+ try {
1274
+ existingSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1275
+ } catch (_) {
1276
+ // If settings.json is malformed, start fresh
1277
+ existingSettings = {};
1278
+ }
1279
+ }
1280
+ // Merge all fields from newSettings into existing, preserving existing values
1281
+ if (newSettings.hooks) existingSettings.hooks = newSettings.hooks;
1282
+ if (newSettings.permissions) {
1283
+ existingSettings.permissions = existingSettings.permissions || {};
1284
+ // MERGE deny rules: keep existing + add new (deduplicate)
1285
+ const existingDeny = existingSettings.permissions.deny || [];
1286
+ const newDeny = newSettings.permissions.deny || [];
1287
+ existingSettings.permissions.deny = [...new Set([...existingDeny, ...newDeny])];
1288
+ // Only set defaultMode if not already set
1289
+ if (!existingSettings.permissions.defaultMode && newSettings.permissions.defaultMode) {
1290
+ existingSettings.permissions.defaultMode = newSettings.permissions.defaultMode;
1291
+ }
1292
+ }
1293
+ if (newSettings.mcpServers) existingSettings.mcpServers = { ...existingSettings.mcpServers, ...newSettings.mcpServers };
1294
+ if (newSettings.nerviqSetup) existingSettings.nerviqSetup = { ...existingSettings.nerviqSetup, ...newSettings.nerviqSetup };
1295
+ fs.writeFileSync(settingsPath, JSON.stringify(existingSettings, null, 2), 'utf8');
1296
+ if (!writtenFiles.includes('.claude/settings.json') && !preservedFiles.includes('.claude/settings.json')) {
1297
+ writtenFiles.push('.claude/settings.json');
1298
+ log(` \x1b[32m✅\x1b[0m Updated .claude/settings.json (hooks registered)`);
1299
+ created++;
1300
+ } else {
1301
+ log(` \x1b[32m✅\x1b[0m Merged hooks into existing .claude/settings.json`);
1302
+ }
1236
1303
  }
1237
1304
  }
1238
1305
 
@@ -1264,6 +1331,29 @@ async function setup(options) {
1264
1331
  log(' Run \x1b[1mnpx nerviq audit\x1b[0m to check your score.');
1265
1332
  log('');
1266
1333
 
1334
+ // Write rollback artifact so setup can be undone
1335
+ let rollbackId = null;
1336
+ if (writtenFiles.length > 0) {
1337
+ const patchedFiles = [];
1338
+ // If settings.json was modified (not newly created), record the before-snapshot
1339
+ if (settingsSnapshotBefore !== null && writtenFiles.includes('.claude/settings.json')) {
1340
+ patchedFiles.push({
1341
+ file: '.claude/settings.json',
1342
+ before: settingsSnapshotBefore,
1343
+ });
1344
+ }
1345
+ const rollbackArtifact = writeRollbackArtifact(options.dir, {
1346
+ sourcePlan: 'setup',
1347
+ createdFiles: writtenFiles.filter(f => {
1348
+ // Exclude patched files from createdFiles list
1349
+ return !patchedFiles.some(p => p.file === f);
1350
+ }),
1351
+ patchedFiles,
1352
+ rollbackInstructions: ['Use nerviq rollback to undo this setup'],
1353
+ });
1354
+ rollbackId = rollbackArtifact.id;
1355
+ }
1356
+
1267
1357
  return {
1268
1358
  created,
1269
1359
  skipped,
@@ -1271,6 +1361,7 @@ async function setup(options) {
1271
1361
  preservedFiles,
1272
1362
  stacks,
1273
1363
  mcpPreflightWarnings,
1364
+ rollbackId,
1274
1365
  };
1275
1366
  }
1276
1367
 
package/src/techniques.js CHANGED
@@ -759,7 +759,11 @@ const TECHNIQUES = {
759
759
  const settings = ctx.jsonFile('.claude/settings.json') || ctx.jsonFile('.claude/settings.local.json');
760
760
  if (!settings || !settings.permissions) return false;
761
761
  const deny = JSON.stringify(settings.permissions.deny || []);
762
- return deny.includes('.env') || deny.includes('secrets');
762
+ const hasDeny = deny.includes('.env') || deny.includes('secrets');
763
+ // Fail if allow includes "*" (overly broad — bypasses deny rules)
764
+ const allow = settings.permissions.allow || [];
765
+ if (Array.isArray(allow) && allow.includes('*')) return false;
766
+ return hasDeny;
763
767
  },
764
768
  impact: 'critical',
765
769
  rating: 5,
@@ -5474,7 +5478,7 @@ const STACKS = {
5474
5478
  ruby: { files: ['Gemfile'], content: {}, label: 'Ruby' },
5475
5479
  java: { files: ['pom.xml'], content: {}, label: 'Java' },
5476
5480
  kotlin: { files: ['build.gradle.kts'], content: {}, label: 'Kotlin' },
5477
- swift: { files: ['Package.swift'], content: {}, label: 'Swift' },
5481
+ swift: { files: ['Package.swift', '.xcodeproj'], content: {}, label: 'Swift' },
5478
5482
  terraform: { files: ['main.tf', 'terraform'], content: {}, label: 'Terraform' },
5479
5483
  kubernetes: { files: ['k8s', 'kubernetes', 'helm'], content: {}, label: 'Kubernetes' },
5480
5484
  cpp: { files: ['CMakeLists.txt', 'Makefile', '.clang-format'], content: {}, label: 'C++' },