feat: dev pipeline — multi-project git tools, git_commit, git_push
git_status/log/diff gain a `project` param (same aliases as aider_run: cortex/aether_api/aether_frontend/aether_container). Default is Cortex for backward compat. New git_commit and git_push tools complete the review→commit→push loop: aider_run(auto_commit=False) → git_diff → git_commit → git_push. Both admin-only, confirm-required, high risk. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,8 @@ from tools.git import (
|
||||
git_status as _git_status,
|
||||
git_log as _git_log,
|
||||
git_diff as _git_diff,
|
||||
git_commit as _git_commit,
|
||||
git_push as _git_push,
|
||||
)
|
||||
from tools.agents import (
|
||||
spawn_agent as _spawn_agent,
|
||||
@@ -129,7 +131,7 @@ import tools.ae_database as _mod_ae_database
|
||||
TOOL_CATEGORIES: dict[str, list[str]] = {
|
||||
"Web": ["web_search", "http_fetch", "web_read", "http_post"],
|
||||
"Project Files": ["project_file_read", "project_file_list", "file_stat", "file_grep", "file_diff", "file_syntax_check"],
|
||||
"Git": ["git_status", "git_log", "git_diff"],
|
||||
"Git": ["git_status", "git_log", "git_diff", "git_commit", "git_push"],
|
||||
"System Files": ["file_read", "file_list", "file_write", "session_read", "session_search"],
|
||||
"Shell": ["shell_exec", "claude_allow_dir"],
|
||||
"System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"],
|
||||
@@ -213,6 +215,8 @@ _CALLABLES: dict[str, callable] = {
|
||||
"git_status": _git_status,
|
||||
"git_log": _git_log,
|
||||
"git_diff": _git_diff,
|
||||
"git_commit": _git_commit,
|
||||
"git_push": _git_push,
|
||||
"spawn_agent": _spawn_agent,
|
||||
"agent_status": _agent_status,
|
||||
"agent_list": _agent_list,
|
||||
@@ -245,6 +249,8 @@ TOOL_ROLES: dict[str, str] = {
|
||||
"agent_list": "user",
|
||||
"agent_cancel": "admin",
|
||||
"aider_run": "admin",
|
||||
"git_commit": "admin",
|
||||
"git_push": "admin",
|
||||
"email_send": "admin",
|
||||
"nc_talk_send": "admin",
|
||||
"http_post": "admin",
|
||||
@@ -268,6 +274,8 @@ CONFIRM_REQUIRED: set[str] = {
|
||||
"ae_journal_entry_disable", # disables a journal entry — not easily reversed
|
||||
"agent_cancel", # kills a running background task
|
||||
"aider_run", # edits files and commits — irreversible without git revert
|
||||
"git_commit", # creates a commit — irreversible without git revert
|
||||
"git_push", # pushes to remote — cannot be un-pushed easily
|
||||
}
|
||||
|
||||
# Security risk ratings — informational for now; will drive auto-allow tiers later.
|
||||
@@ -360,10 +368,12 @@ TOOL_RISK: dict[str, str] = {
|
||||
"agent_notes_append": "low",
|
||||
"agent_notes_clear": "low",
|
||||
|
||||
# Git — all read-only inspections
|
||||
# Git — reads are low; commit/push affect repo history
|
||||
"git_status": "low",
|
||||
"git_log": "low",
|
||||
"git_diff": "low",
|
||||
"git_commit": "high",
|
||||
"git_push": "high",
|
||||
|
||||
# Agents — spawning is high; lifecycle reads are low; cancel is medium (kills a task)
|
||||
"spawn_agent": "high",
|
||||
@@ -586,7 +596,7 @@ CATEGORY_TOOL_MAP: dict[str, list[str]] = {
|
||||
"web_post": ["http_post"],
|
||||
"file": ["project_file_read", "project_file_list", "file_stat", "file_grep",
|
||||
"file_diff", "file_syntax_check", "file_read", "file_list", "file_write"],
|
||||
"git": ["git_status", "git_log", "git_diff"],
|
||||
"git": ["git_status", "git_log", "git_diff", "git_commit", "git_push"],
|
||||
"system": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update", "shell_exec"],
|
||||
"tasks": ["task_list", "task_create", "task_update", "task_complete"],
|
||||
"cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"],
|
||||
@@ -612,7 +622,8 @@ _KEYWORD_CATEGORY_MAP: dict[str, list[str]] = {
|
||||
"web_post": ["post to", "send to", "webhook", "trigger webhook"],
|
||||
"file": ["read file", "show file", "list file", "directory", "grep",
|
||||
"search in", "find in", "diff", "compare", "syntax check", "open file"],
|
||||
"git": ["git", "commit", "branch", "pulled", "merged", "repository", "repo"],
|
||||
"git": ["git", "commit", "branch", "pulled", "merged", "repository", "repo",
|
||||
"push", "push to", "push the changes", "stage", "stash"],
|
||||
"system": ["restart", "update", "status", "logs", "log", "deploy", "run command",
|
||||
"shell", "is it running", "health"],
|
||||
"tasks": ["task", "todo", "to-do", "to do", "add task", "create task",
|
||||
|
||||
@@ -1,27 +1,62 @@
|
||||
"""
|
||||
Git inspection tools — project-scoped, read-only.
|
||||
Git tools — project-scoped.
|
||||
|
||||
git_status — working tree status (staged, unstaged, untracked changes)
|
||||
git_log — recent commit history with optional path filter
|
||||
git_diff — diff between commits, branches, or working tree vs HEAD
|
||||
Read-only inspection:
|
||||
git_status — working tree status
|
||||
git_log — recent commit history
|
||||
git_diff — diff between refs or working tree vs HEAD
|
||||
|
||||
Write operations (admin-only, confirm-required):
|
||||
git_commit — stage files and create a commit
|
||||
git_push — push current branch to remote
|
||||
|
||||
All tools accept an optional `project` parameter using the same aliases as aider_run:
|
||||
"cortex" (default), "aether_api", "aether_frontend", "aether_container"
|
||||
Or pass an absolute path directly.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from google.genai import types
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROJECT_ROOT: Path = Path(__file__).parent.parent.parent.resolve()
|
||||
_CORTEX_ROOT: Path = Path(__file__).parent.parent.parent.resolve()
|
||||
|
||||
_PROJECT_ALIASES: dict[str, str] = {
|
||||
"cortex": str(_CORTEX_ROOT),
|
||||
"aether_api": "~/OSIT_dev/aether_api_fastapi",
|
||||
"aether_frontend": "~/OSIT_dev/aether_app_sveltekit",
|
||||
"aether_container": "~/OSIT_dev/aether_container_env",
|
||||
}
|
||||
|
||||
_MAX_OUTPUT = 50_000
|
||||
|
||||
_PROJECT_PARAM = types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description=(
|
||||
"Project to run git in. Known aliases: 'cortex' (default), 'aether_api', "
|
||||
"'aether_frontend', 'aether_container'. Or an absolute path. "
|
||||
"Omit to use the Cortex project."
|
||||
),
|
||||
)
|
||||
|
||||
async def _git(*args: str, timeout: int = 15) -> tuple[int, str]:
|
||||
"""Run a git command in the project root. Returns (returncode, output)."""
|
||||
|
||||
def _resolve_project(project: str) -> Path:
|
||||
"""Resolve a project alias or path string to an absolute Path."""
|
||||
if not project:
|
||||
return _CORTEX_ROOT
|
||||
resolved = _PROJECT_ALIASES.get(project, project)
|
||||
return Path(os.path.expanduser(resolved))
|
||||
|
||||
|
||||
async def _git(*args: str, cwd: Path, timeout: int = 15) -> tuple[int, str]:
|
||||
"""Run a git command in cwd. Returns (returncode, combined output)."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "-C", str(_PROJECT_ROOT), *args,
|
||||
"git", "-C", str(cwd), *args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -32,8 +67,7 @@ async def _git(*args: str, timeout: int = 15) -> tuple[int, str]:
|
||||
return 1, "git command timed out"
|
||||
out = (stdout or b"").decode(errors="replace").strip()
|
||||
err = (stderr or b"").decode(errors="replace").strip()
|
||||
combined = out if out else err
|
||||
return proc.returncode, combined
|
||||
return proc.returncode, (out if out else err)
|
||||
|
||||
|
||||
def _cap(text: str) -> str:
|
||||
@@ -42,16 +76,24 @@ def _cap(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
async def git_status() -> str:
|
||||
"""Return the current git working tree status."""
|
||||
rc, out = await _git("status")
|
||||
# ── Read-only tools ────────────────────────────────────────────────────────────
|
||||
|
||||
async def git_status(project: str = "") -> str:
|
||||
"""Return the working tree status for a project."""
|
||||
cwd = _resolve_project(project)
|
||||
if not cwd.is_dir():
|
||||
return f"Error: project directory not found: {cwd}"
|
||||
rc, out = await _git("status", cwd=cwd)
|
||||
if rc != 0:
|
||||
return f"git status failed: {out}"
|
||||
return out or "Working tree clean — nothing to report."
|
||||
|
||||
|
||||
async def git_log(n: int = 20, path: str = "", oneline: bool = True) -> str:
|
||||
"""Return recent git commit history."""
|
||||
async def git_log(n: int = 20, path: str = "", oneline: bool = True, project: str = "") -> str:
|
||||
"""Return recent commit history for a project."""
|
||||
cwd = _resolve_project(project)
|
||||
if not cwd.is_dir():
|
||||
return f"Error: project directory not found: {cwd}"
|
||||
args = ["log"]
|
||||
if oneline:
|
||||
args += ["--oneline"]
|
||||
@@ -60,14 +102,17 @@ async def git_log(n: int = 20, path: str = "", oneline: bool = True) -> str:
|
||||
args += [f"-{max(1, min(n, 200))}"]
|
||||
if path:
|
||||
args += ["--", path]
|
||||
rc, out = await _git(*args)
|
||||
rc, out = await _git(*args, cwd=cwd)
|
||||
if rc != 0:
|
||||
return f"git log failed: {out}"
|
||||
return _cap(out) or "No commits found."
|
||||
|
||||
|
||||
async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only: bool = False) -> str:
|
||||
"""Show a git diff. Defaults to working tree vs HEAD (unstaged changes)."""
|
||||
async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only: bool = False, project: str = "") -> str:
|
||||
"""Show a diff for a project. Defaults to working tree vs HEAD."""
|
||||
cwd = _resolve_project(project)
|
||||
if not cwd.is_dir():
|
||||
return f"Error: project directory not found: {cwd}"
|
||||
args = ["diff"]
|
||||
if stat_only:
|
||||
args += ["--stat"]
|
||||
@@ -77,34 +122,82 @@ async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only:
|
||||
args += [ref_a]
|
||||
if path:
|
||||
args += ["--", path]
|
||||
rc, out = await _git(*args)
|
||||
# diff exits 1 when there are differences — that's normal
|
||||
rc, out = await _git(*args, cwd=cwd)
|
||||
# diff exits 1 when differences exist — normal
|
||||
if rc not in (0, 1):
|
||||
return f"git diff failed: {out}"
|
||||
return _cap(out) or "No differences found."
|
||||
|
||||
|
||||
# ── Declarations ──────────────────────────────────────────────────────────────
|
||||
# ── Write tools (admin-only, confirm-required) ─────────────────────────────────
|
||||
|
||||
async def git_commit(message: str, project: str = "", files: list[str] | None = None) -> str:
|
||||
"""Stage files and create a commit in a project."""
|
||||
cwd = _resolve_project(project)
|
||||
if not cwd.is_dir():
|
||||
return f"Error: project directory not found: {cwd}"
|
||||
if not message.strip():
|
||||
return "Error: commit message is required."
|
||||
|
||||
# Stage specified files or all changes
|
||||
if files:
|
||||
for f in files:
|
||||
rc, out = await _git("add", "--", f, cwd=cwd)
|
||||
if rc != 0:
|
||||
return f"git add '{f}' failed: {out}"
|
||||
else:
|
||||
rc, out = await _git("add", "-A", cwd=cwd)
|
||||
if rc != 0:
|
||||
return f"git add -A failed: {out}"
|
||||
|
||||
# Check that something is actually staged
|
||||
rc, staged = await _git("diff", "--cached", "--stat", cwd=cwd)
|
||||
if not staged.strip():
|
||||
return "Nothing staged to commit — working tree already clean."
|
||||
|
||||
rc, out = await _git("commit", "-m", message, cwd=cwd)
|
||||
if rc != 0:
|
||||
return f"git commit failed: {out}"
|
||||
return out or "Committed successfully."
|
||||
|
||||
|
||||
async def git_push(project: str = "", remote: str = "origin", branch: str = "") -> str:
|
||||
"""Push the current branch to a remote."""
|
||||
cwd = _resolve_project(project)
|
||||
if not cwd.is_dir():
|
||||
return f"Error: project directory not found: {cwd}"
|
||||
|
||||
args = ["push", remote]
|
||||
if branch:
|
||||
args.append(branch)
|
||||
|
||||
rc, out = await _git(*args, cwd=cwd, timeout=30)
|
||||
if rc != 0:
|
||||
return f"git push failed: {out}"
|
||||
return out or f"Pushed to {remote} successfully."
|
||||
|
||||
|
||||
# ── Declarations ───────────────────────────────────────────────────────────────
|
||||
|
||||
DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="git_status",
|
||||
description=(
|
||||
"Show the current git working tree status for the Cortex project: "
|
||||
"staged changes, unstaged modifications, and untracked files. "
|
||||
"Use to check whether there are uncommitted changes before restarting or deploying."
|
||||
"Show the working tree status for a project: staged changes, unstaged "
|
||||
"modifications, and untracked files. Use before committing to see what "
|
||||
"will be included. Defaults to the Cortex project."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={},
|
||||
properties={"project": _PROJECT_PARAM},
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="git_log",
|
||||
description=(
|
||||
"Show recent git commit history for the Cortex project. "
|
||||
"Returns commit hashes, dates, and messages. "
|
||||
"Optionally filter to a specific file or directory path."
|
||||
"Show recent commit history for a project. Returns commit hashes, dates, "
|
||||
"and messages. Use after aider_run completes to see what was committed. "
|
||||
"Defaults to the Cortex project."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
@@ -119,30 +212,32 @@ DECLARATIONS = [
|
||||
),
|
||||
"oneline": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description="Use compact one-line format (default true). Set false for more detail.",
|
||||
description="Compact one-line format (default true). False for more detail.",
|
||||
),
|
||||
"project": _PROJECT_PARAM,
|
||||
},
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="git_diff",
|
||||
description=(
|
||||
"Show a git diff for the Cortex project. "
|
||||
"With no arguments: shows unstaged working tree changes vs HEAD. "
|
||||
"With ref_a only: shows changes between that ref and HEAD. "
|
||||
"With ref_a and ref_b: shows changes between the two refs (commits, branches, or tags). "
|
||||
"Use stat_only to get a summary of changed files instead of full patch output."
|
||||
"Show a git diff for a project. "
|
||||
"With no arguments: unstaged working tree changes vs HEAD. "
|
||||
"With ref_a only: changes between that ref and HEAD. "
|
||||
"With ref_a and ref_b: changes between the two refs. "
|
||||
"Use after aider_run (auto_commit=False) to review changes before committing. "
|
||||
"Defaults to the Cortex project."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"ref_a": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="First ref (commit hash, branch name, or tag). Omit for working tree diff.",
|
||||
description="First ref (commit hash, branch, or tag). Omit for working tree diff.",
|
||||
),
|
||||
"ref_b": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Second ref. When provided with ref_a, shows diff between the two.",
|
||||
description="Second ref. With ref_a, shows diff between the two.",
|
||||
),
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
@@ -150,7 +245,58 @@ DECLARATIONS = [
|
||||
),
|
||||
"stat_only": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description="Return only a file-change summary (--stat) instead of the full diff",
|
||||
description="Return only a file-change summary (--stat) instead of the full patch",
|
||||
),
|
||||
"project": _PROJECT_PARAM,
|
||||
},
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="git_commit",
|
||||
description=(
|
||||
"Stage files and create a git commit in a project. "
|
||||
"Use after reviewing changes with git_diff — especially when aider_run ran "
|
||||
"with auto_commit=False. Stages all changes by default (files=None). "
|
||||
"ADMIN ONLY. Requires confirmation."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"message": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Commit message. Follow the project's commit style (e.g. 'feat: ...').",
|
||||
),
|
||||
"project": _PROJECT_PARAM,
|
||||
"files": types.Schema(
|
||||
type=types.Type.ARRAY,
|
||||
items=types.Schema(type=types.Type.STRING),
|
||||
description=(
|
||||
"Specific files to stage (paths relative to project root). "
|
||||
"Omit to stage all changes (git add -A)."
|
||||
),
|
||||
),
|
||||
},
|
||||
required=["message"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="git_push",
|
||||
description=(
|
||||
"Push the current branch to a remote. "
|
||||
"Use after git_commit or after aider_run commits to share the changes. "
|
||||
"ADMIN ONLY. Requires confirmation."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"project": _PROJECT_PARAM,
|
||||
"remote": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Remote name (default: 'origin')",
|
||||
),
|
||||
"branch": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Branch to push. Omit to push the current tracking branch.",
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -343,17 +343,18 @@ and lint commands; model/key come from env vars (not committed).
|
||||
`providers.anthropic.credentials`; `host_label` param for explicit host selection;
|
||||
auto-prefixes model with `openai/` for generic endpoints — 2026-06-03
|
||||
- [x] **`.gitignore`** — added `.aider.chat.history.md`, `.aider.input.history`, `.aider.llm.history` — 2026-05-23
|
||||
- [ ] Specialist agent: frontend (SvelteKit) code changes
|
||||
- [ ] Specialist agent: backend (FastAPI) code changes
|
||||
- [ ] Supervisor agent: diff review, syntax check, test runner
|
||||
- [x] **Multi-project git tools** — `git_status`, `git_log`, `git_diff` now accept a
|
||||
`project` param using same aliases as `aider_run` (cortex/aether_api/aether_frontend/
|
||||
aether_container); default is Cortex for backward compat — 2026-06-17
|
||||
- [x] **`git_commit(message, project, files)`** — stages files (or all changes) and
|
||||
creates a commit; admin-only, confirm-required, high risk — 2026-06-17
|
||||
- [x] **`git_push(project, remote, branch)`** — pushes current branch to remote;
|
||||
admin-only, confirm-required, high risk — 2026-06-17
|
||||
- [x] **`.aider.conf.yml` for `aether_api`** — py_compile lint, ARCH docs as read-only
|
||||
context — 2026-06-17
|
||||
- [x] **`.aider.conf.yml` for `aether_frontend`** — CLAUDE.md as context; svelte-check
|
||||
noted as project-wide manual step (no per-file lint-cmd) — 2026-06-17
|
||||
- [ ] Gitea webhook integration: trigger on push/PR, report back
|
||||
- [ ] Human approval gate before commit
|
||||
- [ ] `.aider.conf.yml` for aether_api, aether_frontend, aether_container projects
|
||||
|
||||
### [Intelligence] Supervisor agent
|
||||
- Runs `py_compile`, `svelte-check`, unit tests after specialist agent work
|
||||
- Reports pass/fail back to orchestrator
|
||||
- Only commits on explicit approval
|
||||
|
||||
### [Channel] Gitea webhooks
|
||||
- Receive push/PR/issue events → route to appropriate agent
|
||||
|
||||
Reference in New Issue
Block a user