@indigoai-us/hq-cli 5.107.1 → 5.108.0

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.
@@ -22,6 +22,7 @@
22
22
  * - the env var `HQ_SYNC_NARROW_HINT=off` is set,
23
23
  * - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
24
24
  * `syncNarrowHint: 'off'`,
25
+ * - the synced flag registry disables `sync.narrow-hint`,
25
26
  * - or the same `{companyUid, level}` pair has already been shown this
26
27
  * process (module-singleton dedupe; the runner imports the same module
27
28
  * once per `hq` invocation so a single invocation prints at most one
@@ -46,6 +47,7 @@
46
47
  * `syncNarrowHintMinBytes` — see `resolveNarrowHintMinBytes`.
47
48
  */
48
49
  import * as fs from "node:fs";
50
+ import { type FlagReader } from "./flag-registry.js";
49
51
  export type BannerLevel = "hint" | "warning" | "strict";
50
52
  /**
51
53
  * Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
@@ -85,6 +87,8 @@ export interface ShouldShowBannerOpts {
85
87
  readFile?: (p: string) => string;
86
88
  /** Test seam: override `fs.existsSync`. */
87
89
  existsFile?: (p: string) => boolean;
90
+ /** Test seam: held registry snapshot reader. */
91
+ flagReader?: FlagReader;
88
92
  }
89
93
  /**
90
94
  * Decides whether a banner should be printed AT ALL — independent of
@@ -150,7 +154,14 @@ export declare function companyFolderExceedsThreshold(companyDir: string, thresh
150
154
  * `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
151
155
  * folder is under the threshold is never refused.
152
156
  */
153
- export declare function isStrictRefusal(syncMode: BannerInput["syncMode"], level: BannerLevel): boolean;
157
+ export declare function isStrictRefusal(syncMode: BannerInput["syncMode"], level: BannerLevel, flagReader?: FlagReader): boolean;
158
+ /**
159
+ * Keep the rendered banner honest about the decision that this invocation
160
+ * actually made. Hint and warning remain local presentation choices. `strict`
161
+ * is reserved for a real refusal, so a registry opt-out of an old strict level
162
+ * degrades to the local warning presentation instead of claiming a block.
163
+ */
164
+ export declare function resolveNarrowHintPresentationLevel(level: BannerLevel, strictRefusal: boolean): BannerLevel;
154
165
  /**
155
166
  * Emit a one-time-per-{company,level} banner. Writes to `stderr` by
156
167
  * default; tests inject a sink. Idempotent — repeated calls with the
@@ -22,6 +22,7 @@
22
22
  * - the env var `HQ_SYNC_NARROW_HINT=off` is set,
23
23
  * - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
24
24
  * `syncNarrowHint: 'off'`,
25
+ * - the synced flag registry disables `sync.narrow-hint`,
25
26
  * - or the same `{companyUid, level}` pair has already been shown this
26
27
  * process (module-singleton dedupe; the runner imports the same module
27
28
  * once per `hq` invocation so a single invocation prints at most one
@@ -48,6 +49,7 @@
48
49
  import chalk from "chalk";
49
50
  import * as fs from "node:fs";
50
51
  import * as path from "node:path";
52
+ import { resolveFlagGate, resolveProcessFlagGate, } from "./flag-registry.js";
51
53
  /**
52
54
  * Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
53
55
  * smaller than this is cheap to keep in full, so all-mode is left alone and no
@@ -99,7 +101,21 @@ export function shouldShowBanner(opts = {}) {
99
101
  }
100
102
  }
101
103
  }
102
- return true;
104
+ const lookup = {
105
+ globalEnvVar: "HQ_SYNC_NARROW_HINT",
106
+ globalValueSemantics: {
107
+ onValues: [],
108
+ offValues: ["off"],
109
+ unrecognizedValue: true,
110
+ unsetValue: true,
111
+ // Deliberately no `trim`: the legacy parser only case-folds.
112
+ caseInsensitive: true,
113
+ },
114
+ fallback: true,
115
+ };
116
+ return opts.flagReader
117
+ ? resolveFlagGate(opts.flagReader, "sync.narrow-hint", lookup, () => true)
118
+ : resolveProcessFlagGate("sync.narrow-hint", lookup, () => true);
103
119
  }
104
120
  /**
105
121
  * Resolve the banner level from environment overrides. Defaults to
@@ -230,8 +246,34 @@ export function companyFolderExceedsThreshold(companyDir, thresholdBytes, deps =
230
246
  * `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
231
247
  * folder is under the threshold is never refused.
232
248
  */
233
- export function isStrictRefusal(syncMode, level) {
234
- return level === "strict" && syncMode === "all";
249
+ export function isStrictRefusal(syncMode, level, flagReader) {
250
+ if (syncMode !== "all")
251
+ return false;
252
+ const lookup = {
253
+ globalEnvVar: "HQ_SYNC_NARROW_HINT_LEVEL",
254
+ globalValueSemantics: {
255
+ onValues: ["strict"],
256
+ offValues: ["hint", "warning"],
257
+ unrecognizedValue: false,
258
+ unsetValue: false,
259
+ caseInsensitive: true,
260
+ },
261
+ fallback: level === "strict",
262
+ };
263
+ return flagReader
264
+ ? resolveFlagGate(flagReader, "sync.narrow-hint-strict", lookup, () => level === "strict")
265
+ : resolveProcessFlagGate("sync.narrow-hint-strict", lookup, () => level === "strict");
266
+ }
267
+ /**
268
+ * Keep the rendered banner honest about the decision that this invocation
269
+ * actually made. Hint and warning remain local presentation choices. `strict`
270
+ * is reserved for a real refusal, so a registry opt-out of an old strict level
271
+ * degrades to the local warning presentation instead of claiming a block.
272
+ */
273
+ export function resolveNarrowHintPresentationLevel(level, strictRefusal) {
274
+ if (strictRefusal)
275
+ return "strict";
276
+ return level === "strict" ? "warning" : level;
235
277
  }
236
278
  /**
237
279
  * Emit a one-time-per-{company,level} banner. Writes to `stderr` by
@@ -15,6 +15,7 @@
15
15
  * Additive only — never throws, never touches `process.exitCode`, never
16
16
  * writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1`.
17
17
  */
18
+ import { type FlagReader } from "./flag-registry.js";
18
19
  export declare const PLAN_LIMIT_UPGRADE_URL = "https://app.indigo-hq.com/billing/upgrade";
19
20
  export interface PlanLimitEntry {
20
21
  used: number;
@@ -39,6 +40,8 @@ export declare function emitPlanLimitNag(opts?: {
39
40
  write?: (s: string) => void;
40
41
  now?: () => Date;
41
42
  statePath?: string;
43
+ /** Test seam: held registry snapshot reader. */
44
+ flagReader?: FlagReader;
42
45
  }): void;
43
46
  /** Test-only helper — clears last-seen status and session dedupe flags. */
44
47
  export declare function _resetForTests(): void;
@@ -19,6 +19,7 @@ import chalk from "chalk";
19
19
  import * as fs from "node:fs";
20
20
  import * as os from "node:os";
21
21
  import * as path from "node:path";
22
+ import { resolveFlagGate, resolveProcessFlagGate, } from "./flag-registry.js";
22
23
  export const PLAN_LIMIT_UPGRADE_URL = "https://app.indigo-hq.com/billing/upgrade";
23
24
  const DAY_MS = 24 * 60 * 60 * 1000;
24
25
  /** Module-level last-seen cell — overwritten by each successful parse. */
@@ -33,6 +34,22 @@ function defaultStatePath() {
33
34
  function isOptedOut() {
34
35
  return process.env.HQ_NO_PLAN_LIMIT_NAG === "1";
35
36
  }
37
+ function isPlanLimitNagEnabled(flagReader) {
38
+ const lookup = {
39
+ globalEnvVar: "HQ_NO_PLAN_LIMIT_NAG",
40
+ globalValueSemantics: {
41
+ onValues: [],
42
+ offValues: ["1"],
43
+ unrecognizedValue: true,
44
+ unsetValue: true,
45
+ },
46
+ fallback: true,
47
+ };
48
+ const legacyFallback = () => !isOptedOut();
49
+ return flagReader
50
+ ? resolveFlagGate(flagReader, "cli.plan-limit-nag", lookup, legacyFallback)
51
+ : resolveProcessFlagGate("cli.plan-limit-nag", lookup, legacyFallback);
52
+ }
36
53
  /**
37
54
  * Defensively parse a single planLimits entry. Returns null if the shape is
38
55
  * not well-formed (non-numeric used/limit, non-boolean over, etc.).
@@ -164,7 +181,7 @@ function buildOverBox(overEntries) {
164
181
  */
165
182
  export function emitPlanLimitNag(opts = {}) {
166
183
  try {
167
- if (isOptedOut())
184
+ if (!isPlanLimitNagEnabled(opts.flagReader))
168
185
  return;
169
186
  if (lastSeen === null)
170
187
  return;
package/dist/main.js CHANGED
@@ -93,6 +93,7 @@ import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
93
93
  import { reportCliClientHealthInvocation } from "./utils/client-health.js";
94
94
  import { settleWithin } from "./utils/settle-with-timeout.js";
95
95
  import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
96
+ import { kickFlagRegistryReadiness } from "./lib/flag-registry.js";
96
97
  import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
97
98
  import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
98
99
  /** Hard upper bound for non-user-visible release-health finalization. */
@@ -290,14 +291,22 @@ registerDoctorCommand(program);
290
291
  // from `hq doctor` (hook guardrails). Does not start MQTT listen.
291
292
  registerMeshCommand(program);
292
293
  program.hook("preAction", async () => {
293
- // Both are best-effort, bounded (1.2s), and fully swallowed: neither can
294
- // delay past its bound or change the command's result or exit code.
294
+ // Both are best-effort and fully swallowed: neither can change the command's
295
+ // result or exit code. The 1.2s bound they carry is a TIMER, so it only
296
+ // preempts asynchronous work — synchronous work inside them runs to
297
+ // completion regardless, because the timer cannot be serviced while the
298
+ // event loop is blocked. Keep anything added here asynchronous, or
299
+ // separately cheap: this hook is on the path of EVERY hq command.
295
300
  await Promise.all([
296
301
  emitCliSessionStarted(),
297
302
  reportCliClientHealthInvocation(),
298
303
  ]);
299
304
  });
300
305
  export async function runCli() {
306
+ // Begin one registry refresh without awaiting it. Every command gate reads
307
+ // only the client's held snapshot and falls back locally, so an offline or
308
+ // slow registry cannot delay command parsing or alter a command failure.
309
+ void kickFlagRegistryReadiness();
301
310
  // Set when a self-update re-exec'd this command on a newer CLI: the child
302
311
  // already did the work, so this process only has to carry its exit status
303
312
  // out (after the finally block's telemetry, hence not process.exit here).
@@ -75,6 +75,15 @@ export declare const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
75
75
  export declare const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100000;
76
76
  export declare const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100000;
77
77
  export declare const CLIENT_HEALTH_MAX_CHECKS = 16;
78
+ /**
79
+ * Local-files-overview bounds (client-sync-health-control-plane hq-cli
80
+ * addition, US-016). These cap the closed set of bounded facts support sees
81
+ * about a user's local log/journal FILES — never a path, never file content,
82
+ * never a log line of text. See {@link ClientHealthLocalFilesOverview}.
83
+ */
84
+ export declare const CLIENT_HEALTH_MAX_ERROR_LINE_COUNT = 50;
85
+ export declare const CLIENT_HEALTH_MAX_FILE_SIZE_BYTES = 1073741824;
86
+ export declare const CLIENT_HEALTH_MAX_FILE_AGE_SECONDS = 315360000;
78
87
  /**
79
88
  * The four client versions. All optional: a CLI-only installation has no
80
89
  * desktop/syncRunner version, and older clients may omit any of them.
@@ -85,6 +94,25 @@ export interface ClientHealthVersions {
85
94
  core?: string;
86
95
  syncRunner?: string;
87
96
  }
97
+ /**
98
+ * Closed, bounded-fact summary of the user's local log/journal FILES —
99
+ * existence, size, age, and a capped count of recent error-marker lines.
100
+ * NEVER a path, NEVER file content, NEVER a line of log text: every field is
101
+ * a boolean or a bounded integer (see the `CLIENT_HEALTH_MAX_*` bounds).
102
+ * Attached to sync-outcome heartbeats (US-016) so support can see "does this
103
+ * installation even have a log, and does it look like it's erroring" without
104
+ * a customer screenshot.
105
+ */
106
+ export interface ClientHealthLocalFilesOverview {
107
+ syncLogExists: boolean;
108
+ syncLogSizeBytes: number;
109
+ syncLogAgeSeconds: number;
110
+ journalExists: boolean;
111
+ journalSizeBytes: number;
112
+ journalAgeSeconds: number;
113
+ /** Capped at {@link CLIENT_HEALTH_MAX_ERROR_LINE_COUNT}. */
114
+ recentErrorLineCount: number;
115
+ }
88
116
  export interface ClientHealthHeartbeat {
89
117
  contractVersion: number;
90
118
  /** Stable random installation identity — NOT a hardware fingerprint. */
@@ -106,6 +134,8 @@ export interface ClientHealthHeartbeat {
106
134
  conflictCount?: number;
107
135
  updaterState?: ClientHealthUpdaterState;
108
136
  failureReason?: ClientHealthFailureReason;
137
+ /** Absent on older clients / non-sync-outcome events. See {@link ClientHealthLocalFilesOverview}. */
138
+ localFilesOverview?: ClientHealthLocalFilesOverview;
109
139
  }
110
140
  export interface ClientHealthCheckResult {
111
141
  check: ClientHealthDiagnosticCheck;
@@ -120,6 +120,15 @@ export const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
120
120
  export const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100_000;
121
121
  export const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100_000;
122
122
  export const CLIENT_HEALTH_MAX_CHECKS = 16;
123
+ /**
124
+ * Local-files-overview bounds (client-sync-health-control-plane hq-cli
125
+ * addition, US-016). These cap the closed set of bounded facts support sees
126
+ * about a user's local log/journal FILES — never a path, never file content,
127
+ * never a log line of text. See {@link ClientHealthLocalFilesOverview}.
128
+ */
129
+ export const CLIENT_HEALTH_MAX_ERROR_LINE_COUNT = 50;
130
+ export const CLIENT_HEALTH_MAX_FILE_SIZE_BYTES = 1_073_741_824; // 1 GiB
131
+ export const CLIENT_HEALTH_MAX_FILE_AGE_SECONDS = 315_360_000; // 10 years
123
132
  export class ClientHealthContractError extends Error {
124
133
  code;
125
134
  field;
@@ -194,6 +203,13 @@ function assertBoundedInt(field, value, max) {
194
203
  throw new ClientHealthContractError("OUT_OF_BOUNDS", field);
195
204
  return value;
196
205
  }
206
+ function assertBoolean(field, value) {
207
+ if (value === undefined || value === null)
208
+ throw new ClientHealthContractError("MISSING_FIELD", field);
209
+ if (typeof value !== "boolean")
210
+ throw new ClientHealthContractError("INVALID_TYPE", field, "expected boolean");
211
+ return value;
212
+ }
197
213
  function asRecord(field, value) {
198
214
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
199
215
  throw new ClientHealthContractError("INVALID_TYPE", field, "expected object");
@@ -246,8 +262,29 @@ export function parseClientHealthHeartbeat(input) {
246
262
  if (raw.failureReason !== undefined) {
247
263
  heartbeat.failureReason = assertEnum("failureReason", raw.failureReason, CLIENT_HEALTH_FAILURE_REASONS);
248
264
  }
265
+ if (raw.localFilesOverview !== undefined) {
266
+ heartbeat.localFilesOverview = parseClientHealthLocalFilesOverview("localFilesOverview", raw.localFilesOverview);
267
+ }
249
268
  return heartbeat;
250
269
  }
270
+ /**
271
+ * Parse + validate a {@link ClientHealthLocalFilesOverview}. Every field is a
272
+ * boolean or a bounded integer — there is no string field to be path- or
273
+ * content-shaped, so this fails closed purely on type/bounds, same as any
274
+ * other bounded-fact block in this contract.
275
+ */
276
+ function parseClientHealthLocalFilesOverview(field, input) {
277
+ const raw = asRecord(field, input);
278
+ return {
279
+ syncLogExists: assertBoolean(`${field}.syncLogExists`, raw.syncLogExists),
280
+ syncLogSizeBytes: assertBoundedInt(`${field}.syncLogSizeBytes`, raw.syncLogSizeBytes, CLIENT_HEALTH_MAX_FILE_SIZE_BYTES),
281
+ syncLogAgeSeconds: assertBoundedInt(`${field}.syncLogAgeSeconds`, raw.syncLogAgeSeconds, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS),
282
+ journalExists: assertBoolean(`${field}.journalExists`, raw.journalExists),
283
+ journalSizeBytes: assertBoundedInt(`${field}.journalSizeBytes`, raw.journalSizeBytes, CLIENT_HEALTH_MAX_FILE_SIZE_BYTES),
284
+ journalAgeSeconds: assertBoundedInt(`${field}.journalAgeSeconds`, raw.journalAgeSeconds, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS),
285
+ recentErrorLineCount: assertBoundedInt(`${field}.recentErrorLineCount`, raw.recentErrorLineCount, CLIENT_HEALTH_MAX_ERROR_LINE_COUNT),
286
+ };
287
+ }
251
288
  /** Parse + validate one diagnostic/repair command receipt. Unknown kinds fail closed. */
252
289
  export function parseClientHealthCommandReceipt(input) {
253
290
  const raw = asRecord("receipt", input);
@@ -20,7 +20,10 @@
20
20
  * caller-supplied identity outright).
21
21
  * - Sync state is read through the engine's `listJournals()` — the ONLY
22
22
  * correct enumeration of per-scope journals (single-path reconstruction
23
- * regressed before: feedback_9fbf1f82 / feedback_46288b7b).
23
+ * regressed before: feedback_9fbf1f82 / feedback_46288b7b). It stays the
24
+ * only enumeration, but it is now rate-limited rather than run on every
25
+ * command: it is expensive and synchronous, so it could not be bounded on
26
+ * the command path (see {@link JOURNAL_OBSERVATION_TTL_MS}).
24
27
  *
25
28
  * Local state (installation identity + monotonic sequence + sync outcome
26
29
  * counters) lives at `{stateDir}/cli-client-health.json` next to the sync
@@ -29,10 +32,42 @@
29
32
  */
30
33
  import { isExpiring, loadCachedTokens } from "./cognito-session.js";
31
34
  import { type VersionInfo } from "./feedback-versions.js";
32
- import { type ClientHealthArch, type ClientHealthHeartbeat, type ClientHealthPlatform } from "./client-health-contract.js";
35
+ import { type ClientHealthArch, type ClientHealthHeartbeat, type ClientHealthLocalFilesOverview, type ClientHealthPlatform } from "./client-health-contract.js";
33
36
  /** Same bound as CLI telemetry: a heartbeat may never stall a command. */
34
37
  export declare const CLIENT_HEALTH_TIMEOUT_MS = 1200;
35
38
  export declare const CLIENT_HEALTH_STATE_FILE = "cli-client-health.json";
39
+ /**
40
+ * Single-flight marker for the journal observation below. Held only for the
41
+ * duration of one `listJournals()` call.
42
+ */
43
+ export declare const CLIENT_HEALTH_OBSERVE_LOCK_FILE = "cli-client-health.observe.lock";
44
+ /** Cached journal observation — see {@link JournalObservation}. */
45
+ export declare const CLIENT_HEALTH_OBSERVATION_FILE = "cli-client-health.observation.json";
46
+ /**
47
+ * How long one journal `lastSync` observation stays usable before an
48
+ * `invocation` heartbeat re-reads it from the engine.
49
+ *
50
+ * `listJournals()` is the correct enumeration, but it is NOT cheap: it returns
51
+ * fully materialized journals, so the engine opens every area of the local
52
+ * sync state store, replays each area's write-ahead log, and deep-clones the
53
+ * whole aggregate file table (`AreaLedger.read` → `readCached` →
54
+ * `primeAggregateCache` → `materializeArea`). On a controller with a large,
55
+ * un-compacted store that measured 21 s of CPU per `hq` invocation — for the
56
+ * ONE field this module actually consumes, `journal.lastSync`.
57
+ *
58
+ * Worse, that work is fully SYNCHRONOUS, so the `settleWithin` bound below
59
+ * cannot preempt it: its timer cannot be serviced until the work it is meant
60
+ * to bound has already finished. Every `hq` command awaits the preAction hook,
61
+ * so an expensive store turned a best-effort telemetry heartbeat into a
62
+ * multi-second stall on every single command, secrets fetches included.
63
+ *
64
+ * The heartbeat only needs a coarse "when did this installation last sync"
65
+ * signal, so one observation is reused for this window instead. Sync commands
66
+ * always re-observe — they have opened the store anyway, so it costs nothing.
67
+ */
68
+ export declare const JOURNAL_OBSERVATION_TTL_MS: number;
69
+ /** An observation lock older than this is treated as abandoned. */
70
+ export declare const JOURNAL_OBSERVATION_LOCK_STALE_MS: number;
36
71
  export interface CliClientHealthState {
37
72
  /** Stable random installation identity — regenerated only if invalid. */
38
73
  installationId: string;
@@ -43,6 +78,22 @@ export interface CliClientHealthState {
43
78
  lastSyncAttemptAt?: string;
44
79
  lastSyncSuccessAt?: string;
45
80
  }
81
+ /**
82
+ * Cached result of one `listJournals()` read.
83
+ *
84
+ * Deliberately its OWN file rather than a field on {@link CliClientHealthState}:
85
+ * older CLI versions rewrite the state file and drop fields they do not know,
86
+ * so an observation stored there would be erased by any co-installed older
87
+ * `hq` — permanently on a machine that runs a mixed fleet, and throughout any
88
+ * staged rollout. That was observed in practice on the controller this change
89
+ * was measured on.
90
+ */
91
+ export interface JournalObservation {
92
+ /** Engine-recorded journal `lastSync` at the time of the read. */
93
+ lastSyncAt?: string;
94
+ /** When the read was taken, ISO-8601. */
95
+ observedAt?: string;
96
+ }
46
97
  export declare function newInstallationId(): string;
47
98
  /**
48
99
  * Load (or initialize) the persisted installation state. Any read/parse
@@ -50,6 +101,10 @@ export declare function newInstallationId(): string;
50
101
  * never break a CLI command.
51
102
  */
52
103
  export declare function loadClientHealthState(stateDir: string): CliClientHealthState;
104
+ /** Load the cached journal observation; any problem reads as "none". */
105
+ export declare function loadJournalObservation(stateDir: string): JournalObservation;
106
+ /** Best-effort persist — a read-only disk must never fail a command. */
107
+ export declare function persistJournalObservation(stateDir: string, observation: JournalObservation): void;
53
108
  /** Best-effort persist — a read-only disk must never fail a command. */
54
109
  export declare function persistClientHealthState(stateDir: string, state: CliClientHealthState): void;
55
110
  /**
@@ -69,6 +124,16 @@ interface JournalLike {
69
124
  * contract ISO-UTC. Null when nothing has ever synced.
70
125
  */
71
126
  export declare function latestJournalSyncTime(journals: readonly JournalLike[]): string | null;
127
+ /**
128
+ * True when this event kind must pay for a fresh `listJournals()` read.
129
+ *
130
+ * Sync events always do: `hq sync` has already opened the state store, so the
131
+ * marginal cost is nil and the resulting heartbeat is the one that most needs
132
+ * an exact timestamp. Plain invocations reuse the last observation until it
133
+ * ages past `ttlMs`, which is what keeps the enumeration off the hot path of
134
+ * every unrelated command.
135
+ */
136
+ export declare function shouldObserveJournals(kind: ClientHealthEventKind, observation: JournalObservation, now: Date, ttlMs: number): boolean;
72
137
  export type ClientHealthEventKind = "invocation" | "sync_attempt" | "sync_success" | "sync_failure";
73
138
  export interface BuildCliHeartbeatInput {
74
139
  kind: ClientHealthEventKind;
@@ -80,6 +145,12 @@ export interface BuildCliHeartbeatInput {
80
145
  now: Date;
81
146
  platform?: ClientHealthPlatform | null;
82
147
  arch?: ClientHealthArch | null;
148
+ /**
149
+ * Bounded local log/journal file facts (US-016). Only ever attached for
150
+ * `sync_success` / `sync_failure` kinds — support asked for this on the
151
+ * sync OUTCOME events, not on every invocation heartbeat.
152
+ */
153
+ localFilesOverview?: ClientHealthLocalFilesOverview | null;
83
154
  }
84
155
  /**
85
156
  * Build one contract-valid CLI heartbeat, or null when this environment
@@ -98,6 +169,12 @@ export interface ClientHealthDeps {
98
169
  journals?: () => readonly JournalLike[];
99
170
  post?: HeartbeatPoster;
100
171
  timeoutMs?: number;
172
+ /** US-016: overridable for tests; defaults to {@link collectLocalFilesOverview}. */
173
+ localFilesOverview?: () => ClientHealthLocalFilesOverview;
174
+ /** Override for {@link JOURNAL_OBSERVATION_TTL_MS}. */
175
+ journalObservationTtlMs?: number;
176
+ /** Override for {@link JOURNAL_OBSERVATION_LOCK_STALE_MS}. */
177
+ journalObservationLockStaleMs?: number;
101
178
  }
102
179
  /**
103
180
  * Apply one health event: update local installation state, then — only when a