@indigoai-us/hq-cli 5.115.4 → 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,20 @@
|
|
|
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
|
+
|
|
5
19
|
## [5.115.4] — 2026-09-15
|
|
6
20
|
|
|
7
21
|
### 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
|
|
@@ -49,6 +49,12 @@ const GROK_SIBLING_MAX_TURNS = "100";
|
|
|
49
49
|
* hosts never read it.
|
|
50
50
|
*/
|
|
51
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";
|
|
52
58
|
const SIBLING_SETTING_OFF = new Set(["0", "false", "off", "no"]);
|
|
53
59
|
const SIBLING_SETTING_ON = new Set(["1", "true", "on", "yes"]);
|
|
54
60
|
const FALLBACK_BACKEND_ORDER = ["claude", "codex", "grok"];
|
|
@@ -445,12 +451,13 @@ function checkpointGateRuntime() {
|
|
|
445
451
|
return runtime;
|
|
446
452
|
return "other";
|
|
447
453
|
}
|
|
448
|
-
function gateEligibility(runtime) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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;
|
|
454
461
|
if (runtime === "claude")
|
|
455
462
|
return true;
|
|
456
463
|
if (runtime !== "codex")
|
|
@@ -472,7 +479,7 @@ function writeGateVerdict(liveRoot) {
|
|
|
472
479
|
const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
|
|
473
480
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
474
481
|
const runtime = checkpointGateRuntime();
|
|
475
|
-
const eligible = gateEligibility(runtime);
|
|
482
|
+
const eligible = gateEligibility(runtime, liveRoot);
|
|
476
483
|
fs.writeFileSync(path.join(stateDir, `checkpoint-gate-eligible-${runtime}`), eligible ? "1" : "0");
|
|
477
484
|
printResult(eligible ? "eligible" : "ineligible");
|
|
478
485
|
}
|
|
@@ -637,36 +644,45 @@ function settingsEnvValue(liveRoot, key) {
|
|
|
637
644
|
return { kind: "absent" };
|
|
638
645
|
}
|
|
639
646
|
/** Maps one resolved value onto the switch, reporting a vocabulary it cannot read. */
|
|
640
|
-
function
|
|
647
|
+
function parseSwitchSetting(key, raw, source) {
|
|
641
648
|
const value = raw.toLowerCase();
|
|
642
649
|
if (SIBLING_SETTING_OFF.has(value))
|
|
643
650
|
return false;
|
|
644
651
|
if (SIBLING_SETTING_ON.has(value))
|
|
645
652
|
return true;
|
|
646
|
-
printError(`checkpoint: ignoring ${
|
|
653
|
+
printError(`checkpoint: ignoring ${key}=${raw} from ${source} ` +
|
|
647
654
|
"(expected 0/1, false/true, off/on or no/yes)");
|
|
648
|
-
return
|
|
655
|
+
return undefined;
|
|
649
656
|
}
|
|
650
657
|
/**
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
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).
|
|
655
662
|
*/
|
|
656
|
-
|
|
657
|
-
const fromEnv = process.env[
|
|
663
|
+
function resolveSwitchSetting(liveRoot, key) {
|
|
664
|
+
const fromEnv = process.env[key]?.trim();
|
|
658
665
|
if (fromEnv)
|
|
659
|
-
return
|
|
660
|
-
const lookup = settingsEnvValue(liveRoot,
|
|
666
|
+
return parseSwitchSetting(key, fromEnv, "the environment");
|
|
667
|
+
const lookup = settingsEnvValue(liveRoot, key);
|
|
661
668
|
if (lookup.kind === "absent")
|
|
662
|
-
return
|
|
669
|
+
return undefined;
|
|
663
670
|
if (lookup.kind === "invalid") {
|
|
664
|
-
printError(`checkpoint: ignoring ${
|
|
671
|
+
printError(`checkpoint: ignoring ${key} in ${lookup.source}: ` +
|
|
665
672
|
`${JSON.stringify(lookup.raw) ?? String(lookup.raw)} is not a value it can read ` +
|
|
666
673
|
'(expected a string such as "0" or "1")');
|
|
667
|
-
return
|
|
674
|
+
return undefined;
|
|
668
675
|
}
|
|
669
|
-
return
|
|
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;
|
|
670
686
|
}
|
|
671
687
|
/** Rejects an unknown `--backend` without paying for the probe that resolves one. */
|
|
672
688
|
function assertKnownBackend(requested) {
|