@indigoai-us/hq-cli 5.17.0 → 5.18.1

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.
@@ -28,6 +28,7 @@ import {
28
28
  computePersonalVaultPaths,
29
29
  type ConflictStrategy,
30
30
  type EntityContext,
31
+ type MembershipSyncConfig,
31
32
  type SyncProgressEvent,
32
33
  type UploadAuthor,
33
34
  } from "@indigoai-us/hq-cloud";
@@ -37,6 +38,12 @@ import {
37
38
  ensureCognitoToken,
38
39
  buildVaultConfig,
39
40
  } from "../utils/cognito-session.js";
41
+ import {
42
+ emitNarrowHint,
43
+ isStrictRefusal,
44
+ resolveBannerLevel,
45
+ type BannerLevel,
46
+ } from "../lib/narrow-hint-banner.js";
40
47
 
41
48
  interface CommonSyncOptions {
42
49
  hqRoot: string;
@@ -53,7 +60,9 @@ interface CommonSyncOptions {
53
60
  // ─────────────────────────────────────────────────────────────────────────────
54
61
 
55
62
  export interface PullAllVaultClient {
56
- listMyMemberships(): Promise<Array<{ companyUid: string }>>;
63
+ listMyMemberships(): Promise<
64
+ Array<{ companyUid: string; membershipKey?: string }>
65
+ >;
57
66
  listPersonEntities(): Promise<
58
67
  Array<{
59
68
  uid: string;
@@ -64,6 +73,15 @@ export interface PullAllVaultClient {
64
73
  }>
65
74
  >;
66
75
  getEntity(uid: string): Promise<{ slug?: string; name?: string } | null>;
76
+ /**
77
+ * US-011: optional — when present, `pullAll` calls it once per
78
+ * membership to surface the narrow-hint banner for all-mode owners.
79
+ * Absent on legacy adapters (push-all et al.) where the banner is not
80
+ * applicable.
81
+ */
82
+ getMembershipSyncConfig?: (
83
+ membershipId: string,
84
+ ) => Promise<MembershipSyncConfig>;
67
85
  }
68
86
 
69
87
  export interface SyncCallOptions {
@@ -91,6 +109,22 @@ export interface PullAllDeps {
91
109
  export interface PullAllOptions {
92
110
  hqRoot: string;
93
111
  onConflict?: ConflictStrategy;
112
+ /**
113
+ * US-011: banner level for the narrow-hint nudge. Defaults to `'hint'`
114
+ * — see `resolveBannerLevel` for the env-driven override. The
115
+ * `'strict'` level causes `pullAll` to refuse to sync any membership
116
+ * still on `syncMode: 'all'` unless `modeAllOverride` is true.
117
+ *
118
+ * TODO(hq-core-staging release N+2): default flips to 'warning'.
119
+ * TODO(hq-core-staging release N+3): default flips to 'strict'.
120
+ */
121
+ narrowHintLevel?: BannerLevel;
122
+ /**
123
+ * US-011: when `true`, strict-mode does NOT refuse all-mode
124
+ * memberships — the operator has explicitly opted into keeping the
125
+ * legacy behavior for this run via `--mode-all`.
126
+ */
127
+ modeAllOverride?: boolean;
94
128
  }
95
129
 
96
130
  export interface PullAllRow {
@@ -168,6 +202,9 @@ export interface PullAllResult {
168
202
  interface PlanEntry {
169
203
  slug: string;
170
204
  syncOptions: SyncCallOptions;
205
+ /** US-011: tracked so we can resolve sync-config + emit the narrow hint. */
206
+ companyUid?: string;
207
+ membershipKey?: string;
171
208
  }
172
209
 
173
210
  // Oldest-first by createdAt, ties broken by uid lexicographic — matches
@@ -191,6 +228,9 @@ export async function pullAll(
191
228
  const memberships = await deps.vaultClient.listMyMemberships();
192
229
  const persons = await deps.vaultClient.listPersonEntities();
193
230
 
231
+ const narrowHintLevel: BannerLevel = options.narrowHintLevel ?? "hint";
232
+ const getSyncConfig = deps.vaultClient.getMembershipSyncConfig;
233
+
194
234
  const plan: PlanEntry[] = [];
195
235
  for (const m of memberships) {
196
236
  let slug = m.companyUid;
@@ -202,6 +242,8 @@ export async function pullAll(
202
242
  }
203
243
  plan.push({
204
244
  slug,
245
+ companyUid: m.companyUid,
246
+ ...(m.membershipKey ? { membershipKey: m.membershipKey } : {}),
205
247
  syncOptions: {
206
248
  company: m.companyUid,
207
249
  hqRoot: options.hqRoot,
@@ -235,12 +277,63 @@ export async function pullAll(
235
277
 
236
278
  for (const entry of plan) {
237
279
  result.attempted += 1;
280
+
281
+ // US-011: resolve the membership's effective sync mode so we can
282
+ // either nudge an all-mode owner toward `hq sync narrow` OR refuse
283
+ // the leg outright when strict-mode is on and the operator didn't
284
+ // pass `--mode-all`. Sync-config lookup is best-effort — a 404 or
285
+ // network blip should never block the sync itself, so we fall back
286
+ // to syncMode='all' (the legacy default) and skip the banner.
287
+ let resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
288
+ if (entry.membershipKey && getSyncConfig) {
289
+ try {
290
+ const cfg = await getSyncConfig(entry.membershipKey);
291
+ resolvedMode = cfg.syncMode;
292
+ } catch {
293
+ resolvedMode = undefined;
294
+ }
295
+ }
296
+
297
+ if (
298
+ resolvedMode === "all" &&
299
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
300
+ !options.modeAllOverride &&
301
+ entry.companyUid
302
+ ) {
303
+ // Emit the strict-level banner once, then mark the leg as errored
304
+ // without invoking sync(). The operator either narrows the
305
+ // membership (`hq sync narrow --apply`) or passes `--mode-all` to
306
+ // opt back in.
307
+ emitNarrowHint({
308
+ companyUid: entry.companyUid,
309
+ syncMode: resolvedMode,
310
+ level: narrowHintLevel,
311
+ });
312
+ const message =
313
+ "Refusing to pull all-mode membership in strict mode. " +
314
+ "Run `hq sync narrow --apply` to migrate, or re-run with --mode-all.";
315
+ result.errors.push({ company: entry.slug, message });
316
+ result.perCompany.push({ slug: entry.slug, error: message });
317
+ continue;
318
+ }
319
+
238
320
  try {
239
321
  const r = await deps.sync(entry.syncOptions);
240
322
  result.filesDownloaded += r.filesDownloaded;
241
323
  result.bytesDownloaded += r.bytesDownloaded;
242
324
  result.conflicts += r.conflicts;
243
325
  result.perCompany.push({ slug: entry.slug, result: r });
326
+
327
+ // Banner emitted AFTER the leg succeeds so it appears alongside
328
+ // the per-company summary line and doesn't get scrolled off by
329
+ // sync chatter.
330
+ if (resolvedMode === "all" && entry.companyUid) {
331
+ emitNarrowHint({
332
+ companyUid: entry.companyUid,
333
+ syncMode: resolvedMode,
334
+ level: narrowHintLevel,
335
+ });
336
+ }
244
337
  } catch (err) {
245
338
  const message = err instanceof Error ? err.message : String(err);
246
339
  result.errors.push({ company: entry.slug, message });
@@ -380,6 +473,86 @@ export function assertSingleSelector(opts: {
380
473
  }
381
474
  }
382
475
 
476
+ /**
477
+ * Per-company pull resolution helper used by `hq sync pull --company <slug>`
478
+ * (US-011 fix, 2026-05-21). Mirrors the inline lookup that `runNowSingle`
479
+ * does for sync-now. Pulled out so the action handler stays thin AND so
480
+ * unit tests can exercise the banner / strict-refusal decision without
481
+ * spinning up commander + a real VaultClient.
482
+ *
483
+ * Input shape:
484
+ * - `targetCompany` — slug or UID the caller passed to `--company`. If
485
+ * undefined, the helper short-circuits to a "no resolution" result
486
+ * (the action handler falls back to .hq/config.json via sync()).
487
+ * - `client` — minimal VaultClient surface: listMyMemberships + entity.get
488
+ * + getMembershipSyncConfig.
489
+ *
490
+ * Output: `{ resolvedCompanyUid, resolvedMode }` — either may be undefined
491
+ * if the membership / sync-config call failed. Both undefined is a clean
492
+ * degradation — the caller pulls without a banner.
493
+ */
494
+ export interface PerCompanyPullResolveClient {
495
+ listMyMemberships(): Promise<Array<{ companyUid: string; membershipKey: string }>>;
496
+ getMembershipSyncConfig(
497
+ membershipKey: string,
498
+ ): Promise<{ syncMode: MembershipSyncConfig["syncMode"] }>;
499
+ entity: { get(uid: string): Promise<{ slug?: string }> };
500
+ }
501
+
502
+ export interface PerCompanyPullResolveResult {
503
+ resolvedCompanyUid: string | undefined;
504
+ resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
505
+ }
506
+
507
+ export async function resolvePerCompanyPullPlan(
508
+ client: PerCompanyPullResolveClient,
509
+ targetCompany: string | undefined,
510
+ ): Promise<PerCompanyPullResolveResult> {
511
+ if (!targetCompany) return { resolvedCompanyUid: undefined, resolvedMode: undefined };
512
+ try {
513
+ const memberships = await client.listMyMemberships();
514
+ // Direct UID / membershipKey match first (cheapest).
515
+ const direct = memberships.find(
516
+ (m) => m.companyUid === targetCompany || m.membershipKey === targetCompany,
517
+ );
518
+ if (direct) {
519
+ let mode: MembershipSyncConfig["syncMode"] | undefined;
520
+ try {
521
+ const cfg = await client.getMembershipSyncConfig(direct.membershipKey);
522
+ mode = cfg.syncMode;
523
+ } catch {
524
+ mode = undefined;
525
+ }
526
+ return { resolvedCompanyUid: direct.companyUid, resolvedMode: mode };
527
+ }
528
+ // Slug match — listMyMemberships returns companyUid only, so fan out
529
+ // entity.get to find the row whose slug matches the caller's input.
530
+ for (const m of memberships) {
531
+ try {
532
+ const entity = await client.entity.get(m.companyUid);
533
+ if (entity.slug === targetCompany) {
534
+ let mode: MembershipSyncConfig["syncMode"] | undefined;
535
+ try {
536
+ const cfg = await client.getMembershipSyncConfig(m.membershipKey);
537
+ mode = cfg.syncMode;
538
+ } catch {
539
+ mode = undefined;
540
+ }
541
+ return { resolvedCompanyUid: m.companyUid, resolvedMode: mode };
542
+ }
543
+ } catch {
544
+ // Entity not visible — skip and continue. Worst case the loop ends
545
+ // with no match and we return undefined for both — the pull still
546
+ // proceeds, banner just stays quiet.
547
+ }
548
+ }
549
+ } catch {
550
+ // listMyMemberships failed — degrade silently. Sync still works without
551
+ // the banner; this matches the runPullAll catch behavior.
552
+ }
553
+ return { resolvedCompanyUid: undefined, resolvedMode: undefined };
554
+ }
555
+
383
556
  export function registerCloudCommands(program: Command): void {
384
557
  program
385
558
  .command("push")
@@ -681,12 +854,20 @@ export function registerCloudCommands(program: Command): void {
681
854
  "from the cached Cognito session. Mutually exclusive with --company " +
682
855
  "and --all.",
683
856
  )
857
+ .option(
858
+ "--mode-all",
859
+ "US-011: opt out of the strict narrow-hint refusal for this run. " +
860
+ "Has no effect today (default narrow-hint level is 'hint'); " +
861
+ "wired so future hq-core-staging releases can flip the default to " +
862
+ "'strict' without re-touching this command.",
863
+ )
684
864
  .action(
685
865
  async (
686
866
  options: CommonSyncOptions & {
687
867
  onConflict?: ConflictStrategy;
688
868
  all?: boolean;
689
869
  personal?: boolean;
870
+ modeAll?: boolean;
690
871
  },
691
872
  ) => {
692
873
  try {
@@ -699,7 +880,11 @@ export function registerCloudCommands(program: Command): void {
699
880
  process.exit(1);
700
881
  }
701
882
  if (options.all) {
702
- await runPullAll(options.hqRoot, options.onConflict);
883
+ await runPullAll(
884
+ options.hqRoot,
885
+ options.onConflict,
886
+ options.modeAll === true,
887
+ );
703
888
  return;
704
889
  }
705
890
  if (options.personal) {
@@ -712,10 +897,50 @@ export function registerCloudCommands(program: Command): void {
712
897
  console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
713
898
 
714
899
  const accessToken = await ensureCognitoToken();
900
+ const vaultConfig = buildVaultConfig(accessToken);
901
+
902
+ // US-011 (2026-05-21 fix): resolve the caller's sync-config for
903
+ // the targeted membership BEFORE the pull, so we can (a) emit the
904
+ // narrow-hint banner after success if still on all-mode and
905
+ // (b) respect strict-mode refusal mirror of the --all + sync-now
906
+ // paths. Failure to resolve degrades silently — pull still works,
907
+ // banner just stays quiet (same as the catch in runPullAll).
908
+ const narrowHintLevel: BannerLevel = resolveBannerLevel();
909
+ const { resolvedCompanyUid, resolvedMode } =
910
+ await resolvePerCompanyPullPlan(
911
+ new VaultClient(vaultConfig),
912
+ options.company,
913
+ );
914
+
915
+ // Strict-mode refusal: matches runPullAll + runNowSingle behavior.
916
+ // Default banner level is 'hint' which never triggers refusal —
917
+ // wired now so future hq-core-staging releases can flip the
918
+ // default to 'strict' without re-touching this command.
919
+ if (
920
+ resolvedMode === "all" &&
921
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
922
+ options.modeAll !== true &&
923
+ resolvedCompanyUid
924
+ ) {
925
+ emitNarrowHint({
926
+ companyUid: resolvedCompanyUid,
927
+ syncMode: resolvedMode,
928
+ level: narrowHintLevel,
929
+ });
930
+ console.error(
931
+ chalk.red(
932
+ "\n✗ Pull refused: strict narrow-hint mode is on and this " +
933
+ "membership still pulls everything. Run `hq sync narrow --apply` " +
934
+ "to migrate, or re-run with --mode-all.",
935
+ ),
936
+ );
937
+ process.exit(1);
938
+ }
939
+
715
940
  const result = await sync({
716
941
  company: options.company,
717
942
  onConflict: options.onConflict,
718
- vaultConfig: buildVaultConfig(accessToken),
943
+ vaultConfig,
719
944
  hqRoot: options.hqRoot,
720
945
  });
721
946
 
@@ -733,6 +958,17 @@ export function registerCloudCommands(program: Command): void {
733
958
  `\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`,
734
959
  ),
735
960
  );
961
+
962
+ // US-011 (2026-05-21 fix): emit the hint banner after success
963
+ // so it appears alongside the summary line. Mirrors the wiring
964
+ // in runPullAll (cloud.ts:331) and runNowSingle (cloud.ts:1371).
965
+ if (resolvedMode === "all" && resolvedCompanyUid) {
966
+ emitNarrowHint({
967
+ companyUid: resolvedCompanyUid,
968
+ syncMode: resolvedMode,
969
+ level: narrowHintLevel,
970
+ });
971
+ }
736
972
  } catch (err) {
737
973
  console.error(
738
974
  chalk.red("\n✗ Pull failed:"),
@@ -833,6 +1069,12 @@ export function registerCloudCommands(program: Command): void {
833
1069
  "Sync the caller's canonical personal vault bidirectionally. " +
834
1070
  "Mutually exclusive with --company and --all.",
835
1071
  )
1072
+ .option(
1073
+ "--mode-all",
1074
+ "US-011: opt out of the strict narrow-hint refusal for this run. " +
1075
+ "No-op today; wired so future hq-core-staging releases can flip " +
1076
+ "the default narrow-hint level to 'strict'.",
1077
+ )
836
1078
  .action(
837
1079
  async (
838
1080
  options: CommonSyncOptions & {
@@ -840,6 +1082,7 @@ export function registerCloudCommands(program: Command): void {
840
1082
  message?: string;
841
1083
  all?: boolean;
842
1084
  personal?: boolean;
1085
+ modeAll?: boolean;
843
1086
  },
844
1087
  ) => {
845
1088
  try {
@@ -849,6 +1092,7 @@ export function registerCloudCommands(program: Command): void {
849
1092
  options.hqRoot,
850
1093
  options.message,
851
1094
  options.onConflict,
1095
+ options.modeAll === true,
852
1096
  );
853
1097
  return;
854
1098
  }
@@ -858,6 +1102,7 @@ export function registerCloudCommands(program: Command): void {
858
1102
  options.personal === true,
859
1103
  options.message,
860
1104
  options.onConflict,
1105
+ options.modeAll === true,
861
1106
  );
862
1107
  } catch (err) {
863
1108
  console.error(
@@ -873,6 +1118,7 @@ export function registerCloudCommands(program: Command): void {
873
1118
  async function runPullAll(
874
1119
  hqRoot: string,
875
1120
  onConflict?: ConflictStrategy,
1121
+ modeAllOverride?: boolean,
876
1122
  ): Promise<void> {
877
1123
  console.log(chalk.bold("\nHQ Sync — Pull (all)"));
878
1124
  console.log(` HQ root: ${hqRoot}`);
@@ -894,10 +1140,17 @@ async function runPullAll(
894
1140
  return null;
895
1141
  }
896
1142
  },
1143
+ getMembershipSyncConfig: (id: string) =>
1144
+ realClient.getMembershipSyncConfig(id),
897
1145
  };
898
1146
 
899
1147
  result = await pullAll(
900
- { hqRoot, ...(onConflict ? { onConflict } : {}) },
1148
+ {
1149
+ hqRoot,
1150
+ ...(onConflict ? { onConflict } : {}),
1151
+ narrowHintLevel: resolveBannerLevel(),
1152
+ ...(modeAllOverride ? { modeAllOverride: true } : {}),
1153
+ },
901
1154
  {
902
1155
  vaultClient: adapter,
903
1156
  sync: (opts) =>
@@ -1095,6 +1348,7 @@ async function runNowSingle(
1095
1348
  personal: boolean,
1096
1349
  message?: string,
1097
1350
  onConflict?: ConflictStrategy,
1351
+ modeAllOverride?: boolean,
1098
1352
  ): Promise<void> {
1099
1353
  console.log(chalk.bold("\nHQ Sync — Now"));
1100
1354
  console.log(` HQ root: ${hqRoot}`);
@@ -1168,6 +1422,59 @@ async function runNowSingle(
1168
1422
  process.exit(1);
1169
1423
  }
1170
1424
 
1425
+ // US-011: resolve membership sync-config so we can either nudge an
1426
+ // all-mode owner or refuse the pull when strict-mode is on. Skipped
1427
+ // for personal targets (personal vault has no membership row) and
1428
+ // for resolution failures (best-effort — never block sync). The
1429
+ // lookup runs BEFORE the pull leg so strict refusal can short-circuit
1430
+ // without burning a sync.
1431
+ const narrowHintLevel: BannerLevel = resolveBannerLevel();
1432
+ let resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
1433
+ let resolvedCompanyUid: string | undefined;
1434
+ if (!personalMode && targetCompany) {
1435
+ try {
1436
+ const client = new VaultClient(vaultConfig);
1437
+ const memberships = await client.listMyMemberships();
1438
+ const match = memberships.find(
1439
+ (m) => m.companyUid === targetCompany || m.membershipKey === targetCompany,
1440
+ );
1441
+ if (match) {
1442
+ resolvedCompanyUid = match.companyUid;
1443
+ try {
1444
+ const cfg = await client.getMembershipSyncConfig(
1445
+ match.membershipKey,
1446
+ );
1447
+ resolvedMode = cfg.syncMode;
1448
+ } catch {
1449
+ resolvedMode = undefined;
1450
+ }
1451
+ }
1452
+ } catch {
1453
+ resolvedMode = undefined;
1454
+ }
1455
+ }
1456
+
1457
+ if (
1458
+ resolvedMode === "all" &&
1459
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
1460
+ !modeAllOverride &&
1461
+ resolvedCompanyUid
1462
+ ) {
1463
+ emitNarrowHint({
1464
+ companyUid: resolvedCompanyUid,
1465
+ syncMode: resolvedMode,
1466
+ level: narrowHintLevel,
1467
+ });
1468
+ console.error(
1469
+ chalk.red(
1470
+ "\n✗ Sync now refused: strict narrow-hint mode is on and this " +
1471
+ "membership still pulls everything. Run `hq sync narrow --apply` " +
1472
+ "to migrate, or re-run with --mode-all.",
1473
+ ),
1474
+ );
1475
+ process.exit(1);
1476
+ }
1477
+
1171
1478
  console.log(chalk.dim(" → pull leg"));
1172
1479
  const pullResult = await sync({
1173
1480
  company: targetCompany,
@@ -1188,6 +1495,17 @@ async function runNowSingle(
1188
1495
  console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
1189
1496
  process.exit(1);
1190
1497
  }
1498
+
1499
+ // US-011: emit the hint banner after a successful pull so it
1500
+ // appears at the bottom of the summary rather than mid-stream.
1501
+ if (resolvedMode === "all" && resolvedCompanyUid) {
1502
+ emitNarrowHint({
1503
+ companyUid: resolvedCompanyUid,
1504
+ syncMode: resolvedMode,
1505
+ level: narrowHintLevel,
1506
+ });
1507
+ }
1508
+
1191
1509
  console.log(chalk.green("\n✓ Sync now complete"));
1192
1510
  } catch (err) {
1193
1511
  console.error(
@@ -1202,6 +1520,7 @@ async function runNowAll(
1202
1520
  hqRoot: string,
1203
1521
  message?: string,
1204
1522
  onConflict?: ConflictStrategy,
1523
+ modeAllOverride?: boolean,
1205
1524
  ): Promise<void> {
1206
1525
  console.log(chalk.bold("\nHQ Sync — Now (all)"));
1207
1526
  console.log(` HQ root: ${hqRoot}`);
@@ -1213,7 +1532,10 @@ async function runNowAll(
1213
1532
  console.log(chalk.dim("→ push --all"));
1214
1533
  await runPushAll(hqRoot, message, onConflict);
1215
1534
  console.log(chalk.dim("\n→ pull --all"));
1216
- await runPullAll(hqRoot, onConflict);
1535
+ // US-011: forward --mode-all so the strict refusal applies to the
1536
+ // pull leg (push doesn't need a narrow-hint — the narrow ritual is
1537
+ // pull-side).
1538
+ await runPullAll(hqRoot, onConflict, modeAllOverride);
1217
1539
  }
1218
1540
 
1219
1541
  /**