diff --git a/AGENTS.md b/AGENTS.md index 1b3c98e..2910a4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,12 +87,12 @@ bash scripts/agent-branch-start.sh "refactor-payment-pipeline" "claude-name" bash scripts/agent-branch-start.sh [--tier T0|T1|T2|T3] "" "claude-" ``` - Creates `agent/claude-/` under `.omx/agent-worktrees/`, scaffolds the OpenSpec change + plan workspaces (sized by tier), and records the bootstrap manifest. Missing `codex-auth` silently falls back to an empty snapshot slug (expected for Claude sessions). + Creates `agent/claude-/` under `.omc/agent-worktrees/`, scaffolds the OpenSpec change + plan workspaces (sized by tier), and records the bootstrap manifest. Codex sessions keep using `.omx/agent-worktrees/`. Missing `codex-auth` silently falls back to an empty snapshot slug (expected for Claude sessions). 2. Work inside the sandbox only: ```bash - cd .omx/agent-worktrees/agent__claude-__ + cd .omc/agent-worktrees/agent__claude-__ python3 scripts/agent-file-locks.py claim --branch "agent/claude-/" # implement + commit inside this worktree ``` diff --git a/README.md b/README.md index 6c8a5ae..d335e89 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ Running Codex across several existing worktrees (e.g. from VS Code Source Contro gx finish --all ``` +Codex sessions default to `.omx/agent-worktrees/`. Claude Code sessions default to `.omc/agent-worktrees/`, so Claude sandboxes stay under the Claude runtime folder instead of sharing the Codex root. + --- ## Visual reference @@ -161,7 +163,7 @@ gx setup --target /path/to/repo --parent-workspace-view ### Monorepo support -Setup auto-installs into every nested git repo (e.g. `apps/*/.git`). Submodules and worktrees under `.omx/agent-worktrees/` are skipped. +Setup auto-installs into every nested git repo (e.g. `apps/*/.git`). Submodules and worktrees under `.omx/agent-worktrees/` or `.omc/agent-worktrees/` are skipped. ```sh gx setup --target /mainfolder @@ -460,6 +462,7 @@ scripts/openspec/init-plan-workspace.sh .claude/commands/gitguardex.md .github/pull.yml.example .github/workflows/cr.yml +.omc/agent-worktrees .omx/state/agent-file-locks.json ``` diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 68d4933..bf1c671 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -183,6 +183,12 @@ const AGENTS_MARKER_START = ''; const AGENTS_MARKER_END = ''; const GITIGNORE_MARKER_START = '# multiagent-safety:START'; const GITIGNORE_MARKER_END = '# multiagent-safety:END'; +const CODEX_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees'); +const CLAUDE_WORKTREE_RELATIVE_DIR = path.join('.omc', 'agent-worktrees'); +const AGENT_WORKTREE_RELATIVE_DIRS = [ + CODEX_WORKTREE_RELATIVE_DIR, + CLAUDE_WORKTREE_RELATIVE_DIR, +]; const MANAGED_GITIGNORE_PATHS = [ '.omx/', '.omc/', @@ -202,7 +208,9 @@ const OMX_SCAFFOLD_DIRECTORIES = [ '.omx/state', '.omx/logs', '.omx/plans', - '.omx/agent-worktrees', + CODEX_WORKTREE_RELATIVE_DIR, + '.omc', + CLAUDE_WORKTREE_RELATIVE_DIR, ]; const OMX_SCAFFOLD_FILES = new Map([ ['.omx/notepad.md', '\n\n## WORKING MEMORY\n'], @@ -273,6 +281,19 @@ const AGENT_BOT_DESCRIPTIONS = [ ['agents', 'Start/stop review + cleanup bots for this repo'], ]; +function envFlagIsTruthy(raw) { + const lowered = String(raw || '').trim().toLowerCase(); + return lowered === '1' || lowered === 'true' || lowered === 'yes' || lowered === 'on'; +} + +function isClaudeCodeSession(env = process.env) { + return envFlagIsTruthy(env.CLAUDECODE) || Boolean(env.CLAUDE_CODE_SESSION_ID); +} + +function defaultAgentWorktreeRelativeDir(env = process.env) { + return isClaudeCodeSession(env) ? CLAUDE_WORKTREE_RELATIVE_DIR : CODEX_WORKTREE_RELATIVE_DIR; +} + const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in this repo. 1) Install: npm i -g @imdeadpool/guardex && gh --version @@ -522,8 +543,6 @@ const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([ '.venv', '.pnpm-store', ]); -const NESTED_REPO_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees'); - function discoverNestedGitRepos(rootPath, opts = {}) { const maxDepth = Number.isFinite(opts.maxDepth) ? Math.max(1, opts.maxDepth) : NESTED_REPO_DEFAULT_MAX_DEPTH; const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []); @@ -538,7 +557,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) { return path.resolve(resolvedRoot, raw); })(); - const workreeSkipAbsolute = path.join(resolvedRoot, NESTED_REPO_WORKTREE_RELATIVE_DIR); + const worktreeSkipAbsolutes = AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => path.join(resolvedRoot, relativeDir)); const found = new Set(); found.add(resolvedRoot); @@ -570,7 +589,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) { if (!entry.isDirectory() || entry.isSymbolicLink()) continue; if (shouldSkipDir(entry.name)) continue; - if (entryPath === workreeSkipAbsolute) continue; + if (worktreeSkipAbsolutes.includes(entryPath)) continue; walk(entryPath, depth + 1); } } @@ -1124,16 +1143,15 @@ function buildParentWorkspaceView(repoRoot) { const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`; const workspacePath = path.join(parentDir, workspaceFileName); const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.'); - const worktreesRelativePath = normalizeWorkspacePath( - path.join(repoRelativePath === '.' ? '' : repoRelativePath, '.omx', 'agent-worktrees'), - ); return { workspacePath, payload: { folders: [ { path: repoRelativePath }, - { path: worktreesRelativePath }, + ...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({ + path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)), + })), ], settings: { 'scm.alwaysShowRepositories': true, @@ -1341,7 +1359,7 @@ function protectedBaseSandboxBranchPrefix() { } function protectedBaseSandboxWorktreePath(repoRoot, branchName) { - return path.join(repoRoot, '.omx', 'agent-worktrees', branchName.replace(/\//g, '__')); + return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__')); } function gitRefExists(repoRoot, ref) { @@ -1876,9 +1894,8 @@ function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata if (allowedPaths.has(filePath)) { return false; } - return !( - filePath === NESTED_REPO_WORKTREE_RELATIVE_DIR - || filePath.startsWith(`${NESTED_REPO_WORKTREE_RELATIVE_DIR}/`) + return !AGENT_WORKTREE_RELATIVE_DIRS.some( + (relativeDir) => filePath === relativeDir || filePath.startsWith(`${relativeDir}/`), ); }); if (unexpectedPaths.length > 0) { diff --git a/openspec/changes/agent-codex-split-claude-worktrees-under-omc-2026-04-21-13-46/notes.md b/openspec/changes/agent-codex-split-claude-worktrees-under-omc-2026-04-21-13-46/notes.md new file mode 100644 index 0000000..3ea1120 --- /dev/null +++ b/openspec/changes/agent-codex-split-claude-worktrees-under-omc-2026-04-21-13-46/notes.md @@ -0,0 +1,5 @@ +# T1 Notes + +- Route Claude-triggered Guardex worktrees into `.omc/agent-worktrees` by default while keeping Codex worktrees under `.omx/agent-worktrees`. +- Persist the selected worktree root on each branch so `agent-branch-finish` and prune/recovery flows can resolve the correct sandbox root later. +- Expand setup/workspace discovery, docs, and install regressions so nested-repo scans and VS Code parent-workspace views treat both `.omx` and `.omc` agent-worktree roots as managed paths. diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh index 95ee231..fb528e1 100755 --- a/scripts/agent-branch-finish.sh +++ b/scripts/agent-branch-finish.sh @@ -144,12 +144,17 @@ else common_git_dir="$(cd "$repo_root/$common_git_dir_raw" && pwd -P)" fi repo_common_root="$(cd "$common_git_dir/.." && pwd -P)" -agent_worktree_root="${repo_common_root}/.omx/agent-worktrees" if [[ -z "$SOURCE_BRANCH" ]]; then SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" fi +stored_worktree_root_rel="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.guardexWorktreeRoot" || true)" +if [[ -z "$stored_worktree_root_rel" ]]; then + stored_worktree_root_rel=".omx/agent-worktrees" +fi +agent_worktree_root="${repo_common_root}/${stored_worktree_root_rel}" + if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then echo "[agent-branch-finish] --base requires a non-empty branch name." >&2 exit 1 diff --git a/scripts/agent-branch-start.sh b/scripts/agent-branch-start.sh index e56e376..7e3024b 100755 --- a/scripts/agent-branch-start.sh +++ b/scripts/agent-branch-start.sh @@ -5,7 +5,8 @@ TASK_NAME="task" AGENT_NAME="agent" BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 -WORKTREE_ROOT_REL=".omx/agent-worktrees" +WORKTREE_ROOT_REL="" +WORKTREE_ROOT_EXPLICIT=0 OPENSPEC_AUTO_INIT_RAW="${GUARDEX_OPENSPEC_AUTO_INIT:-false}" OPENSPEC_PLAN_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_PLAN_SLUG:-}" OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}" @@ -44,6 +45,7 @@ while [[ $# -gt 0 ]]; do ;; --worktree-root) WORKTREE_ROOT_REL="${2:-.omx/agent-worktrees}" + WORKTREE_ROOT_EXPLICIT=1 shift 2 ;; --) @@ -123,6 +125,36 @@ shorten_slug() { printf '%s' "$shortened" } +env_flag_truthy() { + local raw="${1:-}" + local lowered + lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" + case "$lowered" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +default_worktree_root_rel() { + local raw_agent="$1" + local override="${GUARDEX_AGENT_TYPE:-}" + local lowered_agent lowered_override + lowered_agent="$(printf '%s' "$raw_agent" | tr '[:upper:]' '[:lower:]')" + lowered_override="$(printf '%s' "$override" | tr '[:upper:]' '[:lower:]')" + + if [[ -n "${CLAUDE_CODE_SESSION_ID:-}" ]] || env_flag_truthy "${CLAUDECODE:-}"; then + printf '.omc/agent-worktrees' + return 0 + fi + + if [[ "$lowered_agent" == *claude* ]] || [[ "$lowered_override" == *claude* ]]; then + printf '.omc/agent-worktrees' + return 0 + fi + + printf '.omx/agent-worktrees' +} + # Collapse arbitrary agent identifiers to a clean role token. Priority: # GUARDEX_AGENT_TYPE env override, then recognizable claude/codex aliases, then # a small legacy compatibility set, then the literal requested role after slug @@ -415,6 +447,9 @@ fi task_slug="$(sanitize_slug "$TASK_NAME" "task")" agent_slug="$(normalize_role "$AGENT_NAME")" +if [[ "$WORKTREE_ROOT_EXPLICIT" -eq 0 ]]; then + WORKTREE_ROOT_REL="$(default_worktree_root_rel "$AGENT_NAME")" +fi branch_timestamp="$(compose_branch_timestamp)" branch_descriptor="$(compose_branch_descriptor "$task_slug" "$branch_timestamp")" branch_name_base="agent/${agent_slug}/${branch_descriptor}" @@ -499,6 +534,7 @@ if ! worktree_add_output="$(git -C "$repo_root" worktree add -b "$branch_name" " exit 1 fi git -C "$repo_root" config "branch.${branch_name}.guardexBase" "$BASE_BRANCH" >/dev/null 2>&1 || true +git -C "$repo_root" config "branch.${branch_name}.guardexWorktreeRoot" "$WORKTREE_ROOT_REL" >/dev/null 2>&1 || true # Fresh agent branches should start unpublished; clear any inherited base-branch tracking. git -C "$worktree_path" branch --unset-upstream "$branch_name" >/dev/null 2>&1 || true diff --git a/scripts/agent-worktree-prune.sh b/scripts/agent-worktree-prune.sh index 0644ba1..c967140 100755 --- a/scripts/agent-worktree-prune.sh +++ b/scripts/agent-worktree-prune.sh @@ -18,6 +18,10 @@ GH_BIN="${GUARDEX_GH_BIN:-gh}" PR_MERGED_LOOKUP_DISABLED=0 PR_MERGED_LOOKUP_LOADED=0 declare -A MERGED_PR_BRANCHES=() +WORKTREE_ROOT_RELS=( + ".omx/agent-worktrees" + ".omc/agent-worktrees" +) if [[ -n "$BASE_BRANCH" ]]; then BASE_BRANCH_EXPLICIT=1 @@ -77,13 +81,36 @@ fi repo_root="$(git rev-parse --show-toplevel)" current_pwd="$(pwd -P)" -worktree_root="${repo_root}/.omx/agent-worktrees" repo_common_dir="$( git -C "$repo_root" rev-parse --git-common-dir \ | awk -v root="$repo_root" '{ if ($0 ~ /^\//) { print $0 } else { print root "/" $0 } }' )" repo_common_dir="$(cd "$repo_common_dir" && pwd -P)" +resolve_worktree_root_rel_for_entry() { + local entry="$1" + case "$entry" in + */.omc/agent-worktrees/*) + printf '%s' '.omc/agent-worktrees' + ;; + *) + printf '%s' '.omx/agent-worktrees' + ;; + esac +} + +is_managed_worktree_path() { + local entry="$1" + local rel root + for rel in "${WORKTREE_ROOT_RELS[@]}"; do + root="${repo_root}/${rel}" + if [[ "$entry" == "${root}"/* ]]; then + return 0 + fi + done + return 1 +} + resolve_base_branch() { local configured="" local current="" @@ -308,54 +335,59 @@ relocated_foreign=0 skipped_foreign=0 relocate_foreign_worktree_entries() { - [[ -d "$worktree_root" ]] || return 0 - - local entry="" - for entry in "${worktree_root}"/*; do - [[ -d "$entry" ]] || continue - if ! git -C "$entry" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - continue - fi - - local entry_common_dir="" - entry_common_dir="$(resolve_worktree_common_dir "$entry" || true)" - [[ -n "$entry_common_dir" ]] || continue + local rel="" worktree_root="" entry="" + for rel in "${WORKTREE_ROOT_RELS[@]}"; do + worktree_root="${repo_root}/${rel}" + [[ -d "$worktree_root" ]] || continue + + for entry in "${worktree_root}"/*; do + [[ -d "$entry" ]] || continue + if ! git -C "$entry" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + continue + fi - if [[ "$entry_common_dir" == "$repo_common_dir" ]]; then - continue - fi + local entry_common_dir="" + entry_common_dir="$(resolve_worktree_common_dir "$entry" || true)" + [[ -n "$entry_common_dir" ]] || continue - if [[ "$(basename "$entry_common_dir")" != ".git" ]]; then - skipped_foreign=$((skipped_foreign + 1)) - echo "[agent-worktree-prune] Skipping foreign worktree with unsupported git common dir: ${entry}" - continue - fi + if [[ "$entry_common_dir" == "$repo_common_dir" ]]; then + continue + fi - local owner_repo_root - owner_repo_root="$(dirname "$entry_common_dir")" - local owner_worktree_root="${owner_repo_root}/.omx/agent-worktrees" - local target_path - target_path="$(select_unique_worktree_path "$owner_worktree_root" "$(basename "$entry")")" + if [[ "$(basename "$entry_common_dir")" != ".git" ]]; then + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Skipping foreign worktree with unsupported git common dir: ${entry}" + continue + fi - if [[ "$entry" == "$current_pwd" || "$current_pwd" == "${entry}"/* ]]; then - skipped_foreign=$((skipped_foreign + 1)) - echo "[agent-worktree-prune] Skipping active foreign worktree: ${entry}" - continue - fi + local owner_repo_root + owner_repo_root="$(dirname "$entry_common_dir")" + local owner_worktree_root_rel owner_worktree_root + owner_worktree_root_rel="$(resolve_worktree_root_rel_for_entry "$entry")" + owner_worktree_root="${owner_repo_root}/${owner_worktree_root_rel}" + local target_path + target_path="$(select_unique_worktree_path "$owner_worktree_root" "$(basename "$entry")")" + + if [[ "$entry" == "$current_pwd" || "$current_pwd" == "${entry}"/* ]]; then + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Skipping active foreign worktree: ${entry}" + continue + fi - echo "[agent-worktree-prune] Relocating foreign worktree to owning repo: ${entry} -> ${target_path}" - if [[ "$DRY_RUN" -eq 1 ]]; then - relocated_foreign=$((relocated_foreign + 1)) - continue - fi + echo "[agent-worktree-prune] Relocating foreign worktree to owning repo: ${entry} -> ${target_path}" + if [[ "$DRY_RUN" -eq 1 ]]; then + relocated_foreign=$((relocated_foreign + 1)) + continue + fi - mkdir -p "$owner_worktree_root" - if git -C "$owner_repo_root" worktree move "$entry" "$target_path" >/dev/null 2>&1; then - relocated_foreign=$((relocated_foreign + 1)) - else - skipped_foreign=$((skipped_foreign + 1)) - echo "[agent-worktree-prune] Failed to relocate foreign worktree: ${entry}" >&2 - fi + mkdir -p "$owner_worktree_root" + if git -C "$owner_repo_root" worktree move "$entry" "$target_path" >/dev/null 2>&1; then + relocated_foreign=$((relocated_foreign + 1)) + else + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Failed to relocate foreign worktree: ${entry}" >&2 + fi + done done } @@ -371,7 +403,9 @@ process_entry() { local branch_ref="$2" [[ -z "$wt" ]] && return - [[ "$wt" != "${worktree_root}"/* ]] && return + if ! is_managed_worktree_path "$wt"; then + return + fi local branch="" if [[ -n "$branch_ref" ]]; then diff --git a/templates/scripts/agent-branch-finish.sh b/templates/scripts/agent-branch-finish.sh index 95ee231..fb528e1 100755 --- a/templates/scripts/agent-branch-finish.sh +++ b/templates/scripts/agent-branch-finish.sh @@ -144,12 +144,17 @@ else common_git_dir="$(cd "$repo_root/$common_git_dir_raw" && pwd -P)" fi repo_common_root="$(cd "$common_git_dir/.." && pwd -P)" -agent_worktree_root="${repo_common_root}/.omx/agent-worktrees" if [[ -z "$SOURCE_BRANCH" ]]; then SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" fi +stored_worktree_root_rel="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.guardexWorktreeRoot" || true)" +if [[ -z "$stored_worktree_root_rel" ]]; then + stored_worktree_root_rel=".omx/agent-worktrees" +fi +agent_worktree_root="${repo_common_root}/${stored_worktree_root_rel}" + if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then echo "[agent-branch-finish] --base requires a non-empty branch name." >&2 exit 1 diff --git a/templates/scripts/agent-branch-start.sh b/templates/scripts/agent-branch-start.sh index e56e376..7e3024b 100755 --- a/templates/scripts/agent-branch-start.sh +++ b/templates/scripts/agent-branch-start.sh @@ -5,7 +5,8 @@ TASK_NAME="task" AGENT_NAME="agent" BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 -WORKTREE_ROOT_REL=".omx/agent-worktrees" +WORKTREE_ROOT_REL="" +WORKTREE_ROOT_EXPLICIT=0 OPENSPEC_AUTO_INIT_RAW="${GUARDEX_OPENSPEC_AUTO_INIT:-false}" OPENSPEC_PLAN_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_PLAN_SLUG:-}" OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}" @@ -44,6 +45,7 @@ while [[ $# -gt 0 ]]; do ;; --worktree-root) WORKTREE_ROOT_REL="${2:-.omx/agent-worktrees}" + WORKTREE_ROOT_EXPLICIT=1 shift 2 ;; --) @@ -123,6 +125,36 @@ shorten_slug() { printf '%s' "$shortened" } +env_flag_truthy() { + local raw="${1:-}" + local lowered + lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" + case "$lowered" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +default_worktree_root_rel() { + local raw_agent="$1" + local override="${GUARDEX_AGENT_TYPE:-}" + local lowered_agent lowered_override + lowered_agent="$(printf '%s' "$raw_agent" | tr '[:upper:]' '[:lower:]')" + lowered_override="$(printf '%s' "$override" | tr '[:upper:]' '[:lower:]')" + + if [[ -n "${CLAUDE_CODE_SESSION_ID:-}" ]] || env_flag_truthy "${CLAUDECODE:-}"; then + printf '.omc/agent-worktrees' + return 0 + fi + + if [[ "$lowered_agent" == *claude* ]] || [[ "$lowered_override" == *claude* ]]; then + printf '.omc/agent-worktrees' + return 0 + fi + + printf '.omx/agent-worktrees' +} + # Collapse arbitrary agent identifiers to a clean role token. Priority: # GUARDEX_AGENT_TYPE env override, then recognizable claude/codex aliases, then # a small legacy compatibility set, then the literal requested role after slug @@ -415,6 +447,9 @@ fi task_slug="$(sanitize_slug "$TASK_NAME" "task")" agent_slug="$(normalize_role "$AGENT_NAME")" +if [[ "$WORKTREE_ROOT_EXPLICIT" -eq 0 ]]; then + WORKTREE_ROOT_REL="$(default_worktree_root_rel "$AGENT_NAME")" +fi branch_timestamp="$(compose_branch_timestamp)" branch_descriptor="$(compose_branch_descriptor "$task_slug" "$branch_timestamp")" branch_name_base="agent/${agent_slug}/${branch_descriptor}" @@ -499,6 +534,7 @@ if ! worktree_add_output="$(git -C "$repo_root" worktree add -b "$branch_name" " exit 1 fi git -C "$repo_root" config "branch.${branch_name}.guardexBase" "$BASE_BRANCH" >/dev/null 2>&1 || true +git -C "$repo_root" config "branch.${branch_name}.guardexWorktreeRoot" "$WORKTREE_ROOT_REL" >/dev/null 2>&1 || true # Fresh agent branches should start unpublished; clear any inherited base-branch tracking. git -C "$worktree_path" branch --unset-upstream "$branch_name" >/dev/null 2>&1 || true diff --git a/templates/scripts/agent-worktree-prune.sh b/templates/scripts/agent-worktree-prune.sh index 0644ba1..c967140 100644 --- a/templates/scripts/agent-worktree-prune.sh +++ b/templates/scripts/agent-worktree-prune.sh @@ -18,6 +18,10 @@ GH_BIN="${GUARDEX_GH_BIN:-gh}" PR_MERGED_LOOKUP_DISABLED=0 PR_MERGED_LOOKUP_LOADED=0 declare -A MERGED_PR_BRANCHES=() +WORKTREE_ROOT_RELS=( + ".omx/agent-worktrees" + ".omc/agent-worktrees" +) if [[ -n "$BASE_BRANCH" ]]; then BASE_BRANCH_EXPLICIT=1 @@ -77,13 +81,36 @@ fi repo_root="$(git rev-parse --show-toplevel)" current_pwd="$(pwd -P)" -worktree_root="${repo_root}/.omx/agent-worktrees" repo_common_dir="$( git -C "$repo_root" rev-parse --git-common-dir \ | awk -v root="$repo_root" '{ if ($0 ~ /^\//) { print $0 } else { print root "/" $0 } }' )" repo_common_dir="$(cd "$repo_common_dir" && pwd -P)" +resolve_worktree_root_rel_for_entry() { + local entry="$1" + case "$entry" in + */.omc/agent-worktrees/*) + printf '%s' '.omc/agent-worktrees' + ;; + *) + printf '%s' '.omx/agent-worktrees' + ;; + esac +} + +is_managed_worktree_path() { + local entry="$1" + local rel root + for rel in "${WORKTREE_ROOT_RELS[@]}"; do + root="${repo_root}/${rel}" + if [[ "$entry" == "${root}"/* ]]; then + return 0 + fi + done + return 1 +} + resolve_base_branch() { local configured="" local current="" @@ -308,54 +335,59 @@ relocated_foreign=0 skipped_foreign=0 relocate_foreign_worktree_entries() { - [[ -d "$worktree_root" ]] || return 0 - - local entry="" - for entry in "${worktree_root}"/*; do - [[ -d "$entry" ]] || continue - if ! git -C "$entry" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - continue - fi - - local entry_common_dir="" - entry_common_dir="$(resolve_worktree_common_dir "$entry" || true)" - [[ -n "$entry_common_dir" ]] || continue + local rel="" worktree_root="" entry="" + for rel in "${WORKTREE_ROOT_RELS[@]}"; do + worktree_root="${repo_root}/${rel}" + [[ -d "$worktree_root" ]] || continue + + for entry in "${worktree_root}"/*; do + [[ -d "$entry" ]] || continue + if ! git -C "$entry" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + continue + fi - if [[ "$entry_common_dir" == "$repo_common_dir" ]]; then - continue - fi + local entry_common_dir="" + entry_common_dir="$(resolve_worktree_common_dir "$entry" || true)" + [[ -n "$entry_common_dir" ]] || continue - if [[ "$(basename "$entry_common_dir")" != ".git" ]]; then - skipped_foreign=$((skipped_foreign + 1)) - echo "[agent-worktree-prune] Skipping foreign worktree with unsupported git common dir: ${entry}" - continue - fi + if [[ "$entry_common_dir" == "$repo_common_dir" ]]; then + continue + fi - local owner_repo_root - owner_repo_root="$(dirname "$entry_common_dir")" - local owner_worktree_root="${owner_repo_root}/.omx/agent-worktrees" - local target_path - target_path="$(select_unique_worktree_path "$owner_worktree_root" "$(basename "$entry")")" + if [[ "$(basename "$entry_common_dir")" != ".git" ]]; then + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Skipping foreign worktree with unsupported git common dir: ${entry}" + continue + fi - if [[ "$entry" == "$current_pwd" || "$current_pwd" == "${entry}"/* ]]; then - skipped_foreign=$((skipped_foreign + 1)) - echo "[agent-worktree-prune] Skipping active foreign worktree: ${entry}" - continue - fi + local owner_repo_root + owner_repo_root="$(dirname "$entry_common_dir")" + local owner_worktree_root_rel owner_worktree_root + owner_worktree_root_rel="$(resolve_worktree_root_rel_for_entry "$entry")" + owner_worktree_root="${owner_repo_root}/${owner_worktree_root_rel}" + local target_path + target_path="$(select_unique_worktree_path "$owner_worktree_root" "$(basename "$entry")")" + + if [[ "$entry" == "$current_pwd" || "$current_pwd" == "${entry}"/* ]]; then + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Skipping active foreign worktree: ${entry}" + continue + fi - echo "[agent-worktree-prune] Relocating foreign worktree to owning repo: ${entry} -> ${target_path}" - if [[ "$DRY_RUN" -eq 1 ]]; then - relocated_foreign=$((relocated_foreign + 1)) - continue - fi + echo "[agent-worktree-prune] Relocating foreign worktree to owning repo: ${entry} -> ${target_path}" + if [[ "$DRY_RUN" -eq 1 ]]; then + relocated_foreign=$((relocated_foreign + 1)) + continue + fi - mkdir -p "$owner_worktree_root" - if git -C "$owner_repo_root" worktree move "$entry" "$target_path" >/dev/null 2>&1; then - relocated_foreign=$((relocated_foreign + 1)) - else - skipped_foreign=$((skipped_foreign + 1)) - echo "[agent-worktree-prune] Failed to relocate foreign worktree: ${entry}" >&2 - fi + mkdir -p "$owner_worktree_root" + if git -C "$owner_repo_root" worktree move "$entry" "$target_path" >/dev/null 2>&1; then + relocated_foreign=$((relocated_foreign + 1)) + else + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Failed to relocate foreign worktree: ${entry}" >&2 + fi + done done } @@ -371,7 +403,9 @@ process_entry() { local branch_ref="$2" [[ -z "$wt" ]] && return - [[ "$wt" != "${worktree_root}"/* ]] && return + if ! is_managed_worktree_path "$wt"; then + return + fi local branch="" if [[ -n "$branch_ref" ]]; then diff --git a/test/install.test.js b/test/install.test.js index 3c52ae5..a4eaa03 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -371,6 +371,8 @@ test('setup provisions workflow files and repo config', () => { '.omx/logs', '.omx/plans', '.omx/agent-worktrees', + '.omc', + '.omc/agent-worktrees', '.omx/notepad.md', '.omx/project-memory.json', 'scripts/agent-branch-start.sh', @@ -713,6 +715,7 @@ test('setup --parent-workspace-view creates one-level-up VS Code workspace for r assert.deepEqual(workspace.folders, [ { path: path.basename(repoDir) }, { path: `${path.basename(repoDir)}/.omx/agent-worktrees` }, + { path: `${path.basename(repoDir)}/.omc/agent-worktrees` }, ]); assert.equal(workspace.settings['scm.alwaysShowRepositories'], true); }); @@ -1129,6 +1132,8 @@ test('doctor on protected main bootstraps sandbox branch even before setup exist assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'logs')), true); assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'plans')), true); assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'agent-worktrees')), true); + assert.equal(fs.existsSync(path.join(repoDir, '.omc')), true); + assert.equal(fs.existsSync(path.join(repoDir, '.omc', 'agent-worktrees')), true); assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'notepad.md')), true); assert.equal(fs.existsSync(path.join(repoDir, '.omx', 'project-memory.json')), true); @@ -1543,6 +1548,49 @@ test('setup agent-branch-start keeps role-datetime branch labels compact (v7.0.3 assert.doesNotMatch(branchLeaf, /zeus|portasmosonma|admin-recodee/); }); +test('setup agent-branch-start routes Claude sessions into .omc worktrees and stores the selected root', () => { + const repoDir = initRepo(); + + let result = runNode(['setup', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + seedCommit(repoDir); + + result = runCmd( + 'bash', + ['scripts/agent-branch-start.sh', 'claude-session-task', 'bot', 'dev'], + repoDir, + { + env: { + CLAUDECODE: '1', + GUARDEX_AGENT_TYPE: 'planner', + }, + }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const createdBranch = extractCreatedBranch(result.stdout); + assert.match( + createdBranch, + /^agent\/planner\/claude-session-task-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}$/, + ); + + const createdWorktree = extractCreatedWorktree(result.stdout); + assert.match( + createdWorktree, + new RegExp( + `${escapeRegexLiteral(repoDir)}/\\.omc/agent-worktrees/${escapeRegexLiteral(createdBranch.replaceAll('/', '__'))}$`, + ), + ); + + const storedWorktreeRoot = runCmd( + 'git', + ['config', '--get', `branch.${createdBranch}.guardexWorktreeRoot`], + repoDir, + ); + assert.equal(storedWorktreeRoot.status, 0, storedWorktreeRoot.stderr || storedWorktreeRoot.stdout); + assert.equal(storedWorktreeRoot.stdout.trim(), '.omc/agent-worktrees'); +}); + test('setup agent-branch-start supports optional OpenSpec auto-bootstrap toggles', () => { const repoDir = initRepo(); @@ -1821,7 +1869,7 @@ test('agent-branch-start links dependency node_modules directories into new work } }); -test('agent-branch-finish infers base from source branch metadata and updates main worktree', () => { +test('agent-branch-finish handles Claude-root worktrees when inferring base from source branch metadata', () => { const repoDir = initRepoOnBranch('main'); seedCommit(repoDir); attachOriginRemoteForBranch(repoDir, 'main'); @@ -1837,10 +1885,13 @@ test('agent-branch-finish infers base from source branch metadata and updates ma result = runCmd('git', ['push', 'origin', 'main'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - result = runCmd('bash', ['scripts/agent-branch-start.sh', 'finish-from-dev', 'bot'], repoDir); + result = runCmd('bash', ['scripts/agent-branch-start.sh', 'finish-from-dev', 'bot'], repoDir, { + env: { CLAUDECODE: '1' }, + }); assert.equal(result.status, 0, result.stderr || result.stdout); const agentBranch = extractCreatedBranch(result.stdout); const agentWorktree = extractCreatedWorktree(result.stdout); + assert.match(agentWorktree, new RegExp(`${escapeRegexLiteral(repoDir)}/\\.omc/agent-worktrees/`)); commitFile(agentWorktree, 'agent-finish-main.txt', 'merged via inferred main base\n', 'agent change for main'); @@ -3015,7 +3066,7 @@ test('codex-agent restores local branch and falls back to safe worktree start wh const combinedOutput = `${launch.stdout}\n${launch.stderr}`; assert.match(combinedOutput, /Unsafe starter output/); assert.match(combinedOutput, /\[agent-branch-start\] Created branch: agent\/planner\//); - assert.match(combinedOutput, /Origin remote does not provide a mergeable PR surface; skipping auto-finish merge\/PR pipeline/); + assert.match(combinedOutput, /\[codex-agent\] Auto-finish skipped.*no mergeable remote context/); const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); assert.match(