@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
@@ -30,6 +30,8 @@ export interface MutationRegistryEntry {
30
30
  readonly file: string;
31
31
  /** exact text surgery: `find` must occur EXACTLY ONCE in the file (rule 1). */
32
32
  readonly mutation: { readonly find: string; readonly replace: string };
33
+ /** test files that discriminate this entry; a named registry self-check must remain runnable. */
34
+ readonly tests?: readonly string[];
33
35
  /** how many tests MUST go red under the mutation (default 1). */
34
36
  readonly minFailing?: number;
35
37
  /** how many actually did at the time of writing — makes a coverage DROP visible. */
@@ -42,6 +44,34 @@ export interface MutationRegistryEntry {
42
44
  readonly maxFailing?: number;
43
45
  }
44
46
 
47
+ /** Registry integrity tests add the same unrelated failure to every ordinary mutant run. */
48
+ export const REGISTRY_SELFCHECK_TESTS = [
49
+ 'test/mutation-registry-freshness.test.ts',
50
+ 'test/mutation-registry-anchors.test.ts',
51
+ ] as const;
52
+
53
+ export interface MutationTestCommand {
54
+ readonly testCommand: string;
55
+ readonly excluded: readonly string[];
56
+ }
57
+
58
+ /** Build the mutant-only command; baseline commands remain unchanged in the executor. */
59
+ export function buildMutationTestCommand(
60
+ testCommand: string,
61
+ entry: Pick<MutationRegistryEntry, 'tests'>,
62
+ ): MutationTestCommand {
63
+ const entryTests = new Set(entry.tests ?? []);
64
+ const commandTokens = testCommand.split(/\s+/);
65
+ const excluded = REGISTRY_SELFCHECK_TESTS.filter(
66
+ (testFile) => commandTokens.includes(testFile) && !entryTests.has(testFile),
67
+ );
68
+ const excludedSet = new Set<string>(excluded);
69
+ return {
70
+ testCommand: commandTokens.filter((token) => !excludedSet.has(token)).join(' '),
71
+ excluded,
72
+ };
73
+ }
74
+
45
75
  export interface MutationRegistry {
46
76
  /** optional suite command override for the whole registry (default `npm test`). */
47
77
  readonly testCommand?: string;
@@ -112,6 +142,24 @@ export interface MutationObservation {
112
142
  readonly rebaselineFailureReason?: string;
113
143
  /** parsed failing files from a RED restored-tree run; absent when no red rebaseline ran. */
114
144
  readonly rebaselineAttribution?: BaselineAttribution;
145
+ /** bounded stdout+stderr tail supplied by the executor for a RED restored-tree run. */
146
+ readonly rebaselineOutputTail?: string;
147
+ /**
148
+ * path to the FULL stdout+stderr the executor saved for a RED baseline/rebaseline run (gate
149
+ * stability, 2026-09-12) — the bounded tail above is a diagnostic teaser; a multi-entry gate run
150
+ * that goes INCONCLUSIVE/OVER_FAILING needs the whole log to attribute the redness, and the tail
151
+ * alone was measured to hand back three lines of an unrelated neighbour's stderr. Absent when the
152
+ * run was green (nothing written) or the executor could not write the file.
153
+ */
154
+ readonly outputPath?: string;
155
+ /**
156
+ * set (to the caught error's message) when the executor tried and FAILED to save the full RED
157
+ * output — EACCES/ENOSPC/EROFS/ENOTDIR and the like (fix-round-1, HIGH/MEDIUM review findings,
158
+ * 2026-09-12). Mutually exclusive with `outputPath`: a red run either saved (path) or did not
159
+ * (error), never both, and a green run has neither. Absent/undefined = not attempted (green) or
160
+ * the save succeeded.
161
+ */
162
+ readonly outputError?: string;
115
163
  /** bounded log proving an internal runner failure received at most one retry. */
116
164
  readonly internalAttemptLog?: string;
117
165
  }
@@ -137,6 +185,12 @@ export interface MutationEntryResult {
137
185
  * of letting the reader assume it covers every entry.
138
186
  */
139
187
  readonly dropComparable: boolean;
188
+ /** full bounded restored-tree output tail for machine-readable reports. */
189
+ readonly rebaselineOutputTail?: string;
190
+ /** path to the full saved output for a RED baseline/rebaseline run; see MutationObservation. */
191
+ readonly outputPath?: string;
192
+ /** the save FAILED for a RED run; see MutationObservation.outputError. Mutually exclusive with `outputPath`. */
193
+ readonly outputError?: string;
140
194
  /** human sentence for the report line — names the undefended property on a green suite. */
141
195
  readonly detail: string;
142
196
  }
@@ -371,6 +425,9 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
371
425
  property: o['property'].trim(),
372
426
  file,
373
427
  mutation: { find: mut.find, replace: mut.replace },
428
+ ...(Array.isArray(o['tests']) && o['tests'].every((test) => typeof test === 'string')
429
+ ? { tests: o['tests'] as string[] }
430
+ : {}),
374
431
  minFailing,
375
432
  ...(observed !== undefined ? { observed } : {}),
376
433
  ...(maxFailing !== undefined ? { maxFailing } : {}),
@@ -717,6 +774,19 @@ export interface BaselineResult {
717
774
  readonly reason?: BaselineFailureReason;
718
775
  }
719
776
 
777
+ /**
778
+ * `; full output: <path>` when the executor saved the full red-run output, `; full output NOT
779
+ * saved: <error>` when it tried and failed (fix-round-1 MEDIUM finding — an EACCES/ENOSPC/EROFS
780
+ * during the save must not vanish silently), else '' — byte-identical to the pre-gate-stability
781
+ * text when neither is known (NFR-1). `outputPath` takes precedence if somehow both are set (the
782
+ * callers never set both).
783
+ */
784
+ function outputPathSuffix(outputPath?: string, outputError?: string): string {
785
+ if (outputPath !== undefined) return `; full output: ${outputPath}`;
786
+ if (outputError !== undefined) return `; full output NOT saved: ${outputError}`;
787
+ return '';
788
+ }
789
+
720
790
  /**
721
791
  * A RED baseline in the scratch copy is a SETUP error, never a mutation result: every subsequent
722
792
  * "red under mutation" would be noise, and every "green" a lie about an unrunnable copy.
@@ -725,14 +795,17 @@ export function classifyBaseline(
725
795
  exitCode: number | null,
726
796
  runFailureReason?: string,
727
797
  attribution?: BaselineAttribution,
798
+ outputPath?: string,
799
+ outputError?: string,
728
800
  ): BaselineResult {
729
801
  if (exitCode === 0) return { ok: true, detail: 'baseline suite green in the scratch copy' };
802
+ const pathSuffix = outputPathSuffix(outputPath, outputError);
730
803
  if (exitCode === null) {
731
804
  const internal = runFailureReason?.startsWith('runner-internal-error:') === true;
732
805
  return {
733
806
  ok: false,
734
807
  reason: internal ? 'runner-internal-error' : 'runner-no-exit',
735
- detail: `baseline INCONCLUSIVE — suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; do not read this as a mutation result`,
808
+ detail: `baseline INCONCLUSIVE — suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; do not read this as a mutation result${pathSuffix}`,
736
809
  };
737
810
  }
738
811
  const infrastructure = attribution?.infrastructureFailure;
@@ -740,14 +813,14 @@ export function classifyBaseline(
740
813
  return {
741
814
  ok: false,
742
815
  ...(infrastructure.reason !== undefined ? { reason: infrastructure.reason } : {}),
743
- detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — runner infrastructure failure: ${infrastructure.reason} — ${infrastructure.evidence}; the broken copy cannot prove anything`,
816
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — runner infrastructure failure: ${infrastructure.reason} — ${infrastructure.evidence}; the broken copy cannot prove anything${pathSuffix}`,
744
817
  };
745
818
  }
746
819
  if (attribution === undefined || attribution.parsedFrom === 'unparseable') {
747
820
  return {
748
821
  ok: false,
749
822
  reason: 'baseline-red-files-unparseable',
750
- detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — failing files: unparseable from runner output — the broken copy cannot prove anything`,
823
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — failing files: unparseable from runner output — the broken copy cannot prove anything${pathSuffix}`,
751
824
  };
752
825
  }
753
826
  const failing = `failing files: ${attribution.failingFiles.join(', ')}`;
@@ -755,13 +828,13 @@ export function classifyBaseline(
755
828
  return {
756
829
  ok: false,
757
830
  reason: 'extraneous-red-in-allowlist',
758
- detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — ${failing} — extraneous red in the testCommand allowlist; the registry entries themselves are not disproven`,
831
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — ${failing} — extraneous red in the testCommand allowlist; the registry entries themselves are not disproven${pathSuffix}`,
759
832
  };
760
833
  }
761
834
  return {
762
835
  ok: false,
763
836
  reason: 'baseline-red-covered-files',
764
- detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — ${failing} — broken-copy baseline redness touches registry-covered files; fix the copy before evaluating mutations`,
837
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — ${failing} — broken-copy baseline redness touches registry-covered files; fix the copy before evaluating mutations${pathSuffix}`,
765
838
  };
766
839
  }
767
840
 
@@ -814,6 +887,9 @@ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): Mut
814
887
  failingCount: obs.failingCount,
815
888
  // an `observed` anchor is the ONLY thing that makes a drop detectable at all (round-7 honesty)
816
889
  dropComparable: e.observed !== undefined,
890
+ ...(obs.rebaselineOutputTail !== undefined ? { rebaselineOutputTail: obs.rebaselineOutputTail } : {}),
891
+ ...(obs.outputPath !== undefined ? { outputPath: obs.outputPath } : {}),
892
+ ...(obs.outputError !== undefined ? { outputError: obs.outputError } : {}),
817
893
  };
818
894
 
819
895
  if (obs.occurrences !== 1) {
@@ -864,7 +940,7 @@ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): Mut
864
940
  applied: true,
865
941
  verdict: 'INCONCLUSIVE',
866
942
  drop: false,
867
- detail: `suite produced NO exit code under the mutation (${obs.runFailureReason ?? 'unknown timeout / spawn failure'}) — inconclusive is a FAILURE, never a pass`,
943
+ detail: `suite produced NO exit code under the mutation (${obs.runFailureReason ?? 'unknown timeout / spawn failure'}) — inconclusive is a FAILURE, never a pass${outputPathSuffix(obs.outputPath, obs.outputError)}`,
868
944
  };
869
945
  }
870
946
 
@@ -898,7 +974,7 @@ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): Mut
898
974
  applied: true,
899
975
  verdict: 'INCONCLUSIVE',
900
976
  drop: false,
901
- detail: `suite red but the runner output is UNRECOGNISED (${obs.outputUnrecognised.slice(0, 220)}) — this tool has no classifier for the output shape, so file-load redness cannot be told from assertion redness; INCONCLUSIVE is a FAILURE, never a pass (run the gate with a node --test or vitest test command, or extend classifyRunFailure)`,
977
+ detail: `suite red but the runner output is UNRECOGNISED (${obs.outputUnrecognised.slice(0, 220)}) — this tool has no classifier for the output shape, so file-load redness cannot be told from assertion redness; INCONCLUSIVE is a FAILURE, never a pass (run the gate with a node --test or vitest test command, or extend classifyRunFailure)${outputPathSuffix(obs.outputPath, obs.outputError)}`,
902
978
  };
903
979
  }
904
980
 
@@ -912,12 +988,15 @@ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): Mut
912
988
  : obs.rebaselineAttribution === undefined || obs.rebaselineAttribution.parsedFrom === 'unparseable'
913
989
  ? 'failing files: unparseable from runner output'
914
990
  : `failing files: ${obs.rebaselineAttribution.failingFiles.join(', ')}`;
991
+ const outputTailDetail = obs.rebaselineOutputTail === undefined
992
+ ? ''
993
+ : `; output tail (first 3 lines):\n${obs.rebaselineOutputTail.split(/\r?\n/).slice(0, 3).join('\n')}`;
915
994
  return {
916
995
  ...base,
917
996
  applied: true,
918
997
  verdict: 'INCONCLUSIVE',
919
998
  drop: false,
920
- detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) — mutation did not revert / flaky restored-tree route; ${failing}; the redness is not attributable to the protection`,
999
+ detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) — mutation did not revert / flaky restored-tree route; ${failing}${outputTailDetail}; the redness is not attributable to the protection${outputPathSuffix(obs.outputPath, obs.outputError)}`,
921
1000
  };
922
1001
  }
923
1002
 
package/src/operations.ts CHANGED
@@ -1360,6 +1360,58 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1360
1360
  }
1361
1361
  } catch { /* doctor never throws on a diagnostic */ }
1362
1362
 
1363
+ // APPLY-LEG INSTALLED (feature `setup-installs-apply-leg`, ADR-001 Decision 3). Deliberately
1364
+ // OUTSIDE the `existsSync(writerPath)` guard below, for the same reason the Codex apply-leg block
1365
+ // above is: MEASURED 2026-09-12, `dz parity` printed a static "✓ automatic apply-leg" for
1366
+ // `learning-apply` regardless of what the project actually had installed — a capability read off
1367
+ // a table, not off the project. `applyLegStatus` is the ONE measurement; this check and
1368
+ // `dz parity`'s `learning-apply` cell both read it, so they cannot disagree.
1369
+ try {
1370
+ const { applyLegStatus, applyLegReasonMessage } = await import('./apply-leg.js');
1371
+ let configuredBackend: 'agentdb' | 'jsonl' | 'unknown' = 'unknown';
1372
+ try {
1373
+ const cfg = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf-8')) as { memory?: { backend?: string } };
1374
+ if (cfg.memory?.backend === 'agentdb') configuredBackend = 'agentdb';
1375
+ else if (cfg.memory?.backend === 'jsonl') configuredBackend = 'jsonl';
1376
+ } catch { /* no .dz/config.json yet — nothing to check */ }
1377
+
1378
+ if (configuredBackend === 'agentdb') {
1379
+ // `applyLegStatus` is the ONE measurement, `installed` already folds in version + structural
1380
+ // hook wiring (fix round 1, HIGH findings 1/2) — a stale-versioned or unreadable helper is
1381
+ // `installed:false` with a `reason`, never a separate ad-hoc re-check of the same facts here.
1382
+ const status = applyLegStatus(root);
1383
+ if (!status.installed) {
1384
+ if (status.reason === 'stale-version') {
1385
+ // Distinct row name from "apply-leg installed", exactly as `AGENTDB_WRITER_VERSION`'s own
1386
+ // staleness check does — `dz parity` reads the SAME `applyLegReasonMessage` for its
1387
+ // `learning-apply` remedy note, so the two instruments cannot disagree about WHY.
1388
+ checks.push({ name: 'apply-leg version', ok: false, detail: applyLegReasonMessage(status) });
1389
+ } else if (status.reason === 'unreadable') {
1390
+ // Q3 (fix round 1): an EXISTING-but-unreadable helper used to be swallowed by this whole
1391
+ // block's outer catch (silence) — now a named, non-OK row, not folded into "not installed".
1392
+ checks.push({ name: 'apply-leg unreadable', ok: false, detail: applyLegReasonMessage(status) });
1393
+ } else {
1394
+ checks.push({
1395
+ name: 'apply-leg installed',
1396
+ ok: false,
1397
+ detail: 'NOT INSTALLED — run: dz setup --target claude-code --memory agentdb',
1398
+ });
1399
+ }
1400
+ }
1401
+ // Liveness (embed.sock) stays the existing check below, gated on the writer file — installed
1402
+ // implies the recall hook + settings entries exist; whether the daemon is CURRENTLY alive is
1403
+ // a separate, already-covered fact.
1404
+ } else if (configuredBackend === 'jsonl') {
1405
+ checks.push({
1406
+ name: 'apply-leg',
1407
+ ok: true,
1408
+ detail: 'apply-leg unavailable on jsonl backend (needs --memory agentdb)',
1409
+ });
1410
+ }
1411
+ // configuredBackend === 'unknown' (no .dz/config.json yet): nothing to check — a project that
1412
+ // never ran `dz setup` gets no apply-leg opinion, same as every other doctor check here.
1413
+ } catch { /* doctor never throws on a diagnostic */ }
1414
+
1363
1415
  const writerPath = join(root, '.dz', 'agentdb-writer.mjs');
1364
1416
  if (existsSync(writerPath)) {
1365
1417
  const { writerVersionOf, AGENTDB_WRITER_VERSION } = await import('./setup.js');
@@ -1517,6 +1569,43 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1517
1569
  }
1518
1570
  } catch { /* advisory only — the vector tier must never fail doctor */ }
1519
1571
 
1572
+ // Mirror writer state (issue #10 defect 6, AM-9, feature setup-installs-apply-leg): the SAME
1573
+ // reason `dz vector status` and `dz teach` both read (`mirrorWriterReason`) — one function, so a
1574
+ // "legacy-shape" config (a top-level `backend` key instead of `memory.backend`) cannot be silent
1575
+ // in doctor while `dz teach` is silent too. Informational only (`ok:true`): a mirror that is OFF
1576
+ // by explicit config (`engine-off`) or simply unconfigured (`no-config`) is not a doctor FAILURE,
1577
+ // it is a fact worth naming once per run rather than never.
1578
+ try {
1579
+ const { mirrorWriterReason: reasonFn, mirrorWriterExplanation: explainFn } = await import('./vector-tier.js');
1580
+ const mirror = reasonFn(root);
1581
+ // 'no-config' is the honest quiet baseline (a project that never ran `dz setup` at all) — every
1582
+ // OTHER state means `.dz/config.json` exists and has an opinion worth naming, legacy-shape
1583
+ // included, so THAT is the line this check exists to stop being silent about.
1584
+ if (mirror.state !== 'no-config') {
1585
+ checks.push({
1586
+ name: 'mirror writer',
1587
+ ok: true,
1588
+ detail: `${mirror.enabled ? 'ON' : 'OFF'} (${explainFn(mirror.state)})`,
1589
+ });
1590
+ }
1591
+ } catch { /* advisory only — the vector tier must never fail doctor */ }
1592
+
1593
+ // Quarantine labels have their own identity parity: equal aggregate counts can still describe
1594
+ // disjoint dzIds. Informational only; the lexical store is authoritative and reindex projects it.
1595
+ try {
1596
+ const { countLearningStoreRowsReadonly } = await import('./store-counts.js');
1597
+ const parity = countLearningStoreRowsReadonly(root).quarantineTierParity;
1598
+ if (parity !== undefined) {
1599
+ const drift = parity.lexicalOnly + parity.mirrorOnly;
1600
+ checks.push({
1601
+ name: 'quarantine tier parity',
1602
+ ok: true,
1603
+ detail: `quarantine tiers: both ${parity.both} · lexical-only ${parity.lexicalOnly} · mirror-only ${parity.mirrorOnly}` +
1604
+ (drift > 0 ? ' — run dz vector reindex to project' : ''),
1605
+ });
1606
+ }
1607
+ } catch { /* advisory only — unreadable identities leave the line absent */ }
1608
+
1520
1609
  // 10. AQE store integrity (observability item 3/5). `.agentic-qe/integrity-log.jsonl` is the
1521
1610
  // best-attested log in this repo — a UserPromptSubmit hook runs a REAL `PRAGMA quick_check`,
1522
1611
  // stats the file and scans /proc for the process holding it, so unlike almost every other store
package/src/patterns.ts CHANGED
@@ -32,6 +32,7 @@ import type { VectorEntry } from './vector-tier.js';
32
32
  import { rankLessonsByDelta, type LessonHistory } from './safla-delta.js';
33
33
  import { withStoreLock, withStoreLockSync, StoreLockTimeoutError, StoreLockCompromisedError } from './store-lock.js';
34
34
  import { describeNativeDep, exerciseSqliteOpen, probeNativeDep } from './native-dep-probe.js';
35
+ import { classifySqliteReadFailure, warnOnce } from './sqlite-read-helpers.js';
35
36
  import {
36
37
  lessonPairIdOf,
37
38
  mergeLessonFormHits,
@@ -619,9 +620,18 @@ function loadStoreRecordsSync(projectRoot: string): MemoryRecord[] {
619
620
  // SQLite tier (only if the db file exists — avoid creating one on a pure read)
620
621
  if (sqliteBackend !== 'json' && existsSync(sqlitePath(projectRoot))) {
621
622
  try {
622
- const db = SqliteBackend.open(sqlitePath(projectRoot));
623
+ const db = SqliteBackend.openReadOnly(sqlitePath(projectRoot));
623
624
  try { for (const r of db.allSync()) add(r); } finally { db.close(); }
624
- } catch { /* native unavailable — fall through to JSON */ }
625
+ } catch (err) {
626
+ // FR-2 (readonly-residuals): native-unavailable stays a silent JSON fallback (unchanged
627
+ // behaviour); store-unreadable (a corrupt/broken store) warns once instead of silently
628
+ // looking like "fewer lessons".
629
+ if (classifySqliteReadFailure(err) === 'store-unreadable') {
630
+ const cause = err instanceof Error ? err.message : String(err);
631
+ const path = sqlitePath(projectRoot);
632
+ warnOnce(path, `dz: ${path} unreadable (${cause}) — falling back to the JSON store`);
633
+ }
634
+ }
625
635
  }
626
636
  // JSON store (the deterministic fallback; also the source during migration)
627
637
  try {
@@ -1266,7 +1276,7 @@ export function recallPatterns(
1266
1276
  const { sqliteBackend } = readLearningConfig(projectRoot);
1267
1277
  if (sqliteBackend !== 'json' && existsSync(sqlitePath(projectRoot))) {
1268
1278
  try {
1269
- const db = SqliteBackend.open(sqlitePath(projectRoot));
1279
+ const db = SqliteBackend.openReadOnly(sqlitePath(projectRoot));
1270
1280
  try {
1271
1281
  if (!classFormIndexPresent(projectRoot)) {
1272
1282
  return sinkQuarantined(
@@ -1283,7 +1293,16 @@ export function recallPatterns(
1283
1293
  } finally {
1284
1294
  db.close();
1285
1295
  }
1286
- } catch { /* fall through to JSON */ }
1296
+ } catch (err) {
1297
+ // FR-2 (readonly-residuals): same classify-then-warn-once as loadStoreRecordsSync — a
1298
+ // native-unavailable failure keeps the silent JSON fallback; a store-unreadable failure
1299
+ // warns once instead of silently looking like "fewer lessons".
1300
+ if (classifySqliteReadFailure(err) === 'store-unreadable') {
1301
+ const cause = err instanceof Error ? err.message : String(err);
1302
+ const path = sqlitePath(projectRoot);
1303
+ warnOnce(path, `dz: ${path} unreadable (${cause}) — falling back to the JSON store`);
1304
+ }
1305
+ }
1287
1306
  }
1288
1307
  try {
1289
1308
  const backend = JsonFileBackend.openSync(storePath(projectRoot));
@@ -1 +1,7 @@
1
- export const REPOSITORY_ORIGIN = 'git+https://github.com/djd1m/dz-harness-hub.git';
1
+ // Points at the public mirror repo (git+https://github.com/djd1m/dz-harness.git), not the private
2
+ // dz-harness-hub monorepo. Decision + rationale: docs/public-mirror-plan.md (2026-08-29) and
3
+ // feature-adr npm-readme-install-first (2026-09-13, FR-5, owner approval "возвращать" 07:05 UTC).
4
+ // A prior "hygiene" pass (278debac, 2026-09-10) flipped this to the private origin because the
5
+ // decision lived only in prose; package-repository-field.test.ts now cites this same trail so a
6
+ // future pass reads the reason before reverting it again (teach:a05c61bb).
7
+ export const REPOSITORY_ORIGIN = 'git+https://github.com/djd1m/dz-harness.git';
@@ -0,0 +1,85 @@
1
+ /** Pure parsing and classification for one `dz round exec` subprocess receipt. */
2
+
3
+ export type RoundExecOutcome =
4
+ | 'done'
5
+ | 'timeout'
6
+ | 'session-limit'
7
+ | 'model-refused'
8
+ | 'failed'
9
+ | 'empty';
10
+
11
+ export interface RoundExecLedgerRow {
12
+ readonly stage: 'round-exec';
13
+ readonly slug: string;
14
+ readonly round: number;
15
+ readonly coder: string;
16
+ readonly minutes: number;
17
+ readonly tokens: number | null;
18
+ readonly agents: 1;
19
+ readonly outcome: RoundExecOutcome;
20
+ readonly exitCode: number | null;
21
+ readonly bytes: number;
22
+ readonly startedAt: string;
23
+ readonly endedAt: string;
24
+ readonly log: string;
25
+ readonly brief: string;
26
+ }
27
+
28
+ export function parseCodexTokens(logText: string): number | null {
29
+ const matches = [...logText.matchAll(/^tokens used[ \t]*\r?\n([0-9][0-9, \t]*)[ \t]*$/gmi)];
30
+ const raw = matches.at(-1)?.[1];
31
+ if (raw === undefined) return null;
32
+ const parsed = Number(raw.replace(/[ ,\t]/g, ''));
33
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
34
+ }
35
+
36
+ export function classifyRoundExecOutcome(input: {
37
+ readonly exitCode: number | null;
38
+ readonly timedOut: boolean;
39
+ readonly bytes: number;
40
+ readonly tail: string;
41
+ }): RoundExecOutcome {
42
+ if (input.timedOut) return 'timeout';
43
+ if (/rate limit|usage limit|limit reached/i.test(input.tail)) return 'session-limit';
44
+ if (/HTTP 400|not supported when using Codex/i.test(input.tail)) return 'model-refused';
45
+ if (input.exitCode === 0 && input.bytes > 0) {
46
+ const marker = input.tail.lastIndexOf('\ncodex\n');
47
+ const finalLine = marker < 0 ? '' : input.tail.slice(marker + '\ncodex\n'.length).split(/\r?\n/, 1)[0]?.trim() ?? '';
48
+ if (finalLine !== '') return 'done';
49
+ }
50
+ if (input.exitCode === 0 && input.bytes === 0) return 'empty';
51
+ return 'failed';
52
+ }
53
+
54
+ export function buildRoundExecRow(input: {
55
+ readonly slug: string;
56
+ readonly round: number;
57
+ readonly model: string;
58
+ readonly effort: string;
59
+ readonly minutes: number;
60
+ readonly tokens: number | null;
61
+ readonly outcome: RoundExecOutcome;
62
+ readonly exitCode: number | null;
63
+ readonly bytes: number;
64
+ readonly startedAt: string;
65
+ readonly endedAt: string;
66
+ readonly log: string;
67
+ readonly brief: string;
68
+ }): RoundExecLedgerRow {
69
+ return {
70
+ stage: 'round-exec',
71
+ slug: input.slug,
72
+ round: input.round,
73
+ coder: `codex:${input.model}:${input.effort}`,
74
+ minutes: input.minutes,
75
+ tokens: input.tokens,
76
+ agents: 1,
77
+ outcome: input.outcome,
78
+ exitCode: input.exitCode,
79
+ bytes: input.bytes,
80
+ startedAt: input.startedAt,
81
+ endedAt: input.endedAt,
82
+ log: input.log,
83
+ brief: input.brief,
84
+ };
85
+ }