diff --git a/AGENTS.md b/AGENTS.md index b919654c4..ee950bdf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,5 +4,5 @@ This project has a graphify knowledge graph at graphify-out/. Rules: - Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure -- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files — unless a PreToolUse nudge flags it as predating graph.json (wiki/ is only regenerated by `graphify export wiki`, never by `update`/watch), in which case fall back to source or `graphify query` - After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/graphify/always_on/agents-md.md b/graphify/always_on/agents-md.md index 6511cd1dd..222ce344c 100644 --- a/graphify/always_on/agents-md.md +++ b/graphify/always_on/agents-md.md @@ -7,6 +7,6 @@ When the user types `/graphify`, use the installed graphify skill or instruction 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. - Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it. -- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing — unless a PreToolUse nudge flags it as predating graph.json (wiki/ is only regenerated by `graphify export wiki`, never by `update`/watch), in which case fall back to source or `graphify query`. - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/graphify/always_on/antigravity-rules.md b/graphify/always_on/antigravity-rules.md index 0fc786414..66eaafc24 100644 --- a/graphify/always_on/antigravity-rules.md +++ b/graphify/always_on/antigravity-rules.md @@ -9,6 +9,6 @@ This project has a graphify knowledge graph at graphify-out/. Rules: - For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query ""` (CLI) or `query_graph` (MCP). Use `graphify path "" ""` / `shortest_path` for relationships and `graphify explain ""` / `get_node` for focused concepts. These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. -- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files — unless a PreToolUse nudge flags it as predating graph.json (wiki/ is only regenerated by `graphify export wiki`, never by `update`/watch), in which case fall back to source or `graphify query` - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context - After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/graphify/cli.py b/graphify/cli.py index 35e397e60..d29817d63 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -39,17 +39,33 @@ ), } }, ensure_ascii=False, separators=(",", ":")) + "\n" -_READ_NUDGE_STALE = json.dumps({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "additionalContext": ( - 'graphify-out/graph.json exists but may be STALE for this file (the file ' - 'changed after the last build). Prefer `graphify query ""` for ' - 'orientation, and run `graphify update` to refresh the graph. Reading the ' - 'file directly is fine.' - ), - } -}, ensure_ascii=False, separators=(",", ":")) + "\n" +def _read_nudge_stale(out_name: str) -> str: + return json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": ( + f'{out_name}/graph.json exists but may be STALE for this file (the file ' + 'changed after the last build). Prefer `graphify query ""` for ' + 'orientation, and run `graphify update` to refresh the graph. Reading the ' + 'file directly is fine.' + ), + } + }, ensure_ascii=False, separators=(",", ":")) + "\n" + + +def _read_nudge_wiki_stale(out_name: str) -> str: + return json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": ( + f'{out_name}/wiki/ predates the current graph.json. Unlike graph.json, ' + 'wiki/ is only regenerated by `graphify export wiki` — it is not refreshed ' + 'by `graphify update` or watch. Treat it as possibly stale: prefer ' + '`graphify query ""` or read the source directly, and run ' + '`graphify export wiki` to refresh it.' + ), + } + }, ensure_ascii=False, separators=(",", ":")) + "\n" # Strict-mode block (opt-in). Claude Code PreToolUse honors # hookSpecificOutput.permissionDecision == "deny" and shows permissionDecisionReason # to the model. Fires at most once per session (see _mark_session_denied) so it can @@ -467,7 +483,20 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: if "." in seg ] under_out = "graphify-out/" in j or (GRAPHIFY_OUT_NAME.lower() + "/") in j - if under_out or not any(tl in _HOOK_SOURCE_EXTS for tl in tails): + if under_out: + # #2006: wiki/ is only ever regenerated by the explicit `export + # wiki` subcommand, never by `update`/watch, so it can go stale + # relative to graph.json with no other signal to catch it. + if "/wiki/" in j: + try: + wiki_mtime = os.stat(str(out_path("wiki", "index.md"))).st_mtime + gmtime = os.stat(str(out_path("graph.json"))).st_mtime + if wiki_mtime < gmtime: + sys.stdout.write(_read_nudge_wiki_stale(GRAPHIFY_OUT_NAME)) + except OSError: + pass + return + if not any(tl in _HOOK_SOURCE_EXTS for tl in tails): return # #1840 (a): skip files outside the graph's project. cwd (or # CLAUDE_PROJECT_DIR, which Claude Code sets) is the project root, since @@ -515,7 +544,7 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: except Exception: pass if stale: - sys.stdout.write(_READ_NUDGE_STALE) + sys.stdout.write(_read_nudge_stale(GRAPHIFY_OUT_NAME)) return # Strict block: Read tool only, first time per session, not recently # oriented, and the file is demonstrably indexed. diff --git a/tests/test_hook_guard.py b/tests/test_hook_guard.py index 5146ef250..4b308e4fd 100644 --- a/tests/test_hook_guard.py +++ b/tests/test_hook_guard.py @@ -179,6 +179,78 @@ def test_read_nudges_source_outside_custom_output_dir(tmp_path, monkeypatch): assert "graphify query" in out +def test_read_stale_nudge_uses_custom_output_dir_name(tmp_path, monkeypatch): + # Same requirement as the wiki nudge: the STALE message must name the + # actual configured output dir, not a hardcoded 'graphify-out/'. + out_dir = tmp_path / "build-out" + out_dir.mkdir() + (out_dir / "graph.json").write_text("{}", encoding="utf-8") + os.utime(out_dir / "graph.json", (1_000_000, 1_000_000)) + src = tmp_path / "src" + src.mkdir() + f = src / "app.py" + f.write_text("x = 1\n", encoding="utf-8") + os.utime(f, (2_000_000, 2_000_000)) + out = _invoke("read", {"tool_input": {"file_path": "src/app.py"}}, + tmp_path, monkeypatch, graph=False, out_name="build-out") + assert "build-out/graph.json" in out, out + assert "graphify-out" not in out, out + + +# --------------------------------------------------------------------------- # +# read: wiki/ has no automatic regeneration (#2006) — nudge only when it +# predates the current graph.json, stay silent when it's at least as fresh. +# --------------------------------------------------------------------------- # +def test_read_wiki_nudges_when_stale(tmp_path, monkeypatch): + out_dir = tmp_path / "graphify-out" + (out_dir / "wiki").mkdir(parents=True) + (out_dir / "wiki" / "index.md").write_text("stale wiki", encoding="utf-8") + (out_dir / "graph.json").write_text("{}", encoding="utf-8") + os.utime(out_dir / "wiki" / "index.md", (1_000_000, 1_000_000)) + os.utime(out_dir / "graph.json", (2_000_000, 2_000_000)) + out = _invoke("read", {"tool_input": {"file_path": "graphify-out/wiki/index.md"}}, + tmp_path, monkeypatch, graph=False) + assert "predates the current graph.json" in out, out + assert json.loads(out)["hookSpecificOutput"]["hookEventName"] == "PreToolUse" + + +def test_read_wiki_silent_when_fresh(tmp_path, monkeypatch): + out_dir = tmp_path / "graphify-out" + (out_dir / "wiki").mkdir(parents=True) + (out_dir / "wiki" / "index.md").write_text("fresh wiki", encoding="utf-8") + (out_dir / "graph.json").write_text("{}", encoding="utf-8") + os.utime(out_dir / "graph.json", (1_000_000, 1_000_000)) + os.utime(out_dir / "wiki" / "index.md", (2_000_000, 2_000_000)) + out = _invoke("read", {"tool_input": {"file_path": "graphify-out/wiki/index.md"}}, + tmp_path, monkeypatch, graph=False) + assert out.strip() == "", out + + +def test_read_wiki_nudge_uses_custom_output_dir_name(tmp_path, monkeypatch): + # The nudge text must reflect the actual configured output dir, not a + # hardcoded 'graphify-out/' (a custom dir would otherwise get a + # misleading message pointing at a path that doesn't exist). + out_dir = tmp_path / "build-out" + (out_dir / "wiki").mkdir(parents=True) + (out_dir / "wiki" / "index.md").write_text("stale wiki", encoding="utf-8") + (out_dir / "graph.json").write_text("{}", encoding="utf-8") + os.utime(out_dir / "wiki" / "index.md", (1_000_000, 1_000_000)) + os.utime(out_dir / "graph.json", (2_000_000, 2_000_000)) + out = _invoke("read", {"tool_input": {"file_path": "build-out/wiki/index.md"}}, + tmp_path, monkeypatch, graph=False, out_name="build-out") + assert "build-out/wiki/" in out, out + assert "graphify-out" not in out, out + + +def test_read_wiki_silent_without_graph_json(tmp_path, monkeypatch): + out_dir = tmp_path / "graphify-out" + (out_dir / "wiki").mkdir(parents=True) + (out_dir / "wiki" / "index.md").write_text("wiki, no graph.json", encoding="utf-8") + out = _invoke("read", {"tool_input": {"file_path": "graphify-out/wiki/index.md"}}, + tmp_path, monkeypatch, graph=False) + assert out.strip() == "", out + + # --------------------------------------------------------------------------- # # fail-open: malformed / empty stdin never crashes or blocks # --------------------------------------------------------------------------- # diff --git a/tests/test_skillgen.py b/tests/test_skillgen.py index 0c09e601e..e767f03d9 100644 --- a/tests/test_skillgen.py +++ b/tests/test_skillgen.py @@ -616,8 +616,9 @@ def test_always_on_roundtrip_is_byte_faithful(): 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. + # frozen baseline, with the agents-md/antigravity-rules blocks allowed + # exactly the sanctioned substitutions recorded in + # gen.ALWAYS_ON_SANCTIONED_EDITS (#1530, #2006 x2). problems = gen.always_on_roundtrip() assert problems == [] @@ -634,18 +635,55 @@ def test_always_on_roundtrip_is_byte_faithful(): "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. + old_wiki_agents = ( + "- If graphify-out/wiki/index.md exists, use it for broad navigation " + "instead of raw source browsing." + ) + new_wiki_agents = ( + "- If graphify-out/wiki/index.md exists, use it for broad navigation instead of " + "raw source browsing — unless a PreToolUse nudge flags it as predating " + "graph.json (wiki/ is only regenerated by `graphify export wiki`, never by " + "`update`/watch), in which case fall back to source or `graphify query`." + ) + # The sanctioned-edit registry holds exactly these two old->new substitutions + # for _AGENTS_MD_SECTION: #1530 (install guidance) and #2006 (wiki staleness). assert gen.ALWAYS_ON_SANCTIONED_EDITS["_AGENTS_MD_SECTION"] == ( (old_instruction, new_instruction), + (old_wiki_agents, new_wiki_agents), ) 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 — + # The ONLY divergence from the frozen baseline is the two sanctioned edits — # 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 old_wiki_agents in baseline_agents + assert ( + baseline_agents.replace(old_instruction, new_instruction).replace(old_wiki_agents, new_wiki_agents) + == rendered_agents + ) assert "`skill` tool" not in rendered_agents assert 'skill: "graphify"' not in rendered_agents + rendered_antigravity = next( + a.content + for a in gen.render_always_on() + if a.path == "graphify/always_on/antigravity-rules.md" + ) + old_wiki_antigravity = "- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files" + new_wiki_antigravity = ( + "- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files — " + "unless a PreToolUse nudge flags it as predating graph.json (wiki/ is only " + "regenerated by `graphify export wiki`, never by `update`/watch), in which case " + "fall back to source or `graphify query`" + ) + # The sanctioned-edit registry holds exactly this single #2006 substitution + # for _ANTIGRAVITY_RULES. + assert gen.ALWAYS_ON_SANCTIONED_EDITS["_ANTIGRAVITY_RULES"] == ( + (old_wiki_antigravity, new_wiki_antigravity), + ) + baseline_antigravity = gen._always_on_constants(gen.ALWAYS_ON_BASELINE_REF)["_ANTIGRAVITY_RULES"] + assert old_wiki_antigravity in baseline_antigravity + assert baseline_antigravity.replace(old_wiki_antigravity, new_wiki_antigravity) == rendered_antigravity + def test_extracted_constants_equal_the_packaged_always_on_files(): """The live module constants now equal the packaged files they read at load.""" diff --git a/tools/skillgen/expected/graphify__always_on__agents-md.md b/tools/skillgen/expected/graphify__always_on__agents-md.md index 6511cd1dd..222ce344c 100644 --- a/tools/skillgen/expected/graphify__always_on__agents-md.md +++ b/tools/skillgen/expected/graphify__always_on__agents-md.md @@ -7,6 +7,6 @@ When the user types `/graphify`, use the installed graphify skill or instruction 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. - Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it. -- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing — unless a PreToolUse nudge flags it as predating graph.json (wiki/ is only regenerated by `graphify export wiki`, never by `update`/watch), in which case fall back to source or `graphify query`. - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/tools/skillgen/expected/graphify__always_on__antigravity-rules.md b/tools/skillgen/expected/graphify__always_on__antigravity-rules.md index 0fc786414..66eaafc24 100644 --- a/tools/skillgen/expected/graphify__always_on__antigravity-rules.md +++ b/tools/skillgen/expected/graphify__always_on__antigravity-rules.md @@ -9,6 +9,6 @@ This project has a graphify knowledge graph at graphify-out/. Rules: - For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query ""` (CLI) or `query_graph` (MCP). Use `graphify path "" ""` / `shortest_path` for relationships and `graphify explain ""` / `get_node` for focused concepts. These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. -- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files — unless a PreToolUse nudge flags it as predating graph.json (wiki/ is only regenerated by `graphify export wiki`, never by `update`/watch), in which case fall back to source or `graphify query` - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context - After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/tools/skillgen/fragments/always-on/agents-md.md b/tools/skillgen/fragments/always-on/agents-md.md index 6511cd1dd..222ce344c 100644 --- a/tools/skillgen/fragments/always-on/agents-md.md +++ b/tools/skillgen/fragments/always-on/agents-md.md @@ -7,6 +7,6 @@ When the user types `/graphify`, use the installed graphify skill or instruction 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. - Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it. -- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing — unless a PreToolUse nudge flags it as predating graph.json (wiki/ is only regenerated by `graphify export wiki`, never by `update`/watch), in which case fall back to source or `graphify query`. - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/tools/skillgen/fragments/always-on/antigravity-rules.md b/tools/skillgen/fragments/always-on/antigravity-rules.md index 0fc786414..66eaafc24 100644 --- a/tools/skillgen/fragments/always-on/antigravity-rules.md +++ b/tools/skillgen/fragments/always-on/antigravity-rules.md @@ -9,6 +9,6 @@ This project has a graphify knowledge graph at graphify-out/. Rules: - For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query ""` (CLI) or `query_graph` (MCP). Use `graphify path "" ""` / `shortest_path` for relationships and `graphify explain ""` / `get_node` for focused concepts. These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. -- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files — unless a PreToolUse nudge flags it as predating graph.json (wiki/ is only regenerated by `graphify export wiki`, never by `update`/watch), in which case fall back to source or `graphify query` - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context - After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py index 93e403f27..eb1417a44 100644 --- a/tools/skillgen/gen.py +++ b/tools/skillgen/gen.py @@ -110,6 +110,30 @@ def _v8_baseline_ref(platform_key: str) -> str: "When the user types `/graphify`, use the installed graphify skill or instructions " "before doing anything else.", ), + # #2006: wiki/ is only ever regenerated by the explicit `export wiki` + # subcommand, never by `update`/watch, so the unconditional "navigate + # it instead of reading raw files" instruction can point agents at a + # months-stale wiki with no signal anywhere in the loop. Point at the + # new PreToolUse staleness nudge (cli.py) as the escape hatch instead. + ( + "- If graphify-out/wiki/index.md exists, use it for broad navigation " + "instead of raw source browsing.", + "- If graphify-out/wiki/index.md exists, use it for broad navigation instead of " + "raw source browsing — unless a PreToolUse nudge flags it as predating " + "graph.json (wiki/ is only regenerated by `graphify export wiki`, never by " + "`update`/watch), in which case fall back to source or `graphify query`.", + ), + ), + # #2006: same fix as _AGENTS_MD_SECTION above, applied to antigravity's + # wording of the same rule. + "_ANTIGRAVITY_RULES": ( + ( + "- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files", + "- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files — " + "unless a PreToolUse nudge flags it as predating graph.json (wiki/ is only " + "regenerated by `graphify export wiki`, never by `update`/watch), in which case " + "fall back to source or `graphify query`", + ), ), }