@dzhechkov/harness-core 0.8.30 → 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 (129) hide show
  1. package/.dz-manifest.json +233 -93
  2. package/README.md +269 -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/repository-origin.d.ts +1 -1
  63. package/dist/repository-origin.d.ts.map +1 -1
  64. package/dist/repository-origin.js +7 -1
  65. package/dist/repository-origin.js.map +1 -1
  66. package/dist/round-exec.d.ts +41 -0
  67. package/dist/round-exec.d.ts.map +1 -0
  68. package/dist/round-exec.js +45 -0
  69. package/dist/round-exec.js.map +1 -0
  70. package/dist/round.d.ts +109 -0
  71. package/dist/round.d.ts.map +1 -0
  72. package/dist/round.js +148 -0
  73. package/dist/round.js.map +1 -0
  74. package/dist/run-records.d.ts +8 -0
  75. package/dist/run-records.d.ts.map +1 -1
  76. package/dist/run-records.js +69 -3
  77. package/dist/run-records.js.map +1 -1
  78. package/dist/setup.d.ts +8 -0
  79. package/dist/setup.d.ts.map +1 -1
  80. package/dist/setup.js +166 -3
  81. package/dist/setup.js.map +1 -1
  82. package/dist/sqlite-read-helpers.d.ts +60 -0
  83. package/dist/sqlite-read-helpers.d.ts.map +1 -0
  84. package/dist/sqlite-read-helpers.js +118 -0
  85. package/dist/sqlite-read-helpers.js.map +1 -0
  86. package/dist/statusline.d.ts +20 -3
  87. package/dist/statusline.d.ts.map +1 -1
  88. package/dist/statusline.js +40 -7
  89. package/dist/statusline.js.map +1 -1
  90. package/dist/store-counts.d.ts +30 -0
  91. package/dist/store-counts.d.ts.map +1 -1
  92. package/dist/store-counts.js +189 -0
  93. package/dist/store-counts.js.map +1 -1
  94. package/dist/usage.d.ts +55 -0
  95. package/dist/usage.d.ts.map +1 -1
  96. package/dist/usage.js +184 -33
  97. package/dist/usage.js.map +1 -1
  98. package/dist/vector-tier.d.ts +18 -1
  99. package/dist/vector-tier.d.ts.map +1 -1
  100. package/dist/vector-tier.js +26 -2
  101. package/dist/vector-tier.js.map +1 -1
  102. package/package.json +6 -6
  103. package/sbom.json +442 -92
  104. package/src/agentdb-index.ts +315 -32
  105. package/src/agentdb-reindex-marker.ts +330 -0
  106. package/src/agentdb-snapshot-rotation.ts +393 -0
  107. package/src/agentdb-snapshot.ts +222 -0
  108. package/src/apply-leg.ts +1046 -0
  109. package/src/book-kb.ts +64 -1
  110. package/src/brain.ts +11 -2
  111. package/src/cadence.ts +87 -2
  112. package/src/guard.ts +46 -0
  113. package/src/index.ts +58 -5
  114. package/src/integration-probe-worker.ts +4 -6
  115. package/src/integrations-verify.ts +3 -1
  116. package/src/managed-hooks.ts +32 -7
  117. package/src/mutation-gate.ts +87 -8
  118. package/src/operations.ts +89 -0
  119. package/src/patterns.ts +23 -4
  120. package/src/repository-origin.ts +7 -1
  121. package/src/round-exec.ts +85 -0
  122. package/src/round.ts +228 -0
  123. package/src/run-records.ts +77 -2
  124. package/src/setup.ts +198 -3
  125. package/src/sqlite-read-helpers.ts +128 -0
  126. package/src/statusline.ts +57 -9
  127. package/src/store-counts.ts +245 -1
  128. package/src/usage.ts +243 -29
  129. package/src/vector-tier.ts +45 -4
package/src/usage.ts CHANGED
@@ -28,7 +28,7 @@
28
28
  * @packageDocumentation
29
29
  */
30
30
 
31
- import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
31
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
32
32
  import { homedir } from 'node:os';
33
33
  import { join } from 'node:path';
34
34
 
@@ -200,6 +200,173 @@ export interface UsageEstimate {
200
200
  readonly estimatesNotForRouting?: { readonly sessionPct: number | null; readonly weeklyPct: number | null };
201
201
  }
202
202
 
203
+ export interface SpendReport {
204
+ readonly days: ReadonlyArray<{
205
+ readonly date: string;
206
+ readonly weightedTokens: number;
207
+ readonly input: number;
208
+ readonly output: number;
209
+ readonly cacheRead: number;
210
+ readonly cacheWrite: number;
211
+ readonly events: number;
212
+ }>;
213
+ readonly total7d: {
214
+ readonly weightedTokens: number;
215
+ readonly input: number;
216
+ readonly output: number;
217
+ readonly cacheRead: number;
218
+ readonly cacheWrite: number;
219
+ readonly events: number;
220
+ };
221
+ readonly byModel: Readonly<Record<string, {
222
+ readonly weightedTokens: number;
223
+ readonly sharePct: number;
224
+ }>>;
225
+ /**
226
+ * Per-day model breakdown — same events, same `unknown` fallback as {@link byModel}, just not
227
+ * yet collapsed across the window. One entry per day in `days` (same order), so a caller can
228
+ * read "today by model" as `daysByModel.at(-1)`. FR-1/FR-2: `Σ daysByModel[i].models ===
229
+ * days[i].weightedTokens` for every day, checked by {@link spendInvariantViolations}.
230
+ */
231
+ readonly daysByModel: ReadonlyArray<{
232
+ readonly date: string;
233
+ readonly models: Readonly<Record<string, number>>;
234
+ }>;
235
+ }
236
+
237
+ type ParsedSpendEvent = {
238
+ readonly ts: number;
239
+ readonly model: ClaudeUsageModel | null;
240
+ readonly raw: RawTokenMix;
241
+ readonly weightedTokens: number;
242
+ };
243
+
244
+ const DAY_MS = 24 * HOUR_MS;
245
+
246
+ function emptySpendTotal(): SpendReport['total7d'] {
247
+ return { weightedTokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, events: 0 };
248
+ }
249
+
250
+ /** Aggregate already-parsed transcript events into UTC calendar days. Pure: no fs, env, or clock reads. */
251
+ export function spendReport(
252
+ events: readonly ParsedSpendEvent[],
253
+ options: { readonly nowMs: number; readonly days: number },
254
+ ): SpendReport {
255
+ if (!Number.isFinite(options.nowMs) || !Number.isInteger(options.days) || options.days <= 0) {
256
+ return { days: [], total7d: emptySpendTotal(), byModel: {}, daysByModel: [] };
257
+ }
258
+ const now = new Date(options.nowMs);
259
+ const todayStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
260
+ const startMs = todayStart - (options.days - 1) * DAY_MS;
261
+ const days = Array.from({ length: options.days }, (_, index) => ({
262
+ date: new Date(startMs + index * DAY_MS).toISOString().slice(0, 10),
263
+ ...emptySpendTotal(),
264
+ }));
265
+ const daysByDate = new Map(days.map((day) => [day.date, day]));
266
+ const modelTotals = new Map<string, number>();
267
+ // Per-day model totals (FR-1) — same fallback-to-'unknown' rule as `modelTotals`, kept in a
268
+ // separate Map-of-Maps so the two aggregates stay independently derivable from the SAME loop
269
+ // (one pass over events, not two) and therefore can never drift apart by construction.
270
+ const dayModelTotals = new Map<string, Map<string, number>>(days.map((day) => [day.date, new Map()]));
271
+
272
+ for (const event of events) {
273
+ if (!Number.isFinite(event.ts) || event.ts < startMs || event.ts > options.nowMs) continue;
274
+ const date = new Date(event.ts).toISOString().slice(0, 10);
275
+ const day = daysByDate.get(date);
276
+ if (!day) continue;
277
+ day.weightedTokens += event.weightedTokens;
278
+ day.input += event.raw.input;
279
+ day.output += event.raw.output;
280
+ day.cacheRead += event.raw.cacheRead;
281
+ day.cacheWrite += event.raw.cacheWrite;
282
+ day.events += 1;
283
+ const model = event.model ?? 'unknown';
284
+ modelTotals.set(model, (modelTotals.get(model) ?? 0) + event.weightedTokens);
285
+ const dayModels = dayModelTotals.get(date);
286
+ if (dayModels) dayModels.set(model, (dayModels.get(model) ?? 0) + event.weightedTokens);
287
+ }
288
+
289
+ const total7d = days.reduce<SpendReport['total7d']>((total, day) => ({
290
+ weightedTokens: total.weightedTokens + day.weightedTokens,
291
+ input: total.input + day.input,
292
+ output: total.output + day.output,
293
+ cacheRead: total.cacheRead + day.cacheRead,
294
+ cacheWrite: total.cacheWrite + day.cacheWrite,
295
+ events: total.events + day.events,
296
+ }), emptySpendTotal());
297
+ const byModel: Record<string, { weightedTokens: number; sharePct: number }> = {};
298
+ for (const [model, weightedTokens] of [...modelTotals].sort(([a], [b]) => a.localeCompare(b))) {
299
+ byModel[model] = {
300
+ weightedTokens,
301
+ sharePct: total7d.weightedTokens > 0
302
+ ? Math.round((weightedTokens / total7d.weightedTokens) * 1000) / 10
303
+ : 0,
304
+ };
305
+ }
306
+ const daysByModel = days.map((day) => {
307
+ const models: Record<string, number> = {};
308
+ const totals = dayModelTotals.get(day.date);
309
+ if (totals) {
310
+ for (const [model, weightedTokens] of [...totals].sort(([a], [b]) => a.localeCompare(b))) {
311
+ models[model] = weightedTokens;
312
+ }
313
+ }
314
+ return { date: day.date, models };
315
+ });
316
+ return { days, total7d, byModel, daysByModel };
317
+ }
318
+
319
+ /**
320
+ * Fix-round-1 (Codex review, LOW #4): a mismatch is judged on a tolerance that SCALES with the
321
+ * magnitude being compared — `max(1e-6, 1e-9·max(|a|,|b|))`. A fixed absolute `1e-6` is too tight
322
+ * for large sums, where float addition accumulates noise proportional to magnitude, and it is
323
+ * blind to corruption: `Math.abs(NaN - x) > 1e-6` is `false`, so a NaN/Infinity sum used to read as
324
+ * "invariant holds" instead of the loud failure a corrupted report deserves.
325
+ */
326
+ function invariantTolerance(a: number, b: number): number {
327
+ return Math.max(1e-6, 1e-9 * Math.max(Math.abs(a), Math.abs(b)));
328
+ }
329
+
330
+ /** `true` when `a`/`b` cannot be meaningfully compared (either is `NaN` or `±Infinity`). */
331
+ function isNonFiniteMismatch(a: number, b: number): boolean {
332
+ return !Number.isFinite(a) || !Number.isFinite(b);
333
+ }
334
+
335
+ /**
336
+ * FR-2 invariant checker: for every day, `Σ daysByModel[day].models === days[day].weightedTokens`;
337
+ * and `Σ byModel[*].weightedTokens === total7d.weightedTokens`. Pure, no fs/clock. Returns a
338
+ * human-readable violation per mismatch; an empty array means the invariant holds. The tolerance is
339
+ * relative ({@link invariantTolerance}), and a `NaN`/`Infinity` on either side of a comparison is
340
+ * ALWAYS a violation ({@link isNonFiniteMismatch}) — never a silent pass.
341
+ */
342
+ export function spendInvariantViolations(report: SpendReport): string[] {
343
+ const violations: string[] = [];
344
+ const modelsByDate = new Map(report.daysByModel.map((entry) => [entry.date, entry.models]));
345
+ for (const day of report.days) {
346
+ const models = modelsByDate.get(day.date) ?? {};
347
+ const sum = Object.values(models).reduce((a, b) => a + b, 0);
348
+ const nonFinite = isNonFiniteMismatch(sum, day.weightedTokens);
349
+ if (nonFinite || Math.abs(sum - day.weightedTokens) > invariantTolerance(sum, day.weightedTokens)) {
350
+ violations.push(
351
+ `day ${day.date}: Σ daysByModel=${sum} !== days.weightedTokens=${day.weightedTokens}` +
352
+ (nonFinite ? ' (non-finite sum — NaN/Infinity corrupts the invariant)' : ''),
353
+ );
354
+ }
355
+ }
356
+ const byModelSum = Object.values(report.byModel).reduce((a, row) => a + row.weightedTokens, 0);
357
+ const byModelNonFinite = isNonFiniteMismatch(byModelSum, report.total7d.weightedTokens);
358
+ if (
359
+ byModelNonFinite ||
360
+ Math.abs(byModelSum - report.total7d.weightedTokens) > invariantTolerance(byModelSum, report.total7d.weightedTokens)
361
+ ) {
362
+ violations.push(
363
+ `Σ byModel=${byModelSum} !== total7d.weightedTokens=${report.total7d.weightedTokens}` +
364
+ (byModelNonFinite ? ' (non-finite sum — NaN/Infinity corrupts the invariant)' : ''),
365
+ );
366
+ }
367
+ return violations;
368
+ }
369
+
203
370
  export interface UsageCalibrationInput {
204
371
  readonly sessionPct?: unknown;
205
372
  readonly weeklyPct?: unknown;
@@ -359,23 +526,44 @@ export function normalizeClaudeUsageModelKey(raw: unknown): ClaudeUsageModel | n
359
526
  return (CLAUDE_USAGE_MODELS as readonly string[]).includes(key) ? (key as ClaudeUsageModel) : null;
360
527
  }
361
528
 
362
- /** Recursive .jsonl collector under a subagents tree bounded depth, lstat-guarded. */
363
- function walkTranscriptTree(dir: string, depthLeft: number, out: Array<{ path: string; mtimeMs: number }>): void {
529
+ /** Resolve one directory without revisiting a real path. Broken links and non-directories are skipped. */
530
+ function resolveDirectoryOnce(dir: string, visited: Set<string>): string | null {
531
+ try {
532
+ const entry = lstatSync(dir);
533
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) return null;
534
+ const realDir = realpathSync(dir);
535
+ if (entry.isSymbolicLink() && !statSync(realDir).isDirectory()) return null;
536
+ if (visited.has(realDir)) return null;
537
+ visited.add(realDir);
538
+ return realDir;
539
+ } catch {
540
+ return null;
541
+ }
542
+ }
543
+
544
+ /** Recursive .jsonl collector under a subagents tree — bounded depth and real-path deduplicated. */
545
+ function walkTranscriptTree(
546
+ dir: string,
547
+ depthLeft: number,
548
+ out: Array<{ path: string; mtimeMs: number }>,
549
+ visited: Set<string>,
550
+ ): void {
364
551
  if (depthLeft <= 0) return;
552
+ const realDir = resolveDirectoryOnce(dir, visited);
553
+ if (realDir === null) return;
365
554
  let entries: string[];
366
555
  try {
367
- if (!lstatSync(dir).isDirectory()) return; // symlinked dir ⇒ not walked
368
- entries = readdirSync(dir);
556
+ entries = readdirSync(realDir);
369
557
  } catch {
370
558
  return;
371
559
  }
372
560
  for (const e of entries) {
373
- const p = join(dir, e);
561
+ const p = join(realDir, e);
374
562
  if (e.endsWith('.jsonl')) {
375
563
  const m = regularFileMtime(p);
376
564
  if (m !== null) out.push({ path: p, mtimeMs: m });
377
565
  } else {
378
- walkTranscriptTree(p, depthLeft - 1, out);
566
+ walkTranscriptTree(p, depthLeft - 1, out, visited);
379
567
  }
380
568
  }
381
569
  }
@@ -476,6 +664,7 @@ interface Sample {
476
664
  readonly tokens: number;
477
665
  readonly key: string; // dedup key: message.id + ':' + requestId
478
666
  readonly model: ClaudeUsageModel | null;
667
+ readonly raw: RawTokenMix;
479
668
  }
480
669
 
481
670
  /**
@@ -498,6 +687,7 @@ function regularFileMtime(p: string): number | null {
498
687
 
499
688
  function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: number }> {
500
689
  const out: Array<{ path: string; mtimeMs: number }> = [];
690
+ const visitedDirectories = new Set<string>();
501
691
  let dirs: string[];
502
692
  try {
503
693
  if (!existsSync(root)) return out;
@@ -507,12 +697,11 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
507
697
  }
508
698
  for (const d of dirs) {
509
699
  const projDir = join(root, d);
700
+ const realProjDir = resolveDirectoryOnce(projDir, visitedDirectories);
701
+ if (realProjDir === null) continue;
510
702
  let files: string[];
511
703
  try {
512
- // lstat, not stat: a symlinked project directory would otherwise be walked (Codex #3).
513
- const st = lstatSync(projDir);
514
- if (!st.isDirectory()) continue;
515
- files = readdirSync(projDir);
704
+ files = readdirSync(realProjDir);
516
705
  } catch {
517
706
  continue;
518
707
  }
@@ -523,12 +712,12 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
523
712
  // `subagents/workflows/wf_*/agent-*.jsonl` — one level deeper than the first fix reached —
524
713
  // and that blind spot alone hid 283.62M weighted tokens across 551 files (MEASURED
525
714
  // 2026-08-24, 7-day window, this machine). Depth 4 covers today's deepest layout plus one
526
- // future level; lstat at EVERY step keeps symlinked directories unwalked.
715
+ // future level; each real directory is visited once, so symlink aliases and loops stay bounded.
527
716
  if (!f.endsWith('.jsonl')) {
528
- walkTranscriptTree(join(projDir, f, 'subagents'), 4, out);
717
+ walkTranscriptTree(join(realProjDir, f, 'subagents'), 4, out, visitedDirectories);
529
718
  continue;
530
719
  }
531
- const p = join(projDir, f);
720
+ const p = join(realProjDir, f);
532
721
  const mt = regularFileMtime(p);
533
722
  if (mt !== null) out.push({ path: p, mtimeMs: mt });
534
723
  }
@@ -596,6 +785,7 @@ function extractSamples(path: string, scanCutoff: number, into: Sample[], seen:
596
785
  // Prefer the TTL breakdown when present (5m 1.25x / 1h 2x); fall back to the flat field at the
597
786
  // 5m rate. Reading only the flat field scored a nested-only record as ZERO.
598
787
  // ONE estimator, shared with the per-stage cost ledger (feature `cost-ledger`, ADR-002).
788
+ const raw = rawTokenMixOf(usage);
599
789
  const tokens = weightedTokensOf(usage);
600
790
  if (tokens <= 0) continue;
601
791
  // Dedup: streamed assistant messages repeat their usage object across chunks.
@@ -611,8 +801,45 @@ function extractSamples(path: string, scanCutoff: number, into: Sample[], seen:
611
801
  : `anon:${ts}:${n(usage.input_tokens)}:${n(usage.cache_creation_input_tokens)}:${n(usage.cache_read_input_tokens)}:${n(usage.output_tokens)}:${String(rec.message?.model ?? rec.model ?? '')}`;
612
802
  if (seen.has(key)) continue;
613
803
  seen.add(key);
614
- into.push({ ts, tokens, key, model: normalizeClaudeUsageModel(rec.message?.model ?? rec.model) });
804
+ into.push({ ts, tokens, key, model: normalizeClaudeUsageModel(rec.message?.model ?? rec.model), raw });
805
+ }
806
+ }
807
+
808
+ function scanSamples(scanCutoff: number): { samples: Sample[]; scanFileCount: number } {
809
+ const samples: Sample[] = [];
810
+ const seen = new Set<string>();
811
+ let scanFileCount = 0;
812
+ try {
813
+ const files = listTranscriptFiles(claudeProjectsRoot());
814
+ for (const file of files) {
815
+ if (file.mtimeMs < scanCutoff) continue;
816
+ scanFileCount += 1;
817
+ extractSamples(file.path, scanCutoff, samples, seen);
818
+ }
819
+ } catch {
820
+ // Best-effort transcript reads collapse to an empty report.
615
821
  }
822
+ return { samples, scanFileCount };
823
+ }
824
+
825
+ /** Read local transcript events once, then delegate all aggregation to the pure spendReport. */
826
+ export function computeSpendReport(now?: number, days = 7): SpendReport {
827
+ const MAX_TIME = 8.64e15;
828
+ const nowMs = now === undefined || !Number.isFinite(now) || Math.abs(now) > MAX_TIME ? Date.now() : now;
829
+ const today = new Date(nowMs);
830
+ const todayStart = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
831
+ const validDays = Number.isInteger(days) && days > 0 ? days : 7;
832
+ const scanCutoff = todayStart - (validDays - 1) * DAY_MS - MTIME_SLACK_MS;
833
+ const { samples } = scanSamples(scanCutoff);
834
+ return spendReport(
835
+ samples.map((sample) => ({
836
+ ts: sample.ts,
837
+ model: sample.model,
838
+ raw: sample.raw,
839
+ weightedTokens: sample.tokens,
840
+ })),
841
+ { nowMs, days: validDays },
842
+ );
616
843
  }
617
844
 
618
845
  function activeSessionBlock(
@@ -686,20 +913,7 @@ export function computeUsage(projectRoot: string, now?: number): UsageEstimate {
686
913
  const weeklyScanCutoff = weeklyWindow?.startedAtMs ?? nowMs;
687
914
  const scanCutoff = Math.min(sessionScanCutoff, weeklyScanCutoff) - MTIME_SLACK_MS;
688
915
 
689
- const samples: Sample[] = [];
690
- const seen = new Set<string>();
691
- let scanFileCount = 0;
692
- try {
693
- const files = listTranscriptFiles(claudeProjectsRoot());
694
- for (const f of files) {
695
- // mtime prefilter: a file last written before every relevant cutoff cannot contribute.
696
- if (f.mtimeMs < scanCutoff) continue;
697
- scanFileCount += 1;
698
- extractSamples(f.path, scanCutoff, samples, seen);
699
- }
700
- } catch {
701
- // total scan failure ⇒ fall through with empty samples (nulls), never throw
702
- }
916
+ const { samples, scanFileCount } = scanSamples(scanCutoff);
703
917
 
704
918
  let weeklyTokens = 0;
705
919
  const weeklyTokensByModel: Partial<Record<ClaudeUsageModel, number>> = {};
@@ -489,13 +489,39 @@ export function isVectorNoise(text: string): boolean {
489
489
  return isNoiseInsight(text) || TOOL_TELEMETRY_RE.test(text);
490
490
  }
491
491
 
492
+ /** One source of truth for records admitted to the vector mirror and its lexical comparison set. */
493
+ export function isMirrorableRecord(record: Pick<PatternRecord, 'pattern' | 'lessonForm'>): boolean {
494
+ return record.lessonForm !== 'class' && !isVectorNoise(record.pattern);
495
+ }
496
+
497
+ export interface MirrorQuarantineMetadata {
498
+ readonly qStatus?: 'quarantined';
499
+ readonly quarantinedAt?: string;
500
+ }
501
+
502
+ /** Pure lexical-record → vector-metadata projection used by every learned-pattern mirror writer. */
503
+ export function mirrorQuarantineOf(
504
+ record: Pick<PatternRecord, 'quarantined' | 'ts'> | Pick<MemoryRecord, 'metadata' | 'timestamp'>,
505
+ ): MirrorQuarantineMetadata {
506
+ if ('timestamp' in record) {
507
+ const state = readQuarantineState(record as MemoryRecord);
508
+ return state.quarantined
509
+ ? { qStatus: 'quarantined', ...(state.quarantinedAt === undefined ? {} : { quarantinedAt: state.quarantinedAt }) }
510
+ : {};
511
+ }
512
+ return record.quarantined === true
513
+ ? { qStatus: 'quarantined', quarantinedAt: record.ts }
514
+ : {};
515
+ }
516
+
492
517
  /**
493
518
  * ACL: taught {@link PatternRecord} → {@link VectorEntry}. Returns `undefined` for noise (the
494
519
  * ingest gate — I-6). Score is the record's REAL reward, never a fabricated 1.0.
495
520
  */
496
521
  export function patternVectorEntry(p: PatternRecord, source = 'dz-teach', opts: { quarantined?: boolean } = {}): VectorEntry | undefined {
497
- if (p.lessonForm === 'class' || isVectorNoise(p.pattern)) return undefined;
522
+ if (!isMirrorableRecord(p)) return undefined;
498
523
  const dzId = patternRecordId(p);
524
+ const quarantine = mirrorQuarantineOf(opts.quarantined === true ? { ...p, quarantined: true } : p);
499
525
  return {
500
526
  dzId,
501
527
  text: p.pattern,
@@ -509,7 +535,7 @@ export function patternVectorEntry(p: PatternRecord, source = 'dz-teach', opts:
509
535
  ...(p.lessonForm !== undefined && p.lessonPairId !== undefined
510
536
  ? { lessonForm: p.lessonForm, lessonPairId: p.lessonPairId }
511
537
  : {}),
512
- ...(opts.quarantined === true ? { qStatus: 'quarantined' } : {}),
538
+ ...quarantine,
513
539
  },
514
540
  };
515
541
  }
@@ -547,6 +573,7 @@ export function memoryRecordVectorEntry(r: MemoryRecord): VectorEntry | undefine
547
573
  ...(r.metadata?.['lessonForm'] === 'specific' && typeof r.metadata?.['lessonPairId'] === 'string'
548
574
  ? { lessonForm: 'specific', lessonPairId: r.metadata['lessonPairId'] }
549
575
  : {}),
576
+ ...mirrorQuarantineOf(r),
550
577
  },
551
578
  uses: state.uses,
552
579
  avgReward: state.avgReward,
@@ -606,7 +633,16 @@ export type MirrorWriterState =
606
633
  /** The config is readable and simply does not enable a mirror. */
607
634
  | 'not-enabled'
608
635
  /** The config explicitly turns the vector tier off. */
609
- | 'engine-off';
636
+ | 'engine-off'
637
+ /**
638
+ * `.dz/config.json` has a TOP-LEVEL `backend` key (`{"backend":"agentdb"}`) instead of the real
639
+ * shape (`{"memory":{"backend":"agentdb"}}`) — issue #10 defect 6, AM-6 (feature
640
+ * `setup-installs-apply-leg`). `dz setup` never emits this shape (it always nests under
641
+ * `memory`), so this is a hand-written or foreign-tool-written config; distinguished from
642
+ * `not-enabled` because the reader typed the RIGHT intent in the WRONG place, and "no mirror
643
+ * configured" sends them to add a setting that is already there, just misplaced.
644
+ */
645
+ | 'legacy-shape';
610
646
 
611
647
  /**
612
648
  * The mirror writer's state AND its real cause.
@@ -620,7 +656,7 @@ export type MirrorWriterState =
620
656
  export function mirrorWriterReason(projectRoot: string): { enabled: boolean; state: MirrorWriterState } {
621
657
  const path = join(projectRoot, '.dz', 'config.json');
622
658
  if (!existsSync(path)) return { enabled: false, state: 'no-config' };
623
- let cfg: { memory?: { backend?: string; vector?: { engine?: string } } };
659
+ let cfg: { backend?: string; memory?: { backend?: string; vector?: { engine?: string } } };
624
660
  try {
625
661
  cfg = JSON.parse(readFileSync(path, 'utf-8')) as typeof cfg;
626
662
  } catch {
@@ -636,6 +672,10 @@ export function mirrorWriterReason(projectRoot: string): { enabled: boolean; sta
636
672
  if (engine === 'off') return { enabled: false, state: 'engine-off' };
637
673
  if (cfg.memory?.backend === 'agentdb') return { enabled: true, state: 'on' };
638
674
  if (engine === 'agentdb' || engine === 'rvf') return { enabled: true, state: 'on' };
675
+ // Issue #10 defect 6 (AM-6): a TOP-LEVEL `backend` key is a real, readable intent to enable
676
+ // agentdb that this function used to silently ignore (it only ever looked under `memory`) —
677
+ // reported here as a NAMED cause, never folded into the generic `not-enabled` shrug.
678
+ if (cfg.backend === 'agentdb') return { enabled: false, state: 'legacy-shape' };
639
679
  return { enabled: false, state: 'not-enabled' };
640
680
  }
641
681
 
@@ -650,6 +690,7 @@ export function mirrorWriterExplanation(state: MirrorWriterState): string {
650
690
  case 'config-unreadable': return '.dz/config.json exists but could not be read or parsed — fix the file, not the settings';
651
691
  case 'engine-off': return '.dz/config.json sets memory.vector.engine = "off" — the tier is deliberately disabled';
652
692
  case 'not-enabled': return '.dz/config.json enables no mirror (needs memory.backend=agentdb, or memory.vector.engine=agentdb|rvf) — teach is NOT queueing';
693
+ case 'legacy-shape': return '.dz/config.json uses a top-level backend key; the mirror reads memory.backend — run dz setup';
653
694
  }
654
695
  }
655
696