@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.
- package/.dz-manifest.json +97 -37
- package/README.md +30 -0
- package/dist/apply-leg.d.ts +13 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +123 -10
- package/dist/apply-leg.js.map +1 -1
- package/dist/embed-socket-path.d.ts +65 -0
- package/dist/embed-socket-path.d.ts.map +1 -0
- package/dist/embed-socket-path.js +100 -0
- package/dist/embed-socket-path.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +66 -3
- package/dist/operations.js.map +1 -1
- package/dist/packed-install-smoke.d.ts +108 -0
- package/dist/packed-install-smoke.d.ts.map +1 -0
- package/dist/packed-install-smoke.js +172 -0
- package/dist/packed-install-smoke.js.map +1 -0
- package/dist/publish-sibling-drift.d.ts +67 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -0
- package/dist/publish-sibling-drift.js +262 -0
- package/dist/publish-sibling-drift.js.map +1 -0
- package/dist/publish.d.ts +44 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +243 -24
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +16 -0
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +1 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/release.d.ts +19 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +81 -2
- package/dist/release.js.map +1 -1
- package/dist/setup.d.ts +36 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +96 -2
- package/dist/setup.js.map +1 -1
- package/package.json +23 -23
- package/sbom.json +186 -36
- package/src/apply-leg.ts +123 -10
- package/src/embed-socket-path.ts +113 -0
- package/src/index.ts +35 -2
- package/src/operations.ts +67 -3
- package/src/packed-install-smoke.ts +273 -0
- package/src/publish-sibling-drift.ts +293 -0
- package/src/publish.ts +280 -25
- package/src/qe-bridge.ts +14 -0
- package/src/release.ts +103 -2
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
?
|
|
1442
|
-
:
|
|
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
|
}
|