Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ tests/skill_engine/*
!tests/skill_engine/test_evolver_length_recovery.py
!tests/skill_engine/test_evolution_retry_idempotency.py
!tests/skill_engine/test_analyzer_length_recovery.py
!tests/skill_engine/test_detect_patch_type_prose.py
!tests/skill_engine/decision/
tests/skill_engine/decision/*
!tests/skill_engine/decision/test_analysis_adapter.py
Expand Down
15 changes: 11 additions & 4 deletions openspace/skill_engine/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,11 +482,14 @@ def detect_patch_type(content: str) -> PatchType:
if "*** Begin Files" in content:
return PatchType.FULL

# Detect bare *** File: markers (no *** Begin Files envelope).
# Must appear at line start; require at least one to look structural.
file_header_hits = _FILE_HEADER_RE.findall(content)
if file_header_hits:
# ≥2 File: markers, or one at content start with a body (docstring rule 3).
file_headers = list(_FILE_HEADER_RE.finditer(content))
if len(file_headers) >= 2:
return PatchType.FULL
if len(file_headers) == 1:
match = file_headers[0]
if not content[: match.start()].strip() and content[match.end() :].strip():
return PatchType.FULL

if "<<<<<<< SEARCH" in content:
return PatchType.DIFF
Expand Down Expand Up @@ -527,6 +530,10 @@ def parse_multi_file_full(content: str) -> Dict[str, str]:
# No multi-file markers — treat entire content as SKILL.md
return {SKILL_FILENAME: content}

# Single mid-document *** File: is prose/example text, not multi-file.
if len(headers) == 1 and stripped[: headers[0].start()].strip():
return {SKILL_FILENAME: content}

files: Dict[str, str] = {}
for i, match in enumerate(headers):
file_path = match.group(1).strip()
Expand Down
38 changes: 38 additions & 0 deletions tests/skill_engine/test_detect_patch_type_prose.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Regression: mid-document *** File: prose must not drop SKILL.md."""

from __future__ import annotations

from pathlib import Path

from openspace.skill_engine.patch import (
PatchType,
create_skill,
detect_patch_type,
parse_multi_file_full,
)


def test_detect_patch_type_ignores_single_mid_document_file_marker():
content = "# My Skill\n\n*** File: helper.sh\n#!/bin/bash\necho hi\n"
assert detect_patch_type(content) == PatchType.FULL
parsed = parse_multi_file_full(content)
assert list(parsed.keys()) == ["SKILL.md"]
assert "*** File: helper.sh" in parsed["SKILL.md"]


def test_create_skill_keeps_skill_md_when_file_marker_is_prose(tmp_path: Path):
content = "# My Skill\n\n*** File: helper.sh\n#!/bin/bash\necho hi\n"
result = create_skill(tmp_path / "s", content)
assert result.ok
skill_md = tmp_path / "s" / "SKILL.md"
assert skill_md.is_file()
assert "*** File: helper.sh" in skill_md.read_text()
assert not (tmp_path / "s" / "helper.sh").exists()


def test_detect_patch_type_accepts_structural_multi_file_at_start():
content = "*** File: SKILL.md\n# Skill\n\n*** File: helper.sh\n#!/bin/bash\necho hi\n"
assert detect_patch_type(content) == PatchType.FULL
parsed = parse_multi_file_full(content)
assert "SKILL.md" in parsed
assert "helper.sh" in parsed