@dzhechkov/harness-core 0.8.29 → 0.8.31

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 (138) hide show
  1. package/.dz-manifest.json +242 -102
  2. package/README.md +271 -0
  3. package/dist/agentdb-index.d.ts +45 -0
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +345 -85
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +170 -0
  8. package/dist/agentdb-reindex-marker.d.ts.map +1 -0
  9. package/dist/agentdb-reindex-marker.js +293 -0
  10. package/dist/agentdb-reindex-marker.js.map +1 -0
  11. package/dist/agentdb-snapshot-rotation.d.ts +169 -0
  12. package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
  13. package/dist/agentdb-snapshot-rotation.js +322 -0
  14. package/dist/agentdb-snapshot-rotation.js.map +1 -0
  15. package/dist/agentdb-snapshot.d.ts +126 -0
  16. package/dist/agentdb-snapshot.d.ts.map +1 -0
  17. package/dist/agentdb-snapshot.js +195 -0
  18. package/dist/agentdb-snapshot.js.map +1 -0
  19. package/dist/apply-leg.d.ts +202 -0
  20. package/dist/apply-leg.d.ts.map +1 -0
  21. package/dist/apply-leg.js +975 -0
  22. package/dist/apply-leg.js.map +1 -0
  23. package/dist/book-kb.d.ts.map +1 -1
  24. package/dist/book-kb.js +57 -1
  25. package/dist/book-kb.js.map +1 -1
  26. package/dist/brain.d.ts +3 -0
  27. package/dist/brain.d.ts.map +1 -1
  28. package/dist/brain.js +1 -1
  29. package/dist/brain.js.map +1 -1
  30. package/dist/cadence.d.ts +30 -1
  31. package/dist/cadence.d.ts.map +1 -1
  32. package/dist/cadence.js +66 -2
  33. package/dist/cadence.js.map +1 -1
  34. package/dist/guard.d.ts +14 -0
  35. package/dist/guard.d.ts.map +1 -1
  36. package/dist/guard.js +36 -0
  37. package/dist/guard.js.map +1 -1
  38. package/dist/index.d.ts +23 -8
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +18 -5
  41. package/dist/index.js.map +1 -1
  42. package/dist/integration-probe-worker.d.ts.map +1 -1
  43. package/dist/integration-probe-worker.js +4 -6
  44. package/dist/integration-probe-worker.js.map +1 -1
  45. package/dist/integrations-verify.d.ts.map +1 -1
  46. package/dist/integrations-verify.js +3 -1
  47. package/dist/integrations-verify.js.map +1 -1
  48. package/dist/managed-hooks.d.ts +11 -2
  49. package/dist/managed-hooks.d.ts.map +1 -1
  50. package/dist/managed-hooks.js +33 -7
  51. package/dist/managed-hooks.js.map +1 -1
  52. package/dist/mutation-gate.d.ts +35 -1
  53. package/dist/mutation-gate.d.ts.map +1 -1
  54. package/dist/mutation-gate.js +49 -9
  55. package/dist/mutation-gate.js.map +1 -1
  56. package/dist/operations.d.ts.map +1 -1
  57. package/dist/operations.js +94 -0
  58. package/dist/operations.js.map +1 -1
  59. package/dist/patterns.d.ts.map +1 -1
  60. package/dist/patterns.js +23 -4
  61. package/dist/patterns.js.map +1 -1
  62. package/dist/publish.d.ts +21 -0
  63. package/dist/publish.d.ts.map +1 -1
  64. package/dist/publish.js.map +1 -1
  65. package/dist/repository-origin.d.ts +1 -1
  66. package/dist/repository-origin.d.ts.map +1 -1
  67. package/dist/repository-origin.js +7 -1
  68. package/dist/repository-origin.js.map +1 -1
  69. package/dist/round-exec.d.ts +41 -0
  70. package/dist/round-exec.d.ts.map +1 -0
  71. package/dist/round-exec.js +45 -0
  72. package/dist/round-exec.js.map +1 -0
  73. package/dist/round.d.ts +109 -0
  74. package/dist/round.d.ts.map +1 -0
  75. package/dist/round.js +148 -0
  76. package/dist/round.js.map +1 -0
  77. package/dist/run-records.d.ts +8 -0
  78. package/dist/run-records.d.ts.map +1 -1
  79. package/dist/run-records.js +69 -3
  80. package/dist/run-records.js.map +1 -1
  81. package/dist/setup.d.ts +8 -0
  82. package/dist/setup.d.ts.map +1 -1
  83. package/dist/setup.js +166 -3
  84. package/dist/setup.js.map +1 -1
  85. package/dist/sqlite-read-helpers.d.ts +60 -0
  86. package/dist/sqlite-read-helpers.d.ts.map +1 -0
  87. package/dist/sqlite-read-helpers.js +118 -0
  88. package/dist/sqlite-read-helpers.js.map +1 -0
  89. package/dist/statusline.d.ts +22 -3
  90. package/dist/statusline.d.ts.map +1 -1
  91. package/dist/statusline.js +43 -7
  92. package/dist/statusline.js.map +1 -1
  93. package/dist/store-counts.d.ts +41 -3
  94. package/dist/store-counts.d.ts.map +1 -1
  95. package/dist/store-counts.js +305 -59
  96. package/dist/store-counts.js.map +1 -1
  97. package/dist/store-guard.d.ts +2 -2
  98. package/dist/store-guard.d.ts.map +1 -1
  99. package/dist/store-guard.js +6 -0
  100. package/dist/store-guard.js.map +1 -1
  101. package/dist/usage.d.ts +55 -0
  102. package/dist/usage.d.ts.map +1 -1
  103. package/dist/usage.js +184 -33
  104. package/dist/usage.js.map +1 -1
  105. package/dist/vector-tier.d.ts +18 -1
  106. package/dist/vector-tier.d.ts.map +1 -1
  107. package/dist/vector-tier.js +26 -2
  108. package/dist/vector-tier.js.map +1 -1
  109. package/package.json +7 -6
  110. package/sbom.json +451 -101
  111. package/src/agentdb-index.ts +315 -32
  112. package/src/agentdb-reindex-marker.ts +330 -0
  113. package/src/agentdb-snapshot-rotation.ts +393 -0
  114. package/src/agentdb-snapshot.ts +222 -0
  115. package/src/apply-leg.ts +1046 -0
  116. package/src/book-kb.ts +64 -1
  117. package/src/brain.ts +11 -2
  118. package/src/cadence.ts +87 -2
  119. package/src/guard.ts +46 -0
  120. package/src/index.ts +58 -5
  121. package/src/integration-probe-worker.ts +4 -6
  122. package/src/integrations-verify.ts +3 -1
  123. package/src/managed-hooks.ts +32 -7
  124. package/src/mutation-gate.ts +87 -8
  125. package/src/operations.ts +89 -0
  126. package/src/patterns.ts +23 -4
  127. package/src/publish.ts +23 -0
  128. package/src/repository-origin.ts +7 -1
  129. package/src/round-exec.ts +85 -0
  130. package/src/round.ts +228 -0
  131. package/src/run-records.ts +77 -2
  132. package/src/setup.ts +198 -3
  133. package/src/sqlite-read-helpers.ts +128 -0
  134. package/src/statusline.ts +61 -9
  135. package/src/store-counts.ts +391 -61
  136. package/src/store-guard.ts +8 -2
  137. package/src/usage.ts +243 -29
  138. package/src/vector-tier.ts +45 -4
@@ -2,15 +2,147 @@
2
2
  import { createRequire } from 'node:module';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
+ import type { MemoryRecord } from '@dzhechkov/memory';
6
+ import { patternRecordId, recordToPattern } from './patterns.js';
7
+ import { isMirrorableRecord } from './vector-tier.js';
5
8
 
6
9
  interface ReadonlyCountDb {
7
10
  pragma: (s: string) => void;
8
- prepare: (q: string) => { get: (...a: unknown[]) => unknown };
11
+ prepare: (q: string) => {
12
+ get: (...a: unknown[]) => unknown;
13
+ all: (...a: unknown[]) => unknown[];
14
+ };
9
15
  close: () => void;
10
16
  }
11
17
 
18
+ export type StoreRowCount = number | 'unreadable' | 'busy';
19
+
20
+ export interface StoreCountOptions {
21
+ readonly busyTimeoutMs?: number;
22
+ readonly attempts?: number;
23
+ }
24
+
25
+ export interface QuarantineTierRow {
26
+ readonly id?: string;
27
+ readonly dzId?: string;
28
+ /** Content-addressed alias used by the teach-time mirror before a lexical row is re-keyed. */
29
+ readonly patternRecordId?: string;
30
+ readonly quarantined?: boolean;
31
+ readonly qStatus?: unknown;
32
+ readonly metadata?: Record<string, unknown> | string | null;
33
+ }
34
+
35
+ export interface QuarantineTierParity {
36
+ readonly both: number;
37
+ readonly lexicalOnly: number;
38
+ readonly mirrorOnly: number;
39
+ readonly ids: {
40
+ readonly both: readonly string[];
41
+ readonly lexicalOnly: readonly string[];
42
+ readonly mirrorOnly: readonly string[];
43
+ };
44
+ }
45
+
46
+ function rowMetadata(row: QuarantineTierRow): Record<string, unknown> {
47
+ if (typeof row.metadata === 'object' && row.metadata !== null) return row.metadata;
48
+ if (typeof row.metadata !== 'string') return {};
49
+ try {
50
+ const parsed = JSON.parse(row.metadata) as unknown;
51
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
52
+ ? parsed as Record<string, unknown>
53
+ : {};
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ interface QuarantineIdentity {
60
+ readonly primary: string;
61
+ readonly aliases: ReadonlySet<string>;
62
+ }
63
+
64
+ function quarantineIdentities(rows: readonly QuarantineTierRow[]): QuarantineIdentity[] {
65
+ const identities = new Map<string, QuarantineIdentity>();
66
+ for (const row of rows) {
67
+ const metadata = rowMetadata(row);
68
+ const quarantined = row.quarantined === true
69
+ || row.qStatus === 'quarantined'
70
+ || metadata['qStatus'] === 'quarantined';
71
+ if (!quarantined) continue;
72
+ const candidates = [row.dzId, metadata['dzId'], row.id, row.patternRecordId]
73
+ .filter((id): id is string => typeof id === 'string' && id !== '');
74
+ const primary = candidates[0];
75
+ if (primary === undefined) continue;
76
+ identities.set(primary, { primary, aliases: new Set(candidates) });
77
+ }
78
+ return [...identities.values()];
79
+ }
80
+
81
+ /** Pure dual-key comparison for active quarantine labels in the lexical and mirror tiers. */
82
+ export function quarantineTierParity(
83
+ lexicalRows: readonly QuarantineTierRow[],
84
+ mirrorRows: readonly QuarantineTierRow[],
85
+ ): QuarantineTierParity {
86
+ const lexical = quarantineIdentities(lexicalRows);
87
+ const mirror = quarantineIdentities(mirrorRows);
88
+ const lexicalKeys = new Set(lexical.flatMap((identity) => [...identity.aliases]));
89
+ const mirrorKeys = new Set(mirror.flatMap((identity) => [...identity.aliases]));
90
+ const both = [...new Set(lexical.flatMap((identity) => {
91
+ const matched = [...identity.aliases].find((id) => mirrorKeys.has(id));
92
+ return matched === undefined ? [] : [matched];
93
+ }))].sort();
94
+ const lexicalOnly = lexical
95
+ .filter((identity) => ![...identity.aliases].some((id) => mirrorKeys.has(id)))
96
+ .map((identity) => identity.primary)
97
+ .sort();
98
+ const mirrorOnly = mirror
99
+ .filter((identity) => ![...identity.aliases].some((id) => lexicalKeys.has(id)))
100
+ .map((identity) => identity.primary)
101
+ .sort();
102
+ return {
103
+ both: both.length,
104
+ lexicalOnly: lexicalOnly.length,
105
+ mirrorOnly: mirrorOnly.length,
106
+ ids: { both, lexicalOnly, mirrorOnly },
107
+ };
108
+ }
109
+
110
+ type DatabaseRequire = (id: string) => unknown;
111
+
112
+ function isBusy(error: unknown): boolean {
113
+ return typeof error === 'object' && error !== null
114
+ && (error as { code?: unknown }).code === 'SQLITE_BUSY';
115
+ }
116
+
117
+ function isInitializing(error: unknown): boolean {
118
+ return typeof error === 'object' && error !== null
119
+ && (error as { code?: unknown }).code === 'SQLITE_ERROR'
120
+ && typeof (error as { message?: unknown }).message === 'string'
121
+ && (error as { message: string }).message.includes('no such table');
122
+ }
123
+
124
+ const INITIALIZATION_RETRY_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
125
+
126
+ function waitForInitializationRetry(busyTimeoutMs: number): void {
127
+ Atomics.wait(INITIALIZATION_RETRY_WAIT, 0, 0, busyTimeoutMs);
128
+ }
129
+
130
+ function normalizedOptions(options: StoreCountOptions): { busyTimeoutMs: number; attempts: number } {
131
+ return {
132
+ busyTimeoutMs: options.busyTimeoutMs ?? 100,
133
+ attempts: options.attempts ?? 1,
134
+ };
135
+ }
136
+
12
137
  export interface LearningStoreRowCounts {
13
- readonly lexicalRows: number | 'unreadable';
138
+ readonly lexicalRows: StoreRowCount;
139
+ /** Lexical rows accepted by the same class/noise gate as the vector mirror writer. */
140
+ readonly lexicalMirrorableRows?: number;
141
+ /** Mirrorable lexical rows whose current qStatus is quarantined. */
142
+ readonly lexicalMirrorableQuarantinedRows?: number;
143
+ /** Mutually exclusive exclusion buckets; class takes precedence over noise. */
144
+ readonly lexicalExcludedClassRows?: number;
145
+ readonly lexicalExcludedNoiseRows?: number;
14
146
  /** Exact active quarantine labels in the selected lexical SQLite tier; absent for fallback/error paths. */
15
147
  readonly lexicalQuarantinedRows?: number;
16
148
  /** Physical lexical population counted; jsonl and SQLite maxima are not comparable. */
@@ -26,7 +158,7 @@ export interface LearningStoreRowCounts {
26
158
  * до уроков уронило бы наблюдение с 1774 до 631 — страж прочитал бы это как обвал стора.
27
159
  * Для показателя зеркала в панели есть отдельное поле `vectorLessonRows`.
28
160
  */
29
- readonly vectorRows: number | 'unreadable';
161
+ readonly vectorRows: StoreRowCount;
30
162
  /**
31
163
  * Только зеркальные УРОКИ (task_type dz-teach/dz-learning) — величина, сравнимая с `lexicalRows`.
32
164
  * Отсутствует, когда зеркала нет или разложить его по родам не удалось: тогда показатель обязан
@@ -37,6 +169,8 @@ export interface LearningStoreRowCounts {
37
169
  readonly vectorQuarantinedRows?: number;
38
170
  /** Present when the vector store file exists, including when its count is unreadable. */
39
171
  readonly vectorSourcePath?: string;
172
+ /** Exact qStatus parity by dzId; absent when either tier cannot establish quarantine ids. */
173
+ readonly quarantineTierParity?: QuarantineTierParity;
40
174
  }
41
175
 
42
176
  function countJsonlRowsReadonly(path: string): number | 'unreadable' {
@@ -52,89 +186,262 @@ function countJsonlRowsReadonly(path: string): number | 'unreadable' {
52
186
  export function countSqliteRowsReadonly(
53
187
  sqlitePath: string,
54
188
  table: 'memory_records' | 'reasoning_patterns',
55
- ): number | undefined {
56
- try {
57
- const require = createRequire(import.meta.url);
58
- const Database = require('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
59
- const db = new Database(sqlitePath, { readonly: true });
189
+ ): number | undefined;
190
+ export function countSqliteRowsReadonly(
191
+ sqlitePath: string,
192
+ table: 'memory_records' | 'reasoning_patterns',
193
+ options: StoreCountOptions,
194
+ requireModule?: DatabaseRequire,
195
+ ): StoreRowCount;
196
+ export function countSqliteRowsReadonly(
197
+ sqlitePath: string,
198
+ table: 'memory_records' | 'reasoning_patterns',
199
+ options?: StoreCountOptions,
200
+ requireModule: DatabaseRequire = createRequire(import.meta.url),
201
+ ): number | undefined | StoreRowCount {
202
+ const legacyUndefined = options === undefined;
203
+ const { busyTimeoutMs, attempts } = normalizedOptions(options ?? {});
204
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
60
205
  try {
61
- db.pragma('busy_timeout = 100');
62
- const row = db.prepare(`SELECT COUNT(*) as cnt FROM ${table}`).get() as { cnt?: unknown };
63
- return typeof row?.cnt === 'number' ? row.cnt : undefined;
64
- } finally {
65
- db.close();
206
+ const Database = requireModule('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
207
+ const db = new Database(sqlitePath, { readonly: true });
208
+ try {
209
+ db.pragma(`busy_timeout = ${busyTimeoutMs}`);
210
+ const row = db.prepare(`SELECT COUNT(*) as cnt FROM ${table}`).get() as { cnt?: unknown };
211
+ return typeof row?.cnt === 'number' ? row.cnt : (legacyUndefined ? undefined : 'unreadable');
212
+ } finally {
213
+ db.close();
214
+ }
215
+ } catch (error) {
216
+ if (isBusy(error)) {
217
+ if (attempt + 1 < attempts) continue;
218
+ return legacyUndefined ? undefined : 'busy';
219
+ }
220
+ if (isInitializing(error)) {
221
+ if (attempt + 1 < attempts) {
222
+ waitForInitializationRetry(busyTimeoutMs);
223
+ continue;
224
+ }
225
+ return legacyUndefined ? undefined : 'unreadable';
226
+ }
227
+ return legacyUndefined ? undefined : 'unreadable';
66
228
  }
67
- } catch {
68
- return undefined;
69
229
  }
230
+ return legacyUndefined ? undefined : 'busy';
70
231
  }
71
232
 
72
233
  interface SqliteRowsWithQuarantine {
73
234
  readonly rows: number;
74
235
  readonly quarantinedRows?: number;
236
+ readonly mirrorableRows?: number;
237
+ readonly mirrorableQuarantinedRows?: number;
238
+ readonly excludedClassRows?: number;
239
+ readonly excludedNoiseRows?: number;
75
240
  /** Только для зеркала: подсчёт уроков внутри общего объёма. */
76
241
  readonly lessonRows?: number;
242
+ readonly quarantinedIdentities?: readonly QuarantineTierRow[];
243
+ }
244
+
245
+ function lexicalMirrorPopulation(db: ReadonlyCountDb): Pick<
246
+ SqliteRowsWithQuarantine,
247
+ 'mirrorableRows' | 'mirrorableQuarantinedRows' | 'excludedClassRows' | 'excludedNoiseRows' | 'quarantinedIdentities'
248
+ > | undefined {
249
+ let rows: unknown[];
250
+ let idsEstablished = true;
251
+ try {
252
+ rows = db.prepare('SELECT id, skill_id, text, score, outcome, timestamp, metadata FROM memory_records').all();
253
+ } catch {
254
+ try {
255
+ rows = db.prepare('SELECT id, text, metadata FROM memory_records').all();
256
+ } catch {
257
+ idsEstablished = false;
258
+ try {
259
+ rows = db.prepare('SELECT text, metadata FROM memory_records').all();
260
+ } catch {
261
+ try {
262
+ // Legacy/minimal schemas can still establish the class exclusion. With no text column there
263
+ // is no observable noise payload, so every non-class row stays in the comparable population.
264
+ rows = db.prepare("SELECT '' AS text, metadata FROM memory_records").all();
265
+ } catch {
266
+ return undefined;
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ let mirrorableRows = 0;
273
+ let mirrorableQuarantinedRows = 0;
274
+ let excludedClassRows = 0;
275
+ let excludedNoiseRows = 0;
276
+ const quarantinedRows: QuarantineTierRow[] = [];
277
+ for (const value of rows) {
278
+ const row = value as {
279
+ id?: unknown;
280
+ skill_id?: unknown;
281
+ text?: unknown;
282
+ score?: unknown;
283
+ outcome?: unknown;
284
+ timestamp?: unknown;
285
+ metadata?: unknown;
286
+ };
287
+ let metadata: Record<string, unknown> = {};
288
+ if (typeof row.metadata === 'string') {
289
+ try {
290
+ const parsed = JSON.parse(row.metadata) as unknown;
291
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
292
+ metadata = parsed as Record<string, unknown>;
293
+ }
294
+ } catch { /* malformed metadata carries no class/quarantine claim */ }
295
+ }
296
+ const text = typeof row.text === 'string' ? row.text : '';
297
+ const lessonForm = metadata['lessonForm'] === 'class' ? 'class' as const : undefined;
298
+ const record = { pattern: text, ...(lessonForm === undefined ? {} : { lessonForm }) };
299
+ if (!isMirrorableRecord(record)) {
300
+ if (lessonForm === 'class') excludedClassRows += 1;
301
+ else excludedNoiseRows += 1;
302
+ continue;
303
+ }
304
+ mirrorableRows += 1;
305
+ if (metadata['qStatus'] === 'quarantined') {
306
+ mirrorableQuarantinedRows += 1;
307
+ if (typeof row.id !== 'string' || row.id === '') {
308
+ idsEstablished = false;
309
+ continue;
310
+ }
311
+ let derivedId: string | undefined;
312
+ if (typeof row.text === 'string'
313
+ && typeof row.score === 'number'
314
+ && typeof row.outcome === 'string'
315
+ && typeof row.timestamp === 'string') {
316
+ const stringMetadata = Object.fromEntries(
317
+ Object.entries(metadata).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
318
+ );
319
+ const record: MemoryRecord = {
320
+ id: row.id,
321
+ skillId: typeof row.skill_id === 'string' ? row.skill_id : '',
322
+ text: row.text,
323
+ score: row.score,
324
+ outcome: row.outcome,
325
+ timestamp: row.timestamp,
326
+ metadata: stringMetadata,
327
+ };
328
+ derivedId = patternRecordId(recordToPattern(record));
329
+ }
330
+ quarantinedRows.push({
331
+ id: row.id,
332
+ ...(derivedId === undefined ? {} : { patternRecordId: derivedId }),
333
+ qStatus: 'quarantined',
334
+ });
335
+ }
336
+ }
337
+ return {
338
+ mirrorableRows,
339
+ mirrorableQuarantinedRows,
340
+ excludedClassRows,
341
+ excludedNoiseRows,
342
+ ...(idsEstablished ? { quarantinedIdentities: quarantinedRows } : {}),
343
+ };
344
+ }
345
+
346
+ function vectorQuarantinedIds(db: ReadonlyCountDb): readonly QuarantineTierRow[] | undefined {
347
+ try {
348
+ const rows = db.prepare(`SELECT metadata FROM reasoning_patterns
349
+ WHERE task_type IN ('dz-teach', 'dz-learning')
350
+ AND json_valid(metadata)
351
+ AND json_extract(metadata, '$.qStatus') = 'quarantined'`).all() as Array<{ metadata?: unknown }>;
352
+ const rowsWithIds: QuarantineTierRow[] = [];
353
+ for (const row of rows) {
354
+ const metadata = rowMetadata({ metadata: typeof row.metadata === 'string' ? row.metadata : null });
355
+ if (typeof metadata['dzId'] !== 'string' || metadata['dzId'] === '') return undefined;
356
+ rowsWithIds.push({ dzId: metadata['dzId'], qStatus: 'quarantined' });
357
+ }
358
+ return rowsWithIds;
359
+ } catch {
360
+ return undefined;
361
+ }
77
362
  }
78
363
 
79
364
  /** One aggregate query on the healthy path; an unsupported metadata shape falls back to total-only. */
80
365
  function countSqliteRowsWithQuarantineReadonly(
81
366
  sqlitePath: string,
82
367
  table: 'memory_records' | 'reasoning_patterns',
83
- ): SqliteRowsWithQuarantine | undefined {
84
- try {
85
- const require = createRequire(import.meta.url);
86
- const Database = require('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
87
- const db = new Database(sqlitePath, { readonly: true });
368
+ options: StoreCountOptions,
369
+ requireModule: DatabaseRequire,
370
+ ): SqliteRowsWithQuarantine | 'unreadable' | 'busy' {
371
+ const { busyTimeoutMs, attempts } = normalizedOptions(options);
372
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
88
373
  try {
89
- db.pragma('busy_timeout = 100');
374
+ const Database = requireModule('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
375
+ const db = new Database(sqlitePath, { readonly: true });
90
376
  try {
91
- const sql = table === 'memory_records'
92
- ? `SELECT COALESCE(SUM(n), 0) AS cnt,
377
+ db.pragma(`busy_timeout = ${busyTimeoutMs}`);
378
+ try {
379
+ const sql = table === 'memory_records'
380
+ ? `SELECT COALESCE(SUM(n), 0) AS cnt,
93
381
  COALESCE(SUM(CASE WHEN q_status = 'quarantined' THEN n ELSE 0 END), 0) AS quarantined
94
382
  FROM (
95
383
  SELECT CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.qStatus') END AS q_status,
96
384
  COUNT(*) AS n
97
385
  FROM memory_records GROUP BY 1
98
386
  )`
99
- : `SELECT COUNT(*) AS cnt,
387
+ : `SELECT COUNT(*) AS cnt,
100
388
  COALESCE(SUM(CASE WHEN task_type IN ('dz-teach', 'dz-learning') THEN 1 ELSE 0 END), 0) AS lessons,
101
389
  COALESCE(SUM(CASE WHEN task_type IN ('dz-teach', 'dz-learning')
102
390
  AND json_valid(metadata)
103
391
  AND json_extract(metadata, '$.qStatus') = 'quarantined' THEN 1 ELSE 0 END), 0) AS quarantined
104
392
  FROM reasoning_patterns`;
105
- const row = db.prepare(sql).get() as { cnt?: unknown; quarantined?: unknown; lessons?: unknown };
106
- if (typeof row?.cnt !== 'number' || typeof row.quarantined !== 'number') return undefined;
107
- return {
108
- rows: row.cnt,
109
- quarantinedRows: row.quarantined,
110
- ...(typeof row.lessons === 'number' ? { lessonRows: row.lessons } : {}),
111
- };
112
- } catch {
113
- // Запасной путь: общий объём берём всегда, а разложение по родам пробуем ОТДЕЛЬНО —
114
- // схема без `metadata`, но с `task_type` уроки различает, и терять это не за что.
115
- // Если не различает и её — lessonRows остаётся неизвестным, и показатель обязан сказать
116
- // «не читается» вместо мнимого совпадения.
117
- const row = db.prepare(`SELECT COUNT(*) AS cnt FROM ${table}`).get() as { cnt?: unknown };
118
- if (typeof row?.cnt !== 'number') return undefined;
119
- if (table !== 'reasoning_patterns') return { rows: row.cnt };
120
- try {
121
- const lesson = db.prepare(
122
- `SELECT COUNT(*) AS cnt FROM reasoning_patterns
393
+ const row = db.prepare(sql).get() as { cnt?: unknown; quarantined?: unknown; lessons?: unknown };
394
+ if (typeof row?.cnt !== 'number' || typeof row.quarantined !== 'number') return 'unreadable';
395
+ const lexical = table === 'memory_records' ? lexicalMirrorPopulation(db) : undefined;
396
+ const quarantinedRows = table === 'reasoning_patterns' ? vectorQuarantinedIds(db) : undefined;
397
+ return {
398
+ rows: row.cnt,
399
+ quarantinedRows: row.quarantined,
400
+ ...(lexical ?? {}),
401
+ ...(quarantinedRows === undefined ? {} : { quarantinedIdentities: quarantinedRows }),
402
+ ...(typeof row.lessons === 'number' ? { lessonRows: row.lessons } : {}),
403
+ };
404
+ } catch (error) {
405
+ if (isBusy(error) || isInitializing(error)) throw error;
406
+ // Запасной путь: общий объём берём всегда, а разложение по родам пробуем ОТДЕЛЬНО —
407
+ // схема без `metadata`, но с `task_type` уроки различает, и терять это не за что.
408
+ // Если не различает и её — lessonRows остаётся неизвестным, и показатель обязан сказать
409
+ // «не читается» вместо мнимого совпадения.
410
+ const row = db.prepare(`SELECT COUNT(*) AS cnt FROM ${table}`).get() as { cnt?: unknown };
411
+ if (typeof row?.cnt !== 'number') return 'unreadable';
412
+ if (table !== 'reasoning_patterns') return { rows: row.cnt };
413
+ try {
414
+ const lesson = db.prepare(
415
+ `SELECT COUNT(*) AS cnt FROM reasoning_patterns
123
416
  WHERE task_type IN ('dz-teach', 'dz-learning')`,
124
- ).get() as { cnt?: unknown };
125
- return typeof lesson?.cnt === 'number'
126
- ? { rows: row.cnt, lessonRows: lesson.cnt }
127
- : { rows: row.cnt };
128
- } catch {
129
- return { rows: row.cnt };
417
+ ).get() as { cnt?: unknown };
418
+ return typeof lesson?.cnt === 'number'
419
+ ? { rows: row.cnt, lessonRows: lesson.cnt }
420
+ : { rows: row.cnt };
421
+ } catch (lessonError) {
422
+ if (isBusy(lessonError) || isInitializing(lessonError)) throw lessonError;
423
+ return { rows: row.cnt };
424
+ }
130
425
  }
426
+ } finally {
427
+ db.close();
428
+ }
429
+ } catch (error) {
430
+ if (isBusy(error)) {
431
+ if (attempt + 1 < attempts) continue;
432
+ return 'busy';
131
433
  }
132
- } finally {
133
- db.close();
434
+ if (isInitializing(error)) {
435
+ if (attempt + 1 < attempts) {
436
+ waitForInitializationRetry(busyTimeoutMs);
437
+ continue;
438
+ }
439
+ return 'unreadable';
440
+ }
441
+ return 'unreadable';
134
442
  }
135
- } catch {
136
- return undefined;
137
443
  }
444
+ return 'busy';
138
445
  }
139
446
 
140
447
  /**
@@ -142,7 +449,11 @@ function countSqliteRowsWithQuarantineReadonly(
142
449
  * Missing tiers count as zero; an existing tier that cannot be counted is
143
450
  * explicitly `unreadable`.
144
451
  */
145
- export function countLearningStoreRowsReadonly(projectRoot: string): LearningStoreRowCounts {
452
+ export function countLearningStoreRowsReadonly(
453
+ projectRoot: string,
454
+ options: StoreCountOptions = {},
455
+ requireModule: DatabaseRequire = createRequire(import.meta.url),
456
+ ): LearningStoreRowCounts {
146
457
  const root = resolve(projectRoot);
147
458
  const lexicalPath = join(root, '.dz', 'memory', 'patterns.sqlite');
148
459
  const jsonlPath = join(root, '.dz', 'patterns.jsonl');
@@ -150,23 +461,38 @@ export function countLearningStoreRowsReadonly(projectRoot: string): LearningSto
150
461
  const vectorExists = existsSync(vectorPath);
151
462
  const lexicalSource = existsSync(lexicalPath) ? 'sqlite' : 'jsonl';
152
463
  const lexicalSqlite = lexicalSource === 'sqlite'
153
- ? countSqliteRowsWithQuarantineReadonly(lexicalPath, 'memory_records')
464
+ ? countSqliteRowsWithQuarantineReadonly(lexicalPath, 'memory_records', options, requireModule)
154
465
  : undefined;
155
- let lexicalRows: number | 'unreadable';
466
+ let lexicalRows: StoreRowCount;
156
467
  if (lexicalSource === 'sqlite') {
157
- lexicalRows = lexicalSqlite?.rows ?? 'unreadable';
468
+ lexicalRows = typeof lexicalSqlite === 'object' ? lexicalSqlite.rows : lexicalSqlite ?? 'unreadable';
158
469
  } else {
159
470
  lexicalRows = countJsonlRowsReadonly(jsonlPath);
160
471
  }
161
472
  const vectorSqlite = vectorExists
162
- ? countSqliteRowsWithQuarantineReadonly(vectorPath, 'reasoning_patterns')
473
+ ? countSqliteRowsWithQuarantineReadonly(vectorPath, 'reasoning_patterns', options, requireModule)
163
474
  : undefined;
164
475
  const ignoredJsonl = lexicalSource === 'sqlite' && existsSync(jsonlPath)
165
476
  ? countJsonlRowsReadonly(jsonlPath)
166
477
  : undefined;
478
+ const quarantineParity = typeof lexicalSqlite === 'object'
479
+ && lexicalSqlite.quarantinedIdentities !== undefined
480
+ && typeof vectorSqlite === 'object'
481
+ && vectorSqlite.quarantinedIdentities !== undefined
482
+ ? quarantineTierParity(
483
+ lexicalSqlite.quarantinedIdentities,
484
+ vectorSqlite.quarantinedIdentities,
485
+ )
486
+ : undefined;
167
487
  return {
168
488
  lexicalRows,
169
- ...(lexicalSqlite?.quarantinedRows === undefined ? {} : {
489
+ ...(typeof lexicalSqlite !== 'object' || lexicalSqlite.mirrorableRows === undefined ? {} : {
490
+ lexicalMirrorableRows: lexicalSqlite.mirrorableRows,
491
+ lexicalMirrorableQuarantinedRows: lexicalSqlite.mirrorableQuarantinedRows ?? 0,
492
+ lexicalExcludedClassRows: lexicalSqlite.excludedClassRows ?? 0,
493
+ lexicalExcludedNoiseRows: lexicalSqlite.excludedNoiseRows ?? 0,
494
+ }),
495
+ ...(typeof lexicalSqlite !== 'object' || lexicalSqlite.quarantinedRows === undefined ? {} : {
170
496
  lexicalQuarantinedRows: lexicalSqlite.quarantinedRows,
171
497
  }),
172
498
  lexicalSource,
@@ -175,11 +501,15 @@ export function countLearningStoreRowsReadonly(projectRoot: string): LearningSto
175
501
  lexicalIgnoredRows: ignoredJsonl,
176
502
  lexicalIgnoredSourcePath: jsonlPath,
177
503
  }),
178
- vectorRows: vectorExists ? (vectorSqlite?.rows ?? 'unreadable') : 0,
179
- ...(vectorSqlite?.lessonRows === undefined ? {} : { vectorLessonRows: vectorSqlite.lessonRows }),
180
- ...(vectorSqlite?.quarantinedRows === undefined ? {} : {
504
+ vectorRows: vectorExists
505
+ ? (typeof vectorSqlite === 'object' ? vectorSqlite.rows : vectorSqlite ?? 'unreadable')
506
+ : 0,
507
+ ...(typeof vectorSqlite !== 'object' || vectorSqlite.lessonRows === undefined
508
+ ? {} : { vectorLessonRows: vectorSqlite.lessonRows }),
509
+ ...(typeof vectorSqlite !== 'object' || vectorSqlite.quarantinedRows === undefined ? {} : {
181
510
  vectorQuarantinedRows: vectorSqlite.quarantinedRows,
182
511
  }),
183
512
  ...(vectorExists ? { vectorSourcePath: vectorPath } : {}),
513
+ ...(quarantineParity === undefined ? {} : { quarantineTierParity: quarantineParity }),
184
514
  };
185
515
  }
@@ -92,9 +92,9 @@ export interface StoreMarkWriteOptions {
92
92
  readonly expectedPreviousLexicalSource?: StoreCountSource | 'unknown';
93
93
  }
94
94
 
95
- export type StoreRowCount = number | 'unreadable';
95
+ export type StoreRowCount = number | 'unreadable' | 'busy';
96
96
 
97
- export type StoreHealthVerdict = 'ok' | 'collapsed' | 'cold-start-over-existing' | 'source-changed' | 'unreadable' | 'no-mark';
97
+ export type StoreHealthVerdict = 'ok' | 'collapsed' | 'cold-start-over-existing' | 'source-changed' | 'unreadable' | 'busy' | 'no-mark';
98
98
 
99
99
  export interface StoreHealth {
100
100
  readonly verdict: StoreHealthVerdict;
@@ -354,6 +354,12 @@ export function checkStoreHealth(input: StoreHealthInput): StoreHealth {
354
354
  reason: `${unreadable.join(' and ')} store${unreadable.length > 1 ? 's are' : ' is'} unreadable`,
355
355
  };
356
356
  }
357
+ if (lexicalRows === 'busy' || vectorRows === 'busy') {
358
+ return {
359
+ verdict: 'busy',
360
+ reason: 'lexical store busy — another writer holds it; health not measured this run',
361
+ };
362
+ }
357
363
  if (mark === undefined) return { verdict: 'no-mark', reason: 'no store mark exists for this project' };
358
364
 
359
365
  const lexicalCount = lexicalRows as number;