@indigoai-us/hq-cli 5.85.2 → 5.86.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.86.0]
6
+
7
+ ### Added
8
+
9
+ - Added `hq search` and `hq index` commands for keyword, semantic, and hybrid
10
+ search across reconciled QMD collections, with explicit opt-in embedding and
11
+ package-local QMD resolution. Registered unmanaged collections remain
12
+ untouched and are reported as `registered (unmanaged)`. (#306)
13
+
14
+ ## [5.85.3]
15
+
16
+ ### Changed
17
+
18
+ - The shared-mode sync nudge is now size-gated: it appears only once a
19
+ company's local folder crosses ~5 GiB (overridable via
20
+ `HQ_SYNC_NARROW_HINT_MIN_BYTES` or `.hq/config.json`
21
+ `syncNarrowHintMinBytes`), instead of nudging every all-mode member toward
22
+ shared mode. Below the threshold an all-mode membership is left alone — no
23
+ banner, and in strict mode no refusal. `hq sync mode` now also reports `all`
24
+ (the effective default) rather than `shared` when a config fetch fails.
25
+ (#303)
26
+
5
27
  ## [5.85.2]
6
28
 
7
29
  ### 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,
@@ -0,0 +1,16 @@
1
+ import { Command } from 'commander';
2
+ import { type RunQmdOptions, type SearchCollection, type QmdProcessResult } from '../lib/search-index/index.js';
3
+ export type SearchIndexDependencies = {
4
+ reconcileCollections: (hqRoot: string) => unknown;
5
+ deriveCollections: (hqRoot: string) => SearchCollection[];
6
+ listRegisteredCollections: (hqRoot: string, options?: RunQmdOptions) => Set<string>;
7
+ resolveQmdBin: () => string;
8
+ resolveQmdVersion: () => string | undefined;
9
+ runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
10
+ };
11
+ /** Incrementally update qmd, embedding only when an operator explicitly asks. */
12
+ export declare function syncSearchIndex(hqRoot: string, embed: boolean, dependencies?: SearchIndexDependencies): void;
13
+ export declare function collectionStatusLines(expected: SearchCollection[], registered: ReadonlySet<string>): string[];
14
+ export declare function collectionSummary(expected: SearchCollection[], registered: ReadonlySet<string>): string;
15
+ export declare function registerIndexCommand(program: Command, dependencies?: SearchIndexDependencies): void;
16
+ //# sourceMappingURL=index-cmd.d.ts.map
@@ -0,0 +1,76 @@
1
+ import { deriveCollections, listRegisteredCollections, reconcileCollections, resolveQmdBin, resolveQmdVersion, runQmd, } from '../lib/search-index/index.js';
2
+ import { findHqRoot } from '../utils/manifest.js';
3
+ const defaults = {
4
+ reconcileCollections,
5
+ deriveCollections,
6
+ listRegisteredCollections,
7
+ resolveQmdBin,
8
+ resolveQmdVersion,
9
+ runQmd,
10
+ };
11
+ /** Incrementally update qmd, embedding only when an operator explicitly asks. */
12
+ export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
13
+ dependencies.reconcileCollections(hqRoot);
14
+ dependencies.runQmd(['update'], { cwd: hqRoot });
15
+ if (embed)
16
+ dependencies.runQmd(['embed'], { cwd: hqRoot });
17
+ }
18
+ function resolveRoot(hqRoot) {
19
+ return hqRoot ?? findHqRoot();
20
+ }
21
+ export function collectionStatusLines(expected, registered) {
22
+ const expectedNames = new Set(expected.map((collection) => collection.name));
23
+ const managed = expected.map((collection) => `${registered.has(collection.name) ? 'registered' : 'missing'} ${collection.name} ${collection.path}`);
24
+ const unmanaged = [...registered]
25
+ .filter((name) => !expectedNames.has(name))
26
+ .sort((left, right) => left.localeCompare(right))
27
+ .map((name) => `registered (unmanaged) ${name}`);
28
+ return [...managed, ...unmanaged];
29
+ }
30
+ export function collectionSummary(expected, registered) {
31
+ const expectedNames = new Set(expected.map((collection) => collection.name));
32
+ const unmanaged = [...registered].filter((name) => !expectedNames.has(name)).length;
33
+ return `collections: ${registered.size} registered; ${expected.length} expected; ${unmanaged} unmanaged`;
34
+ }
35
+ export function registerIndexCommand(program, dependencies = defaults) {
36
+ const index = program.command('index').description('Manage the local HQ search index');
37
+ index
38
+ .command('sync')
39
+ .description('Reconcile collections and incrementally update the qmd index')
40
+ .option('--embed', 'Also rebuild expensive semantic embeddings')
41
+ .option('--hq-root <path>', 'HQ root to index (defaults to auto-detected root)')
42
+ .action((options) => {
43
+ const hqRoot = resolveRoot(options.hqRoot);
44
+ syncSearchIndex(hqRoot, options.embed === true);
45
+ console.log(`Updated search index for ${hqRoot}${options.embed ? ' (including embeddings)' : ''}.`);
46
+ });
47
+ index
48
+ .command('collections')
49
+ .description('Show expected and registered qmd collections')
50
+ .option('--hq-root <path>', 'HQ root to inspect (defaults to auto-detected root)')
51
+ .action((options) => {
52
+ const hqRoot = resolveRoot(options.hqRoot);
53
+ const registered = dependencies.listRegisteredCollections(hqRoot);
54
+ for (const line of collectionStatusLines(dependencies.deriveCollections(hqRoot), registered))
55
+ console.log(line);
56
+ });
57
+ index
58
+ .command('status')
59
+ .description('Show qmd binary, collection, and index status')
60
+ .option('--hq-root <path>', 'HQ root to inspect (defaults to auto-detected root)')
61
+ .action((options) => {
62
+ const hqRoot = resolveRoot(options.hqRoot);
63
+ const bin = dependencies.resolveQmdBin();
64
+ const registered = dependencies.listRegisteredCollections(hqRoot, { bin, cwd: hqRoot });
65
+ const expected = dependencies.deriveCollections(hqRoot);
66
+ const qmdStatus = dependencies.runQmd(['status'], { bin, cwd: hqRoot });
67
+ const qmdVersion = dependencies.resolveQmdVersion();
68
+ console.log(`qmd: ${bin}${qmdVersion ? ` (version ${qmdVersion})` : ''}`);
69
+ console.log(collectionSummary(expected, registered));
70
+ if (qmdStatus.stdout)
71
+ process.stdout.write(qmdStatus.stdout);
72
+ if (qmdStatus.stderr)
73
+ process.stderr.write(qmdStatus.stderr);
74
+ });
75
+ }
76
+ //# sourceMappingURL=index-cmd.js.map
@@ -0,0 +1,12 @@
1
+ import { Command } from 'commander';
2
+ export type SearchMode = 'keyword' | 'semantic' | 'hybrid';
3
+ export type SearchOptions = {
4
+ mode?: SearchMode;
5
+ collection?: string;
6
+ count?: number;
7
+ json?: boolean;
8
+ };
9
+ export declare function buildSearchArgs(query: string, options?: SearchOptions): string[];
10
+ export declare function buildGetArgs(document: string, options?: Pick<SearchOptions, 'collection'>): string[];
11
+ export declare function registerSearchCommand(program: Command): void;
12
+ //# sourceMappingURL=search.d.ts.map
@@ -0,0 +1,48 @@
1
+ import { runQmd } from '../lib/search-index/index.js';
2
+ export function buildSearchArgs(query, options = {}) {
3
+ const command = { keyword: 'search', semantic: 'vsearch', hybrid: 'query' }[options.mode ?? 'keyword'];
4
+ const args = [command, query];
5
+ if (options.collection)
6
+ args.push('-c', options.collection);
7
+ if (options.count !== undefined)
8
+ args.push('-n', String(options.count));
9
+ if (options.json)
10
+ args.push('--json');
11
+ return args;
12
+ }
13
+ export function buildGetArgs(document, options = {}) {
14
+ const args = ['get', document];
15
+ if (options.collection)
16
+ args.push('-c', options.collection);
17
+ return args;
18
+ }
19
+ function relay(result) {
20
+ if (result.stdout)
21
+ process.stdout.write(result.stdout);
22
+ if (result.stderr)
23
+ process.stderr.write(result.stderr);
24
+ }
25
+ export function registerSearchCommand(program) {
26
+ const search = program.command('search').description('Search the local HQ qmd index');
27
+ search
28
+ .command('get <document>')
29
+ .description('Retrieve a qmd document by path or document id')
30
+ .option('-c, --collection <collection>', 'Restrict retrieval to a collection')
31
+ .action((document, options) => {
32
+ relay(runQmd(buildGetArgs(document, options)));
33
+ });
34
+ search
35
+ .command('<query>')
36
+ .description('Search the local HQ qmd index')
37
+ .option('--mode <mode>', 'Search mode: keyword, semantic, or hybrid', 'keyword')
38
+ .option('-c, --collection <collection>', 'Restrict search to a collection')
39
+ .option('-n, --count <count>', 'Maximum result count', (value) => Number(value))
40
+ .option('--json', 'Request machine-readable qmd output')
41
+ .action((query, options) => {
42
+ if (!['keyword', 'semantic', 'hybrid'].includes(options.mode ?? 'keyword')) {
43
+ throw new Error(`Unknown search mode '${options.mode}'. Expected keyword, semantic, or hybrid.`);
44
+ }
45
+ relay(runQmd(buildSearchArgs(query, options)));
46
+ });
47
+ }
48
+ //# sourceMappingURL=search.js.map
@@ -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() {
@@ -0,0 +1,58 @@
1
+ export type QmdProcessResult = {
2
+ status: number | null;
3
+ stdout: string;
4
+ stderr: string;
5
+ error?: Error;
6
+ };
7
+ export type QmdProcessRunner = (bin: string, args: string[], options: {
8
+ cwd?: string;
9
+ env?: NodeJS.ProcessEnv;
10
+ }) => QmdProcessResult;
11
+ export declare class QmdBinaryMissingError extends Error {
12
+ name: string;
13
+ }
14
+ export declare class QmdExitError extends Error {
15
+ readonly args: string[];
16
+ readonly status: number | null;
17
+ readonly stdout: string;
18
+ readonly stderr: string;
19
+ name: string;
20
+ constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string);
21
+ }
22
+ export declare class QmdCollectionMissingError extends QmdExitError {
23
+ name: string;
24
+ }
25
+ export type ResolveQmdBinOptions = {
26
+ env?: Record<string, string | undefined>;
27
+ isExecutable?: (candidate: string) => boolean;
28
+ packageBin?: () => string | undefined;
29
+ pathBin?: () => string | undefined;
30
+ };
31
+ /** Return the pinned package version when qmd is supplied by this CLI. */
32
+ export declare function resolveQmdVersion(): string | undefined;
33
+ /** Resolve qmd without relying on a globally installed copy. */
34
+ export declare function resolveQmdBin(options?: ResolveQmdBinOptions): string;
35
+ export type RunQmdOptions = {
36
+ bin?: string;
37
+ cwd?: string;
38
+ env?: NodeJS.ProcessEnv;
39
+ runner?: QmdProcessRunner;
40
+ };
41
+ /** Run qmd with captured output and typed failures. */
42
+ export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
43
+ export type SearchCollection = {
44
+ name: string;
45
+ path: string;
46
+ mask: string;
47
+ context: string;
48
+ };
49
+ /** Derive the local qmd collection policy for one HQ tree. */
50
+ export declare function deriveCollections(hqRoot: string): SearchCollection[];
51
+ export type ReconcileCollectionsOptions = {
52
+ bin?: string;
53
+ runner?: QmdProcessRunner;
54
+ };
55
+ /** Register expected collections that qmd does not yet know about. */
56
+ export declare function reconcileCollections(hqRoot: string, options?: ReconcileCollectionsOptions): SearchCollection[];
57
+ export declare function listRegisteredCollections(hqRoot: string, options?: RunQmdOptions): Set<string>;
58
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,197 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import * as path from 'node:path';
5
+ const require = createRequire(import.meta.url);
6
+ export class QmdBinaryMissingError extends Error {
7
+ name = 'QmdBinaryMissingError';
8
+ }
9
+ export class QmdExitError extends Error {
10
+ args;
11
+ status;
12
+ stdout;
13
+ stderr;
14
+ name = 'QmdExitError';
15
+ constructor(message, args, status, stdout, stderr) {
16
+ super(message);
17
+ this.args = args;
18
+ this.status = status;
19
+ this.stdout = stdout;
20
+ this.stderr = stderr;
21
+ }
22
+ }
23
+ export class QmdCollectionMissingError extends QmdExitError {
24
+ name = 'QmdCollectionMissingError';
25
+ }
26
+ function isExecutable(candidate) {
27
+ try {
28
+ fs.accessSync(candidate, fs.constants.X_OK);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function packageLocalBin() {
36
+ try {
37
+ const packageJson = require.resolve('@tobilu/qmd/package.json');
38
+ return path.join(path.dirname(packageJson), 'qmd');
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ /** Return the pinned package version when qmd is supplied by this CLI. */
45
+ export function resolveQmdVersion() {
46
+ try {
47
+ const packageJson = require('@tobilu/qmd/package.json');
48
+ return typeof packageJson.version === 'string' ? packageJson.version : undefined;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ function pathBin() {
55
+ const paths = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
56
+ const names = process.platform === 'win32' ? ['qmd.exe', 'qmd.cmd', 'qmd'] : ['qmd'];
57
+ for (const directory of paths) {
58
+ for (const name of names) {
59
+ const candidate = path.join(directory, name);
60
+ if (isExecutable(candidate))
61
+ return candidate;
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+ /** Resolve qmd without relying on a globally installed copy. */
67
+ export function resolveQmdBin(options = {}) {
68
+ const env = options.env ?? process.env;
69
+ const executable = options.isExecutable ?? isExecutable;
70
+ const probes = [];
71
+ const override = env.HQ_QMD_BIN;
72
+ if (override) {
73
+ if (executable(override))
74
+ return override;
75
+ probes.push(`HQ_QMD_BIN (${override})`);
76
+ }
77
+ else {
78
+ probes.push('HQ_QMD_BIN (not set)');
79
+ }
80
+ const installed = (options.packageBin ?? packageLocalBin)();
81
+ if (installed && executable(installed))
82
+ return installed;
83
+ probes.push(`package-local @tobilu/qmd (${installed ?? 'not found'})`);
84
+ const onPath = (options.pathBin ?? pathBin)();
85
+ if (onPath && executable(onPath))
86
+ return onPath;
87
+ probes.push(`qmd on PATH (${onPath ?? 'not found'})`);
88
+ throw new QmdBinaryMissingError(`Unable to resolve qmd. Probed ${probes.join('; ')}. Install @tobilu/qmd or set HQ_QMD_BIN to an executable qmd binary.`);
89
+ }
90
+ function defaultRunner(bin, args, options) {
91
+ const result = spawnSync(bin, args, { cwd: options.cwd, env: options.env, encoding: 'utf8' });
92
+ return {
93
+ status: result.status,
94
+ stdout: result.stdout ?? '',
95
+ stderr: result.stderr ?? '',
96
+ error: result.error,
97
+ };
98
+ }
99
+ /** Run qmd with captured output and typed failures. */
100
+ export function runQmd(args, options = {}) {
101
+ const bin = options.bin ?? resolveQmdBin({ env: options.env });
102
+ const result = (options.runner ?? defaultRunner)(bin, args, { cwd: options.cwd, env: options.env });
103
+ if (result.error) {
104
+ throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${result.error.message}`);
105
+ }
106
+ if (result.status === 0)
107
+ return result;
108
+ const detail = result.stderr || result.stdout || 'qmd returned no diagnostic output';
109
+ const message = `qmd ${args.join(' ')} exited with ${result.status ?? 'an unknown status'}: ${detail}`;
110
+ if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
111
+ throw new QmdCollectionMissingError(message, args, result.status, result.stdout, result.stderr);
112
+ }
113
+ throw new QmdExitError(message, args, result.status, result.stdout, result.stderr);
114
+ }
115
+ function containsIndexedMarkdown(directory) {
116
+ if (!fs.existsSync(directory))
117
+ return false;
118
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
119
+ const child = path.join(directory, entry.name);
120
+ if (entry.isDirectory() && containsIndexedMarkdown(child))
121
+ return true;
122
+ if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'INDEX.md')
123
+ return true;
124
+ }
125
+ return false;
126
+ }
127
+ function containsProjectSource(directory) {
128
+ if (!fs.existsSync(directory))
129
+ return false;
130
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
131
+ const child = path.join(directory, entry.name);
132
+ if (entry.isDirectory() && containsProjectSource(child))
133
+ return true;
134
+ if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.json')))
135
+ return true;
136
+ }
137
+ return false;
138
+ }
139
+ /** Derive the local qmd collection policy for one HQ tree. */
140
+ export function deriveCollections(hqRoot) {
141
+ const root = path.resolve(hqRoot);
142
+ const companiesDir = path.join(root, 'companies');
143
+ const companies = fs.existsSync(companiesDir)
144
+ ? fs.readdirSync(companiesDir, { withFileTypes: true })
145
+ .filter((entry) => entry.isDirectory())
146
+ .sort((a, b) => a.name.localeCompare(b.name))
147
+ : [];
148
+ const collections = [];
149
+ for (const entry of companies) {
150
+ const knowledge = path.join(companiesDir, entry.name, 'knowledge');
151
+ if (!containsIndexedMarkdown(knowledge))
152
+ continue;
153
+ collections.push({
154
+ name: entry.name,
155
+ path: knowledge,
156
+ mask: '**/*.md',
157
+ context: `Knowledge base for ${entry.name}.`,
158
+ });
159
+ }
160
+ for (const entry of companies) {
161
+ const projects = path.join(companiesDir, entry.name, 'projects');
162
+ if (!containsProjectSource(projects))
163
+ continue;
164
+ collections.push({
165
+ name: `${entry.name}-projects`,
166
+ path: projects,
167
+ mask: '**/*.{md,json}',
168
+ context: `Project PRDs and documentation for ${entry.name}.`,
169
+ });
170
+ }
171
+ const personalKnowledge = path.join(root, 'personal', 'knowledge');
172
+ if (containsIndexedMarkdown(personalKnowledge)) {
173
+ collections.push({
174
+ name: 'personal-knowledge',
175
+ path: personalKnowledge,
176
+ mask: '**/*.md',
177
+ context: 'Personal knowledge base (owner overlay).',
178
+ });
179
+ }
180
+ return collections;
181
+ }
182
+ /** Register expected collections that qmd does not yet know about. */
183
+ export function reconcileCollections(hqRoot, options = {}) {
184
+ const runOptions = { bin: options.bin, runner: options.runner, cwd: hqRoot };
185
+ const registered = listRegisteredCollections(hqRoot, runOptions);
186
+ const missing = deriveCollections(hqRoot).filter((collection) => !registered.has(collection.name));
187
+ for (const collection of missing) {
188
+ runQmd(['collection', 'add', collection.path, '--name', collection.name, '--mask', collection.mask], runOptions);
189
+ runQmd(['context', 'add', `qmd://${collection.name}`, collection.context], runOptions);
190
+ }
191
+ return missing;
192
+ }
193
+ export function listRegisteredCollections(hqRoot, options = {}) {
194
+ const result = runQmd(['collection', 'list'], { ...options, cwd: options.cwd ?? hqRoot });
195
+ return new Set([...result.stdout.matchAll(/qmd:\/\/([^/\s]+)/g)].map((match) => match[1]));
196
+ }
197
+ //# sourceMappingURL=index.js.map
package/dist/main.js CHANGED
@@ -57,6 +57,8 @@ import { registerOutpostsCommand } from "./commands/outposts.js";
57
57
  import { registerBillingCommand } from "./commands/billing.js";
58
58
  import { registerDbCommand } from "./commands/db.js";
59
59
  import { registerCoreCommands } from "./commands/core.js";
60
+ import { registerSearchCommand } from "./commands/search.js";
61
+ import { registerIndexCommand } from "./commands/index-cmd.js";
60
62
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
61
63
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
62
64
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
@@ -228,6 +230,10 @@ registerBillingCommand(program);
228
230
  // the source-root entries are maintainer tools that must never touch a live
229
231
  // install. Registered from a manifest in the module, not wired per script here.
230
232
  registerCoreCommands(program);
233
+ // Local qmd search and index management. Kept distinct from `hq reindex`,
234
+ // which converges scaffold-owned files and hooks rather than search data.
235
+ registerSearchCommand(program);
236
+ registerIndexCommand(program);
231
237
  program.hook("preAction", async () => {
232
238
  await emitCliSessionStarted();
233
239
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.85.2",
3
+ "version": "5.86.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
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
+ "@tobilu/qmd": "1.0.7",
35
36
  "better-sqlite3": "^12.11.1",
36
37
  "chalk": "^5.3.0",
37
38
  "commander": "^12.1.0",