diff --git a/.claude/commands/musafety.md b/.claude/commands/musafety.md new file mode 100644 index 0000000..5b180c0 --- /dev/null +++ b/.claude/commands/musafety.md @@ -0,0 +1,18 @@ +# /musafety + +Run a musafety check-and-repair workflow for the current repository. + +## Steps + +1. Run `musafety status`. +2. If status is degraded, run `musafety doctor`. +3. If still degraded, run `musafety scan` and summarize each finding with a fix. +4. Report final verdict as one of: + - `Repo is musafe` + - `Repo is not musafe` (include blockers) + +## Style + +- Keep output short and operational. +- Include exact commands you executed. +- Prefer concrete next actions over generic advice. diff --git a/.codex/skills/musafety/SKILL.md b/.codex/skills/musafety/SKILL.md new file mode 100644 index 0000000..ceb2cd8 --- /dev/null +++ b/.codex/skills/musafety/SKILL.md @@ -0,0 +1,35 @@ +--- +name: musafety +description: "Use when you need to check, repair, or bootstrap multi-agent safety guardrails in this repository." +--- + +# musafety (Codex skill) + +Use this skill whenever branch safety, lock ownership, or guardrail setup may be broken. + +## Fast path + +1. Run `musafety status`. +2. If repo safety is degraded, run `musafety doctor`. +3. If issues remain, run `musafety scan` and address the findings. + +## Setup path + +If guardrails are missing entirely, run: + +```sh +musafety setup +``` + +Then verify: + +```sh +musafety status +musafety scan +``` + +## Operator notes + +- Prefer `musafety doctor` for one-step repair + verification. +- Keep agent work isolated (`agent/*` branches + lock claims). +- Do not bypass protected branch safeguards unless explicitly required. diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..07cd233 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ -z "$branch" ]]; then + exit 0 +fi + +if [[ "${ALLOW_COMMIT_ON_PROTECTED_BRANCH:-0}" == "1" ]]; then + exit 0 +fi + +is_vscode_git_context=0 +if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" ]]; then + is_vscode_git_context=1 +fi + +allow_vscode_protected_branch_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH:-$(git config --get multiagent.protectedBranches.allowVSCode || true)}" +allow_vscode_protected_branch_raw="$(printf '%s' "${allow_vscode_protected_branch_raw:-}" | tr '[:upper:]' '[:lower:]')" +allow_vscode_protected_branch=0 +case "$allow_vscode_protected_branch_raw" in + 1|true|yes|on) allow_vscode_protected_branch=1 ;; + *) allow_vscode_protected_branch=0 ;; +esac + +protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}" +if [[ -z "$protected_branches_raw" ]]; then + protected_branches_raw="dev main master" +fi +protected_branches_raw="${protected_branches_raw//,/ }" + +is_protected_branch=0 +for protected_branch in $protected_branches_raw; do + if [[ "$branch" == "$protected_branch" ]]; then + is_protected_branch=1 + break + fi +done + +if [[ "$is_protected_branch" == "1" ]]; then + if [[ "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch" == "1" ]]; then + exit 0 + fi + + git_dir="$(git rev-parse --git-dir)" + if [[ -f "$git_dir/MERGE_HEAD" ]]; then + exit 0 + fi + + cat >&2 <<'MSG' +[agent-branch-guard] Direct commits on protected branches are blocked. +Use an agent branch first: + bash scripts/agent-branch-start.sh "" "" +After finishing work: + bash scripts/agent-branch-finish.sh + +Optional repo override to allow VS Code Source Control commits: + git config multiagent.protectedBranches.allowVSCode true + +Temporary bypass (not recommended): + ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... +MSG + exit 1 +fi + +if [[ "$branch" == agent/* ]]; then + 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. +Claim files first: + python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" +MSG + exit 1 + fi + + require_sync_before_commit_raw="$(git config --get multiagent.sync.requireBeforeCommit || true)" + if [[ -z "$require_sync_before_commit_raw" ]]; then + require_sync_before_commit_raw="false" + fi + require_sync_before_commit="$(printf '%s' "$require_sync_before_commit_raw" | tr '[:upper:]' '[:lower:]')" + + should_require_sync=0 + case "$require_sync_before_commit" in + 1|true|yes|on) should_require_sync=1 ;; + 0|false|no|off) should_require_sync=0 ;; + *) should_require_sync=0 ;; + esac + + if [[ "$should_require_sync" == "1" ]]; then + base_branch="$(git config --get multiagent.baseBranch || true)" + if [[ -z "$base_branch" ]]; then + base_branch="dev" + fi + + max_behind_raw="$(git config --get multiagent.sync.maxBehindCommits || true)" + if [[ -z "$max_behind_raw" ]]; then + max_behind_raw="0" + fi + if [[ ! "$max_behind_raw" =~ ^[0-9]+$ ]]; then + echo "[agent-sync-guard] Invalid multiagent.sync.maxBehindCommits value: ${max_behind_raw}" >&2 + echo "[agent-sync-guard] Expected non-negative integer. Example: git config multiagent.sync.maxBehindCommits 0" >&2 + exit 1 + fi + + if ! git fetch origin "$base_branch" --quiet >/dev/null 2>&1; then + echo "[agent-sync-guard] Unable to fetch origin/${base_branch} while commit sync gate is enabled." >&2 + echo "[agent-sync-guard] Disable gate temporarily with: git config multiagent.sync.requireBeforeCommit false" >&2 + exit 1 + fi + + if ! git show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then + echo "[agent-sync-guard] Remote base branch not found: origin/${base_branch}" >&2 + exit 1 + fi + + behind_count="$(git rev-list --left-right --count "${branch}...origin/${base_branch}" 2>/dev/null | awk '{print $2}')" + behind_count="${behind_count:-0}" + max_behind="${max_behind_raw}" + + if [[ "$behind_count" -gt "$max_behind" ]]; then + cat >&2 < +MSG + exit 1 + fi + fi +fi + +if command -v pre-commit >/dev/null 2>&1 && [[ -f .pre-commit-config.yaml ]]; then + pre-commit run --hook-stage pre-commit +fi diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..88a5d7f --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${ALLOW_PUSH_ON_PROTECTED_BRANCH:-0}" == "1" || "${ALLOW_COMMIT_ON_PROTECTED_BRANCH:-0}" == "1" ]]; then + exit 0 +fi + +is_vscode_git_context=0 +if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" ]]; then + is_vscode_git_context=1 +fi + +allow_vscode_protected_branch_raw="${MUSAFETY_ALLOW_VSCODE_PROTECTED_BRANCH:-$(git config --get multiagent.protectedBranches.allowVSCode || true)}" +allow_vscode_protected_branch_raw="$(printf '%s' "${allow_vscode_protected_branch_raw:-}" | tr '[:upper:]' '[:lower:]')" +allow_vscode_protected_branch=0 +case "$allow_vscode_protected_branch_raw" in + 1|true|yes|on) allow_vscode_protected_branch=1 ;; + *) allow_vscode_protected_branch=0 ;; +esac + +if [[ "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch" == "1" ]]; then + exit 0 +fi + +protected_branches_raw="${MUSAFETY_PROTECTED_BRANCHES:-$(git config --get multiagent.protectedBranches || true)}" +if [[ -z "$protected_branches_raw" ]]; then + protected_branches_raw="dev main master" +fi +protected_branches_raw="${protected_branches_raw//,/ }" + +is_protected_branch() { + local branch="$1" + for protected_branch in $protected_branches_raw; do + if [[ "$branch" == "$protected_branch" ]]; then + return 0 + fi + done + return 1 +} + +blocked_refs=() +while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do + if [[ -z "${remote_ref:-}" || "$remote_ref" != refs/heads/* ]]; then + continue + fi + + remote_branch="${remote_ref#refs/heads/}" + if is_protected_branch "$remote_branch"; then + blocked_refs+=("$remote_branch") + fi +done + +if [[ "${#blocked_refs[@]}" -gt 0 ]]; then + { + echo "[agent-branch-guard] Push to protected branch blocked." + echo "[agent-branch-guard] Protected target(s): ${blocked_refs[*]}" + echo "[agent-branch-guard] Push from an agent branch and merge via PR." + echo "[agent-branch-guard] Optional repo override for VS Code Source Control:" + echo " git config multiagent.protectedBranches.allowVSCode true" + echo + echo "Temporary bypass (not recommended):" + echo " ALLOW_PUSH_ON_PROTECTED_BRANCH=1 git push ..." + } >&2 + exit 1 +fi + +exit 0 diff --git a/.gitignore b/.gitignore index 8be440a..451fec9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .omx/ -node_modules \ No newline at end of file +node_modules +oh-my-codex/ diff --git a/README.md b/README.md index e9c90b7..6049618 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Example output: npm i -g musafety musafety setup musafety doctor +bash scripts/codex-agent.sh "task" "agent-name" bash scripts/agent-branch-start.sh "task" "agent-name" python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" @@ -157,6 +158,7 @@ Use this exact checklist to setup multi-agent safety in this repository for Code musafety doctor 4) Confirm next safe agent workflow commands: + bash scripts/codex-agent.sh "task" "agent-name" bash scripts/agent-branch-start.sh "task" "agent-name" python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" @@ -275,6 +277,7 @@ multiagent.protectedBranches - risky stale/missing lock state - accidental loss of critical guardrail files - setup also writes a managed `.gitignore` block so generated musafety scripts/hooks stay out of normal git status noise by default + - includes `oh-my-codex/` by default to keep local OMX source clones out of repo status - pass `--no-gitignore` if you want to keep tracking these files in git ## Files it installs @@ -282,6 +285,7 @@ multiagent.protectedBranches ```text scripts/agent-branch-start.sh scripts/agent-branch-finish.sh +scripts/codex-agent.sh scripts/agent-worktree-prune.sh scripts/agent-file-locks.py scripts/install-agent-git-hooks.sh diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 5bbbf87..a480f4c 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -27,6 +27,7 @@ const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates'); const TEMPLATE_FILES = [ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', + 'scripts/codex-agent.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', 'scripts/install-agent-git-hooks.sh', @@ -39,6 +40,7 @@ const TEMPLATE_FILES = [ const EXECUTABLE_RELATIVE_PATHS = new Set([ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', + 'scripts/codex-agent.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', 'scripts/install-agent-git-hooks.sh', @@ -61,11 +63,13 @@ const GITIGNORE_MARKER_END = '# multiagent-safety:END'; const MANAGED_GITIGNORE_PATHS = [ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', + 'scripts/codex-agent.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', + 'oh-my-codex/', '.codex/skills/musafety/SKILL.md', '.claude/commands/musafety.md', LOCK_FILE_RELATIVE, @@ -132,6 +136,7 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup multi-agent safety in musafety doctor 4) Confirm next safe agent workflow commands: + bash scripts/codex-agent.sh "task" "agent-name" bash scripts/agent-branch-start.sh "task" "agent-name" python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" @@ -150,6 +155,7 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup multi-agent safety in const AI_SETUP_COMMANDS = `npm i -g musafety musafety setup musafety doctor +bash scripts/codex-agent.sh "task" "agent-name" bash scripts/agent-branch-start.sh "task" "agent-name" python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" @@ -462,6 +468,7 @@ function ensurePackageScripts(repoRoot, dryRun) { } const wantedScripts = { + 'agent:codex': 'bash ./scripts/codex-agent.sh', 'agent:branch:start': 'bash ./scripts/agent-branch-start.sh', 'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh', 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh --base dev', diff --git a/package-lock.json b/package-lock.json index 185662e..3c111e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "musafety", - "version": "0.4.7", + "version": "0.4.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "musafety", - "version": "0.4.7", + "version": "0.4.9", "license": "MIT", "bin": { "multiagent-safety": "bin/multiagent-safety.js", diff --git a/package.json b/package.json index 0b0feea..fd794dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "musafety", - "version": "0.4.7", + "version": "0.4.9", "description": "Simple setup command for hardened multi-agent collaboration safety in git repos.", "license": "MIT", "preferGlobal": true, diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh new file mode 100755 index 0000000..a076529 --- /dev/null +++ b/scripts/agent-branch-finish.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_BRANCH="dev" +BASE_BRANCH_EXPLICIT=0 +SOURCE_BRANCH="" +PUSH_ENABLED=1 +DELETE_REMOTE_BRANCH=1 +MERGE_MODE="auto" +GH_BIN="${MUSAFETY_GH_BIN:-gh}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) + BASE_BRANCH="${2:-}" + BASE_BRANCH_EXPLICIT=1 + shift 2 + ;; + --branch) + SOURCE_BRANCH="${2:-}" + shift 2 + ;; + --no-push) + PUSH_ENABLED=0 + shift + ;; + --keep-remote-branch) + DELETE_REMOTE_BRANCH=0 + shift + ;; + --mode) + MERGE_MODE="${2:-auto}" + shift 2 + ;; + --via-pr) + MERGE_MODE="pr" + shift + ;; + --direct-only) + MERGE_MODE="direct" + shift + ;; + *) + echo "[agent-branch-finish] Unknown argument: $1" >&2 + echo "Usage: $0 [--base ] [--branch ] [--no-push] [--keep-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2 + exit 1 + ;; + esac +done + +case "$MERGE_MODE" in + auto|direct|pr) ;; + *) + echo "[agent-branch-finish] Invalid --mode value: ${MERGE_MODE} (expected auto|direct|pr)" >&2 + exit 1 + ;; +esac + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[agent-branch-finish] Not inside a git repository." >&2 + exit 1 +fi + +repo_root="$(git rev-parse --show-toplevel)" +current_worktree="$(pwd -P)" + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured_base" ]]; then + BASE_BRANCH="$configured_base" + fi +fi + +if [[ -z "$SOURCE_BRANCH" ]]; then + SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +fi + +if [[ "$SOURCE_BRANCH" == "$BASE_BRANCH" ]]; then + echo "[agent-branch-finish] Source branch and base branch are both '$BASE_BRANCH'." >&2 + echo "[agent-branch-finish] Switch to your agent branch or pass --branch ." >&2 + exit 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 +fi + +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" +} + +source_worktree="$(get_worktree_for_branch "$SOURCE_BRANCH")" +created_source_probe=0 +source_probe_path="" + +if [[ -z "$source_worktree" ]]; then + source_probe_path="${repo_root}/.omx/agent-worktrees/__source-probe-${SOURCE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)" + mkdir -p "$(dirname "$source_probe_path")" + git -C "$repo_root" worktree add "$source_probe_path" "$SOURCE_BRANCH" >/dev/null + source_worktree="$source_probe_path" + created_source_probe=1 +fi + +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 + exit 1 +fi + +start_ref="$BASE_BRANCH" +if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then + git -C "$repo_root" fetch origin "$BASE_BRANCH" --quiet + start_ref="origin/${BASE_BRANCH}" +fi + +require_before_finish_raw="$(git -C "$repo_root" config --get multiagent.sync.requireBeforeFinish || true)" +if [[ -z "$require_before_finish_raw" ]]; then + require_before_finish_raw="true" +fi +require_before_finish="$(printf '%s' "$require_before_finish_raw" | tr '[:upper:]' '[:lower:]')" +should_require_sync=0 +case "$require_before_finish" in + 1|true|yes|on) should_require_sync=1 ;; + 0|false|no|off) should_require_sync=0 ;; + *) should_require_sync=1 ;; +esac + +if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then + behind_count="$(git -C "$repo_root" rev-list --left-right --count "${SOURCE_BRANCH}...origin/${BASE_BRANCH}" 2>/dev/null | awk '{print $2}')" + behind_count="${behind_count:-0}" + if [[ "$behind_count" -gt 0 ]]; then + echo "[agent-sync-guard] Branch '${SOURCE_BRANCH}' is behind origin/${BASE_BRANCH} by ${behind_count} commit(s)." >&2 + echo "[agent-sync-guard] Run: musafety sync --base ${BASE_BRANCH}" >&2 + echo "[agent-sync-guard] Then retry: bash scripts/agent-branch-finish.sh --branch \"${SOURCE_BRANCH}\"" >&2 + exit 1 + fi +fi + +integration_worktree="${repo_root}/.omx/agent-worktrees/__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 + +cleanup() { + if [[ -d "$integration_worktree" ]]; then + git -C "$repo_root" worktree remove "$integration_worktree" --force >/dev/null 2>&1 || true + 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 +} +trap cleanup EXIT + +if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then + git -C "$source_worktree" fetch origin "$BASE_BRANCH" --quiet + + if ! git -C "$source_worktree" merge --no-commit --no-ff "origin/${BASE_BRANCH}" >/dev/null 2>&1; then + conflict_files="$(git -C "$source_worktree" diff --name-only --diff-filter=U || true)" + git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true + + echo "[agent-branch-finish] Preflight conflict detected between '${SOURCE_BRANCH}' and latest origin/${BASE_BRANCH}." >&2 + if [[ -n "$conflict_files" ]]; then + echo "[agent-branch-finish] Conflicting files:" >&2 + while IFS= read -r file; do + [[ -n "$file" ]] && echo " - ${file}" >&2 + done <<< "$conflict_files" + fi + echo "[agent-branch-finish] Rebase/merge '${BASE_BRANCH}' into '${SOURCE_BRANCH}' and resolve conflicts before finishing." >&2 + exit 1 + fi + + 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 + exit 1 +fi + +merge_completed=1 +merge_status="direct" +direct_push_error="" +pr_url="" + +run_pr_flow() { + if ! command -v "$GH_BIN" >/dev/null 2>&1; then + echo "[agent-branch-finish] PR fallback requested but GitHub CLI not found: ${GH_BIN}" >&2 + return 1 + fi + + git -C "$source_worktree" push -u origin "$SOURCE_BRANCH" + + pr_title="$(git -C "$repo_root" log -1 --pretty=%s "$SOURCE_BRANCH" 2>/dev/null || true)" + if [[ -z "$pr_title" ]]; then + pr_title="Merge ${SOURCE_BRANCH} into ${BASE_BRANCH}" + fi + pr_body="Automated by scripts/agent-branch-finish.sh (PR flow)." + + "$GH_BIN" pr create \ + --base "$BASE_BRANCH" \ + --head "$SOURCE_BRANCH" \ + --title "$pr_title" \ + --body "$pr_body" >/dev/null 2>&1 || true + + pr_url="$("$GH_BIN" pr view "$SOURCE_BRANCH" --json url --jq '.url' 2>/dev/null || true)" + + merge_output="" + if merge_output="$("$GH_BIN" pr merge "$SOURCE_BRANCH" --squash --delete-branch 2>&1)"; then + return 0 + fi + + auto_output="" + if auto_output="$("$GH_BIN" pr merge "$SOURCE_BRANCH" --squash --delete-branch --auto 2>&1)"; then + echo "[agent-branch-finish] PR auto-merge enabled; waiting for required checks/reviews." >&2 + return 2 + fi + + if [[ -n "$merge_output" ]]; then + echo "[agent-branch-finish] PR merge not completed yet; leaving PR open." >&2 + echo "${merge_output}" >&2 + fi + if [[ -n "$auto_output" ]]; then + echo "${auto_output}" >&2 + fi + return 2 +} + +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 + direct_push_error="$direct_push_output" + merge_completed=0 + fi + else + merge_completed=0 + fi + + if [[ "$merge_completed" -eq 0 ]]; then + if [[ "$MERGE_MODE" == "direct" ]]; then + echo "[agent-branch-finish] Direct push/merge failed in --direct-only mode." >&2 + if [[ -n "$direct_push_error" ]]; then + echo "$direct_push_error" >&2 + fi + exit 1 + fi + + if run_pr_flow; then + merge_completed=1 + merge_status="pr" + else + pr_exit=$? + if [[ "$pr_exit" -eq 2 ]]; then + echo "[agent-branch-finish] PR flow created/updated branch '${SOURCE_BRANCH}' against '${BASE_BRANCH}'." >&2 + if [[ -n "$pr_url" ]]; then + echo "[agent-branch-finish] PR: ${pr_url}" >&2 + fi + echo "[agent-branch-finish] Merge pending review/check policy. Branch cleanup skipped for now." >&2 + exit 0 + fi + echo "[agent-branch-finish] PR flow failed." >&2 + if [[ -n "$direct_push_error" ]]; then + echo "[agent-branch-finish] Direct push failure details:" >&2 + echo "$direct_push_error" >&2 + fi + exit 1 + fi + fi +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 + +if [[ "$source_worktree" == "$repo_root" ]]; then + if is_clean_worktree "$source_worktree"; then + git -C "$source_worktree" checkout "$BASE_BRANCH" >/dev/null 2>&1 || true + fi +elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then + git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true +fi + +if [[ "$source_worktree" != "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then + git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true +fi + +git -C "$repo_root" branch -d "$SOURCE_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 + git -C "$repo_root" push origin --delete "$SOURCE_BRANCH" + fi +fi + +base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")" +if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then + git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true +fi + +if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then + if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then + echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 + echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH}" >&2 + fi +fi + +echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and removed branch." +if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; 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}" >&2 +fi diff --git a/scripts/agent-branch-start.sh b/scripts/agent-branch-start.sh new file mode 100755 index 0000000..61ca938 --- /dev/null +++ b/scripts/agent-branch-start.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +TASK_NAME="${1:-task}" +AGENT_NAME="${2:-agent}" +BASE_BRANCH="${3:-dev}" +WORKTREE_MODE=1 +WORKTREE_ROOT_REL=".omx/agent-worktrees" + +while [[ $# -gt 0 ]]; do + case "$1" in + --task) + TASK_NAME="${2:-task}" + shift 2 + ;; + --agent) + AGENT_NAME="${2:-agent}" + shift 2 + ;; + --base) + BASE_BRANCH="${2:-dev}" + shift 2 + ;; + --in-place) + WORKTREE_MODE=0 + shift + ;; + --worktree-root) + WORKTREE_ROOT_REL="${2:-.omx/agent-worktrees}" + shift 2 + ;; + --) + shift + break + ;; + -*) + echo "[agent-branch-start] Unknown option: $1" >&2 + echo "Usage: $0 [task] [agent] [base] [--in-place] [--worktree-root ]" >&2 + exit 1 + ;; + *) + break + ;; + esac +done + +sanitize_slug() { + local raw="$1" + 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="task" + fi + printf '%s' "$slug" +} + +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 +fi + +repo_root="$(git rev-parse --show-toplevel)" + +if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then + git fetch origin "${BASE_BRANCH}" --quiet + start_ref="origin/${BASE_BRANCH}" +else + if ! git show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then + echo "[agent-branch-start] Base branch not found locally or on origin: ${BASE_BRANCH}" >&2 + exit 1 + fi + start_ref="${BASE_BRANCH}" +fi + +task_slug="$(sanitize_slug "$TASK_NAME")" +agent_slug="$(sanitize_slug "$AGENT_NAME")" +timestamp="$(date +%Y%m%d-%H%M%S)" +branch_name="agent/${agent_slug}/${timestamp}-${task_slug}" + +if git show-ref --verify --quiet "refs/heads/${branch_name}"; then + echo "[agent-branch-start] Branch already exists: ${branch_name}" >&2 + exit 1 +fi + +if [[ "$WORKTREE_MODE" -eq 0 ]]; then + if ! git diff --quiet || ! git diff --cached --quiet; then + echo "[agent-branch-start] Working tree is not clean. Commit/stash changes before starting an in-place branch." >&2 + exit 1 + fi + + current_branch="$(git rev-parse --abbrev-ref HEAD)" + if [[ "$current_branch" != "$BASE_BRANCH" ]]; then + git checkout "$BASE_BRANCH" + fi + + if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then + git pull --ff-only origin "$BASE_BRANCH" + fi + + git checkout -b "$branch_name" + echo "[agent-branch-start] Created in-place branch: ${branch_name}" + echo "$branch_name" + exit 0 +fi + +worktree_root="${repo_root}/${WORKTREE_ROOT_REL}" +mkdir -p "$worktree_root" +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 + +git -C "$repo_root" worktree add -b "$branch_name" "$worktree_path" "$start_ref" + +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 + +echo "[agent-branch-start] Created branch: ${branch_name}" +echo "[agent-branch-start] Worktree: ${worktree_path}" +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}\"" diff --git a/scripts/agent-file-locks.py b/scripts/agent-file-locks.py new file mode 100755 index 0000000..0c52c88 --- /dev/null +++ b/scripts/agent-file-locks.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +"""Per-file lock registry for concurrent agent branches. + +Usage examples: + python3 scripts/agent-file-locks.py claim --branch agent/a path/to/file1 path/to/file2 + python3 scripts/agent-file-locks.py claim --branch agent/a --allow-delete path/to/obsolete-file + python3 scripts/agent-file-locks.py allow-delete --branch agent/a path/to/obsolete-file + python3 scripts/agent-file-locks.py validate --branch agent/a --staged + python3 scripts/agent-file-locks.py release --branch agent/a +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +LOCK_FILE_RELATIVE = Path('.omx/state/agent-file-locks.json') +CRITICAL_GUARDRAIL_PATHS = { + 'AGENTS.md', + '.githooks/pre-commit', + 'scripts/agent-branch-start.sh', + 'scripts/agent-branch-finish.sh', + 'scripts/agent-file-locks.py', +} +ALLOW_GUARDRAIL_DELETE_ENV = 'AGENT_ALLOW_GUARDRAIL_DELETE' + + +@dataclass +class LockEntry: + branch: str + claimed_at: str + allow_delete: bool = False + + +class LockError(Exception): + pass + + +def run_git(args: list[str], cwd: Path) -> str: + result = subprocess.run( + ['git', *args], + cwd=str(cwd), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + raise LockError(result.stderr.strip() or f"git {' '.join(args)} failed") + return result.stdout.strip() + + +def resolve_repo_root() -> Path: + output = run_git(['rev-parse', '--show-toplevel'], cwd=Path.cwd()) + return Path(output).resolve() + + +def normalize_repo_path(repo_root: Path, raw_path: str) -> str: + joined = Path(raw_path) + abs_path = joined if joined.is_absolute() else (repo_root / joined) + normalized_abs = Path(os.path.normpath(str(abs_path))) + try: + relative = normalized_abs.relative_to(repo_root) + except ValueError as exc: + raise LockError(f"Path is outside repository: {raw_path}") from exc + return relative.as_posix() + + +def lock_file_path(repo_root: Path) -> Path: + return repo_root / LOCK_FILE_RELATIVE + + +def load_state(repo_root: Path) -> dict[str, Any]: + path = lock_file_path(repo_root) + if not path.exists(): + return {'locks': {}} + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + raise LockError(f'Lock file is invalid JSON: {path}') from exc + + if not isinstance(data, dict): + return {'locks': {}} + locks = data.get('locks', {}) + if not isinstance(locks, dict): + return {'locks': {}} + + # Backward-compat normalization for older lock schema. + normalized_locks: dict[str, dict[str, Any]] = {} + for file_path, entry in locks.items(): + if not isinstance(entry, dict): + continue + branch = str(entry.get('branch', '')) + claimed_at = str(entry.get('claimed_at', '')) + allow_delete = bool(entry.get('allow_delete', False)) + normalized_locks[str(file_path)] = { + 'branch': branch, + 'claimed_at': claimed_at, + 'allow_delete': allow_delete, + } + + return {'locks': normalized_locks} + + +def write_state(repo_root: Path, state: dict[str, Any]) -> None: + path = lock_file_path(repo_root) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + '.tmp') + tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + '\n') + tmp.replace(path) + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def env_truthy(value: str | None) -> bool: + if value is None: + return False + return value.strip().lower() in {'1', 'true', 'yes', 'on'} + + +def staged_changes(repo_root: Path) -> list[tuple[str, str]]: + out = run_git(['diff', '--cached', '--name-status', '--diff-filter=ACMRDTUXB'], cwd=repo_root) + if not out: + return [] + + results: list[tuple[str, str]] = [] + for raw_line in out.splitlines(): + line = raw_line.strip() + if not line: + continue + parts = line.split('\t') + status_token = parts[0] + status = status_token[0] + if status in {'R', 'C'}: + if len(parts) < 3: + continue + path = parts[-1] + else: + if len(parts) < 2: + continue + path = parts[1] + normalized = normalize_repo_path(repo_root, path) + results.append((status, normalized)) + return results + + +def cmd_claim(args: argparse.Namespace, repo_root: Path) -> int: + state = load_state(repo_root) + locks: dict[str, dict[str, Any]] = state['locks'] + + files = [normalize_repo_path(repo_root, p) for p in args.files] + conflicts: list[tuple[str, str]] = [] + + for file_path in files: + existing = locks.get(file_path) + if existing and existing.get('branch') != args.branch: + conflicts.append((file_path, str(existing.get('branch')))) + + if conflicts: + print('[agent-file-locks] Cannot claim files already locked by other branches:', file=sys.stderr) + for file_path, owner_branch in conflicts: + print(f' - {file_path} (locked by {owner_branch})', file=sys.stderr) + return 1 + + for file_path in files: + existing = locks.get(file_path, {}) + existing_allow_delete = bool(existing.get('allow_delete', False)) + locks[file_path] = LockEntry( + branch=args.branch, + claimed_at=now_iso(), + allow_delete=args.allow_delete or existing_allow_delete, + ).__dict__ + + write_state(repo_root, state) + delete_note = ' (delete-approved)' if args.allow_delete else '' + print(f"[agent-file-locks] Claimed {len(files)} file(s) for {args.branch}{delete_note}.") + return 0 + + +def cmd_allow_delete(args: argparse.Namespace, repo_root: Path) -> int: + state = load_state(repo_root) + locks: dict[str, dict[str, Any]] = state['locks'] + files = [normalize_repo_path(repo_root, p) for p in args.files] + + missing: list[str] = [] + foreign: list[tuple[str, str]] = [] + for file_path in files: + entry = locks.get(file_path) + if not entry: + missing.append(file_path) + continue + owner = str(entry.get('branch', '')) + if owner != args.branch: + foreign.append((file_path, owner)) + continue + entry['allow_delete'] = True + + if missing or foreign: + if missing: + print('[agent-file-locks] Cannot enable delete: files are not claimed yet:', file=sys.stderr) + for file_path in missing: + print(f' - {file_path}', file=sys.stderr) + if foreign: + print('[agent-file-locks] Cannot enable delete: files are owned by another branch:', file=sys.stderr) + for file_path, owner in foreign: + print(f' - {file_path} (owner: {owner})', file=sys.stderr) + return 1 + + write_state(repo_root, state) + print(f"[agent-file-locks] Enabled delete approval for {len(files)} file(s) on {args.branch}.") + return 0 + + +def cmd_release(args: argparse.Namespace, repo_root: Path) -> int: + state = load_state(repo_root) + locks: dict[str, dict[str, Any]] = state['locks'] + + to_release: set[str] + if args.files: + requested = {normalize_repo_path(repo_root, p) for p in args.files} + to_release = {p for p in requested if locks.get(p, {}).get('branch') == args.branch} + else: + to_release = {p for p, entry in locks.items() if entry.get('branch') == args.branch} + + for file_path in to_release: + locks.pop(file_path, None) + + write_state(repo_root, state) + print(f"[agent-file-locks] Released {len(to_release)} file(s) for {args.branch}.") + return 0 + + +def cmd_status(args: argparse.Namespace, repo_root: Path) -> int: + state = load_state(repo_root) + locks: dict[str, dict[str, Any]] = state['locks'] + + rows: list[tuple[str, str, str, bool]] = [] + for file_path, entry in sorted(locks.items()): + branch = str(entry.get('branch', '')) + if args.branch and branch != args.branch: + continue + claimed_at = str(entry.get('claimed_at', '')) + allow_delete = bool(entry.get('allow_delete', False)) + rows.append((file_path, branch, claimed_at, allow_delete)) + + if not rows: + print('[agent-file-locks] No active locks.') + return 0 + + print('[agent-file-locks] Active locks:') + for file_path, branch, claimed_at, allow_delete in rows: + delete_flag = ' delete-ok' if allow_delete else '' + print(f' - {file_path} | {branch} | {claimed_at}{delete_flag}') + return 0 + + +def cmd_validate(args: argparse.Namespace, repo_root: Path) -> int: + state = load_state(repo_root) + locks: dict[str, dict[str, Any]] = state['locks'] + + if args.staged: + file_changes = staged_changes(repo_root) + else: + file_changes = [('M', normalize_repo_path(repo_root, p)) for p in args.files] + + file_changes = [ + (status, file_path) + for status, file_path in file_changes + if file_path and file_path != LOCK_FILE_RELATIVE.as_posix() + ] + if not file_changes: + return 0 + + missing: list[str] = [] + foreign: list[tuple[str, str]] = [] + delete_not_allowed: list[str] = [] + guardrail_delete_blocked: list[str] = [] + + allow_guardrail_delete = env_truthy(os.environ.get(ALLOW_GUARDRAIL_DELETE_ENV)) + + for status, file_path in file_changes: + entry = locks.get(file_path) + if not entry: + missing.append(file_path) + continue + + owner = str(entry.get('branch', '')) + if owner != args.branch: + foreign.append((file_path, owner)) + continue + + if status == 'D': + if file_path in CRITICAL_GUARDRAIL_PATHS and not allow_guardrail_delete: + guardrail_delete_blocked.append(file_path) + + allow_delete = bool(entry.get('allow_delete', False)) + if not allow_delete: + delete_not_allowed.append(file_path) + + if not missing and not foreign and not delete_not_allowed and not guardrail_delete_blocked: + return 0 + + print('[agent-file-locks] Commit blocked: staged files must be safely claimed by this branch first.', file=sys.stderr) + if missing: + print(' Unclaimed files:', file=sys.stderr) + for file_path in missing: + print(f' - {file_path}', file=sys.stderr) + if foreign: + print(' Files claimed by another branch:', file=sys.stderr) + for file_path, owner in foreign: + print(f' - {file_path} (owner: {owner})', file=sys.stderr) + if delete_not_allowed: + print(' Delete not approved for claimed files:', file=sys.stderr) + for file_path in delete_not_allowed: + print(f' - {file_path}', file=sys.stderr) + print(' Approve explicit deletions with one of:', file=sys.stderr) + print( + f' python3 scripts/agent-file-locks.py claim --branch "{args.branch}" --allow-delete ', + file=sys.stderr, + ) + print( + f' python3 scripts/agent-file-locks.py allow-delete --branch "{args.branch}" ', + file=sys.stderr, + ) + if guardrail_delete_blocked: + print(' Critical guardrail file deletion blocked:', file=sys.stderr) + for file_path in guardrail_delete_blocked: + print(f' - {file_path}', file=sys.stderr) + print( + f' To intentionally allow this rare operation, set {ALLOW_GUARDRAIL_DELETE_ENV}=1 for the commit command.', + file=sys.stderr, + ) + + print('\nClaim files with:', file=sys.stderr) + print(f' python3 scripts/agent-file-locks.py claim --branch "{args.branch}" ', file=sys.stderr) + return 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description='Concurrent agent file-lock utility') + sub = parser.add_subparsers(dest='command', required=True) + + claim = sub.add_parser('claim', help='Claim file locks for a branch') + claim.add_argument('--branch', required=True, help='Owner branch name (e.g., agent/foo/...)') + claim.add_argument( + '--allow-delete', + action='store_true', + help='Mark these files as explicitly approved for deletion by this branch', + ) + claim.add_argument('files', nargs='+', help='Files to claim (repo-relative or absolute)') + + allow_delete = sub.add_parser('allow-delete', help='Enable delete approval on already claimed files') + allow_delete.add_argument('--branch', required=True, help='Owner branch name') + allow_delete.add_argument('files', nargs='+', help='Files to mark as delete-approved') + + release = sub.add_parser('release', help='Release file locks for a branch') + release.add_argument('--branch', required=True, help='Owner branch name') + release.add_argument('files', nargs='*', help='Optional files; omit to release all branch locks') + + status = sub.add_parser('status', help='Show lock status') + status.add_argument('--branch', help='Filter by branch') + + validate = sub.add_parser('validate', help='Validate staged files are locked by branch') + validate.add_argument('--branch', required=True, help='Owner branch name') + validate.add_argument('--staged', action='store_true', help='Validate staged files from git index') + validate.add_argument('files', nargs='*', help='Files to validate when --staged is not used') + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + try: + repo_root = resolve_repo_root() + if args.command == 'claim': + return cmd_claim(args, repo_root) + if args.command == 'allow-delete': + return cmd_allow_delete(args, repo_root) + if args.command == 'release': + return cmd_release(args, repo_root) + if args.command == 'status': + return cmd_status(args, repo_root) + if args.command == 'validate': + if not args.staged and not args.files: + raise LockError('validate requires --staged or one or more file paths') + return cmd_validate(args, repo_root) + raise LockError(f'Unknown command: {args.command}') + except LockError as exc: + print(f'[agent-file-locks] {exc}', file=sys.stderr) + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/agent-worktree-prune.sh b/scripts/agent-worktree-prune.sh new file mode 100755 index 0000000..170664a --- /dev/null +++ b/scripts/agent-worktree-prune.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_BRANCH="dev" +DRY_RUN=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) + BASE_BRANCH="${2:-dev}" + shift 2 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + *) + echo "[agent-worktree-prune] Unknown argument: $1" >&2 + echo "Usage: $0 [--base ] [--dry-run]" >&2 + exit 1 + ;; + esac +done + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[agent-worktree-prune] Not inside a git repository." >&2 + exit 1 +fi + +repo_root="$(git rev-parse --show-toplevel)" +current_pwd="$(pwd -P)" +worktree_root="${repo_root}/.omx/agent-worktrees" + +if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then + echo "[agent-worktree-prune] Base branch not found: ${BASE_BRANCH}" >&2 + exit 1 +fi + +run_cmd() { + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[agent-worktree-prune] [dry-run] $*" + return 0 + fi + "$@" +} + +branch_has_worktree() { + local branch="$1" + git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$" +} + +removed_worktrees=0 +removed_branches=0 +skipped_active=0 + +process_entry() { + local wt="$1" + local branch_ref="$2" + + [[ -z "$wt" ]] && return + [[ "$wt" != "${worktree_root}"/* ]] && return + + local branch="" + if [[ -n "$branch_ref" ]]; then + branch="${branch_ref#refs/heads/}" + fi + + if [[ "$wt" == "$current_pwd" ]]; then + skipped_active=$((skipped_active + 1)) + echo "[agent-worktree-prune] Skipping active cwd worktree: ${wt}" + return + fi + + local remove_reason="" + + if [[ -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 + if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then + remove_reason="merged-agent-branch" + fi + elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then + remove_reason="temporary-worktree" + fi + + if [[ -z "$remove_reason" ]]; then + return + 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 [[ -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/* ]]; 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 merged branch: ${branch}" + fi + elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; 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}" + fi + fi +} + +current_wt="" +current_branch_ref="" + +while IFS= read -r line || [[ -n "$line" ]]; do + if [[ -z "$line" ]]; then + process_entry "$current_wt" "$current_branch_ref" + current_wt="" + current_branch_ref="" + continue + fi + + case "$line" in + worktree\ *) + current_wt="${line#worktree }" + ;; + branch\ *) + current_branch_ref="${line#branch }" + ;; + esac +done < <(git -C "$repo_root" worktree list --porcelain) + +process_entry "$current_wt" "$current_branch_ref" + +while IFS= read -r branch; do + [[ -z "$branch" ]] && continue + if branch_has_worktree "$branch"; then + continue + fi + if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; 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 merged branch: ${branch}" + fi + fi +done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent) + +run_cmd git -C "$repo_root" worktree prune + +echo "[agent-worktree-prune] Summary: removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}" +if [[ "$skipped_active" -gt 0 ]]; then + echo "[agent-worktree-prune] Tip: leave active agent worktree directories, then run this command again for full cleanup." >&2 +fi diff --git a/scripts/install-agent-git-hooks.sh b/scripts/install-agent-git-hooks.sh new file mode 100755 index 0000000..d3257af --- /dev/null +++ b/scripts/install-agent-git-hooks.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "$repo_root" ]]; then + echo "[install-agent-git-hooks] Not inside a git repository." >&2 + exit 1 +fi + +hooks_dir="$repo_root/.githooks" +if [[ ! -d "$hooks_dir" ]]; then + echo "[install-agent-git-hooks] Missing hooks directory: $hooks_dir" >&2 + exit 1 +fi + +chmod +x "$hooks_dir"/* 2>/dev/null || true + +git -C "$repo_root" config core.hooksPath .githooks + +echo "[install-agent-git-hooks] Installed repo hooks path: .githooks" +echo "[install-agent-git-hooks] Branch protection hook is now active for this repo clone." diff --git a/scripts/openspec/init-plan-workspace.sh b/scripts/openspec/init-plan-workspace.sh new file mode 100755 index 0000000..9a6487a --- /dev/null +++ b/scripts/openspec/init-plan-workspace.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 [role ...]" + echo "Example: $0 stabilize-dashboard planner architect critic executor writer verifier" + exit 1 +fi + +PLAN_SLUG="$1" +shift || true + +if [[ "$PLAN_SLUG" =~ [^a-z0-9-] ]]; then + echo "Error: plan slug must be kebab-case (lowercase letters, numbers, hyphens)." >&2 + exit 1 +fi + +if [[ $# -gt 0 ]]; then + ROLES=("$@") +else + ROLES=(planner architect critic executor writer verifier) +fi + +PLAN_DIR="openspec/plan/${PLAN_SLUG}" +mkdir -p "$PLAN_DIR" + +write_if_missing() { + local file="$1" + shift + if [[ ! -f "$file" ]]; then + mkdir -p "$(dirname "$file")" + cat > "$file" < +\`\`\` +" + +write_if_missing "$PLAN_DIR/planner/plan.md" "# ExecPlan: ${PLAN_SLUG} + +This document is a living plan. Keep progress and decisions current. + +## Purpose / Big Picture + +## Progress + +- [ ] Initial draft +- [ ] Review + iterate +- [ ] Approved for execution + +## Surprises & Discoveries + +## Decision Log + +## Outcomes & Retrospective + +## Validation and Acceptance +" + +for role in "${ROLES[@]}"; do + ROLE_DIR="$PLAN_DIR/$role" + mkdir -p "$ROLE_DIR" + + write_if_missing "$ROLE_DIR/README.md" "# ${role} + +Role workspace for \`${role}\`. +" + + write_if_missing "$ROLE_DIR/tasks.md" "# ${role} tasks + +## 1. Spec + +- [ ] Define requirements and scope for ${role} +- [ ] Confirm acceptance criteria are explicit and testable + +## 2. Tests + +- [ ] Define verification approach and evidence requirements +- [ ] List concrete commands for verification + +## 3. Implementation + +- [ ] Execute role-specific deliverables +- [ ] Capture decisions, risks, and handoff notes + +## 4. Checkpoints + +- [ ] Publish checkpoint update for this role +" +done + +echo "[musafety] OpenSpec plan workspace ready: ${PLAN_DIR}" +echo "[musafety] Roles: ${ROLES[*]}" diff --git a/templates/scripts/codex-agent.sh b/templates/scripts/codex-agent.sh new file mode 100755 index 0000000..fb340d7 --- /dev/null +++ b/templates/scripts/codex-agent.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +TASK_NAME="${1:-task}" +AGENT_NAME="${2:-agent}" +BASE_BRANCH="${3:-dev}" +CODEX_BIN="${MUSAFETY_CODEX_BIN:-codex}" + +if [[ $# -ge 1 ]]; then shift; fi +if [[ $# -ge 1 ]]; then shift; fi +if [[ $# -ge 1 ]]; then shift; fi + +if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then + echo "[codex-agent] Missing Codex CLI command: $CODEX_BIN" >&2 + echo "[codex-agent] Install Codex first, then retry." >&2 + exit 127 +fi + +if [[ ! -x "scripts/agent-branch-start.sh" ]]; then + echo "[codex-agent] Missing scripts/agent-branch-start.sh. Run: musafety setup" >&2 + exit 1 +fi + +start_output="$(bash scripts/agent-branch-start.sh "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH")" +printf '%s\n' "$start_output" + +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 [[ ! -d "$worktree_path" ]]; then + echo "[codex-agent] Reported worktree path does not exist: $worktree_path" >&2 + exit 1 +fi + +echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path" +cd "$worktree_path" +exec "$CODEX_BIN" "$@" diff --git a/test/install.test.js b/test/install.test.js index cb94abd..e3a3d41 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -154,6 +154,7 @@ test('setup provisions workflow files and repo config', () => { const requiredFiles = [ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', + 'scripts/codex-agent.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', 'scripts/install-agent-git-hooks.sh', @@ -171,6 +172,7 @@ test('setup provisions workflow files and repo config', () => { } const packageJson = JSON.parse(fs.readFileSync(path.join(repoDir, 'package.json'), 'utf8')); + assert.equal(packageJson.scripts['agent:codex'], 'bash ./scripts/codex-agent.sh'); assert.equal(packageJson.scripts['agent:branch:start'], 'bash ./scripts/agent-branch-start.sh'); assert.equal(packageJson.scripts['agent:plan:init'], 'bash ./scripts/openspec/init-plan-workspace.sh'); assert.equal(packageJson.scripts['agent:protect:list'], 'musafety protect list'); @@ -185,8 +187,10 @@ test('setup provisions workflow files and repo config', () => { const gitignoreContent = fs.readFileSync(path.join(repoDir, '.gitignore'), 'utf8'); assert.match(gitignoreContent, /# multiagent-safety:START/); assert.match(gitignoreContent, /scripts\/agent-branch-start\.sh/); + assert.match(gitignoreContent, /scripts\/codex-agent\.sh/); assert.match(gitignoreContent, /scripts\/agent-file-locks\.py/); assert.match(gitignoreContent, /\.githooks\/pre-commit/); + assert.match(gitignoreContent, /oh-my-codex\//); assert.match(gitignoreContent, /\.codex\/skills\/musafety\/SKILL\.md/); assert.match(gitignoreContent, /\.claude\/commands\/musafety\.md/); assert.match(gitignoreContent, /\.omx\/state\/agent-file-locks\.json/); @@ -378,6 +382,53 @@ test('pre-commit blocks protected branch commits even from VS Code Source Contro assert.match(hookResult.stderr, /Direct commits on protected branches are blocked/); }); +test('codex-agent launches codex inside a fresh sandbox worktree', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-codex-')); + const fakeCodexPath = path.join(fakeBin, 'codex'); + fs.writeFileSync( + fakeCodexPath, + `#!/usr/bin/env bash\n` + + `pwd > "${'${MUSAFETY_TEST_CODEX_CWD}'}"\n` + + `echo "$@" > "${'${MUSAFETY_TEST_CODEX_ARGS}'}"\n`, + 'utf8', + ); + fs.chmodSync(fakeCodexPath, 0o755); + + const cwdMarker = path.join(repoDir, '.codex-agent-cwd'); + const argsMarker = path.join(repoDir, '.codex-agent-args'); + const launch = runCmd( + 'bash', + ['scripts/codex-agent.sh', 'launch-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], + repoDir, + { + PATH: `${fakeBin}:${process.env.PATH}`, + MUSAFETY_TEST_CODEX_CWD: cwdMarker, + MUSAFETY_TEST_CODEX_ARGS: argsMarker, + }, + ); + assert.equal(launch.status, 0, launch.stderr || launch.stdout); + assert.match(launch.stdout, /\[codex-agent\] Launching codex in sandbox:/); + + const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); + assert.match( + launchedCwd, + new RegExp(`${escapeRegexLiteral(repoDir)}/\\.omx/agent-worktrees/agent__planner__`), + ); + + const launchedArgs = fs.readFileSync(argsMarker, 'utf8').trim(); + assert.match(launchedArgs, /--model gpt-5\.4-mini/); + + const branchResult = runCmd('git', ['-C', launchedCwd, 'branch', '--show-current'], repoDir); + assert.equal(branchResult.status, 0, branchResult.stderr || branchResult.stdout); + assert.match(branchResult.stdout.trim(), /^agent\/planner\//); +}); + test('sync command rebases current agent branch onto latest origin/dev', () => { const repoDir = initRepo(); seedCommit(repoDir);