@nerviq/cli 1.10.0 → 1.11.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/src/audit.js CHANGED
@@ -24,16 +24,18 @@ const { AiderProjectContext } = require('./aider/context');
24
24
  const { OPENCODE_TECHNIQUES } = require('./opencode/techniques');
25
25
  const { OpenCodeProjectContext } = require('./opencode/context');
26
26
  const { getBadgeMarkdown } = require('./badge');
27
- const { sendInsights, getLocalInsights } = require('./insights');
28
- const { getRecommendationOutcomeSummary, getRecommendationAdjustment } = require('./activity');
29
- const { getFeedbackSummary } = require('./feedback');
30
- const { formatSarif } = require('./formatters/sarif');
31
- const { formatOtelMetrics } = require('./formatters/otel');
32
- const { loadPlugins, mergePluginChecks } = require('./plugins');
33
- const { hasWorkspaceConfig, detectWorkspaceGlobs, detectWorkspaces } = require('./workspace');
34
- const { detectDeprecationWarnings } = require('./deprecation');
35
- const { version: packageVersion } = require('../package.json');
36
- const { t } = require('./i18n');
27
+ const { sendInsights, getLocalInsights } = require('./insights');
28
+ const { getRecommendationOutcomeSummary, getRecommendationAdjustment } = require('./activity');
29
+ const { getFeedbackSummary } = require('./feedback');
30
+ const { formatSarif } = require('./formatters/sarif');
31
+ const { formatOtelMetrics } = require('./formatters/otel');
32
+ const { collectAuditTerminology, formatTerminologyLines } = require('./terminology');
33
+ const { loadPlugins, mergePluginChecks } = require('./plugins');
34
+ const { hasWorkspaceConfig, detectWorkspaceGlobs, detectWorkspaces } = require('./workspace');
35
+ const { detectDeprecationWarnings } = require('./deprecation');
36
+ const { estimateTokenCount } = require('./token-estimate');
37
+ const { version: packageVersion } = require('../package.json');
38
+ const { t } = require('./i18n');
37
39
 
38
40
  const COLORS = {
39
41
  reset: '\x1b[0m',
@@ -64,8 +66,8 @@ function formatLocation(file, line) {
64
66
 
65
67
  const IMPACT_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
66
68
  const WEIGHTS = { critical: 15, high: 10, medium: 5, low: 2 };
67
- const LARGE_INSTRUCTION_WARN_BYTES = 50 * 1024;
68
- const LARGE_INSTRUCTION_SKIP_BYTES = 1024 * 1024;
69
+ const LARGE_INSTRUCTION_WARN_TOKENS = 12000;
70
+ const LARGE_INSTRUCTION_SKIP_TOKENS = 240000;
69
71
  const CATEGORY_MODULES = {
70
72
  memory: 'CLAUDE.md',
71
73
  quality: 'verification',
@@ -358,9 +360,13 @@ function getAuditSpec(platform = 'claude') {
358
360
  };
359
361
  }
360
362
 
361
- function normalizeRelativePath(filePath) {
362
- return String(filePath || '').replace(/\\/g, '/').replace(/^\.\//, '');
363
- }
363
+ function normalizeRelativePath(filePath) {
364
+ return String(filePath || '').replace(/\\/g, '/').replace(/^\.\//, '');
365
+ }
366
+
367
+ function formatCount(value) {
368
+ return Number(value || 0).toLocaleString('en-US');
369
+ }
364
370
 
365
371
  function addPath(target, filePath) {
366
372
  if (!filePath || typeof filePath !== 'string') return;
@@ -454,25 +460,27 @@ function instructionFileCandidates(spec, ctx) {
454
460
  return [...candidates];
455
461
  }
456
462
 
457
- function inspectInstructionFiles(spec, ctx) {
458
- const warnings = [];
459
-
460
- for (const filePath of instructionFileCandidates(spec, ctx)) {
461
- const byteCount = typeof ctx.fileSizeBytes === 'function' ? ctx.fileSizeBytes(filePath) : null;
462
- if (!Number.isFinite(byteCount) || byteCount <= LARGE_INSTRUCTION_WARN_BYTES) continue;
463
-
464
- const content = typeof ctx.fileContent === 'function' ? ctx.fileContent(filePath) : null;
465
- warnings.push({
466
- file: normalizeRelativePath(filePath),
467
- byteCount,
468
- lineCount: typeof content === 'string' ? content.split(/\r?\n/).length : null,
469
- skipped: byteCount > LARGE_INSTRUCTION_SKIP_BYTES,
470
- severity: byteCount > LARGE_INSTRUCTION_SKIP_BYTES ? 'critical' : 'warning',
471
- message: byteCount > LARGE_INSTRUCTION_SKIP_BYTES
472
- ? 'Instruction file exceeds 1MB and will be skipped during audit.'
473
- : 'Instruction file exceeds 50KB. Audit will continue, but this file may reduce runtime clarity.',
474
- });
475
- }
463
+ function inspectInstructionFiles(spec, ctx) {
464
+ const warnings = [];
465
+
466
+ for (const filePath of instructionFileCandidates(spec, ctx)) {
467
+ const content = typeof ctx.fileContent === 'function' ? ctx.fileContent(filePath) : null;
468
+ const byteCount = typeof ctx.fileSizeBytes === 'function' ? ctx.fileSizeBytes(filePath) : null;
469
+ const tokenCount = typeof content === 'string' ? estimateTokenCount(content) : null;
470
+ if (!Number.isFinite(tokenCount) || tokenCount <= LARGE_INSTRUCTION_WARN_TOKENS) continue;
471
+
472
+ warnings.push({
473
+ file: normalizeRelativePath(filePath),
474
+ byteCount,
475
+ tokenCount,
476
+ lineCount: typeof content === 'string' ? content.split(/\r?\n/).length : null,
477
+ skipped: tokenCount > LARGE_INSTRUCTION_SKIP_TOKENS,
478
+ severity: tokenCount > LARGE_INSTRUCTION_SKIP_TOKENS ? 'critical' : 'warning',
479
+ message: tokenCount > LARGE_INSTRUCTION_SKIP_TOKENS
480
+ ? 'Instruction file exceeds ~240,000 tokens and will be skipped during audit.'
481
+ : 'Instruction file exceeds ~12,000 tokens. Audit will continue, but this file may reduce runtime clarity.',
482
+ });
483
+ }
476
484
 
477
485
  return warnings;
478
486
  }
@@ -921,11 +929,11 @@ function printLiteAudit(result, dir) {
921
929
  console.log(colorize(` - ${item.title}: ${item.message}`, 'dim'));
922
930
  });
923
931
  }
924
- if (result.largeInstructionFiles && result.largeInstructionFiles.length > 0) {
925
- result.largeInstructionFiles.slice(0, 2).forEach((item) => {
926
- console.log(colorize(` Large file: ${item.file} (${Math.round(item.byteCount / 1024)}KB)`, 'yellow'));
927
- });
928
- }
932
+ if (result.largeInstructionFiles && result.largeInstructionFiles.length > 0) {
933
+ result.largeInstructionFiles.slice(0, 2).forEach((item) => {
934
+ console.log(colorize(` Large file: ${item.file} (~${formatCount(item.tokenCount)} tokens)`, 'yellow'));
935
+ });
936
+ }
929
937
  console.log('');
930
938
 
931
939
  if (result.failed === 0) {
@@ -957,15 +965,23 @@ function printLiteAudit(result, dir) {
957
965
  console.log('');
958
966
  let usagePatterns;
959
967
  try { usagePatterns = require('./usage-patterns'); } catch { usagePatterns = null; }
960
- result.liteSummary.topNextActions.forEach((item, index) => {
961
- const tier = item.impact === 'critical' ? '🔴' : item.impact === 'high' ? '🟡' : '🔵';
962
- const suppressed = usagePatterns && usagePatterns.getPriorityAdjustment(dir, item.key) === 'suppress';
963
- const suffix = suppressed ? colorize(' (suppressed)', 'dim') : '';
964
- console.log(` ${index + 1}. ${tier} ${colorize(item.name, 'bold')}${suffix}`);
965
- console.log(colorize(` ${item.fix}`, 'dim'));
966
- });
967
- console.log('');
968
- console.log(` Ready? Run: ${colorize(result.suggestedNextCommand, 'bold')}`);
968
+ result.liteSummary.topNextActions.forEach((item, index) => {
969
+ const tier = item.impact === 'critical' ? '🔴' : item.impact === 'high' ? '🟡' : '🔵';
970
+ const suppressed = usagePatterns && usagePatterns.getPriorityAdjustment(dir, item.key) === 'suppress';
971
+ const suffix = suppressed ? colorize(' (suppressed)', 'dim') : '';
972
+ console.log(` ${index + 1}. ${tier} ${colorize(item.name, 'bold')}${suffix}`);
973
+ console.log(colorize(` ${item.fix}`, 'dim'));
974
+ });
975
+ console.log('');
976
+ const liteTerminology = formatTerminologyLines(collectAuditTerminology(result));
977
+ if (liteTerminology.length > 0) {
978
+ liteTerminology.forEach((line) => {
979
+ const color = line.startsWith(' Terms used here:') ? 'blue' : 'dim';
980
+ console.log(colorize(line, color));
981
+ });
982
+ console.log('');
983
+ }
984
+ console.log(` Ready? Run: ${colorize(result.suggestedNextCommand, 'bold')}`);
969
985
  if (result.platform === 'codex') {
970
986
  console.log(colorize(' Note: Codex now supports no-write advisory flows via augment and suggest-only before setup/apply.', 'dim'));
971
987
  }
@@ -1054,12 +1070,12 @@ async function audit(options) {
1054
1070
  key: 'largeInstructionFile',
1055
1071
  id: null,
1056
1072
  name: 'Large instruction file warning',
1057
- category: 'performance',
1058
- impact: 'medium',
1059
- rating: null,
1060
- fix: 'Split oversized instruction files so they stay under 50KB, and keep any single instruction file below 1MB.',
1061
- sourceUrl: null,
1062
- confidence: 'high',
1073
+ category: 'performance',
1074
+ impact: 'medium',
1075
+ rating: null,
1076
+ fix: 'Split oversized instruction files so they stay under ~12,000 tokens, and keep any single instruction file below ~240,000 tokens.',
1077
+ sourceUrl: null,
1078
+ confidence: 'high',
1063
1079
  file: largeInstructionFiles[0].file,
1064
1080
  line: null,
1065
1081
  passed: null,
@@ -1127,12 +1143,13 @@ async function audit(options) {
1127
1143
  ...largeInstructionFiles.map((item) => ({
1128
1144
  kind: 'large-instruction-file',
1129
1145
  severity: item.severity,
1130
- message: item.message,
1131
- file: item.file,
1132
- lineCount: item.lineCount,
1133
- byteCount: item.byteCount,
1134
- skipped: item.skipped,
1135
- })),
1146
+ message: item.message,
1147
+ file: item.file,
1148
+ lineCount: item.lineCount,
1149
+ byteCount: item.byteCount,
1150
+ tokenCount: item.tokenCount,
1151
+ skipped: item.skipped,
1152
+ })),
1136
1153
  ...deprecationWarnings.map((item) => ({
1137
1154
  kind: 'deprecated-feature',
1138
1155
  severity: 'warning',
@@ -1260,13 +1277,13 @@ async function audit(options) {
1260
1277
  console.log('');
1261
1278
  }
1262
1279
 
1263
- if (largeInstructionFiles.length > 0) {
1264
- console.log(colorize(' Large instruction files', 'yellow'));
1265
- for (const item of largeInstructionFiles) {
1266
- const sizeKb = Math.round(item.byteCount / 1024);
1267
- console.log(colorize(` ${item.file} (${sizeKb}KB, ${item.lineCount || '?'} lines)`, 'bold'));
1268
- console.log(colorize(` → ${item.message}`, 'dim'));
1269
- }
1280
+ if (largeInstructionFiles.length > 0) {
1281
+ console.log(colorize(' Large instruction files', 'yellow'));
1282
+ for (const item of largeInstructionFiles) {
1283
+ const sizeKb = Number.isFinite(item.byteCount) ? Math.round(item.byteCount / 1024) : '?';
1284
+ console.log(colorize(` ${item.file} (~${formatCount(item.tokenCount)} tokens, ${item.lineCount || '?'} lines, ${sizeKb}KB)`, 'bold'));
1285
+ console.log(colorize(` → ${item.message}`, 'dim'));
1286
+ }
1270
1287
  console.log('');
1271
1288
  }
1272
1289
 
@@ -1366,8 +1383,8 @@ async function audit(options) {
1366
1383
  }
1367
1384
 
1368
1385
  // Top next actions
1369
- if (topNextActions.length > 0) {
1370
- console.log(colorize(' ⚡ Top 5 Next Actions', 'magenta'));
1386
+ if (topNextActions.length > 0) {
1387
+ console.log(colorize(' ⚡ Top 5 Next Actions', 'magenta'));
1371
1388
  for (let i = 0; i < topNextActions.length; i++) {
1372
1389
  const item = topNextActions[i];
1373
1390
  console.log(` ${i + 1}. ${colorize(item.name, 'bold')}`);
@@ -1383,11 +1400,20 @@ async function audit(options) {
1383
1400
  console.log(colorize(` Feedback: accepted ${item.feedback.accepted}, rejected ${item.feedback.rejected}, positive ${item.feedback.positive}, negative ${item.feedback.negative}${avgDelta}`, 'dim'));
1384
1401
  }
1385
1402
  console.log(colorize(` Fix: ${item.fix}`, 'dim'));
1386
- }
1387
- console.log('');
1388
- }
1389
-
1390
- // Summary
1403
+ }
1404
+ console.log('');
1405
+ }
1406
+
1407
+ const terminology = formatTerminologyLines(collectAuditTerminology(result));
1408
+ if (terminology.length > 0) {
1409
+ terminology.forEach((line) => {
1410
+ const color = line.startsWith(' Terms used here:') ? 'blue' : 'dim';
1411
+ console.log(colorize(line, color));
1412
+ });
1413
+ console.log('');
1414
+ }
1415
+
1416
+ // Summary
1391
1417
  console.log(colorize(' ─────────────────────────────────────', 'dim'));
1392
1418
  const deprecatedNote = deprecated.length > 0 ? colorize(`, ${deprecated.length} deprecated`, 'dim') : '';
1393
1419
  console.log(` ${colorize(`${passed.length}/${applicable.length}`, 'bold')} checks passing${skipped.length > 0 ? colorize(` (${skipped.length} not applicable${deprecatedNote})`, 'dim') : (deprecatedNote ? colorize(` (${deprecatedNote})`, 'dim') : '')}`);
package/src/benchmark.js CHANGED
@@ -2,11 +2,12 @@ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
 
5
- const { version } = require('../package.json');
6
- const { audit } = require('./audit');
7
- const { setup } = require('./setup');
8
- const { analyzeProject } = require('./analyze');
9
- const { getGovernanceSummary } = require('./governance');
5
+ const { version } = require('../package.json');
6
+ const { audit } = require('./audit');
7
+ const { setup } = require('./setup');
8
+ const { analyzeProject } = require('./analyze');
9
+ const { getGovernanceSummary } = require('./governance');
10
+ const { formatTerminologyLines } = require('./terminology');
10
11
 
11
12
  function copyProject(sourceDir, targetDir) {
12
13
  fs.mkdirSync(targetDir, { recursive: true });
@@ -340,11 +341,15 @@ function printBenchmark(report, options = {}) {
340
341
  console.log(` Baseline live audit: organic ${report.before.organicScore}/100, total ${report.before.score}/100`);
341
342
  console.log(` Projected after setup: organic ${report.after.organicScore}/100, total ${report.after.score}/100`);
342
343
  console.log('');
343
- console.log(` ${report.executiveSummary.headline}`);
344
- console.log(` Recommendation: ${report.executiveSummary.decisionGuidance}`);
345
- console.log(` Workflow evidence: ${report.workflowEvidence.summary.passed}/${report.workflowEvidence.summary.total} tasks (${report.workflowEvidence.summary.coverageScore}%)`);
346
- console.log('');
347
- }
344
+ console.log(` ${report.executiveSummary.headline}`);
345
+ console.log(` Recommendation: ${report.executiveSummary.decisionGuidance}`);
346
+ console.log(` Workflow evidence: ${report.workflowEvidence.summary.passed}/${report.workflowEvidence.summary.total} tasks (${report.workflowEvidence.summary.coverageScore}%)`);
347
+ console.log('');
348
+ for (const line of formatTerminologyLines(['governance', 'hooks', 'mcp'])) {
349
+ console.log(line);
350
+ }
351
+ console.log('');
352
+ }
348
353
 
349
354
  function writeBenchmarkReport(report, outFile) {
350
355
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
package/src/governance.js CHANGED
@@ -1,6 +1,7 @@
1
- const { DOMAIN_PACKS } = require('./domain-packs');
2
- const { MCP_PACKS, mergeMcpServers, normalizeMcpPackKeys } = require('./mcp-packs');
3
- const { getCodexGovernanceSummary } = require('./codex/governance');
1
+ const { DOMAIN_PACKS } = require('./domain-packs');
2
+ const { MCP_PACKS, mergeMcpServers, normalizeMcpPackKeys } = require('./mcp-packs');
3
+ const { getCodexGovernanceSummary } = require('./codex/governance');
4
+ const { formatTerminologyLines } = require('./terminology');
4
5
 
5
6
  const PERMISSION_PROFILES = [
6
7
  {
@@ -406,10 +407,15 @@ function printGovernanceSummary(summary, options = {}) {
406
407
  console.log('');
407
408
  console.log(` nerviq ${summary.platformLabel.toLowerCase()} governance`);
408
409
  console.log(' ═══════════════════════════════════════');
409
- console.log(` Safe defaults, hook transparency, and pilot guidance for ${summary.platformLabel}.`);
410
- console.log('');
411
-
412
- console.log(' Permission Profiles');
410
+ console.log(` Safe defaults, hook transparency, and pilot guidance for ${summary.platformLabel}.`);
411
+ console.log('');
412
+
413
+ for (const line of formatTerminologyLines(['governance', 'hooks', 'denyRules', 'mcp'])) {
414
+ console.log(line);
415
+ }
416
+ console.log('');
417
+
418
+ console.log(' Permission Profiles');
413
419
  for (const profile of summary.permissionProfiles) {
414
420
  console.log(` - ${profile.label} [${profile.risk}]`);
415
421
  console.log(` ${profile.useWhen}`);
package/src/index.js CHANGED
@@ -88,7 +88,7 @@ const { getOpenCodeGovernanceSummary } = require('./opencode/governance');
88
88
  const { runOpenCodeDeepReview } = require('./opencode/deep-review');
89
89
  const { opencodeInteractive } = require('./opencode/interactive');
90
90
  const { detectPlatforms, getCatalog, synergyReport } = require('./public-api');
91
- const { createServer, startServer } = require('./server');
91
+ const { buildServeOpenApiSpec, createServer, startServer } = require('./server');
92
92
 
93
93
  module.exports = {
94
94
  audit,
@@ -101,6 +101,7 @@ module.exports = {
101
101
  detectPlatforms,
102
102
  getCatalog,
103
103
  synergyReport,
104
+ buildServeOpenApiSpec,
104
105
  createServer,
105
106
  startServer,
106
107
  DOMAIN_PACKS,
@@ -10,66 +10,113 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const https = require('https');
14
- const http = require('http');
15
- const { URL } = require('url');
16
-
17
- // ─── Webhook delivery ────────────────────────────────────────────────────────
13
+ const https = require('https');
14
+ const http = require('http');
15
+ const { URL } = require('url');
16
+
17
+ const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
18
+
19
+ function wait(ms) {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
22
+
23
+ function sendWebhookOnce(parsed, body, opts = {}) {
24
+ return new Promise((resolve, reject) => {
25
+ const timeoutMs = opts.timeoutMs ?? 10_000;
26
+ const customHeaders = opts.headers || {};
27
+ const headers = {
28
+ 'User-Agent': `nerviq/${require('../package.json').version}`,
29
+ ...customHeaders,
30
+ 'Content-Length': Buffer.byteLength(body),
31
+ };
32
+
33
+ const hasContentTypeHeader = Object.keys(headers).some((name) => name.toLowerCase() === 'content-type');
34
+ if (!hasContentTypeHeader) {
35
+ headers['Content-Type'] = 'application/json';
36
+ }
37
+
38
+ const options = {
39
+ hostname: parsed.hostname,
40
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
41
+ path: parsed.pathname + (parsed.search || ''),
42
+ method: 'POST',
43
+ headers,
44
+ };
45
+
46
+ const transport = parsed.protocol === 'https:' ? https : http;
47
+
48
+ const req = transport.request(options, (res) => {
49
+ const chunks = [];
50
+ res.on('data', (c) => chunks.push(c));
51
+ res.on('end', () => {
52
+ const respBody = Buffer.concat(chunks).toString('utf8');
53
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: respBody });
54
+ });
55
+ });
56
+
57
+ req.setTimeout(timeoutMs, () => {
58
+ req.destroy(new Error(`Webhook request timed out after ${timeoutMs}ms`));
59
+ });
60
+
61
+ req.on('error', reject);
62
+ req.write(body);
63
+ req.end();
64
+ });
65
+ }
66
+
67
+ // ─── Webhook delivery ────────────────────────────────────────────────────────
18
68
 
19
69
  /**
20
70
  * POST JSON payload to a webhook URL.
21
71
  * @param {string} url - Destination URL (http or https)
22
72
  * @param {object} payload - JSON-serialisable object
23
- * @param {object} [opts]
24
- * @param {number} [opts.timeoutMs=10000]
25
- * @param {object} [opts.headers]
26
- * @returns {Promise<{ ok: boolean, status: number, body: string }>}
27
- */
28
- function sendWebhook(url, payload, opts = {}) {
29
- return new Promise((resolve, reject) => {
30
- let parsed;
31
- try {
32
- parsed = new URL(url);
33
- } catch {
34
- return reject(new Error(`Invalid webhook URL: ${url}`));
35
- }
36
-
37
- const body = JSON.stringify(payload);
38
- const timeoutMs = opts.timeoutMs ?? 10_000;
39
-
40
- const options = {
41
- hostname: parsed.hostname,
42
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
43
- path: parsed.pathname + (parsed.search || ''),
44
- method: 'POST',
45
- headers: {
46
- 'Content-Type': 'application/json',
47
- 'Content-Length': Buffer.byteLength(body),
48
- 'User-Agent': `nerviq/${require('../package.json').version}`,
49
- ...(opts.headers || {}),
50
- },
51
- };
52
-
53
- const transport = parsed.protocol === 'https:' ? https : http;
54
-
55
- const req = transport.request(options, (res) => {
56
- const chunks = [];
57
- res.on('data', (c) => chunks.push(c));
58
- res.on('end', () => {
59
- const respBody = Buffer.concat(chunks).toString('utf8');
60
- resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: respBody });
61
- });
62
- });
63
-
64
- req.setTimeout(timeoutMs, () => {
65
- req.destroy(new Error(`Webhook request timed out after ${timeoutMs}ms`));
66
- });
67
-
68
- req.on('error', reject);
69
- req.write(body);
70
- req.end();
71
- });
72
- }
73
+ * @param {object} [opts]
74
+ * @param {number} [opts.timeoutMs=10000]
75
+ * @param {object} [opts.headers]
76
+ * @param {number} [opts.retries=2]
77
+ * @param {number} [opts.retryDelayMs=400]
78
+ * @returns {Promise<{ ok: boolean, status: number, body: string, attempts: number }>}
79
+ */
80
+ async function sendWebhook(url, payload, opts = {}) {
81
+ let parsed;
82
+ try {
83
+ parsed = new URL(url);
84
+ } catch {
85
+ throw new Error(`Invalid webhook URL: ${url}`);
86
+ }
87
+
88
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
89
+ throw new Error(`Unsupported webhook protocol: ${parsed.protocol}`);
90
+ }
91
+
92
+ const body = JSON.stringify(payload);
93
+ const retries = Number.isInteger(opts.retries) && opts.retries >= 0 ? opts.retries : 2;
94
+ const retryDelayMs = Number.isFinite(opts.retryDelayMs) && opts.retryDelayMs >= 0 ? opts.retryDelayMs : 400;
95
+ const maxAttempts = retries + 1;
96
+
97
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
98
+ try {
99
+ const response = await sendWebhookOnce(parsed, body, opts);
100
+ const enriched = { ...response, attempts: attempt };
101
+ const shouldRetry = RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxAttempts;
102
+ if (!shouldRetry) {
103
+ return enriched;
104
+ }
105
+ } catch (error) {
106
+ error.attempts = attempt;
107
+ if (attempt >= maxAttempts) {
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ const delayMs = retryDelayMs * attempt;
113
+ if (delayMs > 0) {
114
+ await wait(delayMs);
115
+ }
116
+ }
117
+
118
+ return { ok: false, status: 0, body: '', attempts: maxAttempts };
119
+ }
73
120
 
74
121
  // ─── Slack formatting ─────────────────────────────────────────────────────────
75
122