@indigoai-us/hq-cli 5.115.2 → 5.115.4

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,53 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.115.4] — 2026-09-15
6
+
7
+ ### Changed
8
+ - Search indexing now leaves qmd embeddings for a later pass when CPU or memory
9
+ use reaches 50%, while still updating the index. Set
10
+ `HQ_INDEX_MAX_LOAD_PERCENT` to another percentage, or to `0` or `off` to
11
+ disable this check. After 12 skipped passes or six hours, hq runs an embed
12
+ despite the load so search results do not fall behind forever.
13
+ - HQ's housekeeping pass (`hq reindex`, which runs after an agent finishes a
14
+ turn) is much faster on large setups. On a root with 21 companies it took
15
+ about two and a half minutes even with nothing to do. Two things were to
16
+ blame. The scan that finds your workers read every folder inside any source
17
+ code you keep under a company, `node_modules` and `.git` included, which on
18
+ that root meant reading 83,950 folders to find 126 worker files. It now skips
19
+ those folders instead of walking them, and takes 0.4 seconds instead of 22.
20
+ Separately, the hook health check ran every `hq doctor` check in order to
21
+ read six of them; it now asks only for the group those six live in.
22
+ - `hq doctor` takes `--only <families>`, so you can run one group of checks
23
+ instead of all of them: `hq doctor --only hooks`. A name it does not
24
+ recognise is refused rather than quietly checking nothing, and `--json`
25
+ records which groups ran, so a partial result cannot be read as a verdict on
26
+ the whole tree.
27
+
28
+ ### Fixed
29
+
30
+ - When Claude Code's saved login has stopped working, a local bot now says it needs a sign-in and keeps your message for when it works again. Before, the bot relayed Claude Code's own "Failed to authenticate" notice as if it were the model's answer, so HQ never showed the "Sign in again" button.
31
+
32
+ ### Added
33
+
34
+ - You can now switch off the background helper that tidies HQ after a
35
+ checkpoint, without giving up checkpoints themselves. Add
36
+ `"env": { "HQ_CHECKPOINT_AGENT": "0" }` to `.claude/settings.json` in your HQ
37
+ folder and the helper stops running; your checkpoint notes are still saved.
38
+ Set it back to `"1"` to turn it on again. It works the same whether you use
39
+ Claude Code, Codex or Grok, and `settings.local.json` overrides it if you
40
+ keep one. If the value is something the setting does not understand, hq says
41
+ so rather than quietly leaving the helper on.
42
+
43
+ ## [5.115.3] — 2026-09-15
44
+
45
+ ### Changed
46
+
47
+ - The background helper that tidies HQ after a checkpoint now runs on cheaper
48
+ settings: Claude Sonnet 5 instead of Opus 5, Grok 4.6 instead of 4.5, and a
49
+ medium thinking effort on every engine (Codex and Grok were on high). It does
50
+ the same work; it just costs less while doing it.
51
+
5
52
  ## [5.115.2] — 2026-09-15
6
53
 
7
54
  ### Fixed
@@ -4534,7 +4534,7 @@ export declare const COMMAND_CATALOG: readonly [{
4534
4534
  readonly description: "touch stamps without a checkpoint";
4535
4535
  }, {
4536
4536
  readonly flags: "--no-agent";
4537
- readonly description: "do not spawn the maintenance sibling";
4537
+ readonly description: "do not spawn the maintenance sibling (set it off for good with \"env\": {\"HQ_CHECKPOINT_AGENT\": \"0\"} in .claude/settings.json)";
4538
4538
  }, {
4539
4539
  readonly flags: "--backend <auto|claude|codex|grok|none>";
4540
4540
  readonly description: "sibling backend";
@@ -4952,6 +4952,9 @@ export declare const COMMAND_CATALOG: readonly [{
4952
4952
  }, {
4953
4953
  readonly flags: "--company <slug>";
4954
4954
  readonly description: "Company slug for integrations checks (otherwise resolves your single active company).";
4955
+ }, {
4956
+ readonly flags: "--only <families>";
4957
+ readonly description: "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.";
4955
4958
  }, {
4956
4959
  readonly flags: "--fix";
4957
4960
  readonly description: "Apply the allowlisted safe repairs (backs up first; read-only without this flag).";
@@ -5867,7 +5867,7 @@ export const COMMAND_CATALOG = [
5867
5867
  },
5868
5868
  {
5869
5869
  "flags": "--no-agent",
5870
- "description": "do not spawn the maintenance sibling"
5870
+ "description": "do not spawn the maintenance sibling (set it off for good with \"env\": {\"HQ_CHECKPOINT_AGENT\": \"0\"} in .claude/settings.json)"
5871
5871
  },
5872
5872
  {
5873
5873
  "flags": "--backend <auto|claude|codex|grok|none>",
@@ -6392,6 +6392,10 @@ export const COMMAND_CATALOG = [
6392
6392
  "flags": "--company <slug>",
6393
6393
  "description": "Company slug for integrations checks (otherwise resolves your single active company)."
6394
6394
  },
6395
+ {
6396
+ "flags": "--only <families>",
6397
+ "description": "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope."
6398
+ },
6395
6399
  {
6396
6400
  "flags": "--fix",
6397
6401
  "description": "Apply the allowlisted safe repairs (backs up first; read-only without this flag)."
@@ -59,6 +59,13 @@ export declare function writeStamps(liveRoot: string, sessionId: string | undefi
59
59
  * tested directly.
60
60
  */
61
61
  export declare function autoBackendPreference(): SpawnableBackend[];
62
+ /**
63
+ * Whether the maintenance sibling may run at all. The process environment wins
64
+ * over the settings file, so a one-off `HQ_CHECKPOINT_AGENT=0 hq core
65
+ * checkpoint …` still works and a host that exports the settings `env` block
66
+ * (Claude Code does) agrees with a host that does not.
67
+ */
68
+ export declare function siblingEnabledBySetting(liveRoot: string): boolean;
62
69
  export declare function siblingArgs(backend: SpawnableBackend, prompt: string): string[];
63
70
  /** Attach the native checkpoint command to the hidden `hq core` group. */
64
71
  export declare function registerCoreCheckpointCommand(core: Command): void;
@@ -15,13 +15,13 @@ import { peekIdToken } from "../utils/id-token.js";
15
15
  import { toHqStateWriteError } from "../utils/hq-state-write-error.js";
16
16
  const DEFAULT_TRIGGER = "stop-gate";
17
17
  const BACKENDS = new Set(["auto", "claude", "codex", "grok", "none"]);
18
- // Pinned by operator directive 2026-07-31; change defaults here deliberately.
18
+ // Pinned by operator directive 2026-09-15; change defaults here deliberately.
19
19
  const CODEX_SIBLING_MODEL = "gpt-5.6-terra";
20
- const CODEX_SIBLING_REASONING_EFFORT = "high";
21
- const CLAUDE_SIBLING_MODEL = "claude-opus-5";
20
+ const CODEX_SIBLING_REASONING_EFFORT = "medium";
21
+ const CLAUDE_SIBLING_MODEL = "claude-sonnet-5";
22
22
  const CLAUDE_SIBLING_EFFORT = "medium";
23
- const GROK_SIBLING_MODEL = "grok-4.5";
24
- const GROK_SIBLING_EFFORT = "high";
23
+ const GROK_SIBLING_MODEL = "grok-4.6";
24
+ const GROK_SIBLING_EFFORT = "medium";
25
25
  /**
26
26
  * `grok -p` is single-turn by default: without a turn budget it answers with a
27
27
  * plan and exits, having done nothing. The maintenance flow needs many turns.
@@ -32,6 +32,25 @@ const GROK_SIBLING_MAX_TURNS = "100";
32
32
  * unavailable, unresponsive, out of credits (surfaces as a crashed run), or
33
33
  * simply unknown. First healthy candidate wins.
34
34
  */
35
+ /**
36
+ * Durable opt-out for the maintenance sibling, read from the HQ root's
37
+ * `.claude/settings.json` (overlaid by `settings.local.json`, the same order
38
+ * Claude Code merges them):
39
+ *
40
+ * { "env": { "HQ_CHECKPOINT_AGENT": "0" } }
41
+ *
42
+ * `--no-agent` and `--backend none` already turn the sibling off, but only for
43
+ * one invocation — no help when the checkpoint is fired by the Stop gate,
44
+ * where the user has no command line to edit. The sibling is the expensive
45
+ * half of a checkpoint (an unattended agent turn per Stop), so the off switch
46
+ * has to be somewhere a user can set it once. The `env` block is that place:
47
+ * Claude Code already exports it into hook processes, and reading the file
48
+ * directly means the setting also holds for the Codex and grok gates, whose
49
+ * hosts never read it.
50
+ */
51
+ const SIBLING_SETTING = "HQ_CHECKPOINT_AGENT";
52
+ const SIBLING_SETTING_OFF = new Set(["0", "false", "off", "no"]);
53
+ const SIBLING_SETTING_ON = new Set(["1", "true", "on", "yes"]);
35
54
  const FALLBACK_BACKEND_ORDER = ["claude", "codex", "grok"];
36
55
  /** A backend that cannot answer `--version` this fast is treated as broken. */
37
56
  const BACKEND_PROBE_TIMEOUT_MS = 10_000;
@@ -578,10 +597,86 @@ export function autoBackendPreference() {
578
597
  return [...FALLBACK_BACKEND_ORDER];
579
598
  return [caller, ...FALLBACK_BACKEND_ORDER.filter((name) => name !== caller)];
580
599
  }
581
- function resolveBackend(requested, liveRoot) {
600
+ function settingsEnvValue(liveRoot, key) {
601
+ // settings.local.json wins, matching Claude Code's own merge order.
602
+ for (const file of ["settings.local.json", "settings.json"]) {
603
+ const settingsPath = path.join(liveRoot, ".claude", file);
604
+ let contents;
605
+ try {
606
+ contents = fs.readFileSync(settingsPath, "utf8");
607
+ }
608
+ catch {
609
+ continue; // absent overlay or no .claude directory at all
610
+ }
611
+ let env;
612
+ try {
613
+ env = JSON.parse(contents)?.env;
614
+ }
615
+ catch {
616
+ printError(`checkpoint: ignoring ${settingsPath} (not valid JSON)`);
617
+ continue;
618
+ }
619
+ if (!env || typeof env !== "object" || !(key in env))
620
+ continue;
621
+ const raw = env[key];
622
+ if (typeof raw === "string") {
623
+ const trimmed = raw.trim();
624
+ // An empty string is not a deferral to the next file: the key is here and
625
+ // says nothing, which is a mistake worth naming.
626
+ return trimmed
627
+ ? { kind: "value", value: trimmed, source: settingsPath }
628
+ : { kind: "invalid", source: settingsPath, raw };
629
+ }
630
+ // Claude Code's env block takes strings, but JSON's own `false`/`0` is what
631
+ // a user reaches for first. Honour it rather than ignoring the intent.
632
+ if (typeof raw === "boolean" || typeof raw === "number") {
633
+ return { kind: "value", value: String(raw), source: settingsPath };
634
+ }
635
+ return { kind: "invalid", source: settingsPath, raw };
636
+ }
637
+ return { kind: "absent" };
638
+ }
639
+ /** Maps one resolved value onto the switch, reporting a vocabulary it cannot read. */
640
+ function parseSiblingSetting(raw, source) {
641
+ const value = raw.toLowerCase();
642
+ if (SIBLING_SETTING_OFF.has(value))
643
+ return false;
644
+ if (SIBLING_SETTING_ON.has(value))
645
+ return true;
646
+ printError(`checkpoint: ignoring ${SIBLING_SETTING}=${raw} from ${source} ` +
647
+ "(expected 0/1, false/true, off/on or no/yes)");
648
+ return true;
649
+ }
650
+ /**
651
+ * Whether the maintenance sibling may run at all. The process environment wins
652
+ * over the settings file, so a one-off `HQ_CHECKPOINT_AGENT=0 hq core
653
+ * checkpoint …` still works and a host that exports the settings `env` block
654
+ * (Claude Code does) agrees with a host that does not.
655
+ */
656
+ export function siblingEnabledBySetting(liveRoot) {
657
+ const fromEnv = process.env[SIBLING_SETTING]?.trim();
658
+ if (fromEnv)
659
+ return parseSiblingSetting(fromEnv, "the environment");
660
+ const lookup = settingsEnvValue(liveRoot, SIBLING_SETTING);
661
+ if (lookup.kind === "absent")
662
+ return true;
663
+ if (lookup.kind === "invalid") {
664
+ printError(`checkpoint: ignoring ${SIBLING_SETTING} in ${lookup.source}: ` +
665
+ `${JSON.stringify(lookup.raw) ?? String(lookup.raw)} is not a value it can read ` +
666
+ '(expected a string such as "0" or "1")');
667
+ return true;
668
+ }
669
+ return parseSiblingSetting(lookup.value, lookup.source);
670
+ }
671
+ /** Rejects an unknown `--backend` without paying for the probe that resolves one. */
672
+ function assertKnownBackend(requested) {
582
673
  const value = requested ?? "auto";
583
674
  if (!BACKENDS.has(value))
584
675
  usage(`checkpoint: unknown backend: ${value}`);
676
+ return value;
677
+ }
678
+ function resolveBackend(requested, liveRoot) {
679
+ const value = assertKnownBackend(requested);
585
680
  // An explicitly named backend is honoured as given; only `auto` shops around.
586
681
  if (value !== "auto")
587
682
  return value;
@@ -869,7 +964,12 @@ function runCheckpoint(options, command, group) {
869
964
  if (!input.summary?.trim()) {
870
965
  usage("checkpoint: --summary is required unless --idle or --gate-probe is used");
871
966
  }
872
- const backend = resolveBackend(options.backend, liveRoot);
967
+ // Resolved before resolveBackend so an opted-out run pays for no backend
968
+ // probe: `--backend auto` shells out to every installed CLI for a version.
969
+ const siblingOptOut = options.agent === false || !siblingEnabledBySetting(liveRoot);
970
+ // Validated even when nothing will spawn, so `--backend bogus` is still an error.
971
+ const requestedBackend = assertKnownBackend(options.backend);
972
+ const backend = siblingOptOut ? "none" : resolveBackend(requestedBackend, liveRoot);
873
973
  const now = new Date();
874
974
  const threadId = `T-${formatTimestamp(now)}-auto-${summarySlug(input.summary)}`;
875
975
  const threadPath = path.join(liveRoot, "workspace", "threads", `${threadId}.json`);
@@ -890,7 +990,7 @@ function runCheckpoint(options, command, group) {
890
990
  worker: input.worker,
891
991
  },
892
992
  stamps: stampPaths.map((stampPath) => path.relative(liveRoot, stampPath)),
893
- sibling: options.agent === false ? null : { backend },
993
+ sibling: siblingOptOut ? null : { backend },
894
994
  });
895
995
  return;
896
996
  }
@@ -926,7 +1026,10 @@ function runCheckpoint(options, command, group) {
926
1026
  // requested backend that is not installed).
927
1027
  try {
928
1028
  if (options.agent !== false) {
929
- if (backend === "none") {
1029
+ if (siblingOptOut) {
1030
+ printResult(`checkpoint: sibling disabled (${SIBLING_SETTING})`);
1031
+ }
1032
+ else if (backend === "none") {
930
1033
  printResult("checkpoint: sibling disabled (backend none)");
931
1034
  }
932
1035
  else {
@@ -966,7 +1069,7 @@ export function registerCoreCheckpointCommand(core) {
966
1069
  .option("--transcript <path>", "session transcript path")
967
1070
  .option("--payload <file|->", "JSON payload file, or - for stdin")
968
1071
  .option("--idle", "touch stamps without a checkpoint")
969
- .option("--no-agent", "do not spawn the maintenance sibling")
1072
+ .option("--no-agent", `do not spawn the maintenance sibling (set it off for good with "env": {"${SIBLING_SETTING}": "0"} in .claude/settings.json)`)
970
1073
  .option("--backend <auto|claude|codex|grok|none>", "sibling backend", "auto")
971
1074
  .option("--gate-probe", "write the local Stop-hook eligibility verdict")
972
1075
  .option("--hq-root <path>", "HQ installation to operate on")
@@ -89,6 +89,16 @@ export interface RunDoctorOptions {
89
89
  company?: string;
90
90
  /** Internal test seam; production CLI always runs integrations checks. */
91
91
  integrations?: boolean;
92
+ /**
93
+ * Run only these check families (`--only`). Omitted or empty runs them all.
94
+ *
95
+ * This exists for callers that need one family's verdict and nothing else —
96
+ * `core/scripts/check-hq-hooks.sh` reads six hook check ids, and `hq reindex`
97
+ * runs that checker on every pass. Without scoping they pay for every other
98
+ * family too, which on a large tree is dominated by the per-company sync
99
+ * journal scan. An unknown id is an error, never a silent empty run.
100
+ */
101
+ only?: readonly string[];
92
102
  }
93
103
  /** The outcome of a doctor run, returned rather than thrown so it is testable. */
94
104
  export interface RunDoctorResult {
@@ -93,7 +93,25 @@ export async function runDoctor(options = {}) {
93
93
  ` Run hq doctor from inside your HQ root.\n`);
94
94
  return { exitCode: 1, hqRoot: null, families: [] };
95
95
  }
96
- const registry = options.registry ?? createDefaultRegistry();
96
+ const fullRegistry = options.registry ?? createDefaultRegistry();
97
+ const requested = options.only && options.only.length > 0 ? [...options.only] : undefined;
98
+ let registry = fullRegistry;
99
+ let scope;
100
+ if (requested) {
101
+ const selection = fullRegistry.select(requested);
102
+ // The deep families are appended by `--deep-test` rather than registered,
103
+ // so they are addressable only when that flag is present — naming one
104
+ // without it is the same mistake as naming a family that does not exist.
105
+ const appendable = options.deepTest ? [DEEP_FAMILY_ID, PARITY_FAMILY_ID] : [];
106
+ const unknown = selection.unknown.filter((id) => !appendable.includes(id));
107
+ if (unknown.length > 0) {
108
+ writeErr(`hq doctor --only: unknown check ${unknown.length === 1 ? "family" : "families"}: ${unknown.join(", ")}\n` +
109
+ ` Available: ${[...fullRegistry.ids(), ...appendable].join(", ")}\n`);
110
+ return { exitCode: 1, hqRoot, families: [] };
111
+ }
112
+ registry = selection.registry;
113
+ scope = [...registry.ids(), ...appendable.filter((id) => requested.includes(id))];
114
+ }
97
115
  const platform = options.platform ?? UNKNOWN_PLATFORM;
98
116
  // The detected platform and the session id are exposed to every check so the
99
117
  // host-specific runtime probe (US-006) can decide UNKNOWN vs FAIL vs UNTESTED.
@@ -111,7 +129,11 @@ export async function runDoctor(options = {}) {
111
129
  // own family. Run only when asked — appending here, not in the default
112
130
  // registry, is what keeps `hq doctor` from ever spawning a hook without the
113
131
  // flag. Any FAIL/UNKNOWN it produces flows through computeExitCode below.
114
- if (options.deepTest) {
132
+ // The deep families are appended here rather than registered, so the `--only`
133
+ // filter has to be applied to them explicitly — otherwise
134
+ // `--only hooks --deep-test` would still sandbox-fire every hook.
135
+ const inScope = (id) => !requested || requested.includes(id);
136
+ if (options.deepTest && inScope(DEEP_FAMILY_ID)) {
115
137
  const deepResults = await runDeepGuardTests(context);
116
138
  // US-009: side-effecting hooks (autocommit, checkpoint, journal, reindex, …)
117
139
  // cannot be verified by verdict, so they run in throwaway sandboxes and their
@@ -122,10 +144,12 @@ export async function runDoctor(options = {}) {
122
144
  family: { id: DEEP_FAMILY_ID, title: DEEP_FAMILY_TITLE },
123
145
  results: [...deepResults, ...effectResults],
124
146
  });
125
- // Cross-platform parity replay (US-010): replay every pure-guard fixture
126
- // case through the Claude, Codex, and Grok adapters and compare verdicts, so
127
- // platform drift surfaces as a test result. Also gated behind --deep-test,
128
- // and it too runs only in its own sandbox never the live tree.
147
+ }
148
+ // Cross-platform parity replay (US-010): replay every pure-guard fixture
149
+ // case through the Claude, Codex, and Grok adapters and compare verdicts, so
150
+ // platform drift surfaces as a test result. Also gated behind --deep-test,
151
+ // and it too runs only in its own sandbox — never the live tree.
152
+ if (options.deepTest && inScope(PARITY_FAMILY_ID)) {
129
153
  const parityResults = await runParityReplay(context);
130
154
  families.push({
131
155
  family: { id: PARITY_FAMILY_ID, title: PARITY_FAMILY_TITLE },
@@ -133,7 +157,7 @@ export async function runDoctor(options = {}) {
133
157
  });
134
158
  }
135
159
  if (options.json) {
136
- write(renderJson(buildDoctorJson({ hqRoot, families, platform })));
160
+ write(renderJson(buildDoctorJson({ hqRoot, families, platform, scope })));
137
161
  }
138
162
  else {
139
163
  write(renderText({
@@ -160,10 +184,24 @@ export function registerDoctorCommand(program) {
160
184
  .option("--deep-test", "Also fire pure-guard hooks through the real gate under all three profiles (sandboxed).")
161
185
  .option("--live-runtimes", "Also probe each installed AI CLI (claude, codex, grok) with a one-line prompt to verify login and subscription (networked; uses your subscriptions).")
162
186
  .option("--company <slug>", "Company slug for integrations checks (otherwise resolves your single active company).")
187
+ .option("--only <families>", "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.")
163
188
  .option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
164
189
  .option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
165
190
  .option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
166
191
  .action(async (opts) => {
192
+ // `--fix` repairs the whole tree from its own allowlist and has no
193
+ // notion of a check family, so a scope passed alongside it could only
194
+ // be ignored. Ignoring it silently is the dangerous reading: an
195
+ // operator who wrote `--only sync --fix` would believe they had scoped
196
+ // hook configuration out of a write, and `--only typo --fix` would slip
197
+ // past the unknown-family rejection. Refuse the combination instead.
198
+ if (opts.fix === true && parseOnly(opts.only) !== undefined) {
199
+ process.stderr.write(`hq doctor: --only cannot be combined with --fix.\n` +
200
+ ` --fix applies the allowlisted repairs across the whole tree; it has no family scope.\n` +
201
+ ` Inspect with \`hq doctor --only ${parseOnly(opts.only)?.join(",")}\`, then repair with \`hq doctor --fix\`.\n`);
202
+ process.exitCode = 1;
203
+ return;
204
+ }
167
205
  // `--fix` is the only write path. It resolves the tree, applies the
168
206
  // allowlisted repairs behind a backup + confirmation, and returns its own
169
207
  // exit code; the read-only report below never runs in this branch.
@@ -208,6 +246,7 @@ export function registerDoctorCommand(program) {
208
246
  deepTest: opts.deepTest === true,
209
247
  liveRuntimes: opts.liveRuntimes === true,
210
248
  company: opts.company,
249
+ only: parseOnly(opts.only),
211
250
  });
212
251
  // Set the exit code rather than calling process.exit, so the CLI's
213
252
  // normal shutdown (telemetry flush) still runs. Non-zero means either an
@@ -215,6 +254,19 @@ export function registerDoctorCommand(program) {
215
254
  process.exitCode = result.exitCode;
216
255
  });
217
256
  }
257
+ /**
258
+ * Split `--only` into family ids. Blank entries are dropped so a trailing comma
259
+ * or a quoted empty string reads as "no scope" rather than as a family named "".
260
+ */
261
+ function parseOnly(value) {
262
+ if (value === undefined)
263
+ return undefined;
264
+ const ids = value
265
+ .split(",")
266
+ .map((id) => id.trim())
267
+ .filter((id) => id.length > 0);
268
+ return ids.length > 0 ? ids : undefined;
269
+ }
218
270
  /**
219
271
  * Interactive y/N confirmation for `--fix`. Resolves false on a non-TTY stdin
220
272
  * (so a piped run without `--yes` writes nothing) and on anything other than an
@@ -2,6 +2,7 @@ import { Command } from 'commander';
2
2
  import { type RunQmdOptions, type SearchCollection, type QmdProcessResult } from '../lib/search-index/index.js';
3
3
  import { type BackgroundDependencies, type BackgroundResult, type BackgroundStatus } from '../lib/search-index/background.js';
4
4
  import { type EmbedLockDependencies } from '../lib/search-index/embed-lock.js';
5
+ import { type LoadGateDependencies } from '../lib/search-index/load-gate.js';
5
6
  export type SearchIndexDependencies = {
6
7
  reconcileCollections: (hqRoot: string) => unknown;
7
8
  /** Apply the per-document size cap to qmd's config before the update reads anything. */
@@ -16,6 +17,8 @@ export type SearchIndexDependencies = {
16
17
  backgroundStatus?: (dependencies: BackgroundDependencies) => BackgroundStatus;
17
18
  /** Test seams for the lock shared by every hq-cli embed entry point. */
18
19
  embedLockDependencies?: EmbedLockDependencies;
20
+ /** Test seams for the host load gate consulted while the embed lock is held. */
21
+ loadGateDependencies?: LoadGateDependencies;
19
22
  writeStderr?: (text: string) => void;
20
23
  };
21
24
  export type SyncSearchIndexResult = {
@@ -2,6 +2,7 @@ import { Option } from 'commander';
2
2
  import { deriveCollections, listRegisteredCollections, packageLocalBin, reconcileCollections, resolveQmdBin, resolveQmdHome, resolveQmdVersion, runQmd, } from '../lib/search-index/index.js';
3
3
  import { backgroundStatus, defaultBackgroundDependencies, runBackgroundLauncher, runBackgroundWorker, } from '../lib/search-index/background.js';
4
4
  import { defaultEmbedLockDependencies, runWithEmbedLock, } from '../lib/search-index/embed-lock.js';
5
+ import { checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from '../lib/search-index/load-gate.js';
5
6
  import { applyIndexSizeLimit } from '../lib/search-index/max-doc-bytes.js';
6
7
  import { findHqRoot } from '../utils/manifest.js';
7
8
  import { QMD_NATIVE_BINDING_REMEDY, isQmdNativeBindingError, } from '../utils/qmd-native-binding-error.js';
@@ -38,10 +39,22 @@ export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
38
39
  dependencies.runQmd(['update'], { cwd: hqRoot });
39
40
  if (!embed)
40
41
  return { embedded: false };
41
- const runEmbed = () => dependencies.runQmd(['embed'], { cwd: hqRoot });
42
42
  const lockDependencies = dependencies.embedLockDependencies ?? defaultEmbedLockDependencies();
43
- const outcome = runWithEmbedLock(resolveQmdHome(lockDependencies.env), lockDependencies, runEmbed);
44
43
  const writeStderr = dependencies.writeStderr ?? ((text) => process.stderr.write(text));
44
+ const home = resolveQmdHome(lockDependencies.env);
45
+ const loadGateDependencies = dependencies.loadGateDependencies ?? defaultLoadGateDependencies(lockDependencies.env);
46
+ const outcome = runWithEmbedLock(home, lockDependencies, () => {
47
+ const decision = checkEmbedLoad(home, loadGateDependencies);
48
+ if (decision.action === 'skip') {
49
+ writeStderr(decision.notice);
50
+ return 'skipped';
51
+ }
52
+ if (decision.forced)
53
+ writeStderr(decision.notice);
54
+ dependencies.runQmd(['embed'], { cwd: hqRoot });
55
+ recordSuccessfulEmbed(home, loadGateDependencies);
56
+ return undefined;
57
+ });
45
58
  if (outcome === 'busy')
46
59
  writeStderr('hq: qmd embed is already running; skipping this pass.\n');
47
60
  if (outcome === 'unavailable')
@@ -38,6 +38,24 @@ const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh
38
38
  const WORKTREE_STALE_AFTER_MS = 12 * 60 * 60 * 1_000;
39
39
  const WORKTREE_GIT_TIMEOUT_MS = 2_000;
40
40
  const WORKTREE_HOOK_SWEEP_BUDGET_MS = 5_000;
41
+ /**
42
+ * Ceiling for the shipped hook checker. Sized for the SLOWEST legitimate case,
43
+ * not the fast one: an older core whose checker still runs an unscoped
44
+ * `hq doctor`, on a loaded host, takes tens of seconds. Undercutting that would
45
+ * convert a working checker into a permanent timeout. A wedged checker is still
46
+ * bounded, which is the point.
47
+ */
48
+ const HOOK_CHECK_TIMEOUT_MS = 60_000;
49
+ /** Ceiling override, in milliseconds. Present so tests need not wait a minute. */
50
+ function hookCheckTimeoutMs() {
51
+ const raw = process.env.HQ_HOOK_CHECK_TIMEOUT_MS?.trim();
52
+ if (raw) {
53
+ const parsed = Number(raw);
54
+ if (Number.isFinite(parsed) && parsed > 0)
55
+ return parsed;
56
+ }
57
+ return HOOK_CHECK_TIMEOUT_MS;
58
+ }
41
59
  /** Resolve the same root the repair/check commands must operate on. */
42
60
  function resolveHqRoot(repoRoot) {
43
61
  const root = repoRoot ?? findHqRoot();
@@ -335,16 +353,37 @@ function hasCommandHook(value) {
335
353
  * Run the release health checker when it is available. Its diagnostics cover
336
354
  * runtime/configuration issues outside the safe repair scope; the JSON check
337
355
  * below additionally recognizes UserPromptSubmit, which older checkers omit.
356
+ *
357
+ * Bounded, because reindex runs from Stop / PostToolUse lifecycle hooks and the
358
+ * checker shells into `hq doctor`: an unbounded wait here is an unbounded stall
359
+ * in front of the agent.
360
+ *
361
+ * An overrun is reported as a FAILED check, not as an absent one. Those two
362
+ * look identical to `spawnSync` (both surface as an error) but mean opposite
363
+ * things: absent is "this tree ships no checker, nothing more to learn", while
364
+ * an overrun is "the checker exists and we did not hear back". Collapsing the
365
+ * second into the first would let the slowest trees — exactly the ones running
366
+ * an older, unscoped checker — report healthy while the drift only this checker
367
+ * detects goes unseen.
338
368
  */
339
369
  function runShippedHookCheck(hqRoot) {
340
370
  const checker = path.join(hqRoot, HOOK_CHECK_RELATIVE_PATH);
341
371
  if (!fs.existsSync(checker))
342
372
  return undefined;
373
+ const budgetMs = hookCheckTimeoutMs();
343
374
  try {
344
375
  const result = spawnSync('bash', [checker, '--root', hqRoot], {
345
376
  encoding: 'utf8',
346
377
  stdio: 'pipe',
378
+ timeout: budgetMs,
347
379
  });
380
+ if (result.error?.code === 'ETIMEDOUT') {
381
+ return {
382
+ status: 1,
383
+ output: `the shipped ${HOOK_CHECK_RELATIVE_PATH} check did not finish within ` +
384
+ `${Math.round(budgetMs / 1000)}s, so hook health could not be determined`,
385
+ };
386
+ }
348
387
  if (result.error)
349
388
  return undefined;
350
389
  return {
@@ -356,8 +395,17 @@ function runShippedHookCheck(hqRoot) {
356
395
  return undefined;
357
396
  }
358
397
  }
398
+ /**
399
+ * Classify the tree's hook configuration, cheapest signal first.
400
+ *
401
+ * The shipped checker runs LAST and only when everything else looks healthy.
402
+ * That ordering is not a micro-optimisation: its result is consulted in exactly
403
+ * one branch — the final healthy/minor decision — so on a tree that is already
404
+ * extreme or already minor it is a subprocess whose answer is discarded. Since
405
+ * reindex fires on every Stop / PostToolUse hook, that was the single most
406
+ * expensive discarded result in the command.
407
+ */
359
408
  function inspectHookHealth(hqRoot) {
360
- const checkerResult = runShippedHookCheck(hqRoot);
361
409
  const settingsPath = path.join(hqRoot, '.claude', 'settings.json');
362
410
  if (!fs.existsSync(settingsPath)) {
363
411
  return { state: 'extreme', reason: '.claude/settings.json is missing' };
@@ -386,6 +434,7 @@ function inspectHookHealth(hqRoot) {
386
434
  reason: `missing command hook wiring for ${HOOK_EVENTS.filter((event) => !commandEvents.includes(event)).join(', ')}`,
387
435
  };
388
436
  }
437
+ const checkerResult = runShippedHookCheck(hqRoot);
389
438
  if (checkerResult !== undefined && checkerResult.status !== 0) {
390
439
  return {
391
440
  state: 'minor',
@@ -23,6 +23,12 @@ export function messagesStreamEvents(line) {
23
23
  if (ev.parent_tool_use_id !== null && ev.parent_tool_use_id !== undefined)
24
24
  return [];
25
25
  const message = ev.message && typeof ev.message === "object" ? ev.message : null;
26
+ // Claude Code wraps its own notices (a dead login: "Not logged in", "Failed
27
+ // to authenticate…") in an assistant message from model "<synthetic>". That
28
+ // is the tool talking, not the model: never post it as progress, so a
29
+ // sign-in failure is still recognised as one (the result event carries it).
30
+ if (message?.model === "<synthetic>")
31
+ return [];
26
32
  const content = Array.isArray(message?.content) ? message.content : [];
27
33
  const out = [];
28
34
  for (const block of content) {
@@ -1,4 +1,5 @@
1
1
  import { type EmbedLockDependencies } from "../search-index/embed-lock.js";
2
+ import { type LoadGateDependencies } from "../search-index/load-gate.js";
2
3
  import { type UtilityIo } from "./common.js";
3
4
  export type QmdReindexOptions = UtilityIo & {
4
5
  cwd?: string;
@@ -14,6 +15,8 @@ export type QmdReindexOptions = UtilityIo & {
14
15
  sizeLimit?: (hqRoot: string) => unknown;
15
16
  /** Test seams for the lock shared by every hq-cli embed entry point. */
16
17
  embedLockDependencies?: EmbedLockDependencies;
18
+ /** Test seams for the host load gate consulted while the embed lock is held. */
19
+ loadGateDependencies?: LoadGateDependencies;
17
20
  };
18
21
  export declare function qmdReindexAfterSync(args?: string[], options?: QmdReindexOptions): number;
19
22
  //# sourceMappingURL=qmd-reindex-after-sync.d.ts.map
@@ -14,6 +14,7 @@ import * as path from "node:path";
14
14
  import { reconcileCollections, resolveQmdBin, resolveQmdHome, runQmd } from "../search-index/index.js";
15
15
  import { applyIndexSizeLimit } from "../search-index/max-doc-bytes.js";
16
16
  import { defaultEmbedLockDependencies, runWithEmbedLock, } from "../search-index/embed-lock.js";
17
+ import { LoadGateConfigurationError, checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from "../search-index/load-gate.js";
17
18
  import { ioFor, line } from "./common.js";
18
19
  export function qmdReindexAfterSync(args = [], options = {}) {
19
20
  let hqRoot = "";
@@ -66,15 +67,30 @@ export function qmdReindexAfterSync(args = [], options = {}) {
66
67
  }
67
68
  if (embed) {
68
69
  const lockDependencies = options.embedLockDependencies ?? defaultEmbedLockDependencies();
70
+ const home = resolveQmdHome(lockDependencies.env);
71
+ const loadGateDependencies = options.loadGateDependencies ?? defaultLoadGateDependencies(lockDependencies.env);
69
72
  try {
70
- const outcome = runWithEmbedLock(resolveQmdHome(lockDependencies.env), lockDependencies, () => { run(["embed"], { bin, cwd: hqRoot }); });
73
+ const outcome = runWithEmbedLock(home, lockDependencies, () => {
74
+ const decision = checkEmbedLoad(home, loadGateDependencies);
75
+ if (decision.action === "skip") {
76
+ line(stderr, decision.notice.trimEnd());
77
+ return "skipped";
78
+ }
79
+ if (decision.forced)
80
+ line(stderr, decision.notice.trimEnd());
81
+ run(["embed"], { bin, cwd: hqRoot });
82
+ recordSuccessfulEmbed(home, loadGateDependencies);
83
+ return undefined;
84
+ });
71
85
  if (outcome === "busy") {
72
86
  line(stderr, "hq: qmd embed is already running; skipping this pass.");
73
87
  }
74
88
  if (outcome === "unavailable")
75
89
  line(stderr, "hq: cannot access the qmd embed lock; skipping this pass.");
76
90
  }
77
- catch {
91
+ catch (error) {
92
+ if (error instanceof LoadGateConfigurationError)
93
+ line(stderr, `hq: ${error.message}`);
78
94
  /* embeddings are deferred-cost and optional */
79
95
  }
80
96
  }
@@ -71,6 +71,14 @@ export interface DoctorJsonDocument {
71
71
  summary: StatusCounts;
72
72
  /** The exit code this run produced: 0, or 1 when any FAIL/UNKNOWN is present. */
73
73
  exitCode: number;
74
+ /**
75
+ * The check families this run was restricted to (`--only`), in registration
76
+ * order. ABSENT on a full run, which is the only reading a consumer may treat
77
+ * as a verdict on the whole tree: a scoped document reports nothing at all
78
+ * about the families that never ran, and "no FAILs" in it must not be
79
+ * mistaken for a healthy tree.
80
+ */
81
+ scope?: string[];
74
82
  /** The full, flattened result list in family/registration order. */
75
83
  results: DoctorJsonResult[];
76
84
  }
@@ -82,6 +90,8 @@ export interface BuildDoctorJsonInput {
82
90
  families: FamilyRun[];
83
91
  /** Detected platform. Defaults to {@link UNKNOWN_PLATFORM}. */
84
92
  platform?: DoctorPlatform;
93
+ /** Families this run was restricted to. Omitted on a full run. */
94
+ scope?: readonly string[];
85
95
  }
86
96
  /** Assemble the machine-readable document from a completed run. */
87
97
  export declare function buildDoctorJson(input: BuildDoctorJsonInput): DoctorJsonDocument;
@@ -43,6 +43,7 @@ export function buildDoctorJson(input) {
43
43
  hqRoot: input.hqRoot,
44
44
  summary: summarize(input.families),
45
45
  exitCode: computeExitCode(input.families),
46
+ ...(input.scope && input.scope.length > 0 ? { scope: [...input.scope] } : {}),
46
47
  results,
47
48
  };
48
49
  }
@@ -27,6 +27,20 @@ export declare class DoctorRegistry {
27
27
  families(): CheckFamily[];
28
28
  /** Whether a family with this id is registered. */
29
29
  has(id: string): boolean;
30
+ /** The registered family ids, in registration order. */
31
+ ids(): string[];
32
+ /**
33
+ * A registry holding only the named families, still in REGISTRATION order —
34
+ * the caller's argument order is a selection, not a reordering, so a scoped
35
+ * report reads the same way as a full one.
36
+ *
37
+ * Unknown ids are returned rather than ignored: running zero checks and
38
+ * reporting success would describe a tree nothing looked at.
39
+ */
40
+ select(ids: readonly string[]): {
41
+ registry: DoctorRegistry;
42
+ unknown: string[];
43
+ };
30
44
  /** Run every family in order and collect grouped results. */
31
45
  run(context: CheckContext): Promise<FamilyRun[]>;
32
46
  }
@@ -43,6 +43,28 @@ export class DoctorRegistry {
43
43
  has(id) {
44
44
  return this.familiesById.has(id);
45
45
  }
46
+ /** The registered family ids, in registration order. */
47
+ ids() {
48
+ return [...this.familiesById.keys()];
49
+ }
50
+ /**
51
+ * A registry holding only the named families, still in REGISTRATION order —
52
+ * the caller's argument order is a selection, not a reordering, so a scoped
53
+ * report reads the same way as a full one.
54
+ *
55
+ * Unknown ids are returned rather than ignored: running zero checks and
56
+ * reporting success would describe a tree nothing looked at.
57
+ */
58
+ select(ids) {
59
+ const wanted = new Set(ids);
60
+ const unknown = [...wanted].filter((id) => !this.familiesById.has(id));
61
+ const registry = new DoctorRegistry();
62
+ for (const family of this.families()) {
63
+ if (wanted.has(family.id))
64
+ registry.register(family);
65
+ }
66
+ return { registry, unknown };
67
+ }
46
68
  /** Run every family in order and collect grouped results. */
47
69
  async run(context) {
48
70
  const runs = [];
@@ -1,7 +1,8 @@
1
1
  import { type StdioOptions } from 'node:child_process';
2
2
  import { type QmdProcessResult, type RunQmdOptions } from './index.js';
3
+ import { type LoadGateDependencies } from './load-gate.js';
3
4
  export type BackgroundResult = {
4
- state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed' | 'terminated';
5
+ state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'deferred' | 'completed' | 'update-failed' | 'terminated';
5
6
  } | {
6
7
  state: 'launched';
7
8
  pid: number;
@@ -27,6 +28,9 @@ export type BackgroundDependencies = {
27
28
  /** Test seams for signal delivery; production uses the real process. */
28
29
  processEvents?: Pick<NodeJS.Process, 'once'>;
29
30
  exit?: (code: number) => void;
31
+ /** Test seams for the host load gate consulted immediately before qmd embed. */
32
+ loadGateDependencies?: LoadGateDependencies;
33
+ writeStderr?: (text: string) => void;
30
34
  };
31
35
  export type BackgroundStatus = {
32
36
  lock: 'held' | 'stale' | 'free';
@@ -5,6 +5,7 @@ import { Sentry } from '../../sentry.js';
5
5
  import { reconcileCollections as defaultReconcileCollections, resolveQmdBin as defaultResolveQmdBin, runQmd as defaultRunQmd, } from './index.js';
6
6
  import { applyIndexSizeLimit as defaultApplyIndexSizeLimit } from './max-doc-bytes.js';
7
7
  import { acquireEmbedLock as acquireLock, installEmbedLockCleanup, lockPath, lockRoot, lockState, releaseEmbedLock as releaseLock, } from './embed-lock.js';
8
+ import { LoadGateConfigurationError, checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from './load-gate.js';
8
9
  const COMPLETE_NAME = 'qmd-reindex-bg.completed';
9
10
  function errnoInfo(error) {
10
11
  const e = error;
@@ -123,6 +124,7 @@ export function defaultBackgroundDependencies(hqRoot) {
123
124
  applyIndexSizeLimit: defaultApplyIndexSizeLimit,
124
125
  runQmd: defaultRunQmd,
125
126
  spawnWorker: defaultSpawnWorker,
127
+ writeStderr: (text) => process.stderr.write(text),
126
128
  };
127
129
  }
128
130
  /** Match the shell forwarder's hosted-agent markers before looking up qmd. */
@@ -350,8 +352,30 @@ export async function runBackgroundWorker(dependencies) {
350
352
  }
351
353
  if (await signalWindow())
352
354
  return { state: 'terminated' };
355
+ const loadGateDependencies = dependencies.loadGateDependencies ?? defaultLoadGateDependencies(dependencies.env);
356
+ let loadDecision;
357
+ try {
358
+ loadDecision = checkEmbedLoad(home, loadGateDependencies);
359
+ }
360
+ catch (error) {
361
+ if (error instanceof LoadGateConfigurationError) {
362
+ (dependencies.writeStderr ?? ((text) => process.stderr.write(text)))(`hq: ${error.message}\n`);
363
+ cleanup();
364
+ return { state: 'deferred' };
365
+ }
366
+ throw error;
367
+ }
368
+ if (loadDecision.action === 'skip') {
369
+ (dependencies.writeStderr ?? ((text) => process.stderr.write(text)))(loadDecision.notice);
370
+ writeCompletion(home, dependencies);
371
+ cleanup();
372
+ return { state: 'deferred' };
373
+ }
374
+ if (loadDecision.forced)
375
+ (dependencies.writeStderr ?? ((text) => process.stderr.write(text)))(loadDecision.notice);
353
376
  try {
354
377
  appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot })));
378
+ recordSuccessfulEmbed(home, loadGateDependencies);
355
379
  }
356
380
  catch (error) {
357
381
  appendWorkerLog(logPath, errorOutput(error)); // a completed embed attempt still permits the stamp
@@ -10,7 +10,7 @@ export type EmbedLockDependencies = {
10
10
  export type EmbedLockProcessEvents = Pick<NodeJS.Process, 'once'> & {
11
11
  removeListener?: NodeJS.Process['removeListener'];
12
12
  };
13
- export type EmbedLockRunResult = 'ran' | 'busy' | 'unavailable';
13
+ export type EmbedLockRunResult = 'ran' | 'skipped' | 'busy' | 'unavailable';
14
14
  /**
15
15
  * Serializes embeddings started through hq-cli only. A bare `qmd embed` (or
16
16
  * another tool that does not call this module) cannot participate, because qmd
@@ -34,5 +34,5 @@ export declare function installEmbedLockCleanup(cleanup: () => void, processEven
34
34
  * termination. An interrupted owner is safely reclaimed through the lock's
35
35
  * existing staleness and owner-grace protocol.
36
36
  */
37
- export declare function runWithEmbedLock(home: string, dependencies: EmbedLockDependencies, run: () => void): EmbedLockRunResult;
37
+ export declare function runWithEmbedLock(home: string, dependencies: EmbedLockDependencies, run: () => 'skipped' | void): EmbedLockRunResult;
38
38
  //# sourceMappingURL=embed-lock.d.ts.map
@@ -276,8 +276,7 @@ export function runWithEmbedLock(home, dependencies, run) {
276
276
  if (acquired === 'busy')
277
277
  return 'busy';
278
278
  try {
279
- run();
280
- return 'ran';
279
+ return run() === 'skipped' ? 'skipped' : 'ran';
281
280
  }
282
281
  finally {
283
282
  releaseEmbedLock(home, dependencies);
@@ -0,0 +1,65 @@
1
+ import * as os from 'node:os';
2
+ export declare const DEFAULT_MAX_LOAD_PERCENT = 50;
3
+ /**
4
+ * Twelve requests is enough to avoid quietly starving a frequently requested
5
+ * index, while still leaving several opportunities for a busy machine to cool
6
+ * down before we spend CPU on an embed. The thirteenth request is forced.
7
+ */
8
+ export declare const MAX_CONSECUTIVE_DEFERRED_EMBEDS = 12;
9
+ /** Six hours bounds deferral even when indexing requests are infrequent. */
10
+ export declare const MAX_EMBED_DEFERRAL_SECONDS: number;
11
+ export type LoadGateDependencies = {
12
+ env: NodeJS.ProcessEnv;
13
+ now: () => number;
14
+ readFile: (file: string) => string;
15
+ writeFile: (file: string, contents: string) => void;
16
+ rename: (source: string, destination: string) => void;
17
+ mkdir: (directory: string) => void;
18
+ /** A synchronous seam so the existing synchronous qmd entry points stay synchronous. */
19
+ sleep: (milliseconds: number) => void;
20
+ cpus: () => os.CpuInfo[];
21
+ /** Only used outside Linux, where /proc/meminfo is unavailable. */
22
+ freemem: () => number;
23
+ totalmem: () => number;
24
+ };
25
+ type CpuCounters = {
26
+ total: number;
27
+ idle: number;
28
+ };
29
+ export type EmbedLoadDecision = {
30
+ action: 'embed';
31
+ forced: false;
32
+ } | {
33
+ action: 'embed';
34
+ forced: true;
35
+ notice: string;
36
+ } | {
37
+ action: 'skip';
38
+ signal: 'cpu' | 'memory';
39
+ percent: number;
40
+ limit: number;
41
+ notice: string;
42
+ };
43
+ export declare class LoadGateConfigurationError extends Error {
44
+ constructor(value: string);
45
+ }
46
+ export declare function defaultLoadGateDependencies(env?: NodeJS.ProcessEnv): LoadGateDependencies;
47
+ export declare function parseProcStatAggregate(contents: string): CpuCounters | undefined;
48
+ export declare function cpuBusyPercent(before: CpuCounters, after: CpuCounters): number | undefined;
49
+ /** Sample aggregate CPU busy time over 200 ms, falling back only without /proc/stat. */
50
+ export declare function measureCpuPercent(dependencies: LoadGateDependencies): number;
51
+ export declare function memAvailableFraction(contents: string): number | undefined;
52
+ /** Prefer Linux MemAvailable, because MemFree alone treats reclaimable cache as used memory. */
53
+ export declare function measureMemoryPercent(dependencies: LoadGateDependencies): number;
54
+ export declare function parseLoadThreshold(env: NodeJS.ProcessEnv): number | undefined;
55
+ export declare function loadGateStatePath(home: string): string;
56
+ /**
57
+ * Decide immediately before qmd embed whether this host can absorb it. The
58
+ * caller must already own the shared embed lock, so state updates are atomic
59
+ * with respect to hq-cli's other embed entry points.
60
+ */
61
+ export declare function checkEmbedLoad(home: string, dependencies: LoadGateDependencies): EmbedLoadDecision;
62
+ /** Reset the starvation counter only after qmd embed has actually returned successfully. */
63
+ export declare function recordSuccessfulEmbed(home: string, dependencies: LoadGateDependencies): void;
64
+ export {};
65
+ //# sourceMappingURL=load-gate.d.ts.map
@@ -0,0 +1,270 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { lockRoot } from './embed-lock.js';
5
+ export const DEFAULT_MAX_LOAD_PERCENT = 50;
6
+ /**
7
+ * Twelve requests is enough to avoid quietly starving a frequently requested
8
+ * index, while still leaving several opportunities for a busy machine to cool
9
+ * down before we spend CPU on an embed. The thirteenth request is forced.
10
+ */
11
+ export const MAX_CONSECUTIVE_DEFERRED_EMBEDS = 12;
12
+ /** Six hours bounds deferral even when indexing requests are infrequent. */
13
+ export const MAX_EMBED_DEFERRAL_SECONDS = 6 * 60 * 60;
14
+ const LOAD_GATE_STATE_NAME = 'qmd-embed-load-state';
15
+ const PROC_STAT = '/proc/stat';
16
+ const PROC_MEMINFO = '/proc/meminfo';
17
+ export class LoadGateConfigurationError extends Error {
18
+ constructor(value) {
19
+ super(`HQ_INDEX_MAX_LOAD_PERCENT must be an integer from 1 to 100, 0, or off; received ${JSON.stringify(value)}.`);
20
+ this.name = 'LoadGateConfigurationError';
21
+ }
22
+ }
23
+ function synchronousSleep(milliseconds) {
24
+ const slot = new Int32Array(new SharedArrayBuffer(4));
25
+ Atomics.wait(slot, 0, 0, milliseconds);
26
+ }
27
+ export function defaultLoadGateDependencies(env = process.env) {
28
+ return {
29
+ env,
30
+ now: () => Math.floor(Date.now() / 1_000),
31
+ readFile: (file) => fs.readFileSync(file, 'utf8'),
32
+ writeFile: (file, contents) => fs.writeFileSync(file, contents),
33
+ rename: (source, destination) => fs.renameSync(source, destination),
34
+ mkdir: (directory) => fs.mkdirSync(directory, { recursive: true }),
35
+ sleep: synchronousSleep,
36
+ cpus: () => os.cpus(),
37
+ // os.freemem() reports MemFree on Linux, which excludes reclaimable cache.
38
+ // Use it only where /proc/meminfo does not exist.
39
+ freemem: () => os.freemem(),
40
+ totalmem: () => os.totalmem(),
41
+ };
42
+ }
43
+ function errorCode(error) {
44
+ return error?.code;
45
+ }
46
+ function missing(error) {
47
+ return errorCode(error) === 'ENOENT';
48
+ }
49
+ export function parseProcStatAggregate(contents) {
50
+ const line = contents.split('\n').find((candidate) => /^cpu\s+/.test(candidate));
51
+ if (!line)
52
+ return undefined;
53
+ const values = line.trim().split(/\s+/).slice(1).map(Number);
54
+ if (values.length < 5 || values.some((value) => !Number.isFinite(value) || value < 0))
55
+ return undefined;
56
+ // Linux reports guest and guest_nice inside user and nice respectively.
57
+ const total = values.slice(0, 8).reduce((sum, value) => sum + value, 0);
58
+ const idle = values[3] + values[4];
59
+ return total > 0 && Number.isFinite(idle) ? { total, idle } : undefined;
60
+ }
61
+ export function cpuBusyPercent(before, after) {
62
+ const totalDelta = after.total - before.total;
63
+ const idleDelta = after.idle - before.idle;
64
+ if (totalDelta <= 0 || idleDelta < 0)
65
+ return undefined;
66
+ return Math.max(0, Math.min(100, (1 - idleDelta / totalDelta) * 100));
67
+ }
68
+ function cpuCountersFromCpus(cpus) {
69
+ let total = 0;
70
+ let idle = 0;
71
+ if (cpus.length === 0)
72
+ return undefined;
73
+ for (const cpu of cpus) {
74
+ const { user, nice, sys, idle: cpuIdle, irq } = cpu.times;
75
+ const values = [user, nice, sys, cpuIdle, irq];
76
+ if (values.some((value) => !Number.isFinite(value) || value < 0))
77
+ return undefined;
78
+ total += values.reduce((sum, value) => sum + value, 0);
79
+ idle += cpuIdle;
80
+ }
81
+ return total > 0 ? { total, idle } : undefined;
82
+ }
83
+ function fallbackCpuPercent(dependencies) {
84
+ const before = cpuCountersFromCpus(dependencies.cpus());
85
+ dependencies.sleep(200);
86
+ const after = cpuCountersFromCpus(dependencies.cpus());
87
+ const busy = before && after ? cpuBusyPercent(before, after) : undefined;
88
+ if (busy === undefined)
89
+ throw new Error('Cannot determine CPU busy time from os.cpus().');
90
+ return busy;
91
+ }
92
+ /** Sample aggregate CPU busy time over 200 ms, falling back only without /proc/stat. */
93
+ export function measureCpuPercent(dependencies) {
94
+ let first;
95
+ try {
96
+ first = dependencies.readFile(PROC_STAT);
97
+ }
98
+ catch (error) {
99
+ if (missing(error))
100
+ return fallbackCpuPercent(dependencies);
101
+ throw error;
102
+ }
103
+ dependencies.sleep(200);
104
+ let second;
105
+ try {
106
+ second = dependencies.readFile(PROC_STAT);
107
+ }
108
+ catch (error) {
109
+ if (missing(error))
110
+ return fallbackCpuPercent(dependencies);
111
+ throw error;
112
+ }
113
+ const before = parseProcStatAggregate(first);
114
+ const after = parseProcStatAggregate(second);
115
+ const busy = before && after ? cpuBusyPercent(before, after) : undefined;
116
+ if (busy === undefined)
117
+ throw new Error('Cannot determine CPU busy time from /proc/stat.');
118
+ return busy;
119
+ }
120
+ export function memAvailableFraction(contents) {
121
+ const fields = new Map();
122
+ for (const line of contents.split('\n')) {
123
+ const match = /^(MemTotal|MemAvailable):\s+(\d+)\s+kB$/.exec(line.trim());
124
+ if (match)
125
+ fields.set(match[1], Number(match[2]));
126
+ }
127
+ const total = fields.get('MemTotal');
128
+ const available = fields.get('MemAvailable');
129
+ if (total === undefined || available === undefined || total <= 0 || available < 0 || available > total)
130
+ return undefined;
131
+ return 1 - available / total;
132
+ }
133
+ /** Prefer Linux MemAvailable, because MemFree alone treats reclaimable cache as used memory. */
134
+ export function measureMemoryPercent(dependencies) {
135
+ try {
136
+ const fraction = memAvailableFraction(dependencies.readFile(PROC_MEMINFO));
137
+ if (fraction === undefined)
138
+ throw new Error('Cannot determine memory use from /proc/meminfo.');
139
+ return fraction * 100;
140
+ }
141
+ catch (error) {
142
+ if (!missing(error))
143
+ throw error;
144
+ }
145
+ const total = dependencies.totalmem();
146
+ const free = dependencies.freemem();
147
+ if (total <= 0 || free < 0 || free > total)
148
+ throw new Error('Cannot determine memory use.');
149
+ return (1 - free / total) * 100;
150
+ }
151
+ export function parseLoadThreshold(env) {
152
+ const value = env.HQ_INDEX_MAX_LOAD_PERCENT?.trim() || String(DEFAULT_MAX_LOAD_PERCENT);
153
+ if (value === '0' || value.toLowerCase() === 'off')
154
+ return undefined;
155
+ if (!/^[1-9]\d{0,2}$/.test(value) || Number(value) > 100)
156
+ throw new LoadGateConfigurationError(value);
157
+ return Number(value);
158
+ }
159
+ export function loadGateStatePath(home) {
160
+ return path.join(lockRoot(home), LOAD_GATE_STATE_NAME);
161
+ }
162
+ function readFields(file, dependencies) {
163
+ try {
164
+ const fields = {};
165
+ for (const line of dependencies.readFile(file).split('\n')) {
166
+ if (line === '')
167
+ continue;
168
+ const index = line.indexOf('=');
169
+ const key = line.slice(0, index);
170
+ if (index < 1 || !['skips', 'firstDeferredAt', 'lastSuccessfulEmbedAt'].includes(key) || fields[key] !== undefined)
171
+ return undefined;
172
+ fields[key] = line.slice(index + 1);
173
+ }
174
+ return fields;
175
+ }
176
+ catch (error) {
177
+ if (missing(error))
178
+ return undefined;
179
+ throw error;
180
+ }
181
+ }
182
+ function nonnegativeInteger(value) {
183
+ return value !== undefined && /^\d+$/.test(value) ? Number(value) : undefined;
184
+ }
185
+ function readState(home, dependencies) {
186
+ const fields = readFields(loadGateStatePath(home), dependencies);
187
+ if (!fields)
188
+ return { skips: 0 };
189
+ const skips = nonnegativeInteger(fields.skips);
190
+ const firstDeferredAt = nonnegativeInteger(fields.firstDeferredAt);
191
+ const lastSuccessfulEmbedAt = nonnegativeInteger(fields.lastSuccessfulEmbedAt);
192
+ if (skips === undefined
193
+ || (fields.firstDeferredAt !== undefined && firstDeferredAt === undefined)
194
+ || (fields.lastSuccessfulEmbedAt !== undefined && lastSuccessfulEmbedAt === undefined)
195
+ || (skips > 0 && firstDeferredAt === undefined)
196
+ || (skips === 0 && firstDeferredAt !== undefined))
197
+ return { skips: 0 };
198
+ return {
199
+ skips,
200
+ ...(firstDeferredAt === undefined ? {} : { firstDeferredAt }),
201
+ ...(lastSuccessfulEmbedAt === undefined ? {} : { lastSuccessfulEmbedAt }),
202
+ };
203
+ }
204
+ function writeState(home, state, dependencies) {
205
+ dependencies.mkdir(lockRoot(home));
206
+ const statePath = loadGateStatePath(home);
207
+ const temporaryPath = `${statePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
208
+ dependencies.writeFile(temporaryPath, [
209
+ `skips=${state.skips}`,
210
+ ...(state.firstDeferredAt === undefined ? [] : [`firstDeferredAt=${state.firstDeferredAt}`]),
211
+ ...(state.lastSuccessfulEmbedAt === undefined ? [] : [`lastSuccessfulEmbedAt=${state.lastSuccessfulEmbedAt}`]),
212
+ '',
213
+ ].join('\n'));
214
+ dependencies.rename(temporaryPath, statePath);
215
+ }
216
+ function rounded(percent) {
217
+ return Math.round(percent);
218
+ }
219
+ function trippedSignal(cpuPercent, memoryPercent, limit) {
220
+ if (cpuPercent >= limit)
221
+ return { signal: 'cpu', percent: rounded(cpuPercent) };
222
+ if (memoryPercent >= limit)
223
+ return { signal: 'memory', percent: rounded(memoryPercent) };
224
+ return undefined;
225
+ }
226
+ /**
227
+ * Decide immediately before qmd embed whether this host can absorb it. The
228
+ * caller must already own the shared embed lock, so state updates are atomic
229
+ * with respect to hq-cli's other embed entry points.
230
+ */
231
+ export function checkEmbedLoad(home, dependencies) {
232
+ const limit = parseLoadThreshold(dependencies.env);
233
+ if (limit === undefined)
234
+ return { action: 'embed', forced: false };
235
+ const cpuPercent = measureCpuPercent(dependencies);
236
+ const memoryPercent = measureMemoryPercent(dependencies);
237
+ const tripped = trippedSignal(cpuPercent, memoryPercent, limit);
238
+ if (!tripped)
239
+ return { action: 'embed', forced: false };
240
+ const now = dependencies.now();
241
+ const state = readState(home, dependencies);
242
+ const skips = state.skips + 1;
243
+ const deferredSince = state.firstDeferredAt ?? now;
244
+ const exceededSkipBound = skips > MAX_CONSECUTIVE_DEFERRED_EMBEDS;
245
+ const exceededTimeBound = now - deferredSince >= MAX_EMBED_DEFERRAL_SECONDS;
246
+ if (exceededSkipBound || exceededTimeBound) {
247
+ return {
248
+ action: 'embed',
249
+ forced: true,
250
+ notice: `hq: proceeding with qmd embed despite ${tripped.signal} ${tripped.percent}% (limit ${limit}%) because embeddings have been deferred too long.\n`,
251
+ };
252
+ }
253
+ writeState(home, {
254
+ skips,
255
+ firstDeferredAt: state.firstDeferredAt ?? now,
256
+ ...(state.lastSuccessfulEmbedAt === undefined ? {} : { lastSuccessfulEmbedAt: state.lastSuccessfulEmbedAt }),
257
+ }, dependencies);
258
+ return {
259
+ action: 'skip',
260
+ signal: tripped.signal,
261
+ percent: tripped.percent,
262
+ limit,
263
+ notice: `hq: skipping qmd embed — ${tripped.signal} ${tripped.percent}% (limit ${limit}%).\n`,
264
+ };
265
+ }
266
+ /** Reset the starvation counter only after qmd embed has actually returned successfully. */
267
+ export function recordSuccessfulEmbed(home, dependencies) {
268
+ writeState(home, { skips: 0, lastSuccessfulEmbedAt: dependencies.now() }, dependencies);
269
+ }
270
+ //# sourceMappingURL=load-gate.js.map
@@ -18,10 +18,26 @@ function isFile(target) {
18
18
  return false;
19
19
  }
20
20
  }
21
- /** A sorted `find -L … -name worker.yaml -type f` equivalent, including symlinked pack workers. */
21
+ /**
22
+ * A sorted `find -L … -name worker.yaml -type f` equivalent, including
23
+ * symlinked pack workers.
24
+ *
25
+ * Excluded subtrees are pruned at the descent, not filtered from the result.
26
+ * That distinction is the whole cost of this scan: a tenant's `repos/` holds
27
+ * source checkouts, so filtering afterwards means reading every `node_modules`
28
+ * and `.git` directory inside them — tens of thousands of directories on a real
29
+ * root — to produce matches that are discarded a moment later.
30
+ *
31
+ * Directory entries carry their own type, so the common case costs one
32
+ * `readdir` per directory instead of an extra `stat` per entry. Symlinks are
33
+ * the exception: their dirent type describes the link, so resolving one still
34
+ * needs a `stat`, which is what keeps this faithful to `find -L`.
35
+ */
22
36
  function workerYamlFiles(root, relativeRoot) {
23
37
  const output = [];
24
38
  const walk = (relative, ancestors) => {
39
+ if (isExcludedWorkerDirectory(relative))
40
+ return;
25
41
  const absolute = path.join(root, relative);
26
42
  if (!isDirectory(absolute))
27
43
  return;
@@ -35,20 +51,23 @@ function workerYamlFiles(root, relativeRoot) {
35
51
  if (ancestors.has(physical))
36
52
  return; // `find -L` reports loops; a bounded walk is safer here.
37
53
  const nextAncestors = new Set(ancestors).add(physical);
38
- let names;
54
+ let entries;
39
55
  try {
40
- names = fs.readdirSync(absolute).sort();
56
+ entries = fs.readdirSync(absolute, { withFileTypes: true });
41
57
  }
42
58
  catch {
43
59
  return;
44
60
  }
45
- for (const name of names) {
46
- const childRelative = path.join(relative, name);
61
+ entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
62
+ for (const entry of entries) {
63
+ const childRelative = path.join(relative, entry.name);
47
64
  const child = path.join(root, childRelative);
48
- if (name === 'worker.yaml' && isFile(child))
65
+ if (entry.name === 'worker.yaml' && (entry.isFile() || isFile(child))) {
49
66
  output.push(childRelative);
50
- else if (isDirectory(child))
67
+ }
68
+ else if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(child))) {
51
69
  walk(childRelative, nextAncestors);
70
+ }
52
71
  }
53
72
  };
54
73
  walk(relativeRoot, new Set());
@@ -139,8 +158,8 @@ function withoutTimestamp(content) {
139
158
  * both `core` and `personal` — the checkout wins. The operator's actual worker
140
159
  * then silently vanishes from the registry every skill reads.
141
160
  */
142
- function isExcludedWorkerPath(relativeFile) {
143
- const normalized = relativeFile.replaceAll('\\', '/');
161
+ function isExcludedWorkerDirectory(relativeDirectory) {
162
+ const normalized = relativeDirectory.replaceAll('\\', '/');
144
163
  const [root, tenant, nested] = normalized.split('/');
145
164
  if (root !== 'companies')
146
165
  return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
@@ -150,6 +169,15 @@ function isExcludedWorkerPath(relativeFile) {
150
169
  return true;
151
170
  return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
152
171
  }
172
+ /**
173
+ * Every exclusion is a property of the directory holding the `worker.yaml`, so
174
+ * the file rule is the directory rule applied to its parent. Deriving it keeps
175
+ * the pruned descent and this final filter from ever disagreeing — a drift that
176
+ * would either resurrect a shadowing copy or silently drop a real worker.
177
+ */
178
+ function isExcludedWorkerPath(relativeFile) {
179
+ return isExcludedWorkerDirectory(path.dirname(relativeFile));
180
+ }
153
181
  /**
154
182
  * Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
155
183
  * are quarantined but do not prevent all valid workers from being registered.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.115.2",
3
+ "version": "5.115.4",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {