scene-capability-engine 3.3.21 → 3.3.23

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.
@@ -1,4 +1,6 @@
1
1
  const crypto = require('crypto');
2
+ const http = require('http');
3
+ const https = require('https');
2
4
  const path = require('path');
3
5
  const fs = require('fs-extra');
4
6
  const chalk = require('chalk');
@@ -6,7 +8,14 @@ const Table = require('cli-table3');
6
8
 
7
9
  const ERRORBOOK_INDEX_API_VERSION = 'sce.errorbook.index/v0.1';
8
10
  const ERRORBOOK_ENTRY_API_VERSION = 'sce.errorbook.entry/v0.1';
11
+ const ERRORBOOK_REGISTRY_API_VERSION = 'sce.errorbook.registry/v0.1';
12
+ const ERRORBOOK_REGISTRY_CACHE_API_VERSION = 'sce.errorbook.registry-cache/v0.1';
13
+ const ERRORBOOK_REGISTRY_INDEX_API_VERSION = 'sce.errorbook.registry-index/v0.1';
9
14
  const ERRORBOOK_STATUSES = Object.freeze(['candidate', 'verified', 'promoted', 'deprecated']);
15
+ const TEMPORARY_MITIGATION_TAG = 'temporary-mitigation';
16
+ const DEFAULT_ERRORBOOK_REGISTRY_CONFIG = '.sce/config/errorbook-registry.json';
17
+ const DEFAULT_ERRORBOOK_REGISTRY_CACHE = '.sce/errorbook/registry-cache.json';
18
+ const DEFAULT_ERRORBOOK_REGISTRY_EXPORT = '.sce/errorbook/exports/errorbook-registry-export.json';
10
19
  const STATUS_RANK = Object.freeze({
11
20
  deprecated: 0,
12
21
  candidate: 1,
@@ -59,6 +68,28 @@ function resolveErrorbookPaths(projectPath = process.cwd()) {
59
68
  };
60
69
  }
61
70
 
71
+ function resolveProjectPath(projectPath, maybeRelativePath, fallbackRelativePath) {
72
+ const normalized = normalizeText(maybeRelativePath || fallbackRelativePath || '');
73
+ if (!normalized) {
74
+ return path.resolve(projectPath, fallbackRelativePath || '');
75
+ }
76
+ return path.isAbsolute(normalized)
77
+ ? normalized
78
+ : path.resolve(projectPath, normalized);
79
+ }
80
+
81
+ function resolveErrorbookRegistryPaths(projectPath = process.cwd(), overrides = {}) {
82
+ const configFile = resolveProjectPath(projectPath, overrides.configPath, DEFAULT_ERRORBOOK_REGISTRY_CONFIG);
83
+ const cacheFile = resolveProjectPath(projectPath, overrides.cachePath, DEFAULT_ERRORBOOK_REGISTRY_CACHE);
84
+ const exportFile = resolveProjectPath(projectPath, overrides.exportPath, DEFAULT_ERRORBOOK_REGISTRY_EXPORT);
85
+ return {
86
+ projectPath,
87
+ configFile,
88
+ cacheFile,
89
+ exportFile
90
+ };
91
+ }
92
+
62
93
  function nowIso() {
63
94
  return new Date().toISOString();
64
95
  }
@@ -71,6 +102,23 @@ function normalizeText(value) {
71
102
  return value.trim();
72
103
  }
73
104
 
105
+ function normalizeBoolean(value, fallback = false) {
106
+ if (typeof value === 'boolean') {
107
+ return value;
108
+ }
109
+ const normalized = normalizeText(`${value || ''}`).toLowerCase();
110
+ if (!normalized) {
111
+ return fallback;
112
+ }
113
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
114
+ return true;
115
+ }
116
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
117
+ return false;
118
+ }
119
+ return fallback;
120
+ }
121
+
74
122
  function normalizeCsv(value) {
75
123
  if (Array.isArray(value)) {
76
124
  return value;
@@ -109,6 +157,18 @@ function normalizeStringList(...rawInputs) {
109
157
  return Array.from(new Set(merged));
110
158
  }
111
159
 
160
+ function normalizeIsoTimestamp(value, fieldName = 'datetime') {
161
+ const normalized = normalizeText(value);
162
+ if (!normalized) {
163
+ return '';
164
+ }
165
+ const parsed = Date.parse(normalized);
166
+ if (Number.isNaN(parsed)) {
167
+ throw new Error(`${fieldName} must be a valid ISO datetime`);
168
+ }
169
+ return new Date(parsed).toISOString();
170
+ }
171
+
112
172
  function normalizeOntologyTags(...rawInputs) {
113
173
  const normalized = normalizeStringList(...rawInputs).map((item) => item.toLowerCase());
114
174
  const mapped = normalized.map((item) => ONTOLOGY_TAG_ALIASES[item] || item);
@@ -116,6 +176,140 @@ function normalizeOntologyTags(...rawInputs) {
116
176
  return Array.from(new Set(valid));
117
177
  }
118
178
 
179
+ function hasMitigationInput(options = {}, fromFilePayload = {}) {
180
+ const mitigation = fromFilePayload && typeof fromFilePayload.temporary_mitigation === 'object'
181
+ ? fromFilePayload.temporary_mitigation
182
+ : {};
183
+ return Boolean(
184
+ options.temporaryMitigation === true ||
185
+ normalizeBoolean(mitigation.enabled, false) ||
186
+ normalizeText(options.mitigationReason || mitigation.reason || mitigation.notes) ||
187
+ normalizeText(options.mitigationExit || mitigation.exit_criteria || mitigation.exitCriteria) ||
188
+ normalizeText(options.mitigationCleanup || mitigation.cleanup_task || mitigation.cleanupTask) ||
189
+ normalizeText(options.mitigationDeadline || mitigation.deadline_at || mitigation.deadlineAt)
190
+ );
191
+ }
192
+
193
+ function normalizeTemporaryMitigation(options = {}, fromFilePayload = {}) {
194
+ const mitigationFromFile = fromFilePayload && typeof fromFilePayload.temporary_mitigation === 'object'
195
+ ? fromFilePayload.temporary_mitigation
196
+ : {};
197
+ if (!hasMitigationInput(options, fromFilePayload)) {
198
+ return { enabled: false };
199
+ }
200
+
201
+ return {
202
+ enabled: true,
203
+ reason: normalizeText(options.mitigationReason || mitigationFromFile.reason || mitigationFromFile.notes),
204
+ exit_criteria: normalizeText(options.mitigationExit || mitigationFromFile.exit_criteria || mitigationFromFile.exitCriteria),
205
+ cleanup_task: normalizeText(options.mitigationCleanup || mitigationFromFile.cleanup_task || mitigationFromFile.cleanupTask),
206
+ deadline_at: normalizeIsoTimestamp(
207
+ options.mitigationDeadline || mitigationFromFile.deadline_at || mitigationFromFile.deadlineAt,
208
+ '--mitigation-deadline'
209
+ ),
210
+ created_at: normalizeIsoTimestamp(mitigationFromFile.created_at || mitigationFromFile.createdAt, 'temporary_mitigation.created_at') || '',
211
+ updated_at: normalizeIsoTimestamp(mitigationFromFile.updated_at || mitigationFromFile.updatedAt, 'temporary_mitigation.updated_at') || '',
212
+ resolved_at: normalizeIsoTimestamp(mitigationFromFile.resolved_at || mitigationFromFile.resolvedAt, 'temporary_mitigation.resolved_at') || ''
213
+ };
214
+ }
215
+
216
+ function normalizeExistingTemporaryMitigation(value = {}) {
217
+ if (!value || typeof value !== 'object' || normalizeBoolean(value.enabled, false) !== true) {
218
+ return { enabled: false };
219
+ }
220
+ return {
221
+ enabled: true,
222
+ reason: normalizeText(value.reason || value.notes),
223
+ exit_criteria: normalizeText(value.exit_criteria || value.exitCriteria),
224
+ cleanup_task: normalizeText(value.cleanup_task || value.cleanupTask),
225
+ deadline_at: normalizeText(value.deadline_at || value.deadlineAt),
226
+ created_at: normalizeText(value.created_at || value.createdAt),
227
+ updated_at: normalizeText(value.updated_at || value.updatedAt),
228
+ resolved_at: normalizeText(value.resolved_at || value.resolvedAt)
229
+ };
230
+ }
231
+
232
+ function resolveMergedTemporaryMitigation(existingEntry = {}, incomingPayload = {}) {
233
+ const existing = normalizeExistingTemporaryMitigation(existingEntry.temporary_mitigation);
234
+ const incoming = normalizeExistingTemporaryMitigation(incomingPayload.temporary_mitigation);
235
+ if (!incoming.enabled) {
236
+ return existing;
237
+ }
238
+
239
+ return {
240
+ enabled: true,
241
+ reason: normalizeText(incoming.reason) || existing.reason || '',
242
+ exit_criteria: normalizeText(incoming.exit_criteria) || existing.exit_criteria || '',
243
+ cleanup_task: normalizeText(incoming.cleanup_task) || existing.cleanup_task || '',
244
+ deadline_at: normalizeText(incoming.deadline_at) || existing.deadline_at || '',
245
+ created_at: normalizeText(existing.created_at) || nowIso(),
246
+ updated_at: nowIso(),
247
+ resolved_at: ''
248
+ };
249
+ }
250
+
251
+ function markTemporaryMitigationResolved(entry = {}, resolvedAt = nowIso()) {
252
+ const current = normalizeExistingTemporaryMitigation(entry.temporary_mitigation);
253
+ if (!current.enabled || normalizeText(current.resolved_at)) {
254
+ return current.enabled ? {
255
+ ...current,
256
+ resolved_at: normalizeText(current.resolved_at) || resolvedAt,
257
+ updated_at: normalizeText(current.updated_at) || resolvedAt
258
+ } : { enabled: false };
259
+ }
260
+ return {
261
+ ...current,
262
+ resolved_at: resolvedAt,
263
+ updated_at: resolvedAt
264
+ };
265
+ }
266
+
267
+ function evaluateTemporaryMitigationPolicy(entry = {}) {
268
+ const mitigation = normalizeExistingTemporaryMitigation(entry.temporary_mitigation);
269
+ const status = normalizeStatus(entry.status, 'candidate');
270
+ if (!mitigation.enabled || ['promoted', 'deprecated'].includes(status)) {
271
+ return null;
272
+ }
273
+ if (normalizeText(mitigation.resolved_at)) {
274
+ return null;
275
+ }
276
+
277
+ const policyViolations = [];
278
+ if (!normalizeText(mitigation.exit_criteria)) {
279
+ policyViolations.push('temporary_mitigation.exit_criteria');
280
+ }
281
+ if (!normalizeText(mitigation.cleanup_task)) {
282
+ policyViolations.push('temporary_mitigation.cleanup_task');
283
+ }
284
+ const deadlineAtRaw = normalizeText(mitigation.deadline_at);
285
+ let deadlineAt = deadlineAtRaw;
286
+ let deadlineExpired = false;
287
+ if (!deadlineAtRaw) {
288
+ policyViolations.push('temporary_mitigation.deadline_at');
289
+ } else {
290
+ const parsed = Date.parse(deadlineAtRaw);
291
+ if (Number.isNaN(parsed)) {
292
+ policyViolations.push('temporary_mitigation.deadline_at:invalid_datetime');
293
+ } else {
294
+ deadlineAt = new Date(parsed).toISOString();
295
+ if (parsed <= Date.now()) {
296
+ deadlineExpired = true;
297
+ policyViolations.push('temporary_mitigation.deadline_at:expired');
298
+ }
299
+ }
300
+ }
301
+
302
+ return {
303
+ enabled: true,
304
+ reason: mitigation.reason,
305
+ exit_criteria: mitigation.exit_criteria,
306
+ cleanup_task: mitigation.cleanup_task,
307
+ deadline_at: deadlineAt,
308
+ deadline_expired: deadlineExpired,
309
+ policy_violations: policyViolations
310
+ };
311
+ }
312
+
119
313
  function normalizeStatus(input, fallback = 'candidate') {
120
314
  const normalized = normalizeText(`${input || ''}`).toLowerCase();
121
315
  if (!normalized) {
@@ -273,9 +467,22 @@ function validateRecordPayload(payload) {
273
467
  if (status === 'verified' && (!Array.isArray(payload.verification_evidence) || payload.verification_evidence.length === 0)) {
274
468
  throw new Error('status=verified requires at least one --verification evidence');
275
469
  }
470
+ const mitigation = normalizeExistingTemporaryMitigation(payload.temporary_mitigation);
471
+ if (mitigation.enabled) {
472
+ if (!normalizeText(mitigation.exit_criteria)) {
473
+ throw new Error('temporary mitigation requires --mitigation-exit');
474
+ }
475
+ if (!normalizeText(mitigation.cleanup_task)) {
476
+ throw new Error('temporary mitigation requires --mitigation-cleanup');
477
+ }
478
+ if (!normalizeText(mitigation.deadline_at)) {
479
+ throw new Error('temporary mitigation requires --mitigation-deadline');
480
+ }
481
+ }
276
482
  }
277
483
 
278
484
  function normalizeRecordPayload(options = {}, fromFilePayload = {}) {
485
+ const temporaryMitigation = normalizeTemporaryMitigation(options, fromFilePayload);
279
486
  const payload = {
280
487
  title: normalizeText(options.title || fromFilePayload.title),
281
488
  symptom: normalizeText(options.symptom || fromFilePayload.symptom),
@@ -287,7 +494,11 @@ function normalizeRecordPayload(options = {}, fromFilePayload = {}) {
287
494
  options.verification,
288
495
  options.verificationEvidence
289
496
  ),
290
- tags: normalizeStringList(fromFilePayload.tags, options.tags),
497
+ tags: normalizeStringList(
498
+ fromFilePayload.tags,
499
+ options.tags,
500
+ temporaryMitigation.enabled ? TEMPORARY_MITIGATION_TAG : []
501
+ ),
291
502
  ontology_tags: normalizeOntologyTags(fromFilePayload.ontology_tags, fromFilePayload.ontology, options.ontology),
292
503
  status: normalizeStatus(options.status || fromFilePayload.status || 'candidate'),
293
504
  source: {
@@ -295,6 +506,7 @@ function normalizeRecordPayload(options = {}, fromFilePayload = {}) {
295
506
  files: normalizeStringList(fromFilePayload?.source?.files, options.files),
296
507
  tests: normalizeStringList(fromFilePayload?.source?.tests, options.tests)
297
508
  },
509
+ temporary_mitigation: temporaryMitigation,
298
510
  notes: normalizeText(options.notes || fromFilePayload.notes),
299
511
  fingerprint: createFingerprint({
300
512
  fingerprint: options.fingerprint || fromFilePayload.fingerprint,
@@ -312,6 +524,8 @@ function createEntryId() {
312
524
  }
313
525
 
314
526
  function buildIndexSummary(entry) {
527
+ const mitigation = normalizeExistingTemporaryMitigation(entry.temporary_mitigation);
528
+ const mitigationActive = mitigation.enabled && !normalizeText(mitigation.resolved_at);
315
529
  return {
316
530
  id: entry.id,
317
531
  fingerprint: entry.fingerprint,
@@ -320,6 +534,8 @@ function buildIndexSummary(entry) {
320
534
  quality_score: entry.quality_score,
321
535
  tags: entry.tags,
322
536
  ontology_tags: entry.ontology_tags,
537
+ temporary_mitigation_active: mitigationActive,
538
+ temporary_mitigation_deadline_at: mitigationActive ? normalizeText(mitigation.deadline_at) : '',
323
539
  occurrences: entry.occurrences || 1,
324
540
  created_at: entry.created_at,
325
541
  updated_at: entry.updated_at
@@ -348,6 +564,12 @@ function findSummaryById(index, id) {
348
564
  }
349
565
 
350
566
  function mergeEntry(existingEntry, incomingPayload) {
567
+ const temporaryMitigation = resolveMergedTemporaryMitigation(existingEntry, incomingPayload);
568
+ const mergedTags = normalizeStringList(
569
+ existingEntry.tags,
570
+ incomingPayload.tags,
571
+ temporaryMitigation.enabled ? TEMPORARY_MITIGATION_TAG : []
572
+ );
351
573
  const merged = {
352
574
  ...existingEntry,
353
575
  title: normalizeText(incomingPayload.title) || existingEntry.title,
@@ -355,7 +577,7 @@ function mergeEntry(existingEntry, incomingPayload) {
355
577
  root_cause: normalizeText(incomingPayload.root_cause) || existingEntry.root_cause,
356
578
  fix_actions: normalizeStringList(existingEntry.fix_actions, incomingPayload.fix_actions),
357
579
  verification_evidence: normalizeStringList(existingEntry.verification_evidence, incomingPayload.verification_evidence),
358
- tags: normalizeStringList(existingEntry.tags, incomingPayload.tags),
580
+ tags: mergedTags,
359
581
  ontology_tags: normalizeOntologyTags(existingEntry.ontology_tags, incomingPayload.ontology_tags),
360
582
  status: selectStatus(existingEntry.status, incomingPayload.status),
361
583
  notes: normalizeText(incomingPayload.notes) || existingEntry.notes || '',
@@ -364,6 +586,7 @@ function mergeEntry(existingEntry, incomingPayload) {
364
586
  files: normalizeStringList(existingEntry?.source?.files, incomingPayload?.source?.files),
365
587
  tests: normalizeStringList(existingEntry?.source?.tests, incomingPayload?.source?.tests)
366
588
  },
589
+ temporary_mitigation: temporaryMitigation,
367
590
  occurrences: Number(existingEntry.occurrences || 1) + 1,
368
591
  updated_at: nowIso()
369
592
  };
@@ -396,6 +619,382 @@ async function loadRecordPayloadFromFile(projectPath, sourcePath, fileSystem = f
396
619
  }
397
620
  }
398
621
 
622
+ function normalizeStatusList(values = [], fallback = ['promoted']) {
623
+ const raw = Array.isArray(values) ? values : normalizeStringList(values);
624
+ const list = raw.length > 0 ? raw : fallback;
625
+ const normalized = list
626
+ .map((item) => normalizeText(item).toLowerCase())
627
+ .filter(Boolean);
628
+ const unique = Array.from(new Set(normalized));
629
+ for (const status of unique) {
630
+ if (!ERRORBOOK_STATUSES.includes(status)) {
631
+ throw new Error(`invalid status in list: ${status}`);
632
+ }
633
+ }
634
+ return unique;
635
+ }
636
+
637
+ function normalizeRegistrySource(input = {}) {
638
+ const candidate = input || {};
639
+ const name = normalizeText(candidate.name) || 'default';
640
+ const url = normalizeText(candidate.url || candidate.source);
641
+ const file = normalizeText(candidate.file || candidate.path);
642
+ const source = url || file;
643
+ const indexUrl = normalizeText(candidate.index_url || candidate.indexUrl || candidate.registry_index || candidate.registryIndex);
644
+ return {
645
+ name,
646
+ source,
647
+ index_url: indexUrl,
648
+ enabled: candidate.enabled !== false
649
+ };
650
+ }
651
+
652
+ function normalizeRegistryMode(value, fallback = 'cache') {
653
+ const normalized = normalizeText(`${value || ''}`).toLowerCase();
654
+ if (!normalized) {
655
+ return fallback;
656
+ }
657
+ if (['cache', 'remote', 'hybrid'].includes(normalized)) {
658
+ return normalized;
659
+ }
660
+ throw new Error('registry mode must be one of: cache, remote, hybrid');
661
+ }
662
+
663
+ async function readErrorbookRegistryConfig(paths, fileSystem = fs) {
664
+ const fallback = {
665
+ enabled: false,
666
+ search_mode: 'cache',
667
+ cache_file: DEFAULT_ERRORBOOK_REGISTRY_CACHE,
668
+ sources: []
669
+ };
670
+ if (!await fileSystem.pathExists(paths.configFile)) {
671
+ return fallback;
672
+ }
673
+ const payload = await fileSystem.readJson(paths.configFile).catch(() => null);
674
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
675
+ return fallback;
676
+ }
677
+ const sources = Array.isArray(payload.sources)
678
+ ? payload.sources.map((item) => normalizeRegistrySource(item)).filter((item) => item.enabled && item.source)
679
+ : [];
680
+ return {
681
+ enabled: normalizeBoolean(payload.enabled, true),
682
+ search_mode: normalizeRegistryMode(payload.search_mode || payload.searchMode, 'cache'),
683
+ cache_file: normalizeText(payload.cache_file || payload.cacheFile || DEFAULT_ERRORBOOK_REGISTRY_CACHE),
684
+ sources
685
+ };
686
+ }
687
+
688
+ function isHttpSource(source = '') {
689
+ return /^https?:\/\//i.test(normalizeText(source));
690
+ }
691
+
692
+ function fetchJsonFromHttp(source, timeoutMs = 15000) {
693
+ const normalized = normalizeText(source);
694
+ if (!normalized) {
695
+ return Promise.reject(new Error('registry source is required'));
696
+ }
697
+ const client = normalized.startsWith('https://') ? https : http;
698
+ return new Promise((resolve, reject) => {
699
+ const request = client.get(normalized, {
700
+ timeout: timeoutMs,
701
+ headers: {
702
+ Accept: 'application/json'
703
+ }
704
+ }, (response) => {
705
+ const chunks = [];
706
+ response.on('data', (chunk) => chunks.push(chunk));
707
+ response.on('end', () => {
708
+ const body = Buffer.concat(chunks).toString('utf8');
709
+ if (response.statusCode < 200 || response.statusCode >= 300) {
710
+ reject(new Error(`registry source responded ${response.statusCode}`));
711
+ return;
712
+ }
713
+ try {
714
+ resolve(JSON.parse(body));
715
+ } catch (error) {
716
+ reject(new Error(`registry source returned invalid JSON: ${error.message}`));
717
+ }
718
+ });
719
+ });
720
+ request.on('timeout', () => {
721
+ request.destroy(new Error('registry source request timed out'));
722
+ });
723
+ request.on('error', reject);
724
+ });
725
+ }
726
+
727
+ async function loadRegistryPayload(projectPath, source, fileSystem = fs) {
728
+ const normalized = normalizeText(source);
729
+ if (!normalized) {
730
+ throw new Error('registry source is required');
731
+ }
732
+ if (isHttpSource(normalized)) {
733
+ return fetchJsonFromHttp(normalized);
734
+ }
735
+ const absolutePath = path.isAbsolute(normalized)
736
+ ? normalized
737
+ : path.resolve(projectPath, normalized);
738
+ if (!await fileSystem.pathExists(absolutePath)) {
739
+ throw new Error(`registry source file not found: ${source}`);
740
+ }
741
+ return fileSystem.readJson(absolutePath);
742
+ }
743
+
744
+ function normalizeRegistryEntry(entry = {}, sourceName = 'registry') {
745
+ const title = normalizeText(entry.title || entry.name);
746
+ const symptom = normalizeText(entry.symptom);
747
+ const rootCause = normalizeText(entry.root_cause || entry.rootCause);
748
+ const fingerprint = createFingerprint({
749
+ fingerprint: normalizeText(entry.fingerprint),
750
+ title,
751
+ symptom,
752
+ root_cause: rootCause
753
+ });
754
+ const statusRaw = normalizeText(entry.status || 'candidate').toLowerCase();
755
+ const status = ERRORBOOK_STATUSES.includes(statusRaw) ? statusRaw : 'candidate';
756
+ const mitigation = normalizeExistingTemporaryMitigation(entry.temporary_mitigation);
757
+
758
+ return {
759
+ id: normalizeText(entry.id) || `registry-${fingerprint}`,
760
+ fingerprint,
761
+ title,
762
+ symptom,
763
+ root_cause: rootCause,
764
+ fix_actions: normalizeStringList(entry.fix_actions, entry.fixActions),
765
+ verification_evidence: normalizeStringList(entry.verification_evidence, entry.verificationEvidence),
766
+ tags: normalizeStringList(entry.tags, mitigation.enabled ? TEMPORARY_MITIGATION_TAG : []),
767
+ ontology_tags: normalizeOntologyTags(entry.ontology_tags),
768
+ status,
769
+ quality_score: Number.isFinite(Number(entry.quality_score)) ? Number(entry.quality_score) : scoreQuality(entry),
770
+ updated_at: normalizeIsoTimestamp(entry.updated_at || entry.updatedAt, 'registry.updated_at') || nowIso(),
771
+ source: {
772
+ ...entry.source,
773
+ registry: sourceName
774
+ },
775
+ temporary_mitigation: mitigation,
776
+ entry_source: 'registry'
777
+ };
778
+ }
779
+
780
+ function extractRegistryEntries(payload = {}, sourceName = 'registry') {
781
+ const rawEntries = Array.isArray(payload)
782
+ ? payload
783
+ : Array.isArray(payload.entries)
784
+ ? payload.entries
785
+ : [];
786
+ const normalized = [];
787
+ for (const item of rawEntries) {
788
+ if (!item || typeof item !== 'object') {
789
+ continue;
790
+ }
791
+ const entry = normalizeRegistryEntry(item, sourceName);
792
+ if (!entry.title || !entry.fingerprint) {
793
+ continue;
794
+ }
795
+ normalized.push(entry);
796
+ }
797
+ const deduped = new Map();
798
+ for (const entry of normalized) {
799
+ const key = entry.fingerprint;
800
+ const existing = deduped.get(key);
801
+ if (!existing) {
802
+ deduped.set(key, entry);
803
+ continue;
804
+ }
805
+ if ((Number(entry.quality_score) || 0) >= (Number(existing.quality_score) || 0)) {
806
+ deduped.set(key, entry);
807
+ }
808
+ }
809
+ return Array.from(deduped.values());
810
+ }
811
+
812
+ async function loadRegistryCache(projectPath, cachePathInput = '', fileSystem = fs) {
813
+ const cachePath = resolveProjectPath(projectPath, cachePathInput, DEFAULT_ERRORBOOK_REGISTRY_CACHE);
814
+ if (!await fileSystem.pathExists(cachePath)) {
815
+ return {
816
+ cache_path: cachePath,
817
+ entries: []
818
+ };
819
+ }
820
+ const payload = await fileSystem.readJson(cachePath).catch(() => null);
821
+ const entries = extractRegistryEntries(payload || {}, 'registry-cache');
822
+ return {
823
+ cache_path: cachePath,
824
+ entries
825
+ };
826
+ }
827
+
828
+ function tokenizeQueryText(query = '') {
829
+ return normalizeText(query)
830
+ .toLowerCase()
831
+ .split(/[^a-z0-9_]+/i)
832
+ .map((item) => item.trim())
833
+ .filter((item) => item.length >= 2);
834
+ }
835
+
836
+ function normalizeRegistryIndex(payload = {}, sourceName = '') {
837
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
838
+ return null;
839
+ }
840
+ return {
841
+ api_version: normalizeText(payload.api_version || payload.version || ERRORBOOK_REGISTRY_INDEX_API_VERSION),
842
+ source_name: sourceName || normalizeText(payload.source_name || payload.sourceName),
843
+ min_token_length: Number.isFinite(Number(payload.min_token_length))
844
+ ? Number(payload.min_token_length)
845
+ : 2,
846
+ token_to_source: payload.token_to_source && typeof payload.token_to_source === 'object'
847
+ ? payload.token_to_source
848
+ : {},
849
+ token_to_bucket: payload.token_to_bucket && typeof payload.token_to_bucket === 'object'
850
+ ? payload.token_to_bucket
851
+ : {},
852
+ buckets: payload.buckets && typeof payload.buckets === 'object'
853
+ ? payload.buckets
854
+ : {},
855
+ default_source: normalizeText(payload.default_source || payload.fallback_source || '')
856
+ };
857
+ }
858
+
859
+ function collectRegistryShardSources(indexPayload, queryTokens = [], maxShards = 8) {
860
+ const index = normalizeRegistryIndex(indexPayload);
861
+ if (!index) {
862
+ return [];
863
+ }
864
+ const sources = [];
865
+ const minTokenLength = Number.isFinite(index.min_token_length) ? index.min_token_length : 2;
866
+ for (const token of queryTokens) {
867
+ const normalizedToken = normalizeText(token).toLowerCase();
868
+ if (!normalizedToken || normalizedToken.length < minTokenLength) {
869
+ continue;
870
+ }
871
+ const direct = index.token_to_source[normalizedToken];
872
+ if (direct) {
873
+ const items = Array.isArray(direct) ? direct : [direct];
874
+ for (const item of items) {
875
+ sources.push(normalizeText(item));
876
+ }
877
+ continue;
878
+ }
879
+ const bucket = normalizeText(index.token_to_bucket[normalizedToken]);
880
+ if (!bucket) {
881
+ continue;
882
+ }
883
+ const bucketSource = normalizeText(index.buckets[bucket] || index.buckets[normalizedToken]);
884
+ if (bucketSource) {
885
+ sources.push(bucketSource);
886
+ }
887
+ }
888
+
889
+ const deduped = Array.from(new Set(sources.filter(Boolean)));
890
+ if (deduped.length > 0) {
891
+ return Number.isFinite(Number(maxShards)) && Number(maxShards) > 0
892
+ ? deduped.slice(0, Number(maxShards))
893
+ : deduped;
894
+ }
895
+ if (index.default_source) {
896
+ return [index.default_source];
897
+ }
898
+ return [];
899
+ }
900
+
901
+ async function searchRegistryRemote(options = {}, dependencies = {}) {
902
+ const projectPath = dependencies.projectPath || process.cwd();
903
+ const fileSystem = dependencies.fileSystem || fs;
904
+ const source = normalizeRegistrySource(options.source || {});
905
+ const query = normalizeText(options.query);
906
+ const queryTokens = Array.isArray(options.queryTokens) ? options.queryTokens : tokenizeQueryText(query);
907
+ const requestedStatus = options.requestedStatus || null;
908
+ const maxShards = Number.isFinite(Number(options.maxShards)) ? Number(options.maxShards) : 8;
909
+ const allowRemoteFullscan = options.allowRemoteFullscan === true;
910
+
911
+ if (!source.source) {
912
+ return {
913
+ source_name: source.name || 'registry',
914
+ shard_sources: [],
915
+ matched_count: 0,
916
+ candidates: [],
917
+ warnings: ['registry source is empty']
918
+ };
919
+ }
920
+
921
+ const warnings = [];
922
+ let shardSources = [];
923
+ if (source.index_url) {
924
+ try {
925
+ const indexPayload = await loadRegistryPayload(projectPath, source.index_url, fileSystem);
926
+ shardSources = collectRegistryShardSources(indexPayload, queryTokens, maxShards);
927
+ } catch (error) {
928
+ warnings.push(`failed to load registry index (${source.index_url}): ${error.message}`);
929
+ }
930
+ }
931
+
932
+ if (shardSources.length === 0) {
933
+ if (allowRemoteFullscan) {
934
+ shardSources = [source.source];
935
+ warnings.push('remote index unavailable; fallback to full-source scan');
936
+ } else {
937
+ warnings.push('remote index unavailable and full-source scan disabled');
938
+ return {
939
+ source_name: source.name || 'registry',
940
+ shard_sources: [],
941
+ matched_count: 0,
942
+ candidates: [],
943
+ warnings
944
+ };
945
+ }
946
+ }
947
+
948
+ const candidates = [];
949
+ for (const shardSource of shardSources) {
950
+ try {
951
+ const payload = await loadRegistryPayload(projectPath, shardSource, fileSystem);
952
+ const entries = extractRegistryEntries(payload, source.name || 'registry');
953
+ for (const entry of entries) {
954
+ if (requestedStatus && normalizeStatus(entry.status, 'candidate') !== requestedStatus) {
955
+ continue;
956
+ }
957
+ const matchScore = scoreSearchMatch(entry, queryTokens);
958
+ if (matchScore <= 0) {
959
+ continue;
960
+ }
961
+ candidates.push({
962
+ id: entry.id,
963
+ entry_source: 'registry-remote',
964
+ registry_source: source.name || 'registry',
965
+ status: entry.status,
966
+ quality_score: entry.quality_score,
967
+ title: entry.title,
968
+ fingerprint: entry.fingerprint,
969
+ tags: normalizeStringList(entry.tags),
970
+ ontology_tags: normalizeOntologyTags(entry.ontology_tags),
971
+ match_score: matchScore,
972
+ updated_at: entry.updated_at
973
+ });
974
+ }
975
+ } catch (error) {
976
+ warnings.push(`failed to load registry shard (${shardSource}): ${error.message}`);
977
+ }
978
+ }
979
+
980
+ const deduped = new Map();
981
+ for (const item of candidates) {
982
+ const key = normalizeText(item.fingerprint || item.id);
983
+ const existing = deduped.get(key);
984
+ if (!existing || Number(item.match_score || 0) >= Number(existing.match_score || 0)) {
985
+ deduped.set(key, item);
986
+ }
987
+ }
988
+
989
+ return {
990
+ source_name: source.name || 'registry',
991
+ shard_sources: shardSources,
992
+ matched_count: deduped.size,
993
+ candidates: Array.from(deduped.values()),
994
+ warnings
995
+ };
996
+ }
997
+
399
998
  function printRecordSummary(result) {
400
999
  const action = result.created ? 'Recorded new entry' : 'Updated duplicate fingerprint';
401
1000
  console.log(chalk.green(`✓ ${action}`));
@@ -529,6 +1128,8 @@ async function evaluateErrorbookReleaseGate(options = {}, dependencies = {}) {
529
1128
  const includeVerified = options.includeVerified === true;
530
1129
 
531
1130
  const inspected = [];
1131
+ const mitigationInspected = [];
1132
+ const mitigationBlocked = [];
532
1133
  for (const summary of index.entries) {
533
1134
  const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
534
1135
  if (!entry) {
@@ -536,12 +1137,34 @@ async function evaluateErrorbookReleaseGate(options = {}, dependencies = {}) {
536
1137
  }
537
1138
 
538
1139
  const status = normalizeStatus(entry.status, 'candidate');
1140
+ const risk = evaluateEntryRisk(entry);
1141
+ const mitigation = evaluateTemporaryMitigationPolicy(entry);
1142
+ if (mitigation) {
1143
+ const mitigationItem = {
1144
+ id: entry.id,
1145
+ title: entry.title,
1146
+ status,
1147
+ risk,
1148
+ quality_score: Number(entry.quality_score || 0),
1149
+ tags: normalizeStringList(entry.tags),
1150
+ updated_at: entry.updated_at,
1151
+ temporary_mitigation: mitigation
1152
+ };
1153
+ mitigationInspected.push(mitigationItem);
1154
+ if (Array.isArray(mitigation.policy_violations) && mitigation.policy_violations.length > 0) {
1155
+ mitigationBlocked.push({
1156
+ ...mitigationItem,
1157
+ block_reasons: ['temporary_mitigation_policy'],
1158
+ policy_violations: mitigation.policy_violations
1159
+ });
1160
+ }
1161
+ }
1162
+
539
1163
  const unresolved = status === 'candidate' || (includeVerified && status === 'verified');
540
1164
  if (!unresolved) {
541
1165
  continue;
542
1166
  }
543
1167
 
544
- const risk = evaluateEntryRisk(entry);
545
1168
  inspected.push({
546
1169
  id: entry.id,
547
1170
  title: entry.title,
@@ -553,9 +1176,43 @@ async function evaluateErrorbookReleaseGate(options = {}, dependencies = {}) {
553
1176
  });
554
1177
  }
555
1178
 
556
- const blocked = inspected
1179
+ const riskBlocked = inspected
557
1180
  .filter((item) => riskRank(item.risk) >= riskRank(minRisk))
1181
+ .map((item) => ({
1182
+ ...item,
1183
+ block_reasons: ['risk_threshold']
1184
+ }));
1185
+
1186
+ const blockedById = new Map();
1187
+ for (const item of riskBlocked) {
1188
+ blockedById.set(item.id, {
1189
+ ...item,
1190
+ policy_violations: []
1191
+ });
1192
+ }
1193
+ for (const item of mitigationBlocked) {
1194
+ const existing = blockedById.get(item.id);
1195
+ if (!existing) {
1196
+ blockedById.set(item.id, {
1197
+ ...item,
1198
+ policy_violations: Array.isArray(item.policy_violations) ? item.policy_violations : []
1199
+ });
1200
+ continue;
1201
+ }
1202
+ existing.block_reasons = normalizeStringList(existing.block_reasons, item.block_reasons);
1203
+ existing.policy_violations = normalizeStringList(existing.policy_violations, item.policy_violations);
1204
+ if (!existing.temporary_mitigation && item.temporary_mitigation) {
1205
+ existing.temporary_mitigation = item.temporary_mitigation;
1206
+ }
1207
+ blockedById.set(existing.id, existing);
1208
+ }
1209
+
1210
+ const blocked = Array.from(blockedById.values())
558
1211
  .sort((left, right) => {
1212
+ const mitigationDiff = Number((right.policy_violations || []).length > 0) - Number((left.policy_violations || []).length > 0);
1213
+ if (mitigationDiff !== 0) {
1214
+ return mitigationDiff;
1215
+ }
559
1216
  const riskDiff = riskRank(right.risk) - riskRank(left.risk);
560
1217
  if (riskDiff !== 0) {
561
1218
  return riskDiff;
@@ -571,10 +1228,14 @@ async function evaluateErrorbookReleaseGate(options = {}, dependencies = {}) {
571
1228
  mode: 'errorbook-release-gate',
572
1229
  gate: {
573
1230
  min_risk: minRisk,
574
- include_verified: includeVerified
1231
+ include_verified: includeVerified,
1232
+ mitigation_policy_enforced: true
575
1233
  },
576
1234
  passed: blocked.length === 0,
577
1235
  inspected_count: inspected.length,
1236
+ risk_blocked_count: riskBlocked.length,
1237
+ mitigation_inspected_count: mitigationInspected.length,
1238
+ mitigation_blocked_count: mitigationBlocked.length,
578
1239
  blocked_count: blocked.length,
579
1240
  blocked_entries: blocked
580
1241
  };
@@ -628,6 +1289,15 @@ async function runErrorbookRecordCommand(options = {}, dependencies = {}) {
628
1289
  entry = mergeEntry(existingEntry, normalized);
629
1290
  deduplicated = true;
630
1291
  } else {
1292
+ const temporaryMitigation = normalizeExistingTemporaryMitigation(normalized.temporary_mitigation);
1293
+ const mitigationPayload = temporaryMitigation.enabled
1294
+ ? {
1295
+ ...temporaryMitigation,
1296
+ created_at: nowIso(),
1297
+ updated_at: nowIso(),
1298
+ resolved_at: ''
1299
+ }
1300
+ : { enabled: false };
631
1301
  entry = {
632
1302
  api_version: ERRORBOOK_ENTRY_API_VERSION,
633
1303
  id: createEntryId(),
@@ -643,6 +1313,7 @@ async function runErrorbookRecordCommand(options = {}, dependencies = {}) {
643
1313
  ontology_tags: normalized.ontology_tags,
644
1314
  status: normalized.status,
645
1315
  source: normalized.source,
1316
+ temporary_mitigation: mitigationPayload,
646
1317
  notes: normalized.notes || '',
647
1318
  occurrences: 1
648
1319
  };
@@ -680,6 +1351,154 @@ async function runErrorbookRecordCommand(options = {}, dependencies = {}) {
680
1351
  return result;
681
1352
  }
682
1353
 
1354
+ async function runErrorbookExportCommand(options = {}, dependencies = {}) {
1355
+ const projectPath = dependencies.projectPath || process.cwd();
1356
+ const fileSystem = dependencies.fileSystem || fs;
1357
+ const paths = resolveErrorbookPaths(projectPath);
1358
+ const registryPaths = resolveErrorbookRegistryPaths(projectPath, {
1359
+ exportPath: options.out
1360
+ });
1361
+ const index = await readErrorbookIndex(paths, fileSystem);
1362
+
1363
+ const statuses = normalizeStatusList(options.statuses || options.status || 'promoted', ['promoted']);
1364
+ const minQuality = Number.isFinite(Number(options.minQuality))
1365
+ ? Number(options.minQuality)
1366
+ : 75;
1367
+ const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
1368
+ ? Number(options.limit)
1369
+ : 0;
1370
+
1371
+ const selected = [];
1372
+ for (const summary of index.entries) {
1373
+ const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
1374
+ if (!entry) {
1375
+ continue;
1376
+ }
1377
+ const status = normalizeStatus(entry.status, 'candidate');
1378
+ if (!statuses.includes(status)) {
1379
+ continue;
1380
+ }
1381
+ if (Number(entry.quality_score || 0) < minQuality) {
1382
+ continue;
1383
+ }
1384
+ selected.push({
1385
+ id: entry.id,
1386
+ fingerprint: entry.fingerprint,
1387
+ title: entry.title,
1388
+ symptom: entry.symptom,
1389
+ root_cause: entry.root_cause,
1390
+ fix_actions: normalizeStringList(entry.fix_actions),
1391
+ verification_evidence: normalizeStringList(entry.verification_evidence),
1392
+ tags: normalizeStringList(entry.tags),
1393
+ ontology_tags: normalizeOntologyTags(entry.ontology_tags),
1394
+ status,
1395
+ quality_score: Number(entry.quality_score || 0),
1396
+ updated_at: entry.updated_at,
1397
+ source: entry.source || {},
1398
+ temporary_mitigation: normalizeExistingTemporaryMitigation(entry.temporary_mitigation)
1399
+ });
1400
+ }
1401
+
1402
+ selected.sort((left, right) => {
1403
+ const qualityDiff = Number(right.quality_score || 0) - Number(left.quality_score || 0);
1404
+ if (qualityDiff !== 0) {
1405
+ return qualityDiff;
1406
+ }
1407
+ return `${right.updated_at || ''}`.localeCompare(`${left.updated_at || ''}`);
1408
+ });
1409
+
1410
+ const entries = limit > 0 ? selected.slice(0, limit) : selected;
1411
+ const payload = {
1412
+ api_version: ERRORBOOK_REGISTRY_API_VERSION,
1413
+ generated_at: nowIso(),
1414
+ source: {
1415
+ project: path.basename(projectPath),
1416
+ statuses,
1417
+ min_quality: minQuality
1418
+ },
1419
+ total_entries: entries.length,
1420
+ entries
1421
+ };
1422
+
1423
+ await fileSystem.ensureDir(path.dirname(registryPaths.exportFile));
1424
+ await fileSystem.writeJson(registryPaths.exportFile, payload, { spaces: 2 });
1425
+
1426
+ const result = {
1427
+ mode: 'errorbook-export',
1428
+ out_file: registryPaths.exportFile,
1429
+ statuses,
1430
+ min_quality: minQuality,
1431
+ total_entries: entries.length
1432
+ };
1433
+
1434
+ if (options.json) {
1435
+ console.log(JSON.stringify(result, null, 2));
1436
+ } else if (!options.silent) {
1437
+ console.log(chalk.green('✓ Exported curated errorbook entries'));
1438
+ console.log(chalk.gray(` out: ${registryPaths.exportFile}`));
1439
+ console.log(chalk.gray(` total: ${entries.length}`));
1440
+ console.log(chalk.gray(` statuses: ${statuses.join(', ')}`));
1441
+ }
1442
+
1443
+ return result;
1444
+ }
1445
+
1446
+ async function runErrorbookSyncRegistryCommand(options = {}, dependencies = {}) {
1447
+ const projectPath = dependencies.projectPath || process.cwd();
1448
+ const fileSystem = dependencies.fileSystem || fs;
1449
+ const registryPaths = resolveErrorbookRegistryPaths(projectPath, {
1450
+ configPath: options.config,
1451
+ cachePath: options.cache
1452
+ });
1453
+
1454
+ const config = await readErrorbookRegistryConfig(registryPaths, fileSystem);
1455
+ const sourceOption = normalizeText(options.source);
1456
+ const configuredSource = config.sources.find((item) => item.enabled && item.source);
1457
+ const source = sourceOption || (configuredSource ? configuredSource.source : '');
1458
+ if (!source) {
1459
+ throw new Error('registry source is required (use --source or configure .sce/config/errorbook-registry.json)');
1460
+ }
1461
+ const sourceName = normalizeText(options.sourceName)
1462
+ || (configuredSource ? configuredSource.name : '')
1463
+ || 'registry';
1464
+
1465
+ const payload = await loadRegistryPayload(projectPath, source, fileSystem);
1466
+ const entries = extractRegistryEntries(payload, sourceName);
1467
+ const cachePath = resolveProjectPath(projectPath, options.cache, config.cache_file || DEFAULT_ERRORBOOK_REGISTRY_CACHE);
1468
+ const cachePayload = {
1469
+ api_version: ERRORBOOK_REGISTRY_CACHE_API_VERSION,
1470
+ synced_at: nowIso(),
1471
+ source: {
1472
+ name: sourceName,
1473
+ uri: source
1474
+ },
1475
+ total_entries: entries.length,
1476
+ entries
1477
+ };
1478
+
1479
+ await fileSystem.ensureDir(path.dirname(cachePath));
1480
+ await fileSystem.writeJson(cachePath, cachePayload, { spaces: 2 });
1481
+
1482
+ const result = {
1483
+ mode: 'errorbook-sync-registry',
1484
+ source,
1485
+ source_name: sourceName,
1486
+ cache_file: cachePath,
1487
+ total_entries: entries.length
1488
+ };
1489
+
1490
+ if (options.json) {
1491
+ console.log(JSON.stringify(result, null, 2));
1492
+ } else if (!options.silent) {
1493
+ console.log(chalk.green('✓ Synced external errorbook registry'));
1494
+ console.log(chalk.gray(` source: ${source}`));
1495
+ console.log(chalk.gray(` cache: ${cachePath}`));
1496
+ console.log(chalk.gray(` entries: ${entries.length}`));
1497
+ }
1498
+
1499
+ return result;
1500
+ }
1501
+
683
1502
  async function runErrorbookListCommand(options = {}, dependencies = {}) {
684
1503
  const projectPath = dependencies.projectPath || process.cwd();
685
1504
  const fileSystem = dependencies.fileSystem || fs;
@@ -765,6 +1584,7 @@ async function runErrorbookShowCommand(options = {}, dependencies = {}) {
765
1584
  if (options.json) {
766
1585
  console.log(JSON.stringify(result, null, 2));
767
1586
  } else if (!options.silent) {
1587
+ const mitigation = normalizeExistingTemporaryMitigation(entry.temporary_mitigation);
768
1588
  console.log(chalk.cyan.bold(entry.title));
769
1589
  console.log(chalk.gray(`id: ${entry.id}`));
770
1590
  console.log(chalk.gray(`status: ${entry.status}`));
@@ -775,6 +1595,16 @@ async function runErrorbookShowCommand(options = {}, dependencies = {}) {
775
1595
  console.log(chalk.gray(`fix_actions: ${entry.fix_actions.join(' | ')}`));
776
1596
  console.log(chalk.gray(`verification: ${entry.verification_evidence.join(' | ') || '(none)'}`));
777
1597
  console.log(chalk.gray(`ontology: ${entry.ontology_tags.join(', ') || '(none)'}`));
1598
+ if (mitigation.enabled) {
1599
+ const active = !normalizeText(mitigation.resolved_at);
1600
+ console.log(chalk.gray(`temporary_mitigation: ${active ? 'active' : 'resolved'}`));
1601
+ console.log(chalk.gray(` exit: ${mitigation.exit_criteria || '(none)'}`));
1602
+ console.log(chalk.gray(` cleanup: ${mitigation.cleanup_task || '(none)'}`));
1603
+ console.log(chalk.gray(` deadline: ${mitigation.deadline_at || '(none)'}`));
1604
+ if (mitigation.resolved_at) {
1605
+ console.log(chalk.gray(` resolved_at: ${mitigation.resolved_at}`));
1606
+ }
1607
+ }
778
1608
  }
779
1609
 
780
1610
  return result;
@@ -795,9 +1625,15 @@ async function runErrorbookFindCommand(options = {}, dependencies = {}) {
795
1625
  const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
796
1626
  ? Number(options.limit)
797
1627
  : 10;
798
- const tokens = query.toLowerCase().split(/[\s,;|]+/).map((item) => item.trim()).filter(Boolean);
1628
+ const tokens = tokenizeQueryText(query);
1629
+ const includeRegistry = options.includeRegistry === true;
799
1630
 
800
1631
  const candidates = [];
1632
+ let localMatched = 0;
1633
+ let registryMatched = 0;
1634
+ let registryCacheMatched = 0;
1635
+ let registryRemoteMatched = 0;
1636
+ const registryWarnings = [];
801
1637
  for (const summary of index.entries) {
802
1638
  if (requestedStatus && summary.status !== requestedStatus) {
803
1639
  continue;
@@ -810,8 +1646,10 @@ async function runErrorbookFindCommand(options = {}, dependencies = {}) {
810
1646
  if (matchScore <= 0) {
811
1647
  continue;
812
1648
  }
1649
+ localMatched += 1;
813
1650
  candidates.push({
814
1651
  id: entry.id,
1652
+ entry_source: 'local',
815
1653
  status: entry.status,
816
1654
  quality_score: entry.quality_score,
817
1655
  title: entry.title,
@@ -823,7 +1661,96 @@ async function runErrorbookFindCommand(options = {}, dependencies = {}) {
823
1661
  });
824
1662
  }
825
1663
 
826
- candidates.sort((left, right) => {
1664
+ if (includeRegistry) {
1665
+ const configPaths = resolveErrorbookRegistryPaths(projectPath, {
1666
+ configPath: options.config,
1667
+ cachePath: options.registryCache
1668
+ });
1669
+ const registryConfig = await readErrorbookRegistryConfig(configPaths, fileSystem);
1670
+ const registryMode = normalizeRegistryMode(options.registryMode, registryConfig.search_mode || 'cache');
1671
+ const useCache = registryMode === 'cache' || registryMode === 'hybrid';
1672
+ const useRemote = registryMode === 'remote' || registryMode === 'hybrid';
1673
+
1674
+ if (useRemote) {
1675
+ const configuredSources = Array.isArray(registryConfig.sources)
1676
+ ? registryConfig.sources.filter((item) => item.enabled && item.source)
1677
+ : [];
1678
+ const overrideSource = normalizeText(options.registrySource);
1679
+ const remoteSources = overrideSource
1680
+ ? [normalizeRegistrySource({
1681
+ name: normalizeText(options.registrySourceName) || 'override',
1682
+ source: overrideSource,
1683
+ index_url: normalizeText(options.registryIndex)
1684
+ })]
1685
+ : configuredSources;
1686
+
1687
+ for (const source of remoteSources) {
1688
+ const remoteResult = await searchRegistryRemote({
1689
+ source,
1690
+ query,
1691
+ queryTokens: tokens,
1692
+ requestedStatus,
1693
+ maxShards: options.registryMaxShards,
1694
+ allowRemoteFullscan: options.allowRemoteFullscan === true
1695
+ }, {
1696
+ projectPath,
1697
+ fileSystem
1698
+ });
1699
+ registryRemoteMatched += Number(remoteResult.matched_count || 0);
1700
+ registryMatched += Number(remoteResult.matched_count || 0);
1701
+ if (Array.isArray(remoteResult.warnings)) {
1702
+ registryWarnings.push(...remoteResult.warnings);
1703
+ }
1704
+ if (Array.isArray(remoteResult.candidates)) {
1705
+ candidates.push(...remoteResult.candidates);
1706
+ }
1707
+ }
1708
+ }
1709
+
1710
+ if (useCache) {
1711
+ const cachePath = resolveProjectPath(
1712
+ projectPath,
1713
+ options.registryCache,
1714
+ registryConfig.cache_file || DEFAULT_ERRORBOOK_REGISTRY_CACHE
1715
+ );
1716
+ const registryCache = await loadRegistryCache(projectPath, cachePath, fileSystem);
1717
+ for (const entry of registryCache.entries) {
1718
+ if (requestedStatus && normalizeStatus(entry.status, 'candidate') !== requestedStatus) {
1719
+ continue;
1720
+ }
1721
+ const matchScore = scoreSearchMatch(entry, tokens);
1722
+ if (matchScore <= 0) {
1723
+ continue;
1724
+ }
1725
+ registryMatched += 1;
1726
+ registryCacheMatched += 1;
1727
+ candidates.push({
1728
+ id: entry.id,
1729
+ entry_source: 'registry-cache',
1730
+ status: entry.status,
1731
+ quality_score: entry.quality_score,
1732
+ title: entry.title,
1733
+ fingerprint: entry.fingerprint,
1734
+ tags: normalizeStringList(entry.tags),
1735
+ ontology_tags: normalizeOntologyTags(entry.ontology_tags),
1736
+ match_score: matchScore,
1737
+ updated_at: entry.updated_at
1738
+ });
1739
+ }
1740
+ }
1741
+ }
1742
+
1743
+ const dedupedCandidates = new Map();
1744
+ for (const item of candidates) {
1745
+ const key = normalizeText(item.fingerprint || item.id);
1746
+ const existing = dedupedCandidates.get(key);
1747
+ if (!existing || Number(item.match_score || 0) >= Number(existing.match_score || 0)) {
1748
+ dedupedCandidates.set(key, item);
1749
+ }
1750
+ }
1751
+ const sortedCandidates = Array.from(dedupedCandidates.values());
1752
+
1753
+ sortedCandidates.sort((left, right) => {
827
1754
  const scoreDiff = Number(right.match_score) - Number(left.match_score);
828
1755
  if (scoreDiff !== 0) {
829
1756
  return scoreDiff;
@@ -834,8 +1761,16 @@ async function runErrorbookFindCommand(options = {}, dependencies = {}) {
834
1761
  const result = {
835
1762
  mode: 'errorbook-find',
836
1763
  query,
837
- total_results: candidates.length,
838
- entries: candidates.slice(0, limit)
1764
+ include_registry: includeRegistry,
1765
+ source_breakdown: {
1766
+ local_results: localMatched,
1767
+ registry_results: registryMatched,
1768
+ registry_cache_results: registryCacheMatched,
1769
+ registry_remote_results: registryRemoteMatched
1770
+ },
1771
+ warnings: normalizeStringList(registryWarnings),
1772
+ total_results: sortedCandidates.length,
1773
+ entries: sortedCandidates.slice(0, limit)
839
1774
  };
840
1775
 
841
1776
  if (options.json) {
@@ -880,6 +1815,7 @@ async function runErrorbookPromoteCommand(options = {}, dependencies = {}) {
880
1815
 
881
1816
  entry.status = 'promoted';
882
1817
  entry.promoted_at = nowIso();
1818
+ entry.temporary_mitigation = markTemporaryMitigationResolved(entry, entry.promoted_at);
883
1819
  entry.updated_at = entry.promoted_at;
884
1820
  await writeErrorbookEntry(paths, entry, fileSystem);
885
1821
 
@@ -971,6 +1907,7 @@ async function runErrorbookDeprecateCommand(options = {}, dependencies = {}) {
971
1907
 
972
1908
  entry.status = 'deprecated';
973
1909
  entry.updated_at = nowIso();
1910
+ entry.temporary_mitigation = markTemporaryMitigationResolved(entry, entry.updated_at);
974
1911
  entry.deprecated_at = entry.updated_at;
975
1912
  entry.deprecation = {
976
1913
  reason,
@@ -1109,6 +2046,11 @@ function registerErrorbookCommands(program) {
1109
2046
  .option('--tags <csv>', 'Tags, comma-separated')
1110
2047
  .option('--ontology <csv>', `Ontology focus tags (${ERRORBOOK_ONTOLOGY_TAGS.join(', ')})`)
1111
2048
  .option('--status <status>', 'candidate|verified', 'candidate')
2049
+ .option('--temporary-mitigation', 'Mark this entry as temporary fallback/mitigation (requires governance fields)')
2050
+ .option('--mitigation-reason <text>', 'Temporary mitigation reason/context')
2051
+ .option('--mitigation-exit <text>', 'Exit criteria that define mitigation cleanup completion')
2052
+ .option('--mitigation-cleanup <text>', 'Cleanup task/spec to remove temporary mitigation')
2053
+ .option('--mitigation-deadline <iso>', 'Deadline for mitigation cleanup (ISO datetime)')
1112
2054
  .option('--fingerprint <text>', 'Custom deduplication fingerprint')
1113
2055
  .option('--from <path>', 'Load payload from JSON file')
1114
2056
  .option('--spec <spec>', 'Related spec id/name')
@@ -1156,6 +2098,15 @@ function registerErrorbookCommands(program) {
1156
2098
  .requiredOption('--query <text>', 'Search query')
1157
2099
  .option('--status <status>', `Filter by status (${ERRORBOOK_STATUSES.join(', ')})`)
1158
2100
  .option('--limit <n>', 'Maximum entries returned', parseInt, 10)
2101
+ .option('--include-registry', 'Include external registry entries in search')
2102
+ .option('--registry-mode <mode>', 'Registry lookup mode (cache|remote|hybrid)')
2103
+ .option('--registry-source <url-or-path>', 'Override registry source (for remote mode)')
2104
+ .option('--registry-source-name <name>', 'Override registry source label')
2105
+ .option('--registry-index <url-or-path>', 'Override registry index source (for remote mode)')
2106
+ .option('--registry-max-shards <n>', 'Max remote shards to fetch per query', parseInt, 8)
2107
+ .option('--allow-remote-fullscan', 'Allow remote full-source fallback when index is unavailable')
2108
+ .option('--registry-cache <path>', `Registry cache path (default: ${DEFAULT_ERRORBOOK_REGISTRY_CACHE})`)
2109
+ .option('--config <path>', `Registry config path (default: ${DEFAULT_ERRORBOOK_REGISTRY_CONFIG})`)
1159
2110
  .option('--json', 'Emit machine-readable JSON')
1160
2111
  .action(async (options) => {
1161
2112
  try {
@@ -1165,6 +2116,38 @@ function registerErrorbookCommands(program) {
1165
2116
  }
1166
2117
  });
1167
2118
 
2119
+ errorbook
2120
+ .command('export')
2121
+ .description('Export curated local entries for external registry publication')
2122
+ .option('--status <csv>', 'Statuses to include (csv, default: promoted)', 'promoted')
2123
+ .option('--min-quality <n>', 'Minimum quality score (default: 75)', parseInt)
2124
+ .option('--limit <n>', 'Maximum entries exported', parseInt)
2125
+ .option('--out <path>', `Output file (default: ${DEFAULT_ERRORBOOK_REGISTRY_EXPORT})`)
2126
+ .option('--json', 'Emit machine-readable JSON')
2127
+ .action(async (options) => {
2128
+ try {
2129
+ await runErrorbookExportCommand(options);
2130
+ } catch (error) {
2131
+ emitCommandError(error, options.json);
2132
+ }
2133
+ });
2134
+
2135
+ errorbook
2136
+ .command('sync-registry')
2137
+ .description('Sync external errorbook registry to local cache')
2138
+ .option('--source <url-or-path>', 'Registry source JSON (https://... or local file)')
2139
+ .option('--source-name <name>', 'Registry source name label')
2140
+ .option('--cache <path>', `Registry cache output path (default: ${DEFAULT_ERRORBOOK_REGISTRY_CACHE})`)
2141
+ .option('--config <path>', `Registry config path (default: ${DEFAULT_ERRORBOOK_REGISTRY_CONFIG})`)
2142
+ .option('--json', 'Emit machine-readable JSON')
2143
+ .action(async (options) => {
2144
+ try {
2145
+ await runErrorbookSyncRegistryCommand(options);
2146
+ } catch (error) {
2147
+ emitCommandError(error, options.json);
2148
+ }
2149
+ });
2150
+
1168
2151
  errorbook
1169
2152
  .command('promote <id>')
1170
2153
  .description('Promote entry after strict quality gate')
@@ -1179,7 +2162,7 @@ function registerErrorbookCommands(program) {
1179
2162
 
1180
2163
  errorbook
1181
2164
  .command('release-gate')
1182
- .description('Block release on unresolved high-risk candidate entries')
2165
+ .description('Block release on unresolved high-risk entries and temporary-mitigation policy violations')
1183
2166
  .option('--min-risk <level>', 'Risk threshold (low|medium|high)', 'high')
1184
2167
  .option('--include-verified', 'Also inspect verified (non-promoted) entries')
1185
2168
  .option('--fail-on-block', 'Exit with error when gate is blocked')
@@ -1224,15 +2207,22 @@ module.exports = {
1224
2207
  ERRORBOOK_STATUSES,
1225
2208
  ERRORBOOK_ONTOLOGY_TAGS,
1226
2209
  ERRORBOOK_RISK_LEVELS,
2210
+ TEMPORARY_MITIGATION_TAG,
2211
+ DEFAULT_ERRORBOOK_REGISTRY_CONFIG,
2212
+ DEFAULT_ERRORBOOK_REGISTRY_CACHE,
2213
+ DEFAULT_ERRORBOOK_REGISTRY_EXPORT,
1227
2214
  HIGH_RISK_SIGNAL_TAGS,
1228
2215
  DEFAULT_PROMOTE_MIN_QUALITY,
1229
2216
  resolveErrorbookPaths,
2217
+ resolveErrorbookRegistryPaths,
1230
2218
  normalizeOntologyTags,
1231
2219
  normalizeRecordPayload,
1232
2220
  scoreQuality,
1233
2221
  evaluateEntryRisk,
1234
2222
  evaluateErrorbookReleaseGate,
1235
2223
  runErrorbookRecordCommand,
2224
+ runErrorbookExportCommand,
2225
+ runErrorbookSyncRegistryCommand,
1236
2226
  runErrorbookListCommand,
1237
2227
  runErrorbookShowCommand,
1238
2228
  runErrorbookFindCommand,