@indigoai-us/hq-cli 5.108.1 → 5.108.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/assets/mesh-daemon/README.md +23 -0
  3. package/assets/mesh-daemon/launchd.plist.template +33 -0
  4. package/assets/mesh-daemon/systemd.system.service.template +27 -0
  5. package/assets/mesh-daemon/systemd.user.service.template +18 -0
  6. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +267 -0
  7. package/dist/commands/core.js +5 -4
  8. package/dist/commands/index-cmd.js +13 -0
  9. package/dist/commands/mesh.d.ts +15 -0
  10. package/dist/commands/mesh.js +732 -4
  11. package/dist/commands/search.d.ts +23 -0
  12. package/dist/commands/search.js +42 -1
  13. package/dist/lib/doctor/checks/work-context.d.ts +23 -0
  14. package/dist/lib/doctor/checks/work-context.js +195 -0
  15. package/dist/lib/doctor/registry.js +4 -0
  16. package/dist/lib/mesh/api.d.ts +18 -0
  17. package/dist/lib/mesh/api.js +55 -0
  18. package/dist/lib/mesh/cache.js +35 -4
  19. package/dist/lib/mesh/client.d.ts +118 -0
  20. package/dist/lib/mesh/client.js +268 -0
  21. package/dist/lib/mesh/live/backoff.d.ts +16 -0
  22. package/dist/lib/mesh/live/backoff.js +20 -0
  23. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +49 -0
  24. package/dist/lib/mesh/live/daemon/board-refresh.js +185 -0
  25. package/dist/lib/mesh/live/daemon/credentials.d.ts +71 -0
  26. package/dist/lib/mesh/live/daemon/credentials.js +227 -0
  27. package/dist/lib/mesh/live/daemon/doctor.d.ts +37 -0
  28. package/dist/lib/mesh/live/daemon/doctor.js +139 -0
  29. package/dist/lib/mesh/live/daemon/index.d.ts +24 -0
  30. package/dist/lib/mesh/live/daemon/index.js +14 -0
  31. package/dist/lib/mesh/live/daemon/install.d.ts +83 -0
  32. package/dist/lib/mesh/live/daemon/install.js +330 -0
  33. package/dist/lib/mesh/live/daemon/log.d.ts +11 -0
  34. package/dist/lib/mesh/live/daemon/log.js +79 -0
  35. package/dist/lib/mesh/live/daemon/paths.d.ts +18 -0
  36. package/dist/lib/mesh/live/daemon/paths.js +31 -0
  37. package/dist/lib/mesh/live/daemon/pid-lock.d.ts +48 -0
  38. package/dist/lib/mesh/live/daemon/pid-lock.js +134 -0
  39. package/dist/lib/mesh/live/daemon/presence.d.ts +101 -0
  40. package/dist/lib/mesh/live/daemon/presence.js +250 -0
  41. package/dist/lib/mesh/live/daemon/presign.d.ts +18 -0
  42. package/dist/lib/mesh/live/daemon/presign.js +65 -0
  43. package/dist/lib/mesh/live/daemon/run.d.ts +74 -0
  44. package/dist/lib/mesh/live/daemon/run.js +400 -0
  45. package/dist/lib/mesh/live/daemon/state.d.ts +24 -0
  46. package/dist/lib/mesh/live/daemon/state.js +57 -0
  47. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +170 -0
  48. package/dist/lib/mesh/live/daemon/transcript-watch.js +520 -0
  49. package/dist/lib/mesh/live/enqueue.d.ts +48 -0
  50. package/dist/lib/mesh/live/enqueue.js +100 -0
  51. package/dist/lib/mesh/live/flush.d.ts +56 -0
  52. package/dist/lib/mesh/live/flush.js +360 -0
  53. package/dist/lib/mesh/live/format-spool-line.d.ts +42 -0
  54. package/dist/lib/mesh/live/format-spool-line.js +106 -0
  55. package/dist/lib/mesh/live/index.d.ts +22 -0
  56. package/dist/lib/mesh/live/index.js +16 -0
  57. package/dist/lib/mesh/live/paths.d.ts +13 -0
  58. package/dist/lib/mesh/live/paths.js +30 -0
  59. package/dist/lib/mesh/live/session-events-client.d.ts +58 -0
  60. package/dist/lib/mesh/live/session-events-client.js +120 -0
  61. package/dist/lib/mesh/live/session-identity.d.ts +39 -0
  62. package/dist/lib/mesh/live/session-identity.js +91 -0
  63. package/dist/lib/mesh/live/spool.d.ts +52 -0
  64. package/dist/lib/mesh/live/spool.js +193 -0
  65. package/dist/lib/mesh/live/ulid.d.ts +12 -0
  66. package/dist/lib/mesh/live/ulid.js +39 -0
  67. package/dist/lib/mesh/live/validate-session-event.d.ts +39 -0
  68. package/dist/lib/mesh/live/validate-session-event.js +128 -0
  69. package/dist/lib/search-index/index.d.ts +98 -0
  70. package/dist/lib/search-index/index.js +174 -2
  71. package/dist/lib/work-context/atomic.d.ts +18 -0
  72. package/dist/lib/work-context/atomic.js +132 -0
  73. package/dist/lib/work-context/company.d.ts +103 -0
  74. package/dist/lib/work-context/company.js +274 -0
  75. package/dist/lib/work-context/config.d.ts +59 -0
  76. package/dist/lib/work-context/config.js +172 -0
  77. package/dist/lib/work-context/contract.d.ts +81 -0
  78. package/dist/lib/work-context/contract.js +65 -0
  79. package/dist/lib/work-context/errors.d.ts +27 -0
  80. package/dist/lib/work-context/errors.js +46 -0
  81. package/dist/lib/work-context/index.d.ts +23 -0
  82. package/dist/lib/work-context/index.js +20 -0
  83. package/dist/lib/work-context/migrate.d.ts +84 -0
  84. package/dist/lib/work-context/migrate.js +287 -0
  85. package/dist/lib/work-context/organize.d.ts +130 -0
  86. package/dist/lib/work-context/organize.js +471 -0
  87. package/dist/lib/work-context/outbox.d.ts +97 -0
  88. package/dist/lib/work-context/outbox.js +357 -0
  89. package/dist/lib/work-context/paths.d.ts +14 -0
  90. package/dist/lib/work-context/paths.js +39 -0
  91. package/dist/lib/work-context/project.d.ts +78 -0
  92. package/dist/lib/work-context/project.js +247 -0
  93. package/dist/lib/work-context/reconcile.d.ts +85 -0
  94. package/dist/lib/work-context/reconcile.js +651 -0
  95. package/dist/lib/work-context/repo-remote.d.ts +43 -0
  96. package/dist/lib/work-context/repo-remote.js +200 -0
  97. package/dist/lib/work-context/state.d.ts +112 -0
  98. package/dist/lib/work-context/state.js +162 -0
  99. package/dist/main.js +93 -4
  100. package/dist/utils/cognito-session.d.ts +85 -9
  101. package/dist/utils/cognito-session.js +216 -9
  102. package/dist/utils/qmd-model-download-error.d.ts +27 -0
  103. package/dist/utils/qmd-model-download-error.js +114 -0
  104. package/dist/utils/qmd-query-document-error.d.ts +23 -0
  105. package/dist/utils/qmd-query-document-error.js +117 -0
  106. package/dist/utils/qmd-workdir-missing-error.d.ts +18 -0
  107. package/dist/utils/qmd-workdir-missing-error.js +78 -0
  108. package/dist/utils/sentry-fingerprint.js +7 -0
  109. package/package.json +5 -1
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Work Mesh Live — session-event validator (US-001).
3
+ *
4
+ * Uses Ajv draft 2020-12 against the verbatim schema copy. Prohibited content
5
+ * classes are rejected (additionalProperties: false). Error messages never
6
+ * echo prohibited marker values (redaction assertion surface).
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { dirname, isAbsolute, join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import Ajv2020 from "ajv/dist/2020.js";
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ export const SESSION_EVENT_SCHEMA_PATH = join(HERE, "session-event.schema.json");
15
+ /** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
16
+ export const SESSION_EVENT_SCHEMA_SHA256 = "be300120b4793a5c6f1e713bf9f72b2fd22f6ac082bef8eedfa507b684b09ccb";
17
+ export const PROHIBITED_CONTENT_CLASSES = [
18
+ "prompts",
19
+ "model_output",
20
+ "transcripts",
21
+ "message_bodies",
22
+ "tokens",
23
+ "credentials",
24
+ ];
25
+ /** Fixture field name → prohibited class. */
26
+ export const PROHIBITED_FIELD_BY_CLASS = {
27
+ prompts: "prompt",
28
+ model_output: "modelOutput",
29
+ transcripts: "transcript",
30
+ message_bodies: "messageBody",
31
+ tokens: "token",
32
+ credentials: "credentials",
33
+ };
34
+ const REDACT_MARKERS = [
35
+ "REDACT_MARKER_PROMPT_",
36
+ "REDACT_MARKER_MODEL_OUTPUT_",
37
+ "REDACT_MARKER_TRANSCRIPT_",
38
+ "REDACT_MARKER_MESSAGE_BODY_",
39
+ "REDACT_MARKER_TOKEN_",
40
+ "REDACT_MARKER_CREDENTIALS_",
41
+ ];
42
+ let cachedValidate;
43
+ function loadSchema() {
44
+ return JSON.parse(readFileSync(SESSION_EVENT_SCHEMA_PATH, "utf8"));
45
+ }
46
+ export function getSessionEventValidator() {
47
+ if (cachedValidate)
48
+ return cachedValidate;
49
+ const ajv = new Ajv2020({
50
+ allErrors: true,
51
+ strict: false,
52
+ validateFormats: false,
53
+ });
54
+ cachedValidate = ajv.compile(loadSchema());
55
+ return cachedValidate;
56
+ }
57
+ /**
58
+ * Redact known prohibited markers from Ajv error text so diagnostics never
59
+ * leak prompt/token/credential material.
60
+ */
61
+ export function redactValidationMessage(message) {
62
+ let out = message;
63
+ for (const marker of REDACT_MARKERS) {
64
+ if (out.includes(marker)) {
65
+ out = out.split(marker)[0] + "[REDACTED]";
66
+ }
67
+ }
68
+ // Also strip any bearer-shaped or op:// fragments that slipped through.
69
+ out = out.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]");
70
+ out = out.replace(/op:\/\/[^\s"]+/g, "op://[REDACTED]");
71
+ return out;
72
+ }
73
+ function formatErrors(errors) {
74
+ if (!errors || errors.length === 0)
75
+ return ["validation_failed"];
76
+ return errors.map((e) => {
77
+ const path = e.instancePath || "/";
78
+ const msg = e.message ?? "invalid";
79
+ return redactValidationMessage(`${path}: ${msg}`);
80
+ });
81
+ }
82
+ export function validateSessionEvent(candidate) {
83
+ const validate = getSessionEventValidator();
84
+ const ok = validate(candidate);
85
+ if (ok)
86
+ return { ok: true, errors: [] };
87
+ return { ok: false, errors: formatErrors(validate.errors) };
88
+ }
89
+ export function sha256File(path) {
90
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
91
+ }
92
+ export function sha256SessionEventSchema() {
93
+ return sha256File(SESSION_EVENT_SCHEMA_PATH);
94
+ }
95
+ const HQ_PRO_SCHEMA_RELATIVE = "src/vault-service/work-mesh/live/session-event.schema.json";
96
+ /** Walk up from `start` until a directory containing package.json is found. */
97
+ function findRepoRoot(start) {
98
+ let dir = start;
99
+ for (;;) {
100
+ if (existsSync(join(dir, "package.json")))
101
+ return dir;
102
+ const parent = dirname(dir);
103
+ if (parent === dir)
104
+ return null;
105
+ dir = parent;
106
+ }
107
+ }
108
+ /**
109
+ * Resolve the hq-pro canonical schema path when available.
110
+ *
111
+ * Order: `HQ_PRO_SCHEMA_PATH` env override (absolute path to the hq-pro
112
+ * schema file) → sibling checkout
113
+ * `../hq-pro/src/vault-service/work-mesh/live/session-event.schema.json`
114
+ * from the hq-cli repo root → null.
115
+ * Shipped code must not hardcode machine-specific worktree paths.
116
+ */
117
+ export function resolveHqProSchemaPath() {
118
+ const envPath = process.env.HQ_PRO_SCHEMA_PATH?.trim();
119
+ if (envPath) {
120
+ return isAbsolute(envPath) ? envPath : resolve(envPath);
121
+ }
122
+ const repoRoot = findRepoRoot(HERE);
123
+ if (!repoRoot)
124
+ return null;
125
+ const sibling = resolve(repoRoot, "..", "hq-pro", HQ_PRO_SCHEMA_RELATIVE);
126
+ return existsSync(sibling) ? sibling : null;
127
+ }
128
+ //# sourceMappingURL=validate-session-event.js.map
@@ -38,6 +38,25 @@ export type QmdInvocation = {
38
38
  prefixArgs: string[];
39
39
  execDir?: string;
40
40
  };
41
+ /**
42
+ * Bounded, hq-DERIVED evidence stamped onto a CAPTURED qmd spawn-level failure
43
+ * (the interpreter-missing and residual QmdBinaryMissingError cases). The
44
+ * reporting host (HQ-CLI-1A) shipped no platform log, so spawnSync's ENOENT
45
+ * could not be pinned to a missing command vs. a missing working directory. The
46
+ * boundary forwards this into a bounded Sentry context so the NEXT occurrence
47
+ * carries the evidence this one lacked. Every field is hq-derived — the errno
48
+ * code, the resolved `cwd`, `process.execPath`, the bundled launcher, and their
49
+ * existence at failure time — never caller argv or query text.
50
+ */
51
+ export type QmdSpawnDiagnostics = {
52
+ errnoCode: string | null;
53
+ cwd: string | null;
54
+ cwdExists: boolean | null;
55
+ command: string;
56
+ commandExists: boolean;
57
+ launcher: string;
58
+ launcherExists: boolean;
59
+ };
41
60
  /** Low-level spawn seam (defaults to `spawnSync`); injected in tests. */
42
61
  export type QmdSpawn = (cmd: string, args: string[], options: {
43
62
  cwd?: string;
@@ -59,6 +78,34 @@ export type QmdSpawn = (cmd: string, args: string[], options: {
59
78
  export declare function withNodeDirOnPath(env: NodeJS.ProcessEnv | undefined, execDir: string | undefined, platform?: NodeJS.Platform): NodeJS.ProcessEnv | undefined;
60
79
  export declare class QmdBinaryMissingError extends Error {
61
80
  name: string;
81
+ /**
82
+ * Present only when the failure came through the real spawn path (attribution
83
+ * available): the errno code and per-component existence booleans captured at
84
+ * failure time, forwarded to a bounded Sentry context by the boundary so the
85
+ * next occurrence carries the evidence the reported one lacked (HQ-CLI-1A).
86
+ * Absent on the legacy injected-runner path and the resolver's opaque throws,
87
+ * so their existing shapes are unchanged.
88
+ */
89
+ readonly spawnDiagnostics?: QmdSpawnDiagnostics;
90
+ constructor(message: string, spawnDiagnostics?: QmdSpawnDiagnostics);
91
+ }
92
+ /**
93
+ * qmd could not be SPAWNED because its working directory does not exist:
94
+ * spawnSync returned ENOENT while chdir-ing into `cwd` (the resolved hq root at
95
+ * every index call site) before it could exec the command. On Linux that ENOENT
96
+ * is byte-identical to a missing-command ENOENT — Node sets `error.path` to the
97
+ * COMMAND either way — so finishRunQmd disambiguates by existence and raises
98
+ * THIS typed carrier only when the working directory itself is absent. Carries
99
+ * the hq-DERIVED `workdir`, the `command` that was spawned, and the bounded
100
+ * `errnoCode`; a missing working directory is the caller's filesystem, so the
101
+ * boundary prints qmdWorkdirMissingMessage and SKIPS capture (HQ-CLI-1A).
102
+ */
103
+ export declare class QmdWorkdirMissingError extends Error {
104
+ readonly workdir: string;
105
+ readonly command: string;
106
+ readonly errnoCode: string;
107
+ name: string;
108
+ constructor(message: string, workdir: string, command: string, errnoCode: string);
62
109
  }
63
110
  export declare class QmdExitError extends Error {
64
111
  readonly args: string[];
@@ -200,6 +247,51 @@ export declare class QmdStoreUnopenableError extends QmdExitError {
200
247
  name: string;
201
248
  constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, storeDir?: string | undefined, probeReason?: string | undefined);
202
249
  }
250
+ /**
251
+ * qmd's `query` subcommand (hybrid search) rejected the positional as a
252
+ * MALFORMED QUERY DOCUMENT: qmd 2.5.3 parses a multi-line positional as a query
253
+ * document and requires EVERY non-empty line to carry a lex:/vec:/hyde:/intent:/
254
+ * expand: prefix (dist/cli/qmd.js:2157-2217), throwing otherwise. That is the
255
+ * caller's INPUT SHAPE — a multi-line free-text query, most often a fleet agent
256
+ * forwarding a whole Slack message — not a bug HQ can fix in code.
257
+ *
258
+ * HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, 28 of 29 events): `hq search
259
+ * --mode hybrid` mapped to `qmd query` and handed the caller's multi-line query
260
+ * through unchanged; qmd exited 1 with `Line 1 is missing a lex:/vec:/hyde:/
261
+ * intent: prefix. …`. With no classifier the generic template raised a plain
262
+ * QmdExitError that reached the boundary's final else and was captured.
263
+ * buildSearchArgs now normalises free text to a single line so the common case
264
+ * never reaches qmd multi-line; this typed subclass lets the boundary classify a
265
+ * genuinely malformed structured document as the caller's input and stop
266
+ * fingerprinting on the query text: it is raised only from qmd's OWN captured
267
+ * streams and its message names the SUBCOMMAND only.
268
+ */
269
+ export declare class QmdQueryDocumentError extends QmdExitError {
270
+ name: string;
271
+ }
272
+ /**
273
+ * A qmd MODEL DOWNLOAD did not finish: qmd crashed with ENOENT while renaming its
274
+ * own partial `.ipull` download file into place. The partial file was gone by the
275
+ * time the rename ran — the signature of ANOTHER qmd process on the same box
276
+ * finishing (or clearing) the same download first. That is a transient, retryable
277
+ * LOCAL-CACHE race, not a bug HQ can fix in code: hq cannot coordinate qmd
278
+ * children other users start on the same host, and reaching into qmd's model
279
+ * cache would be hq owning upstream state.
280
+ *
281
+ * HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, the newest event, on release
282
+ * 5.107.1): `qmd query` reached its reranking stage and its reranker-model
283
+ * downloader died uncaught with `[Error: ENOENT: no such file or directory,
284
+ * rename '<cache>/…-reranker-….gguf.ipull' -> '<cache>/…-reranker-….gguf']`.
285
+ * With no classifier it took the identical fall-through to capture as the
286
+ * query-document crash — and because both share the (QmdExitError, qmd:query,
287
+ * exit:1) fingerprint, a query-document-only fix would leave this issue alive.
288
+ * This typed subclass lets the boundary classify it and stop the capture: it is
289
+ * raised only from qmd's OWN captured streams and its message names the
290
+ * SUBCOMMAND only.
291
+ */
292
+ export declare class QmdModelDownloadError extends QmdExitError {
293
+ name: string;
294
+ }
203
295
  export type ResolveQmdBinOptions = {
204
296
  env?: Record<string, string | undefined>;
205
297
  isExecutable?: (candidate: string) => boolean;
@@ -424,6 +516,12 @@ export type RunQmdOptions = {
424
516
  execPath?: string;
425
517
  /** Invocation resolver seam (default {@link resolveQmdInvocation}). */
426
518
  resolveInvocation?: (options: ResolveQmdInvocationOptions) => QmdInvocation;
519
+ /**
520
+ * Existence-check seam threaded into finishRunQmd's spawn-failure
521
+ * disambiguation (default `fs.existsSync`); injected in tests so the missing
522
+ * cwd / interpreter branches are provable without touching the real disk.
523
+ */
524
+ exists?: (candidate: string) => boolean;
427
525
  };
428
526
  /** Run qmd with captured output and typed failures. */
429
527
  export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
@@ -4,8 +4,10 @@ import { createRequire } from 'node:module';
4
4
  import * as os from 'node:os';
5
5
  import * as path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { isQmdModelDownloadError } from '../../utils/qmd-model-download-error.js';
7
8
  import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
8
9
  import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
10
+ import { isQmdQueryDocumentError } from '../../utils/qmd-query-document-error.js';
9
11
  import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
10
12
  import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-error.js';
11
13
  import { redactErrorText } from '../../utils/redact-error-text.js';
@@ -28,6 +30,43 @@ export function withNodeDirOnPath(env, execDir, platform = process.platform) {
28
30
  }
29
31
  export class QmdBinaryMissingError extends Error {
30
32
  name = 'QmdBinaryMissingError';
33
+ /**
34
+ * Present only when the failure came through the real spawn path (attribution
35
+ * available): the errno code and per-component existence booleans captured at
36
+ * failure time, forwarded to a bounded Sentry context by the boundary so the
37
+ * next occurrence carries the evidence the reported one lacked (HQ-CLI-1A).
38
+ * Absent on the legacy injected-runner path and the resolver's opaque throws,
39
+ * so their existing shapes are unchanged.
40
+ */
41
+ spawnDiagnostics;
42
+ constructor(message, spawnDiagnostics) {
43
+ super(message);
44
+ if (spawnDiagnostics)
45
+ this.spawnDiagnostics = spawnDiagnostics;
46
+ }
47
+ }
48
+ /**
49
+ * qmd could not be SPAWNED because its working directory does not exist:
50
+ * spawnSync returned ENOENT while chdir-ing into `cwd` (the resolved hq root at
51
+ * every index call site) before it could exec the command. On Linux that ENOENT
52
+ * is byte-identical to a missing-command ENOENT — Node sets `error.path` to the
53
+ * COMMAND either way — so finishRunQmd disambiguates by existence and raises
54
+ * THIS typed carrier only when the working directory itself is absent. Carries
55
+ * the hq-DERIVED `workdir`, the `command` that was spawned, and the bounded
56
+ * `errnoCode`; a missing working directory is the caller's filesystem, so the
57
+ * boundary prints qmdWorkdirMissingMessage and SKIPS capture (HQ-CLI-1A).
58
+ */
59
+ export class QmdWorkdirMissingError extends Error {
60
+ workdir;
61
+ command;
62
+ errnoCode;
63
+ name = 'QmdWorkdirMissingError';
64
+ constructor(message, workdir, command, errnoCode) {
65
+ super(message);
66
+ this.workdir = workdir;
67
+ this.command = command;
68
+ this.errnoCode = errnoCode;
69
+ }
31
70
  }
32
71
  export class QmdExitError extends Error {
33
72
  args;
@@ -189,6 +228,51 @@ export class QmdStoreUnopenableError extends QmdExitError {
189
228
  this.probeReason = probeReason;
190
229
  }
191
230
  }
231
+ /**
232
+ * qmd's `query` subcommand (hybrid search) rejected the positional as a
233
+ * MALFORMED QUERY DOCUMENT: qmd 2.5.3 parses a multi-line positional as a query
234
+ * document and requires EVERY non-empty line to carry a lex:/vec:/hyde:/intent:/
235
+ * expand: prefix (dist/cli/qmd.js:2157-2217), throwing otherwise. That is the
236
+ * caller's INPUT SHAPE — a multi-line free-text query, most often a fleet agent
237
+ * forwarding a whole Slack message — not a bug HQ can fix in code.
238
+ *
239
+ * HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, 28 of 29 events): `hq search
240
+ * --mode hybrid` mapped to `qmd query` and handed the caller's multi-line query
241
+ * through unchanged; qmd exited 1 with `Line 1 is missing a lex:/vec:/hyde:/
242
+ * intent: prefix. …`. With no classifier the generic template raised a plain
243
+ * QmdExitError that reached the boundary's final else and was captured.
244
+ * buildSearchArgs now normalises free text to a single line so the common case
245
+ * never reaches qmd multi-line; this typed subclass lets the boundary classify a
246
+ * genuinely malformed structured document as the caller's input and stop
247
+ * fingerprinting on the query text: it is raised only from qmd's OWN captured
248
+ * streams and its message names the SUBCOMMAND only.
249
+ */
250
+ export class QmdQueryDocumentError extends QmdExitError {
251
+ name = 'QmdQueryDocumentError';
252
+ }
253
+ /**
254
+ * A qmd MODEL DOWNLOAD did not finish: qmd crashed with ENOENT while renaming its
255
+ * own partial `.ipull` download file into place. The partial file was gone by the
256
+ * time the rename ran — the signature of ANOTHER qmd process on the same box
257
+ * finishing (or clearing) the same download first. That is a transient, retryable
258
+ * LOCAL-CACHE race, not a bug HQ can fix in code: hq cannot coordinate qmd
259
+ * children other users start on the same host, and reaching into qmd's model
260
+ * cache would be hq owning upstream state.
261
+ *
262
+ * HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, the newest event, on release
263
+ * 5.107.1): `qmd query` reached its reranking stage and its reranker-model
264
+ * downloader died uncaught with `[Error: ENOENT: no such file or directory,
265
+ * rename '<cache>/…-reranker-….gguf.ipull' -> '<cache>/…-reranker-….gguf']`.
266
+ * With no classifier it took the identical fall-through to capture as the
267
+ * query-document crash — and because both share the (QmdExitError, qmd:query,
268
+ * exit:1) fingerprint, a query-document-only fix would leave this issue alive.
269
+ * This typed subclass lets the boundary classify it and stop the capture: it is
270
+ * raised only from qmd's OWN captured streams and its message names the
271
+ * SUBCOMMAND only.
272
+ */
273
+ export class QmdModelDownloadError extends QmdExitError {
274
+ name = 'QmdModelDownloadError';
275
+ }
192
276
  function isExecutable(candidate) {
193
277
  try {
194
278
  fs.accessSync(candidate, fs.constants.X_OK);
@@ -919,7 +1003,7 @@ function describeQmdTermination(stdout, stderr) {
919
1003
  return '';
920
1004
  }
921
1005
  /** Normalise a spawn result and raise the typed qmd failures. */
922
- function finishRunQmd(result, bin, args) {
1006
+ function finishRunQmd(result, bin, args, attribution) {
923
1007
  const normalized = {
924
1008
  status: result.status,
925
1009
  stdout: result.stdout ?? '',
@@ -928,6 +1012,54 @@ function finishRunQmd(result, bin, args) {
928
1012
  signal: result.signal,
929
1013
  };
930
1014
  if (normalized.error) {
1015
+ // A spawn-LEVEL failure: the child could not even be exec'd. When the real
1016
+ // spawn path supplied attribution we can attribute it to the right
1017
+ // component; the legacy injected-runner path passes none and keeps today's
1018
+ // exact class and message.
1019
+ if (attribution) {
1020
+ const spawnError = normalized.error;
1021
+ const errnoCode = typeof spawnError.code === 'string' ? spawnError.code : null;
1022
+ const exists = attribution.exists ?? ((candidate) => fs.existsSync(candidate));
1023
+ const cwd = attribution.cwd ?? null;
1024
+ const cwdExists = cwd !== null ? exists(cwd) : null;
1025
+ const commandExists = exists(attribution.command);
1026
+ const launcherExists = exists(attribution.launcher);
1027
+ // (a) MISSING WORKING DIRECTORY. On Linux spawnSync reports ENOENT with
1028
+ // `error.path` set to the COMMAND both when the command is absent AND when
1029
+ // chdir into `cwd` fails, so this cwd check MUST precede the command check
1030
+ // below — a path-based test alone would keep blaming the interpreter for a
1031
+ // directory that is simply gone (HQ-CLI-1A). The directory is the caller's
1032
+ // filesystem, so it is raised as its own carrier and the boundary skips
1033
+ // capture.
1034
+ if (errnoCode === 'ENOENT' && cwd !== null && cwdExists === false) {
1035
+ throw new QmdWorkdirMissingError(`qmd could not run: its working directory (${cwd}) does not exist`, cwd, attribution.command, errnoCode);
1036
+ }
1037
+ const diagnostics = {
1038
+ errnoCode,
1039
+ cwd,
1040
+ cwdExists,
1041
+ command: attribution.command,
1042
+ commandExists,
1043
+ launcher: attribution.launcher,
1044
+ launcherExists,
1045
+ };
1046
+ // (b) MISSING INTERPRETER. The bundled qmd is launched as `<node> <qmd
1047
+ // launcher>`; when the interpreter (`command`, distinct from the launcher)
1048
+ // is what could not be executed, name IT rather than the launcher the
1049
+ // reported message wrongly blamed. Still captured — an absent interpreter
1050
+ // is a genuine defect — but now correct and self-describing.
1051
+ if (errnoCode === 'ENOENT' &&
1052
+ attribution.command !== attribution.launcher &&
1053
+ commandExists === false) {
1054
+ throw new QmdBinaryMissingError(`Unable to execute qmd: its Node interpreter (${attribution.command}) could not be executed: ${normalized.error.message}`, diagnostics);
1055
+ }
1056
+ // (c) RESIDUAL: an ENOENT the fix cannot attribute (a genuinely missing
1057
+ // qmd, or a race where the directory/interpreter reappeared) or any other
1058
+ // spawn errno (EACCES/EINVAL/…). Keep today's class and message byte-for-
1059
+ // byte, and attach the bounded diagnostics so the next occurrence carries
1060
+ // the per-component existence evidence.
1061
+ throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${normalized.error.message}`, diagnostics);
1062
+ }
931
1063
  throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${normalized.error.message}`);
932
1064
  }
933
1065
  if (normalized.status === 0)
@@ -1036,6 +1168,35 @@ function finishRunQmd(result, bin, args) {
1036
1168
  const ensure = lastQmdStoreEnsure;
1037
1169
  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
1170
  }
1171
+ // qmd's `query` subcommand rejected the positional as a MALFORMED QUERY
1172
+ // DOCUMENT: qmd 2.5.3 parses a multi-line positional as a document and requires
1173
+ // every non-empty line to carry a lex:/vec:/hyde:/intent:/expand: prefix, so a
1174
+ // multi-line free-text query (a fleet agent forwarding a whole Slack message)
1175
+ // exits 1 with a syntax diagnostic. That is the caller's INPUT SHAPE, not an
1176
+ // hq-cli defect. Read qmd's OWN captured streams only, never the synthesized
1177
+ // message, so a user query that merely contains the wording can never trip it.
1178
+ // hq-cli side: buildSearchArgs now normalises free text to one line so this no
1179
+ // longer fires for the common case; this branch types a genuinely malformed
1180
+ // structured document so the boundary prints a query-free remedy and skips
1181
+ // capture. Placed AFTER the store checks and BEFORE the collection-missing
1182
+ // regex; the wordings are disjoint (that regex needs collection/qmd:// adjacent
1183
+ // to a not-found token, absent here), so no existing branch changes behaviour
1184
+ // (HQ-CLI-1B / Sentry 7705711476).
1185
+ if (isQmdQueryDocumentError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
1186
+ throw new QmdQueryDocumentError(`qmd ${subcommand} could not run: the query was read as a malformed query document`, args, normalized.status, normalized.stdout, normalized.stderr);
1187
+ }
1188
+ // A qmd MODEL DOWNLOAD died with ENOENT renaming its own partial `.ipull` file
1189
+ // — the signature of another qmd process on the same box finishing or clearing
1190
+ // the same download first. That is a transient, retryable LOCAL-CACHE race, not
1191
+ // an hq-cli defect (hq cannot coordinate qmd children other users start on the
1192
+ // host). Require BOTH the ENOENT-rename signature AND the `.ipull` marker so a
1193
+ // durable failure (ENOSPC/EACCES/network) keeps its reportable path. Read qmd's
1194
+ // OWN captured streams only. Placed with the query-document check, before the
1195
+ // collection-missing regex; the signatures are disjoint, so no existing branch
1196
+ // changes behaviour (HQ-CLI-1B / Sentry 7705711476, the newest event).
1197
+ if (isQmdModelDownloadError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
1198
+ throw new QmdModelDownloadError(`qmd ${subcommand} could not run: a model download did not finish (its partial file disappeared)`, args, normalized.status, normalized.stdout, normalized.stderr);
1199
+ }
1039
1200
  if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(classifyText)) {
1040
1201
  throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
1041
1202
  }
@@ -1081,13 +1242,24 @@ export function runQmd(args, options = {}) {
1081
1242
  // idempotent; only the real spawn path needs it (the injected-runner path
1082
1243
  // above never opens a store).
1083
1244
  ensureQmdStoreDir(options.env ?? process.env, { cwd: options.cwd });
1245
+ const launcher = invocationBin(invocation);
1084
1246
  const result = spawnQmd(invocation, args, {
1085
1247
  cwd: options.cwd,
1086
1248
  env: options.env ?? process.env,
1087
1249
  platform: options.platform,
1088
1250
  spawn: options.spawn,
1089
1251
  });
1090
- return finishRunQmd(result, invocationBin(invocation), args);
1252
+ // Thread attribution so finishRunQmd can attribute a spawn-level ENOENT to the
1253
+ // right component — the working directory, the Node interpreter, or a
1254
+ // genuinely missing qmd — instead of blaming the launcher for all three
1255
+ // (HQ-CLI-1A). `command` is the process actually spawned; `launcher` is qmd's
1256
+ // file identity.
1257
+ return finishRunQmd(result, launcher, args, {
1258
+ command: invocation.command,
1259
+ launcher,
1260
+ cwd: options.cwd,
1261
+ exists: options.exists,
1262
+ });
1091
1263
  }
1092
1264
  function containsIndexedMarkdown(directory) {
1093
1265
  if (!fs.existsSync(directory))
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Atomic owner-only file writes with symlink and path guards for work-context.
3
+ */
4
+ /**
5
+ * Resolve symlinks on an existing path; for a missing leaf, resolve the parent
6
+ * and re-append the basename so we never rename onto a symlink.
7
+ */
8
+ export declare function resolveRealTarget(target: string): string;
9
+ /**
10
+ * Ensure parent dir exists as a real directory (not a symlink), mode 0700.
11
+ */
12
+ export declare function ensureOwnerDir(dir: string): string;
13
+ /**
14
+ * Atomically replace target with data at mode 0600 (same-dir tmp + rename).
15
+ */
16
+ export declare function atomicWriteFile(target: string, data: string | Buffer): void;
17
+ export declare function atomicWriteJson(target: string, value: unknown): void;
18
+ //# sourceMappingURL=atomic.d.ts.map
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Atomic owner-only file writes with symlink and path guards for work-context.
3
+ */
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { UnsafeConfigPathError } from "./errors.js";
7
+ function fsyncDir(dir) {
8
+ try {
9
+ const fd = fs.openSync(dir, "r");
10
+ try {
11
+ fs.fsyncSync(fd);
12
+ }
13
+ finally {
14
+ fs.closeSync(fd);
15
+ }
16
+ }
17
+ catch {
18
+ // Best-effort: some platforms reject directory fsync.
19
+ }
20
+ }
21
+ /**
22
+ * Resolve symlinks on an existing path; for a missing leaf, resolve the parent
23
+ * and re-append the basename so we never rename onto a symlink.
24
+ */
25
+ export function resolveRealTarget(target) {
26
+ try {
27
+ return fs.realpathSync(target);
28
+ }
29
+ catch {
30
+ // Leaf may not exist yet.
31
+ }
32
+ let cur = path.dirname(target);
33
+ const base = path.basename(target);
34
+ for (let i = 0; i < 40; i += 1) {
35
+ try {
36
+ const lst = fs.lstatSync(cur);
37
+ if (lst.isSymbolicLink()) {
38
+ throw new UnsafeConfigPathError(`Refusing write through symlink: ${cur}`);
39
+ }
40
+ const real = fs.realpathSync(cur);
41
+ return path.join(real, base);
42
+ }
43
+ catch (err) {
44
+ if (err instanceof UnsafeConfigPathError)
45
+ throw err;
46
+ const parent = path.dirname(cur);
47
+ if (parent === cur)
48
+ break;
49
+ cur = parent;
50
+ }
51
+ }
52
+ return target;
53
+ }
54
+ /**
55
+ * Ensure parent dir exists as a real directory (not a symlink), mode 0700.
56
+ */
57
+ export function ensureOwnerDir(dir) {
58
+ const resolved = (() => {
59
+ try {
60
+ if (fs.existsSync(dir)) {
61
+ const lst = fs.lstatSync(dir);
62
+ if (lst.isSymbolicLink()) {
63
+ throw new UnsafeConfigPathError(`Refusing directory that is a symlink: ${dir}`);
64
+ }
65
+ return fs.realpathSync(dir);
66
+ }
67
+ }
68
+ catch (err) {
69
+ if (err instanceof UnsafeConfigPathError)
70
+ throw err;
71
+ }
72
+ return dir;
73
+ })();
74
+ fs.mkdirSync(resolved, { recursive: true, mode: 0o700 });
75
+ try {
76
+ fs.chmodSync(resolved, 0o700);
77
+ }
78
+ catch {
79
+ // ignore chmod failures on exotic FS
80
+ }
81
+ const lst = fs.lstatSync(resolved);
82
+ if (lst.isSymbolicLink()) {
83
+ throw new UnsafeConfigPathError(`Refusing directory that is a symlink: ${resolved}`);
84
+ }
85
+ return fs.realpathSync(resolved);
86
+ }
87
+ /**
88
+ * Atomically replace target with data at mode 0600 (same-dir tmp + rename).
89
+ */
90
+ export function atomicWriteFile(target, data) {
91
+ const realTarget = resolveRealTarget(target);
92
+ const dir = ensureOwnerDir(path.dirname(realTarget));
93
+ const finalPath = path.join(dir, path.basename(realTarget));
94
+ const tmp = path.join(dir, `.${path.basename(finalPath)}.tmp.${process.pid}.${Date.now()}`);
95
+ let fd;
96
+ try {
97
+ fd = fs.openSync(tmp, "wx", 0o600);
98
+ fs.writeSync(fd, typeof data === "string" ? Buffer.from(data, "utf8") : data);
99
+ fs.fsyncSync(fd);
100
+ fs.closeSync(fd);
101
+ fd = undefined;
102
+ fs.renameSync(tmp, finalPath);
103
+ fsyncDir(dir);
104
+ try {
105
+ fs.chmodSync(finalPath, 0o600);
106
+ }
107
+ catch {
108
+ // ignore
109
+ }
110
+ }
111
+ catch (err) {
112
+ if (fd !== undefined) {
113
+ try {
114
+ fs.closeSync(fd);
115
+ }
116
+ catch {
117
+ /* already closed */
118
+ }
119
+ }
120
+ try {
121
+ fs.unlinkSync(tmp);
122
+ }
123
+ catch {
124
+ /* temp may not exist */
125
+ }
126
+ throw err;
127
+ }
128
+ }
129
+ export function atomicWriteJson(target, value) {
130
+ atomicWriteFile(target, `${JSON.stringify(value, null, 2)}\n`);
131
+ }
132
+ //# sourceMappingURL=atomic.js.map