@dzhechkov/harness-core 0.7.12 → 0.8.2

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 (105) hide show
  1. package/.dz-manifest.json +210 -70
  2. package/README.md +34 -1
  3. package/dist/book-kb.d.ts.map +1 -1
  4. package/dist/book-kb.js +17 -0
  5. package/dist/book-kb.js.map +1 -1
  6. package/dist/feature-adr-checkpoints.d.ts +61 -0
  7. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  8. package/dist/feature-adr-checkpoints.js +116 -2
  9. package/dist/feature-adr-checkpoints.js.map +1 -1
  10. package/dist/index.d.ts +16 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +13 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/loop-blobs.generated.js +2 -2
  15. package/dist/loop-blobs.generated.js.map +1 -1
  16. package/dist/mutation-gate.d.ts +31 -8
  17. package/dist/mutation-gate.d.ts.map +1 -1
  18. package/dist/mutation-gate.js +57 -16
  19. package/dist/mutation-gate.js.map +1 -1
  20. package/dist/named-lock.d.ts.map +1 -1
  21. package/dist/named-lock.js +11 -9
  22. package/dist/named-lock.js.map +1 -1
  23. package/dist/native-dep-probe.d.ts +11 -0
  24. package/dist/native-dep-probe.d.ts.map +1 -0
  25. package/dist/native-dep-probe.js +100 -0
  26. package/dist/native-dep-probe.js.map +1 -0
  27. package/dist/operations.d.ts.map +1 -1
  28. package/dist/operations.js +82 -1
  29. package/dist/operations.js.map +1 -1
  30. package/dist/parser-safe-region.d.ts +47 -0
  31. package/dist/parser-safe-region.d.ts.map +1 -0
  32. package/dist/parser-safe-region.js +47 -0
  33. package/dist/parser-safe-region.js.map +1 -0
  34. package/dist/patterns.d.ts.map +1 -1
  35. package/dist/patterns.js +10 -1
  36. package/dist/patterns.js.map +1 -1
  37. package/dist/profile.d.ts +202 -0
  38. package/dist/profile.d.ts.map +1 -0
  39. package/dist/profile.js +536 -0
  40. package/dist/profile.js.map +1 -0
  41. package/dist/publish.d.ts +5 -3
  42. package/dist/publish.d.ts.map +1 -1
  43. package/dist/publish.js +46 -8
  44. package/dist/publish.js.map +1 -1
  45. package/dist/qe-rounds.d.ts +75 -0
  46. package/dist/qe-rounds.d.ts.map +1 -0
  47. package/dist/qe-rounds.js +174 -0
  48. package/dist/qe-rounds.js.map +1 -0
  49. package/dist/registry.d.ts.map +1 -1
  50. package/dist/registry.js +5 -1
  51. package/dist/registry.js.map +1 -1
  52. package/dist/run-records.d.ts.map +1 -1
  53. package/dist/run-records.js +8 -1
  54. package/dist/run-records.js.map +1 -1
  55. package/dist/setup.d.ts +35 -4
  56. package/dist/setup.d.ts.map +1 -1
  57. package/dist/setup.js +86 -23
  58. package/dist/setup.js.map +1 -1
  59. package/dist/sign.d.ts +29 -24
  60. package/dist/sign.d.ts.map +1 -1
  61. package/dist/sign.js +183 -61
  62. package/dist/sign.js.map +1 -1
  63. package/dist/store-location.d.ts +56 -0
  64. package/dist/store-location.d.ts.map +1 -0
  65. package/dist/store-location.js +56 -0
  66. package/dist/store-location.js.map +1 -0
  67. package/dist/store-lock.d.ts +1 -0
  68. package/dist/store-lock.d.ts.map +1 -1
  69. package/dist/store-lock.js +7 -1
  70. package/dist/store-lock.js.map +1 -1
  71. package/dist/store-merge.d.ts +55 -0
  72. package/dist/store-merge.d.ts.map +1 -0
  73. package/dist/store-merge.js +77 -0
  74. package/dist/store-merge.js.map +1 -0
  75. package/dist/teach-target.d.ts +61 -0
  76. package/dist/teach-target.d.ts.map +1 -0
  77. package/dist/teach-target.js +105 -0
  78. package/dist/teach-target.js.map +1 -0
  79. package/dist/vector-tier.d.ts.map +1 -1
  80. package/dist/vector-tier.js +63 -20
  81. package/dist/vector-tier.js.map +1 -1
  82. package/package.json +13 -13
  83. package/sbom.json +424 -70
  84. package/src/book-kb.ts +17 -0
  85. package/src/feature-adr-checkpoints.ts +111 -2
  86. package/src/index.ts +39 -1
  87. package/src/loop-blobs.generated.ts +2 -2
  88. package/src/mutation-gate.ts +74 -17
  89. package/src/named-lock.ts +10 -8
  90. package/src/native-dep-probe.ts +118 -0
  91. package/src/operations.ts +77 -1
  92. package/src/parser-safe-region.ts +65 -0
  93. package/src/patterns.ts +10 -1
  94. package/src/profile.ts +593 -0
  95. package/src/publish.ts +43 -7
  96. package/src/qe-rounds.ts +226 -0
  97. package/src/registry.ts +5 -1
  98. package/src/run-records.ts +9 -1
  99. package/src/setup.ts +87 -23
  100. package/src/sign.ts +180 -60
  101. package/src/store-location.ts +76 -0
  102. package/src/store-lock.ts +8 -1
  103. package/src/store-merge.ts +91 -0
  104. package/src/teach-target.ts +126 -0
  105. package/src/vector-tier.ts +58 -9
@@ -0,0 +1,226 @@
1
+ /**
2
+ * qe-rounds — how many Step-8 review rounds has one feature already had?
3
+ *
4
+ * The stopping rule existed ONLY as a sentence in a prose module:
5
+ *
6
+ * .claude/skills/feature-adr/modules/08-qe.md:246
7
+ * "Max iterations: 3. After 3 iterations, flag remaining gaps for user decision."
8
+ *
9
+ * MEASURED 2026-08-27: no counter existed anywhere — `grep -rl 'ReviewScope|scopeId|findingId|
10
+ * lineage'` over harness-core/src and harness-cli/src returned nothing. So the rule sat at layer 4 of
11
+ * the cost-of-detection ladder, and every restart of the agent forgot the sentence. One real slug
12
+ * accumulated **38** graded rounds and 4 failed attempts against a documented ceiling of 3.
13
+ *
14
+ * This module READS what `dz qe-bridge` already writes. It records nothing of its own — deliberately:
15
+ * a counter that starts recording today could not answer for the 38 rounds already on disk, which are
16
+ * the only real evidence this feature has.
17
+ */
18
+
19
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+
22
+ /** One graded review round, as `dz qe-bridge` recorded it. */
23
+ export interface QeRound {
24
+ runId: string;
25
+ emittedAt: string;
26
+ grade: string;
27
+ /** Findings by severity, lowercased. Absent severities are simply not present. */
28
+ severities: Record<string, number>;
29
+ /** The file this round was read from, so a caller can point at it. */
30
+ file: string;
31
+ }
32
+
33
+ /** An attempt that produced NO verdict. Never merged into the round count, never dropped. */
34
+ export interface QeFailedAttempt {
35
+ runId: string;
36
+ emittedAt: string;
37
+ reason: string;
38
+ file: string;
39
+ }
40
+
41
+ export type QeRoundsStatus = 'under-ceiling' | 'at-or-over-ceiling' | 'not-established';
42
+
43
+ export interface QeRoundsReport {
44
+ status: QeRoundsStatus;
45
+ /** Present only when status is 'not-established'. */
46
+ notEstablishedReason?: string | undefined;
47
+ /** The directory actually read. One directory — never a union. */
48
+ dir: string;
49
+ ceiling: number;
50
+ /** Distinct runIds among graded sign-offs. THIS is the number the ceiling applies to. */
51
+ rounds: number;
52
+ /** Graded rounds in emission order. */
53
+ roundList: QeRound[];
54
+ /** Attempts that produced no verdict. Reported separately, on purpose. */
55
+ failedAttempts: QeFailedAttempt[];
56
+ /** Files that would not parse or carried no runId. Named, never silently skipped. */
57
+ unreadable: { file: string; why: string }[];
58
+ /** Grades in emission order, e.g. ['C','C','B'] — a shape a reader can judge at a glance. */
59
+ grades: string[];
60
+ firstAt?: string | undefined;
61
+ lastAt?: string | undefined;
62
+ }
63
+
64
+ export const QE_ROUNDS_DEFAULT_CEILING = 3;
65
+
66
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
67
+ typeof v === 'object' && v !== null && !Array.isArray(v);
68
+
69
+ /** Severity tallies from a sign-off's findings. Unknown shapes contribute nothing rather than throwing. */
70
+ function severitiesOf(findings: unknown): Record<string, number> {
71
+ const out: Record<string, number> = {};
72
+ if (!Array.isArray(findings)) return out;
73
+ for (const f of findings) {
74
+ if (!isRecord(f)) continue;
75
+ const s = typeof f.severity === 'string' ? f.severity.toLowerCase() : '';
76
+ if (!s) continue;
77
+ out[s] = (out[s] ?? 0) + 1;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * Read ONE feature directory's review rounds.
84
+ *
85
+ * `featureDir` is a directory, never a slug — and that is load-bearing. MEASURED 2026-08-27: the slug
86
+ * `package-story-page-hardening` exists in two separate checkouts holding 38 and 7 records. A function
87
+ * that resolved a slug by searching would have summed them to 45 for a run that had 38, and the
88
+ * output would look identical to a correct one. Resolving a slug to a directory is the caller's job.
89
+ */
90
+ export function readQeRounds(featureDir: string, opts?: { ceiling?: number }): QeRoundsReport {
91
+ // A non-finite ceiling is the quietest fail-open there is: `Math.max(1, Math.floor(NaN))` is NaN,
92
+ // and `rounds >= NaN` is FALSE for every count, so the loop never stops. The CLI validates its own
93
+ // input, but this is a public export and a direct caller bypasses that guard. MEASURED: 2 >= NaN
94
+ // is false. Every numeric clamp needs Number.isFinite — a lesson this repo has already paid for.
95
+ const rawCeiling = opts?.ceiling ?? QE_ROUNDS_DEFAULT_CEILING;
96
+ const ceiling = Number.isFinite(rawCeiling)
97
+ ? Math.max(1, Math.floor(rawCeiling as number))
98
+ : QE_ROUNDS_DEFAULT_CEILING;
99
+ const dir = join(featureDir, '.fa-state', 'qe-bridge');
100
+
101
+ const base: QeRoundsReport = {
102
+ status: 'not-established', dir, ceiling, rounds: 0,
103
+ roundList: [], failedAttempts: [], unreadable: [], grades: [],
104
+ };
105
+
106
+ // "No bridge has ever run here" and "zero rounds so far" are DIFFERENT facts. Reporting the first
107
+ // as the second would tell a caller to keep going on the basis of a measurement never taken.
108
+ let entries: string[];
109
+ try {
110
+ if (!statSync(dir).isDirectory()) {
111
+ return { ...base, notEstablishedReason: `${dir} exists but is not a directory` };
112
+ }
113
+ entries = readdirSync(dir);
114
+ } catch {
115
+ return {
116
+ ...base,
117
+ notEstablishedReason:
118
+ `no ${dir} — this feature has no qe-bridge history, which is not the same as zero rounds`,
119
+ };
120
+ }
121
+
122
+ const byRunId = new Map<string, QeRound>();
123
+ const failed: QeFailedAttempt[] = [];
124
+ const unreadable: { file: string; why: string }[] = [];
125
+
126
+ for (const name of entries.slice().sort()) {
127
+ const isSignoff = name.startsWith('signoff-') && name.endsWith('.json');
128
+ const isFailed = name.startsWith('failed-') && name.endsWith('.json');
129
+ if (!isSignoff && !isFailed) continue;
130
+
131
+ const file = join(dir, name);
132
+ let parsed: unknown;
133
+ try {
134
+ parsed = JSON.parse(readFileSync(file, 'utf-8'));
135
+ } catch (e) {
136
+ // NAMED, not skipped. A silently dropped record makes the count quietly too low — in a counter
137
+ // whose whole job is to stop a loop, that fails OPEN.
138
+ unreadable.push({ file: name, why: `unparseable JSON: ${(e as Error).message}` });
139
+ continue;
140
+ }
141
+ if (!isRecord(parsed)) { unreadable.push({ file: name, why: 'not a JSON object' }); continue; }
142
+
143
+ const runId = typeof parsed.runId === 'string' ? parsed.runId : '';
144
+ const emittedAt = typeof parsed.emittedAt === 'string' ? parsed.emittedAt : '';
145
+
146
+ // A ROUND needs a runId, because that is what deduplicates it. An ATTEMPT does not: it produced
147
+ // no verdict, so there is nothing to deduplicate against, and dropping it would hide a run
148
+ // burning attempts. FOUND BY DOGFOODING 2026-08-27 — the first live run of this command against
149
+ // this repo's own `wave1-scorer-negation` reported 4 unreadable records, and all four were
150
+ // `failed-*.json` written before `runId` was added to that record shape. Calling them unreadable
151
+ // was wrong twice: it lost real attempts, and it declared the round count a lower bound when the
152
+ // rounds themselves were complete.
153
+ if (isFailed) {
154
+ failed.push({
155
+ runId: runId || `(no runId: ${name})`,
156
+ emittedAt,
157
+ reason: typeof parsed.reason === 'string' ? parsed.reason : 'unstated',
158
+ file: name,
159
+ });
160
+ continue;
161
+ }
162
+
163
+ if (!runId) { unreadable.push({ file: name, why: 'no runId — a round cannot be deduplicated without one' }); continue; }
164
+
165
+ // A round is a runId. Two records sharing one are ONE round — first wins, and the collision is
166
+ // not an error: a re-emitted sign-off for the same run is still that run.
167
+ if (byRunId.has(runId)) continue;
168
+ byRunId.set(runId, {
169
+ runId, emittedAt,
170
+ grade: typeof parsed.grade === 'string' ? parsed.grade : '',
171
+ severities: severitiesOf(parsed.findings),
172
+ file: name,
173
+ });
174
+ }
175
+
176
+ const roundList = [...byRunId.values()].sort((a, b) => a.emittedAt.localeCompare(b.emittedAt));
177
+ failed.sort((a, b) => a.emittedAt.localeCompare(b.emittedAt));
178
+
179
+ if (roundList.length === 0 && failed.length === 0) {
180
+ return {
181
+ ...base, unreadable,
182
+ notEstablishedReason: unreadable.length
183
+ ? `${dir} holds ${unreadable.length} record(s), none of them readable`
184
+ : `${dir} holds no signoff or failed records`,
185
+ };
186
+ }
187
+
188
+ const stamps = [...roundList.map((r) => r.emittedAt), ...failed.map((f) => f.emittedAt)]
189
+ .filter((t) => t !== '').sort();
190
+ const bounds = { first: stamps[0], last: stamps[stamps.length - 1] };
191
+
192
+ const rounds = roundList.length;
193
+
194
+ // FAIL CLOSED. Until cross-family review caught it, an unreadable record only downgraded the
195
+ // count to a "lower bound" in the printed text while the STATUS still came out `under-ceiling` —
196
+ // so the command answered "another round is within budget" when the true count might already be
197
+ // at the ceiling. That is the exact fail-open this whole module was written against, sitting in
198
+ // the module itself. If ANY candidate record could not be counted, the honest verdict is that the
199
+ // number is not established — not a smaller number presented as if it were the answer.
200
+ if (unreadable.length > 0 && rounds < ceiling) {
201
+ return {
202
+ status: 'not-established',
203
+ notEstablishedReason:
204
+ `${rounds} readable round(s) plus ${unreadable.length} record(s) that could not be counted — `
205
+ + `the true count may already be at the ceiling of ${ceiling}, so this cannot say another `
206
+ + `round is within budget`,
207
+ dir, ceiling, rounds, roundList, failedAttempts: failed, unreadable,
208
+ grades: roundList.map((r) => r.grade),
209
+ firstAt: bounds.first, lastAt: bounds.last,
210
+ };
211
+ }
212
+
213
+ return {
214
+ status: rounds >= ceiling ? 'at-or-over-ceiling' : 'under-ceiling',
215
+ dir, ceiling, rounds, roundList, failedAttempts: failed, unreadable,
216
+ grades: roundList.map((r) => r.grade),
217
+ firstAt: bounds.first,
218
+ lastAt: bounds.last,
219
+ };
220
+ }
221
+
222
+ /** The number alone, for a caller that only needs to compare it. `-1` means NOT ESTABLISHED. */
223
+ export function countQeRounds(featureDir: string, opts?: { ceiling?: number }): number {
224
+ const r = readQeRounds(featureDir, opts);
225
+ return r.status === 'not-established' ? -1 : r.rounds;
226
+ }
package/src/registry.ts CHANGED
@@ -270,7 +270,11 @@ function categoryFromPack(pack: string): string {
270
270
  pack.includes('presentation') ||
271
271
  // decision-mockups: an owner-facing decision page is stakeholder communication, the same
272
272
  // cluster as PRDs and presentations — not design, and not a QE artifact.
273
- pack.includes('decision-mockups')
273
+ pack.includes('decision-mockups') ||
274
+ // package-story-page: a story page that explains one package to non-specialists is stakeholder
275
+ // communication by the same rationale as decision-mockups — and its own README separates it
276
+ // from the tutorial-course sibling, so it is not 'learning'.
277
+ pack.includes('story')
274
278
  )
275
279
  return 'product';
276
280
  // Digitized-book knowledge packs (ADR-001 book-knowledge-digitizer) — `skills-book-*` and named
@@ -13,6 +13,8 @@
13
13
  * Pure: payload in, verdict out. The CLI owns paths, the append, the read-back and the exit code.
14
14
  */
15
15
 
16
+ import { redactTrainingPayload } from './feature-adr-checkpoints.js';
17
+
16
18
  export type RecordKind = 'ledger' | 'training-pair';
17
19
 
18
20
  export type RecordVerdict =
@@ -127,7 +129,13 @@ export function decideRecordWrite(input: {
127
129
  if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
128
130
  return refuse('the payload must be a JSON object');
129
131
  }
130
- const obj = payload as Record<string, unknown>;
132
+ // Operator-profile redaction AT THE PERSIST SEAM (ADR-001 Decision 5 / CF-6 of operator-profile).
133
+ // Every witnessed training-pair write funnels through this decision, so redacting HERE covers the
134
+ // workflow's inline pair builder and any future caller — the core buildTrainingPair redaction
135
+ // alone guarded a path that does not run (Codex cross-family finding, 2026-08-28). Redaction runs
136
+ // BEFORE the shape check, the line cap and the serialisation, so nothing downstream — the file,
137
+ // the read-back, the refusal texts — ever sees a byte of the profile block.
138
+ const obj = (kind === 'training-pair' ? redactTrainingPayload(payload) : payload) as Record<string, unknown>;
131
139
 
132
140
  const mismatch = shapeMismatch(kind, obj);
133
141
  if (mismatch !== null) return refuse(mismatch);
package/src/setup.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  * 1. Skills installation (via init)
7
7
  * 2. Claude Code session hooks (start/end) — with `--memory agentdb`, a real vector-store
8
8
  * write via `.dz/agentdb-writer.mjs`; otherwise a `.dz/sessions.jsonl` marker
9
- * 3. Memory store: `.dz/agentdb.db` (agentdb, shared with the MCP server via AGENTDB_PATH)
9
+ * 3. Memory store: `.dz/agentdb.db` (agentdb the session-hook writer's OWN file; the agentdb
10
+ * MCP server gets a SEPARATE `.dz/agentdb-mcp.db` via AGENTDB_PATH, never this one)
10
11
  * or `.dz/sessions.jsonl` + `.dz/patterns.jsonl` (jsonl default)
11
12
  * 4. Pretrain (project analysis → auto-recommend)
12
13
  *
@@ -53,11 +54,54 @@ export interface SetupStep {
53
54
  readonly detail: string;
54
55
  }
55
56
 
56
- /** Absolute path to the agentdb store shared by the hook writer and the MCP server. */
57
- function agentdbStorePath(projectRoot: string): string {
57
+ /**
58
+ * Absolute path to the store the generated session-hook writer opens NATIVELY (better-sqlite3).
59
+ * It is the writer's own file: the agentdb MCP server must never be pointed at it — see
60
+ * {@link agentdbMcpStorePath}.
61
+ */
62
+ export function agentdbStorePath(projectRoot: string): string {
58
63
  return join(projectRoot, '.dz', 'agentdb.db');
59
64
  }
60
65
 
66
+ /**
67
+ * Absolute path to the agentdb MCP server's OWN store — never the hook writer's
68
+ * (ADR-001 `agentdb-setup-shared-store-fix`, 2026-08-26).
69
+ *
70
+ * WHY two files: a single SQLite file opened by two different engines is a measured data-loss
71
+ * path. `agentdb` falls back to sql.js when better-sqlite3 has no usable binary (no prebuild for
72
+ * Node 24+/ABI 137, no binary in the npm tarball) — and sql.js persists by rewriting the WHOLE
73
+ * file from its in-memory image, discarding whatever the native writer committed meanwhile.
74
+ * MEASURED in this repo 2026-07-09 (commits 1d47a916 / 9ede3fb5): of 20 samples, 5 were zero
75
+ * bytes and 4 were torn. The cure is separation, not a lock: a lock cannot bind a third-party
76
+ * `npx` process, and sql.js ignores SQLite locking outright.
77
+ */
78
+ export function agentdbMcpStorePath(projectRoot: string): string {
79
+ return join(projectRoot, '.dz', 'agentdb-mcp.db');
80
+ }
81
+
82
+ /**
83
+ * The ONE separation predicate — used by both printers (`runSetup`'s `agentdb wiring` step and
84
+ * `runDoctor`'s `agentdb store separation` check), so a surface can never drift into its own
85
+ * copied comparison.
86
+ *
87
+ * @param projectRoot the project whose `.mcp.json` was read
88
+ * @param pinned the observed `mcpServers.agentdb.env.AGENTDB_PATH` (`undefined` when absent)
89
+ * @returns `null` when the registration is correctly separated, else the WHY message
90
+ */
91
+ export function agentdbStoreSeparationProblem(projectRoot: string, pinned: string | undefined): string | null {
92
+ if (pinned === agentdbStorePath(projectRoot)) {
93
+ return 'SHARED STORE — .mcp.json pins the agentdb MCP server at the same file the session-hook '
94
+ + 'writer opens (.dz/agentdb.db). Two engines on one SQLite file: an agentdb server that '
95
+ + 'falls back to sql.js rewrites the WHOLE file and discards the writer\'s pages. Measured '
96
+ + '2026-07-09: 5 of 20 samples zero bytes, 4 torn. Re-run `dz setup --memory agentdb` to '
97
+ + 'repoint the server at .dz/agentdb-mcp.db.';
98
+ }
99
+ if (pinned !== agentdbMcpStorePath(projectRoot)) {
100
+ return '.mcp.json agentdb missing or not pinned to the MCP store (.dz/agentdb-mcp.db)';
101
+ }
102
+ return null;
103
+ }
104
+
61
105
  /**
62
106
  * Absolute path to the throttle marker holding the epoch-ms timestamp of the last consolidate the
63
107
  * writer spawned. Read before every SessionEnd/PreCompact consolidate: within THROTTLE_MS the spawn
@@ -73,14 +117,15 @@ function consolidateMarkerPath(projectRoot: string): string {
73
117
  * `dz-writer-version` stamp is older, WITHOUT requiring `--force` (audit gap G4: generated code
74
118
  * must not fossilize outside the package lifecycle).
75
119
  */
76
- export const AGENTDB_WRITER_VERSION = 4;
120
+ export const AGENTDB_WRITER_VERSION = 5;
77
121
 
78
122
  /**
79
123
  * Generate the `.dz/agentdb-writer.mjs` helper invoked by the session hooks.
80
124
  *
81
125
  * v2 (ADR-002, audit gaps G3 + code#1 + code#4): session markers are **metadata-only telemetry** —
82
- * a plain row in the `dz_session_events` table inside the SAME shared `.dz/agentdb.db` the MCP
83
- * server is pinned to (`AGENTDB_PATH`). No embedding, no model load, no `successRate`:
126
+ * a plain row in the `dz_session_events` table inside the writer's OWN `.dz/agentdb.db` (v5: the
127
+ * MCP server is pinned to a SEPARATE `.dz/agentdb-mcp.db`). No embedding, no model load, no
128
+ * `successRate`:
84
129
  * - ~ms latency (v1 loaded a ~90 MB transformers model → 12 s cold-timeout losing the marker);
85
130
  * - zero pollution of the HNSW index real learnings live in (those enter via `agentdb_*` MCP tools);
86
131
  * - uses better-sqlite3 DIRECTLY (WAL + busy_timeout) — if it is unavailable the writer falls back
@@ -98,9 +143,11 @@ export function generateAgentdbWriter(projectRoot: string): string {
98
143
  // dz-writer-version: ${AGENTDB_WRITER_VERSION}
99
144
  // Auto-generated by \`dz setup --memory agentdb\`. Do not edit — re-run \`dz setup\` to upgrade
100
145
  // (setup regenerates automatically when this version stamp is outdated; --force not required).
101
- // Writes a metadata-only session-event row into the shared AgentDB store (.dz/agentdb.db — the
102
- // same file the agentdb MCP server is pinned to via AGENTDB_PATH). Real learnings go into the
103
- // vector index via the agentdb_* MCP tools; this is deliberately non-semantic telemetry.
146
+ // Writes a metadata-only session-event row into the writer's OWN AgentDB store (.dz/agentdb.db).
147
+ // This file is NEVER shared with the agentdb MCP server that server has its own
148
+ // .dz/agentdb-mcp.db, because two engines on one SQLite file is a measured corruption path
149
+ // (2026-07-09: 5 of 20 samples zero bytes, 4 torn). Real learnings go into the vector index via
150
+ // the agentdb_* MCP tools; this is deliberately non-semantic telemetry.
104
151
  // On SessionEnd AND PreCompact it ALSO fires a detached \`dz consolidate\` (Option C, ADR-003):
105
152
  // harvest this session's learnings into the lexical store and mirror them — with real embeddings —
106
153
  // into the shared AgentDB vector index. PreCompact is the RELIABLE periodic trigger: it fires on
@@ -117,7 +164,13 @@ import { spawn } from 'node:child_process';
117
164
  const arg = process.argv[2];
118
165
  const event = arg === 'end' ? 'end' : arg === 'precompact' ? 'precompact' : 'start';
119
166
  const ts = new Date().toISOString();
120
- const DB = process.env.AGENTDB_PATH || ${JSON.stringify(dbPath)};
167
+ // PINNED, deliberately NOT read from the ambient environment. Honouring an ambient store path re-opened the
168
+ // exact hole this feature closes: export AGENTDB_PATH=.dz/agentdb-mcp.db and the writer and the MCP
169
+ // server share ONE file again — two engines, one database — while the separation invariant still
170
+ // reports PASS, because it inspects the registration and not the writer's runtime resolution.
171
+ // Found by cross-family QE (Codex gpt-5.6-sol) against the first version of this fix. A store that
172
+ // needs to move is a REGENERATION (\`dz setup --memory agentdb\`), never an env override.
173
+ const DB = ${JSON.stringify(dbPath)};
121
174
  const SESSIONS = ${JSON.stringify(sessionsPath)};
122
175
  const ROOT = ${JSON.stringify(projectRoot)};
123
176
  const CONSOLIDATE_MARKER = ${JSON.stringify(markerPath)};
@@ -134,7 +187,7 @@ function fallback(err) {
134
187
 
135
188
  try {
136
189
  // Native better-sqlite3 ONLY (prebuilt; synchronous; WAL). Never the sql.js fallback — its
137
- // whole-file-in-memory persistence is unsafe against a concurrently-writing MCP server.
190
+ // whole-file-in-memory persistence is the mechanism that corrupted a shared store.
138
191
  // Records start/end AND the lightweight precompact row via the SAME insert path.
139
192
  const { default: Database } = await import('better-sqlite3');
140
193
  const db = new Database(DB);
@@ -263,16 +316,18 @@ function generateDzConfig(target: string, preset: string | undefined, backend: M
263
316
  },
264
317
  memory: {
265
318
  backend,
266
- // agentdb: the native SQLite vector store shared by the session-hook writer and the
267
- // agentdb MCP server (both pinned via AGENTDB_PATH). Real ReasoningBank patterns land here.
319
+ // agentdb: the native SQLite vector store the session-hook writer opens. The agentdb MCP
320
+ // server is pinned (via AGENTDB_PATH) to a SEPARATE file — see `mcpStorePath` below.
268
321
  path: backend === 'agentdb' ? '.dz/agentdb.db' : '.dz/sessions.jsonl',
269
322
  maxSizeMb: backend === 'agentdb' ? 100 : 10,
270
323
  agentdb: backend === 'agentdb' ? {
271
324
  learning: true,
272
325
  vectorDim: 384,
273
326
  mcpServer: 'agentdb',
274
- // Both the hook writer and the MCP server read/write THIS path (env AGENTDB_PATH).
327
+ // The hook writer's own file. The MCP server gets `mcpStorePath` (env AGENTDB_PATH)
328
+ // the two are deliberately different files (ADR-001, 2026-08-26).
275
329
  storePath: '.dz/agentdb.db',
330
+ mcpStorePath: '.dz/agentdb-mcp.db',
276
331
  embeddingModel: 'Xenova/paraphrase-multilingual-MiniLM-L12-v2',
277
332
  sessionHookWrites: true,
278
333
  } : undefined,
@@ -615,9 +670,10 @@ export function runSetup(opts: SetupOptions): SetupResult {
615
670
  // Pin to the INSTALLED agentdb version (not @latest) so the MCP server and the hook
616
671
  // writer run the same alpha schema against one DB.
617
672
  args: [installedAgentdbSpec(opts.projectRoot), 'mcp', 'start'],
618
- // Pin the store to the SAME native DB the session-hook writer targets, so hook telemetry
619
- // and agentdb_* pattern/reflexion data live in one shared store.
620
- env: { AGENTDB_PATH: agentdbStorePath(opts.projectRoot) },
673
+ // Pin the server to its OWN store NEVER the writer's .dz/agentdb.db. Two engines on one
674
+ // SQLite file (native better-sqlite3 + a silent sql.js fallback) whole-file-rewrite each
675
+ // other: measured 2026-07-09, 5 of 20 samples zero bytes and 4 torn (ADR-001, 2026-08-26).
676
+ env: { AGENTDB_PATH: agentdbMcpStorePath(opts.projectRoot) },
621
677
  };
622
678
  const mcpConfigPath = join(opts.projectRoot, '.mcp.json');
623
679
  try {
@@ -630,7 +686,13 @@ export function runSetup(opts: SetupOptions): SetupResult {
630
686
  mcpConfig.mcpServers = servers;
631
687
  if (before !== JSON.stringify(agentdbEntry)) {
632
688
  writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
633
- steps.push({ name: 'Register agentdb MCP', status: 'done', detail: '.mcp.json: 41 tools, store pinned to .dz/agentdb.db' });
689
+ steps.push({
690
+ name: 'Register agentdb MCP',
691
+ status: 'done',
692
+ // No tool count: a hardcoded number is a lie waiting to age (the live server answered
693
+ // 35 while its own banner said 32 and the README said 41 — measured 2026-08-26).
694
+ detail: `.mcp.json: ${installedAgentdbSpec(opts.projectRoot)} → .dz/agentdb-mcp.db (own store; hooks keep .dz/agentdb.db)`,
695
+ });
634
696
  } else {
635
697
  steps.push({ name: 'Register agentdb MCP', status: 'skipped', detail: 'already registered and current' });
636
698
  }
@@ -676,14 +738,14 @@ export function runSetup(opts: SetupOptions): SetupResult {
676
738
  const mcp = JSON.parse(readFileSync(join(opts.projectRoot, '.mcp.json'), 'utf-8')) as {
677
739
  mcpServers?: Record<string, { env?: Record<string, string> }>;
678
740
  };
679
- if (mcp.mcpServers?.['agentdb']?.env?.['AGENTDB_PATH'] !== agentdbStorePath(opts.projectRoot)) {
680
- problems.push('.mcp.json agentdb missing or not pinned to .dz/agentdb.db');
681
- }
741
+ // Separation is the REQUIRED state; a shared store is the error (inverted 2026-08-26).
742
+ const sep = agentdbStoreSeparationProblem(opts.projectRoot, mcp.mcpServers?.['agentdb']?.env?.['AGENTDB_PATH']);
743
+ if (sep) problems.push(sep);
682
744
  } catch {
683
745
  problems.push('.mcp.json unreadable');
684
746
  }
685
747
  steps.push(problems.length === 0
686
- ? { name: 'agentdb wiring', status: 'done', detail: 'hooks → writer → .dz/agentdb.db MCP (one shared store)' }
748
+ ? { name: 'agentdb wiring', status: 'done', detail: 'hooks → writer → .dz/agentdb.db · MCP .dz/agentdb-mcp.db (separate stores, by design)' }
687
749
  : { name: 'agentdb wiring', status: 'error', detail: `INCOMPLETE: ${problems.join('; ')}` });
688
750
  }
689
751
 
@@ -692,7 +754,9 @@ export function runSetup(opts: SetupOptions): SetupResult {
692
754
  // on the documented jsonl→agentdb `--force` switch, leaking the binary store into git.
693
755
  const gitignorePath = join(opts.projectRoot, '.gitignore');
694
756
  const dzIgnoreLines = backend === 'agentdb'
695
- ? ['.dz/agentdb.db', '.dz/agentdb.db-wal', '.dz/agentdb.db-shm', '.dz/sessions.jsonl']
757
+ ? ['.dz/agentdb.db', '.dz/agentdb.db-wal', '.dz/agentdb.db-shm',
758
+ '.dz/agentdb-mcp.db', '.dz/agentdb-mcp.db-wal', '.dz/agentdb-mcp.db-shm',
759
+ '.dz/sessions.jsonl']
696
760
  : ['.dz/sessions.jsonl', '.dz/patterns.jsonl'];
697
761
  const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
698
762
  const missing = dzIgnoreLines.filter((line) => !existing.split(/\r?\n/).includes(line));