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:
Scott Idem
2026-06-17 21:47:10 -04:00
parent 67f5db70a3
commit 66978734b4
3 changed files with 212 additions and 54 deletions

View File

@@ -83,9 +83,11 @@ from tools.agent_notes import (
agent_notes_clear as _agent_notes_clear, agent_notes_clear as _agent_notes_clear,
) )
from tools.git import ( from tools.git import (
git_status as _git_status, git_status as _git_status,
git_log as _git_log, git_log as _git_log,
git_diff as _git_diff, git_diff as _git_diff,
git_commit as _git_commit,
git_push as _git_push,
) )
from tools.agents import ( from tools.agents import (
spawn_agent as _spawn_agent, spawn_agent as _spawn_agent,
@@ -129,7 +131,7 @@ import tools.ae_database as _mod_ae_database
TOOL_CATEGORIES: dict[str, list[str]] = { TOOL_CATEGORIES: dict[str, list[str]] = {
"Web": ["web_search", "http_fetch", "web_read", "http_post"], "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"], "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"], "System Files": ["file_read", "file_list", "file_write", "session_read", "session_search"],
"Shell": ["shell_exec", "claude_allow_dir"], "Shell": ["shell_exec", "claude_allow_dir"],
"System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"], "System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"],
@@ -213,6 +215,8 @@ _CALLABLES: dict[str, callable] = {
"git_status": _git_status, "git_status": _git_status,
"git_log": _git_log, "git_log": _git_log,
"git_diff": _git_diff, "git_diff": _git_diff,
"git_commit": _git_commit,
"git_push": _git_push,
"spawn_agent": _spawn_agent, "spawn_agent": _spawn_agent,
"agent_status": _agent_status, "agent_status": _agent_status,
"agent_list": _agent_list, "agent_list": _agent_list,
@@ -245,6 +249,8 @@ TOOL_ROLES: dict[str, str] = {
"agent_list": "user", "agent_list": "user",
"agent_cancel": "admin", "agent_cancel": "admin",
"aider_run": "admin", "aider_run": "admin",
"git_commit": "admin",
"git_push": "admin",
"email_send": "admin", "email_send": "admin",
"nc_talk_send": "admin", "nc_talk_send": "admin",
"http_post": "admin", "http_post": "admin",
@@ -268,6 +274,8 @@ CONFIRM_REQUIRED: set[str] = {
"ae_journal_entry_disable", # disables a journal entry — not easily reversed "ae_journal_entry_disable", # disables a journal entry — not easily reversed
"agent_cancel", # kills a running background task "agent_cancel", # kills a running background task
"aider_run", # edits files and commits — irreversible without git revert "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. # 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_append": "low",
"agent_notes_clear": "low", "agent_notes_clear": "low",
# Git — all read-only inspections # Git — reads are low; commit/push affect repo history
"git_status": "low", "git_status": "low",
"git_log": "low", "git_log": "low",
"git_diff": "low", "git_diff": "low",
"git_commit": "high",
"git_push": "high",
# Agents — spawning is high; lifecycle reads are low; cancel is medium (kills a task) # Agents — spawning is high; lifecycle reads are low; cancel is medium (kills a task)
"spawn_agent": "high", "spawn_agent": "high",
@@ -586,7 +596,7 @@ CATEGORY_TOOL_MAP: dict[str, list[str]] = {
"web_post": ["http_post"], "web_post": ["http_post"],
"file": ["project_file_read", "project_file_list", "file_stat", "file_grep", "file": ["project_file_read", "project_file_list", "file_stat", "file_grep",
"file_diff", "file_syntax_check", "file_read", "file_list", "file_write"], "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"], "system": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update", "shell_exec"],
"tasks": ["task_list", "task_create", "task_update", "task_complete"], "tasks": ["task_list", "task_create", "task_update", "task_complete"],
"cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"], "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"], "web_post": ["post to", "send to", "webhook", "trigger webhook"],
"file": ["read file", "show file", "list file", "directory", "grep", "file": ["read file", "show file", "list file", "directory", "grep",
"search in", "find in", "diff", "compare", "syntax check", "open file"], "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", "system": ["restart", "update", "status", "logs", "log", "deploy", "run command",
"shell", "is it running", "health"], "shell", "is it running", "health"],
"tasks": ["task", "todo", "to-do", "to do", "add task", "create task", "tasks": ["task", "todo", "to-do", "to do", "add task", "create task",

View File

@@ -1,27 +1,62 @@
""" """
Git inspection tools — project-scoped, read-only. Git tools — project-scoped.
git_status — working tree status (staged, unstaged, untracked changes) Read-only inspection:
git_log — recent commit history with optional path filter git_status — working tree status
git_diffdiff between commits, branches, or working tree vs HEAD 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 asyncio
import logging import logging
import os
from pathlib import Path from pathlib import Path
from google.genai import types from google.genai import types
logger = logging.getLogger(__name__) 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 _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( proc = await asyncio.create_subprocess_exec(
"git", "-C", str(_PROJECT_ROOT), *args, "git", "-C", str(cwd), *args,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=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" return 1, "git command timed out"
out = (stdout or b"").decode(errors="replace").strip() out = (stdout or b"").decode(errors="replace").strip()
err = (stderr or b"").decode(errors="replace").strip() err = (stderr or b"").decode(errors="replace").strip()
combined = out if out else err return proc.returncode, (out if out else err)
return proc.returncode, combined
def _cap(text: str) -> str: def _cap(text: str) -> str:
@@ -42,16 +76,24 @@ def _cap(text: str) -> str:
return text return text
async def git_status() -> str: # ── Read-only tools ────────────────────────────────────────────────────────────
"""Return the current git working tree status."""
rc, out = await _git("status") 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: if rc != 0:
return f"git status failed: {out}" return f"git status failed: {out}"
return out or "Working tree clean — nothing to report." return out or "Working tree clean — nothing to report."
async def git_log(n: int = 20, path: str = "", oneline: bool = True) -> str: async def git_log(n: int = 20, path: str = "", oneline: bool = True, project: str = "") -> str:
"""Return recent git commit history.""" """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"] args = ["log"]
if oneline: if oneline:
args += ["--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))}"] args += [f"-{max(1, min(n, 200))}"]
if path: if path:
args += ["--", path] args += ["--", path]
rc, out = await _git(*args) rc, out = await _git(*args, cwd=cwd)
if rc != 0: if rc != 0:
return f"git log failed: {out}" return f"git log failed: {out}"
return _cap(out) or "No commits found." return _cap(out) or "No commits found."
async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only: bool = False) -> str: async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only: bool = False, project: str = "") -> str:
"""Show a git diff. Defaults to working tree vs HEAD (unstaged changes).""" """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"] args = ["diff"]
if stat_only: if stat_only:
args += ["--stat"] args += ["--stat"]
@@ -77,34 +122,82 @@ async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only:
args += [ref_a] args += [ref_a]
if path: if path:
args += ["--", path] args += ["--", path]
rc, out = await _git(*args) rc, out = await _git(*args, cwd=cwd)
# diff exits 1 when there are differences — that's normal # diff exits 1 when differences exist — normal
if rc not in (0, 1): if rc not in (0, 1):
return f"git diff failed: {out}" return f"git diff failed: {out}"
return _cap(out) or "No differences found." 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 = [ DECLARATIONS = [
types.FunctionDeclaration( types.FunctionDeclaration(
name="git_status", name="git_status",
description=( description=(
"Show the current git working tree status for the Cortex project: " "Show the working tree status for a project: staged changes, unstaged "
"staged changes, unstaged modifications, and untracked files. " "modifications, and untracked files. Use before committing to see what "
"Use to check whether there are uncommitted changes before restarting or deploying." "will be included. Defaults to the Cortex project."
), ),
parameters=types.Schema( parameters=types.Schema(
type=types.Type.OBJECT, type=types.Type.OBJECT,
properties={}, properties={"project": _PROJECT_PARAM},
), ),
), ),
types.FunctionDeclaration( types.FunctionDeclaration(
name="git_log", name="git_log",
description=( description=(
"Show recent git commit history for the Cortex project. " "Show recent commit history for a project. Returns commit hashes, dates, "
"Returns commit hashes, dates, and messages. " "and messages. Use after aider_run completes to see what was committed. "
"Optionally filter to a specific file or directory path." "Defaults to the Cortex project."
), ),
parameters=types.Schema( parameters=types.Schema(
type=types.Type.OBJECT, type=types.Type.OBJECT,
@@ -119,30 +212,32 @@ DECLARATIONS = [
), ),
"oneline": types.Schema( "oneline": types.Schema(
type=types.Type.BOOLEAN, 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( types.FunctionDeclaration(
name="git_diff", name="git_diff",
description=( description=(
"Show a git diff for the Cortex project. " "Show a git diff for a project. "
"With no arguments: shows unstaged working tree changes vs HEAD. " "With no arguments: unstaged working tree changes vs HEAD. "
"With ref_a only: shows changes between that ref and HEAD. " "With ref_a only: changes between that ref and HEAD. "
"With ref_a and ref_b: shows changes between the two refs (commits, branches, or tags). " "With ref_a and ref_b: changes between the two refs. "
"Use stat_only to get a summary of changed files instead of full patch output." "Use after aider_run (auto_commit=False) to review changes before committing. "
"Defaults to the Cortex project."
), ),
parameters=types.Schema( parameters=types.Schema(
type=types.Type.OBJECT, type=types.Type.OBJECT,
properties={ properties={
"ref_a": types.Schema( "ref_a": types.Schema(
type=types.Type.STRING, 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( "ref_b": types.Schema(
type=types.Type.STRING, 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( "path": types.Schema(
type=types.Type.STRING, type=types.Type.STRING,
@@ -150,7 +245,58 @@ DECLARATIONS = [
), ),
"stat_only": types.Schema( "stat_only": types.Schema(
type=types.Type.BOOLEAN, 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.",
), ),
}, },
), ),

View File

@@ -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; `providers.anthropic.credentials`; `host_label` param for explicit host selection;
auto-prefixes model with `openai/` for generic endpoints — 2026-06-03 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 - [x] **`.gitignore`** — added `.aider.chat.history.md`, `.aider.input.history`, `.aider.llm.history` — 2026-05-23
- [ ] Specialist agent: frontend (SvelteKit) code changes - [x] **Multi-project git tools**`git_status`, `git_log`, `git_diff` now accept a
- [ ] Specialist agent: backend (FastAPI) code changes `project` param using same aliases as `aider_run` (cortex/aether_api/aether_frontend/
- [ ] Supervisor agent: diff review, syntax check, test runner 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 - [ ] 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 ### [Channel] Gitea webhooks
- Receive push/PR/issue events → route to appropriate agent - Receive push/PR/issue events → route to appropriate agent