diff --git a/.claude/hooks/git_gate.py b/.claude/hooks/git_gate.py index 2e8ae9b4..f9d856ce 100755 --- a/.claude/hooks/git_gate.py +++ b/.claude/hooks/git_gate.py @@ -75,18 +75,20 @@ TRUSTED_HOOK_EDITORS = ("devpulse", "seedgo") GIT_REDIRECT = ( "Raw git write commands are blocked. Use drone instead:\n" - ' drone @git pr "description" # branch-scoped PR\n' - ' drone @git system-pr "description" # devpulse-only system PR\n' - " drone @git smart-sync # fetch + rebase\n" - " drone @git sync # checkout main + pull\n" " drone @git status # what changed\n" + " drone @git diff # see changes\n" + " drone @git log # commit history\n" + " drone @git commit 'msg' --all # commit all changes (devpulse only)\n" + ' drone @git dev-pr "description" # PR dev to main (devpulse only)\n' + " drone @git smart-sync # fetch + rebase\n" "Read-only git (status, log, diff, show, fetch, ls-files) is allowed." ) GH_REDIRECT = ( "Raw gh write commands are blocked. Use drone for git ops:\n" - ' drone @git pr "description"\n' - " drone @git merge # devpulse only, on user request\n" + ' drone @git dev-pr "description" # PR dev to main\n' + " drone @git merge # merge a PR (devpulse only)\n" + " drone @git issue list/create/view # gh issue passthrough\n" "Read-only gh (list, view, status, diff, checks, comments) is allowed." ) diff --git a/README.md b/README.md index ff108a2f..03f6e3a4 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity - **Everything is local.** Your data stays on your machine. Memory is JSON files. Communication is local mailbox files. No cloud dependencies, no external APIs for core operations. - **One pattern for everything.** Every agent follows the same structure. One command (`drone @branch command`) reaches any agent. Learn it once, use it everywhere. - **Projects are isolated by design.** Each project gets its own registry. Agents communicate within their project, not across projects. -- **The system protects itself.** Agent locks prevent double-dispatch. PR locks prevent merge conflicts. Branches don't touch each other's files. Quality standards are embedded in every workflow. Errors trigger self-healing. +- **The system protects itself.** Agent locks prevent double-dispatch. Git access is tier-controlled through drone. Branches don't touch each other's files. Quality standards are embedded in every workflow. Errors trigger self-healing. **Say "hi" tomorrow and pick up exactly where you left off.** One agent or fifteen — the memory persists. @@ -241,7 +241,7 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each. | Agents | 12 core + user-created | | Quality standards | 34 automated checks | | Tests | 7,600+ (across all agents) | -| PRs merged | 538+ (created by agents, reviewed by human) | +| PRs merged | 560+ (human-AI collaboration) | Each agent documents its own operational status in its branch README — what works, what doesn't, and why. diff --git a/src/aipass/aipass/status/.aipass/aipass_global_prompt.md b/src/aipass/aipass/status/.aipass/aipass_global_prompt.md index 37313a03..e65fe52a 100644 --- a/src/aipass/aipass/status/.aipass/aipass_global_prompt.md +++ b/src/aipass/aipass/status/.aipass/aipass_global_prompt.md @@ -83,11 +83,11 @@ drone @memory search # Search archived memories ### Git Workflow ``` -drone @git pr 'description' # Create a pull request drone @git status # Git status (branch-scoped) -drone @git sync # Sync with main -drone @git lock / unlock # Lock/unlock the repo +drone @git diff # See changes +drone @git log # Commit history ``` +You have no git write access. Devpulse handles all commits and PRs. ### Infrastructure ``` diff --git a/src/aipass/devpulse/.aipass/aipass_local_prompt.md b/src/aipass/devpulse/.aipass/aipass_local_prompt.md index b81b91d4..d51f463e 100644 --- a/src/aipass/devpulse/.aipass/aipass_local_prompt.md +++ b/src/aipass/devpulse/.aipass/aipass_local_prompt.md @@ -48,11 +48,13 @@ When a task belongs to a specialist's DOMAIN, ask them. You can still investigat drone @git status # What changed? (all branches can use) drone @git diff # See the diff (all branches can use) drone @git log # Recent commits (all branches can use) -drone @git commit "description" # Commit changes (devpulse only) +drone @git branches # List remote branches (all branches can use) +drone @git commit "msg" --all # Commit all changes (devpulse only) drone @git checkout dev # Switch to dev branch (devpulse only) drone @git checkout main # Switch to main (devpulse only) -drone @git system-pr "description" # System-wide PR (devpulse only) +drone @git dev-pr "description" # PR dev to main (devpulse only) drone @git merge # Merge a PR (devpulse only, user must request) +drone @git delete-branch # Delete remote branch (devpulse only) drone @git sync # Pull latest (devpulse only) drone @git smart-sync # Fetch + rebase (devpulse only) drone @git fix # Fix broken git states (devpulse only) diff --git a/src/aipass/devpulse/dev_workflow_test.txt b/src/aipass/devpulse/dev_workflow_test.txt deleted file mode 100644 index 18a7a0a4..00000000 --- a/src/aipass/devpulse/dev_workflow_test.txt +++ /dev/null @@ -1 +0,0 @@ -dev branch workflow test - Tue May 12 09:06:19 PM PDT 2026 diff --git a/src/aipass/drone/README.md b/src/aipass/drone/README.md index 85e6d12f..4033f53a 100644 --- a/src/aipass/drone/README.md +++ b/src/aipass/drone/README.md @@ -47,20 +47,21 @@ drone @git workflow list # Passthrough to gh workflow list # Git workflow — owner tier (devpulse only) drone @git commit "message" # Commit staged changes -drone @git commit "msg" --all # Stage tracked files and commit +drone @git commit "msg" --all # Stage ALL repo changes and commit +drone @git checkout dev # Switch to dev branch drone @git checkout main # Switch to main branch -drone @git sync # Checkout main and pull +drone @git dev-pr "desc" # Push dev and create PR to main +drone @git merge # Merge a PR and sync local main +drone @git delete-branch # Delete a remote branch (not main/dev) +drone @git branches # List remote branches +drone @git sync # Pull latest (branch-aware: main or dev) drone @git sync --autostash # Sync with autostash for dirty trees -drone @git unlock --force # Force-release the PR lock -drone @git system-pr "desc" # System-wide PR across all tracked changes -drone @git merge # Straight-merge a PR and sync local main drone @git smart-sync # Fetch + detect divergence + rebase +drone @git unlock --force # Force-release the PR lock +drone @git system-pr "desc" # Legacy system-wide PR (use dev-pr) drone @git fix # Auto-fix stuck rebase / detached HEAD drone @git fix --dry-run # Detect issues without fixing -# Git workflow — deprecated -drone @git pr # DEPRECATED — returns error message - # Command discovery drone scan @branch # Discover available commands in a branch drone activate @branch # Scan + register all commands as shortcuts @@ -206,30 +207,26 @@ External modules are declared in `apps/handlers/routing_config.json` with entry ### Git Access Tiers -Auth centralized via `verify_git_access()` in `apps/handlers/git/auth.py`. Two tiers: +Auth centralized via `verify_git_access()` in `apps/plugins/devpulse_ops/auth.py`. Two tiers: | Tier | Who | Commands | |------|-----|----------| -| **Global** | All branches | `status`, `diff`, `log`, `lock` | -| **Owner** | `devpulse` only | `commit`, `checkout`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` | +| **Global** | All branches | `status`, `diff`, `log`, `lock`, `branches`, `issue`, `run`, `workflow` | +| **Owner** | `devpulse` only | `commit`, `checkout`, `dev-pr`, `delete-branch`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` | -- `pr` is **deprecated** — returns an error message directing to devpulse - Auth is checked once at the top of `git_module.handle_command()` before any handler is called - Unauthorized commands return a clear "Access denied" message with the caller's tier -### Git Main-Only Enforcement +### Dev Branch Model -All agents work on `main`. Branch creation is only allowed inside `drone @git system-pr`, which: -1. Commits changes on main -2. Moves branch pointer with `git branch -f` (HEAD stays on main) -3. Pushes branch with `--force-with-lease` -4. Opens PR via `gh` -5. Returns to main +All work happens on `dev`. Only devpulse has write access. Agents build and report; devpulse commits. + +**Flow:** work on dev → stack changes → `drone @git dev-pr "desc"` → merge PR → `drone @git sync` (realigns dev from main) Enforcement layers: -- `.claude/settings.json` deny rules block `git checkout -b`, `git switch -c` -- `_assert_on_main_or_pr_flow()` guard in `git_module.py` -- Persistent citizen branches: `citizen/{name}` reused across PRs +- `git_gate.py` PreToolUse hook blocks ALL raw git/gh commands +- Drone tier system restricts write commands to devpulse only +- Prompt instructions tell agents they have zero git access --- diff --git a/src/aipass/drone/apps/handlers/git/checkout_handler.py b/src/aipass/drone/apps/handlers/git/checkout_handler.py index cc0e94f4..061bd4ae 100644 --- a/src/aipass/drone/apps/handlers/git/checkout_handler.py +++ b/src/aipass/drone/apps/handlers/git/checkout_handler.py @@ -62,6 +62,13 @@ def checkout_branch(target: str) -> dict: text=True, cwd=str(repo_root), ) + if result.returncode != 0 and "did not match" in result.stderr: + result = subprocess.run( + ["git", "checkout", "-b", target], + capture_output=True, + text=True, + cwd=str(repo_root), + ) except (OSError, subprocess.SubprocessError) as exc: logger.error("git checkout failed: %s", exc) return { diff --git a/src/aipass/drone/apps/handlers/git/commit_handler.py b/src/aipass/drone/apps/handlers/git/commit_handler.py index 9dd132e5..2e6dec6b 100644 --- a/src/aipass/drone/apps/handlers/git/commit_handler.py +++ b/src/aipass/drone/apps/handlers/git/commit_handler.py @@ -58,13 +58,23 @@ def commit_changes( branch_dir: Path | None = None, all_files: bool = False, ) -> dict: - """Commit staged changes or all changes under branch_dir.""" + """Commit changes. With --all, stages the entire repo (not CWD-scoped). + + Post-DPLAN-0173: only devpulse commits, agents don't PR. Repo-wide + staging is the correct default since dispatched agents work across + multiple branch directories. + """ repo_root = find_repo_root() - if all_files and branch_dir: - stage_result = stage_branch_dir(branch_dir, repo_root) - if not stage_result["success"]: - return {"stdout": "", "stderr": stage_result["message"], "exit_code": 1} + if all_files: + add_result = subprocess.run( + ["git", "add", "-A"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + if add_result.returncode != 0: + return {"stdout": "", "stderr": f"Failed to stage: {add_result.stderr.strip()}", "exit_code": 1} diff_check = subprocess.run( ["git", "diff", "--cached", "--quiet"], @@ -81,13 +91,6 @@ def commit_changes( try: cmd = ["git", "commit", "-m", message] - if branch_dir: - try: - rel_dir = branch_dir.resolve().relative_to(repo_root.resolve()) - except ValueError as exc: - logger.warning("commit_changes: branch_dir not relative to repo root: %s", exc) - rel_dir = branch_dir - cmd.extend(["--", str(rel_dir) + "/"]) result = subprocess.run( cmd, diff --git a/src/aipass/drone/apps/handlers/git/dev_pr_handler.py b/src/aipass/drone/apps/handlers/git/dev_pr_handler.py index 9044293c..3b5b6023 100644 --- a/src/aipass/drone/apps/handlers/git/dev_pr_handler.py +++ b/src/aipass/drone/apps/handlers/git/dev_pr_handler.py @@ -75,7 +75,17 @@ def create_dev_pr(description: str) -> dict: return {"success": False, "message": f"PR creation failed: {exc}", "pr_url": ""} if pr.returncode != 0: - return {"success": False, "message": f"PR creation failed: {pr.stderr.strip()}", "pr_url": ""} + stderr = pr.stderr.strip() + if "already exists" in stderr: + existing_url = "" + for line in stderr.splitlines(): + if "github.com" in line: + existing_url = line.strip() + break + msg = f"Pushed to dev. PR already open: {existing_url}" if existing_url else "Pushed to dev. PR already open." + json_handler.log_operation("dev_pr_push_existing", {"pr_url": existing_url, "description": description}) + return {"success": True, "message": msg, "pr_url": existing_url} + return {"success": False, "message": f"PR creation failed: {stderr}", "pr_url": ""} pr_url = pr.stdout.strip() json_handler.log_operation("create_dev_pr", {"pr_url": pr_url, "description": description}) diff --git a/src/aipass/drone/apps/handlers/git/sync_handler.py b/src/aipass/drone/apps/handlers/git/sync_handler.py index 5cc85af9..36b2dd80 100644 --- a/src/aipass/drone/apps/handlers/git/sync_handler.py +++ b/src/aipass/drone/apps/handlers/git/sync_handler.py @@ -7,10 +7,11 @@ # ============================================= """ -Safe main branch synchronization. +Branch synchronization — works on both main and dev. -Checks out main and pulls latest, with error handling for dirty -working trees and other common failure modes. +On main: pulls latest from origin/main. +On dev: pulls origin/main into dev (realigns after PR merge). +From other branch: checks out main first, then pulls. """ from __future__ import annotations @@ -36,16 +37,28 @@ def sync_main(autostash: bool = False) -> dict: stashed = False try: - checkout = subprocess.run( - ["git", "checkout", "main"], + head = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, cwd=str(repo_root), ) - if checkout.returncode != 0: - msg = f"Failed to checkout main: {checkout.stderr.strip()}" - logger.error(msg) - return {"success": False, "message": msg, "stdout": checkout.stdout} + current_branch = head.stdout.strip() if head.returncode == 0 else "" + + if current_branch == "dev": + return _sync_dev(repo_root, autostash) + + if current_branch != "main": + checkout = subprocess.run( + ["git", "checkout", "main"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + if checkout.returncode != 0: + msg = f"Failed to checkout main: {checkout.stderr.strip()}" + logger.error(msg) + return {"success": False, "message": msg, "stdout": checkout.stdout} if autostash: stash = subprocess.run( @@ -158,3 +171,51 @@ def sync_main(autostash: bool = False) -> dict: msg = f"Sync failed: {exc}" logger.error(msg) return {"success": False, "message": msg, "stdout": ""} + + +def _sync_dev(repo_root, autostash: bool = False) -> dict: + """Pull origin/main into dev branch to realign after PR merge.""" + stashed = False + + if autostash: + stash = subprocess.run( + ["git", "stash"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + stashed = "No local changes to save" not in stash.stdout + + fetch = subprocess.run( + ["git", "fetch", "origin", "--prune"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + if fetch.returncode != 0: + if stashed: + subprocess.run(["git", "stash", "pop"], capture_output=True, text=True, cwd=str(repo_root)) + return {"success": False, "message": f"Fetch failed: {fetch.stderr.strip()}", "stdout": ""} + + result = subprocess.run( + ["git", "pull", "origin", "main", "--rebase"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + + if stashed: + subprocess.run(["git", "stash", "pop"], capture_output=True, text=True, cwd=str(repo_root)) + + if result.returncode != 0: + raw = result.stderr.strip() + msg = f"Failed to sync dev from main: {raw}" + if not autostash and ("unstaged changes" in raw or "uncommitted changes" in raw): + msg += "\n Tip: retry with 'drone @git sync --autostash'" + return {"success": False, "message": msg, "stdout": result.stdout} + + stdout = result.stdout.strip() + msg = f"Synced dev from origin/main: {stdout}" + json_handler.log_operation("sync_dev", {"result": stdout, "autostash": autostash}) + logger.info(msg) + return {"success": True, "message": msg, "stdout": stdout} diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index aa05ec71..492e3f4d 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -462,13 +462,7 @@ def _handle_commit(args: list[str]) -> dict: "exit_code": 1, } - branch_dir = None - if all_files: - detected = _detect_branch_dir() - if detected: - _, branch_dir = detected - - return commit_handler.commit_changes(message, branch_dir=branch_dir, all_files=all_files) + return commit_handler.commit_changes(message, all_files=all_files) def _handle_checkout(args: list[str]) -> dict: @@ -567,11 +561,23 @@ def get_help(command: str | None = None) -> str: return "git log [count] — Show recent git log entries (default: 10) [global]\n" if command == "lock": return "git lock — Check current lock status [global]\n Shows lock holder, age, stale/orphan detection.\n" + if command == "branches": + return "git branches — List all remote branches [global]\n" + if command == "dev-pr": + return ( + "git dev-pr — Push dev branch and create PR to main [owner]\n" + " Description becomes the PR title.\n" + ) + if command == "delete-branch": + return ( + "git delete-branch — Delete a remote branch [owner]\n" + " Protected: main and dev cannot be deleted.\n" + ) if command == "commit": return ( - "git commit [--all] — Commit staged changes [owner]\n" + "git commit [--all] — Commit changes [owner]\n" " Options:\n" - " --all Stage all changes under your branch directory first.\n" + " --all Stage all repo changes (git add -A) before committing.\n" ) if command == "checkout": return "git checkout — Switch branches (main or dev only) [owner]\n" @@ -610,36 +616,36 @@ def get_help(command: str | None = None) -> str: ) return ( - "git — Tier-based git workflow\n" + "git — Tier-based git workflow (dev branch model)\n" "\n" "Global (all branches):\n" " status Show git status for your branch\n" " diff [--staged] Show git diff for your branch\n" " log [count] Show recent git log (default: 10)\n" " lock Check lock status\n" + " branches List remote branches\n" " issue [args] Passthrough to gh issue\n" " run [args] Passthrough to gh run\n" " workflow [args] Passthrough to gh workflow\n" "\n" "Owner (devpulse only):\n" - " commit [--all] Commit staged changes\n" + " commit [--all] Commit changes (--all stages entire repo)\n" " checkout Switch branches\n" - " sync [--autostash] Checkout main and pull\n" - " unlock --force Force-release the PR lock\n" - " system-pr Create a system-wide PR\n" + " dev-pr Push dev and create PR to main\n" + " delete-branch Delete a remote branch\n" " merge Merge a PR\n" + " sync [--autostash] Checkout main and pull\n" " smart-sync Fetch + rebase if behind\n" + " unlock --force Force-release the PR lock\n" + " system-pr Legacy system-wide PR (use dev-pr)\n" " fix [--dry-run] Fix broken git states\n" - "\n" - "Deprecated:\n" - " pr Agent PRs removed — devpulse handles git\n" ) def get_introspective() -> str: """Return introspection text showing connected handlers.""" return ( - "@git — Tier-based git workflow (v2.0.0)\n" + "@git — Tier-based git workflow, dev branch model (v3.0.0)\n" "\n" "Connected Handlers:\n" " handlers/git/\n" @@ -647,14 +653,17 @@ def get_introspective() -> str: " - status_handler.py (get_branch_status — scoped git status)\n" " - diff_handler.py (get_branch_diff — scoped git diff)\n" " - log_handler.py (get_git_log — recent log entries)\n" - " - commit_handler.py (commit_changes, stage_branch_dir)\n" + " - commit_handler.py (commit_changes — repo-wide staging with --all)\n" " - checkout_handler.py (checkout_branch — main/dev only)\n" " - sync_handler.py (sync_main — safe main synchronization)\n" - " - pr_handler.py (create_pr — DEPRECATED)\n" + " - dev_pr_handler.py (create_dev_pr — push dev, PR to main)\n" + " - branches_handler.py (list_remote_branches)\n" + " - delete_branch_handler.py (delete_remote_branch — protected: main/dev)\n" + " - pr_handler.py (create_pr — DEPRECATED, kept for reference)\n" "\n" " plugins/devpulse_ops/\n" " - auth.py (verify_git_access — tier-based authorization)\n" - " - pr_plugin.py (create_system_pr — system-wide PR workflow)\n" + " - pr_plugin.py (create_system_pr — legacy, use dev-pr instead)\n" " - merge_plugin.py (merge_pr — merge PR + sync)\n" " - sync_plugin.py (smart_sync — fetch + rebase if behind)\n" " - fix_plugin.py (fix_git_state — detect/fix broken states)\n" @@ -662,7 +671,7 @@ def get_introspective() -> str: " gh passthrough:\n" " - issue, run, workflow → subprocess gh [args]\n" "\n" - "Access Tiers: global (status, diff, log, lock, issue, run, workflow) | owner (commit, checkout, sync, unlock, system-pr, merge, smart-sync, fix)\n" + "Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (commit, checkout, dev-pr, delete-branch, sync, unlock, system-pr, merge, smart-sync, fix)\n" ) diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py index e15d577a..d87d5db8 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py @@ -86,8 +86,8 @@ def _find_caller() -> str: def verify_caller() -> str: """Verify the calling branch is authorized for devpulse operations. - system-pr, merge, smart-sync, fix are restricted to ALLOWED_CALLERS. - Other branches use drone @git pr for their own branch-scoped PRs. + Owner-tier commands (commit, dev-pr, merge, etc.) are restricted to + ALLOWED_CALLERS. Other branches have read-only access via global tier. Returns: The caller's branch name if authorized. @@ -97,7 +97,7 @@ def verify_caller() -> str: """ name = _find_caller() if name not in ALLOWED_CALLERS: - msg = f"Branch '{name}' is not authorized for this operation. Use 'drone @git pr' for branch-scoped PRs." + msg = f"Branch '{name}' is not authorized for this operation. Only devpulse can use owner-tier commands." logger.error(msg) raise PermissionError(msg) json_handler.log_operation(