@nerviq/cli 1.7.0 → 1.8.0

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
@@ -224,10 +224,12 @@ Levels:
224
224
 
225
225
  | Command | What it does |
226
226
  |---------|-------------|
227
- | `nerviq audit` | Score 0-100 against 2,431 checks |
228
- | `nerviq audit --lite` | Quick top-3 scan |
227
+ | `nerviq audit` | Score 0-100 quick scan with top 3 actions (default) |
228
+ | `nerviq audit --full` | Full audit with all checks, weakest areas, confidence labels |
229
229
  | `nerviq fix <key>` | Auto-fix a specific check (shows score impact) |
230
230
  | `nerviq fix --all-critical` | Fix all critical issues at once |
231
+ | `nerviq rollback` | Undo the most recent apply (delete created files) |
232
+ | `nerviq rollback --list` | Show available rollback points |
231
233
  | `nerviq setup` | Generate starter-safe CLAUDE.md + hooks + commands |
232
234
  | `nerviq augment` | Repo-aware improvement plan (no writes) |
233
235
  | `nerviq suggest-only` | Structured report for sharing |
@@ -235,6 +237,7 @@ Levels:
235
237
  | `nerviq apply` | Apply proposals with rollback |
236
238
  | `nerviq governance` | Permission profiles, hooks, policy packs |
237
239
  | `nerviq benchmark` | Before/after in isolated temp copy |
240
+ | `nerviq check-health` | Detect regressions between audit snapshots |
238
241
  | `nerviq deep-review` | AI-powered config review (opt-in) |
239
242
  | `nerviq interactive` | Step-by-step guided wizard |
240
243
  | `nerviq watch` | Live monitoring with score delta |
@@ -242,9 +245,13 @@ Levels:
242
245
  | `nerviq compare` | Compare latest vs previous |
243
246
  | `nerviq trend` | Export trend report |
244
247
  | `nerviq feedback` | Record recommendation outcomes |
248
+ | `nerviq anti-patterns` | Detect anti-patterns in current project |
249
+ | `nerviq freshness` | Show verification freshness for all checks |
250
+ | `nerviq rules-export` | Export recommendation rules (human summary or --json) |
245
251
  | `nerviq badge` | shields.io badge for README |
246
252
  | `nerviq certify` | Certification level + badge |
247
253
  | `nerviq scan dir1 dir2` | Compare multiple repos |
254
+ | `nerviq org scan dir1 dir2` | Aggregate multiple repos into one score table |
248
255
  | `nerviq harmony-audit` | Cross-platform DX audit |
249
256
  | `nerviq harmony-sync` | Sync config across platforms |
250
257
  | `nerviq harmony-drift` | Detect platform drift |
@@ -262,20 +269,24 @@ Levels:
262
269
 
263
270
  | Flag | Effect |
264
271
  |------|--------|
272
+ | `--full` | Full audit output (all checks, weakest areas, confidence labels) |
273
+ | `--verbose` | Full audit + medium-priority recommendations |
265
274
  | `--threshold N` | Exit 1 if score < N (for CI) |
266
275
  | `--json` | Machine-readable JSON output |
267
276
  | `--out FILE` | Write output to file |
268
277
  | `--snapshot` | Save audit snapshot for trending |
269
- | `--lite` | Compact top-3 quick scan |
270
- | `--dry-run` | Preview apply without writing |
278
+ | `--dry-run` | Preview changes without writing files |
279
+ | `--config-only` | Only write config files, never source code |
271
280
  | `--auto` | Apply without prompts |
272
- | `--verbose` | Show all recommendations |
281
+ | `--only A,B` | Limit apply to selected proposal IDs |
273
282
  | `--format sarif` | SARIF output for code scanning |
274
283
  | `--platform NAME` | Target platform (claude, codex, gemini, copilot, cursor, windsurf, aider, opencode) |
284
+ | `--workspace GLOB` | Audit workspaces separately (e.g. packages/*) |
285
+ | `--external PATH` | Benchmark an external repo |
275
286
 
276
287
  ## Backed by Research
277
288
 
278
- Nerviq is built on the CLAUDEX knowledge engine — the largest verified catalog of AI coding agent techniques:
289
+ Nerviq is built on the NERVIQ knowledge engine — the largest verified catalog of AI coding agent techniques:
279
290
 
280
291
  - **448+ research documents** covering all 8 platforms
281
292
  - **332+ experiments** with tested, rated results
@@ -312,6 +323,10 @@ Every write command supports `--snapshot` for automatic backup before changes.
312
323
  - **Website**: [nerviq.net](https://nerviq.net)
313
324
  - **Discord**: [Join the community](https://discord.gg/nerviq)
314
325
 
326
+ ---
327
+
328
+ If Nerviq helped you, consider giving it a ⭐ on [GitHub](https://github.com/nerviq/nerviq) — it helps others discover the project.
329
+
315
330
  ## What Nerviq Is — and Isn't
316
331
 
317
332
  **Strongest at:** Agent configuration, workflow governance, repo policy hygiene, cross-platform alignment, and setup standardization.
package/bin/cli.js CHANGED
@@ -26,7 +26,7 @@ const COMMAND_ALIASES = {
26
26
  gov: 'governance',
27
27
  outcome: 'feedback',
28
28
  };
29
- const KNOWN_COMMANDS = ['audit', 'org', 'setup', 'augment', 'suggest-only', 'plan', 'apply', 'fix', 'rollback', 'governance', 'benchmark', 'deep-review', 'interactive', 'watch', 'badge', 'insights', 'history', 'compare', 'trend', 'scan', 'feedback', 'doctor', 'convert', 'migrate', 'catalog', 'certify', 'serve', 'check-health', 'harmony-audit', 'harmony-sync', 'harmony-drift', 'harmony-advise', 'harmony-watch', 'harmony-governance', 'harmony-add', 'synergy-report', 'anti-patterns', 'rules-export', 'freshness', 'help', 'version'];
29
+ const KNOWN_COMMANDS = ['audit', 'org', 'setup', 'augment', 'suggest-only', 'plan', 'apply', 'fix', 'rollback', 'governance', 'benchmark', 'deep-review', 'interactive', 'watch', 'badge', 'insights', 'history', 'compare', 'trend', 'scan', 'feedback', 'doctor', 'convert', 'migrate', 'catalog', 'certify', 'serve', 'check-health', 'dashboard', 'harmony-audit', 'harmony-sync', 'harmony-drift', 'harmony-advise', 'harmony-watch', 'harmony-governance', 'harmony-add', 'synergy-report', 'anti-patterns', 'rules-export', 'freshness', 'help', 'version'];
30
30
 
31
31
  function levenshtein(a, b) {
32
32
  const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
@@ -88,6 +88,7 @@ function parseArgs(rawArgs) {
88
88
  let migrateTo = null;
89
89
  let checkVersion = null;
90
90
  let external = null;
91
+ let repos = [];
91
92
 
92
93
  for (let i = 0; i < rawArgs.length; i++) {
93
94
  const arg = rawArgs[i];
@@ -128,6 +129,22 @@ function parseArgs(rawArgs) {
128
129
  continue;
129
130
  }
130
131
 
132
+ if (arg === '--repos') {
133
+ // Collect all following non-flag args as repo paths (supports comma-separated too)
134
+ while (i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('--')) {
135
+ i++;
136
+ repos.push(...rawArgs[i].split(',').map(s => s.trim()).filter(Boolean));
137
+ }
138
+ if (repos.length === 0) throw new Error('--repos requires at least one path');
139
+ continue;
140
+ }
141
+
142
+ if (arg.startsWith('--repos=')) {
143
+ repos = arg.split('=').slice(1).join('=').split(',').map(s => s.trim()).filter(Boolean);
144
+ if (repos.length === 0) throw new Error('--repos requires at least one path');
145
+ continue;
146
+ }
147
+
131
148
  if (arg.startsWith('--require=')) {
132
149
  requireChecks = arg.split('=').slice(1).join('=').split(',').map(item => item.trim()).filter(Boolean);
133
150
  continue;
@@ -233,7 +250,7 @@ function parseArgs(rawArgs) {
233
250
 
234
251
  const normalizedCommand = COMMAND_ALIASES[command] || command;
235
252
 
236
- return { flags, command, normalizedCommand, threshold, out, planFile, only, profile, mcpPacks, requireChecks, feedbackKey, feedbackStatus, feedbackEffect, feedbackNotes, feedbackSource, feedbackScoreDelta, platform, format, port, workspace, extraArgs, convertFrom, convertTo, migrateFrom, migrateTo, checkVersion, webhookUrl, external };
253
+ return { flags, command, normalizedCommand, threshold, out, planFile, only, profile, mcpPacks, requireChecks, feedbackKey, feedbackStatus, feedbackEffect, feedbackNotes, feedbackSource, feedbackScoreDelta, platform, format, port, workspace, extraArgs, convertFrom, convertTo, migrateFrom, migrateTo, checkVersion, webhookUrl, external, repos };
237
254
  }
238
255
 
239
256
  function printWorkspaceSummary(summary, options) {
@@ -345,6 +362,9 @@ const HELP = `
345
362
  nerviq migrate --platform cursor --from v2 --to v3
346
363
 
347
364
  MONITOR
365
+ nerviq dashboard Generate static HTML dashboard report
366
+ nerviq dashboard --out F Save dashboard to custom file
367
+ nerviq dashboard --open Open dashboard in browser after generating
348
368
  nerviq watch Live config monitoring (re-audits on file change)
349
369
  nerviq history Score history from saved snapshots
350
370
  nerviq compare Latest vs previous snapshot diff
@@ -380,6 +400,7 @@ const HELP = `
380
400
  --dry-run Preview changes without writing files
381
401
  --config-only Only write config files (.claude/, rules, hooks) — never source code
382
402
  --verbose Full audit + medium-priority recommendations
403
+ --show-deprecated Show deprecated checks (excluded from scoring)
383
404
  --json Output as JSON
384
405
  --auto Apply all generated files without prompting
385
406
  --key NAME Feedback: recommendation key (e.g. permissionDeny)
@@ -439,6 +460,7 @@ async function main() {
439
460
  auto: flags.includes('--auto'),
440
461
  lite: flags.includes('--full') || flags.includes('--verbose') ? false : true,
441
462
  full: flags.includes('--full'),
463
+ showDeprecated: flags.includes('--show-deprecated'),
442
464
  snapshot: flags.includes('--snapshot'),
443
465
  feedback: flags.includes('--feedback'),
444
466
  fix: flags.includes('--fix'),
@@ -1165,6 +1187,21 @@ async function main() {
1165
1187
  console.log(`\n Use --json for full output or --out <file> to save.\n`);
1166
1188
  }
1167
1189
  process.exit(0);
1190
+ } else if (normalizedCommand === 'dashboard') {
1191
+ const dashFlags = {
1192
+ out: options.out,
1193
+ open: flags.includes('--open'),
1194
+ json: options.json,
1195
+ platform: options.platform,
1196
+ };
1197
+ if (parsed.repos && parsed.repos.length > 0) {
1198
+ const { generatePortfolioDashboard } = require('../src/dashboard');
1199
+ await generatePortfolioDashboard(parsed.repos, dashFlags);
1200
+ } else {
1201
+ const { generateDashboard } = require('../src/dashboard');
1202
+ await generateDashboard(options.dir, dashFlags);
1203
+ }
1204
+ process.exit(0);
1168
1205
  } else if (normalizedCommand === 'check-health') {
1169
1206
  const { checkHealth, formatCheckHealth } = require('../src/activity');
1170
1207
  const report = checkHealth(options.dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nerviq/cli",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "The intelligent nervous system for AI coding agents — 2,431 checks across 8 platforms, 10 languages, and 62 domain packs. Audit, align, and amplify.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/audit.js CHANGED
@@ -882,6 +882,31 @@ function printLiteAudit(result, dir) {
882
882
  }
883
883
  console.log('');
884
884
  console.log(` Score: ${colorize(`${result.score}/100`, 'bold')} (${result.passed}/${result.passed + result.failed} checks passing)`);
885
+
886
+ // Score explanation line (lite mode only)
887
+ const _critCount = (result.results || []).filter(r => r.passed === false && r.impact === 'critical').length;
888
+ const _highCount = (result.results || []).filter(r => r.passed === false && r.impact === 'high').length;
889
+ let scoreExplanation;
890
+ if (result.score >= 90) {
891
+ scoreExplanation = 'Excellent setup — production-ready governance';
892
+ } else if (result.score >= 70) {
893
+ scoreExplanation = `Strong setup — ${_critCount} critical items to address`;
894
+ } else if (result.score >= 50) {
895
+ scoreExplanation = `Good foundation — ${_critCount + _highCount} items need attention`;
896
+ } else if (result.score >= 30) {
897
+ // Find weakest category (most failures)
898
+ const catFailures = {};
899
+ (result.results || []).filter(r => r.passed === false).forEach(r => {
900
+ const cat = r.category || 'unknown';
901
+ catFailures[cat] = (catFailures[cat] || 0) + 1;
902
+ });
903
+ const weakestCategory = Object.keys(catFailures).sort((a, b) => catFailures[b] - catFailures[a])[0] || 'config';
904
+ scoreExplanation = `Basic setup — significant gaps in ${weakestCategory}`;
905
+ } else {
906
+ scoreExplanation = 'Early stage — run `nerviq setup` to bootstrap your config';
907
+ }
908
+ console.log(colorize(` ${scoreExplanation}`, 'dim'));
909
+
885
910
  if (result.platformScopeNote) {
886
911
  console.log(colorize(` Scope: ${result.platformScopeNote.message}`, 'dim'));
887
912
  }
@@ -908,6 +933,7 @@ function printLiteAudit(result, dir) {
908
933
  if (result.platform === 'codex') {
909
934
  console.log(colorize(' Note: Codex now supports no-write advisory flows via augment and suggest-only before setup/apply.', 'dim'));
910
935
  }
936
+ console.log(colorize(' Star: github.com/nerviq/nerviq | Discord: discord.gg/nerviq', 'dim'));
911
937
  console.log('');
912
938
  return;
913
939
  }
@@ -938,6 +964,7 @@ function printLiteAudit(result, dir) {
938
964
  console.log(colorize(' Note: Codex now supports no-write advisory flows via augment and suggest-only before setup/apply.', 'dim'));
939
965
  }
940
966
  console.log(colorize(` See all ${result.failed} failed checks: ${colorize('nerviq audit --full', 'bold')}`, 'dim'));
967
+ console.log(colorize(' Star: github.com/nerviq/nerviq | Discord: discord.gg/nerviq', 'dim'));
941
968
  console.log('');
942
969
  }
943
970
 
@@ -949,6 +976,7 @@ function printLiteAudit(result, dir) {
949
976
  * @param {boolean} [options.json] - Output result as JSON.
950
977
  * @param {boolean} [options.lite] - Show short top-3 quick scan.
951
978
  * @param {boolean} [options.verbose] - Show all recommendations including medium-impact.
979
+ * @param {boolean} [options.showDeprecated] - Include deprecated checks in output.
952
980
  * @returns {Promise<Object>} Audit result with score, passed/failed counts, quickWins, and topNextActions.
953
981
  */
954
982
  async function audit(options) {
@@ -1033,9 +1061,14 @@ async function audit(options) {
1033
1061
  });
1034
1062
  }
1035
1063
 
1064
+ // Separate deprecated checks from active checks.
1065
+ // Deprecated checks are excluded from scoring but preserved for display.
1066
+ const deprecated = results.filter(r => r.deprecated === true);
1067
+ const activeResults = results.filter(r => r.deprecated !== true);
1068
+
1036
1069
  // null = not applicable (skip), true = pass, false = fail
1037
- const applicable = results.filter(r => r.passed !== null);
1038
- const skipped = results.filter(r => r.passed === null);
1070
+ const applicable = activeResults.filter(r => r.passed !== null);
1071
+ const skipped = activeResults.filter(r => r.passed === null);
1039
1072
  const passed = applicable.filter(r => r.passed);
1040
1073
  const failed = applicable.filter(r => !r.passed);
1041
1074
  const critical = failed.filter(r => r.impact === 'critical');
@@ -1115,9 +1148,17 @@ async function audit(options) {
1115
1148
  passed: passed.length,
1116
1149
  failed: failed.length,
1117
1150
  skipped: skipped.length,
1151
+ deprecated: deprecated.length,
1118
1152
  checkCount: applicable.length,
1119
1153
  stacks,
1120
1154
  results,
1155
+ deprecatedChecks: deprecated.map(r => ({
1156
+ key: r.key,
1157
+ name: r.name,
1158
+ category: r.category,
1159
+ deprecatedReason: r.deprecatedReason || null,
1160
+ sunsetDate: r.sunsetDate || null,
1161
+ })),
1121
1162
  categoryScores,
1122
1163
  quickWins: quickWins.map(({ key, name, impact, fix, category, sourceUrl }) => ({ key, name, impact, category, fix, sourceUrl })),
1123
1164
  topNextActions,
@@ -1264,6 +1305,17 @@ async function audit(options) {
1264
1305
  console.log('');
1265
1306
  }
1266
1307
 
1308
+ // Deprecated checks (shown with --show-deprecated or --full)
1309
+ if (deprecated.length > 0 && (options.showDeprecated || options.full)) {
1310
+ console.log(colorize(` ⏳ Deprecated (${deprecated.length} checks excluded from scoring)`, 'dim'));
1311
+ for (const r of deprecated) {
1312
+ const reason = r.deprecatedReason ? ` — ${r.deprecatedReason}` : '';
1313
+ const sunset = r.sunsetDate ? ` (sunset: ${r.sunsetDate})` : '';
1314
+ console.log(colorize(` [DEPRECATED] ${r.name}${reason}${sunset}`, 'dim'));
1315
+ }
1316
+ console.log('');
1317
+ }
1318
+
1267
1319
  // Failed - by priority
1268
1320
  if (critical.length > 0) {
1269
1321
  console.log(colorize(' 🔴 Critical (fix immediately)', 'red'));
@@ -1331,7 +1383,8 @@ async function audit(options) {
1331
1383
 
1332
1384
  // Summary
1333
1385
  console.log(colorize(' ─────────────────────────────────────', 'dim'));
1334
- console.log(` ${colorize(`${passed.length}/${applicable.length}`, 'bold')} checks passing${skipped.length > 0 ? colorize(` (${skipped.length} not applicable)`, 'dim') : ''}`);
1386
+ const deprecatedNote = deprecated.length > 0 ? colorize(`, ${deprecated.length} deprecated`, 'dim') : '';
1387
+ console.log(` ${colorize(`${passed.length}/${applicable.length}`, 'bold')} checks passing${skipped.length > 0 ? colorize(` (${skipped.length} not applicable${deprecatedNote})`, 'dim') : (deprecatedNote ? colorize(` (${deprecatedNote})`, 'dim') : '')}`);
1335
1388
 
1336
1389
  if (failed.length > 0) {
1337
1390
  console.log(` Next command: ${colorize(result.suggestedNextCommand, 'bold')}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "synced_from": "claudex",
3
- "synced_at": "2026-04-06T08:01:13Z",
3
+ "synced_at": "2026-04-06T12:45:38Z",
4
4
  "total_items": 1118,
5
5
  "tested": 948,
6
- "last_id": 1137
6
+ "last_id": 1168
7
7
  }
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Dashboard generator — produces a self-contained HTML report from audit snapshots.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { version } = require('../package.json');
8
+ const { readSnapshotIndex, getHistory, loadSnapshotPayload } = require('./activity');
9
+
10
+ const COLORS = {
11
+ bg: '#0a0a0a',
12
+ surface: '#18181b',
13
+ border: '#27272a',
14
+ text: '#e4e4e7',
15
+ textDim: '#a1a1aa',
16
+ green: '#22c55e',
17
+ red: '#ef4444',
18
+ yellow: '#eab308',
19
+ blue: '#3b82f6',
20
+ };
21
+
22
+ function scoreColor(score) {
23
+ if (score >= 70) return COLORS.green;
24
+ if (score >= 40) return COLORS.yellow;
25
+ return COLORS.red;
26
+ }
27
+
28
+ function escapeHtml(str) {
29
+ return String(str)
30
+ .replace(/&/g, '&amp;')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;');
34
+ }
35
+
36
+ function buildScoreOverTimeSvg(history) {
37
+ if (!history || history.length < 2) return '';
38
+ const entries = history.slice().reverse(); // oldest first
39
+ const w = 600, h = 200, pad = 40;
40
+ const plotW = w - pad * 2, plotH = h - pad * 2;
41
+ const n = entries.length;
42
+ const step = n > 1 ? plotW / (n - 1) : 0;
43
+
44
+ const points = entries.map((e, i) => {
45
+ const x = pad + i * step;
46
+ const score = e.summary?.score ?? 0;
47
+ const y = pad + plotH - (score / 100) * plotH;
48
+ return { x, y, score, date: (e.createdAt || '').split('T')[0] };
49
+ });
50
+
51
+ const polyline = points.map(p => `${p.x},${p.y}`).join(' ');
52
+ const dots = points.map(p =>
53
+ `<circle cx="${p.x}" cy="${p.y}" r="4" fill="${scoreColor(p.score)}"/>`
54
+ + `<title>${p.date}: ${p.score}/100</title>`
55
+ ).join('\n ');
56
+
57
+ // Y-axis labels
58
+ const yLabels = [0, 25, 50, 75, 100].map(v => {
59
+ const y = pad + plotH - (v / 100) * plotH;
60
+ return `<text x="${pad - 8}" y="${y + 4}" text-anchor="end" fill="${COLORS.textDim}" font-size="11">${v}</text>`
61
+ + `<line x1="${pad}" y1="${y}" x2="${w - pad}" y2="${y}" stroke="${COLORS.border}" stroke-dasharray="4"/>`;
62
+ }).join('\n ');
63
+
64
+ // X-axis: first and last date
65
+ const first = points[0], last = points[points.length - 1];
66
+ const xLabels = `<text x="${first.x}" y="${h - 8}" text-anchor="start" fill="${COLORS.textDim}" font-size="11">${first.date}</text>`
67
+ + `<text x="${last.x}" y="${h - 8}" text-anchor="end" fill="${COLORS.textDim}" font-size="11">${last.date}</text>`;
68
+
69
+ return `
70
+ <div class="card">
71
+ <h2>Score Over Time</h2>
72
+ <svg viewBox="0 0 ${w} ${h}" width="100%" style="max-width:${w}px">
73
+ ${yLabels}
74
+ <polyline points="${polyline}" fill="none" stroke="${COLORS.blue}" stroke-width="2"/>
75
+ ${dots}
76
+ ${xLabels}
77
+ </svg>
78
+ </div>`;
79
+ }
80
+
81
+ function buildCategoryBreakdownSvg(results) {
82
+ if (!results || results.length === 0) return '';
83
+ const cats = {};
84
+ for (const r of results) {
85
+ if (r.passed === null) continue;
86
+ const cat = r.category || 'other';
87
+ if (!cats[cat]) cats[cat] = { pass: 0, total: 0 };
88
+ cats[cat].total++;
89
+ if (r.passed) cats[cat].pass++;
90
+ }
91
+ const sorted = Object.entries(cats).sort((a, b) => {
92
+ const rateA = a[1].total > 0 ? a[1].pass / a[1].total : 0;
93
+ const rateB = b[1].total > 0 ? b[1].pass / b[1].total : 0;
94
+ return rateA - rateB;
95
+ });
96
+ if (sorted.length === 0) return '';
97
+
98
+ const barH = 28, gap = 6, labelW = 160, barMaxW = 360, padR = 60;
99
+ const svgH = sorted.length * (barH + gap) + 10;
100
+ const svgW = labelW + barMaxW + padR;
101
+
102
+ const bars = sorted.map(([cat, data], i) => {
103
+ const rate = data.total > 0 ? data.pass / data.total : 0;
104
+ const pct = Math.round(rate * 100);
105
+ const barW = Math.max(2, rate * barMaxW);
106
+ const y = i * (barH + gap) + 4;
107
+ const color = pct >= 70 ? COLORS.green : pct >= 40 ? COLORS.yellow : COLORS.red;
108
+ return `<text x="${labelW - 8}" y="${y + barH / 2 + 4}" text-anchor="end" fill="${COLORS.text}" font-size="13">${escapeHtml(cat)}</text>`
109
+ + `<rect x="${labelW}" y="${y}" width="${barW}" height="${barH}" rx="4" fill="${color}" opacity="0.85"/>`
110
+ + `<text x="${labelW + barW + 8}" y="${y + barH / 2 + 4}" fill="${COLORS.textDim}" font-size="12">${pct}% (${data.pass}/${data.total})</text>`;
111
+ }).join('\n ');
112
+
113
+ return `
114
+ <div class="card">
115
+ <h2>Category Breakdown</h2>
116
+ <svg viewBox="0 0 ${svgW} ${svgH}" width="100%" style="max-width:${svgW}px">
117
+ ${bars}
118
+ </svg>
119
+ </div>`;
120
+ }
121
+
122
+ function buildHtml(projectName, auditPayload, history) {
123
+ const score = auditPayload.score ?? 0;
124
+ const platform = auditPayload.platform || 'unknown';
125
+ const results = auditPayload.results || [];
126
+ const timestamp = new Date().toISOString();
127
+
128
+ // Top 5 failed checks sorted by impact severity
129
+ const impactOrder = { critical: 0, high: 1, medium: 2, low: 3 };
130
+ const failed = results
131
+ .filter(r => r.passed === false)
132
+ .sort((a, b) => (impactOrder[a.impact] ?? 9) - (impactOrder[b.impact] ?? 9))
133
+ .slice(0, 5);
134
+
135
+ const failedRows = failed.length > 0
136
+ ? failed.map(r =>
137
+ `<tr><td>${escapeHtml(r.name || r.key)}</td><td class="impact-${r.impact || 'medium'}">${escapeHtml(r.impact || '-')}</td><td>${escapeHtml(r.category || '-')}</td></tr>`
138
+ ).join('\n ')
139
+ : '<tr><td colspan="3" style="text-align:center;color:' + COLORS.green + '">All checks passing!</td></tr>';
140
+
141
+ const scoreOverTime = buildScoreOverTimeSvg(history);
142
+ const categoryBreakdown = buildCategoryBreakdownSvg(results);
143
+ const drifts = detectDrifts(history);
144
+ const driftAlerts = buildDriftAlertsHtml(drifts);
145
+
146
+ const detectedPlatforms = auditPayload.detectedPlatforms
147
+ || (auditPayload.platform ? [auditPayload.platform] : ['unknown']);
148
+ const platformList = (Array.isArray(detectedPlatforms) ? detectedPlatforms : [detectedPlatforms])
149
+ .map(p => `<span class="badge">${escapeHtml(p)}</span>`).join(' ');
150
+
151
+ return `<!DOCTYPE html>
152
+ <html lang="en">
153
+ <head>
154
+ <meta charset="utf-8"/>
155
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
156
+ <title>Nerviq Dashboard — ${escapeHtml(projectName)}</title>
157
+ <style>
158
+ *{margin:0;padding:0;box-sizing:border-box}
159
+ body{background:${COLORS.bg};color:${COLORS.text};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:2rem;max-width:900px;margin:0 auto}
160
+ h1{font-size:1.6rem;margin-bottom:.3rem}
161
+ h2{font-size:1.1rem;margin-bottom:1rem;color:${COLORS.textDim}}
162
+ .timestamp{color:${COLORS.textDim};font-size:.85rem;margin-bottom:2rem}
163
+ .card{background:${COLORS.surface};border:1px solid ${COLORS.border};border-radius:12px;padding:1.5rem;margin-bottom:1.5rem}
164
+ .score-card{text-align:center;padding:2rem}
165
+ .score-number{font-size:4rem;font-weight:800;line-height:1}
166
+ .score-label{color:${COLORS.textDim};font-size:1rem;margin-top:.5rem}
167
+ .badge{display:inline-block;background:${COLORS.border};padding:3px 10px;border-radius:6px;font-size:.85rem;margin:2px}
168
+ table{width:100%;border-collapse:collapse}
169
+ th,td{text-align:left;padding:8px 12px;border-bottom:1px solid ${COLORS.border}}
170
+ th{color:${COLORS.textDim};font-weight:600;font-size:.85rem;text-transform:uppercase;letter-spacing:.03em}
171
+ .impact-critical{color:${COLORS.red};font-weight:700}
172
+ .impact-high{color:${COLORS.red}}
173
+ .impact-medium{color:${COLORS.yellow}}
174
+ .impact-low{color:${COLORS.textDim}}
175
+ .footer{text-align:center;color:${COLORS.textDim};font-size:.8rem;margin-top:2rem;padding-top:1rem;border-top:1px solid ${COLORS.border}}
176
+ .footer a{color:${COLORS.blue};text-decoration:none}
177
+ .footer a:hover{text-decoration:underline}
178
+ svg text{font-family:inherit}
179
+ </style>
180
+ </head>
181
+ <body>
182
+ <h1>Nerviq Dashboard &mdash; ${escapeHtml(projectName)}</h1>
183
+ <div class="timestamp">Generated ${timestamp}</div>
184
+
185
+ <div class="card score-card">
186
+ <div class="score-number" style="color:${scoreColor(score)}">${score}</div>
187
+ <div class="score-label">out of 100</div>
188
+ </div>
189
+
190
+ <div class="card">
191
+ <h2>Platforms Detected</h2>
192
+ ${platformList}
193
+ </div>
194
+
195
+ <div class="card">
196
+ <h2>Top Failed Checks</h2>
197
+ <table>
198
+ <thead><tr><th>Check</th><th>Impact</th><th>Category</th></tr></thead>
199
+ <tbody>
200
+ ${failedRows}
201
+ </tbody>
202
+ </table>
203
+ </div>
204
+
205
+ ${scoreOverTime}
206
+ ${driftAlerts}
207
+ ${categoryBreakdown}
208
+
209
+ <div class="footer">
210
+ Generated by <a href="https://github.com/nerviq/cli">Nerviq v${version}</a>
211
+ </div>
212
+ </body>
213
+ </html>`;
214
+ }
215
+
216
+ /**
217
+ * Generate a static HTML dashboard report.
218
+ * @param {string} dir - Project root directory.
219
+ * @param {Object} flags - CLI flags (--out, --open, --json, etc).
220
+ */
221
+ async function generateDashboard(dir, flags = {}) {
222
+ const outputFile = flags.out || 'nerviq-dashboard.html';
223
+ const outputPath = path.isAbsolute(outputFile) ? outputFile : path.join(dir, outputFile);
224
+ const projectName = path.basename(dir);
225
+
226
+ // Collect audit history from snapshots
227
+ const history = getHistory(dir, 50);
228
+ let auditPayload = null;
229
+
230
+ if (history.length > 0) {
231
+ // Load the most recent audit snapshot
232
+ auditPayload = loadSnapshotPayload(dir, history[0]);
233
+ }
234
+
235
+ if (!auditPayload) {
236
+ // No snapshots — run a fresh audit
237
+ const { audit } = require('./audit');
238
+ auditPayload = await audit({ dir, silent: true, platform: flags.platform || 'claude' });
239
+ }
240
+
241
+ const html = buildHtml(projectName, auditPayload, history);
242
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
243
+ fs.writeFileSync(outputPath, html, 'utf8');
244
+
245
+ const relPath = path.relative(dir, outputPath);
246
+ if (!flags.json) {
247
+ console.log('');
248
+ console.log(' nerviq dashboard');
249
+ console.log(' \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550');
250
+ console.log(` Score: ${auditPayload.score ?? '?'}/100`);
251
+ console.log(` Snapshots: ${history.length}`);
252
+ console.log(` Output: ${relPath}`);
253
+ console.log('');
254
+ }
255
+
256
+ if (flags.open) {
257
+ const { exec } = require('child_process');
258
+ const cmd = process.platform === 'win32' ? `start "" "${outputPath}"`
259
+ : process.platform === 'darwin' ? `open "${outputPath}"`
260
+ : `xdg-open "${outputPath}"`;
261
+ exec(cmd);
262
+ }
263
+
264
+ return { outputPath, relativePath: relPath, score: auditPayload.score };
265
+ }
266
+
267
+ /**
268
+ * Detect score drift between recent snapshots.
269
+ * Returns array of { from, to, delta, date } for drifts > threshold.
270
+ */
271
+ function detectDrifts(history, threshold = 5) {
272
+ if (!history || history.length < 2) return [];
273
+ const drifts = [];
274
+ for (let i = 0; i < history.length - 1; i++) {
275
+ const current = history[i];
276
+ const previous = history[i + 1];
277
+ if (current.score != null && previous.score != null) {
278
+ const delta = current.score - previous.score;
279
+ if (Math.abs(delta) >= threshold) {
280
+ drifts.push({
281
+ date: current.date || current.timestamp,
282
+ from: previous.score,
283
+ to: current.score,
284
+ delta,
285
+ });
286
+ }
287
+ }
288
+ }
289
+ return drifts;
290
+ }
291
+
292
+ /**
293
+ * Build drift alerts HTML section for the dashboard.
294
+ */
295
+ function buildDriftAlertsHtml(drifts) {
296
+ if (!drifts.length) return '';
297
+ const rows = drifts.map(d => {
298
+ const color = d.delta < 0 ? COLORS.red : COLORS.green;
299
+ const arrow = d.delta < 0 ? '▼' : '▲';
300
+ const sign = d.delta > 0 ? '+' : '';
301
+ return `<tr>
302
+ <td style="padding:8px 12px;border-bottom:1px solid ${COLORS.border}">${escapeHtml(d.date)}</td>
303
+ <td style="padding:8px 12px;border-bottom:1px solid ${COLORS.border}">${d.from} → ${d.to}</td>
304
+ <td style="padding:8px 12px;border-bottom:1px solid ${COLORS.border};color:${color};font-weight:bold">${arrow} ${sign}${d.delta}</td>
305
+ </tr>`;
306
+ }).join('');
307
+
308
+ return `
309
+ <div style="margin-top:32px">
310
+ <h2 style="color:${COLORS.text};font-size:18px;margin-bottom:12px">⚠ Score Drift Alerts</h2>
311
+ <p style="color:${COLORS.textDim};font-size:13px;margin-bottom:12px">Changes of 5+ points between consecutive snapshots</p>
312
+ <table style="width:100%;border-collapse:collapse;background:${COLORS.surface};border-radius:8px;overflow:hidden">
313
+ <thead><tr style="background:${COLORS.border}">
314
+ <th style="padding:8px 12px;text-align:left;color:${COLORS.textDim};font-size:12px">Date</th>
315
+ <th style="padding:8px 12px;text-align:left;color:${COLORS.textDim};font-size:12px">Score Change</th>
316
+ <th style="padding:8px 12px;text-align:left;color:${COLORS.textDim};font-size:12px">Delta</th>
317
+ </tr></thead>
318
+ <tbody>${rows}</tbody>
319
+ </table>
320
+ </div>`;
321
+ }
322
+
323
+ /**
324
+ * Build a portfolio HTML page summarizing multiple repos.
325
+ */
326
+ function buildPortfolioHtml(repoResults) {
327
+ const timestamp = new Date().toISOString();
328
+ const scores = repoResults.map(r => r.score);
329
+ const avgScore = repoResults.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
330
+ const weakest = repoResults.reduce((a, b) => a.score <= b.score ? a : b, repoResults[0]);
331
+ const strongest = repoResults.reduce((a, b) => a.score >= b.score ? a : b, repoResults[0]);
332
+
333
+ const rows = repoResults.map(r => {
334
+ const indicator = r.score >= 70 ? '\u{1F7E2}' : r.score >= 40 ? '\u{1F7E1}' : '\u{1F534}';
335
+ const platforms = (r.platforms || ['unknown']).map(p => `<span class="badge">${escapeHtml(p)}</span>`).join(' ');
336
+ const isWeak = r.name === weakest.name && repoResults.length > 1;
337
+ const isStrong = r.name === strongest.name && repoResults.length > 1;
338
+ const highlight = isWeak ? ' style="border-left:3px solid ' + COLORS.red + '"'
339
+ : isStrong ? ' style="border-left:3px solid ' + COLORS.green + '"' : '';
340
+ return `<tr${highlight}><td>${escapeHtml(r.name)}</td><td>${platforms}</td><td style="color:${scoreColor(r.score)};font-weight:700">${r.score}</td><td>${r.critical}</td><td>${r.high}</td><td>${indicator}</td></tr>`;
341
+ }).join('\n ');
342
+
343
+ return `<!DOCTYPE html>
344
+ <html lang="en">
345
+ <head>
346
+ <meta charset="utf-8"/>
347
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
348
+ <title>Nerviq Portfolio Dashboard</title>
349
+ <style>
350
+ *{margin:0;padding:0;box-sizing:border-box}
351
+ body{background:${COLORS.bg};color:${COLORS.text};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:2rem;max-width:1000px;margin:0 auto}
352
+ h1{font-size:1.6rem;margin-bottom:.3rem}
353
+ h2{font-size:1.1rem;margin-bottom:1rem;color:${COLORS.textDim}}
354
+ .timestamp{color:${COLORS.textDim};font-size:.85rem;margin-bottom:2rem}
355
+ .card{background:${COLORS.surface};border:1px solid ${COLORS.border};border-radius:12px;padding:1.5rem;margin-bottom:1.5rem}
356
+ .score-card{text-align:center;padding:2rem}
357
+ .score-number{font-size:4rem;font-weight:800;line-height:1}
358
+ .score-label{color:${COLORS.textDim};font-size:1rem;margin-top:.5rem}
359
+ .badge{display:inline-block;background:${COLORS.border};padding:3px 10px;border-radius:6px;font-size:.85rem;margin:2px}
360
+ .highlights{display:flex;gap:1.5rem;margin-bottom:1.5rem}
361
+ .highlights .card{flex:1;text-align:center}
362
+ .highlights .label{color:${COLORS.textDim};font-size:.85rem;margin-bottom:.5rem}
363
+ .highlights .value{font-size:1.3rem;font-weight:700}
364
+ table{width:100%;border-collapse:collapse}
365
+ th,td{text-align:left;padding:8px 12px;border-bottom:1px solid ${COLORS.border}}
366
+ th{color:${COLORS.textDim};font-weight:600;font-size:.85rem;text-transform:uppercase;letter-spacing:.03em}
367
+ .footer{text-align:center;color:${COLORS.textDim};font-size:.8rem;margin-top:2rem;padding-top:1rem;border-top:1px solid ${COLORS.border}}
368
+ .footer a{color:${COLORS.blue};text-decoration:none}
369
+ .footer a:hover{text-decoration:underline}
370
+ </style>
371
+ </head>
372
+ <body>
373
+ <h1>Nerviq Portfolio Dashboard</h1>
374
+ <div class="timestamp">${repoResults.length} repos &mdash; Generated ${timestamp}</div>
375
+
376
+ <div class="card score-card">
377
+ <div class="score-number" style="color:${scoreColor(avgScore)}">${avgScore}</div>
378
+ <div class="score-label">average score across ${repoResults.length} repos</div>
379
+ </div>
380
+
381
+ <div class="highlights">
382
+ <div class="card">
383
+ <div class="label">Strongest Repo</div>
384
+ <div class="value" style="color:${COLORS.green}">${escapeHtml(strongest.name)} (${strongest.score})</div>
385
+ </div>
386
+ <div class="card">
387
+ <div class="label">Weakest Repo</div>
388
+ <div class="value" style="color:${COLORS.red}">${escapeHtml(weakest.name)} (${weakest.score})</div>
389
+ </div>
390
+ </div>
391
+
392
+ <div class="card">
393
+ <h2>Repository Summary</h2>
394
+ <table>
395
+ <thead><tr><th>Repo</th><th>Platform(s)</th><th>Score</th><th>Critical</th><th>High</th><th>Status</th></tr></thead>
396
+ <tbody>
397
+ ${rows}
398
+ </tbody>
399
+ </table>
400
+ </div>
401
+
402
+ <div class="footer">
403
+ Generated by <a href="https://github.com/nerviq/cli">Nerviq v${version}</a>
404
+ </div>
405
+ </body>
406
+ </html>`;
407
+ }
408
+
409
+ /**
410
+ * Generate a portfolio dashboard across multiple repos.
411
+ * @param {string[]} repoPaths - Array of repo directory paths.
412
+ * @param {Object} flags - CLI flags (--out, --open, --json, --platform).
413
+ */
414
+ async function generatePortfolioDashboard(repoPaths, flags = {}) {
415
+ const { audit } = require('./audit');
416
+ const repoResults = [];
417
+
418
+ for (const repoPath of repoPaths) {
419
+ const absPath = path.isAbsolute(repoPath) ? repoPath : path.resolve(repoPath);
420
+ if (!fs.existsSync(absPath)) {
421
+ console.error(` Warning: skipping ${repoPath} (not found)`);
422
+ continue;
423
+ }
424
+ const name = path.basename(absPath);
425
+ try {
426
+ const result = await audit({ dir: absPath, silent: true, platform: flags.platform || 'claude' });
427
+ const results = result.results || [];
428
+ const critical = results.filter(r => !r.passed && r.impact === 'critical').length;
429
+ const high = results.filter(r => !r.passed && r.impact === 'high').length;
430
+ const platforms = result.detectedPlatforms || (result.platform ? [result.platform] : ['unknown']);
431
+ repoResults.push({ name, score: result.score ?? 0, platforms, critical, high });
432
+ } catch (err) {
433
+ console.error(` Warning: audit failed for ${name}: ${err.message}`);
434
+ repoResults.push({ name, score: 0, platforms: ['error'], critical: 0, high: 0 });
435
+ }
436
+ }
437
+
438
+ if (repoResults.length === 0) {
439
+ console.error('\n Error: no valid repos found.\n');
440
+ process.exit(1);
441
+ }
442
+
443
+ const outputFile = flags.out || 'nerviq-portfolio.html';
444
+ const outputPath = path.isAbsolute(outputFile) ? outputFile : path.resolve(outputFile);
445
+ const html = buildPortfolioHtml(repoResults);
446
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
447
+ fs.writeFileSync(outputPath, html, 'utf8');
448
+
449
+ const avgScore = Math.round(repoResults.reduce((s, r) => s + r.score, 0) / repoResults.length);
450
+ if (!flags.json) {
451
+ console.log('');
452
+ console.log(' nerviq portfolio dashboard');
453
+ console.log(' \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550');
454
+ console.log(` Repos: ${repoResults.length}`);
455
+ console.log(` Average score: ${avgScore}/100`);
456
+ console.log(` Output: ${outputPath}`);
457
+ console.log('');
458
+ }
459
+
460
+ if (flags.open) {
461
+ const { exec } = require('child_process');
462
+ const cmd = process.platform === 'win32' ? `start "" "${outputPath}"`
463
+ : process.platform === 'darwin' ? `open "${outputPath}"`
464
+ : `xdg-open "${outputPath}"`;
465
+ exec(cmd);
466
+ }
467
+
468
+ return { outputPath, repoCount: repoResults.length, avgScore, repos: repoResults };
469
+ }
470
+
471
+ module.exports = { generateDashboard, generatePortfolioDashboard, detectDrifts, buildDriftAlertsHtml };