@indigoai-us/hq-cli 5.85.1 → 5.85.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.85.3]
6
+
7
+ ### Changed
8
+
9
+ - The shared-mode sync nudge is now size-gated: it appears only once a
10
+ company's local folder crosses ~5 GiB (overridable via
11
+ `HQ_SYNC_NARROW_HINT_MIN_BYTES` or `.hq/config.json`
12
+ `syncNarrowHintMinBytes`), instead of nudging every all-mode member toward
13
+ shared mode. Below the threshold an all-mode membership is left alone — no
14
+ banner, and in strict mode no refusal. `hq sync mode` now also reports `all`
15
+ (the effective default) rather than `shared` when a config fetch fails.
16
+ (#303)
17
+
18
+ ## [5.85.2]
19
+
20
+ ### Fixed
21
+
22
+ - Bumped the `@indigoai-us/hq-cloud` floor to ^6.14.45, which scopes the sync
23
+ watcher to paths that can actually upload. On a large HQ root the watcher had
24
+ been asking the OS to report every directory in the tree — including
25
+ `repos/`, `workspace/worktrees/`, `node_modules/` and build output that sync
26
+ never uploads — and paying a blocking stat per event before discarding it.
27
+ One observed root reported 154,489 directories where 20,308 were in scope,
28
+ with the sync runner pinned above 100% CPU in garbage collection and growing
29
+ to 1.2 GB RSS over a 12-hour run. (hq-cloud#283)
30
+
5
31
  ## [5.85.1]
6
32
 
7
33
  ### Fixed
@@ -17,7 +17,7 @@ import * as fs from "fs";
17
17
  import * as path from "path";
18
18
  import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, } from "@indigoai-us/hq-cloud";
19
19
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
20
- import { emitNarrowHint, isStrictRefusal, resolveBannerLevel, } from "../lib/narrow-hint-banner.js";
20
+ import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
21
21
  /**
22
22
  * Build a loud, human-readable warning when a push dropped files because
23
23
  * they fell outside the caller's granted write scope. Returns null when
@@ -116,6 +116,26 @@ function readScopeExcludePrefixes(scope) {
116
116
  const prefixes = raw.filter((p) => typeof p === "string" && p.length > 0);
117
117
  return prefixes.length > 0 ? prefixes : undefined;
118
118
  }
119
+ /**
120
+ * The size gate for the narrow-mode nudge. `all` is the default sync mode, so
121
+ * an all-mode membership is nudged toward shared mode ONLY once its local
122
+ * `companies/<slug>/` folder crosses the configured byte threshold (default
123
+ * 5 GiB). Below that, all-mode is left alone — no banner, and (in strict mode)
124
+ * no refusal.
125
+ *
126
+ * `ref` is the caller's company selector, which may be a slug OR a `cmp_*` /
127
+ * `prs_*` uid. The on-disk folder is keyed by SLUG, so a uid selector can't be
128
+ * measured — it degrades to `false` (no nudge), which is the correct
129
+ * best-effort: the nudge is a convenience, never load-bearing. A folder that
130
+ * has never synced (missing directory) likewise measures under threshold.
131
+ */
132
+ function narrowNudgeExceedsSize(hqRoot, ref) {
133
+ if (!ref || ref.startsWith("cmp_") || ref.startsWith("prs_"))
134
+ return false;
135
+ const companyDir = path.join(hqRoot, "companies", ref);
136
+ const threshold = resolveNarrowHintMinBytes({ hqRoot });
137
+ return companyFolderExceedsThreshold(companyDir, threshold);
138
+ }
119
139
  export async function pullAll(options, deps) {
120
140
  const memberships = await deps.vaultClient.listMyMemberships();
121
141
  const persons = await deps.vaultClient.listPersonEntities();
@@ -212,7 +232,13 @@ export async function pullAll(options, deps) {
212
232
  if (options.forceScopeShrink && entry.companyUid) {
213
233
  entry.syncOptions.forceScopeShrink = true;
214
234
  }
235
+ // Size gate: an all-mode membership is only nudged / strict-refused once
236
+ // its local folder has grown past the threshold. Computed once per leg.
237
+ const nudgeExceedsSize = resolvedMode === "all" &&
238
+ entry.companyUid !== undefined &&
239
+ narrowNudgeExceedsSize(options.hqRoot, entry.slug);
215
240
  if (resolvedMode === "all" &&
241
+ nudgeExceedsSize &&
216
242
  isStrictRefusal(resolvedMode, narrowHintLevel) &&
217
243
  !options.modeAllOverride &&
218
244
  entry.companyUid) {
@@ -239,8 +265,8 @@ export async function pullAll(options, deps) {
239
265
  result.perCompany.push({ slug: entry.slug, result: r });
240
266
  // Banner emitted AFTER the leg succeeds so it appears alongside
241
267
  // the per-company summary line and doesn't get scrolled off by
242
- // sync chatter.
243
- if (resolvedMode === "all" && entry.companyUid) {
268
+ // sync chatter. Size-gated: only a large local folder is nudged.
269
+ if (resolvedMode === "all" && nudgeExceedsSize && entry.companyUid) {
244
270
  emitNarrowHint({
245
271
  companyUid: entry.companyUid,
246
272
  syncMode: resolvedMode,
@@ -806,11 +832,16 @@ export function registerCloudCommands(program) {
806
832
  // thread it into the pull below — not just the banner. Best-effort;
807
833
  // degrades to "all" inside the resolver on any failure.
808
834
  const pullScope = await resolveCliPullScope(pullClient, options.company, options.hqRoot);
835
+ // Size gate: only a large local folder is nudged / strict-refused.
836
+ const nudgeExceedsSize = resolvedMode === "all" &&
837
+ resolvedCompanyUid !== undefined &&
838
+ narrowNudgeExceedsSize(options.hqRoot, options.company);
809
839
  // Strict-mode refusal: matches runPullAll + runNowSingle behavior.
810
840
  // Default banner level is 'hint' which never triggers refusal —
811
- // wired now so future hq-core-staging releases can flip the
812
- // default to 'strict' without re-touching this command.
841
+ // wired now so a future release can flip the default to 'strict'
842
+ // (still size-gated) without re-touching this command.
813
843
  if (resolvedMode === "all" &&
844
+ nudgeExceedsSize &&
814
845
  isStrictRefusal(resolvedMode, narrowHintLevel) &&
815
846
  options.modeAll !== true &&
816
847
  resolvedCompanyUid) {
@@ -820,7 +851,7 @@ export function registerCloudCommands(program) {
820
851
  level: narrowHintLevel,
821
852
  });
822
853
  console.error(chalk.red("\n✗ Pull refused: strict narrow-hint mode is on and this " +
823
- "membership still pulls everything. Run `hq sync narrow --apply` " +
854
+ "company's local folder has grown large. Run `hq sync narrow --apply` " +
824
855
  "to migrate, or re-run with --mode-all."));
825
856
  process.exit(1);
826
857
  }
@@ -846,10 +877,10 @@ export function registerCloudCommands(program) {
846
877
  process.exit(1);
847
878
  }
848
879
  console.log(chalk.green(`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
849
- // US-011 (2026-05-21 fix): emit the hint banner after success
850
- // so it appears alongside the summary line. Mirrors the wiring
851
- // in runPullAll (cloud.ts:331) and runNowSingle (cloud.ts:1371).
852
- if (resolvedMode === "all" && resolvedCompanyUid) {
880
+ // Emit the hint banner after success so it appears alongside the
881
+ // summary line. Mirrors the wiring in runPullAll and runNowSingle.
882
+ // Size-gated: only a large local folder is nudged.
883
+ if (resolvedMode === "all" && nudgeExceedsSize && resolvedCompanyUid) {
853
884
  emitNarrowHint({
854
885
  companyUid: resolvedCompanyUid,
855
886
  syncMode: resolvedMode,
@@ -1304,7 +1335,12 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1304
1335
  resolvedMode = undefined;
1305
1336
  }
1306
1337
  }
1338
+ // Size gate: only a large local folder is nudged / strict-refused.
1339
+ const nudgeExceedsSize = resolvedMode === "all" &&
1340
+ resolvedCompanyUid !== undefined &&
1341
+ narrowNudgeExceedsSize(hqRoot, targetCompany);
1307
1342
  if (resolvedMode === "all" &&
1343
+ nudgeExceedsSize &&
1308
1344
  isStrictRefusal(resolvedMode, narrowHintLevel) &&
1309
1345
  !modeAllOverride &&
1310
1346
  resolvedCompanyUid) {
@@ -1314,7 +1350,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1314
1350
  level: narrowHintLevel,
1315
1351
  });
1316
1352
  console.error(chalk.red("\n✗ Sync now refused: strict narrow-hint mode is on and this " +
1317
- "membership still pulls everything. Run `hq sync narrow --apply` " +
1353
+ "company's local folder has grown large. Run `hq sync narrow --apply` " +
1318
1354
  "to migrate, or re-run with --mode-all."));
1319
1355
  process.exit(1);
1320
1356
  }
@@ -1358,9 +1394,10 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1358
1394
  console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
1359
1395
  process.exit(1);
1360
1396
  }
1361
- // US-011: emit the hint banner after a successful pull so it
1362
- // appears at the bottom of the summary rather than mid-stream.
1363
- if (resolvedMode === "all" && resolvedCompanyUid) {
1397
+ // Emit the hint banner after a successful pull so it appears at the
1398
+ // bottom of the summary rather than mid-stream. Size-gated: only a large
1399
+ // local folder is nudged.
1400
+ if (resolvedMode === "all" && nudgeExceedsSize && resolvedCompanyUid) {
1364
1401
  emitNarrowHint({
1365
1402
  companyUid: resolvedCompanyUid,
1366
1403
  syncMode: resolvedMode,
@@ -307,7 +307,20 @@ function writeStamps(liveRoot, sessionId) {
307
307
  fs.writeFileSync(stampPath, timestamp);
308
308
  return stampPaths;
309
309
  }
310
- const CODEX_CHECKPOINT_ACCOUNT = "hassaan@getindigo.ai";
310
+ /**
311
+ * Codex Stop-gate rollout scope: every HQ user on the operator domain, rather
312
+ * than the single account that dogfooded it first. Eligibility is decided on
313
+ * the email domain alone — a candidate must have exactly one `@` and the part
314
+ * after it must equal this domain, so lookalikes never pass (`xgetindigo.ai`,
315
+ * `getindigo.ai.evil.test`, `a@b@getindigo.ai`).
316
+ */
317
+ const CODEX_CHECKPOINT_DOMAIN = "getindigo.ai";
318
+ function hasCodexCheckpointDomain(candidate) {
319
+ if (typeof candidate !== "string")
320
+ return false;
321
+ const parts = candidate.trim().toLowerCase().split("@");
322
+ return parts.length === 2 && parts[0].length > 0 && parts[1] === CODEX_CHECKPOINT_DOMAIN;
323
+ }
311
324
  function checkpointGateRuntime() {
312
325
  const runtime = (process.env.HQ_CHECKPOINT_RUNTIME ?? "claude").trim().toLowerCase();
313
326
  if (runtime === "claude" || runtime === "codex")
@@ -331,7 +344,7 @@ function gateEligibility(runtime) {
331
344
  return false;
332
345
  const claims = peekIdToken(tokens.idToken);
333
346
  const candidateEmails = [claims.email, claims["custom:delegatedEmail"]];
334
- return candidateEmails.some((candidate) => typeof candidate === "string" && candidate.toLowerCase() === CODEX_CHECKPOINT_ACCOUNT);
347
+ return candidateEmails.some(hasCodexCheckpointDomain);
335
348
  }
336
349
  catch {
337
350
  return false;
@@ -139,7 +139,11 @@ export async function showSyncModes(options) {
139
139
  const [config, entity] = await Promise.all([
140
140
  vaultClient.getMembershipSyncConfig(m.membershipKey).catch(() => ({
141
141
  membershipId: m.membershipKey,
142
- syncMode: "shared",
142
+ // Display fallback when the config fetch fails: show 'all', the
143
+ // effective default a membership resolves to (see
144
+ // DEFAULT_MEMBERSHIP_SYNC_MODE / resolveEffectiveSyncMode). Showing
145
+ // 'shared' here would misreport the default the user is actually on.
146
+ syncMode: "all",
143
147
  isDefault: true,
144
148
  })),
145
149
  vaultClient.entity
@@ -1,12 +1,24 @@
1
1
  /**
2
- * `narrow-hint-banner` (US-011) — one-time-per-session hint nudging
3
- * existing all-mode owners to switch to shared-mode sync.
2
+ * `narrow-hint-banner` — one-time-per-session hint suggesting that a member
3
+ * whose LOCAL company folder has grown large could switch to shared-mode sync
4
+ * to pull fewer files.
5
+ *
6
+ * `all` is the DEFAULT sync mode (see `DEFAULT_MEMBERSHIP_SYNC_MODE` in
7
+ * hq-pro): a member who can see a company gets that company's files. Shared
8
+ * mode is a deliberate, opt-in NARROWING — worth suggesting only once the
9
+ * folder is big enough that pulling all of it actually costs disk/bandwidth.
10
+ * So the nudge is SIZE-GATED: below the threshold (default 5 GiB, see
11
+ * `DEFAULT_NARROW_HINT_MIN_BYTES`) no banner is ever shown, and an all-mode
12
+ * membership with a small folder is left completely alone.
4
13
  *
5
14
  * Emitted from `hq sync pull --all` and `hq sync now` after the per-target
6
15
  * fanout resolves each membership's sync config. Suppressed when:
7
16
  *
8
17
  * - the membership is NOT on `syncMode: 'all'` (shared / custom users
9
18
  * have already opted in to narrowing, so there is nothing to nudge),
19
+ * - the local `companies/<slug>/` folder is under the size threshold
20
+ * (the primary gate — the call site computes this and only emits when
21
+ * it is exceeded),
10
22
  * - the env var `HQ_SYNC_NARROW_HINT=off` is set,
11
23
  * - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
12
24
  * `syncNarrowHint: 'off'`,
@@ -16,9 +28,9 @@
16
28
  * banner per company per level).
17
29
  *
18
30
  * Three escalating levels — `'hint' | 'warning' | 'strict'`. Today's
19
- * default level is `'hint'`; this release ships the plumbing so a future
20
- * hq-core-staging release can flip the default to `'warning'` and then
21
- * `'strict'` without re-touching the call sites.
31
+ * default level is `'hint'`; the plumbing lets an install escalate a large
32
+ * folder toward shared mode without re-touching the call sites. The size
33
+ * gate applies to every level: strict never refuses a small folder.
22
34
  *
23
35
  * - hint → dim suggestion to stderr, never blocks.
24
36
  * - warning → yellow note to stderr, never blocks.
@@ -29,13 +41,18 @@
29
41
  *
30
42
  * The level is selected by the caller (typically from the
31
43
  * `HQ_SYNC_NARROW_HINT_LEVEL` env var). See `resolveBannerLevel` for the
32
- * default-and-override ladder.
33
- *
34
- * TODO(hq-core-staging release N+2): bump default level to 'warning'.
35
- * TODO(hq-core-staging release N+3): bump default level to 'strict' and
36
- * wire `--mode-all` as the only opt-out.
44
+ * default-and-override ladder. The size threshold is overridable per install
45
+ * via `HQ_SYNC_NARROW_HINT_MIN_BYTES` or `.hq/config.json`
46
+ * `syncNarrowHintMinBytes` see `resolveNarrowHintMinBytes`.
37
47
  */
48
+ import * as fs from "node:fs";
38
49
  export type BannerLevel = "hint" | "warning" | "strict";
50
+ /**
51
+ * Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
52
+ * smaller than this is cheap to keep in full, so all-mode is left alone and no
53
+ * banner is shown. Overridable per install — see `resolveNarrowHintMinBytes`.
54
+ */
55
+ export declare const DEFAULT_NARROW_HINT_MIN_BYTES: number;
39
56
  export interface BannerInput {
40
57
  /** Company UID — used to dedupe per-process so each company emits once. */
41
58
  companyUid: string;
@@ -43,6 +60,12 @@ export interface BannerInput {
43
60
  syncMode: "shared" | "all" | "custom";
44
61
  /** Escalation level — see file header. */
45
62
  level: BannerLevel;
63
+ /**
64
+ * Measured size of the local `companies/<slug>/` folder, in bytes. Optional
65
+ * and cosmetic: when present it is rendered into the message ("~6.2 GB") so
66
+ * the operator sees WHY the nudge fired. Absent → the message omits the size.
67
+ */
68
+ folderBytes?: number;
46
69
  }
47
70
  export interface ShouldShowBannerOpts {
48
71
  /**
@@ -78,11 +101,54 @@ export declare function shouldShowBanner(opts?: ShouldShowBannerOpts): boolean;
78
101
  * facing and a typo shouldn't break a sync.
79
102
  */
80
103
  export declare function resolveBannerLevel(envValue?: string | undefined): BannerLevel;
104
+ /**
105
+ * Resolve the size threshold (in bytes) above which the narrow nudge fires.
106
+ * Precedence, first match wins:
107
+ *
108
+ * 1. `HQ_SYNC_NARROW_HINT_MIN_BYTES` env var (integer bytes),
109
+ * 2. `<hqRoot>/.hq/config.json` → `syncNarrowHintMinBytes` (integer bytes),
110
+ * 3. `DEFAULT_NARROW_HINT_MIN_BYTES` (5 GiB).
111
+ *
112
+ * A non-integer, negative, or otherwise unparseable override is ignored (falls
113
+ * through to the next source) rather than throwing — this is operator-facing
114
+ * config and a typo must not break a sync.
115
+ */
116
+ export declare function resolveNarrowHintMinBytes(opts?: {
117
+ hqRoot?: string;
118
+ /** Test seam — defaults to `process.env.HQ_SYNC_NARROW_HINT_MIN_BYTES`. */
119
+ envValue?: string | undefined;
120
+ readFile?: (p: string) => string;
121
+ existsFile?: (p: string) => boolean;
122
+ }): number;
123
+ /**
124
+ * Does the on-disk `companies/<slug>/` folder meet or exceed `thresholdBytes`?
125
+ *
126
+ * Walks the tree summing regular-file sizes and SHORT-CIRCUITS the instant the
127
+ * running total reaches the threshold, so a huge folder costs only enough
128
+ * `stat`s to cross the line rather than a full enumeration. Symlinks are
129
+ * counted by their own (link) size and never followed, so a symlink cycle
130
+ * cannot wedge the walk.
131
+ *
132
+ * Best-effort: a missing folder (never synced yet), a permission error, or any
133
+ * other I/O fault resolves to `false`. Not being able to prove a folder is
134
+ * large means we do NOT nag — the nudge is a convenience, never a blocker.
135
+ */
136
+ export declare function companyFolderExceedsThreshold(companyDir: string, thresholdBytes: number, deps?: {
137
+ readdir?: (p: string) => fs.Dirent[];
138
+ lstat?: (p: string) => {
139
+ size: number;
140
+ };
141
+ }): boolean;
81
142
  /**
82
143
  * Returns `true` when the strict-mode rollout has been opted into AND
83
144
  * the membership in question is still on `'all'`. Call sites should
84
145
  * refuse to proceed (exit non-zero) when this returns true and the
85
146
  * operator hasn't passed `--mode-all`.
147
+ *
148
+ * NOTE: this does NOT encode the size gate — the size gate is a separate,
149
+ * mandatory precondition the call site checks FIRST (see
150
+ * `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
151
+ * folder is under the threshold is never refused.
86
152
  */
87
153
  export declare function isStrictRefusal(syncMode: BannerInput["syncMode"], level: BannerLevel): boolean;
88
154
  /**
@@ -1,12 +1,24 @@
1
1
  /**
2
- * `narrow-hint-banner` (US-011) — one-time-per-session hint nudging
3
- * existing all-mode owners to switch to shared-mode sync.
2
+ * `narrow-hint-banner` — one-time-per-session hint suggesting that a member
3
+ * whose LOCAL company folder has grown large could switch to shared-mode sync
4
+ * to pull fewer files.
5
+ *
6
+ * `all` is the DEFAULT sync mode (see `DEFAULT_MEMBERSHIP_SYNC_MODE` in
7
+ * hq-pro): a member who can see a company gets that company's files. Shared
8
+ * mode is a deliberate, opt-in NARROWING — worth suggesting only once the
9
+ * folder is big enough that pulling all of it actually costs disk/bandwidth.
10
+ * So the nudge is SIZE-GATED: below the threshold (default 5 GiB, see
11
+ * `DEFAULT_NARROW_HINT_MIN_BYTES`) no banner is ever shown, and an all-mode
12
+ * membership with a small folder is left completely alone.
4
13
  *
5
14
  * Emitted from `hq sync pull --all` and `hq sync now` after the per-target
6
15
  * fanout resolves each membership's sync config. Suppressed when:
7
16
  *
8
17
  * - the membership is NOT on `syncMode: 'all'` (shared / custom users
9
18
  * have already opted in to narrowing, so there is nothing to nudge),
19
+ * - the local `companies/<slug>/` folder is under the size threshold
20
+ * (the primary gate — the call site computes this and only emits when
21
+ * it is exceeded),
10
22
  * - the env var `HQ_SYNC_NARROW_HINT=off` is set,
11
23
  * - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
12
24
  * `syncNarrowHint: 'off'`,
@@ -16,9 +28,9 @@
16
28
  * banner per company per level).
17
29
  *
18
30
  * Three escalating levels — `'hint' | 'warning' | 'strict'`. Today's
19
- * default level is `'hint'`; this release ships the plumbing so a future
20
- * hq-core-staging release can flip the default to `'warning'` and then
21
- * `'strict'` without re-touching the call sites.
31
+ * default level is `'hint'`; the plumbing lets an install escalate a large
32
+ * folder toward shared mode without re-touching the call sites. The size
33
+ * gate applies to every level: strict never refuses a small folder.
22
34
  *
23
35
  * - hint → dim suggestion to stderr, never blocks.
24
36
  * - warning → yellow note to stderr, never blocks.
@@ -29,15 +41,33 @@
29
41
  *
30
42
  * The level is selected by the caller (typically from the
31
43
  * `HQ_SYNC_NARROW_HINT_LEVEL` env var). See `resolveBannerLevel` for the
32
- * default-and-override ladder.
33
- *
34
- * TODO(hq-core-staging release N+2): bump default level to 'warning'.
35
- * TODO(hq-core-staging release N+3): bump default level to 'strict' and
36
- * wire `--mode-all` as the only opt-out.
44
+ * default-and-override ladder. The size threshold is overridable per install
45
+ * via `HQ_SYNC_NARROW_HINT_MIN_BYTES` or `.hq/config.json`
46
+ * `syncNarrowHintMinBytes` see `resolveNarrowHintMinBytes`.
37
47
  */
38
48
  import chalk from "chalk";
39
49
  import * as fs from "node:fs";
40
50
  import * as path from "node:path";
51
+ /**
52
+ * Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
53
+ * smaller than this is cheap to keep in full, so all-mode is left alone and no
54
+ * banner is shown. Overridable per install — see `resolveNarrowHintMinBytes`.
55
+ */
56
+ export const DEFAULT_NARROW_HINT_MIN_BYTES = 5 * 1024 * 1024 * 1024;
57
+ /** Render a byte count as a short human string, e.g. `6.2 GB`. */
58
+ function formatBytes(bytes) {
59
+ if (!Number.isFinite(bytes) || bytes < 0)
60
+ return "";
61
+ const units = ["B", "KB", "MB", "GB", "TB"];
62
+ let value = bytes;
63
+ let unit = 0;
64
+ while (value >= 1024 && unit < units.length - 1) {
65
+ value /= 1024;
66
+ unit += 1;
67
+ }
68
+ const rounded = unit === 0 ? String(value) : value.toFixed(1);
69
+ return `${rounded} ${units[unit]}`;
70
+ }
41
71
  const SHOWN = new Set();
42
72
  /**
43
73
  * Decides whether a banner should be printed AT ALL — independent of
@@ -86,11 +116,119 @@ export function resolveBannerLevel(envValue = process.env.HQ_SYNC_NARROW_HINT_LE
86
116
  return v;
87
117
  return "hint";
88
118
  }
119
+ /**
120
+ * Resolve the size threshold (in bytes) above which the narrow nudge fires.
121
+ * Precedence, first match wins:
122
+ *
123
+ * 1. `HQ_SYNC_NARROW_HINT_MIN_BYTES` env var (integer bytes),
124
+ * 2. `<hqRoot>/.hq/config.json` → `syncNarrowHintMinBytes` (integer bytes),
125
+ * 3. `DEFAULT_NARROW_HINT_MIN_BYTES` (5 GiB).
126
+ *
127
+ * A non-integer, negative, or otherwise unparseable override is ignored (falls
128
+ * through to the next source) rather than throwing — this is operator-facing
129
+ * config and a typo must not break a sync.
130
+ */
131
+ export function resolveNarrowHintMinBytes(opts = {}) {
132
+ const fromEnv = parsePositiveInt(opts.envValue !== undefined
133
+ ? opts.envValue
134
+ : process.env.HQ_SYNC_NARROW_HINT_MIN_BYTES);
135
+ if (fromEnv !== null)
136
+ return fromEnv;
137
+ if (opts.hqRoot) {
138
+ const configPath = path.join(opts.hqRoot, ".hq", "config.json");
139
+ const exists = opts.existsFile ?? fs.existsSync;
140
+ const read = opts.readFile ?? ((p) => fs.readFileSync(p, "utf-8"));
141
+ if (exists(configPath)) {
142
+ try {
143
+ const cfg = JSON.parse(read(configPath));
144
+ const fromCfg = parsePositiveInt(cfg.syncNarrowHintMinBytes);
145
+ if (fromCfg !== null)
146
+ return fromCfg;
147
+ }
148
+ catch {
149
+ // Malformed config → fall through to the default.
150
+ }
151
+ }
152
+ }
153
+ return DEFAULT_NARROW_HINT_MIN_BYTES;
154
+ }
155
+ /** A finite, non-negative integer parsed from a string/number, else null. */
156
+ function parsePositiveInt(value) {
157
+ if (typeof value === "number") {
158
+ return Number.isInteger(value) && value >= 0 ? value : null;
159
+ }
160
+ if (typeof value !== "string")
161
+ return null;
162
+ const trimmed = value.trim();
163
+ if (!/^\d+$/.test(trimmed))
164
+ return null;
165
+ const n = Number(trimmed);
166
+ return Number.isSafeInteger(n) ? n : null;
167
+ }
168
+ /**
169
+ * Does the on-disk `companies/<slug>/` folder meet or exceed `thresholdBytes`?
170
+ *
171
+ * Walks the tree summing regular-file sizes and SHORT-CIRCUITS the instant the
172
+ * running total reaches the threshold, so a huge folder costs only enough
173
+ * `stat`s to cross the line rather than a full enumeration. Symlinks are
174
+ * counted by their own (link) size and never followed, so a symlink cycle
175
+ * cannot wedge the walk.
176
+ *
177
+ * Best-effort: a missing folder (never synced yet), a permission error, or any
178
+ * other I/O fault resolves to `false`. Not being able to prove a folder is
179
+ * large means we do NOT nag — the nudge is a convenience, never a blocker.
180
+ */
181
+ export function companyFolderExceedsThreshold(companyDir, thresholdBytes, deps = {}) {
182
+ if (thresholdBytes <= 0)
183
+ return true;
184
+ const readdir = deps.readdir ??
185
+ ((p) => fs.readdirSync(p, { withFileTypes: true }));
186
+ const lstat = deps.lstat ?? ((p) => fs.lstatSync(p));
187
+ let total = 0;
188
+ const stack = [companyDir];
189
+ while (stack.length > 0) {
190
+ const dir = stack.pop();
191
+ let entries;
192
+ try {
193
+ entries = readdir(dir);
194
+ }
195
+ catch {
196
+ // Unreadable directory (missing / no permission) — skip it, don't abort
197
+ // the whole measurement over one bad subtree.
198
+ continue;
199
+ }
200
+ for (const entry of entries) {
201
+ const full = path.join(dir, entry.name);
202
+ if (entry.isDirectory()) {
203
+ stack.push(full);
204
+ continue;
205
+ }
206
+ // Symlinks and regular files alike: count the link/file's own size,
207
+ // never follow (isDirectory() above already excluded real dirs; a
208
+ // symlink-to-dir is intentionally treated as a leaf).
209
+ try {
210
+ total += lstat(full).size;
211
+ }
212
+ catch {
213
+ // Vanished between readdir and lstat — ignore.
214
+ continue;
215
+ }
216
+ if (total >= thresholdBytes)
217
+ return true;
218
+ }
219
+ }
220
+ return false;
221
+ }
89
222
  /**
90
223
  * Returns `true` when the strict-mode rollout has been opted into AND
91
224
  * the membership in question is still on `'all'`. Call sites should
92
225
  * refuse to proceed (exit non-zero) when this returns true and the
93
226
  * operator hasn't passed `--mode-all`.
227
+ *
228
+ * NOTE: this does NOT encode the size gate — the size gate is a separate,
229
+ * mandatory precondition the call site checks FIRST (see
230
+ * `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
231
+ * folder is under the threshold is never refused.
94
232
  */
95
233
  export function isStrictRefusal(syncMode, level) {
96
234
  return level === "strict" && syncMode === "all";
@@ -116,23 +254,28 @@ export function emitNarrowHint(input, opts = {}) {
116
254
  return;
117
255
  SHOWN.add(key);
118
256
  const write = opts.write ?? ((s) => process.stderr.write(s + "\n"));
257
+ // The nudge only reaches here for a folder past the size gate, so every
258
+ // message leads with the size fact. `folderBytes` is optional/cosmetic.
259
+ const sizePhrase = typeof input.folderBytes === "number"
260
+ ? `has grown to ~${formatBytes(input.folderBytes)}`
261
+ : "has grown large";
119
262
  if (input.level === "hint") {
120
- write(chalk.dim("Tip: switch to shared-mode sync fewer files, same visibility. " +
121
- "Run `hq sync narrow --dry-run` to preview."));
263
+ write(chalk.dim(`Tip: this company's local folder ${sizePhrase}. You can switch to ` +
264
+ "shared-mode sync to only pull files shared with you — run " +
265
+ "`hq sync narrow --dry-run` to preview."));
122
266
  return;
123
267
  }
124
268
  if (input.level === "warning") {
125
- write(chalk.yellow("Warning: shared-mode sync is the new default; this membership " +
126
- "still pulls everything. Run `hq sync narrow --dry-run` to " +
127
- "preview the migration before the next release flips the " +
128
- "default to strict."));
269
+ write(chalk.yellow(`Warning: this company's local folder ${sizePhrase} and is syncing in ` +
270
+ "full. Consider shared-mode sync to pull only what's shared with " +
271
+ "you run `hq sync narrow --dry-run` to preview the migration."));
129
272
  return;
130
273
  }
131
274
  // strict — caller is responsible for refusing to proceed unless
132
275
  // --mode-all was passed. We only emit the message here.
133
- write(chalk.red("Error: shared-mode sync is now strict; pass --mode-all to keep " +
134
- "all-mode behavior for this run, or run `hq sync narrow --apply` " +
135
- "to migrate this membership."));
276
+ write(chalk.red(`Error: this company's local folder ${sizePhrase}; all-mode sync is ` +
277
+ "blocked for it. Pass --mode-all to keep pulling everything this run, " +
278
+ "or run `hq sync narrow --apply` to switch to shared mode."));
136
279
  }
137
280
  /** Test-only helper — clears the per-process dedupe set. */
138
281
  export function _resetShownForTests() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.85.1",
3
+ "version": "5.85.3",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
31
31
  "@aws-sdk/client-s3": "^3.1049.0",
32
- "@indigoai-us/hq-cloud": "^6.14.37",
32
+ "@indigoai-us/hq-cloud": "^6.14.45",
33
33
  "@indigoai-us/hq-onboarding": "^0.1.0",
34
34
  "@sentry/node": "^10.49.0",
35
35
  "better-sqlite3": "^12.11.1",