@indigoai-us/hq-cli 5.98.3 → 5.99.1

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
  3. package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
  4. package/assets/scaffold/core/scripts/hq-session.sh +251 -0
  5. package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
  6. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
  7. package/dist/commands/core-checkpoint.js +11 -3
  8. package/dist/commands/core.js +60 -5
  9. package/dist/commands/doctor.d.ts +97 -0
  10. package/dist/commands/doctor.js +228 -0
  11. package/dist/commands/scaffold-fast.d.ts +41 -0
  12. package/dist/commands/scaffold-fast.js +57 -0
  13. package/dist/fast-core.d.ts +16 -0
  14. package/dist/fast-core.js +47 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +10 -1
  17. package/dist/lib/core-utils/soft-timeout.d.ts +55 -0
  18. package/dist/lib/core-utils/soft-timeout.js +205 -0
  19. package/dist/lib/core-utils/timeout-guard.d.ts +62 -0
  20. package/dist/lib/core-utils/timeout-guard.js +207 -0
  21. package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
  22. package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
  23. package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
  24. package/dist/lib/doctor/allowed-divergence.js +134 -0
  25. package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
  26. package/dist/lib/doctor/checks/claude-wiring.js +524 -0
  27. package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
  28. package/dist/lib/doctor/checks/codex-wiring.js +376 -0
  29. package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
  30. package/dist/lib/doctor/checks/grok-wiring.js +186 -0
  31. package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
  32. package/dist/lib/doctor/checks/runtime-probe.js +335 -0
  33. package/dist/lib/doctor/compat.d.ts +85 -0
  34. package/dist/lib/doctor/compat.js +102 -0
  35. package/dist/lib/doctor/deep/classify.d.ts +61 -0
  36. package/dist/lib/doctor/deep/classify.js +75 -0
  37. package/dist/lib/doctor/deep/effects.d.ts +107 -0
  38. package/dist/lib/doctor/deep/effects.js +229 -0
  39. package/dist/lib/doctor/deep/executor.d.ts +112 -0
  40. package/dist/lib/doctor/deep/executor.js +369 -0
  41. package/dist/lib/doctor/deep/parity.d.ts +129 -0
  42. package/dist/lib/doctor/deep/parity.js +355 -0
  43. package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
  44. package/dist/lib/doctor/deep/sandbox.js +572 -0
  45. package/dist/lib/doctor/fix/apply.d.ts +119 -0
  46. package/dist/lib/doctor/fix/apply.js +352 -0
  47. package/dist/lib/doctor/fix/backup.d.ts +40 -0
  48. package/dist/lib/doctor/fix/backup.js +64 -0
  49. package/dist/lib/doctor/fix/remediation.d.ts +71 -0
  50. package/dist/lib/doctor/fix/remediation.js +103 -0
  51. package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
  52. package/dist/lib/doctor/fixtures/discover.js +287 -0
  53. package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
  54. package/dist/lib/doctor/fixtures/schema.js +248 -0
  55. package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
  56. package/dist/lib/doctor/hook-gate-profiles.js +107 -0
  57. package/dist/lib/doctor/json-output.d.ts +90 -0
  58. package/dist/lib/doctor/json-output.js +76 -0
  59. package/dist/lib/doctor/payload-shapes.d.ts +170 -0
  60. package/dist/lib/doctor/payload-shapes.js +275 -0
  61. package/dist/lib/doctor/platform.d.ts +244 -0
  62. package/dist/lib/doctor/platform.js +490 -0
  63. package/dist/lib/doctor/registry.d.ts +49 -0
  64. package/dist/lib/doctor/registry.js +176 -0
  65. package/dist/lib/doctor/report.d.ts +87 -0
  66. package/dist/lib/doctor/report.js +164 -0
  67. package/dist/lib/doctor/types.d.ts +87 -0
  68. package/dist/lib/doctor/types.js +29 -0
  69. package/dist/main.js +6 -0
  70. package/dist/utils/version-check.js +2 -2
  71. package/dist/utils/version-gate.d.ts +1 -1
  72. package/dist/utils/version-gate.js +1 -1
  73. package/package.json +1 -1
@@ -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
+ }