Add non-Python project support: skip_repo_parts and generic paths

- SafetyConfig.skip_repo_parts lets projects exclude build artifacts
  (e.g. target/, node_modules/) from repo scanning
- RepoTools uses configurable skipped parts instead of hardcoded set
- Agent prompt templates use generic path.to instead of path.py
- Patch error message uses path.to instead of path.py
This commit is contained in:
RJS 2026-05-23 15:28:19 +00:00
parent 67ea77baa6
commit a0327956dc
4 changed files with 16 additions and 26 deletions

View File

@ -33,6 +33,7 @@ class SafetyConfig:
allowed_commands: tuple[str, ...]
forbidden_commands: tuple[str, ...]
allowed_env: tuple[str, ...] = ()
skip_repo_parts: tuple[str, ...] = ()
@dataclass(frozen=True)
@ -184,6 +185,9 @@ def parse_config(raw: dict[str, Any], config_path: Path) -> NightShiftConfig:
)
safety_raw = _require_mapping(raw["safety"], "safety")
skip_repo_parts = _string_tuple(
safety_raw.get("skip_repo_parts", []), "safety.skip_repo_parts"
)
safety = SafetyConfig(
require_clean_worktree=_optional_bool(
safety_raw.get("require_clean_worktree", False),
@ -195,6 +199,7 @@ def parse_config(raw: dict[str, Any], config_path: Path) -> NightShiftConfig:
safety_raw.get("forbidden_commands", []), "safety.forbidden_commands"
),
allowed_env=_string_tuple(safety_raw.get("allowed_env", []), "safety.allowed_env"),
skip_repo_parts=skip_repo_parts,
)
agents_raw = _require_mapping(raw["agents"], "agents")

View File

@ -106,32 +106,16 @@ def parse_file_updates(text: str) -> tuple[FileUpdate, ...]:
updates.append(FileUpdate(path=path, content=content))
if not updates:
raise PipelineError(
"File writer error: no file blocks found. Expected FILE: path with ---CONTENT---/---END--- or fenced blocks like ```file:path.py."
"File writer error: no file blocks found. Expected fenced blocks like ```file:path.to."
)
return tuple(updates)
def _parse_delimited_file_updates(text: str) -> list[FileUpdate]:
updates: list[FileUpdate] = []
header_pattern = re.compile(r"(?m)^FILE:\s*(?P<path>[^\n]+)\n---CONTENT---\n")
matches = list(header_pattern.finditer(text))
for index, match in enumerate(matches):
path = match.group("path").strip().strip("`")
content_start = match.end()
next_file_start = matches[index + 1].start() if index + 1 < len(matches) else len(text)
raw_content = text[content_start:next_file_start]
end_match = re.search(r"(?m)^---END---\s*$", raw_content)
if end_match:
raw_content = raw_content[: end_match.start()]
content = raw_content.rstrip("\r\n") + "\n"
if path:
updates.append(FileUpdate(path=path, content=content))
if updates:
return updates
pattern = re.compile(
r"(?ms)^FILE:\s*(?P<path>[^\n]+)\n---CONTENT---\n(?P<content>.*?)\n---END---\s*$"
)
updates: list[FileUpdate] = []
for match in pattern.finditer(text):
path = match.group("path").strip().strip("`")
content = match.group("content")

View File

@ -17,7 +17,7 @@ from .safety import resolve_inside_root, resolve_project_root, validate_scoped_p
DEFAULT_MAX_BYTES = 20_000
DEFAULT_MAX_MATCHES = 100
DEFAULT_MAX_LOOKUP_REQUESTS = 8
SKIPPED_REPO_PARTS = {".git", ".nightshift", "__pycache__", ".venv", "venv"}
DEFAULT_SKIPPED_REPO_PARTS = {".git", ".nightshift", "__pycache__", ".venv", "venv"}
@dataclass(frozen=True)
@ -45,6 +45,7 @@ class RepoTools:
self.project_root,
safety.scoped_paths or (".",),
)
self.skipped_parts = DEFAULT_SKIPPED_REPO_PARTS | set(safety.skip_repo_parts)
def list_files(self, path: str = ".", pattern: str = "*", max_files: int = 200) -> str:
root = self._resolve_scoped(path, "list_files path")
@ -57,7 +58,7 @@ class RepoTools:
relative_files = [
_relative(item, self.project_root)
for item in sorted(candidates)
if fnmatch.fnmatch(item.name, pattern) and not _is_skipped_repo_path(item, self.project_root)
if fnmatch.fnmatch(item.name, pattern) and not _is_skipped_repo_path(item, self.project_root, self.skipped_parts)
]
lines = relative_files[:max_files]
if len(relative_files) > max_files:
@ -66,7 +67,7 @@ class RepoTools:
def read_file(self, path: str, max_bytes: int = DEFAULT_MAX_BYTES) -> str:
file_path = self._resolve_scoped(path, "read_file path")
if _is_skipped_repo_path(file_path, self.project_root):
if _is_skipped_repo_path(file_path, self.project_root, self.skipped_parts):
return f"Path is skipped for repository lookup: {path}"
if not file_path.exists() or not file_path.is_file():
return f"File not found: {path}"
@ -89,7 +90,7 @@ class RepoTools:
files = [root] if root.is_file() else [item for item in root.rglob("*") if item.is_file()]
matches: list[str] = []
for file_path in sorted(files):
if _is_skipped_repo_path(file_path, self.project_root):
if _is_skipped_repo_path(file_path, self.project_root, self.skipped_parts):
continue
try:
text = file_path.read_text(encoding="utf-8", errors="replace")
@ -270,9 +271,9 @@ def _relative(path: Path, root: Path) -> str:
return path.as_posix()
def _is_skipped_repo_path(path: Path, root: Path) -> bool:
def _is_skipped_repo_path(path: Path, root: Path, skipped_parts: set[str]) -> bool:
try:
parts = set(path.relative_to(root).parts)
except ValueError:
parts = set(path.parts)
return bool(parts & SKIPPED_REPO_PARTS)
return bool(parts & skipped_parts)

View File

@ -339,7 +339,7 @@ If you need repository context before planning, output lookup requests exactly l
lookup_requests:
- tool: read_file
path: relative/path.py
path: relative/path.to
- tool: grep
path: .
pattern: search_regex
@ -356,7 +356,7 @@ REAL_MODEL_IMPLEMENTER_PROMPT = """You are the implementation agent for NightShi
Output only complete file content blocks.
Use one fenced block per file with this exact opening form:
```file:relative/path.py
```file:relative/path.to
<complete file content>
```
Do not include explanations before or after the file blocks.