@indigoai-us/hq-cli 5.108.5 → 5.108.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,669 @@
1
+ /**
2
+ * Debug-log and state capture for `hq feedback` submissions.
3
+ *
4
+ * A bug report is far cheaper to triage when the submitter's recent log output
5
+ * travels with it. This module collects two bounded, redacted classes of
6
+ * evidence from the user's `~/.hq` directory and hands them to the feedback
7
+ * command, which attaches them to the `diagnostics` blob on the submission:
8
+ *
9
+ * - LOG TAILS (`files`) — the end of each rolling log. One file covers more
10
+ * than its name suggests: `logs/hq-sync.log` is the shared diagnostic log
11
+ * for BOTH the hq-cloud sync engine and the desktop sync app, which write
12
+ * to it through the same path, and its ROTATED generations
13
+ * (`hq-sync.log.1`, `.2`, `.3`) are eligible too — just after a rotation
14
+ * the active file is nearly empty and the history lives in `.1`.
15
+ * `logs/mcp-registry.log` is the CLI's own append-only MCP audit trail.
16
+ * Any future `*.log`/`*.jsonl` dropped into `~/.hq/logs/` is picked up
17
+ * automatically.
18
+ *
19
+ * - STATE SNAPSHOTS (`state`) — small JSON files that describe where sync
20
+ * and client health currently stand: the in-flight sync phase and conflict
21
+ * count, the client-health failure streak, the per-company sync journal
22
+ * locators. These are tiny and answer the questions a log tail usually
23
+ * cannot ("was it mid-pull?", "how many conflicts?", "how long since the
24
+ * last successful sync?"), so they are collected FIRST and the log budget
25
+ * takes what remains.
26
+ *
27
+ * Contrast with `local-files-overview.ts`, which deliberately sends only
28
+ * metadata (existence, size, age, error-line counts) and NEVER a line of log
29
+ * text. That module feeds the BACKGROUND client-health heartbeat, where the
30
+ * user has not asked for anything and content would be surveillance. This
31
+ * module runs only on an EXPLICIT, user-initiated `hq feedback` invocation,
32
+ * where the user is asking us to look at their problem, so content is both
33
+ * expected and useful. The two must not be merged.
34
+ *
35
+ * Three hard safety properties, each with a regression test:
36
+ *
37
+ * 1. ALLOWLIST, NOT A SCAN. `~/.hq` also holds `cognito-tokens.json`,
38
+ * `deploy-passwords.json`, and `secrets-cache/`. Only `*.log` files
39
+ * directly inside `~/.hq/logs/` plus the two known root-level boot logs
40
+ * are ever eligible. A recursive walk over `~/.hq` is a credential leak
41
+ * and must never be introduced here.
42
+ *
43
+ * 2. NO SYMLINKS. Eligibility is decided with `lstat`, and a candidate must
44
+ * be a REGULAR file. Without this, a symlink named `anything.log` inside
45
+ * `~/.hq/logs/` pointing at `~/.hq/cognito-tokens.json` would satisfy the
46
+ * allowlist and exfiltrate the user's tokens. `stat` would follow the link
47
+ * and report a regular file — `lstat` is the whole guard.
48
+ *
49
+ * 3. REDACTION IS APPLIED BEFORE BUDGETING. Every tail passes through
50
+ * `redactLogText` before it is measured or returned, so no code path can
51
+ * emit an unredacted span. Redaction is defence in depth, not the primary
52
+ * control — properties 1 and 2 are what keep secrets out.
53
+ *
54
+ * Every filesystem operation is independently best-effort: a missing file, a
55
+ * permission error, or a file that disappears mid-read degrades that one entry
56
+ * and never throws. Collecting diagnostics must never break a bug report.
57
+ */
58
+ import * as fs from "node:fs";
59
+ import * as os from "node:os";
60
+ import * as path from "node:path";
61
+ /**
62
+ * Ceiling on the total redacted log text attached to one submission.
63
+ *
64
+ * The server caps the WHOLE feedback request body at 64 KiB, so this is only
65
+ * an upper bound — the caller passes a smaller `budgetBytes` computed from the
66
+ * headroom actually left after the title, body, and the rest of diagnostics.
67
+ * See `submitFeedback` in `../commands/feedback.ts`.
68
+ */
69
+ export const FEEDBACK_LOGS_MAX_TOTAL_BYTES = 40 * 1024;
70
+ /** Per-file ceiling, so one noisy log cannot consume the entire budget. */
71
+ export const FEEDBACK_LOGS_MAX_FILE_TAIL_BYTES = 16 * 1024;
72
+ /**
73
+ * Below this much remaining budget a tail is too short to be worth reading,
74
+ * so the file is recorded as skipped rather than truncated into noise.
75
+ */
76
+ export const FEEDBACK_LOGS_MIN_USEFUL_BYTES = 1024;
77
+ /**
78
+ * Ceiling on how many skipped entries are recorded.
79
+ *
80
+ * `skipped` is metadata, not payload, so it was originally left outside the
81
+ * byte budget — which made it unbounded. An installation with hundreds of log
82
+ * files produced hundreds of `{name, reason}` objects and a blob several times
83
+ * the requested budget; `attachDebugLogs` then dropped the whole thing to stay
84
+ * under the request cap, so the user with the MOST log history got the LEAST
85
+ * evidence. The overflow count preserves the signal without the bytes.
86
+ */
87
+ export const FEEDBACK_MAX_SKIPPED_ENTRIES = 25;
88
+ /** Subdirectory of `~/.hq` whose log files are eligible. Not recursive. */
89
+ export const FEEDBACK_LOGS_DIRNAME = "logs";
90
+ /**
91
+ * Eligible extensions inside `~/.hq/logs/`. `.jsonl` is included because the
92
+ * CLI's MCP registry audit trail is JSON-lines, not plain text.
93
+ */
94
+ export const FEEDBACK_LOG_EXTENSIONS = [".log", ".jsonl"];
95
+ /**
96
+ * True for a log filename, including a ROTATED generation.
97
+ *
98
+ * The desktop/sync logger keeps 32 MiB x 3 generations named `hq-sync.log.1`,
99
+ * `.2`, `.3` (see hq-desktop-core `logfile.rs`). A plain "ends with .log"
100
+ * test misses every one of them — and they matter most in the worst case: just
101
+ * after a rotation the active log is nearly empty and all the history a
102
+ * triager needs sits in `.1`.
103
+ */
104
+ export function isEligibleLogName(name) {
105
+ const lower = name.toLowerCase();
106
+ if (FEEDBACK_LOG_EXTENSIONS.some((ext) => lower.endsWith(ext)))
107
+ return true;
108
+ const rotated = /^(.+)\.(\d+)$/.exec(lower);
109
+ if (!rotated)
110
+ return false;
111
+ const base = rotated[1];
112
+ return FEEDBACK_LOG_EXTENSIONS.some((ext) => base.endsWith(ext));
113
+ }
114
+ /** Per-state-file ceiling. These are status documents, not streams. */
115
+ export const FEEDBACK_STATE_MAX_FILE_BYTES = 4 * 1024;
116
+ /**
117
+ * Ceiling on all state snapshots combined. Deliberately a small slice of the
118
+ * overall budget: state is dense but finite, and the rest belongs to logs.
119
+ */
120
+ export const FEEDBACK_STATE_MAX_TOTAL_BYTES = 12 * 1024;
121
+ /**
122
+ * State files at the `~/.hq` ROOT that are eligible, by EXACT name.
123
+ *
124
+ * The root is where `cognito-tokens.json`, `deploy-passwords.json`, and
125
+ * `secrets-cache/` live, so it is never globbed. Adding a name here is a
126
+ * deliberate act: confirm the file carries no credential before listing it.
127
+ */
128
+ export const FEEDBACK_STATE_FILENAMES = [
129
+ // In-flight sync: phase, file counts, conflict count, current file.
130
+ "sync-progress.json",
131
+ // Sync contract version the installation last wrote.
132
+ "sync-version.json",
133
+ // Client-health heartbeat: installation id and consecutive failure streak.
134
+ "cli-client-health.json",
135
+ // Last observed sync/heartbeat timestamps.
136
+ "cli-client-health.observation.json",
137
+ // Outpost session liveness — the answer to "is my Outpost still alive".
138
+ "outpost-session-heartbeat.json",
139
+ // Cached latest release + fetch time — the answer to "why won't it update".
140
+ "version-check.json",
141
+ // Plan-limit nag state.
142
+ "plan-limit-nag.json",
143
+ ];
144
+ /**
145
+ * Per-company sync journal LOCATORS (`sync-journal.<slug>.json`) are also
146
+ * eligible. Matched by an exact prefix AND a `.json` suffix, which is what
147
+ * excludes the multi-hundred-megabyte `…json.last-good` snapshots sitting
148
+ * beside them. No credential file at the root carries this prefix.
149
+ */
150
+ export const FEEDBACK_STATE_JOURNAL_PREFIX = "sync-journal.";
151
+ /**
152
+ * `~/.hq/locks/` — held operation locks and background-work claims.
153
+ *
154
+ * The highest-signal thing in the whole collection, and the one place where the
155
+ * FILENAME is the diagnosis: a `…stale-claim…` entry names the operation, the
156
+ * process that abandoned it, and the fact that the claim went stale. That is
157
+ * the answer to "sync/reindex is stuck", which is otherwise invisible in a log
158
+ * tail because a hung process writes nothing. Contents are tiny (tens to a
159
+ * couple hundred bytes) and included too.
160
+ */
161
+ export const FEEDBACK_LOCKS_DIRNAME = "locks";
162
+ /**
163
+ * `~/.hq/jobs/` — scheduled-job status, reconcile results, and probe attempts.
164
+ * Nested one or two levels (`jobs/probes/<job>/last-attempt.json`), so this is
165
+ * the only source needing a directory walk. See {@link walkJsonFiles} for the
166
+ * bounds that walk carries.
167
+ */
168
+ export const FEEDBACK_JOBS_DIRNAME = "jobs";
169
+ /** Per-entry ceiling for a lock file. Their value is the name plus a little context. */
170
+ export const FEEDBACK_LOCK_MAX_FILE_BYTES = 512;
171
+ /**
172
+ * Bounds on the `jobs/` walk. Depth 3 reaches
173
+ * `jobs/probes/<job>/last-attempt.json`; the file cap stops a pathological
174
+ * tree from crowding out logs.
175
+ */
176
+ export const FEEDBACK_JOBS_MAX_DEPTH = 3;
177
+ export const FEEDBACK_JOBS_MAX_FILES = 20;
178
+ /** Ceiling on how many lock entries are collected. */
179
+ export const FEEDBACK_LOCKS_MAX_FILES = 20;
180
+ /**
181
+ * Log files that live at the root of `~/.hq` rather than under `logs/`.
182
+ * Listed explicitly — the root directory holds credentials and must never be
183
+ * enumerated by pattern.
184
+ */
185
+ export const FEEDBACK_ROOT_LOG_FILENAMES = [
186
+ "boot-sync.log",
187
+ "boot-capture.log",
188
+ ];
189
+ /**
190
+ * Redaction patterns, applied in order. Structured secrets (whole PEM blocks)
191
+ * come first so a later, narrower pattern cannot chop one into fragments that
192
+ * then escape.
193
+ */
194
+ const REDACTION_PATTERNS = [
195
+ // Whole PEM private-key blocks.
196
+ {
197
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
198
+ replace: () => "[redacted private key]",
199
+ },
200
+ // JSON Web Tokens (Cognito access/id/refresh tokens land in logs this way).
201
+ {
202
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/g,
203
+ replace: () => "[redacted jwt]",
204
+ },
205
+ // AWS access key identifiers.
206
+ {
207
+ pattern: /\b(?:AKIA|ASIA|AIDA|AROA|AGPA|AIPA|ANPA|ANVA)[0-9A-Z]{16}\b/g,
208
+ replace: () => "[redacted aws key id]",
209
+ },
210
+ // GitHub personal access / OAuth / refresh tokens.
211
+ {
212
+ pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
213
+ replace: () => "[redacted github token]",
214
+ },
215
+ // Slack bot/user/app tokens.
216
+ {
217
+ pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g,
218
+ replace: () => "[redacted slack token]",
219
+ },
220
+ // SigV4 presigned-URL material. Keeps the URL readable, drops what signs it.
221
+ {
222
+ pattern: /([?&](?:X-Amz-Signature|X-Amz-Credential|X-Amz-Security-Token)=)[^&\s"']+/gi,
223
+ replace: (_m, prefix) => `${prefix}[redacted]`,
224
+ },
225
+ // `Authorization: Bearer <token>` in any casing.
226
+ {
227
+ pattern: /\b([Bb]earer\s+)[A-Za-z0-9._~+/=-]{12,}/g,
228
+ replace: (_m, prefix) => `${prefix}[redacted]`,
229
+ },
230
+ // Generic `key: value` / `key=value` for names that denote a secret. Runs
231
+ // last so the specific patterns above produce the more informative label.
232
+ {
233
+ pattern: /("?\b(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|authorization|credential|session[_-]?token)"?\s*[:=]\s*"?)([^\s",;&}]{6,})/gi,
234
+ replace: (_m, prefix) => `${prefix}[redacted]`,
235
+ },
236
+ ];
237
+ /**
238
+ * Replace every secret-shaped span in `text`. Returns the redacted text and a
239
+ * count of replacements, so triage can tell "no secrets present" from
240
+ * "redaction never ran".
241
+ */
242
+ export function redactLogText(text) {
243
+ let out = text;
244
+ let redactions = 0;
245
+ for (const { pattern, replace } of REDACTION_PATTERNS) {
246
+ out = out.replace(pattern, (match, ...rest) => {
247
+ redactions++;
248
+ // String.replace passes (match, ...groups, offset, string). Keep only
249
+ // the capture groups — the trailing offset/string are not groups.
250
+ const groups = rest.slice(0, Math.max(0, rest.length - 2));
251
+ return replace(match, ...groups);
252
+ });
253
+ }
254
+ return { text: out, redactions };
255
+ }
256
+ /**
257
+ * `true` unless the user disabled log capture via `HQ_FEEDBACK_LOGS`.
258
+ *
259
+ * Uses `?.trim() ||` rather than `??` on purpose: an env var that is SET BUT
260
+ * EMPTY must fall back to the default, and `??` would pass `""` through as a
261
+ * deliberate choice.
262
+ */
263
+ export function feedbackLogsEnabled(env = process.env) {
264
+ const raw = env.HQ_FEEDBACK_LOGS?.trim().toLowerCase() || "";
265
+ if (raw === "")
266
+ return true;
267
+ return !(raw === "0" || raw === "false" || raw === "no" || raw === "off");
268
+ }
269
+ /** Best-effort `lstat`. Returns null on any error. */
270
+ function lstatOrNull(absPath) {
271
+ try {
272
+ return fs.lstatSync(absPath);
273
+ }
274
+ catch {
275
+ return null;
276
+ }
277
+ }
278
+ /**
279
+ * Enumerate eligible log files under `hqDir`.
280
+ *
281
+ * Eligibility is the allowlist described in the module header: `*.log` files
282
+ * directly inside `hqDir/logs/`, plus {@link FEEDBACK_ROOT_LOG_FILENAMES} at
283
+ * the root. Every candidate must be a REGULAR file by `lstat` — directories
284
+ * and, critically, symlinks are rejected. Returned newest-modified first so
285
+ * the most relevant log is offered the budget before older ones.
286
+ */
287
+ export function discoverLogFiles(hqDir) {
288
+ const candidates = [];
289
+ const consider = (relName) => {
290
+ const absPath = path.join(hqDir, relName);
291
+ const st = lstatOrNull(absPath);
292
+ // isFile() is false for both directories and symlinks under lstat. A
293
+ // symlink here would be the credential-exfiltration path (see header).
294
+ if (!st || !st.isFile())
295
+ return;
296
+ candidates.push({
297
+ name: relName,
298
+ absPath,
299
+ sizeBytes: st.size,
300
+ modifiedMs: st.mtimeMs,
301
+ });
302
+ };
303
+ let entries;
304
+ try {
305
+ entries = fs.readdirSync(path.join(hqDir, FEEDBACK_LOGS_DIRNAME));
306
+ }
307
+ catch {
308
+ // No logs directory on this installation — root logs may still exist.
309
+ entries = [];
310
+ }
311
+ for (const entry of entries) {
312
+ if (!isEligibleLogName(entry))
313
+ continue;
314
+ consider(`${FEEDBACK_LOGS_DIRNAME}/${entry}`);
315
+ }
316
+ for (const filename of FEEDBACK_ROOT_LOG_FILENAMES) {
317
+ consider(filename);
318
+ }
319
+ candidates.sort((a, b) => b.modifiedMs - a.modifiedMs);
320
+ return candidates;
321
+ }
322
+ /**
323
+ * Read the last `maxBytes` of a file.
324
+ *
325
+ * When the file is larger than the window, the first (partial) line is dropped
326
+ * so the tail always starts at a line boundary. Returns null if the file
327
+ * cannot be read.
328
+ */
329
+ export function readLogTail(absPath, maxBytes) {
330
+ let fd = null;
331
+ try {
332
+ fd = fs.openSync(absPath, "r");
333
+ const size = fs.fstatSync(fd).size;
334
+ const readBytes = Math.min(size, maxBytes);
335
+ if (readBytes <= 0)
336
+ return { text: "", truncated: false };
337
+ const buf = Buffer.allocUnsafe(readBytes);
338
+ const got = fs.readSync(fd, buf, 0, readBytes, size - readBytes);
339
+ let text = buf.subarray(0, got).toString("utf8");
340
+ const truncated = size > readBytes;
341
+ if (truncated) {
342
+ const nl = text.indexOf("\n");
343
+ // Drop the leading partial line. If the window holds no newline at all
344
+ // the whole tail is one giant line; keep it rather than returning "".
345
+ if (nl >= 0 && nl + 1 < text.length)
346
+ text = text.slice(nl + 1);
347
+ }
348
+ return { text, truncated };
349
+ }
350
+ catch {
351
+ return null;
352
+ }
353
+ finally {
354
+ if (fd !== null) {
355
+ try {
356
+ fs.closeSync(fd);
357
+ }
358
+ catch {
359
+ // Best effort — a failed close must not fail the submission.
360
+ }
361
+ }
362
+ }
363
+ }
364
+ /**
365
+ * Enumerate eligible state files at the `~/.hq` root.
366
+ *
367
+ * Two ways in, both narrow: an exact name in {@link FEEDBACK_STATE_FILENAMES},
368
+ * or the `sync-journal.<slug>.json` shape. As with logs, every candidate must
369
+ * be a REGULAR file by `lstat`, so a symlink cannot be used to reach a
370
+ * credential file through an eligible-looking name.
371
+ */
372
+ export function discoverStateFiles(hqDir) {
373
+ let entries;
374
+ try {
375
+ entries = fs.readdirSync(hqDir);
376
+ }
377
+ catch {
378
+ return [];
379
+ }
380
+ const candidates = [];
381
+ for (const entry of entries) {
382
+ const eligible = FEEDBACK_STATE_FILENAMES.includes(entry) ||
383
+ (entry.startsWith(FEEDBACK_STATE_JOURNAL_PREFIX) && entry.endsWith(".json"));
384
+ if (!eligible)
385
+ continue;
386
+ const absPath = path.join(hqDir, entry);
387
+ const st = lstatOrNull(absPath);
388
+ if (!st || !st.isFile())
389
+ continue;
390
+ candidates.push({
391
+ name: entry,
392
+ absPath,
393
+ sizeBytes: st.size,
394
+ modifiedMs: st.mtimeMs,
395
+ });
396
+ }
397
+ candidates.sort((a, b) => a.name.localeCompare(b.name));
398
+ return candidates;
399
+ }
400
+ /**
401
+ * Enumerate flat, non-recursive entries of a `~/.hq` subdirectory.
402
+ *
403
+ * Used for `locks/`, where every entry is eligible regardless of extension —
404
+ * the filename itself carries the diagnosis. As everywhere else in this module,
405
+ * `lstat` decides eligibility, so a symlink dropped into the directory is
406
+ * skipped rather than followed.
407
+ *
408
+ * Results are sorted NEWEST-FIRST and only then truncated to `maxFiles`, which
409
+ * is load-bearing rather than cosmetic. Sorting by name and cutting at the cap
410
+ * silently drops the most diagnostic entry: ordinary `operation-*.lock` files
411
+ * sort ahead of a `qmd-reindex-bg.claim…stale-claim…`, so an installation with
412
+ * more than `maxFiles` locks would have excluded exactly the entry this source
413
+ * exists to capture. Recency is the right proxy for relevance here, and it
414
+ * matches how log files are prioritised.
415
+ */
416
+ export function discoverFlatDirFiles(hqDir, dirName, maxFiles) {
417
+ let entries;
418
+ try {
419
+ entries = fs.readdirSync(path.join(hqDir, dirName));
420
+ }
421
+ catch {
422
+ return [];
423
+ }
424
+ const candidates = [];
425
+ for (const entry of entries) {
426
+ const absPath = path.join(hqDir, dirName, entry);
427
+ const st = lstatOrNull(absPath);
428
+ if (!st || !st.isFile())
429
+ continue;
430
+ candidates.push({
431
+ name: `${dirName}/${entry}`,
432
+ absPath,
433
+ sizeBytes: st.size,
434
+ modifiedMs: st.mtimeMs,
435
+ });
436
+ }
437
+ // Sort BEFORE truncating, so the cap drops the least recent rather than
438
+ // whatever happened to sort last by name.
439
+ candidates.sort((a, b) => b.modifiedMs - a.modifiedMs || a.name.localeCompare(b.name));
440
+ return candidates.slice(0, maxFiles);
441
+ }
442
+ /**
443
+ * Walk a `~/.hq` subdirectory for `.json` files, bounded in BOTH depth and
444
+ * count.
445
+ *
446
+ * This is the module's only recursive traversal, so it is deliberately the
447
+ * most constrained. Three bounds, each load-bearing:
448
+ *
449
+ * - `lstat` per entry, and recursion only into a REAL directory. A symlinked
450
+ * directory is never followed, so a link at `jobs/x -> /home/user` cannot
451
+ * turn a two-level walk into a scan of the home directory — where, as it
452
+ * happens, `~/.codex/auth.json` and `~/.hq-agent/machine-creds.json` live.
453
+ * This is the same guard the flat enumerations use, applied to directories
454
+ * rather than files, and it is what keeps "walk a subtree" from being a
455
+ * categorically riskier operation than the allowlists around it.
456
+ * - `maxDepth`, so a deep tree cannot cost unbounded stat calls.
457
+ * - `maxFiles`, so a wide tree cannot crowd the log budget.
458
+ *
459
+ * Entries are returned sorted by name for a stable, reviewable order.
460
+ */
461
+ export function walkJsonFiles(hqDir, dirName, maxDepth, maxFiles) {
462
+ const candidates = [];
463
+ const walk = (relDir, depth) => {
464
+ if (depth > maxDepth || candidates.length >= maxFiles)
465
+ return;
466
+ let entries;
467
+ try {
468
+ entries = fs.readdirSync(path.join(hqDir, relDir));
469
+ }
470
+ catch {
471
+ return;
472
+ }
473
+ for (const entry of entries.sort()) {
474
+ if (candidates.length >= maxFiles)
475
+ return;
476
+ const rel = `${relDir}/${entry}`;
477
+ const absPath = path.join(hqDir, rel);
478
+ const st = lstatOrNull(absPath);
479
+ if (!st)
480
+ continue;
481
+ // Recurse ONLY into a real directory: isDirectory() is false under lstat
482
+ // for a symlink, so links are never traversed.
483
+ if (st.isDirectory()) {
484
+ walk(rel, depth + 1);
485
+ continue;
486
+ }
487
+ if (!st.isFile())
488
+ continue;
489
+ if (!entry.toLowerCase().endsWith(".json"))
490
+ continue;
491
+ candidates.push({
492
+ name: rel,
493
+ absPath,
494
+ sizeBytes: st.size,
495
+ modifiedMs: st.mtimeMs,
496
+ });
497
+ }
498
+ };
499
+ walk(dirName, 1);
500
+ return candidates;
501
+ }
502
+ /**
503
+ * Read up to `maxBytes` from the START of a file. State documents are JSON,
504
+ * whose meaningful keys sit at the top, so a head is more useful than a tail.
505
+ * Returns null if the file cannot be read.
506
+ */
507
+ export function readFileHead(absPath, maxBytes) {
508
+ let fd = null;
509
+ try {
510
+ fd = fs.openSync(absPath, "r");
511
+ const size = fs.fstatSync(fd).size;
512
+ const readBytes = Math.min(size, maxBytes);
513
+ if (readBytes <= 0)
514
+ return { text: "", truncated: false };
515
+ const buf = Buffer.allocUnsafe(readBytes);
516
+ const got = fs.readSync(fd, buf, 0, readBytes, 0);
517
+ return { text: buf.subarray(0, got).toString("utf8"), truncated: size > readBytes };
518
+ }
519
+ catch {
520
+ return null;
521
+ }
522
+ finally {
523
+ if (fd !== null) {
524
+ try {
525
+ fs.closeSync(fd);
526
+ }
527
+ catch {
528
+ // Best effort — a failed close must not fail the submission.
529
+ }
530
+ }
531
+ }
532
+ }
533
+ /**
534
+ * Collect redacted `~/.hq` evidence within `budgetBytes`.
535
+ *
536
+ * State snapshots are taken first, capped by
537
+ * {@link FEEDBACK_STATE_MAX_TOTAL_BYTES}, because they are small and answer
538
+ * questions a log tail cannot. Log tails then take whatever budget remains.
539
+ *
540
+ * Returns `undefined` when nothing eligible exists at all, so the caller can
541
+ * omit the field entirely rather than attach an empty object. Never throws.
542
+ */
543
+ export function collectFeedbackLogs(opts = {}) {
544
+ const budgetBytes = Math.max(0, Math.min(opts.budgetBytes ?? FEEDBACK_LOGS_MAX_TOTAL_BYTES, FEEDBACK_LOGS_MAX_TOTAL_BYTES));
545
+ const hqDir = opts.hqDir ?? path.join(opts.homeDir ?? os.homedir(), ".hq");
546
+ let logCandidates;
547
+ let stateCandidates;
548
+ try {
549
+ logCandidates = discoverLogFiles(hqDir);
550
+ // Order matters: root status documents first, then the lock/claim files
551
+ // (highest signal for a hang), then scheduled-job status. Under a tight
552
+ // budget the earlier ones win, and that is the intended priority.
553
+ stateCandidates = [
554
+ ...discoverStateFiles(hqDir),
555
+ ...discoverFlatDirFiles(hqDir, FEEDBACK_LOCKS_DIRNAME, FEEDBACK_LOCKS_MAX_FILES),
556
+ ...walkJsonFiles(hqDir, FEEDBACK_JOBS_DIRNAME, FEEDBACK_JOBS_MAX_DEPTH, FEEDBACK_JOBS_MAX_FILES),
557
+ ];
558
+ }
559
+ catch {
560
+ return undefined;
561
+ }
562
+ if (logCandidates.length === 0 && stateCandidates.length === 0)
563
+ return undefined;
564
+ const files = [];
565
+ const state = [];
566
+ const skipped = [];
567
+ let skippedOverflow = 0;
568
+ const recordSkip = (name, reason) => {
569
+ if (skipped.length < FEEDBACK_MAX_SKIPPED_ENTRIES) {
570
+ skipped.push({ name, reason });
571
+ }
572
+ else {
573
+ skippedOverflow++;
574
+ }
575
+ };
576
+ let remaining = budgetBytes;
577
+ // State first: dense, bounded, and the part a triager reads before the logs.
578
+ let stateRemaining = Math.min(remaining, FEEDBACK_STATE_MAX_TOTAL_BYTES);
579
+ for (const candidate of stateCandidates) {
580
+ // A lock file is the one case where an EMPTY file is still evidence: the
581
+ // filename carries the operation, the owning pid, and whether the claim
582
+ // went stale, and many lock implementations write no body at all. Skipping
583
+ // it for emptiness would discard the single highest-signal artifact for a
584
+ // hang, so locks are kept with empty content and only their metadata.
585
+ const isLock = candidate.name.startsWith(`${FEEDBACK_LOCKS_DIRNAME}/`);
586
+ if (candidate.sizeBytes === 0 && !isLock) {
587
+ recordSkip(candidate.name, "empty");
588
+ continue;
589
+ }
590
+ if (stateRemaining <= 0) {
591
+ recordSkip(candidate.name, "budget");
592
+ continue;
593
+ }
594
+ const perFileMax = candidate.name.startsWith(`${FEEDBACK_LOCKS_DIRNAME}/`)
595
+ ? FEEDBACK_LOCK_MAX_FILE_BYTES
596
+ : FEEDBACK_STATE_MAX_FILE_BYTES;
597
+ const window = Math.min(stateRemaining, perFileMax);
598
+ const raw = readFileHead(candidate.absPath, window);
599
+ if (raw === null) {
600
+ recordSkip(candidate.name, "unreadable");
601
+ continue;
602
+ }
603
+ const { text, redactions } = redactLogText(raw.text);
604
+ if (text.trim().length === 0 && !isLock) {
605
+ recordSkip(candidate.name, "empty");
606
+ continue;
607
+ }
608
+ const includedBytes = Buffer.byteLength(text, "utf8");
609
+ state.push({
610
+ name: candidate.name,
611
+ sizeBytes: candidate.sizeBytes,
612
+ modifiedIso: Number.isFinite(candidate.modifiedMs)
613
+ ? new Date(candidate.modifiedMs).toISOString()
614
+ : null,
615
+ includedBytes,
616
+ truncated: raw.truncated,
617
+ redactions,
618
+ content: text,
619
+ });
620
+ stateRemaining -= includedBytes;
621
+ remaining -= includedBytes;
622
+ }
623
+ for (const candidate of logCandidates) {
624
+ if (candidate.sizeBytes === 0) {
625
+ recordSkip(candidate.name, "empty");
626
+ continue;
627
+ }
628
+ if (remaining < FEEDBACK_LOGS_MIN_USEFUL_BYTES) {
629
+ recordSkip(candidate.name, "budget");
630
+ continue;
631
+ }
632
+ const window = Math.min(remaining, FEEDBACK_LOGS_MAX_FILE_TAIL_BYTES);
633
+ const raw = readLogTail(candidate.absPath, window);
634
+ if (raw === null) {
635
+ recordSkip(candidate.name, "unreadable");
636
+ continue;
637
+ }
638
+ // Redact BEFORE measuring, so the budget is spent on what actually ships
639
+ // and no unredacted span can reach the return value.
640
+ const { text, redactions } = redactLogText(raw.text);
641
+ if (text.trim().length === 0) {
642
+ recordSkip(candidate.name, "empty");
643
+ continue;
644
+ }
645
+ const includedBytes = Buffer.byteLength(text, "utf8");
646
+ files.push({
647
+ name: candidate.name,
648
+ sizeBytes: candidate.sizeBytes,
649
+ modifiedIso: Number.isFinite(candidate.modifiedMs)
650
+ ? new Date(candidate.modifiedMs).toISOString()
651
+ : null,
652
+ includedBytes,
653
+ // Truncated if we only read a window, OR if redaction happened to be a
654
+ // no-op but the read itself was partial.
655
+ truncated: raw.truncated,
656
+ redactions,
657
+ tail: text,
658
+ });
659
+ remaining -= includedBytes;
660
+ }
661
+ return {
662
+ budgetBytes,
663
+ files,
664
+ state,
665
+ skipped,
666
+ ...(skippedOverflow > 0 ? { skippedOverflow } : {}),
667
+ };
668
+ }
669
+ //# sourceMappingURL=feedback-logs.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.5",
3
+ "version": "5.108.6",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -31,7 +31,7 @@
31
31
  "dependencies": {
32
32
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
33
33
  "@aws-sdk/client-s3": "^3.1049.0",
34
- "@indigoai-us/hq-cloud": "~6.16.6",
34
+ "@indigoai-us/hq-cloud": "~6.16.11",
35
35
  "@indigoai-us/hq-flags-client": "^0.1.2",
36
36
  "@indigoai-us/hq-onboarding": "^0.1.0",
37
37
  "@sentry/node": "^10.49.0",