scene-capability-engine 3.3.26 → 3.4.5

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.
@@ -11,6 +11,8 @@ const ERRORBOOK_ENTRY_API_VERSION = 'sce.errorbook.entry/v0.1';
11
11
  const ERRORBOOK_REGISTRY_API_VERSION = 'sce.errorbook.registry/v0.1';
12
12
  const ERRORBOOK_REGISTRY_CACHE_API_VERSION = 'sce.errorbook.registry-cache/v0.1';
13
13
  const ERRORBOOK_REGISTRY_INDEX_API_VERSION = 'sce.errorbook.registry-index/v0.1';
14
+ const ERRORBOOK_INCIDENT_INDEX_API_VERSION = 'sce.errorbook.incident-index/v0.1';
15
+ const ERRORBOOK_INCIDENT_API_VERSION = 'sce.errorbook.incident/v0.1';
14
16
  const ERRORBOOK_STATUSES = Object.freeze(['candidate', 'verified', 'promoted', 'deprecated']);
15
17
  const TEMPORARY_MITIGATION_TAG = 'temporary-mitigation';
16
18
  const DEFAULT_ERRORBOOK_REGISTRY_CONFIG = '.sce/config/errorbook-registry.json';
@@ -65,11 +67,16 @@ const HIGH_RISK_SIGNAL_TAGS = Object.freeze([
65
67
 
66
68
  function resolveErrorbookPaths(projectPath = process.cwd()) {
67
69
  const baseDir = path.join(projectPath, '.sce', 'errorbook');
70
+ const stagingDir = path.join(baseDir, 'staging');
68
71
  return {
69
72
  projectPath,
70
73
  baseDir,
71
74
  entriesDir: path.join(baseDir, 'entries'),
72
- indexFile: path.join(baseDir, 'index.json')
75
+ indexFile: path.join(baseDir, 'index.json'),
76
+ stagingDir,
77
+ incidentsDir: path.join(stagingDir, 'incidents'),
78
+ resolvedDir: path.join(stagingDir, 'resolved'),
79
+ incidentIndexFile: path.join(stagingDir, 'index.json')
73
80
  };
74
81
  }
75
82
 
@@ -417,6 +424,247 @@ async function writeErrorbookEntry(paths, entry, fileSystem = fs) {
417
424
  return entryPath;
418
425
  }
419
426
 
427
+ function buildDefaultIncidentIndex() {
428
+ return {
429
+ api_version: ERRORBOOK_INCIDENT_INDEX_API_VERSION,
430
+ updated_at: nowIso(),
431
+ total_incidents: 0,
432
+ incidents: []
433
+ };
434
+ }
435
+
436
+ function normalizeIncidentState(value, fallback = 'open') {
437
+ const normalized = normalizeText(value).toLowerCase();
438
+ if (!normalized) {
439
+ return fallback;
440
+ }
441
+ if (normalized === 'open' || normalized === 'resolved') {
442
+ return normalized;
443
+ }
444
+ return fallback;
445
+ }
446
+
447
+ function shouldResolveIncidentByStatus(status = '') {
448
+ const normalized = normalizeStatus(status, 'candidate');
449
+ return normalized === 'verified' || normalized === 'promoted' || normalized === 'deprecated';
450
+ }
451
+
452
+ function createIncidentId() {
453
+ return `ebi-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
454
+ }
455
+
456
+ function createIncidentAttemptId() {
457
+ return `attempt-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
458
+ }
459
+
460
+ function buildIncidentFilePath(paths, incidentId) {
461
+ return path.join(paths.incidentsDir, `${incidentId}.json`);
462
+ }
463
+
464
+ function buildIncidentResolvedSnapshotPath(paths, incidentId) {
465
+ return path.join(paths.resolvedDir, `${incidentId}.json`);
466
+ }
467
+
468
+ async function ensureIncidentStorage(paths, fileSystem = fs) {
469
+ await fileSystem.ensureDir(paths.incidentsDir);
470
+ await fileSystem.ensureDir(paths.resolvedDir);
471
+ if (!await fileSystem.pathExists(paths.incidentIndexFile)) {
472
+ await fileSystem.writeJson(paths.incidentIndexFile, buildDefaultIncidentIndex(), { spaces: 2 });
473
+ }
474
+ }
475
+
476
+ async function readIncidentIndex(paths, fileSystem = fs) {
477
+ await ensureIncidentStorage(paths, fileSystem);
478
+ const payload = await fileSystem.readJson(paths.incidentIndexFile).catch(() => null);
479
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.incidents)) {
480
+ return buildDefaultIncidentIndex();
481
+ }
482
+ return {
483
+ api_version: payload.api_version || ERRORBOOK_INCIDENT_INDEX_API_VERSION,
484
+ updated_at: normalizeText(payload.updated_at) || nowIso(),
485
+ total_incidents: Number.isInteger(payload.total_incidents) ? payload.total_incidents : payload.incidents.length,
486
+ incidents: payload.incidents
487
+ };
488
+ }
489
+
490
+ async function writeIncidentIndex(paths, index, fileSystem = fs) {
491
+ const payload = {
492
+ api_version: ERRORBOOK_INCIDENT_INDEX_API_VERSION,
493
+ updated_at: nowIso(),
494
+ incidents: Array.isArray(index.incidents) ? index.incidents : []
495
+ };
496
+ payload.total_incidents = payload.incidents.length;
497
+ await fileSystem.ensureDir(path.dirname(paths.incidentIndexFile));
498
+ await fileSystem.writeJson(paths.incidentIndexFile, payload, { spaces: 2 });
499
+ return payload;
500
+ }
501
+
502
+ async function readIncident(paths, incidentId, fileSystem = fs) {
503
+ const filePath = buildIncidentFilePath(paths, incidentId);
504
+ if (!await fileSystem.pathExists(filePath)) {
505
+ return null;
506
+ }
507
+ return fileSystem.readJson(filePath);
508
+ }
509
+
510
+ async function writeIncident(paths, incident, fileSystem = fs) {
511
+ const filePath = buildIncidentFilePath(paths, incident.id);
512
+ await fileSystem.ensureDir(path.dirname(filePath));
513
+ await fileSystem.writeJson(filePath, incident, { spaces: 2 });
514
+ return filePath;
515
+ }
516
+
517
+ function createIncidentAttemptSignature(payload = {}) {
518
+ const basis = JSON.stringify({
519
+ root_cause: normalizeText(payload.root_cause),
520
+ fix_actions: normalizeStringList(payload.fix_actions),
521
+ verification_evidence: normalizeStringList(payload.verification_evidence),
522
+ notes: normalizeText(payload.notes),
523
+ source: {
524
+ spec: normalizeText(payload?.source?.spec),
525
+ files: normalizeStringList(payload?.source?.files),
526
+ tests: normalizeStringList(payload?.source?.tests)
527
+ }
528
+ });
529
+ return crypto.createHash('sha1').update(basis).digest('hex').slice(0, 16);
530
+ }
531
+
532
+ function createIncidentSummaryFromIncident(incident = {}) {
533
+ return {
534
+ id: incident.id,
535
+ fingerprint: normalizeText(incident.fingerprint),
536
+ title: normalizeText(incident.title),
537
+ symptom: normalizeText(incident.symptom),
538
+ state: normalizeIncidentState(incident.state, 'open'),
539
+ attempt_count: Number(incident.attempt_count || 0),
540
+ created_at: normalizeText(incident.created_at),
541
+ updated_at: normalizeText(incident.updated_at),
542
+ last_attempt_at: normalizeText(incident.last_attempt_at),
543
+ resolved_at: normalizeText(incident.resolved_at),
544
+ linked_entry_id: normalizeText(incident?.resolution?.entry_id || '')
545
+ };
546
+ }
547
+
548
+ async function syncIncidentLoopForRecord(paths, payload = {}, entry = {}, options = {}, fileSystem = fs) {
549
+ await ensureIncidentStorage(paths, fileSystem);
550
+ const incidentIndex = await readIncidentIndex(paths, fileSystem);
551
+ const fingerprint = normalizeText(payload.fingerprint || entry.fingerprint);
552
+ const title = normalizeText(payload.title || entry.title);
553
+ const symptom = normalizeText(payload.symptom || entry.symptom);
554
+ const currentTime = normalizeText(options.nowIso) || nowIso();
555
+
556
+ let incidentSummary = incidentIndex.incidents.find((item) => item.fingerprint === fingerprint) || null;
557
+ let incident = incidentSummary ? await readIncident(paths, incidentSummary.id, fileSystem) : null;
558
+
559
+ if (!incident) {
560
+ const incidentId = incidentSummary ? incidentSummary.id : createIncidentId();
561
+ incident = {
562
+ api_version: ERRORBOOK_INCIDENT_API_VERSION,
563
+ id: incidentId,
564
+ fingerprint,
565
+ title,
566
+ symptom,
567
+ state: 'open',
568
+ created_at: currentTime,
569
+ updated_at: currentTime,
570
+ last_attempt_at: '',
571
+ resolved_at: '',
572
+ attempt_count: 0,
573
+ attempts: [],
574
+ resolution: {
575
+ entry_id: '',
576
+ status: '',
577
+ quality_score: 0,
578
+ resolved_at: ''
579
+ },
580
+ tags: [],
581
+ ontology_tags: []
582
+ };
583
+ }
584
+
585
+ const existingAttempts = Array.isArray(incident.attempts) ? incident.attempts : [];
586
+ const attemptNo = existingAttempts.length + 1;
587
+ const attemptSignature = createIncidentAttemptSignature(payload);
588
+ const duplicateOf = existingAttempts.find((item) => normalizeText(item.signature) === attemptSignature);
589
+ const attempt = {
590
+ id: createIncidentAttemptId(),
591
+ attempt_no: attemptNo,
592
+ recorded_at: currentTime,
593
+ signature: attemptSignature,
594
+ duplicate_of_attempt_no: duplicateOf ? Number(duplicateOf.attempt_no || 0) : 0,
595
+ entry_status: normalizeStatus(entry.status, 'candidate'),
596
+ quality_score: Number(entry.quality_score || scoreQuality(entry)),
597
+ root_cause: normalizeText(payload.root_cause || entry.root_cause),
598
+ fix_actions: normalizeStringList(payload.fix_actions || entry.fix_actions),
599
+ verification_evidence: normalizeStringList(payload.verification_evidence || entry.verification_evidence),
600
+ tags: normalizeStringList(payload.tags || entry.tags),
601
+ ontology_tags: normalizeOntologyTags(payload.ontology_tags || entry.ontology_tags),
602
+ notes: normalizeText(payload.notes || entry.notes),
603
+ source: {
604
+ spec: normalizeText(payload?.source?.spec || entry?.source?.spec),
605
+ files: normalizeStringList(payload?.source?.files || entry?.source?.files),
606
+ tests: normalizeStringList(payload?.source?.tests || entry?.source?.tests)
607
+ }
608
+ };
609
+
610
+ incident.attempts = [...existingAttempts, attempt];
611
+ incident.attempt_count = incident.attempts.length;
612
+ incident.title = title || incident.title;
613
+ incident.symptom = symptom || incident.symptom;
614
+ incident.tags = normalizeStringList(incident.tags, attempt.tags);
615
+ incident.ontology_tags = normalizeOntologyTags(incident.ontology_tags, attempt.ontology_tags);
616
+ incident.last_attempt_at = currentTime;
617
+ incident.updated_at = currentTime;
618
+
619
+ const resolveIncident = shouldResolveIncidentByStatus(entry.status);
620
+ if (resolveIncident) {
621
+ incident.state = 'resolved';
622
+ incident.resolved_at = currentTime;
623
+ incident.resolution = {
624
+ entry_id: normalizeText(entry.id),
625
+ status: normalizeStatus(entry.status, 'candidate'),
626
+ quality_score: Number(entry.quality_score || scoreQuality(entry)),
627
+ resolved_at: currentTime
628
+ };
629
+ } else {
630
+ incident.state = 'open';
631
+ incident.resolved_at = '';
632
+ incident.resolution = {
633
+ entry_id: '',
634
+ status: '',
635
+ quality_score: 0,
636
+ resolved_at: ''
637
+ };
638
+ }
639
+
640
+ await writeIncident(paths, incident, fileSystem);
641
+ if (incident.state === 'resolved') {
642
+ const resolvedSnapshotPath = buildIncidentResolvedSnapshotPath(paths, incident.id);
643
+ await fileSystem.ensureDir(path.dirname(resolvedSnapshotPath));
644
+ await fileSystem.writeJson(resolvedSnapshotPath, incident, { spaces: 2 });
645
+ }
646
+
647
+ const summary = createIncidentSummaryFromIncident(incident);
648
+ const summaryIndex = incidentIndex.incidents.findIndex((item) => item.id === summary.id);
649
+ if (summaryIndex >= 0) {
650
+ incidentIndex.incidents[summaryIndex] = summary;
651
+ } else {
652
+ incidentIndex.incidents.push(summary);
653
+ }
654
+ incidentIndex.incidents.sort((left, right) => `${right.updated_at || ''}`.localeCompare(`${left.updated_at || ''}`));
655
+ await writeIncidentIndex(paths, incidentIndex, fileSystem);
656
+
657
+ return {
658
+ incident: summary,
659
+ latest_attempt: {
660
+ id: attempt.id,
661
+ attempt_no: attempt.attempt_no,
662
+ duplicate_of_attempt_no: attempt.duplicate_of_attempt_no,
663
+ signature: attempt.signature
664
+ }
665
+ };
666
+ }
667
+
420
668
  function scoreQuality(entry = {}) {
421
669
  let score = 0;
422
670
 
@@ -1380,12 +1628,16 @@ async function runErrorbookRecordCommand(options = {}, dependencies = {}) {
1380
1628
  }
1381
1629
  index.entries.sort((left, right) => `${right.updated_at}`.localeCompare(`${left.updated_at}`));
1382
1630
  await writeErrorbookIndex(paths, index, fileSystem);
1631
+ const incidentLoop = await syncIncidentLoopForRecord(paths, normalized, entry, {
1632
+ nowIso: entry.updated_at
1633
+ }, fileSystem);
1383
1634
 
1384
1635
  const result = {
1385
1636
  mode: 'errorbook-record',
1386
1637
  created,
1387
1638
  deduplicated,
1388
- entry
1639
+ entry,
1640
+ incident_loop: incidentLoop
1389
1641
  };
1390
1642
 
1391
1643
  if (options.json) {
@@ -1735,6 +1987,127 @@ async function runErrorbookRegistryHealthCommand(options = {}, dependencies = {}
1735
1987
  return result;
1736
1988
  }
1737
1989
 
1990
+ function findIncidentSummaryById(index, id) {
1991
+ const normalized = normalizeText(id);
1992
+ if (!normalized) {
1993
+ return null;
1994
+ }
1995
+ const exact = index.incidents.find((item) => item.id === normalized);
1996
+ if (exact) {
1997
+ return exact;
1998
+ }
1999
+ const startsWith = index.incidents.filter((item) => item.id.startsWith(normalized));
2000
+ if (startsWith.length === 1) {
2001
+ return startsWith[0];
2002
+ }
2003
+ if (startsWith.length > 1) {
2004
+ throw new Error(`incident id prefix "${normalized}" is ambiguous (${startsWith.length} matches)`);
2005
+ }
2006
+ return null;
2007
+ }
2008
+
2009
+ async function runErrorbookIncidentListCommand(options = {}, dependencies = {}) {
2010
+ const projectPath = dependencies.projectPath || process.cwd();
2011
+ const fileSystem = dependencies.fileSystem || fs;
2012
+ const paths = resolveErrorbookPaths(projectPath);
2013
+ const index = await readIncidentIndex(paths, fileSystem);
2014
+
2015
+ const stateFilter = normalizeText(options.state).toLowerCase();
2016
+ if (stateFilter && !['open', 'resolved'].includes(stateFilter)) {
2017
+ throw new Error('incident state must be one of: open, resolved');
2018
+ }
2019
+ const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
2020
+ ? Number(options.limit)
2021
+ : 20;
2022
+
2023
+ let incidents = [...index.incidents];
2024
+ if (stateFilter) {
2025
+ incidents = incidents.filter((item) => normalizeIncidentState(item.state) === stateFilter);
2026
+ }
2027
+ incidents.sort((left, right) => `${right.updated_at || ''}`.localeCompare(`${left.updated_at || ''}`));
2028
+
2029
+ const result = {
2030
+ mode: 'errorbook-incident-list',
2031
+ total_incidents: index.incidents.length,
2032
+ total_results: incidents.length,
2033
+ incidents: incidents.slice(0, limit)
2034
+ };
2035
+
2036
+ if (options.json) {
2037
+ console.log(JSON.stringify(result, null, 2));
2038
+ } else if (!options.silent) {
2039
+ if (result.incidents.length === 0) {
2040
+ console.log(chalk.gray('No staging incidents found'));
2041
+ } else {
2042
+ const table = new Table({
2043
+ head: ['ID', 'State', 'Attempts', 'Title', 'Updated'].map((item) => chalk.cyan(item)),
2044
+ colWidths: [20, 12, 10, 56, 24]
2045
+ });
2046
+ result.incidents.forEach((incident) => {
2047
+ table.push([
2048
+ incident.id,
2049
+ incident.state,
2050
+ Number(incident.attempt_count || 0),
2051
+ normalizeText(incident.title).length > 52
2052
+ ? `${normalizeText(incident.title).slice(0, 52)}...`
2053
+ : normalizeText(incident.title),
2054
+ incident.updated_at || ''
2055
+ ]);
2056
+ });
2057
+ console.log(table.toString());
2058
+ console.log(chalk.gray(`Total: ${result.total_results} (stored: ${result.total_incidents})`));
2059
+ }
2060
+ }
2061
+
2062
+ return result;
2063
+ }
2064
+
2065
+ async function runErrorbookIncidentShowCommand(options = {}, dependencies = {}) {
2066
+ const projectPath = dependencies.projectPath || process.cwd();
2067
+ const fileSystem = dependencies.fileSystem || fs;
2068
+ const paths = resolveErrorbookPaths(projectPath);
2069
+ const index = await readIncidentIndex(paths, fileSystem);
2070
+
2071
+ const id = normalizeText(options.id || options.incidentId);
2072
+ if (!id) {
2073
+ throw new Error('incident id is required');
2074
+ }
2075
+
2076
+ const summary = findIncidentSummaryById(index, id);
2077
+ if (!summary) {
2078
+ throw new Error(`staging incident not found: ${id}`);
2079
+ }
2080
+
2081
+ const incident = await readIncident(paths, summary.id, fileSystem);
2082
+ if (!incident) {
2083
+ throw new Error(`staging incident file not found: ${summary.id}`);
2084
+ }
2085
+
2086
+ const result = {
2087
+ mode: 'errorbook-incident-show',
2088
+ incident
2089
+ };
2090
+
2091
+ if (options.json) {
2092
+ console.log(JSON.stringify(result, null, 2));
2093
+ } else if (!options.silent) {
2094
+ console.log(chalk.cyan.bold(incident.title || summary.title || summary.id));
2095
+ console.log(chalk.gray(`id: ${incident.id}`));
2096
+ console.log(chalk.gray(`state: ${incident.state}`));
2097
+ console.log(chalk.gray(`attempts: ${Number(incident.attempt_count || 0)}`));
2098
+ console.log(chalk.gray(`fingerprint: ${incident.fingerprint}`));
2099
+ console.log(chalk.gray(`updated_at: ${incident.updated_at}`));
2100
+ if (incident.state === 'resolved') {
2101
+ console.log(chalk.gray(`resolved_at: ${incident.resolved_at || '(none)'}`));
2102
+ if (incident.resolution && incident.resolution.entry_id) {
2103
+ console.log(chalk.gray(`linked_entry: ${incident.resolution.entry_id}`));
2104
+ }
2105
+ }
2106
+ }
2107
+
2108
+ return result;
2109
+ }
2110
+
1738
2111
  async function runErrorbookListCommand(options = {}, dependencies = {}) {
1739
2112
  const projectPath = dependencies.projectPath || process.cwd();
1740
2113
  const fileSystem = dependencies.fileSystem || fs;
@@ -2299,6 +2672,36 @@ function registerErrorbookCommands(program) {
2299
2672
  }
2300
2673
  });
2301
2674
 
2675
+ const incident = errorbook
2676
+ .command('incident')
2677
+ .description('Inspect temporary trial-and-error incident loop before final curation');
2678
+
2679
+ incident
2680
+ .command('list')
2681
+ .description('List staging incidents')
2682
+ .option('--state <state>', 'Filter incident state (open|resolved)')
2683
+ .option('--limit <n>', 'Maximum incidents returned', parseInt, 20)
2684
+ .option('--json', 'Emit machine-readable JSON')
2685
+ .action(async (options) => {
2686
+ try {
2687
+ await runErrorbookIncidentListCommand(options);
2688
+ } catch (error) {
2689
+ emitCommandError(error, options.json);
2690
+ }
2691
+ });
2692
+
2693
+ incident
2694
+ .command('show <id>')
2695
+ .description('Show a staging incident with all attempts')
2696
+ .option('--json', 'Emit machine-readable JSON')
2697
+ .action(async (id, options) => {
2698
+ try {
2699
+ await runErrorbookIncidentShowCommand({ ...options, id });
2700
+ } catch (error) {
2701
+ emitCommandError(error, options.json);
2702
+ }
2703
+ });
2704
+
2302
2705
  errorbook
2303
2706
  .command('list')
2304
2707
  .description('List curated errorbook entries')
@@ -2470,6 +2873,8 @@ module.exports = {
2470
2873
  HIGH_RISK_SIGNAL_TAGS,
2471
2874
  DEBUG_EVIDENCE_TAGS,
2472
2875
  DEFAULT_PROMOTE_MIN_QUALITY,
2876
+ ERRORBOOK_INCIDENT_INDEX_API_VERSION,
2877
+ ERRORBOOK_INCIDENT_API_VERSION,
2473
2878
  resolveErrorbookPaths,
2474
2879
  resolveErrorbookRegistryPaths,
2475
2880
  normalizeOntologyTags,
@@ -2481,6 +2886,8 @@ module.exports = {
2481
2886
  runErrorbookExportCommand,
2482
2887
  runErrorbookSyncRegistryCommand,
2483
2888
  runErrorbookRegistryHealthCommand,
2889
+ runErrorbookIncidentListCommand,
2890
+ runErrorbookIncidentShowCommand,
2484
2891
  runErrorbookListCommand,
2485
2892
  runErrorbookShowCommand,
2486
2893
  runErrorbookFindCommand,
@@ -3,7 +3,8 @@ const path = require('path');
3
3
  const chalk = require('chalk');
4
4
  const {
5
5
  ensureSpecDomainArtifacts,
6
- validateSpecDomainArtifacts
6
+ validateSpecDomainArtifacts,
7
+ analyzeSpecDomainCoverage
7
8
  } = require('../spec/domain-modeling');
8
9
 
9
10
  function normalizeText(value) {
@@ -109,18 +110,29 @@ async function runSpecDomainValidateCommand(options = {}, dependencies = {}) {
109
110
  await assertSpecExists(projectPath, specId, fileSystem);
110
111
 
111
112
  const validation = await validateSpecDomainArtifacts(projectPath, specId, fileSystem);
113
+ const coverage = await analyzeSpecDomainCoverage(projectPath, specId, fileSystem);
112
114
  const payload = {
113
115
  mode: 'spec-domain-validate',
114
116
  spec_id: specId,
115
117
  passed: validation.passed,
116
118
  ratio: validation.ratio,
117
119
  details: validation.details,
118
- warnings: validation.warnings
120
+ warnings: validation.warnings,
121
+ coverage: {
122
+ passed: coverage.passed,
123
+ coverage_ratio: coverage.coverage_ratio,
124
+ covered_count: coverage.covered_count,
125
+ total_count: coverage.total_count,
126
+ uncovered: coverage.uncovered
127
+ }
119
128
  };
120
129
 
121
130
  if (options.failOnError && !validation.passed) {
122
131
  throw new Error(`spec domain validation failed: ${validation.warnings.join('; ')}`);
123
132
  }
133
+ if (options.failOnGap && !coverage.passed) {
134
+ throw new Error(`spec domain coverage has gaps: ${coverage.uncovered.join(', ')}`);
135
+ }
124
136
 
125
137
  if (options.json) {
126
138
  console.log(JSON.stringify(payload, null, 2));
@@ -138,6 +150,49 @@ async function runSpecDomainValidateCommand(options = {}, dependencies = {}) {
138
150
  return payload;
139
151
  }
140
152
 
153
+ async function runSpecDomainCoverageCommand(options = {}, dependencies = {}) {
154
+ const projectPath = dependencies.projectPath || process.cwd();
155
+ const fileSystem = dependencies.fileSystem || fs;
156
+ const specId = resolveSpecId(options);
157
+ if (!specId) {
158
+ throw new Error('--spec is required');
159
+ }
160
+ await assertSpecExists(projectPath, specId, fileSystem);
161
+
162
+ const coverage = await analyzeSpecDomainCoverage(projectPath, specId, fileSystem);
163
+ const payload = {
164
+ mode: 'spec-domain-coverage',
165
+ spec_id: specId,
166
+ passed: coverage.passed,
167
+ coverage_ratio: coverage.coverage_ratio,
168
+ covered_count: coverage.covered_count,
169
+ total_count: coverage.total_count,
170
+ uncovered: coverage.uncovered,
171
+ items: coverage.items
172
+ };
173
+
174
+ if (options.failOnGap && !coverage.passed) {
175
+ throw new Error(`spec domain coverage has gaps: ${coverage.uncovered.join(', ')}`);
176
+ }
177
+
178
+ if (options.json) {
179
+ console.log(JSON.stringify(payload, null, 2));
180
+ } else if (!options.silent) {
181
+ if (coverage.passed) {
182
+ console.log(chalk.green('✓ Spec domain closed-loop coverage passed'));
183
+ } else {
184
+ console.log(chalk.red('✗ Spec domain closed-loop coverage has gaps'));
185
+ coverage.items
186
+ .filter((item) => !item.covered)
187
+ .forEach((item) => {
188
+ console.log(chalk.gray(` - ${item.id}: ${item.label}`));
189
+ });
190
+ }
191
+ }
192
+
193
+ return payload;
194
+ }
195
+
141
196
  function registerSpecDomainCommand(program) {
142
197
  const specDomain = program
143
198
  .command('spec-domain')
@@ -194,6 +249,7 @@ function registerSpecDomainCommand(program) {
194
249
  .description('Validate problem-domain artifacts for a Spec')
195
250
  .requiredOption('--spec <name>', 'Spec identifier')
196
251
  .option('--fail-on-error', 'Exit non-zero when validation fails')
252
+ .option('--fail-on-gap', 'Exit non-zero when closed-loop coverage is incomplete')
197
253
  .option('--json', 'Output machine-readable JSON')
198
254
  .action(async (options) => {
199
255
  try {
@@ -207,11 +263,31 @@ function registerSpecDomainCommand(program) {
207
263
  process.exit(1);
208
264
  }
209
265
  });
266
+
267
+ specDomain
268
+ .command('coverage')
269
+ .description('Analyze scene-closed-loop research coverage for a Spec')
270
+ .requiredOption('--spec <name>', 'Spec identifier')
271
+ .option('--fail-on-gap', 'Exit non-zero when closed-loop coverage is incomplete')
272
+ .option('--json', 'Output machine-readable JSON')
273
+ .action(async (options) => {
274
+ try {
275
+ await runSpecDomainCoverageCommand(options);
276
+ } catch (error) {
277
+ if (options.json) {
278
+ console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
279
+ } else {
280
+ console.error(chalk.red('❌ spec-domain coverage failed:'), error.message);
281
+ }
282
+ process.exit(1);
283
+ }
284
+ });
210
285
  }
211
286
 
212
287
  module.exports = {
213
288
  runSpecDomainInitCommand,
214
289
  runSpecDomainRefreshCommand,
215
290
  runSpecDomainValidateCommand,
291
+ runSpecDomainCoverageCommand,
216
292
  registerSpecDomainCommand
217
293
  };