@indigoai-us/hq-cli 5.115.3 → 5.115.5

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,58 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.115.5] — 2026-09-15
6
+
7
+ ### Fixed
8
+
9
+ - The setting that turns the end-of-turn checkpoint requirement off,
10
+ `HQ_CHECKPOINT_GATE`, now works from `.claude/settings.json` on every runtime.
11
+ Before, only Claude Code passed that setting through to the checkpoint hook,
12
+ so if you work in Codex or Grok, writing it in the settings file did nothing
13
+ and there was no sign of why. hq now reads the file itself. As with the other
14
+ checkpoint settings, `settings.local.json` overrides it, anything you set in
15
+ your terminal overrides both, and `false` or `0` written as plain JSON works
16
+ as well as `"0"`. If the value is something the setting does not understand,
17
+ hq names it and leaves the requirement on rather than guessing.
18
+
19
+ ## [5.115.4] — 2026-09-15
20
+
21
+ ### Changed
22
+ - Search indexing now leaves qmd embeddings for a later pass when CPU or memory
23
+ use reaches 50%, while still updating the index. Set
24
+ `HQ_INDEX_MAX_LOAD_PERCENT` to another percentage, or to `0` or `off` to
25
+ disable this check. After 12 skipped passes or six hours, hq runs an embed
26
+ despite the load so search results do not fall behind forever.
27
+ - HQ's housekeeping pass (`hq reindex`, which runs after an agent finishes a
28
+ turn) is much faster on large setups. On a root with 21 companies it took
29
+ about two and a half minutes even with nothing to do. Two things were to
30
+ blame. The scan that finds your workers read every folder inside any source
31
+ code you keep under a company, `node_modules` and `.git` included, which on
32
+ that root meant reading 83,950 folders to find 126 worker files. It now skips
33
+ those folders instead of walking them, and takes 0.4 seconds instead of 22.
34
+ Separately, the hook health check ran every `hq doctor` check in order to
35
+ read six of them; it now asks only for the group those six live in.
36
+ - `hq doctor` takes `--only <families>`, so you can run one group of checks
37
+ instead of all of them: `hq doctor --only hooks`. A name it does not
38
+ recognise is refused rather than quietly checking nothing, and `--json`
39
+ records which groups ran, so a partial result cannot be read as a verdict on
40
+ the whole tree.
41
+
42
+ ### Fixed
43
+
44
+ - 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.
45
+
46
+ ### Added
47
+
48
+ - You can now switch off the background helper that tidies HQ after a
49
+ checkpoint, without giving up checkpoints themselves. Add
50
+ `"env": { "HQ_CHECKPOINT_AGENT": "0" }` to `.claude/settings.json` in your HQ
51
+ folder and the helper stops running; your checkpoint notes are still saved.
52
+ Set it back to `"1"` to turn it on again. It works the same whether you use
53
+ Claude Code, Codex or Grok, and `settings.local.json` overrides it if you
54
+ keep one. If the value is something the setting does not understand, hq says
55
+ so rather than quietly leaving the helper on.
56
+
5
57
  ## [5.115.3] — 2026-09-15
6
58
 
7
59
  ### Changed
@@ -5,6 +5,12 @@
5
5
 
6
6
  set -uo pipefail
7
7
 
8
+ # The main block ends with `} 2>/dev/null`: a Stop hook must not spray
9
+ # diagnostics into a session. Keep one copy of the real stderr so a
10
+ # misconfigured operator switch — the one failure the operator alone can fix,
11
+ # and otherwise invisible — still has somewhere to be said.
12
+ exec 3>&2
13
+
8
14
  {
9
15
  self_hq="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." 2>/dev/null && pwd)"
10
16
  HQ="${CLAUDE_PROJECT_DIR:-${HQ_ROOT:-$self_hq}}"
@@ -49,6 +55,88 @@ set -uo pipefail
49
55
  return 0
50
56
  }
51
57
 
58
+ # checkpoint_gate_trim — strips only the edges. Deleting interior whitespace
59
+ # would quietly repair a typo: "f alse" would become a valid `false` and
60
+ # disable the gate the operator was trying to keep on.
61
+ checkpoint_gate_trim() {
62
+ local s="$1"
63
+ s="${s#"${s%%[![:space:]]*}"}"
64
+ s="${s%"${s##*[![:space:]]}"}"
65
+ printf '%s' "$s"
66
+ }
67
+
68
+ # checkpoint_gate_setting — one operator switch, resolved the way hq-cli
69
+ # resolves it: the process environment first, then the HQ root's
70
+ # `.claude/settings.json` `env` block (overlaid by `settings.local.json`, the
71
+ # order Claude Code merges them).
72
+ #
73
+ # Reading the file matters because only Claude Code exports that block into
74
+ # hook processes. Under Codex or grok the variable is never set, so an
75
+ # operator who wrote the switch into settings.json would have had it silently
76
+ # ignored on exactly the runtimes where they cannot see why. Fail-open: any
77
+ # missing file, absent jq, or unreadable value leaves the gate at its default.
78
+ #
79
+ # Prints the raw value and returns 0 when one resolves. Returns 1 when the
80
+ # highest-priority file that mentions the key holds something unreadable — a
81
+ # present key stops the lookup rather than falling through, so a stale `"0"`
82
+ # further down cannot be promoted by a typo in the file above it. Same
83
+ # precedence the TypeScript resolver uses.
84
+ checkpoint_gate_setting() {
85
+ local key="$1" value="" file raw
86
+ eval "value=\${$key:-}"
87
+ value="$(checkpoint_gate_trim "$value")"
88
+ if [ -n "$value" ]; then
89
+ printf '%s' "$value"
90
+ return 0
91
+ fi
92
+ command -v jq >/dev/null 2>&1 || return 0
93
+ for file in "$HQ/.claude/settings.local.json" "$HQ/.claude/settings.json"; do
94
+ [ -r "$file" ] || continue
95
+ # Strings, and JSON's own true/false/0/1 — the literal an operator reaches
96
+ # for first — all resolve; null, an object, or an array does not. The
97
+ # leading marker separates "absent" (no output) from "present but
98
+ # unreadable" (`-`), which a bare value could not encode.
99
+ raw="$(jq -r --arg k "$key" '
100
+ try (
101
+ if (.env | type) == "object" and (.env | has($k))
102
+ then (.env[$k]
103
+ | if type == "string" or type == "boolean" or type == "number"
104
+ then "+" + tostring
105
+ else "-" end)
106
+ else empty end
107
+ ) catch empty' "$file" 2>/dev/null || true)"
108
+ case "$raw" in
109
+ "") continue ;;
110
+ -*) ;;
111
+ +*)
112
+ value="$(checkpoint_gate_trim "${raw#+}")"
113
+ if [ -n "$value" ]; then
114
+ printf '%s' "$value"
115
+ return 0
116
+ fi
117
+ ;;
118
+ esac
119
+ printf 'checkpoint-stop-gate: ignoring %s in %s (not a value it can read; expected a string such as "0" or "1")\n' \
120
+ "$key" "$file" >&3 2>/dev/null
121
+ return 1
122
+ done
123
+ return 0
124
+ }
125
+
126
+ # Normalizes one switch to 0, 1, or empty (= not set). An unreadable value is
127
+ # named on stderr rather than passed over: the operator asked for something.
128
+ checkpoint_gate_switch() {
129
+ local key="$1" raw
130
+ raw="$(checkpoint_gate_setting "$key" 2>/dev/null)" || return 0
131
+ [ -n "$raw" ] || return 0
132
+ case "$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" in
133
+ 0|false|off|no) printf '0' ;;
134
+ 1|true|on|yes) printf '1' ;;
135
+ *) printf 'checkpoint-stop-gate: ignoring %s=%s (expected 0/1, false/true, off/on or no/yes)\n' "$key" "$raw" >&3 2>/dev/null ;;
136
+ esac
137
+ return 0
138
+ }
139
+
52
140
  input="$(cat 2>/dev/null || printf '{}')"
53
141
  input_fields="$(printf '%s' "$input" | jq -r '[.transcript_path // "", .session_id // ""] | @tsv' 2>/dev/null)" || exit 0
54
142
  IFS=$'\t' read -r transcript_path session_id <<<"$input_fields" || exit 0
@@ -57,6 +145,15 @@ set -uo pipefail
57
145
  # recursively demand another checkpoint of itself.
58
146
  [ "${HQ_CHECKPOINT_SIBLING:-}" = "1" ] && exit 0
59
147
 
148
+ # Resolved once: every branch below reads this instead of the raw variable.
149
+ gate_switch="$(checkpoint_gate_switch HQ_CHECKPOINT_GATE)"
150
+ # That was the only diagnostic, so drop the duplicate before anything is
151
+ # spawned. The detached `nohup ... &` commands below redirect fds 1 and 2
152
+ # only: a surviving fd 3 would be inherited by them and hold the hook
153
+ # runner's stderr pipe open, so a runner that reads stderr to EOF would wait
154
+ # on the supposedly detached checkpoint instead of on this shell.
155
+ exec 3>&-
156
+
60
157
  # Company-scope gate ---------------------------------------------------------
61
158
  # An OPT-IN requirement layered onto this Stop hook: for an operator whose
62
159
  # email domain is listed in HQ_CHECKPOINT_SCOPE_GATE_DOMAINS (comma-separated,
@@ -69,7 +166,7 @@ set -uo pipefail
69
166
  # alongside the checkpoint gate. Binding is a single recoverable call, so the
70
167
  # block loops only until the operator declares a scope.
71
168
  gate_domains="${HQ_CHECKPOINT_SCOPE_GATE_DOMAINS:-}"
72
- case "${HQ_CHECKPOINT_GATE:-}" in
169
+ case "$gate_switch" in
73
170
  0) ;;
74
171
  *)
75
172
  gate_session_id="$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || true)"
@@ -120,7 +217,7 @@ set -uo pipefail
120
217
  *) [ -d "$gate_root/companies/$bound_co" ] && gate_bound=1 ;;
121
218
  esac
122
219
  if [ "$gate_bound" = 0 ]; then
123
- company_reason="$(printf 'This session has not declared its scope, and a scope is required before the turn can end. Decide where this work belongs, then bind the session and finish the turn with your user-facing reply as the final text after the command output:\n\n bash %s/core/scripts/hq-session.sh --session-id %s set company_slug <company-slug>\n\nUse a real tenant slug from companies/manifest.yaml — the company this session is actually working in, never an invented one. If this session does no company-scoped work, bind it to the reserved personal scope instead:\n\n bash %s/core/scripts/hq-session.sh --session-id %s set company_slug personal\n\nAn operator can also disable this requirement for the run by exporting HQ_CHECKPOINT_GATE=0.' "$gate_root" "$gate_session_id" "$gate_root" "$gate_session_id")"
220
+ company_reason="$(printf 'This session has not declared its scope, and a scope is required before the turn can end. Decide where this work belongs, then bind the session and finish the turn with your user-facing reply as the final text after the command output:\n\n bash %s/core/scripts/hq-session.sh --session-id %s set company_slug <company-slug>\n\nUse a real tenant slug from companies/manifest.yaml — the company this session is actually working in, never an invented one. If this session does no company-scoped work, bind it to the reserved personal scope instead:\n\n bash %s/core/scripts/hq-session.sh --session-id %s set company_slug personal\n\nAn operator can also disable this requirement for the run by exporting HQ_CHECKPOINT_GATE=0, or for every runtime by putting \"env\": {\"HQ_CHECKPOINT_GATE\": \"0\"} in .claude/settings.json.' "$gate_root" "$gate_session_id" "$gate_root" "$gate_session_id")"
124
221
  printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$company_reason" | hq_json_encode)"
125
222
  exit 0
126
223
  fi
@@ -133,7 +230,7 @@ set -uo pipefail
133
230
 
134
231
  # Operator switches precede runtime and identity eligibility. A forced gate
135
232
  # intentionally bypasses the rollout rules for local trials and tests.
136
- case "${HQ_CHECKPOINT_GATE:-}" in
233
+ case "$gate_switch" in
137
234
  0) exit 0 ;;
138
235
  1) enforce=true ;;
139
236
  *)
@@ -151,7 +248,7 @@ set -uo pipefail
151
248
 
152
249
  refresh_eligibility() {
153
250
  command -v hq >/dev/null 2>&1 || return 0
154
- (nohup env HQ_CHECKPOINT_RUNTIME="$runtime" hq core checkpoint --gate-probe >/dev/null 2>&1 &)
251
+ (nohup env HQ_CHECKPOINT_RUNTIME="$runtime" hq core checkpoint --gate-probe >/dev/null 2>&1 3>&- &)
155
252
  }
156
253
 
157
254
  if [ "$runtime" = "codex" ] && [ "$enforce" = false ]; then
@@ -511,7 +608,7 @@ set -uo pipefail
511
608
  --trigger stop-gate-auto \
512
609
  --transcript "$transcript_path" \
513
610
  --summary "turn ended with its user-facing reply already delivered" \
514
- >/dev/null 2>&1 &)
611
+ >/dev/null 2>&1 3>&- &)
515
612
  rm -f "$block_count_file" 2>/dev/null || true
516
613
  exit 0
517
614
  fi
@@ -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;
@@ -32,6 +32,31 @@ 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
+ /**
53
+ * The Stop gate's own switch. Resolved through the same settings file as the
54
+ * sibling switch so it reaches Codex and grok, whose hosts never export the
55
+ * `.claude/settings.json` `env` block into a hook process.
56
+ */
57
+ const GATE_SETTING = "HQ_CHECKPOINT_GATE";
58
+ const SIBLING_SETTING_OFF = new Set(["0", "false", "off", "no"]);
59
+ const SIBLING_SETTING_ON = new Set(["1", "true", "on", "yes"]);
35
60
  const FALLBACK_BACKEND_ORDER = ["claude", "codex", "grok"];
36
61
  /** A backend that cannot answer `--version` this fast is treated as broken. */
37
62
  const BACKEND_PROBE_TIMEOUT_MS = 10_000;
@@ -426,12 +451,13 @@ function checkpointGateRuntime() {
426
451
  return runtime;
427
452
  return "other";
428
453
  }
429
- function gateEligibility(runtime) {
430
- const forced = process.env.HQ_CHECKPOINT_GATE;
431
- if (forced === "0")
432
- return false;
433
- if (forced === "1")
434
- return true;
454
+ function gateEligibility(runtime, liveRoot) {
455
+ // An operator switch precedes runtime and identity eligibility, and is read
456
+ // from the settings file as well as the environment: under Codex and grok the
457
+ // environment is the one place the operator cannot reach.
458
+ const forced = resolveSwitchSetting(liveRoot, GATE_SETTING);
459
+ if (forced !== undefined)
460
+ return forced;
435
461
  if (runtime === "claude")
436
462
  return true;
437
463
  if (runtime !== "codex")
@@ -453,7 +479,7 @@ function writeGateVerdict(liveRoot) {
453
479
  const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
454
480
  fs.mkdirSync(stateDir, { recursive: true });
455
481
  const runtime = checkpointGateRuntime();
456
- const eligible = gateEligibility(runtime);
482
+ const eligible = gateEligibility(runtime, liveRoot);
457
483
  fs.writeFileSync(path.join(stateDir, `checkpoint-gate-eligible-${runtime}`), eligible ? "1" : "0");
458
484
  printResult(eligible ? "eligible" : "ineligible");
459
485
  }
@@ -578,10 +604,95 @@ export function autoBackendPreference() {
578
604
  return [...FALLBACK_BACKEND_ORDER];
579
605
  return [caller, ...FALLBACK_BACKEND_ORDER.filter((name) => name !== caller)];
580
606
  }
581
- function resolveBackend(requested, liveRoot) {
607
+ function settingsEnvValue(liveRoot, key) {
608
+ // settings.local.json wins, matching Claude Code's own merge order.
609
+ for (const file of ["settings.local.json", "settings.json"]) {
610
+ const settingsPath = path.join(liveRoot, ".claude", file);
611
+ let contents;
612
+ try {
613
+ contents = fs.readFileSync(settingsPath, "utf8");
614
+ }
615
+ catch {
616
+ continue; // absent overlay or no .claude directory at all
617
+ }
618
+ let env;
619
+ try {
620
+ env = JSON.parse(contents)?.env;
621
+ }
622
+ catch {
623
+ printError(`checkpoint: ignoring ${settingsPath} (not valid JSON)`);
624
+ continue;
625
+ }
626
+ if (!env || typeof env !== "object" || !(key in env))
627
+ continue;
628
+ const raw = env[key];
629
+ if (typeof raw === "string") {
630
+ const trimmed = raw.trim();
631
+ // An empty string is not a deferral to the next file: the key is here and
632
+ // says nothing, which is a mistake worth naming.
633
+ return trimmed
634
+ ? { kind: "value", value: trimmed, source: settingsPath }
635
+ : { kind: "invalid", source: settingsPath, raw };
636
+ }
637
+ // Claude Code's env block takes strings, but JSON's own `false`/`0` is what
638
+ // a user reaches for first. Honour it rather than ignoring the intent.
639
+ if (typeof raw === "boolean" || typeof raw === "number") {
640
+ return { kind: "value", value: String(raw), source: settingsPath };
641
+ }
642
+ return { kind: "invalid", source: settingsPath, raw };
643
+ }
644
+ return { kind: "absent" };
645
+ }
646
+ /** Maps one resolved value onto the switch, reporting a vocabulary it cannot read. */
647
+ function parseSwitchSetting(key, raw, source) {
648
+ const value = raw.toLowerCase();
649
+ if (SIBLING_SETTING_OFF.has(value))
650
+ return false;
651
+ if (SIBLING_SETTING_ON.has(value))
652
+ return true;
653
+ printError(`checkpoint: ignoring ${key}=${raw} from ${source} ` +
654
+ "(expected 0/1, false/true, off/on or no/yes)");
655
+ return undefined;
656
+ }
657
+ /**
658
+ * One operator switch: the process environment first, then the HQ root's
659
+ * Claude settings. `undefined` means nobody set it — distinct from a setting
660
+ * that says "off", and distinct from one this cannot read (reported, then
661
+ * treated as unset so a typo never silently flips a default).
662
+ */
663
+ function resolveSwitchSetting(liveRoot, key) {
664
+ const fromEnv = process.env[key]?.trim();
665
+ if (fromEnv)
666
+ return parseSwitchSetting(key, fromEnv, "the environment");
667
+ const lookup = settingsEnvValue(liveRoot, key);
668
+ if (lookup.kind === "absent")
669
+ return undefined;
670
+ if (lookup.kind === "invalid") {
671
+ printError(`checkpoint: ignoring ${key} in ${lookup.source}: ` +
672
+ `${JSON.stringify(lookup.raw) ?? String(lookup.raw)} is not a value it can read ` +
673
+ '(expected a string such as "0" or "1")');
674
+ return undefined;
675
+ }
676
+ return parseSwitchSetting(key, lookup.value, lookup.source);
677
+ }
678
+ /**
679
+ * Whether the maintenance sibling may run at all. The process environment wins
680
+ * over the settings file, so a one-off `HQ_CHECKPOINT_AGENT=0 hq core
681
+ * checkpoint …` still works and a host that exports the settings `env` block
682
+ * (Claude Code does) agrees with a host that does not.
683
+ */
684
+ export function siblingEnabledBySetting(liveRoot) {
685
+ return resolveSwitchSetting(liveRoot, SIBLING_SETTING) ?? true;
686
+ }
687
+ /** Rejects an unknown `--backend` without paying for the probe that resolves one. */
688
+ function assertKnownBackend(requested) {
582
689
  const value = requested ?? "auto";
583
690
  if (!BACKENDS.has(value))
584
691
  usage(`checkpoint: unknown backend: ${value}`);
692
+ return value;
693
+ }
694
+ function resolveBackend(requested, liveRoot) {
695
+ const value = assertKnownBackend(requested);
585
696
  // An explicitly named backend is honoured as given; only `auto` shops around.
586
697
  if (value !== "auto")
587
698
  return value;
@@ -869,7 +980,12 @@ function runCheckpoint(options, command, group) {
869
980
  if (!input.summary?.trim()) {
870
981
  usage("checkpoint: --summary is required unless --idle or --gate-probe is used");
871
982
  }
872
- const backend = resolveBackend(options.backend, liveRoot);
983
+ // Resolved before resolveBackend so an opted-out run pays for no backend
984
+ // probe: `--backend auto` shells out to every installed CLI for a version.
985
+ const siblingOptOut = options.agent === false || !siblingEnabledBySetting(liveRoot);
986
+ // Validated even when nothing will spawn, so `--backend bogus` is still an error.
987
+ const requestedBackend = assertKnownBackend(options.backend);
988
+ const backend = siblingOptOut ? "none" : resolveBackend(requestedBackend, liveRoot);
873
989
  const now = new Date();
874
990
  const threadId = `T-${formatTimestamp(now)}-auto-${summarySlug(input.summary)}`;
875
991
  const threadPath = path.join(liveRoot, "workspace", "threads", `${threadId}.json`);
@@ -890,7 +1006,7 @@ function runCheckpoint(options, command, group) {
890
1006
  worker: input.worker,
891
1007
  },
892
1008
  stamps: stampPaths.map((stampPath) => path.relative(liveRoot, stampPath)),
893
- sibling: options.agent === false ? null : { backend },
1009
+ sibling: siblingOptOut ? null : { backend },
894
1010
  });
895
1011
  return;
896
1012
  }
@@ -926,7 +1042,10 @@ function runCheckpoint(options, command, group) {
926
1042
  // requested backend that is not installed).
927
1043
  try {
928
1044
  if (options.agent !== false) {
929
- if (backend === "none") {
1045
+ if (siblingOptOut) {
1046
+ printResult(`checkpoint: sibling disabled (${SIBLING_SETTING})`);
1047
+ }
1048
+ else if (backend === "none") {
930
1049
  printResult("checkpoint: sibling disabled (backend none)");
931
1050
  }
932
1051
  else {
@@ -966,7 +1085,7 @@ export function registerCoreCheckpointCommand(core) {
966
1085
  .option("--transcript <path>", "session transcript path")
967
1086
  .option("--payload <file|->", "JSON payload file, or - for stdin")
968
1087
  .option("--idle", "touch stamps without a checkpoint")
969
- .option("--no-agent", "do not spawn the maintenance sibling")
1088
+ .option("--no-agent", `do not spawn the maintenance sibling (set it off for good with "env": {"${SIBLING_SETTING}": "0"} in .claude/settings.json)`)
970
1089
  .option("--backend <auto|claude|codex|grok|none>", "sibling backend", "auto")
971
1090
  .option("--gate-probe", "write the local Stop-hook eligibility verdict")
972
1091
  .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 = {