Merge pull request #382 from AIOSAI/system/devpulse-docs-lowercase-shouting-imperatives-in-localglobal

feat(system): docs: lowercase shouting imperatives in local+global prompts (volume isn't the fix)
This commit is contained in:
AIPass
2026-04-22 09:38:16 -07:00
committed by GitHub
13 changed files with 352 additions and 273 deletions
+6 -2
View File
@@ -57,7 +57,11 @@ Workflow:
2. Make edits directly on main.
3. When the work is ready to ship: `drone @git system-pr "description"`.
4. That command commits + branches + pushes + PRs + returns you to main. One action.
5. Devpulse reviews + merges with `drone @git merge <PR#>`.
5. STOP. The user merges. Do not run `drone @git merge` unless the user explicitly tells you to merge a specific PR number in this session.
Never merge. Ever. User-merges-only. Past PRs, your own PRs, closed PRs — none of them auto-qualify. You fix, you PR, you stop.
Local files are source of truth. When you edit a file, the state on disk IS reality — you don't wait for a merge to act on what you see locally. This also means: if the truth is wrong, fix it locally, then PR.
Why this matters: the AIPass repo has ONE shared HEAD across all branches. If any agent lingers on a non-main HEAD, every other agent's next edit lands on the wrong branch. Files get stranded. Work gets lost. Conflicts pile up. We've lived this pain — don't repeat it.
@@ -175,7 +179,7 @@ Archive commands:
# Git Workflow
**Drone is the ONLY git interface. Period.** All PR workflow goes through drone. Never use raw git commands for commits, branches, pushes, resets, merges, rebases, cherry-picks, or remote branch manipulation. Drone handles everything atomically with a lockfile that prevents concurrent PR collisions.
**Drone is the only git interface. Period.** All PR workflow goes through drone. Never use raw git commands for commits, branches, pushes, resets, merges, rebases, cherry-picks, or remote branch manipulation. Drone handles everything atomically with a lockfile that prevents concurrent PR collisions.
**If you think you need a raw git command to fix a git problem, STOP. You don't.** Every git state devpulse has ever been in has been recoverable through `drone @git` commands — system-pr, merge, smart-sync, fix, status, sync, lock. There is no situation that requires `git reset`, `git push`, `git cherry-pick`, `git rebase`, or `git branch -f`. Reaching for them has always made things worse. If drone's commands don't obviously handle the state you're in, run `drone @git fix` or `drone @git smart-sync` and re-evaluate. If still stuck, ASK THE USER — do not improvise with raw git.
+3 -1
View File
@@ -36,7 +36,9 @@ You do not create branches. You do not checkout other branches. You do not instr
Branches only exist inside the atomic `drone @git system-pr` command which commits → creates branch → pushes → opens PR → **returns HEAD to main**. That one command owns the entire branch lifecycle. Agents own nothing about branches.
Workflow: edit on main → `drone @git system-pr "msg"` → back on main. Devpulse merges reviewed PRs with `drone @git merge <PR#>`.
Workflow: edit on main → `drone @git system-pr "msg"` → back on main → STOP. The user merges. Never run `drone @git merge` without an explicit user instruction for that specific PR number — not even for your own PRs, not even for PRs that look ready. User-merges-only.
Local files are source of truth. A file edit IS reality on disk; you don't wait for a merge to act on it. If the truth is wrong, fix locally first, then PR.
`git checkout*` and `git add -f*` are denied system-wide in `.claude/settings.json`. These aren't arbitrary rules — they came from fixing actual bugs caused by agents staying on branches. Trust them.
+6
View File
@@ -13,6 +13,12 @@
"Bash(git add -f*)",
"Bash(git add --force*)",
"Bash(git checkout*)",
"Bash(git switch -c*)",
"Bash(git switch --create*)",
"Bash(git branch -c*)",
"Bash(git branch --copy*)",
"Bash(git branch -m*)",
"Bash(git branch --move*)",
"Read(/home/patrick/Patrick-Personal/**)",
"Edit(/home/patrick/Patrick-Personal/**)",
"Write(/home/patrick/Patrick-Personal/**)",
@@ -14,8 +14,8 @@ You are DEVPULSE — Patrick's primary AI collaborator and orchestration hub for
- **Don't solo-rebuild other branches.** Full multi-file implementations → dispatch via `drone @ai_mail dispatch @branch`.
- **Delegate heavy code to sub-agents** (`run_in_background: true`). Fire and forget, move on immediately. Launch → continue → get notified → report results. Never block waiting on agents.
- Use `drone @branch --help` for command syntax. Use `drone systems` for branch list.
- **ALWAYS WAKE after sending dispatch emails.** Send email → wake. Every time. No asking.
- **START WATCHDOG after any dispatch.** Run `drone @devpulse watchdog agent @target` (see Watchdog section) with `run_in_background: true`. Don't wait for the user to ask.
- **Always wake after sending dispatch emails.** Send email → wake. Every time. No asking.
- **Start watchdog after any dispatch.** Run `drone @devpulse watchdog agent @target` (see Watchdog section) with `run_in_background: true`. Don't wait for the user to ask.
## Branch Experts — Ask Before Rebuilding
@@ -32,11 +32,17 @@ When a task belongs to a specialist's DOMAIN, ask them. You can still investigat
| Command routing | @drone | @branch resolution, subprocess |
| Memory, vectors | @memory | ChromaDB, search, archival |
## Git Workflow — Always on Main, Drone Only
## Git Workflow — Always on Main, Drone Only, Never Merge
**One rule: always on main. No exceptions.** You don't create branches. You don't tell other agents to create branches. You don't stay on someone else's branch while they're mid-work. Branches exist ONLY inside the atomic `drone @git system-pr` window which commits → creates branch → pushes → PRs → returns HEAD to main. Every other moment: you're on main.
**Three rules, in order:**
Why: AIPass repo has ONE shared HEAD. Linger on a non-main HEAD and every agent's next edit lands on the wrong branch. Work gets stranded. Dispatch briefs must NEVER say "create a branch as step 1" — that's what caused the S101 merge mess.
1. **Always on main. No exceptions.** You don't create branches. You don't tell other agents to create branches. Branches exist only inside the atomic `drone @git system-pr` window which commits → creates branch → pushes → PRs → returns HEAD to main. Every other moment: you're on main.
2. **Never merge PRs.** That's the user's role. You fix, you PR, you stop. The user says "merge X" or merges themselves. Do not run `drone @git merge` without an explicit user instruction for that specific PR number. Past PRs, closed PRs, your own PRs — none of them auto-qualify. User-merges-only is the rule.
3. **Local files are source of truth.** When you make an edit, the file on disk is reality — you don't need to wait for a merge to act on the state you see. But that also means: if the truth is wrong, fix it locally first, then PR. Don't assume remote state matches.
Why main-only: AIPass repo has one shared HEAD. Linger on a non-main HEAD and every agent's next edit lands on the wrong branch. Work gets stranded. Dispatch briefs must never say "create a branch as step 1" — that's what caused the S101 merge mess.
Never use raw git commands (git commit, git push, git checkout anything, gh pr create). `Bash(git checkout*)` and `Bash(git add -f*)` are denied system-wide in `.claude/settings.json`. Drone handles everything correctly.
@@ -52,7 +58,7 @@ drone @git lock # Check PR lock status
Read-only git commands are fine: `git status`, `git diff`, `git log`.
**NEVER cd to repo root.** `drone @git system-pr` requires `.trinity/passport.json` in the CWD hierarchy. If you cd to the repo root, it fails. Stage files with relative paths from devpulse: `git add ../../../HERALD.md`. Always run drone commands from this directory.
**Never cd to repo root.** `drone @git system-pr` requires `.trinity/passport.json` in the CWD hierarchy. If you cd to the repo root, it fails. Stage files with relative paths from devpulse: `git add ../../../HERALD.md`. Always run drone commands from this directory.
## Key Commands
+16
View File
@@ -142,6 +142,22 @@
"file": "tests/test_devpulse_plugins.py",
"standard": "documentation",
"reason": "Test methods and mock helpers in this file are pre-existing without docstrings. Class docstrings describe intent; per-method docs would be noise."
},
{
"file": "tests/test_git_module.py",
"standard": "architecture",
"reason": "Test file — intentionally outside 3-layer structure. tests/ is a peer of apps/, not part of it."
},
{
"file": "tests/test_git_module.py",
"standard": "encapsulation",
"lines": [20],
"reason": "Test file imports lock_handler directly to test its public interface. Unit tests require direct access to implementation components."
},
{
"file": "tests/test_system_pr.py",
"standard": "architecture",
"reason": "Test file — intentionally outside 3-layer structure. tests/ is a peer of apps/, not part of it."
}
],
"notes": {
+7 -6
View File
@@ -117,14 +117,15 @@ drone/
│ │ ├── scanning/ # Scan result formatting + discovery
│ │ ├── command_registry/ # Command shortcut CRUD + lookup
│ │ └── git/ # Git workflow handlers
│ │ ├── lock_handler.py # Atomic lockfile (O_CREAT|O_EXCL)
│ │ ├── pr_handler.py # 10-step PR workflow
│ │ ├── status_handler.py # Scoped git status
│ │ └── sync_handler.py # Safe main sync
│ │ ├── lock_handler.py # Atomic lockfile (O_CREAT|O_EXCL)
│ │ ├── pr_handler.py # 10-step PR workflow
│ │ ├── status_handler.py # Scoped git status (subprocess)
│ │ ├── status_handler_gitpython.py # [prototype] GitPython status — DPLAN-0140 Phase 1, not wired in
│ │ └── sync_handler.py # Safe main sync
│ └── plugins/ # Extensions beyond core routing
│ └── devpulse_ops/ # System-wide PR, merge, smart-sync, fix
├── docs/ # Documentation
└── tests/ # 513 tests, 19 test files
└── tests/ # 529+ tests, 19 test files
```
---
@@ -172,7 +173,7 @@ Infrastructure modules (seedgo, cli, git) work from external AIPass projects wit
---
**Last Updated:** 2026-04-07
**Last Updated:** 2026-04-21
---
[← Back to AIPass](../../../README.md)
@@ -38,15 +38,22 @@ Design note (two-library split):
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from aipass.prax import logger
from aipass.drone.apps.handlers.json import json_handler
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
if TYPE_CHECKING:
from git import Repo as GitRepo
try:
import git as _git_module
_GITPYTHON_AVAILABLE = True
except ImportError:
except ImportError as exc:
logger.info("status_handler_gitpython: GitPython not installed (%s) — handler disabled", exc)
_git_module = None # type: ignore[assignment]
_GITPYTHON_AVAILABLE = False
@@ -69,13 +76,13 @@ _UNSTAGED_STATUS_MAP: dict[str, str] = {
}
def _collect_staged(repo: "_git_module.Repo", rel_prefix: str, rel_dir: str) -> list[dict]:
def _collect_staged(repo: "GitRepo", rel_prefix: str, rel_dir: str) -> list[dict]:
"""Return staged changes that fall under the branch directory."""
files: list[dict] = []
try:
staged_diffs = repo.head.commit.diff()
except Exception as exc: # empty repo or detached HEAD
logger.debug("status_handler_gitpython: could not get staged diffs: %s", exc)
logger.info("status_handler_gitpython: could not get staged diffs: %s", exc)
return files
for diff in staged_diffs:
@@ -84,12 +91,13 @@ def _collect_staged(repo: "_git_module.Repo", rel_prefix: str, rel_dir: str) ->
continue
if not (path.startswith(rel_prefix) or path == rel_dir):
continue
code = _STAGED_STATUS_MAP.get(diff.change_type, diff.change_type)
change_key = diff.change_type or ""
code = _STAGED_STATUS_MAP.get(change_key, change_key)
files.append({"status": code, "path": path})
return files
def _collect_unstaged(repo: "_git_module.Repo", rel_prefix: str, rel_dir: str) -> list[dict]:
def _collect_unstaged(repo: "GitRepo", rel_prefix: str, rel_dir: str) -> list[dict]:
"""Return unstaged working-tree changes that fall under the branch directory."""
files: list[dict] = []
for diff in repo.index.diff(None):
@@ -98,12 +106,13 @@ def _collect_unstaged(repo: "_git_module.Repo", rel_prefix: str, rel_dir: str) -
continue
if not (path.startswith(rel_prefix) or path == rel_dir):
continue
code = _UNSTAGED_STATUS_MAP.get(diff.change_type, diff.change_type)
change_key = diff.change_type or ""
code = _UNSTAGED_STATUS_MAP.get(change_key, change_key)
files.append({"status": code, "path": path})
return files
def _collect_untracked(repo: "_git_module.Repo", rel_prefix: str, rel_dir: str) -> list[dict]:
def _collect_untracked(repo: "GitRepo", rel_prefix: str, rel_dir: str) -> list[dict]:
"""Return untracked files that fall under the branch directory."""
files: list[dict] = []
for upath in repo.untracked_files:
@@ -127,11 +136,8 @@ def get_branch_status(branch_dir: Path) -> dict:
total -- int count of changed files
message -- human-readable summary string
"""
if not _GITPYTHON_AVAILABLE:
logger.error(
"status_handler_gitpython: GitPython is not installed. "
"Run: pip install gitpython"
)
if not _GITPYTHON_AVAILABLE or _git_module is None:
logger.error("status_handler_gitpython: GitPython is not installed. Run: pip install gitpython")
return {
"files": [],
"total": 0,
+103 -161
View File
@@ -1,3 +1,11 @@
# =================== AIPass ====================
# Name: test_git_module.py
# Description: Tests for the @git module — lock, status, sync, PR, and routing
# Version: 1.0.0
# Created: 2026-04-21
# Modified: 2026-04-21
# =============================================
"""Tests for the @git module — lock, status, sync, PR, and routing."""
from __future__ import annotations
@@ -358,6 +366,41 @@ class TestSyncHandler:
# ===========================================================================
def _run_nothing_staged(cmd: list[str], **kwargs: object) -> MagicMock:
"""Subprocess mock: on main, nothing staged (diff --cached returns 0)."""
r = MagicMock()
r.returncode = 0
r.stderr = ""
r.stdout = "main\n" if cmd[1:3] == ["rev-parse", "--abbrev-ref"] else ""
return r
def _run_cleanup_early_exit(cmd: list[str], **kwargs: object) -> MagicMock:
"""Subprocess mock: on main, nothing staged — triggers early exit path."""
r = MagicMock()
r.returncode = 0
r.stderr = ""
r.stdout = "main\n" if cmd[1:3] == ["rev-parse", "--abbrev-ref"] else ""
return r
def _run_with_staged(cmd: list[str], **kwargs: object) -> MagicMock:
"""Subprocess mock: on main, staged changes, successful commit/push/PR."""
r = MagicMock()
r.returncode = 0
r.stderr = ""
r.stdout = ""
if cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
r.stdout = "main\n"
elif cmd[1:3] == ["diff", "--cached"]:
r.returncode = 1 # 1 = something staged
elif cmd[0] == "git" and cmd[1] == "commit":
r.stdout = "[main abc1234] feat(api): test"
elif cmd[0] == "gh":
r.stdout = "https://github.com/test/repo/pull/1"
return r
class TestPRHandler:
"""PR workflow error path tests."""
@@ -404,34 +447,7 @@ class TestPRHandler:
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
call_count = 0
def mock_subprocess_run(cmd, **kwargs):
"""Simulate git subprocess returning main branch and staged-nothing."""
nonlocal call_count
call_count += 1
result = MagicMock()
result.stderr = ""
result.stdout = ""
if cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
result.returncode = 0
result.stdout = "main\n"
elif cmd[1:3] == ["checkout", "-b"]:
result.returncode = 0
elif cmd[0] == "git" and cmd[1] == "add":
result.returncode = 0
elif cmd[1:3] == ["diff", "--cached"]:
result.returncode = 0 # 0 means nothing staged
elif cmd[1] == "checkout" and cmd[2] == "main":
result.returncode = 0
elif cmd[1] == "pull":
result.returncode = 0
else:
result.returncode = 0
return result
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_subprocess_run):
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=_run_nothing_staged):
with patch(
"aipass.drone.apps.handlers.git.pr_handler.acquire_lock",
return_value={"success": True, "message": "ok"},
@@ -448,27 +464,9 @@ class TestPRHandler:
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
def mock_subprocess_run(cmd, **kwargs):
"""Simulate git subprocess returning main branch, then early exit on no staged files."""
result = MagicMock()
result.stderr = ""
result.stdout = ""
if cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
result.returncode = 0
result.stdout = "main\n"
elif cmd[0] == "git" and cmd[1] == "add":
result.returncode = 0
elif cmd[1:3] == ["diff", "--cached"]:
# Nothing staged — triggers early exit
result.returncode = 0
else:
result.returncode = 0
return result
release_mock = MagicMock(return_value={"success": True, "message": "ok"})
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_subprocess_run):
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=_run_cleanup_early_exit):
with patch(
"aipass.drone.apps.handlers.git.pr_handler.acquire_lock",
return_value={"success": True, "message": "ok"},
@@ -491,36 +489,9 @@ class TestPRHandler:
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
commit_cmd_seen: list[list[str]] = []
def mock_subprocess_run(cmd, **kwargs):
"""Simulate git/gh subprocess calls, recording commit invocations."""
r = MagicMock()
r.stderr = ""
r.stdout = ""
if cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
r.returncode = 0
r.stdout = "main\n"
elif cmd[0] == "git" and cmd[1] == "add":
r.returncode = 0
elif cmd[1:3] == ["diff", "--cached"]:
r.returncode = 1 # 1 means something is staged
elif cmd[0] == "git" and cmd[1] == "commit":
commit_cmd_seen.append(list(cmd))
r.returncode = 0
r.stdout = "[main abc1234] feat(api): test"
elif cmd[0] == "git" and cmd[1] == "branch":
r.returncode = 0
elif cmd[0] == "git" and cmd[1] == "push":
r.returncode = 0
elif cmd[0] == "gh":
r.returncode = 0
r.stdout = "https://github.com/test/repo/pull/1"
else:
r.returncode = 0
return r
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_subprocess_run):
with patch(
"aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=_run_with_staged
) as mock_run:
with patch(
"aipass.drone.apps.handlers.git.pr_handler.acquire_lock",
return_value={"success": True, "message": "ok"},
@@ -529,8 +500,11 @@ class TestPRHandler:
create_pr("api", "test desc", tmp_path / "src" / "aipass" / "api")
# The commit command must include '--' separator + pathspec to scope to branch_dir
assert commit_cmd_seen, "commit was never called"
commit_cmd = commit_cmd_seen[0]
commit_calls = [
c.args[0] for c in mock_run.call_args_list if c.args and c.args[0][0] == "git" and c.args[0][1] == "commit"
]
assert commit_calls, "commit was never called"
commit_cmd = commit_calls[0]
assert "--" in commit_cmd, "commit missing '--' pathspec separator"
pathspec_idx = commit_cmd.index("--")
pathspec = commit_cmd[pathspec_idx + 1]
@@ -744,6 +718,49 @@ class TestModuleRegistration:
# ===========================================================================
def _run_pr_created_success(cmd: list[str], **kwargs: object) -> MagicMock:
"""Subprocess mock for a full successful pr_handler run (fires pr_created)."""
r = MagicMock()
r.returncode = 0
r.stderr = ""
r.stdout = ""
if cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
r.stdout = "main\n"
elif cmd[1:3] == ["diff", "--cached"]:
r.returncode = 1
elif cmd[0] == "gh" and cmd[1] == "pr":
r.stdout = "https://github.com/org/repo/pull/99\n"
return r
def _run_pr_trigger_resilience(cmd: list[str], **kwargs: object) -> MagicMock:
"""Subprocess mock for pr_handler run where trigger.fire raises."""
r = MagicMock()
r.returncode = 0
r.stderr = ""
r.stdout = ""
if cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
r.stdout = "main\n"
elif cmd[1:3] == ["diff", "--cached"]:
r.returncode = 1
elif cmd[0] == "gh" and cmd[1] == "pr":
r.stdout = "https://github.com/org/repo/pull/100\n"
return r
def _run_merge_success(cmd: list[str], **kwargs: object) -> MagicMock:
"""Subprocess mock for a successful merge_plugin run (fires pr_merged)."""
r = MagicMock()
r.returncode = 0
r.stderr = ""
r.stdout = ""
if cmd[0] == "gh" and cmd[1] == "pr" and cmd[2] == "view":
r.stdout = "Fix the thing\n"
elif cmd[1:3] == ["rev-parse", "HEAD"]:
r.stdout = "abc123def456\n"
return r
class TestTriggerFireIntegration:
"""Verify trigger.fire() is called after successful PR/merge operations."""
@@ -753,43 +770,9 @@ class TestTriggerFireIntegration:
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
call_count = 0
def mock_run(cmd, **kwargs):
"""Simulate git subprocess calls and count invocations."""
nonlocal call_count
call_count += 1
r = MagicMock()
r.stderr = ""
if cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
r.returncode = 0
r.stdout = "main\n"
elif cmd[0] == "git" and cmd[1] == "add":
r.returncode = 0
r.stdout = ""
elif cmd[1:3] == ["diff", "--cached"]:
r.returncode = 1 # 1 = something staged
r.stdout = ""
elif cmd[0] == "git" and cmd[1] == "commit":
r.returncode = 0
r.stdout = ""
elif cmd[0] == "git" and cmd[1] == "branch":
r.returncode = 0
r.stdout = ""
elif cmd[0] == "git" and cmd[1] == "push":
r.returncode = 0
r.stdout = ""
elif cmd[0] == "gh" and cmd[1] == "pr":
r.returncode = 0
r.stdout = "https://github.com/org/repo/pull/99\n"
else:
r.returncode = 0
r.stdout = ""
return r
mock_trigger = MagicMock()
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_run):
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=_run_pr_created_success):
with patch(
"aipass.drone.apps.handlers.git.pr_handler.acquire_lock",
return_value={"success": True, "message": "ok"},
@@ -807,28 +790,10 @@ class TestTriggerFireIntegration:
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
def mock_run(cmd, **kwargs):
"""Simulate git/gh subprocess calls for trigger-failure resilience test."""
r = MagicMock()
r.stderr = ""
if cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
r.returncode = 0
r.stdout = "main\n"
elif cmd[1:3] == ["diff", "--cached"]:
r.returncode = 1
r.stdout = ""
elif cmd[0] == "gh" and cmd[1] == "pr":
r.returncode = 0
r.stdout = "https://github.com/org/repo/pull/100\n"
else:
r.returncode = 0
r.stdout = ""
return r
mock_trigger = MagicMock()
mock_trigger.fire.side_effect = RuntimeError("trigger broken")
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_run):
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=_run_pr_trigger_resilience):
with patch(
"aipass.drone.apps.handlers.git.pr_handler.acquire_lock",
return_value={"success": True, "message": "ok"},
@@ -841,40 +806,17 @@ class TestTriggerFireIntegration:
def test_merge_plugin_fires_pr_merged(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""merge_plugin.merge_pr fires pr_merged event on success."""
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
call_idx = 0
def mock_run(cmd, **kwargs):
"""Simulate gh pr merge and git pull subprocess calls."""
nonlocal call_idx
call_idx += 1
r = MagicMock()
r.stderr = ""
if cmd[0] == "gh" and cmd[1] == "pr" and cmd[2] == "merge":
r.returncode = 0
r.stdout = ""
elif cmd[0] == "git" and cmd[1] == "pull":
r.returncode = 0
r.stdout = ""
elif cmd[1:3] == ["rev-parse", "HEAD"]:
r.returncode = 0
r.stdout = "abc123def456\n"
elif cmd[0] == "gh" and cmd[1] == "pr" and cmd[2] == "view":
r.returncode = 0
r.stdout = "Fix the thing\n"
else:
r.returncode = 0
r.stdout = ""
return r
mock_trigger = MagicMock()
with patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run", side_effect=mock_run):
with patch(
"aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run", side_effect=_run_merge_success
):
with patch("aipass.trigger.apps.modules.core.trigger", mock_trigger):
result = merge_pr("42", "devpulse")
+107 -43
View File
@@ -3,7 +3,7 @@
# Description: Tests for devpulse_ops plugin — auth and system PR workflow
# Version: 1.0.0
# Created: 2026-03-30
# Modified: 2026-03-30
# Modified: 2026-04-21
# =============================================
"""Tests for devpulse_ops plugin — auth and system PR workflow."""
@@ -52,15 +52,15 @@ def devpulse_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
@pytest.fixture()
def seedgo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Create a temp directory with a seedgo passport (unauthorized)."""
"""Create a temp directory with an unauthorized branch passport."""
trinity = tmp_path / ".trinity"
trinity.mkdir()
passport = trinity / "passport.json"
passport.write_text(
json.dumps(
{
"branch_info": {"branch_name": "seedgo"},
"identity": {"name": "seedgo"},
"branch_info": {"branch_name": "citizen/unauthorized"},
"identity": {"name": "citizen/unauthorized"},
}
),
encoding="utf-8",
@@ -85,6 +85,54 @@ def repo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
return tmp_path
# ===========================================================================
# Module-level subprocess side-effect helpers
# ===========================================================================
# Responses for "nothing staged" scenario: (stdout, returncode)
_NOTHING_TO_COMMIT_RESPONSES: dict[tuple[str, ...], tuple[str, int]] = {
("git", "rev-parse", "--abbrev-ref"): ("main\n", 0),
("git", "add", "-u"): ("", 0),
("git", "diff", "--cached"): ("", 0), # exit 0 = nothing staged
("git", "fetch", "origin"): ("", 0),
("git", "rev-list", "--count"): ("0\n", 0),
}
# Responses for a complete successful PR flow: (stdout, returncode)
_PR_FLOW_RESPONSES: dict[tuple[str, ...], tuple[str, int]] = {
("git", "rev-parse", "--abbrev-ref"): ("main\n", 0),
("git", "add", "-u"): ("", 0),
("git", "diff", "--cached"): ("diff --git a/foo.py b/foo.py\n", 1), # 1 = staged
("git", "fetch", "origin"): ("", 0),
("git", "rev-list", "--count"): ("1\n", 0),
("git", "commit"): ("[main abc1234] test description\n", 0),
("git", "branch"): ("", 0),
("git", "push"): ("", 0),
("gh", "pr"): ("https://github.com/org/repo/pull/42\n", 0),
}
def _make_proc(stdout: str, returncode: int) -> MagicMock:
"""Build a minimal subprocess mock with stdout, returncode, and empty stderr."""
proc = MagicMock()
proc.stdout = stdout
proc.returncode = returncode
proc.stderr = ""
return proc
def _nothing_to_commit_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
"""Return a mock proc for the 'nothing staged' subprocess sequence."""
stdout, rc = _NOTHING_TO_COMMIT_RESPONSES.get(tuple(cmd[:3]), ("", 0))
return _make_proc(stdout, rc)
def _pr_flow_run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
"""Return a mock proc for a complete successful PR-flow subprocess sequence."""
stdout, rc = _PR_FLOW_RESPONSES.get(tuple(cmd[:2]), ("", 0))
return _make_proc(stdout, rc)
# ===========================================================================
# 1. auth.verify_caller tests
# ===========================================================================
@@ -94,6 +142,7 @@ class TestVerifyCallerAuthorized:
"""verify_caller should return the branch name for devpulse."""
def test_verify_caller_with_devpulse_passport(self, devpulse_dir: Path) -> None:
"""Devpulse passport resolves to the 'devpulse' branch name."""
result = verify_caller()
assert result == "devpulse"
assert result in ALLOWED_CALLERS
@@ -103,11 +152,13 @@ class TestVerifyCallerUnauthorized:
"""verify_caller should raise PermissionError for non-devpulse branches."""
def test_verify_caller_unauthorized(self, seedgo_dir: Path) -> None:
"""An unauthorized branch raises PermissionError with 'not authorized'."""
with pytest.raises(PermissionError, match="not authorized"):
verify_caller()
def test_error_message_includes_branch_name(self, seedgo_dir: Path) -> None:
with pytest.raises(PermissionError, match="seedgo"):
"""The PermissionError message includes the actual branch name."""
with pytest.raises(PermissionError, match="citizen/unauthorized"):
verify_caller()
@@ -115,6 +166,7 @@ class TestVerifyCallerNoPassport:
"""verify_caller should raise PermissionError when no passport exists."""
def test_verify_caller_no_passport(self, no_passport_dir: Path) -> None:
"""Missing passport raises PermissionError mentioning passport path."""
with pytest.raises(PermissionError, match="No .trinity/passport.json"):
verify_caller()
@@ -128,29 +180,37 @@ class TestSlugify:
"""Test the slugify function with various inputs."""
def test_basic_slugify(self) -> None:
"""Space-separated words become hyphen-separated lowercase slugs."""
assert slugify("Update all configs") == "update-all-configs"
def test_special_characters_removed(self) -> None:
"""Punctuation and special characters are stripped from the output."""
assert slugify("fix: broken pipe!") == "fix-broken-pipe"
def test_multiple_spaces_collapse(self) -> None:
"""Consecutive spaces collapse into a single hyphen."""
assert slugify("too many spaces") == "too-many-spaces"
def test_max_length_truncation(self) -> None:
"""Slugs longer than 50 characters are truncated."""
long_desc = "a" * 100
result = slugify(long_desc)
assert len(result) <= 50
def test_leading_trailing_hyphens_stripped(self) -> None:
"""Leading and trailing hyphens are stripped from the result."""
assert slugify(" --hello world-- ") == "hello-world"
def test_empty_string(self) -> None:
"""An empty input returns an empty string."""
assert slugify("") == ""
def test_all_special_chars(self) -> None:
"""A string of only special characters produces an empty slug."""
assert slugify("!!!@@@###") == ""
def test_mixed_case(self) -> None:
"""Mixed-case input is normalized to lowercase."""
assert slugify("Hello World FOO") == "hello-world-foo"
@@ -165,13 +225,9 @@ class TestSystemPrNotOnMain:
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.find_repo_root")
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.subprocess.run")
def test_system_pr_not_on_main(self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None:
"""Returns failure dict when HEAD is on a feature branch instead of main."""
mock_root.return_value = tmp_path
# Simulate being on a feature branch
proc = MagicMock()
proc.stdout = "feature/something\n"
proc.returncode = 0
mock_run.return_value = proc
mock_run.return_value = _make_proc("feature/something\n", 0)
result = create_system_pr("test description", "devpulse")
@@ -199,39 +255,10 @@ class TestSystemPrNothingToCommit:
mock_release: MagicMock,
tmp_path: Path,
) -> None:
"""Returns failure dict with 'Nothing to PR' when no staged changes exist."""
mock_root.return_value = tmp_path
mock_acquire.return_value = {"success": True, "message": "Lock acquired"}
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
proc = MagicMock()
if cmd[:3] == ["git", "rev-parse", "--abbrev-ref"]:
proc.stdout = "main\n"
proc.returncode = 0
elif cmd[:3] == ["git", "add", "-u"]:
proc.stdout = ""
proc.stderr = ""
proc.returncode = 0
elif cmd[:3] == ["git", "diff", "--cached"]:
# returncode 0 means nothing staged
proc.stdout = ""
proc.stderr = ""
proc.returncode = 0
elif cmd[:3] == ["git", "fetch", "origin"]:
proc.stdout = ""
proc.stderr = ""
proc.returncode = 0
elif cmd[:3] == ["git", "rev-list", "--count"]:
# 0 commits ahead
proc.stdout = "0\n"
proc.stderr = ""
proc.returncode = 0
else:
proc.stdout = ""
proc.stderr = ""
proc.returncode = 0
return proc
mock_run.side_effect = side_effect
mock_run.side_effect = _nothing_to_commit_side_effect
result = create_system_pr("test description", "devpulse")
@@ -240,7 +267,38 @@ class TestSystemPrNothingToCommit:
# ===========================================================================
# 5. git_module routing for system-pr
# 5. create_system_pr — HEAD stays on main (no git checkout)
# ===========================================================================
class TestSystemPrHeadStaysOnMain:
"""system-pr must never call 'git checkout' — HEAD stays on main throughout."""
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.release_lock")
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.acquire_lock")
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.find_repo_root")
@patch("aipass.drone.apps.plugins.devpulse_ops.pr_plugin.subprocess.run")
def test_no_git_checkout_during_successful_system_pr(
self,
mock_run: MagicMock,
mock_root: MagicMock,
mock_acquire: MagicMock,
mock_release: MagicMock,
tmp_path: Path,
) -> None:
"""No subprocess call contains 'git checkout' during a complete PR flow."""
mock_root.return_value = tmp_path
mock_acquire.return_value = {"success": True, "message": "Lock acquired"}
mock_run.side_effect = _pr_flow_run_side_effect
create_system_pr("test description", "devpulse")
all_cmds = [c[0][0] if c[0] else c.args[0] for c in mock_run.call_args_list]
assert not any("checkout" in cmd for cmd in all_cmds)
# ===========================================================================
# 6. git_module routing for system-pr
# ===========================================================================
@@ -248,23 +306,27 @@ class TestGitModuleSystemPrRouting:
"""Test that git_module routes system-pr correctly."""
def test_system_pr_in_commands(self) -> None:
"""The _COMMANDS registry includes the 'system-pr' verb."""
from aipass.drone.apps.modules.git_module import _COMMANDS
assert "system-pr" in _COMMANDS
def test_get_help_includes_system_pr(self) -> None:
"""Generic get_help() output mentions 'system-pr'."""
from aipass.drone.apps.modules.git_module import get_help
help_text = get_help()
assert "system-pr" in help_text
def test_get_help_system_pr_specific(self) -> None:
"""get_help('system-pr') output mentions devpulse as the authorized caller."""
from aipass.drone.apps.modules.git_module import get_help
help_text = get_help("system-pr")
assert "devpulse" in help_text
def test_get_introspective_includes_plugin(self) -> None:
"""get_introspective() output mentions the devpulse_ops plugin."""
from aipass.drone.apps.modules.git_module import get_introspective
intro = get_introspective()
@@ -272,6 +334,7 @@ class TestGitModuleSystemPrRouting:
@patch("aipass.drone.apps.plugins.devpulse_ops.auth.verify_caller")
def test_handle_system_pr_no_args(self, mock_verify: MagicMock) -> None:
"""handle_command('system-pr', []) exits with code 1 and a Usage message."""
from aipass.drone.apps.modules.git_module import handle_command
result = handle_command("system-pr", [])
@@ -280,6 +343,7 @@ class TestGitModuleSystemPrRouting:
@patch("aipass.drone.apps.plugins.devpulse_ops.auth.verify_caller")
def test_handle_system_pr_unauthorized(self, mock_verify: MagicMock) -> None:
"""handle_command propagates PermissionError as exit_code 1 with the message."""
from aipass.drone.apps.modules.git_module import handle_command
mock_verify.side_effect = PermissionError("not authorized")
@@ -33,3 +33,12 @@ def read_lines_safe(path: Path, n: int = 0, encoding: str = "utf-8") -> list[str
return fh.readlines()
except OSError:
return []
def write_text_safe(path: Path, text: str, encoding: str = "utf-8") -> bool:
"""Write text to a file. Returns True on success, False on OSError."""
try:
path.write_text(text, encoding=encoding)
return True
except OSError:
return False
@@ -0,0 +1,47 @@
# =================== AIPass ====================
# Name: hooks/__init__.py
# Description: Hook test runner — subprocess execution for hooks_ext module
# Version: 1.0.0
# Created: 2026-04-21
# Modified: 2026-04-21
# =============================================
"""Hook test runner handler.
Encapsulates subprocess execution so hooks_ext module stays
at the display/coordination layer.
"""
import re
import subprocess
import sys
import time
from pathlib import Path
def run_pytest_file(test_file: Path, repo_root: Path, timeout: int = 60) -> tuple[int, int, float]:
"""Run pytest on a single test file. Returns (passed, failed, duration_seconds)."""
t0 = time.monotonic()
proc = subprocess.run(
[sys.executable, "-m", "pytest", str(test_file), "--tb=no", "-q", "--no-header"],
capture_output=True,
text=True,
timeout=timeout,
cwd=str(repo_root),
)
duration = time.monotonic() - t0
passed = 0
failed = 0
for line in proc.stdout.splitlines():
line = line.strip()
if "passed" in line or "failed" in line or "error" in line.lower():
m_passed = re.search(r"(\d+) passed", line)
m_failed = re.search(r"(\d+) failed", line)
m_error = re.search(r"(\d+) error", line)
if m_passed:
passed = int(m_passed.group(1))
if m_failed:
failed = int(m_failed.group(1))
if m_error:
failed += int(m_error.group(1))
return passed, failed, duration
+9 -6
View File
@@ -48,6 +48,9 @@ from aipass.cli.apps.modules import warning
# JSON handler for tracking
from aipass.seedgo.apps.handlers.json import json_handler
# File handler — modules must not call open()/write_text() directly
from aipass.seedgo.apps.handlers.file import write_text_safe
# Extended subcommands (test + list)
from aipass.seedgo.apps.modules.hooks_ext import cmd_hooks_list, cmd_hooks_test
@@ -206,10 +209,8 @@ def _cmd_probe_display(log_path: Path | None = None) -> None:
def _run_headless_claude() -> int:
"""Spawn headless claude with a Read tool call. Returns exit code."""
canary = Path("/tmp/probe_canary.txt")
try:
canary.write_text("probe canary 2026-04-20\n", encoding="utf-8")
except OSError as exc:
logger.info("hooks.py: could not write canary: %s", exc)
if not write_text_safe(canary, "probe canary 2026-04-20\n"):
logger.info("hooks.py: could not write canary")
console.print("[dim]Spawning headless claude...[/dim]")
# --permission-mode bypassPermissions is the AIPass-approved bypass flag
@@ -545,8 +546,10 @@ def handle_command(command: str, args: List[str]) -> bool:
if command != "hooks":
return False
# No args or help -> introspection
if not args or args[0] in ("--help", "-h", "help"):
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
print_introspection()
return True
+9 -36
View File
@@ -17,16 +17,13 @@ Extended subcommands for hooks.py — split out to keep hooks.py under 700 lines
import glob as _glob
import json
import re
import subprocess
import sys
import time
from pathlib import Path
from aipass.prax import logger
from aipass.cli import console
from aipass.cli.apps.modules import warning
from aipass.seedgo.apps.handlers.file import read_lines_safe
from aipass.seedgo.apps.handlers.file import read_lines_safe, read_text_safe
from aipass.seedgo.apps.handlers.hooks import run_pytest_file
from aipass.seedgo.apps.handlers.json import json_handler
from rich.table import Table
@@ -36,25 +33,6 @@ from rich.table import Table
# =============================================================================
def _parse_pytest_counts(stdout: str) -> tuple[int, int]:
"""Parse passed/failed counts from pytest -q output. Returns (passed, failed)."""
passed = 0
failed = 0
for line in stdout.splitlines():
line = line.strip()
if "passed" in line or "failed" in line or "error" in line.lower():
m_passed = re.search(r"(\d+) passed", line)
m_failed = re.search(r"(\d+) failed", line)
m_error = re.search(r"(\d+) error", line)
if m_passed:
passed = int(m_passed.group(1))
if m_failed:
failed = int(m_failed.group(1))
if m_error:
failed += int(m_error.group(1))
return passed, failed
def cmd_hooks_test(repo_root: Path) -> None:
"""Run hook test suite, display per-file pass/fail table."""
pattern = str(repo_root / "src" / "aipass" / "seedgo" / "tests" / "test_hooks*.py")
@@ -82,16 +60,7 @@ def cmd_hooks_test(repo_root: Path) -> None:
for tf in test_files:
stem = Path(tf).stem
t0 = time.monotonic()
proc = subprocess.run(
[sys.executable, "-m", "pytest", tf, "--tb=no", "-q", "--no-header"],
capture_output=True,
text=True,
timeout=60,
cwd=str(repo_root),
)
duration = time.monotonic() - t0
passed, failed = _parse_pytest_counts(proc.stdout)
passed, failed, duration = run_pytest_file(Path(tf), repo_root)
total_passed += passed
total_failed += failed
status = (
@@ -126,10 +95,14 @@ def read_settings_file(path: Path) -> dict:
"""Read and parse a settings.json file. Returns {} on failure."""
if not path.exists():
return {}
text = read_text_safe(path)
if text is None:
logger.info("hooks_ext.py: could not read %s", path)
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
return json.loads(text)
except Exception as exc:
logger.info("hooks_ext.py: could not read %s: %s", path, exc)
logger.info("hooks_ext.py: could not parse %s: %s", path, exc)
return {}