@indigoai-us/hq-cli 5.108.12 → 5.108.14

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,39 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.14] — 2026-09-07
6
+
7
+ ### Fixed
8
+
9
+ - An incomplete hq install tree no longer files an unactionable crash when hq
10
+ fails to load one of its OWN bundled modules in-process (HQ-CLI-1M, HQ-CLI-1N).
11
+ Two shapes shared one cause — hq-cli's globally installed package tree was not
12
+ intact at the moment a command loaded a module. In HQ-CLI-1N (`hq core
13
+ timeout-guard` on Windows) a partial `npm i -g` left a RELATIVE sibling
14
+ unwritten deep inside a bundled dependency, so Node threw
15
+ `MODULE_NOT_FOUND`. In HQ-CLI-1M (`hq rescue` on Linux) a concurrent global
16
+ install rewrote the running tree, so an ESM module that existed at resolve was
17
+ gone at read and Node's loader raised `ENOENT` — the loader validates
18
+ existence at resolve, so an ENOENT at LOAD proves the file vanished mid-run
19
+ rather than being merely absent. Both carried no hq-cli frames, reached the
20
+ top-level handler's final `else`, and produced a bare Sentry crash plus an
21
+ `hq:` line the operator could not act on. A new in-process classifier now
22
+ recognises both shapes — but ONLY when the failing file sits under the running
23
+ install's own `node_modules/`, the CJS shape additionally requires a relative
24
+ specifier, and the ESM shape additionally requires an esm-loader frame — and
25
+ prints an input-free reinstall remedy (run the command again first, then
26
+ `npm i -g @indigoai-us/hq-cli` / `pnpm add -g @indigoai-us/hq-cli`) while
27
+ skipping Sentry capture, the same disposition established for the qmd child in
28
+ HQ-CLI-Y. An hq-cli packaging fault stays reportable: a miss under the
29
+ package's own `dist/` or `assets/`, a bare-specifier miss (a possible
30
+ undeclared dependency), and an esm-loader ENOENT whose path did not survive
31
+ delivery are NOT suppressed — the last is captured WITH a bounded
32
+ `incomplete_install` context so the next occurrence is attributable. The drop
33
+ is wired both at the top-level boundary and in the shared `beforeSend`, so it
34
+ covers every capture route.
35
+
36
+ ## [5.108.13] — 2026-09-06
37
+
5
38
  ## [5.108.12] - 2026-09-05
6
39
 
7
40
  ### Fixed
@@ -110,9 +110,20 @@ export interface ProfilePatch {
110
110
  description?: string;
111
111
  }
112
112
  export interface RuntimeConfigPatch {
113
+ /**
114
+ * Brain/runtime provider. When present the server routes the PATCH to a
115
+ * PROVIDER MIGRATION (handleProviderMigration): it TERMINATES and reprovisions
116
+ * the box and changes the provider/auth contract. Omit for a plain tuning
117
+ * patch (model / effort / tier).
118
+ */
119
+ provider?: string;
113
120
  codexModel?: string;
114
121
  codexReasoningEffort?: string;
115
122
  codexServiceTier?: string;
123
+ /** Provider-migration auth mode: "subscription" | "apiKey". */
124
+ codexAuthMode?: string;
125
+ /** Provider-migration apiKey-mode vault key reference (never a raw secret). */
126
+ codexApiKeyRef?: string;
116
127
  }
117
128
  /**
118
129
  * Authenticated JSON round-trip against the agents control plane. Throws
@@ -143,7 +154,12 @@ export interface ProvisionAgentInput {
143
154
  /** Server quote assertion; hq-pro re-prices and refuses a stale amount. */
144
155
  quotedNetMonthlyCents?: number;
145
156
  quoteCatalogVersion?: string;
157
+ /** Funnel attribution: which client surface made the attempt. */
158
+ surface?: AgentCreateSurface;
146
159
  }
160
+ /** Closed set shared with hq-pro's agent_create_* funnel contract. */
161
+ export declare const CLI_AGENT_CREATE_SURFACE: "cli_agents_create";
162
+ export type AgentCreateSurface = typeof CLI_AGENT_CREATE_SURFACE;
147
163
  export interface AgentCreateSizeOption {
148
164
  key: "basic" | "power" | "dev";
149
165
  productName: string;
@@ -146,6 +146,8 @@ export function slugifyAgentName(name) {
146
146
  .replace(/[^a-z0-9]+/g, "-")
147
147
  .replace(/^-+|-+$/g, "");
148
148
  }
149
+ /** Closed set shared with hq-pro's agent_create_* funnel contract. */
150
+ export const CLI_AGENT_CREATE_SURFACE = "cli_agents_create";
149
151
  /** Read hq-pro's company-specific creation prices and capacities. */
150
152
  export async function getAgentCreateOptions(token, companyUid, idempotencyKey) {
151
153
  const raw = await agentsRequest({
@@ -153,7 +155,9 @@ export async function getAgentCreateOptions(token, companyUid, idempotencyKey) {
153
155
  path: "/v1/agents/provision-options",
154
156
  query: {
155
157
  companyUid,
156
- ...(idempotencyKey ? { idempotencyKey } : {}),
158
+ ...(idempotencyKey
159
+ ? { idempotencyKey, surface: CLI_AGENT_CREATE_SURFACE }
160
+ : {}),
157
161
  },
158
162
  });
159
163
  if (!raw || typeof raw !== "object") {
@@ -842,6 +846,7 @@ export function registerAgentsCommand(program) {
842
846
  : { desiredInstanceType: quotedSize.instanceType }),
843
847
  quotedNetMonthlyCents: quotedSize.netMonthlyCents,
844
848
  quoteCatalogVersion: createOptions.catalogVersion,
849
+ surface: CLI_AGENT_CREATE_SURFACE,
845
850
  });
846
851
  const uid = typeof result.uid === "string" ? result.uid : slug;
847
852
  console.log(chalk.green(`Provisioning started for agent "${name}".`));
@@ -999,15 +1004,54 @@ export function registerAgentsCommand(program) {
999
1004
  });
1000
1005
  agents
1001
1006
  .command("config <agentUid>")
1002
- .description("Update an agent's runtime config (model / reasoning effort / service tier)")
1007
+ .description("Update an agent's runtime config (model / reasoning effort / service tier), or migrate its brain/runtime provider with --provider (DESTRUCTIVE: terminates + reprovisions the box)")
1003
1008
  .option("--company <slug>", "Company slug (resolves to companyUid)")
1004
1009
  .option("--model <model>", "Codex model id")
1005
1010
  .option("--effort <effort>", "Reasoning effort: minimal | low | medium | high | xhigh")
1006
1011
  .option("--tier <tier>", "Service tier (speed): default | priority")
1012
+ .option("--provider <provider>", "Migrate brain/runtime provider (codex | grok | claude | agents-v2). DESTRUCTIVE: terminates and reprovisions the box; requires --model and --yes.")
1013
+ .option("--auth-mode <mode>", "Provider-migration auth mode: subscription | apiKey (default: keep current)")
1014
+ .option("--api-key-ref <ref>", "Provider-migration apiKey-mode vault key reference (never a raw key)")
1015
+ .option("--yes", "Confirm a destructive provider migration (required with --provider)")
1007
1016
  .option("--json", "Emit raw JSON")
1008
1017
  .action(async function (agentUid, opts) {
1009
1018
  try {
1010
1019
  const patch = {};
1020
+ if (opts.provider !== undefined) {
1021
+ // A `provider` field routes the server to handleProviderMigration,
1022
+ // which TERMINATES and reprovisions the box. Guard it: valid provider,
1023
+ // an explicit target --model (the server requires it), and --yes.
1024
+ // Reuses the module-level VALID_PROVIDERS (shared with `provision`).
1025
+ const provider = opts.provider.trim().toLowerCase();
1026
+ if (!VALID_PROVIDERS.has(provider)) {
1027
+ console.error(chalk.red(`Invalid --provider '${opts.provider}': must be one of codex, grok, claude, agents-v2`));
1028
+ process.exit(1);
1029
+ }
1030
+ if (opts.model === undefined) {
1031
+ console.error(chalk.red("A provider migration requires --model (the target brain's model id)."));
1032
+ process.exit(1);
1033
+ }
1034
+ if (!opts.yes) {
1035
+ console.error(chalk.red(`Refusing to migrate agent ${agentUid} to provider '${provider}' without --yes.\n` +
1036
+ "This TERMINATES and reprovisions the box (irreversible) and changes the auth contract.\n" +
1037
+ "Re-run with --yes once you have confirmed the exact agent, company, and model."));
1038
+ process.exit(1);
1039
+ }
1040
+ if (provider !== "agents-v2") {
1041
+ // v1 RESIDENT runtime guard: codex|grok|claude migrate the agent OFF
1042
+ // agents-v2 onto the legacy resident runtime. Fleet boxes run
1043
+ // agents-v2 (brain derived from the model), so this is almost always
1044
+ // a mistake — warn loudly but proceed (the operator passed --yes).
1045
+ console.warn(chalk.yellow(`Warning: --provider ${provider} targets the V1 RESIDENT runtime, not agents-v2.\n` +
1046
+ "Fleet boxes run agents-v2. To keep this agent on the v2 runtime with a\n" +
1047
+ `${provider} brain, use: --provider agents-v2 --model <${provider} model id>`));
1048
+ }
1049
+ patch.provider = provider;
1050
+ if (opts.authMode !== undefined)
1051
+ patch.codexAuthMode = opts.authMode;
1052
+ if (opts.apiKeyRef !== undefined)
1053
+ patch.codexApiKeyRef = opts.apiKeyRef;
1054
+ }
1011
1055
  if (opts.model !== undefined)
1012
1056
  patch.codexModel = opts.model;
1013
1057
  if (opts.effort !== undefined) {
@@ -52,14 +52,30 @@ export declare function mapSkillError(status: number, body: Record<string, unkno
52
52
  * 403, 404, 409, 429 -> ExpectedUserError (HQ-CLI-6: printed, exit 1, not captured)
53
53
  * everything else -> unmarked Error (still captured to Sentry)
54
54
  *
55
- * The 4xx allowlist is closed and ENUMERATED, not a `status < 500` range: a 400
56
- * or 422 means the CLI itself built a malformed request, and every 5xx is a real
57
- * server fault both must keep reaching Sentry so a genuine defect is never
58
- * hidden behind a "caller error" label (HQ-CLI-Z, Sentry 7694457056). Before
59
- * this, both call sites threw a bare `new Error(...)`, so a correctly-denied 403
60
- * (e.g. an agent principal with no person entity) fell through the boundary's
61
- * closed allowlist and filed a crash report for what hq-pro itself deliberately
62
- * exempts from Sentry capture.
55
+ * The 4xx allowlist is closed and ENUMERATED, not a `status < 500` range, and
56
+ * every 5xx is a real server fault that must keep reaching Sentry so a genuine
57
+ * defect is never hidden behind a "caller error" label (HQ-CLI-Z, Sentry
58
+ * 7694457056). Before this, both call sites threw a bare `new Error(...)`, so a
59
+ * correctly-denied 403 (e.g. an agent principal with no person entity) fell
60
+ * through the boundary's closed allowlist and filed a crash for what hq-pro
61
+ * itself deliberately exempts from Sentry capture.
62
+ *
63
+ * A 400 is DISCRIMINATED BY ITS MACHINE-READABLE `code`, never by status alone
64
+ * and never by the prose (Sentry 7710011866). On the register route the
65
+ * observed 400s reject the CALLER'S OWN SKILL.md CONTENT
66
+ * (`SKILL_REGISTER_FRONTMATTER_INVALID`, `SKILL_REGISTER_TOO_LARGE`, …) — the
67
+ * caller fixes the file and retries; there is no hq-cli defect and no operator
68
+ * action — and hq-pro returns each of those through its NON-capturing
69
+ * `expectedValidationResponse` builder, so hq-cli was the only party filing a
70
+ * crash for them. Those codes (see `CALLER_CONTENT_REGISTER_400_CODES`) are
71
+ * marked `expected`. A 400 whose `code` is absent, non-string, or unlisted —
72
+ * including `SKILL_REGISTER_PATH_INVALID`, the ONE register field hq-cli builds
73
+ * itself (`path: skills/<slug>/SKILL.md`) — stays an unmarked, captured Error,
74
+ * as does every 422: an envelope the CLI built wrong is a genuine hq-cli defect.
75
+ * Client-side suppression cannot blind a real fault: hq-pro retains its
76
+ * capturing `response()` builder for every register 400 it still considers a
77
+ * fault, so this only stops hq-cli from double-filing what the server already
78
+ * exempts.
63
79
  *
64
80
  * `opts.machineIdentity` distinguishes a company agent (`isMachineIdentity()`).
65
81
  * A machine session mints automatically and is never repaired by `hq login`, so
@@ -18,6 +18,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
18
18
  import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
19
19
  import { AuthError } from "../utils/auth-error.js";
20
20
  import { redactErrorText } from "../utils/redact-error-text.js";
21
+ import { stampVaultAccessDenied, vaultAccessDeniedDiagnostics, } from "../utils/vault-access-denied-error.js";
21
22
  export const SKILL_UID_PATTERN = /^skl_[A-Za-z0-9]+$/;
22
23
  export const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
23
24
  const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
@@ -208,6 +209,36 @@ export function mapSkillError(status, body) {
208
209
  return `Server error: ${server || status}`;
209
210
  return server || `Request failed (${status})`;
210
211
  }
212
+ /**
213
+ * The CLOSED, ENUMERATED set of skills-API register `code` values whose 400
214
+ * refuses the CALLER-AUTHORED SKILL.md CONTENT rather than the CLI-built request
215
+ * envelope. hq-pro returns each of these through its NON-capturing
216
+ * `expectedValidationResponse` builder (hq-pro src/vault-service/write-routes.ts)
217
+ * — a deliberate, correctly-handled client 400 where the caller fixes the file
218
+ * and retries and there is no operator action:
219
+ *
220
+ * SKILL_REGISTER_FRONTMATTER_INVALID `SKILL.md must contain valid YAML frontmatter`
221
+ * SKILL_REGISTER_TOO_LARGE `Skill content exceeds <N> bytes`
222
+ * SKILL_REGISTER_CONTENT_INVALID malformed skill body / structure
223
+ * SKILL_REGISTER_METADATA_INVALID bad frontmatter metadata (e.g. description)
224
+ * SKILL_REGISTER_GOVERNANCE_INVALID `normalizeSkillTags` rejected the caller's tags
225
+ *
226
+ * DELIBERATELY EXCLUDES `SKILL_REGISTER_PATH_INVALID`: the `path` field is built
227
+ * by hq-cli (`skills/${slug}/SKILL.md`), so a rejection there is a genuine
228
+ * hq-cli defect and must keep reaching Sentry. The two evidenced codes are the
229
+ * first two; the rest are the register route's other caller-content validators,
230
+ * covered so a content refusal never files a crash. Client suppression is safe
231
+ * either way — hq-pro still captures every register 400 it classifies as a
232
+ * fault through its own `response()` builder, so narrowing this set to the two
233
+ * evidenced codes would only re-expose siblings the server already exempts.
234
+ */
235
+ const CALLER_CONTENT_REGISTER_400_CODES = new Set([
236
+ "SKILL_REGISTER_FRONTMATTER_INVALID",
237
+ "SKILL_REGISTER_TOO_LARGE",
238
+ "SKILL_REGISTER_CONTENT_INVALID",
239
+ "SKILL_REGISTER_METADATA_INVALID",
240
+ "SKILL_REGISTER_GOVERNANCE_INVALID",
241
+ ]);
211
242
  /**
212
243
  * Type a failed skills-API response by its HTTP status so the top-level error
213
244
  * boundary can tell a correctly-denied client 4xx (ordinary caller state) from a
@@ -220,14 +251,30 @@ export function mapSkillError(status, body) {
220
251
  * 403, 404, 409, 429 -> ExpectedUserError (HQ-CLI-6: printed, exit 1, not captured)
221
252
  * everything else -> unmarked Error (still captured to Sentry)
222
253
  *
223
- * The 4xx allowlist is closed and ENUMERATED, not a `status < 500` range: a 400
224
- * or 422 means the CLI itself built a malformed request, and every 5xx is a real
225
- * server fault both must keep reaching Sentry so a genuine defect is never
226
- * hidden behind a "caller error" label (HQ-CLI-Z, Sentry 7694457056). Before
227
- * this, both call sites threw a bare `new Error(...)`, so a correctly-denied 403
228
- * (e.g. an agent principal with no person entity) fell through the boundary's
229
- * closed allowlist and filed a crash report for what hq-pro itself deliberately
230
- * exempts from Sentry capture.
254
+ * The 4xx allowlist is closed and ENUMERATED, not a `status < 500` range, and
255
+ * every 5xx is a real server fault that must keep reaching Sentry so a genuine
256
+ * defect is never hidden behind a "caller error" label (HQ-CLI-Z, Sentry
257
+ * 7694457056). Before this, both call sites threw a bare `new Error(...)`, so a
258
+ * correctly-denied 403 (e.g. an agent principal with no person entity) fell
259
+ * through the boundary's closed allowlist and filed a crash for what hq-pro
260
+ * itself deliberately exempts from Sentry capture.
261
+ *
262
+ * A 400 is DISCRIMINATED BY ITS MACHINE-READABLE `code`, never by status alone
263
+ * and never by the prose (Sentry 7710011866). On the register route the
264
+ * observed 400s reject the CALLER'S OWN SKILL.md CONTENT
265
+ * (`SKILL_REGISTER_FRONTMATTER_INVALID`, `SKILL_REGISTER_TOO_LARGE`, …) — the
266
+ * caller fixes the file and retries; there is no hq-cli defect and no operator
267
+ * action — and hq-pro returns each of those through its NON-capturing
268
+ * `expectedValidationResponse` builder, so hq-cli was the only party filing a
269
+ * crash for them. Those codes (see `CALLER_CONTENT_REGISTER_400_CODES`) are
270
+ * marked `expected`. A 400 whose `code` is absent, non-string, or unlisted —
271
+ * including `SKILL_REGISTER_PATH_INVALID`, the ONE register field hq-cli builds
272
+ * itself (`path: skills/<slug>/SKILL.md`) — stays an unmarked, captured Error,
273
+ * as does every 422: an envelope the CLI built wrong is a genuine hq-cli defect.
274
+ * Client-side suppression cannot blind a real fault: hq-pro retains its
275
+ * capturing `response()` builder for every register 400 it still considers a
276
+ * fault, so this only stops hq-cli from double-filing what the server already
277
+ * exempts.
231
278
  *
232
279
  * `opts.machineIdentity` distinguishes a company agent (`isMachineIdentity()`).
233
280
  * A machine session mints automatically and is never repaired by `hq login`, so
@@ -255,6 +302,19 @@ export function skillApiError(status, body, opts = {}) {
255
302
  if (status === 401) {
256
303
  return opts.machineIdentity ? new Error(message) : new AuthError(message);
257
304
  }
305
+ if (status === 400 &&
306
+ typeof body.code === "string" &&
307
+ CALLER_CONTENT_REGISTER_400_CODES.has(body.code)) {
308
+ // A register 400 refusing the caller's own SKILL.md content — expected
309
+ // caller state hq-pro itself does not capture (see the code allowlist
310
+ // above). The message is the UNCHANGED `redactErrorText(mapSkillError(...))`
311
+ // remedy; only the class changes. A 400 with an absent, non-string, or
312
+ // unlisted `code` (including the CLI-built `SKILL_REGISTER_PATH_INVALID`)
313
+ // falls through to the unmarked, captured Error below.
314
+ return Object.assign(new Error(message), {
315
+ expected: true,
316
+ });
317
+ }
258
318
  if (status === 403 || status === 404 || status === 409 || status === 429) {
259
319
  return Object.assign(new Error(message), {
260
320
  expected: true,
@@ -387,6 +447,23 @@ export function registerSkillCommand(program, deps = {}) {
387
447
  // sync failure exactly as it does everywhere else, and a genuine
388
448
  // fault is captured under its own type and stack.
389
449
  console.warn(chalk.yellow(`⚠ Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync failed.`));
450
+ // ARM B (Sentry 7709408531): when the upload was DENIED by S3 (an
451
+ // AWS SDK v3 403 from a truncated IAM session policy that dropped the
452
+ // skills/ prefix), stamp bounded, hq-derived diagnostics onto the
453
+ // ORIGINAL error so the boundary can print an attributable remedy and
454
+ // capture WITH a bounded context. This is additive only: a
455
+ // non-enumerable field, no new Error, no change to `message`/`name`/
456
+ // `cause` — rewrapping is the exact HQ-CLI-14 defect this block
457
+ // prevents. A non-403 sync failure is left untouched and captures (or
458
+ // is suppressed) under its own type exactly as before.
459
+ if (err && typeof err === "object") {
460
+ const diagnostics = vaultAccessDeniedDiagnostics(err, {
461
+ companySlug,
462
+ objectKey: `skills/${slug}/SKILL.md`,
463
+ });
464
+ if (diagnostics)
465
+ stampVaultAccessDenied(err, diagnostics);
466
+ }
390
467
  throw err;
391
468
  }
392
469
  if (syncResult.aborted) {
package/dist/main.js CHANGED
@@ -23,6 +23,7 @@ import { qmdQueryDocumentMessage } from "./utils/qmd-query-document-error.js";
23
23
  import { qmdModelDownloadMessage } from "./utils/qmd-model-download-error.js";
24
24
  import { qmdWorkdirMissingMessage } from "./utils/qmd-workdir-missing-error.js";
25
25
  import { hqStateWriteErrorMessage } from "./utils/hq-state-write-error.js";
26
+ import { incompleteInstallMessage, incompleteInstallCaptureContext, } from "./utils/incomplete-install-error.js";
26
27
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
27
28
  import { isVarlockEnvError } from "./run/env-graph-guard.js";
28
29
  import { isEpipe } from "./utils/epipe.js";
@@ -45,6 +46,7 @@ import { settleWithin } from "./utils/settle-with-timeout.js";
45
46
  import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
46
47
  import { kickFlagRegistryReadiness } from "./lib/flag-registry.js";
47
48
  import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
49
+ import { isVaultAccessDeniedError, vaultAccessDeniedMessage, } from "./utils/vault-access-denied-error.js";
48
50
  import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
49
51
  /** Hard upper bound for non-user-visible release-health finalization. */
50
52
  const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
@@ -335,6 +337,27 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
335
337
  });
336
338
  deps.setExitCode(1);
337
339
  }
340
+ else if (isVaultAccessDeniedError(err)) {
341
+ // ARM B (Sentry 7709408531): `hq skill create`'s post-register vault sync
342
+ // was DENIED by S3 — an AWS SDK v3 403 from an hq-pro IAM session-policy
343
+ // truncation that dropped the skills/ prefix. Because the HEAD carried no
344
+ // body, the SDK minted an untyped `Unknown http=403 UnknownError` with a
345
+ // system-only stack, so it reached the final else, printed
346
+ // `hq: Unknown: Unknown http=403 UnknownError`, and captured an
347
+ // un-attributable event. skill.ts stamped bounded, hq-derived diagnostics
348
+ // onto the error at the failure site; print the attributable remedy naming
349
+ // the object and company, and STILL capture WITH that bounded context —
350
+ // this is a real hq-pro platform fault that must stay REPORTED and become
351
+ // actionable, never silenced. Mirrors the package-root branch's
352
+ // print-and-capture shape. Keyed on hq-cli's own stamped field, so it is
353
+ // disjoint from every neighbour and changes no existing ordering.
354
+ const { vaultAccessDenied } = err;
355
+ deps.stderr.write(`hq: ${vaultAccessDeniedMessage(vaultAccessDenied)}\n`);
356
+ deps.sentry.captureException(err, {
357
+ contexts: { vault_access_denied: vaultAccessDenied },
358
+ });
359
+ deps.setExitCode(1);
360
+ }
338
361
  else {
339
362
  // A full disk / exhausted quota / read-only filesystem is the user's
340
363
  // machine, not an HQ code defect. Surface a clear, actionable message and
@@ -493,7 +516,28 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
493
516
  const stateWriteMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg
494
517
  ? null
495
518
  : hqStateWriteErrorMessage(err);
496
- const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg
519
+ // An IN-PROCESS module-load failure that means hq-cli's OWN installed
520
+ // package tree is incomplete at load time — a partial/interrupted global
521
+ // install left a bundled file unwritten (HQ-CLI-1N, a CJS relative-sibling
522
+ // MODULE_NOT_FOUND), or a concurrent global install rewrote the running
523
+ // tree so an ESM module present at resolve was gone at read (HQ-CLI-1M, an
524
+ // esm-loader ENOENT). Both carry no hq-cli frames and reached the final
525
+ // else, filing a bare crash and an unactionable line. An incomplete
526
+ // install is the caller's machine, the disposition HQ-CLI-Y already
527
+ // established for the qmd CHILD — print the input-free reinstall remedy and
528
+ // skip capture. Placed with the environmental family (after the typed qmd
529
+ // carriers and the hq state-write carrier, before environmentalFsErrorMessage):
530
+ // the signatures are disjoint — ENVIRONMENTAL_FS_CODES is only
531
+ // ENOSPC/EDQUOT/EROFS (never ENOENT/MODULE_NOT_FOUND), no qmd carrier sets
532
+ // requireStack or an esm-loader frame, and the classified file must sit
533
+ // under <packageRoot>/node_modules — so ordering changes nothing that
534
+ // exists. The UNATTRIBUTABLE shape (an esm-loader ENOENT whose path did not
535
+ // survive) is deliberately NOT suppressed; it is captured WITH bounded
536
+ // context on the generic path below.
537
+ const incompleteInstallMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg
538
+ ? null
539
+ : incompleteInstallMessage(err);
540
+ const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg || incompleteInstallMsg
497
541
  ? null
498
542
  : environmentalFsErrorMessage(err);
499
543
  // A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
@@ -510,7 +554,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
510
554
  // environmental-fs check, before network-transport — is pinned by tests.
511
555
  // The `in-process-async-holder` reason is deliberately NOT suppressed here
512
556
  // (see sync-state-lock-error.ts); it stays captured.
513
- const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg || envMsg
557
+ const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg || incompleteInstallMsg || envMsg
514
558
  ? null
515
559
  : syncStateLockMessage(err);
516
560
  // A raw network transport failure (undici's `TypeError: fetch failed`
@@ -534,6 +578,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
534
578
  modelDownloadMsg ||
535
579
  workdirMissingMsg ||
536
580
  stateWriteMsg ||
581
+ incompleteInstallMsg ||
537
582
  envMsg ||
538
583
  lockMsg
539
584
  ? null
@@ -571,6 +616,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
571
616
  else if (stateWriteMsg) {
572
617
  deps.stderr.write(`hq: ${stateWriteMsg}\n`);
573
618
  }
619
+ else if (incompleteInstallMsg) {
620
+ deps.stderr.write(`hq: ${incompleteInstallMsg}\n`);
621
+ }
574
622
  else if (envMsg) {
575
623
  deps.stderr.write(`hq: ${envMsg}\n`);
576
624
  }
@@ -581,13 +629,18 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
581
629
  deps.stderr.write(`hq: ${transportMsg}\n`);
582
630
  }
583
631
  else {
584
- // A genuinely unclassified fault is still captured exactly once. When it
585
- // is a qmd spawn-level failure the fix could not attribute, attach the
586
- // bounded existence context so the next occurrence carries the evidence
587
- // the reported HQ-CLI-1A event lacked; every other error captures bare.
632
+ // A genuinely unclassified fault is still captured exactly once. Two
633
+ // shapes attach bounded, hq-derived context so the next occurrence
634
+ // carries the evidence this one lacked: a qmd spawn-level failure the fix
635
+ // could not attribute (HQ-CLI-1A), and an esm-loader ENOENT whose path
636
+ // did not survive delivery (HQ-CLI-1M — the unattributable incomplete-
637
+ // install shape). Every other error captures bare, exactly as before.
588
638
  const spawnContext = qmdSpawnFailureCaptureContext(err);
589
- if (spawnContext) {
590
- deps.sentry.captureException(err, spawnContext);
639
+ const installContext = incompleteInstallCaptureContext(err);
640
+ if (spawnContext || installContext) {
641
+ deps.sentry.captureException(err, {
642
+ contexts: { ...spawnContext?.contexts, ...installContext },
643
+ });
591
644
  }
592
645
  else {
593
646
  deps.sentry.captureException(err);
package/dist/sentry.js CHANGED
@@ -6,6 +6,7 @@ import { CLI_VERSION } from "./cli-version.js";
6
6
  import { getCachedSentryUser } from "./utils/sentry-identity.js";
7
7
  import { isEpipe } from "./utils/epipe.js";
8
8
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
9
+ import { incompleteInstallMessage } from "./utils/incomplete-install-error.js";
9
10
  import { sentryFingerprintFor } from "./utils/sentry-fingerprint.js";
10
11
  /**
11
12
  * Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
@@ -31,6 +32,20 @@ export function epipeAwareBeforeSend(event, hint) {
31
32
  // route, while the CLI still exits non-zero. HQ-CLI-R (Sentry 7671416365).
32
33
  if (environmentalFsErrorMessage(hint?.originalException))
33
34
  return null;
35
+ // Path-independent belt for an IN-PROCESS incomplete-install module-load
36
+ // failure — hq-cli's own globally installed tree is not intact at load time
37
+ // (HQ-CLI-1N, a CJS relative-sibling MODULE_NOT_FOUND under its bundled
38
+ // node_modules; HQ-CLI-1M, an esm-loader ENOENT for a file present at resolve
39
+ // and gone at read). handleTopLevelError already prints the reinstall remedy
40
+ // for the top-level route; dropping the event here suppresses the fatal
41
+ // regardless of route — the unhandled-rejection boundary, the command-level
42
+ // captureException sites, and bin/hq-auth-refresh — mirroring the EPIPE and
43
+ // environmental-fs drops above. The classifier reads only structured fields
44
+ // and the failing file must sit under the running install's node_modules, so
45
+ // an hq-cli packaging fault (a dist/ miss, a bare-specifier miss) and the
46
+ // path-less unattributable shape are NOT dropped here and stay captured.
47
+ if (incompleteInstallMessage(hint?.originalException))
48
+ return null;
34
49
  // Group an event that survives to send by a BOUNDED machine discriminator so
35
50
  // unrelated gateway/HTTP failures stop colliding into one fungible issue
36
51
  // (HQ-CLI collision, Sentry 7642756130). Placed here — path-independent,
@@ -0,0 +1,50 @@
1
+ import * as fs from "fs";
2
+ /**
3
+ * The actionable remedy shown to the operator. Input-free — nothing from the
4
+ * error, the argv, or the filesystem is interpolated — so there is no injection
5
+ * surface and no way to inflate Sentry grouping, matching the bounded-remedy
6
+ * discipline of every sibling classifier. Covers BOTH sub-cases in the order a
7
+ * user should try them: re-run first (an install that finished mid-run leaves
8
+ * the next invocation healthy), then reinstall if it persists.
9
+ */
10
+ export declare const INCOMPLETE_INSTALL_REMEDY: string;
11
+ /** A resolver for the running install's root; returns null instead of throwing. */
12
+ export type PackageRootResolver = () => string | null;
13
+ /**
14
+ * If `err` is an in-process incomplete-install module-load failure — either the
15
+ * CJS relative-sibling shape (HQ-CLI-1N) or the ESM vanished-file shape
16
+ * (HQ-CLI-1M), with the failing file confirmed under `<packageRoot>/node_modules/`
17
+ * — return the actionable, input-free reinstall remedy; otherwise return null.
18
+ *
19
+ * Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
20
+ * the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
21
+ * means "handle as usual (capture to Sentry)". Never throws — a resolver that
22
+ * fails yields null.
23
+ */
24
+ export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver): string | null;
25
+ /** Bounded, scrubber-safe diagnostics for an unattributable esm-loader ENOENT. */
26
+ export type IncompleteInstallDiagnostics = {
27
+ packageRoot: string;
28
+ packageJsonExists: boolean;
29
+ nodeModulesExists: boolean;
30
+ esmLoaderFrame: boolean;
31
+ code: string;
32
+ };
33
+ /**
34
+ * When an esm-loader ENOENT reaches the capture path WITHOUT being suppressed —
35
+ * the exact shape the delivered HQ-CLI-1M payload arrived in, where neither the
36
+ * exception value nor node_system_error carried a `path` — return a bounded
37
+ * `contexts.incomplete_install` block so the next occurrence carries the
38
+ * evidence this one lacked; otherwise return undefined (bare capture). Built
39
+ * with the byte-capped, scrubber-safe discipline of package-root-diagnostics.ts:
40
+ * the resolved package root and whether its package.json / node_modules exist,
41
+ * the loader-frame marker, and the bounded errno code — never a caller argv,
42
+ * query, or user-minted value. Never throws.
43
+ *
44
+ * main.ts attaches this on the generic capture path exactly as
45
+ * qmdSpawnFailureCaptureContext already does.
46
+ */
47
+ export declare function incompleteInstallCaptureContext(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "existsSync">): {
48
+ incomplete_install: IncompleteInstallDiagnostics;
49
+ } | undefined;
50
+ //# sourceMappingURL=incomplete-install-error.d.ts.map
@@ -0,0 +1,250 @@
1
+ // src/utils/incomplete-install-error.ts
2
+ //
3
+ // Classify an IN-PROCESS module-load failure that means hq-cli's OWN globally
4
+ // installed package tree is not intact at the moment it loads a module — the
5
+ // caller's incomplete install, not an hq-cli code defect. Sibling in spirit to
6
+ // qmd-module-missing-error.ts (HQ-CLI-Y), but keyed on the ERROR OBJECT of a
7
+ // failure inside THIS process rather than a qmd child's captured stderr, which
8
+ // is the gap HQ-CLI-Y's classifier cannot cover.
9
+ //
10
+ // Two shapes, one cause — a partial/interrupted global install left a file
11
+ // unwritten, or a concurrent global install rewrote the running tree
12
+ // underneath a command:
13
+ //
14
+ // HQ-CLI-1N (Sentry 7714890525) — CJS, in-process. `hq core timeout-guard`
15
+ // loads the mesh presence client, whose `import mqtt` pulls a chain that ends
16
+ // at js-sdsl requiring a RELATIVE sibling (`./Base/TreeIterator`) that is
17
+ // absent on disk inside hq-cli's own bundled node_modules. Node throws
18
+ // `Error{ code: 'MODULE_NOT_FOUND', requireStack: [...] }`. A relative
19
+ // specifier internal to a third-party package can only be a truncated on-disk
20
+ // copy, never an hq-cli manifest defect.
21
+ //
22
+ // HQ-CLI-1M (Sentry 7714870912) — ESM load, in-process. A module that existed
23
+ // at RESOLVE was gone at READ (a concurrent writer rewrote the install tree),
24
+ // so Node's ESM loader raised `ENOENT` from getSourceSync/readFileSync/openSync
25
+ // with an `esm/…` loader frame in the stack. A merely-absent ESM module raises
26
+ // ERR_MODULE_NOT_FOUND at resolve, never ENOENT at load; an ENOENT at load
27
+ // proves the file vanished between resolve and read.
28
+ //
29
+ // Both shapes carry no hq-cli frames, reach the boundary's final `else`, and —
30
+ // before this classifier — filed a bare captureException plus an unactionable
31
+ // `hq: <fallback>` line. The disposition is the one HQ-CLI-Y already
32
+ // established: an incomplete install is the caller's machine, so the CLI prints
33
+ // an input-free reinstall remedy and skips Sentry capture.
34
+ //
35
+ // The gate is deliberately narrow so neither an hq-cli packaging fault nor
36
+ // user free-text can trip it. Only structured fields are read — `code`,
37
+ // `syscall`, `path`, `requireStack`, and the stack's loader-frame marker, plus
38
+ // the first message line for the CJS specifier. THREE independent narrowings
39
+ // keep a genuine hq-cli defect reportable:
40
+ // 1. The failing file must sit under `<packageRoot>/node_modules/` — a
41
+ // third-party file hq-cli does not author. A miss under `<packageRoot>/dist`
42
+ // or `/assets` is hq-cli's OWN shipped output and stays captured.
43
+ // 2. The CJS shape additionally requires a RELATIVE specifier — a
44
+ // bare-specifier miss (`Cannot find module 'mqtt'`) can be an undeclared
45
+ // dependency (an hq-cli manifest defect) and stays captured.
46
+ // 3. The ESM shape additionally requires an esm-loader frame — an ordinary
47
+ // `fs.readFileSync` ENOENT written by hq's own code stays captured.
48
+ import * as fs from "fs";
49
+ import * as path from "path";
50
+ import { packageRoot } from "./hq-roots.js";
51
+ import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
52
+ /**
53
+ * The actionable remedy shown to the operator. Input-free — nothing from the
54
+ * error, the argv, or the filesystem is interpolated — so there is no injection
55
+ * surface and no way to inflate Sentry grouping, matching the bounded-remedy
56
+ * discipline of every sibling classifier. Covers BOTH sub-cases in the order a
57
+ * user should try them: re-run first (an install that finished mid-run leaves
58
+ * the next invocation healthy), then reinstall if it persists.
59
+ */
60
+ export const INCOMPLETE_INSTALL_REMEDY = "hq couldn't load part of its own installed files, so the hq install tree is " +
61
+ "incomplete on this machine — most often because a global install (its own " +
62
+ "self-update, the desktop background installer, another hq process, or a " +
63
+ "hand-run install) rewrote the package while this command was running, or an " +
64
+ "earlier install was interrupted before every file was written. Run the " +
65
+ "command again first: an install that finished mid-run leaves the next " +
66
+ "invocation healthy. If it keeps failing, reinstall hq — for a global install " +
67
+ "run `npm i -g @indigoai-us/hq-cli` (or the pnpm equivalent, " +
68
+ "`pnpm add -g @indigoai-us/hq-cli`).";
69
+ /** A relative module specifier — `./x`, `../x`, `.\x`, `..\x`. */
70
+ const RELATIVE_SPECIFIER = /^\.\.?[\\/]/;
71
+ /** A Node ESM loader frame — proves the ENOENT came from the module loader, not hq's own fs call. */
72
+ const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]/;
73
+ const ROOT_DIAGNOSTIC_BYTES = 256;
74
+ const CODE_DIAGNOSTIC_BYTES = 32;
75
+ /**
76
+ * packageRoot() walks up from the compiled module and THROWS
77
+ * PackageRootResolutionError when it cannot resolve. This classifier runs inside
78
+ * beforeSend on EVERY event, so it must never throw — a resolution failure
79
+ * returns null and the error stays captured.
80
+ */
81
+ function safePackageRoot() {
82
+ try {
83
+ return packageRoot();
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** Call a (possibly injected) resolver without letting it throw. */
90
+ function resolveRootSafely(resolve) {
91
+ try {
92
+ return resolve();
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ /** Fold `\`/`/` runs to a single `/` and drop any trailing separator. */
99
+ function foldSeparators(p) {
100
+ return p.replace(/[\\/]+/g, "/").replace(/\/+$/, "");
101
+ }
102
+ /** A Windows-shaped absolute path (drive letter or UNC), regardless of host OS. */
103
+ function looksWin32(p) {
104
+ return /^[a-zA-Z]:[\\/]/.test(p) || /^\\\\/.test(p);
105
+ }
106
+ /**
107
+ * Normalise a path for prefix comparison: separators folded, and case folded
108
+ * ONLY for a Windows-shaped path (drive-letter case and AppData\Roaming casing
109
+ * drift there, but POSIX paths are case-sensitive and must stay so). Detecting
110
+ * win32 by the path's SHAPE — not `process.platform` — lets the reported
111
+ * Windows path classify on a Linux CI runner.
112
+ */
113
+ function normalizeForCompare(p) {
114
+ const folded = foldSeparators(p);
115
+ return looksWin32(p) ? folded.toLowerCase() : folded;
116
+ }
117
+ /**
118
+ * True when `candidate` lives under `<root>/node_modules/`. Anchored at a true
119
+ * directory boundary (`<root>` + sep + `node_modules` + sep) so a sibling such
120
+ * as `<root>-old/node_modules/...` can never match.
121
+ */
122
+ function isUnderNodeModules(candidate, root) {
123
+ if (!candidate || !root)
124
+ return false;
125
+ const prefix = `${normalizeForCompare(root)}/node_modules/`;
126
+ return normalizeForCompare(candidate).startsWith(prefix);
127
+ }
128
+ /** The failing specifier from a `Cannot find module '<spec>'` message, or null. */
129
+ function parseMissingSpecifier(message) {
130
+ if (typeof message !== "string")
131
+ return null;
132
+ const match = message.match(/Cannot find module ['"]([^'"]+)['"]/);
133
+ return match ? match[1] : null;
134
+ }
135
+ /** True when `stack` carries a Node ESM loader frame. */
136
+ function hasEsmLoaderFrame(stack) {
137
+ return typeof stack === "string" && ESM_LOADER_FRAME.test(stack);
138
+ }
139
+ /**
140
+ * The ESM-loader ENOENT SIGNATURE, independent of whether a usable `path`
141
+ * survived: `code === 'ENOENT'`, `syscall === 'open'`, and an esm-loader frame
142
+ * in the stack. This is the shape the instrumentation fallback attaches context
143
+ * to; the message classifier additionally requires a `path` under node_modules.
144
+ */
145
+ function isEsmLoaderEnoent(err) {
146
+ if (err === null || typeof err !== "object")
147
+ return false;
148
+ const record = err;
149
+ return (record.code === "ENOENT" &&
150
+ record.syscall === "open" &&
151
+ hasEsmLoaderFrame(record.stack));
152
+ }
153
+ /**
154
+ * If `err` is an in-process incomplete-install module-load failure — either the
155
+ * CJS relative-sibling shape (HQ-CLI-1N) or the ESM vanished-file shape
156
+ * (HQ-CLI-1M), with the failing file confirmed under `<packageRoot>/node_modules/`
157
+ * — return the actionable, input-free reinstall remedy; otherwise return null.
158
+ *
159
+ * Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
160
+ * the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
161
+ * means "handle as usual (capture to Sentry)". Never throws — a resolver that
162
+ * fails yields null.
163
+ */
164
+ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRoot) {
165
+ if (err === null || typeof err !== "object")
166
+ return null;
167
+ const record = err;
168
+ const code = typeof record.code === "string" ? record.code : undefined;
169
+ if (code !== "MODULE_NOT_FOUND" && code !== "ENOENT")
170
+ return null;
171
+ const root = resolveRootSafely(resolvePackageRoot);
172
+ if (!root)
173
+ return null;
174
+ if (code === "MODULE_NOT_FOUND") {
175
+ // Shape A (CJS, HQ-CLI-1N): a RELATIVE specifier internal to a package under
176
+ // the running install's node_modules can only be a truncated on-disk copy.
177
+ const requireStack = record.requireStack;
178
+ if (!Array.isArray(requireStack) || typeof requireStack[0] !== "string") {
179
+ return null;
180
+ }
181
+ const specifier = parseMissingSpecifier(record.message);
182
+ if (specifier === null || !RELATIVE_SPECIFIER.test(specifier))
183
+ return null;
184
+ return isUnderNodeModules(requireStack[0], root)
185
+ ? INCOMPLETE_INSTALL_REMEDY
186
+ : null;
187
+ }
188
+ // Shape B (ESM load, HQ-CLI-1M): an ENOENT from the module loader for a file
189
+ // that was present at resolve and gone at read.
190
+ if (record.syscall !== "open")
191
+ return null;
192
+ if (typeof record.path !== "string")
193
+ return null;
194
+ if (!hasEsmLoaderFrame(record.stack))
195
+ return null;
196
+ return isUnderNodeModules(record.path, root)
197
+ ? INCOMPLETE_INSTALL_REMEDY
198
+ : null;
199
+ }
200
+ /**
201
+ * When an esm-loader ENOENT reaches the capture path WITHOUT being suppressed —
202
+ * the exact shape the delivered HQ-CLI-1M payload arrived in, where neither the
203
+ * exception value nor node_system_error carried a `path` — return a bounded
204
+ * `contexts.incomplete_install` block so the next occurrence carries the
205
+ * evidence this one lacked; otherwise return undefined (bare capture). Built
206
+ * with the byte-capped, scrubber-safe discipline of package-root-diagnostics.ts:
207
+ * the resolved package root and whether its package.json / node_modules exist,
208
+ * the loader-frame marker, and the bounded errno code — never a caller argv,
209
+ * query, or user-minted value. Never throws.
210
+ *
211
+ * main.ts attaches this on the generic capture path exactly as
212
+ * qmdSpawnFailureCaptureContext already does.
213
+ */
214
+ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePackageRoot, fileSystem = fs) {
215
+ if (!isEsmLoaderEnoent(err))
216
+ return undefined;
217
+ // Only instrument what we did NOT already confidently suppress: a path under
218
+ // node_modules is classified and printed above, never captured.
219
+ if (incompleteInstallMessage(err, resolvePackageRoot) !== null)
220
+ return undefined;
221
+ const record = err;
222
+ const root = resolveRootSafely(resolvePackageRoot);
223
+ const code = typeof record.code === "string" ? record.code : "";
224
+ let packageJsonExists = false;
225
+ let nodeModulesExists = false;
226
+ if (root) {
227
+ try {
228
+ packageJsonExists = fileSystem.existsSync(path.join(root, "package.json"));
229
+ }
230
+ catch {
231
+ packageJsonExists = false;
232
+ }
233
+ try {
234
+ nodeModulesExists = fileSystem.existsSync(path.join(root, "node_modules"));
235
+ }
236
+ catch {
237
+ nodeModulesExists = false;
238
+ }
239
+ }
240
+ return {
241
+ incomplete_install: {
242
+ packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
243
+ packageJsonExists,
244
+ nodeModulesExists,
245
+ esmLoaderFrame: true,
246
+ code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
247
+ },
248
+ };
249
+ }
250
+ //# sourceMappingURL=incomplete-install-error.js.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The finite, hq-derived diagnostics for a denied vault object write. A `type`
3
+ * (not an `interface`) so it carries the implicit index signature Sentry's
4
+ * `Contexts` requires, exactly as `PackageRootResolutionDiagnostics` does.
5
+ */
6
+ export type VaultAccessDeniedDiagnostics = {
7
+ /** The only operation that stamps this today; a closed literal, not free text. */
8
+ operation: "skill-sync";
9
+ /** hq-derived, already `COMPANY_SLUG_PATTERN`-validated at the call site. */
10
+ companySlug: string;
11
+ /** hq-built vault key (`skills/<slug>/SKILL.md`); slug is pattern-validated. */
12
+ objectKey: string;
13
+ /** Always 403 here; kept explicit so the context self-describes. */
14
+ httpStatusCode: number;
15
+ /** AWS request correlation id (`$metadata.requestId`), or null when absent. */
16
+ requestId: string | null;
17
+ /** AWS extended request id (`$metadata.extendedRequestId`), or null. */
18
+ extendedRequestId: string | null;
19
+ };
20
+ /** The hq-derived context the sync failure site already holds. */
21
+ export interface VaultAccessDeniedHint {
22
+ companySlug: string;
23
+ objectKey: string;
24
+ }
25
+ /**
26
+ * If `err` is an AWS SDK v3 authorization failure — an object whose
27
+ * `$metadata.httpStatusCode` is 403 — return bounded diagnostics built from the
28
+ * hq-derived `hint` plus the AWS-minted request identifiers; otherwise `null`.
29
+ *
30
+ * A null result means "not an S3 403" and the caller must leave the error
31
+ * un-stamped so it captures under its own type, exactly as today.
32
+ */
33
+ export declare function vaultAccessDeniedDiagnostics(err: unknown, hint: VaultAccessDeniedHint): VaultAccessDeniedDiagnostics | null;
34
+ /**
35
+ * Stamp bounded diagnostics onto the ORIGINAL sync error as a NON-ENUMERABLE
36
+ * field, preserving the error's identity, `name`, `message` and `cause`
37
+ * (HQ-CLI-14 forbids rewrapping). Non-enumerable so the field never widens the
38
+ * error's own serialization, a `JSON.stringify`, or Sentry's default capture.
39
+ */
40
+ export declare function stampVaultAccessDenied(err: object, diagnostics: VaultAccessDeniedDiagnostics): void;
41
+ /** The carrier the top-level boundary matches: an error hq stamped itself. */
42
+ export interface VaultAccessDeniedError {
43
+ vaultAccessDenied: VaultAccessDeniedDiagnostics;
44
+ }
45
+ /**
46
+ * True when `err` carries the diagnostics {@link stampVaultAccessDenied} put
47
+ * there. Keyed on hq-cli's OWN stamped field (not on `$metadata`), so the
48
+ * boundary branch is disjoint from every neighbour and an AWS 403 that reached
49
+ * the boundary by some other path — un-stamped — still captures bare.
50
+ */
51
+ export declare function isVaultAccessDeniedError(err: unknown): err is VaultAccessDeniedError;
52
+ /**
53
+ * One fixed-shape, input-free actionable line: it names the denied vault object
54
+ * and company, states that the local stamp succeeded, and gives the remedy
55
+ * (grants + re-run). Built only from hq-derived, pattern-validated fields, so —
56
+ * like `syncStateLockMessage` — it needs no redaction or length cap.
57
+ */
58
+ export declare function vaultAccessDeniedMessage(diagnostics: VaultAccessDeniedDiagnostics): string;
59
+ //# sourceMappingURL=vault-access-denied-error.d.ts.map
@@ -0,0 +1,122 @@
1
+ // src/utils/vault-access-denied-error.ts
2
+ //
3
+ // Recognise — and make ATTRIBUTABLE — an AWS S3 AUTHORIZATION failure raised
4
+ // inside `hq skill create`'s post-register vault sync. Sibling in spirit to
5
+ // `sync-state-lock-error.ts` (HQ-CLI-14) and `package-root-diagnostics.ts`: a
6
+ // structurally-matched carrier plus a bounded, hq-derived diagnostics payload.
7
+ //
8
+ // UNLIKE the suppression siblings, this class stays CAPTURED. The reported
9
+ // event (Sentry 7709408531) is a REAL platform fault: hq-pro's STS vendor
10
+ // TRUNCATED the IAM session policy and dropped the very `skills/` prefix being
11
+ // written, so S3 answered the sync HEAD with 403. Because a HEAD carries no
12
+ // body, the AWS SDK v3 could not parse an XML error code and minted an untyped
13
+ // `Unknown http=403 UnknownError` whose `$metadata.httpStatusCode` is 403 and
14
+ // whose stack is system-only. That reached the top-level boundary's final else,
15
+ // which printed `hq: Unknown: Unknown http=403 UnknownError` and captured an
16
+ // event carrying nothing that identifies the object, the company, or the
17
+ // truncation. This module does NOT silence the 403 — it keeps it reported while
18
+ // giving the user an actionable line and the tracker a bounded context.
19
+ //
20
+ // Two hard rules, mirroring the diagnostics siblings:
21
+ // 1. STRUCTURAL match, never `instanceof`. The carrier is whatever the AWS SDK
22
+ // threw; the only signal read is `$metadata.httpStatusCode === 403`, so a
23
+ // future SDK rename cannot break it. A miss degrades to today's behaviour
24
+ // (still captured, just uncontextualised) — it can never suppress.
25
+ // 2. NO caller free text, NO secrets. The company slug and object key are
26
+ // hq-DERIVED (both are already pattern-validated upstream); the AWS request
27
+ // identifiers are opaque correlation tokens, length-bounded here. STS
28
+ // credentials, presigned URLs and message prose never enter the payload.
29
+ /** The non-enumerable field name the sync catch stamps and the boundary reads. */
30
+ const VAULT_ACCESS_DENIED_FIELD = "vaultAccessDenied";
31
+ /** Upper bound on an AWS request identifier before it enters a Sentry context. */
32
+ const REQUEST_ID_MAX_LENGTH = 256;
33
+ /** An AWS request identifier, coerced to a bounded string or null. */
34
+ function boundedRequestId(value) {
35
+ if (typeof value !== "string" || value.length === 0)
36
+ return null;
37
+ return value.slice(0, REQUEST_ID_MAX_LENGTH);
38
+ }
39
+ /**
40
+ * If `err` is an AWS SDK v3 authorization failure — an object whose
41
+ * `$metadata.httpStatusCode` is 403 — return bounded diagnostics built from the
42
+ * hq-derived `hint` plus the AWS-minted request identifiers; otherwise `null`.
43
+ *
44
+ * A null result means "not an S3 403" and the caller must leave the error
45
+ * un-stamped so it captures under its own type, exactly as today.
46
+ */
47
+ export function vaultAccessDeniedDiagnostics(err, hint) {
48
+ if (err === null || typeof err !== "object")
49
+ return null;
50
+ const metadata = err.$metadata;
51
+ if (metadata === null || typeof metadata !== "object")
52
+ return null;
53
+ if (metadata.httpStatusCode !== 403) {
54
+ return null;
55
+ }
56
+ return {
57
+ operation: "skill-sync",
58
+ companySlug: hint.companySlug,
59
+ objectKey: hint.objectKey,
60
+ httpStatusCode: 403,
61
+ requestId: boundedRequestId(metadata.requestId),
62
+ extendedRequestId: boundedRequestId(metadata.extendedRequestId),
63
+ };
64
+ }
65
+ /** True only for a diagnostics object of the exact closed shape. */
66
+ function isVaultAccessDeniedDiagnostics(value) {
67
+ if (value === null || typeof value !== "object")
68
+ return false;
69
+ const d = value;
70
+ return (d.operation === "skill-sync" &&
71
+ typeof d.companySlug === "string" &&
72
+ typeof d.objectKey === "string" &&
73
+ d.httpStatusCode === 403);
74
+ }
75
+ /**
76
+ * Stamp bounded diagnostics onto the ORIGINAL sync error as a NON-ENUMERABLE
77
+ * field, preserving the error's identity, `name`, `message` and `cause`
78
+ * (HQ-CLI-14 forbids rewrapping). Non-enumerable so the field never widens the
79
+ * error's own serialization, a `JSON.stringify`, or Sentry's default capture.
80
+ */
81
+ export function stampVaultAccessDenied(err, diagnostics) {
82
+ Object.defineProperty(err, VAULT_ACCESS_DENIED_FIELD, {
83
+ value: diagnostics,
84
+ enumerable: false,
85
+ configurable: true,
86
+ writable: true,
87
+ });
88
+ }
89
+ /**
90
+ * True when `err` carries the diagnostics {@link stampVaultAccessDenied} put
91
+ * there. Keyed on hq-cli's OWN stamped field (not on `$metadata`), so the
92
+ * boundary branch is disjoint from every neighbour and an AWS 403 that reached
93
+ * the boundary by some other path — un-stamped — still captures bare.
94
+ */
95
+ export function isVaultAccessDeniedError(err) {
96
+ if (err === null || typeof err !== "object")
97
+ return false;
98
+ return isVaultAccessDeniedDiagnostics(err.vaultAccessDenied);
99
+ }
100
+ /** Recover the create `<slug>` from an hq-built `skills/<slug>/SKILL.md` key. */
101
+ function slugFromObjectKey(objectKey) {
102
+ const match = /^skills\/([a-z0-9][a-z0-9-]*)\/SKILL\.md$/.exec(objectKey);
103
+ return match ? match[1] : null;
104
+ }
105
+ /**
106
+ * One fixed-shape, input-free actionable line: it names the denied vault object
107
+ * and company, states that the local stamp succeeded, and gives the remedy
108
+ * (grants + re-run). Built only from hq-derived, pattern-validated fields, so —
109
+ * like `syncStateLockMessage` — it needs no redaction or length cap.
110
+ */
111
+ export function vaultAccessDeniedMessage(diagnostics) {
112
+ const slug = slugFromObjectKey(diagnostics.objectKey);
113
+ const reRun = slug
114
+ ? `hq skill --company ${diagnostics.companySlug} create ${slug}`
115
+ : `hq skill --company ${diagnostics.companySlug} create <slug>`;
116
+ return (`The skill was stamped locally, but the vault upload of ` +
117
+ `'${diagnostics.objectKey}' was denied (HTTP 403) for company ` +
118
+ `'${diagnostics.companySlug}'. Your file grants don't cover that path. Ask ` +
119
+ `a ${diagnostics.companySlug} owner to confirm your file grants cover the ` +
120
+ `skills/ prefix, then re-run \`${reRun}\`.`);
121
+ }
122
+ //# sourceMappingURL=vault-access-denied-error.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.12",
3
+ "version": "5.108.14",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {