@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.
- package/.dz-manifest.json +210 -70
- package/README.md +34 -1
- package/dist/book-kb.d.ts.map +1 -1
- package/dist/book-kb.js +17 -0
- package/dist/book-kb.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +61 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +116 -2
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/mutation-gate.d.ts +31 -8
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +57 -16
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +11 -9
- package/dist/named-lock.js.map +1 -1
- package/dist/native-dep-probe.d.ts +11 -0
- package/dist/native-dep-probe.d.ts.map +1 -0
- package/dist/native-dep-probe.js +100 -0
- package/dist/native-dep-probe.js.map +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -1
- package/dist/operations.js.map +1 -1
- package/dist/parser-safe-region.d.ts +47 -0
- package/dist/parser-safe-region.d.ts.map +1 -0
- package/dist/parser-safe-region.js +47 -0
- package/dist/parser-safe-region.js.map +1 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -1
- package/dist/patterns.js.map +1 -1
- package/dist/profile.d.ts +202 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +536 -0
- package/dist/profile.js.map +1 -0
- package/dist/publish.d.ts +5 -3
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +46 -8
- package/dist/publish.js.map +1 -1
- package/dist/qe-rounds.d.ts +75 -0
- package/dist/qe-rounds.d.ts.map +1 -0
- package/dist/qe-rounds.js +174 -0
- package/dist/qe-rounds.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +5 -1
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +8 -1
- package/dist/run-records.js.map +1 -1
- package/dist/setup.d.ts +35 -4
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +86 -23
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts +29 -24
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +183 -61
- package/dist/sign.js.map +1 -1
- package/dist/store-location.d.ts +56 -0
- package/dist/store-location.d.ts.map +1 -0
- package/dist/store-location.js +56 -0
- package/dist/store-location.js.map +1 -0
- package/dist/store-lock.d.ts +1 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +7 -1
- package/dist/store-lock.js.map +1 -1
- package/dist/store-merge.d.ts +55 -0
- package/dist/store-merge.d.ts.map +1 -0
- package/dist/store-merge.js +77 -0
- package/dist/store-merge.js.map +1 -0
- package/dist/teach-target.d.ts +61 -0
- package/dist/teach-target.d.ts.map +1 -0
- package/dist/teach-target.js +105 -0
- package/dist/teach-target.js.map +1 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +63 -20
- package/dist/vector-tier.js.map +1 -1
- package/package.json +13 -13
- package/sbom.json +424 -70
- package/src/book-kb.ts +17 -0
- package/src/feature-adr-checkpoints.ts +111 -2
- package/src/index.ts +39 -1
- package/src/loop-blobs.generated.ts +2 -2
- package/src/mutation-gate.ts +74 -17
- package/src/named-lock.ts +10 -8
- package/src/native-dep-probe.ts +118 -0
- package/src/operations.ts +77 -1
- package/src/parser-safe-region.ts +65 -0
- package/src/patterns.ts +10 -1
- package/src/profile.ts +593 -0
- package/src/publish.ts +43 -7
- package/src/qe-rounds.ts +226 -0
- package/src/registry.ts +5 -1
- package/src/run-records.ts +9 -1
- package/src/setup.ts +87 -23
- package/src/sign.ts +180 -60
- package/src/store-location.ts +76 -0
- package/src/store-lock.ts +8 -1
- package/src/store-merge.ts +91 -0
- package/src/teach-target.ts +126 -0
- package/src/vector-tier.ts +58 -9
package/src/qe-rounds.ts
ADDED
|
@@ -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
|
package/src/run-records.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
57
|
-
|
|
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 =
|
|
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
|
|
83
|
-
* server is pinned to
|
|
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
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
267
|
-
//
|
|
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
|
-
//
|
|
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
|
|
619
|
-
//
|
|
620
|
-
|
|
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({
|
|
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
|
-
|
|
680
|
-
|
|
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
|
|
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',
|
|
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));
|