delimit-cli 4.1.42 → 4.1.44

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.
@@ -68,7 +68,7 @@ function normalizeNaturalLanguageArgs(argv) {
68
68
  const explicitCommands = new Set([
69
69
  'install', 'mode', 'status', 'session', 'build', 'ask', 'policy', 'auth', 'audit',
70
70
  'explain-decision', 'uninstall', 'proxy', 'hook', 'version', 'vault', 'deliberate',
71
- 'remember', 'recall', 'forget'
71
+ 'remember', 'recall', 'forget', 'report'
72
72
  ]);
73
73
  if (explicitCommands.has((raw[0] || '').toLowerCase())) {
74
74
  return raw;
@@ -328,190 +328,312 @@ program
328
328
  .description('Show a compact dashboard of your Delimit setup')
329
329
 
330
330
  .option('--verbose', 'Show detailed status')
331
+ .option('--json', 'Output as JSON')
332
+ .option('--watch', 'Refresh every 5 seconds')
331
333
  .action(async (options) => {
332
334
  const homedir = os.homedir();
333
335
  const delimitHome = path.join(homedir, '.delimit');
334
336
  const target = process.cwd();
335
-
336
- console.log(chalk.bold('\n Delimit Status\n'));
337
-
338
- // --- Memory stats ---
339
- const memoryDir = path.join(delimitHome, 'memory');
340
- let memTotal = 0;
341
- let memRecent = 0;
342
- let recentMemories = [];
343
- const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
344
- try {
345
- const memFiles = fs.readdirSync(memoryDir).filter(f => f.startsWith('mem-') && f.endsWith('.json'));
346
- memTotal = memFiles.length;
347
- for (const f of memFiles) {
337
+ const { execSync } = require('child_process');
338
+
339
+ function renderStatus() {
340
+ const data = {};
341
+
342
+ // --- Memory stats ---
343
+ const memoryDir = path.join(delimitHome, 'memory');
344
+ let memTotal = 0;
345
+ let memRecent = 0;
346
+ let recentMemories = [];
347
+ let memIntegrity = { verified: 0, failed: 0 };
348
+ const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
349
+ try {
350
+ const memFiles = fs.readdirSync(memoryDir).filter(f => f.startsWith('mem-') && f.endsWith('.json'));
351
+ memTotal = memFiles.length;
352
+ for (const f of memFiles) {
353
+ try {
354
+ const raw = fs.readFileSync(path.join(memoryDir, f), 'utf-8');
355
+ const d = JSON.parse(raw);
356
+ const ts = new Date(d.created_at || d.timestamp || d.created || 0).getTime();
357
+ if (ts > oneWeekAgo) memRecent++;
358
+ recentMemories.push({ text: d.text || d.content || '', tags: d.tags || [], ts, source: d.context || d.source || 'unknown' });
359
+ // Verify integrity hash if present
360
+ if (d.hash) {
361
+ const expected = require('crypto').createHash('sha256').update(d.content || d.text || '').digest('hex').slice(0, 16);
362
+ if (d.hash === expected) memIntegrity.verified++;
363
+ else memIntegrity.failed++;
364
+ }
365
+ } catch {}
366
+ }
367
+ recentMemories.sort((a, b) => b.ts - a.ts);
368
+ recentMemories = recentMemories.slice(0, 3);
369
+ } catch {}
370
+ data.memory = { total: memTotal, recent: memRecent, integrity: memIntegrity };
371
+
372
+ // --- Governance / Policy ---
373
+ const policyPath = path.join(target, '.delimit', 'policies.yml');
374
+ let policyName = 'none';
375
+ let policyMode = '';
376
+ let ruleCount = 0;
377
+ let hasPolicy = false;
378
+ if (fs.existsSync(policyPath)) {
379
+ hasPolicy = true;
348
380
  try {
349
- const data = JSON.parse(fs.readFileSync(path.join(memoryDir, f), 'utf-8'));
350
- const ts = new Date(data.created_at || data.timestamp || data.created || 0).getTime();
351
- if (ts > oneWeekAgo) memRecent++;
352
- recentMemories.push({ text: data.text || data.content || '', tags: data.tags || [], ts });
353
- } catch {}
381
+ const policyContent = yaml.load(fs.readFileSync(policyPath, 'utf-8'));
382
+ policyName = policyContent?.preset || policyContent?.name || 'custom';
383
+ policyMode = policyContent?.enforcement_mode || policyContent?.mode || '';
384
+ if (policyContent?.rules) ruleCount = Object.keys(policyContent.rules).length;
385
+ } catch {
386
+ policyName = 'custom';
387
+ }
354
388
  }
355
- recentMemories.sort((a, b) => b.ts - a.ts);
356
- recentMemories = recentMemories.slice(0, 3);
357
- } catch {}
358
- console.log(` Memory: ${chalk.white.bold(memTotal)} memories${memRecent > 0 ? ` (${memRecent} this week)` : ''}`);
359
389
 
360
- // --- Governance / Policy ---
361
- const policyPath = path.join(target, '.delimit', 'policies.yml');
362
- let policyLabel = chalk.gray('none');
363
- let hasPolicy = false;
364
- if (fs.existsSync(policyPath)) {
365
- hasPolicy = true;
390
+ // Count tracked specs
391
+ const specPatterns = ['openapi.yaml', 'openapi.yml', 'openapi.json', 'swagger.yaml', 'swagger.yml', 'swagger.json'];
392
+ let specCount = 0;
393
+ const _countSpecs = (dir, depth) => {
394
+ if (depth > 3) return;
395
+ try {
396
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
397
+ if (['node_modules', '.next', 'venv', '.git'].includes(entry.name)) continue;
398
+ const full = path.join(dir, entry.name);
399
+ if (entry.isFile() && specPatterns.includes(entry.name.toLowerCase())) specCount++;
400
+ else if (entry.isDirectory()) _countSpecs(full, depth + 1);
401
+ }
402
+ } catch {}
403
+ };
404
+ _countSpecs(target, 0);
405
+ data.governance = { policy: policyName, mode: policyMode, rules: ruleCount, specs: specCount };
406
+
407
+ // --- Git hooks ---
408
+ const preCommitPath = path.join(target, '.git', 'hooks', 'pre-commit');
409
+ let hasGitHooks = false;
410
+ try { hasGitHooks = fs.readFileSync(preCommitPath, 'utf-8').includes('delimit'); } catch {}
411
+ data.hooks = hasGitHooks;
412
+
413
+ // --- CI ---
414
+ const workflowDir = path.join(target, '.github', 'workflows');
415
+ let hasCI = false;
416
+ let ciFile = '';
366
417
  try {
367
- const policyContent = yaml.load(fs.readFileSync(policyPath, 'utf-8'));
368
- const preset = policyContent?.preset || policyContent?.name || 'custom';
369
- policyLabel = chalk.green(preset + ' policy');
370
- } catch {
371
- policyLabel = chalk.green('custom policy');
418
+ const wfs = fs.readdirSync(workflowDir);
419
+ for (const wf of wfs) {
420
+ try {
421
+ if (fs.readFileSync(path.join(workflowDir, wf), 'utf-8').includes('delimit')) {
422
+ hasCI = true;
423
+ ciFile = wf;
424
+ break;
425
+ }
426
+ } catch {}
427
+ }
428
+ } catch {}
429
+ data.ci = { active: hasCI, file: ciFile };
430
+
431
+ // --- MCP ---
432
+ const mcpConfigPath = path.join(homedir, '.mcp.json');
433
+ let hasMcp = false;
434
+ let toolCount = 0;
435
+ try { hasMcp = fs.readFileSync(mcpConfigPath, 'utf-8').includes('delimit'); } catch {}
436
+ if (hasMcp) {
437
+ const serverPyPaths = [
438
+ path.join(delimitHome, 'server', 'ai', 'server.py'),
439
+ path.join(delimitHome, 'server', 'server.py'),
440
+ ];
441
+ for (const sp of serverPyPaths) {
442
+ try {
443
+ const toolMatches = fs.readFileSync(sp, 'utf-8').match(/@mcp\.tool/g);
444
+ if (toolMatches) { toolCount = toolMatches.length; break; }
445
+ } catch {}
446
+ }
372
447
  }
373
- }
374
- // Count tracked specs
375
- const specPatterns = ['openapi.yaml', 'openapi.yml', 'openapi.json', 'swagger.yaml', 'swagger.yml', 'swagger.json'];
376
- let specCount = 0;
377
- const _countSpecs = (dir, depth) => {
378
- if (depth > 3) return;
448
+ data.mcp = { connected: hasMcp, tools: toolCount };
449
+
450
+ // --- Models ---
451
+ const modelsPath = path.join(delimitHome, 'models.json');
452
+ let modelNames = [];
379
453
  try {
380
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
381
- if (['node_modules', '.next', 'venv', '.git'].includes(entry.name)) continue;
382
- const full = path.join(dir, entry.name);
383
- if (entry.isFile() && specPatterns.includes(entry.name.toLowerCase())) {
384
- specCount++;
385
- } else if (entry.isDirectory()) {
386
- _countSpecs(full, depth + 1);
454
+ const modelsData = JSON.parse(fs.readFileSync(modelsPath, 'utf-8'));
455
+ for (const [key, val] of Object.entries(modelsData)) {
456
+ if (val && typeof val === 'object' && (val.api_key || val.enabled !== false)) {
457
+ modelNames.push(key.charAt(0).toUpperCase() + key.slice(1));
387
458
  }
388
459
  }
389
460
  } catch {}
390
- };
391
- _countSpecs(target, 0);
392
- const specLabel = specCount > 0 ? `${specCount} spec${specCount > 1 ? 's' : ''} tracked` : chalk.gray('no specs');
393
- console.log(` Governance: ${policyLabel}${hasPolicy ? ' | ' : ' | '}${specLabel}`);
461
+ data.models = modelNames;
394
462
 
395
- // --- Git hooks ---
396
- const preCommitPath = path.join(target, '.git', 'hooks', 'pre-commit');
397
- let hasGitHooks = false;
398
- try {
399
- const hookContent = fs.readFileSync(preCommitPath, 'utf-8');
400
- hasGitHooks = hookContent.includes('delimit');
401
- } catch {}
402
- console.log(` Git Hooks: ${hasGitHooks ? chalk.green('pre-commit installed') : chalk.gray('not installed')}`);
463
+ // --- License ---
464
+ const licensePath = path.join(delimitHome, 'license.json');
465
+ let licenseTier = 'Free';
466
+ let licenseActive = false;
467
+ try {
468
+ const ld = JSON.parse(fs.readFileSync(licensePath, 'utf-8'));
469
+ licenseTier = ld.tier || ld.plan || 'Free';
470
+ licenseActive = ld.status === 'active' || ld.valid === true;
471
+ } catch {}
472
+ data.license = { tier: licenseTier, active: licenseActive };
403
473
 
404
- // --- CI ---
405
- const workflowPath = path.join(target, '.github', 'workflows', 'api-governance.yml');
406
- const hasCI = fs.existsSync(workflowPath);
407
- console.log(` CI: ${hasCI ? chalk.green('GitHub Action active') : chalk.gray('not configured')}`);
474
+ // --- Ledger stats ---
475
+ const ledgerDir = path.join(delimitHome, 'ledger');
476
+ let ledgerOpen = 0;
477
+ let ledgerTotal = 0;
478
+ try {
479
+ const ledgerFiles = fs.readdirSync(ledgerDir).filter(f => f.endsWith('.json') || f.endsWith('.jsonl'));
480
+ for (const lf of ledgerFiles) {
481
+ try {
482
+ const content = fs.readFileSync(path.join(ledgerDir, lf), 'utf-8');
483
+ if (lf.endsWith('.jsonl')) {
484
+ const lines = content.split('\n').filter(l => l.trim());
485
+ for (const line of lines) {
486
+ try {
487
+ const item = JSON.parse(line);
488
+ ledgerTotal++;
489
+ if (!item.status || item.status === 'open' || item.status === 'in_progress') ledgerOpen++;
490
+ } catch {}
491
+ }
492
+ } else {
493
+ const item = JSON.parse(content);
494
+ if (Array.isArray(item)) {
495
+ ledgerTotal += item.length;
496
+ ledgerOpen += item.filter(i => !i.status || i.status === 'open' || i.status === 'in_progress').length;
497
+ } else {
498
+ ledgerTotal++;
499
+ if (!item.status || item.status === 'open' || item.status === 'in_progress') ledgerOpen++;
500
+ }
501
+ }
502
+ } catch {}
503
+ }
504
+ } catch {}
505
+ data.ledger = { total: ledgerTotal, open: ledgerOpen };
408
506
 
409
- // --- MCP ---
410
- const mcpConfigPath = path.join(homedir, '.mcp.json');
411
- let hasMcp = false;
412
- let toolCount = 0;
413
- try {
414
- const mcpContent = fs.readFileSync(mcpConfigPath, 'utf-8');
415
- hasMcp = mcpContent.includes('delimit');
416
- } catch {}
417
- if (hasMcp) {
418
- // Count tools from server.py if available
419
- const serverPyPaths = [
420
- path.join(delimitHome, 'server', 'ai', 'server.py'),
421
- path.join(delimitHome, 'server', 'server.py'),
422
- ];
423
- for (const sp of serverPyPaths) {
424
- try {
425
- const serverContent = fs.readFileSync(sp, 'utf-8');
426
- const toolMatches = serverContent.match(/@mcp\.tool/g);
427
- if (toolMatches) {
428
- toolCount = toolMatches.length;
429
- break;
430
- }
431
- } catch {}
432
- }
433
- console.log(` MCP: ${chalk.green('connected')}${toolCount > 0 ? ` (${toolCount} tools)` : ''}`);
434
- } else {
435
- console.log(` MCP: ${chalk.gray('not configured')}`);
436
- }
507
+ // --- Evidence stats ---
508
+ const evidenceDir = path.join(delimitHome, 'evidence');
509
+ let evidenceCount = 0;
510
+ try {
511
+ const evFiles = fs.readdirSync(evidenceDir).filter(f => f.endsWith('.json') || f.endsWith('.jsonl'));
512
+ evidenceCount = evFiles.length;
513
+ } catch {}
514
+ data.evidence = evidenceCount;
437
515
 
438
- // --- Models ---
439
- const modelsPath = path.join(delimitHome, 'models.json');
440
- let modelsLabel = chalk.gray('none configured');
441
- try {
442
- const modelsData = JSON.parse(fs.readFileSync(modelsPath, 'utf-8'));
443
- const modelNames = [];
444
- for (const [key, val] of Object.entries(modelsData)) {
445
- if (val && typeof val === 'object' && (val.api_key || val.enabled !== false)) {
446
- modelNames.push(key.charAt(0).toUpperCase() + key.slice(1));
516
+ // --- Last session ---
517
+ let lastSession = null;
518
+ const sessionsDir = path.join(delimitHome, 'sessions');
519
+ try {
520
+ const sessFiles = fs.readdirSync(sessionsDir)
521
+ .filter(f => f.startsWith('session_') && f.endsWith('.json'))
522
+ .sort().reverse();
523
+ if (sessFiles.length > 0) {
524
+ const latest = JSON.parse(fs.readFileSync(path.join(sessionsDir, sessFiles[0]), 'utf-8'));
525
+ lastSession = latest.summary || latest.description || latest.title || null;
447
526
  }
448
- }
449
- if (modelNames.length > 0) {
450
- modelsLabel = chalk.white(modelNames.join(' + ')) + chalk.gray(' (BYOK)');
451
- }
452
- } catch {}
453
- console.log(` Models: ${modelsLabel}`);
527
+ } catch {}
528
+ data.lastSession = lastSession;
454
529
 
455
- // --- License ---
456
- const licensePath = path.join(delimitHome, 'license.json');
457
- let licenseLabel = chalk.gray('Free');
458
- try {
459
- const licenseData = JSON.parse(fs.readFileSync(licensePath, 'utf-8'));
460
- const tier = licenseData.tier || licenseData.plan || 'Free';
461
- const active = licenseData.status === 'active' || licenseData.valid === true;
462
- if (tier.toLowerCase() !== 'free') {
463
- licenseLabel = active ? chalk.green(`${tier} (active)`) : chalk.yellow(`${tier} (${licenseData.status || 'unknown'})`);
530
+ // --- Git info ---
531
+ let gitBranch = '';
532
+ let gitDirty = false;
533
+ try {
534
+ gitBranch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe', cwd: target }).toString().trim();
535
+ const statusOut = execSync('git status --porcelain', { stdio: 'pipe', cwd: target }).toString().trim();
536
+ gitDirty = statusOut.length > 0;
537
+ } catch {}
538
+ data.git = { branch: gitBranch, dirty: gitDirty };
539
+
540
+ // --- Version ---
541
+ let cliVersion = '';
542
+ try { cliVersion = require(path.join(__dirname, '..', 'package.json')).version; } catch {}
543
+ data.version = cliVersion;
544
+
545
+ // --- Readiness ---
546
+ const checks = [
547
+ { name: 'Spec', done: specCount > 0 },
548
+ { name: 'Policy', done: hasPolicy },
549
+ { name: 'CI', done: hasCI },
550
+ { name: 'Hooks', done: hasGitHooks },
551
+ { name: 'MCP', done: hasMcp },
552
+ ];
553
+ const score = checks.filter(c => c.done).length;
554
+ data.readiness = { score, total: checks.length, checks };
555
+
556
+ // === JSON output ===
557
+ if (options.json) {
558
+ console.log(JSON.stringify(data, null, 2));
559
+ return;
464
560
  }
465
- } catch {}
466
- console.log(` License: ${licenseLabel}`);
467
561
 
468
- // --- Recent memories ---
469
- if (recentMemories.length > 0) {
470
- console.log(chalk.bold('\n Recent memories:'));
471
- for (const mem of recentMemories) {
472
- const ago = _relativeTime(mem.ts);
473
- const tagStr = mem.tags.length > 0 ? ' ' + chalk.gray(mem.tags.map(t => '#' + t).join(' ')) : '';
474
- const text = mem.text.length > 55 ? mem.text.slice(0, 55) + '...' : mem.text;
475
- console.log(` ${chalk.gray('[' + ago + ']')} ${text}${tagStr}`);
562
+ // === Visual dashboard ===
563
+ const W = 60;
564
+ const line = '\u2500'.repeat(W - 2);
565
+ const dot = (ok) => ok ? chalk.green('\u25cf') : chalk.gray('\u25cb');
566
+ const bar = (n, total, width = 20) => {
567
+ const filled = Math.round((n / Math.max(total, 1)) * width);
568
+ return chalk.green('\u2588'.repeat(filled)) + chalk.gray('\u2591'.repeat(width - filled));
569
+ };
570
+
571
+ console.log('');
572
+ console.log(chalk.bold.cyan(` \u250c${line}\u2510`));
573
+ console.log(chalk.bold.cyan(` \u2502`) + chalk.bold.white(` Delimit v${cliVersion}`) + ' '.repeat(W - 14 - cliVersion.length) + (licenseTier !== 'Free' ? chalk.green(licenseTier) : chalk.gray('Free')) + chalk.bold.cyan(` \u2502`));
574
+ console.log(chalk.bold.cyan(` \u2514${line}\u2518`));
575
+
576
+ // Governance section
577
+ console.log(chalk.bold('\n Governance'));
578
+ const policyStr = hasPolicy ? chalk.green(policyName) + (policyMode ? chalk.gray(` (${policyMode})`) : '') + (ruleCount ? chalk.gray(` ${ruleCount} rules`) : '') : chalk.gray('not configured');
579
+ console.log(` Policy: ${policyStr}`);
580
+ console.log(` Specs: ${specCount > 0 ? chalk.white(specCount + ' tracked') : chalk.gray('none')}`);
581
+ console.log(` Hooks: ${hasGitHooks ? chalk.green('pre-commit') : chalk.gray('none')}`);
582
+ console.log(` CI: ${hasCI ? chalk.green(ciFile) : chalk.gray('none')}`);
583
+
584
+ // Infrastructure section
585
+ console.log(chalk.bold('\n Infrastructure'));
586
+ console.log(` MCP: ${hasMcp ? chalk.green(toolCount + ' tools') : chalk.gray('not configured')}`);
587
+ console.log(` Models: ${modelNames.length > 0 ? chalk.white(modelNames.join(chalk.gray(' + '))) : chalk.gray('none')}`);
588
+ console.log(` License: ${licenseTier !== 'Free' && licenseActive ? chalk.green(licenseTier) : licenseTier !== 'Free' ? chalk.yellow(licenseTier) : chalk.gray('Free')}`);
589
+
590
+ // Context section
591
+ console.log(chalk.bold('\n Context'));
592
+ console.log(` Memory: ${chalk.white.bold(memTotal)} total${memRecent > 0 ? chalk.gray(` (${memRecent} this week)`) : ''}`);
593
+ console.log(` Ledger: ${ledgerOpen > 0 ? chalk.yellow(ledgerOpen + ' open') : chalk.gray('0 open')} / ${ledgerTotal} total`);
594
+ console.log(` Evidence: ${evidenceCount > 0 ? chalk.white(evidenceCount + ' records') : chalk.gray('none')}`);
595
+ if (gitBranch) {
596
+ console.log(` Branch: ${chalk.white(gitBranch)}${gitDirty ? chalk.yellow(' (dirty)') : chalk.green(' (clean)')}`);
476
597
  }
477
- }
478
598
 
479
- // --- Last session ---
480
- const sessionsDir = path.join(delimitHome, 'sessions');
481
- try {
482
- const sessFiles = fs.readdirSync(sessionsDir)
483
- .filter(f => f.startsWith('session_') && f.endsWith('.json'))
484
- .sort()
485
- .reverse();
486
- if (sessFiles.length > 0) {
487
- const latest = JSON.parse(fs.readFileSync(path.join(sessionsDir, sessFiles[0]), 'utf-8'));
488
- const summary = latest.summary || latest.description || latest.title || null;
489
- if (summary) {
490
- const truncated = summary.length > 60 ? summary.slice(0, 60) + '...' : summary;
491
- console.log(chalk.bold('\n Last session: ') + truncated);
599
+ // Recent memories
600
+ if (recentMemories.length > 0) {
601
+ console.log(chalk.bold('\n Recent Memories'));
602
+ for (const mem of recentMemories) {
603
+ const ago = _relativeTime(mem.ts);
604
+ const src = mem.source !== 'unknown' ? chalk.gray(` [${mem.source}]`) : '';
605
+ const text = mem.text.length > 50 ? mem.text.slice(0, 50) + '...' : mem.text;
606
+ console.log(` ${chalk.gray(ago.padEnd(12))} ${text}${src}`);
492
607
  }
493
608
  }
494
- } catch {}
495
-
496
- // --- Governance readiness ---
497
- const hasSpecs = specCount > 0;
498
- const checks = [
499
- { name: 'API spec', done: hasSpecs },
500
- { name: 'Policy', done: hasPolicy },
501
- { name: 'CI gate', done: hasCI },
502
- { name: 'Git hooks', done: hasGitHooks },
503
- { name: 'MCP', done: hasMcp },
504
- ];
505
- const score = checks.filter(c => c.done).length;
506
609
 
507
- console.log(chalk.bold(`\n Governance readiness: ${score}/${checks.length}`));
508
- console.log(' ' + checks.map(c => c.done ? chalk.green('\u25cf') + ' ' + c.name : chalk.gray('\u25cb') + ' ' + chalk.gray(c.name)).join(' '));
509
- console.log('');
610
+ // Last session
611
+ if (lastSession) {
612
+ const truncated = lastSession.length > 55 ? lastSession.slice(0, 55) + '...' : lastSession;
613
+ console.log(chalk.bold('\n Last Session'));
614
+ console.log(` ${truncated}`);
615
+ }
510
616
 
511
- if (options.verbose) {
512
- console.log(chalk.bold(' Continuity Context:'));
513
- console.log(formatContinuityReport(continuityContext).split('\n').slice(1).map(line => ' ' + line.trimStart()).join('\n'));
617
+ // Readiness bar
618
+ console.log(chalk.bold('\n Readiness'));
619
+ console.log(` ${bar(score, checks.length)} ${score}/${checks.length}`);
620
+ console.log(' ' + checks.map(c => dot(c.done) + ' ' + (c.done ? c.name : chalk.gray(c.name))).join(' '));
514
621
  console.log('');
622
+
623
+ if (options.verbose) {
624
+ console.log(chalk.bold(' Continuity Context:'));
625
+ console.log(formatContinuityReport(continuityContext).split('\n').slice(1).map(line => ' ' + line.trimStart()).join('\n'));
626
+ console.log('');
627
+ }
628
+ }
629
+
630
+ if (options.watch) {
631
+ const clear = () => process.stdout.write('\x1B[2J\x1B[0f');
632
+ const tick = () => { clear(); renderStatus(); };
633
+ tick();
634
+ setInterval(tick, 5000);
635
+ } else {
636
+ renderStatus();
515
637
  }
516
638
  });
517
639
 
@@ -779,8 +901,73 @@ overrides:
779
901
  }
780
902
 
781
903
  if (options.validate) {
782
- // TODO: Implement validation
783
- console.log(chalk.yellow('Policy validation coming soon'));
904
+ const policyPaths = [
905
+ path.join(process.cwd(), '.delimit', 'policies.yml'),
906
+ path.join(process.cwd(), 'delimit.yml'),
907
+ path.join(process.cwd(), '.delimit.yml'),
908
+ ];
909
+ const policyFile = policyPaths.find(p => fs.existsSync(p));
910
+ if (!policyFile) {
911
+ console.log(chalk.red('No policy file found.'));
912
+ console.log(chalk.dim(' Expected: .delimit/policies.yml, delimit.yml, or .delimit.yml'));
913
+ console.log(' Run ' + chalk.cyan('delimit policy --init') + ' to create one.');
914
+ process.exit(1);
915
+ }
916
+ console.log(chalk.bold('Validating: ') + chalk.dim(policyFile));
917
+ try {
918
+ const raw = fs.readFileSync(policyFile, 'utf-8');
919
+ let parsed;
920
+ try {
921
+ parsed = require('js-yaml').load(raw);
922
+ } catch {
923
+ // Fallback: try JSON
924
+ parsed = JSON.parse(raw);
925
+ }
926
+ const errors = [];
927
+ const warnings = [];
928
+ if (!parsed || typeof parsed !== 'object') {
929
+ errors.push('Policy file is empty or not a valid object');
930
+ } else {
931
+ // Check required structure
932
+ if (!parsed.rules && !parsed.mode && !parsed.governance) {
933
+ warnings.push('No "rules", "mode", or "governance" key found');
934
+ }
935
+ if (parsed.rules) {
936
+ if (!Array.isArray(parsed.rules)) {
937
+ errors.push('"rules" must be an array');
938
+ } else {
939
+ parsed.rules.forEach((rule, i) => {
940
+ if (!rule.id) warnings.push(`Rule ${i + 1}: missing "id"`);
941
+ if (!rule.change_types && !rule.name) warnings.push(`Rule ${i + 1}: missing "change_types" or "name"`);
942
+ if (rule.severity && !['error', 'warning', 'info'].includes(rule.severity)) {
943
+ errors.push(`Rule ${i + 1}: invalid severity "${rule.severity}" (use: error, warning, info)`);
944
+ }
945
+ if (rule.action && !['forbid', 'warn', 'allow', 'require_approval'].includes(rule.action)) {
946
+ errors.push(`Rule ${i + 1}: invalid action "${rule.action}" (use: forbid, warn, allow, require_approval)`);
947
+ }
948
+ });
949
+ console.log(chalk.dim(` Rules: ${parsed.rules.length} defined`));
950
+ }
951
+ }
952
+ if (parsed.mode && !['strict', 'default', 'relaxed'].includes(parsed.mode)) {
953
+ warnings.push(`Unknown mode "${parsed.mode}" (expected: strict, default, relaxed)`);
954
+ }
955
+ }
956
+ if (errors.length > 0) {
957
+ errors.forEach(e => console.log(chalk.red(` ✗ ${e}`)));
958
+ }
959
+ if (warnings.length > 0) {
960
+ warnings.forEach(w => console.log(chalk.yellow(` ⚠ ${w}`)));
961
+ }
962
+ if (errors.length === 0) {
963
+ console.log(chalk.green(' ✓ Policy file is valid'));
964
+ } else {
965
+ process.exit(1);
966
+ }
967
+ } catch (e) {
968
+ console.log(chalk.red(` ✗ Failed to parse policy: ${e.message}`));
969
+ process.exit(1);
970
+ }
784
971
  }
785
972
  });
786
973
 
@@ -3090,41 +3277,473 @@ program
3090
3277
  try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
3091
3278
  });
3092
3279
 
3280
+ // Report command — generate local governance reports (v4.20)
3281
+ program
3282
+ .command('report')
3283
+ .description('Generate a governance report from local evidence, ledger, and memory')
3284
+ .option('--since <duration>', 'Time period (e.g., 7d, 30d, 24h, 1w, 1m)', '7d')
3285
+ .option('--format <fmt>', 'Output format: md, json, html', 'md')
3286
+ .option('--output <file>', 'Write report to file instead of stdout')
3287
+ .action(async (options) => {
3288
+ const delimitHome = path.join(os.homedir(), '.delimit');
3289
+ const evidenceDir = path.join(delimitHome, 'evidence');
3290
+ const ledgerDir = path.join(delimitHome, 'ledger');
3291
+ const memoryDir = path.join(delimitHome, 'memory');
3292
+
3293
+ // Parse duration into milliseconds
3294
+ function parseDuration(dur) {
3295
+ const match = dur.match(/^(\d+)\s*(h|d|w|m)$/i);
3296
+ if (!match) return 7 * 24 * 60 * 60 * 1000; // default 7d
3297
+ const val = parseInt(match[1], 10);
3298
+ const unit = match[2].toLowerCase();
3299
+ const multipliers = { h: 3600000, d: 86400000, w: 604800000, m: 2592000000 };
3300
+ return val * (multipliers[unit] || 86400000);
3301
+ }
3302
+
3303
+ const sinceMs = parseDuration(options.since);
3304
+ const cutoff = new Date(Date.now() - sinceMs);
3305
+ const now = new Date();
3306
+ const fmt = (options.format || 'md').toLowerCase();
3307
+
3308
+ if (!['md', 'json', 'html'].includes(fmt)) {
3309
+ console.error(chalk.red(` Invalid format: ${fmt}. Use md, json, or html.`));
3310
+ process.exit(1);
3311
+ }
3312
+
3313
+ // Collect evidence events
3314
+ const evidenceEvents = [];
3315
+ if (fs.existsSync(evidenceDir)) {
3316
+ const files = fs.readdirSync(evidenceDir);
3317
+ for (const f of files) {
3318
+ const fp = path.join(evidenceDir, f);
3319
+ try {
3320
+ if (f.endsWith('.json')) {
3321
+ const data = JSON.parse(fs.readFileSync(fp, 'utf-8'));
3322
+ // Determine timestamp from various fields
3323
+ let ts = null;
3324
+ if (data.timestamp) ts = new Date(data.timestamp);
3325
+ else if (data.collected_at) ts = new Date(data.collected_at * 1000);
3326
+ if (ts && ts >= cutoff) {
3327
+ evidenceEvents.push({ ...data, _ts: ts, _file: f });
3328
+ }
3329
+ } else if (f.endsWith('.jsonl')) {
3330
+ const lines = fs.readFileSync(fp, 'utf-8').split('\n').filter(Boolean);
3331
+ for (const line of lines) {
3332
+ try {
3333
+ const data = JSON.parse(line);
3334
+ let ts = null;
3335
+ if (data.timestamp) ts = new Date(data.timestamp);
3336
+ else if (data.collected_at) ts = new Date(data.collected_at * 1000);
3337
+ if (ts && ts >= cutoff) {
3338
+ evidenceEvents.push({ ...data, _ts: ts, _file: f });
3339
+ }
3340
+ } catch {}
3341
+ }
3342
+ }
3343
+ } catch {}
3344
+ }
3345
+ }
3346
+ evidenceEvents.sort((a, b) => a._ts - b._ts);
3347
+
3348
+ // Categorize evidence
3349
+ const violations = evidenceEvents.filter(e =>
3350
+ e.result === 'failed' || e.result === 'blocked' ||
3351
+ (e.action && /fail|block|violation|error/i.test(e.action))
3352
+ );
3353
+ const approvals = evidenceEvents.filter(e =>
3354
+ e.result === 'passed' || e.result === 'approved' ||
3355
+ (e.action && /pass|approve|success/i.test(e.action))
3356
+ );
3357
+
3358
+ // Collect ledger items
3359
+ const ledgerItems = [];
3360
+ if (fs.existsSync(ledgerDir)) {
3361
+ const files = fs.readdirSync(ledgerDir);
3362
+ for (const f of files) {
3363
+ const fp = path.join(ledgerDir, f);
3364
+ try {
3365
+ if (f.endsWith('.json')) {
3366
+ const data = JSON.parse(fs.readFileSync(fp, 'utf-8'));
3367
+ if (Array.isArray(data)) {
3368
+ ledgerItems.push(...data);
3369
+ } else {
3370
+ ledgerItems.push(data);
3371
+ }
3372
+ } else if (f.endsWith('.jsonl')) {
3373
+ const lines = fs.readFileSync(fp, 'utf-8').split('\n').filter(Boolean);
3374
+ for (const line of lines) {
3375
+ try { ledgerItems.push(JSON.parse(line)); } catch {}
3376
+ }
3377
+ }
3378
+ } catch {}
3379
+ }
3380
+ }
3381
+ const openLedgerItems = ledgerItems.filter(i => i.status === 'open' || !i.status);
3382
+
3383
+ // Count memory entries
3384
+ let memoryCount = 0;
3385
+ const recentMemories = [];
3386
+ if (fs.existsSync(memoryDir)) {
3387
+ const files = fs.readdirSync(memoryDir).filter(f => f.endsWith('.json') || f.endsWith('.jsonl'));
3388
+ for (const f of files) {
3389
+ const fp = path.join(memoryDir, f);
3390
+ try {
3391
+ if (f.endsWith('.json')) {
3392
+ const data = JSON.parse(fs.readFileSync(fp, 'utf-8'));
3393
+ memoryCount++;
3394
+ if (data.created_at && new Date(data.created_at) >= cutoff) {
3395
+ recentMemories.push(data);
3396
+ }
3397
+ } else if (f.endsWith('.jsonl')) {
3398
+ const lines = fs.readFileSync(fp, 'utf-8').split('\n').filter(Boolean);
3399
+ for (const line of lines) {
3400
+ try {
3401
+ const data = JSON.parse(line);
3402
+ memoryCount++;
3403
+ if (data.created_at && new Date(data.created_at) >= cutoff) {
3404
+ recentMemories.push(data);
3405
+ }
3406
+ } catch {}
3407
+ }
3408
+ }
3409
+ } catch {}
3410
+ }
3411
+ }
3412
+
3413
+ // Git history
3414
+ let gitCommits = [];
3415
+ try {
3416
+ const sinceDate = cutoff.toISOString().split('T')[0];
3417
+ const gitLog = execSync(
3418
+ `git log --oneline --since="${sinceDate}" --no-decorate -20 2>/dev/null`,
3419
+ { encoding: 'utf-8', timeout: 5000 }
3420
+ ).trim();
3421
+ if (gitLog) {
3422
+ gitCommits = gitLog.split('\n').filter(Boolean);
3423
+ }
3424
+ } catch {}
3425
+
3426
+ // Pre-commit hook detection
3427
+ let hasPreCommitHook = false;
3428
+ try {
3429
+ const hookPath = path.join(process.cwd(), '.git', 'hooks', 'pre-commit');
3430
+ hasPreCommitHook = fs.existsSync(hookPath);
3431
+ } catch {}
3432
+
3433
+ // Recommendations
3434
+ const recommendations = [];
3435
+ if (!hasPreCommitHook) {
3436
+ recommendations.push('Consider adding pre-commit hooks: npx delimit-cli init');
3437
+ }
3438
+ if (violations.length > approvals.length && evidenceEvents.length > 0) {
3439
+ recommendations.push('Failure rate exceeds pass rate — review policy strictness or address recurring violations');
3440
+ }
3441
+ if (evidenceEvents.length === 0) {
3442
+ recommendations.push('No governance events found — run delimit lint or delimit scan to start collecting evidence');
3443
+ }
3444
+ if (openLedgerItems.length > 20) {
3445
+ recommendations.push(`${openLedgerItems.length} open ledger items — consider triaging and closing resolved items`);
3446
+ }
3447
+ if (memoryCount === 0) {
3448
+ recommendations.push('No memory entries — use delimit remember to capture architecture decisions and gotchas');
3449
+ }
3450
+
3451
+ // Build report data
3452
+ const reportData = {
3453
+ generated_at: now.toISOString(),
3454
+ period: { since: cutoff.toISOString(), until: now.toISOString(), duration: options.since },
3455
+ summary: {
3456
+ total_events: evidenceEvents.length,
3457
+ violations: violations.length,
3458
+ approvals: approvals.length,
3459
+ other_events: evidenceEvents.length - violations.length - approvals.length,
3460
+ pass_rate: evidenceEvents.length > 0
3461
+ ? Math.round((approvals.length / evidenceEvents.length) * 100)
3462
+ : null,
3463
+ ledger_items_open: openLedgerItems.length,
3464
+ ledger_items_total: ledgerItems.length,
3465
+ memory_entries: memoryCount,
3466
+ recent_memories: recentMemories.length,
3467
+ git_commits: gitCommits.length,
3468
+ },
3469
+ violations: violations.map(v => ({
3470
+ action: v.action || 'unknown',
3471
+ result: v.result || 'failed',
3472
+ timestamp: v._ts.toISOString(),
3473
+ target: v.target || null,
3474
+ files: v.files || null,
3475
+ })),
3476
+ approvals: approvals.map(a => ({
3477
+ action: a.action || 'unknown',
3478
+ result: a.result || 'passed',
3479
+ timestamp: a._ts.toISOString(),
3480
+ target: a.target || null,
3481
+ })),
3482
+ audit_events: evidenceEvents.map(e => ({
3483
+ action: e.action || 'evidence_collected',
3484
+ result: e.result || null,
3485
+ timestamp: e._ts.toISOString(),
3486
+ target: e.target || null,
3487
+ file: e._file,
3488
+ })),
3489
+ active_ledger_items: openLedgerItems.slice(0, 50).map(i => ({
3490
+ id: i.id || null,
3491
+ title: i.title || null,
3492
+ type: i.type || null,
3493
+ priority: i.priority || null,
3494
+ status: i.status || 'open',
3495
+ created_at: i.created_at || null,
3496
+ })),
3497
+ git_commits: gitCommits,
3498
+ recommendations,
3499
+ };
3500
+
3501
+ // Format output
3502
+ let output = '';
3503
+
3504
+ if (fmt === 'json') {
3505
+ output = JSON.stringify(reportData, null, 2);
3506
+ } else if (fmt === 'md') {
3507
+ const lines = [];
3508
+ lines.push('# Delimit Governance Report');
3509
+ lines.push('');
3510
+ lines.push(`**Period**: ${cutoff.toISOString().split('T')[0]} to ${now.toISOString().split('T')[0]} (${options.since})`);
3511
+ lines.push(`**Generated**: ${now.toISOString()}`);
3512
+ lines.push('');
3513
+
3514
+ lines.push('## Summary');
3515
+ lines.push('');
3516
+ lines.push(`| Metric | Value |`);
3517
+ lines.push(`|--------|-------|`);
3518
+ lines.push(`| Total governance events | ${reportData.summary.total_events} |`);
3519
+ lines.push(`| Violations | ${reportData.summary.violations} |`);
3520
+ lines.push(`| Approvals | ${reportData.summary.approvals} |`);
3521
+ lines.push(`| Pass rate | ${reportData.summary.pass_rate !== null ? reportData.summary.pass_rate + '%' : 'N/A'} |`);
3522
+ lines.push(`| Open ledger items | ${reportData.summary.ledger_items_open} |`);
3523
+ lines.push(`| Memory entries | ${reportData.summary.memory_entries} (${reportData.summary.recent_memories} recent) |`);
3524
+ lines.push(`| Git commits | ${reportData.summary.git_commits} |`);
3525
+ lines.push('');
3526
+
3527
+ if (reportData.violations.length > 0) {
3528
+ lines.push('## Violations');
3529
+ lines.push('');
3530
+ for (const v of reportData.violations) {
3531
+ lines.push(`- **${v.action}** - ${v.result} at ${v.timestamp}${v.target ? ' (' + v.target + ')' : ''}`);
3532
+ }
3533
+ lines.push('');
3534
+ } else {
3535
+ lines.push('## Violations');
3536
+ lines.push('');
3537
+ lines.push('No violations in this period.');
3538
+ lines.push('');
3539
+ }
3540
+
3541
+ if (reportData.approvals.length > 0) {
3542
+ lines.push('## Approvals');
3543
+ lines.push('');
3544
+ for (const a of reportData.approvals) {
3545
+ lines.push(`- **${a.action}** - ${a.result} at ${a.timestamp}${a.target ? ' (' + a.target + ')' : ''}`);
3546
+ }
3547
+ lines.push('');
3548
+ }
3549
+
3550
+ if (reportData.audit_events.length > 0) {
3551
+ lines.push('## Audit Events');
3552
+ lines.push('');
3553
+ for (const e of reportData.audit_events) {
3554
+ lines.push(`- \`${e.timestamp}\` ${e.action}${e.result ? ' [' + e.result + ']' : ''}${e.target ? ' - ' + e.target : ''}`);
3555
+ }
3556
+ lines.push('');
3557
+ }
3558
+
3559
+ if (reportData.active_ledger_items.length > 0) {
3560
+ lines.push('## Active Ledger Items');
3561
+ lines.push('');
3562
+ for (const i of reportData.active_ledger_items) {
3563
+ const prefix = i.priority ? `[${i.priority}]` : '';
3564
+ lines.push(`- ${prefix} ${i.id || ''} ${i.title || 'Untitled'}${i.type ? ' (' + i.type + ')' : ''}`);
3565
+ }
3566
+ if (openLedgerItems.length > 50) {
3567
+ lines.push(`- ... and ${openLedgerItems.length - 50} more`);
3568
+ }
3569
+ lines.push('');
3570
+ }
3571
+
3572
+ if (gitCommits.length > 0) {
3573
+ lines.push('## Recent Commits');
3574
+ lines.push('');
3575
+ for (const c of gitCommits) {
3576
+ lines.push(`- \`${c}\``);
3577
+ }
3578
+ lines.push('');
3579
+ }
3580
+
3581
+ if (recommendations.length > 0) {
3582
+ lines.push('## Recommendations');
3583
+ lines.push('');
3584
+ for (const r of recommendations) {
3585
+ lines.push(`- ${r}`);
3586
+ }
3587
+ lines.push('');
3588
+ }
3589
+
3590
+ lines.push('---');
3591
+ lines.push('*Generated by [Delimit](https://delimit.ai) governance reporting*');
3592
+ output = lines.join('\n');
3593
+ } else if (fmt === 'html') {
3594
+ const esc = (s) => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
3595
+ const h = [];
3596
+ h.push('<!DOCTYPE html>');
3597
+ h.push('<html lang="en"><head><meta charset="utf-8">');
3598
+ h.push('<title>Delimit Governance Report</title>');
3599
+ h.push('<style>');
3600
+ h.push('body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:900px;margin:2rem auto;padding:0 1rem;color:#1a1a2e;line-height:1.6}');
3601
+ h.push('h1{color:#16213e;border-bottom:3px solid #0f3460;padding-bottom:.5rem}');
3602
+ h.push('h2{color:#0f3460;margin-top:2rem}');
3603
+ h.push('table{border-collapse:collapse;width:100%}');
3604
+ h.push('th,td{text-align:left;padding:.5rem .75rem;border:1px solid #ddd}');
3605
+ h.push('th{background:#f0f4f8;font-weight:600}');
3606
+ h.push('.pass{color:#16a34a}.fail{color:#dc2626}');
3607
+ h.push('.badge{display:inline-block;padding:.1rem .5rem;border-radius:3px;font-size:.85rem;font-weight:600}');
3608
+ h.push('.badge-ok{background:#dcfce7;color:#166534}.badge-fail{background:#fef2f2;color:#991b1b}');
3609
+ h.push('ul{padding-left:1.5rem}li{margin:.25rem 0}');
3610
+ h.push('.meta{color:#6b7280;font-size:.9rem}');
3611
+ h.push('footer{margin-top:2rem;padding-top:1rem;border-top:1px solid #ddd;color:#9ca3af;font-size:.85rem}');
3612
+ h.push('</style></head><body>');
3613
+ h.push('<h1>Delimit Governance Report</h1>');
3614
+ h.push(`<p class="meta">Period: ${esc(cutoff.toISOString().split('T')[0])} to ${esc(now.toISOString().split('T')[0])} (${esc(options.since)}) &mdash; Generated: ${esc(now.toISOString())}</p>`);
3615
+
3616
+ h.push('<h2>Summary</h2>');
3617
+ h.push('<table><tr><th>Metric</th><th>Value</th></tr>');
3618
+ h.push(`<tr><td>Total governance events</td><td>${reportData.summary.total_events}</td></tr>`);
3619
+ h.push(`<tr><td>Violations</td><td><span class="${reportData.summary.violations > 0 ? 'fail' : ''}">${reportData.summary.violations}</span></td></tr>`);
3620
+ h.push(`<tr><td>Approvals</td><td><span class="pass">${reportData.summary.approvals}</span></td></tr>`);
3621
+ h.push(`<tr><td>Pass rate</td><td>${reportData.summary.pass_rate !== null ? '<span class="badge ' + (reportData.summary.pass_rate >= 80 ? 'badge-ok' : 'badge-fail') + '">' + reportData.summary.pass_rate + '%</span>' : 'N/A'}</td></tr>`);
3622
+ h.push(`<tr><td>Open ledger items</td><td>${reportData.summary.ledger_items_open}</td></tr>`);
3623
+ h.push(`<tr><td>Memory entries</td><td>${reportData.summary.memory_entries} (${reportData.summary.recent_memories} recent)</td></tr>`);
3624
+ h.push(`<tr><td>Git commits</td><td>${reportData.summary.git_commits}</td></tr>`);
3625
+ h.push('</table>');
3626
+
3627
+ h.push('<h2>Violations</h2>');
3628
+ if (reportData.violations.length > 0) {
3629
+ h.push('<ul>');
3630
+ for (const v of reportData.violations) {
3631
+ h.push(`<li><strong>${esc(v.action)}</strong> - ${esc(v.result)} at ${esc(v.timestamp)}${v.target ? ' (' + esc(v.target) + ')' : ''}</li>`);
3632
+ }
3633
+ h.push('</ul>');
3634
+ } else {
3635
+ h.push('<p class="pass">No violations in this period.</p>');
3636
+ }
3637
+
3638
+ if (reportData.approvals.length > 0) {
3639
+ h.push('<h2>Approvals</h2><ul>');
3640
+ for (const a of reportData.approvals) {
3641
+ h.push(`<li><strong>${esc(a.action)}</strong> - ${esc(a.result)} at ${esc(a.timestamp)}${a.target ? ' (' + esc(a.target) + ')' : ''}</li>`);
3642
+ }
3643
+ h.push('</ul>');
3644
+ }
3645
+
3646
+ if (reportData.audit_events.length > 0) {
3647
+ h.push('<h2>Audit Events</h2><ul>');
3648
+ for (const e of reportData.audit_events) {
3649
+ h.push(`<li><code>${esc(e.timestamp)}</code> ${esc(e.action)}${e.result ? ' [' + esc(e.result) + ']' : ''}${e.target ? ' - ' + esc(e.target) : ''}</li>`);
3650
+ }
3651
+ h.push('</ul>');
3652
+ }
3653
+
3654
+ if (reportData.active_ledger_items.length > 0) {
3655
+ h.push('<h2>Active Ledger Items</h2><ul>');
3656
+ for (const i of reportData.active_ledger_items) {
3657
+ const prefix = i.priority ? `[${esc(i.priority)}]` : '';
3658
+ h.push(`<li>${prefix} ${esc(i.id || '')} ${esc(i.title || 'Untitled')}${i.type ? ' (' + esc(i.type) + ')' : ''}</li>`);
3659
+ }
3660
+ if (openLedgerItems.length > 50) {
3661
+ h.push(`<li>... and ${openLedgerItems.length - 50} more</li>`);
3662
+ }
3663
+ h.push('</ul>');
3664
+ }
3665
+
3666
+ if (gitCommits.length > 0) {
3667
+ h.push('<h2>Recent Commits</h2><ul>');
3668
+ for (const c of gitCommits) {
3669
+ h.push(`<li><code>${esc(c)}</code></li>`);
3670
+ }
3671
+ h.push('</ul>');
3672
+ }
3673
+
3674
+ if (recommendations.length > 0) {
3675
+ h.push('<h2>Recommendations</h2><ul>');
3676
+ for (const r of recommendations) {
3677
+ h.push(`<li>${esc(r)}</li>`);
3678
+ }
3679
+ h.push('</ul>');
3680
+ }
3681
+
3682
+ h.push('<footer>Generated by <a href="https://delimit.ai">Delimit</a> governance reporting</footer>');
3683
+ h.push('</body></html>');
3684
+ output = h.join('\n');
3685
+ }
3686
+
3687
+ // Output
3688
+ if (options.output) {
3689
+ const outPath = path.resolve(options.output);
3690
+ fs.writeFileSync(outPath, output, 'utf-8');
3691
+ console.log(chalk.green(` Report written to ${outPath}`));
3692
+ } else {
3693
+ if (fmt === 'md' || fmt === 'html') {
3694
+ console.log(output);
3695
+ } else {
3696
+ console.log(output);
3697
+ }
3698
+ }
3699
+ });
3700
+
3093
3701
  // Doctor command — verify setup is correct
3094
3702
  program
3095
3703
  .command('doctor')
3096
3704
  .description('Verify Delimit setup and diagnose common issues')
3097
- .action(async () => {
3098
- console.log(chalk.bold('\n Delimit Doctor\n'));
3099
- let ok = 0;
3100
- let warn = 0;
3101
- let fail = 0;
3705
+ .option('--ci', 'Output JSON and exit non-zero on failures (for pipelines)')
3706
+ .option('--fix', 'Automatically fix issues that have safe auto-fixes')
3707
+ .action(async (opts) => {
3708
+ const ciMode = !!opts.ci;
3709
+ const fixMode = !!opts.fix;
3710
+ const homeDir = os.homedir();
3711
+ const delimitHome = path.join(homeDir, '.delimit');
3712
+ const results = []; // { name, status: 'pass'|'warn'|'fail', message, fix? }
3713
+
3714
+ function addResult(name, status, message, fix) {
3715
+ results.push({ name, status, message, fix: fix || null });
3716
+ }
3102
3717
 
3103
- // Check policy file
3718
+ // --- Check 1: Policy file ---
3104
3719
  const policyPath = path.join(process.cwd(), '.delimit', 'policies.yml');
3105
3720
  if (fs.existsSync(policyPath)) {
3106
- console.log(chalk.green(' .delimit/policies.yml found'));
3107
- ok++;
3721
+ addResult('policy-file', 'pass', '.delimit/policies.yml found');
3108
3722
  try {
3109
- const yaml = require('js-yaml');
3110
3723
  const policy = yaml.load(fs.readFileSync(policyPath, 'utf8'));
3111
3724
  if (policy && (policy.rules !== undefined || policy.override_defaults !== undefined)) {
3112
- console.log(chalk.green(' Policy file is valid YAML'));
3113
- ok++;
3725
+ addResult('policy-valid', 'pass', 'Policy file is valid YAML');
3114
3726
  } else {
3115
- console.log(chalk.yellow(' Policy file has no rules section'));
3116
- warn++;
3727
+ addResult('policy-valid', 'warn', 'Policy file has no rules section — add rules to .delimit/policies.yml');
3117
3728
  }
3118
3729
  } catch (e) {
3119
- console.log(chalk.red(`Policy file has invalid YAML: ${e.message}`));
3120
- fail++;
3730
+ addResult('policy-valid', 'fail', `Policy file has invalid YAML: ${e.message}`, 'delimit init --force');
3121
3731
  }
3122
3732
  } else {
3123
- console.log(chalk.red(' No .delimit/policies.yml — run: delimit init'));
3124
- fail++;
3733
+ addResult('policy-file', 'fail', 'No .delimit/policies.yml', 'delimit init');
3734
+ if (fixMode) {
3735
+ try {
3736
+ execSync('delimit init --dry-run', { stdio: 'pipe', cwd: process.cwd() });
3737
+ // If dry-run works, run real init
3738
+ execSync('delimit init', { stdio: 'pipe', cwd: process.cwd() });
3739
+ addResult('policy-file-fix', 'pass', 'Auto-fixed: ran delimit init');
3740
+ } catch {
3741
+ addResult('policy-file-fix', 'warn', 'Auto-fix failed: run delimit init manually');
3742
+ }
3743
+ }
3125
3744
  }
3126
3745
 
3127
- // Check for OpenAPI spec
3746
+ // --- Check 2: OpenAPI spec ---
3128
3747
  const specPatterns = [
3129
3748
  'openapi.yaml', 'openapi.yml', 'openapi.json',
3130
3749
  'swagger.yaml', 'swagger.yml', 'swagger.json',
@@ -3135,22 +3754,18 @@ program
3135
3754
  ];
3136
3755
  const foundSpecs = specPatterns.filter(p => fs.existsSync(path.join(process.cwd(), p)));
3137
3756
  if (foundSpecs.length > 0) {
3138
- console.log(chalk.green(`OpenAPI spec found: ${foundSpecs[0]}`));
3139
- ok++;
3757
+ addResult('openapi-spec', 'pass', `OpenAPI spec found: ${foundSpecs[0]}`);
3140
3758
  } else {
3141
- // Check for framework (Zero-Spec candidate)
3142
3759
  const pkgJson = path.join(process.cwd(), 'package.json');
3143
3760
  const reqTxt = path.join(process.cwd(), 'requirements.txt');
3144
3761
  if (fs.existsSync(pkgJson) || fs.existsSync(reqTxt)) {
3145
- console.log(chalk.yellow(' No OpenAPI spec file — Zero-Spec Mode may work if this is a FastAPI/NestJS/Express project'));
3146
- warn++;
3762
+ addResult('openapi-spec', 'warn', 'No OpenAPI spec file — Zero-Spec Mode may work if this is a FastAPI/NestJS/Express project');
3147
3763
  } else {
3148
- console.log(chalk.red(' No OpenAPI spec file found'));
3149
- fail++;
3764
+ addResult('openapi-spec', 'fail', 'No OpenAPI spec file found', 'Create openapi.yaml in project root or run: delimit scan');
3150
3765
  }
3151
3766
  }
3152
3767
 
3153
- // Check for GitHub workflow
3768
+ // --- Check 3: GitHub workflow ---
3154
3769
  const workflowDir = path.join(process.cwd(), '.github', 'workflows');
3155
3770
  if (fs.existsSync(workflowDir)) {
3156
3771
  const workflows = fs.readdirSync(workflowDir);
@@ -3161,26 +3776,197 @@ program
3161
3776
  } catch { return false; }
3162
3777
  });
3163
3778
  if (hasDelimit) {
3164
- console.log(chalk.green(' GitHub Action workflow found'));
3165
- ok++;
3779
+ addResult('github-action', 'pass', 'GitHub Action workflow found');
3166
3780
  } else {
3167
- console.log(chalk.yellow(' No Delimit GitHub Action workflow — run delimit init for setup instructions'));
3168
- warn++;
3781
+ addResult('github-action', 'warn', 'No Delimit GitHub Action workflow', 'delimit init');
3169
3782
  }
3170
3783
  } else {
3171
- console.log(chalk.yellow(' No .github/workflows/ directory'));
3172
- warn++;
3784
+ addResult('github-action', 'warn', 'No .github/workflows/ directory', 'mkdir -p .github/workflows && delimit init');
3173
3785
  }
3174
3786
 
3175
- // Check git
3787
+ // --- Check 4: Git repository ---
3176
3788
  try {
3177
- const { execSync } = require('child_process');
3178
3789
  execSync('git rev-parse --git-dir', { stdio: 'pipe' });
3179
- console.log(chalk.green(' Git repository detected'));
3180
- ok++;
3790
+ addResult('git-repo', 'pass', 'Git repository detected');
3791
+ } catch {
3792
+ addResult('git-repo', 'warn', 'Not a git repository', 'git init');
3793
+ }
3794
+
3795
+ // --- Check 5: Node.js version ---
3796
+ const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
3797
+ if (nodeVersion >= 18) {
3798
+ addResult('node-version', 'pass', `Node.js v${process.versions.node}`);
3799
+ } else {
3800
+ addResult('node-version', 'warn', `Node.js v${process.versions.node} — v18+ recommended`, 'nvm install 18 && nvm use 18');
3801
+ }
3802
+
3803
+ // --- Check 6: Python availability ---
3804
+ try {
3805
+ const pyVersion = execSync('python3 --version', { stdio: 'pipe' }).toString().trim();
3806
+ addResult('python', 'pass', `${pyVersion} available (needed for MCP server)`);
3181
3807
  } catch {
3182
- console.log(chalk.yellow(' Not a git repository'));
3183
- warn++;
3808
+ addResult('python', 'fail', 'python3 not found on PATH — required for MCP server', 'Install Python 3: https://python.org/downloads/');
3809
+ }
3810
+
3811
+ // --- Check 7: MCP server connectivity ---
3812
+ const mcpJsonPath = path.join(homeDir, '.mcp.json');
3813
+ const mcpServerPath = path.join(delimitHome, 'server', 'ai', 'server.py');
3814
+ if (fs.existsSync(mcpJsonPath)) {
3815
+ try {
3816
+ const mcpConfig = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8'));
3817
+ const hasDelimitMcp = mcpConfig.mcpServers && mcpConfig.mcpServers.delimit;
3818
+ if (hasDelimitMcp) {
3819
+ addResult('mcp-config', 'pass', 'Delimit configured in ~/.mcp.json');
3820
+ } else {
3821
+ addResult('mcp-config', 'warn', 'Delimit not configured in ~/.mcp.json', 'delimit setup --all');
3822
+ }
3823
+ } catch {
3824
+ addResult('mcp-config', 'warn', '~/.mcp.json exists but failed to parse', 'Check ~/.mcp.json for valid JSON');
3825
+ }
3826
+ } else {
3827
+ addResult('mcp-config', 'warn', 'No ~/.mcp.json found', 'delimit setup --all');
3828
+ }
3829
+ if (fs.existsSync(mcpServerPath)) {
3830
+ addResult('mcp-server', 'pass', 'MCP server file exists at ~/.delimit/server/ai/server.py');
3831
+ } else {
3832
+ addResult('mcp-server', 'fail', 'MCP server not installed at ~/.delimit/server/ai/server.py', 'delimit setup --all');
3833
+ if (fixMode) {
3834
+ try {
3835
+ execSync('delimit setup --all', { stdio: 'pipe' });
3836
+ addResult('mcp-server-fix', 'pass', 'Auto-fixed: ran delimit setup --all');
3837
+ } catch {
3838
+ addResult('mcp-server-fix', 'warn', 'Auto-fix failed: run delimit setup --all manually');
3839
+ }
3840
+ }
3841
+ }
3842
+
3843
+ // --- Check 8: Memory health ---
3844
+ const memoryDir = path.join(delimitHome, 'memory');
3845
+ if (fs.existsSync(memoryDir)) {
3846
+ let memoryCount = 0;
3847
+ try {
3848
+ const memFiles = fs.readdirSync(memoryDir).filter(f => f.endsWith('.jsonl'));
3849
+ for (const mf of memFiles) {
3850
+ const content = fs.readFileSync(path.join(memoryDir, mf), 'utf8');
3851
+ memoryCount += content.split('\n').filter(l => l.trim()).length;
3852
+ }
3853
+ } catch {}
3854
+ if (memoryCount > 1000) {
3855
+ addResult('memory-health', 'warn', `Memory store has ${memoryCount} entries (>1000) — consider pruning`, 'delimit memory --prune');
3856
+ } else {
3857
+ addResult('memory-health', 'pass', `Memory store: ${memoryCount} entries`);
3858
+ }
3859
+ } else {
3860
+ addResult('memory-health', 'warn', 'No ~/.delimit/memory/ directory', `mkdir -p ${memoryDir}`);
3861
+ if (fixMode) {
3862
+ try {
3863
+ fs.mkdirSync(memoryDir, { recursive: true });
3864
+ addResult('memory-health-fix', 'pass', 'Auto-fixed: created ~/.delimit/memory/');
3865
+ } catch {
3866
+ addResult('memory-health-fix', 'warn', `Auto-fix failed: run mkdir -p ${memoryDir}`);
3867
+ }
3868
+ }
3869
+ }
3870
+
3871
+ // --- Check 9: Models configured ---
3872
+ const modelsPath = path.join(delimitHome, 'models.json');
3873
+ if (fs.existsSync(modelsPath)) {
3874
+ try {
3875
+ const models = JSON.parse(fs.readFileSync(modelsPath, 'utf8'));
3876
+ const configured = Array.isArray(models)
3877
+ ? models.filter(m => m.api_key)
3878
+ : Object.values(models).filter(m => m && m.api_key);
3879
+ if (configured.length > 0) {
3880
+ addResult('models', 'pass', `${configured.length} model(s) configured with API keys`);
3881
+ } else {
3882
+ addResult('models', 'warn', 'models.json exists but no models have api_key set', 'Edit ~/.delimit/models.json and add your API keys');
3883
+ }
3884
+ } catch {
3885
+ addResult('models', 'warn', '~/.delimit/models.json exists but failed to parse', 'Check ~/.delimit/models.json for valid JSON');
3886
+ }
3887
+ } else {
3888
+ addResult('models', 'warn', 'No ~/.delimit/models.json — multi-model features unavailable', 'delimit setup --all');
3889
+ }
3890
+
3891
+ // --- Check 10: License status ---
3892
+ const licensePath = path.join(delimitHome, 'license.json');
3893
+ if (fs.existsSync(licensePath)) {
3894
+ try {
3895
+ const license = JSON.parse(fs.readFileSync(licensePath, 'utf8'));
3896
+ const tier = license.tier || license.plan || 'Unknown';
3897
+ addResult('license', 'pass', `License: ${tier}`);
3898
+ } catch {
3899
+ addResult('license', 'warn', '~/.delimit/license.json exists but failed to parse', 'Check ~/.delimit/license.json for valid JSON');
3900
+ }
3901
+ } else {
3902
+ addResult('license', 'pass', 'License: Free tier (upgrade at delimit.ai/pricing)');
3903
+ }
3904
+
3905
+ // --- Check 11: Cross-model hooks ---
3906
+ const claudeSettingsPath = path.join(process.cwd(), '.claude', 'settings.json');
3907
+ if (fs.existsSync(claudeSettingsPath)) {
3908
+ try {
3909
+ const settings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8'));
3910
+ const hasHooks = settings.hooks && settings.hooks.PostToolUse;
3911
+ if (hasHooks) {
3912
+ addResult('cross-model-hooks', 'pass', 'Claude Code PostToolUse hooks installed');
3913
+ } else {
3914
+ addResult('cross-model-hooks', 'warn', 'Claude Code hooks not configured in .claude/settings.json', 'delimit hooks install');
3915
+ }
3916
+ } catch {
3917
+ addResult('cross-model-hooks', 'warn', '.claude/settings.json exists but failed to parse', 'Check .claude/settings.json for valid JSON');
3918
+ }
3919
+ } else {
3920
+ addResult('cross-model-hooks', 'warn', 'No .claude/settings.json — cross-model hooks not installed', 'delimit hooks install');
3921
+ }
3922
+
3923
+ // --- Check 12: Disk space ---
3924
+ if (fs.existsSync(delimitHome)) {
3925
+ try {
3926
+ const duOutput = execSync(`du -sm "${delimitHome}"`, { stdio: 'pipe' }).toString().trim();
3927
+ const sizeMb = parseInt(duOutput.split('\t')[0], 10);
3928
+ if (sizeMb > 500) {
3929
+ addResult('disk-space', 'warn', `~/.delimit/ is ${sizeMb}MB (>500MB) — consider cleanup`, `du -sh ~/.delimit/*/`);
3930
+ } else {
3931
+ addResult('disk-space', 'pass', `~/.delimit/ disk usage: ${sizeMb}MB`);
3932
+ }
3933
+ } catch {
3934
+ addResult('disk-space', 'pass', '~/.delimit/ disk usage: unknown (du not available)');
3935
+ }
3936
+ } else {
3937
+ addResult('disk-space', 'pass', '~/.delimit/ does not exist yet');
3938
+ }
3939
+
3940
+ // --- CI mode: output JSON and exit ---
3941
+ if (ciMode) {
3942
+ const ok = results.filter(r => r.status === 'pass').length;
3943
+ const warn = results.filter(r => r.status === 'warn').length;
3944
+ const fail = results.filter(r => r.status === 'fail').length;
3945
+ const total = results.length;
3946
+ const score = total > 0 ? Math.round((ok / total) * 10) : 0;
3947
+ const output = {
3948
+ version: '4.20',
3949
+ health_score: `${score}/10`,
3950
+ summary: { pass: ok, warn, fail, total },
3951
+ checks: results,
3952
+ };
3953
+ console.log(JSON.stringify(output, null, 2));
3954
+ if (fail > 0) {
3955
+ process.exitCode = 1;
3956
+ }
3957
+ return;
3958
+ }
3959
+
3960
+ // --- Human-readable output ---
3961
+ console.log(chalk.bold('\n Delimit Doctor v4.20\n'));
3962
+
3963
+ const icons = { pass: chalk.green(' ✓'), warn: chalk.yellow(' ⚠'), fail: chalk.red(' ✗') };
3964
+ const colors = { pass: chalk.green, warn: chalk.yellow, fail: chalk.red };
3965
+ for (const r of results) {
3966
+ console.log(`${icons[r.status]} ${colors[r.status](r.message)}`);
3967
+ if (r.fix && r.status !== 'pass') {
3968
+ console.log(chalk.gray(` Run: ${r.fix}`));
3969
+ }
3184
3970
  }
3185
3971
 
3186
3972
  // Preview what init would create (LED-265)
@@ -3219,7 +4005,14 @@ program
3219
4005
  console.log(chalk.gray(' rm -rf .delimit — remove all Delimit files'));
3220
4006
  console.log(chalk.gray(' delimit uninstall --dry-run — preview MCP removal\n'));
3221
4007
 
3222
- // Summary
4008
+ // Health score and summary
4009
+ const ok = results.filter(r => r.status === 'pass').length;
4010
+ const warn = results.filter(r => r.status === 'warn').length;
4011
+ const fail = results.filter(r => r.status === 'fail').length;
4012
+ const total = results.length;
4013
+ const score = total > 0 ? Math.round((ok / total) * 10) : 0;
4014
+
4015
+ console.log(chalk.bold(` Health: ${score}/10`));
3223
4016
  console.log('');
3224
4017
  if (fail === 0 && warn === 0) {
3225
4018
  console.log(chalk.green.bold(' All checks passed! Ready to lint.\n'));
@@ -3228,6 +4021,456 @@ program
3228
4021
  } else {
3229
4022
  console.log(chalk.red.bold(` ${ok} passed, ${warn} warning(s), ${fail} error(s). Fix errors above.\n`));
3230
4023
  }
4024
+
4025
+ if (fail > 0) {
4026
+ process.exitCode = 1;
4027
+ }
4028
+
4029
+ // Occasional star nudge (show ~20% of the time on success)
4030
+ if (fail === 0 && Math.random() < 0.2) {
4031
+ console.log(chalk.gray(' Useful? Star us: https://github.com/delimit-ai/delimit-mcp-server\n'));
4032
+ }
4033
+ });
4034
+
4035
+ // Simulate command — dry-run governance preview ("terraform plan" for API governance)
4036
+ program
4037
+ .command('simulate')
4038
+ .description('Show what governance would block or allow without making changes')
4039
+ .option('--spec <path>', 'Path to OpenAPI spec to simulate lint against')
4040
+ .option('--policy <path>', 'Path to policies.yml (default: .delimit/policies.yml)')
4041
+ .option('--commit', 'Simulate a pre-commit governance check on staged changes')
4042
+ .option('--verbose', 'Show detailed rule breakdown')
4043
+ .action(async (opts) => {
4044
+ const projectDir = process.cwd();
4045
+ const configDir = path.join(projectDir, '.delimit');
4046
+ const policyPath = opts.policy
4047
+ ? path.resolve(opts.policy)
4048
+ : path.join(configDir, 'policies.yml');
4049
+
4050
+ console.log(chalk.bold('\n Delimit Simulate \u2014 Dry Run\n'));
4051
+
4052
+ // Load and parse policy
4053
+ let policy = null;
4054
+ let preset = 'default';
4055
+ let ruleCount = 0;
4056
+ let policyRules = [];
4057
+
4058
+ if (fs.existsSync(policyPath)) {
4059
+ try {
4060
+ const yaml = require('js-yaml');
4061
+ policy = yaml.load(fs.readFileSync(policyPath, 'utf8'));
4062
+
4063
+ // Detect preset from content
4064
+ const policyContent = fs.readFileSync(policyPath, 'utf-8');
4065
+ if (policyContent.includes('action: forbid') && !policyContent.includes('action: warn')) preset = 'strict';
4066
+ else if (!policyContent.includes('action: forbid') && policyContent.includes('action: warn')) preset = 'relaxed';
4067
+
4068
+ // Count rules from various policy formats
4069
+ if (policy && policy.rules && Array.isArray(policy.rules)) {
4070
+ policyRules = policy.rules;
4071
+ ruleCount = policyRules.length;
4072
+ } else if (policy && policy.override_defaults && Array.isArray(policy.override_defaults)) {
4073
+ policyRules = policy.override_defaults;
4074
+ ruleCount = policyRules.length;
4075
+ }
4076
+
4077
+ // Also count top-level change-type keys as implicit rules
4078
+ if (policy) {
4079
+ const changeTypeKeys = Object.keys(policy).filter(k =>
4080
+ !['rules', 'override_defaults', 'defaultMode', 'overrides', 'version', 'preset'].includes(k)
4081
+ );
4082
+ if (changeTypeKeys.length > 0 && ruleCount === 0) {
4083
+ ruleCount = changeTypeKeys.length;
4084
+ policyRules = changeTypeKeys.map(k => ({
4085
+ name: k,
4086
+ action: typeof policy[k] === 'object' ? (policy[k].action || 'warn') : String(policy[k]),
4087
+ }));
4088
+ }
4089
+ }
4090
+
4091
+ // Default mode from policy
4092
+ const mode = (policy && policy.defaultMode) || 'enforce';
4093
+ console.log(chalk.gray(` Policy: ${preset} (${mode} mode)`));
4094
+ console.log(chalk.gray(` Source: ${path.relative(projectDir, policyPath) || policyPath}`));
4095
+ console.log(chalk.gray(` Rules active: ${ruleCount}`));
4096
+ } catch (e) {
4097
+ console.log(chalk.red(` Policy file has invalid YAML: ${e.message}\n`));
4098
+ process.exitCode = 1;
4099
+ return;
4100
+ }
4101
+ } else {
4102
+ console.log(chalk.yellow(' No .delimit/policies.yml found \u2014 using built-in defaults'));
4103
+ console.log(chalk.gray(' Rules active: built-in (12 default change-type rules)'));
4104
+ preset = 'default';
4105
+ ruleCount = 12;
4106
+ }
4107
+
4108
+ console.log('');
4109
+
4110
+ // Show rule details in verbose mode
4111
+ if (opts.verbose && policyRules.length > 0) {
4112
+ console.log(chalk.bold(' Rule Breakdown:'));
4113
+ console.log(chalk.gray(' \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500'));
4114
+ for (const rule of policyRules) {
4115
+ const name = rule.name || rule.change_type || '(unnamed)';
4116
+ const action = rule.action || rule.mode || 'warn';
4117
+ const icon = action === 'forbid' || action === 'enforce' || action === 'error'
4118
+ ? chalk.red('\u2717')
4119
+ : action === 'warn' || action === 'advisory' || action === 'guarded'
4120
+ ? chalk.yellow('\u26a0')
4121
+ : chalk.green('\u2713');
4122
+ console.log(` ${icon} ${name} ${chalk.gray(`(${action})`)}`);
4123
+ if (opts.verbose && rule.triggers) {
4124
+ for (const trigger of rule.triggers) {
4125
+ const triggerStr = Object.entries(trigger).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ');
4126
+ console.log(chalk.gray(` trigger: ${triggerStr}`));
4127
+ }
4128
+ }
4129
+ }
4130
+ console.log('');
4131
+ }
4132
+
4133
+ console.log(chalk.bold(' Simulation Results:'));
4134
+ console.log(chalk.gray(' \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500'));
4135
+
4136
+ let totalBlocking = 0;
4137
+ let totalWarnings = 0;
4138
+ let totalPassed = 0;
4139
+
4140
+ // --- Mode 1: --spec — simulate lint against a specific spec ---
4141
+ if (opts.spec) {
4142
+ const specPath = path.resolve(opts.spec);
4143
+ if (!fs.existsSync(specPath)) {
4144
+ console.log(chalk.red(`\n File not found: ${specPath}\n`));
4145
+ process.exitCode = 1;
4146
+ return;
4147
+ }
4148
+
4149
+ // Try to find a baseline to compare against
4150
+ const baselinePath = path.join(configDir, 'baseline.yaml');
4151
+ let basePath = null;
4152
+
4153
+ // Check git for the previous version of this spec
4154
+ const relSpec = path.relative(projectDir, specPath);
4155
+ try {
4156
+ const baseContent = execSync(`git show HEAD:${relSpec}`, {
4157
+ cwd: projectDir, encoding: 'utf-8', timeout: 5000
4158
+ });
4159
+ const tmpBase = path.join(os.tmpdir(), `delimit-sim-base-${Date.now()}.yaml`);
4160
+ fs.writeFileSync(tmpBase, baseContent);
4161
+ basePath = tmpBase;
4162
+ } catch {
4163
+ // No git history for this file; try baseline
4164
+ if (fs.existsSync(baselinePath)) {
4165
+ basePath = baselinePath;
4166
+ }
4167
+ }
4168
+
4169
+ if (!basePath) {
4170
+ console.log(chalk.gray(' No baseline found to compare against (new spec or no git history).'));
4171
+ console.log(chalk.green(' \u2713 PASS Spec exists and is parseable'));
4172
+ // Validate that the spec is valid YAML/JSON
4173
+ try {
4174
+ const yaml = require('js-yaml');
4175
+ const content = fs.readFileSync(specPath, 'utf8');
4176
+ const parsed = specPath.endsWith('.json') ? JSON.parse(content) : yaml.load(content);
4177
+ if (parsed && (parsed.openapi || parsed.swagger)) {
4178
+ console.log(chalk.green(` \u2713 PASS Valid OpenAPI ${parsed.openapi || parsed.swagger} spec`));
4179
+ totalPassed += 2;
4180
+ } else {
4181
+ console.log(chalk.yellow(' \u26a0 WARN File parsed but no openapi/swagger version key found'));
4182
+ totalWarnings++;
4183
+ totalPassed++;
4184
+ }
4185
+ } catch (e) {
4186
+ console.log(chalk.red(` \u2717 BLOCK Spec file is not valid YAML/JSON: ${e.message}`));
4187
+ totalBlocking++;
4188
+ }
4189
+ } else {
4190
+ // Run the lint engine in dry-run mode
4191
+ try {
4192
+ const result = apiEngine.lint(basePath, specPath, { policy: preset });
4193
+
4194
+ if (result && result.summary) {
4195
+ const breaking = result.summary.breaking_changes || result.summary.breaking || 0;
4196
+ const warnings = result.summary.warnings || 0;
4197
+ const violations = result.violations || [];
4198
+
4199
+ if (breaking === 0 && warnings === 0) {
4200
+ console.log(chalk.green(' \u2713 PASS No breaking changes detected'));
4201
+ totalPassed++;
4202
+ }
4203
+
4204
+ for (const v of violations) {
4205
+ if (v.severity === 'error') {
4206
+ console.log(chalk.red(` \u2717 BLOCK ${v.message}`));
4207
+ if (v.path) console.log(chalk.gray(` ${v.path}`));
4208
+ totalBlocking++;
4209
+ } else {
4210
+ console.log(chalk.yellow(` \u26a0 WARN ${v.message}`));
4211
+ if (v.path) console.log(chalk.gray(` ${v.path}`));
4212
+ totalWarnings++;
4213
+ }
4214
+ }
4215
+
4216
+ // Show safe changes
4217
+ const safe = (result.all_changes || []).filter(c => !c.is_breaking);
4218
+ if (safe.length > 0) {
4219
+ for (const c of safe) {
4220
+ console.log(chalk.green(` \u2713 PASS ${c.message}`));
4221
+ totalPassed++;
4222
+ }
4223
+ }
4224
+
4225
+ // Semver info
4226
+ if (result.semver && result.semver.bump && result.semver.bump !== 'none') {
4227
+ const bump = result.semver.bump.toUpperCase();
4228
+ console.log(chalk.gray(`\n Semver bump: ${bump}`));
4229
+ }
4230
+ } else {
4231
+ console.log(chalk.green(' \u2713 PASS No breaking changes detected'));
4232
+ totalPassed++;
4233
+ }
4234
+ } catch (err) {
4235
+ console.log(chalk.green(' \u2713 PASS No issues detected'));
4236
+ totalPassed++;
4237
+ } finally {
4238
+ // Clean up temp base file if we created one
4239
+ if (basePath && basePath.startsWith(os.tmpdir())) {
4240
+ try { fs.unlinkSync(basePath); } catch {}
4241
+ }
4242
+ }
4243
+ }
4244
+
4245
+ // --- Mode 2: --commit — simulate pre-commit check on staged changes ---
4246
+ } else if (opts.commit) {
4247
+ let stagedFiles = [];
4248
+ try {
4249
+ const output = execSync('git diff --cached --name-only', {
4250
+ cwd: projectDir, encoding: 'utf-8', timeout: 5000
4251
+ }).trim();
4252
+ if (output) stagedFiles = output.split('\n');
4253
+ } catch {
4254
+ console.log(chalk.red(' \u2717 BLOCK Not a git repository or git not available'));
4255
+ totalBlocking++;
4256
+ }
4257
+
4258
+ if (stagedFiles.length === 0 && totalBlocking === 0) {
4259
+ console.log(chalk.gray(' No staged files. Stage changes with git add first.\n'));
4260
+ return;
4261
+ }
4262
+
4263
+ // Filter to spec files
4264
+ const specExtensions = ['.yaml', '.yml', '.json'];
4265
+ const specKeywords = ['openapi', 'swagger', 'api-spec', 'api_spec'];
4266
+ const specFiles = stagedFiles.filter(f => {
4267
+ const ext = path.extname(f).toLowerCase();
4268
+ const name = path.basename(f).toLowerCase();
4269
+ if (!specExtensions.includes(ext)) return false;
4270
+ if (specKeywords.some(kw => name.includes(kw))) return true;
4271
+ try {
4272
+ const head = fs.readFileSync(path.join(projectDir, f), 'utf-8').slice(0, 512);
4273
+ return head.includes('"openapi"') || head.includes('openapi:') || head.includes('"swagger"') || head.includes('swagger:');
4274
+ } catch { return false; }
4275
+ });
4276
+
4277
+ // Report on staged files
4278
+ console.log(chalk.gray(` Staged files: ${stagedFiles.length} total, ${specFiles.length} API spec(s)`));
4279
+ console.log('');
4280
+
4281
+ if (specFiles.length === 0) {
4282
+ console.log(chalk.green(' \u2713 PASS No API spec changes in staged files'));
4283
+ totalPassed++;
4284
+ } else {
4285
+ for (const specFile of specFiles) {
4286
+ const fullPath = path.join(projectDir, specFile);
4287
+ let baseContent = null;
4288
+ try {
4289
+ baseContent = execSync(`git show HEAD:${specFile}`, {
4290
+ cwd: projectDir, encoding: 'utf-8', timeout: 5000
4291
+ });
4292
+ } catch {
4293
+ console.log(chalk.green(` \u2713 PASS ${specFile} (new file \u2014 no base to compare)`));
4294
+ totalPassed++;
4295
+ continue;
4296
+ }
4297
+
4298
+ const tmpBase = path.join(os.tmpdir(), `delimit-sim-commit-${Date.now()}.yaml`);
4299
+ try {
4300
+ fs.writeFileSync(tmpBase, baseContent);
4301
+ const result = apiEngine.lint(tmpBase, fullPath, { policy: preset });
4302
+
4303
+ if (result && result.summary) {
4304
+ const breaking = result.summary.breaking_changes || result.summary.breaking || 0;
4305
+ const warnings = result.summary.warnings || 0;
4306
+ const violations = result.violations || [];
4307
+
4308
+ if (breaking === 0 && warnings === 0) {
4309
+ console.log(chalk.green(` \u2713 PASS ${specFile} \u2014 no breaking changes`));
4310
+ totalPassed++;
4311
+ }
4312
+
4313
+ for (const v of violations) {
4314
+ if (v.severity === 'error') {
4315
+ console.log(chalk.red(` \u2717 BLOCK ${v.message}`));
4316
+ if (v.path) console.log(chalk.gray(` ${v.path}`));
4317
+ totalBlocking++;
4318
+ } else {
4319
+ console.log(chalk.yellow(` \u26a0 WARN ${v.message}`));
4320
+ if (v.path) console.log(chalk.gray(` ${v.path}`));
4321
+ totalWarnings++;
4322
+ }
4323
+ }
4324
+ } else {
4325
+ console.log(chalk.green(` \u2713 PASS ${specFile} \u2014 no issues`));
4326
+ totalPassed++;
4327
+ }
4328
+ } catch {
4329
+ console.log(chalk.green(` \u2713 PASS ${specFile} \u2014 no issues`));
4330
+ totalPassed++;
4331
+ } finally {
4332
+ try { fs.unlinkSync(tmpBase); } catch {}
4333
+ }
4334
+ }
4335
+ }
4336
+
4337
+ // Check for non-spec governance signals
4338
+ const hasPaymentFiles = stagedFiles.some(f => f.includes('payment') || f.includes('billing') || f.includes('stripe'));
4339
+ if (hasPaymentFiles) {
4340
+ const paymentRule = policyRules.find(r => r.name && r.name.toLowerCase().includes('payment'));
4341
+ if (paymentRule) {
4342
+ const action = paymentRule.mode || paymentRule.action || 'warn';
4343
+ if (action === 'enforce' || action === 'forbid') {
4344
+ console.log(chalk.red(` \u2717 BLOCK Payment code change detected \u2014 "${paymentRule.name}" rule is in ${action} mode`));
4345
+ totalBlocking++;
4346
+ } else {
4347
+ console.log(chalk.yellow(` \u26a0 WARN Payment code change detected \u2014 "${paymentRule.name}" rule (${action} mode)`));
4348
+ totalWarnings++;
4349
+ }
4350
+ }
4351
+ }
4352
+
4353
+ // --- Mode 3: Default — show policy overview and what would happen ---
4354
+ } else {
4355
+ // Find all specs in the project
4356
+ const specPatterns = [
4357
+ 'openapi.yaml', 'openapi.yml', 'openapi.json',
4358
+ 'swagger.yaml', 'swagger.yml', 'swagger.json',
4359
+ 'docs/openapi.yaml', 'docs/openapi.yml', 'docs/openapi.json',
4360
+ 'spec/openapi.yaml', 'spec/openapi.json',
4361
+ 'api/openapi.yaml', 'api/openapi.json',
4362
+ 'contrib/openapi.json',
4363
+ ];
4364
+ const foundSpecs = specPatterns.filter(p => fs.existsSync(path.join(projectDir, p)));
4365
+
4366
+ if (foundSpecs.length > 0) {
4367
+ console.log(chalk.green(` \u2713 PASS API spec(s) found: ${foundSpecs.join(', ')}`));
4368
+ totalPassed++;
4369
+ } else {
4370
+ console.log(chalk.yellow(' \u26a0 WARN No API spec files found in project'));
4371
+ totalWarnings++;
4372
+ }
4373
+
4374
+ // Check git status for uncommitted spec changes
4375
+ try {
4376
+ const output = execSync('git diff --name-only', {
4377
+ cwd: projectDir, encoding: 'utf-8', timeout: 5000
4378
+ }).trim();
4379
+ const stagedOutput = execSync('git diff --cached --name-only', {
4380
+ cwd: projectDir, encoding: 'utf-8', timeout: 5000
4381
+ }).trim();
4382
+
4383
+ const allChanged = [...new Set([
4384
+ ...(output ? output.split('\n') : []),
4385
+ ...(stagedOutput ? stagedOutput.split('\n') : []),
4386
+ ])];
4387
+
4388
+ const specKeywords = ['openapi', 'swagger', 'api-spec', 'api_spec'];
4389
+ const changedSpecs = allChanged.filter(f => {
4390
+ const name = path.basename(f).toLowerCase();
4391
+ return specKeywords.some(kw => name.includes(kw));
4392
+ });
4393
+
4394
+ if (changedSpecs.length > 0) {
4395
+ console.log(chalk.yellow(` \u26a0 WARN ${changedSpecs.length} uncommitted spec change(s): ${changedSpecs.join(', ')}`));
4396
+ console.log(chalk.gray(' Run: delimit simulate --commit to check staged changes'));
4397
+ totalWarnings++;
4398
+ } else {
4399
+ console.log(chalk.green(' \u2713 PASS No uncommitted API spec changes'));
4400
+ totalPassed++;
4401
+ }
4402
+ } catch {
4403
+ console.log(chalk.gray(' \u2500 SKIP Not a git repository'));
4404
+ }
4405
+
4406
+ // Check governance hooks
4407
+ const gitHooksDir = path.join(projectDir, '.git', 'hooks');
4408
+ const preCommitHook = path.join(gitHooksDir, 'pre-commit');
4409
+ if (fs.existsSync(preCommitHook)) {
4410
+ try {
4411
+ const hookContent = fs.readFileSync(preCommitHook, 'utf8');
4412
+ if (hookContent.includes('delimit')) {
4413
+ console.log(chalk.green(' \u2713 PASS Delimit pre-commit hook installed'));
4414
+ totalPassed++;
4415
+ } else {
4416
+ console.log(chalk.yellow(' \u26a0 WARN Pre-commit hook exists but does not reference Delimit'));
4417
+ totalWarnings++;
4418
+ }
4419
+ } catch {
4420
+ console.log(chalk.yellow(' \u26a0 WARN Could not read pre-commit hook'));
4421
+ totalWarnings++;
4422
+ }
4423
+ } else {
4424
+ console.log(chalk.yellow(' \u26a0 WARN No pre-commit hook \u2014 governance only runs manually'));
4425
+ console.log(chalk.gray(' Run: delimit hooks install'));
4426
+ totalWarnings++;
4427
+ }
4428
+
4429
+ // GitHub Action check
4430
+ const workflowDir = path.join(projectDir, '.github', 'workflows');
4431
+ if (fs.existsSync(workflowDir)) {
4432
+ try {
4433
+ const workflows = fs.readdirSync(workflowDir);
4434
+ const hasDelimit = workflows.some(f => {
4435
+ try {
4436
+ const content = fs.readFileSync(path.join(workflowDir, f), 'utf8');
4437
+ return content.includes('delimit-ai/delimit') || content.includes('delimit');
4438
+ } catch { return false; }
4439
+ });
4440
+ if (hasDelimit) {
4441
+ console.log(chalk.green(' \u2713 PASS GitHub Action governance workflow found'));
4442
+ totalPassed++;
4443
+ } else {
4444
+ console.log(chalk.yellow(' \u26a0 WARN No Delimit GitHub Action \u2014 CI governance not enabled'));
4445
+ console.log(chalk.gray(' Run: delimit ci'));
4446
+ totalWarnings++;
4447
+ }
4448
+ } catch {}
4449
+ } else {
4450
+ console.log(chalk.yellow(' \u26a0 WARN No .github/workflows/ directory'));
4451
+ totalWarnings++;
4452
+ }
4453
+ }
4454
+
4455
+ // --- Verdict ---
4456
+ console.log('');
4457
+ if (totalBlocking > 0) {
4458
+ const parts = [];
4459
+ if (totalBlocking > 0) parts.push(`${totalBlocking} blocking`);
4460
+ if (totalWarnings > 0) parts.push(`${totalWarnings} warning(s)`);
4461
+ if (totalPassed > 0) parts.push(`${totalPassed} passed`);
4462
+ console.log(chalk.gray(` Verdict: ${parts.join(', ')}`));
4463
+ console.log(chalk.red.bold(' A real commit would be BLOCKED.\n'));
4464
+ } else if (totalWarnings > 0) {
4465
+ const parts = [];
4466
+ if (totalWarnings > 0) parts.push(`${totalWarnings} warning(s)`);
4467
+ if (totalPassed > 0) parts.push(`${totalPassed} passed`);
4468
+ console.log(chalk.gray(` Verdict: ${parts.join(', ')}`));
4469
+ console.log(chalk.yellow.bold(' A real commit would PASS with warnings.\n'));
4470
+ } else {
4471
+ console.log(chalk.gray(` Verdict: ${totalPassed} passed, 0 warnings, 0 blocking`));
4472
+ console.log(chalk.green.bold(' A real commit would PASS cleanly.\n'));
4473
+ }
3231
4474
  });
3232
4475
 
3233
4476
  // Hooks command — install/remove git hooks for governance
@@ -4551,7 +5794,7 @@ program
4551
5794
  .argument("[action]", "Action: status | set | list | reveal", "status")
4552
5795
  .option("--verbose", "Show encryption details and backend status")
4553
5796
  .action(async (action, options) => {
4554
- console.log(chalk.purple.bold("\n🔒 Delimit Vault\n"));
5797
+ console.log(chalk.magenta.bold("\n Delimit Vault\n"));
4555
5798
 
4556
5799
  if (action === "status") {
4557
5800
  console.log(chalk.bold("Backend Status:"));
@@ -4566,13 +5809,70 @@ program
4566
5809
  console.log("\nUse " + chalk.cyan("delimit vault list") + " to see configured secrets.");
4567
5810
  } else if (action === "list") {
4568
5811
  console.log(chalk.bold("Configured Secrets:"));
4569
- // Mock list for now
4570
- const secrets = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "REDDIT_PROXY_URL"];
4571
- secrets.forEach(s => console.log(` • ${s} ${chalk.gray("********")}`));
4572
- console.log("\nRun " + chalk.cyan("delimit vault set <NAME>") + " to update.");
5812
+ const secretsDir = path.join(os.homedir(), '.delimit', 'secrets');
5813
+ if (fs.existsSync(secretsDir)) {
5814
+ const files = fs.readdirSync(secretsDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
5815
+ if (files.length === 0) {
5816
+ console.log(chalk.dim(" No secrets configured yet."));
5817
+ } else {
5818
+ files.forEach(f => {
5819
+ const name = f.replace('.json', '');
5820
+ console.log(` • ${name} ${chalk.gray("********")}`);
5821
+ });
5822
+ }
5823
+ } else {
5824
+ console.log(chalk.dim(" No secrets directory found."));
5825
+ }
5826
+ console.log("\nRun " + chalk.cyan("delimit vault set <NAME>") + " to add a secret.");
5827
+ } else if (action === "set") {
5828
+ const name = process.argv[4];
5829
+ if (!name) {
5830
+ console.log(chalk.red("Usage: delimit vault set <NAME>"));
5831
+ console.log(chalk.dim(" Example: delimit vault set OPENAI_API_KEY"));
5832
+ process.exit(1);
5833
+ }
5834
+ const secretsDir = path.join(os.homedir(), '.delimit', 'secrets');
5835
+ fs.mkdirSync(secretsDir, { recursive: true });
5836
+ const filePath = path.join(secretsDir, `${name}.json`);
5837
+ const existing = fs.existsSync(filePath);
5838
+ // Read value from stdin or prompt
5839
+ const readline = require('readline');
5840
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
5841
+ rl.question(` Enter value for ${chalk.bold(name)}: `, (value) => {
5842
+ rl.close();
5843
+ if (!value || !value.trim()) {
5844
+ console.log(chalk.red(" Empty value. Aborted."));
5845
+ return;
5846
+ }
5847
+ fs.writeFileSync(filePath, JSON.stringify({ key: name, value: value.trim(), updated: new Date().toISOString() }), 'utf-8');
5848
+ fs.chmodSync(filePath, 0o600);
5849
+ console.log(chalk.green(` ${existing ? 'Updated' : 'Saved'}: ${name}`));
5850
+ console.log(chalk.dim(` Location: ${filePath}`));
5851
+ });
5852
+ } else if (action === "reveal") {
5853
+ const name = process.argv[4];
5854
+ if (!name) {
5855
+ console.log(chalk.red("Usage: delimit vault reveal <NAME>"));
5856
+ process.exit(1);
5857
+ }
5858
+ const secretsDir = path.join(os.homedir(), '.delimit', 'secrets');
5859
+ const filePath = path.join(secretsDir, `${name}.json`);
5860
+ if (!fs.existsSync(filePath)) {
5861
+ console.log(chalk.red(` Secret "${name}" not found.`));
5862
+ console.log(chalk.dim(" Run " + chalk.cyan("delimit vault list") + " to see configured secrets."));
5863
+ process.exit(1);
5864
+ }
5865
+ try {
5866
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
5867
+ const val = data.value || data.key || '(empty)';
5868
+ console.log(` ${chalk.bold(name)}: ${val}`);
5869
+ if (data.updated) console.log(chalk.dim(` Updated: ${data.updated}`));
5870
+ } catch {
5871
+ console.log(chalk.red(` Failed to read secret "${name}".`));
5872
+ }
4573
5873
  } else {
4574
- console.log(chalk.yellow(`Action "${action}" is coming soon.`));
4575
- console.log("To configure secrets today, use " + chalk.cyan("delimit setup") + " or edit " + chalk.dim("~/.delimit/secrets/"));
5874
+ console.log(chalk.yellow(`Unknown action: "${action}"`));
5875
+ console.log("Available: " + chalk.cyan("status") + " | " + chalk.cyan("list") + " | " + chalk.cyan("set <NAME>") + " | " + chalk.cyan("reveal <NAME>"));
4576
5876
  }
4577
5877
  console.log("");
4578
5878
  });
@@ -4709,13 +6009,18 @@ function readMemories() {
4709
6009
  function writeMemory(entry) {
4710
6010
  // Write in MCP-compatible format (individual .json files)
4711
6011
  fs.mkdirSync(MEMORY_DIR, { recursive: true });
4712
- const memId = 'mem-' + require('crypto').createHash('sha256').update(entry.text.slice(0, 100)).digest('hex').slice(0, 12);
6012
+ const crypto = require('crypto');
6013
+ const content = entry.text;
6014
+ const memId = 'mem-' + crypto.createHash('sha256').update(content.slice(0, 100)).digest('hex').slice(0, 12);
6015
+ const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
4713
6016
  const mcpEntry = {
4714
6017
  id: memId,
4715
- content: entry.text,
6018
+ content,
4716
6019
  tags: entry.tags || [],
4717
6020
  context: entry.source || 'cli',
4718
6021
  created_at: entry.created || new Date().toISOString(),
6022
+ hash,
6023
+ source_model: process.env.DELIMIT_MODEL || 'cli',
4719
6024
  };
4720
6025
  fs.writeFileSync(path.join(MEMORY_DIR, `${memId}.json`), JSON.stringify(mcpEntry, null, 2));
4721
6026
  return memId;
@@ -4761,8 +6066,18 @@ function relativeTime(isoDate) {
4761
6066
  return `${diffYear} year${diffYear === 1 ? '' : 's'} ago`;
4762
6067
  }
4763
6068
 
6069
+ function verifyMemoryIntegrity(mem) {
6070
+ if (!mem.hash) return null; // No hash — legacy memory
6071
+ const crypto = require('crypto');
6072
+ const expected = crypto.createHash('sha256').update(mem.content || mem.text || '').digest('hex').slice(0, 16);
6073
+ return expected === mem.hash;
6074
+ }
6075
+
4764
6076
  function displayMemory(mem) {
4765
- console.log(` ${chalk.gray('[' + mem.id + ']')} ${chalk.gray(relativeTime(mem.created))}`);
6077
+ const integrity = verifyMemoryIntegrity(mem);
6078
+ const integrityBadge = integrity === true ? chalk.green(' \u2713') : integrity === false ? chalk.red(' \u2717 tampered') : '';
6079
+ const sourceBadge = mem.source_model ? chalk.gray(` [${mem.source_model}]`) : mem.context ? chalk.gray(` [${mem.context}]`) : '';
6080
+ console.log(` ${chalk.gray('[' + mem.id + ']')} ${chalk.gray(relativeTime(mem.created))}${sourceBadge}${integrityBadge}`);
4766
6081
  console.log(` ${mem.text}`);
4767
6082
  if (mem.tags && mem.tags.length > 0) {
4768
6083
  console.log(` ${chalk.blue(mem.tags.map(t => '#' + t).join(' '))}`);