@indigoai-us/hq-cli 5.106.0 → 5.106.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.106.2] — 2026-09-02
6
+
7
+ ### Fixed
8
+
9
+ - An `hq` installed as a local project dependency no longer updates itself in an
10
+ endless loop. The updater used to treat a pnpm virtual store
11
+ (`node_modules/.pnpm/...`) as an npm install prefix and install there — a
12
+ directory nothing on PATH ever reads — then report success, so the next
13
+ command found the same old build and updated again. On one outpost this ran
14
+ `npm install -g` up to 31 times an hour for days. Such copies are now
15
+ identified as local, reported with the path to fix, and left for their owning
16
+ project to update.
17
+ - The updater refuses an "update" to a version that is not newer than the one
18
+ already installed, instead of reinstalling it on every invocation.
19
+ - Success is now reported only after verifying the new version is the one PATH
20
+ actually resolves. An install that lands somewhere the running `hq` never
21
+ reads is reported as a failed update rather than as a success.
22
+
23
+ ## [5.106.1] — 2026-09-02
24
+
5
25
  ## [5.106.0] — 2026-09-02
6
26
 
7
27
  ### Fixed
@@ -6,6 +6,7 @@
6
6
  * reads/writes. It never waits for the sibling, and it never performs network
7
7
  * I/O itself.
8
8
  */
9
+ import * as fs from "node:fs";
9
10
  import { Command } from "commander";
10
11
  type Backend = "claude" | "codex" | "grok" | "none";
11
12
  type SpawnableBackend = Exclude<Backend, "none">;
@@ -41,6 +42,15 @@ export declare const CHECKPOINT_IDLE_REPLY_REMINDER: string;
41
42
  */
42
43
  export declare const SIBLING_PROMPT_TEMPLATE = "You are the HQ checkpoint sibling \u2014 a background maintenance agent for this\nHQ install. Your parent session's state is in <payloadPath>. Work\nquietly and do not ask questions; if something is ambiguous, record it in the\nreport instead of guessing.\n\n1. Read the payload. If it lists a transcript path that exists, read its tail (~400 lines)\n both for session context and to extract additional reusable learnings/insights\n the parent did not pass explicitly. Never quote secrets\n or tokens from the transcript. If .claude/skills/checkpoint/SKILL.md exists\n under this HQ root, read it and follow it wherever it goes beyond these instructions;\n the write bounds below always win over the skill text.\n2. Upgrade the thread file named in the payload IN PLACE: verify/repair its\n JSON; fill git.remote_url, git.initial_commit, git.commits_made, and\n git.knowledge_repos by scanning core/knowledge/public/*,\n core/knowledge/private/*, personal/knowledge/*, and companies/*/knowledge\n for symlinks or directories containing .git, recording dirty repositories\n as {\"<name>\": {\"commit\": \"<short>\", \"dirty\": true}}. Fill worker,\n next_steps, and insights; set type to \"checkpoint\"; then rename the file to\n drop -auto- from its filename. Use the renamed path in every reference you\n write afterwards.\n3. For every explicit or transcript-derived learning that is a reusable rule,\n FIRST search the existing policies for one the learning refines, contradicts\n or duplicates. Then take exactly one of these actions and name it in the\n report \u2014 the policy set is curated, not append-only:\n - AMEND an existing policy in place when the learning sharpens it, narrows\n its scope, or adds a case, and the rule as written is still correct.\n - SUPERSEDE it when the learning contradicts it: rewrite the rule to what is\n now true and record inside the file what changed and why.\n - MERGE near-duplicates into the single best-named file, then delete the\n files you merged away.\n - CREATE a new policy only when no existing policy covers the rule.\n Follow core/knowledge/public/hq-core/policies-spec.md. Write under\n personal/policies/ or, only when the payload names a company and the rule is\n company-specific, companies/<company>/policies/.\n DELETION BOUNDS: delete a policy only as the MERGE or SUPERSEDE step above,\n only inside those two directories, and never one whose body marks it HARD \u2014\n if a hard policy now looks wrong, leave it untouched and flag it in the\n report for a human to decide. Never delete a file you have not read.\n Apply the same curation to durable facts (not rules) under\n personal/knowledge/ or companies/<company>/knowledge/: correct a stale fact\n in place rather than appending a second, contradictory copy of it.\n Store up to two explicit or transcript-derived insights per\n core/knowledge/public/hq-core/insights-spec.md when present, otherwise\n workspace/insights/.\n4. Close an active session journal fail-soft with\n bash .claude/skills/_shared/journal.sh close \"<project_dir>\" \"<one-line synthesis>\".\n Write a legacy checkpoint JSON under workspace/checkpoints/<id>.json with\n id, created_at, summary, files, and next_steps for backward compatibility.\n5. Update workspace/threads/recent.md and regenerate\n workspace/threads/INDEX.md. For each company whose knowledge path appears\n in files_touched, regenerate companies/<company>/knowledge/INDEX.md under\n core/knowledge/public/hq-core/index-md-spec.md. Mechanical index generation\n is allowed for those companies, but knowledge/policy content writes remain\n restricted to the payload's named company.\n6. Run .claude/skills/document-release/SKILL.md best-effort when it exists;\n skip silently on any failure. Hook or automation improvements go ONLY under\n personal/hooks/ as proposals.\n7. WRITE BOUNDS: you may write only under personal/, workspace/, and companies/<company>/ as constrained above. You must NEVER write into .claude/, core/, .agents/, .codex/, repos/, or anywhere outside the HQ root.\n8. Write <runDir>/report.md \u2014 full prose: what you read, what you changed\n (paths), and what you skipped and why. List every policy or knowledge file\n you amended, superseded, merged or deleted with the reason, so a human can\n audit and reverse it; a deletion you do not name in the report is a defect.\n Then drain the queue: while\n workspace/checkpoints/sibling/pending.jsonl exists and is non-empty, claim\n it atomically by renaming it aside \u2014\n mv workspace/checkpoints/sibling/pending.jsonl <runDir>/pending-claimed-N.jsonl\n (N counting up from 2) \u2014 and process the claimed payloads with this same\n flow. Repeat until a claim finds nothing left, then update the report.\n NEVER read the queue and truncate it in place: a payload appended between\n your read and the truncate is lost, and its checkpoint is never enriched.\n";
43
44
  export declare function renderSiblingPrompt(runDir: string, payloadPath: string): string;
45
+ export type WriteStampsOptions = {
46
+ /**
47
+ * Filesystem seam (default the real `node:fs`); injected in tests to force a
48
+ * permission errno so the HqStateWriteError conversion can be proven without a
49
+ * real unwritable path.
50
+ */
51
+ fs?: Pick<typeof fs, "mkdirSync" | "writeFileSync">;
52
+ };
53
+ export declare function writeStamps(liveRoot: string, sessionId: string | undefined, options?: WriteStampsOptions): string[];
44
54
  /**
45
55
  * Preference order for `--backend auto`: the caller's own backend first, so a
46
56
  * claude session keeps using claude and a codex session keeps using codex
@@ -12,6 +12,7 @@ import { execFileSync, spawn } from "node:child_process";
12
12
  import { homedir } from "node:os";
13
13
  import { resolveLiveRoot } from "../utils/hq-roots.js";
14
14
  import { peekIdToken } from "../utils/id-token.js";
15
+ import { toHqStateWriteError } from "../utils/hq-state-write-error.js";
15
16
  const DEFAULT_TRIGGER = "stop-gate";
16
17
  const BACKENDS = new Set(["auto", "claude", "codex", "grok", "none"]);
17
18
  // Pinned by operator directive 2026-07-31; change defaults here deliberately.
@@ -368,9 +369,30 @@ function gitStateFor(cwd, liveRoot, initialCommit, explicitCommits) {
368
369
  : [],
369
370
  };
370
371
  }
371
- function writeStamps(liveRoot, sessionId) {
372
+ /**
373
+ * Perform a write into HQ's OWN state tree, converting a filesystem errno into a
374
+ * typed, path-naming {@link HqStateWriteError} (the boundary prints that and does
375
+ * NOT capture it — the fault is the user's machine, not an hq bug) while leaving
376
+ * any NON-errno failure — a genuine defect — to propagate unchanged and stay
377
+ * reportable. Scoped by the caller to the checkpoint state paths this command
378
+ * derives. The checkpoint still FAILS (exits non-zero) on such an error; only
379
+ * the classification and the message change (HQ-CLI-19, Sentry 7702730566).
380
+ */
381
+ function writeHqState(targetPath, operation, perform) {
382
+ try {
383
+ perform();
384
+ }
385
+ catch (error) {
386
+ const typed = toHqStateWriteError(error, targetPath, operation);
387
+ if (typed)
388
+ throw typed;
389
+ throw error;
390
+ }
391
+ }
392
+ export function writeStamps(liveRoot, sessionId, options = {}) {
393
+ const fsImpl = options.fs ?? fs;
372
394
  const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
373
- fs.mkdirSync(stateDir, { recursive: true });
395
+ writeHqState(stateDir, "create the checkpoint state directory", () => fsImpl.mkdirSync(stateDir, { recursive: true }));
374
396
  const sessionKey = sessionId
375
397
  ? sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown"
376
398
  : "unknown";
@@ -379,8 +401,9 @@ function writeStamps(liveRoot, sessionId) {
379
401
  path.join(stateDir, "checkpoint-cli-last"),
380
402
  path.join(stateDir, `checkpoint-cli-last-${sessionKey}`),
381
403
  ];
382
- for (const stampPath of stampPaths)
383
- fs.writeFileSync(stampPath, timestamp);
404
+ for (const stampPath of stampPaths) {
405
+ writeHqState(stampPath, "write its checkpoint state file", () => fsImpl.writeFileSync(stampPath, timestamp));
406
+ }
384
407
  return stampPaths;
385
408
  }
386
409
  /**
@@ -894,8 +917,8 @@ function runCheckpoint(options, command, group) {
894
917
  trigger: input.trigger,
895
918
  },
896
919
  };
897
- fs.mkdirSync(path.dirname(threadPath), { recursive: true });
898
- fs.writeFileSync(threadPath, `${JSON.stringify(thread, null, 2)}\n`);
920
+ writeHqState(path.dirname(threadPath), "create the checkpoint thread directory", () => fs.mkdirSync(path.dirname(threadPath), { recursive: true }));
921
+ writeHqState(threadPath, "write the checkpoint thread file", () => fs.writeFileSync(threadPath, `${JSON.stringify(thread, null, 2)}\n`));
899
922
  writeStamps(liveRoot, input.sessionId);
900
923
  printResult(`checkpoint: ${relativeThreadPath}`);
901
924
  // finally: the thread is already written at this point, so the agent must
@@ -4,6 +4,7 @@ import { backgroundStatus, defaultBackgroundDependencies, runBackgroundLauncher,
4
4
  import { findHqRoot } from '../utils/manifest.js';
5
5
  import { QMD_NATIVE_BINDING_REMEDY, isQmdNativeBindingError, } from '../utils/qmd-native-binding-error.js';
6
6
  import { isQmdStoreMissingError, qmdStoreMissingMessage, } from '../utils/qmd-store-missing-error.js';
7
+ import { isQmdStoreUnopenableError, qmdStoreUnopenableMessage, } from '../utils/qmd-store-unopenable-error.js';
7
8
  const defaults = {
8
9
  reconcileCollections,
9
10
  deriveCollections,
@@ -134,6 +135,18 @@ export function registerIndexCommand(program, dependencies = defaults) {
134
135
  process.stderr.write(`qmd: unusable — ${remedy}\n`);
135
136
  process.exitCode = 1;
136
137
  }
138
+ else if (isQmdStoreUnopenableError(error)) {
139
+ // The local qmd store directory exists but cannot be opened —
140
+ // typically not writable, so better-sqlite3 throws SQLITE_CANTOPEN
141
+ // (HQ-CLI-18). Like the store-missing case, this diagnostic command
142
+ // should DESCRIBE the broken local store, not crash on it (and never
143
+ // forward qmd's macOS-only Homebrew advisory): print the classified
144
+ // reason + remedy and exit 1 without rethrowing. Every OTHER qmd
145
+ // failure keeps propagating.
146
+ const remedy = qmdStoreUnopenableMessage(error) ?? 'its local search store could not be opened';
147
+ process.stderr.write(`qmd: unusable — ${remedy}\n`);
148
+ process.exitCode = 1;
149
+ }
137
150
  else {
138
151
  throw error;
139
152
  }
@@ -175,6 +175,31 @@ export declare class QmdStoreMissingError extends QmdExitError {
175
175
  name: string;
176
176
  constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, storeDir?: string | undefined, ensureReason?: string | undefined);
177
177
  }
178
+ /**
179
+ * qmd could not OPEN its SQLite store even though the store's directory EXISTS.
180
+ * better-sqlite3 throws `SqliteError: unable to open database file`
181
+ * (SQLITE_CANTOPEN) — most often because the store directory is not writable, so
182
+ * WAL mode (`PRAGMA journal_mode = WAL`) cannot create its -wal/-shm sidecars.
183
+ * The DISJOINT other half of "hq's store could not be opened": {@link
184
+ * QmdStoreMissingError} is a directory that does not EXIST; this is a directory
185
+ * that exists but is not USABLE. Either way it is the caller's ENVIRONMENT —
186
+ * permissions on the cache/index directory or its files — never a bug HQ can fix
187
+ * in code.
188
+ *
189
+ * `storeDir` carries the directory hq resolved qmd would open its store in, and
190
+ * `probeReason` the bounded errno phrase from hq's own best-effort WRITABILITY
191
+ * probe of that directory (undefined when the probe passed or did not run — e.g.
192
+ * a read-only DB file inside a writable directory). Both are hq-DERIVED — the
193
+ * resolved path plus a finite errno reason — so the boundary can name them in an
194
+ * input-free remedy and NEVER re-emit qmd's macOS-only Homebrew/BREW_PREFIX
195
+ * advisory to a Linux terminal. HQ-CLI-18 (Sentry 7702459375).
196
+ */
197
+ export declare class QmdStoreUnopenableError extends QmdExitError {
198
+ readonly storeDir?: string | undefined;
199
+ readonly probeReason?: string | undefined;
200
+ name: string;
201
+ constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, storeDir?: string | undefined, probeReason?: string | undefined);
202
+ }
178
203
  export type ResolveQmdBinOptions = {
179
204
  env?: Record<string, string | undefined>;
180
205
  isExecutable?: (candidate: string) => boolean;
@@ -231,12 +256,15 @@ export declare function resolveQmdVersion(): string | undefined;
231
256
  * directory so the mkdir happens at most once per distinct directory per process
232
257
  * (idempotent, bounded — a process opens a tiny, fixed set of store dirs). Read
233
258
  * by finishRunQmd to stamp the directory and the errno reason onto a
234
- * QmdStoreMissingError, turning the condition that carried no cause in the
235
- * reported event into a self-describing one.
259
+ * QmdStoreMissingError (mkdir failed) or a QmdStoreUnopenableError (mkdir
260
+ * succeeded but the writability probe failed), turning a condition that carried
261
+ * no cause in the reported event into a self-describing one. `writable` records
262
+ * the create+unlink probe result; `created` records whether the mkdir ran.
236
263
  */
237
264
  type QmdStoreEnsureOutcome = {
238
265
  dir: string;
239
266
  created: boolean;
267
+ writable: boolean;
240
268
  reason?: string;
241
269
  };
242
270
  /** Test-only: read the most recent store-directory ensure outcome. */
@@ -246,16 +274,32 @@ export declare function lastQmdStoreEnsureOutcome(): QmdStoreEnsureOutcome | und
246
274
  * pinned @tobilu/qmd@2.5.3 rule (dist/store.js getDefaultDbPath + dist/paths.js
247
275
  * qmdHomedir) against the SAME env the qmd child receives:
248
276
  * - $INDEX_PATH set -> dirname($INDEX_PATH) (qmd returns INDEX_PATH verbatim,
249
- * so the store file's directory is the one that must exist);
277
+ * so the store file's directory is the one that must exist). A RELATIVE
278
+ * INDEX_PATH is resolved against `cwd` — the working directory the qmd child
279
+ * will run in (index sync/status/background pass one) — because qmd opens
280
+ * `new Database(INDEX_PATH)` relative to its own cwd, not the CLI process's;
250
281
  * - else -> ${XDG_CACHE_HOME || <home>/.cache}/qmd, where <home> is
251
282
  * $HOME || $USERPROFILE || os.homedir() || '/tmp' (qmdHomedir's exact order).
252
283
  * An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
253
284
  * changes it turns CI red instead of silently restoring the noise.
254
285
  */
255
- export declare function resolveQmdStoreDir(env?: NodeJS.ProcessEnv): string;
286
+ export declare function resolveQmdStoreDir(env?: NodeJS.ProcessEnv, cwd?: string): string;
256
287
  export type EnsureQmdStoreDirOptions = {
288
+ /**
289
+ * The working directory the qmd child will run in, used to resolve a RELATIVE
290
+ * INDEX_PATH the same way qmd does. Defaults to `process.cwd()` (spawnSync's
291
+ * default when no cwd is given), so it only matters when a caller passes a cwd.
292
+ */
293
+ cwd?: string;
257
294
  /** mkdir seam (default `fs.mkdirSync(dir, { recursive: true })`); injected in tests. */
258
295
  mkdir?: (dir: string) => void;
296
+ /**
297
+ * Writability probe seam (default: create+unlink a uniquely-named temp file
298
+ * inside `dir`); injected in tests. MUST throw on failure. A create+unlink
299
+ * probe is used rather than `fs.accessSync(W_OK)` because access() reports
300
+ * writable for root and can disagree with open() under macOS TCC.
301
+ */
302
+ probeWritable?: (dir: string) => void;
259
303
  };
260
304
  /**
261
305
  * Best-effort, once-per-directory self-provisioning of qmd's store directory
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
8
8
  import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
9
9
  import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
10
+ import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-error.js';
10
11
  import { redactErrorText } from '../../utils/redact-error-text.js';
11
12
  import { planCommandSpawn } from '../../utils/windows-spawn.js';
12
13
  const require = createRequire(import.meta.url);
@@ -159,6 +160,35 @@ export class QmdStoreMissingError extends QmdExitError {
159
160
  this.ensureReason = ensureReason;
160
161
  }
161
162
  }
163
+ /**
164
+ * qmd could not OPEN its SQLite store even though the store's directory EXISTS.
165
+ * better-sqlite3 throws `SqliteError: unable to open database file`
166
+ * (SQLITE_CANTOPEN) — most often because the store directory is not writable, so
167
+ * WAL mode (`PRAGMA journal_mode = WAL`) cannot create its -wal/-shm sidecars.
168
+ * The DISJOINT other half of "hq's store could not be opened": {@link
169
+ * QmdStoreMissingError} is a directory that does not EXIST; this is a directory
170
+ * that exists but is not USABLE. Either way it is the caller's ENVIRONMENT —
171
+ * permissions on the cache/index directory or its files — never a bug HQ can fix
172
+ * in code.
173
+ *
174
+ * `storeDir` carries the directory hq resolved qmd would open its store in, and
175
+ * `probeReason` the bounded errno phrase from hq's own best-effort WRITABILITY
176
+ * probe of that directory (undefined when the probe passed or did not run — e.g.
177
+ * a read-only DB file inside a writable directory). Both are hq-DERIVED — the
178
+ * resolved path plus a finite errno reason — so the boundary can name them in an
179
+ * input-free remedy and NEVER re-emit qmd's macOS-only Homebrew/BREW_PREFIX
180
+ * advisory to a Linux terminal. HQ-CLI-18 (Sentry 7702459375).
181
+ */
182
+ export class QmdStoreUnopenableError extends QmdExitError {
183
+ storeDir;
184
+ probeReason;
185
+ name = 'QmdStoreUnopenableError';
186
+ constructor(message, args, status, stdout, stderr, storeDir, probeReason) {
187
+ super(message, args, status, stdout, stderr);
188
+ this.storeDir = storeDir;
189
+ this.probeReason = probeReason;
190
+ }
191
+ }
162
192
  function isExecutable(candidate) {
163
193
  try {
164
194
  fs.accessSync(candidate, fs.constants.X_OK);
@@ -305,16 +335,21 @@ export function lastQmdStoreEnsureOutcome() {
305
335
  * pinned @tobilu/qmd@2.5.3 rule (dist/store.js getDefaultDbPath + dist/paths.js
306
336
  * qmdHomedir) against the SAME env the qmd child receives:
307
337
  * - $INDEX_PATH set -> dirname($INDEX_PATH) (qmd returns INDEX_PATH verbatim,
308
- * so the store file's directory is the one that must exist);
338
+ * so the store file's directory is the one that must exist). A RELATIVE
339
+ * INDEX_PATH is resolved against `cwd` — the working directory the qmd child
340
+ * will run in (index sync/status/background pass one) — because qmd opens
341
+ * `new Database(INDEX_PATH)` relative to its own cwd, not the CLI process's;
309
342
  * - else -> ${XDG_CACHE_HOME || <home>/.cache}/qmd, where <home> is
310
343
  * $HOME || $USERPROFILE || os.homedir() || '/tmp' (qmdHomedir's exact order).
311
344
  * An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
312
345
  * changes it turns CI red instead of silently restoring the noise.
313
346
  */
314
- export function resolveQmdStoreDir(env = process.env) {
347
+ export function resolveQmdStoreDir(env = process.env, cwd = process.cwd()) {
315
348
  const indexPath = env.INDEX_PATH;
349
+ // path.resolve leaves an absolute INDEX_PATH untouched and anchors a relative
350
+ // one to the child cwd, so the probed/stamped directory is the one qmd uses.
316
351
  if (indexPath)
317
- return path.dirname(indexPath);
352
+ return path.dirname(path.resolve(cwd, indexPath));
318
353
  const home = env.HOME || env.USERPROFILE || os.homedir() || '/tmp';
319
354
  const cacheDir = env.XDG_CACHE_HOME || path.join(home, '.cache');
320
355
  return path.join(cacheDir, 'qmd');
@@ -338,6 +373,23 @@ function ensureFailureReason(error) {
338
373
  return typeof code === 'string' && code.length > 0 ? code : 'an unknown error';
339
374
  }
340
375
  }
376
+ /**
377
+ * Create AND unlink a uniquely-named temp file inside `dir` to prove qmd will be
378
+ * able to open (and, under WAL, create/delete the -wal/-shm sidecars in) its
379
+ * store. A recursive mkdir is a NO-OP on an existing directory, so a successful
380
+ * mkdir does NOT prove writability — this is what makes the store-unopenable
381
+ * remedy self-describing instead of evidence-free (HQ-CLI-18). BOTH the create
382
+ * and the unlink must succeed: a directory that allows creation but denies
383
+ * deletion cannot support WAL sidecar churn, so a failed unlink is a real
384
+ * writability failure (and swallowing it would also litter a probe file every
385
+ * process). Either failure throws so the caller records the bounded errno
386
+ * reason; on the happy path the probe file is always removed.
387
+ */
388
+ function probeDirWritable(dir) {
389
+ const probe = path.join(dir, `.hq-write-probe-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
390
+ fs.writeFileSync(probe, '');
391
+ fs.unlinkSync(probe);
392
+ }
341
393
  /**
342
394
  * Best-effort, once-per-directory self-provisioning of qmd's store directory
343
395
  * BEFORE qmd is spawned, so the benign majority case — a pruned cache or a fresh
@@ -351,7 +403,7 @@ function ensureFailureReason(error) {
351
403
  export function ensureQmdStoreDir(env = process.env, options = {}) {
352
404
  let dir;
353
405
  try {
354
- dir = resolveQmdStoreDir(env);
406
+ dir = resolveQmdStoreDir(env, options.cwd);
355
407
  }
356
408
  catch {
357
409
  // Resolution itself must never break a qmd run.
@@ -360,12 +412,23 @@ export function ensureQmdStoreDir(env = process.env, options = {}) {
360
412
  let outcome = storeEnsureByDir.get(dir);
361
413
  if (!outcome) {
362
414
  const mkdir = options.mkdir ?? ((target) => { fs.mkdirSync(target, { recursive: true }); });
415
+ const probeWritable = options.probeWritable ?? probeDirWritable;
363
416
  try {
364
417
  mkdir(dir);
365
- outcome = { dir, created: true };
418
+ // mkdir(recursive) is a no-op on an EXISTING directory, so success does not
419
+ // prove qmd can open its store there. Probe real writability so an
420
+ // existing-but-unwritable store dir — the reported HQ-CLI-18 shape — is
421
+ // recorded with its errno reason rather than looking successful.
422
+ try {
423
+ probeWritable(dir);
424
+ outcome = { dir, created: true, writable: true };
425
+ }
426
+ catch (probeError) {
427
+ outcome = { dir, created: true, writable: false, reason: ensureFailureReason(probeError) };
428
+ }
366
429
  }
367
430
  catch (error) {
368
- outcome = { dir, created: false, reason: ensureFailureReason(error) };
431
+ outcome = { dir, created: false, writable: false, reason: ensureFailureReason(error) };
369
432
  }
370
433
  storeEnsureByDir.set(dir, outcome);
371
434
  }
@@ -951,6 +1014,28 @@ function finishRunQmd(result, bin, args) {
951
1014
  const ensure = lastQmdStoreEnsure;
952
1015
  throw new QmdStoreMissingError(`qmd ${subcommand} could not run: its local search store could not be opened (its directory does not exist)`, args, normalized.status, normalized.stdout, normalized.stderr, ensure?.dir, ensure && !ensure.created ? ensure.reason : undefined);
953
1016
  }
1017
+ // qmd could not OPEN its SQLite store even though the directory EXISTS
1018
+ // (better-sqlite3: `SqliteError: unable to open database file` /
1019
+ // SQLITE_CANTOPEN — typically the store directory is not writable, so WAL mode
1020
+ // cannot create its -wal/-shm sidecars). That is the caller's environment —
1021
+ // permissions on the cache/index directory or its files — not an hq-cli
1022
+ // defect. Type it so the boundary prints hq's OWN platform-correct remedy
1023
+ // (naming the resolved store dir and the recorded writability-probe reason)
1024
+ // and skips capture, INSTEAD of forwarding qmd's macOS-only Homebrew/BREW_PREFIX
1025
+ // advisory verbatim to a Linux terminal (HQ-CLI-18, Sentry 7702459375). Read
1026
+ // qmd's OWN captured streams only, never the synthesized message, so a user
1027
+ // query containing that phrase can never trip it. Placed AFTER the
1028
+ // store-MISSING check (its sentence is the DISJOINT `Cannot open database
1029
+ // because the directory does not exist`, so it keeps priority for its own
1030
+ // shape) and BEFORE the collection-missing regex (that regex needs
1031
+ // `collection`/`qmd://` adjacent to a not-found token, which this stderr
1032
+ // lacks), so no existing branch changes behaviour. The stamped
1033
+ // storeDir/probeReason come from the pre-spawn ensure that ran for this exact
1034
+ // store directory.
1035
+ if (isQmdStoreUnopenableError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
1036
+ const ensure = lastQmdStoreEnsure;
1037
+ throw new QmdStoreUnopenableError(`qmd ${subcommand} could not run: its local search store could not be opened`, args, normalized.status, normalized.stdout, normalized.stderr, ensure?.dir, ensure && !ensure.writable ? ensure.reason : undefined);
1038
+ }
954
1039
  if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(classifyText)) {
955
1040
  throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
956
1041
  }
@@ -995,7 +1080,7 @@ export function runQmd(args, options = {}) {
995
1080
  // classified with its cause instead of captured (HQ-CLI-16). Best-effort and
996
1081
  // idempotent; only the real spawn path needs it (the injected-runner path
997
1082
  // above never opens a store).
998
- ensureQmdStoreDir(options.env ?? process.env);
1083
+ ensureQmdStoreDir(options.env ?? process.env, { cwd: options.cwd });
999
1084
  const result = spawnQmd(invocation, args, {
1000
1085
  cwd: options.cwd,
1001
1086
  env: options.env ?? process.env,
package/dist/main.js CHANGED
@@ -72,6 +72,8 @@ import { qmdTerminatedMessage } from "./utils/qmd-terminated-error.js";
72
72
  import { qmdLlmDisabledMessage } from "./utils/qmd-llm-disabled-error.js";
73
73
  import { qmdModuleMissingMessage } from "./utils/qmd-module-missing-error.js";
74
74
  import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
75
+ import { qmdStoreUnopenableMessage } from "./utils/qmd-store-unopenable-error.js";
76
+ import { hqStateWriteErrorMessage } from "./utils/hq-state-write-error.js";
75
77
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
76
78
  import { isEpipe } from "./utils/epipe.js";
77
79
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
@@ -556,7 +558,33 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
556
558
  const storeMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg
557
559
  ? null
558
560
  : qmdStoreMissingMessage(err);
559
- const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg
561
+ // A qmd store-UNOPENABLE failure (better-sqlite3 `SqliteError: unable to
562
+ // open database file` / SQLITE_CANTOPEN: the store directory EXISTS but is
563
+ // not writable, so WAL sidecars cannot be created) is the caller's local
564
+ // filesystem/permissions, not an hq-cli defect. finishRunQmd types it
565
+ // QmdStoreUnopenableError, carrying the resolved store directory and the
566
+ // writability-probe errno reason; print that self-describing, PLATFORM-
567
+ // CORRECT remedy and skip capture, so qmd's macOS-only Homebrew/BREW_PREFIX
568
+ // advisory can NEVER reach a Linux terminal (HQ-CLI-18, Sentry 7702459375).
569
+ // Evaluated immediately AFTER the store-MISSING check (disjoint sentence, so
570
+ // it keeps priority for its own shape) and BEFORE the environmental /
571
+ // transport / generic branches.
572
+ const storeUnopenableMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg
573
+ ? null
574
+ : qmdStoreUnopenableMessage(err);
575
+ // An hq STATE-TREE write that failed for an environmental errno
576
+ // (permission/quota/read-only/parent-gone) is typed at the write site as an
577
+ // HqStateWriteError carrying the exact hq-DERIVED path it tried to write.
578
+ // The reported EPERM on the checkpoint stamp file (Node 26 dropped the path)
579
+ // otherwise reached the capture below and gave the user nothing to act on
580
+ // (HQ-CLI-19, Sentry 7702730566). Print the path-naming, platform-aware
581
+ // remedy and skip capture. Placed with the environmental-fs family — BEFORE
582
+ // the generic envMsg computation — so this typed carrier wins even though
583
+ // it also carries an errno `code` environmentalFsErrorMessage could read.
584
+ const stateWriteMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg
585
+ ? null
586
+ : hqStateWriteErrorMessage(err);
587
+ const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || stateWriteMsg
560
588
  ? null
561
589
  : environmentalFsErrorMessage(err);
562
590
  // A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
@@ -573,7 +601,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
573
601
  // environmental-fs check, before network-transport — is pinned by tests.
574
602
  // The `in-process-async-holder` reason is deliberately NOT suppressed here
575
603
  // (see sync-state-lock-error.ts); it stays captured.
576
- const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || envMsg
604
+ const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || stateWriteMsg || envMsg
577
605
  ? null
578
606
  : syncStateLockMessage(err);
579
607
  // A raw network transport failure (undici's `TypeError: fetch failed`
@@ -592,6 +620,8 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
592
620
  llmDisabledMsg ||
593
621
  moduleMissingMsg ||
594
622
  storeMissingMsg ||
623
+ storeUnopenableMsg ||
624
+ stateWriteMsg ||
595
625
  envMsg ||
596
626
  lockMsg
597
627
  ? null
@@ -614,6 +644,12 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
614
644
  else if (storeMissingMsg) {
615
645
  deps.stderr.write(`hq: ${storeMissingMsg}\n`);
616
646
  }
647
+ else if (storeUnopenableMsg) {
648
+ deps.stderr.write(`hq: ${storeUnopenableMsg}\n`);
649
+ }
650
+ else if (stateWriteMsg) {
651
+ deps.stderr.write(`hq: ${stateWriteMsg}\n`);
652
+ }
617
653
  else if (envMsg) {
618
654
  deps.stderr.write(`hq: ${envMsg}\n`);
619
655
  }
@@ -33,6 +33,7 @@ async function emitCachedCliSessionStarted() {
33
33
  app: "hq-cli",
34
34
  source: "cli",
35
35
  occurredAt: new Date().toISOString(),
36
+ consentBasis: "no-consent",
36
37
  schemaVersion: 1,
37
38
  properties: { version: CLI_VERSION },
38
39
  },
@@ -0,0 +1,41 @@
1
+ /** Map an errno code to a bounded reason; anything outside the closed set to a fixed fallback. */
2
+ export declare function stateWriteReason(code: unknown): string;
3
+ /**
4
+ * A permission/filesystem write into HQ's OWN state tree that hq cannot fix in
5
+ * code. Carries the hq-DERIVED absolute `path` hq tried to write, the
6
+ * `operation` (a fixed label, never free text), the bounded errno `reason`, and
7
+ * the raw errno `code` — all of finite cardinality, so the printed line and any
8
+ * grouping stay bounded.
9
+ */
10
+ export declare class HqStateWriteError extends Error {
11
+ readonly path: string;
12
+ readonly operation: string;
13
+ readonly reason: string;
14
+ readonly code: string;
15
+ name: string;
16
+ constructor(path: string, operation: string, reason: string, code: string);
17
+ }
18
+ /**
19
+ * If `error` is an ENVIRONMENTAL filesystem failure — its `.code` is one of the
20
+ * CLOSED set of permission/quota/read-only/parent-shape errnos above — convert
21
+ * it into an {@link HqStateWriteError} that NAMES `targetPath` (the hq-derived
22
+ * path the caller was writing) and the `operation` label; otherwise return
23
+ * `null` so the caller rethrows the original error UNCHANGED. Keeping the set
24
+ * closed is what makes "genuine faults stay reportable" hold: a NON-errno error
25
+ * (a code defect) and an UNUSUAL errno (e.g. `EISDIR`/`EEXIST`, which mean hq
26
+ * wrote to the wrong shape) both stay captured. Scoped by the CALLER to hq's own
27
+ * state paths, never applied as a blanket boundary rule.
28
+ */
29
+ export declare function toHqStateWriteError(error: unknown, targetPath: string, operation: string): HqStateWriteError | null;
30
+ /** True when `err` is an {@link HqStateWriteError} (structural, so a rehydrated shape matches). */
31
+ export declare function isHqStateWriteError(err: unknown): err is HqStateWriteError;
32
+ /**
33
+ * If `err` is an {@link HqStateWriteError}, return the actionable, path-naming
34
+ * remedy; otherwise return `null`. A non-null result means the caller should
35
+ * print the message and SKIP Sentry capture — the condition is the user's
36
+ * machine, not a bug HQ can fix. The wording is platform-aware from `platform`
37
+ * (injected in tests), never from any upstream text, and always names the exact
38
+ * hq-derived path because the runtime does not (Node 26 drops it).
39
+ */
40
+ export declare function hqStateWriteErrorMessage(err: unknown, platform?: NodeJS.Platform): string | null;
41
+ //# sourceMappingURL=hq-state-write-error.d.ts.map
@@ -0,0 +1,113 @@
1
+ // src/utils/hq-state-write-error.ts
2
+ //
3
+ // Type a filesystem write into HQ's OWN state tree that failed for an
4
+ // ENVIRONMENTAL reason (a permission/quota/read-only/parent-gone errno) rather
5
+ // than an hq-cli code defect. These are unactionable from our side — the file
6
+ // or its directory is owned by another user, the terminal lacks Full Disk
7
+ // Access on macOS, the mount is read-only — so the CLI surfaces an actionable
8
+ // message and does NOT report them to Sentry.
9
+ //
10
+ // HQ-CLI-19 (Sentry indigo-d0/hq-cli 7702730566): `hq core checkpoint` wrote its
11
+ // thread file fine, then `fs.writeFileSync` on the EXISTING checkpoint stamp
12
+ // file threw `EPERM: operation not permitted, open` on macOS 26 / Node 26. The
13
+ // raw error reached the boundary; environmentalFsErrorMessage maps only
14
+ // ENOSPC/EDQUOT/EROFS (never EPERM/EACCES — see below), so it was captured. And
15
+ // because Node 26's writeFileSync fast path carried NO `path` on the thrown
16
+ // error, both the user and the issue got `EPERM: operation not permitted, open`
17
+ // with nothing to act on.
18
+ //
19
+ // Why type this at the WRITE SITE instead of widening the boundary's errno
20
+ // allowlist: EPERM/EACCES are AMBIGUOUS — "the user's machine" vs "hq wrote to
21
+ // the wrong place". A blanket errno rule at the boundary would silently suppress
22
+ // EVERY permission error CLI-wide, including a genuine hq path defect that must
23
+ // stay reportable. Typing only the specific hq-DERIVED state paths this command
24
+ // computes keeps the blast radius bounded, and because the remedy NAMES the
25
+ // exact path hq tried to write (the runtime supplies none on Node 26), a
26
+ // wrong-path defect is immediately visible to the user and to support even
27
+ // though it is not captured.
28
+ /** The bounded, human errno reason for a state-write failure (never free text). */
29
+ const STATE_WRITE_REASONS = {
30
+ EACCES: "permission denied",
31
+ EPERM: "operation not permitted (permission denied)",
32
+ EROFS: "the filesystem is read-only",
33
+ ENOSPC: "no space left on device",
34
+ EDQUOT: "the disk quota is exhausted",
35
+ ENOTDIR: "a path component is not a directory",
36
+ ENOENT: "a parent path does not exist",
37
+ };
38
+ /** Map an errno code to a bounded reason; anything outside the closed set to a fixed fallback. */
39
+ export function stateWriteReason(code) {
40
+ if (typeof code === "string" && code in STATE_WRITE_REASONS) {
41
+ return STATE_WRITE_REASONS[code];
42
+ }
43
+ return typeof code === "string" && code.length > 0 ? code : "a filesystem error";
44
+ }
45
+ /**
46
+ * A permission/filesystem write into HQ's OWN state tree that hq cannot fix in
47
+ * code. Carries the hq-DERIVED absolute `path` hq tried to write, the
48
+ * `operation` (a fixed label, never free text), the bounded errno `reason`, and
49
+ * the raw errno `code` — all of finite cardinality, so the printed line and any
50
+ * grouping stay bounded.
51
+ */
52
+ export class HqStateWriteError extends Error {
53
+ path;
54
+ operation;
55
+ reason;
56
+ code;
57
+ name = "HqStateWriteError";
58
+ constructor(path, operation, reason, code) {
59
+ super(`hq could not ${operation} ${path}: ${reason}`);
60
+ this.path = path;
61
+ this.operation = operation;
62
+ this.reason = reason;
63
+ this.code = code;
64
+ }
65
+ }
66
+ /**
67
+ * If `error` is an ENVIRONMENTAL filesystem failure — its `.code` is one of the
68
+ * CLOSED set of permission/quota/read-only/parent-shape errnos above — convert
69
+ * it into an {@link HqStateWriteError} that NAMES `targetPath` (the hq-derived
70
+ * path the caller was writing) and the `operation` label; otherwise return
71
+ * `null` so the caller rethrows the original error UNCHANGED. Keeping the set
72
+ * closed is what makes "genuine faults stay reportable" hold: a NON-errno error
73
+ * (a code defect) and an UNUSUAL errno (e.g. `EISDIR`/`EEXIST`, which mean hq
74
+ * wrote to the wrong shape) both stay captured. Scoped by the CALLER to hq's own
75
+ * state paths, never applied as a blanket boundary rule.
76
+ */
77
+ export function toHqStateWriteError(error, targetPath, operation) {
78
+ const code = error?.code;
79
+ if (typeof code !== "string" || !(code in STATE_WRITE_REASONS))
80
+ return null;
81
+ return new HqStateWriteError(targetPath, operation, STATE_WRITE_REASONS[code], code);
82
+ }
83
+ /** True when `err` is an {@link HqStateWriteError} (structural, so a rehydrated shape matches). */
84
+ export function isHqStateWriteError(err) {
85
+ if (err instanceof HqStateWriteError)
86
+ return true;
87
+ return (err !== null &&
88
+ typeof err === "object" &&
89
+ err.name === "HqStateWriteError" &&
90
+ typeof err.path === "string");
91
+ }
92
+ /**
93
+ * If `err` is an {@link HqStateWriteError}, return the actionable, path-naming
94
+ * remedy; otherwise return `null`. A non-null result means the caller should
95
+ * print the message and SKIP Sentry capture — the condition is the user's
96
+ * machine, not a bug HQ can fix. The wording is platform-aware from `platform`
97
+ * (injected in tests), never from any upstream text, and always names the exact
98
+ * hq-derived path because the runtime does not (Node 26 drops it).
99
+ */
100
+ export function hqStateWriteErrorMessage(err, platform = process.platform) {
101
+ if (!isHqStateWriteError(err))
102
+ return null;
103
+ const base = `hq could not ${err.operation} ${err.path}: ${err.reason}. ` +
104
+ "This is your machine's filesystem, not an hq bug: check that this file and its " +
105
+ "directory exist and are owned by you and writable";
106
+ const platformTail = platform === "darwin"
107
+ ? ", and on macOS grant your terminal Full Disk Access (System Settings → " +
108
+ "Privacy & Security → Full Disk Access) or move your HQ root out of a protected " +
109
+ "location, then run your command again."
110
+ : ", then run your command again.";
111
+ return `${base}${platformTail}`;
112
+ }
113
+ //# sourceMappingURL=hq-state-write-error.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * True when `err` is a qmd store-unopenable failure: better-sqlite3's fixed
3
+ * sentence (or the SQLITE_CANTOPEN errno token) appears in qmd's captured
4
+ * STDERR. Accepts either the thrown error (reads its `stderr`) or a bare
5
+ * `{ stderr }` probe object. A true result means the caller should print the
6
+ * classified remedy, exit non-zero, and SKIP Sentry capture.
7
+ */
8
+ export declare function isQmdStoreUnopenableError(err: unknown): boolean;
9
+ /**
10
+ * If `err` is a qmd store-unopenable failure, return the actionable remedy;
11
+ * otherwise return `null`. Mirrors qmdStoreMissingMessage so the top-level
12
+ * handler can branch on it the same way: a non-null result means
13
+ * print-and-skip-Sentry, null means "handle as usual (capture to Sentry)". The
14
+ * store directory and errno reason are read STRUCTURALLY from the error's own
15
+ * hq-populated `storeDir`/`probeReason` fields (absent on a bare probe object —
16
+ * then the message names neither), NEVER from qmd's output.
17
+ */
18
+ export declare function qmdStoreUnopenableMessage(err: unknown): string | null;
19
+ //# sourceMappingURL=qmd-store-unopenable-error.d.ts.map
@@ -0,0 +1,99 @@
1
+ // src/utils/qmd-store-unopenable-error.ts
2
+ //
3
+ // Classify a qmd failure caused by an UNOPENABLE STORE — the SQLite store's
4
+ // directory EXISTS but qmd cannot open the database (better-sqlite3 throws
5
+ // `SqliteError: unable to open database file` / SQLITE_CANTOPEN, most often
6
+ // because the store directory is not writable, so WAL mode cannot create its
7
+ // -wal/-shm sidecars). This is the caller's LOCAL filesystem/permissions, not
8
+ // an hq-cli code defect, so the CLI surfaces an actionable remedy and SKIPS
9
+ // Sentry capture. Sibling of qmd-store-missing-error.ts (HQ-CLI-16, a store
10
+ // directory that does not exist) — the DISJOINT other half of "hq's store
11
+ // could not be opened".
12
+ //
13
+ // HQ-CLI-18 (Sentry indigo-d0/hq-cli 7702459375): `hq search …` spawned qmd,
14
+ // whose better-sqlite3 could not open the store (SQLITE_CANTOPEN). @tobilu/qmd
15
+ // FIRST console.warn's a macOS-only remedy built by createSqliteVecUnavailableError
16
+ // ("… Install Homebrew SQLite … set BREW_PREFIX …") for ANY probe failure, then
17
+ // throws the uncaught SqliteError from `PRAGMA journal_mode = WAL`. hq-cli's
18
+ // classifier chain missed this shape (store-missing keys on the DISJOINT
19
+ // sentence `Cannot open database because the directory does not exist`), so it
20
+ // stayed a plain QmdExitError, reached the boundary's final else, was captured,
21
+ // and — the visible harm — forwarded qmd's Homebrew/BREW_PREFIX advisory
22
+ // VERBATIM to a Linux terminal. Typing the condition lets the boundary print
23
+ // hq's OWN platform-correct, input-free remedy and never echo upstream text,
24
+ // exactly as qmdTerminatedMessage already does for qmd's raw advisory stderr.
25
+ //
26
+ // Matching is deliberately narrow so it can neither be tripped by user input nor
27
+ // silence a real bug: better-sqlite3's FIXED sentence / SQLite's fixed errno
28
+ // token is required in qmd's captured STDERR ONLY — never the synthesized
29
+ // `message` (which echoes the caller's argv) and never STDOUT. better-sqlite3's
30
+ // uncaught SqliteError and qmd's advisory both land on stderr; STDOUT for a
31
+ // `search`/`get` carries indexed DOCUMENT content, so keying on it would let a
32
+ // result body that merely contains the phrase suppress an UNRELATED nonzero
33
+ // exit. Every other qmd store failure (a locked db, a corrupt store, an unknown
34
+ // exit) matches none of this and stays a reportable QmdExitError.
35
+ /**
36
+ * better-sqlite3's fixed "cannot open" sentence and SQLite's fixed errno token.
37
+ * Either, on its own, is sufficient. Both were present in the reported event and
38
+ * in the local SQLITE_CANTOPEN reproduction.
39
+ */
40
+ const STORE_UNOPENABLE = /SqliteError:\s*unable to open database file|\bSQLITE_CANTOPEN\b/i;
41
+ /**
42
+ * qmd's captured STDERR only. STDOUT is deliberately NOT consulted: for a
43
+ * `search`/`get` it is untrusted document content, so a result body containing
44
+ * this signature must never classify. The synthesized `message` is likewise not
45
+ * consulted — it embeds the caller's qmd arguments.
46
+ */
47
+ function capturedStderr(err) {
48
+ if (err === null || typeof err !== "object")
49
+ return "";
50
+ const record = err;
51
+ return typeof record.stderr === "string" ? record.stderr : "";
52
+ }
53
+ /**
54
+ * True when `err` is a qmd store-unopenable failure: better-sqlite3's fixed
55
+ * sentence (or the SQLITE_CANTOPEN errno token) appears in qmd's captured
56
+ * STDERR. Accepts either the thrown error (reads its `stderr`) or a bare
57
+ * `{ stderr }` probe object. A true result means the caller should print the
58
+ * classified remedy, exit non-zero, and SKIP Sentry capture.
59
+ */
60
+ export function isQmdStoreUnopenableError(err) {
61
+ return STORE_UNOPENABLE.test(capturedStderr(err));
62
+ }
63
+ /**
64
+ * The actionable, PLATFORM-NEUTRAL remedy, naming the store directory qmd could
65
+ * not open and (when the store-directory ensure recorded one) the errno reason
66
+ * its writability probe failed with. Both are hq-DERIVED values — the resolved
67
+ * store path and a bounded errno phrase — never caller argv/query or upstream
68
+ * text, so the line stays input-free and can NEVER re-emit qmd's macOS-only
69
+ * Homebrew/BREW_PREFIX advisory to a Linux terminal.
70
+ */
71
+ function remedyMessage(storeDir, probeReason) {
72
+ const where = storeDir
73
+ ? `hq's local search store (${storeDir}) could not be opened`
74
+ : "hq's local search store could not be opened";
75
+ const because = probeReason ? ` — ${probeReason}.` : ".";
76
+ return (`${where}${because} This is your machine's filesystem, not an hq bug: check that ` +
77
+ "the store directory and its database files are owned by you and writable (or " +
78
+ "point INDEX_PATH at a writable location), then run your command again.");
79
+ }
80
+ /**
81
+ * If `err` is a qmd store-unopenable failure, return the actionable remedy;
82
+ * otherwise return `null`. Mirrors qmdStoreMissingMessage so the top-level
83
+ * handler can branch on it the same way: a non-null result means
84
+ * print-and-skip-Sentry, null means "handle as usual (capture to Sentry)". The
85
+ * store directory and errno reason are read STRUCTURALLY from the error's own
86
+ * hq-populated `storeDir`/`probeReason` fields (absent on a bare probe object —
87
+ * then the message names neither), NEVER from qmd's output.
88
+ */
89
+ export function qmdStoreUnopenableMessage(err) {
90
+ if (!isQmdStoreUnopenableError(err))
91
+ return null;
92
+ const record = err;
93
+ const storeDir = typeof record.storeDir === "string" && record.storeDir.length > 0 ? record.storeDir : null;
94
+ const probeReason = typeof record.probeReason === "string" && record.probeReason.length > 0
95
+ ? record.probeReason
96
+ : null;
97
+ return remedyMessage(storeDir, probeReason);
98
+ }
99
+ //# sourceMappingURL=qmd-store-unopenable-error.js.map
@@ -60,7 +60,7 @@ import { spawnSync } from "node:child_process";
60
60
  import semver from "semver";
61
61
  import chalk from "chalk";
62
62
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
63
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
63
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
64
64
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
65
65
  /**
66
66
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
@@ -222,11 +222,20 @@ async function updateAndReexec(argv, flavor, known, deps) {
222
222
  if (!interactive && flavor.onlyWhenAttended) {
223
223
  return { action: "deferred", latest };
224
224
  }
225
+ // A local copy (a project dependency, a `pnpm dlx` cache) has no self-update
226
+ // path: every install argv here targets a GLOBAL install, so the copy that is
227
+ // actually running would stay exactly as stale as it started while the CLI
228
+ // reported success and re-exec'd into the same build. That is the shape of
229
+ // the 2026-09-02 gate loop — see isLocalDependencyInstall in version-gate.ts.
230
+ const install = (deps.resolveInstall ?? resolveRunningInstall)();
231
+ if (isLocalDependencyInstall(install)) {
232
+ console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
233
+ return { action: "skipped", latest };
234
+ }
225
235
  const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
226
236
  if (!releaseLock)
227
237
  return { action: "skipped", latest };
228
238
  let result;
229
- const install = (deps.resolveInstall ?? resolveRunningInstall)();
230
239
  const plan = buildSelfUpdatePlan(install);
231
240
  // A pnpm global install needs PNPM_HOME to find its global bin dir. A
232
241
  // minimal-environment parent (systemd, cron, non-login shell) lacks it and
@@ -83,6 +83,7 @@ const KNOWN_ERROR_NAMES = new Set([
83
83
  "QmdLlmDisabledError",
84
84
  "QmdModuleMissingError",
85
85
  "QmdStoreMissingError",
86
+ "QmdStoreUnopenableError",
86
87
  ]);
87
88
  /** Fixed bucket for any error name outside the closed allowlist. */
88
89
  const FALLBACK_ERROR_NAME = "other";
@@ -70,6 +70,59 @@ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
70
70
  export declare function isPnpmManagedPackageDir(pkgDir: string): boolean;
71
71
  /** Whether the running package lives inside Bun's global install tree. */
72
72
  export declare function isBunManagedPackageDir(pkgDir: string): boolean;
73
+ /**
74
+ * Whether the package dir sits inside a pnpm **virtual store** — the adjacent
75
+ * `node_modules/.pnpm` segment pair pnpm creates for every install, global or
76
+ * not:
77
+ *
78
+ * <proj>/node_modules/.pnpm/@indigoai-us+hq-cli@5.69.0/node_modules/@indigoai-us/hq-cli
79
+ *
80
+ * A virtual store is a content-addressed cache keyed by the EXACT version in
81
+ * the directory name. Nothing on PATH ever resolves through
82
+ * `<store>/lib/node_modules`, and the store dir is not an npm prefix — so the
83
+ * one thing that must never happen is treating it as one.
84
+ *
85
+ * Note this is deliberately broader than {@link isPnpmManagedPackageDir}, which
86
+ * answers a different question ("is this the copy `pnpm add -g` updates?") and
87
+ * is checked FIRST by {@link resolveRunningInstall}. By the time this predicate
88
+ * decides anything, a pnpm *global* store has already been classified.
89
+ */
90
+ export declare function isPnpmVirtualStorePackageDir(pkgDir: string): boolean;
91
+ /**
92
+ * Whether the running CLI is a **local** copy — a project dependency or a
93
+ * `pnpm dlx` cache — rather than a global install this process may replace.
94
+ *
95
+ * This is the guard for HQ-CLI update-loop incident 2026-09-02. A stale
96
+ * `@indigoai-us/hq-cli@5.69.0` sat in an HQ tree's pnpm virtual store, and the
97
+ * sync runner (npx, which puts `<cwd>/node_modules/.bin` on PATH) kept invoking
98
+ * it. The gate saw a build below `minVersion`, derived an npm prefix from the
99
+ * store path, ran
100
+ *
101
+ * npm install -g --prefix <store> @indigoai-us/hq-cli@latest
102
+ *
103
+ * which unpacked a pristine copy into `<store>/lib/node_modules` — a directory
104
+ * pnpm's shim never reads — then reported success. The next invocation resolved
105
+ * the same 5.69.0 shim and did it all again, ~25 times an hour forever.
106
+ *
107
+ * A local copy has no self-update path at all: replacing it is its owning
108
+ * project's job, and installing globally would leave the copy that is actually
109
+ * running untouched. So the gate must say so and stop, not install.
110
+ */
111
+ export declare function isLocalDependencyPackageDir(pkgDir: string, platform?: NodeJS.Platform): boolean;
112
+ export declare function isLocalDependencyInstall(install: RunningInstall, platform?: NodeJS.Platform): boolean;
113
+ /**
114
+ * Whether an update to `target` would actually move the install forward.
115
+ *
116
+ * Loop protection, independent of the layout bug above: an "update" to a
117
+ * version that is not strictly newer than what is running can never converge,
118
+ * so however the target was resolved — a stale dist-tag, a bad cache, a
119
+ * misconfigured hq-pro pin — the gate must refuse it rather than reinstall on
120
+ * every invocation and announce success each time.
121
+ *
122
+ * Unparseable versions return true: this guard exists to stop a provable
123
+ * no-op, not to become a new way for the gate to refuse to work.
124
+ */
125
+ export declare function isNewerVersion(target: string, current: string): boolean;
73
126
  /**
74
127
  * Where the running CLI is installed and who owns it. Resolved in ONE pass so
75
128
  * the package-root walk (which reads and parses a `package.json` per directory
@@ -278,7 +331,7 @@ export declare function probeCliVersion(bin: string): string | null;
278
331
  export declare function checkUpdateConvergence(targetVersion: string, deps?: {
279
332
  resolveBin?: () => string | null;
280
333
  probeVersion?: (bin: string) => string | null;
281
- }): void;
334
+ }): boolean;
282
335
  /** Injectable surface for {@link enforceUpdateRequired} (unit tests). */
283
336
  interface EnforceUpdateDeps {
284
337
  performUpdateString?: (command: string) => UpdateResult;
@@ -286,7 +339,12 @@ interface EnforceUpdateDeps {
286
339
  runner?: UpdateRunner;
287
340
  cleanStale?: (prefix: string) => string[];
288
341
  acquireLock?: () => UpdateLockHandle | null;
289
- checkConvergence?: (targetVersion: string) => void;
342
+ /**
343
+ * `false` means the probe *disproved* convergence. `void`/`undefined` keeps
344
+ * the historical "unverified is fine" behaviour, so an injected stub that
345
+ * returns nothing still exercises the success path.
346
+ */
347
+ checkConvergence?: (targetVersion: string) => boolean | void;
290
348
  }
291
349
  /**
292
350
  * Hard enforcement when the server says we're below `minVersion`. Print a
@@ -346,7 +404,11 @@ export declare const __test__: {
346
404
  enforceUpdateRequired: typeof enforceUpdateRequired;
347
405
  isBunManagedPackageDir: typeof isBunManagedPackageDir;
348
406
  pnpmUpdateEnv: typeof pnpmUpdateEnv;
407
+ isLocalDependencyInstall: typeof isLocalDependencyInstall;
408
+ isLocalDependencyPackageDir: typeof isLocalDependencyPackageDir;
409
+ isNewerVersion: typeof isNewerVersion;
349
410
  isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
411
+ isPnpmVirtualStorePackageDir: typeof isPnpmVirtualStorePackageDir;
350
412
  npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
351
413
  nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
352
414
  performUpdate: typeof performUpdate;
@@ -34,6 +34,7 @@ import os from "node:os";
34
34
  import path from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
36
  import chalk from "chalk";
37
+ import semver from "semver";
37
38
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
38
39
  import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
39
40
  import { acquireUpdateLock, } from "./update-lock.js";
@@ -140,6 +141,106 @@ export function isBunManagedPackageDir(pkgDir) {
140
141
  }
141
142
  return false;
142
143
  }
144
+ /**
145
+ * Whether the package dir sits inside a pnpm **virtual store** — the adjacent
146
+ * `node_modules/.pnpm` segment pair pnpm creates for every install, global or
147
+ * not:
148
+ *
149
+ * <proj>/node_modules/.pnpm/@indigoai-us+hq-cli@5.69.0/node_modules/@indigoai-us/hq-cli
150
+ *
151
+ * A virtual store is a content-addressed cache keyed by the EXACT version in
152
+ * the directory name. Nothing on PATH ever resolves through
153
+ * `<store>/lib/node_modules`, and the store dir is not an npm prefix — so the
154
+ * one thing that must never happen is treating it as one.
155
+ *
156
+ * Note this is deliberately broader than {@link isPnpmManagedPackageDir}, which
157
+ * answers a different question ("is this the copy `pnpm add -g` updates?") and
158
+ * is checked FIRST by {@link resolveRunningInstall}. By the time this predicate
159
+ * decides anything, a pnpm *global* store has already been classified.
160
+ */
161
+ export function isPnpmVirtualStorePackageDir(pkgDir) {
162
+ const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
163
+ const segments = normalized.split("/").filter(Boolean);
164
+ for (let i = 0; i < segments.length - 1; i += 1) {
165
+ if (segments[i] === "node_modules" && segments[i + 1] === ".pnpm")
166
+ return true;
167
+ }
168
+ return false;
169
+ }
170
+ /**
171
+ * Whether the running CLI is a **local** copy — a project dependency or a
172
+ * `pnpm dlx` cache — rather than a global install this process may replace.
173
+ *
174
+ * This is the guard for HQ-CLI update-loop incident 2026-09-02. A stale
175
+ * `@indigoai-us/hq-cli@5.69.0` sat in an HQ tree's pnpm virtual store, and the
176
+ * sync runner (npx, which puts `<cwd>/node_modules/.bin` on PATH) kept invoking
177
+ * it. The gate saw a build below `minVersion`, derived an npm prefix from the
178
+ * store path, ran
179
+ *
180
+ * npm install -g --prefix <store> @indigoai-us/hq-cli@latest
181
+ *
182
+ * which unpacked a pristine copy into `<store>/lib/node_modules` — a directory
183
+ * pnpm's shim never reads — then reported success. The next invocation resolved
184
+ * the same 5.69.0 shim and did it all again, ~25 times an hour forever.
185
+ *
186
+ * A local copy has no self-update path at all: replacing it is its owning
187
+ * project's job, and installing globally would leave the copy that is actually
188
+ * running untouched. So the gate must say so and stop, not install.
189
+ */
190
+ export function isLocalDependencyPackageDir(pkgDir, platform = process.platform) {
191
+ // A pnpm virtual store is local by construction on every platform. (A pnpm
192
+ // *global* store is classified earlier, by isPnpmManagedPackageDir.)
193
+ if (isPnpmVirtualStorePackageDir(pkgDir))
194
+ return true;
195
+ // On every non-Windows platform npm's global root is ALWAYS
196
+ // `<prefix>/lib/node_modules` — that is what `npm root -g` reports for
197
+ // /usr/local, Homebrew, nvm and a user-level `--prefix` alike. So a package
198
+ // under a `node_modules` with no `lib` parent is not a global install: it is
199
+ // a project dependency (`<proj>/node_modules/@scope/pkg`) or an npx cache
200
+ // (`~/.npm/_npx/<hash>/node_modules/@scope/pkg`). Both reproduce the same
201
+ // loop as the pnpm store — `npm install -g --prefix <dir>` writes
202
+ // `<dir>/lib/node_modules` while the `.bin` shim keeps resolving
203
+ // `<dir>/node_modules`.
204
+ //
205
+ // Windows is the exception and must keep the old behaviour: its global
206
+ // layout is `<prefix>\node_modules` with no `lib` segment, so the same test
207
+ // would misread a genuine global install as local.
208
+ if (platform === "win32")
209
+ return false;
210
+ const segments = pkgDir
211
+ .replace(/\\/g, "/")
212
+ .replace(/\/+$/, "")
213
+ .split("/")
214
+ .filter(Boolean);
215
+ const nodeModulesIndex = segments.lastIndexOf("node_modules");
216
+ if (nodeModulesIndex <= 0)
217
+ return false;
218
+ return segments[nodeModulesIndex - 1] !== "lib";
219
+ }
220
+ export function isLocalDependencyInstall(install, platform = process.platform) {
221
+ return (install.manager === "npm" &&
222
+ install.packageRoot !== null &&
223
+ isLocalDependencyPackageDir(install.packageRoot, platform));
224
+ }
225
+ /**
226
+ * Whether an update to `target` would actually move the install forward.
227
+ *
228
+ * Loop protection, independent of the layout bug above: an "update" to a
229
+ * version that is not strictly newer than what is running can never converge,
230
+ * so however the target was resolved — a stale dist-tag, a bad cache, a
231
+ * misconfigured hq-pro pin — the gate must refuse it rather than reinstall on
232
+ * every invocation and announce success each time.
233
+ *
234
+ * Unparseable versions return true: this guard exists to stop a provable
235
+ * no-op, not to become a new way for the gate to refuse to work.
236
+ */
237
+ export function isNewerVersion(target, current) {
238
+ const t = semver.valid(target);
239
+ const c = semver.valid(current);
240
+ if (!t || !c)
241
+ return true;
242
+ return semver.gt(t, c);
243
+ }
143
244
  export function resolveRunningInstall() {
144
245
  try {
145
246
  const packageRoot = findRunningPackageRoot();
@@ -151,6 +252,13 @@ export function resolveRunningInstall() {
151
252
  if (isBunManagedPackageDir(packageRoot)) {
152
253
  return { manager: "bun", prefix: null, packageRoot };
153
254
  }
255
+ // A local copy — project dependency, pnpm virtual store, or npx cache.
256
+ // `npmPrefixFromPackageDir` would hand back the project/cache directory and
257
+ // `npm install -g --prefix <dir>` then writes where nothing loads from —
258
+ // see isLocalDependencyPackageDir. There is no npm prefix to report here.
259
+ if (isLocalDependencyPackageDir(packageRoot)) {
260
+ return { manager: "npm", prefix: null, packageRoot };
261
+ }
154
262
  return {
155
263
  manager: "npm",
156
264
  prefix: npmPrefixFromPackageDir(packageRoot),
@@ -506,6 +614,14 @@ function manualUpdateCommand(install, decision) {
506
614
  function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
507
615
  const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${decision.currentVersion}).`);
508
616
  console.error(msg);
617
+ // A local copy is not updated by any global command. Printing the server's
618
+ // npm-shaped one would send the user to install a second copy that this
619
+ // invocation still would not use — the same wrong advice the pnpm-global
620
+ // carve-out exists to avoid.
621
+ if (isLocalDependencyInstall(install)) {
622
+ console.error(chalk.dim(` This copy is a local dependency (${install.packageRoot}); update the project that owns it, or invoke the global \`hq\`.`));
623
+ return;
624
+ }
509
625
  const command = install.manager === "pnpm"
510
626
  ? `pnpm ${buildPnpmInstallArgv().join(" ")}`
511
627
  : install.manager === "bun"
@@ -584,21 +700,41 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
584
700
  const bin = (deps.resolveBin ?? resolveHqOnPath)();
585
701
  if (!bin) {
586
702
  console.error(chalk.yellow("⚠ Updated, but couldn't resolve `hq` on PATH to verify the new version took effect."));
587
- return;
703
+ // Unverifiable, not disproven — the caller keeps its success report.
704
+ return true;
588
705
  }
589
706
  const reported = (deps.probeVersion ?? probeCliVersion)(bin);
590
707
  if (!reported) {
591
708
  console.error(chalk.yellow(`⚠ Updated, but \`${bin} --version\` did not respond — couldn't verify the new version took effect.`));
592
- return;
709
+ return true; // unverifiable, see above
593
710
  }
594
711
  if (reported === targetVersion)
595
- return; // converged — the normal case
712
+ return true; // converged — the normal case
596
713
  console.error(chalk.yellow(`⚠ hq updated to ${targetVersion} but PATH still resolves ${bin} at version ${reported} — ` +
597
714
  `a second install is shadowing the managed one. Remove it (e.g. \`pnpm remove -g ${CLI_NAME}\`) ` +
598
715
  "or the updater will loop forever."));
716
+ // The warning above fires for ANY mismatch — something other than the copy
717
+ // we just wrote is winning PATH resolution, which is worth saying either
718
+ // way. But only a STALE result disproves convergence.
719
+ //
720
+ // The install runs `@latest`, not `@<targetVersion>`, so it can legitimately
721
+ // land a version NEWER than the `latestVersion` the gate was handed: the npm
722
+ // dist-tag moves between the version-check response and the install, or the
723
+ // service's value was briefly behind. That is a successful upgrade. Failing
724
+ // it would exit 75 and reinstall on the next invocation — the very loop this
725
+ // guard exists to stop.
726
+ const reportedSemver = semver.valid(reported);
727
+ const targetSemver = semver.valid(targetVersion);
728
+ if (reportedSemver &&
729
+ targetSemver &&
730
+ semver.gte(reportedSemver, targetSemver)) {
731
+ return true;
732
+ }
733
+ return false;
599
734
  }
600
735
  catch {
601
736
  // Verification is best-effort; never break the CLI over a probe.
737
+ return true;
602
738
  }
603
739
  }
604
740
  /**
@@ -629,6 +765,26 @@ function enforceUpdateRequired(decision, deps = {}) {
629
765
  // `resolveRunningInstall`
630
766
  // already reports `prefix: null` there and neither is consulted below.
631
767
  const install = (deps.resolveInstall ?? resolveRunningInstall)();
768
+ // Loop protection #1 — nothing here can update a copy this process does not
769
+ // own. Installing globally would leave the local copy that is actually
770
+ // running stale, so the gate would fire again on the very next invocation.
771
+ // Say what to fix and stop; do NOT spend an install.
772
+ if (isLocalDependencyInstall(install)) {
773
+ console.error(chalk.red(` This copy is a local dependency, not a global install: ${install.packageRoot}`));
774
+ console.error(chalk.dim(" Self-update cannot replace it — an install would land where this copy is never loaded from, and the gate would fire again on the next run."));
775
+ console.error(chalk.dim(` Update the project that owns it (e.g. \`pnpm update ${CLI_NAME}\` in its root, or remove the stale dependency), or invoke the global \`hq\` instead.`));
776
+ process.exit(75);
777
+ }
778
+ // Loop protection #2 — an "update" to a version that is not strictly newer
779
+ // than what is running can never converge. Whatever produced the target (a
780
+ // stale dist-tag, a bad cache, a misconfigured pin), reinstalling it on every
781
+ // invocation and announcing success each time is strictly worse than saying
782
+ // so once.
783
+ if (!isNewerVersion(decision.latestVersion, decision.currentVersion)) {
784
+ console.error(chalk.red(` Refusing to update: the offered version ${decision.latestVersion} is not newer than the installed ${decision.currentVersion}.`));
785
+ console.error(chalk.dim(" This is a server-side or registry problem, not a local one — reinstalling would loop without ever converging."));
786
+ process.exit(75);
787
+ }
632
788
  const isManagedOutsideNpm = install.manager !== "npm";
633
789
  const prefix = install.prefix;
634
790
  if (!isManagedOutsideNpm && !command && !prefix) {
@@ -768,11 +924,17 @@ function attemptRequiredUpdate(decision, deps, install) {
768
924
  }
769
925
  return 75;
770
926
  }
771
- console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
772
927
  // Read-your-writes: a "successful" install into the npm prefix does not
773
- // prove the user's PATH resolves it. Warn (once, no retry) when a ghost
774
- // install is shadowing the copy we just wrote see checkUpdateConvergence.
775
- (deps.checkConvergence ?? checkUpdateConvergence)(decision.latestVersion);
928
+ // prove the user's PATH resolves it. Verify BEFORE announcing, so a ghost
929
+ // install shadowing the copy we just wrote is reported as the failed update
930
+ // it is rather than as a success the caller will keep retrying — see
931
+ // checkUpdateConvergence.
932
+ const converged = (deps.checkConvergence ?? checkUpdateConvergence)(decision.latestVersion);
933
+ if (converged === false) {
934
+ console.error(chalk.red(`✗ Update did not take effect: \`hq\` on PATH still resolves a different build than ${decision.latestVersion}.`));
935
+ return 75;
936
+ }
937
+ console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
776
938
  return 0;
777
939
  }
778
940
  export async function enforceVersionGate(onUpdateRecommended) {
@@ -829,7 +991,11 @@ export const __test__ = {
829
991
  enforceUpdateRequired,
830
992
  isBunManagedPackageDir,
831
993
  pnpmUpdateEnv,
994
+ isLocalDependencyInstall,
995
+ isLocalDependencyPackageDir,
996
+ isNewerVersion,
832
997
  isPnpmManagedPackageDir,
998
+ isPnpmVirtualStorePackageDir,
833
999
  npmPrefixFromPackageDir,
834
1000
  nudgeUpdateRecommended,
835
1001
  performUpdate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.106.0",
3
+ "version": "5.106.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {