@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,329 @@
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
+ /**
59
+ * Ceiling on the total redacted log text attached to one submission.
60
+ *
61
+ * The server caps the WHOLE feedback request body at 64 KiB, so this is only
62
+ * an upper bound — the caller passes a smaller `budgetBytes` computed from the
63
+ * headroom actually left after the title, body, and the rest of diagnostics.
64
+ * See `submitFeedback` in `../commands/feedback.ts`.
65
+ */
66
+ export declare const FEEDBACK_LOGS_MAX_TOTAL_BYTES: number;
67
+ /** Per-file ceiling, so one noisy log cannot consume the entire budget. */
68
+ export declare const FEEDBACK_LOGS_MAX_FILE_TAIL_BYTES: number;
69
+ /**
70
+ * Below this much remaining budget a tail is too short to be worth reading,
71
+ * so the file is recorded as skipped rather than truncated into noise.
72
+ */
73
+ export declare const FEEDBACK_LOGS_MIN_USEFUL_BYTES = 1024;
74
+ /**
75
+ * Ceiling on how many skipped entries are recorded.
76
+ *
77
+ * `skipped` is metadata, not payload, so it was originally left outside the
78
+ * byte budget — which made it unbounded. An installation with hundreds of log
79
+ * files produced hundreds of `{name, reason}` objects and a blob several times
80
+ * the requested budget; `attachDebugLogs` then dropped the whole thing to stay
81
+ * under the request cap, so the user with the MOST log history got the LEAST
82
+ * evidence. The overflow count preserves the signal without the bytes.
83
+ */
84
+ export declare const FEEDBACK_MAX_SKIPPED_ENTRIES = 25;
85
+ /** Subdirectory of `~/.hq` whose log files are eligible. Not recursive. */
86
+ export declare const FEEDBACK_LOGS_DIRNAME = "logs";
87
+ /**
88
+ * Eligible extensions inside `~/.hq/logs/`. `.jsonl` is included because the
89
+ * CLI's MCP registry audit trail is JSON-lines, not plain text.
90
+ */
91
+ export declare const FEEDBACK_LOG_EXTENSIONS: readonly string[];
92
+ /**
93
+ * True for a log filename, including a ROTATED generation.
94
+ *
95
+ * The desktop/sync logger keeps 32 MiB x 3 generations named `hq-sync.log.1`,
96
+ * `.2`, `.3` (see hq-desktop-core `logfile.rs`). A plain "ends with .log"
97
+ * test misses every one of them — and they matter most in the worst case: just
98
+ * after a rotation the active log is nearly empty and all the history a
99
+ * triager needs sits in `.1`.
100
+ */
101
+ export declare function isEligibleLogName(name: string): boolean;
102
+ /** Per-state-file ceiling. These are status documents, not streams. */
103
+ export declare const FEEDBACK_STATE_MAX_FILE_BYTES: number;
104
+ /**
105
+ * Ceiling on all state snapshots combined. Deliberately a small slice of the
106
+ * overall budget: state is dense but finite, and the rest belongs to logs.
107
+ */
108
+ export declare const FEEDBACK_STATE_MAX_TOTAL_BYTES: number;
109
+ /**
110
+ * State files at the `~/.hq` ROOT that are eligible, by EXACT name.
111
+ *
112
+ * The root is where `cognito-tokens.json`, `deploy-passwords.json`, and
113
+ * `secrets-cache/` live, so it is never globbed. Adding a name here is a
114
+ * deliberate act: confirm the file carries no credential before listing it.
115
+ */
116
+ export declare const FEEDBACK_STATE_FILENAMES: readonly string[];
117
+ /**
118
+ * Per-company sync journal LOCATORS (`sync-journal.<slug>.json`) are also
119
+ * eligible. Matched by an exact prefix AND a `.json` suffix, which is what
120
+ * excludes the multi-hundred-megabyte `…json.last-good` snapshots sitting
121
+ * beside them. No credential file at the root carries this prefix.
122
+ */
123
+ export declare const FEEDBACK_STATE_JOURNAL_PREFIX = "sync-journal.";
124
+ /**
125
+ * `~/.hq/locks/` — held operation locks and background-work claims.
126
+ *
127
+ * The highest-signal thing in the whole collection, and the one place where the
128
+ * FILENAME is the diagnosis: a `…stale-claim…` entry names the operation, the
129
+ * process that abandoned it, and the fact that the claim went stale. That is
130
+ * the answer to "sync/reindex is stuck", which is otherwise invisible in a log
131
+ * tail because a hung process writes nothing. Contents are tiny (tens to a
132
+ * couple hundred bytes) and included too.
133
+ */
134
+ export declare const FEEDBACK_LOCKS_DIRNAME = "locks";
135
+ /**
136
+ * `~/.hq/jobs/` — scheduled-job status, reconcile results, and probe attempts.
137
+ * Nested one or two levels (`jobs/probes/<job>/last-attempt.json`), so this is
138
+ * the only source needing a directory walk. See {@link walkJsonFiles} for the
139
+ * bounds that walk carries.
140
+ */
141
+ export declare const FEEDBACK_JOBS_DIRNAME = "jobs";
142
+ /** Per-entry ceiling for a lock file. Their value is the name plus a little context. */
143
+ export declare const FEEDBACK_LOCK_MAX_FILE_BYTES = 512;
144
+ /**
145
+ * Bounds on the `jobs/` walk. Depth 3 reaches
146
+ * `jobs/probes/<job>/last-attempt.json`; the file cap stops a pathological
147
+ * tree from crowding out logs.
148
+ */
149
+ export declare const FEEDBACK_JOBS_MAX_DEPTH = 3;
150
+ export declare const FEEDBACK_JOBS_MAX_FILES = 20;
151
+ /** Ceiling on how many lock entries are collected. */
152
+ export declare const FEEDBACK_LOCKS_MAX_FILES = 20;
153
+ /**
154
+ * Log files that live at the root of `~/.hq` rather than under `logs/`.
155
+ * Listed explicitly — the root directory holds credentials and must never be
156
+ * enumerated by pattern.
157
+ */
158
+ export declare const FEEDBACK_ROOT_LOG_FILENAMES: readonly string[];
159
+ /** Why a discovered log file contributed nothing to the submission. */
160
+ export type SkipReason = "budget" | "empty" | "unreadable";
161
+ export interface LogTail {
162
+ /** Path relative to `~/.hq`, e.g. `logs/hq-sync.log`. Never absolute. */
163
+ name: string;
164
+ /** Full on-disk size, even when only a tail was included. */
165
+ sizeBytes: number;
166
+ /** Last-modified time, or null when it could not be read. */
167
+ modifiedIso: string | null;
168
+ /** Byte length of `tail` after redaction. */
169
+ includedBytes: number;
170
+ /** True when `tail` is a suffix of a larger file. */
171
+ truncated: boolean;
172
+ /** How many spans redaction replaced. Non-zero is expected and fine. */
173
+ redactions: number;
174
+ /** The redacted tail itself. */
175
+ tail: string;
176
+ }
177
+ /** A small JSON status document included whole (or head-truncated). */
178
+ export interface StateSnapshot {
179
+ /** Path relative to `~/.hq`, e.g. `sync-progress.json`. Never absolute. */
180
+ name: string;
181
+ sizeBytes: number;
182
+ modifiedIso: string | null;
183
+ includedBytes: number;
184
+ /** True when the file exceeded the per-file ceiling and was cut. */
185
+ truncated: boolean;
186
+ redactions: number;
187
+ /** The redacted document text. */
188
+ content: string;
189
+ }
190
+ export interface FeedbackLogsBlob {
191
+ /** The budget this collection was given, for interpreting truncation. */
192
+ budgetBytes: number;
193
+ /** Included tails, newest-modified first. */
194
+ files: LogTail[];
195
+ /** Sync and client-health status documents. Collected before the tails. */
196
+ state: StateSnapshot[];
197
+ /**
198
+ * Discovered files that contributed nothing, and why. Capped at
199
+ * {@link FEEDBACK_MAX_SKIPPED_ENTRIES}; see `skippedOverflow`.
200
+ */
201
+ skipped: Array<{
202
+ name: string;
203
+ reason: SkipReason;
204
+ }>;
205
+ /**
206
+ * How many further files were skipped but not listed individually. Absent
207
+ * when nothing overflowed.
208
+ */
209
+ skippedOverflow?: number;
210
+ }
211
+ export interface LogCandidate {
212
+ name: string;
213
+ absPath: string;
214
+ sizeBytes: number;
215
+ modifiedMs: number;
216
+ }
217
+ /**
218
+ * Replace every secret-shaped span in `text`. Returns the redacted text and a
219
+ * count of replacements, so triage can tell "no secrets present" from
220
+ * "redaction never ran".
221
+ */
222
+ export declare function redactLogText(text: string): {
223
+ text: string;
224
+ redactions: number;
225
+ };
226
+ /**
227
+ * `true` unless the user disabled log capture via `HQ_FEEDBACK_LOGS`.
228
+ *
229
+ * Uses `?.trim() ||` rather than `??` on purpose: an env var that is SET BUT
230
+ * EMPTY must fall back to the default, and `??` would pass `""` through as a
231
+ * deliberate choice.
232
+ */
233
+ export declare function feedbackLogsEnabled(env?: NodeJS.ProcessEnv): boolean;
234
+ /**
235
+ * Enumerate eligible log files under `hqDir`.
236
+ *
237
+ * Eligibility is the allowlist described in the module header: `*.log` files
238
+ * directly inside `hqDir/logs/`, plus {@link FEEDBACK_ROOT_LOG_FILENAMES} at
239
+ * the root. Every candidate must be a REGULAR file by `lstat` — directories
240
+ * and, critically, symlinks are rejected. Returned newest-modified first so
241
+ * the most relevant log is offered the budget before older ones.
242
+ */
243
+ export declare function discoverLogFiles(hqDir: string): LogCandidate[];
244
+ /**
245
+ * Read the last `maxBytes` of a file.
246
+ *
247
+ * When the file is larger than the window, the first (partial) line is dropped
248
+ * so the tail always starts at a line boundary. Returns null if the file
249
+ * cannot be read.
250
+ */
251
+ export declare function readLogTail(absPath: string, maxBytes: number): {
252
+ text: string;
253
+ truncated: boolean;
254
+ } | null;
255
+ /**
256
+ * Enumerate eligible state files at the `~/.hq` root.
257
+ *
258
+ * Two ways in, both narrow: an exact name in {@link FEEDBACK_STATE_FILENAMES},
259
+ * or the `sync-journal.<slug>.json` shape. As with logs, every candidate must
260
+ * be a REGULAR file by `lstat`, so a symlink cannot be used to reach a
261
+ * credential file through an eligible-looking name.
262
+ */
263
+ export declare function discoverStateFiles(hqDir: string): LogCandidate[];
264
+ /**
265
+ * Enumerate flat, non-recursive entries of a `~/.hq` subdirectory.
266
+ *
267
+ * Used for `locks/`, where every entry is eligible regardless of extension —
268
+ * the filename itself carries the diagnosis. As everywhere else in this module,
269
+ * `lstat` decides eligibility, so a symlink dropped into the directory is
270
+ * skipped rather than followed.
271
+ *
272
+ * Results are sorted NEWEST-FIRST and only then truncated to `maxFiles`, which
273
+ * is load-bearing rather than cosmetic. Sorting by name and cutting at the cap
274
+ * silently drops the most diagnostic entry: ordinary `operation-*.lock` files
275
+ * sort ahead of a `qmd-reindex-bg.claim…stale-claim…`, so an installation with
276
+ * more than `maxFiles` locks would have excluded exactly the entry this source
277
+ * exists to capture. Recency is the right proxy for relevance here, and it
278
+ * matches how log files are prioritised.
279
+ */
280
+ export declare function discoverFlatDirFiles(hqDir: string, dirName: string, maxFiles: number): LogCandidate[];
281
+ /**
282
+ * Walk a `~/.hq` subdirectory for `.json` files, bounded in BOTH depth and
283
+ * count.
284
+ *
285
+ * This is the module's only recursive traversal, so it is deliberately the
286
+ * most constrained. Three bounds, each load-bearing:
287
+ *
288
+ * - `lstat` per entry, and recursion only into a REAL directory. A symlinked
289
+ * directory is never followed, so a link at `jobs/x -> /home/user` cannot
290
+ * turn a two-level walk into a scan of the home directory — where, as it
291
+ * happens, `~/.codex/auth.json` and `~/.hq-agent/machine-creds.json` live.
292
+ * This is the same guard the flat enumerations use, applied to directories
293
+ * rather than files, and it is what keeps "walk a subtree" from being a
294
+ * categorically riskier operation than the allowlists around it.
295
+ * - `maxDepth`, so a deep tree cannot cost unbounded stat calls.
296
+ * - `maxFiles`, so a wide tree cannot crowd the log budget.
297
+ *
298
+ * Entries are returned sorted by name for a stable, reviewable order.
299
+ */
300
+ export declare function walkJsonFiles(hqDir: string, dirName: string, maxDepth: number, maxFiles: number): LogCandidate[];
301
+ /**
302
+ * Read up to `maxBytes` from the START of a file. State documents are JSON,
303
+ * whose meaningful keys sit at the top, so a head is more useful than a tail.
304
+ * Returns null if the file cannot be read.
305
+ */
306
+ export declare function readFileHead(absPath: string, maxBytes: number): {
307
+ text: string;
308
+ truncated: boolean;
309
+ } | null;
310
+ export interface CollectFeedbackLogsOptions {
311
+ /** Total redacted bytes this collection may emit. Clamped to the max. */
312
+ budgetBytes?: number;
313
+ /** Override `~/.hq` (tests). */
314
+ hqDir?: string;
315
+ /** Override the home directory used to derive `~/.hq` (tests). */
316
+ homeDir?: string;
317
+ }
318
+ /**
319
+ * Collect redacted `~/.hq` evidence within `budgetBytes`.
320
+ *
321
+ * State snapshots are taken first, capped by
322
+ * {@link FEEDBACK_STATE_MAX_TOTAL_BYTES}, because they are small and answer
323
+ * questions a log tail cannot. Log tails then take whatever budget remains.
324
+ *
325
+ * Returns `undefined` when nothing eligible exists at all, so the caller can
326
+ * omit the field entirely rather than attach an empty object. Never throws.
327
+ */
328
+ export declare function collectFeedbackLogs(opts?: CollectFeedbackLogsOptions): FeedbackLogsBlob | undefined;
329
+ //# sourceMappingURL=feedback-logs.d.ts.map