@dzhechkov/harness-core 0.8.30 → 0.8.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +233 -93
- package/README.md +269 -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/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 +20 -3
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +40 -7
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +30 -0
- package/dist/store-counts.d.ts.map +1 -1
- package/dist/store-counts.js +189 -0
- package/dist/store-counts.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 +6 -6
- package/sbom.json +442 -92
- 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/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 +57 -9
- package/src/store-counts.ts +245 -1
- package/src/usage.ts +243 -29
- package/src/vector-tier.ts +45 -4
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply-leg module (feature `setup-installs-apply-leg`, ADR-001 Decisions 1 and 2).
|
|
3
|
+
*
|
|
4
|
+
* The self-learning loop has three legs — COLLECT (session hooks → store), RANK (`dz teach` /
|
|
5
|
+
* `dz recall` / `dz consolidate`), APPLY (a `UserPromptSubmit` hook injects learned lessons back
|
|
6
|
+
* into the prompt via a resident embedding daemon). `dz setup` shipped the first two legs; the
|
|
7
|
+
* third lived ONLY as hand-committed files in this hub's own `.claude/helpers/` — a consumer who
|
|
8
|
+
* ran `dz setup --target claude-code --memory agentdb` got session hooks and a memory store, but no
|
|
9
|
+
* recall injection at all (00_complexity_assessment.md, MEASURED 2026-09-12: clean scratch installs
|
|
10
|
+
* on 0.8.10 and 0.8.22, with and without `--memory agentdb`, wrote no `UserPromptSubmit` entry).
|
|
11
|
+
*
|
|
12
|
+
* This module is the SOURCE of that third leg: the two helper files as versioned template
|
|
13
|
+
* generators (the same shape as `AGENTDB_WRITER_VERSION`/`generateAgentdbWriter` in `setup.ts`),
|
|
14
|
+
* the hook-registry entries `runSetup` merges into `settings.json`, and `applyLegStatus` — the ONE
|
|
15
|
+
* measurement `dz doctor` and `dz parity` both read, so neither can drift into declaring the leg
|
|
16
|
+
* present from a static capability table again (ADR-001 Decision 3, the "приборы врут" defect).
|
|
17
|
+
*
|
|
18
|
+
* Decision 2 (why `coreDistDir` is baked in, first candidate): a hook file lives in a CONSUMER
|
|
19
|
+
* project's `.claude/helpers/`, where `require('@dzhechkov/harness-core')` cannot resolve — Node
|
|
20
|
+
* resolves a bare specifier upward from the file, and the package is not above it in a global
|
|
21
|
+
* install. The old hard-coded `/usr/lib/node_modules/...` candidate assumed one specific npm
|
|
22
|
+
* prefix; a project set up from an nvm or `/usr/local` install found nothing. The fix mirrors
|
|
23
|
+
* `claude-hooks-assets.ts`'s destructive-guard hook: bake the INSTALLING copy's absolute `dist`
|
|
24
|
+
* directory into the generated body as the FIRST candidate (proven first at C-5), keep the
|
|
25
|
+
* project-local candidates as fallbacks for a consumer that vendors its own harness-core.
|
|
26
|
+
*
|
|
27
|
+
* @packageDocumentation
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { hookCommandsOf } from './managed-hooks.js';
|
|
32
|
+
/**
|
|
33
|
+
* Version stamped into BOTH generated helper files as `// dz-apply-leg-version: N` (line 2, right
|
|
34
|
+
* after the shebang). Bump on ANY change to {@link recallHookSource} or {@link embedDaemonSource}'s
|
|
35
|
+
* output — `runSetup` regenerates a deployed helper whose stamp is older, without requiring
|
|
36
|
+
* `--force` (the same self-healing contract as `AGENTDB_WRITER_VERSION`).
|
|
37
|
+
*
|
|
38
|
+
* Bumped 2→3 (fix round 1, review Codex C, MEDIUM finding "hub twins непереносимы"):
|
|
39
|
+
* `recallHookSource`'s `loadCoreModule` candidate list is now built with a conditional spread so a
|
|
40
|
+
* `null` `coreDistDir` (the hub's own portable marker, C-6/finding-6) degrades cleanly to the
|
|
41
|
+
* project-relative fallbacks — the generated BYTES changed for every caller, hub and consumer alike.
|
|
42
|
+
*/
|
|
43
|
+
export const APPLY_LEG_VERSION = 3;
|
|
44
|
+
/**
|
|
45
|
+
* Parse the `dz-apply-leg-version` stamp from a deployed helper file. Unlike
|
|
46
|
+
* `writerVersionOf` (which floors an absent stamp at `0`), this returns `-1` for "no stamp at
|
|
47
|
+
* all" per the plan contract (T1) — `0` is reserved for a future explicit `version 0` helper, and
|
|
48
|
+
* collapsing "never installed" into the same number as "installed at v0" would make
|
|
49
|
+
* `applyLegStatus` unable to tell the two apart.
|
|
50
|
+
*/
|
|
51
|
+
export function applyLegVersionOf(content) {
|
|
52
|
+
const m = /^\/\/ dz-apply-leg-version:\s*(\d+)/m.exec(content);
|
|
53
|
+
return m ? parseInt(m[1] ?? '0', 10) : -1;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read back the `coreDistDir` baked into a deployed `recall-hook.cjs` by {@link recallHookSource}
|
|
57
|
+
* (fix round 1, review Codex C, MEDIUM finding "переезд ядра"): `undefined` when the file carries no
|
|
58
|
+
* recognisable `const CORE_DIST_DIR = …;` line at all (e.g. a pre-feature or hand-edited file),
|
|
59
|
+
* `null` for the hub's own portable marker, otherwise the baked absolute path. `runSetup` compares
|
|
60
|
+
* this against the CURRENT `coreDistDir` so a project whose npm/nvm relocated `harness-core` gets
|
|
61
|
+
* its helper rewritten even when {@link APPLY_LEG_VERSION} did not change — a version-only staleness
|
|
62
|
+
* check missed exactly this case (MEASURED: a stale baked path degrades the hook to permanent
|
|
63
|
+
* silence, never a thrown error, so nothing else would ever surface it).
|
|
64
|
+
*/
|
|
65
|
+
export function bakedCoreDistDirOf(content) {
|
|
66
|
+
const m = /^const CORE_DIST_DIR = (.+);$/m.exec(content);
|
|
67
|
+
if (!m)
|
|
68
|
+
return undefined;
|
|
69
|
+
const raw = m[1] ?? '';
|
|
70
|
+
if (raw === 'null')
|
|
71
|
+
return null;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(raw);
|
|
74
|
+
return typeof parsed === 'string' ? parsed : undefined;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Generate `.claude/helpers/recall-hook.cjs`. `coreDistDir` is baked in as the FIRST resolve
|
|
82
|
+
* candidate in `loadCoreModule` (Decision 2, AC-6/C-5) — an absolute path, supplied by the
|
|
83
|
+
* installer (`dz setup`'s CLI caller resolves its own installed `@dzhechkov/harness-core`).
|
|
84
|
+
*
|
|
85
|
+
* `coreDistDir === null` is the HUB's OWN portable marker (fix round 1, review Codex C, MEDIUM
|
|
86
|
+
* finding "hub twins непереносимы"): a checked-in file that bakes an ABSOLUTE checkout path fails
|
|
87
|
+
* the twins byte-identity test in every clone but the one it was generated from. The hub instead
|
|
88
|
+
* regenerates its two `.claude/helpers/*` files with `null`, which renders `const CORE_DIST_DIR =
|
|
89
|
+
* null;` and — since `loadCoreModule`'s candidate list below SKIPS a falsy `CORE_DIST_DIR` — falls
|
|
90
|
+
* straight through to the project-relative fallback candidates, the SAME `<project>/packages/
|
|
91
|
+
* @dzhechkov/harness-core/dist/<file>` path the hub's own `coreDistDir` would have baked anyway
|
|
92
|
+
* (`PROJECT` resolves from `CLAUDE_PROJECT_DIR`/`cwd()` at hook RUNTIME, not at generation time, so
|
|
93
|
+
* it is correct in any clone). A real consumer install still gets its installer's absolute path
|
|
94
|
+
* baked in as before — this is additive, not a behavior change for that case.
|
|
95
|
+
*
|
|
96
|
+
* Behaviourally identical to the pre-existing hand-committed hub file except for: the version
|
|
97
|
+
* stamp (new, line 2) and the candidate list in `loadCoreModule` (baked path first when present,
|
|
98
|
+
* `/usr/lib/...` dropped — FR-3).
|
|
99
|
+
*/
|
|
100
|
+
export function recallHookSource(coreDistDir) {
|
|
101
|
+
return `#!/usr/bin/env node
|
|
102
|
+
// dz-apply-leg-version: ${APPLY_LEG_VERSION}
|
|
103
|
+
/**
|
|
104
|
+
* \`UserPromptSubmit\` hook — the APPLY leg of dz's self-learning loop.
|
|
105
|
+
*
|
|
106
|
+
* A feedback loop has three legs: COLLECT → RANK → APPLY. dz collected automatically and never read
|
|
107
|
+
* back: of 18 hooks, none touched the pattern store, so 100+ learned lessons reached an agent's
|
|
108
|
+
* context only when \`feature-adr\` Step-0 asked or a human typed \`dz recall\`. The cost was concrete —
|
|
109
|
+
* a lesson that was ALREADY STORED ("Codex writes out-of-band; \`changed=0\` means NOT-YET") failed to
|
|
110
|
+
* prevent the same false grade-D a second time. This file is the missing leg.
|
|
111
|
+
*
|
|
112
|
+
* WHAT IT MUST NOT BECOME. agentic-qe has an apply leg and no rank leg: it injects the same five
|
|
113
|
+
* static guidance lines on every prompt regardless of topic, and only 10 of its 198 patterns were
|
|
114
|
+
* ever used. An apply leg without a floor trains the reader to ignore it. So: when nothing clears the
|
|
115
|
+
* MEASURED relevance floor this hook prints NOTHING AT ALL and exits 0.
|
|
116
|
+
*
|
|
117
|
+
* NEVER-BLOCK is the top safety property, above usefulness. Any failure — no daemon, no deps, a
|
|
118
|
+
* malformed payload, a slow socket — exits 0 in silence. A broken hook must never stall a turn.
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
const net = require('node:net');
|
|
122
|
+
const path = require('node:path');
|
|
123
|
+
const fs = require('node:fs');
|
|
124
|
+
const { pathToFileURL } = require('node:url');
|
|
125
|
+
|
|
126
|
+
const PROJECT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
127
|
+
const CORE_DIST_DIR = ${coreDistDir === null ? 'null' : JSON.stringify(coreDistDir)};
|
|
128
|
+
const SOCKET = process.env.DZ_EMBED_SOCKET || path.join(PROJECT, '.dz', 'embed.sock');
|
|
129
|
+
const USAGE_LOG = process.env.DZ_RECALL_USAGE_LOG || path.join(PROJECT, '.dz', 'recall-usage.jsonl');
|
|
130
|
+
const TIMEOUT_MS = Number(process.env.DZ_RECALL_HOOK_TIMEOUT_MS || 800);
|
|
131
|
+
|
|
132
|
+
const safe = (fn, fb) => {
|
|
133
|
+
try {
|
|
134
|
+
return fn();
|
|
135
|
+
} catch {
|
|
136
|
+
return fb;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Read the whole of stdin, bounded. Returns '' on anything unexpected. */
|
|
141
|
+
function readStdin() {
|
|
142
|
+
return safe(() => fs.readFileSync(0, 'utf-8'), '');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Extract the prompt from a UserPromptSubmit payload — the shapes Claude Code may send. */
|
|
146
|
+
function extractPrompt(raw) {
|
|
147
|
+
const text = String(raw || '').trim();
|
|
148
|
+
if (text === '') return '';
|
|
149
|
+
const parsed = safe(() => JSON.parse(text), undefined);
|
|
150
|
+
if (parsed && typeof parsed === 'object') {
|
|
151
|
+
for (const k of ['prompt', 'user_prompt', 'userPrompt']) {
|
|
152
|
+
if (typeof parsed[k] === 'string' && parsed[k].trim() !== '') return parsed[k].trim();
|
|
153
|
+
}
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
return text; // plain, non-JSON text is the prompt
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Load the pure policy from the built harness-core. It ships as ESM, so a \`.cjs\` hook cannot
|
|
161
|
+
* \`require()\` it — resolve the absolute path and use dynamic \`import()\`, exactly as
|
|
162
|
+
* \`claim-check-hook.cjs\` does. Unresolvable ⇒ the hook is inert (exit 0), never a block.
|
|
163
|
+
*/
|
|
164
|
+
async function loadCoreModule(fileName, predicate) {
|
|
165
|
+
const candidates = [
|
|
166
|
+
...(CORE_DIST_DIR ? [path.join(CORE_DIST_DIR, fileName)] : []),
|
|
167
|
+
path.join(PROJECT, 'node_modules', '@dzhechkov', 'harness-core', 'dist', fileName),
|
|
168
|
+
path.join(PROJECT, 'packages', '@dzhechkov', 'harness-core', 'dist', fileName),
|
|
169
|
+
];
|
|
170
|
+
for (const c of candidates) {
|
|
171
|
+
if (!fs.existsSync(c)) continue;
|
|
172
|
+
try {
|
|
173
|
+
const mod = await import(pathToFileURL(c).href);
|
|
174
|
+
if (predicate(mod)) return mod;
|
|
175
|
+
} catch {
|
|
176
|
+
/* try the next candidate */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function loadPolicy() {
|
|
183
|
+
return loadCoreModule('recall-hook-policy.js', (mod) => typeof mod.selectHookHits === 'function');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Retro admission-debt directive (feature narrated-error-must-be-taught, ADR-001 D2/D3). The Stop
|
|
188
|
+
* hook's scan-tail arms \`.dz/retro-pending.json\` when the assistant admitted an error without a
|
|
189
|
+
* teach; THIS hook confronts the assistant on the very next prompt. Contract:
|
|
190
|
+
* - sentinel absent ⇒ '' and ZERO extra work beyond one existsSync (the no-noise pin — output
|
|
191
|
+
* stays byte-identical to a build without this feature);
|
|
192
|
+
* - stale / other-session sentinel ⇒ '' (the debt belongs to a dead session; retro collected it);
|
|
193
|
+
* - core module absent or old (no directive exports) ⇒ '' — inert, NEVER-BLOCK.
|
|
194
|
+
*/
|
|
195
|
+
const RETRO_PENDING = path.join(PROJECT, '.dz', 'retro-pending.json');
|
|
196
|
+
async function retroDebtDirective(payload) {
|
|
197
|
+
if (!fs.existsSync(RETRO_PENDING)) return '';
|
|
198
|
+
const sentinel = safe(() => JSON.parse(fs.readFileSync(RETRO_PENDING, 'utf-8')), undefined);
|
|
199
|
+
if (!sentinel || typeof sentinel.snippet !== 'string' || sentinel.snippet === '') return '';
|
|
200
|
+
const mod = await loadCoreModule(
|
|
201
|
+
'session-retro.js',
|
|
202
|
+
(m) => typeof m.renderRetroDebtDirective === 'function' && typeof m.retroSentinelIsFresh === 'function',
|
|
203
|
+
);
|
|
204
|
+
if (!mod) return '';
|
|
205
|
+
return safe(() => {
|
|
206
|
+
const ctx = {
|
|
207
|
+
sessionId: payload && typeof payload.session_id === 'string' ? payload.session_id : undefined,
|
|
208
|
+
transcriptPath: payload && typeof payload.transcript_path === 'string' ? payload.transcript_path : undefined,
|
|
209
|
+
nowMs: Date.now(),
|
|
210
|
+
};
|
|
211
|
+
return mod.retroSentinelIsFresh(sentinel, ctx) ? String(mod.renderRetroDebtDirective(sentinel)) : '';
|
|
212
|
+
}, '');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function loadUsagePolicy() {
|
|
216
|
+
return loadCoreModule('recall-usage.js', (mod) => typeof mod.formatRecallUsageRecord === 'function');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The event-chain primitives (ADR-001). Absent ⇒ records are written UNCHAINED, never blocked. */
|
|
220
|
+
async function loadChainPolicy() {
|
|
221
|
+
return loadCoreModule('event-chain.js', (mod) => typeof mod.appendChainedLines === 'function' && typeof mod.guardedRewrite === 'function');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Tail facts of the usage log, read from its END — O(1) in the file size. The apply-leg hook runs on
|
|
226
|
+
* EVERY prompt, so re-reading the whole log per append would be a per-turn tax that eventually gets
|
|
227
|
+
* the hook switched off. Unreadable ⇒ the empty tail, and the caller starts a marked chain segment.
|
|
228
|
+
*/
|
|
229
|
+
function readLogTail(chain, file) {
|
|
230
|
+
return safe(() => {
|
|
231
|
+
if (!chain || typeof chain.readTailInfo !== 'function') return undefined;
|
|
232
|
+
if (!fs.existsSync(file)) return chain.EMPTY_LOG_TAIL;
|
|
233
|
+
const fd = fs.openSync(file, 'r');
|
|
234
|
+
try {
|
|
235
|
+
const size = fs.fstatSync(fd).size;
|
|
236
|
+
if (!Number.isFinite(size) || size <= 0) return chain.EMPTY_LOG_TAIL;
|
|
237
|
+
const want = Math.min(size, Number(chain.EVENT_CHAIN_TAIL_BYTES) || 65536);
|
|
238
|
+
const buf = Buffer.alloc(want);
|
|
239
|
+
fs.readSync(fd, buf, 0, want, size - want);
|
|
240
|
+
return chain.readTailInfo(buf.toString('utf-8'), { partial: want < size });
|
|
241
|
+
} finally {
|
|
242
|
+
try { fs.closeSync(fd); } catch { /* nothing to do */ }
|
|
243
|
+
}
|
|
244
|
+
}, chain && chain.EMPTY_LOG_TAIL);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function askDaemon(prompt) {
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
if (!fs.existsSync(SOCKET)) return resolve(undefined);
|
|
250
|
+
let settled = false;
|
|
251
|
+
const done = (v) => {
|
|
252
|
+
if (settled) return;
|
|
253
|
+
settled = true;
|
|
254
|
+
safe(() => sock.destroy());
|
|
255
|
+
resolve(v);
|
|
256
|
+
};
|
|
257
|
+
const sock = net.connect(SOCKET);
|
|
258
|
+
const timer = setTimeout(() => done(undefined), TIMEOUT_MS);
|
|
259
|
+
timer.unref?.();
|
|
260
|
+
let buf = '';
|
|
261
|
+
sock.on('connect', () => sock.write(JSON.stringify({ op: 'recall', prompt, limit: 8 }) + '\\n'));
|
|
262
|
+
sock.on('data', (chunk) => {
|
|
263
|
+
buf += chunk.toString('utf8');
|
|
264
|
+
const nl = buf.indexOf('\\n');
|
|
265
|
+
if (nl === -1) return;
|
|
266
|
+
clearTimeout(timer);
|
|
267
|
+
const msg = safe(() => JSON.parse(buf.slice(0, nl)), undefined);
|
|
268
|
+
done(msg && Array.isArray(msg.hits) ? msg.hits : undefined);
|
|
269
|
+
});
|
|
270
|
+
sock.on('error', () => {
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
done(undefined);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const REVIVE_LOCK = path.join(PROJECT, '.dz', 'embed-daemon.lock');
|
|
278
|
+
const REVIVE_LOCK_FRESH_MS = 120_000; // model load takes ~45s; don't respawn while one is coming up
|
|
279
|
+
function reviveDaemon() {
|
|
280
|
+
// CROSS-PROCESS lock: every prompt runs a fresh hook process, so a per-process flag let 20 queued
|
|
281
|
+
// prompts spawn 20 daemons while the first was still loading its model (Codex #5). A lockfile with
|
|
282
|
+
// a freshness window means at most one spawn per window, machine-wide.
|
|
283
|
+
safe(() => {
|
|
284
|
+
fs.mkdirSync(path.dirname(REVIVE_LOCK), { recursive: true });
|
|
285
|
+
// ATOMIC acquisition: \`wx\` fails if the lock exists — stat-then-write let every concurrent hook
|
|
286
|
+
// observe "no fresh lock" and spawn its own daemon (the exact burst this lock exists to stop).
|
|
287
|
+
try {
|
|
288
|
+
fs.writeFileSync(REVIVE_LOCK, String(process.pid), { flag: 'wx' });
|
|
289
|
+
} catch {
|
|
290
|
+
// Lock exists. Stale (older than the freshness window) → steal it by rewrite; fresh → yield.
|
|
291
|
+
try {
|
|
292
|
+
const st = fs.statSync(REVIVE_LOCK);
|
|
293
|
+
if (Date.now() - st.mtimeMs < REVIVE_LOCK_FRESH_MS) return;
|
|
294
|
+
fs.writeFileSync(REVIVE_LOCK, String(process.pid));
|
|
295
|
+
} catch {
|
|
296
|
+
return; // cannot inspect the lock — yield rather than storm
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const { spawn } = require('child_process');
|
|
300
|
+
const daemon = path.join(PROJECT, '.claude', 'helpers', 'dz-embed-daemon.mjs');
|
|
301
|
+
if (!fs.existsSync(daemon)) return;
|
|
302
|
+
const child = spawn(process.execPath, [daemon], { detached: true, stdio: 'ignore' });
|
|
303
|
+
child.unref();
|
|
304
|
+
}, undefined);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function recordUsage(hits, usage, query, chain) {
|
|
308
|
+
safe(() => {
|
|
309
|
+
const ts = new Date().toISOString();
|
|
310
|
+
// One PROMPT = one event: the report must count prompts, not injected-hit rows (up to 3 per
|
|
311
|
+
// prompt), or readiness is fabricated from multiplicity.
|
|
312
|
+
const eventId = ts + ':' + Math.random().toString(36).slice(2, 10);
|
|
313
|
+
const records = [];
|
|
314
|
+
for (const h of hits || []) {
|
|
315
|
+
if (typeof h?.dzId !== 'string' || h.dzId.trim() === '') continue;
|
|
316
|
+
// query + runId make the log REPLAYABLE: without them it can say a lesson was used but never
|
|
317
|
+
// say for what, which made cold-vs-warm measurement unbuildable from the recorded history.
|
|
318
|
+
const runId = process.env.CLAUDE_CODE_SESSION_ID || undefined;
|
|
319
|
+
const full = typeof query === 'string' ? query.trim() : '';
|
|
320
|
+
const q = full !== '' ? full.slice(0, 200) : undefined;
|
|
321
|
+
// A truncated prefix cannot REPRODUCE the original recall — flagged so the report excludes it
|
|
322
|
+
// from replay readiness instead of counting a false pair.
|
|
323
|
+
const queryTruncated = full.length > 200 ? true : undefined;
|
|
324
|
+
const input = { dzId: h.dzId, score: h.score, ts, query: q, runId, eventId, queryTruncated };
|
|
325
|
+
const rec =
|
|
326
|
+
usage && typeof usage.buildRecallUsageRecord === 'function'
|
|
327
|
+
? usage.buildRecallUsageRecord(input)
|
|
328
|
+
: { dzId: h.dzId, score: h.score, ts, ...(q ? { query: q } : {}), ...(runId ? { runId } : {}), eventId, ...(queryTruncated ? { queryTruncated } : {}) };
|
|
329
|
+
if (rec && typeof rec === 'object') records.push(rec);
|
|
330
|
+
}
|
|
331
|
+
if (records.length === 0) return;
|
|
332
|
+
// event-chain (ADR-001): seq + prevHash from the LAST LINE ONLY. If the chain module is absent
|
|
333
|
+
// (a broken install) the records are still written, unchained — a hole verify will name, which
|
|
334
|
+
// is the honest outcome; never-block outranks chain completeness.
|
|
335
|
+
const payload =
|
|
336
|
+
chain && typeof chain.appendChainedLines === 'function'
|
|
337
|
+
? chain.appendChainedLines(records, readLogTail(chain, USAGE_LOG))
|
|
338
|
+
: records.map((r) => JSON.stringify(r) + '\\n').join('');
|
|
339
|
+
if (typeof payload !== 'string' || payload === '') return;
|
|
340
|
+
fs.mkdirSync(path.dirname(USAGE_LOG), { recursive: true });
|
|
341
|
+
fs.appendFileSync(USAGE_LOG, payload, 'utf-8');
|
|
342
|
+
compactUsageLogIfNeeded(usage, chain, ts);
|
|
343
|
+
}, undefined);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const COMPACT_LOCK = \`\${USAGE_LOG}.compact.lock\`;
|
|
347
|
+
/** A compaction is milliseconds of work; anything older than this is a crashed holder. */
|
|
348
|
+
const COMPACT_LOCK_FRESH_MS = 30_000;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* ATOMIC \`wx\` acquisition with a staleness window — the same pattern the daemon-revive lock uses.
|
|
352
|
+
* \`wx\` fails when the file exists, so two hooks cannot both believe they hold it (a stat-then-write
|
|
353
|
+
* check let every concurrent hook observe "free" and proceed, which is the burst this prevents).
|
|
354
|
+
*/
|
|
355
|
+
// The lock TOKEN identifies the owner: release must only ever remove the holder's OWN lock, and a
|
|
356
|
+
// stale steal must be an atomic claim, not an overwrite two stealers can both "win" (Codex re-QE MED).
|
|
357
|
+
const COMPACT_LOCK_TOKEN = \`\${process.pid}.\${Math.floor(Math.random() * 0xffffffff).toString(16)}\`;
|
|
358
|
+
function acquireCompactLock() {
|
|
359
|
+
return (
|
|
360
|
+
safe(() => {
|
|
361
|
+
try {
|
|
362
|
+
fs.writeFileSync(COMPACT_LOCK, COMPACT_LOCK_TOKEN, { flag: 'wx' });
|
|
363
|
+
return true;
|
|
364
|
+
} catch {
|
|
365
|
+
const st = fs.statSync(COMPACT_LOCK); // throws ⇒ vanished ⇒ treat as busy, try next prompt
|
|
366
|
+
if (Date.now() - st.mtimeMs < COMPACT_LOCK_FRESH_MS) return false;
|
|
367
|
+
// Stale: unlink, then RE-RACE through \`wx\` — of N concurrent stealers exactly one wins;
|
|
368
|
+
// a plain overwrite here let every stealer proceed and each believed it held the lock.
|
|
369
|
+
try { fs.unlinkSync(COMPACT_LOCK); } catch { /* someone else already removed it */ }
|
|
370
|
+
fs.writeFileSync(COMPACT_LOCK, COMPACT_LOCK_TOKEN, { flag: 'wx' }); // throws ⇒ lost the race ⇒ busy
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
}, false) === true
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
/** Release ONLY our own lock: a token mismatch means a stealer legitimately took over — leave it. */
|
|
377
|
+
function releaseCompactLock() {
|
|
378
|
+
safe(() => {
|
|
379
|
+
if (fs.readFileSync(COMPACT_LOCK, 'utf-8') === COMPACT_LOCK_TOKEN) fs.unlinkSync(COMPACT_LOCK);
|
|
380
|
+
}, undefined);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Append a one-line note where a human (and \`dz doctor\`) will see it. Best-effort, never blocks. */
|
|
384
|
+
function noteEvent(event, detail) {
|
|
385
|
+
safe(() => {
|
|
386
|
+
fs.appendFileSync(
|
|
387
|
+
path.join(PROJECT, '.dz', 'sessions.jsonl'),
|
|
388
|
+
JSON.stringify({ event, ts: new Date().toISOString(), ...detail }) + '\\n',
|
|
389
|
+
);
|
|
390
|
+
}, undefined);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Compaction is the only path that REWRITES the evidence log, so it is the only path that can lose
|
|
395
|
+
* an append. It runs behind \`guardedRewrite\`: an exclusive lock, plus a re-read of the live file
|
|
396
|
+
* after computing the new text and before the rename — an append that landed in between aborts the
|
|
397
|
+
* attempt and is folded into the retry rather than being overwritten by it (Codex QE CRITICAL-1).
|
|
398
|
+
*/
|
|
399
|
+
function compactUsageLogIfNeeded(usage, chain, ts) {
|
|
400
|
+
safe(() => {
|
|
401
|
+
if (!usage || typeof usage.compactRecallUsageLogChecked !== 'function') return;
|
|
402
|
+
const maxBytes = Number(process.env.DZ_RECALL_USAGE_MAX_BYTES || usage.RECALL_USAGE_LOG_MAX_BYTES || 1048576);
|
|
403
|
+
const st = fs.statSync(USAGE_LOG);
|
|
404
|
+
const shouldCompact =
|
|
405
|
+
typeof usage.shouldCompactRecallUsageLogSize === 'function'
|
|
406
|
+
? usage.shouldCompactRecallUsageLogSize(st.size, maxBytes)
|
|
407
|
+
: st.size > maxBytes;
|
|
408
|
+
if (!shouldCompact) return;
|
|
409
|
+
if (!chain || typeof chain.guardedRewrite !== 'function') return; // no guard ⇒ no rewrite
|
|
410
|
+
|
|
411
|
+
const io = {
|
|
412
|
+
read: () => safe(() => fs.readFileSync(USAGE_LOG, 'utf-8'), undefined),
|
|
413
|
+
replace: (text) => {
|
|
414
|
+
const tmp = \`\${USAGE_LOG}.\${process.pid}.tmp\`;
|
|
415
|
+
fs.writeFileSync(tmp, text, 'utf-8');
|
|
416
|
+
fs.renameSync(tmp, USAGE_LOG);
|
|
417
|
+
},
|
|
418
|
+
acquireLock: acquireCompactLock,
|
|
419
|
+
releaseLock: () => releaseCompactLock(),
|
|
420
|
+
};
|
|
421
|
+
const result = chain.guardedRewrite(io, (text) => {
|
|
422
|
+
const r = usage.compactRecallUsageLogChecked(text, { maxBytes, compactedAt: ts });
|
|
423
|
+
return { text: r.text, refusedDirty: r.status === 'refused-dirty', defects: r.defects };
|
|
424
|
+
});
|
|
425
|
+
// A refusal or a persistent race is a FINDING about the evidence base, not a no-op to swallow.
|
|
426
|
+
if (result.status === 'refused-dirty' || result.status === 'raced') {
|
|
427
|
+
noteEvent('recall-usage-compaction-' + result.status, {
|
|
428
|
+
attempts: result.attempts,
|
|
429
|
+
defects: (result.defects || []).slice(0, 3).map((d) => \`\${d.kind}@L\${d.line}\`),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}, undefined);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Emit additionalContext when non-empty; silence otherwise (the floor contract). */
|
|
436
|
+
function emitContext(context) {
|
|
437
|
+
if (typeof context !== 'string' || context === '') return;
|
|
438
|
+
process.stdout.write(
|
|
439
|
+
JSON.stringify({
|
|
440
|
+
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: context },
|
|
441
|
+
}) + '\\n',
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function main() {
|
|
446
|
+
const raw = readStdin();
|
|
447
|
+
const payload = safe(() => JSON.parse(String(raw || '').trim()), undefined);
|
|
448
|
+
const prompt = extractPrompt(raw);
|
|
449
|
+
|
|
450
|
+
// The retro debt directive rides EVERY early-return path below: a down daemon or an empty prompt
|
|
451
|
+
// must not swallow the confrontation — the debt is independent of recall relevance. When the
|
|
452
|
+
// sentinel is absent this is one existsSync and debt === '' (byte-identical outputs to before).
|
|
453
|
+
const debt = await retroDebtDirective(payload);
|
|
454
|
+
|
|
455
|
+
if (prompt === '') return emitContext(debt);
|
|
456
|
+
|
|
457
|
+
const policy = await loadPolicy();
|
|
458
|
+
if (!policy) return emitContext(debt);
|
|
459
|
+
|
|
460
|
+
const hits = await askDaemon(prompt);
|
|
461
|
+
if (!hits) {
|
|
462
|
+
// SELF-HEAL (2026-07-28): the daemon is started at SessionStart only, so when it dies mid-way
|
|
463
|
+
// through a long-lived session NOTHING restarts it — the apply leg was silently dead for 19
|
|
464
|
+
// days (MEASURED: recall-usage.jsonl last record 2026-07-09, socket absent). Spawn it
|
|
465
|
+
// fire-and-forget so the NEXT prompt has it; this prompt stays uninjected (never-block).
|
|
466
|
+
reviveDaemon();
|
|
467
|
+
return emitContext(debt);
|
|
468
|
+
}
|
|
469
|
+
if (hits.length === 0) return emitContext(debt); // daemon alive, nothing relevant — silence is correct
|
|
470
|
+
|
|
471
|
+
const selection = policy.selectHookHits(prompt, hits);
|
|
472
|
+
// lesson-quarantine AM-2: an excluded hypothesis is logged, never a silent context shrink.
|
|
473
|
+
if (typeof selection.quarantinedExcluded === 'number' && selection.quarantinedExcluded > 0) {
|
|
474
|
+
try {
|
|
475
|
+
fs.appendFileSync(
|
|
476
|
+
path.join(PROJECT, '.dz', 'sessions.jsonl'),
|
|
477
|
+
JSON.stringify({ event: 'quarantine-excluded', ts: new Date().toISOString(), count: selection.quarantinedExcluded }) + '\\n',
|
|
478
|
+
);
|
|
479
|
+
} catch { /* best-effort */ }
|
|
480
|
+
}
|
|
481
|
+
const context = policy.renderHookContext(selection);
|
|
482
|
+
// The debt directive is PREPENDED so it is the first thing the model reads (before other work).
|
|
483
|
+
const combined = debt === '' ? context : context === '' ? debt : \`\${debt}\\n\${context}\`;
|
|
484
|
+
if (combined === '') return; // nothing cleared the floor — the whole point
|
|
485
|
+
emitContext(combined);
|
|
486
|
+
|
|
487
|
+
if (context !== '') {
|
|
488
|
+
const usage = await loadUsagePolicy();
|
|
489
|
+
const chain = await loadChainPolicy();
|
|
490
|
+
recordUsage(selection.hits, usage, prompt, chain);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
main()
|
|
495
|
+
.catch(() => {})
|
|
496
|
+
.finally(() => process.exit(0));
|
|
497
|
+
`;
|
|
498
|
+
}
|
|
499
|
+
/** `setTimeout`'s delay argument is a 32-bit signed int in Node — see {@link resolveIdleMs}. */
|
|
500
|
+
export const IDLE_MS_INT32_MAX = 2_147_483_647;
|
|
501
|
+
/**
|
|
502
|
+
* Pure mirror of the `IDLE_MS` resolution inlined into {@link embedDaemonSource}'s generated body
|
|
503
|
+
* (AM-10, dz-harness-hub issue #10 defect 7). Extracted (fix round 1, review Codex C, LOW finding)
|
|
504
|
+
* so the clamp/disable rule is unit-testable directly — the review noted the only prior proof was a
|
|
505
|
+
* LIVE process observed via its first stderr log line, which shows the log fired but not that the
|
|
506
|
+
* underlying arithmetic is right for every input class.
|
|
507
|
+
*
|
|
508
|
+
* A template string cannot `import` this function into the daemon's own runtime (it is generated
|
|
509
|
+
* text, not compiled code), so {@link embedDaemonSource} keeps its OWN copy of the same three rules —
|
|
510
|
+
* `apply-leg-twins.test.ts` pins the generated body to the same `IDLE_MS_INT32_MAX` constant this
|
|
511
|
+
* module exports, so the two cannot drift apart silently.
|
|
512
|
+
*/
|
|
513
|
+
export function resolveIdleMs(raw) {
|
|
514
|
+
if (!Number.isFinite(raw) || raw <= 0)
|
|
515
|
+
return { value: null, clamped: false };
|
|
516
|
+
if (raw > IDLE_MS_INT32_MAX)
|
|
517
|
+
return { value: IDLE_MS_INT32_MAX, clamped: true };
|
|
518
|
+
return { value: raw, clamped: false };
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Generate `.claude/helpers/dz-embed-daemon.mjs`. Behaviourally identical to the pre-existing
|
|
522
|
+
* hand-committed hub file except for: the version stamp (new, line 2) and `resolveDeps()`, which
|
|
523
|
+
* now tries `@huggingface/transformers` before falling back to `@xenova/transformers` — AM-3,
|
|
524
|
+
* dz-harness-hub issue #10 defect 3: `agentdb >= 3.0.0-alpha` depends on the former, and an older
|
|
525
|
+
* agentdb install still carries the latter, so probing only one name silently starved the daemon
|
|
526
|
+
* on either side of that agentdb version boundary.
|
|
527
|
+
*/
|
|
528
|
+
export function embedDaemonSource() {
|
|
529
|
+
return `#!/usr/bin/env node
|
|
530
|
+
// dz-apply-leg-version: ${APPLY_LEG_VERSION}
|
|
531
|
+
/**
|
|
532
|
+
* Resident embedding + recall daemon for the \`dz recall\` APPLY leg.
|
|
533
|
+
*
|
|
534
|
+
* WHY IT EXISTS (measured, not assumed): loading the multilingual embedder costs ~1478 ms, while a
|
|
535
|
+
* warm embed of a query costs 6–8 ms. A \`UserPromptSubmit\` hook is a fresh process every turn, so
|
|
536
|
+
* without a resident model the hook would add ~2 s to every prompt and be switched off within a day.
|
|
537
|
+
* With this daemon the hook is a socket round-trip.
|
|
538
|
+
*
|
|
539
|
+
* ORPHAN SAFETY — the load-bearing property. Today (2026-07-09) two orphaned agentdb MCP servers,
|
|
540
|
+
* 19 h and 7 h old, were found rewriting a SQLite file whole-file several times per second and
|
|
541
|
+
* corrupting concurrent readers. A resident process that outlives its purpose is a liability. So:
|
|
542
|
+
*
|
|
543
|
+
* - it EXITS after \`DZ_EMBED_IDLE_MS\` (default 30 min) with no request;
|
|
544
|
+
* - it refuses to start if a live daemon already owns the socket (no duplicate herd);
|
|
545
|
+
* - it removes a stale socket left by a crashed predecessor;
|
|
546
|
+
* - it opens the pattern store READ-ONLY — it can never be the writer that tears a file;
|
|
547
|
+
* - it exits on SIGINT/SIGTERM/SIGHUP and unlinks its socket.
|
|
548
|
+
*
|
|
549
|
+
* PROTOCOL — newline-delimited JSON over a unix socket:
|
|
550
|
+
* → {"op":"recall","prompt":"…","limit":8} ← {"hits":[{"dzId","pattern","score","domain"}]}
|
|
551
|
+
* → {"op":"ping"} ← {"ok":true,"model":"…","uptimeMs":N}
|
|
552
|
+
* → {"op":"stop"} ← {"ok":true} (then exits)
|
|
553
|
+
*
|
|
554
|
+
* \`score\` is COSINE RELEVANCE in [0,1] — never the teaching reward. The caller applies the floor.
|
|
555
|
+
*/
|
|
556
|
+
|
|
557
|
+
import { createServer } from 'node:net';
|
|
558
|
+
import { existsSync, unlinkSync, readFileSync } from 'node:fs';
|
|
559
|
+
import { join } from 'node:path';
|
|
560
|
+
import { createRequire } from 'node:module';
|
|
561
|
+
import { connect } from 'node:net';
|
|
562
|
+
|
|
563
|
+
const log = (...a) => console.error('[dz-embed]', ...a);
|
|
564
|
+
|
|
565
|
+
/** Never let a diagnostic reach stdout — the hook that spawns us may be parsing it. */
|
|
566
|
+
console.log = (...a) => console.error(...a);
|
|
567
|
+
|
|
568
|
+
const PROJECT = process.env['DZ_PROJECT_ROOT'] ?? process.cwd();
|
|
569
|
+
const SOCKET = process.env['DZ_EMBED_SOCKET'] ?? join(PROJECT, '.dz', 'embed.sock');
|
|
570
|
+
// AM-10 (issue #10 defect 7): a setTimeout delay is a 32-bit signed int in Node — anything
|
|
571
|
+
// above 2_147_483_647 ms silently becomes ~1ms (a "30 days" idle setting exited in ~1 second,
|
|
572
|
+
// MEASURED). 0/NaN/negative disables the idle exit outright rather than firing immediately.
|
|
573
|
+
const IDLE_MS_INT32_MAX = 2_147_483_647;
|
|
574
|
+
const IDLE_MS_RAW = Number(process.env['DZ_EMBED_IDLE_MS'] ?? 30 * 60 * 1000);
|
|
575
|
+
let IDLE_MS;
|
|
576
|
+
if (!Number.isFinite(IDLE_MS_RAW) || IDLE_MS_RAW <= 0) {
|
|
577
|
+
IDLE_MS = null;
|
|
578
|
+
log('idle exit disabled (DZ_EMBED_IDLE_MS=' + IDLE_MS_RAW + ')');
|
|
579
|
+
} else if (IDLE_MS_RAW > IDLE_MS_INT32_MAX) {
|
|
580
|
+
IDLE_MS = IDLE_MS_INT32_MAX;
|
|
581
|
+
log('idle clamp: ' + IDLE_MS_RAW + ' -> ' + IDLE_MS_INT32_MAX + ' ms');
|
|
582
|
+
} else {
|
|
583
|
+
IDLE_MS = IDLE_MS_RAW;
|
|
584
|
+
}
|
|
585
|
+
const MODEL_FALLBACK = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
|
|
586
|
+
|
|
587
|
+
function configuredModel() {
|
|
588
|
+
try {
|
|
589
|
+
const cfg = JSON.parse(readFileSync(join(PROJECT, '.dz', 'config.json'), 'utf-8'));
|
|
590
|
+
const m = cfg?.memory?.agentdb?.embeddingModel ?? cfg?.memory?.embed?.model;
|
|
591
|
+
return typeof m === 'string' && m.trim() !== '' ? m.trim() : MODEL_FALLBACK;
|
|
592
|
+
} catch {
|
|
593
|
+
return MODEL_FALLBACK;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Is a daemon already listening? Resolves true only on a successful connect. */
|
|
598
|
+
function socketAlive(path) {
|
|
599
|
+
return new Promise((resolve) => {
|
|
600
|
+
if (!existsSync(path)) return resolve(false);
|
|
601
|
+
const c = connect(path);
|
|
602
|
+
const done = (v) => {
|
|
603
|
+
c.destroy();
|
|
604
|
+
resolve(v);
|
|
605
|
+
};
|
|
606
|
+
c.on('connect', () => done(true));
|
|
607
|
+
c.on('error', () => done(false));
|
|
608
|
+
setTimeout(() => done(false), 500);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function resolveDeps() {
|
|
613
|
+
const req = createRequire(join(PROJECT, 'package.json'));
|
|
614
|
+
// agentdb >= 3.0.0-alpha depends on '@huggingface/transformers' (the '@xenova/transformers'
|
|
615
|
+
// fork it supersedes); older agentdb releases still pull '@xenova/transformers'. Neither is a
|
|
616
|
+
// DIRECT dependency of the project, so try each candidate via the project's own require AND via
|
|
617
|
+
// agentdb's own declared (possibly hoisted-elsewhere) require before giving up
|
|
618
|
+
// (dz-harness-hub issue #10 defect 3).
|
|
619
|
+
const candidates = ['@huggingface/transformers', '@xenova/transformers'];
|
|
620
|
+
let transformers;
|
|
621
|
+
let lastErr;
|
|
622
|
+
for (const name of candidates) {
|
|
623
|
+
try {
|
|
624
|
+
transformers = req.resolve(name);
|
|
625
|
+
break;
|
|
626
|
+
} catch (err) {
|
|
627
|
+
lastErr = err;
|
|
628
|
+
try {
|
|
629
|
+
transformers = createRequire(req.resolve('agentdb/package.json')).resolve(name);
|
|
630
|
+
break;
|
|
631
|
+
} catch (err2) {
|
|
632
|
+
lastErr = err2;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (transformers === undefined) {
|
|
637
|
+
throw new Error('neither @huggingface/transformers nor @xenova/transformers could be resolved (' + String((lastErr && lastErr.message) || lastErr) + ')');
|
|
638
|
+
}
|
|
639
|
+
return { Database: req('better-sqlite3'), transformers };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const cos = (a, b) => {
|
|
643
|
+
let s = 0;
|
|
644
|
+
for (let i = 0; i < a.length; i += 1) s += a[i] * (b[i] ?? 0);
|
|
645
|
+
return s;
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
function dzIdOf(metadataJson) {
|
|
649
|
+
try {
|
|
650
|
+
const meta = JSON.parse(String(metadataJson || '{}'));
|
|
651
|
+
const id = meta?.dzId ?? meta?.dreamId;
|
|
652
|
+
return typeof id === 'string' && id.trim() !== '' ? id.trim() : undefined;
|
|
653
|
+
} catch {
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// lesson-quarantine: an unproven lesson is flagged so the hook POLICY can exclude it from
|
|
659
|
+
// auto-inject (the daemon only reports; the decision lives in selectHookHits).
|
|
660
|
+
function quarantinedOf(metadataJson) {
|
|
661
|
+
try {
|
|
662
|
+
return JSON.parse(String(metadataJson || '{}'))?.qStatus === 'quarantined';
|
|
663
|
+
} catch {
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function main() {
|
|
669
|
+
if (await socketAlive(SOCKET)) {
|
|
670
|
+
log('a daemon already owns', SOCKET, '— exiting');
|
|
671
|
+
process.exit(0);
|
|
672
|
+
}
|
|
673
|
+
if (existsSync(SOCKET)) {
|
|
674
|
+
try {
|
|
675
|
+
unlinkSync(SOCKET); // stale socket from a crashed predecessor
|
|
676
|
+
} catch {
|
|
677
|
+
/* the bind below will fail loudly enough */
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const started = Date.now();
|
|
682
|
+
const model = configuredModel();
|
|
683
|
+
let deps;
|
|
684
|
+
try {
|
|
685
|
+
deps = resolveDeps();
|
|
686
|
+
} catch (err) {
|
|
687
|
+
log('deps unavailable — not starting:', err?.message ?? err);
|
|
688
|
+
process.exit(0); // never a hard failure: the hook degrades to silence
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const { pipeline } = await import(deps.transformers);
|
|
692
|
+
const extractor = await pipeline('feature-extraction', model);
|
|
693
|
+
const embed = async (text) => Array.from((await extractor(text, { pooling: 'mean', normalize: true })).data);
|
|
694
|
+
|
|
695
|
+
// READ-ONLY. This process must never be the writer that tears the file for a concurrent reader.
|
|
696
|
+
const dbPath = join(PROJECT, '.dz', 'agentdb.db');
|
|
697
|
+
const DZ_TASK_TYPES = ['dz-teach', 'dz-learning'];
|
|
698
|
+
|
|
699
|
+
function loadPatterns() {
|
|
700
|
+
if (!existsSync(dbPath)) return [];
|
|
701
|
+
const db = new deps.Database(dbPath, { readonly: true, fileMustExist: true });
|
|
702
|
+
try {
|
|
703
|
+
const ph = DZ_TASK_TYPES.map(() => '?').join(',');
|
|
704
|
+
// NOTE: the vector mirror carries no \`domain\` — that column lives in the lexical store. An
|
|
705
|
+
// earlier draft read \`tags[0]\` and cheerfully labelled every hit \`dz-backfill\`, which is the
|
|
706
|
+
// SOURCE marker, not a domain. Emitting a wrong label is worse than emitting none.
|
|
707
|
+
const rows = db
|
|
708
|
+
.prepare(
|
|
709
|
+
\`SELECT p.approach AS pattern, p.metadata AS metadata, e.embedding AS embedding
|
|
710
|
+
FROM reasoning_patterns p JOIN pattern_embeddings e ON e.pattern_id = p.id
|
|
711
|
+
WHERE p.task_type IN (\${ph})\`,
|
|
712
|
+
)
|
|
713
|
+
.all(...DZ_TASK_TYPES);
|
|
714
|
+
return rows.map((r) => {
|
|
715
|
+
const buf = r.embedding;
|
|
716
|
+
const vec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
|
717
|
+
return { dzId: dzIdOf(r.metadata), pattern: r.pattern, vec, quarantined: quarantinedOf(r.metadata) };
|
|
718
|
+
});
|
|
719
|
+
} finally {
|
|
720
|
+
db.close();
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
let patterns = loadPatterns();
|
|
725
|
+
let patternsAt = Date.now();
|
|
726
|
+
log(\`ready: \${patterns.length} pattern vectors, model \${model}, socket \${SOCKET}\`);
|
|
727
|
+
|
|
728
|
+
let idleTimer;
|
|
729
|
+
let lastActivityAt = Date.now();
|
|
730
|
+
const touch = () => {
|
|
731
|
+
lastActivityAt = Date.now();
|
|
732
|
+
clearTimeout(idleTimer);
|
|
733
|
+
if (IDLE_MS === null) return; // idle exit disabled (AM-10)
|
|
734
|
+
idleTimer = setTimeout(() => {
|
|
735
|
+
// Prints the ACTUAL elapsed time, not the configured IDLE_MS — the two used to be treated as
|
|
736
|
+
// interchangeable, but a clamped or reconfigured value made the log claim a wait that never
|
|
737
|
+
// happened (AM-10).
|
|
738
|
+
log(\`idle \${Date.now() - lastActivityAt} ms — exiting (orphan safety)\`);
|
|
739
|
+
shutdown(0);
|
|
740
|
+
}, IDLE_MS);
|
|
741
|
+
idleTimer.unref?.();
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
const server = createServer((sock) => {
|
|
745
|
+
touch();
|
|
746
|
+
let buf = '';
|
|
747
|
+
sock.on('data', async (chunk) => {
|
|
748
|
+
buf += chunk.toString('utf8');
|
|
749
|
+
let nl;
|
|
750
|
+
while ((nl = buf.indexOf('\\n')) !== -1) {
|
|
751
|
+
const line = buf.slice(0, nl);
|
|
752
|
+
buf = buf.slice(nl + 1);
|
|
753
|
+
let reply;
|
|
754
|
+
try {
|
|
755
|
+
const msg = JSON.parse(line);
|
|
756
|
+
if (msg.op === 'ping') {
|
|
757
|
+
reply = { ok: true, model, uptimeMs: Date.now() - started, patterns: patterns.length };
|
|
758
|
+
} else if (msg.op === 'stop') {
|
|
759
|
+
sock.write(JSON.stringify({ ok: true }) + '\\n');
|
|
760
|
+
return shutdown(0);
|
|
761
|
+
} else if (msg.op === 'recall') {
|
|
762
|
+
// Reload the mirror if it changed on disk (a \`dz teach\` between turns).
|
|
763
|
+
if (Date.now() - patternsAt > 5000) {
|
|
764
|
+
try {
|
|
765
|
+
patterns = loadPatterns();
|
|
766
|
+
} catch {
|
|
767
|
+
/* keep the previous snapshot */
|
|
768
|
+
}
|
|
769
|
+
patternsAt = Date.now();
|
|
770
|
+
}
|
|
771
|
+
const prompt = typeof msg.prompt === 'string' ? msg.prompt : '';
|
|
772
|
+
if (prompt.trim() === '' || patterns.length === 0) {
|
|
773
|
+
reply = { hits: [] };
|
|
774
|
+
} else {
|
|
775
|
+
const qv = await embed(prompt);
|
|
776
|
+
const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), ...(p.quarantined ? { quarantined: true } : {}) }));
|
|
777
|
+
scored.sort((a, b) => b.score - a.score);
|
|
778
|
+
reply = { hits: scored.slice(0, Math.min(Number(msg.limit) || 8, 32)) };
|
|
779
|
+
}
|
|
780
|
+
} else {
|
|
781
|
+
reply = { error: \`unknown op \${String(msg.op)}\` };
|
|
782
|
+
}
|
|
783
|
+
} catch (err) {
|
|
784
|
+
reply = { error: err?.message ?? String(err) };
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
sock.write(JSON.stringify(reply) + '\\n');
|
|
788
|
+
} catch {
|
|
789
|
+
/* client vanished */
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
sock.on('error', () => sock.destroy());
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
function shutdown(code) {
|
|
797
|
+
try {
|
|
798
|
+
server.close();
|
|
799
|
+
} catch {
|
|
800
|
+
/* ignore */
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
if (existsSync(SOCKET)) unlinkSync(SOCKET);
|
|
804
|
+
} catch {
|
|
805
|
+
/* ignore */
|
|
806
|
+
}
|
|
807
|
+
process.exit(code);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => shutdown(0));
|
|
811
|
+
|
|
812
|
+
server.listen(SOCKET, () => touch());
|
|
813
|
+
server.on('error', (err) => {
|
|
814
|
+
log('listen failed:', err?.message ?? err);
|
|
815
|
+
process.exit(0);
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
main().catch((err) => {
|
|
820
|
+
log('fatal:', err?.message ?? err);
|
|
821
|
+
process.exit(0); // a dead daemon must never fail a session
|
|
822
|
+
});
|
|
823
|
+
`;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* The two hook-registry entries `runSetup` merges into `.claude/settings.json` (FR-1). Commands
|
|
827
|
+
* match the hub's own `.claude/settings.json` verbatim (`grep`-diffed against it at authoring time):
|
|
828
|
+
* the recall hook is invoked with a swallowed non-zero exit (`|| true`) so a broken hook body never
|
|
829
|
+
* fails a prompt, and the embed daemon is spawned detached via `nohup` + a backgrounding `sh -c`
|
|
830
|
+
* so `SessionStart` never waits on model load.
|
|
831
|
+
*/
|
|
832
|
+
export function applyLegHookEntries() {
|
|
833
|
+
return {
|
|
834
|
+
userPromptSubmit: {
|
|
835
|
+
hooks: [{
|
|
836
|
+
type: 'command',
|
|
837
|
+
command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/recall-hook.cjs" 2>/dev/null || true',
|
|
838
|
+
}],
|
|
839
|
+
},
|
|
840
|
+
sessionStart: {
|
|
841
|
+
hooks: [{
|
|
842
|
+
type: 'command',
|
|
843
|
+
command: "sh -c 'nohup node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/dz-embed-daemon.mjs\" >/dev/null 2>&1 & exit 0'",
|
|
844
|
+
}],
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
/** Read one deployed helper's exists/version/unreadable facts. Never throws. */
|
|
849
|
+
function readHelperStatus(path) {
|
|
850
|
+
const exists = existsSync(path);
|
|
851
|
+
if (!exists)
|
|
852
|
+
return { exists: false, version: -1 };
|
|
853
|
+
try {
|
|
854
|
+
return { exists: true, version: applyLegVersionOf(readFileSync(path, 'utf-8')) };
|
|
855
|
+
}
|
|
856
|
+
catch {
|
|
857
|
+
// Exists but could not be read (EISDIR — e.g. a directory left at the path — EACCES, a
|
|
858
|
+
// filesystem error mid-read, …). Distinct from absent: `missing-helpers` tells the reader to
|
|
859
|
+
// run `dz setup`, which does nothing for a file setup cannot even open (fix round 1, Q3).
|
|
860
|
+
return { exists: true, version: -1, unreadable: true };
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* ONE definition of "this command invokes our helper", shared by the installer (`setup.ts`
|
|
865
|
+
* `addIfMissing`) and the diagnostics (`applyLegStatus`) so they can never disagree (re-review
|
|
866
|
+
* Codex, third pass). A bare mention (`echo .claude/helpers/recall-hook.cjs`) is not an invocation:
|
|
867
|
+
* the helper path must follow a `node` word — directly, or inside the daemon's
|
|
868
|
+
* `sh -c 'nohup node "…"'` spawn. Forward slashes only: every command dz writes uses them.
|
|
869
|
+
*/
|
|
870
|
+
export function hookCommandInvokes(command, markerPath) {
|
|
871
|
+
const invoked = new RegExp('(^|[\\s;&|(])node\\s+[\'"]?[^\\s\'"]*' + markerPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'));
|
|
872
|
+
return invoked.test(command);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* A hook-registry entry naming `markerPath` is wired under `event` in the PARSED settings structure
|
|
876
|
+
* — never a raw substring scan over the whole file (fix round 1, HIGH finding 1). A comment, an
|
|
877
|
+
* unrelated field, or the marker under the WRONG event must not read as installed: only an actual
|
|
878
|
+
* `hooks.<event>[*].hooks[*].command` (or the legacy flat shape `hookCommandsOf` also understands)
|
|
879
|
+
* containing `markerPath` counts.
|
|
880
|
+
*/
|
|
881
|
+
function hookWiredUnder(settings, event, markerPath) {
|
|
882
|
+
const hooksSection = settings?.hooks;
|
|
883
|
+
const list = hooksSection && typeof hooksSection === 'object' ? hooksSection[event] : undefined;
|
|
884
|
+
if (!Array.isArray(list))
|
|
885
|
+
return false;
|
|
886
|
+
// Re-review Codex (B) finding: a bare substring (`echo .claude/helpers/recall-hook.cjs`) is not a
|
|
887
|
+
// hook. Our entries always INVOKE the helper — `node "<…>/.claude/helpers/<file>"` (directly, or
|
|
888
|
+
// inside the daemon's `sh -c 'nohup node "…"'`) — so the marker must follow a `node` invocation.
|
|
889
|
+
return list.some((entry) => hookCommandsOf(entry).some((cmd) => hookCommandInvokes(cmd, markerPath)));
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Measure the apply leg's actual state in `root` — plain `fs` reads, no injection (mirrors
|
|
893
|
+
* `writerVersionOf`'s own read style): a diagnostic that needed dependency injection to be testable
|
|
894
|
+
* would be a diagnostic nobody could point at a REAL project either. Never throws (fix round 1, Q3):
|
|
895
|
+
* an unreadable helper is a named fact in the return value, not an exception that would otherwise
|
|
896
|
+
* blow through `dz doctor`'s outer try/catch (silence) or crash `dz parity` outright.
|
|
897
|
+
*/
|
|
898
|
+
export function applyLegStatus(root) {
|
|
899
|
+
const recallHookPath = join(root, '.claude', 'helpers', 'recall-hook.cjs');
|
|
900
|
+
const embedDaemonPath = join(root, '.claude', 'helpers', 'dz-embed-daemon.mjs');
|
|
901
|
+
const recallHook = readHelperStatus(recallHookPath);
|
|
902
|
+
const embedDaemon = readHelperStatus(embedDaemonPath);
|
|
903
|
+
let userPromptSubmitPresent = false;
|
|
904
|
+
let sessionStartPresent = false;
|
|
905
|
+
try {
|
|
906
|
+
const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf-8'));
|
|
907
|
+
userPromptSubmitPresent = hookWiredUnder(settings, 'UserPromptSubmit', '.claude/helpers/recall-hook.cjs');
|
|
908
|
+
sessionStartPresent = hookWiredUnder(settings, 'SessionStart', '.claude/helpers/dz-embed-daemon.mjs');
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
// settings.json absent, unreadable, or not valid JSON — both stay false, the honest answer.
|
|
912
|
+
}
|
|
913
|
+
let backend = 'unknown';
|
|
914
|
+
try {
|
|
915
|
+
const cfg = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf-8'));
|
|
916
|
+
if (cfg.memory?.backend === 'agentdb')
|
|
917
|
+
backend = 'agentdb';
|
|
918
|
+
else if (cfg.memory?.backend === 'jsonl')
|
|
919
|
+
backend = 'jsonl';
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
// config.json absent or unreadable — 'unknown' is the honest answer, not a guess.
|
|
923
|
+
}
|
|
924
|
+
const anyUnreadable = recallHook.unreadable === true || embedDaemon.unreadable === true;
|
|
925
|
+
const helpersPresent = recallHook.exists && embedDaemon.exists;
|
|
926
|
+
const helpersCurrent = helpersPresent && !anyUnreadable && recallHook.version >= APPLY_LEG_VERSION && embedDaemon.version >= APPLY_LEG_VERSION;
|
|
927
|
+
const hooksPresent = userPromptSubmitPresent && sessionStartPresent;
|
|
928
|
+
const installed = helpersCurrent && hooksPresent;
|
|
929
|
+
let reason;
|
|
930
|
+
if (!installed) {
|
|
931
|
+
reason = anyUnreadable
|
|
932
|
+
? 'unreadable'
|
|
933
|
+
: !helpersPresent
|
|
934
|
+
? 'missing-helpers'
|
|
935
|
+
: !helpersCurrent
|
|
936
|
+
? 'stale-version'
|
|
937
|
+
: 'hooks-missing';
|
|
938
|
+
}
|
|
939
|
+
return {
|
|
940
|
+
installed,
|
|
941
|
+
...(reason !== undefined ? { reason } : {}),
|
|
942
|
+
helpers: {
|
|
943
|
+
recallHook: { path: recallHookPath, exists: recallHook.exists, version: recallHook.version, ...(recallHook.unreadable ? { unreadable: true } : {}) },
|
|
944
|
+
embedDaemon: { path: embedDaemonPath, exists: embedDaemon.exists, version: embedDaemon.version, ...(embedDaemon.unreadable ? { unreadable: true } : {}) },
|
|
945
|
+
},
|
|
946
|
+
hooks: {
|
|
947
|
+
userPromptSubmit: { present: userPromptSubmitPresent },
|
|
948
|
+
sessionStart: { present: sessionStartPresent },
|
|
949
|
+
},
|
|
950
|
+
backend,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Human remedy text shared VERBATIM between `dz doctor` and `dz parity` for the two reasons this
|
|
955
|
+
* fix round adds machinery for (fix round 1, HIGH finding 2 / Q3 finding 7): `stale-version` and
|
|
956
|
+
* `unreadable`. Both instruments read the SAME {@link ApplyLegStatus}; routing them through the same
|
|
957
|
+
* text-producing function is what makes "тексты совпадают по причине" a structural guarantee rather
|
|
958
|
+
* than two hand-written strings that happen to agree today. The `missing-helpers`/`hooks-missing`
|
|
959
|
+
* wording is deliberately NOT unified here — `dz doctor` and `dz parity` already carried different
|
|
960
|
+
* (pre-existing, tested) phrasing for that case before this fix round, and unifying it was not asked.
|
|
961
|
+
*/
|
|
962
|
+
export function applyLegReasonMessage(status) {
|
|
963
|
+
if (status.reason === 'stale-version') {
|
|
964
|
+
const deployedMin = Math.min(status.helpers.recallHook.version, status.helpers.embedDaemon.version);
|
|
965
|
+
return `installed v${deployedMin}, current v${APPLY_LEG_VERSION} — re-run dz setup to upgrade (no --force needed)`;
|
|
966
|
+
}
|
|
967
|
+
if (status.reason === 'unreadable') {
|
|
968
|
+
const bad = [status.helpers.recallHook, status.helpers.embedDaemon]
|
|
969
|
+
.filter((h) => h.unreadable === true)
|
|
970
|
+
.map((h) => h.path);
|
|
971
|
+
return `cannot read deployed helper(s): ${bad.join(', ')} — fix or remove it and re-run: dz setup --target claude-code --memory agentdb`;
|
|
972
|
+
}
|
|
973
|
+
return 'not installed — run dz setup --target claude-code --memory agentdb';
|
|
974
|
+
}
|
|
975
|
+
//# sourceMappingURL=apply-leg.js.map
|