@dzhechkov/harness-core 0.8.5 → 0.8.10

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 (134) hide show
  1. package/.dz-manifest.json +286 -82
  2. package/README.md +54 -2
  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/compounding.d.ts +54 -0
  7. package/dist/compounding.d.ts.map +1 -1
  8. package/dist/compounding.js +221 -1
  9. package/dist/compounding.js.map +1 -1
  10. package/dist/eta.d.ts +92 -0
  11. package/dist/eta.d.ts.map +1 -0
  12. package/dist/eta.js +488 -0
  13. package/dist/eta.js.map +1 -0
  14. package/dist/feature-adr-checkpoints.d.ts +11 -2
  15. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  16. package/dist/feature-adr-checkpoints.js +38 -3
  17. package/dist/feature-adr-checkpoints.js.map +1 -1
  18. package/dist/feature-adr-decision-recall.d.ts +167 -0
  19. package/dist/feature-adr-decision-recall.d.ts.map +1 -0
  20. package/dist/feature-adr-decision-recall.js +519 -0
  21. package/dist/feature-adr-decision-recall.js.map +1 -0
  22. package/dist/feature-adr-landing.d.ts +37 -0
  23. package/dist/feature-adr-landing.d.ts.map +1 -0
  24. package/dist/feature-adr-landing.js +59 -0
  25. package/dist/feature-adr-landing.js.map +1 -0
  26. package/dist/feature-adr-routing.d.ts +58 -23
  27. package/dist/feature-adr-routing.d.ts.map +1 -1
  28. package/dist/feature-adr-routing.js +194 -69
  29. package/dist/feature-adr-routing.js.map +1 -1
  30. package/dist/guard-promotion.d.ts +41 -0
  31. package/dist/guard-promotion.d.ts.map +1 -1
  32. package/dist/guard-promotion.js +218 -4
  33. package/dist/guard-promotion.js.map +1 -1
  34. package/dist/guard-volume.d.ts +108 -0
  35. package/dist/guard-volume.d.ts.map +1 -0
  36. package/dist/guard-volume.js +536 -0
  37. package/dist/guard-volume.js.map +1 -0
  38. package/dist/guard.d.ts +34 -0
  39. package/dist/guard.d.ts.map +1 -1
  40. package/dist/guard.js +113 -4
  41. package/dist/guard.js.map +1 -1
  42. package/dist/index.d.ts +18 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +12 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/integration-apply.d.ts +25 -0
  47. package/dist/integration-apply.d.ts.map +1 -0
  48. package/dist/integration-apply.js +299 -0
  49. package/dist/integration-apply.js.map +1 -0
  50. package/dist/integration-evidence.d.ts +46 -0
  51. package/dist/integration-evidence.d.ts.map +1 -0
  52. package/dist/integration-evidence.js +44 -0
  53. package/dist/integration-evidence.js.map +1 -0
  54. package/dist/integration-probe-worker.d.ts +22 -0
  55. package/dist/integration-probe-worker.d.ts.map +1 -0
  56. package/dist/integration-probe-worker.js +334 -0
  57. package/dist/integration-probe-worker.js.map +1 -0
  58. package/dist/integrations-verify.d.ts +60 -0
  59. package/dist/integrations-verify.d.ts.map +1 -0
  60. package/dist/integrations-verify.js +194 -0
  61. package/dist/integrations-verify.js.map +1 -0
  62. package/dist/lesson-generalization.d.ts +29 -0
  63. package/dist/lesson-generalization.d.ts.map +1 -0
  64. package/dist/lesson-generalization.js +84 -0
  65. package/dist/lesson-generalization.js.map +1 -0
  66. package/dist/loop-blobs.generated.js +8 -8
  67. package/dist/loop-blobs.generated.js.map +1 -1
  68. package/dist/loop-plan.d.ts +13 -1
  69. package/dist/loop-plan.d.ts.map +1 -1
  70. package/dist/loop-plan.js +15 -1
  71. package/dist/loop-plan.js.map +1 -1
  72. package/dist/loop-render.d.ts.map +1 -1
  73. package/dist/loop-render.js +51 -6
  74. package/dist/loop-render.js.map +1 -1
  75. package/dist/loop-trace.d.ts +20 -1
  76. package/dist/loop-trace.d.ts.map +1 -1
  77. package/dist/loop-trace.js +83 -1
  78. package/dist/loop-trace.js.map +1 -1
  79. package/dist/model-recommender.d.ts +8 -0
  80. package/dist/model-recommender.d.ts.map +1 -1
  81. package/dist/model-recommender.js +31 -4
  82. package/dist/model-recommender.js.map +1 -1
  83. package/dist/operations.d.ts +27 -0
  84. package/dist/operations.d.ts.map +1 -1
  85. package/dist/operations.js +178 -2
  86. package/dist/operations.js.map +1 -1
  87. package/dist/patterns.d.ts +27 -1
  88. package/dist/patterns.d.ts.map +1 -1
  89. package/dist/patterns.js +211 -45
  90. package/dist/patterns.js.map +1 -1
  91. package/dist/setup.d.ts.map +1 -1
  92. package/dist/setup.js +20 -17
  93. package/dist/setup.js.map +1 -1
  94. package/dist/target-integrations.d.ts +65 -0
  95. package/dist/target-integrations.d.ts.map +1 -0
  96. package/dist/target-integrations.js +152 -0
  97. package/dist/target-integrations.js.map +1 -0
  98. package/dist/vector-tier.d.ts +6 -1
  99. package/dist/vector-tier.d.ts.map +1 -1
  100. package/dist/vector-tier.js +32 -7
  101. package/dist/vector-tier.js.map +1 -1
  102. package/dist/workflow-run.d.ts.map +1 -1
  103. package/dist/workflow-run.js +18 -12
  104. package/dist/workflow-run.js.map +1 -1
  105. package/package.json +19 -15
  106. package/sbom.json +591 -81
  107. package/src/amendment-trace.ts +12 -1
  108. package/src/compounding.ts +300 -1
  109. package/src/eta.ts +590 -0
  110. package/src/feature-adr-checkpoints.ts +39 -3
  111. package/src/feature-adr-decision-recall.ts +652 -0
  112. package/src/feature-adr-landing.ts +109 -0
  113. package/src/feature-adr-routing.ts +224 -65
  114. package/src/guard-promotion.ts +245 -4
  115. package/src/guard-volume.ts +752 -0
  116. package/src/guard.ts +148 -4
  117. package/src/index.ts +65 -3
  118. package/src/integration-apply.ts +332 -0
  119. package/src/integration-evidence.ts +89 -0
  120. package/src/integration-probe-worker.ts +310 -0
  121. package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
  122. package/src/integrations-verify.ts +258 -0
  123. package/src/lesson-generalization.ts +115 -0
  124. package/src/loop-blobs.generated.ts +8 -8
  125. package/src/loop-plan.ts +36 -3
  126. package/src/loop-render.ts +50 -6
  127. package/src/loop-trace.ts +91 -2
  128. package/src/model-recommender.ts +35 -4
  129. package/src/operations.ts +199 -2
  130. package/src/patterns.ts +252 -43
  131. package/src/setup.ts +20 -17
  132. package/src/target-integrations.ts +225 -0
  133. package/src/vector-tier.ts +44 -14
  134. package/src/workflow-run.ts +18 -11
@@ -0,0 +1,225 @@
1
+ /** Exhaustive target companion policy and once-per-install manifest aggregation. */
2
+
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ import { claudeIntegrationAdapter } from '@dzhechkov/adapter-claude';
7
+ import {
8
+ INTEGRATION_MANIFEST_MAX_BYTES,
9
+ INTEGRATION_REGISTRATION_MAX_COUNT,
10
+ canonicalIntegrationJson,
11
+ integrationManifestDigest,
12
+ parseHarnessIntegrationManifestJson,
13
+ type HarnessIntegrationManifestV1,
14
+ type IntegrationComponent,
15
+ type IntegrationPlan,
16
+ type IntegrationReasonCode,
17
+ type IntegrationStatus,
18
+ type TargetIntegrationAdapter,
19
+ } from '@dzhechkov/core';
20
+
21
+ import { TARGET_NAMES, type TargetName } from './targets.js';
22
+
23
+ export interface RegistrationObservation {
24
+ readonly id: string;
25
+ readonly scope: 'project' | 'user' | 'plugin';
26
+ readonly registered: boolean;
27
+ readonly approval?: 'pending' | 'approved' | 'unknown';
28
+ readonly ready?: boolean;
29
+ }
30
+
31
+ export interface IntegrationOutcome {
32
+ readonly target: TargetName;
33
+ readonly component: IntegrationComponent;
34
+ readonly status: IntegrationStatus;
35
+ readonly registrations: readonly RegistrationObservation[];
36
+ readonly carrier?: { readonly scope: 'project' | 'user' | 'plugin'; readonly path: string };
37
+ readonly runtimeVersion?: string;
38
+ readonly evidenceVersion?: string;
39
+ readonly reasonCode?: IntegrationReasonCode;
40
+ readonly remediation?: string;
41
+ /** True when carrier bytes may precede a failed live check or ownership-journal commit. */
42
+ readonly applied?: boolean;
43
+ }
44
+
45
+ export type IntegrationPolicyCell =
46
+ | { readonly disposition: 'receipt'; readonly reasonCode?: never; readonly receiptVersion: string }
47
+ | { readonly disposition: 'legacy-post-write'; readonly reasonCode: 'CURRENT_LIVE_CHECK_FAILED' }
48
+ | { readonly disposition: 'refused'; readonly reasonCode: IntegrationReasonCode };
49
+
50
+ export interface TargetIntegrationPolicy {
51
+ readonly mcp: IntegrationPolicyCell;
52
+ readonly hooks: IntegrationPolicyCell;
53
+ }
54
+
55
+ const refusedAdapter = (target: TargetName): TargetIntegrationAdapter => ({
56
+ target,
57
+ plan(manifest): IntegrationPlan {
58
+ const refusals: IntegrationPlan['refusals'][number][] = [];
59
+ if (Object.keys(manifest.mcpServers ?? {}).length > 0) {
60
+ refusals.push({ component: 'mcp', reasonCode: TARGET_INTEGRATION_POLICY[target].mcp.reasonCode ?? 'NO_QUALIFYING_LIVE_RECEIPT', remediation: 'run dz integrations-verify for this exact target product and version' });
61
+ }
62
+ if ((manifest.hooks?.length ?? 0) > 0) {
63
+ refusals.push({ component: 'hooks', reasonCode: TARGET_INTEGRATION_POLICY[target].hooks.reasonCode ?? 'NO_QUALIFYING_LIVE_RECEIPT', remediation: 'run dz integrations-verify with a hook canary and negative control' });
64
+ }
65
+ return { fragments: [], refusals };
66
+ },
67
+ });
68
+
69
+ /** Closed 10×2 policy. No fallback/default is permitted. */
70
+ export const TARGET_INTEGRATION_POLICY: Record<TargetName, TargetIntegrationPolicy> = {
71
+ 'claude-code': {
72
+ mcp: { disposition: 'receipt', receiptVersion: '2.1.235' },
73
+ hooks: { disposition: 'refused', reasonCode: 'NO_ACTIVATION_RECEIPT' },
74
+ },
75
+ codex: {
76
+ mcp: { disposition: 'refused', reasonCode: 'PROJECT_CARRIER_NOT_OBSERVED' },
77
+ hooks: { disposition: 'legacy-post-write', reasonCode: 'CURRENT_LIVE_CHECK_FAILED' },
78
+ },
79
+ cursor: {
80
+ mcp: { disposition: 'refused', reasonCode: 'TARGET_BINARY_UNAVAILABLE' },
81
+ hooks: { disposition: 'refused', reasonCode: 'TARGET_BINARY_UNAVAILABLE' },
82
+ },
83
+ copilot: {
84
+ mcp: { disposition: 'refused', reasonCode: 'TARGET_BINARY_UNAVAILABLE' },
85
+ hooks: { disposition: 'refused', reasonCode: 'TARGET_BINARY_UNAVAILABLE' },
86
+ },
87
+ windsurf: {
88
+ mcp: { disposition: 'refused', reasonCode: 'PRODUCT_SURFACE_AMBIGUOUS' },
89
+ hooks: { disposition: 'refused', reasonCode: 'TARGET_BINARY_UNAVAILABLE' },
90
+ },
91
+ gemini: {
92
+ mcp: { disposition: 'refused', reasonCode: 'NO_QUALIFYING_LIVE_RECEIPT' },
93
+ hooks: { disposition: 'refused', reasonCode: 'NO_QUALIFYING_LIVE_RECEIPT' },
94
+ },
95
+ opencode: {
96
+ mcp: { disposition: 'refused', reasonCode: 'TARGET_BINARY_UNAVAILABLE' },
97
+ hooks: { disposition: 'refused', reasonCode: 'TARGET_BINARY_UNAVAILABLE' },
98
+ },
99
+ openclaude: {
100
+ mcp: { disposition: 'refused', reasonCode: 'PROJECT_CARRIER_NOT_DOCUMENTED' },
101
+ hooks: { disposition: 'refused', reasonCode: 'TARGET_BINARY_UNAVAILABLE' },
102
+ },
103
+ hermes: {
104
+ mcp: { disposition: 'refused', reasonCode: 'LIVE_PROBE_TIMEOUT' },
105
+ hooks: { disposition: 'refused', reasonCode: 'NO_ACTIVATION_RECEIPT' },
106
+ },
107
+ 'agents-md': {
108
+ mcp: { disposition: 'refused', reasonCode: 'NO_RUNTIME_SURFACE' },
109
+ hooks: { disposition: 'refused', reasonCode: 'NO_RUNTIME_SURFACE' },
110
+ },
111
+ };
112
+
113
+ export const TARGET_INTEGRATIONS: Record<TargetName, TargetIntegrationAdapter> = Object.fromEntries(
114
+ TARGET_NAMES.map((target) => [target, target === 'claude-code' ? claudeIntegrationAdapter : refusedAdapter(target)]),
115
+ ) as Record<TargetName, TargetIntegrationAdapter>;
116
+
117
+ export interface IntegrationManifestSource {
118
+ readonly skillId: string;
119
+ readonly skillDir: string;
120
+ }
121
+
122
+ export interface IntegrationManifestAggregate {
123
+ readonly manifest: HarnessIntegrationManifestV1 | undefined;
124
+ readonly digest: string | undefined;
125
+ readonly sourcePaths: readonly string[];
126
+ }
127
+
128
+ export class IntegrationManifestError extends Error {
129
+ readonly code = 'MANIFEST_INVALID';
130
+ constructor(readonly manifestPath: string, message: string) {
131
+ super(`${manifestPath}: ${message}`);
132
+ this.name = 'IntegrationManifestError';
133
+ }
134
+ }
135
+
136
+ /** Load, validate and aggregate every adjacent manifest before target effects. */
137
+ export function aggregateIntegrationManifests(
138
+ sources: readonly IntegrationManifestSource[],
139
+ ): IntegrationManifestAggregate {
140
+ const manifests: { path: string; value: HarnessIntegrationManifestV1 }[] = [];
141
+ for (const source of sources) {
142
+ const path = join(source.skillDir, source.skillId, 'INTEGRATIONS.json');
143
+ if (!existsSync(path)) continue;
144
+ try {
145
+ manifests.push({ path, value: parseHarnessIntegrationManifestJson(readFileSync(path, 'utf8')) });
146
+ } catch (error) {
147
+ throw new IntegrationManifestError(path, error instanceof Error ? error.message : String(error));
148
+ }
149
+ }
150
+ if (manifests.length === 0) return { manifest: undefined, digest: undefined, sourcePaths: [] };
151
+
152
+ const mcp = Object.create(null) as Record<string, NonNullable<HarnessIntegrationManifestV1['mcpServers']>[string]>;
153
+ const hooks = new Map<string, NonNullable<HarnessIntegrationManifestV1['hooks']>[number]>();
154
+ for (const source of manifests) {
155
+ for (const [id, intent] of Object.entries(source.value.mcpServers ?? {})) {
156
+ const previous = mcp[id];
157
+ if (previous !== undefined && canonicalIntegrationJson(previous) !== canonicalIntegrationJson(intent)) {
158
+ throw new IntegrationManifestError(source.path, `conflicting MCP registration id ${JSON.stringify(id)}`);
159
+ }
160
+ mcp[id] = intent;
161
+ }
162
+ for (const hook of source.value.hooks ?? []) {
163
+ const previous = hooks.get(hook.id);
164
+ if (previous !== undefined && canonicalIntegrationJson(previous) !== canonicalIntegrationJson(hook)) {
165
+ throw new IntegrationManifestError(source.path, `conflicting hook registration id ${JSON.stringify(hook.id)}`);
166
+ }
167
+ hooks.set(hook.id, hook);
168
+ }
169
+ }
170
+ const aggregate = {
171
+ version: 1 as const,
172
+ ...(Object.keys(mcp).length > 0 ? { mcpServers: Object.fromEntries(Object.entries(mcp).sort(([a], [b]) => a.localeCompare(b))) } : {}),
173
+ ...(hooks.size > 0 ? { hooks: [...hooks.values()].sort((a, b) => a.id.localeCompare(b.id)) } : {}),
174
+ } satisfies HarnessIntegrationManifestV1;
175
+ const count = Object.keys(aggregate.mcpServers ?? {}).length + (aggregate.hooks?.length ?? 0);
176
+ if (count > INTEGRATION_REGISTRATION_MAX_COUNT) {
177
+ throw new IntegrationManifestError('<aggregate>', `final aggregate has ${count} registrations; maximum is ${INTEGRATION_REGISTRATION_MAX_COUNT}`);
178
+ }
179
+ const aggregateBytes = Buffer.byteLength(canonicalIntegrationJson(aggregate), 'utf8');
180
+ if (aggregateBytes > INTEGRATION_MANIFEST_MAX_BYTES) {
181
+ throw new IntegrationManifestError('<aggregate>', `final aggregate exceeds ${INTEGRATION_MANIFEST_MAX_BYTES} UTF-8 bytes`);
182
+ }
183
+ return {
184
+ manifest: aggregate,
185
+ digest: integrationManifestDigest([aggregate]),
186
+ sourcePaths: manifests.map((source) => source.path),
187
+ };
188
+ }
189
+
190
+ export function notRequestedOutcomes(target: TargetName): readonly [IntegrationOutcome, IntegrationOutcome] {
191
+ return [
192
+ { target, component: 'mcp', status: 'not-requested', registrations: [] },
193
+ { target, component: 'hooks', status: 'not-requested', registrations: [] },
194
+ ];
195
+ }
196
+
197
+ export function refusedOutcome(
198
+ target: TargetName,
199
+ component: IntegrationComponent,
200
+ reasonCode: IntegrationReasonCode,
201
+ remediation: string,
202
+ ): IntegrationOutcome {
203
+ return { target, component, status: 'refused', registrations: [], reasonCode, remediation };
204
+ }
205
+
206
+ /** Static synchronous outcomes for Gemini/agents-md and other refusal-only seams. */
207
+ export function staticPolicyOutcomes(
208
+ target: TargetName,
209
+ manifest: HarnessIntegrationManifestV1 | undefined,
210
+ noHooks = false,
211
+ ): readonly [IntegrationOutcome, IntegrationOutcome] {
212
+ if (manifest === undefined) return notRequestedOutcomes(target);
213
+ const mcpRequested = Object.keys(manifest.mcpServers ?? {}).length > 0;
214
+ const hooksRequested = (manifest.hooks?.length ?? 0) > 0 && !noHooks;
215
+ const mcpCell = TARGET_INTEGRATION_POLICY[target].mcp;
216
+ const hookCell = TARGET_INTEGRATION_POLICY[target].hooks;
217
+ return [
218
+ mcpRequested
219
+ ? refusedOutcome(target, 'mcp', mcpCell.reasonCode ?? 'NO_QUALIFYING_LIVE_RECEIPT', 'run dz integrations-verify for an exact-version receipt')
220
+ : { target, component: 'mcp', status: 'not-requested', registrations: [] },
221
+ hooksRequested
222
+ ? refusedOutcome(target, 'hooks', hookCell.reasonCode ?? 'NO_ACTIVATION_RECEIPT', 'run dz integrations-verify with a hook canary and negative control')
223
+ : { target, component: 'hooks', status: 'not-requested', registrations: [] },
224
+ ];
225
+ }
@@ -56,6 +56,7 @@ import {
56
56
  updateReinforcementState,
57
57
  type PatternRecord,
58
58
  type RecallHit,
59
+ type RecallPatternsOptions,
59
60
  } from './patterns.js';
60
61
  import {
61
62
  indexPatternsToAgentdb,
@@ -202,6 +203,8 @@ export interface HybridHit {
202
203
  readonly score: number;
203
204
  /** lesson-quarantine: set only for a quarantined hit — display marks ⚠q, ranking was damped. */
204
205
  readonly quarantined?: boolean;
206
+ /** Form provenance from the lexical pair merge; semantic ranking never invents it. */
207
+ readonly matchedForm?: NonNullable<RecallHit['matchedForm']>;
205
208
  /**
206
209
  * Raw closeness from the semantic leg, when the engine reports a genuine cosine. ABSENT for a
207
210
  * lexical-only hit and for an engine whose score is not a cosine — the honest display there is a
@@ -491,7 +494,7 @@ export function isVectorNoise(text: string): boolean {
491
494
  * ingest gate — I-6). Score is the record's REAL reward, never a fabricated 1.0.
492
495
  */
493
496
  export function patternVectorEntry(p: PatternRecord, source = 'dz-teach', opts: { quarantined?: boolean } = {}): VectorEntry | undefined {
494
- if (isVectorNoise(p.pattern)) return undefined;
497
+ if (p.lessonForm === 'class' || isVectorNoise(p.pattern)) return undefined;
495
498
  const dzId = patternRecordId(p);
496
499
  return {
497
500
  dzId,
@@ -501,7 +504,13 @@ export function patternVectorEntry(p: PatternRecord, source = 'dz-teach', opts:
501
504
  tags: ['dz-teach', p.type],
502
505
  // FR-8: quarantine rides into the mirror so the HOOK DAEMON (which reads only the mirror's
503
506
  // sqlite metadata) can exclude unproven lessons from auto-injection.
504
- metadata: { dzId, source, ts: p.ts, domain: p.domain, ...(opts.quarantined === true ? { qStatus: 'quarantined' } : {}) },
507
+ metadata: {
508
+ dzId, source, ts: p.ts, domain: p.domain,
509
+ ...(p.lessonForm !== undefined && p.lessonPairId !== undefined
510
+ ? { lessonForm: p.lessonForm, lessonPairId: p.lessonPairId }
511
+ : {}),
512
+ ...(opts.quarantined === true ? { qStatus: 'quarantined' } : {}),
513
+ },
505
514
  };
506
515
  }
507
516
 
@@ -525,7 +534,7 @@ export function dreamVectorEntry(d: DreamPattern): VectorEntry | undefined {
525
534
 
526
535
  /** ACL: stored {@link MemoryRecord} → {@link VectorEntry} (the consolidate-backfill mapper). */
527
536
  export function memoryRecordVectorEntry(r: MemoryRecord): VectorEntry | undefined {
528
- if (isVectorNoise(r.text)) return undefined;
537
+ if (r.metadata?.['lessonForm'] === 'class' || isVectorNoise(r.text)) return undefined;
529
538
  const state = readReinforcementState(r);
530
539
  return {
531
540
  dzId: r.id,
@@ -533,7 +542,12 @@ export function memoryRecordVectorEntry(r: MemoryRecord): VectorEntry | undefine
533
542
  score: r.score,
534
543
  taskType: r.id.startsWith('dream:') ? 'dz-learning' : 'dz-teach',
535
544
  tags: ['dz-backfill', r.outcome],
536
- metadata: { dzId: r.id, source: r.metadata?.['source'] ?? 'dz-backfill', ts: r.timestamp, skillId: r.skillId },
545
+ metadata: {
546
+ dzId: r.id, source: r.metadata?.['source'] ?? 'dz-backfill', ts: r.timestamp, skillId: r.skillId,
547
+ ...(r.metadata?.['lessonForm'] === 'specific' && typeof r.metadata?.['lessonPairId'] === 'string'
548
+ ? { lessonForm: 'specific', lessonPairId: r.metadata['lessonPairId'] }
549
+ : {}),
550
+ },
537
551
  uses: state.uses,
538
552
  avgReward: state.avgReward,
539
553
  };
@@ -939,6 +953,7 @@ export interface RankedPattern {
939
953
  readonly id: string;
940
954
  readonly pattern: PatternRecord;
941
955
  readonly backend: RecallHit['backend'];
956
+ readonly matchedForm?: NonNullable<RecallHit['matchedForm']>;
942
957
  /**
943
958
  * The semantic leg's raw closeness, when the engine reports a real cosine. Rides ALONGSIDE the RRF
944
959
  * score and never enters the ranking maths — four things depend on RRF magnitude (the reinforce
@@ -967,11 +982,12 @@ export function mergeHybridHits(
967
982
  opts: { readonly limit: number; readonly semanticWeight?: number | undefined },
968
983
  ): HybridHit[] {
969
984
  const weight = opts.semanticWeight ?? 1;
970
- interface Acc { pattern: PatternRecord; lex?: RecallHit['backend']; sem: boolean; score: number; similarity?: number }
985
+ interface Acc { pattern: PatternRecord; lex?: RecallHit['backend']; sem: boolean; score: number; similarity?: number; matchedForm?: NonNullable<RecallHit['matchedForm']> }
971
986
  const acc = new Map<string, Acc>();
972
987
  lexical.forEach((h, rank) => {
973
988
  const cur = acc.get(h.id) ?? { pattern: h.pattern, sem: false, score: 0 };
974
989
  cur.lex = h.backend;
990
+ if (h.matchedForm !== undefined) cur.matchedForm = h.matchedForm;
975
991
  cur.score += 1 / (RRF_K + rank + 1);
976
992
  acc.set(h.id, cur);
977
993
  });
@@ -997,6 +1013,7 @@ export function mergeHybridHits(
997
1013
  pattern: v.pattern,
998
1014
  backend: v.lex !== undefined && v.sem ? ('both' as const) : v.lex ?? ('vector' as const),
999
1015
  score: v.score,
1016
+ ...(v.matchedForm === undefined ? {} : { matchedForm: v.matchedForm }),
1000
1017
  ...(v.similarity === undefined ? {} : { similarity: v.similarity }),
1001
1018
  });
1002
1019
  // `slice(0, -1)` drops the LAST element instead of returning nothing, so a negative limit used to
@@ -1105,6 +1122,8 @@ export async function recallHybrid(
1105
1122
  * sitting at `pulls === 0` forever. Absent ⇒ `general`; it changes nothing while disarmed.
1106
1123
  */
1107
1124
  readonly domain?: string | undefined;
1125
+ readonly onClassDegraded?: RecallPatternsOptions['onClassDegraded'];
1126
+ readonly classMatcher?: RecallPatternsOptions['classMatcher'];
1108
1127
  } = {},
1109
1128
  ): Promise<HybridRecall> {
1110
1129
  // Config-surface note (QE P3, benign by design): recall resolves the engine directly, while teach
@@ -1116,7 +1135,10 @@ export async function recallHybrid(
1116
1135
  // write-side backend flag.
1117
1136
  const limit = opts.limit ?? 10;
1118
1137
  const mode = opts.mode ?? 'hybrid';
1119
- const lexical = recallPatterns(projectRoot, query, limit);
1138
+ const lexical = recallPatterns(projectRoot, query, limit, {
1139
+ ...(opts.onClassDegraded === undefined ? {} : { onClassDegraded: opts.onClassDegraded }),
1140
+ ...(opts.classMatcher === undefined ? {} : { classMatcher: opts.classMatcher }),
1141
+ });
1120
1142
  const lexicalBackend: 'sqlite' | 'json' = lexical[0]?.backend === 'sqlite' ? 'sqlite' : 'json';
1121
1143
  const records = loadStoreRecords(projectRoot);
1122
1144
  const idToRecord = new Map<string, MemoryRecord>();
@@ -1223,7 +1245,12 @@ export async function recallHybrid(
1223
1245
  };
1224
1246
  const lexicalOnly = (extra: Partial<Pick<HybridRecall, 'vectorEngine' | 'vectorReason' | 'vectorError'>>): HybridRecall => {
1225
1247
  // `enhance` FIRST: it is what populates `banditReport` (the key is absent while disarmed).
1226
- const hits = enhance(lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) })));
1248
+ const hits = enhance(lexical.map((h, rank) => ({
1249
+ pattern: h.pattern,
1250
+ backend: h.backend,
1251
+ score: 1 / (RRF_K + rank + 1),
1252
+ ...(h.matchedForm === undefined ? {} : { matchedForm: h.matchedForm }),
1253
+ })));
1227
1254
  return {
1228
1255
  hits,
1229
1256
  lexicalBackend,
@@ -1333,6 +1360,7 @@ export async function recallHybrid(
1333
1360
  id: identityToId.get(patternIdentityOf(h.pattern)) ?? patternRecordId(h.pattern),
1334
1361
  pattern: h.pattern,
1335
1362
  backend: h.backend,
1363
+ ...(h.matchedForm === undefined ? {} : { matchedForm: h.matchedForm }),
1336
1364
  }));
1337
1365
  const hits = enhance(mergeHybridHits(lex, semantic, { limit, semanticWeight: mode === 'semantic' ? 2 : 1 }));
1338
1366
  markRecallHits(projectRoot, learning, hits, idOf, banditEmission());
@@ -1688,13 +1716,15 @@ export async function harmonizeVectorStore(projectRoot: string, opts: HarmonizeO
1688
1716
  } catch {
1689
1717
  records = [];
1690
1718
  }
1691
- const items: HarmonizeItem[] = records.map((r) => ({
1692
- dzId: r.id,
1693
- text: r.text,
1694
- reward: r.score,
1695
- ts: r.timestamp,
1696
- taskType: r.id.startsWith('dream:') ? 'dz-learning' : 'dz-teach',
1697
- }));
1719
+ const items: HarmonizeItem[] = records
1720
+ .filter((r) => r.metadata?.['lessonForm'] !== 'class')
1721
+ .map((r) => ({
1722
+ dzId: r.id,
1723
+ text: r.text,
1724
+ reward: r.score,
1725
+ ts: r.timestamp,
1726
+ taskType: r.id.startsWith('dream:') ? 'dz-learning' : 'dz-teach',
1727
+ }));
1698
1728
 
1699
1729
  // 2. GATE: an embedder ⇒ SEMANTIC clustering; absence/failure ⇒ EXACT-text fallback (D4).
1700
1730
  let embed: ((text: string) => Promise<Float32Array>) | undefined;
@@ -33,6 +33,7 @@ import {
33
33
  traceInit,
34
34
  traceLedgerLine,
35
35
  traceOnDispatch,
36
+ traceOnFanoutTruncated,
36
37
  traceOnSettle,
37
38
  traceShellQuote,
38
39
  TRACE_KEY_RE,
@@ -336,7 +337,7 @@ export function computeBoundaryReservations(
336
337
  if (b.kind === 'region') {
337
338
  const r = b.region;
338
339
  if (r === undefined) continue;
339
- const members = activatedMembers(r.registry, r.dedup, r.maxFanout).length;
340
+ const members = activatedMembers(r.registry, r.dedup, r.maxFanout, r.overflow).length;
340
341
  const perItem = r.chain.reduce((n, s) => n + worstCaseInvocations(s), 0);
341
342
  const perItemWall = r.chain.reduce((n, s) => n + worstCaseInvocations(s) * timeoutMsFor(s), 0);
342
343
  out.push({
@@ -366,17 +367,14 @@ export function computeBoundaryReservations(
366
367
  /**
367
368
  * The members a fanout actually ACTIVATES.
368
369
  *
369
- * `maxFanout` caps ACTIVATION, not merely concurrency the rendered script emits
370
- * `registry.slice(0, maxFanout)` ("bounded fanout (INV-2): never args-derived, never uncapped").
371
- * Getting this wrong is a STRUCTURAL-EQUIVALENCE break, not a performance detail: a runner that
372
- * treated the bound as a concurrency limit would dispatch all six entries of a six-item registry
373
- * where the Claude host dispatched three, and the two hosts' traces would disagree on dispatch
374
- * multiplicity for the same plan. (MEASURED: that is exactly what the F5 equivalence leg caught
375
- * against the committed `pkg-audit-1` run — 6 lanes here, 3 there.)
370
+ * `maxFanout` caps CONCURRENCY under the default/window policy. Only the explicitly declared
371
+ * `overflow:'truncate'` escape hatch caps activation, and that path owes stderr + trace receipts.
372
+ * This function is shared by reservation arithmetic and enactment so neither can silently retain
373
+ * the old prefix-only interpretation.
376
374
  */
377
- function activatedMembers(registry: string[], dedup: boolean, maxFanout: number): string[] {
375
+ function activatedMembers(registry: string[], dedup: boolean, maxFanout: number, overflow: 'window' | 'truncate'): string[] {
378
376
  const base = dedup ? [...new Set(registry)] : [...registry];
379
- return maxFanout > 0 ? base.slice(0, maxFanout) : base;
377
+ return overflow === 'truncate' && maxFanout > 0 ? base.slice(0, maxFanout) : base;
380
378
  }
381
379
 
382
380
  /**
@@ -1605,7 +1603,16 @@ async function runStage(ctx: RunCtx, b: RunBoundary): Promise<string | null> {
1605
1603
  async function runRegion(ctx: RunCtx, b: RunBoundary): Promise<void> {
1606
1604
  const r = b.region;
1607
1605
  if (r === undefined) return;
1608
- const members = activatedMembers(r.registry, r.dedup, r.maxFanout);
1606
+ const members = activatedMembers(r.registry, r.dedup, r.maxFanout, r.overflow);
1607
+ if (r.overflow === 'truncate') {
1608
+ traceOnFanoutTruncated(ctx.trace, {
1609
+ stage: r.fanout,
1610
+ registrySize: r.registry.length,
1611
+ dispatched: members.length,
1612
+ reason: r.truncateReason ?? '',
1613
+ });
1614
+ flush(ctx);
1615
+ }
1609
1616
  for (const k of members) {
1610
1617
  if (!safeItemKey(k)) {
1611
1618
  throw new RunFailure(