scene-capability-engine 3.3.23 → 3.3.25

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.
@@ -47,6 +47,11 @@ const ONTOLOGY_TAG_ALIASES = Object.freeze({
47
47
  });
48
48
  const DEFAULT_PROMOTE_MIN_QUALITY = 75;
49
49
  const ERRORBOOK_RISK_LEVELS = Object.freeze(['low', 'medium', 'high']);
50
+ const DEBUG_EVIDENCE_TAGS = Object.freeze([
51
+ 'debug-evidence',
52
+ 'diagnostic-evidence',
53
+ 'debug-log'
54
+ ]);
50
55
  const HIGH_RISK_SIGNAL_TAGS = Object.freeze([
51
56
  'release-blocker',
52
57
  'security',
@@ -481,6 +486,44 @@ function validateRecordPayload(payload) {
481
486
  }
482
487
  }
483
488
 
489
+ function hasDebugEvidenceSignals(entry = {}) {
490
+ const tags = normalizeStringList(entry.tags).map((item) => item.toLowerCase());
491
+ if (tags.some((tag) => DEBUG_EVIDENCE_TAGS.includes(tag))) {
492
+ return true;
493
+ }
494
+
495
+ const verificationEvidence = normalizeStringList(entry.verification_evidence);
496
+ if (verificationEvidence.some((item) => /^debug:/i.test(item))) {
497
+ return true;
498
+ }
499
+
500
+ const sourceFiles = normalizeStringList(entry?.source?.files);
501
+ if (sourceFiles.some((item) => /(^|[\\/._-])(debug|trace|diagnostic|observability|telemetry|stack)/i.test(item))) {
502
+ return true;
503
+ }
504
+
505
+ const notes = normalizeText(entry.notes).toLowerCase();
506
+ if (notes && /(debug|trace|diagnostic|observability|telemetry|stack|日志|埋点|观测)/i.test(notes)) {
507
+ return true;
508
+ }
509
+
510
+ return false;
511
+ }
512
+
513
+ function enforceDebugEvidenceAfterRepeatedFailures(entry = {}, options = {}) {
514
+ const attemptCount = Number(options.attemptCount || 0);
515
+ if (!Number.isFinite(attemptCount) || attemptCount < 3) {
516
+ return;
517
+ }
518
+ if (hasDebugEvidenceSignals(entry)) {
519
+ return;
520
+ }
521
+ throw new Error(
522
+ 'two failed fix rounds detected (attempt #3+): debug evidence is required. '
523
+ + 'Provide --verification "debug: ...", add tag debug-evidence, or include debug trace/log file references.'
524
+ );
525
+ }
526
+
484
527
  function normalizeRecordPayload(options = {}, fromFilePayload = {}) {
485
528
  const temporaryMitigation = normalizeTemporaryMitigation(options, fromFilePayload);
486
529
  const payload = {
@@ -1287,6 +1330,9 @@ async function runErrorbookRecordCommand(options = {}, dependencies = {}) {
1287
1330
  throw new Error(`errorbook index references missing entry: ${existingSummary.id}`);
1288
1331
  }
1289
1332
  entry = mergeEntry(existingEntry, normalized);
1333
+ enforceDebugEvidenceAfterRepeatedFailures(entry, {
1334
+ attemptCount: Number(entry.occurrences || 0)
1335
+ });
1290
1336
  deduplicated = true;
1291
1337
  } else {
1292
1338
  const temporaryMitigation = normalizeExistingTemporaryMitigation(normalized.temporary_mitigation);
@@ -1499,6 +1545,196 @@ async function runErrorbookSyncRegistryCommand(options = {}, dependencies = {})
1499
1545
  return result;
1500
1546
  }
1501
1547
 
1548
+ async function runErrorbookRegistryHealthCommand(options = {}, dependencies = {}) {
1549
+ const projectPath = dependencies.projectPath || process.cwd();
1550
+ const fileSystem = dependencies.fileSystem || fs;
1551
+ const registryPaths = resolveErrorbookRegistryPaths(projectPath, {
1552
+ configPath: options.config,
1553
+ cachePath: options.cache
1554
+ });
1555
+
1556
+ const warnings = [];
1557
+ const errors = [];
1558
+ const overrideSource = normalizeText(options.source);
1559
+ const overrideIndex = normalizeText(options.index || options.registryIndex);
1560
+ const overrideSourceName = normalizeText(options.sourceName || options.registrySourceName) || 'override';
1561
+
1562
+ const configExists = await fileSystem.pathExists(registryPaths.configFile);
1563
+ if (configExists) {
1564
+ try {
1565
+ const rawConfig = await fileSystem.readJson(registryPaths.configFile);
1566
+ if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
1567
+ errors.push(`registry config must be a JSON object: ${registryPaths.configFile}`);
1568
+ }
1569
+ } catch (error) {
1570
+ errors.push(`failed to parse registry config (${registryPaths.configFile}): ${error.message}`);
1571
+ }
1572
+ } else if (!overrideSource) {
1573
+ errors.push(`registry config file not found: ${registryPaths.configFile}`);
1574
+ }
1575
+
1576
+ const config = await readErrorbookRegistryConfig(registryPaths, fileSystem);
1577
+ let registryEnabled = normalizeBoolean(config.enabled, true);
1578
+ let sources = Array.isArray(config.sources) ? config.sources : [];
1579
+
1580
+ if (overrideSource) {
1581
+ registryEnabled = true;
1582
+ sources = [normalizeRegistrySource({
1583
+ name: overrideSourceName,
1584
+ source: overrideSource,
1585
+ index_url: overrideIndex
1586
+ })];
1587
+ } else if (overrideIndex && sources.length > 0) {
1588
+ sources = sources.map((source, sourceIndex) => (
1589
+ sourceIndex === 0 ? { ...source, index_url: overrideIndex } : source
1590
+ ));
1591
+ }
1592
+
1593
+ if (!registryEnabled) {
1594
+ warnings.push('registry config is disabled');
1595
+ }
1596
+ if (registryEnabled && sources.length === 0) {
1597
+ errors.push('registry enabled but no sources configured');
1598
+ }
1599
+
1600
+ const maxShards = Number.isFinite(Number(options.maxShards)) && Number(options.maxShards) > 0
1601
+ ? Number(options.maxShards)
1602
+ : 8;
1603
+ const shardSample = Number.isFinite(Number(options.shardSample)) && Number(options.shardSample) > 0
1604
+ ? Number(options.shardSample)
1605
+ : 2;
1606
+
1607
+ const sourceResults = [];
1608
+ for (const source of sources) {
1609
+ const sourceName = normalizeText(source.name) || 'registry';
1610
+ const sourceReport = {
1611
+ source_name: sourceName,
1612
+ source: normalizeText(source.source),
1613
+ index_url: normalizeText(source.index_url),
1614
+ source_ok: false,
1615
+ index_ok: null,
1616
+ shard_sources_checked: 0,
1617
+ source_entries: 0,
1618
+ shard_entries: 0,
1619
+ warnings: [],
1620
+ errors: []
1621
+ };
1622
+
1623
+ if (!sourceReport.source) {
1624
+ sourceReport.errors.push('source is empty');
1625
+ } else {
1626
+ try {
1627
+ const payload = await loadRegistryPayload(projectPath, sourceReport.source, fileSystem);
1628
+ const entries = extractRegistryEntries(payload, sourceName);
1629
+ sourceReport.source_ok = true;
1630
+ sourceReport.source_entries = entries.length;
1631
+ if (entries.length === 0) {
1632
+ sourceReport.warnings.push('source returned no valid entries');
1633
+ }
1634
+ } catch (error) {
1635
+ sourceReport.errors.push(`failed to load source (${sourceReport.source}): ${error.message}`);
1636
+ }
1637
+ }
1638
+
1639
+ if (!sourceReport.index_url) {
1640
+ sourceReport.warnings.push('index_url not configured; remote indexed lookup health is partially validated');
1641
+ } else {
1642
+ try {
1643
+ const indexPayload = await loadRegistryPayload(projectPath, sourceReport.index_url, fileSystem);
1644
+ const index = normalizeRegistryIndex(indexPayload, sourceName);
1645
+ if (!index) {
1646
+ sourceReport.errors.push(`invalid index payload: ${sourceReport.index_url}`);
1647
+ } else {
1648
+ sourceReport.index_ok = true;
1649
+ const tokenToBucket = index.token_to_bucket || {};
1650
+ const unresolved = [];
1651
+ for (const [token, bucketRaw] of Object.entries(tokenToBucket)) {
1652
+ const bucket = normalizeText(bucketRaw);
1653
+ if (!bucket) {
1654
+ continue;
1655
+ }
1656
+ const bucketSource = normalizeText(index.buckets[bucket] || index.buckets[token]);
1657
+ if (!bucketSource) {
1658
+ unresolved.push(`${token}->${bucket}`);
1659
+ }
1660
+ }
1661
+ if (unresolved.length > 0) {
1662
+ sourceReport.errors.push(`unresolved index bucket mappings: ${unresolved.slice(0, 10).join(', ')}`);
1663
+ }
1664
+
1665
+ const sampleTokens = Object.keys(tokenToBucket).slice(0, 64);
1666
+ const shardSources = collectRegistryShardSources(index, sampleTokens, maxShards);
1667
+ sourceReport.shard_sources_checked = shardSources.length;
1668
+ if (shardSources.length === 0) {
1669
+ sourceReport.warnings.push('index resolved zero shard sources');
1670
+ }
1671
+
1672
+ for (const shardSource of shardSources.slice(0, shardSample)) {
1673
+ try {
1674
+ const shardPayload = await loadRegistryPayload(projectPath, shardSource, fileSystem);
1675
+ const shardEntries = extractRegistryEntries(shardPayload, `${sourceName}-shard`);
1676
+ sourceReport.shard_entries += shardEntries.length;
1677
+ } catch (error) {
1678
+ sourceReport.errors.push(`failed to load shard (${shardSource}): ${error.message}`);
1679
+ }
1680
+ }
1681
+ }
1682
+ } catch (error) {
1683
+ sourceReport.index_ok = false;
1684
+ sourceReport.errors.push(`failed to load index (${sourceReport.index_url}): ${error.message}`);
1685
+ }
1686
+ }
1687
+
1688
+ for (const message of sourceReport.warnings) {
1689
+ warnings.push(`[${sourceName}] ${message}`);
1690
+ }
1691
+ for (const message of sourceReport.errors) {
1692
+ errors.push(`[${sourceName}] ${message}`);
1693
+ }
1694
+ sourceResults.push(sourceReport);
1695
+ }
1696
+
1697
+ const result = {
1698
+ mode: 'errorbook-health-registry',
1699
+ checked_at: nowIso(),
1700
+ passed: errors.length === 0,
1701
+ warning_count: warnings.length,
1702
+ error_count: errors.length,
1703
+ paths: {
1704
+ config_file: registryPaths.configFile,
1705
+ cache_file: registryPaths.cacheFile
1706
+ },
1707
+ config: {
1708
+ exists: configExists,
1709
+ enabled: registryEnabled,
1710
+ search_mode: config.search_mode || 'cache',
1711
+ source_count: sources.length
1712
+ },
1713
+ sources: sourceResults,
1714
+ warnings,
1715
+ errors
1716
+ };
1717
+
1718
+ if (options.json) {
1719
+ console.log(JSON.stringify(result, null, 2));
1720
+ } else if (!options.silent) {
1721
+ if (result.passed) {
1722
+ console.log(chalk.green('✓ Errorbook registry health check passed'));
1723
+ } else {
1724
+ console.log(chalk.red('✗ Errorbook registry health check failed'));
1725
+ }
1726
+ console.log(chalk.gray(` sources: ${result.config.source_count}`));
1727
+ console.log(chalk.gray(` warnings: ${result.warning_count}`));
1728
+ console.log(chalk.gray(` errors: ${result.error_count}`));
1729
+ }
1730
+
1731
+ if (options.failOnAlert && !result.passed) {
1732
+ throw new Error(`errorbook registry health failed: ${result.error_count} error(s)`);
1733
+ }
1734
+
1735
+ return result;
1736
+ }
1737
+
1502
1738
  async function runErrorbookListCommand(options = {}, dependencies = {}) {
1503
1739
  const projectPath = dependencies.projectPath || process.cwd();
1504
1740
  const fileSystem = dependencies.fileSystem || fs;
@@ -2148,6 +2384,26 @@ function registerErrorbookCommands(program) {
2148
2384
  }
2149
2385
  });
2150
2386
 
2387
+ errorbook
2388
+ .command('health-registry')
2389
+ .description('Validate external registry config/source/index health')
2390
+ .option('--config <path>', `Registry config path (default: ${DEFAULT_ERRORBOOK_REGISTRY_CONFIG})`)
2391
+ .option('--cache <path>', `Registry cache path (default: ${DEFAULT_ERRORBOOK_REGISTRY_CACHE})`)
2392
+ .option('--source <url-or-path>', 'Override registry source JSON (https://... or local file)')
2393
+ .option('--source-name <name>', 'Override source name label')
2394
+ .option('--index <url-or-path>', 'Override registry index source (https://... or local file)')
2395
+ .option('--max-shards <n>', 'Max index-resolved shards to validate', parseInt, 8)
2396
+ .option('--shard-sample <n>', 'Shard sample count to fetch and validate', parseInt, 2)
2397
+ .option('--fail-on-alert', 'Exit with error when health check finds errors')
2398
+ .option('--json', 'Emit machine-readable JSON')
2399
+ .action(async (options) => {
2400
+ try {
2401
+ await runErrorbookRegistryHealthCommand(options);
2402
+ } catch (error) {
2403
+ emitCommandError(error, options.json);
2404
+ }
2405
+ });
2406
+
2151
2407
  errorbook
2152
2408
  .command('promote <id>')
2153
2409
  .description('Promote entry after strict quality gate')
@@ -2212,6 +2468,7 @@ module.exports = {
2212
2468
  DEFAULT_ERRORBOOK_REGISTRY_CACHE,
2213
2469
  DEFAULT_ERRORBOOK_REGISTRY_EXPORT,
2214
2470
  HIGH_RISK_SIGNAL_TAGS,
2471
+ DEBUG_EVIDENCE_TAGS,
2215
2472
  DEFAULT_PROMOTE_MIN_QUALITY,
2216
2473
  resolveErrorbookPaths,
2217
2474
  resolveErrorbookRegistryPaths,
@@ -2223,6 +2480,7 @@ module.exports = {
2223
2480
  runErrorbookRecordCommand,
2224
2481
  runErrorbookExportCommand,
2225
2482
  runErrorbookSyncRegistryCommand,
2483
+ runErrorbookRegistryHealthCommand,
2226
2484
  runErrorbookListCommand,
2227
2485
  runErrorbookShowCommand,
2228
2486
  runErrorbookFindCommand,
@@ -11,6 +11,7 @@ const { ContextCollector } = require('../spec/bootstrap/context-collector');
11
11
  const { QuestionnaireEngine } = require('../spec/bootstrap/questionnaire-engine');
12
12
  const { DraftGenerator } = require('../spec/bootstrap/draft-generator');
13
13
  const { TraceEmitter } = require('../spec/bootstrap/trace-emitter');
14
+ const { ensureSpecDomainArtifacts } = require('../spec/domain-modeling');
14
15
  const { SessionStore } = require('../runtime/session-store');
15
16
  const { resolveSpecSceneBinding } = require('../runtime/scene-session-binding');
16
17
  const { bindMultiSpecSceneSession } = require('../runtime/multi-spec-scene-session');
@@ -114,6 +115,14 @@ async function runSpecBootstrap(options = {}, dependencies = {}) {
114
115
  await fs.writeFile(files.tasks, draft.tasks, 'utf8');
115
116
  }
116
117
 
118
+ const domainArtifacts = await ensureSpecDomainArtifacts(projectPath, specName, {
119
+ dryRun: !!options.dryRun,
120
+ sceneId: sceneBinding.scene_id,
121
+ problemStatement: answers.problemStatement,
122
+ primaryFlow: answers.primaryFlow,
123
+ verificationPlan: answers.verificationPlan
124
+ });
125
+
117
126
  const result = {
118
127
  success: true,
119
128
  specName,
@@ -122,7 +131,10 @@ async function runSpecBootstrap(options = {}, dependencies = {}) {
122
131
  files: {
123
132
  requirements: path.relative(projectPath, files.requirements),
124
133
  design: path.relative(projectPath, files.design),
125
- tasks: path.relative(projectPath, files.tasks)
134
+ tasks: path.relative(projectPath, files.tasks),
135
+ domain_map: path.relative(projectPath, domainArtifacts.paths.domain_map),
136
+ scene_spec: path.relative(projectPath, domainArtifacts.paths.scene_spec),
137
+ domain_chain: path.relative(projectPath, domainArtifacts.paths.domain_chain)
126
138
  },
127
139
  trace: {
128
140
  template: options.template || 'default',
@@ -141,7 +153,10 @@ async function runSpecBootstrap(options = {}, dependencies = {}) {
141
153
  preview: {
142
154
  requirements: draft.requirements,
143
155
  design: draft.design,
144
- tasks: draft.tasks
156
+ tasks: draft.tasks,
157
+ domain_map: domainArtifacts.preview.domain_map,
158
+ scene_spec: domainArtifacts.preview.scene_spec,
159
+ domain_chain: domainArtifacts.preview.domain_chain
145
160
  },
146
161
  scene_session: sceneBinding
147
162
  ? {
@@ -0,0 +1,217 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+ const {
5
+ ensureSpecDomainArtifacts,
6
+ validateSpecDomainArtifacts
7
+ } = require('../spec/domain-modeling');
8
+
9
+ function normalizeText(value) {
10
+ if (typeof value !== 'string') {
11
+ return '';
12
+ }
13
+ return value.trim();
14
+ }
15
+
16
+ function resolveSpecId(options = {}) {
17
+ return normalizeText(options.spec || options.name);
18
+ }
19
+
20
+ async function assertSpecExists(projectPath, specId, fileSystem = fs) {
21
+ const specPath = path.join(projectPath, '.sce', 'specs', specId);
22
+ if (!await fileSystem.pathExists(specPath)) {
23
+ throw new Error(`Spec not found: ${specId}`);
24
+ }
25
+ return specPath;
26
+ }
27
+
28
+ async function runSpecDomainInitCommand(options = {}, dependencies = {}) {
29
+ const projectPath = dependencies.projectPath || process.cwd();
30
+ const fileSystem = dependencies.fileSystem || fs;
31
+ const specId = resolveSpecId(options);
32
+ if (!specId) {
33
+ throw new Error('--spec is required');
34
+ }
35
+ await assertSpecExists(projectPath, specId, fileSystem);
36
+
37
+ const result = await ensureSpecDomainArtifacts(projectPath, specId, {
38
+ fileSystem,
39
+ dryRun: options.dryRun === true,
40
+ force: false,
41
+ sceneId: options.scene,
42
+ problemStatement: options.problem,
43
+ primaryFlow: options.primaryFlow,
44
+ verificationPlan: options.verificationPlan
45
+ });
46
+
47
+ const payload = {
48
+ mode: 'spec-domain-init',
49
+ spec_id: specId,
50
+ dry_run: options.dryRun === true,
51
+ created: result.created,
52
+ files: result.paths
53
+ };
54
+
55
+ if (options.json) {
56
+ console.log(JSON.stringify(payload, null, 2));
57
+ } else if (!options.silent) {
58
+ console.log(chalk.green('✓ Spec domain artifacts initialized'));
59
+ console.log(chalk.gray(` spec: ${specId}`));
60
+ }
61
+
62
+ return payload;
63
+ }
64
+
65
+ async function runSpecDomainRefreshCommand(options = {}, dependencies = {}) {
66
+ const projectPath = dependencies.projectPath || process.cwd();
67
+ const fileSystem = dependencies.fileSystem || fs;
68
+ const specId = resolveSpecId(options);
69
+ if (!specId) {
70
+ throw new Error('--spec is required');
71
+ }
72
+ await assertSpecExists(projectPath, specId, fileSystem);
73
+
74
+ const result = await ensureSpecDomainArtifacts(projectPath, specId, {
75
+ fileSystem,
76
+ dryRun: options.dryRun === true,
77
+ force: true,
78
+ sceneId: options.scene,
79
+ problemStatement: options.problem,
80
+ primaryFlow: options.primaryFlow,
81
+ verificationPlan: options.verificationPlan
82
+ });
83
+
84
+ const payload = {
85
+ mode: 'spec-domain-refresh',
86
+ spec_id: specId,
87
+ dry_run: options.dryRun === true,
88
+ refreshed: result.created,
89
+ files: result.paths
90
+ };
91
+
92
+ if (options.json) {
93
+ console.log(JSON.stringify(payload, null, 2));
94
+ } else if (!options.silent) {
95
+ console.log(chalk.green('✓ Spec domain artifacts refreshed'));
96
+ console.log(chalk.gray(` spec: ${specId}`));
97
+ }
98
+
99
+ return payload;
100
+ }
101
+
102
+ async function runSpecDomainValidateCommand(options = {}, dependencies = {}) {
103
+ const projectPath = dependencies.projectPath || process.cwd();
104
+ const fileSystem = dependencies.fileSystem || fs;
105
+ const specId = resolveSpecId(options);
106
+ if (!specId) {
107
+ throw new Error('--spec is required');
108
+ }
109
+ await assertSpecExists(projectPath, specId, fileSystem);
110
+
111
+ const validation = await validateSpecDomainArtifacts(projectPath, specId, fileSystem);
112
+ const payload = {
113
+ mode: 'spec-domain-validate',
114
+ spec_id: specId,
115
+ passed: validation.passed,
116
+ ratio: validation.ratio,
117
+ details: validation.details,
118
+ warnings: validation.warnings
119
+ };
120
+
121
+ if (options.failOnError && !validation.passed) {
122
+ throw new Error(`spec domain validation failed: ${validation.warnings.join('; ')}`);
123
+ }
124
+
125
+ if (options.json) {
126
+ console.log(JSON.stringify(payload, null, 2));
127
+ } else if (!options.silent) {
128
+ if (validation.passed) {
129
+ console.log(chalk.green('✓ Spec domain validation passed'));
130
+ } else {
131
+ console.log(chalk.red('✗ Spec domain validation failed'));
132
+ validation.warnings.forEach((message) => {
133
+ console.log(chalk.gray(` - ${message}`));
134
+ });
135
+ }
136
+ }
137
+
138
+ return payload;
139
+ }
140
+
141
+ function registerSpecDomainCommand(program) {
142
+ const specDomain = program
143
+ .command('spec-domain')
144
+ .description('Manage problem-domain modeling artifacts (use: sce spec domain)');
145
+
146
+ specDomain
147
+ .command('init')
148
+ .description('Create missing problem-domain artifacts for a Spec')
149
+ .requiredOption('--spec <name>', 'Spec identifier')
150
+ .option('--scene <scene-id>', 'Scene id used in generated chain/model')
151
+ .option('--problem <text>', 'Problem statement seed')
152
+ .option('--primary-flow <text>', 'Primary flow seed')
153
+ .option('--verification-plan <text>', 'Verification plan seed')
154
+ .option('--dry-run', 'Preview output without writing files')
155
+ .option('--json', 'Output machine-readable JSON')
156
+ .action(async (options) => {
157
+ try {
158
+ await runSpecDomainInitCommand(options);
159
+ } catch (error) {
160
+ if (options.json) {
161
+ console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
162
+ } else {
163
+ console.error(chalk.red('❌ spec-domain init failed:'), error.message);
164
+ }
165
+ process.exit(1);
166
+ }
167
+ });
168
+
169
+ specDomain
170
+ .command('refresh')
171
+ .description('Force-refresh problem-domain artifacts for a Spec')
172
+ .requiredOption('--spec <name>', 'Spec identifier')
173
+ .option('--scene <scene-id>', 'Scene id used in generated chain/model')
174
+ .option('--problem <text>', 'Problem statement seed')
175
+ .option('--primary-flow <text>', 'Primary flow seed')
176
+ .option('--verification-plan <text>', 'Verification plan seed')
177
+ .option('--dry-run', 'Preview output without writing files')
178
+ .option('--json', 'Output machine-readable JSON')
179
+ .action(async (options) => {
180
+ try {
181
+ await runSpecDomainRefreshCommand(options);
182
+ } catch (error) {
183
+ if (options.json) {
184
+ console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
185
+ } else {
186
+ console.error(chalk.red('❌ spec-domain refresh failed:'), error.message);
187
+ }
188
+ process.exit(1);
189
+ }
190
+ });
191
+
192
+ specDomain
193
+ .command('validate')
194
+ .description('Validate problem-domain artifacts for a Spec')
195
+ .requiredOption('--spec <name>', 'Spec identifier')
196
+ .option('--fail-on-error', 'Exit non-zero when validation fails')
197
+ .option('--json', 'Output machine-readable JSON')
198
+ .action(async (options) => {
199
+ try {
200
+ await runSpecDomainValidateCommand(options);
201
+ } catch (error) {
202
+ if (options.json) {
203
+ console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
204
+ } else {
205
+ console.error(chalk.red('❌ spec-domain validate failed:'), error.message);
206
+ }
207
+ process.exit(1);
208
+ }
209
+ });
210
+ }
211
+
212
+ module.exports = {
213
+ runSpecDomainInitCommand,
214
+ runSpecDomainRefreshCommand,
215
+ runSpecDomainValidateCommand,
216
+ registerSpecDomainCommand
217
+ };
@@ -0,0 +1,70 @@
1
+ const chalk = require('chalk');
2
+ const { findRelatedSpecs } = require('../spec/related-specs');
3
+
4
+ function normalizeText(value) {
5
+ if (typeof value !== 'string') {
6
+ return '';
7
+ }
8
+ return value.trim();
9
+ }
10
+
11
+ async function runSpecRelatedCommand(options = {}, dependencies = {}) {
12
+ const query = normalizeText(options.query);
13
+ const sceneId = normalizeText(options.scene);
14
+ const specId = normalizeText(options.spec);
15
+
16
+ if (!query && !sceneId && !specId) {
17
+ throw new Error('At least one selector is required: --query or --scene or --spec');
18
+ }
19
+
20
+ const payload = await findRelatedSpecs({
21
+ query,
22
+ sceneId,
23
+ sourceSpecId: specId,
24
+ limit: options.limit
25
+ }, dependencies);
26
+
27
+ if (options.json) {
28
+ console.log(JSON.stringify(payload, null, 2));
29
+ } else {
30
+ console.log(chalk.blue('Related Specs'));
31
+ console.log(` Query: ${payload.query || '(none)'}`);
32
+ console.log(` Scene: ${payload.scene_id || '(none)'}`);
33
+ console.log(` Source Spec: ${payload.source_spec_id || '(none)'}`);
34
+ console.log(` Candidates: ${payload.total_candidates}`);
35
+ for (const item of payload.related_specs) {
36
+ console.log(` - ${item.spec_id} | score=${item.score} | scene=${item.scene_id || 'n/a'}`);
37
+ }
38
+ }
39
+
40
+ return payload;
41
+ }
42
+
43
+ function registerSpecRelatedCommand(program) {
44
+ program
45
+ .command('spec-related')
46
+ .description('Find previously related Specs by query/scene context')
47
+ .option('--query <text>', 'Problem statement or search query')
48
+ .option('--scene <scene-id>', 'Scene id for scene-aligned lookup')
49
+ .option('--spec <spec-id>', 'Use existing spec as query seed')
50
+ .option('--limit <n>', 'Maximum related specs to return', '5')
51
+ .option('--json', 'Output machine-readable JSON')
52
+ .action(async (options) => {
53
+ try {
54
+ await runSpecRelatedCommand(options);
55
+ } catch (error) {
56
+ if (options.json) {
57
+ console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
58
+ } else {
59
+ console.error(chalk.red('❌ spec-related failed:'), error.message);
60
+ }
61
+ process.exit(1);
62
+ }
63
+ });
64
+ }
65
+
66
+ module.exports = {
67
+ runSpecRelatedCommand,
68
+ registerSpecRelatedCommand
69
+ };
70
+