@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,87 @@
1
+ // src/utils/qmd-llm-disabled-error.ts
2
+ //
3
+ // Classify a qmd failure caused by qmd's local LLM being DISABLED IN CI — not an
4
+ // hq-cli code defect. qmd hard-disables every LLM operation whenever `CI` is
5
+ // truthy in the environment (`Error: LLM operations are disabled in CI (set
6
+ // CI=true)`); its semantic/hybrid reads (`vsearch`/`query`) expand the query
7
+ // through that gate and the index build (`embed`) generates embeddings through
8
+ // it, so each dies immediately when CI is set. hq-cli forwards the caller's
9
+ // environment — including CI — to the qmd child, so this is the caller's
10
+ // ENVIRONMENT, not a bug HQ can fix in code: surface an actionable remedy and
11
+ // SKIP Sentry capture. Sibling of qmd-terminated-error.ts (HQ-CLI 7677702704),
12
+ // qmd-collection-missing-error.ts (HQ-CLI-S), qmd-native-binding-error.ts
13
+ // (HQ-CLI-J), environmental-error.ts (HQ-CLI-2) and network-transport-error.ts
14
+ // (HQ-CLI-G): a failure that is NOT an hq-cli defect is printed with an
15
+ // actionable message and never filed as a crash.
16
+ //
17
+ // HQ-CLI 7688850003: `hq search <query> --mode semantic ...` spawned `qmd
18
+ // vsearch`, which refused because CI was set on the host (an automation
19
+ // harness). hq-cli had no classifier for that wording, so finishRunQmd raised a
20
+ // plain QmdExitError whose synthesized message interpolates the caller's whole
21
+ // argv; nothing caught it, so it reached the top-level handler's final else and
22
+ // was captured — and because the message embeds the query, every distinct query
23
+ // minted a brand-new permanent Sentry issue. finishRunQmd now types the
24
+ // condition QmdLlmDisabledError (raised only from qmd's OWN captured streams,
25
+ // message naming the subcommand only); this classifier closes the boundary.
26
+ //
27
+ // The gate is deliberately narrow on TWO axes so it can neither be tripped by
28
+ // user input nor silence a real bug:
29
+ // 1. CLASS: only a QmdLlmDisabledError (the typed subclass finishRunQmd raises
30
+ // for the LLM-disabled wording) qualifies — never a plain QmdExitError, a
31
+ // native-binding failure, or any other error, even one carrying the same
32
+ // wording in a user-controlled field.
33
+ // 2. INVOCATION: only the qmd subcommands that legitimately need the LLM — the
34
+ // caller-supplied semantic/hybrid reads `vsearch`/`query`, plus the index
35
+ // build `embed`. Any other subcommand (e.g. an LLM-disabled failure surfaced
36
+ // by hq's OWN `collection list` reconciliation) returns null and stays on
37
+ // the captured-error path, so a condition hq did not expect is still
38
+ // reported — now grouped per subcommand rather than per query.
39
+ //
40
+ // The remedy is entirely query-free: only the SUBCOMMAND — drawn from a closed
41
+ // allow-list — selects the wording, and NOTHING is interpolated from user input,
42
+ // so there is no injection surface at all. This preserves the bounded-
43
+ // fingerprint doctrine established by HQ-CLI-S and HQ-CLI 7677702704.
44
+ /** Semantic/hybrid reads whose query is EXPANDED through qmd's LLM gate. */
45
+ const LLM_SEARCH_READS = new Set(["vsearch", "query"]);
46
+ /** The index build that generates embeddings through the same LLM gate. */
47
+ const LLM_INDEX_BUILD = "embed";
48
+ /**
49
+ * Remedy for a semantic/hybrid SEARCH read. Leads with `--mode keyword`, which
50
+ * needs no LLM and is correct in EVERY environment (including a genuine CI
51
+ * pipeline where clearing CI is not an option), and offers clearing CI only as
52
+ * the secondary option for a non-CI host that merely has the variable set.
53
+ */
54
+ const SEARCH_REMEDY = "Semantic and hybrid search need qmd's local LLM, which is switched off " +
55
+ "because CI is set in this environment. Re-run with '--mode keyword' (it needs " +
56
+ "no LLM and works everywhere), or clear CI for the command and try again.";
57
+ /**
58
+ * Remedy for the `embed` index build, which cannot run at all without the LLM —
59
+ * so `--mode keyword` does not apply and the only fix is to clear CI.
60
+ */
61
+ const EMBED_REMEDY = "Embeddings can't be built while CI is set in this environment, because qmd " +
62
+ "disables its local LLM there. Clear CI for the command and run it again.";
63
+ /**
64
+ * If `err` is a qmd LLM-disabled-in-CI failure on a subcommand that legitimately
65
+ * needs the LLM, return an actionable, query-free remedy; otherwise return
66
+ * `null`. Mirrors qmdTerminatedMessage / qmdMissingCollectionMessage /
67
+ * qmdNativeBindingErrorMessage so the top-level handler branches the same way: a
68
+ * non-null result means print-and-skip-Sentry, null means "handle as usual
69
+ * (capture to Sentry)".
70
+ */
71
+ export function qmdLlmDisabledMessage(err) {
72
+ if (err === null || typeof err !== "object")
73
+ return null;
74
+ const record = err;
75
+ if (record.name !== "QmdLlmDisabledError")
76
+ return null;
77
+ const args = Array.isArray(record.args) ? record.args : [];
78
+ const subcommand = args[0];
79
+ if (typeof subcommand !== "string")
80
+ return null;
81
+ if (LLM_SEARCH_READS.has(subcommand))
82
+ return SEARCH_REMEDY;
83
+ if (subcommand === LLM_INDEX_BUILD)
84
+ return EMBED_REMEDY;
85
+ return null;
86
+ }
87
+ //# sourceMappingURL=qmd-llm-disabled-error.js.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The actionable remedy shown to the operator. Input-free — nothing is
3
+ * interpolated from the error, the argv, or qmd's output — so there is no
4
+ * injection surface, matching the bounded-fingerprint discipline of its
5
+ * siblings. Names the concrete reinstall for both a global npm and a global
6
+ * pnpm install.
7
+ *
8
+ * Covers BOTH qmd origins deliberately, because they cannot be told apart at
9
+ * runtime: HQ_QMD_BIN is honoured verbatim, and it is also the seam a dev build
10
+ * (and this repo's e2e harness) uses to inject the bundled qmd, so a truthy
11
+ * HQ_QMD_BIN is NOT a reliable "external qmd" signal. A missing module is not an
12
+ * hq-cli code defect on either path, so the remedy — not the classification —
13
+ * carries the origin nuance: it names the bundled reinstall first, then the
14
+ * HQ_QMD_BIN case so an operator running their own qmd is pointed at that
15
+ * install rather than only at hq.
16
+ */
17
+ export declare const QMD_MODULE_MISSING_REMEDY: string;
18
+ /**
19
+ * True when `err` carries, in qmd's OWN captured streams, a module-resolution
20
+ * failure corroborated by a Node module-loader artifact — i.e. qmd's dependency
21
+ * tree is incomplete on this machine. finishRunQmd calls this on the raw streams
22
+ * to type the condition {@link import('../lib/search-index/index.js').QmdModuleMissingError};
23
+ * a true result there means the caller should reinstall.
24
+ */
25
+ export declare function isQmdModuleMissingError(err: unknown): boolean;
26
+ /**
27
+ * If `err` is a caller-driven qmd module-missing failure (a QmdModuleMissingError
28
+ * from `hq search` or its `vsearch`/`query`/`get` siblings), return the
29
+ * actionable, input-free reinstall remedy; otherwise return `null`. Mirrors
30
+ * qmdNativeBindingErrorMessage / qmdMissingCollectionMessage so the top-level
31
+ * handler branches the same way: a non-null result means print-and-skip-Sentry,
32
+ * null means "handle as usual (capture to Sentry)".
33
+ *
34
+ * Gated on the TYPED CLASS plus a caller-reads invocation allow-list: a
35
+ * module-missing raised by hq's OWN reconciliation (`collection list`, `context
36
+ * add`, …) points at a packaging fault hq itself could be responsible for, so it
37
+ * stays a captured internal error rather than being silenced as the user's
38
+ * install.
39
+ */
40
+ export declare function qmdModuleMissingMessage(err: unknown): string | null;
41
+ //# sourceMappingURL=qmd-module-missing-error.d.ts.map
@@ -0,0 +1,130 @@
1
+ // src/utils/qmd-module-missing-error.ts
2
+ //
3
+ // Classify a qmd failure caused by a MISSING JAVASCRIPT MODULE in qmd's own
4
+ // bundled dependency tree — Node could not load a file qmd needs — as the
5
+ // caller's INCOMPLETE INSTALL, not an hq-cli code defect. hq-cli ships qmd and
6
+ // its dependencies, so a `Cannot find module …` from the qmd child means a
7
+ // partial or interrupted global install (`npm i -g` / `pnpm add -g`) left some
8
+ // files unwritten. That is the caller's install shape, not a bug HQ can fix in
9
+ // code, so the CLI surfaces an actionable reinstall remedy and SKIPS Sentry
10
+ // capture. Sibling of qmd-native-binding-error.ts (HQ-CLI-J, unbuilt
11
+ // better-sqlite3), qmd-collection-missing-error.ts (HQ-CLI-S),
12
+ // qmd-llm-disabled-error.ts (HQ-CLI 7688850003) and qmd-terminated-error.ts
13
+ // (HQ-CLI 7677702704): a failure that is NOT an hq-cli defect is printed with an
14
+ // actionable message and never filed as a crash.
15
+ //
16
+ // HQ-CLI-Y (Sentry 7690356976): a qmd child died with `Cannot find module
17
+ // './stringifyComment.js'`, its `Require stack:` rooted at
18
+ // /usr/lib/node_modules/@indigoai-us/hq-cli/node_modules/yaml/dist/stringify/… .
19
+ // Two compounding defects filed it as a high-priority crash AND minted a NEW
20
+ // permanent issue per child failure: finishRunQmd's generic message
21
+ // interpolated the caller's whole argv (query, -c value, absolute paths) AND
22
+ // embedded the child's RAW multi-line stderr, whose ` at <fn> (<file>:<line>)`
23
+ // lines the Sentry Node SDK then parsed as REAL frames of the hq-cli process —
24
+ // so grouping rode foreign frames and every distinct child failure split into
25
+ // its own issue. Bounding the message (search-index/index.ts) fixes the
26
+ // cardinality; this classifier closes the boundary so the broken-install
27
+ // condition reaches the operator as an actionable reinstall, not an unfixable
28
+ // crash.
29
+ //
30
+ // The gate is deliberately narrow on TWO axes, read ONLY from qmd's OWN captured
31
+ // streams (never the synthesized message — even now that it names the subcommand
32
+ // only, the message must never be the classification substrate), so a user
33
+ // searching for the phrase "Cannot find module" can never trip it:
34
+ // 1. RESOLUTION TOKEN: the module-resolution failure itself — `Cannot find
35
+ // module`, or the CJS/ESM `MODULE_NOT_FOUND` / `ERR_MODULE_NOT_FOUND` code.
36
+ // 2. CORROBORATOR: a Node module-loader artifact a bare query cannot
37
+ // synthesize — a `Require stack:` header, a `node_modules/` path, or the
38
+ // ESM `imported from` clause. Either axis alone would be too loose.
39
+ // The better-sqlite3 native-binding failure (HQ-CLI-J) carries neither
40
+ // resolution token, so it does not match here and keeps its own narrower remedy
41
+ // — and the top-level handler checks that classifier FIRST regardless of this
42
+ // one, so the more-actionable approve-builds remedy always wins when both could.
43
+ /** The module-resolution failure token. `MODULE_NOT_FOUND` covers the ESM
44
+ * `ERR_MODULE_NOT_FOUND` code as a substring; `Cannot find module` is the CJS
45
+ * loader's message text. */
46
+ const MODULE_RESOLUTION_TOKEN = /cannot find module|MODULE_NOT_FOUND/i;
47
+ /** A Node module-loader artifact a user's free-text query cannot synthesize. */
48
+ const MODULE_LOADER_ARTIFACT = /Require stack:|node_modules[\\/]|imported from/i;
49
+ /** qmd subcommands whose collection/query is chosen by the CALLER (read surface). */
50
+ const CALLER_READS = new Set(["search", "vsearch", "query", "get"]);
51
+ /**
52
+ * The actionable remedy shown to the operator. Input-free — nothing is
53
+ * interpolated from the error, the argv, or qmd's output — so there is no
54
+ * injection surface, matching the bounded-fingerprint discipline of its
55
+ * siblings. Names the concrete reinstall for both a global npm and a global
56
+ * pnpm install.
57
+ *
58
+ * Covers BOTH qmd origins deliberately, because they cannot be told apart at
59
+ * runtime: HQ_QMD_BIN is honoured verbatim, and it is also the seam a dev build
60
+ * (and this repo's e2e harness) uses to inject the bundled qmd, so a truthy
61
+ * HQ_QMD_BIN is NOT a reliable "external qmd" signal. A missing module is not an
62
+ * hq-cli code defect on either path, so the remedy — not the classification —
63
+ * carries the origin nuance: it names the bundled reinstall first, then the
64
+ * HQ_QMD_BIN case so an operator running their own qmd is pointed at that
65
+ * install rather than only at hq.
66
+ */
67
+ export const QMD_MODULE_MISSING_REMEDY = "hq's local search index can't start: one of its bundled modules is missing, " +
68
+ "which means the hq install tree is incomplete — a partial or interrupted " +
69
+ "install can leave some dependencies unwritten. Reinstall hq and run the " +
70
+ "command again: for a global install run `npm i -g @indigoai-us/hq-cli` (or " +
71
+ "the pnpm equivalent, `pnpm add -g @indigoai-us/hq-cli`). If you point " +
72
+ "HQ_QMD_BIN at your own qmd, that install is missing the module instead — " +
73
+ "reinstall its dependencies, or unset HQ_QMD_BIN to use the bundled copy.";
74
+ /**
75
+ * The qmd process's OWN captured diagnostic — stderr then stdout. Unlike
76
+ * qmd-native-binding-error.ts this NEVER falls back to the synthesized
77
+ * `message`: the message can carry the caller's query, so reading it would let
78
+ * free text trip the classifier. A value with no captured streams yields "". A
79
+ * bare string is treated as captured text directly (test convenience).
80
+ */
81
+ function capturedStreams(err) {
82
+ if (typeof err === "string")
83
+ return err;
84
+ if (err === null || typeof err !== "object")
85
+ return "";
86
+ const record = err;
87
+ const stderr = typeof record.stderr === "string" ? record.stderr : "";
88
+ const stdout = typeof record.stdout === "string" ? record.stdout : "";
89
+ return stderr || stdout ? `${stderr}\n${stdout}` : "";
90
+ }
91
+ /**
92
+ * True when `err` carries, in qmd's OWN captured streams, a module-resolution
93
+ * failure corroborated by a Node module-loader artifact — i.e. qmd's dependency
94
+ * tree is incomplete on this machine. finishRunQmd calls this on the raw streams
95
+ * to type the condition {@link import('../lib/search-index/index.js').QmdModuleMissingError};
96
+ * a true result there means the caller should reinstall.
97
+ */
98
+ export function isQmdModuleMissingError(err) {
99
+ const text = capturedStreams(err);
100
+ if (!text)
101
+ return false;
102
+ return MODULE_RESOLUTION_TOKEN.test(text) && MODULE_LOADER_ARTIFACT.test(text);
103
+ }
104
+ /**
105
+ * If `err` is a caller-driven qmd module-missing failure (a QmdModuleMissingError
106
+ * from `hq search` or its `vsearch`/`query`/`get` siblings), return the
107
+ * actionable, input-free reinstall remedy; otherwise return `null`. Mirrors
108
+ * qmdNativeBindingErrorMessage / qmdMissingCollectionMessage so the top-level
109
+ * handler branches the same way: a non-null result means print-and-skip-Sentry,
110
+ * null means "handle as usual (capture to Sentry)".
111
+ *
112
+ * Gated on the TYPED CLASS plus a caller-reads invocation allow-list: a
113
+ * module-missing raised by hq's OWN reconciliation (`collection list`, `context
114
+ * add`, …) points at a packaging fault hq itself could be responsible for, so it
115
+ * stays a captured internal error rather than being silenced as the user's
116
+ * install.
117
+ */
118
+ export function qmdModuleMissingMessage(err) {
119
+ if (err === null || typeof err !== "object")
120
+ return null;
121
+ const record = err;
122
+ if (record.name !== "QmdModuleMissingError")
123
+ return null;
124
+ const args = Array.isArray(record.args) ? record.args : [];
125
+ const subcommand = args[0];
126
+ if (typeof subcommand !== "string" || !CALLER_READS.has(subcommand))
127
+ return null;
128
+ return QMD_MODULE_MISSING_REMEDY;
129
+ }
130
+ //# sourceMappingURL=qmd-module-missing-error.js.map
@@ -65,13 +65,84 @@ const DEFAULT_GROUPING = "{{ default }}";
65
65
  * request identifier spliced into the name — could otherwise mint an unbounded
66
66
  * number of groups DESPITE the bounded discriminator. The finite-cardinality
67
67
  * promise must hold for the WHOLE key, so any name outside this set collapses to
68
- * FALLBACK_ERROR_NAME. The only first-party error carrying `rpcCode`/`status`
69
- * is IntegrationsCliError; add a name here only when a new bounded carrier
68
+ * FALLBACK_ERROR_NAME. IntegrationsCliError carries `rpcCode`/`status`; the Qmd*
69
+ * errors carry a process EXIT CODE in `status` and are fingerprinted on the
70
+ * dedicated qmd branch below (allow-listed here so their names appear verbatim
71
+ * rather than collapsing). Add a name here only when a new bounded carrier
70
72
  * genuinely warrants its own family of groups.
71
73
  */
72
- const KNOWN_ERROR_NAMES = new Set(["IntegrationsCliError"]);
74
+ const KNOWN_ERROR_NAMES = new Set([
75
+ "IntegrationsCliError",
76
+ // qmd process failures — all extend QmdExitError except QmdBinaryMissingError,
77
+ // which shares the `Qmd` prefix the qmd branch keys on (HQ-CLI-Y, 7690356976).
78
+ "QmdExitError",
79
+ "QmdBinaryMissingError",
80
+ "QmdCollectionMissingError",
81
+ "QmdCollectionExistsError",
82
+ "QmdTerminatedError",
83
+ "QmdLlmDisabledError",
84
+ "QmdModuleMissingError",
85
+ ]);
73
86
  /** Fixed bucket for any error name outside the closed allowlist. */
74
87
  const FALLBACK_ERROR_NAME = "other";
88
+ /**
89
+ * The CLOSED set of qmd subcommands worth their own group — the surfaces hq
90
+ * drives (caller reads plus reconciliation). Anything else, including a missing
91
+ * or non-string args[0], collapses to `qmd:other`, so the subcommand axis is
92
+ * finite no matter what argv qmd was handed (a query value can never reach it).
93
+ */
94
+ const KNOWN_QMD_SUBCOMMANDS = new Set([
95
+ "search", "vsearch", "query", "get",
96
+ "collection", "context", "embed", "status", "update", "cleanup",
97
+ ]);
98
+ /**
99
+ * The small set of qmd process EXIT CODES worth their own group. qmd exits 1 for
100
+ * a general error; any other numeric code collapses to `exit:other`, so the
101
+ * exit-code axis stays finite. A signalled child (`status` null) never reaches
102
+ * this set — it groups by its signal instead (see {@link qmdDispositionToken}).
103
+ */
104
+ const KNOWN_QMD_EXIT_CODES = new Set([1, 2]);
105
+ /**
106
+ * qmd child TERMINATION SIGNALS worth their own group. A signalled qmd child
107
+ * (QmdTerminatedError: `status` null, `signal` set) carries no exit code, so
108
+ * without a signal axis every native crash would collapse into one `exit:other`
109
+ * bucket — merging the distinct failures (SIGSEGV vs SIGABRT vs SIGBUS) the
110
+ * termination policy deliberately keeps on separate reporting paths. Bounded:
111
+ * any signal outside this closed set collapses to `signal:other`, so the axis
112
+ * stays finite no matter what killed the child.
113
+ */
114
+ const KNOWN_QMD_SIGNALS = new Set([
115
+ "SIGSEGV", "SIGABRT", "SIGBUS", "SIGILL", "SIGFPE",
116
+ "SIGKILL", "SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT",
117
+ ]);
118
+ /** The bounded qmd subcommand token (`qmd:<sub>`), read only from args[0]. */
119
+ function qmdSubcommandToken(args) {
120
+ const first = Array.isArray(args) ? args[0] : undefined;
121
+ return typeof first === "string" && KNOWN_QMD_SUBCOMMANDS.has(first)
122
+ ? `qmd:${first}`
123
+ : "qmd:other";
124
+ }
125
+ /** The bounded qmd exit-code token (`exit:<code>`), read only from `status`. */
126
+ function qmdExitToken(status) {
127
+ return typeof status === "number" &&
128
+ Number.isInteger(status) &&
129
+ KNOWN_QMD_EXIT_CODES.has(status)
130
+ ? `exit:${status}`
131
+ : "exit:other";
132
+ }
133
+ /**
134
+ * The bounded qmd DISPOSITION token — the fingerprint's fourth component. A
135
+ * signalled child (a non-empty `signal` string, `status` null) groups by
136
+ * `signal:<name>` from the closed allow-list so distinct native crashes stay in
137
+ * distinct groups; an ordinary exit groups by `exit:<code>`. Both inputs are
138
+ * bounded, so the axis stays finite regardless of what qmd reported.
139
+ */
140
+ function qmdDispositionToken(status, signal) {
141
+ if (typeof signal === "string" && signal.length > 0) {
142
+ return KNOWN_QMD_SIGNALS.has(signal) ? `signal:${signal}` : "signal:other";
143
+ }
144
+ return qmdExitToken(status);
145
+ }
75
146
  /**
76
147
  * Return a bounded `event.fingerprint` array for `err`, or `null` when `err`
77
148
  * carries no discriminator from the closed allowlist (in which case the caller
@@ -98,6 +169,28 @@ export function sentryFingerprintFor(err) {
98
169
  const rawName = typeof record.name === "string" && record.name.length > 0 ? record.name : null;
99
170
  if (rawName === null)
100
171
  return null;
172
+ // qmd process failures carry a PROCESS EXIT CODE in `status` (never an HTTP
173
+ // status) plus a subcommand in args[0]. Fingerprint them on a dedicated,
174
+ // fully-bounded key BEFORE the HTTP-status branch so an exit code is never read
175
+ // as an HTTP status, and so grouping is one group per (subcommand, disposition)
176
+ // instead of one per query — the cardinality blow-up that minted a new
177
+ // permanent issue per qmd child failure (HQ-CLI-Y, Sentry 7690356976). The
178
+ // fourth component is the DISPOSITION: an ordinary exit groups by `exit:<code>`,
179
+ // while a signalled child (QmdTerminatedError: status null, signal set) groups
180
+ // by `signal:<name>` so distinct native crashes (SIGSEGV/SIGABRT/SIGBUS) stay
181
+ // in distinct groups rather than collapsing into one `exit:other`. Keyed on the
182
+ // `Qmd` name prefix; the name is still bounded to the closed allowlist, so an
183
+ // unexpected Qmd* name collapses to FALLBACK_ERROR_NAME while keeping the
184
+ // bounded qmd discriminators.
185
+ if (rawName.startsWith("Qmd")) {
186
+ const qmdName = KNOWN_ERROR_NAMES.has(rawName) ? rawName : FALLBACK_ERROR_NAME;
187
+ return [
188
+ DEFAULT_GROUPING,
189
+ qmdName,
190
+ qmdSubcommandToken(record.args),
191
+ qmdDispositionToken(record.status, record.signal),
192
+ ];
193
+ }
101
194
  const name = KNOWN_ERROR_NAMES.has(rawName) ? rawName : FALLBACK_ERROR_NAME;
102
195
  const rpcCode = record.rpcCode;
103
196
  if (typeof rpcCode === "number" && Number.isInteger(rpcCode)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.26",
3
+ "version": "5.103.28",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {