@nerviq/cli 1.29.0 → 1.30.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +1764 -1493
  2. package/README.md +568 -538
  3. package/SECURITY.md +78 -82
  4. package/bin/cli.js +2838 -2558
  5. package/docs/api-reference.md +356 -356
  6. package/docs/audit-fix.md +109 -0
  7. package/docs/autofix.md +3 -62
  8. package/docs/getting-started.md +1 -1
  9. package/docs/index.html +592 -592
  10. package/docs/integration-contracts.md +287 -287
  11. package/docs/maintenance.md +128 -128
  12. package/docs/new-platform-guide.md +202 -202
  13. package/docs/release-process.md +63 -0
  14. package/docs/shallow-risk.md +244 -244
  15. package/docs/why-nerviq.md +82 -82
  16. package/package.json +75 -67
  17. package/sdk/README.md +12 -3
  18. package/sdk/examples/langchain-integration.md +128 -0
  19. package/sdk/examples/self-governing-agent.js +135 -0
  20. package/sdk/index.d.ts +115 -0
  21. package/sdk/index.js +94 -0
  22. package/sdk/package.json +11 -0
  23. package/src/activity.js +13 -0
  24. package/src/aider/activity.js +226 -226
  25. package/src/aider/context.js +162 -162
  26. package/src/aider/freshness.js +123 -123
  27. package/src/aider/techniques.js +3465 -3465
  28. package/src/audit/layers.js +180 -180
  29. package/src/audit.js +1133 -1032
  30. package/src/auto-suggest.js +9 -2
  31. package/src/behavioral-drift.js +37 -2
  32. package/src/benchmark.js +299 -299
  33. package/src/codex/activity.js +324 -324
  34. package/src/codex/freshness.js +149 -142
  35. package/src/codex/techniques.js +4895 -4895
  36. package/src/context.js +326 -326
  37. package/src/continuous-ops.js +11 -1
  38. package/src/convert.js +340 -340
  39. package/src/copilot/config-parser.js +280 -280
  40. package/src/copilot/context.js +218 -218
  41. package/src/copilot/freshness.js +184 -177
  42. package/src/copilot/patch.js +238 -238
  43. package/src/copilot/techniques.js +3578 -3578
  44. package/src/cursor/freshness.js +194 -194
  45. package/src/cursor/patch.js +243 -243
  46. package/src/cursor/techniques.js +3735 -3735
  47. package/src/doctor.js +201 -201
  48. package/src/fix-engine.js +511 -8
  49. package/src/formatters/csv.js +86 -86
  50. package/src/formatters/junit.js +123 -123
  51. package/src/formatters/markdown.js +164 -164
  52. package/src/formatters/otel.js +151 -151
  53. package/src/freshness.js +163 -156
  54. package/src/gemini/activity.js +402 -402
  55. package/src/gemini/context.js +290 -290
  56. package/src/gemini/freshness.js +188 -188
  57. package/src/gemini/patch.js +229 -229
  58. package/src/gemini/techniques.js +3811 -3811
  59. package/src/governance.js +533 -533
  60. package/src/harmony/audit.js +306 -306
  61. package/src/i18n.js +63 -63
  62. package/src/insights.js +119 -119
  63. package/src/integrations.js +134 -134
  64. package/src/locales/en.json +33 -33
  65. package/src/locales/es.json +33 -33
  66. package/src/migrate.js +354 -354
  67. package/src/opencode/activity.js +286 -286
  68. package/src/opencode/freshness.js +137 -137
  69. package/src/opencode/techniques.js +3450 -3450
  70. package/src/safe-glyph.js +97 -0
  71. package/src/setup/analysis.js +12 -12
  72. package/src/setup.js +13 -6
  73. package/src/shallow-risk/index.js +113 -56
  74. package/src/shallow-risk/patterns/agent-config-cross-platform-drift.js +51 -50
  75. package/src/shallow-risk/patterns/agent-config-dangerous-autoapprove.js +47 -46
  76. package/src/shallow-risk/patterns/agent-config-deprecated-keys.js +47 -46
  77. package/src/shallow-risk/patterns/agent-config-framework-version-mismatch.js +138 -0
  78. package/src/shallow-risk/patterns/agent-config-missing-file.js +318 -317
  79. package/src/shallow-risk/patterns/agent-config-script-not-in-package-json.js +108 -0
  80. package/src/shallow-risk/patterns/agent-config-secret-literal.js +52 -49
  81. package/src/shallow-risk/patterns/agent-config-stack-contradiction.js +35 -34
  82. package/src/shallow-risk/patterns/hook-script-missing.js +71 -70
  83. package/src/shallow-risk/patterns/mcp-server-no-allowlist.js +53 -52
  84. package/src/shallow-risk/shared.js +653 -648
  85. package/src/source-urls.js +295 -295
  86. package/src/state-paths.js +85 -85
  87. package/src/supplemental-checks.js +805 -805
  88. package/src/telemetry.js +160 -160
  89. package/src/watch.js +46 -0
  90. package/src/windsurf/context.js +359 -359
  91. package/src/windsurf/freshness.js +194 -194
  92. package/src/windsurf/patch.js +231 -231
  93. package/src/windsurf/techniques.js +3779 -3779
package/src/i18n.js CHANGED
@@ -1,63 +1,63 @@
1
- /**
2
- * Minimal i18n module for nerviq CLI.
3
- *
4
- * Supports locale files in src/locales/<lang>.json.
5
- * Falls back to English for missing keys.
6
- */
7
-
8
- const path = require('path');
9
- const fs = require('fs');
10
-
11
- const SUPPORTED_LOCALES = ['en', 'es'];
12
- const DEFAULT_LOCALE = 'en';
13
-
14
- let currentLocale = DEFAULT_LOCALE;
15
- let messages = {};
16
- let fallbackMessages = {};
17
-
18
- function loadLocale(locale) {
19
- const file = path.join(__dirname, 'locales', `${locale}.json`);
20
- try {
21
- return JSON.parse(fs.readFileSync(file, 'utf8'));
22
- } catch {
23
- return {};
24
- }
25
- }
26
-
27
- /**
28
- * Initialize i18n with a locale string (e.g. 'en', 'es').
29
- * Call this once at CLI startup.
30
- */
31
- function init(locale) {
32
- const lang = (locale || process.env.NERVIQ_LANG || DEFAULT_LOCALE).toLowerCase().slice(0, 2);
33
- currentLocale = SUPPORTED_LOCALES.includes(lang) ? lang : DEFAULT_LOCALE;
34
- fallbackMessages = loadLocale(DEFAULT_LOCALE);
35
- messages = currentLocale === DEFAULT_LOCALE ? fallbackMessages : loadLocale(currentLocale);
36
- }
37
-
38
- /**
39
- * Translate a key with optional interpolation.
40
- *
41
- * Usage:
42
- * t('audit.score', { score: 85, passed: 20, total: 25 })
43
- * // => "Score: 85/100 (20/25 checks passing)"
44
- */
45
- function t(key, params = {}) {
46
- let msg = messages[key] || fallbackMessages[key] || key;
47
- for (const [k, v] of Object.entries(params)) {
48
- msg = msg.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
49
- }
50
- return msg;
51
- }
52
-
53
- /**
54
- * Get current locale.
55
- */
56
- function getLocale() {
57
- return currentLocale;
58
- }
59
-
60
- // Auto-init with default on first require
61
- init();
62
-
63
- module.exports = { init, t, getLocale, SUPPORTED_LOCALES };
1
+ /**
2
+ * Minimal i18n module for nerviq CLI.
3
+ *
4
+ * Supports locale files in src/locales/<lang>.json.
5
+ * Falls back to English for missing keys.
6
+ */
7
+
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+
11
+ const SUPPORTED_LOCALES = ['en', 'es'];
12
+ const DEFAULT_LOCALE = 'en';
13
+
14
+ let currentLocale = DEFAULT_LOCALE;
15
+ let messages = {};
16
+ let fallbackMessages = {};
17
+
18
+ function loadLocale(locale) {
19
+ const file = path.join(__dirname, 'locales', `${locale}.json`);
20
+ try {
21
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Initialize i18n with a locale string (e.g. 'en', 'es').
29
+ * Call this once at CLI startup.
30
+ */
31
+ function init(locale) {
32
+ const lang = (locale || process.env.NERVIQ_LANG || DEFAULT_LOCALE).toLowerCase().slice(0, 2);
33
+ currentLocale = SUPPORTED_LOCALES.includes(lang) ? lang : DEFAULT_LOCALE;
34
+ fallbackMessages = loadLocale(DEFAULT_LOCALE);
35
+ messages = currentLocale === DEFAULT_LOCALE ? fallbackMessages : loadLocale(currentLocale);
36
+ }
37
+
38
+ /**
39
+ * Translate a key with optional interpolation.
40
+ *
41
+ * Usage:
42
+ * t('audit.score', { score: 85, passed: 20, total: 25 })
43
+ * // => "Score: 85/100 (20/25 checks passing)"
44
+ */
45
+ function t(key, params = {}) {
46
+ let msg = messages[key] || fallbackMessages[key] || key;
47
+ for (const [k, v] of Object.entries(params)) {
48
+ msg = msg.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
49
+ }
50
+ return msg;
51
+ }
52
+
53
+ /**
54
+ * Get current locale.
55
+ */
56
+ function getLocale() {
57
+ return currentLocale;
58
+ }
59
+
60
+ // Auto-init with default on first require
61
+ init();
62
+
63
+ module.exports = { init, t, getLocale, SUPPORTED_LOCALES };
package/src/insights.js CHANGED
@@ -1,119 +1,119 @@
1
- /**
2
- * Anonymous insights collection - opt-in, privacy-first.
3
- *
4
- * What we collect (anonymously, no PII):
5
- * - Score distribution (10/100, 45/100, etc.)
6
- * - Stack detection (React+TS, Python+Docker, etc.)
7
- * - Which checks fail most
8
- * - Which checks pass most
9
- * - OS + Node version
10
- *
11
- * What we NEVER collect:
12
- * - File contents, paths, or project names
13
- * - IP addresses or user identity
14
- * - API keys, tokens, or credentials
15
- * - Any data if user opts out
16
- *
17
- * Users can opt out with: npx nerviq --no-insights
18
- * Or set env: NERVIQ_NO_INSIGHTS=1
19
- */
20
-
21
- const https = require('https');
22
- const os = require('os');
23
-
24
- const INSIGHTS_ENDPOINT = 'https://insights.nerviq.net/v1/report';
25
- const TIMEOUT_MS = 3000;
26
-
27
- function shouldCollect() {
28
- // Opt-IN: only collect if user explicitly enables
29
- if (process.env.NERVIQ_INSIGHTS === '1') return true;
30
- if (process.argv.includes('--insights')) return true;
31
- return false;
32
- }
33
-
34
- function buildPayload(auditResult) {
35
- // Only anonymous aggregate data - no PII, no file contents, no paths
36
- const failedChecks = auditResult.results
37
- .filter(r => !r.passed)
38
- .map(r => r.key);
39
-
40
- const passedChecks = auditResult.results
41
- .filter(r => r.passed)
42
- .map(r => r.key);
43
-
44
- return {
45
- v: 1,
46
- score: auditResult.score,
47
- passed: auditResult.passed,
48
- failed: auditResult.failed,
49
- stacks: (auditResult.stacks || []).map(s => s.label),
50
- failedChecks,
51
- passedChecks,
52
- platform: os.platform(),
53
- nodeVersion: process.version,
54
- toolVersion: require('../package.json').version,
55
- timestamp: new Date().toISOString(),
56
- };
57
- }
58
-
59
- function sendInsights(auditResult) {
60
- if (!shouldCollect()) return;
61
-
62
- try {
63
- const payload = JSON.stringify(buildPayload(auditResult));
64
- const url = new URL(INSIGHTS_ENDPOINT);
65
-
66
- const req = https.request({
67
- hostname: url.hostname,
68
- path: url.pathname,
69
- method: 'POST',
70
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
71
- timeout: TIMEOUT_MS,
72
- });
73
-
74
- // Fire and forget - never block the CLI
75
- req.on('error', () => {}); // silently ignore
76
- req.on('timeout', () => req.destroy());
77
- req.write(payload);
78
- req.end();
79
- } catch (e) {
80
- // Never let insights crash the CLI
81
- }
82
- }
83
-
84
- /**
85
- * Generate insights summary from local audit history.
86
- * This runs locally - no network needed.
87
- */
88
- function getLocalInsights(auditResult) {
89
- const { results } = auditResult;
90
- const applicable = results.filter(r => r.passed !== null);
91
- const failed = applicable.filter(r => r.passed === false);
92
-
93
- // Top 3 most impactful fixes
94
- const impactOrder = { critical: 3, high: 2, medium: 1 };
95
- const topFixes = [...failed]
96
- .sort((a, b) => (impactOrder[b.impact] || 0) - (impactOrder[a.impact] || 0))
97
- .slice(0, 3)
98
- .map(r => ({ name: r.name, impact: r.impact, fix: r.fix }));
99
-
100
- // Score breakdown by category
101
- const categories = {};
102
- for (const r of applicable) {
103
- const cat = r.category || 'other';
104
- if (!categories[cat]) categories[cat] = { passed: 0, total: 0 };
105
- categories[cat].total++;
106
- if (r.passed) categories[cat].passed++;
107
- }
108
-
109
- // Weakest categories
110
- const weakest = Object.entries(categories)
111
- .map(([name, data]) => ({ name, score: Math.round((data.passed / data.total) * 100), ...data }))
112
- .filter(c => c.score < 100)
113
- .sort((a, b) => a.score - b.score)
114
- .slice(0, 3);
115
-
116
- return { topFixes, categories, weakest, totalScore: auditResult.score };
117
- }
118
-
119
- module.exports = { sendInsights, getLocalInsights, shouldCollect };
1
+ /**
2
+ * Anonymous insights collection - opt-in, privacy-first.
3
+ *
4
+ * What we collect (anonymously, no PII):
5
+ * - Score distribution (10/100, 45/100, etc.)
6
+ * - Stack detection (React+TS, Python+Docker, etc.)
7
+ * - Which checks fail most
8
+ * - Which checks pass most
9
+ * - OS + Node version
10
+ *
11
+ * What we NEVER collect:
12
+ * - File contents, paths, or project names
13
+ * - IP addresses or user identity
14
+ * - API keys, tokens, or credentials
15
+ * - Any data if user opts out
16
+ *
17
+ * Users can opt out with: npx nerviq --no-insights
18
+ * Or set env: NERVIQ_NO_INSIGHTS=1
19
+ */
20
+
21
+ const https = require('https');
22
+ const os = require('os');
23
+
24
+ const INSIGHTS_ENDPOINT = 'https://insights.nerviq.net/v1/report';
25
+ const TIMEOUT_MS = 3000;
26
+
27
+ function shouldCollect() {
28
+ // Opt-IN: only collect if user explicitly enables
29
+ if (process.env.NERVIQ_INSIGHTS === '1') return true;
30
+ if (process.argv.includes('--insights')) return true;
31
+ return false;
32
+ }
33
+
34
+ function buildPayload(auditResult) {
35
+ // Only anonymous aggregate data - no PII, no file contents, no paths
36
+ const failedChecks = auditResult.results
37
+ .filter(r => !r.passed)
38
+ .map(r => r.key);
39
+
40
+ const passedChecks = auditResult.results
41
+ .filter(r => r.passed)
42
+ .map(r => r.key);
43
+
44
+ return {
45
+ v: 1,
46
+ score: auditResult.score,
47
+ passed: auditResult.passed,
48
+ failed: auditResult.failed,
49
+ stacks: (auditResult.stacks || []).map(s => s.label),
50
+ failedChecks,
51
+ passedChecks,
52
+ platform: os.platform(),
53
+ nodeVersion: process.version,
54
+ toolVersion: require('../package.json').version,
55
+ timestamp: new Date().toISOString(),
56
+ };
57
+ }
58
+
59
+ function sendInsights(auditResult) {
60
+ if (!shouldCollect()) return;
61
+
62
+ try {
63
+ const payload = JSON.stringify(buildPayload(auditResult));
64
+ const url = new URL(INSIGHTS_ENDPOINT);
65
+
66
+ const req = https.request({
67
+ hostname: url.hostname,
68
+ path: url.pathname,
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
71
+ timeout: TIMEOUT_MS,
72
+ });
73
+
74
+ // Fire and forget - never block the CLI
75
+ req.on('error', () => {}); // silently ignore
76
+ req.on('timeout', () => req.destroy());
77
+ req.write(payload);
78
+ req.end();
79
+ } catch (e) {
80
+ // Never let insights crash the CLI
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Generate insights summary from local audit history.
86
+ * This runs locally - no network needed.
87
+ */
88
+ function getLocalInsights(auditResult) {
89
+ const { results } = auditResult;
90
+ const applicable = results.filter(r => r.passed !== null);
91
+ const failed = applicable.filter(r => r.passed === false);
92
+
93
+ // Top 3 most impactful fixes
94
+ const impactOrder = { critical: 3, high: 2, medium: 1 };
95
+ const topFixes = [...failed]
96
+ .sort((a, b) => (impactOrder[b.impact] || 0) - (impactOrder[a.impact] || 0))
97
+ .slice(0, 3)
98
+ .map(r => ({ name: r.name, impact: r.impact, fix: r.fix }));
99
+
100
+ // Score breakdown by category
101
+ const categories = {};
102
+ for (const r of applicable) {
103
+ const cat = r.category || 'other';
104
+ if (!categories[cat]) categories[cat] = { passed: 0, total: 0 };
105
+ categories[cat].total++;
106
+ if (r.passed) categories[cat].passed++;
107
+ }
108
+
109
+ // Weakest categories
110
+ const weakest = Object.entries(categories)
111
+ .map(([name, data]) => ({ name, score: Math.round((data.passed / data.total) * 100), ...data }))
112
+ .filter(c => c.score < 100)
113
+ .sort((a, b) => a.score - b.score)
114
+ .slice(0, 3);
115
+
116
+ return { topFixes, categories, weakest, totalScore: auditResult.score };
117
+ }
118
+
119
+ module.exports = { sendInsights, getLocalInsights, shouldCollect };
@@ -1,15 +1,15 @@
1
- /**
2
- * Nerviq Integrations
3
- *
4
- * Webhook dispatch and message formatting for Slack, Discord,
5
- * and generic HTTP endpoints.
6
- *
7
- * All functions are synchronous-friendly; sendWebhook is async
8
- * (uses built-in https module, no external dependencies).
9
- */
10
-
11
- 'use strict';
12
-
1
+ /**
2
+ * Nerviq Integrations
3
+ *
4
+ * Webhook dispatch and message formatting for Slack, Discord,
5
+ * and generic HTTP endpoints.
6
+ *
7
+ * All functions are synchronous-friendly; sendWebhook is async
8
+ * (uses built-in https module, no external dependencies).
9
+ */
10
+
11
+ 'use strict';
12
+
13
13
  const https = require('https');
14
14
  const http = require('http');
15
15
  const { URL } = require('url');
@@ -65,11 +65,11 @@ function sendWebhookOnce(parsed, body, opts = {}) {
65
65
  }
66
66
 
67
67
  // ─── Webhook delivery ────────────────────────────────────────────────────────
68
-
69
- /**
70
- * POST JSON payload to a webhook URL.
71
- * @param {string} url - Destination URL (http or https)
72
- * @param {object} payload - JSON-serialisable object
68
+
69
+ /**
70
+ * POST JSON payload to a webhook URL.
71
+ * @param {string} url - Destination URL (http or https)
72
+ * @param {object} payload - JSON-serialisable object
73
73
  * @param {object} [opts]
74
74
  * @param {number} [opts.timeoutMs=10000]
75
75
  * @param {object} [opts.headers]
@@ -117,124 +117,124 @@ async function sendWebhook(url, payload, opts = {}) {
117
117
 
118
118
  return { ok: false, status: 0, body: '', attempts: maxAttempts };
119
119
  }
120
-
121
- // ─── Slack formatting ─────────────────────────────────────────────────────────
122
-
123
- /**
124
- * Format an audit result as a Slack Block Kit message payload.
125
- * @param {object} auditResult - Result from audit()
126
- * @returns {object} Slack-compatible message payload (blocks API)
127
- */
128
- function formatSlackMessage(auditResult) {
129
- const score = auditResult.score ?? 0;
130
- const platform = auditResult.platform ?? 'claude';
131
- const emoji = score >= 70 ? ':white_check_mark:' : score >= 40 ? ':warning:' : ':x:';
132
- const color = score >= 70 ? 'good' : score >= 40 ? 'warning' : 'danger';
133
-
134
- const criticals = (auditResult.results || [])
135
- .filter((r) => r.passed === false && r.impact === 'critical')
136
- .slice(0, 5);
137
-
138
- const sections = [
139
- {
140
- type: 'header',
141
- text: { type: 'plain_text', text: `${emoji} Nerviq Audit — ${platform}`, emoji: true },
142
- },
143
- {
144
- type: 'section',
145
- fields: [
146
- { type: 'mrkdwn', text: `*Score*\n${score}/100` },
147
- { type: 'mrkdwn', text: `*Checks*\n${auditResult.passed ?? 0} pass / ${auditResult.failed ?? 0} fail` },
148
- ],
149
- },
150
- ];
151
-
152
- if (criticals.length > 0) {
153
- sections.push({ type: 'divider' });
154
- sections.push({
155
- type: 'section',
156
- text: {
157
- type: 'mrkdwn',
158
- text: `*Critical gaps:*\n${criticals.map((r) => `• ${r.name}`).join('\n')}`,
159
- },
160
- });
161
- }
162
-
163
- if (auditResult.suggestedNextCommand) {
164
- sections.push({
165
- type: 'section',
166
- text: { type: 'mrkdwn', text: `*Next step:* \`${auditResult.suggestedNextCommand}\`` },
167
- });
168
- }
169
-
170
- // Also include legacy attachment for clients that don't support blocks
171
- return {
172
- blocks: sections,
173
- attachments: [
174
- {
175
- color,
176
- fallback: `Nerviq audit (${platform}): ${score}/100 — ${auditResult.passed ?? 0} pass, ${auditResult.failed ?? 0} fail`,
177
- },
178
- ],
179
- };
180
- }
181
-
182
- // ─── Discord formatting ───────────────────────────────────────────────────────
183
-
184
- /**
185
- * Format an audit result as a Discord webhook embed payload.
186
- * @param {object} auditResult - Result from audit()
187
- * @returns {object} Discord-compatible webhook payload (embeds)
188
- */
120
+
121
+ // ─── Slack formatting ─────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Format an audit result as a Slack Block Kit message payload.
125
+ * @param {object} auditResult - Result from audit()
126
+ * @returns {object} Slack-compatible message payload (blocks API)
127
+ */
128
+ function formatSlackMessage(auditResult) {
129
+ const score = auditResult.score ?? 0;
130
+ const platform = auditResult.platform ?? 'claude';
131
+ const emoji = score >= 70 ? ':white_check_mark:' : score >= 40 ? ':warning:' : ':x:';
132
+ const color = score >= 70 ? 'good' : score >= 40 ? 'warning' : 'danger';
133
+
134
+ const criticals = (auditResult.results || [])
135
+ .filter((r) => r.passed === false && r.impact === 'critical')
136
+ .slice(0, 5);
137
+
138
+ const sections = [
139
+ {
140
+ type: 'header',
141
+ text: { type: 'plain_text', text: `${emoji} Nerviq Audit — ${platform}`, emoji: true },
142
+ },
143
+ {
144
+ type: 'section',
145
+ fields: [
146
+ { type: 'mrkdwn', text: `*Score*\n${score}/100` },
147
+ { type: 'mrkdwn', text: `*Checks*\n${auditResult.passed ?? 0} pass / ${auditResult.failed ?? 0} fail` },
148
+ ],
149
+ },
150
+ ];
151
+
152
+ if (criticals.length > 0) {
153
+ sections.push({ type: 'divider' });
154
+ sections.push({
155
+ type: 'section',
156
+ text: {
157
+ type: 'mrkdwn',
158
+ text: `*Critical gaps:*\n${criticals.map((r) => `• ${r.name}`).join('\n')}`,
159
+ },
160
+ });
161
+ }
162
+
163
+ if (auditResult.suggestedNextCommand) {
164
+ sections.push({
165
+ type: 'section',
166
+ text: { type: 'mrkdwn', text: `*Next step:* \`${auditResult.suggestedNextCommand}\`` },
167
+ });
168
+ }
169
+
170
+ // Also include legacy attachment for clients that don't support blocks
171
+ return {
172
+ blocks: sections,
173
+ attachments: [
174
+ {
175
+ color,
176
+ fallback: `Nerviq audit (${platform}): ${score}/100 — ${auditResult.passed ?? 0} pass, ${auditResult.failed ?? 0} fail`,
177
+ },
178
+ ],
179
+ };
180
+ }
181
+
182
+ // ─── Discord formatting ───────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Format an audit result as a Discord webhook embed payload.
186
+ * @param {object} auditResult - Result from audit()
187
+ * @returns {object} Discord-compatible webhook payload (embeds)
188
+ */
189
189
  function formatDiscordMessage(auditResult) {
190
- const score = auditResult.score ?? 0;
191
- const platform = auditResult.platform ?? 'claude';
192
- const color = score >= 70 ? 0x2ecc71 : score >= 40 ? 0xf39c12 : 0xe74c3c; // green / yellow / red
193
- const icon = score >= 70 ? '✅' : score >= 40 ? '⚠️' : '❌';
194
-
195
- const criticals = (auditResult.results || [])
196
- .filter((r) => r.passed === false && r.impact === 'critical')
197
- .slice(0, 5);
198
-
199
- const highs = (auditResult.results || [])
200
- .filter((r) => r.passed === false && r.impact === 'high')
201
- .slice(0, 3);
202
-
203
- const fields = [
204
- { name: 'Score', value: `**${score}/100**`, inline: true },
205
- { name: 'Pass / Fail', value: `${auditResult.passed ?? 0} / ${auditResult.failed ?? 0}`, inline: true },
206
- { name: 'Platform', value: platform, inline: true },
207
- ];
208
-
209
- if (criticals.length > 0) {
210
- fields.push({
211
- name: '🚨 Critical',
212
- value: criticals.map((r) => `• ${r.name}`).join('\n'),
213
- inline: false,
214
- });
215
- }
216
-
217
- if (highs.length > 0) {
218
- fields.push({
219
- name: '⚠️ High',
220
- value: highs.map((r) => `• ${r.name}`).join('\n'),
221
- inline: false,
222
- });
223
- }
224
-
225
- if (auditResult.suggestedNextCommand) {
226
- fields.push({ name: '▶️ Next step', value: `\`${auditResult.suggestedNextCommand}\``, inline: false });
227
- }
228
-
229
- return {
230
- embeds: [
231
- {
232
- title: `${icon} Nerviq Audit — ${platform}`,
233
- color,
234
- fields,
235
- footer: { text: `nerviq v${require('../package.json').version} • ${new Date().toISOString()}` },
236
- },
237
- ],
190
+ const score = auditResult.score ?? 0;
191
+ const platform = auditResult.platform ?? 'claude';
192
+ const color = score >= 70 ? 0x2ecc71 : score >= 40 ? 0xf39c12 : 0xe74c3c; // green / yellow / red
193
+ const icon = score >= 70 ? '✅' : score >= 40 ? '⚠️' : '❌';
194
+
195
+ const criticals = (auditResult.results || [])
196
+ .filter((r) => r.passed === false && r.impact === 'critical')
197
+ .slice(0, 5);
198
+
199
+ const highs = (auditResult.results || [])
200
+ .filter((r) => r.passed === false && r.impact === 'high')
201
+ .slice(0, 3);
202
+
203
+ const fields = [
204
+ { name: 'Score', value: `**${score}/100**`, inline: true },
205
+ { name: 'Pass / Fail', value: `${auditResult.passed ?? 0} / ${auditResult.failed ?? 0}`, inline: true },
206
+ { name: 'Platform', value: platform, inline: true },
207
+ ];
208
+
209
+ if (criticals.length > 0) {
210
+ fields.push({
211
+ name: '🚨 Critical',
212
+ value: criticals.map((r) => `• ${r.name}`).join('\n'),
213
+ inline: false,
214
+ });
215
+ }
216
+
217
+ if (highs.length > 0) {
218
+ fields.push({
219
+ name: '⚠️ High',
220
+ value: highs.map((r) => `• ${r.name}`).join('\n'),
221
+ inline: false,
222
+ });
223
+ }
224
+
225
+ if (auditResult.suggestedNextCommand) {
226
+ fields.push({ name: '▶️ Next step', value: `\`${auditResult.suggestedNextCommand}\``, inline: false });
227
+ }
228
+
229
+ return {
230
+ embeds: [
231
+ {
232
+ title: `${icon} Nerviq Audit — ${platform}`,
233
+ color,
234
+ fields,
235
+ footer: { text: `nerviq v${require('../package.json').version} • ${new Date().toISOString()}` },
236
+ },
237
+ ],
238
238
  };
239
239
  }
240
240