feat(system): DPLAN-0173: Git workflow redesign — tier-based access, new handlers, hook reverts

Co-Authored-By: @devpulse <devpulse@aipass>
This commit is contained in:
AIOSAI
2026-05-12 18:50:22 -07:00
co-authored by @devpulse
parent 3b8fa1fa5a
commit d19a840253
12 changed files with 338 additions and 211 deletions
@@ -299,30 +299,9 @@ def is_same_file_as_last(file_path: str) -> bool:
return False
def _project_has_own_posttooluse_hooks() -> bool:
"""Check if CWD is inside a project with its own PostToolUse hooks."""
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
settings = search / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
ptu = data.get("hooks", {}).get("PostToolUse", [])
if ptu:
return True
except (json.JSONDecodeError, OSError):
pass
search = search.parent
return False
def main():
"""Main hook entry point."""
try:
if _project_has_own_posttooluse_hooks():
return
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
@@ -384,7 +363,4 @@ Fix these errors in {Path(file_path).name} now. Do not skip or defer."""
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("PostToolUse", "provider", __file__, main)
main()
@@ -6,35 +6,12 @@ Injects branch-specific prompts based on CWD. When working in a branch
directory, loads .aipass/aipass_local_prompt.md and outputs it so the
AI sees branch-specific context.
When CWD is inside a project that has its own UserPromptSubmit hooks
(e.g. a standalone aipass-init project), this provider-level hook exits
silently to avoid double-firing.
Version: 1.1.0
Version: 1.0.0
"""
import json
from pathlib import Path
def _project_has_own_hooks() -> bool:
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
settings = search / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
if ups:
return True
except (json.JSONDecodeError, OSError):
pass
search = search.parent
return False
def find_branch_root() -> Path | None:
"""
Find the branch root directory.
@@ -62,9 +39,6 @@ def find_branch_root() -> Path | None:
def main():
if _project_has_own_hooks():
return
branch_root = find_branch_root()
if branch_root:
@@ -76,9 +50,4 @@ def main():
if __name__ == "__main__":
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("UserPromptSubmit", "provider", __file__, main)
main()
@@ -5,34 +5,13 @@ Email Notification Hook - Notifies of new emails on prompt submit.
Checks the current branch's inbox for unread emails and displays
a notification if any exist.
When CWD is inside a project that has its own UserPromptSubmit hooks,
this provider-level hook exits silently to avoid double-firing.
Version: 1.1.0
Version: 1.0.0
"""
import json
from pathlib import Path
def _project_has_own_hooks() -> bool:
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
settings = search / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
if ups:
return True
except (json.JSONDecodeError, OSError):
pass
search = search.parent
return False
def find_repo_root() -> Path | None:
"""Find the repo root (contains pyproject.toml or .git)."""
search = Path.cwd()
@@ -101,9 +80,6 @@ def count_new_emails(branch_root: Path) -> int:
def main():
if _project_has_own_hooks():
return
branch_root = find_branch_root()
if not branch_root:
return
@@ -117,9 +93,4 @@ def main():
if __name__ == "__main__":
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("UserPromptSubmit", "provider", __file__, main)
main()
@@ -5,34 +5,13 @@ Identity Injector - Injects branch identity on every prompt.
Reads from [BRANCH].id.json and outputs core identity fields.
Finds the branch root by walking up from CWD looking for apps/ or *.id.json.
When CWD is inside a project that has its own UserPromptSubmit hooks,
this provider-level hook exits silently to avoid double-firing.
Version: 1.1.0
Version: 1.0.0
"""
import json
from pathlib import Path
def _project_has_own_hooks() -> bool:
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
search = Path.cwd()
home = Path.home()
while search != home and search.parent != search:
settings = search / ".claude" / "settings.json"
if settings.exists():
try:
data = json.loads(settings.read_text(encoding="utf-8"))
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
if ups:
return True
except (json.JSONDecodeError, OSError):
pass
search = search.parent
return False
def find_repo_root() -> Path | None:
"""Find the repo root (contains pyproject.toml or .git)."""
search = Path.cwd()
@@ -118,9 +97,6 @@ def format_identity(data: dict) -> str:
def main():
if _project_has_own_hooks():
return
branch_root = find_branch_root()
if not branch_root:
return
@@ -139,9 +115,4 @@ def main():
if __name__ == "__main__":
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("UserPromptSubmit", "provider", __file__, main)
main()
@@ -85,7 +85,7 @@ def _get_git_info():
text=True,
timeout=5,
)
status = subprocess.run(
subprocess.run(
["git", "diff", "--stat", "--cached", "HEAD"],
capture_output=True,
text=True,
@@ -165,7 +165,4 @@ Context just compacted. Below is your live state. Use it to continue seamlessly.
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("PreCompact", "provider", __file__, main)
main()
@@ -29,41 +29,18 @@ def _find_repo_root() -> Path | None:
AIPASS_ROOT = _find_repo_root()
def _get_cwd_branch() -> str | None:
"""Detect which branch directory (src/aipass/<name>) the CWD is in."""
cwd = Path.cwd().resolve()
if AIPASS_ROOT is None:
return None
src = AIPASS_ROOT / "src" / "aipass"
try:
rel = cwd.relative_to(src)
return rel.parts[0] if rel.parts else None
except ValueError:
return None
def get_modified_py_files() -> list[str]:
"""Get Python files modified in the working tree, scoped to the CWD branch.
Only returns files inside the current branch's directory (or repo-root files).
This prevents dispatched agents' changes from triggering violations on the
orchestrator or other agents sharing the worktree.
"""
"""Get Python files modified in the working tree (unstaged + staged)."""
if AIPASS_ROOT is None:
return []
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT)
)
cwd_branch = _get_cwd_branch()
files = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line.endswith(".py") and not line.startswith(".claude/"):
if cwd_branch and line.startswith("src/aipass/"):
file_branch = line.split("/")[2] if len(line.split("/")) > 2 else None
if file_branch and file_branch != cwd_branch:
continue
full = AIPASS_ROOT / line
if full.exists():
files.append(str(full))
@@ -100,29 +77,6 @@ def run_seedgo_checklist(file_path: str) -> list[str]:
return []
def check_hook_readme_accountability() -> str | None:
"""Check if hook files changed but README wasn't updated. Returns reminder or None."""
if AIPASS_ROOT is None:
return None
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT)
)
changed = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
hook_files_changed = any(f.startswith(".claude/hooks/") and f.endswith(".py") for f in changed)
readme_changed = ".claude/hooks/README.md" in changed
if hook_files_changed and not readme_changed:
return (
"Hook files were modified but .claude/hooks/README.md was not updated. "
"Consider updating the README to reflect your changes."
)
except Exception:
pass
return None
def main():
try:
json.load(sys.stdin)
@@ -131,8 +85,6 @@ def main():
if not modified:
return # Nothing to check
readme_reminder = check_hook_readme_accountability()
all_violations = {}
for f in modified:
vs = run_seedgo_checklist(f)
@@ -140,30 +92,23 @@ def main():
name = Path(f).name
all_violations[name] = vs
if all_violations:
# Build the block reason
lines = ["Standards violations found in files you modified:\n"]
for fname, vs in all_violations.items():
lines.append(f" {fname}:")
for v in vs:
lines.append(f" - {v}")
lines.append("\nFix these violations before finishing.")
if not all_violations:
return # All clear
if readme_reminder:
lines.append(f"\n⚠️ {readme_reminder}")
# Build the block reason
lines = ["Standards violations found in files you modified:\n"]
for fname, vs in all_violations.items():
lines.append(f" {fname}:")
for v in vs:
lines.append(f" - {v}")
lines.append("\nFix these violations before finishing.")
output = {"decision": "block", "reason": "\n".join(lines)}
print(json.dumps(output))
elif readme_reminder:
output = {"decision": "allow", "reason": f"⚠️ {readme_reminder}"}
print(json.dumps(output))
output = {"decision": "block", "reason": "\n".join(lines)}
print(json.dumps(output))
except Exception:
pass # Silent fail — don't block on errors
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
from hook_log import run_and_log
run_and_log("SubagentStop", "provider", __file__, main)
main()