@indigoai-us/hq-cli 5.109.5 → 5.109.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.109.6] — 2026-09-10
6
+
7
+ ### Fixed
8
+
9
+ - A torn hq install no longer files an unactionable crash when a bundled
10
+ third-party dependency of a dependency is missing (HQ-CLI-1Q). `hq packs list`
11
+ loaded the mesh presence client, whose `import mqtt` chain reached
12
+ mqtt-packet's `parser.js` requiring `bl` — mqtt-packet's OWN declared
13
+ transitive dependency — which a partial Windows global install had left
14
+ unwritten. The in-process incomplete-install classifier shipped in 5.108.14
15
+ recognised only a RELATIVE missing specifier for the CJS shape, so this BARE
16
+ specifier fell through to a bare Sentry crash plus an `hq:` line the operator
17
+ could not act on. The classifier now also recognises a bare miss from UNDER
18
+ the running install's own `node_modules/`, but ONLY when the missing specifier
19
+ names a WHOLE package (not a subpath like `bl/lib/x`, which can mean the
20
+ package is present and only that file gone to a version mismatch) AND the
21
+ REQUIRING third-party package's own manifest declares it in its REQUIRED
22
+ `dependencies` (`optionalDependencies` are excluded, being not guaranteed
23
+ installed) — a correct install always writes such a
24
+ dependency, so its absence beside a present requirer can only be a torn
25
+ install, never an hq-cli manifest defect. Those are printed with the same
26
+ input-free reinstall remedy and skipped, at the top-level boundary and in the
27
+ shared `beforeSend`. A bare miss the requirer does NOT declare (a possible
28
+ undeclared or peer-only dependency) stays reported, now enriched on every
29
+ capture route with a bounded `incomplete_install` context naming both packages
30
+ so the next occurrence is attributable instead of bare.
31
+
5
32
  ## [5.109.5] — 2026-09-10
6
33
 
7
34
  ## [5.109.4] — 2026-09-09
@@ -344,12 +371,13 @@
344
371
  `npm i -g @indigoai-us/hq-cli` / `pnpm add -g @indigoai-us/hq-cli`) while
345
372
  skipping Sentry capture, the same disposition established for the qmd child in
346
373
  HQ-CLI-Y. An hq-cli packaging fault stays reportable: a miss under the
347
- package's own `dist/` or `assets/`, a bare-specifier miss (a possible
348
- undeclared dependency), and an esm-loader ENOENT whose path did not survive
349
- delivery are NOT suppressed the last is captured WITH a bounded
350
- `incomplete_install` context so the next occurrence is attributable. The drop
351
- is wired both at the top-level boundary and in the shared `beforeSend`, so it
352
- covers every capture route.
374
+ package's own `dist/` or `assets/`, a bare-specifier miss (then kept loud as a
375
+ possible undeclared dependency refined for a dependency the requiring package
376
+ actually declares in HQ-CLI-1Q, see Unreleased), and an esm-loader ENOENT whose
377
+ path did not survive delivery are NOT suppressed — the last is captured WITH a
378
+ bounded `incomplete_install` context so the next occurrence is attributable.
379
+ The drop is wired both at the top-level boundary and in the shared
380
+ `beforeSend`, so it covers every capture route.
353
381
 
354
382
  ## [5.108.13] — 2026-09-06
355
383
 
package/dist/main.js CHANGED
@@ -563,21 +563,24 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
563
563
  // An IN-PROCESS module-load failure that means hq-cli's OWN installed
564
564
  // package tree is incomplete at load time — a partial/interrupted global
565
565
  // install left a bundled file unwritten (HQ-CLI-1N, a CJS relative-sibling
566
- // MODULE_NOT_FOUND), or a concurrent global install rewrote the running
567
- // tree so an ESM module present at resolve was gone at read (HQ-CLI-1M, an
568
- // esm-loader ENOENT). Both carry no hq-cli frames and reached the final
569
- // else, filing a bare crash and an unactionable line. An incomplete
570
- // install is the caller's machine, the disposition HQ-CLI-Y already
571
- // established for the qmd CHILD print the input-free reinstall remedy and
572
- // skip capture. Placed with the environmental family (after the typed qmd
573
- // carriers and the hq state-write carrier, before environmentalFsErrorMessage):
574
- // the signatures are disjoint ENVIRONMENTAL_FS_CODES is only
575
- // ENOSPC/EDQUOT/EROFS (never ENOENT/MODULE_NOT_FOUND), no qmd carrier sets
576
- // requireStack or an esm-loader frame, and the classified file must sit
577
- // under <packageRoot>/node_modules so ordering changes nothing that
578
- // exists. The UNATTRIBUTABLE shape (an esm-loader ENOENT whose path did not
579
- // survive) is deliberately NOT suppressed; it is captured WITH bounded
580
- // context on the generic path below.
566
+ // MODULE_NOT_FOUND; HQ-CLI-1Q, a CJS BARE-specifier MODULE_NOT_FOUND for a
567
+ // dependency the requiring third-party package's own manifest declares), or
568
+ // a concurrent global install rewrote the running tree so an ESM module
569
+ // present at resolve was gone at read (HQ-CLI-1M, an esm-loader ENOENT).
570
+ // All carry no hq-cli frames and reached the final else, filing a bare
571
+ // crash and an unactionable line. An incomplete install is the caller's
572
+ // machine, the disposition HQ-CLI-Y already established for the qmd CHILD —
573
+ // print the input-free reinstall remedy and skip capture. Placed with the
574
+ // environmental family (after the typed qmd carriers and the hq state-write
575
+ // carrier, before environmentalFsErrorMessage): the signatures are disjoint
576
+ // ENVIRONMENTAL_FS_CODES is only ENOSPC/EDQUOT/EROFS (never
577
+ // ENOENT/MODULE_NOT_FOUND), no qmd carrier sets requireStack or an
578
+ // esm-loader frame, and the classified file must sit under
579
+ // <packageRoot>/node_modules so ordering changes nothing that exists. The
580
+ // UNATTRIBUTABLE shapes are deliberately NOT suppressed and are captured
581
+ // WITH bounded context on the generic path below: an esm-loader ENOENT
582
+ // whose path did not survive, and a bare miss the requirer does NOT declare
583
+ // (a possible undeclared/peer dependency, now named in the context).
581
584
  const incompleteInstallMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg
582
585
  ? null
583
586
  : incompleteInstallMessage(err);
package/dist/sentry.js CHANGED
@@ -6,7 +6,7 @@ import { CLI_VERSION } from "./cli-version.js";
6
6
  import { getCachedSentryUser } from "./utils/sentry-identity.js";
7
7
  import { isEpipe } from "./utils/epipe.js";
8
8
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
9
- import { incompleteInstallMessage } from "./utils/incomplete-install-error.js";
9
+ import { incompleteInstallCaptureContext, incompleteInstallMessage, } from "./utils/incomplete-install-error.js";
10
10
  import { sentryFingerprintFor } from "./utils/sentry-fingerprint.js";
11
11
  /**
12
12
  * Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
@@ -35,17 +35,33 @@ export function epipeAwareBeforeSend(event, hint) {
35
35
  // Path-independent belt for an IN-PROCESS incomplete-install module-load
36
36
  // failure — hq-cli's own globally installed tree is not intact at load time
37
37
  // (HQ-CLI-1N, a CJS relative-sibling MODULE_NOT_FOUND under its bundled
38
- // node_modules; HQ-CLI-1M, an esm-loader ENOENT for a file present at resolve
39
- // and gone at read). handleTopLevelError already prints the reinstall remedy
40
- // for the top-level route; dropping the event here suppresses the fatal
41
- // regardless of route the unhandled-rejection boundary, the command-level
42
- // captureException sites, and bin/hq-auth-refreshmirroring the EPIPE and
43
- // environmental-fs drops above. The classifier reads only structured fields
44
- // and the failing file must sit under the running install's node_modules, so
45
- // an hq-cli packaging fault (a dist/ miss, a bare-specifier miss) and the
38
+ // node_modules; HQ-CLI-1Q, a CJS BARE-specifier MODULE_NOT_FOUND the requiring
39
+ // third-party package's own manifest declares; HQ-CLI-1M, an esm-loader ENOENT
40
+ // for a file present at resolve and gone at read). handleTopLevelError already
41
+ // prints the reinstall remedy for the top-level route; dropping the event here
42
+ // suppresses the fatal regardless of route — the unhandled-rejection boundary,
43
+ // the command-level captureException sites, and bin/hq-auth-refresh
44
+ // mirroring the EPIPE and environmental-fs drops above. The classifier reads
45
+ // only structured fields (plus, for the bare shape, the requiring package's
46
+ // own manifest) and the failing file must sit under the running install's
47
+ // node_modules, so an hq-cli packaging fault (a dist/ miss, or a bare miss the
48
+ // requirer does NOT declare — a possible undeclared/peer dependency) and the
46
49
  // path-less unattributable shape are NOT dropped here and stay captured.
47
50
  if (incompleteInstallMessage(hint?.originalException))
48
51
  return null;
52
+ // An incomplete-install shape that is NOT suppressed (a bare miss the requirer
53
+ // does not declare, or a path-less esm-loader ENOENT) should still arrive
54
+ // ATTRIBUTABLE on every capture route, not only the top-level boundary:
55
+ // handleTopLevelError in main.ts is the ONLY caller of
56
+ // incompleteInstallCaptureContext, so events reaching this hook directly — the
57
+ // unhandled-rejection integration, the command-level captureException sites,
58
+ // and bin/hq-auth-refresh — would otherwise survive bare. Merge the bounded
59
+ // context here too. Idempotent: main.ts attaches the same block on its route,
60
+ // and an already-present context wins the spread.
61
+ const installContext = incompleteInstallCaptureContext(hint?.originalException);
62
+ if (installContext) {
63
+ event.contexts = { ...installContext, ...event.contexts };
64
+ }
49
65
  // Group an event that survives to send by a BOUNDED machine discriminator so
50
66
  // unrelated gateway/HTTP failures stop colliding into one fungible issue
51
67
  // (HQ-CLI collision, Sentry 7642756130). Placed here — path-independent,
@@ -21,9 +21,9 @@ export type PackageRootResolver = () => string | null;
21
21
  * means "handle as usual (capture to Sentry)". Never throws — a resolver that
22
22
  * fails yields null.
23
23
  */
24
- export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver): string | null;
25
- /** Bounded, scrubber-safe diagnostics for an unattributable esm-loader ENOENT. */
26
- export type IncompleteInstallDiagnostics = {
24
+ export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "readFileSync">): string | null;
25
+ /** Bounded, scrubber-safe diagnostics for an unattributable esm-loader ENOENT (HQ-CLI-1M). */
26
+ export type IncompleteInstallEsmDiagnostics = {
27
27
  packageRoot: string;
28
28
  packageJsonExists: boolean;
29
29
  nodeModulesExists: boolean;
@@ -31,20 +31,42 @@ export type IncompleteInstallDiagnostics = {
31
31
  code: string;
32
32
  };
33
33
  /**
34
- * When an esm-loader ENOENT reaches the capture path WITHOUT being suppressed —
35
- * the exact shape the delivered HQ-CLI-1M payload arrived in, where neither the
36
- * exception value nor node_system_error carried a `path` return a bounded
37
- * `contexts.incomplete_install` block so the next occurrence carries the
38
- * evidence this one lacked; otherwise return undefined (bare capture). Built
39
- * with the byte-capped, scrubber-safe discipline of package-root-diagnostics.ts:
40
- * the resolved package root and whether its package.json / node_modules exist,
41
- * the loader-frame marker, and the bounded errno code — never a caller argv,
42
- * query, or user-minted value. Never throws.
43
- *
44
- * main.ts attaches this on the generic capture path exactly as
45
- * qmdSpawnFailureCaptureContext already does.
34
+ * Bounded, scrubber-safe diagnostics for a NOT-suppressed third-party bare miss
35
+ * (HQ-CLI-1Q): a possible undeclared / peer-only dependency defect, made
36
+ * attributable the missing package, the requiring package, and the fact that
37
+ * the requirer's own manifest did NOT declare it. Both names live inside
38
+ * hq-cli's dependency graph, so grouping cardinality stays bounded.
39
+ */
40
+ export type IncompleteInstallBareDiagnostics = {
41
+ missingPackage: string;
42
+ requiringPackage: string;
43
+ requirerDeclaresMissing: false;
44
+ };
45
+ /**
46
+ * Both enriched shapes as a single OPEN record — every field optional so a
47
+ * consumer can forward either shape to Sentry without narrowing (the boundary
48
+ * and beforeSend only pass the block through). Every value CONSTRUCTED here is
49
+ * exactly one of the two strict shapes above; the looseness is only at the read
50
+ * boundary.
51
+ */
52
+ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics>;
53
+ /**
54
+ * When an incomplete-install failure reaches the capture path WITHOUT being
55
+ * suppressed, return a bounded `contexts.incomplete_install` block so the next
56
+ * occurrence carries the evidence this one lacked; otherwise return undefined
57
+ * (bare capture). Two enriched shapes:
58
+ * - a third-party BARE-specifier miss the requirer did not declare (HQ-CLI-1Q)
59
+ * → { missingPackage, requiringPackage, requirerDeclaresMissing:false };
60
+ * - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
61
+ * shape the delivered payload arrived in, where neither the exception value
62
+ * nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
63
+ * nodeModulesExists, esmLoaderFrame, code }.
64
+ * Built with the byte-capped, scrubber-safe discipline of
65
+ * package-root-diagnostics.ts — never a caller argv, query, or user-minted
66
+ * value. Never throws. main.ts attaches this on the generic capture path
67
+ * exactly as qmdSpawnFailureCaptureContext already does.
46
68
  */
47
- export declare function incompleteInstallCaptureContext(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "existsSync">): {
69
+ export declare function incompleteInstallCaptureContext(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "existsSync"> & Partial<Pick<typeof fs, "readFileSync">>): {
48
70
  incomplete_install: IncompleteInstallDiagnostics;
49
71
  } | undefined;
50
72
  //# sourceMappingURL=incomplete-install-error.d.ts.map
@@ -7,7 +7,7 @@
7
7
  // failure inside THIS process rather than a qmd child's captured stderr, which
8
8
  // is the gap HQ-CLI-Y's classifier cannot cover.
9
9
  //
10
- // Two shapes, one cause — a partial/interrupted global install left a file
10
+ // Three shapes, one cause — a partial/interrupted global install left a file
11
11
  // unwritten, or a concurrent global install rewrote the running tree
12
12
  // underneath a command:
13
13
  //
@@ -19,6 +19,16 @@
19
19
  // specifier internal to a third-party package can only be a truncated on-disk
20
20
  // copy, never an hq-cli manifest defect.
21
21
  //
22
+ // HQ-CLI-1Q (Sentry 7716586928) — CJS, in-process. The SAME `import mqtt`
23
+ // chain, one hop further down: mqtt-packet's `parser.js` requires the BARE
24
+ // specifier `bl` — mqtt-packet's OWN declared transitive dependency — and it
25
+ // is absent inside hq-cli's bundled node_modules after a torn Windows global
26
+ // install. A bare miss looks like an undeclared dependency, but when the
27
+ // REQUIRING third-party package's own manifest declares it, its absence
28
+ // beside a present requirer can only be a torn install. hq-cli declares
29
+ // nothing about a contract between two third-party packages, so this is never
30
+ // an hq-cli manifest defect.
31
+ //
22
32
  // HQ-CLI-1M (Sentry 7714870912) — ESM load, in-process. A module that existed
23
33
  // at RESOLVE was gone at READ (a concurrent writer rewrote the install tree),
24
34
  // so Node's ESM loader raised `ENOENT` from getSourceSync/readFileSync/openSync
@@ -26,7 +36,7 @@
26
36
  // ERR_MODULE_NOT_FOUND at resolve, never ENOENT at load; an ENOENT at load
27
37
  // proves the file vanished between resolve and read.
28
38
  //
29
- // Both shapes carry no hq-cli frames, reach the boundary's final `else`, and —
39
+ // All shapes carry no hq-cli frames, reach the boundary's final `else`, and —
30
40
  // before this classifier — filed a bare captureException plus an unactionable
31
41
  // `hq: <fallback>` line. The disposition is the one HQ-CLI-Y already
32
42
  // established: an incomplete install is the caller's machine, so the CLI prints
@@ -35,19 +45,26 @@
35
45
  // The gate is deliberately narrow so neither an hq-cli packaging fault nor
36
46
  // user free-text can trip it. Only structured fields are read — `code`,
37
47
  // `syscall`, `path`, `requireStack`, and the stack's loader-frame marker, plus
38
- // the first message line for the CJS specifier. THREE independent narrowings
48
+ // the first message line for the CJS specifier plus, for the bare CJS shape
49
+ // only, the requiring package's own on-disk manifest. Independent narrowings
39
50
  // keep a genuine hq-cli defect reportable:
40
51
  // 1. The failing file must sit under `<packageRoot>/node_modules/` — a
41
52
  // third-party file hq-cli does not author. A miss under `<packageRoot>/dist`
42
53
  // or `/assets` is hq-cli's OWN shipped output and stays captured.
43
- // 2. The CJS shape additionally requires a RELATIVE specifier a
44
- // bare-specifier miss (`Cannot find module 'mqtt'`) can be an undeclared
45
- // dependency (an hq-cli manifest defect) and stays captured.
54
+ // 2. The CJS shape splits on the specifier. A RELATIVE specifier internal to
55
+ // a package under node_modules can only be a truncated copy (HQ-CLI-1N). A
56
+ // BARE PACKAGE-ROOT specifier stays captured UNLESS the requiring
57
+ // third-party package's own manifest declares it in its REQUIRED
58
+ // `dependencies` (HQ-CLI-1Q). A subpath miss (`bl/lib/x`), an
59
+ // optional-dependency miss (not guaranteed installed), and an undeclared or
60
+ // peer-only miss (`Cannot find module 'mqtt'` from a requirer that does not
61
+ // declare it) all stay loud — the last two enriched with both package names.
46
62
  // 3. The ESM shape additionally requires an esm-loader frame — an ordinary
47
63
  // `fs.readFileSync` ENOENT written by hq's own code stays captured.
48
64
  import * as fs from "fs";
49
65
  import * as path from "path";
50
66
  import { packageRoot } from "./hq-roots.js";
67
+ import { packageNameOf } from "./install-tree-torn.js";
51
68
  import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
52
69
  /**
53
70
  * The actionable remedy shown to the operator. Input-free — nothing from the
@@ -72,6 +89,11 @@ const RELATIVE_SPECIFIER = /^\.\.?[\\/]/;
72
89
  const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]/;
73
90
  const ROOT_DIAGNOSTIC_BYTES = 256;
74
91
  const CODE_DIAGNOSTIC_BYTES = 32;
92
+ // Package names live inside hq-cli's own dependency graph, so their universe is
93
+ // bounded; the cap is a belt for a pathological requireStack, not a real limit.
94
+ const PACKAGE_NAME_DIAGNOSTIC_BYTES = 128;
95
+ /** The `<something>/node_modules/<something>` directory-boundary marker. */
96
+ const NODE_MODULES_SEGMENT = "/node_modules/";
75
97
  /**
76
98
  * packageRoot() walks up from the compiled module and THROWS
77
99
  * PackageRootResolutionError when it cannot resolve. This classifier runs inside
@@ -132,6 +154,114 @@ function parseMissingSpecifier(message) {
132
154
  const match = message.match(/Cannot find module ['"]([^'"]+)['"]/);
133
155
  return match ? match[1] : null;
134
156
  }
157
+ /**
158
+ * True when `specifier` is a BARE package specifier — neither a RELATIVE
159
+ * specifier (`./x`, `..\x`) nor an ABSOLUTE POSIX (`/x`) or Windows (`C:\x`,
160
+ * `\\unc`) path. Shape-based (never `process.platform`) so a reported Windows
161
+ * path classifies on a Linux CI runner, mirroring normalizeForCompare's win32
162
+ * detection.
163
+ */
164
+ function isBareSpecifier(specifier) {
165
+ if (specifier.length === 0)
166
+ return false;
167
+ if (RELATIVE_SPECIFIER.test(specifier))
168
+ return false;
169
+ if (specifier.startsWith("/"))
170
+ return false; // absolute POSIX
171
+ if (looksWin32(specifier))
172
+ return false; // absolute Windows (drive letter or UNC)
173
+ return true;
174
+ }
175
+ /**
176
+ * True when `specifier` is a bare specifier naming a PACKAGE ROOT with no
177
+ * subpath — `bl` or `@scope/name`, never `bl/lib/inner.js` or `@scope/name/sub`.
178
+ * Only a WHOLE-package miss is a proven torn install; a subpath miss can mean the
179
+ * package IS installed but that one file is absent because of a version or
180
+ * packaging mismatch — a real defect that must stay reported, not be silenced as
181
+ * a torn install.
182
+ */
183
+ function isBarePackageRoot(specifier) {
184
+ return isBareSpecifier(specifier) && specifier === packageNameOf(specifier);
185
+ }
186
+ /**
187
+ * From a file under `<root>/node_modules/…`, derive the REQUIRING package's
188
+ * directory and its package name, nesting- and scope-aware:
189
+ * `<root>/node_modules/a/node_modules/b/index.js` → dir `…/b`, name `b`
190
+ * `<root>/node_modules/@scope/pkg/index.js` → dir `…/@scope/pkg`, name `@scope/pkg`
191
+ * Keyed on the LAST `node_modules` segment so the INNERMOST (actually requiring)
192
+ * package is chosen, consuming two path segments for an `@scope/name` dir. The
193
+ * returned dir preserves the original case and is folded to `/` (Node's fs
194
+ * accepts `/` on every platform); the win32 shape only case-folds the SEARCH so
195
+ * the segment is found, never the returned value. Returns null when the path
196
+ * holds no usable package segment.
197
+ */
198
+ function requiringPackage(requiringFile) {
199
+ const folded = foldSeparators(requiringFile);
200
+ const win = looksWin32(requiringFile);
201
+ const haystack = win ? folded.toLowerCase() : folded;
202
+ const marker = win ? NODE_MODULES_SEGMENT.toLowerCase() : NODE_MODULES_SEGMENT;
203
+ const lastNm = haystack.lastIndexOf(marker);
204
+ if (lastNm === -1)
205
+ return null;
206
+ const prefixEnd = lastNm + NODE_MODULES_SEGMENT.length;
207
+ const prefix = folded.slice(0, prefixEnd); // `…/node_modules/`, original case
208
+ const rest = folded
209
+ .slice(prefixEnd)
210
+ .split("/")
211
+ .filter((segment) => segment.length > 0);
212
+ if (rest.length === 0)
213
+ return null;
214
+ const take = rest[0].startsWith("@") ? 2 : 1;
215
+ if (rest.length < take)
216
+ return null;
217
+ const name = rest.slice(0, take).join("/");
218
+ return { dir: prefix + name, name };
219
+ }
220
+ /** True when `deps` is an object carrying `packageName` as an OWN key. */
221
+ function declaresDependency(deps, packageName) {
222
+ return (typeof deps === "object" &&
223
+ deps !== null &&
224
+ Object.prototype.hasOwnProperty.call(deps, packageName));
225
+ }
226
+ /**
227
+ * Whether the third-party package that REQUIRED `requiringFile` declares
228
+ * `packageName` in its own REQUIRED `dependencies` — the decisive discriminator
229
+ * for Shape C (HQ-CLI-1Q). A correct install of a package that declares a
230
+ * REQUIRED dependency ALWAYS writes it, so its absence beside a present requirer
231
+ * can only be a torn install. `optionalDependencies` are deliberately EXCLUDED:
232
+ * they are not guaranteed installed (`npm install --omit optional`, or a
233
+ * tolerated optional-install failure), so a missing optional package can be a
234
+ * persistent, expected omission — not a torn install — and must stay captured. A
235
+ * peer-only / undeclared / hoisting assumption is likewise NOT declared here.
236
+ * Fails CLOSED: an unresolved dir, an unreadable or invalid-JSON manifest, or a
237
+ * non-object dependency map all return false. Never throws — every fs and
238
+ * JSON.parse call is individually guarded. Reached only after the cheap
239
+ * in-memory narrowings of Shape C pass, so at most one small manifest read per
240
+ * process.
241
+ */
242
+ function requirerDeclaresDependency(requiringFile, packageName, fileSystem) {
243
+ const requirer = requiringPackage(requiringFile);
244
+ if (!requirer)
245
+ return false;
246
+ let raw;
247
+ try {
248
+ raw = fileSystem.readFileSync(`${requirer.dir}/package.json`, "utf-8");
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ let manifest;
254
+ try {
255
+ manifest = JSON.parse(raw);
256
+ }
257
+ catch {
258
+ return false;
259
+ }
260
+ if (manifest === null || typeof manifest !== "object")
261
+ return false;
262
+ const record = manifest;
263
+ return declaresDependency(record.dependencies, packageName);
264
+ }
135
265
  /** True when `stack` carries a Node ESM loader frame. */
136
266
  function hasEsmLoaderFrame(stack) {
137
267
  return typeof stack === "string" && ESM_LOADER_FRAME.test(stack);
@@ -161,7 +291,7 @@ function isEsmLoaderEnoent(err) {
161
291
  * means "handle as usual (capture to Sentry)". Never throws — a resolver that
162
292
  * fails yields null.
163
293
  */
164
- export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRoot) {
294
+ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRoot, fileSystem = fs) {
165
295
  if (err === null || typeof err !== "object")
166
296
  return null;
167
297
  const record = err;
@@ -172,16 +302,36 @@ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRo
172
302
  if (!root)
173
303
  return null;
174
304
  if (code === "MODULE_NOT_FOUND") {
175
- // Shape A (CJS, HQ-CLI-1N): a RELATIVE specifier internal to a package under
176
- // the running install's node_modules can only be a truncated on-disk copy.
177
305
  const requireStack = record.requireStack;
178
306
  if (!Array.isArray(requireStack) || typeof requireStack[0] !== "string") {
179
307
  return null;
180
308
  }
309
+ const requiringFile = requireStack[0];
181
310
  const specifier = parseMissingSpecifier(record.message);
182
- if (specifier === null || !RELATIVE_SPECIFIER.test(specifier))
311
+ if (specifier === null)
312
+ return null;
313
+ // Shape A (CJS, HQ-CLI-1N) — UNCHANGED: a RELATIVE specifier internal to a
314
+ // package under the running install's node_modules can only be a truncated
315
+ // on-disk copy. Kept byte-for-byte so 1N cannot regress.
316
+ if (RELATIVE_SPECIFIER.test(specifier)) {
317
+ return isUnderNodeModules(requiringFile, root)
318
+ ? INCOMPLETE_INSTALL_REMEDY
319
+ : null;
320
+ }
321
+ // Shape C (CJS, HQ-CLI-1Q): a BARE PACKAGE-ROOT specifier missing from UNDER
322
+ // the running install's node_modules is a torn install ONLY when the
323
+ // REQUIRING third-party package's own manifest declares it in its required
324
+ // `dependencies`. A subpath specifier (`bl/lib/inner.js`) is excluded — the
325
+ // package may be present and only that file gone to a version mismatch — and
326
+ // an absolute-path specifier is neither relative nor bare, so both stay
327
+ // captured. The manifest read is the ONLY filesystem access here and is
328
+ // reached only after the cheap in-memory narrowings above — a combination
329
+ // that cannot occur on a healthy run — so a healthy event never touches disk.
330
+ if (!isBarePackageRoot(specifier))
331
+ return null;
332
+ if (!isUnderNodeModules(requiringFile, root))
183
333
  return null;
184
- return isUnderNodeModules(requireStack[0], root)
334
+ return requirerDeclaresDependency(requiringFile, specifier, fileSystem)
185
335
  ? INCOMPLETE_INSTALL_REMEDY
186
336
  : null;
187
337
  }
@@ -197,27 +347,80 @@ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRo
197
347
  ? INCOMPLETE_INSTALL_REMEDY
198
348
  : null;
199
349
  }
350
+ /** A readFileSync surface, defaulting to the real fs when the caller injects none. */
351
+ function readFileFrom(fileSystem) {
352
+ return { readFileSync: fileSystem.readFileSync ?? fs.readFileSync };
353
+ }
200
354
  /**
201
- * When an esm-loader ENOENT reaches the capture path WITHOUT being suppressed —
202
- * the exact shape the delivered HQ-CLI-1M payload arrived in, where neither the
203
- * exception value nor node_system_error carried a `path` return a bounded
204
- * `contexts.incomplete_install` block so the next occurrence carries the
205
- * evidence this one lacked; otherwise return undefined (bare capture). Built
206
- * with the byte-capped, scrubber-safe discipline of package-root-diagnostics.ts:
207
- * the resolved package root and whether its package.json / node_modules exist,
208
- * the loader-frame marker, and the bounded errno code — never a caller argv,
209
- * query, or user-minted value. Never throws.
210
- *
211
- * main.ts attaches this on the generic capture path exactly as
212
- * qmdSpawnFailureCaptureContext already does.
355
+ * When a third-party BARE-specifier CJS miss UNDER the running install's
356
+ * node_modules reached the capture path WITHOUT being suppressed (HQ-CLI-1Q)
357
+ * i.e. the requiring package did NOT declare it, so it is a possible undeclared
358
+ * or peer-only dependency defect return a bounded `incomplete_install` block
359
+ * naming both packages so the next occurrence is attributable instead of bare;
360
+ * otherwise undefined. The suppression decision is delegated to
361
+ * incompleteInstallMessage against the SAME injected filesystem, so a declared
362
+ * (torn-install) miss is never double-attributed here. Never throws.
363
+ */
364
+ function bareSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
365
+ if (err === null || typeof err !== "object")
366
+ return undefined;
367
+ const record = err;
368
+ if (record.code !== "MODULE_NOT_FOUND")
369
+ return undefined;
370
+ const requireStack = record.requireStack;
371
+ if (!Array.isArray(requireStack) || typeof requireStack[0] !== "string")
372
+ return undefined;
373
+ const requiringFile = requireStack[0];
374
+ const specifier = parseMissingSpecifier(record.message);
375
+ // Only a WHOLE-package bare miss is attributed here, matching the classifier's
376
+ // Shape C gate: a subpath miss is a version/packaging defect, not "the package
377
+ // is missing", so naming the package would misattribute it.
378
+ if (specifier === null || !isBarePackageRoot(specifier))
379
+ return undefined;
380
+ const root = resolveRootSafely(resolvePackageRoot);
381
+ if (!root || !isUnderNodeModules(requiringFile, root))
382
+ return undefined;
383
+ // Suppressed (the requirer declares it) → printed-and-skipped, never captured.
384
+ if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) !== null) {
385
+ return undefined;
386
+ }
387
+ return {
388
+ incomplete_install: {
389
+ missingPackage: boundedDiagnosticValue(specifier, PACKAGE_NAME_DIAGNOSTIC_BYTES),
390
+ requiringPackage: boundedDiagnosticValue(requiringPackage(requiringFile)?.name ?? "<unresolved>", PACKAGE_NAME_DIAGNOSTIC_BYTES),
391
+ requirerDeclaresMissing: false,
392
+ },
393
+ };
394
+ }
395
+ /**
396
+ * When an incomplete-install failure reaches the capture path WITHOUT being
397
+ * suppressed, return a bounded `contexts.incomplete_install` block so the next
398
+ * occurrence carries the evidence this one lacked; otherwise return undefined
399
+ * (bare capture). Two enriched shapes:
400
+ * - a third-party BARE-specifier miss the requirer did not declare (HQ-CLI-1Q)
401
+ * → { missingPackage, requiringPackage, requirerDeclaresMissing:false };
402
+ * - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
403
+ * shape the delivered payload arrived in, where neither the exception value
404
+ * nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
405
+ * nodeModulesExists, esmLoaderFrame, code }.
406
+ * Built with the byte-capped, scrubber-safe discipline of
407
+ * package-root-diagnostics.ts — never a caller argv, query, or user-minted
408
+ * value. Never throws. main.ts attaches this on the generic capture path
409
+ * exactly as qmdSpawnFailureCaptureContext already does.
213
410
  */
214
411
  export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePackageRoot, fileSystem = fs) {
412
+ // (A) HQ-CLI-1Q — the not-suppressed third-party bare miss, made attributable.
413
+ const bare = bareSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
414
+ if (bare)
415
+ return bare;
416
+ // (B) HQ-CLI-1M — the path-less esm-loader ENOENT, unchanged.
215
417
  if (!isEsmLoaderEnoent(err))
216
418
  return undefined;
217
419
  // Only instrument what we did NOT already confidently suppress: a path under
218
420
  // node_modules is classified and printed above, never captured.
219
- if (incompleteInstallMessage(err, resolvePackageRoot) !== null)
421
+ if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) !== null) {
220
422
  return undefined;
423
+ }
221
424
  const record = err;
222
425
  const root = resolveRootSafely(resolvePackageRoot);
223
426
  const code = typeof record.code === "string" ? record.code : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.109.5",
3
+ "version": "5.109.6",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {