@dzhechkov/harness-core 0.3.127 → 0.3.129

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 (48) hide show
  1. package/.dz-manifest.json +80 -44
  2. package/README.md +2 -1
  3. package/dist/agentdb-index.d.ts +11 -0
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +44 -0
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/delivery-check.d.ts +169 -0
  8. package/dist/delivery-check.d.ts.map +1 -0
  9. package/dist/delivery-check.js +334 -0
  10. package/dist/delivery-check.js.map +1 -0
  11. package/dist/feature-adr-setup.d.ts +17 -1
  12. package/dist/feature-adr-setup.d.ts.map +1 -1
  13. package/dist/feature-adr-setup.js +76 -1
  14. package/dist/feature-adr-setup.js.map +1 -1
  15. package/dist/index.d.ts +5 -4
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +4 -3
  18. package/dist/index.js.map +1 -1
  19. package/dist/learning-backend.d.ts.map +1 -1
  20. package/dist/learning-backend.js +4 -0
  21. package/dist/learning-backend.js.map +1 -1
  22. package/dist/parity.d.ts +6 -0
  23. package/dist/parity.d.ts.map +1 -1
  24. package/dist/parity.js +20 -1
  25. package/dist/parity.js.map +1 -1
  26. package/dist/patterns.d.ts +74 -1
  27. package/dist/patterns.d.ts.map +1 -1
  28. package/dist/patterns.js +148 -9
  29. package/dist/patterns.js.map +1 -1
  30. package/dist/recall-hook-policy.d.ts +7 -0
  31. package/dist/recall-hook-policy.d.ts.map +1 -1
  32. package/dist/recall-hook-policy.js +6 -2
  33. package/dist/recall-hook-policy.js.map +1 -1
  34. package/dist/vector-tier.d.ts +5 -1
  35. package/dist/vector-tier.d.ts.map +1 -1
  36. package/dist/vector-tier.js +22 -6
  37. package/dist/vector-tier.js.map +1 -1
  38. package/package.json +5 -5
  39. package/sbom.json +133 -43
  40. package/src/agentdb-index.ts +46 -0
  41. package/src/delivery-check.ts +452 -0
  42. package/src/feature-adr-setup.ts +82 -2
  43. package/src/index.ts +5 -3
  44. package/src/learning-backend.ts +4 -0
  45. package/src/parity.ts +21 -1
  46. package/src/patterns.ts +195 -11
  47. package/src/recall-hook-policy.ts +14 -2
  48. package/src/vector-tier.ts +23 -5
package/src/parity.ts CHANGED
@@ -104,7 +104,13 @@ export const PARITY_FEATURES: readonly ParityFeature[] = [
104
104
  {
105
105
  id: 'delivery-gate',
106
106
  title: 'Step-10 Delivery Gate',
107
- forms: [{ form: 'workflow delivery block', requires: ['workflows'], level: 'full' }],
107
+ forms: [
108
+ { form: 'workflow delivery block', requires: ['workflows'], level: 'full' },
109
+ // Portable form (portable-gates, run #3): the CLI runs the deterministic parts (artifact probes,
110
+ // hand-off arithmetic, cross-validation bookkeeping) and DISPATCHES the semantic review to the
111
+ // target's own agent runtime — the `dz challenge` cartridge precedent. Requires only `shell`.
112
+ { form: 'dz delivery-check (CLI 4-plane hand-off protocol)', requires: ['shell'], level: 'manual' },
113
+ ],
108
114
  },
109
115
  {
110
116
  id: 'challenge-panel',
@@ -177,6 +183,20 @@ export const TARGET_SHORT_LABELS: Record<TargetName, string> = {
177
183
  windsurf: 'wsf',
178
184
  };
179
185
 
186
+ /**
187
+ * The quality-GATE class of features — what the scaffolded gates doc lists under "Gates runnable
188
+ * here" (a full parity dump put "Skill packs — full" under a gates heading; delivery finding).
189
+ * Must stay a subset of PARITY_FEATURES ids (pinned by test).
190
+ */
191
+ export const GATE_FEATURE_IDS: readonly string[] = [
192
+ 'feature-adr',
193
+ 'delivery-gate',
194
+ 'challenge-panel',
195
+ 'claim-check',
196
+ 'project-guards',
197
+ 'release-signing',
198
+ ];
199
+
180
200
  /** One computed parity cell: the best available form for a feature on a target. */
181
201
  export interface ParityCell {
182
202
  readonly level: 'full' | 'manual' | 'none';
package/src/patterns.ts CHANGED
@@ -93,6 +93,18 @@ export interface MemoryLearningConfig {
93
93
  * ones nudge down. Default FALSE: absent config is byte-identical to the reinforce-only re-rank.
94
94
  */
95
95
  readonly deltaRerank: boolean;
96
+ /**
97
+ * Lesson quarantine (feature lesson-quarantine, ADR-001). When true, a freshly taught lesson is
98
+ * a HYPOTHESIS, not knowledge: marked `qStatus: 'quarantined'`, excluded from the auto-inject
99
+ * hook, damped + ⚠q-marked in interactive recall, and promoted only by an EARNED signal
100
+ * (reinforce or an explicit `dz recall --promote`). Default FALSE: absent config is
101
+ * byte-identical to today.
102
+ */
103
+ readonly quarantine: boolean;
104
+ /** Interactive-recall rank damp for quarantined hits, (0,1]; default 0.5. */
105
+ readonly quarantineDamp: number;
106
+ /** Days after which an unreinforced quarantined lesson is an EXPIRY CANDIDATE (informational). */
107
+ readonly quarantineExpireDays: number;
96
108
  }
97
109
 
98
110
  /**
@@ -128,10 +140,10 @@ export function readLearningConfig(projectRoot: string): LearningConfig {
128
140
  }
129
141
 
130
142
  export function readMemoryLearningConfig(projectRoot: string): MemoryLearningConfig {
131
- const fallback: MemoryLearningConfig = { backend: 'native', onRecallHits: true, usesSat: 64, halfLifeDays: 30, reinforceThreshold: 0.95, deltaRerank: false };
143
+ const fallback: MemoryLearningConfig = { backend: 'native', onRecallHits: true, usesSat: 64, halfLifeDays: 30, reinforceThreshold: 0.95, deltaRerank: false, quarantine: false, quarantineDamp: 0.5, quarantineExpireDays: 30 };
132
144
  try {
133
145
  const parsed = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
134
- memory?: { learning?: { backend?: string; onRecallHits?: boolean; usesSat?: number; halfLifeDays?: number; reinforceThreshold?: number; deltaRerank?: boolean } };
146
+ memory?: { learning?: { backend?: string; onRecallHits?: boolean; usesSat?: number; halfLifeDays?: number; reinforceThreshold?: number; deltaRerank?: boolean; quarantine?: boolean; quarantineDamp?: number; quarantineExpireDays?: number } };
135
147
  };
136
148
  const learning = parsed.memory?.learning ?? {};
137
149
  const backend = learning.backend === 'off' || learning.backend === 'ruvector-gnn' || learning.backend === 'native' ? learning.backend : 'native';
@@ -145,6 +157,16 @@ export function readMemoryLearningConfig(projectRoot: string): MemoryLearningCon
145
157
  ? learning.reinforceThreshold
146
158
  : fallback.reinforceThreshold,
147
159
  deltaRerank: learning.deltaRerank === true, // opt-in; absent/invalid ⇒ false (byte-identical to today)
160
+ quarantine: learning.quarantine === true, // opt-in (byte-identical to today when absent)
161
+ // Number.isFinite clamps (the recurring Infinity lesson): non-finite/out-of-range ⇒ default.
162
+ quarantineDamp:
163
+ typeof learning.quarantineDamp === 'number' && Number.isFinite(learning.quarantineDamp) && learning.quarantineDamp > 0 && learning.quarantineDamp <= 1
164
+ ? learning.quarantineDamp
165
+ : fallback.quarantineDamp,
166
+ quarantineExpireDays:
167
+ typeof learning.quarantineExpireDays === 'number' && Number.isFinite(learning.quarantineExpireDays) && learning.quarantineExpireDays > 0
168
+ ? Math.floor(learning.quarantineExpireDays)
169
+ : fallback.quarantineExpireDays,
148
170
  };
149
171
  } catch {
150
172
  return fallback;
@@ -386,6 +408,29 @@ export function recordToPattern(r: MemoryRecord): PatternRecord {
386
408
  };
387
409
  }
388
410
 
411
+ /**
412
+ * Quarantine state of a learned record (feature lesson-quarantine, ADR-001). Carried in the SAME
413
+ * record metadata as reinforcement state — no schema migration, no second store. ABSENCE of the
414
+ * field = promoted (every pre-feature record is grandfathered), and AM-1 fail-safe points TOWARD
415
+ * knowledge: any unparseable/unknown `qStatus` reads as promoted — a metadata glitch must never
416
+ * isolate a proven lesson (the conservative side here is NOT quarantining).
417
+ */
418
+ export interface QuarantineState {
419
+ readonly quarantined: boolean;
420
+ readonly quarantinedAt?: string;
421
+ }
422
+
423
+ export function readQuarantineState(r: MemoryRecord): QuarantineState {
424
+ const meta = r.metadata ?? {};
425
+ if (meta['qStatus'] !== 'quarantined') return { quarantined: false }; // AM-1 fail-safe
426
+ const at = typeof meta['quarantinedAt'] === 'string' ? meta['quarantinedAt'] : undefined;
427
+ return { quarantined: true, ...(at !== undefined ? { quarantinedAt: at } : {}) };
428
+ }
429
+
430
+ export function encodeQuarantineState(ts: string): Record<string, string> {
431
+ return { qStatus: 'quarantined', quarantinedAt: ts };
432
+ }
433
+
389
434
  export interface ReinforcementState {
390
435
  readonly uses: number;
391
436
  readonly lastUsedTs?: string;
@@ -497,7 +542,7 @@ export interface ReinforcePatternResult {
497
542
  readonly error?: string;
498
543
  }
499
544
 
500
- export async function reinforcePattern(projectRoot: string, dzIdOrText: string, opts: { reward?: number; ts?: string; mergedFrom?: readonly string[] } = {}): Promise<ReinforcePatternResult> {
545
+ export async function reinforcePattern(projectRoot: string, dzIdOrText: string, opts: { reward?: number; ts?: string; mergedFrom?: readonly string[]; exposure?: boolean } = {}): Promise<ReinforcePatternResult> {
501
546
  const records = loadStoreRecords(projectRoot);
502
547
  const rec = records.find((r) => r.id === dzIdOrText || r.text === dzIdOrText);
503
548
  if (rec === undefined) return { ok: false, error: `no learned pattern matches ${JSON.stringify(dzIdOrText)}` };
@@ -512,9 +557,18 @@ export async function reinforcePattern(projectRoot: string, dzIdOrText: string,
512
557
  avgReward,
513
558
  mergedFrom: [...prev.mergedFrom, ...(opts.mergedFrom ?? [])],
514
559
  };
560
+ // Reinforcement IS the earned promotion signal (FR-6a): CONFIRMING a lesson lifts quarantine.
561
+ // EXPOSURE (a recall-hit sample) is not confirmation — stats update, quarantine stays (the
562
+ // no-promotion-by-exposure invariant; found by cross-model QE as a live hole: recall-hit
563
+ // flushes were routed through this same function and silently promoted every viewed lesson).
564
+ const promotedMeta = { ...(rec.metadata ?? {}), ...encodeReinforcementState(nextState) };
565
+ if (opts.exposure !== true) {
566
+ delete promotedMeta['qStatus'];
567
+ delete promotedMeta['quarantinedAt'];
568
+ }
515
569
  const next: MemoryRecord = {
516
570
  ...rec,
517
- metadata: { ...(rec.metadata ?? {}), ...encodeReinforcementState(nextState) },
571
+ metadata: promotedMeta,
518
572
  };
519
573
  const put = await putStoreRecord(projectRoot, next);
520
574
  if ('error' in put) return { ok: false, dzId: rec.id, error: put.error };
@@ -536,6 +590,99 @@ export async function updateReinforcementState(projectRoot: string, dzId: string
536
590
  return { ok: true, dzId, uses: state.uses };
537
591
  }
538
592
 
593
+ /** Result of a promotion (dz recall --promote). */
594
+ export interface PromoteResult {
595
+ readonly ok: boolean;
596
+ readonly promoted: readonly string[];
597
+ readonly notFound: readonly string[];
598
+ readonly notQuarantined: readonly string[];
599
+ readonly error?: string;
600
+ }
601
+
602
+ /**
603
+ * FR-6b: EXPLICIT promotion — lift quarantine from the named records. The other earned path is
604
+ * reinforcement ({@link reinforcePattern} clears the state as part of confirming the lesson).
605
+ * There is deliberately NO exposure-based auto-promotion: `uses` grows from recall HITS, and
606
+ * promoting by exposure would be a self-fulfilling prophecy (ADR).
607
+ */
608
+ export async function promotePatterns(projectRoot: string, dzIds: readonly string[]): Promise<PromoteResult> {
609
+ const records = loadStoreRecords(projectRoot);
610
+ const promoted: string[] = [];
611
+ const notFound: string[] = [];
612
+ const notQuarantined: string[] = [];
613
+ for (const id of dzIds) {
614
+ const rec = records.find((r) => r.id === id);
615
+ if (rec === undefined) { notFound.push(id); continue; }
616
+ if (!readQuarantineState(rec).quarantined) { notQuarantined.push(id); continue; }
617
+ const meta = { ...(rec.metadata ?? {}) };
618
+ delete meta['qStatus'];
619
+ delete meta['quarantinedAt'];
620
+ const put = await putStoreRecord(projectRoot, { ...rec, metadata: meta });
621
+ if ('error' in put) return { ok: false, promoted, notFound, notQuarantined, error: put.error };
622
+ promoted.push(id);
623
+ }
624
+ return { ok: true, promoted, notFound, notQuarantined };
625
+ }
626
+
627
+ /** One quarantined-and-stale record, surfaced for review (informational — FR-7). */
628
+ export interface QuarantineExpiryCandidate {
629
+ readonly dzId: string;
630
+ readonly text: string;
631
+ readonly quarantinedAt: string;
632
+ readonly ageDays: number;
633
+ }
634
+
635
+ /**
636
+ * FR-7 (informational-first, per the recalled decay-vs-noise lesson): quarantined records older
637
+ * than `expireDays` with ZERO reinforcement are EXPIRY CANDIDATES — reported, never auto-deleted.
638
+ * Deletion happens only through {@link pruneQuarantinePatterns} (explicit, snapshotted).
639
+ */
640
+ export function quarantineExpiryCandidates(projectRoot: string, expireDays: number, now: Date = new Date()): QuarantineExpiryCandidate[] {
641
+ const out: QuarantineExpiryCandidate[] = [];
642
+ // Codex-QE fix (finding 6): the destructive API clamps its own input — a zero/negative/NaN
643
+ // expireDays would make FRESH lessons instantly deletable through the exported surface.
644
+ const days = Number.isFinite(expireDays) && expireDays > 0 ? expireDays : 30;
645
+ for (const r of loadStoreRecords(projectRoot)) {
646
+ const q = readQuarantineState(r);
647
+ if (!q.quarantined) continue;
648
+ // NO uses-based immunity (Codex-QE finding 1): a TRULY reinforced record is no longer
649
+ // quarantined at all (reinforce clears qStatus), so any uses on a still-quarantined record
650
+ // are EXPOSURE — and exposure must not immortalize a hypothesis.
651
+ const at = q.quarantinedAt ?? r.timestamp;
652
+ let ageMs = now.getTime() - new Date(at).getTime();
653
+ if (!Number.isFinite(ageMs)) ageMs = now.getTime() - new Date(r.timestamp).getTime(); // finding 7: fall back
654
+ if (!Number.isFinite(ageMs)) {
655
+ // Both timestamps corrupt: surface for human review (ageDays -1 renders as "?") — an
656
+ // unparseable age must not make the record IMMORTAL in quarantine (finding 7).
657
+ out.push({ dzId: r.id, text: r.text, quarantinedAt: at, ageDays: -1 });
658
+ continue;
659
+ }
660
+ const ageDays = ageMs / 86_400_000;
661
+ if (ageDays >= days) out.push({ dzId: r.id, text: r.text, quarantinedAt: at, ageDays: Math.floor(ageDays) });
662
+ }
663
+ return out;
664
+ }
665
+
666
+ /**
667
+ * Destructive half of expiry — a SEPARATE, explicit gate (never coupled to --prune-noise; AM-3).
668
+ * Dry-run by default; a live run snapshots the store first ({@link snapshotStore}) then removes
669
+ * exactly the expiry candidates via {@link removePatternsByIds}.
670
+ */
671
+ export function pruneQuarantinePatterns(
672
+ projectRoot: string,
673
+ opts: { dryRun?: boolean; expireDays: number; now?: Date },
674
+ ): { candidates: QuarantineExpiryCandidate[]; removed: number; snapshot?: string; error?: string } {
675
+ const now = opts.now ?? new Date();
676
+ const candidates = quarantineExpiryCandidates(projectRoot, opts.expireDays, now);
677
+ if (opts.dryRun !== false || candidates.length === 0) return { candidates, removed: 0 };
678
+ // Snapshot BEFORE the drop; a failed snapshot ABORTS the deletion (the harmonize precedent —
679
+ // a destructive sweep with no receipt is exactly what the recalled decay lesson forbids).
680
+ const snap = snapshotStore(projectRoot, join(projectRoot, '.dz', `patterns-pre-prune-quarantine-${now.getTime()}.json`));
681
+ if (snap.error !== undefined) return { candidates, removed: 0, error: `aborted: ${snap.error}` };
682
+ const res = removePatternsByIds(projectRoot, new Set(candidates.map((c) => c.dzId)));
683
+ return { candidates, removed: res.removed, snapshot: snap.path, ...(res.error !== undefined ? { error: res.error } : {}) };
684
+ }
685
+
539
686
  export interface StoreStats {
540
687
  readonly total: number;
541
688
  readonly perDomain: Record<string, number>;
@@ -703,13 +850,18 @@ function migrationRecords(projectRoot: string): MemoryRecord[] {
703
850
  * duplicates), so migrating JSON→SQLite never loses or doubles a record, and the
704
851
  * JSON file is never deleted. Returns the total record count after the write.
705
852
  */
706
- export async function recordPattern(projectRoot: string, p: PatternRecord): Promise<number> {
853
+ export async function recordPattern(projectRoot: string, p: PatternRecord, opts: { quarantine?: boolean } = {}): Promise<number> {
707
854
  const { sqliteBackend } = readLearningConfig(projectRoot);
855
+ // lesson-quarantine: a fresh lesson is a HYPOTHESIS — mark it when the feature is on. Folded
856
+ // legacy records are NEVER marked (they predate the feature: grandfathered as promoted).
857
+ const rec = opts.quarantine === true
858
+ ? { ...patternToRecord(p), metadata: { ...patternToRecord(p).metadata, ...encodeQuarantineState(p.ts) } }
859
+ : patternToRecord(p);
708
860
  const sqlite = tryOpenSqlite(projectRoot, sqliteBackend);
709
861
  if (sqlite) {
710
862
  try {
711
863
  for (const r of migrationRecords(projectRoot)) await sqlite.put(r);
712
- await sqlite.put(patternToRecord(p));
864
+ await sqlite.put(rec);
713
865
  return await sqlite.count();
714
866
  } finally {
715
867
  sqlite.close();
@@ -720,7 +872,7 @@ export async function recordPattern(projectRoot: string, p: PatternRecord): Prom
720
872
  for (const legacy of readJsonl(join(projectRoot, '.dz', 'patterns.jsonl'), isPatternRecord)) {
721
873
  await backend.put(patternToRecord(legacy));
722
874
  }
723
- await backend.put(patternToRecord(p));
875
+ await backend.put(rec);
724
876
  await backend.save();
725
877
  return backend.count();
726
878
  }
@@ -735,6 +887,8 @@ export interface RecallHit {
735
887
  * emits them — it stays the sync lexical baseline, AC-5).
736
888
  */
737
889
  readonly backend: 'sqlite' | 'json' | 'vector' | 'both';
890
+ /** lesson-quarantine: set (true) only for a quarantined hit — display marks it ⚠q and ranking damps/sinks it. */
891
+ readonly quarantined?: boolean;
738
892
  }
739
893
 
740
894
  /**
@@ -749,21 +903,47 @@ export function recallPatterns(projectRoot: string, query: string, limit = 10):
749
903
  try {
750
904
  const db = SqliteBackend.open(sqlitePath(projectRoot));
751
905
  try {
752
- return db.querySync({ text: query, limit }).map((r) => ({ pattern: recordToPattern(r), backend: 'sqlite' as const }));
906
+ return sinkQuarantined(
907
+ db.querySync({ text: query, limit: limit * 2 }).map((r) => ({
908
+ pattern: recordToPattern(r),
909
+ backend: 'sqlite' as const,
910
+ ...(readQuarantineState(r).quarantined ? { quarantined: true as const } : {}),
911
+ })),
912
+ limit,
913
+ );
753
914
  } finally {
754
915
  db.close();
755
916
  }
756
917
  } catch { /* fall through to JSON */ }
757
918
  }
758
919
  try {
759
- return JsonFileBackend.openSync(storePath(projectRoot))
760
- .querySync({ text: query, limit })
761
- .map((r) => ({ pattern: recordToPattern(r), backend: 'json' as const }));
920
+ return sinkQuarantined(
921
+ JsonFileBackend.openSync(storePath(projectRoot))
922
+ .querySync({ text: query, limit: limit * 2 })
923
+ .map((r) => ({
924
+ pattern: recordToPattern(r),
925
+ backend: 'json' as const,
926
+ ...(readQuarantineState(r).quarantined ? { quarantined: true as const } : {}),
927
+ })),
928
+ limit,
929
+ );
762
930
  } catch {
763
931
  return [];
764
932
  }
765
933
  }
766
934
 
935
+ /**
936
+ * Lexical recall has no numeric rank to damp, so quarantine "damping" is a STABLE sink: promoted
937
+ * hits keep their relative order first, quarantined hits follow (still VISIBLE — hiding them would
938
+ * make the loop a write-only log; ADR D2). Hybrid (scored) recall damps numerically instead.
939
+ */
940
+ function sinkQuarantined<T extends { quarantined?: boolean }>(hits: readonly T[], limit?: number): T[] {
941
+ // Codex-QE finding 2: the query OVERFETCHES (2×limit) before sinking, so quarantined hits in
942
+ // the backend's top-K cannot crowd promoted knowledge out of the final window.
943
+ const sunk = [...hits.filter((h) => h.quarantined !== true), ...hits.filter((h) => h.quarantined === true)];
944
+ return limit !== undefined ? sunk.slice(0, limit) : sunk;
945
+ }
946
+
767
947
  /* ------------------------------------------------------------------ */
768
948
  /* Tier-2.5: session consolidation (harvestDreamPatterns) */
769
949
  /* ------------------------------------------------------------------ */
@@ -1044,6 +1224,9 @@ export function pruneNoisePatterns(projectRoot: string, opts: { dryRun?: boolean
1044
1224
  const db = SqliteBackend.open(sqlitePath(projectRoot));
1045
1225
  try {
1046
1226
  for (const r of db.allSync()) {
1227
+ // AM-3 (recalled decay-vs-noise lesson): a quarantined record is valid-but-unproven, not
1228
+ // garbage — noise-prune must NEVER touch it; its lifecycle belongs to prune-quarantine.
1229
+ if (readQuarantineState(r).quarantined) continue;
1047
1230
  if (isNoiseInsight(r.text)) {
1048
1231
  candidates.push({ id: r.id, text: r.text });
1049
1232
  if (!dryRun) db.removeSync(r.id);
@@ -1064,6 +1247,7 @@ export function pruneNoisePatterns(projectRoot: string, opts: { dryRun?: boolean
1064
1247
  const backend = JsonFileBackend.openSync(storePath(projectRoot));
1065
1248
  let removed = 0;
1066
1249
  for (const r of backend.allSync()) {
1250
+ if (readQuarantineState(r).quarantined) continue; // AM-3: never coupled to noise-prune
1067
1251
  if (isNoiseInsight(r.text)) {
1068
1252
  if (!candidates.some((c) => c.id === r.id)) candidates.push({ id: r.id, text: r.text });
1069
1253
  if (!dryRun) backend.removeSync(r.id);
@@ -100,6 +100,8 @@ export interface HookCandidate {
100
100
  readonly pattern: string;
101
101
  readonly score: number;
102
102
  readonly domain?: string;
103
+ /** lesson-quarantine: an unproven hypothesis — the AUTO-INJECT surface excludes it (FR-5). */
104
+ readonly quarantined?: boolean;
103
105
  }
104
106
 
105
107
  export interface HookSelection {
@@ -107,6 +109,11 @@ export interface HookSelection {
107
109
  /** The floor that was applied — reported so the hook's own output can explain its silence. */
108
110
  readonly floor: number;
109
111
  readonly lang: QueryLang;
112
+ /**
113
+ * lesson-quarantine AM-2: how many candidates were dropped for being quarantined — the
114
+ * exclusion is OBSERVABLE (the hook logs it), never a silent shrink of the context.
115
+ */
116
+ readonly quarantinedExcluded: number;
110
117
  }
111
118
 
112
119
  /**
@@ -125,9 +132,14 @@ export function selectHookHits(
125
132
  const budget = intOr(opts.budgetChars, DEFAULT_RECALL_HOOK_BUDGET_CHARS);
126
133
 
127
134
  // A prompt with no intent gets no injection, whatever its cosine says.
128
- if (!hasEnoughSignal(prompt)) return { hits: [], floor, lang };
135
+ if (!hasEnoughSignal(prompt)) return { hits: [], floor, lang, quarantinedExcluded: 0 };
136
+
137
+ // FR-5: the auto-inject surface is the STRICTEST — a quarantined lesson (unproven hypothesis)
138
+ // never rides into a prompt uninvited. Counted, not silent (AM-2).
139
+ const quarantinedExcluded = (candidates ?? []).filter((c) => c !== null && typeof c === 'object' && c.quarantined === true).length;
129
140
 
130
141
  const clean = (candidates ?? [])
142
+ .filter((c) => !(c !== null && typeof c === 'object' && c.quarantined === true))
131
143
  .filter(
132
144
  (c): c is HookCandidate =>
133
145
  c !== null &&
@@ -152,7 +164,7 @@ export function selectHookHits(
152
164
  hits.push(c);
153
165
  used += cost;
154
166
  }
155
- return { hits, floor, lang };
167
+ return { hits, floor, lang, quarantinedExcluded };
156
168
  }
157
169
 
158
170
  function intOr(v: unknown, fallback: number): number {
@@ -50,6 +50,7 @@ import {
50
50
  loadStoreRecords,
51
51
  readMemoryLearningConfig,
52
52
  readReinforcementState,
53
+ readQuarantineState,
53
54
  removePatternsByIds,
54
55
  snapshotStore,
55
56
  updateReinforcementState,
@@ -155,6 +156,8 @@ export interface HybridHit {
155
156
  readonly backend: RecallHit['backend'];
156
157
  /** Reciprocal-rank-fusion score (ranking only — NOT the pattern's reward). */
157
158
  readonly score: number;
159
+ /** lesson-quarantine: set only for a quarantined hit — display marks ⚠q, ranking was damped. */
160
+ readonly quarantined?: boolean;
158
161
  }
159
162
 
160
163
  /** Outcome of {@link recallHybrid}. With no engine this is content-identical to `recallPatterns`. */
@@ -336,7 +339,7 @@ export function isVectorNoise(text: string): boolean {
336
339
  * ACL: taught {@link PatternRecord} → {@link VectorEntry}. Returns `undefined` for noise (the
337
340
  * ingest gate — I-6). Score is the record's REAL reward, never a fabricated 1.0.
338
341
  */
339
- export function patternVectorEntry(p: PatternRecord, source = 'dz-teach'): VectorEntry | undefined {
342
+ export function patternVectorEntry(p: PatternRecord, source = 'dz-teach', opts: { quarantined?: boolean } = {}): VectorEntry | undefined {
340
343
  if (isVectorNoise(p.pattern)) return undefined;
341
344
  const dzId = patternRecordId(p);
342
345
  return {
@@ -345,7 +348,9 @@ export function patternVectorEntry(p: PatternRecord, source = 'dz-teach'): Vecto
345
348
  score: p.reward,
346
349
  taskType: 'dz-teach',
347
350
  tags: ['dz-teach', p.type],
348
- metadata: { dzId, source, ts: p.ts, domain: p.domain },
351
+ // FR-8: quarantine rides into the mirror so the HOOK DAEMON (which reads only the mirror's
352
+ // sqlite metadata) can exclude unproven lessons from auto-injection.
353
+ metadata: { dzId, source, ts: p.ts, domain: p.domain, ...(opts.quarantined === true ? { qStatus: 'quarantined' } : {}) },
349
354
  };
350
355
  }
351
356
 
@@ -790,7 +795,20 @@ export async function recallHybrid(
790
795
  const learning = resolveLearningBackend(projectRoot);
791
796
  // Phase 3: opt-in SAFLA-delta re-rank. The map is built ONCE per recall (off ⇒ undefined ⇒ the
792
797
  // reinforce-only path, byte-identical to today).
793
- const deltaMap = readMemoryLearningConfig(projectRoot).deltaRerank ? lessonDeltaMap(projectRoot) : undefined;
798
+ const memCfg = readMemoryLearningConfig(projectRoot);
799
+ const deltaMap = memCfg.deltaRerank ? lessonDeltaMap(projectRoot) : undefined;
800
+ // lesson-quarantine: damp read from the AUTHORITATIVE store records (idToRecord), never from
801
+ // mirror metadata — the mirror may lag a promotion; the store cannot.
802
+ const dampQuarantined = (hits: readonly HybridHit[]): HybridHit[] => {
803
+ if (!memCfg.quarantine) return [...hits];
804
+ return hits
805
+ .map((h) => {
806
+ const rec = idToRecord.get(idOf(h.pattern));
807
+ const q = rec !== undefined && readQuarantineState(rec).quarantined;
808
+ return q ? { ...h, score: h.score * memCfg.quarantineDamp, quarantined: true as const } : h;
809
+ })
810
+ .sort((a, b) => b.score - a.score);
811
+ };
794
812
  const enhance = (hits: readonly HybridHit[]): HybridHit[] => {
795
813
  const candidates = hits.map((h) => {
796
814
  const dzId = idOf(h.pattern);
@@ -799,9 +817,9 @@ export async function recallHybrid(
799
817
  });
800
818
  if (deltaMap !== undefined) {
801
819
  const deltaByIndex = candidates.map((c) => deltaMap.get(c.dzId) ?? 0);
802
- return applyLearningSignalsWithDelta(hits, learning, candidates, REINFORCE_RRF_CAP, deltaByIndex, REINFORCE_RRF_CAP);
820
+ return dampQuarantined(applyLearningSignalsWithDelta(hits, learning, candidates, REINFORCE_RRF_CAP, deltaByIndex, REINFORCE_RRF_CAP));
803
821
  }
804
- return applyLearningSignals(hits, learning, candidates, REINFORCE_RRF_CAP);
822
+ return dampQuarantined(applyLearningSignals(hits, learning, candidates, REINFORCE_RRF_CAP));
805
823
  };
806
824
  const lexicalOnly = (extra: Partial<Pick<HybridRecall, 'vectorEngine' | 'vectorReason' | 'vectorError'>>): HybridRecall => ({
807
825
  hits: enhance(lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) }))),