@dzhechkov/harness-core 0.8.31 → 0.8.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.dz-manifest.json +97 -37
  2. package/README.md +30 -0
  3. package/dist/apply-leg.d.ts +13 -1
  4. package/dist/apply-leg.d.ts.map +1 -1
  5. package/dist/apply-leg.js +123 -10
  6. package/dist/apply-leg.js.map +1 -1
  7. package/dist/embed-socket-path.d.ts +65 -0
  8. package/dist/embed-socket-path.d.ts.map +1 -0
  9. package/dist/embed-socket-path.js +100 -0
  10. package/dist/embed-socket-path.js.map +1 -0
  11. package/dist/index.d.ts +9 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +7 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/operations.d.ts.map +1 -1
  16. package/dist/operations.js +66 -3
  17. package/dist/operations.js.map +1 -1
  18. package/dist/packed-install-smoke.d.ts +108 -0
  19. package/dist/packed-install-smoke.d.ts.map +1 -0
  20. package/dist/packed-install-smoke.js +172 -0
  21. package/dist/packed-install-smoke.js.map +1 -0
  22. package/dist/publish-sibling-drift.d.ts +67 -0
  23. package/dist/publish-sibling-drift.d.ts.map +1 -0
  24. package/dist/publish-sibling-drift.js +262 -0
  25. package/dist/publish-sibling-drift.js.map +1 -0
  26. package/dist/publish.d.ts +44 -0
  27. package/dist/publish.d.ts.map +1 -1
  28. package/dist/publish.js +243 -24
  29. package/dist/publish.js.map +1 -1
  30. package/dist/qe-bridge.d.ts +16 -0
  31. package/dist/qe-bridge.d.ts.map +1 -1
  32. package/dist/qe-bridge.js +1 -0
  33. package/dist/qe-bridge.js.map +1 -1
  34. package/dist/release.d.ts +19 -0
  35. package/dist/release.d.ts.map +1 -1
  36. package/dist/release.js +81 -2
  37. package/dist/release.js.map +1 -1
  38. package/dist/setup.d.ts +36 -0
  39. package/dist/setup.d.ts.map +1 -1
  40. package/dist/setup.js +96 -2
  41. package/dist/setup.js.map +1 -1
  42. package/package.json +23 -23
  43. package/sbom.json +186 -36
  44. package/src/apply-leg.ts +123 -10
  45. package/src/embed-socket-path.ts +113 -0
  46. package/src/index.ts +35 -2
  47. package/src/operations.ts +67 -3
  48. package/src/packed-install-smoke.ts +273 -0
  49. package/src/publish-sibling-drift.ts +293 -0
  50. package/src/publish.ts +280 -25
  51. package/src/qe-bridge.ts +14 -0
  52. package/src/release.ts +103 -2
  53. package/src/setup.ts +108 -2
package/src/apply-leg.ts CHANGED
@@ -41,8 +41,15 @@ import { hookCommandsOf } from './managed-hooks.js';
41
41
  * `recallHookSource`'s `loadCoreModule` candidate list is now built with a conditional spread so a
42
42
  * `null` `coreDistDir` (the hub's own portable marker, C-6/finding-6) degrades cleanly to the
43
43
  * project-relative fallbacks — the generated BYTES changed for every caller, hub and consumer alike.
44
+ *
45
+ * Bumped 3→4 (feature `embed-socket-short-path`): both generated files now resolve the socket path
46
+ * through the same `DZ_EMBED_SOCKET` → project-path-if-short → tmpdir-short-hash logic as
47
+ * {@link resolveEmbedSocketPath} (inlined as TEXT in both — a template string cannot `import` a
48
+ * compiled module), the daemon writes a `.dz/embed.sock.path` pointer when it picks the tmpdir-short
49
+ * branch, and `ready` is now printed only after `existsSync(SOCKET)` confirms the bind actually
50
+ * landed (previously logged unconditionally, before `listen` even ran).
44
51
  */
45
- export const APPLY_LEG_VERSION = 3;
52
+ export const APPLY_LEG_VERSION = 4;
46
53
 
47
54
  /**
48
55
  * Parse the `dz-apply-leg-version` stamp from a deployed helper file. Unlike
@@ -98,6 +105,11 @@ export function bakedCoreDistDirOf(content: string): string | null | undefined {
98
105
  * Behaviourally identical to the pre-existing hand-committed hub file except for: the version
99
106
  * stamp (new, line 2) and the candidate list in `loadCoreModule` (baked path first when present,
100
107
  * `/usr/lib/...` dropped — FR-3).
108
+ *
109
+ * Also carries the `embed-socket-short-path` fix (FR-1/FR-2): `SOCKET` is resolved through the
110
+ * same env → project-path-if-short → tmpdir-short-hash logic as
111
+ * {@link "./embed-socket-path.js".resolveEmbedSocketPath}, inlined as text and preferring an
112
+ * on-disk pointer when the resolver itself would land on the tmpdir-short branch.
101
113
  */
102
114
  export function recallHookSource(coreDistDir: string | null): string {
103
115
  return `#!/usr/bin/env node
@@ -123,11 +135,46 @@ export function recallHookSource(coreDistDir: string | null): string {
123
135
  const net = require('node:net');
124
136
  const path = require('node:path');
125
137
  const fs = require('node:fs');
138
+ const crypto = require('node:crypto');
139
+ const os = require('node:os');
126
140
  const { pathToFileURL } = require('node:url');
127
141
 
128
142
  const PROJECT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
129
143
  const CORE_DIST_DIR = ${coreDistDir === null ? 'null' : JSON.stringify(coreDistDir)};
130
- const SOCKET = process.env.DZ_EMBED_SOCKET || path.join(PROJECT, '.dz', 'embed.sock');
144
+
145
+ // embed-socket-short-path (FR-1/FR-2): byte-for-byte the same logic as \`resolveEmbedSocketPath\` /
146
+ // \`resolveEffectiveEmbedSocketPath\` in \`embed-socket-path.ts\` — inlined as TEXT because this file
147
+ // is standalone and cannot \`import\` a compiled core module (apply-leg-twins.test.ts pins the copies
148
+ // to the same behavior as the daemon's own inlined copy).
149
+ const EMBED_SOCKET_PATH_BYTES_LIMIT = 100;
150
+ function resolveEmbedSocketPath(projectRoot, env) {
151
+ const fromEnv = env.DZ_EMBED_SOCKET;
152
+ if (typeof fromEnv === 'string' && fromEnv !== '') return { path: fromEnv, reason: 'env' };
153
+ const projectPath = path.join(projectRoot, '.dz', 'embed.sock');
154
+ if (Buffer.byteLength(projectPath, 'utf8') <= EMBED_SOCKET_PATH_BYTES_LIMIT) return { path: projectPath, reason: 'project' };
155
+ const hash = crypto.createHash('sha1').update(projectRoot).digest('hex').slice(0, 12);
156
+ const uid = String(process.getuid?.() ?? 'u');
157
+ const shortPath = path.join(os.tmpdir(), \`dz-\${uid}\`, \`embed-\${hash}.sock\`);
158
+ const tooLong = Buffer.byteLength(shortPath, 'utf8') > EMBED_SOCKET_PATH_BYTES_LIMIT;
159
+ return tooLong ? { path: shortPath, reason: 'tmpdir-short', tooLong: true } : { path: shortPath, reason: 'tmpdir-short' };
160
+ }
161
+ function readEmbedSocketPointer(projectRoot) {
162
+ const pointerPath = path.join(projectRoot, '.dz', 'embed.sock.path');
163
+ if (!fs.existsSync(pointerPath)) return undefined;
164
+ try {
165
+ const raw = fs.readFileSync(pointerPath, 'utf-8').trim();
166
+ return raw !== '' ? raw : undefined;
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ }
171
+ function resolveEffectiveEmbedSocketPath(projectRoot, env) {
172
+ const resolved = resolveEmbedSocketPath(projectRoot, env);
173
+ if (resolved.reason !== 'tmpdir-short') return resolved;
174
+ const pointer = readEmbedSocketPointer(projectRoot);
175
+ return pointer !== undefined && fs.existsSync(pointer) ? { path: pointer, reason: 'tmpdir-short' } : resolved;
176
+ }
177
+ const SOCKET = resolveEffectiveEmbedSocketPath(PROJECT, process.env).path;
131
178
  const USAGE_LOG = process.env.DZ_RECALL_USAGE_LOG || path.join(PROJECT, '.dz', 'recall-usage.jsonl');
132
179
  const TIMEOUT_MS = Number(process.env.DZ_RECALL_HOOK_TIMEOUT_MS || 800);
133
180
 
@@ -566,10 +613,12 @@ export function embedDaemonSource(): string {
566
613
  */
567
614
 
568
615
  import { createServer } from 'node:net';
569
- import { existsSync, unlinkSync, readFileSync } from 'node:fs';
570
- import { join } from 'node:path';
616
+ import { existsSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, renameSync, statSync } from 'node:fs';
617
+ import { join, dirname } from 'node:path';
571
618
  import { createRequire } from 'node:module';
572
619
  import { connect } from 'node:net';
620
+ import { createHash } from 'node:crypto';
621
+ import { tmpdir } from 'node:os';
573
622
 
574
623
  const log = (...a) => console.error('[dz-embed]', ...a);
575
624
 
@@ -577,7 +626,26 @@ const log = (...a) => console.error('[dz-embed]', ...a);
577
626
  console.log = (...a) => console.error(...a);
578
627
 
579
628
  const PROJECT = process.env['DZ_PROJECT_ROOT'] ?? process.cwd();
580
- const SOCKET = process.env['DZ_EMBED_SOCKET'] ?? join(PROJECT, '.dz', 'embed.sock');
629
+ // embed-socket-short-path (FR-1): a unix socket path has a hard platform limit on \`sun_path\`
630
+ // (Linux 108 bytes incl. NUL, macOS 104) — a deeply nested project's \`.dz/embed.sock\` can exceed
631
+ // it, and \`listen()\` then fails while every OTHER part of the daemon looks healthy. This mirrors
632
+ // {@link resolveEmbedSocketPath} in \`embed-socket-path.ts\` byte-for-byte (this file is generated
633
+ // TEXT, standalone, and cannot \`import\` a compiled core module — apply-leg-twins.test.ts pins the
634
+ // two copies to the same behavior).
635
+ const EMBED_SOCKET_PATH_BYTES_LIMIT = 100;
636
+ function resolveEmbedSocketPath(projectRoot, env) {
637
+ const fromEnv = env['DZ_EMBED_SOCKET'];
638
+ if (typeof fromEnv === 'string' && fromEnv !== '') return { path: fromEnv, reason: 'env' };
639
+ const projectPath = join(projectRoot, '.dz', 'embed.sock');
640
+ if (Buffer.byteLength(projectPath, 'utf8') <= EMBED_SOCKET_PATH_BYTES_LIMIT) return { path: projectPath, reason: 'project' };
641
+ const hash = createHash('sha1').update(projectRoot).digest('hex').slice(0, 12);
642
+ const uid = String(process.getuid?.() ?? 'u');
643
+ const shortPath = join(tmpdir(), \`dz-\${uid}\`, \`embed-\${hash}.sock\`);
644
+ const tooLong = Buffer.byteLength(shortPath, 'utf8') > EMBED_SOCKET_PATH_BYTES_LIMIT;
645
+ return tooLong ? { path: shortPath, reason: 'tmpdir-short', tooLong: true } : { path: shortPath, reason: 'tmpdir-short' };
646
+ }
647
+ const { path: SOCKET, reason: SOCKET_REASON, tooLong: SOCKET_TOO_LONG } = resolveEmbedSocketPath(PROJECT, process.env);
648
+ const SOCKET_POINTER = join(PROJECT, '.dz', 'embed.sock.path');
581
649
  // AM-10 (issue #10 defect 7): a setTimeout delay is a 32-bit signed int in Node — anything
582
650
  // above 2_147_483_647 ms silently becomes ~1ms (a "30 days" idle setting exited in ~1 second,
583
651
  // MEASURED). 0/NaN/negative disables the idle exit outright rather than firing immediately.
@@ -734,7 +802,6 @@ async function main() {
734
802
 
735
803
  let patterns = loadPatterns();
736
804
  let patternsAt = Date.now();
737
- log(\`ready: \${patterns.length} pattern vectors, model \${model}, socket \${SOCKET}\`);
738
805
 
739
806
  let idleTimer;
740
807
  let lastActivityAt = Date.now();
@@ -815,16 +882,62 @@ async function main() {
815
882
  } catch {
816
883
  /* ignore */
817
884
  }
885
+ try {
886
+ // Only OUR pointer is removed — one that already names another daemon's socket stays.
887
+ if (SOCKET_REASON === 'tmpdir-short' && existsSync(SOCKET_POINTER) && readFileSync(SOCKET_POINTER, 'utf-8').trim() === SOCKET) unlinkSync(SOCKET_POINTER);
888
+ } catch {
889
+ /* ignore */
890
+ }
818
891
  process.exit(code);
819
892
  }
820
893
 
821
894
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => shutdown(0));
822
895
 
823
- server.listen(SOCKET, () => touch());
824
- server.on('error', (err) => {
825
- log('listen failed:', err?.message ?? err);
826
- process.exit(0);
896
+ // FR-3 ("absence of a receipt is not success"): \`ready\` is printed ONLY after \`listen\`'s callback
897
+ // AND a fresh \`existsSync(SOCKET)\` both confirm the socket file is actually on disk — a caller
898
+ // that greps stderr for "ready" must never see it for a socket that silently failed to bind.
899
+ function bindFailed(detail) {
900
+ log(\`bind failed: \${detail} (path \${Buffer.byteLength(SOCKET, 'utf8')} bytes)\`);
901
+ process.exit(3);
902
+ }
903
+
904
+ // A fresh project's SOCKET directory (\`.dz/\`, or a caller-injected DZ_EMBED_SOCKET's own parent)
905
+ // may not exist yet — \`dz setup\` usually creates \`.dz/\` before this daemon ever runs, but a
906
+ // missing parent must not surface as an opaque platform EACCES/ENOENT when a plain mkdir fixes it.
907
+ if (SOCKET_TOO_LONG) bindFailed('even the tmpdir-short fallback exceeds the unix socket path limit — set DZ_EMBED_SOCKET to a short path');
908
+ try {
909
+ // Lead edit after Codex review (finding 2): the private per-user directory is created 0700 and
910
+ // verified — a socket in a world-writable tmpdir could be pre-bound or hijacked by a neighbour.
911
+ mkdirSync(dirname(SOCKET), { recursive: true, mode: 0o700 });
912
+ if (SOCKET_REASON === 'tmpdir-short') {
913
+ const st = statSync(dirname(SOCKET));
914
+ const ownUid = typeof process.getuid === 'function' ? process.getuid() : st.uid;
915
+ if (st.uid !== ownUid || (st.mode & 0o077) !== 0) bindFailed(\`socket directory \${dirname(SOCKET)} is not private (uid \${st.uid}, mode \${(st.mode & 0o777).toString(8)})\`);
916
+ }
917
+ } catch (err) {
918
+ if (SOCKET_REASON === 'tmpdir-short') bindFailed(\`cannot prepare socket directory: \${err?.message ?? err}\`);
919
+ /* project branch: listen() below reports the real reason if the directory truly cannot be created */
920
+ }
921
+
922
+ server.listen(SOCKET, () => {
923
+ if (!existsSync(SOCKET)) return bindFailed('socket file missing after listen');
924
+ if (SOCKET_REASON === 'tmpdir-short') {
925
+ // Lead edit after Codex review (finding 3): the pointer is published ATOMICALLY (tmp + rename)
926
+ // and BEFORE \`ready\` — a reader never sees a half-written or missing pointer after \`ready\`;
927
+ // a pointer that cannot be published is a bind failure, not a warning.
928
+ try {
929
+ mkdirSync(join(PROJECT, '.dz'), { recursive: true });
930
+ const tmp = \`\${SOCKET_POINTER}.\${process.pid}.tmp\`;
931
+ writeFileSync(tmp, SOCKET);
932
+ renameSync(tmp, SOCKET_POINTER);
933
+ } catch (err) {
934
+ return bindFailed(\`could not publish socket pointer: \${err?.message ?? err}\`);
935
+ }
936
+ }
937
+ log(\`ready: \${patterns.length} pattern vectors, model \${model}, socket \${SOCKET}\`);
938
+ touch();
827
939
  });
940
+ server.on('error', (err) => bindFailed(err?.message ?? String(err)));
828
941
  }
829
942
 
830
943
  main().catch((err) => {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * embed-socket-short-path (feature, ADR-001 D4-A follow-on to `setup-installs-apply-leg`).
3
+ *
4
+ * The APPLY-leg daemon binds a unix domain socket at `<projectRoot>/.dz/embed.sock`. Unix sockets
5
+ * carry a hard platform limit on `sun_path` — 108 bytes on Linux, 104 on macOS, NUL included — and
6
+ * `Buffer.byteLength`, not `.length`, is the right measure (a multi-byte path component under a
7
+ * deep, non-ASCII-free checkout costs more bytes than characters). Past that limit `listen()` fails
8
+ * while everything else about the daemon (deps resolved, model loaded, patterns read) looks fine —
9
+ * so a deeply nested project got a daemon that logged "ready" and a hook/doctor that both reported
10
+ * ABSENT, three consumers agreeing on a wrong answer for three different reasons.
11
+ *
12
+ * This module is the ONE resolver all three consumers use:
13
+ * - the generated daemon ({@link "./apply-leg.js".embedDaemonSource}) — as inlined TEXT, since a
14
+ * template string cannot `import` a compiled module (apply-leg-twins.test.ts pins the copies);
15
+ * - the generated recall hook ({@link "./apply-leg.js".recallHookSource}) — same inlining;
16
+ * - `dz doctor` (`operations.ts`) — a real `import`, since doctor runs compiled code.
17
+ *
18
+ * A safety margin (100, not 108/104) is deliberate: it leaves room for the NUL terminator and for
19
+ * platform differences without needing to fork the threshold per-OS.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+
24
+ import { existsSync, readFileSync } from 'node:fs';
25
+ import { createHash } from 'node:crypto';
26
+ import { join } from 'node:path';
27
+ import { tmpdir } from 'node:os';
28
+
29
+ /**
30
+ * Byte-length ceiling this module resolves under, measured via `Buffer.byteLength`. Chosen as a
31
+ * safety margin below the tightest real platform limit (macOS `sun_path` = 104 bytes incl. NUL;
32
+ * Linux = 108) rather than as a per-OS fork — a 100-byte project path always fits both.
33
+ */
34
+ export const EMBED_SOCKET_PATH_BYTES_LIMIT = 100;
35
+
36
+ /** Why {@link resolveEmbedSocketPath} picked the path it returned. */
37
+ export type EmbedSocketPathReason = 'env' | 'project' | 'tmpdir-short';
38
+
39
+ /** The resolved socket path plus the reason, so callers can render an honest doctor/log message. */
40
+ export interface ResolvedEmbedSocketPath {
41
+ readonly path: string;
42
+ readonly reason: EmbedSocketPathReason;
43
+ /** The computed fallback itself exceeds the byte limit — the daemon must refuse, not pretend. */
44
+ readonly tooLong?: boolean;
45
+ }
46
+
47
+ /**
48
+ * FR-1. `DZ_EMBED_SOCKET` (if set to a non-empty string) always wins — an explicit override is
49
+ * never second-guessed. Otherwise: the natural `<projectRoot>/.dz/embed.sock` path if its BYTE
50
+ * length is within {@link EMBED_SOCKET_PATH_BYTES_LIMIT}; otherwise a short, deterministic path
51
+ * under `os.tmpdir()` keyed by a 12-hex-char SHA-1 of `projectRoot` — deterministic so a second
52
+ * process resolving the SAME project (with the SAME `os.tmpdir()`) independently arrives at the
53
+ * SAME path without needing to read a pointer file first.
54
+ */
55
+ export function resolveEmbedSocketPath(projectRoot: string, env: NodeJS.ProcessEnv = process.env): ResolvedEmbedSocketPath {
56
+ const fromEnv = env.DZ_EMBED_SOCKET;
57
+ if (typeof fromEnv === 'string' && fromEnv !== '') {
58
+ return { path: fromEnv, reason: 'env' };
59
+ }
60
+ const projectPath = join(projectRoot, '.dz', 'embed.sock');
61
+ if (Buffer.byteLength(projectPath, 'utf8') <= EMBED_SOCKET_PATH_BYTES_LIMIT) {
62
+ return { path: projectPath, reason: 'project' };
63
+ }
64
+ // Lead edit after Codex review (findings 2/4): the short socket lives in a PRIVATE per-user
65
+ // directory (mode 0700, created by the daemon), never directly in the world-writable tmpdir; and
66
+ // the fallback itself is measured — a long TMPDIR can still overflow sun_path, which is reported
67
+ // as `tooLong` for the daemon to refuse with an exact diagnosis instead of a phantom "ready".
68
+ const hash = createHash('sha1').update(projectRoot).digest('hex').slice(0, 12);
69
+ const uid = String(process.getuid?.() ?? 'u');
70
+ const shortPath = join(tmpdir(), `dz-${uid}`, `embed-${hash}.sock`);
71
+ const tooLong = Buffer.byteLength(shortPath, 'utf8') > EMBED_SOCKET_PATH_BYTES_LIMIT;
72
+ return tooLong ? { path: shortPath, reason: 'tmpdir-short', tooLong: true } : { path: shortPath, reason: 'tmpdir-short' };
73
+ }
74
+
75
+ /** FR-2. Where the daemon writes (and a reader looks for) the tmpdir-short pointer file. */
76
+ export function embedSocketPointerPath(projectRoot: string): string {
77
+ return join(projectRoot, '.dz', 'embed.sock.path');
78
+ }
79
+
80
+ /**
81
+ * FR-2. Read back the pointer the daemon wrote when it picked the `tmpdir-short` branch. Returns
82
+ * `undefined` on anything short of a non-empty file — absent, unreadable, or blank — never throws:
83
+ * this is a hint a reader may trust, not a contract it depends on.
84
+ */
85
+ export function readEmbedSocketPointer(projectRoot: string): string | undefined {
86
+ const pointerPath = embedSocketPointerPath(projectRoot);
87
+ if (!existsSync(pointerPath)) return undefined;
88
+ try {
89
+ const raw = readFileSync(pointerPath, 'utf-8').trim();
90
+ return raw !== '' ? raw : undefined;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * FR-2/FR-5. The read-side combinator the hook and doctor both use: resolve as usual, and — ONLY
98
+ * when the resolver itself would have picked the `tmpdir-short` branch — prefer an on-disk pointer
99
+ * if one exists. This is deliberately narrower than "always prefer the pointer": the `env` and
100
+ * `project` branches are computed the same way in every process (no `os.tmpdir()` dependency), so
101
+ * there is nothing for a pointer to protect against there. `tmpdir-short` DOES depend on
102
+ * `os.tmpdir()`, which can differ between the daemon's process and a reader's (a different
103
+ * `TMPDIR`) — the pointer is the daemon's own record of the path it actually bound, and wins.
104
+ */
105
+ export function resolveEffectiveEmbedSocketPath(projectRoot: string, env: NodeJS.ProcessEnv = process.env): ResolvedEmbedSocketPath {
106
+ const resolved = resolveEmbedSocketPath(projectRoot, env);
107
+ if (resolved.reason !== 'tmpdir-short') return resolved;
108
+ // Lead edit after Codex review (finding 1): a pointer is trusted only while its target exists —
109
+ // a stale pointer (daemon gone, TMPDIR changed) falls back to the computed path instead of
110
+ // steering the hook/doctor at a dead or foreign socket.
111
+ const pointer = readEmbedSocketPointer(projectRoot);
112
+ return pointer !== undefined && existsSync(pointer) ? { path: pointer, reason: 'tmpdir-short' } : resolved;
113
+ }
package/src/index.ts CHANGED
@@ -232,7 +232,9 @@ export type {
232
232
  TeachGuardResult,
233
233
  } from './vector-tier.js';
234
234
  export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION,
235
- agentdbStorePath, agentdbMcpStorePath, agentdbStoreSeparationProblem } from './setup.js';
235
+ agentdbStorePath, agentdbMcpStorePath, agentdbStoreSeparationProblem,
236
+ resolveSetupMemoryBackend, memoryBackendSourceLabel } from './setup.js';
237
+ export type { MemoryBackend, MemoryBackendSource, ResolvedSetupMemoryBackend } from './setup.js';
236
238
  // apply-leg (feature setup-installs-apply-leg, ADR-001): the third self-learning leg (APPLY) as a
237
239
  // versioned generator + the ONE measurement dz doctor/parity both read (Decision 3).
238
240
  export {
@@ -255,6 +257,16 @@ export type {
255
257
  ApplyLegNotInstalledReason,
256
258
  ResolvedIdleMs,
257
259
  } from './apply-leg.js';
260
+ // embed-socket-short-path: the ONE resolver the daemon (inlined text), the recall hook (inlined
261
+ // text), and dz doctor (real import) all use for the unix-socket sun_path length limit.
262
+ export {
263
+ EMBED_SOCKET_PATH_BYTES_LIMIT,
264
+ resolveEmbedSocketPath,
265
+ embedSocketPointerPath,
266
+ readEmbedSocketPointer,
267
+ resolveEffectiveEmbedSocketPath,
268
+ } from './embed-socket-path.js';
269
+ export type { EmbedSocketPathReason, ResolvedEmbedSocketPath } from './embed-socket-path.js';
258
270
  export { countLearningStoreRowsReadonly, quarantineTierParity } from './store-counts.js';
259
271
  export type { QuarantineTierRow, QuarantineTierParity } from './store-counts.js';
260
272
  export type { LearningStoreRowCounts } from './store-counts.js';
@@ -717,7 +729,7 @@ export { fetchAllDownloads } from './downloads.js';
717
729
  export type { PackageDownloads, DownloadsReport } from './downloads.js';
718
730
  export { discoverInstalled, checkUpgrades } from './upgrade.js';
719
731
  export type { InstalledSkill, UpgradeCheck, UpgradeReport } from './upgrade.js';
720
- export type { PublishResult, PublishReport, ProvenanceMode, ProvenanceDecision } from './publish.js';
732
+ export type { PublishResult, PublishReport, ProvenanceMode, ProvenanceDecision, PackedTarballArtifact, PackedTransportSmokeVerdict } from './publish.js';
721
733
  // Verified-release engine (feature release-verified, ADR-001) — pure VERIFY-phase planner +
722
734
  // classifier in front of the untouched publish path. formatPublishError is re-exported for the
723
735
  // CLI executor's captured-output discipline.
@@ -750,6 +762,27 @@ export type {
750
762
  FailureIssueContext,
751
763
  } from './release.js';
752
764
  export { formatPublishError } from './publish.js';
765
+ // Sibling-drift + packed-install-smoke gates (feature publish-sibling-drift-gate, ADR-001).
766
+ export { detectSiblingDrift } from './publish-sibling-drift.js';
767
+ export type {
768
+ SiblingDriftStatus,
769
+ SiblingDriftResult,
770
+ FetchedPublished,
771
+ FetchPublished,
772
+ DetectSiblingDriftOptions,
773
+ } from './publish-sibling-drift.js';
774
+ export { planPackedInstallSmoke, judgePackedInstallSmoke, packedTarballName } from './packed-install-smoke.js';
775
+ export type {
776
+ PackedInstallStepKind,
777
+ PackedInstallStep,
778
+ PackedInstallPackageSpec,
779
+ PackedInstallBinSpec,
780
+ PlanPackedInstallSmokeOptions,
781
+ PackedInstallPlan,
782
+ PackedInstallExecution,
783
+ PackedInstallBinVerdict,
784
+ PackedInstallVerdict,
785
+ } from './packed-install-smoke.js';
753
786
  export { computeRiskScore } from './risk-scoring.js';
754
787
  export type { RiskScore, RiskThresholds } from './risk-scoring.js';
755
788
  export {
package/src/operations.ts CHANGED
@@ -1412,6 +1412,60 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1412
1412
  // never ran `dz setup` gets no apply-leg opinion, same as every other doctor check here.
1413
1413
  } catch { /* doctor never throws on a diagnostic */ }
1414
1414
 
1415
+ // MEMORY HOOKS MATCH CONFIG (feature `setup-backend-from-config`, FR-4). "Absence of a receipt
1416
+ // is not success": a repeat `dz setup --target claude-code` (no `--memory`) used to silently drop
1417
+ // `.dz/agentdb-writer.mjs` from SessionStart on an agentdb project without `doctor` ever noticing
1418
+ // (AC-1, red-first) — `.dz/config.json`'s `memory.backend` and the ACTUAL SessionStart/
1419
+ // SessionEnd/PreCompact hooks in `.claude/settings.json` are two independent truths, and this
1420
+ // check compares them by fact rather than trusting either source alone.
1421
+ try {
1422
+ let configuredMemoryBackend: 'agentdb' | 'jsonl' | 'unknown' = 'unknown';
1423
+ try {
1424
+ const cfg = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf-8')) as { memory?: { backend?: string } };
1425
+ if (cfg.memory?.backend === 'agentdb') configuredMemoryBackend = 'agentdb';
1426
+ else if (cfg.memory?.backend === 'jsonl') configuredMemoryBackend = 'jsonl';
1427
+ } catch { /* no .dz/config.json yet — nothing to compare */ }
1428
+
1429
+ if (configuredMemoryBackend !== 'unknown') {
1430
+ const { commandsOf } = await import('./setup.js');
1431
+ // Lead edit after Codex review (findings 1/4): agentdb is "wired" only when EVERY session event
1432
+ // invokes the writer (a writer on SessionStart alone loses the end-of-session row), and the writer
1433
+ // is recognized by its PATH TOKEN, not a substring — `old-agentdb-writer.mjs` or the name inside an
1434
+ // echo must not count.
1435
+ const invokesWriter = (cmd: string): boolean =>
1436
+ cmd.split(/\s+/).some((tok) => /^(?:\.\/)?(?:.*\/)?\.dz\/agentdb-writer\.mjs$/.test(tok.replace(/^["']|["']$/g, '')));
1437
+ const WRITER_EVENTS = ['SessionStart', 'SessionEnd', 'PreCompact'];
1438
+ let eventsWithWriter: string[] = [];
1439
+ let settingsReadable = false;
1440
+ try {
1441
+ const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf-8')) as {
1442
+ hooks?: Record<string, unknown[]>;
1443
+ };
1444
+ settingsReadable = true;
1445
+ eventsWithWriter = WRITER_EVENTS.filter((ev) => (settings.hooks?.[ev] ?? []).some((h) => commandsOf(h).some(invokesWriter)));
1446
+ } catch {
1447
+ }
1448
+ const allWired = eventsWithWriter.length === WRITER_EVENTS.length;
1449
+ const hooksInvokeAgentdbWriter = eventsWithWriter.length > 0;
1450
+
1451
+ if (configuredMemoryBackend === 'agentdb' && !allWired) {
1452
+ checks.push({
1453
+ name: 'memory hooks match config',
1454
+ ok: false,
1455
+ detail: `.dz/config.json says memory.backend=agentdb but ${WRITER_EVENTS.filter((ev) => !eventsWithWriter.includes(ev)).join('/')} hook(s) do not invoke .dz/agentdb-writer.mjs${settingsReadable ? '' : ' (.claude/settings.json unreadable or absent)'} — run: dz setup --target claude-code --memory agentdb`,
1456
+ });
1457
+ } else if (configuredMemoryBackend === 'jsonl' && hooksInvokeAgentdbWriter) {
1458
+ checks.push({
1459
+ name: 'memory hooks match config',
1460
+ ok: false,
1461
+ detail: '.dz/config.json says memory.backend=jsonl but SessionStart/SessionEnd/PreCompact hooks still invoke agentdb-writer.mjs — run: dz setup --target claude-code --memory jsonl (or --memory agentdb to keep agentdb and bring the config back in sync)',
1462
+ });
1463
+ }
1464
+ }
1465
+ // configuredMemoryBackend === 'unknown': no .dz/config.json yet — nothing to compare, same
1466
+ // silence as every other doctor check that needs it.
1467
+ } catch { /* doctor never throws on a diagnostic */ }
1468
+
1415
1469
  const writerPath = join(root, '.dz', 'agentdb-writer.mjs');
1416
1470
  if (existsSync(writerPath)) {
1417
1471
  const { writerVersionOf, AGENTDB_WRITER_VERSION } = await import('./setup.js');
@@ -1433,13 +1487,23 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1433
1487
  const settingsText = readFileSync(settingsPath, 'utf-8');
1434
1488
  const applyLegWired = settingsText.includes('recall-hook.cjs') && settingsText.includes('dz-embed-daemon.mjs');
1435
1489
  if (applyLegWired) {
1436
- const sockAlive = existsSync(join(root, '.dz', 'embed.sock'));
1490
+ // embed-socket-short-path (FR-5): name the ACTUAL path the resolver picked — a deeply
1491
+ // nested project's daemon binds a short tmpdir path, not `<root>/.dz/embed.sock`, and a
1492
+ // doctor that only ever checks the latter reports ABSENT for a daemon that is alive.
1493
+ const { resolveEffectiveEmbedSocketPath, EMBED_SOCKET_PATH_BYTES_LIMIT } = await import('./embed-socket-path.js');
1494
+ const resolved = resolveEffectiveEmbedSocketPath(root);
1495
+ const sockAlive = existsSync(resolved.path);
1496
+ const projectPathBytes = Buffer.byteLength(join(root, '.dz', 'embed.sock'), 'utf8');
1497
+ const tmpdirNote =
1498
+ resolved.reason === 'tmpdir-short'
1499
+ ? ` (tmpdir-short: project path ${projectPathBytes} bytes > ${EMBED_SOCKET_PATH_BYTES_LIMIT})`
1500
+ : '';
1437
1501
  checks.push({
1438
1502
  name: 'apply-leg alive (embed daemon)',
1439
1503
  ok: sockAlive,
1440
1504
  detail: sockAlive
1441
- ? 'embed.sock present — recall injection can run'
1442
- : 'embed.sock ABSENT: the recall hook is wired but cannot inject (the hook self-heals on the next prompt; a persistent absence means the daemon cannot start)',
1505
+ ? `embed socket present at ${resolved.path}${tmpdirNote} — recall injection can run`
1506
+ : `embed socket ABSENT at ${resolved.path}: the recall hook is wired but cannot inject (the hook self-heals on the next prompt; a persistent absence means the daemon cannot start)`,
1443
1507
  });
1444
1508
  }
1445
1509
  }