From 03abc06ab759cfa22785f36fcfab7e252db0184e Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 30 Jun 2026 03:06:08 +0200 Subject: [PATCH 1/2] feat(setup): distribute stalled-report + autofinish-watch to target repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gx claude install bakes a SessionStart hook calling scripts/agent-stalled-report.sh into a target repo's settings.json, but neither that shim nor the watcher it wraps (scripts/agent-autofinish-watch.sh) was ever delivered — gx claude install only copies .claude/hooks/*.py, and the two scripts were not in TEMPLATE_FILES. So target repos got a hook pointing at a missing script. Wire both via the PAIRED pattern (like agent-preflight.sh): move the real files to templates/scripts/, symlink scripts/ back, register in TEMPLATE_FILES (so gx setup copies them verbatim into a target's scripts/) and in check-script-symlinks.sh. Verified end-to-end: gx setup --target now delivers both as runnable executables; setup.test.js requiredFiles asserts their presence. --- scripts/agent-autofinish-watch.sh | 281 +------------------- scripts/agent-stalled-report.sh | 41 +-- scripts/check-script-symlinks.sh | 2 + src/context.js | 2 + templates/scripts/agent-autofinish-watch.sh | 280 +++++++++++++++++++ templates/scripts/agent-stalled-report.sh | 40 +++ test/setup.test.js | 2 + 7 files changed, 328 insertions(+), 320 deletions(-) mode change 100755 => 120000 scripts/agent-autofinish-watch.sh mode change 100755 => 120000 scripts/agent-stalled-report.sh create mode 100755 templates/scripts/agent-autofinish-watch.sh create mode 100755 templates/scripts/agent-stalled-report.sh diff --git a/scripts/agent-autofinish-watch.sh b/scripts/agent-autofinish-watch.sh deleted file mode 100755 index d4d41035..00000000 --- a/scripts/agent-autofinish-watch.sh +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env bash -# Detect stalled agent/* worktrees and (optionally) reap lanes whose PR already -# merged but whose worktree was retained on disk. -# -# This is the watcher that scripts/agent-stalled-report.sh (the SessionStart -# hook) expects. Without it, that shim soft-exits 0 and merged-PR worktrees are -# never cleaned up (the "retained for now" path in agent-branch-finish.sh). -# -# It does NOT reinvent cleanup: reaping delegates to `gx worktree prune` -# (scripts/agent-worktree-prune.sh), the existing, tested primitive. -# -# Per-lane status lines use the prefix the report shim greps: -# [agent-autofinish-watch] agent/: -# A line is emitted ONLY for actionable lanes (merged-but-retained, or stalled -# with no open PR after the idle gate). Healthy in-flight lanes stay silent. -# -# Exit codes: 0 always (informational); reaping failures warn but do not fail. - -set -euo pipefail - -MODE="once" # once | daemon -DRY_RUN=0 -AUTO_MERGE=0 -INTERVAL=300 -IDLE_MINUTES="${GUARDEX_AUTOFINISH_IDLE_MINUTES:-60}" -BASE_BRANCH="${GUARDEX_BASE_BRANCH:-}" -GH_BIN="${GUARDEX_GH_BIN:-gh}" -NOW_EPOCH_OVERRIDE="${GUARDEX_AUTOFINISH_NOW_EPOCH:-}" - -WORKTREE_ROOT_RELS=( - ".omx/agent-worktrees" - ".omx/.tmp-worktrees" - ".omc/agent-worktrees" - ".omc/.tmp-worktrees" -) -LOCK_FILE_REL=".omx/state/agent-file-locks.json" - -while [[ $# -gt 0 ]]; do - case "$1" in - --once) MODE="once"; shift ;; - --daemon) MODE="daemon"; shift ;; - --dry-run) DRY_RUN=1; shift ;; - --auto-merge) AUTO_MERGE=1; shift ;; - --interval) - [[ $# -ge 2 ]] || { echo "[agent-autofinish-watch] --interval requires a value" >&2; exit 1; } - INTERVAL="$2"; shift 2 ;; - --idle-minutes) - [[ $# -ge 2 ]] || { echo "[agent-autofinish-watch] --idle-minutes requires a value" >&2; exit 1; } - IDLE_MINUTES="$2"; shift 2 ;; - --base) - [[ $# -ge 2 ]] || { echo "[agent-autofinish-watch] --base requires a value" >&2; exit 1; } - BASE_BRANCH="$2"; shift 2 ;; - -h|--help) - echo "Usage: $0 [--once|--daemon] [--dry-run] [--auto-merge] [--interval SEC] [--idle-minutes MIN] [--base BRANCH]" - echo "Note: merged/open PR detection reads the most recent 200 PRs per state; a" - echo " branch whose merged PR is older than that will not be auto-reaped." - exit 0 - ;; - *) - echo "[agent-autofinish-watch] Unknown argument: $1" >&2 - exit 1 - ;; - esac -done - -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "[agent-autofinish-watch] Not inside a git repository." >&2 - exit 0 -fi - -# Resolve the PRIMARY checkout root, not the current worktree: the managed -# worktree roots (.omc/agent-worktrees, ...) live under the primary checkout, -# and refs/reflogs are shared via the common git dir. Running from inside an -# agent worktree must still see every sibling lane. -git_common_dir="$(git rev-parse --git-common-dir 2>/dev/null)" -case "$git_common_dir" in - /*) ;; - *) git_common_dir="$(git rev-parse --show-toplevel)/${git_common_dir}" ;; -esac -repo_root="$(cd "$(dirname "$git_common_dir")" && pwd)" - -resolve_base_branch() { - [[ -n "$BASE_BRANCH" ]] && return 0 - local head_ref - head_ref="$(git -C "$repo_root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" - if [[ -n "$head_ref" ]]; then - BASE_BRANCH="${head_ref#origin/}" - return 0 - fi - for cand in main master dev; do - if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${cand}"; then - BASE_BRANCH="$cand" - return 0 - fi - done - BASE_BRANCH="main" -} - -is_managed_worktree_path() { - local entry="$1" rel - for rel in "${WORKTREE_ROOT_RELS[@]}"; do - [[ "$entry" == "${repo_root}/${rel}"/* ]] && return 0 - done - return 1 -} - -is_temporary_worktree_path() { - local name - name="$(basename "$1")" - [[ "$name" == __agent_integrate-* || "$name" == __source-probe-* ]] -} - -now_epoch() { - if [[ -n "$NOW_EPOCH_OVERRIDE" ]]; then - printf '%s' "$NOW_EPOCH_OVERRIDE" - else - date +%s - fi -} - -has_live_process_in_worktree() { - local wt="$1" proc_cwd live_cwd - [[ -d /proc ]] || return 1 - for proc_cwd in /proc/[0-9]*/cwd; do - [[ -e "$proc_cwd" ]] || continue - live_cwd="$(readlink "$proc_cwd" 2>/dev/null || true)" - [[ -n "$live_cwd" ]] || continue - live_cwd="${live_cwd% (deleted)}" - if [[ "$live_cwd" == "$wt" || "$live_cwd" == "${wt}"/* ]]; then - return 0 - fi - done - return 1 -} - -branch_idle_minutes() { - local branch="$1" wt="$2" activity_epoch="" lock_mtime now - activity_epoch="$(git -C "$repo_root" reflog show --format='%ct' -n 1 "refs/heads/${branch}" 2>/dev/null | head -n1 | tr -d '[:space:]')" - if [[ -z "$activity_epoch" ]]; then - activity_epoch="$(git -C "$repo_root" log -1 --format='%ct' "$branch" 2>/dev/null | head -n1 | tr -d '[:space:]')" - fi - if [[ -n "$wt" && -f "${wt}/${LOCK_FILE_REL}" ]]; then - lock_mtime="$(stat -c %Y "${wt}/${LOCK_FILE_REL}" 2>/dev/null || stat -f %m "${wt}/${LOCK_FILE_REL}" 2>/dev/null || true)" - if [[ "$lock_mtime" =~ ^[0-9]+$ && ( -z "$activity_epoch" || "$lock_mtime" -gt "$activity_epoch" ) ]]; then - activity_epoch="$lock_mtime" - fi - fi - [[ "$activity_epoch" =~ ^[0-9]+$ ]] || { printf '%s' 999999; return; } - now="$(now_epoch)" - printf '%s' $(( (now - activity_epoch) / 60 )) -} - -# Count uncommitted changes, ignoring lock-file churn. -dirty_count() { - local wt="$1" - git -C "$wt" status --porcelain -- . ":(exclude)${LOCK_FILE_REL}" 2>/dev/null | grep -c . || true -} - -commits_ahead() { - local branch="$1" - git -C "$repo_root" rev-list --count "${BASE_BRANCH}..${branch}" 2>/dev/null || printf '0' -} - -# Prefer the gx CLI; fall back to the bundled prune script. -run_prune() { - if command -v gx >/dev/null 2>&1; then - gx worktree prune "$@" - else - bash "${repo_root}/scripts/agent-worktree-prune.sh" "$@" - fi -} - -declare -A MERGED_BRANCHES=() -declare -A OPEN_BRANCHES=() - -load_pr_state() { - command -v "$GH_BIN" >/dev/null 2>&1 || return 0 - local line - while IFS= read -r line; do - [[ -n "$line" ]] && MERGED_BRANCHES["$line"]=1 - done < <("$GH_BIN" pr list --state merged --base "$BASE_BRANCH" --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null || true) - while IFS= read -r line; do - [[ -n "$line" ]] && OPEN_BRANCHES["$line"]=1 - done < <("$GH_BIN" pr list --state open --base "$BASE_BRANCH" --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null || true) -} - -run_once() { - resolve_base_branch - MERGED_BRANCHES=() - OPEN_BRANCHES=() - load_pr_state - - local scanned=0 stalled=0 merged=0 - local cur_wt="" cur_branch="" - - while IFS= read -r line; do - if [[ "$line" == worktree\ * ]]; then - cur_wt="${line#worktree }" - cur_branch="" - elif [[ "$line" == branch\ refs/heads/* ]]; then - cur_branch="${line#branch refs/heads/}" - elif [[ -z "$line" ]]; then - process_lane "$cur_wt" "$cur_branch" - cur_wt=""; cur_branch="" - fi - done < <(git -C "$repo_root" worktree list --porcelain; printf '\n') - - # Reap merged-but-retained lanes before the summary so reaped= is accurate. - if [[ "$merged" -gt 0 ]]; then - reap_merged - fi - - printf '[agent-autofinish-watch] scanned=%s stalled=%s merged=%s reaped=%s\n' \ - "$scanned" "$stalled" "$merged" "$reaped" -} - -# process_lane mutates scanned/stalled/merged in the caller scope (bash dynamic -# scope via run_once's locals); reaped is a top-level global set by reap_merged. -process_lane() { - local wt="$1" branch="$2" - [[ -n "$wt" && -n "$branch" ]] || return 0 - [[ "$branch" == agent/* ]] || return 0 - is_managed_worktree_path "$wt" || return 0 - is_temporary_worktree_path "$wt" && return 0 - scanned=$((scanned + 1)) - - if [[ -n "${MERGED_BRANCHES[$branch]:-}" && -d "$wt" ]]; then - merged=$((merged + 1)) - echo "[agent-autofinish-watch] ${branch}: merged PR, worktree retained -> prunable" - return 0 - fi - - # Open PR or live process => healthy in-flight, stay silent. - [[ -n "${OPEN_BRANCHES[$branch]:-}" ]] && return 0 - has_live_process_in_worktree "$wt" && return 0 - - local idle dirty ahead - idle="$(branch_idle_minutes "$branch" "$wt")" - [[ "$idle" -ge "$IDLE_MINUTES" ]] || return 0 - - dirty="$(dirty_count "$wt")" - if [[ "$dirty" -gt 0 ]]; then - stalled=$((stalled + 1)) - echo "[agent-autofinish-watch] ${branch}: ${dirty} uncommitted change(s), idle ${idle}m -> needs commit + finish" - return 0 - fi - - ahead="$(commits_ahead "$branch")" - if [[ "$ahead" -gt 0 ]]; then - stalled=$((stalled + 1)) - echo "[agent-autofinish-watch] ${branch}: ${ahead} commit(s) ahead of ${BASE_BRANCH}, no PR, idle ${idle}m -> needs finish" - fi -} - -reaped=0 - -reap_merged() { - [[ "$AUTO_MERGE" -eq 1 ]] || return 0 - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "[agent-autofinish-watch] [dry-run] would prune merged lanes: gx worktree prune --include-pr-merged --delete-branches --base ${BASE_BRANCH}" - return 0 - fi - local out="" - out="$(run_prune --include-pr-merged --delete-branches --base "$BASE_BRANCH" 2>&1 || true)" - printf '%s\n' "$out" - local removed - removed="$(printf '%s\n' "$out" | sed -n 's/.*removed_worktrees=\([0-9]*\).*/\1/p' | head -n1)" - [[ "$removed" =~ ^[0-9]+$ ]] && reaped="$removed" -} - -if [[ "$MODE" == "daemon" ]]; then - while true; do - reaped=0 - run_once - sleep "$INTERVAL" - done -else - reaped=0 - run_once -fi diff --git a/scripts/agent-autofinish-watch.sh b/scripts/agent-autofinish-watch.sh new file mode 120000 index 00000000..8e2c1ca3 --- /dev/null +++ b/scripts/agent-autofinish-watch.sh @@ -0,0 +1 @@ +../templates/scripts/agent-autofinish-watch.sh \ No newline at end of file diff --git a/scripts/agent-stalled-report.sh b/scripts/agent-stalled-report.sh deleted file mode 100755 index b494cc82..00000000 --- a/scripts/agent-stalled-report.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# Quiet wrapper around scripts/agent-autofinish-watch.sh that surfaces stalled -# agent/* worktrees. Prints nothing when everything's clean; prints a one-line -# summary per stalled worktree + a finish hint when there's work to recover. -# -# Designed to be wired as a SessionStart hook so Claude Code (and the user) -# learn about half-finished codex/claude agent runs at the top of each session. -# -# Exit codes: -# 0 — no stalled worktrees (or watcher missing — soft fail) -# 0 — even when stalled worktrees ARE detected (this is informational, not a hard error) - -set -euo pipefail - -repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -watcher="${repo_root}/scripts/agent-autofinish-watch.sh" - -if [[ ! -x "$watcher" ]]; then - exit 0 -fi - -raw_output="$(bash "$watcher" --once --dry-run 2>&1 || true)" - -# Filter to per-worktree status lines only ("[agent-autofinish-watch] agent/: ..."). -stalled_lines="$(printf '%s\n' "$raw_output" | grep -E '^\[agent-autofinish-watch\] agent/' || true)" - -if [[ -z "$stalled_lines" ]]; then - exit 0 -fi - -stalled_count="$(printf '%s\n' "$stalled_lines" | wc -l | tr -d ' ')" - -printf '⚠ Stalled agent worktrees detected (%s):\n' "$stalled_count" -printf '%s\n' "$stalled_lines" | sed 's/^\[agent-autofinish-watch\] / • /' -printf '\nResolve options:\n' -printf ' • Inspect: bash scripts/agent-autofinish-watch.sh --once --dry-run\n' -printf ' • Auto-finish: bash scripts/agent-autofinish-watch.sh --once --auto-merge\n' -printf ' • Run daemon: bash scripts/agent-autofinish-watch.sh --daemon --auto-merge\n' - -exit 0 diff --git a/scripts/agent-stalled-report.sh b/scripts/agent-stalled-report.sh new file mode 120000 index 00000000..5275d984 --- /dev/null +++ b/scripts/agent-stalled-report.sh @@ -0,0 +1 @@ +../templates/scripts/agent-stalled-report.sh \ No newline at end of file diff --git a/scripts/check-script-symlinks.sh b/scripts/check-script-symlinks.sh index 33a736ff..62b9df8b 100755 --- a/scripts/check-script-symlinks.sh +++ b/scripts/check-script-symlinks.sh @@ -14,11 +14,13 @@ cd "$repo_root" # in src/context.js. Files that are intentionally gitignored or scaffolded # locally (e.g. guardex-env.sh, guardex-docker-loader.sh) are excluded. required_symlinks=( + scripts/agent-autofinish-watch.sh scripts/agent-branch-start.sh scripts/agent-branch-finish.sh scripts/agent-branch-merge.sh scripts/agent-file-locks.py scripts/agent-preflight.sh + scripts/agent-stalled-report.sh scripts/agent-worktree-prune.sh scripts/codex-agent.sh scripts/install-agent-git-hooks.sh diff --git a/src/context.js b/src/context.js index 13a47a81..5fcf6f8f 100644 --- a/src/context.js +++ b/src/context.js @@ -163,6 +163,8 @@ function toDestinationPath(relativeTemplatePath) { // safety block (auto-managed by syncManagedGitignoreLines below). const TEMPLATE_FILES = [ 'scripts/agent-preflight.sh', + 'scripts/agent-stalled-report.sh', + 'scripts/agent-autofinish-watch.sh', 'scripts/guardex-docker-loader.sh', 'scripts/guardex-env.sh', 'github/pull.yml.example', diff --git a/templates/scripts/agent-autofinish-watch.sh b/templates/scripts/agent-autofinish-watch.sh new file mode 100755 index 00000000..d4d41035 --- /dev/null +++ b/templates/scripts/agent-autofinish-watch.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash +# Detect stalled agent/* worktrees and (optionally) reap lanes whose PR already +# merged but whose worktree was retained on disk. +# +# This is the watcher that scripts/agent-stalled-report.sh (the SessionStart +# hook) expects. Without it, that shim soft-exits 0 and merged-PR worktrees are +# never cleaned up (the "retained for now" path in agent-branch-finish.sh). +# +# It does NOT reinvent cleanup: reaping delegates to `gx worktree prune` +# (scripts/agent-worktree-prune.sh), the existing, tested primitive. +# +# Per-lane status lines use the prefix the report shim greps: +# [agent-autofinish-watch] agent/: +# A line is emitted ONLY for actionable lanes (merged-but-retained, or stalled +# with no open PR after the idle gate). Healthy in-flight lanes stay silent. +# +# Exit codes: 0 always (informational); reaping failures warn but do not fail. + +set -euo pipefail + +MODE="once" # once | daemon +DRY_RUN=0 +AUTO_MERGE=0 +INTERVAL=300 +IDLE_MINUTES="${GUARDEX_AUTOFINISH_IDLE_MINUTES:-60}" +BASE_BRANCH="${GUARDEX_BASE_BRANCH:-}" +GH_BIN="${GUARDEX_GH_BIN:-gh}" +NOW_EPOCH_OVERRIDE="${GUARDEX_AUTOFINISH_NOW_EPOCH:-}" + +WORKTREE_ROOT_RELS=( + ".omx/agent-worktrees" + ".omx/.tmp-worktrees" + ".omc/agent-worktrees" + ".omc/.tmp-worktrees" +) +LOCK_FILE_REL=".omx/state/agent-file-locks.json" + +while [[ $# -gt 0 ]]; do + case "$1" in + --once) MODE="once"; shift ;; + --daemon) MODE="daemon"; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --auto-merge) AUTO_MERGE=1; shift ;; + --interval) + [[ $# -ge 2 ]] || { echo "[agent-autofinish-watch] --interval requires a value" >&2; exit 1; } + INTERVAL="$2"; shift 2 ;; + --idle-minutes) + [[ $# -ge 2 ]] || { echo "[agent-autofinish-watch] --idle-minutes requires a value" >&2; exit 1; } + IDLE_MINUTES="$2"; shift 2 ;; + --base) + [[ $# -ge 2 ]] || { echo "[agent-autofinish-watch] --base requires a value" >&2; exit 1; } + BASE_BRANCH="$2"; shift 2 ;; + -h|--help) + echo "Usage: $0 [--once|--daemon] [--dry-run] [--auto-merge] [--interval SEC] [--idle-minutes MIN] [--base BRANCH]" + echo "Note: merged/open PR detection reads the most recent 200 PRs per state; a" + echo " branch whose merged PR is older than that will not be auto-reaped." + exit 0 + ;; + *) + echo "[agent-autofinish-watch] Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[agent-autofinish-watch] Not inside a git repository." >&2 + exit 0 +fi + +# Resolve the PRIMARY checkout root, not the current worktree: the managed +# worktree roots (.omc/agent-worktrees, ...) live under the primary checkout, +# and refs/reflogs are shared via the common git dir. Running from inside an +# agent worktree must still see every sibling lane. +git_common_dir="$(git rev-parse --git-common-dir 2>/dev/null)" +case "$git_common_dir" in + /*) ;; + *) git_common_dir="$(git rev-parse --show-toplevel)/${git_common_dir}" ;; +esac +repo_root="$(cd "$(dirname "$git_common_dir")" && pwd)" + +resolve_base_branch() { + [[ -n "$BASE_BRANCH" ]] && return 0 + local head_ref + head_ref="$(git -C "$repo_root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + if [[ -n "$head_ref" ]]; then + BASE_BRANCH="${head_ref#origin/}" + return 0 + fi + for cand in main master dev; do + if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${cand}"; then + BASE_BRANCH="$cand" + return 0 + fi + done + BASE_BRANCH="main" +} + +is_managed_worktree_path() { + local entry="$1" rel + for rel in "${WORKTREE_ROOT_RELS[@]}"; do + [[ "$entry" == "${repo_root}/${rel}"/* ]] && return 0 + done + return 1 +} + +is_temporary_worktree_path() { + local name + name="$(basename "$1")" + [[ "$name" == __agent_integrate-* || "$name" == __source-probe-* ]] +} + +now_epoch() { + if [[ -n "$NOW_EPOCH_OVERRIDE" ]]; then + printf '%s' "$NOW_EPOCH_OVERRIDE" + else + date +%s + fi +} + +has_live_process_in_worktree() { + local wt="$1" proc_cwd live_cwd + [[ -d /proc ]] || return 1 + for proc_cwd in /proc/[0-9]*/cwd; do + [[ -e "$proc_cwd" ]] || continue + live_cwd="$(readlink "$proc_cwd" 2>/dev/null || true)" + [[ -n "$live_cwd" ]] || continue + live_cwd="${live_cwd% (deleted)}" + if [[ "$live_cwd" == "$wt" || "$live_cwd" == "${wt}"/* ]]; then + return 0 + fi + done + return 1 +} + +branch_idle_minutes() { + local branch="$1" wt="$2" activity_epoch="" lock_mtime now + activity_epoch="$(git -C "$repo_root" reflog show --format='%ct' -n 1 "refs/heads/${branch}" 2>/dev/null | head -n1 | tr -d '[:space:]')" + if [[ -z "$activity_epoch" ]]; then + activity_epoch="$(git -C "$repo_root" log -1 --format='%ct' "$branch" 2>/dev/null | head -n1 | tr -d '[:space:]')" + fi + if [[ -n "$wt" && -f "${wt}/${LOCK_FILE_REL}" ]]; then + lock_mtime="$(stat -c %Y "${wt}/${LOCK_FILE_REL}" 2>/dev/null || stat -f %m "${wt}/${LOCK_FILE_REL}" 2>/dev/null || true)" + if [[ "$lock_mtime" =~ ^[0-9]+$ && ( -z "$activity_epoch" || "$lock_mtime" -gt "$activity_epoch" ) ]]; then + activity_epoch="$lock_mtime" + fi + fi + [[ "$activity_epoch" =~ ^[0-9]+$ ]] || { printf '%s' 999999; return; } + now="$(now_epoch)" + printf '%s' $(( (now - activity_epoch) / 60 )) +} + +# Count uncommitted changes, ignoring lock-file churn. +dirty_count() { + local wt="$1" + git -C "$wt" status --porcelain -- . ":(exclude)${LOCK_FILE_REL}" 2>/dev/null | grep -c . || true +} + +commits_ahead() { + local branch="$1" + git -C "$repo_root" rev-list --count "${BASE_BRANCH}..${branch}" 2>/dev/null || printf '0' +} + +# Prefer the gx CLI; fall back to the bundled prune script. +run_prune() { + if command -v gx >/dev/null 2>&1; then + gx worktree prune "$@" + else + bash "${repo_root}/scripts/agent-worktree-prune.sh" "$@" + fi +} + +declare -A MERGED_BRANCHES=() +declare -A OPEN_BRANCHES=() + +load_pr_state() { + command -v "$GH_BIN" >/dev/null 2>&1 || return 0 + local line + while IFS= read -r line; do + [[ -n "$line" ]] && MERGED_BRANCHES["$line"]=1 + done < <("$GH_BIN" pr list --state merged --base "$BASE_BRANCH" --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null || true) + while IFS= read -r line; do + [[ -n "$line" ]] && OPEN_BRANCHES["$line"]=1 + done < <("$GH_BIN" pr list --state open --base "$BASE_BRANCH" --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null || true) +} + +run_once() { + resolve_base_branch + MERGED_BRANCHES=() + OPEN_BRANCHES=() + load_pr_state + + local scanned=0 stalled=0 merged=0 + local cur_wt="" cur_branch="" + + while IFS= read -r line; do + if [[ "$line" == worktree\ * ]]; then + cur_wt="${line#worktree }" + cur_branch="" + elif [[ "$line" == branch\ refs/heads/* ]]; then + cur_branch="${line#branch refs/heads/}" + elif [[ -z "$line" ]]; then + process_lane "$cur_wt" "$cur_branch" + cur_wt=""; cur_branch="" + fi + done < <(git -C "$repo_root" worktree list --porcelain; printf '\n') + + # Reap merged-but-retained lanes before the summary so reaped= is accurate. + if [[ "$merged" -gt 0 ]]; then + reap_merged + fi + + printf '[agent-autofinish-watch] scanned=%s stalled=%s merged=%s reaped=%s\n' \ + "$scanned" "$stalled" "$merged" "$reaped" +} + +# process_lane mutates scanned/stalled/merged in the caller scope (bash dynamic +# scope via run_once's locals); reaped is a top-level global set by reap_merged. +process_lane() { + local wt="$1" branch="$2" + [[ -n "$wt" && -n "$branch" ]] || return 0 + [[ "$branch" == agent/* ]] || return 0 + is_managed_worktree_path "$wt" || return 0 + is_temporary_worktree_path "$wt" && return 0 + scanned=$((scanned + 1)) + + if [[ -n "${MERGED_BRANCHES[$branch]:-}" && -d "$wt" ]]; then + merged=$((merged + 1)) + echo "[agent-autofinish-watch] ${branch}: merged PR, worktree retained -> prunable" + return 0 + fi + + # Open PR or live process => healthy in-flight, stay silent. + [[ -n "${OPEN_BRANCHES[$branch]:-}" ]] && return 0 + has_live_process_in_worktree "$wt" && return 0 + + local idle dirty ahead + idle="$(branch_idle_minutes "$branch" "$wt")" + [[ "$idle" -ge "$IDLE_MINUTES" ]] || return 0 + + dirty="$(dirty_count "$wt")" + if [[ "$dirty" -gt 0 ]]; then + stalled=$((stalled + 1)) + echo "[agent-autofinish-watch] ${branch}: ${dirty} uncommitted change(s), idle ${idle}m -> needs commit + finish" + return 0 + fi + + ahead="$(commits_ahead "$branch")" + if [[ "$ahead" -gt 0 ]]; then + stalled=$((stalled + 1)) + echo "[agent-autofinish-watch] ${branch}: ${ahead} commit(s) ahead of ${BASE_BRANCH}, no PR, idle ${idle}m -> needs finish" + fi +} + +reaped=0 + +reap_merged() { + [[ "$AUTO_MERGE" -eq 1 ]] || return 0 + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[agent-autofinish-watch] [dry-run] would prune merged lanes: gx worktree prune --include-pr-merged --delete-branches --base ${BASE_BRANCH}" + return 0 + fi + local out="" + out="$(run_prune --include-pr-merged --delete-branches --base "$BASE_BRANCH" 2>&1 || true)" + printf '%s\n' "$out" + local removed + removed="$(printf '%s\n' "$out" | sed -n 's/.*removed_worktrees=\([0-9]*\).*/\1/p' | head -n1)" + [[ "$removed" =~ ^[0-9]+$ ]] && reaped="$removed" +} + +if [[ "$MODE" == "daemon" ]]; then + while true; do + reaped=0 + run_once + sleep "$INTERVAL" + done +else + reaped=0 + run_once +fi diff --git a/templates/scripts/agent-stalled-report.sh b/templates/scripts/agent-stalled-report.sh new file mode 100755 index 00000000..b494cc82 --- /dev/null +++ b/templates/scripts/agent-stalled-report.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Quiet wrapper around scripts/agent-autofinish-watch.sh that surfaces stalled +# agent/* worktrees. Prints nothing when everything's clean; prints a one-line +# summary per stalled worktree + a finish hint when there's work to recover. +# +# Designed to be wired as a SessionStart hook so Claude Code (and the user) +# learn about half-finished codex/claude agent runs at the top of each session. +# +# Exit codes: +# 0 — no stalled worktrees (or watcher missing — soft fail) +# 0 — even when stalled worktrees ARE detected (this is informational, not a hard error) + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +watcher="${repo_root}/scripts/agent-autofinish-watch.sh" + +if [[ ! -x "$watcher" ]]; then + exit 0 +fi + +raw_output="$(bash "$watcher" --once --dry-run 2>&1 || true)" + +# Filter to per-worktree status lines only ("[agent-autofinish-watch] agent/: ..."). +stalled_lines="$(printf '%s\n' "$raw_output" | grep -E '^\[agent-autofinish-watch\] agent/' || true)" + +if [[ -z "$stalled_lines" ]]; then + exit 0 +fi + +stalled_count="$(printf '%s\n' "$stalled_lines" | wc -l | tr -d ' ')" + +printf '⚠ Stalled agent worktrees detected (%s):\n' "$stalled_count" +printf '%s\n' "$stalled_lines" | sed 's/^\[agent-autofinish-watch\] / • /' +printf '\nResolve options:\n' +printf ' • Inspect: bash scripts/agent-autofinish-watch.sh --once --dry-run\n' +printf ' • Auto-finish: bash scripts/agent-autofinish-watch.sh --once --auto-merge\n' +printf ' • Run daemon: bash scripts/agent-autofinish-watch.sh --daemon --auto-merge\n' + +exit 0 diff --git a/test/setup.test.js b/test/setup.test.js index e3d831eb..a15b3976 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -84,6 +84,8 @@ test('setup provisions workflow files and repo config', () => { '.omx/project-memory.json', 'scripts/guardex-docker-loader.sh', 'scripts/guardex-env.sh', + 'scripts/agent-stalled-report.sh', + 'scripts/agent-autofinish-watch.sh', '.githooks/pre-commit', '.githooks/pre-push', '.githooks/post-merge', From 7fe85d0d9ceb4e1a8d2f1d78e8de9b991acaf5c7 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 30 Jun 2026 03:07:01 +0200 Subject: [PATCH 2/2] docs(openspec): proposal/spec/tasks for target-repo script distribution --- .../.openspec.yaml | 2 ++ .../proposal.md | 15 ++++++++ .../spec.md | 14 ++++++++ .../tasks.md | 34 +++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/.openspec.yaml create mode 100644 openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/proposal.md create mode 100644 openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/specs/distribute-autofinish-watcher-stalled-report-hook-to-target-repos-via-gx-claude-install/spec.md create mode 100644 openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/tasks.md diff --git a/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/.openspec.yaml b/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/.openspec.yaml new file mode 100644 index 00000000..d6b53dee --- /dev/null +++ b/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/proposal.md b/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/proposal.md new file mode 100644 index 00000000..f30e9011 --- /dev/null +++ b/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/proposal.md @@ -0,0 +1,15 @@ +## Why + +`gx claude install` bakes a SessionStart hook calling `scripts/agent-stalled-report.sh` into a target repo's `settings.json` (via `TEMPLATE_DEFAULT_SETTINGS`), but neither that shim nor the watcher it wraps (`scripts/agent-autofinish-watch.sh`, shipped in #665) was ever delivered to target repos: `gx claude install` only copies `.claude/hooks/*.py`, and the two scripts were absent from `TEMPLATE_FILES`. Result: every target repo got a hook pointing at a missing script, so stalled-worktree recovery silently no-op'd everywhere except gitguardex itself. + +## What Changes + +- Move `scripts/agent-stalled-report.sh` and `scripts/agent-autofinish-watch.sh` to `templates/scripts/` (the real files) and replace `scripts/` with tracked symlinks — the PAIRED convention used by `agent-preflight.sh`. +- Register both in `TEMPLATE_FILES` (`src/context.js`) so `gx setup` copies them verbatim into a target's `scripts/`, and in `scripts/check-script-symlinks.sh` so the pairing stays enforced. +- Extend `test/setup.test.js` `requiredFiles` to assert both land after setup. + +## Impact + +- Affected surfaces: `src/context.js`, `scripts/check-script-symlinks.sh`, `templates/scripts/` (2 new), `scripts/` (2 → symlinks), `test/setup.test.js`. +- Delivery vector is `gx setup` / `gx doctor` (which scaffold `TEMPLATE_FILES`), NOT `gx claude install` (which never touches `scripts/`). No `claude.js` / `MANAGED_HOOK_FILES` change is needed — that earlier-suspected lever was wrong. +- Low risk: additive distribution following an existing precedent; verified end-to-end that `gx setup --target` delivers both scripts as runnable executables. diff --git a/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/specs/distribute-autofinish-watcher-stalled-report-hook-to-target-repos-via-gx-claude-install/spec.md b/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/specs/distribute-autofinish-watcher-stalled-report-hook-to-target-repos-via-gx-claude-install/spec.md new file mode 100644 index 00000000..fc7c913f --- /dev/null +++ b/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/specs/distribute-autofinish-watcher-stalled-report-hook-to-target-repos-via-gx-claude-install/spec.md @@ -0,0 +1,14 @@ +## ADDED Requirements + +### Requirement: Stalled-report hook and watcher reach target repos +`gx setup` SHALL deliver both `scripts/agent-stalled-report.sh` (the SessionStart hook shim) and `scripts/agent-autofinish-watch.sh` (the watcher it invokes) into a target repo's `scripts/` directory as regular executable files, so the SessionStart hook baked into the target's `settings.json` references a script that actually exists. Both scripts SHALL follow the PAIRED convention: the real file lives under `templates/scripts/` and `scripts/` is a tracked symlink to it. + +#### Scenario: setup delivers both scripts +- **WHEN** `gx setup` runs against a target repo +- **THEN** `scripts/agent-stalled-report.sh` and `scripts/agent-autofinish-watch.sh` exist in the target as executable regular files whose content matches `templates/scripts/` +- **AND** the delivered `agent-stalled-report.sh` runs and resolves the watcher next to it. + +#### Scenario: pairing stays enforced +- **WHEN** `scripts/check-script-symlinks.sh` runs +- **THEN** both `scripts/agent-stalled-report.sh` and `scripts/agent-autofinish-watch.sh` are verified as symlinks into `templates/scripts/` +- **AND** replacing either symlink with a regular file fails the check. diff --git a/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/tasks.md b/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/tasks.md new file mode 100644 index 00000000..09c027b4 --- /dev/null +++ b/openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56`; branch=`agent//`; scope=`distribute agent-stalled-report.sh + agent-autofinish-watch.sh to target repos via TEMPLATE_FILES (PAIRED pattern)`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56`. +- [x] 1.2 Define normative requirements in `specs/distribute-autofinish-watcher-stalled-report-hook-to-target-repos-via-gx-claude-install/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-claude-distribute-autofinish-watcher-stalled-re-2026-06-30-02-56 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).