diff --git a/.githooks/post-merge b/.githooks/post-merge index 20dfd41..dc4e005 100755 --- a/.githooks/post-merge +++ b/.githooks/post-merge @@ -1,43 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -if [[ "${MUSAFETY_DISABLE_POST_MERGE_CLEANUP:-0}" == "1" ]]; then - exit 0 +# Auto-sync agent worktrees when the base branch is updated in this worktree. +if [[ -x "scripts/agent-sync-on-base-update.sh" ]]; then + bash scripts/agent-sync-on-base-update.sh --quiet || true fi - -repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" -if [[ -z "$repo_root" ]]; then - exit 0 -fi - -branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" -if [[ -z "$branch" || "$branch" == "HEAD" ]]; then - exit 0 -fi - -base_branch="${MUSAFETY_BASE_BRANCH:-$(git -C "$repo_root" config --get multiagent.baseBranch || true)}" -if [[ -z "$base_branch" ]]; then - base_branch="dev" -fi - -if [[ "$branch" != "$base_branch" ]]; then - exit 0 -fi - -cli_path="$repo_root/bin/multiagent-safety.js" -if [[ ! -f "$cli_path" ]]; then - exit 0 -fi - -node_bin="${MUSAFETY_NODE_BIN:-node}" -if ! command -v "$node_bin" >/dev/null 2>&1; then - exit 0 -fi - -"$node_bin" "$cli_path" cleanup \ - --target "$repo_root" \ - --base "$base_branch" \ - --include-pr-merged \ - --keep-clean-worktrees >/dev/null 2>&1 || true - -exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 6db0df4..358defe 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -9,6 +9,12 @@ if [[ -z "$branch" ]]; then exit 0 fi +git_dir="$(git rev-parse --git-dir 2>/dev/null || true)" +is_linked_worktree=0 +if [[ -n "$git_dir" && "$git_dir" == *"/worktrees/"* ]]; then + is_linked_worktree=1 +fi + if [[ "${ALLOW_COMMIT_ON_PROTECTED_BRANCH:-0}" == "1" ]]; then exit 0 fi @@ -24,7 +30,7 @@ if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" fi is_vscode_git_context=0 -if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" ]]; then +if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" || "${TERM_PROGRAM:-}" == "vscode" ]]; then is_vscode_git_context=1 fi @@ -68,6 +74,163 @@ case "$codex_require_agent_branch" in *) should_require_codex_agent_branch=1 ;; esac +sanitize_slug() { + local raw="$1" + local fallback="${2:-task}" + local slug + slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')" + if [[ -z "$slug" ]]; then + slug="$fallback" + fi + printf '%s' "$slug" +} + +resolve_agent_branch_base() { + local branch_name="$1" + git config --get "branch.${branch_name}.musafetyBase" || true +} + +is_helper_agent_branch() { + local branch_name="$1" + local base_branch="" + base_branch="$(resolve_agent_branch_base "$branch_name")" + [[ "$base_branch" == agent/* ]] +} + +ensure_agent_branch_openspec_workspace() { + local branch_name="$1" + local change_slug change_dir specs_dir capability_slug branch_base + local missing_workspace=0 + local openspec_script="scripts/openspec/init-change-workspace.sh" + + branch_base="$(git config --get "branch.${branch_name}.musafetyBase" || true)" + if [[ "$branch_base" == agent/* ]]; then + echo "[agent-openspec-guard] Skipping OpenSpec change workspace bootstrap for helper branch '${branch_name}' (base '${branch_base}')." + return 0 + fi + + change_slug="$(sanitize_slug "${branch_name//\//-}" "change")" + change_dir="openspec/changes/${change_slug}" + specs_dir="${change_dir}/specs" + + if [[ ! -f "${change_dir}/.openspec.yaml" || ! -f "${change_dir}/proposal.md" || ! -f "${change_dir}/tasks.md" ]]; then + missing_workspace=1 + elif [[ ! -d "$specs_dir" ]] || ! find "$specs_dir" -mindepth 2 -maxdepth 2 -type f -name spec.md | grep -q .; then + missing_workspace=1 + fi + + if [[ "$missing_workspace" -ne 1 ]]; then + return 0 + fi + + if [[ ! -f "$openspec_script" ]]; then + cat >&2 <" +MSG + exit 1 + fi + + if [[ ! -x "$openspec_script" ]]; then + chmod +x "$openspec_script" 2>/dev/null || true + fi + + capability_slug="$(sanitize_slug "${branch_name##*/}" "general-behavior")" + init_output="" + if ! init_output="$(bash "$openspec_script" "$change_slug" "$capability_slug" 2>&1)"; then + printf '%s\n' "$init_output" >&2 + cat >&2 </dev/null 2>&1 || true + fi + fi + + echo "[agent-openspec-guard] Bootstrapped OpenSpec change workspace: ${change_dir}" +} + +should_auto_reroute_protected_branch() { + local raw="${MUSAFETY_AUTO_REROUTE_PROTECTED_BRANCH:-$(git config --get multiagent.autoRerouteProtectedBranch || true)}" + local lowered="" + if [[ -z "$raw" ]]; then + raw="true" + fi + lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" + case "$lowered" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +auto_reroute_protected_branch_commit() { + local branch_name="$1" + local starter_script="scripts/agent-branch-start.sh" + local task_name="${MUSAFETY_AUTO_REROUTE_TASK_NAME:-protected-branch-commit-reroute}" + local agent_name="${MUSAFETY_AUTO_REROUTE_AGENT_NAME:-auto-reroute}" + local changed_paths="" + local start_output="" + local start_status=0 + local new_branch="" + local worktree_path="" + + changed_paths="$({ + git diff --name-only + git diff --cached --name-only + git ls-files --others --exclude-standard + } | sed '/^$/d' | sort -u)" + + if [[ -z "$changed_paths" ]]; then + return 1 + fi + + if [[ ! -x "$starter_script" ]]; then + return 1 + fi + + set +e + start_output="$(bash "$starter_script" "$task_name" "$agent_name" "$branch_name" 2>&1)" + start_status=$? + set -e + + if [[ "$start_status" -ne 0 ]]; then + printf '%s\n' "$start_output" >&2 + return 1 + fi + + new_branch="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Created branch: //p' | tail -n 1)" + worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n 1)" + + printf '%s\n' "$start_output" >&2 + cat >&2 <} + worktree: ${worktree_path:-} +Continue work and commit from that agent worktree. +MSG + return 0 +} + is_codex_managed_only_commit_on_protected=0 if [[ "$is_codex_session" == "1" && "$is_protected_branch" == "1" ]]; then deleted_paths="$(git diff --cached --name-only --diff-filter=D)" @@ -123,17 +286,32 @@ MSG fi fi -if [[ "$is_protected_branch" == "1" ]]; then - if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then - if [[ "$allow_vscode_protected_branch_writes" == "1" ]]; then - exit 0 - fi +if [[ "$is_codex_session" == "1" && "$branch" == agent/* ]]; then + if [[ "$is_linked_worktree" != "1" && "${MUSAFETY_ALLOW_CODEX_ON_PRIMARY_WORKTREE:-0}" != "1" ]]; then + cat >&2 <<'MSG' +[codex-worktree-guard] Codex agent commits are blocked from the primary checkout. +Use a linked agent worktree for agent/* branches: + bash scripts/agent-branch-start.sh "" "" +Then commit from the printed worktree path. + +Temporary bypass (not recommended): + MUSAFETY_ALLOW_CODEX_ON_PRIMARY_WORKTREE=1 git commit ... +MSG + exit 1 fi +fi - if [[ "$is_unborn_branch" == "1" && "$is_codex_session" != "1" ]]; then +if [[ "$is_protected_branch" == "1" ]]; then + if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then exit 0 fi + if should_auto_reroute_protected_branch; then + if auto_reroute_protected_branch_commit "$branch"; then + exit 1 + fi + fi + git_dir="$(git rev-parse --git-dir)" if [[ -f "$git_dir/MERGE_HEAD" ]]; then exit 0 @@ -145,8 +323,10 @@ Use an agent branch first: bash scripts/agent-branch-start.sh "" "" After finishing work: bash scripts/agent-branch-finish.sh +Auto-reroute can be disabled (not recommended): + MUSAFETY_AUTO_REROUTE_PROTECTED_BRANCH=0 git commit ... -Optional repo opt-in for VS Code protected-branch commits: +Optional repo override for manual VS Code protected-branch commits: git config multiagent.allowVscodeProtectedBranchWrites true Temporary bypass (not recommended): @@ -156,6 +336,13 @@ MSG fi if [[ "$branch" == agent/* ]]; then + if is_helper_agent_branch "$branch"; then + helper_base="$(resolve_agent_branch_base "$branch")" + echo "[agent-openspec-guard] Skipping OpenSpec change workspace bootstrap for helper branch '${branch}' (base '${helper_base}')." + else + ensure_agent_branch_openspec_workspace "$branch" + fi + if ! python3 scripts/agent-file-locks.py validate --branch "$branch" --staged; then cat >&2 <<'MSG' [agent-branch-guard] Agent branch commits require file ownership locks. diff --git a/.gitignore b/.gitignore index 5dd1021..49c374e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,76 @@ +# Project-specific +refs/ +poc/ +WEBU/ +examples/WEBU/ +examples/claudia/ +examples/claduio/ + +# Python +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Env/config +.env +.env.* +!.env.example +.python-version + +# Build artifacts +build/ +dist/ +app/static/ +apps/app/static/ +deploy/helm/*/charts/ + +# Node +node_modules/ +frontend/node_modules/ +frontend/dist/ +frontend/coverage/ + +# Editors/OS +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Local +.local/ +.worktrees/ +certs/ + +.codex-lb/ +.sisyphus/ + +.specstory/ +logs/* +!logs/.gitkeep +.dev-ports.json +apps/logs/*.log + +.agents/hooks/state/ +.agents/.personality_migration +.agents/version.json +.agents/log/ +.venv + .omx/ -node_modules -oh-my-codex/ + +# Keep OpenSpec plan workspaces local +openspec/plan/* +!openspec/plan/README.md +!openspec/plan/PLANS.md +!openspec/plan/migrate-multica-runtime-model/ +!openspec/plan/migrate-multica-runtime-model/** +!openspec/plan/role-artifact-smoke-main/ +!openspec/plan/role-artifact-smoke-main/** # multiagent-safety:START .omx/ @@ -15,9 +85,9 @@ scripts/openspec/init-plan-workspace.sh scripts/openspec/init-change-workspace.sh .githooks/pre-commit .githooks/pre-push -.githooks/post-merge oh-my-codex/ .codex/skills/guardex/SKILL.md +.codex/skills/guardex-merge-skills-to-dev/SKILL.md .claude/commands/guardex.md .omx/state/agent-file-locks.json # multiagent-safety:END diff --git a/AGENTS.md b/AGENTS.md index ed71b79..09e51e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,87 +1,282 @@ - -YOU ARE AN AUTONOMOUS CODING AGENT. EXECUTE TASKS TO COMPLETION WITHOUT ASKING FOR PERMISSION. -DO NOT STOP TO ASK "SHOULD I PROCEED?" — PROCEED. DO NOT WAIT FOR CONFIRMATION ON OBVIOUS NEXT STEPS. -IF BLOCKED, TRY AN ALTERNATIVE APPROACH. ONLY ASK WHEN TRULY AMBIGUOUS OR DESTRUCTIVE. -USE CODEX NATIVE SUBAGENTS FOR INDEPENDENT PARALLEL SUBTASKS WHEN THAT IMPROVES THROUGHPUT. THIS IS COMPLEMENTARY TO OMX TEAM MODE. - - -# oh-my-codex - Intelligent Multi-Agent Orchestration - -This AGENTS.md is the top-level operating contract for this repository. - -## Operating principles - -- Solve the task directly when possible. -- Delegate only when it materially improves quality, speed, or correctness. -- Keep progress short, concrete, and useful. -- Prefer evidence over assumption; verify before claiming completion. -- Use the lightest path that preserves quality. -- Check official docs before implementing with unfamiliar SDKs/APIs. - -## Working agreements - -- For cleanup/refactor/deslop work: write a cleanup plan first. -- Lock behavior with regression tests before cleanup edits when needed. -- Treat `main` and any currently checked-out base branch as read-only workspaces. -- Every new session must start by creating an isolated agent branch/worktree via `scripts/agent-branch-start.sh` before making edits. -- If edits are found on `main`/base by mistake, immediately move them to a dedicated agent branch/worktree before continuing. -- In-place agent branching is disallowed; keep the visible local/base checkout unchanged and do all edits in dedicated agent worktrees. -- Prefer deletion over addition. -- Reuse existing patterns before introducing new abstractions. -- No new dependencies without explicit request. -- When publishing or bumping a version, update release notes in the same change (`README.md` release notes section and the release body when tagging). -- Keep diffs small, reviewable, and reversible. -- Run lint/typecheck/tests/static analysis after changes. -- Final reports must include: changed files, simplifications made, and remaining risks. - -## Delegation rules - -Default posture: work directly. - -Mode guidance: -- Use deep interview for unclear requirements. -- Use ralplan for plan/tradeoff/test-shape consensus. -- Use team only for multi-lane coordinated execution. -- Use ralph only for persistent single-owner completion loops. -- Otherwise execute directly in solo mode. - -## Verification - -- Verify before claiming completion. -- Run dependent tasks sequentially. -- If verification fails, continue iterating instead of stopping early. -- Before concluding, confirm: no pending work, tests pass, no known errors, and evidence collected. - -## Lore commit protocol - -Commit messages should capture decision records using git trailers. - -Recommended trailers: -- Constraint: -- Rejected: -- Confidence: -- Scope-risk: -- Reversibility: -- Directive: -- Tested: -- Not-tested: -- Related: - -## Cancellation - -Use cancel mode/workflow only when work is complete, user says stop, or a hard blocker prevents meaningful progress. - -## State management - -OMX runtime state typically lives under `.omx/`: -- `.omx/state/` -- `.omx/notepad.md` -- `.omx/project-memory.json` -- `.omx/plans/` -- `.omx/logs/` +# AGENTS + +# ExecPlans + +When writing complex features or significant refactors, use an ExecPlan (as described in .agent/PLANS.md) from design to implementation. + +## Environment + +- Python: .venv/bin/python (uv, CPython 3.13.3) +- GitHub auth for git/API is available via env vars: `GITHUB_USER`, `GITHUB_TOKEN` (PAT). Do not hardcode or commit tokens. +- For authenticated git over HTTPS in automation, use: `https://x-access-token:${GITHUB_TOKEN}@github.com//.git` + +## Code Conventions + +The `/project-conventions` skill is auto-activated on code edits (PreToolUse guard). + +| Convention | Location | When | +| ----------------------- | ------------------------------------- | ---------------------------- | +| Code Conventions (Full) | `/project-conventions` skill | On code edit (auto-enforced) | +| Git Workflow | `.agents/conventions/git-workflow.md` | Commit / PR | + +## UI/UX Skill Default (UI Pro Max) + +- For any frontend/UI/UX request (new page, component, styling, layout, redesign, or UI review), **always load and apply** `.codex/skills/ui-ux-pro-max/SKILL.md` first. +- Treat `ui-ux-pro-max` as the default UI decision surface unless the user explicitly asks to skip it. +- Follow the skill workflow before implementation (including design-system guidance) so generated UI stays consistent and high quality. + +## Git Hygiene Preference + +- Prefer committing and pushing completed work by default unless the user explicitly asks to keep it local. +- Do not commit ephemeral local runtime artifacts (for example `.dev-ports.json` and `apps/logs/*.log`). + +## CLI Session Detection Lock (Dashboard / Accounts) + +The current CLI session detection behavior is intentionally frozen and must stay order-sensitive. + +Canonical implementation: + +- `frontend/src/utils/account-working.ts` + - `hasActiveCliSessionSignal(...)` + - `hasFreshLiveTelemetry(...)` + - `getFreshDebugRawSampleCount(...)` + +Locked detection cascade (do not reorder): + +1. `codexAuth.hasLiveSession` +2. Fresh live telemetry / live session count +3. Tracked session counters (`codexTrackedSessionCount` / `codexSessionCount`) +4. Fresh debug raw samples + +Regression lock: + +- `frontend/src/utils/account-working.test.ts` (`hasActiveCliSessionSignal` + `isAccountWorkingNow` suites) + +Rule for future edits: + +- Do not change this cascade unless explicitly requested by the user and accompanied by updated regression tests proving the new behavior. + +## Rust Runtime Proxy Lock (`rust/codex-lb-runtime/src/main.rs`) + +The Rust runtime should stay a **thin proxy** for app APIs unless explicitly requested otherwise. + +Canonical routing posture: + +- Keep wildcard pass-through routes enabled: + - `/api/{*path}` + - `/backend-api/{*path}` + - `/v1/{*path}` +- Prefer generic proxy handlers over large explicit per-endpoint Rust route lists. + +Auth/session rule: + +- Treat Python as the source of truth for dashboard auth/session enforcement (`validate_dashboard_session` and related dependencies). +- Do not duplicate or drift auth/session logic in Rust endpoint copies unless the user explicitly requests moving that logic into Rust and corresponding tests are updated. + +Parallel-work safety: + +- When editing `main.rs`, assume other agents may be changing Python API surfaces at the same time. +- Prefer compatibility-preserving proxy behavior over endpoint-specific Rust implementations that can break on concurrent backend changes. +- `main.rs` is now lock-protected for parallel agent sessions. Before **any** edit to + `rust/codex-lb-runtime/src/main.rs`, claim ownership: + - `python3 scripts/main_rs_lock.py claim --owner "" --branch ""` + - Check owner/lease: `python3 scripts/main_rs_lock.py status` + - Release when done: `python3 scripts/main_rs_lock.py release --branch ""` +- Lock ownership is **branch-scoped**; if lock branch and current branch differ, edits are blocked. +- `main.rs` is **integrator-only** by default: branch must match `agent/integrator/...` (configurable via `MAIN_RS_INTEGRATOR_AGENT`). +- If the lock is held by another agent, do not edit `main.rs`; continue in owned module files or hand off to the integrator. + +Required verification before claiming Rust runtime changes are complete: + +- Confirm wildcard proxy routes still exist in `app_with_state(...)`. +- Confirm proxy helpers are still present and used by wildcard routes. +- Run: + - `cargo check -p codex-lb-runtime` + - `cargo test -p codex-lb-runtime --no-run` +- If route/auth behavior changed, add/adjust Rust runtime tests in `rust/codex-lb-runtime/src/main.rs` test module. + +## Multi-Agent Execution Contract (Default) + +Use this contract whenever multiple agents are active in parallel. + +0. Session plan comment + read gate (required) + +- Before editing, each agent must post a short session comment/handoff note that includes: + - plan/change name (or checkpoint id), + - owned files/scope, + - intended action. +- Before deleting/replacing code, each agent must read the latest session comments/handoffs first and confirm the target code is in their owned scope. +- If ownership is unclear or overlaps, stop that edit, post a blocker comment, and let the leader/integrator reassign scope. +- For git isolation, each agent must start on a dedicated branch/worktree via `scripts/agent-branch-start.sh "" ""`. +- Local `dev` is protected: never edit, stage, or commit task changes directly on `dev`. +- If currently checked out on `dev`, create the agent branch/worktree first and only then begin edits. +- Creating or attaching an agent worktree must never switch the primary local checkout branch. Keep the caller checkout on its original branch (typically `dev`) and do all branch switches only inside the agent worktree path. +- Each agent must claim file ownership before edits: + - `python3 scripts/agent-file-locks.py claim --branch "" ` +- If `main.rs` is in scope, claim branch lock first: + - `python3 scripts/main_rs_lock.py claim --owner "" --branch ""` +- Non-integrator branches must not edit `main.rs` unless explicit emergency override is approved. +- Agent completion must use `scripts/agent-branch-finish.sh` (preflight conflict check, merge into `dev`, push, delete agent branch). +- Mandatory completion chain for any `agent/*` branch: `commit -> push -> create/update PR -> merged`. +- Local commit-only completion is prohibited on `agent/*` branches. +- `agent-branch-start` and `agent-branch-finish` must fast-forward local `dev` from `origin/dev` before branch creation/merge, so `dev` always pulls latest remote changes first. +- Pre-commit guard blocks `agent/*` commits when staged files are unclaimed or claimed by another branch. +- Pre-commit guard blocks `agent/*` commits that stage `main.rs` without a valid main-rs lock for that same branch. + +1. Explicit ownership before edits + +- Assign each agent clear file/module ownership. +- Do not edit files outside your assigned scope unless the leader reassigns ownership. + +2. No destructive rewrites of shared behavior + +- Do not delete, replace, or “simplify away” critical paths (auth/session, proxy routes, production API wiring) without: + - explicit user request or approved plan checkpoint, and + - updated regression tests proving intended behavior. + +3. Preserve parallel safety + +- Assume other agents are editing nearby code concurrently. +- Never revert unrelated changes authored by others. +- If another change conflicts with your approach, adapt and report the conflict in handoff. + +4. Verify before completion + +- Run required local checks for the area you changed. +- For Rust runtime changes, minimum gate: + - `bun run verify:rust-runtime-guardrails` + - `cargo check -p codex-lb-runtime` + - `cargo test -p codex-lb-runtime --no-run` +- Do not mark work complete without command output evidence. + +5. Required handoff format (every agent) + +- Files changed +- Behavior touched +- Verification commands + results +- Risks / follow-ups + +6. Integration-first finalization + +- Use one integrator pass before final completion to confirm: + - no critical behavior was removed unintentionally, + - ownership boundaries were respected, + - session plan comments/handoffs were followed, + - verification gates passed. + +## Versioning Rule + +## Workflow (OpenSpec-first) + +This repo uses **OpenSpec as the primary workflow and SSOT** for change-driven development. + +### OpenSpec philosophy (enforced) + +- fluid, not rigid +- iterative, not waterfall +- easy to apply, not process-heavy +- built for brownfield and greenfield work +- scalable from solo projects to large teams + +### How to work (default) + +1. Use the default artifact-guided flow first: `/opsx:propose ` -> `/opsx:apply` -> `/opsx:archive`. +2. For **every** repo change (feature, fix, refactor, chore, test, config, docs), create/update an OpenSpec change in `openspec/changes/**` before editing code. + Exception: helper agent branches that target another `agent/*` base branch are execution-only assists and must not create standalone OpenSpec change/spec/tasks docs; keep documentation on the owner change branch. +3. Keep artifacts editable throughout implementation (proposal/spec/design/tasks are living docs, not rigid phase gates). +4. Implement from `tasks.md`; keep code and specs in sync (update `spec.md` as behavior changes). +5. Keep `tasks.md` checkpoint status updated continuously during execution; mark items as soon as they complete (do not batch-update at the end). +6. Validate specs locally: `openspec validate --specs`. +7. Verify before archiving (`/opsx:verify ` when applicable); never archive unverified changes. + +### OpenSpec tooling freshness (required) + +- Keep the global CLI current: + - `npm install -g @fission-ai/openspec@latest` +- Refresh project-local AI guidance/slash commands after updates: + - `openspec update` +- If expanded workflow commands are needed (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:sync`, `/opsx:bulk-archive`, `/opsx:onboard`), select a profile and refresh: + - `openspec config profile ` + - `openspec update` + +### Source of Truth + +- **Specs/Design/Tasks (SSOT)**: `openspec/` + - Active changes: `openspec/changes//` + - Main specs: `openspec/specs//spec.md` + - Archived changes: `openspec/changes/archive/YYYY-MM-DD-/` + +## Documentation & Release Notes + +- **Do not add/update feature or behavior documentation under `docs/`**. Use OpenSpec context docs under `openspec/specs//context.md` (or change-level context under `openspec/changes//context.md`) as the SSOT. +- **Do not edit `CHANGELOG.md` directly.** Leave changelog updates to the release process; record change notes in OpenSpec artifacts instead. + +### Documentation Model (Spec + Context) + +- `spec.md` is the **normative SSOT** and should contain only testable requirements. +- Use `openspec/specs//context.md` for **free-form context** (purpose, rationale, examples, ops notes). +- If context grows, split into `overview.md`, `rationale.md`, `examples.md`, or `ops.md` within the same capability folder. +- Change-level notes live in `openspec/changes//context.md` or `notes.md`, then **sync stable context** back into the main context docs. + +Prompting cue (use when writing docs): +"Keep `spec.md` strictly for requirements. Add/update `context.md` with purpose, decisions, constraints, failure modes, and at least one concrete example." + +### Commands (recommended) + +- Default flow (recommended): `/opsx:propose ` -> `/opsx:apply` -> `/opsx:archive` +- Expanded flow start: `/opsx:new ` +- Continue artifacts: `/opsx:continue ` +- Fast-forward artifacts: `/opsx:ff ` +- Verify before archive: `/opsx:verify ` +- Sync delta specs → main specs: `/opsx:sync ` +- Bulk archive completed changes: `/opsx:bulk-archive` +- Guided onboarding workflow: `/opsx:onboard` +- Create/refresh plan workspace: `/opsx:plan ` +- Update plan checkpoint: `/opsx:checkpoint ` +- Watch team -> plan checkpoints: `/opsx:watch-plan ` + +## Plan Workspace Contract (`openspec/plan`) + +Use `openspec/plan/` as the durable pre-implementation planning layer. + +Planner narrative plans must follow `openspec/plan/PLANS.md`. + +Required shape for each plan: + +```text +openspec/plan// + summary.md + checkpoints.md + planner/plan.md + planner/tasks.md + architect/tasks.md + critic/tasks.md + executor/tasks.md + writer/tasks.md + verifier/tasks.md +``` + +Role folders may additionally include `README.md`, notes, and evidence artifacts. + +When operating in ralplan/team-style planning flows: + +1. Create/maintain the plan workspace at `openspec/plan//`. +2. Ensure every participating role has a `tasks.md`. +3. Keep checklist sections visible in each `tasks.md`: + - `## 1. Spec` + - `## 2. Tests` + - `## 3. Implementation` + - `## 4. Checkpoints` +4. Update checkboxes during execution so status remains human-readable in OpenSpec style. + +Scaffold command: + +```bash +scripts/openspec/init-plan-workspace.sh +``` -## Multi-Agent Execution Contract (GX) +## Multi-Agent Execution Contract (multiagent-safety) 0. Session plan comment + read gate (required) @@ -92,17 +287,17 @@ OMX runtime state typically lives under `.omx/`: - Before deleting/replacing code, each agent must read the latest session comments/handoffs first and confirm the target code is in their owned scope. - If ownership is unclear or overlaps, stop that edit, post a blocker comment, and let the leader/integrator reassign scope. - For git isolation, each agent must start on a dedicated branch via `scripts/agent-branch-start.sh "" ""`. -- In-place branch mode is disallowed: never switch the active local/base checkout to an agent branch. - Treat the base branch (`main` or the user's current local base branch) as read-only while the agent branch is active. - Agent completion defaults to `scripts/codex-agent.sh`, which auto-finishes the branch (auto-commit changed files, push/create PR, attempt merge, and pull the local base branch after merge). -- OMX completion policy: when a task is done, the agent must commit the task changes, push the agent branch, and create/update a PR for those changes (via `codex-agent` or `agent-branch-finish`). - Auto-finish now waits for required checks/merge and then cleans merged sandbox branch/worktree by default. -- Use `--no-cleanup` only when you explicitly need to keep a merged sandbox for audit/debug follow-up. -- If codex-agent auto-finish cannot complete, immediately run `scripts/agent-branch-finish.sh --branch "" --base dev --via-pr --wait-for-merge` and keep the branch open until checks/review pass. -- If merge/rebase conflicts block auto-finish, run a conflict-resolution review pass in that sandbox branch, then rerun `agent-branch-finish.sh --base dev --via-pr --wait-for-merge` until merged. +- Cleanup for merged `agent/*` branches is mandatory; `agent-branch-finish` must not report completion while local/remote refs or sandbox worktree cleanup is still pending. +- If codex-agent auto-finish cannot complete, immediately run `scripts/agent-branch-finish.sh --branch "" --via-pr --wait-for-merge` and keep the branch open until checks/review pass. +- If merge/rebase conflicts block auto-finish, run a conflict-resolution review pass in that sandbox branch, then rerun `agent-branch-finish.sh --via-pr` until merged. - Completion is not valid until these are true: commit exists on the agent branch, branch is pushed to `origin`, and PR/merge status is produced by `agent-branch-finish.sh` or `codex-agent`. -- For every new task, including follow-up work in the same chat/session, if an assigned agent sub-branch/worktree is already open, continue in that sub-branch; otherwise create a fresh one from the current local base snapshot with `scripts/agent-branch-start.sh`. +- Completion report must include the PR URL and explicit merge state (`OPEN`/`MERGED`); without this, the task is not complete. +- For every new task, if an assigned agent sub-branch/worktree is already open, continue in that sub-branch; otherwise create a fresh one from the current local base snapshot with `scripts/agent-branch-start.sh`. - Never implement directly on the local/base branch checkout; keep it unchanged and perform all edits in the agent sub-branch/worktree. +- Agent worktree startup must preserve the primary local checkout branch exactly as-is; branch switching is allowed only inside the agent worktree. - If the change publishes or bumps a version, the same change must also update release notes/changelog entries. 1. Explicit ownership before edits @@ -128,35 +323,64 @@ OMX runtime state typically lives under `.omx/`: - Verification commands + results - Risks / follow-ups -## OpenSpec Workspaces (required for agent sub-branch changes) +## OpenSpec Multi-Codex Change Management (owner + joined Codexes) -OMX Codex execution flows must use OpenSpec. `scripts/codex-agent.sh` bootstraps -per-branch OpenSpec workspaces automatically: +Use this checklist for active OpenSpec changes when one owner Codex may receive help from joined Codexes (including other worktree Codexes). Apply this to current changes such as `agent-codex-admin-compastor-com-retry-merge-zeus-improve-integrate-ref-cleanup`. -```text -openspec/changes// -openspec/plan// -``` +Joined helper branches that merge into another `agent/*` branch are documentation-exempt assist lanes; they implement assigned scope only and report handoff evidence back to the owner branch artifacts. -For manual `scripts/agent-branch-start.sh` usage, enable auto-bootstrap with -`MUSAFETY_OPENSPEC_AUTO_INIT=true` or scaffold manually before implementation: +Checkpoint discipline (required): update the active change `tasks.md` during work, checkpoint-by-checkpoint, and keep checkbox state synchronized with current progress. -```bash -bash scripts/openspec/init-change-workspace.sh "" "" -bash scripts/openspec/init-plan-workspace.sh "" -``` +## 1. Specification -Expected change shape: +- [ ] 1.1 Finalize proposal scope and acceptance criteria for the active change. +- [ ] 1.2 Define normative requirements in the change spec (`specs//spec.md`). -```text -openspec/changes// - .openspec.yaml - proposal.md - tasks.md - specs//spec.md +## 2. Implementation + +- [ ] 2.1 Implement scoped behavior changes. +- [ ] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [ ] 3.1 Run targeted project verification commands. +- [ ] 3.2 Run `openspec validate --type change --strict`. +- [ ] 3.3 Run `openspec validate --specs`. + +## 4. Collaboration (only when another Codex joins) + +- [ ] 4.1 Owner Codex records each joined Codex (branch/worktree + scope) before accepting work. +- [ ] 4.2 Joined Codexes may review, propose solution tasks, and implement only within assigned scope. +- [ ] 4.3 Owner Codex must acknowledge joined outputs (accept/revise/reject) before moving to cleanup. +- [ ] 4.4 If no Codex joined, mark this section `N/A` and continue. + +## 5. Cleanup + +- [ ] 5.1 Commit the changes to the agent worktree branch. +- [ ] 5.2 Merge the agent branch into the current local base branch (for example `dev`). +- [ ] 5.3 After successful merge, clean up the merged agent worktree branch on both `origin` and local. + +For change specs that need explicit baseline requirement wording, use this pattern: + +## ADDED Requirements + +### Requirement: retry-merge-zeus-improve-integrate-ref-cleanup behavior +The system SHALL enforce retry-merge-zeus-improve-integrate-ref-cleanup behavior as defined by this change. + +#### Scenario: Baseline acceptance +- **WHEN** retry-merge-zeus-improve-integrate-ref-cleanup behavior is exercised +- **THEN** the expected outcome is produced +- **AND** regressions are covered by tests. + +## OpenSpec Plan Workspace (recommended) + +When work needs a durable planning phase, scaffold a plan workspace before implementation: + +```bash +bash scripts/openspec/init-plan-workspace.sh "" ``` -Expected plan shape: +Expected shape: ```text openspec/plan// diff --git a/README.md b/README.md index ec8bee1..92de6ce 100644 --- a/README.md +++ b/README.md @@ -372,9 +372,15 @@ npm pack --dry-run ## Release notes +### v5.0.17 + +- Bumped package version from `5.0.16` to `5.0.17` for the next npm publish. + ### v5.0.16 - Fixed `gx doctor` runtime crash (`parseDoctorArgs is not defined`) by restoring the doctor argument parser for `--target` and `--strict`. +- Fixed `gx doctor` command routing so the repair-first doctor flow remains the active command path (duplicate legacy doctor definition no longer overrides it). +- Updated worktree change detection to run `git status --porcelain --untracked-files=normal --` for consistent normal untracked-file behavior. - Added regression coverage that asserts the doctor parser function exists in `bin/multiagent-safety.js`. - Bumped package version from `5.0.15` to `5.0.16`. diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 1aa45c0..14aed11 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -2117,7 +2117,14 @@ function mapWorktreePathsByBranch(repoRoot) { } function hasSignificantWorkingTreeChanges(worktreePath) { - const result = run('git', ['-C', worktreePath, 'status', '--porcelain']); + const result = run('git', [ + '-C', + worktreePath, + 'status', + '--porcelain', + '--untracked-files=normal', + '--', + ]); if (result.status !== 0) { return true; } @@ -4659,7 +4666,7 @@ function initWorkspace(rawArgs) { } } -function doctor(rawArgs) { +function doctorAudit(rawArgs) { const options = parseDoctorArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); const failures = []; diff --git a/openspec/changes/agent-codex-admin-compastor-com-openspec-cleanup-checklist/proposal.md b/openspec/changes/agent-codex-admin-compastor-com-openspec-cleanup-checklist/proposal.md new file mode 100644 index 0000000..313eca3 --- /dev/null +++ b/openspec/changes/agent-codex-admin-compastor-com-openspec-cleanup-checklist/proposal.md @@ -0,0 +1,11 @@ +## Why + +- TODO: describe the user/problem outcome this change addresses. + +## What Changes + +- TODO: summarize the intended behavior and scope. + +## Impact + +- TODO: call out risks, rollout notes, and affected surfaces. diff --git a/openspec/changes/agent-codex-admin-compastor-com-openspec-cleanup-checklist/specs/openspec-cleanup-checklist/spec.md b/openspec/changes/agent-codex-admin-compastor-com-openspec-cleanup-checklist/specs/openspec-cleanup-checklist/spec.md new file mode 100644 index 0000000..a239fc9 --- /dev/null +++ b/openspec/changes/agent-codex-admin-compastor-com-openspec-cleanup-checklist/specs/openspec-cleanup-checklist/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: openspec-cleanup-checklist behavior +The system SHALL enforce openspec-cleanup-checklist behavior as defined by this change. + +#### Scenario: Baseline acceptance +- **WHEN** openspec-cleanup-checklist behavior is exercised +- **THEN** the expected outcome is produced +- **AND** regressions are covered by tests. diff --git a/openspec/changes/agent-codex-admin-compastor-com-openspec-cleanup-checklist/tasks.md b/openspec/changes/agent-codex-admin-compastor-com-openspec-cleanup-checklist/tasks.md new file mode 100644 index 0000000..c757b26 --- /dev/null +++ b/openspec/changes/agent-codex-admin-compastor-com-openspec-cleanup-checklist/tasks.md @@ -0,0 +1,15 @@ +## 1. Specification + +- [ ] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-admin-compastor-com-openspec-cleanup-checklist`. +- [ ] 1.2 Define normative requirements in `specs/openspec-cleanup-checklist/spec.md`. + +## 2. Implementation + +- [ ] 2.1 Implement scoped behavior changes. +- [ ] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [ ] 3.1 Run targeted project verification commands. +- [ ] 3.2 Run `openspec validate agent-codex-admin-compastor-com-openspec-cleanup-checklist --type change --strict`. +- [ ] 3.3 Run `openspec validate --specs`. diff --git a/openspec/changes/agent-codex-webubusiness-gmail-com-parent-workspace-setup/tasks.md b/openspec/changes/agent-codex-webubusiness-gmail-com-parent-workspace-setup/tasks.md new file mode 100644 index 0000000..8f3c2c0 --- /dev/null +++ b/openspec/changes/agent-codex-webubusiness-gmail-com-parent-workspace-setup/tasks.md @@ -0,0 +1,20 @@ +## 1. Specification + +- [x] 1.1 Confirm restore scope includes guardex hooks/scripts/AGENTS plus related docs needed for merge safety. +- [x] 1.2 Confirm release bump scope is the next patch version for npm publish (`5.0.17`). + +## 2. Implementation + +- [x] 2.1 Carry restored guardex workflow changes onto this branch and keep them committed. +- [x] 2.2 Restore publishable guardex package manifest fields and set package version to `5.0.17`. +- [x] 2.3 Add release note entry for `v5.0.17`. + +## 3. Verification + +- [x] 3.1 Validate shell scripts/hooks syntax with `bash -n`. +- [x] 3.2 Validate package metadata with `node -p "require('./package.json').version"`. +- [x] 3.3 Validate publish packaging with `npm pack --dry-run`. + +## 4. Cleanup + +- [x] 4.1 Branch is ready for `agent-branch-finish` merge flow to `main`. diff --git a/package.json b/package.json index 3e587c4..8f578c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@imdeadpool/guardex", - "version": "5.0.16", + "version": "5.0.17", "description": "GuardeX: the Guardian T-Rex for your repo, with hardened multi-agent git guardrails.", "license": "MIT", "preferGlobal": true, diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh index 9be4944..3db4886 100755 --- a/scripts/agent-branch-finish.sh +++ b/scripts/agent-branch-finish.sh @@ -4,6 +4,7 @@ set -euo pipefail BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 SOURCE_BRANCH="" +SOURCE_BRANCH_EXPLICIT=0 PUSH_ENABLED=1 DELETE_REMOTE_BRANCH=0 DELETE_REMOTE_BRANCH_EXPLICIT=0 @@ -13,6 +14,11 @@ CLEANUP_AFTER_MERGE_RAW="${MUSAFETY_FINISH_CLEANUP:-false}" WAIT_FOR_MERGE_RAW="${MUSAFETY_FINISH_WAIT_FOR_MERGE:-false}" WAIT_TIMEOUT_SECONDS_RAW="${MUSAFETY_FINISH_WAIT_TIMEOUT_SECONDS:-1800}" WAIT_POLL_SECONDS_RAW="${MUSAFETY_FINISH_WAIT_POLL_SECONDS:-10}" +REQUIRE_REMOTE_GATES_RAW="${MUSAFETY_REQUIRE_REMOTE_GATES:-false}" +ENFORCE_AGENT_CLEANUP_RAW="${MUSAFETY_ENFORCE_AGENT_CLEANUP:-true}" +PR_REF="${MUSAFETY_GH_PR_REF:-}" +GH_REPO_REF="${MUSAFETY_GH_REPO:-}" +NO_CLEANUP_REQUESTED=0 normalize_bool() { local raw="${1:-}" @@ -48,6 +54,8 @@ CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "0")" WAIT_FOR_MERGE="$(normalize_bool "$WAIT_FOR_MERGE_RAW" "0")" WAIT_TIMEOUT_SECONDS="$(normalize_int "$WAIT_TIMEOUT_SECONDS_RAW" "1800" "30")" WAIT_POLL_SECONDS="$(normalize_int "$WAIT_POLL_SECONDS_RAW" "10" "0")" +REQUIRE_REMOTE_GATES="$(normalize_bool "$REQUIRE_REMOTE_GATES_RAW" "0")" +ENFORCE_AGENT_CLEANUP="$(normalize_bool "$ENFORCE_AGENT_CLEANUP_RAW" "1")" while [[ $# -gt 0 ]]; do case "$1" in @@ -58,6 +66,7 @@ while [[ $# -gt 0 ]]; do ;; --branch) SOURCE_BRANCH="${2:-}" + SOURCE_BRANCH_EXPLICIT=1 shift 2 ;; --no-push) @@ -80,6 +89,7 @@ while [[ $# -gt 0 ]]; do ;; --no-cleanup) CLEANUP_AFTER_MERGE=0 + NO_CLEANUP_REQUESTED=1 shift ;; --wait-for-merge) @@ -102,6 +112,22 @@ while [[ $# -gt 0 ]]; do MERGE_MODE="${2:-auto}" shift 2 ;; + --pr) + PR_REF="${2:-}" + shift 2 + ;; + --repo) + GH_REPO_REF="${2:-}" + shift 2 + ;; + --require-remote-gates) + REQUIRE_REMOTE_GATES=1 + shift + ;; + --no-require-remote-gates) + REQUIRE_REMOTE_GATES=0 + shift + ;; --via-pr) MERGE_MODE="pr" shift @@ -112,16 +138,12 @@ while [[ $# -gt 0 ]]; do ;; *) echo "[agent-branch-finish] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--branch ] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds ] [--wait-poll-seconds ] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2 + echo "Usage: $0 [--base ] [--branch ] [--no-push] [--cleanup|--no-cleanup] [--wait-for-merge|--no-wait-for-merge] [--wait-timeout-seconds ] [--wait-poll-seconds ] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only] [--pr ] [--repo ] [--require-remote-gates|--no-require-remote-gates]" >&2 exit 1 ;; esac done -if [[ "$CLEANUP_AFTER_MERGE" -eq 1 && "$DELETE_REMOTE_BRANCH_EXPLICIT" -eq 0 ]]; then - DELETE_REMOTE_BRANCH=1 -fi - case "$MERGE_MODE" in auto|direct|pr) ;; *) @@ -146,10 +168,65 @@ fi repo_common_root="$(cd "$common_git_dir/.." && pwd -P)" agent_worktree_root="${repo_common_root}/.omx/agent-worktrees" +infer_agent_branch_from_worktree_path() { + local wt_path="$1" + local wt_name="" + local suffix="" + local candidate="" + + if [[ "$wt_path" != "${agent_worktree_root}"/* ]]; then + return 1 + fi + + wt_name="$(basename "$wt_path")" + if [[ "$wt_name" != agent__* ]]; then + return 1 + fi + + suffix="${wt_name#agent__}" + candidate="agent/${suffix//__//}" + if [[ ! "$candidate" =~ ^agent/[A-Za-z0-9._/-]+$ ]]; then + return 1 + fi + printf '%s' "$candidate" +} + if [[ -z "$SOURCE_BRANCH" ]]; then SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" fi +if [[ "$SOURCE_BRANCH_EXPLICIT" -eq 0 && "$SOURCE_BRANCH" == "HEAD" ]]; then + detached_hint_branch="" + detached_recover_cmd="" + detached_recover_branch="" + detached_conflicts="$(git -C "$current_worktree" diff --name-only --diff-filter=U 2>/dev/null || true)" + + detached_hint_branch="$(infer_agent_branch_from_worktree_path "$current_worktree" || true)" + if [[ -n "$detached_hint_branch" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${detached_hint_branch}"; then + detached_recover_cmd="git -C \"$current_worktree\" checkout \"$detached_hint_branch\"" + elif [[ -n "$detached_hint_branch" ]]; then + detached_recover_cmd="git -C \"$current_worktree\" checkout -b \"$detached_hint_branch\"" + else + detached_recover_branch="agent/recover/detached-$(date +%Y%m%d-%H%M%S)" + detached_recover_cmd="git -C \"$current_worktree\" checkout -b \"$detached_recover_branch\"" + fi + + echo "[agent-branch-finish] Current worktree is in detached HEAD; finish requires a branch context." >&2 + if [[ -n "$detached_conflicts" ]]; then + echo "[agent-branch-finish] Unmerged files detected in this detached worktree:" >&2 + while IFS= read -r file; do + [[ -n "$file" ]] && echo " - ${file}" >&2 + done <<< "$detached_conflicts" + fi + echo "[agent-branch-finish] Recover branch context with: ${detached_recover_cmd}" >&2 + if [[ -n "$detached_hint_branch" ]]; then + echo "[agent-branch-finish] Then resolve/commit and rerun finish with: bash scripts/agent-branch-finish.sh --branch \"${detached_hint_branch}\" --base dev --via-pr --wait-for-merge --cleanup" >&2 + else + echo "[agent-branch-finish] Then resolve/commit and rerun finish with --branch ." >&2 + fi + exit 1 +fi + if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then echo "[agent-branch-finish] --base requires a non-empty branch name." >&2 exit 1 @@ -162,6 +239,28 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then fi fi +if [[ -z "$BASE_BRANCH" ]]; then + branch_stored_base="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.musafetyBase" || true)" + if [[ -n "$branch_stored_base" ]]; then + BASE_BRANCH="$branch_stored_base" + fi +fi + +if [[ -z "$BASE_BRANCH" ]]; then + source_upstream="$(git -C "$repo_root" for-each-ref --count=1 --format='%(upstream:short)' "refs/heads/${SOURCE_BRANCH}" || true)" + source_upstream="${source_upstream:-}" + if [[ "$source_upstream" == */* ]]; then + BASE_BRANCH="${source_upstream#*/}" + fi +fi + +if [[ -z "$BASE_BRANCH" ]]; then + current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -n "$current_branch" && "$current_branch" != "HEAD" && "$current_branch" != "$SOURCE_BRANCH" ]]; then + BASE_BRANCH="$current_branch" + fi +fi + if [[ -z "$BASE_BRANCH" ]]; then BASE_BRANCH="dev" fi @@ -172,6 +271,32 @@ if [[ "$SOURCE_BRANCH" == "$BASE_BRANCH" ]]; then exit 1 fi +cleanup_mandatory=0 +if [[ "$ENFORCE_AGENT_CLEANUP" -eq 1 && "$PUSH_ENABLED" -eq 1 && "$SOURCE_BRANCH" =~ ^agent/ ]]; then + cleanup_mandatory=1 +fi + +if [[ "$cleanup_mandatory" -eq 1 ]]; then + if [[ "$CLEANUP_AFTER_MERGE" -ne 1 ]]; then + if [[ "$NO_CLEANUP_REQUESTED" -eq 1 ]]; then + echo "[agent-branch-finish] Ignoring --no-cleanup for '${SOURCE_BRANCH}': cleanup is mandatory for merged agent branches." >&2 + else + echo "[agent-branch-finish] Enforcing mandatory cleanup for merged agent branch '${SOURCE_BRANCH}'." >&2 + fi + CLEANUP_AFTER_MERGE=1 + fi + if [[ "$DELETE_REMOTE_BRANCH" -ne 1 ]]; then + if [[ "$DELETE_REMOTE_BRANCH_EXPLICIT" -eq 1 ]]; then + echo "[agent-branch-finish] Ignoring --keep-remote-branch for '${SOURCE_BRANCH}': remote branch deletion is required by cleanup policy." >&2 + fi + DELETE_REMOTE_BRANCH=1 + fi +fi + +if [[ "$CLEANUP_AFTER_MERGE" -eq 1 && "$DELETE_REMOTE_BRANCH_EXPLICIT" -eq 0 ]]; then + DELETE_REMOTE_BRANCH=1 +fi + if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${SOURCE_BRANCH}"; then echo "[agent-branch-finish] Local source branch does not exist: ${SOURCE_BRANCH}" >&2 exit 1 @@ -191,6 +316,107 @@ is_clean_worktree() { && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" } +validate_openspec_tasks_gate() { + local branch="$1" + local branch_root="$2" + local helper_base="" + + if [[ ! "$branch" =~ ^agent/ ]]; then + return 0 + fi + + helper_base="$(git -C "$repo_root" config --get "branch.${branch}.musafetyBase" || true)" + if [[ "$BASE_BRANCH" == agent/* ]] || [[ "$helper_base" == agent/* ]]; then + if [[ -z "$helper_base" && "$BASE_BRANCH" == agent/* ]]; then + helper_base="$BASE_BRANCH" + fi + echo "[agent-branch-finish] Skipping OpenSpec tasks gate for helper branch '${branch}' (base '${helper_base}')." >&2 + return 0 + fi + + local change_slug="${branch//\//-}" + local tasks_file="${branch_root}/openspec/changes/${change_slug}/tasks.md" + local use_collaboration_flow=0 + local cleanup_step="4" + local required_section_labels=( + "## 1. Specification" + "## 2. Implementation" + "## 3. Verification" + ) + local required_section_patterns=( + '^## 1\. Specification([[:space:]].*)?$' + '^## 2\. Implementation([[:space:]].*)?$' + '^## 3\. Verification([[:space:]].*)?$' + ) + + if [[ ! -f "$tasks_file" ]]; then + echo "[agent-branch-finish] OpenSpec tasks gate failed for '${branch}'." >&2 + echo "[agent-branch-finish] Missing required file: openspec/changes/${change_slug}/tasks.md" >&2 + echo "[agent-branch-finish] Finish is blocked until the checklist file exists and is fully updated." >&2 + exit 1 + fi + + if grep -Eq '^## 4\. Collaboration([[:space:]].*)?$' "$tasks_file" && grep -Eq '^## 5\. Cleanup([[:space:]].*)?$' "$tasks_file"; then + use_collaboration_flow=1 + cleanup_step="5" + required_section_labels+=("## 4. Collaboration" "## 5. Cleanup") + required_section_patterns+=('^## 4\. Collaboration([[:space:]].*)?$' '^## 5\. Cleanup([[:space:]].*)?$') + else + required_section_labels+=("## 4. Cleanup") + required_section_patterns+=('^## 4\. Cleanup([[:space:]].*)?$') + fi + + local missing_section=0 + local i + for i in "${!required_section_labels[@]}"; do + local section_label="${required_section_labels[$i]}" + local section_pattern="${required_section_patterns[$i]}" + if ! grep -Eq "$section_pattern" "$tasks_file"; then + missing_section=1 + echo "[agent-branch-finish] OpenSpec tasks gate failed for '${branch}'." >&2 + echo "[agent-branch-finish] Missing required section in ${tasks_file}: ${section_label}" >&2 + fi + done + if [[ "$missing_section" -eq 1 ]]; then + echo "[agent-branch-finish] Finish is blocked until all required checklist sections are present." >&2 + exit 1 + fi + + if ! grep -Eq "^[[:space:]]*-[[:space:]]*\\[[ xX]\\][[:space:]]*${cleanup_step}\\.1\\b" "$tasks_file"; then + echo "[agent-branch-finish] OpenSpec tasks gate failed for '${branch}'." >&2 + echo "[agent-branch-finish] Missing required cleanup readiness item in ${tasks_file}: ${cleanup_step}.1" >&2 + echo "[agent-branch-finish] Finish is blocked until cleanup item ${cleanup_step}.1 is present." >&2 + exit 1 + fi + + local gate_unchecked + gate_unchecked="$(awk -v collab_flow="$use_collaboration_flow" ' + BEGIN { scope = "" } + /^## 1\. Specification([[:space:]].*)?$/ { scope = "spec"; next } + /^## 2\. Implementation([[:space:]].*)?$/ { scope = "impl"; next } + /^## 3\. Verification([[:space:]].*)?$/ { scope = "verify"; next } + collab_flow == 1 && /^## 4\. Collaboration([[:space:]].*)?$/ { scope = "collaboration"; next } + collab_flow == 1 && /^## 5\. Cleanup([[:space:]].*)?$/ { scope = "cleanup"; next } + collab_flow != 1 && /^## 4\. Cleanup([[:space:]].*)?$/ { scope = "cleanup"; next } + /^## / { scope = "" } + + scope ~ /^(spec|impl|verify)$/ && /^[[:space:]]*-[[:space:]]*\[ \]/ { + print NR ":" $0 + next + } + ' "$tasks_file")" + + if [[ -n "$gate_unchecked" ]]; then + echo "[agent-branch-finish] OpenSpec tasks gate failed for '${branch}'." >&2 + echo "[agent-branch-finish] Unchecked checklist items remain in ${tasks_file}:" >&2 + while IFS= read -r line; do + [[ -n "$line" ]] && echo " - ${line}" >&2 + done <<< "$gate_unchecked" + echo "[agent-branch-finish] Finish is blocked until all items in sections 1-3 are marked [x]." >&2 + exit 1 + fi +} + source_worktree="$(get_worktree_for_branch "$SOURCE_BRANCH")" created_source_probe=0 source_probe_path="" @@ -203,6 +429,8 @@ if [[ -z "$source_worktree" ]]; then created_source_probe=1 fi +validate_openspec_tasks_gate "$SOURCE_BRANCH" "$source_worktree" + if ! is_clean_worktree "$source_worktree"; then echo "[agent-branch-finish] Source worktree is not clean for '${SOURCE_BRANCH}': ${source_worktree}" >&2 echo "[agent-branch-finish] Commit/stash changes on the source branch before finishing." >&2 @@ -254,22 +482,99 @@ if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify - fi fi -integration_worktree="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)" -integration_branch="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)" -mkdir -p "$(dirname "$integration_worktree")" +integration_worktree="" +integration_branch="" +use_integration_worktree=1 +if [[ "$MERGE_MODE" == "pr" && "$PUSH_ENABLED" -eq 1 ]]; then + # PR mode merges by pushing the source branch and letting GitHub merge. + # Skip creating temporary local integration worktrees in this lane. + use_integration_worktree=0 +fi -git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null -git -C "$integration_worktree" checkout -b "$integration_branch" >/dev/null +if [[ "$use_integration_worktree" -eq 1 ]]; then + integration_worktree="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)" + integration_branch="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)" + mkdir -p "$(dirname "$integration_worktree")" + git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null + git -C "$integration_worktree" checkout -b "$integration_branch" >/dev/null +else + integration_worktree="" + integration_branch="" +fi -cleanup() { +transient_worktrees_released=0 + +release_transient_worktrees() { + if [[ "$transient_worktrees_released" -eq 1 ]]; then + return + fi if [[ -d "$integration_worktree" ]]; then git -C "$repo_root" worktree remove "$integration_worktree" --force >/dev/null 2>&1 || true fi + if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${integration_branch}"; then + local integration_branch_worktree="" + integration_branch_worktree="$(get_worktree_for_branch "$integration_branch" || true)" + if [[ -z "$integration_branch_worktree" ]]; then + git -C "$repo_root" branch -D "$integration_branch" >/dev/null 2>&1 || true + fi + fi if [[ "$created_source_probe" -eq 1 && -n "$source_probe_path" && -d "$source_probe_path" ]]; then git -C "$repo_root" worktree remove "$source_probe_path" --force >/dev/null 2>&1 || true fi + if [[ "$created_source_probe" -eq 1 ]]; then + source_worktree="$repo_root" + created_source_probe=0 + source_probe_path="" + fi + transient_worktrees_released=1 +} + +cleanup() { + release_transient_worktrees +} + +handle_interrupt() { + cleanup + exit 130 } + trap cleanup EXIT +trap handle_interrupt INT TERM HUP +merge_gate_json="" + +run_merge_quality_gate() { + if [[ ! -x "${repo_root}/scripts/omx-merge-gate.sh" ]]; then + echo "[agent-branch-finish] Required merge-gate helper is missing: scripts/omx-merge-gate.sh" >&2 + echo "[agent-branch-finish] Repair with: gx doctor (or restore the script) before finishing." >&2 + return 1 + fi + + local gate_args=(--branch "$SOURCE_BRANCH" --base "$BASE_BRANCH" --output-dir "${repo_root}/.omx/state/merge-gates") + if [[ -n "$PR_REF" ]]; then + gate_args+=(--pr "$PR_REF") + fi + if [[ -n "$GH_REPO_REF" ]]; then + gate_args+=(--repo "$GH_REPO_REF") + fi + if [[ "$REQUIRE_REMOTE_GATES" -eq 1 ]]; then + gate_args+=(--require-remote) + fi + + local gate_output="" + if gate_output="$(bash "${repo_root}/scripts/omx-merge-gate.sh" "${gate_args[@]}" 2>&1)"; then + printf '%s\n' "$gate_output" + merge_gate_json="$(printf '%s\n' "$gate_output" | sed -n 's/^Merge gate JSON: //p' | tail -n1)" + return 0 + fi + + merge_gate_json="$(printf '%s\n' "$gate_output" | sed -n 's/^Merge gate JSON: //p' | tail -n1)" + echo "$gate_output" >&2 + echo "[agent-branch-finish] Merge-quality gate failed. Resolve blockers before finishing." >&2 + if [[ -n "$merge_gate_json" ]]; then + echo "[agent-branch-finish] Gate details: ${merge_gate_json}" >&2 + fi + return 1 +} if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then git -C "$source_worktree" fetch origin "$BASE_BRANCH" --quiet @@ -292,12 +597,18 @@ if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRA git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true fi -if ! git -C "$integration_worktree" merge --no-ff --no-edit "$SOURCE_BRANCH"; then - echo "[agent-branch-finish] Merge conflict detected while merging '${SOURCE_BRANCH}' into '${BASE_BRANCH}'." >&2 - git -C "$integration_worktree" merge --abort >/dev/null 2>&1 || true +if ! run_merge_quality_gate; then exit 1 fi +if [[ "$use_integration_worktree" -eq 1 ]]; then + if ! git -C "$integration_worktree" merge --no-ff --no-edit "$SOURCE_BRANCH"; then + echo "[agent-branch-finish] Merge conflict detected while merging '${SOURCE_BRANCH}' into '${BASE_BRANCH}'." >&2 + git -C "$integration_worktree" merge --abort >/dev/null 2>&1 || true + exit 1 + fi +fi + merge_completed=1 merge_status="direct" direct_push_error="" @@ -314,26 +625,90 @@ is_local_branch_delete_error() { return 1 } -read_pr_state() { - local state_line - state_line="$("$GH_BIN" pr view "$SOURCE_BRANCH" --json state,mergedAt,url --jq '[.state, (.mergedAt // ""), (.url // "")] | join("\u001f")' 2>/dev/null || true)" - if [[ -z "$state_line" ]]; then - return 1 +delete_local_source_branch() { + local branch="$1" + local base_branch="$2" + local delete_output="" + local branch_upstream="" + local safe_delete_ref="" + local safe_to_force_delete=0 + + branch_upstream="$(git -C "$repo_root" for-each-ref --count=1 --format='%(upstream:short)' "refs/heads/${branch}" || true)" + branch_upstream="${branch_upstream:-}" + if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then + safe_delete_ref="origin/${base_branch}" + elif git -C "$repo_root" show-ref --verify --quiet "refs/heads/${base_branch}"; then + safe_delete_ref="${base_branch}" + fi + if [[ -n "$safe_delete_ref" ]] && git -C "$repo_root" merge-base --is-ancestor "$branch" "$safe_delete_ref" >/dev/null 2>&1; then + safe_to_force_delete=1 + fi + + if delete_output="$(git -C "$repo_root" branch -d "$branch" 2>&1)"; then + return 0 + fi + + if [[ "$branch_upstream" == "origin/${branch}" ]]; then + git -C "$repo_root" branch --unset-upstream "$branch" >/dev/null 2>&1 || true + if git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then + echo "[agent-branch-finish] Cleared upstream tracking for '${branch}' to complete local merged-branch cleanup." >&2 + return 0 + fi fi - local parsed_state="" - local parsed_merged_at="" - local parsed_url="" - IFS=$'\x1f' read -r parsed_state parsed_merged_at parsed_url <<< "$state_line" - PR_STATE="$parsed_state" - PR_MERGED_AT="$parsed_merged_at" - if [[ -n "$parsed_url" ]]; then - pr_url="$parsed_url" + if [[ "$safe_to_force_delete" -eq 1 ]]; then + if git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1; then + echo "[agent-branch-finish] Deleted '${branch}' with forced local cleanup after verifying merge ancestry in '${safe_delete_ref}'." >&2 + return 0 + fi + fi + + echo "[agent-branch-finish] Failed to delete local branch '${branch}' after merge." >&2 + echo "$delete_output" >&2 + return 1 +} + +read_pr_state() { + local preferred_ref="${1:-}" + local state_line="" + local refs_to_try=() + local candidate_ref + + if [[ -n "$preferred_ref" ]]; then + refs_to_try+=("$preferred_ref") fi - return 0 + if [[ -n "$pr_url" && "$pr_url" != "$preferred_ref" ]]; then + refs_to_try+=("$pr_url") + fi + if [[ "$SOURCE_BRANCH" != "$preferred_ref" ]]; then + refs_to_try+=("$SOURCE_BRANCH") + fi + + for candidate_ref in "${refs_to_try[@]}"; do + state_line="$("$GH_BIN" pr view "$candidate_ref" --json state,mergedAt,url --jq '[.state, (.mergedAt // ""), (.url // "")] | join("\u001f")' 2>/dev/null || true)" + if [[ -z "$state_line" ]]; then + continue + fi + + local parsed_state="" + local parsed_merged_at="" + local parsed_url="" + IFS=$'\x1f' read -r parsed_state parsed_merged_at parsed_url <<< "$state_line" + PR_STATE="$parsed_state" + PR_MERGED_AT="$parsed_merged_at" + if [[ -n "$parsed_url" ]]; then + pr_url="$parsed_url" + fi + return 0 + done + + return 1 } wait_for_pr_merge() { + # Integration/source-probe worktrees are no longer needed during GH check wait loops. + # Release them early so long waits do not leave temporary repos visible in Source Control. + release_transient_worktrees local deadline deadline=$(( $(date +%s) + WAIT_TIMEOUT_SECONDS )) local wait_notice_printed=0 @@ -416,6 +791,13 @@ run_pr_flow() { echo "[agent-branch-finish] PR merged but gh could not delete the local branch (active worktree); continuing local cleanup." >&2 return 0 fi + PR_STATE="" + PR_MERGED_AT="" + if read_pr_state "$pr_url"; then + if [[ "$PR_STATE" == "MERGED" || -n "$PR_MERGED_AT" ]]; then + return 0 + fi + fi if [[ "$WAIT_FOR_MERGE" -eq 1 ]]; then wait_for_pr_merge @@ -438,6 +820,42 @@ run_pr_flow() { return 2 } +capture_post_merge_learning() { + if [[ ! -x "${repo_root}/scripts/omx-learning-capture.sh" ]]; then + return 0 + fi + + local learning_args=( + --branch "$SOURCE_BRANCH" + --base "$BASE_BRANCH" + --outcome "merged-${merge_status}" + --summary "Merged ${SOURCE_BRANCH} into ${BASE_BRANCH} via ${merge_status} flow." + --output-dir "${repo_root}/.omx/learning" + ) + if [[ -n "$PR_REF" ]]; then + learning_args+=(--pr "$PR_REF") + elif [[ -n "$pr_url" ]]; then + learning_args+=(--pr "$pr_url") + fi + if [[ -n "$GH_REPO_REF" ]]; then + learning_args+=(--repo "$GH_REPO_REF") + fi + if [[ -n "$merge_gate_json" ]]; then + learning_args+=(--merge-gate-file "$merge_gate_json") + fi + if [[ -f "${source_worktree}/.omx/context/github/sandbox-startup-latest.json" ]]; then + learning_args+=(--context-file "${source_worktree}/.omx/context/github/sandbox-startup-latest.json") + fi + + local learning_output="" + if learning_output="$(bash "${repo_root}/scripts/omx-learning-capture.sh" "${learning_args[@]}" 2>&1)"; then + printf '%s\n' "$learning_output" + else + echo "[agent-branch-finish] Warning: post-merge learning capture failed." >&2 + printf '%s\n' "$learning_output" >&2 + fi +} + if [[ "$PUSH_ENABLED" -eq 1 ]]; then if [[ "$MERGE_MODE" != "pr" ]]; then if ! direct_push_output="$(git -C "$integration_worktree" push origin "HEAD:${BASE_BRANCH}" 2>&1)"; then @@ -484,6 +902,10 @@ if [[ "$PUSH_ENABLED" -eq 1 ]]; then fi fi +if [[ "$merge_completed" -eq 1 ]]; then + capture_post_merge_learning +fi + if [[ -x "${repo_root}/scripts/agent-file-locks.py" ]]; then python3 "${repo_root}/scripts/agent-file-locks.py" release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true fi @@ -494,6 +916,9 @@ if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ fi if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then + cleanup_incomplete=0 + cleanup_remaining_messages=() + if [[ "$source_worktree" == "$repo_root" ]]; then if is_clean_worktree "$source_worktree"; then switched_to_base=0 @@ -514,7 +939,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true fi - git -C "$repo_root" branch -d "$SOURCE_BRANCH" + delete_local_source_branch "$SOURCE_BRANCH" "$BASE_BRANCH" if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then @@ -533,11 +958,35 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then fi fi - echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree." - if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${agent_worktree_root}"/* ]]; then - echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2 - echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 + if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${SOURCE_BRANCH}"; then + cleanup_incomplete=1 + cleanup_remaining_messages+=("local branch still exists: ${SOURCE_BRANCH}") + fi + + if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then + if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then + cleanup_incomplete=1 + cleanup_remaining_messages+=("remote branch still exists: origin/${SOURCE_BRANCH}") + fi fi + + if [[ "$source_worktree" == "${agent_worktree_root}"/* && -d "$source_worktree" ]]; then + cleanup_incomplete=1 + cleanup_remaining_messages+=("agent worktree path still exists: ${source_worktree}") + fi + + if [[ "$cleanup_incomplete" -eq 1 ]]; then + echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow, but mandatory cleanup is still incomplete." >&2 + for cleanup_message in "${cleanup_remaining_messages[@]}"; do + echo "[agent-branch-finish] Remaining cleanup: ${cleanup_message}" >&2 + done + if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${agent_worktree_root}"/* ]]; then + echo "[agent-branch-finish] Leave this active sandbox directory, then rerun: bash scripts/agent-branch-finish.sh --branch ${SOURCE_BRANCH} --base ${BASE_BRANCH} --via-pr --wait-for-merge --cleanup" >&2 + fi + exit 1 + fi + + echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree." else if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then diff --git a/scripts/agent-branch-start.sh b/scripts/agent-branch-start.sh index 9c99e6d..5a81f1e 100755 --- a/scripts/agent-branch-start.sh +++ b/scripts/agent-branch-start.sh @@ -6,10 +6,13 @@ AGENT_NAME="agent" BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 WORKTREE_ROOT_REL=".omx/agent-worktrees" -OPENSPEC_AUTO_INIT_RAW="${MUSAFETY_OPENSPEC_AUTO_INIT:-false}" +OPENSPEC_AUTO_INIT_RAW="${GX_OPENSPEC_AUTO_INIT:-${MUSAFETY_OPENSPEC_AUTO_INIT:-true}}" +GH_SYNC_ON_START_RAW="${MUSAFETY_GH_SYNC_ON_START:-true}" OPENSPEC_PLAN_SLUG_OVERRIDE="${MUSAFETY_OPENSPEC_PLAN_SLUG:-}" OPENSPEC_CHANGE_SLUG_OVERRIDE="${MUSAFETY_OPENSPEC_CHANGE_SLUG:-}" OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${MUSAFETY_OPENSPEC_CAPABILITY_SLUG:-}" +PR_REF="${MUSAFETY_GH_PR_REF:-}" +GH_REPO_REF="${MUSAFETY_GH_REPO:-}" POSITIONAL_ARGS=() while [[ $# -gt 0 ]]; do @@ -36,6 +39,22 @@ while [[ $# -gt 0 ]]; do WORKTREE_ROOT_REL="${2:-.omx/agent-worktrees}" shift 2 ;; + --pr) + PR_REF="${2:-}" + shift 2 + ;; + --repo) + GH_REPO_REF="${2:-}" + shift 2 + ;; + --gh-sync) + GH_SYNC_ON_START_RAW="true" + shift + ;; + --no-gh-sync) + GH_SYNC_ON_START_RAW="false" + shift + ;; --) shift while [[ $# -gt 0 ]]; do @@ -46,7 +65,7 @@ while [[ $# -gt 0 ]]; do ;; -*) echo "[agent-branch-start] Unknown option: $1" >&2 - echo "Usage: $0 [task] [agent] [base] [--worktree-root ]" >&2 + echo "Usage: $0 [task] [agent] [base] [--worktree-root ] [--pr ] [--repo ] [--gh-sync|--no-gh-sync]" >&2 exit 1 ;; *) @@ -58,7 +77,7 @@ done if [[ "${#POSITIONAL_ARGS[@]}" -gt 3 ]]; then echo "[agent-branch-start] Too many positional arguments." >&2 - echo "Usage: $0 [task] [agent] [base] [--worktree-root ]" >&2 + echo "Usage: $0 [task] [agent] [base] [--worktree-root ] [--pr ] [--repo ] [--gh-sync|--no-gh-sync]" >&2 exit 1 fi @@ -100,6 +119,12 @@ normalize_bool() { } OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")" +GH_SYNC_ON_START="$(normalize_bool "$GH_SYNC_ON_START_RAW" "1")" + +is_helper_agent_base_branch() { + local base_branch="$1" + [[ "$base_branch" == agent/* ]] +} resolve_openspec_plan_slug() { local branch_name="$1" @@ -184,6 +209,51 @@ is_protected_branch_name() { return 1 } +branch_exists_locally_or_on_origin() { + local root="$1" + local branch="$2" + if git -C "$root" show-ref --verify --quiet "refs/heads/${branch}"; then + return 0 + fi + if git -C "$root" show-ref --verify --quiet "refs/remotes/origin/${branch}"; then + return 0 + fi + return 1 +} + +resolve_default_base_branch_for_agent_subbranch() { + local root="$1" + local protected_raw="$2" + local configured_base candidate + + configured_base="$(git -C "$root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured_base" ]] && branch_exists_locally_or_on_origin "$root" "$configured_base"; then + printf '%s' "$configured_base" + return 0 + fi + + for candidate in $protected_raw; do + if branch_exists_locally_or_on_origin "$root" "$candidate"; then + printf '%s' "$candidate" + return 0 + fi + done + + if branch_exists_locally_or_on_origin "$root" "dev"; then + printf 'dev' + return 0 + fi + if branch_exists_locally_or_on_origin "$root" "main"; then + printf 'main' + return 0 + fi + if branch_exists_locally_or_on_origin "$root" "master"; then + printf 'master' + return 0 + fi + return 1 +} + hydrate_local_helper_in_worktree() { local repo="$1" local worktree="$2" @@ -306,9 +376,472 @@ initialize_openspec_change_workspace() { if [[ -n "$init_output" ]]; then printf '%s\n' "$init_output" fi + normalize_openspec_change_cleanup_instruction "$worktree" "$change_slug" echo "[agent-branch-start] OpenSpec change workspace: ${worktree}/openspec/changes/${change_slug}" } +normalize_openspec_change_cleanup_instruction() { + local worktree="$1" + local change_slug="$2" + local tasks_file="${worktree}/openspec/changes/${change_slug}/tasks.md" + + if [[ ! -f "$tasks_file" ]]; then + return 0 + fi + + sed -i -E 's#^- \[[ xX]\] 4\.3 .*$#- [ ] 4.3 After successful merge, run `bash scripts/agent-worktree-prune.sh --base --delete-branches --delete-remote-branches` so merged agent branch/worktree sandboxes are removed from local and `origin`.#' "$tasks_file" +} + +filtered_status_output() { + local wt="$1" + git -C "$wt" status --porcelain --untracked-files=normal -- \ + . \ + ":(exclude).omx/state/agent-file-locks.json" \ + ":(exclude).dev-ports.json" \ + ":(exclude)apps/logs/*.log" +} + +resolve_worktree_git_dir() { + local wt="$1" + local git_dir="" + git_dir="$(git -C "$wt" rev-parse --git-dir 2>/dev/null || true)" + if [[ -z "$git_dir" ]]; then + return 1 + fi + if [[ "$git_dir" == /* ]]; then + git_dir="$(cd "$git_dir" 2>/dev/null && pwd -P || true)" + else + git_dir="$(cd "$wt/$git_dir" 2>/dev/null && pwd -P || true)" + fi + if [[ -z "$git_dir" ]]; then + return 1 + fi + printf '%s' "$git_dir" +} + +bootstrap_manifest_path_for_worktree() { + local wt="$1" + local git_dir="" + git_dir="$(resolve_worktree_git_dir "$wt" || true)" + if [[ -z "$git_dir" ]]; then + return 1 + fi + printf '%s/musafety-bootstrap-manifest.json' "$git_dir" +} + +record_worktree_bootstrap_manifest() { + local worktree="$1" + local branch="$2" + local base_branch="$3" + local change_slug="$4" + local plan_slug="$5" + local manifest_path="" + local status_output="" + + manifest_path="$(bootstrap_manifest_path_for_worktree "$worktree" || true)" + if [[ -z "$manifest_path" ]]; then + return 0 + fi + + status_output="$(filtered_status_output "$worktree")" + STATUS_OUTPUT="$status_output" python3 - "$worktree" "$manifest_path" "$branch" "$base_branch" "$change_slug" "$plan_slug" <<'PY' +from __future__ import annotations + +import hashlib +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def parse_status_paths(raw: str) -> list[str]: + paths: list[str] = [] + for line in raw.splitlines(): + if len(line) < 4: + continue + path_part = line[3:] + if " -> " in path_part: + path_part = path_part.split(" -> ", 1)[1] + path_part = path_part.strip() + if path_part: + paths.append(path_part) + return sorted(set(paths)) + + +def sha256_for_file(path: Path) -> str | None: + if not path.exists() or not path.is_file(): + return None + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +if len(sys.argv) != 7: + sys.exit(1) + +worktree_root = Path(sys.argv[1]) +manifest_path = Path(sys.argv[2]) +branch_name = sys.argv[3] +base_branch = sys.argv[4] +change_slug = sys.argv[5] +plan_slug = sys.argv[6] + +status_paths = parse_status_paths(os.environ.get("STATUS_OUTPUT", "")) +entries: list[dict[str, object]] = [] +for rel_path in status_paths: + file_path = worktree_root / rel_path + entries.append( + { + "path": rel_path, + "sha256": sha256_for_file(file_path), + } + ) + +payload = { + "version": 1, + "generatedAt": datetime.now(timezone.utc).isoformat(), + "branch": branch_name, + "baseBranch": base_branch, + "changeSlug": change_slug, + "planSlug": plan_slug, + "files": entries, +} + +manifest_path.parent.mkdir(parents=True, exist_ok=True) +manifest_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY + echo "[agent-branch-start] Bootstrap manifest: ${manifest_path}" +} + +worktree_matches_bootstrap_manifest() { + local worktree="$1" + local manifest_path="" + local status_output="" + + manifest_path="$(bootstrap_manifest_path_for_worktree "$worktree" || true)" + if [[ -z "$manifest_path" || ! -f "$manifest_path" ]]; then + return 1 + fi + + status_output="$(filtered_status_output "$worktree")" + if [[ -z "$status_output" ]]; then + return 1 + fi + + STATUS_OUTPUT="$status_output" python3 - "$worktree" "$manifest_path" <<'PY' +from __future__ import annotations + +import hashlib +import json +import os +import sys +from pathlib import Path + + +def parse_status_paths(raw: str) -> list[str]: + paths: list[str] = [] + for line in raw.splitlines(): + if len(line) < 4: + continue + path_part = line[3:] + if " -> " in path_part: + path_part = path_part.split(" -> ", 1)[1] + path_part = path_part.strip() + if path_part: + paths.append(path_part) + return sorted(set(paths)) + + +def sha256_for_file(path: Path) -> str | None: + if not path.exists() or not path.is_file(): + return None + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +if len(sys.argv) != 3: + sys.exit(1) + +worktree_root = Path(sys.argv[1]) +manifest_path = Path(sys.argv[2]) + +status_paths = parse_status_paths(os.environ.get("STATUS_OUTPUT", "")) +if not status_paths: + sys.exit(1) + +try: + payload = json.loads(manifest_path.read_text(encoding="utf-8")) +except Exception: + sys.exit(1) + +entries = payload.get("files") +if not isinstance(entries, list): + sys.exit(1) + +manifest_by_path: dict[str, str | None] = {} +for entry in entries: + if not isinstance(entry, dict): + continue + path_value = entry.get("path") + if not isinstance(path_value, str) or not path_value: + continue + sha_value = entry.get("sha256") + if sha_value is not None and not isinstance(sha_value, str): + continue + manifest_by_path[path_value] = sha_value + +if not manifest_by_path: + sys.exit(1) + +for rel_path in status_paths: + if rel_path not in manifest_by_path: + sys.exit(1) + current_sha = sha256_for_file(worktree_root / rel_path) + if current_sha != manifest_by_path.get(rel_path): + sys.exit(1) + +sys.exit(0) +PY +} + +get_worktree_for_branch() { + local branch="$1" + git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" ' + $1 == "worktree" { wt = $2 } + $1 == "branch" && $2 == target { print wt; exit } + ' +} + +is_clean_worktree() { + local wt="$1" + git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ + && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ + && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] +} + +json_escape() { + local raw="$1" + raw="${raw//\\/\\\\}" + raw="${raw//\"/\\\"}" + raw="${raw//$'\n'/\\n}" + printf '%s' "$raw" +} + +link_worktree_mem0_compat_file() { + local mem0_file="$1" + local compat_file="$2" + local compat_target="$3" + + mkdir -p "$(dirname "$compat_file")" + if [[ -e "$compat_file" ]]; then + return 0 + fi + + if ln -s "$compat_target" "$compat_file" >/dev/null 2>&1; then + return 0 + fi + + cp "$mem0_file" "$compat_file" +} + +initialize_worktree_mem0_layer() { + local worktree="$1" + local branch="$2" + local base_branch="$3" + local task_slug="$4" + local agent_slug="$5" + + local omx_dir="${worktree}/.omx" + local mem0_dir="${omx_dir}/mem0" + local notepad_path="${mem0_dir}/notepad.md" + local project_memory_path="${mem0_dir}/project-memory.json" + local scope_path="${mem0_dir}/worktree-scope.json" + local created_at + local now + + mkdir -p "$mem0_dir" + + if [[ ! -f "$notepad_path" ]]; then + cat >"$notepad_path" <"$project_memory_path" <"$scope_path" <&1)"; then + printf '%s\n' "$sync_output" + context_json="$(printf '%s\n' "$sync_output" | sed -n 's/^Context JSON: //p' | tail -n1)" + else + echo "[agent-branch-start] Warning: GitHub context sync failed; continuing with local-only startup context." >&2 + printf '%s\n' "$sync_output" >&2 + fi + else + echo "[agent-branch-start] Warning: scripts/omx-gh-sync.sh is missing; skipping GitHub context sync." >&2 + fi + else + echo "[agent-branch-start] GitHub context sync disabled (--no-gh-sync)." + fi + + if [[ -x "${repo}/scripts/agent-conflict-predict.sh" ]]; then + local conflict_output="" + local conflict_args=(--branch "$branch" --base "$base_branch" --output-dir "$merge_gate_dir") + if conflict_output="$(bash "${repo}/scripts/agent-conflict-predict.sh" "${conflict_args[@]}" 2>&1)"; then + printf '%s\n' "$conflict_output" + conflict_json="$(printf '%s\n' "$conflict_output" | sed -n 's/^Conflict JSON: //p' | tail -n1)" + else + conflict_passed=0 + echo "[agent-branch-start] Warning: conflict predictor reported overlaps/locks before coding begins." >&2 + printf '%s\n' "$conflict_output" >&2 + fi + fi + + if [[ -x "${repo}/scripts/omx-context-pack.sh" ]]; then + local pack_args=( + --slug "$branch_slug" + --branch "$branch" + --base "$base_branch" + --output-dir "$context_pack_dir" + ) + if [[ -n "$context_json" ]]; then + pack_args+=(--context-file "$context_json") + fi + if [[ -n "$conflict_json" ]]; then + pack_args+=(--conflict-file "$conflict_json") + fi + local pack_output="" + if pack_output="$(bash "${repo}/scripts/omx-context-pack.sh" "${pack_args[@]}" 2>&1)"; then + printf '%s\n' "$pack_output" + context_pack_json="$(printf '%s\n' "$pack_output" | sed -n 's/^Context pack JSON: //p' | tail -n1)" + else + echo "[agent-branch-start] Warning: context pack assembly failed; continuing without startup pack." >&2 + printf '%s\n' "$pack_output" >&2 + fi + fi + + python3 - "${github_context_dir}/sandbox-startup-latest.json" "$branch" "$base_branch" "$pr_ref" "$repo_ref" "$GH_SYNC_ON_START" "$conflict_passed" "$context_json" "$conflict_json" "$context_pack_json" <<'PY' +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +( + _, + output_path, + branch_name, + base_name, + pr_value, + repo_value, + gh_sync_value, + conflict_value, + context_json_path, + conflict_json_path, + context_pack_path, +) = sys.argv + +payload = { + "version": 1, + "generated_at": datetime.now(timezone.utc).isoformat(), + "branch": branch_name, + "base_branch": base_name, + "pr": pr_value, + "repo": repo_value, + "gh_sync_enabled": int(gh_sync_value), + "conflict_passed": int(conflict_value), + "context_json": context_json_path, + "conflict_json": conflict_json_path, + "context_pack_json": context_pack_path, +} + +target = Path(output_path) +target.parent.mkdir(parents=True, exist_ok=True) +target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY + + echo "[agent-branch-start] Startup metadata: ${github_context_dir}/sandbox-startup-latest.json" +} + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "[agent-branch-start] Not inside a git repository." >&2 exit 1 @@ -324,20 +857,33 @@ fi if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" protected_branches_raw="$(resolve_protected_branches "$repo_root")" + configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]] && is_protected_branch_name "$current_branch" "$protected_branches_raw"; then BASE_BRANCH="$current_branch" + elif [[ -n "$current_branch" && "$current_branch" == agent/* ]]; then + BASE_BRANCH="$current_branch" + echo "[agent-branch-start] Using current agent branch '${BASE_BRANCH}' as helper base." + elif [[ -n "$configured_base" ]]; then + BASE_BRANCH="$configured_base" else - configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" - if [[ -n "$configured_base" ]]; then - BASE_BRANCH="$configured_base" - elif [[ -n "$current_branch" && "$current_branch" != "HEAD" ]]; then + if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]]; then BASE_BRANCH="$current_branch" else - BASE_BRANCH="dev" + BASE_BRANCH="$(resolve_default_base_branch_for_agent_subbranch "$repo_root" "$protected_branches_raw" || printf 'dev')" fi fi fi +helper_branch_assist_mode=0 +if is_helper_agent_base_branch "$BASE_BRANCH"; then + helper_branch_assist_mode=1 + OPENSPEC_AUTO_INIT=0 + echo "[agent-branch-start] Helper branch base '${BASE_BRANCH}' detected; skipping OpenSpec auto-init for joined-agent assist." +elif [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]]; then + echo "[agent-branch-start] OpenSpec auto-init is mandatory for non-helper agent branches; ignoring disabled override." >&2 + OPENSPEC_AUTO_INIT=1 +fi + if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then git fetch origin "${BASE_BRANCH}" --quiet start_ref="origin/${BASE_BRANCH}" @@ -361,63 +907,54 @@ else fi branch_name="$branch_name_base" -branch_suffix=2 -while git show-ref --verify --quiet "refs/heads/${branch_name}"; do - branch_name="${branch_name_base}-${branch_suffix}" - branch_suffix=$((branch_suffix + 1)) -done - worktree_root="${repo_root}/${WORKTREE_ROOT_REL}" mkdir -p "$worktree_root" -worktree_path="${worktree_root}/${branch_name//\//__}" +worktree_path="" +reused_existing_worktree=0 + +if git show-ref --verify --quiet "refs/heads/${branch_name_base}"; then + existing_worktree_path="$(get_worktree_for_branch "$branch_name_base" || true)" + if [[ -n "$existing_worktree_path" && -d "$existing_worktree_path" ]] \ + && git -C "$repo_root" merge-base --is-ancestor "$branch_name_base" "$start_ref" >/dev/null 2>&1; then + if is_clean_worktree "$existing_worktree_path" || worktree_matches_bootstrap_manifest "$existing_worktree_path"; then + worktree_path="$existing_worktree_path" + reused_existing_worktree=1 + echo "[agent-branch-start] Reusing untouched sandbox branch/worktree: ${branch_name_base} (${worktree_path})" + fi + fi +fi + +if [[ "$reused_existing_worktree" -eq 0 ]]; then + branch_suffix=2 + while git show-ref --verify --quiet "refs/heads/${branch_name}"; do + branch_name="${branch_name_base}-${branch_suffix}" + branch_suffix=$((branch_suffix + 1)) + done + worktree_path="${worktree_root}/${branch_name//\//__}" + if [[ -e "$worktree_path" ]]; then + echo "[agent-branch-start] Worktree path already exists: ${worktree_path}" >&2 + exit 1 + fi +fi + openspec_plan_slug="$(resolve_openspec_plan_slug "$branch_name" "$task_slug")" openspec_change_slug="$(resolve_openspec_change_slug "$branch_name" "$task_slug")" openspec_capability_slug="$(resolve_openspec_capability_slug "$task_slug")" -if [[ -e "$worktree_path" ]]; then - echo "[agent-branch-start] Worktree path already exists: ${worktree_path}" >&2 - exit 1 -fi - -auto_transfer_stash_ref="" -auto_transfer_message="" -auto_transfer_source_branch="" -current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +primary_branch_before="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" protected_branches_raw="$(resolve_protected_branches "$repo_root")" -if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]] && is_protected_branch_name "$current_branch" "$protected_branches_raw"; then +if [[ -n "$primary_branch_before" && "$primary_branch_before" != "HEAD" ]] && is_protected_branch_name "$primary_branch_before" "$protected_branches_raw"; then if has_local_changes "$repo_root"; then - auto_transfer_message="musafety-auto-transfer-${timestamp}-${agent_slug}-${task_slug}" - if git -C "$repo_root" stash push --include-untracked --message "$auto_transfer_message" >/dev/null 2>&1; then - auto_transfer_stash_ref="$( - git -C "$repo_root" stash list \ - | awk -v msg="$auto_transfer_message" '$0 ~ msg { ref=$1; sub(/:$/, "", ref); print ref; exit }' - )" - if [[ -n "$auto_transfer_stash_ref" ]]; then - auto_transfer_source_branch="$current_branch" - echo "[agent-branch-start] Detected local changes on protected branch '${current_branch}'. Moving them to '${branch_name}'..." - fi - fi + echo "[agent-branch-start] Detected local changes on protected branch '${primary_branch_before}'. Leaving them in place." fi fi -git -C "$repo_root" worktree add -b "$branch_name" "$worktree_path" "$start_ref" -git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$BASE_BRANCH" >/dev/null 2>&1 || true +if [[ "$reused_existing_worktree" -eq 0 ]]; then + git -C "$repo_root" worktree add -b "$branch_name" "$worktree_path" "$start_ref" + git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$BASE_BRANCH" >/dev/null 2>&1 || true -if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then - git -C "$worktree_path" branch --set-upstream-to="origin/${BASE_BRANCH}" "$branch_name" >/dev/null 2>&1 || true -fi - -if [[ -n "$auto_transfer_stash_ref" ]]; then - if git -C "$worktree_path" stash apply "$auto_transfer_stash_ref" >/dev/null 2>&1; then - git -C "$repo_root" stash drop "$auto_transfer_stash_ref" >/dev/null 2>&1 || true - transfer_label="${auto_transfer_source_branch:-$BASE_BRANCH}" - echo "[agent-branch-start] Moved local changes from '${transfer_label}' into '${branch_name}'." - else - echo "[agent-branch-start] Failed to auto-apply moved changes in new worktree." >&2 - transfer_label="${auto_transfer_source_branch:-$BASE_BRANCH}" - echo "[agent-branch-start] Changes are preserved in ${auto_transfer_stash_ref} on ${transfer_label}." >&2 - echo "[agent-branch-start] Apply manually with: git -C \"$worktree_path\" stash apply \"${auto_transfer_stash_ref}\"" >&2 - exit 1 + if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then + git -C "$worktree_path" branch --set-upstream-to="origin/${BASE_BRANCH}" "$branch_name" >/dev/null 2>&1 || true fi fi @@ -425,19 +962,41 @@ hydrate_local_helper_in_worktree "$repo_root" "$worktree_path" "scripts/codex-ag hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "node_modules" hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "apps/frontend/node_modules" hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "apps/backend/node_modules" -if ! initialize_openspec_change_workspace "$repo_root" "$worktree_path" "$openspec_change_slug" "$openspec_capability_slug"; then - exit 1 +if [[ "$reused_existing_worktree" -eq 0 ]]; then + if ! initialize_openspec_change_workspace "$repo_root" "$worktree_path" "$openspec_change_slug" "$openspec_capability_slug"; then + exit 1 + fi + if ! initialize_openspec_plan_workspace "$repo_root" "$worktree_path" "$openspec_plan_slug"; then + exit 1 + fi fi -if ! initialize_openspec_plan_workspace "$repo_root" "$worktree_path" "$openspec_plan_slug"; then - exit 1 +initialize_worktree_mem0_layer "$worktree_path" "$branch_name" "$BASE_BRANCH" "$task_slug" "$agent_slug" + +run_startup_context_artifacts "$repo_root" "$worktree_path" "$branch_name" "$BASE_BRANCH" "$PR_REF" "$GH_REPO_REF" +record_worktree_bootstrap_manifest "$worktree_path" "$branch_name" "$BASE_BRANCH" "$openspec_change_slug" "$openspec_plan_slug" + +primary_branch_after="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ -n "$primary_branch_before" && "$primary_branch_before" != "HEAD" && "$primary_branch_after" != "$primary_branch_before" ]]; then + echo "[agent-branch-start] Warning: primary checkout moved from '${primary_branch_before}' to '${primary_branch_after}'. Restoring '${primary_branch_before}'." + git -C "$repo_root" checkout -q "$primary_branch_before" + primary_branch_after="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ "$primary_branch_after" != "$primary_branch_before" ]]; then + echo "[agent-branch-start] Failed to restore primary checkout branch '${primary_branch_before}'." >&2 + exit 1 + fi fi echo "[agent-branch-start] Created branch: ${branch_name}" echo "[agent-branch-start] Worktree: ${worktree_path}" -echo "[agent-branch-start] OpenSpec change: openspec/changes/${openspec_change_slug}" -echo "[agent-branch-start] OpenSpec plan: openspec/plan/${openspec_plan_slug}" +if [[ "$helper_branch_assist_mode" -eq 1 ]]; then + echo "[agent-branch-start] OpenSpec change: skipped (helper branch assisting ${BASE_BRANCH})" + echo "[agent-branch-start] OpenSpec plan: skipped (helper branch assisting ${BASE_BRANCH})" +else + echo "[agent-branch-start] OpenSpec change: openspec/changes/${openspec_change_slug}" + echo "[agent-branch-start] OpenSpec plan: openspec/plan/${openspec_plan_slug}" +fi echo "[agent-branch-start] Next steps:" echo " cd \"${worktree_path}\"" echo " python3 scripts/agent-file-locks.py claim --branch \"${branch_name}\" " echo " # implement + commit" -echo " bash scripts/agent-branch-finish.sh --branch \"${branch_name}\" --base dev --via-pr --wait-for-merge" +echo " bash scripts/agent-branch-finish.sh --branch \"${branch_name}\" --base ${BASE_BRANCH} --via-pr --wait-for-merge" diff --git a/scripts/agent-worktree-prune.sh b/scripts/agent-worktree-prune.sh index 4bc8162..ee05800 100755 --- a/scripts/agent-worktree-prune.sh +++ b/scripts/agent-worktree-prune.sh @@ -8,16 +8,11 @@ FORCE_DIRTY=0 DELETE_BRANCHES=0 DELETE_REMOTE_BRANCHES=0 ONLY_DIRTY_WORKTREES=0 -INCLUDE_PR_MERGED=0 TARGET_BRANCH="" IDLE_MINUTES=0 NOW_EPOCH_RAW="${MUSAFETY_PRUNE_NOW_EPOCH:-}" IDLE_SECONDS=0 NOW_EPOCH=0 -GH_BIN="${MUSAFETY_GH_BIN:-gh}" -PR_MERGED_LOOKUP_DISABLED=0 -PR_MERGED_LOOKUP_LOADED=0 -declare -A MERGED_PR_BRANCHES=() if [[ -n "$BASE_BRANCH" ]]; then BASE_BRANCH_EXPLICIT=1 @@ -50,10 +45,6 @@ while [[ $# -gt 0 ]]; do ONLY_DIRTY_WORKTREES=1 shift ;; - --include-pr-merged) - INCLUDE_PR_MERGED=1 - shift - ;; --branch) TARGET_BRANCH="${2:-}" shift 2 @@ -64,7 +55,7 @@ while [[ $# -gt 0 ]]; do ;; *) echo "[agent-worktree-prune] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--include-pr-merged] [--branch ] [--idle-minutes ]" >&2 + echo "Usage: $0 [--base ] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--branch ]" >&2 exit 1 ;; esac @@ -75,7 +66,15 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 1 fi -repo_root="$(git rev-parse --show-toplevel)" +current_worktree_root="$(git rev-parse --show-toplevel)" +common_git_dir_raw="$(git -C "$current_worktree_root" rev-parse --git-common-dir)" +if [[ "$common_git_dir_raw" == /* ]]; then + repo_common_dir="$common_git_dir_raw" +else + repo_common_dir="${current_worktree_root}/${common_git_dir_raw}" +fi +repo_common_dir="$(cd "$repo_common_dir" && pwd -P)" +repo_root="$(cd "$repo_common_dir/.." && pwd -P)" current_pwd="$(pwd -P)" worktree_root="${repo_root}/.omx/agent-worktrees" repo_common_dir="$( @@ -110,42 +109,19 @@ resolve_base_branch() { printf '%s' "" } -load_merged_pr_branches() { - if [[ "$INCLUDE_PR_MERGED" -ne 1 ]]; then - return 1 - fi - if [[ "$PR_MERGED_LOOKUP_DISABLED" -eq 1 ]]; then - return 1 - fi - if [[ "$PR_MERGED_LOOKUP_LOADED" -eq 1 ]]; then - return 0 - fi - if ! command -v "$GH_BIN" >/dev/null 2>&1; then - PR_MERGED_LOOKUP_DISABLED=1 - return 1 - fi +is_agent_branch() { + local branch="$1" + [[ "$branch" == agent/* ]] +} - local merged_branches="" - merged_branches="$( - "$GH_BIN" pr list --state merged --base "$BASE_BRANCH" --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null || true - )" - if [[ -n "$merged_branches" ]]; then - while IFS= read -r merged_branch; do - [[ -z "$merged_branch" ]] && continue - MERGED_PR_BRANCHES["$merged_branch"]=1 - done <<< "$merged_branches" - fi - PR_MERGED_LOOKUP_LOADED=1 - return 0 +is_temporary_branch() { + local branch="$1" + [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]] } -branch_has_merged_pr() { +is_supported_target_branch() { local branch="$1" - if [[ "$INCLUDE_PR_MERGED" -ne 1 ]]; then - return 1 - fi - load_merged_pr_branches || return 1 - [[ -n "${MERGED_PR_BRANCHES[$branch]:-}" ]] + is_agent_branch "$branch" || is_temporary_branch "$branch" } if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then @@ -153,8 +129,8 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then exit 1 fi -if [[ -n "$TARGET_BRANCH" && "$TARGET_BRANCH" != agent/* ]]; then - echo "[agent-worktree-prune] --branch must reference an agent/* branch: ${TARGET_BRANCH}" >&2 +if [[ -n "$TARGET_BRANCH" ]] && ! is_supported_target_branch "$TARGET_BRANCH"; then + echo "[agent-worktree-prune] --branch must reference agent/*, __agent_integrate_*, or __source-probe-*: ${TARGET_BRANCH}" >&2 exit 1 fi @@ -199,7 +175,10 @@ run_cmd() { branch_has_worktree() { local branch="$1" - git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$" + git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" ' + $1 == "branch" && $2 == target { found = 1; exit } + END { exit(found ? 0 : 1) } + ' } is_clean_worktree() { @@ -209,6 +188,163 @@ is_clean_worktree() { && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] } +has_unmerged_conflicts() { + local wt="$1" + [[ -n "$(git -C "$wt" diff --name-only --diff-filter=U 2>/dev/null || true)" ]] +} + +filtered_status_output() { + local wt="$1" + git -C "$wt" status --porcelain --untracked-files=normal -- \ + . \ + ":(exclude).omx/state/agent-file-locks.json" \ + ":(exclude).dev-ports.json" \ + ":(exclude)apps/logs/*.log" +} + +resolve_worktree_git_dir() { + local wt="$1" + local git_dir="" + git_dir="$(git -C "$wt" rev-parse --git-dir 2>/dev/null || true)" + if [[ -z "$git_dir" ]]; then + return 1 + fi + if [[ "$git_dir" == /* ]]; then + git_dir="$(cd "$git_dir" 2>/dev/null && pwd -P || true)" + else + git_dir="$(cd "$wt/$git_dir" 2>/dev/null && pwd -P || true)" + fi + if [[ -z "$git_dir" ]]; then + return 1 + fi + printf '%s' "$git_dir" +} + +bootstrap_manifest_path_for_worktree() { + local wt="$1" + local git_dir="" + git_dir="$(resolve_worktree_git_dir "$wt" || true)" + if [[ -z "$git_dir" ]]; then + return 1 + fi + printf '%s/musafety-bootstrap-manifest.json' "$git_dir" +} + +worktree_matches_bootstrap_manifest() { + local wt="$1" + local manifest_path="" + local status_output="" + + manifest_path="$(bootstrap_manifest_path_for_worktree "$wt" || true)" + if [[ -z "$manifest_path" || ! -f "$manifest_path" ]]; then + return 1 + fi + + status_output="$(filtered_status_output "$wt")" + if [[ -z "$status_output" ]]; then + return 1 + fi + + STATUS_OUTPUT="$status_output" python3 - "$wt" "$manifest_path" <<'PY' +from __future__ import annotations + +import hashlib +import json +import os +import sys +from pathlib import Path + + +def parse_status_paths(raw: str) -> list[str]: + paths: list[str] = [] + for line in raw.splitlines(): + if len(line) < 4: + continue + path_part = line[3:] + if " -> " in path_part: + path_part = path_part.split(" -> ", 1)[1] + path_part = path_part.strip() + if path_part: + paths.append(path_part) + return paths + + +def sha256_for_path(path: Path) -> str | None: + if not path.exists() or not path.is_file(): + return None + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +if len(sys.argv) != 3: + sys.exit(1) + +worktree_root = Path(sys.argv[1]) +manifest_path = Path(sys.argv[2]) +status_raw = os.environ.get("STATUS_OUTPUT", "") +status_paths = sorted(set(parse_status_paths(status_raw))) +if not status_paths: + sys.exit(1) + +try: + payload = json.loads(manifest_path.read_text(encoding="utf-8")) +except Exception: + sys.exit(1) + +entries = payload.get("files") +if not isinstance(entries, list): + sys.exit(1) + +manifest_by_path: dict[str, str | None] = {} +for entry in entries: + if not isinstance(entry, dict): + continue + path_value = entry.get("path") + if not isinstance(path_value, str) or not path_value: + continue + sha_value = entry.get("sha256") + if sha_value is not None and not isinstance(sha_value, str): + continue + manifest_by_path[path_value] = sha_value + +if not manifest_by_path: + sys.exit(1) + +for rel_path in status_paths: + if rel_path not in manifest_by_path: + sys.exit(1) + file_path = worktree_root / rel_path + current_sha = sha256_for_path(file_path) + if current_sha != manifest_by_path.get(rel_path): + sys.exit(1) + +sys.exit(0) +PY +} + +sanitize_branch_component() { + local raw="$1" + raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g')" + if [[ -z "$raw" ]]; then + raw="sandbox" + fi + printf '%s' "$raw" +} + +resolve_unique_recovery_branch_name() { + local seed="$1" + local candidate="$seed" + local suffix=2 + while git -C "$repo_root" show-ref --verify --quiet "refs/heads/${candidate}"; do + candidate="${seed}-${suffix}" + suffix=$((suffix + 1)) + done + printf '%s' "$candidate" +} + resolve_worktree_common_dir() { local wt="$1" local common_dir="" @@ -239,68 +375,45 @@ select_unique_worktree_path() { printf '%s' "$candidate" } -read_branch_activity_epoch() { - local branch="$1" - local wt="${2:-}" - local activity_epoch="" - - activity_epoch="$( - git -C "$repo_root" reflog show --format='%ct' -n 1 "refs/heads/${branch}" 2>/dev/null \ - | head -n 1 \ - | tr -d '[:space:]' - )" - if [[ -z "$activity_epoch" ]]; then - activity_epoch="$( - git -C "$repo_root" log -1 --format='%ct' "$branch" 2>/dev/null \ - | head -n 1 \ - | tr -d '[:space:]' - )" - fi - - if [[ -n "$wt" && -d "$wt" ]]; then - local lock_file="${wt}/.omx/state/agent-file-locks.json" - if [[ -f "$lock_file" ]]; then - local lock_mtime="" - lock_mtime="$(stat -c %Y "$lock_file" 2>/dev/null || stat -f %m "$lock_file" 2>/dev/null || true)" - if [[ "$lock_mtime" =~ ^[0-9]+$ ]]; then - if [[ -z "$activity_epoch" || "$lock_mtime" -gt "$activity_epoch" ]]; then - activity_epoch="$lock_mtime" - fi - fi - fi - fi - - printf '%s' "$activity_epoch" -} - skipped_recent=0 branch_idle_gate() { local branch="$1" local wt="$2" local reason="$3" + local subject="" + local commit_epoch="" + local age=0 + local wait_remaining=0 + if [[ "$IDLE_SECONDS" -le 0 ]]; then return 0 fi - if [[ -z "$branch" ]]; then - return 0 + + if [[ -n "$branch" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}"; then + commit_epoch="$(git -C "$repo_root" log -1 --format=%ct "$branch" 2>/dev/null || true)" + subject="$branch" + elif [[ -n "$wt" ]]; then + commit_epoch="$(git -C "$wt" log -1 --format=%ct 2>/dev/null || true)" + subject="$wt" fi - local last_activity_epoch="" - last_activity_epoch="$(read_branch_activity_epoch "$branch" "$wt")" - if [[ ! "$last_activity_epoch" =~ ^[0-9]+$ ]]; then + if [[ -z "$commit_epoch" || ! "$commit_epoch" =~ ^[0-9]+$ ]]; then return 0 fi - local idle_age=$((NOW_EPOCH - last_activity_epoch)) - if [[ "$idle_age" -lt 0 ]]; then - idle_age=0 + age=$((NOW_EPOCH - commit_epoch)) + if (( age < 0 )); then + age=0 fi - if [[ "$idle_age" -lt "$IDLE_SECONDS" ]]; then + + if (( age < IDLE_SECONDS )); then + wait_remaining=$((IDLE_SECONDS - age)) skipped_recent=$((skipped_recent + 1)) - echo "[agent-worktree-prune] Skipping recent branch (${reason}): ${branch} (idle=${idle_age}s < ${IDLE_SECONDS}s)" + echo "[agent-worktree-prune] Skipping recent ${reason}: ${subject} (age=${age}s, threshold=${IDLE_SECONDS}s, wait~${wait_remaining}s)" return 1 fi + return 0 } @@ -363,6 +476,10 @@ removed_worktrees=0 removed_branches=0 skipped_active=0 skipped_dirty=0 +repaired_detached_conflicts=0 +failed_ops=0 + +relocate_foreign_worktree_entries relocate_foreign_worktree_entries @@ -389,24 +506,26 @@ process_entry() { fi local remove_reason="" - local branch_delete_mode="safe" + local wt_name + wt_name="$(basename "$wt")" - if [[ -z "$branch_ref" ]]; then + if [[ "$wt_name" == __integrate-* || "$wt_name" == __source-probe-* ]]; then + remove_reason="temporary-worktree" + elif [[ -z "$branch_ref" ]]; then remove_reason="detached-worktree" elif ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}"; then remove_reason="missing-branch" - elif [[ "$branch" == agent/* ]]; then + elif is_agent_branch "$branch"; then if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then if [[ "$DELETE_BRANCHES" -eq 1 ]]; then remove_reason="merged-agent-branch" + else + remove_reason="merged-agent-worktree" fi - elif [[ "$DELETE_BRANCHES" -eq 1 ]] && branch_has_merged_pr "$branch"; then - remove_reason="merged-agent-pr" - branch_delete_mode="force" elif [[ "$ONLY_DIRTY_WORKTREES" -eq 1 ]] && is_clean_worktree "$wt"; then remove_reason="clean-agent-worktree" fi - elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then + elif is_temporary_branch "$branch"; then remove_reason="temporary-worktree" fi @@ -418,39 +537,73 @@ process_entry() { return fi - if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then - skipped_dirty=$((skipped_dirty + 1)) - echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}" + if [[ "$FORCE_DIRTY" -ne 1 ]] \ + && [[ "$remove_reason" == "detached-worktree" ]] \ + && has_unmerged_conflicts "$wt"; then + local wt_component + local base_component + local recovery_seed + local recovery_branch + + wt_component="$(sanitize_branch_component "$wt_name")" + base_component="$(sanitize_branch_component "$BASE_BRANCH")" + recovery_seed="agent/recover/${base_component}-${wt_component}-$(date +%Y%m%d-%H%M%S)" + recovery_branch="$(resolve_unique_recovery_branch_name "$recovery_seed")" + + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[agent-worktree-prune] [dry-run] Would recover detached conflicted worktree: ${wt} -> ${recovery_branch}" + repaired_detached_conflicts=$((repaired_detached_conflicts + 1)) + return + fi + + if git -C "$wt" checkout -b "$recovery_branch" >/dev/null 2>&1; then + repaired_detached_conflicts=$((repaired_detached_conflicts + 1)) + echo "[agent-worktree-prune] Recovered detached conflicted worktree: ${wt} -> ${recovery_branch}" + return + fi + + failed_ops=$((failed_ops + 1)) + echo "[agent-worktree-prune] Failed to recover detached conflicted worktree: ${wt}" >&2 return fi + if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then + if [[ "$remove_reason" == "merged-agent-branch" || "$remove_reason" == "merged-agent-worktree" ]] \ + && worktree_matches_bootstrap_manifest "$wt"; then + echo "[agent-worktree-prune] Treating bootstrap-only sandbox as safe to remove (${remove_reason}): ${wt}" + else + skipped_dirty=$((skipped_dirty + 1)) + echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}" + return + fi + fi + echo "[agent-worktree-prune] Removing worktree (${remove_reason}): ${wt}" - run_cmd git -C "$repo_root" worktree remove "$wt" --force - removed_worktrees=$((removed_worktrees + 1)) + if run_cmd git -C "$repo_root" worktree remove "$wt" --force; then + removed_worktrees=$((removed_worktrees + 1)) + else + failed_ops=$((failed_ops + 1)) + echo "[agent-worktree-prune] Failed to remove worktree (${remove_reason}): ${wt}" >&2 + return + fi if [[ -z "$branch" ]]; then return fi if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" && ! branch_has_worktree "$branch"; then - if [[ "$branch" == agent/* && "$DELETE_BRANCHES" -eq 1 ]]; then - local delete_flag="-d" - local deleted_label="merged" - if [[ "$branch_delete_mode" == "force" ]]; then - delete_flag="-D" - deleted_label="merged PR" - fi - if run_cmd git -C "$repo_root" branch "$delete_flag" "$branch" >/dev/null 2>&1; then + if is_agent_branch "$branch" && [[ "$DELETE_BRANCHES" -eq 1 ]]; then + if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then removed_branches=$((removed_branches + 1)) - echo "[agent-worktree-prune] Deleted ${deleted_label} branch: ${branch}" + echo "[agent-worktree-prune] Deleted merged branch: ${branch}" if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true - echo "[agent-worktree-prune] Deleted ${deleted_label} remote branch: ${branch}" + echo "[agent-worktree-prune] Deleted merged remote branch: ${branch}" fi fi fi - elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then + elif is_temporary_branch "$branch"; then run_cmd git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1 || true removed_branches=$((removed_branches + 1)) echo "[agent-worktree-prune] Deleted temporary branch: ${branch}" @@ -490,40 +643,81 @@ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then if branch_has_worktree "$branch"; then continue fi + if is_temporary_branch "$branch"; then + if ! branch_idle_gate "$branch" "" "stale-temporary-branch"; then + continue + fi + if run_cmd git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1; then + removed_branches=$((removed_branches + 1)) + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[agent-worktree-prune] Would delete stale temporary branch: ${branch}" + else + echo "[agent-worktree-prune] Deleted stale temporary branch: ${branch}" + fi + fi + continue + fi + if ! is_agent_branch "$branch"; then + continue + fi if ! branch_idle_gate "$branch" "" "stale-merged-branch"; then continue fi - local merged_by_ancestor=0 - local merged_by_pr=0 if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then - merged_by_ancestor=1 - elif branch_has_merged_pr "$branch"; then - merged_by_pr=1 - fi - if [[ "$merged_by_ancestor" -eq 1 || "$merged_by_pr" -eq 1 ]]; then - local delete_flag="-d" - local deleted_label="merged" - if [[ "$merged_by_pr" -eq 1 && "$merged_by_ancestor" -eq 0 ]]; then - delete_flag="-D" - deleted_label="merged PR" - fi - if run_cmd git -C "$repo_root" branch "$delete_flag" "$branch" >/dev/null 2>&1; then + if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then removed_branches=$((removed_branches + 1)) - echo "[agent-worktree-prune] Deleted stale ${deleted_label} branch: ${branch}" + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[agent-worktree-prune] Would delete stale merged branch: ${branch}" + else + echo "[agent-worktree-prune] Deleted stale merged branch: ${branch}" + fi if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true - echo "[agent-worktree-prune] Deleted stale ${deleted_label} remote branch: ${branch}" + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[agent-worktree-prune] Would delete stale merged remote branch: ${branch}" + else + echo "[agent-worktree-prune] Deleted stale merged remote branch: ${branch}" + fi fi fi fi fi - done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent) + done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads | awk '/^agent\// || /^__agent_integrate_/ || /^__source-probe-/') fi -run_cmd git -C "$repo_root" worktree prune +if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then + while IFS= read -r remote_ref; do + [[ -z "$remote_ref" ]] && continue + local_branch="${remote_ref#origin/}" + if [[ -n "$TARGET_BRANCH" && "$local_branch" != "$TARGET_BRANCH" ]]; then + continue + fi + if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${local_branch}"; then + continue + fi + if ! is_agent_branch "$local_branch"; then + continue + fi + if git -C "$repo_root" merge-base --is-ancestor "$remote_ref" "$BASE_BRANCH"; then + if run_cmd git -C "$repo_root" push origin --delete "$local_branch" >/dev/null 2>&1; then + removed_branches=$((removed_branches + 1)) + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[agent-worktree-prune] Would delete stale merged remote-only branch: ${local_branch}" + else + echo "[agent-worktree-prune] Deleted stale merged remote-only branch: ${local_branch}" + fi + fi + fi + done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/remotes/origin/agent) +fi -echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, idle_minutes=${IDLE_MINUTES}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}, skipped_recent=${skipped_recent}" +if ! run_cmd git -C "$repo_root" worktree prune; then + failed_ops=$((failed_ops + 1)) + echo "[agent-worktree-prune] Warning: git worktree prune failed." >&2 +fi + +echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}, repaired_detached_conflicts=${repaired_detached_conflicts}" if [[ "$relocated_foreign" -gt 0 || "$skipped_foreign" -gt 0 ]]; then echo "[agent-worktree-prune] Foreign routing: relocated=${relocated_foreign}, skipped=${skipped_foreign}" fi @@ -536,3 +730,6 @@ fi if [[ "$IDLE_SECONDS" -gt 0 && "$skipped_recent" -gt 0 ]]; then echo "[agent-worktree-prune] Tip: recent branches were preserved by --idle-minutes=${IDLE_MINUTES}. Re-run later or lower the threshold." >&2 fi +if [[ "$failed_ops" -gt 0 ]]; then + echo "[agent-worktree-prune] Tip: some cleanup operations failed and were skipped. Re-run after fixing file-system or permission blockers." >&2 +fi diff --git a/scripts/codex-agent.sh b/scripts/codex-agent.sh index afc7ef3..38a85ff 100755 --- a/scripts/codex-agent.sh +++ b/scripts/codex-agent.sh @@ -6,33 +6,9 @@ AGENT_NAME="${MUSAFETY_AGENT_NAME:-agent}" BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}" BASE_BRANCH_EXPLICIT=0 CODEX_BIN="${MUSAFETY_CODEX_BIN:-codex}" -AUTO_FINISH_RAW="${MUSAFETY_CODEX_AUTO_FINISH:-true}" -AUTO_REVIEW_ON_CONFLICT_RAW="${MUSAFETY_CODEX_AUTO_REVIEW_ON_CONFLICT:-true}" -AUTO_CLEANUP_RAW="${MUSAFETY_CODEX_AUTO_CLEANUP:-true}" -AUTO_WAIT_FOR_MERGE_RAW="${MUSAFETY_CODEX_WAIT_FOR_MERGE:-true}" -OPENSPEC_AUTO_INIT_RAW="${MUSAFETY_OPENSPEC_AUTO_INIT:-true}" -OPENSPEC_PLAN_SLUG_OVERRIDE="${MUSAFETY_OPENSPEC_PLAN_SLUG:-}" -OPENSPEC_CHANGE_SLUG_OVERRIDE="${MUSAFETY_OPENSPEC_CHANGE_SLUG:-}" -OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${MUSAFETY_OPENSPEC_CAPABILITY_SLUG:-}" - -normalize_bool() { - local raw="${1:-}" - local fallback="${2:-0}" - local lowered - lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" - case "$lowered" in - 1|true|yes|on) printf '1' ;; - 0|false|no|off) printf '0' ;; - '') printf '%s' "$fallback" ;; - *) printf '%s' "$fallback" ;; - esac -} - -AUTO_FINISH="$(normalize_bool "$AUTO_FINISH_RAW" "1")" -AUTO_REVIEW_ON_CONFLICT="$(normalize_bool "$AUTO_REVIEW_ON_CONFLICT_RAW" "1")" -AUTO_CLEANUP="$(normalize_bool "$AUTO_CLEANUP_RAW" "1")" -AUTO_WAIT_FOR_MERGE="$(normalize_bool "$AUTO_WAIT_FOR_MERGE_RAW" "1")" -OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")" +GH_PR_REF="${MUSAFETY_GH_PR_REF:-}" +GH_REPO_REF="${MUSAFETY_GH_REPO:-}" +GH_SYNC_FLAG="" if [[ -n "$BASE_BRANCH" ]]; then BASE_BRANCH_EXPLICIT=1 @@ -57,36 +33,20 @@ while [[ $# -gt 0 ]]; do CODEX_BIN="${2:-$CODEX_BIN}" shift 2 ;; - --auto-finish) - AUTO_FINISH=1 - shift - ;; - --no-auto-finish) - AUTO_FINISH=0 - shift - ;; - --auto-review-on-conflict) - AUTO_REVIEW_ON_CONFLICT=1 - shift - ;; - --no-auto-review-on-conflict) - AUTO_REVIEW_ON_CONFLICT=0 - shift - ;; - --cleanup) - AUTO_CLEANUP=1 - shift + --pr) + GH_PR_REF="${2:-$GH_PR_REF}" + shift 2 ;; - --no-cleanup) - AUTO_CLEANUP=0 - shift + --repo) + GH_REPO_REF="${2:-$GH_REPO_REF}" + shift 2 ;; - --wait-for-merge) - AUTO_WAIT_FOR_MERGE=1 + --gh-sync) + GH_SYNC_FLAG="--gh-sync" shift ;; - --no-wait-for-merge) - AUTO_WAIT_FOR_MERGE=0 + --no-gh-sync) + GH_SYNC_FLAG="--no-gh-sync" shift ;; --) @@ -130,160 +90,6 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then fi repo_root="$(git rev-parse --show-toplevel)" -sanitize_slug() { - local raw="$1" - local fallback="${2:-task}" - local slug - slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')" - if [[ -z "$slug" ]]; then - slug="$fallback" - fi - printf '%s' "$slug" -} - -resolve_openspec_plan_slug() { - local branch_name="$1" - local task_slug - task_slug="$(sanitize_slug "$TASK_NAME" "task")" - if [[ -n "$OPENSPEC_PLAN_SLUG_OVERRIDE" ]]; then - sanitize_slug "$OPENSPEC_PLAN_SLUG_OVERRIDE" "$task_slug" - return 0 - fi - sanitize_slug "${branch_name//\//-}" "$task_slug" -} - -resolve_openspec_change_slug() { - local branch_name="$1" - local task_slug - task_slug="$(sanitize_slug "$TASK_NAME" "task")" - if [[ -n "$OPENSPEC_CHANGE_SLUG_OVERRIDE" ]]; then - sanitize_slug "$OPENSPEC_CHANGE_SLUG_OVERRIDE" "$task_slug" - return 0 - fi - sanitize_slug "${branch_name//\//-}" "$task_slug" -} - -resolve_openspec_capability_slug() { - local task_slug - task_slug="$(sanitize_slug "$TASK_NAME" "task")" - if [[ -n "$OPENSPEC_CAPABILITY_SLUG_OVERRIDE" ]]; then - sanitize_slug "$OPENSPEC_CAPABILITY_SLUG_OVERRIDE" "$task_slug" - return 0 - fi - sanitize_slug "$task_slug" "general-behavior" -} - -hydrate_local_helper_in_worktree() { - local worktree="$1" - local relative_path="$2" - local worktree_target="${worktree}/${relative_path}" - local source_path="" - - if [[ -e "$worktree_target" ]]; then - return 0 - fi - - if [[ -f "${repo_root}/${relative_path}" ]]; then - source_path="${repo_root}/${relative_path}" - elif [[ -f "${repo_root}/templates/${relative_path}" ]]; then - source_path="${repo_root}/templates/${relative_path}" - fi - - if [[ -z "$source_path" ]]; then - return 0 - fi - - mkdir -p "$(dirname "$worktree_target")" - cp "$source_path" "$worktree_target" - if [[ -x "$source_path" ]]; then - chmod +x "$worktree_target" - fi - - echo "[codex-agent] Hydrated local helper in sandbox: ${relative_path}" -} - -resolve_start_base_branch() { - if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -n "$BASE_BRANCH" ]]; then - printf '%s' "$BASE_BRANCH" - return 0 - fi - - local configured_base - configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" - if [[ -n "$configured_base" ]]; then - printf '%s' "$configured_base" - return 0 - fi - - printf 'dev' -} - -resolve_start_ref() { - local base_branch="$1" - git -C "$repo_root" fetch origin "$base_branch" --quiet >/dev/null 2>&1 || true - if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then - printf 'origin/%s' "$base_branch" - return 0 - fi - if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${base_branch}"; then - printf '%s' "$base_branch" - return 0 - fi - return 1 -} - -restore_repo_branch_if_changed() { - local expected_branch="$1" - if [[ -z "$expected_branch" || "$expected_branch" == "HEAD" ]]; then - return 0 - fi - local current_branch - current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" - if [[ -z "$current_branch" || "$current_branch" == "$expected_branch" ]]; then - return 0 - fi - git -C "$repo_root" checkout "$expected_branch" >/dev/null 2>&1 -} - -start_sandbox_fallback() { - local base_branch start_ref timestamp task_slug agent_slug branch_name_base branch_name suffix - local worktree_root worktree_path - - base_branch="$(resolve_start_base_branch)" - if ! start_ref="$(resolve_start_ref "$base_branch")"; then - echo "[codex-agent] Unable to resolve base ref for fallback sandbox start: ${base_branch}" >&2 - return 1 - fi - - timestamp="$(date +%Y%m%d-%H%M%S)" - task_slug="$(sanitize_slug "$TASK_NAME" "task")" - agent_slug="$(sanitize_slug "$AGENT_NAME" "agent")" - branch_name_base="agent/${agent_slug}/${timestamp}-${task_slug}" - branch_name="$branch_name_base" - suffix=2 - while git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch_name}"; do - branch_name="${branch_name_base}-${suffix}" - suffix=$((suffix + 1)) - done - - worktree_root="${repo_root}/.omx/agent-worktrees" - mkdir -p "$worktree_root" - worktree_path="${worktree_root}/${branch_name//\//__}" - if [[ -e "$worktree_path" ]]; then - echo "[codex-agent] Fallback worktree path already exists: $worktree_path" >&2 - return 1 - fi - - git -C "$repo_root" worktree add -b "$branch_name" "$worktree_path" "$start_ref" >/dev/null - git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$base_branch" >/dev/null 2>&1 || true - if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then - git -C "$worktree_path" branch --set-upstream-to="origin/${base_branch}" "$branch_name" >/dev/null 2>&1 || true - fi - - printf '[agent-branch-start] Created branch: %s\n' "$branch_name" - printf '[agent-branch-start] Worktree: %s\n' "$worktree_path" -} - if [[ ! -x "${repo_root}/scripts/agent-branch-start.sh" ]]; then echo "[codex-agent] Missing scripts/agent-branch-start.sh. Run: gx setup" >&2 exit 1 @@ -293,436 +99,114 @@ start_args=("$TASK_NAME" "$AGENT_NAME") if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then start_args+=("$BASE_BRANCH") fi - -initial_repo_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" -start_output="" -start_status=0 -set +e -start_output="$(MUSAFETY_OPENSPEC_AUTO_INIT=0 bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1)" -start_status=$? -set -e - -worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n1)" -current_repo_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" -resolved_repo_root="$(cd "$repo_root" && pwd -P)" -resolved_worktree_path="" -if [[ -n "$worktree_path" && -d "$worktree_path" ]]; then - resolved_worktree_path="$(cd "$worktree_path" && pwd -P)" -fi - -fallback_reason="" -if [[ "$start_status" -ne 0 ]]; then - fallback_reason="starter exited with status ${start_status}" -elif [[ -z "$worktree_path" ]]; then - fallback_reason="starter did not report worktree path" -elif [[ -n "$resolved_worktree_path" && "$resolved_worktree_path" == "$resolved_repo_root" ]]; then - fallback_reason="starter pointed to active checkout path" -elif [[ -n "$initial_repo_branch" && -n "$current_repo_branch" && "$current_repo_branch" != "$initial_repo_branch" ]]; then - fallback_reason="starter switched active checkout branch" +if [[ -n "$GH_PR_REF" ]]; then + start_args+=(--pr "$GH_PR_REF") fi - -if [[ -n "$fallback_reason" ]]; then - if ! restore_repo_branch_if_changed "$initial_repo_branch"; then - echo "[codex-agent] agent-branch-start changed the active checkout branch and restore failed." >&2 - echo "[codex-agent] Run 'gx setup --target ${repo_root}' and 'gx doctor --target ${repo_root}', then retry." >&2 - exit 1 - fi - if [[ -n "$start_output" ]]; then - printf '%s\n' "$start_output" >&2 - fi - echo "[codex-agent] Unsafe starter output (${fallback_reason}); creating sandbox worktree directly." >&2 - start_output="$(start_sandbox_fallback)" - printf '%s\n' "$start_output" - worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n1)" -else - printf '%s\n' "$start_output" -fi - -if [[ -z "$worktree_path" ]]; then - echo "[codex-agent] Could not determine sandbox worktree path from sandbox startup output." >&2 - echo "[codex-agent] Run 'gx setup --target ${repo_root}' and 'gx doctor --target ${repo_root}', then retry." >&2 - exit 1 +if [[ -n "$GH_REPO_REF" ]]; then + start_args+=(--repo "$GH_REPO_REF") fi - -if [[ ! -d "$worktree_path" ]]; then - echo "[codex-agent] Reported worktree path does not exist: $worktree_path" >&2 - exit 1 +if [[ -n "$GH_SYNC_FLAG" ]]; then + start_args+=("$GH_SYNC_FLAG") fi -has_origin_remote() { - git -C "$repo_root" remote get-url origin >/dev/null 2>&1 -} - -resolve_worktree_base_branch() { - local _wt="$1" - if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -n "$BASE_BRANCH" ]]; then - printf '%s' "$BASE_BRANCH" - return 0 - fi - - local configured_base - configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" - if [[ -n "$configured_base" ]]; then - printf '%s' "$configured_base" - return 0 - fi - - printf 'dev' -} - -sync_worktree_with_base() { - local wt="$1" - if ! has_origin_remote; then - return 0 - fi - - local base_branch - base_branch="$(resolve_worktree_base_branch "$wt")" - if [[ -z "$base_branch" ]]; then - return 0 - fi - - if ! git -C "$wt" fetch origin "$base_branch" --quiet; then - echo "[codex-agent] Warning: could not fetch origin/${base_branch} before task start." >&2 - return 0 - fi - - if ! git -C "$wt" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then - return 0 - fi - - local behind_count - behind_count="$(git -C "$wt" rev-list --left-right --count "HEAD...origin/${base_branch}" 2>/dev/null | awk '{print $2}')" - behind_count="${behind_count:-0}" - if [[ "$behind_count" -le 0 ]]; then - return 0 - fi - - local branch - branch="$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" - echo "[codex-agent] Task sync: '${branch}' is behind origin/${base_branch} by ${behind_count} commit(s). Rebasing before launch..." - if ! git -C "$wt" rebase "origin/${base_branch}"; then - echo "[codex-agent] Task sync failed. Resolve and continue in sandbox:" >&2 - echo " git -C \"$wt\" rebase --continue" >&2 - echo " # or abort" >&2 - echo " git -C \"$wt\" rebase --abort" >&2 - return 1 - fi - echo "[codex-agent] Task sync complete." - return 0 -} - -ensure_openspec_plan_workspace() { - local wt="$1" - local branch="$2" - - if [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]]; then - return 0 - fi - - hydrate_local_helper_in_worktree "$wt" "scripts/openspec/init-plan-workspace.sh" - - local openspec_script="${wt}/scripts/openspec/init-plan-workspace.sh" - if [[ ! -f "$openspec_script" ]]; then - echo "[codex-agent] Missing OpenSpec init script in sandbox: ${openspec_script}" >&2 - echo "[codex-agent] Run 'gx setup --target ${repo_root}' and retry." >&2 - return 1 - fi - if [[ ! -x "$openspec_script" ]]; then - chmod +x "$openspec_script" 2>/dev/null || true - fi - - local plan_slug - plan_slug="$(resolve_openspec_plan_slug "$branch")" - local init_output="" - if ! init_output="$( - cd "$wt" - bash "scripts/openspec/init-plan-workspace.sh" "$plan_slug" 2>&1 - )"; then - printf '%s\n' "$init_output" >&2 - echo "[codex-agent] OpenSpec workspace initialization failed for plan '${plan_slug}'." >&2 - return 1 - fi - if [[ -n "$init_output" ]]; then - printf '%s\n' "$init_output" - fi - echo "[codex-agent] OpenSpec plan workspace: ${wt}/openspec/plan/${plan_slug}" -} - -ensure_openspec_change_workspace() { - local wt="$1" - local branch="$2" +derive_worktree_session_key() { + local worktree="$1" + local digest="" - if [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]]; then - return 0 + if command -v sha256sum >/dev/null 2>&1; then + digest="$(printf '%s' "$worktree" | sha256sum | awk '{print $1}' | cut -c1-20)" + elif command -v shasum >/dev/null 2>&1; then + digest="$(printf '%s' "$worktree" | shasum -a 256 | awk '{print $1}' | cut -c1-20)" fi - hydrate_local_helper_in_worktree "$wt" "scripts/openspec/init-change-workspace.sh" - - local openspec_script="${wt}/scripts/openspec/init-change-workspace.sh" - if [[ ! -f "$openspec_script" ]]; then - echo "[codex-agent] Missing OpenSpec change init script in sandbox: ${openspec_script}" >&2 - echo "[codex-agent] Run 'gx setup --target ${repo_root}' and retry." >&2 - return 1 - fi - if [[ ! -x "$openspec_script" ]]; then - chmod +x "$openspec_script" 2>/dev/null || true + if [[ -z "$digest" ]]; then + digest="$(printf '%s' "$worktree" | tr -cs 'a-zA-Z0-9' '-' | sed -E 's/^-+//; s/-+$//' | cut -c1-40)" fi - local change_slug capability_slug init_output="" - change_slug="$(resolve_openspec_change_slug "$branch")" - capability_slug="$(resolve_openspec_capability_slug)" - if ! init_output="$( - cd "$wt" - bash "scripts/openspec/init-change-workspace.sh" "$change_slug" "$capability_slug" 2>&1 - )"; then - printf '%s\n' "$init_output" >&2 - echo "[codex-agent] OpenSpec workspace initialization failed for change '${change_slug}'." >&2 - return 1 + if [[ -z "$digest" ]]; then + digest="sandbox" fi - if [[ -n "$init_output" ]]; then - printf '%s\n' "$init_output" - fi - echo "[codex-agent] OpenSpec change workspace: ${wt}/openspec/changes/${change_slug}" -} -worktree_has_changes() { - local wt="$1" - if ! git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json"; then - return 0 - fi - if ! git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json"; then - return 0 - fi - if [[ -n "$(git -C "$wt" ls-files --others --exclude-standard)" ]]; then - return 0 - fi - return 1 + printf 'worktree:%s' "$digest" } -claim_changed_files() { - local wt="$1" - local branch="$2" - local lock_script="${repo_root}/scripts/agent-file-locks.py" - - if [[ ! -x "$lock_script" ]]; then - return 0 - fi - - local changed_raw deleted_raw - changed_raw="$({ - git -C "$wt" diff --name-only -- . ":(exclude).omx/state/agent-file-locks.json"; - git -C "$wt" diff --cached --name-only -- . ":(exclude).omx/state/agent-file-locks.json"; - git -C "$wt" ls-files --others --exclude-standard; - } | sed '/^$/d' | sort -u)" - - if [[ -n "$changed_raw" ]]; then - mapfile -t changed_files < <(printf '%s\n' "$changed_raw") - python3 "$lock_script" claim --branch "$branch" "${changed_files[@]}" >/dev/null 2>&1 || true - fi - - deleted_raw="$({ - git -C "$wt" diff --name-only --diff-filter=D -- . ":(exclude).omx/state/agent-file-locks.json"; - git -C "$wt" diff --cached --name-only --diff-filter=D -- . ":(exclude).omx/state/agent-file-locks.json"; - } | sed '/^$/d' | sort -u)" +export_worktree_mem0_env() { + local worktree="$1" + local mem0_dir="${worktree}/.omx/mem0" + local notepad_path="${mem0_dir}/notepad.md" + local project_memory_path="${mem0_dir}/project-memory.json" + local scope_path="${mem0_dir}/worktree-scope.json" - if [[ -n "$deleted_raw" ]]; then - mapfile -t deleted_files < <(printf '%s\n' "$deleted_raw") - python3 "$lock_script" allow-delete --branch "$branch" "${deleted_files[@]}" >/dev/null 2>&1 || true + export OMX_MEM0_SCOPE="worktree" + export OMX_MEM0_DIR="$mem0_dir" + if [[ -f "$notepad_path" ]]; then + export OMX_NOTEPAD_PATH="$notepad_path" fi -} - -auto_commit_worktree_changes() { - local wt="$1" - local branch="$2" - - if ! worktree_has_changes "$wt"; then - return 0 + if [[ -f "$project_memory_path" ]]; then + export OMX_PROJECT_MEMORY_PATH="$project_memory_path" fi - - claim_changed_files "$wt" "$branch" - git -C "$wt" add -A - - if git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json"; then - return 0 - fi - - local default_message="Auto-finish: ${TASK_NAME}" - local commit_message="${MUSAFETY_CODEX_AUTO_COMMIT_MESSAGE:-$default_message}" - local commit_output="" - - if commit_output="$(git -C "$wt" commit -m "$commit_message" 2>&1)"; then - echo "[codex-agent] Auto-committed sandbox changes on '${branch}'." - return 0 + if [[ -f "$scope_path" ]]; then + export OMX_MEM0_SCOPE_PATH="$scope_path" fi - if auto_sync_for_commit_retry "$wt" "$branch"; then - claim_changed_files "$wt" "$branch" - git -C "$wt" add -A - if commit_output="$(git -C "$wt" commit -m "$commit_message" 2>&1)"; then - echo "[codex-agent] Auto-committed sandbox changes on '${branch}' after sync retry." - return 0 - fi + if [[ -z "${CODEX_AUTH_SESSION_KEY:-}" ]]; then + export CODEX_AUTH_SESSION_KEY="$(derive_worktree_session_key "$worktree")" + echo "[codex-agent] Scoped CODEX_AUTH_SESSION_KEY to ${CODEX_AUTH_SESSION_KEY}" fi - echo "[codex-agent] Auto-commit failed in sandbox. Keeping branch for manual review: $branch" >&2 - if [[ -n "$commit_output" ]]; then - printf '%s\n' "$commit_output" >&2 - fi - return 1 + echo "[codex-agent] Worktree mem0 scope: $mem0_dir" } -auto_sync_for_commit_retry() { - local wt="$1" - local branch="$2" +resolve_finish_base_branch() { + local branch="$1" + local stored_base="" - if ! has_origin_remote; then - return 1 - fi - - local base_branch - base_branch="$(resolve_worktree_base_branch "$wt")" - if [[ -z "$base_branch" ]]; then - return 1 - fi - - if ! git -C "$wt" fetch origin "$base_branch" --quiet; then - return 1 - fi - - if ! git -C "$wt" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then - return 1 - fi - - local behind_count - behind_count="$(git -C "$wt" rev-list --left-right --count "HEAD...origin/${base_branch}" 2>/dev/null | awk '{print $2}')" - behind_count="${behind_count:-0}" - if [[ "$behind_count" -le 0 ]]; then - return 1 - fi - - echo "[codex-agent] Auto-commit retry: '${branch}' is behind origin/${base_branch} by ${behind_count} commit(s). Syncing and retrying..." - - local stash_ref="" - local stash_output="" - if worktree_has_changes "$wt"; then - if ! stash_output="$(git -C "$wt" stash push --include-untracked -m "codex-agent-autocommit-sync-${branch}-$(date +%s)" 2>&1)"; then - return 1 - fi - stash_ref="$(printf '%s\n' "$stash_output" | grep -o 'stash@{[0-9]\+}' | head -n 1 || true)" + stored_base="$(git -C "$repo_root" config --get "branch.${branch}.musafetyBase" || true)" + if [[ -n "$stored_base" ]]; then + printf '%s' "$stored_base" + return fi - if ! git -C "$wt" rebase "origin/${base_branch}" >/dev/null 2>&1; then - git -C "$wt" rebase --abort >/dev/null 2>&1 || true - if [[ -n "$stash_ref" ]]; then - git -C "$wt" stash pop "$stash_ref" >/dev/null 2>&1 || true - fi - return 1 - fi - - if [[ -n "$stash_ref" ]]; then - if ! git -C "$wt" stash pop "$stash_ref" >/dev/null 2>&1; then - echo "[codex-agent] Auto-commit retry could not re-apply local changes after sync. Manual resolution required in: $wt" >&2 - return 1 - fi - fi - - return 0 -} - -looks_like_conflict_failure() { - local output="$1" - if grep -qiE 'preflight conflict detected|merge conflict detected|auto-sync failed while rebasing|rebase --continue|rebase --abort' <<< "$output"; then - return 0 + if [[ -n "$BASE_BRANCH" ]]; then + printf '%s' "$BASE_BRANCH" fi - return 1 } -run_finish_flow() { - local wt="$1" - local branch="$2" - local finish_base_branch="" - local finish_output="" - local -a finish_args +render_finish_hint() { + local branch="$1" + local base="${2:-}" + local hint="" - finish_args=(--branch "$branch") - if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -n "$BASE_BRANCH" ]]; then - finish_base_branch="$BASE_BRANCH" - else - finish_base_branch="$(resolve_worktree_base_branch "$wt")" + hint="bash scripts/agent-branch-finish.sh --branch \"${branch}\"" + if [[ -n "$base" ]]; then + hint="${hint} --base \"${base}\"" fi - if [[ -n "$finish_base_branch" ]]; then - finish_args+=(--base "$finish_base_branch") + hint="${hint} --via-pr --wait-for-merge --cleanup" + if [[ -n "$GH_PR_REF" ]]; then + hint="${hint} --pr \"${GH_PR_REF}\"" fi - if [[ "$AUTO_CLEANUP" -eq 1 ]]; then - finish_args+=(--cleanup) - fi - if [[ "$AUTO_WAIT_FOR_MERGE" -eq 1 ]]; then - finish_args+=(--wait-for-merge) + if [[ -n "$GH_REPO_REF" ]]; then + hint="${hint} --repo \"${GH_REPO_REF}\"" fi - if has_origin_remote; then - if ! command -v "${MUSAFETY_GH_BIN:-gh}" >/dev/null 2>&1 && ! command -v gh >/dev/null 2>&1; then - echo "[codex-agent] Auto-finish requires GitHub CLI for PR flow; command not found: ${MUSAFETY_GH_BIN:-gh}" >&2 - return 2 - fi - finish_args+=(--via-pr) - else - echo "[codex-agent] No origin remote detected; skipping auto-finish merge/PR pipeline." >&2 - return 2 - fi - - if finish_output="$(bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}" 2>&1)"; then - printf '%s\n' "$finish_output" - return 0 - fi - - printf '%s\n' "$finish_output" >&2 - - if [[ "$AUTO_REVIEW_ON_CONFLICT" -eq 1 ]] && looks_like_conflict_failure "$finish_output"; then - echo "[codex-agent] Auto-finish hit conflicts. Launching Codex conflict-review pass in sandbox..." >&2 - local review_prompt - review_prompt="Resolve git conflicts for branch ${branch} against ${finish_base_branch:-dev}, then commit the resolution in this sandbox worktree and exit." - - ( - cd "$wt" - set +e - "$CODEX_BIN" "$review_prompt" - review_exit="$?" - set -e - if [[ "$review_exit" -ne 0 ]]; then - echo "[codex-agent] Conflict-review Codex pass exited with status ${review_exit}." >&2 - fi - ) - - if finish_output="$(bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}" 2>&1)"; then - printf '%s\n' "$finish_output" - return 0 - fi - - printf '%s\n' "$finish_output" >&2 - fi - - return 1 + printf '%s' "$hint" } -if ! sync_worktree_with_base "$worktree_path"; then - exit 1 -fi +start_output="$(bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}")" +printf '%s\n' "$start_output" -worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" -if [[ -z "$worktree_branch" || "$worktree_branch" == "HEAD" ]]; then - echo "[codex-agent] Could not determine sandbox branch for worktree: $worktree_path" >&2 +worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n1)" +if [[ -z "$worktree_path" ]]; then + echo "[codex-agent] Could not determine sandbox worktree path from agent-branch-start output." >&2 exit 1 fi -if ! ensure_openspec_change_workspace "$worktree_path" "$worktree_branch"; then +if [[ ! -d "$worktree_path" ]]; then + echo "[codex-agent] Reported worktree path does not exist: $worktree_path" >&2 exit 1 fi -if ! ensure_openspec_plan_workspace "$worktree_path" "$worktree_branch"; then - exit 1 -fi +export_worktree_mem0_env "$worktree_path" echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path" cd "$worktree_path" @@ -732,50 +216,57 @@ codex_exit="$?" set -e cd "$repo_root" + final_exit="$codex_exit" -auto_finish_completed=0 - -if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then - if [[ "$AUTO_WAIT_FOR_MERGE" -eq 1 && "$AUTO_CLEANUP" -eq 1 ]]; then - echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> wait for merge -> cleanup." - elif [[ "$AUTO_WAIT_FOR_MERGE" -eq 1 ]]; then - echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> wait for merge (keep branch/worktree)." - elif [[ "$AUTO_CLEANUP" -eq 1 ]]; then - echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> merge -> cleanup." - else - echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> merge (keep branch/worktree)." - fi - if auto_commit_worktree_changes "$worktree_path" "$worktree_branch"; then - if run_finish_flow "$worktree_path" "$worktree_branch"; then - auto_finish_completed=1 - echo "[codex-agent] Auto-finish completed for '${worktree_branch}'." - else - finish_status="$?" - if [[ "$finish_status" -eq 2 ]]; then - echo "[codex-agent] Auto-finish skipped for '${worktree_branch}' (no mergeable remote context)." >&2 - else - echo "[codex-agent] Auto-finish did not complete; keeping sandbox for manual review: $worktree_path" >&2 - if [[ "$final_exit" -eq 0 ]]; then - final_exit=1 +worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +finish_base_branch="" +finish_hint="" +if [[ -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then + finish_base_branch="$(resolve_finish_base_branch "$worktree_branch")" + finish_hint="$(render_finish_hint "$worktree_branch" "$finish_base_branch")" +fi + +if [[ "$codex_exit" -eq 0 ]]; then + if [[ -x "${repo_root}/scripts/agent-branch-finish.sh" ]]; then + if [[ -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then + finish_args=(--branch "$worktree_branch") + if [[ -n "$finish_base_branch" ]]; then + finish_args+=(--base "$finish_base_branch") + fi + finish_args+=(--via-pr --wait-for-merge --cleanup) + if [[ -n "$GH_PR_REF" ]]; then + finish_args+=(--pr "$GH_PR_REF") + fi + if [[ -n "$GH_REPO_REF" ]]; then + finish_args+=(--repo "$GH_REPO_REF") + fi + + echo "[codex-agent] Codex finished successfully. Auto-finishing branch via PR merge + cleanup..." + if ! bash "${repo_root}/scripts/agent-branch-finish.sh" "${finish_args[@]}"; then + echo "[codex-agent] Auto-finish failed. Sandbox is kept for manual resolve/retry." >&2 + if [[ -n "$finish_hint" ]]; then + echo "[codex-agent] Retry with: ${finish_hint}" >&2 fi + final_exit=1 fi - fi - else - if [[ "$final_exit" -eq 0 ]]; then + else + echo "[codex-agent] Could not determine sandbox branch name; skipping auto-finish." >&2 final_exit=1 fi + else + echo "[codex-agent] Missing scripts/agent-branch-finish.sh; skipping auto-finish." >&2 + final_exit=1 fi +else + echo "[codex-agent] Skipping auto-finish because Codex exited with status ${codex_exit}." fi if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..." + echo "[codex-agent] Session ended (exit=${final_exit}). Running worktree cleanup..." prune_args=() if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then prune_args+=(--base "$BASE_BRANCH") fi - if [[ "$AUTO_CLEANUP" -eq 1 && "$auto_finish_completed" -eq 1 ]]; then - prune_args+=(--only-dirty-worktrees --delete-branches --delete-remote-branches) - fi if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2 fi @@ -784,15 +275,13 @@ fi if [[ ! -d "$worktree_path" ]]; then echo "[codex-agent] Auto-cleaned sandbox worktree: $worktree_path" else - worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" echo "[codex-agent] Sandbox worktree kept: $worktree_path" if [[ -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then - if [[ "$auto_finish_completed" -eq 1 ]]; then - echo "[codex-agent] Branch kept intentionally. Cleanup on demand: gx cleanup --branch \"${worktree_branch}\"" - else - echo "[codex-agent] If finished, merge with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\" --base dev --via-pr --wait-for-merge" - echo "[codex-agent] Cleanup on demand: gx cleanup --branch \"${worktree_branch}\"" + if [[ -z "$finish_hint" ]]; then + finish_base_branch="$(resolve_finish_base_branch "$worktree_branch")" + finish_hint="$(render_finish_hint "$worktree_branch" "$finish_base_branch")" fi + echo "[codex-agent] If finished, merge + clean with: ${finish_hint}" fi fi diff --git a/test/metadata.test.js b/test/metadata.test.js index 7ca4b1e..084eddf 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -80,5 +80,19 @@ test('doctor CLI parser exists to prevent runtime ReferenceError regressions', ( const cliPath = path.join(repoRoot, 'bin', 'multiagent-safety.js'); const cliSource = fs.readFileSync(cliPath, 'utf8'); assert.match(cliSource, /function parseDoctorArgs\(rawArgs\)/); - assert.match(cliSource, /const options = parseDoctorArgs\(rawArgs\);/); + assert.match(cliSource, /function doctorAudit\(rawArgs\)/); +}); + +test('active doctor command remains single-source and runs the repair-first path', () => { + const cliPath = path.join(repoRoot, 'bin', 'multiagent-safety.js'); + const cliSource = fs.readFileSync(cliPath, 'utf8'); + const doctorDefs = cliSource.match(/function doctor\(rawArgs\)/g) || []; + assert.equal(doctorDefs.length, 1, 'doctor() must not be duplicated'); + assert.match(cliSource, /printOperations\('Doctor\/fix', fixPayload, options\.dryRun\);/); +}); + +test('worktree-change detection uses normal untracked-file mode', () => { + const cliPath = path.join(repoRoot, 'bin', 'multiagent-safety.js'); + const cliSource = fs.readFileSync(cliPath, 'utf8'); + assert.match(cliSource, /'status',\s*'--porcelain',\s*'--untracked-files=normal',\s*'--'/s); });