File size: 28,289 Bytes
c61f7ed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 | """Coding-context awareness: the single place that decides "are we coding?".
In a code workspace on an interactive surface Hermes adopts a **coding posture**: a
frozen :class:`RuntimeMode` built from a :class:`ContextProfile` (pure data). The
system prompt reads ``system_prompt_parts()``; the toolset collapses ONLY under opt-in
``focus`` (never strips a user-enabled toolset). ``agent.coding_context``: ``auto``
(default, prompt-only) / ``focus`` / ``on`` / ``off``. Resolved once, immutable; the
workspace snapshot is probed once per session and replayed through
``system_prompt_parts(workspace_block=...)`` on every later build (cache safety).
"""
from __future__ import annotations
import json
import logging
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from hermes_cli._subprocess_compat import bounded_git_probe
logger = logging.getLogger("hermes.coding_context")
CODING_TOOLSET = "coding"
# Surfaces where ``auto`` may adopt the posture; messaging platforms are deliberately absent.
INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""}
# Project-root signals (cheap filename checks) marking a code workspace even without git.
_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",
)
# Agent-instruction files surfaced separately from manifests in the snapshot.
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
# Extensions that make a manifest-less git repo a *code* workspace (a `git init` notes folder is not).
_CODE_EXTENSIONS = frozenset({
".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".go", ".rs", ".java", ".kt", ".kts",
".scala", ".rb", ".php", ".c", ".h", ".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs",
".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl", ".hs", ".clj", ".erl", ".pl",
})
_CODE_SCAN_SKIP_DIRS = frozenset({
".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build", "target", ".next", ".turbo", "vendor",
})
# Bounded sweep: a code workspace reveals itself in the first handful of entries.
_CODE_SCAN_MAX_ENTRIES = 500
# Lockfile β package manager, checked in priority order.
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
_JS_LOCKFILES = (
("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"), ("yarn.lock", "yarn"), ("package-lock.json", "npm"),
)
# package.json scripts / Makefile targets worth surfacing as verify commands.
_VERIFY_TARGETS = ("test", "tests", "lint", "typecheck", "check", "build", "fmt", "format")
_MAX_VERIFY_COMMANDS = 8
_MAX_FACT_FILE_BYTES = 256 * 1024
_GIT_TIMEOUT = 2.5
# Per-model edit-format steering toward the `patch` mode each family was trained on (unknown
# families get nothing). GPT/Codex get V4A for ALL edits incl. single-file: codex-rs ships
# apply_patch as its ONLY editor. Anthropic and most open-weight coders were RL'd on str_replace
# editors. Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
"patch": (
("gpt", "codex"),
"- Edit format: author new files with `write_file`; for edits to "
"existing code use `patch` with `mode='patch'` (V4A diff) β including "
"single-file edits. It's the edit format you handle most reliably.",
),
"replace": (
("claude", "sonnet", "opus", "haiku",
"gemini", "gemma", "deepseek", "qwen", "kimi", "glm", "grok",
"hermes", "llama", "mistral", "devstral", "minimax"),
"- Edit format: author new files with `write_file`; for edits to "
"existing code prefer `patch` in `mode='replace'` β match a unique "
"snippet and swap it. Reach for `mode='patch'` (V4A) only when an edit "
"genuinely spans several files at once.",
),
}
# Operating brief. Tool names referenced here are in the coding toolset and _HERMES_CORE_TOOLS.
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"
"- Batch independent lookups: when several reads/searches don't depend on "
"each other, issue them together in one turn instead of one at a time.\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 β no drive-by refactors, renames, or reformatting "
"β 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. If the same region "
"fails twice, rewrite the enclosing function or file with `write_file` "
"instead of attempting a third 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"
"- Terminal state persists across calls: current directory and exported "
"environment variables carry forward. Activate a virtualenv or export setup "
"vars once, then reuse that state instead of re-sourcing it before every "
"test command.\n"
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
"paths for the same flaw and fix the class, not just the reported site.\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_list`. 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, and never read, print, or commit secrets β leave `.env` and "
"credential files alone unless the user explicitly asks. 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."
)
_TODO_SENTENCE = (
"- Track multi-step work with `todo_list`. Reference code as "
"`path:line` instead of pasting whole files."
)
_NO_TODO_SENTENCE = "- Reference code as `path:line` instead of pasting whole files."
# Clearly non-coding skill categories (deny-list; coding-adjacent and custom ones keep full entries).
_NON_CODING_SKILL_CATEGORIES = (
"apple", "communication", "cooking", "creative", "email", "finance", "gaming", "gifs", "health", "media",
"music", "note-taking", "productivity", "shopping", "smart-home", "social-media", "travel", "yuanbao",
)
_MODE_ALIASES = {
**dict.fromkeys(("focus", "strict", "lean"), "focus"),
**dict.fromkeys(("on", "true", "yes", "1", "always"), "on"),
**dict.fromkeys(("off", "false", "no", "0", "never"), "off"),
}
@dataclass(frozen=True)
class ContextProfile:
"""A named operating posture (pure data). ``toolset``: collapse target under ``focus``
(``None`` keeps the platform default). ``compact_skill_categories``: DEMOTED to
names-only under ``focus`` β deny-list, never hidden, so recall keeps working."""
name: str
toolset: Optional[str] = None
guidance: str = ""
model_hint: Optional[str] = None
compact_skill_categories: tuple[str, ...] = ()
GENERAL_PROFILE = ContextProfile(name="general")
CODING_PROFILE = ContextProfile(
name="coding", toolset=CODING_TOOLSET, guidance=CODING_AGENT_GUIDANCE, model_hint="coding",
compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
)
# ββ Detection helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _model_family(model: Optional[str]) -> Optional[str]:
"""Edit-format family key for a model id, or ``None`` (neutral wording applies)."""
lowered = (model or "").lower()
for family, (needles, _line) in _EDIT_FORMAT_GUIDANCE.items():
if lowered and any(n in lowered for n in needles):
return family
return None
def _agent_config_value(config: Optional[dict[str, Any]], key: str, default: Any, *, readonly: bool) -> Any:
"""``config["agent"][key]``, loading config when none was passed."""
if config is None:
try:
from hermes_cli.config import load_config, load_config_readonly
config = load_config_readonly() if readonly else load_config()
except Exception:
config = {}
return ((config or {}).get("agent", {}) or {}).get(key, default)
def _coding_mode(config: Optional[dict[str, Any]]) -> str:
"""Normalized ``agent.coding_context`` mode (auto/focus/on/off)."""
raw = _agent_config_value(config, "coding_context", "auto", readonly=True)
return _MODE_ALIASES.get(str(raw).strip().lower(), "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()
return next((p for p in (current, *current.parents) if (p / ".git").exists()), None)
def _home() -> Optional[Path]:
try:
return Path.home().resolve()
except (OSError, RuntimeError):
return None
def _marker_root(cwd: Path) -> Optional[Path]:
"""Nearest ancestor (β€6 levels) that looks like a project root, or ``None``. ``$HOME``
and the shared temp root are skipped: a Makefile/AGENTS.md in the home dir is global
config, and a stray manifest in /tmp must not flip every session under it."""
current = cwd.resolve()
try:
temp_root = Path(tempfile.gettempdir()).resolve()
except Exception:
temp_root = None
skip = (_home(), temp_root)
for parent in (current, *current.parents)[:7]:
if parent not in skip and any((parent / marker).exists() for marker in _PROJECT_MARKERS):
return parent
return None
def _has_code_files(root: Path) -> bool:
"""Bounded check for source files in the root and its immediate subdirs."""
seen = 0
stack = [(root, True)]
while stack:
directory, is_root = stack.pop()
try:
entries = os.scandir(directory)
except OSError:
continue
with entries:
for entry in entries:
seen += 1
if seen > _CODE_SCAN_MAX_ENTRIES:
return False
try:
if entry.is_file():
if os.path.splitext(entry.name)[1].lower() in _CODE_EXTENSIONS:
return True
elif is_root and entry.is_dir() and entry.name not in _CODE_SCAN_SKIP_DIRS and not entry.name.startswith("."):
stack.append((Path(entry.path), False))
except OSError:
continue
return False
def _detect_profile(mode: str, platform: str, cwd: Path) -> ContextProfile:
"""``auto``/``focus``: coding when the surface is interactive AND the cwd is a code
workspace (project root, or a git repo that actually holds code; a repo rooted at
``$HOME`` is NOT a signal). ``on``/``off`` force. Not memoized: one gateway serves many cwds."""
if mode == "off":
return GENERAL_PROFILE
if mode == "on":
return CODING_PROFILE
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
return GENERAL_PROFILE
if _marker_root(cwd) is not None:
return CODING_PROFILE
git_root = _git_root(cwd)
if git_root is not None and git_root != _home() and _has_code_files(git_root):
return CODING_PROFILE
return GENERAL_PROFILE
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
"""Names of MCP servers the user has enabled β kept in the coding posture."""
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 []
# ββ RuntimeMode (the seam) ββββββββββββββββββββββββββββββββββββββββββββββββββ
@dataclass(frozen=True)
class RuntimeMode:
"""The resolved operating posture for a session; immutable, built once via
:func:`resolve_runtime_mode` and never re-resolved mid-session (prompt cache).
``config_mode``: normalized ``agent.coding_context`` (toolset collapse gated on
``focus``); ``model``: steers edit-format guidance only; ``instructions``:
``agent.coding_instructions``."""
profile: ContextProfile
surface: str
cwd: Path
config_mode: str = "auto"
model: Optional[str] = None
instructions: str = ""
@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 (only under ``focus``), or ``None`` to keep the platform default. Callers
apply it only when the user hasn't pinned a selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``)."""
if self.config_mode != "focus" or self.profile.toolset is None:
return None
return [self.profile.toolset, *_enabled_mcp_servers(config)]
def system_prompt_parts(self, valid_tool_names=None, workspace_block: Optional[str] = None) -> tuple[list[str], list[str], list[str]]:
"""Return (prefix, workspace, trailing) posture blocks in the historical flat order β
brief, snapshot, operator instructions β so prompt assembly can put a cache boundary
before the snapshot without changing persisted bytes. The brief carries the model-family
edit-format nudge (one cached string); ``valid_tool_names`` drops the ``todo_list``
sentence when that tool isn't loaded; operator instructions ride their own block so
the brief stays byte-stable. ``workspace_block`` replays a snapshot the caller already
pinned at session start (``""`` = no workspace) instead of re-running the git probe;
``None`` probes."""
if not self.is_coding:
return [], [], []
prefix: list[str] = []
if self.profile.guidance:
brief = self.profile.guidance
if valid_tool_names is not None and "todo_list" not in valid_tool_names:
brief = brief.replace(_TODO_SENTENCE, _NO_TODO_SENTENCE)
family = _model_family(self.model)
if family is not None:
brief = f"{brief}\n{_EDIT_FORMAT_GUIDANCE[family][1]}"
prefix.append(brief)
workspace = build_coding_workspace_block(self.cwd) if workspace_block is None else workspace_block
trailing = [f"Operator instructions (from config):\n{self.instructions}"] if self.instructions else []
return prefix, [workspace] if workspace else [], trailing
def system_blocks(self) -> list[str]:
"""Posture blocks as one flat list in historical order."""
prefix, workspace, trailing = self.system_prompt_parts()
return [*prefix, *workspace, *trailing]
def compact_skill_categories(self) -> frozenset[str]:
"""Skill categories to demote to names-only in the skill index. Gated on ``focus``
like the toolset collapse (index changes under ``auto`` proved too surprising).
Demoted, never hidden: pruning caused silent capability loss."""
if not self.is_coding or self.config_mode != "focus":
return frozenset()
return frozenset(self.profile.compact_skill_categories)
def resolve_runtime_mode(
*, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None,
model: Optional[str] = None,
) -> RuntimeMode:
"""Resolve the operating posture once (a handful of ``stat`` calls) β the single entry
point every domain should call; the result is safe to hold for the session. ``model``
only steers edit-format guidance; ``agent.coding_instructions`` (str or list) becomes
the trailing block so a user can pin workflow rules without editing the shipped brief."""
resolved_cwd = _resolve_cwd(cwd)
mode = _coding_mode(config)
raw = _agent_config_value(config, "coding_instructions", "", readonly=False)
items = raw if isinstance(raw, (list, tuple)) else [raw or ""]
instructions = "\n".join(str(item).strip() for item in items if str(item).strip())
return RuntimeMode(
profile=_detect_profile(mode, (platform or "").strip().lower(), resolved_cwd),
surface=platform or "",
cwd=resolved_cwd,
config_mode=mode,
model=model,
instructions=instructions,
)
# ββ Functional API (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 ``focus`` and active)."""
return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).toolset_selection(config)
def coding_system_prompt_parts(
*, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None,
model: Optional[str] = None, valid_tool_names=None, workspace_block: Optional[str] = None,
) -> tuple[list[str], list[str], list[str]]:
"""Return coding prefix, workspace snapshot, and trailing guidance. ``workspace_block``
replays the caller's pinned session-start snapshot instead of probing git again."""
mode = resolve_runtime_mode(platform=platform, cwd=cwd, config=config, model=model)
return mode.system_prompt_parts(valid_tool_names=valid_tool_names, workspace_block=workspace_block)
def coding_compact_skill_categories(*, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None) -> frozenset[str]:
"""Skill categories the active posture demotes to names-only (empty outside ``focus``)."""
return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).compact_skill_categories()
# ββ git/workspace probe βββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _git(cwd: Path, *args: str) -> str:
"""``git -C <cwd> <args>`` β stripped stdout, or ``""`` on any failure. bounded_git_probe
bounds post-kill cleanup on Windows β plain ``subprocess.run(timeout=...)`` deadlocked
when a killed git left a suspended descendant holding the pipe handles.
See #66037.
"""
return bounded_git_probe(["git", "-C", str(cwd), *args], timeout=_GIT_TIMEOUT)
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.upstream")):
parts = line.split(maxsplit=2)
branch[parts[1].removeprefix("branch.")] = parts[-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]
counts["staged"] += xy[0] != "."
counts["modified"] += xy[1] != "."
elif line.startswith(("u ", "? ")):
counts["conflicts" if line[0] == "u" else "untracked"] += 1
return branch, counts
def _read_small(path: Path) -> str:
"""Read a small text file, or ``""`` β never raises, never reads huge files."""
try:
if not path.is_file() or path.stat().st_size > _MAX_FACT_FILE_BYTES:
return ""
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
@dataclass(frozen=True)
class ProjectFacts:
"""Structured project facts β exposed so non-prompt consumers (desktop verify UI) don't re-detect."""
manifests: list[str]
package_managers: list[str]
verify_commands: list[str]
context_files: list[str]
def detect_project_facts(root: Path) -> ProjectFacts:
"""Detect manifests, package manager(s), verify commands, context files (single source of truth)."""
verify: list[str] = []
if (root / "scripts" / "run_tests.sh").is_file():
verify.append("scripts/run_tests.sh")
if (root / "package.json").is_file():
try:
scripts = json.loads(_read_small(root / "package.json") or "{}").get("scripts") or {}
except (json.JSONDecodeError, AttributeError):
scripts = {}
js_pm = next((pm for lock, pm in _JS_LOCKFILES if (root / lock).is_file()), "npm")
verify.extend(f"{js_pm} run {name}" for name in _VERIFY_TARGETS if name in scripts)
if (root / "pytest.ini").is_file() or "[tool.pytest" in _read_small(root / "pyproject.toml"):
verify.append("pytest")
makefile = _read_small(root / "Makefile")
verify.extend(
f"make {name}" for name in _VERIFY_TARGETS
if makefile and re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
)
return ProjectFacts(
manifests=[m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()],
package_managers=list(dict.fromkeys(
pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
)),
verify_commands=list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS],
context_files=[c for c in _CONTEXT_FILES if (root / c).is_file()],
)
def _workspace_roots(cwd: Optional[str | Path]) -> tuple[Optional[Path], Optional[Path]]:
"""(git_root, workspace_root) for *cwd*; workspace root is git root else marker root."""
resolved = _resolve_cwd(cwd)
git_root = _git_root(resolved)
return git_root, git_root or _marker_root(resolved)
def project_facts_for(cwd: Optional[str | Path] = None) -> Optional[dict[str, Any]]:
"""Structured project facts for ``cwd`` (desktop verify UI) β ``None`` outside a workspace."""
_, root = _workspace_roots(cwd)
if root is None:
return None
f = detect_project_facts(root)
return {
"root": str(root),
"manifests": f.manifests,
"packageManagers": f.package_managers,
"verifyCommands": f.verify_commands,
"contextFiles": f.context_files,
}
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
"""Workspace snapshot for the system prompt (empty outside a workspace): git state when
in a repo, plus project facts β so marker-only (non-git) projects still get one."""
git_root, root = _workspace_roots(cwd)
if root is None:
return ""
lines = [
"Workspace (snapshot at session start β re-check with `git` before acting on it):",
f"- Root: {root}",
]
if git_root is not None:
branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch"))
head = branch.get("head", "")
if head == "(detached)":
lines.append("- Branch: (detached HEAD)")
elif head:
ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0")
upstream = f" \u2192 {branch['upstream']}" if branch.get("upstream") else ""
ab = f" (ahead {ahead}, behind {behind})" if upstream and (ahead, behind) != ("0", "0") else ""
lines.append(f"- Branch: {head}{upstream}{ab}")
# Linked worktree: say so (branches/stashes are shared state) but do NOT expose
# the primary tree path β a second absolute path makes the model run commands
# in the wrong directory.
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():
lines.append("- Worktree: linked (git state shared with primary tree)")
dirty = [f"{n} {label}" for label, n in counts.items() if n]
lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}")
recent = _git(root, "log", "-3", "--pretty=%h %s")
if recent:
lines.extend(["- Recent commits:", *(f" {c}" for c in recent.splitlines())])
f = detect_project_facts(root)
if f.manifests:
managers = f" ({'/'.join(f.package_managers)})" if f.package_managers else ""
lines.append(f"- Project: {', '.join(f.manifests[:6])}{managers}")
if f.verify_commands:
lines.append(f"- Verify: {'; '.join(f.verify_commands)}")
if f.context_files:
lines.append(f"- Context files: {', '.join(f.context_files)}")
return "\n".join(lines)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def coding_system_blocks(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
model: Optional[str] = None,
) -> list[str]:
"""Stable system-prompt blocks for the current posture (empty when general).
``model`` steers the brief's edit-format nudge toward the model's family.
"""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config, model=model
).system_blocks()
_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)
# ---- END PLUGIN-COMPAT ----
|