@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.
Files changed (138) hide show
  1. package/.dz-manifest.json +242 -102
  2. package/README.md +271 -0
  3. package/dist/agentdb-index.d.ts +45 -0
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +345 -85
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +170 -0
  8. package/dist/agentdb-reindex-marker.d.ts.map +1 -0
  9. package/dist/agentdb-reindex-marker.js +293 -0
  10. package/dist/agentdb-reindex-marker.js.map +1 -0
  11. package/dist/agentdb-snapshot-rotation.d.ts +169 -0
  12. package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
  13. package/dist/agentdb-snapshot-rotation.js +322 -0
  14. package/dist/agentdb-snapshot-rotation.js.map +1 -0
  15. package/dist/agentdb-snapshot.d.ts +126 -0
  16. package/dist/agentdb-snapshot.d.ts.map +1 -0
  17. package/dist/agentdb-snapshot.js +195 -0
  18. package/dist/agentdb-snapshot.js.map +1 -0
  19. package/dist/apply-leg.d.ts +202 -0
  20. package/dist/apply-leg.d.ts.map +1 -0
  21. package/dist/apply-leg.js +975 -0
  22. package/dist/apply-leg.js.map +1 -0
  23. package/dist/book-kb.d.ts.map +1 -1
  24. package/dist/book-kb.js +57 -1
  25. package/dist/book-kb.js.map +1 -1
  26. package/dist/brain.d.ts +3 -0
  27. package/dist/brain.d.ts.map +1 -1
  28. package/dist/brain.js +1 -1
  29. package/dist/brain.js.map +1 -1
  30. package/dist/cadence.d.ts +30 -1
  31. package/dist/cadence.d.ts.map +1 -1
  32. package/dist/cadence.js +66 -2
  33. package/dist/cadence.js.map +1 -1
  34. package/dist/guard.d.ts +14 -0
  35. package/dist/guard.d.ts.map +1 -1
  36. package/dist/guard.js +36 -0
  37. package/dist/guard.js.map +1 -1
  38. package/dist/index.d.ts +23 -8
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +18 -5
  41. package/dist/index.js.map +1 -1
  42. package/dist/integration-probe-worker.d.ts.map +1 -1
  43. package/dist/integration-probe-worker.js +4 -6
  44. package/dist/integration-probe-worker.js.map +1 -1
  45. package/dist/integrations-verify.d.ts.map +1 -1
  46. package/dist/integrations-verify.js +3 -1
  47. package/dist/integrations-verify.js.map +1 -1
  48. package/dist/managed-hooks.d.ts +11 -2
  49. package/dist/managed-hooks.d.ts.map +1 -1
  50. package/dist/managed-hooks.js +33 -7
  51. package/dist/managed-hooks.js.map +1 -1
  52. package/dist/mutation-gate.d.ts +35 -1
  53. package/dist/mutation-gate.d.ts.map +1 -1
  54. package/dist/mutation-gate.js +49 -9
  55. package/dist/mutation-gate.js.map +1 -1
  56. package/dist/operations.d.ts.map +1 -1
  57. package/dist/operations.js +94 -0
  58. package/dist/operations.js.map +1 -1
  59. package/dist/patterns.d.ts.map +1 -1
  60. package/dist/patterns.js +23 -4
  61. package/dist/patterns.js.map +1 -1
  62. package/dist/publish.d.ts +21 -0
  63. package/dist/publish.d.ts.map +1 -1
  64. package/dist/publish.js.map +1 -1
  65. package/dist/repository-origin.d.ts +1 -1
  66. package/dist/repository-origin.d.ts.map +1 -1
  67. package/dist/repository-origin.js +7 -1
  68. package/dist/repository-origin.js.map +1 -1
  69. package/dist/round-exec.d.ts +41 -0
  70. package/dist/round-exec.d.ts.map +1 -0
  71. package/dist/round-exec.js +45 -0
  72. package/dist/round-exec.js.map +1 -0
  73. package/dist/round.d.ts +109 -0
  74. package/dist/round.d.ts.map +1 -0
  75. package/dist/round.js +148 -0
  76. package/dist/round.js.map +1 -0
  77. package/dist/run-records.d.ts +8 -0
  78. package/dist/run-records.d.ts.map +1 -1
  79. package/dist/run-records.js +69 -3
  80. package/dist/run-records.js.map +1 -1
  81. package/dist/setup.d.ts +8 -0
  82. package/dist/setup.d.ts.map +1 -1
  83. package/dist/setup.js +166 -3
  84. package/dist/setup.js.map +1 -1
  85. package/dist/sqlite-read-helpers.d.ts +60 -0
  86. package/dist/sqlite-read-helpers.d.ts.map +1 -0
  87. package/dist/sqlite-read-helpers.js +118 -0
  88. package/dist/sqlite-read-helpers.js.map +1 -0
  89. package/dist/statusline.d.ts +22 -3
  90. package/dist/statusline.d.ts.map +1 -1
  91. package/dist/statusline.js +43 -7
  92. package/dist/statusline.js.map +1 -1
  93. package/dist/store-counts.d.ts +41 -3
  94. package/dist/store-counts.d.ts.map +1 -1
  95. package/dist/store-counts.js +305 -59
  96. package/dist/store-counts.js.map +1 -1
  97. package/dist/store-guard.d.ts +2 -2
  98. package/dist/store-guard.d.ts.map +1 -1
  99. package/dist/store-guard.js +6 -0
  100. package/dist/store-guard.js.map +1 -1
  101. package/dist/usage.d.ts +55 -0
  102. package/dist/usage.d.ts.map +1 -1
  103. package/dist/usage.js +184 -33
  104. package/dist/usage.js.map +1 -1
  105. package/dist/vector-tier.d.ts +18 -1
  106. package/dist/vector-tier.d.ts.map +1 -1
  107. package/dist/vector-tier.js +26 -2
  108. package/dist/vector-tier.js.map +1 -1
  109. package/package.json +7 -6
  110. package/sbom.json +451 -101
  111. package/src/agentdb-index.ts +315 -32
  112. package/src/agentdb-reindex-marker.ts +330 -0
  113. package/src/agentdb-snapshot-rotation.ts +393 -0
  114. package/src/agentdb-snapshot.ts +222 -0
  115. package/src/apply-leg.ts +1046 -0
  116. package/src/book-kb.ts +64 -1
  117. package/src/brain.ts +11 -2
  118. package/src/cadence.ts +87 -2
  119. package/src/guard.ts +46 -0
  120. package/src/index.ts +58 -5
  121. package/src/integration-probe-worker.ts +4 -6
  122. package/src/integrations-verify.ts +3 -1
  123. package/src/managed-hooks.ts +32 -7
  124. package/src/mutation-gate.ts +87 -8
  125. package/src/operations.ts +89 -0
  126. package/src/patterns.ts +23 -4
  127. package/src/publish.ts +23 -0
  128. package/src/repository-origin.ts +7 -1
  129. package/src/round-exec.ts +85 -0
  130. package/src/round.ts +228 -0
  131. package/src/run-records.ts +77 -2
  132. package/src/setup.ts +198 -3
  133. package/src/sqlite-read-helpers.ts +128 -0
  134. package/src/statusline.ts +61 -9
  135. package/src/store-counts.ts +391 -61
  136. package/src/store-guard.ts +8 -2
  137. package/src/usage.ts +243 -29
  138. 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
+ }
@@ -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
- execSync('npm install agentdb better-sqlite3 --save-exact --no-audit --no-fund --loglevel=error', {
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.