diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index 7c70e9f..169f829 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -83,9 +83,11 @@ from tools.agent_notes import ( agent_notes_clear as _agent_notes_clear, ) from tools.git import ( - git_status as _git_status, - git_log as _git_log, - git_diff as _git_diff, + 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", diff --git a/cortex/tools/git.py b/cortex/tools/git.py index 42597fc..a0674fa 100644 --- a/cortex/tools/git.py +++ b/cortex/tools/git.py @@ -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.", ), }, ), diff --git a/documentation/TODO__Agents.md b/documentation/TODO__Agents.md index c8a5c3d..b4f4fc0 100644 --- a/documentation/TODO__Agents.md +++ b/documentation/TODO__Agents.md @@ -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