@dzhechkov/harness-core 0.8.29 → 0.8.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +242 -102
- package/README.md +271 -0
- package/dist/agentdb-index.d.ts +45 -0
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +345 -85
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +170 -0
- package/dist/agentdb-reindex-marker.d.ts.map +1 -0
- package/dist/agentdb-reindex-marker.js +293 -0
- package/dist/agentdb-reindex-marker.js.map +1 -0
- package/dist/agentdb-snapshot-rotation.d.ts +169 -0
- package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
- package/dist/agentdb-snapshot-rotation.js +322 -0
- package/dist/agentdb-snapshot-rotation.js.map +1 -0
- package/dist/agentdb-snapshot.d.ts +126 -0
- package/dist/agentdb-snapshot.d.ts.map +1 -0
- package/dist/agentdb-snapshot.js +195 -0
- package/dist/agentdb-snapshot.js.map +1 -0
- package/dist/apply-leg.d.ts +202 -0
- package/dist/apply-leg.d.ts.map +1 -0
- package/dist/apply-leg.js +975 -0
- package/dist/apply-leg.js.map +1 -0
- package/dist/book-kb.d.ts.map +1 -1
- package/dist/book-kb.js +57 -1
- package/dist/book-kb.js.map +1 -1
- package/dist/brain.d.ts +3 -0
- package/dist/brain.d.ts.map +1 -1
- package/dist/brain.js +1 -1
- package/dist/brain.js.map +1 -1
- package/dist/cadence.d.ts +30 -1
- package/dist/cadence.d.ts.map +1 -1
- package/dist/cadence.js +66 -2
- package/dist/cadence.js.map +1 -1
- package/dist/guard.d.ts +14 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +36 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +23 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -5
- package/dist/index.js.map +1 -1
- package/dist/integration-probe-worker.d.ts.map +1 -1
- package/dist/integration-probe-worker.js +4 -6
- package/dist/integration-probe-worker.js.map +1 -1
- package/dist/integrations-verify.d.ts.map +1 -1
- package/dist/integrations-verify.js +3 -1
- package/dist/integrations-verify.js.map +1 -1
- package/dist/managed-hooks.d.ts +11 -2
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +33 -7
- package/dist/managed-hooks.js.map +1 -1
- package/dist/mutation-gate.d.ts +35 -1
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +49 -9
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +94 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +23 -4
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +21 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js.map +1 -1
- package/dist/repository-origin.d.ts +1 -1
- package/dist/repository-origin.d.ts.map +1 -1
- package/dist/repository-origin.js +7 -1
- package/dist/repository-origin.js.map +1 -1
- package/dist/round-exec.d.ts +41 -0
- package/dist/round-exec.d.ts.map +1 -0
- package/dist/round-exec.js +45 -0
- package/dist/round-exec.js.map +1 -0
- package/dist/round.d.ts +109 -0
- package/dist/round.d.ts.map +1 -0
- package/dist/round.js +148 -0
- package/dist/round.js.map +1 -0
- package/dist/run-records.d.ts +8 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +69 -3
- package/dist/run-records.js.map +1 -1
- package/dist/setup.d.ts +8 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +166 -3
- package/dist/setup.js.map +1 -1
- package/dist/sqlite-read-helpers.d.ts +60 -0
- package/dist/sqlite-read-helpers.d.ts.map +1 -0
- package/dist/sqlite-read-helpers.js +118 -0
- package/dist/sqlite-read-helpers.js.map +1 -0
- package/dist/statusline.d.ts +22 -3
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +43 -7
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +41 -3
- package/dist/store-counts.d.ts.map +1 -1
- package/dist/store-counts.js +305 -59
- package/dist/store-counts.js.map +1 -1
- package/dist/store-guard.d.ts +2 -2
- package/dist/store-guard.d.ts.map +1 -1
- package/dist/store-guard.js +6 -0
- package/dist/store-guard.js.map +1 -1
- package/dist/usage.d.ts +55 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +184 -33
- package/dist/usage.js.map +1 -1
- package/dist/vector-tier.d.ts +18 -1
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +26 -2
- package/dist/vector-tier.js.map +1 -1
- package/package.json +7 -6
- package/sbom.json +451 -101
- package/src/agentdb-index.ts +315 -32
- package/src/agentdb-reindex-marker.ts +330 -0
- package/src/agentdb-snapshot-rotation.ts +393 -0
- package/src/agentdb-snapshot.ts +222 -0
- package/src/apply-leg.ts +1046 -0
- package/src/book-kb.ts +64 -1
- package/src/brain.ts +11 -2
- package/src/cadence.ts +87 -2
- package/src/guard.ts +46 -0
- package/src/index.ts +58 -5
- package/src/integration-probe-worker.ts +4 -6
- package/src/integrations-verify.ts +3 -1
- package/src/managed-hooks.ts +32 -7
- package/src/mutation-gate.ts +87 -8
- package/src/operations.ts +89 -0
- package/src/patterns.ts +23 -4
- package/src/publish.ts +23 -0
- package/src/repository-origin.ts +7 -1
- package/src/round-exec.ts +85 -0
- package/src/round.ts +228 -0
- package/src/run-records.ts +77 -2
- package/src/setup.ts +198 -3
- package/src/sqlite-read-helpers.ts +128 -0
- package/src/statusline.ts +61 -9
- package/src/store-counts.ts +391 -61
- package/src/store-guard.ts +8 -2
- package/src/usage.ts +243 -29
- package/src/vector-tier.ts +45 -4
package/src/round.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A focused work round. The module owns decisions only: callers inject observed state, time,
|
|
3
|
+
* lesson ids, ledger writing/reading and pid liveness. In particular, this file never imports a
|
|
4
|
+
* filesystem or process API; the CLI owns `.dz/rounds/` and the witnessed ledger writer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ROUND_OUTCOMES = ['shipped', 'refuted', 'blocked', 'abandoned'] as const;
|
|
8
|
+
type RoundOutcome = typeof ROUND_OUTCOMES[number];
|
|
9
|
+
|
|
10
|
+
export interface RoundState {
|
|
11
|
+
readonly slug: string;
|
|
12
|
+
readonly round: number;
|
|
13
|
+
readonly topic: string;
|
|
14
|
+
readonly startedAt: string;
|
|
15
|
+
readonly pid: number;
|
|
16
|
+
readonly ownerKind: 'explicit' | 'parent' | 'exec' | 'run';
|
|
17
|
+
readonly ownerRun?: string;
|
|
18
|
+
readonly run?: string;
|
|
19
|
+
readonly recalled: readonly string[];
|
|
20
|
+
readonly execs?: readonly RoundExecState[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RoundExecState {
|
|
24
|
+
readonly startedAt: string;
|
|
25
|
+
readonly endedAt: string;
|
|
26
|
+
readonly exitCode: number | null;
|
|
27
|
+
readonly outcome: import('./round-exec.js').RoundExecOutcome;
|
|
28
|
+
readonly tokens: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Additive row shape accepted by the existing run-cost ledger readers. */
|
|
32
|
+
export interface RoundLedgerRow {
|
|
33
|
+
readonly slug: string;
|
|
34
|
+
readonly stage: 'round';
|
|
35
|
+
readonly tier: null;
|
|
36
|
+
readonly coder: string | null;
|
|
37
|
+
readonly reviewer: string | null;
|
|
38
|
+
readonly lead: null;
|
|
39
|
+
readonly minutes: number | null;
|
|
40
|
+
readonly agents: number | null;
|
|
41
|
+
readonly tokens: number | null;
|
|
42
|
+
readonly grade: null;
|
|
43
|
+
readonly outcome: RoundOutcome;
|
|
44
|
+
readonly reason: string | null;
|
|
45
|
+
readonly round: number;
|
|
46
|
+
readonly lessons: readonly string[];
|
|
47
|
+
readonly noNewKnowledge: string | null;
|
|
48
|
+
readonly note: string;
|
|
49
|
+
readonly date: null;
|
|
50
|
+
readonly costIn?: 'stages';
|
|
51
|
+
/** round-state-lock (lead edit after Codex re-review): identity of the state instance this row
|
|
52
|
+
* closes — lets a retried `close` detect its own earlier row regardless of the clock. */
|
|
53
|
+
readonly stateId?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type RoundRefusal = { readonly ok: false; readonly exit: 1 | 2; readonly reason: string };
|
|
57
|
+
|
|
58
|
+
function nonEmpty(value: unknown): value is string {
|
|
59
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validSlug(value: string): boolean {
|
|
63
|
+
return /^[a-z0-9][a-z0-9._-]*$/i.test(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validCount(value: number | undefined): boolean {
|
|
67
|
+
return value === undefined || (Number.isInteger(value) && value >= 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function openRound(input: {
|
|
71
|
+
readonly slug: string;
|
|
72
|
+
readonly round: number;
|
|
73
|
+
readonly topic: string;
|
|
74
|
+
readonly startedAt: string;
|
|
75
|
+
readonly ownerPid: number;
|
|
76
|
+
readonly ownerKind: 'explicit' | 'parent' | 'run';
|
|
77
|
+
readonly ownerRun?: string | undefined;
|
|
78
|
+
readonly run?: string | undefined;
|
|
79
|
+
readonly recalled: readonly string[];
|
|
80
|
+
readonly existing: RoundState | null;
|
|
81
|
+
readonly force: boolean;
|
|
82
|
+
readonly existingOwnerAlive: boolean | null;
|
|
83
|
+
readonly isRunAlive: (runId: string) => boolean | null;
|
|
84
|
+
}): { readonly ok: true; readonly state: RoundState; readonly archiveExisting: boolean } | RoundRefusal {
|
|
85
|
+
if (!validSlug(input.slug) || !Number.isInteger(input.round) || input.round < 1 || !nonEmpty(input.topic)) {
|
|
86
|
+
return { ok: false, exit: 2, reason: 'нужны безопасный --slug, положительный --round и непустой --topic' };
|
|
87
|
+
}
|
|
88
|
+
const runOwner = input.ownerKind === 'run';
|
|
89
|
+
if (!Number.isFinite(Date.parse(input.startedAt)) || !Number.isInteger(input.ownerPid)
|
|
90
|
+
|| (runOwner ? input.ownerPid !== 0 || !nonEmpty(input.ownerRun) : input.ownerPid < 1)
|
|
91
|
+
|| (!runOwner && input.ownerKind !== 'explicit' && input.ownerKind !== 'parent')) {
|
|
92
|
+
return { ok: false, exit: 2, reason: 'время начала или pid круга недопустимы' };
|
|
93
|
+
}
|
|
94
|
+
const state: RoundState = {
|
|
95
|
+
slug: input.slug,
|
|
96
|
+
round: input.round,
|
|
97
|
+
topic: input.topic.trim(),
|
|
98
|
+
startedAt: input.startedAt,
|
|
99
|
+
pid: input.ownerPid,
|
|
100
|
+
ownerKind: input.ownerKind,
|
|
101
|
+
...(runOwner ? { ownerRun: input.ownerRun!.trim() } : {}),
|
|
102
|
+
...(nonEmpty(input.run) ? { run: input.run.trim() } : {}),
|
|
103
|
+
recalled: [...input.recalled],
|
|
104
|
+
};
|
|
105
|
+
let existingOwnerAlive = input.existingOwnerAlive;
|
|
106
|
+
if (input.existing?.ownerKind === 'run' && input.force) {
|
|
107
|
+
try { existingOwnerAlive = nonEmpty(input.existing.ownerRun) ? input.isRunAlive(input.existing.ownerRun) : null; }
|
|
108
|
+
catch { existingOwnerAlive = null; }
|
|
109
|
+
}
|
|
110
|
+
if (input.existing !== null && input.force && existingOwnerAlive === false) {
|
|
111
|
+
return { ok: true, archiveExisting: true, state };
|
|
112
|
+
}
|
|
113
|
+
if (input.existing !== null && input.force) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
exit: 1,
|
|
117
|
+
reason: input.existing.ownerKind === 'run'
|
|
118
|
+
? `круг открыт живым прогоном ${input.existing.ownerRun ?? 'unknown'}, --force не перезаписывает живой круг`
|
|
119
|
+
: `круг открыт живым процессом ${input.existing.pid}, --force не перезаписывает живой круг`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (input.existing !== null) return { ok: false, exit: 1, reason: 'круг уже открыт' };
|
|
123
|
+
return { ok: true, archiveExisting: false, state };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function closeRound(input: {
|
|
127
|
+
readonly state: RoundState;
|
|
128
|
+
readonly outcome: string;
|
|
129
|
+
readonly reason?: string | undefined;
|
|
130
|
+
readonly lessons?: readonly string[];
|
|
131
|
+
readonly noNewKnowledge?: string | undefined;
|
|
132
|
+
readonly tokens?: number | undefined;
|
|
133
|
+
readonly agents?: number | undefined;
|
|
134
|
+
readonly coder?: string | undefined;
|
|
135
|
+
readonly reviewer?: string | undefined;
|
|
136
|
+
readonly note?: string | undefined;
|
|
137
|
+
readonly noCost?: boolean | undefined;
|
|
138
|
+
readonly closedAt: string;
|
|
139
|
+
readonly knownLessonIds: readonly string[];
|
|
140
|
+
readonly stateId?: string | undefined;
|
|
141
|
+
}, io: {
|
|
142
|
+
readonly writeLedger: (row: RoundLedgerRow) => unknown;
|
|
143
|
+
readonly readLedgerTail: () => string;
|
|
144
|
+
}): { readonly ok: true; readonly row: RoundLedgerRow; readonly marker: string } | RoundRefusal {
|
|
145
|
+
if (!(ROUND_OUTCOMES as readonly string[]).includes(input.outcome)) {
|
|
146
|
+
return { ok: false, exit: 2, reason: '--outcome: shipped | refuted | blocked | abandoned' };
|
|
147
|
+
}
|
|
148
|
+
if (!validCount(input.tokens) || !validCount(input.agents)) {
|
|
149
|
+
return { ok: false, exit: 2, reason: '--tokens и --agents должны быть целыми числами не меньше нуля' };
|
|
150
|
+
}
|
|
151
|
+
const lessons = [...new Set(input.lessons ?? [])];
|
|
152
|
+
const noNewKnowledge = input.noNewKnowledge?.trim() ?? '';
|
|
153
|
+
if (lessons.length === 0 && noNewKnowledge === '') {
|
|
154
|
+
return { ok: false, exit: 1, reason: 'урок не назван: передайте --lesson teach:<id> или --no-new-knowledge "<почему>"' };
|
|
155
|
+
}
|
|
156
|
+
if (lessons.length > 0 && noNewKnowledge !== '') {
|
|
157
|
+
return { ok: false, exit: 1, reason: '--lesson и --no-new-knowledge взаимоисключающие' };
|
|
158
|
+
}
|
|
159
|
+
const known = new Set(input.knownLessonIds);
|
|
160
|
+
const missing = lessons.find((id) => !/^teach:[a-z0-9]+$/i.test(id) || !known.has(id));
|
|
161
|
+
if (missing !== undefined) return { ok: false, exit: 1, reason: `урок не найден: ${missing}` };
|
|
162
|
+
|
|
163
|
+
const startedMs = Date.parse(input.state.startedAt);
|
|
164
|
+
const closedMs = Date.parse(input.closedAt);
|
|
165
|
+
if (!Number.isFinite(startedMs) || !Number.isFinite(closedMs) || closedMs < startedMs) {
|
|
166
|
+
return { ok: false, exit: 1, reason: 'длительность круга не установлена: время состояния недопустимо' };
|
|
167
|
+
}
|
|
168
|
+
const compactTs = new Date(closedMs).toISOString().replace(/[-:.]/g, '');
|
|
169
|
+
const marker = `round-${input.state.slug}-${input.state.round}-${compactTs}`;
|
|
170
|
+
const note = input.note?.trim() ?? '';
|
|
171
|
+
const row: RoundLedgerRow = {
|
|
172
|
+
slug: input.state.slug,
|
|
173
|
+
stage: 'round',
|
|
174
|
+
tier: null,
|
|
175
|
+
coder: nonEmpty(input.coder) ? input.coder.trim() : null,
|
|
176
|
+
reviewer: nonEmpty(input.reviewer) ? input.reviewer.trim() : null,
|
|
177
|
+
lead: null,
|
|
178
|
+
minutes: input.noCost === true ? null : Math.floor((closedMs - startedMs) / 60_000),
|
|
179
|
+
agents: input.noCost === true ? null : input.agents ?? null,
|
|
180
|
+
tokens: input.noCost === true ? null : input.tokens ?? null,
|
|
181
|
+
grade: null,
|
|
182
|
+
outcome: input.outcome as RoundOutcome,
|
|
183
|
+
reason: nonEmpty(input.reason) ? input.reason.trim() : null,
|
|
184
|
+
round: input.state.round,
|
|
185
|
+
lessons,
|
|
186
|
+
noNewKnowledge: noNewKnowledge === '' ? null : noNewKnowledge,
|
|
187
|
+
note: note === '' ? marker : `${marker} | ${note}`,
|
|
188
|
+
date: null,
|
|
189
|
+
...(input.noCost === true ? { costIn: 'stages' as const } : {}),
|
|
190
|
+
...(nonEmpty(input.stateId) ? { stateId: input.stateId } : {}),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
io.writeLedger(row);
|
|
195
|
+
} catch {
|
|
196
|
+
return { ok: false, exit: 1, reason: 'строка не найдена — круг НЕ закрыт (писатель отказал)' };
|
|
197
|
+
}
|
|
198
|
+
let tail = '';
|
|
199
|
+
try { tail = io.readLedgerTail(); } catch { /* unreadable is absence of a receipt */ }
|
|
200
|
+
if (!tail.includes(marker)) {
|
|
201
|
+
return { ok: false, exit: 1, reason: 'строка не найдена — круг НЕ закрыт' };
|
|
202
|
+
}
|
|
203
|
+
return { ok: true, row, marker };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function listRounds(states: readonly RoundState[], input: {
|
|
207
|
+
readonly now: number;
|
|
208
|
+
readonly olderThanMinutes: number;
|
|
209
|
+
readonly isPidAlive: (pid: number) => boolean | null;
|
|
210
|
+
readonly isRunAlive: (runId: string) => boolean | null;
|
|
211
|
+
}): Array<{ readonly state: RoundState; readonly ageMinutes: number; readonly pidAlive: boolean | null }> {
|
|
212
|
+
if (!Number.isFinite(input.now) || !Number.isFinite(input.olderThanMinutes) || input.olderThanMinutes < 0) return [];
|
|
213
|
+
const rows: Array<{ state: RoundState; ageMinutes: number; pidAlive: boolean | null }> = [];
|
|
214
|
+
for (const state of states) {
|
|
215
|
+
const startedMs = Date.parse(state.startedAt);
|
|
216
|
+
if (!Number.isFinite(startedMs)) continue;
|
|
217
|
+
const ageMinutes = Math.max(0, Math.floor((input.now - startedMs) / 60_000));
|
|
218
|
+
if (ageMinutes <= input.olderThanMinutes) continue;
|
|
219
|
+
let pidAlive: boolean | null = null;
|
|
220
|
+
try {
|
|
221
|
+
pidAlive = state.ownerKind === 'run'
|
|
222
|
+
? input.isRunAlive(state.ownerRun ?? '')
|
|
223
|
+
: input.isPidAlive(state.pid);
|
|
224
|
+
} catch { /* an unavailable probe is unknown */ }
|
|
225
|
+
rows.push({ state, ageMinutes, pidAlive });
|
|
226
|
+
}
|
|
227
|
+
return rows.sort((a, b) => b.ageMinutes - a.ageMinutes || a.state.slug.localeCompare(b.state.slug) || a.state.round - b.state.round);
|
|
228
|
+
}
|
package/src/run-records.ts
CHANGED
|
@@ -114,6 +114,14 @@ export function decideRecordWrite(input: {
|
|
|
114
114
|
targetHasPair?: boolean;
|
|
115
115
|
/** Stamped INTO the object before serialising — never rewritten in the shell afterwards (FR-7). */
|
|
116
116
|
timestamp?: string | null;
|
|
117
|
+
/**
|
|
118
|
+
* ledger-stage-minutes FR-2/FR-3: the `ts` of the LAST ledger row that shares this row's `runId`,
|
|
119
|
+
* found by the CALLER (the CLI reads the file; this function stays pure). Absent/null means "no
|
|
120
|
+
* such row, or it had no `ts`" — both collapse to the same honest `unavailable`, never a guess.
|
|
121
|
+
*/
|
|
122
|
+
previousRowTs?: string | null;
|
|
123
|
+
/** The runId this row WILL carry after write-time resolution, when the payload itself has none. */
|
|
124
|
+
effectiveRunId?: string | null;
|
|
117
125
|
/** Who ran it. Supplied by the CALLER, which lives outside the workflow sandbox and can see the
|
|
118
126
|
* host; absent stays absent (see the stamping comment below). */
|
|
119
127
|
runnerId?: string | null;
|
|
@@ -174,13 +182,12 @@ export function decideRecordWrite(input: {
|
|
|
174
182
|
// inside an already-serialised document — text surgery on a structured value, and the exact place
|
|
175
183
|
// a payload containing that literal token could corrupt itself.
|
|
176
184
|
const stamped: Record<string, unknown> = { ...obj };
|
|
185
|
+
const isGap = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
|
|
177
186
|
if (input.timestamp != null && input.timestamp !== '') {
|
|
178
187
|
// An EMPTY STRING is a gap, not a value. Stamping only over null/undefined let
|
|
179
188
|
// `"date":""` through as `written` (cross-family review, 2026-08-21) — a row that looks recorded
|
|
180
189
|
// and carries no date.
|
|
181
|
-
const isGap = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
|
|
182
190
|
if (kind === 'ledger' && isGap(stamped['date'])) stamped['date'] = input.timestamp.slice(0, 10);
|
|
183
|
-
if (kind === 'training-pair' && isGap(stamped['ts'])) stamped['ts'] = input.timestamp;
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
// WHO ran this. Stamped HERE and nowhere else, for a structural reason: the workflow lives in a
|
|
@@ -194,10 +201,78 @@ export function decideRecordWrite(input: {
|
|
|
194
201
|
// because a fabricated identity is worse than a missing one for anything that later joins on it.
|
|
195
202
|
// A blank supplied id is a gap too: `' '` sneaking in as a value would join later as a distinct
|
|
196
203
|
// runner made of spaces — the same class of harm as inventing 'unknown'.
|
|
204
|
+
//
|
|
205
|
+
// Stamped BEFORE `ts` below (fix-round-1/AM-n, cross-family review B): a runnerId this call itself
|
|
206
|
+
// adds is still an ESTABLISHED field, from the runnerId feature that shipped before
|
|
207
|
+
// ledger-stage-minutes — NFR-1's "new fields land after everything else" is a promise about the
|
|
208
|
+
// fields THIS feature introduces (`ts`, `minutesSincePrev`, `minutesSource`), not about the order
|
|
209
|
+
// decideRecordWrite happens to run its own blocks in. The original order stamped `ts` first, so a
|
|
210
|
+
// freshly-added runnerId landed AFTER it — an object key order a `--full-qe-extended` Codex review
|
|
211
|
+
// (grade B) caught by diffing `Object.keys` against the documented convention.
|
|
197
212
|
if (kind === 'ledger' && isRunnerGap(stamped['runnerId']) && !isRunnerGap(input.runnerId)) {
|
|
198
213
|
stamped['runnerId'] = (input.runnerId as string).trim();
|
|
199
214
|
}
|
|
200
215
|
|
|
216
|
+
if (input.timestamp != null && input.timestamp !== '') {
|
|
217
|
+
// FR-1 (ledger-stage-minutes): every ledger row also gets the FULL ISO instant it was recorded,
|
|
218
|
+
// next to `date` — `date` alone cannot answer "how long between two rows of this run", `ts` can.
|
|
219
|
+
//
|
|
220
|
+
// fix-round-1/AM-n (cross-family review B): `ts` is ALWAYS the instant of THIS write, never a
|
|
221
|
+
// value the payload happened to bring in — the delta below measures from `ts`, and a caller-
|
|
222
|
+
// supplied instant (stale, forged, or simply wrong) would silently become "now" for that
|
|
223
|
+
// measurement. The original `isGap` check let a non-empty payload `ts` survive untouched, which
|
|
224
|
+
// is exactly the value a clock-skewed or replayed payload could poison. No data is discarded: a
|
|
225
|
+
// real payload `ts` is kept, renamed to `payloadTs`, so the row still says what the caller claimed
|
|
226
|
+
// — just not under the name the delta trusts.
|
|
227
|
+
if (kind === 'ledger') {
|
|
228
|
+
const payloadTs = stamped['ts'];
|
|
229
|
+
// Lead edit after re-review (Codex B): never clobber a `payloadTs` the caller already carries,
|
|
230
|
+
// and re-insert `ts` so it lands LAST even when the payload brought its own `ts` key
|
|
231
|
+
// (assigning an existing property keeps its old insertion position).
|
|
232
|
+
if (!isGap(payloadTs) && isGap(stamped['payloadTs'])) stamped['payloadTs'] = payloadTs;
|
|
233
|
+
delete stamped['ts'];
|
|
234
|
+
stamped['ts'] = input.timestamp;
|
|
235
|
+
}
|
|
236
|
+
if (kind === 'training-pair' && isGap(stamped['ts'])) stamped['ts'] = input.timestamp;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// FR-2 (ledger-stage-minutes): the writer cannot measure a stage's full duration — the workflow
|
|
240
|
+
// sandbox has no clock (`Date.now()` is banned there for resume-safety) — but it DOES know the
|
|
241
|
+
// moment of every write and the run each write belongs to. For an `auto:true` row that carries a
|
|
242
|
+
// `runId`, the gap since the PREVIOUS row of the same run is a real, partial measurement, and it
|
|
243
|
+
// gets its own named field and source rather than being folded into (or mistaken for) `minutes`
|
|
244
|
+
// — "a claim exactly as strong as its inputs" (lesson, repeated 2026-08-25/2026-09-12): a partial
|
|
245
|
+
// quantity is reported as itself, tagged with where it came from, never smuggled into a field that
|
|
246
|
+
// implies the whole. `minutes` is left untouched by this block — it stays whatever the payload
|
|
247
|
+
// already carried (null for every auto row today).
|
|
248
|
+
if (kind === 'ledger') {
|
|
249
|
+
const runIdVal = stamped['runId'];
|
|
250
|
+
// Lead edit after re-review (Codex B): the pipeline's own rows carry NO runId in the payload —
|
|
251
|
+
// the CLI resolves it at write time (`resolved-at-write`) — so the caller may hand the resolved
|
|
252
|
+
// id in as `effectiveRunId`; the delta is measurable for those rows too.
|
|
253
|
+
const effectiveRunId = typeof input.effectiveRunId === 'string' && input.effectiveRunId.trim() !== '' ? input.effectiveRunId : null;
|
|
254
|
+
const hasRunId = (typeof runIdVal === 'string' && runIdVal.trim() !== '') || effectiveRunId !== null;
|
|
255
|
+
if (stamped['auto'] === true && hasRunId) {
|
|
256
|
+
const nowTs = typeof stamped['ts'] === 'string' && stamped['ts'].trim() !== '' ? stamped['ts'] : null;
|
|
257
|
+
const prevTs = typeof input.previousRowTs === 'string' && input.previousRowTs.trim() !== '' ? input.previousRowTs : null;
|
|
258
|
+
let minutesSincePrev: number | null = null;
|
|
259
|
+
let minutesSource: 'ledger-ts-delta' | 'unavailable' = 'unavailable';
|
|
260
|
+
if (nowTs !== null && prevTs !== null) {
|
|
261
|
+
const nowMs = Date.parse(nowTs);
|
|
262
|
+
const prevMs = Date.parse(prevTs);
|
|
263
|
+
// Absence of a receipt is not success: an unparseable timestamp or a previous row that is
|
|
264
|
+
// somehow LATER than this one (clock skew, out-of-order backfill) must not be reported as a
|
|
265
|
+
// measured value — it stays `unavailable`, never a fabricated or negative minute count.
|
|
266
|
+
if (Number.isFinite(nowMs) && Number.isFinite(prevMs) && nowMs >= prevMs) {
|
|
267
|
+
minutesSincePrev = Math.round(((nowMs - prevMs) / 60000) * 10) / 10;
|
|
268
|
+
minutesSource = 'ledger-ts-delta';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
stamped['minutesSincePrev'] = minutesSincePrev;
|
|
272
|
+
stamped['minutesSource'] = minutesSource;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
201
276
|
let line: string;
|
|
202
277
|
try {
|
|
203
278
|
line = JSON.stringify(stamped);
|
package/src/setup.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
20
|
-
import { basename, dirname, join } from 'node:path';
|
|
20
|
+
import { basename, dirname, isAbsolute, join, relative } from 'node:path';
|
|
21
21
|
import { execSync, spawnSync } from 'node:child_process';
|
|
22
22
|
|
|
23
23
|
import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
@@ -29,6 +29,17 @@ import {
|
|
|
29
29
|
isDzManagedHookBody,
|
|
30
30
|
} from './claude-hooks-assets.js';
|
|
31
31
|
import { applyIntegrationFragments, IntegrationApplyError } from './integration-apply.js';
|
|
32
|
+
import { harnessCoreDistDir } from './harness-core-location.js';
|
|
33
|
+
import { ensureAgentdbSchema } from './agentdb-index.js';
|
|
34
|
+
import {
|
|
35
|
+
APPLY_LEG_VERSION,
|
|
36
|
+
applyLegHookEntries,
|
|
37
|
+
applyLegVersionOf,
|
|
38
|
+
bakedCoreDistDirOf,
|
|
39
|
+
embedDaemonSource,
|
|
40
|
+
recallHookSource,
|
|
41
|
+
hookCommandInvokes,
|
|
42
|
+
} from './apply-leg.js';
|
|
32
43
|
|
|
33
44
|
/** Memory backend type. */
|
|
34
45
|
export type MemoryBackend = 'jsonl' | 'agentdb';
|
|
@@ -45,6 +56,14 @@ export interface SetupOptions {
|
|
|
45
56
|
readonly force?: boolean | undefined;
|
|
46
57
|
/** Also deploy the operating-instructions "driver" skill + agent docs. */
|
|
47
58
|
readonly installDriver?: boolean | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Absolute directory holding the BUILT harness-core modules, baked into the generated apply-leg
|
|
61
|
+
* hooks as the first resolve candidate (ADR-001 Decision 2, feature `setup-installs-apply-leg`).
|
|
62
|
+
* The CLI computes this from ITS OWN `@dzhechkov/harness-core` resolution (the installation
|
|
63
|
+
* actually running `dz setup`); when core's own `runSetup` is called without it (e.g. a direct
|
|
64
|
+
* programmatic call, or a test), it falls back to resolving ITSELF via {@link harnessCoreDistDir}.
|
|
65
|
+
*/
|
|
66
|
+
readonly coreDistDir?: string | undefined;
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
/** Setup result. */
|
|
@@ -384,8 +403,13 @@ export function generateHooksConfig(projectRoot: string, backend: MemoryBackend)
|
|
|
384
403
|
// Use a RELATIVE path (Claude Code runs hooks from the project root): interpolating the absolute
|
|
385
404
|
// ${dzDir} into a single-quoted JS literal inside shell double-quotes breaks on Windows backslash
|
|
386
405
|
// paths (\U, \b…) and on any path containing a quote. `.dz/sessions.jsonl` sidesteps all of it.
|
|
406
|
+
// AM-5 (dz-harness-hub issue #10 defect 5): `mkdirSync('.dz',{recursive:true})` FIRST —
|
|
407
|
+
// `appendFileSync` throws ENOENT when `.dz/` has been removed (a fresh checkout with `.dz`
|
|
408
|
+
// gitignored, or a user who deleted it) or when custom `settings.json` runs hooks from a cwd
|
|
409
|
+
// where the directory was never created; `mkdirSync` with `recursive:true` is a no-op when the
|
|
410
|
+
// directory already exists, so this is free on the common path.
|
|
387
411
|
const jsonlCmd = (event: 'start' | 'end' | 'precompact'): string =>
|
|
388
|
-
`node -e "const fs=require('fs');const d=new Date().toISOString();fs.appendFileSync('.dz/sessions.jsonl',JSON.stringify({event:'${event}',ts:d,backend:'jsonl'})+'\\n')"`;
|
|
412
|
+
`node -e "const fs=require('fs');fs.mkdirSync('.dz',{recursive:true});const d=new Date().toISOString();fs.appendFileSync('.dz/sessions.jsonl',JSON.stringify({event:'${event}',ts:d,backend:'jsonl'})+'\\n')"`;
|
|
389
413
|
// Matcher-less PreCompact mirrors the agentdb backend so long-session bookkeeping stays reliable
|
|
390
414
|
// even without a vector store (jsonl has no consolidator — this is just an honest marker row).
|
|
391
415
|
return JSON.stringify({
|
|
@@ -485,7 +509,14 @@ function installAgentdbLocally(projectRoot: string): boolean {
|
|
|
485
509
|
// 'pipe') avoids execSync's 1 MB maxBuffer aborting the child on npm's verbose output.
|
|
486
510
|
// --save-exact: agentdb is alpha; a semver range would let a later `npm update` drift the
|
|
487
511
|
// local copy away from the version the MCP registration pins (audit gap G7).
|
|
488
|
-
|
|
512
|
+
//
|
|
513
|
+
// better-sqlite3@^11 (AM-2, dz-harness-hub issue #10 defect 1, MEASURED Node 20.20.2 with no
|
|
514
|
+
// `make` on PATH): an unpinned `npm install better-sqlite3` resolved 12.11.1, which ships no
|
|
515
|
+
// prebuilt binary for Node 20's ABI 115 — the install fell through to a node-gyp source build
|
|
516
|
+
// and failed on a machine with no C toolchain. `agentdb` itself requests `^11.8.1`, which DOES
|
|
517
|
+
// publish an ABI-115 prebuild, so pinning the range here costs nothing agentdb wasn't already
|
|
518
|
+
// going to resolve to, and buys a working install on a bare Node 20/22 host.
|
|
519
|
+
execSync('npm install agentdb better-sqlite3@^11 --save-exact --no-audit --no-fund --loglevel=error', {
|
|
489
520
|
cwd: projectRoot,
|
|
490
521
|
stdio: 'ignore',
|
|
491
522
|
timeout: 300000,
|
|
@@ -623,6 +654,158 @@ function installDriverDocs(projectRoot: string, force: boolean): string {
|
|
|
623
654
|
return parts.join('; ') || 'no changes';
|
|
624
655
|
}
|
|
625
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Install the apply-leg (recall hook + embed daemon) — ADR-001 Decision 1, feature
|
|
659
|
+
* `setup-installs-apply-leg`. The third self-learning leg (COLLECT/RANK are Steps 4/2 of
|
|
660
|
+
* `runSetup`; APPLY is this one) lived only as hand-committed files in this hub's OWN
|
|
661
|
+
* `.claude/helpers/` — a consumer's `dz setup --memory agentdb` wrote session hooks and a memory
|
|
662
|
+
* store but never a `UserPromptSubmit` recall hook at all (00_complexity_assessment.md, MEASURED
|
|
663
|
+
* 2026-09-12).
|
|
664
|
+
*
|
|
665
|
+
* WHY THIS RUNS ITS WORK BEFORE "Configure hooks", even though the STEP is reported after it
|
|
666
|
+
* (`runSetup` calls this first, then pushes the returned step once "Configure hooks" has run).
|
|
667
|
+
* "Configure hooks" owns SessionStart too (the session-hook writer's own entry) via
|
|
668
|
+
* `mergeManagedHookEntries`'s drop-its-own-managed-entries/reappend-at-tail algorithm — a call that
|
|
669
|
+
* is perfectly stable in isolation, but which REORDERS a genuinely foreign SessionStart entry
|
|
670
|
+
* relative to its own the FIRST time one coexists (kept-foreign-entries-in-place, then append fresh
|
|
671
|
+
* own at the tail — stable only once the foreign entry is already positioned before it). Running
|
|
672
|
+
* this step's ADDITIVE-ONLY write first establishes that stable [foreign, own] layout on the VERY
|
|
673
|
+
* FIRST run, so "Configure hooks" never has anything to reorder on any later run — MEASURED: with
|
|
674
|
+
* the write ordered the other way, a repeat `runSetup` flips `SessionStart`'s two entries back and
|
|
675
|
+
* forth forever and neither step ever reports `skipped`, breaking the pre-existing
|
|
676
|
+
* `setup.test.ts` "PreCompact merge is idempotent" contract (FR-6) this feature must not touch.
|
|
677
|
+
*
|
|
678
|
+
* ADDITIVE-ONLY, deliberately NOT `mergeManagedHookEntries`: this step never needs to REPLACE a
|
|
679
|
+
* stale command text (the two commands `applyLegHookEntries()` emits do not change without an
|
|
680
|
+
* `APPLY_LEG_VERSION` bump, and a version bump is about the FILE content, not the hook command) —
|
|
681
|
+
* it only needs "is our command already referenced under this event, anywhere, in any position?".
|
|
682
|
+
* That question is order-independent, so it can never itself be a source of reordering, and it is
|
|
683
|
+
* exactly what keeps "Configure hooks" stable once the first run has established the layout above.
|
|
684
|
+
*/
|
|
685
|
+
function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupStep {
|
|
686
|
+
if (opts.noHooks) return { name: 'Install apply-leg', status: 'skipped', detail: '--no-hooks' };
|
|
687
|
+
if (backend !== 'agentdb') {
|
|
688
|
+
return {
|
|
689
|
+
name: 'Install apply-leg',
|
|
690
|
+
status: 'skipped',
|
|
691
|
+
detail: "apply-leg needs --memory agentdb (embed daemon requires agentdb's transformers)",
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
const coreDistDir = opts.coreDistDir ?? harnessCoreDistDir();
|
|
696
|
+
// Re-review Codex (B) finding: the hub's own portable `null` marker must survive an ordinary
|
|
697
|
+
// `dz setup` run INSIDE the hub — when harness-core resolves to a path inside THIS project's
|
|
698
|
+
// `packages/@dzhechkov/harness-core`, the checkout is the monorepo itself and the helper is
|
|
699
|
+
// baked portable (`null` → runtime `<project>/packages/...` candidate), never an absolute path
|
|
700
|
+
// that would dirty the committed twin and break the twins test in any other clone.
|
|
701
|
+
const monorepoCoreDist = join(opts.projectRoot, 'packages', '@dzhechkov', 'harness-core', 'dist');
|
|
702
|
+
const relToMonorepo = relative(monorepoCoreDist, coreDistDir);
|
|
703
|
+
const insideMonorepo = relToMonorepo === '' || (!relToMonorepo.startsWith('..') && !isAbsolute(relToMonorepo));
|
|
704
|
+
const bakeTarget: string | null = insideMonorepo ? null : coreDistDir;
|
|
705
|
+
const helpersDir = join(opts.projectRoot, '.claude', 'helpers');
|
|
706
|
+
const recallHookPath = join(helpersDir, 'recall-hook.cjs');
|
|
707
|
+
const embedDaemonPath = join(helpersDir, 'dz-embed-daemon.mjs');
|
|
708
|
+
const settingsPath = join(opts.projectRoot, '.claude', 'settings.json');
|
|
709
|
+
|
|
710
|
+
const deployedRecallContent = existsSync(recallHookPath) ? readFileSync(recallHookPath, 'utf-8') : undefined;
|
|
711
|
+
const deployedEmbedContent = existsSync(embedDaemonPath) ? readFileSync(embedDaemonPath, 'utf-8') : undefined;
|
|
712
|
+
const deployedRecallVersion = deployedRecallContent !== undefined ? applyLegVersionOf(deployedRecallContent) : -1;
|
|
713
|
+
const deployedEmbedVersion = deployedEmbedContent !== undefined ? applyLegVersionOf(deployedEmbedContent) : -1;
|
|
714
|
+
// MEDIUM finding "переезд ядра" (fix round 1): a version-only staleness check misses the case
|
|
715
|
+
// where npm/nvm RELOCATED the installed harness-core without any template change — the deployed
|
|
716
|
+
// file still stamps the current APPLY_LEG_VERSION, but its baked `CORE_DIST_DIR` now points at a
|
|
717
|
+
// path that no longer exists, and `loadCoreModule` degrades to permanent silence rather than an
|
|
718
|
+
// error nothing else would ever surface. Comparing the BAKED path against the CURRENT one closes
|
|
719
|
+
// that gap independently of the version stamp.
|
|
720
|
+
const deployedCoreDistDir = deployedRecallContent !== undefined ? bakedCoreDistDirOf(deployedRecallContent) : undefined;
|
|
721
|
+
// Re-review Codex (B) finding: a path-staleness rewrite must never DOWNGRADE a helper that a
|
|
722
|
+
// newer CLI already deployed — only a file at or below the current version is ours to rewrite.
|
|
723
|
+
const recallDistDirStale = deployedRecallContent !== undefined
|
|
724
|
+
&& deployedRecallVersion <= APPLY_LEG_VERSION
|
|
725
|
+
&& deployedCoreDistDir !== bakeTarget;
|
|
726
|
+
let wroteHelpers = false;
|
|
727
|
+
if (deployedRecallVersion === -1 || opts.force || deployedRecallVersion < APPLY_LEG_VERSION || recallDistDirStale) {
|
|
728
|
+
mkdirSync(helpersDir, { recursive: true });
|
|
729
|
+
writeFileSync(recallHookPath, recallHookSource(bakeTarget), { mode: 0o755 });
|
|
730
|
+
wroteHelpers = true;
|
|
731
|
+
}
|
|
732
|
+
if (deployedEmbedVersion === -1 || opts.force || deployedEmbedVersion < APPLY_LEG_VERSION) {
|
|
733
|
+
mkdirSync(helpersDir, { recursive: true });
|
|
734
|
+
writeFileSync(embedDaemonPath, embedDaemonSource(), { mode: 0o755 });
|
|
735
|
+
wroteHelpers = true;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ADD-IF-MISSING, per event: FR-2's literal contract — "ours is added only if no command of
|
|
739
|
+
// the event already contains OUR entry". Never removes or reorders an existing entry (foreign
|
|
740
|
+
// OR our own) — see the WHY above for why that matters here.
|
|
741
|
+
//
|
|
742
|
+
// MEDIUM finding "совпадение подстроки в чужой команде" (fix round 1): the substring probe used
|
|
743
|
+
// to be the bare filename (`recall-hook.cjs`), so a foreign command that merely MENTIONS the
|
|
744
|
+
// filename (e.g. `echo recall-hook.cjs`) was indistinguishable from our own entry and silently
|
|
745
|
+
// blocked ours from ever being added. "Ours is already present" now means either an EXACT match
|
|
746
|
+
// of the command we would emit, or the command containing our full relative PATH
|
|
747
|
+
// (`.claude/helpers/<file>`, the same marker `applyLegStatus` structurally looks for) — a bare
|
|
748
|
+
// filename mention under any other wrapper text no longer counts.
|
|
749
|
+
const entries = applyLegHookEntries();
|
|
750
|
+
const existingSettings = existsSync(settingsPath)
|
|
751
|
+
? (JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>)
|
|
752
|
+
: {};
|
|
753
|
+
const hooks = { ...((existingSettings['hooks'] ?? {}) as Record<string, unknown[]>) };
|
|
754
|
+
let hooksAdded = false;
|
|
755
|
+
const addIfMissing = (event: string, ownCommand: string, markerPath: string, entry: unknown): void => {
|
|
756
|
+
const current = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
757
|
+
const alreadyPresent = current.some((e) =>
|
|
758
|
+
commandsOf(e).some((cmd) => cmd === ownCommand || hookCommandInvokes(cmd, markerPath)),
|
|
759
|
+
);
|
|
760
|
+
if (alreadyPresent) return;
|
|
761
|
+
hooks[event] = [...current, entry];
|
|
762
|
+
hooksAdded = true;
|
|
763
|
+
};
|
|
764
|
+
addIfMissing(
|
|
765
|
+
'UserPromptSubmit',
|
|
766
|
+
entries.userPromptSubmit.hooks[0]?.command ?? '',
|
|
767
|
+
'.claude/helpers/recall-hook.cjs',
|
|
768
|
+
entries.userPromptSubmit,
|
|
769
|
+
);
|
|
770
|
+
addIfMissing(
|
|
771
|
+
'SessionStart',
|
|
772
|
+
entries.sessionStart.hooks[0]?.command ?? '',
|
|
773
|
+
'.claude/helpers/dz-embed-daemon.mjs',
|
|
774
|
+
entries.sessionStart,
|
|
775
|
+
);
|
|
776
|
+
if (hooksAdded) {
|
|
777
|
+
existingSettings['hooks'] = hooks;
|
|
778
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
779
|
+
writeFileSync(settingsPath, JSON.stringify(existingSettings, null, 2));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// AM-4 (dz-harness-hub issue #10 defect 4): create the empty AgentDB-schema store now, so a
|
|
783
|
+
// lesson taught before the first session's SessionEnd/PreCompact writer has ever run still has
|
|
784
|
+
// somewhere to mirror into — see `ensureAgentdbSchema`'s own doc for the full mechanism. Never
|
|
785
|
+
// touches an EXISTING store (never re-opens a populated db on every routine re-run); "creates a
|
|
786
|
+
// store" is a claim about a store that did not exist.
|
|
787
|
+
const dbPath = join(opts.projectRoot, '.dz', 'agentdb.db');
|
|
788
|
+
let schemaDetail = '';
|
|
789
|
+
if (!existsSync(dbPath)) {
|
|
790
|
+
const schemaResult = ensureAgentdbSchema(opts.projectRoot);
|
|
791
|
+
schemaDetail = schemaResult.ok ? '; empty agentdb.db created' : `; agentdb.db NOT created (${schemaResult.error ?? 'unknown error'})`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const changed = wroteHelpers || hooksAdded;
|
|
795
|
+
return {
|
|
796
|
+
name: 'Install apply-leg',
|
|
797
|
+
status: changed ? 'done' : 'skipped',
|
|
798
|
+
detail: (changed ? `Apply-leg: installed v${APPLY_LEG_VERSION}` : `Apply-leg: current (v${APPLY_LEG_VERSION})`) + schemaDetail,
|
|
799
|
+
};
|
|
800
|
+
} catch (err) {
|
|
801
|
+
return {
|
|
802
|
+
name: 'Install apply-leg',
|
|
803
|
+
status: 'error',
|
|
804
|
+
detail: `could not install apply-leg: ${err instanceof Error ? err.message : String(err)}`,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
626
809
|
export function runSetup(opts: SetupOptions): SetupResult {
|
|
627
810
|
const steps: SetupStep[] = [];
|
|
628
811
|
const dzDir = join(opts.projectRoot, '.dz');
|
|
@@ -712,6 +895,13 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
712
895
|
}
|
|
713
896
|
}
|
|
714
897
|
|
|
898
|
+
// Step 4.6: Install apply-leg — the WORK happens here (before "Configure hooks" writes
|
|
899
|
+
// SessionStart), so a foreign SessionStart entry is already in place before that step's own
|
|
900
|
+
// merge ever sees it; see `applyLegStepResult`'s doc for why order matters. The STEP is reported
|
|
901
|
+
// further down, after "Configure hooks" pushes its own, so the printed order still reads as
|
|
902
|
+
// "collect → rank → apply".
|
|
903
|
+
const applyLegStep = applyLegStepResult(opts, backend);
|
|
904
|
+
|
|
715
905
|
// Step 5: Configure hooks (write to .claude/settings.json) — EVENT-LEVEL merge (gap G2):
|
|
716
906
|
// dz-generated entries (recognized by signature, incl. the broken legacy `agentdb add` hooks
|
|
717
907
|
// this feature fixes) are replaced in place WITHOUT --force; the user's own hooks and every
|
|
@@ -897,6 +1087,11 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
897
1087
|
steps.push({ name: 'Configure hooks', status: 'skipped', detail: '--no-hooks' });
|
|
898
1088
|
}
|
|
899
1089
|
|
|
1090
|
+
// Step 5.6: Install apply-leg — report pushed AFTER "Configure hooks" below (for a report order
|
|
1091
|
+
// that reads naturally), but see `applyLegStepResult()` above `runSetup` for why the WRITE itself
|
|
1092
|
+
// happens BEFORE it.
|
|
1093
|
+
steps.push(applyLegStep);
|
|
1094
|
+
|
|
900
1095
|
// Step 5.5: Register agentdb MCP through the SAME ownership-aware transaction used by `dz init`.
|
|
901
1096
|
// `.mcp.json` is the project-scope carrier Claude Code actually loads. A known historical dz
|
|
902
1097
|
// agentdb shape is adopted; an ambiguous hand-authored entry is preserved and named as an error.
|