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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion graphify/always_on/agents-md.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ When the user types `/graphify`, use the installed graphify skill or instruction
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` 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).
2 changes: 1 addition & 1 deletion graphify/always_on/antigravity-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<question>"` (CLI) or `query_graph` (MCP). Use `graphify path "<A>" "<B>"` / `shortest_path` for relationships and `graphify explain "<concept>"` / `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)
55 changes: 42 additions & 13 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<question>"` 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 "<question>"` 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 "<question>"` 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
72 changes: 72 additions & 0 deletions tests/test_hook_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# --------------------------------------------------------------------------- #
Expand Down
48 changes: 43 additions & 5 deletions tests/test_skillgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []

Expand All @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion tools/skillgen/expected/graphify__always_on__agents-md.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ When the user types `/graphify`, use the installed graphify skill or instruction
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` 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).
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<question>"` (CLI) or `query_graph` (MCP). Use `graphify path "<A>" "<B>"` / `shortest_path` for relationships and `graphify explain "<concept>"` / `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)
2 changes: 1 addition & 1 deletion tools/skillgen/fragments/always-on/agents-md.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ When the user types `/graphify`, use the installed graphify skill or instruction
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` 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).
2 changes: 1 addition & 1 deletion tools/skillgen/fragments/always-on/antigravity-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<question>"` (CLI) or `query_graph` (MCP). Use `graphify path "<A>" "<B>"` / `shortest_path` for relationships and `graphify explain "<concept>"` / `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)
Loading