@runhalo/cli 0.4.1 → 0.6.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
@@ -180,7 +180,7 @@ function formatSARIF(results, rules) {
180
180
  name: r.name,
181
181
  shortDescription: { text: r.description },
182
182
  helpUri: `https://runhalo.dev/rules/${r.id}`,
183
- defaultConfiguration: { level: r.severity }
183
+ defaultConfiguration: { level: r.severity === 'critical' || r.severity === 'high' ? 'error' : r.severity === 'medium' ? 'warning' : 'note' }
184
184
  }))
185
185
  }
186
186
  },
@@ -248,6 +248,81 @@ const useColor = !process.env.NO_COLOR && process.stdout.isTTY !== false;
248
248
  function c(color, text) {
249
249
  return useColor ? `${color}${text}${colors.reset}` : text;
250
250
  }
251
+ // Enforcement lookup: rule_id → matching enforcement cases
252
+ // Source of truth: halo-admin/src/data/regulatory-actions.ts
253
+ const ENFORCEMENT_DATA = {};
254
+ // Build the lookup table from inline enforcement data
255
+ (function buildEnforcementLookup() {
256
+ const cases = [
257
+ { company: 'Epic Games', fine: 275_000_000, date: '2022-12', jurisdiction: 'COPPA', violation: 'Tracking + dark patterns in child accounts', rules: [{ id: 'coppa-tracking-003', c: 'direct' }, { id: 'coppa-ui-008', c: 'direct' }] },
258
+ { company: 'Amazon Alexa', fine: 25_000_000, date: '2023-05', jurisdiction: 'COPPA', violation: 'Voice recording retention without deletion workflows', rules: [{ id: 'coppa-audio-007', c: 'direct' }, { id: 'coppa-data-002', c: 'related' }] },
259
+ { company: 'Microsoft Xbox', fine: 20_000_000, date: '2023-06', jurisdiction: 'COPPA', violation: 'Data collection before verifiable parental consent', rules: [{ id: 'coppa-auth-001', c: 'direct' }] },
260
+ { company: 'Disney YouTube', fine: 10_000_000, date: '2025-03', jurisdiction: 'COPPA', violation: 'Third-party data collection on kids channels', rules: [{ id: 'coppa-ext-017', c: 'direct' }, { id: 'coppa-tracking-003', c: 'direct' }] },
261
+ { company: 'Edmodo', fine: 6_000_000, date: '2023-05', jurisdiction: 'COPPA', violation: 'Kids data used for advertising profiles', rules: [{ id: 'coppa-tracking-003', c: 'direct' }, { id: 'coppa-analytics-018', c: 'direct' }] },
262
+ { company: 'NGL Labs', fine: 5_000_000, date: '2024-07', jurisdiction: 'COPPA', violation: 'Anonymous messaging + safety risk exposure', rules: [{ id: 'coppa-default-020', c: 'direct' }, { id: 'coppa-ext-011', c: 'direct' }, { id: 'coppa-ui-008', c: 'related' }] },
263
+ { company: 'HyperBeard', fine: 4_000_000, date: '2020-06', jurisdiction: 'COPPA', violation: 'Ad network tracking inside kids games', rules: [{ id: 'coppa-tracking-003', c: 'direct' }, { id: 'coppa-analytics-018', c: 'direct' }] },
264
+ { company: 'Kuuhubb Recolor', fine: 3_000_000, date: '2021-07', jurisdiction: 'COPPA', violation: 'Data collection without consent flow', rules: [{ id: 'coppa-auth-001', c: 'direct' }] },
265
+ { company: 'OpenX', fine: 2_000_000, date: '2021-12', jurisdiction: 'COPPA', violation: 'Location data harvested from kids apps', rules: [{ id: 'coppa-geo-004', c: 'direct' }, { id: 'coppa-tracking-003', c: 'direct' }] },
266
+ { company: 'Weight Watchers Kurbo', fine: 1_500_000, date: '2022-03', jurisdiction: 'COPPA', violation: 'Health data captured without consent', rules: [{ id: 'coppa-bio-012', c: 'direct' }, { id: 'coppa-sec-006', c: 'related' }] },
267
+ { company: 'Tilting Point Media', fine: 500_000, date: '2024-06', jurisdiction: 'COPPA/CCPA', violation: 'Unauthorized disclosure of kids info', rules: [{ id: 'coppa-ext-017', c: 'direct' }, { id: 'coppa-data-002', c: 'direct' }] },
268
+ { company: 'Instagram (Meta)', fine: 405_000_000, date: '2022-09', jurisdiction: 'GDPR Art.8', violation: 'Exposed children\'s contact info publicly', rules: [{ id: 'coppa-default-020', c: 'direct' }, { id: 'coppa-auth-001', c: 'direct' }, { id: 'coppa-sec-006', c: 'related' }] },
269
+ { company: 'TikTok (EU)', fine: 345_000_000, date: '2023-09', jurisdiction: 'GDPR Art.8', violation: 'Default public accounts for children, dark patterns', rules: [{ id: 'coppa-default-020', c: 'direct' }, { id: 'coppa-ui-008', c: 'direct' }, { id: 'coppa-auth-001', c: 'direct' }] },
270
+ { company: 'Google (YouTube)', fine: 170_000_000, date: '2019-09', jurisdiction: 'COPPA', violation: 'Cookies + persistent identifiers from children\'s channels', rules: [{ id: 'coppa-cookies-016', c: 'direct' }, { id: 'coppa-tracking-003', c: 'direct' }, { id: 'coppa-analytics-018', c: 'direct' }] },
271
+ { company: 'Reddit', fine: 14_470_000, date: '2026-02', jurisdiction: 'AADC', violation: 'Inadequate age gating under UK Children\'s Code', rules: [{ id: 'coppa-auth-001', c: 'direct' }, { id: 'coppa-default-020', c: 'direct' }, { id: 'coppa-ui-008', c: 'related' }] },
272
+ { company: 'Musical.ly (TikTok)', fine: 5_700_000, date: '2019-02', jurisdiction: 'COPPA', violation: 'PII collection from children, social features without safeguards', rules: [{ id: 'coppa-default-020', c: 'direct' }, { id: 'coppa-ext-011', c: 'direct' }, { id: 'coppa-auth-001', c: 'direct' }] },
273
+ { company: 'Apitor Technology', fine: 500_000, date: '2025-09', jurisdiction: 'COPPA', violation: 'Robot toy tracked location without parental consent', rules: [{ id: 'coppa-geo-004', c: 'direct' }, { id: 'coppa-auth-001', c: 'direct' }] },
274
+ { company: 'MediaLab (Imgur)', fine: 247_590, date: '2026-02', jurisdiction: 'AADC', violation: 'No age assurance, children exposed to adult content', rules: [{ id: 'coppa-auth-001', c: 'direct' }, { id: 'coppa-default-020', c: 'direct' }] },
275
+ ];
276
+ for (const c of cases) {
277
+ for (const rule of c.rules) {
278
+ if (!ENFORCEMENT_DATA[rule.id])
279
+ ENFORCEMENT_DATA[rule.id] = [];
280
+ ENFORCEMENT_DATA[rule.id].push({
281
+ company: c.company,
282
+ fine: c.fine,
283
+ date: c.date,
284
+ jurisdiction: c.jurisdiction,
285
+ violation: c.violation,
286
+ confidence: rule.c,
287
+ });
288
+ }
289
+ }
290
+ })();
291
+ function formatFine(amount) {
292
+ if (amount >= 1_000_000_000)
293
+ return `$${(amount / 1_000_000_000).toFixed(1)}B`;
294
+ if (amount >= 1_000_000)
295
+ return `$${(amount / 1_000_000).toFixed(0)}M`;
296
+ if (amount >= 1_000)
297
+ return `$${(amount / 1_000).toFixed(0)}K`;
298
+ if (amount === 0)
299
+ return 'DOJ action';
300
+ return `$${amount.toLocaleString()}`;
301
+ }
302
+ // COPPA Rule amendment compliance deadline
303
+ const COPPA_DEADLINE = new Date('2026-04-22');
304
+ function daysUntilDeadline() {
305
+ const now = new Date();
306
+ return Math.max(0, Math.ceil((COPPA_DEADLINE.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)));
307
+ }
308
+ // Rules that are directly addressed by COPPA 2.0 amendment (April 22, 2026)
309
+ const COPPA_2_RULES = new Set([
310
+ 'coppa-auth-001', // Age verification strengthened
311
+ 'coppa-tracking-003', // Ad tracking restrictions expanded
312
+ 'coppa-bio-012', // Biometric data explicitly covered
313
+ 'coppa-data-002', // Data minimization requirements
314
+ 'coppa-retention-005', // Retention limits codified
315
+ 'coppa-notif-013', // Push notification consent required
316
+ 'coppa-geo-004', // Location data restrictions
317
+ 'coppa-audio-007', // Voice data as biometric
318
+ ]);
319
+ function classifyTier(ruleId) {
320
+ if (ENFORCEMENT_DATA[ruleId]?.some(c => c.confidence === 'direct'))
321
+ return 'enforcement-risk';
322
+ if (COPPA_2_RULES.has(ruleId) || ENFORCEMENT_DATA[ruleId]?.some(c => c.confidence === 'related'))
323
+ return 'regulatory-scrutiny';
324
+ return 'hardening';
325
+ }
251
326
  /**
252
327
  * Format violations as human-readable text
253
328
  */
@@ -273,6 +348,11 @@ function formatText(results, verbose = false, fileCount = 0, scoreResult) {
273
348
  let mediumCount = 0;
274
349
  let lowCount = 0;
275
350
  let filesWithViolations = 0;
351
+ // Sprint 10b: Track enforcement tier counts
352
+ let enforcementRiskCount = 0;
353
+ let regulatoryScrutinyCount = 0;
354
+ let hardeningCount = 0;
355
+ const enforcementMatchedFines = new Set(); // Track unique cases for aggregate fine total
276
356
  for (const result of results) {
277
357
  if (result.violations.length === 0)
278
358
  continue;
@@ -338,10 +418,43 @@ function formatText(results, verbose = false, fileCount = 0, scoreResult) {
338
418
  confidenceBadge = c(colors.dim, ` [${confStr}]`);
339
419
  }
340
420
  }
421
+ // Sprint 10b: Classify enforcement tier
422
+ const tier = classifyTier(violation.ruleId);
423
+ switch (tier) {
424
+ case 'enforcement-risk':
425
+ enforcementRiskCount++;
426
+ break;
427
+ case 'regulatory-scrutiny':
428
+ regulatoryScrutinyCount++;
429
+ break;
430
+ case 'hardening':
431
+ hardeningCount++;
432
+ break;
433
+ }
341
434
  // Always show line:column (developer-standard format)
342
435
  const location = c(colors.dim, `${violation.line}:${violation.column}`);
343
436
  output += ` ${location} ${severityTag} ${c(colors.cyan, violation.ruleId)}${astBadge}${confidenceBadge}\n`;
344
437
  output += ` ${c(colors.dim, '│')} ${violation.message}\n`;
438
+ // Sprint 10b: Enforcement context — show related enforcement precedents
439
+ const enforcementCases = ENFORCEMENT_DATA[violation.ruleId];
440
+ if (enforcementCases) {
441
+ // Show top 2 direct matches by fine amount (most impactful)
442
+ const directCases = enforcementCases
443
+ .filter(ec => ec.confidence === 'direct' && ec.fine > 0)
444
+ .sort((a, b) => b.fine - a.fine)
445
+ .slice(0, 2);
446
+ for (const ec of directCases) {
447
+ output += ` ${c(colors.dim, '│')} ${c(colors.red, '⚖️')} Related enforcement precedent: ${ec.jurisdiction} v. ${ec.company} (${formatFine(ec.fine)}, ${ec.date})\n`;
448
+ enforcementMatchedFines.add(`${ec.company}-${ec.date}`);
449
+ }
450
+ }
451
+ // Show COPPA 2.0 deadline for rules affected by the amendment
452
+ if (COPPA_2_RULES.has(violation.ruleId)) {
453
+ const days = daysUntilDeadline();
454
+ if (days > 0) {
455
+ output += ` ${c(colors.dim, '│')} ${c(colors.yellow, '⚠️')} COPPA Rule amendment compliance deadline: April 22, 2026 (${days} days)\n`;
456
+ }
457
+ }
345
458
  if (verbose) {
346
459
  output += ` ${c(colors.dim, '│')} ${c(colors.magenta, '💡')} ${violation.fixSuggestion}\n`;
347
460
  if (violation.penalty) {
@@ -369,16 +482,50 @@ function formatText(results, verbose = false, fileCount = 0, scoreResult) {
369
482
  if (scoreResult) {
370
483
  cleanOutput += `\n${formatScoreLine(scoreResult)}\n`;
371
484
  }
485
+ // Sprint 15: Scope honesty
486
+ cleanOutput += `\n${c(colors.dim, '📋 Optimized for Python/PHP web applications. Coverage expanding to additional frameworks.')}\n`;
372
487
  return cleanOutput;
373
488
  }
489
+ // Sprint 10b: Calculate aggregate enforcement fine total
490
+ let totalEnforcementFines = 0;
491
+ const seenCases = new Set();
492
+ for (const result of results) {
493
+ for (const v of result.violations) {
494
+ const cases = ENFORCEMENT_DATA[v.ruleId];
495
+ if (cases) {
496
+ for (const ec of cases) {
497
+ if (ec.confidence === 'direct') {
498
+ const key = `${ec.company}-${ec.date}`;
499
+ if (!seenCases.has(key)) {
500
+ seenCases.add(key);
501
+ totalEnforcementFines += ec.fine;
502
+ }
503
+ }
504
+ }
505
+ }
506
+ }
507
+ }
374
508
  // Summary header
375
509
  let header = `\n${c(colors.bold, `⚠ Found ${totalViolations} issue(s)`)}`;
376
510
  header += ` across ${filesWithViolations} file(s)`;
377
511
  if (fileCount > 0) {
378
512
  header += ` (${fileCount} files scanned)`;
379
513
  }
514
+ // Sprint 10b: Enforcement aggregate in summary line
515
+ if (enforcementRiskCount > 0) {
516
+ header += ` — ${c(colors.red + colors.bold, `${enforcementRiskCount} match active enforcement patterns`)} (${formatFine(totalEnforcementFines)} in historical fines)`;
517
+ }
380
518
  header += '\n';
381
- // Severity breakdown
519
+ // Sprint 10b: Three-tier breakdown replaces raw severity counts
520
+ const tierParts = [];
521
+ if (enforcementRiskCount > 0)
522
+ tierParts.push(c(colors.red + colors.bold, `${enforcementRiskCount} enforcement-risk`));
523
+ if (regulatoryScrutinyCount > 0)
524
+ tierParts.push(c(colors.yellow + colors.bold, `${regulatoryScrutinyCount} regulatory scrutiny`));
525
+ if (hardeningCount > 0)
526
+ tierParts.push(c(colors.dim, `${hardeningCount} hardening`));
527
+ header += ` ${tierParts.join(c(colors.dim, ' · '))}\n`;
528
+ // Severity breakdown (secondary)
382
529
  const parts = [];
383
530
  if (criticalCount > 0)
384
531
  parts.push(c(colors.red + colors.bold, `${criticalCount} critical`));
@@ -388,17 +535,23 @@ function formatText(results, verbose = false, fileCount = 0, scoreResult) {
388
535
  parts.push(c(colors.blue, `${mediumCount} medium`));
389
536
  if (lowCount > 0)
390
537
  parts.push(c(colors.dim, `${lowCount} low`));
391
- header += ` ${parts.join(c(colors.dim, ' · '))}\n`;
392
- // Compliance score line
538
+ header += ` ${c(colors.dim, 'Severity:')} ${parts.join(c(colors.dim, ' · '))}\n`;
539
+ // Compliance score line with grade context labels
393
540
  if (scoreResult) {
394
- header += `\n${formatScoreLine(scoreResult)}\n`;
541
+ header += `\n${formatScoreLine(scoreResult, enforcementRiskCount, regulatoryScrutinyCount, hardeningCount)}\n`;
542
+ }
543
+ // Sprint 15: Scope honesty
544
+ header += `\n${c(colors.dim, '📋 Optimized for Python/PHP web applications. Coverage expanding to additional frameworks.')}\n`;
545
+ // Sprint 10b: Enforcement disclaimer
546
+ if (enforcementRiskCount > 0) {
547
+ header += `\n${c(colors.dim, 'Enforcement citations are historical references, not risk assessments. Consult legal counsel for compliance guidance.')}\n`;
395
548
  }
396
549
  return header + output;
397
550
  }
398
551
  /**
399
552
  * Format the compliance score line with grade coloring
400
553
  */
401
- function formatScoreLine(scoreResult) {
554
+ function formatScoreLine(scoreResult, enforcementRisk = 0, regulatoryScrutiny = 0, hardening = 0) {
402
555
  const { score, grade } = scoreResult;
403
556
  // Color the grade based on value
404
557
  let gradeColor;
@@ -420,7 +573,27 @@ function formatScoreLine(scoreResult) {
420
573
  }
421
574
  const gradeStr = c(gradeColor + colors.bold, grade);
422
575
  const scoreStr = c(colors.bold, `${score}/100`);
423
- return `${c(colors.bold, '📊 COPPA Compliance Score:')} ${scoreStr} (${gradeStr})`;
576
+ let line = `${c(colors.bold, '📊 COPPA Compliance Score:')} ${scoreStr} (${gradeStr})`;
577
+ // Sprint 10b: Grade context labels — reframe from "you failed" to "here's your path forward"
578
+ const total = enforcementRisk + regulatoryScrutiny + hardening;
579
+ if (total > 0) {
580
+ const contextParts = [];
581
+ if (enforcementRisk > 0)
582
+ contextParts.push(`${enforcementRisk} enforcement-risk items`);
583
+ if (regulatoryScrutiny > 0)
584
+ contextParts.push(`${regulatoryScrutiny} under regulatory scrutiny`);
585
+ if (hardening > 0)
586
+ contextParts.push(`${hardening} hardening items`);
587
+ line += `\n ${c(colors.dim, contextParts.join(', '))}`;
588
+ // Gap-to-compliance guidance
589
+ if (enforcementRisk > 0 && (grade === 'D' || grade === 'F')) {
590
+ line += `\n ${c(colors.yellow, `Gap to compliance: Address the ${enforcementRisk} enforcement-risk items to reach C grade.`)}`;
591
+ }
592
+ else if (enforcementRisk > 0 && grade === 'C') {
593
+ line += `\n ${c(colors.yellow, `Gap to compliance: Resolve enforcement-risk items and regulatory scrutiny items for B grade.`)}`;
594
+ }
595
+ }
596
+ return line;
424
597
  }
425
598
  /**
426
599
  * Format trend line comparing current score to last scan for the same project.
@@ -655,6 +828,10 @@ function escapeHtml(text) {
655
828
  .replace(/"/g, '"')
656
829
  .replace(/'/g, ''');
657
830
  }
831
+ // ==================== Module-level scan data for cross-function access ====================
832
+ // Stores the last scan's results so the action handler can regenerate the PDF
833
+ // with AI Review Board data after the review board runs.
834
+ const _lastScanData = { results: [], scoreResult: null, fileCount: 0, projectPath: '' };
658
835
  // ==================== PDF Report Generator (P3-2) ====================
659
836
  // PDF color constants
660
837
  const PDF_COLORS = {
@@ -690,11 +867,7 @@ function severityColor(severity) {
690
867
  default: return PDF_COLORS.cyan;
691
868
  }
692
869
  }
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) {
870
+ function generatePdfReport(results, scoreResult, fileCount, projectPath, history, reviewData) {
698
871
  return new Promise((resolve, reject) => {
699
872
  const doc = new pdfkit_1.default({
700
873
  size: 'LETTER',
@@ -983,6 +1156,117 @@ function generatePdfReport(results, scoreResult, fileCount, projectPath, history
983
1156
  }
984
1157
  addFooter();
985
1158
  }
1159
+ // ═══════════════ AI REVIEW BOARD (Sprint 9) ═══════════════
1160
+ if (reviewData && reviewData.results.length > 0) {
1161
+ doc.addPage();
1162
+ doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('AI Review Board Assessment', 60, 60);
1163
+ doc.moveTo(60, 88).lineTo(60 + pageWidth, 88).lineWidth(1).strokeColor(PDF_COLORS.border).stroke();
1164
+ y = 100;
1165
+ // Review Board summary box
1166
+ doc.rect(60, y, pageWidth, 70).fill(PDF_COLORS.lightBg);
1167
+ doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
1168
+ doc.text(`${reviewData.summary.total} violations reviewed by Halo AI Review Board`, 70, y + 8, { width: pageWidth - 20 });
1169
+ y += 22;
1170
+ const verdictLine = [
1171
+ reviewData.summary.escalated > 0 ? `🔴 ${reviewData.summary.escalated} escalated` : '',
1172
+ reviewData.summary.confirmed > 0 ? `🟡 ${reviewData.summary.confirmed} confirmed` : '',
1173
+ reviewData.summary.downgraded > 0 ? `🟢 ${reviewData.summary.downgraded} downgraded` : '',
1174
+ reviewData.summary.dismissed > 0 ? `✅ ${reviewData.summary.dismissed} dismissed` : '',
1175
+ ].filter(Boolean).join(' ');
1176
+ doc.fontSize(9).fillColor(PDF_COLORS.bodyText);
1177
+ doc.text(verdictLine, 70, y, { width: pageWidth - 20 });
1178
+ y += 16;
1179
+ // Marshall summary
1180
+ if (reviewData.marshall_summary) {
1181
+ const ms = reviewData.marshall_summary;
1182
+ doc.fontSize(8).fillColor(PDF_COLORS.mutedText);
1183
+ 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 });
1184
+ }
1185
+ y += 40; // past the summary box
1186
+ // Per-verdict sections
1187
+ const verdictOrder = [
1188
+ { key: 'escalated', label: 'ESCALATED — More Serious Than Initially Detected', color: PDF_COLORS.red, emoji: '🔴' },
1189
+ { key: 'confirmed', label: 'CONFIRMED — Violations Validated by AI Review', color: PDF_COLORS.orange, emoji: '🟡' },
1190
+ { key: 'downgraded', label: 'DOWNGRADED — Lower Risk Than Severity Suggests', color: PDF_COLORS.green, emoji: '🟢' },
1191
+ { key: 'dismissed', label: 'DISMISSED — False Positives Cleared', color: PDF_COLORS.cyan, emoji: '✅' },
1192
+ ];
1193
+ for (const vType of verdictOrder) {
1194
+ const items = reviewData.results.filter(r => r.verdict === vType.key);
1195
+ if (items.length === 0)
1196
+ continue;
1197
+ if (y > doc.page.height - 140) {
1198
+ addFooter();
1199
+ doc.addPage();
1200
+ y = 60;
1201
+ }
1202
+ doc.fontSize(12).fillColor(vType.color);
1203
+ doc.text(`${vType.emoji} ${vType.label} (${items.length})`, 60, y);
1204
+ y += 20;
1205
+ // Show up to 10 per verdict type
1206
+ const itemsToShow = items.slice(0, 10);
1207
+ for (const item of itemsToShow) {
1208
+ if (y > doc.page.height - 100) {
1209
+ addFooter();
1210
+ doc.addPage();
1211
+ y = 60;
1212
+ }
1213
+ // Rule ID + verdict
1214
+ doc.fontSize(9).fillColor(PDF_COLORS.primary);
1215
+ doc.text(item.ruleId, 70, y);
1216
+ y += 14;
1217
+ // Clinical context
1218
+ if (item.clinicalContext) {
1219
+ doc.fontSize(8).fillColor(PDF_COLORS.bodyText);
1220
+ const ctxHeight = doc.heightOfString(item.clinicalContext, { width: pageWidth - 30 });
1221
+ doc.text(item.clinicalContext, 80, y, { width: pageWidth - 30 });
1222
+ y += ctxHeight + 4;
1223
+ }
1224
+ // Age groups
1225
+ if (item.ageGroupImpact && item.ageGroupImpact.length > 0) {
1226
+ doc.fontSize(7).fillColor(PDF_COLORS.mutedText);
1227
+ doc.text(`Ages most affected: ${item.ageGroupImpact.join(', ')}`, 80, y);
1228
+ y += 12;
1229
+ }
1230
+ // Regulatory context (Marshall enrichment)
1231
+ if (item.regulatoryContext) {
1232
+ const rc = item.regulatoryContext;
1233
+ const priorityLabel = rc.enforcement_priority === 'active' ? '🔴 ACTIVE'
1234
+ : rc.enforcement_priority === 'watching' ? '🟡 WATCHING' : '⚪ DORMANT';
1235
+ doc.fontSize(7).fillColor(PDF_COLORS.red);
1236
+ doc.text(`Regulatory: ${rc.regulation} | ${priorityLabel} | Penalty: ${rc.penalty_exposure} | Urgency: ${rc.urgency_score}`, 80, y, { width: pageWidth - 30 });
1237
+ y += 12;
1238
+ if (rc.recent_case) {
1239
+ doc.fontSize(7).fillColor(PDF_COLORS.mutedText);
1240
+ doc.text(`Recent precedent: ${rc.recent_case}`, 80, y, { width: pageWidth - 30 });
1241
+ y += 12;
1242
+ }
1243
+ }
1244
+ // Remediation
1245
+ if (item.remediationGuidance) {
1246
+ doc.fontSize(8).fillColor(PDF_COLORS.green);
1247
+ const remHeight = doc.heightOfString(`Fix: ${item.remediationGuidance}`, { width: pageWidth - 30 });
1248
+ doc.text(`Fix: ${item.remediationGuidance}`, 80, y, { width: pageWidth - 30 });
1249
+ y += remHeight + 4;
1250
+ }
1251
+ y += 8;
1252
+ }
1253
+ if (items.length > 10) {
1254
+ doc.fontSize(8).fillColor(PDF_COLORS.mutedText);
1255
+ doc.text(`+ ${items.length - 10} more ${vType.key} violation(s). See full results on your Halo Dashboard.`, 80, y, { width: pageWidth - 30 });
1256
+ y += 16;
1257
+ }
1258
+ y += 10;
1259
+ }
1260
+ // Review Board footer note
1261
+ if (y > doc.page.height - 80) {
1262
+ addFooter();
1263
+ doc.addPage();
1264
+ y = 60;
1265
+ }
1266
+ doc.fontSize(7).fillColor(PDF_COLORS.lightText);
1267
+ 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 });
1268
+ addFooter();
1269
+ }
986
1270
  // ═══════════════ RECOMMENDATIONS ═══════════════
987
1271
  doc.addPage();
988
1272
  doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Recommendations', 60, 60);
@@ -1068,6 +1352,122 @@ function loadHaloignore(startDir) {
1068
1352
  }
1069
1353
  return undefined;
1070
1354
  }
1355
+ /**
1356
+ * Detect the project framework by scanning project files.
1357
+ * Checks package.json, Gemfile, go.mod, Cargo.toml, manage.py, requirements.txt.
1358
+ * Returns a framework identifier string or null if unknown.
1359
+ */
1360
+ function detectProjectFramework(dir) {
1361
+ // Check package.json for JS/TS frameworks
1362
+ const pkgPath = path.join(dir, 'package.json');
1363
+ if (fs.existsSync(pkgPath)) {
1364
+ try {
1365
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
1366
+ const allDeps = {
1367
+ ...(pkg.dependencies || {}),
1368
+ ...(pkg.devDependencies || {}),
1369
+ };
1370
+ // Order matters: check specific frameworks before generic ones
1371
+ if (allDeps['next'])
1372
+ return 'nextjs';
1373
+ if (allDeps['@angular/core'])
1374
+ return 'angular';
1375
+ if (allDeps['vue'])
1376
+ return 'vue';
1377
+ if (allDeps['svelte'])
1378
+ return 'svelte';
1379
+ if (allDeps['react'])
1380
+ return 'react';
1381
+ }
1382
+ catch {
1383
+ // Malformed package.json — continue detection
1384
+ }
1385
+ }
1386
+ // Django: manage.py or requirements.txt with django
1387
+ if (fs.existsSync(path.join(dir, 'manage.py')))
1388
+ return 'django';
1389
+ const reqPath = path.join(dir, 'requirements.txt');
1390
+ if (fs.existsSync(reqPath)) {
1391
+ try {
1392
+ const reqs = fs.readFileSync(reqPath, 'utf-8').toLowerCase();
1393
+ if (reqs.includes('django'))
1394
+ return 'django';
1395
+ }
1396
+ catch {
1397
+ // Continue detection
1398
+ }
1399
+ }
1400
+ // Rails: Gemfile with rails
1401
+ const gemfilePath = path.join(dir, 'Gemfile');
1402
+ if (fs.existsSync(gemfilePath)) {
1403
+ try {
1404
+ const gemfile = fs.readFileSync(gemfilePath, 'utf-8').toLowerCase();
1405
+ if (gemfile.includes('rails'))
1406
+ return 'rails';
1407
+ }
1408
+ catch {
1409
+ // Continue detection
1410
+ }
1411
+ }
1412
+ // Go
1413
+ if (fs.existsSync(path.join(dir, 'go.mod')))
1414
+ return 'go';
1415
+ // Rust
1416
+ if (fs.existsSync(path.join(dir, 'Cargo.toml')))
1417
+ return 'rust';
1418
+ return null;
1419
+ }
1420
+ /**
1421
+ * Get default .haloignore content based on detected framework.
1422
+ */
1423
+ function getDefaultHaloignoreContent(framework) {
1424
+ const lines = [
1425
+ '# Halo ignore patterns',
1426
+ '# Generated by: runhalo init',
1427
+ '',
1428
+ 'node_modules/',
1429
+ 'dist/',
1430
+ 'build/',
1431
+ 'coverage/',
1432
+ '*.min.js',
1433
+ '*.bundle.js',
1434
+ ];
1435
+ // Add framework-specific ignores
1436
+ switch (framework) {
1437
+ case 'nextjs':
1438
+ lines.push('.next/');
1439
+ lines.push('.vercel/');
1440
+ break;
1441
+ case 'angular':
1442
+ lines.push('.angular/');
1443
+ break;
1444
+ case 'vue':
1445
+ lines.push('.nuxt/');
1446
+ break;
1447
+ case 'svelte':
1448
+ lines.push('.svelte-kit/');
1449
+ break;
1450
+ case 'django':
1451
+ lines.push('__pycache__/');
1452
+ lines.push('*.pyc');
1453
+ lines.push('.venv/');
1454
+ lines.push('venv/');
1455
+ break;
1456
+ case 'rails':
1457
+ lines.push('tmp/');
1458
+ lines.push('log/');
1459
+ lines.push('vendor/bundle/');
1460
+ break;
1461
+ case 'go':
1462
+ lines.push('vendor/');
1463
+ break;
1464
+ case 'rust':
1465
+ lines.push('target/');
1466
+ break;
1467
+ }
1468
+ lines.push('');
1469
+ return lines.join('\n');
1470
+ }
1071
1471
  /**
1072
1472
  * Create a Halo engine instance
1073
1473
  */
@@ -1358,6 +1758,108 @@ function saveHistory(entry) {
1358
1758
  // Silent failure — never block scan
1359
1759
  }
1360
1760
  }
1761
+ /**
1762
+ * Send webhook notifications to Discord and/or Slack after a scan completes.
1763
+ * Non-blocking — failures are logged but never affect the scan exit code.
1764
+ */
1765
+ async function sendWebhookNotifications(rcConfig, lastEntry, verbose) {
1766
+ const notifications = rcConfig?.notifications;
1767
+ if (!notifications)
1768
+ return;
1769
+ const { discord_webhook, slack_webhook } = notifications;
1770
+ if (!discord_webhook && !slack_webhook)
1771
+ return;
1772
+ const hasFailed = lastEntry.totalViolations > 0;
1773
+ const filesScanned = String(lastEntry.filesScanned);
1774
+ const violations = String(lastEntry.totalViolations);
1775
+ const grade = lastEntry.grade || 'N/A';
1776
+ const topRules = lastEntry.rulesTriggered.slice(0, 5).join(', ') || 'None';
1777
+ const timestamp = new Date().toISOString();
1778
+ // Discord webhook
1779
+ if (discord_webhook) {
1780
+ try {
1781
+ const discordPayload = {
1782
+ embeds: [{
1783
+ title: '\u{1F6E1}\uFE0F Halo Scan Complete',
1784
+ color: hasFailed ? 15158332 : 3066993,
1785
+ fields: [
1786
+ { name: 'Files Scanned', value: filesScanned, inline: true },
1787
+ { name: 'Violations', value: violations, inline: true },
1788
+ { name: 'Grade', value: grade, inline: true },
1789
+ { name: 'Top Rules', value: topRules, inline: false },
1790
+ ],
1791
+ footer: { text: 'Halo \u2014 runhalo.dev' },
1792
+ timestamp,
1793
+ }],
1794
+ };
1795
+ const controller = new AbortController();
1796
+ const timeout = setTimeout(() => controller.abort(), 5000);
1797
+ const res = await fetch(discord_webhook, {
1798
+ method: 'POST',
1799
+ headers: { 'Content-Type': 'application/json' },
1800
+ body: JSON.stringify(discordPayload),
1801
+ signal: controller.signal,
1802
+ });
1803
+ clearTimeout(timeout);
1804
+ if (verbose) {
1805
+ console.error(res.ok
1806
+ ? '\u2709\uFE0F Notification sent to Discord'
1807
+ : `\u26A0\uFE0F Discord webhook returned ${res.status}`);
1808
+ }
1809
+ }
1810
+ catch (err) {
1811
+ if (verbose) {
1812
+ console.error(`\u26A0\uFE0F Discord webhook failed: ${err instanceof Error ? err.message : err}`);
1813
+ }
1814
+ }
1815
+ }
1816
+ // Slack webhook
1817
+ if (slack_webhook) {
1818
+ try {
1819
+ const slackPayload = {
1820
+ blocks: [
1821
+ {
1822
+ type: 'header',
1823
+ text: { type: 'plain_text', text: '\u{1F6E1}\uFE0F Halo Scan Complete' },
1824
+ },
1825
+ {
1826
+ type: 'section',
1827
+ fields: [
1828
+ { type: 'mrkdwn', text: `*Files Scanned*\n${filesScanned}` },
1829
+ { type: 'mrkdwn', text: `*Violations*\n${violations}` },
1830
+ { type: 'mrkdwn', text: `*Grade*\n${grade}` },
1831
+ ],
1832
+ },
1833
+ {
1834
+ type: 'section',
1835
+ fields: [
1836
+ { type: 'mrkdwn', text: `*Top Rules*\n${topRules}` },
1837
+ ],
1838
+ },
1839
+ ],
1840
+ };
1841
+ const controller = new AbortController();
1842
+ const timeout = setTimeout(() => controller.abort(), 5000);
1843
+ const res = await fetch(slack_webhook, {
1844
+ method: 'POST',
1845
+ headers: { 'Content-Type': 'application/json' },
1846
+ body: JSON.stringify(slackPayload),
1847
+ signal: controller.signal,
1848
+ });
1849
+ clearTimeout(timeout);
1850
+ if (verbose) {
1851
+ console.error(res.ok
1852
+ ? '\u2709\uFE0F Notification sent to Slack'
1853
+ : `\u26A0\uFE0F Slack webhook returned ${res.status}`);
1854
+ }
1855
+ }
1856
+ catch (err) {
1857
+ if (verbose) {
1858
+ console.error(`\u26A0\uFE0F Slack webhook failed: ${err instanceof Error ? err.message : err}`);
1859
+ }
1860
+ }
1861
+ }
1862
+ }
1361
1863
  async function submitCliLead(email) {
1362
1864
  try {
1363
1865
  const res = await fetch(`${SUPABASE_URL}/rest/v1/halo_leads`, {
@@ -1757,6 +2259,7 @@ async function scan(paths, options) {
1757
2259
  'au-sbd': 'AU Safety by Design',
1758
2260
  'au-osa': 'AU Online Safety Act',
1759
2261
  'caadca': 'California AADCA',
2262
+ 'eu-ai-act': 'EU AI Act (Children)',
1760
2263
  };
1761
2264
  const packLabel = packs.map(p => packNameMap[p] || p).join(' + ');
1762
2265
  console.error(c(colors.dim, `🔍 Scanning ${uniqueFiles.length} files (${packLabel})...`));
@@ -1843,7 +2346,14 @@ async function scan(paths, options) {
1843
2346
  output += trendLine + '\n';
1844
2347
  }
1845
2348
  }
2349
+ // Store scan data for potential PDF regeneration with AI Review Board data
2350
+ _lastScanData.results = results;
2351
+ _lastScanData.scoreResult = scoreResult;
2352
+ _lastScanData.fileCount = fileCount;
2353
+ _lastScanData.projectPath = projectPath;
1846
2354
  // Generate report if requested (HTML or PDF based on filename extension)
2355
+ // Note: if --review-board is also set, the PDF will be regenerated with AI data
2356
+ // in the action handler after the review board completes.
1847
2357
  if (options.report) {
1848
2358
  const reportFilename = typeof options.report === 'string'
1849
2359
  ? options.report
@@ -2132,6 +2642,9 @@ program
2132
2642
  .option('--report [filename]', 'Generate HTML compliance report (default: halo-report.html)')
2133
2643
  .option('--upload', 'Upload scan results to Halo Dashboard (requires Pro)')
2134
2644
  .option('--watch', 'Watch for file changes and re-scan automatically')
2645
+ .option('--review-board', 'Enable AI Review Board — clinical assessment of each violation (Pro/Enterprise)')
2646
+ .option('--license-key <key>', 'License key for Pro/Enterprise features (or set HALO_LICENSE_KEY env var)')
2647
+ .option('--framework <framework>', 'Override framework detection (react, nextjs, vue, angular, django, rails)')
2135
2648
  .option('--no-prompt', 'Skip first-run email prompt')
2136
2649
  .option('-v, --verbose', 'Verbose output')
2137
2650
  .action(async (paths, options) => {
@@ -2156,6 +2669,9 @@ program
2156
2669
  if (options.upload && !checkProFeature('Dashboard Upload', '--upload')) {
2157
2670
  process.exit(0);
2158
2671
  }
2672
+ if (options.reviewBoard && !checkProFeature('AI Review Board', '--review-board')) {
2673
+ process.exit(0);
2674
+ }
2159
2675
  // Scan limit check (soft — exit 0, not error)
2160
2676
  if (!checkScanLimit()) {
2161
2677
  process.exit(0);
@@ -2203,9 +2719,14 @@ program
2203
2719
  report: options.report || false,
2204
2720
  pack: mergedPacks,
2205
2721
  offline: options.offline || false,
2206
- // Sprint 8: Pass framework and AST config from .halorc.json to scan()
2207
- framework: rcConfig?.framework,
2722
+ // Sprint 10: Framework resolution: CLI flag > .halorc.json > auto-detection
2723
+ framework: options.framework || rcConfig?.framework || detectProjectFramework(fs.existsSync(projectRoot) && fs.statSync(projectRoot).isDirectory()
2724
+ ? projectRoot
2725
+ : path.dirname(projectRoot)) || undefined,
2208
2726
  astAnalysis: rcConfig?.astAnalysis,
2727
+ // Sprint 9: AI Review Board
2728
+ reviewBoard: options.reviewBoard || rcConfig?.reviewBoard || false,
2729
+ licenseKey: options.licenseKey || process.env.HALO_LICENSE_KEY || rcConfig?.licenseKey,
2209
2730
  };
2210
2731
  // ==================== Watch Mode ====================
2211
2732
  if (options.watch) {
@@ -2360,6 +2881,179 @@ program
2360
2881
  }
2361
2882
  }
2362
2883
  }
2884
+ // ==================== AI Review Board (Sprint 9) ====================
2885
+ let _reviewBoardResult;
2886
+ if (scanOptions.reviewBoard) {
2887
+ const licenseKey = scanOptions.licenseKey || loadConfig().license_key;
2888
+ if (!licenseKey) {
2889
+ console.error('⚠️ AI Review Board requires a license key. Run `halo activate <key>` or pass --license-key.');
2890
+ }
2891
+ else {
2892
+ try {
2893
+ // Extract violations directly from the scan results already in memory
2894
+ const allViolations = _lastScanData.results.flatMap(r => r.violations);
2895
+ if (allViolations.length > 0) {
2896
+ console.error('\n🤖 Running AI Review Board...');
2897
+ const reviewUrl = 'https://wrfwcmyxxbafcdvxlmug.supabase.co/functions/v1/ai-review';
2898
+ // Chunk violations in batches of 50 (endpoint limit)
2899
+ const CHUNK_SIZE = 50;
2900
+ const chunks = [];
2901
+ for (let i = 0; i < allViolations.length; i += CHUNK_SIZE) {
2902
+ chunks.push(allViolations.slice(i, i + CHUNK_SIZE));
2903
+ }
2904
+ if (chunks.length > 1) {
2905
+ console.error(` Sending ${allViolations.length} violations in ${chunks.length} batches...`);
2906
+ }
2907
+ // Process each chunk and merge results
2908
+ const mergedResults = [];
2909
+ let totalCost = 0;
2910
+ let totalLatency = 0;
2911
+ let totalCacheHits = 0;
2912
+ let mergedMarshall;
2913
+ let chunksFailed = 0;
2914
+ for (let ci = 0; ci < chunks.length; ci++) {
2915
+ const chunk = chunks[ci];
2916
+ if (chunks.length > 1) {
2917
+ console.error(` Batch ${ci + 1}/${chunks.length} (${chunk.length} violations)...`);
2918
+ }
2919
+ const reviewRes = await fetch(reviewUrl, {
2920
+ method: 'POST',
2921
+ headers: { 'Content-Type': 'application/json' },
2922
+ body: JSON.stringify({
2923
+ license_key: licenseKey,
2924
+ violations: chunk,
2925
+ repo_metadata: {
2926
+ framework: scanOptions.framework,
2927
+ },
2928
+ }),
2929
+ });
2930
+ if (reviewRes.ok) {
2931
+ const chunkReview = await reviewRes.json();
2932
+ mergedResults.push(...chunkReview.results);
2933
+ totalCost += chunkReview.cost.estimated_usd;
2934
+ totalLatency += chunkReview.latency_ms;
2935
+ totalCacheHits += chunkReview.summary.cache_hits;
2936
+ // Merge marshall summary (take the one with highest urgency)
2937
+ if (chunkReview.marshall_summary) {
2938
+ if (!mergedMarshall || (chunkReview.marshall_summary.avg_urgency > mergedMarshall.avg_urgency)) {
2939
+ mergedMarshall = chunkReview.marshall_summary;
2940
+ }
2941
+ if (mergedMarshall && chunkReview.marshall_summary !== mergedMarshall) {
2942
+ mergedMarshall.enriched_count += chunkReview.marshall_summary.enriched_count;
2943
+ mergedMarshall.active_enforcement_count += chunkReview.marshall_summary.active_enforcement_count;
2944
+ }
2945
+ }
2946
+ }
2947
+ else {
2948
+ const err = await reviewRes.json().catch(() => ({}));
2949
+ console.error(` ⚠️ Batch ${ci + 1} failed: ${err.error || reviewRes.statusText}`);
2950
+ chunksFailed++;
2951
+ }
2952
+ }
2953
+ if (mergedResults.length > 0) {
2954
+ // Build merged ReviewBoardData
2955
+ const review = {
2956
+ results: mergedResults,
2957
+ summary: {
2958
+ total: mergedResults.length,
2959
+ confirmed: mergedResults.filter(r => r.verdict === 'confirmed').length,
2960
+ downgraded: mergedResults.filter(r => r.verdict === 'downgraded').length,
2961
+ escalated: mergedResults.filter(r => r.verdict === 'escalated').length,
2962
+ dismissed: mergedResults.filter(r => r.verdict === 'dismissed').length,
2963
+ cache_hits: totalCacheHits,
2964
+ },
2965
+ marshall_summary: mergedMarshall,
2966
+ cost: { estimated_usd: totalCost },
2967
+ latency_ms: totalLatency,
2968
+ };
2969
+ _reviewBoardResult = review; // Store for PDF report
2970
+ // Display Review Board results
2971
+ console.error(`\n🛡️ Halo AI Review Board — ${review.summary.total} violations analyzed (${review.latency_ms}ms)`);
2972
+ console.error(`⚠️ AI-assisted review — may over-dismiss valid findings. Deterministic engine results (above) are authoritative.\n`);
2973
+ const critical = review.results.filter(r => r.verdict === 'escalated');
2974
+ const confirmed = review.results.filter(r => r.verdict === 'confirmed');
2975
+ const downgraded = review.results.filter(r => r.verdict === 'downgraded');
2976
+ const dismissed = review.results.filter(r => r.verdict === 'dismissed');
2977
+ if (critical.length > 0) {
2978
+ console.error(`🔴 ESCALATED (${critical.length}) — More serious than initially detected`);
2979
+ for (const r of critical) {
2980
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2981
+ if (r.ageGroupImpact.length > 0)
2982
+ console.error(` Ages most affected: ${r.ageGroupImpact.join(', ')}`);
2983
+ if (r.remediationGuidance)
2984
+ console.error(` Fix: ${r.remediationGuidance}`);
2985
+ }
2986
+ console.error('');
2987
+ }
2988
+ if (confirmed.length > 0) {
2989
+ console.error(`🟡 CONFIRMED (${confirmed.length}) — Violations validated by AI review`);
2990
+ for (const r of confirmed) {
2991
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2992
+ }
2993
+ console.error('');
2994
+ }
2995
+ if (downgraded.length > 0) {
2996
+ console.error(`🟢 DOWNGRADED (${downgraded.length}) — Lower risk than severity suggests`);
2997
+ for (const r of downgraded) {
2998
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2999
+ }
3000
+ console.error('');
3001
+ }
3002
+ if (dismissed.length > 0) {
3003
+ console.error(`✅ DISMISSED (${dismissed.length}) — False positives cleared by AI review`);
3004
+ for (const r of dismissed) {
3005
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
3006
+ }
3007
+ console.error('');
3008
+ }
3009
+ const cacheStr = review.summary.cache_hits > 0 ? ` (${review.summary.cache_hits} cached)` : '';
3010
+ console.error(`📊 Cost: $${review.cost.estimated_usd.toFixed(4)}${cacheStr}`);
3011
+ console.error(`🤖 Reviewed by: Halo AI Review Board (${review.latency_ms}ms)\n`);
3012
+ // Output ReviewBoardData as JSON to stdout for batch tooling
3013
+ // (summary generator's readJsonSafe picks up the last JSON object)
3014
+ if (scanOptions.format === 'json') {
3015
+ console.log(JSON.stringify(review));
3016
+ }
3017
+ }
3018
+ else if (chunksFailed > 0) {
3019
+ console.error(`⚠️ AI Review failed: all ${chunksFailed} batches failed`);
3020
+ }
3021
+ }
3022
+ }
3023
+ catch (reviewErr) {
3024
+ console.error(`⚠️ AI Review failed: ${reviewErr instanceof Error ? reviewErr.message : reviewErr}`);
3025
+ }
3026
+ }
3027
+ }
3028
+ // ==================== Generate PDF Report with AI Review Board data ====================
3029
+ // If both --review-board and --report *.pdf are set, regenerate the PDF with review data
3030
+ if (options.report && _reviewBoardResult) {
3031
+ const reportFilename = typeof options.report === 'string'
3032
+ ? options.report
3033
+ : 'halo-report.html';
3034
+ if (reportFilename.endsWith('.pdf') && _lastScanData.results.length > 0) {
3035
+ const projectHistory = loadHistory().filter(h => h.projectPath === _lastScanData.projectPath);
3036
+ const historyForReport = projectHistory.slice(0, -1);
3037
+ const pdfBuffer = await generatePdfReport(_lastScanData.results, _lastScanData.scoreResult, _lastScanData.fileCount, _lastScanData.projectPath, historyForReport, _reviewBoardResult);
3038
+ fs.writeFileSync(reportFilename, pdfBuffer);
3039
+ console.error(`📄 PDF report updated with AI Review Board assessment`);
3040
+ }
3041
+ }
3042
+ // ==================== Webhook Notifications (Discord/Slack) ====================
3043
+ if (rcConfig?.notifications) {
3044
+ try {
3045
+ const history = loadHistory();
3046
+ const lastEntry = history[history.length - 1];
3047
+ if (lastEntry) {
3048
+ await sendWebhookNotifications(rcConfig, lastEntry, options.verbose);
3049
+ }
3050
+ }
3051
+ catch (notifyErr) {
3052
+ if (options.verbose) {
3053
+ console.error(`\u26A0\uFE0F Webhook notification error: ${notifyErr instanceof Error ? notifyErr.message : notifyErr}`);
3054
+ }
3055
+ }
3056
+ }
2363
3057
  // Upload to Halo Dashboard (non-blocking — upload failure doesn't affect exit code)
2364
3058
  if (options.upload) {
2365
3059
  try {
@@ -2607,78 +3301,142 @@ function getCopilotInstructionsContent() {
2607
3301
  return getIDERulesContent();
2608
3302
  }
2609
3303
  /**
2610
- * Init command — generate IDE rules files and project configuration.
3304
+ * Init command — detect framework, generate .halorc.json, .haloignore, and IDE rules files.
2611
3305
  */
2612
3306
  async function init(projectPath, options) {
2613
3307
  const resolvedPath = path.resolve(projectPath);
2614
- if (!options.ide) {
2615
- console.log('🔮 Halo init — project setup');
3308
+ // --ide flag: generate AI coding assistant rules files (existing behavior)
3309
+ if (options.ide) {
3310
+ console.log('🔮 Halo init — Generating AI coding assistant rules...\n');
3311
+ const files = [
3312
+ {
3313
+ path: path.join(resolvedPath, '.cursor', 'rules'),
3314
+ content: getCursorRulesContent(),
3315
+ label: 'Cursor'
3316
+ },
3317
+ {
3318
+ path: path.join(resolvedPath, '.windsurfrules'),
3319
+ content: getWindsurfRulesContent(),
3320
+ label: 'Windsurf'
3321
+ },
3322
+ {
3323
+ path: path.join(resolvedPath, '.github', 'copilot-instructions.md'),
3324
+ content: getCopilotInstructionsContent(),
3325
+ label: 'GitHub Copilot'
3326
+ }
3327
+ ];
3328
+ let created = 0;
3329
+ let skipped = 0;
3330
+ for (const file of files) {
3331
+ const dir = path.dirname(file.path);
3332
+ const relativePath = path.relative(resolvedPath, file.path);
3333
+ if (fs.existsSync(file.path) && !options.force) {
3334
+ console.log(` ⏭ ${relativePath} (exists — use --force to overwrite)`);
3335
+ skipped++;
3336
+ continue;
3337
+ }
3338
+ try {
3339
+ if (!fs.existsSync(dir)) {
3340
+ fs.mkdirSync(dir, { recursive: true });
3341
+ }
3342
+ fs.writeFileSync(file.path, file.content, 'utf-8');
3343
+ console.log(` ✅ ${relativePath} — ${file.label} rules`);
3344
+ created++;
3345
+ }
3346
+ catch (err) {
3347
+ console.error(` ❌ ${relativePath} — ${err instanceof Error ? err.message : err}`);
3348
+ }
3349
+ }
2616
3350
  console.log('');
2617
- console.log('Usage:');
2618
- console.log(' runhalo init --ide Generate AI coding assistant rules files');
3351
+ if (created > 0) {
3352
+ console.log(`Created ${created} rules file${created > 1 ? 's' : ''}. Your AI assistant now knows COPPA 2.0.`);
3353
+ }
3354
+ if (skipped > 0) {
3355
+ console.log(`Skipped ${skipped} existing file${skipped > 1 ? 's' : ''}.`);
3356
+ }
3357
+ console.log('');
3358
+ console.log('What happens next:');
3359
+ console.log(' • Cursor, Windsurf, and Copilot will read these rules automatically');
3360
+ console.log(' • AI-generated code will follow COPPA compliance patterns');
3361
+ console.log(' • Run "npx runhalo scan ." to verify compliance');
2619
3362
  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');
3363
+ console.log('Full-stack compliance for the AI coding era:');
3364
+ console.log(' CI: uses: runhalo/action@v1 catches violations in PRs');
3365
+ console.log(' Local: npx runhalo scan . catches violations on your machine');
3366
+ console.log(' Proactive: AI rules files prevent violations before they\'re written');
2623
3367
  return 0;
2624
3368
  }
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'
3369
+ // Default init: auto-detect framework, generate .halorc.json and .haloignore
3370
+ console.log('🔮 Halo init — project setup\n');
3371
+ // Step 1: Detect framework
3372
+ const detectedFramework = detectProjectFramework(resolvedPath);
3373
+ if (detectedFramework) {
3374
+ console.log(` 🔍 Detected framework: ${c(colors.bold, detectedFramework)}`);
3375
+ }
3376
+ else {
3377
+ console.log(` 🔍 Framework: ${c(colors.dim, 'not detected (generic config will be generated)')}`);
3378
+ }
3379
+ let configCreated = false;
3380
+ let ignoreCreated = false;
3381
+ // Step 2: Generate .halorc.json
3382
+ const rcPath = path.join(resolvedPath, '.halorc.json');
3383
+ if (fs.existsSync(rcPath) && !options.force) {
3384
+ console.log(` ⏭ .halorc.json (exists — use --force to overwrite)`);
3385
+ }
3386
+ else {
3387
+ const rcConfig = {
3388
+ packs: ['coppa', 'ethical'],
3389
+ severity_threshold: 'medium',
3390
+ ignore: ['**/test/**', '**/__tests__/**', '**/node_modules/**'],
3391
+ astAnalysis: true,
3392
+ notifications: {},
3393
+ };
3394
+ if (detectedFramework) {
3395
+ rcConfig.framework = detectedFramework;
2641
3396
  }
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;
3397
+ try {
3398
+ fs.writeFileSync(rcPath, JSON.stringify(rcConfig, null, 2) + '\n', 'utf-8');
3399
+ console.log(` ✅ .halorc.json project configuration`);
3400
+ configCreated = true;
3401
+ }
3402
+ catch (err) {
3403
+ console.error(` ❌ .halorc.json ${err instanceof Error ? err.message : err}`);
2652
3404
  }
3405
+ }
3406
+ // Step 3: Generate .haloignore
3407
+ const ignorePath = path.join(resolvedPath, '.haloignore');
3408
+ if (fs.existsSync(ignorePath) && !options.force) {
3409
+ console.log(` ⏭ .haloignore (exists — use --force to overwrite)`);
3410
+ }
3411
+ else {
2653
3412
  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++;
3413
+ const ignoreContent = getDefaultHaloignoreContent(detectedFramework);
3414
+ fs.writeFileSync(ignorePath, ignoreContent, 'utf-8');
3415
+ console.log(` ✅ .haloignore — scan exclusion patterns`);
3416
+ ignoreCreated = true;
2660
3417
  }
2661
3418
  catch (err) {
2662
- console.error(` ❌ ${relativePath} — ${err instanceof Error ? err.message : err}`);
3419
+ console.error(` ❌ .haloignore — ${err instanceof Error ? err.message : err}`);
2663
3420
  }
2664
3421
  }
3422
+ // Step 4: Summary
2665
3423
  console.log('');
2666
- if (created > 0) {
2667
- console.log(`Created ${created} rules file${created > 1 ? 's' : ''}. Your AI assistant now knows COPPA 2.0.`);
3424
+ if (configCreated || ignoreCreated) {
3425
+ const parts = [];
3426
+ if (configCreated)
3427
+ parts.push('.halorc.json');
3428
+ if (ignoreCreated)
3429
+ parts.push('.haloignore');
3430
+ console.log(`Created: ${parts.join(', ')}`);
2668
3431
  }
2669
- if (skipped > 0) {
2670
- console.log(`Skipped ${skipped} existing file${skipped > 1 ? 's' : ''}.`);
3432
+ else {
3433
+ console.log('No files created (all exist use --force to overwrite).');
2671
3434
  }
2672
3435
  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');
3436
+ console.log('Next steps:');
3437
+ console.log(` ${c(colors.bold, 'npx runhalo scan .')} Scan your project for compliance issues`);
3438
+ console.log(` ${c(colors.bold, 'npx runhalo init --ide')} Generate AI coding assistant rules files`);
2677
3439
  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
3440
  return 0;
2683
3441
  }
2684
3442
  program
@@ -2811,10 +3569,10 @@ program
2811
3569
  });
2812
3570
  program
2813
3571
  .command('init')
2814
- .description('Initialize Halo in your project (generate IDE rules files)')
3572
+ .description('Initialize Halo in your project (detect framework, generate .halorc.json and .haloignore)')
2815
3573
  .argument('[path]', 'Project root path (default: current directory)', '.')
2816
3574
  .option('--ide', 'Generate AI coding assistant rules files', false)
2817
- .option('--force', 'Overwrite existing rules files', false)
3575
+ .option('--force', 'Overwrite existing files', false)
2818
3576
  .action(async (projectPath, options) => {
2819
3577
  try {
2820
3578
  const exitCode = await init(projectPath, {
@@ -2828,6 +3586,73 @@ program
2828
3586
  process.exit(3);
2829
3587
  }
2830
3588
  });
3589
+ // ─── Sprint 15: Graduate Command ──────────────────────────────────────────
3590
+ program
3591
+ .command('graduate')
3592
+ .description('Run graduation pipeline — analyze FP patterns for heuristic promotion')
3593
+ .option('--dry-run', 'Show candidates without modifying engine')
3594
+ .option('--report', 'Output JSON report')
3595
+ .option('--log-dir <dir>', 'Verdict log directory', 'verdict-logs')
3596
+ .action(async (options) => {
3597
+ try {
3598
+ const { GraduationPipeline, formatAggregationReport, formatGraduationReport, formatCodegenReport } = require('@runhalo/engine/dist/graduation');
3599
+ const logDir = path.resolve(options.logDir);
3600
+ console.log('🎓 Graduation Pipeline — Sprint 15');
3601
+ console.log(` Log directory: ${logDir}\n`);
3602
+ const pipeline = new GraduationPipeline({ logDir });
3603
+ // Run full pipeline (stages 2-4)
3604
+ const { aggregation, graduation, codegen } = pipeline.runPipeline();
3605
+ if (options.report) {
3606
+ // JSON output
3607
+ console.log(JSON.stringify({
3608
+ aggregation: {
3609
+ totalPatterns: aggregation.patterns.size,
3610
+ totalVerdicts: aggregation.totalVerdicts,
3611
+ },
3612
+ graduation: {
3613
+ eligible: graduation.eligible.length,
3614
+ ineligible: graduation.ineligible.length,
3615
+ candidates: graduation.eligible.map((c) => ({
3616
+ patternId: c.patternId,
3617
+ ruleId: c.ruleId,
3618
+ dismissals: c.dismissals,
3619
+ precision: c.precision,
3620
+ avgConfidence: c.avgConfidence,
3621
+ })),
3622
+ },
3623
+ codegen: codegen.map((c) => ({
3624
+ patternId: c.patternId,
3625
+ type: c.type,
3626
+ })),
3627
+ }, null, 2));
3628
+ }
3629
+ else {
3630
+ // Human-readable output
3631
+ console.log(formatAggregationReport(aggregation));
3632
+ console.log('\n' + formatGraduationReport(graduation));
3633
+ if (codegen.length > 0) {
3634
+ console.log('\n' + formatCodegenReport(codegen));
3635
+ }
3636
+ console.log(`\n📊 Summary:`);
3637
+ console.log(` Patterns analyzed: ${aggregation.patterns.size}`);
3638
+ console.log(` Eligible for graduation: ${graduation.eligible.length}`);
3639
+ console.log(` Code snippets generated: ${codegen.length}`);
3640
+ if (options.dryRun) {
3641
+ console.log('\n (dry-run mode — no changes applied)\n');
3642
+ }
3643
+ }
3644
+ }
3645
+ catch (error) {
3646
+ if (error.code === 'MODULE_NOT_FOUND' || error.message?.includes('no such file')) {
3647
+ console.error('⚠️ No verdict logs found. Run scans with AI Review Board first to generate graduation data.');
3648
+ console.error(' Usage: runhalo scan . --review-board --license-key <key>');
3649
+ }
3650
+ else {
3651
+ console.error('❌ Error:', error.message || error);
3652
+ }
3653
+ process.exit(3);
3654
+ }
3655
+ });
2831
3656
  // Run CLI
2832
3657
  if (require.main === module) {
2833
3658
  program.parse();