@dzhechkov/harness-core 0.8.32 → 0.8.34

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 (63) hide show
  1. package/.dz-manifest.json +107 -47
  2. package/README.md +108 -0
  3. package/dist/agentdb-index.d.ts +17 -6
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +61 -17
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +68 -5
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +466 -37
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/embed-socket-path.d.ts +65 -0
  12. package/dist/embed-socket-path.d.ts.map +1 -0
  13. package/dist/embed-socket-path.js +100 -0
  14. package/dist/embed-socket-path.js.map +1 -0
  15. package/dist/index.d.ts +11 -4
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +9 -3
  18. package/dist/index.js.map +1 -1
  19. package/dist/operations.d.ts.map +1 -1
  20. package/dist/operations.js +105 -3
  21. package/dist/operations.js.map +1 -1
  22. package/dist/packed-install-smoke.d.ts +108 -0
  23. package/dist/packed-install-smoke.d.ts.map +1 -0
  24. package/dist/packed-install-smoke.js +172 -0
  25. package/dist/packed-install-smoke.js.map +1 -0
  26. package/dist/publish-sibling-drift.d.ts +139 -0
  27. package/dist/publish-sibling-drift.d.ts.map +1 -0
  28. package/dist/publish-sibling-drift.js +408 -0
  29. package/dist/publish-sibling-drift.js.map +1 -0
  30. package/dist/publish.d.ts +44 -0
  31. package/dist/publish.d.ts.map +1 -1
  32. package/dist/publish.js +243 -24
  33. package/dist/publish.js.map +1 -1
  34. package/dist/qe-bridge.d.ts +16 -0
  35. package/dist/qe-bridge.d.ts.map +1 -1
  36. package/dist/qe-bridge.js +1 -0
  37. package/dist/qe-bridge.js.map +1 -1
  38. package/dist/release.d.ts +91 -0
  39. package/dist/release.d.ts.map +1 -1
  40. package/dist/release.js +317 -21
  41. package/dist/release.js.map +1 -1
  42. package/dist/setup.d.ts +36 -0
  43. package/dist/setup.d.ts.map +1 -1
  44. package/dist/setup.js +96 -2
  45. package/dist/setup.js.map +1 -1
  46. package/dist/vector-tier.d.ts +27 -2
  47. package/dist/vector-tier.d.ts.map +1 -1
  48. package/dist/vector-tier.js +112 -3
  49. package/dist/vector-tier.js.map +1 -1
  50. package/package.json +23 -23
  51. package/sbom.json +196 -46
  52. package/src/agentdb-index.ts +65 -17
  53. package/src/apply-leg.ts +461 -37
  54. package/src/embed-socket-path.ts +113 -0
  55. package/src/index.ts +44 -3
  56. package/src/operations.ts +104 -3
  57. package/src/packed-install-smoke.ts +273 -0
  58. package/src/publish-sibling-drift.ts +498 -0
  59. package/src/publish.ts +280 -25
  60. package/src/qe-bridge.ts +14 -0
  61. package/src/release.ts +366 -19
  62. package/src/setup.ts +108 -2
  63. package/src/vector-tier.ts +147 -5
@@ -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';
@@ -291,7 +303,7 @@ export {
291
303
  segmentRun,
292
304
  } from './eta.js';
293
305
  export type { CheckpointObservation, EtaEstimate, EtaInput, IncompleteCoverageSample, RunSegment, StageDurationSample, StageSample } from './eta.js';
294
- export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema } from './agentdb-index.js';
306
+ export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, resetAgentdbEmbedderCache, getAgentdbEmbedderCacheStats, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema } from './agentdb-index.js';
295
307
  export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
296
308
  export { DEFAULT_EMBED_MODEL, LEGACY_EMBED_MODEL, DEFAULT_EMBED_DIM, KNOWN_EMBED_DIMS, resolveEmbedModel, readEmbedManifest, writeEmbedManifest, embedManifestPath, legacyEmbedManifest } from './embedding-config.js';
297
309
  export type { EmbedModelConfig, EmbedModelSource, EmbedManifest } from './embedding-config.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.
@@ -730,6 +742,8 @@ export {
730
742
  buildReleaseNotes,
731
743
  releaseTagName,
732
744
  firstOutputLine,
745
+ testsFailureDetail,
746
+ outputTail,
733
747
  RELEASE_GATE_ORDER,
734
748
  RELEASE_TIMEOUTS,
735
749
  } from './release.js';
@@ -750,6 +764,33 @@ export type {
750
764
  FailureIssueContext,
751
765
  } from './release.js';
752
766
  export { formatPublishError } from './publish.js';
767
+ // Sibling-drift + packed-install-smoke gates (feature publish-sibling-drift-gate, ADR-001).
768
+ export { detectSiblingDrift, parseNpmPackInventory } from './publish-sibling-drift.js';
769
+ export type {
770
+ SiblingDriftStatus,
771
+ InventorySource,
772
+ SiblingDriftResult,
773
+ FetchedPublished,
774
+ FetchPublished,
775
+ DetectSiblingDriftOptions,
776
+ PackInventory,
777
+ PackInventoryUnavailable,
778
+ LocalInventoryResult,
779
+ PackedTree,
780
+ LocalInventory,
781
+ } from './publish-sibling-drift.js';
782
+ export { planPackedInstallSmoke, judgePackedInstallSmoke, packedTarballName } from './packed-install-smoke.js';
783
+ export type {
784
+ PackedInstallStepKind,
785
+ PackedInstallStep,
786
+ PackedInstallPackageSpec,
787
+ PackedInstallBinSpec,
788
+ PlanPackedInstallSmokeOptions,
789
+ PackedInstallPlan,
790
+ PackedInstallExecution,
791
+ PackedInstallBinVerdict,
792
+ PackedInstallVerdict,
793
+ } from './packed-install-smoke.js';
753
794
  export { computeRiskScore } from './risk-scoring.js';
754
795
  export type { RiskScore, RiskThresholds } from './risk-scoring.js';
755
796
  export {
package/src/operations.ts CHANGED
@@ -1412,6 +1412,85 @@ 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
+ // Lead edit after Codex review (finding 1, 2026-09-13): three states, not a boolean — an
1441
+ // EXISTING settings.json that cannot be parsed is "unknowable", never "agrees".
1442
+ const settingsPath = join(root, '.claude', 'settings.json');
1443
+ const settingsPresent = existsSync(settingsPath);
1444
+ try {
1445
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as {
1446
+ hooks?: Record<string, unknown[]>;
1447
+ };
1448
+ eventsWithWriter = WRITER_EVENTS.filter((ev) => (settings.hooks?.[ev] ?? []).some((h) => commandsOf(h).some(invokesWriter)));
1449
+ // Lead edit after Codex round 2 (new finding 2): "readable" means the hooks were actually
1450
+ // INSPECTED — a parseable but malformed shape (`null`, a non-array event) throws inside the
1451
+ // traversal and must land in the "cannot be compared" row, never in the OK row.
1452
+ settingsReadable = true;
1453
+ } catch {
1454
+ }
1455
+ const settingsUnreadable = settingsPresent && !settingsReadable;
1456
+ const allWired = eventsWithWriter.length === WRITER_EVENTS.length;
1457
+ const hooksInvokeAgentdbWriter = eventsWithWriter.length > 0;
1458
+
1459
+ if (settingsUnreadable) {
1460
+ // Lead edit after Codex round 2: precedence — an unparseable settings.json is diagnosed FIRST for
1461
+ // both backends; the hooks are UNKNOWABLE, so neither "not wired" nor "in agreement" may be claimed.
1462
+ checks.push({
1463
+ name: 'memory hooks match config',
1464
+ ok: false,
1465
+ detail: `.dz/config.json says memory.backend=${configuredMemoryBackend} but .claude/settings.json exists and could not be parsed or inspected (invalid JSON or a malformed hooks shape) — the hooks cannot be compared; fix the file (or re-run dz setup)`,
1466
+ });
1467
+ } else if (configuredMemoryBackend === 'agentdb' && !allWired) {
1468
+ checks.push({
1469
+ name: 'memory hooks match config',
1470
+ ok: false,
1471
+ 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`,
1472
+ });
1473
+ } else if (configuredMemoryBackend === 'jsonl' && hooksInvokeAgentdbWriter) {
1474
+ checks.push({
1475
+ name: 'memory hooks match config',
1476
+ ok: false,
1477
+ 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)',
1478
+ });
1479
+ } else {
1480
+ // the OK receipt comes from the SAME comparison that produces the red rows (all three events observed)
1481
+ checks.push({
1482
+ name: 'memory hooks match config',
1483
+ ok: true,
1484
+ detail: configuredMemoryBackend === 'agentdb'
1485
+ ? 'memory.backend=agentdb — SessionStart/SessionEnd/PreCompact all invoke .dz/agentdb-writer.mjs'
1486
+ : `memory.backend=jsonl — SessionStart/SessionEnd/PreCompact do not invoke .dz/agentdb-writer.mjs${settingsPresent ? '' : ' (no .claude/settings.json — no hooks at all)'}`,
1487
+ });
1488
+ }
1489
+ }
1490
+ // configuredMemoryBackend === 'unknown': no .dz/config.json yet — nothing to compare, same
1491
+ // silence as every other doctor check that needs it.
1492
+ } catch { /* doctor never throws on a diagnostic */ }
1493
+
1415
1494
  const writerPath = join(root, '.dz', 'agentdb-writer.mjs');
1416
1495
  if (existsSync(writerPath)) {
1417
1496
  const { writerVersionOf, AGENTDB_WRITER_VERSION } = await import('./setup.js');
@@ -1433,13 +1512,35 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1433
1512
  const settingsText = readFileSync(settingsPath, 'utf-8');
1434
1513
  const applyLegWired = settingsText.includes('recall-hook.cjs') && settingsText.includes('dz-embed-daemon.mjs');
1435
1514
  if (applyLegWired) {
1436
- const sockAlive = existsSync(join(root, '.dz', 'embed.sock'));
1515
+ // embed-socket-short-path (FR-5): name the ACTUAL path the resolver picked — a deeply
1516
+ // nested project's daemon binds a short tmpdir path, not `<root>/.dz/embed.sock`, and a
1517
+ // doctor that only ever checks the latter reports ABSENT for a daemon that is alive.
1518
+ const { resolveEffectiveEmbedSocketPath, EMBED_SOCKET_PATH_BYTES_LIMIT } = await import('./embed-socket-path.js');
1519
+ const resolved = resolveEffectiveEmbedSocketPath(root);
1520
+ const sockAlive = existsSync(resolved.path);
1521
+ const projectPathBytes = Buffer.byteLength(join(root, '.dz', 'embed.sock'), 'utf8');
1522
+ const tmpdirNote =
1523
+ resolved.reason === 'tmpdir-short'
1524
+ ? ` (tmpdir-short: project path ${projectPathBytes} bytes > ${EMBED_SOCKET_PATH_BYTES_LIMIT})`
1525
+ : '';
1526
+ // FR-6 (hook-recall-hybrid-parity): a socket that merely EXISTS is not proof of what it
1527
+ // answers with — probe it. A non-live fixture (a plain file, no listener) fails the probe
1528
+ // near-instantly and this note stays empty, so every pre-existing detail string here is
1529
+ // untouched.
1530
+ // AM-8 (fix round 1): the socket-speaking probe itself now lives in apply-leg.ts (the
1531
+ // module that already owns the daemon's wire protocol + socket-path resolution) rather
1532
+ // than duplicating a `node:net` IO surface here — dynamic `import()`, same pattern as
1533
+ // `embed-socket-path.js` two lines above, so operations.ts carries no new top-level IO
1534
+ // import for this.
1535
+ const { probeRecallEngine } = await import('./apply-leg.js');
1536
+ const engine = sockAlive ? await probeRecallEngine(resolved.path) : undefined;
1537
+ const engineNote = engine !== undefined ? ` (engine: ${engine})` : '';
1437
1538
  checks.push({
1438
1539
  name: 'apply-leg alive (embed daemon)',
1439
1540
  ok: sockAlive,
1440
1541
  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)',
1542
+ ? `embed socket present at ${resolved.path}${tmpdirNote}${engineNote} — recall injection can run`
1543
+ : `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
1544
  });
1444
1545
  }
1445
1546
  }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Packed-install smoke — feature `publish-sibling-drift-gate`, ADR-001 (Decision 2).
3
+ *
4
+ * `dz release`'s existing smoke gate boots a package's bin straight from the WORKSPACE — its
5
+ * sibling `workspace:*` deps resolve via pnpm's workspace links, never through a real install.
6
+ * That makes the whole class of "published tarball missing an export" incidents invisible by
7
+ * construction (Alternative Б1, rejected). This module plans and judges the alternative
8
+ * (Б2, accepted): pack every package in the batch, `npm install` the resulting tarballs together
9
+ * into a CLEAN directory — siblings OUTSIDE the batch resolve from the registry, exactly like a
10
+ * fresh user's install — then boot every bin with `--version` and require exit 0 AND non-empty
11
+ * stdout (the "publisher output is not a receipt" lesson: a bin that boots but prints nothing has
12
+ * not proven it works).
13
+ *
14
+ * Pure by construction (NFR-2): `planPackedInstallSmoke` only builds command STRINGS from
15
+ * injected package/bin facts and paths — it never spawns anything. `judgePackedInstallSmoke`
16
+ * only classifies injected execution records. The CLI (`cmdPublish`, `cmdRelease`) is the single
17
+ * executor, sharing this same plan/judge pair so both doors apply the identical rule.
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+
22
+ import { join } from 'node:path';
23
+
24
+ export type PackedInstallStepKind = 'pack' | 'install' | 'bin-exists' | 'bin-version';
25
+
26
+ /** One concrete step — data, not action (mirrors release.ts's GateStep idiom). */
27
+ export interface PackedInstallStep {
28
+ readonly id: string;
29
+ readonly kind: PackedInstallStepKind;
30
+ readonly cmd: string;
31
+ readonly cwd: string;
32
+ readonly timeoutMs: number;
33
+ /** Present for 'pack' and 'bin-version' steps. */
34
+ readonly pkg?: string;
35
+ /** Present for 'bin-version' steps only. */
36
+ readonly binName?: string;
37
+ }
38
+
39
+ export interface PackedInstallPackageSpec {
40
+ readonly name: string;
41
+ /** Absolute source directory to `npm pack`. */
42
+ readonly dir: string;
43
+ readonly version: string;
44
+ }
45
+
46
+ export interface PackedInstallBinSpec {
47
+ readonly pkg: string;
48
+ readonly binName: string;
49
+ /** Path to the executable relative to the package's OWN directory (as it ships), e.g. "dist/bin.js". */
50
+ readonly relPath: string;
51
+ }
52
+
53
+ export interface PlanPackedInstallSmokeOptions {
54
+ /** Every package to pack — the full batch, since a bin-less sibling can still be a dependency. */
55
+ readonly packages: readonly PackedInstallPackageSpec[];
56
+ /** Bins to boot after install (typically the subset of `packages` that declare one). */
57
+ readonly bins: readonly PackedInstallBinSpec[];
58
+ /** Directory `npm pack --pack-destination` writes tarballs into. */
59
+ readonly packDir: string;
60
+ /** Fresh, empty directory `npm install` runs in — outside-batch siblings resolve from the registry here. */
61
+ readonly installDir: string;
62
+ readonly packTimeoutMs?: number;
63
+ readonly installTimeoutMs?: number;
64
+ readonly versionTimeoutMs?: number;
65
+ /**
66
+ * AM-1 (feature publish-sibling-drift-gate): the caller (`publishPackages`'s `packedTransport`)
67
+ * already packed each artifact ONCE, post-bump — a SECOND, different `npm pack` here would smoke
68
+ * bytes other than the ones about to be published, reintroducing the exact defect this amendment
69
+ * closes. `true` skips planning any 'pack' step; `tarballs` is still populated with the SAME
70
+ * deterministic `packedTarballName(name, version)` path under `packDir` — the caller is
71
+ * responsible for having written the tarball there already (`packages[].dir` is unused in this
72
+ * mode and may be any string).
73
+ */
74
+ readonly skipPack?: boolean;
75
+ }
76
+
77
+ export interface PackedInstallPlan {
78
+ readonly steps: readonly PackedInstallStep[];
79
+ /** Absolute tarball paths the install step references, in package order. */
80
+ readonly tarballs: readonly string[];
81
+ }
82
+
83
+ const DEFAULT_PACK_TIMEOUT_MS = 60_000;
84
+ const DEFAULT_INSTALL_TIMEOUT_MS = 180_000;
85
+ const DEFAULT_VERSION_TIMEOUT_MS = 30_000;
86
+
87
+ /** Mirror npm's own tarball naming: `@scope/name@1.2.3` -> `scope-name-1.2.3.tgz`. */
88
+ export function packedTarballName(name: string, version: string): string {
89
+ return `${name.replace(/^@/, '').replace(/\//g, '-')}-${version}.tgz`;
90
+ }
91
+
92
+ export function planPackedInstallSmoke(opts: PlanPackedInstallSmokeOptions): PackedInstallPlan {
93
+ const packTimeoutMs = opts.packTimeoutMs ?? DEFAULT_PACK_TIMEOUT_MS;
94
+ const installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
95
+ const versionTimeoutMs = opts.versionTimeoutMs ?? DEFAULT_VERSION_TIMEOUT_MS;
96
+
97
+ const steps: PackedInstallStep[] = [];
98
+ const tarballs: string[] = [];
99
+
100
+ for (const pkg of opts.packages) {
101
+ const tgz = join(opts.packDir, packedTarballName(pkg.name, pkg.version));
102
+ tarballs.push(tgz);
103
+ if (opts.skipPack === true) continue; // AM-1: already packed by the caller — see skipPack's doc
104
+ steps.push({
105
+ id: `pack:${pkg.name}`,
106
+ kind: 'pack',
107
+ cmd: `npm pack ${JSON.stringify(pkg.dir)} --pack-destination ${JSON.stringify(opts.packDir)}`,
108
+ cwd: pkg.dir,
109
+ timeoutMs: packTimeoutMs,
110
+ pkg: pkg.name,
111
+ });
112
+ }
113
+
114
+ if (tarballs.length > 0) {
115
+ steps.push({
116
+ id: 'install',
117
+ kind: 'install',
118
+ cmd: `npm install ${tarballs.map((t) => JSON.stringify(t)).join(' ')} --no-audit --no-fund`,
119
+ cwd: opts.installDir,
120
+ timeoutMs: installTimeoutMs,
121
+ });
122
+ }
123
+
124
+ for (const bin of opts.bins) {
125
+ const absBinPath = join(opts.installDir, 'node_modules', bin.pkg, bin.relPath);
126
+ // AM-8: a manifest can declare a `bin` whose target file does not exist (never built, moved,
127
+ // typo'd) — the OLD `cli.ts` bin-collection step silently DROPPED such a bin before this
128
+ // amendment, which read as "n/a: nothing to smoke" (or even skipped the whole gate when it
129
+ // was the batch's only bin). `test -f` is a dedicated, portable existence probe RUN AFTER THE
130
+ // REAL INSTALL — pass/fail here is judged into a specific, honest message
131
+ // ("declared bin missing after packed install") instead of being folded into whatever
132
+ // `node <bin> --version` happens to print for a missing file (a generic MODULE_NOT_FOUND).
133
+ steps.push({
134
+ id: `bin-exists:${bin.pkg}:${bin.binName}`,
135
+ kind: 'bin-exists',
136
+ cmd: `test -f ${JSON.stringify(absBinPath)}`,
137
+ cwd: opts.installDir,
138
+ timeoutMs: versionTimeoutMs,
139
+ pkg: bin.pkg,
140
+ binName: bin.binName,
141
+ });
142
+ steps.push({
143
+ id: `bin:${bin.pkg}:${bin.binName}`,
144
+ kind: 'bin-version',
145
+ cmd: `node ${JSON.stringify(absBinPath)} --version`,
146
+ cwd: opts.installDir,
147
+ timeoutMs: versionTimeoutMs,
148
+ pkg: bin.pkg,
149
+ binName: bin.binName,
150
+ });
151
+ }
152
+
153
+ return { steps, tarballs };
154
+ }
155
+
156
+ export interface PackedInstallExecution {
157
+ readonly stepId: string;
158
+ readonly exitCode: number;
159
+ readonly stdout: string;
160
+ readonly stderr: string;
161
+ readonly timedOut?: boolean;
162
+ }
163
+
164
+ export interface PackedInstallBinVerdict {
165
+ readonly pkg: string;
166
+ readonly binName: string;
167
+ readonly ok: boolean;
168
+ readonly stdout: string;
169
+ /** First 3 non-empty lines of stderr (falling back to stdout), present only when !ok. */
170
+ readonly detail?: string;
171
+ }
172
+
173
+ export interface PackedInstallVerdict {
174
+ readonly ok: boolean;
175
+ readonly packOk: boolean;
176
+ readonly installOk: boolean;
177
+ /** First failure's detail, from whichever of pack/install failed first. */
178
+ readonly failureDetail?: string;
179
+ readonly bins: readonly PackedInstallBinVerdict[];
180
+ }
181
+
182
+ /**
183
+ * First 3 lines (FR-3), EXCEPT when they are pure source context. MEASURED 2026-09-13 reproducing
184
+ * the exact incident this gate targets — a bin whose `import` names a missing export — Node
185
+ * prints the location, the offending source line and a caret BEFORE the actual
186
+ * `SyntaxError: … does not provide an export named …` message (identically for a plain uncaught
187
+ * `throw`: file:line / code / `^` / blank / `Error: …`). A literal first-3-lines slice therefore
188
+ * shows three lines of code and caret marks and never the reason — useless for the incident it
189
+ * exists to surface. When stderr contains a line that LOOKS like an error header
190
+ * (`SomethingError: …` or bare `Error: …`), the snippet starts there instead.
191
+ */
192
+ function firstLines(text: string, n: number): string {
193
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
194
+ if (lines.length === 0) return '(no output)';
195
+ const errorHeaderAt = lines.findIndex((l) => /^[A-Za-z][A-Za-z0-9_]*Error:|^Error\b/.test(l));
196
+ const start = errorHeaderAt >= 0 ? errorHeaderAt : 0;
197
+ return lines.slice(start, start + n).join(' | ');
198
+ }
199
+
200
+ const NO_EXECUTION_RECORD = '(no execution record — under-executed plan)';
201
+
202
+ function stepOk(exec: PackedInstallExecution | undefined): boolean {
203
+ return exec !== undefined && exec.timedOut !== true && exec.exitCode === 0;
204
+ }
205
+
206
+ function stepDetail(exec: PackedInstallExecution | undefined): string {
207
+ if (exec === undefined) return NO_EXECUTION_RECORD;
208
+ if (exec.timedOut === true) return 'timed out';
209
+ return firstLines(exec.stderr || exec.stdout, 3);
210
+ }
211
+
212
+ /**
213
+ * Classify a plan's executions. `plan` may be the `{ steps }` half of {@link PackedInstallPlan}.
214
+ * A missing execution for a planned step is a FAILURE (an under-executed plan cannot pass) —
215
+ * never treated as "nothing to judge, so it passed".
216
+ */
217
+ export function judgePackedInstallSmoke(
218
+ plan: { readonly steps: readonly PackedInstallStep[] },
219
+ executions: readonly PackedInstallExecution[],
220
+ ): PackedInstallVerdict {
221
+ const byId = new Map(executions.map((e) => [e.stepId, e]));
222
+
223
+ let packOk = true;
224
+ let failureDetail: string | undefined;
225
+ for (const step of plan.steps.filter((s) => s.kind === 'pack')) {
226
+ const exec = byId.get(step.id);
227
+ if (!stepOk(exec)) {
228
+ packOk = false;
229
+ failureDetail ??= stepDetail(exec);
230
+ }
231
+ }
232
+
233
+ let installOk = true;
234
+ const installStep = plan.steps.find((s) => s.kind === 'install');
235
+ if (installStep !== undefined) {
236
+ const exec = byId.get(installStep.id);
237
+ if (!stepOk(exec)) {
238
+ installOk = false;
239
+ failureDetail ??= stepDetail(exec);
240
+ }
241
+ }
242
+
243
+ const bins: PackedInstallBinVerdict[] = plan.steps
244
+ .filter((s) => s.kind === 'bin-version')
245
+ .map((step) => {
246
+ const exec = byId.get(step.id);
247
+ if (!packOk || !installOk) {
248
+ // Pack/install already failed for the whole batch — the generic pack/install detail is
249
+ // more informative than a bin-specific message about a step that never had a chance to run.
250
+ return { pkg: step.pkg!, binName: step.binName!, ok: false, stdout: '', detail: stepDetail(exec) };
251
+ }
252
+ // AM-8: a declared bin missing from the REAL post-install tree is its own failure class —
253
+ // checked and reported BEFORE the generic stdout rule below, whose message ("empty stdout")
254
+ // would otherwise misdescribe a file that was never there to boot at all.
255
+ const existsStep = plan.steps.find((s) => s.kind === 'bin-exists' && s.pkg === step.pkg && s.binName === step.binName);
256
+ if (existsStep !== undefined && !stepOk(byId.get(existsStep.id))) {
257
+ return { pkg: step.pkg!, binName: step.binName!, ok: false, stdout: '', detail: 'declared bin missing after packed install' };
258
+ }
259
+ // FR-3 / lesson "publisher output is not a receipt": exit 0 alone is not enough — the
260
+ // version output must be non-empty, or a bin that silently no-ops would read as healthy.
261
+ const ok = stepOk(exec) && (exec?.stdout ?? '').trim() !== '';
262
+ return {
263
+ pkg: step.pkg!,
264
+ binName: step.binName!,
265
+ ok,
266
+ stdout: exec?.stdout ?? '',
267
+ ...(ok ? {} : { detail: exec !== undefined && stepOk(exec) ? '(exit 0 but empty stdout)' : stepDetail(exec) }),
268
+ };
269
+ });
270
+
271
+ const ok = packOk && installOk && bins.every((b) => b.ok);
272
+ return { ok, packOk, installOk, ...(failureDetail !== undefined ? { failureDetail } : {}), bins };
273
+ }