scene-capability-engine 3.3.17 → 3.3.18

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.3.18] - 2026-02-26
11
+
12
+ ### Added
13
+ - Curated `errorbook` command set for high-signal failure remediation knowledge:
14
+ - `sce errorbook record`
15
+ - `sce errorbook list`
16
+ - `sce errorbook show <id>`
17
+ - `sce errorbook find --query <text>`
18
+ - `sce errorbook promote <id>`
19
+ - `sce errorbook deprecate <id> --reason <text>`
20
+ - `sce errorbook requalify <id> --status <candidate|verified>`
21
+
22
+ ### Changed
23
+ - Added strict curation/promotion policy to command reference (`宁缺毋滥,优胜略汰`):
24
+ - fingerprint-based deduplication on record
25
+ - promotion gate requires validated root cause, fix actions, verification evidence, ontology tags, and minimum quality score
26
+
10
27
  ## [3.3.17] - 2026-02-26
11
28
 
12
29
  ### Added
@@ -817,6 +817,10 @@ registerLockCommands(program);
817
817
  const { registerKnowledgeCommands } = require('../lib/commands/knowledge');
818
818
  registerKnowledgeCommands(program);
819
819
 
820
+ // Errorbook commands
821
+ const { registerErrorbookCommands } = require('../lib/commands/errorbook');
822
+ registerErrorbookCommands(program);
823
+
820
824
  // Studio orchestration commands
821
825
  const { registerStudioCommands } = require('../lib/commands/studio');
822
826
  registerStudioCommands(program);
@@ -318,6 +318,49 @@ Rate-limit profiles:
318
318
  - `balanced`: default profile for normal multi-agent runs
319
319
  - `aggressive`: higher throughput with lower protection margins
320
320
 
321
+ ### Errorbook (Curated Failure Knowledge)
322
+
323
+ ```bash
324
+ # Record curated remediation entry
325
+ sce errorbook record \
326
+ --title "Order approval queue saturation" \
327
+ --symptom "Approval queue backlog exceeded SLA" \
328
+ --root-cause "Worker pool under-provisioned and retries amplified load" \
329
+ --fix-action "Increase workers from 4 to 8" \
330
+ --fix-action "Reduce retry burst window" \
331
+ --verification "Load test confirms p95 < SLA threshold" \
332
+ --ontology "entity,relation,decision_policy" \
333
+ --tags "moqui,order,performance" \
334
+ --status verified \
335
+ --json
336
+
337
+ # List/show/find entries
338
+ sce errorbook list --status promoted --min-quality 75 --json
339
+ sce errorbook show <entry-id> --json
340
+ sce errorbook find --query "approve order timeout" --limit 10 --json
341
+
342
+ # Promote only after strict gate checks pass
343
+ sce errorbook promote <entry-id> --json
344
+
345
+ # Eliminate obsolete/low-value entries (curation)
346
+ sce errorbook deprecate <entry-id> --reason "superseded by v2 policy" --json
347
+
348
+ # Requalify deprecated entry after remediation review
349
+ sce errorbook requalify <entry-id> --status verified --json
350
+ ```
351
+
352
+ Curated quality policy (`宁缺毋滥,优胜略汰`) defaults:
353
+ - `record` requires: `title`, `symptom`, `root_cause`, and at least one `fix_action`.
354
+ - Fingerprint dedup is automatic; repeated records merge evidence and increment occurrence count.
355
+ - `promote` enforces strict gate:
356
+ - `root_cause` present
357
+ - `fix_actions` non-empty
358
+ - `verification_evidence` non-empty
359
+ - `ontology_tags` non-empty
360
+ - `quality_score >= 75`
361
+ - `deprecate` requires explicit `--reason` to preserve elimination traceability.
362
+ - `requalify` only accepts `candidate|verified`; `promoted` must still go through `promote` gate.
363
+
321
364
  ### Studio Workflow
322
365
 
323
366
  ```bash
@@ -0,0 +1,1075 @@
1
+ const crypto = require('crypto');
2
+ const path = require('path');
3
+ const fs = require('fs-extra');
4
+ const chalk = require('chalk');
5
+ const Table = require('cli-table3');
6
+
7
+ const ERRORBOOK_INDEX_API_VERSION = 'sce.errorbook.index/v0.1';
8
+ const ERRORBOOK_ENTRY_API_VERSION = 'sce.errorbook.entry/v0.1';
9
+ const ERRORBOOK_STATUSES = Object.freeze(['candidate', 'verified', 'promoted', 'deprecated']);
10
+ const STATUS_RANK = Object.freeze({
11
+ deprecated: 0,
12
+ candidate: 1,
13
+ verified: 2,
14
+ promoted: 3
15
+ });
16
+ const ERRORBOOK_ONTOLOGY_TAGS = Object.freeze([
17
+ 'entity',
18
+ 'relation',
19
+ 'business_rule',
20
+ 'decision_policy',
21
+ 'execution_flow'
22
+ ]);
23
+ const ONTOLOGY_TAG_ALIASES = Object.freeze({
24
+ entities: 'entity',
25
+ relations: 'relation',
26
+ rule: 'business_rule',
27
+ rules: 'business_rule',
28
+ business_rules: 'business_rule',
29
+ decision: 'decision_policy',
30
+ decisions: 'decision_policy',
31
+ policy: 'decision_policy',
32
+ policies: 'decision_policy',
33
+ execution: 'execution_flow',
34
+ flow: 'execution_flow',
35
+ workflow: 'execution_flow',
36
+ workflows: 'execution_flow',
37
+ action_chain: 'execution_flow'
38
+ });
39
+ const DEFAULT_PROMOTE_MIN_QUALITY = 75;
40
+
41
+ function resolveErrorbookPaths(projectPath = process.cwd()) {
42
+ const baseDir = path.join(projectPath, '.sce', 'errorbook');
43
+ return {
44
+ projectPath,
45
+ baseDir,
46
+ entriesDir: path.join(baseDir, 'entries'),
47
+ indexFile: path.join(baseDir, 'index.json')
48
+ };
49
+ }
50
+
51
+ function nowIso() {
52
+ return new Date().toISOString();
53
+ }
54
+
55
+ function normalizeText(value) {
56
+ if (typeof value !== 'string') {
57
+ return '';
58
+ }
59
+
60
+ return value.trim();
61
+ }
62
+
63
+ function normalizeCsv(value) {
64
+ if (Array.isArray(value)) {
65
+ return value;
66
+ }
67
+
68
+ if (typeof value !== 'string' || !value.trim()) {
69
+ return [];
70
+ }
71
+
72
+ return value.split(',').map((item) => item.trim()).filter(Boolean);
73
+ }
74
+
75
+ function normalizeStringList(...rawInputs) {
76
+ const merged = [];
77
+ for (const raw of rawInputs) {
78
+ if (Array.isArray(raw)) {
79
+ for (const item of raw) {
80
+ const normalized = normalizeText(`${item}`);
81
+ if (normalized) {
82
+ merged.push(normalized);
83
+ }
84
+ }
85
+ continue;
86
+ }
87
+
88
+ if (typeof raw === 'string') {
89
+ for (const item of normalizeCsv(raw)) {
90
+ const normalized = normalizeText(item);
91
+ if (normalized) {
92
+ merged.push(normalized);
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ return Array.from(new Set(merged));
99
+ }
100
+
101
+ function normalizeOntologyTags(...rawInputs) {
102
+ const normalized = normalizeStringList(...rawInputs).map((item) => item.toLowerCase());
103
+ const mapped = normalized.map((item) => ONTOLOGY_TAG_ALIASES[item] || item);
104
+ const valid = mapped.filter((item) => ERRORBOOK_ONTOLOGY_TAGS.includes(item));
105
+ return Array.from(new Set(valid));
106
+ }
107
+
108
+ function normalizeStatus(input, fallback = 'candidate') {
109
+ const normalized = normalizeText(`${input || ''}`).toLowerCase();
110
+ if (!normalized) {
111
+ return fallback;
112
+ }
113
+
114
+ if (!ERRORBOOK_STATUSES.includes(normalized)) {
115
+ throw new Error(`status must be one of: ${ERRORBOOK_STATUSES.join(', ')}`);
116
+ }
117
+
118
+ return normalized;
119
+ }
120
+
121
+ function selectStatus(...candidates) {
122
+ let selected = 'candidate';
123
+ for (const candidate of candidates) {
124
+ const status = normalizeStatus(candidate, 'candidate');
125
+ if ((STATUS_RANK[status] || 0) > (STATUS_RANK[selected] || 0)) {
126
+ selected = status;
127
+ }
128
+ }
129
+ return selected;
130
+ }
131
+
132
+ function createFingerprint(input = {}) {
133
+ const explicit = normalizeText(input.fingerprint);
134
+ if (explicit) {
135
+ return explicit;
136
+ }
137
+
138
+ const basis = [
139
+ normalizeText(input.title).toLowerCase(),
140
+ normalizeText(input.symptom).toLowerCase(),
141
+ normalizeText(input.root_cause || input.rootCause).toLowerCase()
142
+ ].join('|');
143
+
144
+ const digest = crypto.createHash('sha1').update(basis).digest('hex').slice(0, 16);
145
+ return `fp-${digest}`;
146
+ }
147
+
148
+ function buildDefaultIndex() {
149
+ return {
150
+ api_version: ERRORBOOK_INDEX_API_VERSION,
151
+ updated_at: nowIso(),
152
+ total_entries: 0,
153
+ entries: []
154
+ };
155
+ }
156
+
157
+ async function ensureErrorbookStorage(paths, fileSystem = fs) {
158
+ await fileSystem.ensureDir(paths.entriesDir);
159
+ if (!await fileSystem.pathExists(paths.indexFile)) {
160
+ await fileSystem.writeJson(paths.indexFile, buildDefaultIndex(), { spaces: 2 });
161
+ }
162
+ }
163
+
164
+ async function readErrorbookIndex(paths, fileSystem = fs) {
165
+ await ensureErrorbookStorage(paths, fileSystem);
166
+ const index = await fileSystem.readJson(paths.indexFile);
167
+ if (!index || typeof index !== 'object' || !Array.isArray(index.entries)) {
168
+ return buildDefaultIndex();
169
+ }
170
+
171
+ return {
172
+ api_version: index.api_version || ERRORBOOK_INDEX_API_VERSION,
173
+ updated_at: index.updated_at || nowIso(),
174
+ total_entries: Number.isInteger(index.total_entries) ? index.total_entries : index.entries.length,
175
+ entries: index.entries
176
+ };
177
+ }
178
+
179
+ async function writeErrorbookIndex(paths, index, fileSystem = fs) {
180
+ const payload = {
181
+ ...index,
182
+ api_version: ERRORBOOK_INDEX_API_VERSION,
183
+ updated_at: nowIso(),
184
+ total_entries: Array.isArray(index.entries) ? index.entries.length : 0
185
+ };
186
+ await fileSystem.ensureDir(path.dirname(paths.indexFile));
187
+ await fileSystem.writeJson(paths.indexFile, payload, { spaces: 2 });
188
+ return payload;
189
+ }
190
+
191
+ function buildEntryFilePath(paths, entryId) {
192
+ return path.join(paths.entriesDir, `${entryId}.json`);
193
+ }
194
+
195
+ async function readErrorbookEntry(paths, entryId, fileSystem = fs) {
196
+ const entryPath = buildEntryFilePath(paths, entryId);
197
+ if (!await fileSystem.pathExists(entryPath)) {
198
+ return null;
199
+ }
200
+ return fileSystem.readJson(entryPath);
201
+ }
202
+
203
+ async function writeErrorbookEntry(paths, entry, fileSystem = fs) {
204
+ const entryPath = buildEntryFilePath(paths, entry.id);
205
+ await fileSystem.ensureDir(path.dirname(entryPath));
206
+ await fileSystem.writeJson(entryPath, entry, { spaces: 2 });
207
+ return entryPath;
208
+ }
209
+
210
+ function scoreQuality(entry = {}) {
211
+ let score = 0;
212
+
213
+ if (normalizeText(entry.title)) {
214
+ score += 10;
215
+ }
216
+ if (normalizeText(entry.symptom)) {
217
+ score += 10;
218
+ }
219
+ if (normalizeText(entry.fingerprint)) {
220
+ score += 10;
221
+ }
222
+ if (normalizeText(entry.root_cause)) {
223
+ score += 20;
224
+ }
225
+ if (Array.isArray(entry.fix_actions) && entry.fix_actions.length > 0) {
226
+ score += 20;
227
+ }
228
+ if (Array.isArray(entry.verification_evidence) && entry.verification_evidence.length > 0) {
229
+ score += 20;
230
+ }
231
+ if (Array.isArray(entry.ontology_tags) && entry.ontology_tags.length > 0) {
232
+ score += 5;
233
+ }
234
+ if (Array.isArray(entry.tags) && entry.tags.length > 0) {
235
+ score += 3;
236
+ }
237
+ if (normalizeText(entry.symptom).length >= 24 && normalizeText(entry.root_cause).length >= 24) {
238
+ score += 2;
239
+ }
240
+
241
+ return Math.max(0, Math.min(100, score));
242
+ }
243
+
244
+ function validateRecordPayload(payload) {
245
+ if (!normalizeText(payload.title)) {
246
+ throw new Error('--title is required');
247
+ }
248
+ if (!normalizeText(payload.symptom)) {
249
+ throw new Error('--symptom is required');
250
+ }
251
+ if (!normalizeText(payload.root_cause)) {
252
+ throw new Error('--root-cause is required');
253
+ }
254
+ if (!Array.isArray(payload.fix_actions) || payload.fix_actions.length === 0) {
255
+ throw new Error('at least one --fix-action is required');
256
+ }
257
+
258
+ const status = normalizeStatus(payload.status, 'candidate');
259
+ if (status === 'promoted') {
260
+ throw new Error('record does not accept status=promoted. Use "sce errorbook promote <id>"');
261
+ }
262
+ if (status === 'verified' && (!Array.isArray(payload.verification_evidence) || payload.verification_evidence.length === 0)) {
263
+ throw new Error('status=verified requires at least one --verification evidence');
264
+ }
265
+ }
266
+
267
+ function normalizeRecordPayload(options = {}, fromFilePayload = {}) {
268
+ const payload = {
269
+ title: normalizeText(options.title || fromFilePayload.title),
270
+ symptom: normalizeText(options.symptom || fromFilePayload.symptom),
271
+ root_cause: normalizeText(options.rootCause || options.root_cause || fromFilePayload.root_cause || fromFilePayload.rootCause),
272
+ fix_actions: normalizeStringList(fromFilePayload.fix_actions, fromFilePayload.fixActions, options.fixAction, options.fixActions),
273
+ verification_evidence: normalizeStringList(
274
+ fromFilePayload.verification_evidence,
275
+ fromFilePayload.verificationEvidence,
276
+ options.verification,
277
+ options.verificationEvidence
278
+ ),
279
+ tags: normalizeStringList(fromFilePayload.tags, options.tags),
280
+ ontology_tags: normalizeOntologyTags(fromFilePayload.ontology_tags, fromFilePayload.ontology, options.ontology),
281
+ status: normalizeStatus(options.status || fromFilePayload.status || 'candidate'),
282
+ source: {
283
+ spec: normalizeText(options.spec || fromFilePayload?.source?.spec),
284
+ files: normalizeStringList(fromFilePayload?.source?.files, options.files),
285
+ tests: normalizeStringList(fromFilePayload?.source?.tests, options.tests)
286
+ },
287
+ notes: normalizeText(options.notes || fromFilePayload.notes),
288
+ fingerprint: createFingerprint({
289
+ fingerprint: options.fingerprint || fromFilePayload.fingerprint,
290
+ title: options.title || fromFilePayload.title,
291
+ symptom: options.symptom || fromFilePayload.symptom,
292
+ root_cause: options.rootCause || options.root_cause || fromFilePayload.root_cause || fromFilePayload.rootCause
293
+ })
294
+ };
295
+
296
+ return payload;
297
+ }
298
+
299
+ function createEntryId() {
300
+ return `eb-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
301
+ }
302
+
303
+ function buildIndexSummary(entry) {
304
+ return {
305
+ id: entry.id,
306
+ fingerprint: entry.fingerprint,
307
+ title: entry.title,
308
+ status: entry.status,
309
+ quality_score: entry.quality_score,
310
+ tags: entry.tags,
311
+ ontology_tags: entry.ontology_tags,
312
+ occurrences: entry.occurrences || 1,
313
+ created_at: entry.created_at,
314
+ updated_at: entry.updated_at
315
+ };
316
+ }
317
+
318
+ function findSummaryById(index, id) {
319
+ const normalized = normalizeText(id);
320
+ if (!normalized) {
321
+ return null;
322
+ }
323
+
324
+ const exact = index.entries.find((item) => item.id === normalized);
325
+ if (exact) {
326
+ return exact;
327
+ }
328
+
329
+ const startsWith = index.entries.filter((item) => item.id.startsWith(normalized));
330
+ if (startsWith.length === 1) {
331
+ return startsWith[0];
332
+ }
333
+ if (startsWith.length > 1) {
334
+ throw new Error(`entry id prefix "${normalized}" is ambiguous (${startsWith.length} matches)`);
335
+ }
336
+ return null;
337
+ }
338
+
339
+ function mergeEntry(existingEntry, incomingPayload) {
340
+ const merged = {
341
+ ...existingEntry,
342
+ title: normalizeText(incomingPayload.title) || existingEntry.title,
343
+ symptom: normalizeText(incomingPayload.symptom) || existingEntry.symptom,
344
+ root_cause: normalizeText(incomingPayload.root_cause) || existingEntry.root_cause,
345
+ fix_actions: normalizeStringList(existingEntry.fix_actions, incomingPayload.fix_actions),
346
+ verification_evidence: normalizeStringList(existingEntry.verification_evidence, incomingPayload.verification_evidence),
347
+ tags: normalizeStringList(existingEntry.tags, incomingPayload.tags),
348
+ ontology_tags: normalizeOntologyTags(existingEntry.ontology_tags, incomingPayload.ontology_tags),
349
+ status: selectStatus(existingEntry.status, incomingPayload.status),
350
+ notes: normalizeText(incomingPayload.notes) || existingEntry.notes || '',
351
+ source: {
352
+ spec: normalizeText(incomingPayload?.source?.spec) || normalizeText(existingEntry?.source?.spec),
353
+ files: normalizeStringList(existingEntry?.source?.files, incomingPayload?.source?.files),
354
+ tests: normalizeStringList(existingEntry?.source?.tests, incomingPayload?.source?.tests)
355
+ },
356
+ occurrences: Number(existingEntry.occurrences || 1) + 1,
357
+ updated_at: nowIso()
358
+ };
359
+ merged.quality_score = scoreQuality(merged);
360
+ return merged;
361
+ }
362
+
363
+ async function loadRecordPayloadFromFile(projectPath, sourcePath, fileSystem = fs) {
364
+ const normalized = normalizeText(sourcePath);
365
+ if (!normalized) {
366
+ return {};
367
+ }
368
+
369
+ const absolutePath = path.isAbsolute(normalized)
370
+ ? normalized
371
+ : path.join(projectPath, normalized);
372
+
373
+ if (!await fileSystem.pathExists(absolutePath)) {
374
+ throw new Error(`record source file not found: ${sourcePath}`);
375
+ }
376
+
377
+ try {
378
+ const payload = await fileSystem.readJson(absolutePath);
379
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
380
+ throw new Error('record source JSON must be an object');
381
+ }
382
+ return payload;
383
+ } catch (error) {
384
+ throw new Error(`failed to parse record source file (${sourcePath}): ${error.message}`);
385
+ }
386
+ }
387
+
388
+ function printRecordSummary(result) {
389
+ const action = result.created ? 'Recorded new entry' : 'Updated duplicate fingerprint';
390
+ console.log(chalk.green(`✓ ${action}`));
391
+ console.log(chalk.gray(` id: ${result.entry.id}`));
392
+ console.log(chalk.gray(` status: ${result.entry.status}`));
393
+ console.log(chalk.gray(` quality: ${result.entry.quality_score}`));
394
+ console.log(chalk.gray(` fingerprint: ${result.entry.fingerprint}`));
395
+ }
396
+
397
+ function printListSummary(payload) {
398
+ if (payload.entries.length === 0) {
399
+ console.log(chalk.gray('No errorbook entries found'));
400
+ return;
401
+ }
402
+
403
+ const table = new Table({
404
+ head: ['ID', 'Status', 'Quality', 'Title', 'Updated', 'Occurrences'].map((item) => chalk.cyan(item)),
405
+ colWidths: [16, 12, 10, 44, 22, 12]
406
+ });
407
+
408
+ payload.entries.forEach((entry) => {
409
+ table.push([
410
+ entry.id,
411
+ entry.status,
412
+ entry.quality_score,
413
+ entry.title.length > 40 ? `${entry.title.slice(0, 40)}...` : entry.title,
414
+ entry.updated_at,
415
+ entry.occurrences || 1
416
+ ]);
417
+ });
418
+
419
+ console.log(table.toString());
420
+ console.log(chalk.gray(`Total: ${payload.total_results} (stored: ${payload.total_entries})`));
421
+ }
422
+
423
+ function scoreSearchMatch(entry, queryTokens) {
424
+ const title = normalizeText(entry.title).toLowerCase();
425
+ const symptom = normalizeText(entry.symptom).toLowerCase();
426
+ const rootCause = normalizeText(entry.root_cause).toLowerCase();
427
+ const fingerprint = normalizeText(entry.fingerprint).toLowerCase();
428
+ const tagText = normalizeStringList(entry.tags, entry.ontology_tags).join(' ').toLowerCase();
429
+ const fixText = normalizeStringList(entry.fix_actions).join(' ').toLowerCase();
430
+
431
+ let score = 0;
432
+ for (const token of queryTokens) {
433
+ if (!token) {
434
+ continue;
435
+ }
436
+ if (title.includes(token)) {
437
+ score += 8;
438
+ }
439
+ if (symptom.includes(token)) {
440
+ score += 5;
441
+ }
442
+ if (rootCause.includes(token)) {
443
+ score += 5;
444
+ }
445
+ if (fixText.includes(token)) {
446
+ score += 3;
447
+ }
448
+ if (tagText.includes(token)) {
449
+ score += 2;
450
+ }
451
+ if (fingerprint.includes(token)) {
452
+ score += 1;
453
+ }
454
+ }
455
+
456
+ score += (Number(entry.quality_score) || 0) / 20;
457
+ score += STATUS_RANK[entry.status] || 0;
458
+ return Number(score.toFixed(3));
459
+ }
460
+
461
+ function validatePromoteCandidate(entry, minQuality = DEFAULT_PROMOTE_MIN_QUALITY) {
462
+ const missing = [];
463
+ if (!normalizeText(entry.root_cause)) {
464
+ missing.push('root_cause');
465
+ }
466
+ if (!Array.isArray(entry.fix_actions) || entry.fix_actions.length === 0) {
467
+ missing.push('fix_actions');
468
+ }
469
+ if (!Array.isArray(entry.verification_evidence) || entry.verification_evidence.length === 0) {
470
+ missing.push('verification_evidence');
471
+ }
472
+ if (!Array.isArray(entry.ontology_tags) || entry.ontology_tags.length === 0) {
473
+ missing.push('ontology_tags');
474
+ }
475
+ if ((Number(entry.quality_score) || 0) < minQuality) {
476
+ missing.push(`quality_score>=${minQuality}`);
477
+ }
478
+ if (entry.status === 'deprecated') {
479
+ missing.push('status!=deprecated');
480
+ }
481
+ return missing;
482
+ }
483
+
484
+ async function runErrorbookRecordCommand(options = {}, dependencies = {}) {
485
+ const projectPath = dependencies.projectPath || process.cwd();
486
+ const fileSystem = dependencies.fileSystem || fs;
487
+ const paths = resolveErrorbookPaths(projectPath);
488
+
489
+ const fromFilePayload = await loadRecordPayloadFromFile(projectPath, options.from, fileSystem);
490
+ const normalized = normalizeRecordPayload(options, fromFilePayload);
491
+ validateRecordPayload(normalized);
492
+
493
+ const index = await readErrorbookIndex(paths, fileSystem);
494
+ const existingSummary = index.entries.find((entry) => entry.fingerprint === normalized.fingerprint);
495
+ const timestamp = nowIso();
496
+
497
+ let entry;
498
+ let created = false;
499
+ let deduplicated = false;
500
+
501
+ if (existingSummary) {
502
+ const existingEntry = await readErrorbookEntry(paths, existingSummary.id, fileSystem);
503
+ if (!existingEntry) {
504
+ throw new Error(`errorbook index references missing entry: ${existingSummary.id}`);
505
+ }
506
+ entry = mergeEntry(existingEntry, normalized);
507
+ deduplicated = true;
508
+ } else {
509
+ entry = {
510
+ api_version: ERRORBOOK_ENTRY_API_VERSION,
511
+ id: createEntryId(),
512
+ created_at: timestamp,
513
+ updated_at: timestamp,
514
+ fingerprint: normalized.fingerprint,
515
+ title: normalized.title,
516
+ symptom: normalized.symptom,
517
+ root_cause: normalized.root_cause,
518
+ fix_actions: normalized.fix_actions,
519
+ verification_evidence: normalized.verification_evidence,
520
+ tags: normalized.tags,
521
+ ontology_tags: normalized.ontology_tags,
522
+ status: normalized.status,
523
+ source: normalized.source,
524
+ notes: normalized.notes || '',
525
+ occurrences: 1
526
+ };
527
+ entry.quality_score = scoreQuality(entry);
528
+ created = true;
529
+ }
530
+
531
+ entry.updated_at = nowIso();
532
+ entry.quality_score = scoreQuality(entry);
533
+ await writeErrorbookEntry(paths, entry, fileSystem);
534
+
535
+ const summary = buildIndexSummary(entry);
536
+ const summaryIndex = index.entries.findIndex((item) => item.id === summary.id);
537
+ if (summaryIndex >= 0) {
538
+ index.entries[summaryIndex] = summary;
539
+ } else {
540
+ index.entries.push(summary);
541
+ }
542
+ index.entries.sort((left, right) => `${right.updated_at}`.localeCompare(`${left.updated_at}`));
543
+ await writeErrorbookIndex(paths, index, fileSystem);
544
+
545
+ const result = {
546
+ mode: 'errorbook-record',
547
+ created,
548
+ deduplicated,
549
+ entry
550
+ };
551
+
552
+ if (options.json) {
553
+ console.log(JSON.stringify(result, null, 2));
554
+ } else if (!options.silent) {
555
+ printRecordSummary(result);
556
+ }
557
+
558
+ return result;
559
+ }
560
+
561
+ async function runErrorbookListCommand(options = {}, dependencies = {}) {
562
+ const projectPath = dependencies.projectPath || process.cwd();
563
+ const fileSystem = dependencies.fileSystem || fs;
564
+ const paths = resolveErrorbookPaths(projectPath);
565
+ const index = await readErrorbookIndex(paths, fileSystem);
566
+
567
+ const requestedStatus = options.status ? normalizeStatus(options.status) : null;
568
+ const requestedTag = normalizeText(options.tag).toLowerCase();
569
+ const requestedOntology = normalizeOntologyTags(options.ontology)[0] || '';
570
+ const minQuality = Number.isFinite(Number(options.minQuality)) ? Number(options.minQuality) : null;
571
+ const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
572
+ ? Number(options.limit)
573
+ : 20;
574
+
575
+ let filtered = [...index.entries];
576
+ if (requestedStatus) {
577
+ filtered = filtered.filter((entry) => entry.status === requestedStatus);
578
+ }
579
+ if (requestedTag) {
580
+ filtered = filtered.filter((entry) => normalizeStringList(entry.tags).some((tag) => tag.toLowerCase() === requestedTag));
581
+ }
582
+ if (requestedOntology) {
583
+ filtered = filtered.filter((entry) => normalizeOntologyTags(entry.ontology_tags).includes(requestedOntology));
584
+ }
585
+ if (minQuality !== null) {
586
+ filtered = filtered.filter((entry) => Number(entry.quality_score || 0) >= minQuality);
587
+ }
588
+
589
+ filtered.sort((left, right) => {
590
+ const qualityDiff = Number(right.quality_score || 0) - Number(left.quality_score || 0);
591
+ if (qualityDiff !== 0) {
592
+ return qualityDiff;
593
+ }
594
+ return `${right.updated_at}`.localeCompare(`${left.updated_at}`);
595
+ });
596
+
597
+ const result = {
598
+ mode: 'errorbook-list',
599
+ total_entries: index.entries.length,
600
+ total_results: filtered.length,
601
+ entries: filtered.slice(0, limit).map((entry) => ({
602
+ ...entry,
603
+ tags: normalizeStringList(entry.tags),
604
+ ontology_tags: normalizeOntologyTags(entry.ontology_tags)
605
+ }))
606
+ };
607
+
608
+ if (options.json) {
609
+ console.log(JSON.stringify(result, null, 2));
610
+ } else if (!options.silent) {
611
+ printListSummary(result);
612
+ }
613
+
614
+ return result;
615
+ }
616
+
617
+ async function runErrorbookShowCommand(options = {}, dependencies = {}) {
618
+ const projectPath = dependencies.projectPath || process.cwd();
619
+ const fileSystem = dependencies.fileSystem || fs;
620
+ const paths = resolveErrorbookPaths(projectPath);
621
+ const index = await readErrorbookIndex(paths, fileSystem);
622
+
623
+ const id = normalizeText(options.id || options.entryId);
624
+ if (!id) {
625
+ throw new Error('entry id is required');
626
+ }
627
+
628
+ const summary = findSummaryById(index, id);
629
+ if (!summary) {
630
+ throw new Error(`errorbook entry not found: ${id}`);
631
+ }
632
+
633
+ const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
634
+ if (!entry) {
635
+ throw new Error(`errorbook entry file not found: ${summary.id}`);
636
+ }
637
+
638
+ const result = {
639
+ mode: 'errorbook-show',
640
+ entry
641
+ };
642
+
643
+ if (options.json) {
644
+ console.log(JSON.stringify(result, null, 2));
645
+ } else if (!options.silent) {
646
+ console.log(chalk.cyan.bold(entry.title));
647
+ console.log(chalk.gray(`id: ${entry.id}`));
648
+ console.log(chalk.gray(`status: ${entry.status}`));
649
+ console.log(chalk.gray(`quality: ${entry.quality_score}`));
650
+ console.log(chalk.gray(`fingerprint: ${entry.fingerprint}`));
651
+ console.log(chalk.gray(`symptom: ${entry.symptom}`));
652
+ console.log(chalk.gray(`root_cause: ${entry.root_cause}`));
653
+ console.log(chalk.gray(`fix_actions: ${entry.fix_actions.join(' | ')}`));
654
+ console.log(chalk.gray(`verification: ${entry.verification_evidence.join(' | ') || '(none)'}`));
655
+ console.log(chalk.gray(`ontology: ${entry.ontology_tags.join(', ') || '(none)'}`));
656
+ }
657
+
658
+ return result;
659
+ }
660
+
661
+ async function runErrorbookFindCommand(options = {}, dependencies = {}) {
662
+ const projectPath = dependencies.projectPath || process.cwd();
663
+ const fileSystem = dependencies.fileSystem || fs;
664
+ const paths = resolveErrorbookPaths(projectPath);
665
+ const index = await readErrorbookIndex(paths, fileSystem);
666
+
667
+ const query = normalizeText(options.query);
668
+ if (!query) {
669
+ throw new Error('--query is required');
670
+ }
671
+
672
+ const requestedStatus = options.status ? normalizeStatus(options.status) : null;
673
+ const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
674
+ ? Number(options.limit)
675
+ : 10;
676
+ const tokens = query.toLowerCase().split(/[\s,;|]+/).map((item) => item.trim()).filter(Boolean);
677
+
678
+ const candidates = [];
679
+ for (const summary of index.entries) {
680
+ if (requestedStatus && summary.status !== requestedStatus) {
681
+ continue;
682
+ }
683
+ const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
684
+ if (!entry) {
685
+ continue;
686
+ }
687
+ const matchScore = scoreSearchMatch(entry, tokens);
688
+ if (matchScore <= 0) {
689
+ continue;
690
+ }
691
+ candidates.push({
692
+ id: entry.id,
693
+ status: entry.status,
694
+ quality_score: entry.quality_score,
695
+ title: entry.title,
696
+ fingerprint: entry.fingerprint,
697
+ tags: normalizeStringList(entry.tags),
698
+ ontology_tags: normalizeOntologyTags(entry.ontology_tags),
699
+ match_score: matchScore,
700
+ updated_at: entry.updated_at
701
+ });
702
+ }
703
+
704
+ candidates.sort((left, right) => {
705
+ const scoreDiff = Number(right.match_score) - Number(left.match_score);
706
+ if (scoreDiff !== 0) {
707
+ return scoreDiff;
708
+ }
709
+ return `${right.updated_at}`.localeCompare(`${left.updated_at}`);
710
+ });
711
+
712
+ const result = {
713
+ mode: 'errorbook-find',
714
+ query,
715
+ total_results: candidates.length,
716
+ entries: candidates.slice(0, limit)
717
+ };
718
+
719
+ if (options.json) {
720
+ console.log(JSON.stringify(result, null, 2));
721
+ } else if (!options.silent) {
722
+ printListSummary({
723
+ entries: result.entries,
724
+ total_results: result.total_results,
725
+ total_entries: index.entries.length
726
+ });
727
+ }
728
+
729
+ return result;
730
+ }
731
+
732
+ async function runErrorbookPromoteCommand(options = {}, dependencies = {}) {
733
+ const projectPath = dependencies.projectPath || process.cwd();
734
+ const fileSystem = dependencies.fileSystem || fs;
735
+ const paths = resolveErrorbookPaths(projectPath);
736
+ const index = await readErrorbookIndex(paths, fileSystem);
737
+
738
+ const id = normalizeText(options.id || options.entryId);
739
+ if (!id) {
740
+ throw new Error('entry id is required');
741
+ }
742
+
743
+ const summary = findSummaryById(index, id);
744
+ if (!summary) {
745
+ throw new Error(`errorbook entry not found: ${id}`);
746
+ }
747
+
748
+ const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
749
+ if (!entry) {
750
+ throw new Error(`errorbook entry file not found: ${summary.id}`);
751
+ }
752
+
753
+ entry.quality_score = scoreQuality(entry);
754
+ const missing = validatePromoteCandidate(entry, DEFAULT_PROMOTE_MIN_QUALITY);
755
+ if (missing.length > 0) {
756
+ throw new Error(`promote gate failed: ${missing.join(', ')}`);
757
+ }
758
+
759
+ entry.status = 'promoted';
760
+ entry.promoted_at = nowIso();
761
+ entry.updated_at = entry.promoted_at;
762
+ await writeErrorbookEntry(paths, entry, fileSystem);
763
+
764
+ const updatedSummary = buildIndexSummary(entry);
765
+ const targetIndex = index.entries.findIndex((item) => item.id === entry.id);
766
+ if (targetIndex >= 0) {
767
+ index.entries[targetIndex] = updatedSummary;
768
+ } else {
769
+ index.entries.push(updatedSummary);
770
+ }
771
+ index.entries.sort((left, right) => `${right.updated_at}`.localeCompare(`${left.updated_at}`));
772
+ await writeErrorbookIndex(paths, index, fileSystem);
773
+
774
+ const result = {
775
+ mode: 'errorbook-promote',
776
+ promoted: true,
777
+ entry
778
+ };
779
+
780
+ if (options.json) {
781
+ console.log(JSON.stringify(result, null, 2));
782
+ } else if (!options.silent) {
783
+ console.log(chalk.green('✓ Promoted errorbook entry'));
784
+ console.log(chalk.gray(` id: ${entry.id}`));
785
+ console.log(chalk.gray(` quality: ${entry.quality_score}`));
786
+ console.log(chalk.gray(` promoted_at: ${entry.promoted_at}`));
787
+ }
788
+
789
+ return result;
790
+ }
791
+
792
+ async function runErrorbookDeprecateCommand(options = {}, dependencies = {}) {
793
+ const projectPath = dependencies.projectPath || process.cwd();
794
+ const fileSystem = dependencies.fileSystem || fs;
795
+ const paths = resolveErrorbookPaths(projectPath);
796
+ const index = await readErrorbookIndex(paths, fileSystem);
797
+
798
+ const id = normalizeText(options.id || options.entryId);
799
+ if (!id) {
800
+ throw new Error('entry id is required');
801
+ }
802
+
803
+ const summary = findSummaryById(index, id);
804
+ if (!summary) {
805
+ throw new Error(`errorbook entry not found: ${id}`);
806
+ }
807
+
808
+ const reason = normalizeText(options.reason);
809
+ if (!reason) {
810
+ throw new Error('--reason is required for deprecate');
811
+ }
812
+
813
+ const replacement = normalizeText(options.replacement);
814
+ if (replacement && replacement === summary.id) {
815
+ throw new Error('--replacement cannot reference the same entry id');
816
+ }
817
+
818
+ const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
819
+ if (!entry) {
820
+ throw new Error(`errorbook entry file not found: ${summary.id}`);
821
+ }
822
+
823
+ entry.status = 'deprecated';
824
+ entry.updated_at = nowIso();
825
+ entry.deprecated_at = entry.updated_at;
826
+ entry.deprecation = {
827
+ reason,
828
+ replacement_id: replacement || null
829
+ };
830
+ entry.quality_score = scoreQuality(entry);
831
+ await writeErrorbookEntry(paths, entry, fileSystem);
832
+
833
+ const updatedSummary = buildIndexSummary(entry);
834
+ const targetIndex = index.entries.findIndex((item) => item.id === entry.id);
835
+ if (targetIndex >= 0) {
836
+ index.entries[targetIndex] = updatedSummary;
837
+ } else {
838
+ index.entries.push(updatedSummary);
839
+ }
840
+ index.entries.sort((left, right) => `${right.updated_at}`.localeCompare(`${left.updated_at}`));
841
+ await writeErrorbookIndex(paths, index, fileSystem);
842
+
843
+ const result = {
844
+ mode: 'errorbook-deprecate',
845
+ deprecated: true,
846
+ entry
847
+ };
848
+
849
+ if (options.json) {
850
+ console.log(JSON.stringify(result, null, 2));
851
+ } else if (!options.silent) {
852
+ console.log(chalk.yellow('✓ Deprecated errorbook entry'));
853
+ console.log(chalk.gray(` id: ${entry.id}`));
854
+ console.log(chalk.gray(` reason: ${reason}`));
855
+ if (replacement) {
856
+ console.log(chalk.gray(` replacement: ${replacement}`));
857
+ }
858
+ }
859
+
860
+ return result;
861
+ }
862
+
863
+ async function runErrorbookRequalifyCommand(options = {}, dependencies = {}) {
864
+ const projectPath = dependencies.projectPath || process.cwd();
865
+ const fileSystem = dependencies.fileSystem || fs;
866
+ const paths = resolveErrorbookPaths(projectPath);
867
+ const index = await readErrorbookIndex(paths, fileSystem);
868
+
869
+ const id = normalizeText(options.id || options.entryId);
870
+ if (!id) {
871
+ throw new Error('entry id is required');
872
+ }
873
+
874
+ const summary = findSummaryById(index, id);
875
+ if (!summary) {
876
+ throw new Error(`errorbook entry not found: ${id}`);
877
+ }
878
+
879
+ const status = normalizeStatus(options.status || 'verified');
880
+ if (status === 'promoted') {
881
+ throw new Error('requalify does not accept status=promoted. Use "sce errorbook promote <id>"');
882
+ }
883
+ if (status === 'deprecated') {
884
+ throw new Error('requalify does not accept status=deprecated. Use "sce errorbook deprecate <id>"');
885
+ }
886
+
887
+ const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
888
+ if (!entry) {
889
+ throw new Error(`errorbook entry file not found: ${summary.id}`);
890
+ }
891
+
892
+ if (status === 'verified' && (!Array.isArray(entry.verification_evidence) || entry.verification_evidence.length === 0)) {
893
+ throw new Error('requalify to verified requires verification_evidence');
894
+ }
895
+
896
+ entry.status = status;
897
+ entry.updated_at = nowIso();
898
+ entry.requalified_at = entry.updated_at;
899
+ if (entry.deprecation) {
900
+ delete entry.deprecation;
901
+ }
902
+ entry.quality_score = scoreQuality(entry);
903
+ await writeErrorbookEntry(paths, entry, fileSystem);
904
+
905
+ const updatedSummary = buildIndexSummary(entry);
906
+ const targetIndex = index.entries.findIndex((item) => item.id === entry.id);
907
+ if (targetIndex >= 0) {
908
+ index.entries[targetIndex] = updatedSummary;
909
+ } else {
910
+ index.entries.push(updatedSummary);
911
+ }
912
+ index.entries.sort((left, right) => `${right.updated_at}`.localeCompare(`${left.updated_at}`));
913
+ await writeErrorbookIndex(paths, index, fileSystem);
914
+
915
+ const result = {
916
+ mode: 'errorbook-requalify',
917
+ requalified: true,
918
+ entry
919
+ };
920
+
921
+ if (options.json) {
922
+ console.log(JSON.stringify(result, null, 2));
923
+ } else if (!options.silent) {
924
+ console.log(chalk.green('✓ Requalified errorbook entry'));
925
+ console.log(chalk.gray(` id: ${entry.id}`));
926
+ console.log(chalk.gray(` status: ${entry.status}`));
927
+ }
928
+
929
+ return result;
930
+ }
931
+
932
+ function collectOptionValue(value, previous = []) {
933
+ const next = Array.isArray(previous) ? previous : [];
934
+ next.push(value);
935
+ return next;
936
+ }
937
+
938
+ function emitCommandError(error, json) {
939
+ if (json) {
940
+ console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
941
+ } else {
942
+ console.error(chalk.red('Error:'), error.message);
943
+ }
944
+ process.exit(1);
945
+ }
946
+
947
+ function registerErrorbookCommands(program) {
948
+ const errorbook = program
949
+ .command('errorbook')
950
+ .description('Curated failure-remediation knowledge base');
951
+
952
+ errorbook
953
+ .command('record')
954
+ .description('Record a high-signal failure remediation entry')
955
+ .option('--title <text>', 'Entry title')
956
+ .option('--symptom <text>', 'Observed symptom')
957
+ .option('--root-cause <text>', 'Validated root cause')
958
+ .option('--fix-action <text>', 'Concrete fix action (repeatable)', collectOptionValue, [])
959
+ .option('--verification <text>', 'Verification evidence (repeatable)', collectOptionValue, [])
960
+ .option('--tags <csv>', 'Tags, comma-separated')
961
+ .option('--ontology <csv>', `Ontology focus tags (${ERRORBOOK_ONTOLOGY_TAGS.join(', ')})`)
962
+ .option('--status <status>', 'candidate|verified', 'candidate')
963
+ .option('--fingerprint <text>', 'Custom deduplication fingerprint')
964
+ .option('--from <path>', 'Load payload from JSON file')
965
+ .option('--spec <spec>', 'Related spec id/name')
966
+ .option('--json', 'Emit machine-readable JSON')
967
+ .action(async (options) => {
968
+ try {
969
+ await runErrorbookRecordCommand(options);
970
+ } catch (error) {
971
+ emitCommandError(error, options.json);
972
+ }
973
+ });
974
+
975
+ errorbook
976
+ .command('list')
977
+ .description('List curated errorbook entries')
978
+ .option('--status <status>', `Filter by status (${ERRORBOOK_STATUSES.join(', ')})`)
979
+ .option('--tag <tag>', 'Filter by tag')
980
+ .option('--ontology <tag>', `Filter by ontology tag (${ERRORBOOK_ONTOLOGY_TAGS.join(', ')})`)
981
+ .option('--min-quality <n>', 'Minimum quality score', parseInt)
982
+ .option('--limit <n>', 'Maximum entries returned', parseInt, 20)
983
+ .option('--json', 'Emit machine-readable JSON')
984
+ .action(async (options) => {
985
+ try {
986
+ await runErrorbookListCommand(options);
987
+ } catch (error) {
988
+ emitCommandError(error, options.json);
989
+ }
990
+ });
991
+
992
+ errorbook
993
+ .command('show <id>')
994
+ .description('Show a single errorbook entry')
995
+ .option('--json', 'Emit machine-readable JSON')
996
+ .action(async (id, options) => {
997
+ try {
998
+ await runErrorbookShowCommand({ ...options, id });
999
+ } catch (error) {
1000
+ emitCommandError(error, options.json);
1001
+ }
1002
+ });
1003
+
1004
+ errorbook
1005
+ .command('find')
1006
+ .description('Search curated entries with ranking')
1007
+ .requiredOption('--query <text>', 'Search query')
1008
+ .option('--status <status>', `Filter by status (${ERRORBOOK_STATUSES.join(', ')})`)
1009
+ .option('--limit <n>', 'Maximum entries returned', parseInt, 10)
1010
+ .option('--json', 'Emit machine-readable JSON')
1011
+ .action(async (options) => {
1012
+ try {
1013
+ await runErrorbookFindCommand(options);
1014
+ } catch (error) {
1015
+ emitCommandError(error, options.json);
1016
+ }
1017
+ });
1018
+
1019
+ errorbook
1020
+ .command('promote <id>')
1021
+ .description('Promote entry after strict quality gate')
1022
+ .option('--json', 'Emit machine-readable JSON')
1023
+ .action(async (id, options) => {
1024
+ try {
1025
+ await runErrorbookPromoteCommand({ ...options, id });
1026
+ } catch (error) {
1027
+ emitCommandError(error, options.json);
1028
+ }
1029
+ });
1030
+
1031
+ errorbook
1032
+ .command('deprecate <id>')
1033
+ .description('Deprecate low-value or obsolete entry')
1034
+ .requiredOption('--reason <text>', 'Deprecation reason')
1035
+ .option('--replacement <id>', 'Replacement entry id')
1036
+ .option('--json', 'Emit machine-readable JSON')
1037
+ .action(async (id, options) => {
1038
+ try {
1039
+ await runErrorbookDeprecateCommand({ ...options, id });
1040
+ } catch (error) {
1041
+ emitCommandError(error, options.json);
1042
+ }
1043
+ });
1044
+
1045
+ errorbook
1046
+ .command('requalify <id>')
1047
+ .description('Requalify deprecated/candidate entry back to candidate|verified')
1048
+ .option('--status <status>', 'candidate|verified', 'verified')
1049
+ .option('--json', 'Emit machine-readable JSON')
1050
+ .action(async (id, options) => {
1051
+ try {
1052
+ await runErrorbookRequalifyCommand({ ...options, id });
1053
+ } catch (error) {
1054
+ emitCommandError(error, options.json);
1055
+ }
1056
+ });
1057
+ }
1058
+
1059
+ module.exports = {
1060
+ ERRORBOOK_STATUSES,
1061
+ ERRORBOOK_ONTOLOGY_TAGS,
1062
+ DEFAULT_PROMOTE_MIN_QUALITY,
1063
+ resolveErrorbookPaths,
1064
+ normalizeOntologyTags,
1065
+ normalizeRecordPayload,
1066
+ scoreQuality,
1067
+ runErrorbookRecordCommand,
1068
+ runErrorbookListCommand,
1069
+ runErrorbookShowCommand,
1070
+ runErrorbookFindCommand,
1071
+ runErrorbookPromoteCommand,
1072
+ runErrorbookDeprecateCommand,
1073
+ runErrorbookRequalifyCommand,
1074
+ registerErrorbookCommands
1075
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scene-capability-engine",
3
- "version": "3.3.17",
3
+ "version": "3.3.18",
4
4
  "description": "SCE (Scene Capability Engine) - A CLI tool and npm package for spec-driven development with AI coding assistants.",
5
5
  "main": "index.js",
6
6
  "bin": {