@runhalo/cli 0.4.1 → 0.5.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/dist/index.js CHANGED
@@ -655,6 +655,10 @@ function escapeHtml(text) {
655
655
  .replace(/"/g, '"')
656
656
  .replace(/'/g, ''');
657
657
  }
658
+ // ==================== Module-level scan data for cross-function access ====================
659
+ // Stores the last scan's results so the action handler can regenerate the PDF
660
+ // with AI Review Board data after the review board runs.
661
+ const _lastScanData = { results: [], scoreResult: null, fileCount: 0, projectPath: '' };
658
662
  // ==================== PDF Report Generator (P3-2) ====================
659
663
  // PDF color constants
660
664
  const PDF_COLORS = {
@@ -690,11 +694,7 @@ function severityColor(severity) {
690
694
  default: return PDF_COLORS.cyan;
691
695
  }
692
696
  }
693
- /**
694
- * Generate a government-procurement-grade PDF compliance report.
695
- * Uses PDFKit — pure JS, no browser dependencies, CI-safe.
696
- */
697
- function generatePdfReport(results, scoreResult, fileCount, projectPath, history) {
697
+ function generatePdfReport(results, scoreResult, fileCount, projectPath, history, reviewData) {
698
698
  return new Promise((resolve, reject) => {
699
699
  const doc = new pdfkit_1.default({
700
700
  size: 'LETTER',
@@ -983,6 +983,117 @@ function generatePdfReport(results, scoreResult, fileCount, projectPath, history
983
983
  }
984
984
  addFooter();
985
985
  }
986
+ // ═══════════════ AI REVIEW BOARD (Sprint 9) ═══════════════
987
+ if (reviewData && reviewData.results.length > 0) {
988
+ doc.addPage();
989
+ doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('AI Review Board Assessment', 60, 60);
990
+ doc.moveTo(60, 88).lineTo(60 + pageWidth, 88).lineWidth(1).strokeColor(PDF_COLORS.border).stroke();
991
+ y = 100;
992
+ // Review Board summary box
993
+ doc.rect(60, y, pageWidth, 70).fill(PDF_COLORS.lightBg);
994
+ doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
995
+ doc.text(`${reviewData.summary.total} violations reviewed by Halo AI Review Board`, 70, y + 8, { width: pageWidth - 20 });
996
+ y += 22;
997
+ const verdictLine = [
998
+ reviewData.summary.escalated > 0 ? `🔴 ${reviewData.summary.escalated} escalated` : '',
999
+ reviewData.summary.confirmed > 0 ? `🟡 ${reviewData.summary.confirmed} confirmed` : '',
1000
+ reviewData.summary.downgraded > 0 ? `🟢 ${reviewData.summary.downgraded} downgraded` : '',
1001
+ reviewData.summary.dismissed > 0 ? `✅ ${reviewData.summary.dismissed} dismissed` : '',
1002
+ ].filter(Boolean).join(' ');
1003
+ doc.fontSize(9).fillColor(PDF_COLORS.bodyText);
1004
+ doc.text(verdictLine, 70, y, { width: pageWidth - 20 });
1005
+ y += 16;
1006
+ // Marshall summary
1007
+ if (reviewData.marshall_summary) {
1008
+ const ms = reviewData.marshall_summary;
1009
+ doc.fontSize(8).fillColor(PDF_COLORS.mutedText);
1010
+ doc.text(`Marshall Intelligence: ${ms.enriched_count} violations enriched | Avg urgency: ${ms.avg_urgency} | ${ms.active_enforcement_count} in active enforcement areas`, 70, y, { width: pageWidth - 20 });
1011
+ }
1012
+ y += 40; // past the summary box
1013
+ // Per-verdict sections
1014
+ const verdictOrder = [
1015
+ { key: 'escalated', label: 'ESCALATED — More Serious Than Initially Detected', color: PDF_COLORS.red, emoji: '🔴' },
1016
+ { key: 'confirmed', label: 'CONFIRMED — Violations Validated by AI Review', color: PDF_COLORS.orange, emoji: '🟡' },
1017
+ { key: 'downgraded', label: 'DOWNGRADED — Lower Risk Than Severity Suggests', color: PDF_COLORS.green, emoji: '🟢' },
1018
+ { key: 'dismissed', label: 'DISMISSED — False Positives Cleared', color: PDF_COLORS.cyan, emoji: '✅' },
1019
+ ];
1020
+ for (const vType of verdictOrder) {
1021
+ const items = reviewData.results.filter(r => r.verdict === vType.key);
1022
+ if (items.length === 0)
1023
+ continue;
1024
+ if (y > doc.page.height - 140) {
1025
+ addFooter();
1026
+ doc.addPage();
1027
+ y = 60;
1028
+ }
1029
+ doc.fontSize(12).fillColor(vType.color);
1030
+ doc.text(`${vType.emoji} ${vType.label} (${items.length})`, 60, y);
1031
+ y += 20;
1032
+ // Show up to 10 per verdict type
1033
+ const itemsToShow = items.slice(0, 10);
1034
+ for (const item of itemsToShow) {
1035
+ if (y > doc.page.height - 100) {
1036
+ addFooter();
1037
+ doc.addPage();
1038
+ y = 60;
1039
+ }
1040
+ // Rule ID + verdict
1041
+ doc.fontSize(9).fillColor(PDF_COLORS.primary);
1042
+ doc.text(item.ruleId, 70, y);
1043
+ y += 14;
1044
+ // Clinical context
1045
+ if (item.clinicalContext) {
1046
+ doc.fontSize(8).fillColor(PDF_COLORS.bodyText);
1047
+ const ctxHeight = doc.heightOfString(item.clinicalContext, { width: pageWidth - 30 });
1048
+ doc.text(item.clinicalContext, 80, y, { width: pageWidth - 30 });
1049
+ y += ctxHeight + 4;
1050
+ }
1051
+ // Age groups
1052
+ if (item.ageGroupImpact && item.ageGroupImpact.length > 0) {
1053
+ doc.fontSize(7).fillColor(PDF_COLORS.mutedText);
1054
+ doc.text(`Ages most affected: ${item.ageGroupImpact.join(', ')}`, 80, y);
1055
+ y += 12;
1056
+ }
1057
+ // Regulatory context (Marshall enrichment)
1058
+ if (item.regulatoryContext) {
1059
+ const rc = item.regulatoryContext;
1060
+ const priorityLabel = rc.enforcement_priority === 'active' ? '🔴 ACTIVE'
1061
+ : rc.enforcement_priority === 'watching' ? '🟡 WATCHING' : '⚪ DORMANT';
1062
+ doc.fontSize(7).fillColor(PDF_COLORS.red);
1063
+ doc.text(`Regulatory: ${rc.regulation} | ${priorityLabel} | Penalty: ${rc.penalty_exposure} | Urgency: ${rc.urgency_score}`, 80, y, { width: pageWidth - 30 });
1064
+ y += 12;
1065
+ if (rc.recent_case) {
1066
+ doc.fontSize(7).fillColor(PDF_COLORS.mutedText);
1067
+ doc.text(`Recent precedent: ${rc.recent_case}`, 80, y, { width: pageWidth - 30 });
1068
+ y += 12;
1069
+ }
1070
+ }
1071
+ // Remediation
1072
+ if (item.remediationGuidance) {
1073
+ doc.fontSize(8).fillColor(PDF_COLORS.green);
1074
+ const remHeight = doc.heightOfString(`Fix: ${item.remediationGuidance}`, { width: pageWidth - 30 });
1075
+ doc.text(`Fix: ${item.remediationGuidance}`, 80, y, { width: pageWidth - 30 });
1076
+ y += remHeight + 4;
1077
+ }
1078
+ y += 8;
1079
+ }
1080
+ if (items.length > 10) {
1081
+ doc.fontSize(8).fillColor(PDF_COLORS.mutedText);
1082
+ doc.text(`+ ${items.length - 10} more ${vType.key} violation(s). See full results on your Halo Dashboard.`, 80, y, { width: pageWidth - 30 });
1083
+ y += 16;
1084
+ }
1085
+ y += 10;
1086
+ }
1087
+ // Review Board footer note
1088
+ if (y > doc.page.height - 80) {
1089
+ addFooter();
1090
+ doc.addPage();
1091
+ y = 60;
1092
+ }
1093
+ doc.fontSize(7).fillColor(PDF_COLORS.lightText);
1094
+ doc.text(`Reviewed by Halo AI Review Board (Richard + Marshall) in ${reviewData.latency_ms}ms. Cost: $${reviewData.cost.estimated_usd.toFixed(4)}. ${reviewData.summary.cache_hits} results served from cache.`, 60, y, { width: pageWidth });
1095
+ addFooter();
1096
+ }
986
1097
  // ═══════════════ RECOMMENDATIONS ═══════════════
987
1098
  doc.addPage();
988
1099
  doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Recommendations', 60, 60);
@@ -1068,6 +1179,122 @@ function loadHaloignore(startDir) {
1068
1179
  }
1069
1180
  return undefined;
1070
1181
  }
1182
+ /**
1183
+ * Detect the project framework by scanning project files.
1184
+ * Checks package.json, Gemfile, go.mod, Cargo.toml, manage.py, requirements.txt.
1185
+ * Returns a framework identifier string or null if unknown.
1186
+ */
1187
+ function detectProjectFramework(dir) {
1188
+ // Check package.json for JS/TS frameworks
1189
+ const pkgPath = path.join(dir, 'package.json');
1190
+ if (fs.existsSync(pkgPath)) {
1191
+ try {
1192
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
1193
+ const allDeps = {
1194
+ ...(pkg.dependencies || {}),
1195
+ ...(pkg.devDependencies || {}),
1196
+ };
1197
+ // Order matters: check specific frameworks before generic ones
1198
+ if (allDeps['next'])
1199
+ return 'nextjs';
1200
+ if (allDeps['@angular/core'])
1201
+ return 'angular';
1202
+ if (allDeps['vue'])
1203
+ return 'vue';
1204
+ if (allDeps['svelte'])
1205
+ return 'svelte';
1206
+ if (allDeps['react'])
1207
+ return 'react';
1208
+ }
1209
+ catch {
1210
+ // Malformed package.json — continue detection
1211
+ }
1212
+ }
1213
+ // Django: manage.py or requirements.txt with django
1214
+ if (fs.existsSync(path.join(dir, 'manage.py')))
1215
+ return 'django';
1216
+ const reqPath = path.join(dir, 'requirements.txt');
1217
+ if (fs.existsSync(reqPath)) {
1218
+ try {
1219
+ const reqs = fs.readFileSync(reqPath, 'utf-8').toLowerCase();
1220
+ if (reqs.includes('django'))
1221
+ return 'django';
1222
+ }
1223
+ catch {
1224
+ // Continue detection
1225
+ }
1226
+ }
1227
+ // Rails: Gemfile with rails
1228
+ const gemfilePath = path.join(dir, 'Gemfile');
1229
+ if (fs.existsSync(gemfilePath)) {
1230
+ try {
1231
+ const gemfile = fs.readFileSync(gemfilePath, 'utf-8').toLowerCase();
1232
+ if (gemfile.includes('rails'))
1233
+ return 'rails';
1234
+ }
1235
+ catch {
1236
+ // Continue detection
1237
+ }
1238
+ }
1239
+ // Go
1240
+ if (fs.existsSync(path.join(dir, 'go.mod')))
1241
+ return 'go';
1242
+ // Rust
1243
+ if (fs.existsSync(path.join(dir, 'Cargo.toml')))
1244
+ return 'rust';
1245
+ return null;
1246
+ }
1247
+ /**
1248
+ * Get default .haloignore content based on detected framework.
1249
+ */
1250
+ function getDefaultHaloignoreContent(framework) {
1251
+ const lines = [
1252
+ '# Halo ignore patterns',
1253
+ '# Generated by: runhalo init',
1254
+ '',
1255
+ 'node_modules/',
1256
+ 'dist/',
1257
+ 'build/',
1258
+ 'coverage/',
1259
+ '*.min.js',
1260
+ '*.bundle.js',
1261
+ ];
1262
+ // Add framework-specific ignores
1263
+ switch (framework) {
1264
+ case 'nextjs':
1265
+ lines.push('.next/');
1266
+ lines.push('.vercel/');
1267
+ break;
1268
+ case 'angular':
1269
+ lines.push('.angular/');
1270
+ break;
1271
+ case 'vue':
1272
+ lines.push('.nuxt/');
1273
+ break;
1274
+ case 'svelte':
1275
+ lines.push('.svelte-kit/');
1276
+ break;
1277
+ case 'django':
1278
+ lines.push('__pycache__/');
1279
+ lines.push('*.pyc');
1280
+ lines.push('.venv/');
1281
+ lines.push('venv/');
1282
+ break;
1283
+ case 'rails':
1284
+ lines.push('tmp/');
1285
+ lines.push('log/');
1286
+ lines.push('vendor/bundle/');
1287
+ break;
1288
+ case 'go':
1289
+ lines.push('vendor/');
1290
+ break;
1291
+ case 'rust':
1292
+ lines.push('target/');
1293
+ break;
1294
+ }
1295
+ lines.push('');
1296
+ return lines.join('\n');
1297
+ }
1071
1298
  /**
1072
1299
  * Create a Halo engine instance
1073
1300
  */
@@ -1358,6 +1585,108 @@ function saveHistory(entry) {
1358
1585
  // Silent failure — never block scan
1359
1586
  }
1360
1587
  }
1588
+ /**
1589
+ * Send webhook notifications to Discord and/or Slack after a scan completes.
1590
+ * Non-blocking — failures are logged but never affect the scan exit code.
1591
+ */
1592
+ async function sendWebhookNotifications(rcConfig, lastEntry, verbose) {
1593
+ const notifications = rcConfig?.notifications;
1594
+ if (!notifications)
1595
+ return;
1596
+ const { discord_webhook, slack_webhook } = notifications;
1597
+ if (!discord_webhook && !slack_webhook)
1598
+ return;
1599
+ const hasFailed = lastEntry.totalViolations > 0;
1600
+ const filesScanned = String(lastEntry.filesScanned);
1601
+ const violations = String(lastEntry.totalViolations);
1602
+ const grade = lastEntry.grade || 'N/A';
1603
+ const topRules = lastEntry.rulesTriggered.slice(0, 5).join(', ') || 'None';
1604
+ const timestamp = new Date().toISOString();
1605
+ // Discord webhook
1606
+ if (discord_webhook) {
1607
+ try {
1608
+ const discordPayload = {
1609
+ embeds: [{
1610
+ title: '\u{1F6E1}\uFE0F Halo Scan Complete',
1611
+ color: hasFailed ? 15158332 : 3066993,
1612
+ fields: [
1613
+ { name: 'Files Scanned', value: filesScanned, inline: true },
1614
+ { name: 'Violations', value: violations, inline: true },
1615
+ { name: 'Grade', value: grade, inline: true },
1616
+ { name: 'Top Rules', value: topRules, inline: false },
1617
+ ],
1618
+ footer: { text: 'Halo \u2014 runhalo.dev' },
1619
+ timestamp,
1620
+ }],
1621
+ };
1622
+ const controller = new AbortController();
1623
+ const timeout = setTimeout(() => controller.abort(), 5000);
1624
+ const res = await fetch(discord_webhook, {
1625
+ method: 'POST',
1626
+ headers: { 'Content-Type': 'application/json' },
1627
+ body: JSON.stringify(discordPayload),
1628
+ signal: controller.signal,
1629
+ });
1630
+ clearTimeout(timeout);
1631
+ if (verbose) {
1632
+ console.error(res.ok
1633
+ ? '\u2709\uFE0F Notification sent to Discord'
1634
+ : `\u26A0\uFE0F Discord webhook returned ${res.status}`);
1635
+ }
1636
+ }
1637
+ catch (err) {
1638
+ if (verbose) {
1639
+ console.error(`\u26A0\uFE0F Discord webhook failed: ${err instanceof Error ? err.message : err}`);
1640
+ }
1641
+ }
1642
+ }
1643
+ // Slack webhook
1644
+ if (slack_webhook) {
1645
+ try {
1646
+ const slackPayload = {
1647
+ blocks: [
1648
+ {
1649
+ type: 'header',
1650
+ text: { type: 'plain_text', text: '\u{1F6E1}\uFE0F Halo Scan Complete' },
1651
+ },
1652
+ {
1653
+ type: 'section',
1654
+ fields: [
1655
+ { type: 'mrkdwn', text: `*Files Scanned*\n${filesScanned}` },
1656
+ { type: 'mrkdwn', text: `*Violations*\n${violations}` },
1657
+ { type: 'mrkdwn', text: `*Grade*\n${grade}` },
1658
+ ],
1659
+ },
1660
+ {
1661
+ type: 'section',
1662
+ fields: [
1663
+ { type: 'mrkdwn', text: `*Top Rules*\n${topRules}` },
1664
+ ],
1665
+ },
1666
+ ],
1667
+ };
1668
+ const controller = new AbortController();
1669
+ const timeout = setTimeout(() => controller.abort(), 5000);
1670
+ const res = await fetch(slack_webhook, {
1671
+ method: 'POST',
1672
+ headers: { 'Content-Type': 'application/json' },
1673
+ body: JSON.stringify(slackPayload),
1674
+ signal: controller.signal,
1675
+ });
1676
+ clearTimeout(timeout);
1677
+ if (verbose) {
1678
+ console.error(res.ok
1679
+ ? '\u2709\uFE0F Notification sent to Slack'
1680
+ : `\u26A0\uFE0F Slack webhook returned ${res.status}`);
1681
+ }
1682
+ }
1683
+ catch (err) {
1684
+ if (verbose) {
1685
+ console.error(`\u26A0\uFE0F Slack webhook failed: ${err instanceof Error ? err.message : err}`);
1686
+ }
1687
+ }
1688
+ }
1689
+ }
1361
1690
  async function submitCliLead(email) {
1362
1691
  try {
1363
1692
  const res = await fetch(`${SUPABASE_URL}/rest/v1/halo_leads`, {
@@ -1757,6 +2086,7 @@ async function scan(paths, options) {
1757
2086
  'au-sbd': 'AU Safety by Design',
1758
2087
  'au-osa': 'AU Online Safety Act',
1759
2088
  'caadca': 'California AADCA',
2089
+ 'eu-ai-act': 'EU AI Act (Children)',
1760
2090
  };
1761
2091
  const packLabel = packs.map(p => packNameMap[p] || p).join(' + ');
1762
2092
  console.error(c(colors.dim, `🔍 Scanning ${uniqueFiles.length} files (${packLabel})...`));
@@ -1843,7 +2173,14 @@ async function scan(paths, options) {
1843
2173
  output += trendLine + '\n';
1844
2174
  }
1845
2175
  }
2176
+ // Store scan data for potential PDF regeneration with AI Review Board data
2177
+ _lastScanData.results = results;
2178
+ _lastScanData.scoreResult = scoreResult;
2179
+ _lastScanData.fileCount = fileCount;
2180
+ _lastScanData.projectPath = projectPath;
1846
2181
  // Generate report if requested (HTML or PDF based on filename extension)
2182
+ // Note: if --review-board is also set, the PDF will be regenerated with AI data
2183
+ // in the action handler after the review board completes.
1847
2184
  if (options.report) {
1848
2185
  const reportFilename = typeof options.report === 'string'
1849
2186
  ? options.report
@@ -2132,6 +2469,8 @@ program
2132
2469
  .option('--report [filename]', 'Generate HTML compliance report (default: halo-report.html)')
2133
2470
  .option('--upload', 'Upload scan results to Halo Dashboard (requires Pro)')
2134
2471
  .option('--watch', 'Watch for file changes and re-scan automatically')
2472
+ .option('--review-board', 'Enable AI Review Board — clinical assessment of each violation (Pro/Enterprise)')
2473
+ .option('--license-key <key>', 'License key for Pro/Enterprise features (or set HALO_LICENSE_KEY env var)')
2135
2474
  .option('--no-prompt', 'Skip first-run email prompt')
2136
2475
  .option('-v, --verbose', 'Verbose output')
2137
2476
  .action(async (paths, options) => {
@@ -2156,6 +2495,9 @@ program
2156
2495
  if (options.upload && !checkProFeature('Dashboard Upload', '--upload')) {
2157
2496
  process.exit(0);
2158
2497
  }
2498
+ if (options.reviewBoard && !checkProFeature('AI Review Board', '--review-board')) {
2499
+ process.exit(0);
2500
+ }
2159
2501
  // Scan limit check (soft — exit 0, not error)
2160
2502
  if (!checkScanLimit()) {
2161
2503
  process.exit(0);
@@ -2206,6 +2548,9 @@ program
2206
2548
  // Sprint 8: Pass framework and AST config from .halorc.json to scan()
2207
2549
  framework: rcConfig?.framework,
2208
2550
  astAnalysis: rcConfig?.astAnalysis,
2551
+ // Sprint 9: AI Review Board
2552
+ reviewBoard: options.reviewBoard || rcConfig?.reviewBoard || false,
2553
+ licenseKey: options.licenseKey || process.env.HALO_LICENSE_KEY || rcConfig?.licenseKey,
2209
2554
  };
2210
2555
  // ==================== Watch Mode ====================
2211
2556
  if (options.watch) {
@@ -2360,6 +2705,124 @@ program
2360
2705
  }
2361
2706
  }
2362
2707
  }
2708
+ // ==================== AI Review Board (Sprint 9) ====================
2709
+ let _reviewBoardResult;
2710
+ if (scanOptions.reviewBoard) {
2711
+ const licenseKey = scanOptions.licenseKey || loadConfig().license_key;
2712
+ if (!licenseKey) {
2713
+ console.error('⚠️ AI Review Board requires a license key. Run `halo activate <key>` or pass --license-key.');
2714
+ }
2715
+ else {
2716
+ try {
2717
+ const history = loadHistory();
2718
+ const lastEntry = history[history.length - 1];
2719
+ if (lastEntry && lastEntry.rulesTriggered.length > 0) {
2720
+ console.error('\n🤖 Running AI Review Board...');
2721
+ const reviewUrl = 'https://wrfwcmyxxbafcdvxlmug.supabase.co/functions/v1/ai-review';
2722
+ // Build violations from scan results for review
2723
+ // We re-scan in JSON to get structured violations
2724
+ const jsonResults = await scan(paths, { ...scanOptions, format: 'json', output: '' });
2725
+ // Read the last JSON output from scan history
2726
+ const reviewHistory = loadHistory();
2727
+ const reviewEntry = reviewHistory[reviewHistory.length - 1];
2728
+ if (reviewEntry) {
2729
+ const reviewRes = await fetch(reviewUrl, {
2730
+ method: 'POST',
2731
+ headers: { 'Content-Type': 'application/json' },
2732
+ body: JSON.stringify({
2733
+ license_key: licenseKey,
2734
+ violations: reviewEntry.violations || [],
2735
+ repo_metadata: {
2736
+ framework: scanOptions.framework,
2737
+ },
2738
+ }),
2739
+ });
2740
+ if (reviewRes.ok) {
2741
+ const review = await reviewRes.json();
2742
+ _reviewBoardResult = review; // Store for PDF report
2743
+ // Display Review Board results
2744
+ console.error(`\n🛡️ Halo AI Review Board — ${review.summary.total} violations analyzed (${review.latency_ms}ms)\n`);
2745
+ const critical = review.results.filter(r => r.verdict === 'escalated');
2746
+ const confirmed = review.results.filter(r => r.verdict === 'confirmed');
2747
+ const downgraded = review.results.filter(r => r.verdict === 'downgraded');
2748
+ const dismissed = review.results.filter(r => r.verdict === 'dismissed');
2749
+ if (critical.length > 0) {
2750
+ console.error(`🔴 ESCALATED (${critical.length}) — More serious than initially detected`);
2751
+ for (const r of critical) {
2752
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2753
+ if (r.ageGroupImpact.length > 0)
2754
+ console.error(` Ages most affected: ${r.ageGroupImpact.join(', ')}`);
2755
+ if (r.remediationGuidance)
2756
+ console.error(` Fix: ${r.remediationGuidance}`);
2757
+ }
2758
+ console.error('');
2759
+ }
2760
+ if (confirmed.length > 0) {
2761
+ console.error(`🟡 CONFIRMED (${confirmed.length}) — Violations validated by AI review`);
2762
+ for (const r of confirmed) {
2763
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2764
+ }
2765
+ console.error('');
2766
+ }
2767
+ if (downgraded.length > 0) {
2768
+ console.error(`🟢 DOWNGRADED (${downgraded.length}) — Lower risk than severity suggests`);
2769
+ for (const r of downgraded) {
2770
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2771
+ }
2772
+ console.error('');
2773
+ }
2774
+ if (dismissed.length > 0) {
2775
+ console.error(`✅ DISMISSED (${dismissed.length}) — False positives cleared by AI review`);
2776
+ for (const r of dismissed) {
2777
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2778
+ }
2779
+ console.error('');
2780
+ }
2781
+ const cacheStr = review.summary.cache_hits > 0 ? ` (${review.summary.cache_hits} cached)` : '';
2782
+ console.error(`📊 Cost: $${review.cost.estimated_usd.toFixed(4)}${cacheStr}`);
2783
+ console.error(`🤖 Reviewed by: Halo AI Review Board (${review.latency_ms}ms)\n`);
2784
+ }
2785
+ else {
2786
+ const err = await reviewRes.json().catch(() => ({}));
2787
+ console.error(`⚠️ AI Review failed: ${err.error || reviewRes.statusText}`);
2788
+ }
2789
+ }
2790
+ }
2791
+ }
2792
+ catch (reviewErr) {
2793
+ console.error(`⚠️ AI Review failed: ${reviewErr instanceof Error ? reviewErr.message : reviewErr}`);
2794
+ }
2795
+ }
2796
+ }
2797
+ // ==================== Generate PDF Report with AI Review Board data ====================
2798
+ // If both --review-board and --report *.pdf are set, regenerate the PDF with review data
2799
+ if (options.report && _reviewBoardResult) {
2800
+ const reportFilename = typeof options.report === 'string'
2801
+ ? options.report
2802
+ : 'halo-report.html';
2803
+ if (reportFilename.endsWith('.pdf') && _lastScanData.results.length > 0) {
2804
+ const projectHistory = loadHistory().filter(h => h.projectPath === _lastScanData.projectPath);
2805
+ const historyForReport = projectHistory.slice(0, -1);
2806
+ const pdfBuffer = await generatePdfReport(_lastScanData.results, _lastScanData.scoreResult, _lastScanData.fileCount, _lastScanData.projectPath, historyForReport, _reviewBoardResult);
2807
+ fs.writeFileSync(reportFilename, pdfBuffer);
2808
+ console.error(`📄 PDF report updated with AI Review Board assessment`);
2809
+ }
2810
+ }
2811
+ // ==================== Webhook Notifications (Discord/Slack) ====================
2812
+ if (rcConfig?.notifications) {
2813
+ try {
2814
+ const history = loadHistory();
2815
+ const lastEntry = history[history.length - 1];
2816
+ if (lastEntry) {
2817
+ await sendWebhookNotifications(rcConfig, lastEntry, options.verbose);
2818
+ }
2819
+ }
2820
+ catch (notifyErr) {
2821
+ if (options.verbose) {
2822
+ console.error(`\u26A0\uFE0F Webhook notification error: ${notifyErr instanceof Error ? notifyErr.message : notifyErr}`);
2823
+ }
2824
+ }
2825
+ }
2363
2826
  // Upload to Halo Dashboard (non-blocking — upload failure doesn't affect exit code)
2364
2827
  if (options.upload) {
2365
2828
  try {
@@ -2607,78 +3070,142 @@ function getCopilotInstructionsContent() {
2607
3070
  return getIDERulesContent();
2608
3071
  }
2609
3072
  /**
2610
- * Init command — generate IDE rules files and project configuration.
3073
+ * Init command — detect framework, generate .halorc.json, .haloignore, and IDE rules files.
2611
3074
  */
2612
3075
  async function init(projectPath, options) {
2613
3076
  const resolvedPath = path.resolve(projectPath);
2614
- if (!options.ide) {
2615
- console.log('🔮 Halo init — project setup');
3077
+ // --ide flag: generate AI coding assistant rules files (existing behavior)
3078
+ if (options.ide) {
3079
+ console.log('🔮 Halo init — Generating AI coding assistant rules...\n');
3080
+ const files = [
3081
+ {
3082
+ path: path.join(resolvedPath, '.cursor', 'rules'),
3083
+ content: getCursorRulesContent(),
3084
+ label: 'Cursor'
3085
+ },
3086
+ {
3087
+ path: path.join(resolvedPath, '.windsurfrules'),
3088
+ content: getWindsurfRulesContent(),
3089
+ label: 'Windsurf'
3090
+ },
3091
+ {
3092
+ path: path.join(resolvedPath, '.github', 'copilot-instructions.md'),
3093
+ content: getCopilotInstructionsContent(),
3094
+ label: 'GitHub Copilot'
3095
+ }
3096
+ ];
3097
+ let created = 0;
3098
+ let skipped = 0;
3099
+ for (const file of files) {
3100
+ const dir = path.dirname(file.path);
3101
+ const relativePath = path.relative(resolvedPath, file.path);
3102
+ if (fs.existsSync(file.path) && !options.force) {
3103
+ console.log(` ⏭ ${relativePath} (exists — use --force to overwrite)`);
3104
+ skipped++;
3105
+ continue;
3106
+ }
3107
+ try {
3108
+ if (!fs.existsSync(dir)) {
3109
+ fs.mkdirSync(dir, { recursive: true });
3110
+ }
3111
+ fs.writeFileSync(file.path, file.content, 'utf-8');
3112
+ console.log(` ✅ ${relativePath} — ${file.label} rules`);
3113
+ created++;
3114
+ }
3115
+ catch (err) {
3116
+ console.error(` ❌ ${relativePath} — ${err instanceof Error ? err.message : err}`);
3117
+ }
3118
+ }
2616
3119
  console.log('');
2617
- console.log('Usage:');
2618
- console.log(' runhalo init --ide Generate AI coding assistant rules files');
3120
+ if (created > 0) {
3121
+ console.log(`Created ${created} rules file${created > 1 ? 's' : ''}. Your AI assistant now knows COPPA 2.0.`);
3122
+ }
3123
+ if (skipped > 0) {
3124
+ console.log(`Skipped ${skipped} existing file${skipped > 1 ? 's' : ''}.`);
3125
+ }
2619
3126
  console.log('');
2620
- console.log('Options:');
2621
- console.log(' --ide Generate .cursor/rules, .windsurfrules, .github/copilot-instructions.md');
2622
- console.log(' --force Overwrite existing rules files');
3127
+ console.log('What happens next:');
3128
+ console.log(' Cursor, Windsurf, and Copilot will read these rules automatically');
3129
+ console.log(' AI-generated code will follow COPPA compliance patterns');
3130
+ console.log(' • Run "npx runhalo scan ." to verify compliance');
3131
+ console.log('');
3132
+ console.log('Full-stack compliance for the AI coding era:');
3133
+ console.log(' CI: uses: runhalo/action@v1 catches violations in PRs');
3134
+ console.log(' Local: npx runhalo scan . catches violations on your machine');
3135
+ console.log(' Proactive: AI rules files prevent violations before they\'re written');
2623
3136
  return 0;
2624
3137
  }
2625
- console.log('🔮 Halo init Generating AI coding assistant rules...\n');
2626
- const files = [
2627
- {
2628
- path: path.join(resolvedPath, '.cursor', 'rules'),
2629
- content: getCursorRulesContent(),
2630
- label: 'Cursor'
2631
- },
2632
- {
2633
- path: path.join(resolvedPath, '.windsurfrules'),
2634
- content: getWindsurfRulesContent(),
2635
- label: 'Windsurf'
2636
- },
2637
- {
2638
- path: path.join(resolvedPath, '.github', 'copilot-instructions.md'),
2639
- content: getCopilotInstructionsContent(),
2640
- label: 'GitHub Copilot'
3138
+ // Default init: auto-detect framework, generate .halorc.json and .haloignore
3139
+ console.log('🔮 Halo init — project setup\n');
3140
+ // Step 1: Detect framework
3141
+ const detectedFramework = detectProjectFramework(resolvedPath);
3142
+ if (detectedFramework) {
3143
+ console.log(` 🔍 Detected framework: ${c(colors.bold, detectedFramework)}`);
3144
+ }
3145
+ else {
3146
+ console.log(` 🔍 Framework: ${c(colors.dim, 'not detected (generic config will be generated)')}`);
3147
+ }
3148
+ let configCreated = false;
3149
+ let ignoreCreated = false;
3150
+ // Step 2: Generate .halorc.json
3151
+ const rcPath = path.join(resolvedPath, '.halorc.json');
3152
+ if (fs.existsSync(rcPath) && !options.force) {
3153
+ console.log(` ⏭ .halorc.json (exists — use --force to overwrite)`);
3154
+ }
3155
+ else {
3156
+ const rcConfig = {
3157
+ packs: ['coppa', 'ethical'],
3158
+ severity_threshold: 'medium',
3159
+ ignore: ['**/test/**', '**/__tests__/**', '**/node_modules/**'],
3160
+ astAnalysis: true,
3161
+ notifications: {},
3162
+ };
3163
+ if (detectedFramework) {
3164
+ rcConfig.framework = detectedFramework;
2641
3165
  }
2642
- ];
2643
- let created = 0;
2644
- let skipped = 0;
2645
- for (const file of files) {
2646
- const dir = path.dirname(file.path);
2647
- const relativePath = path.relative(resolvedPath, file.path);
2648
- if (fs.existsSync(file.path) && !options.force) {
2649
- console.log(` ⏭ ${relativePath} (exists — use --force to overwrite)`);
2650
- skipped++;
2651
- continue;
3166
+ try {
3167
+ fs.writeFileSync(rcPath, JSON.stringify(rcConfig, null, 2) + '\n', 'utf-8');
3168
+ console.log(` ✅ .halorc.json project configuration`);
3169
+ configCreated = true;
3170
+ }
3171
+ catch (err) {
3172
+ console.error(` ❌ .halorc.json ${err instanceof Error ? err.message : err}`);
2652
3173
  }
3174
+ }
3175
+ // Step 3: Generate .haloignore
3176
+ const ignorePath = path.join(resolvedPath, '.haloignore');
3177
+ if (fs.existsSync(ignorePath) && !options.force) {
3178
+ console.log(` ⏭ .haloignore (exists — use --force to overwrite)`);
3179
+ }
3180
+ else {
2653
3181
  try {
2654
- if (!fs.existsSync(dir)) {
2655
- fs.mkdirSync(dir, { recursive: true });
2656
- }
2657
- fs.writeFileSync(file.path, file.content, 'utf-8');
2658
- console.log(` ✅ ${relativePath} — ${file.label} rules`);
2659
- created++;
3182
+ const ignoreContent = getDefaultHaloignoreContent(detectedFramework);
3183
+ fs.writeFileSync(ignorePath, ignoreContent, 'utf-8');
3184
+ console.log(` ✅ .haloignore — scan exclusion patterns`);
3185
+ ignoreCreated = true;
2660
3186
  }
2661
3187
  catch (err) {
2662
- console.error(` ❌ ${relativePath} — ${err instanceof Error ? err.message : err}`);
3188
+ console.error(` ❌ .haloignore — ${err instanceof Error ? err.message : err}`);
2663
3189
  }
2664
3190
  }
3191
+ // Step 4: Summary
2665
3192
  console.log('');
2666
- if (created > 0) {
2667
- console.log(`Created ${created} rules file${created > 1 ? 's' : ''}. Your AI assistant now knows COPPA 2.0.`);
3193
+ if (configCreated || ignoreCreated) {
3194
+ const parts = [];
3195
+ if (configCreated)
3196
+ parts.push('.halorc.json');
3197
+ if (ignoreCreated)
3198
+ parts.push('.haloignore');
3199
+ console.log(`Created: ${parts.join(', ')}`);
2668
3200
  }
2669
- if (skipped > 0) {
2670
- console.log(`Skipped ${skipped} existing file${skipped > 1 ? 's' : ''}.`);
3201
+ else {
3202
+ console.log('No files created (all exist use --force to overwrite).');
2671
3203
  }
2672
3204
  console.log('');
2673
- console.log('What happens next:');
2674
- console.log(' • Cursor, Windsurf, and Copilot will read these rules automatically');
2675
- console.log(' AI-generated code will follow COPPA compliance patterns');
2676
- console.log(' • Run "npx runhalo scan ." to verify compliance');
3205
+ console.log('Next steps:');
3206
+ console.log(` ${c(colors.bold, 'npx runhalo scan .')} Scan your project for compliance issues`);
3207
+ console.log(` ${c(colors.bold, 'npx runhalo init --ide')} Generate AI coding assistant rules files`);
2677
3208
  console.log('');
2678
- console.log('Full-stack compliance for the AI coding era:');
2679
- console.log(' CI: uses: runhalo/action@v1 catches violations in PRs');
2680
- console.log(' Local: npx runhalo scan . catches violations on your machine');
2681
- console.log(' Proactive: AI rules files prevent violations before they\'re written');
2682
3209
  return 0;
2683
3210
  }
2684
3211
  program
@@ -2811,10 +3338,10 @@ program
2811
3338
  });
2812
3339
  program
2813
3340
  .command('init')
2814
- .description('Initialize Halo in your project (generate IDE rules files)')
3341
+ .description('Initialize Halo in your project (detect framework, generate .halorc.json and .haloignore)')
2815
3342
  .argument('[path]', 'Project root path (default: current directory)', '.')
2816
3343
  .option('--ide', 'Generate AI coding assistant rules files', false)
2817
- .option('--force', 'Overwrite existing rules files', false)
3344
+ .option('--force', 'Overwrite existing files', false)
2818
3345
  .action(async (projectPath, options) => {
2819
3346
  try {
2820
3347
  const exitCode = await init(projectPath, {