@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 +52 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +102 -5
- package/dist/command-catalog.generated.d.ts +4 -1
- package/dist/command-catalog.generated.js +5 -1
- package/dist/commands/core-checkpoint.d.ts +7 -0
- package/dist/commands/core-checkpoint.js +131 -12
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/doctor.js +59 -7
- package/dist/commands/index-cmd.d.ts +3 -0
- package/dist/commands/index-cmd.js +15 -2
- package/dist/commands/reindex.js +50 -1
- package/dist/lib/bot/runtime/messages-stream.js +6 -0
- package/dist/lib/core-utils/qmd-reindex-after-sync.d.ts +3 -0
- package/dist/lib/core-utils/qmd-reindex-after-sync.js +18 -2
- package/dist/lib/doctor/json-output.d.ts +10 -0
- package/dist/lib/doctor/json-output.js +1 -0
- package/dist/lib/doctor/registry.d.ts +14 -0
- package/dist/lib/doctor/registry.js +22 -0
- package/dist/lib/search-index/background.d.ts +5 -1
- package/dist/lib/search-index/background.js +24 -0
- package/dist/lib/search-index/embed-lock.d.ts +2 -2
- package/dist/lib/search-index/embed-lock.js +1 -2
- package/dist/lib/search-index/load-gate.d.ts +65 -0
- package/dist/lib/search-index/load-gate.js +270 -0
- package/dist/lib/workers-registry/index.js +37 -9
- package/package.json +1 -1
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 "$
|
|
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 "$
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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",
|
|
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 {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 = {
|