fix(agent): coding posture requires code, not a bare .git

A prose/notes repo (git-init'd, no source) was flipping the agent into the
coding posture purely on the `.git` marker. Require an actual code file (or a
project manifest) before adopting coding context, so non-coding projects — a
huge use case for first-class Projects — stay general.
This commit is contained in:
Brooklyn Nicholson
2026-06-23 13:17:59 -05:00
parent cc9b33499f
commit 062ff4a7e4
2 changed files with 83 additions and 2 deletions

View File

@@ -83,6 +83,59 @@ _PROJECT_MARKERS = (
# 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 = (
@@ -368,10 +421,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
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
if git_root is not None or _marker_root(cwd) is not None:
# 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

View File

@@ -23,9 +23,14 @@ def _git_init(path):
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"HOME": str(path),
}
# Commit a source file so the fixture is a real *code* workspace: a bare git
# repo with no code no longer flips into the coding posture (see
# _detect_profile_name / _has_code_files), so "a code repo" needs code.
(Path(path) / "main.py").write_text("print('hi')\n")
for args in (
["init", "-q", "-b", "main"],
["commit", "-q", "--allow-empty", "-m", "init commit"],
["add", "-A"],
["commit", "-q", "-m", "init commit"],
):
subprocess.run([shutil.which("git"), "-C", str(path), *args], check=True, env=env)
@@ -48,6 +53,23 @@ class TestIsCodingContext:
_git_init(tmp_path)
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
def test_auto_bare_git_repo_without_code_stays_general(self, tmp_path):
# A git repo of only prose (notes/writing/research — a big non-coding use
# case) is NOT a code workspace: .git alone must not flip the posture.
cfg = {"agent": {"coding_context": "auto"}}
env = {
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "HOME": str(tmp_path),
}
(tmp_path / "notes.md").write_text("# my novel\n")
for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "notes"]):
subprocess.run([shutil.which("git"), "-C", str(tmp_path), *args], check=True, env=env)
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
# …but adding a manifest or source file makes it a code workspace.
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
def test_auto_skips_messaging_surfaces(self, tmp_path):
_git_init(tmp_path)
cfg = {"agent": {"coding_context": "auto"}}