scene-capability-engine 3.3.26 → 3.4.6

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';
@@ -46,6 +48,7 @@ const ONTOLOGY_TAG_ALIASES = Object.freeze({
46
48
  action_chain: 'execution_flow'
47
49
  });
48
50
  const DEFAULT_PROMOTE_MIN_QUALITY = 75;
51
+ const DEFAULT_RELEASE_GATE_MIN_QUALITY = 70;
49
52
  const ERRORBOOK_RISK_LEVELS = Object.freeze(['low', 'medium', 'high']);
50
53
  const DEBUG_EVIDENCE_TAGS = Object.freeze([
51
54
  'debug-evidence',
@@ -65,11 +68,16 @@ const HIGH_RISK_SIGNAL_TAGS = Object.freeze([
65
68
 
66
69
  function resolveErrorbookPaths(projectPath = process.cwd()) {
67
70
  const baseDir = path.join(projectPath, '.sce', 'errorbook');
71
+ const stagingDir = path.join(baseDir, 'staging');
68
72
  return {
69
73
  projectPath,
70
74
  baseDir,
71
75
  entriesDir: path.join(baseDir, 'entries'),
72
- indexFile: path.join(baseDir, 'index.json')
76
+ indexFile: path.join(baseDir, 'index.json'),
77
+ stagingDir,
78
+ incidentsDir: path.join(stagingDir, 'incidents'),
79
+ resolvedDir: path.join(stagingDir, 'resolved'),
80
+ incidentIndexFile: path.join(stagingDir, 'index.json')
73
81
  };
74
82
  }
75
83
 
@@ -417,6 +425,266 @@ async function writeErrorbookEntry(paths, entry, fileSystem = fs) {
417
425
  return entryPath;
418
426
  }
419
427
 
428
+ function buildDefaultIncidentIndex() {
429
+ return {
430
+ api_version: ERRORBOOK_INCIDENT_INDEX_API_VERSION,
431
+ updated_at: nowIso(),
432
+ total_incidents: 0,
433
+ incidents: []
434
+ };
435
+ }
436
+
437
+ function normalizeIncidentState(value, fallback = 'open') {
438
+ const normalized = normalizeText(value).toLowerCase();
439
+ if (!normalized) {
440
+ return fallback;
441
+ }
442
+ if (normalized === 'open' || normalized === 'resolved') {
443
+ return normalized;
444
+ }
445
+ return fallback;
446
+ }
447
+
448
+ function shouldResolveIncidentByStatus(status = '') {
449
+ const normalized = normalizeStatus(status, 'candidate');
450
+ return normalized === 'verified' || normalized === 'promoted' || normalized === 'deprecated';
451
+ }
452
+
453
+ function createIncidentId() {
454
+ return `ebi-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
455
+ }
456
+
457
+ function createIncidentAttemptId() {
458
+ return `attempt-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
459
+ }
460
+
461
+ function buildIncidentFilePath(paths, incidentId) {
462
+ return path.join(paths.incidentsDir, `${incidentId}.json`);
463
+ }
464
+
465
+ function buildIncidentResolvedSnapshotPath(paths, incidentId) {
466
+ return path.join(paths.resolvedDir, `${incidentId}.json`);
467
+ }
468
+
469
+ async function ensureIncidentStorage(paths, fileSystem = fs) {
470
+ await fileSystem.ensureDir(paths.incidentsDir);
471
+ await fileSystem.ensureDir(paths.resolvedDir);
472
+ if (!await fileSystem.pathExists(paths.incidentIndexFile)) {
473
+ await fileSystem.writeJson(paths.incidentIndexFile, buildDefaultIncidentIndex(), { spaces: 2 });
474
+ }
475
+ }
476
+
477
+ async function readIncidentIndex(paths, fileSystem = fs) {
478
+ await ensureIncidentStorage(paths, fileSystem);
479
+ const payload = await fileSystem.readJson(paths.incidentIndexFile).catch(() => null);
480
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.incidents)) {
481
+ return buildDefaultIncidentIndex();
482
+ }
483
+ return {
484
+ api_version: payload.api_version || ERRORBOOK_INCIDENT_INDEX_API_VERSION,
485
+ updated_at: normalizeText(payload.updated_at) || nowIso(),
486
+ total_incidents: Number.isInteger(payload.total_incidents) ? payload.total_incidents : payload.incidents.length,
487
+ incidents: payload.incidents
488
+ };
489
+ }
490
+
491
+ async function writeIncidentIndex(paths, index, fileSystem = fs) {
492
+ const payload = {
493
+ api_version: ERRORBOOK_INCIDENT_INDEX_API_VERSION,
494
+ updated_at: nowIso(),
495
+ incidents: Array.isArray(index.incidents) ? index.incidents : []
496
+ };
497
+ payload.total_incidents = payload.incidents.length;
498
+ await fileSystem.ensureDir(path.dirname(paths.incidentIndexFile));
499
+ await fileSystem.writeJson(paths.incidentIndexFile, payload, { spaces: 2 });
500
+ return payload;
501
+ }
502
+
503
+ async function readIncident(paths, incidentId, fileSystem = fs) {
504
+ const filePath = buildIncidentFilePath(paths, incidentId);
505
+ if (!await fileSystem.pathExists(filePath)) {
506
+ return null;
507
+ }
508
+ return fileSystem.readJson(filePath);
509
+ }
510
+
511
+ async function writeIncident(paths, incident, fileSystem = fs) {
512
+ const filePath = buildIncidentFilePath(paths, incident.id);
513
+ await fileSystem.ensureDir(path.dirname(filePath));
514
+ await fileSystem.writeJson(filePath, incident, { spaces: 2 });
515
+ return filePath;
516
+ }
517
+
518
+ function createIncidentAttemptSignature(payload = {}) {
519
+ const attemptContract = payload && payload.attempt_contract && typeof payload.attempt_contract === 'object'
520
+ ? payload.attempt_contract
521
+ : {};
522
+ const basis = JSON.stringify({
523
+ root_cause: normalizeText(payload.root_cause),
524
+ fix_actions: normalizeStringList(payload.fix_actions),
525
+ verification_evidence: normalizeStringList(payload.verification_evidence),
526
+ notes: normalizeText(payload.notes),
527
+ attempt_contract: {
528
+ hypothesis: normalizeText(attemptContract.hypothesis),
529
+ change_points: normalizeStringList(attemptContract.change_points),
530
+ verification_result: normalizeText(attemptContract.verification_result),
531
+ rollback_point: normalizeText(attemptContract.rollback_point),
532
+ conclusion: normalizeText(attemptContract.conclusion)
533
+ },
534
+ source: {
535
+ spec: normalizeText(payload?.source?.spec),
536
+ files: normalizeStringList(payload?.source?.files),
537
+ tests: normalizeStringList(payload?.source?.tests)
538
+ }
539
+ });
540
+ return crypto.createHash('sha1').update(basis).digest('hex').slice(0, 16);
541
+ }
542
+
543
+ function createIncidentSummaryFromIncident(incident = {}) {
544
+ return {
545
+ id: incident.id,
546
+ fingerprint: normalizeText(incident.fingerprint),
547
+ title: normalizeText(incident.title),
548
+ symptom: normalizeText(incident.symptom),
549
+ state: normalizeIncidentState(incident.state, 'open'),
550
+ attempt_count: Number(incident.attempt_count || 0),
551
+ created_at: normalizeText(incident.created_at),
552
+ updated_at: normalizeText(incident.updated_at),
553
+ last_attempt_at: normalizeText(incident.last_attempt_at),
554
+ resolved_at: normalizeText(incident.resolved_at),
555
+ linked_entry_id: normalizeText(incident?.resolution?.entry_id || '')
556
+ };
557
+ }
558
+
559
+ async function syncIncidentLoopForRecord(paths, payload = {}, entry = {}, options = {}, fileSystem = fs) {
560
+ await ensureIncidentStorage(paths, fileSystem);
561
+ const incidentIndex = await readIncidentIndex(paths, fileSystem);
562
+ const fingerprint = normalizeText(payload.fingerprint || entry.fingerprint);
563
+ const title = normalizeText(payload.title || entry.title);
564
+ const symptom = normalizeText(payload.symptom || entry.symptom);
565
+ const currentTime = normalizeText(options.nowIso) || nowIso();
566
+
567
+ let incidentSummary = incidentIndex.incidents.find((item) => item.fingerprint === fingerprint) || null;
568
+ let incident = incidentSummary ? await readIncident(paths, incidentSummary.id, fileSystem) : null;
569
+
570
+ if (!incident) {
571
+ const incidentId = incidentSummary ? incidentSummary.id : createIncidentId();
572
+ incident = {
573
+ api_version: ERRORBOOK_INCIDENT_API_VERSION,
574
+ id: incidentId,
575
+ fingerprint,
576
+ title,
577
+ symptom,
578
+ state: 'open',
579
+ created_at: currentTime,
580
+ updated_at: currentTime,
581
+ last_attempt_at: '',
582
+ resolved_at: '',
583
+ attempt_count: 0,
584
+ attempts: [],
585
+ resolution: {
586
+ entry_id: '',
587
+ status: '',
588
+ quality_score: 0,
589
+ resolved_at: ''
590
+ },
591
+ tags: [],
592
+ ontology_tags: []
593
+ };
594
+ }
595
+
596
+ const existingAttempts = Array.isArray(incident.attempts) ? incident.attempts : [];
597
+ const attemptNo = existingAttempts.length + 1;
598
+ const attemptSignature = createIncidentAttemptSignature(payload);
599
+ const duplicateOf = existingAttempts.find((item) => normalizeText(item.signature) === attemptSignature);
600
+ const attempt = {
601
+ id: createIncidentAttemptId(),
602
+ attempt_no: attemptNo,
603
+ recorded_at: currentTime,
604
+ signature: attemptSignature,
605
+ duplicate_of_attempt_no: duplicateOf ? Number(duplicateOf.attempt_no || 0) : 0,
606
+ entry_status: normalizeStatus(entry.status, 'candidate'),
607
+ quality_score: Number(entry.quality_score || scoreQuality(entry)),
608
+ root_cause: normalizeText(payload.root_cause || entry.root_cause),
609
+ fix_actions: normalizeStringList(payload.fix_actions || entry.fix_actions),
610
+ verification_evidence: normalizeStringList(payload.verification_evidence || entry.verification_evidence),
611
+ tags: normalizeStringList(payload.tags || entry.tags),
612
+ ontology_tags: normalizeOntologyTags(payload.ontology_tags || entry.ontology_tags),
613
+ notes: normalizeText(payload.notes || entry.notes),
614
+ attempt_contract: {
615
+ hypothesis: normalizeText(payload?.attempt_contract?.hypothesis || entry?.attempt_contract?.hypothesis),
616
+ change_points: normalizeStringList(payload?.attempt_contract?.change_points || entry?.attempt_contract?.change_points),
617
+ verification_result: normalizeText(
618
+ payload?.attempt_contract?.verification_result || entry?.attempt_contract?.verification_result
619
+ ),
620
+ rollback_point: normalizeText(payload?.attempt_contract?.rollback_point || entry?.attempt_contract?.rollback_point),
621
+ conclusion: normalizeText(payload?.attempt_contract?.conclusion || entry?.attempt_contract?.conclusion)
622
+ },
623
+ source: {
624
+ spec: normalizeText(payload?.source?.spec || entry?.source?.spec),
625
+ files: normalizeStringList(payload?.source?.files || entry?.source?.files),
626
+ tests: normalizeStringList(payload?.source?.tests || entry?.source?.tests)
627
+ }
628
+ };
629
+
630
+ incident.attempts = [...existingAttempts, attempt];
631
+ incident.attempt_count = incident.attempts.length;
632
+ incident.title = title || incident.title;
633
+ incident.symptom = symptom || incident.symptom;
634
+ incident.tags = normalizeStringList(incident.tags, attempt.tags);
635
+ incident.ontology_tags = normalizeOntologyTags(incident.ontology_tags, attempt.ontology_tags);
636
+ incident.last_attempt_at = currentTime;
637
+ incident.updated_at = currentTime;
638
+
639
+ const resolveIncident = shouldResolveIncidentByStatus(entry.status);
640
+ if (resolveIncident) {
641
+ incident.state = 'resolved';
642
+ incident.resolved_at = currentTime;
643
+ incident.resolution = {
644
+ entry_id: normalizeText(entry.id),
645
+ status: normalizeStatus(entry.status, 'candidate'),
646
+ quality_score: Number(entry.quality_score || scoreQuality(entry)),
647
+ resolved_at: currentTime
648
+ };
649
+ } else {
650
+ incident.state = 'open';
651
+ incident.resolved_at = '';
652
+ incident.resolution = {
653
+ entry_id: '',
654
+ status: '',
655
+ quality_score: 0,
656
+ resolved_at: ''
657
+ };
658
+ }
659
+
660
+ await writeIncident(paths, incident, fileSystem);
661
+ if (incident.state === 'resolved') {
662
+ const resolvedSnapshotPath = buildIncidentResolvedSnapshotPath(paths, incident.id);
663
+ await fileSystem.ensureDir(path.dirname(resolvedSnapshotPath));
664
+ await fileSystem.writeJson(resolvedSnapshotPath, incident, { spaces: 2 });
665
+ }
666
+
667
+ const summary = createIncidentSummaryFromIncident(incident);
668
+ const summaryIndex = incidentIndex.incidents.findIndex((item) => item.id === summary.id);
669
+ if (summaryIndex >= 0) {
670
+ incidentIndex.incidents[summaryIndex] = summary;
671
+ } else {
672
+ incidentIndex.incidents.push(summary);
673
+ }
674
+ incidentIndex.incidents.sort((left, right) => `${right.updated_at || ''}`.localeCompare(`${left.updated_at || ''}`));
675
+ await writeIncidentIndex(paths, incidentIndex, fileSystem);
676
+
677
+ return {
678
+ incident: summary,
679
+ latest_attempt: {
680
+ id: attempt.id,
681
+ attempt_no: attempt.attempt_no,
682
+ duplicate_of_attempt_no: attempt.duplicate_of_attempt_no,
683
+ signature: attempt.signature
684
+ }
685
+ };
686
+ }
687
+
420
688
  function scoreQuality(entry = {}) {
421
689
  let score = 0;
422
690
 
@@ -447,6 +715,19 @@ function scoreQuality(entry = {}) {
447
715
  if (normalizeText(entry.symptom).length >= 24 && normalizeText(entry.root_cause).length >= 24) {
448
716
  score += 2;
449
717
  }
718
+ const attemptContract = entry && typeof entry.attempt_contract === 'object'
719
+ ? entry.attempt_contract
720
+ : {};
721
+ const attemptContractComplete = Boolean(
722
+ normalizeText(attemptContract.hypothesis)
723
+ && normalizeStringList(attemptContract.change_points).length > 0
724
+ && normalizeText(attemptContract.verification_result)
725
+ && normalizeText(attemptContract.rollback_point)
726
+ && normalizeText(attemptContract.conclusion)
727
+ );
728
+ if (attemptContractComplete) {
729
+ score += 5;
730
+ }
450
731
 
451
732
  return Math.max(0, Math.min(100, score));
452
733
  }
@@ -464,6 +745,24 @@ function validateRecordPayload(payload) {
464
745
  if (!Array.isArray(payload.fix_actions) || payload.fix_actions.length === 0) {
465
746
  throw new Error('at least one --fix-action is required');
466
747
  }
748
+ const attemptContract = payload.attempt_contract && typeof payload.attempt_contract === 'object'
749
+ ? payload.attempt_contract
750
+ : {};
751
+ if (!normalizeText(attemptContract.hypothesis)) {
752
+ throw new Error('attempt contract requires hypothesis');
753
+ }
754
+ if (!Array.isArray(attemptContract.change_points) || attemptContract.change_points.length === 0) {
755
+ throw new Error('attempt contract requires change_points');
756
+ }
757
+ if (!normalizeText(attemptContract.verification_result)) {
758
+ throw new Error('attempt contract requires verification_result');
759
+ }
760
+ if (!normalizeText(attemptContract.rollback_point)) {
761
+ throw new Error('attempt contract requires rollback_point');
762
+ }
763
+ if (!normalizeText(attemptContract.conclusion)) {
764
+ throw new Error('attempt contract requires conclusion');
765
+ }
467
766
 
468
767
  const status = normalizeStatus(payload.status, 'candidate');
469
768
  if (status === 'promoted') {
@@ -559,6 +858,42 @@ function normalizeRecordPayload(options = {}, fromFilePayload = {}) {
559
858
  })
560
859
  };
561
860
 
861
+ const attemptContractFromFile = fromFilePayload && typeof fromFilePayload.attempt_contract === 'object'
862
+ ? fromFilePayload.attempt_contract
863
+ : {};
864
+ payload.attempt_contract = {
865
+ hypothesis: normalizeText(
866
+ options.attemptHypothesis
867
+ || attemptContractFromFile.hypothesis
868
+ || payload.root_cause
869
+ ),
870
+ change_points: normalizeStringList(
871
+ options.attemptChangePoints,
872
+ attemptContractFromFile.change_points,
873
+ attemptContractFromFile.changePoints,
874
+ payload.fix_actions
875
+ ),
876
+ verification_result: normalizeText(
877
+ options.attemptVerificationResult
878
+ || attemptContractFromFile.verification_result
879
+ || attemptContractFromFile.verificationResult
880
+ || (payload.verification_evidence[0] || '')
881
+ || 'pending-verification'
882
+ ),
883
+ rollback_point: normalizeText(
884
+ options.attemptRollbackPoint
885
+ || attemptContractFromFile.rollback_point
886
+ || attemptContractFromFile.rollbackPoint
887
+ || 'not-required'
888
+ ),
889
+ conclusion: normalizeText(
890
+ options.attemptConclusion
891
+ || attemptContractFromFile.conclusion
892
+ || payload.notes
893
+ || payload.root_cause
894
+ )
895
+ };
896
+
562
897
  return payload;
563
898
  }
564
899
 
@@ -624,6 +959,30 @@ function mergeEntry(existingEntry, incomingPayload) {
624
959
  ontology_tags: normalizeOntologyTags(existingEntry.ontology_tags, incomingPayload.ontology_tags),
625
960
  status: selectStatus(existingEntry.status, incomingPayload.status),
626
961
  notes: normalizeText(incomingPayload.notes) || existingEntry.notes || '',
962
+ attempt_contract: {
963
+ hypothesis: normalizeText(incomingPayload?.attempt_contract?.hypothesis)
964
+ || normalizeText(existingEntry?.attempt_contract?.hypothesis)
965
+ || normalizeText(incomingPayload.root_cause)
966
+ || normalizeText(existingEntry.root_cause),
967
+ change_points: normalizeStringList(
968
+ existingEntry?.attempt_contract?.change_points,
969
+ incomingPayload?.attempt_contract?.change_points,
970
+ incomingPayload.fix_actions
971
+ ),
972
+ verification_result: normalizeText(incomingPayload?.attempt_contract?.verification_result)
973
+ || normalizeText(existingEntry?.attempt_contract?.verification_result)
974
+ || normalizeStringList(incomingPayload.verification_evidence, existingEntry.verification_evidence)[0]
975
+ || '',
976
+ rollback_point: normalizeText(incomingPayload?.attempt_contract?.rollback_point)
977
+ || normalizeText(existingEntry?.attempt_contract?.rollback_point)
978
+ || 'not-required',
979
+ conclusion: normalizeText(incomingPayload?.attempt_contract?.conclusion)
980
+ || normalizeText(existingEntry?.attempt_contract?.conclusion)
981
+ || normalizeText(incomingPayload.notes)
982
+ || normalizeText(existingEntry.notes)
983
+ || normalizeText(incomingPayload.root_cause)
984
+ || normalizeText(existingEntry.root_cause)
985
+ },
627
986
  source: {
628
987
  spec: normalizeText(incomingPayload?.source?.spec) || normalizeText(existingEntry?.source?.spec),
629
988
  files: normalizeStringList(existingEntry?.source?.files, incomingPayload?.source?.files),
@@ -1147,7 +1506,7 @@ function evaluateEntryRisk(entry = {}) {
1147
1506
  if (hasHighRiskTag) {
1148
1507
  return 'high';
1149
1508
  }
1150
- if (status === 'candidate' && qualityScore >= 85) {
1509
+ if (status === 'candidate' && qualityScore > 85) {
1151
1510
  return 'high';
1152
1511
  }
1153
1512
  if (status === 'candidate' && qualityScore >= 75 && ontologyTags.includes('decision_policy')) {
@@ -1156,7 +1515,7 @@ function evaluateEntryRisk(entry = {}) {
1156
1515
  if (status === 'candidate') {
1157
1516
  return 'medium';
1158
1517
  }
1159
- if (qualityScore >= 85 && ontologyTags.includes('decision_policy')) {
1518
+ if (qualityScore > 85 && ontologyTags.includes('decision_policy')) {
1160
1519
  return 'high';
1161
1520
  }
1162
1521
  return 'medium';
@@ -1169,6 +1528,9 @@ async function evaluateErrorbookReleaseGate(options = {}, dependencies = {}) {
1169
1528
  const index = await readErrorbookIndex(paths, fileSystem);
1170
1529
  const minRisk = normalizeRiskLevel(options.minRisk || options.min_risk || 'high', 'high');
1171
1530
  const includeVerified = options.includeVerified === true;
1531
+ const minQuality = Number.isFinite(Number(options.minQuality || options.min_quality))
1532
+ ? Math.max(0, Math.min(100, Number(options.minQuality || options.min_quality)))
1533
+ : DEFAULT_RELEASE_GATE_MIN_QUALITY;
1172
1534
 
1173
1535
  const inspected = [];
1174
1536
  const mitigationInspected = [];
@@ -1226,6 +1588,14 @@ async function evaluateErrorbookReleaseGate(options = {}, dependencies = {}) {
1226
1588
  block_reasons: ['risk_threshold']
1227
1589
  }));
1228
1590
 
1591
+ const curationBlocked = inspected
1592
+ .filter((item) => item.status === 'verified' && Number(item.quality_score || 0) < minQuality)
1593
+ .map((item) => ({
1594
+ ...item,
1595
+ block_reasons: ['curation_quality'],
1596
+ policy_violations: [`quality_score<${minQuality}`]
1597
+ }));
1598
+
1229
1599
  const blockedById = new Map();
1230
1600
  for (const item of riskBlocked) {
1231
1601
  blockedById.set(item.id, {
@@ -1249,6 +1619,19 @@ async function evaluateErrorbookReleaseGate(options = {}, dependencies = {}) {
1249
1619
  }
1250
1620
  blockedById.set(existing.id, existing);
1251
1621
  }
1622
+ for (const item of curationBlocked) {
1623
+ const existing = blockedById.get(item.id);
1624
+ if (!existing) {
1625
+ blockedById.set(item.id, {
1626
+ ...item,
1627
+ policy_violations: normalizeStringList(item.policy_violations)
1628
+ });
1629
+ continue;
1630
+ }
1631
+ existing.block_reasons = normalizeStringList(existing.block_reasons, item.block_reasons);
1632
+ existing.policy_violations = normalizeStringList(existing.policy_violations, item.policy_violations);
1633
+ blockedById.set(existing.id, existing);
1634
+ }
1252
1635
 
1253
1636
  const blocked = Array.from(blockedById.values())
1254
1637
  .sort((left, right) => {
@@ -1271,12 +1654,14 @@ async function evaluateErrorbookReleaseGate(options = {}, dependencies = {}) {
1271
1654
  mode: 'errorbook-release-gate',
1272
1655
  gate: {
1273
1656
  min_risk: minRisk,
1657
+ min_quality: minQuality,
1274
1658
  include_verified: includeVerified,
1275
1659
  mitigation_policy_enforced: true
1276
1660
  },
1277
1661
  passed: blocked.length === 0,
1278
1662
  inspected_count: inspected.length,
1279
1663
  risk_blocked_count: riskBlocked.length,
1664
+ curation_blocked_count: curationBlocked.length,
1280
1665
  mitigation_inspected_count: mitigationInspected.length,
1281
1666
  mitigation_blocked_count: mitigationBlocked.length,
1282
1667
  blocked_count: blocked.length,
@@ -1361,6 +1746,7 @@ async function runErrorbookRecordCommand(options = {}, dependencies = {}) {
1361
1746
  source: normalized.source,
1362
1747
  temporary_mitigation: mitigationPayload,
1363
1748
  notes: normalized.notes || '',
1749
+ attempt_contract: normalized.attempt_contract,
1364
1750
  occurrences: 1
1365
1751
  };
1366
1752
  entry.quality_score = scoreQuality(entry);
@@ -1380,12 +1766,16 @@ async function runErrorbookRecordCommand(options = {}, dependencies = {}) {
1380
1766
  }
1381
1767
  index.entries.sort((left, right) => `${right.updated_at}`.localeCompare(`${left.updated_at}`));
1382
1768
  await writeErrorbookIndex(paths, index, fileSystem);
1769
+ const incidentLoop = await syncIncidentLoopForRecord(paths, normalized, entry, {
1770
+ nowIso: entry.updated_at
1771
+ }, fileSystem);
1383
1772
 
1384
1773
  const result = {
1385
1774
  mode: 'errorbook-record',
1386
1775
  created,
1387
1776
  deduplicated,
1388
- entry
1777
+ entry,
1778
+ incident_loop: incidentLoop
1389
1779
  };
1390
1780
 
1391
1781
  if (options.json) {
@@ -1735,6 +2125,127 @@ async function runErrorbookRegistryHealthCommand(options = {}, dependencies = {}
1735
2125
  return result;
1736
2126
  }
1737
2127
 
2128
+ function findIncidentSummaryById(index, id) {
2129
+ const normalized = normalizeText(id);
2130
+ if (!normalized) {
2131
+ return null;
2132
+ }
2133
+ const exact = index.incidents.find((item) => item.id === normalized);
2134
+ if (exact) {
2135
+ return exact;
2136
+ }
2137
+ const startsWith = index.incidents.filter((item) => item.id.startsWith(normalized));
2138
+ if (startsWith.length === 1) {
2139
+ return startsWith[0];
2140
+ }
2141
+ if (startsWith.length > 1) {
2142
+ throw new Error(`incident id prefix "${normalized}" is ambiguous (${startsWith.length} matches)`);
2143
+ }
2144
+ return null;
2145
+ }
2146
+
2147
+ async function runErrorbookIncidentListCommand(options = {}, dependencies = {}) {
2148
+ const projectPath = dependencies.projectPath || process.cwd();
2149
+ const fileSystem = dependencies.fileSystem || fs;
2150
+ const paths = resolveErrorbookPaths(projectPath);
2151
+ const index = await readIncidentIndex(paths, fileSystem);
2152
+
2153
+ const stateFilter = normalizeText(options.state).toLowerCase();
2154
+ if (stateFilter && !['open', 'resolved'].includes(stateFilter)) {
2155
+ throw new Error('incident state must be one of: open, resolved');
2156
+ }
2157
+ const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
2158
+ ? Number(options.limit)
2159
+ : 20;
2160
+
2161
+ let incidents = [...index.incidents];
2162
+ if (stateFilter) {
2163
+ incidents = incidents.filter((item) => normalizeIncidentState(item.state) === stateFilter);
2164
+ }
2165
+ incidents.sort((left, right) => `${right.updated_at || ''}`.localeCompare(`${left.updated_at || ''}`));
2166
+
2167
+ const result = {
2168
+ mode: 'errorbook-incident-list',
2169
+ total_incidents: index.incidents.length,
2170
+ total_results: incidents.length,
2171
+ incidents: incidents.slice(0, limit)
2172
+ };
2173
+
2174
+ if (options.json) {
2175
+ console.log(JSON.stringify(result, null, 2));
2176
+ } else if (!options.silent) {
2177
+ if (result.incidents.length === 0) {
2178
+ console.log(chalk.gray('No staging incidents found'));
2179
+ } else {
2180
+ const table = new Table({
2181
+ head: ['ID', 'State', 'Attempts', 'Title', 'Updated'].map((item) => chalk.cyan(item)),
2182
+ colWidths: [20, 12, 10, 56, 24]
2183
+ });
2184
+ result.incidents.forEach((incident) => {
2185
+ table.push([
2186
+ incident.id,
2187
+ incident.state,
2188
+ Number(incident.attempt_count || 0),
2189
+ normalizeText(incident.title).length > 52
2190
+ ? `${normalizeText(incident.title).slice(0, 52)}...`
2191
+ : normalizeText(incident.title),
2192
+ incident.updated_at || ''
2193
+ ]);
2194
+ });
2195
+ console.log(table.toString());
2196
+ console.log(chalk.gray(`Total: ${result.total_results} (stored: ${result.total_incidents})`));
2197
+ }
2198
+ }
2199
+
2200
+ return result;
2201
+ }
2202
+
2203
+ async function runErrorbookIncidentShowCommand(options = {}, dependencies = {}) {
2204
+ const projectPath = dependencies.projectPath || process.cwd();
2205
+ const fileSystem = dependencies.fileSystem || fs;
2206
+ const paths = resolveErrorbookPaths(projectPath);
2207
+ const index = await readIncidentIndex(paths, fileSystem);
2208
+
2209
+ const id = normalizeText(options.id || options.incidentId);
2210
+ if (!id) {
2211
+ throw new Error('incident id is required');
2212
+ }
2213
+
2214
+ const summary = findIncidentSummaryById(index, id);
2215
+ if (!summary) {
2216
+ throw new Error(`staging incident not found: ${id}`);
2217
+ }
2218
+
2219
+ const incident = await readIncident(paths, summary.id, fileSystem);
2220
+ if (!incident) {
2221
+ throw new Error(`staging incident file not found: ${summary.id}`);
2222
+ }
2223
+
2224
+ const result = {
2225
+ mode: 'errorbook-incident-show',
2226
+ incident
2227
+ };
2228
+
2229
+ if (options.json) {
2230
+ console.log(JSON.stringify(result, null, 2));
2231
+ } else if (!options.silent) {
2232
+ console.log(chalk.cyan.bold(incident.title || summary.title || summary.id));
2233
+ console.log(chalk.gray(`id: ${incident.id}`));
2234
+ console.log(chalk.gray(`state: ${incident.state}`));
2235
+ console.log(chalk.gray(`attempts: ${Number(incident.attempt_count || 0)}`));
2236
+ console.log(chalk.gray(`fingerprint: ${incident.fingerprint}`));
2237
+ console.log(chalk.gray(`updated_at: ${incident.updated_at}`));
2238
+ if (incident.state === 'resolved') {
2239
+ console.log(chalk.gray(`resolved_at: ${incident.resolved_at || '(none)'}`));
2240
+ if (incident.resolution && incident.resolution.entry_id) {
2241
+ console.log(chalk.gray(`linked_entry: ${incident.resolution.entry_id}`));
2242
+ }
2243
+ }
2244
+ }
2245
+
2246
+ return result;
2247
+ }
2248
+
1738
2249
  async function runErrorbookListCommand(options = {}, dependencies = {}) {
1739
2250
  const projectPath = dependencies.projectPath || process.cwd();
1740
2251
  const fileSystem = dependencies.fileSystem || fs;
@@ -2299,6 +2810,36 @@ function registerErrorbookCommands(program) {
2299
2810
  }
2300
2811
  });
2301
2812
 
2813
+ const incident = errorbook
2814
+ .command('incident')
2815
+ .description('Inspect temporary trial-and-error incident loop before final curation');
2816
+
2817
+ incident
2818
+ .command('list')
2819
+ .description('List staging incidents')
2820
+ .option('--state <state>', 'Filter incident state (open|resolved)')
2821
+ .option('--limit <n>', 'Maximum incidents returned', parseInt, 20)
2822
+ .option('--json', 'Emit machine-readable JSON')
2823
+ .action(async (options) => {
2824
+ try {
2825
+ await runErrorbookIncidentListCommand(options);
2826
+ } catch (error) {
2827
+ emitCommandError(error, options.json);
2828
+ }
2829
+ });
2830
+
2831
+ incident
2832
+ .command('show <id>')
2833
+ .description('Show a staging incident with all attempts')
2834
+ .option('--json', 'Emit machine-readable JSON')
2835
+ .action(async (id, options) => {
2836
+ try {
2837
+ await runErrorbookIncidentShowCommand({ ...options, id });
2838
+ } catch (error) {
2839
+ emitCommandError(error, options.json);
2840
+ }
2841
+ });
2842
+
2302
2843
  errorbook
2303
2844
  .command('list')
2304
2845
  .description('List curated errorbook entries')
@@ -2420,6 +2961,7 @@ function registerErrorbookCommands(program) {
2420
2961
  .command('release-gate')
2421
2962
  .description('Block release on unresolved high-risk entries and temporary-mitigation policy violations')
2422
2963
  .option('--min-risk <level>', 'Risk threshold (low|medium|high)', 'high')
2964
+ .option('--min-quality <n>', `Minimum quality for unresolved entries (default: ${DEFAULT_RELEASE_GATE_MIN_QUALITY})`, parseInt)
2423
2965
  .option('--include-verified', 'Also inspect verified (non-promoted) entries')
2424
2966
  .option('--fail-on-block', 'Exit with error when gate is blocked')
2425
2967
  .option('--json', 'Emit machine-readable JSON')
@@ -2470,6 +3012,9 @@ module.exports = {
2470
3012
  HIGH_RISK_SIGNAL_TAGS,
2471
3013
  DEBUG_EVIDENCE_TAGS,
2472
3014
  DEFAULT_PROMOTE_MIN_QUALITY,
3015
+ DEFAULT_RELEASE_GATE_MIN_QUALITY,
3016
+ ERRORBOOK_INCIDENT_INDEX_API_VERSION,
3017
+ ERRORBOOK_INCIDENT_API_VERSION,
2473
3018
  resolveErrorbookPaths,
2474
3019
  resolveErrorbookRegistryPaths,
2475
3020
  normalizeOntologyTags,
@@ -2481,6 +3026,8 @@ module.exports = {
2481
3026
  runErrorbookExportCommand,
2482
3027
  runErrorbookSyncRegistryCommand,
2483
3028
  runErrorbookRegistryHealthCommand,
3029
+ runErrorbookIncidentListCommand,
3030
+ runErrorbookIncidentShowCommand,
2484
3031
  runErrorbookListCommand,
2485
3032
  runErrorbookShowCommand,
2486
3033
  runErrorbookFindCommand,