@indigoai-us/hq-cli 5.163.0 → 5.165.0

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,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.165.0] — 2026-09-24
6
+
7
+ ### Fixed
8
+
9
+ - Codex lane starts and resumes now pass the assigned worker policy directory to
10
+ policy hooks.
11
+
12
+ ## [5.164.0] — 2026-09-24
13
+
14
+ ### Fixed
15
+
16
+ - When a startup import fails, hq-cli checks for an update lock, a retired package
17
+ directory, or install-tree changes for 1.5 seconds. If none appears, it exits
18
+ with `npm install -g @indigoai-us/hq-cli@latest` instead of waiting 90 seconds.
19
+ Sentry context replaces the home-directory prefix with `~`.
20
+ - Sentry keeps the `guarded`, `unsettled`, `reexec-failed`, and `lock-held`
21
+ outcomes, adds `stale_tree` and `tree_state`, and tags `updater_source` when
22
+ known. The fingerprint remains `["InstallTreeTornError", "outcome:<token>"]`,
23
+ so the four existing outcome groups keep their keys.
24
+
5
25
  ## [5.163.0] — 2026-09-24
6
26
 
7
27
  - Workforce plan refusals in the CLI keep direct checkout for eligible companies.
@@ -222,6 +222,8 @@ export declare function buildProviderCommand(provider: LaneProvider, input: {
222
222
  fastMode?: boolean;
223
223
  /** Per-spawn worker profile instructions and materialized settings. */
224
224
  profile?: ProviderProfileLaunch;
225
+ /** Worker profile path, forwarded to Codex hook subprocesses when supported. */
226
+ workerPolicyDir?: string;
225
227
  /** Continue a recorded session. Only providers in PROVIDER_SESSION_RESUME accept this. */
226
228
  resume?: ProviderResumeInput;
227
229
  /**
@@ -522,6 +522,19 @@ function appendProviderProfileFlags(provider, args, profile) {
522
522
  }
523
523
  }
524
524
  }
525
+ /** Make the worker policy directory available to Codex hook subprocesses. */
526
+ function appendCodexWorkerPolicyDirFlag(provider, args, workerPolicyDir) {
527
+ if (provider !== "codex" || workerPolicyDir === undefined)
528
+ return args;
529
+ if (!workerPolicyDir.trim()) {
530
+ throw new LanesError("invalid_worker_policy_dir", "Worker policy directory must not be empty");
531
+ }
532
+ const setting = `shell_environment_policy.set.HQ_POLICY_WORKER_DIR=${JSON.stringify(workerPolicyDir)}`;
533
+ const separator = args.indexOf("--");
534
+ if (separator === -1)
535
+ return [...args, "-c", setting];
536
+ return [...args.slice(0, separator), "-c", setting, ...args.slice(separator)];
537
+ }
525
538
  export const PID_LIVENESS_DECISIONS = [
526
539
  "esrch",
527
540
  "starttime_mismatch",
@@ -1138,7 +1151,7 @@ export function buildProviderCommand(provider, input) {
1138
1151
  }
1139
1152
  return {
1140
1153
  command,
1141
- args: appendFastModeFlags(parsed, appendEffortFlag(parsed, appendModelFlag(parsed, appendProviderProfileFlags(parsed, args, input.profile), input.model), input.effort), input.fastMode),
1154
+ args: appendFastModeFlags(parsed, appendEffortFlag(parsed, appendModelFlag(parsed, appendCodexWorkerPolicyDirFlag(parsed, appendProviderProfileFlags(parsed, args, input.profile), input.workerPolicyDir), input.model), input.effort), input.fastMode),
1142
1155
  };
1143
1156
  }
1144
1157
  export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, meshCompany, workerPolicyDir) {
@@ -1757,6 +1770,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1757
1770
  effort: effort.effort,
1758
1771
  fastMode,
1759
1772
  profile: profileLaunch,
1773
+ workerPolicyDir,
1760
1774
  resume,
1761
1775
  });
1762
1776
  // A systemd PATH mismatch is an environment defect, not a lane failure.
@@ -1806,6 +1820,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1806
1820
  effort: effort.effort,
1807
1821
  fastMode,
1808
1822
  profile: profileLaunch,
1823
+ workerPolicyDir,
1809
1824
  resume: { session_id: resume.session_id, prompt: COMPACTION_PROMPT },
1810
1825
  });
1811
1826
  compaction = compactLaneSession({
@@ -2128,6 +2143,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2128
2143
  effort: effort.effort,
2129
2144
  fastMode,
2130
2145
  profile: profileLaunch,
2146
+ workerPolicyDir,
2131
2147
  ...(mode === "resume" && sessionId !== null
2132
2148
  ? { resume: { session_id: sessionId, prompt: repairPrompt } }
2133
2149
  : { freshTurnPrompt: repairPrompt }),
package/dist/sentry.js CHANGED
@@ -7,7 +7,7 @@ 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
9
  import { incompleteInstallCaptureContext, incompleteInstallMessage, } from "./utils/incomplete-install-error.js";
10
- import { sentryFingerprintFor, sentryMcpCollisionContextFor, } from "./utils/sentry-fingerprint.js";
10
+ import { installTreeTornSentryTagsFor, sentryFingerprintFor, sentryMcpCollisionContextFor, } from "./utils/sentry-fingerprint.js";
11
11
  /**
12
12
  * Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
13
13
  * reader (`hq … | head`, `source <(hq …)`, a parent that exited) is normal
@@ -84,6 +84,10 @@ export function epipeAwareBeforeSend(event, hint) {
84
84
  if (fingerprint)
85
85
  event.fingerprint = fingerprint;
86
86
  }
87
+ const installTreeTags = installTreeTornSentryTagsFor(hint?.originalException);
88
+ if (installTreeTags) {
89
+ event.tags = { ...event.tags, ...installTreeTags };
90
+ }
87
91
  return beforeSend(event, hint);
88
92
  }
89
93
  /**
@@ -23,6 +23,8 @@ import { type InstallRewriteSignalArgs, type WaitForInstallTreeSettledArgs, type
23
23
  * shadow each other.
24
24
  */
25
25
  export declare const INSTALL_TREE_RECOVERY_GUARD_ENV = "HQ_INSTALL_TREE_RECOVERED";
26
+ /** Carries the writer-state observed by the parent into the guarded re-exec. */
27
+ export declare const INSTALL_TREE_RECOVERY_TREE_STATE_ENV = "HQ_INSTALL_TREE_RECOVERY_TREE_STATE";
26
28
  /** Operator override for the settle wait's deadline. */
27
29
  export declare const INSTALL_SETTLE_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_TIMEOUT_MS";
28
30
  /** Default settle deadline (ms). Overridable by {@link INSTALL_SETTLE_TIMEOUT_ENV}. */
@@ -32,7 +34,7 @@ export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
32
34
  * actually happens. It tells a human why the command paused and is the e2e's
33
35
  * synchronization point; it is never a remedy and never sets an error exit code.
34
36
  */
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";
37
+ export declare const INSTALL_TREE_WAIT_NOTICE = "hq: checking whether the hq-cli install tree is still changing before retrying\u2026";
36
38
  /** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
37
39
  export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
38
40
  /** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
@@ -27,6 +27,8 @@ import { classifyEvalThrow, classifyModuleNotFound, installRewriteInProgress, In
27
27
  * shadow each other.
28
28
  */
29
29
  export const INSTALL_TREE_RECOVERY_GUARD_ENV = "HQ_INSTALL_TREE_RECOVERED";
30
+ /** Carries the writer-state observed by the parent into the guarded re-exec. */
31
+ export const INSTALL_TREE_RECOVERY_TREE_STATE_ENV = "HQ_INSTALL_TREE_RECOVERY_TREE_STATE";
30
32
  /** Operator override for the settle wait's deadline. */
31
33
  export const INSTALL_SETTLE_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_TIMEOUT_MS";
32
34
  /** Default settle deadline (ms). Overridable by {@link INSTALL_SETTLE_TIMEOUT_ENV}. */
@@ -36,7 +38,7 @@ export const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90_000;
36
38
  * actually happens. It tells a human why the command paused and is the e2e's
37
39
  * synchronization point; it is never a remedy and never sets an error exit code.
38
40
  */
39
- export const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish…";
41
+ export const INSTALL_TREE_WAIT_NOTICE = "hq: checking whether the hq-cli install tree is still changing before retrying…";
40
42
  /** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
41
43
  export function resolveSettleTimeoutMs(env) {
42
44
  const raw = env[INSTALL_SETTLE_TIMEOUT_ENV];
@@ -121,10 +123,12 @@ export async function registerCommandsWithRecovery(args) {
121
123
  classified,
122
124
  packageRoot,
123
125
  outcome: "guarded",
126
+ treeState: env[INSTALL_TREE_RECOVERY_TREE_STATE_ENV] === "stale" ? "stale" : "in_flight",
124
127
  attempt: 2,
125
128
  waitedMs: 0,
126
129
  sawLock: false,
127
130
  sawRetired: false,
131
+ sawTreeChange: false,
128
132
  });
129
133
  }
130
134
  stderr.write(`${INSTALL_TREE_WAIT_NOTICE}\n`);
@@ -137,6 +141,7 @@ export async function registerCommandsWithRecovery(args) {
137
141
  onLockExtended: () => stderr.write(`${INSTALL_TREE_LOCK_WAIT_NOTICE}\n`),
138
142
  });
139
143
  if (!result.settled) {
144
+ const treeState = result.staleTree === true ? "stale" : "in_flight";
140
145
  throw new InstallTreeTornError({
141
146
  cause: err,
142
147
  classified,
@@ -145,12 +150,15 @@ export async function registerCommandsWithRecovery(args) {
145
150
  // writer held the shared lock past its own ceiling — report 'lock-held'
146
151
  // (its remedy tells the user to let the update finish, never to reinstall
147
152
  // over it). Otherwise the tree genuinely never settled with no writer
148
- // visible, which keeps meaning 'unsettled'.
149
- outcome: result.lockHeldAtEnd ? "lock-held" : "unsettled",
153
+ // visible. The settle probe only uses `stale_tree` when it could observe
154
+ // an unchanged tree with no active writer or retired sibling.
155
+ outcome: result.lockHeldAtEnd ? "lock-held" : result.staleTree ? "stale_tree" : "unsettled",
156
+ treeState,
150
157
  attempt: 1,
151
158
  waitedMs: result.waitedMs,
152
159
  sawLock: result.sawLock,
153
160
  sawRetired: result.sawRetired,
161
+ sawTreeChange: result.sawTreeChange,
154
162
  lockHeldAtEnd: result.lockHeldAtEnd,
155
163
  lockedMs: result.lockedMs,
156
164
  lockTool: result.lockTool,
@@ -158,17 +166,30 @@ export async function registerCommandsWithRecovery(args) {
158
166
  }
159
167
  const spawn = deps.spawn ?? spawnSync;
160
168
  const execArgv = deps.execArgv ?? process.execArgv;
161
- const child = spawn(process.execPath, [...execArgv, entryPath, ...argv.slice(2)], { stdio: "inherit", env: { ...env, [INSTALL_TREE_RECOVERY_GUARD_ENV]: "1" } });
169
+ const treeState = result.sawLock || result.sawRetired || result.sawTreeChange
170
+ ? "in_flight"
171
+ : "stale";
172
+ const child = spawn(process.execPath, [...execArgv, entryPath, ...argv.slice(2)], {
173
+ stdio: "inherit",
174
+ env: {
175
+ ...env,
176
+ [INSTALL_TREE_RECOVERY_GUARD_ENV]: "1",
177
+ [INSTALL_TREE_RECOVERY_TREE_STATE_ENV]: treeState,
178
+ },
179
+ });
162
180
  if (child.error) {
163
181
  throw new InstallTreeTornError({
164
182
  cause: err,
165
183
  classified,
166
184
  packageRoot,
167
185
  outcome: "reexec-failed",
186
+ treeState,
168
187
  attempt: 1,
169
188
  waitedMs: result.waitedMs,
170
189
  sawLock: result.sawLock,
171
190
  sawRetired: result.sawRetired,
191
+ sawTreeChange: result.sawTreeChange,
192
+ lockTool: result.lockTool,
172
193
  });
173
194
  }
174
195
  // A signal-killed child has status null; surface it as a failure exit rather
@@ -179,6 +179,8 @@ export interface InstallTreeFs {
179
179
  existsSync(p: string): boolean;
180
180
  statSync(p: string): {
181
181
  isDirectory(): boolean;
182
+ size?: number;
183
+ mtimeMs?: number;
182
184
  };
183
185
  readFileSync(p: string, encoding: "utf-8"): string;
184
186
  readdirSync(p: string): string[];
@@ -225,6 +227,8 @@ export interface WaitForInstallTreeSettledArgs {
225
227
  isPidAlive?: (pid: number) => boolean;
226
228
  pollMs?: number;
227
229
  quietMs?: number;
230
+ /** Short observation window for an unchanged, lock-free broken tree. */
231
+ staleProbeMs?: number;
228
232
  deadlineMs: number;
229
233
  /**
230
234
  * Ceiling on the CUMULATIVE time observed under a FRESH foreign lock held by a
@@ -256,7 +260,12 @@ export interface WaitForInstallTreeSettledResult {
256
260
  lockedMs: number;
257
261
  /** The bounded `tool` of the last fresh foreign lock seen, or "" if none. */
258
262
  lockTool: string;
263
+ /** Whether a relevant install-tree path changed during this wait. */
264
+ sawTreeChange?: boolean;
265
+ /** True when the lock-free tree stayed unchanged for `staleProbeMs`. */
266
+ staleTree?: boolean;
259
267
  }
268
+ export declare const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1500;
260
269
  /**
261
270
  * Poll until the install tree has been continuously READY for `quietMs`, or the
262
271
  * wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
@@ -265,14 +274,14 @@ export interface WaitForInstallTreeSettledResult {
265
274
  * `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
266
275
  * its condition "not ready" rather than throwing.
267
276
  *
268
- * The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
269
- * cooperating writer (the desktop app, the box's update timer, another `hq`)
270
- * cannot make a legitimately-in-progress reinstall look like a torn install:
277
+ * The wait is LOCK-AWARE and bounded, so a live cooperating writer (the desktop
278
+ * app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
279
+ * reinstall look stale:
271
280
  * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
272
- * by `deadlineMs`. That is the budget for the TREE to settle once no writer is
273
- * visible; with no lock ever seen, unlocked time equals total wait time and
274
- * the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
275
- * `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
281
+ * by `deadlineMs`. If the target is still missing, no lock or retired sibling
282
+ * is present, and the observed tree stays unchanged for `staleProbeMs`, the
283
+ * wait returns `staleTree` early. Any observed tree change keeps the ordinary
284
+ * settle deadline in force. `deadlineMs === 0` evaluates readiness once.
276
285
  * - LOCKED time — wall time observed under a fresh foreign lock held by a live
277
286
  * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
278
287
  * 10-minute staleness window) and does NOT consume the settle budget. When
@@ -285,7 +294,8 @@ export interface WaitForInstallTreeSettledResult {
285
294
  * began, so a starved event loop only makes the wait coarser, never unbounded. A
286
295
  * stale lock (dead pid, older than the staleness window, unparseable) or our own
287
296
  * pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
288
- * lockWaitMs`), so the caller can never hang.
297
+ * lockWaitMs`), and the unchanged-tree path normally returns after the much
298
+ * shorter `staleProbeMs` observation window.
289
299
  */
290
300
  export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
291
301
  export interface InstallRewriteSignalArgs {
@@ -310,7 +320,8 @@ export interface InstallRewriteSignalArgs {
310
320
  */
311
321
  export declare function installRewriteInProgress(args: InstallRewriteSignalArgs): boolean;
312
322
  /** The recovery OUTCOME, which — not the dialect — decides capture. */
313
- export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held";
323
+ export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held" | "stale_tree";
324
+ export type InstallTreeState = "in_flight" | "stale";
314
325
  export type InstallTreeTornDiagnostics = {
315
326
  dialect: ModuleErrorDialect;
316
327
  code: ModuleNotFoundCode;
@@ -318,10 +329,12 @@ export type InstallTreeTornDiagnostics = {
318
329
  importer: string;
319
330
  packageRoot: string;
320
331
  outcome: InstallTreeTornOutcome;
332
+ treeState: InstallTreeState;
321
333
  attempt: 1 | 2;
322
334
  waitedMs: number;
323
335
  sawLock: boolean;
324
336
  sawRetired: boolean;
337
+ sawTreeChange: boolean;
325
338
  /** A fresh foreign lock was still held when the wait gave up (→ 'lock-held'). */
326
339
  lockHeldAtEnd: boolean;
327
340
  /** Cumulative wall time the wait spent under a fresh foreign lock. */
@@ -342,6 +355,8 @@ export interface InstallTreeTornInit {
342
355
  waitedMs: number;
343
356
  sawLock: boolean;
344
357
  sawRetired: boolean;
358
+ sawTreeChange?: boolean;
359
+ treeState?: InstallTreeState;
345
360
  /** Whether a fresh foreign lock was held when the wait gave up (default false). */
346
361
  lockHeldAtEnd?: boolean;
347
362
  /** Cumulative time under a fresh foreign lock (default 0). */
@@ -362,11 +377,13 @@ export declare class InstallTreeTornError extends Error {
362
377
  }
363
378
  export declare function isInstallTreeTornError(err: unknown): err is InstallTreeTornError;
364
379
  /** The exact Sentry `contexts` payload for a captured torn-install failure. */
365
- export declare function installTreeTornCaptureContext(err: InstallTreeTornError): {
380
+ export declare function installTreeTornCaptureContext(err: InstallTreeTornError, homeDirectory?: string): {
366
381
  contexts: {
367
382
  install_tree_torn: InstallTreeTornDiagnostics;
368
383
  };
369
384
  };
385
+ /** Replace a home-directory prefix without matching similarly-named sibling folders. */
386
+ export declare function scrubHomeDirectoryPrefix(value: string, homeDirectory?: string): string;
370
387
  /**
371
388
  * The fixed, input-free operator remedy. It names the likely writers (so a
372
389
  * human knows this was not their command's fault) and the reinstall commands,
@@ -374,6 +391,8 @@ export declare function installTreeTornCaptureContext(err: InstallTreeTornError)
374
391
  * appended by {@link installTreeTornStderrLine}.
375
392
  */
376
393
  export declare const INSTALL_TREE_TORN_REMEDY: string;
394
+ /** Fixed repair guidance for an unchanged tree with no active updater signal. */
395
+ export declare const INSTALL_TREE_STALE_REMEDY: string;
377
396
  /**
378
397
  * The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
379
398
  * held the shared update lock past its own staleness ceiling. Telling the user to
@@ -30,6 +30,7 @@
30
30
  * are only READ (their exported symbols), never modified.
31
31
  */
32
32
  import * as nodeFs from "node:fs";
33
+ import { homedir } from "node:os";
33
34
  import * as path from "node:path";
34
35
  import { fileURLToPath } from "node:url";
35
36
  import { CLI_NAME } from "../cli-version.js";
@@ -637,6 +638,125 @@ function packageRootHealthy(packageRoot, fs) {
637
638
  const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"));
638
639
  return manifest.name === CLI_NAME;
639
640
  }
641
+ function isWithinPath(candidate, root) {
642
+ const relative = path.relative(root, candidate);
643
+ return relative === "" ||
644
+ (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
645
+ }
646
+ /** Paths whose presence, metadata, or immediate entries show activity for this import. */
647
+ function installTreeActivityPaths(target, packageRoot) {
648
+ const root = packageRoot ? path.resolve(packageRoot) : null;
649
+ const paths = new Set();
650
+ const directories = new Set();
651
+ const addPath = (candidate) => {
652
+ const resolved = path.resolve(candidate);
653
+ paths.add(resolved);
654
+ directories.add(path.dirname(resolved));
655
+ if (!root || !isWithinPath(resolved, root))
656
+ return;
657
+ let current = path.dirname(resolved);
658
+ for (;;) {
659
+ paths.add(current);
660
+ directories.add(current);
661
+ if (current === root)
662
+ break;
663
+ const parent = path.dirname(current);
664
+ if (parent === current || !isWithinPath(parent, root))
665
+ break;
666
+ current = parent;
667
+ }
668
+ };
669
+ if (root) {
670
+ for (const candidate of [
671
+ root,
672
+ path.join(root, "package.json"),
673
+ path.join(root, "dist"),
674
+ path.join(root, "node_modules"),
675
+ ]) {
676
+ addPath(candidate);
677
+ directories.add(candidate);
678
+ }
679
+ }
680
+ if (target.kind === "path" || target.kind === "exists") {
681
+ addPath(target.path);
682
+ }
683
+ else if (target.kind === "relative") {
684
+ const base = path.resolve(target.from, target.specifier);
685
+ const candidates = [
686
+ base,
687
+ ...CJS_FILE_EXTENSIONS.map((ext) => base + ext),
688
+ ...CJS_FILE_EXTENSIONS.map((ext) => path.join(base, `index${ext}`)),
689
+ ];
690
+ for (const candidate of candidates)
691
+ addPath(candidate);
692
+ }
693
+ else if (target.kind === "package") {
694
+ let from = path.resolve(target.from);
695
+ let ancestorCount = 0;
696
+ for (;;) {
697
+ if (root && !isWithinPath(from, root))
698
+ break;
699
+ addPath(path.join(from, "node_modules", target.name));
700
+ if (from === root)
701
+ break;
702
+ ancestorCount += 1;
703
+ if (!root && ancestorCount >= 12)
704
+ break;
705
+ const parent = path.dirname(from);
706
+ if (parent === from)
707
+ break;
708
+ from = parent;
709
+ }
710
+ }
711
+ return {
712
+ paths: [...paths].sort(),
713
+ directories: [...directories].sort(),
714
+ };
715
+ }
716
+ /** A local-only snapshot of the missing target and directories an installer may change. */
717
+ function installTreeActivitySignature(target, packageRoot, fs) {
718
+ const activity = installTreeActivityPaths(target, packageRoot);
719
+ const snapshots = [];
720
+ let observed = true;
721
+ const errorCode = (err) => {
722
+ if (err !== null && typeof err === "object" && "code" in err && typeof err.code === "string") {
723
+ return err.code;
724
+ }
725
+ return "error";
726
+ };
727
+ for (const candidate of activity.paths) {
728
+ try {
729
+ if (!fs.existsSync(candidate)) {
730
+ snapshots.push([candidate, "missing"]);
731
+ continue;
732
+ }
733
+ const stat = fs.statSync(candidate);
734
+ snapshots.push([
735
+ candidate,
736
+ stat.isDirectory() ? "directory" : "file",
737
+ stat.size ?? null,
738
+ stat.mtimeMs ?? null,
739
+ ]);
740
+ }
741
+ catch (err) {
742
+ observed = false;
743
+ snapshots.push([candidate, `unreadable:${errorCode(err)}`]);
744
+ }
745
+ }
746
+ for (const directory of activity.directories) {
747
+ try {
748
+ if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory())
749
+ continue;
750
+ snapshots.push([directory, "entries", fs.readdirSync(directory).sort()]);
751
+ }
752
+ catch (err) {
753
+ observed = false;
754
+ snapshots.push([directory, `entries-unreadable:${errorCode(err)}`]);
755
+ }
756
+ }
757
+ return { value: JSON.stringify(snapshots), observed };
758
+ }
759
+ export const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
640
760
  /**
641
761
  * Poll until the install tree has been continuously READY for `quietMs`, or the
642
762
  * wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
@@ -645,14 +765,14 @@ function packageRootHealthy(packageRoot, fs) {
645
765
  * `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
646
766
  * its condition "not ready" rather than throwing.
647
767
  *
648
- * The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
649
- * cooperating writer (the desktop app, the box's update timer, another `hq`)
650
- * cannot make a legitimately-in-progress reinstall look like a torn install:
768
+ * The wait is LOCK-AWARE and bounded, so a live cooperating writer (the desktop
769
+ * app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
770
+ * reinstall look stale:
651
771
  * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
652
- * by `deadlineMs`. That is the budget for the TREE to settle once no writer is
653
- * visible; with no lock ever seen, unlocked time equals total wait time and
654
- * the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
655
- * `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
772
+ * by `deadlineMs`. If the target is still missing, no lock or retired sibling
773
+ * is present, and the observed tree stays unchanged for `staleProbeMs`, the
774
+ * wait returns `staleTree` early. Any observed tree change keeps the ordinary
775
+ * settle deadline in force. `deadlineMs === 0` evaluates readiness once.
656
776
  * - LOCKED time — wall time observed under a fresh foreign lock held by a live
657
777
  * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
658
778
  * 10-minute staleness window) and does NOT consume the settle budget. When
@@ -665,7 +785,8 @@ function packageRootHealthy(packageRoot, fs) {
665
785
  * began, so a starved event loop only makes the wait coarser, never unbounded. A
666
786
  * stale lock (dead pid, older than the staleness window, unparseable) or our own
667
787
  * pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
668
- * lockWaitMs`), so the caller can never hang.
788
+ * lockWaitMs`), and the unchanged-tree path normally returns after the much
789
+ * shorter `staleProbeMs` observation window.
669
790
  */
670
791
  export async function waitForInstallTreeSettled(args) {
671
792
  const now = args.now ?? Date.now;
@@ -676,7 +797,11 @@ export async function waitForInstallTreeSettled(args) {
676
797
  const quietMs = args.quietMs ?? 1500;
677
798
  const deadlineMs = args.deadlineMs;
678
799
  const lockWaitMs = args.lockWaitMs ?? DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS;
800
+ const staleProbeMs = Math.max(0, args.staleProbeMs ?? DEFAULT_INSTALL_TREE_STALE_PROBE_MS);
679
801
  const start = now();
802
+ let lastActivitySnapshot = installTreeActivitySignature(args.target, args.packageRoot, fs);
803
+ let staleObservationStartedAt = lastActivitySnapshot.observed ? start : null;
804
+ let sawTreeChange = false;
680
805
  let readySince = null;
681
806
  let sawLock = false;
682
807
  let sawRetired = false;
@@ -700,6 +825,32 @@ export async function waitForInstallTreeSettled(args) {
700
825
  sawLock = true;
701
826
  lockTool = boundedDiagnosticValue(lock.tool, LOCK_TOOL_BYTES);
702
827
  }
828
+ if (!sawTreeChange) {
829
+ const activitySnapshot = installTreeActivitySignature(args.target, args.packageRoot, fs);
830
+ if (!activitySnapshot.observed) {
831
+ staleObservationStartedAt = null;
832
+ }
833
+ else if (!lastActivitySnapshot.observed) {
834
+ staleObservationStartedAt = t;
835
+ }
836
+ else if (activitySnapshot.value !== lastActivitySnapshot.value) {
837
+ sawTreeChange = true;
838
+ staleObservationStartedAt = null;
839
+ }
840
+ lastActivitySnapshot = activitySnapshot;
841
+ }
842
+ let retiredPresent = false;
843
+ let retiredCheckFailed = false;
844
+ if (args.packageRoot) {
845
+ try {
846
+ retiredPresent = retiredSiblingPresent(args.packageRoot, fs);
847
+ if (retiredPresent)
848
+ sawRetired = true;
849
+ }
850
+ catch {
851
+ retiredCheckFailed = true;
852
+ }
853
+ }
703
854
  let ready = !foreignLockHeld && installTargetPresent(args.target, fs);
704
855
  if (ready && args.packageRoot) {
705
856
  try {
@@ -709,17 +860,8 @@ export async function waitForInstallTreeSettled(args) {
709
860
  catch {
710
861
  ready = false;
711
862
  }
712
- if (ready) {
713
- try {
714
- if (retiredSiblingPresent(args.packageRoot, fs)) {
715
- sawRetired = true;
716
- ready = false;
717
- }
718
- }
719
- catch {
720
- ready = false;
721
- }
722
- }
863
+ if (ready && (retiredPresent || retiredCheckFailed))
864
+ ready = false;
723
865
  }
724
866
  if (ready) {
725
867
  if (readySince === null)
@@ -731,7 +873,38 @@ export async function waitForInstallTreeSettled(args) {
731
873
  const waitedMs = t - start;
732
874
  if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
733
875
  // Settling requires no foreign lock, so it is never held at the end here.
734
- return { settled: true, waitedMs, sawLock, sawRetired, lockHeldAtEnd: false, lockedMs, lockTool };
876
+ return {
877
+ settled: true,
878
+ waitedMs,
879
+ sawLock,
880
+ sawRetired,
881
+ lockHeldAtEnd: false,
882
+ lockedMs,
883
+ lockTool,
884
+ sawTreeChange,
885
+ staleTree: false,
886
+ };
887
+ }
888
+ if (!ready &&
889
+ !foreignLockHeld &&
890
+ !retiredPresent &&
891
+ !retiredCheckFailed &&
892
+ !sawLock &&
893
+ !sawRetired &&
894
+ !sawTreeChange &&
895
+ staleObservationStartedAt !== null &&
896
+ t - staleObservationStartedAt >= staleProbeMs) {
897
+ return {
898
+ settled: false,
899
+ waitedMs,
900
+ sawLock,
901
+ sawRetired,
902
+ lockHeldAtEnd: false,
903
+ lockedMs,
904
+ lockTool,
905
+ sawTreeChange,
906
+ staleTree: true,
907
+ };
735
908
  }
736
909
  // Give up when the TREE has had a full unlocked budget with no writer visible,
737
910
  // OR a writer is STILL holding the lock and has held it past its own staleness
@@ -742,7 +915,17 @@ export async function waitForInstallTreeSettled(args) {
742
915
  // `deadlineMs` 0 makes the unlocked test fire on the first poll (evaluate-once),
743
916
  // matching the pre-lock-aware contract; `lockHeldAtEnd` distinguishes the causes.
744
917
  if (unlockedMs >= deadlineMs || (foreignLockHeld && lockedMs >= lockWaitMs)) {
745
- return { settled: false, waitedMs, sawLock, sawRetired, lockHeldAtEnd: foreignLockHeld, lockedMs, lockTool };
918
+ return {
919
+ settled: false,
920
+ waitedMs,
921
+ sawLock,
922
+ sawRetired,
923
+ lockHeldAtEnd: foreignLockHeld,
924
+ lockedMs,
925
+ lockTool,
926
+ sawTreeChange,
927
+ staleTree: false,
928
+ };
746
929
  }
747
930
  // The first poll at which the wait has reached `deadlineMs` while a lock is
748
931
  // still held is exactly where the pre-lock-aware wait gave up — announce the
@@ -808,7 +991,7 @@ export function installRewriteInProgress(args) {
808
991
  }
809
992
  }
810
993
  /** The fixed, input-free carrier message (grouping cardinality stays bounded). */
811
- const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree was being rewritten while this command started";
994
+ const INSTALL_TREE_TORN_MESSAGE = "hq-cli could not load a module from its install tree and automatic recovery did not complete";
812
995
  /**
813
996
  * A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
814
997
  * diagnostics. Fixed `name`/`message` (no argv, path, or free text in either),
@@ -822,6 +1005,8 @@ export class InstallTreeTornError extends Error {
822
1005
  super(INSTALL_TREE_TORN_MESSAGE);
823
1006
  this.name = "InstallTreeTornError";
824
1007
  this.cause = init.cause;
1008
+ const sawTreeChange = init.sawTreeChange ?? false;
1009
+ const treeState = init.treeState ?? (init.outcome === "stale_tree" ? "stale" : "in_flight");
825
1010
  this.diagnostics = {
826
1011
  dialect: init.classified.dialect,
827
1012
  code: init.classified.code,
@@ -829,10 +1014,12 @@ export class InstallTreeTornError extends Error {
829
1014
  importer: boundedDiagnosticValue(init.classified.importer, IMPORTER_BYTES),
830
1015
  packageRoot: boundedDiagnosticValue(init.packageRoot ?? "", PACKAGE_ROOT_BYTES),
831
1016
  outcome: init.outcome,
1017
+ treeState,
832
1018
  attempt: init.attempt,
833
1019
  waitedMs: init.waitedMs,
834
1020
  sawLock: init.sawLock,
835
1021
  sawRetired: init.sawRetired,
1022
+ sawTreeChange,
836
1023
  lockHeldAtEnd: init.lockHeldAtEnd ?? false,
837
1024
  lockedMs: init.lockedMs ?? 0,
838
1025
  lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
@@ -846,8 +1033,45 @@ export function isInstallTreeTornError(err) {
846
1033
  return err instanceof InstallTreeTornError;
847
1034
  }
848
1035
  /** The exact Sentry `contexts` payload for a captured torn-install failure. */
849
- export function installTreeTornCaptureContext(err) {
850
- return { contexts: { install_tree_torn: err.diagnostics } };
1036
+ export function installTreeTornCaptureContext(err, homeDirectory = homedir()) {
1037
+ const diagnostics = err.diagnostics;
1038
+ return {
1039
+ contexts: {
1040
+ install_tree_torn: {
1041
+ ...diagnostics,
1042
+ specifier: scrubHomeDirectoryPrefix(diagnostics.specifier, homeDirectory),
1043
+ importer: scrubHomeDirectoryPrefix(diagnostics.importer, homeDirectory),
1044
+ packageRoot: scrubHomeDirectoryPrefix(diagnostics.packageRoot, homeDirectory),
1045
+ },
1046
+ },
1047
+ };
1048
+ }
1049
+ function looksLikeWindowsPath(value) {
1050
+ return /^[a-zA-Z]:[\\/]/.test(value) || /^\\\\/.test(value);
1051
+ }
1052
+ /** Replace a home-directory prefix without matching similarly-named sibling folders. */
1053
+ export function scrubHomeDirectoryPrefix(value, homeDirectory = homedir()) {
1054
+ if (!value || !homeDirectory)
1055
+ return value;
1056
+ if (looksLikeWindowsPath(value) || looksLikeWindowsPath(homeDirectory)) {
1057
+ const home = path.win32.normalize(homeDirectory).replace(/[\\/]+$/, "");
1058
+ const normalized = path.win32.normalize(value);
1059
+ const foldedHome = home.toLowerCase();
1060
+ const foldedValue = normalized.toLowerCase();
1061
+ if (foldedValue === foldedHome)
1062
+ return "~";
1063
+ const prefix = `${foldedHome}\\`;
1064
+ if (foldedValue.startsWith(prefix))
1065
+ return `~\\${normalized.slice(home.length + 1)}`;
1066
+ return value;
1067
+ }
1068
+ const home = path.posix.normalize(homeDirectory).replace(/\/+$/, "");
1069
+ const normalized = path.posix.normalize(value);
1070
+ if (normalized === home)
1071
+ return "~";
1072
+ if (normalized.startsWith(`${home}/`))
1073
+ return `~/${normalized.slice(home.length + 1)}`;
1074
+ return value;
851
1075
  }
852
1076
  /**
853
1077
  * The fixed, input-free operator remedy. It names the likely writers (so a
@@ -855,10 +1079,15 @@ export function installTreeTornCaptureContext(err) {
855
1079
  * and interpolates NOTHING — the only per-event value is the bounded specifier
856
1080
  * appended by {@link installTreeTornStderrLine}.
857
1081
  */
858
- export const INSTALL_TREE_TORN_REMEDY = "the hq-cli install was being updated by another process (the box's hq-cli-update " +
859
- "timer, the desktop app, or another hq command) while this command started. " +
860
- "Re-run your command; if it keeps failing, reinstall with " +
861
- "`npm i -g @indigoai-us/hq-cli` (or `pnpm add -g @indigoai-us/hq-cli`).";
1082
+ export const INSTALL_TREE_TORN_REMEDY = "hq-cli could not finish automatic install-tree recovery. Re-run your command; " +
1083
+ "if it still fails, reinstall with `npm install -g @indigoai-us/hq-cli@latest` " +
1084
+ "(or `pnpm add -g @indigoai-us/hq-cli@latest` / `bun add -g @indigoai-us/hq-cli@latest` " +
1085
+ "when that manager owns your install).";
1086
+ /** Fixed repair guidance for an unchanged tree with no active updater signal. */
1087
+ export const INSTALL_TREE_STALE_REMEDY = "hq-cli's install tree is incomplete and no active updater was detected. " +
1088
+ "Reinstall with `npm install -g @indigoai-us/hq-cli@latest` " +
1089
+ "(or `pnpm add -g @indigoai-us/hq-cli@latest` / `bun add -g @indigoai-us/hq-cli@latest` " +
1090
+ "when that manager owns your install), then run the command again.";
862
1091
  /**
863
1092
  * The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
864
1093
  * held the shared update lock past its own staleness ceiling. Telling the user to
@@ -877,7 +1106,11 @@ export const INSTALL_TREE_LOCK_HELD_REMEDY = "another hq updater (the desktop ap
877
1106
  * {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
878
1107
  */
879
1108
  export function installTreeTornStderrLine(err) {
880
- const remedy = err.diagnostics.outcome === "lock-held" ? INSTALL_TREE_LOCK_HELD_REMEDY : INSTALL_TREE_TORN_REMEDY;
1109
+ const remedy = err.diagnostics.outcome === "lock-held"
1110
+ ? INSTALL_TREE_LOCK_HELD_REMEDY
1111
+ : err.diagnostics.outcome === "stale_tree"
1112
+ ? INSTALL_TREE_STALE_REMEDY
1113
+ : INSTALL_TREE_TORN_REMEDY;
881
1114
  const specifier = err.diagnostics.specifier;
882
1115
  return specifier ? `${remedy} (missing: ${specifier})` : remedy;
883
1116
  }
@@ -9,6 +9,12 @@ export interface SentryMcpCollisionContext {
9
9
  }
10
10
  /** Return bounded, value-free Sentry context for an instrumented Codex name collision. */
11
11
  export declare function sentryMcpCollisionContextFor(err: unknown): SentryMcpCollisionContext | null;
12
+ /**
13
+ * Bounded, aggregatable tags for an unrecovered install-tree failure. Phase,
14
+ * outcome, state, and OS are closed values. Paths and raw lock-tool strings stay
15
+ * in context; only a known updater source becomes a tag.
16
+ */
17
+ export declare function installTreeTornSentryTagsFor(err: unknown): Record<string, string> | null;
12
18
  /**
13
19
  * Return a bounded `event.fingerprint` array for `err`, or `null` when `err`
14
20
  * carries no discriminator from the closed allowlist (in which case the caller
@@ -23,7 +29,7 @@ export declare function sentryMcpCollisionContextFor(err: unknown): SentryMcpCol
23
29
  * The InstallTreeTornError branch is the deliberate exception: it returns a
24
30
  * fully explicit `["InstallTreeTornError", "outcome:<token>"]` with NO
25
31
  * DEFAULT_GROUPING lead, because its chained loader cause makes the default
26
- * component unbounded (see the module header — HQ-CLI-21/22/23).
32
+ * component unbounded.
27
33
  *
28
34
  * `rpcCode` is checked first so a gateway JSON-RPC fault groups by its protocol
29
35
  * code; the two carriers are disjoint in practice (the JSON-RPC throw site sets
@@ -38,7 +38,9 @@
38
38
  // in three different groups (hashes a4289a5e530e166ebdedea9d34dcad0a,
39
39
  // b563c1b891bd975bdc605efc86034b44, b4ee254473ea249758617de1a98848fa —
40
40
  // HQ-CLI-21/22/23). So that branch returns a FULLY EXPLICIT key with no
41
- // default lead: ["InstallTreeTornError", "outcome:<token>"].
41
+ // default lead: ["InstallTreeTornError", "outcome:<token>"]. This PR keeps
42
+ // the existing explicit key so the new stale-tree outcome extends the current
43
+ // groups without splitting them.
42
44
  //
43
45
  // Genuinely different failure classes therefore form distinct, PREDICTABLE
44
46
  // groups, and everything with no bounded discriminator is left to default
@@ -156,17 +158,96 @@ const KNOWN_ERROR_NAMES = new Set([
156
158
  "QmdWorkdirMissingError",
157
159
  // A torn-install failure the bounded settle-wait + single re-exec could not
158
160
  // recover (HQ-CLI-1G/1H/1J/1K, HQ-CLI-1Y). Fingerprinted on its own branch below
159
- // by the bounded `diagnostics.outcome`, so the whole family groups into ≤4 issues.
161
+ // by the normalized recovery result, so the family groups into two stable buckets.
160
162
  "InstallTreeTornError",
161
163
  ]);
162
164
  /**
163
- * The CLOSED set of InstallTreeTornError recovery outcomes worth their own
164
- * group. Anything else collapses to `outcome:other`, so the axis stays finite;
165
- * the carrier's message is fixed, so this outcome IS the only discriminator.
165
+ * The closed loader dialects accepted for the install_tree_phase tag. Anything
166
+ * outside this set collapses to unknown, so tags stay safe to aggregate.
166
167
  */
168
+ const INSTALL_TREE_PHASES = new Set([
169
+ "cjs-enoent",
170
+ "cjs-package",
171
+ "cjs-path",
172
+ "cjs-relative",
173
+ "esm-enoent",
174
+ "esm-package",
175
+ "esm-path",
176
+ "eval-throw",
177
+ "resolve-enoent",
178
+ "unknown",
179
+ ]);
180
+ const KNOWN_INSTALL_TREE_UPDATERS = new Map([
181
+ ["hq-cli-self-update", "cli-self-update"],
182
+ ["hq-cli-update", "box-timer"],
183
+ ["hq-cli-version-gate", "cli-version-gate"],
184
+ ["hq-desktop-app-cli-update", "desktop-app"],
185
+ ]);
167
186
  const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
168
- "guarded", "unsettled", "reexec-failed", "lock-held",
187
+ "guarded",
188
+ "unsettled",
189
+ "reexec-failed",
190
+ "lock-held",
191
+ "stale_tree",
169
192
  ]);
193
+ function installTreeRecoveryOutcomeTag(value) {
194
+ return typeof value === "string" && KNOWN_INSTALL_TREE_OUTCOMES.has(value)
195
+ ? value
196
+ : null;
197
+ }
198
+ function installTreeStateTag(value) {
199
+ return value === "stale" ? "stale" : "in_flight";
200
+ }
201
+ function installTreePhaseTag(value) {
202
+ if (typeof value !== "string" || !INSTALL_TREE_PHASES.has(value))
203
+ return "unknown";
204
+ if (value === "esm-enoent" || value === "cjs-enoent")
205
+ return "loader_read";
206
+ if (value === "eval-throw")
207
+ return "module_evaluation";
208
+ if (value === "esm-package" || value === "cjs-package")
209
+ return "package_resolution";
210
+ if (value === "unknown")
211
+ return "unknown";
212
+ return "path_resolution";
213
+ }
214
+ function installTreeOsTag(platform) {
215
+ if (platform === "win32")
216
+ return "windows";
217
+ if (platform === "darwin")
218
+ return "macos";
219
+ if (platform === "linux")
220
+ return "linux";
221
+ return "other";
222
+ }
223
+ /**
224
+ * Bounded, aggregatable tags for an unrecovered install-tree failure. Phase,
225
+ * outcome, state, and OS are closed values. Paths and raw lock-tool strings stay
226
+ * in context; only a known updater source becomes a tag.
227
+ */
228
+ export function installTreeTornSentryTagsFor(err) {
229
+ if (err === null || typeof err !== "object")
230
+ return null;
231
+ const record = err;
232
+ if (record.name !== "InstallTreeTornError")
233
+ return null;
234
+ const diagnostics = record.diagnostics !== null && typeof record.diagnostics === "object"
235
+ ? record.diagnostics
236
+ : {};
237
+ const recoveryOutcome = installTreeRecoveryOutcomeTag(diagnostics.outcome);
238
+ const tags = {
239
+ install_tree_phase: installTreePhaseTag(diagnostics.dialect),
240
+ tree_state: installTreeStateTag(diagnostics.treeState),
241
+ os: installTreeOsTag(process.platform),
242
+ };
243
+ if (recoveryOutcome)
244
+ tags.recovery_outcome = recoveryOutcome;
245
+ const lockTool = diagnostics.lockTool;
246
+ const updaterSource = typeof lockTool === "string" ? KNOWN_INSTALL_TREE_UPDATERS.get(lockTool) : undefined;
247
+ if (updaterSource)
248
+ tags.updater_source = updaterSource;
249
+ return tags;
250
+ }
170
251
  /** Fixed bucket for any error name outside the closed allowlist. */
171
252
  const FALLBACK_ERROR_NAME = "other";
172
253
  /**
@@ -241,7 +322,7 @@ function qmdDispositionToken(status, signal) {
241
322
  * The InstallTreeTornError branch is the deliberate exception: it returns a
242
323
  * fully explicit `["InstallTreeTornError", "outcome:<token>"]` with NO
243
324
  * DEFAULT_GROUPING lead, because its chained loader cause makes the default
244
- * component unbounded (see the module header — HQ-CLI-21/22/23).
325
+ * component unbounded.
245
326
  *
246
327
  * `rpcCode` is checked first so a gateway JSON-RPC fault groups by its protocol
247
328
  * code; the two carriers are disjoint in practice (the JSON-RPC throw site sets
@@ -284,10 +365,9 @@ export function sentryFingerprintFor(err) {
284
365
  ];
285
366
  }
286
367
  // A torn-install failure carries neither an rpcCode nor an HTTP status; its
287
- // only bounded discriminator is the recovery `diagnostics.outcome`. Keyed
368
+ // only bounded discriminator is the existing recovery outcome. Keyed
288
369
  // BEFORE the rpc/status branches so the family groups into a bounded set of
289
- // predictable issues (guarded / unsettled / reexec-failed / lock-held, plus the
290
- // `outcome:other` fallback) rather than one fungible bucket.
370
+ // predictable issues rather than one fungible bucket.
291
371
  //
292
372
  // The key is FULLY EXPLICIT — it does NOT lead with DEFAULT_GROUPING. Leading
293
373
  // with the placeholder salts the appended tokens with Sentry's default grouping
@@ -300,16 +380,16 @@ export function sentryFingerprintFor(err) {
300
380
  // a4289a5e530e166ebdedea9d34dcad0a, b563c1b891bd975bdc605efc86034b44 and
301
381
  // b4ee254473ea249758617de1a98848fa (HQ-CLI-21/22/23) — defeating the very bound
302
382
  // this branch exists to enforce. The explicit key collapses them to one stable
303
- // group per outcome, independent of machine, install path and which module tore.
383
+ // group per recovery outcome, independent of machine, install path and module.
304
384
  if (rawName === "InstallTreeTornError") {
305
385
  const diagnostics = record.diagnostics;
306
386
  const outcome = diagnostics !== null && typeof diagnostics === "object"
307
387
  ? diagnostics.outcome
308
388
  : undefined;
309
- const token = typeof outcome === "string" && KNOWN_INSTALL_TREE_OUTCOMES.has(outcome)
310
- ? `outcome:${outcome}`
311
- : "outcome:other";
312
- return ["InstallTreeTornError", token];
389
+ return [
390
+ "InstallTreeTornError",
391
+ `outcome:${installTreeRecoveryOutcomeTag(outcome) ?? "other"}`,
392
+ ];
313
393
  }
314
394
  const name = KNOWN_ERROR_NAMES.has(rawName) ? rawName : FALLBACK_ERROR_NAME;
315
395
  const rpcCode = record.rpcCode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.163.0",
3
+ "version": "5.165.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {