@indigoai-us/hq-cli 5.289.0 → 5.291.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/assets/scaffold/core/scripts/handoff-finalize.sh +670 -0
  3. package/assets/scaffold/core/scripts/handoff-open-steps.sh +81 -0
  4. package/assets/scaffold/core/scripts/handoff-post.sh +187 -0
  5. package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
  6. package/assets/scaffold/core/scripts/hq-session.sh +182 -21
  7. package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
  8. package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
  9. package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
  10. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
  11. package/assets/scaffold/core/scripts/qmd-reindex-bg.sh +40 -0
  12. package/dist/command-catalog.generated.d.ts +110 -0
  13. package/dist/command-catalog.generated.js +136 -0
  14. package/dist/commands/cloud.d.ts +26 -14
  15. package/dist/commands/cloud.js +207 -56
  16. package/dist/commands/core-handoff.d.ts +22 -0
  17. package/dist/commands/core-handoff.js +1240 -0
  18. package/dist/commands/core-jobs-native.d.ts +8 -0
  19. package/dist/commands/core-jobs-native.js +624 -0
  20. package/dist/commands/core-native-jobs-flag.d.ts +4 -0
  21. package/dist/commands/core-native-jobs-flag.js +7 -0
  22. package/dist/commands/core-ontology.d.ts +6 -1
  23. package/dist/commands/core-ontology.js +42 -7
  24. package/dist/commands/core-policy.js +24 -5
  25. package/dist/commands/core.js +15 -1
  26. package/dist/commands/integrations-core.js +7 -0
  27. package/dist/commands/lanes.js +111 -35
  28. package/dist/commands/members.js +5 -0
  29. package/dist/commands/mesh.js +46 -25
  30. package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
  31. package/dist/commands/personal-targeted-push-flag.js +7 -0
  32. package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
  33. package/dist/commands/personal-targeted-push-paths.js +67 -0
  34. package/dist/commands/secrets.js +9 -0
  35. package/dist/commands/skill.d.ts +3 -0
  36. package/dist/commands/skill.js +28 -0
  37. package/dist/fast-core.js +1 -0
  38. package/dist/lib/billing/free-month.d.ts +18 -0
  39. package/dist/lib/billing/free-month.js +31 -0
  40. package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
  41. package/dist/lib/billing/plan-limit-refusals.js +187 -0
  42. package/dist/lib/billing/plan-lock.d.ts +2 -6
  43. package/dist/lib/billing/plan-lock.js +5 -16
  44. package/dist/lib/checkpoint-ontology/entity-file.d.ts +21 -0
  45. package/dist/lib/checkpoint-ontology/entity-file.js +100 -0
  46. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +34 -2
  47. package/dist/lib/checkpoint-ontology/merge-copies.js +541 -27
  48. package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
  49. package/dist/lib/core-utils/secret-patterns.js +3 -2
  50. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  51. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  52. package/dist/lib/lanes/autopilot.d.ts +69 -0
  53. package/dist/lib/lanes/autopilot.js +418 -0
  54. package/dist/lib/lanes/mesh-events.js +2 -0
  55. package/dist/lib/lanes/store.d.ts +4 -2
  56. package/dist/lib/lanes/store.js +18 -9
  57. package/dist/lib/mesh/api.js +4 -0
  58. package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
  59. package/dist/lib/mesh/live/daemon/state.js +34 -2
  60. package/dist/main.js +23 -12
  61. package/dist/utils/plan-gate-error.d.ts +24 -0
  62. package/dist/utils/plan-gate-error.js +219 -9
  63. package/dist/utils/self-update.d.ts +3 -0
  64. package/dist/utils/self-update.js +232 -1
  65. package/package.json +2 -2
@@ -23,6 +23,26 @@
23
23
 
24
24
  HQ_LIB_JQ="$(command -v jq 2>/dev/null || true)"
25
25
  HQ_LIB_NODE="$(command -v node 2>/dev/null || true)"
26
+
27
+ # Is "\" a path SEPARATOR on this host, or an ordinary filename character?
28
+ #
29
+ # On Windows-family shells (Git Bash/MSYS/Cygwin) it is a separator and must be
30
+ # folded to "/". On POSIX it is a perfectly legal character IN a filename, and
31
+ # folding it corrupts real paths — dangerously so in a guard: with a symlink
32
+ # literally named `link\name` pointing into repos/, rewriting the input yields
33
+ # the non-existent lexical path workspace/link/name/file.txt, which resolves to
34
+ # nothing under repos/, so the hook exits 0 while the actual write follows the
35
+ # symlink and lands in the checkout.
36
+ #
37
+ # Detected ONCE at source time — hq_normpath is on the hook hot path and must
38
+ # not fork `uname` per call. Override with HQ_LIB_WINSEP=1|0 for tests.
39
+ if [ -z "${HQ_LIB_WINSEP:-}" ]; then
40
+ HQ_LIB_WINSEP=0
41
+ case "${OSTYPE:-}" in msys*|cygwin*|win32*) HQ_LIB_WINSEP=1 ;; esac
42
+ if [ "$HQ_LIB_WINSEP" = "0" ]; then
43
+ case "$(uname -s 2>/dev/null || true)" in MINGW*|MSYS*|CYGWIN*) HQ_LIB_WINSEP=1 ;; esac
44
+ fi
45
+ fi
26
46
  case "${HQ_HOOK_ENGINE:-}" in
27
47
  jq) HQ_LIB_NODE="" ;;
28
48
  node) HQ_LIB_JQ="" ;;
@@ -79,35 +99,214 @@ hq_json_encode() {
79
99
 
80
100
  # hq_normpath <path>
81
101
  # 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
102
+ # interior "//" and "/./", resolve "x/..", trim trailing "/" (keeps root).
103
+ # Both engines produce IDENTICAL output, so equality checks between two
84
104
  # hq_normpath results are stable regardless of engine or platform.
105
+ #
106
+ # A LEADING "//" is preserved ONLY where it names a genuinely DISTINCT root:
107
+ # Windows Git Bash/MSYS/Cygwin UNC shares (//server/share). POSIX leaves the
108
+ # exactly-two-slash case implementation-defined; Linux resolves //tmp and /tmp
109
+ # to the SAME directory, so preserving the distinction there protects nothing
110
+ # and instead guarantees a mismatch whenever the two sides of a prefix check
111
+ # are spelled differently. Both directions are live bypasses: a "//" project
112
+ # root with a "/" file path, and a "/" root with a "//" file path, each let a
113
+ # write into repos/ through. `pwd -P` does preserve "//", so the collapse must
114
+ # happen HERE, after the physical resolution both helpers end with -- that is
115
+ # what makes every spelling of one physical path compare equal, which is the
116
+ # only property the repo guard actually depends on.
85
117
  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
- }'
118
+ local input="$1" p line remaining s out last r drive root
119
+ local isabs winsep
120
+ local nl='
121
+ '
122
+ [ -n "$input" ] || return 0
123
+ winsep="${HQ_LIB_WINSEP:-0}"
124
+
125
+ # Preserve the prior record-by-record behavior for embedded newlines while
126
+ # keeping this hot path entirely within bash.
127
+ while :; do
128
+ case "$input" in
129
+ *"$nl"*)
130
+ line=${input%%"$nl"*}
131
+ input=${input#*"$nl"}
132
+ ;;
133
+ *)
134
+ line=$input
135
+ input=""
136
+ ;;
137
+ esac
138
+
139
+ p=$line
140
+ if [ "$winsep" = "1" ]; then
141
+ p=${p//\\//}
142
+ fi
143
+ isabs=0
144
+ drive=""
145
+ case "$p" in
146
+ /*) isabs=1 ;;
147
+ esac
148
+ case "$p" in
149
+ [A-Za-z]:*)
150
+ drive=${p:0:2}
151
+ p=${p:2}
152
+ case "$p" in
153
+ /*) isabs=1 ;;
154
+ *) isabs=0 ;;
155
+ esac
156
+ ;;
157
+ esac
158
+ root=""
159
+ if [ "$isabs" = "1" ]; then
160
+ if [ "$winsep" = "1" ]; then
161
+ case "$p" in
162
+ //|//[!/]*) root="//" ;;
163
+ *) root="/" ;;
164
+ esac
165
+ else
166
+ root="/"
167
+ fi
168
+ fi
169
+
170
+ out=""
171
+ remaining=$p
172
+ while :; do
173
+ case "$remaining" in
174
+ */*)
175
+ s=${remaining%%/*}
176
+ remaining=${remaining#*/}
177
+ ;;
178
+ *)
179
+ s=$remaining
180
+ remaining=""
181
+ ;;
182
+ esac
183
+ case "$s" in
184
+ ''|.) ;;
185
+ ..)
186
+ if [ -n "$out" ]; then
187
+ last=${out##*/}
188
+ else
189
+ last=""
190
+ fi
191
+ if [ -n "$out" ] && [ "$last" != ".." ]; then
192
+ case "$out" in
193
+ */*) out=${out%/*} ;;
194
+ *) out="" ;;
195
+ esac
196
+ elif [ "$isabs" != "1" ]; then
197
+ if [ -n "$out" ]; then
198
+ out="$out/.."
199
+ else
200
+ out=".."
201
+ fi
202
+ fi
203
+ ;;
204
+ *)
205
+ if [ -n "$out" ]; then
206
+ out="$out/$s"
207
+ else
208
+ out=$s
209
+ fi
210
+ ;;
211
+ esac
212
+ [ -n "$remaining" ] || break
213
+ done
214
+
215
+ r=$out
216
+ if [ "$isabs" = "1" ]; then
217
+ r="$root$r"
218
+ fi
219
+ if [ -n "$drive" ]; then
220
+ r="$drive$r"
221
+ fi
222
+ if [ -z "$r" ]; then
223
+ if [ "$isabs" = "1" ]; then
224
+ r=$root
225
+ else
226
+ r="."
227
+ fi
228
+ fi
229
+ printf '%s\n' "$r"
230
+ [ -n "$input" ] || break
231
+ done
232
+ }
233
+
234
+ # hq_realpath_lenient <path>
235
+ # Symlink-resolved path that tolerates a leaf which does not exist yet, and
236
+ # an arbitrarily deep tail of missing parents. Mirrors the semantics the repo
237
+ # guard previously got from `python3 os.path.realpath(dirname(p))` + join
238
+ # leaf: the PARENT chain is symlink-resolved, the leaf is kept verbatim.
239
+ #
240
+ # Implementation is `cd -P` + POSIX shell only. `readlink -f` is deliberately
241
+ # NOT used: macOS ships BSD readlink, which has no -f. python3 is deliberately
242
+ # NOT used: it is absent on many Windows machines, and there the Store alias
243
+ # stub resolves on PATH while failing every call, so a `command -v python3`
244
+ # guard around it silently disables whatever the resolution was protecting.
245
+ #
246
+ # ORDER IS LOAD-BEARING. The existing part of the path is handed to `cd -P`
247
+ # with its `..` segments INTACT, because `..` must be applied by the OS after
248
+ # symlinks are resolved, not collapsed lexically first. Given
249
+ # workspace/link -> repos/private/app/dir, the path
250
+ # workspace/link/../secret.txt really means repos/private/app/secret.txt;
251
+ # normalizing first yields workspace/secret.txt, which escapes a repos/ prefix
252
+ # check entirely. For the same reason `cd` must be `cd -P` — bash's default
253
+ # logical `cd` collapses link/.. to the link's parent, reintroducing the bug.
254
+ # Only the not-yet-existing tail is normalized lexically, since there is no
255
+ # filesystem there to consult (this is what python's realpath does too).
256
+ hq_realpath_lenient() {
257
+ local p="$1" leaf parent head tail resolved
258
+ [ -n "$p" ] || { printf '%s' "$p"; return 0; }
259
+ # Separator folding ONLY where "\" is actually a separator; NO `..` collapsing
260
+ # here — see above. On POSIX a backslash is part of the filename and folding it
261
+ # would make this resolve a path the write will never touch.
262
+ if [ "${HQ_LIB_WINSEP:-0}" = "1" ]; then
263
+ case "$p" in
264
+ *\\*) p="$(printf '%s' "$p" | tr '\\' '/')" ;;
265
+ esac
266
+ fi
267
+ case "$p" in
268
+ */*) leaf="${p##*/}"; parent="${p%/*}"; [ -n "$parent" ] || parent="/" ;;
269
+ *) leaf="$p"; parent="." ;;
270
+ esac
271
+ # Walk up to the deepest ancestor that actually exists; remember the rest.
272
+ # `[ -d ]` follows symlinks, so a head ending in `link/..` tests the real dir.
273
+ head="$parent"; tail=""
274
+ while [ "$head" != "/" ] && [ "$head" != "." ] && [ ! -d "$head" ]; do
275
+ tail="${head##*/}${tail:+/}${tail}"
276
+ case "$head" in
277
+ */*) head="${head%/*}"; [ -n "$head" ] || head="/" ;;
278
+ *) head="." ;;
279
+ esac
280
+ done
281
+ resolved="$(cd -P "$head" 2>/dev/null && pwd -P)" || resolved="$(hq_normpath "$head")"
282
+ [ -n "$tail" ] && resolved="${resolved%/}/$tail"
283
+ hq_normpath "${resolved%/}/$leaf"
284
+ }
285
+
286
+ # hq_realpath_dir <dir>
287
+ # FULL symlink resolution for a directory that exists, including its own leaf;
288
+ # echoes the input unchanged if it is not a directory.
289
+ #
290
+ # Use this for the ROOT side of a prefix comparison. hq_realpath_lenient
291
+ # deliberately leaves the leaf alone, which is right for a write target but
292
+ # wrong for a root: when HQ itself is reached through a symlink (/home/me/hq
293
+ # -> /mnt/data/hq), a leaf-preserving root stays "/home/me/hq" while every
294
+ # fully-resolved file path under it reads "/mnt/data/hq/...", so the prefix
295
+ # test silently never matches and the guard passes everything.
296
+ # `cd -P`, not plain `cd`, for the same reason as hq_realpath_lenient — and it
297
+ # matters MORE here, because the two sides of the prefix test must agree. With
298
+ # link -> real/other and both real/hq and hq existing, a logical `cd` on
299
+ # link/../hq lands on hq while the file side resolves to real/hq/..., the
300
+ # prefix comparison misses, and the guard exits 0.
301
+ # The result is passed through hq_normpath for the same reason: BOTH operands
302
+ # of a prefix comparison must go through the identical normalizer, or a form
303
+ # one side preserves and the other rewrites (a leading "//", a trailing "/")
304
+ # silently breaks the match. Do not "simplify" this by returning pwd -P raw.
305
+ hq_realpath_dir() {
306
+ local d="$1" p
307
+ [ -d "$d" ] || { hq_normpath "$d"; return 0; }
308
+ p="$(cd -P "$d" 2>/dev/null && pwd -P)" || p="$d"
309
+ hq_normpath "$p"
111
310
  }
112
311
 
113
312
  # hq_shell_simple_commands <command>
@@ -129,8 +328,12 @@ hq_shell_simple_commands() {
129
328
  for (j in argv) delete argv[j]
130
329
  argc = 0
131
330
  }
132
- {
133
- text = $0 "\n"
331
+ { source = source $0 "\n" }
332
+ END {
333
+ # Parse the complete payload, not one physical input line at a time.
334
+ # A quoted `bash -c` program and a heredoc can span newlines; resetting
335
+ # quote state at each line silently discarded the command before it.
336
+ text = source
134
337
  n = length(text); quote = ""; escaped = 0; word = ""; argc = 0
135
338
  for (i = 1; i <= n; i++) {
136
339
  c = substr(text, i, 1)
@@ -155,7 +358,12 @@ hq_shell_simple_commands() {
155
358
  }
156
359
  if (c == ">" || c == "<") {
157
360
  push_word(); argv[++argc] = c
158
- if (substr(text, i + 1, 1) == c) { argv[argc] = argv[argc] c; i++ }
361
+ # Keep the Bash noclobber-override redirection (>|) with its target.
362
+ # Treating the pipe byte as a command boundary would otherwise split
363
+ # `printf x >| target` into two harmless-looking records.
364
+ if (substr(text, i + 1, 1) == c || (c == ">" && substr(text, i + 1, 1) == "|")) {
365
+ argv[argc] = argv[argc] substr(text, i + 1, 1); i++
366
+ }
159
367
  continue
160
368
  }
161
369
  word = word c
@@ -174,14 +382,18 @@ hq_shell_command_executable() {
174
382
  local i=0
175
383
  while [ "$i" -lt "${#argv[@]}" ]; do
176
384
  token="${argv[$i]}"
385
+ if [[ "$token" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
386
+ i=$((i + 1)); continue
387
+ fi
177
388
  case "$token" in
178
- [A-Za-z_][A-Za-z0-9_]*=*) i=$((i + 1)); continue ;;
179
389
  env)
180
390
  i=$((i + 1))
181
391
  while [ "$i" -lt "${#argv[@]}" ]; do
182
392
  token="${argv[$i]}"
393
+ if [[ "$token" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
394
+ i=$((i + 1)); continue
395
+ fi
183
396
  case "$token" in
184
- [A-Za-z_][A-Za-z0-9_]*=*) i=$((i + 1)); continue ;;
185
397
  -*) i=$((i + 1)); continue ;;
186
398
  esac
187
399
  break
@@ -354,6 +566,34 @@ hq_path_matches_core_yaml_exclude() {
354
566
  return 1
355
567
  }
356
568
 
569
+ # hq_strip_tokens_containing_literals <command> <literal>...
570
+ # Remove whitespace-delimited command tokens containing any literal needle,
571
+ # preserving separators. This stays in Bash so hook hot paths do not spawn a
572
+ # sed process per configured exclude entry.
573
+ hq_strip_tokens_containing_literals() {
574
+ local input="$1" remaining token after_token separator consumed needle match
575
+ local whitespace_chars=$' \t\n\r'
576
+ shift
577
+ local -a needles=("$@")
578
+ local -a output_parts=()
579
+ remaining="$input"
580
+ while [ -n "$remaining" ]; do
581
+ token="${remaining%%[$whitespace_chars]*}"
582
+ after_token="${remaining:${#token}}"
583
+ separator="${after_token%%[!$whitespace_chars]*}"
584
+ match=no
585
+ for needle in "${needles[@]}"; do
586
+ [ -n "$needle" ] || continue
587
+ case "$token" in *"$needle"*) match=yes; break ;; esac
588
+ done
589
+ [ "$match" = yes ] || output_parts+=("$token")
590
+ output_parts+=("$separator")
591
+ consumed=$((${#token} + ${#separator}))
592
+ remaining="${remaining:$consumed}"
593
+ done
594
+ printf '%s' "${output_parts[@]}"
595
+ }
596
+
357
597
  # hq_bash_strip_core_yaml_exclude_tokens <command> <hq_root> [<core_yaml>]
358
598
  # Best-effort removal of command tokens that reference rules.exclude paths.
359
599
  # Match full root-relative (and absolute) exclude paths — never basename
@@ -363,35 +603,44 @@ hq_bash_strip_core_yaml_exclude_tokens() {
363
603
  local cmd="$1"
364
604
  local hq_root="$2"
365
605
  local core_yaml="${3:-$hq_root/core/core.yaml}"
366
- local exc_path rel abs esc esc_dir sed_script exclude_paths
606
+ local exc_path rel abs exclude_paths stripped abs_paths_text normalized_abs_paths
607
+ local -a exclude_needles=()
367
608
 
368
609
  [ -n "$cmd" ] || { printf '%s' "$cmd"; return 0; }
369
610
  [ -f "$core_yaml" ] || { printf '%s' "$cmd"; return 0; }
370
611
  command -v yq >/dev/null 2>&1 || { printf '%s' "$cmd"; return 0; }
371
612
 
372
613
  hq_root="$(hq_canonical_path "$hq_root")"
373
- sed_script=''
374
614
  exclude_paths="$(yq eval '.rules.exclude[]' "$core_yaml" 2>/dev/null)" || exclude_paths=""
615
+ abs_paths_text=""
375
616
  while IFS= read -r exc_path; do
376
617
  [ -n "$exc_path" ] || continue
377
618
  rel="${exc_path%/}"
378
619
  [ -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
-
620
+ exclude_needles+=("$rel")
383
621
  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;"
622
+ exclude_needles+=("$exc_path")
386
623
  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;"
624
+ # hq_root is already in canonical slash form. Normalize all absolute
625
+ # exclude paths in one Bash pass below instead of launching one command
626
+ # substitution (and, on MSYS, cygpath process) per entry.
627
+ abs_paths_text+="${hq_root%/}/${rel}"$'\n'
391
628
  done <<< "$exclude_paths"
392
629
 
393
- [ -n "$sed_script" ] || { printf '%s' "$cmd"; return 0; }
394
- printf '%s' "$cmd" | sed "$sed_script"
630
+ [ "${#exclude_needles[@]}" -gt 0 ] || { printf '%s' "$cmd"; return 0; }
631
+ normalized_abs_paths="$(hq_normpath "$abs_paths_text")" || normalized_abs_paths=""
632
+ while IFS= read -r abs; do
633
+ [ -n "$abs" ] && exclude_needles+=("$abs")
634
+ done <<< "$normalized_abs_paths"
635
+ stripped="$(hq_strip_tokens_containing_literals "$cmd" "${exclude_needles[@]}")" || stripped=""
636
+ # Never fail open. An empty helper result would make block-core-writes-bash
637
+ # treat the command as touching no protected paths and allow the write.
638
+ # Hand the original command back so the caller still sees the real targets.
639
+ if [ -z "$stripped" ]; then
640
+ printf '%s' "$cmd"
641
+ return 0
642
+ fi
643
+ printf '%s' "$stripped"
395
644
  }
396
645
 
397
646
 
@@ -434,6 +683,36 @@ hq_path_label() {
434
683
  esac
435
684
  }
436
685
 
686
+ # Git Bash/MSYS chmod rewrites NTFS ACLs with DENY ACEs. That can make the
687
+ # owner unable to read a file whose POSIX mode still looks like -rw-r--r--.
688
+ # Never chmod HQ files on Windows; launch with bash instead.
689
+ hq_windows_native_path() {
690
+ local p="${1:-}"
691
+ [ -n "$p" ] || return 1
692
+ if command -v cygpath >/dev/null 2>&1; then
693
+ cygpath -w "$p" 2>/dev/null || printf '%s' "$p"
694
+ return 0
695
+ fi
696
+ printf '%s' "$p"
697
+ }
698
+
699
+ hq_repair_windows_acl() {
700
+ local path="${1:-}" native user
701
+ [ -n "$path" ] || return 1
702
+ [ -e "$path" ] || return 1
703
+ [ -r "$path" ] && return 0
704
+ [ "${HQ_LIB_WINSEP:-0}" = 1 ] || return 1
705
+ command -v icacls >/dev/null 2>&1 || return 1
706
+ native="$(hq_windows_native_path "$path")" || return 1
707
+ user="${USERNAME:-${USER:-}}"
708
+ icacls "$native" /reset >/dev/null 2>&1 || true
709
+ if [ -n "$user" ]; then
710
+ icacls "$native" /remove:d "$user" >/dev/null 2>&1 || true
711
+ icacls "$native" /grant:r "${user}:(RX)" >/dev/null 2>&1 || true
712
+ fi
713
+ [ -r "$path" ]
714
+ }
715
+
437
716
  hq_hook_repair_command() {
438
717
  local root="$1"
439
718
  local path="$2"
@@ -443,6 +722,10 @@ hq_hook_repair_command() {
443
722
  fi
444
723
  rel="$(hq_path_label "$root" "$path")"
445
724
  [ -n "$rel" ] || return 0
725
+ if [ "${HQ_LIB_WINSEP:-0}" = 1 ]; then
726
+ printf 'icacls "$(cygpath -w "$HQ_ROOT/%s")" /grant:r "%%USERNAME%%:(RX)"' "$rel"
727
+ return 0
728
+ fi
446
729
  printf 'chmod u+x "$HQ_ROOT/%s"' "$rel"
447
730
  }
448
731
 
@@ -520,13 +803,24 @@ hq_launch_shell_path() {
520
803
  return 127
521
804
  fi
522
805
 
523
- if [ ! -x "$path" ] && hq_path_within_root "$root" "$path"; then
806
+ if [ ! -r "$path" ] && hq_path_within_root "$root" "$path"; then
807
+ hq_repair_windows_acl "$path" || true
808
+ fi
809
+
810
+ # chmod on Git Bash writes NTFS DENY ACEs; skip it on Windows.
811
+ if [ ! -x "$path" ] && hq_path_within_root "$root" "$path" \
812
+ && [ "${HQ_LIB_WINSEP:-0}" != 1 ]; then
524
813
  chmod u+x "$path" 2>/dev/null || true
525
814
  fi
526
815
 
527
816
  if [ -x "$path" ]; then
528
817
  printf '%s' "$payload" | "$path" "$@"
529
- HQ_HOOK_LAST_STATUS=$?
818
+ # PIPESTATUS[1], never $?. A hook that exits before reading stdin (the
819
+ # `[ -n "${HQ_CONDUCT_RUN_DIR:-}" ] || exit 0` guard shape) closes the read
820
+ # end while printf is still writing, so printf dies with SIGPIPE. Under the
821
+ # `pipefail` that every HQ dispatcher sets, $? is then 141 and a hook that
822
+ # exited 0 is reported as having failed. Read the hook's own element.
823
+ HQ_HOOK_LAST_STATUS=${PIPESTATUS[1]}
530
824
  return "$HQ_HOOK_LAST_STATUS"
531
825
  fi
532
826
 
@@ -536,13 +830,14 @@ hq_launch_shell_path() {
536
830
  fi
537
831
  if [ -r "$path" ] && [ -n "$bash_bin" ]; then
538
832
  printf '%s' "$payload" | "$bash_bin" "$path" "$@"
539
- HQ_HOOK_LAST_STATUS=$?
833
+ # Same contract as the exec path above: the hook's status, not the writer's.
834
+ HQ_HOOK_LAST_STATUS=${PIPESTATUS[1]}
540
835
  return "$HQ_HOOK_LAST_STATUS"
541
836
  fi
542
837
 
543
838
  if [ ! -r "$path" ]; then
544
839
  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"
840
+ HQ_HOOK_LAST_CAUSE="the file is not readable (on Windows, NTFS DENY ACEs can hide it while ls still shows -rw-r--r--)"
546
841
  else
547
842
  HQ_HOOK_LAST_CAUSE="file is not executable or readable"
548
843
  fi