"""Coding-context awareness — base Hermes, every interactive surface. When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or an editor over ACP), Hermes shifts into a **coding posture**. This module is the single place that decides whether we're in that posture and what it implies, so the rest of the codebase never re-derives "are we coding?" on its own. Architecture — one seam, many consumers ---------------------------------------- The posture is modelled as a frozen :class:`RuntimeMode` selected from a small :class:`ContextProfile` registry (today: ``coding`` and ``general``). A profile is *data* — it declares the toolset to collapse to, the operating brief to inject, and hints for other domains (model routing, memory, subagents). Every domain reads the same resolved object instead of probing git/config itself: * **System prompt** — ``RuntimeMode.system_blocks()`` → the operating brief + a live git/workspace snapshot (``agent/system_prompt.py``). * **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only under the opt-in ``focus`` mode: the default posture is prompt-only and never touches the user's configured toolsets (toolsets like messaging / smart-home / music are off-by-default anyway, and someone who explicitly enabled image-gen or Spotify shouldn't lose it for being in a git repo). * **Delegation** — subagents inherit the parent's toolset and run through the same prompt builder, so the coding posture propagates to children for free. * **Model / memory / compression** — declared on the profile (``model_hint``, ``memory_policy``) as the extension seam; consumers read ``mode.profile`` rather than re-deciding. Cache safety ------------ The mode is resolved **once** and is immutable. The workspace snapshot is built once at prompt-build time and baked into the *stable* system-prompt tier — never re-probed per turn (that would shatter the prompt cache). Branch and dirty state drift mid-session, so the brief tells the model to re-check with ``git`` before acting on the snapshot. A ``/coding`` flip therefore only takes effect next session (deferred), the same contract as ``/skills install`` vs ``--now``. Activation (config ``agent.coding_context``): * ``auto`` (default) — posture (brief + snapshot) on an interactive coding surface sitting in a code workspace (git repo or recognised project root). Prompt-only; toolsets untouched. * ``focus`` — like ``auto``, but additionally collapses the toolset to the ``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema. * ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only. * ``off`` — disable entirely. """ from __future__ import annotations import logging import os import subprocess from dataclasses import dataclass from pathlib import Path from typing import Any, Optional logger = logging.getLogger("hermes.coding_context") CODING_TOOLSET = "coding" # Surfaces where a coding posture makes sense under ``auto``. Messaging # platforms (telegram, discord, slack, …) are intentionally absent — a chat bot # in a group is not pair-programming. INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""} # Project-root signals that mark a directory as a code workspace even when it # isn't (yet) a git repo. Cheap filename checks — no parsing. _PROJECT_MARKERS = ( "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "package.json", "tsconfig.json", "deno.json", "Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts", "Gemfile", "composer.json", "mix.exs", "pubspec.yaml", "CMakeLists.txt", "Makefile", "Dockerfile", "AGENTS.md", "CLAUDE.md", ".cursorrules", ) _GIT_TIMEOUT = 2.5 # Operating brief for the coding posture. Tool names referenced here (read_file, # search_files, patch, write_file, terminal, todo) are in the coding toolset and # in _HERMES_CORE_TOOLS, so they're present on every surface this fires on. CODING_AGENT_GUIDANCE = ( "You are a coding agent pairing with the user inside their codebase. " "Operate like a careful senior engineer.\n" "\n" "Gather context first:\n" "- Read the relevant files with `read_file` and locate code with " "`search_files` before changing anything. Trace a symbol to its definition " "and usages rather than guessing its shape.\n" "- Never invent files, symbols, APIs, or imports. If you haven't seen it in " "the repo, go look. Don't assume a library is available — check the project " "manifest (pyproject.toml / package.json / Cargo.toml / go.mod) and how " "neighbouring files import it.\n" "\n" "Make changes through the tools, not the chat:\n" "- Edit with `patch`/`write_file`. Do NOT print code blocks to the user as " "a substitute for editing — apply the change, then summarise it. Only show " "code when the user explicitly asks to see it.\n" "- Match the project's existing style and conventions; AGENTS.md / " "CLAUDE.md / .cursorrules already in context win over your defaults. Touch " "only what the task needs, and add any imports/dependencies your code " "requires.\n" "- If an edit fails to apply, re-read the file to get the current exact " "contents before retrying — don't repeat a stale patch.\n" "\n" "Verify, and know when to stop:\n" "- Use `terminal` for git, builds, tests, and inspection. Run the relevant " "tests/linter/build and confirm they pass before claiming the work is done.\n" "- When fixing linter/type errors on a file, stop after about three " "attempts on the same file and ask the user rather than looping.\n" "- Track multi-step work with `todo`. Reference code as `path:line` instead " "of pasting whole files.\n" "\n" "Respect the user's repo: don't commit, push, or rewrite history unless " "asked. The Workspace block below is a snapshot from session start — re-run " "`git status`/`git branch` before relying on it. Be concise: lead with the " "change or answer, not a preamble." ) # ── Context profiles (declarative posture definitions) ────────────────────── @dataclass(frozen=True) class ContextProfile: """A named operating posture. Pure data — consumers read these fields. ``toolset`` — collapse to this toolset (+ enabled MCP) when no explicit selection is pinned; ``None`` keeps the platform default. ``guidance`` — operating brief injected into the stable system prompt; ``""`` injects nothing. ``model_hint`` — routing preference key for smart model routing (extension seam; not yet consumed by the router). ``memory_policy``— memory namespace/weighting hint (extension seam). """ name: str toolset: Optional[str] = None guidance: str = "" model_hint: Optional[str] = None memory_policy: str = "default" GENERAL_PROFILE = ContextProfile(name="general") CODING_PROFILE = ContextProfile( name="coding", toolset=CODING_TOOLSET, guidance=CODING_AGENT_GUIDANCE, model_hint="coding", memory_policy="project", ) _PROFILES: dict[str, ContextProfile] = { GENERAL_PROFILE.name: GENERAL_PROFILE, CODING_PROFILE.name: CODING_PROFILE, } def get_profile(name: str) -> ContextProfile: """Return a registered profile, falling back to ``general``.""" return _PROFILES.get(name, GENERAL_PROFILE) # ── Helpers ───────────────────────────────────────────────────────────────── def _coding_mode(config: Optional[dict[str, Any]]) -> str: """Return the normalized ``agent.coding_context`` mode (auto/focus/on/off).""" if config is None: try: from hermes_cli.config import load_config config = load_config() except Exception: config = {} raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto") mode = str(raw).strip().lower() if mode in {"focus", "strict", "lean"}: return "focus" if mode in {"on", "true", "yes", "1", "always"}: return "on" if mode in {"off", "false", "no", "0", "never"}: return "off" return "auto" def _resolve_cwd(cwd: Optional[str | Path]) -> Path: if cwd: return Path(cwd).expanduser() try: from agent.runtime_cwd import resolve_agent_cwd return resolve_agent_cwd() except Exception: return Path(os.getcwd()) def _git_root(cwd: Path) -> Optional[Path]: current = cwd.resolve() for parent in [current, *current.parents]: if (parent / ".git").exists(): return parent return None def _has_project_marker(cwd: Path) -> bool: """Cheap check: does cwd (or a parent up to the git root) look like a project? Walks up at most a few levels so a manifest in the workspace root counts even when the user is in a subdirectory. """ current = cwd.resolve() for depth, parent in enumerate([current, *current.parents]): if depth > 6: break for marker in _PROJECT_MARKERS: if (parent / marker).exists(): return True return False def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str: """Resolve which profile applies. ``auto``/``focus``: coding when the surface is interactive AND the cwd is a code workspace (a git repo or a recognised project root). ``on``: always coding. ``off``: always general. Detection is intentionally not memoized: it's a handful of ``stat`` calls, and callers resolve the mode once per session anyway. Caching here would risk a stale posture if a long-lived process (gateway/TUI) serves sessions from different working directories. """ if mode == "off": return GENERAL_PROFILE.name if mode == "on": return CODING_PROFILE.name if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS: return GENERAL_PROFILE.name cwd = Path(cwd_str) if _git_root(cwd) is not None or _has_project_marker(cwd): return CODING_PROFILE.name return GENERAL_PROFILE.name # ── RuntimeMode (the seam) ────────────────────────────────────────────────── @dataclass(frozen=True) class RuntimeMode: """The resolved operating posture for a session. Immutable by construction. Built once via :func:`resolve_runtime_mode` and consumed by every domain that cares about the coding/general distinction. Never mutate or re-resolve mid-session — that would break the prompt cache. """ profile: ContextProfile surface: str cwd: Path # The normalized ``agent.coding_context`` mode this posture was resolved # under (auto/focus/on/off). Toolset collapse is gated on ``focus``. config_mode: str = "auto" @property def kind(self) -> str: return self.profile.name @property def is_coding(self) -> bool: return self.profile.name == CODING_PROFILE.name def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional[list[str]]: """Toolset list for this posture, or ``None`` to keep the platform default. Non-``None`` only under the opt-in ``focus`` mode. The default posture is prompt-only: most strippable toolsets are off-by-default anyway, and a user who explicitly enabled one (image-gen for frontend/game assets, messaging for build notifications, …) keeps it while coding. Callers apply this only when the user hasn't pinned an explicit selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``, …); they never override a pin. Returns the profile's toolset plus enabled MCP servers. """ if self.config_mode != "focus": return None if self.profile.toolset is None: return None return [self.profile.toolset, *_enabled_mcp_servers(config)] def system_blocks(self) -> list[str]: """Stable system-prompt blocks for this posture (brief + workspace).""" if not self.is_coding: return [] blocks: list[str] = [] if self.profile.guidance: blocks.append(self.profile.guidance) workspace = build_coding_workspace_block(self.cwd) if workspace: blocks.append(workspace) return blocks def resolve_runtime_mode( *, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None, ) -> RuntimeMode: """Resolve the operating posture once. Cheap; detection is memoized. This is the single entry point every domain should call. The returned object is immutable and safe to cache for the session. """ resolved_cwd = _resolve_cwd(cwd) mode = _coding_mode(config) name = _detect_profile_name( mode, (platform or "").strip().lower(), str(resolved_cwd) ) return RuntimeMode( profile=get_profile(name), surface=platform or "", cwd=resolved_cwd, config_mode=mode, ) # ── Back-compat surface (thin wrappers over RuntimeMode) ──────────────────── def is_coding_context( *, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None, ) -> bool: """Whether Hermes should operate in its coding posture right now.""" return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).is_coding def coding_selection( *, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None, ) -> Optional[list[str]]: """Toolset selection for the coding posture. ``None`` unless the user opted into ``focus`` mode AND the posture is active — the default coding posture never overrides configured toolsets. """ return resolve_runtime_mode( platform=platform, cwd=cwd, config=config ).toolset_selection(config) def coding_system_blocks( *, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None, ) -> list[str]: """Stable system-prompt blocks for the current posture (empty when general).""" return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).system_blocks() def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]: """Names of MCP servers the user has enabled — kept in the coding posture. MCP servers (figma, browser, tophat, …) are explicitly configured and part of the coding workflow, not noise to strip. """ try: from hermes_cli.config import read_raw_config from hermes_cli.tools_config import _parse_enabled_flag servers = read_raw_config().get("mcp_servers") or {} return [ str(name) for name, cfg in servers.items() if isinstance(cfg, dict) and _parse_enabled_flag(cfg.get("enabled", True), default=True) ] except Exception: return [] # ── git/workspace probe ───────────────────────────────────────────────────── def _git(cwd: Path, *args: str) -> str: try: out = subprocess.run( ["git", "-C", str(cwd), *args], capture_output=True, text=True, timeout=_GIT_TIMEOUT, ) except (OSError, subprocess.SubprocessError): return "" return out.stdout.strip() if out.returncode == 0 else "" def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]: """Parse ``git status --porcelain=2 --branch`` into branch + counts.""" branch: dict[str, str] = {} counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0} for line in porcelain.splitlines(): if line.startswith("# branch.head"): branch["head"] = line.split(maxsplit=2)[-1] elif line.startswith("# branch.upstream"): branch["upstream"] = line.split(maxsplit=2)[-1] elif line.startswith("# branch.ab"): parts = line.split() branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-") elif line.startswith(("1 ", "2 ")): xy = line.split(maxsplit=2)[1] if xy[0] != ".": counts["staged"] += 1 if xy[1] != ".": counts["modified"] += 1 elif line.startswith("u "): counts["conflicts"] += 1 elif line.startswith("? "): counts["untracked"] += 1 return branch, counts def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str: """Live git/workspace snapshot for the system prompt (empty if not a repo).""" root = _git_root(_resolve_cwd(cwd)) if root is None: return "" lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"] lines.append(f"- Root: {root}") branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch")) head = branch.get("head", "") if head and head != "(detached)": line = f"- Branch: {head}" if branch.get("upstream"): line += f" \u2192 {branch['upstream']}" ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0") if ahead != "0" or behind != "0": line += f" (ahead {ahead}, behind {behind})" lines.append(line) elif head == "(detached)": lines.append("- Branch: (detached HEAD)") # Linked worktree: the per-worktree git dir differs from the shared common dir. git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir") if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve(): main_tree = Path(common_dir).resolve().parent lines.append(f"- Worktree: linked (primary tree at {main_tree})") dirty = [f"{n} {label}" for label, n in ( ("staged", counts["staged"]), ("modified", counts["modified"]), ("untracked", counts["untracked"]), ("conflicts", counts["conflicts"]), ) if n] lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}") recent = _git(root, "log", "-3", "--pretty=%h %s") if recent: lines.append("- Recent commits:") lines.extend(f" {c}" for c in recent.splitlines()) return "\n".join(lines)