From e3e4198038088820d4b560052b63d989917d59ca Mon Sep 17 00:00:00 2001 From: Ariadne Mitophane Date: Mon, 29 Jun 2026 10:01:06 +0100 Subject: [PATCH] fix(skillgen): host-generic /graphify install guidance (#1530) Generated install/skill guidance told agents to invoke a literal `skill` tool with `skill: "graphify"`, which is host-specific and not valid in every environment. The always-on AGENTS fragment, packaged artifact, expected snapshot, and _skill_registration() output now use host-generic wording: "use the installed graphify skill or instructions". Also decodes skillgen git blob reads as UTF-8 for Windows and replaces stale English code-block examples in the translated READMEs. The always-on roundtrip guard deliberately freezes the v8 baseline, so an intentional wording change would otherwise fail it. Rather than only patching the pytest mirror (which left the blocking CLI guard --always-on-roundtrip red, as the original PR did), this adds an explicit, reviewable ALWAYS_ON_SANCTIONED_EDITS registry: the guard applies the approved old->new substitution to the baseline before the byte-for-byte compare, so this exact sentence is allowed while any other drift still fails. CLI guard and pytest test now agree and CI passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/translations/README.ja-JP.md | 2 +- docs/translations/README.ko-KR.md | 2 +- docs/translations/README.zh-CN.md | 2 +- graphify/__main__.py | 4 +-- graphify/always_on/agents-md.md | 2 +- tests/test_install_strings.py | 14 ++++++++ tests/test_skillgen.py | 30 +++++++++++++++- .../graphify__always_on__agents-md.md | 2 +- .../skillgen/fragments/always-on/agents-md.md | 2 +- tools/skillgen/gen.py | 34 +++++++++++++++++-- uv.lock | 2 +- 11 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docs/translations/README.ja-JP.md b/docs/translations/README.ja-JP.md index f83c99c..467ff68 100644 --- a/docs/translations/README.ja-JP.md +++ b/docs/translations/README.ja-JP.md @@ -114,7 +114,7 @@ curl -fsSL https://raw.githubusercontent.com/safishamsi/graphify/v3/graphify/ski ``` - **graphify** (`~/.claude/skills/graphify/SKILL.md`) - any input to knowledge graph. Trigger: `/graphify` -When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"` before doing anything else. +When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else. ``` diff --git a/docs/translations/README.ko-KR.md b/docs/translations/README.ko-KR.md index aee7776..0fa46a2 100644 --- a/docs/translations/README.ko-KR.md +++ b/docs/translations/README.ko-KR.md @@ -150,7 +150,7 @@ curl -fsSL https://raw.githubusercontent.com/safishamsi/graphify/v3/graphify/ski ``` - **graphify** (`~/.claude/skills/graphify/SKILL.md`) - any input to knowledge graph. Trigger: `/graphify` -When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"` before doing anything else. +When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else. ``` diff --git a/docs/translations/README.zh-CN.md b/docs/translations/README.zh-CN.md index 0aa194c..e418322 100644 --- a/docs/translations/README.zh-CN.md +++ b/docs/translations/README.zh-CN.md @@ -110,7 +110,7 @@ curl -fsSL https://raw.githubusercontent.com/safishamsi/graphify/v3/graphify/ski ``` - **graphify** (`~/.claude/skills/graphify/SKILL.md`) - any input to knowledge graph. Trigger: `/graphify` -When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"` before doing anything else. +When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else. ``` diff --git a/graphify/__main__.py b/graphify/__main__.py index f7903c4..b838201 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -462,8 +462,8 @@ def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") "\n# graphify\n" f"- **graphify** (`{skill_path}`) " "- any input to knowledge graph. Trigger: `/graphify`\n" - "When the user types `/graphify`, invoke the Skill tool " - "with `skill: \"graphify\"` before doing anything else.\n" + "When the user types `/graphify`, use the installed graphify skill " + "or instructions before doing anything else.\n" ) diff --git a/graphify/always_on/agents-md.md b/graphify/always_on/agents-md.md index 20cff72..6511cd1 100644 --- a/graphify/always_on/agents-md.md +++ b/graphify/always_on/agents-md.md @@ -2,7 +2,7 @@ This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. -When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else. +When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else. Rules: - For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. diff --git a/tests/test_install_strings.py b/tests/test_install_strings.py index fb8fd0d..38bec1e 100644 --- a/tests/test_install_strings.py +++ b/tests/test_install_strings.py @@ -14,6 +14,7 @@ import json from graphify.__main__ import ( _SETTINGS_HOOK, _READ_SETTINGS_HOOK, + _skill_registration, _CLAUDE_MD_SECTION, _AGENTS_MD_SECTION, _GEMINI_MD_SECTION, @@ -127,6 +128,19 @@ def test_agents_section_does_not_skip_dirty_graph_output(): assert "not a reason to skip graphify" in _AGENTS_MD_SECTION +def test_agents_section_uses_generic_graphify_instruction(): + assert "`skill` tool" not in _AGENTS_MD_SECTION + assert 'skill: "graphify"' not in _AGENTS_MD_SECTION + assert "use the installed graphify skill" in _AGENTS_MD_SECTION + + +def test_skill_registration_uses_host_generic_instruction(): + reg = _skill_registration() + assert 'skill: "graphify"' not in reg + assert "Skill tool" not in reg + assert "use the installed graphify skill or instructions" in reg + + def test_how_it_works_clarifies_code_only_semantic_extraction(): from pathlib import Path doc = (Path(__file__).parent.parent / "docs" / "how-it-works.md").read_text(encoding="utf-8") diff --git a/tests/test_skillgen.py b/tests/test_skillgen.py index 25b40a9..be404c6 100644 --- a/tests/test_skillgen.py +++ b/tests/test_skillgen.py @@ -601,8 +601,36 @@ def test_always_on_roundtrip_is_byte_faithful(): graphify.__main__, so the packaged markdown must round-trip exactly or those contracts silently change. """ + # The guard passes with zero problems: every always-on block reproduces its + # frozen baseline, with the agents-md block allowed exactly the #1530 + # sanctioned substitution recorded in gen.ALWAYS_ON_SANCTIONED_EDITS. problems = gen.always_on_roundtrip() - assert problems == [], "\n".join(problems) + assert problems == [] + + rendered_agents = next( + a.content + for a in gen.render_always_on() + if a.path == "graphify/always_on/agents-md.md" + ) + old_instruction = ( + "When the user types `/graphify`, invoke the `skill` tool with " + '`skill: "graphify"` before doing anything else.' + ) + new_instruction = ( + "When the user types `/graphify`, use the installed graphify skill or instructions " + "before doing anything else." + ) + # The sanctioned-edit registry holds exactly this single old->new substitution. + assert gen.ALWAYS_ON_SANCTIONED_EDITS["_AGENTS_MD_SECTION"] == ( + (old_instruction, new_instruction), + ) + baseline_agents = gen._always_on_constants(gen.ALWAYS_ON_BASELINE_REF)["_AGENTS_MD_SECTION"] + # The ONLY divergence from the frozen baseline is the sanctioned sentence — + # any other byte drift would have surfaced as a problem above. + assert old_instruction in baseline_agents + assert baseline_agents.replace(old_instruction, new_instruction) == rendered_agents + assert "`skill` tool" not in rendered_agents + assert 'skill: "graphify"' not in rendered_agents def test_extracted_constants_equal_the_packaged_always_on_files(): diff --git a/tools/skillgen/expected/graphify__always_on__agents-md.md b/tools/skillgen/expected/graphify__always_on__agents-md.md index 20cff72..6511cd1 100644 --- a/tools/skillgen/expected/graphify__always_on__agents-md.md +++ b/tools/skillgen/expected/graphify__always_on__agents-md.md @@ -2,7 +2,7 @@ This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. -When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else. +When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else. Rules: - For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. diff --git a/tools/skillgen/fragments/always-on/agents-md.md b/tools/skillgen/fragments/always-on/agents-md.md index 20cff72..6511cd1 100644 --- a/tools/skillgen/fragments/always-on/agents-md.md +++ b/tools/skillgen/fragments/always-on/agents-md.md @@ -2,7 +2,7 @@ This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. -When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else. +When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else. Rules: - For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py index e369dd7..7b198d1 100644 --- a/tools/skillgen/gen.py +++ b/tools/skillgen/gen.py @@ -92,6 +92,27 @@ ALWAYS_ON_BLOCKS = { "kiro-steering": "_KIRO_STEERING", } +# Sanctioned divergences from the frozen always-on baseline above. The roundtrip +# guard deliberately does NOT track HEAD, so any *intentional* change to an +# always-on instruction block must be recorded here as an explicit, reviewable +# old -> new substitution keyed by the baseline constant. The guard applies these +# to the baseline before the byte-for-byte comparison; anything not covered here +# still fails the guard, so unrelated drift cannot slip through. Each entry is a +# one-time, audited edit to the otherwise-immutable v8 baseline. +ALWAYS_ON_SANCTIONED_EDITS: dict[str, tuple[tuple[str, str], ...]] = { + # #1530: install guidance must stay host-generic — do not tell agents to + # invoke a literal `skill` tool with `skill: "graphify"`, which is + # host-specific and not valid in every environment. + "_AGENTS_MD_SECTION": ( + ( + "When the user types `/graphify`, invoke the `skill` tool with " + '`skill: "graphify"` before doing anything else.', + "When the user types `/graphify`, use the installed graphify skill or instructions " + "before doing anything else.", + ), + ), +} + # The full six-value file_type enum (Decision A). Every rendered platform — split # or monolith — must carry exactly this enum, byte for byte. schema-singleton # guards it. @@ -561,6 +582,7 @@ def _git_show(ref: str) -> str: cwd=REPO_ROOT, capture_output=True, text=True, + encoding="utf-8", ) if result.returncode != 0: raise SystemExit(f"error: could not read {ref}: {result.stderr.strip()}") @@ -949,10 +971,18 @@ def always_on_roundtrip() -> list[str]: if const_name not in baseline: problems.append(f"could not find constant {const_name} in {ALWAYS_ON_BASELINE_REF}") continue - if rendered[path] != baseline[const_name]: + expected = baseline[const_name] + for old, new in ALWAYS_ON_SANCTIONED_EDITS.get(const_name, ()): + if old not in expected: + problems.append( + f"sanctioned edit for {const_name} no longer applies: " + f"old text not found in {ALWAYS_ON_BASELINE_REF}" + ) + expected = expected.replace(old, new) + if rendered[path] != expected: problems.append( f"always_on/{basename}.md does not reproduce {const_name} byte for byte " - f"(rendered {len(rendered[path])} chars vs baseline {len(baseline[const_name])} chars)" + f"(rendered {len(rendered[path])} chars vs baseline {len(expected)} chars)" ) return problems diff --git a/uv.lock b/uv.lock index 70e258d..ab71ee1 100644 --- a/uv.lock +++ b/uv.lock @@ -1150,7 +1150,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.0" +version = "0.9.1" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },