Merge pull request #566 from AIOSAI/dev

feat: DPLAN-0173 Phase 2 — dev branch workflow complete (handlers, sync, prompts, docs)
This commit is contained in:
AIPass
2026-05-12 21:54:05 -07:00
committed by GitHub
12 changed files with 173 additions and 83 deletions
+8 -6
View File
@@ -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 <PR#> # devpulse only, on user request\n"
' drone @git dev-pr "description" # PR dev to main\n'
" drone @git merge <PR#> # 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."
)
+2 -2
View File
@@ -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.
@@ -83,11 +83,11 @@ drone @memory search <query> # 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
```
@@ -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 <PR#> # Merge a PR (devpulse only, user must request)
drone @git delete-branch <name> # 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)
@@ -1 +0,0 @@
dev branch workflow test - Tue May 12 09:06:19 PM PDT 2026
+19 -22
View File
@@ -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 <PR#> # Merge a PR and sync local main
drone @git delete-branch <name> # 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 <PR#> # 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
---
@@ -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 {
@@ -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,
@@ -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})
@@ -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}
+31 -22
View File
@@ -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 <description> — Push dev branch and create PR to main [owner]\n"
" Description becomes the PR title.\n"
)
if command == "delete-branch":
return (
"git delete-branch <name> — Delete a remote branch [owner]\n"
" Protected: main and dev cannot be deleted.\n"
)
if command == "commit":
return (
"git commit <message> [--all] — Commit staged changes [owner]\n"
"git commit <message> [--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 <main|dev> — 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 <msg> [--all] Commit staged changes\n"
" commit <msg> [--all] Commit changes (--all stages entire repo)\n"
" checkout <main|dev> Switch branches\n"
" sync [--autostash] Checkout main and pull\n"
" unlock --force Force-release the PR lock\n"
" system-pr <desc> Create a system-wide PR\n"
" dev-pr <desc> Push dev and create PR to main\n"
" delete-branch <name> Delete a remote branch\n"
" merge <PR#> 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 <desc> 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 <cmd> [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"
)
@@ -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(