@indigoai-us/hq-cli 5.103.26 → 5.103.28

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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Connection health classification, shared by every surface that has to explain
3
+ * why an integration is not usable.
4
+ *
5
+ * This lived inside the `hq doctor` integrations check until `list`/`show` had
6
+ * to answer the same question. It is deliberately presentation-free and makes
7
+ * no network calls: it classifies stored health signals only, so a caller can
8
+ * render a finding without deciding what the underlying codes mean. Keeping one
9
+ * copy is the point — `hq doctor` and `hq integrations show` disagreeing about
10
+ * whether a row needs a reconnect is exactly the confusion this family exists
11
+ * to remove.
12
+ *
13
+ * It never echoes untrusted provider text: upstream messages can carry tokens or
14
+ * secret-bearing URLs, so every user-visible string here is HQ-authored, and the
15
+ * only externally-sourced string that reaches a user is the server's own
16
+ * `fix_path`.
17
+ */
18
+ import { bareProvider } from "./provider-slug.js";
19
+ const RECONNECT_REASON_CODES = new Set([
20
+ "oauth_refresh_invalid_grant",
21
+ "oauth_refresh_unavailable",
22
+ "token_refresh_failed",
23
+ "credentials_rejected",
24
+ ]);
25
+ const RETRYABLE_REASON_CODES = new Set([
26
+ "oauth_refresh_transient",
27
+ "oauth_refresh_write_conflict",
28
+ ]);
29
+ const HQ_CONFIGURATION_REASON_CODE = "oauth_client_secret_unavailable";
30
+ const UNSPECIFIED_REASON_CODE = "unspecified";
31
+ /** Classify without echoing untrusted provider text, which may contain secrets. */
32
+ export function classifyConnection(connection) {
33
+ if (connection.status === "revoked")
34
+ return [];
35
+ const provider = bareProvider(connection.provider);
36
+ const reason = recordedReason(connection);
37
+ const connectedWithRecordedFailure = connection.status === "connected" && reason !== "";
38
+ const knownReasonCode = knownReasonCodeFor(connection);
39
+ // `unspecified` is a recognised member of the server vocabulary, but it is an
40
+ // absence of diagnosis rather than one. Short-circuiting on it here would skip
41
+ // the status-aware branches below and report a degraded connection as an error
42
+ // needing a reconnect. It is deliberately allowed to fall through, where the
43
+ // server's role- and credential-aware remediation is applied like any other
44
+ // undiagnosed row.
45
+ if (knownReasonCode && knownReasonCode !== UNSPECIFIED_REASON_CODE) {
46
+ const finding = findingForKnownReasonCode(connection, provider, knownReasonCode);
47
+ // Retryable and HQ-configuration reason codes diagnose conditions that a
48
+ // caller-specific remediation cannot change. Reconnect-class codes defer
49
+ // to the server's credential/role-aware remediation classification, so an
50
+ // install in progress is never interrupted by a reconnect.
51
+ return [withServerRemediation(connection, RECONNECT_REASON_CODES.has(knownReasonCode)
52
+ ? findingForServerFixKind(finding, connection.fix_kind)
53
+ : finding)];
54
+ }
55
+ // A stale remediation value does not make a clean connected row unhealthy.
56
+ if (!connectedWithRecordedFailure && connection.status === "connected")
57
+ return [];
58
+ if (isProviderBlocked(reason) || connection.status === "degraded") {
59
+ return [withServerRemediation(connection, {
60
+ provider,
61
+ connectionId: connection.id,
62
+ kind: "provider-blocked",
63
+ message: connectedWithRecordedFailure
64
+ ? "reports connected, but recorded provider health says access is blocked upstream"
65
+ : "provider-side access is blocked or unavailable",
66
+ })];
67
+ }
68
+ const fallback = genericReconnectFinding(connection, provider);
69
+ const serverClassified = findingForServerFixKind(fallback, connection.fix_kind);
70
+ if (serverClassified.kind !== "reconnect") {
71
+ return [withServerRemediation(connection, serverClassified)];
72
+ }
73
+ if (isTokenRefreshFailure(reason) || isCredentialFailure(reason)) {
74
+ const problem = isTokenRefreshFailure(reason)
75
+ ? "token refresh failed"
76
+ : connectedWithRecordedFailure
77
+ ? "reports connected, but the provider rejected the stored credentials"
78
+ : "the provider rejected the stored credentials";
79
+ return [withServerRemediation(connection, {
80
+ provider,
81
+ connectionId: connection.id,
82
+ kind: "reconnect",
83
+ message: problem,
84
+ })];
85
+ }
86
+ // Past this point the reason vocabulary has diagnosed nothing: no recognised
87
+ // reason code and no reason text this module can read. Two things can still
88
+ // have diagnosed the row, and both are honoured before HQ admits it does not
89
+ // know:
90
+ // - an explicit server `fix_kind: "reconnect"`; and
91
+ // - a Factory installation parked at `needs_credentials`, which means the
92
+ // install exists but has never been given a sign-in. `hq integrations
93
+ // list` and `show` already say exactly that, so calling the same row
94
+ // undiagnosed would print two contradictory diagnoses on one screen.
95
+ // Absent both, saying "reconnect" is a guess dressed as advice — see the
96
+ // FindingKind doc comment.
97
+ const serverSaidReconnect = connection.fix_kind === "reconnect";
98
+ const installNeedsSignIn = connection.installation?.status === "needs_credentials";
99
+ if (connection.status === "needs-reauth" || connection.status === "error") {
100
+ const diagnosed = serverSaidReconnect
101
+ ? fallback
102
+ : installNeedsSignIn
103
+ ? { ...fallback, message: "has an installation that has never been given a sign-in" }
104
+ : undefined;
105
+ return [withServerRemediation(connection, diagnosed ?? { ...fallback, kind: "undiagnosed", message: undiagnosedMessage(connection) })];
106
+ }
107
+ if (connection.status !== "connected") {
108
+ return [withServerRemediation(connection, {
109
+ provider,
110
+ connectionId: connection.id,
111
+ kind: serverSaidReconnect || installNeedsSignIn ? "reconnect" : "undiagnosed",
112
+ message: `reports an unrecognized non-healthy status (${connection.status}) and HQ was not told why`,
113
+ })];
114
+ }
115
+ return [];
116
+ }
117
+ /**
118
+ * HQ-authored wording for a fault the control plane did not explain. It states
119
+ * the observable fact and stops there, rather than naming a repair nothing has
120
+ * justified.
121
+ */
122
+ function undiagnosedMessage(connection) {
123
+ if (connection.status !== "needs-reauth")
124
+ return "is not working, and HQ was not told why";
125
+ // A server-supplied `fix_path` means HQ WAS told what to do about the row,
126
+ // just not why it happened. `withServerRemediation` prints that instruction
127
+ // directly beneath this line, so claiming HQ does not know the fix would
128
+ // contradict the advice the reader is being handed.
129
+ return connection.fix_path
130
+ ? "reports that its saved sign-in is no longer valid, but HQ was not told why"
131
+ : "reports that its saved sign-in is no longer valid, but HQ was not told why or what will fix it";
132
+ }
133
+ function genericReconnectFinding(connection, provider) {
134
+ return {
135
+ provider,
136
+ connectionId: connection.id,
137
+ kind: "reconnect",
138
+ message: connection.status === "needs-reauth"
139
+ ? "needs re-authentication"
140
+ : "is in an error state",
141
+ };
142
+ }
143
+ /** Only values emitted by hq-pro affect the classification; future values fall back. */
144
+ function findingForServerFixKind(fallback, fixKind) {
145
+ switch (fixKind) {
146
+ case "re-add":
147
+ return {
148
+ ...fallback,
149
+ kind: "re-add",
150
+ message: "requires its API key to be re-entered",
151
+ };
152
+ case "contact-admin":
153
+ return {
154
+ ...fallback,
155
+ kind: "contact-admin",
156
+ message: "must be repaired by a company owner or admin",
157
+ };
158
+ case "wait":
159
+ return {
160
+ ...fallback,
161
+ kind: "wait",
162
+ message: "is waiting for the Factory installation to complete",
163
+ };
164
+ case "reconnect":
165
+ default:
166
+ return fallback;
167
+ }
168
+ }
169
+ /**
170
+ * A fix path is server-authored, role- and credential-aware wording. It cannot
171
+ * make a connection unhealthy: the caller must first have classified a health
172
+ * signal, so a stale fix path on an otherwise clean connected row is ignored.
173
+ */
174
+ function withServerRemediation(connection, finding) {
175
+ return connection.fix_path
176
+ ? { ...finding, remediation: connection.fix_path }
177
+ : finding;
178
+ }
179
+ /**
180
+ * These are a closed hq-pro contract. Exact matching deliberately comes before
181
+ * the legacy text heuristics, which remain below for old and unknown rows.
182
+ */
183
+ function knownReasonCodeFor(connection) {
184
+ const values = [connection.errorReason, connection.needsReauthReason, connection.degradedReason];
185
+ if (values.includes(HQ_CONFIGURATION_REASON_CODE))
186
+ return HQ_CONFIGURATION_REASON_CODE;
187
+ return values.find((value) => (typeof value === "string" &&
188
+ (RECONNECT_REASON_CODES.has(value) ||
189
+ RETRYABLE_REASON_CODES.has(value) ||
190
+ value === UNSPECIFIED_REASON_CODE)));
191
+ }
192
+ function findingForKnownReasonCode(connection, provider, code) {
193
+ if (RECONNECT_REASON_CODES.has(code)) {
194
+ return {
195
+ provider,
196
+ connectionId: connection.id,
197
+ kind: "reconnect",
198
+ message: "stored credentials need re-authentication",
199
+ };
200
+ }
201
+ if (RETRYABLE_REASON_CODES.has(code)) {
202
+ return {
203
+ provider,
204
+ connectionId: connection.id,
205
+ kind: "retryable",
206
+ message: code === "oauth_refresh_write_conflict"
207
+ ? "token refresh lost a concurrent write race; the stored credential remains intact"
208
+ : "token refresh is temporarily unavailable; the stored credential remains intact",
209
+ };
210
+ }
211
+ return {
212
+ provider,
213
+ connectionId: connection.id,
214
+ kind: "hq-configuration",
215
+ message: "the HQ OAuth client secret is unavailable",
216
+ };
217
+ }
218
+ function recordedReason(connection) {
219
+ return [connection.errorReason, connection.needsReauthReason, connection.degradedReason]
220
+ .filter((value) => typeof value === "string")
221
+ .join(" ")
222
+ .toLowerCase();
223
+ }
224
+ function isTokenRefreshFailure(reason) {
225
+ return /token[\s_-]*refresh|refresh[\s_-]*token|invalid_grant/.test(reason);
226
+ }
227
+ function isCredentialFailure(reason) {
228
+ return /rejected[\s_-]*(the[\s_-]*)?stored[\s_-]*credentials|credentials[\s_-]*rejected|unauthori[sz]ed|invalid[\s_-]*(token|credentials)/.test(reason);
229
+ }
230
+ function isProviderBlocked(reason) {
231
+ return /\b403\b|access[\s_-]*denied|upstream[\s_-]*(block|blocked)|provider[\s_-]*(block|blocked|unavailable)|service[\s_-]*unavailable/.test(reason);
232
+ }
233
+ /**
234
+ * A status alone ("error") tells a user nothing they can act on. The control
235
+ * plane already sends the diagnosis and the fix path on every non-healthy row;
236
+ * this turns those into the two lines a surface should print next to the status.
237
+ *
238
+ * Revoked rows return `null` on purpose. They are tombstones rather than broken
239
+ * connections, and `revokedConnectionDetails` already produces the correct
240
+ * re-add wording for them — classifying them here would print two competing
241
+ * recovery paths for the same row.
242
+ */
243
+ export function connectionHealthNotice(connection) {
244
+ if (connection.status === "revoked")
245
+ return null;
246
+ const finding = classifyConnection(connection)[0];
247
+ if (!finding)
248
+ return null;
249
+ return {
250
+ kind: finding.kind,
251
+ headline: `${finding.provider} ${finding.message}.`,
252
+ ...(finding.remediation ? { remediation: finding.remediation } : {}),
253
+ };
254
+ }
255
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The `factory:` runtime prefix, stripped.
3
+ *
4
+ * A leaf on purpose. Health classification needs it, and so does every command
5
+ * surface, but `commands/integrations-core.ts` imports the health module — so
6
+ * homing this there would close a runtime import cycle. Nothing else belongs in
7
+ * this file.
8
+ */
9
+ export declare function bareProvider(provider: string): string;
10
+ //# sourceMappingURL=provider-slug.d.ts.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The `factory:` runtime prefix, stripped.
3
+ *
4
+ * A leaf on purpose. Health classification needs it, and so does every command
5
+ * surface, but `commands/integrations-core.ts` imports the health module — so
6
+ * homing this there would close a runtime import cycle. Nothing else belongs in
7
+ * this file.
8
+ */
9
+ export function bareProvider(provider) {
10
+ return provider.replace(/^factory:/, "");
11
+ }
12
+ //# sourceMappingURL=provider-slug.js.map
@@ -108,6 +108,50 @@ export declare class QmdTerminatedError extends QmdExitError {
108
108
  name: string;
109
109
  constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, signal: string);
110
110
  }
111
+ /**
112
+ * qmd refused an LLM-backed operation because its local LLM is DISABLED IN CI:
113
+ * qmd hard-disables every LLM operation whenever `CI` is truthy in the
114
+ * environment and throws `Error: LLM operations are disabled in CI (set
115
+ * CI=true)`. Its semantic/hybrid reads (`vsearch`/`query`) expand the query
116
+ * through that gate, and the index build (`embed`) generates vectors through it,
117
+ * so any of them dies immediately when CI is set. hq-cli forwards the caller's
118
+ * environment — including CI — to the qmd child, so this is the caller's
119
+ * ENVIRONMENT, not an hq-cli defect.
120
+ *
121
+ * Before this class the refusal rendered as `qmd <full argv> exited with 1:
122
+ * <qmd's stderr>` and, because that message interpolated the caller's whole
123
+ * argv, every distinct search query minted a brand-new permanent Sentry issue —
124
+ * the same unbounded-fingerprint failure fixed for HQ-CLI-S and HQ-CLI
125
+ * 7677702704. This typed subclass lets the boundary classify the condition as
126
+ * the caller's environment and stop fingerprinting on the query text: it is
127
+ * raised only from qmd's OWN captured streams and its message names the
128
+ * SUBCOMMAND only. Sentry HQ-CLI 7688850003.
129
+ */
130
+ export declare class QmdLlmDisabledError extends QmdExitError {
131
+ name: string;
132
+ }
133
+ /**
134
+ * A qmd child died on MODULE RESOLUTION — Node could not load a JavaScript
135
+ * module qmd needs (`Cannot find module …` / `ERR_MODULE_NOT_FOUND`, alongside a
136
+ * `Require stack:` or a node_modules path). hq-cli bundles qmd and its whole
137
+ * dependency tree, so a missing module means the caller's INSTALL is incomplete
138
+ * — a partial or interrupted global `npm i -g` / `pnpm add -g` left some files
139
+ * unwritten — not a bug HQ can fix in code.
140
+ *
141
+ * HQ-CLI-Y (Sentry 7690356976): a qmd child died with `Cannot find module
142
+ * './stringifyComment.js'`, its Require stack rooted at
143
+ * /usr/lib/node_modules/@indigoai-us/hq-cli/node_modules/yaml/dist/stringify/… .
144
+ * The generic template rendered that as `qmd <full argv> exited with 1: <raw
145
+ * multi-line stderr>`; the argv split the Sentry group per query, and the
146
+ * child's ` at …` stderr lines were parsed as REAL frames of the hq-cli
147
+ * process, so grouping rode foreign frames and every distinct child failure
148
+ * minted a new permanent issue. finishRunQmd now types the condition (raised
149
+ * only from qmd's OWN captured streams, message naming the subcommand only); the
150
+ * boundary prints a reinstall remedy and skips capture for caller-driven reads.
151
+ */
152
+ export declare class QmdModuleMissingError extends QmdExitError {
153
+ name: string;
154
+ }
111
155
  export type ResolveQmdBinOptions = {
112
156
  env?: Record<string, string | undefined>;
113
157
  isExecutable?: (candidate: string) => boolean;
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import * as os from 'node:os';
5
5
  import * as path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
7
8
  import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
8
9
  import { redactErrorText } from '../../utils/redact-error-text.js';
9
10
  import { planCommandSpawn } from '../../utils/windows-spawn.js';
@@ -86,6 +87,50 @@ export class QmdTerminatedError extends QmdExitError {
86
87
  this.signal = signal;
87
88
  }
88
89
  }
90
+ /**
91
+ * qmd refused an LLM-backed operation because its local LLM is DISABLED IN CI:
92
+ * qmd hard-disables every LLM operation whenever `CI` is truthy in the
93
+ * environment and throws `Error: LLM operations are disabled in CI (set
94
+ * CI=true)`. Its semantic/hybrid reads (`vsearch`/`query`) expand the query
95
+ * through that gate, and the index build (`embed`) generates vectors through it,
96
+ * so any of them dies immediately when CI is set. hq-cli forwards the caller's
97
+ * environment — including CI — to the qmd child, so this is the caller's
98
+ * ENVIRONMENT, not an hq-cli defect.
99
+ *
100
+ * Before this class the refusal rendered as `qmd <full argv> exited with 1:
101
+ * <qmd's stderr>` and, because that message interpolated the caller's whole
102
+ * argv, every distinct search query minted a brand-new permanent Sentry issue —
103
+ * the same unbounded-fingerprint failure fixed for HQ-CLI-S and HQ-CLI
104
+ * 7677702704. This typed subclass lets the boundary classify the condition as
105
+ * the caller's environment and stop fingerprinting on the query text: it is
106
+ * raised only from qmd's OWN captured streams and its message names the
107
+ * SUBCOMMAND only. Sentry HQ-CLI 7688850003.
108
+ */
109
+ export class QmdLlmDisabledError extends QmdExitError {
110
+ name = 'QmdLlmDisabledError';
111
+ }
112
+ /**
113
+ * A qmd child died on MODULE RESOLUTION — Node could not load a JavaScript
114
+ * module qmd needs (`Cannot find module …` / `ERR_MODULE_NOT_FOUND`, alongside a
115
+ * `Require stack:` or a node_modules path). hq-cli bundles qmd and its whole
116
+ * dependency tree, so a missing module means the caller's INSTALL is incomplete
117
+ * — a partial or interrupted global `npm i -g` / `pnpm add -g` left some files
118
+ * unwritten — not a bug HQ can fix in code.
119
+ *
120
+ * HQ-CLI-Y (Sentry 7690356976): a qmd child died with `Cannot find module
121
+ * './stringifyComment.js'`, its Require stack rooted at
122
+ * /usr/lib/node_modules/@indigoai-us/hq-cli/node_modules/yaml/dist/stringify/… .
123
+ * The generic template rendered that as `qmd <full argv> exited with 1: <raw
124
+ * multi-line stderr>`; the argv split the Sentry group per query, and the
125
+ * child's ` at …` stderr lines were parsed as REAL frames of the hq-cli
126
+ * process, so grouping rode foreign frames and every distinct child failure
127
+ * minted a new permanent issue. finishRunQmd now types the condition (raised
128
+ * only from qmd's OWN captured streams, message naming the subcommand only); the
129
+ * boundary prints a reinstall remedy and skips capture for caller-driven reads.
130
+ */
131
+ export class QmdModuleMissingError extends QmdExitError {
132
+ name = 'QmdModuleMissingError';
133
+ }
89
134
  function isExecutable(candidate) {
90
135
  try {
91
136
  fs.accessSync(candidate, fs.constants.X_OK);
@@ -676,27 +721,31 @@ export function resolveQmdInvocation(options = {}) {
676
721
  const ANSI_ESCAPE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
677
722
  /** Lines qmd prints as ADVISORIES (a tip, a warning) — never a failure reason. */
678
723
  const QMD_ADVISORY_LINE = /^(?:Tip:|Warning:|QMD Warning:)/i;
679
- /**
680
- * Render qmd's captured output as a human diagnostic for a SIGNALLED run: strip
681
- * ANSI/control noise, then drop purely-advisory lines as long as a substantive
682
- * line survives. If ONLY advisory lines were printed — the reported shape, where
683
- * an embeddings tip and a GPU warning were all qmd emitted before it was killed —
684
- * say so explicitly rather than presenting an advisory as the cause of death.
685
- * Any retained qmd text is redacted + length-bounded, so a hostile line can
686
- * neither forge a second `hq:` line, emit terminal escapes, nor leak a token.
687
- */
688
- function describeQmdTermination(stdout, stderr) {
689
- const lines = `${stderr}\n${stdout}`
724
+ function summarizeQmdOutput(combined) {
725
+ const lines = combined
690
726
  .split(/\r?\n/)
691
727
  .map((line) => line.replace(ANSI_ESCAPE, '').trim())
692
728
  .filter((line) => line.length > 0);
693
729
  if (lines.length === 0)
694
- return '';
730
+ return { kind: 'empty' };
695
731
  const substantive = lines.filter((line) => !QMD_ADVISORY_LINE.test(line));
696
732
  if (substantive.length === 0)
733
+ return { kind: 'advisory-only' };
734
+ const reason = redactErrorText(substantive.join(' '));
735
+ return reason ? { kind: 'reason', reason } : { kind: 'empty' };
736
+ }
737
+ /**
738
+ * Render qmd's captured output as a human diagnostic for a SIGNALLED run.
739
+ * Byte-for-byte identical to its prior behaviour, now expressed through the
740
+ * shared {@link summarizeQmdOutput} core so the exited path renders the same way.
741
+ */
742
+ function describeQmdTermination(stdout, stderr) {
743
+ const summary = summarizeQmdOutput(`${stderr}\n${stdout}`);
744
+ if (summary.kind === 'advisory-only')
697
745
  return 'qmd printed only advisory output before it was terminated';
698
- const detail = redactErrorText(substantive.join(' '));
699
- return detail ? `qmd reported: ${detail}` : '';
746
+ if (summary.kind === 'reason')
747
+ return `qmd reported: ${summary.reason}`;
748
+ return '';
700
749
  }
701
750
  /** Normalise a spawn result and raise the typed qmd failures. */
702
751
  function finishRunQmd(result, bin, args) {
@@ -728,9 +777,53 @@ function finishRunQmd(result, bin, args) {
728
777
  : `qmd ${subcommand} was terminated by signal ${namedSignal} before it finished`;
729
778
  throw new QmdTerminatedError(signalMessage, args, normalized.status, normalized.stdout, normalized.stderr, signal);
730
779
  }
731
- const detail = normalized.stderr || normalized.stdout || 'qmd returned no diagnostic output';
732
- const message = `qmd ${args.join(' ')} exited with ${normalized.status ?? 'an unknown status'}: ${detail}`;
733
- if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
780
+ // Name the SUBCOMMAND only (args[0]) never args.join(' '), which used to
781
+ // interpolate the caller's whole argv (their search query, -c collection name,
782
+ // any absolute paths) straight into the Sentry issue title and split the group
783
+ // per invocation. `classifyText` keeps qmd's RAW captured streams for the
784
+ // wording-based branches below (byte-identical to the prior `detail`), while
785
+ // `reason` is the flattened, redacted, single-line rendering that goes into the
786
+ // human-facing message. Collapsing `reason` is what stops a child process's
787
+ // ` at …` stderr frames from polluting this error's stack and minting a
788
+ // brand-new Sentry issue per child failure (HQ-CLI-Y / Sentry 7690356976).
789
+ const subcommand = typeof args[0] === 'string' && args[0].length > 0 ? redactErrorText(args[0]) || 'qmd' : 'qmd';
790
+ const classifyText = normalized.stderr || normalized.stdout || 'qmd returned no diagnostic output';
791
+ const summary = summarizeQmdOutput(normalized.stderr || normalized.stdout || '');
792
+ const reason = summary.kind === 'reason'
793
+ ? summary.reason
794
+ : summary.kind === 'advisory-only'
795
+ ? 'qmd printed only advisory output'
796
+ : 'qmd returned no diagnostic output';
797
+ const message = `qmd ${subcommand} exited with ${normalized.status ?? 'an unknown status'}: ${reason}`;
798
+ // qmd refused because its local LLM is disabled: whenever CI is set in the
799
+ // environment, qmd hard-disables every LLM operation, so `vsearch`/`query`
800
+ // (query expansion) and `embed` (vector build) throw `Error: LLM operations
801
+ // are disabled in CI (set CI=true)` before doing any work. hq-cli inherits the
802
+ // caller's CI into the qmd child (runQmd forwards process.env), so this is the
803
+ // caller's ENVIRONMENT, not an hq-cli defect. Match qmd's OWN captured streams
804
+ // (stderr AND stdout) — never the synthesized `message`, which embeds the
805
+ // caller's whole argv — and name the SUBCOMMAND only, so the Sentry group can
806
+ // no longer fingerprint per search query (HQ-CLI 7688850003). Checked ahead of
807
+ // the collection wordings: the refusal text matches none of them, so order is
808
+ // safe, and the narrower typed signature stays first.
809
+ if (/LLM operations are disabled/i.test(`${normalized.stderr}\n${normalized.stdout}`)) {
810
+ throw new QmdLlmDisabledError(`qmd ${subcommand} could not run: its local LLM is disabled because CI is set in the environment`, args, normalized.status, normalized.stdout, normalized.stderr);
811
+ }
812
+ // A qmd child that died on MODULE RESOLUTION — `Cannot find module …` with a
813
+ // Require stack / node_modules path — means the caller's hq install tree is
814
+ // incomplete (a partial or interrupted global install), not an hq-cli defect.
815
+ // Type it so the boundary can print a reinstall remedy and skip capture for a
816
+ // caller-driven read, and so its grouping is bounded. Read qmd's OWN captured
817
+ // streams only, never the synthesized message, so a query for "Cannot find
818
+ // module" cannot trip it. Placed AFTER the LLM-disabled check and BEFORE the
819
+ // collection checks: the wordings are disjoint (the collection regex needs
820
+ // `collection`/`qmd://` adjacent to a not-found token, which this stderr
821
+ // lacks), so no existing branch changes behaviour (HQ-CLI-Y / Sentry
822
+ // 7690356976).
823
+ if (isQmdModuleMissingError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
824
+ throw new QmdModuleMissingError(`qmd ${subcommand} could not run: a module it needs was not found (the hq install tree looks incomplete)`, args, normalized.status, normalized.stdout, normalized.stderr);
825
+ }
826
+ if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(classifyText)) {
734
827
  throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
735
828
  }
736
829
  // qmd's same-name duplicate: `collection add` targeted a name YAML already
@@ -741,7 +834,7 @@ function finishRunQmd(result, bin, args) {
741
834
  // stays a plain QmdExitError. Colour is off under capture (qmd gates on
742
835
  // isTTY) and any surrounding ANSI sits outside this substring, so the plain
743
836
  // match is robust either way.
744
- const alreadyExists = /Collection '([^']+)' already exists\./.exec(detail);
837
+ const alreadyExists = /Collection '([^']+)' already exists\./.exec(classifyText);
745
838
  if (alreadyExists) {
746
839
  throw new QmdCollectionExistsError(message, args, normalized.status, normalized.stdout, normalized.stderr, alreadyExists[1]);
747
840
  }
package/dist/main.js CHANGED
@@ -68,6 +68,8 @@ import { networkTransportErrorMessage } from "./utils/network-transport-error.js
68
68
  import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
69
69
  import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-error.js";
70
70
  import { qmdTerminatedMessage } from "./utils/qmd-terminated-error.js";
71
+ import { qmdLlmDisabledMessage } from "./utils/qmd-llm-disabled-error.js";
72
+ import { qmdModuleMissingMessage } from "./utils/qmd-module-missing-error.js";
71
73
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
72
74
  import { isEpipe } from "./utils/epipe.js";
73
75
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
@@ -486,7 +488,41 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
486
488
  // collection-missing checks (both narrower typed signatures) and BEFORE the
487
489
  // environmental / transport / generic branches.
488
490
  const terminatedMsg = qmdMsg || collectionMsg ? null : qmdTerminatedMessage(err);
489
- const envMsg = qmdMsg || collectionMsg || terminatedMsg ? null : environmentalFsErrorMessage(err);
491
+ // A qmd LLM-backed operation refused because qmd's local LLM is DISABLED
492
+ // whenever CI is set in the environment (`vsearch`/`query` expand the
493
+ // query through it; `embed` builds vectors through it). hq-cli forwards the
494
+ // caller's CI to the qmd child, so this is the caller's ENVIRONMENT, not an
495
+ // hq-cli defect. Before this branch the refusal reached the final else and,
496
+ // because the synthesized message embedded the caller's whole argv, minted
497
+ // a brand-new permanent issue per query (HQ-CLI 7688850003). finishRunQmd
498
+ // now types it QmdLlmDisabledError; print the query-free remedy and skip
499
+ // capture. Evaluated AFTER the native-binding / collection-missing /
500
+ // terminated checks (narrower typed signatures) and BEFORE the
501
+ // environmental / transport / generic branches. Scoped to the subcommands
502
+ // that legitimately need the LLM (vsearch/query/embed); an LLM-disabled
503
+ // failure from hq's OWN reconciliation stays a captured internal error.
504
+ const llmDisabledMsg = qmdMsg || collectionMsg || terminatedMsg ? null : qmdLlmDisabledMessage(err);
505
+ // A qmd child that died on MODULE RESOLUTION (`Cannot find module …` with a
506
+ // Require stack / node_modules path) means the caller's hq install tree is
507
+ // incomplete — a partial or interrupted global install — not an hq-cli
508
+ // defect. Before this branch the broken-install condition reached the final
509
+ // else and, because the generic message embedded the caller's whole argv
510
+ // AND the child's raw stderr frames, filed a high-priority crash and minted
511
+ // a NEW permanent issue per child failure (HQ-CLI-Y, Sentry 7690356976).
512
+ // finishRunQmd now types it QmdModuleMissingError; print the input-free
513
+ // reinstall remedy and skip capture. Evaluated AFTER the native-binding /
514
+ // collection-missing / terminated / llm-disabled checks (native-binding in
515
+ // particular MUST keep winning when a bindings failure also looks
516
+ // module-missing, since it is checked first) and BEFORE the environmental /
517
+ // transport / generic branches. Scoped to caller-supplied reads
518
+ // (search/vsearch/query/get); a module-missing raised by hq's OWN
519
+ // reconciliation names a packaging fault hq could own, so it stays captured.
520
+ const moduleMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg
521
+ ? null
522
+ : qmdModuleMissingMessage(err);
523
+ const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg
524
+ ? null
525
+ : environmentalFsErrorMessage(err);
490
526
  // A raw network transport failure (undici's `TypeError: fetch failed`
491
527
  // with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
492
528
  // caller's connectivity, not an hq-cli defect. Before this branch it fell
@@ -497,7 +533,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
497
533
  // message that names the unreachable host, exit 1, and skip Sentry.
498
534
  // Ordered after the environmental check so a full disk keeps its exact
499
535
  // existing message.
500
- const transportMsg = qmdMsg || collectionMsg || terminatedMsg || envMsg ? null : networkTransportErrorMessage(err);
536
+ const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg
537
+ ? null
538
+ : networkTransportErrorMessage(err);
501
539
  if (qmdMsg) {
502
540
  deps.stderr.write(`hq: ${qmdMsg}\n`);
503
541
  }
@@ -507,6 +545,12 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
507
545
  else if (terminatedMsg) {
508
546
  deps.stderr.write(`hq: ${terminatedMsg}\n`);
509
547
  }
548
+ else if (llmDisabledMsg) {
549
+ deps.stderr.write(`hq: ${llmDisabledMsg}\n`);
550
+ }
551
+ else if (moduleMissingMsg) {
552
+ deps.stderr.write(`hq: ${moduleMissingMsg}\n`);
553
+ }
510
554
  else if (envMsg) {
511
555
  deps.stderr.write(`hq: ${envMsg}\n`);
512
556
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * If `err` is a qmd LLM-disabled-in-CI failure on a subcommand that legitimately
3
+ * needs the LLM, return an actionable, query-free remedy; otherwise return
4
+ * `null`. Mirrors qmdTerminatedMessage / qmdMissingCollectionMessage /
5
+ * qmdNativeBindingErrorMessage so the top-level handler branches the same way: a
6
+ * non-null result means print-and-skip-Sentry, null means "handle as usual
7
+ * (capture to Sentry)".
8
+ */
9
+ export declare function qmdLlmDisabledMessage(err: unknown): string | null;
10
+ //# sourceMappingURL=qmd-llm-disabled-error.d.ts.map