@indigoai-us/hq-cli 5.108.4 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,132 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.6] — 2026-09-05
6
+
7
+ ### Added
8
+
9
+ - `hq feedback bug|feature` now uploads a full log bundle alongside the report,
10
+ so a bug report carries the submitter's whole recent log history rather than a
11
+ 16 KiB sliver of it. Measured against a real `~/.hq`: 21 MB of logs across 21
12
+ files, compressed to 1.3 MB — roughly 500x what the inline path can hold.
13
+
14
+ The inline `diagnostics.logs` summary is unchanged and still ships in the
15
+ request body. It cannot be grown: the server rejects a request over 64 KiB and
16
+ stores `diagnostics` in a DynamoDB item, which caps at 400 KB. So the bundle
17
+ goes out of band — gzipped and uploaded direct to S3 through a presigned URL,
18
+ the same path screenshots already use — and the report carries only a
19
+ reference to it.
20
+
21
+ Three properties are load-bearing and are covered by tests that fail if any is
22
+ removed:
23
+
24
+ - **Same allowlist.** Discovery is reused from the inline collector verbatim.
25
+ The bundle carries more OF the same files, never more files. Credential
26
+ material lives outside `~/.hq` (`~/.codex/auth.json`,
27
+ `~/.hq-agent/machine-creds.json`), so widening discovery — not raising the
28
+ size — is what would turn this into an exfiltration bug.
29
+ - **Same redaction.** Every chunk passes through the existing redactor before
30
+ compression. A chunk boundary is held open rather than cut through a PEM
31
+ block, and an over-long line is cut on whitespace, so a split can never
32
+ bisect a token and let the halves escape a pattern that would have matched
33
+ the whole.
34
+ - **Bounded memory.** 50 MB compressed is roughly a gigabyte of raw text.
35
+ Nothing is fully materialised: files are read in slices, redacted, and
36
+ streamed into gzip, retaining only the compressed output.
37
+
38
+ Ordering is the truncation policy — status documents first, then logs newest
39
+ first, so a capped run keeps what a triager reads first. Oversized files are
40
+ read from the tail, because the end of a log is what explains a failure.
41
+
42
+ `--no-logs` and `HQ_FEEDBACK_LOGS=0` disable the bundle and the inline logs
43
+ together. Every failure is silent and non-fatal: a missing endpoint, a
44
+ disabled bucket, a refused presign, or a failed upload all degrade to
45
+ submitting without the bundle rather than costing the user their bug report.
46
+
47
+ ### Added
48
+
49
+ - `hq feedback` debug capture now also collects three more `~/.hq` sources, all
50
+ small and all high-signal for the failures they describe:
51
+
52
+ - **`locks/`** — held operation locks and background-work claims. This is the
53
+ one place where the FILENAME is the diagnosis: a `…stale-claim…` entry names
54
+ the operation, the process that abandoned it, and the fact that the claim
55
+ went stale, which is the answer to "sync/reindex is stuck". A hung process
56
+ writes nothing to a log, so this is otherwise invisible. Zero-byte lock
57
+ files are kept for their names rather than skipped as empty.
58
+ - **`jobs/`** — scheduled-job status, last reconcile, and probe attempts,
59
+ answering "my scheduled job never ran". Nested, so this is the collector's
60
+ only directory walk; it is bounded in depth (3) and file count (20), and
61
+ refuses to traverse a symlinked directory so it cannot escape `~/.hq`.
62
+ - Three more root status documents: `outpost-session-heartbeat.json` (Outpost
63
+ liveness), `version-check.json` (why an update is not offered), and
64
+ `plan-limit-nag.json`.
65
+
66
+ Deliberately still excluded: the two multi-megabyte telemetry cursors, the
67
+ `sync-state-v3/` internals, `backups/`, and everything outside `~/.hq` —
68
+ notably Claude Code session transcripts, which are conversation content and
69
+ do not belong on a bug report.
70
+
71
+ ### Changed
72
+
73
+ - Runtime bumped to `@indigoai-us/hq-cloud` ~6.16.11: personal-vault sync no
74
+ longer downloads `workspace/.session-logs/` from other machines (push-only),
75
+ and `hq reindex` now prunes local session-log copies 7 days after the vault
76
+ confirms them (`HQ_SESSION_LOG_LOCAL_RETENTION_DAYS`, `off` to disable).
77
+
78
+
79
+ ## [5.108.5] — 2026-09-04
80
+
81
+ ### Added
82
+
83
+ - `hq meetings import <file>` imports normalized historical meeting transcripts
84
+ through HQ's managed source pipeline, with immutable idempotent replay and
85
+ explicit conflict detection when an external source ID is reused with
86
+ different content.
87
+ - `hq feedback bug|feature` now attaches bounded, redacted debug evidence from
88
+ the submitter's `~/.hq` directory to the submission's `diagnostics`, so a bug
89
+ report carries the context a triager would otherwise have to ask for. Two
90
+ classes are collected:
91
+
92
+ - **Log tails** — `*.log` and `*.jsonl` directly inside `~/.hq/logs/`,
93
+ including rotated generations (`hq-sync.log.1`, `.2`, `.3`), plus
94
+ `boot-sync.log` and `boot-capture.log` at the `~/.hq` root. This covers the
95
+ hq-cloud sync engine and the desktop sync app (which share
96
+ `logs/hq-sync.log`) and the CLI's MCP registry audit trail
97
+ (`logs/mcp-registry.log`). Rotated generations matter most right after a
98
+ rotation, when the active file is nearly empty and the history a triager
99
+ needs sits in `.1`. Any future log dropped into `~/.hq/logs/` is picked up
100
+ automatically.
101
+ - **State snapshots** — the small JSON status documents that say where sync
102
+ and client health stand: `sync-progress.json` (phase, file counts, conflict
103
+ count), `sync-version.json`, `cli-client-health.json` (failure streak),
104
+ `cli-client-health.observation.json`, and the per-company
105
+ `sync-journal.<slug>.json` locators. These are collected first, since they
106
+ answer questions a log tail cannot, and the log budget takes what remains.
107
+
108
+ Eligibility is an explicit allowlist in both classes. The rest of `~/.hq`
109
+ (`cognito-tokens.json`, `deploy-passwords.json`, `secrets-cache/`) is never
110
+ read, the enormous `sync-journal.*.json.last-good` snapshots are excluded by
111
+ suffix, and eligibility is decided with `lstat` so a symlink wearing an
112
+ eligible name cannot be used to reach a credential file.
113
+
114
+ Every tail is redacted before it is measured or sent (JWTs, AWS key ids,
115
+ GitHub and Slack tokens, `Bearer` values, SigV4 presign signatures, PEM
116
+ private-key blocks, and `key=value` pairs for secret-shaped names). The total
117
+ is budgeted against the headroom left under the server's 64 KiB request-body
118
+ cap and re-measured against the real serialized envelope after attaching, so
119
+ a large report can never be turned into a 413 by its own diagnostics.
120
+
121
+ Opt out per invocation with `--no-logs`, or globally with
122
+ `HQ_FEEDBACK_LOGS=0`.
123
+
124
+ ### Fixed
125
+
126
+ - `hq` self-update no longer loops. After the CLI updates to `@latest` and a
127
+ re-run produces no effective version change, the version check records that
128
+ outcome and converges instead of re-triggering the updater, so 5.105.x (and
129
+ later) installs stop repeatedly self-updating (#494).
130
+
5
131
  ## [5.108.4] — 2026-09-04
6
132
 
7
133
  ### Fixed
@@ -1,5 +1,17 @@
1
1
  import { Command } from "commander";
2
+ import { type DiagnosticsBlob } from "../utils/feedback-diagnostics.js";
3
+ import { collectFeedbackLogs, feedbackLogsEnabled } from "../utils/feedback-logs.js";
4
+ import { type LogBundleRef } from "../utils/feedback-log-bundle.js";
2
5
  export declare const BODY_MAX_BYTES: number;
6
+ /**
7
+ * Mirror of the server's `MAX_BODY_BYTES` in hq-pro
8
+ * `src/vault-service/handlers/feedback.ts`, which rejects the WHOLE request
9
+ * body over this size with a 413. Note this is the same number as
10
+ * `BODY_MAX_BYTES` above but a different measurement: that one bounds the
11
+ * markdown body alone, this one bounds the serialized JSON envelope. Debug
12
+ * logs are budgeted against whatever headroom is left between them.
13
+ */
14
+ export declare const REQUEST_MAX_BYTES: number;
3
15
  export interface FeedbackResult {
4
16
  id: string;
5
17
  }
@@ -11,8 +23,34 @@ export interface FeedbackSubmitOptions {
11
23
  token: string;
12
24
  /** S3 object keys of already-uploaded screenshots (see uploadScreenshots). */
13
25
  screenshots?: string[];
26
+ /**
27
+ * Attach redacted tails of the submitter's `~/.hq` logs. Defaults to true;
28
+ * `false` (from `--no-logs`) skips collection entirely. Even when true, the
29
+ * user's `HQ_FEEDBACK_LOGS` env override still applies.
30
+ */
31
+ includeLogs?: boolean;
32
+ /**
33
+ * Reference to an already-uploaded log bundle (see uploadLogBundle). Carries
34
+ * the full log history that the inline `diagnostics.logs` blob cannot — the
35
+ * inline blob is bounded by the request cap and DynamoDB's item limit, so it
36
+ * stays a summary and this carries the rest.
37
+ */
38
+ logBundle?: LogBundleRef;
14
39
  }
15
40
  export declare function readBodyFile(bodyFile: string, stdin?: NodeJS.ReadableStream): Promise<string>;
41
+ /**
42
+ * Attach redacted `~/.hq` log tails to `diagnostics` if, and only if, they fit
43
+ * inside the server's request-body cap.
44
+ *
45
+ * Mutates `diagnostics` in place (it is already referenced by `requestBody`,
46
+ * so the measurement below sees the attached logs). Best-effort throughout: a
47
+ * collection failure leaves the submission untouched rather than failing a bug
48
+ * report over its own diagnostics.
49
+ */
50
+ export declare function attachDebugLogs(requestBody: Record<string, unknown>, diagnostics: DiagnosticsBlob, enabled: boolean, deps?: {
51
+ collect?: typeof collectFeedbackLogs;
52
+ envEnabled?: typeof feedbackLogsEnabled;
53
+ }): void;
16
54
  export declare function submitFeedback(opts: FeedbackSubmitOptions): Promise<FeedbackResult>;
17
55
  export declare function registerFeedbackCommand(program: Command): void;
18
56
  //# sourceMappingURL=feedback.d.ts.map
@@ -4,7 +4,24 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
4
4
  import { vaultApiFetch } from "../utils/vault-api.js";
5
5
  import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
6
6
  import { MAX_SCREENSHOTS, uploadScreenshots } from "../utils/feedback-screenshots.js";
7
+ import { FEEDBACK_LOGS_MAX_TOTAL_BYTES, FEEDBACK_LOGS_MIN_USEFUL_BYTES, collectFeedbackLogs, feedbackLogsEnabled, } from "../utils/feedback-logs.js";
8
+ import { uploadLogBundle } from "../utils/feedback-log-bundle.js";
7
9
  export const BODY_MAX_BYTES = 64 * 1024;
10
+ /**
11
+ * Mirror of the server's `MAX_BODY_BYTES` in hq-pro
12
+ * `src/vault-service/handlers/feedback.ts`, which rejects the WHOLE request
13
+ * body over this size with a 413. Note this is the same number as
14
+ * `BODY_MAX_BYTES` above but a different measurement: that one bounds the
15
+ * markdown body alone, this one bounds the serialized JSON envelope. Debug
16
+ * logs are budgeted against whatever headroom is left between them.
17
+ */
18
+ export const REQUEST_MAX_BYTES = 64 * 1024;
19
+ /**
20
+ * Headroom held back for the `"logs":{...}` JSON envelope itself — its keys,
21
+ * braces, and the escaping of the tail text. The final size is re-measured
22
+ * after attaching, so this reserve only needs to be roughly right.
23
+ */
24
+ const LOGS_ENVELOPE_RESERVE_BYTES = 1024;
8
25
  export async function readBodyFile(bodyFile, stdin) {
9
26
  if (bodyFile === "-") {
10
27
  const stream = stdin ?? process.stdin;
@@ -23,6 +40,44 @@ export async function readBodyFile(bodyFile, stdin) {
23
40
  }
24
41
  return fs.promises.readFile(bodyFile, "utf-8");
25
42
  }
43
+ /**
44
+ * Attach redacted `~/.hq` log tails to `diagnostics` if, and only if, they fit
45
+ * inside the server's request-body cap.
46
+ *
47
+ * Mutates `diagnostics` in place (it is already referenced by `requestBody`,
48
+ * so the measurement below sees the attached logs). Best-effort throughout: a
49
+ * collection failure leaves the submission untouched rather than failing a bug
50
+ * report over its own diagnostics.
51
+ */
52
+ export function attachDebugLogs(requestBody, diagnostics, enabled, deps = {}) {
53
+ const collect = deps.collect ?? collectFeedbackLogs;
54
+ const envEnabled = deps.envEnabled ?? feedbackLogsEnabled;
55
+ if (!enabled || !envEnabled())
56
+ return;
57
+ try {
58
+ const baseBytes = Buffer.byteLength(JSON.stringify(requestBody), "utf8");
59
+ const available = REQUEST_MAX_BYTES - baseBytes - LOGS_ENVELOPE_RESERVE_BYTES;
60
+ if (available < FEEDBACK_LOGS_MIN_USEFUL_BYTES)
61
+ return;
62
+ const logs = collect({
63
+ budgetBytes: Math.min(available, FEEDBACK_LOGS_MAX_TOTAL_BYTES),
64
+ });
65
+ if (!logs)
66
+ return;
67
+ diagnostics.logs = logs;
68
+ // Final authority: re-measure the ACTUAL envelope. JSON escaping of the
69
+ // tail text (quotes, newlines, control chars) can expand it well past the
70
+ // raw byte count the budget counted, so the estimate above is necessary
71
+ // but not sufficient. If we overshot, drop the logs rather than 413.
72
+ if (Buffer.byteLength(JSON.stringify(requestBody), "utf8") > REQUEST_MAX_BYTES) {
73
+ delete diagnostics.logs;
74
+ }
75
+ }
76
+ catch {
77
+ // Never let diagnostics collection break the submission.
78
+ delete diagnostics.logs;
79
+ }
80
+ }
26
81
  export async function submitFeedback(opts) {
27
82
  // Validate the title locally, symmetric with the body check below. Commander's
28
83
  // `requiredOption("--title")` only requires the flag to be PRESENT — an empty
@@ -54,6 +109,18 @@ export async function submitFeedback(opts) {
54
109
  if (opts.screenshots && opts.screenshots.length > 0) {
55
110
  requestBody.screenshots = opts.screenshots;
56
111
  }
112
+ // The bundle reference is four small fields, so unlike the inline logs below
113
+ // it needs no budgeting — but it is added BEFORE attachDebugLogs so those
114
+ // bytes are counted against the inline budget rather than overrunning it.
115
+ if (opts.logBundle) {
116
+ requestBody.logBundle = opts.logBundle;
117
+ }
118
+ // Debug logs are attached LAST and sized against the headroom the rest of
119
+ // the request left behind. The server 413s the whole body over 64 KiB, so a
120
+ // fixed log budget would turn a previously-working large-body submission
121
+ // into a hard failure. Measuring the real serialized envelope — the exact
122
+ // bytes vaultApiFetch will send — is what makes that impossible.
123
+ attachDebugLogs(requestBody, diagnostics, opts.includeLogs !== false);
57
124
  const res = await vaultApiFetch({
58
125
  token: opts.token,
59
126
  path: "/v1/feedback",
@@ -81,6 +148,9 @@ function registerSubcommand(feedbackCmd, type) {
81
148
  .requiredOption("--body-file <path>", "Path to a markdown file with the body; use - to read from stdin")
82
149
  .option("--company <slug>", "Company slug to associate with the report")
83
150
  .option("--screenshot <path>", `Attach a screenshot (repeatable, up to ${MAX_SCREENSHOTS}; .png/.jpg/.jpeg/.webp/.gif)`, (value, prev) => [...prev, value], [])
151
+ // Commander turns `--no-logs` into a boolean `logs` that defaults to true,
152
+ // so the default is "attach logs" and the flag is the opt-out.
153
+ .option("--no-logs", "Do not attach redacted tails of your ~/.hq log files (also: HQ_FEEDBACK_LOGS=0)")
84
154
  .action(async (opts) => {
85
155
  try {
86
156
  const token = await ensureCognitoToken({ interactive: false });
@@ -91,6 +161,14 @@ function registerSubcommand(feedbackCmd, type) {
91
161
  paths: opts.screenshot ?? [],
92
162
  token,
93
163
  });
164
+ // Full-history logs go direct to S3 before the submission that
165
+ // references them. Best-effort throughout: if the endpoint is absent
166
+ // (older server), the bucket is unconfigured, or the upload fails,
167
+ // this resolves undefined and the report still carries inline logs.
168
+ const logBundle = await uploadLogBundle({
169
+ token,
170
+ enabled: opts.logs !== false && feedbackLogsEnabled(),
171
+ });
94
172
  const result = await submitFeedback({
95
173
  type,
96
174
  title: opts.title,
@@ -98,6 +176,8 @@ function registerSubcommand(feedbackCmd, type) {
98
176
  company: opts.company,
99
177
  token,
100
178
  screenshots,
179
+ includeLogs: opts.logs !== false,
180
+ logBundle,
101
181
  });
102
182
  console.log(`Submitted: ${result.id}`);
103
183
  }
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
3
- import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
4
+ import { vaultApiFetch, getCompanyUid, resolveCallerPersonUid, } from "../utils/vault-api.js";
4
5
  function formatDuration(seconds) {
5
6
  const h = Math.floor(seconds / 3600);
6
7
  const m = Math.floor((seconds % 3600) / 60);
@@ -216,6 +217,59 @@ export function registerMeetingsCommand(program) {
216
217
  }
217
218
  });
218
219
  // ── hq meetings invite <meeting-url> ──────────────────────────────
220
+ meetings
221
+ .command("import <file>")
222
+ .description("Import a normalized historical transcript JSON file")
223
+ .action(async (file) => {
224
+ try {
225
+ const companySlug = meetings.opts().company;
226
+ if (!companySlug) {
227
+ throw new Error("--company <slug> is required for historical imports");
228
+ }
229
+ const bytes = await readFile(file);
230
+ if (bytes.byteLength > 5 * 1024 * 1024) {
231
+ throw new Error("Historical meeting import exceeds 5 MiB");
232
+ }
233
+ let parsed;
234
+ try {
235
+ parsed = JSON.parse(bytes.toString("utf8"));
236
+ }
237
+ catch {
238
+ throw new Error("Historical meeting import file must contain valid JSON");
239
+ }
240
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
241
+ throw new Error("Historical meeting import file must contain a JSON object");
242
+ }
243
+ const token = await ensureCognitoToken();
244
+ const companyId = await getCompanyUid(token, companySlug);
245
+ const recorderPersonUid = await resolveCallerPersonUid(token);
246
+ const res = await vaultApiFetch({
247
+ token,
248
+ method: "POST",
249
+ path: "/v1/meetings/import",
250
+ body: {
251
+ ...parsed,
252
+ companyId,
253
+ recorderPersonUid,
254
+ },
255
+ });
256
+ if (!res.ok)
257
+ await handleApiError(res, meetings.opts().json);
258
+ const result = (await res.json());
259
+ if (meetings.opts().json) {
260
+ console.log(JSON.stringify(result, null, 2));
261
+ return;
262
+ }
263
+ console.log(chalk.green(`\n✓ Historical meeting ${result.outcome}: ${result.meetingId}`));
264
+ if (result.state)
265
+ console.log(chalk.dim(` State: ${result.state}`));
266
+ console.log();
267
+ }
268
+ catch (err) {
269
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
270
+ process.exit(1);
271
+ }
272
+ });
219
273
  meetings
220
274
  .command("invite <meetingUrl>")
221
275
  .description("Invite the meeting bot to a Google Meet, Zoom, or Teams URL")
@@ -101,6 +101,12 @@ async function runList(options) {
101
101
  const format = options.format ?? defaultListFormat();
102
102
  const accessToken = await resolveAccessToken();
103
103
  const vaultConfig = buildVaultConfig(accessToken);
104
+ // HQ-59: every company (cmp_) read below rides the presign VaultClient
105
+ // (`usePresign` keys on the vault client + cmp_ prefix), so tell entity
106
+ // resolution to skip the company `POST /sts/vend` — the vended creds were
107
+ // never consumed here, but each call re-entered the packed FILE_ACL fit
108
+ // ladder (policyTruncated noise). Personal vaults still vend-self.
109
+ vaultConfig.companyVaultUsesPresign = true;
104
110
  const entity = await resolveEntity({ slug, vaultConfig });
105
111
  const result = await listSignals({
106
112
  entity,
@@ -134,6 +140,12 @@ async function runGet(options) {
134
140
  const format = options.format ?? defaultGetFormat();
135
141
  const accessToken = await resolveAccessToken();
136
142
  const vaultConfig = buildVaultConfig(accessToken);
143
+ // HQ-59: every company (cmp_) read below rides the presign VaultClient
144
+ // (`usePresign` keys on the vault client + cmp_ prefix), so tell entity
145
+ // resolution to skip the company `POST /sts/vend` — the vended creds were
146
+ // never consumed here, but each call re-entered the packed FILE_ACL fit
147
+ // ladder (policyTruncated noise). Personal vaults still vend-self.
148
+ vaultConfig.companyVaultUsesPresign = true;
137
149
  const entity = await resolveEntity({ slug, vaultConfig });
138
150
  const doc = await getSignal({
139
151
  entity,
@@ -109,6 +109,12 @@ async function runList(options) {
109
109
  const format = options.format ?? defaultListFormat();
110
110
  const accessToken = await resolveAccessToken();
111
111
  const vaultConfig = buildVaultConfig(accessToken);
112
+ // HQ-59: every company (cmp_) read below rides the presign VaultClient
113
+ // (`usePresign` keys on the vault client + cmp_ prefix), so tell entity
114
+ // resolution to skip the company `POST /sts/vend` — the vended creds were
115
+ // never consumed here, but each call re-entered the packed FILE_ACL fit
116
+ // ladder (policyTruncated noise). Personal vaults still vend-self.
117
+ vaultConfig.companyVaultUsesPresign = true;
112
118
  const entity = await resolveEntity({ slug, vaultConfig });
113
119
  const result = await listSources({
114
120
  entity,
@@ -142,6 +148,12 @@ async function runGet(options) {
142
148
  const format = options.format ?? defaultGetFormat();
143
149
  const accessToken = await resolveAccessToken();
144
150
  const vaultConfig = buildVaultConfig(accessToken);
151
+ // HQ-59: every company (cmp_) read below rides the presign VaultClient
152
+ // (`usePresign` keys on the vault client + cmp_ prefix), so tell entity
153
+ // resolution to skip the company `POST /sts/vend` — the vended creds were
154
+ // never consumed here, but each call re-entered the packed FILE_ACL fit
155
+ // ladder (policyTruncated noise). Personal vaults still vend-self.
156
+ vaultConfig.companyVaultUsesPresign = true;
145
157
  const entity = await resolveEntity({ slug, vaultConfig });
146
158
  const doc = await getSource({
147
159
  entity,
@@ -345,7 +345,23 @@ export function buildCliHeartbeat(input) {
345
345
  syncState = "error";
346
346
  break;
347
347
  default:
348
- syncState = lastSyncSuccessAt ? "idle" : "never_synced";
348
+ // An UNRESOLVED failure streak is a live error state, not idleness.
349
+ // Reporting `idle` here is what put "BROKEN — Runner failed" next to
350
+ // "Sync state: idle" on the support view: every later `hq` invocation
351
+ // overwrote the `error` state the failing sync had reported while
352
+ // leaving the streak that earned it untouched.
353
+ //
354
+ // Only this CLI's own `sync_success` clears the streak. The journal
355
+ // `lastSync` folded into `lastSyncSuccessAt` below deliberately does
356
+ // NOT: the engine stamps it per FILE update (hq-cloud journal.ts
357
+ // `updateEntry`), and a push stamps it before throwing its upload worker
358
+ // errors — so it means "some file moved", not "a run succeeded", and
359
+ // must never clear an alarm counter. That is why this branch keys off
360
+ // the streak rather than off the success timestamp.
361
+ if (input.state.consecutiveFailures > 0)
362
+ syncState = "error";
363
+ else
364
+ syncState = lastSyncSuccessAt ? "idle" : "never_synced";
349
365
  break;
350
366
  }
351
367
  const heartbeat = {
@@ -1,4 +1,5 @@
1
1
  import { type VersionInfo } from "./feedback-versions.js";
2
+ import type { FeedbackLogsBlob } from "./feedback-logs.js";
2
3
  export interface GitContext {
3
4
  branch: string | null;
4
5
  head: string | null;
@@ -23,6 +24,16 @@ export interface DiagnosticsBlob {
23
24
  cwd: string;
24
25
  git: GitContext;
25
26
  recentSentryBreadcrumbs: unknown[];
27
+ /**
28
+ * Redacted tails of the submitter's `~/.hq` log files.
29
+ *
30
+ * Attached by `submitFeedback`, NOT by `collectDiagnostics`, because the
31
+ * size budget can only be computed once the rest of the request body is
32
+ * known (the server caps the whole body at 64 KiB). Absent when the user
33
+ * opted out (`--no-logs` / `HQ_FEEDBACK_LOGS=0`), when no eligible log file
34
+ * exists, or when the body left no headroom.
35
+ */
36
+ logs?: FeedbackLogsBlob;
26
37
  }
27
38
  export declare function sanitizeArgv(argv: string[]): string[];
28
39
  export declare function collectDiagnostics(): DiagnosticsBlob;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Full-history log bundle for `hq feedback` — the out-of-band companion to the
3
+ * inline `diagnostics.logs` blob in `feedback-logs.ts`.
4
+ *
5
+ * Inline logs are capped twice over: the feedback endpoint rejects a request
6
+ * body above 64 KiB, and `diagnostics` is written into a DynamoDB item, which
7
+ * caps at 400 KB. Neither can be tuned into carrying a real log history, so the
8
+ * inline blob is deliberately a ~40 KiB summary of 16 KiB tails. This module
9
+ * produces the other half: a gzipped bundle uploaded direct to S3, carrying the
10
+ * files whole rather than in tail-sized slivers.
11
+ *
12
+ * Three properties are load-bearing and none may be traded away:
13
+ *
14
+ * 1. SAME ALLOWLIST. Discovery reuses `feedback-logs.ts` verbatim —
15
+ * `discoverLogFiles`, `discoverStateFiles`, `discoverFlatDirFiles`,
16
+ * `walkJsonFiles`. The bundle carries MORE OF the same files, never more
17
+ * files. Credential material lives outside `~/.hq` (`~/.codex/auth.json`,
18
+ * `~/.hq-agent/machine-creds.json`), so widening discovery here — not
19
+ * raising the size — is what would turn this into an exfiltration bug.
20
+ *
21
+ * 2. SAME REDACTION. Every line goes through `redactLogText` before it is
22
+ * compressed. Shipping a raw archive would be far simpler and would
23
+ * silently undo the entire security model of the inline path.
24
+ *
25
+ * 3. BOUNDED MEMORY. 50 MB compressed is roughly a gigabyte of raw log text
26
+ * at the ratio these files compress at. Nothing is ever fully materialised:
27
+ * files are read in slices, redacted a line at a time, and streamed into
28
+ * gzip, with only the compressed output retained.
29
+ *
30
+ * Output format — gzipped NDJSON, one JSON record per line:
31
+ * {"kind":"manifest","version":1,...} exactly one, first
32
+ * {"kind":"file","name":"logs/hq-sync.log",...} one per file
33
+ * {"kind":"chunk","name":"logs/hq-sync.log","seq":0,...} many per file
34
+ * {"kind":"summary","fileCount":12,...} exactly one, last
35
+ *
36
+ * NDJSON rather than tar so there is no archive dependency, so a truncated
37
+ * bundle is still parseable line-by-line up to the cut, and so the records
38
+ * carry the same redaction metadata the inline blob already reports.
39
+ */
40
+ import { type LogCandidate } from "./feedback-logs.js";
41
+ import { vaultApiFetch } from "./vault-api.js";
42
+ /**
43
+ * Compressed ceiling. Mirrors `MAX_LOG_BUNDLE_BYTES` in the hq-pro handler
44
+ * `feedback-log-bundles.ts`, which refuses to presign above it — the two must
45
+ * stay in step or the CLI will build bundles the server will not accept.
46
+ */
47
+ export declare const LOG_BUNDLE_MAX_BYTES: number;
48
+ /**
49
+ * Headroom between the size we stop feeding at and the hard cap.
50
+ *
51
+ * gzip reports compressed bytes only as its internal buffer flushes, so the
52
+ * running total lags the bytes actually consumed. The lag is bounded by that
53
+ * buffer (tens of KiB); a 1 MiB margin covers it with three orders of magnitude
54
+ * to spare, and the final size is asserted against the real cap regardless.
55
+ */
56
+ export declare const LOG_BUNDLE_SAFETY_MARGIN_BYTES: number;
57
+ /**
58
+ * Per-file raw ceiling. The desktop logger rotates at 32 MiB (hq-desktop-core
59
+ * `logfile.rs`), so this admits a full generation with headroom while stopping
60
+ * one pathological file from consuming the whole bundle. Files above it are
61
+ * read from the TAIL — the end of a log is what explains a failure.
62
+ */
63
+ export declare const LOG_BUNDLE_MAX_FILE_RAW_BYTES: number;
64
+ export interface LogBundleResult {
65
+ /** The gzipped NDJSON bytes, ready to PUT. */
66
+ gzip: Buffer;
67
+ /** `gzip.byteLength` — what the presign request must declare. */
68
+ sizeBytes: number;
69
+ /** How many files contributed at least one chunk. */
70
+ fileCount: number;
71
+ /** True when the size cap stopped collection before every file was read. */
72
+ truncated: boolean;
73
+ /** Raw (pre-compression, post-redaction) bytes read into the bundle. */
74
+ rawBytes: number;
75
+ /** Total redacted spans across every file. Non-zero is expected and fine. */
76
+ redactions: number;
77
+ }
78
+ /**
79
+ * What a submission records about its uploaded bundle. Must stay in step with
80
+ * `LogBundleRef` in the hq-pro handler `feedback-log-bundles.ts`, which
81
+ * re-validates every field before persisting it.
82
+ */
83
+ export interface LogBundleRef {
84
+ key: string;
85
+ sizeBytes: number;
86
+ fileCount: number;
87
+ truncated: boolean;
88
+ }
89
+ export interface BuildLogBundleOptions {
90
+ /** Compressed ceiling. Clamped to {@link LOG_BUNDLE_MAX_BYTES}. */
91
+ maxBytes?: number;
92
+ /** Override `~/.hq` (tests). */
93
+ hqDir?: string;
94
+ /** Override the home directory used to derive `~/.hq` (tests). */
95
+ homeDir?: string;
96
+ /** Override the per-file raw ceiling (tests). */
97
+ maxFileRawBytes?: number;
98
+ }
99
+ /**
100
+ * Order candidates so that, when the cap truncates collection, what survives is
101
+ * what a triager reads first.
102
+ *
103
+ * State documents lead: they are tiny and answer questions a log cannot (which
104
+ * operation is claimed, what the sync cursor is). Log files follow, newest
105
+ * first. Reversing this would let one large old log crowd out every status
106
+ * file — the same class of ordering defect that let ordinary lock entries
107
+ * crowd out a stale claim in the inline collector.
108
+ */
109
+ export declare function orderBundleCandidates(hqDir: string): LogCandidate[];
110
+ /**
111
+ * Read `absPath` in slices, redacting whole lines, invoking `onChunk` with
112
+ * roughly {@link CHUNK_TEXT_BYTES} of redacted text at a time.
113
+ *
114
+ * Reads from the tail when the file exceeds `maxRawBytes`, and drops the first
115
+ * partial line after seeking so a chunk never begins mid-record. `onChunk`
116
+ * returns false to stop early (the cap was hit).
117
+ */
118
+ export declare function streamRedactedFile(absPath: string, sizeBytes: number, maxRawBytes: number, chunkBytes: number, onChunk: (text: string, redactions: number) => Promise<boolean>): Promise<{
119
+ fromTail: boolean;
120
+ stopped: boolean;
121
+ }>;
122
+ /**
123
+ * Build a gzipped NDJSON bundle of the submitter's redacted `~/.hq` logs.
124
+ *
125
+ * Returns `undefined` when nothing eligible exists, so the caller can skip the
126
+ * upload entirely. Never throws: a bug report must not fail over its own
127
+ * diagnostics, so any unexpected error yields `undefined` and the submission
128
+ * proceeds with inline logs alone.
129
+ */
130
+ export declare function buildLogBundle(opts?: BuildLogBundleOptions): Promise<LogBundleResult | undefined>;
131
+ /**
132
+ * Build, presign, and upload a log bundle; return the reference the submission
133
+ * should carry, or `undefined` if anything at all did not work out.
134
+ *
135
+ * Every failure path is silent and non-fatal by design. The bundle is an
136
+ * enrichment on top of the inline logs that already ship in the request body,
137
+ * so a missing endpoint, a disabled bucket, a refused presign, or a failed PUT
138
+ * must all degrade to "submit without it" rather than cost the user their bug
139
+ * report. In particular a 404 is expected and unremarkable while a CLI that
140
+ * knows about bundles is running against a server that does not yet.
141
+ */
142
+ export declare function uploadLogBundle(opts: {
143
+ token: string;
144
+ enabled: boolean;
145
+ fetchImpl?: typeof fetch;
146
+ build?: typeof buildLogBundle;
147
+ apiFetch?: typeof vaultApiFetch;
148
+ }): Promise<LogBundleRef | undefined>;
149
+ //# sourceMappingURL=feedback-log-bundle.d.ts.map