@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
@@ -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
+ }
@@ -58,6 +58,40 @@ export interface TelemetryField {
58
58
  readonly unit: 'ms' | 'tokens' | null;
59
59
  }
60
60
 
61
+ export type RunOutcome =
62
+ | 'completed' | 'completed-unverified' | 'refused-repo-root' | 'refused-design'
63
+ | 'refused-plan' | 'paused-checkpoint' | 'crashed' | 'unclassified';
64
+
65
+ /** Every member of the closed set, as data for reachability checks. */
66
+ export const RUN_OUTCOMES = [
67
+ 'completed', 'completed-unverified', 'refused-repo-root', 'refused-design',
68
+ 'refused-plan', 'paused-checkpoint', 'crashed', 'unclassified',
69
+ ] as const satisfies readonly RunOutcome[];
70
+
71
+ export function runOutcomeOf(input: {
72
+ phase: string | null | undefined;
73
+ gates: Record<string, unknown> | null | undefined;
74
+ }): RunOutcome {
75
+ if (input.phase === 'repo-root-mismatch') return 'refused-repo-root';
76
+ if (input.phase === 'design-incomplete') return 'refused-design';
77
+ if (input.phase === 'plan-gate-failed') return 'refused-plan';
78
+ if (input.phase === 'checkpoint-after-plan') return 'paused-checkpoint';
79
+
80
+ const gates = input.gates;
81
+ if ((input.phase === null || input.phase === undefined || input.phase === '')
82
+ && gates !== null && typeof gates === 'object') {
83
+ const codeCompleted = gates.code === 'produced' || gates.code === 'landed';
84
+ const qe = gates.qe;
85
+ if (codeCompleted && typeof qe === 'string' && qe !== 'not-run' && qe !== 'ran' && qe !== '') {
86
+ return 'completed';
87
+ }
88
+ return 'completed-unverified';
89
+ }
90
+
91
+ // A crashed run cannot classify itself; an external consumer assigns that outcome later.
92
+ return 'unclassified';
93
+ }
94
+
61
95
  /**
62
96
  * The vocabulary. Keys are stable identifiers for OUR code to reference; `field` is what goes on
63
97
  * disk, so an upstream rename touches the value and never the call sites.
@@ -75,6 +109,7 @@ export const TELEMETRY_FIELDS: Readonly<Record<string, TelemetryField>> = {
75
109
  // OpenTelemetry carries elapsed time as span structure, not as an attribute, so there is no
76
110
  // upstream name to copy for a JSONL row. Ours, and said so.
77
111
  durationMs: { field: 'dz.duration_ms', source: 'local', means: 'elapsed wall time of a stage or run', unit: 'ms' },
112
+ runOutcome: { field: 'dz.run_outcome', source: 'local', means: 'terminal outcome of one feature-adr pipeline run', unit: null },
78
113
  // Upstream splits usage into input and output and has no TOTAL. Ours are totals, and folding them
79
114
  // into input_tokens would silently change the measured quantity (cross-family review).
80
115
  totalTokens: { field: 'dz.total_tokens', source: 'local', means: 'input + output for a run or stage', unit: 'tokens' },
@@ -122,6 +157,7 @@ export const LOCAL_FIELD_ALIASES: Readonly<Record<string, string>> = {
122
157
  grade: 'evaluationLabel',
123
158
  runId: 'runId',
124
159
  stage: 'stage',
160
+ outcome: 'runOutcome',
125
161
  };
126
162
 
127
163
  /**
@@ -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;