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 ----