@dzhechkov/harness-core 0.8.6 → 0.8.11

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.
Files changed (164) hide show
  1. package/.dz-manifest.json +361 -97
  2. package/README.md +48 -1
  3. package/dist/amendment-trace.d.ts.map +1 -1
  4. package/dist/amendment-trace.js +12 -1
  5. package/dist/amendment-trace.js.map +1 -1
  6. package/dist/codex-invoke.d.ts +73 -0
  7. package/dist/codex-invoke.d.ts.map +1 -0
  8. package/dist/codex-invoke.js +80 -0
  9. package/dist/codex-invoke.js.map +1 -0
  10. package/dist/compounding.d.ts +54 -0
  11. package/dist/compounding.d.ts.map +1 -1
  12. package/dist/compounding.js +221 -1
  13. package/dist/compounding.js.map +1 -1
  14. package/dist/discrimination-gate.d.ts +63 -3
  15. package/dist/discrimination-gate.d.ts.map +1 -1
  16. package/dist/discrimination-gate.js +113 -16
  17. package/dist/discrimination-gate.js.map +1 -1
  18. package/dist/eta.d.ts +92 -0
  19. package/dist/eta.d.ts.map +1 -0
  20. package/dist/eta.js +488 -0
  21. package/dist/eta.js.map +1 -0
  22. package/dist/event-chain.d.ts +30 -0
  23. package/dist/event-chain.d.ts.map +1 -1
  24. package/dist/event-chain.js +24 -0
  25. package/dist/event-chain.js.map +1 -1
  26. package/dist/feature-adr-checkpoints.js +1 -1
  27. package/dist/feature-adr-decision-recall.d.ts +167 -0
  28. package/dist/feature-adr-decision-recall.d.ts.map +1 -0
  29. package/dist/feature-adr-decision-recall.js +519 -0
  30. package/dist/feature-adr-decision-recall.js.map +1 -0
  31. package/dist/feature-adr-landing.d.ts +37 -0
  32. package/dist/feature-adr-landing.d.ts.map +1 -0
  33. package/dist/feature-adr-landing.js +59 -0
  34. package/dist/feature-adr-landing.js.map +1 -0
  35. package/dist/feature-adr-routing.d.ts +2 -2
  36. package/dist/feature-adr-routing.d.ts.map +1 -1
  37. package/dist/feature-adr-routing.js +7 -11
  38. package/dist/feature-adr-routing.js.map +1 -1
  39. package/dist/guard-promotion.d.ts +41 -0
  40. package/dist/guard-promotion.d.ts.map +1 -1
  41. package/dist/guard-promotion.js +218 -4
  42. package/dist/guard-promotion.js.map +1 -1
  43. package/dist/guard-volume.d.ts +108 -0
  44. package/dist/guard-volume.d.ts.map +1 -0
  45. package/dist/guard-volume.js +536 -0
  46. package/dist/guard-volume.js.map +1 -0
  47. package/dist/guard.d.ts +17 -0
  48. package/dist/guard.d.ts.map +1 -1
  49. package/dist/guard.js +92 -4
  50. package/dist/guard.js.map +1 -1
  51. package/dist/index.d.ts +24 -7
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +19 -5
  54. package/dist/index.js.map +1 -1
  55. package/dist/integration-apply.d.ts +25 -0
  56. package/dist/integration-apply.d.ts.map +1 -0
  57. package/dist/integration-apply.js +299 -0
  58. package/dist/integration-apply.js.map +1 -0
  59. package/dist/integration-evidence.d.ts +46 -0
  60. package/dist/integration-evidence.d.ts.map +1 -0
  61. package/dist/integration-evidence.js +44 -0
  62. package/dist/integration-evidence.js.map +1 -0
  63. package/dist/integration-probe-worker.d.ts +22 -0
  64. package/dist/integration-probe-worker.d.ts.map +1 -0
  65. package/dist/integration-probe-worker.js +334 -0
  66. package/dist/integration-probe-worker.js.map +1 -0
  67. package/dist/integrations-verify.d.ts +60 -0
  68. package/dist/integrations-verify.d.ts.map +1 -0
  69. package/dist/integrations-verify.js +194 -0
  70. package/dist/integrations-verify.js.map +1 -0
  71. package/dist/lesson-generalization.d.ts +29 -0
  72. package/dist/lesson-generalization.d.ts.map +1 -0
  73. package/dist/lesson-generalization.js +84 -0
  74. package/dist/lesson-generalization.js.map +1 -0
  75. package/dist/mutation-gate.d.ts +39 -36
  76. package/dist/mutation-gate.d.ts.map +1 -1
  77. package/dist/mutation-gate.js +111 -5
  78. package/dist/mutation-gate.js.map +1 -1
  79. package/dist/operations.d.ts +27 -0
  80. package/dist/operations.d.ts.map +1 -1
  81. package/dist/operations.js +186 -7
  82. package/dist/operations.js.map +1 -1
  83. package/dist/patterns.d.ts +27 -1
  84. package/dist/patterns.d.ts.map +1 -1
  85. package/dist/patterns.js +211 -45
  86. package/dist/patterns.js.map +1 -1
  87. package/dist/plugin.d.ts.map +1 -1
  88. package/dist/plugin.js +27 -5
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/recommend.d.ts +4 -5
  91. package/dist/recommend.d.ts.map +1 -1
  92. package/dist/recommend.js +110 -45
  93. package/dist/recommend.js.map +1 -1
  94. package/dist/registry.d.ts +32 -1
  95. package/dist/registry.d.ts.map +1 -1
  96. package/dist/registry.js +165 -9
  97. package/dist/registry.js.map +1 -1
  98. package/dist/run-records.d.ts +3 -0
  99. package/dist/run-records.d.ts.map +1 -1
  100. package/dist/run-records.js +18 -0
  101. package/dist/run-records.js.map +1 -1
  102. package/dist/score.d.ts +95 -0
  103. package/dist/score.d.ts.map +1 -1
  104. package/dist/score.js +274 -2
  105. package/dist/score.js.map +1 -1
  106. package/dist/setup.d.ts.map +1 -1
  107. package/dist/setup.js +20 -17
  108. package/dist/setup.js.map +1 -1
  109. package/dist/skill-selection.d.ts +72 -0
  110. package/dist/skill-selection.d.ts.map +1 -0
  111. package/dist/skill-selection.js +76 -0
  112. package/dist/skill-selection.js.map +1 -0
  113. package/dist/stem.d.ts +12 -0
  114. package/dist/stem.d.ts.map +1 -0
  115. package/dist/stem.js +89 -0
  116. package/dist/stem.js.map +1 -0
  117. package/dist/target-integrations.d.ts +65 -0
  118. package/dist/target-integrations.d.ts.map +1 -0
  119. package/dist/target-integrations.js +152 -0
  120. package/dist/target-integrations.js.map +1 -0
  121. package/dist/telemetry-vocabulary.d.ts +7 -0
  122. package/dist/telemetry-vocabulary.d.ts.map +1 -1
  123. package/dist/telemetry-vocabulary.js +29 -0
  124. package/dist/telemetry-vocabulary.js.map +1 -1
  125. package/dist/vector-tier.d.ts +6 -1
  126. package/dist/vector-tier.d.ts.map +1 -1
  127. package/dist/vector-tier.js +32 -7
  128. package/dist/vector-tier.js.map +1 -1
  129. package/package.json +7 -6
  130. package/sbom.json +772 -112
  131. package/src/amendment-trace.ts +12 -1
  132. package/src/codex-invoke.ts +138 -0
  133. package/src/compounding.ts +300 -1
  134. package/src/discrimination-gate.ts +183 -19
  135. package/src/eta.ts +590 -0
  136. package/src/event-chain.ts +41 -0
  137. package/src/feature-adr-checkpoints.ts +1 -1
  138. package/src/feature-adr-decision-recall.ts +652 -0
  139. package/src/feature-adr-landing.ts +109 -0
  140. package/src/feature-adr-routing.ts +7 -11
  141. package/src/guard-promotion.ts +245 -4
  142. package/src/guard-volume.ts +752 -0
  143. package/src/guard.ts +110 -4
  144. package/src/index.ts +73 -6
  145. package/src/integration-apply.ts +332 -0
  146. package/src/integration-evidence.ts +89 -0
  147. package/src/integration-probe-worker.ts +310 -0
  148. package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
  149. package/src/integrations-verify.ts +258 -0
  150. package/src/lesson-generalization.ts +115 -0
  151. package/src/mutation-gate.ts +165 -5
  152. package/src/operations.ts +207 -7
  153. package/src/patterns.ts +252 -43
  154. package/src/plugin.ts +27 -5
  155. package/src/recommend.ts +116 -46
  156. package/src/registry.ts +144 -11
  157. package/src/run-records.ts +23 -0
  158. package/src/score.ts +361 -3
  159. package/src/setup.ts +20 -17
  160. package/src/skill-selection.ts +111 -0
  161. package/src/stem.ts +87 -0
  162. package/src/target-integrations.ts +225 -0
  163. package/src/telemetry-vocabulary.ts +36 -0
  164. package/src/vector-tier.ts +44 -14
package/src/patterns.ts CHANGED
@@ -32,6 +32,13 @@ import type { VectorEntry } from './vector-tier.js';
32
32
  import { rankLessonsByDelta, type LessonHistory } from './safla-delta.js';
33
33
  import { withStoreLock, withStoreLockSync, StoreLockTimeoutError, StoreLockCompromisedError } from './store-lock.js';
34
34
  import { describeNativeDep, exerciseSqliteOpen, probeNativeDep } from './native-dep-probe.js';
35
+ import {
36
+ lessonPairIdOf,
37
+ mergeLessonFormHits,
38
+ validateClassTemplate,
39
+ type LessonForm,
40
+ type LessonMatchedForm,
41
+ } from './lesson-generalization.js';
35
42
 
36
43
  /** A learned pattern as written by `dz teach` to `.dz/patterns.jsonl`. */
37
44
  export interface PatternRecord {
@@ -47,6 +54,12 @@ export interface PatternRecord {
47
54
  readonly ts: string;
48
55
  /** Origin of the record (e.g. "dz-teach"). */
49
56
  readonly source: string;
57
+ /** Durable role in an optional linked lesson pair. */
58
+ readonly lessonForm?: LessonForm;
59
+ /** Stable identity shared by the specific and class rows. */
60
+ readonly lessonPairId?: string;
61
+ /** Read-model companion; derived from the linked class row, never persisted on this row. */
62
+ readonly classForm?: string;
50
63
  }
51
64
 
52
65
  /** A session lifecycle event as written by the session hooks to `.dz/sessions.jsonl`. */
@@ -371,6 +384,25 @@ function sqlitePath(projectRoot: string): string {
371
384
  return join(projectRoot, '.dz', 'memory', 'patterns.sqlite');
372
385
  }
373
386
 
387
+ function classFormMarkerPath(projectRoot: string): string {
388
+ return join(projectRoot, '.dz', 'memory', '.class-forms-present');
389
+ }
390
+
391
+ function ensureClassFormMarker(projectRoot: string): void {
392
+ const marker = classFormMarkerPath(projectRoot);
393
+ if (existsSync(marker)) return;
394
+ mkdirSync(dirname(marker), { recursive: true });
395
+ try {
396
+ writeFileSync(marker, 'lesson-class-forms/1\n', { flag: 'wx' });
397
+ } catch (error) {
398
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
399
+ }
400
+ }
401
+
402
+ function classFormIndexPresent(projectRoot: string): boolean {
403
+ return existsSync(classFormMarkerPath(projectRoot));
404
+ }
405
+
374
406
  /**
375
407
  * Open the SQLite store backend if the configured mode allows it AND the native
376
408
  * `better-sqlite3` is loadable. Returns `undefined` to fall back to JSON (the
@@ -404,7 +436,11 @@ function tryOpenSqlite(projectRoot: string, mode: SqliteBackendMode): SqliteBack
404
436
  * identity, keeping migration/import an idempotent upsert.
405
437
  */
406
438
  function patternIdentity(p: PatternRecord): string {
407
- return `${p.pattern}|${p.ts}|${p.reward}|${p.domain}|${p.type}`;
439
+ const legacy = `${p.pattern}|${p.ts}|${p.reward}|${p.domain}|${p.type}`;
440
+ // Existing rows must retain their historical ids; only a complete pair identity changes the key.
441
+ return p.lessonPairId !== undefined && p.lessonForm !== undefined
442
+ ? `${legacy}|${p.lessonForm}|${p.lessonPairId}`
443
+ : legacy;
408
444
  }
409
445
 
410
446
  /** Deterministic store id derived from the full-content {@link patternIdentity}. */
@@ -434,6 +470,9 @@ const PATTERN_TYPES: ReadonlySet<string> = new Set(['rule', 'success-pattern', '
434
470
 
435
471
  /** Anti-corruption mapping: harness `PatternRecord` → canonical `MemoryRecord`. */
436
472
  export function patternToRecord(p: PatternRecord): MemoryRecord {
473
+ const pairMetadata = p.lessonPairId !== undefined && p.lessonForm !== undefined
474
+ ? { lessonForm: p.lessonForm, lessonPairId: p.lessonPairId }
475
+ : {};
437
476
  return {
438
477
  id: recordId(p),
439
478
  skillId: '', // taught patterns are free-text, not tied to one skill
@@ -447,12 +486,18 @@ export function patternToRecord(p: PatternRecord): MemoryRecord {
447
486
  // the learn-loop write path.
448
487
  outcome: PATTERN_TYPES.has(p.type) ? p.type : 'lesson-learned',
449
488
  timestamp: p.ts,
450
- metadata: { domain: p.domain ?? 'general', source: p.source ?? 'dz-teach' },
489
+ metadata: { domain: p.domain ?? 'general', source: p.source ?? 'dz-teach', ...pairMetadata },
451
490
  };
452
491
  }
453
492
 
454
493
  /** Anti-corruption mapping: canonical `MemoryRecord` → harness `PatternRecord`. */
455
494
  export function recordToPattern(r: MemoryRecord): PatternRecord {
495
+ const lessonForm = r.metadata?.['lessonForm'];
496
+ const lessonPairId = r.metadata?.['lessonPairId'];
497
+ const pair: { lessonForm?: LessonForm; lessonPairId?: string } = (lessonForm === 'specific' || lessonForm === 'class')
498
+ && typeof lessonPairId === 'string' && lessonPairId !== ''
499
+ ? { lessonForm, lessonPairId }
500
+ : {};
456
501
  return {
457
502
  pattern: r.text,
458
503
  type: PATTERN_TYPES.has(r.outcome) ? (r.outcome as PatternRecord['type']) : 'lesson-learned',
@@ -460,6 +505,7 @@ export function recordToPattern(r: MemoryRecord): PatternRecord {
460
505
  domain: r.metadata?.['domain'] ?? 'general',
461
506
  ts: r.timestamp,
462
507
  source: r.metadata?.['source'] ?? 'dz-teach',
508
+ ...pair,
463
509
  };
464
510
  }
465
511
 
@@ -652,6 +698,10 @@ async function reinforcePatternLocked(projectRoot: string, dzIdOrText: string, o
652
698
  const records = loadStoreRecords(projectRoot);
653
699
  const rec = records.find((r) => r.id === dzIdOrText || r.text === dzIdOrText);
654
700
  if (rec === undefined) return { ok: false, error: `no learned pattern matches ${JSON.stringify(dzIdOrText)}` };
701
+ const pairId = typeof rec.metadata?.['lessonPairId'] === 'string' ? rec.metadata['lessonPairId'] : undefined;
702
+ const targets = pairId === undefined
703
+ ? [rec]
704
+ : records.filter((record) => record.metadata?.['lessonPairId'] === pairId);
655
705
  const prev = readReinforcementState(rec);
656
706
  const ts = opts.ts ?? new Date().toISOString();
657
707
  const observed = opts.reward !== undefined && Number.isFinite(opts.reward) ? Math.max(0, Math.min(1, opts.reward)) : rec.score;
@@ -667,17 +717,15 @@ async function reinforcePatternLocked(projectRoot: string, dzIdOrText: string, o
667
717
  // EXPOSURE (a recall-hit sample) is not confirmation — stats update, quarantine stays (the
668
718
  // no-promotion-by-exposure invariant; found by cross-model QE as a live hole: recall-hit
669
719
  // flushes were routed through this same function and silently promoted every viewed lesson).
670
- const promotedMeta = { ...(rec.metadata ?? {}), ...encodeReinforcementState(nextState) };
671
- if (opts.exposure !== true) {
672
- delete promotedMeta['qStatus'];
673
- delete promotedMeta['quarantinedAt'];
720
+ for (const target of targets) {
721
+ const promotedMeta = { ...(target.metadata ?? {}), ...encodeReinforcementState(nextState) };
722
+ if (opts.exposure !== true) {
723
+ delete promotedMeta['qStatus'];
724
+ delete promotedMeta['quarantinedAt'];
725
+ }
726
+ const put = await putStoreRecord(projectRoot, { ...target, metadata: promotedMeta });
727
+ if ('error' in put) return { ok: false, dzId: rec.id, error: put.error };
674
728
  }
675
- const next: MemoryRecord = {
676
- ...rec,
677
- metadata: promotedMeta,
678
- };
679
- const put = await putStoreRecord(projectRoot, next);
680
- if ('error' in put) return { ok: false, dzId: rec.id, error: put.error };
681
729
  try {
682
730
  appendFileSync(join(projectRoot, '.dz', 'sessions.jsonl'), JSON.stringify({ event: 'reinforce', ts, dzId: rec.id, uses: nextState.uses }) + '\n');
683
731
  } catch { /* best-effort */ }
@@ -687,14 +735,18 @@ async function reinforcePatternLocked(projectRoot: string, dzIdOrText: string, o
687
735
  export async function updateReinforcementState(projectRoot: string, dzId: string, state: ReinforcementState): Promise<ReinforcePatternResult> {
688
736
  try {
689
737
  return await withStoreLock(projectRoot, async () => {
690
- const rec = loadStoreRecords(projectRoot).find((r) => r.id === dzId);
738
+ const records = loadStoreRecords(projectRoot);
739
+ const rec = records.find((r) => r.id === dzId);
691
740
  if (rec === undefined) return { ok: false, error: `no learned pattern matches ${JSON.stringify(dzId)}` };
692
- const next: MemoryRecord = {
693
- ...rec,
694
- metadata: { ...(rec.metadata ?? {}), ...encodeReinforcementState(state) },
695
- };
696
- const put = await putStoreRecord(projectRoot, next);
697
- if ('error' in put) return { ok: false, dzId, error: put.error };
741
+ const pairId = typeof rec.metadata?.['lessonPairId'] === 'string' ? rec.metadata['lessonPairId'] : undefined;
742
+ const targets = pairId === undefined ? [rec] : records.filter((r) => r.metadata?.['lessonPairId'] === pairId);
743
+ for (const target of targets) {
744
+ const put = await putStoreRecord(projectRoot, {
745
+ ...target,
746
+ metadata: { ...(target.metadata ?? {}), ...encodeReinforcementState(state) },
747
+ });
748
+ if ('error' in put) return { ok: false, dzId, error: put.error };
749
+ }
698
750
  return { ok: true, dzId, uses: state.uses };
699
751
  });
700
752
  } catch (err) {
@@ -724,16 +776,23 @@ export async function promotePatterns(projectRoot: string, dzIds: readonly strin
724
776
  const promoted: string[] = [];
725
777
  const notFound: string[] = [];
726
778
  const notQuarantined: string[] = [];
779
+ const handled = new Set<string>();
727
780
  for (const id of dzIds) {
728
781
  const rec = records.find((r) => r.id === id);
729
782
  if (rec === undefined) { notFound.push(id); continue; }
730
- if (!readQuarantineState(rec).quarantined) { notQuarantined.push(id); continue; }
731
- const meta = { ...(rec.metadata ?? {}) };
732
- delete meta['qStatus'];
733
- delete meta['quarantinedAt'];
734
- const put = await putStoreRecord(projectRoot, { ...rec, metadata: meta });
735
- if ('error' in put) return { ok: false, promoted, notFound, notQuarantined, error: put.error };
736
- promoted.push(id);
783
+ const pairId = typeof rec.metadata?.['lessonPairId'] === 'string' ? rec.metadata['lessonPairId'] : undefined;
784
+ const targets = pairId === undefined ? [rec] : records.filter((r) => r.metadata?.['lessonPairId'] === pairId);
785
+ const pending = targets.filter((target) => !handled.has(target.id) && readQuarantineState(target).quarantined);
786
+ if (pending.length === 0) { notQuarantined.push(id); continue; }
787
+ for (const target of pending) {
788
+ const meta = { ...(target.metadata ?? {}) };
789
+ delete meta['qStatus'];
790
+ delete meta['quarantinedAt'];
791
+ const put = await putStoreRecord(projectRoot, { ...target, metadata: meta });
792
+ if ('error' in put) return { ok: false, promoted, notFound, notQuarantined, error: put.error };
793
+ handled.add(target.id);
794
+ promoted.push(target.id);
795
+ }
737
796
  }
738
797
  return { ok: true, promoted, notFound, notQuarantined };
739
798
  });
@@ -969,6 +1028,7 @@ function migrationRecords(projectRoot: string): MemoryRecord[] {
969
1028
  * JSON file is never deleted. Returns the total record count after the write.
970
1029
  */
971
1030
  export async function recordPattern(projectRoot: string, p: PatternRecord, opts: { quarantine?: boolean } = {}): Promise<number> {
1031
+ if (p.lessonForm === 'class') ensureClassFormMarker(projectRoot);
972
1032
  const { sqliteBackend } = readLearningConfig(projectRoot);
973
1033
  // lesson-quarantine: a fresh lesson is a HYPOTHESIS — mark it when the feature is on. Folded
974
1034
  // legacy records are NEVER marked (they predate the feature: grandfathered as promoted).
@@ -1001,6 +1061,61 @@ export async function recordPattern(projectRoot: string, p: PatternRecord, opts:
1001
1061
  });
1002
1062
  }
1003
1063
 
1064
+ export interface RecordLessonFormsResult {
1065
+ readonly count: number;
1066
+ readonly specific: 'stored';
1067
+ readonly class: 'absent' | 'stored' | 'rejected' | 'failed';
1068
+ readonly reason?: string;
1069
+ readonly records: readonly PatternRecord[];
1070
+ }
1071
+
1072
+ export async function recordLessonForms(
1073
+ projectRoot: string,
1074
+ specific: PatternRecord,
1075
+ classTemplate?: string,
1076
+ opts: {
1077
+ readonly quarantine?: boolean;
1078
+ readonly writeRecord?: (root: string, record: PatternRecord, options: { quarantine?: boolean }) => Promise<number>;
1079
+ } = {},
1080
+ ): Promise<RecordLessonFormsResult> {
1081
+ const persist = opts.writeRecord ?? recordPattern;
1082
+ const persistOptions = opts.quarantine === true ? { quarantine: true } : {};
1083
+ const proposed = classTemplate?.trim();
1084
+ if (proposed === undefined || proposed === '') {
1085
+ const count = await persist(projectRoot, specific, persistOptions);
1086
+ return { count, specific: 'stored', class: 'absent', records: [specific] };
1087
+ }
1088
+ const validation = proposed === specific.pattern
1089
+ ? { ok: true as const }
1090
+ : validateClassTemplate(specific.pattern, proposed);
1091
+ if (!validation.ok) {
1092
+ const count = await persist(projectRoot, specific, persistOptions);
1093
+ return { count, specific: 'stored', class: 'rejected', reason: validation.reason, records: [specific] };
1094
+ }
1095
+ const lessonPairId = lessonPairIdOf(specific.pattern, proposed, specific.ts);
1096
+ const specificRow: PatternRecord = { ...specific, lessonForm: 'specific', lessonPairId };
1097
+ const classRow: PatternRecord = {
1098
+ ...specific,
1099
+ pattern: proposed,
1100
+ source: 'dz-teach-class',
1101
+ lessonForm: 'class',
1102
+ lessonPairId,
1103
+ };
1104
+ const firstCount = await persist(projectRoot, specificRow, persistOptions);
1105
+ try {
1106
+ const count = await persist(projectRoot, classRow, persistOptions);
1107
+ return { count, specific: 'stored', class: 'stored', records: [specificRow, classRow] };
1108
+ } catch (error) {
1109
+ return {
1110
+ count: firstCount,
1111
+ specific: 'stored',
1112
+ class: 'failed',
1113
+ reason: error instanceof Error ? error.message : String(error),
1114
+ records: [specificRow],
1115
+ };
1116
+ }
1117
+ }
1118
+
1004
1119
  /** Outcome of a {@link recallPatterns} ranked search (or a hybrid merge — see `vector-tier.ts`). */
1005
1120
  export interface RecallHit {
1006
1121
  /** The matched pattern. */
@@ -1013,6 +1128,76 @@ export interface RecallHit {
1013
1128
  readonly backend: 'sqlite' | 'json' | 'vector' | 'both';
1014
1129
  /** lesson-quarantine: set (true) only for a quarantined hit — display marks it ⚠q and ranking damps/sinks it. */
1015
1130
  readonly quarantined?: boolean;
1131
+ /** Which stored formulation caused this logical lesson to rank. */
1132
+ readonly matchedForm?: LessonMatchedForm;
1133
+ }
1134
+
1135
+ export interface RecallPatternsOptions {
1136
+ readonly onClassDegraded?: (message: string) => void;
1137
+ readonly classMatcher?: typeof validateClassTemplate;
1138
+ }
1139
+
1140
+ function mergeRecallRows(
1141
+ records: readonly MemoryRecord[],
1142
+ ranked: readonly MemoryRecord[],
1143
+ backend: 'sqlite' | 'json',
1144
+ query: string,
1145
+ limit: number,
1146
+ opts: RecallPatternsOptions,
1147
+ ): RecallHit[] {
1148
+ const pairRows = new Map<string, { specific?: MemoryRecord; classRow?: MemoryRecord }>();
1149
+ for (const record of records) {
1150
+ const pattern = recordToPattern(record);
1151
+ if (pattern.lessonPairId === undefined || pattern.lessonForm === undefined) continue;
1152
+ const pair = pairRows.get(pattern.lessonPairId) ?? {};
1153
+ if (pattern.lessonForm === 'specific') pair.specific = record;
1154
+ else pair.classRow = record;
1155
+ pairRows.set(pattern.lessonPairId, pair);
1156
+ }
1157
+ const rankedIds = new Set(ranked.map((record) => record.id));
1158
+ const specificHits: Array<{ key: string; value: RecallHit; matchedForm: 'specific' }> = [];
1159
+ const rankedClassRows: MemoryRecord[] = [];
1160
+ const logicalHit = (record: MemoryRecord): RecallHit => {
1161
+ const source = recordToPattern(record);
1162
+ const pair = source.lessonPairId === undefined ? undefined : pairRows.get(source.lessonPairId);
1163
+ const specificRecord = source.lessonForm === 'class' ? pair?.specific : record;
1164
+ const classRecord = source.lessonForm === 'class' ? record : pair?.classRow;
1165
+ const base = recordToPattern(specificRecord ?? record);
1166
+ const pattern = classRecord === undefined ? base : { ...base, classForm: classRecord.text };
1167
+ const quarantined = readQuarantineState(specificRecord ?? record).quarantined
1168
+ || (classRecord !== undefined && readQuarantineState(classRecord).quarantined);
1169
+ return { pattern, backend, ...(quarantined ? { quarantined: true as const } : {}) };
1170
+ };
1171
+ for (const record of ranked) {
1172
+ const pattern = recordToPattern(record);
1173
+ const key = pattern.lessonPairId ?? record.id;
1174
+ if (pattern.lessonForm === 'class') rankedClassRows.push(record);
1175
+ else specificHits.push({ key, value: logicalHit(record), matchedForm: 'specific' });
1176
+ }
1177
+ const finish = (classHits: Array<{ key: string; value: RecallHit; matchedForm: 'class' }>): RecallHit[] =>
1178
+ sinkQuarantined(
1179
+ mergeLessonFormHits(specificHits, classHits, limit * 2).map((hit) => ({ ...hit.value, matchedForm: hit.matchedForm })),
1180
+ limit,
1181
+ );
1182
+ try {
1183
+ const classHits: Array<{ key: string; value: RecallHit; matchedForm: 'class' }> = rankedClassRows.map((record) => {
1184
+ const pattern = recordToPattern(record);
1185
+ return { key: pattern.lessonPairId ?? record.id, value: logicalHit(record), matchedForm: 'class' };
1186
+ });
1187
+ const classMatcher = opts.classMatcher ?? validateClassTemplate;
1188
+ for (const record of records) {
1189
+ const pattern = recordToPattern(record);
1190
+ if (pattern.lessonForm !== 'class' || rankedIds.has(record.id)) continue;
1191
+ if (classMatcher(query, pattern.pattern).ok) {
1192
+ classHits.push({ key: pattern.lessonPairId ?? record.id, value: logicalHit(record), matchedForm: 'class' });
1193
+ }
1194
+ }
1195
+ return finish(classHits);
1196
+ } catch (error) {
1197
+ const message = `class-form search degraded: ${error instanceof Error ? error.message : String(error)}`;
1198
+ try { opts.onClassDegraded?.(message); } catch { /* an advisory sink may not suppress specific recall */ }
1199
+ return finish([]);
1200
+ }
1016
1201
  }
1017
1202
 
1018
1203
  /**
@@ -1021,36 +1206,48 @@ export interface RecallHit {
1021
1206
  * recall — note it is *lexical*, not vector similarity (true embedding/HNSW recall
1022
1207
  * lives in the `agentdb-memory` MCP skill, MCP-host only). Graceful: `[]` on failure.
1023
1208
  */
1024
- export function recallPatterns(projectRoot: string, query: string, limit = 10): RecallHit[] {
1209
+ export function recallPatterns(
1210
+ projectRoot: string,
1211
+ query: string,
1212
+ limit = 10,
1213
+ opts: RecallPatternsOptions = {},
1214
+ ): RecallHit[] {
1025
1215
  const { sqliteBackend } = readLearningConfig(projectRoot);
1026
1216
  if (sqliteBackend !== 'json' && existsSync(sqlitePath(projectRoot))) {
1027
1217
  try {
1028
1218
  const db = SqliteBackend.open(sqlitePath(projectRoot));
1029
1219
  try {
1030
- return sinkQuarantined(
1031
- db.querySync({ text: query, limit: limit * 2 }).map((r) => ({
1032
- pattern: recordToPattern(r),
1033
- backend: 'sqlite' as const,
1034
- ...(readQuarantineState(r).quarantined ? { quarantined: true as const } : {}),
1035
- })),
1036
- limit,
1037
- );
1220
+ if (!classFormIndexPresent(projectRoot)) {
1221
+ return sinkQuarantined(
1222
+ db.querySync({ text: query, limit: limit * 2 }).map((r) => ({
1223
+ pattern: recordToPattern(r),
1224
+ backend: 'sqlite' as const,
1225
+ ...(readQuarantineState(r).quarantined ? { quarantined: true as const } : {}),
1226
+ })),
1227
+ limit,
1228
+ );
1229
+ }
1230
+ const records = db.allSync();
1231
+ return mergeRecallRows(records, db.querySync({ text: query, limit: limit * 4 }), 'sqlite', query, limit, opts);
1038
1232
  } finally {
1039
1233
  db.close();
1040
1234
  }
1041
1235
  } catch { /* fall through to JSON */ }
1042
1236
  }
1043
1237
  try {
1044
- return sinkQuarantined(
1045
- JsonFileBackend.openSync(storePath(projectRoot))
1046
- .querySync({ text: query, limit: limit * 2 })
1047
- .map((r) => ({
1238
+ const backend = JsonFileBackend.openSync(storePath(projectRoot));
1239
+ if (!classFormIndexPresent(projectRoot)) {
1240
+ return sinkQuarantined(
1241
+ backend.querySync({ text: query, limit: limit * 2 }).map((r) => ({
1048
1242
  pattern: recordToPattern(r),
1049
1243
  backend: 'json' as const,
1050
1244
  ...(readQuarantineState(r).quarantined ? { quarantined: true as const } : {}),
1051
1245
  })),
1052
- limit,
1053
- );
1246
+ limit,
1247
+ );
1248
+ }
1249
+ const records = backend.allSync();
1250
+ return mergeRecallRows(records, backend.querySync({ text: query, limit: limit * 4 }), 'json', query, limit, opts);
1054
1251
  } catch {
1055
1252
  return [];
1056
1253
  }
@@ -1430,6 +1627,18 @@ export interface RemovePatternsResult {
1430
1627
  */
1431
1628
  export function removePatternsByIds(projectRoot: string, ids: ReadonlySet<string>): RemovePatternsResult {
1432
1629
  if (ids.size === 0) return { removed: 0 };
1630
+ const expandedIds = new Set(ids);
1631
+ const records = loadStoreRecords(projectRoot);
1632
+ const selectedPairs = new Set(
1633
+ records
1634
+ .filter((record) => ids.has(record.id) && typeof record.metadata?.['lessonPairId'] === 'string')
1635
+ .map((record) => record.metadata?.['lessonPairId'] as string),
1636
+ );
1637
+ for (const record of records) {
1638
+ if (typeof record.metadata?.['lessonPairId'] === 'string' && selectedPairs.has(record.metadata['lessonPairId'])) {
1639
+ expandedIds.add(record.id);
1640
+ }
1641
+ }
1433
1642
  let removed = 0;
1434
1643
  const errors: string[] = [];
1435
1644
 
@@ -1440,7 +1649,7 @@ export function removePatternsByIds(projectRoot: string, ids: ReadonlySet<string
1440
1649
  const db = SqliteBackend.open(sqlitePath(projectRoot));
1441
1650
  try {
1442
1651
  for (const r of db.allSync()) {
1443
- if (ids.has(r.id)) {
1652
+ if (expandedIds.has(r.id)) {
1444
1653
  db.removeSync(r.id);
1445
1654
  removed += 1;
1446
1655
  }
@@ -1463,7 +1672,7 @@ export function removePatternsByIds(projectRoot: string, ids: ReadonlySet<string
1463
1672
  const backend = JsonFileBackend.openSync(storePath(projectRoot));
1464
1673
  let localRemoved = 0;
1465
1674
  for (const r of backend.allSync()) {
1466
- if (ids.has(r.id)) {
1675
+ if (expandedIds.has(r.id)) {
1467
1676
  backend.removeSync(r.id);
1468
1677
  localRemoved += 1;
1469
1678
  }
package/src/plugin.ts CHANGED
@@ -27,6 +27,13 @@ export interface PluginManifest {
27
27
  readonly skills: readonly string[];
28
28
  }
29
29
 
30
+ /** id→path index built from the registry, so the generator never guesses a layout. */
31
+ function skillPathIndex(registry: { entries: readonly { id: string; pack: string; path?: string }[] }): Map<string, string> {
32
+ const m = new Map<string, string>();
33
+ for (const e of registry.entries) if (e.path !== undefined && e.path !== '') m.set(`${e.pack}/${e.id}`, e.path);
34
+ return m;
35
+ }
36
+
30
37
  /** Generate .claude-plugin/ directory from registry. */
31
38
  export function generatePlugin(
32
39
  projectRoot: string,
@@ -61,16 +68,26 @@ export function generatePlugin(
61
68
  };
62
69
 
63
70
  const skillPacks = [...packMap.entries()].map(([name, count]) => ({
71
+ // `pack` is the REAL directory name; `name` is only the display short form. Deriving the key
72
+ // back by re-adding the `skills-` prefix broke the moment the catalogue learned packs that
73
+ // never had one (health-advisor, keysarium, …): the lookup missed and their entries listed
74
+ // zero skills.
75
+ pack: name,
64
76
  name: name.replace('skills-', ''),
65
77
  skills: count,
66
78
  description: packDescriptions[name] ?? `${count} skills`,
67
79
  }));
68
80
 
81
+ const pathById = skillPathIndex(registry);
82
+
69
83
  // Explicit skill paths for the full-suite plugin (source `./`): every skill
70
84
  // dir across every pack, relative to the repo root. This is what makes
71
85
  // `claude plugin details` report the real skill count instead of 0.
72
86
  const allSkillPaths = [...packSkillIds.entries()]
73
- .flatMap(([pack, ids]) => ids.map((id) => `packages/@dzhechkov/${pack}/${id}`))
87
+ // Prefer the path the catalogue recorded; `<pack>/<id>` is only a fallback for entries built
88
+ // before the field existed. Reconstructing it is what produced unresolvable paths for skills
89
+ // that live under `skills/` or `templates/.claude/skills/`.
90
+ .flatMap(([pack, ids]) => ids.map((id) => pathById.get(`${pack}/${id}`) ?? `packages/@dzhechkov/${pack}/${id}`))
74
91
  .sort();
75
92
 
76
93
  const pluginJson = {
@@ -103,12 +120,17 @@ export function generatePlugin(
103
120
  ...skillPacks.map((pack) => ({
104
121
  name: `dz-${pack.name}`,
105
122
  displayName: `DZ ${pack.name.charAt(0).toUpperCase() + pack.name.slice(1)} Skills`,
106
- source: `packages/@dzhechkov/skills-${pack.name}`,
123
+ source: `packages/@dzhechkov/${pack.pack}`,
107
124
  description: `${pack.skills} ${pack.name} skills — ${pack.description}`,
108
125
  keywords: [pack.name],
109
- // Skill dirs sit at the pack source root, so they are listed relative
110
- // to `source` here (`./<id>`) required for Claude Code to discover them.
111
- skills: (packSkillIds.get(`skills-${pack.name}`) ?? []).slice().sort().map((id) => `./${id}`),
126
+ // Listed relative to `source` required for Claude Code to discover them. The path comes
127
+ // from the catalogue rather than from the assumption "skill dirs sit at the pack root",
128
+ // which holds for `skills-*` packs and for neither of the two other layouts.
129
+ skills: (packSkillIds.get(pack.pack) ?? []).slice().sort().map((id) => {
130
+ const full = pathById.get(`${pack.pack}/${id}`);
131
+ const base = `packages/@dzhechkov/${pack.pack}/`;
132
+ return full !== undefined && full.startsWith(base) ? `./${full.slice(base.length)}` : `./${id}`;
133
+ }),
112
134
  })),
113
135
  ],
114
136
  };