Files
hermes-agent/hermes_cli/init_command.py
funkyotc 6f25660a6b fix(init): target the session's active directory, not the process cwd
/init resolved its project directory via os.getcwd() — the process
launch directory — on all three dispatch surfaces (CLI, gateway,
TUI/desktop). On the desktop app the process launches from the home
directory, so /init scanned the HOME and merge-updated the home
directory's AGENTS.md even though the session was attached to a
project workspace (its terminal commands ran in the right directory).

Resolve the session's ACTIVE directory instead: the terminal tool's
per-session cwd record (seeded when a workspace attaches, updated
after every cd) first, then resolve_agent_cwd()'s canonical ladder
(session contextvar -> TERMINAL_CWD -> process cwd). Dispatch sites
pass the session key so multi-session hosts consult the right record.

The stale-record guard lives in build_init_prompt_for_cwd: a cwd that
names a deleted directory (removed project, linked worktree) falls
through to the session ladder on every surface instead of each
dispatch site validating its own record. Without it the TUI/desktop
path handed a truthy-but-dead record straight through as cwd, so /init
targeted a directory that no longer existed — write_file would have
recreated it and written a fresh AGENTS.md into it.

Regression tests cover: session record wins over the process cwd,
stale-record fallback, explicit cwd precedence, ambient session key,
a stale explicit cwd falling through to the session record, and the
TUI/desktop command.dispatch path end-to-end (live record, and a
stale record falling through to the session's attached workspace).

Fixes #96376
2026-10-01 04:50:26 -05:00

133 lines
6.5 KiB
Python

"""``/init`` — build the prompt that asks the agent to generate or update a project AGENTS.md."""
from __future__ import annotations
import os
# Embedded in every prompt so the generated file reads like a maintainer wrote it.
_QUALITY_BAR = """\
Quality bar for the file you write (this is what separates a useful AGENTS.md
from noise):
- CONCISE: target under 100 lines. Agents load this file every session — every
line costs context. No essays, no marketing prose, no filler.
- Commands must be EXACT invocations you verified from the repo (package.json
scripts, Makefile targets, pyproject/tox/CI config, existing docs). Write
`npm run test:unit` or `scripts/run_tests.sh tests/foo`, never "run the
tests". NEVER invent a command you didn't see evidence for.
- No generic advice. "Write tests for new code" and "follow best practices"
are banned — if a line would be true of any repo, cut it.
- Conventions must be OBSERVED, not assumed: naming patterns, module layout,
error-handling style, commit-message format — only what the code actually
shows.
- Include pitfalls that would genuinely trip up a newcomer or an agent
(required env vars, generated files not to hand-edit, slow test suites,
ports already in use), if you found any. Skip the section if you found none.
- Markdown structure: a short title + one-paragraph overview, then focused
sections (e.g. "Dev environment", "Build & test", "Conventions",
"Pitfalls"). Flat and scannable — no deep nesting."""
def build_init_prompt(cwd: str, existing_file: str | None = None, extra: str = "") -> str:
"""Build the ``/init`` prompt; ``existing_file`` (current AGENTS.md) switches to merge discipline,
``extra`` is the user's free text after ``/init``."""
extra = (extra or "").strip()
update = existing_file is not None
parts: list[str] = [
"[/init] The user wants you to "
+ ("UPDATE the existing" if update else "generate an")
+ f" AGENTS.md project-instructions file for the project at: {cwd}\n",
"AGENTS.md is the instruction file coding agents (Hermes included) "
"load as project context every session. It should teach an agent how "
"to work in THIS repo: what the project is, how to set up, the exact "
"build/test/lint commands, the conventions the code actually follows, "
"and the pitfalls that waste time.\n",
"Do this:\n"
"1. Inspect the project with your read-only tools (`read_file`, "
"`search_files`) — start with manifests and toolchain files "
"(package.json, pyproject.toml, Cargo.toml, go.mod, Makefile, "
"CI workflow configs, lockfiles), then the directory layout, existing "
"README/docs, and test/lint configuration. Learn the real commands, "
"don't guess them.\n"
"2. Write the file to "
f"{cwd.rstrip('/')}/AGENTS.md with `write_file`"
+ (" — but this is an UPDATE, so follow the merge discipline below." if update else ".")
+ "\n"
"3. Confirm to the user the exact path you wrote and summarize in one "
"or two lines what the file covers.\n",
]
if update:
parts.append(
"MERGE DISCIPLINE — an AGENTS.md already exists (its current "
"content is below). Do NOT overwrite or regenerate it from "
"scratch. Preserve the user's existing content — their wording, "
"their sections, their rules — and merge in only what is missing "
"or verifiably stale (e.g. a command that no longer exists in the "
"repo). When existing content conflicts with what you observed, "
"prefer minimal surgical edits over rewrites, and keep the "
"user's intent. The result must still meet the quality bar.\n\n"
"CURRENT AGENTS.md CONTENT:\n"
"<<<EXISTING_AGENTS_MD\n"
f"{existing_file}\n"
"EXISTING_AGENTS_MD\n"
)
parts.append(_QUALITY_BAR)
if extra:
parts.append(
"\nUSER NOTES — honor these while authoring (they override the "
f"defaults above where they conflict):\n{extra}"
)
return "\n".join(parts)
def _resolve_session_cwd(session_key: str | None) -> str:
"""The session's ACTIVE directory — where /init should scan and write.
Ladder: the terminal tool's per-session cwd record (seeded when a surface attaches a
workspace to the session — desktop project picker, ``project_create``/``project_switch``,
gateway ``terminal.cwd`` — and updated after every completed command, so it tracks
``cd``), then ``agent.runtime_cwd.resolve_agent_cwd()`` (session-pinned cwd →
``TERMINAL_CWD`` → process cwd). A bare ``os.getcwd()`` is only right for a CLI launched
inside a project: on the desktop app it is the home directory, so /init scanned and
updated the HOME's AGENTS.md instead of the workspace attached to the session.
``session_key`` is the surface's own key (multi-session hosts must pass it); an empty
string reads the single-session CLI's ``"default"`` record, and ``None`` falls back to
the ambient ``HERMES_SESSION_KEY``.
"""
try:
from gateway.session_context import get_session_env
from tools.terminal_tool import get_session_cwd
key = session_key if session_key is not None else get_session_env("HERMES_SESSION_KEY", "")
recorded = get_session_cwd(key)
if recorded and os.path.isdir(recorded):
return recorded
except Exception:
pass
try:
from agent.runtime_cwd import resolve_agent_cwd
return str(resolve_agent_cwd())
except Exception:
return os.getcwd()
def build_init_prompt_for_cwd(cwd: str | None = None, extra: str = "",
session_key: str | None = None) -> str:
"""Convenience wrapper used by the dispatch surfaces.
An explicit ``cwd`` wins while it still exists; a stale one (deleted project, removed
worktree) falls through to :func:`_resolve_session_cwd` — the guard lives here so every
dispatch surface shares it rather than each one validating its own record.
"""
resolved = os.path.abspath(cwd if cwd and os.path.isdir(cwd) else _resolve_session_cwd(session_key))
existing: str | None = None
agents_path = os.path.join(resolved, "AGENTS.md")
try:
if os.path.isfile(agents_path):
with open(agents_path, encoding="utf-8-sig", errors="replace") as fh:
existing = fh.read()
except OSError:
existing = None
return build_init_prompt(resolved, existing_file=existing, extra=extra)