diff --git a/nightshift/config.py b/nightshift/config.py index f3a9c8e..ad71474 100644 --- a/nightshift/config.py +++ b/nightshift/config.py @@ -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") diff --git a/nightshift/patches.py b/nightshift/patches.py index 6189243..b1d2898 100644 --- a/nightshift/patches.py +++ b/nightshift/patches.py @@ -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[^\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[^\n]+)\n---CONTENT---\n(?P.*?)\n---END---\s*$" ) + updates: list[FileUpdate] = [] for match in pattern.finditer(text): path = match.group("path").strip().strip("`") content = match.group("content") diff --git a/nightshift/repo_tools.py b/nightshift/repo_tools.py index c34ab6e..9dfbeae 100644 --- a/nightshift/repo_tools.py +++ b/nightshift/repo_tools.py @@ -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) diff --git a/nightshift/templates.py b/nightshift/templates.py index f574c49..1c6e586 100644 --- a/nightshift/templates.py +++ b/nightshift/templates.py @@ -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 ``` Do not include explanations before or after the file blocks.