@indigoai-us/hq-cli 5.162.0 → 5.164.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/command-catalog.generated.d.ts +188 -0
  3. package/dist/command-catalog.generated.js +235 -0
  4. package/dist/command-registration-plan.d.ts +7 -0
  5. package/dist/command-registration-plan.js +1 -0
  6. package/dist/commands/agent-enroll.d.ts +6 -1
  7. package/dist/commands/agent-enroll.js +30 -3
  8. package/dist/commands/agents.js +15 -22
  9. package/dist/commands/bot.js +56 -6
  10. package/dist/commands/daemon.d.ts +53 -0
  11. package/dist/commands/daemon.js +554 -0
  12. package/dist/commands/lanes.js +7 -0
  13. package/dist/commands/meetings.js +18 -16
  14. package/dist/commands/mesh.js +9 -0
  15. package/dist/lib/daemon/catalog.d.ts +85 -0
  16. package/dist/lib/daemon/catalog.js +274 -0
  17. package/dist/lib/daemon/config.d.ts +26 -0
  18. package/dist/lib/daemon/config.js +85 -0
  19. package/dist/lib/daemon/handoff.d.ts +53 -0
  20. package/dist/lib/daemon/handoff.js +136 -0
  21. package/dist/lib/daemon/install.d.ts +76 -0
  22. package/dist/lib/daemon/install.js +299 -0
  23. package/dist/lib/daemon/legacy.d.ts +35 -0
  24. package/dist/lib/daemon/legacy.js +120 -0
  25. package/dist/lib/daemon/lock.d.ts +47 -0
  26. package/dist/lib/daemon/lock.js +138 -0
  27. package/dist/lib/daemon/machine-type.d.ts +48 -0
  28. package/dist/lib/daemon/machine-type.js +139 -0
  29. package/dist/lib/daemon/migrate.d.ts +61 -0
  30. package/dist/lib/daemon/migrate.js +238 -0
  31. package/dist/lib/daemon/paths.d.ts +22 -0
  32. package/dist/lib/daemon/paths.js +42 -0
  33. package/dist/lib/daemon/remote-control.d.ts +35 -0
  34. package/dist/lib/daemon/remote-control.js +47 -0
  35. package/dist/lib/daemon/requests.d.ts +24 -0
  36. package/dist/lib/daemon/requests.js +63 -0
  37. package/dist/lib/daemon/run.d.ts +41 -0
  38. package/dist/lib/daemon/run.js +319 -0
  39. package/dist/lib/daemon/supervisor.d.ts +87 -0
  40. package/dist/lib/daemon/supervisor.js +523 -0
  41. package/dist/lib/daemon/updater.d.ts +39 -0
  42. package/dist/lib/daemon/updater.js +103 -0
  43. package/dist/lib/flag-registry.d.ts +7 -1
  44. package/dist/lib/flag-registry.js +8 -0
  45. package/dist/lib/mesh/live/backoff.d.ts +2 -2
  46. package/dist/lib/mesh/live/backoff.js +16 -3
  47. package/dist/lib/mesh/live/daemon/run.d.ts +2 -2
  48. package/dist/lib/mesh/live/daemon/run.js +5 -2
  49. package/dist/lib/mesh/live/flush.d.ts +3 -2
  50. package/dist/lib/mesh/live/flush.js +34 -7
  51. package/dist/lib/mesh/live/session-events-client.d.ts +2 -0
  52. package/dist/lib/mesh/live/session-events-client.js +25 -0
  53. package/dist/lib/service-manager/systemd.d.ts +2 -2
  54. package/dist/lib/service-manager/systemd.js +1 -1
  55. package/dist/lib/service-manager/types.d.ts +2 -0
  56. package/dist/main.d.ts +6 -0
  57. package/dist/main.js +53 -13
  58. package/dist/sentry.js +5 -1
  59. package/dist/startup-registration.d.ts +3 -1
  60. package/dist/startup-registration.js +25 -4
  61. package/dist/utils/cognito-session.d.ts +4 -0
  62. package/dist/utils/cognito-session.js +11 -10
  63. package/dist/utils/install-tree-torn.d.ts +29 -10
  64. package/dist/utils/install-tree-torn.js +262 -29
  65. package/dist/utils/plan-gate-error.d.ts +27 -13
  66. package/dist/utils/plan-gate-error.js +133 -58
  67. package/dist/utils/self-update.d.ts +9 -0
  68. package/dist/utils/self-update.js +15 -2
  69. package/dist/utils/sentry-fingerprint.d.ts +7 -1
  70. package/dist/utils/sentry-fingerprint.js +95 -15
  71. package/package.json +1 -1
@@ -4,19 +4,74 @@
4
4
  * message instead of every command parsing and printing a server response.
5
5
  */
6
6
  import { renderPersonalPlanFeatureNotice } from "../lib/plan-limit-nag.js";
7
- const TEAM_UPGRADE_HINT = "Run `hq billing upgrade` to move to HQ Workforce.";
7
+ function planGateReason(code, details) {
8
+ if (details.reason?.trim())
9
+ return details.reason.trim();
10
+ if (code === "PLAN_LIMIT_EXCEEDED" &&
11
+ typeof details.resource === "string" &&
12
+ typeof details.used === "number" &&
13
+ typeof details.limit === "number") {
14
+ return `Starter plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`;
15
+ }
16
+ if (code === "PERSONAL_PLAN_REQUIRED") {
17
+ return renderPersonalPlanFeatureNotice(details.feature ?? "this feature", null);
18
+ }
19
+ if (code === "MEETING_PLAN_REQUIRED") {
20
+ return "HQ Workforce plan required to invite a meeting bot.";
21
+ }
22
+ if (code === "BILLING_REQUIRED") {
23
+ return "Upgrade the company plan before enrolling an external agent.";
24
+ }
25
+ return "HQ Workforce plan required for this feature.";
26
+ }
27
+ /** One renderer for server-selected upgrade links across CLI plan refusals. */
28
+ export function formatUpgradeRefusal(reason, upgradeUrl, options = {}) {
29
+ const safeUrl = safeUpgradeUrl(upgradeUrl);
30
+ if (options.urlOnly && safeUrl)
31
+ return safeUrl;
32
+ if (!safeUrl)
33
+ return reason;
34
+ return `${reason}\nUpgrade: ${safeUrl}`;
35
+ }
36
+ function safeUpgradeUrl(value) {
37
+ if (typeof value !== "string" ||
38
+ value.trim() !== value ||
39
+ !/^https:\/\//i.test(value)) {
40
+ return undefined;
41
+ }
42
+ try {
43
+ const url = new URL(value);
44
+ if (url.protocol !== "https:" ||
45
+ !url.hostname ||
46
+ url.username ||
47
+ url.password) {
48
+ return undefined;
49
+ }
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ return value;
55
+ }
56
+ /** Recover a company UID only from the server's UID-keyed billing callback. */
57
+ export function companyUidFromPlanUpgradeUrl(value) {
58
+ const safeUrl = safeUpgradeUrl(value);
59
+ if (!safeUrl)
60
+ return undefined;
61
+ const callbackUrl = new URL(safeUrl).searchParams.get("callbackUrl");
62
+ if (!callbackUrl)
63
+ return undefined;
64
+ const match = /^\/api\/companies\/(cmp_[A-Za-z0-9_-]+)\/billing\/upgrade\/?$/.exec(callbackUrl);
65
+ return match?.[1];
66
+ }
67
+ function formatPlanGateErrorText(code, details) {
68
+ return formatUpgradeRefusal(planGateReason(code, details), details.upgradeUrl);
69
+ }
8
70
  export class PlanGateError extends Error {
9
71
  code;
10
72
  details;
11
73
  constructor(code, details) {
12
- // Commands with their own expected-error boundary commonly print
13
- // `err.message`. Keeping the friendly copy here means those boundaries
14
- // retain the same plan-gate voice as main.ts without per-command handling.
15
- // The HQ Workforce hint is company advice; a personal denial ends at its
16
- // own notice, which already names the Individual plan and its checkout.
17
- super(code === "PERSONAL_PLAN_REQUIRED"
18
- ? formatPlanGateDetails(code, details)
19
- : [formatPlanGateDetails(code, details), TEAM_UPGRADE_HINT].join("\n"));
74
+ super(formatPlanGateErrorText(code, details));
20
75
  this.code = code;
21
76
  this.details = details;
22
77
  this.name = "PlanGateError";
@@ -26,56 +81,52 @@ export function isPlanGateError(err) {
26
81
  return err instanceof PlanGateError;
27
82
  }
28
83
  /** Plain, stable CLI copy for plan-gated resource creation. */
29
- function formatPlanGateDetails(code, details) {
30
- const existingResourcesNote = "Existing resources will keep working.";
31
- if (code === "PLAN_LIMIT_EXCEEDED" &&
32
- typeof details.resource === "string" &&
33
- typeof details.used === "number" &&
34
- typeof details.limit === "number") {
35
- const upgrade = typeof details.upgradeUrl === "string"
36
- ? `Upgrade to HQ Workforce ($500/mo) to remove limits: ${details.upgradeUrl}`
37
- : "Upgrade to HQ Workforce ($500/mo) to remove limits.";
38
- return [
39
- `Starter plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`,
40
- upgrade,
41
- existingResourcesNote,
42
- ].join("\n");
43
- }
44
- // A personal scope is not a workspace: it has no members to trim, no $500
45
- // tier to buy, and nothing of its own is paused. One line, the plan's own
46
- // copy, and no company remedy.
47
- if (code === "PERSONAL_PLAN_REQUIRED") {
48
- return renderPersonalPlanFeatureNotice(details.feature ?? "this feature", details.upgradeUrl ?? null);
49
- }
50
- const upgrade = typeof details.upgradeUrl === "string"
51
- ? `Upgrade to HQ Workforce ($500/mo) to remove limits: ${details.upgradeUrl}`
52
- : "Upgrade to HQ Workforce ($500/mo) to remove limits.";
53
- return [
54
- "HQ Workforce plan required for this feature.",
55
- upgrade,
56
- existingResourcesNote,
57
- ].join("\n");
58
- }
59
- export function formatPlanGateError(err) {
60
- if (err.code === "PERSONAL_PLAN_REQUIRED") {
61
- return formatPlanGateDetails(err.code, err.details);
62
- }
63
- return [
64
- formatPlanGateDetails(err.code, err.details),
65
- TEAM_UPGRADE_HINT,
66
- ].join("\n");
84
+ export function formatPlanGateError(err, options = {}) {
85
+ return formatUpgradeRefusal(planGateReason(err.code, err.details), err.details.upgradeUrl, options);
67
86
  }
68
87
  export function canOfferTeamUpgrade(argv = process.argv) {
69
88
  return Boolean(process.stdin.isTTY && process.stderr.isTTY) &&
70
89
  !process.env.CI &&
71
90
  !argv.includes("--json");
72
91
  }
73
- /** Ask once, only on a real interactive terminal. */
92
+ /** Ask before opening the exact URL returned by the server. */
93
+ export async function offerUpgradeLink(url, deps = {}) {
94
+ const safeUrl = safeUpgradeUrl(url);
95
+ if (!safeUrl)
96
+ return false;
97
+ if (!(deps.interactive ?? canOfferTeamUpgrade(deps.argv)))
98
+ return false;
99
+ let answer;
100
+ if (deps.ask) {
101
+ answer = await deps.ask("Open upgrade link now? [y/N] ");
102
+ }
103
+ else {
104
+ const { createInterface } = await import("node:readline/promises");
105
+ const readline = createInterface({ input: process.stdin, output: process.stderr });
106
+ try {
107
+ answer = await readline.question("Open upgrade link now? [y/N] ");
108
+ }
109
+ finally {
110
+ readline.close();
111
+ }
112
+ }
113
+ if (!/^y(?:es)?$/i.test(answer.trim()))
114
+ return false;
115
+ if (deps.openUrl) {
116
+ await deps.openUrl(safeUrl);
117
+ }
118
+ else {
119
+ const { default: open } = await import("open");
120
+ await open(safeUrl);
121
+ }
122
+ return true;
123
+ }
124
+ /** Legacy checkout callback retained for callers outside the shared refusal paths. */
74
125
  export async function offerTeamUpgrade(onConfirm, deps = {}) {
75
126
  if (!(deps.interactive ?? canOfferTeamUpgrade(deps.argv)))
76
127
  return false;
77
128
  if (deps.ask) {
78
- const answer = await deps.ask("Upgrade to HQ Workforce ($500/mo) now? [y/N] ");
129
+ const answer = await deps.ask("Upgrade now? (opens checkout) [y/N] ");
79
130
  if (!/^y(?:es)?$/i.test(answer.trim()))
80
131
  return false;
81
132
  await onConfirm();
@@ -84,7 +135,7 @@ export async function offerTeamUpgrade(onConfirm, deps = {}) {
84
135
  const { createInterface } = await import("node:readline/promises");
85
136
  const readline = createInterface({ input: process.stdin, output: process.stderr });
86
137
  try {
87
- const answer = await readline.question("Upgrade to HQ Workforce ($500/mo) now? [y/N] ");
138
+ const answer = await readline.question("Upgrade now? (opens checkout) [y/N] ");
88
139
  if (!/^y(?:es)?$/i.test(answer.trim()))
89
140
  return false;
90
141
  await onConfirm();
@@ -106,28 +157,52 @@ export function planGateErrorFromPayload(status, body, companyUid) {
106
157
  // through to the company path below (defensive consumer).
107
158
  if (payload.error === "plan_feature_unavailable" &&
108
159
  payload.scope === "personal") {
160
+ const upgradeUrl = safeUpgradeUrl(payload.upgradeUrl);
109
161
  return new PlanGateError("PERSONAL_PLAN_REQUIRED", {
110
162
  ...(typeof payload.feature === "string"
111
163
  ? { feature: payload.feature }
112
164
  : {}),
113
- ...(typeof payload.upgradeUrl === "string"
114
- ? { upgradeUrl: payload.upgradeUrl }
115
- : {}),
165
+ ...(upgradeUrl ? { upgradeUrl } : {}),
116
166
  });
117
167
  }
118
- if (payload.code !== "PLAN_LIMIT_EXCEEDED" && payload.code !== "PLAN_REQUIRED") {
168
+ const code = payload.code;
169
+ const upgradeUrl = safeUpgradeUrl(payload.upgradeUrl);
170
+ const targetCompanyUid = companyUid ?? companyUidFromPlanUpgradeUrl(upgradeUrl);
171
+ const planLimit = payload.planLimit !== null &&
172
+ typeof payload.planLimit === "object" &&
173
+ !Array.isArray(payload.planLimit)
174
+ ? payload.planLimit
175
+ : undefined;
176
+ const isTeamPlanBillingRequired = code === "BILLING_REQUIRED" && planLimit?.requiredPlan === "team";
177
+ if (code === "BILLING_REQUIRED") {
178
+ if (!isTeamPlanBillingRequired ||
179
+ !targetCompanyUid ||
180
+ !/^cmp_[A-Za-z0-9_-]+$/.test(targetCompanyUid)) {
181
+ return null;
182
+ }
183
+ }
184
+ else if (code !== "PLAN_LIMIT_EXCEEDED" &&
185
+ code !== "PLAN_REQUIRED" &&
186
+ code !== "MEETING_PLAN_REQUIRED") {
119
187
  return null;
120
188
  }
121
- return new PlanGateError(payload.code, {
189
+ const reason = typeof payload.message === "string"
190
+ ? payload.message
191
+ : typeof payload.error === "string" && payload.error !== payload.code
192
+ ? payload.error
193
+ : undefined;
194
+ return new PlanGateError(code, {
122
195
  ...(typeof payload.resource === "string" ? { resource: payload.resource } : {}),
196
+ ...(typeof payload.feature === "string" ? { feature: payload.feature } : {}),
123
197
  ...(typeof payload.used === "number" ? { used: payload.used } : {}),
124
198
  ...(typeof payload.limit === "number" ? { limit: payload.limit } : {}),
125
- ...(typeof payload.upgradeUrl === "string" ? { upgradeUrl: payload.upgradeUrl } : {}),
126
- ...(companyUid ? { companyUid } : {}),
199
+ ...(upgradeUrl ? { upgradeUrl } : {}),
200
+ ...(reason ? { reason } : {}),
201
+ ...(targetCompanyUid ? { companyUid: targetCompanyUid } : {}),
127
202
  });
128
203
  }
129
204
  /**
130
- * Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
205
+ * Decode the supported plan-gate envelopes. Malformed, unrelated,
131
206
  * or non-402 responses retain their existing command-specific handling.
132
207
  */
133
208
  export async function planGateErrorFromResponse(response, companyUid) {
@@ -83,6 +83,8 @@ export type SelfUpdateAction =
83
83
  | "update-ineffective"
84
84
  /** Updated and the command re-ran on the new version; exit with `reexecStatus`. */
85
85
  | "reexec"
86
+ /** Updated with no re-exec (the `hq daemon` updater restarts the daemon instead). */
87
+ | "updated"
86
88
  /**
87
89
  * Unattended caller: replacing the CLI in place is unsafe here, so nothing
88
90
  * was attempted and the command runs on the current version. An attended
@@ -171,4 +173,11 @@ export declare function autoUpdateAndReexec(argv: readonly string[], latest: str
171
173
  * are preserved.
172
174
  */
173
175
  export declare function selfUpdateAndReexec(argv: readonly string[], deps?: SelfUpdateDeps): Promise<SelfUpdateOutcome>;
176
+ /**
177
+ * `hq daemon` updater service: update to npm latest with nobody at a terminal
178
+ * and no re-exec. It runs as its own short-lived child of the daemon, so no
179
+ * user command is waiting on the package being replaced; after an "updated"
180
+ * outcome the daemon restarts itself onto the new version.
181
+ */
182
+ export declare function daemonSelfUpdate(deps?: SelfUpdateDeps): Promise<SelfUpdateOutcome>;
174
183
  //# sourceMappingURL=self-update.d.ts.map
@@ -292,6 +292,10 @@ async function updateAndReexec(argv, flavor, known, deps) {
292
292
  catch {
293
293
  // Defensive only; the default helper already absorbs manager failures.
294
294
  }
295
+ if (!flavor.reexec) {
296
+ console.error(chalk.dim(`✓ hq-cli updated to ${latest}.`));
297
+ return { action: "updated", latest };
298
+ }
295
299
  const childEnv = { ...env, [REEXEC_GUARD_ENV]: "1" };
296
300
  const status = (deps.reexec ?? reexecHq)([...argv.slice(2)], childEnv);
297
301
  if (status === null) {
@@ -309,7 +313,7 @@ async function updateAndReexec(argv, flavor, known, deps) {
309
313
  * this resolve it from the registry.
310
314
  */
311
315
  export async function autoUpdateAndReexec(argv, latest, deps = {}) {
312
- return updateAndReexec(argv, { noun: "command", verbose: false, lock: true, onlyWhenAttended: true }, latest, deps);
316
+ return updateAndReexec(argv, { noun: "command", verbose: false, lock: true, onlyWhenAttended: true, reexec: true }, latest, deps);
313
317
  }
314
318
  /**
315
319
  * Rescue path: update to npm latest and re-exec the rescue on the new version.
@@ -318,6 +322,15 @@ export async function autoUpdateAndReexec(argv, latest, deps = {}) {
318
322
  * are preserved.
319
323
  */
320
324
  export async function selfUpdateAndReexec(argv, deps = {}) {
321
- return updateAndReexec(argv, { noun: "rescue", verbose: true, lock: true, onlyWhenAttended: false }, null, deps);
325
+ return updateAndReexec(argv, { noun: "rescue", verbose: true, lock: true, onlyWhenAttended: false, reexec: true }, null, deps);
326
+ }
327
+ /**
328
+ * `hq daemon` updater service: update to npm latest with nobody at a terminal
329
+ * and no re-exec. It runs as its own short-lived child of the daemon, so no
330
+ * user command is waiting on the package being replaced; after an "updated"
331
+ * outcome the daemon restarts itself onto the new version.
332
+ */
333
+ export async function daemonSelfUpdate(deps = {}) {
334
+ return updateAndReexec([], { noun: "daemon update", verbose: false, lock: true, onlyWhenAttended: false, reexec: false }, null, deps);
322
335
  }
323
336
  //# sourceMappingURL=self-update.js.map
@@ -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.162.0",
3
+ "version": "5.164.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {