Overview

Knowledge Graph Staleness Hooks
An interactive, zoomable version of this diagram is available at knowledge-graph-staleness-hooks.html.
Each area of this monorepo keeps its own Understand-Anything knowledge graph — a map of every file, function, class, and the edges between them — under <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 an autoUpdate 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 a post-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:
  1. Finds every area containing .ua/fingerprints.json.
  2. For each area, runs the plugin’s own scan-project.mjs to 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.
  3. Compares each file’s SHA-256 against the hash recorded in fingerprints.json.
  4. Classifies the result as added, modified, or deleted.
If anything drifted, it writes <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:
Answer yes and the agent runs /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 corrupt fingerprints.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 through core.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

Eight tests run against a throwaway git repo in $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

To silence an area temporarily, delete its 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:
Two things to get right before the first scan, both learned from the areas already covered:
  • Curate <area>/.ua/.understandignore first. 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 of file:src/..., and does not match what these hooks fingerprint.
Do not merge areas into one graph. The tool cannot draw cross-language edges, so a combined graph is just disconnected islands.

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-recovers imports 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.

Summary

  • Graphs are per area and gitignored; they go stale silently.
  • frontend/astro and frontend/solidstart are 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.json and 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.