Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agent/STALLED-WORKTREE-RECOVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ The Guardex Codex launcher auto-finishes a branch only when the codex CLI exits

`scripts/agent-stalled-report.sh` is a quiet wrapper around `scripts/agent-autofinish-watch.sh --once --dry-run` that surfaces stalled worktrees. It is wired as a `SessionStart` hook in `.claude/settings.json`, so each Claude Code session begins with a one-line summary per stalled branch (and is silent when nothing is stalled).

`scripts/agent-claude-stop-finish.sh` is wired as a Claude `Stop` hook. When Claude stops inside an `agent/*` worktree and there is committed or dirty work, it delegates to `gx branch finish --branch <branch> --base <base> --via-pr --wait-for-merge --cleanup`. Set `GUARDEX_CLAUDE_STOP_FINISH=clean` to only finish already-committed lanes, or `GUARDEX_CLAUDE_STOP_FINISH=off` to disable the stop-time handoff.

To act on the report:

- Inspect: `bash scripts/agent-autofinish-watch.sh --once --dry-run`
Expand Down
10 changes: 10 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/scripts/agent-claude-stop-finish.sh\""
}
]
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-30
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## Why

- Claude Code can start Guardex agent worktrees correctly, but completed lanes can remain stranded when Claude stops without running the final `gx branch finish --via-pr --wait-for-merge --cleanup` command.
- A stranded lane keeps work off `main`, leaves the sandbox around, and can keep locks or follow-up context dangling.

## What Changes

- Add a managed Claude `Stop` hook that runs only from an `agent/*` worktree and delegates to the canonical Guardex finish flow.
- Ship the hook as a managed `templates/scripts` helper and wire it through `gx setup` and `gx claude install`.
- Keep the helper fail-open: when the finish flow cannot complete, print the retry command and keep the sandbox instead of blocking Claude shutdown.

## Impact

- Affects Claude Code integration settings, managed script scaffolding, and worktree finish recovery.
- Default behavior auto-commits dirty agent lanes through the existing finish flow; operators can set `GUARDEX_CLAUDE_STOP_FINISH=clean` or `off` to reduce or disable automation.
- The helper never runs on protected base branches or recursive Stop-hook invocations.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## ADDED Requirements

### Requirement: Claude Stop hook lands agent worktrees
Guardex SHALL install a Claude `Stop` hook that attempts to finish the current lane when Claude stops inside an `agent/*` worktree with committed or dirty work.

#### Scenario: Committed agent worktree is handed to finish flow
- **GIVEN** Claude stops with `cwd` inside an `agent/*` worktree
- **AND** the worktree has commits ahead of its resolved base branch
- **WHEN** the `Stop` hook runs
- **THEN** Guardex SHALL invoke `gx branch finish --branch <branch> --base <base> --via-pr --wait-for-merge --cleanup`.

#### Scenario: Dirty worktree follows configured mode
- **GIVEN** Claude stops with `cwd` inside an `agent/*` worktree that has uncommitted changes
- **WHEN** `GUARDEX_CLAUDE_STOP_FINISH=clean`
- **THEN** Guardex SHALL leave the sandbox open and print the exact finish command.
- **WHEN** `GUARDEX_CLAUDE_STOP_FINISH` is unset or set to commit mode
- **THEN** Guardex SHALL delegate to the existing finish flow, which owns auto-commit, PR, merge wait, and cleanup.

#### Scenario: Stop hook stays fail-open
- **WHEN** the hook runs outside an `agent/*` worktree, during a recursive Stop hook invocation, or with no local work to finish
- **THEN** Guardex SHALL exit successfully without invoking the finish flow.
- **WHEN** the finish flow fails
- **THEN** Guardex SHALL keep the sandbox and print the retry command without failing Claude's Stop hook.
Original file line number Diff line number Diff line change
@@ -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-codex-make-claude-worktree-lanes-land-automati-2026-06-30-11-58`; branch=`agent/codex/make-claude-worktree-lanes-land-automati-2026-06-30-11-58`; scope=`Claude Stop hook lands agent worktrees via canonical Guardex finish flow`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
- Copy prompt: Continue `agent-codex-make-claude-worktree-lanes-land-automati-2026-06-30-11-58` on branch `agent/codex/make-claude-worktree-lanes-land-automati-2026-06-30-11-58`. Work inside the existing sandbox, review `openspec/changes/agent-codex-make-claude-worktree-lanes-land-automati-2026-06-30-11-58/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/codex/make-claude-worktree-lanes-land-automati-2026-06-30-11-58 --base main --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-make-claude-worktree-lanes-land-automati-2026-06-30-11-58`.
- [x] 1.2 Define normative requirements in `specs/make-claude-worktree-lanes-land-automatically/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-codex-make-claude-worktree-lanes-land-automati-2026-06-30-11-58 --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/codex/make-claude-worktree-lanes-land-automati-2026-06-30-11-58 --base main --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).
1 change: 1 addition & 0 deletions scripts/agent-claude-stop-finish.sh
1 change: 1 addition & 0 deletions scripts/check-script-symlinks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ required_symlinks=(
scripts/agent-branch-start.sh
scripts/agent-branch-finish.sh
scripts/agent-branch-merge.sh
scripts/agent-claude-stop-finish.sh
scripts/agent-file-locks.py
scripts/agent-preflight.sh
scripts/agent-stalled-report.sh
Expand Down
11 changes: 11 additions & 0 deletions src/cli/commands/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const EXPECTED_HOOK_MATCHERS = {
UserPromptSubmit: ['skill_activation.py', 'agent_branch_advisor.py'],
PreToolUse: ['skill_guard.py'],
PostToolUse: ['post_edit_tracker.py', 'skill_tracker.py'],
Stop: ['agent-claude-stop-finish.sh'],
};

const TEMPLATE_DEFAULT_SETTINGS = {
Expand Down Expand Up @@ -115,6 +116,16 @@ const TEMPLATE_DEFAULT_SETTINGS = {
],
},
],
Stop: [
{
hooks: [
{
type: 'command',
command: 'bash "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/scripts/agent-claude-stop-finish.sh"',
},
],
},
],
},
};

Expand Down
3 changes: 2 additions & 1 deletion src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ function toDestinationPath(relativeTemplatePath) {

// scripts/ ↔ templates/scripts/ layout convention (single source of truth):
//
// 1. PAIRED files (10): tracked on both sides; scripts/<file> is a symlink
// 1. PAIRED files: tracked on both sides; scripts/<file> is a symlink
// to ../templates/scripts/<file> per PR #548. See
// scripts/check-script-symlinks.sh for the exact list. CI + the
// .githooks/pre-commit shim both enforce that no symlink is ever
Expand All @@ -165,6 +165,7 @@ const TEMPLATE_FILES = [
'scripts/agent-preflight.sh',
'scripts/agent-stalled-report.sh',
'scripts/agent-autofinish-watch.sh',
'scripts/agent-claude-stop-finish.sh',
'scripts/guardex-docker-loader.sh',
'scripts/guardex-env.sh',
'github/pull.yml.example',
Expand Down
175 changes: 175 additions & 0 deletions templates/scripts/agent-claude-stop-finish.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
# Claude Stop hook: when a Claude session ends inside an agent/* worktree,
# hand the lane to the canonical Guardex finish flow so commits, PR merge wait,
# and sandbox cleanup stay coupled.
#
# Modes:
# GUARDEX_CLAUDE_STOP_FINISH=commit auto-commit dirty work via gx finish (default)
# GUARDEX_CLAUDE_STOP_FINISH=clean finish only clean committed lanes
# GUARDEX_CLAUDE_STOP_FINISH=off disable the hook
#
# The hook is fail-open: finish failures keep the sandbox and print a recovery
# command, but never block Claude's Stop event.

set -euo pipefail

NODE_BIN="${GUARDEX_NODE_BIN:-node}"
CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}"

run_guardex_cli() {
if [[ -n "$CLI_ENTRY" ]]; then
"$NODE_BIN" "$CLI_ENTRY" "$@"
return $?
fi
if command -v gx >/dev/null 2>&1; then
gx "$@"
return $?
fi
if command -v gitguardex >/dev/null 2>&1; then
gitguardex "$@"
return $?
fi
echo "[agent-claude-stop-finish] Guardex CLI entrypoint unavailable; rerun via gx." >&2
return 127
}

normalize_mode() {
local raw="${1:-commit}" lowered
lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
case "$lowered" in
''|1|true|yes|on|commit|dirty|auto) printf 'commit' ;;
clean|committed) printf 'clean' ;;
0|false|no|off|none|disabled) printf 'off' ;;
*)
echo "[agent-claude-stop-finish] Unknown GUARDEX_CLAUDE_STOP_FINISH='${raw}', using commit." >&2
printf 'commit'
;;
esac
}

json_field() {
local key="$1"
python3 -c '
import json
import sys

key = sys.argv[1]
try:
data = json.loads(sys.stdin.read() or "{}")
except Exception:
data = {}
value = data.get(key, "")
if isinstance(value, bool):
print("true" if value else "false")
elif value is not None:
print(value)
' "$key" 2>/dev/null || true
}

resolve_base_branch() {
local repo="$1" branch="$2" configured head_ref cand
configured="$(git -C "$repo" config --get "branch.${branch}.guardexBase" 2>/dev/null || true)"
if [[ -n "$configured" ]]; then
printf '%s' "$configured"
return 0
fi
configured="$(git -C "$repo" config --get multiagent.baseBranch 2>/dev/null || true)"
if [[ -n "$configured" ]]; then
printf '%s' "$configured"
return 0
fi
head_ref="$(git -C "$repo" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
if [[ -n "$head_ref" ]]; then
printf '%s' "${head_ref#origin/}"
return 0
fi
for cand in main dev master; do
if git -C "$repo" show-ref --verify --quiet "refs/heads/${cand}"; then
printf '%s' "$cand"
return 0
fi
done
printf 'main'
}

base_ref_for_count() {
local repo="$1" base="$2"
if git -C "$repo" show-ref --verify --quiet "refs/heads/${base}"; then
printf '%s' "$base"
return 0
fi
if git -C "$repo" show-ref --verify --quiet "refs/remotes/origin/${base}"; then
printf 'origin/%s' "$base"
return 0
fi
return 1
}

dirty_count() {
local wt="$1"
git -C "$wt" status --porcelain -- . ":(exclude).omx/state/agent-file-locks.json" 2>/dev/null | grep -c . || true
}

payload="$(cat || true)"
event="$(printf '%s' "$payload" | json_field hook_event_name)"
stop_hook_active="$(printf '%s' "$payload" | json_field stop_hook_active)"
session_cwd="$(printf '%s' "$payload" | json_field cwd)"

if [[ -n "$event" && "$event" != "Stop" ]]; then
exit 0
fi
if [[ "$stop_hook_active" == "true" ]]; then
exit 0
fi

mode="$(normalize_mode "${GUARDEX_CLAUDE_STOP_FINISH:-commit}")"
if [[ "$mode" == "off" ]]; then
exit 0
fi

if [[ -z "$session_cwd" || ! -d "$session_cwd" ]]; then
session_cwd="$PWD"
fi

if ! worktree_path="$(git -C "$session_cwd" rev-parse --show-toplevel 2>/dev/null)"; then
exit 0
fi
if ! branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null)"; then
exit 0
fi
if [[ "$branch" != agent/* ]]; then
exit 0
fi

base_branch="$(resolve_base_branch "$worktree_path" "$branch")"
base_ref=""
ahead="0"
if base_ref="$(base_ref_for_count "$worktree_path" "$base_branch")"; then
ahead="$(git -C "$worktree_path" rev-list --count "${base_ref}..${branch}" 2>/dev/null || printf '0')"
fi
dirty="$(dirty_count "$worktree_path")"

if [[ "$dirty" -eq 0 && "$ahead" -eq 0 ]]; then
exit 0
fi

finish_cmd=(branch finish --branch "$branch" --base "$base_branch" --via-pr --wait-for-merge --cleanup)

if [[ "$dirty" -gt 0 && "$mode" == "clean" ]]; then
echo "[agent-claude-stop-finish] ${branch}: ${dirty} uncommitted change(s); clean-only mode left the sandbox open." >&2
echo "[agent-claude-stop-finish] Finish manually when ready: gx ${finish_cmd[*]}" >&2
exit 0
fi

echo "[agent-claude-stop-finish] ${branch}: handing off to gx ${finish_cmd[*]}" >&2
finish_output=""
if finish_output="$(run_guardex_cli "${finish_cmd[@]}" 2>&1)"; then
printf '%s\n' "$finish_output"
echo "[agent-claude-stop-finish] ${branch}: finish completed." >&2
exit 0
fi

printf '%s\n' "$finish_output" >&2
echo "[agent-claude-stop-finish] ${branch}: finish did not complete; sandbox kept at ${worktree_path}." >&2
echo "[agent-claude-stop-finish] Retry when ready: gx ${finish_cmd[*]}" >&2
exit 0
Loading
Loading