@indigoai-us/hq-cli 5.98.2 → 5.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
- package/assets/scaffold/core/scripts/hq-session.sh +251 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
- package/dist/commands/core.js +25 -5
- package/dist/commands/doctor.d.ts +97 -0
- package/dist/commands/doctor.js +228 -0
- package/dist/commands/scaffold-fast.d.ts +41 -0
- package/dist/commands/scaffold-fast.js +57 -0
- package/dist/fast-core.d.ts +16 -0
- package/dist/fast-core.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -1
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
- package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
- package/dist/lib/doctor/allowed-divergence.js +134 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
- package/dist/lib/doctor/checks/claude-wiring.js +524 -0
- package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
- package/dist/lib/doctor/checks/codex-wiring.js +376 -0
- package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
- package/dist/lib/doctor/checks/grok-wiring.js +186 -0
- package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
- package/dist/lib/doctor/checks/runtime-probe.js +335 -0
- package/dist/lib/doctor/compat.d.ts +85 -0
- package/dist/lib/doctor/compat.js +102 -0
- package/dist/lib/doctor/deep/classify.d.ts +61 -0
- package/dist/lib/doctor/deep/classify.js +75 -0
- package/dist/lib/doctor/deep/effects.d.ts +107 -0
- package/dist/lib/doctor/deep/effects.js +229 -0
- package/dist/lib/doctor/deep/executor.d.ts +112 -0
- package/dist/lib/doctor/deep/executor.js +369 -0
- package/dist/lib/doctor/deep/parity.d.ts +129 -0
- package/dist/lib/doctor/deep/parity.js +355 -0
- package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
- package/dist/lib/doctor/deep/sandbox.js +572 -0
- package/dist/lib/doctor/fix/apply.d.ts +119 -0
- package/dist/lib/doctor/fix/apply.js +352 -0
- package/dist/lib/doctor/fix/backup.d.ts +40 -0
- package/dist/lib/doctor/fix/backup.js +64 -0
- package/dist/lib/doctor/fix/remediation.d.ts +71 -0
- package/dist/lib/doctor/fix/remediation.js +103 -0
- package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
- package/dist/lib/doctor/fixtures/discover.js +287 -0
- package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
- package/dist/lib/doctor/fixtures/schema.js +248 -0
- package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
- package/dist/lib/doctor/hook-gate-profiles.js +107 -0
- package/dist/lib/doctor/json-output.d.ts +90 -0
- package/dist/lib/doctor/json-output.js +76 -0
- package/dist/lib/doctor/payload-shapes.d.ts +170 -0
- package/dist/lib/doctor/payload-shapes.js +275 -0
- package/dist/lib/doctor/platform.d.ts +244 -0
- package/dist/lib/doctor/platform.js +490 -0
- package/dist/lib/doctor/registry.d.ts +49 -0
- package/dist/lib/doctor/registry.js +176 -0
- package/dist/lib/doctor/report.d.ts +87 -0
- package/dist/lib/doctor/report.js +164 -0
- package/dist/lib/doctor/types.d.ts +87 -0
- package/dist/lib/doctor/types.js +29 -0
- package/dist/main.js +6 -0
- package/dist/utils/hook-trust.d.ts +10 -13
- package/dist/utils/hook-trust.js +148 -27
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-gate.d.ts +1 -1
- package/dist/utils/version-gate.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# hook-lib.sh — shared, python-free primitives for HQ hooks and scripts.
|
|
3
|
+
#
|
|
4
|
+
# SOURCED, never executed: . "$HELPERS/hook-lib.sh"
|
|
5
|
+
# bash 3.2 safe; no heredocs nested inside $( ) (hooks-heredoc-syntax lint).
|
|
6
|
+
#
|
|
7
|
+
# HQ hooks must run on machines WITHOUT python3 — including Windows, where the
|
|
8
|
+
# Store alias stub resolves on PATH but cannot execute anything (so a bare
|
|
9
|
+
# `command -v python3` is not a usable capability probe). These primitives
|
|
10
|
+
# replace every inline `python3 -c` snippet the hooks used to carry.
|
|
11
|
+
#
|
|
12
|
+
# Engine order (locked 2026-07-15, Shahzaib):
|
|
13
|
+
# - Hot-path primitives (hq_json_get / hq_json_encode / hq_normpath):
|
|
14
|
+
# jq FIRST — jq ships with the HQ toolchain, is already a hard requirement
|
|
15
|
+
# of the policy-trigger pipeline, and spawns fastest — then node (HQ is
|
|
16
|
+
# npm-installed, so node exists on every HQ machine), else degrade to ""
|
|
17
|
+
# so callers keep their existing fail-open behavior.
|
|
18
|
+
# - Complex analyzers stay in their own hooks but follow node-first with an
|
|
19
|
+
# awk fallback (see validate-policy-frontmatter.sh).
|
|
20
|
+
#
|
|
21
|
+
# Test override: HQ_HOOK_ENGINE=jq|node forces a single engine so parity
|
|
22
|
+
# suites can drive both implementations over the same payloads.
|
|
23
|
+
|
|
24
|
+
HQ_LIB_JQ="$(command -v jq 2>/dev/null || true)"
|
|
25
|
+
HQ_LIB_NODE="$(command -v node 2>/dev/null || true)"
|
|
26
|
+
case "${HQ_HOOK_ENGINE:-}" in
|
|
27
|
+
jq) HQ_LIB_NODE="" ;;
|
|
28
|
+
node) HQ_LIB_JQ="" ;;
|
|
29
|
+
esac
|
|
30
|
+
|
|
31
|
+
# hq_json_get <dotted.key.path>
|
|
32
|
+
# stdin: JSON document. stdout: the addressed value, or "" when the path is
|
|
33
|
+
# missing, null, or resolves to an object/array. Numeric path segments index
|
|
34
|
+
# arrays ("tool_input.edits.0.old_string").
|
|
35
|
+
hq_json_get() {
|
|
36
|
+
if [ -n "$HQ_LIB_JQ" ]; then
|
|
37
|
+
"$HQ_LIB_JQ" -r --arg p "$1" '
|
|
38
|
+
try (getpath($p | split(".") | map(if test("^[0-9]+$") then tonumber else . end))
|
|
39
|
+
| if . == null or type == "object" or type == "array" then "" else tostring end)
|
|
40
|
+
catch ""' 2>/dev/null || echo ""
|
|
41
|
+
return 0
|
|
42
|
+
fi
|
|
43
|
+
if [ -n "$HQ_LIB_NODE" ]; then
|
|
44
|
+
"$HQ_LIB_NODE" -e '
|
|
45
|
+
let d = "";
|
|
46
|
+
process.stdin.on("data", c => d += c).on("end", () => {
|
|
47
|
+
let v;
|
|
48
|
+
try { v = JSON.parse(d); } catch (e) { console.log(""); return; }
|
|
49
|
+
for (const k of process.argv[1].split(".")) {
|
|
50
|
+
if (v && typeof v === "object") v = Array.isArray(v) ? v[Number(k)] : v[k];
|
|
51
|
+
else { v = undefined; break; }
|
|
52
|
+
}
|
|
53
|
+
if (v === undefined || v === null || typeof v === "object") console.log("");
|
|
54
|
+
else console.log(String(v));
|
|
55
|
+
});' "$1" 2>/dev/null || echo ""
|
|
56
|
+
return 0
|
|
57
|
+
fi
|
|
58
|
+
cat >/dev/null 2>&1 || true
|
|
59
|
+
echo ""
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# hq_json_encode
|
|
63
|
+
# stdin: raw string. stdout: the string as a JSON literal (quoted, escaped).
|
|
64
|
+
hq_json_encode() {
|
|
65
|
+
if [ -n "$HQ_LIB_JQ" ]; then
|
|
66
|
+
"$HQ_LIB_JQ" -Rs . 2>/dev/null || echo '""'
|
|
67
|
+
return 0
|
|
68
|
+
fi
|
|
69
|
+
if [ -n "$HQ_LIB_NODE" ]; then
|
|
70
|
+
"$HQ_LIB_NODE" -e '
|
|
71
|
+
let d = "";
|
|
72
|
+
process.stdin.on("data", c => d += c).on("end", () => console.log(JSON.stringify(d)));' \
|
|
73
|
+
2>/dev/null || echo '""'
|
|
74
|
+
return 0
|
|
75
|
+
fi
|
|
76
|
+
cat >/dev/null 2>&1 || true
|
|
77
|
+
echo '""'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# hq_normpath <path>
|
|
81
|
+
# Lexical normalization (no filesystem access): backslashes -> "/", collapse
|
|
82
|
+
# "//" and "/./", resolve "x/..", trim trailing "/" (keeps root). Both
|
|
83
|
+
# engines produce IDENTICAL output, so equality checks between two
|
|
84
|
+
# hq_normpath results are stable regardless of engine or platform.
|
|
85
|
+
hq_normpath() {
|
|
86
|
+
printf '%s' "$1" | awk '
|
|
87
|
+
{
|
|
88
|
+
p = $0
|
|
89
|
+
gsub(/\\/, "/", p)
|
|
90
|
+
isabs = (p ~ /^\//) ? 1 : 0
|
|
91
|
+
drive = ""
|
|
92
|
+
if (p ~ /^[A-Za-z]:/) { drive = substr(p, 1, 2); p = substr(p, 3); isabs = (p ~ /^\//) ? 1 : 0 }
|
|
93
|
+
n = split(p, seg, "/")
|
|
94
|
+
out_n = 0
|
|
95
|
+
for (i = 1; i <= n; i++) {
|
|
96
|
+
s = seg[i]
|
|
97
|
+
if (s == "" || s == ".") continue
|
|
98
|
+
if (s == "..") {
|
|
99
|
+
if (out_n > 0 && out[out_n] != "..") { out_n--; continue }
|
|
100
|
+
if (isabs) continue
|
|
101
|
+
out[++out_n] = ".."
|
|
102
|
+
} else out[++out_n] = s
|
|
103
|
+
}
|
|
104
|
+
r = ""
|
|
105
|
+
for (i = 1; i <= out_n; i++) r = r (i > 1 ? "/" : "") out[i]
|
|
106
|
+
if (isabs) r = "/" r
|
|
107
|
+
if (drive != "") r = drive r
|
|
108
|
+
if (r == "") r = (isabs ? "/" : ".")
|
|
109
|
+
print r
|
|
110
|
+
}'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# hq_shell_simple_commands <command>
|
|
114
|
+
# Emit one unit-separator-delimited argv record for each simple command in a
|
|
115
|
+
# shell command string. Quotes and escapes are interpreted only far enough to
|
|
116
|
+
# identify command boundaries and arguments; nothing is evaluated. This gives
|
|
117
|
+
# guards a safe classification surface: `grep "git reset"` is grep, while
|
|
118
|
+
# `git reset` is git. Redirections are retained as argv tokens so write guards
|
|
119
|
+
# can inspect their targets.
|
|
120
|
+
hq_shell_simple_commands() {
|
|
121
|
+
printf '%s' "$1" | awk '
|
|
122
|
+
function push_word() { if (word != "") { argv[++argc] = word; word = "" } }
|
|
123
|
+
function emit( j, out) {
|
|
124
|
+
push_word()
|
|
125
|
+
if (argc == 0) return
|
|
126
|
+
out = argv[1]
|
|
127
|
+
for (j = 2; j <= argc; j++) out = out sprintf("%c", 31) argv[j]
|
|
128
|
+
print out
|
|
129
|
+
for (j in argv) delete argv[j]
|
|
130
|
+
argc = 0
|
|
131
|
+
}
|
|
132
|
+
{
|
|
133
|
+
text = $0 "\n"
|
|
134
|
+
n = length(text); quote = ""; escaped = 0; word = ""; argc = 0
|
|
135
|
+
for (i = 1; i <= n; i++) {
|
|
136
|
+
c = substr(text, i, 1)
|
|
137
|
+
if (escaped) { word = word c; escaped = 0; continue }
|
|
138
|
+
if (quote == "\047") {
|
|
139
|
+
if (c == "\047") quote = ""; else word = word c
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (quote == "\"") {
|
|
143
|
+
if (c == "\"") quote = ""
|
|
144
|
+
else if (c == "\\") escaped = 1
|
|
145
|
+
else word = word c
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (c == "\047" || c == "\"") { quote = c; continue }
|
|
149
|
+
if (c == "\\") { escaped = 1; continue }
|
|
150
|
+
if (c == " " || c == "\t") { push_word(); continue }
|
|
151
|
+
if (c == ";" || c == "|" || c == "&" || c == "\n" || c == "(" || c == ")") {
|
|
152
|
+
emit()
|
|
153
|
+
if ((c == "|" || c == "&") && substr(text, i + 1, 1) == c) i++
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (c == ">" || c == "<") {
|
|
157
|
+
push_word(); argv[++argc] = c
|
|
158
|
+
if (substr(text, i + 1, 1) == c) { argv[argc] = argv[argc] c; i++ }
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
word = word c
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# hq_shell_command_executable <US-delimited argv>
|
|
168
|
+
# Print the executable of a simple command after leading environment
|
|
169
|
+
# assignments and the common `env` / `command` wrappers.
|
|
170
|
+
hq_shell_command_executable() {
|
|
171
|
+
local record="$1" token
|
|
172
|
+
local -a argv
|
|
173
|
+
IFS=$'\037' read -r -a argv <<< "$record"
|
|
174
|
+
local i=0
|
|
175
|
+
while [ "$i" -lt "${#argv[@]}" ]; do
|
|
176
|
+
token="${argv[$i]}"
|
|
177
|
+
case "$token" in
|
|
178
|
+
[A-Za-z_][A-Za-z0-9_]*=*) i=$((i + 1)); continue ;;
|
|
179
|
+
env)
|
|
180
|
+
i=$((i + 1))
|
|
181
|
+
while [ "$i" -lt "${#argv[@]}" ]; do
|
|
182
|
+
token="${argv[$i]}"
|
|
183
|
+
case "$token" in
|
|
184
|
+
[A-Za-z_][A-Za-z0-9_]*=*) i=$((i + 1)); continue ;;
|
|
185
|
+
-*) i=$((i + 1)); continue ;;
|
|
186
|
+
esac
|
|
187
|
+
break
|
|
188
|
+
done
|
|
189
|
+
continue
|
|
190
|
+
;;
|
|
191
|
+
command|builtin|exec|sudo)
|
|
192
|
+
i=$((i + 1)); continue ;;
|
|
193
|
+
*) printf '%s' "$token"; return 0 ;;
|
|
194
|
+
esac
|
|
195
|
+
done
|
|
196
|
+
return 1
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
hq_text_compact() {
|
|
200
|
+
local max="${1:-240}"
|
|
201
|
+
local text="$2"
|
|
202
|
+
text="$(printf '%s' "$text" | tr '\r\n' ' ' | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//')"
|
|
203
|
+
if [ "${#text}" -gt "$max" ]; then
|
|
204
|
+
printf '%s...' "${text:0:$((max - 3))}"
|
|
205
|
+
else
|
|
206
|
+
printf '%s' "$text"
|
|
207
|
+
fi
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
hq_hook_safe_session_key() {
|
|
211
|
+
local raw="$1"
|
|
212
|
+
raw="$(printf '%s' "$raw" | tr -c 'A-Za-z0-9._-' '_')"
|
|
213
|
+
raw="${raw#_}"
|
|
214
|
+
raw="${raw%_}"
|
|
215
|
+
if [ -z "$raw" ]; then
|
|
216
|
+
raw="session-${PPID:-$$}"
|
|
217
|
+
fi
|
|
218
|
+
if [ "${#raw}" -gt 96 ]; then
|
|
219
|
+
raw="${raw:0:96}"
|
|
220
|
+
fi
|
|
221
|
+
printf '%s' "$raw"
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
hq_hook_session_key_from_payload() {
|
|
225
|
+
local payload="$1"
|
|
226
|
+
local key="${HQ_HOOK_SESSION_ID:-}"
|
|
227
|
+
if [ -z "$key" ]; then
|
|
228
|
+
key="$(printf '%s' "$payload" | hq_json_get 'session_id')"
|
|
229
|
+
fi
|
|
230
|
+
if [ -z "$key" ]; then
|
|
231
|
+
key="$(printf '%s' "$payload" | hq_json_get 'sessionId')"
|
|
232
|
+
fi
|
|
233
|
+
if [ -z "$key" ]; then
|
|
234
|
+
key="$(printf '%s' "$payload" | hq_json_get 'conversation_id')"
|
|
235
|
+
fi
|
|
236
|
+
if [ -z "$key" ]; then
|
|
237
|
+
key="$(printf '%s' "$payload" | hq_json_get 'conversationId')"
|
|
238
|
+
fi
|
|
239
|
+
if [ -z "$key" ]; then
|
|
240
|
+
key="$(printf '%s' "$payload" | hq_json_get 'thread_id')"
|
|
241
|
+
fi
|
|
242
|
+
if [ -z "$key" ]; then
|
|
243
|
+
key="$(printf '%s' "$payload" | hq_json_get 'threadId')"
|
|
244
|
+
fi
|
|
245
|
+
if [ -z "$key" ]; then
|
|
246
|
+
key="$(printf '%s' "$payload" | hq_json_get 'tool_input.session_id')"
|
|
247
|
+
fi
|
|
248
|
+
hq_hook_safe_session_key "$key"
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# hq_hook_state_dir <hq_root>
|
|
252
|
+
# Session-scoped hook state lives under the resolved HQ root, never /tmp and
|
|
253
|
+
# never keyed by $PPID — tool and hook processes do not share a parent PID in
|
|
254
|
+
# every host, so a PID-keyed path silently defeats debouncing.
|
|
255
|
+
hq_hook_state_dir() {
|
|
256
|
+
local root="$1"
|
|
257
|
+
local dir="$root/workspace/orchestrator/hook-state"
|
|
258
|
+
mkdir -p "$dir" 2>/dev/null || true
|
|
259
|
+
printf '%s' "$dir"
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
# hq_hook_tool_failed <payload>
|
|
263
|
+
# Exit 0 when the PostToolUse payload shows the tool call FAILED, 1 when it
|
|
264
|
+
# succeeded or the host reported nothing either way.
|
|
265
|
+
#
|
|
266
|
+
# Claude Code does not dispatch PostToolUse at all for a failed tool call, so
|
|
267
|
+
# this matters for the Codex adapter, which forwards every call along with
|
|
268
|
+
# `tool_response.exit_code`. Without it a red test run counts as a milestone
|
|
269
|
+
# and every targeted rerun mints another reminder.
|
|
270
|
+
hq_hook_tool_failed() {
|
|
271
|
+
local payload="$1"
|
|
272
|
+
local key v
|
|
273
|
+
for key in tool_response.exit_code tool_response.exitCode tool_response.returnCode; do
|
|
274
|
+
v="$(printf '%s' "$payload" | hq_json_get "$key")"
|
|
275
|
+
case "$v" in
|
|
276
|
+
'') continue ;;
|
|
277
|
+
0) return 1 ;;
|
|
278
|
+
*[!0-9-]*) continue ;;
|
|
279
|
+
*) return 0 ;;
|
|
280
|
+
esac
|
|
281
|
+
done
|
|
282
|
+
for key in tool_response.is_error tool_response.isError tool_response.interrupted; do
|
|
283
|
+
v="$(printf '%s' "$payload" | hq_json_get "$key")"
|
|
284
|
+
[ "$v" = "true" ] && return 0
|
|
285
|
+
done
|
|
286
|
+
return 1
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
# hq_hook_within_window <state_file> <seconds>
|
|
290
|
+
# Exit 0 when <state_file> holds an epoch stamp newer than <seconds> ago —
|
|
291
|
+
# i.e. the caller is still inside its debounce window and should stay quiet.
|
|
292
|
+
hq_hook_within_window() {
|
|
293
|
+
local file="$1"
|
|
294
|
+
local window="$2"
|
|
295
|
+
local last now
|
|
296
|
+
[ -f "$file" ] || return 1
|
|
297
|
+
last="$(tr -dc '0-9' < "$file" 2>/dev/null)"
|
|
298
|
+
[ -n "$last" ] || return 1
|
|
299
|
+
now="$(date +%s)"
|
|
300
|
+
[ $(( now - last )) -lt "$window" ]
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
hq_hook_stamp_now() {
|
|
304
|
+
date +%s > "$1" 2>/dev/null || true
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
# hq_path_is_absolute <path>
|
|
308
|
+
# exit 0 for Unix or Windows drive-letter absolutes.
|
|
309
|
+
hq_path_is_absolute() {
|
|
310
|
+
case "$1" in
|
|
311
|
+
/*|[a-zA-Z]:/*|[a-zA-Z]:\\*) return 0 ;;
|
|
312
|
+
esac
|
|
313
|
+
return 1
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
# hq_canonical_path <path>
|
|
317
|
+
# Normalize for hook comparisons. On MSYS/Git Bash, cygpath -m unifies
|
|
318
|
+
# /tmp/... and C:/Users/.../Temp/... forms of the same directory.
|
|
319
|
+
hq_canonical_path() {
|
|
320
|
+
local p="$1"
|
|
321
|
+
p="$(hq_normpath "$p" 2>/dev/null || printf '%s' "$p")"
|
|
322
|
+
if command -v cygpath >/dev/null 2>&1; then
|
|
323
|
+
cygpath -m "$p" 2>/dev/null || printf '%s' "$p"
|
|
324
|
+
return 0
|
|
325
|
+
fi
|
|
326
|
+
printf '%s' "$p"
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
# hq_path_matches_core_yaml_exclude <hq_root> <file_path> [<core_yaml>]
|
|
330
|
+
# exit 0 when file_path matches a core/core.yaml rules.exclude entry (file or
|
|
331
|
+
# directory prefix). Requires yq and a readable core_yaml; otherwise exit 1.
|
|
332
|
+
hq_path_matches_core_yaml_exclude() {
|
|
333
|
+
local hq_root="$1"
|
|
334
|
+
local file_path="$2"
|
|
335
|
+
local core_yaml="${3:-$hq_root/core/core.yaml}"
|
|
336
|
+
local exc_path exc_abs exclude_paths
|
|
337
|
+
|
|
338
|
+
[ -n "$hq_root" ] || return 1
|
|
339
|
+
[ -n "$file_path" ] || return 1
|
|
340
|
+
[ -f "$core_yaml" ] || return 1
|
|
341
|
+
command -v yq >/dev/null 2>&1 || return 1
|
|
342
|
+
|
|
343
|
+
file_path="$(hq_canonical_path "$file_path")"
|
|
344
|
+
hq_root="$(hq_canonical_path "$hq_root")"
|
|
345
|
+
|
|
346
|
+
exclude_paths="$(yq eval '.rules.exclude[]' "$core_yaml" 2>/dev/null)" || exclude_paths=""
|
|
347
|
+
while IFS= read -r exc_path; do
|
|
348
|
+
[ -n "$exc_path" ] || continue
|
|
349
|
+
exc_abs="$(hq_normpath "${hq_root}/${exc_path%/}")"
|
|
350
|
+
case "$file_path" in
|
|
351
|
+
"$exc_abs"|"$exc_abs"/*) return 0 ;;
|
|
352
|
+
esac
|
|
353
|
+
done <<< "$exclude_paths"
|
|
354
|
+
return 1
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
# hq_bash_strip_core_yaml_exclude_tokens <command> <hq_root> [<core_yaml>]
|
|
358
|
+
# Best-effort removal of command tokens that reference rules.exclude paths.
|
|
359
|
+
# Match full root-relative (and absolute) exclude paths — never basename
|
|
360
|
+
# substrings, which would strip unrelated protected targets (e.g. toolkit.sh
|
|
361
|
+
# when .claude/kit/ is excluded).
|
|
362
|
+
hq_bash_strip_core_yaml_exclude_tokens() {
|
|
363
|
+
local cmd="$1"
|
|
364
|
+
local hq_root="$2"
|
|
365
|
+
local core_yaml="${3:-$hq_root/core/core.yaml}"
|
|
366
|
+
local exc_path rel abs esc esc_dir sed_script exclude_paths
|
|
367
|
+
|
|
368
|
+
[ -n "$cmd" ] || { printf '%s' "$cmd"; return 0; }
|
|
369
|
+
[ -f "$core_yaml" ] || { printf '%s' "$cmd"; return 0; }
|
|
370
|
+
command -v yq >/dev/null 2>&1 || { printf '%s' "$cmd"; return 0; }
|
|
371
|
+
|
|
372
|
+
hq_root="$(hq_canonical_path "$hq_root")"
|
|
373
|
+
sed_script=''
|
|
374
|
+
exclude_paths="$(yq eval '.rules.exclude[]' "$core_yaml" 2>/dev/null)" || exclude_paths=""
|
|
375
|
+
while IFS= read -r exc_path; do
|
|
376
|
+
[ -n "$exc_path" ] || continue
|
|
377
|
+
rel="${exc_path%/}"
|
|
378
|
+
[ -n "$rel" ] || continue
|
|
379
|
+
|
|
380
|
+
esc="$(printf '%s' "$rel" | sed 's/[][\\.*^$(){}?+|/]/\\&/g')"
|
|
381
|
+
sed_script="${sed_script}s|[^[:space:]]*${esc}[^[:space:]]*||g; s|${esc}||g;"
|
|
382
|
+
|
|
383
|
+
if [[ "$exc_path" == */ ]]; then
|
|
384
|
+
esc_dir="$(printf '%s' "$exc_path" | sed 's/[][\\.*^$(){}?+|/]/\\&/g')"
|
|
385
|
+
sed_script="${sed_script}s|[^[:space:]]*${esc_dir}[^[:space:]]*||g; s|${esc_dir}||g;"
|
|
386
|
+
fi
|
|
387
|
+
|
|
388
|
+
abs="$(hq_canonical_path "${hq_root}/${rel}")"
|
|
389
|
+
esc_abs="$(printf '%s' "$abs" | sed 's/[][\\.*^$(){}?+|/]/\\&/g')"
|
|
390
|
+
sed_script="${sed_script}s|[^[:space:]]*${esc_abs}[^[:space:]]*||g; s|${esc_abs}||g;"
|
|
391
|
+
done <<< "$exclude_paths"
|
|
392
|
+
|
|
393
|
+
[ -n "$sed_script" ] || { printf '%s' "$cmd"; return 0; }
|
|
394
|
+
printf '%s' "$cmd" | sed "$sed_script"
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
hq_path_within_root() {
|
|
399
|
+
local root="$1"
|
|
400
|
+
local path="$2"
|
|
401
|
+
local root_norm path_norm
|
|
402
|
+
[ -n "$root" ] || return 1
|
|
403
|
+
[ -n "$path" ] || return 1
|
|
404
|
+
# Never chmod through a symlink that may resolve outside HQ_ROOT.
|
|
405
|
+
[ -L "$path" ] && return 1
|
|
406
|
+
root_norm="$(hq_normpath "$root")"
|
|
407
|
+
path_norm="$(hq_normpath "$path")"
|
|
408
|
+
case "$path_norm" in
|
|
409
|
+
"$root_norm"|"$root_norm"/*) return 0 ;;
|
|
410
|
+
esac
|
|
411
|
+
return 1
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
hq_path_label() {
|
|
415
|
+
local root="$1"
|
|
416
|
+
local path="$2"
|
|
417
|
+
local root_norm path_norm
|
|
418
|
+
[ -n "$path" ] || return 0
|
|
419
|
+
root_norm="$(hq_normpath "$root")"
|
|
420
|
+
path_norm="$(hq_normpath "$path")"
|
|
421
|
+
case "$path_norm" in
|
|
422
|
+
"$root_norm")
|
|
423
|
+
printf '.'
|
|
424
|
+
return 0
|
|
425
|
+
;;
|
|
426
|
+
"$root_norm"/*)
|
|
427
|
+
printf '%s' "${path_norm#"$root_norm"/}"
|
|
428
|
+
return 0
|
|
429
|
+
;;
|
|
430
|
+
esac
|
|
431
|
+
case "$path" in
|
|
432
|
+
*/*) printf '%s' "${path##*/}" ;;
|
|
433
|
+
*) printf '%s' "$path" ;;
|
|
434
|
+
esac
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
hq_hook_repair_command() {
|
|
438
|
+
local root="$1"
|
|
439
|
+
local path="$2"
|
|
440
|
+
local rel
|
|
441
|
+
if ! hq_path_within_root "$root" "$path"; then
|
|
442
|
+
return 0
|
|
443
|
+
fi
|
|
444
|
+
rel="$(hq_path_label "$root" "$path")"
|
|
445
|
+
[ -n "$rel" ] || return 0
|
|
446
|
+
printf 'chmod u+x "$HQ_ROOT/%s"' "$rel"
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
hq_hook_warning_cache_path() {
|
|
450
|
+
local root="$1"
|
|
451
|
+
local session_key="$2"
|
|
452
|
+
local cache_dir
|
|
453
|
+
[ -n "$root" ] || return 1
|
|
454
|
+
cache_dir="$root/workspace/.hook-launch-warnings"
|
|
455
|
+
mkdir -p "$cache_dir" 2>/dev/null || return 1
|
|
456
|
+
printf '%s/%s.keys' "$cache_dir" "$session_key"
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
hq_hook_warning_once() {
|
|
460
|
+
local root="$1"
|
|
461
|
+
local session_key="$2"
|
|
462
|
+
local dedupe_key="$3"
|
|
463
|
+
local cache_path
|
|
464
|
+
cache_path="$(hq_hook_warning_cache_path "$root" "$session_key")" || return 0
|
|
465
|
+
if grep -Fqx "$dedupe_key" "$cache_path" 2>/dev/null; then
|
|
466
|
+
return 1
|
|
467
|
+
fi
|
|
468
|
+
printf '%s\n' "$dedupe_key" >>"$cache_path" 2>/dev/null || true
|
|
469
|
+
return 0
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
hq_hook_launch_warning_text() {
|
|
473
|
+
local payload="$1"
|
|
474
|
+
local root="$2"
|
|
475
|
+
local mode="$3"
|
|
476
|
+
local kind="$4"
|
|
477
|
+
local label="$5"
|
|
478
|
+
local path="$6"
|
|
479
|
+
local cause="$7"
|
|
480
|
+
local session_key rel repair dedupe_key msg
|
|
481
|
+
|
|
482
|
+
session_key="$(hq_hook_session_key_from_payload "$payload")"
|
|
483
|
+
rel="$(hq_path_label "$root" "$path")"
|
|
484
|
+
repair="$(hq_hook_repair_command "$root" "$path")"
|
|
485
|
+
dedupe_key="$(hq_text_compact 240 "$mode|$kind|$label|$rel|$cause")"
|
|
486
|
+
if ! hq_hook_warning_once "$root" "$session_key" "$dedupe_key"; then
|
|
487
|
+
return 0
|
|
488
|
+
fi
|
|
489
|
+
|
|
490
|
+
msg="WARNING: HQ ${mode} ${kind} launch failed for ${label}"
|
|
491
|
+
if [ -n "$rel" ]; then
|
|
492
|
+
msg="${msg} (${rel})"
|
|
493
|
+
fi
|
|
494
|
+
msg="${msg}: ${cause}."
|
|
495
|
+
if [ -n "$repair" ]; then
|
|
496
|
+
msg="${msg} Repair: ${repair}"
|
|
497
|
+
fi
|
|
498
|
+
printf '%s' "$(hq_text_compact 420 "$msg")"
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
HQ_HOOK_LAST_STATUS=0
|
|
502
|
+
HQ_HOOK_LAST_CAUSE=""
|
|
503
|
+
|
|
504
|
+
hq_launch_shell_path() {
|
|
505
|
+
local root="$1"
|
|
506
|
+
local path="$2"
|
|
507
|
+
local payload="$3"
|
|
508
|
+
shift 3
|
|
509
|
+
|
|
510
|
+
HQ_HOOK_LAST_STATUS=0
|
|
511
|
+
HQ_HOOK_LAST_CAUSE=""
|
|
512
|
+
|
|
513
|
+
if [ ! -f "$path" ]; then
|
|
514
|
+
HQ_HOOK_LAST_STATUS=127
|
|
515
|
+
if hq_path_within_root "$root" "$path"; then
|
|
516
|
+
HQ_HOOK_LAST_CAUSE="file is missing under HQ_ROOT"
|
|
517
|
+
else
|
|
518
|
+
HQ_HOOK_LAST_CAUSE="file is missing"
|
|
519
|
+
fi
|
|
520
|
+
return 127
|
|
521
|
+
fi
|
|
522
|
+
|
|
523
|
+
if [ ! -x "$path" ] && hq_path_within_root "$root" "$path"; then
|
|
524
|
+
chmod u+x "$path" 2>/dev/null || true
|
|
525
|
+
fi
|
|
526
|
+
|
|
527
|
+
if [ -x "$path" ]; then
|
|
528
|
+
printf '%s' "$payload" | "$path" "$@"
|
|
529
|
+
HQ_HOOK_LAST_STATUS=$?
|
|
530
|
+
return "$HQ_HOOK_LAST_STATUS"
|
|
531
|
+
fi
|
|
532
|
+
|
|
533
|
+
local bash_bin="${BASH:-}"
|
|
534
|
+
if [ -z "$bash_bin" ] || [ ! -x "$bash_bin" ]; then
|
|
535
|
+
bash_bin="$(command -v bash 2>/dev/null || true)"
|
|
536
|
+
fi
|
|
537
|
+
if [ -r "$path" ] && [ -n "$bash_bin" ]; then
|
|
538
|
+
printf '%s' "$payload" | "$bash_bin" "$path" "$@"
|
|
539
|
+
HQ_HOOK_LAST_STATUS=$?
|
|
540
|
+
return "$HQ_HOOK_LAST_STATUS"
|
|
541
|
+
fi
|
|
542
|
+
|
|
543
|
+
if [ ! -r "$path" ]; then
|
|
544
|
+
if hq_path_within_root "$root" "$path"; then
|
|
545
|
+
HQ_HOOK_LAST_CAUSE="chmod u+x could not repair the file and bash could not read it"
|
|
546
|
+
else
|
|
547
|
+
HQ_HOOK_LAST_CAUSE="file is not executable or readable"
|
|
548
|
+
fi
|
|
549
|
+
HQ_HOOK_LAST_STATUS=126
|
|
550
|
+
return 126
|
|
551
|
+
fi
|
|
552
|
+
|
|
553
|
+
HQ_HOOK_LAST_STATUS=127
|
|
554
|
+
# shellcheck disable=SC2034 # Read by adapters after this sourced function returns.
|
|
555
|
+
HQ_HOOK_LAST_CAUSE="bash is unavailable for the readable shell fallback"
|
|
556
|
+
return 127
|
|
557
|
+
}
|