Overview
<area>/.ua/knowledge-graph.json:
These graphs are generated, gitignored, and go stale the moment someone commits code. A stale graph is worse than no graph: it answers questions confidently with facts that are no longer true.
The hooks in
scripts/hooks/ detect that drift automatically. They do not fix it. Detection is cheap and deterministic; the fix costs an LLM run, so it stays your decision.
An interactive, zoomable version of this flow is available at knowledge-graph-staleness-hooks.html.
Problem Statement
Graphs go stale silently
Nothing about a stale graph looks wrong. It opens fine, the dashboard renders, the node counts look plausible. You only discover the drift when it tells you a file exists that was deleted three weeks ago.The plugin’s own auto-update does not fit this repo
Understand-Anything ships anautoUpdate option with two hooks. Neither works correctly here:
A warning that fires constantly is the same as no warning. These hooks compare real per-file hashes, so an area stays silent unless its own files actually changed.
Refreshing inside a git hook is the wrong place
A full refresh dispatches LLM sub-agents per changed batch. Putting that in apost-merge hook would block git pull for a minute or more, spend tokens on every merge, and fail silently when it goes wrong. So the work is split:
How It Works
1. Detection
scripts/hooks/ua-stale-check.mjs runs after git changes the working tree:
- Finds every area containing
.ua/fingerprints.json. - For each area, runs the plugin’s own
scan-project.mjsto get the current in-scope file list. This matters — each area has a different.understandignore(104, 45, and 65 lines for go, bun, and flutter), so a hand-rolled matcher would drift from what the graph was actually built from. - Compares each file’s SHA-256 against the hash recorded in
fingerprints.json. - Classifies the result as added, modified, or deleted.
<area>/.ua/STALE.json. If the area is back in sync, it deletes the marker.
Whole-repo runtime is roughly 0.6 seconds across the three scanned areas, and grows with each area added.
2. The marker
STALE.json lives inside the gitignored .ua/ directory, so it is never committed. File lists are capped at 40 entries per category to keep the marker readable when a graph is far behind.
3. The prompt
scripts/hooks/ua-session-notice.sh runs at Claude session start, reads any markers, and reports them — explicitly instructing the agent to ask first:
/understand-anything:understand, then deletes that area’s marker.
Triggers
Safety
The check always exits 0. A broken detector must never block a git operation. It survives corruptfingerprints.json, a missing .ua/ directory, an unavailable plugin, and a failure in one area without affecting the others.
If the plugin scanner cannot be found, it falls back to degraded mode: modified and deleted files are still detected exactly, but new files are not, because without the scanner there is no way to know which of them .understandignore would have kept. The marker sets "degraded": true and the notice says (partial).
Setup
Hooks are wired throughcore.hooksPath, already set in this repo:
Watch out. If git config core.hooksPath prints a path that does not exist, no git hook in
this repo runs at all — including any lint or format gate you believe is active. This was the
state before these hooks were added.
The Claude-side notice is registered in .claude/settings.json under hooks.SessionStart, so it is shared with everyone who clones the repo.
Testing
$TMPDIR and never touch the real graphs. They cover: an in-sync graph writing no marker, a modified file being caught and named, the marker clearing once back in sync, deletion detection, and exit code 0 on both corrupt fingerprints and a missing .ua/ directory.
Manual Use
STALE.json. It will come back on the next git operation if the drift is real.
Adding a New Area
frontend/astro and frontend/solidstart are planned but not yet scanned. When they are, no change to these hooks is needed.
Areas are discovered at runtime by looking for <area>/.ua/fingerprints.json, not from a hardcoded list. The walk covers four directory levels from the repo root, so an area at frontend/<app> is found the same way backend/<runtime> is. Verified: dropping a graph into frontend/astro/ makes the hook report frontend/astro: 1 changed with no edit to the script.
To bring a new area in:
- Curate
<area>/.ua/.understandignorefirst. It is per area and the existing three differ a lot (104, 45, and 65 lines). Frontends carry large generated surfaces —.astro/,.output/,.vinxi/,dist/,node_modules/, route type artefacts, Paraglide’s generated messages — and scanning them wastes an LLM run and buries the real architecture in noise. - Keep the graph inside the area. Node IDs are relative to the area root, so a graph built from the repo root produces IDs like
file:frontend/astro/src/...instead offile:src/..., and does not match what these hooks fingerprint.
Known Gotcha: Incremental Refresh Drops Edges
When a refresh runs incrementally, pruning the changed files’ nodes also removes inbound edges pointing at them from unchanged files. The merge step only auto-recoversimports edges — configures, exports, and depends_on are silently lost.
Real example: config:package.json → file:scripts/seed-test-users.ts (configures) disappeared, because package.json itself had not changed and so nothing regenerated the edge.
After an incremental refresh, compare the pruned edge list against the merged graph and restore any edge whose source node was not itself re-analysed.
Related Files
Summary
- Graphs are per area and gitignored; they go stale silently.
frontend/astroandfrontend/solidstartare planned; areas are auto-discovered, so no hook change is needed when they are scanned.- Git hooks detect drift by hashing files, in about 0.6s, with no LLM.
- Drift is recorded in
<area>/.ua/STALE.jsonand surfaced at session start. - Refreshing always asks first, because it costs an LLM run per changed batch.
- The check can never block a git operation — it always exits 0.