@indigoai-us/hq-cli 5.315.0 → 5.316.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 (40) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/assets/scaffold/core/scripts/detect-stale-review-base.sh +199 -0
  3. package/assets/scaffold/core/scripts/repo-run-registry.sh +408 -0
  4. package/assets/scaffold/core/scripts/share-suggestion-state.sh +541 -0
  5. package/dist/command-catalog.generated.d.ts +45 -0
  6. package/dist/command-catalog.generated.js +56 -0
  7. package/dist/commands/bot-jobs.d.ts +5 -1
  8. package/dist/commands/bot-jobs.js +24 -4
  9. package/dist/commands/core-detect-stale-review-base-native.d.ts +23 -0
  10. package/dist/commands/core-detect-stale-review-base-native.js +178 -0
  11. package/dist/commands/core-hook-state-helpers-native.d.ts +11 -0
  12. package/dist/commands/core-hook-state-helpers-native.js +74 -0
  13. package/dist/commands/core-repo-run-registry-native.d.ts +19 -0
  14. package/dist/commands/core-repo-run-registry-native.js +357 -0
  15. package/dist/commands/core-share-suggestion-state-native.d.ts +18 -0
  16. package/dist/commands/core-share-suggestion-state-native.js +445 -0
  17. package/dist/commands/core.js +5 -0
  18. package/dist/commands/lanes.js +35 -5
  19. package/dist/fast-core.d.ts +1 -0
  20. package/dist/fast-core.js +10 -6
  21. package/dist/fast-scaffold.js +17 -0
  22. package/dist/lib/bot/job-quiet-flag.d.ts +4 -0
  23. package/dist/lib/bot/job-quiet-flag.js +14 -0
  24. package/dist/lib/bot/job-run.d.ts +5 -0
  25. package/dist/lib/bot/job-run.js +23 -3
  26. package/dist/lib/bot/run.d.ts +2 -0
  27. package/dist/lib/bot/run.js +15 -3
  28. package/dist/lib/core-utils/common.d.ts +0 -1
  29. package/dist/lib/core-utils/common.js +0 -9
  30. package/dist/lib/lanes/autopilot.d.ts +5 -1
  31. package/dist/lib/lanes/autopilot.js +72 -3
  32. package/dist/lib/lanes/disk-guard.d.ts +3 -4
  33. package/dist/lib/lanes/disk-guard.js +2 -2
  34. package/dist/lib/lanes/flag-keys.d.ts +3 -0
  35. package/dist/lib/lanes/flag-keys.js +3 -0
  36. package/dist/lib/lanes/hooks-install.js +78 -2
  37. package/dist/lib/lanes/keep-alive-audit.d.ts +6 -5
  38. package/dist/lib/lanes/outpost-dispatch.d.ts +9 -0
  39. package/dist/lib/lanes/outpost-dispatch.js +22 -0
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.316.0] — 2026-10-02
6
+ - Add a default-off autopilot notice for registered lead headlines that remain stale for more than 55 minutes, using `headline_at` or the timestamp prefix in `now`.
7
+ - Add a flag-gated per-job option to complete successful scheduled bot runs without an owner DM; failures still report normally.
8
+ - Port `share-suggestion-state`, `repo-run-registry`, and `detect-stale-review-base` to native TypeScript on the fast route behind the default-off `core-native-hook-helpers` flag.
9
+ - Detect the installed hq-cli drain entry in provider settings so lane messages do not warn when the running worker can drain its inbox.
10
+ - Resolve hostless Outpost lane lookups by ULID without transporting the full lane list, including compatibility with older Outpost CLIs.
11
+ - Refuse to publish a stable or rc release tag unless its commit is on main, its version is higher than npm latest, and it contains the commit npm latest was built from. This stops a tag on an old commit from putting older code on npm as latest.
12
+ - Remove an unused file-exists helper and stop exporting four disk-guard internals. No behaviour change.
13
+
5
14
  ## [5.315.0] — 2026-10-02
6
15
  - The CLI loads the Sentry error-reporting library only when it first needs it, so commands that report nothing start with fewer imports. Reporting, scrubbing and flushing work as before.
7
16
  - Keep first-sync guidance enabled without its rollout gate and remove the obsolete plan-limit fallback flag-off test.
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env bash
2
+ # detect-stale-review-base.sh — find remote-tracking refs in the HQ root that
3
+ # have fallen so far behind local HEAD that a diff/review tool will pick one as
4
+ # its comparison base and try to review the entire tree.
5
+ #
6
+ # Background: HQ autosaves one commit per edited file (.claude/hooks/hq-autocommit.sh)
7
+ # and is never pushed to a remote (policy hq-root-never-push-remote). An HQ root
8
+ # that is itself a git repo with a remote attached therefore accumulates local
9
+ # commits the remote-tracking ref never receives. The gap only grows.
10
+ #
11
+ # Review tools that auto-select a base branch resolve it from refs/remotes/*.
12
+ # Once the gap is large enough that base is effectively "the empty tree", so the
13
+ # review payload becomes the whole repository instead of the working delta. The
14
+ # observed failure (2026-07-28) was a Codex desktop git worker taking SIGTRAP on
15
+ # a 108,434-path / 29.2 MB payload built against a remote-tracking ref 22,881
16
+ # commits behind local main, retried until the app reached 5.5 GB RSS.
17
+ #
18
+ # Codex exposes no configuration for the review base — `review_model` only picks
19
+ # the reviewing model, and `auto_review` governs the approval-reviewer subagent,
20
+ # not code review. `codex review --base` is per-invocation and does not reach the
21
+ # desktop app's automatic review. Removing the stale ref is therefore the only
22
+ # lever available on the HQ side: with nothing stale to resolve, there is no
23
+ # whole-tree base to pick.
24
+ #
25
+ # Modes:
26
+ # (default) human-readable report; exit 0.
27
+ # --json machine-readable JSON array; exit 0.
28
+ # --check exit 0 if clean, 1 if any stale base exists (advisory gate).
29
+ # --fix delete the offending remote-tracking refs; print each action.
30
+ #
31
+ # --fix deletes ONLY refs under refs/remotes/. It never touches refs/heads/,
32
+ # never removes a configured remote, never fetches, and never pushes. The
33
+ # deletion is recoverable: `git fetch <remote>` recreates the ref (and, on an HQ
34
+ # root, recreates this condition — HQ roots are not meant to be fetched).
35
+ #
36
+ # Thresholds (override with flags or env):
37
+ # --max-paths N changed paths tolerated vs. a base (default 5000)
38
+ # --max-commits N commits ahead tolerated vs. a base (default 1000)
39
+ # A ref is stale when EITHER threshold is exceeded. Path count is the primary
40
+ # signal: it is what determines the review payload size.
41
+
42
+ set -euo pipefail
43
+
44
+ MODE="report"
45
+ MAX_PATHS="${HQ_REVIEW_BASE_MAX_PATHS:-5000}"
46
+ MAX_COMMITS="${HQ_REVIEW_BASE_MAX_COMMITS:-1000}"
47
+ ROOT_OVERRIDE=""
48
+
49
+ while [ $# -gt 0 ]; do
50
+ case "$1" in
51
+ --json) MODE="json"; shift ;;
52
+ --check) MODE="check"; shift ;;
53
+ --fix) MODE="fix"; shift ;;
54
+ --max-paths) MAX_PATHS="${2:?--max-paths needs a value}"; shift 2 ;;
55
+ --max-commits) MAX_COMMITS="${2:?--max-commits needs a value}"; shift 2 ;;
56
+ --root) ROOT_OVERRIDE="${2:?--root needs a value}"; shift 2 ;;
57
+ -h|--help) sed -n '2,41p' "$0"; exit 0 ;;
58
+ *)
59
+ printf 'detect-stale-review-base: unknown argument: %s\n' "$1" >&2
60
+ printf 'usage: %s [--json|--check|--fix] [--max-paths N] [--max-commits N] [--root PATH]\n' "$0" >&2
61
+ exit 2 ;;
62
+ esac
63
+ done
64
+
65
+ case "$MAX_PATHS" in ''|*[!0-9]*)
66
+ echo "detect-stale-review-base: --max-paths must be a non-negative integer" >&2; exit 2 ;;
67
+ esac
68
+ case "$MAX_COMMITS" in ''|*[!0-9]*)
69
+ echo "detect-stale-review-base: --max-commits must be a non-negative integer" >&2; exit 2 ;;
70
+ esac
71
+
72
+ # Resolve HQ root: explicit flag wins, then env, then the tree this script ships in.
73
+ # Physical paths (pwd -P) so the nesting guard below cannot be dodged through a
74
+ # symlink to a clone.
75
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
76
+ HQ_ROOT="${ROOT_OVERRIDE:-${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd -P)}}}"
77
+
78
+ if [ ! -d "$HQ_ROOT" ]; then
79
+ echo "detect-stale-review-base: no such directory: $HQ_ROOT" >&2
80
+ exit 2
81
+ fi
82
+ HQ_ROOT="$(cd "$HQ_ROOT" && pwd -P)"
83
+
84
+ emit_clean() {
85
+ case "$MODE" in
86
+ json) echo "[]" ;;
87
+ check) : ;;
88
+ fix) printf 'detect-stale-review-base: nothing to fix (%s)\n' "$1" ;;
89
+ *) printf 'detect-stale-review-base: clean (%s)\n' "$1" ;;
90
+ esac
91
+ exit 0
92
+ }
93
+
94
+ # Only ever operate on an HQ root. This guard is what keeps the tool away from
95
+ # repos/ working clones, where a remote-tracking ref far behind local HEAD is
96
+ # ordinary and its refs must never be deleted.
97
+ if [ ! -f "$HQ_ROOT/core/core.yaml" ]; then
98
+ echo "detect-stale-review-base: $HQ_ROOT is not an HQ root (no core/core.yaml)" >&2
99
+ exit 2
100
+ fi
101
+
102
+ # A repos/ clone of hq-core is itself HQ-root-shaped — core/core.yaml ships in
103
+ # the repo, so the check above cannot tell it from a real install. What does
104
+ # distinguish a working clone is position: clones and worktree checkouts live
105
+ # NESTED inside another HQ root (repos/, workspace/worktrees/), while a real HQ
106
+ # root never does. Refuse any candidate with an HQ-root-shaped ancestor rather
107
+ # than trusting the caller.
108
+ ANCESTOR="$(dirname "$HQ_ROOT")"
109
+ while [ -n "$ANCESTOR" ] && [ "$ANCESTOR" != "/" ] && [ "$ANCESTOR" != "." ]; do
110
+ if [ -f "$ANCESTOR/core/core.yaml" ]; then
111
+ echo "detect-stale-review-base: $HQ_ROOT is nested inside the HQ root $ANCESTOR — refusing. Working clones and worktree checkouts keep their remote-tracking refs; this tool only repairs a top-level HQ install." >&2
112
+ exit 2
113
+ fi
114
+ ANCESTOR="$(dirname "$ANCESTOR")"
115
+ done
116
+
117
+ if [ "$(git -C "$HQ_ROOT" rev-parse --show-toplevel 2>/dev/null || true)" != "$HQ_ROOT" ]; then
118
+ emit_clean "HQ root is not a git repository — no review base to go stale"
119
+ fi
120
+
121
+ if ! git -C "$HQ_ROOT" rev-parse --verify -q HEAD >/dev/null 2>&1; then
122
+ emit_clean "HQ root has no commits yet"
123
+ fi
124
+
125
+ REFS="$(git -C "$HQ_ROOT" for-each-ref --format='%(refname)' refs/remotes 2>/dev/null || true)"
126
+ if [ -z "$REFS" ]; then
127
+ emit_clean "no remote-tracking refs in the HQ root"
128
+ fi
129
+
130
+ STALE_REFS=""
131
+ STALE_JSON=""
132
+ STALE_COUNT=0
133
+
134
+ while IFS= read -r ref; do
135
+ [ -n "$ref" ] || continue
136
+ # Symbolic refs (refs/remotes/<remote>/HEAD) resolve to a ref already covered.
137
+ if git -C "$HQ_ROOT" symbolic-ref -q "$ref" >/dev/null 2>&1; then
138
+ continue
139
+ fi
140
+
141
+ commits="$(git -C "$HQ_ROOT" rev-list --count "$ref..HEAD" 2>/dev/null || true)"
142
+ [ -n "$commits" ] || continue
143
+
144
+ # Three-dot: paths changed since the merge base — what a review of
145
+ # "HEAD against this base" would actually carry. This is the expensive probe
146
+ # on exactly the repos this tool exists for (it IS the whole-tree diff), so
147
+ # skip it when the commit gap alone already proves staleness and the mode
148
+ # does not display counts. --json always measures both.
149
+ paths=""
150
+ if [ "$commits" -le "$MAX_COMMITS" ] || [ "$MODE" = "json" ]; then
151
+ paths="$(git -C "$HQ_ROOT" diff --name-only "$ref...HEAD" 2>/dev/null | wc -l | tr -d ' ')"
152
+ [ -n "$paths" ] || paths=0
153
+ fi
154
+
155
+ if [ "$commits" -le "$MAX_COMMITS" ] && [ "$paths" -le "$MAX_PATHS" ]; then
156
+ continue
157
+ fi
158
+
159
+ STALE_COUNT=$((STALE_COUNT + 1))
160
+ STALE_REFS="${STALE_REFS}${ref}"$'\n'
161
+ if [ "$MODE" = "json" ]; then
162
+ [ -z "$STALE_JSON" ] || STALE_JSON="${STALE_JSON},"
163
+ STALE_JSON="${STALE_JSON}{\"ref\":\"${ref}\",\"commits_ahead\":${commits},\"changed_paths\":${paths}}"
164
+ fi
165
+ done <<EOF
166
+ $REFS
167
+ EOF
168
+
169
+ if [ "$STALE_COUNT" -eq 0 ]; then
170
+ emit_clean "every remote-tracking ref is within ${MAX_PATHS} paths / ${MAX_COMMITS} commits of HEAD"
171
+ fi
172
+
173
+ case "$MODE" in
174
+ json)
175
+ printf '[%s]\n' "$STALE_JSON"
176
+ ;;
177
+ check)
178
+ printf 'detect-stale-review-base: %s stale review base(s) in %s\n' "$STALE_COUNT" "$HQ_ROOT" >&2
179
+ exit 1
180
+ ;;
181
+ fix)
182
+ printf '%s' "$STALE_REFS" | while IFS= read -r ref; do
183
+ [ -n "$ref" ] || continue
184
+ git -C "$HQ_ROOT" update-ref -d "$ref"
185
+ printf 'detect-stale-review-base: deleted %s\n' "$ref"
186
+ done
187
+ printf 'detect-stale-review-base: removed %s stale review base(s). A later `git fetch` in the HQ root recreates them — HQ roots are not meant to be fetched (policy hq-root-never-push-remote).\n' "$STALE_COUNT"
188
+ ;;
189
+ *)
190
+ printf 'detect-stale-review-base: %s stale review base(s) in %s\n\n' "$STALE_COUNT" "$HQ_ROOT"
191
+ printf '%s' "$STALE_REFS" | while IFS= read -r ref; do
192
+ [ -n "$ref" ] || continue
193
+ c="$(git -C "$HQ_ROOT" rev-list --count "$ref..HEAD" 2>/dev/null || echo '?')"
194
+ p="$(git -C "$HQ_ROOT" diff --name-only "$ref...HEAD" 2>/dev/null | wc -l | tr -d ' ')"
195
+ printf ' %s — HEAD is %s commits ahead, %s changed paths\n' "$ref" "$c" "$p"
196
+ done
197
+ printf '\nA review tool that auto-selects a base will pick one of these and build a\nwhole-tree payload from it. Remove them with:\n\n bash core/scripts/detect-stale-review-base.sh --fix\n'
198
+ ;;
199
+ esac
@@ -0,0 +1,408 @@
1
+ #!/bin/bash
2
+ # repo-run-registry.sh — CLI for cross-session repo-level run coordination
3
+ #
4
+ # Manages workspace/orchestrator/active-runs.json: a global registry of
5
+ # long-running Claude sessions that currently hold a repo (scope=repo) or
6
+ # a specific worktree (scope=worktree:<path>). Other sessions consult this
7
+ # registry at SessionStart and before Edit/Write/dangerous Bash to avoid
8
+ # concurrent edit conflicts.
9
+ #
10
+ # See plan: ~/.claude/plans/structured-stargazing-candy.md
11
+ # Policy: core/policies/repo-run-coordination.md
12
+ #
13
+ # Subcommands:
14
+ # register --run-id X --pid N --session-id S --command C --project P --repo R --scope SC [--host H]
15
+ # deregister --run-id X
16
+ # heartbeat --run-id X
17
+ # list
18
+ # check --target PATH [--pid N] [--session-id S]
19
+ # clean-stale
20
+ # owner-of --path PATH
21
+ #
22
+ # Exit codes:
23
+ # 0 = ok / not blocked
24
+ # 2 = blocked by foreign owner (check only)
25
+ # 1 = usage / internal error
26
+
27
+ set -euo pipefail
28
+
29
+ HQ_ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
30
+ # shellcheck source=core/scripts/lib/portable.sh
31
+ . "$HQ_ROOT/core/scripts/lib/portable.sh"
32
+ REG_DIR="$HQ_ROOT/workspace/orchestrator"
33
+ REG_FILE="$REG_DIR/active-runs.json"
34
+ LOCK_DIR="$REG_FILE.lock"
35
+ # Resolve the orchestrator settings file. personal/settings is read DIRECTLY now
36
+ # (the reindex symlink mirror into core/settings is retired). orchestrator.yaml
37
+ # ships as a core default; unlike the list-shaped overlays (policies/workers/
38
+ # knowledge, where core + personal BOTH surface), this is a singleton config
39
+ # file — so a personal copy, when present, overrides the shipped default.
40
+ ORCH_YAML="$HQ_ROOT/core/settings/orchestrator.yaml"
41
+ if [[ -f "$HQ_ROOT/personal/settings/orchestrator.yaml" ]]; then
42
+ ORCH_YAML="$HQ_ROOT/personal/settings/orchestrator.yaml"
43
+ fi
44
+
45
+ # ---------- helpers ----------
46
+
47
+ _log() { echo "[repo-run-registry] $*" >&2; }
48
+ _die() { echo "ERROR: $*" >&2; exit 1; }
49
+
50
+ _iso_now() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
51
+
52
+ # ISO8601 (UTC, trailing Z) → epoch seconds. BSD date first, then GNU date.
53
+ # A BSD-only parse returned 0 on Linux, so _prune_stale treated every live
54
+ # run as stale and deleted it before any check could block on it.
55
+ _iso_to_epoch() {
56
+ local iso="$1"
57
+ date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$iso" +%s 2>/dev/null \
58
+ || date -u -d "$iso" +%s 2>/dev/null \
59
+ || echo 0
60
+ }
61
+
62
+ _hostname() { hostname -s 2>/dev/null || echo "unknown"; }
63
+
64
+ _abs_path() {
65
+ local p="$1"
66
+ if [[ "$p" = /* ]]; then
67
+ echo "$p"
68
+ else
69
+ (cd "$(dirname "$p")" 2>/dev/null && echo "$(pwd)/$(basename "$p")") || echo "$p"
70
+ fi
71
+ }
72
+
73
+ _ensure_reg() {
74
+ mkdir -p "$REG_DIR"
75
+ if [[ ! -f "$REG_FILE" ]]; then
76
+ echo '{"version":1,"runs":[]}' > "$REG_FILE"
77
+ fi
78
+ }
79
+
80
+ # Atomic mutex via mkdir — single-machine only
81
+ _lock() { _try_lock || _die "registry lock timeout"; }
82
+
83
+ # Returns 1 instead of exiting when the lock stays busy (~5s).
84
+ _try_lock() {
85
+ local tries=0
86
+ local max=50
87
+ # register takes the lock before _ensure_reg; without its parent the mkdir
88
+ # below can never succeed and the first register on a fresh install timed out.
89
+ mkdir -p "$REG_DIR" 2>/dev/null || true
90
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do
91
+ tries=$((tries + 1))
92
+ if [[ $tries -ge $max ]]; then
93
+ # stale lock fallback: remove if older than 60s
94
+ if [[ -d "$LOCK_DIR" ]]; then
95
+ local age mtime
96
+ mtime="$(portable_stat_mtime "$LOCK_DIR" 2>/dev/null || echo 0)"
97
+ age=$(( $(date +%s) - mtime ))
98
+ if [[ $age -gt 60 ]]; then
99
+ rm -rf "$LOCK_DIR" 2>/dev/null || true
100
+ continue
101
+ fi
102
+ fi
103
+ return 1
104
+ fi
105
+ sleep 0.1
106
+ done
107
+ }
108
+ _unlock() { rm -rf "$LOCK_DIR" 2>/dev/null || true; }
109
+
110
+ # list, owner-of and check prune too. Hold the lock so a reader cannot write an
111
+ # older snapshot over a concurrent register or heartbeat. If the lock stays
112
+ # busy, skip the prune and answer from the file as it is.
113
+ _prune_stale_locked() {
114
+ _try_lock || return 0
115
+ trap '_unlock' EXIT
116
+ _prune_stale >/dev/null 2>&1 || true
117
+ _unlock
118
+ trap - EXIT
119
+ }
120
+
121
+ _is_pid_alive() {
122
+ local pid="$1"
123
+ [[ -z "$pid" || "$pid" == "null" ]] && return 1
124
+ kill -0 "$pid" 2>/dev/null
125
+ }
126
+
127
+ _stale_minutes() {
128
+ if [[ -f "$ORCH_YAML" ]] && command -v yq >/dev/null 2>&1; then
129
+ local v
130
+ v=$(yq eval '.repo_coordination.stale_heartbeat_minutes // 15' "$ORCH_YAML" 2>/dev/null)
131
+ [[ -n "$v" && "$v" != "null" ]] && { echo "$v"; return; }
132
+ fi
133
+ echo 15
134
+ }
135
+
136
+ # Given a target filesystem path, walk up to find the nearest ancestor that
137
+ # is either a git repo root OR a HQ orchestrator-known repo path. Emits the
138
+ # absolute path of the owning repo (or empty string if none).
139
+ _find_owning_repo() {
140
+ local target
141
+ target=$(_abs_path "$1")
142
+ [[ -z "$target" ]] && return
143
+ # If target doesn't exist, walk up to an existing ancestor first
144
+ while [[ -n "$target" && ! -e "$target" ]]; do
145
+ local parent
146
+ parent=$(dirname "$target")
147
+ [[ "$parent" == "$target" ]] && return
148
+ target="$parent"
149
+ done
150
+ # Now walk up looking for .git
151
+ local dir="$target"
152
+ [[ -f "$dir" ]] && dir=$(dirname "$dir")
153
+ while [[ "$dir" != "/" && -n "$dir" ]]; do
154
+ if [[ -e "$dir/.git" ]]; then
155
+ echo "$dir"
156
+ return
157
+ fi
158
+ dir=$(dirname "$dir")
159
+ done
160
+ echo ""
161
+ }
162
+
163
+ # Prune stale entries: dead PID OR heartbeat older than stale_minutes.
164
+ # Emits the number of pruned entries on stderr.
165
+ _prune_stale() {
166
+ _ensure_reg
167
+ local stale_min
168
+ stale_min=$(_stale_minutes)
169
+ local now_epoch
170
+ now_epoch=$(date +%s)
171
+ local cutoff=$((now_epoch - stale_min * 60))
172
+
173
+ # Build a jq filter that keeps only entries with fresh heartbeat; then
174
+ # separately filter dead PIDs in bash.
175
+ local tmp="$REG_FILE.tmp.$$"
176
+ local kept='[]'
177
+ local runs
178
+ runs=$(jq -c '.runs[]' "$REG_FILE" 2>/dev/null || true)
179
+ local pruned=0
180
+ if [[ -n "$runs" ]]; then
181
+ while IFS= read -r entry; do
182
+ [[ -z "$entry" ]] && continue
183
+ local pid hb_iso hb_epoch
184
+ pid=$(echo "$entry" | jq -r '.pid // empty')
185
+ hb_iso=$(echo "$entry" | jq -r '.heartbeat_at // .started_at // empty')
186
+ hb_epoch=$(_iso_to_epoch "$hb_iso")
187
+ if ! _is_pid_alive "$pid"; then
188
+ pruned=$((pruned + 1))
189
+ continue
190
+ fi
191
+ if [[ $hb_epoch -lt $cutoff ]]; then
192
+ pruned=$((pruned + 1))
193
+ continue
194
+ fi
195
+ kept=$(echo "$kept" | jq --argjson e "$entry" '. + [$e]')
196
+ done <<< "$runs"
197
+ fi
198
+ jq --argjson runs "$kept" '.runs = $runs' "$REG_FILE" > "$tmp"
199
+ mv "$tmp" "$REG_FILE"
200
+ [[ $pruned -gt 0 ]] && _log "pruned $pruned stale entries"
201
+ return 0
202
+ }
203
+
204
+ # ---------- subcommands ----------
205
+
206
+ _cmd_register() {
207
+ local run_id="" pid="" session_id="" command="" project="" repo="" scope="" host=""
208
+ while [[ $# -gt 0 ]]; do
209
+ case "$1" in
210
+ --run-id) run_id="$2"; shift 2 ;;
211
+ --pid) pid="$2"; shift 2 ;;
212
+ --session-id) session_id="$2"; shift 2 ;;
213
+ --command) command="$2"; shift 2 ;;
214
+ --project) project="$2"; shift 2 ;;
215
+ --repo) repo="$2"; shift 2 ;;
216
+ --scope) scope="$2"; shift 2 ;;
217
+ --host) host="$2"; shift 2 ;;
218
+ *) shift ;;
219
+ esac
220
+ done
221
+ [[ -z "$pid" || -z "$command" || -z "$repo" || -z "$scope" ]] && \
222
+ _die "register requires --pid --command --repo --scope"
223
+ repo=$(_abs_path "$repo")
224
+ [[ -z "$run_id" ]] && run_id="$(echo "$command" | tr '/' '-')-$(date +%s)-${project:-none}-$pid"
225
+ [[ -z "$host" ]] && host=$(_hostname)
226
+
227
+ _lock
228
+ trap '_unlock' EXIT
229
+ _ensure_reg
230
+ _prune_stale
231
+
232
+ local now
233
+ now=$(_iso_now)
234
+ local tmp="$REG_FILE.tmp.$$"
235
+ jq --arg run_id "$run_id" \
236
+ --arg pid "$pid" \
237
+ --arg session_id "$session_id" \
238
+ --arg command "$command" \
239
+ --arg project "$project" \
240
+ --arg repo "$repo" \
241
+ --arg scope "$scope" \
242
+ --arg host "$host" \
243
+ --arg now "$now" \
244
+ '.runs |= map(select(.run_id != $run_id)) |
245
+ .runs += [{
246
+ run_id: $run_id,
247
+ pid: ($pid | tonumber),
248
+ session_id: $session_id,
249
+ command: $command,
250
+ project: $project,
251
+ repo_path: $repo,
252
+ scope: $scope,
253
+ host: $host,
254
+ started_at: $now,
255
+ heartbeat_at: $now
256
+ }]' "$REG_FILE" > "$tmp"
257
+ mv "$tmp" "$REG_FILE"
258
+ _unlock
259
+ trap - EXIT
260
+ echo "$run_id"
261
+ }
262
+
263
+ _cmd_deregister() {
264
+ local run_id=""
265
+ while [[ $# -gt 0 ]]; do
266
+ case "$1" in
267
+ --run-id) run_id="$2"; shift 2 ;;
268
+ *) shift ;;
269
+ esac
270
+ done
271
+ [[ -z "$run_id" ]] && _die "deregister requires --run-id"
272
+ _lock
273
+ trap '_unlock' EXIT
274
+ _ensure_reg
275
+ local tmp="$REG_FILE.tmp.$$"
276
+ jq --arg run_id "$run_id" '.runs |= map(select(.run_id != $run_id))' "$REG_FILE" > "$tmp"
277
+ mv "$tmp" "$REG_FILE"
278
+ _unlock
279
+ trap - EXIT
280
+ }
281
+
282
+ _cmd_heartbeat() {
283
+ local run_id=""
284
+ while [[ $# -gt 0 ]]; do
285
+ case "$1" in
286
+ --run-id) run_id="$2"; shift 2 ;;
287
+ *) shift ;;
288
+ esac
289
+ done
290
+ [[ -z "$run_id" ]] && _die "heartbeat requires --run-id"
291
+ _lock
292
+ trap '_unlock' EXIT
293
+ _ensure_reg
294
+ local now
295
+ now=$(_iso_now)
296
+ local tmp="$REG_FILE.tmp.$$"
297
+ jq --arg run_id "$run_id" --arg now "$now" \
298
+ '.runs |= map(if .run_id == $run_id then .heartbeat_at = $now else . end)' \
299
+ "$REG_FILE" > "$tmp"
300
+ mv "$tmp" "$REG_FILE"
301
+ _unlock
302
+ trap - EXIT
303
+ }
304
+
305
+ _cmd_list() {
306
+ _ensure_reg
307
+ _prune_stale_locked
308
+ jq '.runs' "$REG_FILE"
309
+ }
310
+
311
+ _cmd_clean_stale() {
312
+ _lock
313
+ trap '_unlock' EXIT
314
+ _prune_stale
315
+ _unlock
316
+ trap - EXIT
317
+ }
318
+
319
+ _cmd_owner_of() {
320
+ local path=""
321
+ while [[ $# -gt 0 ]]; do
322
+ case "$1" in
323
+ --path) path="$2"; shift 2 ;;
324
+ *) shift ;;
325
+ esac
326
+ done
327
+ [[ -z "$path" ]] && _die "owner-of requires --path"
328
+ local repo
329
+ repo=$(_find_owning_repo "$path")
330
+ [[ -z "$repo" ]] && { echo ""; return; }
331
+ _ensure_reg
332
+ _prune_stale_locked
333
+ jq --arg repo "$repo" --arg target "$(_abs_path "$path")" \
334
+ '[.runs[] | select(
335
+ (.scope == "repo" and .repo_path == $repo)
336
+ or (.scope | startswith("worktree:")) and
337
+ ($target | startswith(.scope | sub("^worktree:"; "")))
338
+ )]' "$REG_FILE"
339
+ }
340
+
341
+ # Check if a target path is owned by a foreign run.
342
+ # Exit 0 = clear / self-owned / no owner; exit 2 = blocked by foreign owner.
343
+ _cmd_check() {
344
+ local target="" my_pid="" my_session=""
345
+ while [[ $# -gt 0 ]]; do
346
+ case "$1" in
347
+ --target) target="$2"; shift 2 ;;
348
+ --pid) my_pid="$2"; shift 2 ;;
349
+ --session-id) my_session="$2"; shift 2 ;;
350
+ *) shift ;;
351
+ esac
352
+ done
353
+ [[ -z "$target" ]] && _die "check requires --target"
354
+
355
+ _ensure_reg
356
+ _prune_stale_locked
357
+
358
+ local abs_target
359
+ abs_target=$(_abs_path "$target")
360
+ local repo
361
+ repo=$(_find_owning_repo "$abs_target")
362
+ [[ -z "$repo" ]] && return 0
363
+
364
+ # Collect foreign entries covering this target
365
+ local matches
366
+ matches=$(jq -c --arg repo "$repo" \
367
+ --arg target "$abs_target" \
368
+ --arg mypid "${my_pid:-0}" \
369
+ --arg mysid "${my_session:-__none__}" \
370
+ '[.runs[] | select(
371
+ ((.scope == "repo" and .repo_path == $repo)
372
+ or ((.scope | startswith("worktree:")) and
373
+ ($target | startswith(.scope | sub("^worktree:"; "")))))
374
+ and ((.pid | tostring) != $mypid)
375
+ and (.session_id != $mysid)
376
+ )]' "$REG_FILE" 2>/dev/null || echo '[]')
377
+
378
+ local n
379
+ n=$(echo "$matches" | jq 'length')
380
+ if [[ "${n:-0}" -eq 0 ]]; then
381
+ return 0
382
+ fi
383
+
384
+ # Emit one summary line per owner to stderr for the calling hook
385
+ echo "$matches" | jq -r '.[] |
386
+ "run_id=\(.run_id) pid=\(.pid) command=\(.command) project=\(.project) scope=\(.scope) repo=\(.repo_path) started=\(.started_at) heartbeat=\(.heartbeat_at)"' >&2
387
+ return 2
388
+ }
389
+
390
+ # ---------- dispatch ----------
391
+
392
+ main() {
393
+ local sub="${1:-}"
394
+ [[ -z "$sub" ]] && _die "usage: repo-run-registry.sh <subcommand> [args...]"
395
+ shift
396
+ case "$sub" in
397
+ register) _cmd_register "$@" ;;
398
+ deregister) _cmd_deregister "$@" ;;
399
+ heartbeat) _cmd_heartbeat "$@" ;;
400
+ list) _cmd_list "$@" ;;
401
+ check) _cmd_check "$@" ;;
402
+ clean-stale) _cmd_clean_stale "$@" ;;
403
+ owner-of) _cmd_owner_of "$@" ;;
404
+ *) _die "unknown subcommand: $sub" ;;
405
+ esac
406
+ }
407
+
408
+ main "$@"