File size: 40,501 Bytes
fbd5681
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
"""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 and the skill index untouched.

  * ``focus`` β€” like ``auto``, but additionally collapses the toolset to the

    ``coding`` set + enabled MCP servers and demotes non-coding skill

    categories to names-only in the prompt's skill index (no skill is ever

    hidden). 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 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 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",
)

# Agent-instruction files surfaced separately from manifests in the snapshot.
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")

# Source-file extensions that make a git repo a *code* workspace even with no
# manifest. Without this, `git init` on a notes/writing/research folder (a huge
# non-coding use case) would flip the whole session into the coding posture just
# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`).
_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",
})

# Dirs never worth scanning for the code check (deps/build/vcs/venv noise).
_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


def _has_code_files(root: Path) -> bool:
    """Cheap, bounded check for source files in a repo's top two levels.



    Lets a git repo of loose scripts (no manifest) still read as a code

    workspace while a bare notes/writing repo does not. Scans the root and its

    immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats β€”

    a handful of readdirs at session start, not a full walk.

    """
    seen = 0
    stack = [(root, True)]
    while stack:
        directory, is_root = stack.pop()
        try:
            with os.scandir(directory) as entries:
                for entry in entries:
                    seen += 1
                    if seen > _CODE_SCAN_MAX_ENTRIES:
                        return False
                    name = entry.name
                    try:
                        if entry.is_file():
                            if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS:
                                return True
                        elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."):
                            stack.append((Path(entry.path), False))
                    except OSError:
                        continue
        except OSError:
            continue
    return False

# 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. Matching the edit tool format to how a model
# was trained reduces mistakes and wasted reasoning (OpenAI/Codex handle
# patch-style diffs best; Anthropic models β€” and most open-weight coding
# models, whose RL scaffolds use str_replace-style editors β€” do best with
# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
# its native format. Unknown families get nothing (the brief's neutral wording
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
#
# GPT/Codex get V4A for ALL edits, single-file included: in codex-rs,
# apply_patch (V4A β€” apply_patch.lark) is the ONLY file editor, no
# str_replace-style tool exists, and the shipped model prompts say to use
# apply_patch even "for single file edits" β€” so a replace-mode nudge would
# steer those models toward a format their first-party harness never taught
# them.
_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.",
    ),
}


def _model_family(model: Optional[str]) -> Optional[str]:
    """Classify a model id into an edit-format family key, or ``None``.



    Used to steer the coding posture toward the edit tool format a model was

    trained on. Family-agnostic by design: an unrecognised model gets ``None``

    and the operating brief's neutral edit wording applies.

    """
    if not model:
        return None
    lowered = model.lower()
    for family, (needles, _line) in _EDIT_FORMAT_GUIDANCE.items():
        if any(n in lowered for n in needles):
            return family
    return None


def _edit_format_line(model: Optional[str]) -> str:
    """The edit-format guidance line for this model's family (``""`` if none)."""
    family = _model_family(model)
    if family is None:
        return ""
    return _EDIT_FORMAT_GUIDANCE[family][1]


# 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"
    "- 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`. 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."
)


# ── 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).

    ``compact_skill_categories`` β€” skill categories DEMOTED to names-only in

                       the system-prompt skill index under the opt-in ``focus``

                       mode. Never hidden: every skill name stays visible

                       (so memory-anchored recall keeps working) β€” only the

                       descriptions are dropped to cut index noise. Deny-list

                       semantics so unknown/custom categories keep full

                       entries.

    """

    name: str
    toolset: Optional[str] = None
    guidance: str = ""
    model_hint: Optional[str] = None
    memory_policy: str = "default"
    compact_skill_categories: tuple[str, ...] = ()


# Skill categories that are clearly not part of a coding workflow. Demoted to
# names-only in the prompt's skill index under the opt-in ``focus`` mode only
# (deny-list β€” anything not listed here, incl. custom user categories, keeps
# full entries). Coding-adjacent categories (devops, github, mcp,
# data-science, diagramming, research, security, …) are intentionally absent.
_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",
)


GENERAL_PROFILE = ContextProfile(name="general")
CODING_PROFILE = ContextProfile(
    name="coding",
    toolset=CODING_TOOLSET,
    guidance=CODING_AGENT_GUIDANCE,
    model_hint="coding",
    memory_policy="project",
    compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
)

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

            config = load_config_readonly()
        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 _coding_instructions(config: Optional[dict[str, Any]]) -> str:
    """Standing operator instructions for the coding posture (config).



    ``agent.coding_instructions`` β€” a string or list of strings appended to the

    coding brief as an extra stable system block, so a user can pin project-wide

    coding-workflow rules (e.g. "for UI work don't run tsc/lint until I approve;

    clean the diff before committing") without editing the shipped brief.

    Cache-safe: resolved once per session into the stable system-prompt tier,

    like the rest of the posture.

    """
    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_instructions", "")
    if isinstance(raw, (list, tuple)):
        return "\n".join(str(item).strip() for item in raw if str(item).strip())
    return str(raw or "").strip()


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 _home() -> Optional[Path]:
    try:
        return Path.home().resolve()
    except (OSError, RuntimeError):
        return None


def _marker_root(cwd: Path) -> Optional[Path]:
    """Nearest ancestor that looks like a project root, or ``None``.



    Walks up at most a few levels so a manifest in the workspace root counts

    even when the user is in a subdirectory. ``$HOME`` itself is skipped β€” a

    Makefile or AGENTS.md sitting in the home directory is global user config,

    not a project-root signal.

    """
    current = cwd.resolve()
    home = _home()
    # Shared world-writable temp roots are never project roots: a stray
    # manifest in /tmp (left by any process) must not flip every session
    # whose cwd lives under the temp dir into the coding posture. Same
    # reasoning as the $HOME skip below.
    try:
        temp_root = Path(tempfile.gettempdir()).resolve()
    except Exception:
        temp_root = None
    for depth, parent in enumerate([current, *current.parents]):
        if depth > 6:
            break
        if parent == home or (temp_root is not None and parent == temp_root):
            continue
        for marker in _PROJECT_MARKERS:
            if (parent / marker).exists():
                return parent
    return None


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.



    A git repo rooted at ``$HOME`` (the dotfiles pattern) is NOT a workspace

    signal β€” without the guard, every session anywhere under a dotfiles-managed

    home directory would silently flip to the coding posture.



    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)
    # A recognized project root (manifest / AGENTS.md / .cursorrules) is a code
    # workspace on its own β€” cheap stat checks, no scan.
    if _marker_root(cwd) is not None:
        return CODING_PROFILE.name
    git_root = _git_root(cwd)
    if git_root is not None and git_root == _home():
        git_root = None  # dotfiles repo at $HOME β€” not a code workspace
    # A bare git repo only counts when it actually holds code, so `git init` on a
    # notes/writing/research folder stays in the general posture.
    if git_root is not None and _has_code_files(git_root):
        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"
    # The model id this session runs (e.g. "anthropic/claude-opus-4.8"). Used
    # only to steer edit-format guidance toward the model's family β€” see
    # ``_edit_format_line``. Fixed for the session, so cache-safe.
    model: Optional[str] = None
    # Standing operator instructions (``agent.coding_instructions``), appended
    # as an extra stable system block. Empty unless the user configures it.
    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 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_prompt_parts(self) -> tuple[list[str], list[str], list[str]]:
        """Return prefix, workspace, and trailing posture blocks separately.



        The operating brief carries a model-family edit-format nudge appended

        to it (one cached string, not a separate block) so the model is steered

        toward the `patch` mode it handles best β€” see ``_edit_format_line``.



        The three lists preserve the historical flat prompt order: the brief,

        the live workspace snapshot, then configured operator instructions.

        Prompt assembly can therefore put a cache boundary before the snapshot

        without changing the persisted system-prompt bytes.

        """
        if not self.is_coding:
            return [], [], []
        prefix: list[str] = []
        workspace_parts: list[str] = []
        trailing: list[str] = []
        if self.profile.guidance:
            brief = self.profile.guidance
            edit_line = _edit_format_line(self.model)
            if edit_line:
                brief = f"{brief}\n{edit_line}"
            prefix.append(brief)
        workspace = build_coding_workspace_block(self.cwd)
        if workspace:
            workspace_parts.append(workspace)
        # Operator instructions ride their own block so the brief (block 0) stays
        # byte-stable and cache-keyed independently of user config.
        if self.instructions:
            trailing.append(f"Operator instructions (from config):\n{self.instructions}")
        return prefix, workspace_parts, trailing

    def system_blocks(self) -> list[str]:
        """Return posture blocks in their historical display order.



        ``system_prompt_parts`` is the cache-aware API. This compatibility

        helper retains the public flat list for callers outside prompt assembly.

        """
        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 prompt's skill index.



        Gated on the opt-in ``focus`` mode, like the toolset collapse: the

        default posture leaves the skill index untouched. Users who didn't ask

        for a lean prompt keep full entries for every category β€” index changes

        under ``auto`` proved too surprising in practice, even names-only ones

        (a demoted description is information the model no longer weighs when

        deciding what to load).



        Demoted β€” never hidden β€” even under ``focus``. An earlier revision

        fully pruned these categories from the index, which caused silent

        capability loss in a real workflow: agent-created skills are the

        model's accumulated project memory (server-ops runbooks, learned

        pitfalls, …), and models do not reliably reach for ``skills_list`` to

        rediscover what the index stopped showing them. Names-only keeps every

        skill loadable on recall while still cutting the description noise.

        """
        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. Cheap β€” a handful of ``stat`` calls.



    This is the single entry point every domain should call. The returned

    object is immutable and safe to cache for the session. Detection itself is

    intentionally *not* memoized (see ``_detect_profile_name``) so a long-lived

    process can't pin a stale posture; callers resolve once per session and

    hold the result. ``model`` is recorded only to steer edit-format guidance;

    it never affects detection.

    """
    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,
        model=model,
        instructions=_coding_instructions(config),
    )


# ── 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,

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


def coding_system_prompt_parts(

    *,

    platform: Optional[str] = None,

    cwd: Optional[str | Path] = None,

    config: Optional[dict[str, Any]] = None,

    model: Optional[str] = None,

) -> tuple[list[str], list[str], list[str]]:
    """Return coding prefix, workspace snapshot, and trailing guidance."""
    return resolve_runtime_mode(
        platform=platform, cwd=cwd, config=config, model=model
    ).system_prompt_parts()


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 in the index.



    Empty outside the coding posture and outside the opt-in ``focus`` mode β€”

    the default posture never touches the skill index. Under ``focus``,

    demoted β€” never hidden: every skill name stays in the index and remains

    loadable via ``skill_view`` / ``skills_list``; only descriptions are

    dropped.

    """
    return resolve_runtime_mode(
        platform=platform, cwd=cwd, config=config
    ).compact_skill_categories()


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:
    """``git -C <cwd> <args>`` β†’ stripped stdout, or ``""`` on any failure.



    Uses the shared :func:`bounded_git_probe` so the post-kill cleanup is bounded

    on Windows β€” a plain ``subprocess.run(timeout=...)`` here deadlocked the agent

    turn inside ``build_coding_workspace_block`` when a killed git left a suspended

    descendant holding the pipe handles (issue #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["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 _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 β€” the model's verify loop, detected once.



    The same data that feeds the workspace snapshot, exposed structurally so

    non-prompt consumers (e.g. the desktop verify UI) read it instead of

    re-detecting and drifting from the prompt.

    """

    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, and context files.



    Cheap: stat calls plus reads of a couple of small files. The single source

    of truth for both the prompt snapshot (:func:`_project_facts`) and the

    gateway's ``project.facts`` β€” so the UI never re-sniffs verify commands.

    """
    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: 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")
    if makefile:
        verify.extend(
            f"make {name}" for name in _VERIFY_TARGETS
            if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
        )

    return ProjectFacts(
        manifests=manifests,
        package_managers=package_managers,
        verify_commands=list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS],
        context_files=[c for c in _CONTEXT_FILES if (root / c).is_file()],
    )


def _project_facts(root: Path) -> list[str]:
    """Render :func:`detect_project_facts` as workspace-snapshot lines.



    Hands the model its *verify loop* up front β€” which manifest, which package

    manager, and the exact test/lint/build commands β€” instead of making it

    rediscover them every session. Built once at prompt-build time; the string

    output must stay byte-stable to preserve the prompt cache.

    """
    f = detect_project_facts(root)
    facts: list[str] = []

    if f.manifests:
        line = f"- Project: {', '.join(f.manifests[:6])}"
        if f.package_managers:
            line += f" ({'/'.join(f.package_managers)})"
        facts.append(line)
    if f.verify_commands:
        facts.append(f"- Verify: {'; '.join(f.verify_commands)}")
    if f.context_files:
        facts.append(f"- Context files: {', '.join(f.context_files)}")

    return facts


def project_facts_for(cwd: Optional[str | Path] = None) -> Optional[dict[str, Any]]:
    """Structured project facts for ``cwd`` β€” ``None`` outside a workspace.



    Same detection the system-prompt snapshot uses (git root, else marker root),

    exposed for non-prompt consumers (the desktop verify UI) so they never

    re-derive "are we coding?" or duplicate the verify-command sniffing.

    """
    resolved = _resolve_cwd(cwd)
    root = _git_root(resolved) or _marker_root(resolved)
    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 (branch/status/commits) when the cwd is in a repo, plus detected

    project facts (manifest, package manager, verify commands, context files)

    β€” so marker-only (non-git) projects still get a snapshot.

    """
    resolved = _resolve_cwd(cwd)
    git_root = _git_root(resolved)
    root = git_root or _marker_root(resolved)
    if root is None:
        return ""

    lines = ["Workspace (snapshot at session start β€” re-check with `git` before acting on it):"]
    lines.append(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 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.
        # We surface the fact that it's a worktree (so the model knows branches/stashes
        # are shared state) but deliberately do NOT expose the primary tree path β€”
        # giving the model a second absolute path causes it to sometimes 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 (
            ("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())

    lines.extend(_project_facts(root))
    return "\n".join(lines)