From c6f24134317317f028c921c9ffe100ea9732ace1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 08:58:07 +0000 Subject: [PATCH] fix: keep work-only skills and legacy self skills working after today's merges - The English work_builder prompt (#205) words the Persona handoff as "outside the scope of responsibility", which the work-only filter did not match, so work_skill.md kept a pointer to a Persona section it lacks. Widen the English and Chinese patterns. - #197 made self/yourself aliases of the new owner family, so generated skills whose stored meta says type "self" were renamed owner- on install. Stored skill metadata now maps owner back to relationship. - owner_sources.py discover no longer crashes on a malformed MCP config (non-dict mcpServers, non-UTF-8 TOML/YAML). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018qsZs2yPoPC21GnGkktmwp --- tests/test_owner_sources.py | 10 ++++++++++ tests/test_skill_writer.py | 19 +++++++++++++++++++ tools/owner_sources.py | 15 ++++++++++----- tools/skill_presets.py | 10 ++++++++++ tools/skill_schema.py | 4 ++-- tools/skill_writer.py | 9 +++++---- 6 files changed, 56 insertions(+), 11 deletions(-) diff --git a/tests/test_owner_sources.py b/tests/test_owner_sources.py index 83fe06bf..461cb3d1 100644 --- a/tests/test_owner_sources.py +++ b/tests/test_owner_sources.py @@ -96,6 +96,16 @@ def test_finds_files_connectors_skills_and_exports_by_name_only(self) -> None: self.assertNotIn("secret-value-123", json.dumps(report)) self.assertNotIn("gmail-mcp", json.dumps(report)) + def test_malformed_configs_do_not_stop_discovery(self) -> None: + self.write(".claude.json", json.dumps({"mcpServers": ["gmail"], "projects": []})) + (self.home / ".codex").mkdir() + (self.home / ".codex" / "config.toml").write_bytes(b"\xff\xfe") + self.write(".hermes/config.yaml", "mcp_servers:\n slack:\n command: s\n") + + report = owner_sources.discover() + + self.assertEqual([item["name"] for item in report["connectors"]], ["slack"]) + def test_cli_json(self) -> None: self.write(".claude.json", json.dumps({"mcpServers": {"x": {}}})) result = subprocess.run( diff --git a/tests/test_skill_writer.py b/tests/test_skill_writer.py index 31061223..d550aa62 100644 --- a/tests/test_skill_writer.py +++ b/tests/test_skill_writer.py @@ -262,6 +262,25 @@ def test_work_only_skill_replaces_persona_handoff(self) -> None: self.assertNotIn(skill_writer.WORK_ONLY_FALLBACK_ZH, zh_combined) self.assertNotIn(skill_writer.WORK_ONLY_FALLBACK_EN, en_combined) + def test_work_only_skill_drops_handoff_from_current_work_builder_prompt(self) -> None: + prompt = (Path(__file__).resolve().parents[1] / "prompts" / "shared" / "work_builder.md").read_text(encoding="utf-8") + handoff = next(line for line in prompt.splitlines() if "Persona" in line and line.startswith("If asked")) + zh_handoff = "如果被问到职责范围之外的问题,请按这位同事的方式回应(参见 Persona 部分)。" + self.assertNotIn("Persona", skill_writer.work_only_content(f"## Rules\n\n{handoff}\n", chinese=False) + .replace(skill_writer.WORK_ONLY_FALLBACK_EN, "")) + self.assertNotIn("Persona", skill_writer.work_only_content(f"## 规则\n\n{zh_handoff}\n", chinese=True) + .replace(skill_writer.WORK_ONLY_FALLBACK_ZH, "")) + + def test_legacy_self_skill_stays_relationship(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) / "skills" / "relationship" + skill_dir = skill_writer.create_skill( + base_dir, "me", {"name": "Me", "type": "self"}, "Work body", "Persona body" + ) + meta = json.loads((skill_dir / "meta.json").read_text(encoding="utf-8")) + self.assertEqual(meta["character"], "relationship") + self.assertEqual(meta["artifacts"]["combined_name"], "relationship-me") + def test_create_celebrity_adds_research_dirs_and_toolchain(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: base_dir = Path(tmp_dir) / "skills" / "celebrity" diff --git a/tools/owner_sources.py b/tools/owner_sources.py index d915866f..52266790 100644 --- a/tools/owner_sources.py +++ b/tools/owner_sources.py @@ -168,12 +168,17 @@ def _read_json(path: Path) -> dict: return data if isinstance(data, dict) else {} +def _server_names(value: object) -> list[str]: + return list(value.keys()) if isinstance(value, dict) else [] + + def _json_mcp_names(path: Path) -> list[str]: data = _read_json(path) - names = list((data.get("mcpServers") or {}).keys()) - for project in (data.get("projects") or {}).values(): + names = _server_names(data.get("mcpServers")) + projects = data.get("projects") + for project in projects.values() if isinstance(projects, dict) else (): if isinstance(project, dict): - names.extend((project.get("mcpServers") or {}).keys()) + names.extend(_server_names(project.get("mcpServers"))) return names @@ -184,7 +189,7 @@ def _json_mcp_names(path: Path) -> list[str]: def _codex_mcp_names(path: Path) -> list[str]: try: return _TOML_TABLE.findall(path.read_text(encoding="utf-8")) - except OSError: + except (OSError, ValueError): return [] @@ -192,7 +197,7 @@ def _yaml_section_keys(path: Path, section: str) -> list[str]: """Return the direct child keys of a top-level YAML section, without a YAML parser.""" try: lines = path.read_text(encoding="utf-8").splitlines() - except OSError: + except (OSError, ValueError): return [] names: list[str] = [] inside = False diff --git a/tools/skill_presets.py b/tools/skill_presets.py index 71be6f64..d529b264 100644 --- a/tools/skill_presets.py +++ b/tools/skill_presets.py @@ -215,6 +215,16 @@ def normalize_character(character: str | None) -> str: return normalized if normalized in CHARACTER_PRESETS else "colleague" +def normalize_skill_character(character: str | None) -> str: + """Normalize the family stored in a generated skill's metadata. + + Generated skills are never owner profiles. Before the owner family existed, + ``self`` and ``yourself`` meant ``relationship``, so legacy skills keep that. + """ + normalized = normalize_character(character) + return "relationship" if normalized == "owner" else normalized + + def get_character_preset(character: str | None) -> dict: """Return the preset for the given character family.""" return CHARACTER_PRESETS[normalize_character(character)] diff --git a/tools/skill_schema.py b/tools/skill_schema.py index 58a7c747..73dd9f81 100644 --- a/tools/skill_schema.py +++ b/tools/skill_schema.py @@ -15,7 +15,7 @@ from skill_presets import ( get_character_preset, get_research_profile_preset, - normalize_character, + normalize_skill_character, normalize_research_profile, ) @@ -68,7 +68,7 @@ def flatten_legacy_tags(meta: dict) -> list[str]: def resolve_character(meta: dict, explicit_character: str | None = None) -> str: """Resolve the active character family from new or legacy fields.""" - return normalize_character( + return normalize_skill_character( explicit_character or meta.get("character") or meta.get("type") diff --git a/tools/skill_writer.py b/tools/skill_writer.py index e37a24f8..17dfbf8b 100644 --- a/tools/skill_writer.py +++ b/tools/skill_writer.py @@ -20,6 +20,7 @@ from skill_presets import ( get_character_preset, normalize_character, + normalize_skill_character, normalize_research_profile, resolve_existing_storage_root, resolve_storage_root, @@ -175,10 +176,10 @@ def render_combined_skill(meta: dict, work_content: str, persona_content: str) - _PERSONA_HANDOFF_PATTERNS = ( - re.compile(r"如果被问到职责范围外的问题,以该同事的方式回应(参见 Persona 部分)。\s*"), + re.compile(r"如果被问到[^。\n]*职责范围[^。\n]*Persona[^。\n]*。\s*"), re.compile( - r"If (?:you are )?asked (?:a question )?outside (?:your|the) " - r"(?:recorded )?responsibilities[^.\n]*Persona[^.\n]*\.\s*", + r"If (?:you are )?asked (?:a question |about something )?outside (?:of )?(?:your|the) " + r"(?:recorded )?(?:scope of )?(?:your )?responsibilit(?:y|ies)[^.\n]*Persona[^.\n]*\.\s*", re.IGNORECASE, ), ) @@ -648,7 +649,7 @@ def main() -> None: if args.name: meta["name"] = args.name meta["display_name"] = args.name - meta["character"] = normalize_character( + meta["character"] = normalize_skill_character( meta.get("character", meta.get("type", requested_character)) ) meta["research_profile"] = normalize_research_profile(