@indigoai-us/hq-cli 5.109.10 → 5.109.12

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,45 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.109.12] — 2026-09-12
6
+
7
+ ### Fixed
8
+
9
+ - A background reinstall no longer turns into a crash report in two more shapes
10
+ it was still slipping through (HQ-CLI-1S, HQ-CLI-1T). When another program
11
+ reinstalls hq globally while a command is starting, it renames hq's files aside
12
+ and rewrites them file by file over a few seconds. Two failure shapes from that
13
+ window were still filed as crashes instead of being recovered: (1) a file that
14
+ Node checked a moment earlier vanished between its two resolve steps, surfacing
15
+ as a low-level `ENOENT … lstat` from inside the module resolver (HQ-CLI-1S);
16
+ and (2) a dependency file that was found but only half-written, so loading it
17
+ returned an empty module and its own code threw `TypeError: … is not a
18
+ function` (HQ-CLI-1T). Both are now recognized as "your install was mid-update,
19
+ not an hq bug": the command waits for the reinstall to settle and re-runs once
20
+ on the healed tree instead of reporting an error. The half-written-file case
21
+ only ever waits when a reinstall is actually in progress — a genuine, lasting
22
+ defect in a third-party dependency still reports exactly once and never pays a
23
+ wait, and a fault in hq's own shipped files is still reported. (The two related
24
+ shapes HQ-CLI-1V and HQ-CLI-1W are already covered by 5.109.8 and #561.)
25
+
26
+ ## [5.109.11] — 2026-09-12
27
+
28
+ ### Fixed
29
+
30
+ - A torn-install recovery no longer gives up after 90 seconds while another hq
31
+ updater is still legitimately installing hq-cli. When a command starts just as
32
+ the desktop app (or the box's `hq-cli-update` timer, or another `hq`) is
33
+ rewriting the install, the recovery waits for that writer to finish — bounded by
34
+ the shared install lock's own 10-minute staleness window rather than the 90-second
35
+ settle deadline — and then re-runs once on the healed tree, so the common case
36
+ (an install that finishes within the window) no longer fails at all. A short
37
+ second line explains the pause while it waits. If a writer holds the lock past
38
+ that ceiling, the command still fails once, but with a "let the update finish,
39
+ then re-run — do not reinstall while it is running" remedy and a distinct
40
+ `lock-held` outcome, instead of advice to reinstall over a live installer
41
+ (Sentry HQ-CLI-1Y, HQ-CLI-23). The ceiling is overridable with
42
+ `HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS`.
43
+
5
44
  ## [5.109.10] — 2026-09-12
6
45
 
7
46
  ### Fixed
@@ -16,7 +16,7 @@
16
16
  * style of handleTopLevelError so the whole seam is unit-testable without real
17
17
  * spawns, waits, or a real install tree.
18
18
  */
19
- import { type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
19
+ import { type InstallRewriteSignalArgs, type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
20
20
  /**
21
21
  * Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
22
22
  * from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
@@ -35,6 +35,24 @@ export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
35
35
  export declare const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish\u2026";
36
36
  /** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
37
37
  export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
38
+ /** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
39
+ export declare const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
40
+ /**
41
+ * Default ceiling (ms) on the time the settle wait spends under a fresh foreign
42
+ * lock held by a live cooperating writer — the update-lock contract's own
43
+ * staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
44
+ */
45
+ export declare const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS: number;
46
+ /**
47
+ * The single dim line emitted once when the wait continues PAST the settle
48
+ * deadline because a live updater still holds the shared install lock — so a human
49
+ * knows the pause is a running update, not a hang. Never a remedy, never an exit
50
+ * code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
51
+ * synchronize on it.
52
+ */
53
+ export declare const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish\u2026";
54
+ /** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
55
+ export declare function resolveSettleLockTimeoutMs(env: NodeJS.ProcessEnv): number;
38
56
  /** Minimal view of a `spawnSync` result the seam relies on. */
39
57
  export interface RecoverySpawnResult {
40
58
  status: number | null;
@@ -52,6 +70,8 @@ export interface RegisterRecoveryDeps {
52
70
  deriveInstallRoot?: () => string | null;
53
71
  lockPath?: () => string;
54
72
  waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
73
+ /** Positive writer-signal probe for the eval-throw gate (defaults to installRewriteInProgress). */
74
+ rewriteInProgress?: (args: InstallRewriteSignalArgs) => boolean;
55
75
  /** node flags to forward to the re-exec child (defaults to process.execArgv). */
56
76
  execArgv?: readonly string[];
57
77
  }
@@ -19,8 +19,8 @@
19
19
  import { spawnSync } from "node:child_process";
20
20
  import { resolveRunningInstall } from "./utils/version-gate.js";
21
21
  import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
22
- import { updateLockPath } from "./utils/update-lock.js";
23
- import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
22
+ import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.js";
23
+ import { classifyEvalThrow, classifyModuleNotFound, installRewriteInProgress, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
24
24
  /**
25
25
  * Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
26
26
  * from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
@@ -46,6 +46,31 @@ export function resolveSettleTimeoutMs(env) {
46
46
  return DEFAULT_INSTALL_SETTLE_TIMEOUT_MS;
47
47
  return Number.parseInt(raw.trim(), 10);
48
48
  }
49
+ /** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
50
+ export const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
51
+ /**
52
+ * Default ceiling (ms) on the time the settle wait spends under a fresh foreign
53
+ * lock held by a live cooperating writer — the update-lock contract's own
54
+ * staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
55
+ */
56
+ export const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS = UPDATE_LOCK_STALE_MS;
57
+ /**
58
+ * The single dim line emitted once when the wait continues PAST the settle
59
+ * deadline because a live updater still holds the shared install lock — so a human
60
+ * knows the pause is a running update, not a hang. Never a remedy, never an exit
61
+ * code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
62
+ * synchronize on it.
63
+ */
64
+ export const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish…";
65
+ /** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
66
+ export function resolveSettleLockTimeoutMs(env) {
67
+ const raw = env[INSTALL_SETTLE_LOCK_TIMEOUT_ENV];
68
+ if (typeof raw !== "string" || raw.trim() === "")
69
+ return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
70
+ if (!/^\d+$/.test(raw.trim()))
71
+ return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
72
+ return Number.parseInt(raw.trim(), 10);
73
+ }
49
74
  /**
50
75
  * Register commands, recovering once from a torn-install module-resolution
51
76
  * failure. On a clean registration this returns `{}` and touches nothing else.
@@ -62,12 +87,32 @@ export async function registerCommandsWithRecovery(args) {
62
87
  return {};
63
88
  }
64
89
  catch (err) {
65
- const classified = classifyModuleNotFound(err);
66
- // Not a module-resolution failure rethrow the SAME object to the existing
67
- // boundary, preserving today's behaviour exactly.
90
+ // The packageRoot bounds the eval-throw classifier's throw site and feeds the
91
+ // settle wait's manifest-health / retired-sibling guards; resolved once here.
92
+ const packageRoot = resolvePackageRoot(deps);
93
+ // Classify the loader failure. classifyModuleNotFound covers the resolve/load
94
+ // dialects (incl. HQ-CLI-1S resolve-enoent); classifyEvalThrow covers a
95
+ // module-EVALUATION throw (HQ-CLI-1T). Anything unclassified is rethrown as the
96
+ // SAME object to the existing boundary, preserving today's behaviour exactly.
97
+ const classified = classifyModuleNotFound(err) ?? classifyEvalThrow(err, packageRoot);
68
98
  if (!classified)
69
99
  throw err;
70
- const packageRoot = resolvePackageRoot(deps);
100
+ const lockPath = (deps.lockPath ?? updateLockPath)();
101
+ const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
102
+ // Writer-signal gate for the eval-throw dialect ONLY. A module-evaluation
103
+ // throw is indistinguishable from a persistent third-party load-time defect
104
+ // unless a global reinstall is provably in progress, so require a POSITIVE
105
+ // writer signal — a fresh foreign lock, an absent/name-mismatched manifest, or
106
+ // a retired `.hq-cli-<hash>` sibling — before entering recovery. With no such
107
+ // signal the SAME error is rethrown raw (today's immediate capture) and never
108
+ // pays a settle wait; a benign fs error (an unreadable manifest or unlistable
109
+ // parent) is deliberately NOT read as a signal, so a real defect never waits.
110
+ // The loader-race dialects need no such gate. Injected for a hermetic seam.
111
+ if (classified.dialect === "eval-throw") {
112
+ const rewriteInProgress = deps.rewriteInProgress ?? installRewriteInProgress;
113
+ if (!rewriteInProgress({ packageRoot, lockPath }))
114
+ throw err;
115
+ }
71
116
  // A child that already recovered must never wait or re-exec again: report
72
117
  // once and let the boundary capture it.
73
118
  if (env[INSTALL_TREE_RECOVERY_GUARD_ENV] === "1") {
@@ -83,24 +128,32 @@ export async function registerCommandsWithRecovery(args) {
83
128
  });
84
129
  }
85
130
  stderr.write(`${INSTALL_TREE_WAIT_NOTICE}\n`);
86
- const lockPath = (deps.lockPath ?? updateLockPath)();
87
- const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
88
131
  const result = await waitFor({
89
132
  target: classified.target,
90
133
  packageRoot,
91
134
  lockPath,
92
135
  deadlineMs: resolveSettleTimeoutMs(env),
136
+ lockWaitMs: resolveSettleLockTimeoutMs(env),
137
+ onLockExtended: () => stderr.write(`${INSTALL_TREE_LOCK_WAIT_NOTICE}\n`),
93
138
  });
94
139
  if (!result.settled) {
95
140
  throw new InstallTreeTornError({
96
141
  cause: err,
97
142
  classified,
98
143
  packageRoot,
99
- outcome: "unsettled",
144
+ // A fresh foreign lock still held at the end means a live cooperating
145
+ // writer held the shared lock past its own ceiling — report 'lock-held'
146
+ // (its remedy tells the user to let the update finish, never to reinstall
147
+ // over it). Otherwise the tree genuinely never settled with no writer
148
+ // visible, which keeps meaning 'unsettled'.
149
+ outcome: result.lockHeldAtEnd ? "lock-held" : "unsettled",
100
150
  attempt: 1,
101
151
  waitedMs: result.waitedMs,
102
152
  sawLock: result.sawLock,
103
153
  sawRetired: result.sawRetired,
154
+ lockHeldAtEnd: result.lockHeldAtEnd,
155
+ lockedMs: result.lockedMs,
156
+ lockTool: result.lockTool,
104
157
  });
105
158
  }
106
159
  const spawn = deps.spawn ?? spawnSync;
@@ -13,10 +13,11 @@ export type PackageRootResolver = () => string | null;
13
13
  /** Which strategy produced the running install's root, recorded in diagnostics. */
14
14
  export type PackageRootResolverSource = "manifest-walk" | "string-derivation" | "unresolved" | "injected";
15
15
  /**
16
- * If `err` is an in-process incomplete-install module-load failure — either the
17
- * CJS relative-sibling shape (HQ-CLI-1N) or the ESM vanished-file shape
18
- * (HQ-CLI-1M), with the failing file confirmed under `<packageRoot>/node_modules/`
19
- * — return the actionable, input-free reinstall remedy; otherwise return null.
16
+ * If `err` is an in-process incomplete-install module-load failure — the CJS
17
+ * relative-sibling shape (HQ-CLI-1N), the ESM vanished-at-read shape (HQ-CLI-1M),
18
+ * or the resolve-time lstat shape (HQ-CLI-1S), with the failing file confirmed
19
+ * under `<packageRoot>/node_modules/` — return the actionable, input-free reinstall
20
+ * remedy; otherwise return null.
20
21
  *
21
22
  * Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
22
23
  * the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
@@ -24,7 +25,11 @@ export type PackageRootResolverSource = "manifest-walk" | "string-derivation" |
24
25
  * fails yields null.
25
26
  */
26
27
  export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "readFileSync">): string | null;
27
- /** Bounded, scrubber-safe diagnostics for an unattributable esm-loader ENOENT (HQ-CLI-1M). */
28
+ /**
29
+ * Bounded, scrubber-safe diagnostics for an unattributable loader/resolver ENOENT
30
+ * whose `path` did not survive delivery — the esm-loader open shape (HQ-CLI-1M) or
31
+ * the resolve-time lstat shape (HQ-CLI-1S). The two frame booleans say which.
32
+ */
28
33
  export type IncompleteInstallEsmDiagnostics = {
29
34
  /** The resolved install root, or the literal `<unresolved>` when none was found. */
30
35
  packageRoot: string;
@@ -41,9 +46,28 @@ export type IncompleteInstallEsmDiagnostics = {
41
46
  */
42
47
  packageJsonExists?: boolean;
43
48
  nodeModulesExists?: boolean;
49
+ /** The stack carried an esm-loader (source-read) frame — the HQ-CLI-1M route. */
44
50
  esmLoaderFrame: boolean;
51
+ /** The stack carried a module-resolver frame — the HQ-CLI-1S resolve-time route. */
52
+ resolverFrame: boolean;
45
53
  code: string;
46
54
  };
55
+ /**
56
+ * Bounded, scrubber-safe diagnostics for a module-EVALUATION throw that reached
57
+ * the boundary raw (HQ-CLI-1T) — a code-less TypeError/ReferenceError/SyntaxError
58
+ * whose throw site sits under the running install's node_modules, so the next
59
+ * occurrence names the offending third-party package instead of filing bare. Only
60
+ * the closed error name and the bounded package name are recorded; never the
61
+ * message, argv, or a full path.
62
+ */
63
+ export type IncompleteInstallEvalThrowDiagnostics = {
64
+ evalThrow: true;
65
+ errorName: string;
66
+ throwSitePackage: string;
67
+ packageRoot: string;
68
+ packageRootResolved: boolean;
69
+ resolver: PackageRootResolverSource;
70
+ };
47
71
  /**
48
72
  * Bounded, scrubber-safe diagnostics for a NOT-suppressed third-party bare miss
49
73
  * (HQ-CLI-1Q): a possible undeclared / peer-only dependency defect, made
@@ -82,7 +106,7 @@ export type IncompleteInstallRelativeDiagnostics = {
82
106
  * exactly one of the strict shapes above; the looseness is only at the read
83
107
  * boundary.
84
108
  */
85
- export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics>;
109
+ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics & IncompleteInstallEvalThrowDiagnostics>;
86
110
  /**
87
111
  * When an incomplete-install failure reaches the capture path WITHOUT being
88
112
  * suppressed, return a bounded `contexts.incomplete_install` block so the next
@@ -94,10 +118,13 @@ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnosti
94
118
  * dist/ or assets/, outside the install, or under an unresolved root
95
119
  * (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
96
120
  * packageRootResolved, resolver, requirerScope };
97
- * - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
98
- * shape the delivered payload arrived in, where neither the exception value
99
- * nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
100
- * nodeModulesExists, esmLoaderFrame, code }.
121
+ * - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
122
+ * esm-load open, HQ-CLI-1S resolve-time lstat) where neither the exception
123
+ * value nor node_system_error carried a `path` → { packageRoot,
124
+ * packageJsonExists, nodeModulesExists, esmLoaderFrame, resolverFrame, code };
125
+ * - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
126
+ * { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
127
+ * resolver }.
101
128
  * Built with the byte-capped, scrubber-safe discipline of
102
129
  * package-root-diagnostics.ts — never a caller argv, query, or user-minted
103
130
  * value. Never throws. main.ts attaches this on the generic capture path
@@ -64,7 +64,7 @@
64
64
  import * as fs from "fs";
65
65
  import * as path from "path";
66
66
  import { packageRoot, stringDerivedPackageRoot } from "./hq-roots.js";
67
- import { packageNameOf } from "./install-tree-torn.js";
67
+ import { evalThrowShape, packageNameOf } from "./install-tree-torn.js";
68
68
  import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
69
69
  /**
70
70
  * The actionable remedy shown to the operator. Input-free — nothing from the
@@ -95,6 +95,16 @@ const RELATIVE_SPECIFIER = /^\.\.?[\\/]/;
95
95
  * `esm/loader` module.
96
96
  */
97
97
  const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
98
+ /**
99
+ * The Node module RESOLVER frame — `finalizeResolution` in
100
+ * `node:internal/modules/esm/resolve`, or the CJS `Module._findPath → toRealPath`
101
+ * route in `node:internal/modules/cjs/loader`. Alongside `syscall === 'lstat'`
102
+ * this proves a resolve-time ENOENT (HQ-CLI-1S) — a path component that vanished
103
+ * between the resolver's stat and its realpathSync lstat — so an ordinary
104
+ * `fs.lstatSync` ENOENT from hq's own code stays captured. Mirrors the frame
105
+ * matcher in install-tree-torn.ts (duplicated exactly as ESM_LOADER_FRAME is).
106
+ */
107
+ const RESOLVER_FRAME = /node:internal[\\/]modules[\\/](?:esm[\\/]resolve|cjs[\\/]loader)\b/;
98
108
  const ROOT_DIAGNOSTIC_BYTES = 256;
99
109
  const CODE_DIAGNOSTIC_BYTES = 32;
100
110
  // Package names live inside hq-cli's own dependency graph, so their universe is
@@ -306,6 +316,10 @@ function requirerDeclaresDependency(requiringFile, packageName, fileSystem) {
306
316
  function hasEsmLoaderFrame(stack) {
307
317
  return typeof stack === "string" && ESM_LOADER_FRAME.test(stack);
308
318
  }
319
+ /** True when `stack` carries a Node module RESOLVER frame. */
320
+ function hasResolverFrame(stack) {
321
+ return typeof stack === "string" && RESOLVER_FRAME.test(stack);
322
+ }
309
323
  /**
310
324
  * The ESM-loader ENOENT SIGNATURE, independent of whether a usable `path`
311
325
  * survived: `code === 'ENOENT'`, `syscall === 'open'`, and an esm-loader frame
@@ -321,10 +335,25 @@ function isEsmLoaderEnoent(err) {
321
335
  hasEsmLoaderFrame(record.stack));
322
336
  }
323
337
  /**
324
- * If `err` is an in-process incomplete-install module-load failure — either the
325
- * CJS relative-sibling shape (HQ-CLI-1N) or the ESM vanished-file shape
326
- * (HQ-CLI-1M), with the failing file confirmed under `<packageRoot>/node_modules/`
327
- * return the actionable, input-free reinstall remedy; otherwise return null.
338
+ * The resolve-time ENOENT SIGNATURE (HQ-CLI-1S): `code === 'ENOENT'`,
339
+ * `syscall === 'lstat'`, and a resolver frame independent of whether a usable
340
+ * `path` survived delivery. Sibling of {@link isEsmLoaderEnoent}; the message
341
+ * classifier additionally requires a `path` under node_modules.
342
+ */
343
+ function isResolveEnoent(err) {
344
+ if (err === null || typeof err !== "object")
345
+ return false;
346
+ const record = err;
347
+ return (record.code === "ENOENT" &&
348
+ record.syscall === "lstat" &&
349
+ hasResolverFrame(record.stack));
350
+ }
351
+ /**
352
+ * If `err` is an in-process incomplete-install module-load failure — the CJS
353
+ * relative-sibling shape (HQ-CLI-1N), the ESM vanished-at-read shape (HQ-CLI-1M),
354
+ * or the resolve-time lstat shape (HQ-CLI-1S), with the failing file confirmed
355
+ * under `<packageRoot>/node_modules/` — return the actionable, input-free reinstall
356
+ * remedy; otherwise return null.
328
357
  *
329
358
  * Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
330
359
  * the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
@@ -375,17 +404,24 @@ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRo
375
404
  ? INCOMPLETE_INSTALL_REMEDY
376
405
  : null;
377
406
  }
378
- // Shape B (ESM load, HQ-CLI-1M): an ENOENT from the module loader for a file
379
- // that was present at resolve and gone at read.
380
- if (record.syscall !== "open")
381
- return null;
382
- if (typeof record.path !== "string")
383
- return null;
384
- if (!hasEsmLoaderFrame(record.stack))
385
- return null;
386
- return isUnderNodeModules(record.path, root)
387
- ? INCOMPLETE_INSTALL_REMEDY
388
- : null;
407
+ // Shape B (ESM load, HQ-CLI-1M): an ENOENT from the module LOADER for a file
408
+ // present at resolve and gone at read (syscall 'open' + an esm-loader frame).
409
+ if (record.syscall === "open" &&
410
+ typeof record.path === "string" &&
411
+ hasEsmLoaderFrame(record.stack)) {
412
+ return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
413
+ }
414
+ // Shape B' (RESOLVE time, HQ-CLI-1S): an ENOENT from the resolver's realpath
415
+ // step (syscall 'lstat' + a resolver frame) — a path component that a concurrent
416
+ // reinstall renamed away between the resolver's stat and its lstat. Same
417
+ // disposition as B: a vanished component under the running install's
418
+ // node_modules is a torn install, so print the remedy and skip capture.
419
+ if (record.syscall === "lstat" &&
420
+ typeof record.path === "string" &&
421
+ hasResolverFrame(record.stack)) {
422
+ return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
423
+ }
424
+ return null;
389
425
  }
390
426
  /** A readFileSync surface, defaulting to the real fs when the caller injects none. */
391
427
  function readFileFrom(fileSystem) {
@@ -513,10 +549,13 @@ function relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
513
549
  * dist/ or assets/, outside the install, or under an unresolved root
514
550
  * (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
515
551
  * packageRootResolved, resolver, requirerScope };
516
- * - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
517
- * shape the delivered payload arrived in, where neither the exception value
518
- * nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
519
- * nodeModulesExists, esmLoaderFrame, code }.
552
+ * - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
553
+ * esm-load open, HQ-CLI-1S resolve-time lstat) where neither the exception
554
+ * value nor node_system_error carried a `path` → { packageRoot,
555
+ * packageJsonExists, nodeModulesExists, esmLoaderFrame, resolverFrame, code };
556
+ * - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
557
+ * { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
558
+ * resolver }.
520
559
  * Built with the byte-capped, scrubber-safe discipline of
521
560
  * package-root-diagnostics.ts — never a caller argv, query, or user-minted
522
561
  * value. Never throws. main.ts attaches this on the generic capture path
@@ -531,19 +570,63 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
531
570
  const relative = relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
532
571
  if (relative)
533
572
  return relative;
534
- // (C) HQ-CLI-1M — the path-less esm-loader ENOENT, unchanged.
535
- if (!isEsmLoaderEnoent(err))
536
- return undefined;
537
- // Only instrument what we did NOT already confidently suppress: a path under
538
- // node_modules is classified and printed above, never captured.
539
- if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) !== null) {
540
- return undefined;
573
+ // (C) HQ-CLI-1M / HQ-CLI-1S — the path-less loader/resolver ENOENT, made
574
+ // attributable. An ENOENT whose `path` survived and sits under node_modules is
575
+ // classified and printed above, never captured; only the unattributable shape
576
+ // (no usable path, or a path outside node_modules) reaches here.
577
+ if (isEsmLoaderEnoent(err) || isResolveEnoent(err)) {
578
+ if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) === null) {
579
+ const record = err;
580
+ const code = typeof record.code === "string" ? record.code : "";
581
+ // The default resolver knows which strategy answered (manifest walk vs the
582
+ // string derivation that survives a torn tree); an injected resolver is
583
+ // opaque, so it is recorded as "injected" and its return used as the root.
584
+ const resolution = resolvePackageRoot === safePackageRoot
585
+ ? resolvePackageRootWithSource()
586
+ : {
587
+ root: resolveRootSafely(resolvePackageRoot),
588
+ source: "injected",
589
+ };
590
+ const root = resolution.root;
591
+ const diagnostics = {
592
+ packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
593
+ packageRootResolved: root !== null,
594
+ resolver: resolution.source,
595
+ // Which route raised it — the esm-loader (source-read) frame for 1M, the
596
+ // resolver frame for 1S; either or both are recorded from the stack.
597
+ esmLoaderFrame: hasEsmLoaderFrame(record.stack),
598
+ resolverFrame: hasResolverFrame(record.stack),
599
+ code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
600
+ };
601
+ // Report the existence booleans ONLY when a root was resolved. Reported for
602
+ // an unresolved root they were uninitialised `false`s indistinguishable from
603
+ // "resolved but missing" — the instrumentation defect the prior fix shipped.
604
+ if (root !== null) {
605
+ diagnostics.packageJsonExists = safeExistsSync(fileSystem, path.join(root, "package.json"));
606
+ diagnostics.nodeModulesExists = safeExistsSync(fileSystem, path.join(root, "node_modules"));
607
+ }
608
+ return { incomplete_install: diagnostics };
609
+ }
541
610
  }
542
- const record = err;
543
- const code = typeof record.code === "string" ? record.code : "";
544
- // The default resolver knows which strategy answered (manifest walk vs the
545
- // string derivation that survives a torn tree); an injected resolver is
546
- // opaque, so it is recorded as "injected" and its return used as the root.
611
+ // (D) HQ-CLI-1T — a module-EVALUATION throw that reached the boundary raw (the
612
+ // writer-signal gate found no reinstall in progress), made attributable.
613
+ return evalThrowCaptureContext(err, resolvePackageRoot);
614
+ }
615
+ /**
616
+ * When a module-EVALUATION throw (HQ-CLI-1T) reached the capture path raw — a
617
+ * code-less TypeError/ReferenceError/SyntaxError whose top-level code ran while a
618
+ * half-written dependency file returned an empty module — return a bounded
619
+ * `incomplete_install` block naming the offending third-party package so the next
620
+ * occurrence is attributable instead of bare; otherwise undefined. When the root
621
+ * resolves, the throw site MUST sit under its node_modules (an hq-cli `dist/` or
622
+ * `assets/` eval throw is hq's OWN defect and stays a bare capture); when the root
623
+ * is unresolved the block is still attached with `packageRootResolved:false`.
624
+ * Never throws; never emits the message, argv, or a full path.
625
+ */
626
+ function evalThrowCaptureContext(err, resolvePackageRoot) {
627
+ const shape = evalThrowShape(err);
628
+ if (!shape)
629
+ return undefined;
547
630
  const resolution = resolvePackageRoot === safePackageRoot
548
631
  ? resolvePackageRootWithSource()
549
632
  : {
@@ -551,21 +634,18 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
551
634
  source: "injected",
552
635
  };
553
636
  const root = resolution.root;
554
- const diagnostics = {
555
- packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
556
- packageRootResolved: root !== null,
557
- resolver: resolution.source,
558
- esmLoaderFrame: true,
559
- code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
637
+ if (root !== null && !isUnderNodeModules(shape.throwSite, root))
638
+ return undefined;
639
+ return {
640
+ incomplete_install: {
641
+ evalThrow: true,
642
+ errorName: boundedDiagnosticValue(shape.errorName, CODE_DIAGNOSTIC_BYTES),
643
+ throwSitePackage: boundedDiagnosticValue(requiringPackage(shape.throwSite)?.name ?? "<unresolved>", PACKAGE_NAME_DIAGNOSTIC_BYTES),
644
+ packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
645
+ packageRootResolved: root !== null,
646
+ resolver: resolution.source,
647
+ },
560
648
  };
561
- // Report the existence booleans ONLY when a root was resolved. Reported for an
562
- // unresolved root they were uninitialised `false`s indistinguishable from
563
- // "resolved but missing" — the instrumentation defect the prior fix shipped.
564
- if (root !== null) {
565
- diagnostics.packageJsonExists = safeExistsSync(fileSystem, path.join(root, "package.json"));
566
- diagnostics.nodeModulesExists = safeExistsSync(fileSystem, path.join(root, "node_modules"));
567
- }
568
- return { incomplete_install: diagnostics };
569
649
  }
570
650
  /** existsSync that never throws — any filesystem error reads as "absent". */
571
651
  function safeExistsSync(fileSystem, target) {