@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.
- package/.dz-manifest.json +107 -47
- package/README.md +108 -0
- package/dist/agentdb-index.d.ts +17 -6
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +61 -17
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +68 -5
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +466 -37
- 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 +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +105 -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 +139 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -0
- package/dist/publish-sibling-drift.js +408 -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 +91 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +317 -21
- 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/dist/vector-tier.d.ts +27 -2
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +112 -3
- package/dist/vector-tier.js.map +1 -1
- package/package.json +23 -23
- package/sbom.json +196 -46
- package/src/agentdb-index.ts +65 -17
- package/src/apply-leg.ts +461 -37
- package/src/embed-socket-path.ts +113 -0
- package/src/index.ts +44 -3
- package/src/operations.ts +104 -3
- package/src/packed-install-smoke.ts +273 -0
- package/src/publish-sibling-drift.ts +498 -0
- package/src/publish.ts +280 -25
- package/src/qe-bridge.ts +14 -0
- package/src/release.ts +366 -19
- package/src/setup.ts +108 -2
- 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
|
|
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
|
-
|
|
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
|
-
?
|
|
1442
|
-
:
|
|
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
|
+
}
|