@dzhechkov/harness-core 0.8.34 → 0.8.36
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 +61 -61
- package/README.md +255 -12
- package/dist/agentdb-index.d.ts +22 -1
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +156 -6
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +180 -2
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +781 -38
- package/dist/apply-leg.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +67 -5
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +13 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +13 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/mutation-gate.d.ts +19 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +37 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +17 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +88 -9
- package/dist/operations.js.map +1 -1
- package/dist/publish.d.ts +59 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +205 -32
- package/dist/publish.js.map +1 -1
- package/dist/release-line.d.ts +16 -0
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +31 -0
- package/dist/release-line.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +90 -14
- package/dist/setup.js.map +1 -1
- package/dist/skills.d.ts +87 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +266 -15
- package/dist/skills.js.map +1 -1
- package/dist/vector-tier.d.ts +34 -3
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +117 -22
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +60 -60
- package/src/agentdb-index.ts +158 -7
- package/src/apply-leg.ts +824 -38
- package/src/codex-hooks-assets.ts +67 -5
- package/src/codex-hooks.ts +13 -1
- package/src/index.ts +15 -2
- package/src/mutation-gate.ts +58 -2
- package/src/operations.ts +91 -10
- package/src/publish.ts +247 -30
- package/src/release-line.ts +32 -0
- package/src/setup.ts +81 -16
- package/src/skills.ts +303 -14
- package/src/vector-tier.ts +147 -24
package/dist/apply-leg.js
CHANGED
|
@@ -26,10 +26,16 @@
|
|
|
26
26
|
*
|
|
27
27
|
* @packageDocumentation
|
|
28
28
|
*/
|
|
29
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
29
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
30
30
|
import { connect as netConnect } from 'node:net';
|
|
31
|
-
|
|
31
|
+
// `node:os` is NOT one of the modules `countIoImports` (core-boundary.ts) tracks — free to import
|
|
32
|
+
// (core-boundary.test.ts's ratchet only counts fs/child_process/https). `probeApplyLeg`'s temp probe
|
|
33
|
+
// cwd reuses the existing top-level 'node:fs' import above (mkdtempSync/rmSync added to that SAME
|
|
34
|
+
// import statement, not a new one) so the ratchet stays at its pinned files:63 imports:69.
|
|
35
|
+
import { tmpdir, uptime } from 'node:os';
|
|
36
|
+
import { join, resolve } from 'node:path';
|
|
32
37
|
import { hookCommandsOf } from './managed-hooks.js';
|
|
38
|
+
import { patternRecordId, recordPattern, removePatternsByIds, loadStorePatternsSync } from './patterns.js';
|
|
33
39
|
/**
|
|
34
40
|
* FR-6 (feature `hook-recall-hybrid-parity`, ADR-001 C-4): send ONE `op: recall` probe to a LIVE
|
|
35
41
|
* embed daemon socket and report the `engine` it answers with (`'hybrid'` | `'cosine-fallback'`) —
|
|
@@ -143,8 +149,52 @@ export function probeRecallEngine(socketPath, timeoutMs = 1000) {
|
|
|
143
149
|
* fallback rather than a bare protocol error (AM-3); (d) reports the RAW core RRF score, unchanged,
|
|
144
150
|
* instead of a locally re-normalized [0,1] value (AM-5) — the hook's own `HOOK_SCORE_FLOOR` default
|
|
145
151
|
* moved from `0.01` to `0.005` to match (see that constant's own comment for the measurement).
|
|
152
|
+
*
|
|
153
|
+
* Bumped 6→7 (feature `apply-leg-install-root`, ADR-001 D1): both generated files now resolve
|
|
154
|
+
* `PROJECT` install-root-first (`INSTALL_ROOT` = this file's own location, when it owns a `.dz/`)
|
|
155
|
+
* instead of trusting a foreign session's `CLAUDE_PROJECT_DIR`/cwd (issue #2) — the hook's own
|
|
156
|
+
* `[dz-recall]` diagnostic line also gains `root=<path> (install|env|cwd)`.
|
|
157
|
+
*
|
|
158
|
+
* Bumped 7→8 (`apply-leg-install-root`, fix round 1, AM-7 HIGH — real regression MEASURED via
|
|
159
|
+
* `retro-debt-hook.test.ts` going 4/5 red on the v7 hub helper): `PROJECT` install-root-first is
|
|
160
|
+
* correct for the STORE (pattern db, socket, daemon script) but WRONG for a per-session artifact —
|
|
161
|
+
* the narrated-error retro-debt sentinel (`retro-pending.json`) is written by the INVOKING
|
|
162
|
+
* SESSION's own Stop hook under `CLAUDE_PROJECT_DIR`, not under wherever the hook happens to be
|
|
163
|
+
* installed; a shared $HOME install made the hook look for a foreign session's sentinel under the
|
|
164
|
+
* install root and silently drop every session's own debt confrontation. Split: `PROJECT` (install-
|
|
165
|
+
* root-first) stays the STORE root; a new `SESSION_ROOT` (`CLAUDE_PROJECT_DIR || cwd()`, the
|
|
166
|
+
* pre-feature resolution, unchanged) is the root for `RETRO_PENDING` — the ONE per-session file this
|
|
167
|
+
* hook reads (every other `PROJECT`-derived path in this file names the store, the daemon, or the
|
|
168
|
+
* harness-core install, confirmed by grep against every `path.join(PROJECT, …)` site). The diag line
|
|
169
|
+
* gains `session=<path>` alongside the existing `root=<path> (…)`.
|
|
170
|
+
*
|
|
171
|
+
* Bumped 8→9 (feature `apply-leg-never-silent`, ADR-001 D2, FR-1): every early return in `main()`
|
|
172
|
+
* now prints `[dz-recall] skipped reason=<store-not-found|socket-absent|core-unavailable|
|
|
173
|
+
* empty-prompt|no-hits> root=<path> (…) session=<path>` on stderr before returning — the hook used
|
|
174
|
+
* to exit silently on every one of these paths, indistinguishable (from stderr alone) from a
|
|
175
|
+
* correctly-quiet "nothing relevant" outcome. `embedDaemonSource`'s own bytes are UNCHANGED by this
|
|
176
|
+
* bump; the shared version number still advances because both helpers are upgraded as one unit by
|
|
177
|
+
* `dz setup`/`applyLegStatus`.
|
|
178
|
+
*
|
|
179
|
+
* Bumped 9→10 (`apply-leg-never-silent`, fix round 1 — cross-model review AM-3/AM-6): (a) the hook
|
|
180
|
+
* now tags its `op: recall` request with `probe: <bool>` (true only when
|
|
181
|
+
* `DZ_HOOK_LIVENESS_PROBE=1`, the env {@link "./operations.js".probeHookLiveness} already stamps on
|
|
182
|
+
* every live-probe spawn) so the daemon can tell a genuine session prompt apart from
|
|
183
|
+
* `probeApplyLeg`'s own beacon query — AM-3: a beacon written for the ~8s of a doctor/parity probe
|
|
184
|
+
* used to be recallable by ANY concurrent real prompt in the SAME project, a probe-only fixture
|
|
185
|
+
* leaking into a real session's context; (b) `askDaemon`'s every failure path used to collapse into
|
|
186
|
+
* one `undefined`, forcing the hook's own `skip('socket-absent')` call regardless of what actually
|
|
187
|
+
* went wrong — AM-6: it now returns a tagged `{error: 'socket-absent'|'connect-refused'|
|
|
188
|
+
* 'daemon-timeout'|'bad-reply'}` so the stderr reason names the ACTUAL failure (no socket file vs a
|
|
189
|
+
* non-socket file at that path vs a listener that never replies vs a listener that replies with
|
|
190
|
+
* something unparseable/shapeless). `embedDaemonSource`'s bytes also change for AM-3: `loadPatterns`
|
|
191
|
+
* now reads each row's `domain` out of the SAME metadata JSON `dzIdOf`/`quarantinedOf` already
|
|
192
|
+
* parse (the vector mirror carries no separate domain column), and both `hybridRecall`'s hits and
|
|
193
|
+
* the cosine-fallback `scored` array are filtered to exclude `domain === 'apply-leg-probe'` unless
|
|
194
|
+
* the request carried `probe: true` — a probe's own beacon still needs to reach ITS query, only a
|
|
195
|
+
* REAL prompt must never see it.
|
|
146
196
|
*/
|
|
147
|
-
export const APPLY_LEG_VERSION =
|
|
197
|
+
export const APPLY_LEG_VERSION = 10;
|
|
148
198
|
/**
|
|
149
199
|
* Parse the `dz-apply-leg-version` stamp from a deployed helper file. Unlike
|
|
150
200
|
* `writerVersionOf` (which floors an absent stamp at `0`), this returns `-1` for "no stamp at
|
|
@@ -234,7 +284,24 @@ const crypto = require('node:crypto');
|
|
|
234
284
|
const os = require('node:os');
|
|
235
285
|
const { pathToFileURL } = require('node:url');
|
|
236
286
|
|
|
237
|
-
|
|
287
|
+
// FR-1 (ADR-001 D1, apply-leg-install-root): resolve OUR OWN store from where this hook is
|
|
288
|
+
// INSTALLED, not from whatever project the invoking session happens to be in — a hook installed at
|
|
289
|
+
// $HOME (a common single-machine layout: \`dz setup --target claude --memory agentdb --project
|
|
290
|
+
// $HOME\`, expecting the leg everywhere) used to silently look up a DIFFERENT project's \`.dz/\`
|
|
291
|
+
// whenever CLAUDE_PROJECT_DIR pointed elsewhere (issue #2, MEASURED). Precedent:
|
|
292
|
+
// claude-hooks-assets.ts's \`path.resolve(__dirname, '..', '..')\` for the destructive-guard hook.
|
|
293
|
+
// Order: INSTALL_ROOT (if it owns a \`.dz/\`) -> CLAUDE_PROJECT_DIR -> cwd.
|
|
294
|
+
const INSTALL_ROOT = path.resolve(__dirname, '..', '..');
|
|
295
|
+
const ROOT_SOURCE = fs.existsSync(path.join(INSTALL_ROOT, '.dz')) ? 'install' : (process.env.CLAUDE_PROJECT_DIR ? 'env' : 'cwd');
|
|
296
|
+
const PROJECT = ROOT_SOURCE === 'install' ? INSTALL_ROOT : (process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
297
|
+
// AM-7 (fix round 1, apply-leg-install-root): PROJECT above is the STORE root (install-first) — a
|
|
298
|
+
// pattern db shared across every session at a $HOME install is correctly install-scoped. A
|
|
299
|
+
// per-session ARTIFACT is the opposite: the retro-debt sentinel is written by THIS SESSION's own
|
|
300
|
+
// Stop hook under its own CLAUDE_PROJECT_DIR, so looking it up under a foreign install root finds
|
|
301
|
+
// nothing (or, worse, another session's leftover file) and silently drops the confrontation.
|
|
302
|
+
// SESSION_ROOT is the pre-feature resolution, unchanged — the root for any file that belongs to the
|
|
303
|
+
// INVOKING session rather than to the store.
|
|
304
|
+
const SESSION_ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
238
305
|
const CORE_DIST_DIR = ${coreDistDir === null ? 'null' : JSON.stringify(coreDistDir)};
|
|
239
306
|
|
|
240
307
|
// embed-socket-short-path (FR-1/FR-2): byte-for-byte the same logic as \`resolveEmbedSocketPath\` /
|
|
@@ -270,6 +337,11 @@ function resolveEffectiveEmbedSocketPath(projectRoot, env) {
|
|
|
270
337
|
return pointer !== undefined && fs.existsSync(pointer) ? { path: pointer, reason: 'tmpdir-short' } : resolved;
|
|
271
338
|
}
|
|
272
339
|
const SOCKET = resolveEffectiveEmbedSocketPath(PROJECT, process.env).path;
|
|
340
|
+
// AM-3 (fix round 1, apply-leg-never-silent): set ONLY by probeHookLiveness (operations.ts) on
|
|
341
|
+
// every live-probe spawn — never by a real Claude Code session. Rides into the daemon's op:recall
|
|
342
|
+
// request as \`probe\` so the daemon can admit the probe's OWN beacon (domain=apply-leg-probe)
|
|
343
|
+
// without ever surfacing it to a concurrent real prompt in the same project.
|
|
344
|
+
const IS_LIVENESS_PROBE = process.env.DZ_HOOK_LIVENESS_PROBE === '1';
|
|
273
345
|
const USAGE_LOG = process.env.DZ_RECALL_USAGE_LOG || path.join(PROJECT, '.dz', 'recall-usage.jsonl');
|
|
274
346
|
// Measured (2026-09-14, apply-leg-socket.test.ts): an ordinary hook round-trip (spawn + one socket
|
|
275
347
|
// op) took 81-121 ms; 800 ms leaves a wide margin for a loaded daemon while still bounding the AM-2
|
|
@@ -362,7 +434,9 @@ async function loadPolicy() {
|
|
|
362
434
|
* - stale / other-session sentinel ⇒ '' (the debt belongs to a dead session; retro collected it);
|
|
363
435
|
* - core module absent or old (no directive exports) ⇒ '' — inert, NEVER-BLOCK.
|
|
364
436
|
*/
|
|
365
|
-
|
|
437
|
+
// AM-7 (fix round 1): SESSION_ROOT, not PROJECT — the sentinel is a per-session artifact (see the
|
|
438
|
+
// SESSION_ROOT comment above).
|
|
439
|
+
const RETRO_PENDING = path.join(SESSION_ROOT, '.dz', 'retro-pending.json');
|
|
366
440
|
async function retroDebtDirective(payload) {
|
|
367
441
|
if (!fs.existsSync(RETRO_PENDING)) return '';
|
|
368
442
|
const sentinel = safe(() => JSON.parse(fs.readFileSync(RETRO_PENDING, 'utf-8')), undefined);
|
|
@@ -417,9 +491,13 @@ function readLogTail(chain, file) {
|
|
|
417
491
|
// FR-6 (hook-recall-hybrid-parity): the reply now carries \`engine\`/\`reason\` alongside \`hits\` —
|
|
418
492
|
// returned as a small object rather than the bare hit array, so the caller can apply the RIGHT
|
|
419
493
|
// floor (FR-5) and print the engine to stderr ONLY (never into the injected context, FR-2).
|
|
494
|
+
// AM-6 (fix round 1): every failure used to collapse into one \`undefined\`, forcing the caller's
|
|
495
|
+
// \`skip('socket-absent')\` regardless of whether the socket was truly absent, refused the connect,
|
|
496
|
+
// never replied, or replied with garbage. Each branch now tags its OWN reason so the stderr line
|
|
497
|
+
// (and, through it, \`probeApplyLeg\`'s parsed reason) names what actually happened.
|
|
420
498
|
function askDaemon(prompt) {
|
|
421
499
|
return new Promise((resolve) => {
|
|
422
|
-
if (!fs.existsSync(SOCKET)) return resolve(
|
|
500
|
+
if (!fs.existsSync(SOCKET)) return resolve({ error: 'socket-absent' });
|
|
423
501
|
let settled = false;
|
|
424
502
|
const done = (v) => {
|
|
425
503
|
if (settled) return;
|
|
@@ -428,29 +506,29 @@ function askDaemon(prompt) {
|
|
|
428
506
|
resolve(v);
|
|
429
507
|
};
|
|
430
508
|
const sock = net.connect(SOCKET);
|
|
431
|
-
const timer = setTimeout(() => done(
|
|
509
|
+
const timer = setTimeout(() => done({ error: 'daemon-timeout' }), TIMEOUT_MS);
|
|
432
510
|
timer.unref?.();
|
|
433
511
|
let buf = '';
|
|
434
|
-
sock.on('connect', () => sock.write(JSON.stringify({ op: 'recall', prompt, limit: 8 }) + '\\n'));
|
|
512
|
+
sock.on('connect', () => sock.write(JSON.stringify({ op: 'recall', prompt, limit: 8, probe: IS_LIVENESS_PROBE }) + '\\n'));
|
|
435
513
|
sock.on('data', (chunk) => {
|
|
436
514
|
buf += chunk.toString('utf8');
|
|
437
515
|
const nl = buf.indexOf('\\n');
|
|
438
516
|
if (nl === -1) return;
|
|
439
517
|
clearTimeout(timer);
|
|
440
518
|
const msg = safe(() => JSON.parse(buf.slice(0, nl)), undefined);
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
519
|
+
if (msg && Array.isArray(msg.hits)) {
|
|
520
|
+
done({
|
|
521
|
+
hits: msg.hits,
|
|
522
|
+
engine: typeof msg.engine === 'string' ? msg.engine : undefined,
|
|
523
|
+
reason: typeof msg.reason === 'string' ? msg.reason : undefined,
|
|
524
|
+
});
|
|
525
|
+
} else {
|
|
526
|
+
done({ error: 'bad-reply' });
|
|
527
|
+
}
|
|
450
528
|
});
|
|
451
529
|
sock.on('error', () => {
|
|
452
530
|
clearTimeout(timer);
|
|
453
|
-
done(
|
|
531
|
+
done({ error: 'connect-refused' });
|
|
454
532
|
});
|
|
455
533
|
});
|
|
456
534
|
}
|
|
@@ -458,6 +536,13 @@ function askDaemon(prompt) {
|
|
|
458
536
|
const REVIVE_LOCK = path.join(PROJECT, '.dz', 'embed-daemon.lock');
|
|
459
537
|
const REVIVE_LOCK_FRESH_MS = 120_000; // model load takes ~45s; don't respawn while one is coming up
|
|
460
538
|
function reviveDaemon() {
|
|
539
|
+
// AM-7 (fix round 1, test-only): a test that probes the SAME dead fixture multiple times in quick
|
|
540
|
+
// succession (probeApplyLeg directly, then again through runDoctor, then again through a
|
|
541
|
+
// 'dz parity' subprocess) used to race against this very self-heal — the first probe's revive
|
|
542
|
+
// could finish loading a real daemon before the second or third probe ran, flipping
|
|
543
|
+
// "socket-absent" into "hybrid"/"cosine-fallback" non-deterministically. No production session
|
|
544
|
+
// ever sets this.
|
|
545
|
+
if (process.env.DZ_RECALL_NO_REVIVE === '1') return;
|
|
461
546
|
// CROSS-PROCESS lock: every prompt runs a fresh hook process, so a per-process flag let 20 queued
|
|
462
547
|
// prompts spawn 20 daemons while the first was still loading its model (Codex #5). A lockfile with
|
|
463
548
|
// a freshness window means at most one spawn per window, machine-wide.
|
|
@@ -623,6 +708,17 @@ function emitContext(context) {
|
|
|
623
708
|
);
|
|
624
709
|
}
|
|
625
710
|
|
|
711
|
+
// FR-1 (ADR-001 D2, apply-leg-never-silent): every silent early exit below now names WHY, on
|
|
712
|
+
// stderr, one line, same shape as the existing \`[dz-recall] engine=…\` diagnostic. Exit code stays
|
|
713
|
+
// 0 — a broken/empty/quiet hook must never fail a prompt (NEVER-BLOCK, unchanged). The reason is
|
|
714
|
+
// for TWO readers, neither of which is "the user watching Claude Code's own transcript" (FR-2's
|
|
715
|
+
// own manifest names why that channel does not apply to this hook): (1) \`dz doctor\`'s live probe,
|
|
716
|
+
// which spawns this exact command as a child process and reads ITS OWN child's stderr directly —
|
|
717
|
+
// unmediated by Claude Code's UI, so the redirect policy of any particular hook EVENT is moot; and
|
|
718
|
+
// (2) a human running the command by hand from a terminal, who sees stderr exactly as printed.
|
|
719
|
+
const skip = (reason) =>
|
|
720
|
+
safe(() => process.stderr.write(\`[dz-recall] skipped reason=\${reason} root=\${PROJECT} (\${ROOT_SOURCE}) session=\${SESSION_ROOT}\\n\`));
|
|
721
|
+
|
|
626
722
|
async function main() {
|
|
627
723
|
const raw = readStdin();
|
|
628
724
|
const payload = safe(() => JSON.parse(String(raw || '').trim()), undefined);
|
|
@@ -633,13 +729,34 @@ async function main() {
|
|
|
633
729
|
// sentinel is absent this is one existsSync and debt === '' (byte-identical outputs to before).
|
|
634
730
|
const debt = await retroDebtDirective(payload);
|
|
635
731
|
|
|
636
|
-
if (prompt === '')
|
|
732
|
+
if (prompt === '') {
|
|
733
|
+
skip('empty-prompt');
|
|
734
|
+
return emitContext(debt);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// FR-1: the most fundamental silent failure (issue #2) — no \`.dz/\` at all under the resolved
|
|
738
|
+
// PROJECT root. Checked BEFORE the policy/daemon legs below: a missing store makes every
|
|
739
|
+
// downstream question ("is the daemon alive?") moot, and printing THIS reason first is what let
|
|
740
|
+
// the original issue's symptom (four green checks, a store that was never there) be diagnosed
|
|
741
|
+
// from stderr alone.
|
|
742
|
+
if (!fs.existsSync(path.join(PROJECT, '.dz'))) {
|
|
743
|
+
skip('store-not-found');
|
|
744
|
+
return emitContext(debt);
|
|
745
|
+
}
|
|
637
746
|
|
|
638
747
|
const policy = await loadPolicy();
|
|
639
|
-
if (!policy)
|
|
748
|
+
if (!policy) {
|
|
749
|
+
skip('core-unavailable');
|
|
750
|
+
return emitContext(debt);
|
|
751
|
+
}
|
|
640
752
|
|
|
641
753
|
const daemonReply = await askDaemon(prompt);
|
|
642
|
-
|
|
754
|
+
// AM-6: the tagged reason IS the diagnosis now — 'socket-absent' (no file), 'connect-refused' (a
|
|
755
|
+
// file exists but nothing answers like a daemon), 'daemon-timeout' (something answers, never in
|
|
756
|
+
// time) and 'bad-reply' (answers, unparseable/shapeless) are four DIFFERENT defects with four
|
|
757
|
+
// different remedies; collapsing them back into one string is exactly the finding this fixes.
|
|
758
|
+
if (daemonReply.error) {
|
|
759
|
+
skip(daemonReply.error);
|
|
643
760
|
// SELF-HEAL (2026-07-28): the daemon is started at SessionStart only, so when it dies mid-way
|
|
644
761
|
// through a long-lived session NOTHING restarts it — the apply leg was silently dead for 19
|
|
645
762
|
// days (MEASURED: recall-usage.jsonl last record 2026-07-09, socket absent). Spawn it
|
|
@@ -651,9 +768,12 @@ async function main() {
|
|
|
651
768
|
// FR-6/FR-2: the engine (and, on fallback, why) is the caller's business, not the model's — it
|
|
652
769
|
// NEVER rides into additionalContext, only stderr, which Claude Code does not read as context.
|
|
653
770
|
if (typeof engine === 'string') {
|
|
654
|
-
safe(() => process.stderr.write(\`[dz-recall] engine=\${engine}\${reason ? \` reason=\${reason}\` : ''}\\n\`));
|
|
771
|
+
safe(() => process.stderr.write(\`[dz-recall] engine=\${engine}\${reason ? \` reason=\${reason}\` : ''} root=\${PROJECT} (\${ROOT_SOURCE}) session=\${SESSION_ROOT}\\n\`));
|
|
772
|
+
}
|
|
773
|
+
if (hits.length === 0) {
|
|
774
|
+
skip('no-hits');
|
|
775
|
+
return emitContext(debt); // daemon alive, nothing relevant — silence is correct
|
|
655
776
|
}
|
|
656
|
-
if (hits.length === 0) return emitContext(debt); // daemon alive, nothing relevant — silence is correct
|
|
657
777
|
|
|
658
778
|
// FR-5 (ADR-001 D2): a hybrid-engine reply carries an RRF-based score — its OWN floor, applied to
|
|
659
779
|
// both languages. A cosine-fallback reply (or an old daemon that never sent \`engine\` at all)
|
|
@@ -768,14 +888,19 @@ import { createRequire } from 'node:module';
|
|
|
768
888
|
import { connect } from 'node:net';
|
|
769
889
|
import { createHash } from 'node:crypto';
|
|
770
890
|
import { tmpdir } from 'node:os';
|
|
771
|
-
import { pathToFileURL } from 'node:url';
|
|
891
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
772
892
|
|
|
773
893
|
const log = (...a) => console.error('[dz-embed]', ...a);
|
|
774
894
|
|
|
775
895
|
/** Never let a diagnostic reach stdout — the hook that spawns us may be parsing it. */
|
|
776
896
|
console.log = (...a) => console.error(...a);
|
|
777
897
|
|
|
778
|
-
|
|
898
|
+
// FR-4 (ADR-001 D1, apply-leg-install-root): the SAME install-root-first order the hook uses (see
|
|
899
|
+
// recallHookSource's own PROJECT comment) — DZ_PROJECT_ROOT stays the TOP override for the daemon
|
|
900
|
+
// (a caller that explicitly names a project root always wins), then INSTALL_ROOT (this file's own
|
|
901
|
+
// location, when it owns a \`.dz/\`), then cwd.
|
|
902
|
+
const INSTALL_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
903
|
+
const PROJECT = process.env['DZ_PROJECT_ROOT'] ?? (existsSync(join(INSTALL_ROOT, '.dz')) ? INSTALL_ROOT : process.cwd());
|
|
779
904
|
|
|
780
905
|
// ADR-001 (hook-recall-hybrid-parity, D1): the SAME candidate-list resolution the hook uses for its
|
|
781
906
|
// own policy modules — the baked \`coreDistDir\` first (a real install's absolute dist path), then
|
|
@@ -952,6 +1077,27 @@ function quarantinedOf(metadataJson) {
|
|
|
952
1077
|
}
|
|
953
1078
|
}
|
|
954
1079
|
|
|
1080
|
+
// AM-3 (fix round 1, apply-leg-never-silent): the vector mirror's own row carries no domain column
|
|
1081
|
+
// (see loadPatterns' own note below) — domain lives in the SAME metadata JSON dzIdOf/quarantinedOf
|
|
1082
|
+
// already parse, so this is the ONE extra field read off a column that's already in hand.
|
|
1083
|
+
function domainOf(metadataJson) {
|
|
1084
|
+
try {
|
|
1085
|
+
const d = JSON.parse(String(metadataJson || '{}'))?.domain;
|
|
1086
|
+
return typeof d === 'string' && d !== '' ? d : undefined;
|
|
1087
|
+
} catch {
|
|
1088
|
+
return undefined;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// AM-3: the ONE domain a genuine session must never see — probeApplyLeg's own beacon marker
|
|
1093
|
+
// (apply-leg.ts's PROBE_BEACON_DOMAIN, inlined here as text for the same reason every other shared
|
|
1094
|
+
// constant in this generated file is: a template string cannot import a compiled module).
|
|
1095
|
+
const PROBE_BEACON_DOMAIN = 'apply-leg-probe';
|
|
1096
|
+
/** Strip probe-domain hits from a real (non-probe) answer; a probe request passes through untouched. */
|
|
1097
|
+
function filterProbeHits(hits, probe) {
|
|
1098
|
+
return probe ? hits : hits.filter((h) => h.domain !== PROBE_BEACON_DOMAIN);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
955
1101
|
async function main() {
|
|
956
1102
|
if (await socketAlive(SOCKET)) {
|
|
957
1103
|
log('a daemon already owns', SOCKET, '— exiting');
|
|
@@ -1001,7 +1147,9 @@ async function main() {
|
|
|
1001
1147
|
return rows.map((r) => {
|
|
1002
1148
|
const buf = r.embedding;
|
|
1003
1149
|
const vec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
|
1004
|
-
|
|
1150
|
+
// AM-3: domain rides along so answerRecall can exclude the probe's own beacon from a real
|
|
1151
|
+
// session's cosine-fallback answer — the SAME metadata column dzIdOf/quarantinedOf already read.
|
|
1152
|
+
return { dzId: dzIdOf(r.metadata), pattern: r.pattern, vec, quarantined: quarantinedOf(r.metadata), domain: domainOf(r.metadata) };
|
|
1005
1153
|
});
|
|
1006
1154
|
} finally {
|
|
1007
1155
|
db.close();
|
|
@@ -1027,7 +1175,7 @@ async function main() {
|
|
|
1027
1175
|
// \`hybridRecall\`'s own promise is left running past a timeout loss (never awaited a second time)
|
|
1028
1176
|
// — its \`.catch\` below only silences a LATE rejection so a slow, eventually-failing engine call
|
|
1029
1177
|
// can never become an unhandled-rejection crash for this long-lived process.
|
|
1030
|
-
async function hybridRecall(prompt, limit) {
|
|
1178
|
+
async function hybridRecall(prompt, limit, probe) {
|
|
1031
1179
|
if (hybridInFlight >= HYBRID_MAX_IN_FLIGHT) {
|
|
1032
1180
|
return { ok: false, reason: \`hybrid saturated (\${hybridInFlight} attempt(s) still in flight, cap \${HYBRID_MAX_IN_FLIGHT})\` };
|
|
1033
1181
|
}
|
|
@@ -1067,7 +1215,10 @@ async function main() {
|
|
|
1067
1215
|
domain: h.pattern.domain,
|
|
1068
1216
|
...(h.quarantined ? { quarantined: true } : {}),
|
|
1069
1217
|
}));
|
|
1070
|
-
|
|
1218
|
+
// AM-3 (fix round 1): filter BEFORE slicing to \`limit\` — filtering after would let a probe
|
|
1219
|
+
// beacon that happened to rank in the top \`limit\` silently crowd out a real hit for a real
|
|
1220
|
+
// (non-probe) caller instead of simply being excluded from consideration.
|
|
1221
|
+
return { ok: true, hits: filterProbeHits(hits, probe).slice(0, limit) };
|
|
1071
1222
|
} catch (err) {
|
|
1072
1223
|
clearTimeout(timer);
|
|
1073
1224
|
return { ok: false, reason: \`recallHybrid failed: \${err?.message ?? err}\` };
|
|
@@ -1076,10 +1227,10 @@ async function main() {
|
|
|
1076
1227
|
|
|
1077
1228
|
/** \`op: recall\`'s whole answer: hybrid first (budget-bounded), cosine fallback on ANY failure —
|
|
1078
1229
|
* always honestly labelled with \`engine\`/\`reason\` (FR-2/FR-6). */
|
|
1079
|
-
async function answerRecall(prompt, limitRaw) {
|
|
1230
|
+
async function answerRecall(prompt, limitRaw, probe) {
|
|
1080
1231
|
const limit = Math.min(Number(limitRaw) || 8, 32);
|
|
1081
1232
|
if (prompt.trim() === '') return { hits: [], engine: 'none', reason: 'empty prompt' }; // Codex round-2: every reply carries \`engine\`
|
|
1082
|
-
const hybrid = await hybridRecall(prompt, limit);
|
|
1233
|
+
const hybrid = await hybridRecall(prompt, limit, probe);
|
|
1083
1234
|
if (hybrid.ok) return { hits: hybrid.hits, engine: 'hybrid' };
|
|
1084
1235
|
// Reload the cosine mirror if it changed on disk (a \`dz teach\` between turns) — the SAME
|
|
1085
1236
|
// staleness window as before this feature, just checked only when actually falling back.
|
|
@@ -1093,9 +1244,10 @@ async function main() {
|
|
|
1093
1244
|
}
|
|
1094
1245
|
if (patterns.length === 0) return { hits: [], engine: 'cosine-fallback', reason: hybrid.reason };
|
|
1095
1246
|
const qv = await embed(prompt);
|
|
1096
|
-
const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), ...(p.quarantined ? { quarantined: true } : {}) }));
|
|
1247
|
+
const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), domain: p.domain, ...(p.quarantined ? { quarantined: true } : {}) }));
|
|
1097
1248
|
scored.sort((a, b) => b.score - a.score);
|
|
1098
|
-
|
|
1249
|
+
// AM-3: same domain exclusion as the hybrid leg, applied before slicing for the same reason.
|
|
1250
|
+
return { hits: filterProbeHits(scored, probe).slice(0, limit), engine: 'cosine-fallback', reason: hybrid.reason };
|
|
1099
1251
|
}
|
|
1100
1252
|
|
|
1101
1253
|
// AM-1 (fix round 1): warm resolveAgentdbEmbedder — cached PER PROCESS since db1521ba (cold
|
|
@@ -1156,7 +1308,7 @@ async function main() {
|
|
|
1156
1308
|
return shutdown(0);
|
|
1157
1309
|
} else if (msg.op === 'recall') {
|
|
1158
1310
|
const prompt = typeof msg.prompt === 'string' ? msg.prompt : '';
|
|
1159
|
-
reply = await answerRecall(prompt, msg.limit);
|
|
1311
|
+
reply = await answerRecall(prompt, msg.limit, msg.probe === true);
|
|
1160
1312
|
} else {
|
|
1161
1313
|
reply = { error: \`unknown op \${String(msg.op)}\` };
|
|
1162
1314
|
}
|
|
@@ -1251,25 +1403,101 @@ main().catch((err) => {
|
|
|
1251
1403
|
});
|
|
1252
1404
|
`;
|
|
1253
1405
|
}
|
|
1406
|
+
/**
|
|
1407
|
+
* POSIX single-quote a value for safe interpolation into a shell command line: wraps it in `'`,
|
|
1408
|
+
* escaping every embedded `'` as the standard `'\''` sequence (close quote, literal escaped quote,
|
|
1409
|
+
* reopen quote). Single quotes disable EVERY shell expansion — `$`, backticks, `"`, another `'` —
|
|
1410
|
+
* unlike a bare `"..."` interpolation, which blocks only whitespace/globbing and still lets `$`/
|
|
1411
|
+
* backtick content run (AM-1, fix round 1, HIGH).
|
|
1412
|
+
*/
|
|
1413
|
+
function shellQuote(value) {
|
|
1414
|
+
return `'${value.split(`'`).join(`'\\''`)}'`;
|
|
1415
|
+
}
|
|
1254
1416
|
/**
|
|
1255
1417
|
* The two hook-registry entries `runSetup` merges into `.claude/settings.json` (FR-1). Commands
|
|
1256
1418
|
* match the hub's own `.claude/settings.json` verbatim (`grep`-diffed against it at authoring time):
|
|
1257
1419
|
* the recall hook is invoked with a swallowed non-zero exit (`|| true`) so a broken hook body never
|
|
1258
1420
|
* fails a prompt, and the embed daemon is spawned detached via `nohup` + a backgrounding `sh -c`
|
|
1259
1421
|
* so `SessionStart` never waits on model load.
|
|
1422
|
+
*
|
|
1423
|
+
* `installRoot` (ADR-001 D2, feature `apply-leg-install-root`): when the caller (`dz setup`) knows
|
|
1424
|
+
* its own install root, the commands bake it in as an ABSOLUTE path — the deployed helper already
|
|
1425
|
+
* bakes an absolute `CORE_DIST_DIR`, so a `${CLAUDE_PROJECT_DIR:-.}`-relative command in
|
|
1426
|
+
* settings.json only masked that non-portability, and broke down to `Cannot find module` (swallowed
|
|
1427
|
+
* by `2>/dev/null || true`) whenever `project === $HOME` and a session's own `CLAUDE_PROJECT_DIR`
|
|
1428
|
+
* pointed elsewhere (issue #2). Omitting `installRoot` (every pre-existing zero-arg caller — status
|
|
1429
|
+
* fixtures, `applyLegStatus` regression tests) keeps the original `CLAUDE_PROJECT_DIR`-relative
|
|
1430
|
+
* form byte for byte; `hookCommandInvokes`/`applyLegStatus` (FR-3) recognize BOTH forms as wired,
|
|
1431
|
+
* and `runSetup`'s `addIfMissing` (setup.ts) REPLACES a stale form with the current one in place —
|
|
1432
|
+
* never a second entry — on re-setup.
|
|
1433
|
+
*
|
|
1434
|
+
* Fix round 1 corrections to the absolute (`installRoot`-given) branch — the legacy zero-arg branch
|
|
1435
|
+
* is UNCHANGED byte for byte:
|
|
1436
|
+
* - AM-1 (HIGH): a caller-controlled path was interpolated RAW into shell source. A `"`, `$`,
|
|
1437
|
+
* backtick, or `'` in `installRoot` altered or injected commands, and the SessionStart form broke
|
|
1438
|
+
* outright on a `'` (it cannot be escaped inside a `'...'` body by nesting `"`). Fixed:
|
|
1439
|
+
* {@link shellQuote} wraps every path; SessionStart passes them as POSITIONAL ARGS (`$1`/`$2`) to
|
|
1440
|
+
* an INNER `sh -c` whose script text is a FIXED literal with no caller-controlled bytes, so
|
|
1441
|
+
* nested-quote fragility cannot arise at all.
|
|
1442
|
+
* - AM-3 (HIGH): the daemon used to fall back to `DZ_PROJECT_ROOT ?? installLocal`, and nothing in
|
|
1443
|
+
* the SessionStart command ever SET that variable — a stale inherited `DZ_PROJECT_ROOT` in the
|
|
1444
|
+
* parent env could win over the install root the hook itself resolves to. Fixed: the SessionStart
|
|
1445
|
+
* command now sets `DZ_PROJECT_ROOT="$1"` (`$1` = installRoot) explicitly, so the daemon and the
|
|
1446
|
+
* hook agree by construction regardless of what the parent environment happens to carry.
|
|
1447
|
+
* - AM-4 (LOW): a relative `installRoot` used to produce a relative command, breaking the "every
|
|
1448
|
+
* baked path is absolute" invariant the module's own docs claim. Fixed: `resolve()`s its input.
|
|
1260
1449
|
*/
|
|
1261
|
-
export function applyLegHookEntries() {
|
|
1450
|
+
export function applyLegHookEntries(installRoot) {
|
|
1451
|
+
if (installRoot === undefined) {
|
|
1452
|
+
// Legacy zero-arg form — byte-identical to every pre-fix-round build. Kept only for
|
|
1453
|
+
// `applyLegStatus`'s upgrade-recognition tests (a pre-feature install's settings.json) and for
|
|
1454
|
+
// seeding "stale entry" fixtures; every real `dz setup` caller passes `opts.projectRoot`.
|
|
1455
|
+
return {
|
|
1456
|
+
userPromptSubmit: {
|
|
1457
|
+
hooks: [{
|
|
1458
|
+
type: 'command',
|
|
1459
|
+
// FR-2 (apply-leg-never-silent, ADR-001 D2): `2>/dev/null` removed — the hook itself now
|
|
1460
|
+
// names every silent exit on stderr (FR-1), and swallowing that stream at the settings.json
|
|
1461
|
+
// level would defeat it at the source. `|| true` stays: a broken hook body must never fail
|
|
1462
|
+
// the prompt.
|
|
1463
|
+
command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/recall-hook.cjs" || true',
|
|
1464
|
+
}],
|
|
1465
|
+
},
|
|
1466
|
+
sessionStart: {
|
|
1467
|
+
hooks: [{
|
|
1468
|
+
type: 'command',
|
|
1469
|
+
command: "sh -c 'nohup node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/dz-embed-daemon.mjs\" >/dev/null 2>&1 & exit 0'",
|
|
1470
|
+
}],
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
// AM-4: make a relative caller input absolute so the "every baked path is absolute" invariant
|
|
1475
|
+
// holds regardless of what the caller passed, not merely for callers that already resolve first.
|
|
1476
|
+
const root = resolve(installRoot);
|
|
1477
|
+
const recallHookPath = `${root}/.claude/helpers/recall-hook.cjs`;
|
|
1478
|
+
const daemonPath = `${root}/.claude/helpers/dz-embed-daemon.mjs`;
|
|
1262
1479
|
return {
|
|
1263
1480
|
userPromptSubmit: {
|
|
1264
1481
|
hooks: [{
|
|
1265
1482
|
type: 'command',
|
|
1266
|
-
|
|
1483
|
+
// AM-1: shellQuote the WHOLE path — a bare `"..."` interpolation only blocks whitespace and
|
|
1484
|
+
// globbing, it still lets `$`, backticks, and a literal `"` do damage; single-quoting blocks
|
|
1485
|
+
// every shell expansion at once.
|
|
1486
|
+
// FR-2 (apply-leg-never-silent, ADR-001 D2): `2>/dev/null` removed — see the zero-arg branch's
|
|
1487
|
+
// comment above for why.
|
|
1488
|
+
command: `node ${shellQuote(recallHookPath)} || true`,
|
|
1267
1489
|
}],
|
|
1268
1490
|
},
|
|
1269
1491
|
sessionStart: {
|
|
1270
1492
|
hooks: [{
|
|
1271
1493
|
type: 'command',
|
|
1272
|
-
|
|
1494
|
+
// AM-1/AM-3: the INNER script text (`'DZ_PROJECT_ROOT="$1" nohup node "$2" …'`) is a FIXED
|
|
1495
|
+
// literal — no caller-controlled byte ever sits inside it, so it can never itself contain an
|
|
1496
|
+
// unescaped `'` that would break the outer single-quoting. `root`/`daemonPath` instead arrive
|
|
1497
|
+
// as POSITIONAL ARGS (`$1`/`$2`), each independently shellQuote()d for the OUTER shell that
|
|
1498
|
+
// parses this whole command line. AM-3: `DZ_PROJECT_ROOT="$1"` pins the daemon to THIS
|
|
1499
|
+
// install root explicitly — a stale value already in the parent environment can never win.
|
|
1500
|
+
command: `sh -c 'DZ_PROJECT_ROOT="$1" nohup node "$2" >/dev/null 2>&1 & exit 0' sh ${shellQuote(root)} ${shellQuote(daemonPath)}`,
|
|
1273
1501
|
}],
|
|
1274
1502
|
},
|
|
1275
1503
|
};
|
|
@@ -1295,10 +1523,29 @@ function readHelperStatus(path) {
|
|
|
1295
1523
|
* Codex, third pass). A bare mention (`echo .claude/helpers/recall-hook.cjs`) is not an invocation:
|
|
1296
1524
|
* the helper path must follow a `node` word — directly, or inside the daemon's
|
|
1297
1525
|
* `sh -c 'nohup node "…"'` spawn. Forward slashes only: every command dz writes uses them.
|
|
1526
|
+
*
|
|
1527
|
+
* Fix round 1 (AM-1/AM-3, apply-leg-install-root): the SessionStart command now passes its daemon
|
|
1528
|
+
* path as a POSITIONAL ARG (`sh -c '… node "$2" …' sh <root> <daemonPath>`) rather than interpolating
|
|
1529
|
+
* it textually next to `node`, so the ORIGINAL adjacency regex alone no longer matches it. A SECOND
|
|
1530
|
+
* recognizer accepts that shape: the command invokes `node` with a `$N`-style positional argument
|
|
1531
|
+
* AND carries `markerPath` as one of its own (shellQuote()d) trailing arguments — both conditions
|
|
1532
|
+
* together, so a foreign command that merely echoes the marker path near an unrelated `node "$1"`
|
|
1533
|
+
* invocation still does not count.
|
|
1298
1534
|
*/
|
|
1299
1535
|
export function hookCommandInvokes(command, markerPath) {
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1536
|
+
const escapedMarker = markerPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
1537
|
+
const direct = new RegExp('(^|[\\s;&|(])node\\s+[\'"]?[^\\s\'"]*' + escapedMarker);
|
|
1538
|
+
if (direct.test(command))
|
|
1539
|
+
return true;
|
|
1540
|
+
// Codex round-2 (NEW): the absolute form is POSIX-single-quoted (`node '<root>/.claude/…'`), and a
|
|
1541
|
+
// root may contain whitespace or `'` (written as `'\''`) — the bare `[^\s'"]*` run above stops at
|
|
1542
|
+
// the first space, so such an entry read as "absent" and re-setup appended a duplicate.
|
|
1543
|
+
const quotedDirect = new RegExp("(^|[\\s;&|(])node\\s+'(?:[^']|'\\\\'')*" + escapedMarker);
|
|
1544
|
+
if (quotedDirect.test(command))
|
|
1545
|
+
return true;
|
|
1546
|
+
const invokesNodeWithPositional = /\bnode\s+["']?\$\d/.test(command);
|
|
1547
|
+
const markerAsQuotedArg = new RegExp("'[^']*" + escapedMarker + "'");
|
|
1548
|
+
return invokesNodeWithPositional && markerAsQuotedArg.test(command);
|
|
1302
1549
|
}
|
|
1303
1550
|
/**
|
|
1304
1551
|
* A hook-registry entry naming `markerPath` is wired under `event` in the PARSED settings structure
|
|
@@ -1401,4 +1648,500 @@ export function applyLegReasonMessage(status) {
|
|
|
1401
1648
|
}
|
|
1402
1649
|
return 'not installed — run dz setup --target claude-code --memory agentdb';
|
|
1403
1650
|
}
|
|
1651
|
+
/**
|
|
1652
|
+
* The ACTUAL command `.claude/settings.json` carries for the wired `UserPromptSubmit` recall hook —
|
|
1653
|
+
* not a reconstruction. {@link probeApplyLeg} must run exactly what a real session would run,
|
|
1654
|
+
* `${CLAUDE_PROJECT_DIR:-.}`-relative legacy form and all: reconstructing our own `node <path> ||
|
|
1655
|
+
* true` would silently stop testing the shell-expansion half of the legacy form, the exact half
|
|
1656
|
+
* issue #2 broke. Mirrors {@link hookWiredUnder}'s traversal (kept in lock-step: both read
|
|
1657
|
+
* `hooks.UserPromptSubmit[*].hooks[*].command` and recognize it via {@link hookCommandInvokes}) but
|
|
1658
|
+
* returns the command TEXT instead of a boolean.
|
|
1659
|
+
*/
|
|
1660
|
+
function findConfiguredRecallHookCommand(root) {
|
|
1661
|
+
try {
|
|
1662
|
+
const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf-8'));
|
|
1663
|
+
const hooksSection = settings?.hooks;
|
|
1664
|
+
const list = hooksSection && typeof hooksSection === 'object' ? hooksSection['UserPromptSubmit'] : undefined;
|
|
1665
|
+
if (!Array.isArray(list))
|
|
1666
|
+
return undefined;
|
|
1667
|
+
for (const entry of list) {
|
|
1668
|
+
for (const cmd of hookCommandsOf(entry)) {
|
|
1669
|
+
if (hookCommandInvokes(cmd, '.claude/helpers/recall-hook.cjs'))
|
|
1670
|
+
return cmd;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
catch {
|
|
1675
|
+
/* settings.json absent, unreadable, or not valid JSON — nothing to probe */
|
|
1676
|
+
}
|
|
1677
|
+
return undefined;
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* AM-4 (fix round 1, apply-leg-never-silent): the LEGACY zero-arg form ({@link applyLegHookEntries}'s
|
|
1681
|
+
* no-installRoot branch) reads `${CLAUDE_PROJECT_DIR:-.}` — a shell expansion that only resolves to
|
|
1682
|
+
* something useful from a REAL session's own cwd. Spawning it from `probeApplyLeg`'s temp "foreign"
|
|
1683
|
+
* cwd can never find the deployed helper by construction (the file lives at `root`'s own
|
|
1684
|
+
* `.claude/helpers/`, never under the temp dir), so a probe against this form would spawn a doomed
|
|
1685
|
+
* command and report a confusing generic failure — not a fact about whether the leg injects, only a
|
|
1686
|
+
* fact about the fixture being unprobeable. The absolute form ({@link shellQuote}'d installRoot)
|
|
1687
|
+
* never contains this literal env-expansion syntax — it bakes a resolved path instead — so a plain
|
|
1688
|
+
* substring check distinguishes the two without re-parsing shell grammar.
|
|
1689
|
+
*/
|
|
1690
|
+
export function isLegacyRelativeRecallCommand(command) {
|
|
1691
|
+
return command.includes('${CLAUDE_PROJECT_DIR');
|
|
1692
|
+
}
|
|
1693
|
+
/** Words a real prompt needs to clear `hasEnoughSignal` (recall-hook-policy.ts: `MIN_PROMPT_CHARS`
|
|
1694
|
+
* 10, `MIN_CONTENT_TOKENS` 2) — a bare unique token alone is ONE token and would be silently
|
|
1695
|
+
* dropped by the very floor this probe means to exercise honestly. */
|
|
1696
|
+
const PROBE_PROMPT_WORDS = 'apply leg live probe';
|
|
1697
|
+
/** Doctor/parity probes share ONE domain tag so a leaked beacon (a failed removal) is trivially
|
|
1698
|
+
* findable and excludable — never `dz-teach`/`general`, which would blend it into real lessons. */
|
|
1699
|
+
const PROBE_BEACON_DOMAIN = 'apply-leg-probe';
|
|
1700
|
+
/**
|
|
1701
|
+
* FR-3 (feature `apply-leg-daemon-hygiene`): the scavenger that ran at the top of every probe used
|
|
1702
|
+
* to delete EVERY beacon-domain record unconditionally — safe against a probe killed mid-flight
|
|
1703
|
+
* (Codex round-2's own reason for the scavenger existing at all), but WRONG the moment two probes
|
|
1704
|
+
* from two DIFFERENT sessions can be live against the SAME store at once: the second probe's
|
|
1705
|
+
* scavenge deletes the first probe's still-in-flight beacon, and the first probe then reports a
|
|
1706
|
+
* false `ok:false` (its own hook query returns nothing, because the lesson it was about to match
|
|
1707
|
+
* against is already gone) — a false-red `dz doctor`/`dz parity` parity check with no defect behind
|
|
1708
|
+
* it. The fix: tag every beacon with its OWNER (`probe-owner=<pid>:<startedMs>`, embedded in the
|
|
1709
|
+
* pattern TEXT so it survives a round-trip through any store tier without a schema change — NFR-1
|
|
1710
|
+
* forbids a new column) and scavenge ONLY a beacon whose owner is provably gone: the pid no longer
|
|
1711
|
+
* answers `process.kill(pid, 0)`, OR the beacon has outlived its TTL (see {@link
|
|
1712
|
+
* scavengeStaleProbeBeacons}'s own `ttlMs` parameter).
|
|
1713
|
+
*/
|
|
1714
|
+
const PROBE_OWNER_TAG_PREFIX = 'probe-owner=';
|
|
1715
|
+
/** Fix round 1 (HIGH-6 residual): a second, independent tag alongside `probe-owner=` — the WRITING
|
|
1716
|
+
* process's own OS-level start time (epoch ms, from `/proc/<pid>/stat`), so the scavenger can tell a
|
|
1717
|
+
* PID-REUSE case (the recorded pid is technically "alive" per `process.kill(pid,0)`, but the LIVE
|
|
1718
|
+
* process at that pid started at a different time than the one that wrote the beacon) apart from
|
|
1719
|
+
* the genuine same-process case. Absent whenever the write-time `/proc` read fails (non-Linux,
|
|
1720
|
+
* permission) — the scavenger then falls back to the plain alive+TTL check alone, unchanged. */
|
|
1721
|
+
const PROC_START_TAG_PREFIX = 'proc-start=';
|
|
1722
|
+
/** Lead delta after Codex round 2 (HIGH-4): the beacon's OWN expiry, in epoch ms, written by the
|
|
1723
|
+
* probe that owns it. The pre-delta scavenger applied ITS OWN `ttlMs` to SOMEONE ELSE'S beacon, so a
|
|
1724
|
+
* default-budget probe (ttl 60 s) deleted the live beacon of a widened-budget probe (ttl 360 s) after
|
|
1725
|
+
* 60 s — the very false-red this hardening exists to prevent, one level up. A beacon now states when
|
|
1726
|
+
* IT expires; the scavenger's own `ttlMs` is only the fallback for a beacon written before this tag
|
|
1727
|
+
* existed. */
|
|
1728
|
+
const PROBE_EXPIRES_TAG_PREFIX = 'probe-expires=';
|
|
1729
|
+
/**
|
|
1730
|
+
* Fix round 1 (HIGH-6): the pre-fix-round TTL was a flat 60 000 ms, independent of the probe's own
|
|
1731
|
+
* `timeoutMs` — a caller that legitimately widens `timeoutMs` past that (a widened, suspended, or
|
|
1732
|
+
* heavily loaded probe) could have its OWN still-in-flight beacon scavenged by a concurrent probe
|
|
1733
|
+
* before it ever replies. The TTL is now DERIVED from the probe's own `timeoutMs`
|
|
1734
|
+
* (`max(timeoutMs * 3, 60_000)`, see {@link probeApplyLeg}'s call site) so a widened timeout widens
|
|
1735
|
+
* its own protection window too; the 60 000 ms floor keeps the pre-fix-round generous margin for the
|
|
1736
|
+
* default (unwidened) case. Exported as a named constant only for the floor value — the ACTUAL TTL
|
|
1737
|
+
* used by a given probe is always `ttlMs`, computed at the call site, never this constant alone.
|
|
1738
|
+
*/
|
|
1739
|
+
const PROBE_BEACON_TTL_FLOOR_MS = 60_000;
|
|
1740
|
+
function formatProbeOwner(pid, startedMs) {
|
|
1741
|
+
return `${PROBE_OWNER_TAG_PREFIX}${pid}:${startedMs}`;
|
|
1742
|
+
}
|
|
1743
|
+
/** Parses the `probe-owner=<pid>:<startedMs>` tag out of a beacon's pattern text. `undefined` for
|
|
1744
|
+
* any beacon predating this tag (an older deployed core wrote it) — treated by the scavenger as
|
|
1745
|
+
* ownerless and therefore always safe to remove (the pre-FR-3 behavior for exactly that case). */
|
|
1746
|
+
function parseProbeOwner(patternText) {
|
|
1747
|
+
const idx = patternText.indexOf(PROBE_OWNER_TAG_PREFIX);
|
|
1748
|
+
if (idx === -1)
|
|
1749
|
+
return undefined;
|
|
1750
|
+
const match = /probe-owner=(\d+):(\d+)/u.exec(patternText.slice(idx));
|
|
1751
|
+
if (match?.[1] === undefined || match[2] === undefined)
|
|
1752
|
+
return undefined;
|
|
1753
|
+
return { pid: Number(match[1]), startedMs: Number(match[2]) };
|
|
1754
|
+
}
|
|
1755
|
+
function formatProcStart(procStartedAtMs) {
|
|
1756
|
+
return ` ${PROC_START_TAG_PREFIX}${procStartedAtMs}`;
|
|
1757
|
+
}
|
|
1758
|
+
function formatProbeExpires(expiresAtMs) {
|
|
1759
|
+
return ` ${PROBE_EXPIRES_TAG_PREFIX}${expiresAtMs}`;
|
|
1760
|
+
}
|
|
1761
|
+
/** Parses the `probe-expires=<epochMs>` tag — `undefined` for a beacon written before the tag
|
|
1762
|
+
* existed, which is exactly when the scavenger falls back to its own `ttlMs`. */
|
|
1763
|
+
function parseProbeExpires(patternText) {
|
|
1764
|
+
const idx = patternText.indexOf(PROBE_EXPIRES_TAG_PREFIX);
|
|
1765
|
+
if (idx === -1)
|
|
1766
|
+
return undefined;
|
|
1767
|
+
const match = /probe-expires=(\d+)/u.exec(patternText.slice(idx));
|
|
1768
|
+
if (match?.[1] === undefined)
|
|
1769
|
+
return undefined;
|
|
1770
|
+
return Number(match[1]);
|
|
1771
|
+
}
|
|
1772
|
+
/** Parses the `proc-start=<ticks>` tag — `undefined` when absent (pre-fix-round beacon, or the
|
|
1773
|
+
* write-time `/proc` read failed). */
|
|
1774
|
+
function parseProcStart(patternText) {
|
|
1775
|
+
const idx = patternText.indexOf(PROC_START_TAG_PREFIX);
|
|
1776
|
+
if (idx === -1)
|
|
1777
|
+
return undefined;
|
|
1778
|
+
const match = /proc-start=(\d+)/u.exec(patternText.slice(idx));
|
|
1779
|
+
if (match?.[1] === undefined)
|
|
1780
|
+
return undefined;
|
|
1781
|
+
return Number(match[1]);
|
|
1782
|
+
}
|
|
1783
|
+
/** True when `pid` answers a liveness signal — `process.kill(pid, 0)` sends no actual signal, it
|
|
1784
|
+
* only probes whether the OS still has a process at that pid (ESRCH ⇒ dead). */
|
|
1785
|
+
function isPidAlive(pid) {
|
|
1786
|
+
try {
|
|
1787
|
+
process.kill(pid, 0);
|
|
1788
|
+
return true;
|
|
1789
|
+
}
|
|
1790
|
+
catch (err) {
|
|
1791
|
+
return err.code !== 'ESRCH';
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Fix round 1 (HIGH-6 residual, Linux-only, best-effort), tightened by the lead after Codex round 2
|
|
1796
|
+
* (MEDIUM-6): a process's OWN `starttime` from `/proc/<pid>/stat` — field 22 overall, found by
|
|
1797
|
+
* skipping past the LAST `)` so a `comm` containing spaces or parens never misaligns the split —
|
|
1798
|
+
* returned as RAW TICKS SINCE BOOT, the unit the kernel reports. No wall clock is consulted and no
|
|
1799
|
+
* CLK_TCK conversion is performed, so a stepped wall clock can no longer make the same live process
|
|
1800
|
+
* look like a different one. `undefined` on ANY read/parse failure (non-Linux, permission, the
|
|
1801
|
+
* process exiting mid-read) — the caller MUST treat that as "cannot prove", never as a pass in
|
|
1802
|
+
* either direction.
|
|
1803
|
+
*/
|
|
1804
|
+
function pidStartedAtMsFromProcStat(pid) {
|
|
1805
|
+
try {
|
|
1806
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
|
1807
|
+
const afterComm = stat.slice(stat.lastIndexOf(')') + 1).trim();
|
|
1808
|
+
const fields = afterComm.split(/\s+/u);
|
|
1809
|
+
// Overall field 22 (`starttime`) = fields[22 - 3] here, since fields[] starts at overall field 3
|
|
1810
|
+
// (state) once `pid (comm)` (fields 1-2) has been stripped above.
|
|
1811
|
+
const starttimeTicks = Number(fields[19]);
|
|
1812
|
+
if (!Number.isFinite(starttimeTicks))
|
|
1813
|
+
return undefined;
|
|
1814
|
+
// Lead delta after Codex round 2 (MEDIUM-6): return the RAW ticks-since-boot, never an epoch
|
|
1815
|
+
// derived from `Date.now() - uptime()`. The derived epoch moves when the WALL CLOCK is stepped,
|
|
1816
|
+
// so the same still-running process appeared to have "started at a different time" and its LIVE
|
|
1817
|
+
// beacon was deleted as a pid-reuse. Ticks since boot are monotonic within a boot and identical
|
|
1818
|
+
// for the same process on every read; across a reboot the recorded pid is dead anyway, which the
|
|
1819
|
+
// liveness check catches first — WITHDRAWN by the lead after Codex round 4: that sentence was
|
|
1820
|
+
// wrong, because across a reboot the pid may ALREADY have been reused and can collide on start
|
|
1821
|
+
// ticks too; the honest scope is the NAMED LIMIT stated below. This also removes the CLK_TCK assumption entirely — no conversion
|
|
1822
|
+
// to milliseconds happens at all, the two values are compared in their own unit.
|
|
1823
|
+
// NAMED LIMIT (lead delta after Codex round 3, MEDIUM): identity holds WITHIN one boot. Across a
|
|
1824
|
+
// reboot both pid allocation and ticks-since-boot restart, so a beacon that somehow persisted
|
|
1825
|
+
// could in principle collide with a new process at the same pid and the same tick. A boot id
|
|
1826
|
+
// would close it; the honest scope today is "within one boot", and a reboot also means the
|
|
1827
|
+
// beacon's own TTL has almost certainly passed, which the TTL branch catches first.
|
|
1828
|
+
return starttimeTicks;
|
|
1829
|
+
}
|
|
1830
|
+
catch {
|
|
1831
|
+
return undefined;
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
/** Fix round 1 (HIGH-6 residual): `process.kill(pid,0)` proves SOME process occupies `pid`, never
|
|
1835
|
+
* that it is the SAME process that wrote the beacon — PID reuse defeats the plain alive check named
|
|
1836
|
+
* as a residual limit in the review. Compares the LIVE process's own start time (from `/proc/<pid>/
|
|
1837
|
+
* stat`) against `recordedStartedMs` (the beacon's own `proc-start=` tag). A mismatch beyond {@link
|
|
1838
|
+
* PID_START_TOLERANCE_MS} means the pid was reused by an unrelated process — the true owner is
|
|
1839
|
+
* confirmed gone. `undefined` (stat unreadable) means "cannot prove either way" — the lead's decision
|
|
1840
|
+
* (fix round 1, item 6) is explicit: that MUST read as "do not remove" wherever it is consumed, never
|
|
1841
|
+
* as a pass. */
|
|
1842
|
+
function isSameProcessInstance(pid, recordedStartTicks) {
|
|
1843
|
+
const actualStartTicks = pidStartedAtMsFromProcStat(pid);
|
|
1844
|
+
if (actualStartTicks === undefined)
|
|
1845
|
+
return undefined;
|
|
1846
|
+
// Lead delta after Codex round 2 (MEDIUM-6): EXACT equality on ticks-since-boot. The old
|
|
1847
|
+
// ±5 000 ms tolerance existed only to absorb the CLK_TCK guess in the epoch conversion; with the
|
|
1848
|
+
// raw kernel value there is nothing to absorb, and a tolerance would re-admit the very pid-reuse
|
|
1849
|
+
// case it was meant to exclude (a reused pid started within the tolerance read as "same process").
|
|
1850
|
+
return actualStartTicks === recordedStartTicks;
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* FR-3/FR-4: remove only the STALE beacon-domain records in `root`'s store — owner dead
|
|
1854
|
+
* (`process.kill(pid, 0)` ⇒ ESRCH), older than `ttlMs`, OR (fix round 1, HIGH-6 residual) alive AND
|
|
1855
|
+
* within `ttlMs` but the live pid's OWN `/proc/<pid>/stat` start time no longer matches the beacon's
|
|
1856
|
+
* `proc-start=` tag — a confirmed PID-reuse case, where the recorded owner is provably gone even
|
|
1857
|
+
* though `pid` itself answers. An UNREADABLE stat at scavenge time never counts as reuse evidence —
|
|
1858
|
+
* it falls back to the plain alive+TTL verdict, per the lead's "cannot prove ⇒ do not remove"
|
|
1859
|
+
* decision. A beacon whose owner is alive, within TTL, and (when provable) confirmed the SAME
|
|
1860
|
+
* process is left untouched, even though it belongs to a different probe. An ownerless (pre-FR-3)
|
|
1861
|
+
* beacon is always treated as stale. `ttlMs` defaults to {@link PROBE_BEACON_TTL_FLOOR_MS} for a
|
|
1862
|
+
* caller that does not derive one (`apply-leg-beacon-owner.test.ts`'s own fixtures); `probeApplyLeg`
|
|
1863
|
+
* always passes its own derived value. Exported so `apply-leg-beacon-owner.test.ts` can exercise the
|
|
1864
|
+
* property directly, without needing a live hook/daemon (FR-4's red-first case needs only a store
|
|
1865
|
+
* and an injectable remover, never a real probe round-trip).
|
|
1866
|
+
*/
|
|
1867
|
+
export function scavengeStaleProbeBeacons(root, removeBeacon, ttlMs = PROBE_BEACON_TTL_FLOOR_MS) {
|
|
1868
|
+
try {
|
|
1869
|
+
const beacons = loadStorePatternsSync(root).filter((p) => p.domain === PROBE_BEACON_DOMAIN);
|
|
1870
|
+
const staleIds = [];
|
|
1871
|
+
for (const beacon of beacons) {
|
|
1872
|
+
const owner = parseProbeOwner(beacon.pattern);
|
|
1873
|
+
if (owner === undefined) {
|
|
1874
|
+
staleIds.push(beacon.dzId ?? patternRecordId(beacon));
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
const alive = isPidAlive(owner.pid);
|
|
1878
|
+
// Lead delta after Codex round 2 (HIGH-4): the beacon's OWN expiry wins over this scavenger's
|
|
1879
|
+
// `ttlMs`, which belongs to a DIFFERENT probe and knows nothing of this owner's budget.
|
|
1880
|
+
const ownExpiresAtMs = parseProbeExpires(beacon.pattern);
|
|
1881
|
+
const withinTtl = ownExpiresAtMs !== undefined
|
|
1882
|
+
? Date.now() <= ownExpiresAtMs
|
|
1883
|
+
: Date.now() - owner.startedMs <= ttlMs;
|
|
1884
|
+
if (!alive || !withinTtl) {
|
|
1885
|
+
staleIds.push(beacon.dzId ?? patternRecordId(beacon));
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1888
|
+
// alive AND within TTL — the plain check says "keep", but confirm it is the SAME process, not
|
|
1889
|
+
// a reused pid, whenever the beacon carries the (best-effort) proc-start tag.
|
|
1890
|
+
const procStart = parseProcStart(beacon.pattern);
|
|
1891
|
+
if (procStart !== undefined) {
|
|
1892
|
+
const same = isSameProcessInstance(owner.pid, procStart);
|
|
1893
|
+
if (same === false)
|
|
1894
|
+
staleIds.push(beacon.dzId ?? patternRecordId(beacon)); // confirmed reuse
|
|
1895
|
+
// same === true, or same === undefined (unreadable ⇒ cannot prove ⇒ do not remove): keep.
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
if (staleIds.length === 0)
|
|
1899
|
+
return { ok: true, removed: 0 };
|
|
1900
|
+
const result = removeBeacon(root, new Set(staleIds));
|
|
1901
|
+
if (result.error !== undefined)
|
|
1902
|
+
return { ok: false, error: result.error };
|
|
1903
|
+
return { ok: true, removed: result.removed };
|
|
1904
|
+
}
|
|
1905
|
+
catch (err) {
|
|
1906
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Live, end-to-end proof that the apply leg actually injects — ADR-001 Decision 1. `applyLegStatus`
|
|
1911
|
+
* only proves FILES exist and are STRUCTURALLY wired (issue #2's whole defect: four green checks,
|
|
1912
|
+
* a leg that injected nothing in every session but one). This spawns the REAL configured hook
|
|
1913
|
+
* command from a TEMPORARY cwd with `CLAUDE_PROJECT_DIR` pointing at that same temp dir — the exact
|
|
1914
|
+
* shape of a real Claude Code session, which never runs a hook from the project root itself — and
|
|
1915
|
+
* asks it to recall a throwaway "beacon" lesson written into the store for the duration of the call.
|
|
1916
|
+
* `ok: true` only when the beacon's own SECRET token (fix round 1, AM-1 — never sent as input, only
|
|
1917
|
+
* stored) comes back inside `additionalContext`; every other outcome is `ok: false` with a `reason`,
|
|
1918
|
+
* never a silent guess.
|
|
1919
|
+
*
|
|
1920
|
+
* The beacon is written via {@link recordPattern} (the SAME lexical-store seam `dz teach` uses) and
|
|
1921
|
+
* removed via {@link removePatternsByIds} in a `finally` — a probe that throws, times out, or never
|
|
1922
|
+
* finds the leg alive still leaves the store exactly as it found it (proven by a count-before ==
|
|
1923
|
+
* count-after test, not merely claimed).
|
|
1924
|
+
*
|
|
1925
|
+
* `timeoutMs` bounds `probeHookLiveness`'s spawn. Measured (this environment, 2026-09-14, T1): a
|
|
1926
|
+
* `store-not-found`/`socket-absent` early exit returns in well under 200 ms; a live-daemon probe
|
|
1927
|
+
* answers in ~100-200 ms, matching ADR-001's own estimate. 8000 ms leaves roughly a 40x margin for a
|
|
1928
|
+
* loaded daemon without ever approaching `probeHookLiveness`'s own un-overridden 20 000 ms ceiling —
|
|
1929
|
+
* a genuinely dead probe still returns to `dz doctor`/`dz parity` in bounded time.
|
|
1930
|
+
*
|
|
1931
|
+
* `env` is a TEST-ONLY escape hatch (never used by `dz doctor`/`dz parity`, both call this with
|
|
1932
|
+
* default opts): it lets a test widen the HOOK's OWN internal socket-connect timeout
|
|
1933
|
+
* (`DZ_RECALL_HOOK_TIMEOUT_MS`) against a genuinely cold daemon, matching the same widening
|
|
1934
|
+
* `apply-leg-recall-parity.test.ts`/`apply-leg-install-root.test.ts` already apply to the daemon's
|
|
1935
|
+
* `HOOK_RECALL_BUDGET_MS`. Merged BEFORE `CLAUDE_PROJECT_DIR`, so a caller can never override the
|
|
1936
|
+
* one env var this probe's own honesty depends on.
|
|
1937
|
+
*/
|
|
1938
|
+
export async function probeApplyLeg(root, opts = {}) {
|
|
1939
|
+
const started = Date.now();
|
|
1940
|
+
const elapsed = () => Date.now() - started;
|
|
1941
|
+
// Fix round 1 (HIGH-6): computed HERE, before the scavenger runs, so the scavenge TTL can be
|
|
1942
|
+
// derived from THIS probe's own budget rather than a flat constant blind to a caller-widened
|
|
1943
|
+
// timeout — see the `ttlMs` computation below, right before the beacon that carries its implicit
|
|
1944
|
+
// promise is written.
|
|
1945
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
1946
|
+
const status = applyLegStatus(root);
|
|
1947
|
+
if (!status.installed) {
|
|
1948
|
+
return { ok: false, reason: 'apply-leg not installed', elapsedMs: elapsed() };
|
|
1949
|
+
}
|
|
1950
|
+
const command = findConfiguredRecallHookCommand(root);
|
|
1951
|
+
if (command === undefined) {
|
|
1952
|
+
return { ok: false, reason: 'no UserPromptSubmit entry invokes recall-hook.cjs (settings.json missing or unreadable)', elapsedMs: elapsed() };
|
|
1953
|
+
}
|
|
1954
|
+
// AM-4: the legacy relative form can never be reached from a foreign cwd by construction — see
|
|
1955
|
+
// isLegacyRelativeRecallCommand's own doc comment. Reported BEFORE any beacon is written: there is
|
|
1956
|
+
// nothing to clean up for a probe that never ran.
|
|
1957
|
+
if (isLegacyRelativeRecallCommand(command)) {
|
|
1958
|
+
return { ok: false, reason: 'legacy-relative-command', elapsedMs: elapsed() };
|
|
1959
|
+
}
|
|
1960
|
+
// AM-1 (CRITICAL, fix round 1): two INDEPENDENT tokens, not one. `queryToken` rides the PROMPT the
|
|
1961
|
+
// probe sends the hook — a dead/stub hook that merely echoes its own stdin back into
|
|
1962
|
+
// `additionalContext` makes THIS token reappear too, so it alone can never prove genuine
|
|
1963
|
+
// injection. `secretToken` exists ONLY inside the beacon's STORED pattern text and is never sent
|
|
1964
|
+
// to the hook as input — only a hook that actually queried the store and returned a matched
|
|
1965
|
+
// pattern's own text can produce it. `ok: true` therefore requires the SECRET, never the query.
|
|
1966
|
+
const queryToken = `dzapplylegquery${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
|
1967
|
+
const secretToken = `dzapplylegsecret${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
|
1968
|
+
// FR-3: the owner tag rides the SAME pattern text as the secret — a beacon's owner is knowable
|
|
1969
|
+
// from its store record alone, no side channel, no schema change (NFR-1).
|
|
1970
|
+
const ownerTag = formatProbeOwner(process.pid, started);
|
|
1971
|
+
// Fix round 1 (HIGH-6): TTL derives from THIS probe's own timeoutMs — a caller that widens
|
|
1972
|
+
// timeoutMs (this repo's own live tests widen it to 15_000 ms) widens its own protection window
|
|
1973
|
+
// too, rather than staying pinned to a flat constant blind to that widening. Floored at the
|
|
1974
|
+
// pre-fix-round 60_000 ms so the default (unwidened) case keeps its original generous margin.
|
|
1975
|
+
// Asserted, not merely trusted — an unreasoned future edit to the formula must fail loudly right
|
|
1976
|
+
// here, where the beacon carrying this TTL's implicit promise is about to be written, rather than
|
|
1977
|
+
// silently reintroducing the false-red-under-concurrency defect this closes.
|
|
1978
|
+
const ttlMs = Math.max(timeoutMs * 3, PROBE_BEACON_TTL_FLOOR_MS);
|
|
1979
|
+
if (!(timeoutMs < ttlMs)) {
|
|
1980
|
+
throw new Error(`invariant violated: timeoutMs (${String(timeoutMs)}) must be < ttlMs (${String(ttlMs)}) — scavenger TTL formula regressed`);
|
|
1981
|
+
}
|
|
1982
|
+
// Fix round 1 (HIGH-6 residual, best-effort): the writing process's OWN OS-level start time,
|
|
1983
|
+
// captured NOW so a later scavenger can tell a genuinely-still-alive owner apart from a DIFFERENT
|
|
1984
|
+
// process that merely reused this pid (isSameProcessInstance's own doc comment). Omitted entirely
|
|
1985
|
+
// when unreadable (non-Linux, permission) — the scavenger then falls back to the plain alive+TTL
|
|
1986
|
+
// check for this beacon, unchanged from before this residual hardening.
|
|
1987
|
+
const ownProcStart = pidStartedAtMsFromProcStat(process.pid);
|
|
1988
|
+
const procStartTag = ownProcStart !== undefined ? formatProcStart(ownProcStart) : '';
|
|
1989
|
+
// Lead delta after Codex round 2 (HIGH-4): this beacon states its OWN expiry, so a concurrent
|
|
1990
|
+
// probe with a different (shorter) budget can never out-vote this probe's protection window.
|
|
1991
|
+
const expiresTag = formatProbeExpires(started + ttlMs);
|
|
1992
|
+
const beaconPattern = {
|
|
1993
|
+
pattern: `${PROBE_PROMPT_WORDS} ${queryToken} — dz doctor / dz parity live-probe marker, safe to remove. probe-secret=${secretToken} ${ownerTag}${procStartTag}${expiresTag}`,
|
|
1994
|
+
type: 'lesson-learned',
|
|
1995
|
+
reward: 0,
|
|
1996
|
+
domain: PROBE_BEACON_DOMAIN,
|
|
1997
|
+
ts: new Date().toISOString(),
|
|
1998
|
+
source: 'apply-leg-probe',
|
|
1999
|
+
};
|
|
2000
|
+
// Deterministic content-hash id (patternRecordId), computed from the SAME object recordPattern is
|
|
2001
|
+
// about to write — the id is a pure function of {pattern, ts, reward, domain, type}, so the value
|
|
2002
|
+
// computed here and the value the store assigns are guaranteed equal without a round-trip read.
|
|
2003
|
+
const beaconId = patternRecordId(beaconPattern);
|
|
2004
|
+
const probePrompt = `${PROBE_PROMPT_WORDS} ${queryToken}`;
|
|
2005
|
+
const removeBeacon = opts.removeBeacon ?? removePatternsByIds;
|
|
2006
|
+
// AM-2 (HIGH, fix round 1): `wrote` is armed BEFORE the write is even attempted, and cleanup below
|
|
2007
|
+
// runs off `wrote` alone — a `recordPattern` call that PARTIALLY lands and then rejects used to
|
|
2008
|
+
// skip cleanup entirely (the old code's `finally` only wrapped the code AFTER a successful
|
|
2009
|
+
// `await recordPattern`), leaking the beacon forever. A cleanup FAILURE (the store refuses the
|
|
2010
|
+
// delete) now overrides whatever `result` the probe body computed — `ok: true` is not honest if
|
|
2011
|
+
// the probe cannot even prove the store is clean afterward.
|
|
2012
|
+
let wrote = false;
|
|
2013
|
+
let cleanupFailed = false;
|
|
2014
|
+
let cleanupErrMsg = '';
|
|
2015
|
+
let tempCwd;
|
|
2016
|
+
let result;
|
|
2017
|
+
// Codex round-2: a process killed mid-probe bypasses `finally`, so a beacon can outlive its probe.
|
|
2018
|
+
// Every probe therefore starts by SCAVENGING any beacon left behind by an earlier one (the probe
|
|
2019
|
+
// domain is reserved for beacons, never for user lessons) — the store is clean before AND after.
|
|
2020
|
+
// FR-3/FR-4: scavenging is no longer unconditional — a LIVE beacon from a DIFFERENT concurrent
|
|
2021
|
+
// probe (another session's `dz doctor`/`dz parity` against the same store) must survive; only a
|
|
2022
|
+
// beacon whose owner is dead or past its TTL is removed. A scavenge failure is a named FACT
|
|
2023
|
+
// (`scavengeError`), never a swallowed exception (the pre-fix `catch {}` this replaces).
|
|
2024
|
+
let scavengeError;
|
|
2025
|
+
const scavengeResult = scavengeStaleProbeBeacons(root, removeBeacon, ttlMs);
|
|
2026
|
+
if (!scavengeResult.ok)
|
|
2027
|
+
scavengeError = scavengeResult.error;
|
|
2028
|
+
try {
|
|
2029
|
+
wrote = true;
|
|
2030
|
+
let writeFailed;
|
|
2031
|
+
try {
|
|
2032
|
+
await recordPattern(root, beaconPattern);
|
|
2033
|
+
}
|
|
2034
|
+
catch (err) {
|
|
2035
|
+
writeFailed = err instanceof Error ? err.message : String(err);
|
|
2036
|
+
}
|
|
2037
|
+
if (writeFailed !== undefined) {
|
|
2038
|
+
result = { ok: false, reason: `beacon write failed: ${writeFailed}`, elapsedMs: elapsed() };
|
|
2039
|
+
}
|
|
2040
|
+
else {
|
|
2041
|
+
// AM-4: a bare empty temp dir does not model a FOREIGN session — a real foreign
|
|
2042
|
+
// CLAUDE_PROJECT_DIR names a DIFFERENT project with its own (empty-of-lessons, but present)
|
|
2043
|
+
// `.dz`/`.claude` tree, not "nothing at all". This closes the gap between "no project" and "a
|
|
2044
|
+
// different, empty project" a bare empty dir cannot distinguish, matching what the hook's own
|
|
2045
|
+
// SESSION_ROOT-derived reads (e.g. the retro-debt sentinel) would see in a real foreign session.
|
|
2046
|
+
tempCwd = mkdtempSync(join(tmpdir(), 'dz-apply-leg-probe-'));
|
|
2047
|
+
mkdirSync(join(tempCwd, '.dz'), { recursive: true });
|
|
2048
|
+
mkdirSync(join(tempCwd, '.claude'), { recursive: true });
|
|
2049
|
+
const { probeHookLiveness } = await import('./operations.js');
|
|
2050
|
+
const probeResult = probeHookLiveness(command, JSON.stringify({ prompt: probePrompt }), {
|
|
2051
|
+
cwd: tempCwd,
|
|
2052
|
+
env: { ...(opts.env ?? {}), CLAUDE_PROJECT_DIR: tempCwd },
|
|
2053
|
+
timeoutMs,
|
|
2054
|
+
});
|
|
2055
|
+
const stdoutLines = probeResult.stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
2056
|
+
let additionalContext;
|
|
2057
|
+
for (const line of stdoutLines) {
|
|
2058
|
+
try {
|
|
2059
|
+
const parsed = JSON.parse(line);
|
|
2060
|
+
if (typeof parsed?.hookSpecificOutput?.additionalContext === 'string') {
|
|
2061
|
+
additionalContext = parsed.hookSpecificOutput.additionalContext;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
catch {
|
|
2065
|
+
/* not a JSON line — the hook only ever emits at most one, but tolerate stray output */
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
if (typeof additionalContext === 'string' && additionalContext.includes(secretToken)) {
|
|
2069
|
+
result = { ok: true, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
2070
|
+
}
|
|
2071
|
+
else if (typeof additionalContext === 'string' && additionalContext.includes(queryToken)) {
|
|
2072
|
+
// AM-1: the QUERY came back but the SECRET did not — the hook (or a stub standing in for
|
|
2073
|
+
// it) echoed its own input instead of genuinely querying the store. Named distinctly from
|
|
2074
|
+
// every other red reason so a dead leg and a FAKING one never read the same.
|
|
2075
|
+
result = { ok: false, reason: 'echo-not-injection', elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
2076
|
+
}
|
|
2077
|
+
else {
|
|
2078
|
+
// FR-1's own reason line is the authoritative source — the hook names itself why it stayed
|
|
2079
|
+
// quiet. Falling back to a raw stderr/status summary keeps the probe honest even against an
|
|
2080
|
+
// OLDER deployed hook (pre-`apply-leg-never-silent`) that has not been upgraded yet.
|
|
2081
|
+
const skipMatch = /\[dz-recall\] skipped reason=(\S+)/.exec(probeResult.stderr);
|
|
2082
|
+
const skipReason = skipMatch?.[1];
|
|
2083
|
+
if (skipReason !== undefined) {
|
|
2084
|
+
result = { ok: false, reason: skipReason, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
2085
|
+
}
|
|
2086
|
+
else if (probeResult.status === null) {
|
|
2087
|
+
result = { ok: false, reason: `probe did not complete (timeout or spawn error after ${timeoutMs} ms)`, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
2088
|
+
}
|
|
2089
|
+
else {
|
|
2090
|
+
const stderrFirstLine = probeResult.stderr.trim().split('\n')[0];
|
|
2091
|
+
result = {
|
|
2092
|
+
ok: false,
|
|
2093
|
+
reason: stderrFirstLine && stderrFirstLine !== '' ? stderrFirstLine : 'no beacon in additionalContext (empty or non-matching reply)',
|
|
2094
|
+
elapsedMs: elapsed(),
|
|
2095
|
+
groupKillAttempted: probeResult.groupKillAttempted,
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
finally {
|
|
2102
|
+
if (tempCwd !== undefined) {
|
|
2103
|
+
try {
|
|
2104
|
+
rmSync(tempCwd, { recursive: true, force: true });
|
|
2105
|
+
}
|
|
2106
|
+
catch {
|
|
2107
|
+
/* best-effort cleanup of the probe's own temp cwd */
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
// Beacon removal is UNCONDITIONAL on `wrote` — success, failure, or a thrown probe all reach
|
|
2111
|
+
// here (AM-2). `removePatternsByIds` never throws (patterns.ts's own contract) — a failure is
|
|
2112
|
+
// reported through its RETURN VALUE's `.error`, checked below, never via a catch.
|
|
2113
|
+
if (wrote) {
|
|
2114
|
+
try {
|
|
2115
|
+
const removeResult = removeBeacon(root, new Set([beaconId]));
|
|
2116
|
+
if (removeResult.error !== undefined) {
|
|
2117
|
+
cleanupFailed = true;
|
|
2118
|
+
cleanupErrMsg = removeResult.error;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
catch (err) {
|
|
2122
|
+
// Codex round-2: a remover that THROWS (a foreign store implementation, a test seam) must not
|
|
2123
|
+
// escape past the cleanup accounting — it is a cleanup failure like any other.
|
|
2124
|
+
cleanupFailed = true;
|
|
2125
|
+
cleanupErrMsg = err instanceof Error ? err.message : String(err);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
if (cleanupFailed) {
|
|
2130
|
+
// a cleanup failure can only happen after `result` was assigned (the finally block runs after
|
|
2131
|
+
// the try body) — carry the kill-attempt fact through rather than dropping it on this path.
|
|
2132
|
+
// `exactOptionalPropertyTypes` forbids assigning `undefined` to an optional field explicitly, so
|
|
2133
|
+
// the key is included only when `result.groupKillAttempted` actually has a value.
|
|
2134
|
+
return {
|
|
2135
|
+
ok: false,
|
|
2136
|
+
reason: `beacon-cleanup-failed: beacon ${beaconId} could not be removed (${cleanupErrMsg})`,
|
|
2137
|
+
elapsedMs: elapsed(),
|
|
2138
|
+
...(result.groupKillAttempted !== undefined ? { groupKillAttempted: result.groupKillAttempted } : {}),
|
|
2139
|
+
...(scavengeError !== undefined ? { scavengeError } : {}),
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
// FR-3: a scavenge failure is surfaced on the SUCCESS path too — it does not override `ok`/
|
|
2143
|
+
// `reason` (this probe's own injection result may be perfectly genuine), but it is a fact a
|
|
2144
|
+
// caller should not lose.
|
|
2145
|
+
return scavengeError !== undefined ? { ...result, scavengeError } : result;
|
|
2146
|
+
}
|
|
1404
2147
|
//# sourceMappingURL=apply-leg.js.map
|