@indigoai-us/hq-cli 5.337.0 → 5.338.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 CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.338.0] — 2026-10-03
6
+ - Preserve policy-trigger mutual exclusion without `flock` and honor configured stale-run thresholds without `yq`.
7
+ - Add a manual macOS VM parity workflow for checking HQ CLI behavior in Tart.
8
+
5
9
  ## [5.337.0] — 2026-10-03
6
10
  - QMD index updates now share the cross-process writer lock and skip bounded SQLite contention so a later sync can catch up.
7
11
  - Self-update now checks the Windows npm prefix that stores command shims, so an unwritable prefix is detected before npm runs.
@@ -124,8 +124,61 @@ fi
124
124
  COOLDOWN_SECONDS="${HQ_MIGRATE_POLICY_TRIGGERS_COOLDOWN_SECONDS:-3600}"
125
125
  STAMP=""
126
126
  LOCK=""
127
+ LOCK_FALLBACK_DIR=""
128
+ LOCK_FALLBACK_HELD=0
129
+ LOCK_FALLBACK_OWNER=""
130
+ LOCK_FALLBACK_STALE_SECONDS=86400
127
131
  STATE_READY=0
128
132
 
133
+ release_fallback_lock() {
134
+ [ "$LOCK_FALLBACK_HELD" = "1" ] || return 0
135
+ [ "$(cat "$LOCK_FALLBACK_DIR/owner" 2>/dev/null)" = "$LOCK_FALLBACK_OWNER" ] || return 0
136
+ rm -f "$LOCK_FALLBACK_DIR/owner" 2>/dev/null || return 0
137
+ rmdir "$LOCK_FALLBACK_DIR" 2>/dev/null || true
138
+ }
139
+
140
+ fallback_lock_is_stale() {
141
+ local lock_dir="$1" modified now age
142
+ modified="$(stat -c '%Y' "$lock_dir" 2>/dev/null || stat -f '%m' "$lock_dir" 2>/dev/null)"
143
+ case "$modified" in ''|*[!0-9]*) return 1 ;; esac
144
+ now="$(date -u '+%s')"
145
+ age=$((now - modified))
146
+ [ "$age" -ge "$LOCK_FALLBACK_STALE_SECONDS" ]
147
+ }
148
+
149
+ reclaim_stale_fallback_lock() {
150
+ local stale_dir="${LOCK_FALLBACK_DIR}.stale.$$.$RANDOM" owner_present=0 owner_value=""
151
+ restore_stale_dir() {
152
+ # Never let mv place the quarantined directory inside a replacement lock.
153
+ # A new holder may have created the path after our rename-aside.
154
+ if [ -e "$LOCK_FALLBACK_DIR" ] || [ -L "$LOCK_FALLBACK_DIR" ]; then return 1; fi
155
+ mv "$stale_dir" "$LOCK_FALLBACK_DIR" 2>/dev/null
156
+ }
157
+ [ -d "$LOCK_FALLBACK_DIR" ] || return 1
158
+ fallback_lock_is_stale "$LOCK_FALLBACK_DIR" || return 1
159
+ # Rename the observed lock aside atomically. Recheck the moved directory so
160
+ # a stale decision is never used to remove a replacement lock at the path.
161
+ mv "$LOCK_FALLBACK_DIR" "$stale_dir" 2>/dev/null || return 1
162
+ if fallback_lock_is_stale "$stale_dir"; then
163
+ if [ -e "$stale_dir/owner" ]; then
164
+ owner_present=1
165
+ owner_value="$(cat "$stale_dir/owner" 2>/dev/null)"
166
+ rm -f "$stale_dir/owner" 2>/dev/null || {
167
+ restore_stale_dir || true
168
+ return 1
169
+ }
170
+ fi
171
+ if rmdir "$stale_dir" 2>/dev/null; then
172
+ return 0
173
+ fi
174
+ if [ "$owner_present" = "1" ]; then
175
+ (set -o noclobber; printf '%s\n' "$owner_value" >"$stale_dir/owner") 2>/dev/null || true
176
+ fi
177
+ fi
178
+ restore_stale_dir || true
179
+ return 1
180
+ }
181
+
129
182
  # Hash the resolved scope rather than using a shared per-root stamp: the
130
183
  # default scan deliberately includes tenant-specific directories.
131
184
  scope_key() {
@@ -192,12 +245,34 @@ fi
192
245
  # A scope-local advisory lock prevents concurrent SessionStart hooks from both
193
246
  # observing the same absent/stale stamp. Recheck after acquiring it because a
194
247
  # prior holder may have completed while this invocation waited to acquire it.
195
- if [ "$STATE_READY" = "1" ] && [ "$DRY" != "1" ] && command -v flock >/dev/null 2>&1; then
196
- if ! exec 9>"$LOCK"; then
197
- exit 0
198
- fi
199
- if ! flock -n 9; then # command -v flock checked above
200
- exit 0
248
+ if [ "$STATE_READY" = "1" ] && [ "$DRY" != "1" ]; then
249
+ if command -v flock >/dev/null 2>&1; then
250
+ if ! exec 9>"$LOCK"; then
251
+ exit 0
252
+ fi
253
+ if ! flock -n 9; then
254
+ exit 0
255
+ fi
256
+ else
257
+ # Atomic directory creation preserves mutual exclusion on stock macOS,
258
+ # which does not ship util-linux flock. An abandoned directory is only
259
+ # reclaimed after a 24-hour age threshold, well above this bounded scan.
260
+ LOCK_FALLBACK_DIR="${LOCK}.d"
261
+ if ! mkdir "$LOCK_FALLBACK_DIR" 2>/dev/null; then
262
+ if reclaim_stale_fallback_lock; then
263
+ mkdir "$LOCK_FALLBACK_DIR" 2>/dev/null || exit 0
264
+ else
265
+ exit 0
266
+ fi
267
+ fi
268
+ LOCK_FALLBACK_HELD=1
269
+ LOCK_FALLBACK_OWNER="$$:$RANDOM:$(date -u '+%s')"
270
+ if ! printf '%s\n' "$LOCK_FALLBACK_OWNER" >"$LOCK_FALLBACK_DIR/owner"; then
271
+ rmdir "$LOCK_FALLBACK_DIR" 2>/dev/null || true
272
+ LOCK_FALLBACK_HELD=0
273
+ exit 0
274
+ fi
275
+ trap release_fallback_lock EXIT
201
276
  fi
202
277
  if cooldown_blocks_run; then
203
278
  exit 0
@@ -127,10 +127,32 @@ _is_pid_alive() {
127
127
  }
128
128
 
129
129
  _stale_minutes() {
130
- if [[ -f "$ORCH_YAML" ]] && command -v yq >/dev/null 2>&1; then
131
- local v
132
- v=$(yq eval '.repo_coordination.stale_heartbeat_minutes // 15' "$ORCH_YAML" 2>/dev/null)
133
- [[ -n "$v" && "$v" != "null" ]] && { echo "$v"; return; }
130
+ if [[ -f "$ORCH_YAML" ]]; then
131
+ if command -v yq >/dev/null 2>&1; then
132
+ local v
133
+ v=$(yq eval '.repo_coordination.stale_heartbeat_minutes // 15' "$ORCH_YAML" 2>/dev/null)
134
+ [[ -n "$v" && "$v" != "null" ]] && { echo "$v"; return; }
135
+ else
136
+ # Parse the one scalar this helper needs when yq is unavailable. Do not
137
+ # silently replace a configured timeout with the 15-minute default.
138
+ awk '
139
+ /^repo_coordination:[[:space:]]*(#.*)?$/ { in_repo=1; next }
140
+ in_repo && /^[^[:space:]#]/ { exit }
141
+ in_repo && /^[[:space:]]+stale_heartbeat_minutes:[[:space:]]*/ {
142
+ sub(/^[[:space:]]+stale_heartbeat_minutes:[[:space:]]*/, "")
143
+ sub(/[[:space:]]+#.*$/, "")
144
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "")
145
+ gsub(/^["\047]|["\047]$/, "")
146
+ if ($0 == "" || $0 == "null" || $0 == "~") print 15
147
+ else if ($0 ~ /^[0-9]+$/) print $0
148
+ else print 15
149
+ found=1
150
+ exit
151
+ }
152
+ END { if (!found) print 15 }
153
+ ' "$ORCH_YAML"
154
+ return
155
+ fi
134
156
  fi
135
157
  echo 15
136
158
  }
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { evaluateTrigger } from "../utils/core-eval-trigger.js";
8
8
  import { awkCannotOpenFileDiagnostic } from "../lib/core-utils/awk-diagnostics.js";
9
9
  export const MIGRATE_POLICY_TRIGGERS_ASSET = "core/scripts/migrate-policy-triggers.sh";
10
+ const FALLBACK_LOCK_STALE_SECONDS = 24 * 60 * 60;
10
11
  const ON_LIVE = "[PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent]";
11
12
  const ON_START = "[SessionStart]";
12
13
  const TAG_STOP = new Set(("infrastructure consolidated ux safety hq hq-core hq-cli hq-packages " +
@@ -173,7 +174,7 @@ function unreadablePolicyDiagnostic(filename, error, pathValue) {
173
174
  const message = code === "EACCES" || code === "EPERM" ? "Permission denied" : "No such file or directory";
174
175
  const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/scaffold/core/scripts/migrate-policy-triggers.sh");
175
176
  const awk = awkCannotOpenFileDiagnostic(filename, message, 1, process.platform, pathValue);
176
- return `${script}: line 314: ${filename}: ${message}\n${awk.repeat(3)}`;
177
+ return `${script}: line 389: ${filename}: ${message}\n${awk.repeat(3)}`;
177
178
  }
178
179
  function shellGlobMd(fileSystem, directory) {
179
180
  try {
@@ -255,20 +256,141 @@ export function runMigratePolicyTriggers(args, deps) {
255
256
  if (cooldownBlocks())
256
257
  return { code: 0, stdout, stderr };
257
258
  let lockFd;
258
- if (stateReady && !dryRun && executableInPath("flock", deps.env, fileSystem)) {
259
+ let fallbackLockDir;
260
+ let fallbackLockOwner;
261
+ const releaseFallbackLock = () => {
262
+ if (!fallbackLockDir)
263
+ return;
259
264
  try {
260
- lockFd = fileSystem.openSync(lock, "w");
265
+ if (fileSystem.readFileSync(path.join(fallbackLockDir, "owner"), "utf8").trim() === fallbackLockOwner) {
266
+ fileSystem.unlinkSync(path.join(fallbackLockDir, "owner"));
267
+ fileSystem.rmdirSync(fallbackLockDir);
268
+ }
269
+ }
270
+ catch { /* preserve the lock if ownership cannot be confirmed */ }
271
+ fallbackLockDir = undefined;
272
+ };
273
+ const reclaimStaleFallbackLock = (directory) => {
274
+ const restoreQuarantined = (staleDirectory) => {
275
+ try {
276
+ fileSystem.lstatSync(directory);
277
+ return;
278
+ }
279
+ catch (error) {
280
+ if (error.code !== "ENOENT")
281
+ return;
282
+ }
283
+ try {
284
+ fileSystem.renameSync(staleDirectory, directory);
285
+ }
286
+ catch { /* preserve the quarantine if restoration fails */ }
287
+ };
288
+ const age = (candidate) => {
289
+ try {
290
+ return Math.floor(deps.now().getTime() / 1000) - Math.floor(fileSystem.statSync(candidate).mtimeMs / 1000);
291
+ }
292
+ catch {
293
+ return undefined;
294
+ }
295
+ };
296
+ if ((age(directory) ?? -1) < FALLBACK_LOCK_STALE_SECONDS)
297
+ return false;
298
+ const staleDirectory = `${directory}.stale.${process.pid}.${Math.random().toString(16).slice(2)}`;
299
+ try {
300
+ fileSystem.renameSync(directory, staleDirectory);
261
301
  }
262
302
  catch {
263
- return { code: 0, stdout, stderr };
303
+ return false;
264
304
  }
265
- const lockResult = (deps.runFlock ?? runNonblockingFlock)(lockFd, deps.cwd, deps.env.PATH ?? "");
266
- if (lockResult.error || lockResult.status !== 0) {
267
- fileSystem.closeSync(lockFd);
268
- return { code: 0, stdout, stderr };
305
+ const staleAge = age(staleDirectory);
306
+ if (staleAge !== undefined && staleAge >= FALLBACK_LOCK_STALE_SECONDS) {
307
+ let ownerBytes;
308
+ try {
309
+ ownerBytes = fileSystem.readFileSync(path.join(staleDirectory, "owner"), "utf8");
310
+ }
311
+ catch (error) {
312
+ if (error.code !== "ENOENT") {
313
+ restoreQuarantined(staleDirectory);
314
+ return false;
315
+ }
316
+ }
317
+ if (ownerBytes !== undefined) {
318
+ try {
319
+ fileSystem.unlinkSync(path.join(staleDirectory, "owner"));
320
+ }
321
+ catch {
322
+ restoreQuarantined(staleDirectory);
323
+ return false;
324
+ }
325
+ }
326
+ try {
327
+ fileSystem.rmdirSync(staleDirectory);
328
+ return true;
329
+ }
330
+ catch {
331
+ if (ownerBytes !== undefined) {
332
+ try {
333
+ fileSystem.writeFileSync(path.join(staleDirectory, "owner"), ownerBytes, { encoding: "utf8", mode: 0o600, flag: "wx" });
334
+ }
335
+ catch { /* keep the quarantined directory rather than overwrite a changed marker */ }
336
+ }
337
+ restoreQuarantined(staleDirectory);
338
+ return false;
339
+ }
340
+ }
341
+ restoreQuarantined(staleDirectory);
342
+ return false;
343
+ };
344
+ if (stateReady && !dryRun) {
345
+ if (executableInPath("flock", deps.env, fileSystem)) {
346
+ try {
347
+ lockFd = fileSystem.openSync(lock, "w");
348
+ }
349
+ catch {
350
+ return { code: 0, stdout, stderr };
351
+ }
352
+ const lockResult = (deps.runFlock ?? runNonblockingFlock)(lockFd, deps.cwd, deps.env.PATH ?? "");
353
+ if (lockResult.error || lockResult.status !== 0) {
354
+ fileSystem.closeSync(lockFd);
355
+ return { code: 0, stdout, stderr };
356
+ }
357
+ }
358
+ else {
359
+ fallbackLockDir = `${lock}.d`;
360
+ try {
361
+ fileSystem.mkdirSync(fallbackLockDir);
362
+ }
363
+ catch {
364
+ if (!reclaimStaleFallbackLock(fallbackLockDir)) {
365
+ fallbackLockDir = undefined;
366
+ return { code: 0, stdout, stderr };
367
+ }
368
+ try {
369
+ fileSystem.mkdirSync(fallbackLockDir);
370
+ }
371
+ catch {
372
+ fallbackLockDir = undefined;
373
+ return { code: 0, stdout, stderr };
374
+ }
375
+ }
376
+ fallbackLockOwner = `${process.pid}:${Math.random().toString(36).slice(2)}:${nowSeconds()}`;
377
+ try {
378
+ fileSystem.writeFileSync(path.join(fallbackLockDir, "owner"), `${fallbackLockOwner}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
379
+ }
380
+ catch {
381
+ try {
382
+ fileSystem.rmdirSync(fallbackLockDir);
383
+ }
384
+ catch { /* preserve a lock if cleanup is unsafe */ }
385
+ fallbackLockDir = undefined;
386
+ fallbackLockOwner = undefined;
387
+ return { code: 0, stdout, stderr };
388
+ }
269
389
  }
270
390
  if (cooldownBlocks()) {
271
- fileSystem.closeSync(lockFd);
391
+ if (lockFd !== undefined)
392
+ fileSystem.closeSync(lockFd);
393
+ releaseFallbackLock();
272
394
  return { code: 0, stdout, stderr };
273
395
  }
274
396
  }
@@ -363,6 +485,7 @@ export function runMigratePolicyTriggers(args, deps) {
363
485
  finally {
364
486
  if (lockFd !== undefined)
365
487
  fileSystem.closeSync(lockFd);
488
+ releaseFallbackLock();
366
489
  }
367
490
  return { code: errorCode, stdout, stderr };
368
491
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.337.0",
3
+ "version": "5.338.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {